package apply // Where the node-engine places nothing and mounts nothing, whoever asks (novox/hq issue 339). // // A directory names its path, and the controller resolves part of that path from what was set for the // module: its `places` setting moves a directory anywhere, with an owner it names, and its `accesses` setting // says which of the machine's paths are mounted into its container. The engine runs as root, so a path it // accepts blindly is a path anyone who could change those settings hands to any account: a directory at /etc // owned by a caller's account gives it /etc, and an access at / mounts the machine's root into a container. // The controller refuses both where a setting is made; the engine refuses them again where it applies, // because a guard in one place is a guard one change away from gone. Whatever the declaration says: // // 1. **No directory, access or mount source is one of the machine's own roots, or holds one**: /, /etc, // /usr, /var, /var/lib, /home, /run and the rest of protectedRoots. Modules place directories BELOW /etc // or /var/lib, never the root itself; owning one is owning everything in it. // 2. **Nothing is placed in /proc, /sys, /dev, /boot, /root, /var/spool, /opt or the container runtimes' data // (/var/lib/docker, /var/lib/containers, /var/lib/containerd, and where the runtimes' configuration moves // them: runtimeDataRoots), nor in the node-engine's // own trees** (its state, its identity, its installed builds) but by its own module; nothing is mounted from // those but /proc, /sys and /dev. A mount of a kernel file, a device or the clock is the plumbing // systemPath names. // 2a. **An account's .ssh is never an access or a mount**, and is a directory only below its account's home, // as that account's (rule 4). // 3. **A directory below /etc, /usr or /run is root's.** The machine's configuration and programs are read // as root's word; a directory there owned by another account is that account writing root's word. An // access is never there at all: the operator's data is not the machine's configuration. // 4. **Below a person's or an agent's home, a directory is that account's.** Root's or another account's // directory there is one the account does not control in a tree whose every parent it does. A home // itself may be a module's directory (a backup repository kept as an account's home is one), and as // every directory the mesh did not make, it is used as found: never chowned or chmodded (applyDirectory). // 5. **A mount source that is a refused directory or a refused access is refused with it**: the container // would otherwise bind the very path the engine would not place, and the runtime creates a missing one // as root. // // Each is a failed resource with its reason in the node's report; nothing is touched. Paths are judged as // declared and again with every link in them resolved, so a link at /srv/x pointing at /etc places nothing. import ( "bufio" "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "github.com/novox/mesh-host/internal/declaration" ) // protectedRoots are paths no directory, access or mount source may be, nor hold. var protectedRoots = []string{"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64", "/media", "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/usr/bin", "/usr/lib", "/usr/lib64", "/usr/local", "/usr/local/bin", "/usr/local/lib", "/usr/local/sbin", "/usr/sbin", "/usr/share", "/var", "/var/cache", "/var/lib", "/var/lib/mesh", "/var/log", "/var/run", "/var/tmp", "/var/spool"} // forbiddenBelow are trees nothing is placed in or mounted from: the kernel's, the boot loader's and root's // home. engineTrees are the node-engine's own, which only its own module places in. var ( forbiddenBelow = []string{"/proc", "/sys", "/dev", "/boot", "/root", "/var/spool", "/opt", "/var/lib/docker", "/var/lib/containers", "/var/lib/containerd"} // forbiddenBelowMount are the trees no container mounts from: a mount of the kernel's files and devices is // the plumbing a container may need (systemPath); root's home and the boot loader's are no plumbing. forbiddenBelowMount = []string{"/boot", "/root", "/var/spool", "/opt", "/var/lib/docker", "/var/lib/containers", "/var/lib/containerd"} engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"} // rootsOnly are trees a directory below is root's, and an access is never in. rootsOnly = []string{"/etc", "/usr", "/run", "/var/run"} ) // runtimeFiles are where the container runtimes say where they keep their data: dockerd's daemon.json, its // unit and the unit's drop-ins (an ExecStart with --data-root, or the older -g/--graph, continued over lines, // quoted, through Environment= or EnvironmentFile=, or a --config-file naming another daemon.json), and podman's // storage.conf (graphroot, a basic or a literal string). The running runtimes are asked first. containerd keeps its own under /var/lib/containerd, which forbiddenBelow names; a // containerd configured elsewhere, and podman's rootless stores under each account's home, are not read: the // first is no runtime this mesh runs, and the second is below a home, which rule 4 already keeps for its // account. A variable so a test names its own. type runtimeFiles struct { daemonJSON string units []string dropInDirs []string storageConf string } var runtimeConfigs = runtimeFiles{ daemonJSON: "/etc/docker/daemon.json", units: []string{"/etc/systemd/system/docker.service", "/usr/lib/systemd/system/docker.service", "/lib/systemd/system/docker.service"}, dropInDirs: []string{"/etc/systemd/system/docker.service.d", "/run/systemd/system/docker.service.d", "/usr/lib/systemd/system/docker.service.d"}, storageConf: "/etc/containers/storage.conf", } var ( dataRootFlag = regexp.MustCompile(`(?:--data-root|--graph|-g)(?:=|\s+)(\S+)`) configFlag = regexp.MustCompile(`--config-file(?:=|\s+)(\S+)`) graphRoot = regexp.MustCompile(`(?m)^\s*graphroot\s*=\s*(?:"([^"]+)"|'([^']+)')`) envVar = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)`) ) // runtimeRoots is where the container runtimes keep their data, as the last apply read it (readRuntimeRoots); // nil until an apply has read it, when the guard reads the files itself. var ( runtimeRootsMu sync.Mutex runtimeRoots []string ) // AskRuntimes is how the engine asks the running container runtimes where they keep their data: set by the engine // to run the commands, nil in a test, which then reads the files alone. Its own, never the apply's runner, whose // commands a test reads back as what the apply did. var AskRuntimes Runner // refreshRuntimeRoots reads where the runtimes keep their data once, at the start of an apply. func refreshRuntimeRoots(ctx context.Context) { roots := readRuntimeRoots(ctx, AskRuntimes) runtimeRootsMu.Lock() runtimeRoots = roots runtimeRootsMu.Unlock() } // runtimeDataRoots is every place the container runtimes keep their data, beyond the default trees forbiddenBelow // names: every container's filesystem is there. func runtimeDataRoots() []string { runtimeRootsMu.Lock() roots := runtimeRoots runtimeRootsMu.Unlock() if roots != nil { return roots } return readRuntimeRoots(context.Background(), nil) } // readRuntimeRoots asks the running runtimes where they keep their data, when run is given (`docker info`, // `podman info`), and reads their configuration besides: an answer from a runtime that is running is what it // really does, and the files say what it will do when it starts again. Both count. A runtime that is not running // or not installed answers nothing, which is no error. func readRuntimeRoots(ctx context.Context, run Runner) []string { seen := map[string]bool{} var out []string add := func(p string) { p = strings.Trim(strings.TrimSpace(p), `"'`) if !filepath.IsAbs(p) { return } p = filepath.Clean(p) if !seen[p] { seen[p] = true out = append(out, p) } } if run != nil { // Each runtime has its own ten seconds: one that hangs costs the other nothing. for _, q := range [][]string{{"docker", "info", "--format", "{{.DockerRootDir}}"}, {"podman", "info", "--format", "{{.Store.GraphRoot}}"}} { ask, cancel := context.WithTimeout(ctx, 10*time.Second) if root, err := run(ask, q[0], q[1:]...); err == nil { add(root) } cancel() } } daemonJSON := func(path string) { raw, err := os.ReadFile(path) if err != nil { return } var c struct { DataRoot string `json:"data-root"` Graph string `json:"graph"` } if json.Unmarshal(raw, &c) == nil { add(c.DataRoot) add(c.Graph) } } daemonJSON(runtimeConfigs.daemonJSON) units := append([]string(nil), runtimeConfigs.units...) for _, dir := range runtimeConfigs.dropInDirs { matches, _ := filepath.Glob(filepath.Join(dir, "*.conf")) units = append(units, matches...) } // The unit and its drop-ins are one unit to systemd: a variable set in one is seen by an ExecStart in another. env := map[string]string{} var execs []string for _, u := range units { raw, err := os.ReadFile(u) if err != nil { continue } e, x := unitLines(string(raw)) for k, v := range e { env[k] = v } execs = append(execs, x...) } for _, line := range execs { line = envVar.ReplaceAllStringFunc(line, func(ref string) string { m := envVar.FindStringSubmatch(ref) if v, ok := env[m[1]+m[2]]; ok { return v } return ref }) for _, m := range dataRootFlag.FindAllStringSubmatch(line, -1) { add(m[1]) } for _, m := range configFlag.FindAllStringSubmatch(line, -1) { daemonJSON(strings.Trim(m[1], `"'`)) } } if raw, err := os.ReadFile(runtimeConfigs.storageConf); err == nil { for _, m := range graphRoot.FindAllStringSubmatch(string(raw), -1) { add(m[1] + m[2]) } } return out } // unitLines reads a unit file as systemd does for what matters here: a line ending in a backslash continues on the // next, Environment= sets variables (quoted or not, several to a line), EnvironmentFile= (a leading - says it may // be missing) reads KEY=value lines, and every ExecStart line is returned whole. func unitLines(text string) (map[string]string, []string) { var joined []string var cur strings.Builder for _, line := range strings.Split(text, "\n") { trimmed := strings.TrimRight(line, " \t") if strings.HasSuffix(trimmed, "\\") { cur.WriteString(strings.TrimSuffix(trimmed, "\\") + " ") continue } cur.WriteString(line) joined = append(joined, cur.String()) cur.Reset() } if cur.Len() > 0 { joined = append(joined, cur.String()) } env := map[string]string{} setPairs := func(s string) { for _, f := range splitQuoted(s) { if k, v, ok := strings.Cut(f, "="); ok { env[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"'`) } } } var execs []string for _, line := range joined { l := strings.TrimSpace(line) switch { case strings.HasPrefix(l, "Environment="): setPairs(strings.TrimPrefix(l, "Environment=")) case strings.HasPrefix(l, "EnvironmentFile="): path := strings.TrimPrefix(strings.TrimSpace(strings.TrimPrefix(l, "EnvironmentFile=")), "-") if raw, err := os.ReadFile(path); err == nil { for _, kv := range strings.Split(string(raw), "\n") { kv = strings.TrimSpace(kv) if kv == "" || strings.HasPrefix(kv, "#") { continue } setPairs(kv) } } case strings.HasPrefix(l, "ExecStart"): execs = append(execs, l) } } return env, execs } // splitQuoted splits on blanks outside double or single quotes, keeping the quotes' contents whole. func splitQuoted(s string) []string { var out []string var cur strings.Builder var quote rune for _, r := range s { switch { case quote != 0 && r == quote: quote = 0 case quote == 0 && (r == '"' || r == '\''): quote = r case quote == 0 && (r == ' ' || r == '\t'): if cur.Len() > 0 { out = append(out, cur.String()) cur.Reset() } default: cur.WriteRune(r) } } if cur.Len() > 0 { out = append(out, cur.String()) } return out } // engineModule is the module whose resources may place in the engine's own trees. const engineModule = "mesh-host" // passwdFile is the user database homes are read from. A variable so a test names its own. var passwdFile = "/etc/passwd" // PlacementRefusedError is a resource the engine will not place, or mount, where it says. type PlacementRefusedError struct { Path, Why string } func (e *PlacementRefusedError) Error() string { return fmt.Sprintf("%s is not placed: %s (novox/hq issue 339); nothing was touched", e.Path, e.Why) } // homeAccount is a person's or an agent's account and its home. type homeAccount struct { Name string UID int } // accountsOfHomes is each person's or agent's home and the account it belongs to, from the user database: an // account with a uid of 1000 or more, or a home under /home. A variable so a test names its own. var accountsOfHomes = func() map[string]homeAccount { f, err := os.Open(passwdFile) if err != nil { return nil } defer f.Close() out := map[string]homeAccount{} sc := bufio.NewScanner(f) for sc.Scan() { fields := strings.Split(sc.Text(), ":") if len(fields) < 6 { continue } uid, err := strconv.Atoi(fields[2]) if err != nil { continue } home := filepath.Clean(fields[5]) if home == "/" || home == "." || home == "" || home == "/nonexistent" { continue } if (uid >= 1000 && uid != 65534) || strings.HasPrefix(home, "/home/") { out[home] = homeAccount{Name: fields[0], UID: uid} } } return out } // below says whether path is strictly below dir. func below(path, dir string) bool { if dir == "/" { return path != "/" } return strings.HasPrefix(path, dir+"/") } // atOrBelow says whether path is dir or below it. func atOrBelow(path, dir string) bool { return path == dir || below(path, dir) } // resolved is a path with every link in it followed, as far as the path exists, and the rest as declared. func resolved(path string) string { rest := "" for p := path; ; p = filepath.Dir(p) { if real, err := filepath.EvalSymlinks(p); err == nil { return filepath.Clean(filepath.Join(real, rest)) } if filepath.Dir(p) == p { return path } rest = filepath.Join(filepath.Base(p), rest) } } // ownedByAccount says whether a declared owner is that account: by name, or by its uid ("1001", "1001:1001"). func ownedByAccount(owner string, a homeAccount) bool { if owner == a.Name { return true } user, _, _ := strings.Cut(owner, ":") if uid, err := strconv.Atoi(user); err == nil { return uid == a.UID } return false } // rootOwner says whether a declared owner is root: none, "root", or uid 0. func rootOwner(owner string) bool { if owner == "" || owner == "root" { return true } user, _, _ := strings.Cut(owner, ":") return user == "0" } // what a guarded path is, for the words of a refusal. type placing int const ( placingDirectory placing = iota placingAccess placingMount ) // refusePath says why a path is not placed or mounted; nil when it may be. module is the resource's module, // owner a directory's declared owner. func refusePath(path string, kind placing, module, owner string) error { clean := filepath.Clean(path) if !filepath.IsAbs(clean) { return nil // the declaration refuses a relative path already; a named volume is not a path } for _, p := range []string{clean, resolved(clean)} { if err := refuseOne(p, kind, module, owner); err != nil { if p != clean { err.Why = fmt.Sprintf("through a link, it is %s, and %s", p, err.Why) err.Path = clean } return err } } return nil } func refuseOne(path string, kind placing, module, owner string) *PlacementRefusedError { for _, root := range protectedRoots { if path == root || below(root, path) { return &PlacementRefusedError{Path: path, Why: root + " is one of the machine's own directories, " + "and owning or mounting it would be owning or mounting everything in it"} } } trees := append([]string(nil), forbiddenBelow...) if kind == placingMount { // A mount is the machine's plumbing as often as a module's data — the clock, a kernel file, /dev/null // (systemPath) — and what a setting can mount at all is a directory or an access, refused above it. trees = append([]string(nil), forbiddenBelowMount...) } // The container runtimes' data, wherever the machine keeps it: every container's filesystem is there. trees = append(trees, runtimeDataRoots()...) for _, tree := range trees { if atOrBelow(path, tree) { return &PlacementRefusedError{Path: path, Why: "nothing is placed in or mounted from " + tree} } } if module != engineModule { for _, tree := range engineTrees { if atOrBelow(path, tree) { return &PlacementRefusedError{Path: path, Why: tree + " is the node-engine's own, placed in by " + "its own module alone"} } } } for _, tree := range rootsOnly { if !below(path, tree) { continue } switch { case kind == placingAccess: return &PlacementRefusedError{Path: path, Why: "an access is the operator's data, and " + tree + " is the machine's own"} case kind == placingDirectory && !rootOwner(owner): return &PlacementRefusedError{Path: path, Why: fmt.Sprintf("a directory below %s is root's, and this "+ "one is declared %s's", tree, owner)} } } ssh := false for _, part := range strings.Split(path, "/") { ssh = ssh || part == ".ssh" } if ssh && kind != placingDirectory { return &PlacementRefusedError{Path: path, Why: "it is an account's .ssh, which holds its keys and who may " + "log in as it, and is never an access or a mount"} } if kind != placingDirectory { return nil } homes := accountsOfHomes() var deepest string for home := range homes { if below(path, home) && len(home) > len(deepest) { deepest = home } } if ssh && deepest == "" { return &PlacementRefusedError{Path: path, Why: "a .ssh directory is placed only below its account's home, " + "as that account's"} } if deepest != "" { if a := homes[deepest]; !ownedByAccount(owner, a) { if owner == "" { owner = "root" } return &PlacementRefusedError{Path: path, Why: fmt.Sprintf("it is below %s's home and declared %s's; "+ "below a home only that account's directories are placed", a.Name, owner)} } } return nil } // moduleOfID is the module a resource id names, or "". func moduleOfID(id string) string { module, _ := moduleOf(id) return module } // refusedPlaces judges every directory and access of a declaration before anything is applied, and answers // each refusal by path: what is refused is refused again as a container's mount source. func refusedPlaces(resources []declaration.Resource) map[string]error { out := map[string]error{} for _, r := range resources { var err error switch res := r.(type) { case *declaration.Directory: err = refusePath(res.Path, placingDirectory, moduleOfID(res.ID), res.Owner) case *declaration.Access: err = refusePath(res.Path, placingAccess, moduleOfID(res.ID), "") default: continue } if err != nil { out[filepath.Clean(r.Target())] = err } } return out } // refuseMounts says why a container's mounts are not made; nil when they may be. func refuseMounts(c *declaration.Container, refused map[string]error) error { for _, v := range c.Volumes { src := mountSource(v) if !strings.HasPrefix(src, "/") { continue // a named volume, which the runtime keeps in its own tree } src = filepath.Clean(src) for path, why := range refused { if atOrBelow(src, path) { var refusal *PlacementRefusedError if errors.As(why, &refusal) { return &PlacementRefusedError{Path: src, Why: "it is mounted from " + path + ", which is refused: " + refusal.Why} } return why } } if err := refusePath(src, placingMount, moduleOfID(c.ID), ""); err != nil { return err } } return nil }