Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d06648bc1 | ||
|
|
e810afb3eb | ||
|
|
838e1c2616 | ||
|
|
91c6fa6fce | ||
|
|
c42cd285e2 | ||
|
|
0fa90e5cf2 | ||
|
|
8bbea4a2ad | ||
|
|
f0f0a79623 | ||
|
|
04894e6618 | ||
|
|
b8982b4a7c | ||
|
|
838a6e510b | ||
|
|
b1b7e58e4b | ||
|
|
3b43a1ef4d | ||
|
|
b352f3920e | ||
|
|
152ef9621d | ||
|
|
db297e8bdd | ||
|
|
45befbbd02 | ||
|
|
86cf6d438d | ||
|
|
4d028d40c5 | ||
|
|
5c212531da | ||
|
|
2e082d1680 | ||
|
|
d9336d11d0 | ||
|
|
21d8a7f6b4 | ||
|
|
a8d308d440 | ||
|
|
f015aba34a | ||
|
|
44aafc9b1c | ||
|
|
0d72c3f29a | ||
|
|
3ac7c0289e | ||
|
|
dde9c264f5 | ||
|
|
24f11f2138 | ||
|
|
af63f12129 | ||
|
|
a72df57214 | ||
|
|
5d59b35cf7 | ||
|
|
b485379505 | ||
|
|
306d01d74d | ||
|
|
19a4055bb5 | ||
|
|
1bc6daf31b | ||
|
|
15b2e6b86e | ||
|
|
9208f7409a | ||
|
|
a80af7a97f | ||
|
|
83a51832d7 | ||
|
|
9e63a258d0 | ||
|
|
328d90fb88 | ||
|
|
ca5ab288f6 | ||
|
|
5fd0f72221 | ||
|
|
5003dc0377 | ||
|
|
0a78d130e5 | ||
|
|
4295aad88e | ||
|
|
9dfd3b1105 | ||
|
|
22e8714040 | ||
|
|
11e7ede8a4 | ||
|
|
27315d35cf | ||
|
|
525c639041 | ||
|
|
42c80fa9e1 | ||
|
|
56e0830700 | ||
|
|
0844b35ebb | ||
|
|
566739e02c | ||
|
|
38b56a7877 | ||
|
|
0596503db5 | ||
|
|
3668b02b94 | ||
|
|
c0159ca0a1 | ||
|
|
159ed53103 | ||
|
|
723e676b75 | ||
|
|
661114370f | ||
|
|
a1d7b9ad5a | ||
|
|
5548b0f4e9 | ||
|
|
295cc59e1e | ||
|
|
6a7e4ebd5e | ||
|
|
9e8146192c | ||
|
|
6171d747db | ||
|
|
84609c0373 | ||
|
|
28d5e7f939 | ||
|
|
4128380a3d | ||
|
|
cf57d3fd8f | ||
|
|
da8a46cfe8 | ||
|
|
ed50130a6a | ||
|
|
bd2123166f | ||
|
|
d9db931bd0 | ||
|
|
69f2efb591 | ||
|
|
03e729d103 | ||
|
|
eab335b755 | ||
|
|
f42b58f789 | ||
|
|
5737752744 |
@@ -15,7 +15,7 @@ test("audit-logger records every event to the trail as one line each", async ()
|
||||
const path = join(dir, "audit.log");
|
||||
|
||||
// The audit-logger's whole behaviour: consume everything, record it.
|
||||
await on("**", async (event) => record(event, path));
|
||||
await on("#", async (event) => record(event, path)); // the pattern index.ts subscribes
|
||||
|
||||
process.env.MESH_MODULE = "umami";
|
||||
process.env.MESH_NODE = "anchor";
|
||||
@@ -24,7 +24,9 @@ test("audit-logger records every event to the trail as one line each", async ()
|
||||
|
||||
const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.equal(lines.length, 2);
|
||||
assert.deepEqual(lines.map((l) => l.type), ["umami.site.created", "node.anchor.joined"]);
|
||||
// A module names its events locally (design 29); the module is the `source`, which together with
|
||||
// the type says whose event it was. This broker does no namespacing, so the type is as emitted.
|
||||
assert.deepEqual(lines.map((l) => l.type), ["site.created", "node.anchor.joined"]);
|
||||
assert.equal(lines[0].source, "umami");
|
||||
assert.equal(lines[0].node, "anchor");
|
||||
assert.equal(lines[0].body.domain, "my-app");
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# avahi
|
||||
|
||||
The local network's name and service discovery (mDNS/DNS-SD) as a module (novox/hq to-be 42 Phase 1,
|
||||
research 027).
|
||||
|
||||
## What it owns
|
||||
|
||||
- The `avahi` package.
|
||||
- `avahi-daemon.service`, running and enabled.
|
||||
|
||||
## What it improves
|
||||
|
||||
It was on all four machines and owned by none. It is now declared, and its tools show why discovery
|
||||
does not work today:
|
||||
|
||||
- **The packet filter drops mDNS.** The mesh's filter has no rule for inbound UDP 5353 on any of the
|
||||
four machines, so avahi announces this machine but hears no other machine's answers. A browse
|
||||
finds nothing, and resolving even the machine's own `.local` name times out. A module's `listens`
|
||||
can reach the private network, this machine or anywhere, but not the local link. Opening the port
|
||||
to anywhere would answer the internet on a public machine, so the module opens nothing. This needs
|
||||
a decision in novox/hq: a local-link source scope for `listens`. Until then, `avahi_status` reports
|
||||
`inbound_mdns_accepted: false`, and browse and resolve say so whenever they hear nothing.
|
||||
|
||||
## What it leaves found
|
||||
|
||||
- **`nss-mdns` and `/etc/nsswitch.conf`.** An ordinary lookup reaches avahi only through the
|
||||
`hosts:` line. That line is one ordered list shared by every name source: containers, files, DNS,
|
||||
mDNS and the resolver daemon. The host can write a marked block into a file, but it cannot add a
|
||||
member to a line. Owning the whole file would make this module the owner of every machine's name
|
||||
resolution. On 2026-10-04 all four machines had the same file, with `mdns4_minimal` wired by hand
|
||||
and nss-mdns installed. Both are left as found, and `avahi_status` reports the wiring.
|
||||
- `/etc/avahi/avahi-daemon.conf`, including each workstation's hand-set `allow-interfaces`, which
|
||||
names that machine's own network interface.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | answers |
|
||||
|---|---|---|
|
||||
| `avahi_status` | r | the daemon, its version and configuration, the `hosts:` line and whether mdns is on it, nss-mdns, whether the filter accepts inbound 5353, systemd-resolved beside it, and notes |
|
||||
| `avahi_browse` | r | every service announced in a few seconds (`avahi-browse -prt`), resolved where possible, narrowed to a type |
|
||||
| `avahi_resolve` | r | a `.local` name through avahi and through the name service side by side, or an address to its name |
|
||||
| `avahi_services` | r | what this machine publishes from `/etc/avahi/services` |
|
||||
@@ -0,0 +1,353 @@
|
||||
package main
|
||||
|
||||
// Avahi, the local network's name and service discovery (mDNS/DNS-SD), as a module (novox/hq to-be 42
|
||||
// Phase 1, research 027: "on all four, owned by none"). The module declares the package and the
|
||||
// daemon. Two things it does not declare, and these tools report instead:
|
||||
//
|
||||
// - **The name service switch.** nss-mdns is what lets an ordinary lookup answer `<host>.local`, and
|
||||
// it works only through the `hosts:` line of /etc/nsswitch.conf. That line is one ordered list
|
||||
// shared by every name source on the machine (containers, files, DNS, mDNS, the resolver daemon),
|
||||
// the host can write a marked block into a file but not a member into a line, and owning the whole
|
||||
// file would make this module the owner of every machine's name resolution. So both stay as found
|
||||
// (wired by hand, identically, on all four machines on 2026-10-04) and `avahi_status` says whether
|
||||
// the wiring is there.
|
||||
// - **The packet filter.** mDNS is multicast to UDP 5353 on the local link. The mesh's filter has no
|
||||
// source scope for "the local link" — a module's `listens` reach the private network, this machine
|
||||
// or anywhere — so it drops what other machines announce, and a browse hears nothing. Opening it to
|
||||
// anywhere would answer the internet on a public machine. `avahi_status` reports whether inbound
|
||||
// 5353 is accepted; browse and resolve say so when they hear nothing.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The files avahi and the name service read.
|
||||
const (
|
||||
DaemonConf = "/etc/avahi/avahi-daemon.conf"
|
||||
ServicesDir = "/etc/avahi/services"
|
||||
NSSwitch = "/etc/nsswitch.conf"
|
||||
Daemon = "avahi-daemon.service"
|
||||
)
|
||||
|
||||
// Status is the daemon, its configuration, the name service's wiring and the filter.
|
||||
type Status struct {
|
||||
Daemon map[string]string `json:"daemon"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Config map[string]map[string]string `json:"config"`
|
||||
HostsLine string `json:"nsswitch_hosts"`
|
||||
MDNSWired bool `json:"nss_mdns_wired"`
|
||||
NSSMDNS string `json:"nss_mdns_package,omitempty"`
|
||||
InboundMDNS *bool `json:"inbound_mdns_accepted"`
|
||||
FilterError string `json:"filter_error,omitempty"`
|
||||
ResolvedOn bool `json:"systemd_resolved_active"`
|
||||
Notes []string `json:"notes"`
|
||||
}
|
||||
|
||||
// ParseINI reads avahi-daemon.conf's sections and their set keys; commented keys are defaults.
|
||||
func ParseINI(text string) map[string]map[string]string {
|
||||
out := map[string]map[string]string{}
|
||||
section := ""
|
||||
for _, l := range lines(text) {
|
||||
l = strings.TrimSpace(l)
|
||||
switch {
|
||||
case strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";"):
|
||||
case strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]"):
|
||||
section = strings.Trim(l, "[]")
|
||||
out[section] = map[string]string{}
|
||||
default:
|
||||
if k, v, ok := strings.Cut(l, "="); ok && section != "" {
|
||||
out[section][strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HostsLine is the `hosts:` line of nsswitch.conf, and whether an mdns source is on it.
|
||||
func HostsLine(text string) (string, bool) {
|
||||
for _, l := range lines(text) {
|
||||
l = strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(l, "hosts:") {
|
||||
continue
|
||||
}
|
||||
for _, f := range strings.Fields(strings.TrimPrefix(l, "hosts:")) {
|
||||
if strings.HasPrefix(f, "mdns") {
|
||||
return l, true
|
||||
}
|
||||
}
|
||||
return l, false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
var mdnsAccept = regexp.MustCompile(`(?m)\budp dport (?:\{[^}\n]*\b(?:5353|mdns)\b[^}\n]*\}|(?:5353|mdns)\b)[^\n]*\baccept\b`)
|
||||
|
||||
// InboundMDNS is whether a ruleset accepts UDP 5353 coming in.
|
||||
func InboundMDNS(ruleset string) bool { return mdnsAccept.MatchString(ruleset) }
|
||||
|
||||
// GetStatus reads the daemon, its configuration, the name service and the packet filter.
|
||||
func (m *Machine) GetStatus() (Status, error) {
|
||||
s := Status{Config: map[string]map[string]string{}, Notes: []string{}}
|
||||
d, err := m.unitProps(Daemon, "LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID")
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Daemon = d
|
||||
if v, err := m.Out("avahi-daemon", "--version"); err == nil {
|
||||
s.Version = strings.TrimSpace(v)
|
||||
}
|
||||
if text, err := m.ReadFile(DaemonConf); err == nil {
|
||||
s.Config = ParseINI(string(text))
|
||||
}
|
||||
if text, err := m.ReadFile(NSSwitch); err == nil {
|
||||
s.HostsLine, s.MDNSWired = HostsLine(string(text))
|
||||
}
|
||||
if r := m.Run(bg(), "pacman", "-Q", "nss-mdns"); r.Status == 0 && r.Err == "" {
|
||||
s.NSSMDNS = strings.TrimSpace(r.Stdout)
|
||||
}
|
||||
if rs, err := m.Root("nft", "list", "ruleset"); err == nil {
|
||||
open := InboundMDNS(rs)
|
||||
s.InboundMDNS = &open
|
||||
if !open {
|
||||
s.Notes = append(s.Notes, "the packet filter drops inbound UDP 5353: this machine announces itself but hears no other machine's mDNS")
|
||||
}
|
||||
} else {
|
||||
s.FilterError = err.Error()
|
||||
}
|
||||
if p, err := m.unitProps("systemd-resolved.service", "ActiveState"); err == nil {
|
||||
s.ResolvedOn = p["ActiveState"] == "active"
|
||||
}
|
||||
if s.MDNSWired && s.NSSMDNS == "" {
|
||||
s.Notes = append(s.Notes, "nsswitch names mdns and nss-mdns is not installed: those lookups fail")
|
||||
}
|
||||
if !s.MDNSWired {
|
||||
s.Notes = append(s.Notes, "nsswitch does not name mdns: ordinary lookups never ask avahi")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Service is one service a browse found.
|
||||
type Service struct {
|
||||
Interface string `json:"interface"`
|
||||
Protocol string `json:"protocol"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Domain string `json:"domain"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
TXT []string `json:"txt,omitempty"`
|
||||
Resolved bool `json:"resolved"`
|
||||
}
|
||||
|
||||
// unescape undoes avahi-browse -p's escaping: a special byte as a backslash and three decimals, any
|
||||
// other character after a backslash as itself. Decoded as bytes, so a name in UTF-8 stays whole.
|
||||
func unescape(s string) string {
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' {
|
||||
if d := s[i+1 : min(i+4, len(s))]; len(d) == 3 && isDigits(d) {
|
||||
n, _ := strconv.Atoi(d)
|
||||
out = append(out, byte(n))
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
if i+1 < len(s) {
|
||||
out = append(out, s[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, s[i])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func isDigits(s string) bool {
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var txtItem = regexp.MustCompile(`"((?:[^"\\]|\\.)*)"`)
|
||||
|
||||
// ParseBrowse reads `avahi-browse -p -r`: `+` lines found, `=` lines resolved; a found service
|
||||
// that resolved is answered once, resolved.
|
||||
func ParseBrowse(out string) []Service {
|
||||
byKey := map[string]int{}
|
||||
services := []Service{}
|
||||
for _, l := range lines(out) {
|
||||
f := strings.Split(l, ";")
|
||||
if len(f) < 6 || (f[0] != "+" && f[0] != "=") {
|
||||
continue
|
||||
}
|
||||
s := Service{Interface: f[1], Protocol: f[2], Name: unescape(f[3]), Type: f[4], Domain: f[5]}
|
||||
if f[0] == "=" && len(f) >= 9 {
|
||||
s.Resolved, s.Host, s.Address = true, f[6], f[7]
|
||||
s.Port, _ = strconv.Atoi(f[8])
|
||||
if len(f) >= 10 {
|
||||
for _, t := range txtItem.FindAllStringSubmatch(strings.Join(f[9:], ";"), -1) {
|
||||
s.TXT = append(s.TXT, t[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
key := strings.Join([]string{s.Interface, s.Protocol, s.Name, s.Type, s.Domain}, "\x00")
|
||||
if i, seen := byKey[key]; seen {
|
||||
if s.Resolved {
|
||||
services[i] = s
|
||||
}
|
||||
continue
|
||||
}
|
||||
byKey[key] = len(services)
|
||||
services = append(services, s)
|
||||
}
|
||||
sort.SliceStable(services, func(i, j int) bool {
|
||||
if services[i].Type != services[j].Type {
|
||||
return services[i].Type < services[j].Type
|
||||
}
|
||||
return services[i].Name < services[j].Name
|
||||
})
|
||||
return services
|
||||
}
|
||||
|
||||
var serviceType = regexp.MustCompile(`^_[A-Za-z0-9-]+\._(tcp|udp)$`)
|
||||
|
||||
// Browse listens for a few seconds and answers every service announced, resolved where it could be.
|
||||
func (m *Machine) Browse(seconds int, kind string) (map[string]any, error) {
|
||||
args := []string{strconv.Itoa(seconds), "avahi-browse", "-p", "-r", "-t"}
|
||||
if kind == "" {
|
||||
args = append(args, "-a")
|
||||
} else {
|
||||
if !serviceType.MatchString(kind) {
|
||||
return nil, fmt.Errorf("%q is not a service type such as _ssh._tcp", kind)
|
||||
}
|
||||
args = append(args, kind)
|
||||
}
|
||||
r := m.Run(bg(), "timeout", args...)
|
||||
// timeout's 124 is the listening time ending, which is how a browse that keeps hearing ends.
|
||||
if r.Err != "" || (r.Status != 0 && r.Status != 124) {
|
||||
return nil, failure("avahi-browse", "avahi-browse", r)
|
||||
}
|
||||
services := ParseBrowse(r.Stdout)
|
||||
out := map[string]any{"seconds": seconds, "count": len(services), "services": services}
|
||||
if len(services) == 0 {
|
||||
out["note"] = m.silenceNote()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// silenceNote says why nothing may have been heard, from the packet filter when it can be read.
|
||||
func (m *Machine) silenceNote() string {
|
||||
if rs, err := m.Root("nft", "list", "ruleset"); err == nil && !InboundMDNS(rs) {
|
||||
return "nothing was heard, and this machine's packet filter drops inbound UDP 5353 (mDNS): other machines' answers do not reach avahi"
|
||||
}
|
||||
return "nothing was heard on the local network"
|
||||
}
|
||||
|
||||
// Resolve asks avahi for a name's address (or an address's name), and the name service the same,
|
||||
// so an answer avahi has and an ordinary lookup does not shows the switch unwired.
|
||||
func (m *Machine) Resolve(name, address string) (map[string]any, error) {
|
||||
if (name == "") == (address == "") {
|
||||
return nil, fmt.Errorf("give a name or an address")
|
||||
}
|
||||
out := map[string]any{}
|
||||
var r Ran
|
||||
if name != "" {
|
||||
if !strings.HasSuffix(name, ".local") {
|
||||
name += ".local"
|
||||
}
|
||||
out["name"] = name
|
||||
r = m.Run(bg(), "avahi-resolve", "-n", name)
|
||||
} else {
|
||||
if net.ParseIP(address) == nil {
|
||||
return nil, fmt.Errorf("%q is not an address", address)
|
||||
}
|
||||
out["address"] = address
|
||||
r = m.Run(bg(), "avahi-resolve", "-a", address)
|
||||
}
|
||||
if r.Err != "" {
|
||||
return nil, failure("avahi-resolve", "avahi-resolve", r)
|
||||
}
|
||||
// avahi-resolve says a failure on stderr and exits 0.
|
||||
avahi := map[string]any{"answers": []string{}}
|
||||
for _, l := range lines(r.Stdout) {
|
||||
if f := strings.Fields(l); len(f) >= 2 {
|
||||
avahi["answers"] = append(avahi["answers"].([]string), f[1])
|
||||
}
|
||||
}
|
||||
if said := firstLine(r.Stderr); said != "" {
|
||||
avahi["error"] = said
|
||||
}
|
||||
avahi["resolved"] = len(avahi["answers"].([]string)) > 0
|
||||
out["avahi"] = avahi
|
||||
if name != "" {
|
||||
nss := map[string]any{"answers": []string{}}
|
||||
g := m.Run(bg(), "getent", "hosts", name)
|
||||
for _, l := range lines(g.Stdout) {
|
||||
if f := strings.Fields(l); len(f) >= 1 {
|
||||
nss["answers"] = append(nss["answers"].([]string), f[0])
|
||||
}
|
||||
}
|
||||
nss["resolved"] = len(nss["answers"].([]string)) > 0
|
||||
out["name_service"] = nss
|
||||
}
|
||||
if avahi["resolved"] == false {
|
||||
out["note"] = m.silenceNote()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Published is one service this machine announces from a file of /etc/avahi/services.
|
||||
type Published struct {
|
||||
File string `json:"file"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Types []string `json:"types"`
|
||||
Ports []int `json:"ports"`
|
||||
}
|
||||
|
||||
var (
|
||||
xmlName = regexp.MustCompile(`<name[^>]*>([^<]*)</name>`)
|
||||
xmlType = regexp.MustCompile(`<type>([^<]*)</type>`)
|
||||
xmlPort = regexp.MustCompile(`<port>(\d+)</port>`)
|
||||
)
|
||||
|
||||
// Services is what this machine publishes from its service files.
|
||||
func (m *Machine) Services() (map[string]any, error) {
|
||||
r := m.Run(bg(), "find", ServicesDir, "-mindepth", "1", "-maxdepth", "1", "-name", "*.service", "-printf", "%f\n")
|
||||
if r.Err != "" || r.Status != 0 {
|
||||
if strings.Contains(r.Stderr, "No such file") {
|
||||
return map[string]any{"directory": ServicesDir, "published": []Published{}}, nil
|
||||
}
|
||||
return nil, failure("find", "find", r)
|
||||
}
|
||||
pub := []Published{}
|
||||
names := lines(r.Stdout)
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
text, err := m.ReadFile(ServicesDir + "/" + n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := Published{File: n, Types: []string{}, Ports: []int{}}
|
||||
if x := xmlName.FindStringSubmatch(string(text)); x != nil {
|
||||
p.Name = x[1]
|
||||
}
|
||||
for _, t := range xmlType.FindAllStringSubmatch(string(text), -1) {
|
||||
p.Types = append(p.Types, t[1])
|
||||
}
|
||||
for _, x := range xmlPort.FindAllStringSubmatch(string(text), -1) {
|
||||
port, _ := strconv.Atoi(x[1])
|
||||
p.Ports = append(p.Ports, port)
|
||||
}
|
||||
pub = append(pub, p)
|
||||
}
|
||||
return map[string]any{"directory": ServicesDir, "published": pub}, nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const browse = `+;enp6s0;IPv4;home\032server;_ssh._tcp;local
|
||||
+;enp6s0;IPv4;Printer\046Co;_ipp._tcp;local
|
||||
=;enp6s0;IPv4;home\032server;_ssh._tcp;local;home-server.local;192.168.1.10;22;
|
||||
=;enp6s0;IPv4;Printer\046Co;_ipp._tcp;local;printer.local;192.168.1.20;631;"txtvers=1" "rp=ipp/print"
|
||||
+;enp6s0;IPv6;Kitchen;_spotify-connect._tcp;local
|
||||
`
|
||||
|
||||
func TestABrowseIsReadResolvedOnceAndUnescaped(t *testing.T) {
|
||||
s := ParseBrowse(browse)
|
||||
if len(s) != 3 {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
by := map[string]Service{}
|
||||
for _, x := range s {
|
||||
by[x.Name] = x
|
||||
}
|
||||
ssh := by["home server"]
|
||||
if !ssh.Resolved || ssh.Address != "192.168.1.10" || ssh.Port != 22 || ssh.Host != "home-server.local" {
|
||||
t.Fatalf("%+v", ssh)
|
||||
}
|
||||
ipp := by["Printer.Co"]
|
||||
if strings.Join(ipp.TXT, ",") != "txtvers=1,rp=ipp/print" {
|
||||
t.Fatalf("%+v", ipp)
|
||||
}
|
||||
if k := by["Kitchen"]; k.Resolved || k.Type != "_spotify-connect._tcp" {
|
||||
t.Fatalf("%+v", k)
|
||||
}
|
||||
if unescape(`caf\195\169`) != "café" || unescape(`a\.b`) != "a.b" {
|
||||
t.Fatal("unescape")
|
||||
}
|
||||
}
|
||||
|
||||
func TestABrowseThatHearsNothingSaysTheFilterDropsMDNS(t *testing.T) {
|
||||
var calls []call
|
||||
m := machine(fake(func(c call) Ran {
|
||||
switch c.String() {
|
||||
case "timeout 5 avahi-browse -p -r -t -a":
|
||||
return Ran{Status: 124}
|
||||
case "sudo -n nft list ruleset":
|
||||
return Ran{Stdout: "table inet mesh {\n chain input {\n type filter hook input priority filter; policy drop;\n tcp dport 22 accept\n }\n}\n"}
|
||||
}
|
||||
return Ran{Status: 99}
|
||||
}, &calls), 1000)
|
||||
r, err := m.Browse(5, "")
|
||||
if err != nil || r["count"] != 0 || !strings.Contains(r["note"].(string), "drops inbound UDP 5353") {
|
||||
t.Fatalf("%v %v", r, err)
|
||||
}
|
||||
if _, err := m.Browse(5, "ssh; rm"); err == nil {
|
||||
t.Fatal("not a service type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFilterIsReadForAnAcceptedInboundMDNS(t *testing.T) {
|
||||
for rs, want := range map[string]bool{
|
||||
"\t\tudp dport 5353 accept\n": true,
|
||||
"\t\tiifname \"enp6s0\" udp dport { 53, 5353 } accept\n": true,
|
||||
"\t\tudp dport mdns accept\n": true,
|
||||
"\t\tudp dport 53 accept\n": false,
|
||||
"\t\tudp dport 5353 drop\n": false,
|
||||
"\t\tip saddr 10.0.0.0/8 udp dport 15353 accept\n": false,
|
||||
} {
|
||||
if InboundMDNS(rs) != want {
|
||||
t.Errorf("%q: %v", rs, !want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusNamesTheSwitchTheFilterAndTheDaemon(t *testing.T) {
|
||||
m := machine(fake(func(c call) Ran {
|
||||
switch {
|
||||
case c.name == "systemctl" && c.args[1] == Daemon:
|
||||
return Ran{Stdout: "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\n"}
|
||||
case c.name == "systemctl":
|
||||
return Ran{Stdout: "ActiveState=inactive\n"}
|
||||
case c.String() == "avahi-daemon --version":
|
||||
return Ran{Stdout: "avahi-daemon 0.9-rc5\n"}
|
||||
case c.String() == "pacman -Q nss-mdns":
|
||||
return Ran{Stdout: "nss-mdns 0.15.1-2\n"}
|
||||
case c.String() == "sudo -n nft list ruleset":
|
||||
return Ran{Stdout: "udp dport 53 accept\n"}
|
||||
}
|
||||
return Ran{Status: 99}
|
||||
}, nil), 1000)
|
||||
files := map[string]string{
|
||||
DaemonConf: "[server]\nuse-ipv4=yes\n#host-name=foo\nallow-interfaces=enp6s0\n[publish]\npublish-hinfo=no\n",
|
||||
NSSwitch: "passwd: files\nhosts: mymachines files dns mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return]\n",
|
||||
}
|
||||
m.ReadFile = func(p string) ([]byte, error) {
|
||||
if s, ok := files[p]; ok {
|
||||
return []byte(s), nil
|
||||
}
|
||||
return nil, errNoFile
|
||||
}
|
||||
s, err := m.GetStatus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.MDNSWired || s.NSSMDNS != "nss-mdns 0.15.1-2" || s.InboundMDNS == nil || *s.InboundMDNS || s.Version != "avahi-daemon 0.9-rc5" {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if s.Config["server"]["allow-interfaces"] != "enp6s0" || s.Config["server"]["host-name"] != "" || s.Daemon["ActiveState"] != "active" {
|
||||
t.Fatalf("%+v", s.Config)
|
||||
}
|
||||
if len(s.Notes) != 1 || !strings.Contains(s.Notes[0], "drops inbound UDP 5353") {
|
||||
t.Fatalf("%v", s.Notes)
|
||||
}
|
||||
if _, wired := HostsLine("hosts: files dns\n"); wired {
|
||||
t.Fatal("no mdns on the line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAsksAvahiAndTheNameServiceAndReadsAFailureFromStderr(t *testing.T) {
|
||||
m := machine(byLine(map[string]Ran{
|
||||
"avahi-resolve -n printer.local": {Stdout: "printer.local\t192.168.1.20\n"},
|
||||
"getent hosts printer.local": {Status: 2},
|
||||
"avahi-resolve -n nowhere.local": {Stderr: "Failed to resolve host name 'nowhere.local': Timeout reached\n"},
|
||||
"getent hosts nowhere.local": {Status: 2},
|
||||
"sudo -n nft list ruleset": {Stdout: "udp dport 5353 accept\n"},
|
||||
"avahi-resolve -a 192.168.1.20": {Stdout: "192.168.1.20\tprinter.local\n"},
|
||||
}, nil), 1000)
|
||||
r, err := m.Resolve("printer", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r["avahi"].(map[string]any)["resolved"] != true || r["name_service"].(map[string]any)["resolved"] != false {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
r, _ = m.Resolve("nowhere.local", "")
|
||||
if a := r["avahi"].(map[string]any); a["resolved"] != false || !strings.Contains(a["error"].(string), "Timeout reached") || r["note"] != "nothing was heard on the local network" {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
r, _ = m.Resolve("", "192.168.1.20")
|
||||
if r["avahi"].(map[string]any)["answers"].([]string)[0] != "printer.local" {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
for _, bad := range [][2]string{{"", ""}, {"a", "1.2.3.4"}, {"", "not-an-ip"}} {
|
||||
if _, err := m.Resolve(bad[0], bad[1]); err == nil {
|
||||
t.Errorf("%v accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishedServicesAreReadFromTheirFiles(t *testing.T) {
|
||||
m := machine(byLine(map[string]Ran{
|
||||
"find /etc/avahi/services -mindepth 1 -maxdepth 1 -name *.service -printf %f\n": {Stdout: "ssh.service\n"},
|
||||
}, nil), 1000)
|
||||
m.ReadFile = func(string) ([]byte, error) {
|
||||
return []byte(`<service-group><name replace-wildcards="yes">%h</name><service><type>_ssh._tcp</type><port>22</port></service></service-group>`), nil
|
||||
}
|
||||
r, err := m.Services()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := r["published"].([]Published)
|
||||
if len(p) != 1 || p[0].Name != "%h" || p[0].Types[0] != "_ssh._tcp" || p[0].Ports[0] != 22 {
|
||||
t.Fatalf("%+v", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package main
|
||||
|
||||
// The commands this bundle runs on its machine, and who runs them.
|
||||
//
|
||||
// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4),
|
||||
// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words —
|
||||
// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only
|
||||
// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the
|
||||
// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the
|
||||
// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an
|
||||
// empty answer.
|
||||
//
|
||||
// The runner is injected, so every tool is tested over a fake one without the machine.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ran is what one command did: its output, its exit status, and why it never ran to an answer.
|
||||
type Ran struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Status int
|
||||
// Err is "ENOENT" when the program is not there, or that it was ended for taking too long.
|
||||
Err string
|
||||
}
|
||||
|
||||
// Runner runs one command, so the tools can be tested without the machine.
|
||||
type Runner func(ctx context.Context, name string, args ...string) Ran
|
||||
|
||||
// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a
|
||||
// command that hangs is answered as such rather than as a call the runtime gave up on.
|
||||
const CallTimeout = 20 * time.Second
|
||||
|
||||
// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the
|
||||
// process; well above anything a tool answers.
|
||||
const outputLimit = 16 << 20
|
||||
|
||||
type bounded struct {
|
||||
bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (b *bounded) Write(p []byte) (int, error) {
|
||||
if room := outputLimit - b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
b.Buffer.Write(p[:room])
|
||||
}
|
||||
b.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return b.Buffer.Write(p)
|
||||
}
|
||||
|
||||
// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language.
|
||||
func ExecRunner(ctx context.Context, name string, args ...string) Ran {
|
||||
ctx, cancel := context.WithTimeout(ctx, CallTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = append(os.Environ(), "LC_ALL=C")
|
||||
var out, errb bounded
|
||||
cmd.Stdout, cmd.Stderr = &out, &errb
|
||||
err := cmd.Run()
|
||||
r := Ran{Stdout: out.String(), Stderr: errb.String()}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds()))
|
||||
return r
|
||||
}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.As(err, &exit):
|
||||
r.Status = exit.ExitCode()
|
||||
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist):
|
||||
r.Status, r.Err = 127, "ENOENT"
|
||||
default:
|
||||
r.Status, r.Err = 126, err.Error()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Escalated is the command as it is run: as given when this process is root, else through sudo
|
||||
// without a prompt.
|
||||
func Escalated(uid int, name string, args ...string) (string, []string) {
|
||||
if uid == 0 {
|
||||
return name, args
|
||||
}
|
||||
return "sudo", append([]string{"-n", name}, args...)
|
||||
}
|
||||
|
||||
// Machine is this machine as the tools see it: a runner, who this process is, and its files.
|
||||
type Machine struct {
|
||||
Run Runner
|
||||
UID int
|
||||
User string
|
||||
Account string
|
||||
ReadFile func(path string) ([]byte, error)
|
||||
Now func() time.Time
|
||||
Sleep func(time.Duration)
|
||||
}
|
||||
|
||||
// ThisMachine is the machine the runtime launched this bundle on.
|
||||
func ThisMachine() *Machine {
|
||||
user := os.Getenv("USER")
|
||||
if user == "" {
|
||||
user = os.Getenv("LOGNAME")
|
||||
}
|
||||
account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT"))
|
||||
if account == "" {
|
||||
account = user
|
||||
}
|
||||
return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep}
|
||||
}
|
||||
|
||||
// Out runs a command that only reads, and fails with what went wrong named.
|
||||
func (m *Machine) Out(name string, args ...string) (string, error) {
|
||||
r := m.Run(context.Background(), name, args...)
|
||||
if r.Status == 0 && r.Err == "" {
|
||||
return r.Stdout, nil
|
||||
}
|
||||
return r.Stdout, failure(name, name, r)
|
||||
}
|
||||
|
||||
// Root runs a command that needs root, escalated when this process is not.
|
||||
func (m *Machine) Root(name string, args ...string) (string, error) {
|
||||
program, argv := Escalated(m.UID, name, args...)
|
||||
r := m.Run(context.Background(), program, argv...)
|
||||
if r.Status == 0 && r.Err == "" {
|
||||
return r.Stdout, nil
|
||||
}
|
||||
return r.Stdout, failure(name, program, r)
|
||||
}
|
||||
|
||||
// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer.
|
||||
func (m *Machine) RootRan(name string, args ...string) (Ran, error) {
|
||||
program, argv := Escalated(m.UID, name, args...)
|
||||
r := m.Run(context.Background(), program, argv...)
|
||||
if r.Err != "" || (program == "sudo" && sudoRefused(r)) {
|
||||
return r, failure(name, program, r)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func sudoRefused(r Ran) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:")
|
||||
}
|
||||
|
||||
// failure names what failed by how it failed: the program missing is a spawn error, sudo missing
|
||||
// or refusing speaks for itself, and the rest is the command's own first line.
|
||||
func failure(cmd, program string, r Ran) error {
|
||||
said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout)
|
||||
if r.Err == "ENOENT" {
|
||||
if program == "sudo" {
|
||||
return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", cmd)
|
||||
}
|
||||
if r.Err != "" {
|
||||
return fmt.Errorf("%s did not answer: %s", cmd, r.Err)
|
||||
}
|
||||
if program == "sudo" && sudoRefused(r) {
|
||||
if strings.Contains(said, "command not found") {
|
||||
return fmt.Errorf("%s is not installed on this machine", cmd)
|
||||
}
|
||||
return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said))
|
||||
}
|
||||
if line := firstLine(said); line != "" {
|
||||
return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line)
|
||||
}
|
||||
return fmt.Errorf("%s failed with status %d", cmd, r.Status)
|
||||
}
|
||||
|
||||
func firstLine(text string) string {
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func lines(text string) []string {
|
||||
var out []string
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// text is a string argument; required says whether it may be absent. It is never something a
|
||||
// command would read as an option, which under sudo would be root's option.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
raw, present := args[key]
|
||||
if !present || raw == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if required && s == "" {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") {
|
||||
return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument with a default, kept within bounds.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
raw, present := args[key]
|
||||
if !present || raw == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := raw.(float64)
|
||||
if !ok || f != float64(int(f)) {
|
||||
return 0, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least {
|
||||
return 0, fmt.Errorf("%s must be at least %d", key, least)
|
||||
}
|
||||
if n > most {
|
||||
n = most
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// flag is a boolean argument, false when absent.
|
||||
func flag(args map[string]any, key string) (bool, error) {
|
||||
raw, present := args[key]
|
||||
if !present || raw == nil {
|
||||
return false, nil
|
||||
}
|
||||
b, ok := raw.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s must be true or false", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// schema is a tool's input: its properties and the ones it requires.
|
||||
func schema(properties map[string]any, required ...string) map[string]any {
|
||||
s := map[string]any{"type": "object", "properties": properties}
|
||||
if len(required) > 0 {
|
||||
s["required"] = required
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// unitProps reads a unit's properties as systemctl shows them.
|
||||
func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) {
|
||||
args := []string{"show", unit, "--no-pager"}
|
||||
for _, p := range props {
|
||||
args = append(args, "--property="+p)
|
||||
}
|
||||
out, err := m.Out("systemctl", args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keyValues(out, "="), nil
|
||||
}
|
||||
|
||||
// keyValues reads `key<sep>value` lines; a line without the separator is skipped.
|
||||
func keyValues(out, sep string) map[string]string {
|
||||
kv := map[string]string{}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
k, v, ok := strings.Cut(l, sep)
|
||||
if ok {
|
||||
kv[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return kv
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// call is one command a fake runner was asked to run.
|
||||
type call struct {
|
||||
name string
|
||||
args []string
|
||||
}
|
||||
|
||||
func (c call) String() string {
|
||||
if len(c.args) == 0 {
|
||||
return c.name
|
||||
}
|
||||
return c.name + " " + strings.Join(c.args, " ")
|
||||
}
|
||||
|
||||
// fake is a runner answering by the command line it is given, recording every call.
|
||||
func fake(answer func(c call) Ran, calls *[]call) Runner {
|
||||
return func(_ context.Context, name string, args ...string) Ran {
|
||||
c := call{name, append([]string(nil), args...)}
|
||||
if calls != nil {
|
||||
*calls = append(*calls, c)
|
||||
}
|
||||
return answer(c)
|
||||
}
|
||||
}
|
||||
|
||||
// byLine answers from a table keyed by the whole command line, and refuses anything else as a
|
||||
// command the test did not expect.
|
||||
func byLine(table map[string]Ran, calls *[]call) Runner {
|
||||
return fake(func(c call) Ran {
|
||||
if r, ok := table[c.String()]; ok {
|
||||
return r
|
||||
}
|
||||
return Ran{Status: 99, Stderr: "unexpected command: " + c.String()}
|
||||
}, calls)
|
||||
}
|
||||
|
||||
func machine(run Runner, uid int) *Machine {
|
||||
return &Machine{Run: run, UID: uid, User: "operator", Account: "operator",
|
||||
ReadFile: func(string) ([]byte, error) { return nil, errNoFile },
|
||||
Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) },
|
||||
Sleep: func(time.Duration) {}}
|
||||
}
|
||||
|
||||
type noFile struct{}
|
||||
|
||||
func (noFile) Error() string { return "no such file" }
|
||||
|
||||
var errNoFile = noFile{}
|
||||
|
||||
func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) {
|
||||
if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" {
|
||||
t.Fatalf("not root: %s %v", p, a)
|
||||
}
|
||||
if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" {
|
||||
t.Fatalf("root: %s %v", p, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
r Ran
|
||||
want string
|
||||
}{
|
||||
{Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"},
|
||||
{Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"},
|
||||
{Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"},
|
||||
{Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
m := machine(fake(func(call) Ran { return c.r }, nil), 1000)
|
||||
if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%+v: %v, want %q", c.r, err, c.want)
|
||||
}
|
||||
}
|
||||
m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000)
|
||||
if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") {
|
||||
t.Errorf("a missing program: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnArgumentIsNeverAnOption(t *testing.T) {
|
||||
for _, bad := range []any{"-rf", "a\nb", 3.0} {
|
||||
if _, err := text(map[string]any{"x": bad}, "x", true); err == nil {
|
||||
t.Errorf("%v was accepted", bad)
|
||||
}
|
||||
}
|
||||
if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" {
|
||||
t.Errorf("a plain value: %q %v", s, err)
|
||||
}
|
||||
if _, err := text(map[string]any{}, "x", true); err == nil {
|
||||
t.Error("a missing required value was accepted")
|
||||
}
|
||||
if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 {
|
||||
t.Errorf("not bounded: %d", n)
|
||||
}
|
||||
if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil {
|
||||
t.Error("below the least was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// avahi's tools bundle (novox/hq to-be 42 Phase 1, research 026/05): a process the node's runtime
|
||||
// launches and speaks MCP over stdio to, through the Go SDK (ADR 0188, ADR 0193). It reads the
|
||||
// daemon, the name service's wiring and the packet filter's view of mDNS, browses the local network
|
||||
// for services, resolves a name, and lists what the machine publishes. It changes nothing.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// binaryName is what the build names this bundle's executable: the manifest's `binary`.
|
||||
const binaryName = "avahi-tools"
|
||||
|
||||
func bg() context.Context { return context.Background() }
|
||||
|
||||
func main() {
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): avahi.
|
||||
if err := stdio.Serve("", tools(ThisMachine())); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func tools(m *Machine) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "avahi_status",
|
||||
Description: "The daemon's state and version, its configuration as set, the name service switch's hosts line and whether mdns is on it, " +
|
||||
"whether nss-mdns is installed, whether the packet filter accepts inbound mDNS (UDP 5353), whether systemd-resolved runs beside it, " +
|
||||
"and notes naming what keeps discovery from working.",
|
||||
Input: schema(map[string]any{}),
|
||||
Run: func(map[string]any) (any, error) { return m.GetStatus() },
|
||||
},
|
||||
{
|
||||
Name: "avahi_browse",
|
||||
Description: "Listen on the local network for a few seconds (avahi-browse -prt) and answer every service announced, with interface, " +
|
||||
"protocol, name, type, host, address, port and TXT where it resolved; narrowed to one service type when given. Hearing nothing says why it may be.",
|
||||
Input: schema(map[string]any{
|
||||
"seconds": map[string]any{"type": "integer", "description": "how long to listen (default 5, at most 15)"},
|
||||
"type": map[string]any{"type": "string", "description": "one service type, e.g. _ssh._tcp (optional)"},
|
||||
}),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
n, err := whole(args, "seconds", 5, 1, 15)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kind, err := text(args, "type", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Browse(n, kind)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "avahi_resolve",
|
||||
Description: "Resolve a .local name to its addresses through avahi, and through the name service (getent) beside it, or an address to its name. " +
|
||||
"An answer from avahi that the name service lacks shows nsswitch unwired; no answer says why it may be.",
|
||||
Input: schema(map[string]any{
|
||||
"name": map[string]any{"type": "string", "description": "a host name; .local is added when missing"},
|
||||
"address": map[string]any{"type": "string", "description": "an address to name instead"},
|
||||
}),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
name, err := text(args, "name", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
address, err := text(args, "address", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Resolve(name, address)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "avahi_services",
|
||||
Description: "What this machine publishes from /etc/avahi/services: each file with the service's name, types and ports.",
|
||||
Input: schema(map[string]any{}),
|
||||
Run: func(map[string]any) (any, error) { return m.Services() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
// The module's shape (novox/hq to-be 42 Phase 1, research 027): the package and the daemon, and
|
||||
// nothing written into the name service switch or opened in the packet filter — avahi.go says why
|
||||
// neither can be declared safely today, and the tools report both instead.
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestItDeclaresThePackageAndTheDaemonOnly(t *testing.T) {
|
||||
m := manifest(t)
|
||||
if p := m.resource(t, "package"); p["package"] != "avahi" {
|
||||
t.Fatalf("%v", p)
|
||||
}
|
||||
d := m.resource(t, "daemon")
|
||||
if d["unit"] != Daemon || d["state"] != "running" || d["boot"] != "enabled" {
|
||||
t.Fatalf("%v", d)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["path"] == NSSwitch || r["package"] == "nss-mdns" {
|
||||
t.Fatalf("%v: the name service switch is left as found", r["id"])
|
||||
}
|
||||
}
|
||||
if len(m.Resources) != 2 {
|
||||
t.Fatalf("%v", m.Resources)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type resource map[string]any
|
||||
|
||||
type manifestShape struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Claims []map[string]any `json:"claims"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []resource `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func manifest(t *testing.T) manifestShape {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifestShape
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifestShape) resource(t *testing.T, id string) resource {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the
|
||||
// bundle to the shape the builder compiles and the runtime loads.
|
||||
func TestToolsAreTheManifests(t *testing.T) {
|
||||
m := manifest(t)
|
||||
names := map[string]bool{}
|
||||
for _, tool := range tools(machine(nil, 1000)) {
|
||||
if names[tool.Name] {
|
||||
t.Errorf("%s is served twice", tool.Name)
|
||||
}
|
||||
names[tool.Name] = true
|
||||
}
|
||||
for _, want := range m.Tools {
|
||||
if !names[want] {
|
||||
t.Errorf("the manifest lists %s and the bundle does not serve it", want)
|
||||
}
|
||||
delete(names, want)
|
||||
}
|
||||
if len(names) != 0 {
|
||||
t.Errorf("served and not listed: %v", names)
|
||||
}
|
||||
var tools map[string]any
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["name"] == "tools" {
|
||||
tools = a
|
||||
}
|
||||
}
|
||||
if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" ||
|
||||
tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName {
|
||||
t.Fatalf("the tools artifact: %v", tools)
|
||||
}
|
||||
if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName {
|
||||
t.Fatalf("loads: %v", tools["loads"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module avahi
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"module": "avahi",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"tools": [
|
||||
"avahi_status",
|
||||
"avahi_browse",
|
||||
"avahi_resolve",
|
||||
"avahi_services"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "avahi"
|
||||
},
|
||||
{
|
||||
"id": "daemon",
|
||||
"type": "service",
|
||||
"unit": "avahi-daemon.service",
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/avahi-tools",
|
||||
"binary": "avahi-tools",
|
||||
"loads": [
|
||||
"avahi-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# bluetooth
|
||||
|
||||
Bluetooth on the two workstations (novox/hq research 027/02, to-be 42 phase 2 step 9).
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the Bluetooth stack and its daemon | package `bluez` |
|
||||
| `bluetoothctl`, which the tools speak through | package `bluez-utils` |
|
||||
| the daemon, running and enabled | `bluetooth.service` |
|
||||
|
||||
All official. `/etc/bluetooth/main.conf` is the package's file, unchanged on both workstations
|
||||
(every setting commented out). The module states nothing in it, so it declares nothing there.
|
||||
|
||||
## Improves
|
||||
|
||||
- **The stack is declared, not a dependency of something else.** On both workstations `bluez` is
|
||||
installed only as a dependency. Removing the applet that pulled it in would have left it an orphan
|
||||
for the next clean-up to take, and Bluetooth with it.
|
||||
- **An owner for the daemon**, running and enabled on both today with nothing recording why.
|
||||
- **Headphones from the mesh.** `bluetooth_connect` and `bluetooth_devices` (with battery) answer from
|
||||
any machine, without the applet.
|
||||
|
||||
## Tools
|
||||
|
||||
All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. bluez's bus policy lets
|
||||
the account act; if it ever refuses (`AccessDenied`), the act is repeated through `sudo -n`. An act
|
||||
whose output says it failed (`Failed to …`, `org.bluez.Error…`, `not available`) is an error, whatever
|
||||
bluetoothctl's exit status.
|
||||
|
||||
| tool | what |
|
||||
|---|---|
|
||||
| `bluetooth_controller` (r) | address, name, powered, discoverable, pairable, discovering |
|
||||
| `bluetooth_power` (r/a) | read, or switch the controller on or off |
|
||||
| `bluetooth_devices` (r) | all, paired, connected or trusted devices: kind, paired, bonded, trusted, blocked, connected, battery where reported |
|
||||
| `bluetooth_scan` (r) | discover for 1 to 15 s (default 8); the unpaired devices found, strongest signal first |
|
||||
| `bluetooth_connect` / `bluetooth_disconnect` (a) | one device; connect waits up to 15 s |
|
||||
| `bluetooth_trust` (a) | trust, or untrust |
|
||||
| `bluetooth_pair` (a) | pair with an agent that confirms nothing (headphones, speakers), then trust. A device that shows a code is paired from the desktop |
|
||||
| `bluetooth_remove` (a) | forget a device |
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
Nothing on disk on either workstation: both packages are installed, and the service is enabled and
|
||||
running. `bluez` becomes explicitly the mesh's.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state).
|
||||
- `blueman` on both workstations, and its applet, which the window manager's configuration starts.
|
||||
That line is the `i3` module's to keep or drop.
|
||||
- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop.
|
||||
@@ -0,0 +1,281 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var macAddress = regexp.MustCompile(`^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$`)
|
||||
|
||||
func addressOf(args map[string]any) (string, error) {
|
||||
a, err := text(args, "address")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !macAddress.MatchString(a) {
|
||||
return "", fmt.Errorf("%q is not a Bluetooth address (six hex pairs separated by colons)", a)
|
||||
}
|
||||
return strings.ToUpper(a), nil
|
||||
}
|
||||
|
||||
var ansi = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]|\x01|\x02`)
|
||||
|
||||
// btFailed are the words bluetoothctl uses for an act that did not happen, whatever its exit status.
|
||||
var btFailed = regexp.MustCompile(`(?m)(Failed to \w+|not available|org\.bluez\.Error\.\w+|No default controller available)`)
|
||||
|
||||
// bt runs bluetoothctl once, non-interactively, as the account; bluez's bus policy lets the
|
||||
// account act, and if it refuses, the act is run through sudo -n.
|
||||
func bt(timeout time.Duration, args ...string) (string, error) {
|
||||
c := Cmd{Name: "bluetoothctl", Args: args, Timeout: timeout}
|
||||
r := run(c)
|
||||
if strings.Contains(r.Stdout+r.Stderr, "AccessDenied") || strings.Contains(r.Stdout+r.Stderr, "Not authorized") {
|
||||
c.Root = true
|
||||
r = run(c)
|
||||
}
|
||||
out := ansi.ReplaceAllString(r.Stdout+"\n"+r.Stderr, "")
|
||||
if r.Error != "" {
|
||||
return out, failure(c, r)
|
||||
}
|
||||
if strings.Contains(out, "No default controller available") {
|
||||
return out, fmt.Errorf("this machine has no Bluetooth controller bluez can use: none is present, it is blocked (rfkill), or bluetooth.service is not running")
|
||||
}
|
||||
if m := btFailed.FindString(out); m != "" || r.Status != 0 {
|
||||
said := strings.TrimSpace(out)
|
||||
if said == "" {
|
||||
said = fmt.Sprintf("exit status %d", r.Status)
|
||||
}
|
||||
return out, fmt.Errorf("bluetoothctl %s: %s", strings.Join(args, " "), tail(said, 1000))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fields reads bluetoothctl's "\tKey: value" lines; a key seen twice keeps its first value.
|
||||
func fields(s string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if !strings.HasPrefix(l, "\t") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(strings.TrimSpace(l), ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, seen := out[k]; !seen {
|
||||
out[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func yes(v string) bool { return v == "yes" }
|
||||
|
||||
// ControllerAnswer is what bluetooth_controller answers.
|
||||
type ControllerAnswer struct {
|
||||
Address string `json:"address"`
|
||||
Name string `json:"name"`
|
||||
Alias string `json:"alias"`
|
||||
Powered bool `json:"powered"`
|
||||
PowerState string `json:"power_state,omitempty"`
|
||||
Discoverable bool `json:"discoverable"`
|
||||
Pairable bool `json:"pairable"`
|
||||
Discovering bool `json:"discovering"`
|
||||
}
|
||||
|
||||
// Controller answers the default controller.
|
||||
func Controller() (ControllerAnswer, error) {
|
||||
out, err := bt(CallTimeout, "show")
|
||||
if err != nil {
|
||||
return ControllerAnswer{}, err
|
||||
}
|
||||
c := ControllerAnswer{}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if f := strings.Fields(l); len(f) >= 2 && f[0] == "Controller" {
|
||||
c.Address = f[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if c.Address == "" {
|
||||
return ControllerAnswer{}, fmt.Errorf("bluetoothctl show answered no controller: %s", tail(strings.TrimSpace(out), 500))
|
||||
}
|
||||
f := fields(out)
|
||||
c.Name, c.Alias, c.PowerState = f["Name"], f["Alias"], f["PowerState"]
|
||||
c.Powered, c.Discoverable, c.Pairable, c.Discovering = yes(f["Powered"]), yes(f["Discoverable"]), yes(f["Pairable"]), yes(f["Discovering"])
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Power switches the controller on or off.
|
||||
func Power(on bool) (map[string]any, error) {
|
||||
word := "off"
|
||||
if on {
|
||||
word = "on"
|
||||
}
|
||||
if _, err := bt(CallTimeout, "power", word); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := Controller()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"powered": c.Powered, "asked": word}, nil
|
||||
}
|
||||
|
||||
// Device is one device bluez knows.
|
||||
type Device struct {
|
||||
Address string `json:"address"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"kind,omitempty"`
|
||||
Paired bool `json:"paired"`
|
||||
Bonded bool `json:"bonded"`
|
||||
Trusted bool `json:"trusted"`
|
||||
Blocked bool `json:"blocked"`
|
||||
Connected bool `json:"connected"`
|
||||
Battery *int `json:"battery_percent,omitempty"`
|
||||
RSSI *int `json:"rssi,omitempty"`
|
||||
}
|
||||
|
||||
var inParens = regexp.MustCompile(`\((-?[0-9]+)\)`)
|
||||
|
||||
// number reads "0x50 (80)" or "-62" as a number.
|
||||
func number(v string) *int {
|
||||
if m := inParens.FindStringSubmatch(v); m != nil {
|
||||
v = m[1]
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &n
|
||||
}
|
||||
|
||||
// deviceInfo asks bluez for one device.
|
||||
func deviceInfo(address string) (Device, error) {
|
||||
out, err := bt(CallTimeout, "info", address)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
f := fields(out)
|
||||
d := Device{Address: address, Name: f["Name"], Icon: f["Icon"], Paired: yes(f["Paired"]), Bonded: yes(f["Bonded"]),
|
||||
Trusted: yes(f["Trusted"]), Blocked: yes(f["Blocked"]), Connected: yes(f["Connected"])}
|
||||
if d.Name == "" {
|
||||
d.Name = f["Alias"]
|
||||
}
|
||||
if v, ok := f["Battery Percentage"]; ok {
|
||||
d.Battery = number(v)
|
||||
}
|
||||
if v, ok := f["RSSI"]; ok {
|
||||
d.RSSI = number(v)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// listed reads "Device <address> <name>" lines.
|
||||
func listed(out string) []string {
|
||||
seen := map[string]bool{}
|
||||
addrs := []string{}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
f := strings.Fields(strings.TrimSpace(l))
|
||||
if len(f) >= 2 && f[0] == "Device" && macAddress.MatchString(f[1]) && !seen[f[1]] {
|
||||
seen[f[1]] = true
|
||||
addrs = append(addrs, f[1])
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
// Devices answers the devices bluez knows, with each one's state.
|
||||
func Devices(which string) (map[string]any, error) {
|
||||
if err := oneOf("which", which, "all", "paired", "connected", "trusted"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := []string{"devices"}
|
||||
if which != "all" {
|
||||
args = append(args, strings.ToUpper(which[:1])+which[1:])
|
||||
}
|
||||
out, err := bt(CallTimeout, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices := []Device{}
|
||||
for _, a := range listed(out) {
|
||||
d, err := deviceInfo(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices = append(devices, d)
|
||||
}
|
||||
sort.SliceStable(devices, func(i, k int) bool {
|
||||
if devices[i].Connected != devices[k].Connected {
|
||||
return devices[i].Connected
|
||||
}
|
||||
return devices[i].Name < devices[k].Name
|
||||
})
|
||||
return map[string]any{"which": which, "count": len(devices), "devices": devices}, nil
|
||||
}
|
||||
|
||||
// Scan discovers for a while and answers the devices found that are not paired.
|
||||
func Scan(seconds int) (map[string]any, error) {
|
||||
// bluetoothctl's own --timeout ends the scan; the command's bound is a little longer.
|
||||
limit := time.Duration(seconds+4) * time.Second
|
||||
if _, err := bt(limit, "--timeout", strconv.Itoa(seconds), "scan", "on"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := bt(CallTimeout, "devices")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := []Device{}
|
||||
for _, a := range listed(out) {
|
||||
// A device seen a moment ago may have gone out of reach: it is skipped, not a failure.
|
||||
d, err := deviceInfo(a)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !d.Paired {
|
||||
found = append(found, d)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(found, func(i, k int) bool {
|
||||
ri, rk := -1000, -1000
|
||||
if found[i].RSSI != nil {
|
||||
ri = *found[i].RSSI
|
||||
}
|
||||
if found[k].RSSI != nil {
|
||||
rk = *found[k].RSSI
|
||||
}
|
||||
return ri > rk
|
||||
})
|
||||
return map[string]any{"seconds": seconds, "count": len(found), "found": found}, nil
|
||||
}
|
||||
|
||||
// Act runs one act on a device and answers the device's state afterwards.
|
||||
func Act(verb, address string) (map[string]any, error) {
|
||||
limit := CallTimeout
|
||||
if verb == "connect" {
|
||||
// A connect waits for the device; bluetoothctl's own timeout ends it first.
|
||||
if _, err := bt(limit, "--timeout", "15", verb, address); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if _, err := bt(limit, verb, address); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if verb == "remove" {
|
||||
return map[string]any{"act": verb, "address": address, "removed": true}, nil
|
||||
}
|
||||
d, err := deviceInfo(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"act": verb, "device": d}, nil
|
||||
}
|
||||
|
||||
// Pair pairs a device with an agent that confirms nothing, then trusts it.
|
||||
func Pair(address string) (map[string]any, error) {
|
||||
if _, err := bt(CallTimeout, "--agent", "NoInputNoOutput", "--timeout", "15", "pair", address); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Act("trust", address)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheManifestIsTheStackItsToolsAndTheDaemon(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
holdsTheBundle(t, m, "bluetooth")
|
||||
if got := strings.Join(m.packages(), ","); got != "bluez,bluez-utils" {
|
||||
t.Errorf("packages %s: the applet and the TUI are the operator's", got)
|
||||
}
|
||||
s := m.services()["bluetooth.service"]
|
||||
if s == nil || s["state"] != "running" || s["boot"] != "enabled" {
|
||||
t.Errorf("%v", s)
|
||||
}
|
||||
if len(m.Resources) != 3 {
|
||||
t.Errorf("no configuration file: /etc/bluetooth/main.conf is the package's, unchanged on both workstations: %v", m.Resources)
|
||||
}
|
||||
}
|
||||
|
||||
const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: g14\n\tAlias: g14\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n"
|
||||
|
||||
func headphones(connected bool) string {
|
||||
c := "no"
|
||||
extra := ""
|
||||
if connected {
|
||||
c, extra = "yes", "\tBattery Percentage: 0x50 (80)\n"
|
||||
}
|
||||
return "Device 80:99:E7:C2:29:DA (public)\n\tName: WH-1000XM4\n\tAlias: WH-1000XM4\n\tIcon: audio-headset\n\tPaired: yes\n\tBonded: yes\n\tTrusted: yes\n\tBlocked: no\n\tConnected: " + c + "\n" + extra + "\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n"
|
||||
}
|
||||
|
||||
func TestControllerReadsShow(t *testing.T) {
|
||||
using(t, func(string, Cmd) Result { return ok("\x1b[0;94m" + show) })
|
||||
c, err := Controller()
|
||||
if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "g14" || !c.Powered || c.Discoverable || !c.Pairable {
|
||||
t.Fatalf("%+v %v", c, err)
|
||||
}
|
||||
using(t, func(string, Cmd) Result { return ok("No default controller available\n") })
|
||||
if _, err := Controller(); err == nil || !strings.Contains(err.Error(), "no Bluetooth controller") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicesAskEachOneAndReportBatteryWhereGiven(t *testing.T) {
|
||||
f := using(t, func(line string, c Cmd) Result {
|
||||
switch line {
|
||||
case "bluetoothctl devices Paired":
|
||||
return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice 2C:41:A1:E4:EC:86 Earmuffs\n")
|
||||
case "bluetoothctl info 80:99:E7:C2:29:DA":
|
||||
return ok(headphones(true))
|
||||
}
|
||||
return ok("Device 2C:41:A1:E4:EC:86 (public)\n\tName: Earmuffs\n\tPaired: yes\n\tConnected: no\n")
|
||||
})
|
||||
got, err := Devices("paired")
|
||||
devices := got["devices"].([]Device)
|
||||
if err != nil || got["count"] != 2 || !devices[0].Connected || devices[0].Battery == nil || *devices[0].Battery != 80 || devices[1].Battery != nil {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if f.lines()[0] != "bluetoothctl devices Paired" {
|
||||
t.Errorf("%v", f.lines())
|
||||
}
|
||||
if _, err := Devices("nearby"); err == nil {
|
||||
t.Error("an unknown which")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnActThatFailsIsAnErrorWhateverTheExitStatus(t *testing.T) {
|
||||
using(t, func(line string, c Cmd) Result {
|
||||
return ok("Attempting to connect to 80:99:E7:C2:29:DA\nFailed to connect: org.bluez.Error.Failed br-connection-page-timeout\n")
|
||||
})
|
||||
if _, err := Act("connect", "80:99:E7:C2:29:DA"); err == nil || !strings.Contains(err.Error(), "page-timeout") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
using(t, func(string, Cmd) Result { return Result{Status: 1, Stdout: "Device 00:11:22:33:44:55 not available\n"} })
|
||||
if _, err := Act("trust", "00:11:22:33:44:55"); err == nil || !strings.Contains(err.Error(), "not available") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectWaitsWithBluetoothctlsOwnTimeoutAndAnswersTheState(t *testing.T) {
|
||||
f := using(t, func(line string, c Cmd) Result {
|
||||
if strings.Contains(line, "connect") {
|
||||
return ok("Attempting to connect\n[CHG] Device Connected: yes\nConnection successful\n")
|
||||
}
|
||||
return ok(headphones(true))
|
||||
})
|
||||
got, err := Act("connect", "80:99:E7:C2:29:DA")
|
||||
if err != nil || !got["device"].(Device).Connected {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
if f.lines()[0] != "bluetoothctl --timeout 15 connect 80:99:E7:C2:29:DA" || f.asked[0].Timeout != CallTimeout {
|
||||
t.Errorf("%v", f.lines())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnActBluezRefusesTheAccountIsRetriedThroughSudo(t *testing.T) {
|
||||
f := using(t, func(line string, c Cmd) Result {
|
||||
if strings.HasPrefix(line, "sudo") {
|
||||
return ok("Changing power off succeeded\n" + show)
|
||||
}
|
||||
return ok("Failed to set power off: org.freedesktop.DBus.Error.AccessDenied\n")
|
||||
})
|
||||
if _, err := Power(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := f.lines(); l[0] != "bluetoothctl power off" || l[1] != "sudo -n bluetoothctl power off" {
|
||||
t.Errorf("%v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanIsBoundedAndAnswersUnpairedDevicesStrongestFirst(t *testing.T) {
|
||||
f := using(t, func(line string, c Cmd) Result {
|
||||
switch {
|
||||
case strings.Contains(line, "scan on"):
|
||||
return ok("Discovery started\n[NEW] Device AA:BB:CC:DD:EE:01 Speaker\n")
|
||||
case line == "bluetoothctl devices":
|
||||
return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice AA:BB:CC:DD:EE:01 Speaker\nDevice AA:BB:CC:DD:EE:02 Phone\nDevice AA:BB:CC:DD:EE:03 Gone\n")
|
||||
case strings.HasSuffix(line, "EE:01"):
|
||||
return ok("Device AA:BB:CC:DD:EE:01\n\tName: Speaker\n\tPaired: no\n\tRSSI: 0xffffffc4 (-60)\n")
|
||||
case strings.HasSuffix(line, "EE:02"):
|
||||
return ok("Device AA:BB:CC:DD:EE:02\n\tName: Phone\n\tPaired: no\n\tRSSI: -40\n")
|
||||
case strings.HasSuffix(line, "EE:03"):
|
||||
return Result{Status: 1, Stdout: "Device AA:BB:CC:DD:EE:03 not available\n"}
|
||||
}
|
||||
return ok(headphones(false))
|
||||
})
|
||||
got, err := Scan(8)
|
||||
found := got["found"].([]Device)
|
||||
if err != nil || len(found) != 2 || found[0].Name != "Phone" || *found[1].RSSI != -60 {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if f.lines()[0] != "bluetoothctl --timeout 8 scan on" || f.asked[0].Timeout.Seconds() != 12 {
|
||||
t.Errorf("%v %v", f.lines()[0], f.asked[0].Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairUsesAnAgentThatConfirmsNothingAndThenTrusts(t *testing.T) {
|
||||
f := using(t, func(line string, c Cmd) Result {
|
||||
if strings.Contains(line, " pair ") {
|
||||
return ok("Pairing successful\n")
|
||||
}
|
||||
return ok(headphones(false))
|
||||
})
|
||||
if _, err := Pair("80:99:e7:c2:29:da"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := f.lines(); l[0] != "bluetoothctl --agent NoInputNoOutput --timeout 15 pair 80:99:e7:c2:29:da" || l[1] != "bluetoothctl trust 80:99:e7:c2:29:da" {
|
||||
t.Errorf("%v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAddressIsSixHexPairs(t *testing.T) {
|
||||
for _, bad := range []string{"", "80:99:E7:C2:29", "80:99:E7:C2:29:DA; rm", "--help", "GG:99:E7:C2:29:DA"} {
|
||||
if _, err := addressOf(map[string]any{"address": bad}); err == nil {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
if a, err := addressOf(map[string]any{"address": "80:99:e7:c2:29:da"}); err != nil || a != "80:99:E7:C2:29:DA" {
|
||||
t.Errorf("%s %v", a, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package main
|
||||
|
||||
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||
// than shared; a change to one copy is made to all eight.
|
||||
//
|
||||
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||
// started when it takes longer;
|
||||
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command is held to.
|
||||
const (
|
||||
CallTimeout = 20 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
)
|
||||
|
||||
// Cmd is one command a tool runs.
|
||||
type Cmd struct {
|
||||
Name string
|
||||
Args []string
|
||||
// Stdin is written to the command's standard input when not empty.
|
||||
Stdin string
|
||||
// Env is added to this process's own environment.
|
||||
Env []string
|
||||
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||
Root bool
|
||||
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||
Timeout time.Duration
|
||||
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||
// until the child exits.
|
||||
Detached bool
|
||||
}
|
||||
|
||||
// Result is what a command did.
|
||||
type Result struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Status int `json:"status"`
|
||||
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||
Error string `json:"error,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// Runner runs a command. Tests replace it; nothing else does.
|
||||
type Runner func(Cmd) Result
|
||||
|
||||
var (
|
||||
run Runner = execRun
|
||||
euid = os.Geteuid
|
||||
)
|
||||
|
||||
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||
// process is not root.
|
||||
func argv(c Cmd) (string, []string) {
|
||||
if c.Root && euid() != 0 {
|
||||
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||
}
|
||||
return c.Name, c.Args
|
||||
}
|
||||
|
||||
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||
type bounded struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (w *bounded) Write(p []byte) (int, error) {
|
||||
room := MostOutput - w.b.Len()
|
||||
if room <= 0 {
|
||||
w.cut = w.cut || len(p) > 0
|
||||
return len(p), nil
|
||||
}
|
||||
if len(p) > room {
|
||||
w.b.Write(p[:room])
|
||||
w.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return w.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(c Cmd) Result {
|
||||
timeout := c.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = CallTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
name, args := argv(c)
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||
if !c.Detached {
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
if c.Stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||
}
|
||||
var out, errs bounded
|
||||
var outFile, errFile *os.File
|
||||
if c.Detached {
|
||||
var err error
|
||||
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(outFile.Name())
|
||||
defer outFile.Close()
|
||||
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(errFile.Name())
|
||||
defer errFile.Close()
|
||||
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||
} else {
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
}
|
||||
err := cmd.Run()
|
||||
if c.Detached {
|
||||
for _, f := range []struct {
|
||||
file *os.File
|
||||
into *bounded
|
||||
}{{outFile, &out}, {errFile, &errs}} {
|
||||
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||
_, _ = io.Copy(f.into, f.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
r.Status, r.Error = 124, "timeout"
|
||||
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||
r.Status, r.Error = 127, "not-found"
|
||||
case errors.As(err, &exit):
|
||||
r.Status = exit.ExitCode()
|
||||
default:
|
||||
r.Status, r.Error = 127, err.Error()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// call runs a command and answers its result, or an error naming what went wrong.
|
||||
func call(c Cmd) (Result, error) {
|
||||
r := run(c)
|
||||
if r.Status == 0 && r.Error == "" {
|
||||
return r, nil
|
||||
}
|
||||
return r, failure(c, r)
|
||||
}
|
||||
|
||||
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||
// status with the end of what it said.
|
||||
func failure(c Cmd, r Result) error {
|
||||
program, _ := argv(c)
|
||||
switch {
|
||||
case r.Error == "not-found" && program == "sudo":
|
||||
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||
case r.Error == "not-found":
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case r.Error == "timeout":
|
||||
limit := c.Timeout
|
||||
if limit <= 0 {
|
||||
limit = CallTimeout
|
||||
}
|
||||
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||
case r.Error != "":
|
||||
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||
c.Name, firstLine(r.Stderr))
|
||||
}
|
||||
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||
if said == "" {
|
||||
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
// lines are a command's output lines, blank ones dropped.
|
||||
func lines(s string) []string {
|
||||
out := []string{}
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
out = append(out, strings.TrimRight(l, "\r"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Arguments, read the way a tool's JSON arguments arrive.
|
||||
|
||||
func text(args map[string]any, key string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "", fmt.Errorf("%s must not be empty", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func optText(args map[string]any, key, def string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def, nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s must be a number", key)
|
||||
}
|
||||
}
|
||||
if f != float64(int(f)) {
|
||||
return 0, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least {
|
||||
return 0, fmt.Errorf("%s must be at least %d", key, least)
|
||||
}
|
||||
if n > most {
|
||||
n = most
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s must be true or false", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func optList(args map[string]any, key string) ([]string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
s, ok := it.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// oneOf refuses a value outside a closed set.
|
||||
func oneOf(key, value string, allowed ...string) error {
|
||||
for _, a := range allowed {
|
||||
if value == a {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||
}
|
||||
|
||||
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||
// snap, application and printer names never do.
|
||||
func plainName(key, value string) error {
|
||||
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
// Tests of kit.go, the same in each workstation module.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fake records the commands asked and answers each from a function of the command line.
|
||||
type fake struct {
|
||||
asked []Cmd
|
||||
answer func(line string, c Cmd) Result
|
||||
}
|
||||
|
||||
func (f *fake) runner() Runner {
|
||||
return func(c Cmd) Result {
|
||||
f.asked = append(f.asked, c)
|
||||
name, args := argv(c)
|
||||
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||
if f.answer == nil {
|
||||
return Result{}
|
||||
}
|
||||
return f.answer(line, c)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fake) lines() []string {
|
||||
out := []string{}
|
||||
for _, c := range f.asked {
|
||||
name, args := argv(c)
|
||||
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// using installs a fake runner and a non-root uid for one test.
|
||||
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||
t.Helper()
|
||||
f := &fake{answer: answer}
|
||||
wasRun, wasUID := run, euid
|
||||
run, euid = f.runner(), func() int { return 1000 }
|
||||
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||
return f
|
||||
}
|
||||
|
||||
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||
|
||||
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||
was := euid
|
||||
defer func() { euid = was }()
|
||||
euid = func() int { return 1000 }
|
||||
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||
t.Fatalf("not root: %s %v", name, args)
|
||||
}
|
||||
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||
t.Fatalf("a read is run as the account: %s", name)
|
||||
}
|
||||
euid = func() int { return 0 }
|
||||
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||
t.Fatalf("as root no sudo: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||
was := euid
|
||||
defer func() { euid = was }()
|
||||
euid = func() int { return 1000 }
|
||||
cases := []struct {
|
||||
c Cmd
|
||||
r Result
|
||||
want string
|
||||
}{
|
||||
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||
}
|
||||
for _, k := range cases {
|
||||
err := failure(k.c, k.r)
|
||||
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||
var w bounded
|
||||
big := strings.Repeat("a", MostOutput+10)
|
||||
n, _ := w.Write([]byte(big))
|
||||
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||
if r.Error != "timeout" {
|
||||
t.Fatalf("a slow command: %+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||
if r.Error != "not-found" {
|
||||
t.Fatalf("a missing program: %+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||
if r.Stdout != "given" {
|
||||
t.Fatalf("stdin: %+v", r)
|
||||
}
|
||||
start := time.Now()
|
||||
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||
if _, err := text(args, "missing"); err == nil {
|
||||
t.Error("a missing required string")
|
||||
}
|
||||
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||
t.Errorf("held to most: %d", n)
|
||||
}
|
||||
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||
t.Error("below least")
|
||||
}
|
||||
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||
t.Error("a fraction")
|
||||
}
|
||||
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||
t.Errorf("list: %v", l)
|
||||
}
|
||||
if b, _ := optFlag(args, "b", false); !b {
|
||||
t.Error("flag")
|
||||
}
|
||||
if err := plainName("name", "--all"); err == nil {
|
||||
t.Error("an option as a name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// The bluetooth module's tools (novox/hq research 027/02, 026/05): the controller, the devices with
|
||||
// their state and battery, scanning, and pairing, connecting, trusting and forgetting a device. A Go
|
||||
// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193); it runs as the operator
|
||||
// account, and speaks to bluez through bluetoothctl.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
var providedBy = map[string]string{
|
||||
"bluetoothctl": "the bluez-utils package, which this module installs",
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var addressArg = map[string]any{"type": "string", "description": "the device's address, such as 80:99:E7:C2:29:DA, as bluetooth_devices answers it"}
|
||||
|
||||
func withAddress(f func(string) (any, error)) func(map[string]any) (any, error) {
|
||||
return func(args map[string]any) (any, error) {
|
||||
a, err := addressOf(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f(a)
|
||||
}
|
||||
}
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "bluetooth_controller",
|
||||
Description: "The machine's Bluetooth controller: address, name, powered, discoverable, pairable, discovering. (r)",
|
||||
Input: map[string]any{},
|
||||
Run: func(map[string]any) (any, error) { return Controller() },
|
||||
},
|
||||
{
|
||||
Name: "bluetooth_power",
|
||||
Description: "Whether the controller is powered; with on, switch it on or off. (r/a)",
|
||||
Input: map[string]any{"on": map[string]any{"type": "boolean", "description": "power the controller on (true) or off (false)"}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
if _, given := args["on"]; !given {
|
||||
c, err := Controller()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"powered": c.Powered}, nil
|
||||
}
|
||||
on, err := optFlag(args, "on", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Power(on)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "bluetooth_devices",
|
||||
Description: "The devices bluez knows: every one, or only the paired, connected or trusted. Each with its " +
|
||||
"name, kind, paired, bonded, trusted, blocked, connected, and its battery where the device reports it. (r)",
|
||||
Input: map[string]any{"which": map[string]any{"type": "string", "enum": []string{"all", "paired", "connected", "trusted"}, "description": "which devices (default all)"}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
which, err := optText(args, "which", "all")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Devices(which)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "bluetooth_scan",
|
||||
Description: "Discover devices nearby for a few seconds (default 8, at most 15), and answer the ones not " +
|
||||
"paired, with their signal strength. (r)",
|
||||
Input: map[string]any{"seconds": map[string]any{"type": "integer", "description": "how long to scan (default 8, at most 15)"}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
s, err := optWhole(args, "seconds", 8, 1, 15)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Scan(s)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "bluetooth_connect",
|
||||
Description: "Connect a paired device, such as headphones. Answers its state afterwards. (a)",
|
||||
Input: map[string]any{"address": addressArg},
|
||||
Run: withAddress(func(a string) (any, error) { return Act("connect", a) }),
|
||||
},
|
||||
{
|
||||
Name: "bluetooth_disconnect",
|
||||
Description: "Disconnect a device. (a)",
|
||||
Input: map[string]any{"address": addressArg},
|
||||
Run: withAddress(func(a string) (any, error) { return Act("disconnect", a) }),
|
||||
},
|
||||
{
|
||||
Name: "bluetooth_trust",
|
||||
Description: "Trust a device, so it may connect by itself; or with trusted false, stop trusting it. (a)",
|
||||
Input: map[string]any{"address": addressArg, "trusted": map[string]any{"type": "boolean", "description": "trust (default) or untrust"}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
a, err := addressOf(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trusted, err := optFlag(args, "trusted", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if trusted {
|
||||
return Act("trust", a)
|
||||
}
|
||||
return Act("untrust", a)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "bluetooth_pair",
|
||||
Description: "Pair a device found by a scan, and trust it. Works for a device that needs no code to be " +
|
||||
"confirmed, such as headphones; one that shows a code is paired from the desktop. (a)",
|
||||
Input: map[string]any{"address": addressArg},
|
||||
Run: withAddress(func(a string) (any, error) { return Pair(a) }),
|
||||
},
|
||||
{
|
||||
Name: "bluetooth_remove",
|
||||
Description: "Forget a device: unpair it and drop what bluez knows of it. (a)",
|
||||
Input: map[string]any{"address": addressArg},
|
||||
Run: withAddress(func(a string) (any, error) { return Act("remove", a) }),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||
// definition so the module's own tests can hold it to what it says.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// packages are the packages the module installs, sorted.
|
||||
func (m manifest) packages() []string {
|
||||
out := []string{}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" && r["absent"] != true {
|
||||
out = append(out, r["package"].(string))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// services are the units the module declares, by unit name.
|
||||
func (m manifest) services() map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "service" {
|
||||
out[r["unit"].(string)] = r
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||
t.Helper()
|
||||
registered := []string{}
|
||||
for _, tool := range tools() {
|
||||
registered = append(registered, tool.Name)
|
||||
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||
}
|
||||
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||
}
|
||||
}
|
||||
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
binary := filepath.Base(cwd)
|
||||
a := m.Build.Artifacts[0]
|
||||
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||
for k, v := range want {
|
||||
if a[k] != v {
|
||||
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||
}
|
||||
}
|
||||
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil {
|
||||
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module bluetooth
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"module": "bluetooth",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"tools": [
|
||||
"bluetooth_controller",
|
||||
"bluetooth_power",
|
||||
"bluetooth_devices",
|
||||
"bluetooth_scan",
|
||||
"bluetooth_connect",
|
||||
"bluetooth_disconnect",
|
||||
"bluetooth_trust",
|
||||
"bluetooth_pair",
|
||||
"bluetooth_remove"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "stack",
|
||||
"type": "package",
|
||||
"package": "bluez"
|
||||
},
|
||||
{
|
||||
"id": "utilities",
|
||||
"type": "package",
|
||||
"package": "bluez-utils"
|
||||
},
|
||||
{
|
||||
"id": "daemon",
|
||||
"type": "service",
|
||||
"unit": "bluetooth.service",
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/bluetooth-tools",
|
||||
"binary": "bluetooth-tools",
|
||||
"loads": [
|
||||
"bluetooth-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# claude-code
|
||||
|
||||
The operator's agent on a machine (novox/hq design 36): its package, its machine-wide managed
|
||||
configuration, and the consumer side of the Anthropic licence manager (design 39, ADR 0183).
|
||||
|
||||
## What it owns
|
||||
|
||||
Two directories, declared, so the mesh refuses a second module owning either:
|
||||
|
||||
- `/etc/claude-code`, the agent's machine-wide managed directory, root's, `0755`.
|
||||
- `~/.claude` under the operator account's home, the operator's, `0700`. The module owns the directory —
|
||||
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
|
||||
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's
|
||||
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host
|
||||
removes a directory only when it is empty.
|
||||
|
||||
## What it writes
|
||||
|
||||
Under the agent's managed directory, `/etc/claude-code`, owned whole by this module and rewritten
|
||||
whenever the node's tool runtime collects the module's tools:
|
||||
|
||||
| file | holds |
|
||||
|---|---|
|
||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||
| `managed-settings.json` | the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
|
||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions |
|
||||
|
||||
Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands
|
||||
this node a subscription token. Nothing else under the home is read or written.
|
||||
|
||||
## Over NATS
|
||||
|
||||
Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that
|
||||
something happened and carries no secret, because a stream keeps it; a **request** carries a token,
|
||||
because nothing keeps it (hq design 32 §10); and **state** is the current value of something every node
|
||||
must see, a node that joins later included — kept, so it carries no secret either (hq ADR 0201).
|
||||
|
||||
| what | how |
|
||||
|---|---|
|
||||
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206) |
|
||||
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
|
||||
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
|
||||
| an MCP server registered through this module | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. An entry with a secret in its `env` or `headers` is refused by the runtime |
|
||||
|
||||
## Tools
|
||||
|
||||
`claude_code_status`, `claude_code_render`, `claude_code_pull`, `claude_code_grant` (for the licence
|
||||
manager), `claude_code_mcp_list`,
|
||||
`claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this
|
||||
node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`.
|
||||
|
||||
## Settings
|
||||
|
||||
Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`:
|
||||
|
||||
- `role` — what this node is, in a few words; shown to every session.
|
||||
- `mcp_servers` — extra tool servers, set by the operator for the mesh or a node, beside the ones
|
||||
registered through the tools; keyed by name, in the vendor's `.mcp.json` entry shape
|
||||
(`{"type":"http","url":…}` or `{"type":"stdio","command":…,"args":[…]}`). The name `mesh` is the
|
||||
module's own and cannot be set. Put a person's own servers here, or they stop loading.
|
||||
|
||||
## On a machine that carried the predecessor
|
||||
|
||||
Remove these by hand, once; the mesh removes nothing it did not make (ADR 0182):
|
||||
|
||||
- `~/.claude/CLAUDE.md`
|
||||
- `~/.claude/rules/00-hal-mesh.md`, `~/.claude/rules/conventions.md`
|
||||
- `~/.claude/skills/cleanup/`, `~/.claude/skills/hal-switch-license/`
|
||||
- the hand-made console entry in `~/.claude.json` under `mcpServers` — it is ignored now anyway
|
||||
|
||||
## Escalation
|
||||
|
||||
Writing `/etc/claude-code` needs root. The runtime runs as the operator account, and the module uses
|
||||
that account's passwordless `sudo`; on a machine without it, `claude_code_render` says so and nothing
|
||||
is written.
|
||||
|
||||
## Code
|
||||
|
||||
Go, one binary (`cmd/claude-code`) the node's runtime launches. Tested with `go test ./...`; the managed
|
||||
instruction file is held to the TypeScript renderer it replaced (`testdata/rendered-by-typescript.json`),
|
||||
and the sealed box is the licence manager's own format.
|
||||
@@ -0,0 +1,364 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var now = time.Now().UnixMilli()
|
||||
|
||||
func node(t *testing.T, name string) (Paths, map[string]string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
p := Paths{State: filepath.Join(root, "state"), Facts: filepath.Join(root, "state", "facts.json"),
|
||||
Settings: filepath.Join(root, "state", "settings.json"), Home: filepath.Join(root, "home"), Node: name}
|
||||
_ = os.MkdirAll(p.State, 0o700)
|
||||
_ = os.MkdirAll(filepath.Join(p.Home, ".claude"), 0o700)
|
||||
_ = os.WriteFile(p.Facts, []byte(`{"node":"`+name+`","console":"http://127.0.0.1:4270/mcp"}`), 0o600)
|
||||
_ = os.WriteFile(p.Settings, []byte(`{"role":"","mcp_servers":{}}`), 0o600)
|
||||
return p, map[string]string{}
|
||||
}
|
||||
|
||||
func writer(w map[string]string) WriteManaged {
|
||||
return func(name, content string) (string, error) { w[name] = content; return name + ": written", nil }
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func creds(t *testing.T, p Paths) map[string]any {
|
||||
t.Helper()
|
||||
var c map[string]any
|
||||
raw, _ := os.ReadFile(p.credentials())
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c["claudeAiOauth"].(map[string]any)
|
||||
}
|
||||
|
||||
// ---- the renderer, held to the TypeScript it replaced -------------------------------------------------
|
||||
|
||||
func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/rendered-by-typescript.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f struct {
|
||||
Facts Facts `json:"facts"`
|
||||
Settings Settings `json:"settings"`
|
||||
Registered Servers `json:"registered"`
|
||||
WithKey map[string]string `json:"withKey"`
|
||||
Plain map[string]string `json:"plain"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same := func(label string, got, want map[string]string) {
|
||||
if got["CLAUDE.md"] != want["CLAUDE.md"] {
|
||||
t.Errorf("%s: CLAUDE.md differs from the TypeScript's:\n--- go\n%s\n--- typescript\n%s", label, got["CLAUDE.md"], want["CLAUDE.md"])
|
||||
}
|
||||
for _, file := range []string{"managed-mcp.json", "managed-settings.json"} {
|
||||
var a, b any
|
||||
_ = json.Unmarshal([]byte(got[file]), &a)
|
||||
_ = json.Unmarshal([]byte(want[file]), &b)
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("%s: %s means something else:\n--- go\n%s\n--- typescript\n%s", label, file, got[file], want[file])
|
||||
}
|
||||
}
|
||||
}
|
||||
same("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered), f.WithKey)
|
||||
same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}), f.Plain)
|
||||
}
|
||||
|
||||
func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) {
|
||||
out := Render(Facts{Node: "w", Console: "http://127.0.0.1:4270/mcp"},
|
||||
Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil)
|
||||
var mcp struct {
|
||||
MCPServers map[string]map[string]any `json:"mcpServers"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(out["managed-mcp.json"]), &mcp)
|
||||
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
|
||||
t.Fatalf("%v", mcp.MCPServers)
|
||||
}
|
||||
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil)) {
|
||||
t.Fatal("rendering is not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the credentials file -----------------------------------------------------------------------------
|
||||
|
||||
func i64(v int64) *int64 { return &v }
|
||||
|
||||
func TestTheLineageRules(t *testing.T) {
|
||||
const hour = 3_600_000
|
||||
g := func(at string, exp int64, rtExp int64) Grant {
|
||||
return Grant{AccessToken: at, ExpiresAt: exp, RefreshTokenExpiresAt: i64(rtExp)}
|
||||
}
|
||||
month := now + 30*24*hour
|
||||
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + hour, RefreshTokenExpiresAt: i64(month)}, g("B", now+2*hour, month), false); !d.Apply {
|
||||
t.Fatal("a newer rotation was refused")
|
||||
}
|
||||
if d := DecideApply(&Grant{AccessToken: "new", ExpiresAt: now + 2*hour, RefreshTokenExpiresAt: i64(month)}, g("old", now+hour, month), false); d.Apply || d.Reason != "not-newer" {
|
||||
t.Fatalf("a late older rotation: %+v", d)
|
||||
}
|
||||
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + 8*hour, RefreshTokenExpiresAt: i64(month)}, g("re", now+hour, now+5*24*hour), false); !d.Apply || !d.Reissued {
|
||||
t.Fatalf("a re-issued grant: %+v", d)
|
||||
}
|
||||
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + 8*hour}, g("other", now+hour, month), true); !d.Apply {
|
||||
t.Fatal("a switch was refused")
|
||||
}
|
||||
if d := DecideApply(&Grant{AccessToken: "A"}, Grant{AccessToken: "A"}, true); d.Apply || d.Reason != "already-current" {
|
||||
t.Fatalf("the same token: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALoginIsSeenAndStrippedWhenTheNodesOwnGrantIsWritten(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-login","refreshToken":"rt-login","expiresAt":1700000000000},"other":1}`)
|
||||
login := ReadCredentials(p.credentials())
|
||||
if !HoldsLogin(login) {
|
||||
t.Fatal("a login was not seen")
|
||||
}
|
||||
if err := WriteCredentials(p.credentials(), WithGrant(login, Grant{AccessToken: "at-mesh", ExpiresAt: 1, Scopes: []string{"user:inference"}})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back := ReadCredentials(p.credentials())
|
||||
raw, _ := os.ReadFile(p.credentials())
|
||||
info, _ := os.Stat(p.credentials())
|
||||
if HoldsLogin(back) || GrantOf(back).AccessToken != "at-mesh" || back["other"] == nil || strings.Contains(string(raw), "rt-login") || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("written %s (mode %v)", raw, info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAccountIsReadFromTheAgentsStateFileAndNeverGuessed(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-1","emailAddress":"a@example.org"},"other":2}`)
|
||||
if id := ReadIdentity(p.account()); id == nil || id.AccountUUID != "u-1" || id.EmailAddress != "a@example.org" {
|
||||
t.Fatalf("%+v", id)
|
||||
}
|
||||
if ReadIdentity("/nonexistent/.claude.json") != nil {
|
||||
t.Fatal("an identity from nothing")
|
||||
}
|
||||
writeFile(t, p.account(), `{}`)
|
||||
if ReadIdentity(p.account()) != nil {
|
||||
t.Fatal("an identity from an empty file")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the licence, ADR 0206 ----------------------------------------------------------------------------
|
||||
|
||||
func TestWhatANodeHoldsIsReportedWithFingerprintsAndItsAccountNeverAToken(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-secret","refreshToken":"rt-secret","expiresAt":2000,"refreshTokenExpiresAt":9000}}`)
|
||||
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-1","emailAddress":"a@example.org"}}`)
|
||||
h := HoldingsOf(p)
|
||||
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || *h.Kind != "subscription" || !h.Refresh.Present ||
|
||||
!strings.HasPrefix(*h.Refresh.Fingerprint, "sha256:") || h.Access.ExpiresAt != 2000 || h.ChangedAt == nil {
|
||||
t.Fatalf("%+v", h)
|
||||
}
|
||||
raw, _ := json.Marshal(h)
|
||||
if strings.Contains(string(raw), "at-secret") || strings.Contains(string(raw), "rt-secret") {
|
||||
t.Fatalf("a token is in the report: %s", raw)
|
||||
}
|
||||
var keys map[string]any
|
||||
_ = json.Unmarshal(raw, &keys)
|
||||
for k := range keys {
|
||||
if strings.Contains(strings.ToLower(k), "token") || strings.Contains(strings.ToLower(k), "secret") {
|
||||
t.Fatalf("a field the runtime would refuse: %s", k)
|
||||
}
|
||||
}
|
||||
// The manager reads exactly this shape.
|
||||
if _, err := time.Parse(time.RFC3339Nano, *h.ChangedAt); err != nil {
|
||||
t.Fatalf("the manager cannot read the report's time: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGrantAnswersOnlyAWaitingLoginSealedToTheManagersKey(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
manager, _ := GenerateKeyPair()
|
||||
if a, _ := GrantFor(p, manager.PublicKey); a.Sealed != nil || a.Waiting == nil || *a.Waiting {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at","refreshToken":"rt-login","expiresAt":1}}`)
|
||||
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-9"}}`)
|
||||
a, err := GrantFor(p, manager.PublicKey)
|
||||
if err != nil || a.Identity.AccountUUID != "u-9" {
|
||||
t.Fatalf("%+v %v", a, err)
|
||||
}
|
||||
plain, _ := Open(*a.Sealed, manager.PrivateKey)
|
||||
if !strings.Contains(plain, `"refreshToken":"rt-login"`) {
|
||||
t.Fatalf("opened %s", plain)
|
||||
}
|
||||
raw, _ := json.Marshal(a)
|
||||
if strings.Contains(string(raw), "rt-login") {
|
||||
t.Fatal("the refresh token crossed in the clear")
|
||||
}
|
||||
}
|
||||
|
||||
// seat answers `current` as the manager does: the grant sealed to the key the node sent.
|
||||
func seat(t *testing.T, licence, token string, gen int64, asked *[]string) Ask {
|
||||
return func(address string, args any) (json.RawMessage, error) {
|
||||
*asked = append(*asked, address)
|
||||
key := args.(map[string]any)["public_key"].(string)
|
||||
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000})
|
||||
box, err := Seal(string(g), key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return json.Marshal(Current{Licence: licence, Kind: "subscription", Generation: gen, Sealed: &box})
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
var asked []string
|
||||
ask := seat(t, "personal", "at-1", 3, &asked)
|
||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(asked) != 1 || asked[0] != "seat:anthropic-licence-manager.current" || creds(t, p)["accessToken"] != "at-1" {
|
||||
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
|
||||
}
|
||||
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 1 {
|
||||
t.Fatal("an equal generation asked again")
|
||||
}
|
||||
if HoldingsOf(p).Generation != 3 || w["managed-mcp.json"] == "" {
|
||||
t.Fatal("the generation or the managed files were not written")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
|
||||
strings.TrimSpace(string(mustJSON(now+7_200_000)))+`}}`)
|
||||
var asked []string
|
||||
out, err := Pull(p, seat(t, "personal", "at-new", 1, &asked), writer(w))
|
||||
if err != nil || out["applied"] != true {
|
||||
t.Fatalf("%v %v", out, err)
|
||||
}
|
||||
c := creds(t, p)
|
||||
if c["accessToken"] != "at-new" || c["refreshToken"] != nil || HoldingsOf(p).Refresh.Present {
|
||||
t.Fatalf("%v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
|
||||
|
||||
// ---- MCP servers in state, ADR 0201 -------------------------------------------------------------------
|
||||
|
||||
// bus is the `servers` state as every node in a test shares it, with each node's watch.
|
||||
type bus struct {
|
||||
kept map[string]map[string]any
|
||||
watchers []func(ServerChange)
|
||||
}
|
||||
|
||||
func (b *bus) Put(key string, value any) error {
|
||||
v := value.(map[string]any)
|
||||
b.kept[key] = v
|
||||
for _, w := range b.watchers {
|
||||
w(ServerChange{Key: key, Op: "put", Value: v})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bus) Delete(key string) error {
|
||||
delete(b.kept, key)
|
||||
for _, w := range b.watchers {
|
||||
w(ServerChange{Key: key, Op: "delete"})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bus) Keys() ([]string, error) {
|
||||
var out []string
|
||||
for k := range b.kept {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// join is a node joining: its view takes the current state, then every change.
|
||||
func (b *bus) join(p Paths, w map[string]string) *ServerView {
|
||||
v := NewServerView(p)
|
||||
for k, val := range b.kept {
|
||||
_, _ = OnServerChange(v, ServerChange{Key: k, Op: "put", Value: val}, p, writer(w))
|
||||
}
|
||||
b.watchers = append(b.watchers, func(c ServerChange) { _, _ = OnServerChange(v, c, p, writer(w)) })
|
||||
return v
|
||||
}
|
||||
|
||||
func noOthers() ([]string, error) { return nil, nil }
|
||||
|
||||
func TestRegisteringHerePutsItUnderThisNodesKeyAndAsksAboutTheOthers(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
b := &bus{kept: map[string]map[string]any{}}
|
||||
v := b.join(p, w)
|
||||
r, err := RegisterServer(p, Registration{Name: "search", Entry: map[string]any{"type": "http", "url": "https://s.example/mcp"}}, b, v, writer(w),
|
||||
func() ([]string, error) { return []string{"laptop", "server", "desktop"}, nil })
|
||||
if err != nil || r["here"] != "changed" || !strings.Contains(r["also"].(string), "server, desktop") || b.kept["laptop.search"] == nil {
|
||||
t.Fatalf("%v %v %v", r, err, b.kept)
|
||||
}
|
||||
if !strings.Contains(w["managed-mcp.json"], `"search"`) {
|
||||
t.Fatal("not rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryNodeRegistrationReachesTheOthersAndALateNodeReadsIt(t *testing.T) {
|
||||
a, wa := node(t, "laptop")
|
||||
s, ws := node(t, "server")
|
||||
b := &bus{kept: map[string]map[string]any{}}
|
||||
va := b.join(a, wa)
|
||||
b.join(s, ws)
|
||||
_, _ = RegisterServer(a, Registration{Name: "docs", Entry: map[string]any{"type": "stdio", "command": "docs-mcp"}, Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
|
||||
if Registered(s)["docs"] == nil || !strings.Contains(ws["managed-mcp.json"], "docs-mcp") {
|
||||
t.Fatalf("the other node did not take it: %v", Registered(s))
|
||||
}
|
||||
late, wl := node(t, "desktop")
|
||||
b.join(late, wl)
|
||||
if Registered(late)["docs"] == nil {
|
||||
t.Fatal("a node joining later did not read the current set")
|
||||
}
|
||||
_, _ = RegisterServer(a, Registration{Name: "docs", Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
|
||||
if Registered(s)["docs"] != nil || Registered(late)["docs"] != nil {
|
||||
t.Fatal("an unregistration did not reach every node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodesOwnRegistrationOverridesTheOneForEveryNode(t *testing.T) {
|
||||
a, wa := node(t, "laptop")
|
||||
s, ws := node(t, "server")
|
||||
b := &bus{kept: map[string]map[string]any{}}
|
||||
va := b.join(a, wa)
|
||||
b.join(s, ws)
|
||||
_, _ = RegisterServer(a, Registration{Name: "x", Entry: map[string]any{"type": "http", "url": "https://all"}, Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
|
||||
_, _ = RegisterServer(a, Registration{Name: "x", Entry: map[string]any{"type": "http", "url": "https://laptop"}}, b, va, writer(wa), noOthers)
|
||||
if Registered(a)["x"]["url"] != "https://laptop" || Registered(s)["x"]["url"] != "https://all" {
|
||||
t.Fatalf("%v %v", Registered(a), Registered(s))
|
||||
}
|
||||
r, _ := RegisterServer(a, Registration{Name: "x"}, b, va, writer(wa), noOthers)
|
||||
if !strings.Contains(r["still"].(string), "still applies here") || Registered(a)["x"]["url"] != "https://all" {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABadEntryIsRefusedBeforeAnythingIsPut(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
b := &bus{kept: map[string]map[string]any{}}
|
||||
v := b.join(p, w)
|
||||
r, _ := RegisterServer(p, Registration{Name: "mesh", Entry: map[string]any{"type": "http", "url": "https://x"}}, b, v, writer(w), noOthers)
|
||||
if r["registered"] != false || len(b.kept) != 0 {
|
||||
t.Fatalf("%v %v", r, b.kept)
|
||||
}
|
||||
if done, _ := OnServerChange(v, ServerChange{Key: "server.b", Op: "put", Value: map[string]any{"type": "http", "url": "https://b"}}, p, writer(w)); done != "" {
|
||||
t.Fatal("another node's key changed this one")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
// The agent's credentials file, and whether an offered grant may replace what it holds (novox/hq ADR 0183,
|
||||
// ADR 0206, design 36 §5). Pure where it decides, so the rules are tested without a file.
|
||||
//
|
||||
// The file is the vendor's: `{ claudeAiOauth: { accessToken, expiresAt, refreshTokenExpiresAt?, scopes?,
|
||||
// subscriptionType?, rateLimitTier? }, ... }`. A node bound to a licence never holds a refresh token, so
|
||||
// the one this module writes never carries one; a refresh token found there is a person's login.
|
||||
//
|
||||
// The lineage rule is the predecessor's, with the incidents that earned it: a rotation of the same licence
|
||||
// is applied only if newer; a grant re-issued by a login is adopted whatever its expiry; a switch to another
|
||||
// licence is applied regardless, because across licences the expiries are unrelated numbers.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Grant is what the manager hands a node: an access token and its expiries, never a refresh token.
|
||||
type Grant struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
RefreshTokenExpiresAt *int64 `json:"refreshTokenExpiresAt,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
SubscriptionType string `json:"subscriptionType,omitempty"`
|
||||
RateLimitTier string `json:"rateLimitTier,omitempty"`
|
||||
}
|
||||
|
||||
// Decision is whether a handed grant is applied, and why not.
|
||||
type Decision struct {
|
||||
Apply bool
|
||||
Reissued bool
|
||||
Reason string // already-current | not-newer
|
||||
}
|
||||
|
||||
// generationTolerance: two refresh-token expiries within a day are one lineage; a login starts a fresh
|
||||
// window weeks away.
|
||||
const generationTolerance = 24 * 60 * 60 * 1000
|
||||
|
||||
func sameGeneration(a, b *int64) bool {
|
||||
if a == nil || b == nil {
|
||||
return true
|
||||
}
|
||||
return math.Abs(float64(*a-*b)) <= generationTolerance
|
||||
}
|
||||
|
||||
// DecideApply says whether an offered grant replaces the one held; switch is a move to another licence.
|
||||
func DecideApply(local *Grant, offered Grant, switching bool) Decision {
|
||||
if local == nil || local.AccessToken == "" {
|
||||
return Decision{Apply: true}
|
||||
}
|
||||
if local.AccessToken == offered.AccessToken {
|
||||
return Decision{Reason: "already-current"}
|
||||
}
|
||||
reissued := !sameGeneration(local.RefreshTokenExpiresAt, offered.RefreshTokenExpiresAt)
|
||||
if !switching && !reissued && local.ExpiresAt >= offered.ExpiresAt {
|
||||
return Decision{Reason: "not-newer"}
|
||||
}
|
||||
return Decision{Apply: true, Reissued: reissued}
|
||||
}
|
||||
|
||||
// Credentials is the file as found, every key kept — the vendor's other keys are not this module's.
|
||||
type Credentials map[string]any
|
||||
|
||||
func (c Credentials) oauth() map[string]any {
|
||||
o, _ := c["claudeAiOauth"].(map[string]any)
|
||||
return o
|
||||
}
|
||||
|
||||
// ReadCredentials reads the file, keeping numbers as written; nil when there is none.
|
||||
func ReadCredentials(path string) Credentials {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var c Credentials
|
||||
if dec.Decode(&c) != nil {
|
||||
return nil
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func number(v any) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err != nil {
|
||||
f, err := n.Float64()
|
||||
return int64(f), err == nil
|
||||
}
|
||||
return i, true
|
||||
case float64:
|
||||
return int64(n), true
|
||||
case int64:
|
||||
return n, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// GrantOf is the grant the file holds, or nil.
|
||||
func GrantOf(c Credentials) *Grant {
|
||||
o := c.oauth()
|
||||
at, _ := o["accessToken"].(string)
|
||||
if at == "" {
|
||||
return nil
|
||||
}
|
||||
g := &Grant{AccessToken: at}
|
||||
g.ExpiresAt, _ = number(o["expiresAt"])
|
||||
if v, ok := number(o["refreshTokenExpiresAt"]); ok {
|
||||
g.RefreshTokenExpiresAt = &v
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// HoldsLogin says the file holds a refresh token — which this module never writes, so a person's login.
|
||||
func HoldsLogin(c Credentials) bool {
|
||||
rt, _ := c.oauth()["refreshToken"].(string)
|
||||
return rt != ""
|
||||
}
|
||||
|
||||
// RefreshTokenOf is the refresh token a login left, or "".
|
||||
func RefreshTokenOf(c Credentials) string {
|
||||
rt, _ := c.oauth()["refreshToken"].(string)
|
||||
return rt
|
||||
}
|
||||
|
||||
// WithGrant lays the handed grant over what is there, and deletes any refresh token.
|
||||
func WithGrant(local Credentials, g Grant) Credentials {
|
||||
next := Credentials{}
|
||||
for k, v := range local {
|
||||
next[k] = v
|
||||
}
|
||||
oauth := map[string]any{}
|
||||
for k, v := range local.oauth() {
|
||||
oauth[k] = v
|
||||
}
|
||||
oauth["accessToken"] = g.AccessToken
|
||||
oauth["expiresAt"] = g.ExpiresAt
|
||||
if g.RefreshTokenExpiresAt != nil {
|
||||
oauth["refreshTokenExpiresAt"] = *g.RefreshTokenExpiresAt
|
||||
}
|
||||
if len(g.Scopes) > 0 {
|
||||
oauth["scopes"] = g.Scopes
|
||||
}
|
||||
if g.SubscriptionType != "" {
|
||||
oauth["subscriptionType"] = g.SubscriptionType
|
||||
}
|
||||
if g.RateLimitTier != "" {
|
||||
oauth["rateLimitTier"] = g.RateLimitTier
|
||||
}
|
||||
delete(oauth, "refreshToken")
|
||||
next["claudeAiOauth"] = oauth
|
||||
return next
|
||||
}
|
||||
|
||||
// ReplacedBy is the handed grant in place of the old licence's, whole — scopes and subscription included;
|
||||
// only keys outside the grant stay. No refresh token survives.
|
||||
func ReplacedBy(local Credentials, g Grant) Credentials {
|
||||
next := Credentials{}
|
||||
for k, v := range local {
|
||||
if k != "claudeAiOauth" {
|
||||
next[k] = v
|
||||
}
|
||||
}
|
||||
return WithGrant(next, g)
|
||||
}
|
||||
|
||||
// WriteCredentials writes atomically at 0600: a partial credentials file must never be read as a whole one.
|
||||
func WriteCredentials(path string, c Credentials) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := indented(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".mesh-tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// indented is JSON as the agent's own files are written: two-space indent, a trailing newline, nothing
|
||||
// escaped that need not be.
|
||||
func indented(v any) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
enc := json.NewEncoder(&b)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
err := enc.Encode(v)
|
||||
return b.Bytes(), err
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
// Which account the agent is logged in as (novox/hq ADR 0183): not in the token, but in the agent's own
|
||||
// state file beside the home, `~/.claude.json` → `oauthAccount`. Read to report and attribute a login;
|
||||
// written, three keys and nothing else, when a licence is switched, so the account Claude Code shows is
|
||||
// the one whose token it now holds.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Identity is an account as the agent's state file names it.
|
||||
type Identity struct {
|
||||
AccountUUID string `json:"accountUuid"`
|
||||
EmailAddress string `json:"emailAddress,omitempty"`
|
||||
OrganizationUUID string `json:"organizationUuid,omitempty"`
|
||||
}
|
||||
|
||||
func readState(path string) (map[string]any, bool) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var m map[string]any
|
||||
if dec.Decode(&m) != nil || m == nil {
|
||||
return nil, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
// ReadIdentity is the account the state file names, or nil — never a guess.
|
||||
func ReadIdentity(path string) *Identity {
|
||||
m, ok := readState(path)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
a, _ := m["oauthAccount"].(map[string]any)
|
||||
uuid, _ := a["accountUuid"].(string)
|
||||
if uuid == "" {
|
||||
return nil
|
||||
}
|
||||
id := &Identity{AccountUUID: uuid}
|
||||
id.EmailAddress, _ = a["emailAddress"].(string)
|
||||
id.OrganizationUUID, _ = a["organizationUuid"].(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// WriteIdentity points the state file's account at id, keeping every other key as found; answers whether
|
||||
// the file changed. A file that is there and cannot be read as an object is left alone.
|
||||
func WriteIdentity(path string, id Identity) (bool, error) {
|
||||
m, ok := readState(path)
|
||||
if !ok {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return false, nil
|
||||
}
|
||||
m = map[string]any{}
|
||||
}
|
||||
current, _ := m["oauthAccount"].(map[string]any)
|
||||
if current == nil {
|
||||
current = map[string]any{}
|
||||
}
|
||||
e, _ := current["emailAddress"].(string)
|
||||
o, _ := current["organizationUuid"].(string)
|
||||
if current["accountUuid"] == id.AccountUUID && e == id.EmailAddress && o == id.OrganizationUUID {
|
||||
return false, nil
|
||||
}
|
||||
current["accountUuid"] = id.AccountUUID
|
||||
current["emailAddress"] = id.EmailAddress
|
||||
current["organizationUuid"] = id.OrganizationUUID
|
||||
m["oauthAccount"] = current
|
||||
raw, err := indented(m)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
tmp := path + ".mesh-tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, os.Rename(tmp, path)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
// instructionsText is the managed instruction file, generated from the TypeScript renderer it replaced so
|
||||
// the file under the agent's managed directory did not change by a byte when the module moved to Go;
|
||||
// a test holds it to that renderer's own output (testdata/rendered-by-typescript.json).
|
||||
func instructionsText(node, role string) string {
|
||||
return "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" +
|
||||
node +
|
||||
"`\n- **Role:** " +
|
||||
role +
|
||||
"\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// claude-code's bundle (novox/hq design 36, ADR 0183, ADR 0206): a binary the node's runtime launches over
|
||||
// stdio as the operator account (ADR 0193) and is the bus for (ADR 0198). It is given its state directory
|
||||
// and two files the mesh renders into it (ADR 0192), beside the runtime's own words.
|
||||
//
|
||||
// At start it renders the agent's managed directory, reports what this node holds as the module's
|
||||
// `holdings` state and again whenever the credentials file changes, watches the licence manager's
|
||||
// `bindings` state for this node and fetches the token when it says so, and watches the module's
|
||||
// `servers` state — every node's MCP server registrations (ADR 0201). node.go holds the logic.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func say(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "[claude-code] "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// writeManaged writes one managed file as root, only when its content changed. From a staged file, never
|
||||
// /dev/stdin: a child's input may be a socket, which /dev/stdin cannot open (found on the first assignment).
|
||||
func writeManaged(name, content string) (string, error) {
|
||||
path := filepath.Join(ManagedDir, name)
|
||||
if was, err := os.ReadFile(path); err == nil && string(was) == content {
|
||||
return name + ": unchanged", nil
|
||||
}
|
||||
staged, err := os.MkdirTemp("", "claude-code-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(staged)
|
||||
source := filepath.Join(staged, name)
|
||||
if err := os.WriteFile(source, []byte(content), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
args := []string{"install", "-D", "-m", "0644", source, path}
|
||||
if os.Geteuid() != 0 {
|
||||
args = append([]string{"sudo", "-n"}, args...)
|
||||
}
|
||||
if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("%s: could not be written to %s (%s); the module writes there through the operator account's passwordless sudo",
|
||||
name, ManagedDir, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return name + ": written", nil
|
||||
}
|
||||
|
||||
// ask is a tool on the bus, through the runtime: its answer is the tool's value.
|
||||
func ask(address string, args any) (json.RawMessage, error) { return stdio.Ask(address, args) }
|
||||
|
||||
// stateOf adapts the SDK's state to what node.go asks of one.
|
||||
type stateOf struct{ s stdio.KeptState }
|
||||
|
||||
func (s stateOf) Put(key string, value any) error { _, err := s.s.Put(key, value); return err }
|
||||
func (s stateOf) Delete(key string) error { return s.s.Delete(key) }
|
||||
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
||||
|
||||
// nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register
|
||||
// tool's question.
|
||||
func nodesRunningMe() ([]string, error) {
|
||||
raw, err := ask("seat:mesh-controller.modules", map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var answer struct {
|
||||
Output string `json:"output"`
|
||||
}
|
||||
text := string(raw)
|
||||
if json.Unmarshal(raw, &answer) == nil && answer.Output != "" {
|
||||
text = answer.Output
|
||||
}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if !strings.HasPrefix(line, "claude-code ") {
|
||||
continue
|
||||
}
|
||||
_, on, ok := strings.Cut(line, " on ")
|
||||
if !ok || strings.TrimSpace(on) == "nothing" {
|
||||
return nil, nil
|
||||
}
|
||||
var out []string
|
||||
for _, n := range strings.Split(on, ",") {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func fingerprintOfFile(path string) any {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return Fingerprint(string(raw))
|
||||
}
|
||||
|
||||
func status(p Paths) map[string]any {
|
||||
creds := ReadCredentials(p.credentials())
|
||||
var token any
|
||||
if g := GrantOf(creds); g != nil {
|
||||
token = map[string]any{"fingerprint": Fingerprint(g.AccessToken), "expiresAt": stamp(g.ExpiresAt), "loginWaiting": HoldsLogin(creds)}
|
||||
}
|
||||
var managed []map[string]any
|
||||
for _, f := range []string{"managed-mcp.json", "managed-settings.json", "CLAUDE.md"} {
|
||||
path := filepath.Join(ManagedDir, f)
|
||||
managed = append(managed, map[string]any{"file": path, "fingerprint": fingerprintOfFile(path)})
|
||||
}
|
||||
var licence any
|
||||
var b Binding
|
||||
if readJSON(p.binding(), &b) {
|
||||
licence = b
|
||||
}
|
||||
names := []string{}
|
||||
for n := range Registered(p) {
|
||||
names = append(names, n)
|
||||
}
|
||||
return map[string]any{"node": p.Node, "licence": licence, "token": token, "holdings": HoldingsOf(p),
|
||||
"managed": managed, "registered": names}
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
// nodesOf reads the tools' `nodes` argument: absent is this node, "all" every node, else a list.
|
||||
func nodesOf(v any) []string {
|
||||
s, _ := v.(string)
|
||||
s = strings.TrimSpace(s)
|
||||
switch s {
|
||||
case "":
|
||||
return nil
|
||||
case "all":
|
||||
return []string{"all"}
|
||||
}
|
||||
var out []string
|
||||
for _, n := range strings.Split(s, ",") {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
|
||||
nodesArg := str(`more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only`)
|
||||
return []stdio.Tool{
|
||||
{Name: "claude_code_status",
|
||||
Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
|
||||
Run: func(map[string]any) (any, error) { return status(p), nil }},
|
||||
{Name: "claude_code_render",
|
||||
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
out, err := RenderNow(p, writeManaged)
|
||||
return map[string]any{"rendered": out}, err
|
||||
}},
|
||||
{Name: "claude_code_pull",
|
||||
Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.",
|
||||
Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }},
|
||||
{Name: "claude_code_grant",
|
||||
Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
|
||||
Input: map[string]any{"public_key": str("the manager's public key, PEM; the grant opens only with its private half")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
key, _ := a["public_key"].(string)
|
||||
if !strings.Contains(key, "PUBLIC KEY") {
|
||||
return nil, errors.New("claude_code_grant seals to a public key, and none was given")
|
||||
}
|
||||
return GrantFor(p, key)
|
||||
}},
|
||||
{Name: "claude_code_mcp_list",
|
||||
Description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.<server>` for every node, `<node>.<server>` for one.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
keys, err := servers.Keys()
|
||||
return map[string]any{"here": Registered(p), "everywhere": keys}, err
|
||||
}},
|
||||
{Name: "claude_code_mcp_register",
|
||||
Description: "Register an MCP server with Claude Code on this node, every node, or a list — an http/sse server by url, or a stdio server by command. Kept on the bus, so a node that joins later takes it too. Never put a secret in env or headers: the mesh refuses one.",
|
||||
Input: map[string]any{
|
||||
"name": str("the server's name: letters, digits, - and _"),
|
||||
"type": str("http, sse or stdio (default stdio when a command is given, http when a url is)"),
|
||||
"url": str("an http or sse server's url"),
|
||||
"command": str("a stdio server's program"),
|
||||
"args": map[string]any{"type": "array", "description": "a stdio server's arguments"},
|
||||
"env": map[string]any{"type": "object", "description": "a stdio server's environment"},
|
||||
"headers": map[string]any{"type": "object", "description": "an http server's headers"},
|
||||
"nodes": nodesArg,
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
entry := map[string]any{}
|
||||
if t, _ := a["type"].(string); t != "" {
|
||||
entry["type"] = t
|
||||
} else if _, hasURL := a["url"]; hasURL {
|
||||
entry["type"] = "http"
|
||||
} else {
|
||||
entry["type"] = "stdio"
|
||||
}
|
||||
for _, k := range []string{"url", "command", "args", "env", "headers"} {
|
||||
if v, ok := a[k]; ok {
|
||||
entry[k] = v
|
||||
}
|
||||
}
|
||||
name, _ := a["name"].(string)
|
||||
return RegisterServer(p, Registration{Name: name, Entry: entry, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
|
||||
}},
|
||||
{Name: "claude_code_mcp_unregister",
|
||||
Description: "Remove an MCP server registered through this module, on this node or more.",
|
||||
Input: map[string]any{"name": str("the server's name"), "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
name, _ := a["name"].(string)
|
||||
return RegisterServer(p, Registration{Name: name, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// persist asks the state again until it answers: its bucket or the bus's grant may arrive after the module.
|
||||
func persist(what string, attempt func() error, done func(refusals int)) {
|
||||
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
||||
for n := 0; ; n++ {
|
||||
err := attempt()
|
||||
if err == nil {
|
||||
done(n)
|
||||
return
|
||||
}
|
||||
pause := time.Minute
|
||||
if n < len(waits) {
|
||||
pause = waits[n]
|
||||
}
|
||||
say("%s not yet (%v); asking again in %s", what, err, pause)
|
||||
time.Sleep(pause)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
p, launched := PathsFrom(os.Getenv)
|
||||
if !launched {
|
||||
// Outside a launch — a build, a check — it serves nothing and says why.
|
||||
say("not launched by the runtime with this module's words; serving no tools")
|
||||
if err := stdio.Serve("", nil); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := Keypair(p); err != nil {
|
||||
say("this module's key: %v", err)
|
||||
}
|
||||
if out, err := RenderNow(p, writeManaged); err != nil {
|
||||
say("%v", err)
|
||||
} else {
|
||||
for _, line := range out {
|
||||
if !strings.HasSuffix(line, "unchanged") {
|
||||
say("%s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
servers := stateOf{stdio.State("servers")}
|
||||
view := NewServerView(p)
|
||||
go run(p, view)
|
||||
if err := stdio.Serve("", tools(p, servers, view)); err != nil {
|
||||
say("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run is the module's long-running half, beside the tools (ADR 0198).
|
||||
func run(p Paths, view *ServerView) {
|
||||
// Every node's MCP servers: the whole current set first, then each change (ADR 0201).
|
||||
go persist("watching the MCP servers", func() error {
|
||||
return stdio.State("servers").Watch("", func(c stdio.StateChange) error {
|
||||
var value map[string]any
|
||||
_ = json.Unmarshal(c.Value, &value)
|
||||
if done, err := OnServerChange(view, ServerChange{Key: c.Key, Op: c.Op, Value: value}, p, writeManaged); err != nil {
|
||||
say("taking %s %s: %v", c.Op, c.Key, err) // the view took it; the next render writes it
|
||||
} else if done != "" {
|
||||
say("%s", done)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}, func(n int) { say("watching the MCP servers%s", refusals(n)) })
|
||||
|
||||
// What this node holds (ADR 0206): at start — a node already logged in is reported at once — and on
|
||||
// every change of the credentials file, polled, because the file is replaced by rename and a watch on
|
||||
// the old inode would go quiet. Fingerprints and expiries only.
|
||||
holdings := stdio.State("holdings")
|
||||
reported := ""
|
||||
report := func() {
|
||||
now := HoldingsOf(p)
|
||||
raw, _ := json.Marshal(now)
|
||||
if string(raw) == reported {
|
||||
return
|
||||
}
|
||||
persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) {
|
||||
reported = string(raw)
|
||||
account := "no account"
|
||||
if now.Identity != nil && now.Identity.EmailAddress != "" {
|
||||
account = now.Identity.EmailAddress
|
||||
}
|
||||
line := "reported: " + account
|
||||
if now.Kind != nil {
|
||||
line += ", " + *now.Kind
|
||||
}
|
||||
if now.Refresh.Present {
|
||||
line += ", a login waiting"
|
||||
}
|
||||
if now.Licence != nil {
|
||||
line += fmt.Sprintf(", licence %s g%d", *now.Licence, now.Generation)
|
||||
}
|
||||
say("%s", line)
|
||||
})
|
||||
}
|
||||
|
||||
// What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer
|
||||
// generation is fetched with the seat's `current`, sealed to this module's key.
|
||||
go persist("watching this node's licence binding", func() error {
|
||||
return stdio.State(Manager+".bindings").Watch(p.Node, func(c stdio.StateChange) error {
|
||||
if c.Key != p.Node {
|
||||
return nil
|
||||
}
|
||||
var b *BindingState
|
||||
if c.Op == "put" {
|
||||
b = &BindingState{}
|
||||
if err := json.Unmarshal(c.Value, b); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if done, err := OnBinding(p, b, ask, writeManaged); err != nil {
|
||||
say("fetching this node's token failed: %v", err)
|
||||
} else if done != "" {
|
||||
say("%s", done)
|
||||
}
|
||||
go report()
|
||||
return nil
|
||||
})
|
||||
}, func(n int) { say("watching this node's licence binding%s", refusals(n)) })
|
||||
|
||||
report()
|
||||
var last string
|
||||
for range time.Tick(5 * time.Second) {
|
||||
info, err := os.Stat(p.credentials())
|
||||
now := "absent"
|
||||
if err == nil {
|
||||
now = fmt.Sprintf("%d/%d", info.ModTime().UnixNano(), info.Size())
|
||||
}
|
||||
if now != last {
|
||||
last = now
|
||||
report()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refusals(n int) string {
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" (after %d refusal(s))", n)
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
package main
|
||||
|
||||
// What claude-code does on a node, written against what it is handed — a way to ask a tool on the bus, its
|
||||
// own state, a way to write a managed file — so every path is tested without a bus (novox/hq design 36,
|
||||
// ADR 0183, ADR 0201, ADR 0206).
|
||||
//
|
||||
// Over NATS, and nothing an event: what is current is state, and a secret only ever travels on a request,
|
||||
// sealed to its one recipient.
|
||||
// - What this node holds is the module's `holdings` state, one key per node: the account, the kind,
|
||||
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
|
||||
// file, so the licence manager learns a login, or a node already logged in, from the state alone.
|
||||
// - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives.
|
||||
// - What this node should hold is the manager's `bindings` state; a newer generation for this node is
|
||||
// fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only.
|
||||
// - An MCP server registered through this module is a key in its `servers` state — `all.<server>` for
|
||||
// every node, `<node>.<server>` for one — which every node watches.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Seat is the licence manager's role, and Manager the module whose `bindings` state this one reads.
|
||||
const (
|
||||
Seat = "anthropic-licence-manager"
|
||||
Manager = "claude-licence-manager"
|
||||
)
|
||||
|
||||
// SeatVerb is a seat's verb as the runtime addresses it: a role, not a module.
|
||||
func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb }
|
||||
|
||||
// Fingerprint names a token without being one: the first 16 hex of its SHA-256, as the manager computes it.
|
||||
func Fingerprint(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
// Paths are where this node's files are, from the module's words (ADR 0192).
|
||||
type Paths struct {
|
||||
State, Facts, Settings, Home, Node string
|
||||
}
|
||||
|
||||
// PathsFrom reads them, or answers false outside a launch.
|
||||
func PathsFrom(env func(string) string) (Paths, bool) {
|
||||
p := Paths{State: env("MESH_CLAUDE_CODE_STATE"), Facts: env("MESH_CLAUDE_CODE_FACTS"),
|
||||
Settings: env("MESH_CLAUDE_CODE_SETTINGS"), Home: env("MESH_OPERATOR_HOME"), Node: env("MESH_NODE")}
|
||||
return p, p.State != "" && p.Facts != "" && p.Settings != "" && p.Home != "" && p.Node != ""
|
||||
}
|
||||
|
||||
func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") }
|
||||
func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") }
|
||||
func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") }
|
||||
func (p Paths) apiKey() string { return filepath.Join(p.State, "api-key") }
|
||||
func (p Paths) helper() string { return filepath.Join(p.State, "api-key-helper") }
|
||||
func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") }
|
||||
|
||||
// Ask is a tool on the bus: its address and arguments in, its JSON answer out.
|
||||
type Ask func(address string, args any) (json.RawMessage, error)
|
||||
|
||||
// WriteManaged writes one managed file and answers what happened.
|
||||
type WriteManaged func(name, content string) (string, error)
|
||||
|
||||
func readJSON(path string, into any) bool {
|
||||
raw, err := os.ReadFile(path)
|
||||
return err == nil && json.Unmarshal(raw, into) == nil
|
||||
}
|
||||
|
||||
// Keypair is this module's own, made once in its state; the TypeScript module's files are kept, so a node
|
||||
// moving to this binary keeps the key it had.
|
||||
func Keypair(p Paths) (KeyPair, error) {
|
||||
priv, pub := filepath.Join(p.State, "key.pem"), filepath.Join(p.State, "key.pub.pem")
|
||||
if _, err := os.Stat(priv); errors.Is(err, os.ErrNotExist) {
|
||||
k, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
if err := os.WriteFile(priv, []byte(k.PrivateKey), 0o600); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
if err := os.WriteFile(pub, []byte(k.PublicKey), 0o644); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
}
|
||||
a, err1 := os.ReadFile(priv)
|
||||
b, err2 := os.ReadFile(pub)
|
||||
return KeyPair{PrivateKey: string(a), PublicKey: string(b)}, errors.Join(err1, err2)
|
||||
}
|
||||
|
||||
// Registered is what applies here of the servers registered through this module.
|
||||
func Registered(p Paths) Servers {
|
||||
s := Servers{}
|
||||
readJSON(p.registry(), &s)
|
||||
return s
|
||||
}
|
||||
|
||||
// RenderNow writes the managed directory from the facts, the settings, the licence held and the servers
|
||||
// registered here.
|
||||
func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
var facts Facts
|
||||
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
||||
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
||||
}
|
||||
var settings Settings
|
||||
readJSON(p.Settings, &settings)
|
||||
var binding *Binding
|
||||
var b Binding
|
||||
if readJSON(p.binding(), &b) {
|
||||
binding = &b
|
||||
}
|
||||
files := Render(facts, settings, binding, p.helper(), Registered(p))
|
||||
names := make([]string, 0, len(files))
|
||||
for n := range files {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var out []string
|
||||
for _, n := range names {
|
||||
line, err := write(n, files[n])
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- the licence ----------------------------------------------------------------------------------
|
||||
|
||||
// BindingState is what the manager's `bindings` state says one consumer should hold (ADR 0206).
|
||||
type BindingState struct {
|
||||
Licence string `json:"licence"`
|
||||
Kind string `json:"kind"`
|
||||
Generation int64 `json:"generation"`
|
||||
}
|
||||
|
||||
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
||||
type Current struct {
|
||||
Licence string `json:"licence"`
|
||||
Kind string `json:"kind"`
|
||||
Generation int64 `json:"generation"`
|
||||
Sealed *SealedBox `json:"sealed"`
|
||||
Identity *Identity `json:"identity"`
|
||||
}
|
||||
|
||||
// Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager
|
||||
// to tell a login it has not adopted from one it has, and never a token — fingerprints and expiries only.
|
||||
type Holdings struct {
|
||||
Node string `json:"node"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Kind *string `json:"kind"`
|
||||
Refresh struct {
|
||||
Present bool `json:"present"`
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
ExpiresAt *int64 `json:"expiresAt"`
|
||||
} `json:"refresh"`
|
||||
Access *struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
} `json:"access"`
|
||||
Licence *string `json:"licence"`
|
||||
Generation int64 `json:"generation"`
|
||||
ChangedAt *string `json:"changedAt"`
|
||||
}
|
||||
|
||||
// HoldingsOf is what this node holds now.
|
||||
func HoldingsOf(p Paths) Holdings {
|
||||
h := Holdings{Node: p.Node, Identity: ReadIdentity(p.account())}
|
||||
creds := ReadCredentials(p.credentials())
|
||||
if info, err := os.Stat(p.credentials()); err == nil {
|
||||
at := info.ModTime().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
h.ChangedAt = &at
|
||||
}
|
||||
if rt := RefreshTokenOf(creds); rt != "" {
|
||||
fp := Fingerprint(rt)
|
||||
h.Refresh.Present, h.Refresh.Fingerprint = true, &fp
|
||||
}
|
||||
if v, ok := number(creds.oauth()["refreshTokenExpiresAt"]); ok {
|
||||
h.Refresh.ExpiresAt = &v
|
||||
}
|
||||
if g := GrantOf(creds); g != nil {
|
||||
h.Access = &struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}{Fingerprint(g.AccessToken), g.ExpiresAt}
|
||||
}
|
||||
kind := ""
|
||||
if _, err := os.Stat(p.apiKey()); err == nil {
|
||||
kind = "api-key"
|
||||
} else if h.Access != nil {
|
||||
kind = "subscription"
|
||||
}
|
||||
if kind != "" {
|
||||
h.Kind = &kind
|
||||
}
|
||||
var applied Binding
|
||||
if readJSON(p.binding(), &applied) && applied.Licence != "" {
|
||||
h.Licence, h.Generation = &applied.Licence, applied.Generation
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// GrantAnswer is what `claude_code_grant` answers: a login sealed to the key given, or nothing waiting.
|
||||
type GrantAnswer struct {
|
||||
Sealed *SealedBox `json:"sealed,omitempty"`
|
||||
Identity *Identity `json:"identity,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Waiting *bool `json:"waiting,omitempty"`
|
||||
}
|
||||
|
||||
// GrantFor is the full grant in the credentials file sealed to the manager's key — the one time a refresh
|
||||
// token leaves this node, for the manager to adopt by refreshing it (ADR 0206). Nothing waiting when the
|
||||
// file holds no refresh token.
|
||||
func GrantFor(p Paths, managerPublicKey string) (GrantAnswer, error) {
|
||||
creds := ReadCredentials(p.credentials())
|
||||
rt := RefreshTokenOf(creds)
|
||||
if rt == "" {
|
||||
no := false
|
||||
return GrantAnswer{Waiting: &no}, nil
|
||||
}
|
||||
raw, err := json.Marshal(creds.oauth())
|
||||
if err != nil {
|
||||
return GrantAnswer{}, err
|
||||
}
|
||||
box, err := Seal(string(raw), managerPublicKey)
|
||||
if err != nil {
|
||||
return GrantAnswer{}, err
|
||||
}
|
||||
return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil
|
||||
}
|
||||
|
||||
// Pull asks the seat for this node's current token and applies it.
|
||||
func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
|
||||
keys, err := Keypair(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node, "public_key": keys.PublicKey})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c Current
|
||||
if err := json.Unmarshal(raw, &c); err != nil || c.Sealed == nil {
|
||||
return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil
|
||||
}
|
||||
return Apply(p, c, write)
|
||||
}
|
||||
|
||||
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
|
||||
// fetched when the generation is newer than the one applied. A released binding keeps the last token,
|
||||
// which lives hours, and says so.
|
||||
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
|
||||
if b == nil {
|
||||
return "this node's binding was released; it keeps its last token until it expires", nil
|
||||
}
|
||||
var applied Binding
|
||||
if readJSON(p.binding(), &applied) && applied.Generation >= b.Generation {
|
||||
return "", nil
|
||||
}
|
||||
out, err := Pull(p, ask, write)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// Apply applies what the seat handed over. A switch replaces the grant whole and cleans up after the old
|
||||
// licence; whatever it is, the file is written without a refresh token, so the agent here never refreshes.
|
||||
func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
||||
keys, err := Keypair(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain, err := Open(*c.Sealed, keys.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var previous Binding
|
||||
had := readJSON(p.binding(), &previous)
|
||||
switched := !had || previous.Licence != c.Licence
|
||||
out := map[string]any{"applied": true, "licence": c.Licence, "kind": c.Kind, "switched": switched}
|
||||
if c.Kind == "api-key" {
|
||||
if err := os.WriteFile(p.apiKey(), []byte(strings.TrimSpace(plain)+"\n"), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(p.helper(), []byte("#!/bin/sh\nexec cat '"+p.apiKey()+"'\n"), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.Chmod(p.helper(), 0o700)
|
||||
} else {
|
||||
var g Grant
|
||||
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
local := ReadCredentials(p.credentials())
|
||||
// A login waiting here was handed to the manager first (ADR 0206): what comes back is its successor,
|
||||
// and the refresh token in the file is the one the manager just spent.
|
||||
d := DecideApply(GrantOf(local), g, switched || HoldsLogin(local))
|
||||
if d.Apply {
|
||||
next := WithGrant(local, g)
|
||||
if switched {
|
||||
next = ReplacedBy(local, g)
|
||||
}
|
||||
if err := WriteCredentials(p.credentials(), next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
out = map[string]any{"applied": false, "licence": c.Licence, "reason": d.Reason}
|
||||
}
|
||||
// Away from the API key: it goes, with its helper.
|
||||
_ = os.Remove(p.apiKey())
|
||||
_ = os.Remove(p.helper())
|
||||
}
|
||||
if switched && c.Identity != nil && c.Identity.AccountUUID != "" {
|
||||
changed, err := WriteIdentity(p.account(), *c.Identity)
|
||||
if err == nil {
|
||||
out["account"] = map[bool]string{true: "updated", false: "unchanged"}[changed]
|
||||
}
|
||||
}
|
||||
gen := c.Generation
|
||||
if gen == 0 {
|
||||
gen = previous.Generation
|
||||
}
|
||||
raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen})
|
||||
if err := os.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The key-helper comes or goes with the licence's kind.
|
||||
if rendered, err := RenderNow(p, write); err != nil {
|
||||
out["rendered"] = map[string]any{"failed": err.Error()}
|
||||
} else {
|
||||
out["rendered"] = rendered
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- MCP servers ----------------------------------------------------------------------------------
|
||||
|
||||
// Registration is a server registered (or, with no entry, unregistered) through this module.
|
||||
type Registration struct {
|
||||
Name string
|
||||
Entry map[string]any
|
||||
// Nodes: nil for this node, ["all"] for every node running the module, or a list.
|
||||
Nodes []string
|
||||
}
|
||||
|
||||
// ServerState is the `servers` state as this module reaches it through the runtime.
|
||||
type ServerState interface {
|
||||
Put(key string, value any) error
|
||||
Delete(key string) error
|
||||
Keys() ([]string, error)
|
||||
}
|
||||
|
||||
// ServerChange is one change to the `servers` state, as a watch hands it over.
|
||||
type ServerChange struct {
|
||||
Key string
|
||||
Op string // put | delete
|
||||
Value map[string]any
|
||||
}
|
||||
|
||||
// KeyOf is the key a registration lives at: `all.<server>` for every node, `<node>.<server>` for one.
|
||||
func KeyOf(scope, name string) string { return scope + "." + name }
|
||||
|
||||
// ServerView is what this node takes from the `servers` state: the entries for every node and for this
|
||||
// one, kept in memory from the watch and written through to the module's own file whenever what applies
|
||||
// here changes, so the managed directory renders without the bus.
|
||||
type ServerView struct {
|
||||
p Paths
|
||||
mu sync.Mutex
|
||||
entries map[string]map[string]any
|
||||
}
|
||||
|
||||
// NewServerView is an empty view for this node.
|
||||
func NewServerView(p Paths) *ServerView {
|
||||
return &ServerView{p: p, entries: map[string]map[string]any{}}
|
||||
}
|
||||
|
||||
// Take takes one change, and answers whether what applies to this node changed.
|
||||
func (v *ServerView) Take(c ServerChange) bool {
|
||||
scope, name, ok := strings.Cut(c.Key, ".")
|
||||
if !ok || scope == "" || (scope != "all" && scope != v.p.Node) {
|
||||
return false
|
||||
}
|
||||
v.mu.Lock()
|
||||
if c.Op == "put" && c.Value != nil && EntryProblem(name, c.Value) == "" {
|
||||
v.entries[c.Key] = c.Value
|
||||
} else {
|
||||
delete(v.entries, c.Key)
|
||||
}
|
||||
v.mu.Unlock()
|
||||
return v.writeThrough()
|
||||
}
|
||||
|
||||
// Effective is what applies here: every node's entries, with this node's own laid over them by name.
|
||||
func (v *ServerView) Effective() Servers {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
out := Servers{}
|
||||
for _, scope := range []string{"all", v.p.Node} {
|
||||
for key, entry := range v.entries {
|
||||
if name, ok := strings.CutPrefix(key, scope+"."); ok {
|
||||
out[name] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (v *ServerView) writeThrough() bool {
|
||||
now, _ := indented(v.Effective())
|
||||
before, _ := os.ReadFile(v.p.registry())
|
||||
if string(before) == string(now) {
|
||||
return false
|
||||
}
|
||||
_ = os.WriteFile(v.p.registry(), now, 0o600)
|
||||
return true
|
||||
}
|
||||
|
||||
// OnServerChange takes a change from the watch, and renders when what applies here changed.
|
||||
func OnServerChange(v *ServerView, c ServerChange, p Paths, write WriteManaged) (string, error) {
|
||||
if !v.Take(c) {
|
||||
return "", nil
|
||||
}
|
||||
if _, err := RenderNow(p, write); err != nil {
|
||||
return "", err
|
||||
}
|
||||
what := "registered"
|
||||
if c.Op != "put" {
|
||||
what = "unregistered"
|
||||
}
|
||||
return what + " " + c.Key, nil
|
||||
}
|
||||
|
||||
// RegisterServer registers (or, with no entry, unregisters) a server: a put (or delete) per scope in the
|
||||
// `servers` state, taken into this node's view at once so the answer says what it did here; every other
|
||||
// node takes it from its watch, and a node that joins later from the current state.
|
||||
func RegisterServer(p Paths, r Registration, servers ServerState, v *ServerView, write WriteManaged,
|
||||
others func() ([]string, error)) (map[string]any, error) {
|
||||
if r.Entry != nil {
|
||||
if problem := EntryProblem(r.Name, r.Entry); problem != "" {
|
||||
return map[string]any{"registered": false, "reason": problem}, nil
|
||||
}
|
||||
}
|
||||
scopes := r.Nodes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{p.Node}
|
||||
}
|
||||
// Compared before and after rather than read from Take: this node's own watch may hand the view the
|
||||
// same change first, and then Take here finds nothing new although this call made it.
|
||||
before, _ := json.Marshal(v.Effective())
|
||||
for _, scope := range scopes {
|
||||
key := KeyOf(scope, r.Name)
|
||||
var err error
|
||||
if r.Entry != nil {
|
||||
err = servers.Put(key, r.Entry)
|
||||
} else {
|
||||
err = servers.Delete(key)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
op := "put"
|
||||
if r.Entry == nil {
|
||||
op = "delete"
|
||||
}
|
||||
v.Take(ServerChange{Key: key, Op: op, Value: r.Entry})
|
||||
}
|
||||
after, _ := json.Marshal(v.Effective())
|
||||
changed := string(before) != string(after)
|
||||
here := false
|
||||
for _, s := range scopes {
|
||||
here = here || s == "all" || s == p.Node
|
||||
}
|
||||
verb := "registered"
|
||||
if r.Entry == nil {
|
||||
verb = "unregistered"
|
||||
}
|
||||
answer := map[string]any{verb: r.Name, "on": scopes}
|
||||
switch {
|
||||
case !here:
|
||||
answer["here"] = "not this node"
|
||||
case changed:
|
||||
answer["here"] = "changed"
|
||||
default:
|
||||
answer["here"] = "already so"
|
||||
}
|
||||
if changed {
|
||||
rendered, err := RenderNow(p, write)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer["rendered"] = rendered
|
||||
}
|
||||
if r.Entry == nil {
|
||||
if _, still := v.Effective()[r.Name]; still {
|
||||
answer["still"] = r.Name + " still applies here from another registration (for every node, or for this one); unregister that too"
|
||||
}
|
||||
}
|
||||
if len(r.Nodes) == 0 {
|
||||
// The question the operator wanted asked: here only, or more?
|
||||
var elsewhere []string
|
||||
if nodes, err := others(); err == nil {
|
||||
for _, n := range nodes {
|
||||
if n != p.Node {
|
||||
elsewhere = append(elsewhere, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(elsewhere) > 0 {
|
||||
answer["also"] = fmt.Sprintf("claude-code also runs on %s. To %s it there too, call again with nodes: \"all\" or a list of those nodes.",
|
||||
strings.Join(elsewhere, ", "), map[bool]string{true: "register", false: "unregister"}[r.Entry != nil])
|
||||
} else {
|
||||
answer["also"] = "To do the same on every node running claude-code, call again with nodes: \"all\"."
|
||||
}
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// stamp is a time as the status answers it.
|
||||
func stamp(ms int64) string { return time.UnixMilli(ms).UTC().Format(time.RFC3339) }
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
// What the module writes into the agent's machine-wide managed directory (novox/hq design 36 §1–§4).
|
||||
// Pure: composed from the facts the mesh rendered, the settings the operator set and the licence the node
|
||||
// holds, so what lands under /etc is tested without a machine.
|
||||
//
|
||||
// Three files, owned whole by this module:
|
||||
//
|
||||
// managed-mcp.json the tool servers every session loads: the mesh's console as `mesh`, and the
|
||||
// servers the operator declared or registered through this module. Exclusive by
|
||||
// the vendor's rule — a server not listed here does not load (operator's choice,
|
||||
// 2026-10-03).
|
||||
// managed-settings.json the mesh's keys only: the repositories' attribution convention, the claude.ai
|
||||
// connectors kept beside the managed servers, and — for an API-key licence only —
|
||||
// the key-helper. A person's preferences are theirs.
|
||||
// CLAUDE.md how a session on this mesh works, who this node is, the conventions.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ManagedDir is the agent's machine-wide managed directory.
|
||||
const ManagedDir = "/etc/claude-code"
|
||||
|
||||
const meshEntry = "mesh"
|
||||
|
||||
// Facts are what the mesh rendered for this node.
|
||||
type Facts struct {
|
||||
Node string `json:"node"`
|
||||
Console string `json:"console"`
|
||||
}
|
||||
|
||||
// Settings are the operator's, for the mesh or this node.
|
||||
type Settings struct {
|
||||
Role string `json:"role"`
|
||||
MCPServers map[string]map[string]any `json:"mcp_servers"`
|
||||
}
|
||||
|
||||
// Binding is the licence this node holds, as it was last applied.
|
||||
type Binding struct {
|
||||
Licence string `json:"licence"`
|
||||
Kind string `json:"kind"` // subscription | api-key
|
||||
Generation int64 `json:"generation,omitempty"`
|
||||
}
|
||||
|
||||
// Servers are tool server entries by name, in the vendor's `.mcp.json` shape.
|
||||
type Servers map[string]map[string]any
|
||||
|
||||
var serverName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// EntryProblem says why the vendor's managed file would not take an entry, or "" when it would: a name of
|
||||
// letters, digits, `-` and `_`, and an http/sse server with a url or a stdio server with a command.
|
||||
func EntryProblem(name string, entry map[string]any) string {
|
||||
if !serverName.MatchString(name) {
|
||||
return fmt.Sprintf("%q is not a name the agent takes: letters, digits, - and _", name)
|
||||
}
|
||||
if name == meshEntry {
|
||||
return fmt.Sprintf("%q is the mesh's own entry", meshEntry)
|
||||
}
|
||||
kind, _ := entry["type"].(string)
|
||||
if kind == "" {
|
||||
kind = "stdio"
|
||||
}
|
||||
switch kind {
|
||||
case "http", "sse", "streamable-http":
|
||||
if u, _ := entry["url"].(string); u != "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("an %s server needs a url", kind)
|
||||
case "stdio":
|
||||
if c, _ := entry["command"].(string); c != "" {
|
||||
return ""
|
||||
}
|
||||
return "a stdio server needs a command"
|
||||
}
|
||||
return fmt.Sprintf("%q is not a server type the agent knows (http, sse, stdio)", kind)
|
||||
}
|
||||
|
||||
// jsonFile is a value as the managed files are written: two-space indent, a trailing newline, nothing
|
||||
// escaped that need not be.
|
||||
func jsonFile(v any) string {
|
||||
var b bytes.Buffer
|
||||
enc := json.NewEncoder(&b)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Render composes the three files. registered — what was registered through this module and applies
|
||||
// here — is laid over the servers the operator set in its settings.
|
||||
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers) map[string]string {
|
||||
servers := map[string]any{}
|
||||
for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} {
|
||||
for name, entry := range layer {
|
||||
if EntryProblem(name, entry) != "" {
|
||||
continue // the mesh's own entry, or one the agent would refuse
|
||||
}
|
||||
servers[name] = entry
|
||||
}
|
||||
}
|
||||
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
|
||||
|
||||
managed := map[string]any{"attribution": map[string]any{"commit": "", "pr": ""}, "allowAllClaudeAiMcps": true}
|
||||
if binding != nil && binding.Kind == "api-key" {
|
||||
managed["apiKeyHelper"] = helperPath
|
||||
}
|
||||
role := strings.TrimSpace(settings.Role)
|
||||
if role == "" {
|
||||
role = "not stated — set it in this module's settings for the node"
|
||||
}
|
||||
return map[string]string{
|
||||
"managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}),
|
||||
"managed-settings.json": jsonFile(managed),
|
||||
"CLAUDE.md": instructionsText(facts.Node, role),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
// Sealing to one recipient (novox/hq ADR 0183, ADR 0206): the manager seals what it hands a consumer to
|
||||
// the key that consumer sent, and a node seals a waiting login to the key the manager gives. The same box
|
||||
// the agent module's TypeScript makes and opens, byte for byte — X25519 for the agreement, HKDF-SHA256 for
|
||||
// the key, AES-256-GCM for the box — so `testdata/sealed-by-typescript.json` is opened here, and a test
|
||||
// reopens what this seals with the same derivation.
|
||||
//
|
||||
// A box is `{ v: 1, eph, iv, tag, ct }`, every field base64; `eph` is the one-time public key as SPKI DER,
|
||||
// and the key is bound to it and to the recipient's raw public key, so a box cannot be re-addressed.
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SealedBox is a value sealed to one recipient.
|
||||
type SealedBox struct {
|
||||
V int `json:"v"`
|
||||
Eph string `json:"eph"`
|
||||
IV string `json:"iv"`
|
||||
Tag string `json:"tag"`
|
||||
Ct string `json:"ct"`
|
||||
}
|
||||
|
||||
// KeyPair is a recipient's keypair as the two PEM strings it is kept and sent as.
|
||||
type KeyPair struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
PrivateKey string `json:"privateKey"`
|
||||
}
|
||||
|
||||
const sealInfo = "novox-mesh sealed box v1"
|
||||
|
||||
// GenerateKeyPair makes an X25519 keypair, PEM-encoded as the agent module's are.
|
||||
func GenerateKeyPair() (KeyPair, error) {
|
||||
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(priv.PublicKey())
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
return KeyPair{
|
||||
PublicKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})),
|
||||
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func publicFromPEM(p string) (*ecdh.PublicKey, error) {
|
||||
block, _ := pem.Decode([]byte(p))
|
||||
if block == nil {
|
||||
return nil, errors.New("not a PEM public key")
|
||||
}
|
||||
k, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub, ok := k.(*ecdh.PublicKey)
|
||||
if !ok || pub.Curve() != ecdh.X25519() {
|
||||
return nil, errors.New("not an X25519 public key")
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
func privateFromPEM(p string) (*ecdh.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(p))
|
||||
if block == nil {
|
||||
return nil, errors.New("not a PEM private key")
|
||||
}
|
||||
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priv, ok := k.(*ecdh.PrivateKey)
|
||||
if !ok || priv.Curve() != ecdh.X25519() {
|
||||
return nil, errors.New("not an X25519 private key")
|
||||
}
|
||||
return priv, nil
|
||||
}
|
||||
|
||||
func boxKey(secret, ephDER, recipientRaw []byte) ([]byte, error) {
|
||||
salt := append(append([]byte{}, ephDER...), recipientRaw...)
|
||||
return hkdf.Key(sha256.New, secret, salt, sealInfo, 32)
|
||||
}
|
||||
|
||||
// Seal seals plaintext to the recipient's public key.
|
||||
func Seal(plaintext, recipientPEM string) (SealedBox, error) {
|
||||
recipient, err := publicFromPEM(recipientPEM)
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
secret, err := eph.ECDH(recipient)
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
ephDER, err := x509.MarshalPKIXPublicKey(eph.PublicKey())
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
key, err := boxKey(secret, ephDER, recipient.Bytes())
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
iv := make([]byte, 12)
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
out := gcm.Seal(nil, iv, []byte(plaintext), nil)
|
||||
ct, tag := out[:len(out)-gcm.Overhead()], out[len(out)-gcm.Overhead():]
|
||||
b64 := base64.StdEncoding.EncodeToString
|
||||
return SealedBox{V: 1, Eph: b64(ephDER), IV: b64(iv), Tag: b64(tag), Ct: b64(ct)}, nil
|
||||
}
|
||||
|
||||
// Open opens a box with the recipient's private key; it fails for a box to another key or one tampered with.
|
||||
func Open(box SealedBox, privatePEM string) (string, error) {
|
||||
if box.V != 1 {
|
||||
return "", errors.New("not a sealed box this module can open")
|
||||
}
|
||||
priv, err := privateFromPEM(privatePEM)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d := base64.StdEncoding.DecodeString
|
||||
ephDER, err := d(box.Eph)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the box's eph: %w", err)
|
||||
}
|
||||
ephKey, err := x509.ParsePKIXPublicKey(ephDER)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
eph, ok := ephKey.(*ecdh.PublicKey)
|
||||
if !ok {
|
||||
return "", errors.New("the box's eph is not an X25519 key")
|
||||
}
|
||||
secret, err := priv.ECDH(eph)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key, err := boxKey(secret, ephDER, priv.PublicKey().Bytes())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iv, err1 := d(box.IV)
|
||||
tag, err2 := d(box.Tag)
|
||||
ct, err3 := d(box.Ct)
|
||||
if err := errors.Join(err1, err2, err3); err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain, err := gcm.Open(nil, iv, append(ct, tag...), nil)
|
||||
if err != nil {
|
||||
return "", errors.New("the box does not open with this key")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"facts": {
|
||||
"node": "workstation",
|
||||
"console": "http://127.0.0.1:4270/mcp"
|
||||
},
|
||||
"settings": {
|
||||
"role": "the laptop",
|
||||
"mcp_servers": {
|
||||
"search": {
|
||||
"type": "http",
|
||||
"url": "https://s.example/mcp"
|
||||
},
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": [
|
||||
"--x"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"registered": {
|
||||
"anton": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"/a/b.js"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
},
|
||||
"withKey": {
|
||||
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
|
||||
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
},
|
||||
"plain": {
|
||||
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
|
||||
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module claude-code
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"module": "claude-code",
|
||||
"version": "1",
|
||||
"slug": "agent",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"mcp-endpoint"
|
||||
],
|
||||
"binds": {
|
||||
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
|
||||
},
|
||||
"state": [
|
||||
"servers",
|
||||
"holdings"
|
||||
],
|
||||
"reads": [
|
||||
"claude-licence-manager.bindings"
|
||||
],
|
||||
"tools": [
|
||||
"claude_code_status",
|
||||
"claude_code_render",
|
||||
"claude_code_pull",
|
||||
"claude_code_grant",
|
||||
"claude_code_mcp_list",
|
||||
"claude_code_mcp_register",
|
||||
"claude_code_mcp_unregister"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "claude-code"
|
||||
},
|
||||
{
|
||||
"id": "managed",
|
||||
"type": "directory",
|
||||
"path": "/etc/claude-code",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "agent-home",
|
||||
"type": "directory",
|
||||
"path": "${machine:account-home}/.claude",
|
||||
"mode": "0700",
|
||||
"owner": "${machine:account}"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "${machine:account}",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "facts",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/facts.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"content": "{\n \"node\": \"${machine:name}\",\n \"console\": \"http://127.0.0.1:${bound:mcp-endpoint:port}/mcp\"\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"merge": "json",
|
||||
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {}\n}\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/claude-code",
|
||||
"binary": "claude-code",
|
||||
"loads": [
|
||||
"claude-code"
|
||||
],
|
||||
"env": {
|
||||
"MESH_CLAUDE_CODE_STATE": "${dir:state}",
|
||||
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
|
||||
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# claude-licence-manager
|
||||
|
||||
Holds the `anthropic-licence-manager` seat: every Anthropic licence the mesh has, kept alive by one
|
||||
rotation source, and handed to each consumer sealed (novox/hq ADR 0183, ADR 0206, design 39).
|
||||
|
||||
## How a licence comes to exist
|
||||
|
||||
Nothing is configured. Every node running `claude-code` reports what it holds as that module's
|
||||
`holdings` state — the account, fingerprints and expiries, never a token. This module reads every report
|
||||
when it starts and watches them:
|
||||
|
||||
1. A report with a refresh token it does not hold is a **candidate**.
|
||||
2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it.
|
||||
3. **It refreshes it.** If the vendor exchanges the token, the grant is this module's — encrypted at rest
|
||||
with the key the vault made for it — and from then on it is the only refresher. If not, the candidate
|
||||
is recorded dead and nothing is adopted.
|
||||
4. Several nodes logged in to one account: newest login first; the rest are never exchanged.
|
||||
5. A node reporting that account and bound to nothing is bound to it.
|
||||
|
||||
Each node is then handed an access token only, so the agent there never refreshes, and a refresh token
|
||||
appearing on a node later can only be a person's login — which wins if it refreshes.
|
||||
|
||||
An API key enters through `adopt`, from a file on this module's node.
|
||||
|
||||
## What each consumer holds
|
||||
|
||||
This module's `bindings` state: one key per consumer (a node's name) with the licence, its kind and a
|
||||
generation that grows with every rotation and switch. `claude-code` watches its own key and, on a newer
|
||||
generation, asks `current` with its public key.
|
||||
|
||||
## The seat's verbs
|
||||
|
||||
`licences`, `bindings`, `bind`, `switch`, `release`, `refresh`, `usage`, `adopt`, `current` — through
|
||||
the console as `anthropic-licence-manager.<verb>`. No answer carries a token.
|
||||
|
||||
## Settings
|
||||
|
||||
`settings.json` in the state directory: `cadence_minutes` (240), `floor_minutes` (60),
|
||||
`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3).
|
||||
|
||||
## Events
|
||||
|
||||
`licence.adopted`, `licence.refused`, `licence.failing`, `usage.read` — none carries a secret.
|
||||
|
||||
## Code and tests
|
||||
|
||||
Go, one binary (`cmd/claude-licence-manager`): the seat's verbs and the daemon in one launched bundle,
|
||||
`prepare` as the run-once preparation step. The sealed box is `claude-code`'s own format, byte for byte —
|
||||
the two modules carry the same `seal.go` — and a test opens one sealed by the TypeScript agent module the
|
||||
Go one replaced, so the format is the one already on the machines.
|
||||
|
||||
go test ./...
|
||||
# the store against a real postgres:
|
||||
docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
|
||||
MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
// The grants at rest (novox/hq ADR 0183): encrypted with a key the vault made for this module, so the
|
||||
// store holds ciphertext and only this module, reading its own secret, can open a row. AES-256-GCM, the
|
||||
// key derived from the vault's secret by SHA-256 so a secret of any length serves.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Crypt seals and opens what the store keeps.
|
||||
type Crypt struct{ key []byte }
|
||||
|
||||
// NewCrypt is the store's cipher from the vault's secret.
|
||||
func NewCrypt(secret string) (*Crypt, error) {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" {
|
||||
return nil, errors.New("the key the grants are encrypted with is empty: the vault has not delivered it yet")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return &Crypt{key: sum[:]}, nil
|
||||
}
|
||||
|
||||
// CryptFromFile reads the vault's secret from the file the mesh delivered it to.
|
||||
func CryptFromFile(path string) (*Crypt, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewCrypt(string(raw))
|
||||
}
|
||||
|
||||
// Seal encrypts a plaintext as `v1.<iv>.<ciphertext and tag>`.
|
||||
func (c *Crypt) Seal(plaintext string) string {
|
||||
gcm, _ := newGCM(c.key)
|
||||
iv := make([]byte, 12)
|
||||
_, _ = rand.Read(iv)
|
||||
b64 := base64.StdEncoding.EncodeToString
|
||||
return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil))
|
||||
}
|
||||
|
||||
// Open decrypts what Seal made with the same key.
|
||||
func (c *Crypt) Open(sealed string) (string, error) {
|
||||
parts := strings.Split(sealed, ".")
|
||||
if len(parts) != 3 || parts[0] != "v1" {
|
||||
return "", errors.New("not a grant this module sealed")
|
||||
}
|
||||
iv, err1 := base64.StdEncoding.DecodeString(parts[1])
|
||||
ct, err2 := base64.StdEncoding.DecodeString(parts[2])
|
||||
if err := errors.Join(err1, err2); err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, _ := newGCM(c.key)
|
||||
plain, err := gcm.Open(nil, iv, ct, nil)
|
||||
if err != nil {
|
||||
return "", errors.New("the grant does not open with this module's key")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by the control
|
||||
// node's runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). It serves the
|
||||
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports
|
||||
// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage.
|
||||
//
|
||||
// `claude-licence-manager prepare` is the preparation step (ADR 0135): the host runs it once before the
|
||||
// version that needs it, with the module's words and no bus, and it brings the store's schema to shape.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds.
|
||||
const Seat = "anthropic-licence-manager"
|
||||
|
||||
func say(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "[claude-licence-manager] "+format+"\n", args...)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "prepare" {
|
||||
if err := prepare(); err != nil {
|
||||
say("preparing the store failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
say("the store's schema is what this version needs")
|
||||
return
|
||||
}
|
||||
go daemon()
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
say("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func prepare() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
store, err := PgStoreFromEnv(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
return store.Migrate(ctx)
|
||||
}
|
||||
|
||||
// ---- what the binary is handed -----------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
built *Manager
|
||||
buildMu sync.Mutex
|
||||
)
|
||||
|
||||
// manager builds the rules' dependencies once, from the module's words (ADR 0192): file paths, never
|
||||
// values. A failure is said and tried again on the next call, so a database that arrives late is not fatal.
|
||||
func manager() (*Manager, error) {
|
||||
buildMu.Lock()
|
||||
defer buildMu.Unlock()
|
||||
if built != nil {
|
||||
return built, nil
|
||||
}
|
||||
dir, keyFile := os.Getenv("MESH_LICENCE_STATE"), os.Getenv("MESH_LICENCE_KEY_FILE")
|
||||
if dir == "" || keyFile == "" {
|
||||
return nil, errors.New("MESH_LICENCE_STATE and MESH_LICENCE_KEY_FILE are not set: the mesh renders them for this module")
|
||||
}
|
||||
crypt, err := CryptFromFile(keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys, err := keypair(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, err := PgStoreFromEnv(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node, _ := os.Hostname()
|
||||
if n := os.Getenv("MESH_NODE"); n != "" {
|
||||
node = n
|
||||
}
|
||||
bindings := stdio.State("bindings")
|
||||
built = &Manager{
|
||||
Store: store,
|
||||
Vendor: LiveVendor(),
|
||||
Crypt: crypt,
|
||||
Keys: keys,
|
||||
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
||||
var a GrantAnswer
|
||||
raw, err := stdio.Ask("claude-code.claude_code_grant@"+node, map[string]any{"public_key": publicKey})
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
return a, json.Unmarshal(raw, &a)
|
||||
},
|
||||
PutBinding: func(_ context.Context, consumer string, b BindingState) error {
|
||||
_, err := bindings.Put(consumer, b)
|
||||
return err
|
||||
},
|
||||
DeleteBinding: func(_ context.Context, consumer string) error { return bindings.Delete(consumer) },
|
||||
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
|
||||
Now: time.Now,
|
||||
Log: say,
|
||||
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
|
||||
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
|
||||
}
|
||||
return built, nil
|
||||
}
|
||||
|
||||
// keypair is this module's own, made once in its state directory; the private half never leaves it.
|
||||
// Written whole, then linked into place, so a second process reads the first's key and never half of it.
|
||||
func keypair(dir string) (KeyPair, error) {
|
||||
file := filepath.Join(dir, "manager-key.json")
|
||||
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
|
||||
k, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
raw, _ := json.Marshal(k)
|
||||
tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid()))
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
_ = os.Link(tmp, file) // fails when another made it first, which is right
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
var k KeyPair
|
||||
return k, json.Unmarshal(raw, &k)
|
||||
}
|
||||
|
||||
// ---- the long-running half ---------------------------------------------------------------------------
|
||||
|
||||
func daemon() {
|
||||
var mu sync.Mutex
|
||||
reports := map[string]Holdings{}
|
||||
var passing sync.Mutex
|
||||
pass := func() {
|
||||
passing.Lock() // one pass at a time: each account is leased, and a pass is cheap
|
||||
defer passing.Unlock()
|
||||
m, err := manager()
|
||||
if err != nil {
|
||||
say("not ready: %v", err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
all := make([]Holdings, 0, len(reports))
|
||||
for _, r := range reports {
|
||||
all = append(all, r)
|
||||
}
|
||||
mu.Unlock()
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node })
|
||||
adopted, err := m.Consider(context.Background(), all)
|
||||
if err != nil {
|
||||
say("considering the reports failed: %v", err)
|
||||
}
|
||||
if len(adopted) > 0 {
|
||||
say("adopted %s", strings.Join(adopted, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// What every node holds (ADR 0206): the whole current set, then each change. Asked again until it
|
||||
// answers — the channel to the runtime opens as the bundle starts, and the agent module's state may
|
||||
// arrive after this one.
|
||||
go func() {
|
||||
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
||||
for attempt := 0; ; attempt++ {
|
||||
err := stdio.State("claude-code.holdings").Watch("", func(c stdio.StateChange) error {
|
||||
mu.Lock()
|
||||
if c.Op == "put" {
|
||||
var h Holdings
|
||||
if json.Unmarshal(c.Value, &h) == nil {
|
||||
reports[c.Key] = h
|
||||
}
|
||||
} else {
|
||||
delete(reports, c.Key)
|
||||
}
|
||||
mu.Unlock()
|
||||
// During the current values the pass waits for the whole set: newest login first needs all.
|
||||
if !c.Current {
|
||||
go pass()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
mu.Lock()
|
||||
n := len(reports)
|
||||
mu.Unlock()
|
||||
say("watching what %d node(s) hold", n)
|
||||
pass()
|
||||
return
|
||||
}
|
||||
pause := time.Minute
|
||||
if attempt < len(waits) {
|
||||
pause = waits[attempt]
|
||||
}
|
||||
say("what the nodes hold cannot be watched yet (%v); asking again in %s", err, pause)
|
||||
time.Sleep(pause)
|
||||
}
|
||||
}()
|
||||
|
||||
refresh := time.NewTicker(time.Minute)
|
||||
usage := time.NewTicker(5 * time.Minute)
|
||||
for {
|
||||
select {
|
||||
case <-refresh.C:
|
||||
if m, err := manager(); err == nil {
|
||||
out, err := m.RotateDue(context.Background())
|
||||
for _, r := range out {
|
||||
b, _ := json.Marshal(r)
|
||||
say("%s", b)
|
||||
}
|
||||
if err != nil {
|
||||
say("refreshing failed: %v", err)
|
||||
}
|
||||
}
|
||||
pass() // a login whose node did not answer last time is asked again
|
||||
case <-usage.C:
|
||||
if m, err := manager(); err == nil {
|
||||
if err := m.ReadUsage(context.Background()); err != nil {
|
||||
say("reading usage failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the seat's verbs --------------------------------------------------------------------------------
|
||||
|
||||
func text(a map[string]any, k string) (string, error) {
|
||||
v, _ := a[k].(string)
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return "", fmt.Errorf("%s is required", k)
|
||||
}
|
||||
return strings.TrimSpace(v), nil
|
||||
}
|
||||
|
||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
|
||||
func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
|
||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
m, err := manager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return run(context.Background(), m, a)
|
||||
}}
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func two(a map[string]any, k1, k2 string) (string, string, error) {
|
||||
v1, err1 := text(a, k1)
|
||||
v2, err2 := text(a, k2)
|
||||
return v1, v2, errors.Join(err1, err2)
|
||||
}
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
consumer := str("the node's name")
|
||||
return []stdio.Tool{
|
||||
verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it. Never a token.",
|
||||
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Licences(ctx) }),
|
||||
verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given.",
|
||||
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Store.Bindings(ctx) }),
|
||||
verb("bind", "Bind a consumer — a node's agent, by the node's name — to a licence. Its node fetches the licence's token at once.",
|
||||
map[string]any{"consumer": consumer, "licence": str("a licence, as `licences` names it")},
|
||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||
c, l, err := two(a, "consumer", "licence")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Bind(ctx, c, l, "bind")
|
||||
}),
|
||||
verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it.",
|
||||
map[string]any{"consumer": consumer, "licence": str("the licence to move to")},
|
||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||
c, l, err := two(a, "consumer", "licence")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Bind(ctx, c, l, "switch")
|
||||
}),
|
||||
verb("release", "Unbind a consumer. Its node keeps its last token, which expires within hours.",
|
||||
map[string]any{"consumer": consumer},
|
||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||
c, err := text(a, "consumer")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Release(ctx, c, "release")
|
||||
}),
|
||||
verb("refresh", "Refresh a licence now, or every due licence when none is named; under each licence's lease, so it never races the daemon. Answers the outcome, never a token.",
|
||||
map[string]any{"licence": str("a licence; absent for every due one")},
|
||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||
if l, _ := a["licence"].(string); l != "" {
|
||||
return m.Rotate(ctx, l, true)
|
||||
}
|
||||
return m.RotateDue(ctx)
|
||||
}),
|
||||
verb("usage", "Usage readings, the latest first, for every licence or one.",
|
||||
map[string]any{"licence": str("a licence; absent for all"), "limit": map[string]any{"type": "number", "description": "how many readings (default 20)"}},
|
||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||
l, _ := a["licence"].(string)
|
||||
limit := 20
|
||||
if v, ok := a["limit"].(float64); ok && v > 0 {
|
||||
limit = int(v)
|
||||
}
|
||||
return m.Store.Usage(ctx, l, limit)
|
||||
}),
|
||||
verb("adopt", "Adopt an API key from a file on the manager's node, never as an argument. Subscriptions are adopted from the nodes' logins by themselves.",
|
||||
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key")},
|
||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||
n, f, err := two(a, "name", "file")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.AdoptKey(ctx, n, f)
|
||||
}),
|
||||
verb("current", "For a consumer's agent module (ADR 0206): its token, sealed to the public key it sends, with the licence, kind and generation. Null when it is bound to nothing.",
|
||||
map[string]any{"consumer": consumer, "public_key": str("the consumer's public key, PEM")},
|
||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||
c, k, err := two(a, "consumer", "public_key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Current(ctx, c, k)
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
package main
|
||||
|
||||
// The Anthropic licence manager's rules (novox/hq ADR 0183, ADR 0206, design 39), written against what it
|
||||
// is handed — a store, the vendor, a way to ask a node, its own state, a way to emit — so every rule is
|
||||
// tested without a bus, a database or the vendor.
|
||||
//
|
||||
// - A licence is an account, learned from what the nodes report (`holdings`, the agent module's state).
|
||||
// A report with a refresh token the manager does not hold is a candidate.
|
||||
// - The secret is asked for, sealed to this module's key, never published.
|
||||
// - Adopting is refreshing: newest login first, once per account; a failure adopts nothing.
|
||||
// - What each consumer should hold is this module's `bindings` state, with a generation that grows with
|
||||
// every rotation and switch; the consumer fetches its token by asking `current`.
|
||||
// - One rotation source: every exchange with the vendor runs under a lease.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare.
|
||||
func Fingerprint(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
// Identity is the account a grant belongs to, as the agent's own state file names it.
|
||||
type Identity struct {
|
||||
AccountUUID string `json:"accountUuid"`
|
||||
EmailAddress string `json:"emailAddress,omitempty"`
|
||||
OrganizationUUID string `json:"organizationUuid,omitempty"`
|
||||
}
|
||||
|
||||
// Holdings is one node's report, as the agent module writes it to its `holdings` state (ADR 0206).
|
||||
type Holdings struct {
|
||||
Node string `json:"node"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Kind string `json:"kind"`
|
||||
Refresh struct {
|
||||
Present bool `json:"present"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
} `json:"refresh"`
|
||||
ChangedAt string `json:"changedAt"`
|
||||
}
|
||||
|
||||
// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which
|
||||
// generation.
|
||||
type BindingState struct {
|
||||
Licence string `json:"licence"`
|
||||
Kind string `json:"kind"`
|
||||
Generation int64 `json:"generation"`
|
||||
}
|
||||
|
||||
// Settings are the manager's own, declared with defaults (design 39 §8).
|
||||
type Settings struct {
|
||||
Cadence time.Duration // rotate a grant once older than this
|
||||
Floor time.Duration // refresh in any case with less than this left
|
||||
FailuresToNotify int
|
||||
Cooldown time.Duration // at most one notification per licence in this window
|
||||
RefreshWarn time.Duration // warn this long before a refresh token itself expires
|
||||
}
|
||||
|
||||
// Defaults are the settings a fresh mesh runs with.
|
||||
var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, RefreshWarn: 72 * time.Hour}
|
||||
|
||||
// SettingsFrom reads the settings file, keeping a default for anything absent or not positive.
|
||||
func SettingsFrom(path string) Settings {
|
||||
s := Defaults
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
var f map[string]float64
|
||||
if json.Unmarshal(raw, &f) != nil {
|
||||
return s
|
||||
}
|
||||
set := func(k string, unit time.Duration, into *time.Duration) {
|
||||
if v := f[k]; v > 0 {
|
||||
*into = time.Duration(v * float64(unit))
|
||||
}
|
||||
}
|
||||
set("cadence_minutes", time.Minute, &s.Cadence)
|
||||
set("floor_minutes", time.Minute, &s.Floor)
|
||||
set("cooldown_hours", time.Hour, &s.Cooldown)
|
||||
if v := f["refresh_warn_days"]; v > 0 {
|
||||
s.RefreshWarn = time.Duration(v * float64(24*time.Hour))
|
||||
}
|
||||
if v := f["failures_to_notify"]; v > 0 {
|
||||
s.FailuresToNotify = int(v)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing.
|
||||
type GrantAnswer struct {
|
||||
Sealed *SealedBox `json:"sealed"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
// Manager is the rules and what they are handed.
|
||||
type Manager struct {
|
||||
Store Store
|
||||
Vendor Vendor
|
||||
Crypt *Crypt
|
||||
Keys KeyPair
|
||||
// AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error
|
||||
// is the node not answering; a nil Sealed is no login waiting.
|
||||
AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error)
|
||||
// PutBinding and DeleteBinding are this module's `bindings` state.
|
||||
PutBinding func(ctx context.Context, consumer string, b BindingState) error
|
||||
DeleteBinding func(ctx context.Context, consumer string) error
|
||||
Emit func(event string, body map[string]any) error
|
||||
Now func() time.Time
|
||||
Log func(format string, args ...any)
|
||||
// Holder names this process in a lease, so a second run is told apart.
|
||||
Holder string
|
||||
Settings Settings
|
||||
}
|
||||
|
||||
const leaseFor = 2 * time.Minute
|
||||
|
||||
// NameFor is a licence's name: the account's address where it has one, else its id.
|
||||
func NameFor(id Identity) string {
|
||||
if e := strings.TrimSpace(id.EmailAddress); e != "" {
|
||||
return e
|
||||
}
|
||||
return id.AccountUUID
|
||||
}
|
||||
|
||||
// ---- learning licences from what the nodes hold ------------------------------------------------------
|
||||
|
||||
// Candidate is a report the manager should try.
|
||||
type Candidate struct {
|
||||
Node string
|
||||
Identity Identity
|
||||
Fingerprint string
|
||||
ChangedAt int64
|
||||
}
|
||||
|
||||
// CandidatesIn is the candidates in a set of reports by account, newest login first (ADR 0206 §2, §4). A
|
||||
// report without an identity is not one: a grant is filed under its account or not at all.
|
||||
func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[string][]Candidate, error) {
|
||||
out := map[string][]Candidate{}
|
||||
for _, r := range reports {
|
||||
if !r.Refresh.Present || r.Refresh.Fingerprint == "" || r.Identity == nil || r.Identity.AccountUUID == "" {
|
||||
continue
|
||||
}
|
||||
settled, err := m.Store.Outcome(ctx, r.Refresh.Fingerprint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if settled != "" {
|
||||
continue // adopted, dead, skipped, refused or gone: settled once
|
||||
}
|
||||
held, err := m.Store.LicenceForAccount(ctx, r.Identity.AccountUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if held != nil && held.RefreshFingerprint == r.Refresh.Fingerprint {
|
||||
continue
|
||||
}
|
||||
var changed int64
|
||||
if t, err := time.Parse(time.RFC3339Nano, r.ChangedAt); err == nil {
|
||||
changed = t.UnixMilli()
|
||||
}
|
||||
// The latest login wins: one no newer than the grant held is not a newer login.
|
||||
if held != nil && changed <= held.AdoptedAt {
|
||||
continue
|
||||
}
|
||||
out[r.Identity.AccountUUID] = append(out[r.Identity.AccountUUID],
|
||||
Candidate{Node: r.Node, Identity: *r.Identity, Fingerprint: r.Refresh.Fingerprint, ChangedAt: changed})
|
||||
}
|
||||
for _, list := range out {
|
||||
sort.SliceStable(list, func(i, j int) bool { return list[i].ChangedAt > list[j].ChangedAt })
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Consider every report (ADR 0206): for each account with candidates, under that account's lease, try them
|
||||
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
|
||||
// exchanged. Answers what was adopted.
|
||||
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
|
||||
byAccount, err := m.CandidatesIn(ctx, reports)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accounts := make([]string, 0, len(byAccount))
|
||||
for a := range byAccount {
|
||||
accounts = append(accounts, a)
|
||||
}
|
||||
sort.Strings(accounts)
|
||||
// A node reporting an account the manager already holds, and bound to nothing, is bound to it
|
||||
// (ADR 0206 §7) — whenever its report arrives, not only when the licence is adopted: a node whose own
|
||||
// login is older than the one adopted is never a candidate, and would otherwise never be bound.
|
||||
if err := m.bindReporters(ctx, reports); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var adopted []string
|
||||
for _, account := range accounts {
|
||||
list := byAccount[account]
|
||||
key := "account:" + account
|
||||
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
for i, c := range list {
|
||||
name, err := m.adoptOne(ctx, c, reports)
|
||||
if err != nil {
|
||||
m.Log("adopting %s's login failed: %v", c.Node, err)
|
||||
continue
|
||||
}
|
||||
if name != "" {
|
||||
adopted = append(adopted, name)
|
||||
for _, rest := range list[i+1:] {
|
||||
_ = m.Store.RecordOutcome(ctx, rest.Fingerprint, rest.Node, account, Skipped, c.Node+"'s newer login was adopted first")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = m.Store.Unlease(ctx, key, m.Holder)
|
||||
}
|
||||
return adopted, nil
|
||||
}
|
||||
|
||||
// bindReporters binds each reporting node that is bound to nothing to the licence its account already has.
|
||||
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
|
||||
for _, rep := range reports {
|
||||
if rep.Identity == nil || rep.Identity.AccountUUID == "" {
|
||||
continue
|
||||
}
|
||||
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
l, err := m.Store.LicenceForAccount(ctx, rep.Identity.AccountUUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
b, err := m.Store.Bind(ctx, rep.Node, l.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its account's report"})
|
||||
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Log("bound %s to %s, the licence its account already has", rep.Node, l.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) (string, error) {
|
||||
answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey)
|
||||
if err != nil {
|
||||
// Not answering is not an answer: asked again on the next pass.
|
||||
m.Log("%s did not hand over its login: %v", c.Node, err)
|
||||
return "", nil
|
||||
}
|
||||
if answer.Sealed == nil {
|
||||
return "", m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Gone, "no login was waiting when asked")
|
||||
}
|
||||
plain, err := Open(*answer.Sealed, m.Keys.PrivateKey)
|
||||
if err != nil {
|
||||
return "", m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, "the grant did not open with this module's key")
|
||||
}
|
||||
var offered FullGrant
|
||||
if err := json.Unmarshal([]byte(plain), &offered); err != nil || offered.RefreshToken == "" {
|
||||
return "", m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, "the grant holds no refresh token")
|
||||
}
|
||||
if Fingerprint(offered.RefreshToken) != c.Fingerprint {
|
||||
m.Log("%s's login changed while it was asked for; waiting for its next report", c.Node)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Adopting is refreshing (ADR 0206 §4): the exchange is the check, and from here this module is the
|
||||
// only holder of a live refresh token for the account.
|
||||
r := m.Vendor.Refresh(ctx, offered)
|
||||
if !r.OK {
|
||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Dead, fmt.Sprintf("%d %s", r.Status, r.Reason))
|
||||
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason})
|
||||
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
|
||||
"reason": fmt.Sprintf("the login's refresh token did not refresh (%d)", r.Status)})
|
||||
m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason)
|
||||
return "", nil
|
||||
}
|
||||
// The identity guard, on two sources (ADR 0206 §8).
|
||||
if r.Account != "" && r.Account != c.Identity.AccountUUID {
|
||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused,
|
||||
"the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account)
|
||||
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account})
|
||||
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
|
||||
"reason": "the account the node reported is not the one the vendor answered for"})
|
||||
return "", nil
|
||||
}
|
||||
|
||||
held, err := m.Store.LicenceForAccount(ctx, c.Identity.AccountUUID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := m.Now().UnixMilli()
|
||||
l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID,
|
||||
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID}
|
||||
if held != nil {
|
||||
l.Name, l.NotifiedAt = held.Name, held.NotifiedAt
|
||||
if l.Email == "" {
|
||||
l.Email = held.Email
|
||||
}
|
||||
if l.OrganizationUUID == "" {
|
||||
l.OrganizationUUID = held.OrganizationUUID
|
||||
}
|
||||
}
|
||||
m.keep(&l, r.Grant)
|
||||
l.AdoptedAt = max(now, c.ChangedAt)
|
||||
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||
return "", err
|
||||
}
|
||||
why := "a new licence"
|
||||
if held != nil {
|
||||
why = "a newer login"
|
||||
}
|
||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why)
|
||||
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
|
||||
"vendorNamedAccount": r.Account != ""})
|
||||
|
||||
// A first binding follows the login (ADR 0206 §7): every node reporting this account and bound to nothing.
|
||||
for _, rep := range reports {
|
||||
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
|
||||
continue
|
||||
}
|
||||
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
|
||||
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its login"})
|
||||
}
|
||||
}
|
||||
if err := m.publishAdvance(ctx, l); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil})
|
||||
m.Log("adopted %s from %s (%s)", l.Name, c.Node, why)
|
||||
return l.Name, nil
|
||||
}
|
||||
|
||||
// keep stores a grant on its licence: encrypted, fingerprinted, its expiries, fresh.
|
||||
func (m *Manager) keep(l *Licence, g FullGrant) {
|
||||
raw, _ := json.Marshal(g)
|
||||
l.Sealed = m.Crypt.Seal(string(raw))
|
||||
l.RefreshFingerprint = Fingerprint(g.RefreshToken)
|
||||
l.AccessExpiresAt = g.ExpiresAt
|
||||
if g.RefreshTokenExpiresAt != nil {
|
||||
l.RefreshExpiresAt = *g.RefreshTokenExpiresAt
|
||||
}
|
||||
l.Failures = 0
|
||||
l.RotatedAt = m.Now().UnixMilli()
|
||||
}
|
||||
|
||||
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state.
|
||||
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
|
||||
bindings, err := m.Store.Advance(ctx, l.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range bindings {
|
||||
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- keeping grants alive ----------------------------------------------------------------------------
|
||||
|
||||
// Due says whether a licence needs a refresh now: near expiry, or older than the cadence.
|
||||
func Due(l Licence, now time.Time, s Settings) bool {
|
||||
if l.Kind != "subscription" || l.Sealed == "" {
|
||||
return false
|
||||
}
|
||||
ms := now.UnixMilli()
|
||||
if l.AccessExpiresAt != 0 && l.AccessExpiresAt-ms < s.Floor.Milliseconds() {
|
||||
return true
|
||||
}
|
||||
return l.RotatedAt == 0 || ms-l.RotatedAt >= s.Cadence.Milliseconds()
|
||||
}
|
||||
|
||||
// Rotate refreshes one licence under its lease (design 39 §3). A second run started together finds the
|
||||
// lease live and does nothing; one started just after finds a fresh grant and is not due. force refreshes
|
||||
// whatever the age — the seat's `refresh` verb.
|
||||
func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[string]any, error) {
|
||||
key := "licence:" + name
|
||||
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return map[string]any{"licence": name, "refreshed": false, "reason": "another run holds its lease"}, nil
|
||||
}
|
||||
defer func() { _ = m.Store.Unlease(ctx, key, m.Holder) }()
|
||||
l, err := m.Store.Licence(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if l == nil {
|
||||
return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil
|
||||
}
|
||||
if l.Kind != "subscription" || l.Sealed == "" {
|
||||
return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
|
||||
}
|
||||
now := m.Now()
|
||||
if !force && !Due(*l, now, m.Settings) {
|
||||
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil
|
||||
}
|
||||
plain, err := m.Crypt.Open(l.Sealed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var g FullGrant
|
||||
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := m.Vendor.Refresh(ctx, g)
|
||||
if !r.OK {
|
||||
l.Failures++
|
||||
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
|
||||
_ = m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures})
|
||||
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify)
|
||||
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil
|
||||
}
|
||||
m.keep(l, r.Grant)
|
||||
if l.RefreshExpiresAt != 0 {
|
||||
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond
|
||||
m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24),
|
||||
left < m.Settings.RefreshWarn)
|
||||
}
|
||||
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = m.Store.Audit(ctx, "rotated", map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt})
|
||||
if err := m.publishAdvance(ctx, *l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
// notify emits `licence.failing` at most once per cooldown (design 39 §3); it carries no secret.
|
||||
func (m *Manager) notify(l *Licence, why string, when bool) {
|
||||
if !when {
|
||||
return
|
||||
}
|
||||
now := m.Now().UnixMilli()
|
||||
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.Settings.Cooldown.Milliseconds() {
|
||||
return
|
||||
}
|
||||
l.NotifiedAt = now
|
||||
_ = m.Emit("licence.failing", map[string]any{"licence": l.Name, "why": why})
|
||||
}
|
||||
|
||||
// RotateDue refreshes every licence that is due.
|
||||
func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
|
||||
all, err := m.Store.Licences(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []map[string]any
|
||||
for _, l := range all {
|
||||
if Due(l, m.Now(), m.Settings) {
|
||||
r, err := m.Rotate(ctx, l.Name, false)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers.
|
||||
func (m *Manager) ReadUsage(ctx context.Context) error {
|
||||
all, err := m.Store.Licences(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range all {
|
||||
if l.Kind != "subscription" || l.Sealed == "" {
|
||||
continue
|
||||
}
|
||||
plain, err := m.Crypt.Open(l.Sealed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var g FullGrant
|
||||
_ = json.Unmarshal([]byte(plain), &g)
|
||||
raw, err := m.Vendor.Usage(ctx, g.AccessToken)
|
||||
if err != nil || raw == nil {
|
||||
continue
|
||||
}
|
||||
reading := FlattenUsage(raw)
|
||||
if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct,
|
||||
"weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- the seat's verbs --------------------------------------------------------------------------------
|
||||
|
||||
// Current is a consumer's token sealed to the key it sent (ADR 0206 §6): for a subscription the access
|
||||
// token and its expiries only — never the refresh token, which no node holds. Nil when it is bound to
|
||||
// nothing.
|
||||
func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[string]any, error) {
|
||||
if !strings.Contains(publicKey, "PUBLIC KEY") {
|
||||
return nil, errors.New("current seals to the consumer's public key, and none was given")
|
||||
}
|
||||
b, err := m.Store.Binding(ctx, consumer)
|
||||
if err != nil || b == nil {
|
||||
return nil, err
|
||||
}
|
||||
l, err := m.Store.Licence(ctx, b.Licence)
|
||||
if err != nil || l == nil || l.Sealed == "" {
|
||||
return nil, err
|
||||
}
|
||||
plain, err := m.Crypt.Open(l.Sealed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handed := plain
|
||||
if l.Kind == "subscription" {
|
||||
var g FullGrant
|
||||
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
|
||||
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
|
||||
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
|
||||
handed = string(access)
|
||||
}
|
||||
box, err := Seal(handed, publicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"licence": l.Name, "kind": l.Kind, "generation": b.Generation, "sealed": box}
|
||||
if l.AccountUUID != "" {
|
||||
out["identity"] = Identity{AccountUUID: l.AccountUUID, EmailAddress: l.Email, OrganizationUUID: l.OrganizationUUID}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation.
|
||||
func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[string]any, error) {
|
||||
l, err := m.Store.Licence(ctx, licence)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if l == nil {
|
||||
return nil, fmt.Errorf("there is no licence %s; `licences` lists them", licence)
|
||||
}
|
||||
before, err := m.Store.Binding(ctx, consumer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := m.Store.Bind(ctx, consumer, licence)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
from, what := "", "bound"
|
||||
if before != nil {
|
||||
from, what = before.Licence, "switched"
|
||||
}
|
||||
_ = m.Store.Audit(ctx, what, map[string]any{"consumer": consumer, "licence": licence, "from": from, "by": by})
|
||||
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
|
||||
}
|
||||
|
||||
// Release unbinds a consumer; its node keeps its last token, which expires within hours.
|
||||
func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]any, error) {
|
||||
was, err := m.Store.Binding(ctx, consumer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok, err := m.Store.Unbind(ctx, consumer); err != nil || !ok {
|
||||
return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err
|
||||
}
|
||||
if err := m.DeleteBinding(ctx, consumer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = m.Store.Audit(ctx, "released", map[string]any{"consumer": consumer, "licence": was.Licence, "by": by})
|
||||
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
||||
}
|
||||
|
||||
var licenceName = regexp.MustCompile(`^[A-Za-z0-9@._-]+$`)
|
||||
|
||||
// AdoptKey adopts an API key from a file on this node — never an argument (design 39 §6).
|
||||
func (m *Manager) AdoptKey(ctx context.Context, name, file string) (map[string]any, error) {
|
||||
if !licenceName.MatchString(name) {
|
||||
return nil, fmt.Errorf("%q is not a licence name: letters, digits and @._-", name)
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := strings.TrimSpace(string(raw))
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("%s is empty", file)
|
||||
}
|
||||
held, err := m.Store.Licence(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if held != nil && held.Kind != "api-key" {
|
||||
return nil, fmt.Errorf("%s is a subscription; an API key needs a name of its own", name)
|
||||
}
|
||||
l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()}
|
||||
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": "a file"})
|
||||
if err := m.publishAdvance(ctx, l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": "a file", "replaced": held != nil})
|
||||
return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil
|
||||
}
|
||||
|
||||
// Licences is each licence as a person reads it: health and who is bound, never a token.
|
||||
func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
|
||||
all, err := m.Store.Licences(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bindings, err := m.Store.Bindings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stamp := func(ms int64) any {
|
||||
if ms == 0 {
|
||||
return nil
|
||||
}
|
||||
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, l := range all {
|
||||
bound := []string{}
|
||||
for _, b := range bindings {
|
||||
if b.Licence == l.Name {
|
||||
bound = append(bound, b.Consumer)
|
||||
}
|
||||
}
|
||||
account := l.Email
|
||||
if account == "" {
|
||||
account = l.AccountUUID
|
||||
}
|
||||
out = append(out, map[string]any{"name": l.Name, "kind": l.Kind, "account": account,
|
||||
"accessExpiresAt": stamp(l.AccessExpiresAt), "refreshExpiresAt": stamp(l.RefreshExpiresAt),
|
||||
"rotatedAt": stamp(l.RotatedAt), "failures": l.Failures, "bound": bound})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var t0 = time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// stubVendor rotates like the real one is presumed to: each refresh token exchanges once.
|
||||
type stubVendor struct {
|
||||
mu sync.Mutex
|
||||
live map[string]bool
|
||||
exchanged []string
|
||||
issued int
|
||||
account string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.exchanged = append(v.exchanged, g.RefreshToken)
|
||||
if !v.live[g.RefreshToken] {
|
||||
return Refreshed{Status: 400, Reason: `{"error":"invalid_grant"}`}
|
||||
}
|
||||
delete(v.live, g.RefreshToken)
|
||||
v.issued++
|
||||
next := fmt.Sprintf("rt-%d", v.issued)
|
||||
v.live[next] = true
|
||||
g.AccessToken = fmt.Sprintf("at-%d", v.issued)
|
||||
g.RefreshToken = next
|
||||
g.ExpiresAt = v.now().Add(8 * time.Hour).UnixMilli()
|
||||
exp := v.now().Add(30 * 24 * time.Hour).UnixMilli()
|
||||
g.RefreshTokenExpiresAt = &exp
|
||||
return Refreshed{OK: true, Grant: g, Account: v.account}
|
||||
}
|
||||
|
||||
func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) {
|
||||
return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
|
||||
}
|
||||
|
||||
type miniMesh struct {
|
||||
m *Manager
|
||||
store *MemoryStore
|
||||
vendor *stubVendor
|
||||
logins map[string]FullGrant // node → what its credentials file holds
|
||||
state map[string]BindingState
|
||||
events []string
|
||||
now time.Time
|
||||
keys KeyPair
|
||||
askDown bool
|
||||
}
|
||||
|
||||
func newMesh(t *testing.T) *miniMesh {
|
||||
t.Helper()
|
||||
mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0}
|
||||
now := func() time.Time { return mm.now }
|
||||
mm.store = NewMemoryStore(now)
|
||||
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
|
||||
crypt, _ := NewCrypt("a key the vault made")
|
||||
mm.keys, _ = GenerateKeyPair()
|
||||
mm.m = &Manager{
|
||||
Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Keys: mm.keys,
|
||||
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
||||
if mm.askDown {
|
||||
return GrantAnswer{}, fmt.Errorf("503 no responders")
|
||||
}
|
||||
g, ok := mm.logins[node]
|
||||
if !ok {
|
||||
return GrantAnswer{}, nil
|
||||
}
|
||||
raw, _ := json.Marshal(g)
|
||||
box, err := Seal(string(raw), publicKey)
|
||||
return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err
|
||||
},
|
||||
PutBinding: func(_ context.Context, c string, b BindingState) error { mm.state[c] = b; return nil },
|
||||
DeleteBinding: func(_ context.Context, c string) error { delete(mm.state, c); return nil },
|
||||
Emit: func(event string, body map[string]any) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
mm.events = append(mm.events, event+" "+string(raw))
|
||||
return nil
|
||||
},
|
||||
Now: now, Log: func(string, ...any) {}, Holder: "test", Settings: Defaults,
|
||||
}
|
||||
return mm
|
||||
}
|
||||
|
||||
func (mm *miniMesh) report(node, rt string, at time.Time, account string) Holdings {
|
||||
h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"},
|
||||
Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)}
|
||||
if rt != "" {
|
||||
h.Refresh.Present, h.Refresh.Fingerprint = true, Fingerprint(rt)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (mm *miniMesh) login(node, rt string, valid bool, at time.Time) Holdings {
|
||||
mm.logins[node] = FullGrant{AccessToken: "local-" + node, RefreshToken: rt, ExpiresAt: mm.now.Add(time.Hour).UnixMilli()}
|
||||
if valid {
|
||||
mm.vendor.live[rt] = true
|
||||
}
|
||||
return mm.report(node, rt, at, "acct-1")
|
||||
}
|
||||
|
||||
const licence1 = "acct-1@example.org"
|
||||
|
||||
func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchangesTheRest(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
older := mm.login("server", "rt-server", true, t0.Add(-time.Hour))
|
||||
newest := mm.login("laptop", "rt-laptop", true, t0.Add(-time.Minute))
|
||||
adopted, err := mm.m.Consider(ctx, []Holdings{older, newest})
|
||||
if err != nil || len(adopted) != 1 || adopted[0] != licence1 {
|
||||
t.Fatalf("adopted %v, %v", adopted, err)
|
||||
}
|
||||
if strings.Join(mm.vendor.exchanged, ",") != "rt-laptop" {
|
||||
t.Fatalf("exchanged %v: an older login was exchanged although a newer one refreshed", mm.vendor.exchanged)
|
||||
}
|
||||
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-server")); o != Skipped {
|
||||
t.Fatalf("the older login is %q, not skipped", o)
|
||||
}
|
||||
// A first binding follows the login: both nodes reported this account and were bound to nothing.
|
||||
bs, _ := mm.store.Bindings(ctx)
|
||||
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
|
||||
t.Fatalf("bindings %v, state %v", bs, mm.state)
|
||||
}
|
||||
if !strings.HasPrefix(strings.Join(mm.events, "|"), "licence.adopted") {
|
||||
t.Fatalf("events %v", mm.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALoginThatDoesNotRefreshAdoptsNothingAndIsNotTriedAgain(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
dead := mm.login("laptop", "rt-dead", false, t0)
|
||||
if adopted, _ := mm.m.Consider(ctx, []Holdings{dead}); len(adopted) != 0 {
|
||||
t.Fatalf("adopted %v", adopted)
|
||||
}
|
||||
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-dead")); o != Dead {
|
||||
t.Fatalf("outcome %q", o)
|
||||
}
|
||||
if ls, _ := mm.store.Licences(ctx); len(ls) != 0 {
|
||||
t.Fatalf("licences %v", ls)
|
||||
}
|
||||
if !strings.Contains(strings.Join(mm.events, "|"), "licence.refused") {
|
||||
t.Fatalf("events %v", mm.events)
|
||||
}
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{dead})
|
||||
if len(mm.vendor.exchanged) != 1 {
|
||||
t.Fatalf("a dead refresh token was exchanged %d times", len(mm.vendor.exchanged))
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0.Add(-time.Minute))})
|
||||
before, _ := mm.store.Licence(ctx, licence1)
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-old", true, t0.Add(-24*time.Hour))})
|
||||
if strings.Join(mm.vendor.exchanged, ",") != "rt-a" {
|
||||
t.Fatalf("an older login was exchanged: %v", mm.vendor.exchanged)
|
||||
}
|
||||
mm.now = t0.Add(10 * time.Minute)
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("desktop", "rt-new", true, t0.Add(10*time.Minute))})
|
||||
after, _ := mm.store.Licence(ctx, licence1)
|
||||
if after.RefreshFingerprint == before.RefreshFingerprint || mm.vendor.exchanged[len(mm.vendor.exchanged)-1] != "rt-new" {
|
||||
t.Fatalf("a newer login did not win: %v", mm.vendor.exchanged)
|
||||
}
|
||||
if ls, _ := mm.store.Licences(ctx); len(ls) != 1 {
|
||||
t.Fatalf("one account became %d licences", len(ls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReportNamingAnotherAccountThanTheVendorAnsweredForIsRefused(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
mm.vendor.account = "someone-else"
|
||||
if adopted, _ := mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}); len(adopted) != 0 {
|
||||
t.Fatalf("adopted %v", adopted)
|
||||
}
|
||||
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-a")); o != Refused {
|
||||
t.Fatalf("outcome %q", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
mm.now = t0.Add(5 * time.Hour)
|
||||
second := *mm.m
|
||||
second.Holder = "another run"
|
||||
var wg sync.WaitGroup
|
||||
results := make([]map[string]any, 2)
|
||||
for i, m := range []*Manager{mm.m, &second} {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }()
|
||||
}
|
||||
wg.Wait()
|
||||
n := 0
|
||||
for _, r := range results {
|
||||
if r["refreshed"] == true {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("rotated %d times: %v", n, results)
|
||||
}
|
||||
if r, _ := second.Rotate(ctx, licence1, false); r["reason"] != "not due" {
|
||||
t.Fatalf("a run just after was %v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
g0 := mm.state["laptop"].Generation
|
||||
_, _ = mm.m.Rotate(ctx, licence1, true)
|
||||
g1 := mm.state["laptop"].Generation
|
||||
if g1 <= g0 {
|
||||
t.Fatalf("a rotation went from %d to %d", g0, g1)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "key")
|
||||
_ = os.WriteFile(file, []byte("sk-ant-api-key\n"), 0o600)
|
||||
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := mm.m.Bind(ctx, "laptop", "api", "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
|
||||
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
|
||||
}
|
||||
if _, err := mm.m.Release(ctx, "laptop", "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := mm.state["laptop"]; ok {
|
||||
t.Fatal("a released consumer still has a binding in the state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentHandsAnAccessTokenOnlySealedToTheConsumersKey(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
node, _ := GenerateKeyPair()
|
||||
answer, err := mm.m.Current(ctx, "laptop", node.PublicKey)
|
||||
if err != nil || answer["kind"] != "subscription" {
|
||||
t.Fatalf("%v %v", answer, err)
|
||||
}
|
||||
box := answer["sealed"].(SealedBox)
|
||||
plain, err := Open(box, node.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var handed map[string]any
|
||||
_ = json.Unmarshal([]byte(plain), &handed)
|
||||
if !strings.HasPrefix(handed["accessToken"].(string), "at-") || handed["refreshToken"] != nil {
|
||||
t.Fatalf("handed %v", handed)
|
||||
}
|
||||
other, _ := GenerateKeyPair()
|
||||
if _, err := Open(box, other.PrivateKey); err == nil {
|
||||
t.Fatal("the hand-over opened with another key")
|
||||
}
|
||||
if a, _ := mm.m.Current(ctx, "nobody", node.PublicKey); a != nil {
|
||||
t.Fatalf("a consumer bound to nothing was answered %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-secret-1", true, t0)})
|
||||
_, _ = mm.m.Rotate(ctx, licence1, true)
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-secret-dead", false, t0.Add(time.Hour))})
|
||||
_ = mm.m.ReadUsage(ctx)
|
||||
ls, _ := mm.m.Licences(ctx)
|
||||
bs, _ := mm.store.Bindings(ctx)
|
||||
everything, _ := json.Marshal([]any{mm.events, mm.state, ls, bs})
|
||||
for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} {
|
||||
if strings.Contains(string(everything), token) {
|
||||
t.Fatalf("%s was published: %s", token, everything)
|
||||
}
|
||||
}
|
||||
row, _ := mm.store.Licence(ctx, licence1)
|
||||
if strings.Contains(row.Sealed, "rt-") {
|
||||
t.Fatal("the grant is stored in the clear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeThatDoesNotAnswerIsAskedAgainAndOneWithNoLoginIsSettled(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
r := mm.login("laptop", "rt-a", true, t0)
|
||||
mm.askDown = true
|
||||
if adopted, _ := mm.m.Consider(ctx, []Holdings{r}); len(adopted) != 0 {
|
||||
t.Fatalf("adopted %v from a node that did not answer", adopted)
|
||||
}
|
||||
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-a")); o != "" {
|
||||
t.Fatalf("a node that was down was settled %q", o)
|
||||
}
|
||||
mm.askDown = false
|
||||
if adopted, _ := mm.m.Consider(ctx, []Holdings{r}); len(adopted) != 1 {
|
||||
t.Fatalf("adopted %v on the next pass", adopted)
|
||||
}
|
||||
gone := mm.report("server", "rt-gone", t0.Add(time.Second), "acct-2")
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{gone})
|
||||
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-gone")); o != Gone {
|
||||
t.Fatalf("outcome %q", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) {
|
||||
// The agent module's own report shape (claude-code's holdingsOf), parsed here.
|
||||
raw := `{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription",
|
||||
"refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},
|
||||
"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":0,
|
||||
"changedAt":"2026-10-04T11:00:00.000Z"}`
|
||||
var h Holdings
|
||||
if err := json.Unmarshal([]byte(raw), &h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || !h.Refresh.Present || h.Refresh.Fingerprint != "sha256:0123456789abcdef" {
|
||||
t.Fatalf("%+v", h)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, h.ChangedAt); err != nil {
|
||||
t.Fatalf("the report's time does not parse: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A node whose report arrives after its account was adopted — with an older login, so never a candidate —
|
||||
// is still bound to that account's licence, once.
|
||||
func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
|
||||
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
||||
if mm.state["laptop"].Licence != licence1 {
|
||||
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.state)
|
||||
}
|
||||
if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") {
|
||||
t.Fatal("the older login was exchanged")
|
||||
}
|
||||
g := mm.state["laptop"].Generation
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
||||
if mm.state["laptop"].Generation != g {
|
||||
t.Fatal("a node already bound was bound again")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package main
|
||||
|
||||
// The store on the mesh's postgres (novox/hq design 39 §1), the database the mesh provisioned for this
|
||||
// module. The schema is brought to this version's shape by the preparation step (ADR 0135), each
|
||||
// statement idempotent.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Schema is what this version needs.
|
||||
var Schema = []string{
|
||||
`create table if not exists licence (
|
||||
name text primary key,
|
||||
kind text not null check (kind in ('subscription', 'api-key')),
|
||||
account_uuid text unique,
|
||||
email text,
|
||||
organization_uuid text,
|
||||
sealed text,
|
||||
refresh_fingerprint text,
|
||||
access_expires_at bigint,
|
||||
refresh_expires_at bigint,
|
||||
failures integer not null default 0,
|
||||
notified_at bigint,
|
||||
adopted_at bigint not null,
|
||||
rotated_at bigint)`,
|
||||
`create sequence if not exists binding_generation`,
|
||||
`create table if not exists binding (
|
||||
consumer text primary key,
|
||||
licence text not null references licence(name),
|
||||
generation bigint not null)`,
|
||||
`create table if not exists lease (
|
||||
key text primary key,
|
||||
holder text not null,
|
||||
until timestamptz not null)`,
|
||||
`create table if not exists offered (
|
||||
fingerprint text primary key,
|
||||
node text not null,
|
||||
account_uuid text,
|
||||
outcome text not null,
|
||||
why text not null,
|
||||
at timestamptz not null default now())`,
|
||||
`create table if not exists usage (
|
||||
licence text not null,
|
||||
at bigint not null,
|
||||
reading jsonb not null,
|
||||
raw jsonb not null)`,
|
||||
`create index if not exists usage_by_licence on usage (licence, at desc)`,
|
||||
`create table if not exists audit (
|
||||
at timestamptz not null default now(),
|
||||
what text not null,
|
||||
detail jsonb not null)`,
|
||||
}
|
||||
|
||||
// PgStore is the store on postgres.
|
||||
type PgStore struct{ pool *pgxpool.Pool }
|
||||
|
||||
// PgStoreFromEnv opens the store the mesh provisioned, its URL in the file DATABASE_URL_FILE names.
|
||||
func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
|
||||
file := os.Getenv("DATABASE_URL_FILE")
|
||||
if file == "" {
|
||||
return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves")
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return OpenPgStore(ctx, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
|
||||
// OpenPgStore opens a store at a URL.
|
||||
func OpenPgStore(ctx context.Context, url string) (*PgStore, error) {
|
||||
pool, err := pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PgStore{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Migrate brings the schema to this version's shape.
|
||||
func (s *PgStore) Migrate(ctx context.Context) error {
|
||||
for _, q := range Schema {
|
||||
if _, err := s.pool.Exec(ctx, q); err != nil {
|
||||
return fmt.Errorf("%s: %w", strings.Fields(q)[0:6], err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''),
|
||||
coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0), coalesce(refresh_expires_at,0),
|
||||
failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0)`
|
||||
|
||||
func scanLicence(row pgx.Row) (*Licence, error) {
|
||||
var l Licence
|
||||
err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint,
|
||||
&l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return &l, err
|
||||
}
|
||||
|
||||
func (s *PgStore) Licences(ctx context.Context) ([]Licence, error) {
|
||||
rows, err := s.pool.Query(ctx, `select `+licenceColumns+` from licence order by name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Licence
|
||||
for rows.Next() {
|
||||
l, err := scanLicence(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) Licence(ctx context.Context, name string) (*Licence, error) {
|
||||
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where name = $1`, name))
|
||||
}
|
||||
|
||||
func (s *PgStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) {
|
||||
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where account_uuid = $1`, account))
|
||||
}
|
||||
|
||||
func nullable(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullableInt(v int64) any {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error {
|
||||
_, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, sealed, refresh_fingerprint,
|
||||
access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at)
|
||||
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email,
|
||||
organization_uuid = excluded.organization_uuid, sealed = excluded.sealed, refresh_fingerprint = excluded.refresh_fingerprint,
|
||||
access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at,
|
||||
failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at,
|
||||
rotated_at = excluded.rotated_at`,
|
||||
l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.Sealed),
|
||||
nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures,
|
||||
nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt))
|
||||
return err
|
||||
}
|
||||
|
||||
// Lease is taken in the store before a row is read (design 39 §3): a second run started together finds
|
||||
// it live and does nothing.
|
||||
func (s *PgStore) Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) {
|
||||
tag, err := s.pool.Exec(ctx, `insert into lease (key, holder, until) values ($1, $2, now() + make_interval(secs => $3))
|
||||
on conflict (key) do update set holder = excluded.holder, until = excluded.until
|
||||
where lease.until < now() or lease.holder = excluded.holder`, key, holder, d.Seconds())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
func (s *PgStore) Unlease(ctx context.Context, key, holder string) error {
|
||||
_, err := s.pool.Exec(ctx, `delete from lease where key = $1 and holder = $2`, key, holder)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) bindingsWhere(ctx context.Context, q string, args ...any) ([]Binding, error) {
|
||||
rows, err := s.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Binding
|
||||
for rows.Next() {
|
||||
var b Binding
|
||||
if err := rows.Scan(&b.Consumer, &b.Licence, &b.Generation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) Bindings(ctx context.Context) ([]Binding, error) {
|
||||
return s.bindingsWhere(ctx, `select consumer, licence, generation from binding order by consumer`)
|
||||
}
|
||||
|
||||
func (s *PgStore) Binding(ctx context.Context, consumer string) (*Binding, error) {
|
||||
out, err := s.bindingsWhere(ctx, `select consumer, licence, generation from binding where consumer = $1`, consumer)
|
||||
if err != nil || len(out) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &out[0], nil
|
||||
}
|
||||
|
||||
func (s *PgStore) Bind(ctx context.Context, consumer, licence string) (Binding, error) {
|
||||
out, err := s.bindingsWhere(ctx, `insert into binding (consumer, licence, generation) values ($1, $2, nextval('binding_generation'))
|
||||
on conflict (consumer) do update set licence = excluded.licence, generation = excluded.generation
|
||||
returning consumer, licence, generation`, consumer, licence)
|
||||
if err != nil {
|
||||
return Binding{}, err
|
||||
}
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) {
|
||||
tag, err := s.pool.Exec(ctx, `delete from binding where consumer = $1`, consumer)
|
||||
return err == nil && tag.RowsAffected() == 1, err
|
||||
}
|
||||
|
||||
func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) {
|
||||
return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1
|
||||
returning consumer, licence, generation`, licence)
|
||||
}
|
||||
|
||||
func (s *PgStore) Outcome(ctx context.Context, fp string) (Outcome, error) {
|
||||
var o string
|
||||
err := s.pool.QueryRow(ctx, `select outcome from offered where fingerprint = $1`, fp).Scan(&o)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return Outcome(o), err
|
||||
}
|
||||
|
||||
func (s *PgStore) RecordOutcome(ctx context.Context, fp, node, account string, o Outcome, why string) error {
|
||||
_, err := s.pool.Exec(ctx, `insert into offered (fingerprint, node, account_uuid, outcome, why) values ($1,$2,$3,$4,$5)
|
||||
on conflict (fingerprint) do update set outcome = excluded.outcome, why = excluded.why, at = now()`,
|
||||
fp, node, nullable(account), string(o), why)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
|
||||
reading, _ := json.Marshal(r)
|
||||
rawJSON, _ := json.Marshal(raw)
|
||||
_, err := s.pool.Exec(ctx, `insert into usage (licence, at, reading, raw) values ($1,$2,$3,$4)`, licence, at, reading, rawJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `select licence, at, reading from usage where ($1 = '' or licence = $1) order by at desc limit $2`, licence, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []UsageRow
|
||||
for rows.Next() {
|
||||
var u UsageRow
|
||||
var reading []byte
|
||||
if err := rows.Scan(&u.Licence, &u.At, &reading); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(reading, &u.Reading)
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) Audit(ctx context.Context, what string, detail map[string]any) error {
|
||||
raw, _ := json.Marshal(detail)
|
||||
_, err := s.pool.Exec(ctx, `insert into audit (what, detail) values ($1, $2)`, what, raw)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) Close() { s.pool.Close() }
|
||||
@@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
// Sealing to one recipient (novox/hq ADR 0183, ADR 0206): the manager seals what it hands a consumer to
|
||||
// the key that consumer sent, and a node seals a waiting login to the key the manager gives. The same box
|
||||
// the agent module's TypeScript makes and opens, byte for byte — X25519 for the agreement, HKDF-SHA256 for
|
||||
// the key, AES-256-GCM for the box — so `testdata/sealed-by-typescript.json` is opened here, and a test
|
||||
// reopens what this seals with the same derivation.
|
||||
//
|
||||
// A box is `{ v: 1, eph, iv, tag, ct }`, every field base64; `eph` is the one-time public key as SPKI DER,
|
||||
// and the key is bound to it and to the recipient's raw public key, so a box cannot be re-addressed.
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SealedBox is a value sealed to one recipient.
|
||||
type SealedBox struct {
|
||||
V int `json:"v"`
|
||||
Eph string `json:"eph"`
|
||||
IV string `json:"iv"`
|
||||
Tag string `json:"tag"`
|
||||
Ct string `json:"ct"`
|
||||
}
|
||||
|
||||
// KeyPair is a recipient's keypair as the two PEM strings it is kept and sent as.
|
||||
type KeyPair struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
PrivateKey string `json:"privateKey"`
|
||||
}
|
||||
|
||||
const sealInfo = "novox-mesh sealed box v1"
|
||||
|
||||
// GenerateKeyPair makes an X25519 keypair, PEM-encoded as the agent module's are.
|
||||
func GenerateKeyPair() (KeyPair, error) {
|
||||
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(priv.PublicKey())
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
return KeyPair{
|
||||
PublicKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})),
|
||||
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func publicFromPEM(p string) (*ecdh.PublicKey, error) {
|
||||
block, _ := pem.Decode([]byte(p))
|
||||
if block == nil {
|
||||
return nil, errors.New("not a PEM public key")
|
||||
}
|
||||
k, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub, ok := k.(*ecdh.PublicKey)
|
||||
if !ok || pub.Curve() != ecdh.X25519() {
|
||||
return nil, errors.New("not an X25519 public key")
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
func privateFromPEM(p string) (*ecdh.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(p))
|
||||
if block == nil {
|
||||
return nil, errors.New("not a PEM private key")
|
||||
}
|
||||
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priv, ok := k.(*ecdh.PrivateKey)
|
||||
if !ok || priv.Curve() != ecdh.X25519() {
|
||||
return nil, errors.New("not an X25519 private key")
|
||||
}
|
||||
return priv, nil
|
||||
}
|
||||
|
||||
func boxKey(secret, ephDER, recipientRaw []byte) ([]byte, error) {
|
||||
salt := append(append([]byte{}, ephDER...), recipientRaw...)
|
||||
return hkdf.Key(sha256.New, secret, salt, sealInfo, 32)
|
||||
}
|
||||
|
||||
// Seal seals plaintext to the recipient's public key.
|
||||
func Seal(plaintext, recipientPEM string) (SealedBox, error) {
|
||||
recipient, err := publicFromPEM(recipientPEM)
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
secret, err := eph.ECDH(recipient)
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
ephDER, err := x509.MarshalPKIXPublicKey(eph.PublicKey())
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
key, err := boxKey(secret, ephDER, recipient.Bytes())
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
iv := make([]byte, 12)
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
out := gcm.Seal(nil, iv, []byte(plaintext), nil)
|
||||
ct, tag := out[:len(out)-gcm.Overhead()], out[len(out)-gcm.Overhead():]
|
||||
b64 := base64.StdEncoding.EncodeToString
|
||||
return SealedBox{V: 1, Eph: b64(ephDER), IV: b64(iv), Tag: b64(tag), Ct: b64(ct)}, nil
|
||||
}
|
||||
|
||||
// Open opens a box with the recipient's private key; it fails for a box to another key or one tampered with.
|
||||
func Open(box SealedBox, privatePEM string) (string, error) {
|
||||
if box.V != 1 {
|
||||
return "", errors.New("not a sealed box this module can open")
|
||||
}
|
||||
priv, err := privateFromPEM(privatePEM)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d := base64.StdEncoding.DecodeString
|
||||
ephDER, err := d(box.Eph)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the box's eph: %w", err)
|
||||
}
|
||||
ephKey, err := x509.ParsePKIXPublicKey(ephDER)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
eph, ok := ephKey.(*ecdh.PublicKey)
|
||||
if !ok {
|
||||
return "", errors.New("the box's eph is not an X25519 key")
|
||||
}
|
||||
secret, err := priv.ECDH(eph)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key, err := boxKey(secret, ephDER, priv.PublicKey().Bytes())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iv, err1 := d(box.IV)
|
||||
tag, err2 := d(box.Tag)
|
||||
ct, err3 := d(box.Ct)
|
||||
if err := errors.Join(err1, err2, err3); err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain, err := gcm.Open(nil, iv, append(ct, tag...), nil)
|
||||
if err != nil {
|
||||
return "", errors.New("the box does not open with this key")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A box the agent module's TypeScript sealed opens here: the two implementations are one format.
|
||||
func TestABoxSealedInTypeScriptOpensInGo(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/sealed-by-typescript.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f struct {
|
||||
PrivateKey string `json:"privateKey"`
|
||||
Box SealedBox `json:"box"`
|
||||
Plaintext string `json:"plaintext"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Open(f.Box, f.PrivateKey)
|
||||
if err != nil || got != f.Plaintext {
|
||||
t.Fatalf("opened %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// What Go seals opens with its own key and no other.
|
||||
func TestABoxOpensOnlyForItsRecipient(t *testing.T) {
|
||||
a, _ := GenerateKeyPair()
|
||||
b, _ := GenerateKeyPair()
|
||||
box, err := Seal("a token", a.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := Open(box, a.PrivateKey); err != nil || got != "a token" {
|
||||
t.Fatalf("opened %q, %v", got, err)
|
||||
}
|
||||
if _, err := Open(box, b.PrivateKey); err == nil {
|
||||
t.Fatal("a box opened for another key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package main
|
||||
|
||||
// The manager's store (novox/hq ADR 0183, design 39 §1): licences with their grants encrypted, bindings
|
||||
// with a generation, what became of each login it was offered, usage readings, and the audit. One
|
||||
// interface, two implementations — postgres for the mesh (pgstore.go), memory for the tests — so every
|
||||
// rule is tested without a database, and the database is asked only to keep rows.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Licence is one licence: an account, or an API key.
|
||||
type Licence struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // subscription | api-key
|
||||
AccountUUID string `json:"accountUuid,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
OrganizationUUID string `json:"organizationUuid,omitempty"`
|
||||
Sealed string `json:"-"` // the grant or the key, encrypted at rest
|
||||
RefreshFingerprint string `json:"-"`
|
||||
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
|
||||
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
|
||||
Failures int `json:"failures"`
|
||||
NotifiedAt int64 `json:"-"`
|
||||
AdoptedAt int64 `json:"adoptedAt"`
|
||||
RotatedAt int64 `json:"rotatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// Binding is one consumer's binding and the generation it was last given (ADR 0206).
|
||||
type Binding struct {
|
||||
Consumer string `json:"consumer"`
|
||||
Licence string `json:"licence"`
|
||||
Generation int64 `json:"generation"`
|
||||
}
|
||||
|
||||
// Outcome is what became of a login the manager was offered, by its refresh token's fingerprint.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
Adopted Outcome = "adopted"
|
||||
Dead Outcome = "dead"
|
||||
Skipped Outcome = "skipped"
|
||||
Refused Outcome = "refused"
|
||||
Gone Outcome = "gone"
|
||||
)
|
||||
|
||||
// UsageRow is one usage reading.
|
||||
type UsageRow struct {
|
||||
Licence string `json:"licence"`
|
||||
At int64 `json:"at"`
|
||||
Reading UsageReading `json:"reading"`
|
||||
}
|
||||
|
||||
// Store is what the manager keeps.
|
||||
type Store interface {
|
||||
Licences(ctx context.Context) ([]Licence, error)
|
||||
Licence(ctx context.Context, name string) (*Licence, error)
|
||||
LicenceForAccount(ctx context.Context, account string) (*Licence, error)
|
||||
SaveLicence(ctx context.Context, l Licence) error
|
||||
// Lease takes a lease for d, or answers false while another holder's is live.
|
||||
Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error)
|
||||
Unlease(ctx context.Context, key, holder string) error
|
||||
Bindings(ctx context.Context) ([]Binding, error)
|
||||
Binding(ctx context.Context, consumer string) (*Binding, error)
|
||||
// Bind binds (or switches) a consumer at the next generation.
|
||||
Bind(ctx context.Context, consumer, licence string) (Binding, error)
|
||||
Unbind(ctx context.Context, consumer string) (bool, error)
|
||||
// Advance gives every consumer of a licence a new generation: what a rotation is to them.
|
||||
Advance(ctx context.Context, licence string) ([]Binding, error)
|
||||
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
|
||||
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
|
||||
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
|
||||
Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error)
|
||||
Audit(ctx context.Context, what string, detail map[string]any) error
|
||||
Close()
|
||||
}
|
||||
|
||||
// MemoryStore is the tests' store.
|
||||
type MemoryStore struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
rows map[string]Licence
|
||||
binds map[string]Binding
|
||||
leases map[string]struct {
|
||||
holder string
|
||||
until time.Time
|
||||
}
|
||||
outcomes map[string]Outcome
|
||||
usage []UsageRow
|
||||
Audits []map[string]any
|
||||
generation int64
|
||||
}
|
||||
|
||||
// NewMemoryStore is an empty store whose leases age by now.
|
||||
func NewMemoryStore(now func() time.Time) *MemoryStore {
|
||||
return &MemoryStore{now: now, rows: map[string]Licence{}, binds: map[string]Binding{},
|
||||
leases: map[string]struct {
|
||||
holder string
|
||||
until time.Time
|
||||
}{}, outcomes: map[string]Outcome{}}
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Licences(context.Context) ([]Licence, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]Licence, 0, len(m.rows))
|
||||
for _, l := range m.rows {
|
||||
out = append(out, l)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Licence(_ context.Context, name string) (*Licence, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if l, ok := m.rows[name]; ok {
|
||||
return &l, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) LicenceForAccount(_ context.Context, account string) (*Licence, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, l := range m.rows {
|
||||
if l.AccountUUID == account {
|
||||
return &l, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) SaveLicence(_ context.Context, l Licence) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.rows[l.Name] = l
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if held, ok := m.leases[key]; ok && held.until.After(m.now()) && held.holder != holder {
|
||||
return false, nil
|
||||
}
|
||||
m.leases[key] = struct {
|
||||
holder string
|
||||
until time.Time
|
||||
}{holder, m.now().Add(d)}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.leases[key].holder == holder {
|
||||
delete(m.leases, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Bindings(context.Context) ([]Binding, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]Binding, 0, len(m.binds))
|
||||
for _, b := range m.binds {
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Binding(_ context.Context, consumer string) (*Binding, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if b, ok := m.binds[consumer]; ok {
|
||||
return &b, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Bind(_ context.Context, consumer, licence string) (Binding, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.generation++
|
||||
b := Binding{Consumer: consumer, Licence: licence, Generation: m.generation}
|
||||
m.binds[consumer] = b
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_, ok := m.binds[consumer]
|
||||
delete(m.binds, consumer)
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []Binding
|
||||
for c, b := range m.binds {
|
||||
if b.Licence != licence {
|
||||
continue
|
||||
}
|
||||
m.generation++
|
||||
b.Generation = m.generation
|
||||
m.binds[c] = b
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Outcome(_ context.Context, fp string) (Outcome, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.outcomes[fp], nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) RecordOutcome(_ context.Context, fp, _, _ string, o Outcome, _ string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.outcomes[fp] = o
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.usage = append(m.usage, UsageRow{Licence: licence, At: at, Reading: r})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Usage(_ context.Context, licence string, limit int) ([]UsageRow, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []UsageRow
|
||||
for i := len(m.usage) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
if licence == "" || m.usage[i].Licence == licence {
|
||||
out = append(out, m.usage[i])
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Audit(_ context.Context, what string, detail map[string]any) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d := map[string]any{"what": what}
|
||||
for k, v := range detail {
|
||||
d[k] = v
|
||||
}
|
||||
m.Audits = append(m.Audits, d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Close() {}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Against a real postgres, because the questions are the database's: does the schema apply twice, does a
|
||||
// lease refuse a second holder, does a generation only grow. Skipped unless one is named:
|
||||
//
|
||||
// docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
|
||||
// MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
|
||||
func TestTheStoreOnPostgres(t *testing.T) {
|
||||
url := os.Getenv("MESH_TEST_POSTGRES")
|
||||
if url == "" {
|
||||
t.Skip("MESH_TEST_POSTGRES unset")
|
||||
}
|
||||
ctx := context.Background()
|
||||
s, err := OpenPgStore(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
for _, table := range []string{"binding", "licence", "lease", "offered", "usage", "audit"} {
|
||||
_, _ = s.pool.Exec(ctx, "drop table if exists "+table+" cascade")
|
||||
}
|
||||
_, _ = s.pool.Exec(ctx, "drop sequence if exists binding_generation")
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := s.Migrate(ctx); err != nil {
|
||||
t.Fatalf("migration %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", Sealed: "v1.x.y",
|
||||
RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3}
|
||||
if err := s.SaveLicence(ctx, l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.Failures = 2
|
||||
_ = s.SaveLicence(ctx, l)
|
||||
if got, _ := s.LicenceForAccount(ctx, "u-1"); got == nil || got.Failures != 2 || got.OrganizationUUID != "" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if none, err := s.Licence(ctx, "nobody"); none != nil || err != nil {
|
||||
t.Fatalf("%v %v", none, err)
|
||||
}
|
||||
lease := func(holder string) bool {
|
||||
ok, err := s.Lease(ctx, "licence:a", holder, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
if !lease("one") || lease("two") || !lease("one") {
|
||||
t.Fatal("a lease did not refuse a second holder, or its own holder could not renew it")
|
||||
}
|
||||
_ = s.Unlease(ctx, "licence:a", "one")
|
||||
if !lease("two") {
|
||||
t.Fatal("a released lease was not taken")
|
||||
}
|
||||
b1, _ := s.Bind(ctx, "laptop", l.Name)
|
||||
adv, _ := s.Advance(ctx, l.Name)
|
||||
b3, _ := s.Bind(ctx, "laptop", l.Name)
|
||||
if len(adv) != 1 || !(b1.Generation < adv[0].Generation && adv[0].Generation < b3.Generation) {
|
||||
t.Fatalf("generations %d %v %d", b1.Generation, adv, b3.Generation)
|
||||
}
|
||||
_ = s.RecordOutcome(ctx, "sha256:x", "laptop", "u-1", Dead, "400")
|
||||
if o, _ := s.Outcome(ctx, "sha256:x"); o != Dead {
|
||||
t.Fatalf("outcome %q", o)
|
||||
}
|
||||
if o, _ := s.Outcome(ctx, "sha256:none"); o != "" {
|
||||
t.Fatalf("an unknown login is %q", o)
|
||||
}
|
||||
pct := 1.0
|
||||
_ = s.RecordUsage(ctx, l.Name, 5, UsageReading{SessionPct: &pct}, map[string]any{})
|
||||
if u, _ := s.Usage(ctx, "", 5); len(u) != 1 || *u[0].Reading.SessionPct != 1 {
|
||||
t.Fatalf("usage %v", u)
|
||||
}
|
||||
if err := s.Audit(ctx, "bound", map[string]any{"consumer": "laptop"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, _ := s.Unbind(ctx, "laptop"); !ok {
|
||||
t.Fatal("unbind")
|
||||
}
|
||||
all, _ := s.Licences(ctx)
|
||||
if len(all) != 1 || !strings.HasPrefix(all[0].Sealed, "v1.") {
|
||||
t.Fatalf("%v", all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
|
||||
prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1}
|
||||
in := int64(60)
|
||||
kept := NextGrant(prev, tokenResponse{AccessToken: "a2", ExpiresIn: &in}, 1000)
|
||||
if !kept.OK || kept.Grant.RefreshToken != "r" || kept.Grant.ExpiresAt != 61_000 {
|
||||
t.Fatalf("%+v", kept)
|
||||
}
|
||||
rotated := NextGrant(prev, tokenResponse{AccessToken: "a3", RefreshToken: "r2"}, 0)
|
||||
if rotated.Grant.RefreshToken != "r2" {
|
||||
t.Fatalf("%+v", rotated)
|
||||
}
|
||||
if NextGrant(prev, tokenResponse{}, 0).OK {
|
||||
t.Fatal("an answer with no access token was a grant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
|
||||
a, _ := NewCrypt("one key")
|
||||
b, _ := NewCrypt("another key")
|
||||
sealed := a.Seal(`{"refreshToken":"rt"}`)
|
||||
if strings.Contains(sealed, "rt") {
|
||||
t.Fatal("stored in the clear")
|
||||
}
|
||||
if got, err := a.Open(sealed); err != nil || got != `{"refreshToken":"rt"}` {
|
||||
t.Fatalf("%q %v", got, err)
|
||||
}
|
||||
if _, err := b.Open(sealed); err == nil {
|
||||
t.Fatal("opened with another key")
|
||||
}
|
||||
if _, err := NewCrypt(" "); err == nil {
|
||||
t.Fatal("an empty key was accepted")
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"privateKey": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VuBCIEIAi2NK/bN+p7cqYUwv/kz72TgLdmJUfOHCDZTrMpQzpS\n-----END PRIVATE KEY-----\n",
|
||||
"publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VuAyEARYvD/w+9ah0KWS9T9pd6Ea6CBymUE48vEVk983QPKyY=\n-----END PUBLIC KEY-----\n",
|
||||
"box": {
|
||||
"v": 1,
|
||||
"eph": "MCowBQYDK2VuAyEAIYlSGrJvF8qSjR1aTFWbEQM/NFbZXrarglN0aRLZZ0E=",
|
||||
"iv": "ABqT/hMukn6+xpjh",
|
||||
"tag": "POzmsWTPHSn9xLMMJJ9Akg==",
|
||||
"ct": "m2u0kuQ0PwrsGelM99Z5aBw6FCd6lFISUbwiK5TzzDw4ZrGtsHEvxytoIeFC"
|
||||
},
|
||||
"plaintext": "a grant sealed by the TypeScript agent module"
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
// The only file that talks to Anthropic (novox/hq ADR 0183): the token endpoint, which this module alone
|
||||
// calls — one rotation source — and the usage endpoint. Ported from the predecessor's manager, whose
|
||||
// client id and error handling were each earned by an incident.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The public Claude Code client's id: not a secret, and a hard-won constant — a metadata URL in its place
|
||||
// answers 400, which the predecessor once misdiagnosed as a dead grant.
|
||||
const clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
|
||||
func tokenEndpoint() string {
|
||||
if v := os.Getenv("MESH_ANTHROPIC_TOKEN_ENDPOINT"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "https://platform.claude.com/v1/oauth/token"
|
||||
}
|
||||
|
||||
func usageEndpoint() string {
|
||||
if v := os.Getenv("MESH_ANTHROPIC_USAGE_ENDPOINT"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "https://api.anthropic.com/api/oauth/usage"
|
||||
}
|
||||
|
||||
// FullGrant is a subscription's grant as this module keeps it: what the agent's credentials file calls
|
||||
// `claudeAiOauth`.
|
||||
type FullGrant struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
RefreshTokenExpiresAt *int64 `json:"refreshTokenExpiresAt,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
SubscriptionType string `json:"subscriptionType,omitempty"`
|
||||
RateLimitTier string `json:"rateLimitTier,omitempty"`
|
||||
}
|
||||
|
||||
// Refreshed is what a refresh came to: the next grant and the account the vendor answered for, or why not.
|
||||
type Refreshed struct {
|
||||
OK bool
|
||||
Grant FullGrant
|
||||
Account string // empty when the vendor named none
|
||||
Status int
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Vendor is the vendor as the manager reaches it; a test stubs it.
|
||||
type Vendor interface {
|
||||
Refresh(ctx context.Context, g FullGrant) Refreshed
|
||||
Usage(ctx context.Context, accessToken string) (map[string]any, error)
|
||||
}
|
||||
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn *int64 `json:"expires_in"`
|
||||
RefreshTokenExpiresIn *int64 `json:"refresh_token_expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
Scopes []string `json:"scopes"`
|
||||
SubscriptionType string `json:"subscription_type"`
|
||||
Account *struct {
|
||||
UUID string `json:"uuid"`
|
||||
} `json:"account"`
|
||||
}
|
||||
|
||||
// NextGrant is the grant a refresh answered, laid over the one refreshed: a refresh token the vendor did
|
||||
// not rotate is kept, so a rotating vendor and one that does not are both handled.
|
||||
func NextGrant(prev FullGrant, r tokenResponse, nowMs int64) Refreshed {
|
||||
if r.AccessToken == "" {
|
||||
return Refreshed{Status: 200, Reason: "the vendor answered without an access token"}
|
||||
}
|
||||
g := prev
|
||||
g.AccessToken = r.AccessToken
|
||||
if r.RefreshToken != "" {
|
||||
g.RefreshToken = r.RefreshToken
|
||||
}
|
||||
if r.ExpiresIn != nil {
|
||||
g.ExpiresAt = nowMs + *r.ExpiresIn*1000
|
||||
}
|
||||
if r.RefreshTokenExpiresIn != nil {
|
||||
v := nowMs + *r.RefreshTokenExpiresIn*1000
|
||||
g.RefreshTokenExpiresAt = &v
|
||||
}
|
||||
if len(r.Scopes) > 0 {
|
||||
g.Scopes = r.Scopes
|
||||
} else if r.Scope != "" {
|
||||
g.Scopes = strings.Fields(r.Scope)
|
||||
}
|
||||
if r.SubscriptionType != "" {
|
||||
g.SubscriptionType = r.SubscriptionType
|
||||
}
|
||||
out := Refreshed{OK: true, Grant: g}
|
||||
if r.Account != nil {
|
||||
out.Account = r.Account.UUID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type liveVendor struct{ client *http.Client }
|
||||
|
||||
// LiveVendor is the vendor over the network.
|
||||
func LiveVendor() Vendor { return liveVendor{client: &http.Client{Timeout: 30 * time.Second}} }
|
||||
|
||||
func (v liveVendor) Refresh(ctx context.Context, g FullGrant) Refreshed {
|
||||
form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {g.RefreshToken}, "client_id": {clientID}}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint(), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Refreshed{Reason: err.Error()}
|
||||
}
|
||||
req.Header.Set("content-type", "application/x-www-form-urlencoded")
|
||||
resp, err := v.client.Do(req)
|
||||
if err != nil {
|
||||
return Refreshed{Reason: "the token endpoint did not answer: " + err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
// The body, never only the status: a malformed request and a revoked grant both answer 400.
|
||||
reason := string(body)
|
||||
if len(reason) > 400 {
|
||||
reason = reason[:400]
|
||||
}
|
||||
return Refreshed{Status: resp.StatusCode, Reason: reason}
|
||||
}
|
||||
var r tokenResponse
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return Refreshed{Status: resp.StatusCode, Reason: "the vendor's answer is not JSON"}
|
||||
}
|
||||
return NextGrant(g, r, time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("authorization", "Bearer "+accessToken)
|
||||
resp, err := v.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, fmt.Errorf("the usage endpoint answered %d", resp.StatusCode)
|
||||
}
|
||||
var out map[string]any
|
||||
return out, json.NewDecoder(resp.Body).Decode(&out)
|
||||
}
|
||||
|
||||
// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows.
|
||||
type UsageReading struct {
|
||||
SessionPct *float64 `json:"sessionPct"`
|
||||
SessionResetsAt string `json:"sessionResetsAt,omitempty"`
|
||||
WeeklyPct *float64 `json:"weeklyPct"`
|
||||
SonnetPct *float64 `json:"sonnetPct"`
|
||||
}
|
||||
|
||||
// FlattenUsage reads the windows the predecessor read.
|
||||
func FlattenUsage(u map[string]any) UsageReading {
|
||||
window := func(k string) (*float64, string) {
|
||||
w, ok := u[k].(map[string]any)
|
||||
if !ok {
|
||||
return nil, ""
|
||||
}
|
||||
pct, ok := w["utilization"].(float64)
|
||||
resets, _ := w["resets_at"].(string)
|
||||
if !ok {
|
||||
return nil, resets
|
||||
}
|
||||
return &pct, resets
|
||||
}
|
||||
s, resets := window("five_hour")
|
||||
w, _ := window("seven_day")
|
||||
so, _ := window("seven_day_sonnet")
|
||||
return UsageReading{SessionPct: s, SessionResetsAt: resets, WeeklyPct: w, SonnetPct: so}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
module claude-licence-manager
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"module": "claude-licence-manager",
|
||||
"version": "1",
|
||||
"slug": "licmgr",
|
||||
"requires": [
|
||||
"postgres-database",
|
||||
"secret"
|
||||
],
|
||||
"contributes": {
|
||||
"postgres-database": {
|
||||
"name": "claude_licences"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "${dir:state}/database.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "${dir:state}/database.secret",
|
||||
"secret": {
|
||||
"grant-key": "${dir:state}/grant.key"
|
||||
}
|
||||
},
|
||||
"seats": [
|
||||
{
|
||||
"name": "anthropic-licence-manager",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"licences",
|
||||
"bindings",
|
||||
"bind",
|
||||
"switch",
|
||||
"release",
|
||||
"refresh",
|
||||
"usage",
|
||||
"adopt",
|
||||
"current"
|
||||
]
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "anthropic-licence-manager",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"licences",
|
||||
"bindings",
|
||||
"bind",
|
||||
"switch",
|
||||
"release",
|
||||
"refresh",
|
||||
"usage",
|
||||
"adopt",
|
||||
"current"
|
||||
]
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"licence.adopted",
|
||||
"licence.refused",
|
||||
"licence.failing",
|
||||
"usage.read"
|
||||
],
|
||||
"state": [
|
||||
"bindings"
|
||||
],
|
||||
"reads": [
|
||||
"claude-code.holdings"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "database-url",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/database.url",
|
||||
"mode": "0600",
|
||||
"content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"merge": "json",
|
||||
"content": "{\n \"cadence_minutes\": 240,\n \"floor_minutes\": 60,\n \"failures_to_notify\": 3,\n \"cooldown_hours\": 24,\n \"refresh_warn_days\": 3\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "prepare",
|
||||
"type": "process",
|
||||
"name": "claude-licence-manager-prepare",
|
||||
"artifact": "code",
|
||||
"run": [
|
||||
"./claude-licence-manager",
|
||||
"prepare"
|
||||
],
|
||||
"run-once": true,
|
||||
"env": {
|
||||
"DATABASE_URL_FILE": "${dir:state}/database.url"
|
||||
},
|
||||
"restart-on": [
|
||||
"database-url"
|
||||
]
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/claude-licence-manager",
|
||||
"binary": "claude-licence-manager",
|
||||
"loads": [
|
||||
"claude-licence-manager"
|
||||
],
|
||||
"env": {
|
||||
"DATABASE_URL_FILE": "${dir:state}/database.url",
|
||||
"MESH_LICENCE_STATE": "${dir:state}",
|
||||
"MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key",
|
||||
"MESH_LICENCE_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# clipmenu
|
||||
|
||||
The clipboard manager as a module (novox/hq ADR 0208, research 026/04).
|
||||
|
||||
- Installs `clipmenu` from the official repositories. It brings `clipnotify`, `xsel`, `xdotool` and
|
||||
`dmenu` as its own dependencies.
|
||||
- Claims the mesh's `node-clipboard` seat and serves its verbs `history` and `copy`. Requires
|
||||
`x11-display` on its own machine.
|
||||
- **Declares `rofi-greenclip` absent** (ADR 0180). This module replaces it.
|
||||
- Starts `clipmenud` **once per session**, from the session's start (the `xinitrc` slot `normal`).
|
||||
It is not a user unit as well. Its packaged unit needs user-scoped units (mesh-host #72, not
|
||||
merged), and the session start alone is one starter.
|
||||
- Binds `$mod+period` to `clipmenu`, as its own i3 drop-in (`50-clipmenu.conf`). clipmenu shows the
|
||||
history through `dmenu`, the seat command of `node-launcher`, so it looks like every other menu.
|
||||
- Its settings are environment contributions (ADR 0203), read by the daemon, the menu and the tools
|
||||
alike:
|
||||
- `CM_SELECTIONS=clipboard`: what was copied, not every highlighted word. The found greenclip did
|
||||
the same.
|
||||
- `CM_MAX_CLIPS=500`: how many clips are kept.
|
||||
- `CM_HISTLENGTH=15`: how many lines the menu shows.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `node-clipboard.history` | the history, newest first, each entry once with its id, first line, time, size and text (cut) |
|
||||
| `node-clipboard.copy` | put text on the clipboard; it enters the history like any copy |
|
||||
| `clipmenu_paste` | what the clipboard holds now |
|
||||
| `clipmenu_clear` | forget the history; the daemon's locks stay |
|
||||
| `clipmenu_delete` | forget one entry, by id or first line |
|
||||
|
||||
The history is read from clipmenu's own store, in the account's runtime directory, under clipmenu's
|
||||
own lock, so a copy arriving meanwhile is neither lost nor half-written. `copy` hands the text to an
|
||||
owner (`xsel`) under the account's service manager. As a child of the tools runtime, the clipboard
|
||||
would empty whenever the runtime restarted.
|
||||
|
||||
## What it improves on what was found
|
||||
|
||||
- **No AUR package.** greenclip came from the user repository. clipmenu is in the official one.
|
||||
- **Started once.** greenclip was started by the window manager on both workstations, and on the
|
||||
desktop by an enabled user unit as well.
|
||||
- **No absolute home path** in any configuration. greenclip's named one.
|
||||
- **The history does not outlive a reboot.** greenclip kept it in `~/.cache`, so every password ever
|
||||
copied stayed on disk. clipmenu keeps it in the runtime directory, which is memory.
|
||||
- **One menu.** The history appears in the launcher's own menu, through the seat's `dmenu` command,
|
||||
instead of a theme from a cloned theme repository.
|
||||
|
||||
## What it leaves as found
|
||||
|
||||
- `~/.config/greenclip.toml` and greenclip's history, `~/.cache/greenclip.history`.
|
||||
- On the desktop: greenclip's enabled user unit link
|
||||
(`~/.config/systemd/user/default.target.wants/greenclip.service`). It dangles once the package is
|
||||
gone.
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
1. After the first push, delete `~/.config/greenclip.toml` and `~/.cache/greenclip.history`.
|
||||
2. On the desktop: `systemctl --user disable greenclip.service`, before the push if you can. The
|
||||
package's removal takes the unit file with it.
|
||||
3. Until the `i3` module carries the main configuration, the found `exec --no-startup-id greenclip
|
||||
daemon` and `$mod+period` lines stay in `~/.config/i3/config`. i3 reports `$mod+period` as bound
|
||||
twice. The `i3` module's configuration carries neither.
|
||||
|
||||
## Blockers
|
||||
|
||||
- `node-clipboard`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows
|
||||
them, `mctl` reads them as unknown.
|
||||
- `CM_*` reach the session through `node-env` (ADR 0203), so the account's environment module must
|
||||
be assigned too. Without it clipmenu runs on its defaults: both selections, 1000 clips, 8 lines.
|
||||
@@ -0,0 +1,97 @@
|
||||
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
|
||||
// The same in every desktop module that carries it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// text is a string argument, trimmed; required says an empty one is refused.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is a string, not %T", key, v)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" && required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument within [least, most], or def when absent.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s is a number, not %T", key, v)
|
||||
}
|
||||
}
|
||||
if f != math.Trunc(f) {
|
||||
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least || n > most {
|
||||
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// flag is a boolean argument, or def when absent.
|
||||
func flag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false, not %T", key, v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// texts is a list-of-strings argument.
|
||||
func texts(args map[string]any, key string) ([]string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
if ss, isStrings := v.([]string); isStrings {
|
||||
return ss, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for i, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
|
||||
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
|
||||
n, err := whole(args, key, def, 1, most)
|
||||
return time.Duration(n) * time.Second, err
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
mostCopy = 1 << 20
|
||||
mostPaste = 64 << 10
|
||||
// majorVersion is clipmenu's store layout: <dir>/clipmenu.<major>.<user>/.
|
||||
majorVersion = 6
|
||||
)
|
||||
|
||||
// account is the user clipmenu's store is named for.
|
||||
func account() string {
|
||||
for _, k := range []string{"USER", "MESH_OPERATOR_ACCOUNT", "LOGNAME"} {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if u, err := user.Current(); err == nil {
|
||||
return u.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// storeDir is where clipmenud keeps the history: CM_DIR, else the account's runtime directory.
|
||||
func storeDir(s Session) (string, error) {
|
||||
base := os.Getenv("CM_DIR")
|
||||
if base == "" {
|
||||
base = s.RuntimeDir
|
||||
}
|
||||
if base == "" {
|
||||
return "", fmt.Errorf("%w: the account's runtime directory, where the clipboard history lives, is missing (the account is not logged in)", ErrNoBus)
|
||||
}
|
||||
return filepath.Join(base, fmt.Sprintf("clipmenu.%d.%s", majorVersion, account())), nil
|
||||
}
|
||||
|
||||
// cksum is POSIX cksum(1) of data: clipmenu names each entry's file by the cksum of its first line
|
||||
// followed by a newline, as "<crc> <length>".
|
||||
func cksum(data []byte) string {
|
||||
var crc uint32
|
||||
step := func(b byte) {
|
||||
crc ^= uint32(b) << 24
|
||||
for i := 0; i < 8; i++ {
|
||||
if crc&0x80000000 != 0 {
|
||||
crc = crc<<1 ^ 0x04C11DB7
|
||||
} else {
|
||||
crc <<= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, b := range data {
|
||||
step(b)
|
||||
}
|
||||
for n := len(data); n != 0; n >>= 8 {
|
||||
step(byte(n))
|
||||
}
|
||||
return fmt.Sprintf("%d %d", ^crc, len(data))
|
||||
}
|
||||
|
||||
func entryID(line string) string { return cksum([]byte(line + "\n")) }
|
||||
|
||||
// Entry is one clip in the history.
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
Line string `json:"line"`
|
||||
At string `json:"at"`
|
||||
Bytes int `json:"bytes"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
at int64
|
||||
}
|
||||
|
||||
// HistoryResult is what node-clipboard.history answers.
|
||||
type HistoryResult struct {
|
||||
Collecting bool `json:"collecting"`
|
||||
Store string `json:"store"`
|
||||
Total int `json:"total"`
|
||||
Entries []Entry `json:"entries"`
|
||||
}
|
||||
|
||||
// readStore reads clipmenu's line cache: one "<nanoseconds> <first line>" per copy, oldest first, a
|
||||
// line repeated when the same thing was copied again. The newest copy of each line wins.
|
||||
func readStore(dir string) ([]Entry, error) {
|
||||
f, err := os.Open(filepath.Join(dir, "line_cache"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []Entry{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
latest := map[string]int64{}
|
||||
scan := bufio.NewScanner(f)
|
||||
scan.Buffer(make([]byte, 64<<10), 1<<20)
|
||||
for scan.Scan() {
|
||||
stamp, line, ok := strings.Cut(scan.Text(), " ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ns, err := strconv.ParseInt(stamp, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ns >= latest[line] {
|
||||
latest[line] = ns
|
||||
}
|
||||
}
|
||||
out := make([]Entry, 0, len(latest))
|
||||
for line, ns := range latest {
|
||||
out = append(out, Entry{ID: entryID(line), Line: line, at: ns, At: time.Unix(0, ns).Format(time.RFC3339)})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].at > out[j].at })
|
||||
return out, scan.Err()
|
||||
}
|
||||
|
||||
// History is the clipboard's history, newest first.
|
||||
func History(limit, maxBytes int) (HistoryResult, error) {
|
||||
dir, err := storeDir(findEnvironment())
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
entries, err := readStore(dir)
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
out := HistoryResult{Collecting: len(processesOf("clipmenud")) > 0, Store: dir, Total: len(entries), Entries: []Entry{}}
|
||||
for i, e := range entries {
|
||||
if i == limit {
|
||||
break
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(dir, e.ID)); err == nil {
|
||||
e.Bytes = int(info.Size())
|
||||
if maxBytes > 0 {
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, e.ID))
|
||||
if len(raw) > maxBytes {
|
||||
raw, e.Truncated = raw[:maxBytes], true
|
||||
}
|
||||
e.Text = string(raw)
|
||||
}
|
||||
}
|
||||
out.Entries = append(out.Entries, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CopyResult is what node-clipboard.copy answers.
|
||||
type CopyResult struct {
|
||||
Bytes int `json:"bytes"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
|
||||
// Copy puts text on the clipboard. The clipboard is owned by a process until another copies, so the
|
||||
// owner (xsel) runs under the account's service manager, not as a child of this tool.
|
||||
func Copy(text string) (CopyResult, error) {
|
||||
if len(text) == 0 {
|
||||
return CopyResult{}, errors.New("text is empty; to empty the clipboard's history, clipmenu_clear")
|
||||
}
|
||||
if len(text) > mostCopy {
|
||||
return CopyResult{}, fmt.Errorf("%d bytes; the clipboard takes at most %d here", len(text), mostCopy)
|
||||
}
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
return CopyResult{}, err
|
||||
}
|
||||
if s.RuntimeDir == "" {
|
||||
return CopyResult{}, fmt.Errorf("%w: no runtime directory to hand the text over in", ErrNoBus)
|
||||
}
|
||||
// Handed over in a file only the account can read, which the owner reads and removes.
|
||||
f, err := os.CreateTemp(s.RuntimeDir, "clipmenu-copy-")
|
||||
if err != nil {
|
||||
return CopyResult{}, err
|
||||
}
|
||||
if _, err := f.WriteString(text); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return CopyResult{}, err
|
||||
}
|
||||
f.Close()
|
||||
unit := uniqueUnit("clipmenu-copy")
|
||||
script := `xsel --nodetach --input --clipboard < "$0" & sleep 1; rm -f "$0"; wait`
|
||||
if err := s.detach(unit, "/bin/sh", "-c", script, f.Name()); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return CopyResult{}, err
|
||||
}
|
||||
return CopyResult{Bytes: len(text), Unit: unit + ".service"}, nil
|
||||
}
|
||||
|
||||
// PasteResult is what clipmenu_paste answers.
|
||||
type PasteResult struct {
|
||||
Text string `json:"text"`
|
||||
Bytes int `json:"bytes"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// Paste reads the clipboard now.
|
||||
func Paste() (PasteResult, error) {
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
return PasteResult{}, err
|
||||
}
|
||||
r, err := s.run(5*time.Second, "", "xsel", "--output", "--clipboard")
|
||||
if err != nil {
|
||||
return PasteResult{}, err
|
||||
}
|
||||
if r.Code != 0 {
|
||||
return PasteResult{}, fmt.Errorf("xsel: %s", strings.TrimSpace(r.Stderr))
|
||||
}
|
||||
out := PasteResult{Text: r.Stdout, Bytes: len(r.Stdout), Truncated: r.Truncated}
|
||||
if len(out.Text) > mostPaste {
|
||||
out.Text, out.Truncated = out.Text[:mostPaste], true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ChangeResult is what clear and delete answer.
|
||||
type ChangeResult struct {
|
||||
Removed int `json:"removed"`
|
||||
Remaining int `json:"remaining"`
|
||||
}
|
||||
|
||||
// withStoreLock holds clipmenu's own lock on its store, the one clipmenud and clipdel take, while
|
||||
// change runs, so a copy arriving meanwhile is neither lost nor half-written.
|
||||
func withStoreLock(dir string, change func() error) error {
|
||||
lock, err := os.OpenFile(filepath.Join(dir, "lock"), os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer lock.Close()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("the clipboard store is locked by clipmenud and did not come free within 2s")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
|
||||
return change()
|
||||
}
|
||||
|
||||
func storeOf() (string, error) {
|
||||
dir, err := storeDir(findEnvironment())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("there is no clipboard history at %s: clipmenud has not run in this login", dir)
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// Clear forgets every entry and its text, keeping the store and its locks (clipdel's own clear
|
||||
// removes the directory, the daemon's lock with it).
|
||||
func Clear() (ChangeResult, error) {
|
||||
dir, err := storeOf()
|
||||
if err != nil {
|
||||
return ChangeResult{}, err
|
||||
}
|
||||
var out ChangeResult
|
||||
err = withStoreLock(dir, func() error {
|
||||
entries, err := readStore(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Removed = len(entries)
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range files {
|
||||
switch f.Name() {
|
||||
case "lock", "session_lock", "line_cache":
|
||||
continue
|
||||
}
|
||||
if f.Type().IsRegular() {
|
||||
if err := os.Remove(filepath.Join(dir, f.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "line_cache"), nil, 0o600)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Delete forgets one entry, by id or by its first line.
|
||||
func Delete(id, line string) (ChangeResult, error) {
|
||||
if (id == "") == (line == "") {
|
||||
return ChangeResult{}, errors.New("give the entry's id or its line, one of the two")
|
||||
}
|
||||
dir, err := storeOf()
|
||||
if err != nil {
|
||||
return ChangeResult{}, err
|
||||
}
|
||||
var out ChangeResult
|
||||
err = withStoreLock(dir, func() error {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "line_cache"))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
var kept []string
|
||||
for _, l := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") {
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
_, text, _ := strings.Cut(l, " ")
|
||||
if text == line || (id != "" && entryID(text) == id) {
|
||||
out.Removed++
|
||||
_ = os.Remove(filepath.Join(dir, entryID(text)))
|
||||
continue
|
||||
}
|
||||
kept = append(kept, l)
|
||||
}
|
||||
if out.Removed == 0 {
|
||||
return fmt.Errorf("no entry %s%s in the clipboard history", id, line)
|
||||
}
|
||||
content := strings.Join(kept, "\n")
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
tmp := filepath.Join(dir, ".line_cache.mesh")
|
||||
if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, filepath.Join(dir, "line_cache"))
|
||||
})
|
||||
if err != nil {
|
||||
return ChangeResult{}, err
|
||||
}
|
||||
entries, _ := readStore(dir)
|
||||
out.Remaining = len(entries)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const nobody = 4194400
|
||||
|
||||
func TestEntryIdsAreWhatClipmenuNamesItsFiles(t *testing.T) {
|
||||
for _, line := range []string{"hello", "", "two words (3 lines)", "ünïcode ✓", strings.Repeat("x", 300)} {
|
||||
out, err := exec.Command("bash", "-c", `cksum <<< "$1"`, "_", line).Output()
|
||||
if err != nil {
|
||||
t.Skip("bash or cksum is missing here")
|
||||
}
|
||||
if got, want := entryID(line), strings.TrimSpace(string(out)); got != want {
|
||||
t.Errorf("%q: %s, cksum says %s", line, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// store makes clipmenu's store as clipmenud leaves it, for the account the tools run as.
|
||||
func store(t *testing.T, clips map[string]string, order ...string) string {
|
||||
t.Helper()
|
||||
fakeMachine(t)
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
|
||||
t.Setenv("USER", "op")
|
||||
t.Setenv("CM_DIR", "")
|
||||
dir := filepath.Join(runtime, "clipmenu.6.op")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cache strings.Builder
|
||||
for i, line := range order {
|
||||
cache.WriteString(strconv.FormatInt(1_700_000_000_000_000_000+int64(i)*1_000_000_000, 10) + " " + line + "\n")
|
||||
if err := os.WriteFile(filepath.Join(dir, entryID(line)), []byte(clips[line]), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "line_cache"), []byte(cache.String()), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestTheHistoryIsNewestFirstOnceEachWithItsText(t *testing.T) {
|
||||
store(t, map[string]string{"first": "first", "second (2 lines)": "second\nline two"}, "first", "second (2 lines)", "first")
|
||||
fakeProcess(t, nobody, "clipmenud")
|
||||
h, err := History(10, 4096)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !h.Collecting || h.Total != 2 || h.Entries[0].Line != "first" || h.Entries[1].Text != "second\nline two" || h.Entries[1].Bytes != 15 {
|
||||
t.Fatalf("%+v", h)
|
||||
}
|
||||
if h, _ := History(1, 3); len(h.Entries) != 1 || h.Entries[0].Text != "fir" || !h.Entries[0].Truncated {
|
||||
t.Fatalf("limited and cut: %+v", h)
|
||||
}
|
||||
if h, _ := History(10, 0); h.Entries[0].Text != "" || h.Entries[0].Bytes != 5 {
|
||||
t.Fatalf("without text: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEntryIsDeletedByIdOrLineWithItsText(t *testing.T) {
|
||||
dir := store(t, map[string]string{"a": "a", "b": "b", "c": "c"}, "a", "b", "c", "a")
|
||||
r, err := Delete(entryID("a"), "")
|
||||
if err != nil || r.Removed != 2 || r.Remaining != 2 {
|
||||
t.Fatalf("by id, both copies: %+v, %v", r, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, entryID("a"))); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("the text stayed")
|
||||
}
|
||||
if r, err := Delete("", "b"); err != nil || r.Removed != 1 || r.Remaining != 1 {
|
||||
t.Fatalf("by line: %+v, %v", r, err)
|
||||
}
|
||||
if _, err := Delete("", "zzz"); err == nil {
|
||||
t.Fatal("a missing entry was reported deleted")
|
||||
}
|
||||
if _, err := Delete("x", "y"); err == nil {
|
||||
t.Fatal("both an id and a line were accepted")
|
||||
}
|
||||
cache, _ := os.ReadFile(filepath.Join(dir, "line_cache"))
|
||||
if !strings.HasSuffix(string(cache), " c\n") || strings.Count(string(cache), "\n") != 1 {
|
||||
t.Fatalf("line cache: %q", cache)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) {
|
||||
dir := store(t, map[string]string{"a": "a", "b": "b"}, "a", "b")
|
||||
if err := os.WriteFile(filepath.Join(dir, "session_lock"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := Clear()
|
||||
if err != nil || r.Removed != 2 {
|
||||
t.Fatalf("%+v, %v", r, err)
|
||||
}
|
||||
left, _ := os.ReadDir(dir)
|
||||
var names []string
|
||||
for _, f := range left {
|
||||
names = append(names, f.Name())
|
||||
}
|
||||
if strings.Join(names, ",") != "line_cache,lock,session_lock" {
|
||||
t.Fatalf("left: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) {
|
||||
store(t, nil)
|
||||
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
|
||||
bin := fakeBinaries(t, map[string]string{"systemctl": "true", "systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`})
|
||||
t.Setenv("LOG", filepath.Join(bin, "log"))
|
||||
r, err := Copy("secret-free text")
|
||||
if err != nil || r.Bytes != 16 || !strings.HasPrefix(r.Unit, "clipmenu-copy-") {
|
||||
t.Fatalf("%+v, %v", r, err)
|
||||
}
|
||||
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
|
||||
if !strings.Contains(string(asked), "--setenv=DISPLAY=:1 -- /bin/sh -c xsel --nodetach --input --clipboard") {
|
||||
t.Fatalf("asked: %s", asked)
|
||||
}
|
||||
handed, _ := os.ReadFile(filepath.Join(bin, "log.text"))
|
||||
if string(handed) != "secret-free text" {
|
||||
t.Fatalf("handed over: %q", handed)
|
||||
}
|
||||
if _, err := Copy(""); err == nil {
|
||||
t.Fatal("empty text was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
if _, err := Paste(); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
|
||||
fakeBinaries(t, map[string]string{"xsel": `[ "$*" = "--output --clipboard" ] && printf 'on the clipboard'`})
|
||||
p, err := Paste()
|
||||
if err != nil || p.Text != "on the clipboard" || p.Bytes != 16 {
|
||||
t.Fatalf("%+v, %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutAStoreTheChangesSayWhy(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
|
||||
if err := os.MkdirAll(runtime, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("USER", "op")
|
||||
if _, err := Clear(); err == nil || !strings.Contains(err.Error(), "clipmenud has not run") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h, err := History(5, 0); err != nil || h.Total != 0 || h.Collecting {
|
||||
t.Fatalf("an empty history: %+v, %v", h, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// clipmenu's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): its implementation of
|
||||
// node-clipboard's verbs `history` and `copy`, and its own tools, served by the node's runtime as the
|
||||
// operator account. The history is read from clipmenu's own store in the account's runtime directory;
|
||||
// the clipboard itself is the X session's.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "node-clipboard.history",
|
||||
Description: "What the operator copied, newest first: each entry's id, its first line, when, its size " +
|
||||
"and its text (each cut at max_bytes). Whether the clipboard daemon is collecting.",
|
||||
Input: map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"},
|
||||
"max_bytes": map[string]any{"type": "integer", "description": "cut each entry's text at this many bytes; 0 leaves the text out (default 4096, at most 65536)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
limit, err := whole(args, "limit", 20, 1, 500)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
most, err := whole(args, "max_bytes", 4096, 0, 65536)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return History(limit, most)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "node-clipboard.copy",
|
||||
Description: "Put text on the operator's clipboard, as if they had copied it; it enters the history " +
|
||||
"like any copy. Answers how many bytes.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": fmt.Sprintf("the text (at most %d bytes)", mostCopy)},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
t, ok := args["text"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("text is required, as a string")
|
||||
}
|
||||
return Copy(t)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "clipmenu_paste",
|
||||
Description: "What the operator's clipboard holds right now, as text (cut at 64 KiB, said in truncated).",
|
||||
Run: func(map[string]any) (any, error) { return Paste() },
|
||||
},
|
||||
{
|
||||
Name: "clipmenu_clear",
|
||||
Description: "Forget the whole clipboard history. What is on the clipboard now stays there.",
|
||||
Run: func(map[string]any) (any, error) { return Clear() },
|
||||
},
|
||||
{
|
||||
Name: "clipmenu_delete",
|
||||
Description: "Forget one entry of the clipboard history, by its id as the history answers it, or by " +
|
||||
"its first line exactly.",
|
||||
Input: map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "the entry's id"},
|
||||
"line": map[string]any{"type": "string", "description": "the entry's first line, exactly"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
id, err := text(args, "id", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line, _ := args["line"].(string)
|
||||
return Delete(id, line)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
|
||||
// every desktop module that carries it.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []claim `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Environment *environment `json:"environment"`
|
||||
Shell []shellCode `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
type claim struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
}
|
||||
|
||||
type environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
Path []map[string]any `json:"path"`
|
||||
}
|
||||
|
||||
type shellCode struct {
|
||||
For string `json:"for"`
|
||||
Slot string `json:"slot"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(t *testing.T, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %q", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m manifest) packages() (present, absent []string) {
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" {
|
||||
if r["absent"] == true {
|
||||
absent = append(absent, r["package"].(string))
|
||||
} else {
|
||||
present = append(present, r["package"].(string))
|
||||
}
|
||||
}
|
||||
}
|
||||
return present, absent
|
||||
}
|
||||
|
||||
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
|
||||
// the readable file in the repository is what the machine gets.
|
||||
func (m manifest) sameAsSource(t *testing.T, id, source string) {
|
||||
t.Helper()
|
||||
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := m.resource(t, id)
|
||||
if r["type"] != "file" {
|
||||
t.Fatalf("%s is a %v, not a file", id, r["type"])
|
||||
}
|
||||
if got, _ := r["content"].(string); got != string(want) {
|
||||
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
|
||||
}
|
||||
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
|
||||
t.Fatalf("%s under the home is the account's", id)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
|
||||
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
|
||||
func checkTheToolsAgree(t *testing.T, m manifest) {
|
||||
t.Helper()
|
||||
own, seat := map[string]bool{}, map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
if strings.Contains(tool.Name, ".") {
|
||||
seat[tool.Name] = true
|
||||
} else {
|
||||
own[tool.Name] = true
|
||||
}
|
||||
if strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s has no description", tool.Name)
|
||||
}
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, name := range m.Tools {
|
||||
listed[name] = true
|
||||
if !own[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
}
|
||||
for name := range own {
|
||||
if !listed[name] {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
|
||||
t.Errorf("%s is not prefixed with the module's name", name)
|
||||
}
|
||||
}
|
||||
promised := map[string]bool{}
|
||||
for _, c := range m.Claims {
|
||||
for _, verb := range c.Serves {
|
||||
promised[c.Name+"."+verb] = true
|
||||
if !seat[c.Name+"."+verb] {
|
||||
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range seat {
|
||||
if !promised[name] {
|
||||
t.Errorf("the bundle serves %s, which no claim promises", name)
|
||||
}
|
||||
}
|
||||
var bundle map[string]any
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "bundle" {
|
||||
bundle = a
|
||||
}
|
||||
}
|
||||
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
|
||||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", bundle)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
|
||||
func checkNoSecretsOrInstallationNames(t *testing.T) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := strings.ToLower(string(raw))
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// clipmenu's shape (novox/hq ADR 0208, research 026/04): it claims node-clipboard serving history
|
||||
// and copy, requires the X display on its own machine, replaces greenclip, starts its daemon once
|
||||
// from the session's start, and binds its menu as an i3 drop-in through the launcher's dmenu command.
|
||||
|
||||
func TestItClaimsTheClipboardSeatServingHistoryAndCopy(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if m.Module != "clipmenu" || m.Seats != nil {
|
||||
t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-clipboard", Scope: "node", Serves: []string{"history", "copy"}}}) {
|
||||
t.Fatalf("claims: %+v", m.Claims)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
|
||||
t.Fatalf("requires: %v", m.Requires)
|
||||
}
|
||||
present, absent := m.packages()
|
||||
if !reflect.DeepEqual(present, []string{"clipmenu"}) || !reflect.DeepEqual(absent, []string{"rofi-greenclip"}) {
|
||||
t.Fatalf("packages: %v, absent %v", present, absent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDaemonStartsOnceFromTheSessionsStart(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
code := m.Shell[0].Code
|
||||
if strings.Count(code, "\nclipmenud &\n") != 1 || strings.Contains(code, "greenclip") {
|
||||
t.Fatalf("%q", code)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "service" || r["type"] == "process" {
|
||||
t.Fatalf("a second start: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItsSettingsAreEnvironmentAndItsMenuIsTheLaunchersDmenu(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if !reflect.DeepEqual(m.Environment.Variables, map[string]string{"CM_SELECTIONS": "clipboard", "CM_MAX_CLIPS": "500", "CM_HISTLENGTH": "15"}) {
|
||||
t.Fatalf("%v", m.Environment.Variables)
|
||||
}
|
||||
if _, set := m.Environment.Variables["CM_LAUNCHER"]; set {
|
||||
t.Fatal("the launcher is clipmenu's default, dmenu: the seat's command")
|
||||
}
|
||||
m.sameAsSource(t, "i3-bindings", "files/i3/50-clipmenu.conf")
|
||||
if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+period exec --no-startup-id clipmenu") {
|
||||
t.Fatalf("%s", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
checkTheToolsAgree(t, m)
|
||||
checkNoSecretsOrInstallationNames(t)
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// The operator's graphical session, as a tool the node's runtime runs finds it (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment — no DISPLAY, no XAUTHORITY, no session bus. A tool that
|
||||
// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of
|
||||
// the account that is part of the session (the window manager first), the same thing `loginctl` and
|
||||
// a person's own shell would point at, and says where it found them.
|
||||
//
|
||||
// Long-lived programs a tool starts go to the account's own service manager through `systemd-run
|
||||
// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties
|
||||
// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would
|
||||
// die with it.
|
||||
//
|
||||
// This file is the same in every desktop module that carries it; it moves into the Go SDK once a
|
||||
// second consumer outside the desktop wants it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Where the session is looked for. Variables so a test can point them at a fake tree.
|
||||
var (
|
||||
procRoot = "/proc"
|
||||
runUserDir = "/run/user"
|
||||
x11Sockets = "/tmp/.X11-unix"
|
||||
)
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first: the window
|
||||
// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"}
|
||||
|
||||
// sessionKeys are the variables a session carries that a tool hands on to what it runs.
|
||||
var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
|
||||
"XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"}
|
||||
|
||||
// Session is what a tool needs to reach the operator's desktop.
|
||||
type Session struct {
|
||||
UID int `json:"uid"`
|
||||
Display string `json:"display,omitempty"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Wayland string `json:"wayland_display,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
I3Sock string `json:"i3sock,omitempty"`
|
||||
// From says where the values were found: the tool's own environment, a process, or the socket.
|
||||
From string `json:"from"`
|
||||
}
|
||||
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
var ErrNoSession = errors.New("no graphical session")
|
||||
|
||||
// ErrTimedOut is what run answers for a command ended because it ran past its time.
|
||||
var ErrTimedOut = errors.New("timed out")
|
||||
|
||||
// ErrNoBus is answered by a tool that needs the session bus when the account has none.
|
||||
var ErrNoBus = errors.New("no session bus")
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
// findSession finds the graphical session of the account this tool runs as, or answers
|
||||
// ErrNoSession with what it looked at.
|
||||
func findSession() (Session, error) {
|
||||
s := findEnvironment()
|
||||
if s.Display == "" && s.Wayland == "" {
|
||||
return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+
|
||||
"or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// findBus finds the account's session bus, which a logged-in account has whether or not a desktop
|
||||
// is running.
|
||||
func findBus() (Session, error) {
|
||||
s := findEnvironment()
|
||||
if s.Bus == "" {
|
||||
return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+
|
||||
"(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus"))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func findEnvironment() Session {
|
||||
uid := os.Getuid()
|
||||
s := Session{UID: uid}
|
||||
own := map[string]string{}
|
||||
for _, k := range sessionKeys {
|
||||
own[k] = os.Getenv(k)
|
||||
}
|
||||
if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" {
|
||||
s.fill(own)
|
||||
s.From = "the tool's own environment"
|
||||
} else if pid, comm, env, ok := sessionProcess(uid); ok {
|
||||
s.fill(env)
|
||||
s.From = fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
} else if display, ok := lonelyX11Socket(); ok {
|
||||
if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) {
|
||||
s.Display, s.XAuthority = display, a
|
||||
s.From = "the X server socket and the account's ~/.Xauthority"
|
||||
}
|
||||
s.fill(own)
|
||||
} else {
|
||||
s.fill(own)
|
||||
s.From = "nothing: no session found"
|
||||
}
|
||||
// The bus and the runtime directory are the account's, whether or not the process named them.
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(uid))
|
||||
if s.RuntimeDir == "" && exists(runtime) {
|
||||
s.RuntimeDir = runtime
|
||||
}
|
||||
if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Session) fill(env map[string]string) {
|
||||
set := func(dst *string, key string) {
|
||||
if *dst == "" {
|
||||
*dst = env[key]
|
||||
}
|
||||
}
|
||||
set(&s.Display, "DISPLAY")
|
||||
set(&s.XAuthority, "XAUTHORITY")
|
||||
set(&s.Wayland, "WAYLAND_DISPLAY")
|
||||
set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS")
|
||||
set(&s.RuntimeDir, "XDG_RUNTIME_DIR")
|
||||
set(&s.SessionID, "XDG_SESSION_ID")
|
||||
set(&s.I3Sock, "I3SOCK")
|
||||
}
|
||||
|
||||
// sessionProcess is the best process of this uid whose environment names a display.
|
||||
func sessionProcess(uid int) (int, string, map[string]string, bool) {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return 0, "", nil, false
|
||||
}
|
||||
type candidate struct {
|
||||
pid int
|
||||
comm string
|
||||
env map[string]string
|
||||
rank int
|
||||
}
|
||||
var found []candidate
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(procRoot, e.Name())
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
env := parseEnviron(raw)
|
||||
if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
break
|
||||
}
|
||||
}
|
||||
found = append(found, candidate{pid, comm, env, rank})
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return 0, "", nil, false
|
||||
}
|
||||
sort.Slice(found, func(i, j int) bool {
|
||||
if found[i].rank != found[j].rank {
|
||||
return found[i].rank < found[j].rank
|
||||
}
|
||||
return found[i].pid > found[j].pid // the newer of two equals
|
||||
})
|
||||
best := found[0]
|
||||
return best.pid, best.comm, best.env, true
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func ownerOf(path string) (int, bool) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(st.Uid), true
|
||||
}
|
||||
|
||||
// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one.
|
||||
func lonelyX11Socket() (string, bool) {
|
||||
entries, err := os.ReadDir(x11Sockets)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var displays []string
|
||||
for _, e := range entries {
|
||||
if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() {
|
||||
if _, err := strconv.Atoi(n); err == nil {
|
||||
displays = append(displays, ":"+n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(displays) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return displays[0], true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Env is this process's environment with the session's variables in place of its own.
|
||||
func (s Session) Env() []string {
|
||||
drop := map[string]bool{}
|
||||
for _, k := range sessionKeys {
|
||||
drop[k] = true
|
||||
}
|
||||
var env []string
|
||||
for _, kv := range os.Environ() {
|
||||
if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] {
|
||||
continue
|
||||
}
|
||||
env = append(env, kv)
|
||||
}
|
||||
add := func(k, v string) {
|
||||
if v != "" {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
}
|
||||
add("DISPLAY", s.Display)
|
||||
add("XAUTHORITY", s.XAuthority)
|
||||
add("WAYLAND_DISPLAY", s.Wayland)
|
||||
add("DBUS_SESSION_BUS_ADDRESS", s.Bus)
|
||||
add("XDG_RUNTIME_DIR", s.RuntimeDir)
|
||||
add("XDG_SESSION_ID", s.SessionID)
|
||||
add("I3SOCK", s.I3Sock)
|
||||
return env
|
||||
}
|
||||
|
||||
// mostOutput bounds what a command may answer with, per stream.
|
||||
const mostOutput = 256 << 10
|
||||
|
||||
// Result is what a command did.
|
||||
type Result struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
Code int `json:"code"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// run runs a command in the session's environment, its input given, ended with everything it
|
||||
// started after timeout. A command that is not installed is an error naming it; one that exits
|
||||
// non-zero is a Result with its code, for the caller to judge.
|
||||
func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("%s is not installed on this machine", name)
|
||||
}
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Env = s.Env()
|
||||
if home := operatorHome(); exists(home) {
|
||||
cmd.Dir = home
|
||||
}
|
||||
if stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
}
|
||||
var out, errOut capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errOut
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return Result{}, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-time.After(timeout):
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
<-done
|
||||
return Result{Stdout: out.String(), Stderr: errOut.String()},
|
||||
fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut)
|
||||
}
|
||||
r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut}
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
r.Code = exit.ExitCode()
|
||||
} else if err != nil {
|
||||
return r, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's own service manager, as a transient unit
|
||||
// that carries the session's display, so it outlives the runtime that asked for it. A unit already
|
||||
// running under the same name is stopped first, so a fixed name means "at most one".
|
||||
func (s Session) detach(unit string, args ...string) error {
|
||||
if s.RuntimeDir == "" {
|
||||
return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+
|
||||
"cannot be reached", ErrNoBus)
|
||||
}
|
||||
_, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(call, "--")
|
||||
call = append(call, args...)
|
||||
r, err := s.run(10*time.Second, "", "systemd-run", call...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Code != 0 {
|
||||
return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uniqueUnit is a transient unit name that will not collide with an earlier one.
|
||||
func uniqueUnit(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := mostOutput - c.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.Buffer.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.Buffer.Write(p)
|
||||
}
|
||||
|
||||
// processesOf are the pids of this uid's processes whose command name is comm, oldest first.
|
||||
func processesOf(comm string) []int {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
uid := os.Getuid()
|
||||
var pids []int
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(procRoot, e.Name())
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
if readTrimmed(filepath.Join(dir, "comm")) == comm {
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
}
|
||||
sort.Ints(pids)
|
||||
return pids
|
||||
}
|
||||
|
||||
// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
|
||||
func signalAll(comm string, sig syscall.Signal) []int {
|
||||
var reached []int
|
||||
for _, pid := range processesOf(comm) {
|
||||
if syscall.Kill(pid, sig) == nil {
|
||||
reached = append(reached, pid)
|
||||
}
|
||||
}
|
||||
return reached
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with
|
||||
// none of the test process's own session variables, and gives back the root.
|
||||
func fakeMachine(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11")
|
||||
for _, d := range []string{procRoot, runUserDir, x11Sockets} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, k := range sessionKeys {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home"))
|
||||
t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" })
|
||||
return root
|
||||
}
|
||||
|
||||
func fakeProcess(t *testing.T, pid int, comm string, env ...string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(procRoot, strconv.Itoa(pid))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere")
|
||||
fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority",
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied")
|
||||
fakeProcess(t, 50, "bash", "PATH=/usr/bin")
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") {
|
||||
t.Fatalf("the window manager's environment: %+v", s)
|
||||
}
|
||||
for _, kv := range s.Env() {
|
||||
if strings.HasPrefix(kv, "SECRET_TOKEN=") {
|
||||
t.Fatal("a variable of the session process that is not a session variable was handed on")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 10, "firefox", "DISPLAY=:0")
|
||||
fakeProcess(t, 20, "firefox", "DISPLAY=:2")
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":2" {
|
||||
t.Fatalf("the newest of two equals: %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 10, "sshd", "PATH=/usr/bin")
|
||||
_, err := findSession()
|
||||
if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") {
|
||||
t.Fatalf("no session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) {
|
||||
root := fakeMachine(t)
|
||||
if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") {
|
||||
t.Fatalf("socket and authority: %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
|
||||
if _, err := findBus(); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatalf("no runtime directory is no bus: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(runtime, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := findBus()
|
||||
if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime {
|
||||
t.Fatalf("bus: %+v, %v", s, err)
|
||||
}
|
||||
env := strings.Join(s.Env(), "\n")
|
||||
if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") {
|
||||
t.Fatalf("the bus is handed on: %s", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
s := Session{}
|
||||
r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3")
|
||||
if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" {
|
||||
t.Fatalf("result: %+v, %v", r, err)
|
||||
}
|
||||
start := time.Now()
|
||||
if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second {
|
||||
t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start))
|
||||
}
|
||||
if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") {
|
||||
t.Fatalf("a missing program: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
bin := fakeBinaries(t, map[string]string{
|
||||
"systemctl": `echo "systemctl $*" >> "$LOG"`,
|
||||
"systemd-run": `echo "systemd-run $*" >> "$LOG"`,
|
||||
})
|
||||
log := filepath.Join(bin, "log")
|
||||
t.Setenv("LOG", log)
|
||||
s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"}
|
||||
if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := os.ReadFile(log)
|
||||
want := "systemctl --user stop picom-session.service\n" +
|
||||
"systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n"
|
||||
if string(got) != want {
|
||||
t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatalf("no runtime directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory.
|
||||
func fakeBinaries(t *testing.T, scripts map[string]string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range scripts {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return dir
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at
|
||||
# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which
|
||||
# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the
|
||||
# clipboard.
|
||||
bindsym $mod+period exec --no-startup-id clipmenu -p Clipboard
|
||||
@@ -0,0 +1,5 @@
|
||||
module clipmenu
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"module": "clipmenu",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-clipboard",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"history",
|
||||
"copy"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"clipmenu_paste",
|
||||
"clipmenu_clear",
|
||||
"clipmenu_delete"
|
||||
],
|
||||
"environment": {
|
||||
"variables": {
|
||||
"CM_SELECTIONS": "clipboard",
|
||||
"CM_MAX_CLIPS": "500",
|
||||
"CM_HISTLENGTH": "15"
|
||||
}
|
||||
},
|
||||
"shell": [
|
||||
{
|
||||
"for": "xinitrc",
|
||||
"slot": "normal",
|
||||
"code": "# The clipboard's history (module clipmenu, novox/hq ADR 0208): clipmenud collects every copy from\n# here on, once per session. It keeps the history in the account's runtime directory, so a reboot\n# forgets it, and with it every password that was ever copied.\nclipmenud &\n"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "clipmenu"
|
||||
},
|
||||
{
|
||||
"id": "greenclip",
|
||||
"type": "package",
|
||||
"package": "rofi-greenclip",
|
||||
"absent": true
|
||||
},
|
||||
{
|
||||
"id": "i3-bindings",
|
||||
"type": "file",
|
||||
"path": "${machine:account-home}/.config/i3/config.d/50-clipmenu.conf",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/clipmenu-tools",
|
||||
"binary": "clipmenu-tools",
|
||||
"loads": [
|
||||
"clipmenu-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# cups
|
||||
|
||||
Printing on the two workstations (novox/hq research 027/02: "`cups` with the printer's driver"; to-be 42
|
||||
phase 2 step 9).
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the print scheduler | package `cups` |
|
||||
| driverless printing: the filters that turn a document into what an IPP Everywhere printer takes | package `cups-filters` |
|
||||
| the scheduler, started on demand and at boot | `cups.socket` and `cups.service`, running and enabled |
|
||||
|
||||
All from the official repositories. The queues (`/etc/cups/printers.conf`, the PPDs CUPS generates)
|
||||
and the default printer are CUPS's own state, set through its tools. They are found (ADR 0182), and
|
||||
the module declares none of them.
|
||||
|
||||
## The printer's driver: none needed for the Brother
|
||||
|
||||
Research 027 asked for "`cups` with the printer's driver". Measured on 2026-10-04:
|
||||
|
||||
| workstation | queue | device | prints through | driver package installed |
|
||||
|---|---|---|---|---|
|
||||
| laptop | `Brother` (MFC-L8690CDW) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8690cdw` (AUR), unused |
|
||||
| desktop | `Brother_MFC_Novox` (default) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8390cdw` and its `-debug` (AUR), unused |
|
||||
| desktop | `Kanjuro` (Canon PIXMA MG4200) | `cnijnet:` | the vendor's PPD and filter | `cnijfilter-mg4200` 3.80 (AUR) |
|
||||
|
||||
**Both Brother queues already print without a vendor driver.** CUPS's own IPP Everywhere support,
|
||||
with `cups-filters`, is the whole driver. The vendor packages installed beside them serve no queue,
|
||||
and the laptop's pulls in 32-bit glibc from multilib for a filter nothing runs. So the module declares
|
||||
no driver, and the Brother needs nothing outside the official repositories.
|
||||
|
||||
**The Canon is the exception, and it is blocked.** Its driver is a user-repository package from 2012,
|
||||
with its own network backend. Like snapd, it waits for the mesh's package repository (research 027
|
||||
question 1, option P2). Until then it stays as found on the desktop. If the printer answers IPP (check
|
||||
with `cups_drivers` on a fresh queue, or `driverless` from `cups-filters`), a driverless queue replaces
|
||||
it and the question goes away.
|
||||
|
||||
## Improves
|
||||
|
||||
- **An owner for the scheduler.** It runs on both workstations today, enabled by nothing the mesh records.
|
||||
- **No driver package that nothing uses.** The README's one-off step below removes them, once.
|
||||
- **Supplies and state from anywhere.** `cups_printers` answers each queue's toner levels and flags a
|
||||
low one. It also answers why a queue stopped, without opening the printer's page.
|
||||
- **The laptop has no default printer**, so a print without a named printer fails there.
|
||||
`cups_default` sets one; it is the operator's choice, not declared.
|
||||
|
||||
## Tools
|
||||
|
||||
All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. CUPS lets any account
|
||||
print and cancel its own jobs. Setting the default, resuming a printer, and cancelling another
|
||||
account's job are kept for its administrators, so those go through `sudo -n`; a cancel tries the
|
||||
account first.
|
||||
|
||||
| tool | what |
|
||||
|---|---|
|
||||
| `cups_printers` (r) | every queue: state, enabled, accepting, default, device, make and model, driverless or not, state reasons, supply levels (low flagged) |
|
||||
| `cups_queue` (r) | jobs waiting or printing, or the completed ones, newest first, bounded |
|
||||
| `cups_cancel` (a) | one job, or every job on a printer |
|
||||
| `cups_print` (a) | a file on this machine to a printer or the default, with copies and IPP options; answers the job id |
|
||||
| `cups_default` (r/a) | read the default, or set it |
|
||||
| `cups_resume` (a) | enable a stopped printer and make it accept jobs |
|
||||
| `cups_drivers` (r) | how each queue prints, the packages that bring filters and backends (foreign ones flagged), findings, and the driver models CUPS offers, filtered |
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
Nothing on disk on either workstation: both have `cups` (explicit) and `cups-filters` (as its
|
||||
dependency), with `cups.socket` and `cups.service` enabled and running. The packages become the
|
||||
mesh's; `cups-filters` is now declared explicitly.
|
||||
|
||||
## The one-off step for the operator (ADR 0182)
|
||||
|
||||
The mesh removes nothing it did not install. Once the Brother queues are confirmed printing (they do
|
||||
today), remove the unused vendor drivers, once:
|
||||
|
||||
- **laptop:** `brother-mfc-l8690cdw`, then whatever `pacman -Qdtq` shows it alone pulled in
|
||||
(`lib32-glibc`).
|
||||
- **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while
|
||||
the Canon queue is used.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset),
|
||||
`system-config-printer` on the desktop, and `cnijfilter-mg4200`.
|
||||
@@ -0,0 +1,560 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var queueName = regexp.MustCompile(`^[A-Za-z0-9_.@-]{1,127}$`)
|
||||
|
||||
func checkPrinter(p string) error {
|
||||
if !queueName.MatchString(p) || strings.HasPrefix(p, "-") {
|
||||
return fmt.Errorf("%q is not a printer queue's name", p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var optionName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`)
|
||||
var optionValue = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,128}$`)
|
||||
|
||||
// optionsOf reads the print options object.
|
||||
func optionsOf(args map[string]any) (map[string]string, error) {
|
||||
v, ok := args["options"]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("options must be an object of names to values")
|
||||
}
|
||||
out := map[string]string{}
|
||||
for k, val := range m {
|
||||
s, ok := val.(string)
|
||||
if !ok || !optionName.MatchString(k) || !optionValue.MatchString(s) {
|
||||
return nil, fmt.Errorf("option %s=%v is not an IPP option name and a plain value", k, val)
|
||||
}
|
||||
out[k] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lpoptionsOf reads lpoptions' answer: name=value pairs, a value quoted with ' or with backslash escapes.
|
||||
func lpoptionsOf(s string) map[string]string {
|
||||
out := map[string]string{}
|
||||
s = strings.TrimSpace(s)
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
for i < len(s) && s[i] == ' ' {
|
||||
i++
|
||||
}
|
||||
start := i
|
||||
for i < len(s) && s[i] != '=' && s[i] != ' ' {
|
||||
i++
|
||||
}
|
||||
key := s[start:i]
|
||||
if i >= len(s) || s[i] != '=' {
|
||||
if key != "" {
|
||||
out[key] = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
i++
|
||||
var b strings.Builder
|
||||
for i < len(s) && s[i] != ' ' {
|
||||
switch s[i] {
|
||||
case '\'':
|
||||
i++
|
||||
for i < len(s) && s[i] != '\'' {
|
||||
if s[i] == '\\' && i+1 < len(s) {
|
||||
i++
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
i++
|
||||
case '\\':
|
||||
if i+1 < len(s) {
|
||||
b.WriteByte(s[i+1])
|
||||
}
|
||||
i += 2
|
||||
default:
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
out[key] = b.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Marker is one supply the printer reports.
|
||||
type Marker struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Level int `json:"level_percent"`
|
||||
Low bool `json:"low"`
|
||||
}
|
||||
|
||||
// Printer is one queue.
|
||||
type Printer struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Accepting bool `json:"accepting"`
|
||||
Default bool `json:"default"`
|
||||
URI string `json:"uri"`
|
||||
MakeModel string `json:"make_and_model"`
|
||||
Driverless bool `json:"driverless"`
|
||||
Shared bool `json:"shared"`
|
||||
Reasons []string `json:"reasons"`
|
||||
Markers []Marker `json:"markers"`
|
||||
Since string `json:"since,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// PrintersAnswer is what cups_printers answers.
|
||||
type PrintersAnswer struct {
|
||||
Scheduler string `json:"scheduler"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Printers []Printer `json:"printers"`
|
||||
}
|
||||
|
||||
func lpstat(args ...string) (string, error) {
|
||||
r, err := call(Cmd{Name: "lpstat", Args: args})
|
||||
if err != nil {
|
||||
if strings.Contains(r.Stderr, "Scheduler is not running") || strings.Contains(r.Stderr, "Connection refused") {
|
||||
return "", fmt.Errorf("the print scheduler is not running on this machine: %s", firstLine(r.Stderr))
|
||||
}
|
||||
// lpstat answers "No destinations added." with a non-zero status: that is no printers.
|
||||
if strings.Contains(r.Stderr, "No destinations added") {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return r.Stdout, nil
|
||||
}
|
||||
|
||||
func defaultPrinter() (string, error) {
|
||||
out, err := lpstat("-d")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, after, ok := strings.Cut(out, "system default destination: "); ok {
|
||||
return strings.TrimSpace(firstLine(after)), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Printers answers every queue with its state, device, model and supplies.
|
||||
func Printers() (PrintersAnswer, error) {
|
||||
out := PrintersAnswer{Printers: []Printer{}}
|
||||
sched, err := lpstat("-r")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Scheduler = strings.TrimSpace(sched)
|
||||
if out.Default, err = defaultPrinter(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
ps, err := lpstat("-p")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
var cur *Printer
|
||||
for _, l := range strings.Split(ps, "\n") {
|
||||
if strings.HasPrefix(l, "printer ") {
|
||||
f := strings.Fields(l)
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
out.Printers = append(out.Printers, Printer{Name: f[1], Reasons: []string{}, Markers: []Marker{}})
|
||||
cur = &out.Printers[len(out.Printers)-1]
|
||||
rest := strings.Join(f[2:], " ")
|
||||
switch {
|
||||
case strings.HasPrefix(rest, "is idle"):
|
||||
cur.State = "idle"
|
||||
case strings.HasPrefix(rest, "now printing"):
|
||||
cur.State = "printing"
|
||||
default:
|
||||
cur.State = "stopped"
|
||||
}
|
||||
// "disabled since …" (stopped), or "enabled since …" after the state.
|
||||
cur.Enabled = !strings.HasPrefix(rest, "disabled") && !strings.Contains(rest, "disabled since")
|
||||
if _, since, found := strings.Cut(rest, " since "); found {
|
||||
cur.Since = strings.TrimSuffix(strings.TrimSpace(since), " -")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cur != nil && strings.HasPrefix(l, "\t") && strings.TrimSpace(l) != "" {
|
||||
cur.Message = strings.TrimSpace(cur.Message + " " + strings.TrimSpace(l))
|
||||
}
|
||||
}
|
||||
acc, err := lpstat("-a")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
accepting := map[string]bool{}
|
||||
for _, l := range lines(acc) {
|
||||
if f := strings.Fields(l); len(f) >= 2 && f[1] == "accepting" {
|
||||
accepting[f[0]] = true
|
||||
}
|
||||
}
|
||||
for i := range out.Printers {
|
||||
p := &out.Printers[i]
|
||||
p.Accepting = accepting[p.Name]
|
||||
p.Default = p.Name == out.Default
|
||||
r, err := call(Cmd{Name: "lpoptions", Args: []string{"-p", p.Name}})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
o := lpoptionsOf(r.Stdout)
|
||||
p.URI = o["device-uri"]
|
||||
p.MakeModel = o["printer-make-and-model"]
|
||||
p.Driverless = driverless(p.MakeModel, p.URI)
|
||||
p.Shared = o["printer-is-shared"] == "true"
|
||||
for _, reason := range strings.Split(o["printer-state-reasons"], ",") {
|
||||
if reason = strings.TrimSpace(reason); reason != "" && reason != "none" {
|
||||
p.Reasons = append(p.Reasons, reason)
|
||||
}
|
||||
}
|
||||
p.Markers = markersOf(o)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// driverless says a queue prints without a vendor driver: CUPS's own IPP Everywhere model, or a
|
||||
// driverless URI.
|
||||
func driverless(model, uri string) bool {
|
||||
m := strings.ToLower(model)
|
||||
return strings.Contains(m, "ipp everywhere") || strings.Contains(m, "driverless") || strings.HasPrefix(uri, "implicitclass:") ||
|
||||
strings.HasPrefix(uri, "ipp://") && strings.Contains(m, "everywhere")
|
||||
}
|
||||
|
||||
func markersOf(o map[string]string) []Marker {
|
||||
split := func(k string) []string {
|
||||
if o[k] == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(o[k], ",")
|
||||
}
|
||||
names, levels, lows, types := split("marker-names"), split("marker-levels"), split("marker-low-levels"), split("marker-types")
|
||||
out := []Marker{}
|
||||
for i, n := range names {
|
||||
if i >= len(levels) {
|
||||
break
|
||||
}
|
||||
level, err := strconv.Atoi(strings.TrimSpace(levels[i]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
m := Marker{Name: strings.TrimSpace(n), Level: level}
|
||||
if i < len(types) {
|
||||
m.Type = strings.TrimSpace(types[i])
|
||||
}
|
||||
if i < len(lows) {
|
||||
if low, err := strconv.Atoi(strings.TrimSpace(lows[i])); err == nil && level >= 0 && level <= low {
|
||||
m.Low = true
|
||||
}
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Job is one print job.
|
||||
type Job struct {
|
||||
ID string `json:"id"`
|
||||
Printer string `json:"printer"`
|
||||
User string `json:"user"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Submitted string `json:"submitted"`
|
||||
}
|
||||
|
||||
// Queue answers the jobs waiting, or the finished ones.
|
||||
func Queue(printer string, completed bool, limit int) (map[string]any, error) {
|
||||
args := []string{}
|
||||
if completed {
|
||||
args = append(args, "-W", "completed")
|
||||
}
|
||||
args = append(args, "-o")
|
||||
if printer != "" {
|
||||
if err := checkPrinter(printer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, printer)
|
||||
}
|
||||
out, err := lpstat(args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs := []Job{}
|
||||
for _, l := range lines(out) {
|
||||
f := strings.Fields(l)
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
i := strings.LastIndex(f[0], "-")
|
||||
if i <= 0 {
|
||||
continue
|
||||
}
|
||||
size, _ := strconv.ParseInt(f[2], 10, 64)
|
||||
jobs = append(jobs, Job{ID: f[0], Printer: f[0][:i], User: f[1], Bytes: size, Submitted: strings.Join(f[3:], " ")})
|
||||
}
|
||||
sort.SliceStable(jobs, func(a, b int) bool { return jobNumber(jobs[a].ID) > jobNumber(jobs[b].ID) })
|
||||
total := len(jobs)
|
||||
if len(jobs) > limit {
|
||||
jobs = jobs[:limit]
|
||||
}
|
||||
return map[string]any{"count": total, "jobs": jobs, "completed": completed}, nil
|
||||
}
|
||||
|
||||
func jobNumber(id string) int {
|
||||
n, _ := strconv.Atoi(id[strings.LastIndex(id, "-")+1:])
|
||||
return n
|
||||
}
|
||||
|
||||
var jobID = regexp.MustCompile(`^([A-Za-z0-9_.@-]+-)?[0-9]+$`)
|
||||
|
||||
// refused says CUPS kept an act for its administrators.
|
||||
func refused(r Result) bool {
|
||||
s := r.Stderr + r.Stdout
|
||||
return strings.Contains(s, "Forbidden") || strings.Contains(s, "not-authorized") || strings.Contains(s, "Not authorized") || strings.Contains(s, "not allowed")
|
||||
}
|
||||
|
||||
// asAccountThenRoot runs an act as the account, and through sudo -n when CUPS refuses the account.
|
||||
func asAccountThenRoot(c Cmd) (Result, bool, error) {
|
||||
r := run(c)
|
||||
if r.Status == 0 && r.Error == "" {
|
||||
return r, false, nil
|
||||
}
|
||||
if !refused(r) {
|
||||
return r, false, failure(c, r)
|
||||
}
|
||||
c.Root = true
|
||||
r, err := call(c)
|
||||
return r, true, err
|
||||
}
|
||||
|
||||
// Cancel cancels one job, or every job on a printer.
|
||||
func Cancel(job, printer string, all bool) (map[string]any, error) {
|
||||
var c Cmd
|
||||
switch {
|
||||
case all:
|
||||
if printer == "" {
|
||||
return nil, fmt.Errorf("all needs printer: cancelling every job on every printer is not offered")
|
||||
}
|
||||
if err := checkPrinter(printer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c = Cmd{Name: "cancel", Args: []string{"-a", printer}}
|
||||
case job != "":
|
||||
if !jobID.MatchString(job) {
|
||||
return nil, fmt.Errorf("%q is not a job id", job)
|
||||
}
|
||||
c = Cmd{Name: "cancel", Args: []string{job}}
|
||||
default:
|
||||
return nil, fmt.Errorf("give job, or printer with all")
|
||||
}
|
||||
_, escalated, err := asAccountThenRoot(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"cancelled": strings.Join(c.Args, " "), "as_root": escalated}, nil
|
||||
}
|
||||
|
||||
var requestID = regexp.MustCompile(`request id is (\S+)`)
|
||||
|
||||
// statFile tells a regular file. Tests replace it.
|
||||
var statFile = func(p string) error {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s is not a regular file", p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print sends a file to a printer.
|
||||
func Print(file, printer string, copies int, opts map[string]string, title string) (map[string]any, error) {
|
||||
if !filepath.IsAbs(file) {
|
||||
return nil, fmt.Errorf("file must be an absolute path, not %q", file)
|
||||
}
|
||||
if err := statFile(file); err != nil {
|
||||
return nil, fmt.Errorf("cannot print %s: %v", file, err)
|
||||
}
|
||||
args := []string{}
|
||||
if printer != "" {
|
||||
if err := checkPrinter(printer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, "-d", printer)
|
||||
}
|
||||
args = append(args, "-n", strconv.Itoa(copies))
|
||||
names := make([]string, 0, len(opts))
|
||||
for k := range opts {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, k := range names {
|
||||
args = append(args, "-o", k+"="+opts[k])
|
||||
}
|
||||
if title == "" {
|
||||
title = filepath.Base(file)
|
||||
}
|
||||
args = append(args, "-t", title, "--", file)
|
||||
r, err := call(Cmd{Name: "lp", Args: args})
|
||||
if err != nil {
|
||||
if strings.Contains(r.Stderr, "No default destination") {
|
||||
return nil, fmt.Errorf("no printer given and this machine has no default printer: name one, or set one with cups_default")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
m := requestID.FindStringSubmatch(r.Stdout)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("lp answered no job id: %s", strings.TrimSpace(r.Stdout+r.Stderr))
|
||||
}
|
||||
return map[string]any{"job": m[1], "file": file, "copies": copies, "follow": "cups_queue"}, nil
|
||||
}
|
||||
|
||||
// Default answers the default printer, or sets it.
|
||||
func Default(printer string) (map[string]any, error) {
|
||||
if printer == "" {
|
||||
d, err := defaultPrinter()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"default": d}, nil
|
||||
}
|
||||
if err := checkPrinter(printer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
was, err := defaultPrinter()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := call(Cmd{Name: "lpadmin", Args: []string{"-d", printer}, Root: true}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"default": printer, "was": was}, nil
|
||||
}
|
||||
|
||||
// Resume enables a printer and makes it accept jobs.
|
||||
func Resume(printer string) (map[string]any, error) {
|
||||
if err := checkPrinter(printer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range []string{"cupsenable", "cupsaccept"} {
|
||||
if _, err := call(Cmd{Name: c, Args: []string{printer}, Root: true}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return map[string]any{"printer": printer, "enabled": true, "accepting": true}, nil
|
||||
}
|
||||
|
||||
// DriverPackage is a package that brings filters or backends.
|
||||
type DriverPackage struct {
|
||||
Package string `json:"package"`
|
||||
Version string `json:"version"`
|
||||
Foreign bool `json:"foreign"`
|
||||
}
|
||||
|
||||
// Model is one driver model CUPS offers.
|
||||
type Model struct {
|
||||
PPD string `json:"ppd"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// QueueDriver is how one queue prints.
|
||||
type QueueDriver struct {
|
||||
Printer string `json:"printer"`
|
||||
MakeModel string `json:"make_and_model"`
|
||||
Driverless bool `json:"driverless"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
// DriversAnswer is what cups_drivers answers.
|
||||
type DriversAnswer struct {
|
||||
Queues []QueueDriver `json:"queues"`
|
||||
Packages []DriverPackage `json:"driver_packages"`
|
||||
Models []Model `json:"models"`
|
||||
Matched int `json:"models_matched"`
|
||||
Findings []string `json:"findings"`
|
||||
}
|
||||
|
||||
// driverDirs are where drivers put what CUPS runs.
|
||||
var driverDirs = []string{"/usr/lib/cups/filter", "/usr/lib/cups/backend"}
|
||||
|
||||
// basePackages bring CUPS's own filters and backends, not a printer's driver.
|
||||
var basePackages = map[string]bool{"cups": true, "cups-filters": true, "libcups": true, "ghostscript": true, "cups-pdf": false}
|
||||
|
||||
// Drivers answers how each queue prints and which packages bring drivers.
|
||||
func Drivers(match string, limit int) (DriversAnswer, error) {
|
||||
out := DriversAnswer{Queues: []QueueDriver{}, Packages: []DriverPackage{}, Models: []Model{}, Findings: []string{}}
|
||||
ps, err := Printers()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, p := range ps.Printers {
|
||||
out.Queues = append(out.Queues, QueueDriver{Printer: p.Name, MakeModel: p.MakeModel, Driverless: p.Driverless, URI: p.URI})
|
||||
}
|
||||
r := run(Cmd{Name: "pacman", Args: append([]string{"-Qo"}, driverDirs...)})
|
||||
if r.Error != "" {
|
||||
return out, failure(Cmd{Name: "pacman", Args: []string{"-Qo"}}, r)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, l := range lines(r.Stdout) {
|
||||
if _, after, ok := strings.Cut(l, " is owned by "); ok {
|
||||
f := strings.Fields(after)
|
||||
if len(f) >= 2 && !seen[f[0]] && !basePackages[f[0]] {
|
||||
seen[f[0]] = true
|
||||
out.Packages = append(out.Packages, DriverPackage{Package: f[0], Version: f[1]})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out.Packages) > 0 {
|
||||
r := run(Cmd{Name: "pacman", Args: []string{"-Qqm"}})
|
||||
foreign := map[string]bool{}
|
||||
for _, l := range lines(r.Stdout) {
|
||||
foreign[strings.TrimSpace(l)] = true
|
||||
}
|
||||
for i := range out.Packages {
|
||||
out.Packages[i].Foreign = foreign[out.Packages[i].Package]
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Packages, func(i, k int) bool { return out.Packages[i].Package < out.Packages[k].Package })
|
||||
m, err := call(Cmd{Name: "lpinfo", Args: []string{"-m"}})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, l := range lines(m.Stdout) {
|
||||
ppd, desc, _ := strings.Cut(l, " ")
|
||||
if match != "" && !strings.Contains(strings.ToLower(desc), strings.ToLower(match)) && !strings.Contains(strings.ToLower(ppd), strings.ToLower(match)) {
|
||||
continue
|
||||
}
|
||||
out.Matched++
|
||||
if len(out.Models) < limit {
|
||||
out.Models = append(out.Models, Model{PPD: ppd, Description: desc})
|
||||
}
|
||||
}
|
||||
// Which driver packages no queue prints through.
|
||||
allDriverless := len(out.Queues) > 0
|
||||
for _, q := range out.Queues {
|
||||
allDriverless = allDriverless && q.Driverless
|
||||
}
|
||||
for _, p := range out.Packages {
|
||||
if p.Foreign {
|
||||
out.Findings = append(out.Findings, "driver package "+p.Package+" is not from the official repositories")
|
||||
}
|
||||
if allDriverless {
|
||||
out.Findings = append(out.Findings, "every queue prints driverless, so no queue uses the driver package "+p.Package)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheManifestIsTheSchedulerAndDriverlessPrintingAndNoVendorDriver(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
holdsTheBundle(t, m, "cups")
|
||||
if got := strings.Join(m.packages(), ","); got != "cups,cups-filters" {
|
||||
t.Errorf("packages %s: a vendor driver is from outside the official repositories, and no queue measured needs one but the desktop's Canon", got)
|
||||
}
|
||||
s := m.services()
|
||||
for _, u := range []string{"cups.socket", "cups.service"} {
|
||||
if s[u] == nil || s[u]["state"] != "running" || s[u]["boot"] != "enabled" {
|
||||
t.Errorf("%s: %v", u, s[u])
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "file" {
|
||||
t.Errorf("the queues and cupsd's files are CUPS's own: %v", r["id"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The desktop's two queues on 2026-10-04.
|
||||
func theDesktop(t *testing.T, more func(line string, c Cmd) (Result, bool)) *fake {
|
||||
return using(t, func(line string, c Cmd) Result {
|
||||
if more != nil {
|
||||
if r, handled := more(line, c); handled {
|
||||
return r
|
||||
}
|
||||
}
|
||||
switch line {
|
||||
case "lpstat -r":
|
||||
return ok("scheduler is running\n")
|
||||
case "lpstat -d":
|
||||
return ok("system default destination: Brother_MFC_Novox\n")
|
||||
case "lpstat -p":
|
||||
return ok("printer Brother_MFC_Novox is idle. enabled since Mon Jun 29 21:34:30 2026\n" +
|
||||
"printer Kanjuro disabled since Sun Jun 9 11:16:03 2024 -\n\tPaused\n")
|
||||
case "lpstat -a":
|
||||
return ok("Brother_MFC_Novox accepting requests since Mon Jun 29 21:34:30 2026\nKanjuro not accepting requests since Sun Jun 9 11:16:03 2024 -\n\tRejecting Jobs\n")
|
||||
case "lpoptions -p Brother_MFC_Novox":
|
||||
return ok(`copies=1 device-uri=ipp://192.0.2.171/ipp/port1 finishings=3 marker-levels=70,100,8,100 marker-low-levels=10,10,10,10 marker-names='Black\ Toner\ Cartridge,Cyan\ Toner\ Cartridge,Magenta\ Toner\ Cartridge,Yellow\ Toner\ Cartridge' marker-types=toner,toner,toner,toner printer-is-shared=false printer-make-and-model='Printer - IPP Everywhere' printer-state-reasons=none`)
|
||||
case "lpoptions -p Kanjuro":
|
||||
return ok(`device-uri=cnijnet:/18-0C-AC-B0-62-83 printer-is-shared=false printer-make-and-model='Canon MG4200 series Ver.3.80' printer-state-reasons=paused`)
|
||||
}
|
||||
return Result{Status: 9, Stderr: "unexpected " + line}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrintersReadsStateDeviceModelAndSupplies(t *testing.T) {
|
||||
theDesktop(t, nil)
|
||||
got, err := Printers()
|
||||
if err != nil || len(got.Printers) != 2 || got.Default != "Brother_MFC_Novox" || got.Scheduler != "scheduler is running" {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
b, k := got.Printers[0], got.Printers[1]
|
||||
if b.State != "idle" || !b.Enabled || !b.Accepting || !b.Default || !b.Driverless || b.URI != "ipp://192.0.2.171/ipp/port1" || len(b.Reasons) != 0 {
|
||||
t.Errorf("%+v", b)
|
||||
}
|
||||
if len(b.Markers) != 4 || b.Markers[0].Name != "Black Toner Cartridge" || b.Markers[2].Level != 8 || !b.Markers[2].Low || b.Markers[0].Low {
|
||||
t.Errorf("markers %+v", b.Markers)
|
||||
}
|
||||
if k.State != "stopped" || k.Enabled || k.Accepting || k.Driverless || strings.Join(k.Reasons, ",") != "paused" || k.Message != "Paused" {
|
||||
t.Errorf("%+v", k)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintersWithoutAQueueOrAScheduler(t *testing.T) {
|
||||
using(t, func(line string, c Cmd) Result {
|
||||
if line == "lpstat -r" {
|
||||
return ok("scheduler is running\n")
|
||||
}
|
||||
return Result{Status: 1, Stderr: "lpstat: No destinations added.\n"}
|
||||
})
|
||||
got, err := Printers()
|
||||
if err != nil || len(got.Printers) != 0 {
|
||||
t.Fatalf("no queue is an empty answer, not a failure: %+v %v", got, err)
|
||||
}
|
||||
using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "lpstat: Scheduler is not running.\n"} })
|
||||
if _, err := Printers(); err == nil || !strings.Contains(err.Error(), "scheduler is not running") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLpoptionsQuotingIsRead(t *testing.T) {
|
||||
o := lpoptionsOf(`a=1 b='x y' c=p\ q d e=`)
|
||||
if o["a"] != "1" || o["b"] != "x y" || o["c"] != "p q" || o["d"] != "" || o["e"] != "" {
|
||||
t.Errorf("%v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueReadsJobsNewestFirstAndBounded(t *testing.T) {
|
||||
f := using(t, func(string, Cmd) Result {
|
||||
return ok("Brother-6 jochen 1024 Mon Jul 8 12:15:34 2024\nBrother-8 jochen 2048 Mon Jul 8 12:19:56 2024\nBrother-7 other 1024 Mon Jul 8 12:15:23 2024\n")
|
||||
})
|
||||
got, err := Queue("Brother", true, 2)
|
||||
jobs := got["jobs"].([]Job)
|
||||
if err != nil || got["count"] != 3 || len(jobs) != 2 || jobs[0].ID != "Brother-8" || jobs[0].Printer != "Brother" || jobs[0].Bytes != 2048 || jobs[0].Submitted != "Mon Jul 8 12:19:56 2024" {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if f.lines()[0] != "lpstat -W completed -o Brother" {
|
||||
t.Errorf("%v", f.lines())
|
||||
}
|
||||
if _, err := Queue("-h", false, 5); err == nil {
|
||||
t.Error("an option as a printer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelTriesTheAccountThenRootWhenCUPSRefusesIt(t *testing.T) {
|
||||
f := using(t, func(line string, c Cmd) Result {
|
||||
if !strings.HasPrefix(line, "sudo") {
|
||||
return Result{Status: 1, Stderr: "cancel: Forbidden\n"}
|
||||
}
|
||||
return ok("")
|
||||
})
|
||||
got, err := Cancel("Brother-12", "", false)
|
||||
if err != nil || got["as_root"] != true {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
if strings.Join(f.lines(), "|") != "cancel Brother-12|sudo -n cancel Brother-12" {
|
||||
t.Errorf("%v", f.lines())
|
||||
}
|
||||
f = using(t, func(string, Cmd) Result { return ok("") })
|
||||
if got, err := Cancel("", "Brother", true); err != nil || got["as_root"] != false || f.lines()[0] != "cancel -a Brother" {
|
||||
t.Fatalf("%v %v %v", got, err, f.lines())
|
||||
}
|
||||
using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "cancel: Unknown job 99\n"} })
|
||||
if _, err := Cancel("99", "", false); err == nil || !strings.Contains(err.Error(), "Unknown job") {
|
||||
t.Errorf("a failure that is not a refusal is not retried as root: %v", err)
|
||||
}
|
||||
for _, bad := range [][3]string{{"", "", ""}, {"12; rm", "", ""}, {"", "", "all"}} {
|
||||
if _, err := Cancel(bad[0], bad[1], bad[2] == "all"); err == nil {
|
||||
t.Errorf("%v accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintChecksTheFileAndOptionsAndAnswersTheJob(t *testing.T) {
|
||||
was := statFile
|
||||
defer func() { statFile = was }()
|
||||
statFile = func(p string) error {
|
||||
if p == "/home/op/doc.pdf" {
|
||||
return nil
|
||||
}
|
||||
return errString("no such file")
|
||||
}
|
||||
f := using(t, func(string, Cmd) Result { return ok("request id is Brother-13 (1 file(s))\n") })
|
||||
got, err := Print("/home/op/doc.pdf", "Brother", 2, map[string]string{"sides": "two-sided-long-edge", "media": "A4"}, "")
|
||||
if err != nil || got["job"] != "Brother-13" {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
if l := f.lines()[0]; l != "lp -d Brother -n 2 -o media=A4 -o sides=two-sided-long-edge -t doc.pdf -- /home/op/doc.pdf" {
|
||||
t.Errorf("%s", l)
|
||||
}
|
||||
if _, err := Print("doc.pdf", "", 1, nil, ""); err == nil {
|
||||
t.Error("a relative file")
|
||||
}
|
||||
if _, err := Print("/home/op/missing.pdf", "", 1, nil, ""); err == nil {
|
||||
t.Error("a missing file")
|
||||
}
|
||||
if _, err := optionsOf(map[string]any{"options": map[string]any{"sides": "x y"}}); err == nil {
|
||||
t.Error("an option value with a space")
|
||||
}
|
||||
if _, err := optionsOf(map[string]any{"options": map[string]any{"-o": "x"}}); err == nil {
|
||||
t.Error("an option name that is an option")
|
||||
}
|
||||
using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "lp: Error - No default destination."} })
|
||||
if _, err := Print("/home/op/doc.pdf", "", 1, nil, ""); err == nil || !strings.Contains(err.Error(), "no default printer") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
func TestDefaultReadsAndSetsThroughSudo(t *testing.T) {
|
||||
f := theDesktop(t, func(line string, c Cmd) (Result, bool) {
|
||||
if strings.HasPrefix(line, "sudo -n lpadmin") {
|
||||
return ok(""), true
|
||||
}
|
||||
return Result{}, false
|
||||
})
|
||||
got, err := Default("")
|
||||
if err != nil || got["default"] != "Brother_MFC_Novox" {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
got, err = Default("Kanjuro")
|
||||
if err != nil || got["default"] != "Kanjuro" || got["was"] != "Brother_MFC_Novox" {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
if l := f.lines(); l[len(l)-1] != "sudo -n lpadmin -d Kanjuro" {
|
||||
t.Errorf("%v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeEnablesAndAcceptsThroughSudo(t *testing.T) {
|
||||
f := using(t, func(string, Cmd) Result { return ok("") })
|
||||
if _, err := Resume("Kanjuro"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(f.lines(), "|") != "sudo -n cupsenable Kanjuro|sudo -n cupsaccept Kanjuro" {
|
||||
t.Errorf("%v", f.lines())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriversNamesForeignDriverPackagesAndOnesNoQueueUses(t *testing.T) {
|
||||
theDesktop(t, func(line string, c Cmd) (Result, bool) {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "pacman -Qo"):
|
||||
return ok("/usr/lib/cups/filter/ is owned by brother-mfc-l8390cdw 3.5.1-2\n/usr/lib/cups/filter/ is owned by cups 2:2.4.19-1\n/usr/lib/cups/filter/ is owned by cups-filters 2.0.1-2\n/usr/lib/cups/backend/ is owned by cnijfilter-mg4200 3.80-6\n/usr/lib/cups/backend/ is owned by cups 2:2.4.19-1\n"), true
|
||||
case line == "pacman -Qqm":
|
||||
return ok("brother-mfc-l8390cdw\ncnijfilter-mg4200\nsnapd\n"), true
|
||||
case line == "lpinfo -m":
|
||||
return ok("drv:///sample.drv/dymo.ppd DYMO Label Printer\ncanonmg4200.ppd Canon MG4200 series Ver.3.80\neverywhere IPP Everywhere\n"), true
|
||||
}
|
||||
return Result{}, false
|
||||
})
|
||||
got, err := Drivers("canon", 10)
|
||||
if err != nil || len(got.Queues) != 2 || len(got.Packages) != 2 || got.Matched != 1 || got.Models[0].PPD != "canonmg4200.ppd" {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if !got.Packages[0].Foreign || got.Packages[0].Package != "brother-mfc-l8390cdw" {
|
||||
t.Errorf("%+v", got.Packages)
|
||||
}
|
||||
all := strings.Join(got.Findings, ";")
|
||||
if !strings.Contains(all, "cnijfilter-mg4200 is not from the official") || strings.Contains(all, "every queue prints driverless") {
|
||||
t.Errorf("the Canon queue uses its driver, so not every queue is driverless: %s", all)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package main
|
||||
|
||||
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||
// than shared; a change to one copy is made to all eight.
|
||||
//
|
||||
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||
// started when it takes longer;
|
||||
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command is held to.
|
||||
const (
|
||||
CallTimeout = 20 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
)
|
||||
|
||||
// Cmd is one command a tool runs.
|
||||
type Cmd struct {
|
||||
Name string
|
||||
Args []string
|
||||
// Stdin is written to the command's standard input when not empty.
|
||||
Stdin string
|
||||
// Env is added to this process's own environment.
|
||||
Env []string
|
||||
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||
Root bool
|
||||
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||
Timeout time.Duration
|
||||
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||
// until the child exits.
|
||||
Detached bool
|
||||
}
|
||||
|
||||
// Result is what a command did.
|
||||
type Result struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Status int `json:"status"`
|
||||
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||
Error string `json:"error,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// Runner runs a command. Tests replace it; nothing else does.
|
||||
type Runner func(Cmd) Result
|
||||
|
||||
var (
|
||||
run Runner = execRun
|
||||
euid = os.Geteuid
|
||||
)
|
||||
|
||||
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||
// process is not root.
|
||||
func argv(c Cmd) (string, []string) {
|
||||
if c.Root && euid() != 0 {
|
||||
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||
}
|
||||
return c.Name, c.Args
|
||||
}
|
||||
|
||||
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||
type bounded struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (w *bounded) Write(p []byte) (int, error) {
|
||||
room := MostOutput - w.b.Len()
|
||||
if room <= 0 {
|
||||
w.cut = w.cut || len(p) > 0
|
||||
return len(p), nil
|
||||
}
|
||||
if len(p) > room {
|
||||
w.b.Write(p[:room])
|
||||
w.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return w.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(c Cmd) Result {
|
||||
timeout := c.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = CallTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
name, args := argv(c)
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||
if !c.Detached {
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
if c.Stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||
}
|
||||
var out, errs bounded
|
||||
var outFile, errFile *os.File
|
||||
if c.Detached {
|
||||
var err error
|
||||
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(outFile.Name())
|
||||
defer outFile.Close()
|
||||
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(errFile.Name())
|
||||
defer errFile.Close()
|
||||
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||
} else {
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
}
|
||||
err := cmd.Run()
|
||||
if c.Detached {
|
||||
for _, f := range []struct {
|
||||
file *os.File
|
||||
into *bounded
|
||||
}{{outFile, &out}, {errFile, &errs}} {
|
||||
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||
_, _ = io.Copy(f.into, f.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
r.Status, r.Error = 124, "timeout"
|
||||
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||
r.Status, r.Error = 127, "not-found"
|
||||
case errors.As(err, &exit):
|
||||
r.Status = exit.ExitCode()
|
||||
default:
|
||||
r.Status, r.Error = 127, err.Error()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// call runs a command and answers its result, or an error naming what went wrong.
|
||||
func call(c Cmd) (Result, error) {
|
||||
r := run(c)
|
||||
if r.Status == 0 && r.Error == "" {
|
||||
return r, nil
|
||||
}
|
||||
return r, failure(c, r)
|
||||
}
|
||||
|
||||
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||
// status with the end of what it said.
|
||||
func failure(c Cmd, r Result) error {
|
||||
program, _ := argv(c)
|
||||
switch {
|
||||
case r.Error == "not-found" && program == "sudo":
|
||||
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||
case r.Error == "not-found":
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case r.Error == "timeout":
|
||||
limit := c.Timeout
|
||||
if limit <= 0 {
|
||||
limit = CallTimeout
|
||||
}
|
||||
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||
case r.Error != "":
|
||||
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||
c.Name, firstLine(r.Stderr))
|
||||
}
|
||||
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||
if said == "" {
|
||||
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
// lines are a command's output lines, blank ones dropped.
|
||||
func lines(s string) []string {
|
||||
out := []string{}
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
out = append(out, strings.TrimRight(l, "\r"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Arguments, read the way a tool's JSON arguments arrive.
|
||||
|
||||
func text(args map[string]any, key string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "", fmt.Errorf("%s must not be empty", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func optText(args map[string]any, key, def string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def, nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s must be a number", key)
|
||||
}
|
||||
}
|
||||
if f != float64(int(f)) {
|
||||
return 0, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least {
|
||||
return 0, fmt.Errorf("%s must be at least %d", key, least)
|
||||
}
|
||||
if n > most {
|
||||
n = most
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s must be true or false", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func optList(args map[string]any, key string) ([]string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
s, ok := it.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// oneOf refuses a value outside a closed set.
|
||||
func oneOf(key, value string, allowed ...string) error {
|
||||
for _, a := range allowed {
|
||||
if value == a {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||
}
|
||||
|
||||
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||
// snap, application and printer names never do.
|
||||
func plainName(key, value string) error {
|
||||
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
// Tests of kit.go, the same in each workstation module.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fake records the commands asked and answers each from a function of the command line.
|
||||
type fake struct {
|
||||
asked []Cmd
|
||||
answer func(line string, c Cmd) Result
|
||||
}
|
||||
|
||||
func (f *fake) runner() Runner {
|
||||
return func(c Cmd) Result {
|
||||
f.asked = append(f.asked, c)
|
||||
name, args := argv(c)
|
||||
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||
if f.answer == nil {
|
||||
return Result{}
|
||||
}
|
||||
return f.answer(line, c)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fake) lines() []string {
|
||||
out := []string{}
|
||||
for _, c := range f.asked {
|
||||
name, args := argv(c)
|
||||
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// using installs a fake runner and a non-root uid for one test.
|
||||
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||
t.Helper()
|
||||
f := &fake{answer: answer}
|
||||
wasRun, wasUID := run, euid
|
||||
run, euid = f.runner(), func() int { return 1000 }
|
||||
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||
return f
|
||||
}
|
||||
|
||||
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||
|
||||
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||
was := euid
|
||||
defer func() { euid = was }()
|
||||
euid = func() int { return 1000 }
|
||||
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||
t.Fatalf("not root: %s %v", name, args)
|
||||
}
|
||||
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||
t.Fatalf("a read is run as the account: %s", name)
|
||||
}
|
||||
euid = func() int { return 0 }
|
||||
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||
t.Fatalf("as root no sudo: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||
was := euid
|
||||
defer func() { euid = was }()
|
||||
euid = func() int { return 1000 }
|
||||
cases := []struct {
|
||||
c Cmd
|
||||
r Result
|
||||
want string
|
||||
}{
|
||||
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||
}
|
||||
for _, k := range cases {
|
||||
err := failure(k.c, k.r)
|
||||
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||
var w bounded
|
||||
big := strings.Repeat("a", MostOutput+10)
|
||||
n, _ := w.Write([]byte(big))
|
||||
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||
if r.Error != "timeout" {
|
||||
t.Fatalf("a slow command: %+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||
if r.Error != "not-found" {
|
||||
t.Fatalf("a missing program: %+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||
if r.Stdout != "given" {
|
||||
t.Fatalf("stdin: %+v", r)
|
||||
}
|
||||
start := time.Now()
|
||||
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||
if _, err := text(args, "missing"); err == nil {
|
||||
t.Error("a missing required string")
|
||||
}
|
||||
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||
t.Errorf("held to most: %d", n)
|
||||
}
|
||||
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||
t.Error("below least")
|
||||
}
|
||||
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||
t.Error("a fraction")
|
||||
}
|
||||
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||
t.Errorf("list: %v", l)
|
||||
}
|
||||
if b, _ := optFlag(args, "b", false); !b {
|
||||
t.Error("flag")
|
||||
}
|
||||
if err := plainName("name", "--all"); err == nil {
|
||||
t.Error("an option as a name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// The cups module's tools (novox/hq research 027/02, 026/05): the printers, their state, supplies and
|
||||
// driver, the queue, and printing, cancelling and choosing the default. A Go bundle the node's runtime
|
||||
// launches over stdio (ADR 0188, ADR 0193); it runs as the operator account. An act CUPS keeps for
|
||||
// its administrators goes through `sudo -n`.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
var providedBy = map[string]string{
|
||||
"lpstat": "the cups package, which this module installs",
|
||||
"lpoptions": "the cups package, which this module installs",
|
||||
"lp": "the cups package, which this module installs",
|
||||
"cancel": "the cups package, which this module installs",
|
||||
"lpadmin": "the cups package, which this module installs",
|
||||
"lpinfo": "the cups package, which this module installs",
|
||||
"cupsenable": "the cups package, which this module installs",
|
||||
"cupsaccept": "the cups package, which this module installs",
|
||||
"pacman": "this is not an Arch machine",
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var printerArg = map[string]any{"type": "string", "description": "the printer's queue name, as cups_printers answers it"}
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "cups_printers",
|
||||
Description: "Every printer queue: state, whether it is enabled and accepting jobs, the default, its device " +
|
||||
"address, make and model, whether it prints driverless (IPP Everywhere), the reasons for its state, and " +
|
||||
"supply levels where the printer reports them. (r)",
|
||||
Input: map[string]any{},
|
||||
Run: func(map[string]any) (any, error) { return Printers() },
|
||||
},
|
||||
{
|
||||
Name: "cups_queue",
|
||||
Description: "The jobs waiting or printing, on every printer or one; or, with completed, the finished ones. " +
|
||||
"Each with its id, printer, owner, size and when it was submitted. (r)",
|
||||
Input: map[string]any{
|
||||
"printer": printerArg,
|
||||
"completed": map[string]any{"type": "boolean", "description": "the finished jobs instead"},
|
||||
"limit": map[string]any{"type": "integer", "description": "at most this many, newest first (default 50, at most 500)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
p, err := optText(args, "printer", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
done, err := optFlag(args, "completed", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit, err := optWhole(args, "limit", 50, 1, 500)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Queue(p, done, limit)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cups_cancel",
|
||||
Description: "Cancel one job by its id (\"Brother-12\" or 12), or every job on a printer with all. Another " +
|
||||
"account's job is cancelled through sudo -n. (a)",
|
||||
Input: map[string]any{
|
||||
"job": map[string]any{"type": "string", "description": "the job id"},
|
||||
"printer": printerArg,
|
||||
"all": map[string]any{"type": "boolean", "description": "every job on printer"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
job, err := optText(args, "job", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := optText(args, "printer", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all, err := optFlag(args, "all", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Cancel(job, p, all)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cups_print",
|
||||
Description: "Print a file on this machine, to a printer or the default, with copies and IPP options such as " +
|
||||
"sides=two-sided-long-edge or media=A4. Answers the job id. (a)",
|
||||
Input: map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "the file's absolute path on this machine"},
|
||||
"printer": printerArg,
|
||||
"copies": map[string]any{"type": "integer", "description": "copies (default 1, at most 99)"},
|
||||
"options": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "IPP options, name to value"},
|
||||
"title": map[string]any{"type": "string", "description": "the job's title (default the file's name)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
file, err := text(args, "file")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := optText(args, "printer", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copies, err := optWhole(args, "copies", 1, 1, 99)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts, err := optionsOf(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title, err := optText(args, "title", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Print(file, p, copies, opts, title)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cups_default",
|
||||
Description: "The machine's default printer; with printer, make that printer the default (through sudo -n). " +
|
||||
"The default is CUPS's own setting, kept as the operator chose it: the mesh does not declare it. (r/a)",
|
||||
Input: map[string]any{"printer": printerArg},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
p, err := optText(args, "printer", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Default(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cups_resume",
|
||||
Description: "Enable a printer and make it accept jobs again, after CUPS stopped it on an error. Through sudo -n. (a)",
|
||||
Input: map[string]any{"printer": printerArg},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
p, err := text(args, "printer")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Resume(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cups_drivers",
|
||||
Description: "What each printer prints through (driverless or a driver's PPD), the packages that bring drivers " +
|
||||
"and backends, which of them are from outside the official repositories, and the driver models CUPS " +
|
||||
"offers, filtered by match. (r)",
|
||||
Input: map[string]any{
|
||||
"match": map[string]any{"type": "string", "description": "only models whose description contains this, any case"},
|
||||
"limit": map[string]any{"type": "integer", "description": "at most this many models (default 50, at most 1000)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
match, err := optText(args, "match", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit, err := optWhole(args, "limit", 50, 0, 1000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Drivers(match, limit)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||
// definition so the module's own tests can hold it to what it says.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// packages are the packages the module installs, sorted.
|
||||
func (m manifest) packages() []string {
|
||||
out := []string{}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" && r["absent"] != true {
|
||||
out = append(out, r["package"].(string))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// services are the units the module declares, by unit name.
|
||||
func (m manifest) services() map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "service" {
|
||||
out[r["unit"].(string)] = r
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||
t.Helper()
|
||||
registered := []string{}
|
||||
for _, tool := range tools() {
|
||||
registered = append(registered, tool.Name)
|
||||
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||
}
|
||||
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||
}
|
||||
}
|
||||
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
binary := filepath.Base(cwd)
|
||||
a := m.Build.Artifacts[0]
|
||||
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||
for k, v := range want {
|
||||
if a[k] != v {
|
||||
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||
}
|
||||
}
|
||||
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil {
|
||||
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module cups
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"module": "cups",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"tools": [
|
||||
"cups_printers",
|
||||
"cups_queue",
|
||||
"cups_cancel",
|
||||
"cups_print",
|
||||
"cups_default",
|
||||
"cups_resume",
|
||||
"cups_drivers"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "cups"
|
||||
},
|
||||
{
|
||||
"id": "driverless",
|
||||
"type": "package",
|
||||
"package": "cups-filters"
|
||||
},
|
||||
{
|
||||
"id": "socket",
|
||||
"type": "service",
|
||||
"unit": "cups.socket",
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
},
|
||||
{
|
||||
"id": "scheduler",
|
||||
"type": "service",
|
||||
"unit": "cups.service",
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/cups-tools",
|
||||
"binary": "cups-tools",
|
||||
"loads": [
|
||||
"cups-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,10 @@
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/mesh-registry:/var/lib/registry"
|
||||
]
|
||||
],
|
||||
"env": {
|
||||
"REGISTRY_STORAGE_DELETE_ENABLED": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# dmenu
|
||||
|
||||
The suckless menu, as a module (novox/hq research 026/04: "`dmenu` is a module of its own (official
|
||||
repositories), able to hold the same seat on a machine that wants it"; to-be 42 phase 2 step 7).
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| `dmenu`, `dmenu_run`, `dmenu_path`, `stest` | package `dmenu` (official repositories) |
|
||||
|
||||
## Improves: two broken calls work by existing
|
||||
|
||||
Research 026/04 measured "plain `dmenu` in two places" in the operator's configuration, with dmenu
|
||||
installed on neither workstation, so both failed. Measured again on 2026-10-04, the two are one line on
|
||||
each workstation, in the notifier's configuration:
|
||||
|
||||
- `~/.config/dunst/dunstrc`: `dmenu = /usr/bin/dmenu -p dunst:`
|
||||
|
||||
It is the menu dunst opens to pick a notification's action or link. Every other menu in the
|
||||
operator's scripts calls `rofi -dmenu`. Once this module installs the package, `/usr/bin/dmenu` exists
|
||||
and that menu opens. The `dunst` module may later point the line at the launcher seat's command
|
||||
instead (below). The line is that module's to own, so this module does not touch it.
|
||||
|
||||
## The seat it would hold: not claimed yet
|
||||
|
||||
Research 026/04 gives `node-launcher` to `rofi`, with a dmenu-compatible command as part of its
|
||||
protocol, and lets `dmenu` hold the same seat on a machine that wants it. **The seat is not in the
|
||||
controller's seat table yet**, and a claim on an unknown seat is refused. So this module claims
|
||||
nothing. Its tool `dmenu_menu` is shaped like the seat's `menu` verb (research 026/05): show a list,
|
||||
answer the chosen line. When the seat is recorded, the claim is one line here, serving `menu`.
|
||||
|
||||
## Tools
|
||||
|
||||
All answer JSON. `(r)` reads; `(d)` acts in the operator's session.
|
||||
|
||||
| tool | what |
|
||||
|---|---|
|
||||
| `dmenu_menu` (d) | show up to 1000 choices, with a prompt, as one line or a vertical list, case-insensitive by default. Answers the chosen line and its index; a typed line that is not a choice (`typed`); `cancelled` when dismissed; `timed_out` when not answered in time (default 20 s, at most 25, below the runtime's 30 s call limit) |
|
||||
| `dmenu_session` (r) | the session the menu would appear in and how it was found, or why there is none; dmenu's version |
|
||||
|
||||
**Reaching the session** works as the `xclip` module's README describes: the runtime runs as the
|
||||
account with no session words, and `session.go` finds the account's display and cookie from its own
|
||||
processes. With no session, the tool says so and shows nothing. A keyboard held by another program
|
||||
(a locked screen, an open menu) is answered as such.
|
||||
|
||||
The menu draws in fontconfig's `monospace` at dmenu's default size, which the `fonts` module makes
|
||||
JetBrains Mono Nerd Font.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
On both workstations, the `dmenu` package is installed, which neither has today. Nothing else.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
The notifier's configuration, and every `rofi -dmenu` call in the operator's scripts.
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MostWait is the longest a menu stays open: below the runtime's 30 s call limit, so an unanswered
|
||||
// menu is answered as such rather than as a call the runtime gave up on.
|
||||
const MostWait = 25
|
||||
|
||||
// MostChoices bounds the list.
|
||||
const MostChoices = 1000
|
||||
|
||||
// Ask is one menu.
|
||||
type Ask struct {
|
||||
Choices []string
|
||||
Prompt string
|
||||
Lines int
|
||||
CaseInsensitive bool
|
||||
Timeout int
|
||||
}
|
||||
|
||||
// MenuAnswer is what dmenu_menu answers.
|
||||
type MenuAnswer struct {
|
||||
Chosen string `json:"chosen,omitempty"`
|
||||
Index int `json:"index"`
|
||||
Typed bool `json:"typed"`
|
||||
Cancelled bool `json:"cancelled"`
|
||||
TimedOut bool `json:"timed_out"`
|
||||
Session Session `json:"session"`
|
||||
}
|
||||
|
||||
// Menu shows the choices and answers the one taken. dmenu prints the selected (or typed) line and
|
||||
// exits 0; it exits 1 with nothing printed when dismissed.
|
||||
func Menu(a Ask) (MenuAnswer, error) {
|
||||
if len(a.Choices) == 0 {
|
||||
return MenuAnswer{}, fmt.Errorf("choices is required: at least one line")
|
||||
}
|
||||
if len(a.Choices) > MostChoices {
|
||||
return MenuAnswer{}, fmt.Errorf("%d choices; at most %d are shown", len(a.Choices), MostChoices)
|
||||
}
|
||||
for _, c := range a.Choices {
|
||||
if strings.ContainsAny(c, "\n\r") {
|
||||
return MenuAnswer{}, fmt.Errorf("a choice holds a line break: %q", c)
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(a.Prompt, "\n\r") {
|
||||
return MenuAnswer{}, fmt.Errorf("the prompt holds a line break")
|
||||
}
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
return MenuAnswer{}, err
|
||||
}
|
||||
args := []string{}
|
||||
if a.CaseInsensitive {
|
||||
args = append(args, "-i")
|
||||
}
|
||||
if a.Lines > 0 {
|
||||
args = append(args, "-l", strconv.Itoa(a.Lines))
|
||||
}
|
||||
if a.Prompt != "" {
|
||||
args = append(args, "-p", a.Prompt)
|
||||
}
|
||||
c := Cmd{Name: "dmenu", Args: args, Stdin: strings.Join(a.Choices, "\n") + "\n", Env: s.Env(), Timeout: time.Duration(a.Timeout) * time.Second}
|
||||
r := run(c)
|
||||
out := MenuAnswer{Index: -1, Session: s}
|
||||
switch {
|
||||
case r.Error == "timeout":
|
||||
out.TimedOut = true
|
||||
return out, nil
|
||||
case r.Error != "":
|
||||
return MenuAnswer{}, failure(c, r)
|
||||
case strings.Contains(r.Stderr, "cannot open display"):
|
||||
return MenuAnswer{}, fmt.Errorf("the X session at %s (found by %s) refused the connection", s.Display, s.FoundBy)
|
||||
case strings.Contains(r.Stderr, "cannot grab keyboard"):
|
||||
return MenuAnswer{}, fmt.Errorf("dmenu could not take the keyboard: another program holds it (a locked screen, an open menu)")
|
||||
case r.Status == 1 && strings.TrimSpace(r.Stdout) == "":
|
||||
out.Cancelled = true
|
||||
return out, nil
|
||||
case r.Status != 0:
|
||||
return MenuAnswer{}, failure(c, r)
|
||||
}
|
||||
out.Chosen = strings.TrimRight(r.Stdout, "\r\n")
|
||||
for i, ch := range a.Choices {
|
||||
if ch == out.Chosen {
|
||||
out.Index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
out.Typed = out.Index < 0
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SessionCheck answers the session the menu would appear in, and dmenu's version.
|
||||
func SessionCheck() (map[string]any, error) {
|
||||
out := map[string]any{}
|
||||
if r := run(Cmd{Name: "dmenu", Args: []string{"-v"}}); r.Error == "" {
|
||||
out["dmenu"] = strings.TrimSpace(r.Stdout + r.Stderr)
|
||||
} else {
|
||||
out["dmenu"] = failure(Cmd{Name: "dmenu"}, r).Error()
|
||||
}
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
out["found"], out["why"] = false, err.Error()
|
||||
return out, nil
|
||||
}
|
||||
out["found"], out["session"] = true, s
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTheManifestIsThePackageAndClaimsNoSeatYet(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
// holdsTheBundle also holds it to no claim: node-launcher is not in the controller's seat table
|
||||
// yet (README).
|
||||
holdsTheBundle(t, m, "dmenu")
|
||||
if got := strings.Join(m.packages(), ","); got != "dmenu" || len(m.Resources) != 1 {
|
||||
t.Errorf("packages %s, resources %v", got, m.Resources)
|
||||
}
|
||||
}
|
||||
|
||||
func aSession(t *testing.T) {
|
||||
t.Helper()
|
||||
_, sockets := aMachine(t, map[string]string{"DISPLAY": ":1", "XAUTHORITY": "/home/op/.Xauthority"})
|
||||
aSocket(t, sockets, "X1")
|
||||
}
|
||||
|
||||
func TestMenuShowsTheChoicesInTheSessionAndAnswersTheOneTaken(t *testing.T) {
|
||||
aSession(t)
|
||||
f := using(t, func(string, Cmd) Result { return ok("Lock\n") })
|
||||
got, err := Menu(Ask{Choices: []string{"Shutdown", "Lock"}, Prompt: "power:", Lines: 5, CaseInsensitive: true, Timeout: 20})
|
||||
if err != nil || got.Chosen != "Lock" || got.Index != 1 || got.Typed || got.Cancelled || got.TimedOut {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
c := f.asked[0]
|
||||
if f.lines()[0] != "dmenu -i -l 5 -p power:" || c.Stdin != "Shutdown\nLock\n" || c.Timeout != 20*time.Second {
|
||||
t.Errorf("%v %+v", f.lines(), c)
|
||||
}
|
||||
if strings.Join(c.Env, " ") != "DISPLAY=:1 XAUTHORITY=/home/op/.Xauthority" {
|
||||
t.Errorf("env %v", c.Env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuTellsTypedDismissedAndUnansweredApart(t *testing.T) {
|
||||
aSession(t)
|
||||
answer := ok("something else\n")
|
||||
using(t, func(string, Cmd) Result { return answer })
|
||||
got, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5})
|
||||
if err != nil || !got.Typed || got.Index != -1 || got.Chosen != "something else" {
|
||||
t.Errorf("typed: %+v %v", got, err)
|
||||
}
|
||||
answer = Result{Status: 1}
|
||||
got, err = Menu(Ask{Choices: []string{"a"}, Timeout: 5})
|
||||
if err != nil || !got.Cancelled || got.Chosen != "" {
|
||||
t.Errorf("dismissed: %+v %v", got, err)
|
||||
}
|
||||
answer = Result{Status: 124, Error: "timeout"}
|
||||
got, err = Menu(Ask{Choices: []string{"a"}, Timeout: 5})
|
||||
if err != nil || !got.TimedOut {
|
||||
t.Errorf("unanswered: %+v %v", got, err)
|
||||
}
|
||||
answer = Result{Status: 1, Stderr: "cannot grab keyboard\n"}
|
||||
if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "keyboard") {
|
||||
t.Errorf("a held keyboard: %v", err)
|
||||
}
|
||||
answer = Result{Status: 1, Stderr: "cannot open display\n"}
|
||||
if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "refused") {
|
||||
t.Errorf("a refusing display: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuRefusesWhatCannotBeShownAndRunsNothingWithoutASession(t *testing.T) {
|
||||
aSession(t)
|
||||
f := using(t, func(string, Cmd) Result { return ok("") })
|
||||
for _, bad := range []Ask{{}, {Choices: []string{"a\nb"}}, {Choices: []string{"a"}, Prompt: "x\ny"}, {Choices: make([]string, MostChoices+1)}} {
|
||||
if _, err := Menu(bad); err == nil {
|
||||
t.Errorf("%+v accepted", bad)
|
||||
}
|
||||
}
|
||||
aMachine(t, map[string]string{})
|
||||
if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
if len(f.asked) != 0 {
|
||||
t.Errorf("ran %v", f.lines())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWaitIsBelowTheRuntimesCallLimit(t *testing.T) {
|
||||
if MostWait >= 30 {
|
||||
t.Fatalf("a menu may stay open %d s; the runtime gives a call 30", MostWait)
|
||||
}
|
||||
n, _ := optWhole(map[string]any{"timeout_seconds": float64(600)}, "timeout_seconds", 20, 1, MostWait)
|
||||
if n != MostWait {
|
||||
t.Errorf("%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCheckSaysTheVersionAndTheSession(t *testing.T) {
|
||||
aSession(t)
|
||||
using(t, func(string, Cmd) Result { return ok("dmenu-5.4\n") })
|
||||
got, err := SessionCheck()
|
||||
if err != nil || got["dmenu"] != "dmenu-5.4" || got["found"] != true {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} })
|
||||
got, _ = SessionCheck()
|
||||
if !strings.Contains(got["dmenu"].(string), "not installed") {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package main
|
||||
|
||||
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||
// than shared; a change to one copy is made to all eight.
|
||||
//
|
||||
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||
// started when it takes longer;
|
||||
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command is held to.
|
||||
const (
|
||||
CallTimeout = 20 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
)
|
||||
|
||||
// Cmd is one command a tool runs.
|
||||
type Cmd struct {
|
||||
Name string
|
||||
Args []string
|
||||
// Stdin is written to the command's standard input when not empty.
|
||||
Stdin string
|
||||
// Env is added to this process's own environment.
|
||||
Env []string
|
||||
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||
Root bool
|
||||
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||
Timeout time.Duration
|
||||
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||
// until the child exits.
|
||||
Detached bool
|
||||
}
|
||||
|
||||
// Result is what a command did.
|
||||
type Result struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Status int `json:"status"`
|
||||
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||
Error string `json:"error,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// Runner runs a command. Tests replace it; nothing else does.
|
||||
type Runner func(Cmd) Result
|
||||
|
||||
var (
|
||||
run Runner = execRun
|
||||
euid = os.Geteuid
|
||||
)
|
||||
|
||||
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||
// process is not root.
|
||||
func argv(c Cmd) (string, []string) {
|
||||
if c.Root && euid() != 0 {
|
||||
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||
}
|
||||
return c.Name, c.Args
|
||||
}
|
||||
|
||||
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||
type bounded struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (w *bounded) Write(p []byte) (int, error) {
|
||||
room := MostOutput - w.b.Len()
|
||||
if room <= 0 {
|
||||
w.cut = w.cut || len(p) > 0
|
||||
return len(p), nil
|
||||
}
|
||||
if len(p) > room {
|
||||
w.b.Write(p[:room])
|
||||
w.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return w.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(c Cmd) Result {
|
||||
timeout := c.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = CallTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
name, args := argv(c)
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||
if !c.Detached {
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
if c.Stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||
}
|
||||
var out, errs bounded
|
||||
var outFile, errFile *os.File
|
||||
if c.Detached {
|
||||
var err error
|
||||
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(outFile.Name())
|
||||
defer outFile.Close()
|
||||
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(errFile.Name())
|
||||
defer errFile.Close()
|
||||
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||
} else {
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
}
|
||||
err := cmd.Run()
|
||||
if c.Detached {
|
||||
for _, f := range []struct {
|
||||
file *os.File
|
||||
into *bounded
|
||||
}{{outFile, &out}, {errFile, &errs}} {
|
||||
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||
_, _ = io.Copy(f.into, f.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
r.Status, r.Error = 124, "timeout"
|
||||
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||
r.Status, r.Error = 127, "not-found"
|
||||
case errors.As(err, &exit):
|
||||
r.Status = exit.ExitCode()
|
||||
default:
|
||||
r.Status, r.Error = 127, err.Error()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// call runs a command and answers its result, or an error naming what went wrong.
|
||||
func call(c Cmd) (Result, error) {
|
||||
r := run(c)
|
||||
if r.Status == 0 && r.Error == "" {
|
||||
return r, nil
|
||||
}
|
||||
return r, failure(c, r)
|
||||
}
|
||||
|
||||
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||
// status with the end of what it said.
|
||||
func failure(c Cmd, r Result) error {
|
||||
program, _ := argv(c)
|
||||
switch {
|
||||
case r.Error == "not-found" && program == "sudo":
|
||||
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||
case r.Error == "not-found":
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case r.Error == "timeout":
|
||||
limit := c.Timeout
|
||||
if limit <= 0 {
|
||||
limit = CallTimeout
|
||||
}
|
||||
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||
case r.Error != "":
|
||||
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||
c.Name, firstLine(r.Stderr))
|
||||
}
|
||||
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||
if said == "" {
|
||||
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
// lines are a command's output lines, blank ones dropped.
|
||||
func lines(s string) []string {
|
||||
out := []string{}
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
out = append(out, strings.TrimRight(l, "\r"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Arguments, read the way a tool's JSON arguments arrive.
|
||||
|
||||
func text(args map[string]any, key string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "", fmt.Errorf("%s must not be empty", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func optText(args map[string]any, key, def string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def, nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s must be a number", key)
|
||||
}
|
||||
}
|
||||
if f != float64(int(f)) {
|
||||
return 0, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least {
|
||||
return 0, fmt.Errorf("%s must be at least %d", key, least)
|
||||
}
|
||||
if n > most {
|
||||
n = most
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s must be true or false", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func optList(args map[string]any, key string) ([]string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
s, ok := it.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// oneOf refuses a value outside a closed set.
|
||||
func oneOf(key, value string, allowed ...string) error {
|
||||
for _, a := range allowed {
|
||||
if value == a {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||
}
|
||||
|
||||
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||
// snap, application and printer names never do.
|
||||
func plainName(key, value string) error {
|
||||
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
// Tests of kit.go, the same in each workstation module.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fake records the commands asked and answers each from a function of the command line.
|
||||
type fake struct {
|
||||
asked []Cmd
|
||||
answer func(line string, c Cmd) Result
|
||||
}
|
||||
|
||||
func (f *fake) runner() Runner {
|
||||
return func(c Cmd) Result {
|
||||
f.asked = append(f.asked, c)
|
||||
name, args := argv(c)
|
||||
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||
if f.answer == nil {
|
||||
return Result{}
|
||||
}
|
||||
return f.answer(line, c)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fake) lines() []string {
|
||||
out := []string{}
|
||||
for _, c := range f.asked {
|
||||
name, args := argv(c)
|
||||
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// using installs a fake runner and a non-root uid for one test.
|
||||
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||
t.Helper()
|
||||
f := &fake{answer: answer}
|
||||
wasRun, wasUID := run, euid
|
||||
run, euid = f.runner(), func() int { return 1000 }
|
||||
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||
return f
|
||||
}
|
||||
|
||||
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||
|
||||
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||
was := euid
|
||||
defer func() { euid = was }()
|
||||
euid = func() int { return 1000 }
|
||||
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||
t.Fatalf("not root: %s %v", name, args)
|
||||
}
|
||||
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||
t.Fatalf("a read is run as the account: %s", name)
|
||||
}
|
||||
euid = func() int { return 0 }
|
||||
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||
t.Fatalf("as root no sudo: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||
was := euid
|
||||
defer func() { euid = was }()
|
||||
euid = func() int { return 1000 }
|
||||
cases := []struct {
|
||||
c Cmd
|
||||
r Result
|
||||
want string
|
||||
}{
|
||||
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||
}
|
||||
for _, k := range cases {
|
||||
err := failure(k.c, k.r)
|
||||
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||
var w bounded
|
||||
big := strings.Repeat("a", MostOutput+10)
|
||||
n, _ := w.Write([]byte(big))
|
||||
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||
if r.Error != "timeout" {
|
||||
t.Fatalf("a slow command: %+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||
if r.Error != "not-found" {
|
||||
t.Fatalf("a missing program: %+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||
if r.Stdout != "given" {
|
||||
t.Fatalf("stdin: %+v", r)
|
||||
}
|
||||
start := time.Now()
|
||||
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||
if _, err := text(args, "missing"); err == nil {
|
||||
t.Error("a missing required string")
|
||||
}
|
||||
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||
t.Errorf("held to most: %d", n)
|
||||
}
|
||||
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||
t.Error("below least")
|
||||
}
|
||||
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||
t.Error("a fraction")
|
||||
}
|
||||
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||
t.Errorf("list: %v", l)
|
||||
}
|
||||
if b, _ := optFlag(args, "b", false); !b {
|
||||
t.Error("flag")
|
||||
}
|
||||
if err := plainName("name", "--all"); err == nil {
|
||||
t.Error("an option as a name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// The dmenu module's tool (novox/hq research 026/04, 026/05): show the operator a menu of choices in
|
||||
// the graphical session and answer the one chosen — the dmenu-compatible command as a tool. A Go
|
||||
// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193). It runs as the operator account
|
||||
// and reaches the account's X session as session.go finds it; with no session, it says so.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
var providedBy = map[string]string{
|
||||
"dmenu": "the dmenu package, which this module installs",
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "dmenu_menu",
|
||||
Description: "Show the operator a menu of choices on their screen and answer the line chosen, its index, or " +
|
||||
"that the menu was dismissed or not answered in time (default 20 s, at most 25). The operator may also " +
|
||||
"type a line that is not a choice. Needs the operator's graphical session. (d)",
|
||||
Input: map[string]any{
|
||||
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the lines to choose from, at most 1000, none with a line break"},
|
||||
"prompt": map[string]any{"type": "string", "description": "a prompt shown left of the input"},
|
||||
"lines": map[string]any{"type": "integer", "description": "show the choices as a vertical list of this many lines (default 0: one horizontal line; at most 40)"},
|
||||
"case_insensitive": map[string]any{"type": "boolean", "description": "match what is typed regardless of case (default true)"},
|
||||
"timeout_seconds": map[string]any{"type": "integer", "description": "close the menu unanswered after this long (default 20, at most 25)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
choices, err := optList(args, "choices")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prompt, err := optText(args, "prompt", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := optWhole(args, "lines", 0, 0, 40)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ci, err := optFlag(args, "case_insensitive", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeout, err := optWhole(args, "timeout_seconds", 20, 1, MostWait)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Menu(Ask{Choices: choices, Prompt: prompt, Lines: n, CaseInsensitive: ci, Timeout: timeout})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "dmenu_session",
|
||||
Description: "Which X session the menu would appear in and how it was found, or why there is none; and dmenu's version. (r)",
|
||||
Input: map[string]any{},
|
||||
Run: func(map[string]any) (any, error) { return SessionCheck() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||
// definition so the module's own tests can hold it to what it says.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// packages are the packages the module installs, sorted.
|
||||
func (m manifest) packages() []string {
|
||||
out := []string{}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" && r["absent"] != true {
|
||||
out = append(out, r["package"].(string))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// services are the units the module declares, by unit name.
|
||||
func (m manifest) services() map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "service" {
|
||||
out[r["unit"].(string)] = r
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||
t.Helper()
|
||||
registered := []string{}
|
||||
for _, tool := range tools() {
|
||||
registered = append(registered, tool.Name)
|
||||
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||
}
|
||||
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||
}
|
||||
}
|
||||
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
binary := filepath.Base(cwd)
|
||||
a := m.Build.Artifacts[0]
|
||||
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||
for k, v := range want {
|
||||
if a[k] != v {
|
||||
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||
}
|
||||
}
|
||||
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil {
|
||||
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
// session.go is the same file in the bundles whose tools act in the operator's graphical session
|
||||
// (xclip, dmenu): how a process the node's tool runtime launched reaches that session.
|
||||
//
|
||||
// The runtime is a system service running as the operator account (novox/hq ADR 0175 §4), in the
|
||||
// machine's own mount namespace, and is given no session words: no DISPLAY, no XAUTHORITY. An X
|
||||
// server accepts a client that names its display and presents the cookie in the authority file, and
|
||||
// both are the account's: the display's socket is in /tmp/.X11-unix, and the cookie file is
|
||||
// readable by the account. So the session is found, not configured:
|
||||
//
|
||||
// 1. the process's own DISPLAY, when the runtime happens to have one;
|
||||
// 2. else the DISPLAY and XAUTHORITY of the account's own running processes, read from
|
||||
// /proc/<pid>/environ (the window manager's, by preference), whose socket exists;
|
||||
// 3. else the only X socket there is, with the authority file in the account's home.
|
||||
//
|
||||
// When none is found the tool says that no graphical session of the account is running, and does
|
||||
// nothing.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Session is the operator's X session as a tool reaches it.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
// FoundBy says how: "environment", "process <pid> (<name>)" or "socket".
|
||||
FoundBy string `json:"found_by"`
|
||||
}
|
||||
|
||||
// Env is what a command needs to reach the session.
|
||||
func (s Session) Env() []string {
|
||||
env := []string{"DISPLAY=" + s.Display}
|
||||
if s.XAuthority != "" {
|
||||
env = append(env, "XAUTHORITY="+s.XAuthority)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// Where the session is looked for. Tests point these at a tree of their own.
|
||||
var (
|
||||
procRoot = "/proc"
|
||||
x11Sockets = "/tmp/.X11-unix"
|
||||
getenv = os.Getenv
|
||||
myUID = os.Getuid
|
||||
)
|
||||
|
||||
// sessionWMs are the programs whose environment is the session's own, preferred over any other
|
||||
// process's (a terminal's child may carry a stale or forwarded DISPLAY).
|
||||
var sessionWMs = map[string]bool{"i3": true, "sway": true, "xinit": true, "i3bar": true, "picom": true, "dunst": true}
|
||||
|
||||
func accountHome() string {
|
||||
if h := strings.TrimSpace(getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
if h := strings.TrimSpace(getenv("HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
// socketOf is the local socket of a display such as ":1" or ":1.0", or "" for a remote one.
|
||||
func socketOf(display string) string {
|
||||
if !strings.HasPrefix(display, ":") {
|
||||
return ""
|
||||
}
|
||||
n := strings.TrimPrefix(display, ":")
|
||||
if i := strings.IndexByte(n, '.'); i >= 0 {
|
||||
n = n[:i]
|
||||
}
|
||||
if _, err := strconv.Atoi(n); err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(x11Sockets, "X"+n)
|
||||
}
|
||||
|
||||
func exists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// findSession answers the account's X session, or an error saying there is none.
|
||||
func findSession() (Session, error) {
|
||||
if d := strings.TrimSpace(getenv("DISPLAY")); d != "" {
|
||||
if s := socketOf(d); s == "" || exists(s) {
|
||||
return Session{Display: d, XAuthority: getenv("XAUTHORITY"), FoundBy: "environment"}, nil
|
||||
}
|
||||
}
|
||||
type seen struct {
|
||||
Session
|
||||
wm bool
|
||||
count int
|
||||
}
|
||||
found := map[string]*seen{}
|
||||
entries, _ := os.ReadDir(procRoot)
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil || !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(procRoot, e.Name())
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != myUID() {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var display, auth string
|
||||
for _, kv := range strings.Split(string(raw), "\x00") {
|
||||
switch {
|
||||
case strings.HasPrefix(kv, "DISPLAY="):
|
||||
display = strings.TrimPrefix(kv, "DISPLAY=")
|
||||
case strings.HasPrefix(kv, "XAUTHORITY="):
|
||||
auth = strings.TrimPrefix(kv, "XAUTHORITY=")
|
||||
}
|
||||
}
|
||||
if display == "" {
|
||||
continue
|
||||
}
|
||||
if s := socketOf(display); s == "" || !exists(s) {
|
||||
continue
|
||||
}
|
||||
comm, _ := os.ReadFile(filepath.Join(dir, "comm"))
|
||||
name := strings.TrimSpace(string(comm))
|
||||
key := display + "\x00" + auth
|
||||
if found[key] == nil {
|
||||
found[key] = &seen{Session: Session{Display: display, XAuthority: auth, FoundBy: fmt.Sprintf("process %d (%s)", pid, name)}}
|
||||
}
|
||||
f := found[key]
|
||||
f.count++
|
||||
if sessionWMs[name] && !f.wm {
|
||||
f.wm = true
|
||||
f.FoundBy = fmt.Sprintf("process %d (%s)", pid, name)
|
||||
}
|
||||
}
|
||||
if len(found) > 0 {
|
||||
all := make([]*seen, 0, len(found))
|
||||
for _, f := range found {
|
||||
all = append(all, f)
|
||||
}
|
||||
sort.Slice(all, func(i, k int) bool {
|
||||
if all[i].wm != all[k].wm {
|
||||
return all[i].wm
|
||||
}
|
||||
if all[i].count != all[k].count {
|
||||
return all[i].count > all[k].count
|
||||
}
|
||||
return all[i].Display < all[k].Display
|
||||
})
|
||||
return all[0].Session, nil
|
||||
}
|
||||
sockets, _ := filepath.Glob(filepath.Join(x11Sockets, "X*"))
|
||||
if len(sockets) == 1 {
|
||||
s := Session{Display: ":" + strings.TrimPrefix(filepath.Base(sockets[0]), "X"), FoundBy: "socket"}
|
||||
if a := filepath.Join(accountHome(), ".Xauthority"); exists(a) {
|
||||
s.XAuthority = a
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
if len(sockets) > 1 {
|
||||
return Session{}, fmt.Errorf("no process of this account names its X display, and there are %d X sockets in %s: which one is the operator's session cannot be told", len(sockets), x11Sockets)
|
||||
}
|
||||
return Session{}, fmt.Errorf("no graphical session of this account is running on this machine: no process of the account has DISPLAY set, and there is no X socket in %s. A desktop tool acts only while the operator is logged in to the graphical session", x11Sockets)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// aMachine gives findSession a /proc and an X socket directory of the test's own.
|
||||
func aMachine(t *testing.T, env map[string]string) (proc, sockets string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
proc, sockets = filepath.Join(root, "proc"), filepath.Join(root, "x11")
|
||||
for _, d := range []string{proc, sockets} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
wasProc, wasX, wasEnv := procRoot, x11Sockets, getenv
|
||||
procRoot, x11Sockets = proc, sockets
|
||||
getenv = func(k string) string { return env[k] }
|
||||
t.Cleanup(func() { procRoot, x11Sockets, getenv = wasProc, wasX, wasEnv })
|
||||
return proc, sockets
|
||||
}
|
||||
|
||||
func aProcess(t *testing.T, proc string, pid int, comm string, env ...string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(proc, strconv.Itoa(pid))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o644)
|
||||
}
|
||||
|
||||
func aSocket(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTheWindowManagersDisplayAndCookieAreTheSessions(t *testing.T) {
|
||||
proc, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": "/home/op"})
|
||||
aSocket(t, sockets, "X1")
|
||||
aProcess(t, proc, 3, "bash", "DISPLAY=:9", "XAUTHORITY=/stale")
|
||||
aProcess(t, proc, 4, "kitty", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority")
|
||||
aProcess(t, proc, 5, "i3", "DISPLAY=:1.0", "XAUTHORITY=/home/op/.Xauthority")
|
||||
aProcess(t, proc, 6, "sshd", "PATH=/bin")
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Display != ":1.0" || s.XAuthority != "/home/op/.Xauthority" || !strings.Contains(s.FoundBy, "i3") {
|
||||
t.Fatalf("%+v: a display without a socket (:9) is skipped, and the window manager's is preferred", s)
|
||||
}
|
||||
if got := strings.Join(s.Env(), " "); got != "DISPLAY=:1.0 XAUTHORITY=/home/op/.Xauthority" {
|
||||
t.Fatalf("env %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTheOnlySocketWithTheHomesCookieIsTheFallback(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(home, ".Xauthority"), []byte("c"), 0o600)
|
||||
_, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": home})
|
||||
aSocket(t, sockets, "X0")
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":0" || s.XAuthority != filepath.Join(home, ".Xauthority") || s.FoundBy != "socket" {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionNoSessionIsSaidNotGuessed(t *testing.T) {
|
||||
_, sockets := aMachine(t, map[string]string{})
|
||||
if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("none: %v", err)
|
||||
}
|
||||
aSocket(t, sockets, "X0")
|
||||
aSocket(t, sockets, "X1")
|
||||
if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "2 X sockets") {
|
||||
t.Fatalf("two: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTheProcessesOwnDisplayComesFirst(t *testing.T) {
|
||||
_, sockets := aMachine(t, map[string]string{"DISPLAY": ":2", "XAUTHORITY": "/a"})
|
||||
aSocket(t, sockets, "X2")
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":2" || s.FoundBy != "environment" {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module dmenu
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"module": "dmenu",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"tools": [
|
||||
"dmenu_menu",
|
||||
"dmenu_session"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "dmenu"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/dmenu-tools",
|
||||
"binary": "dmenu-tools",
|
||||
"loads": [
|
||||
"dmenu-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# docker-compose
|
||||
|
||||
Compose, for development work on the two workstations (novox/hq research 027/02: "the distribution's
|
||||
package and nothing else", assigned only to the workstations; to-be 42 phase 2 step 9). The servers
|
||||
run nothing through compose.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| compose, as the container runtime's plugin (`docker compose`) and as `docker-compose` | package `docker-compose` |
|
||||
|
||||
Nothing else. The runtime, its configuration, buildx and the `docker` group are the `docker` module's
|
||||
(to-be 42 phase 1 step 8). This module needs that runtime on the machine. Until the `docker` module
|
||||
holds `node-container-runtime` there, nothing in the mesh says so, and the tools answer that the
|
||||
runtime is missing or unreachable rather than an empty list.
|
||||
|
||||
## Improves
|
||||
|
||||
- **An owner for a package both workstations already carry.** On both, `docker-compose` 5.5.0 is
|
||||
installed explicitly by hand, as the plugin in `/usr/lib/docker/cli-plugins`. Assigning the module
|
||||
changes nothing on disk; from then on the package is the mesh's, upgraded with the machine and
|
||||
given back when the module goes.
|
||||
- **The projects become visible from the mesh** without a shell on the machine: which are running,
|
||||
where their files are, their containers, logs and rendered configuration.
|
||||
- **No account-level copy of the plugin** exists on either workstation (`~/.docker/cli-plugins` is
|
||||
empty), so there is no second compose to remove.
|
||||
|
||||
## Tools
|
||||
|
||||
All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account, which reaches the runtime
|
||||
through the `docker` group. Nothing goes through `sudo`.
|
||||
|
||||
A project is named by `dir`, its absolute directory, or by `project`, its name. A directory docker
|
||||
already knows a project for is that project, with the files it was started from, overrides included.
|
||||
Any other directory must hold a compose file.
|
||||
|
||||
| tool | what |
|
||||
|---|---|
|
||||
| `docker_compose_projects` (r) | every project docker knows, running or stopped: status, working directory (from the containers' labels), compose files, services, containers running of total |
|
||||
| `docker_compose_ps` (r) | one project's containers: service, state, health, exit code, image, published ports |
|
||||
| `docker_compose_logs` (r) | the last lines per service (default 200, at most 5000), optionally `since`; cut at 256 KiB |
|
||||
| `docker_compose_config` (r) | the rendered configuration. Values of environment variables, build arguments and labels whose names suggest a secret, and inline secret or config content, are replaced with `[redacted]`, and the answer counts them |
|
||||
| `docker_compose_up` (a) | `up --detach`, with the pull policy (default `missing`) and optionally `--build`, for all or some services |
|
||||
| `docker_compose_down` (a) | `down`: containers and networks. **Volumes are kept**: no tool here removes data |
|
||||
| `docker_compose_restart` (a) | restart all or some services |
|
||||
| `docker_compose_pull` (a) | pull images without starting anything |
|
||||
| `docker_compose_job` (r) | a long act's state: running or finished, exit status, the end of its output; without an id, every act this process knows |
|
||||
|
||||
**Acts are jobs.** An `up` that pulls or builds takes minutes, and the runtime gives a call 30 s. Each
|
||||
act runs inside the tool's process for up to 15 minutes, and is waited on for 18 s. A finished act is
|
||||
answered with its output, and a failed one as an error. One still running is answered with its job id,
|
||||
which `docker_compose_job` follows. A job ends if the runtime restarts the bundle.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
The projects themselves are the operator's work, under the operator's directories. Measured on
|
||||
2026-10-04:
|
||||
|
||||
- **laptop:** `anton-lavinmq` and `anton-traefik` running, `anton-redis` stopped.
|
||||
- **desktop:** `anton-lavinmq`, `lavinmq` (from `/services/lavinmq`, a predecessor's directory) and
|
||||
`registry` running.
|
||||
|
||||
Whether the desktop's `lavinmq` and `registry` should still run is the operator's call;
|
||||
`docker_compose_down` with their directory stops them.
|
||||
@@ -0,0 +1,455 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Project is one compose project as docker knows it.
|
||||
type Project struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status,omitempty"`
|
||||
WorkingDir string `json:"working_dir,omitempty"`
|
||||
ConfigFiles []string `json:"config_files"`
|
||||
Services []string `json:"services"`
|
||||
Running int `json:"running"`
|
||||
Containers int `json:"containers"`
|
||||
}
|
||||
|
||||
// ProjectsAnswer is what docker_compose_projects answers.
|
||||
type ProjectsAnswer struct {
|
||||
Count int `json:"count"`
|
||||
Projects []Project `json:"projects"`
|
||||
}
|
||||
|
||||
// composeFiles are the names compose looks for in a directory, in its order.
|
||||
var composeFiles = []string{"compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"}
|
||||
|
||||
// statDir says whether a path is a directory. Tests replace it.
|
||||
var statDir = func(p string) bool {
|
||||
info, err := os.Stat(p)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// statFile says whether a path is a regular file. Tests replace it.
|
||||
var statFile = func(p string) bool {
|
||||
info, err := os.Stat(p)
|
||||
return err == nil && info.Mode().IsRegular()
|
||||
}
|
||||
|
||||
// docker runs one docker command and names a daemon the account cannot reach as such.
|
||||
func docker(args ...string) (Result, error) {
|
||||
r, err := call(Cmd{Name: "docker", Args: args})
|
||||
if err != nil && strings.Contains(r.Stderr, "permission denied") && strings.Contains(r.Stderr, "docker.sock") {
|
||||
return r, fmt.Errorf("the account cannot reach the container runtime's socket (permission denied): it is not in the docker group, or has not logged in since it was added. The docker module owns the group's members")
|
||||
}
|
||||
if err != nil && strings.Contains(r.Stderr, "Cannot connect to the Docker daemon") {
|
||||
return r, fmt.Errorf("the container runtime is not running on this machine: %s", firstLine(r.Stderr))
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
// Projects merges what compose lists with what the containers' labels say.
|
||||
func Projects() (ProjectsAnswer, error) {
|
||||
r, err := docker("compose", "ls", "--all", "--format", "json")
|
||||
if err != nil {
|
||||
return ProjectsAnswer{}, err
|
||||
}
|
||||
var listed []struct {
|
||||
Name string `json:"Name"`
|
||||
Status string `json:"Status"`
|
||||
ConfigFiles string `json:"ConfigFiles"`
|
||||
}
|
||||
if s := strings.TrimSpace(r.Stdout); s != "" {
|
||||
if err := json.Unmarshal([]byte(s), &listed); err != nil {
|
||||
return ProjectsAnswer{}, fmt.Errorf("docker compose ls answered what is not JSON: %v", err)
|
||||
}
|
||||
}
|
||||
by := map[string]*Project{}
|
||||
get := func(name string) *Project {
|
||||
if by[name] == nil {
|
||||
by[name] = &Project{Name: name, ConfigFiles: []string{}, Services: []string{}}
|
||||
}
|
||||
return by[name]
|
||||
}
|
||||
for _, l := range listed {
|
||||
p := get(l.Name)
|
||||
p.Status = l.Status
|
||||
p.ConfigFiles = splitFiles(l.ConfigFiles)
|
||||
}
|
||||
r, err = docker("ps", "-a", "--filter", "label=com.docker.compose.project", "--format",
|
||||
`{{.Label "com.docker.compose.project"}}`+"\t"+`{{.Label "com.docker.compose.project.working_dir"}}`+"\t"+
|
||||
`{{.Label "com.docker.compose.project.config_files"}}`+"\t"+`{{.Label "com.docker.compose.service"}}`+"\t{{.State}}")
|
||||
if err != nil {
|
||||
return ProjectsAnswer{}, err
|
||||
}
|
||||
services := map[string]map[string]bool{}
|
||||
for _, l := range lines(r.Stdout) {
|
||||
f := strings.Split(l, "\t")
|
||||
if len(f) < 5 || f[0] == "" {
|
||||
continue
|
||||
}
|
||||
p := get(f[0])
|
||||
if p.WorkingDir == "" {
|
||||
p.WorkingDir = f[1]
|
||||
}
|
||||
if len(p.ConfigFiles) == 0 {
|
||||
p.ConfigFiles = splitFiles(f[2])
|
||||
}
|
||||
if services[f[0]] == nil {
|
||||
services[f[0]] = map[string]bool{}
|
||||
}
|
||||
if f[3] != "" {
|
||||
services[f[0]][f[3]] = true
|
||||
}
|
||||
p.Containers++
|
||||
if f[4] == "running" {
|
||||
p.Running++
|
||||
}
|
||||
}
|
||||
out := ProjectsAnswer{Projects: []Project{}}
|
||||
for name, p := range by {
|
||||
for s := range services[name] {
|
||||
p.Services = append(p.Services, s)
|
||||
}
|
||||
sort.Strings(p.Services)
|
||||
if p.WorkingDir == "" && len(p.ConfigFiles) > 0 {
|
||||
p.WorkingDir = filepath.Dir(p.ConfigFiles[0])
|
||||
}
|
||||
out.Projects = append(out.Projects, *p)
|
||||
}
|
||||
sort.Slice(out.Projects, func(i, k int) bool { return out.Projects[i].Name < out.Projects[k].Name })
|
||||
out.Count = len(out.Projects)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func splitFiles(s string) []string {
|
||||
out := []string{}
|
||||
for _, f := range strings.Split(s, ",") {
|
||||
if f = strings.TrimSpace(f); f != "" {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Target is the project a tool acts on, and how compose is told which it is.
|
||||
type Target struct {
|
||||
Project string `json:"project,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
// args are compose's own options naming the target.
|
||||
func (t Target) args() []string {
|
||||
out := []string{"compose"}
|
||||
if t.Dir != "" {
|
||||
out = append(out, "--project-directory", t.Dir)
|
||||
}
|
||||
for _, f := range t.Files {
|
||||
out = append(out, "-f", f)
|
||||
}
|
||||
if t.Project != "" {
|
||||
out = append(out, "-p", t.Project)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var projectName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
// targetOf reads dir or project. A directory docker already knows a project for is that project,
|
||||
// with the files it was started from; otherwise it must hold a compose file. needFiles says the
|
||||
// tool reads the files (config, up, pull), so a project known only by its containers is not enough.
|
||||
func targetOf(args map[string]any, needFiles bool) (Target, error) {
|
||||
dir, err := optText(args, "dir", "")
|
||||
if err != nil {
|
||||
return Target{}, err
|
||||
}
|
||||
name, err := optText(args, "project", "")
|
||||
if err != nil {
|
||||
return Target{}, err
|
||||
}
|
||||
if dir == "" && name == "" {
|
||||
return Target{}, fmt.Errorf("give dir, the project's directory, or project, its name")
|
||||
}
|
||||
if dir != "" {
|
||||
if !filepath.IsAbs(dir) {
|
||||
return Target{}, fmt.Errorf("dir must be an absolute path, not %q", dir)
|
||||
}
|
||||
dir = filepath.Clean(dir)
|
||||
if !statDir(dir) {
|
||||
return Target{}, fmt.Errorf("%s is not a directory on this machine", dir)
|
||||
}
|
||||
}
|
||||
if name != "" && !projectName.MatchString(name) {
|
||||
return Target{}, fmt.Errorf("%q is not a compose project name", name)
|
||||
}
|
||||
known, err := Projects()
|
||||
if err != nil {
|
||||
return Target{}, err
|
||||
}
|
||||
for _, p := range known.Projects {
|
||||
if (dir != "" && p.WorkingDir == dir) || (dir == "" && p.Name == name) {
|
||||
if name != "" && p.Name != name {
|
||||
continue
|
||||
}
|
||||
t := Target{Project: p.Name, Dir: p.WorkingDir}
|
||||
present := len(p.ConfigFiles) > 0
|
||||
for _, f := range p.ConfigFiles {
|
||||
present = present && statFile(f)
|
||||
}
|
||||
if present {
|
||||
t.Files = p.ConfigFiles
|
||||
} else if needFiles && !hasComposeFile(t.Dir) {
|
||||
return Target{}, fmt.Errorf("project %s was started from %s, which is no longer there", p.Name, strings.Join(p.ConfigFiles, ", "))
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
if dir == "" {
|
||||
return Target{}, fmt.Errorf("no compose project named %s is known to docker here: give dir, its directory", name)
|
||||
}
|
||||
if !hasComposeFile(dir) {
|
||||
return Target{}, fmt.Errorf("%s holds no compose file (%s)", dir, strings.Join(composeFiles, ", "))
|
||||
}
|
||||
return Target{Dir: dir, Project: name}, nil
|
||||
}
|
||||
|
||||
func hasComposeFile(dir string) bool {
|
||||
for _, f := range composeFiles {
|
||||
if statFile(filepath.Join(dir, f)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Container is one of a project's containers.
|
||||
type Container struct {
|
||||
Name string `json:"name"`
|
||||
Service string `json:"service"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
Health string `json:"health,omitempty"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Image string `json:"image"`
|
||||
Ports []string `json:"ports"`
|
||||
}
|
||||
|
||||
// PsAnswer is what docker_compose_ps answers.
|
||||
type PsAnswer struct {
|
||||
Target Target `json:"target"`
|
||||
Containers []Container `json:"containers"`
|
||||
}
|
||||
|
||||
// jsonObjects reads compose's JSON output, which is one array or one object per line by version.
|
||||
func jsonObjects(s string, into any) error {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
s = "[]"
|
||||
}
|
||||
if !strings.HasPrefix(s, "[") {
|
||||
s = "[" + strings.Join(lines(s), ",") + "]"
|
||||
}
|
||||
return json.Unmarshal([]byte(s), into)
|
||||
}
|
||||
|
||||
// Ps answers a project's containers.
|
||||
func Ps(t Target) (PsAnswer, error) {
|
||||
r, err := docker(append(t.args(), "ps", "-a", "--format", "json")...)
|
||||
if err != nil {
|
||||
return PsAnswer{}, err
|
||||
}
|
||||
var raw []struct {
|
||||
Name string `json:"Name"`
|
||||
Service string `json:"Service"`
|
||||
State string `json:"State"`
|
||||
Status string `json:"Status"`
|
||||
Health string `json:"Health"`
|
||||
ExitCode int `json:"ExitCode"`
|
||||
Image string `json:"Image"`
|
||||
Publishers []struct {
|
||||
URL string `json:"URL"`
|
||||
TargetPort int `json:"TargetPort"`
|
||||
PublishedPort int `json:"PublishedPort"`
|
||||
Protocol string `json:"Protocol"`
|
||||
} `json:"Publishers"`
|
||||
}
|
||||
if err := jsonObjects(r.Stdout, &raw); err != nil {
|
||||
return PsAnswer{}, fmt.Errorf("docker compose ps answered what is not JSON: %v", err)
|
||||
}
|
||||
out := PsAnswer{Target: t, Containers: []Container{}}
|
||||
for _, c := range raw {
|
||||
ports := []string{}
|
||||
for _, p := range c.Publishers {
|
||||
if p.PublishedPort == 0 {
|
||||
continue
|
||||
}
|
||||
ports = append(ports, fmt.Sprintf("%s:%d->%d/%s", p.URL, p.PublishedPort, p.TargetPort, p.Protocol))
|
||||
}
|
||||
out.Containers = append(out.Containers, Container{Name: c.Name, Service: c.Service, State: c.State, Status: c.Status,
|
||||
Health: c.Health, ExitCode: c.ExitCode, Image: c.Image, Ports: ports})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LogsAnswer is what docker_compose_logs answers.
|
||||
type LogsAnswer struct {
|
||||
Target Target `json:"target"`
|
||||
Lines []string `json:"lines"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
var since = regexp.MustCompile(`^[0-9A-Za-z:.+-]+$`)
|
||||
|
||||
// Logs answers a project's last lines.
|
||||
func Logs(t Target, services []string, n int, from string) (LogsAnswer, error) {
|
||||
args := append(t.args(), "logs", "--no-color", "--timestamps", "--tail", fmt.Sprint(n))
|
||||
if from != "" {
|
||||
if !since.MatchString(from) {
|
||||
return LogsAnswer{}, fmt.Errorf("since %q is neither a duration nor a timestamp", from)
|
||||
}
|
||||
args = append(args, "--since", from)
|
||||
}
|
||||
for _, s := range services {
|
||||
if err := plainName("service", s); err != nil {
|
||||
return LogsAnswer{}, err
|
||||
}
|
||||
}
|
||||
r, err := docker(append(args, services...)...)
|
||||
if err != nil {
|
||||
return LogsAnswer{}, err
|
||||
}
|
||||
// compose writes the containers' output on its stdout, and its own complaints on stderr.
|
||||
return LogsAnswer{Target: t, Lines: lines(r.Stdout), Truncated: r.Truncated}, nil
|
||||
}
|
||||
|
||||
// ConfigAnswer is what docker_compose_config answers.
|
||||
type ConfigAnswer struct {
|
||||
Target Target `json:"target"`
|
||||
Services []string `json:"services"`
|
||||
Redacted int `json:"redacted"`
|
||||
Rendered map[string]any `json:"rendered"`
|
||||
}
|
||||
|
||||
// secretish is a name whose value is not shown.
|
||||
var secretish = regexp.MustCompile(`(?i)(pass|secret|token|key|credential|auth|private|cert|cookie|session|salt|dsn|api)`)
|
||||
|
||||
// redact replaces the values of secret-looking names in the maps compose renders.
|
||||
func redact(v any, count *int) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
for k, child := range x {
|
||||
switch k {
|
||||
case "environment", "args", "labels", "build_args":
|
||||
if m, ok := child.(map[string]any); ok {
|
||||
for name, val := range m {
|
||||
if val != nil && secretish.MatchString(name) {
|
||||
m[name] = "[redacted]"
|
||||
*count++
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
case "content":
|
||||
// An inline config or secret: its content is the secret itself.
|
||||
if _, ok := child.(string); ok {
|
||||
x[k] = "[redacted]"
|
||||
*count++
|
||||
continue
|
||||
}
|
||||
}
|
||||
redact(child, count)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range x {
|
||||
redact(child, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Config answers the rendered configuration.
|
||||
func Config(t Target) (ConfigAnswer, error) {
|
||||
r, err := docker(append(t.args(), "config", "--format", "json")...)
|
||||
if err != nil {
|
||||
return ConfigAnswer{}, err
|
||||
}
|
||||
var rendered map[string]any
|
||||
if err := json.Unmarshal([]byte(r.Stdout), &rendered); err != nil {
|
||||
return ConfigAnswer{}, fmt.Errorf("docker compose config answered what is not JSON: %v", err)
|
||||
}
|
||||
out := ConfigAnswer{Target: t, Services: []string{}, Rendered: rendered}
|
||||
if s, ok := rendered["services"].(map[string]any); ok {
|
||||
for name := range s {
|
||||
out.Services = append(out.Services, name)
|
||||
}
|
||||
sort.Strings(out.Services)
|
||||
}
|
||||
redact(rendered, &out.Redacted)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ActAnswer is what an act answers: the target and the job that carries it.
|
||||
type ActAnswer struct {
|
||||
Act string `json:"act"`
|
||||
Target Target `json:"target"`
|
||||
Job Job `json:"job"`
|
||||
}
|
||||
|
||||
func act(name string, t Target, extra ...string) (ActAnswer, error) {
|
||||
j, err := actAsJob(Cmd{Name: "docker", Args: append(append(t.args(), name), extra...)})
|
||||
if err != nil {
|
||||
return ActAnswer{}, err
|
||||
}
|
||||
return ActAnswer{Act: name, Target: t, Job: j}, nil
|
||||
}
|
||||
|
||||
func checkServices(services []string) error {
|
||||
for _, s := range services {
|
||||
if err := plainName("service", s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Up brings a project up, detached.
|
||||
func Up(t Target, services []string, build bool, pull string) (ActAnswer, error) {
|
||||
if err := oneOf("pull", pull, "missing", "always", "never"); err != nil {
|
||||
return ActAnswer{}, err
|
||||
}
|
||||
if err := checkServices(services); err != nil {
|
||||
return ActAnswer{}, err
|
||||
}
|
||||
extra := []string{"--detach", "--pull", pull}
|
||||
if build {
|
||||
extra = append(extra, "--build")
|
||||
}
|
||||
return act("up", t, append(extra, services...)...)
|
||||
}
|
||||
|
||||
// Down stops and removes a project's containers and networks, keeping its volumes.
|
||||
func Down(t Target) (ActAnswer, error) {
|
||||
return act("down", t)
|
||||
}
|
||||
|
||||
// Restart restarts a project's containers.
|
||||
func Restart(t Target, services []string) (ActAnswer, error) {
|
||||
if err := checkServices(services); err != nil {
|
||||
return ActAnswer{}, err
|
||||
}
|
||||
return act("restart", t, services...)
|
||||
}
|
||||
|
||||
// Pull pulls a project's images.
|
||||
func Pull(t Target, services []string) (ActAnswer, error) {
|
||||
if err := checkServices(services); err != nil {
|
||||
return ActAnswer{}, err
|
||||
}
|
||||
return act("pull", t, services...)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheManifestIsComposesPackageAndNothingElse(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
holdsTheBundle(t, m, "docker_compose")
|
||||
if got := strings.Join(m.packages(), ","); got != "docker-compose" {
|
||||
t.Errorf("packages %s: buildx and the runtime are the docker module's", got)
|
||||
}
|
||||
if len(m.Resources) != 1 {
|
||||
t.Errorf("one resource, the package: %v", m.Resources)
|
||||
}
|
||||
}
|
||||
|
||||
const lsJSON = `[{"Name":"anton-lavinmq","Status":"running(1)","ConfigFiles":"/home/op/hub/lavinmq/docker-compose.yml"},{"Name":"old","Status":"exited(2)","ConfigFiles":"/srv/old/compose.yaml,/srv/old/compose.override.yaml"}]`
|
||||
|
||||
const psLabels = "anton-lavinmq\t/home/op/hub/lavinmq\t/home/op/hub/lavinmq/docker-compose.yml\tlavinmq\trunning\n" +
|
||||
"old\t/srv/old\t/srv/old/compose.yaml,/srv/old/compose.override.yaml\tweb\texited\n" +
|
||||
"old\t/srv/old\t/srv/old/compose.yaml,/srv/old/compose.override.yaml\tdb\texited\n"
|
||||
|
||||
// aDocker answers compose ls and the labelled ps, and hands every other line to rest.
|
||||
func aDocker(t *testing.T, rest func(line string) Result) *fake {
|
||||
return using(t, func(line string, c Cmd) Result {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "docker compose ls"):
|
||||
return ok(lsJSON)
|
||||
case strings.HasPrefix(line, "docker ps -a --filter label=com.docker.compose.project"):
|
||||
return ok(psLabels)
|
||||
}
|
||||
if rest != nil {
|
||||
return rest(line)
|
||||
}
|
||||
return ok("")
|
||||
})
|
||||
}
|
||||
|
||||
func onDisk(t *testing.T, dirs, files []string) {
|
||||
t.Helper()
|
||||
wasD, wasF := statDir, statFile
|
||||
in := func(set []string) func(string) bool {
|
||||
return func(p string) bool {
|
||||
for _, s := range set {
|
||||
if s == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
statDir, statFile = in(dirs), in(files)
|
||||
t.Cleanup(func() { statDir, statFile = wasD, wasF })
|
||||
}
|
||||
|
||||
func TestProjectsMergesComposesListWithTheContainersLabels(t *testing.T) {
|
||||
aDocker(t, nil)
|
||||
got, err := Projects()
|
||||
if err != nil || got.Count != 2 {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
p := got.Projects[0]
|
||||
if p.Name != "anton-lavinmq" || p.WorkingDir != "/home/op/hub/lavinmq" || p.Running != 1 || p.Containers != 1 || p.Status != "running(1)" {
|
||||
t.Errorf("%+v", p)
|
||||
}
|
||||
o := got.Projects[1]
|
||||
if strings.Join(o.Services, ",") != "db,web" || len(o.ConfigFiles) != 2 || o.Running != 0 || o.Containers != 2 {
|
||||
t.Errorf("%+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectsNamesADaemonTheAccountCannotReach(t *testing.T) {
|
||||
using(t, func(string, Cmd) Result {
|
||||
return Result{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock"}
|
||||
})
|
||||
if _, err := Projects(); err == nil || !strings.Contains(err.Error(), "docker group") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATargetIsAKnownProjectWithItsFilesOrADirectoryHoldingAComposeFile(t *testing.T) {
|
||||
aDocker(t, nil)
|
||||
onDisk(t, []string{"/home/op/hub/lavinmq", "/srv/new", "/srv/empty", "/srv/old"},
|
||||
[]string{"/home/op/hub/lavinmq/docker-compose.yml", "/srv/new/compose.yaml"})
|
||||
got, err := targetOf(map[string]any{"dir": "/home/op/hub/lavinmq/"}, true)
|
||||
if err != nil || got.Project != "anton-lavinmq" || len(got.Files) != 1 {
|
||||
t.Fatalf("a known directory: %+v %v", got, err)
|
||||
}
|
||||
if a := strings.Join(got.args(), " "); a != "compose --project-directory /home/op/hub/lavinmq -f /home/op/hub/lavinmq/docker-compose.yml -p anton-lavinmq" {
|
||||
t.Errorf("args %s", a)
|
||||
}
|
||||
got, err = targetOf(map[string]any{"dir": "/srv/new"}, true)
|
||||
if err != nil || got.Project != "" || got.Dir != "/srv/new" {
|
||||
t.Fatalf("a new directory: %+v %v", got, err)
|
||||
}
|
||||
got, err = targetOf(map[string]any{"project": "old"}, false)
|
||||
if err != nil || got.Dir != "/srv/old" || got.Files != nil {
|
||||
t.Fatalf("a project whose files are gone, for an act that needs none: %+v %v", got, err)
|
||||
}
|
||||
if _, err := targetOf(map[string]any{"project": "old"}, true); err == nil || !strings.Contains(err.Error(), "no longer there") {
|
||||
t.Errorf("its files are needed: %v", err)
|
||||
}
|
||||
for _, bad := range []map[string]any{{}, {"dir": "rel/path"}, {"dir": "/nope"}, {"dir": "/srv/empty"}, {"project": "Bad Name"}, {"project": "unknown"}} {
|
||||
if _, err := targetOf(bad, false); err == nil {
|
||||
t.Errorf("%v was accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPsReadsEitherJSONShapeAndKeepsOnlyPublishedPorts(t *testing.T) {
|
||||
aDocker(t, func(line string) Result {
|
||||
return ok(`{"Name":"a-web-1","Service":"web","State":"running","Status":"Up 2 hours","Health":"healthy","ExitCode":0,"Image":"nginx","Publishers":[{"URL":"0.0.0.0","TargetPort":80,"PublishedPort":8080,"Protocol":"tcp"},{"URL":"","TargetPort":443,"PublishedPort":0,"Protocol":"tcp"}]}
|
||||
{"Name":"a-db-1","Service":"db","State":"exited","Status":"Exited (1)","ExitCode":1,"Image":"postgres","Publishers":[]}`)
|
||||
})
|
||||
got, err := Ps(Target{Project: "a"})
|
||||
if err != nil || len(got.Containers) != 2 {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if c := got.Containers[0]; strings.Join(c.Ports, ",") != "0.0.0.0:8080->80/tcp" || c.Health != "healthy" {
|
||||
t.Errorf("%+v", c)
|
||||
}
|
||||
var arr []map[string]any
|
||||
if err := jsonObjects(`[{"Name":"x"}]`, &arr); err != nil || len(arr) != 1 {
|
||||
t.Errorf("an array: %v %v", arr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogsAreBoundedAndRefuseAnOptionInAName(t *testing.T) {
|
||||
f := aDocker(t, func(line string) Result { return Result{Stdout: "web-1 | a\nweb-1 | b\n", Truncated: true} })
|
||||
got, err := Logs(Target{Project: "a"}, []string{"web"}, 50, "10m")
|
||||
if err != nil || len(got.Lines) != 2 || !got.Truncated {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if l := f.lines()[0]; l != "docker compose -p a logs --no-color --timestamps --tail 50 --since 10m web" {
|
||||
t.Errorf("%s", l)
|
||||
}
|
||||
if _, err := Logs(Target{Project: "a"}, []string{"--follow"}, 5, ""); err == nil {
|
||||
t.Error("an option as a service")
|
||||
}
|
||||
if _, err := Logs(Target{Project: "a"}, nil, 5, "1h; rm"); err == nil {
|
||||
t.Error("a since that is neither")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRedactsSecretLookingValuesAndInlineContent(t *testing.T) {
|
||||
aDocker(t, func(string) Result {
|
||||
return ok(`{"name":"a","services":{"web":{"image":"nginx","environment":{"DB_PASSWORD":"hunter2","PORT":"80","API_TOKEN":"t"},"build":{"args":{"NPM_TOKEN":"n","NODE_ENV":"production"}}}},"secrets":{"s":{"content":"raw"}}}`)
|
||||
})
|
||||
got, err := Config(Target{Dir: "/srv/a"})
|
||||
if err != nil || got.Redacted != 4 || strings.Join(got.Services, ",") != "web" {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
raw, _ := json.Marshal(got.Rendered)
|
||||
for _, secret := range []string{"hunter2", `"t"`, `"n"`, "raw"} {
|
||||
if strings.Contains(string(raw), secret) {
|
||||
t.Errorf("%s shown: %s", secret, raw)
|
||||
}
|
||||
}
|
||||
for _, kept := range []string{`"PORT":"80"`, `"NODE_ENV":"production"`, `"image":"nginx"`} {
|
||||
if !strings.Contains(string(raw), kept) {
|
||||
t.Errorf("%s hidden: %s", kept, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigAnInvalidFileIsComposesError(t *testing.T) {
|
||||
aDocker(t, func(string) Result {
|
||||
return Result{Status: 15, Stderr: "services.web Additional property foo is not allowed"}
|
||||
})
|
||||
if _, err := Config(Target{Dir: "/srv/a"}); err == nil || !strings.Contains(err.Error(), "Additional property") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActsAreJobsAsTheAccountKeepVolumesAndCheckTheirArguments(t *testing.T) {
|
||||
f := aDocker(t, nil)
|
||||
tg := Target{Dir: "/srv/a", Project: "a"}
|
||||
if got, err := Up(tg, []string{"web"}, true, "always"); err != nil || got.Job.Running || got.Act != "up" {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if _, err := Down(tg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Restart(tg, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Pull(tg, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"docker compose --project-directory /srv/a -p a up --detach --pull always --build web",
|
||||
"docker compose --project-directory /srv/a -p a down",
|
||||
"docker compose --project-directory /srv/a -p a restart",
|
||||
"docker compose --project-directory /srv/a -p a pull",
|
||||
}
|
||||
if got := strings.Join(f.lines(), "\n"); got != strings.Join(want, "\n") {
|
||||
t.Errorf("asked\n%s\nwant\n%s", got, strings.Join(want, "\n"))
|
||||
}
|
||||
for _, l := range f.lines() {
|
||||
if strings.Contains(l, "sudo") || strings.Contains(l, "-v") || strings.Contains(l, "--volumes") {
|
||||
t.Errorf("%s", l)
|
||||
}
|
||||
}
|
||||
if _, err := Up(tg, nil, false, "sometimes"); err == nil {
|
||||
t.Error("an unknown pull policy")
|
||||
}
|
||||
if _, err := Restart(tg, []string{"-t"}); err == nil {
|
||||
t.Error("an option as a service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedActIsAnError(t *testing.T) {
|
||||
aDocker(t, func(string) Result {
|
||||
return Result{Status: 1, Stderr: "Error response from daemon: port is already allocated"}
|
||||
})
|
||||
if _, err := Up(Target{Dir: "/srv/a"}, nil, false, "missing"); err == nil || !strings.Contains(err.Error(), "already allocated") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
// jobs.go is the same file in the bundles whose acts can outlast one call (flatpak, docker-compose):
|
||||
// an install or an `up` that pulls images takes minutes, and the runtime gives a call 30 s. Such an
|
||||
// act is started as a job inside this process, waited on for a while, and answered either finished
|
||||
// or with the job's id for the module's `_job` tool to follow. A job ends with this process: if the
|
||||
// runtime restarts the bundle, a running job is cut off, and its id is then unknown.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JobLimit is the longest a job may run; JobWait how long an act waits before answering a job id.
|
||||
const (
|
||||
JobLimit = 15 * time.Minute
|
||||
JobWait = 18 * time.Second
|
||||
keptJobs = 50
|
||||
)
|
||||
|
||||
// Job is one long act, as its tool answers it.
|
||||
type Job struct {
|
||||
ID string `json:"job"`
|
||||
Command string `json:"command"`
|
||||
Started time.Time `json:"started"`
|
||||
Finished *time.Time `json:"finished,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
Status *int `json:"status,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type jobBook struct {
|
||||
mu sync.Mutex
|
||||
seq int
|
||||
jobs map[string]*Job
|
||||
}
|
||||
|
||||
var jobs = &jobBook{jobs: map[string]*Job{}}
|
||||
|
||||
// startJob runs c in the background, held to JobLimit.
|
||||
func startJob(c Cmd) *Job {
|
||||
c.Timeout = JobLimit
|
||||
name, args := argv(c)
|
||||
jobs.mu.Lock()
|
||||
jobs.seq++
|
||||
j := &Job{ID: fmt.Sprintf("%d-%d", time.Now().Unix(), jobs.seq), Command: strings.TrimSpace(name + " " + strings.Join(args, " ")),
|
||||
Started: time.Now().UTC(), Running: true, done: make(chan struct{})}
|
||||
jobs.jobs[j.ID] = j
|
||||
jobs.forgetOldest()
|
||||
jobs.mu.Unlock()
|
||||
go func() {
|
||||
r := run(c)
|
||||
var err error
|
||||
if r.Status != 0 || r.Error != "" {
|
||||
err = failure(c, r)
|
||||
}
|
||||
jobs.mu.Lock()
|
||||
now := time.Now().UTC()
|
||||
j.Finished, j.Running = &now, false
|
||||
status := r.Status
|
||||
j.Status = &status
|
||||
if err != nil {
|
||||
j.Error = err.Error()
|
||||
}
|
||||
j.Output = tail(strings.TrimSpace(r.Stdout+"\n"+r.Stderr), 16<<10)
|
||||
j.Truncated = r.Truncated || len(r.Stdout)+len(r.Stderr) > 16<<10
|
||||
jobs.mu.Unlock()
|
||||
close(j.done)
|
||||
}()
|
||||
return j
|
||||
}
|
||||
|
||||
// forgetOldest keeps the book bounded; finished jobs go first. Called with the lock held.
|
||||
func (b *jobBook) forgetOldest() {
|
||||
if len(b.jobs) <= keptJobs {
|
||||
return
|
||||
}
|
||||
all := make([]*Job, 0, len(b.jobs))
|
||||
for _, j := range b.jobs {
|
||||
all = append(all, j)
|
||||
}
|
||||
sort.Slice(all, func(i, k int) bool { return all[i].Started.Before(all[k].Started) })
|
||||
for _, j := range all {
|
||||
if len(b.jobs) <= keptJobs {
|
||||
return
|
||||
}
|
||||
if !j.Running {
|
||||
delete(b.jobs, j.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// awaitJob waits up to d for a job to finish and answers a copy of it as it then stands.
|
||||
func awaitJob(j *Job, d time.Duration) Job {
|
||||
select {
|
||||
case <-j.done:
|
||||
case <-time.After(d):
|
||||
}
|
||||
return snapshot(j)
|
||||
}
|
||||
|
||||
func snapshot(j *Job) Job {
|
||||
jobs.mu.Lock()
|
||||
defer jobs.mu.Unlock()
|
||||
c := *j
|
||||
c.done = nil
|
||||
return c
|
||||
}
|
||||
|
||||
// jobByID answers a job by its id, or says it is not known to this process.
|
||||
func jobByID(id string) (Job, error) {
|
||||
jobs.mu.Lock()
|
||||
j, ok := jobs.jobs[id]
|
||||
jobs.mu.Unlock()
|
||||
if !ok {
|
||||
return Job{}, fmt.Errorf("no job %s in this process: it was never started here, was forgotten after %d newer ones, or the bundle has restarted since", id, keptJobs)
|
||||
}
|
||||
return snapshot(j), nil
|
||||
}
|
||||
|
||||
// listJobs answers every job this process knows, newest first.
|
||||
func listJobs() []Job {
|
||||
jobs.mu.Lock()
|
||||
all := make([]*Job, 0, len(jobs.jobs))
|
||||
for _, j := range jobs.jobs {
|
||||
all = append(all, j)
|
||||
}
|
||||
jobs.mu.Unlock()
|
||||
sort.Slice(all, func(i, k int) bool { return all[i].Started.After(all[k].Started) })
|
||||
out := make([]Job, 0, len(all))
|
||||
for _, j := range all {
|
||||
out = append(out, snapshot(j))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// actAsJob starts c and answers the job once it finishes or JobWait passes, whichever is first.
|
||||
// A finished job that failed is answered as an error, so a failed act is never read as success.
|
||||
func actAsJob(c Cmd) (Job, error) {
|
||||
j := awaitJob(startJob(c), JobWait)
|
||||
if !j.Running && j.Error != "" {
|
||||
return j, fmt.Errorf("%s (job %s)", j.Error, j.ID)
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestJobsAFastActIsAnsweredFinishedAndAFailedOneAsAnError(t *testing.T) {
|
||||
using(t, func(line string, c Cmd) Result {
|
||||
if c.Timeout != JobLimit {
|
||||
t.Errorf("a job is held to JobLimit, not %s", c.Timeout)
|
||||
}
|
||||
if strings.Contains(line, "bad") {
|
||||
return Result{Status: 2, Stderr: "it broke"}
|
||||
}
|
||||
return ok("done")
|
||||
})
|
||||
j, err := actAsJob(Cmd{Name: "good"})
|
||||
if err != nil || j.Running || j.Status == nil || *j.Status != 0 || j.Output != "done" {
|
||||
t.Fatalf("%+v %v", j, err)
|
||||
}
|
||||
if _, err := actAsJob(Cmd{Name: "bad"}); err == nil || !strings.Contains(err.Error(), "it broke") {
|
||||
t.Fatalf("a failed job: %v", err)
|
||||
}
|
||||
got, err := jobByID(j.ID)
|
||||
if err != nil || got.ID != j.ID {
|
||||
t.Fatalf("by id: %+v %v", got, err)
|
||||
}
|
||||
if _, err := jobByID("nope"); err == nil {
|
||||
t.Fatal("an unknown job")
|
||||
}
|
||||
if len(listJobs()) < 2 {
|
||||
t.Fatal("listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobsASlowActIsAnsweredRunningWithItsID(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
using(t, func(line string, c Cmd) Result { <-release; return ok("") })
|
||||
j := awaitJob(startJob(Cmd{Name: "slow"}), 50*time.Millisecond)
|
||||
if !j.Running || j.ID == "" {
|
||||
t.Fatalf("%+v", j)
|
||||
}
|
||||
close(release)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
got, _ := jobByID(j.ID)
|
||||
if got.Running {
|
||||
t.Fatalf("finished afterwards: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package main
|
||||
|
||||
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||
// than shared; a change to one copy is made to all eight.
|
||||
//
|
||||
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||
// started when it takes longer;
|
||||
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command is held to.
|
||||
const (
|
||||
CallTimeout = 20 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
)
|
||||
|
||||
// Cmd is one command a tool runs.
|
||||
type Cmd struct {
|
||||
Name string
|
||||
Args []string
|
||||
// Stdin is written to the command's standard input when not empty.
|
||||
Stdin string
|
||||
// Env is added to this process's own environment.
|
||||
Env []string
|
||||
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||
Root bool
|
||||
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||
Timeout time.Duration
|
||||
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||
// until the child exits.
|
||||
Detached bool
|
||||
}
|
||||
|
||||
// Result is what a command did.
|
||||
type Result struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Status int `json:"status"`
|
||||
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||
Error string `json:"error,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// Runner runs a command. Tests replace it; nothing else does.
|
||||
type Runner func(Cmd) Result
|
||||
|
||||
var (
|
||||
run Runner = execRun
|
||||
euid = os.Geteuid
|
||||
)
|
||||
|
||||
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||
// process is not root.
|
||||
func argv(c Cmd) (string, []string) {
|
||||
if c.Root && euid() != 0 {
|
||||
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||
}
|
||||
return c.Name, c.Args
|
||||
}
|
||||
|
||||
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||
type bounded struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (w *bounded) Write(p []byte) (int, error) {
|
||||
room := MostOutput - w.b.Len()
|
||||
if room <= 0 {
|
||||
w.cut = w.cut || len(p) > 0
|
||||
return len(p), nil
|
||||
}
|
||||
if len(p) > room {
|
||||
w.b.Write(p[:room])
|
||||
w.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return w.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(c Cmd) Result {
|
||||
timeout := c.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = CallTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
name, args := argv(c)
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||
if !c.Detached {
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
if c.Stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||
}
|
||||
var out, errs bounded
|
||||
var outFile, errFile *os.File
|
||||
if c.Detached {
|
||||
var err error
|
||||
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(outFile.Name())
|
||||
defer outFile.Close()
|
||||
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(errFile.Name())
|
||||
defer errFile.Close()
|
||||
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||
} else {
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
}
|
||||
err := cmd.Run()
|
||||
if c.Detached {
|
||||
for _, f := range []struct {
|
||||
file *os.File
|
||||
into *bounded
|
||||
}{{outFile, &out}, {errFile, &errs}} {
|
||||
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||
_, _ = io.Copy(f.into, f.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
r.Status, r.Error = 124, "timeout"
|
||||
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||
r.Status, r.Error = 127, "not-found"
|
||||
case errors.As(err, &exit):
|
||||
r.Status = exit.ExitCode()
|
||||
default:
|
||||
r.Status, r.Error = 127, err.Error()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// call runs a command and answers its result, or an error naming what went wrong.
|
||||
func call(c Cmd) (Result, error) {
|
||||
r := run(c)
|
||||
if r.Status == 0 && r.Error == "" {
|
||||
return r, nil
|
||||
}
|
||||
return r, failure(c, r)
|
||||
}
|
||||
|
||||
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||
// status with the end of what it said.
|
||||
func failure(c Cmd, r Result) error {
|
||||
program, _ := argv(c)
|
||||
switch {
|
||||
case r.Error == "not-found" && program == "sudo":
|
||||
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||
case r.Error == "not-found":
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case r.Error == "timeout":
|
||||
limit := c.Timeout
|
||||
if limit <= 0 {
|
||||
limit = CallTimeout
|
||||
}
|
||||
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||
case r.Error != "":
|
||||
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||
c.Name, firstLine(r.Stderr))
|
||||
}
|
||||
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||
if said == "" {
|
||||
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
// lines are a command's output lines, blank ones dropped.
|
||||
func lines(s string) []string {
|
||||
out := []string{}
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
out = append(out, strings.TrimRight(l, "\r"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Arguments, read the way a tool's JSON arguments arrive.
|
||||
|
||||
func text(args map[string]any, key string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "", fmt.Errorf("%s must not be empty", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func optText(args map[string]any, key, def string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def, nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s must be a number", key)
|
||||
}
|
||||
}
|
||||
if f != float64(int(f)) {
|
||||
return 0, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least {
|
||||
return 0, fmt.Errorf("%s must be at least %d", key, least)
|
||||
}
|
||||
if n > most {
|
||||
n = most
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s must be true or false", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func optList(args map[string]any, key string) ([]string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
s, ok := it.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// oneOf refuses a value outside a closed set.
|
||||
func oneOf(key, value string, allowed ...string) error {
|
||||
for _, a := range allowed {
|
||||
if value == a {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||
}
|
||||
|
||||
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||
// snap, application and printer names never do.
|
||||
func plainName(key, value string) error {
|
||||
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
// Tests of kit.go, the same in each workstation module.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fake records the commands asked and answers each from a function of the command line.
|
||||
type fake struct {
|
||||
asked []Cmd
|
||||
answer func(line string, c Cmd) Result
|
||||
}
|
||||
|
||||
func (f *fake) runner() Runner {
|
||||
return func(c Cmd) Result {
|
||||
f.asked = append(f.asked, c)
|
||||
name, args := argv(c)
|
||||
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||
if f.answer == nil {
|
||||
return Result{}
|
||||
}
|
||||
return f.answer(line, c)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fake) lines() []string {
|
||||
out := []string{}
|
||||
for _, c := range f.asked {
|
||||
name, args := argv(c)
|
||||
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// using installs a fake runner and a non-root uid for one test.
|
||||
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||
t.Helper()
|
||||
f := &fake{answer: answer}
|
||||
wasRun, wasUID := run, euid
|
||||
run, euid = f.runner(), func() int { return 1000 }
|
||||
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||
return f
|
||||
}
|
||||
|
||||
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||
|
||||
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||
was := euid
|
||||
defer func() { euid = was }()
|
||||
euid = func() int { return 1000 }
|
||||
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||
t.Fatalf("not root: %s %v", name, args)
|
||||
}
|
||||
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||
t.Fatalf("a read is run as the account: %s", name)
|
||||
}
|
||||
euid = func() int { return 0 }
|
||||
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||
t.Fatalf("as root no sudo: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||
was := euid
|
||||
defer func() { euid = was }()
|
||||
euid = func() int { return 1000 }
|
||||
cases := []struct {
|
||||
c Cmd
|
||||
r Result
|
||||
want string
|
||||
}{
|
||||
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||
}
|
||||
for _, k := range cases {
|
||||
err := failure(k.c, k.r)
|
||||
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||
var w bounded
|
||||
big := strings.Repeat("a", MostOutput+10)
|
||||
n, _ := w.Write([]byte(big))
|
||||
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||
if r.Error != "timeout" {
|
||||
t.Fatalf("a slow command: %+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||
if r.Error != "not-found" {
|
||||
t.Fatalf("a missing program: %+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||
if r.Stdout != "given" {
|
||||
t.Fatalf("stdin: %+v", r)
|
||||
}
|
||||
start := time.Now()
|
||||
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||
if _, err := text(args, "missing"); err == nil {
|
||||
t.Error("a missing required string")
|
||||
}
|
||||
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||
t.Errorf("held to most: %d", n)
|
||||
}
|
||||
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||
t.Error("below least")
|
||||
}
|
||||
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||
t.Error("a fraction")
|
||||
}
|
||||
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||
t.Errorf("list: %v", l)
|
||||
}
|
||||
if b, _ := optFlag(args, "b", false); !b {
|
||||
t.Error("flag")
|
||||
}
|
||||
if err := plainName("name", "--all"); err == nil {
|
||||
t.Error("an option as a name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// The docker-compose module's tools (novox/hq research 027/02, 026/05): the compose projects on this
|
||||
// machine, their containers, logs and rendered configuration, and bringing one up, down or round
|
||||
// again by its directory. A Go bundle the node's runtime launches over stdio (ADR 0188, ADR 0193); it
|
||||
// runs as the operator account, which reaches the container runtime through the docker group.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
var providedBy = map[string]string{
|
||||
"docker": "the docker module installs the container runtime; this module adds compose to it",
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// where is the argument schema every tool that acts on one project takes.
|
||||
var where = map[string]any{
|
||||
"dir": map[string]any{"type": "string", "description": "the project's directory, absolute: where its compose file is"},
|
||||
"project": map[string]any{"type": "string", "description": "the project's name, for a project docker already knows (instead of dir)"},
|
||||
}
|
||||
|
||||
func with(extra map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range where {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range extra {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var servicesArg = map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "only these services (default all)"}
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "docker_compose_projects",
|
||||
Description: "The compose projects docker knows on this machine, running or stopped: name, status, working " +
|
||||
"directory, compose files, services, and containers running of total. (r)",
|
||||
Input: map[string]any{},
|
||||
Run: func(map[string]any) (any, error) { return Projects() },
|
||||
},
|
||||
{
|
||||
Name: "docker_compose_ps",
|
||||
Description: "One project's containers: service, state, health, exit code, image and published ports. (r)",
|
||||
Input: with(nil),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
t, err := targetOf(args, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Ps(t)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_compose_logs",
|
||||
Description: "One project's logs, the last lines of each service (default 200, at most 5000), optionally since a " +
|
||||
"time (\"10m\", \"2026-10-04T12:00:00\"). Cut at 256 KiB. (r)",
|
||||
Input: with(map[string]any{
|
||||
"services": servicesArg,
|
||||
"tail": map[string]any{"type": "integer", "description": "lines per service (default 200, at most 5000)"},
|
||||
"since": map[string]any{"type": "string", "description": "only lines since this: a duration such as 10m or a timestamp"},
|
||||
}),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
t, err := targetOf(args, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
services, err := optList(args, "services")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := optWhole(args, "tail", 200, 1, 5000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
since, err := optText(args, "since", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Logs(t, services, n, since)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_compose_config",
|
||||
Description: "A project's configuration as compose renders it: files merged, variables filled. Values of " +
|
||||
"environment variables, build arguments and labels whose names suggest a secret are replaced with " +
|
||||
"[redacted]. An invalid file is answered as the error compose gives. (r)",
|
||||
Input: with(nil),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
t, err := targetOf(args, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Config(t)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_compose_up",
|
||||
Description: "Bring a project up, detached: create and start its containers, building or pulling what is " +
|
||||
"missing. Answers when finished, or after 18 s with a job to follow with docker_compose_job. (a)",
|
||||
Input: with(map[string]any{
|
||||
"services": servicesArg,
|
||||
"build": map[string]any{"type": "boolean", "description": "build images before starting (--build)"},
|
||||
"pull": map[string]any{"type": "string", "enum": []string{"missing", "always", "never"}, "description": "pull policy (default missing)"},
|
||||
}),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
t, err := targetOf(args, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
services, err := optList(args, "services")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
build, err := optFlag(args, "build", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pull, err := optText(args, "pull", "missing")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Up(t, services, build, pull)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_compose_down",
|
||||
Description: "Stop and remove a project's containers and networks. Its volumes are kept: removing data is not " +
|
||||
"this tool's. Answers when finished, or with a job to follow. (a)",
|
||||
Input: with(nil),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
t, err := targetOf(args, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Down(t)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_compose_restart",
|
||||
Description: "Restart a project's containers, or some of its services. Answers when finished, or with a job to follow. (a)",
|
||||
Input: with(map[string]any{"services": servicesArg}),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
t, err := targetOf(args, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
services, err := optList(args, "services")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Restart(t, services)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_compose_pull",
|
||||
Description: "Pull a project's images, or some services', without starting anything. Answers when finished, or with a job to follow. (a)",
|
||||
Input: with(map[string]any{"services": servicesArg}),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
t, err := targetOf(args, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
services, err := optList(args, "services")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Pull(t, services)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_compose_job",
|
||||
Description: "A long act this module started (up, down, restart, pull): running or finished, its exit status " +
|
||||
"and the end of its output. Without job, every act this process knows, newest first. (r)",
|
||||
Input: map[string]any{"job": map[string]any{"type": "string", "description": "the job id an act answered"}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
id, err := optText(args, "job", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id == "" {
|
||||
return map[string]any{"jobs": listJobs()}, nil
|
||||
}
|
||||
return jobByID(id)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||
// definition so the module's own tests can hold it to what it says.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// packages are the packages the module installs, sorted.
|
||||
func (m manifest) packages() []string {
|
||||
out := []string{}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" && r["absent"] != true {
|
||||
out = append(out, r["package"].(string))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// services are the units the module declares, by unit name.
|
||||
func (m manifest) services() map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "service" {
|
||||
out[r["unit"].(string)] = r
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||
t.Helper()
|
||||
registered := []string{}
|
||||
for _, tool := range tools() {
|
||||
registered = append(registered, tool.Name)
|
||||
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||
}
|
||||
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||
}
|
||||
}
|
||||
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
binary := filepath.Base(cwd)
|
||||
a := m.Build.Artifacts[0]
|
||||
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||
for k, v := range want {
|
||||
if a[k] != v {
|
||||
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||
}
|
||||
}
|
||||
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil {
|
||||
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module docker-compose
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"module": "docker-compose",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"tools": [
|
||||
"docker_compose_projects",
|
||||
"docker_compose_ps",
|
||||
"docker_compose_logs",
|
||||
"docker_compose_config",
|
||||
"docker_compose_up",
|
||||
"docker_compose_down",
|
||||
"docker_compose_restart",
|
||||
"docker_compose_pull",
|
||||
"docker_compose_job"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "docker-compose"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/docker-compose-tools",
|
||||
"binary": "docker-compose-tools",
|
||||
"loads": [
|
||||
"docker-compose-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
# docker
|
||||
|
||||
The container runtime as a module (novox/hq to-be 42 phase 1, item 8; research 027/01–02; ADR 0166,
|
||||
ADR 0207). It claims the node seat `node-container-runtime`. That seat carries no verbs yet: its verbs,
|
||||
and the host creating containers through its holder, wait on ADR 0166's acceptance. Until then the
|
||||
tools below are the module's own.
|
||||
|
||||
## What it declares
|
||||
|
||||
| resource | what | the host's rule |
|
||||
|---|---|---|
|
||||
| `package` | `docker` | installed if absent; never uninstalled when the module goes |
|
||||
| `buildx` | `docker-buildx` | the same. Only the build machine has it today; `docker build` needs it for BuildKit everywhere |
|
||||
| `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) |
|
||||
| `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module |
|
||||
| `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) |
|
||||
|
||||
The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It
|
||||
takes no volume, no container and no image a container uses, so it never touches a container the mesh
|
||||
holds. It runs at idle priority, at a random point in the hour after the weekly mark. A run missed
|
||||
while the machine was off happens at the next boot.
|
||||
|
||||
**Capabilities:** `package-manager`, `service-manager`, `privileged`. It does not declare
|
||||
`container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and
|
||||
the module that installs the daemon cannot require it.
|
||||
|
||||
## What it does not declare yet, and why
|
||||
|
||||
Three things this module should own are already declared by other modules on every machine. The
|
||||
controller refuses two modules on one node that declare the same `path`, `unit`, `name` or `package`
|
||||
(`checkResources`, mesh-controller `internal/catalogue/resolve.go`). Declaring any of them here would
|
||||
make the module unassignable everywhere. The refusals were checked against the controller's own
|
||||
check:
|
||||
|
||||
```
|
||||
zsh and docker both declare the name "${machine:account}"
|
||||
dnsmasq and docker both declare the path "/etc/docker/daemon.json"
|
||||
dnsmasq and docker both declare the unit "docker.service"
|
||||
```
|
||||
|
||||
### 1. `/etc/docker/daemon.json` and `docker.service` (issue 190)
|
||||
|
||||
Today the file has three writers. Each writes into it (`into: json`, ADR 0102) and reloads the
|
||||
service:
|
||||
|
||||
- **`dnsmasq`** writes `dns` and `live-restore`, through `dnsmasq.runtime-dns` and `dnsmasq.runtime`.
|
||||
- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes
|
||||
`insecure-registries`. The collision check does not see generated resources.
|
||||
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand.
|
||||
|
||||
**The change proposed, in one merge:**
|
||||
|
||||
1. `dnsmasq` drops its `runtime-dns` and `runtime` resources.
|
||||
2. `docker` adds the two resources below:
|
||||
|
||||
```json
|
||||
{"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "mode": "0644", "into": "json",
|
||||
"content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true, \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"100m\", \"max-file\": \"5\"}}\n"},
|
||||
{"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", "boot": "enabled", "reload-on": ["daemon"]}
|
||||
```
|
||||
|
||||
The service is **reloaded, never restarted**: a restart stops every container. The daemon reads
|
||||
`live-restore` on a reload. It reads `dns`, `log-driver` and `log-opts` only at its next start, so
|
||||
they apply then (to containers created afterwards, for the log keys). With `live-restore` on, that
|
||||
start keeps every container running.
|
||||
|
||||
**Why one merge, and only after this module is on every machine:**
|
||||
|
||||
- In one apply, the host first gives back the resources that are no longer declared, then applies
|
||||
the new ones (mesh-host `apply.go`).
|
||||
- `dnsmasq` gives back `dns` and `live-restore` to what they held before it, and `docker` sets them
|
||||
again in the same apply. The daemon is reloaded once, after both steps.
|
||||
- A machine pushed the new `dnsmasq` *without* this module would keep its pre-mesh values for both
|
||||
keys. On one machine that is `live-restore: false`, and the next daemon restart there would stop
|
||||
every container.
|
||||
|
||||
**Later:** the controller hands the registry to this module as a value, and the overlay stops
|
||||
generating its two resources (issue 190, steps 2 and 5). Until then the overlay keeps writing its one
|
||||
key beside this module's. The host merges disjoint keys correctly; the mesh-host `into.go` record is
|
||||
per resource.
|
||||
|
||||
### 2. The operator account's membership of the `docker` group
|
||||
|
||||
The right shape is the host's `user` shape. Its `groups` are additive: the host runs
|
||||
`usermod --append` and never takes a group away.
|
||||
|
||||
```json
|
||||
{"id": "group", "type": "user", "name": "${machine:account}", "groups": ["docker"]}
|
||||
```
|
||||
|
||||
`zsh` already declares a `user` resource for the same account (its login shell). The controller
|
||||
compares `name` across modules, so the two collide.
|
||||
|
||||
**The change proposed (mesh-controller, `checkResources`):** judge a `user` resource by the fields it
|
||||
sets, not by its name:
|
||||
|
||||
- `shell` and `home` stay single-owner;
|
||||
- `groups` may be declared by any number of modules, because the host only adds them.
|
||||
|
||||
Then this module declares the resource above, and no module has to carry another's group.
|
||||
|
||||
Today the operator account is in the group on every machine, by hand. Nothing is lost while it waits.
|
||||
|
||||
## The bootstrap's runtime
|
||||
|
||||
On the machine the mesh was first installed on, the foundation bundle declared `package docker`
|
||||
(`container-runtime`) and `docker.service` running and enabled (`container-runtime-running`). ADR 0207
|
||||
§5 exempts them.
|
||||
|
||||
- The host records them under their bare ids, with origin *carried*. A mesh declaration's orphan pass
|
||||
never sees them (mesh-host `store.go`).
|
||||
- So `docker.package` here is a **second record of the same package**. The apply says "already
|
||||
installed", and neither record ever uninstalls it.
|
||||
- This module does not declare `docker.service` today, so nothing overlaps there. The proposed step
|
||||
1 would add a second record of that unit. Its found state is *running*, because genesis started
|
||||
it, so undeclaring this module would leave the daemon running.
|
||||
|
||||
## Tools
|
||||
|
||||
The tools run as the operator account. If the daemon's socket refuses that account, a call is asked
|
||||
again through `sudo -n` (a process keeps the groups it started with). Every call has a 20 s bound.
|
||||
A failure is an error naming how it failed, never an empty answer.
|
||||
|
||||
**Every container on the machine is in scope.** A container the mesh holds carries the host's label
|
||||
`mesh-host.id` (its value names the assignment), and every answer says `mesh_held`.
|
||||
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `docker_list` | r | every container: image, state, health, restarts, ports, mounts, compose project, `mesh_held`; filter by owner, state or name |
|
||||
| `docker_inspect` | r | one container whole, **environment values left out** (names kept) |
|
||||
| `docker_logs` | r | the last lines of both streams, merged in order, with timestamps (default 200, at most 2000) |
|
||||
| `docker_stats` | r | CPU, memory, I/O and process count per running container, heaviest first |
|
||||
| `docker_start` / `docker_stop` / `docker_restart` | a | one container. On a mesh-held one, the answer says the host restores its declared state at its next apply |
|
||||
| `docker_top` | r | the processes inside one container |
|
||||
| `docker_images` | r | images, largest first, with the containers using each; `dangling`, `unused` or `used` |
|
||||
| `docker_prune` | a | dangling images and build cache, and stopped containers the mesh does not hold if `containers` is true. **A dry run unless `dry_run` is false. Never a volume** |
|
||||
| `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each |
|
||||
| `docker_networks` | r | networks, subnets, and the containers on each |
|
||||
| `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked |
|
||||
| `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise |
|
||||
| `docker_daemon_config` | r | `daemon.json` as on disk, `docker info`'s essentials, and keys the daemon has not taken yet |
|
||||
| `docker_unlabelled` | r | the containers the mesh does not hold: the cleanup list |
|
||||
| `docker_problems` | r | unhealthy, restarting, dead, killed for memory, failed, or restarted five times or more |
|
||||
| `docker_ports` | r | every published port, and the containers on the host's network |
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
go test ./...
|
||||
```
|
||||
|
||||
The tests run against a fake runner and cover:
|
||||
|
||||
- escalation through `sudo -n` on a refused socket, and never as root;
|
||||
- each failure named by its cause;
|
||||
- a name or id never read as an option;
|
||||
- mesh-held marking;
|
||||
- the environment left out of `inspect`;
|
||||
- the restore note on a mesh-held act;
|
||||
- prune being a dry run by default and never reaching a volume, a mesh container or `--volumes`;
|
||||
- the log merge;
|
||||
- size parsing;
|
||||
- what the daemon has not yet taken;
|
||||
- event filtering;
|
||||
- volume ownership;
|
||||
- that the tools served are exactly the manifest's `tools`.
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user