mesh-reads: read a node's files and accounts as an account that holds no secret (hq ADR 0310, issue 418)
Reading a file, searching one, listing a directory, the owner and mode of each component of a path and looking up an account went through the login shell's execute as the operator's account, which sees the operator's keys and the agent's credentials, and execute is not served on the control-node. This module holds node-reads on every node, runs as mesh-reads (no login, no group beside its own), refuses the node's secret places by name before any read (the controller's list and the known credential files), withholds any line shaped like a secret through mesh-sdk's outward rule, and bounds every answer.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# mesh-reads
|
||||
|
||||
A node's files and accounts, read through the mesh (novox/hq ADR 0310, extending ADR 0268 §5; hq to-be 41
|
||||
WP8; issue 418). It holds the node seat `node-reads` on every node, the control-node included, so reading a
|
||||
file, searching one, listing a directory, the owner and mode of each component of a path and looking up an
|
||||
account or a group never go through the login shell's `execute`, and an approval is asked for an act, never
|
||||
for a read.
|
||||
|
||||
## Who it reads as
|
||||
|
||||
The account `mesh-reads`, which this module makes on every node (`runs-as`, ADR 0259 §8): no login shell, a
|
||||
home of its own under `/var/lib`, and no group beside its own. The tool runner serves it in a runtime of its
|
||||
own, on its own bus credential, never the machine's runtime, which runs as the operator's account. So the
|
||||
node's file modes bound what it sees: a file only the operator, root or a module's own account may read is
|
||||
refused by its mode, and the answer says so. It refuses every read when it finds itself running as root.
|
||||
|
||||
## What it refuses by name
|
||||
|
||||
Before any read, whatever the file modes would allow (ADR 0310 §5):
|
||||
|
||||
- **The places the mesh keeps a secret on this node**, which the controller composes from the manifests
|
||||
and gives it as `MESH_SECRET_PLACES`: the directory of every module's own secret (its bus credential
|
||||
among them), every credential file and grant directory, the vault's directories, and where the
|
||||
operator-sealed secrets are kept. Without that list the module does not start.
|
||||
- **The known credential files of a machine's programs** (`refuse.go`): an account's `.ssh`, `.gnupg`,
|
||||
`.aws`, `.kube`; the agent's `.credentials.json`, `.netrc`, `.pgpass`, `.git-credentials`; private key
|
||||
names (`id_ed25519`, `*.key`, `ssh_host_*_key`); `/etc/shadow`; `/etc/ssl/private`, `/etc/wireguard`
|
||||
and the network manager's connections.
|
||||
|
||||
A link is judged by where it leads as well as by its name. `stat` and `readlink` say a secret place's owner
|
||||
and mode, and mark it, because those are not its content; `list`, `read` and `search` refuse it.
|
||||
|
||||
## The content rule
|
||||
|
||||
Every answer passes the content rule's secret class, from `mesh-sdk`'s `outward` package (ADR 0310 §4): a
|
||||
line of a file or a matching line shaped like a secret is withheld and its number said, as is a link target
|
||||
or an account's words. A path and an entry's name are not judged: they are what the caller named or what the
|
||||
file system calls a thing, and the rule would take a temporary directory's or a container layer's name for a
|
||||
token.
|
||||
|
||||
## Its verbs and their bounds
|
||||
|
||||
| verb | replaces | bounds |
|
||||
|---|---|---|
|
||||
| `stat` | `stat`, `ls -ld`, `namei -l`, `findmnt -T` | 200 paths, 64 components each; links followed only with `follow` |
|
||||
| `readlink` | `readlink`, `readlink -f`, `realpath` | one path |
|
||||
| `list` | `ls`, `ls -la` | 2000 entries, cut and said |
|
||||
| `read` | `cat`, `head`, `tail`, `less` | 64 KiB, or up to 256 KiB when asked; cut at a line and said; text only |
|
||||
| `search` | `grep`, `grep -r`, `grep -F` | a fixed string; 100 matches (500 asked), 4 deep (8 asked), 5000 files, 4 MiB a file, 20 seconds; the bound met is said |
|
||||
| `account` | `getent passwd`, `getent group`, `id`, `groups` | 2000 accounts and groups; never a password field |
|
||||
|
||||
No answer is larger than 1 MiB. Nothing here writes a file.
|
||||
@@ -0,0 +1,204 @@
|
||||
// mesh-reads (novox/hq ADR 0310, to-be 41 WP8): the holder of node-reads. A node's files and accounts read
|
||||
// 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 — so these reads never go through the login shell's execute.
|
||||
//
|
||||
// It runs as an account of its own, mesh-reads, served by the tool runner in a runtime of its own (ADR 0259
|
||||
// §8): in no group that holds a secret, with no login shell and no home of the operator's, so the node's file
|
||||
// modes bound what it sees. On top, it refuses by name the places a secret is kept (refuse.go) and withholds
|
||||
// any piece of an answer shaped like a secret (mesh-sdk's outward). Read-only: nothing here writes a file.
|
||||
//
|
||||
// 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-reads"
|
||||
|
||||
// PlacesWord is where the controller names this node's secret places (novox/hq ADR 0310 §5).
|
||||
const PlacesWord = "MESH_SECRET_PLACES"
|
||||
|
||||
func main() {
|
||||
places, err := placesFrom(os.Getenv(PlacesWord))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-reads] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := stdio.Serve("", tools(newReader(places))); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-reads] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 0310 §5): a controller that knows node-reads 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 0310): 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-reads 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}
|
||||
}
|
||||
|
||||
func tools(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\". Read as the account mesh-reads, which holds no "+
|
||||
"secret: a component it may not reach says so. At most 200 paths in one call.",
|
||||
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 mesh-reads may.",
|
||||
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; 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")) }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ADR 0310 §3: the account the reads run as is the module's own, made by the mesh, with no login shell, no
|
||||
// home of the operator's and no group beside its own, and the module runs as it.
|
||||
func TestTheAccountHoldsNoSecretsGroupAndNoLogin(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
RunsAs string `json:"runs-as"`
|
||||
SecretsOwner string `json:"secrets-owner"`
|
||||
Claims []map[string]any `json:"claims"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.RunsAs != "mesh-reads" || m.SecretsOwner != "mesh-reads" {
|
||||
t.Errorf("runs as %q, secrets owned by %q", m.RunsAs, m.SecretsOwner)
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0]["name"] != Seat {
|
||||
t.Errorf("claims %v", m.Claims)
|
||||
}
|
||||
found := false
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] != "user" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r["name"] != m.RunsAs {
|
||||
t.Errorf("a user other than its own: %v", r)
|
||||
}
|
||||
if r["shell"] != "/usr/bin/nologin" {
|
||||
t.Errorf("the account has a login shell: %v", r["shell"])
|
||||
}
|
||||
if home, _ := r["home"].(string); !strings.HasPrefix(home, "/var/lib/") {
|
||||
t.Errorf("the account's home is %q, not one of the mesh's", home)
|
||||
}
|
||||
if _, grouped := r["groups"]; grouped {
|
||||
t.Errorf("the account is put in groups: %v", r["groups"])
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the module makes no account of its own")
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] != "user" && r["type"] != "directory" {
|
||||
t.Errorf("a read-only holder declares a %v: %v", r["type"], r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The bounds (novox/hq ADR 0310, Consequences: set by the build and stated in each verb's description). Every
|
||||
// answer stays under MaxAnswer, which the tools check once more after an answer is made.
|
||||
const (
|
||||
MaxAnswer = 1 << 20 // no answer of this module is larger, whatever the bounds below allow
|
||||
MaxPaths = 200 // paths one stat answers
|
||||
MaxComponents = 64 // components of one path
|
||||
MaxEntries = 2000 // entries one list answers
|
||||
DefaultReadSize = 64 << 10 // bytes a read answers unless more is asked
|
||||
MaxReadSize = 256 << 10 // bytes a read answers at most
|
||||
MaxScan = 64 << 20 // bytes a read passes over to reach the lines asked
|
||||
DefaultMatches = 100
|
||||
MaxMatches = 500
|
||||
DefaultDepth = 4
|
||||
MaxDepth = 8
|
||||
MaxFiles = 5000 // files one search opens
|
||||
MaxFileScan = 4 << 20 // bytes one search reads of one file
|
||||
MaxLine = 300 // characters of one matching line answered
|
||||
SearchTime = 20 * time.Second
|
||||
MaxAccounts = 2000
|
||||
)
|
||||
|
||||
// Reader reads one node's files and accounts as the account it runs as.
|
||||
type Reader struct {
|
||||
Refuser
|
||||
// Passwd, Group and MountInfo are the files accounts, groups and mount points are read from.
|
||||
Passwd, Group, MountInfo string
|
||||
// Euid is the account this runs as; never root (ADR 0310 §3).
|
||||
Euid func() int
|
||||
// Now is the clock a search is bounded by.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func newReader(places []string) Reader {
|
||||
return Reader{Refuser: Refuser{Places: places}, Passwd: "/etc/passwd", Group: "/etc/group",
|
||||
MountInfo: "/proc/self/mountinfo", Euid: os.Geteuid, Now: time.Now}
|
||||
}
|
||||
|
||||
// asItsOwn refuses every read when this runs as root: what it may see is bounded by its account's file
|
||||
// modes, and root's are none (novox/hq ADR 0310 §3). Loud, never a silent widening.
|
||||
func (r Reader) asItsOwn() error {
|
||||
if r.Euid() == 0 {
|
||||
return errors.New("mesh-reads runs as root here, and its reads are bounded by an account of its own that " +
|
||||
"holds no secret (novox/hq ADR 0310 §3): it answers nothing until it runs as mesh-reads")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func absolute(path string) (string, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return "", fmt.Errorf("%q is not an absolute path: name it from /", path)
|
||||
}
|
||||
return filepath.Clean(path), nil
|
||||
}
|
||||
|
||||
// why turns a file error into the words an answer says: what stopped the read, never a Go error's text.
|
||||
func why(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return "it does not exist"
|
||||
case errors.Is(err, fs.ErrPermission):
|
||||
return "the account mesh-reads may not reach it (its file modes refuse it)"
|
||||
case errors.Is(err, syscall.ELOOP):
|
||||
return "its links loop"
|
||||
case errors.Is(err, syscall.ENOTDIR):
|
||||
return "a component of it is not a directory"
|
||||
}
|
||||
var pe *fs.PathError
|
||||
if errors.As(err, &pe) {
|
||||
return pe.Err.Error()
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// ---- stat ----
|
||||
|
||||
// Component is one component of a path as lstat sees it.
|
||||
type Component struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
UID *int `json:"uid,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
GID *int `json:"gid,omitempty"`
|
||||
Mode string `json:"mode,omitempty"` // octal, with the setuid, setgid and sticky bits
|
||||
Perms string `json:"perms,omitempty"` // as ls shows them
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Link string `json:"link,omitempty"` // what a link points to, as written
|
||||
Mount *Mount `json:"mount,omitempty"`
|
||||
Secret string `json:"secret-place,omitempty"` // why a read under it is refused
|
||||
Unreachd string `json:"unreachable,omitempty"`
|
||||
}
|
||||
|
||||
// Mount is a mount point: its file system and source.
|
||||
type Mount struct {
|
||||
FS string `json:"fs"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// PathStat is one path's components, from / down, and where it leads once every link is followed.
|
||||
type PathStat struct {
|
||||
Path string `json:"path"`
|
||||
Components []Component `json:"components"`
|
||||
LeadsTo string `json:"leads-to,omitempty"`
|
||||
Leads *Component `json:"there,omitempty"`
|
||||
Stopped string `json:"stopped,omitempty"`
|
||||
}
|
||||
|
||||
// StatAnswer is what stat answers.
|
||||
type StatAnswer struct {
|
||||
Paths []PathStat `json:"paths"`
|
||||
Withheld []Withheld `json:"withheld,omitempty"`
|
||||
}
|
||||
|
||||
func (r Reader) Stat(paths []string, follow bool) (StatAnswer, error) {
|
||||
if err := r.asItsOwn(); err != nil {
|
||||
return StatAnswer{}, err
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return StatAnswer{}, errors.New("name a path to stat")
|
||||
}
|
||||
if len(paths) > MaxPaths {
|
||||
return StatAnswer{}, fmt.Errorf("%d paths asked, and one stat answers at most %d: ask in parts", len(paths), MaxPaths)
|
||||
}
|
||||
names := r.names()
|
||||
mounts := r.mounts()
|
||||
var out StatAnswer
|
||||
for _, raw := range paths {
|
||||
path, err := absolute(raw)
|
||||
if err != nil {
|
||||
return StatAnswer{}, err
|
||||
}
|
||||
ps := PathStat{Path: path}
|
||||
walk := []string{"/"}
|
||||
acc := ""
|
||||
for _, part := range strings.Split(strings.TrimPrefix(path, "/"), "/") {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
acc += "/" + part
|
||||
walk = append(walk, acc)
|
||||
}
|
||||
if len(walk) > MaxComponents {
|
||||
return StatAnswer{}, fmt.Errorf("%s has %d components, and a stat answers at most %d", path, len(walk), MaxComponents)
|
||||
}
|
||||
for _, p := range walk {
|
||||
c := r.component(p, names, mounts)
|
||||
ps.Components = append(ps.Components, c)
|
||||
if c.Unreachd != "" {
|
||||
ps.Stopped = c.Path + ": " + c.Unreachd
|
||||
break
|
||||
}
|
||||
}
|
||||
if follow && ps.Stopped == "" {
|
||||
if leads, err := filepath.EvalSymlinks(path); err != nil {
|
||||
ps.Stopped = "following its links: " + why(err)
|
||||
} else {
|
||||
ps.LeadsTo = leads
|
||||
if leads != path {
|
||||
c := r.component(leads, names, mounts)
|
||||
ps.Leads = &c
|
||||
}
|
||||
}
|
||||
}
|
||||
out.Paths = append(out.Paths, ps)
|
||||
}
|
||||
out.Withheld = cleanStat(&out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r Reader) component(p string, names accounts, mounts map[string]Mount) Component {
|
||||
c := Component{Path: p}
|
||||
info, err := os.Lstat(p)
|
||||
if err != nil {
|
||||
c.Unreachd = why(err)
|
||||
return c
|
||||
}
|
||||
c.Type = typeOf(info.Mode())
|
||||
c.Perms = info.Mode().String()
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
uid, gid := int(st.Uid), int(st.Gid)
|
||||
c.UID, c.GID = &uid, &gid
|
||||
c.Owner, c.Group = names.user(uid), names.group(gid)
|
||||
c.Mode = fmt.Sprintf("%04o", st.Mode&07777)
|
||||
}
|
||||
if info.Mode().IsRegular() {
|
||||
size := info.Size()
|
||||
c.Size = &size
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
if target, err := os.Readlink(p); err == nil {
|
||||
c.Link = target
|
||||
}
|
||||
}
|
||||
if m, ok := mounts[p]; ok {
|
||||
c.Mount = &m
|
||||
}
|
||||
if why := r.Refused(p); why != "" {
|
||||
c.Secret = why
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func typeOf(m fs.FileMode) string {
|
||||
switch {
|
||||
case m.IsDir():
|
||||
return "directory"
|
||||
case m&fs.ModeSymlink != 0:
|
||||
return "link"
|
||||
case m.IsRegular():
|
||||
return "file"
|
||||
case m&fs.ModeNamedPipe != 0:
|
||||
return "fifo"
|
||||
case m&fs.ModeSocket != 0:
|
||||
return "socket"
|
||||
case m&fs.ModeCharDevice != 0:
|
||||
return "character device"
|
||||
case m&fs.ModeDevice != 0:
|
||||
return "block device"
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
func cleanStat(a *StatAnswer) []Withheld {
|
||||
var w []Withheld
|
||||
cleanC := func(c *Component) {
|
||||
for _, f := range []*string{&c.Link} {
|
||||
if s, what := clean(*f); what != "" {
|
||||
w = append(w, Withheld{At: "a link's target", Class: what})
|
||||
*f = s
|
||||
}
|
||||
}
|
||||
if c.Mount != nil {
|
||||
if s, what := clean(c.Mount.Source); what != "" {
|
||||
w = append(w, Withheld{At: "a mount's source", Class: what})
|
||||
c.Mount.Source = s
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range a.Paths {
|
||||
p := &a.Paths[i]
|
||||
for j := range p.Components {
|
||||
cleanC(&p.Components[j])
|
||||
}
|
||||
if p.Leads != nil {
|
||||
cleanC(p.Leads)
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// ---- readlink ----
|
||||
|
||||
// LinkAnswer is what readlink answers.
|
||||
type LinkAnswer struct {
|
||||
Path string `json:"path"`
|
||||
Link string `json:"link,omitempty"` // as written; empty when the path is not a link
|
||||
IsLink bool `json:"is-link"`
|
||||
LeadsTo string `json:"leads-to,omitempty"`
|
||||
Exists bool `json:"exists"`
|
||||
Stopped string `json:"stopped,omitempty"`
|
||||
Withheld []Withheld `json:"withheld,omitempty"`
|
||||
}
|
||||
|
||||
func (r Reader) Readlink(raw string) (LinkAnswer, error) {
|
||||
if err := r.asItsOwn(); err != nil {
|
||||
return LinkAnswer{}, err
|
||||
}
|
||||
path, err := absolute(raw)
|
||||
if err != nil {
|
||||
return LinkAnswer{}, err
|
||||
}
|
||||
a := LinkAnswer{Path: path}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
a.Stopped = why(err)
|
||||
return a, nil
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
a.IsLink = true
|
||||
if a.Link, err = os.Readlink(path); err != nil {
|
||||
a.Stopped = why(err)
|
||||
}
|
||||
}
|
||||
if leads, err := filepath.EvalSymlinks(path); err == nil {
|
||||
a.LeadsTo, a.Exists = leads, true
|
||||
} else if a.Stopped == "" {
|
||||
a.Stopped = "following its links: " + why(err)
|
||||
}
|
||||
for _, f := range []*string{&a.Link} {
|
||||
if s, what := clean(*f); what != "" {
|
||||
a.Withheld = append(a.Withheld, Withheld{At: "a link's target", Class: what})
|
||||
*f = s
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ---- list ----
|
||||
|
||||
// Entry is one entry of a directory.
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Link string `json:"link,omitempty"`
|
||||
}
|
||||
|
||||
// ListAnswer is what list answers.
|
||||
type ListAnswer struct {
|
||||
Path string `json:"path"`
|
||||
Entries []Entry `json:"entries"`
|
||||
Count int `json:"count"`
|
||||
Cut string `json:"cut,omitempty"`
|
||||
Withheld []Withheld `json:"withheld,omitempty"`
|
||||
}
|
||||
|
||||
// refusedRead is a refusal by name, before any read: the path asked and where it leads are both judged, so a
|
||||
// link to a secret place is refused as the place is.
|
||||
func (r Reader) refusedRead(path string) error {
|
||||
if why := r.Refused(path); why != "" {
|
||||
return fmt.Errorf("%s is refused by name: %s (novox/hq ADR 0310 §5)", path, why)
|
||||
}
|
||||
if leads, err := filepath.EvalSymlinks(path); err == nil && leads != path {
|
||||
if why := r.Refused(leads); why != "" {
|
||||
return fmt.Errorf("%s leads to %s, which is refused by name: %s (novox/hq ADR 0310 §5)", path, leads, why)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r Reader) List(raw string) (ListAnswer, error) {
|
||||
if err := r.asItsOwn(); err != nil {
|
||||
return ListAnswer{}, err
|
||||
}
|
||||
path, err := absolute(raw)
|
||||
if err != nil {
|
||||
return ListAnswer{}, err
|
||||
}
|
||||
if err := r.refusedRead(path); err != nil {
|
||||
return ListAnswer{}, err
|
||||
}
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return ListAnswer{}, fmt.Errorf("%s cannot be listed: %s", path, why(err))
|
||||
}
|
||||
names := r.names()
|
||||
a := ListAnswer{Path: path, Count: len(entries), Entries: []Entry{}}
|
||||
if len(entries) > MaxEntries {
|
||||
a.Cut = fmt.Sprintf("cut at %d of %d entries (the bound of one list), in name order", MaxEntries, len(entries))
|
||||
entries = entries[:MaxEntries]
|
||||
}
|
||||
for _, e := range entries {
|
||||
en := Entry{Name: e.Name(), Type: typeOf(e.Type())}
|
||||
if info, err := e.Info(); err == nil {
|
||||
en.Type = typeOf(info.Mode())
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
en.Owner, en.Group = names.user(int(st.Uid)), names.group(int(st.Gid))
|
||||
en.Mode = fmt.Sprintf("%04o", st.Mode&07777)
|
||||
}
|
||||
if info.Mode().IsRegular() {
|
||||
size := info.Size()
|
||||
en.Size = &size
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
en.Link, _ = os.Readlink(filepath.Join(path, e.Name()))
|
||||
}
|
||||
}
|
||||
for _, f := range []*string{&en.Link} {
|
||||
if s, what := clean(*f); what != "" {
|
||||
a.Withheld = append(a.Withheld, Withheld{At: "a link's target", Class: what})
|
||||
*f = s
|
||||
}
|
||||
}
|
||||
a.Entries = append(a.Entries, en)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ---- read ----
|
||||
|
||||
// ReadAnswer is what read answers.
|
||||
type ReadAnswer struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"` // as the file says; 0 for most of /proc and /sys
|
||||
From int `json:"from"` // the first line answered, counted from 1
|
||||
Lines int `json:"lines"`
|
||||
Text string `json:"text"`
|
||||
Cut string `json:"cut,omitempty"`
|
||||
Withheld []int `json:"withheld-lines,omitempty"`
|
||||
Why string `json:"withheld-because,omitempty"`
|
||||
}
|
||||
|
||||
// ReadAsk is what a read asks: the lines from From (1 is the first; a negative From counts from the end, as
|
||||
// tail does), at most Lines of them (0: as many as fit), and at most Bytes bytes.
|
||||
type ReadAsk struct {
|
||||
From, Lines, Bytes int
|
||||
}
|
||||
|
||||
func (r Reader) Read(raw string, ask ReadAsk) (ReadAnswer, error) {
|
||||
if err := r.asItsOwn(); err != nil {
|
||||
return ReadAnswer{}, err
|
||||
}
|
||||
path, err := absolute(raw)
|
||||
if err != nil {
|
||||
return ReadAnswer{}, err
|
||||
}
|
||||
if err := r.refusedRead(path); err != nil {
|
||||
return ReadAnswer{}, err
|
||||
}
|
||||
bound := ask.Bytes
|
||||
switch {
|
||||
case bound <= 0:
|
||||
bound = DefaultReadSize
|
||||
case bound > MaxReadSize:
|
||||
return ReadAnswer{}, fmt.Errorf("%d bytes asked, and one read answers at most %d", bound, MaxReadSize)
|
||||
}
|
||||
if ask.Lines < 0 {
|
||||
return ReadAnswer{}, errors.New("lines is a count: name how many, or none for as many as fit")
|
||||
}
|
||||
// Non-blocking, so a fifo answers rather than waits; and judged by what was opened, not by its name.
|
||||
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return ReadAnswer{}, fmt.Errorf("%s cannot be read: %s", path, why(err))
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return ReadAnswer{}, fmt.Errorf("%s cannot be read: %s", path, why(err))
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return ReadAnswer{}, fmt.Errorf("%s is a %s, not a file: list a directory, stat anything else", path, typeOf(info.Mode()))
|
||||
}
|
||||
a := ReadAnswer{Path: path, Size: info.Size()}
|
||||
lines, cut, err := pick(io.LimitReader(f, MaxScan), ask, bound)
|
||||
if err != nil {
|
||||
return ReadAnswer{}, fmt.Errorf("%s: %v", path, err)
|
||||
}
|
||||
a.Cut = cut
|
||||
if len(lines) > 0 {
|
||||
a.From = lines[0].n
|
||||
}
|
||||
var text strings.Builder
|
||||
for _, l := range lines {
|
||||
s, what := clean(l.text)
|
||||
if what != "" {
|
||||
a.Withheld = append(a.Withheld, l.n)
|
||||
a.Why = "a line shaped like a secret is withheld by the content rule (novox/hq ADR 0310 §4)"
|
||||
}
|
||||
text.WriteString(s)
|
||||
if l.newline {
|
||||
text.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
a.Text, a.Lines = text.String(), len(lines)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
type line struct {
|
||||
n int
|
||||
text string
|
||||
newline bool
|
||||
size int
|
||||
}
|
||||
|
||||
// pick reads lines and keeps the ones asked, within bound bytes. A NUL byte is a binary file, refused.
|
||||
func pick(in io.Reader, ask ReadAsk, bound int) ([]line, string, error) {
|
||||
br := bufio.NewReaderSize(in, 64<<10)
|
||||
var kept []line
|
||||
size, n := 0, 0
|
||||
from := ask.From
|
||||
if from == 0 {
|
||||
from = 1
|
||||
}
|
||||
for {
|
||||
raw, err := br.ReadString('\n')
|
||||
if raw == "" && err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
n++
|
||||
if strings.IndexByte(raw, 0) >= 0 {
|
||||
return nil, "", errors.New("it is a binary file, and read answers text")
|
||||
}
|
||||
l := line{n: n, text: strings.TrimSuffix(raw, "\n"), newline: strings.HasSuffix(raw, "\n"), size: len(raw)}
|
||||
if from > 0 {
|
||||
if n < from {
|
||||
continue
|
||||
}
|
||||
if ask.Lines > 0 && len(kept) == ask.Lines {
|
||||
break
|
||||
}
|
||||
if size+len(raw) > bound {
|
||||
return kept, fmt.Sprintf("cut at line %d: the answer holds at most %d bytes", n, bound), nil
|
||||
}
|
||||
kept = append(kept, l)
|
||||
size += len(raw)
|
||||
} else {
|
||||
// From the end, as tail does: the last -from lines, kept as they pass.
|
||||
kept = append(kept, l)
|
||||
size += len(raw)
|
||||
for len(kept) > -from || size > bound {
|
||||
size -= kept[0].size
|
||||
kept = kept[1:]
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
if from > 0 && n < from && n > 0 {
|
||||
return nil, "", fmt.Errorf("it has %d lines, and line %d was asked", n, from)
|
||||
}
|
||||
if from < 0 && ask.Lines > 0 && len(kept) > ask.Lines {
|
||||
kept = kept[:ask.Lines]
|
||||
}
|
||||
return kept, "", nil
|
||||
}
|
||||
|
||||
// ---- search ----
|
||||
|
||||
// Match is one matching line.
|
||||
type Match struct {
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// SearchAnswer is what search answers.
|
||||
type SearchAnswer struct {
|
||||
Text string `json:"text"`
|
||||
Path string `json:"path"`
|
||||
Matches []Match `json:"matches"`
|
||||
Files int `json:"files-read"`
|
||||
Bound string `json:"bound-met,omitempty"`
|
||||
Skipped []string `json:"refused-by-name,omitempty"`
|
||||
Unreadable int `json:"unreadable,omitempty"`
|
||||
Binary int `json:"binary-skipped,omitempty"`
|
||||
Withheld []Withheld `json:"withheld,omitempty"`
|
||||
}
|
||||
|
||||
// SearchAsk is what a search asks.
|
||||
type SearchAsk struct {
|
||||
Text, Path string
|
||||
Depth, Matches int
|
||||
CaseInsensitive bool
|
||||
}
|
||||
|
||||
var errBound = errors.New("bound met")
|
||||
|
||||
func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) {
|
||||
if err := r.asItsOwn(); err != nil {
|
||||
return SearchAnswer{}, err
|
||||
}
|
||||
if ask.Text == "" {
|
||||
return SearchAnswer{}, errors.New("name the text to find")
|
||||
}
|
||||
if strings.ContainsAny(ask.Text, "\n\x00") {
|
||||
return SearchAnswer{}, errors.New("the text is found within one line: it may not hold a line break")
|
||||
}
|
||||
root, err := absolute(ask.Path)
|
||||
if err != nil {
|
||||
return SearchAnswer{}, err
|
||||
}
|
||||
if err := r.refusedRead(root); err != nil {
|
||||
return SearchAnswer{}, err
|
||||
}
|
||||
depth, matches := ask.Depth, ask.Matches
|
||||
switch {
|
||||
case depth == 0:
|
||||
depth = DefaultDepth
|
||||
case depth < 0 || depth > MaxDepth:
|
||||
return SearchAnswer{}, fmt.Errorf("depth %d asked; a search goes at most %d directories deep", depth, MaxDepth)
|
||||
}
|
||||
switch {
|
||||
case matches == 0:
|
||||
matches = DefaultMatches
|
||||
case matches < 0 || matches > MaxMatches:
|
||||
return SearchAnswer{}, fmt.Errorf("%d matches asked; a search answers at most %d", matches, MaxMatches)
|
||||
}
|
||||
needle := ask.Text
|
||||
if ask.CaseInsensitive {
|
||||
needle = strings.ToLower(needle)
|
||||
}
|
||||
a := SearchAnswer{Text: ask.Text, Path: root, Matches: []Match{}}
|
||||
deadline := r.Now().Add(SearchTime)
|
||||
base := strings.Count(root, "/")
|
||||
if root == "/" {
|
||||
base = 0
|
||||
}
|
||||
err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if r.Now().After(deadline) {
|
||||
a.Bound = fmt.Sprintf("time: a search runs at most %s", SearchTime)
|
||||
return errBound
|
||||
}
|
||||
if err != nil {
|
||||
a.Unreadable++
|
||||
if d != nil && d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if p != root {
|
||||
if why := r.Refused(p); why != "" {
|
||||
a.Skipped = append(a.Skipped, p)
|
||||
if d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if d.IsDir() {
|
||||
if p != root && strings.Count(p, "/")-base > depth {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil // links are not followed; a device, a fifo or a socket is not text
|
||||
}
|
||||
if a.Files >= MaxFiles {
|
||||
a.Bound = fmt.Sprintf("files: a search reads at most %d files", MaxFiles)
|
||||
return errBound
|
||||
}
|
||||
a.Files++
|
||||
return r.searchFile(p, needle, ask.CaseInsensitive, matches, &a)
|
||||
})
|
||||
if err != nil && !errors.Is(err, errBound) {
|
||||
return SearchAnswer{}, fmt.Errorf("%s cannot be searched: %s", root, why(err))
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r Reader) searchFile(p, needle string, fold bool, bound int, a *SearchAnswer) error {
|
||||
f, err := os.OpenFile(p, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
a.Unreadable++
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
br := bufio.NewReaderSize(io.LimitReader(f, MaxFileScan), 64<<10)
|
||||
if head, _ := br.Peek(8 << 10); bytes.IndexByte(head, 0) >= 0 {
|
||||
a.Binary++
|
||||
return nil
|
||||
}
|
||||
n := 0
|
||||
for {
|
||||
raw, err := br.ReadString('\n')
|
||||
if raw == "" && err != nil {
|
||||
return nil
|
||||
}
|
||||
n++
|
||||
hay := raw
|
||||
if fold {
|
||||
hay = strings.ToLower(hay)
|
||||
}
|
||||
if strings.Contains(hay, needle) {
|
||||
if len(a.Matches) >= bound {
|
||||
a.Bound = fmt.Sprintf("matches: %d answered, as asked or the default", bound)
|
||||
return errBound
|
||||
}
|
||||
text := strings.TrimRight(raw, "\r\n")
|
||||
if r := []rune(text); len(r) > MaxLine {
|
||||
text = string(r[:MaxLine]) + "…"
|
||||
}
|
||||
file := p
|
||||
for _, f := range []*string{&text} {
|
||||
if s, what := clean(*f); what != "" {
|
||||
a.Withheld = append(a.Withheld, Withheld{At: "a matching line", Class: what})
|
||||
*f = s
|
||||
}
|
||||
}
|
||||
a.Matches = append(a.Matches, Match{File: file, Line: n, Text: text})
|
||||
}
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- accounts ----
|
||||
|
||||
// Account is one account as the account file says it, never its password field.
|
||||
type Account struct {
|
||||
Name string `json:"name"`
|
||||
UID int `json:"uid"`
|
||||
GID int `json:"gid"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Gecos string `json:"gecos,omitempty"`
|
||||
Home string `json:"home"`
|
||||
Shell string `json:"shell"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// GroupEntry is one group.
|
||||
type GroupEntry struct {
|
||||
Name string `json:"name"`
|
||||
GID int `json:"gid"`
|
||||
Members []string `json:"members"`
|
||||
}
|
||||
|
||||
// AccountAnswer is what account answers.
|
||||
type AccountAnswer struct {
|
||||
Accounts []Account `json:"accounts,omitempty"`
|
||||
Groups []GroupEntry `json:"groups,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Cut string `json:"cut,omitempty"`
|
||||
Withheld []Withheld `json:"withheld,omitempty"`
|
||||
}
|
||||
|
||||
type accounts struct {
|
||||
users []Account
|
||||
groups []GroupEntry
|
||||
}
|
||||
|
||||
func (a accounts) user(uid int) string {
|
||||
for _, u := range a.users {
|
||||
if u.UID == uid {
|
||||
return u.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a accounts) group(gid int) string {
|
||||
for _, g := range a.groups {
|
||||
if g.GID == gid {
|
||||
return g.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// names reads the account files; a file it cannot read gives no names, and a component says only numbers.
|
||||
func (r Reader) names() accounts {
|
||||
var a accounts
|
||||
if raw, err := os.ReadFile(r.Passwd); err == nil {
|
||||
for _, l := range strings.Split(string(raw), "\n") {
|
||||
f := strings.Split(l, ":")
|
||||
if len(f) < 7 || strings.HasPrefix(l, "#") {
|
||||
continue
|
||||
}
|
||||
uid, e1 := strconv.Atoi(f[2])
|
||||
gid, e2 := strconv.Atoi(f[3])
|
||||
if e1 != nil || e2 != nil {
|
||||
continue
|
||||
}
|
||||
a.users = append(a.users, Account{Name: f[0], UID: uid, GID: gid, Gecos: f[4], Home: f[5], Shell: f[6]})
|
||||
}
|
||||
}
|
||||
if raw, err := os.ReadFile(r.Group); err == nil {
|
||||
for _, l := range strings.Split(string(raw), "\n") {
|
||||
f := strings.Split(l, ":")
|
||||
if len(f) < 4 || strings.HasPrefix(l, "#") {
|
||||
continue
|
||||
}
|
||||
gid, err := strconv.Atoi(f[2])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
members := []string{}
|
||||
for _, m := range strings.Split(f[3], ",") {
|
||||
if m = strings.TrimSpace(m); m != "" {
|
||||
members = append(members, m)
|
||||
}
|
||||
}
|
||||
a.groups = append(a.groups, GroupEntry{Name: f[0], GID: gid, Members: members})
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (r Reader) Account(name, group string) (AccountAnswer, error) {
|
||||
if err := r.asItsOwn(); err != nil {
|
||||
return AccountAnswer{}, err
|
||||
}
|
||||
all := r.names()
|
||||
if len(all.users) == 0 {
|
||||
return AccountAnswer{}, fmt.Errorf("%s gave no account: it is unreadable or empty", r.Passwd)
|
||||
}
|
||||
out := AccountAnswer{Source: r.Passwd + ", " + r.Group + " (an account kept elsewhere, such as a directory " +
|
||||
"service or systemd's dynamic accounts, is not here)"}
|
||||
complete := func(u Account) Account {
|
||||
u.Group = all.group(u.GID)
|
||||
for _, g := range all.groups {
|
||||
if g.GID == u.GID {
|
||||
u.Groups = append(u.Groups, g.Name)
|
||||
continue
|
||||
}
|
||||
for _, m := range g.Members {
|
||||
if m == u.Name {
|
||||
u.Groups = append(u.Groups, g.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return u
|
||||
}
|
||||
name, group = strings.TrimSpace(name), strings.TrimSpace(group)
|
||||
switch {
|
||||
case name != "":
|
||||
for _, u := range all.users {
|
||||
if u.Name == name || strconv.Itoa(u.UID) == name {
|
||||
out.Accounts = append(out.Accounts, complete(u))
|
||||
}
|
||||
}
|
||||
if len(out.Accounts) == 0 {
|
||||
return AccountAnswer{}, fmt.Errorf("no account %q on this node", name)
|
||||
}
|
||||
case group != "":
|
||||
for _, g := range all.groups {
|
||||
if g.Name == group || strconv.Itoa(g.GID) == group {
|
||||
// Its members as getent says them, and every account whose own group it is.
|
||||
for _, u := range all.users {
|
||||
if u.GID == g.GID && !contains(g.Members, u.Name) {
|
||||
g.Members = append(g.Members, u.Name)
|
||||
}
|
||||
}
|
||||
out.Groups = append(out.Groups, g)
|
||||
}
|
||||
}
|
||||
if len(out.Groups) == 0 {
|
||||
return AccountAnswer{}, fmt.Errorf("no group %q on this node", group)
|
||||
}
|
||||
default:
|
||||
for _, u := range all.users {
|
||||
out.Accounts = append(out.Accounts, complete(u))
|
||||
}
|
||||
out.Groups = all.groups
|
||||
if len(out.Accounts) > MaxAccounts {
|
||||
out.Cut = fmt.Sprintf("cut at %d of %d accounts", MaxAccounts, len(out.Accounts))
|
||||
out.Accounts = out.Accounts[:MaxAccounts]
|
||||
}
|
||||
if len(out.Groups) > MaxAccounts {
|
||||
out.Cut = strings.TrimPrefix(out.Cut+"; ", "; ") + fmt.Sprintf("cut at %d of %d groups", MaxAccounts, len(out.Groups))
|
||||
out.Groups = out.Groups[:MaxAccounts]
|
||||
}
|
||||
}
|
||||
for i := range out.Accounts {
|
||||
u := &out.Accounts[i]
|
||||
for _, f := range []*string{&u.Gecos, &u.Home, &u.Shell} {
|
||||
if s, what := clean(*f); what != "" {
|
||||
out.Withheld = append(out.Withheld, Withheld{At: "account " + u.Name, Class: what})
|
||||
*f = s
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out.Accounts, func(i, j int) bool { return out.Accounts[i].UID < out.Accounts[j].UID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- mounts ----
|
||||
|
||||
// mounts reads the mount points this process sees, by path. /proc/self/mountinfo is readable by every account.
|
||||
func (r Reader) mounts() map[string]Mount {
|
||||
out := map[string]Mount{}
|
||||
raw, err := os.ReadFile(r.MountInfo)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, l := range strings.Split(string(raw), "\n") {
|
||||
f := strings.Fields(l)
|
||||
dash := -1
|
||||
for i, x := range f {
|
||||
if x == "-" {
|
||||
dash = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(f) < 5 || dash < 0 || dash+2 >= len(f) {
|
||||
continue
|
||||
}
|
||||
out[unescape(f[4])] = Mount{FS: f[dash+1], Source: unescape(f[dash+2])}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unescape reads mountinfo's octal escapes (\040 for a space).
|
||||
func unescape(s string) string {
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+3 < len(s) {
|
||||
if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
|
||||
b.WriteByte(byte(v))
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A token shaped as a secret, written into files the tests read.
|
||||
const aToken = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
func testReader(t *testing.T, places ...string) (Reader, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
passwd := filepath.Join(dir, "passwd")
|
||||
group := filepath.Join(dir, "group")
|
||||
mountinfo := filepath.Join(dir, "mountinfo")
|
||||
write(t, passwd, "root:x:0:0::/root:/usr/bin/bash\nops:x:1000:1000:The Operator:/home/ops:/usr/bin/zsh\n"+
|
||||
"mesh-reads:x:961:961::/var/lib/mesh-reads:/usr/bin/nologin\n")
|
||||
write(t, group, "root:x:0:\nwheel:x:998:ops\nops:x:1000:\nmesh-reads:x:961:\ndocker:x:970:ops,builder\n")
|
||||
data := filepath.Join(dir, "data")
|
||||
write(t, mountinfo, "22 1 0:21 / / rw,relatime - btrfs /dev/nvme0n1p2 rw\n"+
|
||||
"40 22 0:40 / "+strings.ReplaceAll(data, " ", `\040`)+" rw - nfs4 home-server:/tank/data rw\n")
|
||||
r := Reader{Refuser: Refuser{Places: places}, Passwd: passwd, Group: group, MountInfo: mountinfo,
|
||||
Euid: func() int { return 961 }, Now: time.Now}
|
||||
return r, dir
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §3: run as root, every read is refused, loudly — its bound is an account of its own.
|
||||
func TestEveryReadIsRefusedAsRoot(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
r.Euid = func() int { return 0 }
|
||||
write(t, filepath.Join(dir, "f"), "x\n")
|
||||
if _, err := r.Read(filepath.Join(dir, "f"), ReadAsk{}); err == nil || !strings.Contains(err.Error(), "root") {
|
||||
t.Errorf("a read as root: %v", err)
|
||||
}
|
||||
if _, err := r.Stat([]string{dir}, false); err == nil {
|
||||
t.Error("a stat as root was answered")
|
||||
}
|
||||
if _, err := r.Account("ops", ""); err == nil {
|
||||
t.Error("an account lookup as root was answered")
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §5: without the controller's list of secret places it does not start: a refusal it promises would be
|
||||
// silent.
|
||||
func TestItDoesNotStartWithoutTheNodesSecretPlaces(t *testing.T) {
|
||||
if _, err := placesFrom(""); err == nil || !strings.Contains(err.Error(), PlacesWord) {
|
||||
t.Errorf("no places: %v", err)
|
||||
}
|
||||
if p, err := placesFrom(`["/var/lib/mesh-vault"]`); err != nil || len(p) != 1 {
|
||||
t.Errorf("a list: %v %v", p, err)
|
||||
}
|
||||
if p, err := placesFrom(`[]`); err != nil || len(p) != 0 {
|
||||
t.Errorf("an empty list is a node with no secret place: %v %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §5: a path under the vault's directory, a module's own-secret directory and a known credential file
|
||||
// are each refused by name before any read — though the account's modes would allow it — and a link to one is
|
||||
// refused as the place is.
|
||||
func TestSecretPlacesAreRefusedByNameBeforeAnyRead(t *testing.T) {
|
||||
r0, dir := testReader(t)
|
||||
vault := filepath.Join(dir, "var/lib/mesh-vault")
|
||||
own := filepath.Join(dir, "var/lib/desk-channel")
|
||||
r, _ := r0, dir
|
||||
r.Places = []string{vault, own}
|
||||
for _, p := range []string{
|
||||
filepath.Join(vault, "ledger/entries.json"),
|
||||
filepath.Join(own, "broker"),
|
||||
filepath.Join(own, "settings.json"),
|
||||
filepath.Join(dir, "home/ops/.claude/.credentials.json"),
|
||||
filepath.Join(dir, "home/ops/.ssh/config"),
|
||||
filepath.Join(dir, "home/ops/.pgpass"),
|
||||
filepath.Join(dir, "etc/ssh/ssh_host_ed25519_key"),
|
||||
filepath.Join(dir, "etc/shadow"),
|
||||
filepath.Join(dir, "srv/tls/server.key"),
|
||||
} {
|
||||
write(t, p, "harmless words\n")
|
||||
if _, err := r.Read(p, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
||||
t.Errorf("%s was read: %v", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := r.List(vault); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
||||
t.Errorf("the vault's directory was listed: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "tmp/innocent")
|
||||
write(t, filepath.Join(dir, "tmp/x"), "")
|
||||
if err := os.Symlink(filepath.Join(own, "broker"), link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Read(link, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "leads to") {
|
||||
t.Errorf("a link to a secret place was read: %v", err)
|
||||
}
|
||||
// A search under a directory holding a secret place skips it and names it.
|
||||
write(t, filepath.Join(dir, "home/ops/notes.txt"), "harmless words\n")
|
||||
a, err := r.Search(SearchAsk{Text: "harmless", Path: filepath.Join(dir, "home"), Depth: 8})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Matches) != 1 || !strings.HasSuffix(a.Matches[0].File, "notes.txt") || len(a.Skipped) != 3 {
|
||||
t.Errorf("search: matches %v, skipped %v", a.Matches, a.Skipped)
|
||||
}
|
||||
// What a public key or a certificate is, is not refused.
|
||||
for _, p := range []string{filepath.Join(dir, "etc/ssh/ssh_host_ed25519_key.pub"), filepath.Join(dir, "etc/ssl/certs/ca.pem")} {
|
||||
write(t, p, "harmless words\n")
|
||||
if _, err := r.Read(p, ReadAsk{}); err != nil {
|
||||
t.Errorf("%s was refused: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §3: what the account's file modes refuse is refused — the first bound, below the names.
|
||||
func TestAFileTheAccountMayNotReadIsRefusedByItsMode(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("run as root, a file's mode refuses nothing")
|
||||
}
|
||||
r, dir := testReader(t)
|
||||
p := filepath.Join(dir, "operators-only")
|
||||
write(t, p, "the operator's words\n")
|
||||
if err := os.Chmod(p, 0o000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Read(p, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "may not reach it") {
|
||||
t.Errorf("a file of mode 0000: %v", err)
|
||||
}
|
||||
closed := filepath.Join(dir, "closed")
|
||||
write(t, filepath.Join(closed, "inside"), "x")
|
||||
if err := os.Chmod(closed, 0o700&^0o100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chmod(closed, 0o755)
|
||||
a, err := r.Stat([]string{filepath.Join(closed, "inside")}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := a.Paths[0]; !strings.Contains(got.Stopped, "may not reach") {
|
||||
t.Errorf("a path through a directory it may not search: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §2 and the bounds: a read over the size bound is cut at a line and says so; more than the most is
|
||||
// refused; lines and a tail are answered as asked.
|
||||
func TestAReadIsBounded(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
p := filepath.Join(dir, "big")
|
||||
var b strings.Builder
|
||||
for i := 1; i <= 20000; i++ {
|
||||
fmt.Fprintf(&b, "line %d of the file\n", i)
|
||||
}
|
||||
write(t, p, b.String())
|
||||
a, err := r.Read(p, ReadAsk{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Text) > DefaultReadSize || !strings.Contains(a.Cut, fmt.Sprint(DefaultReadSize)) || a.From != 1 {
|
||||
t.Errorf("a default read: %d bytes, cut %q", len(a.Text), a.Cut)
|
||||
}
|
||||
if _, err := r.Read(p, ReadAsk{Bytes: MaxReadSize + 1}); err == nil {
|
||||
t.Error("more than the most a read answers was answered")
|
||||
}
|
||||
a, err = r.Read(p, ReadAsk{From: 10, Lines: 2})
|
||||
if err != nil || a.Text != "line 10 of the file\nline 11 of the file\n" || a.From != 10 || a.Cut != "" {
|
||||
t.Errorf("lines 10 and 11: %+v %v", a, err)
|
||||
}
|
||||
a, err = r.Read(p, ReadAsk{From: -2})
|
||||
if err != nil || a.Text != "line 19999 of the file\nline 20000 of the file\n" || a.From != 19999 {
|
||||
t.Errorf("the last two lines: %q from %d, %v", a.Text, a.From, err)
|
||||
}
|
||||
if _, err := r.Read(p, ReadAsk{From: 30000}); err == nil || !strings.Contains(err.Error(), "20000 lines") {
|
||||
t.Errorf("a line past the end: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A binary file, a directory and a fifo are refused, and the fifo without waiting for a writer.
|
||||
func TestOnlyATextFileIsRead(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
bin := filepath.Join(dir, "bin")
|
||||
write(t, bin, "ELF\x00\x01\x02")
|
||||
if _, err := r.Read(bin, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "binary") {
|
||||
t.Errorf("a binary file: %v", err)
|
||||
}
|
||||
if _, err := r.Read(dir, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "directory") {
|
||||
t.Errorf("a directory: %v", err)
|
||||
}
|
||||
fifo := filepath.Join(dir, "fifo")
|
||||
if err := syscall.Mkfifo(fifo, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := r.Read(fifo, ReadAsk{}); done <- err }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil || !strings.Contains(err.Error(), "fifo") {
|
||||
t.Errorf("a fifo: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("a read of a fifo waits for a writer")
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §4: a line shaped like a secret is withheld, its number said, and the rest is answered.
|
||||
func TestALineShapedLikeASecretIsWithheld(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
p := filepath.Join(dir, "app.conf")
|
||||
write(t, p, "listen = /run/app.sock\ntoken = "+aToken+"\nuser = app\n")
|
||||
a, err := r.Read(p, ReadAsk{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(a.Text, aToken) || !strings.Contains(a.Text, "user = app") ||
|
||||
!strings.Contains(a.Text, "listen = /run/app.sock") || fmt.Sprint(a.Withheld) != "[2]" {
|
||||
t.Errorf("read: %q withheld %v", a.Text, a.Withheld)
|
||||
}
|
||||
m, err := r.Search(SearchAsk{Text: "token", Path: dir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := json.Marshal(m)
|
||||
if strings.Contains(string(body), aToken) || len(m.Matches) != 1 || len(m.Withheld) != 1 {
|
||||
t.Errorf("search: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The bounds of a search: matches, depth, the most asked, and time — each met is said.
|
||||
func TestASearchIsBounded(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
for i := 0; i < 30; i++ {
|
||||
write(t, filepath.Join(dir, fmt.Sprintf("f%02d", i)), "needle\nneedle\n")
|
||||
}
|
||||
a, err := r.Search(SearchAsk{Text: "needle", Path: dir, Matches: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Matches) != 7 || !strings.HasPrefix(a.Bound, "matches") {
|
||||
t.Errorf("seven matches asked: %d, bound %q", len(a.Matches), a.Bound)
|
||||
}
|
||||
if _, err := r.Search(SearchAsk{Text: "needle", Path: dir, Matches: MaxMatches + 1}); err == nil {
|
||||
t.Error("more matches than the most were asked and answered")
|
||||
}
|
||||
if _, err := r.Search(SearchAsk{Text: "needle", Path: dir, Depth: MaxDepth + 1}); err == nil {
|
||||
t.Error("deeper than the most was asked and answered")
|
||||
}
|
||||
deep := t.TempDir()
|
||||
write(t, filepath.Join(deep, "a/b/c/shallow"), "needle\n")
|
||||
write(t, filepath.Join(deep, "a/b/c/d/e/deep"), "needle\n")
|
||||
a, err = r.Search(SearchAsk{Text: "needle", Path: deep, Depth: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Matches) != 1 || !strings.HasSuffix(a.Matches[0].File, "shallow") {
|
||||
t.Errorf("three deep: %+v", a.Matches)
|
||||
}
|
||||
// Case: as asked.
|
||||
write(t, filepath.Join(deep, "case"), "NEEDLE\n")
|
||||
if a, _ := r.Search(SearchAsk{Text: "needle", Path: filepath.Join(deep, "case")}); len(a.Matches) != 0 {
|
||||
t.Error("a match whatever the case, unasked")
|
||||
}
|
||||
if a, _ := r.Search(SearchAsk{Text: "needle", Path: filepath.Join(deep, "case"), CaseInsensitive: true}); len(a.Matches) != 1 {
|
||||
t.Error("no match whatever the case, asked")
|
||||
}
|
||||
// Time: a clock past the bound stops the walk and says so.
|
||||
start := time.Now()
|
||||
calls := 0
|
||||
r.Now = func() time.Time { calls++; return start.Add(time.Duration(calls) * SearchTime / 3) }
|
||||
a, err = r.Search(SearchAsk{Text: "needle", Path: dir})
|
||||
if err != nil || !strings.HasPrefix(a.Bound, "time") {
|
||||
t.Errorf("the time bound: %q %v", a.Bound, err)
|
||||
}
|
||||
// A fixed string: a pattern's characters are themselves.
|
||||
write(t, filepath.Join(deep, "pattern"), "a.c\nabc\n")
|
||||
if a, _ := r.Search(SearchAsk{Text: "a.c", Path: filepath.Join(deep, "pattern")}); len(a.Matches) != 1 || a.Matches[0].Line != 1 {
|
||||
t.Errorf("a.c as a fixed string: %+v", a.Matches)
|
||||
}
|
||||
}
|
||||
|
||||
// The survey issue 418 was asked for: each component of a path with its owner, group, mode, whether it is a
|
||||
// link and to what, and whether it is a mount point — the link not followed unless asked.
|
||||
func TestStatSaysEachComponent(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
data := filepath.Join(dir, "data")
|
||||
write(t, filepath.Join(data, "real/file"), "x")
|
||||
if err := os.Chmod(filepath.Join(data, "real"), os.ModeSetgid|0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("real", filepath.Join(data, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := r.Stat([]string{filepath.Join(data, "link"), filepath.Join(data, "real/file")}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := a.Paths[0]
|
||||
last := link.Components[len(link.Components)-1]
|
||||
if link.Components[0].Path != "/" || last.Type != "link" || last.Link != "real" || link.LeadsTo != "" {
|
||||
t.Errorf("the link, not followed: %+v", link)
|
||||
}
|
||||
var dataC, realC Component
|
||||
for _, c := range a.Paths[1].Components {
|
||||
switch c.Path {
|
||||
case data:
|
||||
dataC = c
|
||||
case filepath.Join(data, "real"):
|
||||
realC = c
|
||||
}
|
||||
}
|
||||
if dataC.Mount == nil || dataC.Mount.FS != "nfs4" {
|
||||
t.Errorf("the mount point is not said: %+v", dataC)
|
||||
}
|
||||
if realC.Mode != "2750" || realC.Type != "directory" || realC.UID == nil || realC.Perms == "" {
|
||||
t.Errorf("a component's mode: %+v", realC)
|
||||
}
|
||||
if uid := *realC.UID; uid == 1000 && realC.Owner != "ops" {
|
||||
t.Errorf("owner %d named %q", uid, realC.Owner)
|
||||
}
|
||||
a, err = r.Stat([]string{filepath.Join(data, "link")}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Paths[0].LeadsTo != filepath.Join(data, "real") || a.Paths[0].Leads == nil || a.Paths[0].Leads.Type != "directory" {
|
||||
t.Errorf("followed: %+v", a.Paths[0])
|
||||
}
|
||||
a, err = r.Stat([]string{filepath.Join(dir, "missing/deeper")}, false)
|
||||
if err != nil || !strings.Contains(a.Paths[0].Stopped, "does not exist") {
|
||||
t.Errorf("a missing path: %+v %v", a, err)
|
||||
}
|
||||
if _, err := r.Stat([]string{"relative/path"}, false); err == nil {
|
||||
t.Error("a relative path was answered")
|
||||
}
|
||||
many := make([]string, MaxPaths+1)
|
||||
for i := range many {
|
||||
many[i] = "/"
|
||||
}
|
||||
if _, err := r.Stat(many, false); err == nil {
|
||||
t.Error("more paths than the most were answered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadlinkSaysTheTargetAndWhereItLeads(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
write(t, filepath.Join(dir, "target"), "")
|
||||
if err := os.Symlink("target", filepath.Join(dir, "l1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("l1", filepath.Join(dir, "l2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("nowhere", filepath.Join(dir, "dangling")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := r.Readlink(filepath.Join(dir, "l2"))
|
||||
if err != nil || !a.IsLink || a.Link != "l1" || a.LeadsTo != filepath.Join(dir, "target") || !a.Exists {
|
||||
t.Errorf("l2: %+v %v", a, err)
|
||||
}
|
||||
a, err = r.Readlink(filepath.Join(dir, "dangling"))
|
||||
if err != nil || !a.IsLink || a.Exists || !strings.Contains(a.Stopped, "does not exist") {
|
||||
t.Errorf("dangling: %+v %v", a, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSaysEveryEntry(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
write(t, filepath.Join(dir, "d/a"), "abc")
|
||||
if err := os.Symlink("a", filepath.Join(dir, "d/b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := r.List(filepath.Join(dir, "d"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Count != 2 || a.Entries[0].Name != "a" || *a.Entries[0].Size != 3 || a.Entries[1].Type != "link" || a.Entries[1].Link != "a" {
|
||||
t.Errorf("list: %+v", a)
|
||||
}
|
||||
for i := 0; i < MaxEntries+5; i++ {
|
||||
write(t, filepath.Join(dir, "many", fmt.Sprintf("e%05d", i)), "")
|
||||
}
|
||||
a, err = r.List(filepath.Join(dir, "many"))
|
||||
if err != nil || len(a.Entries) != MaxEntries || a.Count != MaxEntries+5 || a.Cut == "" {
|
||||
t.Errorf("a long directory: %d of %d, cut %q, %v", len(a.Entries), a.Count, a.Cut, err)
|
||||
}
|
||||
}
|
||||
|
||||
// getent passwd, getent group and id: an account with every group it is in, a group with its members, never a
|
||||
// password field.
|
||||
func TestAccountAndGroupLookup(t *testing.T) {
|
||||
r, _ := testReader(t)
|
||||
a, err := r.Account("ops", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := a.Accounts[0]
|
||||
sort.Strings(u.Groups)
|
||||
if u.UID != 1000 || u.Group != "ops" || u.Shell != "/usr/bin/zsh" || fmt.Sprint(u.Groups) != "[docker ops wheel]" {
|
||||
t.Errorf("ops: %+v", u)
|
||||
}
|
||||
if a, err := r.Account("961", ""); err != nil || a.Accounts[0].Name != "mesh-reads" {
|
||||
t.Errorf("by number: %+v %v", a, err)
|
||||
}
|
||||
g, err := r.Account("", "docker")
|
||||
if err != nil || g.Groups[0].GID != 970 || fmt.Sprint(g.Groups[0].Members) != "[ops builder]" {
|
||||
t.Errorf("docker: %+v %v", g, err)
|
||||
}
|
||||
if _, err := r.Account("nobody-here", ""); err == nil {
|
||||
t.Error("a missing account was answered")
|
||||
}
|
||||
all, err := r.Account("", "")
|
||||
if err != nil || len(all.Accounts) != 3 || len(all.Groups) != 5 {
|
||||
t.Errorf("all: %+v %v", all, err)
|
||||
}
|
||||
body, _ := json.Marshal(all)
|
||||
if strings.Contains(string(body), `"x"`) || strings.Contains(string(body), "password") {
|
||||
t.Errorf("a password field is answered: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// No answer is larger than the bound, whatever a verb made.
|
||||
func TestNoAnswerIsLargerThanTheBound(t *testing.T) {
|
||||
if _, err := bounded(strings.Repeat("x", MaxAnswer+1), nil); err == nil {
|
||||
t.Error("an answer over the bound was given")
|
||||
}
|
||||
if _, err := bounded("small", nil); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The seat's six verbs, as the controller defines them (mesh-controller internal/catalogue/reads.go).
|
||||
func TestItServesTheSeatsVerbs(t *testing.T) {
|
||||
r, _ := testReader(t)
|
||||
var names []string
|
||||
for _, tool := range tools(r) {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
if fmt.Sprint(names) != "[node-reads.stat node-reads.readlink node-reads.list node-reads.read node-reads.search node-reads.account]" {
|
||||
t.Errorf("verbs: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/outward"
|
||||
)
|
||||
|
||||
// What is refused by name, before any read (novox/hq ADR 0310 §5), and what an answer withholds (§4).
|
||||
//
|
||||
// **Two lists of places.** The places the mesh knows a secret is kept on this node — every module's own-secret
|
||||
// directory, every credential file and grant directory, the vault's directories — come from the controller,
|
||||
// which composes them from the manifests (MESH_SECRET_PLACES). The known credential files of the programs
|
||||
// on a machine are this module's list, below: the mesh does not write them, so nothing else names them. Both
|
||||
// hold whatever the account's file modes would allow: the file modes are the first bound, the names the second.
|
||||
|
||||
// knownDirs are directories that hold nothing but credentials, wherever they are: an account's keys.
|
||||
var knownDirs = map[string]bool{
|
||||
".ssh": true, ".gnupg": true, ".password-store": true, ".aws": true, ".kube": true, ".azure": true,
|
||||
}
|
||||
|
||||
// knownNames are file names that are a credential whatever directory they are in.
|
||||
var knownNames = map[string]bool{
|
||||
".credentials.json": true, // the agent's credentials (Claude Code)
|
||||
".netrc": true, ".pgpass": true, ".git-credentials": true, ".npmrc": true, ".pypirc": true,
|
||||
".my.cnf": true, ".vault-token": true, "credentials": true, "credentials.json": true,
|
||||
"id_rsa": true, "id_dsa": true, "id_ecdsa": true, "id_ed25519": true, "id_ecdsa_sk": true, "id_ed25519_sk": true,
|
||||
"shadow": true, "gshadow": true, "shadow-": true, "gshadow-": true, "opasswd": true,
|
||||
"broker": true, // the mesh's name for a module's bus credential
|
||||
}
|
||||
|
||||
// knownSuffixes are file name endings a credential has.
|
||||
var knownSuffixes = []string{".key", ".p12", ".pfx", ".jks", ".keystore", ".creds", ".nk", ".kdbx", "_key"}
|
||||
|
||||
// knownPaths are the places on a machine that hold credentials and are not a module's.
|
||||
var knownPaths = []string{
|
||||
"/etc/ssl/private", "/etc/wireguard", "/etc/NetworkManager/system-connections", "/etc/ipsec.d/private",
|
||||
"/var/lib/iwd", "/etc/docker/key.json", "/root/.docker/config.json", "/var/lib/sss/secrets",
|
||||
}
|
||||
|
||||
// Refuser says whether a path is a place a secret is kept.
|
||||
type Refuser struct {
|
||||
// Places are the mesh's, from the controller: refused with everything beneath them.
|
||||
Places []string
|
||||
}
|
||||
|
||||
// Refused says why a path may not be read, or "" when it may. The path is clean and absolute.
|
||||
func (r Refuser) Refused(path string) string {
|
||||
path = filepath.Clean(path)
|
||||
for _, p := range r.Places {
|
||||
if within(path, p) {
|
||||
return "it is a place the mesh keeps a secret (" + p + ")"
|
||||
}
|
||||
}
|
||||
for _, p := range knownPaths {
|
||||
if within(path, p) {
|
||||
return "it is a place a machine keeps credentials (" + p + ")"
|
||||
}
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if knownDirs[part] {
|
||||
return "it is in " + part + ", which holds an account's credentials"
|
||||
}
|
||||
if i == len(parts)-1 {
|
||||
if knownNames[part] {
|
||||
return part + " is a known credential file"
|
||||
}
|
||||
for _, s := range knownSuffixes {
|
||||
if strings.HasSuffix(part, s) && len(part) > len(s) {
|
||||
return part + " is named as a key or a credential (" + s + ")"
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(part, ".pem") && (strings.Contains(part, "key") || strings.Contains(part, "private")) {
|
||||
return part + " is named as a private key"
|
||||
}
|
||||
if part == "config.json" && i > 0 && parts[i-1] == ".docker" {
|
||||
return "a container registry login is kept there"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func within(path, place string) bool {
|
||||
return path == place || strings.HasPrefix(path, strings.TrimSuffix(place, "/")+"/")
|
||||
}
|
||||
|
||||
// Withheld is one piece of an answer the content rule kept back: where it was, and the class it carried.
|
||||
type Withheld struct {
|
||||
At string `json:"at"`
|
||||
Class string `json:"why"`
|
||||
}
|
||||
|
||||
// clean is the content rule's secret class (mesh-sdk outward, novox/hq ADR 0310 §4) over one piece of an
|
||||
// answer: the piece, or a mark in its place when it is shaped like a secret.
|
||||
//
|
||||
// **What a file holds, not what the file system names.** It judges a file's lines, a matching line, a link's
|
||||
// target, a mount's source and an account's words. A path and an entry's name are not judged: they are what
|
||||
// the caller named or the file system calls a thing, and the rule's random-run test takes a temporary
|
||||
// directory's or a container layer's name for a token, which would withhold the very survey asked for. A
|
||||
// place where a secret sits by name is refused before any read instead (Refuser).
|
||||
func clean(s string) (string, string) {
|
||||
if refusal, ok := outward.CheckSecret(s); !ok {
|
||||
return "‹withheld: " + refusal.What + "›", refusal.What
|
||||
}
|
||||
return s, ""
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module mesh-reads
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012115257-4dc5f29d3e23
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012115257-4dc5f29d3e23 h1:/F4NwABPAuFZlBx1yK4rdj6sVL5K1XvRlOnZZfNnlsQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012115257-4dc5f29d3e23/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"module": "mesh-reads",
|
||||
"version": "1",
|
||||
"runs-as": "mesh-reads",
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-reads",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"stat",
|
||||
"readlink",
|
||||
"list",
|
||||
"read",
|
||||
"search",
|
||||
"account"
|
||||
]
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-reads",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-reads",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-reads"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-reads",
|
||||
"place": "."
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-reads",
|
||||
"binary": "mesh-reads",
|
||||
"loads": [
|
||||
"mesh-reads"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user