A module's places setting can move a directory anywhere, and the engine chowned whatever it was pointed at as root: a directory at /etc owned by the agent account would hand it /etc (hq ADR 0266). Refuse the machine's roots, the kernel's and the engine's trees, another account's home, and an archive or written-into file below an agent's home; and leave the owner and mode of a directory the mesh did not make.
218 lines
7.6 KiB
Go
218 lines
7.6 KiB
Go
package apply
|
|
|
|
// Where the node-engine places nothing, whoever asks (novox/hq ADR 0266, the third review of 2026-10-08).
|
|
//
|
|
// A directory, a file or an archive names its path, and the controller resolves part of that path from what
|
|
// a person or a verb set: a module's `places` setting moves a directory anywhere. The engine runs as root, so
|
|
// a path it accepts blindly is a path any caller of the controller's settings could hand to any account — a
|
|
// directory resource at /etc owned by the agent account gives the agent /etc. So the engine itself refuses,
|
|
// whatever the declaration says:
|
|
//
|
|
// 1. **a directory that is one of the machine's own roots**, or an ancestor of one: /, /etc, /usr, /var,
|
|
// /var/lib, /home, /run and the rest of protectedRoots. Modules place files and directories BELOW /etc or
|
|
// /var/lib, never the root itself; owning one is owning everything in it;
|
|
// 2. **anything below /proc, /sys, /dev or /boot**, and **anything in the node-engine's own trees** (its
|
|
// state, its identity, its installed builds) but its own module's;
|
|
// 3. **anything below a person's or an agent's home, for an owner other than that home's account**. A
|
|
// directory, file or archive below /home/<account> belongs to that account or is not placed: a module
|
|
// placing root's, or another account's, file there is placing it where the account controls every parent;
|
|
// and a home itself is its account's, so a directory resource naming a home exactly is refused;
|
|
// 4. **an archive, or a file written into (`into`), below the home of an account declared `root: never`**,
|
|
// however the path is spelled. The engine writes those after checking the path, not through descriptors,
|
|
// and that account owns every parent and could swap a link in between.
|
|
//
|
|
// Each is a refusal of the resource, said in words; nothing is touched.
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
)
|
|
|
|
// protectedRoots are directories no directory resource may be, nor be an ancestor of.
|
|
var protectedRoots = []string{"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/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/tmp"}
|
|
|
|
// forbiddenBelow are trees nothing is placed in: the kernel's and the boot loader's. engineTrees are the
|
|
// engine's own, which only its own module (`mesh-host`, whose builds are installed there) places in.
|
|
var (
|
|
forbiddenBelow = []string{"/proc", "/sys", "/dev", "/boot"}
|
|
engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"}
|
|
)
|
|
|
|
// engineModule is the module whose resources may place in the engine's own trees.
|
|
const engineModule = "mesh-host."
|
|
|
|
// PlacementRefusedError is a resource the engine will not place where it says.
|
|
type PlacementRefusedError struct {
|
|
Path, Why string
|
|
}
|
|
|
|
func (e *PlacementRefusedError) Error() string {
|
|
return fmt.Sprintf("%s is not placed: %s (novox/hq ADR 0266); nothing was touched", e.Path, e.Why)
|
|
}
|
|
|
|
// accountsOfHomes is each person's or agent's home and the account it belongs to, from the user database —
|
|
// the same homes homeAbove reads. 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 == "" {
|
|
continue
|
|
}
|
|
if (uid >= 1000 && uid != 65534) || strings.HasPrefix(home, "/home/") {
|
|
out[home] = homeAccount{Name: fields[0], UID: uid}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type homeAccount struct {
|
|
Name string
|
|
UID int
|
|
}
|
|
|
|
// below says whether path is strictly below dir.
|
|
func below(path, dir string) bool {
|
|
if dir == "/" {
|
|
return path != "/"
|
|
}
|
|
return strings.HasPrefix(path, dir+string(os.PathSeparator))
|
|
}
|
|
|
|
// ownerName is the owner a resource declares, "" for root.
|
|
func ownerName(r declaration.Resource) string {
|
|
switch res := r.(type) {
|
|
case *declaration.Directory:
|
|
return res.Owner
|
|
case *declaration.File:
|
|
return res.Owner
|
|
case *declaration.Archive:
|
|
return res.Owner
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// refusePlacement says why a directory, file or archive is not placed; nil when it may be. agentHomes are the
|
|
// homes of the accounts the declaration says never become root.
|
|
func refusePlacement(r declaration.Resource, agentHomes []string) error {
|
|
path := filepath.Clean(r.Target())
|
|
if !filepath.IsAbs(path) {
|
|
return nil // the declaration refuses a relative path already
|
|
}
|
|
if _, isDir := r.(*declaration.Directory); isDir {
|
|
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 it would be owning everything in it"}
|
|
}
|
|
}
|
|
}
|
|
for _, tree := range forbiddenBelow {
|
|
if path == tree || below(path, tree) {
|
|
return &PlacementRefusedError{Path: path, Why: "nothing is placed in " + tree}
|
|
}
|
|
}
|
|
if !strings.HasPrefix(r.Identity(), engineModule) {
|
|
for _, tree := range engineTrees {
|
|
if path == tree || below(path, tree) {
|
|
return &PlacementRefusedError{Path: path, Why: tree + " is the node-engine's own, placed in by its own module alone"}
|
|
}
|
|
}
|
|
}
|
|
homes := accountsOfHomes()
|
|
if a, isHome := homes[path]; isHome {
|
|
return &PlacementRefusedError{Path: path, Why: "it is " + a.Name + "'s home, which is that account's"}
|
|
}
|
|
var deepest string
|
|
for home := range homes {
|
|
if below(path, home) && len(home) > len(deepest) {
|
|
deepest = home
|
|
}
|
|
}
|
|
if deepest != "" {
|
|
a := homes[deepest]
|
|
if owner := ownerName(r); !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 files are placed", a.Name, owner)}
|
|
}
|
|
}
|
|
risky := ""
|
|
switch res := r.(type) {
|
|
case *declaration.Archive:
|
|
risky = "an archive unpacked"
|
|
case *declaration.File:
|
|
if res.Into != "" {
|
|
risky = "a file written into (" + res.Into + ")"
|
|
}
|
|
}
|
|
if risky != "" {
|
|
for _, home := range agentHomes {
|
|
if path == home || below(path, home) {
|
|
return &PlacementRefusedError{Path: path, Why: risky + " below the home of an account that never " +
|
|
"becomes root, which owns every parent there and could swap a link in between the check and the write"}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// rootNeverHomes is the home of every account the declaration says never becomes root, from the user database;
|
|
// an account not made yet has no home to protect.
|
|
func rootNeverHomes(d *declaration.Declaration) []string {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
homes := accountsOfHomes()
|
|
var out []string
|
|
for _, r := range d.Resources {
|
|
u, ok := r.(*declaration.User)
|
|
if !ok || u.Root != declaration.RootNever {
|
|
continue
|
|
}
|
|
for home, a := range homes {
|
|
if a.Name == u.Name {
|
|
out = append(out, home)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|