From 8ceec32692943e0f84ad1334d541e186c23636d5 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 17:50:56 +0200 Subject: [PATCH] The mesh owns the operator's ~/.ssh: account fact + home-scoped resources (to-be 29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node carries its operator account (name + home; migration 0036, Node.Account, SetAccount, 'node account' CLI). The account and its home are offered as machine facts ${machine:account} / ${machine:account-home}, and machineInto now resolves placeholders in a resource's path and owner (not just content), so a module writes into a person's home naming what it cannot know. A RosterFile gains Home: the file is placed under the account's home and chowned to it, its template sees each node's Account, and a machine with no account gets none — this is how the ssh Host blocks for every node reach a person's ~/.ssh. Roster carries per-node accounts (Rendering.Accounts). Tested, including ssh-client composed end-to-end. Not deployed. --- cmd/mesh-controller/nodes.go | 41 +++++++++++- cmd/mesh-controller/plan.go | 25 +++++++- internal/catalogue/declaration.go | 7 ++- internal/catalogue/machine_into_files.go | 53 +++++++++++----- internal/catalogue/resolve.go | 10 +++ internal/catalogue/roster.go | 51 +++++++++++---- internal/catalogue/roster_test.go | 63 +++++++++++++++---- internal/catalogue/ssh_client_test.go | 46 ++++++++++++++ .../0036-a-node-has-an-operator-account.sql | 13 ++++ internal/inventory/nodes.go | 43 ++++++++++++- 10 files changed, 307 insertions(+), 45 deletions(-) create mode 100644 internal/catalogue/ssh_client_test.go create mode 100644 internal/inventory/migrations/0036-a-node-has-an-operator-account.sql diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index d7ce9dd..d75ee40 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -67,8 +67,14 @@ func nodeCommand(ctx context.Context, args []string) error { // because the damage is already done by the time it prints. return publicDomain(ctx, inv, args[1:]) + case "account": + // The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is + // owned by and which account `ssh ` uses. Reports with no argument; sets with one; + // an optional second argument is the home when it is not /home/. + return nodeAccount(ctx, inv, args[1:]) + default: - return fmt.Errorf("node has no %q; it has add, list, show and public-domain", args[0]) + return fmt.Errorf("node has no %q; it has add, list, show, public-domain and account", args[0]) } } @@ -106,6 +112,39 @@ func modeOf(n inventory.Node) string { } // publicDomainUsage is the one description of the three forms, so a refusal and the help agree. +// nodeAccount reports or sets a node's operator account (novox/hq to-be 29). Read-shaped with no +// argument, like public-domain: `node account novox` answers, it does not change anything. +func nodeAccount(ctx context.Context, inv *inventory.Inventory, positionals []string) error { + if len(positionals) == 0 || len(positionals) > 3 { + return errors.New("node account — what it is now; " + + "node account [home] — set it (home defaults to /home/)") + } + node := positionals[0] + if len(positionals) == 1 { + who, err := inv.NodeByName(ctx, node) + if err != nil { + return err + } + if who.Account == "" { + fmt.Printf("%s has no operator account known\n", node) + fmt.Printf(" `node account %s ` sets it\n", node) + return nil + } + fmt.Printf("%s logs a person in as %s (home %s)\n", node, who.Account, who.Home()) + return nil + } + home := "" + if len(positionals) == 3 { + home = positionals[2] + } + if err := inv.SetAccount(ctx, node, positionals[1], home); err != nil { + return err + } + fmt.Printf("%s logs a person in as %s\n", node, positionals[1]) + fmt.Printf(" run `push %s` once ssh-client is assigned, to send its operator config\n", node) + return nil +} + const publicDomainUsage = "node public-domain — what it is now; " + " to set it; --clear to take it away" diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 781f330..85f9a0f 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -83,9 +83,17 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso return catalogue.Resolution{}, nil, err } + // The operator account this node logs a person in as, and where its home is (novox/hq to-be + // 29) — carried so a home-scoped file's owner and path resolve for this machine. + who, err := inv.NodeByName(ctx, nodeName) + if err != nil { + return catalogue.Resolution{}, nil, err + } + resolved, err := catalogue.Resolve(shelf, assigned, catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities, - At: onNetwork[nodeName], PublicDomain: publicDomain}, world) + At: onNetwork[nodeName], PublicDomain: publicDomain, + Account: who.Account, AccountHome: who.AccountHome}, world) if err != nil { return catalogue.Resolution{}, nil, err } @@ -520,6 +528,19 @@ func renderingFor(ctx context.Context, open *stores, node string, return catalogue.Rendering{}, inventory.Node{}, err } + // Each machine's operator account, so an ssh Host block can name the login for every node + // (novox/hq to-be 29). Keyed by the bare node name, which entriesFrom falls back to. + allNodes, err := inv.Nodes(ctx) + if err != nil { + return catalogue.Rendering{}, inventory.Node{}, err + } + accounts := map[string]string{} + for _, n := range allNodes { + if n.Account != "" { + accounts[n.Name] = n.Account + } + } + // And every routed name → the node that serves it (novox/hq ADR 0066). Alongside the // `.internal` names above, so a container — or an internal ACME validator — resolves a // routed name to the proxy that serves it, mesh-wide. The mesh publishes the names it was told @@ -604,7 +625,7 @@ func renderingFor(ctx context.Context, open *stores, node string, Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports, Certificate: certificate, Authority: authority, Mesh: private, Names: names, Machines: machines, - Suffix: overlay.Suffix(), MeshRange: meshRange, Foundation: foundation, Kept: kept, + Suffix: overlay.Suffix(), MeshRange: meshRange, Accounts: accounts, Foundation: foundation, Kept: kept, Adopted: record.Adopted, Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built, }, record, nil diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index eb2e990..5cd726e 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -103,6 +103,11 @@ type Rendering struct { // and offered as ${machine:mesh-range}, the same way one machine's address is. MeshRange string + // Accounts is each machine's operator account, by the same internal name Names uses (novox/hq + // to-be 29). What an ssh Host block's `User` line is composed from; empty for a machine no + // operator account is known on. + Accounts map[string]string + // Kept is every operator-sealed secret in the mesh, for a module that `keeps` them. Nil when // nothing on this node keeps them, or the mesh has no operator key. Kept *KeptExport @@ -656,7 +661,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri // plane's; making a name resolve is the module's software. Emitted as ordinary files under // this module's name, so they are applied, reported and removed exactly as anything else // it declares. - given, err := FactsInto(m, r, with.Names, with.Machines, with.Suffix) + given, err := FactsInto(m, r, with.Names, with.Machines, with.Accounts, with.Suffix) if err != nil { return nil, err } diff --git a/internal/catalogue/machine_into_files.go b/internal/catalogue/machine_into_files.go index d47dab6..bc2071a 100644 --- a/internal/catalogue/machine_into_files.go +++ b/internal/catalogue/machine_into_files.go @@ -71,32 +71,53 @@ func machineFacts(r Resolution, names map[string]string, meshRange string) map[s if meshRange != "" { out["mesh-range"] = meshRange } + // The operator's login on this machine and where its home is (novox/hq to-be 29), so a module + // that writes operator config names the account and its home rather than a value it cannot know. + // Absent when no operator account is known — a headless box a person never logs into. + if r.Account != "" { + out["account"] = r.Account + out["account-home"] = accountHomeOf(r.Account, r.AccountHome) + } return out } +// accountHomeOf is where an account's home is: what was stored, or the derived default — /root for +// root, /home/ otherwise. The one place the default is written, so a fact and the store +// cannot disagree about it. +func accountHomeOf(account, home string) string { + if home != "" { + return home + } + if account == "root" { + return "/root" + } + return "/home/" + account +} + // machineInto replaces a file's ${machine:…} placeholders with what the mesh knows about the // machine the module was assigned to. // // A key the mesh does not hold is refused, for the same reason a binding's is: left alone, the // literal would be written into a configuration file and read as a value. func machineInto(resource map[string]any, facts map[string]string, module string) error { - if fmt.Sprint(resource["type"]) != "file" { - return nil - } - content, ok := resource["content"].(string) - if !ok { - return nil - } - for _, key := range machineUsed(content) { - value, has := facts[key] - if !has { - return fmt.Errorf( - "%s has a file that says ${machine:%s}, and this machine says %s", - module, key, orNothing(namesOfFacts(facts))) + // Content, and now the path and owner too: a module that writes into a person's home names it + // with ${machine:account-home} and ${machine:account}, which it cannot know until assigned + // (novox/hq to-be 29), the same reason its content names ${machine:address}. + for _, field := range []string{"path", "owner", "content"} { + s, ok := resource[field].(string) + if !ok { + continue + } + for _, key := range machineUsed(s) { + value, has := facts[key] + if !has { + return fmt.Errorf( + "%s has a %s that says ${machine:%s}, and this machine says %s", + module, field, key, orNothing(namesOfFacts(facts))) + } + s = strings.ReplaceAll(s, fmt.Sprintf("${machine:%s}", key), value) + resource[field] = s } - resource["content"] = strings.ReplaceAll( - content, fmt.Sprintf("${machine:%s}", key), value) - content = resource["content"].(string) } return nil } diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index d825359..213490e 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -28,6 +28,10 @@ type Node struct { // (novox/hq ADR 0066). A route contribution carries only a label — the subdomain — and the mesh // joins