package main // The agent's home, touched as the agent's account (novox/hq ADR 0266). // // On a machine that names an agent account of its own — the control node — agents run as that account, not // as the operator's, so that no agent there can become root without a person. Its home is its own, 0700: // the node's tool runtime, which runs this module as the operator's account, can neither read nor write it. // And this module must: the licence token and the account the agent is logged in as, the items registered // at the home scope, and the home's own items the status and the removal tools read. // // So every path under that home is touched by a helper running as the agent's account: this module's own // binary, as the plugin carries it under the managed directory (root's, so the agent cannot change it), // started once through `sudo -n -u ` and kept, speaking one JSON request and one JSON answer per line // on its stdin and stdout. Once, not per file: a credentials file is looked at every five seconds, and sudo // says every start in the journal. **The escalation goes down, never up**: the runtime's account may become // the agent's; nothing here lets the agent's become anything. // // Everything else — the module's state, the managed directory, a machine whose agents run as the operator — // is the plain filesystem, as before. Files routes each path; with no agent account of its own it routes none. import ( "bufio" "encoding/json" "errors" "fmt" "io" "io/fs" "os" "os/exec" "os/user" "path/filepath" "sort" "strings" "sync" "syscall" "time" ) // HelperArg is the argument that makes this binary the helper (as the agent's account). const HelperArg = "home-fs" // Files is the filesystem as this module touches it: a path under the agent's home through the helper when // the agent's account is not this process's, every other path as it is. type Files struct { // Home is the agent's home and Account its login; Helper starts the helper, nil for none. Home string Account string Helper *Helper } // fsys is the module's: plain until the launch says whose home the agent's is. var fsys = &Files{} // FilesFor is the filesystem for these paths: through the helper when the agent's account is another than the // one this process runs as. func FilesFor(p Paths) *Files { if p.Agent == "" || p.Home == "" { return &Files{} } if me, err := user.Current(); err == nil && me.Username == p.Agent { return &Files{} } return &Files{Home: filepath.Clean(p.Home), Account: p.Agent, Helper: &Helper{Account: p.Agent, Exe: HelperPath()}} } // HelperPath is the helper's binary: the guard's copy of this module's binary in the plugin, root's and // readable by every account, so the agent's account runs exactly what the mesh placed. func HelperPath() string { return filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir, "guard") } // through says whether a path is touched as the agent. func (f *Files) through(path string) bool { if f == nil || f.Helper == nil || f.Home == "" { return false } path = filepath.Clean(path) return path == f.Home || strings.HasPrefix(path, f.Home+string(os.PathSeparator)) } // ---- the operations --------------------------------------------------------------------------------------- func (f *Files) ReadFile(path string) ([]byte, error) { if !f.through(path) { return os.ReadFile(path) } a, err := f.Helper.Do(Op{Op: "read", Path: path}) return a.Data, err } func (f *Files) WriteFile(path string, data []byte, mode os.FileMode) error { if !f.through(path) { return os.WriteFile(path, data, mode) } _, err := f.Helper.Do(Op{Op: "write", Path: path, Data: data, Mode: uint32(mode.Perm())}) return err } // CreateExclusive writes a file that must not be there yet. func (f *Files) CreateExclusive(path string, data []byte, mode os.FileMode) error { if !f.through(path) { file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) if err != nil { return err } _, werr := file.Write(data) return errors.Join(werr, file.Close()) } _, err := f.Helper.Do(Op{Op: "create", Path: path, Data: data, Mode: uint32(mode.Perm())}) return err } func (f *Files) Rename(from, to string) error { if f.through(from) != f.through(to) { return fmt.Errorf("%s cannot be moved to %s: one is the agent's home and the other is not", from, to) } if !f.through(from) { return os.Rename(from, to) } _, err := f.Helper.Do(Op{Op: "rename", Path: from, To: to}) return err } func (f *Files) Remove(path string) error { if !f.through(path) { return os.Remove(path) } _, err := f.Helper.Do(Op{Op: "remove", Path: path}) return err } func (f *Files) RemoveAll(path string) error { if !f.through(path) { return os.RemoveAll(path) } _, err := f.Helper.Do(Op{Op: "remove-all", Path: path}) return err } func (f *Files) MkdirAll(path string, mode os.FileMode) error { if !f.through(path) { return os.MkdirAll(path, mode) } _, err := f.Helper.Do(Op{Op: "mkdir", Path: path, Mode: uint32(mode.Perm())}) return err } func (f *Files) Chmod(path string, mode os.FileMode) error { if !f.through(path) { return os.Chmod(path, mode) } _, err := f.Helper.Do(Op{Op: "chmod", Path: path, Mode: uint32(mode.Perm())}) return err } func (f *Files) Stat(path string) (fs.FileInfo, error) { if !f.through(path) { return os.Stat(path) } a, err := f.Helper.Do(Op{Op: "stat", Path: path}) if err != nil { return nil, err } return a.Info.info(), nil } func (f *Files) Lstat(path string) (fs.FileInfo, error) { if !f.through(path) { return os.Lstat(path) } a, err := f.Helper.Do(Op{Op: "lstat", Path: path}) if err != nil { return nil, err } return a.Info.info(), nil } func (f *Files) ReadDir(path string) ([]fs.DirEntry, error) { if !f.through(path) { return os.ReadDir(path) } a, err := f.Helper.Do(Op{Op: "readdir", Path: path}) if err != nil { return nil, err } out := make([]fs.DirEntry, 0, len(a.Entries)) for _, e := range a.Entries { // A name is one entry of path, never a way out of it: the helper runs as the agent's account, so what it // answers is the agent's word, and a name joined to path is read as the runtime's when it leaves the home. if e == nil || !entryName(e.Name) { return nil, fmt.Errorf("readdir %s as %s: the helper answered an entry that is not a name in it", path, f.Helper.Account) } out = append(out, fs.FileInfoToDirEntry(e.info())) } return out, nil } // WalkDir is filepath.WalkDir, through the helper under the agent's home: lexical order, a directory before // what is in it, a symbolic link reported and never followed. func (f *Files) WalkDir(root string, fn fs.WalkDirFunc) error { if !f.through(root) { return filepath.WalkDir(root, fn) } info, err := f.Lstat(root) if err != nil { err = fn(root, nil, err) } else { err = f.walk(root, fs.FileInfoToDirEntry(info), fn) } if err == filepath.SkipDir || err == filepath.SkipAll { return nil } return err } // entryName says whether name is one entry of a directory: not empty, not . or .., and no separator. func entryName(name string) bool { return name != "" && name != "." && name != ".." && !strings.ContainsRune(name, '/') && !strings.ContainsRune(name, 0) } func (f *Files) walk(path string, d fs.DirEntry, fn fs.WalkDirFunc) error { if err := fn(path, d, nil); err != nil || !d.IsDir() { if err == filepath.SkipDir && d.IsDir() { err = nil } return err } entries, err := f.ReadDir(path) if err != nil { if err = fn(path, d, err); err != nil { if err == filepath.SkipDir { err = nil } return err } } for _, e := range entries { if err := f.walk(filepath.Join(path, e.Name()), e, fn); err != nil { if err == filepath.SkipDir { break } return err } } return nil } // ---- the wire --------------------------------------------------------------------------------------------- // Op is one request to the helper. type Op struct { Op string `json:"op"` Path string `json:"path"` To string `json:"to,omitempty"` Data []byte `json:"data,omitempty"` Mode uint32 `json:"mode,omitempty"` } // Answer is the helper's to one Op. Err is said by its kind, so the caller's os.IsNotExist and errors.Is // read it as they would read the filesystem's own. type Answer struct { Data []byte `json:"data,omitempty"` Info *Info `json:"info,omitempty"` Entries []*Info `json:"entries,omitempty"` Err string `json:"err,omitempty"` Kind string `json:"kind,omitempty"` // not-exist | exist | permission } // Info is a file's information as the wire carries it. type Info struct { Name string `json:"name"` Size int64 `json:"size"` Mode uint32 `json:"mode"` ModTime time.Time `json:"modTime"` } func infoOf(i fs.FileInfo) *Info { return &Info{Name: i.Name(), Size: i.Size(), Mode: uint32(i.Mode()), ModTime: i.ModTime()} } func (i *Info) info() fs.FileInfo { if i == nil { return fileInfo{} } return fileInfo{*i} } type fileInfo struct{ Info } func (i fileInfo) Name() string { return i.Info.Name } func (i fileInfo) Size() int64 { return i.Info.Size } func (i fileInfo) Mode() fs.FileMode { return fs.FileMode(i.Info.Mode) } func (i fileInfo) ModTime() time.Time { return i.Info.ModTime } func (i fileInfo) IsDir() bool { return i.Mode().IsDir() } func (i fileInfo) Sys() any { return nil } func errorOf(op, path string, a Answer) error { if a.Err == "" { return nil } var kind error switch a.Kind { case "not-exist": kind = fs.ErrNotExist case "exist": kind = fs.ErrExist case "permission": kind = fs.ErrPermission default: return errors.New(a.Err) } // The kind itself, unwrapped: os.IsNotExist compares rather than unwraps. return &fs.PathError{Op: op, Path: path, Err: kind} } // ---- the client ------------------------------------------------------------------------------------------- // Helper is the helper as this module keeps it: started at the first request, restarted once when it is gone. type Helper struct { Account string Exe string // Start is how it is started; nil is through sudo as the account. A test starts it in the process. Start func() (io.WriteCloser, io.Reader, func(), error) mu sync.Mutex in io.WriteCloser out *bufio.Reader close func() } // helperAnswerWithin bounds how long one request may take. The helper's own reads cannot block (serveOne), so // a helper this slow has gone wrong: it is stopped and the request fails, said, never retried, so no request // holds every later one behind it. var helperAnswerWithin = 30 * time.Second // errHelperSlow is a helper that did not answer in time. var errHelperSlow = errors.New("the helper did not answer in time") // Do answers one request, starting the helper when it is not running. func (h *Helper) Do(op Op) (Answer, error) { h.mu.Lock() defer h.mu.Unlock() var last error for try := 0; try < 2; try++ { if h.in == nil { if err := h.start(); err != nil { return Answer{}, err } } a, err := h.roundTrip(op) if err == nil { return a, errorOf(op.Op, op.Path, a) } last = err h.stop() if errors.Is(err, errHelperSlow) { break } } return Answer{}, fmt.Errorf("%s %s as %s: the helper did not answer: %w", op.Op, op.Path, h.Account, last) } func (h *Helper) roundTrip(op Op) (Answer, error) { raw, err := json.Marshal(op) if err != nil { return Answer{}, err } type read struct { line []byte err error } done := make(chan read, 1) in, out := h.in, h.out go func() { if _, err := in.Write(append(raw, '\n')); err != nil { done <- read{err: err} return } line, err := readLine(out, helperAnswerMax) done <- read{line, err} }() var line []byte select { case r := <-done: if r.err != nil { return Answer{}, r.err } line = r.line case <-time.After(helperAnswerWithin): // The goroutine ends when stop closes the pipes and the helper goes. return Answer{}, fmt.Errorf("%w (%s)", errHelperSlow, helperAnswerWithin) } var a Answer if err := json.Unmarshal(line, &a); err != nil { return Answer{}, fmt.Errorf("the helper said something that is not an answer: %w", err) } return a, nil } // helperAnswerMax bounds one answer of the helper: far above the largest file this module reads from a home // (an item is at most 256 KiB), so an answer past it is a helper gone wrong, not a file. const helperAnswerMax = 16 << 20 // readLine reads one line of at most max bytes; a longer one is an error and the helper is started again. func readLine(r *bufio.Reader, max int) ([]byte, error) { var line []byte for { chunk, err := r.ReadSlice('\n') line = append(line, chunk...) if len(line) > max { return nil, fmt.Errorf("the helper's answer is larger than %d bytes", max) } if err == bufio.ErrBufferFull { continue } return line, err } } func (h *Helper) start() error { start := h.Start if start == nil { start = h.sudo } in, out, closer, err := start() if err != nil { return err } h.in, h.out, h.close = in, bufio.NewReaderSize(out, 1<<20), closer return nil } func (h *Helper) stop() { if h.in != nil { _ = h.in.Close() } if h.close != nil { h.close() } h.in, h.out, h.close = nil, nil, nil } // sudo starts the helper as the agent's account: the runtime's account may become it (the sudo module's // rule for the operator's account names every account), and nothing the agent's account holds lets it back. func (h *Helper) sudo() (io.WriteCloser, io.Reader, func(), error) { if _, err := os.Stat(h.Exe); err != nil { return nil, nil, nil, fmt.Errorf("the helper that touches %s's home is not placed yet (%s): the managed "+ "directory is written first, and the next render reaches the home", h.Account, h.Exe) } cmd := exec.Command("sudo", "-n", "-u", h.Account, "--", h.Exe, HelperArg) cmd.Dir = "/" // One language for what sudo says when it refuses, so its words are read the same on every machine. cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") in, err := cmd.StdinPipe() if err != nil { return nil, nil, nil, err } out, err := cmd.StdoutPipe() if err != nil { return nil, nil, nil, err } var stderr strings.Builder cmd.Stderr = &limited{w: &stderr, left: 4096} if err := cmd.Start(); err != nil { return nil, nil, nil, fmt.Errorf("the helper could not be started as %s: %w", h.Account, err) } return in, out, func() { done := make(chan struct{}) go func() { _ = cmd.Wait(); close(done) }() select { case <-done: case <-time.After(2 * time.Second): // sudo passes a SIGTERM on to the helper; a SIGKILL would end sudo alone and leave it running. _ = cmd.Process.Signal(syscall.SIGTERM) select { case <-done: case <-time.After(2 * time.Second): _ = cmd.Process.Kill() <-done } } if s := strings.TrimSpace(stderr.String()); s != "" { say("the helper as %s said: %s", h.Account, s) } }, nil } type limited struct { w io.Writer left int } func (l *limited) Write(p []byte) (int, error) { n := len(p) if l.left <= 0 { return n, nil } if len(p) > l.left { p = p[:l.left] } l.left -= len(p) _, _ = l.w.Write(p) return n, nil } // ---- the helper ------------------------------------------------------------------------------------------- // helperReadMax bounds a file the helper reads: its answer, as JSON, stays within helperAnswerMax. const helperReadMax = 10 << 20 // readRegular reads a regular file of at most helperReadMax bytes. Opened without blocking and judged by what was // opened, so a FIFO, a device or a link to one in the agent's home answers an error at once instead of holding // the helper, and every request behind it, or filling its memory. func readRegular(path string) ([]byte, error) { f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) if err != nil { return nil, err } defer f.Close() info, err := f.Stat() if err != nil { return nil, err } if !info.Mode().IsRegular() { return nil, fmt.Errorf("%s is not a regular file (%s)", path, info.Mode().Type()) } data, err := io.ReadAll(io.LimitReader(f, helperReadMax+1)) if err != nil { return nil, err } if len(data) > helperReadMax { return nil, fmt.Errorf("%s is larger than %d bytes", path, helperReadMax) } return data, nil } // ServeHelper is the helper: one answer per request, until its input ends. It runs as the agent's account and // does only what that account may, on its own home: the account's permissions are the boundary. func ServeHelper(in io.Reader, out io.Writer) error { dec := json.NewDecoder(bufio.NewReaderSize(in, 1<<20)) enc := json.NewEncoder(out) for { var op Op if err := dec.Decode(&op); err != nil { if errors.Is(err, io.EOF) { return nil } return err } if err := enc.Encode(serveOne(op)); err != nil { return err } } } func serveOne(op Op) Answer { if !filepath.IsAbs(op.Path) || (op.To != "" && !filepath.IsAbs(op.To)) { return Answer{Err: "a path must be absolute"} } mode := os.FileMode(op.Mode).Perm() var a Answer var err error switch op.Op { case "read": a.Data, err = readRegular(op.Path) case "write": err = os.WriteFile(op.Path, op.Data, mode) case "create": var f *os.File if f, err = os.OpenFile(op.Path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode); err == nil { _, werr := f.Write(op.Data) err = errors.Join(werr, f.Close()) } case "rename": err = os.Rename(op.Path, op.To) case "remove": err = os.Remove(op.Path) case "remove-all": err = os.RemoveAll(op.Path) case "mkdir": err = os.MkdirAll(op.Path, mode) case "chmod": err = os.Chmod(op.Path, mode) case "stat", "lstat": var i fs.FileInfo if op.Op == "stat" { i, err = os.Stat(op.Path) } else { i, err = os.Lstat(op.Path) } if err == nil { a.Info = infoOf(i) } case "readdir": var entries []fs.DirEntry entries, err = os.ReadDir(op.Path) sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) for _, e := range entries { if i, ierr := e.Info(); ierr == nil { a.Entries = append(a.Entries, infoOf(i)) } } default: return Answer{Err: "no such operation: " + op.Op} } if err != nil { a = Answer{Err: err.Error()} switch { case errors.Is(err, fs.ErrNotExist): a.Kind = "not-exist" case errors.Is(err, fs.ErrExist): a.Kind = "exist" case errors.Is(err, fs.ErrPermission): a.Kind = "permission" } } return a }