The mesh owns the operator's ~/.ssh: account fact + home-scoped resources (to-be 29)
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.
This commit is contained in:
@@ -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 <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:
|
||||
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 <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; " +
|
||||
"<name> <domain> to set it; <name> --clear to take it away"
|
||||
|
||||
|
||||
@@ -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
|
||||
// `<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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/<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
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ type Node struct {
|
||||
// (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.
|
||||
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.
|
||||
@@ -114,6 +118,11 @@ type Resolution struct {
|
||||
// (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.
|
||||
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 []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,
|
||||
Account: node.Account, AccountHome: node.AccountHome,
|
||||
Because: because, Needs: needs, Unhostable: unhostable}
|
||||
for _, n := range providersFirst(order, catalogue) {
|
||||
resolution.Modules = append(resolution.Modules, catalogue[n])
|
||||
|
||||
@@ -46,6 +46,12 @@ type RosterFile struct {
|
||||
// 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.
|
||||
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
|
||||
@@ -57,11 +63,14 @@ type rosterView struct {
|
||||
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 {
|
||||
Name string
|
||||
FQDN string
|
||||
Address string
|
||||
Account string
|
||||
}
|
||||
|
||||
// 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;
|
||||
// `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both
|
||||
// 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 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -83,25 +92,39 @@ func FactsInto(m Manifest, r Resolution, every, machines map[string]string, suff
|
||||
view := rosterView{
|
||||
Node: r.Node,
|
||||
Suffix: strings.TrimPrefix(suffixOr(suffix), "."),
|
||||
Names: entriesFrom(every, suffix),
|
||||
Machines: entriesFrom(machines, suffix),
|
||||
Names: entriesFrom(every, accounts, suffix),
|
||||
Machines: entriesFrom(machines, accounts, suffix),
|
||||
}
|
||||
|
||||
out := make([]map[string]any, 0, len(names))
|
||||
for _, name := range names {
|
||||
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)
|
||||
if err != nil {
|
||||
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{
|
||||
"id": "fact-" + name, "type": "file", "path": fact.Path, "mode": "0644",
|
||||
"id": "fact-" + name, "type": "file", "path": path, "mode": "0644",
|
||||
"content": content,
|
||||
}
|
||||
if owner != "" {
|
||||
file["owner"] = owner
|
||||
}
|
||||
if fact.Shared {
|
||||
// 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
|
||||
@@ -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
|
||||
// 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.
|
||||
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))
|
||||
for _, name := range sortedNames(addresses) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestAModuleIsGivenTheFileItAskedFor(t *testing.T) {
|
||||
m := Manifest{Module: "resolver", Facts: map[string]RosterFile{
|
||||
"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 {
|
||||
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-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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -68,11 +68,11 @@ func TestTheFormatIsTheModulesOwn(t *testing.T) {
|
||||
sshish := Manifest{Module: "b", Facts: map[string]RosterFile{
|
||||
"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 {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func TestTheFormatIsTheModulesOwn(t *testing.T) {
|
||||
func TestABrokenTemplateIsRefusedHere(t *testing.T) {
|
||||
m := Manifest{Module: "resolver", Facts: map[string]RosterFile{
|
||||
"zones": {Path: "/etc/zones", Template: "{{range .Machines}}oops"}}}
|
||||
_, err := FactsInto(m, Resolution{}, nil, nil, "")
|
||||
_, err := FactsInto(m, Resolution{}, nil, nil, nil, "")
|
||||
if err == nil {
|
||||
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) {
|
||||
m := Manifest{Module: "resolver", Facts: map[string]RosterFile{
|
||||
"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")
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func TestATemplateReadingWhatTheMeshDoesNotHaveIsRefused(t *testing.T) {
|
||||
func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) {
|
||||
m := Manifest{Module: "resolver", Facts: map[string]RosterFile{
|
||||
"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")
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) {
|
||||
func TestAMachineWithNoAddressIsNotInTheRoster(t *testing.T) {
|
||||
m := Manifest{Module: "a", Facts: map[string]RosterFile{
|
||||
"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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -142,11 +142,11 @@ func TestNamesAreNotSuffixedTwice(t *testing.T) {
|
||||
bare := map[string]string{"homer": "10.42.0.1"}
|
||||
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 {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func TestTheSuffixIsCarriedAsComposed(t *testing.T) {
|
||||
names := map[string]string{"homer.lan": "10.42.0.1"}
|
||||
m := Manifest{Module: "a", Facts: map[string]RosterFile{
|
||||
"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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func TestATemplateChoosesMachinesOrEveryName(t *testing.T) {
|
||||
"zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
@@ -55,6 +55,29 @@ type Node struct {
|
||||
// AdoptedSince is when it last became so; zero for a converged node.
|
||||
Adopted bool
|
||||
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.
|
||||
@@ -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
|
||||
// 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) {
|
||||
var n Node
|
||||
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
|
||||
}
|
||||
if seen != nil {
|
||||
@@ -129,6 +153,21 @@ func scanNode(row pgx.Row) (Node, error) {
|
||||
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.
|
||||
func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
|
||||
Reference in New Issue
Block a user