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.
|
// 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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
// 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user