diff --git a/cmd/mesh-controller/acts.go b/cmd/mesh-controller/acts.go index 6b1aa6da..9b06b742 100644 --- a/cmd/mesh-controller/acts.go +++ b/cmd/mesh-controller/acts.go @@ -51,6 +51,9 @@ func assignWith(ctx context.Context, open *stores, node string, opts assignOptio if len(modules) == 0 { return "", fmt.Errorf("assign %s names no module", node) } + if err := refusedMovingTheController(modules); err != nil { + return "", err + } // Held while it is recorded, so it cannot land between a converge's preview and its flip and // be taken without ever having been previewed (novox/hq ADR 0100). ctx, release, err := holdNodes(ctx, open, []string{node}) @@ -209,6 +212,9 @@ func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, mo // (novox/hq ADR 0207) — the other side of refusing that module's assignment without one. Several // modules in one act are judged together, so a holder and its dependents come off in one command. func unassign(ctx context.Context, open *stores, node string, modules ...string) (string, error) { + if err := refusedMovingTheController(modules); err != nil { + return "", err + } if len(modules) == 0 { return "", fmt.Errorf("unassign %s names no module", node) } @@ -360,3 +366,17 @@ func issueOnAssign(ctx context.Context, open *stores, node, module string) strin } return fmt.Sprintf("its bus credential is issued and sealed to %s, and arrives with the push", node) } + +// refusedMovingTheController refuses assigning or unassigning the controller's module through a verb (review of +// novox/hq ADR 0272): the node it is assigned to is the control-node, and the control-node's operator account is +// the controller's terminal, so whoever moves the module chooses the terminal. At the terminal alone, as every +// change that says who may change what. +func refusedMovingTheController(modules []string) error { + verb, through := throughAVerb() + if !through || !slices.Contains(modules, controllerModule) { + return nil + } + return fmt.Errorf("%s is assigned and unassigned at the controller's terminal only (mesh-cli on the control-node), "+ + "never through a verb (this came through %q): the node it runs on is the control-node, whose operator is the "+ + "terminal (novox/hq ADR 0272). Nothing was assigned", controllerModule, verb) +} diff --git a/cmd/mesh-controller/build_source.go b/cmd/mesh-controller/build_source.go index 43cae8e0..6cc7685f 100644 --- a/cmd/mesh-controller/build_source.go +++ b/cmd/mesh-controller/build_source.go @@ -47,10 +47,20 @@ const servedVar = "MESH_SERVED_BY_THE_CONTROLLER" // not anything it started, not a verb's command, not a seat call's. The serving controller marks its own // environment (servedVar), so a build asked in it, or by any process it starts, never reads as the terminal's; // runVerb also names the verb and the caller. +// +// **And a line mesh-cli asked as the controller's terminal** (novox/hq ADR 0272 §4): the serving controller runs it +// without the served mark and without a verb, names its caller, and marks it with cliTerminalVar — a mark only the +// mesh-cli path sets and every other command line the controller runs is stripped of (commandEnvironment). func startedAtTheTerminal() bool { - return os.Getenv(servedVar) == "" && os.Getenv(verbVar) == "" && os.Getenv(link.CallerVar) == "" + if os.Getenv(servedVar) != "" || os.Getenv(verbVar) != "" { + return false + } + return os.Getenv(link.CallerVar) == "" || os.Getenv(cliTerminalVar) != "" } +// cliTerminalVar marks a command line the serving controller runs as the controller's terminal for mesh-cli. +const cliTerminalVar = "MESH_CLI_TERMINAL" + // markServed marks this process, and so everything it starts, as the serving controller's. func markServed() { if err := os.Setenv(servedVar, "1"); err != nil { diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index b480107b..2c03372a 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -216,6 +216,10 @@ func run() error { func usage() { fmt.Fprint(os.Stderr, `mesh-controller — the control plane + On a node the operator types these as 'mesh-cli ' (in zsh, 'nox '): asked + through the node's engine, and run as the controller's terminal only on the control-node + (novox/hq ADR 0272). + migrate bring each context's schema up to date prepare the same, asked the way the mesh asks any module (ADR 0135) node add [--adopted] create a node record; --adopted: the machine is in use diff --git a/cmd/mesh-controller/meshcli.go b/cmd/mesh-controller/meshcli.go new file mode 100644 index 00000000..97c979d5 --- /dev/null +++ b/cmd/mesh-controller/meshcli.go @@ -0,0 +1,255 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "slices" + "strings" + + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// The operator's command, `mesh-cli` (alias `nox`), answered here (novox/hq ADR 0272). +// +// mesh-cli asks the node-engine on its own machine; the engine reads the asking account from the kernel and asks +// this controller on its own node's subject. Here it is judged — the controller's terminal, an ordinary call, or +// nothing — and run as every verb's line is: a fresh process of this binary, with this process's environment. +// +// **The terminal** is a line from a node's operator account, on the control-node (§4). Phase 2 adds a node whose +// agents run under an account of their own, judged unable to become root (ADR 0266, not built yet); until then no +// other node is the terminal, because agents there run as its operator. **An ordinary call** is a line from that +// account elsewhere: it meets every refusal of the generic `command` verb and runs with `MESH_VERB=mesh-cli`, so a +// terminal-only change is refused with its reason. **Any other account is refused**, root included: those two +// accounts already reach the mesh's verbs through the mesh MCP server, and no other account gains anything here. + +// cliVerb is what a line from mesh-cli that is not the terminal runs as: the verb its process names, so every +// terminal-only refusal applies and says it came through mesh-cli. +const cliVerb = "mesh-cli" + +// cliServers are commands that serve until stopped. Run for mesh-cli they would hold a second server under this +// one until the call's bound killed it. +var cliServers = map[string]bool{"serve": true, "api": true, "board": true} + +// cliVerdict is how a line is run, or why it is not. +type cliVerdict struct { + terminal bool + // why says why the line is or is not the terminal, in words the operator reads under the answer. + why string + // refused says why nothing runs. + refused string +} + +// judgeCLI decides how a line from mesh-cli runs (ADR 0272 §4). nodes is every node the mesh knows; control is +// every node the controller's module is assigned to, which is exactly one in a mesh that is well. +func judgeCLI(node string, asked link.CLIAsked, nodes []inventory.Node, control []string) cliVerdict { + var record *inventory.Node + for i := range nodes { + if nodes[i].Name == node { + record = &nodes[i] + break + } + } + switch { + case record == nil: + return cliVerdict{refused: fmt.Sprintf("%s is not a node this mesh knows, so nothing ran", node)} + case asked.UID == 0 || asked.Account == "root": + return cliVerdict{refused: "mesh-cli answers the operator's own account, never root: run it as yourself, " + + "not under sudo. Nothing ran"} + case record.Account == "": + return cliVerdict{refused: fmt.Sprintf("the mesh does not know %s's operator account (`node account `), "+ + "so no account there is answered. Nothing ran", node)} + case asked.Account != record.Account: + return cliVerdict{refused: fmt.Sprintf("mesh-cli answers %s's operator account (%s) only, and this line came "+ + "from %s. Nothing ran", node, record.Account, asked.Account)} + } + if len(control) != 1 { + return cliVerdict{why: fmt.Sprintf("not the controller's terminal: the mesh names %d control-nodes, and the "+ + "terminal is the one control-node's operator account", len(control))} + } + if control[0] == node { + return cliVerdict{terminal: true, why: fmt.Sprintf("the controller's terminal: %s on the control-node %s", + asked.Account, node)} + } + return cliVerdict{why: fmt.Sprintf("not the controller's terminal: agents on %s may run as %s, so a "+ + "terminal-only change is made from the control-node %s (novox/hq ADR 0272)", node, asked.Account, control[0])} +} + +// controlNodes is every node the controller's module is assigned to. +func controlNodes(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) ([]string, error) { + var out []string + for _, n := range nodes { + modules, err := inv.Assigned(ctx, n.Name) + if err != nil { + return nil, err + } + if slices.Contains(modules, controllerModule) { + out = append(out, n.Name) + } + } + return out, nil +} + +// controllerModule is the module that runs the controller, and so marks the control-node. +const controllerModule = "mesh-controller" + +// answerMeshCLI is the serving controller's answer to mesh-cli. +func answerMeshCLI(inv *inventory.Inventory) link.CLIHandler { + return func(ctx context.Context, node string, asked link.CLIAsked) link.CLIAnswer { + if handingOver.Load() { + return link.CLIRefusal("this controller is stopping and runs no new command; ask again in a moment. Nothing ran") + } + nodes, err := inv.Nodes(ctx) + if err != nil { + return link.CLIRefusal("the mesh's nodes cannot be read, so nothing ran: " + err.Error()) + } + control, err := controlNodes(ctx, inv, nodes) + if err != nil { + return link.CLIRefusal("which node is the control-node cannot be read, so nothing ran: " + err.Error()) + } + return runForMeshCLI(ctx, node, asked, judgeCLI(node, asked, nodes, control)) + } +} + +// cliJournal says one line in the controller's journal (its standard output); a variable so a test can read it. +var cliJournal = func(line string) { fmt.Println(line) } + +// runForMeshCLI runs a judged line and answers what it said. Every line is said in the journal first: its call, +// who asked on which node, its command word only, and how it runs (review of ADR 0272). +func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliVerdict) link.CLIAnswer { + how := "as an ordinary call" + switch { + case v.refused != "": + how = "refused: " + v.refused + case v.terminal: + how = "as the controller's terminal" + } + call := link.CallIDIn(ctx) + if call == "" { + call = "(no call)" + } + cliJournal(fmt.Sprintf("mesh-cli %s: %s on %s asked %q, %s", call, asked.Account, node, asked.Line[0], how)) + if v.refused != "" { + return link.CLIRefusal(v.refused) + } + if cliServers[asked.Line[0]] { + return link.CLIAnswer{Exit: 1, Why: v.why, Refused: fmt.Sprintf("%s serves until stopped, and is not a "+ + "command line mesh-cli runs. Nothing ran", asked.Line[0])} + } + verb, line := "", asked.Line + if !v.terminal { + composed, err := ordinaryLine(asked.Line) + if err != nil { + return link.CLIAnswer{Exit: 1, Why: v.why, Refused: err.Error()} + } + verb, line = cliVerb, composed + } + cmd := selfCommand(ctx, line) + cmd.Env = commandEnvironment(fmt.Sprintf("%s through mesh-cli on %s", asked.Account, node), verb) + // No standard input: a command that reads one gets nothing, and fails saying so (ADR 0272 §5). + cmd.Stdin = nil + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + answer := link.CLIAnswer{Stdout: stdout.Bytes(), Stderr: stderr.Bytes(), Terminal: v.terminal, Why: v.why} + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + answer.Exit = exit.ExitCode() + if answer.Exit < 0 { + // Killed: by the call's bound, or by this controller stopping. + answer.Exit = 1 + answer.Stderr = append(answer.Stderr, []byte(fmt.Sprintf("\nmesh-cli: the command was stopped (%v)\n", + exit))...) + } + default: + answer.Exit = 1 + answer.Refused = fmt.Sprintf("could not run %s from this controller's own build: %v", strings.Join(asked.Line, " "), err) + } + return answer +} + +// settingsForms is what an ordinary `settings` line may say: the settings verb's own forms. +const settingsForms = "settings set [--replace] [--node ], settings clear " + + "[--node ], settings show [--history] [--node ], or settings preferences [] " + + "[--node ]" + +// ordinaryLine is the command line an ordinary call runs (ADR 0272 §4): a `settings` line composed exactly as the +// settings verb composes its own, so its refusals — the terminal-only keys among them — are that verb's (review of +// ADR 0272); any other line as the generic `command` verb takes it, with its refusals. +func ordinaryLine(argv []string) ([]string, error) { + if len(argv) == 0 || argv[0] != "settings" { + // What the generic verb runs, and nothing it would refuse: its reading forms only, and never a command that + // is the terminal's alone (novox/hq ADR 0266) — the two checks argvFor makes of the `command` verb's line, + // made of the words as given rather than re-split from one string. + if err := refusedAsTheGenericCommand(argv); err != nil { + return nil, err + } + if err := terminalOnly(argv); err != nil { + return nil, err + } + return argv, nil + } + args := map[string]any{} + var words []string + rest := argv[1:] + for i := 0; i < len(rest); i++ { + w := rest[i] + switch { + case w == "--replace" || w == "-replace": + args["replace"] = "true" + case w == "--history" || w == "-history": + args["history"] = "true" + case w == "--node" || w == "-node": + if i+1 >= len(rest) { + return nil, fmt.Errorf("--node names no node; settings takes %s. Nothing ran", settingsForms) + } + i++ + args["node"] = rest[i] + case strings.HasPrefix(w, "--node=") || strings.HasPrefix(w, "-node="): + _, args["node"], _ = strings.Cut(w, "=") + case strings.HasPrefix(w, "-"): + return nil, fmt.Errorf("settings takes no %s through mesh-cli outside the terminal; it takes %s. "+ + "Nothing ran", w, settingsForms) + default: + words = append(words, w) + } + } + wrong := fmt.Errorf("through mesh-cli outside the terminal, settings takes the settings verb's forms: %s. "+ + "Nothing ran", settingsForms) + if len(words) == 0 { + return nil, wrong + } + switch words[0] { + case "set": + if len(words) != 3 { + return nil, wrong + } + args["module"], args["values"] = words[1], words[2] + case "clear": + if len(words) != 2 { + return nil, wrong + } + args["module"], args["clear"] = words[1], "true" + case "show": + if len(words) != 2 { + return nil, wrong + } + args["module"] = words[1] + case "preferences": + if len(words) > 2 { + return nil, wrong + } + args["list"] = "preferences" + if len(words) == 2 { + args["module"] = words[1] + } + default: + return nil, wrong + } + return argvFor("settings", args) +} diff --git a/cmd/mesh-controller/meshcli_test.go b/cmd/mesh-controller/meshcli_test.go new file mode 100644 index 00000000..8baf28a3 --- /dev/null +++ b/cmd/mesh-controller/meshcli_test.go @@ -0,0 +1,266 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/testbus" +) + +// echoEnvironment makes the test binary, run as a command line, say the verb and caller it was given (TestMain). +const echoEnvironment = "MESH_TEST_ECHO_ENVIRONMENT" + +var cliNodes = []inventory.Node{ + {Name: "control", Account: "operator"}, + {Name: "laptop", Account: "operator"}, + {Name: "unnamed"}, +} + +func asked(account string, uid uint32, line ...string) link.CLIAsked { + return link.CLIAsked{Line: line, Account: account, UID: uid} +} + +// Who is the controller's terminal, and who is an ordinary call or refused (novox/hq ADR 0272 §4). +func TestMeshCLIIsTheTerminalOnlyForTheControlNodesOperator(t *testing.T) { + control := []string{"control"} + cases := []struct { + name, node string + asked link.CLIAsked + control []string + terminal bool + refused string + why string + }{ + {"the control-node's operator", "control", asked("operator", 1000, "status"), control, true, "", "the controller's terminal"}, + {"another node's operator", "laptop", asked("operator", 1000, "status"), control, false, "", "agents on laptop may run as operator"}, + {"another account", "control", asked("agent", 1001, "status"), control, false, "operator account (operator) only", ""}, + {"root", "control", asked("root", 0, "status"), control, false, "never root", ""}, + {"a node with no operator account", "unnamed", asked("operator", 1000, "status"), control, false, "does not know unnamed's operator account", ""}, + {"a node the mesh does not know", "elsewhere", asked("operator", 1000, "status"), control, false, "not a node this mesh knows", ""}, + {"two control-nodes", "control", asked("operator", 1000, "status"), []string{"control", "laptop"}, false, "", "2 control-nodes"}, + } + for _, c := range cases { + v := judgeCLI(c.node, c.asked, cliNodes, c.control) + if v.terminal != c.terminal { + t.Errorf("%s: terminal %v, want %v (%+v)", c.name, v.terminal, c.terminal, v) + } + if c.refused == "" && v.refused != "" || c.refused != "" && !strings.Contains(v.refused, c.refused) { + t.Errorf("%s: refused %q, want %q", c.name, v.refused, c.refused) + } + if c.why != "" && !strings.Contains(v.why, c.why) { + t.Errorf("%s: why %q, want %q", c.name, v.why, c.why) + } + } +} + +// The terminal runs its line without MESH_VERB, even where this process carries one; an ordinary call names +// mesh-cli; both record who asked through mesh-cli. +func TestTheTerminalRunsWithoutAVerbAndAnOrdinaryCallNamesMeshCLI(t *testing.T) { + t.Setenv(echoEnvironment, "1") + t.Setenv("MESH_VERB", "leaked-from-the-serving-process") + // The serving controller marks itself (ADR 0266); its terminal line for mesh-cli is still the terminal's. + t.Setenv(servedVar, "1") + ctx := context.Background() + + a := runForMeshCLI(ctx, "control", asked("operator", 1000, "status"), cliVerdict{terminal: true, why: "the terminal"}) + if a.Exit != 0 || a.Refused != "" || !a.Terminal { + t.Fatalf("the terminal's line did not run: %+v", a) + } + if got := string(a.Stdout); !strings.Contains(got, `verb=""`) || !strings.Contains(got, "operator through mesh-cli on control") || + !strings.Contains(got, "terminal=true") { + t.Fatalf("the terminal's line ran with %s", got) + } + + a = runForMeshCLI(ctx, "laptop", asked("operator", 1000, "status"), cliVerdict{why: "not the terminal"}) + if a.Exit != 0 || a.Terminal || a.Why != "not the terminal" { + t.Fatalf("an ordinary line did not run as one: %+v", a) + } + if got := string(a.Stdout); !strings.Contains(got, `verb="mesh-cli"`) || !strings.Contains(got, "terminal=false") { + t.Fatalf("an ordinary line ran with %s", got) + } +} + +// An ordinary call meets every refusal of the generic command verb, and nothing runs; a server is never run. +func TestAnOrdinaryCallMeetsTheCommandVerbsRefusals(t *testing.T) { + t.Setenv(echoEnvironment, "1") + ctx := context.Background() + ordinary := cliVerdict{why: "not the terminal"} + a := runForMeshCLI(ctx, "laptop", asked("operator", 1000, "cleanup", "delete", "x"), ordinary) + if a.Refused == "" || len(a.Stdout) != 0 || a.Exit != 1 || a.Why != "not the terminal" { + t.Fatalf("a repair without --why ran as an ordinary call: %+v", a) + } + a = runForMeshCLI(ctx, "laptop", asked("operator", 1000, "settings", "set", "claude-code", "{}"), ordinary) + if a.Refused != "" || !strings.Contains(string(a.Stdout), `verb="mesh-cli"`) { + t.Fatalf("an ordinary settings set did not run through the settings verb's path with MESH_VERB set: %+v", a) + } + for _, server := range []string{"serve", "api", "board"} { + a := runForMeshCLI(ctx, "control", asked("operator", 1000, server), cliVerdict{terminal: true}) + if a.Refused == "" || len(a.Stdout) != 0 { + t.Fatalf("%s was run for mesh-cli: %+v", server, a) + } + } + a = runForMeshCLI(ctx, "control", asked("agent", 1001, "status"), cliVerdict{refused: "agent is not answered"}) + if a.Refused != "agent is not answered" || len(a.Stdout) != 0 { + t.Fatalf("a refused line ran: %+v", a) + } +} + +// **Which node is the terminal does not follow a verb** (review of ADR 0272): the controller's module is assigned +// and unassigned at the terminal alone, so no caller of `assign` can move the terminal to a node of its choosing. +// Asked through the acts themselves, as the verbs ask them; refused before any store is touched (none is given). +func TestTheControllersModuleIsMovedAtTheTerminalAlone(t *testing.T) { + t.Setenv("MESH_VERB", "assign") + ctx := context.Background() + if _, err := assignWith(ctx, nil, "laptop", assignOptions{}, "zsh", "mesh-controller"); err == nil || + !strings.Contains(err.Error(), "terminal") { + t.Fatalf("assigning the controller through a verb was not refused: %v", err) + } + if _, err := assign(ctx, nil, "laptop", "mesh-controller"); err == nil || !strings.Contains(err.Error(), "terminal") { + t.Fatalf("assigning the controller through a verb was not refused: %v", err) + } + t.Setenv("MESH_VERB", "unassign") + if _, err := unassign(ctx, nil, "control", "mesh-controller"); err == nil || !strings.Contains(err.Error(), "terminal") { + t.Fatalf("unassigning the controller through a verb was not refused: %v", err) + } + // Another module through a verb, and the controller's at the terminal, are not refused for it. + if err := refusedMovingTheController([]string{"zsh"}); err != nil { + t.Fatalf("another module was refused: %v", err) + } + t.Setenv("MESH_VERB", "") + if err := refusedMovingTheController([]string{"mesh-controller"}); err != nil { + t.Fatalf("the terminal was refused: %v", err) + } +} + +// An ordinary `settings set|clear` goes down the settings verb's own path: composed as that verb composes it, and +// run with MESH_VERB set, so its refusals — the terminal-only keys among them — are the settings command's own, +// not a blanket refusal of the generic command (review of ADR 0272). +func TestAnOrdinarySettingsLineTakesTheSettingsVerbsPath(t *testing.T) { + cases := []struct { + line []string + want string + err string + }{ + {[]string{"settings", "set", "zsh", `{"execute":"withhold"}`, "--node", "laptop"}, `settings set zsh {"execute":"withhold"} --node laptop`, ""}, + {[]string{"settings", "set", "zsh", "{}", "--replace"}, "settings set zsh {} --replace", ""}, + {[]string{"settings", "clear", "zsh", "--node", "laptop"}, "settings clear zsh --node laptop", ""}, + {[]string{"settings", "show", "zsh", "--history"}, "settings show zsh --history", ""}, + {[]string{"settings", "preferences"}, "settings preferences", ""}, + {[]string{"settings", "set", "zsh"}, "", "settings"}, + {[]string{"settings", "set", "zsh", "{}", "--sideways"}, "", "--sideways"}, + } + for _, c := range cases { + argv, err := ordinaryLine(c.line) + if c.err != "" { + if err == nil || !strings.Contains(err.Error(), c.err) { + t.Errorf("%q: refused with %v, want %q", c.line, err, c.err) + } + continue + } + if err != nil || strings.Join(argv, " ") != c.want { + t.Errorf("%q: composed %q (%v), want %q", c.line, argv, err, c.want) + } + } + // Anything else still meets the generic command verb's refusals. + if _, err := ordinaryLine([]string{"retire", "delete", "x"}); err == nil { + t.Error("a repair composed as an ordinary line") + } +} + +// Every line is said in the controller's journal, with its call, who asked where and how it ran — its command word +// only, never the rest of the line (review of ADR 0272). +func TestEveryMeshCLILineIsSaidInTheJournal(t *testing.T) { + t.Setenv(echoEnvironment, "1") + var said []string + was := cliJournal + cliJournal = func(line string) { said = append(said, line) } + t.Cleanup(func() { cliJournal = was }) + ctx := link.WithCallID(context.Background(), "call-1") + runForMeshCLI(ctx, "control", asked("operator", 1000, "settings", "set", "x", `{"password":"s3cret"}`), + cliVerdict{terminal: true, why: "the terminal"}) + runForMeshCLI(ctx, "control", asked("agent", 1001, "status"), cliVerdict{refused: "agent is not answered"}) + all := strings.Join(said, "\n") + if len(said) != 2 || !strings.Contains(all, "call-1") || !strings.Contains(all, "operator on control") || + !strings.Contains(all, "as the controller's terminal") || !strings.Contains(all, "refused") { + t.Fatalf("the journal said %q", said) + } + if strings.Contains(all, "s3cret") { + t.Fatalf("the journal carries the line's values: %q", said) + } +} + +// **An ordinary line runs only what the generic command verb would** (review of ADR 0272, ADR 0266): its reading +// forms, and never a command that is the terminal's alone. Each of these, from another node's operator, is refused +// and runs nothing. +func TestAnOrdinaryLineRunsNothingTheCommandVerbWouldRefuse(t *testing.T) { + t.Setenv(echoEnvironment, "1") + for _, line := range [][]string{ + {"node", "account", "control", "x"}, + {"node", "agent-account", "control", "x", "--clear"}, + {"token", "issue", "laptop"}, + {"secret", "export", "x"}, + {"operator", "key", "set", "x"}, + {"assign", "laptop", "zsh"}, + } { + if _, err := ordinaryLine(line); err == nil { + t.Errorf("%q composed as an ordinary line", line) + } + a := runForMeshCLI(context.Background(), "laptop", asked("operator", 1000, line...), cliVerdict{why: "not the terminal"}) + if a.Refused == "" || len(a.Stdout) != 0 { + t.Errorf("%q ran as an ordinary line: %+v", line, a) + } + } + if argv, err := ordinaryLine([]string{"status"}); err != nil || argv[0] != "status" { + t.Fatalf("a read was refused: %v", err) + } +} + +// **`calls` never shows a mesh-cli line's answer** (review of ADR 0272): the verb's own answer is read for a line +// served over a bus, and neither the answer's text nor the base64 JSON writes its bytes in is there. +func TestTheCallsVerbNeverShowsAMeshCLILinesAnswer(t *testing.T) { + conn, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + stop, err := link.OverNATS{Conn: conn}.ServeCLI(func(context.Context, string, link.CLIAsked) link.CLIAnswer { + return link.CLIAnswer{Stdout: []byte("s3cret-join")} + }, nil) + if err != nil { + t.Fatal(err) + } + defer stop() + body, _ := json.Marshal(link.CLIAsked{Line: []string{"token", "issue", "x"}, Account: "operator"}) + msg, err := conn.Request(link.CLISubject("control"), body, 5*time.Second) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(msg.Data), base64.StdEncoding.EncodeToString([]byte("s3cret-join"))) { + t.Fatalf("the asker was not given its answer: %s", msg.Data) + } + recent, _ := link.Calls.Recent() + var id string + for _, c := range recent { + if c.Seat == link.CLISeat { + id = c.ID + break + } + } + shown, err := callsAnswer(link.Calls, id) + if err != nil { + t.Fatal(err) + } + said, _ := json.Marshal(shown) + if strings.Contains(string(said), "s3cret-join") || + strings.Contains(string(said), base64.StdEncoding.EncodeToString([]byte("s3cret-join"))) { + t.Fatalf("calls showed a mesh-cli line's answer: %s", said) + } +} diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 9f4d306d..51128962 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -1107,7 +1107,7 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve } // A trusted mergeable file takes any key, so its module's whole layer is the terminal's (novox/hq issue 340). if files := catalogue.TrustedMergeable(shelf[module]); len(files) > 0 && !sameLayer(before, after) { - return fmt.Errorf("the settings of %s on %s are set at the controller's terminal only, never through a verb (this "+ + return fmt.Errorf("the settings of %s on %s are set at the controller's terminal only (`mesh-cli` on the control-node), never through a verb (this "+ "line came through %q): %s merges whatever key a layer sets into a file root or a consumer trusts, so "+ "any key could point the module at a listener of the caller's, and whoever may call a verb includes "+ "agents (novox/hq issue 340; a file nothing trusts says \"trusted\": false). Nothing was changed", @@ -1119,7 +1119,7 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve if string(was) == string(now) { continue } - return fmt.Errorf("%s of %s on %s is set at the controller's terminal only, never through a verb (this "+ + return fmt.Errorf("%s of %s on %s is set at the controller's terminal only (`mesh-cli` on the control-node), never through a verb (this "+ "line came through %q): it says where root creates and owns a module's directories, which of "+ "the machine's paths are mounted into its container, what the mesh's consumers trust, or what a file "+ "root or a person's session obeys takes, and whoever may call a verb includes agents (novox/hq issue 339; "+ diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 7495ea4e..e1903da2 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -266,6 +266,12 @@ func serve(ctx context.Context) (err error) { return err } defer stopServing() + // And the operator's command on every node, asked through each node's engine (novox/hq ADR 0272). + stopCLI, err := bus.ServeCLI(answerMeshCLI(open.inventory), log.New(os.Stdout, "", log.LstdFlags)) + if err != nil { + return err + } + defer stopCLI() // And says so on the bus (novox/hq ADR 0197): what it serves, as the NATS services protocol asks. stopAnnouncing, err := bus.Announce(seatAnnouncement(handlers), log.New(os.Stdout, "", log.LstdFlags)) if err != nil { diff --git a/cmd/mesh-controller/sayable_test.go b/cmd/mesh-controller/sayable_test.go index 6fb86b7c..ab7951d5 100644 --- a/cmd/mesh-controller/sayable_test.go +++ b/cmd/mesh-controller/sayable_test.go @@ -26,6 +26,12 @@ import ( // shape fails the suite, naming its source. The keeper would say such a summary in words at run time; // this is where the producer is made to say it rightly in the first place. func TestMain(m *testing.M) { + // The process a mesh-cli test runs as a command line: it says the verb and the caller it was given, and + // ends (meshcli_test.go). + if os.Getenv(echoEnvironment) != "" { + fmt.Printf("verb=%q caller=%q terminal=%v\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER"), startedAtTheTerminal()) + os.Exit(0) + } conditions.Unsayable = func(o conditions.Observation, field string, r outward.Refusal) { unsaid.note(o, field, r) } diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 92e42822..039b84e4 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -229,6 +229,35 @@ func quoteAll(xs []string) string { return strings.Join(q, ", ") } +// refusedAsTheGenericCommand is what the generic `command` verb refuses of a command line, and so what every +// line asked through a verb's route refuses: the `command` verb's, and an ordinary line from mesh-cli (novox/hq ADR +// 0272 §4). One function, so the two routes cannot drift apart. +func refusedAsTheGenericCommand(argv []string) error { + if len(argv) == 0 { + return errors.New("command names no command") + } + // A layer is written through the settings verb, never the generic one (novox/hq issue 339): the + // settings verb is where what a verb may not set is refused, and one route is one set of words. + // The command refuses places and accesses through any verb as well; this says so before it runs. + if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) { + return &heldAtTheTerminal{msg: "settings are set and cleared through the settings verb, not the " + + "generic command; and places and accesses only at the controller's terminal (novox/hq issue 339). " + + "Nothing was done"} + } + // The generic verb only reads (novox/hq ADR 0266): what writes has a named verb that composes its own + // line, or is the operator's at the controller's terminal. + if err := commandReads(argv); err != nil { + return err + } + // The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through + // it says why, as it would through its own verb. + if repair := repairingCommand(argv); repair != "" && !slices.ContainsFunc(argv, isWhyFlag) { + return fmt.Errorf("%s is a repair done by hand, and says why: add --why to the command "+ + "line (recorded in the hand-act log). Nothing was done", repair) + } + return nil +} + // commandLine composes the command. Every argument it reads is one it uses: a branch that reads an // argument and then drops it would pass it over, which is what the check after it exists to refuse. func (a *verbArguments) commandLine() ([]string, error) { @@ -245,28 +274,9 @@ func (a *verbArguments) commandLine() ([]string, error) { if err != nil { return nil, err } - if len(argv) == 0 { - return nil, errors.New("command names no command") - } - // A layer is written through the settings verb, never the generic one (novox/hq issue 339): the - // settings verb is where what a verb may not set is refused, and one route is one set of words. - // The command refuses places and accesses through any verb as well; this says so before it runs. - if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) { - return nil, &heldAtTheTerminal{msg: "settings are set and cleared through the settings verb, not the " + - "generic command; and places and accesses only at the controller's terminal (novox/hq issue 339). " + - "Nothing was done"} - } - // The generic verb only reads (novox/hq ADR 0266): what writes has a named verb that composes its own - // line, or is the operator's at the controller's terminal. - if err := commandReads(argv); err != nil { + if err := refusedAsTheGenericCommand(argv); err != nil { return nil, err } - // The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through - // it says why, as it would through its own verb. - if repair := repairingCommand(argv); repair != "" && !slices.ContainsFunc(argv, isWhyFlag) { - return nil, fmt.Errorf("%s is a repair done by hand, and says why: add --why to the command "+ - "line (recorded in the hand-act log). Nothing was done", repair) - } return argv, nil case "tools": return nil, errors.New("tools is answered from the records, not by a command") @@ -919,6 +929,31 @@ func isWhyFlag(word string) bool { return word == "--why" || word == "-why" || strings.HasPrefix(word, "--why=") || strings.HasPrefix(word, "-why=") } +// commandEnvironment is the environment of a command line this controller runs for someone: its own — the +// stores' credentials, the bus, the broker, everything a command run from a shell beside it would have, because it +// is that — with who asked, and the verb it came through. An empty verb is the controller's terminal (novox/hq ADR +// 0272 §4): no `MESH_VERB` at all, whatever this process was started with. +// +// The terminal's line is also not the serving controller's (servedVar), and carries cliTerminalVar, so what reads +// whether it was started at the terminal (startedAtTheTerminal) reads yes; every other line is stripped of that mark. +func commandEnvironment(caller, verb string) []string { + env := make([]string, 0, len(os.Environ())+3) + for _, kv := range os.Environ() { + if strings.HasPrefix(kv, verbVar+"=") || strings.HasPrefix(kv, link.CallerVar+"=") || + strings.HasPrefix(kv, cliTerminalVar+"=") || (verb == "" && strings.HasPrefix(kv, servedVar+"=")) { + continue + } + env = append(env, kv) + } + env = append(env, link.CallerVar+"="+caller) + if verb != "" { + env = append(env, verbVar+"="+verb) + } else { + env = append(env, cliTerminalVar+"=1") + } + return env +} + // runVerb runs this binary with the given command line and gathers what it said. // // **This binary is the image this process runs, never the file at the path it started from** @@ -932,21 +967,17 @@ func runVerb(ctx context.Context, argv []string) (verbAnswer, error) { return verbAnswer{}, fmt.Errorf("%w: this controller is stopping and runs no new command", link.ErrHandingOver) } cmd := selfCommand(ctx, argv) - // The same environment: the stores' credentials, the bus, the broker — everything a command run - // from a shell in this container would have, because it is that. - cmd.Env = os.Environ() // And who asked, so an act it does by hand is recorded as theirs (novox/hq to-be 45 §7). caller := link.CallerIn(ctx) if caller == "" { caller = "a seat call whose caller the bus did not name" } - cmd.Env = append(cmd.Env, link.CallerVar+"="+caller+", through the "+catalogue.ControllerSeatName+" seat") // And which verb, so the connection it dials says so in the bus's list (novox/hq issue 327). verb, _ := ctx.Value(verbKey{}).(string) if verb == "" { verb = argv[0] } - cmd.Env = append(cmd.Env, verbVar+"="+verb) + cmd.Env = commandEnvironment(caller+", through the "+catalogue.ControllerSeatName+" seat", verb) // Two buffers, one answer. What the command *says* is both streams, in the order a person at // a shell would read them; what it *answers as data* is standard output alone — `status --json` // prints its warnings beside the document, and a JSON parsed from the two together parsed @@ -1168,7 +1199,7 @@ func answersFirst(argv []string) bool { // the reason said beside it. func callsAnswer(log *link.CallLog, id string) (any, error) { if id != "" { - c, ok, err := log.Get(id) + c, ok, err := log.Shown(id) if err != nil { return nil, fmt.Errorf("call %s is not in this controller's memory, and the calls kept on the "+ "bus could not be read: %w", id, err) diff --git a/internal/broker/writers.go b/internal/broker/writers.go index 70cb099b..a775d4ce 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -87,8 +87,10 @@ var WritersTable = []WriterRow{ {State: "a machine's applied state and its report", Writer: "the node-engine's apply queue", KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply", // And its health statement between reports (novox/hq ADR 0240): the same writer stating the same - // machine, inside the grant it already had (`mesh.control..>`). - Subjects: []string{"mesh.control.*.report", "mesh.control.*.health"}, Writes: ownMachine}, + // machine, inside the grant it already had (`mesh.control..>`). And a line mesh-cli was given + // on the machine (novox/hq ADR 0272): the node is what the controller judges the terminal by, so that + // subject is this machine's engine's alone. + Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine}, {State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket, Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController}, {State: "plans and their tiers", Writer: "controller (lease holder), compare-and-set on the plan's revision", diff --git a/internal/broker/writers_test.go b/internal/broker/writers_test.go index 72aba5e4..11eeee7f 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -86,6 +86,10 @@ func TestASecondWriterIsRefusedAtComposition(t *testing.T) { []string{"mesh.control.>"}, "a machine's applied state and its report"}, {"a machine publishing another's report", Principal{Kind: KindNode, Node: "one"}, []string{"mesh.control.two.report"}, "a machine's applied state and its report"}, + {"a machine asking the controller as another (mesh-cli, ADR 0272)", Principal{Kind: KindNode, Node: "one"}, + []string{"mesh.control.two.cli"}, "a machine's applied state and its report"}, + {"a module asking the controller as a machine (mesh-cli, ADR 0272)", Principal{Kind: KindModule, Module: "shop"}, + []string{"mesh.control.one.cli"}, "a machine's applied state and its report"}, {"a machine publishing every machine's", Principal{Kind: KindNode, Node: "one"}, []string{"mesh.control.*.>"}, "a machine's applied state and its report"}, {"a module writing the lease", Principal{Kind: KindModule, Module: "shop"}, @@ -115,6 +119,7 @@ func TestASecondWriterIsRefusedAtComposition(t *testing.T) { publish []string }{ {Principal{Kind: KindNode, Node: "one"}, []string{"mesh.control.one.>"}}, + {Principal{Kind: KindNode, Node: "one"}, []string{"mesh.control.one.cli"}}, {Principal{Kind: KindController}, []string{"mesh.node.>", "$JS.API.>", "$KV.mesh-controller_lease.>"}}, {Principal{Kind: KindModule, Module: "gitea"}, []string{"mesh.mod.gitea.event.pull.merged"}}, {Principal{Kind: KindModule, Module: "shop"}, []string{"$JS.API.CONSUMER.CREATE.KV_shop_carts.>"}}, diff --git a/internal/link/calls.go b/internal/link/calls.go index daaaf6e1..29bbdc5a 100644 --- a/internal/link/calls.go +++ b/internal/link/calls.go @@ -324,6 +324,15 @@ func (l *CallLog) finish(c *Call, answer []byte, failed, answeredAlready bool) { return } onTheBus := *c + if c.Seat == CLISeat { + // **A mesh-cli line's answer is its asker's alone** (ADR 0272): what a command at the controller's terminal + // printed — a token, a secret's reference — and `calls` answers anyone who may call the seat. Kept in this + // process's memory for the asker to follow, and never on the bus. + onTheBus.Answer, _ = json.Marshal(map[string]any{"not kept": "a mesh-cli line's answer, its asker's alone: " + + "kept in the memory of the controller that ran it, for the asker to follow"}) + l.keep(onTheBus) + return + } if len(onTheBus.Answer) > keptOnTheBusAtMost { // A record on the bus is one message too, and one larger than the bus carries is refused whole — // the call's state with it (novox/hq issue 314). The answer stays in this process's memory, and @@ -391,6 +400,29 @@ func (l *CallLog) Recent() ([]Call, error) { return out, nil } +// inMemory is one call this process served and still holds, whole. +func (l *CallLog) inMemory(id string) (Call, bool) { + l.mu.Lock() + defer l.mu.Unlock() + for _, c := range l.calls { + if c.ID == id { + return *c, true + } + } + return Call{}, false +} + +// Shown is one kept call as `calls` shows it: whole, but for a mesh-cli line's answer, which is its asker's alone +// and followed by it (ADR 0272) — `calls` answers anyone who may call the seat. +func (l *CallLog) Shown(id string) (Call, bool, error) { + c, found, err := l.Get(id) + if found && c.Seat == CLISeat { + c.Answer, _ = json.Marshal(map[string]any{"not shown": "a mesh-cli line's answer is its asker's alone: " + + "mesh-cli follows it"}) + } + return c, found, err +} + // Get is one kept call: from memory, or from the bus when this process did not serve it. func (l *CallLog) Get(id string) (Call, bool, error) { l.mu.Lock() @@ -431,6 +463,11 @@ func kept(args json.RawMessage) json.RawMessage { switch { case k == "values" || k == "secret": out[k] = "(given, not kept)" + case k == "line": + // A mesh-cli line (ADR 0272): its command word, never the rest, which may carry settings. + if words, ok := v.([]any); ok && len(words) > 0 { + out[k] = []any{words[0], "(the rest given, not kept)"} + } case isString && len(s) <= 120: out[k] = s case isString: @@ -513,6 +550,19 @@ var watched struct { conns map[*nats.Conn]bool } +type callIDKey struct{} + +// WithCallID is ctx carrying the call it serves; CallIDIn reads it back, empty outside one. +func WithCallID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, callIDKey{}, id) +} + +// CallIDIn is the call ctx serves, or empty. +func CallIDIn(ctx context.Context) string { + id, _ := ctx.Value(callIDKey{}).(string) + return id +} + // answerNow is how a handler asks for its caller to be answered before it goes on (Acknowledge). type answerNowKey struct{} @@ -565,6 +615,8 @@ func (l *CallLog) serveCallWithin(seat, verb string, args json.RawMessage, reply c := l.begin(seat, verb, kept(args), reply) // Who asked travels with the call, so an act it does by hand says so (novox/hq to-be 45 §7). ctx = context.WithValue(ctx, callerKey{}, c.Caller) + // And which call it is, for what the handler says of it in the journal. + ctx = WithCallID(ctx, c.ID) acknowledged := make(chan struct{}) var once sync.Once ctx = context.WithValue(ctx, answerNowKey{}, func() { once.Do(func() { close(acknowledged) }) }) diff --git a/internal/link/meshcli.go b/internal/link/meshcli.go new file mode 100644 index 00000000..27dcc6f3 --- /dev/null +++ b/internal/link/meshcli.go @@ -0,0 +1,216 @@ +package link + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/nats-io/nats.go" +) + +// mesh-cli asks the controller through the node-engine of the machine it runs on (novox/hq ADR 0272 §3). The engine +// reads the asking account from the kernel and asks on its own node's subject, which only that node's engine may +// publish (its grant is `mesh.control..>`): so the node is a fact the bus server enforces, and the account a +// fact the kernel gave. The engine's side holds the same field names (mesh-host internal/meshcli, a test on each +// side), as for its health statement. + +// CLISubjects is every node's mesh-cli subject, which the serving controller answers. +const CLISubjects = "mesh.control.*.cli" + +// CLISubject is one node's. +func CLISubject(node string) string { return "mesh.control." + node + ".cli" } + +// CLISeat is what a mesh-cli line is recorded under in the calls record, beside the seats' verbs: its verb there is +// the node it was asked on. +const CLISeat = "mesh-cli" + +// CLIAtOnce bounds the mesh-cli lines running at once, from every node together. A person types one at a time; one +// over the bound is answered busy at once, and nothing runs. +var CLIAtOnce = 8 + +// CLIAsked is a line mesh-cli was given on a node, and the account the node-engine says is asking — or, with +// Follow, the call a line runs as, asked again by the same account on the same node until it ends. +type CLIAsked struct { + Line []string `json:"line,omitempty"` + Account string `json:"account"` + UID uint32 `json:"uid"` + Follow string `json:"follow,omitempty"` +} + +// CLIAnswer is what the controller answers, as the `result` of a call's answer: what the command printed, how it +// exited, whether it ran as the controller's terminal and why, or why nothing ran. A line still running is answered +// as every call is (`running`, `call`), and followed. +type CLIAnswer struct { + Stdout []byte `json:"stdout,omitempty"` + Stderr []byte `json:"stderr,omitempty"` + Exit int `json:"exit"` + Terminal bool `json:"terminal"` + Why string `json:"why,omitempty"` + Refused string `json:"refused,omitempty"` + Cut bool `json:"cut,omitempty"` +} + +// CLIRefusal is an answer saying nothing ran, and why. +func CLIRefusal(why string) CLIAnswer { return CLIAnswer{Exit: 1, Refused: why} } + +// CLINode is the node a mesh-cli subject names, or false for any other subject. +func CLINode(subject string) (string, bool) { + rest, ok := strings.CutPrefix(subject, "mesh.control.") + if !ok { + return "", false + } + node, tail, ok := strings.Cut(rest, ".") + if !ok || tail != "cli" || node == "" { + return "", false + } + return node, true +} + +// CLIHandler answers one line from one node. +type CLIHandler func(ctx context.Context, node string, asked CLIAsked) CLIAnswer + +// ServeCLI answers mesh-cli for every node until stopped: one queue group, so of two controllers during a handover +// one answers. +// +// **Every line is a call** (review of ADR 0272): run, recorded and answered as a seat's verb is (calls.go) — its +// caller answered within AnswerWithin that it is still running, with its call, and the line's answer kept for +// the asker to follow. The bus permits an answer for a minute only (broker.ResponseTTL); a line answered when it +// ends lost every answer after that, and mesh-cli said nothing ran of a line that had. +func (b OverNATS) ServeCLI(handle CLIHandler, logger *log.Logger) (func(), error) { + return b.serveCLI(Calls, CLIAtOnce, handle, logger) +} + +func (b OverNATS) serveCLI(calls *CallLog, atOnce int, handle CLIHandler, logger *log.Logger) (func(), error) { + done := make(chan struct{}) + slots := make(chan struct{}, atOnce) + bind := func() (*nats.Subscription, error) { + return b.Conn.QueueSubscribe(CLISubjects, "mesh-cli", func(msg *nats.Msg) { + go b.answerCLI(msg, calls, slots, handle, logger) + }) + } + sub, err := bind() + if err != nil { + return nil, fmt.Errorf("serving %s: %w", CLISubjects, err) + } + go keepBound(sub, bind, CLISubjects, done, logger) + return func() { + close(done) + _ = sub.Unsubscribe() + }, nil +} + +// cliEnvelope is an answer as every call's is: its result. +func cliEnvelope(a CLIAnswer) []byte { + body, _ := json.Marshal(map[string]any{"result": a}) + return body +} + +func (b OverNATS) answerCLI(msg *nats.Msg, calls *CallLog, slots chan struct{}, handle CLIHandler, logger *log.Logger) { + if msg.Reply == "" { + return + } + respond := func(body []byte) { + if err := msg.Respond(body); err != nil && logger != nil { + logger.Printf("mesh-cli on %s: the answer could not be sent: %v", msg.Subject, err) + } + } + node, ok := CLINode(msg.Subject) + var asked CLIAsked + switch { + case !ok: + respond(cliEnvelope(CLIRefusal("not a mesh-cli subject: " + msg.Subject))) + return + case json.Unmarshal(msg.Data, &asked) != nil: + respond(cliEnvelope(CLIRefusal("the node-engine's request could not be read, so nothing ran"))) + return + case asked.Follow != "": + respond(calls.followCLI(asked.Follow, node, asked.Account)) + return + case len(asked.Line) == 0: + respond(cliEnvelope(CLIRefusal("the request names no command, so nothing ran"))) + return + } + select { + case slots <- struct{}{}: + defer func() { <-slots }() + default: + respond(cliEnvelope(CLIRefusal(fmt.Sprintf("busy: %d lines from mesh-cli are running already; ask again "+ + "when one has ended. Nothing ran", cap(slots))))) + return + } + limit := b.Conn.MaxPayload() + calls.serveCallWithin(CLISeat, node, msg.Data, msg.Reply, func(ctx context.Context, _ json.RawMessage) (any, error) { + return fitCLI(handle(ctx, node, asked), limit-4096), nil + }, msg.Respond, limit, logger) +} + +// followCLI answers the asker of a line what came of it: running still, its answer, or why that is not known. Only +// the account that asked it, on the node it was asked on: the answer is what the command printed, and `calls` +// keeps it from everyone else. +func (l *CallLog) followCLI(id, node, account string) []byte { + noSuch := func() []byte { + body, _ := json.Marshal(map[string]any{"error": fmt.Sprintf("no mesh-cli call %s was asked by %s on %s", id, + account, node)}) + return body + } + c, inMemory := l.inMemory(id) + if !inMemory { + kept, found, err := l.Get(id) + if err != nil || !found { + return noSuch() + } + c = kept + } + var args CLIAsked + _ = json.Unmarshal(c.Args, &args) + if c.Seat != CLISeat || c.Verb != node || args.Account != account { + return noSuch() + } + switch { + case c.State == CallRunning: + return running(&c, AnswerWithin, false, l.Follow) + case c.State == CallAbandoned: + body, _ := json.Marshal(map[string]any{"error": fmt.Sprintf("call %s was running under a controller that "+ + "stopped before it finished: it may have done part of what it was asked, and nothing will finish it", id)}) + return body + case !inMemory: + body, _ := json.Marshal(map[string]any{"error": fmt.Sprintf("call %s finished (%s), and its answer was kept "+ + "only in the memory of the controller that ran it, which has stopped; whether it took effect, the "+ + "mesh says (status, the hand-act log)", id, c.State)}) + return body + } + return c.Answer +} + +// fitCLI is an answer whose streams fit in limit bytes once written: what the command printed is cut, standard +// output first, and the cut is said (ADR 0272 §5) — never silently short. +func fitCLI(a CLIAnswer, limit int64) CLIAnswer { + body, _ := json.Marshal(a) + if limit <= 0 || int64(len(body)) <= limit { + return a + } + a.Cut = true + // Room for the rest of the answer and JSON's base64 of the streams (4 bytes for every 3). + room := (limit - 4096) * 3 / 4 + if room < 0 { + room = 0 + } + if int64(len(a.Stderr)) > room/2 { + a.Stderr = a.Stderr[:room/2] + } + if left := room - int64(len(a.Stderr)); int64(len(a.Stdout)) > left { + if left < 0 { + left = 0 + } + a.Stdout = a.Stdout[:left] + } + return a +} + +// FitCLIAnswer is the answer as one bus message of at most limit bytes, written. +func FitCLIAnswer(a CLIAnswer, limit int64) []byte { + body, _ := json.Marshal(fitCLI(a, limit)) + return body +} diff --git a/internal/link/meshcli_test.go b/internal/link/meshcli_test.go new file mode 100644 index 00000000..2a9f9e0b --- /dev/null +++ b/internal/link/meshcli_test.go @@ -0,0 +1,265 @@ +package link + +import ( + "context" + "encoding/base64" + "encoding/json" + "sort" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-controller/internal/testbus" +) + +// The node-engine's request and the answer it hands mesh-cli hold these field names; the engine's side holds the +// same list (mesh-host internal/meshcli, TestTheRequestToTheControllerKeepsItsFieldNames). +func TestTheMeshCLIRequestAndAnswerKeepTheirFieldNames(t *testing.T) { + body, _ := json.Marshal(CLIAsked{Line: []string{"status"}, Account: "a", UID: 1}) + if got := keysIn(t, body); got != "account line uid" { + t.Fatalf("the request's fields are %q", got) + } + body, _ = json.Marshal(CLIAnswer{Stdout: []byte("o"), Stderr: []byte("e"), Exit: 1, Terminal: true, Why: "w", + Refused: "r", Cut: true}) + if got := keysIn(t, body); got != "cut exit refused stderr stdout terminal why" { + t.Fatalf("the answer's fields are %q", got) + } + body, _ = json.Marshal(CLIAsked{Follow: "call-1", Account: "a", UID: 1}) + if got := keysIn(t, body); got != "account follow uid" { + t.Fatalf("a follow's fields are %q", got) + } + // What a line still running answers, in the envelope every call's answer is in: `result`, then these. + var env struct { + Result json.RawMessage `json:"result"` + } + _ = json.Unmarshal(running(&Call{ID: "call-1", Seat: CLISeat, Verb: "a"}, AnswerWithin, false, ""), &env) + if got := keysIn(t, env.Result); got != "call output running started" { + t.Fatalf("a running answer's fields are %q", got) + } +} + +func keysIn(t *testing.T, body []byte) string { + t.Helper() + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatal(err) + } + var keys []string + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return strings.Join(keys, " ") +} + +// A node's mesh-cli subject names the node, and no other subject does. +func TestTheMeshCLISubjectNamesItsNode(t *testing.T) { + if node, ok := CLINode(CLISubject("laptop")); !ok || node != "laptop" { + t.Fatalf("CLINode(%q) = %q %v", CLISubject("laptop"), node, ok) + } + for _, s := range []string{"mesh.control.laptop.report", "mesh.control..cli", "mesh.control.a.b.cli", "mesh.node.a.cli"} { + if _, ok := CLINode(s); ok { + t.Fatalf("%s was read as a mesh-cli subject", s) + } + } +} + +// An answer larger than one bus message is cut to fit, and the cut is said. +func TestALargeMeshCLIAnswerIsCutAndSaysSo(t *testing.T) { + a := CLIAnswer{Stdout: []byte(strings.Repeat("o", 200000)), Stderr: []byte(strings.Repeat("e", 1000)), Why: "the terminal"} + body := FitCLIAnswer(a, 64<<10) + if len(body) > 64<<10 { + t.Fatalf("the answer is %d bytes, over the bound", len(body)) + } + var got CLIAnswer + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + if !got.Cut || got.Why != "the terminal" || len(got.Stdout) == 0 || string(got.Stderr) != strings.Repeat("e", 1000) { + t.Fatalf("the cut answer is %+v", got) + } + if small := FitCLIAnswer(CLIAnswer{Stdout: []byte("ok")}, 64<<10); strings.Contains(string(small), `"cut"`) { + t.Fatal("an answer that fits was said to be cut") + } +} + +// cliBus serves mesh-cli on a bus of the test's own with a call log of its own, and returns a connection to ask on. +func cliBus(t *testing.T, l *CallLog, atOnce int, handle CLIHandler) *nats.Conn { + t.Helper() + conn, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(conn.Close) + stop, err := OverNATS{Conn: conn}.serveCLI(l, atOnce, handle, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(stop) + return conn +} + +// askCLI asks one request on a node's subject and reads the envelope. +func askCLI(t *testing.T, conn *nats.Conn, node string, asked CLIAsked) (CLIAnswer, map[string]any, string) { + t.Helper() + body, _ := json.Marshal(asked) + msg, err := conn.Request(CLISubject(node), body, 5*time.Second) + if err != nil { + t.Fatal(err) + } + var env struct { + Result json.RawMessage `json:"result"` + Error string `json:"error"` + } + if err := json.Unmarshal(msg.Data, &env); err != nil { + t.Fatalf("not an envelope: %s", msg.Data) + } + var a CLIAnswer + var raw map[string]any + _ = json.Unmarshal(env.Result, &a) + _ = json.Unmarshal(env.Result, &raw) + return a, raw, env.Error +} + +// **A line that outlasts the bus's window for an answer is followed to its answer, never lost** (review of ADR +// 0272): the first answer says it is running and names its call, and following the call by the same account on +// the same node gives what the line printed once it ends. Another account, or another node, is told no such call. +func TestALongMeshCLILineIsFollowedToItsAnswer(t *testing.T) { + was := AnswerWithin + AnswerWithin = 50 * time.Millisecond + t.Cleanup(func() { AnswerWithin = was }) + release := make(chan struct{}) + conn := cliBus(t, NewCallLog(), 4, func(ctx context.Context, node string, asked CLIAsked) CLIAnswer { + <-release + return CLIAnswer{Stdout: []byte("done on " + node), Terminal: true} + }) + _, first, failed := askCLI(t, conn, "laptop", CLIAsked{Line: []string{"push", "laptop"}, Account: "op", UID: 1000}) + call, _ := first["call"].(string) + if failed != "" || first["running"] != true || call == "" { + t.Fatalf("a long line was not answered as running with its call: %v %q", first, failed) + } + _, still, _ := askCLI(t, conn, "laptop", CLIAsked{Follow: call, Account: "op", UID: 1000}) + if still["running"] != true { + t.Fatalf("following a running line answered %v", still) + } + close(release) + deadline := time.Now().Add(5 * time.Second) + for { + a, raw, failed := askCLI(t, conn, "laptop", CLIAsked{Follow: call, Account: "op", UID: 1000}) + if failed != "" { + t.Fatalf("following answered %q", failed) + } + if raw["running"] != true { + if string(a.Stdout) != "done on laptop" || !a.Terminal { + t.Fatalf("followed to %+v", a) + } + break + } + if time.Now().After(deadline) { + t.Fatal("the line never finished for its follower") + } + time.Sleep(20 * time.Millisecond) + } + if _, _, failed := askCLI(t, conn, "laptop", CLIAsked{Follow: call, Account: "agent", UID: 1001}); failed == "" { + t.Fatal("another account followed the operator's line") + } + if _, _, failed := askCLI(t, conn, "desktop", CLIAsked{Follow: call, Account: "op", UID: 1000}); failed == "" { + t.Fatal("another node followed the line") + } +} + +// Lines running at once are bounded: one over the bound is answered busy at once, and nothing ran. +func TestMeshCLILinesAtOnceAreBounded(t *testing.T) { + was := AnswerWithin + AnswerWithin = 50 * time.Millisecond + t.Cleanup(func() { AnswerWithin = was }) + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + ran := make(chan struct{}, 4) + conn := cliBus(t, NewCallLog(), 1, func(context.Context, string, CLIAsked) CLIAnswer { + ran <- struct{}{} + <-release + return CLIAnswer{} + }) + if _, first, _ := askCLI(t, conn, "laptop", CLIAsked{Line: []string{"status"}, Account: "op"}); first["running"] != true { + t.Fatalf("the first line answered %v", first) + } + a, _, _ := askCLI(t, conn, "laptop", CLIAsked{Line: []string{"status"}, Account: "op"}) + if !strings.Contains(a.Refused, "busy") || a.Exit == 0 { + t.Fatalf("a line over the bound answered %+v", a) + } + if len(ran) != 1 { + t.Fatalf("%d lines ran, one was bound", len(ran)) + } +} + +// A mesh-cli line's record keeps its first word, never the rest of its line, and its answer is never kept on the +// bus, where `calls` answers anyone who may call the seat. +func TestAMeshCLIRecordKeepsNeitherItsLineNorItsAnswerOnTheBus(t *testing.T) { + l, a := NewCallLog(), newAnswers(t) + writes := make(chan Call, 4) + l.writes = writes + asked, _ := json.Marshal(CLIAsked{Line: []string{"settings", "set", "x", `{"password":"s3cret"}`}, Account: "op"}) + l.serveCall(CLISeat, "laptop", asked, "_INBOX.node.laptop.abcdefghijklmnopqrstuv", + func(context.Context, json.RawMessage) (any, error) { + return CLIAnswer{Stdout: []byte("s3cret-join")}, nil + }, + a.respond, nil) + _ = a.only() + close(writes) + for c := range writes { + if carries(c.Args, "s3cret") || carries(c.Answer, "s3cret-join") { + t.Fatalf("the bus was sent %s / %s", c.Args, c.Answer) + } + if !strings.Contains(string(c.Args), "settings") || !strings.Contains(string(c.Args), `"op"`) { + t.Fatalf("the record does not say what was asked and by whom: %s", c.Args) + } + } +} + +// `calls` answers anyone who may call the seat, agents among them: a mesh-cli line's answer is never shown there, +// even from the memory of the controller that ran it; every other call's is (review of ADR 0272). +func TestCallsNeverShowsAMeshCLILinesAnswer(t *testing.T) { + l, a := NewCallLog(), newAnswers(t) + asked, _ := json.Marshal(CLIAsked{Line: []string{"token", "issue", "x"}, Account: "operator"}) + l.serveCall(CLISeat, "control", asked, "_INBOX.node.control.abcdefghijklmnopqrstuv", + func(context.Context, json.RawMessage) (any, error) { + return CLIAnswer{Stdout: []byte("s3cret-join")}, nil + }, + a.respond, nil) + _ = a.only() + recent, _ := l.Recent() + shown, found, err := l.Shown(recent[0].ID) + if err != nil || !found { + t.Fatalf("the line is not shown at all: %v %v", found, err) + } + if carries(shown.Answer, "s3cret-join") { + t.Fatalf("calls shows a mesh-cli line's answer: %s", shown.Answer) + } + b := newAnswers(t) + l.serveCall("mesh-controller", "status", nil, "_INBOX.x.abcdefghijklmnopqrstuv", + func(context.Context, json.RawMessage) (any, error) { return "all well", nil }, b.respond, nil) + _ = b.only() + recent, _ = l.Recent() + if shown, _, _ := l.Shown(recent[0].ID); !strings.Contains(string(shown.Answer), "all well") { + t.Fatalf("another call's answer is withheld: %s", shown.Answer) + } +} + +// carries says whether a record holds a secret as text or as the base64 JSON writes bytes in: a line's output is +// bytes, so a search for its text alone finds nothing whatever the record keeps (review of ADR 0272). +func carries(body []byte, secret string) bool { + return strings.Contains(string(body), secret) || + strings.Contains(string(body), base64.StdEncoding.EncodeToString([]byte(secret))) +} + +// The two tests above hold what they claim: each fails when the protection it names is taken away. +func TestTheWithholdingTestsHoldSomething(t *testing.T) { + // The answer as a mesh-cli line's record would carry it, were it kept: the search finds it. + body, _ := json.Marshal(map[string]any{"result": CLIAnswer{Stdout: []byte("s3cret-join")}}) + if !carries(body, "s3cret-join") { + t.Fatalf("a record carrying the answer is not found carrying it: %s", body) + } +}