Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66106d93ac |
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/words
|
||||
|
||||
go 1.22
|
||||
@@ -1,330 +0,0 @@
|
||||
// Command words holds what the catalogue's modules say to an agent to the glossary's words (novox/hq ADR
|
||||
// 0244): no word the glossary retired for the tools' descriptions — the copy in retired-words at the
|
||||
// catalogue's root — in any text a module's tools can show. That text is every string literal in a module's
|
||||
// own code that is not a test (a tool's description, the notes and errors it answers with) and every
|
||||
// description in its manifest. Comments are not read: an agent never sees them.
|
||||
//
|
||||
// A word is matched whole and in any case, a space in it matching any run of white space, and its plural
|
||||
// (`s` or `es` on its last part) is matched as the word. A vendor word is
|
||||
// never on the list (the glossary does not retire one for the tools), so a wrapped program's own objects —
|
||||
// an identity provider's users, a media manager's releases — are never findings.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding, 2 when the list cannot be read.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/scanner"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// text is one piece of text an agent can be shown, and where it starts.
|
||||
type text struct {
|
||||
file string
|
||||
line int
|
||||
s string
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
words, err := readList(filepath.Join(root, "retired-words"))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "words:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if len(words) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "words: retired-words lists no word — the check would pass on anything")
|
||||
os.Exit(2)
|
||||
}
|
||||
var texts []text
|
||||
files := 0
|
||||
err = filepath.WalkDir(filepath.Join(root, "modules"), func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
switch d.Name() {
|
||||
case "node_modules", "dist", "vendor", "test", "tests", "testdata", ".git":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
name := d.Name()
|
||||
var found []text
|
||||
switch {
|
||||
case strings.HasSuffix(name, "_test.go"), strings.Contains(name, ".test."), strings.Contains(name, ".spec."):
|
||||
return nil
|
||||
case strings.HasSuffix(name, ".go"):
|
||||
found, err = goStrings(path, rel)
|
||||
case strings.HasSuffix(name, ".ts"), strings.HasSuffix(name, ".js"), strings.HasSuffix(name, ".mjs"):
|
||||
found, err = scriptStrings(path, rel)
|
||||
case name == "module.json":
|
||||
found, err = jsonStrings(path, rel)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", rel, err)
|
||||
}
|
||||
files++
|
||||
texts = append(texts, found...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "words:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
findings := check(words, texts)
|
||||
for _, f := range findings {
|
||||
fmt.Println(f)
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
fmt.Printf("words: %d use(s) of a word the glossary retired (novox/hq ADR 0244) — say it in the glossary's word\n", len(findings))
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("words: %d retired words, none in the %d strings of %d files\n", len(words), len(texts), files)
|
||||
}
|
||||
|
||||
// readList reads retired-words: one word or phrase a line; blank lines and lines starting with # are not words.
|
||||
func readList(path string) ([]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
var words []string
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
words = append(words, line)
|
||||
}
|
||||
return words, sc.Err()
|
||||
}
|
||||
|
||||
func pattern(word string) *regexp.Regexp {
|
||||
parts := strings.Fields(word)
|
||||
for i, p := range parts {
|
||||
parts[i] = regexp.QuoteMeta(p)
|
||||
}
|
||||
// The plural is the word too: `s` or `es` on its last part, so "control planes" is found as "control
|
||||
// plane" is. Anything else joined on is another word.
|
||||
return regexp.MustCompile(`(?i)(?:^|[^\w-])(` + strings.Join(parts, `\s+`) + `(?:e?s)?)(?:$|[^\w-])`)
|
||||
}
|
||||
|
||||
func check(words []string, texts []text) []string {
|
||||
var out []string
|
||||
for _, w := range words {
|
||||
rx := pattern(w)
|
||||
for _, t := range texts {
|
||||
if m := rx.FindStringSubmatchIndex(t.s); m != nil {
|
||||
line := t.line
|
||||
if line > 0 {
|
||||
line += strings.Count(t.s[:m[2]], "\n")
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s:%d: %q is retired — in %q", t.file, line, w, excerpt(t.s, m[2], m[3])))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func excerpt(s string, from, to int) string {
|
||||
a, b := from-50, to+50
|
||||
if a < 0 {
|
||||
a = 0
|
||||
}
|
||||
if b > len(s) {
|
||||
b = len(s)
|
||||
}
|
||||
return strings.Join(strings.Fields(s[a:b]), " ")
|
||||
}
|
||||
|
||||
// goStrings returns a Go file's string literals, adjacent ones joined across `+` as the program joins them.
|
||||
func goStrings(path, rel string) ([]text, error) {
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file := fset.AddFile(rel, -1, len(src))
|
||||
var s scanner.Scanner
|
||||
var bad error
|
||||
s.Init(file, src, func(pos token.Position, msg string) { bad = fmt.Errorf("%s: %s", pos, msg) }, 0)
|
||||
var out []text
|
||||
var cur *text
|
||||
joining := false
|
||||
for {
|
||||
pos, tok, lit := s.Scan()
|
||||
if tok == token.EOF {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case tok == token.STRING:
|
||||
v, err := strconv.Unquote(lit)
|
||||
if err != nil {
|
||||
v = lit
|
||||
}
|
||||
if cur != nil && joining {
|
||||
cur.s += v
|
||||
} else {
|
||||
out = append(out, text{file: rel, line: fset.Position(pos).Line, s: v})
|
||||
cur = &out[len(out)-1]
|
||||
}
|
||||
joining = false
|
||||
case tok == token.ADD && cur != nil:
|
||||
joining = true
|
||||
default:
|
||||
cur, joining = nil, false
|
||||
}
|
||||
}
|
||||
return out, bad
|
||||
}
|
||||
|
||||
// scriptStrings returns a TypeScript or JavaScript file's string literals, comments skipped, adjacent ones
|
||||
// joined across `+`. A template literal's `${…}` parts are left out of the text around them.
|
||||
func scriptStrings(path, rel string) ([]text, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
src := string(b)
|
||||
var out []text
|
||||
line := 1
|
||||
lastWasString, joining := false, false
|
||||
for i := 0; i < len(src); i++ {
|
||||
c := src[i]
|
||||
switch {
|
||||
case c == '\n':
|
||||
line++
|
||||
case c == '/' && i+1 < len(src) && src[i+1] == '/':
|
||||
for i < len(src) && src[i] != '\n' {
|
||||
i++
|
||||
}
|
||||
line++
|
||||
case c == '/' && i+1 < len(src) && src[i+1] == '*':
|
||||
end := strings.Index(src[i+2:], "*/")
|
||||
if end < 0 {
|
||||
end = len(src) - i - 2
|
||||
}
|
||||
line += strings.Count(src[i:i+2+end], "\n")
|
||||
i += end + 3
|
||||
case c == '"' || c == '\'' || c == '`':
|
||||
start := line
|
||||
var sb strings.Builder
|
||||
depth := 0
|
||||
j := i + 1
|
||||
for ; j < len(src); j++ {
|
||||
d := src[j]
|
||||
if d == '\n' {
|
||||
line++
|
||||
}
|
||||
if depth > 0 {
|
||||
if d == '\n' {
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
if d == '{' {
|
||||
depth++
|
||||
} else if d == '}' {
|
||||
depth--
|
||||
}
|
||||
continue
|
||||
}
|
||||
if d == '\\' && j+1 < len(src) {
|
||||
j++
|
||||
sb.WriteByte(src[j])
|
||||
continue
|
||||
}
|
||||
if c == '`' && d == '$' && j+1 < len(src) && src[j+1] == '{' {
|
||||
depth = 1
|
||||
j++
|
||||
sb.WriteByte(' ')
|
||||
continue
|
||||
}
|
||||
if d == c || (c != '`' && d == '\n') {
|
||||
break
|
||||
}
|
||||
sb.WriteByte(d)
|
||||
}
|
||||
if lastWasString && joining && len(out) > 0 {
|
||||
out[len(out)-1].s += sb.String()
|
||||
} else {
|
||||
out = append(out, text{file: rel, line: start, s: sb.String()})
|
||||
}
|
||||
lastWasString, joining = true, false
|
||||
i = j
|
||||
continue
|
||||
case c == '+' && lastWasString:
|
||||
joining = true
|
||||
continue
|
||||
case c == ' ' || c == '\t' || c == '\r':
|
||||
continue
|
||||
default:
|
||||
lastWasString, joining = false, false
|
||||
}
|
||||
if c == '\n' {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// jsonStrings returns the descriptions in a manifest — every string under a key named "description", at any
|
||||
// depth. The rest of a manifest is names, paths and the contents of files it places, none of which a tool
|
||||
// shows an agent.
|
||||
func jsonStrings(path, rel string) ([]text, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []text
|
||||
var walk func(any, bool)
|
||||
walk = func(v any, described bool) {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
if described {
|
||||
out = append(out, text{file: rel, line: lineOf(string(b), x), s: x})
|
||||
}
|
||||
case []any:
|
||||
for _, e := range x {
|
||||
walk(e, described)
|
||||
}
|
||||
case map[string]any:
|
||||
for k, e := range x {
|
||||
walk(e, k == "description")
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(v, false)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func lineOf(src, s string) int {
|
||||
q, _ := json.Marshal(s)
|
||||
if i := strings.Index(src, string(q)); i >= 0 {
|
||||
return strings.Count(src[:i], "\n") + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// A retired word's plural is the word; anything else joined on is another word.
|
||||
func TestPatternMatchesTheWordAndItsPlural(t *testing.T) {
|
||||
cases := []struct {
|
||||
word, text string
|
||||
want bool
|
||||
}{
|
||||
{"control plane", "ask the control plane", true},
|
||||
{"control plane", "two control planes", true},
|
||||
{"control plane", "two control\n\tplanes", true},
|
||||
{"substrate", "the substrates", true},
|
||||
{"flavor", "flavors of a module", true},
|
||||
{"flavor", "flavored", false},
|
||||
{"node tools", "node toolsets", false},
|
||||
{"mesh-console", "the mesh-consoles", true},
|
||||
{"mesh-console", "mesh-console-x", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := pattern(c.word).MatchString(c.text); got != c.want {
|
||||
t.Errorf("pattern(%q) on %q = %v, want %v", c.word, c.text, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -14,15 +14,9 @@
|
||||
# long-running resources without `health` held to the number in health-undeclared, which only goes down;
|
||||
# 2. the Go tests of every module the change touches that has them, under the race detector when the
|
||||
# toolchain has a C compiler — and a module whose dependencies cannot be fetched here, or that is
|
||||
# written in TypeScript, is said as not tested, never passed silently;
|
||||
# 3. the words (novox/hq ADR 0244): no word the glossary retired for the tools, as copied in
|
||||
# retired-words, in any text a module's tools can show an agent — every string in its own code that is
|
||||
# not a test, and every description in its manifest. Run over every module, not only the touched ones,
|
||||
# so a word newly retired is found everywhere it stands. The check's own test runs first.
|
||||
# written in TypeScript, is said as not tested, never passed silently.
|
||||
set -eu
|
||||
|
||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
||||
|
||||
if [ -n "${MESH_GATE:-}" ]; then
|
||||
checked=$("$MESH_GATE" module check modules/*/module.json) || { printf '%s\n' "$checked"; exit 1; }
|
||||
# **The count only goes down** (novox/hq ADR 0240 rule 8): the long-running resources that do not say how
|
||||
|
||||
@@ -23,8 +23,8 @@ whenever the node's tool runtime collects the module's tools:
|
||||
|---|---|
|
||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting, then the settings registered through this module (the mesh's, then this node's), under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin |
|
||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
|
||||
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands, hooks and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook, first. Replaced whole, staged beside and swapped in |
|
||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions — then the instruction sections registered for every node and for this one |
|
||||
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands, hooks and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>`. Replaced whole, staged beside and swapped in |
|
||||
|
||||
Under the operator's home: `~/.claude/.credentials.json`, only when the licence manager hands this node a
|
||||
subscription token; and what is registered at the **home** scope for this node — a skill, subagent,
|
||||
@@ -49,40 +49,9 @@ must see, a node that joins later included — kept, so it carries no secret eit
|
||||
| an MCP server registered through this module | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. An entry with a secret in its `env` or `headers` is refused by the runtime |
|
||||
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name |
|
||||
|
||||
## The mesh's tools first (hq ADR 0245)
|
||||
|
||||
The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
|
||||
|
||||
- **The "instead of" table.** Every seat verb and module tool may say which shell commands it replaces
|
||||
(`replaces`, in the seat's definition or the module's manifest). The module asks the controller — `tools`,
|
||||
`modules`, `nodes`, `node` — at start, every ten minutes and on `claude_code_render`, keeps the answer in its
|
||||
state (`mesh-tools.json`), and renders it into `CLAUDE.md` as one row per seat or module: `<node>/<seat>.` and
|
||||
each verb with the commands it replaces. Generated, never written by hand: a verb that gains `replaces` is in
|
||||
the next render. Ordered by what the guard here refused most, then the machines' seats, the modules, the
|
||||
mesh's seats; at most sixteen rows, the rest one `mesh_search` away.
|
||||
- **The guard.** A PreToolUse hook on `Bash`, `Edit`, `Write`, `MultiEdit` and `NotebookEdit`: this module's own
|
||||
binary (`claude-code guard`), copied root's into the plugin with what it judges with (`guard.json`: the
|
||||
machines by name, domain and address, and the replaced commands), run by its path under `/etc/claude-code` so
|
||||
the session cannot change it. It refuses `ssh`, `scp`, `sftp`, `rsync`, `mosh` and `autossh` to a mesh machine
|
||||
(any `*.internal` name, a machine's name or a name under its domains, one of its addresses, as `ssh -G` reads
|
||||
the destination; a jump through one too), writing `/etc/hosts` or `/etc/resolv.conf`, and `HOSTALIASES` —
|
||||
naming the verb that does the job on that machine when one says it replaces the command, and otherwise that a
|
||||
missing tool is created in the module that owns it, never worked around. **Stated, not silent:** an ssh login
|
||||
as `git` is the forge's account, which runs nothing but git, and passes; a git remote is never an ssh command
|
||||
line anyway.
|
||||
- **The operator's override**: `MESH_GUARD_OVERRIDE=<why>`, exported in the operator's own shell before the
|
||||
session starts. It is read from the session's environment as the kernel kept it at exec
|
||||
(`/proc/<pid>/environ` of the agent's process), so nothing a session does — a command's `export`, a
|
||||
settings `env` key — can set it, and a command naming it is refused outright. Every override and every
|
||||
refusal is a line in the module's `guard.log`; an override that cannot be recorded is not honoured.
|
||||
`claude_code_guard` shows the rules, the data and the record.
|
||||
|
||||
It is a guard against the habit, not a sandbox: a command written to hide what it runs can hide it, and the
|
||||
record is how a habit that found a way round is seen.
|
||||
|
||||
## Tools
|
||||
|
||||
`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull`, `claude_code_grant` (for the licence
|
||||
`claude_code_status`, `claude_code_render`, `claude_code_pull`, `claude_code_grant` (for the licence
|
||||
manager), `claude_code_mcp_list`,
|
||||
`claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this
|
||||
node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`.
|
||||
@@ -101,19 +70,7 @@ operator account's own `~/.claude` on those nodes):
|
||||
- `claude_code_config_list`, `_show` (one registration in full), `_status` (what applies here, the plugin
|
||||
as written, and the home's own items — which the mesh placed, which share a name with a mesh item, which
|
||||
call a tool server not loaded here) and `_import` (an item of this node's home, registered at a scope;
|
||||
the original stays);
|
||||
- `claude_code_home_show` (`kind`, `name`): one item of this node's home in full — a skill, subagent,
|
||||
command, output style, rule file (`instructions`), or the account's own memory `~/.claude/CLAUDE.md`
|
||||
(`memory`) — and whether the mesh placed it;
|
||||
- `claude_code_home_remove` (`kind`, `name`, `why`): removes one item the person made, on their word —
|
||||
removing it is the person's act, and this tool is that act made explicit. `why` is required; what the
|
||||
mesh placed is refused (its `_unregister` owns it), and so is a symbolic link. A copy is kept first in
|
||||
the module's state, `removed-from-home/<date>/<time>-<kind>-<name>/`, with a `removal.json` note, and
|
||||
the removal and its reason are appended to `removed-from-home/removed.log`; the answer names the copy;
|
||||
- `claude_code_home_removed`: every kept removal, newest first, with its `keptAt` and whether it was put back;
|
||||
- `claude_code_home_restore` (`kept`): puts a removal's copy back at its path — refused when something is
|
||||
there now, or when the copy is not, file by file, what its `removal.json` digests say was removed.
|
||||
Logged to `removed.log` and the journal; the copy stays, marked with a `restored.json`.
|
||||
the original stays).
|
||||
|
||||
A new session takes a change; a running one at `/reload-plugins`.
|
||||
|
||||
|
||||
@@ -324,41 +324,15 @@ func ConfigOf(items map[string]Item) Config {
|
||||
|
||||
// ---- rendering --------------------------------------------------------------------------------------
|
||||
|
||||
// PluginFile is one file of the marketplace: its content and whether it is run — or, for a binary, the
|
||||
// file it is copied from (the guard's, which is this module's own executable).
|
||||
// PluginFile is one file of the marketplace: its content and whether it is run.
|
||||
type PluginFile struct {
|
||||
Content string
|
||||
Executable bool
|
||||
From string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// GuardDir is where the guard on the agent's shell lives in the plugin (novox/hq ADR 0245): its binary and
|
||||
// what it is given. Outside hooks/, so no registered hook's name can be it.
|
||||
const GuardDir = "guard"
|
||||
|
||||
// GuardFiles are the guard as the plugin carries it: the binary to copy and the data it judges with.
|
||||
type GuardFiles struct {
|
||||
Exe string
|
||||
Data GuardData
|
||||
}
|
||||
|
||||
// GuardMatcher is what the guard is asked about: the shell, and the agent's own file edits.
|
||||
const GuardMatcher = "Bash|Edit|Write|MultiEdit|NotebookEdit"
|
||||
|
||||
// GuardCommand is the hook's command line: by its path under the managed directory, root's — never the
|
||||
// copy the agent may keep of a plugin, which the session could change.
|
||||
func GuardCommand() string {
|
||||
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
|
||||
return `"` + filepath.Join(dir, "guard") + `" guard "` + filepath.Join(dir, "guard.json") + `"`
|
||||
}
|
||||
|
||||
// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid
|
||||
// over a mesh item of the same kind and name: the node's wins.
|
||||
func Marketplace(c Config) map[string]PluginFile { return MarketplaceWith(c, nil) }
|
||||
|
||||
// MarketplaceWith is the marketplace with the guard on the agent's shell, when there is one to place: its
|
||||
// hook first, before any registered one.
|
||||
func MarketplaceWith(c Config, guard *GuardFiles) map[string]PluginFile {
|
||||
func Marketplace(c Config) map[string]PluginFile {
|
||||
root := "plugins/" + Plugin + "/"
|
||||
out := map[string]PluginFile{
|
||||
".claude-plugin/marketplace.json": {Content: jsonFile(map[string]any{
|
||||
@@ -385,12 +359,6 @@ func MarketplaceWith(c Config, guard *GuardFiles) map[string]PluginFile {
|
||||
}
|
||||
sort.Strings(keys)
|
||||
hooks := map[string][]any{}
|
||||
if guard != nil && guard.Exe != "" {
|
||||
out[root+GuardDir+"/guard"] = PluginFile{From: guard.Exe, Executable: true}
|
||||
out[root+GuardDir+"/guard.json"] = PluginFile{Content: jsonFile(guard.Data)}
|
||||
hooks["PreToolUse"] = append(hooks["PreToolUse"], map[string]any{"matcher": GuardMatcher,
|
||||
"hooks": []any{map[string]any{"type": "command", "command": GuardCommand(), "timeout": 15}}})
|
||||
}
|
||||
for _, k := range keys {
|
||||
it := chosen[k]
|
||||
switch it.Kind {
|
||||
@@ -697,7 +665,7 @@ type HomeItem struct {
|
||||
|
||||
var mcpToolRef = regexp.MustCompile(`mcp__([A-Za-z0-9_-]+)__[A-Za-z0-9_-]+`)
|
||||
|
||||
// HomeItems lists the home's skills, subagents, commands, output styles, rule files and its own memory (CLAUDE.md), says which the mesh
|
||||
// HomeItems lists the home's skills, subagents, commands, output styles and rule files, says which the mesh
|
||||
// placed, and notes those that share a name with a mesh item or call a tool server that is not loaded here.
|
||||
func HomeItems(p Paths, c Config, loaded Servers) []HomeItem {
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
@@ -750,9 +718,6 @@ func HomeItems(p Paths, c Config, loaded Servers) []HomeItem {
|
||||
add(kind, strings.TrimSuffix(e.Name(), ".md"), sub+"/"+e.Name(), string(body))
|
||||
}
|
||||
}
|
||||
if body, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md")); err == nil {
|
||||
add(KindMemory, memoryName, "CLAUDE.md", string(body))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Kind != out[j].Kind {
|
||||
return out[i].Kind < out[j].Kind
|
||||
|
||||
@@ -94,9 +94,8 @@ func TestEachKindLandsInItsOnePlace(t *testing.T) {
|
||||
} `json:"hooks"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(plugin[root+"hooks/hooks.json"].Content), &hooks)
|
||||
// The mesh's own guard on the shell first (novox/hq ADR 0245), then the hook registered.
|
||||
if pre := hooks.Hooks["PreToolUse"]; len(pre) != 2 || pre[0].Matcher != GuardMatcher || pre[0].Hooks[0].Command != GuardCommand() ||
|
||||
pre[1].Matcher != "Bash" || pre[1].Hooks[0].Command != `"${CLAUDE_PLUGIN_ROOT}/hooks/guard"/guard.sh` {
|
||||
if pre := hooks.Hooks["PreToolUse"]; len(pre) != 1 || pre[0].Matcher != "Bash" ||
|
||||
pre[0].Hooks[0].Command != `"${CLAUDE_PLUGIN_ROOT}/hooks/guard"/guard.sh` {
|
||||
t.Errorf("the hook's command does not name its directory, quoted: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
}
|
||||
if !strings.Contains(w["CLAUDE.md"], "### conventions\n\nCommit in the imperative.") {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
@@ -248,7 +247,7 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
Input: map[string]any{"key": str("the key")},
|
||||
Run: func(a map[string]any) (any, error) { return Show(state, strArg(a, "key")) }},
|
||||
stdio.Tool{Name: "claude_code_config_status",
|
||||
Description: "Claude Code's configuration on this node: what was registered and applies here (mesh, node, home), the plugin as written, and the home's own skills, subagents, commands, output styles, rule files and memory (CLAUDE.md) — which the mesh placed, which share a name with a mesh item, and which call tools of a tool server not loaded here (stale).",
|
||||
Description: "Claude Code's configuration on this node: what was registered and applies here (mesh, node, home), the plugin as written, and the home's own skills, subagents, commands, output styles and rule files — which the mesh placed, which share a name with a mesh item, and which call tools of a tool server not loaded here (stale).",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
c := ConfigOf(view.Items())
|
||||
names := func(items []Item) []string {
|
||||
@@ -302,40 +301,6 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
}
|
||||
return Register(p, it, nodes, false, state, view, writeManaged)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_home_show",
|
||||
Description: "One item of this node's operator account's own ~/.claude in full, as the status tool lists it — a skill (every file in its folder), subagent, command, output style or rule file — or the account's own memory, ~/.claude/CLAUDE.md: where it is, whether the mesh placed it, and its content.",
|
||||
Input: map[string]any{
|
||||
"kind": str("skill, agent, command, output-style, instructions (a rule file) or memory (~/.claude/CLAUDE.md)"),
|
||||
"name": str("its name in the home: the skill's folder, or the file without .md; absent for memory"),
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) { return ShowHome(p, strArg(a, "kind"), strArg(a, "name")) }},
|
||||
stdio.Tool{Name: "claude_code_home_remove",
|
||||
Description: "Remove one item the person made in this node's operator account's own ~/.claude — a skill, subagent, command, output style, rule file, or the memory ~/.claude/CLAUDE.md — on the person's word: removing it is the person's act, and this tool is that act made explicit. Called only when the person asked for that item to go, never on the agent's own judgement. `why` is required. An item the mesh placed is refused (its unregister owns it). A copy is kept first in the module's state, under removed-from-home/<date>/, and the removal and its reason are logged there; the answer says where the copy is, so it can be put back.",
|
||||
Input: map[string]any{
|
||||
"kind": str("skill, agent, command, output-style, instructions (a rule file) or memory (~/.claude/CLAUDE.md)"),
|
||||
"name": str("its name in the home: the skill's folder, or the file without .md; absent for memory"),
|
||||
"why": str("the person's reason for removing it, kept in the log"),
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
answer, err := RemoveHome(p, strArg(a, "kind"), strArg(a, "name"), strArg(a, "why"), time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A home registration of the same name was held back by the person's file; it is placed now.
|
||||
for _, it := range ConfigOf(view.Items()).Home {
|
||||
if it.Kind == answer["kind"] && it.Name == answer["name"] {
|
||||
answer["then"] = "the mesh registers a " + it.Kind + " of this name for this home; it is placed at the next render"
|
||||
}
|
||||
}
|
||||
return answer, nil
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_home_removed",
|
||||
Description: "Every item removed from this node's home through claude_code_home_remove whose copy the module keeps, newest first: what it was, where it was, why it went, its keptAt, and whether it was put back.",
|
||||
Run: func(map[string]any) (any, error) { return KeptRemovals(p) }},
|
||||
stdio.Tool{Name: "claude_code_home_restore",
|
||||
Description: "Undo a removal made with claude_code_home_remove: put the kept copy back at the path it was removed from. Refused when something is at that path now, or when the copy is not exactly what was removed (checked file by file against the digests its removal.json recorded). Logged as the removal was; the copy stays.",
|
||||
Input: map[string]any{"kept": str("the keptAt the removal answered, as claude_code_home_removed lists it")},
|
||||
Run: func(a map[string]any) (any, error) { return RestoreHome(p, strArg(a, "kept"), time.Now()) }},
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,785 +0,0 @@
|
||||
package main
|
||||
|
||||
// The guard on the agent's shell (novox/hq ADR 0245): a PreToolUse hook every session on a machine of the
|
||||
// mesh runs before a shell command or a file edit, delivered in the module's plugin.
|
||||
//
|
||||
// It refuses three work-arounds, each with the mesh tool that does the job when one says it replaces the
|
||||
// command, and otherwise with the rule that a missing tool is created, never worked around:
|
||||
//
|
||||
// 1. **ssh to a mesh machine** — `ssh`, `scp`, `sftp`, `rsync`, `mosh` or `autossh` to a machine's name, a
|
||||
// name under its domains, any `*.internal` name, or one of its addresses; a jump through one too. The
|
||||
// destination is read as ssh itself reads it (`ssh -G`), so an alias in ~/.ssh/config is no way round.
|
||||
// **Stated, not silent**: an ssh login as `git` is the forge's account, which runs git and nothing
|
||||
// else — so `git push` over ssh, and `ssh -T git@<forge>`, reach no machine and pass. A git remote is
|
||||
// never an ssh command line anyway.
|
||||
// 2. **writing /etc/hosts or /etc/resolv.conf** — by redirect, tee, sed -i, an editor, cp/mv/install onto
|
||||
// it, or the agent's own Edit and Write tools: a mesh name is the mesh's resolvers' (ADR 0194), and a
|
||||
// machine's hosts file is its node-hostname seat's.
|
||||
// 3. **HOSTALIASES**, named anywhere in a command line.
|
||||
//
|
||||
// And it refuses any command that names the operator's override, so the agent never sets it (guard_run.go).
|
||||
//
|
||||
// The matching is on the command line as a shell would split it — quotes, separators, `$(…)`, `bash -c` —
|
||||
// and wrappers such as sudo, env and timeout are looked through. It is a guard against the habit of reaching
|
||||
// for ssh, not a sandbox: a command built to hide what it runs can hide it, and the record of what was
|
||||
// refused (the module's `guard.log`, read by `claude_code_guard`) is how a habit that found a way round is seen.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GuardData is what the hook is given: this machine, the mesh's machines, what replaces what, and where
|
||||
// its record is kept. Written beside the hook on every render, root's, so the session cannot change it.
|
||||
type GuardData struct {
|
||||
Node string `json:"node"`
|
||||
Machines []Machine `json:"machines"`
|
||||
Replacements []Replacement `json:"replacements"`
|
||||
Log string `json:"log"`
|
||||
}
|
||||
|
||||
// HookInput is what the agent hands a PreToolUse hook.
|
||||
type HookInput struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
ToolInput map[string]any `json:"tool_input"`
|
||||
Cwd string `json:"cwd"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// Verdict is the guard's answer.
|
||||
type Verdict struct {
|
||||
Refuse bool `json:"refuse"`
|
||||
Rule string `json:"rule,omitempty"` // ssh | hosts-file | hostaliases | override-named
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
// Overridable is false for a refusal no override lifts: the agent naming the override itself.
|
||||
Overridable bool `json:"-"`
|
||||
// Calls are the tools the refusal named, by address without a machine: what the instructions' table
|
||||
// puts first, the tools the agent most reached round.
|
||||
Calls []string `json:"calls,omitempty"`
|
||||
}
|
||||
|
||||
// OverrideVar is the operator's override: set in the operator's own shell before the session starts,
|
||||
// with why as its value. Read from the session's own environment as it was started, never from what a
|
||||
// command or a setting put there (guard_run.go).
|
||||
const OverrideVar = "MESH_GUARD_OVERRIDE"
|
||||
|
||||
// ForgeUser is the forge's ssh account: it runs git, never a shell (stated in ADR 0245).
|
||||
const ForgeUser = "git"
|
||||
|
||||
// protectedFiles are the files a session never writes for a mesh name.
|
||||
var protectedFiles = map[string]bool{"/etc/hosts": true, "/etc/resolv.conf": true}
|
||||
|
||||
// SSHView is how ssh itself reads a destination with these options: the host it connects to, the user,
|
||||
// and the jumps. Empty host when ssh could not say.
|
||||
type SSHView func(options []string, destination string) (host, user string, jumps []string)
|
||||
|
||||
// Guard judges what a session is about to do.
|
||||
type Guard struct {
|
||||
Data GuardData
|
||||
// SSH reads a destination as ssh does (`ssh -G`); nil reads it from the command line alone.
|
||||
SSH SSHView
|
||||
// Resolve is a name's addresses, for a name that is none of the mesh's but may point at a machine.
|
||||
Resolve func(string) []string
|
||||
}
|
||||
|
||||
// Judge is the verdict on one tool call.
|
||||
func (g Guard) Judge(in HookInput) Verdict {
|
||||
switch in.ToolName {
|
||||
case "Bash":
|
||||
command, _ := in.ToolInput["command"].(string)
|
||||
return g.judgeLine(command, 0)
|
||||
case "Edit", "Write", "MultiEdit", "NotebookEdit":
|
||||
for _, key := range []string{"file_path", "notebook_path"} {
|
||||
if p, _ := in.ToolInput[key].(string); p != "" && protectedFiles[filepath.Clean(p)] {
|
||||
return g.hostsFile(filepath.Clean(p), in.ToolName+" "+filepath.Clean(p))
|
||||
}
|
||||
}
|
||||
}
|
||||
return Verdict{}
|
||||
}
|
||||
|
||||
func (g Guard) judgeLine(line string, depth int) Verdict {
|
||||
if depth > 4 {
|
||||
return Verdict{}
|
||||
}
|
||||
if strings.Contains(line, OverrideVar) {
|
||||
return Verdict{Refuse: true, Rule: "override-named", Message: fmt.Sprintf(
|
||||
"Refused by the mesh's guard (novox/hq ADR 0245): this command names %s, the operator's override. "+
|
||||
"It is the operator's alone, set in their own shell before a session starts; a session never sets, "+
|
||||
"reads or passes it.", OverrideVar)}
|
||||
}
|
||||
if strings.Contains(line, "HOSTALIASES") {
|
||||
return g.refuseLocal("hostaliases", "HOSTALIASES", "setting HOSTALIASES")
|
||||
}
|
||||
for _, words := range simpleCommands(line) {
|
||||
if v := g.judgeCommand(words, depth); v.Refuse {
|
||||
return v
|
||||
}
|
||||
// What a word runs in its own shell: `$(…)` and backticks inside a quoted word.
|
||||
for _, w := range words {
|
||||
for _, inner := range substitutions(w) {
|
||||
if v := g.judgeLine(inner, depth+1); v.Refuse {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Verdict{}
|
||||
}
|
||||
|
||||
// wrappers run the command after them; each with the options that take a value.
|
||||
var wrappers = map[string]string{
|
||||
"sudo": "ugCDhpTrt", "doas": "uC", "env": "uSC", "timeout": "sk", "nohup": "", "nice": "n", "ionice": "cnpP",
|
||||
"command": "", "exec": "a", "time": "fo", "stdbuf": "ioe", "xargs": "aEdIiLlnPs", "setsid": "", "unbuffer": "",
|
||||
"chronic": "", "flock": "wE", "torsocks": "", "proxychains": "f", "proxychains4": "f", "caffeinate": "",
|
||||
}
|
||||
|
||||
// shells run their -c argument as a command line.
|
||||
var shells = map[string]bool{"sh": true, "bash": true, "zsh": true, "dash": true, "ksh": true, "fish": true, "eval": true}
|
||||
|
||||
func (g Guard) judgeCommand(words []string, depth int) Verdict {
|
||||
words = unwrap(words)
|
||||
if len(words) == 0 {
|
||||
return Verdict{}
|
||||
}
|
||||
name := path.Base(words[0])
|
||||
args := words[1:]
|
||||
if shells[name] {
|
||||
if name == "eval" {
|
||||
return g.judgeLine(strings.Join(args, " "), depth+1)
|
||||
}
|
||||
for i, a := range args {
|
||||
if strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") && strings.Contains(a, "c") && i+1 < len(args) {
|
||||
return g.judgeLine(args[i+1], depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch name {
|
||||
case "ssh", "autossh", "mosh", "slogin":
|
||||
return g.judgeSSH(name, args, strings.Join(words, " "))
|
||||
case "scp", "sftp", "rsync":
|
||||
return g.judgeCopy(name, args, strings.Join(words, " "))
|
||||
}
|
||||
if target := writesProtected(name, args); target != "" {
|
||||
return g.hostsFile(target, strings.Join(words, " "))
|
||||
}
|
||||
return Verdict{}
|
||||
}
|
||||
|
||||
// unwrap takes off what only runs the command: assignments before it, and wrappers with their options.
|
||||
func unwrap(words []string) []string {
|
||||
for len(words) > 0 {
|
||||
w := words[0]
|
||||
switch {
|
||||
case w == "!" || w == "{" || w == "}" || w == "then" || w == "do" || w == "else" || w == "if" || w == "while" ||
|
||||
w == "until" || w == "elif":
|
||||
words = words[1:]
|
||||
case isAssignment(w):
|
||||
words = words[1:]
|
||||
default:
|
||||
opts, wrapper := wrappers[path.Base(w)]
|
||||
if !wrapper {
|
||||
return words
|
||||
}
|
||||
base := path.Base(w)
|
||||
words = words[1:]
|
||||
for len(words) > 0 {
|
||||
a := words[0]
|
||||
if a == "--" {
|
||||
words = words[1:]
|
||||
break
|
||||
}
|
||||
if base == "env" && isAssignment(a) {
|
||||
words = words[1:]
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(a, "-") || a == "-" {
|
||||
break
|
||||
}
|
||||
words = words[1:]
|
||||
if strings.HasPrefix(a, "--") {
|
||||
continue
|
||||
}
|
||||
if f := a[len(a)-1:]; strings.Contains(opts, f) && len(words) > 0 {
|
||||
words = words[1:]
|
||||
}
|
||||
}
|
||||
// timeout's duration, nice's adjustment as a word: a number before the command.
|
||||
if (base == "timeout") && len(words) > 0 {
|
||||
words = words[1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
func isAssignment(w string) bool {
|
||||
i := strings.Index(w, "=")
|
||||
if i <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, r := range w[:i] {
|
||||
if !(r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sshOptionsWithValue are ssh's single-letter options that take a value.
|
||||
const sshOptionsWithValue = "BbcDEeFIiJLlmOoPpQRSWw"
|
||||
|
||||
func (g Guard) judgeSSH(name string, args []string, line string) Verdict {
|
||||
var options, jumps []string
|
||||
user := ""
|
||||
dest, rest := "", []string(nil)
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if a == "--" {
|
||||
if i+1 < len(args) {
|
||||
dest, rest = args[i+1], args[i+2:]
|
||||
}
|
||||
break
|
||||
}
|
||||
if name == "autossh" && strings.HasPrefix(a, "-M") {
|
||||
if a == "-M" {
|
||||
i++ // autossh's own: the monitoring port
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(a, "-") && len(a) > 1 {
|
||||
options = append(options, a)
|
||||
if name == "mosh" || strings.HasPrefix(a, "--") {
|
||||
continue // mosh's own options are long, each with its value after `=`
|
||||
}
|
||||
for j := 1; j < len(a); j++ {
|
||||
if !strings.ContainsRune(sshOptionsWithValue, rune(a[j])) {
|
||||
continue
|
||||
}
|
||||
value := a[j+1:]
|
||||
if value == "" && i+1 < len(args) {
|
||||
i++
|
||||
value = args[i]
|
||||
options = append(options, value)
|
||||
}
|
||||
switch a[j] {
|
||||
case 'J':
|
||||
jumps = append(jumps, strings.Split(value, ",")...)
|
||||
case 'l':
|
||||
user = value
|
||||
case 'o':
|
||||
k, v, _ := strings.Cut(value, "=")
|
||||
switch strings.ToLower(strings.TrimSpace(k)) {
|
||||
case "proxyjump":
|
||||
jumps = append(jumps, strings.Split(v, ",")...)
|
||||
case "user":
|
||||
user = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
dest, rest = a, args[i+1:]
|
||||
break
|
||||
}
|
||||
if dest == "" {
|
||||
return Verdict{}
|
||||
}
|
||||
host, destUser := hostOf(dest)
|
||||
if destUser != "" {
|
||||
user = destUser
|
||||
}
|
||||
if g.SSH != nil && name != "mosh" {
|
||||
if h, u, j := g.SSH(options, dest); h != "" {
|
||||
host, user = h, u
|
||||
jumps = append(jumps, j...)
|
||||
}
|
||||
}
|
||||
remote := strings.Join(rest, " ")
|
||||
for _, jump := range jumps {
|
||||
jh, ju := hostOf(jump)
|
||||
if machine, ok := g.meshHost(jh); ok && ju != ForgeUser {
|
||||
return g.refuseSSH(machine, jh, remote, line)
|
||||
}
|
||||
}
|
||||
if user == ForgeUser {
|
||||
return Verdict{} // the forge's account: git and nothing else (stated in ADR 0245)
|
||||
}
|
||||
if machine, ok := g.meshHost(host); ok {
|
||||
return g.refuseSSH(machine, host, remote, line)
|
||||
}
|
||||
return Verdict{}
|
||||
}
|
||||
|
||||
// judgeCopy reads scp's, sftp's and rsync's remote paths, `[user@]host:path` or `scp://[user@]host/…`.
|
||||
func (g Guard) judgeCopy(name string, args []string, line string) Verdict {
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if strings.HasPrefix(a, "-") {
|
||||
if name != "rsync" && len(a) == 2 && strings.ContainsRune("cFiJloPSXBRD", rune(a[1])) {
|
||||
i++
|
||||
}
|
||||
if name == "rsync" && (a == "-e" || a == "--rsh") {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
var host, user string
|
||||
if strings.Contains(a, "://") {
|
||||
host, user = hostOf(a)
|
||||
} else if colon := strings.Index(a, ":"); colon > 0 && !strings.Contains(a[:colon], "/") {
|
||||
host, user = hostOf(a[:colon])
|
||||
} else if name == "sftp" {
|
||||
host, user = hostOf(a)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
if user == ForgeUser {
|
||||
continue
|
||||
}
|
||||
if g.SSH != nil {
|
||||
if h, u, _ := g.SSH(nil, host); h != "" {
|
||||
host = h
|
||||
if u == ForgeUser && user == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if machine, ok := g.meshHost(host); ok {
|
||||
return g.refuseSSH(machine, host, "", line)
|
||||
}
|
||||
}
|
||||
return Verdict{}
|
||||
}
|
||||
|
||||
// hostOf reads `[user@]host[:port]`, `ssh://[user@]host[:port]/…` or `[v6]`.
|
||||
func hostOf(dest string) (host, user string) {
|
||||
if strings.Contains(dest, "://") {
|
||||
if u, err := url.Parse(dest); err == nil {
|
||||
return strings.ToLower(u.Hostname()), u.User.Username()
|
||||
}
|
||||
}
|
||||
if at := strings.LastIndex(dest, "@"); at >= 0 {
|
||||
user, dest = dest[:at], dest[at+1:]
|
||||
}
|
||||
if strings.HasPrefix(dest, "[") {
|
||||
if end := strings.Index(dest, "]"); end > 0 {
|
||||
return strings.ToLower(dest[1:end]), user
|
||||
}
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(dest); err == nil && strings.Count(dest, ":") == 1 {
|
||||
dest = h
|
||||
}
|
||||
return strings.TrimSuffix(strings.ToLower(dest), "."), user
|
||||
}
|
||||
|
||||
// meshHost says whether a host is one of the mesh's machines, and which when it can tell.
|
||||
func (g Guard) meshHost(host string) (string, bool) {
|
||||
host = strings.TrimSuffix(strings.ToLower(strings.Trim(host, "[]")), ".")
|
||||
if host == "" {
|
||||
return "", false
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
for _, m := range g.Data.Machines {
|
||||
for _, a := range m.Addresses {
|
||||
if b := net.ParseIP(a); b != nil && b.Equal(ip) {
|
||||
return m.Name, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
for _, m := range g.Data.Machines {
|
||||
if host == strings.ToLower(m.Name) {
|
||||
return m.Name, true
|
||||
}
|
||||
for _, d := range m.Domains {
|
||||
if host == d || strings.HasSuffix(host, "."+d) {
|
||||
return m.Name, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(host, InternalSuffix) {
|
||||
return "", true
|
||||
}
|
||||
if g.Resolve != nil && !strings.Contains(host, "/") {
|
||||
for _, a := range g.Resolve(host) {
|
||||
if m, ok := g.meshHost(a); ok && net.ParseIP(a) != nil {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// writesProtected is the protected file a command writes, or "".
|
||||
func writesProtected(name string, args []string) string {
|
||||
protected := func(a string) string {
|
||||
a = strings.Trim(a, `"'`)
|
||||
if strings.HasPrefix(a, "of=") {
|
||||
a = a[3:]
|
||||
}
|
||||
if protectedFiles[filepath.Clean(a)] {
|
||||
return filepath.Clean(a)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// A redirect onto it, from any command.
|
||||
for i, a := range args {
|
||||
if (a == ">" || a == ">>") && i+1 < len(args) {
|
||||
if p := protected(args[i+1]); p != "" {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
operands := func() []string {
|
||||
var out []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == ">" || args[i] == ">>" || args[i] == "<" {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out = append(out, args[i])
|
||||
}
|
||||
return out
|
||||
}()
|
||||
switch name {
|
||||
case "tee", "vi", "vim", "nvim", "nano", "emacs", "ed", "ex", "micro", "hx", "helix", "kak", "gedit", "code",
|
||||
"truncate", "chattr", "chmod", "chown", "rm", "unlink", "shred", "dd", "sponge", "visudo", "vipw":
|
||||
for _, a := range operands {
|
||||
if p := protected(a); p != "" {
|
||||
return p
|
||||
}
|
||||
}
|
||||
case "sed", "perl", "ruby", "awk", "gawk":
|
||||
inPlace := false
|
||||
for _, a := range operands {
|
||||
switch {
|
||||
case name == "awk" || name == "gawk":
|
||||
inPlace = inPlace || a == "inplace"
|
||||
case strings.HasPrefix(a, "--in-place"):
|
||||
inPlace = true
|
||||
case strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") && strings.Contains(a, "i"):
|
||||
inPlace = true
|
||||
}
|
||||
}
|
||||
if inPlace {
|
||||
for _, a := range operands {
|
||||
if p := protected(a); p != "" {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
case "cp", "mv", "install", "ln", "rsync":
|
||||
var files []string
|
||||
for _, a := range operands {
|
||||
if !strings.HasPrefix(a, "-") {
|
||||
files = append(files, a)
|
||||
}
|
||||
}
|
||||
if len(files) > 1 {
|
||||
if p := protected(files[len(files)-1]); p != "" {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// replacementsFor are the mesh's tools that replace a command, the most specific first, each address once.
|
||||
func (g Guard) replacementsFor(command string) []string {
|
||||
have := commandWordsOf(command)
|
||||
type found struct {
|
||||
address string
|
||||
weight int
|
||||
replace string
|
||||
}
|
||||
var all []found
|
||||
for _, r := range g.Data.Replacements {
|
||||
best := 0
|
||||
which := ""
|
||||
for _, c := range r.Replaces {
|
||||
want := commandWordsOf(c)
|
||||
if inOrderWords(want, have) && len(want) > best {
|
||||
best, which = len(want), c
|
||||
}
|
||||
}
|
||||
if best > 0 {
|
||||
all = append(all, found{r.Address, best, which})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(all, func(i, j int) bool { return all[i].weight > all[j].weight })
|
||||
var out []string
|
||||
for _, f := range all {
|
||||
out = append(out, f.address+"\x00"+f.replace)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// callFor is how an address is called for a machine.
|
||||
func (g Guard) callFor(address, machine string) string {
|
||||
for _, r := range g.Data.Replacements {
|
||||
if r.Address != address || !r.NodeScoped {
|
||||
continue
|
||||
}
|
||||
on := machine
|
||||
if !r.Seat && len(r.On) > 0 && !containsString(r.On, machine) {
|
||||
on = r.On[0]
|
||||
}
|
||||
if on == "" {
|
||||
on = "<node>"
|
||||
}
|
||||
return on + "/" + address
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
func containsString(xs []string, s string) bool {
|
||||
for _, x := range xs {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// exact is the replacement that names this exact command, or "".
|
||||
func (g Guard) exact(command string) string {
|
||||
for _, r := range g.Data.Replacements {
|
||||
for _, c := range r.Replaces {
|
||||
if c == command {
|
||||
return r.Address
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const missingTool = "No mesh tool says it replaces this. Do not work around it: a missing tool is created in the module " +
|
||||
"that owns the thing, on the seat it occupies — say what is missing to the operator. First make sure with " +
|
||||
"`mesh_search` and the command's own words."
|
||||
|
||||
func (g Guard) refuseSSH(machine, host, remote, line string) Verdict {
|
||||
who := machine
|
||||
if who == "" {
|
||||
who = host + " (a mesh name)"
|
||||
}
|
||||
var b strings.Builder
|
||||
var named []string
|
||||
fmt.Fprintf(&b, "Refused by the mesh's guard (novox/hq ADR 0245): `%s` reaches %s round the mesh's tools.\n", cut(line, 160), who)
|
||||
switch {
|
||||
case strings.TrimSpace(remote) == "":
|
||||
target := machine
|
||||
if target == "" {
|
||||
target = "<machine>"
|
||||
}
|
||||
fmt.Fprintf(&b, "What a machine serves is `mesh_machine %s`; `mesh_search` with what you meant to run finds its tool.\n", target)
|
||||
default:
|
||||
calls := g.replacementsFor(remote)
|
||||
if len(calls) == 0 {
|
||||
b.WriteString(missingTool + "\n")
|
||||
break
|
||||
}
|
||||
b.WriteString("Call instead, through `mesh_call`:\n")
|
||||
for i, c := range calls {
|
||||
if i == 3 {
|
||||
break
|
||||
}
|
||||
address, replaces, _ := strings.Cut(c, "\x00")
|
||||
named = append(named, address)
|
||||
fmt.Fprintf(&b, " %s — replaces `%s`\n", g.callFor(address, machine), replaces)
|
||||
}
|
||||
}
|
||||
return Verdict{Refuse: true, Rule: "ssh", Machine: machine, Message: strings.TrimRight(b.String(), "\n"), Overridable: true,
|
||||
Calls: named}
|
||||
}
|
||||
|
||||
func (g Guard) refuseLocal(rule, replaced, what string) Verdict {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Refused by the mesh's guard (novox/hq ADR 0245): %s is a work-around for a mesh name. "+
|
||||
"A mesh name is the mesh's resolvers' to answer, and a machine's own lines in /etc/hosts are its node-hostname seat's.\n", what)
|
||||
var named []string
|
||||
if address := g.exact(replaced); address != "" {
|
||||
named = append(named, address)
|
||||
fmt.Fprintf(&b, "Call instead, through `mesh_call`: %s — replaces `%s`", g.callFor(address, g.Data.Node), replaced)
|
||||
} else {
|
||||
b.WriteString(missingTool)
|
||||
}
|
||||
return Verdict{Refuse: true, Rule: rule, Machine: g.Data.Node, Message: b.String(), Overridable: true, Calls: named}
|
||||
}
|
||||
|
||||
func (g Guard) hostsFile(file, line string) Verdict {
|
||||
replaced := "edit " + file
|
||||
return g.refuseLocal("hosts-file", replaced, fmt.Sprintf("writing %s (`%s`)", file, cut(line, 120)))
|
||||
}
|
||||
|
||||
func cut(s string, n int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if r := []rune(s); len(r) > n {
|
||||
return string(r[:n-1]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// commandWordsOf are a command line's words as matched against a replaced command (the mesh MCP server's search rule):
|
||||
// lower-cased, a program by path reduced to its name, sudo left out.
|
||||
func commandWordsOf(line string) []string {
|
||||
var out []string
|
||||
for i, w := range strings.Fields(strings.ToLower(line)) {
|
||||
if i == 0 || strings.HasPrefix(w, "/") {
|
||||
w = path.Base(w)
|
||||
}
|
||||
if w == "sudo" {
|
||||
continue
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func inOrderWords(want, have []string) bool {
|
||||
i := 0
|
||||
for _, h := range have {
|
||||
if i < len(want) && h == want[i] {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return len(want) > 0 && i == len(want)
|
||||
}
|
||||
|
||||
// ---- the shell's words ------------------------------------------------------------------------------
|
||||
|
||||
// simpleCommands splits a command line as a shell would into its simple commands' words: quotes
|
||||
// removed, `;`, `&`, `|`, `&&`, `||`, newlines, parentheses and backticks between commands, `$(` opening
|
||||
// one; `>` and `>>` kept as words of their own, before what they redirect to.
|
||||
func simpleCommands(line string) [][]string {
|
||||
var out [][]string
|
||||
var words []string
|
||||
var cur strings.Builder
|
||||
inWord := false
|
||||
flushWord := func() {
|
||||
if inWord {
|
||||
words = append(words, cur.String())
|
||||
cur.Reset()
|
||||
inWord = false
|
||||
}
|
||||
}
|
||||
flushCommand := func() {
|
||||
flushWord()
|
||||
if len(words) > 0 {
|
||||
out = append(out, words)
|
||||
}
|
||||
words = nil
|
||||
}
|
||||
r := []rune(line)
|
||||
for i := 0; i < len(r); i++ {
|
||||
c := r[i]
|
||||
switch {
|
||||
case c == '\\' && i+1 < len(r):
|
||||
i++
|
||||
if r[i] != '\n' {
|
||||
cur.WriteRune(r[i])
|
||||
inWord = true
|
||||
}
|
||||
case c == '\'':
|
||||
inWord = true
|
||||
for i++; i < len(r) && r[i] != '\''; i++ {
|
||||
cur.WriteRune(r[i])
|
||||
}
|
||||
case c == '"':
|
||||
inWord = true
|
||||
for i++; i < len(r) && r[i] != '"'; i++ {
|
||||
if r[i] == '\\' && i+1 < len(r) && strings.ContainsRune("\"\\$`", r[i+1]) {
|
||||
i++
|
||||
}
|
||||
cur.WriteRune(r[i])
|
||||
}
|
||||
case c == '$' && i+1 < len(r) && r[i+1] == '(':
|
||||
flushCommand()
|
||||
i++
|
||||
case c == ';' || c == '&' || c == '|' || c == '\n' || c == '(' || c == ')' || c == '`':
|
||||
flushCommand()
|
||||
case c == '>' || c == '<':
|
||||
// A file descriptor before it (`2>`) is not a word.
|
||||
if inWord && isDigits(cur.String()) {
|
||||
cur.Reset()
|
||||
inWord = false
|
||||
}
|
||||
flushWord()
|
||||
op := string(c)
|
||||
if c == '>' && i+1 < len(r) && r[i+1] == '>' {
|
||||
op = ">>"
|
||||
i++
|
||||
}
|
||||
if i+1 < len(r) && (r[i+1] == '&' || r[i+1] == '|') {
|
||||
i++
|
||||
}
|
||||
words = append(words, op)
|
||||
case c == ' ' || c == '\t':
|
||||
flushWord()
|
||||
case c == '#' && !inWord:
|
||||
for i < len(r) && r[i] != '\n' {
|
||||
i++
|
||||
}
|
||||
flushCommand()
|
||||
default:
|
||||
cur.WriteRune(c)
|
||||
inWord = true
|
||||
}
|
||||
}
|
||||
flushCommand()
|
||||
return out
|
||||
}
|
||||
|
||||
func isDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// substitutions are the command lines a quoted word runs in a shell of its own: `$(…)` and `…`.
|
||||
func substitutions(word string) []string {
|
||||
var out []string
|
||||
for {
|
||||
i := strings.Index(word, "$(")
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
rest := word[i+2:]
|
||||
depth, end := 1, len(rest)
|
||||
for j, c := range rest {
|
||||
if c == '(' {
|
||||
depth++
|
||||
} else if c == ')' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = j
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, rest[:end])
|
||||
if end >= len(rest) {
|
||||
break
|
||||
}
|
||||
word = rest[end+1:]
|
||||
}
|
||||
parts := strings.Split(word, "`")
|
||||
for i := 1; i < len(parts); i += 2 {
|
||||
out = append(out, parts[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
package main
|
||||
|
||||
// The guard as the hook runs it (novox/hq ADR 0245): `claude-code guard <guard.json>`, the tool call on
|
||||
// standard input, exit 2 with the reason on standard error to refuse — the agent reads it — and 0 to let it be.
|
||||
//
|
||||
// **The operator's override.** `MESH_GUARD_OVERRIDE=<why>` lets one session through a refusal — never the
|
||||
// refusal of a command naming the override itself — and only:
|
||||
//
|
||||
// - **from the session's environment as it was started**: read from /proc/<pid>/environ of the agent's own
|
||||
// process, which is fixed at exec. A command's `export`, an `env` key in a settings file, a variable the
|
||||
// session sets for its tools — none of them reaches it. A nested session the agent starts with the
|
||||
// variable is refused before it starts: the command names the override;
|
||||
// - **recorded**: every override is a line in the module's guard.log, with why, the command and the
|
||||
// machine — and an override that cannot be recorded is not honoured. Refusals are recorded the same way.
|
||||
//
|
||||
// A guard that fails — a fault in this code — exits 1, which the agent shows and does not block on: a guard
|
||||
// that refused every command would stop the session the operator needs to repair it.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// procRoot and parentPID are where the session's process is found; a test points them at its own.
|
||||
var (
|
||||
procRoot = "/proc"
|
||||
parentPID = os.Getppid
|
||||
)
|
||||
|
||||
// GuardLogMax is the size the guard's record grows to before it is kept once as guard.log.1.
|
||||
const GuardLogMax = 1 << 20
|
||||
|
||||
// GuardEntry is one line of the guard's record.
|
||||
type GuardEntry struct {
|
||||
At string `json:"at"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Decision string `json:"decision"` // refused | overridden
|
||||
Rule string `json:"rule"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Tool string `json:"tool"`
|
||||
Command string `json:"command"`
|
||||
Why string `json:"why,omitempty"`
|
||||
// Tools are the tools the refusal named instead.
|
||||
Tools []string `json:"tools,omitempty"`
|
||||
Session string `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
// RecordGuard appends one line to the record.
|
||||
func RecordGuard(path string, e GuardEntry) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("no record is kept here")
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil && info.Size() > GuardLogMax {
|
||||
_ = os.Rename(path, path+".1")
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.Marshal(e)
|
||||
if _, err := f.Write(append(raw, '\n')); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// ReadGuardLog is the record's last lines, newest last.
|
||||
func ReadGuardLog(path string, last int) []GuardEntry {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return []GuardEntry{}
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
if len(lines) > last {
|
||||
lines = lines[len(lines)-last:]
|
||||
}
|
||||
out := []GuardEntry{}
|
||||
for _, l := range lines {
|
||||
var e GuardEntry
|
||||
if json.Unmarshal([]byte(l), &e) == nil {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sshDashG reads a destination as ssh does: `ssh -G` prints the configuration it would use and connects
|
||||
// to nothing, so an alias, a HostName and a ProxyJump in ~/.ssh/config are what is judged.
|
||||
func sshDashG(options []string, destination string) (host, user string, jumps []string) {
|
||||
if _, err := exec.LookPath("ssh"); err != nil {
|
||||
return "", "", nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
args := append([]string{"-G"}, options...)
|
||||
args = append(args, "--", destination)
|
||||
out, err := exec.CommandContext(ctx, "ssh", args...).Output()
|
||||
if err != nil {
|
||||
return "", "", nil
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
k, v, ok := strings.Cut(strings.TrimSpace(line), " ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch k {
|
||||
case "hostname":
|
||||
host = strings.ToLower(v)
|
||||
case "user":
|
||||
user = v
|
||||
case "proxyjump":
|
||||
if v != "none" {
|
||||
jumps = append(jumps, strings.Split(v, ",")...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return host, user, jumps
|
||||
}
|
||||
|
||||
func quickLookup(name string) []string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
addrs, _ := net.DefaultResolver.LookupHost(ctx, name)
|
||||
return addrs
|
||||
}
|
||||
|
||||
// sessionEnvironment is the environment the agent's own process was started with: the nearest ancestor
|
||||
// that is the agent, read from what the kernel kept at its exec.
|
||||
func sessionEnvironment(proc string, pid int) map[string]string {
|
||||
for i := 0; i < 32 && pid > 1; i++ {
|
||||
cmdline, _ := os.ReadFile(filepath.Join(proc, strconv.Itoa(pid), "cmdline"))
|
||||
if isAgent(cmdline) {
|
||||
raw, err := os.ReadFile(filepath.Join(proc, strconv.Itoa(pid), "environ"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if k, v, ok := strings.Cut(string(kv), "="); ok {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
stat, err := os.ReadFile(filepath.Join(proc, strconv.Itoa(pid), "stat"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// The parent is the fourth field, after the command's name in parentheses (which may hold spaces).
|
||||
closing := bytes.LastIndexByte(stat, ')')
|
||||
fields := strings.Fields(string(stat[closing+1:]))
|
||||
if len(fields) < 2 {
|
||||
return nil
|
||||
}
|
||||
pid, _ = strconv.Atoi(fields[1])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAgent says whether a process's command line is the agent's: its native binary, or its package run by node.
|
||||
func isAgent(cmdline []byte) bool {
|
||||
args := strings.Split(strings.TrimRight(string(cmdline), "\x00"), "\x00")
|
||||
if len(args) == 0 || args[0] == "" {
|
||||
return false
|
||||
}
|
||||
if filepath.Base(args[0]) == "claude" {
|
||||
return true
|
||||
}
|
||||
if b := filepath.Base(args[0]); (b == "node" || b == "bun") && len(args) > 1 {
|
||||
return filepath.Base(args[1]) == "claude" || strings.Contains(args[1], "@anthropic-ai/claude-code")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runGuard is the hook: the tool call in, the verdict out as the exit code.
|
||||
func runGuard(dataPath string, stdin io.Reader, stderr io.Writer) (code int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(stderr, "the mesh's guard failed and judged nothing: %v\n", r)
|
||||
code = 1
|
||||
}
|
||||
}()
|
||||
var in HookInput
|
||||
if err := json.NewDecoder(stdin).Decode(&in); err != nil {
|
||||
fmt.Fprintf(stderr, "the mesh's guard could not read the tool call: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
var data GuardData
|
||||
if raw, err := os.ReadFile(dataPath); err == nil {
|
||||
_ = json.Unmarshal(raw, &data)
|
||||
}
|
||||
g := Guard{Data: data, SSH: sshDashG, Resolve: quickLookup}
|
||||
v := g.Judge(in)
|
||||
if !v.Refuse {
|
||||
return 0
|
||||
}
|
||||
command, _ := in.ToolInput["command"].(string)
|
||||
if command == "" {
|
||||
command, _ = in.ToolInput["file_path"].(string)
|
||||
}
|
||||
entry := GuardEntry{At: time.Now().UTC().Format(time.RFC3339), Node: data.Node, Decision: "refused", Rule: v.Rule,
|
||||
Machine: v.Machine, Tool: in.ToolName, Command: cut(command, 400), Session: in.SessionID, Tools: v.Calls}
|
||||
if v.Overridable {
|
||||
if why := strings.TrimSpace(sessionEnvironment(procRoot, parentPID())[OverrideVar]); why != "" {
|
||||
entry.Decision, entry.Why = "overridden", cut(why, 200)
|
||||
if err := RecordGuard(data.Log, entry); err == nil {
|
||||
return 0
|
||||
} else {
|
||||
v.Message += fmt.Sprintf("\nThe operator's override is set, and could not be recorded (%v): an override "+
|
||||
"that is not recorded is not honoured.", err)
|
||||
entry.Decision, entry.Why = "refused", ""
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = RecordGuard(data.Log, entry)
|
||||
fmt.Fprintln(stderr, v.Message)
|
||||
return 2
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// meshForTheGuard is a mesh of two machines and the verbs that replace what an agent runs over ssh, as the
|
||||
// controller's records say them.
|
||||
func meshForTheGuard() GuardData {
|
||||
tools := json.RawMessage(`{"seats":[
|
||||
{"seat":"mesh-controller","scope":"mesh","tools":[{"name":"node","description":"What one machine reported it can do.","replaces":["hostnamectl","uptime"]}]},
|
||||
{"seat":"node-service-manager","scope":"node","tools":[
|
||||
{"name":"status","description":"One unit as the service manager sees it now: its states.","replaces":["systemctl status","systemctl is-active"]},
|
||||
{"name":"restart","description":"Restart one unit.","replaces":["systemctl restart"]},
|
||||
{"name":"journal","description":"The last lines of one unit's journal.","replaces":["journalctl"]}]},
|
||||
{"seat":"node-hostname","scope":"node","tools":[
|
||||
{"name":"add","description":"Add one address and its names to the operator's lines of /etc/hosts.","replaces":["edit /etc/hosts","HOSTALIASES"]}]}]}`)
|
||||
modules := json.RawMessage(`[{"module":"docker","on":["anchor"],"replaces":{"docker_logs":["docker logs"],"docker_list":["docker ps"]}},
|
||||
{"module":"systemd","on":["anchor","laptop"]}]`)
|
||||
return GuardData{Node: "laptop", Replacements: ReplacementsOf(tools, modules), Machines: []Machine{
|
||||
{Name: "anchor", Domains: []string{"anchor" + InternalSuffix, "anchor.example"}, Addresses: []string{"192.0.2.10", "10.10.0.1"}},
|
||||
{Name: "laptop", Domains: []string{"laptop" + InternalSuffix}, Addresses: []string{"10.10.0.4"}},
|
||||
}}
|
||||
}
|
||||
|
||||
// noSSH reads a destination from the command line alone; aliases reads one alias as ssh's configuration would.
|
||||
func aliases(options []string, destination string) (string, string, []string) {
|
||||
host, user := hostOf(destination)
|
||||
switch host {
|
||||
case "a":
|
||||
return "anchor.example", "operator", nil
|
||||
case "via-anchor":
|
||||
return "203.0.113.5", "operator", []string{"anchor"}
|
||||
case "forge":
|
||||
return "git.anchor" + InternalSuffix, "git", nil
|
||||
}
|
||||
if user == "" {
|
||||
user = "operator"
|
||||
}
|
||||
for i, o := range options {
|
||||
if o == "-l" && i+1 < len(options) {
|
||||
user = options[i+1]
|
||||
}
|
||||
}
|
||||
return host, user, nil
|
||||
}
|
||||
|
||||
func guard() Guard {
|
||||
return Guard{Data: meshForTheGuard(), SSH: aliases, Resolve: func(name string) []string {
|
||||
if name == "nas.lan" {
|
||||
return []string{"192.0.2.10"}
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
func bash(command string) HookInput {
|
||||
return HookInput{ToolName: "Bash", ToolInput: map[string]any{"command": command}}
|
||||
}
|
||||
|
||||
// **ssh to a mesh machine is refused**, by every name and address it has and however the shell spells it,
|
||||
// and the refusal names the verb that does the job on that machine (novox/hq ADR 0245).
|
||||
func TestSSHToAMeshMachineIsRefusedNamingTheTool(t *testing.T) {
|
||||
g := guard()
|
||||
for command, want := range map[string]string{
|
||||
"ssh anchor journalctl -u mesh-controller -n 50": "anchor/node-service-manager.journal",
|
||||
"ssh anchor 'sudo journalctl -fu sshd'": "anchor/node-service-manager.journal",
|
||||
"ssh -p 22 root@anchor.example systemctl status nats": "anchor/node-service-manager.status",
|
||||
"ssh anchor.internal systemctl restart nats": "anchor/node-service-manager.restart",
|
||||
"ssh -o StrictHostKeyChecking=no 10.10.0.1 docker ps -a": "anchor/docker.docker_list",
|
||||
"ssh anchor docker logs --tail 100 nats": "anchor/docker.docker_logs",
|
||||
"ssh laptop docker logs x": "anchor/docker.docker_logs",
|
||||
"ssh a uptime": "mesh-controller.node",
|
||||
"cd /tmp && ssh anchor journalctl": "anchor/node-service-manager.journal",
|
||||
"sudo -u root ssh anchor journalctl": "anchor/node-service-manager.journal",
|
||||
"timeout 10 ssh anchor journalctl": "anchor/node-service-manager.journal",
|
||||
"env LANG=C ssh anchor journalctl": "anchor/node-service-manager.journal",
|
||||
"bash -c 'ssh anchor journalctl -u x'": "anchor/node-service-manager.journal",
|
||||
`echo "$(ssh anchor journalctl -n 5)" | tail`: "anchor/node-service-manager.journal",
|
||||
"for n in anchor laptop; do ssh $n uptime; done; ssh anchor uptime": "mesh-controller.node",
|
||||
"ssh -J anchor 203.0.113.9 journalctl": "anchor/node-service-manager.journal",
|
||||
"ssh via-anchor journalctl": "anchor/node-service-manager.journal",
|
||||
"ssh nas.lan journalctl": "anchor/node-service-manager.journal",
|
||||
"ssh ssh://anchor:2222 journalctl": "anchor/node-service-manager.journal",
|
||||
} {
|
||||
v := g.Judge(bash(command))
|
||||
if !v.Refuse || v.Rule != "ssh" {
|
||||
t.Errorf("%q was not refused: %+v", command, v)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(v.Message, want) {
|
||||
t.Errorf("%q: the refusal does not name %s:\n%s", command, want, v.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What no tool replaces is refused all the same, saying a tool is created, never worked around; a shell
|
||||
// on a machine is pointed at what the machine serves; copying a file off one is refused too.
|
||||
func TestSSHWithNoToolForItSaysCreateOne(t *testing.T) {
|
||||
g := guard()
|
||||
for command, want := range map[string]string{
|
||||
"ssh anchor cat /var/lib/thing/state.json": "a missing tool is created in the module",
|
||||
"ssh anchor": "mesh_machine anchor",
|
||||
"ssh build.internal ls": "a missing tool is created",
|
||||
"scp anchor:/etc/nats/nats.conf /tmp/": "mesh_machine anchor",
|
||||
"rsync -av root@10.10.0.4:/srv/ ./srv/": "mesh_machine laptop",
|
||||
"sftp anchor.example": "mesh_machine anchor",
|
||||
"mosh anchor": "mesh_machine anchor",
|
||||
"autossh -M 0 -N -L 5432:localhost:5432 anchor": "mesh_machine anchor",
|
||||
} {
|
||||
v := g.Judge(bash(command))
|
||||
if !v.Refuse {
|
||||
t.Errorf("%q was not refused", command)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(v.Message, want) {
|
||||
t.Errorf("%q: want %q in:\n%s", command, want, v.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **What is not a work-around passes**: git over ssh to the forge above all, and ssh beyond the mesh, and
|
||||
// every command that merely mentions ssh or a machine.
|
||||
func TestWhatIsNotAWorkAroundPasses(t *testing.T) {
|
||||
g := guard()
|
||||
for _, command := range []string{
|
||||
"git push -u origin feat/x",
|
||||
"git clone ssh://git@git.anchor.internal:222/novox/hq.git",
|
||||
"git fetch ssh://git@git.anchor.internal:222/novox/hq.git main",
|
||||
`GIT_SSH_COMMAND="ssh -i ~/.ssh/forge -o IdentitiesOnly=yes" git push origin HEAD`,
|
||||
"git -c core.sshCommand='ssh -p 222' pull",
|
||||
"ssh -T git@git.anchor.internal -p 222",
|
||||
"ssh forge",
|
||||
"scp git@git.anchor.internal:novox/hq.git .",
|
||||
"ssh github.com",
|
||||
"ssh -T git@github.com",
|
||||
"ssh user@203.0.113.7 uptime",
|
||||
"ssh -V",
|
||||
"ssh-keygen -t ed25519 -f ~/.ssh/x",
|
||||
"ssh-add -l",
|
||||
"man ssh",
|
||||
"grep -r 'ssh anchor' docs/",
|
||||
`git commit -m "Stop running ssh anchor journalctl"`,
|
||||
"echo anchor && journalctl --user -n 5",
|
||||
"cat /etc/hosts",
|
||||
"getent hosts anchor.internal",
|
||||
"cp /etc/hosts /tmp/hosts.copy",
|
||||
"sed -n 1,5p /etc/hosts",
|
||||
"curl -s http://anchor.internal:8080/health",
|
||||
"go test ./...",
|
||||
} {
|
||||
if v := g.Judge(bash(command)); v.Refuse {
|
||||
t.Errorf("%q was refused:\n%s", command, v.Message)
|
||||
}
|
||||
}
|
||||
if v := g.Judge(HookInput{ToolName: "Read", ToolInput: map[string]any{"file_path": "/etc/hosts"}}); v.Refuse {
|
||||
t.Errorf("reading /etc/hosts was refused")
|
||||
}
|
||||
if v := g.Judge(HookInput{ToolName: "Write", ToolInput: map[string]any{"file_path": "/tmp/hosts"}}); v.Refuse {
|
||||
t.Errorf("writing a file named hosts elsewhere was refused")
|
||||
}
|
||||
}
|
||||
|
||||
// **The local work-arounds for a mesh name are refused** with the machine's own hosts verb: writing
|
||||
// /etc/hosts by any means, the agent's own Edit and Write included, and HOSTALIASES.
|
||||
func TestTheLocalWorkAroundsForAMeshNameAreRefused(t *testing.T) {
|
||||
g := guard()
|
||||
for _, command := range []string{
|
||||
"echo '10.10.0.1 anchor' | sudo tee -a /etc/hosts",
|
||||
"sudo sh -c 'echo 10.10.0.1 anchor >> /etc/hosts'",
|
||||
"echo x >>/etc/hosts",
|
||||
"sudo sed -i 's/old/new/' /etc/hosts",
|
||||
"sudo sed -Ei.bak 's/a/b/' /etc/hosts",
|
||||
"sudo vim /etc/hosts",
|
||||
"sudo cp /tmp/hosts /etc/hosts",
|
||||
"sudo install -m 644 hosts /etc/hosts",
|
||||
"HOSTALIASES=~/.hosts curl http://anchor/",
|
||||
"export HOSTALIASES=/tmp/aliases",
|
||||
} {
|
||||
v := g.Judge(bash(command))
|
||||
if !v.Refuse {
|
||||
t.Errorf("%q was not refused", command)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(v.Message, "laptop/node-hostname.add") {
|
||||
t.Errorf("%q: the refusal does not name this machine's hosts verb:\n%s", command, v.Message)
|
||||
}
|
||||
}
|
||||
for _, tool := range []string{"Edit", "Write", "MultiEdit"} {
|
||||
v := g.Judge(HookInput{ToolName: tool, ToolInput: map[string]any{"file_path": "/etc/../etc/hosts"}})
|
||||
if !v.Refuse || !strings.Contains(v.Message, "node-hostname.add") {
|
||||
t.Errorf("%s of /etc/hosts was not refused with the verb: %+v", tool, v)
|
||||
}
|
||||
}
|
||||
// The resolver file has no verb that replaces writing it: the refusal says to create one.
|
||||
v := g.Judge(bash("echo nameserver 192.0.2.53 | sudo tee /etc/resolv.conf"))
|
||||
if !v.Refuse || !strings.Contains(v.Message, "a missing tool is created") {
|
||||
t.Errorf("writing the resolver file: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// The agent never names the operator's override, and no override lifts that refusal.
|
||||
func TestACommandNamingTheOverrideIsRefused(t *testing.T) {
|
||||
g := guard()
|
||||
for _, command := range []string{
|
||||
"export " + OverrideVar + "=because",
|
||||
OverrideVar + "=x claude -p 'ssh anchor uptime'",
|
||||
"env " + OverrideVar + "=1 bash",
|
||||
} {
|
||||
v := g.Judge(bash(command))
|
||||
if !v.Refuse || v.Rule != "override-named" || v.Overridable {
|
||||
t.Errorf("%q: %+v", command, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without the mesh's answer — a controller not asked yet — the mesh's own names are still refused.
|
||||
func TestWithoutTheMeshsAnswerItsNamesAreStillRefused(t *testing.T) {
|
||||
g := Guard{}
|
||||
if v := g.Judge(bash("ssh anchor.internal journalctl")); !v.Refuse || !strings.Contains(v.Message, "a missing tool is created") {
|
||||
t.Errorf("an internal name without data: %+v", v)
|
||||
}
|
||||
if v := g.Judge(bash("echo x | sudo tee -a /etc/hosts")); !v.Refuse {
|
||||
t.Errorf("the hosts file without data: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeProc lays out a process tree: the agent started with env, a shell under it, the hook under that.
|
||||
func fakeProc(t *testing.T, agentEnv []string) (root string, hookParent int) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
write := func(pid, ppid int, cmdline []string, env []string) {
|
||||
dir := filepath.Join(root, strconv.Itoa(pid))
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(dir, "cmdline"), []byte(strings.Join(cmdline, "\x00")+"\x00"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(dir, "stat"), []byte(strconv.Itoa(pid)+" (some (odd) name) S "+strconv.Itoa(ppid)+" 1 1"), 0o644)
|
||||
}
|
||||
write(100, 1, []string{"/opt/claude-code/bin/claude", "--resume"}, agentEnv)
|
||||
write(200, 100, []string{"/bin/sh", "-c", "hook"}, []string{OverrideVar + "=set-by-the-session"})
|
||||
return root, 200
|
||||
}
|
||||
|
||||
// **The override is the operator's, from the session as it was started, and recorded** — and a value a
|
||||
// command or a setting put in the session's later environment is not it.
|
||||
func TestTheOverrideIsTheOperatorsAndRecorded(t *testing.T) {
|
||||
data := meshForTheGuard()
|
||||
data.Log = filepath.Join(t.TempDir(), "guard.log")
|
||||
path := filepath.Join(t.TempDir(), "guard.json")
|
||||
raw, _ := json.Marshal(data)
|
||||
_ = os.WriteFile(path, raw, 0o644)
|
||||
was, wasPID := procRoot, parentPID
|
||||
t.Cleanup(func() { procRoot, parentPID = was, wasPID })
|
||||
run := func(command string) (int, string) {
|
||||
var stderr bytes.Buffer
|
||||
in, _ := json.Marshal(bash(command))
|
||||
return runGuard(path, bytes.NewReader(in), &stderr), stderr.String()
|
||||
}
|
||||
|
||||
// Not set where the session started: the later shell's value is not the operator's.
|
||||
root, hook := fakeProc(t, []string{"HOME=/home/operator"})
|
||||
procRoot, parentPID = root, func() int { return hook }
|
||||
if code, says := run("ssh anchor journalctl"); code != 2 || !strings.Contains(says, "anchor/node-service-manager.journal") {
|
||||
t.Fatalf("refused with %d: %s", code, says)
|
||||
}
|
||||
// Set by the operator before the session: let through, with why on record.
|
||||
root, hook = fakeProc(t, []string{"HOME=/home/operator", OverrideVar + "=reading the broker's log by hand while the systemd module is down"})
|
||||
procRoot, parentPID = root, func() int { return hook }
|
||||
if code, says := run("ssh anchor journalctl -u nats"); code != 0 {
|
||||
t.Fatalf("the operator's override was not honoured: %d %s", code, says)
|
||||
}
|
||||
// Never for a command naming the override itself.
|
||||
if code, _ := run("export " + OverrideVar + "=x"); code != 2 {
|
||||
t.Fatalf("a command naming the override passed under it: %d", code)
|
||||
}
|
||||
record := ReadGuardLog(data.Log, 10)
|
||||
if len(record) != 3 || record[0].Decision != "refused" || record[1].Decision != "overridden" ||
|
||||
!strings.Contains(record[1].Why, "systemd module is down") || record[1].Machine != "anchor" || record[2].Rule != "override-named" {
|
||||
t.Fatalf("the record: %+v", record)
|
||||
}
|
||||
// An override that cannot be recorded is not honoured.
|
||||
data.Log = filepath.Join(t.TempDir(), "no", "such", "dir", "guard.log")
|
||||
raw, _ = json.Marshal(data)
|
||||
_ = os.WriteFile(path, raw, 0o644)
|
||||
if code, says := run("ssh anchor journalctl"); code != 2 || !strings.Contains(says, "could not be recorded") {
|
||||
t.Fatalf("an override not recorded was honoured: %d %s", code, says)
|
||||
}
|
||||
}
|
||||
|
||||
// What passes is not recorded, and a guard that cannot read the call says so and blocks nothing.
|
||||
func TestTheHookExitsAsTheAgentReadsIt(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "guard.json")
|
||||
_ = os.WriteFile(path, []byte(`{}`), 0o644)
|
||||
var stderr bytes.Buffer
|
||||
in, _ := json.Marshal(bash("git push"))
|
||||
if code := runGuard(path, bytes.NewReader(in), &stderr); code != 0 || stderr.Len() != 0 {
|
||||
t.Errorf("git push: %d %q", code, stderr.String())
|
||||
}
|
||||
if code := runGuard(path, strings.NewReader("not json"), &stderr); code != 1 {
|
||||
t.Errorf("an unreadable call: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAgentsProcessIsKnown(t *testing.T) {
|
||||
for cmdline, want := range map[string]bool{
|
||||
"/opt/claude-code/bin/claude\x00--resume\x00": true,
|
||||
"claude\x00": true,
|
||||
"node\x00/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js\x00": true,
|
||||
"/usr/bin/node\x00/home/x/.local/bin/claude\x00": true,
|
||||
"/bin/zsh\x00": false,
|
||||
"node\x00server.js\x00": false,
|
||||
"/usr/bin/claude-code-tools\x00": false,
|
||||
} {
|
||||
if isAgent([]byte(cmdline)) != want {
|
||||
t.Errorf("%q: want %v", cmdline, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
package main
|
||||
|
||||
// The person's own items in the operator account's agent directory — the ones the mesh did not place (novox/hq
|
||||
// ADR 0216 rule 6) — read and removed through the mesh rather than over a shell on the machine.
|
||||
//
|
||||
// Removing one stays the person's act: the remove tool is the act made explicit. It is called on the person's
|
||||
// word, takes their reason, refuses anything the mesh placed (unregister owns those), keeps a copy outside the
|
||||
// agent directory before it deletes, and logs what it removed and why. Nothing here removes on its own.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// KindMemory is the account's own instruction file, ~/.claude/CLAUDE.md: never placed by the mesh, read by
|
||||
// every session of the account.
|
||||
const KindMemory = "memory"
|
||||
|
||||
// memoryName is the one name the memory kind has.
|
||||
const memoryName = "CLAUDE"
|
||||
|
||||
// RemovedDir is where, in the module's state, a removed item is kept: one dated folder per day, one folder
|
||||
// per removal inside it.
|
||||
const RemovedDir = "removed-from-home"
|
||||
|
||||
// homeKinds are the kinds the home tools take: those the status tool lists, and the memory.
|
||||
var homeKinds = map[string]string{KindSkill: "skills", KindAgent: "agents", KindCommand: "commands",
|
||||
KindOutputStyle: "output-styles", KindInstructions: "rules", KindMemory: ""}
|
||||
|
||||
func (p Paths) removedLog() string { return filepath.Join(p.State, RemovedDir, "removed.log") }
|
||||
|
||||
// homeKindOf reads a kind as a person may write it: output_style for output-style, rule for instructions.
|
||||
func homeKindOf(kind string) string {
|
||||
switch k := strings.ToLower(strings.TrimSpace(kind)); k {
|
||||
case "output_style":
|
||||
return KindOutputStyle
|
||||
case "rule", "rules":
|
||||
return KindInstructions
|
||||
case "claude.md":
|
||||
return KindMemory
|
||||
default:
|
||||
return k
|
||||
}
|
||||
}
|
||||
|
||||
// HomeItemPath is where one item of the home is, relative to the agent directory: a skill is its folder,
|
||||
// every other kind one file.
|
||||
func HomeItemPath(kind, name string) (rel string, folder bool, err error) {
|
||||
kind = homeKindOf(kind)
|
||||
sub, ok := homeKinds[kind]
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("kind is skill, agent, command, output-style, instructions (a rule file) or memory (~/.claude/CLAUDE.md), not %q", kind)
|
||||
}
|
||||
if kind == KindMemory {
|
||||
if name != "" && name != memoryName && name != memoryName+".md" {
|
||||
return "", false, fmt.Errorf("the memory is one file, CLAUDE.md; its name is %s or absent", memoryName)
|
||||
}
|
||||
return "CLAUDE.md", false, nil
|
||||
}
|
||||
name = strings.TrimSuffix(name, ".md")
|
||||
if name == "" || name == "." || name == ".." || strings.HasPrefix(name, ".") || strings.ContainsAny(name, `/\`) || strings.ContainsRune(name, 0) {
|
||||
return "", false, fmt.Errorf("%q is not an item's name: its folder, or its file without .md, as the status tool lists it", name)
|
||||
}
|
||||
if kind == KindSkill {
|
||||
return sub + "/" + name, true, nil
|
||||
}
|
||||
return sub + "/" + name + ".md", false, nil
|
||||
}
|
||||
|
||||
// placedUnder says whether the mesh placed the path, or anything inside it, and still holds it as its own: a
|
||||
// path it placed that the person deleted and then made again is theirs (PlaceHome leaves it alone).
|
||||
func placedUnder(p Paths, rel string) string {
|
||||
var placed Placed
|
||||
_ = readJSON(p.placed(), &placed)
|
||||
for at, ours := range placed {
|
||||
if ours == deletedByHand {
|
||||
continue
|
||||
}
|
||||
if at == rel || strings.HasPrefix(at, rel+"/") {
|
||||
return at
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// readItem reads one item's files by path inside it, refusing a symbolic link anywhere on the way or in it:
|
||||
// what it points at is not the home's.
|
||||
func readItem(dir, rel string, folder bool) (map[string]string, map[string]fs.FileMode, error) {
|
||||
if why := linkedParent(dir, rel+"/x"); why != "" {
|
||||
return nil, nil, errors.New(why)
|
||||
}
|
||||
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
info, err := os.Lstat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil, fmt.Errorf("%s is not in this home", full)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, nil, fmt.Errorf("%s is a symbolic link", full)
|
||||
}
|
||||
files, modes := map[string]string{}, map[string]fs.FileMode{}
|
||||
if !folder {
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, nil, fmt.Errorf("%s is not a file", full)
|
||||
}
|
||||
raw, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
files[filepath.Base(full)], modes[filepath.Base(full)] = string(raw), info.Mode().Perm()
|
||||
return files, modes, nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, nil, fmt.Errorf("%s is not a folder", full)
|
||||
}
|
||||
err = filepath.WalkDir(full, func(at string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Type()&fs.ModeSymlink != 0 {
|
||||
return fmt.Errorf("%s is a symbolic link", at)
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return fmt.Errorf("%s is not a file", at)
|
||||
}
|
||||
in, _ := filepath.Rel(full, at)
|
||||
raw, err := os.ReadFile(at)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[filepath.ToSlash(in)], modes[filepath.ToSlash(in)] = string(raw), fi.Mode().Perm()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return files, modes, nil
|
||||
}
|
||||
|
||||
// ShowHome answers one item of the home in full: where it is, whether the mesh placed it, and its files.
|
||||
func ShowHome(p Paths, kind, name string) (map[string]any, error) {
|
||||
rel, folder, err := HomeItemPath(kind, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
files, _, err := readItem(dir, rel, folder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer := map[string]any{"kind": homeKindOf(kind), "path": filepath.Join(dir, filepath.FromSlash(rel)),
|
||||
"placedByTheMesh": placedUnder(p, rel) != "", "files": files}
|
||||
if !folder {
|
||||
for _, content := range files {
|
||||
answer["content"] = content
|
||||
}
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// Removal is what the removed-items log keeps of one removal, and the copy's own note.
|
||||
type Removal struct {
|
||||
Action string `json:"action"`
|
||||
At string `json:"at"`
|
||||
Node string `json:"node"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Why string `json:"why,omitempty"`
|
||||
Kept string `json:"keptAt"`
|
||||
Files map[string]KeptFile `json:"files"`
|
||||
}
|
||||
|
||||
// KeptFile is one file of a kept copy as it was in the home: its digest, checked before it is put back, and
|
||||
// its mode.
|
||||
type KeptFile struct {
|
||||
Digest string `json:"sha256"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
// RemoveHome removes one item the person made in the home, on their word and for the reason given: it keeps
|
||||
// a copy in the module's state first, under a dated folder, checks the copy, then deletes and logs. An item
|
||||
// the mesh placed is refused — unregistering owns it. Answers where the copy is, so it can be put back.
|
||||
func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any, error) {
|
||||
why = strings.TrimSpace(why)
|
||||
if why == "" {
|
||||
return nil, errors.New("why is required: the person's reason for removing it, kept in the log")
|
||||
}
|
||||
rel, folder, err := HomeItemPath(kind, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kind = homeKindOf(kind)
|
||||
if kind == KindMemory {
|
||||
name = memoryName
|
||||
} else {
|
||||
name = strings.TrimSuffix(name, ".md")
|
||||
}
|
||||
if at := placedUnder(p, rel); at != "" {
|
||||
return nil, fmt.Errorf("the mesh placed %s: remove it with claude_code_%s_unregister at the home scope", at,
|
||||
strings.ReplaceAll(kind, "-", "_"))
|
||||
}
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
files, modes, err := readItem(dir, rel, folder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
|
||||
// The copy: <state>/removed-from-home/<date>/<time>-<kind>-<name>/<path as it was in the home>.
|
||||
now = now.UTC()
|
||||
day := filepath.Join(p.State, RemovedDir, now.Format("2006-01-02"))
|
||||
kept := filepath.Join(day, now.Format("150405")+"-"+kind+"-"+name)
|
||||
for n := 2; ; n++ {
|
||||
if _, err := os.Lstat(kept); os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
kept = filepath.Join(day, fmt.Sprintf("%s-%s-%s-%d", now.Format("150405"), kind, name, n))
|
||||
}
|
||||
copyRoot := filepath.Join(kept, filepath.FromSlash(rel))
|
||||
if !folder {
|
||||
copyRoot = filepath.Dir(copyRoot)
|
||||
}
|
||||
names := map[string]KeptFile{}
|
||||
for in, content := range files {
|
||||
to := filepath.Join(copyRoot, filepath.FromSlash(in))
|
||||
if err := os.MkdirAll(filepath.Dir(to), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("the copy could not be made, nothing removed: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(to, []byte(content), modes[in]|0o600); err != nil {
|
||||
return nil, fmt.Errorf("the copy could not be made, nothing removed: %w", err)
|
||||
}
|
||||
if back, err := os.ReadFile(to); err != nil || !bytes.Equal(back, []byte(content)) {
|
||||
return nil, fmt.Errorf("the copy of %s does not read back the same, nothing removed", in)
|
||||
}
|
||||
names[in] = KeptFile{Digest: digest(content), Mode: fmt.Sprintf("%04o", modes[in])}
|
||||
}
|
||||
r := Removal{Action: "removed", At: now.Format(time.RFC3339), Node: p.Node, Kind: kind, Name: name, Path: full, Why: why,
|
||||
Kept: filepath.Join(kept, filepath.FromSlash(rel)), Files: names}
|
||||
note, _ := indented(r)
|
||||
if err := os.WriteFile(filepath.Join(kept, "removal.json"), note, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("the copy's note could not be written, nothing removed: %w", err)
|
||||
}
|
||||
|
||||
// The item may have changed while it was copied: only what was copied is removed.
|
||||
again, _, err := readItem(dir, rel, folder)
|
||||
if err != nil || len(again) != len(files) {
|
||||
return nil, fmt.Errorf("%s changed while it was copied, nothing removed; the copy is at %s", full, kept)
|
||||
}
|
||||
for in, content := range again {
|
||||
if files[in] != content {
|
||||
return nil, fmt.Errorf("%s changed while it was copied, nothing removed; the copy is at %s", full, kept)
|
||||
}
|
||||
}
|
||||
if folder {
|
||||
err = os.RemoveAll(full)
|
||||
} else {
|
||||
err = os.Remove(full)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s could not be removed (the copy is at %s): %w", full, kept, err)
|
||||
}
|
||||
|
||||
logged := logRemoval(p, r)
|
||||
say("removed %s from the home on the person's word, kept at %s: %s", full, r.Kept, why)
|
||||
answer := map[string]any{"removed": full, "kind": kind, "name": name, "why": why, "keptAt": r.Kept,
|
||||
"undo": "copy " + r.Kept + " back to " + full, "log": p.removedLog()}
|
||||
if !logged {
|
||||
answer["log"] = "the log could not be written; the removal is noted in " + filepath.Join(kept, "removal.json")
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// logRemoval appends one line to the removed-items log, and answers whether it could.
|
||||
func logRemoval(p Paths, r Removal) bool {
|
||||
line, _ := json.Marshal(r)
|
||||
f, err := os.OpenFile(p.removedLog(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, werr := f.Write(append(line, '\n'))
|
||||
return f.Close() == nil && werr == nil
|
||||
}
|
||||
|
||||
// ---- putting a removal back ---------------------------------------------------------------------------
|
||||
|
||||
// Kept is one removal whose copy the module keeps, as the list of removals shows it.
|
||||
type Kept struct {
|
||||
Removal
|
||||
Restored string `json:"restored,omitempty"`
|
||||
}
|
||||
|
||||
// KeptRemovals lists every removal the module keeps a copy of, newest first, and whether it was put back.
|
||||
func KeptRemovals(p Paths) ([]Kept, error) {
|
||||
root := filepath.Join(p.State, RemovedDir)
|
||||
notes, _ := filepath.Glob(filepath.Join(root, "*", "*", "removal.json"))
|
||||
out := []Kept{}
|
||||
for _, note := range notes {
|
||||
var k Kept
|
||||
if !readJSON(note, &k.Removal) {
|
||||
continue
|
||||
}
|
||||
var back Removal
|
||||
if readJSON(filepath.Join(filepath.Dir(note), "restored.json"), &back) {
|
||||
k.Restored = back.At
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].At != out[j].At {
|
||||
return out[i].At > out[j].At
|
||||
}
|
||||
return out[i].Kept > out[j].Kept
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// removalFolder finds the folder of one removal from what a removal answered as keptAt (or the folder
|
||||
// itself): <state>/removed-from-home/<date>/<removal>, and nothing outside it.
|
||||
func removalFolder(p Paths, kept string) (string, error) {
|
||||
root := filepath.Join(p.State, RemovedDir)
|
||||
rel, err := filepath.Rel(root, filepath.Clean(kept))
|
||||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||
if err != nil || kept == "" || len(parts) < 2 || parts[0] == ".." || parts[0] == "." {
|
||||
return "", fmt.Errorf("%q is not a copy this module kept: give the keptAt a removal answered, as claude_code_home_removed lists it", kept)
|
||||
}
|
||||
return filepath.Join(root, parts[0], parts[1]), nil
|
||||
}
|
||||
|
||||
// RestoreHome puts a removed item back where it was: only when nothing is at that path now, and only when the
|
||||
// kept copy is exactly what was removed, file by file against the digests its note recorded. Logged as the
|
||||
// removal was; the copy stays, marked as put back.
|
||||
func RestoreHome(p Paths, kept string, now time.Time) (map[string]any, error) {
|
||||
folder, err := removalFolder(p, kept)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r Removal
|
||||
if !readJSON(filepath.Join(folder, "removal.json"), &r) {
|
||||
return nil, fmt.Errorf("%s holds no readable removal.json: nothing to check the copy against, nothing restored", folder)
|
||||
}
|
||||
// Where it goes is worked out again from its kind and name, never taken from the note alone.
|
||||
rel, isFolder, err := HomeItemPath(r.Kind, r.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
if r.Path != full || r.Kept != filepath.Join(folder, filepath.FromSlash(rel)) {
|
||||
return nil, fmt.Errorf("%s/removal.json does not describe the copy beside it, nothing restored", folder)
|
||||
}
|
||||
|
||||
// The copy's own integrity: the same files, each with the digest recorded when it was removed.
|
||||
files, _, err := readItem(folder, rel, isFolder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the kept copy cannot be read, nothing restored: %w", err)
|
||||
}
|
||||
if len(files) != len(r.Files) {
|
||||
return nil, fmt.Errorf("the kept copy holds %d file(s), its note %d: nothing restored", len(files), len(r.Files))
|
||||
}
|
||||
for in, content := range files {
|
||||
want, ok := r.Files[in]
|
||||
if !ok || digest(content) != want.Digest {
|
||||
return nil, fmt.Errorf("the kept copy's %s is not what was removed: nothing restored", in)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing may be in the way: whatever is there now is the person's, or the mesh's.
|
||||
if why := linkedParent(dir, rel+"/x"); why != "" {
|
||||
return nil, errors.New(why + ", nothing restored")
|
||||
}
|
||||
if _, err := os.Lstat(full); err == nil {
|
||||
return nil, fmt.Errorf("%s exists now, nothing restored: look at it with claude_code_home_show first", full)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
base := full
|
||||
if !isFolder {
|
||||
base = filepath.Dir(full)
|
||||
}
|
||||
written := []string{}
|
||||
undo := func() {
|
||||
for _, w := range written {
|
||||
_ = os.Remove(w)
|
||||
}
|
||||
if isFolder {
|
||||
_ = os.RemoveAll(full)
|
||||
}
|
||||
}
|
||||
for in, content := range files {
|
||||
to := filepath.Join(base, filepath.FromSlash(in))
|
||||
if !isFolder {
|
||||
to = full
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
var m uint32
|
||||
if _, err := fmt.Sscanf(r.Files[in].Mode, "%o", &m); err == nil && m != 0 {
|
||||
mode = os.FileMode(m).Perm()
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
|
||||
undo()
|
||||
return nil, fmt.Errorf("%s could not be restored: %w", full, err)
|
||||
}
|
||||
f, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||
if err != nil {
|
||||
undo()
|
||||
return nil, fmt.Errorf("%s could not be restored: %w", full, err)
|
||||
}
|
||||
_, werr := f.WriteString(content)
|
||||
if cerr := f.Close(); werr != nil || cerr != nil {
|
||||
undo()
|
||||
return nil, fmt.Errorf("%s could not be restored", to)
|
||||
}
|
||||
_ = os.Chmod(to, mode) // the umask may have taken bits the file had
|
||||
written = append(written, to)
|
||||
}
|
||||
|
||||
back := Removal{Action: "restored", At: now.UTC().Format(time.RFC3339), Node: p.Node, Kind: r.Kind, Name: r.Name,
|
||||
Path: full, Why: "undoes the removal of " + r.At + ": " + r.Why, Kept: r.Kept, Files: r.Files}
|
||||
note, _ := indented(back)
|
||||
_ = os.WriteFile(filepath.Join(folder, "restored.json"), note, 0o600)
|
||||
logged := logRemoval(p, back)
|
||||
say("restored %s from %s, undoing its removal of %s", full, r.Kept, r.At)
|
||||
answer := map[string]any{"restored": full, "kind": r.Kind, "name": r.Name, "from": r.Kept, "removedAt": r.At,
|
||||
"log": p.removedLog()}
|
||||
if !logged {
|
||||
answer["log"] = "the log could not be written; the restore is noted in " + filepath.Join(folder, "restored.json")
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
package main
|
||||
|
||||
// The person's own items in the home, read and removed on their word (novox/hq ADR 0216 rule 6: removing the
|
||||
// original is the person's act — the remove tool is that act made explicit).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var at = time.Date(2026, 10, 7, 9, 30, 0, 0, time.UTC)
|
||||
|
||||
func TestEveryKindIsShownInFull(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
|
||||
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "rules", "hal-era.md"), "# old rule\n")
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\n")
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
|
||||
writeFile(t, filepath.Join(dir, "CLAUDE.md"), "# my memory\n")
|
||||
|
||||
rule, err := ShowHome(p, "instructions", "hal-era")
|
||||
if err != nil || rule["content"] != "# old rule\n" || rule["placedByTheMesh"] != false {
|
||||
t.Fatalf("%v %v", rule, err)
|
||||
}
|
||||
if again, err := ShowHome(p, "rule", "hal-era.md"); err != nil || again["content"] != "# old rule\n" {
|
||||
t.Fatalf("a rule named as a person writes it: %v %v", again, err)
|
||||
}
|
||||
skill, err := ShowHome(p, KindSkill, "old")
|
||||
if files, _ := skill["files"].(map[string]string); err != nil || len(files) != 2 || files["scripts/a.sh"] != "#!/bin/sh\n" {
|
||||
t.Fatalf("%v %v", skill, err)
|
||||
}
|
||||
memory, err := ShowHome(p, KindMemory, "")
|
||||
if err != nil || memory["content"] != "# my memory\n" {
|
||||
t.Fatalf("%v %v", memory, err)
|
||||
}
|
||||
if _, err := ShowHome(p, KindAgent, "absent"); err == nil {
|
||||
t.Fatal("an absent item was shown")
|
||||
}
|
||||
for _, name := range []string{"../../etc/passwd", "..", ".credentials", "a/b"} {
|
||||
if _, err := ShowHome(p, KindInstructions, name); err == nil {
|
||||
t.Errorf("%q reached outside its kind's folder", name)
|
||||
}
|
||||
}
|
||||
if _, err := ShowHome(p, KindHook, "x"); err == nil {
|
||||
t.Fatal("a kind the home does not hold was taken")
|
||||
}
|
||||
// The status tool lists the memory beside the rest.
|
||||
found := false
|
||||
for _, it := range HomeItems(p, Config{}, Servers{}) {
|
||||
found = found || (it.Kind == KindMemory && it.Name == memoryName && !it.Placed)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("the status does not list the home's memory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARemovalNeedsItsReason(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "rules", "keep.md"), "x")
|
||||
if _, err := RemoveHome(p, KindInstructions, "keep", " ", at); err == nil {
|
||||
t.Fatal("removed without a reason")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "rules", "keep.md")); err != nil {
|
||||
t.Fatal("the file went although the removal was refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARemovalKeepsACopyAndLogsWhy(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
|
||||
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "rules", "hal-era.md"), "# old rule\n")
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\n")
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
|
||||
_ = os.Chmod(filepath.Join(dir, "skills", "old", "scripts", "a.sh"), 0o755)
|
||||
|
||||
answer, err := RemoveHome(p, KindInstructions, "hal-era", "HAL is retired", at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "rules", "hal-era.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("the rule file is still there")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "rules")); err != nil {
|
||||
t.Fatal("the kind's own folder went with it")
|
||||
}
|
||||
kept, _ := answer["keptAt"].(string)
|
||||
want := filepath.Join(p.State, RemovedDir, "2026-10-07", "093000-instructions-hal-era", "rules", "hal-era.md")
|
||||
if kept != want {
|
||||
t.Fatalf("kept at %s, not %s", kept, want)
|
||||
}
|
||||
if !strings.HasPrefix(kept, p.State) || strings.HasPrefix(kept, dir) {
|
||||
t.Fatal("the copy is inside the agent directory")
|
||||
}
|
||||
if raw, _ := os.ReadFile(kept); string(raw) != "# old rule\n" {
|
||||
t.Fatalf("the copy holds %q", raw)
|
||||
}
|
||||
|
||||
// A second removal in the same second does not overwrite the first's copy.
|
||||
writeFile(t, filepath.Join(dir, "rules", "hal-era.md"), "# made again\n")
|
||||
second, err := RemoveHome(p, KindInstructions, "hal-era", "again", at)
|
||||
if err != nil || second["keptAt"] == kept {
|
||||
t.Fatalf("%v %v", second, err)
|
||||
}
|
||||
if raw, _ := os.ReadFile(kept); string(raw) != "# old rule\n" {
|
||||
t.Fatal("the first copy was overwritten")
|
||||
}
|
||||
|
||||
// A skill goes as its whole folder, kept with its modes.
|
||||
skill, err := RemoveHome(p, KindSkill, "old", "superseded by the plugin's", at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills", "old")); !os.IsNotExist(err) {
|
||||
t.Fatal("the skill's folder is still there")
|
||||
}
|
||||
script := filepath.Join(skill["keptAt"].(string), "scripts", "a.sh")
|
||||
if info, err := os.Stat(script); err != nil || info.Mode().Perm()&0o100 == 0 {
|
||||
t.Fatalf("the script's copy lost its executable bit: %v", err)
|
||||
}
|
||||
|
||||
// The log holds every removal, each with its reason.
|
||||
raw, err := os.ReadFile(p.removedLog())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("%d lines logged", len(lines))
|
||||
}
|
||||
var r Removal
|
||||
if err := json.Unmarshal([]byte(lines[0]), &r); err != nil || r.Why != "HAL is retired" || r.Node != "laptop" || r.Kept != kept {
|
||||
t.Fatalf("%+v %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMemoryIsRemovedAndKept(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
writeFile(t, filepath.Join(dir, "CLAUDE.md"), "# HAL era\n")
|
||||
answer, err := RemoveHome(p, KindMemory, "", "the managed file says it now", at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("the memory is still there")
|
||||
}
|
||||
if raw, _ := os.ReadFile(answer["keptAt"].(string)); string(raw) != "# HAL era\n" {
|
||||
t.Fatal("the memory was not kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatTheMeshPlacedIsRefused(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
state, view := memConfig{}, NewConfigView(p)
|
||||
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome, Files: one("placed", "v1")}, nil, false, state, view, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
full := filepath.Join(p.Home, ".claude", "agents", "placed.md")
|
||||
if _, err := os.Stat(full); err != nil {
|
||||
t.Fatal("the home item was not placed")
|
||||
}
|
||||
shown, err := ShowHome(p, KindAgent, "placed")
|
||||
if err != nil || shown["placedByTheMesh"] != true {
|
||||
t.Fatalf("%v %v", shown, err)
|
||||
}
|
||||
if _, err := RemoveHome(p, KindAgent, "placed", "tidy", at); err == nil || !strings.Contains(err.Error(), "claude_code_agent_unregister") {
|
||||
t.Fatalf("what the mesh placed was not refused: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(full); err != nil {
|
||||
t.Fatal("the mesh's item was removed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(p.State, RemovedDir)); !os.IsNotExist(err) {
|
||||
t.Fatal("a refused removal left a copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASymbolicLinkIsLeftAlone(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
elsewhere := t.TempDir()
|
||||
writeFile(t, filepath.Join(elsewhere, "target.md"), "not the home's")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
|
||||
if err := os.Symlink(filepath.Join(elsewhere, "target.md"), filepath.Join(dir, "rules", "linked.md")); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
if _, err := RemoveHome(p, KindInstructions, "linked", "x", at); err == nil {
|
||||
t.Fatal("a symbolic link was removed")
|
||||
}
|
||||
if err := os.Symlink(elsewhere, filepath.Join(dir, "skills")); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join(elsewhere, "s"), 0o755)
|
||||
writeFile(t, filepath.Join(elsewhere, "s", "SKILL.md"), "x")
|
||||
if _, err := RemoveHome(p, KindSkill, "s", "x", at); err == nil {
|
||||
t.Fatal("a skill was removed through a linked folder")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "s", "SKILL.md")); err != nil {
|
||||
t.Fatal("what the link points at was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARemovalIsListedAndPutBack(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
|
||||
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "rules", "hal-era.md"), "# old rule\n")
|
||||
_ = os.Chmod(filepath.Join(dir, "rules", "hal-era.md"), 0o644)
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\n")
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
|
||||
_ = os.Chmod(filepath.Join(dir, "skills", "old", "scripts", "a.sh"), 0o755)
|
||||
|
||||
rule, err := RemoveHome(p, KindInstructions, "hal-era", "HAL is retired", at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skill, err := RemoveHome(p, KindSkill, "old", "superseded", at.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept, err := KeptRemovals(p)
|
||||
if err != nil || len(kept) != 2 || kept[0].Name != "old" || kept[1].Kept != rule["keptAt"] || kept[1].Restored != "" {
|
||||
t.Fatalf("%+v %v", kept, err)
|
||||
}
|
||||
|
||||
back, err := RestoreHome(p, rule["keptAt"].(string), at.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
full := filepath.Join(dir, "rules", "hal-era.md")
|
||||
if raw, _ := os.ReadFile(full); string(raw) != "# old rule\n" || back["restored"] != full {
|
||||
t.Fatalf("%v: %q", back, raw)
|
||||
}
|
||||
if info, _ := os.Stat(full); info.Mode().Perm() != 0o644 {
|
||||
t.Fatalf("restored as %v, removed as 0644", info.Mode().Perm())
|
||||
}
|
||||
if _, err := RestoreHome(p, skill["keptAt"].(string), at.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(dir, "skills", "old", "scripts", "a.sh")); err != nil || info.Mode().Perm()&0o100 == 0 {
|
||||
t.Fatalf("the skill's script came back without its executable bit: %v", err)
|
||||
}
|
||||
|
||||
// Listed as put back; logged with the removals.
|
||||
kept, _ = KeptRemovals(p)
|
||||
for _, k := range kept {
|
||||
if k.Restored == "" {
|
||||
t.Errorf("%s is not listed as put back", k.Kept)
|
||||
}
|
||||
}
|
||||
raw, _ := os.ReadFile(p.removedLog())
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
var last Removal
|
||||
if len(lines) != 4 || json.Unmarshal([]byte(lines[2]), &last) != nil || last.Action != "restored" || last.Path != full {
|
||||
t.Fatalf("%d lines, %+v", len(lines), last)
|
||||
}
|
||||
|
||||
// Something at the path now: refused, and left as it is.
|
||||
if _, err := RestoreHome(p, rule["keptAt"].(string), at); err == nil {
|
||||
t.Fatal("restored over what is there now")
|
||||
}
|
||||
if raw, _ := os.ReadFile(full); string(raw) != "# old rule\n" {
|
||||
t.Fatal("what was there was changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACopyThatChangedIsNotPutBack(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
|
||||
_ = os.MkdirAll(filepath.Join(dir, "skills", "s"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "rules", "r.md"), "original")
|
||||
writeFile(t, filepath.Join(dir, "skills", "s", "SKILL.md"), "x")
|
||||
rule, _ := RemoveHome(p, KindInstructions, "r", "why", at)
|
||||
skill, _ := RemoveHome(p, KindSkill, "s", "why", at)
|
||||
|
||||
writeFile(t, rule["keptAt"].(string), "tampered")
|
||||
if _, err := RestoreHome(p, rule["keptAt"].(string), at); err == nil {
|
||||
t.Fatal("a changed copy was put back")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "rules", "r.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("something was written from a changed copy")
|
||||
}
|
||||
// A file added to a kept skill: not what was removed.
|
||||
writeFile(t, filepath.Join(skill["keptAt"].(string), "extra.md"), "x")
|
||||
if _, err := RestoreHome(p, skill["keptAt"].(string), at); err == nil {
|
||||
t.Fatal("a copy with an extra file was put back")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills", "s")); !os.IsNotExist(err) {
|
||||
t.Fatal("the skill's folder was made from a changed copy")
|
||||
}
|
||||
// A note pointing elsewhere than its own kind and name: refused.
|
||||
note := filepath.Join(filepath.Dir(filepath.Dir(rule["keptAt"].(string))), "removal.json")
|
||||
var r Removal
|
||||
_ = readJSON(note, &r)
|
||||
writeFile(t, rule["keptAt"].(string), "original")
|
||||
r.Path = "/etc/passwd"
|
||||
raw, _ := json.Marshal(r)
|
||||
writeFile(t, note, string(raw))
|
||||
if _, err := RestoreHome(p, rule["keptAt"].(string), at); err == nil {
|
||||
t.Fatal("a note naming another path was followed")
|
||||
}
|
||||
// Nothing outside the module's kept copies.
|
||||
for _, kept := range []string{"", "/etc/passwd", filepath.Join(p.State, RemovedDir), filepath.Join(p.State, RemovedDir, "..", "config.json")} {
|
||||
if _, err := RestoreHome(p, kept, at); err == nil {
|
||||
t.Errorf("%q was taken as a kept copy", kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package main
|
||||
|
||||
// The "instead of" table in the agent's managed instructions (novox/hq ADR 0245): to do this, call that
|
||||
// address, not this shell command — generated on every render from what each seat verb and module tool
|
||||
// says it replaces, so it cannot drift from the tools the mesh has.
|
||||
//
|
||||
// Short: a row per seat or module, its top what this machine's agent reached round most — the tools the
|
||||
// guard named most often in its record.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// InsteadOfRows is how many rows — seats and modules — the table holds; the rest are one search away.
|
||||
const InsteadOfRows = 16
|
||||
|
||||
// insteadOrder is the order rows are taken in: a machine's seat, a module on a machine, the mesh's seat.
|
||||
func insteadOrder(r Replacement) int {
|
||||
switch {
|
||||
case r.Seat && r.NodeScoped:
|
||||
return 0
|
||||
case !r.Seat:
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func code(s string) string { return "`" + strings.ReplaceAll(s, "`", "'") + "`" }
|
||||
|
||||
// insteadGroup is one row: a seat or a module, and its verbs that replace a command.
|
||||
type insteadGroup struct {
|
||||
prefix string // `<node>/node-service-manager.` or `mesh-controller.`
|
||||
verbs []Replacement
|
||||
refused int
|
||||
}
|
||||
|
||||
// InsteadOf is the section, or "" when the mesh said nothing replaces anything — an older controller,
|
||||
// or one not yet asked.
|
||||
//
|
||||
// One row per seat or module, every verb of it that replaces a command in the row, so a verb is never cut
|
||||
// for being late in its seat's list; the rows the guard here named most come first, then the machines'
|
||||
// seats, the modules' tools and the mesh's own seats in the records' order.
|
||||
func InsteadOf(m *MeshTools) string {
|
||||
if m == nil || len(m.Replacements) == 0 {
|
||||
return ""
|
||||
}
|
||||
var groups []*insteadGroup
|
||||
at := map[string]*insteadGroup{}
|
||||
for order := 0; order <= 2; order++ {
|
||||
for _, r := range m.Replacements {
|
||||
if insteadOrder(r) != order {
|
||||
continue
|
||||
}
|
||||
prefix := r.Address[:strings.LastIndex(r.Address, ".")+1]
|
||||
if r.NodeScoped {
|
||||
prefix = "<node>/" + prefix
|
||||
}
|
||||
g := at[prefix]
|
||||
if g == nil {
|
||||
g = &insteadGroup{prefix: prefix}
|
||||
at[prefix] = g
|
||||
groups = append(groups, g)
|
||||
}
|
||||
g.verbs = append(g.verbs, r)
|
||||
g.refused += m.Refused[r.Address]
|
||||
}
|
||||
}
|
||||
sort.SliceStable(groups, func(i, j int) bool { return groups[i].refused > groups[j].refused })
|
||||
for _, g := range groups {
|
||||
sort.SliceStable(g.verbs, func(i, j int) bool { return m.Refused[g.verbs[i].Address] > m.Refused[g.verbs[j].Address] })
|
||||
}
|
||||
more := 0
|
||||
if len(groups) > InsteadOfRows {
|
||||
more = len(groups) - InsteadOfRows
|
||||
groups = groups[:InsteadOfRows]
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\n## Instead of a shell command\n\n")
|
||||
b.WriteString("Generated from what each seat verb and module tool says it replaces (`replaces`, novox/hq ADR 0245),\n")
|
||||
b.WriteString("rewritten on every render. Call `<address><verb>` through `mesh_call`, `<node>` being the machine;\n")
|
||||
b.WriteString("`mesh_search` with the command you would have typed finds it too.\n\n")
|
||||
b.WriteString("| call | the verb — instead of |\n|---|---|\n")
|
||||
for _, g := range groups {
|
||||
var cells []string
|
||||
for _, r := range g.verbs {
|
||||
var not []string
|
||||
for _, c := range r.Replaces {
|
||||
not = append(not, code(c))
|
||||
}
|
||||
cells = append(cells, code(r.Address[strings.LastIndex(r.Address, ".")+1:])+" — "+strings.Join(not, ", "))
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %s |\n", code(g.prefix), strings.ReplaceAll(strings.Join(cells, " · "), "|", "/"))
|
||||
}
|
||||
if more > 0 {
|
||||
fmt.Fprintf(&b, "\n%d more seats and modules: `mesh_search` with the command finds the tool for it.\n", more)
|
||||
}
|
||||
b.WriteString("\n- **Never `ssh` to a mesh machine**, and never edit `/etc/hosts` or set `HOSTALIASES` for a mesh\n" +
|
||||
" name: the guard on this session's shell refuses it and names the tool. Where no tool covers what you\n" +
|
||||
" need, none is worked around: say so, and the tool is created in the module that owns it, on its seat.\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// askingAController answers the controller's verbs as the seat does: `tools` in-process, the rest as the
|
||||
// command's output and its JSON.
|
||||
func askingAController(t *testing.T) Ask {
|
||||
t.Helper()
|
||||
wrap := func(output string, answer any) json.RawMessage {
|
||||
raw, _ := json.Marshal(map[string]any{"ok": true, "output": output, "answer": answer})
|
||||
return raw
|
||||
}
|
||||
return func(address string, args any) (json.RawMessage, error) {
|
||||
switch address {
|
||||
case "seat:mesh-controller.tools":
|
||||
return json.RawMessage(`{"seats":[
|
||||
{"seat":"mesh-controller","scope":"mesh","tools":[{"name":"node","description":"What one machine reported it can do, what it is assigned, and why.","replaces":["hostnamectl","uptime"]},{"name":"nodes","description":"Every machine."}]},
|
||||
{"seat":"node-service-manager","scope":"node","tools":[
|
||||
{"name":"status","description":"One unit as the service manager sees it now: its states, whether it starts at boot.","replaces":["systemctl status","systemctl is-active"]},
|
||||
{"name":"journal","description":"The last lines of one unit's journal.","replaces":["journalctl"]}]}]}`), nil
|
||||
case "seat:mesh-controller.modules":
|
||||
return wrap("[...]", []any{map[string]any{"module": "docker", "on": []string{"anchor"},
|
||||
"replaces": map[string][]string{"docker_logs": {"docker logs"}}}, map[string]any{"module": "zsh", "on": []string{"anchor"}}}), nil
|
||||
case "seat:mesh-controller.nodes":
|
||||
return wrap("", []any{map[string]any{"name": "anchor"}, map[string]any{"name": "laptop"}}), nil
|
||||
case "seat:mesh-controller.node":
|
||||
node, _ := args.(map[string]any)["node"].(string)
|
||||
if node == "anchor" {
|
||||
return wrap("anchor\n mode converged\n public domain Anchor.Example\n\n what it runs\n", nil), nil
|
||||
}
|
||||
return wrap("laptop\n mode converged\n", nil), nil
|
||||
}
|
||||
return nil, errors.New("no such verb")
|
||||
}
|
||||
}
|
||||
|
||||
func resolving(name string) []string {
|
||||
return map[string][]string{"anchor": {"10.10.0.1"}, "anchor.internal": {"10.10.0.1"}, "anchor.example": {"192.0.2.10"},
|
||||
"laptop": {"127.0.0.1", "10.10.0.4"}}[name]
|
||||
}
|
||||
|
||||
// What the controller says of its tools and machines is read whole: the seats' verbs, the modules' own tools,
|
||||
// every machine with its domains and addresses — a loopback address never one of them.
|
||||
func TestTheMeshsToolsAreAskedOfTheController(t *testing.T) {
|
||||
m, err := AskMeshTools(askingAController(t), resolving)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var addresses []string
|
||||
for _, r := range m.Replacements {
|
||||
addresses = append(addresses, r.Address)
|
||||
}
|
||||
if got := strings.Join(addresses, ","); got != "mesh-controller.node,node-service-manager.status,node-service-manager.journal,docker.docker_logs" {
|
||||
t.Errorf("replacements: %s", got)
|
||||
}
|
||||
if len(m.Machines) != 2 || strings.Join(m.Machines[0].Domains, ",") != "anchor.internal,anchor.example" ||
|
||||
strings.Join(m.Machines[0].Addresses, ",") != "10.10.0.1,192.0.2.10" || strings.Join(m.Machines[1].Addresses, ",") != "10.10.0.4" {
|
||||
t.Errorf("machines: %+v", m.Machines)
|
||||
}
|
||||
if m.Replacements[3].Does != "logs" || !m.Replacements[2].NodeScoped || m.Replacements[0].NodeScoped {
|
||||
t.Errorf("the rows: %+v", m.Replacements)
|
||||
}
|
||||
}
|
||||
|
||||
// **The table is generated, not written** (novox/hq ADR 0245): what the records say a verb replaces is a row,
|
||||
// the machines' seats first, and nothing is rendered where the records say nothing.
|
||||
func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) {
|
||||
m, _ := AskMeshTools(askingAController(t), resolving)
|
||||
table := InsteadOf(&m)
|
||||
for _, want := range []string{
|
||||
"| `<node>/node-service-manager.` | `status` — `systemctl status`, `systemctl is-active` · `journal` — `journalctl` |",
|
||||
"| `<node>/docker.` | `docker_logs` — `docker logs` |",
|
||||
"| `mesh-controller.` | `node` — `hostnamectl`, `uptime` |",
|
||||
"Never `ssh` to a mesh machine",
|
||||
} {
|
||||
if !strings.Contains(table, want) {
|
||||
t.Errorf("the table lacks %q:\n%s", want, table)
|
||||
}
|
||||
}
|
||||
if strings.Index(table, "node-service-manager.") > strings.Index(table, "docker.") ||
|
||||
strings.Index(table, "docker.") > strings.Index(table, "`mesh-controller.`") {
|
||||
t.Errorf("not the machines' seats, then modules, then the mesh's:\n%s", table)
|
||||
}
|
||||
// What the guard here named most comes first: its row, and in its row its verb.
|
||||
m.Refused = map[string]int{"node-service-manager.journal": 3, "mesh-controller.node": 1}
|
||||
ranked := InsteadOf(&m)
|
||||
if !strings.Contains(ranked, "| `<node>/node-service-manager.` | `journal` — `journalctl` · `status`") ||
|
||||
strings.Index(ranked, "`mesh-controller.`") > strings.Index(ranked, "`<node>/docker.`") {
|
||||
t.Errorf("not ranked by the guard's record:\n%s", ranked)
|
||||
}
|
||||
m.Refused = nil
|
||||
if InsteadOf(nil) != "" || InsteadOf(&MeshTools{}) != "" {
|
||||
t.Error("a table from nothing")
|
||||
}
|
||||
// Into the instructions after the mesh's own text, before what was registered.
|
||||
out := RenderWithMesh(Facts{Node: "w", Console: "x"}, Settings{}, nil, "/h", nil,
|
||||
Config{Mesh: []Item{{Kind: KindInstructions, Name: "conventions", Scope: ScopeMesh, Files: map[string]string{"conventions.md": "Be brief."}}}}, &m)
|
||||
md := out["CLAUDE.md"]
|
||||
if i, j, k := strings.Index(md, "## Conventions"), strings.Index(md, "## Instead of a shell command"), strings.Index(md, "### conventions"); !(i < j && j < k && i >= 0) {
|
||||
t.Errorf("the table is not between the mesh's text and the registered sections:\n%s", md)
|
||||
}
|
||||
if os.Getenv("SHOW_TABLE") != "" {
|
||||
t.Log(table)
|
||||
}
|
||||
// A long list is cut, and says how the rest is found.
|
||||
var many MeshTools
|
||||
for i := 0; i < InsteadOfRows+3; i++ {
|
||||
many.Replacements = append(many.Replacements, Replacement{Address: "s" + string(rune('a'+i)) + ".v", Seat: true, Replaces: []string{"c"}})
|
||||
}
|
||||
if t2 := InsteadOf(&many); strings.Count(t2, "| `s") != InsteadOfRows || !strings.Contains(t2, "3 more seats and modules") {
|
||||
t.Errorf("not cut to %d rows:\n%s", InsteadOfRows, t2)
|
||||
}
|
||||
}
|
||||
|
||||
// Asked again with the same answer, nothing is written and nothing renders.
|
||||
func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(t *testing.T) {
|
||||
p := Paths{State: t.TempDir()}
|
||||
m, _ := AskMeshTools(askingAController(t), resolving)
|
||||
if !KeepMeshTools(p, m) {
|
||||
t.Fatal("the first answer was not kept")
|
||||
}
|
||||
if KeepMeshTools(p, m) {
|
||||
t.Fatal("the same answer was kept again")
|
||||
}
|
||||
m.Machines = m.Machines[:1]
|
||||
if !KeepMeshTools(p, m) || len(ReadMeshTools(p).Machines) != 1 {
|
||||
t.Fatal("a changed answer was not kept")
|
||||
}
|
||||
d := GuardDataOf(Paths{State: p.State, Node: "laptop"}, ReadMeshTools(p))
|
||||
if d.Node != "laptop" || len(d.Replacements) != 4 || !strings.HasSuffix(d.Log, "guard.log") {
|
||||
t.Errorf("the guard's data: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// Where claude-code runs is read from the controller's `modules` answer as it comes — JSON, from `module list
|
||||
// --json` — and from the printed list too.
|
||||
func TestTheMachinesRunningTheModuleAreReadFromTheControllersAnswer(t *testing.T) {
|
||||
asJSON := json.RawMessage(`{"ok":true,"output":"[...]","answer":[{"module":"zsh","on":["a"]},{"module":"claude-code","on":["a","b"]}]}`)
|
||||
if got := strings.Join(NodesRunning(asJSON, "claude-code"), ","); got != "a,b" {
|
||||
t.Errorf("from JSON: %q", got)
|
||||
}
|
||||
printed := json.RawMessage(`{"ok":true,"output":"zsh 1 built x on a\nclaude-code 1 built y on a, b\n"}`)
|
||||
if got := strings.Join(NodesRunning(printed, "claude-code"), ","); got != "a,b" {
|
||||
t.Errorf("from the printed list: %q", got)
|
||||
}
|
||||
if got := NodesRunning(json.RawMessage(`{"ok":true,"answer":[{"module":"zsh","on":["a"]}]}`), "claude-code"); got != nil {
|
||||
t.Errorf("a module that runs nowhere: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -84,14 +83,7 @@ func writeManagedTree(name, content string) (string, error) {
|
||||
if f.Executable {
|
||||
mode = 0o755
|
||||
}
|
||||
content := []byte(f.Content)
|
||||
if f.From != "" {
|
||||
var err error
|
||||
if content, err = os.ReadFile(f.From); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(full, content, mode); err != nil {
|
||||
if err := os.WriteFile(full, []byte(f.Content), mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = os.Chmod(full, mode)
|
||||
@@ -124,13 +116,7 @@ func sameTree(dir string, files map[string]PluginFile) bool {
|
||||
}
|
||||
raw, err := os.ReadFile(full)
|
||||
info, ierr := d.Info()
|
||||
want := []byte(f.Content)
|
||||
if f.From != "" {
|
||||
if want, err = os.ReadFile(f.From); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err != nil || ierr != nil || !bytes.Equal(raw, want) || (info.Mode()&0o111 != 0) != f.Executable {
|
||||
if err != nil || ierr != nil || string(raw) != f.Content || (info.Mode()&0o111 != 0) != f.Executable {
|
||||
return errors.New("differs")
|
||||
}
|
||||
found++
|
||||
@@ -163,37 +149,20 @@ func nodesRunningMe() ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NodesRunning(raw, "claude-code"), nil
|
||||
}
|
||||
|
||||
// NodesRunning reads where a module runs from the controller's `modules` answer. The verb runs `module list
|
||||
// --json`, so the answer is a JSON list of {module, on}: the lines of the printed list it was once read as
|
||||
// never came, and every "also on" hint and every `nodes: "all"` named no machine. The printed form, `<module>
|
||||
// … on a, b`, is still read when that is what came.
|
||||
func NodesRunning(raw json.RawMessage, module string) []string {
|
||||
var listed []struct {
|
||||
Module string `json:"module"`
|
||||
On []string `json:"on"`
|
||||
var answer struct {
|
||||
Output string `json:"output"`
|
||||
}
|
||||
if json.Unmarshal(verbAnswer(raw), &listed) == nil {
|
||||
for _, m := range listed {
|
||||
if m.Module == module {
|
||||
return m.On
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
text := outputOf(raw)
|
||||
if text == "" {
|
||||
text = string(raw)
|
||||
text := string(raw)
|
||||
if json.Unmarshal(raw, &answer) == nil && answer.Output != "" {
|
||||
text = answer.Output
|
||||
}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if !strings.HasPrefix(line, module+" ") {
|
||||
if !strings.HasPrefix(line, "claude-code ") {
|
||||
continue
|
||||
}
|
||||
_, on, ok := strings.Cut(line, " on ")
|
||||
if !ok || strings.TrimSpace(on) == "nothing" {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
var out []string
|
||||
for _, n := range strings.Split(on, ",") {
|
||||
@@ -201,9 +170,9 @@ func NodesRunning(raw json.RawMessage, module string) []string {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func fingerprintOfFile(path string) any {
|
||||
@@ -268,27 +237,10 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
||||
Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
|
||||
Run: func(map[string]any) (any, error) { return status(p), nil }},
|
||||
{Name: "claude_code_render",
|
||||
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after asking the controller again what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).",
|
||||
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
answer := map[string]any{}
|
||||
if m, err := AskMeshTools(ask, lookup); err != nil {
|
||||
answer["asked"] = "the controller did not answer in full (" + err.Error() + "); what was kept is rendered"
|
||||
} else {
|
||||
KeepMeshTools(p, m)
|
||||
}
|
||||
out, err := RenderNow(p, writeManaged)
|
||||
answer["rendered"] = out
|
||||
return answer, err
|
||||
}},
|
||||
{Name: "claude_code_guard",
|
||||
Description: "The guard on the agent's shell on this machine (novox/hq ADR 0245): what it refuses — ssh to a mesh machine, writing /etc/hosts or /etc/resolv.conf, HOSTALIASES — the machines and the replaced commands it judges with, the \"instead of\" table rendered into the agent's instructions, and its record of what it refused and what the operator's override let through, newest last.",
|
||||
Input: map[string]any{"last": map[string]any{"type": "integer", "description": "how many lines of the record (default 50)"}},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
last := 50
|
||||
if n, ok := a["last"].(float64); ok && n > 0 {
|
||||
last = int(n)
|
||||
}
|
||||
return GuardStatus(p, last), nil
|
||||
return map[string]any{"rendered": out}, err
|
||||
}},
|
||||
{Name: "claude_code_pull",
|
||||
Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.",
|
||||
@@ -387,10 +339,6 @@ func persist(what string, attempt func() error, done func(refusals int)) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
// The guard on the agent's shell (novox/hq ADR 0245): this binary, run by the agent's hook.
|
||||
if len(os.Args) == 3 && os.Args[1] == "guard" {
|
||||
os.Exit(runGuard(os.Args[2], os.Stdin, os.Stderr))
|
||||
}
|
||||
p, launched := PathsFrom(os.Getenv)
|
||||
if !launched {
|
||||
// Outside a launch — a build, a check — it serves nothing and says why.
|
||||
@@ -412,7 +360,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
go refreshMeshTools(p)
|
||||
servers := stateOf{stdio.State("servers")}
|
||||
view := NewServerView(p)
|
||||
config := stateOf{stdio.State("config")}
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
package main
|
||||
|
||||
// What the mesh says about its own tools and machines, as the agent's instructions and the guard on its
|
||||
// shell need it (novox/hq ADR 0245): every seat verb and module tool that says what shell command it
|
||||
// replaces, and the mesh's machines by every name and address a session could reach one by.
|
||||
//
|
||||
// Asked of the controller — `tools` for the seats' verbs, `modules` for the modules' own tools, `nodes`
|
||||
// and `node` for the machines — at start and every few minutes, kept in the module's state so a render
|
||||
// never waits for the bus, and rendered whenever it changes. Nothing here is written by hand: a verb that
|
||||
// gains a `replaces` in the records is in the next render's table and the guard's next refusal.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Replacement is one verb or tool and the shell commands it is the mesh's way to do.
|
||||
type Replacement struct {
|
||||
// Address is how it is called: `<seat>.<verb>`, or `<module>.<tool>`; NodeScoped says a machine goes
|
||||
// before it, `<node>/`.
|
||||
Address string `json:"address"`
|
||||
NodeScoped bool `json:"node_scoped,omitempty"`
|
||||
Seat bool `json:"seat,omitempty"`
|
||||
Does string `json:"does,omitempty"`
|
||||
Replaces []string `json:"replaces"`
|
||||
// On is where a module's tool runs; empty for a seat's verb, which every holder serves.
|
||||
On []string `json:"on,omitempty"`
|
||||
}
|
||||
|
||||
// Machine is one of the mesh's machines and the names and addresses it is reached by.
|
||||
type Machine struct {
|
||||
Name string `json:"name"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Addresses []string `json:"addresses,omitempty"`
|
||||
}
|
||||
|
||||
// MeshTools is what the controller said, as kept in the module's state.
|
||||
type MeshTools struct {
|
||||
Replacements []Replacement `json:"replacements"`
|
||||
Machines []Machine `json:"machines"`
|
||||
// Asked is when the controller last answered.
|
||||
Asked string `json:"asked,omitempty"`
|
||||
// Refused is how often the guard here named each tool instead of a work-around, from its record: the
|
||||
// table's order. Never kept — read from the record at each render.
|
||||
Refused map[string]int `json:"-"`
|
||||
}
|
||||
|
||||
// RefusedCounts are how often the guard's record named each tool, over its last lines.
|
||||
func RefusedCounts(log string) map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, e := range ReadGuardLog(log, 2000) {
|
||||
for _, t := range e.Tools {
|
||||
out[t]++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// InternalSuffix is the mesh's own names' domain: every machine and service is `<name>.internal`
|
||||
// (novox/hq ADR 0194), and a session reaches none of them by ssh.
|
||||
const InternalSuffix = ".internal"
|
||||
|
||||
func (p Paths) meshTools() string { return filepath.Join(p.State, "mesh-tools.json") }
|
||||
|
||||
// ReadMeshTools is what was kept, or nil.
|
||||
func ReadMeshTools(p Paths) *MeshTools {
|
||||
var m MeshTools
|
||||
if !readJSON(p.meshTools(), &m) {
|
||||
return nil
|
||||
}
|
||||
return &m
|
||||
}
|
||||
|
||||
// verbAnswer is a controller verb's answer: the command's printed output, and its JSON when it printed one.
|
||||
func verbAnswer(raw json.RawMessage) json.RawMessage {
|
||||
var r struct {
|
||||
Output string `json:"output"`
|
||||
Answer json.RawMessage `json:"answer"`
|
||||
}
|
||||
if json.Unmarshal(raw, &r) != nil {
|
||||
return raw
|
||||
}
|
||||
if len(r.Answer) > 0 && string(r.Answer) != "null" {
|
||||
return r.Answer
|
||||
}
|
||||
if r.Output != "" {
|
||||
t := strings.TrimSpace(r.Output)
|
||||
for _, open := range []string{"[", "{"} {
|
||||
if strings.HasPrefix(t, open) {
|
||||
return json.RawMessage(t)
|
||||
}
|
||||
if i := strings.Index(t, "\n"+open); i >= 0 {
|
||||
return json.RawMessage(strings.TrimSpace(t[i+1:]))
|
||||
}
|
||||
}
|
||||
return json.RawMessage(nil)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func outputOf(raw json.RawMessage) string {
|
||||
var r struct {
|
||||
Output string `json:"output"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
return r.Output
|
||||
}
|
||||
|
||||
var publicDomain = regexp.MustCompile(`(?m)^\s*public domain\s+(\S+)\s*$`)
|
||||
|
||||
// firstSentence is what a verb does, short: its description to the first full stop, at most 70 characters.
|
||||
func firstSentence(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.Index(s, ". "); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
s = strings.TrimSuffix(s, ".")
|
||||
if i := strings.IndexAny(s, ":;("); i > 20 {
|
||||
s = strings.TrimSpace(s[:i])
|
||||
}
|
||||
if r := []rune(s); len(r) > 70 {
|
||||
s = strings.TrimSpace(string(r[:69])) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ReplacementsOf reads the controller's `tools` and `modules` answers: the seats' verbs first, in the
|
||||
// records' order, then the modules' own tools by module and tool.
|
||||
func ReplacementsOf(tools, modules json.RawMessage) []Replacement {
|
||||
var out []Replacement
|
||||
var seats struct {
|
||||
Seats []struct {
|
||||
Seat string `json:"seat"`
|
||||
Scope string `json:"scope"`
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Replaces []string `json:"replaces"`
|
||||
} `json:"tools"`
|
||||
} `json:"seats"`
|
||||
}
|
||||
if json.Unmarshal(tools, &seats) == nil {
|
||||
for _, s := range seats.Seats {
|
||||
for _, t := range s.Tools {
|
||||
if len(t.Replaces) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, Replacement{Address: s.Seat + "." + t.Name, NodeScoped: s.Scope == "node", Seat: true,
|
||||
Does: firstSentence(t.Description), Replaces: t.Replaces})
|
||||
}
|
||||
}
|
||||
}
|
||||
var listed []struct {
|
||||
Module string `json:"module"`
|
||||
On []string `json:"on"`
|
||||
Replaces map[string][]string `json:"replaces"`
|
||||
}
|
||||
if json.Unmarshal(modules, &listed) == nil {
|
||||
sort.SliceStable(listed, func(i, j int) bool { return listed[i].Module < listed[j].Module })
|
||||
for _, m := range listed {
|
||||
names := make([]string, 0, len(m.Replaces))
|
||||
for t := range m.Replaces {
|
||||
names = append(names, t)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, t := range names {
|
||||
if len(m.Replaces[t]) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, Replacement{Address: m.Module + "." + t, NodeScoped: true, Replaces: m.Replaces[t], On: m.On,
|
||||
Does: strings.ReplaceAll(strings.TrimPrefix(t, strings.ReplaceAll(m.Module, "-", "_")+"_"), "_", " ")})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lookup resolves one name, briefly: a name that does not resolve here is no address.
|
||||
func lookup(name string) []string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
addrs, _ := net.DefaultResolver.LookupHost(ctx, name)
|
||||
return addrs
|
||||
}
|
||||
|
||||
// AskMeshTools asks the controller now. A part that does not answer is left out and said in the error;
|
||||
// what did answer is kept.
|
||||
func AskMeshTools(ask Ask, resolve func(string) []string) (MeshTools, error) {
|
||||
var m MeshTools
|
||||
tools, terr := ask("seat:mesh-controller.tools", map[string]any{})
|
||||
modules, merr := ask("seat:mesh-controller.modules", map[string]any{})
|
||||
if terr != nil && merr != nil {
|
||||
return m, terr
|
||||
}
|
||||
m.Replacements = ReplacementsOf(verbAnswer(tools), verbAnswer(modules))
|
||||
nodesRaw, err := ask("seat:mesh-controller.nodes", map[string]any{})
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
var nodes []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.Unmarshal(verbAnswer(nodesRaw), &nodes)
|
||||
for _, n := range nodes {
|
||||
if n.Name == "" {
|
||||
continue
|
||||
}
|
||||
machine := Machine{Name: n.Name, Domains: []string{n.Name + InternalSuffix}}
|
||||
if shown, err := ask("seat:mesh-controller.node", map[string]any{"node": n.Name}); err == nil {
|
||||
if d := publicDomain.FindStringSubmatch(outputOf(shown)); d != nil {
|
||||
machine.Domains = append(machine.Domains, strings.ToLower(d[1]))
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, name := range append([]string{n.Name}, machine.Domains...) {
|
||||
for _, a := range resolve(name) {
|
||||
if !seen[a] && !net.ParseIP(a).IsLoopback() {
|
||||
seen[a] = true
|
||||
machine.Addresses = append(machine.Addresses, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(machine.Addresses)
|
||||
m.Machines = append(m.Machines, machine)
|
||||
}
|
||||
sort.Slice(m.Machines, func(i, j int) bool { return m.Machines[i].Name < m.Machines[j].Name })
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// KeepMeshTools writes what was asked to the module's state when it differs from what was kept, and
|
||||
// answers whether it did. When was asked is not a difference.
|
||||
func KeepMeshTools(p Paths, m MeshTools) bool {
|
||||
if was := ReadMeshTools(p); was != nil {
|
||||
a, b := *was, m
|
||||
a.Asked, b.Asked = "", ""
|
||||
x, _ := json.Marshal(a)
|
||||
y, _ := json.Marshal(b)
|
||||
if string(x) == string(y) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
m.Asked = time.Now().UTC().Format(time.RFC3339)
|
||||
raw, _ := indented(m)
|
||||
tmp := p.meshTools() + ".tmp"
|
||||
if os.WriteFile(tmp, raw, 0o600) != nil {
|
||||
return false
|
||||
}
|
||||
return os.Rename(tmp, p.meshTools()) == nil
|
||||
}
|
||||
|
||||
// GuardDataOf is what the guard judges with on this machine.
|
||||
func GuardDataOf(p Paths, m *MeshTools) GuardData {
|
||||
d := GuardData{Node: p.Node, Log: filepath.Join(p.State, "guard.log"), Machines: []Machine{}, Replacements: []Replacement{}}
|
||||
if m != nil {
|
||||
d.Machines, d.Replacements = m.Machines, m.Replacements
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// guardFiles is the guard to place: this module's own executable and what it judges with. None where the
|
||||
// executable cannot be named — then no hook is written, rather than one that cannot run.
|
||||
func guardFiles(p Paths, m *MeshTools) *GuardFiles {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m)}
|
||||
}
|
||||
|
||||
// MeshToolsEvery is how often the controller is asked again.
|
||||
const MeshToolsEvery = 10 * time.Minute
|
||||
|
||||
// refreshMeshTools asks the controller at start and every few minutes, and renders when what it said changed.
|
||||
func refreshMeshTools(p Paths) {
|
||||
for {
|
||||
m, err := AskMeshTools(ask, lookup)
|
||||
if err != nil {
|
||||
say("asking the controller what each tool replaces: %v", err)
|
||||
}
|
||||
if (err == nil || len(m.Replacements) > 0) && KeepMeshTools(p, m) {
|
||||
if _, err := RenderNow(p, writeManaged); err != nil {
|
||||
say("rendering what the controller said of its tools: %v", err)
|
||||
} else {
|
||||
say("the mesh's tools changed: %d replace a command, %d machines; rendered", len(m.Replacements), len(m.Machines))
|
||||
}
|
||||
}
|
||||
time.Sleep(MeshToolsEvery)
|
||||
}
|
||||
}
|
||||
|
||||
// GuardStatus is what claude_code_guard answers.
|
||||
func GuardStatus(p Paths, last int) map[string]any {
|
||||
m := ReadMeshTools(p)
|
||||
d := GuardDataOf(p, m)
|
||||
asked := "never: the controller has not answered yet"
|
||||
if m != nil {
|
||||
asked = m.Asked
|
||||
}
|
||||
return map[string]any{
|
||||
"refuses": []string{
|
||||
"ssh, scp, sftp, rsync, mosh or autossh to a mesh machine: its name, a name under its domains, any *" + InternalSuffix +
|
||||
" name or one of its addresses, read as ssh reads it (ssh -G); a jump through one too. An ssh login as `" +
|
||||
ForgeUser + "` is the forge's account and passes",
|
||||
"writing /etc/hosts or /etc/resolv.conf, by the shell or the agent's Edit and Write",
|
||||
"HOSTALIASES, named anywhere in a command",
|
||||
"any command naming " + OverrideVar,
|
||||
},
|
||||
"override": OverrideVar + "=<why>, set in the operator's own shell before the session starts; read from the session's " +
|
||||
"environment as it was started, recorded with why, and not honoured when it cannot be recorded",
|
||||
"hook": GuardCommand(),
|
||||
"asked": asked,
|
||||
"machines": d.Machines,
|
||||
"replacements": d.Replacements,
|
||||
"instead_of": InsteadOf(m),
|
||||
"record": ReadGuardLog(d.Log, last),
|
||||
}
|
||||
}
|
||||
@@ -127,12 +127,8 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
var items map[string]Item
|
||||
_ = readJSON(p.config(), &items)
|
||||
config := ConfigOf(items)
|
||||
mesh := ReadMeshTools(p)
|
||||
if mesh != nil {
|
||||
mesh.Refused = RefusedCounts(GuardDataOf(p, mesh).Log)
|
||||
}
|
||||
files := RenderWithMesh(facts, settings, binding, p.helper(), Registered(p), config, mesh)
|
||||
tree, _ := json.Marshal(MarketplaceWith(config, guardFiles(p, mesh)))
|
||||
files := Render(facts, settings, binding, p.helper(), Registered(p), config)
|
||||
tree, _ := json.Marshal(Marketplace(config))
|
||||
files[MarketplaceDir+"/"] = string(tree)
|
||||
names := make([]string, 0, len(files))
|
||||
for n := range files {
|
||||
|
||||
@@ -100,13 +100,6 @@ func jsonFile(v any) string {
|
||||
// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
|
||||
// after the mesh's own text. The plugin itself is Marketplace's, and the home's PlaceHome's.
|
||||
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers, config Config) map[string]string {
|
||||
return RenderWithMesh(facts, settings, binding, helperPath, registered, config, nil)
|
||||
}
|
||||
|
||||
// RenderWithMesh is Render with what the mesh said about its tools (novox/hq ADR 0245): the "instead of"
|
||||
// table, after the mesh's own text and before the sections registered for the agent.
|
||||
func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers, config Config,
|
||||
mesh *MeshTools) map[string]string {
|
||||
servers := map[string]any{}
|
||||
for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} {
|
||||
for name, entry := range layer {
|
||||
@@ -149,6 +142,6 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
|
||||
return map[string]string{
|
||||
"managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}),
|
||||
"managed-settings.json": jsonFile(managed),
|
||||
"CLAUDE.md": instructionsText(facts.Node, role) + InsteadOf(mesh) + InstructionSections(config),
|
||||
"CLAUDE.md": instructionsText(facts.Node, role) + InstructionSections(config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"tools": [
|
||||
"claude_code_status",
|
||||
"claude_code_render",
|
||||
"claude_code_guard",
|
||||
"claude_code_pull",
|
||||
"claude_code_grant",
|
||||
"claude_code_add_api_key",
|
||||
@@ -55,11 +54,7 @@
|
||||
"claude_code_config_list",
|
||||
"claude_code_config_show",
|
||||
"claude_code_config_status",
|
||||
"claude_code_config_import",
|
||||
"claude_code_home_show",
|
||||
"claude_code_home_remove",
|
||||
"claude_code_home_removed",
|
||||
"claude_code_home_restore"
|
||||
"claude_code_config_import"
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
|
||||
@@ -527,7 +527,7 @@ func (c *Client) Act(ctx context.Context, verb, ref string) (map[string]any, err
|
||||
answer := map[string]any{"container": s.Name, "verb": verb, "ok": true, "state": s.State, "mesh_held": s.MeshHeld}
|
||||
if s.MeshHeld {
|
||||
answer["held_by"] = s.HeldBy
|
||||
answer["note"] = fmt.Sprintf("the mesh holds this container (%s): the node-engine restores what its declaration says at its next apply", s.HeldBy)
|
||||
answer["note"] = fmt.Sprintf("the mesh holds this container (%s): the host restores what its declaration says at its next apply", s.HeldBy)
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ func TestActingOnAMeshContainerSaysTheHostRestoresIt(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.ran("docker stop --time 10 mesh-web") || got["mesh_held"] != true || !strings.Contains(got["note"].(string), "node-engine restores") {
|
||||
if !f.ran("docker stop --time 10 mesh-web") || got["mesh_held"] != true || !strings.Contains(got["note"].(string), "host restores") {
|
||||
t.Fatalf("%v %+v", got, f.calls)
|
||||
}
|
||||
got, _ = client(f, 1000).Act(context.Background(), "start", "dev-db")
|
||||
|
||||
@@ -140,8 +140,8 @@ func tools(c *Client) []stdio.Tool {
|
||||
return map[string]any{"count": len(stats), "containers": stats}, nil
|
||||
},
|
||||
},
|
||||
act("start", "Start one container. A container the mesh holds is started too, and the answer says the node-engine restores what its declaration says at its next apply."),
|
||||
act("stop", "Stop one container (ten seconds, then killed). For a container the mesh holds, the answer says the node-engine will start it again at its next apply if its declaration says running."),
|
||||
act("start", "Start one container. A container the mesh holds is started too, and the answer says the host restores what its declaration says at its next apply."),
|
||||
act("stop", "Stop one container (ten seconds, then killed). For a container the mesh holds, the answer says the host will start it again at its next apply if its declaration says running."),
|
||||
act("restart", "Restart one container (ten seconds to stop, then killed); the answer says whether the mesh holds it."),
|
||||
{
|
||||
Name: "docker_top",
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# forticlient
|
||||
|
||||
The FortiClient VPN client on the workstations, as a module (novox/hq ADR 0208): its tray in the
|
||||
operator's session and the vendor's service behind it, and an adapter that hands the client's resolver
|
||||
file to the machine's own resolver (novox/hq ADR 0247). It requires `x11-display` and `split-dns`, so it
|
||||
is assigned only where a display server and the machine's own resolver (`systemd-resolved`) are held on
|
||||
the same machine.
|
||||
operator's session and the vendor's service behind it. It requires `x11-display`, so it is assigned
|
||||
only where a display server is held on the same machine.
|
||||
|
||||
**This is the operator's work VPN.** Nothing of its configuration is the mesh's: no profile, no
|
||||
credential, no gateway, no certificate is declared, read, printed or stored by the module or its
|
||||
@@ -15,44 +13,9 @@ tools. The tools report running and connected state only.
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the vendor's scheduler service, which holds the tunnel | `forticlient.service`, running and enabled |
|
||||
| the adapter to the machine's own resolver | `forticlient-tools split-dns`, a process as root |
|
||||
|
||||
Nothing else. It holds no seat, makes no contribution and writes no file.
|
||||
|
||||
## The client's names: the adapter (ADR 0247)
|
||||
|
||||
FortiClient's Linux client, connecting, moves `/etc/resolv.conf` aside and writes its own: its servers,
|
||||
reached through its tunnel, and the company's search domains. It never tells systemd-resolved or
|
||||
NetworkManager a link's DNS, and never writes the file again during a session. On its own that file
|
||||
either cuts the machine off from the mesh's names (while it stands) or is overwritten by the mesh and
|
||||
cuts the person off from the company's names (for the rest of the session). Research 033 measured both.
|
||||
|
||||
**The quirk is the client's, so the adapter is this module's.** The machine's resolver keeps the client's
|
||||
write and holds it for whoever handles it. The adapter, once a second:
|
||||
|
||||
1. asks the resolver, over its socket on the machine, for the write standing now;
|
||||
2. if the file's header says FortiClient wrote it, reads its servers and its `search` and `domain` lines,
|
||||
and waits up to 15 s for the client's tunnel interface (`fctvpn…`) to be up;
|
||||
3. calls the resolver's `route` with the tunnel, those domains and those servers, taking the write in the
|
||||
same call. The resolver puts its own file back at once, and from then on the company's domains go to
|
||||
the company's servers over the tunnel, and every other name to the mesh's resolvers;
|
||||
4. when the tunnel goes, calls `unroute`;
|
||||
5. every 10 s, checks the route is still in place (a resolver restarted forgets it) and gives it again.
|
||||
|
||||
A write that is not the client's, one with no tunnel within 15 s, one with nothing to route and one the
|
||||
resolver refuses are left to the resolver, which puts its own file back after 90 s. The node-engine then
|
||||
says it (ADR 0241's `rewritten`, naming the writer). So a failed handover is loud.
|
||||
|
||||
**Search domains route, they do not expand.** The client's domains become routing domains: a full name
|
||||
under one goes to the company's servers. A short name is not completed with them, because the machine's
|
||||
resolver file is the mesh's and lists no search domains.
|
||||
|
||||
**All of it stays on the machine.** The adapter runs as root and talks to the resolver over its root-only
|
||||
socket, never over the bus. Its journal names counts, never a server, a domain or the tunnel. The client's
|
||||
configuration is still never opened: what is read is the file the client wrote where every program reads
|
||||
it. A VPN whose client tells systemd-resolved its link's DNS itself needs no adapter.
|
||||
|
||||
|
||||
- **The client is kept as found.** `forticlient-vpn` (7.4.3) is not in the official repositories: on
|
||||
both workstations it is a foreign (AUR) package that repackages the vendor's build, installed
|
||||
explicitly. The host installs from the official repositories only, so the module cannot declare it.
|
||||
@@ -129,5 +92,3 @@ logs, `/etc/xdg/autostart/Fortitray.desktop` (the vendor's link), the package it
|
||||
- **`i3`'s `dex` line for the start**: XDG autostart has no seat. Assigned without `i3`, the tray does
|
||||
not start. `forticlient_check` says so.
|
||||
- A display server on the same machine (`x11-display`, ADR 0208 §3).
|
||||
- The machine's own resolver on the same machine (`split-dns`, ADR 0247): `systemd-resolved`, assigned
|
||||
before this module requires it, on every machine this module runs on.
|
||||
|
||||
Binary file not shown.
@@ -1,33 +1,17 @@
|
||||
// The forticlient module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the FortiClient
|
||||
// VPN client's tray in the operator's session and the vendor's service behind it, served by the node's
|
||||
// runtime as the operator account, and its adapter to the machine's own resolver (splitdns.go, hq ADR
|
||||
// 0247). The module holds no seat, so every tool is its own. The tools
|
||||
// runtime as the operator account. The module holds no seat, so every tool is its own. The tools
|
||||
// report running and connected state only: never a profile, a credential, a gateway or a certificate.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Started as `forticlient-tools split-dns` it is the module's adapter to the machine's own resolver,
|
||||
// a long-running process as root (splitdns.go). With no argument, the runtime's tools bundle.
|
||||
if len(os.Args) > 1 {
|
||||
if os.Args[1] != "split-dns" || len(os.Args) != 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: forticlient-tools [split-dns]")
|
||||
os.Exit(2)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||
defer stop()
|
||||
NewAdapter().Run(ctx)
|
||||
return
|
||||
}
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -16,12 +16,8 @@ import (
|
||||
// tools.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Upgrade struct {
|
||||
Policy string `json:"policy"`
|
||||
Why string `json:"why"`
|
||||
} `json:"upgrade"`
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Tools []string `json:"tools"`
|
||||
@@ -91,25 +87,14 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
|
||||
func TestItDeclaresTheServiceAndNothingOfTheConfiguration(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
// split-dns (novox/hq ADR 0247): the machine's own resolver, which its adapter hands the client's
|
||||
// resolver file to. Required at the machine's reach, so the module is assigned only beside one.
|
||||
if m.Module != "forticlient" || !reflect.DeepEqual(m.Requires, []string{"x11-display", "split-dns"}) ||
|
||||
if m.Module != "forticlient" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
|
||||
!reflect.DeepEqual(m.Capabilities, []string{"service-manager"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
if len(m.Resources) != 2 || m.Resources[0]["type"] != "service" || m.Resources[0]["unit"] != serviceUnit ||
|
||||
if len(m.Resources) != 1 || m.Resources[0]["type"] != "service" || m.Resources[0]["unit"] != serviceUnit ||
|
||||
m.Resources[0]["state"] != "running" || m.Resources[0]["boot"] != "enabled" || m.Resources[0]["restart-on"] != nil {
|
||||
t.Fatalf("resources: %v", m.Resources)
|
||||
}
|
||||
// The adapter: this bundle, as root (no user), long-running, its health said.
|
||||
a := m.Resources[1]
|
||||
if a["type"] != "process" || a["artifact"] != "tools" || !reflect.DeepEqual(a["run"], []any{"./forticlient-tools", "split-dns"}) ||
|
||||
a["user"] != nil || a["run-once"] != nil || a["schedule"] != nil || a["health"] == nil {
|
||||
t.Fatalf("the adapter: %v", a)
|
||||
}
|
||||
if m.Upgrade.Policy != "record" || m.Upgrade.Why == "" {
|
||||
t.Error("a build of what routes the person's work names rolls out on its own")
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
|
||||
t.Fatal("no seat, no environment, and no second start")
|
||||
}
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
package main
|
||||
|
||||
// FortiClient's resolver file, handed to the machine's own resolver (novox/hq ADR 0247).
|
||||
//
|
||||
// **The quirk is the client's, so the adapter is this module's.** FortiClient's Linux client, connecting,
|
||||
// moves /etc/resolv.conf aside and writes its own: two servers reached through its tunnel and the
|
||||
// company's search domains. It never tells systemd-resolved or NetworkManager a link's DNS, and never
|
||||
// writes its file again during a session. The machine's resolver (the node-resolver seat's holder,
|
||||
// required here as `split-dns`) keeps that write and holds it for whoever handles it; this adapter is the
|
||||
// one that does. It reads the client's servers and domains from the write, routes those domains to those
|
||||
// servers over the client's tunnel, says it took the write — and the resolver puts its own file back at
|
||||
// once. When the tunnel goes, it takes the route away.
|
||||
//
|
||||
// **All of it stays on the machine.** It runs as root and talks to the resolver over its socket, which only
|
||||
// root reaches, never over the bus. What it says on its journal is counts, never a server, a domain or the
|
||||
// tunnel's address. The client's configuration is still never opened: what is read is the file the client
|
||||
// wrote where every program on the machine reads it.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// ResolverSocket is where the node-resolver seat's holder serves its verbs on the machine.
|
||||
ResolverSocket = "/run/node-resolver/verbs.sock"
|
||||
// clientWords is how FortiClient's own resolver file says who wrote it.
|
||||
clientWords = "generated by forticlient"
|
||||
// adaptEvery is how often the adapter looks; tunnelWait how long it waits for the tunnel's link to be
|
||||
// up after the client wrote its file, before leaving the write for the resolver to hold and raise.
|
||||
adaptEvery = time.Second
|
||||
tunnelWait = 15 * time.Second
|
||||
// checkEvery is how often a route in place is checked against the resolver: a resolver restarted
|
||||
// forgets every link's route.
|
||||
checkEvery = 10 * time.Second
|
||||
// takenBy is this module's name, as the resolver records who took a write.
|
||||
takenBy = "forticlient"
|
||||
)
|
||||
|
||||
// ResolverCall is one verb of the machine's resolver, called on the machine.
|
||||
type ResolverCall func(verb string, args map[string]any) (json.RawMessage, error)
|
||||
|
||||
// callResolver calls the resolver's socket: one JSON line out, one back.
|
||||
func callResolver(verb string, args map[string]any) (json.RawMessage, error) {
|
||||
c, err := net.DialTimeout("unix", ResolverSocket, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the machine's resolver does not answer on its socket: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(20 * time.Second))
|
||||
req, _ := json.Marshal(map[string]any{"verb": verb, "args": args})
|
||||
if _, err := c.Write(append(req, '\n')); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line, err := bufio.NewReader(c).ReadBytes('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var reply struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &reply); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reply.Error != "" {
|
||||
return nil, errors.New(reply.Error)
|
||||
}
|
||||
return reply.Result, nil
|
||||
}
|
||||
|
||||
// ClientFile is what FortiClient's resolver file says: its servers and its domains.
|
||||
type ClientFile struct {
|
||||
Servers []string
|
||||
Domains []string
|
||||
}
|
||||
|
||||
// ReadClientFile reads a resolver file FortiClient wrote. False when it is not FortiClient's: the adapter
|
||||
// handles its own client's file and nobody else's.
|
||||
func ReadClientFile(content string) (ClientFile, bool) {
|
||||
var f ClientFile
|
||||
ours := false
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
ours = ours || strings.Contains(strings.ToLower(line), clientWords)
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
switch fields[0] {
|
||||
case "nameserver":
|
||||
at, _, _ := strings.Cut(fields[1], "%")
|
||||
if a, err := netip.ParseAddr(at); err == nil && !seen[a.String()] {
|
||||
seen[a.String()] = true
|
||||
f.Servers = append(f.Servers, a.String())
|
||||
}
|
||||
case "search", "domain":
|
||||
for _, d := range fields[1:] {
|
||||
d = strings.ToLower(strings.TrimSuffix(d, "."))
|
||||
if d != "" && !seen["~"+d] {
|
||||
seen["~"+d] = true
|
||||
f.Domains = append(f.Domains, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return f, ours
|
||||
}
|
||||
|
||||
// Adapter hands FortiClient's resolver file to the machine's resolver, and takes the route away when the
|
||||
// tunnel goes.
|
||||
type Adapter struct {
|
||||
Call ResolverCall
|
||||
NetDir string
|
||||
Now func() time.Time
|
||||
Log func(format string, args ...any)
|
||||
|
||||
// route is the one in place: the tunnel's link, and what was routed over it.
|
||||
link string
|
||||
domains []string
|
||||
servers []string
|
||||
lastCheck time.Time
|
||||
adopted bool
|
||||
tried string
|
||||
triedSince time.Time
|
||||
gaveUpOn string
|
||||
}
|
||||
|
||||
// NewAdapter is the machine's.
|
||||
func NewAdapter() *Adapter {
|
||||
return &Adapter{Call: callResolver, NetDir: "/sys/class/net", Now: time.Now,
|
||||
Log: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }}
|
||||
}
|
||||
|
||||
// tunnelLink is the client's tunnel interface that is up, or empty.
|
||||
func (a *Adapter) tunnelLink() string {
|
||||
entries, _ := os.ReadDir(a.NetDir)
|
||||
for _, e := range entries {
|
||||
if !strings.HasPrefix(e.Name(), tunnelPrefix) {
|
||||
continue
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(a.NetDir, e.Name(), "flags"))
|
||||
flags, err := strconv.ParseUint(strings.TrimPrefix(strings.TrimSpace(string(raw)), "0x"), 16, 32)
|
||||
if err == nil && flags&1 == 1 {
|
||||
return e.Name()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *Adapter) present(link string) bool {
|
||||
_, err := os.Stat(filepath.Join(a.NetDir, link))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
type routesAnswer struct {
|
||||
Links []struct {
|
||||
Link string `json:"link"`
|
||||
Servers []string `json:"servers"`
|
||||
Domains []string `json:"domains"`
|
||||
} `json:"links"`
|
||||
}
|
||||
|
||||
// adopt takes a route already in place over the client's tunnel as this adapter's — after the adapter
|
||||
// itself restarted, resolved kept it.
|
||||
func (a *Adapter) adopt() {
|
||||
a.adopted = true
|
||||
raw, err := a.Call("routes", nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var r routesAnswer
|
||||
if json.Unmarshal(raw, &r) != nil {
|
||||
return
|
||||
}
|
||||
for _, l := range r.Links {
|
||||
if strings.HasPrefix(l.Link, tunnelPrefix) && len(l.Domains) > 0 {
|
||||
a.link, a.domains, a.servers = l.Link, l.Domains, l.Servers
|
||||
a.Log("a route over the client's tunnel was in place (%d domains); kept as this adapter's", len(l.Domains))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tick is one look. It answers what it did, for the journal and the tests.
|
||||
func (a *Adapter) Tick() string {
|
||||
if !a.adopted {
|
||||
a.adopt()
|
||||
}
|
||||
now := a.Now()
|
||||
// The tunnel went: its route goes with it.
|
||||
if a.link != "" && !a.present(a.link) {
|
||||
if _, err := a.Call("unroute", map[string]any{"link": a.link}); err != nil {
|
||||
a.Log("taking the route away failed: %v", err)
|
||||
return "failed"
|
||||
}
|
||||
a.Log("the tunnel went; its %d domains go to the mesh's resolvers again", len(a.domains))
|
||||
a.link, a.domains, a.servers = "", nil, nil
|
||||
return "unrouted"
|
||||
}
|
||||
// A route in place that the resolver forgot (it restarted): given again.
|
||||
if a.link != "" && now.Sub(a.lastCheck) >= checkEvery {
|
||||
a.lastCheck = now
|
||||
if raw, err := a.Call("routes", nil); err == nil {
|
||||
var r routesAnswer
|
||||
found := false
|
||||
if json.Unmarshal(raw, &r) == nil {
|
||||
for _, l := range r.Links {
|
||||
found = found || (l.Link == a.link && len(l.Domains) > 0)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if _, err := a.Call("route", map[string]any{"link": a.link, "domains": a.domains, "servers": a.servers}); err != nil {
|
||||
a.Log("routing the tunnel's domains again failed: %v", err)
|
||||
return "failed"
|
||||
}
|
||||
a.Log("the resolver had forgotten the tunnel's route; given again")
|
||||
return "routed again"
|
||||
}
|
||||
}
|
||||
}
|
||||
// The client wrote its file: route what it pushed, and take the write.
|
||||
raw, err := a.Call("displaced", nil)
|
||||
if err != nil || string(raw) == "null" || len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var pending struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal(raw, &pending) != nil || pending.ID == "" || pending.ID == a.gaveUpOn {
|
||||
return ""
|
||||
}
|
||||
file, ours := ReadClientFile(pending.Content)
|
||||
if !ours {
|
||||
return "" // another program's write: the resolver holds it, and the node-engine says it
|
||||
}
|
||||
if pending.ID != a.tried {
|
||||
a.tried, a.triedSince = pending.ID, now
|
||||
}
|
||||
if len(file.Servers) == 0 || len(file.Domains) == 0 {
|
||||
a.gaveUpOn = pending.ID
|
||||
a.Log("the client's file names %d servers and %d domains; nothing to route, so the write is left to the resolver", len(file.Servers), len(file.Domains))
|
||||
return "nothing to route"
|
||||
}
|
||||
link := a.tunnelLink()
|
||||
if link == "" {
|
||||
if now.Sub(a.triedSince) >= tunnelWait {
|
||||
a.gaveUpOn = pending.ID
|
||||
a.Log("the client wrote its file and no tunnel came up within %s; the write is left to the resolver", tunnelWait)
|
||||
return "no tunnel"
|
||||
}
|
||||
return "waiting for the tunnel"
|
||||
}
|
||||
if _, err := a.Call("route", map[string]any{"link": link, "domains": file.Domains, "servers": file.Servers,
|
||||
"takes": pending.ID, "by": takenBy}); err != nil {
|
||||
a.gaveUpOn = pending.ID
|
||||
a.Log("routing the client's domains was refused: %v; the write is left to the resolver", err)
|
||||
return "refused"
|
||||
}
|
||||
a.link, a.domains, a.servers, a.lastCheck = link, file.Domains, file.Servers, now
|
||||
a.Log("the client's %d domains go to its %d servers over its tunnel; the resolver's file is put back", len(file.Domains), len(file.Servers))
|
||||
return "routed"
|
||||
}
|
||||
|
||||
// Run looks until the context ends.
|
||||
func (a *Adapter) Run(ctx context.Context) {
|
||||
t := time.NewTicker(adaptEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
a.Tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The client's file as FortiClient writes it (the header is the binary's own); the servers and domains
|
||||
// are documentation's, never a real company's.
|
||||
const clientFile = "# Dynamic resolv.conf(5) file for glibc resolver(3) generated by forticlient\n" +
|
||||
"# The original file has been backed up and will be restored after the VPN disconnects\n" +
|
||||
"nameserver 192.0.2.53\nnameserver 192.0.2.54\nsearch corp.example cloud.example Corp.Example.\n"
|
||||
|
||||
// fakeResolver is the machine's resolver as its socket answers, recording what was asked.
|
||||
type fakeResolver struct {
|
||||
pending *struct{ ID, Content string }
|
||||
links map[string][]string // link → domains routed
|
||||
calls []string
|
||||
refuse string
|
||||
}
|
||||
|
||||
func (f *fakeResolver) call(verb string, args map[string]any) (json.RawMessage, error) {
|
||||
raw, _ := json.Marshal(args)
|
||||
f.calls = append(f.calls, verb+" "+string(raw))
|
||||
switch verb {
|
||||
case "displaced":
|
||||
if f.pending == nil {
|
||||
return json.RawMessage("null"), nil
|
||||
}
|
||||
return json.Marshal(map[string]any{"id": f.pending.ID, "content": f.pending.Content})
|
||||
case "routes":
|
||||
var links []map[string]any
|
||||
for l, d := range f.links {
|
||||
links = append(links, map[string]any{"link": l, "domains": d, "servers": []string{"192.0.2.53"}})
|
||||
}
|
||||
return json.Marshal(map[string]any{"links": links})
|
||||
case "route":
|
||||
if f.refuse != "" {
|
||||
return nil, fmt.Errorf("%s", f.refuse)
|
||||
}
|
||||
var ds []string
|
||||
switch d := args["domains"].(type) {
|
||||
case []string:
|
||||
ds = d
|
||||
}
|
||||
f.links[args["link"].(string)] = ds
|
||||
if args["takes"] != nil && f.pending != nil && args["takes"] == f.pending.ID {
|
||||
f.pending = nil // the resolver puts its own file back
|
||||
}
|
||||
return json.Marshal(map[string]any{"link": args["link"]})
|
||||
case "unroute":
|
||||
delete(f.links, args["link"].(string))
|
||||
return json.Marshal(map[string]any{"link": args["link"]})
|
||||
}
|
||||
return nil, fmt.Errorf("%q is not a verb", verb)
|
||||
}
|
||||
|
||||
// aTunnelledMachine is an adapter over a fake resolver and a /sys/class/net the test brings links up in.
|
||||
func aTunnelledMachine(t *testing.T) (*Adapter, *fakeResolver, string, *time.Time, *[]string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
|
||||
f := &fakeResolver{links: map[string][]string{}}
|
||||
var said []string
|
||||
a := &Adapter{Call: f.call, NetDir: dir, Now: func() time.Time { return now },
|
||||
Log: func(format string, args ...any) { said = append(said, fmt.Sprintf(format, args...)) }}
|
||||
return a, f, dir, &now, &said
|
||||
}
|
||||
|
||||
func linkUp(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name, "flags"), []byte("0x1091\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The client's own file is read for its servers and its domains, each once; another program's is not
|
||||
// the client's.
|
||||
func TestTheClientsFileIsReadForItsServersAndDomains(t *testing.T) {
|
||||
f, ours := ReadClientFile(clientFile)
|
||||
if !ours || strings.Join(f.Servers, " ") != "192.0.2.53 192.0.2.54" || strings.Join(f.Domains, " ") != "corp.example cloud.example" {
|
||||
t.Errorf("read %+v (%v)", f, ours)
|
||||
}
|
||||
if _, ours := ReadClientFile("# Generated by NetworkManager\nnameserver 192.0.2.1\nsearch corp.example\n"); ours {
|
||||
t.Error("another program's file was taken for the client's")
|
||||
}
|
||||
}
|
||||
|
||||
// The client connects: its file is displaced, its tunnel is up — the adapter routes its domains to its
|
||||
// servers over the tunnel and takes the write in one call, so the resolver's file is back. The tunnel
|
||||
// goes: the route is taken away. What it says names counts only.
|
||||
func TestTheClientsDomainsAreRoutedOverItsTunnelAndGoWithIt(t *testing.T) {
|
||||
a, f, dir, _, said := aTunnelledMachine(t)
|
||||
if did := a.Tick(); did != "" {
|
||||
t.Fatalf("with nothing written the adapter did %q", did)
|
||||
}
|
||||
f.pending = &struct{ ID, Content string }{"w1", clientFile}
|
||||
linkUp(t, dir, "fctvpn0")
|
||||
if did := a.Tick(); did != "routed" {
|
||||
t.Fatalf("the client's write was %q", did)
|
||||
}
|
||||
if f.pending != nil {
|
||||
t.Error("the write was routed and not taken")
|
||||
}
|
||||
route := f.calls[len(f.calls)-1]
|
||||
for _, want := range []string{`"link":"fctvpn0"`, `"takes":"w1"`, `"by":"forticlient"`, `"192.0.2.53"`, `"cloud.example"`} {
|
||||
if !strings.Contains(route, want) {
|
||||
t.Errorf("the route asked lacks %s: %s", want, route)
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(dir, "fctvpn0")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if did := a.Tick(); did != "unrouted" || len(f.links) != 0 {
|
||||
t.Errorf("the tunnel went and its route stayed: %q %v", did, f.links)
|
||||
}
|
||||
for _, s := range *said {
|
||||
for _, never := range []string{"192.0.2", "corp.example", "fctvpn"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("the journal is told %q: %s", never, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The tunnel is not up yet: the adapter waits for it, then gives the write up to the resolver, which
|
||||
// holds it and has it raised. Another program's write is never taken.
|
||||
func TestAWriteWithNoTunnelOrNotTheClientsIsLeftToTheResolver(t *testing.T) {
|
||||
a, f, dir, now, _ := aTunnelledMachine(t)
|
||||
f.pending = &struct{ ID, Content string }{"w1", clientFile}
|
||||
if did := a.Tick(); did != "waiting for the tunnel" {
|
||||
t.Fatalf("no tunnel: %q", did)
|
||||
}
|
||||
*now = now.Add(tunnelWait)
|
||||
if did := a.Tick(); did != "no tunnel" {
|
||||
t.Fatalf("no tunnel after the wait: %q", did)
|
||||
}
|
||||
linkUp(t, dir, "fctvpn0")
|
||||
if did := a.Tick(); did != "" || f.pending == nil {
|
||||
t.Errorf("a write given up on was taken later: %q", did)
|
||||
}
|
||||
f.pending = &struct{ ID, Content string }{"w2", "# Generated by NetworkManager\nnameserver 192.0.2.1\nsearch corp.example\n"}
|
||||
if did := a.Tick(); did != "" || f.pending == nil {
|
||||
t.Errorf("another program's write was taken: %q", did)
|
||||
}
|
||||
f.pending = &struct{ ID, Content string }{"w3", clientFile}
|
||||
f.refuse = "\"internal\" is the mesh's own domain"
|
||||
if did := a.Tick(); did != "refused" || f.pending == nil {
|
||||
t.Errorf("a refused route took the write anyway: %q", did)
|
||||
}
|
||||
}
|
||||
|
||||
// The resolver restarted and forgot the tunnel's route: it is given again. An adapter restarted keeps a
|
||||
// route resolved still holds over the client's tunnel, and takes it away when the tunnel goes.
|
||||
func TestARouteIsKeptAcrossARestartOfEither(t *testing.T) {
|
||||
a, f, dir, now, _ := aTunnelledMachine(t)
|
||||
f.pending = &struct{ ID, Content string }{"w1", clientFile}
|
||||
linkUp(t, dir, "fctvpn0")
|
||||
a.Tick()
|
||||
delete(f.links, "fctvpn0")
|
||||
*now = now.Add(checkEvery)
|
||||
if did := a.Tick(); did != "routed again" || len(f.links["fctvpn0"]) != 2 {
|
||||
t.Errorf("a forgotten route: %q %v", did, f.links)
|
||||
}
|
||||
|
||||
b := &Adapter{Call: f.call, NetDir: dir, Now: a.Now, Log: func(string, ...any) {}}
|
||||
b.Tick()
|
||||
if b.link != "fctvpn0" {
|
||||
t.Fatalf("a restarted adapter did not keep the route in place: %+v", b)
|
||||
}
|
||||
_ = os.RemoveAll(filepath.Join(dir, "fctvpn0"))
|
||||
if did := b.Tick(); did != "unrouted" {
|
||||
t.Errorf("a restarted adapter left the gone tunnel's route: %q", did)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
{
|
||||
"module": "forticlient",
|
||||
"version": "1",
|
||||
"upgrade": {
|
||||
"policy": "record",
|
||||
"why": "its adapter routes the company's domains on the machine a person works on: a build that breaks it cuts the person off from work names until a person pushes the next (hq ADR 0236, ADR 0247)"
|
||||
},
|
||||
"capabilities": [
|
||||
"service-manager"
|
||||
],
|
||||
"requires": [
|
||||
"x11-display",
|
||||
"split-dns"
|
||||
"x11-display"
|
||||
],
|
||||
"tools": [
|
||||
"forticlient_status",
|
||||
@@ -24,19 +19,6 @@
|
||||
"unit": "forticlient.service",
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
},
|
||||
{
|
||||
"id": "split-dns",
|
||||
"type": "process",
|
||||
"name": "forticlient-split-dns",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./forticlient-tools",
|
||||
"split-dns"
|
||||
],
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// What the forge says of a commit's checks (novox/hq ADR 0239): every status it keeps for the commit, the
|
||||
// newest of each context, and whether the branch it merges into would let it merge — every status that
|
||||
// branch's protection requires being a success. Read by mesh-delivery's `checks` verb, which joins it to the
|
||||
// verdict it keeps; the forge's statuses are what the merge button reads, so they are asked of the forge.
|
||||
//
|
||||
// Pure functions, so they are tested without a forge; the tool does the asking.
|
||||
|
||||
import type { BranchProtection } from "./protection.js";
|
||||
|
||||
/** One status of a commit as the forge keeps it. */
|
||||
export interface StatusRead {
|
||||
context: string;
|
||||
state: string;
|
||||
description: string;
|
||||
target_url?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
creator?: string;
|
||||
}
|
||||
|
||||
/** The forge's statuses of a commit, the newest of each context, in context order. The forge answers the
|
||||
* newest first; a status it says without a context is not one anything can require, and is left out. */
|
||||
export function newestByContext(raw: unknown[]): StatusRead[] {
|
||||
const seen = new Map<string, StatusRead>();
|
||||
const stamp = (s: StatusRead) => s.updated_at || s.created_at || "";
|
||||
for (const r of raw ?? []) {
|
||||
const s = r as Record<string, any>;
|
||||
const context = String(s?.context ?? "").trim();
|
||||
if (!context) continue;
|
||||
const read: StatusRead = {
|
||||
context,
|
||||
state: String(s.status ?? s.state ?? ""),
|
||||
description: String(s.description ?? ""),
|
||||
};
|
||||
if (s.target_url) read.target_url = String(s.target_url);
|
||||
if (s.created_at) read.created_at = String(s.created_at);
|
||||
if (s.updated_at) read.updated_at = String(s.updated_at);
|
||||
const creator = s.creator?.login ?? s.creator?.username;
|
||||
if (creator) read.creator = String(creator);
|
||||
const before = seen.get(context);
|
||||
if (!before || stamp(read) > stamp(before)) seen.set(context, read);
|
||||
}
|
||||
return [...seen.values()].sort((a, b) => a.context.localeCompare(b.context));
|
||||
}
|
||||
|
||||
/** Whether a commit may merge into a branch, as its protection reads the statuses. */
|
||||
export interface MergeableRead {
|
||||
/** The branch the protection is read for. */
|
||||
branch: string;
|
||||
/** Whether the branch has a rule at all, and whether it requires statuses. */
|
||||
protected: boolean;
|
||||
required: string[];
|
||||
/** Every required status that is not a success, with its state ("missing" when the commit has none). */
|
||||
blocking: { context: string; state: string }[];
|
||||
/** True when every required status is a success; null when the protection could not be read. */
|
||||
mergeable: boolean | null;
|
||||
/** In words. */
|
||||
says: string;
|
||||
}
|
||||
|
||||
/** Whether the statuses let the commit merge under the branch's rule. A rule that cannot be read says
|
||||
* nothing, never yes. A warning blocks: the forge combines it as a failure (novox/hq issue 293). */
|
||||
export function mergeableUnder(branch: string, rule: BranchProtection | null | undefined, statuses: StatusRead[],
|
||||
unreadable?: string): MergeableRead {
|
||||
if (unreadable !== undefined) {
|
||||
return { branch, protected: false, required: [], blocking: [], mergeable: null,
|
||||
says: `the protection of ${branch} could not be read: ${unreadable}` };
|
||||
}
|
||||
const required = rule && rule.enable_status_check ? [...new Set((rule.status_check_contexts ?? []).filter(Boolean))] : [];
|
||||
const by = new Map(statuses.map((s) => [s.context, s.state]));
|
||||
const blocking = required
|
||||
.filter((c) => by.get(c) !== "success")
|
||||
.map((c) => ({ context: c, state: by.get(c) ?? "missing" }));
|
||||
const says = !rule
|
||||
? `${branch} has no protection: nothing is required`
|
||||
: required.length === 0
|
||||
? `${branch} requires no status`
|
||||
: blocking.length === 0
|
||||
? `every status ${branch} requires is a success: ${required.join(", ")}`
|
||||
: `${branch} requires ${required.join(", ")}; not a success: ` +
|
||||
blocking.map((b) => `${b.context} (${b.state})`).join(", ");
|
||||
return { branch, protected: !!rule, required, blocking, mergeable: blocking.length === 0, says };
|
||||
}
|
||||
@@ -348,13 +348,6 @@ export class GiteaClient {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A commit's statuses whole, as the forge combines them for a ref (a sha, or the start of one): the commit
|
||||
* it resolved to and every status with its description, link, times and setter (novox/hq ADR 0239). */
|
||||
async combinedStatus(owner: string, repo: string, ref: string): Promise<{ sha: string; state: string; statuses: unknown[] }> {
|
||||
const raw = await this.request<any>(`/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}/status?limit=50`);
|
||||
return { sha: String(raw?.sha ?? ""), state: String(raw?.state ?? ""), statuses: Array.isArray(raw?.statuses) ? raw.statuses : [] };
|
||||
}
|
||||
|
||||
/** Replace a comment's body: the delivery's view, kept current in place (novox/hq ADR 0239). */
|
||||
async editComment(owner: string, repo: string, id: number, body: string): Promise<{ id: number; html_url: string }> {
|
||||
const c = await this.request<any>(`/repos/${owner}/${repo}/issues/comments/${id}`, { method: "PATCH", body: JSON.stringify({ body }) });
|
||||
|
||||
@@ -68,9 +68,9 @@ function builds(plan?: ChangePlan): boolean {
|
||||
return !!plan && ((plan.moved?.length ?? 0) > 0 || (plan.new?.length ?? 0) > 0);
|
||||
}
|
||||
|
||||
/** A delivery plan (the controller's ChangePlan) as a person reads it on the pull request. */
|
||||
/** A change plan as a person reads it on the pull request. */
|
||||
export function planText(plan: ChangePlan): string {
|
||||
const lines = [`**Delivery plan** — ${plan.summary}`];
|
||||
const lines = [`**Change plan** — ${plan.summary}`];
|
||||
(plan.tiers ?? []).forEach((tier, i) => lines.push(`- tier ${i}: ${tier.join(", ")}`));
|
||||
for (const m of plan.machines ?? []) {
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
// What the forge says of a commit's checks (novox/hq ADR 0239): read by mesh-delivery's `checks` verb.
|
||||
|
||||
test("the newest status of each context is kept, with what it said, when and by whom", async () => {
|
||||
const { newestByContext } = await import("../checks.ts");
|
||||
const read = newestByContext([
|
||||
{ context: "mesh/merge-gate", status: "success", description: "pass: every machine composes", updated_at: "2026-10-07T10:00:00Z",
|
||||
creator: { login: "mesh-admin" }, target_url: "https://forge.example/o/r/pulls/1" },
|
||||
{ context: "mesh/merge-gate", status: "failure", description: "fail: 0 of 4 compose", updated_at: "2026-10-07T09:00:00Z" },
|
||||
{ context: "mesh/repo-check", state: "error", description: "error: the toolchain was not there", created_at: "2026-10-07T10:01:00Z" },
|
||||
{ status: "success", description: "a status with no context" },
|
||||
]);
|
||||
assert.deepEqual(read.map((s) => [s.context, s.state]), [["mesh/merge-gate", "success"], ["mesh/repo-check", "error"]]);
|
||||
assert.equal(read[0].creator, "mesh-admin");
|
||||
assert.equal(read[0].description, "pass: every machine composes");
|
||||
assert.equal(read[1].created_at, "2026-10-07T10:01:00Z");
|
||||
});
|
||||
|
||||
test("a commit merges only when every required status is a success; a warning blocks", async () => {
|
||||
const { mergeableUnder } = await import("../checks.ts");
|
||||
const rule = { rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] };
|
||||
const ok = mergeableUnder("main", rule, [
|
||||
{ context: "mesh/merge-gate", state: "success", description: "" },
|
||||
{ context: "mesh/repo-check", state: "success", description: "" },
|
||||
]);
|
||||
assert.equal(ok.mergeable, true);
|
||||
assert.deepEqual(ok.blocking, []);
|
||||
|
||||
const not = mergeableUnder("main", rule, [{ context: "mesh/merge-gate", state: "warning", description: "" }]);
|
||||
assert.equal(not.mergeable, false);
|
||||
assert.deepEqual(not.blocking, [{ context: "mesh/merge-gate", state: "warning" }, { context: "mesh/repo-check", state: "missing" }]);
|
||||
assert.match(not.says, /mesh\/repo-check \(missing\)/);
|
||||
});
|
||||
|
||||
test("no rule requires nothing; a rule that cannot be read says nothing, never yes", async () => {
|
||||
const { mergeableUnder } = await import("../checks.ts");
|
||||
const none = mergeableUnder("main", null, []);
|
||||
assert.equal(none.mergeable, true);
|
||||
assert.equal(none.protected, false);
|
||||
const off = mergeableUnder("main", { rule_name: "main", enable_status_check: false, status_check_contexts: ["mesh/merge-gate"] }, []);
|
||||
assert.equal(off.mergeable, true);
|
||||
assert.deepEqual(off.required, []);
|
||||
const unread = mergeableUnder("main", undefined, [], "Gitea API: 500");
|
||||
assert.equal(unread.mergeable, null);
|
||||
assert.match(unread.says, /could not be read/);
|
||||
});
|
||||
@@ -137,7 +137,7 @@ test("a change plan is the gate's result: said on the status and, when it builds
|
||||
summary: "every machine composes", gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea"] }, plan };
|
||||
assert.equal(statusFor(c).description, "pass: builds gitea → anchor; no bus step; every machine composes");
|
||||
const said = commentFor(c) ?? "";
|
||||
assert.match(said, /Delivery plan\*\* — builds gitea → anchor/);
|
||||
assert.match(said, /Change plan\*\* — builds gitea → anchor/);
|
||||
assert.match(said, /- anchor: receives gitea/);
|
||||
// A plan that builds nothing, passing: the statuses say it, no comment.
|
||||
const nothing = { ...c, plan: { ...plan, moved: [], tiers: [], machines: [], summary: "builds nothing" } };
|
||||
|
||||
@@ -12,7 +12,6 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { GiteaClient, type BranchProtection } from "../client.js";
|
||||
import { appendNote, deliveryStatus, run, viewBody, viewComment } from "../delivery.js";
|
||||
import { mergeableUnder, newestByContext } from "../checks.js";
|
||||
|
||||
/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */
|
||||
const forgeContainer = process.env.MESH_GITEA_CONTAINER || "gitea";
|
||||
@@ -451,47 +450,6 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
return { created: await gitea.addComment(owner, repo, number, body) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_commit_statuses",
|
||||
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "a pull request's number: its head is read, and its base's protection" },
|
||||
sha: { type: "string", description: "a commit, or the start of one, instead of a pull request" },
|
||||
base: { type: "string", description: "with sha: the branch whose protection is read (default main)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
|
||||
const number = args.number === undefined || args.number === "" ? 0 : Number(args.number);
|
||||
let ref = String(args.sha ?? "").trim();
|
||||
let base = String(args.base ?? "").trim();
|
||||
if (!owner || !repo) throw new Error("name the repository: owner and repo");
|
||||
if (!Number.isInteger(number) || number < 0) throw new Error(`${args.number} is not a pull request's number`);
|
||||
let pull: { number: number; title: string; state: string; merged: boolean; base?: string; head?: string;
|
||||
head_sha?: string; merge_commit_sha?: string; html_url: string } | undefined;
|
||||
if (number > 0) {
|
||||
const p = await gitea.getPullRequest(owner, repo, number);
|
||||
pull = { number: p.number, title: p.title, state: p.state, merged: p.merged, base: p.base, head: p.head,
|
||||
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, html_url: p.html_url };
|
||||
if (!ref) ref = p.head_sha ?? "";
|
||||
if (!base) base = p.base ?? "";
|
||||
if (!ref) throw new Error(`${owner}/${repo}#${number} names no head commit`);
|
||||
}
|
||||
if (!ref) throw new Error("name a pull request's number or a commit");
|
||||
const combined = await gitea.combinedStatus(owner, repo, ref);
|
||||
const statuses = newestByContext(combined.statuses);
|
||||
base = base || "main";
|
||||
let rule: BranchProtection | null = null, unreadable: string | undefined;
|
||||
try {
|
||||
rule = await gitea.branchProtection(owner, repo, base);
|
||||
} catch (err) {
|
||||
unreadable = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
return { commit: combined.sha || ref, pull, combined: combined.state, statuses,
|
||||
merge: mergeableUnder(base, rule, statuses, unreadable) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_commit_status",
|
||||
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to.",
|
||||
|
||||
@@ -100,7 +100,7 @@ node -e ${shellQuote(
|
||||
`const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`,
|
||||
)}
|
||||
${setState("building")}
|
||||
# The @novox scope resolves from the mesh's own package registry on the forge, as the builder
|
||||
# The @novox scope resolves from the mesh's own package registry on the forge, as the build machine
|
||||
# resolves it; nothing else is asked of it.
|
||||
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
|
||||
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
{
|
||||
"module": "mailu",
|
||||
"version": "1",
|
||||
"upgrade": {
|
||||
"policy": "record",
|
||||
"why": "people's mail: any change to how its containers are declared recreates them together in one send, and the mail is down for everyone until they are up again — a person chooses the moment (hq ADR 0242, issue 295)"
|
||||
},
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The `checks` verb (novox/hq ADR 0239): what the mesh's checks said of a pull request's head, or of one
|
||||
// commit. Two records answer it, and neither alone is enough. The forge keeps each status — the newest of
|
||||
// each context, a description clipped to what it shows, and the protection that decides whether the merge
|
||||
// button works. This owner keeps the verdict whole as the controller said it: each layer's summary
|
||||
// unclipped ("0 of 4 compose" is not lost to a 140-character description), the build seat's ask that ran
|
||||
// it, the machine it ran on, and its report. So the forge is asked for its statuses and this owner joins
|
||||
// its own verdict to the two statuses that verdict set. Nothing is written: a read, by anyone.
|
||||
|
||||
// Context names of the statuses this owner can say more of than the forge keeps.
|
||||
const (
|
||||
ctxGate = "mesh/merge-gate"
|
||||
ctxRepo = "mesh/repo-check"
|
||||
ctxDelivery = "mesh/delivery"
|
||||
ctxGroup = "mesh/delivery-group"
|
||||
)
|
||||
|
||||
// CheckStatus is one status of the commit, with what this owner knows of it beyond the forge.
|
||||
type CheckStatus struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SetAt string `json:"set_at,omitempty"`
|
||||
SetBy string `json:"set_by,omitempty"`
|
||||
Link string `json:"link,omitempty"`
|
||||
// From the verdict that set it, when this owner holds it: the build seat's ask, the machine that ran it,
|
||||
// when it was heard, and the layer's verdict in full.
|
||||
Build string `json:"build,omitempty"`
|
||||
CheckedOn string `json:"checked_on,omitempty"`
|
||||
CheckedAt string `json:"checked_at,omitempty"`
|
||||
Verdict string `json:"verdict,omitempty"`
|
||||
// Source says where the status was read: the forge, or — the forge unreachable — this owner's verdict.
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// ChecksAnswer is the `checks` verb's answer.
|
||||
type ChecksAnswer struct {
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
Pull *ForgePull `json:"pull,omitempty"`
|
||||
Delivery *ChecksOf `json:"delivery,omitempty"`
|
||||
Statuses []CheckStatus `json:"statuses"`
|
||||
// Verdict is the merge check's verdict whole, as this owner heard it for this commit.
|
||||
Verdict *Verdict `json:"verdict,omitempty"`
|
||||
// Merge is whether the branch's protection lets the commit merge; Mergeable nil when it is not known.
|
||||
Merge ForgeMerge `json:"merge"`
|
||||
// Forge is why the forge could not be read, when it could not.
|
||||
Forge string `json:"forge,omitempty"`
|
||||
Says string `json:"says"`
|
||||
}
|
||||
|
||||
// ChecksOf is the delivery of the commit, in brief: `show` has it whole.
|
||||
type ChecksOf struct {
|
||||
ID string `json:"id"`
|
||||
State State `json:"state"`
|
||||
Since string `json:"since"`
|
||||
Waits string `json:"waits,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
}
|
||||
|
||||
// Checks is the `checks` verb: a repository and a pull request's number, or a repository and a commit.
|
||||
func (h *Holder) Checks(repository string, number int, commit string) (*ChecksAnswer, error) {
|
||||
repository = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(repository), ".git"))
|
||||
owner, repo, ok := strings.Cut(repository, "/")
|
||||
if !ok || owner == "" || repo == "" || strings.Contains(repo, "/") {
|
||||
return nil, fmt.Errorf("%q is not owner/repository", repository)
|
||||
}
|
||||
commit = strings.ToLower(strings.TrimSpace(commit))
|
||||
if number < 0 {
|
||||
return nil, fmt.Errorf("%d is not a pull request's number", number)
|
||||
}
|
||||
if number == 0 && commit == "" {
|
||||
return nil, errors.New("name a pull request's number or a commit")
|
||||
}
|
||||
if commit != "" && (len(commit) < 7 || strings.Trim(commit, "0123456789abcdef") != "") {
|
||||
return nil, fmt.Errorf("%q is not a commit: seven or more hexadecimal characters", commit)
|
||||
}
|
||||
|
||||
// What this owner knows first, for the base whose protection is read; the forge then says the head.
|
||||
base := ""
|
||||
if d := h.deliveryOf(repository, number, commit); d != nil {
|
||||
base = d.Base
|
||||
}
|
||||
a := &ChecksAnswer{Repository: repository, Commit: commit}
|
||||
fc, err := h.Forge.Statuses(owner, repo, number, commit, base)
|
||||
if err != nil {
|
||||
a.Forge = err.Error()
|
||||
} else {
|
||||
a.Commit, a.Pull = fc.Commit, fc.Pull
|
||||
}
|
||||
|
||||
d := h.deliveryOf(repository, number, a.Commit)
|
||||
if a.Commit == "" && d != nil {
|
||||
a.Commit = d.Commit // the forge away: the newest head this owner holds for the pull request
|
||||
}
|
||||
if d != nil {
|
||||
a.Delivery = &ChecksOf{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), Waits: d.waits,
|
||||
Group: d.Group}
|
||||
if d.Check != nil && sameCommit(d.Commit, a.Commit) {
|
||||
a.Verdict = d.Check
|
||||
}
|
||||
}
|
||||
|
||||
if fc != nil {
|
||||
for _, s := range fc.Statuses {
|
||||
a.Statuses = append(a.Statuses, CheckStatus{Name: s.Context, State: s.State, Description: s.Description,
|
||||
SetAt: firstOf(s.UpdatedAt, s.CreatedAt), SetBy: s.Creator, Link: s.TargetURL, Source: "the forge"})
|
||||
}
|
||||
a.Merge = fc.Merge
|
||||
} else {
|
||||
// The forge away: the statuses the verdict set, as the forge holder sets them from it, said as such.
|
||||
if v := a.Verdict; v != nil {
|
||||
a.Statuses = append(a.Statuses, CheckStatus{Name: ctxGate, State: forgeStateOf(v.Gate), Source: "mesh-delivery's verdict; the forge could not be read"})
|
||||
if v.Repo != "" {
|
||||
a.Statuses = append(a.Statuses, CheckStatus{Name: ctxRepo, State: forgeStateOf(v.Repo), Source: "mesh-delivery's verdict; the forge could not be read"})
|
||||
}
|
||||
}
|
||||
a.Merge = ForgeMerge{Branch: firstOf(base, "main"), Says: "not known: the forge could not be read, and only it holds the protection"}
|
||||
}
|
||||
for i := range a.Statuses {
|
||||
h.addWhatIsKnown(&a.Statuses[i], a.Verdict, d)
|
||||
}
|
||||
sort.SliceStable(a.Statuses, func(i, j int) bool { return a.Statuses[i].Name < a.Statuses[j].Name })
|
||||
a.Says = a.says()
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// checksDelivery is the part of a delivery `checks` reads, copied under the lock.
|
||||
type checksDelivery struct {
|
||||
Delivery
|
||||
waits string
|
||||
groupSaid string
|
||||
}
|
||||
|
||||
// deliveryOf is the delivery of a commit — its head, or the commit it merged as — else, for a pull request,
|
||||
// its newest head that was not superseded, else its newest.
|
||||
func (h *Holder) deliveryOf(repository string, number int, commit string) *checksDelivery {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
var found *Delivery
|
||||
if commit != "" {
|
||||
for _, d := range h.deliveries {
|
||||
if strings.EqualFold(d.Repository, repository) && (sameCommit(d.Commit, commit) || sameCommit(d.MergedAs, commit)) {
|
||||
found = d
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if number > 0 {
|
||||
for _, d := range h.deliveries {
|
||||
if !strings.EqualFold(d.Repository, repository) || d.Number != number {
|
||||
continue
|
||||
}
|
||||
if found == nil || newerHead(d, found) {
|
||||
found = d
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return nil
|
||||
}
|
||||
c := &checksDelivery{Delivery: *found, waits: h.waitsFor(found)}
|
||||
if found.Check != nil {
|
||||
v := *found.Check
|
||||
c.Check = &v
|
||||
}
|
||||
if g := h.groups[found.Group]; g != nil {
|
||||
state, why := GroupState(g, h.membersOf(g))
|
||||
c.groupSaid = state + ": " + why
|
||||
if g.Check != nil && g.Check.Verdict != "" {
|
||||
c.groupSaid += "; composed check " + g.Check.Verdict + " — " + g.Check.Summary
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// newerHead is whether d is a pull request's head to read before e: one not superseded first, then the newest.
|
||||
func newerHead(d, e *Delivery) bool {
|
||||
if (d.State == Superseded) != (e.State == Superseded) {
|
||||
return e.State == Superseded
|
||||
}
|
||||
return d.Created.After(e.Created)
|
||||
}
|
||||
|
||||
// addWhatIsKnown joins this owner's verdict to the status it set, and the delivery to its own statuses.
|
||||
func (h *Holder) addWhatIsKnown(s *CheckStatus, v *Verdict, d *checksDelivery) {
|
||||
switch s.Name {
|
||||
case ctxGate, ctxRepo:
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
s.Build, s.CheckedOn = v.ID, v.On
|
||||
if !v.At.IsZero() {
|
||||
s.CheckedAt = v.At.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if s.Name == ctxGate {
|
||||
s.Verdict = strings.ToUpper(firstOf(v.Gate, "error")) + " — " + v.Summary
|
||||
if len(v.Modules) > 0 {
|
||||
s.Verdict += " (modules: " + strings.Join(v.Modules, ", ")
|
||||
if len(v.Dependents) > 0 {
|
||||
s.Verdict += "; built after them: " + strings.Join(v.Dependents, ", ")
|
||||
}
|
||||
s.Verdict += ")"
|
||||
}
|
||||
} else if v.Repo != "" {
|
||||
s.Verdict = strings.ToUpper(v.Repo) + " — " + v.RepoSaid
|
||||
}
|
||||
case ctxDelivery:
|
||||
if d != nil {
|
||||
s.Verdict = string(d.State)
|
||||
if d.waits != "" {
|
||||
s.Verdict += ": waits for " + d.waits
|
||||
}
|
||||
}
|
||||
case ctxGroup:
|
||||
if d != nil && d.groupSaid != "" {
|
||||
s.Verdict = d.groupSaid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// forgeStateOf is the forge state the forge's holder sets for a verdict (the gitea module's pulls.ts): a
|
||||
// warning is a pass with a note, an error never a success.
|
||||
func forgeStateOf(verdict string) string {
|
||||
switch verdict {
|
||||
case "pass", "warning":
|
||||
return "success"
|
||||
case "fail":
|
||||
return "failure"
|
||||
}
|
||||
return "error"
|
||||
}
|
||||
|
||||
func (a *ChecksAnswer) says() string {
|
||||
what := a.Repository + "@" + shortOf(a.Commit, 8)
|
||||
if a.Pull != nil {
|
||||
what = fmt.Sprintf("%s#%d at %s", a.Repository, a.Pull.Number, shortOf(a.Commit, 8))
|
||||
}
|
||||
var parts []string
|
||||
for _, s := range a.Statuses {
|
||||
parts = append(parts, s.Name+" "+s.State)
|
||||
}
|
||||
statuses := "no status"
|
||||
if len(parts) > 0 {
|
||||
statuses = strings.Join(parts, ", ")
|
||||
}
|
||||
merge := a.Merge.Says
|
||||
switch {
|
||||
case a.Merge.Mergeable == nil:
|
||||
merge = "mergeable: not known — " + merge
|
||||
case *a.Merge.Mergeable:
|
||||
merge = "mergeable — " + merge
|
||||
default:
|
||||
merge = "NOT mergeable — " + merge
|
||||
}
|
||||
return what + ": " + statuses + "; " + merge
|
||||
}
|
||||
|
||||
func firstOf(s ...string) string {
|
||||
for _, x := range s {
|
||||
if x != "" {
|
||||
return x
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The `checks` verb (novox/hq ADR 0239): what the mesh's checks said of a pull request's head or a commit —
|
||||
// the forge's statuses, the verdict whole as the controller said it, and whether the base's protection lets
|
||||
// it merge.
|
||||
|
||||
// checkedAs is the controller's `checked` as it comes off the bus, both layers said.
|
||||
func checkedAs(t *testing.T, repo string, number int, sha, gate, gateSaid, repoVerdict, repoSaid string) CheckedEvent {
|
||||
t.Helper()
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
raw := map[string]any{"owner": owner, "repo": name, "number": number, "commit": sha, "verdict": gate,
|
||||
"summary": gateSaid, "id": "build-" + sha[:8], "on": "the-build-node", "report": "== compose\n" + gateSaid,
|
||||
"gate": map[string]any{"verdict": gate, "summary": gateSaid, "modules": []string{"app"}, "dependents": []string{"web"}},
|
||||
"repo-check": map[string]any{"verdict": repoVerdict, "summary": repoSaid}}
|
||||
var c CheckedEvent
|
||||
if err := json.Unmarshal(mustJSON(raw), &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// statusesAsTheForgeSets puts the two statuses the forge's holder sets from a verdict: clipped, as it keeps them.
|
||||
func statusesAsTheForgeSets(w *world, repo, sha string, c CheckedEvent) {
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
_ = w.forge.Status(owner, name, sha, ctxGate, forgeStateOf(c.Gate.Verdict), clip(c.Gate.Verdict+": "+c.Gate.Summary, 40), "https://forge.invalid/pr")
|
||||
_ = w.forge.Status(owner, name, sha, ctxRepo, forgeStateOf(c.RepoCheck.Verdict), clip(c.RepoCheck.Verdict+": "+c.RepoCheck.Summary, 40), "https://forge.invalid/pr")
|
||||
}
|
||||
|
||||
func statusNamed(a *ChecksAnswer, name string) *CheckStatus {
|
||||
for i := range a.Statuses {
|
||||
if a.Statuses[i].Name == name {
|
||||
return &a.Statuses[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestChecksOfAPullRequestSayEveryStatusTheVerdictWholeAndWhetherItMerges(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.required = []string{ctxGate, ctxRepo}
|
||||
w.forge.pulls["novox/app#7"] = ForgePull{Number: 7, Title: "a change", State: "open", Base: "main", Head: "feat/x", HeadSHA: head}
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
said := "0 of 4 compose: anchor refuses app's manifest — a provision nothing provides, and three more machines alike"
|
||||
c := checkedAs(t, "novox/app", 7, head, "fail", said, "pass", "its merge-check.sh passed")
|
||||
w.h.Checked(c)
|
||||
w.settleAll()
|
||||
statusesAsTheForgeSets(w, "novox/app", head, c)
|
||||
|
||||
a, err := w.h.Checks("novox/app", 7, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Commit != head || a.Pull == nil || a.Pull.Number != 7 || a.Forge != "" {
|
||||
t.Fatalf("the head read is %q, pull %+v, forge %q", a.Commit, a.Pull, a.Forge)
|
||||
}
|
||||
gate := statusNamed(a, ctxGate)
|
||||
if gate == nil || gate.State != "failure" || gate.Source != "the forge" || gate.SetBy != "mesh-admin" {
|
||||
t.Fatalf("the gate's status is %+v", gate)
|
||||
}
|
||||
// The forge clipped it; the verdict says it whole, with the machine, the build and the modules.
|
||||
if strings.Contains(gate.Description, "three more machines") {
|
||||
t.Fatalf("the fake forge did not clip: %q", gate.Description)
|
||||
}
|
||||
if !strings.Contains(gate.Verdict, "FAIL — 0 of 4 compose") || !strings.Contains(gate.Verdict, "three more machines") ||
|
||||
!strings.Contains(gate.Verdict, "modules: app; built after them: web") {
|
||||
t.Fatalf("the gate's verdict is %q", gate.Verdict)
|
||||
}
|
||||
if gate.Build != "build-"+head[:8] || gate.CheckedOn != "the-build-node" || gate.CheckedAt == "" {
|
||||
t.Fatalf("the gate was checked as %q on %q at %q", gate.Build, gate.CheckedOn, gate.CheckedAt)
|
||||
}
|
||||
repo := statusNamed(a, ctxRepo)
|
||||
if repo == nil || repo.State != "success" || repo.Verdict != "PASS — its merge-check.sh passed" || repo.CheckedOn != "the-build-node" {
|
||||
t.Fatalf("the repository check's status is %+v", repo)
|
||||
}
|
||||
if d := statusNamed(a, ctxDelivery); d == nil || !strings.HasPrefix(d.Verdict, "rejected: waits for a new head") {
|
||||
t.Fatalf("the delivery's status is %+v", d)
|
||||
}
|
||||
if a.Verdict == nil || !strings.Contains(a.Verdict.Report, "== compose") {
|
||||
t.Fatalf("the verdict whole is %+v", a.Verdict)
|
||||
}
|
||||
if a.Delivery == nil || a.Delivery.ID != IDOf("novox/app", head) || a.Delivery.State != Rejected {
|
||||
t.Fatalf("the delivery is %+v", a.Delivery)
|
||||
}
|
||||
if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 1 || a.Merge.Blocking[0].Context != ctxGate {
|
||||
t.Fatalf("the merge is %+v", a.Merge)
|
||||
}
|
||||
if !strings.Contains(a.Says, "novox/app#7 at aaaaaaaa") || !strings.Contains(a.Says, "NOT mergeable") {
|
||||
t.Fatalf("it says %q", a.Says)
|
||||
}
|
||||
// The forge was asked for the pull request, with the base this owner knew.
|
||||
if len(w.forge.asked) != 1 || w.forge.asked[0] != "novox/app#7@ base=main" {
|
||||
t.Fatalf("the forge was asked %v", w.forge.asked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewHeadIsReadWithItsOwnVerdictNeverTheOlderOnes(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.required = []string{ctxGate, ctxRepo}
|
||||
newer := "bbbbbbbbbbbb2222"
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x"))
|
||||
w.h.Checked(checkedAs(t, "novox/app", 7, head, "fail", "0 of 4 compose", "pass", "passed"))
|
||||
w.h.PullUpdated(pr("novox/app", 7, newer, "feat/x"))
|
||||
w.forge.pulls["novox/app#7"] = ForgePull{Number: 7, State: "open", Base: "main", HeadSHA: newer}
|
||||
|
||||
a, err := w.h.Checks("novox/app", 7, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Commit != newer || a.Delivery == nil || a.Delivery.ID != IDOf("novox/app", newer) || a.Delivery.State != Proposed {
|
||||
t.Fatalf("the new head reads as %q, %+v", a.Commit, a.Delivery)
|
||||
}
|
||||
if a.Verdict != nil {
|
||||
t.Fatalf("the older head's verdict was given for the newer: %+v", a.Verdict)
|
||||
}
|
||||
if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 2 || a.Merge.Blocking[0].State != "missing" {
|
||||
t.Fatalf("a head not checked yet merges as %+v", a.Merge)
|
||||
}
|
||||
|
||||
// Passed, it merges.
|
||||
c := checkedAs(t, "novox/app", 7, newer, "pass", "every machine composes", "pass", "its merge-check.sh passed")
|
||||
w.h.Checked(c)
|
||||
statusesAsTheForgeSets(w, "novox/app", newer, c)
|
||||
a, _ = w.h.Checks("novox/app", 7, "")
|
||||
if a.Merge.Mergeable == nil || !*a.Merge.Mergeable || !strings.Contains(a.Says, "mergeable") || strings.Contains(a.Says, "NOT") {
|
||||
t.Fatalf("a head that passed: %+v, says %q", a.Merge, a.Says)
|
||||
}
|
||||
if g := statusNamed(a, ctxGate); g == nil || g.Verdict != "PASS — every machine composes (modules: app; built after them: web)" {
|
||||
t.Fatalf("its gate: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommitIsReadByTheStartOfItsShaAndAMergeByItsDelivery(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x"))
|
||||
c := checkedAs(t, "novox/app", 7, head, "pass", "every machine composes", "pass", "passed")
|
||||
w.h.Checked(c)
|
||||
statusesAsTheForgeSets(w, "novox/app", head, c)
|
||||
|
||||
a, err := w.h.Checks("Novox/App.git", 0, head[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Commit != head || a.Verdict == nil || a.Delivery == nil || a.Pull != nil {
|
||||
t.Fatalf("by the start of its sha: %q, %+v, %+v", a.Commit, a.Verdict, a.Delivery)
|
||||
}
|
||||
// No protection: nothing is required, so it merges.
|
||||
if a.Merge.Mergeable == nil || !*a.Merge.Mergeable {
|
||||
t.Fatalf("with no protection: %+v", a.Merge)
|
||||
}
|
||||
|
||||
mergedAs := "dddddddddddd9999"
|
||||
w.h.PullMerged(merged("novox/app", 7, head, mergedAs))
|
||||
_ = w.forge.Status("novox", "app", mergedAs, ctxDelivery, "pending", "published", "")
|
||||
a, err = w.h.Checks("novox/app", 0, mergedAs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Delivery == nil || a.Delivery.ID != IDOf("novox/app", head) {
|
||||
t.Fatalf("the merge commit's delivery is %+v", a.Delivery)
|
||||
}
|
||||
if a.Verdict != nil {
|
||||
t.Fatalf("the head's verdict was given for the merge commit: %+v", a.Verdict)
|
||||
}
|
||||
if s := statusNamed(a, ctxDelivery); s == nil || s.State != "pending" || s.Verdict == "" {
|
||||
t.Fatalf("the merge's delivery status: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTheForgeAwayTheVerdictIsSaidAndWhetherItMergesIsNotKnown(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x"))
|
||||
w.h.Checked(checkedAs(t, "novox/app", 7, head, "warning", "every machine composes, with a note", "error", "the toolchain was not there"))
|
||||
w.forge.down = true
|
||||
|
||||
a, err := w.h.Checks("novox/app", 7, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Forge == "" || a.Commit != head || a.Verdict == nil {
|
||||
t.Fatalf("with the forge away: forge %q, commit %q, verdict %+v", a.Forge, a.Commit, a.Verdict)
|
||||
}
|
||||
gate, repo := statusNamed(a, ctxGate), statusNamed(a, ctxRepo)
|
||||
if gate == nil || gate.State != "success" || !strings.Contains(gate.Source, "could not be read") ||
|
||||
repo == nil || repo.State != "error" || repo.Verdict != "ERROR — the toolchain was not there" {
|
||||
t.Fatalf("the statuses from the verdict: %+v %+v", gate, repo)
|
||||
}
|
||||
if a.Merge.Mergeable != nil || !strings.Contains(a.Says, "mergeable: not known") {
|
||||
t.Fatalf("whether it merges, with the forge away: %+v, %q", a.Merge, a.Says)
|
||||
}
|
||||
|
||||
// The protection unreadable: the statuses are said, and whether it merges is not known.
|
||||
w.forge.down, w.forge.protectionDown = false, true
|
||||
w.forge.pulls["novox/app#7"] = ForgePull{Number: 7, Base: "main", HeadSHA: head}
|
||||
a, _ = w.h.Checks("novox/app", 7, "")
|
||||
if a.Merge.Mergeable != nil || a.Forge != "" {
|
||||
t.Fatalf("with the protection unreadable: %+v", a.Merge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksRefuseWhatNamesNoCommit(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
for _, c := range []struct {
|
||||
repo string
|
||||
number int
|
||||
commit string
|
||||
says string
|
||||
}{
|
||||
{"novox/app", 0, "", "number or a commit"},
|
||||
{"app", 7, "", "owner/repository"},
|
||||
{"novox/app/x", 7, "", "owner/repository"},
|
||||
{"novox/app", 0, "abc", "not a commit"},
|
||||
{"novox/app", 0, "zzzzzzzzzz", "not a commit"},
|
||||
{"novox/app", -1, "", "not a pull request's number"},
|
||||
} {
|
||||
if _, err := w.h.Checks(c.repo, c.number, c.commit); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s #%d %q: %v", c.repo, c.number, c.commit, err)
|
||||
}
|
||||
}
|
||||
if len(w.forge.asked) != 0 {
|
||||
t.Fatalf("the forge was asked for what names nothing: %v", w.forge.asked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANumberIsTakenAsANumberOrAsText(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in any
|
||||
want int
|
||||
ok bool
|
||||
}{{float64(168), 168, true}, {"168", 168, true}, {"#168", 168, true}, {nil, 0, true}, {"", 0, true},
|
||||
{"x", 0, false}, {1.5, 0, false}} {
|
||||
got, err := intArg(map[string]any{"number": c.in}, "number")
|
||||
if (err == nil) != c.ok || got != c.want {
|
||||
t.Errorf("%v → %d %v", c.in, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,17 +215,10 @@ type fakeForge struct {
|
||||
views map[string]string
|
||||
statuses map[string]string
|
||||
down bool
|
||||
// What Statuses reads: the pull requests by owner/repo#number, the base's required statuses, whether the
|
||||
// protection cannot be read, and what it was asked.
|
||||
pulls map[string]ForgePull
|
||||
required []string
|
||||
protectionDown bool
|
||||
asked []string
|
||||
}
|
||||
|
||||
func newFakeForge() *fakeForge {
|
||||
return &fakeForge{notes: map[string][]string{}, views: map[string]string{}, statuses: map[string]string{},
|
||||
pulls: map[string]ForgePull{}}
|
||||
return &fakeForge{notes: map[string][]string{}, views: map[string]string{}, statuses: map[string]string{}}
|
||||
}
|
||||
|
||||
func (f *fakeForge) Note(owner, repo, commit, line string) error {
|
||||
@@ -262,73 +255,6 @@ func (f *fakeForge) Status(owner, repo, commit, context, state, description, tar
|
||||
return nil
|
||||
}
|
||||
|
||||
// Statuses answers what the fake was told: a pull request's head from pulls, the statuses set on the commit
|
||||
// (those Status set too, as the forge would keep them), and the base's required statuses from required.
|
||||
func (f *fakeForge) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.down {
|
||||
return nil, errors.New("the forge is away")
|
||||
}
|
||||
f.asked = append(f.asked, fmt.Sprintf("%s/%s#%d@%s base=%s", owner, repo, number, commit, base))
|
||||
out := &ForgeChecks{}
|
||||
if number > 0 {
|
||||
p, ok := f.pulls[fmt.Sprintf("%s/%s#%d", owner, repo, number)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no pull request %d", number)
|
||||
}
|
||||
out.Pull = &p
|
||||
if commit == "" {
|
||||
commit = p.HeadSHA
|
||||
}
|
||||
if base == "" {
|
||||
base = p.Base
|
||||
}
|
||||
}
|
||||
for key, said := range f.statuses {
|
||||
at, context, _ := strings.Cut(key, " ")
|
||||
if !strings.HasPrefix(at, owner+"/"+repo+"@") || !sameCommit(strings.TrimPrefix(at, owner+"/"+repo+"@"), commit) {
|
||||
continue
|
||||
}
|
||||
if len(strings.TrimPrefix(at, owner+"/"+repo+"@")) > len(commit) {
|
||||
commit = strings.TrimPrefix(at, owner+"/"+repo+"@")
|
||||
}
|
||||
state, rest, _ := strings.Cut(said, " ")
|
||||
description, link, _ := strings.Cut(rest, " → ")
|
||||
out.Statuses = append(out.Statuses, ForgeStatus{Context: context, State: state, Description: description,
|
||||
TargetURL: link, UpdatedAt: "2026-10-06T12:00:00Z", Creator: "mesh-admin"})
|
||||
}
|
||||
sort.Slice(out.Statuses, func(i, j int) bool { return out.Statuses[i].Context < out.Statuses[j].Context })
|
||||
out.Commit = commit
|
||||
if base == "" {
|
||||
base = "main"
|
||||
}
|
||||
out.Merge = ForgeMerge{Branch: base, Protected: f.required != nil, Required: f.required}
|
||||
if f.protectionDown {
|
||||
out.Merge.Says = "the protection of " + base + " could not be read"
|
||||
return out, nil
|
||||
}
|
||||
ok := true
|
||||
for _, r := range f.required {
|
||||
state := "missing"
|
||||
for _, st := range out.Statuses {
|
||||
if st.Context == r {
|
||||
state = st.State
|
||||
}
|
||||
}
|
||||
if state != "success" {
|
||||
ok = false
|
||||
out.Merge.Blocking = append(out.Merge.Blocking, struct {
|
||||
Context string `json:"context"`
|
||||
State string `json:"state"`
|
||||
}{r, state})
|
||||
}
|
||||
}
|
||||
out.Merge.Mergeable = &ok
|
||||
out.Merge.Says = fmt.Sprintf("%s requires %v", base, f.required)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeForge) notesOn(commit string) []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -456,13 +456,9 @@ type CheckedEvent struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
ID string `json:"id"`
|
||||
On string `json:"on,omitempty"`
|
||||
Report string `json:"report,omitempty"`
|
||||
Gate *struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
Modules []string `json:"modules,omitempty"`
|
||||
Dependents []string `json:"dependents,omitempty"`
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
} `json:"gate,omitempty"`
|
||||
RepoCheck *struct {
|
||||
Verdict string `json:"verdict"`
|
||||
@@ -513,10 +509,9 @@ func (h *Holder) Checked(c CheckedEvent) {
|
||||
h.Logf("[mesh-delivery] %s is %s: a verdict for it now is history (%s)", d.ID, d.State, c.Verdict)
|
||||
return
|
||||
}
|
||||
v := &Verdict{ID: c.ID, On: c.On, At: now, Gate: c.Verdict, Summary: c.Summary, Report: c.Report}
|
||||
v := &Verdict{ID: c.ID, At: now, Gate: c.Verdict, Summary: c.Summary}
|
||||
if c.Gate != nil {
|
||||
v.Gate, v.Summary = c.Gate.Verdict, c.Gate.Summary
|
||||
v.Modules, v.Dependents = c.Gate.Modules, c.Gate.Dependents
|
||||
}
|
||||
if c.RepoCheck != nil {
|
||||
v.Repo, v.RepoSaid = c.RepoCheck.Verdict, c.RepoCheck.Summary
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -138,29 +137,6 @@ func str(description string) map[string]any {
|
||||
|
||||
func strArg(a map[string]any, k string) string { s, _ := a[k].(string); return strings.TrimSpace(s) }
|
||||
|
||||
// intArg is a whole number given as a number or as text; absent is zero.
|
||||
func intArg(a map[string]any, k string) (int, error) {
|
||||
switch v := a[k].(type) {
|
||||
case nil:
|
||||
return 0, nil
|
||||
case float64:
|
||||
if v == float64(int(v)) {
|
||||
return int(v), nil
|
||||
}
|
||||
case int:
|
||||
return v, nil
|
||||
case string:
|
||||
v = strings.TrimPrefix(strings.TrimSpace(v), "#")
|
||||
if v == "" {
|
||||
return 0, nil
|
||||
}
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("%q is not a whole number: %v", k, a[k])
|
||||
}
|
||||
|
||||
func boolArg(a map[string]any, k string) bool {
|
||||
switch v := a[k].(type) {
|
||||
case bool:
|
||||
@@ -219,26 +195,6 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
}
|
||||
return h.WhatIf(strArg(a, "repository"), strArg(a, "base"), strings.Split(strArg(a, "paths"), ","))
|
||||
}},
|
||||
{Name: seat + "checks",
|
||||
Description: "What the mesh's checks said of a pull request's head or of one commit: each of the commit's " +
|
||||
"statuses (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, description and when it " +
|
||||
"was set; the merge check's full verdict as the controller said it — each layer's summary, the machine " +
|
||||
"that ran it, when, its build id and its report; and whether the branch's protection would let it " +
|
||||
"merge, every required status being success.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"repository": str("owner/repository"),
|
||||
"number": str("a pull request's number: its head is read"),
|
||||
"commit": str("a commit's sha, or the start of one, instead of a pull request")},
|
||||
"required": []string{"repository"}},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
if err := need(a, "repository"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
number, err := intArg(a, "number")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.Checks(strArg(a, "repository"), number, strArg(a, "commit"))
|
||||
}},
|
||||
{Name: seat + "table",
|
||||
Description: "The state table every delivery runs by: each transition with its guard, each state's bound " +
|
||||
"and what healer H2 may do once it has passed; and the machine steps' table.",
|
||||
|
||||
@@ -59,13 +59,12 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
if !reflect.DeepEqual(m.State, []string{"deliveries", "groups"}) {
|
||||
t.Fatalf("state %v", m.State)
|
||||
}
|
||||
// It calls exactly what its ports ask: the controller's six verbs and the forge's four tools.
|
||||
// It calls exactly what its ports ask: the controller's six verbs and the forge's three tools.
|
||||
want := []string{}
|
||||
for _, v := range []string{"delivery-plan", "delivery-order", "delivery-check", "deliver", "delivery-stop", "delivery-walks"} {
|
||||
want = append(want, "seat:"+ControllerSeat+"."+v)
|
||||
}
|
||||
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
|
||||
"gitea.gitea_commit_statuses")
|
||||
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status")
|
||||
if !reflect.DeepEqual(m.Invokes, want) {
|
||||
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
|
||||
}
|
||||
|
||||
@@ -80,20 +80,14 @@ type Transition struct {
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// Verdict is a check's verdict: the gate's and the repository's own. ID is the build seat's ask that ran
|
||||
// it, On the machine it ran on, At when this owner heard it; Report is the check's own account, as bounded
|
||||
// as the controller says it. All of it is what `checks` answers, whole, beside the forge's short statuses.
|
||||
// Verdict is a check's verdict: the gate's and the repository's own.
|
||||
type Verdict struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
On string `json:"on,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Gate string `json:"gate"`
|
||||
Summary string `json:"summary"`
|
||||
Modules []string `json:"modules,omitempty"`
|
||||
Dependents []string `json:"dependents,omitempty"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
RepoSaid string `json:"repo_summary,omitempty"`
|
||||
Report string `json:"report,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Gate string `json:"gate"`
|
||||
Summary string `json:"summary"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
RepoSaid string `json:"repo_summary,omitempty"`
|
||||
}
|
||||
|
||||
// CheckAsk is a head's own check, asked of the controller by this owner (or re-asked by healer H2).
|
||||
|
||||
@@ -71,56 +71,6 @@ type Forge interface {
|
||||
View(owner, repo string, number int, body string) error
|
||||
// Status sets one status of a commit, linking to the view.
|
||||
Status(owner, repo, commit, context, state, description, target string) error
|
||||
// Statuses reads a commit's statuses — a pull request's head's, given its number — and whether the branch
|
||||
// it merges into would let it merge. Reads only.
|
||||
Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error)
|
||||
}
|
||||
|
||||
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
|
||||
type ForgeChecks struct {
|
||||
Commit string `json:"commit"`
|
||||
Pull *ForgePull `json:"pull,omitempty"`
|
||||
Combined string `json:"combined,omitempty"`
|
||||
Statuses []ForgeStatus `json:"statuses"`
|
||||
Merge ForgeMerge `json:"merge"`
|
||||
}
|
||||
|
||||
// ForgePull is a pull request as the forge says it.
|
||||
type ForgePull struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Merged bool `json:"merged,omitempty"`
|
||||
Base string `json:"base,omitempty"`
|
||||
Head string `json:"head,omitempty"`
|
||||
HeadSHA string `json:"head_sha,omitempty"`
|
||||
MergeCommit string `json:"merge_commit_sha,omitempty"`
|
||||
HTMLURL string `json:"html_url,omitempty"`
|
||||
}
|
||||
|
||||
// ForgeStatus is one status of a commit, the newest of its context.
|
||||
type ForgeStatus struct {
|
||||
Context string `json:"context"`
|
||||
State string `json:"state"`
|
||||
Description string `json:"description"`
|
||||
TargetURL string `json:"target_url,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Creator string `json:"creator,omitempty"`
|
||||
}
|
||||
|
||||
// ForgeMerge is whether the branch's protection lets the commit merge: every required status a success.
|
||||
// Mergeable is nil when the protection could not be read.
|
||||
type ForgeMerge struct {
|
||||
Branch string `json:"branch"`
|
||||
Protected bool `json:"protected"`
|
||||
Required []string `json:"required"`
|
||||
Blocking []struct {
|
||||
Context string `json:"context"`
|
||||
State string `json:"state"`
|
||||
} `json:"blocking"`
|
||||
Mergeable *bool `json:"mergeable"`
|
||||
Says string `json:"says"`
|
||||
}
|
||||
|
||||
// Store keeps deliveries and groups: one key each, in the module's own state (ADR 0201).
|
||||
@@ -309,32 +259,6 @@ func (f toolForge) Status(owner, repo, commit, context, state, description, targ
|
||||
"context": context, "state": state, "description": description, "target_url": target})
|
||||
}
|
||||
|
||||
func (f toolForge) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) {
|
||||
args := map[string]any{"owner": owner, "repo": repo}
|
||||
if number > 0 {
|
||||
args["number"] = number
|
||||
}
|
||||
if commit != "" {
|
||||
args["sha"] = commit
|
||||
}
|
||||
if base != "" {
|
||||
args["base"] = base
|
||||
}
|
||||
raw, err := f.ask("gitea.gitea_commit_statuses", args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer, output, ok := answerOf(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the forge refused gitea_commit_statuses: %s", lastLine(output))
|
||||
}
|
||||
var c ForgeChecks
|
||||
if err := json.Unmarshal(answer, &c); err != nil || c.Commit == "" {
|
||||
return nil, fmt.Errorf("the forge's gitea_commit_statuses is not readable: %s", clip(string(answer), 200))
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// kvStore is the module's own state: `deliveries` and `groups`, one key each.
|
||||
type kvStore struct{}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// The seat's verbs (novox/hq ADR 0239, the mesh-delivery seat in the controller's set): six that read, and
|
||||
// The seat's verbs (novox/hq ADR 0239, the mesh-delivery seat in the controller's set): five that read, and
|
||||
// the acts — a person's recheck, release and stop, and healer H2's close, each only by a transition the table
|
||||
// holds.
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"show",
|
||||
"groups",
|
||||
"what-if",
|
||||
"checks",
|
||||
"table",
|
||||
"stalled",
|
||||
"recheck",
|
||||
@@ -41,8 +40,7 @@
|
||||
"seat:mesh-controller.delivery-walks",
|
||||
"gitea.gitea_note_append",
|
||||
"gitea.gitea_delivery_view",
|
||||
"gitea.gitea_commit_status",
|
||||
"gitea.gitea_commit_statuses"
|
||||
"gitea.gitea_commit_status"
|
||||
],
|
||||
"state": [
|
||||
"deliveries",
|
||||
|
||||
@@ -186,7 +186,7 @@ export class FirewallClient {
|
||||
}
|
||||
return { where, did };
|
||||
}
|
||||
throw new Error(`${JSON.stringify(where)} is not a rule set as the node-engine reports one: ` +
|
||||
throw new Error(`${JSON.stringify(where)} is not a rule set as the host reports one: ` +
|
||||
"`chain X (iptables-legacy)` or `table <family> <name>, chain X`");
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ test("what is not the operator's to remove is refused by name", async () => {
|
||||
await assert.rejects(c.remove("chain DOCKER (iptables-legacy)"), /container runtime's own/);
|
||||
await assert.rejects(c.remove("chain FORWARD (iptables-legacy)"), /built in/);
|
||||
await assert.rejects(c.remove("chain ufw6-docker-logging-deny (ip6tables-legacy)"), /found firewall, which is in force/);
|
||||
await assert.rejects(c.remove("something else"), /not a rule set as the node-engine reports one/);
|
||||
await assert.rejects(c.remove("something else"), /not a rule set as the host reports one/);
|
||||
// Retired, a front end's leftover is nobody's and goes.
|
||||
const retired = await new FirewallClient(fake(false).run, undefined, () => true).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)");
|
||||
assert.ok(retired.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny"));
|
||||
|
||||
@@ -594,7 +594,7 @@ func (m *Machine) Check() (CheckAnswer, error) {
|
||||
return a, err
|
||||
}
|
||||
if v == "" {
|
||||
add("Slack ("+packageFor+") is not installed", "install it from the AUR: the module does not install it, because the node-engine installs packages from the official repositories only")
|
||||
add("Slack ("+packageFor+") is not installed", "install it from the AUR: the module does not install it, because the host installs packages from the official repositories only")
|
||||
}
|
||||
mine := 0
|
||||
for _, st := range m.starts() {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# systemd-resolved
|
||||
|
||||
A machine's own resolver (novox/hq ADR 0247): systemd-resolved, holding the node seat `node-resolver` and
|
||||
providing `split-dns` at the machine's reach. It is assigned only where something on the machine requires
|
||||
`split-dns` — today a VPN client whose company domains must go to the VPN's own servers while every other
|
||||
name still goes to the mesh's two resolvers. Every other machine has none, and lists the mesh's resolvers
|
||||
in its resolver file as ADR 0223 says.
|
||||
|
||||
**It knows nothing about any VPN.** It routes a set of domains to a set of servers over one link, lists
|
||||
what is routed, and takes a route away. A module wrapping a VPN client that writes `/etc/resolv.conf`
|
||||
itself carries its own adapter, which calls these verbs. A VPN that tells systemd-resolved its link's DNS
|
||||
itself (NetworkManager's VPNs, WireGuard set up by networkd, Tailscale) needs none.
|
||||
|
||||
## What it writes
|
||||
|
||||
| file | what |
|
||||
|---|---|
|
||||
| `/etc/resolv.conf` | this machine's own private address alone, with ADR 0223's options. The uplink's holder steps back from this file where this module is assigned (the controller's rule, ADR 0247), so it has one writer |
|
||||
| `/etc/node-resolver/resolv.conf` | the same file, kept beside it for the guard to compare with and put back |
|
||||
| `/etc/node-resolver/suffix` | the mesh's own domain, which a route may never take |
|
||||
| `/etc/systemd/resolved.conf.d/50-mesh.conf` | every mesh resolver as the default route for names (`DNS=`, `Domains=~.`), no public fallback, the stub on loopback and on the private address, no cache, no LLMNR or mDNS |
|
||||
|
||||
**On the private address, so containers reach it.** A container copies its machine's resolver file and
|
||||
cannot reach the machine's loopback, so the file names the address the machine has on the private
|
||||
network, where resolved also listens (`DNSStubListenerExtra`). The packet filter admits a machine's own
|
||||
guests and nobody else, so no other machine can ask it. The port is declared `from: machine` and fixed:
|
||||
a fixed port is a claim on the machine, given to one module, and the mesh's own resolver (`dnsmasq`, on
|
||||
the anchor and the home server) claims the same one. The two are not meant to share a machine.
|
||||
|
||||
**No cache.** Nothing on the machine keeps a copy of a mesh name or of a "no such name". Each question is
|
||||
asked again, as it was before this resolver.
|
||||
|
||||
**Its package is systemd's.** resolved ships with systemd, whose package belongs to the `systemd` module
|
||||
holding `node-service-manager` (ADR 0207). This module declares the service, running and enabled,
|
||||
restarted when its drop-in changes, and nothing to install.
|
||||
|
||||
## The guard
|
||||
|
||||
One long-running process, as root: `resolver-tools guard`.
|
||||
|
||||
- **It keeps the resolver file the module's own.** Twice a second it compares `/etc/resolv.conf` with the
|
||||
kept copy. Another program's write is *displaced*: the guard keeps what it wrote in
|
||||
`/run/node-resolver/displaced/`, readable by root alone and gone at the next boot, and names its writer
|
||||
from the file's own header.
|
||||
- **A write a module takes is put back at once.** A module on the machine that reads the write and routes
|
||||
what it needed says it *took* it, and the module's own file stands again within a second.
|
||||
- **A write nobody takes stands for 90 seconds**, then is put back. 90 seconds is longer than the
|
||||
node-engine needs to see the rewrite twice, so ADR 0241's `machine.<m>.systemd-resolved.rewritten` is
|
||||
still raised, naming the writer, for a write nothing on the machine declared to handle.
|
||||
- **Only the mesh's resolvers answer every name.** Every five seconds, a link that a network manager gave
|
||||
servers of its own is told it is not a default route for names. Its own domains stay its own.
|
||||
- **What it says elsewhere** — the `routes` verb's `outside_writes`, from `/run/node-resolver/history.json`
|
||||
— is when, the writer's name and what became of each write. It never includes a server or a domain.
|
||||
|
||||
## Its verbs
|
||||
|
||||
On the mesh, through the node's runtime as the operator account (writes escalate with `sudo -n`):
|
||||
|
||||
| verb | does |
|
||||
|---|---|
|
||||
| `<node>/node-resolver.routes` (r) | the mesh's resolvers, each link given servers of its own with its routed domains and whether it is a default route, and the resolver file's outside writes |
|
||||
| `<node>/node-resolver.route` (a) | `{link, domains, servers}`: these domains, and every name under them, to these servers over this link, and only them. The mesh's own domain and the root are refused |
|
||||
| `<node>/node-resolver.unroute` (a) | `{link}`: that link's domains go to the mesh's resolvers again |
|
||||
|
||||
**On the machine, to its own root processes**, over `/run/node-resolver/verbs.sock` (mode 0600). The path
|
||||
is the seat's, so a caller does not need to know which holder answers. The protocol is one JSON line
|
||||
`{"verb": …, "args": {…}}` and one JSON line back, `{"result": …}` or `{"error": "…"}`. The verbs are the
|
||||
same three, plus:
|
||||
|
||||
- `displaced`: the write standing now, with what it held, or null;
|
||||
- `route` with `takes` (the write's `id`) and `by` (the module's name): route, then take that write, so
|
||||
the module's file is put back.
|
||||
|
||||
A VPN's servers and domains are handed over on this socket, never over the bus. They cross the bus only
|
||||
as the answer to `routes` when the operator asks it, and nothing keeps that answer.
|
||||
|
||||
resolved holds the routes itself, per link, and forgets a link's route when the link goes. Nothing here
|
||||
keeps a table of its own that could disagree with it.
|
||||
@@ -1,432 +0,0 @@
|
||||
// The guard: the module's one long-running process, as root (novox/hq ADR 0247). It keeps the machine's
|
||||
// resolver file the module's own, and serves the seat's verbs on the machine itself.
|
||||
//
|
||||
// **An outside write is kept, then put back.** Another program writing /etc/resolv.conf — a VPN client
|
||||
// does it on every connect — is the module's file displaced. The guard keeps what that program wrote, for
|
||||
// whoever handles it on the machine to read, and puts the module's own file back:
|
||||
//
|
||||
// - at once, when a module on the machine took the write: it read what it needed and routed it (`route`
|
||||
// with `takes`);
|
||||
// - otherwise after Hold, which is longer than the node-engine needs to see the rewrite twice — so a
|
||||
// write nobody declared to handle is still said, as ADR 0241's rewrite, naming its writer, and then
|
||||
// ends within a bound instead of at the next reconcile.
|
||||
//
|
||||
// What was written stays on this machine, in a directory only root reads, and is gone at the next boot.
|
||||
// What the guard says of it anywhere else — the `routes` verb's history — is when, the writer the file's
|
||||
// own header names, and what became of it: never a server or a domain.
|
||||
//
|
||||
// **It knows nothing of any VPN**: a writer is whatever the file's header says, and a taker whichever
|
||||
// module says it took it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Where the guard keeps its things. The socket's path is the seat's protocol on the machine: any holder of
|
||||
// node-resolver serves its verbs there, so a module calling them does not know which holder answers.
|
||||
const (
|
||||
ResolvConf = "/etc/resolv.conf"
|
||||
// KeptPath is the module's own resolver file, rendered by the mesh beside the live one (the fact
|
||||
// `kept`), so the guard compares and puts back exactly what the mesh declared.
|
||||
KeptPath = "/etc/node-resolver/resolv.conf"
|
||||
RunDir = "/run/node-resolver"
|
||||
Socket = RunDir + "/verbs.sock"
|
||||
History = RunDir + "/history.json"
|
||||
Displaced = RunDir + "/displaced"
|
||||
)
|
||||
|
||||
// Timing.
|
||||
const (
|
||||
// Look is how often the guard reads the file.
|
||||
Look = 500 * time.Millisecond
|
||||
// Hold is how long a write nobody took stands: two of the node-engine's looks (30 s each, ADR 0241)
|
||||
// with room, so it is said before it is put back.
|
||||
Hold = 90 * time.Second
|
||||
// Kept is how many displaced writes are kept on the machine, and in the history.
|
||||
Kept = 10
|
||||
// DefaultRouteEvery is how often a link's servers are kept from being a default route.
|
||||
DefaultRouteEvery = 5 * time.Second
|
||||
)
|
||||
|
||||
// Displacement is one outside write of the resolver file, as the guard says it.
|
||||
type Displacement struct {
|
||||
ID string `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
// Writer is who the file's own header names, or empty.
|
||||
Writer string `json:"writer,omitempty"`
|
||||
// TakenBy is the module that took it, and when.
|
||||
TakenBy string `json:"taken_by,omitempty"`
|
||||
TakenAt *time.Time `json:"taken_at,omitempty"`
|
||||
// Ended is when the module's own file stood again, and How.
|
||||
Ended *time.Time `json:"ended,omitempty"`
|
||||
How string `json:"how,omitempty"`
|
||||
|
||||
content string
|
||||
}
|
||||
|
||||
// Guard is the module's file kept, and its verbs served on the machine.
|
||||
type Guard struct {
|
||||
Path, KeptPath, Dir string
|
||||
Hold time.Duration
|
||||
Now func() time.Time
|
||||
Resolver *Resolver
|
||||
// Log says what the guard did, on its own journal: never a server or a domain.
|
||||
Log func(format string, args ...any)
|
||||
|
||||
mu sync.Mutex
|
||||
pending *Displacement
|
||||
history []Displacement
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
// NewGuard is the machine's.
|
||||
func NewGuard() *Guard {
|
||||
return &Guard{Path: ResolvConf, KeptPath: KeptPath, Dir: RunDir, Hold: Hold, Now: time.Now,
|
||||
Resolver: ThisResolver(), Log: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) },
|
||||
wake: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
// signs are the words a writer leaves in its file's comments, and its name. The same list the node-engine
|
||||
// names a writer from (ADR 0241 rule 3): what a file says of itself.
|
||||
var signs = []struct{ word, name string }{
|
||||
{"forti", "FortiClient"}, {"openfortivpn", "openfortivpn"}, {"networkmanager", "NetworkManager"},
|
||||
{"systemd-resolved", "systemd-resolved"}, {"resolvconf", "resolvconf"}, {"dhcpcd", "dhcpcd"},
|
||||
{"dhclient", "dhclient"}, {"netconfig", "netconfig"}, {"openvpn", "OpenVPN"},
|
||||
{"openconnect", "OpenConnect"}, {"vpnc", "vpnc"}, {"tailscale", "Tailscale"}, {"connman", "ConnMan"},
|
||||
}
|
||||
|
||||
// WriterOf is the writer a file's comments name, or empty.
|
||||
func WriterOf(content string) string {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
for _, s := range signs {
|
||||
if strings.Contains(lower, s.word) {
|
||||
return s.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// same is whether two resolver files say the same, apart from surrounding whitespace — the node-engine's
|
||||
// own comparison (ADR 0241 rule 1).
|
||||
func same(a, b string) bool { return strings.TrimSpace(a) == strings.TrimSpace(b) }
|
||||
|
||||
// read is the file as a reader of it sees it: its content, or what a link in its place points at.
|
||||
func read(path string) (content string, isLink bool, err error) {
|
||||
fi, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if fi.Mode()&os.ModeSymlink != 0 {
|
||||
target, _ := os.Readlink(path)
|
||||
raw, _ := os.ReadFile(path)
|
||||
return "# a link to " + target + "\n" + string(raw), true, nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
return string(raw), false, err
|
||||
}
|
||||
|
||||
// Tick is one look: notice a write, put the module's file back when it was taken or held long enough, and
|
||||
// close a displacement once the file is the module's again. It answers what it did, for the log and tests.
|
||||
func (g *Guard) Tick() string {
|
||||
kept, err := os.ReadFile(g.KeptPath)
|
||||
if err != nil {
|
||||
return "" // not yet rendered: nothing declared to keep
|
||||
}
|
||||
current, isLink, err := read(g.Path)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return ""
|
||||
}
|
||||
now := g.Now()
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if err == nil && !isLink && same(current, string(kept)) {
|
||||
if g.pending != nil {
|
||||
how := "the module's file was written back by another"
|
||||
if g.pending.How != "" {
|
||||
how = g.pending.How
|
||||
}
|
||||
g.end(now, how)
|
||||
return "ended"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if g.pending == nil || g.pending.content != current {
|
||||
if g.pending != nil {
|
||||
g.end(now, "written over again before it was put back")
|
||||
}
|
||||
d := &Displacement{ID: now.UTC().Format("20060102T150405.000Z"), At: now, Writer: WriterOf(current), content: current}
|
||||
g.pending = d
|
||||
g.keep(d)
|
||||
g.Log("the resolver file was written by %s; kept as %s", orAnother(d.Writer), d.ID)
|
||||
}
|
||||
d := g.pending
|
||||
switch {
|
||||
case d.TakenBy != "":
|
||||
if err := g.putBack(kept); err != nil {
|
||||
g.Log("putting the resolver file back failed: %v", err)
|
||||
return "failed"
|
||||
}
|
||||
d.How = "taken by " + d.TakenBy + ", and the module's file put back"
|
||||
g.end(g.Now(), d.How)
|
||||
return "put back, taken"
|
||||
case now.Sub(d.At) >= g.Hold:
|
||||
if err := g.putBack(kept); err != nil {
|
||||
g.Log("putting the resolver file back failed: %v", err)
|
||||
return "failed"
|
||||
}
|
||||
d.How = fmt.Sprintf("nobody took it; the module's file put back after %s", g.Hold)
|
||||
g.end(g.Now(), d.How)
|
||||
return "put back, held"
|
||||
}
|
||||
return "holding"
|
||||
}
|
||||
|
||||
func orAnother(w string) string {
|
||||
if w == "" {
|
||||
return "another program"
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// end closes the pending displacement into the history.
|
||||
func (g *Guard) end(at time.Time, how string) {
|
||||
d := *g.pending
|
||||
d.Ended, d.How = &at, how
|
||||
g.pending = nil
|
||||
g.history = append([]Displacement{d}, g.history...)
|
||||
if len(g.history) > Kept {
|
||||
g.history = g.history[:Kept]
|
||||
}
|
||||
g.writeHistory()
|
||||
g.Log("displacement %s ended: %s", d.ID, how)
|
||||
}
|
||||
|
||||
// keep writes what was written where only root reads it, and the oldest beyond Kept goes.
|
||||
func (g *Guard) keep(d *Displacement) {
|
||||
dir := filepath.Join(g.Dir, "displaced")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(dir, d.ID+".conf"), []byte(d.content), 0o600)
|
||||
entries, _ := os.ReadDir(dir)
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
for len(names) > Kept {
|
||||
_ = os.Remove(filepath.Join(dir, names[0]))
|
||||
names = names[1:]
|
||||
}
|
||||
g.writeHistory()
|
||||
}
|
||||
|
||||
// writeHistory says, readable by the operator's account, what became of each write: never what it held.
|
||||
func (g *Guard) writeHistory() {
|
||||
list := []Displacement{}
|
||||
if g.pending != nil {
|
||||
list = append(list, *g.pending)
|
||||
}
|
||||
list = append(list, g.history...)
|
||||
raw, _ := json.MarshalIndent(list, "", " ")
|
||||
tmp := filepath.Join(g.Dir, ".history.json")
|
||||
if os.WriteFile(tmp, raw, 0o644) == nil {
|
||||
_ = os.Rename(tmp, filepath.Join(g.Dir, "history.json"))
|
||||
}
|
||||
}
|
||||
|
||||
// putBack writes the module's file in place, whole, by a rename in the same directory: a reader sees the
|
||||
// old file or the new one, never half, and a link in its place is replaced by the file.
|
||||
func (g *Guard) putBack(kept []byte) error {
|
||||
tmp := filepath.Join(filepath.Dir(g.Path), ".resolv.conf.node-resolver")
|
||||
if err := os.WriteFile(tmp, kept, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, g.Path)
|
||||
}
|
||||
|
||||
// Pending is the write standing now, with what it held — for a module on this machine, over the socket.
|
||||
type Pending struct {
|
||||
ID string `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
Writer string `json:"writer,omitempty"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// Displaced is the write standing now, or nil.
|
||||
func (g *Guard) Displaced() *Pending {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.pending == nil {
|
||||
return nil
|
||||
}
|
||||
return &Pending{ID: g.pending.ID, At: g.pending.At, Writer: g.pending.Writer, Content: g.pending.content}
|
||||
}
|
||||
|
||||
// Take marks the write standing now as taken by a module, and has it put back at the next look.
|
||||
func (g *Guard) Take(id, by string) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.pending == nil || g.pending.ID != id {
|
||||
return fmt.Errorf("no write %q stands now", id)
|
||||
}
|
||||
now := g.Now()
|
||||
g.pending.TakenBy, g.pending.TakenAt = by, &now
|
||||
g.writeHistory()
|
||||
select {
|
||||
case g.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadHistory is what became of the last writes, as the guard said it; empty where no guard runs.
|
||||
func ReadHistory(path string) []Displacement {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return []Displacement{}
|
||||
}
|
||||
var list []Displacement
|
||||
if json.Unmarshal(raw, &list) != nil {
|
||||
return []Displacement{}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Run looks until the context ends, and keeps every link's own servers from being a default route.
|
||||
func (g *Guard) Run(ctx context.Context) {
|
||||
look := time.NewTicker(Look)
|
||||
defer look.Stop()
|
||||
routes := time.NewTicker(DefaultRouteEvery)
|
||||
defer routes.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-look.C:
|
||||
g.Tick()
|
||||
case <-g.wake:
|
||||
g.Tick()
|
||||
case <-routes.C:
|
||||
if changed, err := g.Resolver.OnlyTheMeshIsADefaultRoute(ctx); err == nil && len(changed) > 0 {
|
||||
g.Log("%s had servers answering every name; now only their own domains", strings.Join(changed, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request is one call over the socket: a verb and its arguments, one JSON line.
|
||||
type Request struct {
|
||||
Verb string `json:"verb"`
|
||||
Args map[string]any `json:"args"`
|
||||
}
|
||||
|
||||
// Reply is its answer, one JSON line.
|
||||
type Reply struct {
|
||||
Result any `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var takerName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// Answer is one verb, as the machine's modules call it. `route` with `takes` and `by` also takes the write
|
||||
// standing now, once its route is in place.
|
||||
func (g *Guard) Answer(ctx context.Context, req Request) Reply {
|
||||
str := func(k string) string { s, _ := req.Args[k].(string); return strings.TrimSpace(s) }
|
||||
var result any
|
||||
var err error
|
||||
switch req.Verb {
|
||||
case "routes":
|
||||
var routes *Routes
|
||||
if routes, err = g.Resolver.Routes(ctx); err == nil {
|
||||
result = map[string]any{"mesh": routes.Mesh, "links": routes.Links}
|
||||
}
|
||||
case "route":
|
||||
var routed *Routed
|
||||
routed, err = g.Resolver.Route(ctx, str("link"), Split(req.Args["domains"]), Split(req.Args["servers"]))
|
||||
if err == nil && str("takes") != "" {
|
||||
if !takerName.MatchString(str("by")) {
|
||||
err = errors.New("a write is taken by a module, named in `by`")
|
||||
} else {
|
||||
err = g.Take(str("takes"), str("by"))
|
||||
}
|
||||
}
|
||||
result = routed
|
||||
case "unroute":
|
||||
result, err = g.Resolver.Unroute(ctx, str("link"))
|
||||
case "displaced":
|
||||
result = g.Displaced()
|
||||
default:
|
||||
err = fmt.Errorf("%q is not a verb of node-resolver", req.Verb)
|
||||
}
|
||||
if err != nil {
|
||||
return Reply{Error: err.Error()}
|
||||
}
|
||||
return Reply{Result: result}
|
||||
}
|
||||
|
||||
// Serve answers the socket until the context ends. Only root can reach it: what a module hands over
|
||||
// here — a VPN's servers and domains — never leaves the machine.
|
||||
func (g *Guard) Serve(ctx context.Context, path string) error {
|
||||
_ = os.Remove(path)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
l.Close()
|
||||
return err
|
||||
}
|
||||
go func() { <-ctx.Done(); l.Close() }()
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
line, err := bufio.NewReader(c).ReadBytes('\n')
|
||||
var reply Reply
|
||||
var req Request
|
||||
if err != nil && len(line) == 0 {
|
||||
return
|
||||
}
|
||||
if jerr := json.Unmarshal(line, &req); jerr != nil {
|
||||
reply = Reply{Error: "one JSON object per line: {\"verb\": …, \"args\": {…}}"}
|
||||
} else {
|
||||
reply = g.Answer(ctx, req)
|
||||
}
|
||||
raw, _ := json.Marshal(reply)
|
||||
_, _ = c.Write(append(raw, '\n'))
|
||||
}(conn)
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const meshFile = "# Managed by the mesh\nnameserver 10.42.0.2\noptions timeout:1 attempts:2 edns0\n"
|
||||
|
||||
// vpnFile is a VPN client's file as one writes it; the addresses and domains are documentation's.
|
||||
const vpnFile = "# Dynamic resolv.conf(5) file for glibc resolver(3) generated by forticlient\n" +
|
||||
"nameserver 192.0.2.53\nnameserver 192.0.2.54\nsearch corp.example cloud.example\n"
|
||||
|
||||
// aGuardedMachine is a guard over a temporary /etc and /run, with a clock the test moves.
|
||||
func aGuardedMachine(t *testing.T) (*Guard, *time.Time, *fakeResolved) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, d := range []string{"etc/node-resolver", "run"} {
|
||||
if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(t, filepath.Join(dir, "etc/node-resolver/resolv.conf"), meshFile)
|
||||
write(t, filepath.Join(dir, "etc/resolv.conf"), meshFile)
|
||||
now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
|
||||
f := &fakeResolved{}
|
||||
g := &Guard{Path: filepath.Join(dir, "etc/resolv.conf"), KeptPath: filepath.Join(dir, "etc/node-resolver/resolv.conf"),
|
||||
Dir: filepath.Join(dir, "run"), Hold: Hold, Now: func() time.Time { return now },
|
||||
Resolver: aMachine(t, f, "tun0"), Log: t.Logf, wake: make(chan struct{}, 1)}
|
||||
return g, &now, f
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func contentOf(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// The module's own file is left alone, and nothing is said.
|
||||
func TestTheModulesOwnFileIsLeftAlone(t *testing.T) {
|
||||
g, _, _ := aGuardedMachine(t)
|
||||
if did := g.Tick(); did != "" || g.Displaced() != nil {
|
||||
t.Errorf("the module's own file was taken for an outside write: %q", did)
|
||||
}
|
||||
}
|
||||
|
||||
// A write a module takes: kept for it to read, with its writer named from its header; once taken, the
|
||||
// module's own file is back at the next look; and the history says when, who and what became of it —
|
||||
// never a server or a domain.
|
||||
func TestATakenWriteIsPutBackAtOnce(t *testing.T) {
|
||||
g, now, _ := aGuardedMachine(t)
|
||||
write(t, g.Path, vpnFile)
|
||||
if did := g.Tick(); did != "holding" {
|
||||
t.Fatalf("the write was %q, not held for a taker", did)
|
||||
}
|
||||
d := g.Displaced()
|
||||
if d == nil || d.Writer != "FortiClient" || d.Content != vpnFile {
|
||||
t.Fatalf("the write is not kept as written, naming its writer: %+v", d)
|
||||
}
|
||||
kept := filepath.Join(g.Dir, "displaced", d.ID+".conf")
|
||||
if fi, err := os.Stat(kept); err != nil || fi.Mode().Perm() != 0o600 || contentOf(t, kept) != vpnFile {
|
||||
t.Errorf("the write is not kept where only root reads it: %v", err)
|
||||
}
|
||||
*now = now.Add(2 * time.Second)
|
||||
if err := g.Take(d.ID, "forticlient"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if did := g.Tick(); did != "put back, taken" {
|
||||
t.Fatalf("a taken write was %q", did)
|
||||
}
|
||||
if contentOf(t, g.Path) != meshFile {
|
||||
t.Errorf("the module's file is not back:\n%s", contentOf(t, g.Path))
|
||||
}
|
||||
if fi, _ := os.Stat(g.Path); fi.Mode().Perm() != 0o644 {
|
||||
t.Errorf("the file is %v, not readable by everyone", fi.Mode().Perm())
|
||||
}
|
||||
history := contentOf(t, filepath.Join(g.Dir, "history.json"))
|
||||
for _, never := range []string{"192.0.2", "corp.example", "nameserver"} {
|
||||
if strings.Contains(history, never) {
|
||||
t.Errorf("the history says %q, which stays on the machine:\n%s", never, history)
|
||||
}
|
||||
}
|
||||
list := ReadHistory(filepath.Join(g.Dir, "history.json"))
|
||||
if len(list) != 1 || list[0].TakenBy != "forticlient" || list[0].Ended == nil || !strings.Contains(list[0].How, "taken") {
|
||||
t.Errorf("the history is %+v", list)
|
||||
}
|
||||
if g.Tick() != "" {
|
||||
t.Error("the module's own file, back, was taken for another write")
|
||||
}
|
||||
}
|
||||
|
||||
// A write nobody takes stands for Hold — long enough for the node-engine to see it twice and say it —
|
||||
// and is then put back.
|
||||
func TestAWriteNobodyTakesStandsUntilItIsSaidThenGoes(t *testing.T) {
|
||||
g, now, _ := aGuardedMachine(t)
|
||||
write(t, g.Path, "# written by another program\nnameserver 198.51.100.1\n")
|
||||
g.Tick()
|
||||
*now = now.Add(61 * time.Second)
|
||||
if did := g.Tick(); did != "holding" || contentOf(t, g.Path) == meshFile {
|
||||
t.Fatalf("an untaken write was put back after a minute, before the node-engine's second look: %q", did)
|
||||
}
|
||||
if Hold < 75*time.Second {
|
||||
t.Errorf("Hold is %s; two of the node-engine's 30 s looks need more", Hold)
|
||||
}
|
||||
*now = now.Add(Hold)
|
||||
if did := g.Tick(); did != "put back, held" || contentOf(t, g.Path) != meshFile {
|
||||
t.Fatalf("an untaken write was not put back after Hold: %q", did)
|
||||
}
|
||||
if h := ReadHistory(filepath.Join(g.Dir, "history.json")); len(h) != 1 || h[0].TakenBy != "" || h[0].Writer != "" {
|
||||
t.Errorf("the history is %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// A write the reconcile or the writer itself undoes is closed as written back by another; one written
|
||||
// over before it was put back is a new one; a link in the file's place is a write too.
|
||||
func TestWritesUndoneAndWrittenOverAreSaid(t *testing.T) {
|
||||
g, now, _ := aGuardedMachine(t)
|
||||
write(t, g.Path, vpnFile)
|
||||
g.Tick()
|
||||
write(t, g.Path, meshFile)
|
||||
*now = now.Add(time.Second)
|
||||
if did := g.Tick(); did != "ended" {
|
||||
t.Errorf("a write undone by another was %q", did)
|
||||
}
|
||||
write(t, g.Path, vpnFile)
|
||||
g.Tick()
|
||||
first := g.Displaced().ID
|
||||
*now = now.Add(time.Second)
|
||||
write(t, g.Path, vpnFile+"search more.example\n")
|
||||
g.Tick()
|
||||
if g.Displaced().ID == first {
|
||||
t.Error("a second write was taken for the first")
|
||||
}
|
||||
if err := g.Take(first, "forticlient"); err == nil {
|
||||
t.Error("a write that no longer stands was taken")
|
||||
}
|
||||
_ = os.Remove(g.Path)
|
||||
if err := os.Symlink(g.KeptPath, g.Path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*now = now.Add(time.Second)
|
||||
g.Tick()
|
||||
if d := g.Displaced(); d == nil || !strings.Contains(d.Content, "a link to") {
|
||||
t.Errorf("a link in the file's place was not a write: %+v", d)
|
||||
}
|
||||
*now = now.Add(Hold)
|
||||
g.Tick()
|
||||
if fi, err := os.Lstat(g.Path); err != nil || fi.Mode()&os.ModeSymlink != 0 {
|
||||
t.Errorf("the link was not replaced by the module's file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is kept before the mesh rendered the module's file: there is nothing to keep it to.
|
||||
func TestNothingIsGuardedBeforeTheFileIsRendered(t *testing.T) {
|
||||
g, _, _ := aGuardedMachine(t)
|
||||
_ = os.Remove(g.KeptPath)
|
||||
write(t, g.Path, vpnFile)
|
||||
if g.Tick() != "" || contentOf(t, g.Path) != vpnFile {
|
||||
t.Error("the guard acted with no file of its own to keep")
|
||||
}
|
||||
}
|
||||
|
||||
// The socket: a module routes and takes the write standing now in one call, and the module's file is
|
||||
// back; a bad request and an unknown verb are answered, not dropped; the socket is root's alone.
|
||||
func TestTheVerbsOnTheMachine(t *testing.T) {
|
||||
g, _, f := aGuardedMachine(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sock := filepath.Join(g.Dir, "verbs.sock")
|
||||
go func() { _ = g.Serve(ctx, sock) }()
|
||||
for i := 0; i < 100; i++ {
|
||||
if _, err := os.Stat(sock); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if fi, err := os.Stat(sock); err != nil || fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("the socket is not root's alone: %v", err)
|
||||
}
|
||||
call := func(line string) Reply {
|
||||
t.Helper()
|
||||
c, err := net.Dial("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
if _, err := c.Write([]byte(line + "\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := bufio.NewReader(c).ReadBytes('\n')
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var r Reply
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
write(t, g.Path, vpnFile)
|
||||
g.Tick()
|
||||
shown := call(`{"verb":"displaced"}`)
|
||||
pending, _ := shown.Result.(map[string]any)
|
||||
if pending == nil || pending["content"] != vpnFile {
|
||||
t.Fatalf("the write standing now is not shown on the machine: %+v", shown)
|
||||
}
|
||||
req, _ := json.Marshal(Request{Verb: "route", Args: map[string]any{"link": "tun0",
|
||||
"domains": []any{"corp.example", "cloud.example"}, "servers": "192.0.2.53 192.0.2.54",
|
||||
"takes": pending["id"], "by": "forticlient"}})
|
||||
if r := call(string(req)); r.Error != "" {
|
||||
t.Fatalf("route and take: %s", r.Error)
|
||||
}
|
||||
if len(f.changed) != 3 {
|
||||
t.Errorf("resolved was asked %v", f.changed)
|
||||
}
|
||||
select {
|
||||
case <-g.wake:
|
||||
g.Tick()
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("taking the write did not wake the guard")
|
||||
}
|
||||
if contentOf(t, g.Path) != meshFile {
|
||||
t.Error("the module's file is not back once its write was taken")
|
||||
}
|
||||
if r := call(`not json`); !strings.Contains(r.Error, "JSON") {
|
||||
t.Errorf("a bad request: %+v", r)
|
||||
}
|
||||
if r := call(`{"verb":"flush"}`); !strings.Contains(r.Error, "not a verb") {
|
||||
t.Errorf("an unknown verb: %+v", r)
|
||||
}
|
||||
if r := call(`{"verb":"route","args":{"link":"tun0","domains":"internal","servers":"192.0.2.53"}}`); !strings.Contains(r.Error, "mesh's own") {
|
||||
t.Errorf("the mesh's domain over the socket: %+v", r)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// systemd-resolved's tools bundle (novox/hq ADR 0247): the node-resolver seat's verbs, and the module's
|
||||
// guard.
|
||||
//
|
||||
// Started with no arguments it is served by the node's runtime as the operator account, over MCP on stdio
|
||||
// through the Go SDK (ADR 0188, ADR 0193): `routes`, `route` and `unroute` on the mesh, for the operator
|
||||
// to read and correct. Started as `resolver-tools guard` it is the module's long-running process, as root:
|
||||
// it keeps the machine's resolver file the module's own and serves the same verbs on the machine, to the
|
||||
// modules there (guard.go). stdout is the MCP channel; everything else is said on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds.
|
||||
const Seat = "node-resolver"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
if os.Args[1] != "guard" || len(os.Args) != 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: resolver-tools [guard]")
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := guard(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := stdio.Serve("", tools(ThisResolver(), History)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func guard() error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||
defer stop()
|
||||
g := NewGuard()
|
||||
if err := os.MkdirAll(g.Dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
g.writeHistory()
|
||||
go g.Run(ctx)
|
||||
return g.Serve(ctx, Socket)
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func arg(a map[string]any, k string) string {
|
||||
v, _ := a[k].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
|
||||
// subject, as <node>/node-resolver.<verb>.
|
||||
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: run}
|
||||
}
|
||||
|
||||
func tools(r *Resolver, history string) []stdio.Tool {
|
||||
ctx := context.Background()
|
||||
return []stdio.Tool{
|
||||
verb("routes", "What this machine's own resolver sends where: the mesh's resolvers, which answer every name "+
|
||||
"not routed elsewhere, and each link given servers of its own with the domains routed to them. Also the "+
|
||||
"resolver file's outside writes, newest first: when, who wrote it as far as the file says, whether a "+
|
||||
"module took it, and when the module's own file stood again. (r)",
|
||||
nil, func(map[string]any) (any, error) {
|
||||
routes, err := r.Routes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"mesh": routes.Mesh, "links": routes.Links, "outside_writes": ReadHistory(history)}, nil
|
||||
}),
|
||||
verb("route", "Send these domains, and every name under them, to these servers over this link, and only "+
|
||||
"them: the link never answers other names, and the mesh's own domain is refused. Replaces whatever the "+
|
||||
"link was given before; a link that goes away takes its route with it. (a)",
|
||||
map[string]any{"link": str("the network link the servers are reached over, by name"),
|
||||
"domains": str("the domains to route there, separated by spaces or commas"),
|
||||
"servers": str("the servers' addresses, separated by spaces or commas")},
|
||||
func(a map[string]any) (any, error) {
|
||||
return r.Route(ctx, arg(a, "link"), Split(a["domains"]), Split(a["servers"]))
|
||||
}),
|
||||
verb("unroute", "Take one link's route away: its domains go to the mesh's resolvers again. Nothing changes "+
|
||||
"when the link has none. (a)",
|
||||
map[string]any{"link": str("the network link, by name")},
|
||||
func(a map[string]any) (any, error) { return r.Unroute(ctx, arg(a, "link")) }),
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The manifest and this code say one thing: the paths the guard keeps are the files the mesh renders, the
|
||||
// process runs this binary as the guard, and the resolver file and its kept copy are one template.
|
||||
|
||||
type manifest struct {
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Provides []struct {
|
||||
Name string `json:"name"`
|
||||
Reach string `json:"reach"`
|
||||
} `json:"provides"`
|
||||
Upgrade struct {
|
||||
Policy string `json:"policy"`
|
||||
Why string `json:"why"`
|
||||
} `json:"upgrade"`
|
||||
Facts map[string]struct {
|
||||
Path string `json:"path"`
|
||||
Template string `json:"template"`
|
||||
} `json:"facts"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
Binary string `json:"binary"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func theManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestTheManifestSaysWhatTheGuardKeeps(t *testing.T) {
|
||||
m := theManifest(t)
|
||||
if m.Facts["resolvers"].Path != ResolvConf || m.Facts["kept"].Path != KeptPath || m.Facts["suffix"].Path != SuffixPath {
|
||||
t.Errorf("the rendered paths are not the ones the guard reads: %+v", m.Facts)
|
||||
}
|
||||
if m.Facts["resolvers"].Template != m.Facts["kept"].Template {
|
||||
t.Error("the resolver file and the copy the guard puts back are not one template")
|
||||
}
|
||||
// Sorted before the live file, so the mesh writes the copy first and the guard never puts back the
|
||||
// file it is about to be given.
|
||||
if !("kept" < "resolvers") {
|
||||
t.Error("the kept copy is not rendered before the file")
|
||||
}
|
||||
if !strings.HasPrefix(m.Facts["resolvers"].Template, "# Managed by the mesh") {
|
||||
t.Error("the resolver file does not begin as the mesh's own does, which is how the uplink's verb reads it")
|
||||
}
|
||||
var lines []string
|
||||
for _, l := range strings.Split(m.Facts["resolvers"].Template, "\n") {
|
||||
if strings.Contains(l, "nameserver") && !strings.HasPrefix(l, "#") {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
if len(lines) != 1 || strings.Contains(m.Facts["resolvers"].Template, "search ") {
|
||||
t.Errorf("the resolver file lists more than this machine's own resolver: %v", lines)
|
||||
}
|
||||
conf := m.Facts["resolved"].Template
|
||||
for _, want := range []string{`DNS={{range index .Holders "mesh-dns-resolver"}}`, "\nDomains=~.\n", "\nFallbackDNS=\n",
|
||||
"DNSStubListenerExtra={{$own}}\n", "\nCache=no\n", "\nLLMNR=no\n", "\nMulticastDNS=no\n"} {
|
||||
if !strings.Contains(conf, want) {
|
||||
t.Errorf("resolved's drop-in lacks %q", want)
|
||||
}
|
||||
}
|
||||
guard, service := false, false
|
||||
for _, r := range m.Resources {
|
||||
run, _ := r["run"].([]any)
|
||||
if r["type"] == "process" && len(run) == 2 && run[0] == "./"+m.Build.Artifacts[0].Binary && run[1] == "guard" {
|
||||
guard = r["user"] == nil && r["run-once"] == nil && r["health"] != nil
|
||||
}
|
||||
if r["type"] == "service" && r["unit"] == "systemd-resolved.service" {
|
||||
on, _ := r["restart-on"].([]any)
|
||||
service = r["state"] == "running" && len(on) == 1 && on[0] == "systemd-resolved.fact-resolved" && r["health"] != nil
|
||||
}
|
||||
}
|
||||
if !guard || !service {
|
||||
t.Errorf("the guard (root, long-running, its health said) %v; resolved (running, restarted on its drop-in) %v", guard, service)
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || strings.Join(m.Claims[0].Serves, " ") != "routes route unroute" {
|
||||
t.Errorf("the claim is %+v", m.Claims)
|
||||
}
|
||||
if len(m.Provides) != 1 || m.Provides[0].Name != "split-dns" || m.Provides[0].Reach != "machine" {
|
||||
t.Errorf("split-dns is not provided at the machine's reach: %+v", m.Provides)
|
||||
}
|
||||
if m.Upgrade.Policy != "record" || m.Upgrade.Why == "" {
|
||||
t.Error("a build of the machine's names rolls out on its own")
|
||||
}
|
||||
}
|
||||
|
||||
// Every verb the claim serves is a tool of the bundle, by the seat's name.
|
||||
func TestTheBundleServesTheClaimedVerbs(t *testing.T) {
|
||||
have := map[string]bool{}
|
||||
for _, tool := range tools(&Resolver{}, "") {
|
||||
have[tool.Name] = true
|
||||
}
|
||||
for _, v := range theManifest(t).Claims[0].Serves {
|
||||
if !have[Seat+"."+v] {
|
||||
t.Errorf("%s.%s is claimed and not served", Seat, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
// The node-resolver seat's verbs, done by systemd-resolved (novox/hq ADR 0247): what is routed where,
|
||||
// route a set of domains to a set of servers over one link, and take a link's route away.
|
||||
//
|
||||
// **resolved holds the routes, not this code.** A link's servers and routing domains are resolved's own
|
||||
// per-link state, set through resolvectl and forgotten by resolved when the link goes. So nothing here
|
||||
// keeps a table that could disagree with what resolved does: `routes` reads resolved, and the two
|
||||
// transports that serve these verbs — the mesh, through the node's runtime as the operator account, and
|
||||
// the machine, through the guard's socket as root — run the same code against the same daemon.
|
||||
//
|
||||
// **It knows nothing of any VPN.** A link, domains and servers. What a VPN client pushed is its own
|
||||
// module's to read and hand over.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Runner runs one command — as root when it changes something — and answers what it printed.
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// escalated is the command as it is run: as given when this process is root (the guard), else through
|
||||
// sudo without a prompt (the runtime's account), as the hosts file's and the packet filter's verbs do.
|
||||
func escalated(uid int, name string, args []string) (string, []string) {
|
||||
if uid == 0 {
|
||||
return name, args
|
||||
}
|
||||
return "sudo", append([]string{"-n", name}, args...)
|
||||
}
|
||||
|
||||
// execRunner runs a command that changes resolved's state, escalated.
|
||||
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
return run(ctx, true, name, args...)
|
||||
}
|
||||
|
||||
// readRunner runs a command that only reads, as whoever this process is.
|
||||
func readRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
return run(ctx, false, name, args...)
|
||||
}
|
||||
|
||||
func run(ctx context.Context, escalate bool, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
program, argv := name, args
|
||||
if escalate {
|
||||
program, argv = escalated(os.Getuid(), name, args)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, program, argv...)
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return stdout.String(), nil
|
||||
}
|
||||
said := strings.TrimSpace(stdout.String() + stderr.String())
|
||||
if program == "sudo" {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name)
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) {
|
||||
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
||||
}
|
||||
}
|
||||
if said != "" {
|
||||
return "", fmt.Errorf("%s: %s", name, said)
|
||||
}
|
||||
return "", fmt.Errorf("%s failed: %v", name, err)
|
||||
}
|
||||
|
||||
// Resolver is systemd-resolved on this machine, as the seat's verbs see it.
|
||||
type Resolver struct {
|
||||
// Change runs what changes resolved (escalated); Read what only reads it.
|
||||
Change, Read Runner
|
||||
// NetDir is where the machine's links are listed (/sys/class/net).
|
||||
NetDir string
|
||||
// SuffixFile holds the mesh's own domain, which is never routed elsewhere.
|
||||
SuffixFile string
|
||||
}
|
||||
|
||||
// ThisResolver is the machine's.
|
||||
func ThisResolver() *Resolver {
|
||||
return &Resolver{Change: execRunner, Read: readRunner, NetDir: "/sys/class/net", SuffixFile: SuffixPath}
|
||||
}
|
||||
|
||||
// SuffixPath is the file the mesh renders the mesh's own domain into (the manifest's fact `suffix`).
|
||||
const SuffixPath = "/etc/node-resolver/suffix"
|
||||
|
||||
// Scope is one place resolved sends names: the machine's global servers (the mesh's resolvers), or a link.
|
||||
type Scope struct {
|
||||
Link string `json:"link,omitempty"`
|
||||
Servers []string `json:"servers"`
|
||||
// Domains are the routing domains, without resolved's `~`: every name under one goes to these servers.
|
||||
Domains []string `json:"domains"`
|
||||
// DefaultRoute is whether names no domain routes may also go here. Only the mesh's resolvers are.
|
||||
DefaultRoute *bool `json:"default_route,omitempty"`
|
||||
}
|
||||
|
||||
// Routes is what resolved sends where.
|
||||
type Routes struct {
|
||||
// Mesh is the global scope: the mesh's resolvers, which answer every name nothing routes elsewhere.
|
||||
Mesh Scope `json:"mesh"`
|
||||
// Links is every link given servers of its own.
|
||||
Links []Scope `json:"links"`
|
||||
}
|
||||
|
||||
var linkLine = regexp.MustCompile(`^Link\s+\d+\s+\(([^)]+)\):\s*(.*)$`)
|
||||
|
||||
// perScope reads one resolvectl listing (`dns`, `domain`, `default-route`) into the global line and one
|
||||
// line per link.
|
||||
func perScope(out string) (global []string, links map[string][]string) {
|
||||
links = map[string][]string{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if rest, ok := strings.CutPrefix(line, "Global:"); ok {
|
||||
global = strings.Fields(rest)
|
||||
continue
|
||||
}
|
||||
if m := linkLine.FindStringSubmatch(line); m != nil {
|
||||
links[m[1]] = strings.Fields(m[2])
|
||||
}
|
||||
}
|
||||
return global, links
|
||||
}
|
||||
|
||||
func unrouted(domains []string) []string {
|
||||
out := make([]string, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
out = append(out, strings.TrimPrefix(d, "~"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Routes reads what resolved sends where. It changes nothing and needs no root.
|
||||
func (r *Resolver) Routes(ctx context.Context) (*Routes, error) {
|
||||
dns, err := r.Read(ctx, "resolvectl", "dns")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("systemd-resolved does not answer: %w", err)
|
||||
}
|
||||
domain, err := r.Read(ctx, "resolvectl", "domain")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("systemd-resolved does not answer: %w", err)
|
||||
}
|
||||
defaults, _ := r.Read(ctx, "resolvectl", "default-route")
|
||||
gServers, lServers := perScope(dns)
|
||||
gDomains, lDomains := perScope(domain)
|
||||
_, lDefault := perScope(defaults)
|
||||
out := &Routes{Mesh: Scope{Servers: orEmpty(gServers), Domains: orEmpty(unrouted(gDomains))}, Links: []Scope{}}
|
||||
names := make([]string, 0, len(lServers))
|
||||
for name, servers := range lServers {
|
||||
if len(servers) > 0 {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
s := Scope{Link: name, Servers: lServers[name], Domains: orEmpty(unrouted(lDomains[name]))}
|
||||
if d, ok := lDefault[name]; ok && len(d) > 0 {
|
||||
yes := d[0] == "yes"
|
||||
s.DefaultRoute = &yes
|
||||
}
|
||||
out.Links = append(out.Links, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func orEmpty(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Routed is what a route did.
|
||||
type Routed struct {
|
||||
Link string `json:"link"`
|
||||
Servers []string `json:"servers"`
|
||||
Domains []string `json:"domains"`
|
||||
Said string `json:"said"`
|
||||
}
|
||||
|
||||
var (
|
||||
linkName = regexp.MustCompile(`^[A-Za-z0-9_.:@-]{1,15}$`)
|
||||
domainName = regexp.MustCompile(`^([a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?\.)*[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$`)
|
||||
separators = regexp.MustCompile(`[\s,]+`)
|
||||
)
|
||||
|
||||
// Split reads a list given as one string, separated by spaces or commas, or as a list.
|
||||
func Split(v any) []string {
|
||||
var raw []string
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
raw = separators.Split(v, -1)
|
||||
case []any:
|
||||
for _, x := range v {
|
||||
if s, ok := x.(string); ok {
|
||||
raw = append(raw, separators.Split(s, -1)...)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
for _, s := range v {
|
||||
raw = append(raw, separators.Split(s, -1)...)
|
||||
}
|
||||
}
|
||||
out := []string{}
|
||||
for _, s := range raw {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// suffix is the mesh's own domain, as the mesh rendered it; "internal" when it has not been yet.
|
||||
func (r *Resolver) suffix() string {
|
||||
raw, err := os.ReadFile(r.SuffixFile)
|
||||
if s := strings.Trim(strings.TrimSpace(string(raw)), "."); err == nil && s != "" {
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
return "internal"
|
||||
}
|
||||
|
||||
// checkLink refuses a link that is not one, loopback, and one that is not on this machine now.
|
||||
func (r *Resolver) checkLink(link string) error {
|
||||
if !linkName.MatchString(link) {
|
||||
return fmt.Errorf("%q is not a link's name", link)
|
||||
}
|
||||
if link == "lo" {
|
||||
return errors.New("loopback carries no servers of its own")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(r.NetDir, link)); err != nil {
|
||||
return fmt.Errorf("there is no link %q on this machine now", link)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Domains reads the domains to route: lower-cased, without resolved's `~` or a final dot, each once. The
|
||||
// root and the mesh's own domain are refused: routing either away would send the mesh's names, or every
|
||||
// name, to servers that are not the mesh's (ADR 0223, ADR 0247).
|
||||
func (r *Resolver) Domains(given []string) ([]string, error) {
|
||||
suffix := r.suffix()
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, d := range given {
|
||||
d = strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(d, "~"), "."))
|
||||
if d == "" {
|
||||
return nil, errors.New("the root domain is every name: only the mesh's resolvers answer every name")
|
||||
}
|
||||
if !domainName.MatchString(d) || len(d) > 253 {
|
||||
return nil, fmt.Errorf("%q is not a domain", d)
|
||||
}
|
||||
if d == suffix || strings.HasSuffix(d, "."+suffix) {
|
||||
return nil, fmt.Errorf("%q is the mesh's own domain: the mesh's names are answered by the mesh's resolvers alone", d)
|
||||
}
|
||||
if !seen[d] {
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("no domain given: a link's servers answer only the domains routed to them")
|
||||
}
|
||||
if len(out) > 64 {
|
||||
return nil, fmt.Errorf("%d domains; at most 64", len(out))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Servers reads the servers: addresses, each once, at most eight.
|
||||
func Servers(given []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, s := range given {
|
||||
a, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is not an address", s)
|
||||
}
|
||||
if a.IsUnspecified() || a.IsMulticast() {
|
||||
return nil, fmt.Errorf("%s cannot answer names", s)
|
||||
}
|
||||
if !seen[a.String()] {
|
||||
seen[a.String()] = true
|
||||
out = append(out, a.String())
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("no server given")
|
||||
}
|
||||
if len(out) > 8 {
|
||||
return nil, fmt.Errorf("%d servers; at most 8", len(out))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Route sends these domains, and every name under them, to these servers over this link — and only them.
|
||||
// Whatever the link was given before is replaced. resolved forgets it when the link goes.
|
||||
func (r *Resolver) Route(ctx context.Context, link string, domains, servers []string) (*Routed, error) {
|
||||
if err := r.checkLink(link); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ds, err := r.Domains(domains)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ss, err := Servers(servers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routing := make([]string, len(ds))
|
||||
for i, d := range ds {
|
||||
routing[i] = "~" + d
|
||||
}
|
||||
// The link is never a default route: names no domain routes go to the mesh's resolvers, so set
|
||||
// first, before the servers, that no question but these domains' ever reaches it.
|
||||
steps := [][]string{
|
||||
{"default-route", link, "false"},
|
||||
append([]string{"domain", link}, routing...),
|
||||
append([]string{"dns", link}, ss...),
|
||||
}
|
||||
for _, s := range steps {
|
||||
if _, err := r.Change(ctx, "resolvectl", s...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Routed{Link: link, Servers: ss, Domains: ds,
|
||||
Said: fmt.Sprintf("%d domains go to %d servers over %s; every other name to the mesh's resolvers", len(ds), len(ss), link)}, nil
|
||||
}
|
||||
|
||||
// Unrouted is what taking a route away did.
|
||||
type Unrouted struct {
|
||||
Link string `json:"link"`
|
||||
Said string `json:"said"`
|
||||
}
|
||||
|
||||
// Unroute takes one link's route away. A link that has gone has nothing to take away.
|
||||
func (r *Resolver) Unroute(ctx context.Context, link string) (*Unrouted, error) {
|
||||
if !linkName.MatchString(link) || link == "lo" {
|
||||
return nil, fmt.Errorf("%q is not a link's name", link)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(r.NetDir, link)); err != nil {
|
||||
return &Unrouted{Link: link, Said: link + " is not on this machine; resolved forgot its route with it"}, nil
|
||||
}
|
||||
if _, err := r.Change(ctx, "resolvectl", "revert", link); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Unrouted{Link: link, Said: link + "'s domains go to the mesh's resolvers again"}, nil
|
||||
}
|
||||
|
||||
// OnlyTheMeshIsADefaultRoute keeps every link that has servers of its own from answering names nothing
|
||||
// routes to it: a network manager telling resolved a network's servers makes them a default route, and
|
||||
// then resolved asks them every name beside the mesh's resolvers. Their routing domains are kept — a
|
||||
// link's own domains still go to it. Answers the links it changed.
|
||||
func (r *Resolver) OnlyTheMeshIsADefaultRoute(ctx context.Context) ([]string, error) {
|
||||
routes, err := r.Routes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var changed []string
|
||||
for _, l := range routes.Links {
|
||||
if l.DefaultRoute == nil || !*l.DefaultRoute {
|
||||
continue
|
||||
}
|
||||
if _, err := r.Change(ctx, "resolvectl", "default-route", l.Link, "false"); err != nil {
|
||||
return changed, err
|
||||
}
|
||||
changed = append(changed, l.Link)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeResolved answers resolvectl as systemd-resolved would, and records what was asked of it.
|
||||
type fakeResolved struct {
|
||||
dns, domain, defaults string
|
||||
changed [][]string
|
||||
}
|
||||
|
||||
func (f *fakeResolved) read(_ context.Context, name string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "dns":
|
||||
return f.dns, nil
|
||||
case "domain":
|
||||
return f.domain, nil
|
||||
case "default-route":
|
||||
return f.defaults, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f *fakeResolved) change(_ context.Context, name string, args ...string) (string, error) {
|
||||
f.changed = append(f.changed, append([]string{name}, args...))
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// aMachine is a resolver over a fake resolved and a /sys/class/net holding the links named.
|
||||
func aMachine(t *testing.T, f *fakeResolved, links ...string) *Resolver {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, l := range links {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "net", l), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
suffix := filepath.Join(dir, "suffix")
|
||||
if err := os.WriteFile(suffix, []byte("internal\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Resolver{Change: f.change, Read: f.read, NetDir: filepath.Join(dir, "net"), SuffixFile: suffix}
|
||||
}
|
||||
|
||||
// A route sends the domains to the servers over the link, and only them: the link is first told it is
|
||||
// no default route, then given its domains as routing domains, then its servers.
|
||||
func TestARouteSendsOnlyItsDomainsOverItsLink(t *testing.T) {
|
||||
f := &fakeResolved{}
|
||||
r := aMachine(t, f, "tun0")
|
||||
got, err := r.Route(context.Background(), "tun0", Split("Corp.Example. ~cloud.example, corp.example"), Split("10.9.0.1 10.9.0.2"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"resolvectl default-route tun0 false",
|
||||
"resolvectl domain tun0 ~corp.example ~cloud.example",
|
||||
"resolvectl dns tun0 10.9.0.1 10.9.0.2",
|
||||
}
|
||||
if len(f.changed) != len(want) {
|
||||
t.Fatalf("resolved was asked %v", f.changed)
|
||||
}
|
||||
for i, w := range want {
|
||||
if strings.Join(f.changed[i], " ") != w {
|
||||
t.Errorf("step %d was %v, not %s", i, f.changed[i], w)
|
||||
}
|
||||
}
|
||||
if len(got.Domains) != 2 || len(got.Servers) != 2 {
|
||||
t.Errorf("the route says %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The mesh's own domain, the root, a link that is not here and loopback are refused, and resolved is
|
||||
// asked nothing.
|
||||
func TestARouteThatWouldTakeTheMeshsNamesIsRefused(t *testing.T) {
|
||||
for name, c := range map[string]struct{ link, domains, servers, says string }{
|
||||
"the mesh's domain": {"tun0", "corp.example internal", "10.9.0.1", "mesh's own domain"},
|
||||
"under it": {"tun0", "anchor.internal", "10.9.0.1", "mesh's own domain"},
|
||||
"the root": {"tun0", "~.", "10.9.0.1", "every name"},
|
||||
"no domain": {"tun0", "", "10.9.0.1", "no domain"},
|
||||
"not a domain": {"tun0", "a b/c", "10.9.0.1", "not a domain"},
|
||||
"no server": {"tun0", "corp.example", "", "no server"},
|
||||
"not an address": {"tun0", "corp.example", "dns.corp.example", "not an address"},
|
||||
"a link not here": {"tun9", "corp.example", "10.9.0.1", "no link"},
|
||||
"loopback": {"lo", "corp.example", "10.9.0.1", "loopback"},
|
||||
"not a link's name": {"../etc", "corp.example", "10.9.0.1", "not a link"},
|
||||
"an unspecified one": {"tun0", "corp.example", "0.0.0.0", "cannot answer"},
|
||||
"a multicast address": {"tun0", "corp.example", "224.0.0.251", "cannot answer"},
|
||||
} {
|
||||
f := &fakeResolved{}
|
||||
r := aMachine(t, f, "tun0", "lo")
|
||||
_, err := r.Route(context.Background(), c.link, Split(c.domains), Split(c.servers))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
if len(f.changed) != 0 {
|
||||
t.Errorf("%s: resolved was changed anyway: %v", name, f.changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Routes reads resolved: the mesh's resolvers as the global scope, and each link with servers of its
|
||||
// own, its routing domains without the `~` and whether it is a default route.
|
||||
func TestRoutesReadWhatResolvedSendsWhere(t *testing.T) {
|
||||
f := &fakeResolved{
|
||||
dns: "Global: 10.42.0.1 10.42.0.3\nLink 2 (wlan0): 192.168.1.1\nLink 3 (mesh0):\nLink 7 (tun0): 10.9.0.1 10.9.0.2\n",
|
||||
domain: "Global: ~.\nLink 2 (wlan0):\nLink 3 (mesh0):\nLink 7 (tun0): ~corp.example ~cloud.example\n",
|
||||
defaults: "Link 2 (wlan0): yes\nLink 3 (mesh0): yes\nLink 7 (tun0): no\n",
|
||||
}
|
||||
got, err := aMachine(t, f).Routes(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(got.Mesh.Servers, " ") != "10.42.0.1 10.42.0.3" || strings.Join(got.Mesh.Domains, " ") != "." {
|
||||
t.Errorf("the mesh's scope is %+v", got.Mesh)
|
||||
}
|
||||
if len(got.Links) != 2 || got.Links[0].Link != "tun0" && got.Links[1].Link != "tun0" {
|
||||
t.Fatalf("the links with servers are %+v", got.Links)
|
||||
}
|
||||
for _, l := range got.Links {
|
||||
if l.Link == "tun0" && (strings.Join(l.Domains, " ") != "corp.example cloud.example" || *l.DefaultRoute) {
|
||||
t.Errorf("tun0 is %+v", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A link a network manager gave servers is kept from answering every name: its default route goes, its
|
||||
// own domains stay; a link without servers, and one already routed, are left alone.
|
||||
func TestOnlyTheMeshsResolversAnswerEveryName(t *testing.T) {
|
||||
f := &fakeResolved{
|
||||
dns: "Global: 10.42.0.1\nLink 2 (wlan0): 192.168.1.1\nLink 3 (mesh0):\nLink 7 (tun0): 10.9.0.1\n",
|
||||
domain: "Global: ~.\nLink 2 (wlan0): lan\nLink 7 (tun0): ~corp.example\n",
|
||||
defaults: "Link 2 (wlan0): yes\nLink 3 (mesh0): yes\nLink 7 (tun0): no\n",
|
||||
}
|
||||
changed, err := aMachine(t, f).OnlyTheMeshIsADefaultRoute(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(changed, " ") != "wlan0" || len(f.changed) != 1 ||
|
||||
strings.Join(f.changed[0], " ") != "resolvectl default-route wlan0 false" {
|
||||
t.Errorf("changed %v by %v", changed, f.changed)
|
||||
}
|
||||
}
|
||||
|
||||
// Taking a route away reverts the link; a link that went has nothing to take away.
|
||||
func TestUnrouteRevertsTheLink(t *testing.T) {
|
||||
f := &fakeResolved{}
|
||||
r := aMachine(t, f, "tun0")
|
||||
if _, err := r.Unroute(context.Background(), "tun0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Unroute(context.Background(), "tun1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.changed) != 1 || strings.Join(f.changed[0], " ") != "resolvectl revert tun0" {
|
||||
t.Errorf("resolved was asked %v", f.changed)
|
||||
}
|
||||
}
|
||||
|
||||
// Escalation: root runs the command as given; the runtime's account through sudo without a prompt.
|
||||
func TestChangesAreEscalatedOnlyWhenNotRoot(t *testing.T) {
|
||||
if p, a := escalated(0, "resolvectl", []string{"revert", "tun0"}); p != "resolvectl" || len(a) != 2 {
|
||||
t.Errorf("as root: %s %v", p, a)
|
||||
}
|
||||
if p, a := escalated(1000, "resolvectl", []string{"revert", "tun0"}); p != "sudo" || strings.Join(a, " ") != "-n resolvectl revert tun0" {
|
||||
t.Errorf("as the account: %s %v", p, a)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
module systemd-resolved
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -1,2 +0,0 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -1,110 +0,0 @@
|
||||
{
|
||||
"module": "systemd-resolved",
|
||||
"version": "1",
|
||||
"upgrade": {
|
||||
"policy": "record",
|
||||
"why": "the machine's names: a build that breaks this resolver stops every name resolving on a machine a person works on, the bus's included, and then neither the gate's rollback nor a push reaches it (hq ADR 0236, ADR 0247)"
|
||||
},
|
||||
"provides": [
|
||||
{
|
||||
"name": "split-dns",
|
||||
"reach": "machine"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"wildcard-resolution"
|
||||
],
|
||||
"capabilities": [
|
||||
"service-manager"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-resolver",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"routes",
|
||||
"route",
|
||||
"unroute"
|
||||
]
|
||||
}
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "dns-udp",
|
||||
"port": 53,
|
||||
"protocol": "udp",
|
||||
"from": "machine",
|
||||
"fixed": true,
|
||||
"why": "this machine's own resolver (ADR 0247), on loopback and on its private address for its own containers; never another machine's, so a VPN's domains routed here are asked by nothing beyond this machine"
|
||||
},
|
||||
{
|
||||
"name": "dns-tcp",
|
||||
"port": 53,
|
||||
"protocol": "tcp",
|
||||
"from": "machine",
|
||||
"fixed": true,
|
||||
"why": "the same names over tcp, which a resolver answers on for an answer too large for a datagram"
|
||||
}
|
||||
],
|
||||
"facts": {
|
||||
"kept": {
|
||||
"path": "/etc/node-resolver/resolv.conf",
|
||||
"template": "# Managed by the mesh, and written by the module holding this machine's own resolver\n# (module systemd-resolved, novox/hq ADR 0247). On every other machine the module holding\n# the uplink writes this file, listing the mesh's resolvers (ADR 0223); here something\n# requires names routed by domain - a VPN client's domains to its own servers - so the file\n# names this machine's own resolver alone, which sends those domains over the VPN's link and\n# every other name to the mesh's resolvers. One server listed, so one answer per name.\n#\n# Its address on the private network, not loopback: a container copies this file, and\n# this address is one it can reach. Another program writing this file is put back by the\n# module's guard, which keeps what it wrote for whoever handles it on this machine.\n# Replaced on every push; edit nothing here.\n{{$own := \"\"}}{{range .Machines}}{{if eq .Name $.Node}}{{$own = .Address}}{{end}}{{end}}nameserver {{if $own}}{{$own}}{{else}}127.0.0.53{{end}}\noptions timeout:1 attempts:2 edns0\n"
|
||||
},
|
||||
"resolved": {
|
||||
"path": "/etc/systemd/resolved.conf.d/50-mesh.conf",
|
||||
"template": "# Managed by the mesh (module systemd-resolved, novox/hq ADR 0247). Replaced on every\n# push; a drop-in of the operator's that sorts after this one overrides it, and is theirs.\n#\n# The mesh's resolvers, every one of them (ADR 0223): they answer every name no link's own\n# domains route elsewhere. ~. makes them the default route for names, and a link's servers\n# answer only the domains routed to them (the module's verb `route`).\n[Resolve]\nDNS={{range index .Holders \"mesh-dns-resolver\"}}{{.Address}} {{end}}\nDomains=~.\n# No compiled-in public fallback: a public resolver beside the mesh's is what ADR 0223\n# removed, because one of them said \"no such name\" for a mesh name and was believed.\nFallbackDNS=\n# The stub on loopback for this machine, and on its private address for its containers,\n# which cannot reach loopback. The packet filter admits this machine's own guests and\n# nobody else: another machine never asks this resolver (ADR 0247).\nDNSStubListener=yes\n{{$own := \"\"}}{{range .Machines}}{{if eq .Name $.Node}}{{$own = .Address}}{{end}}{{end}}DNSStubListenerExtra={{$own}}\n# No cache: nothing on this machine keeps a copy of a mesh name or of a \"no such name\",\n# as before this resolver - each question is asked again (ADR 0223's objection to a\n# local forwarder was a copy disagreeing with the truth).\nCache=no\n# What this machine's own programs read from /etc/hosts they read themselves; containers\n# asking here get what the mesh's resolvers say, as before.\nReadEtcHosts=no\n# Names are the mesh's resolvers' and a routed link's to answer, never the local network's\n# guesses; validation stays the upstreams' (the mesh's resolvers pass the bit through).\nLLMNR=no\nMulticastDNS=no\nDNSSEC=no\nDNSOverTLS=no\n"
|
||||
},
|
||||
"resolvers": {
|
||||
"path": "/etc/resolv.conf",
|
||||
"template": "# Managed by the mesh, and written by the module holding this machine's own resolver\n# (module systemd-resolved, novox/hq ADR 0247). On every other machine the module holding\n# the uplink writes this file, listing the mesh's resolvers (ADR 0223); here something\n# requires names routed by domain - a VPN client's domains to its own servers - so the file\n# names this machine's own resolver alone, which sends those domains over the VPN's link and\n# every other name to the mesh's resolvers. One server listed, so one answer per name.\n#\n# Its address on the private network, not loopback: a container copies this file, and\n# this address is one it can reach. Another program writing this file is put back by the\n# module's guard, which keeps what it wrote for whoever handles it on this machine.\n# Replaced on every push; edit nothing here.\n{{$own := \"\"}}{{range .Machines}}{{if eq .Name $.Node}}{{$own = .Address}}{{end}}{{end}}nameserver {{if $own}}{{$own}}{{else}}127.0.0.53{{end}}\noptions timeout:1 attempts:2 edns0\n"
|
||||
},
|
||||
"suffix": {
|
||||
"path": "/etc/node-resolver/suffix",
|
||||
"template": "{{.Suffix}}\n"
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "service",
|
||||
"type": "service",
|
||||
"unit": "systemd-resolved.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"systemd-resolved.fact-resolved"
|
||||
],
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "guard",
|
||||
"type": "process",
|
||||
"name": "systemd-resolved-guard",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./resolver-tools",
|
||||
"guard"
|
||||
],
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/resolver-tools",
|
||||
"binary": "resolver-tools",
|
||||
"loads": [
|
||||
"resolver-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -283,32 +282,97 @@ func (m *Manager) Act(scope Scope, verb, unit string) (map[string]any, error) {
|
||||
answer := map[string]any{"unit": unit, "scope": string(scope), "verb": verb, "ok": true,
|
||||
"active": after["ActiveState"], "boot": after["UnitFileState"], "mesh_declared": after["mesh_declared"]}
|
||||
if after["mesh_declared"] == true {
|
||||
answer["note"] = "the mesh declares this unit: the node-engine restores its declared state at its next apply"
|
||||
answer["note"] = "the mesh declares this unit: the host restores its declared state at its next apply"
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// Journal is the last lines of one unit's journal.
|
||||
func (m *Manager) Journal(scope Scope, unit string, lines int) (map[string]any, error) {
|
||||
// Journal is the last lines of one unit's journal that a query keeps, its secrets redacted.
|
||||
//
|
||||
// **Every argument is one word of journalctl's argv, never a shell's** (journal.go): the unit is refused
|
||||
// when it would read as an option, a window bound is given as --since=<v> so a relative "-30min" is its
|
||||
// value and never a flag, and the rest is validated to the forms journalctl reads before anything runs —
|
||||
// under sudo, a word read as an option would be root's option.
|
||||
//
|
||||
// **A match is a fixed string, applied here to the redacted lines**, not journalctl's --grep, which is a
|
||||
// pattern and depends on how journalctl was built; and applied after redaction, so a caller cannot find a
|
||||
// secret by asking which lines hold it. journalctl is then asked for a bounded scan of the window's last
|
||||
// lines, and the answer says how many were read, so a match that found fewer than asked is not read as
|
||||
// all there is when the scan was full.
|
||||
func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]any, error) {
|
||||
if err := unitArg(unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := m.call(scope, "journalctl", "--no-pager", "-n", strconv.Itoa(lines), "-u", unit, "-o", "short-iso")
|
||||
q, err := q.valid()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kept := []string{}
|
||||
read := q.Lines
|
||||
if q.Match != "" {
|
||||
read = MatchScan
|
||||
}
|
||||
out, err := m.call(scope, "journalctl", q.argv(unit, read)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known, envErr := m.unitSecrets(scope, unit)
|
||||
all := []string{}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if l != "" {
|
||||
kept = append(kept, l)
|
||||
all = append(all, l)
|
||||
}
|
||||
}
|
||||
return map[string]any{"unit": unit, "scope": string(scope), "lines": kept}, nil
|
||||
scanned := len(all)
|
||||
kept, redacted := []string{}, 0
|
||||
for _, l := range all {
|
||||
l, n := redact(l, known)
|
||||
redacted += n
|
||||
if q.Match != "" && !strings.Contains(l, q.Match) {
|
||||
continue
|
||||
}
|
||||
if len(l) > LongestLine {
|
||||
l = l[:LongestLine] + "…"
|
||||
}
|
||||
kept = append(kept, l)
|
||||
}
|
||||
if len(kept) > q.Lines {
|
||||
kept = kept[len(kept)-q.Lines:]
|
||||
}
|
||||
answer := map[string]any{"unit": unit, "scope": string(scope), "lines": kept, "count": len(kept)}
|
||||
for k, v := range map[string]string{"since": q.Since, "until": q.Until, "match": q.Match, "priority": q.Priority} {
|
||||
if v != "" {
|
||||
answer[k] = v
|
||||
}
|
||||
}
|
||||
if q.Match != "" {
|
||||
answer["scanned"] = scanned
|
||||
if scanned >= MatchScan {
|
||||
answer["note"] = fmt.Sprintf("the match was looked for in the window's last %d lines only: narrow the window to reach earlier ones", MatchScan)
|
||||
}
|
||||
}
|
||||
if envErr != nil {
|
||||
answer["redaction"] = "only what a line's shape says is a secret: the unit's environment could not be read (" + envErr.Error() + ")"
|
||||
}
|
||||
if redacted > 0 {
|
||||
answer["redacted"] = redacted
|
||||
answer["leak"] = "this unit's journal holds secrets, shown as [redacted: <what it was>]: rotate each one after the program stops printing it"
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// Failed is every failed unit in both managers. A manager that does not answer is reported as such,
|
||||
// beside the other's answer — never as "nothing failed".
|
||||
func (m *Manager) Failed() map[string]any {
|
||||
// unitSecrets are the values of the unit's own Environment= that must not be answered. Read with
|
||||
// systemctl show, which needs no escalation; a unit that does not exist has none.
|
||||
func (m *Manager) unitSecrets(scope Scope, unit string) ([]knownSecret, error) {
|
||||
out, err := m.call(scope, "systemctl", "show", unit, "--no-pager", "--property=Environment", "--value")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return secretsIn(environment(strings.TrimSpace(out))), nil
|
||||
}
|
||||
|
||||
// Failed is every failed unit in the managers asked — both when none is named. A manager that does not
|
||||
// answer is reported as such, beside the other's answer — never as "nothing failed".
|
||||
func (m *Manager) Failed(scopes ...Scope) map[string]any {
|
||||
in := func(scope Scope) any {
|
||||
units, err := m.Units(scope, "")
|
||||
if err != nil {
|
||||
@@ -322,7 +386,14 @@ func (m *Manager) Failed() map[string]any {
|
||||
}
|
||||
return failed
|
||||
}
|
||||
return map[string]any{"system": in(System), "user": in(User)}
|
||||
if len(scopes) == 0 {
|
||||
scopes = []Scope{System, User}
|
||||
}
|
||||
answer := map[string]any{}
|
||||
for _, s := range scopes {
|
||||
answer[string(s)] = in(s)
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
// unitArg refuses a unit name systemctl or journalctl would read as an option — which under sudo would be
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestTheUserScopeIsPlainUserWithTheAccountsRuntimeDirectoryAndBus(t *testing
|
||||
if !strings.Contains(env, "XDG_RUNTIME_DIR=/run/user/1234") || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1234/bus") || !strings.Contains(env, "HOME=/h") {
|
||||
t.Fatalf("%v", calls[0].env)
|
||||
}
|
||||
if _, err := m.Journal(User, "watcher.service", 10); err != nil {
|
||||
if _, err := m.Journal(User, "watcher.service", JournalQuery{Lines: 10}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls[1].cmd != "journalctl" || calls[1].args[0] != "--user" {
|
||||
@@ -155,7 +155,7 @@ func TestTheRestoreNoteIsOnlyOnAUnitTheMeshDeclares(t *testing.T) {
|
||||
return Ran{}
|
||||
}, nil))
|
||||
r, _ := m.Act(System, "stop", "showcase.service")
|
||||
if r["mesh_declared"] != true || !strings.Contains(r["note"].(string), "node-engine restores its declared state") {
|
||||
if r["mesh_declared"] != true || !strings.Contains(r["note"].(string), "host restores its declared state") {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
}
|
||||
@@ -242,7 +242,7 @@ func TestAUnitsNameIsNeverAnOption(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest owns the systemd package, claims the seat's eight verbs, and lists exactly the tools served.
|
||||
// The manifest owns the systemd package, claims the seat's nine verbs, and lists exactly the tools served.
|
||||
func TestTheManifestOwnsThePackageAndListsWhatIsServed(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
// What the journal verb may be asked (the operator's direction 2026-10-07, recorded in novox/hq): a time
|
||||
// window, a priority and a fixed text, beside the unit and how many lines.
|
||||
//
|
||||
// **Nothing a caller says reaches a shell, and nothing is read as an option.** journalctl is run with an
|
||||
// argv of its own words (execRunner), under `sudo -n` for the system journal (issue 255) — so a value that
|
||||
// journalctl read as an option would be root's option. Every value is therefore held to the forms
|
||||
// journalctl reads for it and given as `--name=value`, one word: a relative "-30min" is the value of
|
||||
// --since, never a flag. A value in any other form is refused naming the forms, before anything runs.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MostLines is the most lines one answer carries: well below what the runtime carries back in one reply.
|
||||
MostLines = 2000
|
||||
// DefaultLines is how many when the caller does not say.
|
||||
DefaultLines = 100
|
||||
// MatchScan is how many of the window's last lines a match is looked for in.
|
||||
MatchScan = 50000
|
||||
// LongestLine is where one line is cut, so a single runaway line cannot fill the answer.
|
||||
LongestLine = 4096
|
||||
// LongestMatch is the longest text a match may be.
|
||||
LongestMatch = 256
|
||||
)
|
||||
|
||||
// JournalQuery is what a journal read is narrowed by.
|
||||
type JournalQuery struct {
|
||||
Lines int
|
||||
Since string
|
||||
Until string
|
||||
Match string
|
||||
Priority string
|
||||
}
|
||||
|
||||
// relative is a time journalctl reads relative to now: -30min, +1h, 2h30min ago, and the day words.
|
||||
var relative = regexp.MustCompile(`^(now|today|yesterday|tomorrow|[+-]?([0-9]+(usec|us|msec|ms|seconds|second|sec|s|minutes|minute|min|m|hours|hour|hr|h|days|day|d|weeks|week|w|months|month|M|years|year|y))+|([0-9]+(usec|us|msec|ms|seconds|second|sec|s|minutes|minute|min|m|hours|hour|hr|h|days|day|d|weeks|week|w|months|month|M|years|year|y) ?)+ago)$`)
|
||||
|
||||
// localDate is a date, or a date and a time, in the machine's own zone, as journalctl reads it.
|
||||
var localDate = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}( [0-9]{2}:[0-9]{2}(:[0-9]{2})?)?$`)
|
||||
|
||||
// priorities are journalctl's priority names, and the words people use for them.
|
||||
var priorities = map[string]int{
|
||||
"emerg": 0, "emergency": 0, "panic": 0, "alert": 1, "crit": 2, "critical": 2, "err": 3, "error": 3,
|
||||
"warning": 4, "warn": 4, "notice": 5, "info": 6, "debug": 7,
|
||||
}
|
||||
|
||||
// when is one bound of the window as journalctl is given it, and the absolute time it names when it is
|
||||
// one: an RFC 3339 time becomes seconds since the epoch, which every journalctl reads whatever its version.
|
||||
func when(name, v string, ceil bool) (string, *time.Time, error) {
|
||||
if t, err := time.Parse(time.RFC3339Nano, v); err == nil {
|
||||
s := t.Unix()
|
||||
if ceil && t.Nanosecond() > 0 {
|
||||
s++
|
||||
}
|
||||
return "@" + strconv.FormatInt(s, 10), &t, nil
|
||||
}
|
||||
if relative.MatchString(v) || localDate.MatchString(v) {
|
||||
return v, nil, nil
|
||||
}
|
||||
return "", nil, fmt.Errorf("%s %q: an RFC 3339 time (2026-10-07T09:30:00Z), a time relative to now "+
|
||||
"(-30min, -2h, 1h ago, yesterday) or a local date (2026-10-07 09:30)", name, v)
|
||||
}
|
||||
|
||||
// valid is the query with its defaults applied and every value held to its forms.
|
||||
func (q JournalQuery) valid() (JournalQuery, error) {
|
||||
switch {
|
||||
case q.Lines == 0:
|
||||
q.Lines = DefaultLines
|
||||
case q.Lines < 0:
|
||||
return q, fmt.Errorf("lines %d: at least 1", q.Lines)
|
||||
case q.Lines > MostLines:
|
||||
q.Lines = MostLines
|
||||
}
|
||||
var since, until *time.Time
|
||||
var err error
|
||||
if q.Since != "" {
|
||||
if _, since, err = when("since", q.Since, false); err != nil {
|
||||
return q, err
|
||||
}
|
||||
}
|
||||
if q.Until != "" {
|
||||
if _, until, err = when("until", q.Until, true); err != nil {
|
||||
return q, err
|
||||
}
|
||||
}
|
||||
if since != nil && until != nil && until.Before(*since) {
|
||||
return q, fmt.Errorf("the window ends (%s) before it starts (%s)", q.Until, q.Since)
|
||||
}
|
||||
if q.Priority != "" {
|
||||
p := strings.ToLower(q.Priority)
|
||||
if n, ok := priorities[p]; ok {
|
||||
q.Priority = strconv.Itoa(n)
|
||||
} else if len(p) != 1 || p[0] < '0' || p[0] > '7' {
|
||||
return q, fmt.Errorf("priority %q: 0-7, or emerg, alert, crit, err, warning, notice, info, debug", q.Priority)
|
||||
}
|
||||
}
|
||||
if len(q.Match) > LongestMatch {
|
||||
return q, fmt.Errorf("a match is at most %d bytes", LongestMatch)
|
||||
}
|
||||
if strings.ContainsAny(q.Match, "\n\r\x00") {
|
||||
return q, fmt.Errorf("a match is one line of text")
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// argv is journalctl's words for a valid query: each value inside the word of its own option.
|
||||
func (q JournalQuery) argv(unit string, lines int) []string {
|
||||
args := []string{"--no-pager", "--output=short-iso", "--unit=" + unit}
|
||||
if q.Since != "" {
|
||||
v, _, _ := when("since", q.Since, false)
|
||||
args = append(args, "--since="+v)
|
||||
}
|
||||
if q.Until != "" {
|
||||
v, _, _ := when("until", q.Until, true)
|
||||
args = append(args, "--until="+v)
|
||||
}
|
||||
if q.Priority != "" {
|
||||
args = append(args, "--priority="+q.Priority)
|
||||
}
|
||||
return append(args, "--lines="+strconv.Itoa(lines))
|
||||
}
|
||||
|
||||
// journalQuery is the query a call's arguments say. A number may come as a JSON number or as its text:
|
||||
// the seat's schema carries every argument as a string.
|
||||
func journalQuery(a map[string]any) (JournalQuery, error) {
|
||||
q := JournalQuery{}
|
||||
switch v := a["lines"].(type) {
|
||||
case nil:
|
||||
case float64:
|
||||
if v != float64(int(v)) {
|
||||
return q, fmt.Errorf("lines %v: a whole number", v)
|
||||
}
|
||||
q.Lines = int(v)
|
||||
case string:
|
||||
if strings.TrimSpace(v) != "" {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return q, fmt.Errorf("lines %q: a whole number", v)
|
||||
}
|
||||
q.Lines = n
|
||||
}
|
||||
default:
|
||||
return q, fmt.Errorf("lines: a whole number")
|
||||
}
|
||||
for name, into := range map[string]*string{"since": &q.Since, "until": &q.Until, "match": &q.Match, "priority": &q.Priority} {
|
||||
switch v := a[name].(type) {
|
||||
case nil:
|
||||
case string:
|
||||
if name == "match" {
|
||||
*into = v
|
||||
} else {
|
||||
*into = strings.TrimSpace(v)
|
||||
}
|
||||
case float64:
|
||||
if name != "priority" {
|
||||
return q, fmt.Errorf("%s: text", name)
|
||||
}
|
||||
*into = strconv.FormatFloat(v, 'f', -1, 64)
|
||||
default:
|
||||
return q, fmt.Errorf("%s: text", name)
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package main
|
||||
|
||||
// The journal verb's window, priority and match (the operator's direction 2026-10-07): every value one
|
||||
// word of journalctl's argv, nothing read as an option, the cap kept, a secret never answered — and
|
||||
// what has failed on the seat.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// journalOf is a manager whose journalctl answers the given lines and whose unit has the given
|
||||
// Environment=, keeping each call.
|
||||
func journalOf(lines []string, env string, calls *[]call) *Manager {
|
||||
return operator(fake(func(c call) Ran {
|
||||
if contains(c.args, "journalctl") || c.cmd == "journalctl" {
|
||||
return Ran{Stdout: strings.Join(lines, "\n") + "\n"}
|
||||
}
|
||||
if contains(c.args, "--property=Environment") {
|
||||
return Ran{Stdout: env + "\n"}
|
||||
}
|
||||
return Ran{}
|
||||
}, calls))
|
||||
}
|
||||
|
||||
func journalArgs(t *testing.T, calls []call) []string {
|
||||
t.Helper()
|
||||
for _, c := range calls {
|
||||
if c.cmd == "sudo" && len(c.args) > 1 && c.args[1] == "journalctl" {
|
||||
return c.args[2:]
|
||||
}
|
||||
if c.cmd == "journalctl" {
|
||||
return c.args
|
||||
}
|
||||
}
|
||||
t.Fatalf("journalctl was not run: %+v", calls)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAWindowAndAPriorityAreEachOneWordOfJournalctl(t *testing.T) {
|
||||
var calls []call
|
||||
m := journalOf([]string{"a"}, "", &calls)
|
||||
_, err := m.Journal(System, "mail.service", JournalQuery{Lines: 50, Since: "-30min", Until: "2026-10-07T10:00:00.5Z", Priority: "warning"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := strings.Join(journalArgs(t, calls), " ")
|
||||
want := "--no-pager --output=short-iso --unit=mail.service --since=-30min --until=@1791367201 --priority=4 --lines=50"
|
||||
if got != want {
|
||||
t.Fatalf("journalctl was given\n %s\nnot\n %s", got, want)
|
||||
}
|
||||
if calls[0].cmd != "sudo" || calls[0].args[0] != "-n" {
|
||||
t.Fatalf("the system journal was not read escalated: %+v", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFormsAWindowIsReadIn(t *testing.T) {
|
||||
for _, ok := range []string{"-30min", "-2h", "+1h", "-1h30min", "2h ago", "30min ago", "yesterday", "now", "today",
|
||||
"2026-10-07T09:30:00Z", "2026-10-07T09:30:00+02:00", "2026-10-07", "2026-10-07 09:30", "2026-10-07 09:30:15"} {
|
||||
if _, err := (JournalQuery{Since: ok}).valid(); err != nil {
|
||||
t.Errorf("%q refused: %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"--user", "-u", "-D/etc", "--directory=/", "-30min --merge", "-30min;id", "$(id)",
|
||||
"-x", "--", "1h; rm", "2026-10-07T09:30:00", "last week", "-30min\n--merge"} {
|
||||
if _, err := (JournalQuery{Since: bad}).valid(); err == nil {
|
||||
t.Errorf("since %q accepted", bad)
|
||||
}
|
||||
if _, err := (JournalQuery{Until: bad}).valid(); err == nil {
|
||||
t.Errorf("until %q accepted", bad)
|
||||
}
|
||||
}
|
||||
if _, err := (JournalQuery{Since: "2026-10-07T10:00:00Z", Until: "2026-10-07T09:00:00Z"}).valid(); err == nil {
|
||||
t.Error("a window ending before it starts was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityIsANumberOrAName(t *testing.T) {
|
||||
for in, want := range map[string]string{"0": "0", "7": "7", "err": "3", "ERROR": "3", "warning": "4", "debug": "7", "emerg": "0"} {
|
||||
q, err := (JournalQuery{Priority: in}).valid()
|
||||
if err != nil || q.Priority != want {
|
||||
t.Errorf("%q: %q %v", in, q.Priority, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"8", "-1", "--user", "3..5", "loud", "33"} {
|
||||
if _, err := (JournalQuery{Priority: bad}).valid(); err == nil {
|
||||
t.Errorf("priority %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingRunsWhenAValueIsRefused(t *testing.T) {
|
||||
var calls []call
|
||||
m := journalOf(nil, "", &calls)
|
||||
for _, q := range []JournalQuery{{Since: "--merge"}, {Until: "-D/"}, {Priority: "--user"}, {Lines: -1},
|
||||
{Match: "a\nb"}, {Match: strings.Repeat("x", LongestMatch+1)}} {
|
||||
if _, err := m.Journal(System, "x.service", q); err == nil {
|
||||
t.Errorf("%+v accepted", q)
|
||||
}
|
||||
}
|
||||
if _, err := m.Journal(System, "--merge", JournalQuery{}); err == nil {
|
||||
t.Error("an option was accepted as a unit")
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("something ran: %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCapIsKeptAndTheDefaultIsAHundred(t *testing.T) {
|
||||
for in, want := range map[int]int{0: DefaultLines, 1: 1, 2000: 2000, 2001: 2000, 1 << 30: 2000} {
|
||||
q, err := (JournalQuery{Lines: in}).valid()
|
||||
if err != nil || q.Lines != want {
|
||||
t.Errorf("%d: %d %v", in, q.Lines, err)
|
||||
}
|
||||
}
|
||||
// What the seat's schema carries is text, and an agent may send a number: both read the same.
|
||||
for _, a := range []map[string]any{{"lines": "250"}, {"lines": float64(250)}} {
|
||||
q, err := journalQuery(a)
|
||||
if err != nil || q.Lines != 250 {
|
||||
t.Errorf("%v: %+v %v", a, q, err)
|
||||
}
|
||||
}
|
||||
for _, a := range []map[string]any{{"lines": "many"}, {"lines": 2.5}, {"since": float64(3)}, {"match": []any{"x"}}} {
|
||||
if _, err := journalQuery(a); err == nil {
|
||||
t.Errorf("%v accepted", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMatchIsAFixedStringOverTheWindowsLastLines(t *testing.T) {
|
||||
var calls []call
|
||||
lines := []string{"one (a.b)", "two a.b", "three axb", "four (a.b)"}
|
||||
r, err := journalOf(lines, "", &calls).Journal(System, "x.service", JournalQuery{Lines: 1, Match: "(a.b)"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r["lines"].([]string); len(got) != 1 || got[0] != "four (a.b)" {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
if r["scanned"] != 4 || r["count"] != 1 || r["match"] != "(a.b)" {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
if a := journalArgs(t, calls); a[len(a)-1] != fmt.Sprintf("--lines=%d", MatchScan) {
|
||||
t.Fatalf("a match was not looked for over a scan: %v", a)
|
||||
}
|
||||
for _, a := range journalArgs(t, calls) {
|
||||
if strings.Contains(a, "a.b") || strings.HasPrefix(a, "--grep") {
|
||||
t.Fatalf("the match reached journalctl: %v", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecretTheUnitPrintedIsNeverAnsweredNorFoundByAMatch(t *testing.T) {
|
||||
env := `HOME=/var/lib/x "DB_PASSWORD=hunter2hunter2" DATABASE_URL=postgres://app:s3cr3tpw@db/app PORT=5432`
|
||||
lines := []string{
|
||||
"starting with password hunter2hunter2",
|
||||
"connecting to postgres://app:s3cr3tpw@db/app",
|
||||
"proxy at https://u:otherpassword@example.test/",
|
||||
"ran: tool --password=flagsecret1 --token tokensecret2",
|
||||
"API_TOKEN=abcdefghij set",
|
||||
"nothing secret here",
|
||||
}
|
||||
r, err := journalOf(lines, env, nil).Journal(System, "x.service", JournalQuery{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all := strings.Join(r["lines"].([]string), "\n")
|
||||
for _, secret := range []string{"hunter2hunter2", "s3cr3tpw", "otherpassword", "flagsecret1", "tokensecret2", "abcdefghij"} {
|
||||
if strings.Contains(all, secret) {
|
||||
t.Errorf("%s was answered:\n%s", secret, all)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(all, "[redacted: DB_PASSWORD]") || !strings.Contains(all, "nothing secret here") {
|
||||
t.Fatalf("%s", all)
|
||||
}
|
||||
if r["redacted"] == nil || r["leak"] == nil {
|
||||
t.Fatalf("the redaction was not said: %v", r)
|
||||
}
|
||||
found, _ := journalOf(lines, env, nil).Journal(System, "x.service", JournalQuery{Match: "hunter2"})
|
||||
if found["count"] != 0 {
|
||||
t.Fatalf("a match found a secret: %v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnreadableEnvironmentStillRedactsByShapeAndSaysSo(t *testing.T) {
|
||||
m := operator(fake(func(c call) Ran {
|
||||
if contains(c.args, "--property=Environment") {
|
||||
return Ran{Status: 1, Stderr: "Failed to get properties: Access denied\n"}
|
||||
}
|
||||
return Ran{Stdout: "postgres://app:s3cr3tpw@db/app\n"}
|
||||
}, nil))
|
||||
r, err := m.Journal(System, "x.service", JournalQuery{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(strings.Join(r["lines"].([]string), ""), "s3cr3tpw") || r["redaction"] == nil {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALongLineIsCut(t *testing.T) {
|
||||
r, _ := journalOf([]string{strings.Repeat("y", LongestLine+10)}, "", nil).Journal(System, "x.service", JournalQuery{})
|
||||
if l := r["lines"].([]string)[0]; len(l) > LongestLine+len("…") {
|
||||
t.Fatalf("a line of %d bytes", len(l))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheEnvironmentPropertyIsReadAsWords(t *testing.T) {
|
||||
got := environment(`A=1 "B=two words" 'C=x\'y' D=`)
|
||||
if strings.Join(got, "|") != "A=1|B=two words|C=x'y|D=" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedIsOnTheSeatAndNarrowsToAScope(t *testing.T) {
|
||||
var calls []call
|
||||
m := operator(fake(func(call) Ran { return Ran{Stdout: list} }, &calls))
|
||||
var failed func(map[string]any) (any, error)
|
||||
for _, tool := range tools(m) {
|
||||
if tool.Name == seat+".failed" {
|
||||
failed = tool.Run
|
||||
}
|
||||
if tool.Name == "systemd_failed" {
|
||||
t.Fatal("the module still serves its own systemd_failed beside the seat's verb")
|
||||
}
|
||||
}
|
||||
if failed == nil {
|
||||
t.Fatal("the seat's failed is not served")
|
||||
}
|
||||
both, err := failed(map[string]any{})
|
||||
if err != nil || len(both.(map[string]any)) != 2 {
|
||||
t.Fatalf("%v %v", both, err)
|
||||
}
|
||||
calls = nil
|
||||
one, err := failed(map[string]any{"scope": "system"})
|
||||
if err != nil || len(one.(map[string]any)) != 1 || len(calls) != 1 || contains(calls[0].args, "--user") {
|
||||
t.Fatalf("%v %v %+v", one, err, calls)
|
||||
}
|
||||
if _, err := failed(map[string]any{"scope": "everything"}); err == nil {
|
||||
t.Fatal("a scope that is none was accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// systemd's tools: the node-service-manager seat's eight verbs — the units on this machine in both scopes,
|
||||
// read and acted on by name — and the module's own reading of what has failed (novox/hq ADR 0177). The node
|
||||
// systemd's tools: the node-service-manager seat's nine verbs — the units on this machine in both scopes,
|
||||
// read and acted on by name, their journal, and what has failed (novox/hq ADR 0177). The node
|
||||
// tools runtime launches this bundle as a process of its own and serves what it serves (ADR 0188, ADR 0193);
|
||||
// it runs as the operator account, so acts on the system manager, and reads of its journal, escalate with
|
||||
// sudo -n, and the user scope is the account's own manager (client.go). The host applies units; this answers
|
||||
@@ -92,15 +92,22 @@ func tools(m *Manager) []stdio.Tool {
|
||||
}
|
||||
return m.Status(scope, unit)
|
||||
}},
|
||||
act("start", "Start one unit. For a unit the mesh declares, the answer says the node-engine will restore what its declaration says at its next apply."),
|
||||
act("stop", "Stop one unit; for a unit the mesh declares, the answer says the node-engine will restore its declared state."),
|
||||
act("start", "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at its next apply."),
|
||||
act("stop", "Stop one unit; for a unit the mesh declares, the answer says the host will restore its declared state."),
|
||||
act("restart", "Restart one unit."),
|
||||
act("enable", "Make one unit start at boot (or at the account's login, in user scope)."),
|
||||
act("disable", "Stop one unit starting at boot (or at login, in user scope)."),
|
||||
{Name: seat + ".journal",
|
||||
Description: "The last lines of one unit's journal (at most 2000) — a system service's included: the read is escalated, so it is the service's own lines and not only the operator account's.",
|
||||
Description: "The last lines of one unit's journal (at most 2000), in a time window and narrowed to a priority " +
|
||||
"and to lines holding a text when asked — a system service's included: the read is escalated, so it is the " +
|
||||
"service's own lines and not only the operator account's. A secret the unit printed is shown as " +
|
||||
"[redacted: <what it was>].",
|
||||
Input: map[string]any{"scope": scopeArg, "unit": unitArgS,
|
||||
"lines": map[string]any{"type": "number", "description": "how many lines from the end (default 100, at most 2000)"}},
|
||||
"lines": str("how many lines from the end of what matches (default 100, at most 2000)"),
|
||||
"since": str("the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)"),
|
||||
"until": str("the window's end, in the same forms (optional; now when absent)"),
|
||||
"match": str("only the lines holding this text, as written — a fixed string, not a pattern (optional)"),
|
||||
"priority": str("only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
scope, err := scopeOf(a)
|
||||
if err != nil {
|
||||
@@ -110,16 +117,27 @@ func tools(m *Manager) []stdio.Tool {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Bounded so the answer stays well below what the runtime carries back in one reply.
|
||||
n := 100
|
||||
if v, ok := a["lines"].(float64); ok && v >= 1 {
|
||||
n = min(int(v), 2000)
|
||||
q, err := journalQuery(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Journal(scope, unit, n)
|
||||
return m.Journal(scope, unit, q)
|
||||
}},
|
||||
{Name: "systemd_failed",
|
||||
// Was the module's own systemd_failed; on the seat since the operator's direction of 2026-10-07, so
|
||||
// whatever holds the role answers it and every machine is asked the same way.
|
||||
{Name: seat + ".failed",
|
||||
Description: "Every failed unit on this machine, in the system manager and in the operator account's; a manager that does not answer is reported with its error, not as nothing failed.",
|
||||
Run: func(map[string]any) (any, error) { return m.Failed(), nil }},
|
||||
Input: map[string]any{"scope": str(`"system" or "user": only that manager (both when absent)`)},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
if s, _ := a["scope"].(string); s == "" {
|
||||
return m.Failed(), nil
|
||||
}
|
||||
scope, err := scopeOf(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Failed(scope), nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package main
|
||||
|
||||
// A unit's secrets in its own journal (novox/hq issue 268, issue 282, as the docker module reads a
|
||||
// container's log).
|
||||
//
|
||||
// **The leak this hides.** Software prints what it was given — a server announcing its password, a
|
||||
// script echoing the URI it connects with, a command line logged with its password flag — and the
|
||||
// journal keeps it. The journal verb's answer is read by agents and kept in their transcripts, which
|
||||
// would make it a second copy of the leak; and with a window and a filter on the verb, a caller could
|
||||
// otherwise go looking for one.
|
||||
//
|
||||
// **What is known here.** The values of the unit's own Environment= named like a secret (PASSWORD,
|
||||
// SECRET, TOKEN, KEY, …) and the password inside any URI one of them holds; and, whatever the source,
|
||||
// what a line carries by its shape: a credential-bearing URI (`scheme://user:password@`), the word after
|
||||
// a flag that takes a password, a NAME=value whose name says secret. A secret given only in an
|
||||
// EnvironmentFile= or a credential is not known — the unit's files are root's — and is caught only by
|
||||
// its shape.
|
||||
//
|
||||
// Copied from the docker module's secrets.go and cmdline.go, narrowed to what a journal line needs: each
|
||||
// module is its own Go module, and the two share no package.
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// secretName is a variable name that says its value is a secret.
|
||||
var secretName = regexp.MustCompile(`(?i)(pass(word|wd|phrase)?|secret|token|api_?key|private_?key|access_?key|credential|auth)`)
|
||||
|
||||
// notAValue is a name that says its value is where a secret is, not the secret: a file or a path.
|
||||
var notAValue = regexp.MustCompile(`(?i)(_FILE|FILE|_PATH|_DIR)$`)
|
||||
|
||||
// uriPassword is a URI carrying a password in its userinfo: scheme://user:password@.
|
||||
var uriPassword = regexp.MustCompile(`[A-Za-z][A-Za-z0-9+.-]*://[^\s/:@'"]*:([^\s/@'"]+)@`)
|
||||
|
||||
// masked is a password a program already hid: ***, xxx, <redacted>, [REDACTED].
|
||||
var masked = regexp.MustCompile(`^(\*+|x+|X+|<[^>]*>|\[[^\]]*\]|%2A+)$`)
|
||||
|
||||
// ordinary is a value under a secret's name that is not one: a path, an address, a number, a switch.
|
||||
var ordinary = regexp.MustCompile(`^(/.*|[A-Za-z][A-Za-z0-9+.-]*://.*|[0-9.]+[a-z]?|(?i:true|false|yes|no|on|off|none|null))$`)
|
||||
|
||||
// leastSecret is the shortest value compared as a secret: a shorter one matches ordinary words.
|
||||
const leastSecret = 6
|
||||
|
||||
// passwordFlags take a secret as their next word, or after `=`, whatever the program.
|
||||
var passwordFlags = map[string]bool{
|
||||
"-P": true, "--password": true, "--pass": true, "--passwd": true, "--secret": true, "--secret-key": true,
|
||||
"--token": true, "--api-key": true, "--apikey": true, "--auth": true,
|
||||
}
|
||||
|
||||
// knownSecret is one value a unit was given, by the name it came under.
|
||||
type knownSecret struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
// secretsIn are the values in a unit's environment that must never appear in what it answers.
|
||||
func secretsIn(env []string) []knownSecret {
|
||||
var out []knownSecret
|
||||
seen := map[string]bool{}
|
||||
add := func(name, value string) {
|
||||
if len(value) < leastSecret || masked.MatchString(value) || seen[name+"\x00"+value] {
|
||||
return
|
||||
}
|
||||
seen[name+"\x00"+value] = true
|
||||
out = append(out, knownSecret{name, value})
|
||||
}
|
||||
for _, e := range env {
|
||||
name, value, ok := strings.Cut(e, "=")
|
||||
if !ok || value == "" {
|
||||
continue
|
||||
}
|
||||
for _, m := range uriPassword.FindAllStringSubmatch(value, -1) {
|
||||
add(name+" (the password in its URI)", m[1])
|
||||
if dec, err := url.PathUnescape(m[1]); err == nil && dec != m[1] {
|
||||
add(name+" (the password in its URI)", dec)
|
||||
}
|
||||
}
|
||||
if secretName.MatchString(name) && !notAValue.MatchString(name) && !ordinary.MatchString(value) {
|
||||
add(name, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// environment is the words of systemd's Environment= property as `systemctl show --value` prints it:
|
||||
// separated by spaces, a word holding one quoted in C style.
|
||||
func environment(value string) []string {
|
||||
var out []string
|
||||
var word strings.Builder
|
||||
quote := byte(0)
|
||||
in := false
|
||||
for i := 0; i < len(value); i++ {
|
||||
c := value[i]
|
||||
switch {
|
||||
case quote != 0 && c == '\\' && i+1 < len(value):
|
||||
i++
|
||||
word.WriteByte(value[i])
|
||||
case quote != 0 && c == quote:
|
||||
quote = 0
|
||||
case quote == 0 && (c == '"' || c == '\''):
|
||||
quote, in = c, true
|
||||
case quote == 0 && (c == ' ' || c == '\t' || c == '\n'):
|
||||
if in {
|
||||
out = append(out, word.String())
|
||||
word.Reset()
|
||||
in = false
|
||||
}
|
||||
default:
|
||||
word.WriteByte(c)
|
||||
in = true
|
||||
}
|
||||
}
|
||||
if in {
|
||||
out = append(out, word.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// forms are the ways a value may appear printed: as given, and URL-encoded.
|
||||
func forms(value string) []string {
|
||||
out := []string{value}
|
||||
for _, f := range []string{url.QueryEscape(value), url.PathEscape(value)} {
|
||||
if f != value && !has(out, f) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func has(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shaped are the values a line carries by their shape: the word after a password flag, or the value of
|
||||
// one given with `=`, and a NAME=value whose name says secret.
|
||||
func shaped(line string) []knownSecret {
|
||||
var out []knownSecret
|
||||
add := func(name, value string) {
|
||||
value = strings.Trim(value, `"',;`)
|
||||
if len(value) < leastSecret || masked.MatchString(value) || ordinary.MatchString(value) {
|
||||
return
|
||||
}
|
||||
out = append(out, knownSecret{name, value})
|
||||
}
|
||||
words := strings.Fields(line)
|
||||
for i, w := range words {
|
||||
if flag, value, ok := strings.Cut(w, "="); ok && strings.HasPrefix(flag, "-") {
|
||||
if passwordFlags[flag] {
|
||||
add("the value of "+flag, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if name, value, ok := strings.Cut(w, "="); ok && name != "" && secretName.MatchString(name) &&
|
||||
!notAValue.MatchString(name) && !strings.ContainsAny(name, "/:") {
|
||||
add("the value of "+name, value)
|
||||
continue
|
||||
}
|
||||
if i+1 < len(words) && passwordFlags[w] {
|
||||
add("the word after "+w, words[i+1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// redact is a line with every known secret, every value its shape says is one, and every password
|
||||
// inside a URI replaced by a mark naming what was there; and how many were replaced.
|
||||
func redact(line string, known []knownSecret) (string, int) {
|
||||
n := 0
|
||||
replace := func(s knownSecret) {
|
||||
for _, f := range forms(s.Value) {
|
||||
if c := strings.Count(line, f); c > 0 {
|
||||
line = strings.ReplaceAll(line, f, "[redacted: "+s.Name+"]")
|
||||
n += c
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range known {
|
||||
replace(s)
|
||||
}
|
||||
for _, s := range shaped(line) {
|
||||
replace(s)
|
||||
}
|
||||
line = uriPassword.ReplaceAllStringFunc(line, func(m string) string {
|
||||
sub := uriPassword.FindStringSubmatch(m)
|
||||
if masked.MatchString(sub[1]) || strings.HasPrefix(sub[1], "[redacted") {
|
||||
return m
|
||||
}
|
||||
n++
|
||||
return strings.TrimSuffix(m, sub[1]+"@") + "[redacted: a password in a URI]@"
|
||||
})
|
||||
return line, n
|
||||
}
|
||||
@@ -17,13 +17,11 @@
|
||||
"restart",
|
||||
"enable",
|
||||
"disable",
|
||||
"journal"
|
||||
"journal",
|
||||
"failed"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"systemd_failed"
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# The words the glossary retired for the descriptions of the mesh's tools (novox/hq ADR 0244): its
|
||||
# *Not:* words with no scope or with (tools). A copy, so a catalogue merge needs nothing else; novox/hq's
|
||||
# words.py compares it with the glossary. Regenerate with: python3 00-META/checks/words.py --list tools
|
||||
build machine
|
||||
change plan
|
||||
control plane
|
||||
flavor
|
||||
host agent
|
||||
mesh-console
|
||||
node host
|
||||
node tools
|
||||
release plan
|
||||
substrate
|
||||
tool bridge
|
||||
tools-sdk
|
||||
Reference in New Issue
Block a user