mesh/delivery-group group feat/900-node-files checking: composed check out of date: novox/mesh-controller@210f7cf5e64b was judged against trunk 611e2275cf7c, t…
mesh/merge-gate out of date: judged against trunk 3db85a0dde83, the trunk is now 8b53a3663244; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 3db85a0dde83, the trunk is now 8b53a3663244; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
329 lines
15 KiB
Go
329 lines
15 KiB
Go
// mesh-files (novox/hq ADR 0394, to-be 41 WP9): the holder of node-files. A node's files and accounts, read and
|
|
// managed through the mesh — the owner and mode of each component of a path, a link, a directory, a text file,
|
|
// a search by fixed string, an account or a group; and the writes: write, copy, move, delete, mkdir, chmod,
|
|
// chown, and the trash that delete and overwrite use.
|
|
//
|
|
// It runs as the tool runner's account, the node's operator account, and acts with that account's own rights
|
|
// first; where that fails with a permission error it repeats the act through `sudo -n` with a fixed binary
|
|
// (sudo.go), never a shell. It refuses by name the places a secret is kept (refuse.go), withholds any piece of
|
|
// an answer shaped like a secret (mesh-sdk's outward), and refuses a change of a path a module owns unless a
|
|
// reason is given (files.go). Every write act is one line in the act log (files.go).
|
|
//
|
|
// stdout is the MCP channel; what this module says, it says on stderr.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// Seat is the role this module holds.
|
|
const Seat = "node-files"
|
|
|
|
// PlacesWord is where the controller names this node's secret places (novox/hq ADR 0394 §5).
|
|
const PlacesWord = "MESH_SECRET_PLACES"
|
|
|
|
func main() {
|
|
places, err := placesFrom(os.Getenv(PlacesWord))
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[mesh-files] %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
f, err := newFiles(places)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[mesh-files] %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := stdio.Serve("", tools(f)); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[mesh-files] %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// StateWord is where the module's state directory (the trash and the act log) is named.
|
|
const StateWord = "MESH_FILES_STATE"
|
|
|
|
func newFiles(places []string) (*Files, error) {
|
|
state := os.Getenv(StateWord)
|
|
if strings.TrimSpace(state) == "" {
|
|
return nil, fmt.Errorf("%s is not given: the trash and the act log have no place", StateWord)
|
|
}
|
|
owned, err := ownedFrom(os.Getenv(OwnedWord))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(owned) == 0 {
|
|
fmt.Fprintf(os.Stderr, "[mesh-files] %s is not given, so this node's owned paths are unknown and nothing is "+
|
|
"refused as owned (novox/hq ADR 0394 §5): a controller that knows node-files composes it\n", OwnedWord)
|
|
}
|
|
r := newReader(places)
|
|
r.Root = sudoRoot{}
|
|
node := os.Getenv("MESH_NODE")
|
|
return &Files{Reader: r, State: state, Owned: owned, Node: node, Tell: tellOperator,
|
|
OwnerOf: ownerOnDisk, Lchown: os.Lchown, NewID: newID}, nil
|
|
}
|
|
|
|
// tellOperator tells the operator a plain notice, through the operator channel's tell verb: no condition is
|
|
// opened, and nothing is kept open.
|
|
func tellOperator(headline string, lines []string, subject string) error {
|
|
_, err := stdio.Ask("seat:operator-channel.tell", map[string]any{"headline": headline, "lines": lines, "subject": subject})
|
|
return err
|
|
}
|
|
|
|
// placesFrom reads the controller's list. Absent is refused: without it, a module's own-secret directory
|
|
// would be read wherever the account's modes allow, and a refusal the decision promises would be silent.
|
|
func placesFrom(raw string) ([]string, error) {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil, fmt.Errorf("%s is not given, so this node's secret places are unknown and nothing would be "+
|
|
"refused by them (novox/hq ADR 0394 §5): a controller that knows node-files composes it", PlacesWord)
|
|
}
|
|
var places []string
|
|
if err := json.Unmarshal([]byte(raw), &places); err != nil {
|
|
return nil, fmt.Errorf("%s is not a list of paths: %v", PlacesWord, err)
|
|
}
|
|
return places, nil
|
|
}
|
|
|
|
func str(description string) map[string]any {
|
|
return map[string]any{"type": "string", "description": description}
|
|
}
|
|
|
|
func arg(a map[string]any, k string) string {
|
|
switch v := a[k].(type) {
|
|
case string:
|
|
return strings.TrimSpace(v)
|
|
case float64:
|
|
return strconv.FormatFloat(v, 'f', -1, 64)
|
|
case bool:
|
|
return strconv.FormatBool(v)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func number(a map[string]any, k string) (int, error) {
|
|
s := arg(a, k)
|
|
if s == "" {
|
|
return 0, nil
|
|
}
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s is a whole number, and %q was given", k, s)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// pathsArg is the paths given, one per line — or, from a caller that sends a list, the list.
|
|
func pathsArg(a map[string]any) []string {
|
|
var raw []string
|
|
switch v := a["paths"].(type) {
|
|
case string:
|
|
raw = strings.Split(v, "\n")
|
|
case []any:
|
|
for _, p := range v {
|
|
if s, ok := p.(string); ok {
|
|
raw = append(raw, s)
|
|
}
|
|
}
|
|
}
|
|
var out []string
|
|
for _, p := range raw {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bounded is the last bound (novox/hq ADR 0394): whatever a verb made, no answer is larger than MaxAnswer.
|
|
func bounded(answer any, err error) (any, error) {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body, err := json.Marshal(answer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) > MaxAnswer {
|
|
return nil, fmt.Errorf("the answer is %d bytes, and an answer of node-files is at most %d: ask for less", len(body), MaxAnswer)
|
|
}
|
|
return answer, nil
|
|
}
|
|
|
|
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
|
|
// The descriptions are the controller's (internal/catalogue/reads.go), said again here for a caller of the
|
|
// module's own address.
|
|
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
|
|
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
|
|
Run: func(a map[string]any) (any, error) { return bounded(run(a)) }}
|
|
}
|
|
|
|
func object(props map[string]any, required ...string) map[string]any {
|
|
if required == nil {
|
|
required = []string{}
|
|
}
|
|
return map[string]any{"type": "object", "properties": props, "required": required}
|
|
}
|
|
|
|
// The descriptions of the reads are the controller's (internal/catalogue/files.go): the reads' own, with
|
|
// where they are read as restated for the operator's account.
|
|
const asAccount = "as the node's operator account, through sudo -n where its rights do not reach; a place a " +
|
|
"secret is kept, or a known credential file, is refused by name"
|
|
|
|
func boolArg(a map[string]any, k string) bool { return arg(a, k) == "true" }
|
|
|
|
func actOf(a map[string]any) Act {
|
|
return Act{Caller: stdio.CallerOf(a), Reason: arg(a, "reason"), Override: arg(a, "override")}
|
|
}
|
|
|
|
// write is one of the write verbs: it takes reason and override beside its own inputs.
|
|
func writeVerb(name, description string, props map[string]any, required []string,
|
|
run func(act Act, a map[string]any) (any, error)) stdio.Tool {
|
|
props["reason"] = str("why the file is changed, for the audit trail")
|
|
props["override"] = str("a reason, to change a path a module owns anyway (optional)")
|
|
return verb(name, description+" Refuses a path a module owns unless override names a reason; done as the node's "+
|
|
"operator account, through sudo -n where its rights do not reach.", object(props, append(required, "reason")...),
|
|
func(a map[string]any) (any, error) { return run(actOf(a), a) })
|
|
}
|
|
|
|
func tools(f *Files) []stdio.Tool {
|
|
r := f.Reader
|
|
return append(readTools(r),
|
|
writeVerb("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.",
|
|
map[string]any{"path": str("an absolute path"), "content": str("the text to write"),
|
|
"mode": str("the file's mode, such as 0644 (optional)")}, []string{"path", "content"},
|
|
func(act Act, a map[string]any) (any, error) {
|
|
content, _ := a["content"].(string) // as given: the text is not trimmed
|
|
return f.Write(act, arg(a, "path"), content, arg(a, "mode"))
|
|
}),
|
|
writeVerb("copy", "Copy a file or directory to a path on this node. A target on another node (<node>:/path) is "+
|
|
"refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+
|
|
"kept in the node's trash for 7 days.",
|
|
map[string]any{"from": str("an absolute path"), "to": str("an absolute path on this node")},
|
|
[]string{"from", "to"},
|
|
func(act Act, a map[string]any) (any, error) { return f.Copy(act, arg(a, "from"), arg(a, "to")) }),
|
|
writeVerb("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+
|
|
"comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.",
|
|
map[string]any{"from": str("an absolute path"), "to": str("an absolute path")}, []string{"from", "to"},
|
|
func(act Act, a map[string]any) (any, error) { return f.Move(act, arg(a, "from"), arg(a, "to")) }),
|
|
writeVerb("delete", "Delete a file or directory into the node's trash, where restore can bring it back.",
|
|
map[string]any{"path": str("an absolute path"),
|
|
"recursive": str("\"true\": delete a directory with what it holds")}, []string{"path"},
|
|
func(act Act, a map[string]any) (any, error) {
|
|
return f.Delete(act, arg(a, "path"), boolArg(a, "recursive"))
|
|
}),
|
|
writeVerb("mkdir", "Make a directory.",
|
|
map[string]any{"path": str("an absolute path"),
|
|
"parents": str("\"true\": make the missing parents too")}, []string{"path"},
|
|
func(act Act, a map[string]any) (any, error) {
|
|
return f.Mkdir(act, arg(a, "path"), boolArg(a, "parents"))
|
|
}),
|
|
writeVerb("chmod", "Change the mode of a file or directory.",
|
|
map[string]any{"path": str("an absolute path"), "mode": str("the mode, such as 0644 or u+x"),
|
|
"recursive": str("\"true\": also everything under a directory")}, []string{"path", "mode"},
|
|
func(act Act, a map[string]any) (any, error) {
|
|
return f.Chmod(act, arg(a, "path"), arg(a, "mode"), boolArg(a, "recursive"))
|
|
}),
|
|
writeVerb("chown", "Change the owner and group of a file or directory.",
|
|
map[string]any{"path": str("an absolute path"), "owner": str("an account's name or number"),
|
|
"group": str("a group's name or number (optional)"),
|
|
"recursive": str("\"true\": also everything under a directory")}, []string{"path", "owner"},
|
|
func(act Act, a map[string]any) (any, error) {
|
|
return f.Chown(act, arg(a, "path"), arg(a, "owner"), arg(a, "group"), boolArg(a, "recursive"))
|
|
}),
|
|
verb("trash", "What the node's trash holds: each deleted or replaced item with its id, original path and when "+
|
|
"it went.", object(map[string]any{}),
|
|
func(map[string]any) (any, error) { return f.Trash() }),
|
|
verb("restore", "Put an item of the node's trash back where it was; refuses when something is there now.",
|
|
object(map[string]any{"id": str("an id from trash"),
|
|
"reason": str("why it is put back, for the audit trail"),
|
|
"override": str("a reason, to change a path a module owns anyway (optional)")}, "id", "reason"),
|
|
func(a map[string]any) (any, error) { return f.Restore(actOf(a), arg(a, "id")) }),
|
|
)
|
|
}
|
|
|
|
// readTools are the reads, each saying how it reads.
|
|
func readTools(r Reader) []stdio.Tool {
|
|
ts := rawReadTools(r)
|
|
for i := range ts {
|
|
if !strings.Contains(ts[i].Description, asAccount) {
|
|
ts[i].Description += " Read " + asAccount + "."
|
|
}
|
|
}
|
|
return ts
|
|
}
|
|
|
|
func rawReadTools(r Reader) []stdio.Tool {
|
|
return []stdio.Tool{
|
|
verb("stat", "Each component of one path or several, from / down: its type, owner and group (name and "+
|
|
"number), mode, whether it is a link and to what, and whether it is a mount point and of which file "+
|
|
"system. A link is not followed unless follow is \"true\". At most 200 paths in one call. Read "+asAccount+".",
|
|
object(map[string]any{"paths": str("one absolute path, or several separated by line breaks"),
|
|
"follow": str("\"true\": also say where the path leads once every link is followed")}, "paths"),
|
|
func(a map[string]any) (any, error) { return r.Stat(pathsArg(a), arg(a, "follow") == "true") }),
|
|
verb("readlink", "What one link points to, as written, and where the path leads once every link is "+
|
|
"followed, and whether that exists.",
|
|
object(map[string]any{"path": str("an absolute path")}, "path"),
|
|
func(a map[string]any) (any, error) { return r.Readlink(arg(a, "path")) }),
|
|
verb("list", "Every entry of one directory with its type, owner, group, mode, size and, for a link, its "+
|
|
"target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is "+
|
|
"refused by name.",
|
|
object(map[string]any{"path": str("an absolute path to a directory")}, "path"),
|
|
func(a map[string]any) (any, error) { return r.List(arg(a, "path")) }),
|
|
verb("read", "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more "+
|
|
"is asked), cut at a line and said so. A negative from counts from the end, as tail does. A binary file "+
|
|
"is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped "+
|
|
"like a secret is withheld and its number said. /proc and /sys are read as far as the account may. Read "+asAccount+".",
|
|
object(map[string]any{"path": str("an absolute path to a file"),
|
|
"from": str("the first line to answer, counted from 1; negative: that many lines from the end (optional)"),
|
|
"lines": str("how many lines to answer (optional)"),
|
|
"bytes": str("the most bytes to answer, up to 262144 (optional, 65536 unless given)")}, "path"),
|
|
func(a map[string]any) (any, error) {
|
|
var ask ReadAsk
|
|
var err error
|
|
if ask.From, err = number(a, "from"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Lines, err = number(a, "lines"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Bytes, err = number(a, "bytes"); err != nil {
|
|
return nil, err
|
|
}
|
|
return r.Read(arg(a, "path"), ask)
|
|
}),
|
|
verb("search", "Find a fixed string (never a pattern) in one file or under one directory: every matching "+
|
|
"line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories "+
|
|
"deep (4 unless more is asked), 5000 files, 4 MiB per file and 20 seconds, on the file system of the path "+
|
|
"named; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like "+
|
|
"a secret is withheld. The answer says which bound it met.",
|
|
object(map[string]any{"text": str("the fixed string to find"),
|
|
"path": str("an absolute path to a file or a directory"),
|
|
"depth": str("how many directories deep, up to 8 (optional)"),
|
|
"matches": str("the most matches to answer, up to 500 (optional)"),
|
|
"case": str("\"false\": match whatever the case (optional)")}, "text", "path"),
|
|
func(a map[string]any) (any, error) {
|
|
text, _ := a["text"].(string) // as given: a space at either end is part of what is found
|
|
ask := SearchAsk{Text: text, Path: arg(a, "path"), CaseInsensitive: arg(a, "case") == "false"}
|
|
var err error
|
|
if ask.Depth, err = number(a, "depth"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Matches, err = number(a, "matches"); err != nil {
|
|
return nil, err
|
|
}
|
|
return r.Search(ask)
|
|
}),
|
|
verb("account", "One account or group by name or number, as this node's account files say: an account's "+
|
|
"number, group, home, shell and every group it is in; a group's number and members. With neither, every "+
|
|
"account and group. Never a password field.",
|
|
object(map[string]any{"name": str("an account's name or number (optional)"),
|
|
"group": str("a group's name or number (optional)")}),
|
|
func(a map[string]any) (any, error) { return r.Account(arg(a, "name"), arg(a, "group")) }),
|
|
}
|
|
}
|