| 1 | package layout |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "encoding/hex" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/instancefs" |
| 14 | "github.com/carlosframework/platform/internal/provision" |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | ) |
| 17 | |
| 18 | // Preflight is the ownership check of design §8.2 step 4, plus the socket |
| 19 | // length check of §3.1. Under the flat rules <host>.config.env is host's |
| 20 | // rendered bundle UNLESS a route named <host>.config exists, in which case |
| 21 | // it is that route's instance env (SyncExec's own collision rule). Moving |
| 22 | // it with host would take another route's file; reverting onto it would |
| 23 | // overwrite one. Either is a refusal naming both owners. <host>-sidecar.env |
| 24 | // is shared the same way with a route named <host>-sidecar. |
| 25 | func Preflight(p *Plan, routes []registry.Route) error { |
| 26 | hosts := map[string]bool{} |
| 27 | for _, rt := range routes { |
| 28 | hosts[rt.Host] = true |
| 29 | } |
| 30 | if other := p.Host + ".config"; hosts[other] { |
| 31 | return fmt.Errorf("layout: %s: %s.config.env is owned by both %s (its bundle) and %s (its instance env); refusing to move it", p.Host, p.Host, p.Host, other) |
| 32 | } |
| 33 | if strings.HasSuffix(p.Host, ".config") { |
| 34 | if owner := strings.TrimSuffix(p.Host, ".config"); hosts[owner] { |
| 35 | return fmt.Errorf("layout: %s: its env file %s.env is owned by both %s (its instance env) and %s (its bundle); refusing to move it", p.Host, p.Host, p.Host, owner) |
| 36 | } |
| 37 | } |
| 38 | // The sidecar env collides the same way: <host>-sidecar.env is host's |
| 39 | // sidecar env, and also the instance env of a route named |
| 40 | // <host>-sidecar. |
| 41 | if other := p.Host + "-sidecar"; hosts[other] { |
| 42 | return fmt.Errorf("layout: %s: %s-sidecar.env is owned by both %s (its sidecar env) and %s (its instance env); refusing to move it", p.Host, p.Host, p.Host, other) |
| 43 | } |
| 44 | if strings.HasSuffix(p.Host, "-sidecar") { |
| 45 | if owner := strings.TrimSuffix(p.Host, "-sidecar"); hosts[owner] { |
| 46 | return fmt.Errorf("layout: %s: its env file %s.env is owned by both %s (its instance env) and %s (its sidecar env); refusing to move it", p.Host, p.Host, p.Host, owner) |
| 47 | } |
| 48 | } |
| 49 | // The activator binds a control socket beside every exec listener at |
| 50 | // the listener's path plus ".control". A nested address that leaves no |
| 51 | // room for it would make the host unwakeable after the move, so it is |
| 52 | // refused before anything moves. |
| 53 | if p.Mode == ModeNested { |
| 54 | if n := len(p.To.Socket) + len(".control"); n > provision.MaxUnixPath { |
| 55 | return fmt.Errorf("layout: %s: control socket %s.control is %d bytes, over the %d a unix socket address holds; refusing to move it", p.Host, p.To.Socket, n, provision.MaxUnixPath) |
| 56 | } |
| 57 | } |
| 58 | return nil |
| 59 | } |
| 60 | |
| 61 | // DestinationCheck is step 5, run before the journal so that at the moment |
| 62 | // the journal is written nothing the pass will move has anything at its |
| 63 | // destination, and, on a migration, so that the instance directory holds |
| 64 | // nothing the pass did not put there. A database, or any file of one, |
| 65 | // already at the destination is a refusal (two databases; refusing to |
| 66 | // choose), and so is a litestream shadow (two replication states for one |
| 67 | // database). An env file already there, which an older binary running |
| 68 | // between a migration and a revert can have minted fresh, is moved aside |
| 69 | // to the pass's own aside directory so the source, the file the instance |
| 70 | // ran with for longer, takes its place; a quarantined restore is kept the |
| 71 | // same way. The rendered bundle is simply cleared; sync re-renders it |
| 72 | // anyway. |
| 73 | // |
| 74 | // The aside copy lives OUTSIDE the instance tree, in <data>/layout/aside/<host>, |
| 75 | // where no instance's namespace reaches (design §4.1 binds the instance |
| 76 | // its own directory and nothing else of /data). Inside the tree it would |
| 77 | // be the tenant's to read, and a tenant that reclaims a hostname, or that |
| 78 | // planted the file itself before its own migration, must not read what a |
| 79 | // previous owner or a sibling left at that name. |
| 80 | // |
| 81 | // Every change goes through a directory fd that follows no symlink, so a |
| 82 | // planted link at a destination directory cannot redirect it. |
| 83 | func DestinationCheck(p *Plan, now func() time.Time) (aside []string, err error) { |
| 84 | if p.Mode == ModeNested { |
| 85 | moved, err := quarantineForeignEntries(p) |
| 86 | aside = append(aside, moved...) |
| 87 | if err != nil { |
| 88 | return aside, err |
| 89 | } |
| 90 | } |
| 91 | for _, a := range p.Artifacts { |
| 92 | if a.Name == "socket" { |
| 93 | continue |
| 94 | } |
| 95 | present, err := lstatPresent(a.Dst) |
| 96 | if err != nil { |
| 97 | return aside, err |
| 98 | } |
| 99 | if !present { |
| 100 | continue |
| 101 | } |
| 102 | dir, name := filepath.Dir(a.Dst), filepath.Base(a.Dst) |
| 103 | switch a.Name { |
| 104 | case "db", "db-wal", "db-shm": |
| 105 | return aside, fmt.Errorf("layout: %s: two databases (%s exists at the destination); refusing to choose", p.Host, a.Dst) |
| 106 | case "litestream-shadow": |
| 107 | return aside, fmt.Errorf("layout: %s: two replication states (%s exists at the destination); refusing to choose", p.Host, a.Dst) |
| 108 | case "env", "sidecar-env", "corrupt": |
| 109 | asideDir, err := ensureAsideDir(p.DataDir, p.Host) |
| 110 | if err != nil { |
| 111 | return aside, fmt.Errorf("layout: %s: %w", p.Host, err) |
| 112 | } |
| 113 | to := fmt.Sprintf("%s.aside-%d", name, now().Unix()) |
| 114 | taken, err := lstatPresent(filepath.Join(asideDir, to)) |
| 115 | if err != nil { |
| 116 | return aside, err |
| 117 | } |
| 118 | if taken { |
| 119 | return aside, fmt.Errorf("layout: %s: %s and %s both exist; refusing to replace a kept copy", p.Host, a.Dst, filepath.Join(asideDir, to)) |
| 120 | } |
| 121 | if err := instancefs.RenameBetween(dir, name, asideDir, to); err != nil { |
| 122 | return aside, fmt.Errorf("layout: %s: move %s aside: %w", p.Host, a.Dst, err) |
| 123 | } |
| 124 | aside = append(aside, filepath.Join(asideDir, to)) |
| 125 | case "config-env": |
| 126 | if err := instancefs.RemoveBeneath(dir, name); err != nil { |
| 127 | return aside, fmt.Errorf("layout: %s: clear %s: %w", p.Host, a.Dst, err) |
| 128 | } |
| 129 | default: |
| 130 | return aside, fmt.Errorf("layout: %s: no destination rule for artifact %q", p.Host, a.Name) |
| 131 | } |
| 132 | } |
| 133 | return aside, nil |
| 134 | } |
| 135 | |
| 136 | // asideDir is where the pass keeps what it moved out of a host's way: |
| 137 | // under the pass's own layout directory, per host, which no instance |
| 138 | // namespace contains. It is created on first use, carlos 0700 like the |
| 139 | // journal directory above it, through fds that follow no symlink. |
| 140 | func asideDir(dataDir, host string) string { |
| 141 | return filepath.Join(journalDir(dataDir), "aside", host) |
| 142 | } |
| 143 | |
| 144 | func ensureAsideDir(dataDir, host string) (string, error) { |
| 145 | if _, err := instancefs.EnsureDirBeneath(dataDir, filepath.Base(journalDir(dataDir)), 0o700); err != nil { |
| 146 | return "", err |
| 147 | } |
| 148 | if err := ensureDirDisplacing(dataDir, journalDir(dataDir), "aside"); err != nil { |
| 149 | return "", err |
| 150 | } |
| 151 | if err := ensureDirDisplacing(dataDir, filepath.Join(journalDir(dataDir), "aside"), host); err != nil { |
| 152 | return "", err |
| 153 | } |
| 154 | return asideDir(dataDir, host), nil |
| 155 | } |
| 156 | |
| 157 | // ensureDirDisplacing makes name a real directory under parent. The layout |
| 158 | // directory and everything under it are carlos 0700, which every flat |
| 159 | // tenant can write, so a tenant can plant a symlink or a file at the aside |
| 160 | // directory, or at a sibling's directory under it, ahead of the pass; left |
| 161 | // as a refusal that would be a hold on the sibling (or on every host) with |
| 162 | // one command. A blocker that is not a real directory is renamed out of |
| 163 | // the way to a random, no-replace name under the layout directory itself, |
| 164 | // where the operator can see it, and the directory is made once more. |
| 165 | // Only a second failure is returned. |
| 166 | func ensureDirDisplacing(dataDir, parent, name string) error { |
| 167 | _, err := instancefs.EnsureDirBeneath(parent, name, 0o700) |
| 168 | if err == nil { |
| 169 | return nil |
| 170 | } |
| 171 | to := fmt.Sprintf(".%s.blocker-%d-%s", name, time.Now().Unix(), randomSuffix()) |
| 172 | if rerr := instancefs.RenameBetweenNoReplace(parent, name, journalDir(dataDir), to); rerr != nil { |
| 173 | return fmt.Errorf("%w (and moving the blocker aside: %v)", err, rerr) |
| 174 | } |
| 175 | if _, err := instancefs.EnsureDirBeneath(parent, name, 0o700); err != nil { |
| 176 | return err |
| 177 | } |
| 178 | return nil |
| 179 | } |
| 180 | |
| 181 | // quarantineForeignEntries moves everything out of a migration's instance |
| 182 | // data directory that the plan does not name, into the host's aside |
| 183 | // directory, and reports where each went. Until every flat tenant on the |
| 184 | // box has migrated, the raw directory is writable by every one of them |
| 185 | // (same uid, §1.3), and a tenant can make a sibling's future directory |
| 186 | // ahead of the pass and hard-link the sibling's database or the box's |
| 187 | // bucket credentials into it, which protected_hardlinks allows because |
| 188 | // the files are its own uid's; the sibling's namespace would then bind |
| 189 | // that directory read-write. So the directory the pass is about to fill |
| 190 | // must hold nothing but what the pass itself moves in: an entry at a |
| 191 | // planned name is handled by the destination rules (refused, cleared, or |
| 192 | // kept aside), and any other entry is renamed out here. |
| 193 | // |
| 194 | // Renamed, not refused: a refusal would let one tenant hold a sibling |
| 195 | // down with one touch(1) until an operator acted. rename(2) moves the |
| 196 | // directory entry itself, whatever it names, a hard link or a symlink |
| 197 | // included, so the quarantined entry is as harmless in the aside |
| 198 | // directory as it was dangerous in the tenant's, and the original file |
| 199 | // (if the entry was a second name for one) keeps its own name wherever |
| 200 | // that is. The destination name carries a random suffix and is written |
| 201 | // with RENAME_NOREPLACE, so nothing pre-planted in the aside directory |
| 202 | // can make the quarantine collide. A tenant's own leftovers from an |
| 203 | // earlier nested life (a revert leaves what the app wrote beside its |
| 204 | // database) are quarantined the same way; the aside copy is kept for |
| 205 | // the operator. The run directory is not checked: it is on tmpfs, where |
| 206 | // a hard link to anything under /data is impossible, and its mount is |
| 207 | // nosymfollow. |
| 208 | func quarantineForeignEntries(p *Plan) ([]string, error) { |
| 209 | dir := rawInstanceDir(p.DataDir, p.Host) |
| 210 | entries, err := os.ReadDir(dir) |
| 211 | if errors.Is(err, os.ErrNotExist) { |
| 212 | return nil, nil |
| 213 | } |
| 214 | if err != nil { |
| 215 | return nil, fmt.Errorf("layout: %s: list %s: %w", p.Host, dir, err) |
| 216 | } |
| 217 | planned := map[string]bool{} |
| 218 | for _, a := range p.Artifacts { |
| 219 | if a.Name != "socket" { |
| 220 | planned[filepath.Base(a.Dst)] = true |
| 221 | } |
| 222 | } |
| 223 | var moved []string |
| 224 | for _, e := range entries { |
| 225 | if planned[e.Name()] { |
| 226 | continue |
| 227 | } |
| 228 | aside, err := ensureAsideDir(p.DataDir, p.Host) |
| 229 | if err != nil { |
| 230 | return moved, fmt.Errorf("layout: %s: %w", p.Host, err) |
| 231 | } |
| 232 | var last error |
| 233 | for attempt := 0; attempt < 3; attempt++ { |
| 234 | to := fmt.Sprintf("%s.q-%d-%s", e.Name(), time.Now().Unix(), randomSuffix()) |
| 235 | last = instancefs.RenameBetweenNoReplace(dir, e.Name(), aside, to) |
| 236 | if last == nil { |
| 237 | moved = append(moved, filepath.Join(aside, to)) |
| 238 | break |
| 239 | } |
| 240 | if !errors.Is(last, os.ErrExist) { |
| 241 | break |
| 242 | } |
| 243 | } |
| 244 | if last != nil { |
| 245 | return moved, fmt.Errorf("layout: %s: quarantine %s out of %s: %w", p.Host, e.Name(), dir, last) |
| 246 | } |
| 247 | } |
| 248 | return moved, nil |
| 249 | } |
| 250 | |
| 251 | func randomSuffix() string { |
| 252 | var b [4]byte |
| 253 | if _, err := rand.Read(b[:]); err != nil { |
| 254 | return fmt.Sprintf("%d", time.Now().UnixNano()) |
| 255 | } |
| 256 | return hex.EncodeToString(b[:]) |
| 257 | } |
| 258 | |
| 259 | // LinkCheck refuses a plan whose files have more than one name. It runs |
| 260 | // on the sources right before the journal is written, and again on the |
| 261 | // destinations after the renames and before the row flips (VerifyMoved): |
| 262 | // a regular file with a second hard link somewhere is a second reader and |
| 263 | // writer of it, and a tenant that linked a sibling's database into its own |
| 264 | // shadow directory before migrating would otherwise carry that link into |
| 265 | // its namespace. The shadow directory is walked, and a symlink or anything |
| 266 | // that is neither a regular file nor a directory inside it is refused too: |
| 267 | // litestream writes only files and directories there. |
| 268 | // |
| 269 | // This is a check against what was planted before the pass arrived. A |
| 270 | // flat tenant that is still running while the pass moves another host can |
| 271 | // still plant between the check and the rename, which is why a box is not |
| 272 | // isolated until no flat tenant remains on it (Converge's awake bound, and |
| 273 | // the mixed-layout warning in carlos status --layout). |
| 274 | func LinkCheck(p *Plan, at func(Artifact) string) error { |
| 275 | for _, a := range p.Artifacts { |
| 276 | if a.Name == "socket" || !a.Present { |
| 277 | continue |
| 278 | } |
| 279 | path := at(a) |
| 280 | fi, err := os.Lstat(path) |
| 281 | if errors.Is(err, os.ErrNotExist) { |
| 282 | continue |
| 283 | } |
| 284 | if err != nil { |
| 285 | return fmt.Errorf("layout: %s: %w", p.Host, err) |
| 286 | } |
| 287 | if a.Dir { |
| 288 | if err := walkLinks(p.Host, p.DataDir, path); err != nil { |
| 289 | return err |
| 290 | } |
| 291 | continue |
| 292 | } |
| 293 | if a.Extra && fi.IsDir() { |
| 294 | // Said before the walk, which stats every file: a blob store |
| 295 | // can be large, the host is stopped meanwhile, and the walk |
| 296 | // runs twice (sources, then destinations). |
| 297 | p.logf("layout: %s: checking every file under %s for hard links and symlinks before the move goes on", p.Host, path) |
| 298 | if err := walkExtraLinks(p.Host, p.DataDir, path); err != nil { |
| 299 | return err |
| 300 | } |
| 301 | continue |
| 302 | } |
| 303 | if err := oneName(p.Host, p.DataDir, path, fi); err != nil { |
| 304 | return err |
| 305 | } |
| 306 | } |
| 307 | return nil |
| 308 | } |
| 309 | |
| 310 | // walkExtraLinks is walkLinks for a directory an app made for itself |
| 311 | // (extras.go), with one allowance the shadow directory does not need: a |
| 312 | // file with several names may be moved when EVERY one of those names is |
| 313 | // inside the directory, since the move carries them all and nothing |
| 314 | // outside can reach the file afterwards. Apps deduplicate content by |
| 315 | // hard-linking inside their own blob store, and refusing that would hold |
| 316 | // a host down for its own ordinary files. A name outside the directory |
| 317 | // is still a second reader and writer the move does not know about, and |
| 318 | // is refused with the inode and the find that lists it. Symlinks and |
| 319 | // special files are refused as in walkLinks. |
| 320 | func walkExtraLinks(host, dataDir, root string) error { |
| 321 | type linked struct { |
| 322 | path string |
| 323 | nlink uint64 |
| 324 | seen uint64 |
| 325 | } |
| 326 | byInode := map[uint64]*linked{} |
| 327 | err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { |
| 328 | if err != nil { |
| 329 | return fmt.Errorf("layout: %s: %w", host, err) |
| 330 | } |
| 331 | if d.IsDir() { |
| 332 | return nil |
| 333 | } |
| 334 | fi, err := d.Info() |
| 335 | if err != nil { |
| 336 | return fmt.Errorf("layout: %s: %w", host, err) |
| 337 | } |
| 338 | if !fi.Mode().IsRegular() { |
| 339 | return fmt.Errorf("layout: %s: %s is not a regular file (%v); refusing to move it", host, path, fi.Mode().Type()) |
| 340 | } |
| 341 | if n, ino := linkCount(fi); n > 1 { |
| 342 | l := byInode[ino] |
| 343 | if l == nil { |
| 344 | l = &linked{path: path, nlink: n} |
| 345 | byInode[ino] = l |
| 346 | } |
| 347 | l.seen++ |
| 348 | } |
| 349 | return nil |
| 350 | }) |
| 351 | if err != nil { |
| 352 | return err |
| 353 | } |
| 354 | for ino, l := range byInode { |
| 355 | if l.seen < l.nlink { |
| 356 | return fmt.Errorf("layout: %s: %s has %d names (hard links, inode %d) and only %d of them are inside %s; refusing to move a file something else can still reach. find the others with: find %s -xdev -samefile %s", host, l.path, l.nlink, ino, l.seen, root, dataDir, l.path) |
| 357 | } |
| 358 | } |
| 359 | return nil |
| 360 | } |
| 361 | |
| 362 | func walkLinks(host, dataDir, root string) error { |
| 363 | return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { |
| 364 | if err != nil { |
| 365 | return fmt.Errorf("layout: %s: %w", host, err) |
| 366 | } |
| 367 | if d.IsDir() { |
| 368 | return nil |
| 369 | } |
| 370 | fi, err := d.Info() |
| 371 | if err != nil { |
| 372 | return fmt.Errorf("layout: %s: %w", host, err) |
| 373 | } |
| 374 | return oneName(host, dataDir, path, fi) |
| 375 | }) |
| 376 | } |
| 377 | |
| 378 | // oneName refuses a file that is not a regular file with exactly one |
| 379 | // name. The refusal tells the operator how to find the other names: the |
| 380 | // inode, and the find invocation that lists every path to it on the data |
| 381 | // volume (-xdev, because a hard link cannot cross a filesystem). |
| 382 | func oneName(host, dataDir, path string, fi os.FileInfo) error { |
| 383 | if !fi.Mode().IsRegular() { |
| 384 | return fmt.Errorf("layout: %s: %s is not a regular file (%v); refusing to move it", host, path, fi.Mode().Type()) |
| 385 | } |
| 386 | if n, ino := linkCount(fi); n > 1 { |
| 387 | return fmt.Errorf("layout: %s: %s has %d names (hard links, inode %d); refusing to move a file something else can still reach. find the others with: find %s -xdev -samefile %s", host, path, n, ino, dataDir, path) |
| 388 | } |
| 389 | return nil |
| 390 | } |
| 391 | |
| 392 | // VerifyMoved is the check after the renames and before the row flips: |
| 393 | // every moved file still has one name, and on a migration the instance |
| 394 | // directory holds nothing but the plan's files, anything else planted |
| 395 | // between the destination check and the renames being quarantined now. |
| 396 | // It reports what it quarantined. A link failure keeps the journal at its |
| 397 | // files phase, so the host stays fenced and the next recovery meets the |
| 398 | // same check with the same answer until an operator has looked. |
| 399 | func VerifyMoved(p *Plan) ([]string, error) { |
| 400 | if err := LinkCheck(p, func(a Artifact) string { return a.Dst }); err != nil { |
| 401 | return nil, err |
| 402 | } |
| 403 | if p.Mode == ModeNested { |
| 404 | return quarantineForeignEntries(p) |
| 405 | } |
| 406 | return nil, nil |
| 407 | } |
| 408 | |
| 409 | // EnsureDirs makes the raw directories the bind mounts show: the data |
| 410 | // directory 0700 (the tenant's own ground) and the run directory 2750 in |
| 411 | // the carlos-sockets group, so an enrolled consumer can dial app.sock |
| 412 | // (design §3.1). The directories must exist before the row flips, because |
| 413 | // Restore and prepare both assume them. |
| 414 | // |
| 415 | // The pass runs as carlos, which is not a member of carlos-sockets. It |
| 416 | // therefore cannot chown anything into that group, and any chmod it makes |
| 417 | // on a directory of that group has the kernel clear setgid, which on the |
| 418 | // run parent would hand every later instance directory the wrong group. |
| 419 | // So nothing here changes a mode or an owner. The run directory gets its |
| 420 | // group and setgid by inheritance from <run>/.instances, which tmpfiles |
| 421 | // owns, exactly as prepare relies on; both parents are checked and never |
| 422 | // repaired, and a new or existing instance directory that is not what |
| 423 | // inheritance should have made is refused as it stands. |
| 424 | // |
| 425 | // Each directory is made and checked through an fd on its parent that |
| 426 | // followed no symlink: a same-uid tenant can plant a symlink at |
| 427 | // <data>/.instances/<iid> before the pass arrives, and a move into it |
| 428 | // would land the database wherever the link points. |
| 429 | func EnsureDirs(dataDir, socketDir, host string, socketsGID int) error { |
| 430 | data := rawInstanceDir(dataDir, host) |
| 431 | run := rawRunDir(socketDir, host) |
| 432 | // The run parent is checked before anything is created, so a refusal |
| 433 | // leaves nothing behind. |
| 434 | runParent, err := checkRunParent(filepath.Dir(run), socketsGID) |
| 435 | if err != nil { |
| 436 | return fmt.Errorf("layout: %s: %w", host, err) |
| 437 | } |
| 438 | // The data parent is tmpfiles' too, but carlos:carlos 0700 is exactly |
| 439 | // what mkdir as carlos makes, so a missing one is created, never |
| 440 | // changed. |
| 441 | if _, err := instancefs.EnsureDirBeneath(dataDir, filepath.Base(filepath.Dir(data)), 0o700); err != nil { |
| 442 | return fmt.Errorf("layout: %s: %w", host, err) |
| 443 | } |
| 444 | st, err := instancefs.EnsureDirBeneath(filepath.Dir(data), filepath.Base(data), 0o700) |
| 445 | if err != nil { |
| 446 | return fmt.Errorf("layout: %s: %w", host, err) |
| 447 | } |
| 448 | if err := checkInstanceDir(data, st, 0o700, false, -1); err != nil { |
| 449 | return fmt.Errorf("layout: %s: %w", host, err) |
| 450 | } |
| 451 | st, err = instancefs.EnsureDirBeneath(filepath.Dir(run), filepath.Base(run), 0o750) |
| 452 | if err != nil { |
| 453 | return fmt.Errorf("layout: %s: %w", host, err) |
| 454 | } |
| 455 | if err := checkInstanceDir(run, st, 0o750, runParent.Mode&os.ModeSetgid != 0, socketsGID); err != nil { |
| 456 | return fmt.Errorf("layout: %s: %w", host, err) |
| 457 | } |
| 458 | return nil |
| 459 | } |
| 460 | |
| 461 | // checkRunParent reads <run>/.instances. With the sockets group known it |
| 462 | // must be there already, in that group and setgid, because only tmpfiles, |
| 463 | // running as root, can make it so. Without the group (a box that has none, |
| 464 | // or a test) a missing parent is simply created. |
| 465 | func checkRunParent(parent string, socketsGID int) (instancefs.DirStat, error) { |
| 466 | if socketsGID < 0 { |
| 467 | return instancefs.EnsureDirBeneath(filepath.Dir(parent), filepath.Base(parent), 0o750) |
| 468 | } |
| 469 | st, err := instancefs.StatDir(parent) |
| 470 | if errors.Is(err, os.ErrNotExist) { |
| 471 | return st, fmt.Errorf("%s is missing; tmpfiles should create it, and the pass does not", parent) |
| 472 | } |
| 473 | if err != nil { |
| 474 | return st, err |
| 475 | } |
| 476 | if st.GID != socketsGID { |
| 477 | return st, fmt.Errorf("%s has group %d, not carlos-sockets (%d); tmpfiles should restore it, and the pass does not repair it", parent, st.GID, socketsGID) |
| 478 | } |
| 479 | if st.Mode&os.ModeSetgid == 0 { |
| 480 | return st, fmt.Errorf("%s has lost its setgid bit (mode %v); tmpfiles should restore it, and the pass does not repair it", parent, st.Mode) |
| 481 | } |
| 482 | return st, nil |
| 483 | } |
| 484 | |
| 485 | // checkInstanceDir refuses an instance directory the pass cannot vouch |
| 486 | // for: not its own, the wrong permission bits, no setgid under a setgid |
| 487 | // parent, or not the sockets group. Each would need a chmod or chown to |
| 488 | // fix, which is exactly what the pass must not do. |
| 489 | func checkInstanceDir(path string, st instancefs.DirStat, perm os.FileMode, wantSetgid bool, gid int) error { |
| 490 | if st.UID != os.Geteuid() { |
| 491 | return fmt.Errorf("%s is owned by uid %d, not the agent's %d; refusing to use it", path, st.UID, os.Geteuid()) |
| 492 | } |
| 493 | if st.Mode.Perm() != perm { |
| 494 | return fmt.Errorf("%s has mode %v, not %v; refusing to use it", path, st.Mode.Perm(), perm) |
| 495 | } |
| 496 | if wantSetgid && st.Mode&os.ModeSetgid == 0 { |
| 497 | return fmt.Errorf("%s has no setgid bit though its parent has; refusing to use it", path) |
| 498 | } |
| 499 | if gid >= 0 && st.GID != gid { |
| 500 | return fmt.Errorf("%s has group %d, not carlos-sockets (%d); refusing to use it", path, st.GID, gid) |
| 501 | } |
| 502 | return nil |
| 503 | } |
| 504 | |
| 505 | func lstatPresent(path string) (bool, error) { |
| 506 | _, err := os.Lstat(path) |
| 507 | if errors.Is(err, os.ErrNotExist) { |
| 508 | return false, nil |
| 509 | } |
| 510 | return err == nil, err |
| 511 | } |
| 512 | |