mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestBelowAHomeOnlyThatAccountsDirectoryIsPlaced (0.00s)
mesh/delivery superseded: a newer head of the same pull request
A module's places setting could point a directory anywhere, with an owner it named, and the engine chowned and chmodded whatever it was pointed at as root, through a link too; its accesses setting could mount any path into a container (hq issue 339). The engine now refuses, as a failed resource with its reason, a directory, access or mount source that is or holds one of the machine's roots, anything placed in the kernel's, the boot loader's, root's or the engine's own trees, a non-root directory below /etc, /usr or /run, an access there, another account's directory below a home, and a container mounting anything it refused, judged with every link resolved. A directory whose last component is a link is refused, and one found here that the mesh did not make keeps its owner and mode and is never removed.
301 lines
11 KiB
Go
301 lines
11 KiB
Go
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 or /root, nor in the node-engine's own trees** (its
|
|
// state, its identity, its installed builds) but by its own module; nothing is mounted from /boot, /root or
|
|
// the engine's trees. A mount of a kernel file, a device or the clock is the plumbing systemPath names.
|
|
// 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"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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"}
|
|
// 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"}
|
|
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"}
|
|
)
|
|
|
|
// 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 := 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 = forbiddenBelowMount
|
|
}
|
|
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)}
|
|
}
|
|
}
|
|
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 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
|
|
}
|