package resolution import ( "encoding/json" "fmt" "path/filepath" "sort" "strings" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) // readsVerbs is the contract every holder of node-reads serves (novox/hq ADR 0310 §2). The bounds are the // build's to set and each verb's description to state (ADR 0310, Consequences); the holder states them in // every answer it cuts. func readsVerbs() []mcat.Verb { return []mcat.Verb{ {Name: "stat", Description: "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\". Read as the holder's own account, which " + "holds no secret: a component it may not reach says so. At most 200 paths in one call.", Input: schema(map[string]string{ "paths": "one absolute path, or several separated by line breaks", "follow": "\"true\": also say where the path leads once every link is followed", }, []string{"paths"}, "follow"), Replaces: []string{"stat", "ls -ld", "namei -l", "findmnt -T"}}, {Name: "readlink", Description: "What one link points to, as written, and where the path leads once every " + "link is followed, and whether that exists.", Input: schema(map[string]string{"path": "an absolute path"}, []string{"path"}), Replaces: []string{"readlink", "readlink -f", "realpath"}}, {Name: "list", Description: "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.", Input: schema(map[string]string{"path": "an absolute path to a directory"}, []string{"path"}), Replaces: []string{"ls", "ls -la"}}, {Name: "read", Description: "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 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 holder's account may.", Input: schema(map[string]string{ "path": "an absolute path to a file", "from": "the first line to answer, counted from 1 (optional)", "lines": "how many lines to answer (optional)", "bytes": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", }, []string{"path"}), Replaces: []string{"cat", "head", "tail", "less"}}, {Name: "search", Description: "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 and 4 MiB per file; 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.", Input: schema(map[string]string{ "text": "the fixed string to find", "path": "an absolute path to a file or a directory", "depth": "how many directories deep, up to 8 (optional)", "matches": "the most matches to answer, up to 500 (optional)", "case": "\"false\": match whatever the case (optional)", }, []string{"text", "path"}, "case"), Replaces: []string{"grep", "grep -r", "grep -F"}}, {Name: "account", Description: "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.", Input: schema(map[string]string{ "name": "an account's name or number (optional)", "group": "a group's name or number (optional)", }, nil), Replaces: []string{"getent passwd", "getent group", "id", "groups"}}, } } // secretPlaces is every place on this node a secret is kept, as the placed manifests say (novox/hq ADR 0310 // §5): the directory of every module's own secret, every credential file a module is given and every grant // directory a provider is handed its consumers' credentials in, the place a module keeps the operator-sealed // secrets, and every directory of the module that provides `secret` — the vault — wherever the node places it, // a directory declared without a path included. Sorted, each once, and a place under another dropped, so the // list says each refusal once. // // **Failure is loud.** A place still holding a placeholder, or not absolute, fails the composition with the // module named: a list that silently shrank would be a refusal the decision promises and nothing does. func resolutionSecretPlaces(r mcat.Resolution, with mcat.Rendering) ([]string, error) { seen := map[string]bool{} var problems []string add := func(module, what, path string) { path = strings.TrimSpace(path) if path == "" { return } if !strings.HasPrefix(path, "/") || strings.Contains(path, "${") { problems = append(problems, fmt.Sprintf("%s's %s is %q, which is not a place on %s", module, what, path, r.Node)) return } path = filepath.Clean(path) if path == "/" { problems = append(problems, fmt.Sprintf("%s's %s is the root directory", module, what)) return } seen[path] = true } for _, m := range r.Modules { for name, own := range m.OwnSecrets { add(m.Module, "own secret "+name, own.Path) if strings.HasPrefix(own.Path, "/") { add(m.Module, "own secret "+name+"'s directory", filepath.Dir(own.Path)) } } for _, to := range m.SecretRequirements() { for _, f := range m.SecretFiles(to) { add(m.Module, "credential file for "+to, f.Path) } } for to, where := range m.Grants { add(m.Module, "grant directory for "+to, where) } add(m.Module, "place for the operator-sealed secrets (keeps)", m.Keeps) if providesSecret(m) { dirs := dirsFor(m, with) for _, id := range sortedKeys(dirs) { add(m.Module, "directory "+id, dirs[id]) } } } if len(problems) > 0 { sort.Strings(problems) return nil, fmt.Errorf("the holder of %s on %s cannot be told where a secret is kept, so it would read "+ "one (novox/hq ADR 0310 §5): %s", mcat.ReadsSeat, r.Node, strings.Join(problems, "; ")) } all := make([]string, 0, len(seen)) for p := range seen { all = append(all, p) } sort.Strings(all) var out []string for _, p := range all { if n := len(out); n > 0 && strings.HasPrefix(p, out[n-1]+"/") { continue } out = append(out, p) } return out, nil } func providesSecret(m mcat.Manifest) bool { for _, p := range m.Provides { if p.Name == "secret" { return true } } return false } // readsWords is what the holder of node-reads is given beside its own words: the node's secret places. func resolutionReadsWords(r mcat.Resolution, m mcat.Manifest, words map[string]string, with mcat.Rendering) (map[string]string, error) { if !m.ClaimsSeat(mcat.ReadsSeat) { return words, nil } places, err := resolutionSecretPlaces(r, with) if err != nil { return nil, err } body, err := json.Marshal(places) if err != nil { return nil, err } if words == nil { words = map[string]string{} } if _, has := words[mcat.ReadsSecretPlaces]; has { return nil, fmt.Errorf("%s gives its own %s; the node's secret places are the mesh's to say (novox/hq ADR 0310 §5)", m.Module, mcat.ReadsSecretPlaces) } words[mcat.ReadsSecretPlaces] = string(body) return words, nil }