Files
mesh-controller/internal/catalogue/machine_into_files.go
T
jochen c30b79dd2a Name the account agents run as on a node, and say whether it can become root
On the control node every agent ran as the operator's account, which has
passwordless sudo, so an agent could become root without a person (hq ADR
0266). A node now names an agent account at the controller's terminal only;
the agent's module declares it never to become root, the node-engine judges
that, and the self-check (DA) raises agent-can-become-root while it does not
hold, so ADR 0259's router can rest on it.
2026-10-08 21:46:10 +02:00

176 lines
7.5 KiB
Go

package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// What a module may say about the machine it is running on.
//
// **A module cannot know where it will be assigned, and sometimes it must say so anyway.** Every
// other name in a declaration is either the module's own — which it wrote — or something it
// requires, which arrives as a binding. The machine underneath is neither: it is chosen when the
// module is assigned, long after the manifest was written, and until now nothing carried it into a
// file.
//
// The case that found this is a certificate authority inside the mesh (novox/hq ADR 0066). A proxy
// reaches it at the address the mesh handed over, `<machine>.internal` — so the authority's own
// certificate has to be issued for that name, or the first thing that happens is the proxy refusing
// to talk to it. The authority is the one thing that cannot be told its name by a binding: it
// provides, it does not require. Written as a literal it would be a manifest carrying one
// deployment's machine name, which is the shape [ADR 0066] exists to remove.
//
// Three facts, all the mesh's own vocabulary — the same `node` and `at` a contribution already
// carries, and the address behind `at`, for software that takes an address and not a name. The
// case that found the third is a resolver pointing the container runtime at itself: the runtime's
// list of resolvers is addresses, because a name there would have to be resolved by the resolver
// it names. Nothing about what a machine is *for*: that would be the mesh learning what a module
// means, which it does not do.
// ofMachine is where a module says a fact about the machine underneath it belongs:
// ${machine:<key>}.
var ofMachine = regexp.MustCompile(`\$\{machine:([a-z0-9][a-z0-9_-]*)\}`)
// machineUsed are the keys a file's content asks for, first appearance first.
func machineUsed(content string) []string {
var used []string
seen := map[string]bool{}
for _, m := range ofMachine.FindAllStringSubmatch(content, -1) {
if key := m[1]; !seen[key] {
seen[key] = true
used = append(used, key)
}
}
return used
}
// machineFacts is what a module may name about the machine it was assigned to.
//
// `at` is absent rather than empty when the machine is not on the private network. A module asking
// to be reached at an address that does not exist is a misconfiguration, and it is said here —
// where the module and the machine are both named — rather than discovered later as a certificate
// nobody can verify.
//
// `address` is what `at` resolves to, read from the names the control plane composed — the same map
// the hosts file and the resolver's wildcards are written from, so a file naming the machine's
// address and the file every other machine reaches it by cannot disagree. Absent, like `at`, when
// the machine is off the network or the mesh has not placed it.
func machineFacts(r Resolution, names map[string]string, meshRange string) map[string]string {
out := map[string]string{"name": r.Node}
if r.At != "" {
out["at"] = r.At
if address := names[r.At]; address != "" {
out["address"] = address
}
}
// The private network's whole range — a mesh-wide fact, not this machine's, but named here
// because a module cannot know it and sometimes must (an intrusion filter that must never ban a
// tunnel peer). Absent when the mesh has no range to give.
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)
}
// The account agents run as here, and whether it must never become root (novox/hq ADR 0266). The agent
// account where the node names one; the operator account otherwise, so a module writing the agent's
// home names one fact on every machine. `agent-root` is "never" only for an account of the agents' own:
// the user resource naming it then asks the node-engine to judge it, and on a machine where agents run
// as the operator it is empty, asserting nothing — the operator's account may become root there.
if agent, home := r.agentAccount(); agent != "" {
out["agent-account"] = agent
out["agent-home"] = home
out["agent-root"] = ""
if r.AgentAccount != "" {
out["agent-root"] = RootNever
}
}
return out
}
// RootNever is what a user resource's `root` says of an account that must never become root without a
// person (novox/hq ADR 0266); the node-engine judges it.
const RootNever = "never"
// agentAccount is the account agents run as on this machine and its home: the agent account when the node
// names one (novox/hq ADR 0266), else the operator account; empty when neither is known.
func (r Resolution) agentAccount() (string, string) {
if r.AgentAccount != "" {
return r.AgentAccount, agentHomeOf(r.AgentAccount, r.AgentAccountHome)
}
if r.Account != "" {
return r.Account, accountHomeOf(r.Account, r.AccountHome)
}
return "", ""
}
// agentHomeOf is where the agent account's home is: what was stored, or /home/<account>. Never /root: the
// agent account is never root.
func agentHomeOf(account, home string) string {
if home != "" {
return home
}
return "/home/" + account
}
// 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 {
// 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}. And the name a
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
// the shell module makes the operator's account its holder's login shell, and the desktop's
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person. And a
// user's `root`: the agent's module declares the account agents run as with ${machine:agent-root},
// "never" only where that account is the agents' own (novox/hq ADR 0266).
for _, field := range []string{"path", "owner", "content", "name", "user", "root"} {
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
}
}
return nil
}
func namesOfFacts(facts map[string]string) []string {
out := make([]string, 0, len(facts))
for k := range facts {
out = append(out, k)
}
sort.Strings(out)
return out
}