Merge pull request 'The mesh owns the operator's ~/.ssh: account fact + home-scoped resources (to-be 29)' (#86) from feat/to-be-29-mesh-owns-ssh into main

This commit was merged in pull request #86.
This commit is contained in:
2026-09-27 15:51:55 +00:00
10 changed files with 307 additions and 45 deletions
+40 -1
View File
@@ -67,8 +67,14 @@ func nodeCommand(ctx context.Context, args []string) error {
// because the damage is already done by the time it prints. // because the damage is already done by the time it prints.
return publicDomain(ctx, inv, args[1:]) 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 <node>` uses. Reports with no argument; sets with one;
// an optional second argument is the home when it is not /home/<account>.
return nodeAccount(ctx, inv, args[1:])
default: 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. // 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 <name> — what it is now; " +
"node account <name> <account> [home] — set it (home defaults to /home/<account>)")
}
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 <account>` 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 <name> — what it is now; " + const publicDomainUsage = "node public-domain <name> — what it is now; " +
"<name> <domain> to set it; <name> --clear to take it away" "<name> <domain> to set it; <name> --clear to take it away"
+23 -2
View File
@@ -83,9 +83,17 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
return catalogue.Resolution{}, nil, err 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, resolved, err := catalogue.Resolve(shelf, assigned,
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities, 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 { if err != nil {
return catalogue.Resolution{}, nil, err return catalogue.Resolution{}, nil, err
} }
@@ -520,6 +528,19 @@ func renderingFor(ctx context.Context, open *stores, node string,
return catalogue.Rendering{}, inventory.Node{}, err 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 // And every routed name → the node that serves it (novox/hq ADR 0066). Alongside the
// `<node>.internal` names above, so a container — or an internal ACME validator — resolves a // `<node>.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 // 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, Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
Certificate: certificate, Authority: authority, Mesh: private, Names: names, Certificate: certificate, Authority: authority, Mesh: private, Names: names,
Machines: machines, 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, Adopted: record.Adopted,
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built, Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built,
}, record, nil }, record, nil
+6 -1
View File
@@ -103,6 +103,11 @@ type Rendering struct {
// and offered as ${machine:mesh-range}, the same way one machine's address is. // and offered as ${machine:mesh-range}, the same way one machine's address is.
MeshRange string 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 // 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. // nothing on this node keeps them, or the mesh has no operator key.
Kept *KeptExport 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 // 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 // this module's name, so they are applied, reported and removed exactly as anything else
// it declares. // 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 { if err != nil {
return nil, err return nil, err
} }
+32 -11
View File
@@ -71,32 +71,53 @@ func machineFacts(r Resolution, names map[string]string, meshRange string) map[s
if meshRange != "" { if meshRange != "" {
out["mesh-range"] = 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 return out
} }
// accountHomeOf is where an account's home is: what was stored, or the derived default — /root for
// root, /home/<account> 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 // machineInto replaces a file's ${machine:…} placeholders with what the mesh knows about the
// machine the module was assigned to. // 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 // 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. // 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 { func machineInto(resource map[string]any, facts map[string]string, module string) error {
if fmt.Sprint(resource["type"]) != "file" { // Content, and now the path and owner too: a module that writes into a person's home names it
return nil // 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}.
content, ok := resource["content"].(string) for _, field := range []string{"path", "owner", "content"} {
s, ok := resource[field].(string)
if !ok { if !ok {
return nil continue
} }
for _, key := range machineUsed(content) { for _, key := range machineUsed(s) {
value, has := facts[key] value, has := facts[key]
if !has { if !has {
return fmt.Errorf( return fmt.Errorf(
"%s has a file that says ${machine:%s}, and this machine says %s", "%s has a %s that says ${machine:%s}, and this machine says %s",
module, key, orNothing(namesOfFacts(facts))) 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 return nil
} }
+10
View File
@@ -28,6 +28,10 @@ type Node struct {
// (novox/hq ADR 0066). A route contribution carries only a label — the subdomain — and the mesh // (novox/hq ADR 0066). A route contribution carries only a label — the subdomain — and the mesh
// joins <label>.<public-domain> to make the name it grants, interpreting neither half. // joins <label>.<public-domain> to make the name it grants, interpreting neither half.
PublicDomain string PublicDomain string
// Account is the operator's login on this machine, AccountHome where its home is (novox/hq
// to-be 29). What a home-scoped file is owned by and what ${machine:account} resolves to.
Account string
AccountHome string
} }
// World is what the rest of the mesh already has. // World is what the rest of the mesh already has.
@@ -114,6 +118,11 @@ type Resolution struct {
// (novox/hq ADR 0066). Carried from the node so that composing <label>.<public-domain> for a // (novox/hq ADR 0066). Carried from the node so that composing <label>.<public-domain> for a
// route contribution needs no store lookup here — the join is a fact about this one machine. // route contribution needs no store lookup here — the join is a fact about this one machine.
PublicDomain string PublicDomain string
// Account and AccountHome are the operator's login on this machine and where its home is
// (novox/hq to-be 29), carried from the node so a home-scoped file's owner and path resolve
// here without a store lookup.
Account string
AccountHome string
// Modules in the order they were resolved: assigned first, then what they pulled in. // Modules in the order they were resolved: assigned first, then what they pulled in.
Modules []Manifest Modules []Manifest
@@ -541,6 +550,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
} }
resolution := Resolution{Node: node.Name, At: node.At, PublicDomain: node.PublicDomain, resolution := Resolution{Node: node.Name, At: node.At, PublicDomain: node.PublicDomain,
Account: node.Account, AccountHome: node.AccountHome,
Because: because, Needs: needs, Unhostable: unhostable} Because: because, Needs: needs, Unhostable: unhostable}
for _, n := range providersFirst(order, catalogue) { for _, n := range providersFirst(order, catalogue) {
resolution.Modules = append(resolution.Modules, catalogue[n]) resolution.Modules = append(resolution.Modules, catalogue[n])
+40 -11
View File
@@ -46,6 +46,12 @@ type RosterFile struct {
// whole. A property of the fact, not of the path: the format determines whether the file is // whole. A property of the fact, not of the path: the format determines whether the file is
// wholly the mesh's, not where a module happened to ask for it. // wholly the mesh's, not where a module happened to ask for it.
Shared bool `json:"shared,omitempty"` Shared bool `json:"shared,omitempty"`
// Home places the file under this node's operator-account home and chowns it to that account,
// rather than at an absolute system path (novox/hq to-be 29). Then Path is home-relative
// (`.ssh/config.d/mesh`), resolved against the account's home on the node it is composed for; a
// node with no operator account gets no such file. This is how the ssh-client config — every
// other node's Host block — is written into a person's home rather than into /etc.
Home bool `json:"home,omitempty"`
} }
// rosterView is what a RosterFile's template sees. A closed shape — a template referencing a field // rosterView is what a RosterFile's template sees. A closed shape — a template referencing a field
@@ -57,11 +63,14 @@ type rosterView struct {
Machines []rosterEntry Machines []rosterEntry
} }
// rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address. // rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address,
// and the operator account to log into it as (novox/hq to-be 29) — empty when none is known, so an
// ssh Host block template can omit the User line for a machine nobody has an account on.
type rosterEntry struct { type rosterEntry struct {
Name string Name string
FQDN string FQDN string
Address string Address string
Account string
} }
// FactsInto renders the roster files a module asked for, as files it will be given. // FactsInto renders the roster files a module asked for, as files it will be given.
@@ -70,7 +79,7 @@ type rosterEntry struct {
// or a client does with it. This only puts it there. `every` is every name the mesh serves; // or a client does with it. This only puts it there. `every` is every name the mesh serves;
// `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both // `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both
// are given and the template chooses. // are given and the template chooses.
func FactsInto(m Manifest, r Resolution, every, machines map[string]string, suffix string) ([]map[string]any, error) { func FactsInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string) ([]map[string]any, error) {
if len(m.Facts) == 0 { if len(m.Facts) == 0 {
return nil, nil return nil, nil
} }
@@ -83,25 +92,39 @@ func FactsInto(m Manifest, r Resolution, every, machines map[string]string, suff
view := rosterView{ view := rosterView{
Node: r.Node, Node: r.Node,
Suffix: strings.TrimPrefix(suffixOr(suffix), "."), Suffix: strings.TrimPrefix(suffixOr(suffix), "."),
Names: entriesFrom(every, suffix), Names: entriesFrom(every, accounts, suffix),
Machines: entriesFrom(machines, suffix), Machines: entriesFrom(machines, accounts, suffix),
} }
out := make([]map[string]any, 0, len(names)) out := make([]map[string]any, 0, len(names))
for _, name := range names { for _, name := range names {
fact := m.Facts[name] fact := m.Facts[name]
if !strings.HasPrefix(fact.Path, "/") {
return nil, fmt.Errorf(
"%s asks for %q at %q, which is not an absolute path", m.Module, name, fact.Path)
}
content, err := renderRoster(fact.Template, view) content, err := renderRoster(fact.Template, view)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s cannot render %q: %w", m.Module, name, err) return nil, fmt.Errorf("%s cannot render %q: %w", m.Module, name, err)
} }
// Where the file goes: under the operator's home and chowned to it (a home fact), or at the
// absolute system path it names. A home fact on a machine with no operator account cannot be
// placed, and is left out rather than written to nowhere (novox/hq to-be 29).
path := fact.Path
var owner string
if fact.Home {
if r.Account == "" {
continue
}
path = accountHomeOf(r.Account, r.AccountHome) + "/" + strings.TrimLeft(fact.Path, "/")
owner = r.Account
} else if !strings.HasPrefix(fact.Path, "/") {
return nil, fmt.Errorf(
"%s asks for %q at %q, which is not an absolute path", m.Module, name, fact.Path)
}
file := map[string]any{ file := map[string]any{
"id": "fact-" + name, "type": "file", "path": fact.Path, "mode": "0644", "id": "fact-" + name, "type": "file", "path": path, "mode": "0644",
"content": content, "content": content,
} }
if owner != "" {
file["owner"] = owner
}
if fact.Shared { if fact.Shared {
// The host owns only the lines between `# BEGIN mesh <id>` and `# END mesh <id>` and // The host owns only the lines between `# BEGIN mesh <id>` and `# END mesh <id>` and
// keeps the rest of the file byte for byte; undeclared, the region goes and nothing else // keeps the rest of the file byte for byte; undeclared, the region goes and nothing else
@@ -136,11 +159,17 @@ func renderRoster(tmpl string, view rosterView) (string, error) {
// and does not yet know where it is, which is the ordinary state between adding a machine and it // and does not yet know where it is, which is the ordinary state between adding a machine and it
// joining. Writing the name anyway would give a name that resolves to nothing, and a connection to // joining. Writing the name anyway would give a name that resolves to nothing, and a connection to
// that hangs; leaving it out fails at once and says the name is unknown. // that hangs; leaving it out fails at once and says the name is unknown.
func entriesFrom(addresses map[string]string, suffix string) []rosterEntry { func entriesFrom(addresses, accounts map[string]string, suffix string) []rosterEntry {
out := make([]rosterEntry, 0, len(addresses)) out := make([]rosterEntry, 0, len(addresses))
for _, name := range sortedNames(addresses) { for _, name := range sortedNames(addresses) {
internal, bare := meshName(name, suffix) internal, bare := meshName(name, suffix)
out = append(out, rosterEntry{Name: bare, FQDN: internal, Address: addresses[name]}) // The account is looked up by whichever key the caller keys accounts on — the internal name
// or the bare one — so a template gets the right login however the maps were built.
account := accounts[name]
if account == "" {
account = accounts[bare]
}
out = append(out, rosterEntry{Name: bare, FQDN: internal, Address: addresses[name], Account: account})
} }
return out return out
} }
+51 -12
View File
@@ -14,7 +14,7 @@ func TestAModuleIsGivenTheFileItAskedFor(t *testing.T) {
m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ m := Manifest{Module: "resolver", Facts: map[string]RosterFile{
"zones": {Path: "/etc/mesh/zones.conf", Template: "{{range .Machines}}address=/{{.FQDN}}/{{.Address}}\n{{end}}"}, "zones": {Path: "/etc/mesh/zones.conf", Template: "{{range .Machines}}address=/{{.FQDN}}/{{.Address}}\n{{end}}"},
}} }}
given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, "") given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, nil, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -43,7 +43,7 @@ func TestASharedFactIsWrittenIntoARegion(t *testing.T) {
"node-names": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}", Shared: true}, "node-names": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}", Shared: true},
"node-zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"}, "node-zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"},
}} }}
given, err := FactsInto(m, Resolution{Node: "homer"}, roster, roster, "") given, err := FactsInto(m, Resolution{Node: "homer"}, roster, roster, nil, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -68,11 +68,11 @@ func TestTheFormatIsTheModulesOwn(t *testing.T) {
sshish := Manifest{Module: "b", Facts: map[string]RosterFile{ sshish := Manifest{Module: "b", Facts: map[string]RosterFile{
"f": {Path: "/f", Template: "{{range .Names}}Host {{.Name}}\n HostName {{.FQDN}}\n{{end}}"}}} "f": {Path: "/f", Template: "{{range .Names}}Host {{.Name}}\n HostName {{.FQDN}}\n{{end}}"}}}
h, err := FactsInto(hostsish, Resolution{Node: "homer"}, roster, roster, "") h, err := FactsInto(hostsish, Resolution{Node: "homer"}, roster, roster, nil, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
s, err := FactsInto(sshish, Resolution{Node: "homer"}, roster, roster, "") s, err := FactsInto(sshish, Resolution{Node: "homer"}, roster, roster, nil, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -89,7 +89,7 @@ func TestTheFormatIsTheModulesOwn(t *testing.T) {
func TestABrokenTemplateIsRefusedHere(t *testing.T) { func TestABrokenTemplateIsRefusedHere(t *testing.T) {
m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ m := Manifest{Module: "resolver", Facts: map[string]RosterFile{
"zones": {Path: "/etc/zones", Template: "{{range .Machines}}oops"}}} "zones": {Path: "/etc/zones", Template: "{{range .Machines}}oops"}}}
_, err := FactsInto(m, Resolution{}, nil, nil, "") _, err := FactsInto(m, Resolution{}, nil, nil, nil, "")
if err == nil { if err == nil {
t.Fatal("a template that does not parse was accepted, so the machine gets an empty file") t.Fatal("a template that does not parse was accepted, so the machine gets an empty file")
} }
@@ -103,7 +103,7 @@ func TestABrokenTemplateIsRefusedHere(t *testing.T) {
func TestATemplateReadingWhatTheMeshDoesNotHaveIsRefused(t *testing.T) { func TestATemplateReadingWhatTheMeshDoesNotHaveIsRefused(t *testing.T) {
m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ m := Manifest{Module: "resolver", Facts: map[string]RosterFile{
"zones": {Path: "/etc/zones", Template: "{{.Weather}}"}}} "zones": {Path: "/etc/zones", Template: "{{.Weather}}"}}}
if _, err := FactsInto(m, Resolution{}, nil, nil, ""); err == nil { if _, err := FactsInto(m, Resolution{}, nil, nil, nil, ""); err == nil {
t.Fatal("a template read a field nobody computes and rendered anyway, silently") t.Fatal("a template read a field nobody computes and rendered anyway, silently")
} }
} }
@@ -112,7 +112,7 @@ func TestATemplateReadingWhatTheMeshDoesNotHaveIsRefused(t *testing.T) {
func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) { func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) {
m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ m := Manifest{Module: "resolver", Facts: map[string]RosterFile{
"hosts": {Path: "etc/hosts", Template: "x"}}} "hosts": {Path: "etc/hosts", Template: "x"}}}
if _, err := FactsInto(m, Resolution{}, nil, nil, ""); err == nil { if _, err := FactsInto(m, Resolution{}, nil, nil, nil, ""); err == nil {
t.Fatal("a relative path was accepted") t.Fatal("a relative path was accepted")
} }
} }
@@ -125,7 +125,7 @@ func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) {
func TestAMachineWithNoAddressIsNotInTheRoster(t *testing.T) { func TestAMachineWithNoAddressIsNotInTheRoster(t *testing.T) {
m := Manifest{Module: "a", Facts: map[string]RosterFile{ m := Manifest{Module: "a", Facts: map[string]RosterFile{
"f": {Path: "/f", Template: "{{range .Machines}}{{.Name}}\n{{end}}"}}} "f": {Path: "/f", Template: "{{range .Machines}}{{.Name}}\n{{end}}"}}}
given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, "") given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, nil, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -142,11 +142,11 @@ func TestNamesAreNotSuffixedTwice(t *testing.T) {
bare := map[string]string{"homer": "10.42.0.1"} bare := map[string]string{"homer": "10.42.0.1"}
tmpl := RosterFile{Path: "/f", Template: "{{range .Machines}}{{.FQDN}} {{.Name}}\n{{end}}"} tmpl := RosterFile{Path: "/f", Template: "{{range .Machines}}{{.FQDN}} {{.Name}}\n{{end}}"}
fromInternal, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, internal, internal, "") fromInternal, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, internal, internal, nil, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
fromBare, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, bare, bare, "") fromBare, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, bare, bare, nil, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -165,7 +165,7 @@ func TestTheSuffixIsCarriedAsComposed(t *testing.T) {
names := map[string]string{"homer.lan": "10.42.0.1"} names := map[string]string{"homer.lan": "10.42.0.1"}
m := Manifest{Module: "a", Facts: map[string]RosterFile{ m := Manifest{Module: "a", Facts: map[string]RosterFile{
"f": {Path: "/f", Template: "local=/{{.Suffix}}/\n{{range .Machines}}{{.FQDN}}\n{{end}}"}}} "f": {Path: "/f", Template: "local=/{{.Suffix}}/\n{{range .Machines}}{{.FQDN}}\n{{end}}"}}}
given, err := FactsInto(m, Resolution{Node: "homer"}, names, names, "lan") given, err := FactsInto(m, Resolution{Node: "homer"}, names, names, nil, "lan")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -193,7 +193,7 @@ func TestATemplateChoosesMachinesOrEveryName(t *testing.T) {
"zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"}, "zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"},
"hosts": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}"}, "hosts": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}"},
}} }}
given, err := FactsInto(m, Resolution{Node: "homer"}, every, machines, "") given, err := FactsInto(m, Resolution{Node: "homer"}, every, machines, nil, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -219,3 +219,42 @@ func TestATemplateChoosesMachinesOrEveryName(t *testing.T) {
} }
} }
} }
// A home fact is placed under the operator account's home and chowned to it, and its template sees
// each node's account (novox/hq to-be 29) — the ssh-client config is the case.
func TestAHomeFactIsPlacedUnderTheAccountsHomeAndOwnedByIt(t *testing.T) {
names := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"}
accounts := map[string]string{"homer": "jo", "marge": "jo"}
m := Manifest{Module: "ssh-client", Facts: map[string]RosterFile{
"ssh-config": {Path: ".ssh/config.d/mesh", Home: true,
Template: "{{range .Names}}Host {{.Name}}\n HostName {{.FQDN}}\n User {{.Account}}\n{{end}}"}}}
given, err := FactsInto(m, Resolution{Node: "homer", Account: "jo"}, names, names, accounts, "")
if err != nil {
t.Fatal(err)
}
f := given[0]
if f["path"] != "/home/jo/.ssh/config.d/mesh" {
t.Fatalf("the home fact was not placed under the account's home: %v", f["path"])
}
if f["owner"] != "jo" {
t.Fatalf("the home fact is not owned by the account: %v", f["owner"])
}
if !strings.Contains(f["content"].(string), "Host marge\n HostName marge.internal\n User jo") {
t.Fatalf("the config does not name the peer's account:\n%s", f["content"])
}
}
// A machine with no operator account gets no home fact — it cannot be placed, so it is left out
// rather than written to nowhere.
func TestAHomeFactIsSkippedWhereThereIsNoAccount(t *testing.T) {
names := map[string]string{"homer.internal": "10.42.0.1"}
m := Manifest{Module: "ssh-client", Facts: map[string]RosterFile{
"ssh-config": {Path: ".ssh/config", Home: true, Template: "x"}}}
given, err := FactsInto(m, Resolution{Node: "homer"}, names, names, nil, "")
if err != nil {
t.Fatal(err)
}
if len(given) != 0 {
t.Fatalf("a home fact was placed on a machine with no operator account: %v", given)
}
}
+46
View File
@@ -0,0 +1,46 @@
package catalogue
import (
"strings"
"testing"
)
// The ssh-client module, composed as a machine receives it (novox/hq to-be 29): every other node's
// Host block written into a marked region of the operator's ~/.ssh/config, owned by the account,
// with ~/.ssh created 0700 — the operator's own config kept.
func TestSSHClientOwnsTheOperatorsSSHConfig(t *testing.T) {
shelf := shelf(catalogueManifest(t, "ssh-client"))
got, err := Resolve(shelf, []string{"ssh-client"},
Node{Name: "homer", At: "homer.internal", Account: "jo"}, World{})
if err != nil {
t.Fatal(err)
}
names := map[string]string{"homer.internal": "10.10.0.1", "marge.internal": "10.10.0.2"}
out, err := got.Declaration(Rendering{
Names: names, Machines: names, Accounts: map[string]string{"homer": "jo", "marge": "jo"},
Suffix: "internal",
})
if err != nil {
t.Fatal(err)
}
by := map[string]map[string]any{}
for _, r := range out {
by[r["id"].(string)] = r
}
dir := by["ssh-client.ssh-dir"]
if dir == nil || dir["path"] != "/home/jo/.ssh" || dir["owner"] != "jo" || dir["mode"] != "0700" {
t.Fatalf("~/.ssh is not created 0700 owned by the account: %v", dir)
}
cfg := by["ssh-client.fact-ssh-config"]
if cfg == nil || cfg["path"] != "/home/jo/.ssh/config" || cfg["owner"] != "jo" || cfg["into"] != "block" {
t.Fatalf("the ssh config is not written into the operator's ~/.ssh/config as a region: %v", cfg)
}
body := cfg["content"].(string)
if !strings.Contains(body, "Host marge marge.internal") || !strings.Contains(body, "User jo") {
t.Fatalf("the config does not name the peer node and its account:\n%s", body)
}
if strings.Contains(body, "Host homer ") {
t.Fatalf("the config names the machine itself, not only its peers:\n%s", body)
}
}
@@ -0,0 +1,13 @@
-- A node has an operator account: the human login on it (novox/hq to-be 29).
--
-- The mesh modelled the machine but not the person on it — `jochens` on novox, `ace` on ace,
-- `jochen` on shanks and g14. That name decides who a file under a home is owned by and which
-- account `ssh <node>` logs in as; it was silently lost when the predecessor's per-node `user:`
-- was not carried over, and `ssh ace` failed to `ace` because nothing here said so.
--
-- Empty rather than null and defaulted, because "no operator account known yet" is a real state
-- (a freshly enrolled machine, a headless box). The home is stored too rather than always assumed
-- to be /home/<account>, because root's is /root and a machine may put a home elsewhere; empty
-- means "derive it" (/root for root, /home/<account> otherwise), so the common case needs no entry.
alter table node add column account text not null default '';
alter table node add column account_home text not null default '';
+41 -2
View File
@@ -55,6 +55,29 @@ type Node struct {
// AdoptedSince is when it last became so; zero for a converged node. // AdoptedSince is when it last became so; zero for a converged node.
Adopted bool Adopted bool
AdoptedSince time.Time AdoptedSince time.Time
// Account is the operator's login on this machine — `jochens` on novox, `ace` on ace (novox/hq
// to-be 29). Empty when none is known yet. AccountHome is where that account's home is; empty
// means derive it (/root for root, /home/<account> otherwise), so the common case needs no
// entry. What decides who a file under a home is owned by, and which account `ssh <node>` uses.
Account string
AccountHome string
}
// Home is the account's home directory, derived when not stored: /root for root, /home/<account>
// otherwise. Empty only when there is no account at all.
func (n Node) Home() string {
if n.AccountHome != "" {
return n.AccountHome
}
switch n.Account {
case "":
return ""
case "root":
return "/root"
default:
return "/home/" + n.Account
}
} }
// Silent is how long since this node was last heard from, and whether it ever was. // Silent is how long since this node was last heard from, and whether it ever was.
@@ -112,12 +135,13 @@ func (i *Inventory) AddNodeAs(ctx context.Context, name string, adopted bool) (N
// nodeColumns and scanNode are the one reading of a node row, so every way of finding a node // nodeColumns and scanNode are the one reading of a node row, so every way of finding a node
// says whether it is adopted. // says whether it is adopted.
const nodeColumns = `id, name, created, last_seen, adopted, adopted_since` const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home`
func scanNode(row pgx.Row) (Node, error) { func scanNode(row pgx.Row) (Node, error) {
var n Node var n Node
var seen, since *time.Time var seen, since *time.Time
if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since); err != nil { if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since,
&n.Account, &n.AccountHome); err != nil {
return Node{}, err return Node{}, err
} }
if seen != nil { if seen != nil {
@@ -129,6 +153,21 @@ func scanNode(row pgx.Row) (Node, error) {
return n, nil return n, nil
} }
// SetAccount records the operator account on a node — its human login — and optionally where that
// account's home is (novox/hq to-be 29). An empty home means the mesh derives it. Clearing the
// account (empty name) is allowed: a machine may stop having a known operator.
func (i *Inventory) SetAccount(ctx context.Context, node, account, home string) error {
tag, err := i.store.Pool().Exec(ctx,
`update node set account = $1, account_home = $2 where name = $3`, account, home, node)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: %s", ErrNoSuchNode, node)
}
return nil
}
// Nodes are every node record, oldest first. // Nodes are every node record, oldest first.
func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) { func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
rows, err := i.store.Pool().Query(ctx, rows, err := i.store.Pool().Query(ctx,