On the control node agents are to run as an account that cannot become root (hq ADR 0266). The runtime keeps running as the operator, so the token, the identity and the home-scope items go to the agent's home through a helper started as that account; the licence and the account file follow the move, what was placed in the operator's home goes once the agent holds the licence, and the guard refuses every tool of a session running as anyone else, recording to the journal. An API key is taken only from the agent's home and only when it is one, here and in the licence manager, so the tool cannot carry off the runtime's credential. Unchanged where no agent account is named.
335 lines
11 KiB
Go
335 lines
11 KiB
Go
package main
|
|
|
|
// What the mesh says about its own tools and machines, as the agent's instructions and the guard on its
|
|
// shell need it (novox/hq ADR 0245): every seat verb and module tool that says what shell command it
|
|
// replaces, and the mesh's machines by every name and address a session could reach one by.
|
|
//
|
|
// Asked of the controller — `tools` for the seats' verbs, `modules` for the modules' own tools, `nodes`
|
|
// and `node` for the machines — at start and every few minutes, kept in the module's state so a render
|
|
// never waits for the bus, and rendered whenever it changes. Nothing here is written by hand: a verb that
|
|
// gains a `replaces` in the records is in the next render's table and the guard's next refusal.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Replacement is one verb or tool and the shell commands it is the mesh's way to do.
|
|
type Replacement struct {
|
|
// Address is how it is called: `<seat>.<verb>`, or `<module>.<tool>`; NodeScoped says a machine goes
|
|
// before it, `<node>/`.
|
|
Address string `json:"address"`
|
|
NodeScoped bool `json:"node_scoped,omitempty"`
|
|
Seat bool `json:"seat,omitempty"`
|
|
Does string `json:"does,omitempty"`
|
|
Replaces []string `json:"replaces"`
|
|
// On is where a module's tool runs; empty for a seat's verb, which every holder serves.
|
|
On []string `json:"on,omitempty"`
|
|
}
|
|
|
|
// Machine is one of the mesh's machines and the names and addresses it is reached by.
|
|
type Machine struct {
|
|
Name string `json:"name"`
|
|
Domains []string `json:"domains,omitempty"`
|
|
Addresses []string `json:"addresses,omitempty"`
|
|
}
|
|
|
|
// MeshTools is what the controller said, as kept in the module's state.
|
|
type MeshTools struct {
|
|
Replacements []Replacement `json:"replacements"`
|
|
Machines []Machine `json:"machines"`
|
|
// Asked is when the controller last answered.
|
|
Asked string `json:"asked,omitempty"`
|
|
// Refused is how often the guard here named each tool instead of a work-around, from its record: the
|
|
// table's order. Never kept — read from the record at each render.
|
|
Refused map[string]int `json:"-"`
|
|
}
|
|
|
|
// RefusedCounts are how often the guard's record named each tool, over its last lines.
|
|
func RefusedCounts(log string) map[string]int {
|
|
out := map[string]int{}
|
|
for _, e := range ReadGuardLog(log, 2000) {
|
|
for _, t := range e.Tools {
|
|
out[t]++
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// InternalSuffix is the mesh's own names' domain: every machine and service is `<name>.internal`
|
|
// (novox/hq ADR 0194), and a session reaches none of them by ssh.
|
|
const InternalSuffix = ".internal"
|
|
|
|
func (p Paths) meshTools() string { return filepath.Join(p.State, "mesh-tools.json") }
|
|
|
|
// ReadMeshTools is what was kept, or nil.
|
|
func ReadMeshTools(p Paths) *MeshTools {
|
|
var m MeshTools
|
|
if !readJSON(p.meshTools(), &m) {
|
|
return nil
|
|
}
|
|
return &m
|
|
}
|
|
|
|
// verbAnswer is a controller verb's answer: the command's printed output, and its JSON when it printed one.
|
|
func verbAnswer(raw json.RawMessage) json.RawMessage {
|
|
var r struct {
|
|
Output string `json:"output"`
|
|
Answer json.RawMessage `json:"answer"`
|
|
}
|
|
if json.Unmarshal(raw, &r) != nil {
|
|
return raw
|
|
}
|
|
if len(r.Answer) > 0 && string(r.Answer) != "null" {
|
|
return r.Answer
|
|
}
|
|
if r.Output != "" {
|
|
t := strings.TrimSpace(r.Output)
|
|
for _, open := range []string{"[", "{"} {
|
|
if strings.HasPrefix(t, open) {
|
|
return json.RawMessage(t)
|
|
}
|
|
if i := strings.Index(t, "\n"+open); i >= 0 {
|
|
return json.RawMessage(strings.TrimSpace(t[i+1:]))
|
|
}
|
|
}
|
|
return json.RawMessage(nil)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func outputOf(raw json.RawMessage) string {
|
|
var r struct {
|
|
Output string `json:"output"`
|
|
}
|
|
_ = json.Unmarshal(raw, &r)
|
|
return r.Output
|
|
}
|
|
|
|
var publicDomain = regexp.MustCompile(`(?m)^\s*public domain\s+(\S+)\s*$`)
|
|
|
|
// firstSentence is what a verb does, short: its description to the first full stop, at most 70 characters.
|
|
func firstSentence(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if i := strings.Index(s, ". "); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
s = strings.TrimSuffix(s, ".")
|
|
if i := strings.IndexAny(s, ":;("); i > 20 {
|
|
s = strings.TrimSpace(s[:i])
|
|
}
|
|
if r := []rune(s); len(r) > 70 {
|
|
s = strings.TrimSpace(string(r[:69])) + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ReplacementsOf reads the controller's `tools` and `modules` answers: the seats' verbs first, in the
|
|
// records' order, then the modules' own tools by module and tool.
|
|
func ReplacementsOf(tools, modules json.RawMessage) []Replacement {
|
|
var out []Replacement
|
|
var seats struct {
|
|
Seats []struct {
|
|
Seat string `json:"seat"`
|
|
Scope string `json:"scope"`
|
|
Tools []struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Replaces []string `json:"replaces"`
|
|
} `json:"tools"`
|
|
} `json:"seats"`
|
|
}
|
|
if json.Unmarshal(tools, &seats) == nil {
|
|
for _, s := range seats.Seats {
|
|
for _, t := range s.Tools {
|
|
if len(t.Replaces) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, Replacement{Address: s.Seat + "." + t.Name, NodeScoped: s.Scope == "node", Seat: true,
|
|
Does: firstSentence(t.Description), Replaces: t.Replaces})
|
|
}
|
|
}
|
|
}
|
|
var listed []struct {
|
|
Module string `json:"module"`
|
|
On []string `json:"on"`
|
|
Replaces map[string][]string `json:"replaces"`
|
|
}
|
|
if json.Unmarshal(modules, &listed) == nil {
|
|
sort.SliceStable(listed, func(i, j int) bool { return listed[i].Module < listed[j].Module })
|
|
for _, m := range listed {
|
|
names := make([]string, 0, len(m.Replaces))
|
|
for t := range m.Replaces {
|
|
names = append(names, t)
|
|
}
|
|
sort.Strings(names)
|
|
for _, t := range names {
|
|
if len(m.Replaces[t]) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, Replacement{Address: m.Module + "." + t, NodeScoped: true, Replaces: m.Replaces[t], On: m.On,
|
|
Does: strings.ReplaceAll(strings.TrimPrefix(t, strings.ReplaceAll(m.Module, "-", "_")+"_"), "_", " ")})
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// lookup resolves one name, briefly: a name that does not resolve here is no address.
|
|
func lookup(name string) []string {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
addrs, _ := net.DefaultResolver.LookupHost(ctx, name)
|
|
return addrs
|
|
}
|
|
|
|
// AskMeshTools asks the controller now. A part that does not answer is left out and said in the error;
|
|
// what did answer is kept.
|
|
func AskMeshTools(ask Ask, resolve func(string) []string) (MeshTools, error) {
|
|
var m MeshTools
|
|
tools, terr := ask("seat:mesh-controller.tools", map[string]any{})
|
|
modules, merr := ask("seat:mesh-controller.modules", map[string]any{})
|
|
if terr != nil && merr != nil {
|
|
return m, terr
|
|
}
|
|
m.Replacements = ReplacementsOf(verbAnswer(tools), verbAnswer(modules))
|
|
nodesRaw, err := ask("seat:mesh-controller.nodes", map[string]any{})
|
|
if err != nil {
|
|
return m, err
|
|
}
|
|
var nodes []struct {
|
|
Name string `json:"name"`
|
|
}
|
|
_ = json.Unmarshal(verbAnswer(nodesRaw), &nodes)
|
|
for _, n := range nodes {
|
|
if n.Name == "" {
|
|
continue
|
|
}
|
|
machine := Machine{Name: n.Name, Domains: []string{n.Name + InternalSuffix}}
|
|
if shown, err := ask("seat:mesh-controller.node", map[string]any{"node": n.Name}); err == nil {
|
|
if d := publicDomain.FindStringSubmatch(outputOf(shown)); d != nil {
|
|
machine.Domains = append(machine.Domains, strings.ToLower(d[1]))
|
|
}
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, name := range append([]string{n.Name}, machine.Domains...) {
|
|
for _, a := range resolve(name) {
|
|
if !seen[a] && !net.ParseIP(a).IsLoopback() {
|
|
seen[a] = true
|
|
machine.Addresses = append(machine.Addresses, a)
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(machine.Addresses)
|
|
m.Machines = append(m.Machines, machine)
|
|
}
|
|
sort.Slice(m.Machines, func(i, j int) bool { return m.Machines[i].Name < m.Machines[j].Name })
|
|
return m, nil
|
|
}
|
|
|
|
// KeepMeshTools writes what was asked to the module's state when it differs from what was kept, and
|
|
// answers whether it did. When was asked is not a difference.
|
|
func KeepMeshTools(p Paths, m MeshTools) bool {
|
|
if was := ReadMeshTools(p); was != nil {
|
|
a, b := *was, m
|
|
a.Asked, b.Asked = "", ""
|
|
x, _ := json.Marshal(a)
|
|
y, _ := json.Marshal(b)
|
|
if string(x) == string(y) {
|
|
return false
|
|
}
|
|
}
|
|
m.Asked = time.Now().UTC().Format(time.RFC3339)
|
|
raw, _ := indented(m)
|
|
tmp := p.meshTools() + ".tmp"
|
|
if os.WriteFile(tmp, raw, 0o600) != nil {
|
|
return false
|
|
}
|
|
return os.Rename(tmp, p.meshTools()) == nil
|
|
}
|
|
|
|
// GuardDataOf is what the guard judges with on this machine.
|
|
func GuardDataOf(p Paths, m *MeshTools) GuardData {
|
|
d := GuardData{Node: p.Node, Log: filepath.Join(p.State, "guard.log"), Machines: []Machine{}, Replacements: []Replacement{}}
|
|
if p.Confined() {
|
|
// The session's account cannot write the module's state, and must not rewrite its own record (ADR 0266).
|
|
d.Agent, d.Log = p.Agent, JournalRecord+GuardJournalID
|
|
}
|
|
if m != nil {
|
|
d.Machines, d.Replacements = m.Machines, m.Replacements
|
|
}
|
|
return d
|
|
}
|
|
|
|
// guardFiles is the guard to place: this module's own executable and what it judges with. None where the
|
|
// executable cannot be named — then no hook is written, rather than one that cannot run.
|
|
func guardFiles(p Paths, m *MeshTools) *GuardFiles {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
|
exe = resolved
|
|
}
|
|
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m)}
|
|
}
|
|
|
|
// MeshToolsEvery is how often the controller is asked again.
|
|
const MeshToolsEvery = 10 * time.Minute
|
|
|
|
// refreshMeshTools asks the controller at start and every few minutes, and renders when what it said changed.
|
|
func refreshMeshTools(p Paths) {
|
|
for {
|
|
m, err := AskMeshTools(ask, lookup)
|
|
if err != nil {
|
|
say("asking the controller what each tool replaces: %v", err)
|
|
}
|
|
if (err == nil || len(m.Replacements) > 0) && KeepMeshTools(p, m) {
|
|
if _, err := RenderNow(p, writeManaged); err != nil {
|
|
say("rendering what the controller said of its tools: %v", err)
|
|
} else {
|
|
say("the mesh's tools changed: %d replace a command, %d machines; rendered", len(m.Replacements), len(m.Machines))
|
|
}
|
|
}
|
|
time.Sleep(MeshToolsEvery)
|
|
}
|
|
}
|
|
|
|
// GuardStatus is what claude_code_guard answers.
|
|
func GuardStatus(p Paths, last int) map[string]any {
|
|
m := ReadMeshTools(p)
|
|
d := GuardDataOf(p, m)
|
|
asked := "never: the controller has not answered yet"
|
|
if m != nil {
|
|
asked = m.Asked
|
|
}
|
|
return map[string]any{
|
|
"refuses": []string{
|
|
"ssh, scp, sftp, rsync, mosh or autossh to a mesh machine: its name, a name under its domains, any *" + InternalSuffix +
|
|
" name or one of its addresses, read as ssh reads it (ssh -G); a jump through one too. An ssh login as `" +
|
|
ForgeUser + "` is the forge's account and passes",
|
|
"writing /etc/hosts or /etc/resolv.conf, by the shell or the agent's Edit and Write",
|
|
"HOSTALIASES, named anywhere in a command",
|
|
"any command naming " + OverrideVar,
|
|
"where the machine names an agent account of its own (novox/hq ADR 0266): every command and file edit of a " +
|
|
"session that does not run as it, whatever it is, and no override lifts it",
|
|
},
|
|
"agent_account": map[bool]string{true: p.Agent, false: "none of its own: agents run as the operator's account"}[p.Confined()],
|
|
"override": OverrideVar + "=<why>, set in the operator's own shell before the session starts; read from the session's " +
|
|
"environment as it was started, recorded with why, and not honoured when it cannot be recorded",
|
|
"hook": GuardCommand(),
|
|
"asked": asked,
|
|
"machines": d.Machines,
|
|
"replacements": d.Replacements,
|
|
"instead_of": InsteadOf(m),
|
|
"record": ReadGuardLog(d.Log, last),
|
|
}
|
|
}
|