Compare commits

..
Author SHA1 Message Date
mesh-admin 0d723484a8 Merge pull request 'lab: clone the forge with the credential the mesh binds and seals to the lab (issue 624)' (#272) from fix/624-lab-reads-the-forge-with-its-credential into main 2026-10-13 12:07:06 +00:00
jochen 8d8dcd5ebb lab: a read-only forge account of its own, and no credential left on disk after a stop (hq issue 624, review 196)
mesh/merge-gate head 8d8dcd5ebb86 merged into trunk cd239283207a — pass: builds gitea, lab → novox, shanks; no bus step; every machine composes with the ch…
mesh/repo-check head 8d8dcd5ebb86 merged into trunk cd239283207a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The lab's credential came from the package team, which also writes the @novox packages, and the
branches a run clones are not reviewed yet. The lab now contributes {"access": "read"} to its
npm-package-registry requirement, and gitea's provisioner puts such a consumer on a team of its own,
readers: code read on the org's repositories, package read, no write, and off the package team. The
build agent, which publishes, stays on packages.

A stop's SIGTERM to the run's process group killed the wrapper before it removed the credential file.
The run's script now removes it as soon as its clones are done and traps every way it ends, and a run's
start and lab_check remove what a crash or a reboot left.
2026-10-13 14:00:59 +02:00
mesh-admin cd23928320 Merge pull request 'licence manager: say what moved a binding, why, and who asked (issue 625)' (#270) from fix/625-a-licence-move-says-what-why-and-who into main 2026-10-13 11:59:02 +00:00
mesh-admin 8922c89059 Merge pull request 'dev-tools: give every node the toolchains to build and test the mesh's code (issue 626)' (#269) from fix/626-every-node-has-the-dev-tools into main 2026-10-13 11:56:18 +00:00
jochen 789d2d9d64 lab: clone the forge with the credential the mesh binds and seals to the lab (hq issue 624)
mesh/merge-gate out of date: judged against trunk 19ecad261cf2, the trunk is now 8922c890595b; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 19ecad261cf2, the trunk is now 8922c890595b; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
mesh-tools-go is private, and the lab cloned over HTTP with no credential: a run failed at its checkout,
"could not read Username". The lab now requires the package registry, as the build agent does; the
forge's provisioner binds it an account and seals that account's password, and a run offers them to
the forge's own origin through git's credential store, from a file beside the run and removed when it
ends: never in a URL, the run's script, an argument or the log. lab_check reads every repository a run
clones with that same credential and names one it cannot read.
2026-10-13 13:54:16 +02:00
mesh-admin 1725ffc799 licence manager: a login's adoption is said beside the bindings it made, not first (issue 625)
mesh/merge-gate head 1725ffc799bd merged into trunk 8922c890595b — pass: builds claude-code, claude-licence-manager → ace, g14, novox, shanks; no bus step;…
mesh/repo-check head 1725ffc799bd merged into trunk 8922c890595b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The bindings a login makes are now announced as licence.bound before the adoption's own event, so the test asks for each event rather than for adoption to come first.
2026-10-13 11:49:30 +00:00
mesh-admin a72eb3dbb2 licence manager: say what moved a binding, why, and who asked (issue 625)
mesh/merge-gate head a72eb3dbb28d merged into trunk 19ecad261cf2 — pass: builds claude-code, claude-licence-manager → ace, g14, novox, shanks; no bus step;…
mesh/repo-check head a72eb3dbb28d merged into trunk 19ecad261cf2 — fail: its merge-check.sh failed: --- FAIL: TestAManagerWithNoLicenceAdoptsTheNewestLogin…
mesh/delivery superseded: a newer head of the same pull request
A switch of g14 back to another licence on 2026-10-13 left no trace: the manager logged no line and sent no event for bind, switch or release, and its audit kept only the verb's name. Every change of what a consumer holds now goes to the journal, the audit and a licence.<what> event with the same fields: consumer, licence before and after, generation, why (the verb or the rule) and by (the caller the runtime proved, or the manager itself). The node is told why in the bindings state and says it when it applies. The audit and binding tables become readable through the store's reader. The SDK moves to the version that proves the caller (issue 365).
2026-10-13 11:45:52 +00:00
mesh-admin c0726ac264 dev-tools: give every node the toolchains to build and test the mesh's code (issue 626)
mesh/merge-gate head c0726ac2641b merged into trunk 19ecad261cf2 — pass: builds new: modules/dev-tools, sent nowhere; no bus step; every machine composes w…
mesh/repo-check head c0726ac2641b merged into trunk 19ecad261cf2 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Every node is a development machine: an agent builds a change and runs its tests there before opening a pull request, and the lab tests it afterwards as QA. No module declared Go or npm, so no node could do the first part.
2026-10-13 11:42:12 +00:00
mesh-admin 19ecad261c Merge pull request 'lab: install each checkout where its lock file is, check out mesh-tools-go, and have lab_check catch both (issue 624)' (#268) from fix/624-lab-run-installs-what-each-checkout-has into main 2026-10-13 11:11:58 +00:00
jochen c6a9c52dc7 Install each lab checkout where its lock file is, check out mesh-tools-go, and have lab_check catch both (hq issue 624)
mesh/merge-gate head c6a9c52dc7f2 merged into trunk 45d01e77c26d — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head c6a9c52dc7f2 merged into trunk 45d01e77c26d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
lab_run ran npm ci at the root of mesh-sdk, mesh-tools and mesh-lab; mesh-tools keeps its package in
node-tools/ with no lock file at its root, so every run failed at its setup in nine seconds. A run now
installs (and, for the SDK and the base, builds) where a package-lock.json is: the root, or each
directory one level down; a package.json with no lock file fails the run naming it. It checks out
mesh-tools-go, which genesis builds since issue 616, and tells the beds where its checkouts are
(MESH_LAB_REPOS). lab_check asks the forge for every repository a run clones and applies the same install
rule to the ones it installs, so a run's setup failure is found before a run.
2026-10-13 11:55:01 +02:00
mesh-admin 45d01e77c2 Merge pull request 'fail2ban: know the own-addresses list in the form fail2ban prints it (hq issue 238)' (#267) from fix/238-fail2bans-own-file-form into main 2026-10-13 09:18:26 +00:00
jochen c78d0a0134 fail2ban: know the own-addresses list in the form fail2ban prints it (hq issue 238)
mesh/merge-gate head c78d0a01342c merged into trunk f35b06750452 — pass: builds fail2ban → ace, g14, novox, shanks; no bus step; every machine composes wit…
mesh/repo-check head c78d0a01342c merged into trunk f35b06750452 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
fail2ban reads the ignoreip entry file:/var/lib/... and prints it back as file://var/lib/...,
in get <jail> ignoreip and in its "Ignore ... by ..." log lines alike. The watch compared the
text, so on ace it said every jail lacked the list while each held it, failing fail2ban's health
and its walk; and it would never have counted a failure from the operator's own address. Compare
the path instead, with a test on fail2ban's real form.
2026-10-13 11:14:24 +02:00
42 changed files with 943 additions and 2635 deletions
@@ -285,6 +285,25 @@ func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
}
}
// The node's line names the generation it applied and why the manager moved it, and who asked (novox/hq issue 625).
func TestAnAppliedBindingSaysWhyAndWho(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 7,
Why: "the switch verb", By: "g14.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"g14.node-tools"`} {
if !strings.Contains(done, want) {
t.Fatalf("the line %s lacks %s", done, want)
}
}
if strings.Contains(done, "at-7") {
t.Fatal("the line carries the token")
}
}
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
p, w := node(t, "laptop")
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
@@ -298,6 +298,9 @@ type BindingState struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
// Why and By are what moved the binding and who asked, as the manager recorded it (novox/hq issue 625).
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
}
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
@@ -433,6 +436,15 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e
if err != nil {
return "", err
}
// The line says which binding this was and why the manager moved it, so a node's journal alone answers
// what changed here and who asked (novox/hq issue 625).
out["generation"] = b.Generation
if b.Why != "" {
out["why"] = b.Why
}
if b.By != "" {
out["by"] = b.By
}
raw, _ := json.Marshal(out)
return string(raw), nil
}
@@ -253,6 +253,7 @@ func text(a map[string]any, k string) (string, error) {
}
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625).
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) {
@@ -260,7 +261,8 @@ func verb(name, description string, input map[string]any, run func(ctx context.C
if err != nil {
return nil, err
}
return run(context.Background(), m, a)
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)})
return run(ctx, m, a)
}}
}
@@ -288,7 +290,7 @@ func tools() []stdio.Tool {
if err != nil {
return nil, err
}
return m.Bind(ctx, c, l, "bind")
return m.Bind(ctx, c, l)
}),
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. A login on a node does this by itself (ADR 0209); this is for moving one without a login, or back.",
map[string]any{"consumer": consumer, "licence": str("the licence to move to")},
@@ -297,7 +299,7 @@ func tools() []stdio.Tool {
if err != nil {
return nil, err
}
return m.Bind(ctx, c, l, "switch")
return m.Bind(ctx, c, l)
}),
verb("release", "Unbind a consumer. Its node keeps its last token, which expires within hours.",
map[string]any{"consumer": consumer},
@@ -306,7 +308,7 @@ func tools() []stdio.Tool {
if err != nil {
return nil, err
}
return m.Release(ctx, c, "release")
return m.Release(ctx, c)
}),
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")},
@@ -59,6 +59,54 @@ type BindingState struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
// Why and By are what moved it and who asked (novox/hq issue 625), so the node can say so when it applies it.
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
}
// Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb
// called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself.
type Cause struct {
Why string
By string
}
// Itself names this module where it moved a binding by a rule of its own, with no caller.
const Itself = "claude-licence-manager"
type causeKey struct{}
// WithCause is ctx carrying why what runs in it changes a binding, and who asked.
func WithCause(ctx context.Context, c Cause) context.Context {
return context.WithValue(ctx, causeKey{}, c)
}
// CauseOf is the cause ctx carries, or "a rule of this module's own" when none: never empty, so a record always
// says something, and a change nobody named a cause for reads as this module's own.
func CauseOf(ctx context.Context) Cause {
if c, ok := ctx.Value(causeKey{}).(Cause); ok && c.By != "" {
return c
}
return Cause{Why: "a rule of this module's own", By: Itself}
}
// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line
// in the journal, a row in the audit table, and the event `licence.<what>` on the bus. Never a token.
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) {
_ = m.Store.Audit(ctx, what, detail)
m.Log("%s", line)
_ = m.Emit("licence."+what, detail)
}
// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom.
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) {
what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence)
if from != "" {
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence)
}
m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation,
"why": c.Why, "by": c.By})
}
// Settings are the manager's own, declared with defaults (design 39 §8).
@@ -267,11 +315,12 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
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 {
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
m.moved(ctx, rep.Node, "", l.Name, b.Generation, c)
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
return err
}
m.Log("bound %s to %s, the licence its account already has", rep.Node, l.Name)
}
return nil
}
@@ -351,21 +400,21 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
ctx = WithCause(ctx, cause)
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
return "", err
} else if b == nil || b.Licence != l.Name {
if _, err := m.Store.Bind(ctx, c.Node, l.Name); err != nil {
moved, err := m.Store.Bind(ctx, c.Node, l.Name)
if err != nil {
return "", err
}
from := ""
if b != nil {
from = b.Licence
}
_ = m.Store.Audit(ctx, map[bool]string{true: "switched", false: "bound"}[b != nil],
map[string]any{"consumer": c.Node, "licence": l.Name, "from": from, "by": "a login there"})
if b != nil {
m.Log("%s moved from %s to %s: a login there", c.Node, from, l.Name)
}
m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause)
}
for _, rep := range reports {
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
@@ -374,8 +423,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
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 b, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
m.moved(ctx, rep.Node, "", l.Name, b.Generation,
Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself})
}
}
if err := m.publishAdvance(ctx, l); err != nil {
@@ -399,14 +449,17 @@ func (m *Manager) keep(l *Licence, g FullGrant) {
l.RotatedAt = m.Now().UnixMilli()
}
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state.
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the
// cause ctx carries, so the node says why it fetched again (novox/hq issue 625).
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
bindings, err := m.Store.Advance(ctx, l.Name)
if err != nil {
return err
}
c := CauseOf(ctx)
for _, b := range bindings {
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
return err
}
}
@@ -482,7 +535,19 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
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})
c := CauseOf(ctx)
if force {
c.Why = "a refresh asked for: " + c.Why
} else {
c = Cause{Why: "due: older than its cadence or near its expiry", By: Itself}
if by := CauseOf(ctx); by.By != Itself {
c.By = by.By
}
}
ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By})
m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By),
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By})
if err := m.publishAdvance(ctx, *l); err != nil {
return nil, err
}
@@ -594,8 +659,9 @@ func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[
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) {
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and
// recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625).
func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) {
l, err := m.Store.Licence(ctx, licence)
if err != nil {
return nil, err
@@ -611,19 +677,21 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[s
if err != nil {
return nil, err
}
from, what := "", "bound"
from := ""
if before != nil {
from, what = before.Licence, "switched"
from = before.Licence
}
_ = 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 {
c := CauseOf(ctx)
m.moved(ctx, consumer, from, licence, b.Generation, c)
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); 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) {
func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any, error) {
was, err := m.Store.Binding(ctx, consumer)
if err != nil {
return nil, err
@@ -634,7 +702,9 @@ func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]
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})
c := CauseOf(ctx)
m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By})
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
}
@@ -685,7 +755,10 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
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": from})
c := CauseOf(ctx)
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By})
m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By)
ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By})
if err := m.publishAdvance(ctx, l); err != nil {
return nil, err
}
@@ -132,8 +132,13 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges
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)
// The adoption is said, and so is each binding it made (novox/hq issue 625).
events := strings.Join(mm.events, "|")
for _, want := range []string{"licence.adopted ", `licence.bound {"by":"claude-licence-manager","consumer":"laptop"`,
`licence.bound {"by":"claude-licence-manager","consumer":"server"`} {
if !strings.Contains(events, want) {
t.Fatalf("events lack %s: %v", want, mm.events)
}
}
}
@@ -234,13 +239,13 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
t.Fatal(err)
}
if _, err := mm.m.Bind(ctx, "laptop", "api", "test"); err != nil {
if _, err := mm.m.Bind(ctx, "laptop", "api"); 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 {
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
t.Fatal(err)
}
if _, ok := mm.state["laptop"]; ok {
@@ -454,3 +459,73 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
t.Fatal("nothing was kept")
}
}
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release.
func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
mm := newMesh(t)
var lines []string
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
if s := mm.state["laptop"]; !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
t.Fatalf("a login's binding said %+v", s)
}
file := filepath.Join(t.TempDir(), "key")
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
t.Fatal(err)
}
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
t.Fatal(err)
}
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
t.Fatalf("the node is told %+v", s)
}
said := strings.Join(lines, "\n")
if !strings.Contains(said, "switched laptop from "+licence1+" to api (generation ") ||
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
t.Fatalf("the journal said:\n%s", said)
}
var switched string
for _, e := range mm.events {
if strings.HasPrefix(e, "licence.switched ") {
switched = e
}
}
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
if !strings.Contains(switched, want) {
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
}
}
last := mm.store.Audits[len(mm.store.Audits)-1]
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
t.Fatalf("the audit kept %s", raw)
}
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) {
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
}
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
t.Fatal(err)
}
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
t.Fatalf("the journal said:\n%s", said)
}
for _, e := range mm.events {
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
t.Fatalf("an event carries a secret: %s", e)
}
}
}
// A call with no cause is said as this module's own, never as nobody.
func TestNoCauseReadsAsTheModulesOwn(t *testing.T) {
if c := CauseOf(context.Background()); c.By != Itself || c.Why == "" {
t.Fatalf("no cause read as %+v", c)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module claude-licence-manager
go 1.25.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.7
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac
github.com/jackc/pgx/v5 v5.11.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
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=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac h1:jnZ/n+fFMo7VQPNNXK1gjStHIJPw3SPbzPxsf/PpZqw=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac/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=
+9 -1
View File
@@ -8,7 +8,11 @@
],
"contributes": {
"postgres-database": {
"name": "claude_licences"
"name": "claude_licences",
"readable": [
"audit",
"binding"
]
}
},
"binds": {
@@ -59,6 +63,10 @@
"emits": [
"licence.adopted",
"licence.refused",
"licence.bound",
"licence.switched",
"licence.released",
"licence.rotated",
"licence.failing",
"usage.read"
],
+26
View File
@@ -0,0 +1,26 @@
# dev-tools
The tools a machine needs to develop the mesh's own code (novox/hq issue 626). Every node is a development
machine: an agent working on a change builds it and runs its tests there, with these tools, before it opens a
pull request. The lab is the QA step after that: it tests the work in a sandbox, through its seat at the mesh
scope, and is not where development happens.
Assign it to every node.
## Owns
| what | package | class (ADR 0182) |
|---|---|---|
| the Go toolchain, for `go build` and `go test` in every Go module | `go` | package |
| a C compiler, for `go test -race` and cgo | `gcc` | package |
| make | `make` | package |
| Node.js and npm, for the TypeScript modules' `npm ci` and `npm test` | `nodejs`, `npm` | package |
| Python, for the scripts some checks run | `python` | package |
| git and jq | `git`, `jq` | package |
All are in the distribution's official repositories, so the node-engine's package manager installs them on any
node. The versions are the distribution's: the toolchain images that run `mesh/repo-check` stay the judge of a
pull request, and a local run is the developer's early answer, not the verdict.
Nothing else is placed: no file, no service, no environment. A module's own dependencies come from its lock
file (`go.mod`, `package-lock.json`), as in the toolchain images.
+49
View File
@@ -0,0 +1,49 @@
{
"module": "dev-tools",
"version": "1",
"capabilities": [
"package-manager"
],
"resources": [
{
"id": "go",
"type": "package",
"package": "go"
},
{
"id": "c-compiler",
"type": "package",
"package": "gcc"
},
{
"id": "make",
"type": "package",
"package": "make"
},
{
"id": "nodejs",
"type": "package",
"package": "nodejs"
},
{
"id": "npm",
"type": "package",
"package": "npm"
},
{
"id": "python",
"type": "package",
"package": "python"
},
{
"id": "git",
"type": "package",
"package": "git"
},
{
"id": "jq",
"type": "package",
"package": "jq"
}
]
}
@@ -44,6 +44,20 @@ const (
IgnoredBy = "file:" + ListFile
)
// isOurList says whether an entry of a jail's ignore list, or the source the daemon's log names, is the list of
// the operator's own addresses. fail2ban keeps a file entry in its own form, not as written: it reads
// `file:/var/lib/…` and prints it back as `file://var/lib/…` (server/ipdns.py, FileIPAddrSet.__repr__, in 1.1),
// in `get <jail> ignoreip` and in its `Ignore … by …` lines alike. So the path is compared, with fail2ban's own
// reading of the slashes after `file:`, never the text (seen on ace on 2026-10-13: every jail held the list
// and the watch said none did).
func isOurList(word string) bool {
path, ok := strings.CutPrefix(strings.TrimSpace(word), "file:")
if !ok {
return false
}
return "/"+strings.TrimLeft(path, "/") == ListFile
}
// Own is the setting read: its entries, and each one that is not an address or a range, with why.
type Own struct {
Entries []netip.Prefix `json:"-"`
@@ -15,10 +15,15 @@ import (
"time"
)
// asPrinted is the list's entry as fail2ban 1.1 prints it back, in `get <jail> ignoreip` and in its log: it reads
// `file:/var/lib/…` and keeps it as `file://var/lib/…` (FileIPAddrSet.__repr__), as ace's daemon printed it on
// 2026-10-13.
const asPrinted = "file://var/lib/mesh-fail2ban/own-addresses"
// ignoring is a jail's ignore list as fail2ban-client 1.1 prints it, with the base the module writes.
const ignoring = "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n|- 10.0.0.0/8\n" +
"|- 172.16.0.0/12\n|- 192.168.0.0/16\n|- 169.254.0.0/16\n|- fc00::/7\n|- fe80::/10\n" +
"|- file:/var/lib/mesh-fail2ban/own-addresses\n`- ::1\n"
"|- " + asPrinted + "\n`- ::1\n"
// home is the operator's home uplink in these tests (RFC 5737).
const home = "203.0.113.5"
@@ -153,7 +158,7 @@ func appendLog(t *testing.T, root, text string) {
func daemon(missing bool) Runner {
gitea := ignoring
if missing {
gitea = strings.Replace(ignoring, "|- file:/var/lib/mesh-fail2ban/own-addresses\n", "", 1)
gitea = strings.Replace(ignoring, "|- "+asPrinted+"\n", "", 1)
}
return fake(map[string]string{"status": "Status\n|- Number of jail:\t2\n`- Jail list:\tgitea, recidive\n",
"get gitea ignoreip": gitea, "get recidive ignoreip": ignoring}, nil)
@@ -168,7 +173,7 @@ func TestAFailureIgnoredFromTheOperatorsOwnAddressIsSaidUntilADayAfterTheLast(t
clock := now
w := &Watcher{F: Fail2ban{Run: daemon(false)}, Root: root, Now: func() time.Time { return clock }}
appendLog(t, root, logAt(now.Add(-48*time.Hour), "gitea", home, IgnoredBy)+
appendLog(t, root, logAt(now.Add(-48*time.Hour), "gitea", home, asPrinted)+
logAt(now.Add(-time.Minute), "sshd", "192.168.1.1", "ip"))
s := w.Look(context.Background())
if err := WriteState(root, s); err != nil {
@@ -183,7 +188,7 @@ func TestAFailureIgnoredFromTheOperatorsOwnAddressIsSaidUntilADayAfterTheLast(t
var loop strings.Builder
for i := 0; i < 40; i++ {
loop.WriteString(logAt(now.Add(time.Duration(i)*15*time.Second), "gitea", home, IgnoredBy))
loop.WriteString(logAt(now.Add(time.Duration(i)*15*time.Second), "gitea", home, asPrinted))
}
appendLog(t, root, loop.String()[:loop.Len()-10]) // the daemon is still writing the last line
clock = now.Add(10 * time.Minute)
@@ -303,3 +308,21 @@ func TestTheManifestGivesEveryJailTheListAndTheWatchItsHealth(t *testing.T) {
t.Errorf("tools %v", m.Tools)
}
}
// The list is known by its path, in the form the module writes it and in the form fail2ban prints it back.
func TestTheListIsKnownInTheFormFail2banPrintsItBack(t *testing.T) {
for _, word := range []string{IgnoredBy, asPrinted, "file:///var/lib/mesh-fail2ban/own-addresses"} {
if !isOurList(word) {
t.Errorf("%q is the list and was not known", word)
}
}
for _, word := range []string{"ip", "/var/lib/mesh-fail2ban/own-addresses", "file://var/lib/mesh-fail2ban/other",
"file:/var/lib/mesh-fail2ban/own-addresses.new", "192.168.0.0/16"} {
if isOurList(word) {
t.Errorf("%q was taken for the list", word)
}
}
if !namesOurList(listed(ignoring)) {
t.Errorf("the jail's list as the daemon prints it was read as lacking the list")
}
}
+11 -2
View File
@@ -103,7 +103,7 @@ func (w *Watcher) Look(ctx context.Context) State {
s.Unread = append(s.Unread, fmt.Sprintf("the jail %s's ignore list cannot be read: %v", j, err))
continue
}
if !contains(listed(out), IgnoredBy) {
if !namesOurList(listed(out)) {
s.Missing = append(s.Missing, j)
}
}
@@ -115,6 +115,15 @@ func (w *Watcher) Look(ctx context.Context) State {
return s
}
func namesOurList(entries []string) bool {
for _, e := range entries {
if isOurList(e) {
return true
}
}
return false
}
func contains(list []string, want string) bool {
for _, l := range list {
if l == want {
@@ -162,7 +171,7 @@ func (w *Watcher) readLog(now time.Time) error {
// take keeps one line of the log when it is a failure ignored because of the operator's own addresses.
func (w *Watcher) take(line string, now time.Time) {
m := ignoredLine.FindStringSubmatch(line)
if m == nil || m[4] != IgnoredBy {
if m == nil || !isOurList(m[4]) {
return
}
at, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local)
+9
View File
@@ -905,6 +905,15 @@ export class GiteaAdmin {
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
}
/** Take a user off a team; one not on it is already off. */
async removeUserFromTeam(teamId: number, username: string): Promise<void> {
const res = await this.request(`/teams/${teamId}/members/${encodeURIComponent(username)}`, {
method: "DELETE",
});
if (res.status === 204 || res.status === 200 || res.status === 404) return;
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
}
/**
* Whether a consumer's user logs in with exactly this password and is still a member of the
* package team. Read-only: the password is checked as the consumer presents it, basic auth on the
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"private": true,
"scripts": {
"build": "tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"build": "tsc client.ts token.ts index.ts provisioner/index.ts provisioner/teams.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
+9 -20
View File
@@ -11,8 +11,8 @@
//
// The `npm-package-registry` interface: a consumer authenticates to the npm registry at
// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can
// read and write packages under the `@novox` scope. The registry's npm owner is the gitea org
// `novox`; a consumer is a gitea *user* placed on that org's package team.
// read packages under the `@novox` scope, and write them unless it asked for read only (teams.ts). The
// registry's npm owner is the gitea org `novox`; a consumer is a gitea *user* on one of that org's teams.
//
// **The user name and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives
// the login and hands it to both ends, and mints the password. gitea creates a user under exactly
@@ -24,11 +24,9 @@
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { GiteaAdmin } from "../client.js";
import { grant, holds } from "./teams.js";
// The npm registry owner: a gitea org named `novox`, whose package team every consumer joins so it
// can read and write packages under the `@novox` scope (ADR 0076).
const ORG = "novox";
const PACKAGE_TEAM = "packages";
// Which team a consumer joins — `packages` to publish, `readers` to read only — is teams.ts's.
const gitea = GiteaAdmin.fromEnv();
@@ -38,19 +36,10 @@ const gitea = GiteaAdmin.fromEnv();
// module needs the mesh to say where each provision's file is — not yet possible, and not faked.
runProvisioner("npm-package-registry", {
async create(p: Provision): Promise<void> {
// The org and its package team are the same for every consumer; ensuring them per-create is
// idempotent and needs no separate bootstrap step.
await gitea.ensureOrg(ORG);
const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true);
// The user carries the consumer's login and the mesh's minted password, set every run so a
// rotation takes. Membership of the package team is what grants read+write on packages.
//
// The address is gitea's own convention for one that is not real: its email validation
// requires a dotted domain, so `@localhost` was refused at create — the fault that had this
// grant retrying for a day — while `@noreply.localhost` is the shape gitea itself gives
// hidden addresses.
await gitea.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`);
await gitea.addUserToTeam(teamId, p.as);
// The org and its teams are ensured per create: idempotent, and no separate bootstrap step. The
// user carries the consumer's login and the mesh's minted password, set every run so a rotation
// takes; membership of its team is what grants it what it may do.
await grant(gitea, p);
},
async remove(p: { as: string }): Promise<void> {
@@ -60,6 +49,6 @@ runProvisioner("npm-package-registry", {
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password);
return holds(gitea, p);
},
});
+64
View File
@@ -0,0 +1,64 @@
// Which team of the forge's npm owner a consumer of `npm-package-registry` joins, and the per-consumer
// half of the provisioner around it — pure of the forge's address, so it is tested with a stand-in.
//
// **Two teams, by what the consumer said it needs** (novox/hq issue 624). A consumer that publishes —
// the build agent, whose builds publish the SDK and every TypeScript module's packages — joins `packages`:
// code read on the org's repositories and write on its packages. A consumer that contributes
// `{"access": "read"}` joins `readers`: code read and package read, and no write. The lab is the first: it
// clones branches that are not yet reviewed and runs their code beside its credential, and code that is
// not reviewed must not be able to publish the mesh's packages with it.
/** The npm registry's owner: the gitea org every consumer's packages live under (ADR 0076). */
export const ORG = "novox";
/** The team whose members read and write the org's packages. */
export const PACKAGE_TEAM = "packages";
/** The team whose members read the org's code and packages, and write nothing. */
export const READ_TEAM = "readers";
/** The team a consumer joins, from what it contributed; an access it may not ask for is refused. */
export function teamFor(values: Readonly<Record<string, unknown>> | undefined): { team: string; packageWrite: boolean } {
const access = values?.access;
if (access === undefined || access === "write") return { team: PACKAGE_TEAM, packageWrite: true };
if (access === "read") return { team: READ_TEAM, packageWrite: false };
throw new Error(`npm-package-registry: access ${JSON.stringify(access)} is not "read" or "write"`);
}
/** What the adapter asks of the forge's admin API (client.ts GiteaAdmin). */
export interface Forge {
ensureOrg(org: string): Promise<void>;
ensureTeam(org: string, team: string, packageWrite: boolean): Promise<number>;
ensureUser(username: string, password: string, email: string): Promise<void>;
addUserToTeam(teamId: number, username: string): Promise<void>;
removeUserFromTeam(teamId: number, username: string): Promise<void>;
holdsTeamMember(org: string, team: string, username: string, password: string): Promise<boolean>;
}
export interface Consumer {
readonly as: string;
readonly password: string;
readonly values: Readonly<Record<string, unknown>>;
}
/**
* grant makes the consumer's user, with exactly the mesh's password, a member of its one team. A consumer
* that asks for read only is also taken out of the package team, so one that asked for write before and
* read now loses the write at once.
*/
export async function grant(forge: Forge, p: Consumer): Promise<void> {
const { team, packageWrite } = teamFor(p.values);
await forge.ensureOrg(ORG);
const teamId = await forge.ensureTeam(ORG, team, packageWrite);
// The address is gitea's own convention for one that is not real: its email validation requires a
// dotted domain, so `@localhost` was refused at create, while `@noreply.localhost` is the shape gitea
// itself gives hidden addresses.
await forge.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`);
await forge.addUserToTeam(teamId, p.as);
if (!packageWrite) {
await forge.removeUserFromTeam(await forge.ensureTeam(ORG, PACKAGE_TEAM, true), p.as);
}
}
/** holds is whether the forge still holds the consumer as the mesh gave it: its login, in its team. */
export async function holds(forge: Forge, p: Consumer): Promise<boolean> {
return forge.holdsTeamMember(ORG, teamFor(p.values).team, p.as, p.password);
}
+56
View File
@@ -0,0 +1,56 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { grant, holds, PACKAGE_TEAM, READ_TEAM, teamFor, type Forge } from "../provisioner/teams.ts";
// Defends novox/hq issue 624: the lab reads the forge with a credential of its own that can write no
// package. A consumer that contributes {"access": "read"} joins a team with code and package read only.
function aForge(): { forge: Forge; did: string[] } {
const did: string[] = [];
const teams: Record<string, number> = {};
const forge: Forge = {
async ensureOrg(org) { did.push(`org ${org}`); },
async ensureTeam(org, team, packageWrite) {
did.push(`team ${org}/${team} packages:${packageWrite ? "write" : "read"}`);
return (teams[team] ??= Object.keys(teams).length + 1);
},
async ensureUser(username) { did.push(`user ${username}`); },
async addUserToTeam(id, username) { did.push(`add ${username} to ${id}`); },
async removeUserFromTeam(id, username) { did.push(`remove ${username} from ${id}`); },
async holdsTeamMember(org, team, username) { did.push(`holds ${username} in ${org}/${team}`); return true; },
};
return { forge, did };
}
test("a consumer that asks nothing, or write, publishes: the package team", () => {
assert.deepEqual(teamFor({}), { team: PACKAGE_TEAM, packageWrite: true });
assert.deepEqual(teamFor({ access: "write" }), { team: PACKAGE_TEAM, packageWrite: true });
});
test("a consumer that asks to read joins the readers, who write no package", () => {
assert.deepEqual(teamFor({ access: "read" }), { team: READ_TEAM, packageWrite: false });
assert.throws(() => teamFor({ access: "admin" }), /not "read" or "write"/);
});
test("a reader is made, put on the readers team, and taken off the package team", async () => {
const { forge, did } = aForge();
await grant(forge, { as: "lab-shanks", password: "pw", values: { access: "read" } });
assert.deepEqual(did, [
"org novox",
"team novox/readers packages:read",
"user lab-shanks",
"add lab-shanks to 1",
"team novox/packages packages:write",
"remove lab-shanks from 2",
]);
did.length = 0;
await holds(forge, { as: "lab-shanks", password: "pw", values: { access: "read" } });
assert.deepEqual(did, ["holds lab-shanks in novox/readers"]);
});
test("a publisher is made and put on the package team, as before", async () => {
const { forge, did } = aForge();
await grant(forge, { as: "build-agent-ace", password: "pw", values: {} });
assert.deepEqual(did, ["org novox", "team novox/packages packages:write", "user build-agent-ace", "add build-agent-ace to 1"]);
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
"include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "provisioner/teams.ts", "tools/index.ts"]
}
+15 -1
View File
@@ -5,6 +5,20 @@
"container-runtime",
"virtualisation"
],
"requires": [
"npm-package-registry"
],
"contributes": {
"npm-package-registry": {
"access": "read"
}
},
"binds": {
"npm-package-registry": "${dir:state}/package-registry.json"
},
"secrets": {
"npm-package-registry": "${dir:state}/package-registry.secret"
},
"data": {
"own": [
{
@@ -33,7 +47,7 @@
"type": "file",
"path": "${dir:state}/lab.env",
"mode": "0600",
"content": "MESH_LAB_FORGE=${setting:forge}\n"
"content": "MESH_LAB_FORGE=${setting:forge}\nMESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json\nMESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret\n"
},
{
"id": "git",
+3
View File
@@ -4,6 +4,9 @@
"description": "lab — the lab, as a module: runs beds against the forge's branches when the mesh asks (novox/hq ADR 0172).",
"type": "module",
"private": true,
"scripts": {
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": { "@novox/mesh-sdk": "^0.1.0" },
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" }
}
+196
View File
@@ -0,0 +1,196 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { installPlan, installStep, NPM_REPOSITORIES, REPOSITORIES, script } from "../tools/runs.ts";
// Defends novox/hq issue 624: lab_run ran `npm ci` at the root of every checkout it installs, and
// mesh-tools keeps its package in node-tools/ with no lock file at its root, so every run failed at its
// setup, in 9 seconds, before anything was built.
function aCheckout(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), "lab-checkout-"));
for (const [path, content] of Object.entries(files)) {
mkdirSync(join(root, path, ".."), { recursive: true });
writeFileSync(join(root, path), content);
}
return root;
}
test("a checkout with a lock file at its root is installed there", () => {
const root = aCheckout({ "package.json": "{}", "package-lock.json": "{}", "node-tools/package.json": "{}" });
assert.deepEqual(installPlan(root), { install: [root], unlocked: [] });
rmSync(root, { recursive: true, force: true });
});
test("a checkout whose package lives one directory down is installed there, as mesh-tools' node-tools", () => {
const root = aCheckout({ "node-tools/package.json": "{}", "node-tools/package-lock.json": "{}", "Dockerfile": "" });
assert.deepEqual(installPlan(root), { install: [join(root, "node-tools")], unlocked: [] });
rmSync(root, { recursive: true, force: true });
});
test("a package.json with no lock file is named, never installed", () => {
const root = aCheckout({ "package.json": "{}" });
assert.deepEqual(installPlan(root), { install: [], unlocked: [root] });
rmSync(root, { recursive: true, force: true });
});
// The shell a run executes follows the same rule: npm is asked where installPlan says, and a checkout
// with nothing installable fails the run loudly, naming it.
function runStep(root: string, build: boolean): { ok: boolean; said: string; asked: string[] } {
const bin = mkdtempSync(join(tmpdir(), "lab-npm-"));
const record = join(bin, "asked");
writeFileSync(join(bin, "npm"), `#!/bin/sh\necho "$(pwd) $*" >> ${JSON.stringify(record)}\n`);
chmodSync(join(bin, "npm"), 0o755);
const ran = spawnSync("bash", ["-c", `set -euo pipefail\n${installStep(root, build)}`], {
encoding: "utf8", env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
});
let asked: string[] = [];
try {
asked = readFileSync(record, "utf8").trim().split("\n").filter(Boolean);
} catch { /* nothing asked */ }
rmSync(bin, { recursive: true, force: true });
return { ok: ran.status === 0, said: `${ran.stdout}${ran.stderr}`, asked };
}
test("a run installs and builds mesh-tools in node-tools, where its lock file is", () => {
const root = aCheckout({ "node-tools/package.json": "{}", "node-tools/package-lock.json": "{}" });
const r = runStep(root, true);
assert.ok(r.ok, r.said);
assert.deepEqual(r.asked, [
`${join(root, "node-tools")} ci --no-audit --no-fund --loglevel=error`,
`${join(root, "node-tools")} run build --if-present`,
]);
rmSync(root, { recursive: true, force: true });
});
test("a run installs a checkout with its lock file at the root there, and builds only when asked", () => {
const root = aCheckout({ "package.json": "{}", "package-lock.json": "{}" });
const r = runStep(root, false);
assert.ok(r.ok, r.said);
assert.deepEqual(r.asked, [`${root} ci --no-audit --no-fund --loglevel=error`]);
rmSync(root, { recursive: true, force: true });
});
test("a run fails at its setup, naming the checkout, when nothing of it can be installed", () => {
const layouts: Record<string, string>[] = [{ "package.json": "{}" }, { "Dockerfile": "" }, { "sub/package.json": "{}" }];
for (const files of layouts) {
const root = aCheckout(files);
const r = runStep(root, false);
assert.equal(r.ok, false, `installed ${JSON.stringify(files)}`);
assert.match(r.said, /issue 624/);
assert.deepEqual(r.asked, []);
rmSync(root, { recursive: true, force: true });
}
});
// A run checks out mesh-tools-go too (novox/hq issue 616): genesis builds the Go toolchain from the
// repository beside mesh-tools, and a bed that raises genesis serves it. And it tells the beds where the
// checkouts are.
test("a run checks out mesh-tools-go and says where its checkouts are", () => {
assert.ok(REPOSITORIES.includes("mesh-tools-go"));
const s = script("/work", "run-x", "http://forge", ["test/integration/genesis-keeps-its-bus.test.ts"], {});
assert.match(s, /novox\/mesh-tools-go\.git' '\/work\/run-x\/mesh-tools-go'/);
assert.match(s, /export MESH_LAB_REPOS='\/work\/run-x'/);
assert.doesNotMatch(s, /cd '\/work\/run-x'\/\$repo && npm ci/);
for (const repo of NPM_REPOSITORIES) assert.ok(s.includes(`'/work/run-x/${repo}'/package-lock.json`), repo);
});
// Defends novox/hq issue 624, its second half: mesh-tools-go is private, and the lab cloned over HTTP with
// no credential ("could not read Username"). A run clones with the forge credential the mesh binds and
// seals to the lab, through git's credential store, and the secret is in no script, URL or argument.
import { credentialArgs, credentialPath, forgeCredential } from "../tools/runs.ts";
function aBinding(as: string, secret: string): { binding: string; secret: string } {
const dir = mkdtempSync(join(tmpdir(), "lab-binding-"));
writeFileSync(join(dir, "package-registry.json"), JSON.stringify({ provision: "npm-package-registry", at: "10.42.0.1", as,
serves: { scheme: "http", port: 3000 } }));
writeFileSync(join(dir, "package-registry.secret"), `${secret}\n`);
return { binding: join(dir, "package-registry.json"), secret: join(dir, "package-registry.secret") };
}
test("the forge credential is the bound account and its sealed secret, for the forge's own origin", () => {
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
assert.equal(forgeCredential("http://novox.internal:20000", b.binding, b.secret),
"http://lab-shanks:s3cret-of-forty-characters@novox.internal:20000");
assert.equal(forgeCredential("http://novox.internal:20000/", b.binding, ""), undefined);
assert.equal(forgeCredential("", b.binding, b.secret), undefined);
assert.equal(forgeCredential("http://novox.internal:20000", "/nowhere", b.secret), undefined);
});
test("a run clones through git's credential store, and its script carries no secret", () => {
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
const credential = forgeCredential("http://novox.internal:20000", b.binding, b.secret);
const path = credentialPath("/work", "run-x");
const s = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, path);
assert.ok(credential);
assert.doesNotMatch(s, /s3cret/);
assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/novox\.internal:20000\/novox\/mesh-tools-go\.git'/);
assert.match(s, /export GIT_TERMINAL_PROMPT=0/);
assert.equal(path.startsWith("/work/run-x/"), false, "the credential is kept out of the run's own directory");
assert.equal(credentialArgs(undefined), "");
});
// Held against git itself: the credential in the store is offered to the forge's origin when it asks, and
// a clone without it fails at once rather than prompting.
test("git presents the stored credential to the forge, and only when one is given", () => {
const dir = mkdtempSync(join(tmpdir(), "lab-git-"));
const store = join(dir, "git-credentials");
writeFileSync(store, "http://lab-shanks:pw@forge.invalid:20000\n", { mode: 0o600 });
const fill = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=forge.invalid:20000\\n\\n' | git ${credentialArgs(store)}credential fill`],
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
assert.match(fill.stdout, /username=lab-shanks/);
assert.match(fill.stdout, /password=pw/);
const other = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=elsewhere.invalid\\n\\n' | git ${credentialArgs(store)}credential fill`],
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
assert.doesNotMatch(other.stdout, /password=pw/);
rmSync(dir, { recursive: true, force: true });
});
// Review 196 on mesh-catalog #272: the credential file outlived a stop, because SIGTERM to the run's
// process group killed the wrapper before its cleanup. It is now removed right after the clones, on
// every way the script ends, and at the next run's start and in lab_check for what a crash left.
import { cleanCredentials, credentialGuard } from "../tools/runs.ts";
test("a stopped script removes the credential, whatever ends it", () => {
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
const dir = mkdtempSync(join(tmpdir(), "lab-guard-"));
const file = join(dir, ".run-x.git-credentials");
writeFileSync(file, "http://u:p@h\n", { mode: 0o600 });
const ran = spawnSync("bash", ["-c", `${credentialGuard(file)}\nkill -${signal.slice(3)} $$\nsleep 5`], { encoding: "utf8", timeout: 10_000 });
assert.notEqual(ran.status, 0, `${signal}: the script was not stopped`);
assert.equal(existsSync(file), false, `${signal} left the credential on disk`);
rmSync(dir, { recursive: true, force: true });
}
});
test("the script removes the credential as soon as its clones are done", () => {
const s = script("/work", "run-x", "http://forge", ["t.test.ts"], {}, "/work/.run-x.git-credentials");
const lastClone = s.lastIndexOf("clone --quiet");
const removed = s.indexOf("rm -f '/work/.run-x.git-credentials'", lastClone);
const building = s.indexOf("npm ci");
assert.ok(lastClone > 0 && removed > lastClone && removed < building, "not removed between the clones and the build");
assert.match(s, /trap 'rm -f '\\''\/work\/\.run-x\.git-credentials'\\''' EXIT/);
});
test("credentials a crashed run left are removed, but not the running one's", () => {
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
for (const n of [".run-a.git-credentials", ".run-b.git-credentials", "run-a", "other"]) writeFileSync(join(work, n), "x");
const removed = cleanCredentials(work, join(work, ".run-b.git-credentials"));
assert.deepEqual(removed, [join(work, ".run-a.git-credentials")]);
assert.ok(existsSync(join(work, ".run-b.git-credentials")) && existsSync(join(work, "run-a")) && existsSync(join(work, "other")));
rmSync(work, { recursive: true, force: true });
});
// The lab asks the forge for read only (review 196): its credential is for clones of branches nobody has
// reviewed yet, and must not be able to publish the mesh's packages.
test("the lab asks the package registry for read only", () => {
const m = JSON.parse(readFileSync(join(import.meta.dirname, "..", "module.json"), "utf8")) as {
requires: unknown[]; contributes?: Record<string, Record<string, unknown>>;
};
assert.ok(m.requires.includes("npm-package-registry"));
assert.equal(m.contributes?.["npm-package-registry"]?.access, "read");
});
+69 -3
View File
@@ -4,7 +4,13 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
cleanCredentials, credentialArgs, forgeCredential, installPlan, listRuns, NPM_REPOSITORIES, readStatus, REPOSITORIES, running, start,
stop, tail, writeCredential,
} from "./runs.js";
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
@@ -12,6 +18,10 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
// The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call.
const credentialOf = (forge: string): string | undefined => forgeCredential(forge,
env.MESH_LAB_FORGE_BINDING ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING"),
env.MESH_LAB_FORGE_SECRET ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_SECRET"));
return [
{
name: "lab_check",
@@ -26,7 +36,17 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
if (clone.status !== 0) return { ok: false, output: clone.stderr };
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
const check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
return { ok: check.status === 0, output: `${check.stdout}${check.stderr}`.trim() };
// **And what a run would check out and install, before a run finds out** (novox/hq issue 624): every
// repository it clones answers on the forge, and every one it installs has a lock file where the run
// installs from.
// A credential a stopped, crashed or rebooted run left behind is removed, and said; one still in use
// by a running run is not.
const busy = running(work);
const left = cleanCredentials(work, busy ? `${work}/.${busy.id}.git-credentials` : undefined);
const problems = setupProblems(forge, `${work}/check-setup`, credentialOf(forge));
const removed = left.length > 0 ? `removed ${left.length} forge credential(s) a run left behind: ${left.join(", ")}` : "";
const output = [`${check.stdout}${check.stderr}`.trim(), removed, ...problems].filter(Boolean).join("\n");
return { ok: check.status === 0 && problems.length === 0, output };
},
},
{
@@ -59,7 +79,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
const busy = running(work);
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy };
return { started: true, run: start(work, forge, tests, refs) };
return { started: true, run: start(work, forge, tests, refs, credentialOf(forge)) };
},
},
{
@@ -89,6 +109,52 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
];
}
/**
* What would stop a run at its setup, in sentences: a repository the run clones that the forge does not
* answer for, and a checkout it installs with nothing installable (installPlan). Empty when a run's setup
* would go through.
*/
export function setupProblems(forge: string, dir: string, credential?: string): string[] {
const problems: string[] = [];
spawnSync("rm", ["-rf", dir]);
// The credential a run would offer, in a file of its own outside the check's clones, removed after.
const keep = mkdtempSync(join(tmpdir(), "lab-check-"));
const file = writeCredential(join(keep, "git-credentials"), credential);
const git = (args: string[]) =>
spawnSync("sh", ["-c", `git ${credentialArgs(file)}${args.map((a) => `'${a.replace(/'/g, `'\\''`)}'`).join(" ")}`], {
encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
});
try {
for (const repo of REPOSITORIES) {
const url = `${forge}/novox/${repo}.git`;
const remote = git(["ls-remote", "--heads", url, "main"]);
if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) {
const said = remote.stderr.trim().split("\n")[0] ?? "";
problems.push(`a run checks out ${repo}, and ${url} could not be read` +
(said ? `: ${said}` : ": no main branch") +
(credential ? "" : " — the lab holds no forge credential (its npm-package-registry binding and secret)"));
continue;
}
if (!NPM_REPOSITORIES.includes(repo)) continue;
const at = `${dir}/${repo}`;
const clone = git(["clone", "--quiet", "--depth", "1", url, at]);
if (clone.status !== 0) {
problems.push(`${repo} could not be cloned from ${url}: ${clone.stderr.trim()}`);
continue;
}
const plan = installPlan(at);
for (const d of plan.unlocked) problems.push(`${repo}: ${d.slice(at.length) || "/"} has a package.json and no package-lock.json, so a run's npm ci cannot install it`);
if (plan.install.length === 0 && plan.unlocked.length === 0) {
problems.push(`${repo}: no package-lock.json at its root or one directory down, so a run installs nothing of it`);
}
}
} finally {
rmSync(keep, { recursive: true, force: true });
spawnSync("rm", ["-rf", dir]);
}
return problems;
}
/** One word from an env-file (`KEY=value` lines), or "" when the file or the word is absent. */
export function wordIn(file: string | undefined, word: string): string {
if (!file) return "";
+150 -10
View File
@@ -7,11 +7,141 @@
// for it, and says it was lost rather than pretending it is still going.
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/** The repositories a lab run checks out, side by side, as the lab expects its siblings. */
export const REPOSITORIES = ["mesh-lab", "mesh-controller", "mesh-host", "mesh-catalog", "mesh-tools", "mesh-sdk"];
/**
* The repositories a lab run checks out, side by side, as the lab expects its siblings. mesh-tools-go is
* one since genesis builds the Go toolchain at its base step, from the repository beside mesh-tools
* (novox/hq issue 616): a bed that raises genesis serves it from its forge.
*/
export const REPOSITORIES = ["mesh-lab", "mesh-controller", "mesh-host", "mesh-catalog", "mesh-tools", "mesh-tools-go", "mesh-sdk"];
/** The checkouts whose JavaScript dependencies a run installs: the lab, the SDK and the base. */
export const NPM_REPOSITORIES = ["mesh-sdk", "mesh-tools", "mesh-lab"];
/**
* Where a checkout's JavaScript dependencies are installed (novox/hq issue 624): its root when the root
* holds a lock file, otherwise each directory one level down that holds one. `npm ci` installs only from a
* lock file, and a repository that moved its package into a directory of its own (mesh-tools' node-tools)
* has none at its root; the run asked for one there and failed before it built anything. A package.json
* with no lock file beside it cannot be installed reproducibly, and is named rather than installed.
*/
export function installPlan(root: string): { install: string[]; unlocked: string[] } {
const has = (dir: string, file: string) => existsSync(join(dir, file));
if (has(root, "package-lock.json")) return { install: [root], unlocked: [] };
const install: string[] = [];
const unlocked: string[] = has(root, "package.json") ? [root] : [];
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
const dir = join(root, entry.name);
if (has(dir, "package-lock.json")) install.push(dir);
else if (has(dir, "package.json")) unlocked.push(dir);
}
return { install: install.sort(), unlocked: unlocked.sort() };
}
/**
* installStep is the shell a run installs one checkout with: the same rule as installPlan, so what
* lab_check says a run would install is what it installs. Building runs where the package says how.
*/
export function installStep(root: string, build: boolean): string {
const q = shellQuote(root);
const install = `(cd "$d" && npm ci --no-audit --no-fund --loglevel=error${build ? " && npm run build --if-present" : ""})`;
return `if [ -f ${q}/package-lock.json ]; then d=${q}; ${install}; else
[ ! -f ${q}/package.json ] || { echo "${root}: a package.json and no package-lock.json, so npm ci cannot install it (novox/hq issue 624)" >&2; exit 1; }
found=0
for d in ${q}/*/; do
d="\${d%/}"; [ "$(basename "$d")" = node_modules ] && continue
if [ -f "$d/package-lock.json" ]; then found=1; ${install}
elif [ -f "$d/package.json" ]; then echo "$d: a package.json and no package-lock.json, so npm ci cannot install it (novox/hq issue 624)" >&2; exit 1; fi
done
[ "$found" = 1 ] || { echo "${root}: no package-lock.json at its root or one directory down, so nothing of it can be installed (novox/hq issue 624)" >&2; exit 1; }
fi`;
}
/**
* The forge credential a run's clones may present, as a git credential-store line —
* scheme://user:password@host[:port] — for the forge's own origin, or undefined when the lab holds none.
*
* **The mesh's credential for the forge, the way the build agent holds it** (novox/hq issue 624): the lab
* requires the package registry, the forge's provisioner binds it an account (`as`) and seals that
* account's password, and the forge that answers npm is the forge that hosts the repositories. Private
* repositories (mesh-tools-go) are cloned with it; public ones clone as before. Written to a file git reads
* through its credential store, never into a URL, an argument, the run's script or its log, and offered
* only to the forge's own scheme, host and port.
*/
export function forgeCredential(forge: string, bindingFile: string, secretFile: string): string | undefined {
if (!forge || !bindingFile || !secretFile) return undefined;
let as = "";
let secret = "";
try {
as = String((JSON.parse(readFileSync(bindingFile, "utf8")) as { as?: string }).as ?? "");
secret = readFileSync(secretFile, "utf8").trim();
} catch {
return undefined;
}
if (!as || !secret) return undefined;
let origin: URL;
try {
origin = new URL(forge);
} catch {
return undefined;
}
const line = new URL(`${origin.protocol}//${origin.host}`);
line.username = as;
line.password = secret;
return line.toString().replace(/\/$/, "");
}
/** Where a run keeps its forge credential: beside its directory, never in it, and removed when it ends. */
export function credentialPath(work: string, id: string): string {
return join(work, `.${id}.git-credentials`);
}
/** writeCredential puts a credential-store line where git reads it, readable by this account alone. */
export function writeCredential(path: string, line: string | undefined): string | undefined {
if (!line) return undefined;
writeFileSync(path, `${line}\n`, { mode: 0o600 });
return path;
}
/**
* credentialGuard is the shell that removes the credential however the script ends — its end, an error,
* or a stop's SIGTERM to the run's process group, which kills the wrapper before its own cleanup runs
* (review 196 on mesh-catalog #272). The script also removes it as soon as its clones are done.
*/
export function credentialGuard(path: string | undefined): string {
if (!path) return "";
const rm = `rm -f ${shellQuote(path)}`;
return `trap ${shellQuote(rm)} EXIT
trap ${shellQuote(`${rm}; exit 143`)} TERM
trap ${shellQuote(`${rm}; exit 130`)} INT
trap ${shellQuote(`${rm}; exit 129`)} HUP`;
}
/**
* cleanCredentials removes every run's credential file left in the work directory but the one named — what
* a crash or a reboot left behind, which no trap could remove. Answers what it removed.
*/
export function cleanCredentials(work: string, keep?: string): string[] {
if (!existsSync(work)) return [];
const removed: string[] = [];
for (const name of readdirSync(work)) {
if (!/^\.run-.*\.git-credentials$/.test(name)) continue;
const path = join(work, name);
if (path === keep) continue;
rmSync(path, { force: true });
removed.push(path);
}
return removed;
}
/** The arguments that have git offer the credential in that file, and only that one. */
export function credentialArgs(path: string | undefined): string {
if (!path) return "";
return `-c credential.helper= -c ${shellQuote(`credential.helper=store --file=${path}`)} `;
}
export interface RunStatus {
id: string;
@@ -78,7 +208,8 @@ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
* The environment is the one the lab's README describes for a run against sibling checkouts, pointed
* at this run's own tree, so what is built and claimed is exactly what was checked out.
*/
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>): string {
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>,
credential?: string): string {
const dir = runDir(work, id);
const setState = (state: string) =>
`node -e ${shellQuote(
@@ -87,15 +218,19 @@ export function script(work: string, id: string, forge: string, tests: string[],
const clones = REPOSITORIES.map((repo) => {
const ref = refs[repo] ?? "main";
return [
`git clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
`git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
`echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`,
].join("\n");
}).join("\n");
const bin = join(dir, "bin");
return `set -euo pipefail
# A repository the forge will not show fails its clone at once, rather than waiting on a prompt.
export GIT_TERMINAL_PROMPT=0
${credentialGuard(credential)}
cd ${shellQuote(dir)}
${setState("checking-out")}
${clones}
${credential ? `# The forge credential is for the clones, and nothing after them runs with it on disk.\nrm -f ${shellQuote(credential)}` : ""}
node -e ${shellQuote(
`const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`,
)}
@@ -104,9 +239,7 @@ ${setState("building")}
# resolves it; nothing else is asked of it.
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
for repo in mesh-sdk mesh-tools mesh-lab; do (cd ${shellQuote(dir)}/$repo && npm ci --no-audit --no-fund --loglevel=error); done
(cd ${shellQuote(dir)}/mesh-sdk && npm run build --if-present)
(cd ${shellQuote(dir)}/mesh-tools && npm run build --if-present)
${NPM_REPOSITORIES.map((repo) => installStep(join(dir, repo), repo !== "mesh-lab")).join("\n")}
mkdir -p ${shellQuote(bin)}
for p in postgres-provisioner objectstore-provisioner route-proxy; do
(cd ${shellQuote(dir)}/mesh-controller && CGO_ENABLED=0 go build -o ${shellQuote(bin)}/$p ./examples/$p)
@@ -117,6 +250,8 @@ export MESH_LAB_MODULES=${shellQuote(join(dir, "mesh-controller", "examples", "m
export MESH_LAB_BUILDER=${shellQuote(join(dir, "mesh-controller", "build", "mesh-builder"))}
export MESH_LAB_BOOTSTRAP_BINARY=${shellQuote(join(dir, "mesh-host", "mesh-bootstrap"))}
export MESH_LAB_CATALOG=${shellQuote(join(dir, "mesh-catalog", "modules"))}
# Where a bed that serves repositories from its own forge finds them: this run's checkouts, side by side.
export MESH_LAB_REPOS=${shellQuote(dir)}
export MESH_LAB_PROVISIONER=${shellQuote(join(bin, "postgres-provisioner"))}
export MESH_LAB_OBJECTSTORE_PROVISIONER=${shellQuote(join(bin, "objectstore-provisioner"))}
export MESH_LAB_ROUTE_PROXY=${shellQuote(join(bin, "route-proxy"))}
@@ -127,13 +262,17 @@ node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join("
}
/** start begins a run and returns at once with its status. */
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>): RunStatus {
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>,
credential?: string): RunStatus {
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
const dir = runDir(work, id);
mkdirSync(dir, { recursive: true });
const status: RunStatus = { id, state: "checking-out", started: new Date().toISOString(), tests, refs };
writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2));
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs), { mode: 0o700 });
// What a crashed or rebooted run left behind goes before this run writes its own.
cleanCredentials(work);
const credentials = writeCredential(credentialPath(work, id), credential);
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials), { mode: 0o700 });
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
@@ -141,6 +280,7 @@ export function start(work: string, forge: string, tests: string[], refs: Record
node -e ${shellQuote(
`const f=${JSON.stringify(statusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));if(s.state!=="stopped"){s.state=process.argv[1]==="0"?"passed":"failed"};s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
)} "$code"
rm -f ${shellQuote(credentialPath(work, id))}
cd ${shellQuote(dir)} && rm -rf ${REPOSITORIES.map(shellQuote).join(" ")} bin`;
const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" });
child.unref();
+1 -14
View File
@@ -17,20 +17,7 @@ another node requires.
(`/etc/nfs-server/machines`, rendered by the mesh), never one a consumer gives, and it must lie inside
the private network's range. The control node, or any node, gets a share only when both name it. A
want that is not granted, and a grant nobody wants, are said by `exports`. Port 2049 is opened to the
mesh by default. The machine's local network is reached only by a grant to `local-network` (below).
- **The local network, only when granted and admitted** (novox/hq ADR 0341, after ADR 0340). `grants` may
name `local-network` in a node's place: `library=ws:rw,local-network:ro`. The process then reads the
machine's links (`ip -j`) and takes the subnets on the links carrying its default route that lie wholly
in a private range (RFC 1918, fc00::/7); a public subnet, the private network's link and container
bridges are never counted, and no range is ever typed in a setting. It exports to such a subnet only
when the mesh's packet filter (table `inet mesh`, chain `input`, read with `nft -j list ruleset`)
admits 2049/tcp from all of it, which it does once the assignment binds 2049 with the reach
`local-network`. A grant the filter does not admit is not exported, said by `exports`, and makes the
module unhealthy, naming that act. Every device on the local network then acts as the folder's owner,
as the hand-written export it replaces already allowed.
- **A dataset's `sharenfs` is cleared only when nobody loses the folder.** `adopt` refuses while the
property, by its value or the file ZFS writes for it, exports to a client the mesh's live export does
not cover: for a property to the home network, the module's `local-network` export.
mesh only; the home network is never named.
- **Every client is the folder's owner.** NFS with `sec=sys` believes the user id a client sends, and one
id names different accounts on different machines. Every line is `all_squash` with `anonuid` and
`anongid` read from the folder's owner on this machine, so a file written from any node lands owned by
@@ -11,7 +11,7 @@ import (
func TestStrayFindsAnExportOfTheModulesThatNoFileCarries(t *testing.T) {
live := []Entry{{Path: "/srv/library", Client: "10.0.0.4"}}
if got := Stray(live, nil, nil, meshRange, nil); len(got) != 1 {
if got := Stray(live, nil, nil, meshRange); len(got) != 1 {
t.Errorf("an export inside the mesh range that no file carries is the module's leftover: %+v", got)
}
}
@@ -21,7 +21,7 @@ func TestStrayLeavesAnExportAnotherFileCarriesEvenInsideTheMeshRange(t *testing.
// ZFS's own file exports the library to a client inside the mesh's range.
foreign := ParseExports("/srv/library 10.0.0.9(rw,no_subtree_check)\n", "/etc/exports.d/zfs.exports")
live := []Entry{{Path: "/srv/library", Client: "10.0.0.9", Options: "rw"}}
if got := Stray(live, nil, foreign, meshRange, nil); len(got) != 0 {
if got := Stray(live, nil, foreign, meshRange); len(got) != 0 {
t.Errorf("what ZFS's file exports is not the module's, whatever its client: %+v", got)
}
}
@@ -30,7 +30,7 @@ func TestStrayLeavesAnExportAnotherFileCarriesEvenInsideTheMeshRange(t *testing.
func TestStrayLeavesAClientOutsideTheMeshRange(t *testing.T) {
live := []Entry{{Path: "/srv/library", Client: "192.0.2.0/24"}, {Path: "/srv/library", Client: "192.0.2.7"},
{Path: "/srv/library", Client: "10.0.0.0/16"}}
if got := Stray(live, nil, nil, meshRange, nil); len(got) != 0 {
if got := Stray(live, nil, nil, meshRange); len(got) != 0 {
t.Errorf("a client the module never exports to (outside or wider than the mesh's range) is not its leftover: %+v", got)
}
}
@@ -1,417 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/netip"
"sort"
"strings"
)
// A share granted to the machine's local network (novox/hq ADR 0341 clauses 8 and 9, after ADR 0340).
//
// **The grant names `local-network`, never a range.** `grants` may name `local-network` for a share beside
// node names: `library=ws:rw,local-network:ro`. The export goes to the subnets this machine's own local
// network is, by ADR 0340's rules: the networks on the links that carry its default route (the uplink),
// each wholly inside a private range (RFC 1918, or fc00::/7), never a public or link-local one, never the
// private network's own link or a container bridge. A range typed in a setting is never used.
//
// **A grant the filter does not admit is not exported, and is said.** ADR 0340 lets an assignment bind a
// port with the reach `local-network`, and the filter then admits it from those subnets on that port's
// rule alone. The module does not take the reach on trust: every pass it reads the mesh's filter (the
// table `inet mesh`, chain `input`) and exports to a local subnet only when 2049/tcp is admitted from all
// of it. Otherwise the share is not exported there, and the status and the health say so, naming the act
// (bind 2049 with the reach local-network) — a grant never reads as served while the filter drops it.
//
// **What this does not promise.** NFS with sec=sys trusts the client, so every device on the local
// network acts as the share's owner (all_squash, as for a node). That is what the hand-written export it
// replaces already allowed.
// LocalNetwork is the name a grant gives the machine's local network.
const LocalNetwork = "local-network"
// NFSPort is the port the filter must admit from a local subnet before a share is exported to it.
const NFSPort = 2049
// private are the ranges a local network must lie wholly inside (ADR 0340 §2).
var private = []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.0.0/16"),
netip.MustParsePrefix("fc00::/7"),
}
// wholly says whether a network lies wholly inside one of the private ranges.
func wholly(p netip.Prefix) bool {
for _, r := range private {
if p.Bits() >= r.Bits() && r.Contains(p.Addr()) {
return true
}
}
return false
}
// ipRoute is one route as `ip -j route show default` answers it.
type ipRoute struct {
Dev string `json:"dev"`
}
// ipLink is one link as `ip -j addr show` answers it.
type ipLink struct {
Name string `json:"ifname"`
AddrInfo []struct {
Family string `json:"family"`
Local string `json:"local"`
Prefixlen int `json:"prefixlen"`
Scope string `json:"scope"`
} `json:"addr_info"`
}
// LocalNetworksFrom is the machine's local networks from what `ip` answered: the links carrying a default
// route, and their addresses. A network not wholly private, or not global in scope, is left out, and why.
func LocalNetworksFrom(routes4, routes6, addrs string) ([]string, []string) {
uplinks := map[string]bool{}
for _, text := range []string{routes4, routes6} {
var rs []ipRoute
if strings.TrimSpace(text) == "" {
continue
}
if err := json.Unmarshal([]byte(text), &rs); err != nil {
continue
}
for _, r := range rs {
if r.Dev != "" {
uplinks[r.Dev] = true
}
}
}
var links []ipLink
if strings.TrimSpace(addrs) != "" {
_ = json.Unmarshal([]byte(addrs), &links)
}
seen := map[string]bool{}
var out, left []string
for _, l := range links {
if !uplinks[l.Name] {
continue
}
for _, a := range l.AddrInfo {
if a.Scope != "global" {
continue
}
addr, err := netip.ParseAddr(a.Local)
if err != nil {
continue
}
p, err := addr.Prefix(a.Prefixlen)
if err != nil {
continue
}
if !wholly(p) {
left = append(left, fmt.Sprintf("%s on %s is not wholly inside a private range, so it is not the local network", p, l.Name))
continue
}
if !seen[p.String()] {
seen[p.String()] = true
out = append(out, p.String())
}
}
}
sort.Strings(out)
return out, left
}
// LocalNetworks is this machine's local networks now, read with `ip`, and what was left out or could not
// be read.
func (m *Machine) LocalNetworks(ctx context.Context) ([]string, []string) {
r4 := m.Run(ctx, "ip", "-j", "-4", "route", "show", "default")
r6 := m.Run(ctx, "ip", "-j", "-6", "route", "show", "default")
ad := m.Run(ctx, "ip", "-j", "addr", "show")
var notes []string
if r4.Code != 0 || ad.Code != 0 {
notes = append(notes, "this machine's links could not be read with ip: "+firstLine(r4.Err+" "+ad.Err))
}
nets, left := LocalNetworksFrom(r4.Out, r6.Out, ad.Out)
return nets, append(notes, left...)
}
// nftRule is one rule of `nft -j list ruleset`.
type nftRule struct {
Family string `json:"family"`
Table string `json:"table"`
Chain string `json:"chain"`
Expr []json.RawMessage `json:"expr"`
}
// nftChain is one chain of `nft -j list ruleset`.
type nftChain struct {
Family string `json:"family"`
Table string `json:"table"`
Name string `json:"name"`
Policy string `json:"policy"`
}
// Admitted says, for each subnet, whether the mesh's filter (table inet mesh, chain input) accepts tcp to
// the port from all of it, read from `nft -j list ruleset`. A rule counts only when every condition it has
// is one this reads — the protocol, the destination port and the source address — and its verdict is
// accept; a rule with any other condition (an interface, a connection state) is left out, so nothing is
// called admitted that may not be. With no such table, nothing is admitted, and why.
func Admitted(ruleset string, port int, subnets []string) (map[string]bool, string) {
out := map[string]bool{}
var doc struct {
Nftables []map[string]json.RawMessage `json:"nftables"`
}
if err := json.Unmarshal([]byte(ruleset), &doc); err != nil {
return out, "the packet filter's rules could not be read (nft -j list ruleset): " + err.Error()
}
var rules []nftRule
found := false
policy := ""
for _, item := range doc.Nftables {
if raw, ok := item["chain"]; ok {
var c nftChain
if json.Unmarshal(raw, &c) == nil && c.Family == "inet" && c.Table == "mesh" && c.Name == "input" {
found, policy = true, c.Policy
}
}
if raw, ok := item["rule"]; ok {
var r nftRule
if json.Unmarshal(raw, &r) == nil && r.Family == "inet" && r.Table == "mesh" && r.Chain == "input" {
rules = append(rules, r)
}
}
}
if !found {
return out, "the mesh's packet filter (table inet mesh, chain input) is not there, so no local network is admitted"
}
if policy == "accept" {
for _, s := range subnets {
out[s] = true
}
return out, ""
}
for _, r := range rules {
sources, ok := admitsPort(r.Expr, port)
if !ok {
continue
}
for _, s := range subnets {
p, err := netip.ParsePrefix(s)
if err != nil {
continue
}
if sources == nil {
out[s] = true
continue
}
for _, src := range sources {
if src.Bits() <= p.Bits() && src.Contains(p.Addr()) {
out[s] = true
}
}
}
}
return out, ""
}
// admitsPort reads one rule: whether it accepts tcp to the port, and from which sources (nil: from
// anywhere). ok is false for a rule that does not accept that port, or that has a condition this does not
// read.
func admitsPort(exprs []json.RawMessage, port int) (sources []netip.Prefix, ok bool) {
accepts, portMatched, anywhere := false, false, true
for _, raw := range exprs {
var e map[string]json.RawMessage
if json.Unmarshal(raw, &e) != nil {
return nil, false
}
if _, is := e["accept"]; is {
accepts = true
continue
}
if _, is := e["counter"]; is {
continue
}
m, is := e["match"]
if !is {
return nil, false
}
var match struct {
Op string `json:"op"`
Left json.RawMessage `json:"left"`
Right json.RawMessage `json:"right"`
}
if json.Unmarshal(m, &match) != nil || (match.Op != "==" && match.Op != "in") {
return nil, false
}
var left struct {
Payload *struct {
Protocol string `json:"protocol"`
Field string `json:"field"`
} `json:"payload"`
Meta *struct {
Key string `json:"key"`
} `json:"meta"`
}
if json.Unmarshal(match.Left, &left) != nil {
return nil, false
}
switch {
case left.Meta != nil && left.Meta.Key == "l4proto":
var proto string
if json.Unmarshal(match.Right, &proto) != nil || proto != "tcp" {
return nil, false
}
case left.Payload != nil && left.Payload.Protocol == "tcp" && left.Payload.Field == "dport":
if !hasPort(match.Right, port) {
return nil, false
}
portMatched = true
case left.Payload != nil && (left.Payload.Protocol == "ip" || left.Payload.Protocol == "ip6") && left.Payload.Field == "saddr":
anywhere = false
sources = append(sources, prefixesOf(match.Right)...)
default:
return nil, false
}
}
if !accepts || !portMatched {
return nil, false
}
if anywhere {
return nil, true
}
if sources == nil {
sources = []netip.Prefix{}
}
return sources, true
}
// hasPort reads a match's right side: a number, or a set holding it.
func hasPort(raw json.RawMessage, port int) bool {
var n int
if json.Unmarshal(raw, &n) == nil {
return n == port
}
var set struct {
Set []json.RawMessage `json:"set"`
}
if json.Unmarshal(raw, &set) == nil {
for _, item := range set.Set {
if json.Unmarshal(item, &n) == nil && n == port {
return true
}
}
}
return false
}
// prefixesOf reads a match's right side as sources: an address, a prefix, or a set of either.
func prefixesOf(raw json.RawMessage) []netip.Prefix {
var s string
if json.Unmarshal(raw, &s) == nil {
if a, err := netip.ParseAddr(s); err == nil {
return []netip.Prefix{netip.PrefixFrom(a, a.BitLen())}
}
if p, err := netip.ParsePrefix(s); err == nil {
return []netip.Prefix{p.Masked()}
}
return nil
}
var pre struct {
Prefix *struct {
Addr string `json:"addr"`
Len int `json:"len"`
} `json:"prefix"`
Set []json.RawMessage `json:"set"`
}
if json.Unmarshal(raw, &pre) != nil {
return nil
}
if pre.Prefix != nil {
if a, err := netip.ParseAddr(pre.Prefix.Addr); err == nil {
if p, err := a.Prefix(pre.Prefix.Len); err == nil {
return []netip.Prefix{p}
}
}
return nil
}
var out []netip.Prefix
for _, item := range pre.Set {
out = append(out, prefixesOf(item)...)
}
return out
}
// LocalNetworkAdmitted is what the process learns each pass: the machine's local subnets, which of them the
// filter admits 2049 from, and what it could not learn.
func (m *Machine) LocalNetworkAdmitted(ctx context.Context) (nets []string, admitted map[string]bool, notes []string) {
nets, notes = m.LocalNetworks(ctx)
if len(nets) == 0 {
return nets, map[string]bool{}, notes
}
r := m.Run(ctx, "nft", "-j", "list", "ruleset")
if r.Code != 0 {
return nets, map[string]bool{}, append(notes, "the packet filter's rules could not be read (nft -j list ruleset): "+firstLine(r.Err))
}
admitted, note := Admitted(r.Out, NFSPort, nets)
if note != "" {
notes = append(notes, note)
}
return nets, admitted, notes
}
// Covered says whether a client a hand-made export names is covered by the mesh's live export of the same
// path: the same address, or a network inside one the mesh exports to. "*" and a name are never covered.
func Covered(client string, mine []Grantee) bool {
var want netip.Prefix
if a, err := netip.ParseAddr(client); err == nil {
want = netip.PrefixFrom(a, a.BitLen())
} else if p, err := netip.ParsePrefix(client); err == nil {
want = p.Masked()
} else {
return false
}
for _, g := range mine {
var have netip.Prefix
if a, err := netip.ParseAddr(g.Address); err == nil {
have = netip.PrefixFrom(a, a.BitLen())
} else if p, err := netip.ParsePrefix(g.Address); err == nil {
have = p.Masked()
} else {
continue
}
if have.Bits() <= want.Bits() && have.Contains(want.Addr()) {
return true
}
}
return false
}
// PropertyClients is who a dataset's sharenfs property exports to: every `@network` or address its value
// names, and "*" for a bare `on` or a value naming nobody, which nothing the mesh exports ever covers.
func PropertyClients(sharenfs string) []string {
v := strings.TrimSpace(sharenfs)
if v == "on" || v == "" {
return []string{"*"}
}
var out []string
for _, opt := range strings.Split(v, ",") {
key, list, ok := strings.Cut(strings.TrimSpace(opt), "=")
if key != "rw" && key != "ro" {
continue
}
if !ok {
return []string{"*"} // rw or ro alone: to everyone
}
for _, c := range strings.Split(list, ":") {
c = strings.TrimPrefix(strings.TrimSpace(c), "@")
if c != "" {
out = append(out, c)
}
}
}
if len(out) == 0 {
return []string{"*"}
}
return out
}
@@ -1,184 +0,0 @@
package main
import (
"context"
"reflect"
"strings"
"testing"
)
// Defends novox/hq ADR 0341 clauses 8 to 10: a share granted to local-network is exported to the machine's
// own private subnets on its uplink, only those the mesh's filter admits 2049 from; a grant the filter does
// not admit is said and unhealthy; and sharenfs is cleared only once the mesh's export covers every client
// the property exported to.
const homeNet = "192.168.1.0/24"
// The home server's links, as `ip -j` answers them: the uplink with a private and a public address, the
// private network's link, and a container bridge.
const (
routes4 = `[{"dst":"default","gateway":"192.168.1.1","dev":"enp6s0"}]`
addrs = `[
{"ifname":"lo","addr_info":[{"family":"inet","local":"127.0.0.1","prefixlen":8,"scope":"host"}]},
{"ifname":"enp6s0","addr_info":[
{"family":"inet","local":"192.168.1.135","prefixlen":24,"scope":"global"},
{"family":"inet","local":"198.51.100.7","prefixlen":24,"scope":"global"},
{"family":"inet6","local":"fe80::1","prefixlen":64,"scope":"link"}]},
{"ifname":"mesh0","addr_info":[{"family":"inet","local":"10.0.0.2","prefixlen":32,"scope":"global"}]},
{"ifname":"docker0","addr_info":[{"family":"inet","local":"172.17.0.1","prefixlen":16,"scope":"global"}]}]`
)
// rulesetAdmitting is the mesh's filter with 2049 admitted from the nodes and, when local is set, from it.
func rulesetAdmitting(local string) string {
src := `{"set":["10.0.0.1","10.0.0.3"]}`
if local != "" {
addr, bits, _ := strings.Cut(local, "/")
src = `{"set":["10.0.0.1","10.0.0.3",{"prefix":{"addr":"` + addr + `","len":` + bits + `}}]}`
}
return `{"nftables":[{"metainfo":{"version":"1.1"}},
{"table":{"family":"inet","name":"mesh"}},
{"chain":{"family":"inet","table":"mesh","name":"input","type":"filter","hook":"input","prio":0,"policy":"drop"}},
{"rule":{"family":"inet","table":"mesh","chain":"input","expr":[{"match":{"op":"!=","left":{"meta":{"key":"iifname"}},"right":{"set":["enp6s0","mesh0"]}}},{"accept":null}]}},
{"rule":{"family":"inet","table":"mesh","chain":"input","expr":[{"match":{"op":"==","left":{"payload":{"protocol":"ip","field":"saddr"}},"right":` + src + `}},{"match":{"op":"==","left":{"payload":{"protocol":"tcp","field":"dport"}},"right":2049}},{"accept":null}]}},
{"rule":{"family":"inet","table":"mesh","chain":"input","expr":[{"match":{"op":"==","left":{"payload":{"protocol":"tcp","field":"dport"}},"right":445}},{"accept":null}]}}]}`
}
func (f *fake) homeLinks(ruleset string) {
f.answers["ip -j -4 route show default"] = Ran{Out: routes4}
f.answers["ip -j -6 route show default"] = Ran{Out: "[]"}
f.answers["ip -j addr show"] = Ran{Out: addrs}
f.answers["nft -j list ruleset"] = Ran{Out: ruleset}
}
func TestTheLocalNetworkIsTheUplinksPrivateSubnetsOnly(t *testing.T) {
nets, left := LocalNetworksFrom(routes4, "", addrs)
if !reflect.DeepEqual(nets, []string{homeNet}) {
t.Errorf("local networks %v; the uplink's private subnet alone, never the public one, the mesh's link or a bridge", nets)
}
if len(left) != 1 || !strings.Contains(left[0], "198.51.100.0/24") {
t.Errorf("the public subnet is not said as left out: %v", left)
}
if nets, _ := LocalNetworksFrom("", "", addrs); len(nets) != 0 {
t.Errorf("with no default route there is no uplink and no local network: %v", nets)
}
}
func TestAdmittedReadsThePortsOwnRuleOnly(t *testing.T) {
got, note := Admitted(rulesetAdmitting(homeNet), 2049, []string{homeNet})
if note != "" || !got[homeNet] {
t.Errorf("2049 admitted from the home network is not read as admitted: %v %q", got, note)
}
got, _ = Admitted(rulesetAdmitting(""), 2049, []string{homeNet})
if got[homeNet] {
t.Error("2049 admitted from the nodes only is read as admitted from the home network")
}
// A rule open to every source on 445 says nothing of 2049, and the interface rule is not read as one.
if got, _ := Admitted(rulesetAdmitting(""), 445, []string{homeNet}); !got[homeNet] {
t.Error("a port open from anywhere is not admitted")
}
if got, note := Admitted(`{"nftables":[]}`, 2049, []string{homeNet}); got[homeNet] || note == "" {
t.Errorf("with no mesh filter a subnet is admitted, or nothing is said: %v %q", got, note)
}
}
// grantedLocal is the library granted to ws and to the local network, ws wanting it.
func grantedLocal(f *fake, grant string) {
f.mountinfo("/", "/srv/library")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw,"+grant))
f.serve("library")
f.kernelReads()
}
func TestAShareGrantedToTheLocalNetworkGoesToItsSubnetWhenTheFilterAdmitsIt(t *testing.T) {
f := newFake(t)
grantedLocal(f, "local-network:ro")
f.homeLinks(rulesetAdmitting(homeNet))
s := f.machine().Pass(context.Background(), nil)
text := f.read(ExportFile)
if !strings.Contains(text, " "+homeNet+"(ro,mp,sync,secure,no_subtree_check,all_squash,") {
t.Errorf("the library is not exported read-only to the home network, squashed:\n%s", text)
}
if strings.Contains(text, "198.51.100") || strings.Contains(text, "172.17") {
t.Errorf("a public subnet or a bridge is exported to:\n%s", text)
}
if len(s.Unreached) != 0 || !s.Shares[0].Live || len(s.Shares[0].To) != 2 {
t.Errorf("status: %+v", s)
}
if h := f.machine().Health(); !h.Healthy || !strings.Contains(h.Why, "local-network ("+homeNet+", ro)") {
t.Errorf("health: %+v", h)
}
}
func TestAGrantTheFilterDoesNotAdmitIsNotExportedAndIsUnhealthy(t *testing.T) {
f := newFake(t)
grantedLocal(f, "local-network:rw")
f.homeLinks(rulesetAdmitting(""))
s := f.machine().Pass(context.Background(), nil)
if strings.Contains(f.read(ExportFile), homeNet) {
t.Errorf("the library is exported to a subnet the filter drops:\n%s", f.read(ExportFile))
}
if len(s.Unreached) != 1 || !strings.Contains(s.Unreached[0], "reach local-network") {
t.Errorf("the unreached grant is not said with its act: %v", s.Unreached)
}
if h := f.machine().Health(); h.Healthy || !strings.Contains(h.Why, "does not admit 2049/tcp") {
t.Errorf("health: %+v", h)
}
}
func TestWithNoPrivateNetworkOnTheUplinkALocalGrantGoesNowhereAndIsSaid(t *testing.T) {
f := newFake(t)
grantedLocal(f, "local-network:rw")
s := f.machine().Pass(context.Background(), nil) // ip answers nothing
if strings.Contains(f.read(ExportFile), "192.168") {
t.Errorf("exported to a local network that was not found:\n%s", f.read(ExportFile))
}
if !strings.Contains(strings.Join(s.Said, "\n"), "reports no private network on its uplink") {
t.Errorf("not said: %v", s.Said)
}
}
func TestAdoptWaitsUntilTheLocalNetworkExportCoversTheProperty(t *testing.T) {
f := newFake(t)
f.uid = 1000
zfsWith(f, "rw=@"+homeNet)
f.file("/etc/exports.d/zfs.exports", "/srv/library "+homeNet+"(sec=sys,rw,no_subtree_check,mountpoint)\n")
grantedLocal(f, "local-network:rw")
f.homeLinks(rulesetAdmitting("")) // the filter does not admit the home network yet
f.machine().Pass(context.Background(), nil)
if _, err := f.machine().Adopt("/srv/library", false, ""); err == nil || !strings.Contains(err.Error(), "does not cover") {
t.Fatalf("adopt while the home network is not covered: %v", err)
}
f.homeLinks(rulesetAdmitting(homeNet))
f.machine().Pass(context.Background(), nil)
f.ran = nil
dry, err := f.machine().Adopt("/srv/library", false, "")
if err != nil || !strings.Contains(dry.Said, "would set sharenfs=off") {
t.Fatalf("dry run once covered: %+v %v", dry, err)
}
for _, r := range f.ran {
if strings.Contains(r, "zfs set") {
t.Fatalf("a dry run changed something: %v", f.ran)
}
}
}
func TestPropertyClientsAndCovered(t *testing.T) {
for v, want := range map[string]string{
"rw=@192.168.1.0/24": "192.168.1.0/24",
"rw=@192.168.1.0/24:@10.0.0.3": "192.168.1.0/24 10.0.0.3",
"on": "*",
"rw,no_root_squash": "*",
"sec=sys,ro=@10.0.0.3": "10.0.0.3",
} {
if got := strings.Join(PropertyClients(v), " "); got != want {
t.Errorf("PropertyClients(%q) = %q, not %q", v, got, want)
}
}
mine := []Grantee{{Node: "ws", Address: "10.0.0.3"}, {Node: LocalNetwork, Address: homeNet}}
for client, want := range map[string]bool{"10.0.0.3": true, homeNet: true, "192.168.1.7": true, "192.168.0.0/16": false, "*": false, "10.0.0.4": false} {
if Covered(client, mine) != want {
t.Errorf("Covered(%q) is not %v", client, want)
}
}
}
@@ -259,9 +259,7 @@ func TestAdoptIsADryRunWithoutConfirmAndRefusesWhileTheMeshExportIsNotLive(t *te
f := newFake(t)
f.uid = 1000
f.dir("/srv/library")
// A property the mesh's export covers once live: the one node. One to the home network is
// localnet_test.go's.
zfsWith(f, "rw=@"+wsAddress)
zfsWith(f, "rw=@192.0.2.0/24")
f.file(ConfigFile, config("library=/srv/library"))
f.serve("library")
// The kernel has not read the mesh's file: no exportfs effect.
+7 -45
View File
@@ -31,11 +31,6 @@ type Status struct {
// Warnings make the module no less healthy but are said in its health: what it could not learn, and
// how old what it relies on is.
Warnings []string `json:"warnings,omitempty"`
// Local is the machine's local subnets this pass (ADR 0341): what a grant to local-network may reach.
Local []string `json:"local_networks,omitempty"`
// Unreached is each grant to the local network the packet filter does not admit: not exported, and the
// module is unhealthy until it is.
Unreached []string `json:"unreached,omitempty"`
// Reloaded is when the process last had the kernel read the export files again, and why.
Reloaded *time.Time `json:"reloaded,omitempty"`
ReloadWhy string `json:"reload_why,omitempty"`
@@ -66,17 +61,8 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
if _, note := m.Backup(); note != "" {
s.Warnings = append(s.Warnings, note)
}
if grantsLocal(c) {
nets, admitted, notes := m.LocalNetworkAdmitted(ctx)
c.Local = &Local{Nets: nets, Admitted: admitted}
s.Local = nets
s.Said = append(s.Said, notes...)
}
m.setWalk(ctx)
exports, refused, said := m.Judge(c, roster, wants)
if c.Local != nil {
s.Unreached = c.Local.Unreached
}
m.setWalk(nil)
if ctx.Err() != nil {
// Stopping: write nothing and reload nothing, so Stopped starts at once.
@@ -114,7 +100,7 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
}
}
if reload == "" {
if stray := Stray(live, mine, foreign, c.Range, s.Local); len(stray) > 0 {
if stray := Stray(live, mine, foreign, c.Range); len(stray) > 0 {
reload = fmt.Sprintf("the kernel exports %s to %s, which the export file no longer carries", stray[0].Path, stray[0].Client)
}
}
@@ -130,7 +116,7 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
if note != "" && len(exports) > 0 {
s.Problems = append(s.Problems, note)
}
for _, e := range Stray(live, mine, foreign, c.Range, s.Local) {
for _, e := range Stray(live, mine, foreign, c.Range) {
s.Problems = append(s.Problems, fmt.Sprintf("the kernel still exports %s to %s, which the module's export file "+
"no longer carries: a reload has to take it away (exportfs -ra as root)", e.Path, e.Client))
}
@@ -270,34 +256,21 @@ func loud(what string) {
}
// Stray is what the kernel exports that is the module's and that its file no longer carries: an entry to a
// client inside the private network's range, or one of the machine's local subnets — the only clients the
// module ever exports to — that neither the module's file nor any other export file (/etc/exports, ZFS's
// own) has. What another file exports is not the module's, whatever its client.
func Stray(live, mine, foreign []Entry, rng string, local []string) []Entry {
// client inside the private network's range — the only clients the module ever exports to — that neither
// the module's file nor any other export file (/etc/exports, ZFS's own) has. What another file exports is
// not the module's, whatever its client.
func Stray(live, mine, foreign []Entry, rng string) []Entry {
prefix, err := netip.ParsePrefix(rng)
if err != nil {
return nil
}
ours := []netip.Prefix{prefix}
for _, n := range local {
if p, err := netip.ParsePrefix(n); err == nil {
ours = append(ours, p.Masked())
}
}
has := map[string]bool{}
for _, e := range append(append([]Entry{}, mine...), foreign...) {
has[path.Clean(e.Path)+" "+e.Client] = true
}
var out []Entry
for _, e := range live {
if has[path.Clean(e.Path)+" "+e.Client] {
continue
}
inside := false
for _, p := range ours {
inside = inside || insideRange(e.Client, p)
}
if !inside {
if has[path.Clean(e.Path)+" "+e.Client] || !insideRange(e.Client, prefix) {
continue
}
out = append(out, e)
@@ -315,14 +288,3 @@ func insideRange(client string, prefix netip.Prefix) bool {
}
return false
}
// grantsLocal says whether any share is granted to the local network, so the process looks at the links
// and the filter only when a grant asks for them.
func grantsLocal(c Config) bool {
for _, nodes := range c.Grants {
if _, ok := nodes[LocalNetwork]; ok {
return true
}
}
return false
}
+3 -34
View File
@@ -28,8 +28,7 @@ import (
// write to WantsFile). The access is the lower of the two: a grant of ro is never widened by a want. The
// address is the mesh's own, from the roster the mesh renders (RosterFile), never one a consumer gave,
// and it must lie in the private network's range. A share nobody is granted and wants is exported to
// nobody. The local network is named only by a grant to `local-network`, and then only the subnets the
// packet filter admits 2049 from (localnet.go, novox/hq ADR 0341).
// nobody. The home network is never named.
//
// **Never the backups, never root's.** A share is judged at its real path (symlinks resolved). It is
// refused when it is the machine's root, is not there, is not a folder, is owned by root or root's group
@@ -87,17 +86,6 @@ type Config struct {
// Grants is, per share, the nodes this machine lets mount it and with which access.
Grants map[string]map[string]string
Range string
// Local is the machine's local network as the process found it this pass (localnet.go): nil when it was
// not looked at, and a grant to local-network then goes nowhere.
Local *Local
}
// Local is the machine's local subnets, which of them the filter admits 2049 from, and — written by Judge —
// each local-network grant the filter does not admit.
type Local struct {
Nets []string
Admitted map[string]bool
Unreached []string
}
// Want is one share a node's mounts module wants of this machine, as it put it on the bus.
@@ -184,8 +172,7 @@ func ParseShares(value string) ([]Share, []string) {
}
// ParseGrants reads the `grants` setting: `share=node:rw|ro[,node:rw|ro]`, entries separated by spaces;
// `none` grants nothing. `local-network` in a node's place grants the machine's local network
// (localnet.go).
// `none` grants nothing.
func ParseGrants(value string) (map[string]map[string]string, []string) {
out := map[string]map[string]string{}
seen := map[string]bool{}
@@ -470,25 +457,8 @@ func (m *Machine) Judge(c Config, roster map[string]string, wants map[string][]W
e.To = append(e.To, Grantee{Node: node, Address: addr, Access: access})
}
}
if g, ok := granted[LocalNetwork]; ok {
switch {
case c.Local == nil || len(c.Local.Nets) == 0:
said = append(said, fmt.Sprintf("%s is granted to the local network, and this machine reports no private network on its uplink, so it is exported to no local network", s.Name))
default:
for _, n := range c.Local.Nets {
if !c.Local.Admitted[n] {
c.Local.Unreached = append(c.Local.Unreached, fmt.Sprintf("the share %s is granted to the local network %s, and the packet filter does not admit %d/tcp from it, so it is not exported there: bind nfs-server's %d with the reach local-network (novox/hq ADR 0340, ADR 0341)", s.Name, n, NFSPort, NFSPort))
continue
}
e.To = append(e.To, Grantee{Node: LocalNetwork, Address: n, Access: lower(g, s.Access)})
}
}
}
grantedNodes := make([]string, 0, len(granted))
for n := range granted {
if n == LocalNetwork {
continue
}
if !wanting[n] {
grantedNodes = append(grantedNodes, n)
}
@@ -504,7 +474,7 @@ func (m *Machine) Judge(c Config, roster map[string]string, wants map[string][]W
e.Clients = strings.Join(addrs, ",")
e.Line = ExportLine(e)
if len(e.To) == 0 {
said = append(said, fmt.Sprintf("%s is exported to no node: none is both granted it and wants it, and no local network is granted and admitted", s.Name))
said = append(said, fmt.Sprintf("%s is exported to no node: none is both granted it and wants it", s.Name))
}
out = append(out, e)
}
@@ -547,7 +517,6 @@ func ExportFileText(exports []Exported) string {
b.WriteString("# Written by the mesh's module nfs-server (novox/hq ADR 0263). Rendered by the module's own process\n")
b.WriteString("# from its assignment's `shares` and `grants` and the nodes that want each share; edit the settings,\n")
b.WriteString("# never this file. One share per folder, to each granted and wanting node's private address only,\n")
b.WriteString("# and to a local subnet only when local-network is granted and the filter admits 2049 from it;\n")
b.WriteString("# every client squashed to the folder's owner.\n")
for _, e := range exports {
if e.Line == "" {
+1 -29
View File
@@ -127,7 +127,7 @@ func (m *Machine) Exports() ExportsAnswer {
}
a.Notes = append(a.Notes, st.Problems...)
a.Notes = append(a.Notes, st.Warnings...)
a.Unmatched = append(append([]string{}, st.Said...), st.Unreached...)
a.Unmatched = st.Said
}
entries, sets, notes := m.Foreign()
a.Notes = append(a.Notes, notes...)
@@ -380,31 +380,6 @@ func (m *Machine) Adopt(p string, confirm bool, why string) (AdoptAnswer, error)
if lnote != "" || !SharedLive(live, *mine) {
return a, fmt.Errorf("the mesh's export of %s to %s is not live in the kernel yet, so taking over would unexport it; wait for the module's process, or call reload", p, grantees(mine.To))
}
// Every client the property exported to must be covered by the mesh's live export, so no client loses
// the folder in the take-over (novox/hq ADR 0341 clause 10): for a property to the home network, the
// module's local-network export of it. Who the property exports to is read from its value and from the
// file ZFS writes for it.
clients := PropertyClients(ds.ShareNFS)
entries, _, _ := m.Foreign()
for _, e := range entries {
if strings.Contains(e.From, "zfs") && path.Clean(e.Path) == p {
clients = append(clients, e.Client)
}
}
var uncovered []string
seenClient := map[string]bool{}
for _, c := range clients {
if seenClient[c] {
continue
}
seenClient[c] = true
if !Covered(c, mine.To) {
uncovered = append(uncovered, c)
}
}
if len(uncovered) > 0 {
return a, fmt.Errorf("the dataset's sharenfs exports %s to %s, which the mesh's export (to %s) does not cover, so taking over would unexport it for them: grant the share to local-network (and bind %d with the reach local-network) until the mesh's export covers them, or have a person decide they lose it", p, strings.Join(uncovered, ", "), grantees(mine.To), NFSPort)
}
plan := fmt.Sprintf("set sharenfs=off on the dataset %s (now %q) and run exportfs -ra; %s stays exported as the share %s to %s, every client as %d:%d",
ds.Name, ds.ShareNFS, p, mine.Name, grantees(mine.To), mine.UID, mine.GID)
if !confirm {
@@ -453,9 +428,6 @@ func (m *Machine) Health() Health {
for _, r := range st.Refused {
found = append(found, r.Why)
}
for _, u := range st.Unreached {
found = append(found, sentence(u))
}
if st.ReloadError != "" {
found = append(found, sentence("the kernel could not read the export files again: "+st.ReloadError))
}
+3 -3
View File
@@ -55,7 +55,7 @@
"grants": {
"kind": "preference",
"default": "none",
"why": "which nodes may mount which share, and how, is this machine's to say (hq ADR 0263, review of mesh-catalog #136): share=node:rw|ro[,node:rw|ro], entries separated by spaces; none exports to no node, whatever a node wants. local-network in a node's place grants this machine's local network, the private subnets on its uplink (hq ADR 0341)"
"why": "which nodes may mount which share, and how, is this machine's to say (hq ADR 0263, review of mesh-catalog #136): share=node:rw|ro[,node:rw|ro], entries separated by spaces; none exports to no node, whatever a node wants"
}
},
"facts": {
@@ -74,7 +74,7 @@
"protocol": "tcp",
"from": "mesh",
"fixed": true,
"why": "the shares, to the mesh's machines (hq ADR 0263): NFS version 4 alone, which needs no other port. A machine whose grants name local-network binds this port with the reach local-network (hq ADR 0340, ADR 0341), and the module exports to a local subnet only while the filter admits it from there"
"why": "the shares, to the mesh's machines only (hq ADR 0263): NFS version 4 alone, which needs no other port, and never the home network, where a device that is not a node could claim any user id"
}
],
"resources": [
@@ -154,7 +154,7 @@
"type": "file",
"path": "/etc/nfs-server/shares.conf",
"mode": "0644",
"content": "# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment.\n# Replaced on every push; change the `shares` setting, never this file.\n#\n# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…],\n# which nodes may mount each and how; local-network in a node's place grants this machine's local\n# network (novox/hq ADR 0341). The module's process exports a share to a node's private address only\n# when it is granted here and that node's mounts module wants it, and to a local subnet only when the\n# packet filter admits 2049 from it; every client mapped to the folder's owner.\nshares=${setting:shares}\ngrants=${setting:grants}\nrange=${machine:mesh-range}\n"
"content": "# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment.\n# Replaced on every push; change the `shares` setting, never this file.\n#\n# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…],\n# which nodes may mount each and how. The module's process exports a share to a node's private address\n# only when it is granted here and that node's mounts module wants it, every client mapped to the\n# folder's owner, and only to addresses inside the range below.\nshares=${setting:shares}\ngrants=${setting:grants}\nrange=${machine:mesh-range}\n"
}
],
"build": {
-288
View File
@@ -1,288 +0,0 @@
package main
import (
"fmt"
"path"
"sort"
"strings"
)
// The shares and logins, from the assignment, the hand-written configuration as found, and the module's
// rendering (novox/hq ADR 0341 clauses 2 to 4).
//
// **One share per folder, every client as its owner.** A share is a name and a path in the `shares`
// setting (`library=/srv/library`, `:ro` for read-only). It is served with `force user` and `force group`
// read from the real folder's owner, never written as numbers in a setting, so a file written by any client
// lands owned by the folder's owner. The refusals are ADR 0263's: the machine's root, a folder not there or
// not a folder, a folder owned by root or root's group, and a folder inside or holding a backup repository.
//
// **SMB 2.1 and later, on 445 alone.** NetBIOS is off (`disable netbios`, `smb ports = 445`), so 137, 138 and
// 139 are never served; the minimum protocol is SMB2_10. No guest: `map to guest = never`.
// Share is one share the operator asked for.
type Share struct {
Name string `json:"name"`
Path string `json:"path"`
Access string `json:"access"`
}
// Config is what the mesh rendered from the assignment.
type Config struct {
Shares []Share
Users []string
// Passwords is the folder the mesh places the logins' secrets in (smb-password-<user>.secret).
Passwords string
}
// ParseConfig reads the config file: `shares=`, `users=` and `passwords=` lines.
func ParseConfig(text string) (Config, []string) {
var c Config
var problems []string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
problems = append(problems, fmt.Sprintf("the line %q of %s says nothing this module reads", line, ConfigFile))
continue
}
switch strings.TrimSpace(key) {
case "shares":
shares, bad := ParseShares(value)
c.Shares, problems = shares, append(problems, bad...)
case "users":
users, bad := ParseUsers(value)
c.Users, problems = users, append(problems, bad...)
case "passwords":
c.Passwords = strings.TrimSpace(value)
default:
problems = append(problems, fmt.Sprintf("%s names %q, which this module does not read", ConfigFile, key))
}
}
return c, problems
}
// ParseShares reads the `shares` setting: `name=path` or `name=path:ro`, separated by spaces.
func ParseShares(value string) ([]Share, []string) {
var shares []Share
var problems []string
seen := map[string]bool{}
for _, item := range strings.Fields(value) {
if item == "none" {
continue
}
name, p, ok := strings.Cut(item, "=")
if !ok || name == "" || p == "" {
problems = append(problems, fmt.Sprintf("the share %q is not name=path", item))
continue
}
if !validName(name) {
problems = append(problems, fmt.Sprintf("the share name %q is not lower-case letters, digits and dashes", name))
continue
}
if seen[name] {
problems = append(problems, fmt.Sprintf("the share %q is named twice", name))
continue
}
seen[name] = true
s := Share{Name: name, Path: p, Access: "rw"}
if strings.HasSuffix(p, ":ro") {
s.Path, s.Access = strings.TrimSuffix(p, ":ro"), "ro"
} else if strings.HasSuffix(p, ":rw") {
s.Path = strings.TrimSuffix(p, ":rw")
}
shares = append(shares, s)
}
sort.Slice(shares, func(i, j int) bool { return shares[i].Name < shares[j].Name })
return shares, problems
}
// ParseUsers reads the `users` setting: login names separated by spaces; `none` is none.
func ParseUsers(value string) ([]string, []string) {
var out, problems []string
seen := map[string]bool{}
for _, u := range strings.Fields(value) {
if u == "none" {
continue
}
if !validName(u) {
problems = append(problems, fmt.Sprintf("the login %q is not lower-case letters, digits and dashes", u))
continue
}
if !seen[u] {
seen[u] = true
out = append(out, u)
}
}
sort.Strings(out)
return out, problems
}
func validName(n string) bool {
if n == "" || n[0] == '-' {
return false
}
for _, r := range n {
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
return false
}
}
return true
}
// Section is one section of a Samba configuration: its name and its parameters, keys lower-cased with their
// spaces folded.
type Section struct {
Name string `json:"name"`
Params map[string]string `json:"params"`
}
// ParseSamba reads a Samba configuration's sections, in order. Comments (# and ;) and continuation lines are
// handled as Samba does.
func ParseSamba(text string) []Section {
var out []Section
var cur *Section
text = strings.ReplaceAll(text, "\\\n", " ")
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || line[0] == '#' || line[0] == ';' {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
out = append(out, Section{Name: strings.TrimSpace(line[1 : len(line)-1]), Params: map[string]string{}})
cur = &out[len(out)-1]
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok || cur == nil {
continue
}
cur.Params[foldKey(k)] = strings.TrimSpace(v)
}
return out
}
func foldKey(k string) string { return strings.Join(strings.Fields(strings.ToLower(k)), " ") }
// special are the sections that are not shares.
var special = map[string]bool{"global": true, "homes": true, "printers": true, "print$": true}
// HandShare is one share of the hand-written configuration, and the setting's share that takes its place.
type HandShare struct {
Name string `json:"name"`
Path string `json:"path"`
// ValidUsers is who may log in to it, as the hand-written file said.
ValidUsers string `json:"valid_users,omitempty"`
ReadOnly bool `json:"read_only"`
// Counterpart is the setting's share of the same name and folder; empty when the settings name none.
Counterpart string `json:"counterpart,omitempty"`
Why string `json:"why,omitempty"`
}
// HandShares are the hand-written configuration's shares, each matched to the setting's share of the same
// name serving the same folder (a trailing slash aside).
func HandShares(sections []Section, shares []Share) []HandShare {
byName := map[string]Share{}
for _, s := range shares {
byName[strings.ToLower(s.Name)] = s
}
var out []HandShare
for _, sec := range sections {
if special[strings.ToLower(sec.Name)] {
continue
}
h := HandShare{Name: sec.Name, Path: sec.Params["path"], ValidUsers: sec.Params["valid users"], ReadOnly: readOnly(sec.Params)}
s, ok := byName[strings.ToLower(sec.Name)]
switch {
case !ok:
h.Why = fmt.Sprintf("the setting shares names no share %s", sec.Name)
case cleanPath(s.Path) != cleanPath(h.Path):
h.Why = fmt.Sprintf("the setting's share %s serves %s, and the hand-written one serves %s", s.Name, s.Path, h.Path)
default:
h.Counterpart = s.Name
}
out = append(out, h)
}
return out
}
func cleanPath(p string) string {
if p == "" {
return ""
}
return path.Clean(p)
}
// readOnly is Samba's reading of a share's access: read only = yes unless writable/writeable/write ok says
// otherwise, as Samba defaults it.
func readOnly(p map[string]string) bool {
yes := func(v string) bool {
v = strings.ToLower(strings.TrimSpace(v))
return v == "yes" || v == "true" || v == "1"
}
if v, ok := p["read only"]; ok {
return yes(v)
}
for _, k := range []string{"writable", "writeable", "write ok"} {
if v, ok := p[k]; ok {
return !yes(v)
}
}
return true
}
// Served is a share as the module serves it.
type Served struct {
Name string `json:"name"`
Path string `json:"path"`
Access string `json:"access"`
UID int `json:"uid"`
GID int `json:"gid"`
User string `json:"force_user"`
Group string `json:"force_group"`
Users []string `json:"valid_users"`
// Live is whether Samba serves it now, as testparm reads the configuration in use.
Live bool `json:"live"`
}
// Refused is a share that is not served, and why.
type Refused struct {
Name string `json:"name"`
Path string `json:"path"`
Why string `json:"why"`
}
// Render is Samba's whole configuration from the shares the module serves.
func Render(shares []Served) string {
var b strings.Builder
b.WriteString(Marker + "\n")
b.WriteString("# Rendered by the module's own process from its assignment's settings shares and users; change the\n")
b.WriteString("# settings, never this file. The hand-written configuration it replaced is kept beside it, dated.\n")
b.WriteString("[global]\n")
for _, kv := range [][2]string{
{"server role", "standalone server"},
{"security", "user"},
{"passdb backend", "tdbsam"},
{"map to guest", "never"},
{"server min protocol", "SMB2_10"},
{"disable netbios", "yes"},
{"smb ports", "445"},
{"load printers", "no"},
{"printing", "bsd"},
{"printcap name", "/dev/null"},
{"disable spoolss", "yes"},
} {
fmt.Fprintf(&b, " %s = %s\n", kv[0], kv[1])
}
for _, s := range shares {
ro := "no"
if s.Access == "ro" {
ro = "yes"
}
fmt.Fprintf(&b, "\n[%s]\n path = %s\n read only = %s\n browseable = yes\n guest ok = no\n", s.Name, s.Path, ro)
fmt.Fprintf(&b, " valid users = %s\n force user = %s\n force group = %s\n", strings.Join(s.Users, " "), s.User, s.Group)
}
return b.String()
}
@@ -1,279 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
// The machine as the module sees it (novox/hq ADR 0341).
//
// **Two processes, two accounts.** The tools run as the operator account (ADR 0175 §4) and read; what only
// root may do (`smbcontrol`, `smbstatus`) goes through `sudo -n`. The module's long-running process runs as
// root: it judges the shares, and once a person adopted the configuration it writes Samba's configuration
// file whole, sets the logins' passwords from their secrets, and stops NetBIOS.
//
// **It writes its own files and nothing else.** Samba's configuration (only after adoption), its dated copy
// of the hand-written one, the status, and the record of which passwords it set. Nothing here can create,
// chown, chmod or remove anything under a shared path: the machine offers no such call, and Write refuses
// every path but the module's (novox/hq ADR 0051).
// The module's files on the machine.
const (
// ConfigFile is what the mesh renders from the assignment: the shares, the logins, where the secrets are.
ConfigFile = "/etc/smb-server/shares.conf"
// SambaConf is Samba's configuration: the hand-written one until adoption, the module's after.
SambaConf = "/etc/samba/smb.conf"
// Candidate is the module's rendering, checked with testparm before it takes SambaConf's place.
Candidate = "/etc/samba/smb.conf.mesh-new"
// HandCopyPrefix begins the dated copy of the hand-written configuration kept at adoption.
HandCopyPrefix = "/etc/samba/smb.conf.hand-written-"
// StatusFile is the process's last look, for the tools.
StatusFile = "/run/smb-server/status.json"
// AdoptDir is where the adopt verb, as the operator account, leaves a person's word for the process.
AdoptDir = "/var/lib/smb-server/adopt"
// AdoptFile is that word: when, and why.
AdoptFile = AdoptDir + "/adopt.json"
// SetFile is which password the process last set for each login, as a digest, readable by root only.
SetFile = "/var/lib/smb-server/passwords.json"
// Unit is Samba's service; NMBUnit its NetBIOS name service, stopped once the module takes over.
Unit = "smb.service"
NMBUnit = "nmb.service"
// Port is the one port SMB is served on.
Port = 445
)
// Marker is the first line of a configuration the module wrote: how it tells its own from a person's.
const Marker = "# Written by the mesh (module smb-server, novox/hq ADR 0341)."
// writable is every file the module may write.
func writable(p string) bool {
switch p {
case SambaConf, Candidate, StatusFile, AdoptFile, SetFile:
return true
}
return strings.HasPrefix(p, HandCopyPrefix) && !strings.Contains(strings.TrimPrefix(p, HandCopyPrefix), "/")
}
// CommandTimeout bounds every command.
const CommandTimeout = 20 * time.Second
// Ran is what one command did.
type Ran struct {
Out string
Err string
Code int
}
// Runner runs one command, with what it reads on stdin; injected, so everything is tested over recorded
// answers.
type Runner func(ctx context.Context, input, name string, args ...string) Ran
// ExecRunner runs a command on the machine, bounded by CommandTimeout.
func ExecRunner(ctx context.Context, input, name string, args ...string) Ran {
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
if input != "" {
cmd.Stdin = strings.NewReader(input)
}
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
r := Ran{Out: stdout.String(), Err: strings.TrimSpace(stderr.String())}
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
r.Code = exit.ExitCode()
} else {
r.Code = -1
if r.Err == "" {
r.Err = err.Error()
}
}
}
return r
}
// Owner is who owns a path, and whether it is a directory.
type Owner struct {
UID, GID int
Dir bool
}
// Machine is a filesystem root (the real one, or a test's tree), a runner, who this process is, and a clock.
type Machine struct {
Root string
Run Runner
UID int
Now func() time.Time
// ownerOf reads a file's owner from its inode; tests set it so their folders stand for an owner.
ownerOf func(info os.FileInfo) (uid, gid int)
}
// Here is this machine.
func Here() *Machine {
return &Machine{Root: "/", Run: ExecRunner, UID: os.Getuid(), Now: time.Now, ownerOf: inodeOwner}
}
func (m *Machine) at(p string) string { return filepath.Join(m.Root, p) }
// Read reads a file.
func (m *Machine) Read(p string) (string, error) {
b, err := os.ReadFile(m.at(p))
return string(b), err
}
// Exists says whether a path is there.
func (m *Machine) Exists(p string) bool {
_, err := os.Lstat(m.at(p))
return err == nil
}
// Stat is a path's owner and whether it is a folder.
func (m *Machine) Stat(p string) (Owner, error) {
info, err := os.Stat(m.at(p))
if err != nil {
return Owner{}, err
}
of := m.ownerOf
if of == nil {
of = inodeOwner
}
uid, gid := of(info)
return Owner{UID: uid, GID: gid, Dir: info.IsDir()}, nil
}
// Real is a path with every symbolic link followed, as the machine sees it.
func (m *Machine) Real(p string) (string, error) {
real, err := filepath.EvalSymlinks(m.at(p))
if err != nil {
return "", err
}
rel, err := filepath.Rel(m.Root, real)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("%s leads outside the machine", p)
}
return path.Join("/", filepath.ToSlash(rel)), nil
}
// Children are a folder's subfolders, by name; symbolic links are not followed.
func (m *Machine) Children(p string) []string {
entries, err := os.ReadDir(m.at(p))
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if e.Type()&fs.ModeSymlink == 0 && e.IsDir() {
out = append(out, e.Name())
}
}
return out
}
// Write writes one of the module's own files, and refuses every other path.
func (m *Machine) Write(p, content string, mode os.FileMode) error {
if !writable(p) {
return fmt.Errorf("%s is not one of the module's files; it writes nothing else", p)
}
if err := os.MkdirAll(filepath.Dir(m.at(p)), 0o755); err != nil {
return err
}
tmp := m.at(p) + ".tmp"
if err := os.WriteFile(tmp, []byte(content), mode); err != nil {
return err
}
return os.Rename(tmp, m.at(p))
}
// Place moves the checked candidate into Samba's configuration's place.
func (m *Machine) Place() error { return os.Rename(m.at(Candidate), m.at(SambaConf)) }
// NameOf is the account or group name for an id, read from the machine's passwd or group file.
func (m *Machine) NameOf(file string, id int) string {
text, _ := m.Read(file)
for _, line := range strings.Split(text, "\n") {
f := strings.Split(line, ":")
if len(f) >= 3 && f[2] == strconv.Itoa(id) {
return f[0]
}
}
return ""
}
// HasAccount says whether the machine has an account of that name.
func (m *Machine) HasAccount(name string) bool {
text, _ := m.Read("/etc/passwd")
for _, line := range strings.Split(text, "\n") {
if f := strings.SplitN(line, ":", 2); len(f) == 2 && f[0] == name {
return true
}
}
return false
}
// Escalated is a command as the operator account runs what only root may: through sudo without a prompt.
func (m *Machine) Escalated(name string, args ...string) (string, []string) {
if m.UID == 0 {
return name, args
}
return "sudo", append([]string{"-n", name}, args...)
}
// AsRoot runs what only root may do, and names a refusal by how it failed.
func (m *Machine) AsRoot(ctx context.Context, input, name string, args ...string) (Ran, error) {
program, argv := m.Escalated(name, args...)
r := m.Run(ctx, input, program, argv...)
if r.Code != 0 {
why := r.Err
if why == "" {
why = strings.TrimSpace(r.Out)
}
return r, fmt.Errorf("%s %s: exit %d: %s", name, strings.Join(args, " "), r.Code, why)
}
return r, nil
}
// UnitState is what the service manager says of a unit: active, inactive, failed, …
func (m *Machine) UnitState(unit string) string {
r := m.Run(context.Background(), "", "systemctl", "is-active", unit)
if s := strings.TrimSpace(r.Out); s != "" {
return s
}
return "unknown"
}
// UnitEnabled is what the service manager says of a unit's boot: enabled, disabled, …
func (m *Machine) UnitEnabled(unit string) string {
r := m.Run(context.Background(), "", "systemctl", "is-enabled", unit)
if s := strings.TrimSpace(r.Out); s != "" {
return s
}
return "unknown"
}
func inodeOwner(info os.FileInfo) (uid, gid int) {
if st, ok := info.Sys().(*syscall.Stat_t); ok {
return int(st.Uid), int(st.Gid)
}
return -1, -1
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
-38
View File
@@ -1,38 +0,0 @@
// The smb-server module's Go bundle (novox/hq ADR 0341): a node serves folders over SMB to the devices on its
// own network, one share per folder, every client as the folder's owner, SMB 2.1 and later on 445 alone.
//
// Started with no arguments it is served by the node's runtime as the operator account, over MCP on stdio
// (ADR 0188, ADR 0193): the node-smb-server seat's verbs and the module's health. Started as
// `smb-server config` it is the module's long-running process, as root (process.go). stdout is the MCP
// channel; everything else is said on stderr.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if len(os.Args) > 1 {
if os.Args[1] != "config" || len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: smb-server [config]")
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
if err := Here().RunConfig(ctx); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
if err := stdio.Serve("", Tools(Here())); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -1,397 +0,0 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"path"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
)
// The module's process, as root: every Every it judges the shares, and — only once a person adopted the
// configuration — writes Samba's configuration whole, checks it with testparm before Samba reads it, sets the
// logins' passwords from their secrets and stops NetBIOS (novox/hq ADR 0341 clauses 3 to 7). Before adoption
// it reads the hand-written configuration, reports it, and writes nothing but its status.
// Every is how often the process looks.
const Every = 30 * time.Second
// Adoption is a person's word to take over, left by the adopt verb.
type Adoption struct {
At string `json:"at"`
Why string `json:"why"`
// Copy is the dated copy of the hand-written configuration, once the process kept it.
Copy string `json:"copy,omitempty"`
}
// Login is one login of the `users` setting, as the process found it.
type Login struct {
Name string `json:"name"`
// Password is "set" (from its secret), "kept as found" (in Samba's database, no secret given),
// "waits" (no secret, not in Samba's database) or "refused" (why says).
Password string `json:"password"`
Why string `json:"why,omitempty"`
}
// Status is the process's last look, as the tools read it.
type Status struct {
At string `json:"at"`
// Mode is "found" (the hand-written configuration serves, kept as found) or "adopted" (the module's).
Mode string `json:"mode"`
Shares []Served `json:"shares"`
Refused []Refused `json:"refused,omitempty"`
Hand []HandShare `json:"hand_written,omitempty"`
Logins []Login `json:"logins,omitempty"`
Problems []string `json:"problems,omitempty"`
Waits []health.Wait `json:"waits,omitempty"`
Adopted *Adoption `json:"adopted,omitempty"`
// Written is when the process last wrote Samba's configuration, and Reloaded when Samba last read it.
Written string `json:"written,omitempty"`
Reloaded string `json:"reloaded,omitempty"`
NetBIOS string `json:"netbios,omitempty"`
}
// WalkDepth and WalkLimit bound the look below a share for a backup repository (ADR 0263 §3).
const (
WalkDepth = 4
WalkLimit = 200000
)
// isRepository says whether a folder is a restic repository: `config` beside `keys`, `snapshots`, `data`.
func (m *Machine) isRepository(dir string) bool {
if o, err := m.Stat(path.Join(dir, "config")); err != nil || o.Dir {
return false
}
for _, sub := range []string{"keys", "snapshots", "data"} {
if o, err := m.Stat(path.Join(dir, sub)); err != nil || !o.Dir {
return false
}
}
return true
}
// repositoryNear finds a backup repository at, above or within WalkDepth levels below a folder; complete is
// false when the look below stopped at its bound.
func (m *Machine) repositoryNear(real string) (found string, complete bool) {
for p := real; ; p = path.Dir(p) {
if m.isRepository(p) {
return p, true
}
if p == "/" {
break
}
}
seen, level := 0, []string{real}
for depth := 1; depth <= WalkDepth && len(level) > 0; depth++ {
var next []string
for _, d := range level {
for _, c := range m.Children(d) {
if seen++; seen > WalkLimit {
return "", false
}
p := path.Join(d, c)
if m.isRepository(p) {
return p, true
}
next = append(next, p)
}
}
level = next
}
return "", true
}
// Judge turns the asked-for shares into served shares, or refusals.
func (m *Machine) Judge(c Config) ([]Served, []Refused) {
var out []Served
var refused []Refused
for _, s := range c.Shares {
no := func(why string) { refused = append(refused, Refused{Name: s.Name, Path: s.Path, Why: why}) }
if !path.IsAbs(s.Path) || strings.ContainsAny(s.Path, "\n\r\"\\") {
no(fmt.Sprintf("The share %s names %q, which is not an absolute path a configuration can carry.", s.Name, s.Path))
continue
}
real, err := m.Real(path.Clean(s.Path))
if err != nil {
no(fmt.Sprintf("The share %s's folder %s is not there (%v); the module never creates it.", s.Name, s.Path, err))
continue
}
if real == "/" {
no(fmt.Sprintf("The share %s is the machine's root, which is never shared.", s.Name))
continue
}
o, err := m.Stat(real)
if err != nil || !o.Dir {
no(fmt.Sprintf("The share %s's path %s is not a folder.", s.Name, real))
continue
}
if o.UID == 0 || o.GID == 0 {
no(fmt.Sprintf("The share %s's folder %s is owned by root (%d:%d), and every client would act as root there: give it an owner of its own.", s.Name, real, o.UID, o.GID))
continue
}
if p, complete := m.repositoryNear(real); p != "" {
no(fmt.Sprintf("The share %s's folder %s is at, inside or above the backup repository %s, and backups are never shared.", s.Name, real, p))
continue
} else if !complete {
no(fmt.Sprintf("The share %s's folder %s holds more folders than the module looks through (%d levels, at most %d), so it could not make sure no backup repository is under it.", s.Name, real, WalkDepth, WalkLimit))
continue
}
user, group := m.NameOf("/etc/passwd", o.UID), m.NameOf("/etc/group", o.GID)
if user == "" || group == "" {
no(fmt.Sprintf("The share %s's folder %s is owned by %d:%d, which this machine names no account or group for, so no client can be made its owner.", s.Name, real, o.UID, o.GID))
continue
}
if len(c.Users) == 0 {
no(fmt.Sprintf("The share %s is not served: the setting users names no login.", s.Name))
continue
}
out = append(out, Served{Name: s.Name, Path: real, Access: s.Access, UID: o.UID, GID: o.GID, User: user, Group: group, Users: c.Users})
}
return out, refused
}
// PasswordSecret is the secret holding a login's password.
func PasswordSecret(user string) string { return "smb-password-" + user }
// readSecret reads a login's password from its secret: one line; absent is not given.
func (m *Machine) readSecret(dir, user string) (string, bool, error) {
if dir == "" || !path.IsAbs(dir) {
return "", false, fmt.Errorf("the mesh gave no folder for the logins' secrets")
}
raw, err := m.Read(path.Join(dir, PasswordSecret(user)+".secret"))
if err != nil {
if !m.Exists(path.Join(dir, PasswordSecret(user)+".secret")) {
return "", false, nil
}
return "", false, err
}
pw := strings.TrimRight(raw, "\r\n")
if pw == "" || strings.ContainsAny(pw, "\r\n") {
return "", false, fmt.Errorf("its secret is not one line")
}
return pw, true, nil
}
func digest(user, pw string) string {
h := sha256.Sum256([]byte(user + "\x00" + pw))
return hex.EncodeToString(h[:])
}
// Logins judges every login of the setting and, when adopted, sets a password whose secret is new or changed
// (clause 5). A login in Samba's database with no secret given keeps its password; the module never removes
// or resets an entry by itself.
func (m *Machine) Logins(ctx context.Context, c Config, adopted bool) ([]Login, []health.Wait, []string) {
var logins []Login
var waits []health.Wait
var problems []string
set := map[string]string{}
if raw, err := m.Read(SetFile); err == nil {
_ = json.Unmarshal([]byte(raw), &set)
}
changed := false
for _, u := range c.Users {
l := Login{Name: u}
inDB := m.Run(ctx, "", "pdbedit", "-L", "-u", u).Code == 0
pw, given, err := m.readSecret(c.Passwords, u)
switch {
case err != nil:
l.Password, l.Why = "refused", "its password "+PasswordSecret(u)+" could not be read: "+err.Error()
problems = append(problems, fmt.Sprintf("the login %s: %s", u, l.Why))
case !given && inDB:
l.Password, l.Why = "kept as found", "no secret "+PasswordSecret(u)+" is given, so the password in Samba's database stands"
case !given:
l.Password, l.Why = "waits", "no secret "+PasswordSecret(u)+" is given and Samba's database has no such login"
waits = append(waits, health.Wait{Part: "the login " + u, Secret: PasswordSecret(u), What: "the password of the login " + u})
case !m.HasAccount(u):
l.Password, l.Why = "refused", "this machine has no account "+u+"; the module creates no account"
problems = append(problems, fmt.Sprintf("the login %s: %s", u, l.Why))
case !adopted:
l.Password, l.Why = "kept as found", "its secret is given and is set once the configuration is adopted"
case set[u] == digest(u, pw) && inDB:
l.Password = "set"
default:
// smbpasswd -s reads the new password twice from stdin; -a adds the login when it is not there.
args := []string{"-s", "-a", u}
if r := m.Run(ctx, pw+"\n"+pw+"\n", "smbpasswd", args...); r.Code != 0 {
l.Password, l.Why = "refused", "smbpasswd refused it: "+firstLine(r.Err)
problems = append(problems, fmt.Sprintf("the login %s: %s", u, l.Why))
break
}
set[u], changed, l.Password = digest(u, pw), true, "set"
}
logins = append(logins, l)
}
if changed {
raw, _ := json.MarshalIndent(set, "", " ")
if err := m.Write(SetFile, string(raw)+"\n", 0o600); err != nil {
problems = append(problems, "which passwords were set could not be kept: "+err.Error())
}
}
return logins, waits, problems
}
// Pass is one look; what it did is in the status it answers, which it has written.
func (m *Machine) Pass(ctx context.Context) Status {
s := Status{At: m.Now().UTC().Format(time.RFC3339), Mode: "found", Shares: []Served{}}
text, err := m.Read(ConfigFile)
if err != nil {
s.Problems = append(s.Problems, fmt.Sprintf("the assignment's shares (%s) could not be read: %v", ConfigFile, err))
}
c, problems := ParseConfig(text)
s.Problems = append(s.Problems, problems...)
served, refused := m.Judge(c)
s.Refused = refused
conf, _ := m.Read(SambaConf)
ours := strings.HasPrefix(conf, Marker)
if !ours {
s.Hand = HandShares(ParseSamba(conf), c.Shares)
}
var adoption *Adoption
if raw, err := m.Read(AdoptFile); err == nil {
var a Adoption
if json.Unmarshal([]byte(raw), &a) == nil && a.At != "" {
adoption = &a
}
}
if ours && adoption == nil {
// Written by the module before, and the word is gone: what it wrote stands, and is the module's.
adoption = &Adoption{At: "before", Why: "the configuration is already the module's"}
}
s.Adopted = adoption
if adoption != nil {
if !ours {
if missing := withoutCounterpart(s.Hand); len(missing) > 0 {
s.Problems = append(s.Problems, "the configuration is not taken over: "+strings.Join(missing, "; "))
adoption = nil
} else if served == nil && len(c.Shares) > 0 {
s.Problems = append(s.Problems, "the configuration is not taken over: no share the settings name can be served")
adoption = nil
}
}
}
if adoption != nil {
s.Mode = "adopted"
m.takeOver(ctx, &s, conf, ours, served, adoption)
} else {
for i := range served {
served[i].Live = false
}
}
logins, waits, lp := m.Logins(ctx, c, s.Mode == "adopted")
s.Logins, s.Waits = logins, waits
s.Problems = append(s.Problems, lp...)
if s.Mode == "adopted" {
s.Shares = m.markLive(ctx, served)
} else {
s.Shares = served
}
raw, _ := json.MarshalIndent(s, "", " ")
if err := m.Write(StatusFile, string(raw)+"\n", 0o644); err != nil {
s.Problems = append(s.Problems, "the status file could not be written: "+err.Error())
}
return s
}
// withoutCounterpart is each hand-written share the settings do not take the place of, in words.
func withoutCounterpart(hand []HandShare) []string {
var out []string
for _, h := range hand {
if h.Counterpart == "" {
out = append(out, fmt.Sprintf("the hand-written share %s (%s) has no counterpart: %s", h.Name, h.Path, h.Why))
}
}
return out
}
// takeOver writes the module's configuration when it differs from what is there: the first time, a dated
// copy of the hand-written one is kept; every time, testparm checks the candidate before it takes the
// configuration's place and Samba reads it. Then NetBIOS is stopped.
func (m *Machine) takeOver(ctx context.Context, s *Status, conf string, ours bool, served []Served, a *Adoption) {
want := Render(served)
if conf != want {
if err := m.Write(Candidate, want, 0o644); err != nil {
s.Problems = append(s.Problems, "the module's configuration could not be written: "+err.Error())
return
}
if r := m.Run(ctx, "", "testparm", "-s", m.at(Candidate)); r.Code != 0 {
s.Problems = append(s.Problems, "the module's configuration fails Samba's own check and is kept out of use: "+firstLine(r.Err))
return
}
if !ours {
copyAt := HandCopyPrefix + m.Now().UTC().Format("20060102T150405Z")
if err := m.Write(copyAt, conf, 0o644); err != nil {
s.Problems = append(s.Problems, "the hand-written configuration could not be kept, so it is not replaced: "+err.Error())
return
}
a.Copy = copyAt
if a.At != "before" {
raw, _ := json.MarshalIndent(a, "", " ")
_ = m.Write(AdoptFile, string(raw)+"\n", 0o644)
}
}
if err := m.Place(); err != nil {
s.Problems = append(s.Problems, "the module's configuration could not take its place: "+err.Error())
return
}
s.Written = m.Now().UTC().Format(time.RFC3339)
if r := m.Run(ctx, "", "smbcontrol", "smbd", "reload-config"); r.Code != 0 {
s.Problems = append(s.Problems, "Samba did not read its configuration again: "+firstLine(r.Err))
} else {
s.Reloaded = s.Written
}
}
if m.UnitState(NMBUnit) == "active" || m.UnitEnabled(NMBUnit) == "enabled" {
if r := m.Run(ctx, "", "systemctl", "disable", "--now", NMBUnit); r.Code != 0 {
s.Problems = append(s.Problems, "NetBIOS ("+NMBUnit+") could not be stopped: "+firstLine(r.Err))
}
}
s.NetBIOS = m.UnitState(NMBUnit)
}
// markLive marks each share Samba serves now, as testparm reads the configuration in use.
func (m *Machine) markLive(ctx context.Context, served []Served) []Served {
r := m.Run(ctx, "", "testparm", "-s", m.at(SambaConf))
live := map[string]bool{}
if r.Code == 0 {
for _, sec := range ParseSamba(r.Out) {
live[strings.ToLower(sec.Name)] = true
}
}
for i := range served {
served[i].Live = live[strings.ToLower(served[i].Name)] && m.UnitState(Unit) == "active"
}
return served
}
// RunConfig is the process: a pass now and every Every until the context ends.
func (m *Machine) RunConfig(ctx context.Context) error {
m.Pass(ctx)
t := time.NewTicker(Every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-t.C:
m.Pass(ctx)
}
}
}
// LastStatus is the process's last look, or why there is none.
func (m *Machine) LastStatus() (*Status, error) {
text, err := m.Read(StatusFile)
if err != nil {
return nil, fmt.Errorf("the module's process has written no status (%s): %v", StatusFile, err)
}
var s Status
if err := json.Unmarshal([]byte(text), &s); err != nil {
return nil, fmt.Errorf("the module's status file is not one: %v", err)
}
return &s, nil
}
@@ -1,301 +0,0 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Defends novox/hq ADR 0341 clauses 2 to 7 and 11 to 13: an SMB share is served as its folder's owner, SMB
// 2.1 and later on 445 alone with NetBIOS off; before a person adopts it the hand-written configuration is
// kept as found and nothing is written; adoption keeps a dated copy, writes the module's configuration only
// once testparm passes it, and refuses while a hand-written share has no counterpart; a login without its
// secret keeps its password, and one with it is set once.
type fake struct {
t *testing.T
root string
answers map[string]Ran
ran []string
inputs map[string]string
now time.Time
}
func newFake(t *testing.T) *fake {
f := &fake{t: t, root: t.TempDir(), answers: map[string]Ran{}, inputs: map[string]string{}, now: time.Date(2026, 10, 13, 9, 30, 0, 0, time.UTC)}
f.answers["systemctl is-active smb.service"] = Ran{Out: "active\n"}
f.answers["systemctl is-active nmb.service"] = Ran{Out: "active\n"}
f.answers["systemctl is-enabled nmb.service"] = Ran{Out: "enabled\n"}
f.answers["pdbedit -L -u media"] = Ran{Out: "media:1001:\n"}
f.file("/etc/passwd", "root:x:0:0::/root:/bin/sh\nmedia:x:1001:2000::/home/media:/bin/sh\n")
f.file("/etc/group", "root:x:0:\nmedia:x:2000:\n")
return f
}
func (f *fake) machine() *Machine {
return &Machine{Root: f.root, UID: 0, Now: func() time.Time { return f.now },
ownerOf: func(info os.FileInfo) (int, int) {
if strings.HasPrefix(info.Name(), "rootowned") {
return 0, 0
}
return 1001, 2000
},
Run: func(_ context.Context, input, name string, args ...string) Ran {
line := strings.Join(append([]string{name}, args...), " ")
line = strings.ReplaceAll(line, f.root, "")
f.ran = append(f.ran, line)
if input != "" {
f.inputs[line] = input
}
if line == "systemctl disable --now nmb.service" {
f.answers["systemctl is-active nmb.service"] = Ran{Out: "inactive\n"}
f.answers["systemctl is-enabled nmb.service"] = Ran{Out: "disabled\n"}
}
if strings.HasPrefix(line, "testparm -s ") {
if r, ok := f.answers["testparm"]; ok {
return r
}
return Ran{Out: f.read(strings.TrimPrefix(line, "testparm -s "))}
}
return f.answers[line]
}}
}
func (f *fake) dir(p string) {
if err := os.MkdirAll(filepath.Join(f.root, p), 0o755); err != nil {
f.t.Fatal(err)
}
}
func (f *fake) file(p, content string) {
f.dir(filepath.Dir(p))
if err := os.WriteFile(filepath.Join(f.root, p), []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
func (f *fake) read(p string) string {
b, _ := os.ReadFile(filepath.Join(f.root, p))
return string(b)
}
func (f *fake) ran1(prefix string) []string {
var out []string
for _, r := range f.ran {
if strings.HasPrefix(r, prefix) {
out = append(out, r)
}
}
return out
}
// handWritten is the home server's configuration as issue 169's survey found it.
const handWritten = "[global]\n workgroup = WORKGROUP\n server string = home\n\n[media]\n path = /storage/media/\n valid users = media\n writable = yes\n"
func (f *fake) home(shares, users string) {
f.dir("/storage/media")
f.file(ConfigFile, "shares="+shares+"\nusers="+users+"\npasswords=/var/lib/mesh/smb-server/state\n")
f.file(SambaConf, handWritten)
}
func TestTheRenderingServesAsTheOwnerOnFourFortyFiveAloneWithoutNetBIOS(t *testing.T) {
text := Render([]Served{{Name: "media", Path: "/storage/media", Access: "rw", User: "media", Group: "media", Users: []string{"media"}}})
for _, want := range []string{Marker, "server min protocol = SMB2_10", "disable netbios = yes", "smb ports = 445", "map to guest = never",
"[media]", "path = /storage/media", "read only = no", "valid users = media", "force user = media", "force group = media"} {
if !strings.Contains(text, want) {
t.Errorf("the rendering lacks %q:\n%s", want, text)
}
}
if strings.Contains(text, "139") || strings.Contains(text, "1001") {
t.Errorf("the rendering names 139, or an owner as a number:\n%s", text)
}
}
func TestAShareIsRefusedAtRootRootOwnedMissingOrNearABackupAndNothingIsCreated(t *testing.T) {
f := newFake(t)
f.dir("/srv/rootowned")
f.dir("/srv/pool/library")
f.file("/srv/pool/library/deep/repo/config", "x")
for _, d := range []string{"keys", "snapshots", "data"} {
f.dir("/srv/pool/library/deep/repo/" + d)
}
f.dir("/srv/ok")
c, _ := ParseConfig("shares=root=/ owned=/srv/rootowned gone=/srv/gone lib=/srv/pool/library ok=/srv/ok:ro\nusers=media\n")
served, refused := f.machine().Judge(c)
why := map[string]string{}
for _, r := range refused {
why[r.Name] = r.Why
}
for name, says := range map[string]string{"root": "machine's root", "owned": "owned by root", "gone": "not there", "lib": "backup repository"} {
if !strings.Contains(why[name], says) {
t.Errorf("%s is not refused as %q: %q", name, says, why[name])
}
}
if len(served) != 1 || served[0].Name != "ok" || served[0].Access != "ro" || served[0].User != "media" {
t.Errorf("served: %+v", served)
}
if f.machine().Exists("/srv/gone") {
t.Error("the module created a missing folder")
}
}
func TestBeforeAdoptionTheHandWrittenConfigurationIsKeptAndNothingIsWritten(t *testing.T) {
f := newFake(t)
f.home("media=/storage/media", "media")
s := f.machine().Pass(context.Background())
if s.Mode != "found" || f.read(SambaConf) != handWritten {
t.Fatalf("mode %s; the hand-written configuration changed:\n%s", s.Mode, f.read(SambaConf))
}
if len(s.Hand) != 1 || s.Hand[0].Counterpart != "media" || s.Hand[0].ReadOnly {
t.Errorf("the hand-written share is not matched: %+v", s.Hand)
}
for _, r := range f.ran {
if strings.HasPrefix(r, "smbcontrol") || strings.HasPrefix(r, "systemctl disable") || strings.HasPrefix(r, "smbpasswd") {
t.Errorf("before adoption the process ran %q", r)
}
}
if s.Logins[0].Password != "kept as found" {
t.Errorf("the login: %+v", s.Logins)
}
if h := f.machine().Health(); !h.Healthy || !strings.Contains(h.Why, "adopt") {
t.Errorf("health before adoption: %+v", h)
}
}
func TestAdoptRefusesWhileAHandWrittenShareHasNoCounterpart(t *testing.T) {
f := newFake(t)
f.home("library=/storage/media", "media")
f.machine().Pass(context.Background())
if _, err := f.machine().Adopt(true, "take it over"); err == nil || !strings.Contains(err.Error(), "no share disappears") {
t.Fatalf("adopt with a hand-written share the settings do not name: %v", err)
}
if f.machine().Exists(AdoptFile) {
t.Error("a refused adopt left the word for the process")
}
f.file(ConfigFile, "shares=media=/storage/media\nusers=media\npasswords=/x\n")
f.machine().Pass(context.Background())
dry, err := f.machine().Adopt(false, "")
if err != nil || !dry.DryRun || len(dry.Beside) != 1 || !strings.Contains(dry.Beside[0], "every client as media:media") {
t.Fatalf("dry run: %+v %v", dry, err)
}
if f.machine().Exists(AdoptFile) {
t.Error("a dry run left the word for the process")
}
if _, err := f.machine().Adopt(true, ""); err == nil {
t.Error("adopt with confirm and no why was done")
}
}
func TestAdoptionKeepsADatedCopyChecksAndThenStopsNetBIOS(t *testing.T) {
f := newFake(t)
f.home("media=/storage/media", "media")
f.machine().Pass(context.Background())
f.file(AdoptFile, `{"at":"2026-10-13T09:31:00Z","why":"the operator keeps the share"}`)
f.ran = nil
s := f.machine().Pass(context.Background())
if s.Mode != "adopted" || len(s.Problems) != 0 {
t.Fatalf("status: %+v", s)
}
copyAt := HandCopyPrefix + "20261013T093000Z"
if f.read(copyAt) != handWritten {
t.Errorf("the dated copy is not the hand-written configuration: %q", f.read(copyAt))
}
if !strings.HasPrefix(f.read(SambaConf), Marker) || !strings.Contains(f.read(SambaConf), "force user = media") {
t.Errorf("the module's configuration is not in place:\n%s", f.read(SambaConf))
}
want := []string{"testparm -s " + Candidate, "smbcontrol smbd reload-config", "systemctl disable --now nmb.service"}
for _, w := range want {
if len(f.ran1(w)) != 1 {
t.Errorf("%q ran %d times: %v", w, len(f.ran1(w)), f.ran)
}
}
if !s.Shares[0].Live || s.NetBIOS != "inactive" {
t.Errorf("served: %+v netbios %s", s.Shares, s.NetBIOS)
}
// The next pass finds its own configuration and writes nothing again.
f.ran = nil
f.machine().Pass(context.Background())
if len(f.ran1("smbcontrol")) != 0 || len(f.ran1("systemctl disable")) != 0 {
t.Errorf("an unchanged configuration was written or reloaded again: %v", f.ran)
}
}
func TestAConfigurationFailingTestparmIsKeptOutOfUse(t *testing.T) {
f := newFake(t)
f.home("media=/storage/media", "media")
f.file(AdoptFile, `{"at":"2026-10-13T09:31:00Z","why":"take over"}`)
f.answers["testparm"] = Ran{Code: 1, Err: "Unknown parameter encountered"}
s := f.machine().Pass(context.Background())
if f.read(SambaConf) != handWritten {
t.Errorf("a configuration testparm refused took the hand-written one's place")
}
if len(f.ran1("smbcontrol")) != 0 || !strings.Contains(strings.Join(s.Problems, " "), "fails Samba's own check") {
t.Errorf("not said, or reloaded: %v %v", s.Problems, f.ran)
}
if h := f.machine().Health(); h.Healthy {
t.Errorf("healthy with a refused configuration: %+v", h)
}
}
func TestALoginKeepsItsPasswordWithoutASecretWaitsWhenNewAndIsSetOnce(t *testing.T) {
f := newFake(t)
f.home("media=/storage/media", "media guest2")
f.answers["pdbedit -L -u guest2"] = Ran{Code: 255}
f.file(AdoptFile, `{"at":"2026-10-13T09:31:00Z","why":"take over"}`)
s := f.machine().Pass(context.Background())
got := map[string]string{}
for _, l := range s.Logins {
got[l.Name] = l.Password
}
if got["media"] != "kept as found" || got["guest2"] != "waits" || len(f.ran1("smbpasswd")) != 0 {
t.Fatalf("logins %v; smbpasswd ran %v", got, f.ran1("smbpasswd"))
}
h := f.machine().Health()
if h.State != "waits" || len(h.Waits) != 1 || h.Waits[0].Secret != "smb-password-guest2" {
t.Errorf("health: %+v", h)
}
// The secret given: set once from stdin, never on the command line, and not again while unchanged.
f.file("/etc/passwd", f.read("/etc/passwd")+"guest2:x:1002:2000::/:/bin/sh\n")
f.file("/var/lib/mesh/smb-server/state/smb-password-guest2.secret", "s3cret\n")
f.machine().Pass(context.Background())
runs := f.ran1("smbpasswd")
if len(runs) != 1 || runs[0] != "smbpasswd -s -a guest2" || f.inputs[runs[0]] != "s3cret\ns3cret\n" {
t.Fatalf("smbpasswd: %v %q", runs, f.inputs)
}
f.answers["pdbedit -L -u guest2"] = Ran{Out: "guest2:1002:\n"}
f.machine().Pass(context.Background())
if len(f.ran1("smbpasswd")) != 1 {
t.Errorf("an unchanged password was set again: %v", f.ran1("smbpasswd"))
}
var set map[string]string
_ = json.Unmarshal([]byte(f.read(SetFile)), &set)
if strings.Contains(f.read(SetFile), "s3cret") || set["guest2"] == "" {
t.Errorf("the record of set passwords holds the password, or nothing: %s", f.read(SetFile))
}
if info, err := os.Stat(filepath.Join(f.root, SetFile)); err != nil || info.Mode().Perm() != 0o600 {
t.Errorf("the record of set passwords is not root's alone: %v", info.Mode())
}
}
func TestTheModuleWritesOnlyItsOwnFiles(t *testing.T) {
m := newFake(t).machine()
for _, p := range []string{"/etc/passwd", "/storage/media/x", "/etc/samba/other.conf", HandCopyPrefix + "x/../../evil"} {
if err := m.Write(p, "x", 0o644); err == nil {
t.Errorf("wrote %s", p)
}
}
}
func TestParseSambaReadsSectionsAndAccess(t *testing.T) {
secs := ParseSamba("; c\n[global]\nworkgroup = W\n[a]\n path = /x\n read only = no\n[b]\n path = /y\n[c]\n path = /z\n writeable = yes\n")
if len(secs) != 4 || secs[1].Params["path"] != "/x" {
t.Fatalf("%+v", secs)
}
if readOnly(secs[1].Params) || !readOnly(secs[2].Params) || readOnly(secs[3].Params) {
t.Error("read only is not read as Samba reads it")
}
}
-366
View File
@@ -1,366 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/health"
)
// Seat is the role this module holds on its machine (novox/hq ADR 0341).
const Seat = "node-smb-server"
// Stale is how old the process's last look may be before the tools say it stopped looking.
const Stale = 5 * time.Minute
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func arg(a map[string]any, k string) string {
v, _ := a[k].(string)
return strings.TrimSpace(v)
}
// confirmed says whether an act was confirmed: the boolean true, or the string "true" the seat's schema passes.
func confirmed(a map[string]any) bool {
switch v := a["confirm"].(type) {
case bool:
return v
case string:
return strings.EqualFold(strings.TrimSpace(v), "true")
}
return false
}
// Tools are the seat's verbs and the module's health.
func Tools(m *Machine) []stdio.Tool {
return []stdio.Tool{
verb("shares", "Every SMB share this machine serves: its name, folder, rw or ro, the owner every client acts as "+
"(uid, gid, names), the logins allowed and whether each login's password was set from its secret, is kept "+
"as found or waits for it, and whether Samba serves it now; whether the configuration is the mesh's or still "+
"the hand-written one, and each hand-written share with whether the settings take its place; the shares "+
"refused, and why. A password is never shown. Replaces testparm -s, cat /etc/samba/smb.conf and pdbedit -L. (r)",
map[string]any{}, func(map[string]any) (any, error) { return m.Shares(), nil }),
verb("sessions", "Which devices are connected to which share now, with the login and protocol each uses, as "+
"Samba knows them (smbstatus, as root). Replaces smbstatus. (r)",
map[string]any{}, func(map[string]any) (any, error) { return m.Sessions(context.Background()) }),
verb("test", "Whether this machine serves one share now: asked for and not refused, served by Samba, Samba "+
"active, and 445 listening. Replaces smbclient -L. (r)",
map[string]any{"share": str("the share's name")},
func(a map[string]any) (any, error) { return m.Test(context.Background(), arg(a, "share")) }),
verb("reload", "Have Samba read its configuration again now (smbcontrol smbd reload-config, as root). The "+
"module's process does this itself whenever it writes the configuration; this is for after a change made "+
"outside it. Changes no shared folder. Replaces smbcontrol smbd reload-config. (a)",
map[string]any{}, func(map[string]any) (any, error) { return m.Reload(context.Background()) }),
verb("adopt", "Take over Samba's hand-written configuration: the module's process keeps a dated copy of it "+
"beside it and writes the mesh's own in its place, checked with testparm, then stops NetBIOS. Only when every "+
"hand-written share has its counterpart in the setting shares (same name, same folder), so no share "+
"disappears. Without confirm, a dry run: each hand-written share beside the module's rendering of it. A "+
"person's act, with why. Replaces edit /etc/samba/smb.conf. (a)",
map[string]any{
"confirm": str("\"true\" to do it; anything else, a dry run"),
"why": str("why, in a sentence (required with confirm)"),
},
func(a map[string]any) (any, error) { return m.Adopt(confirmed(a), arg(a, "why")) }),
{
Name: "smb_health",
Description: "Whether this machine's SMB shares are served, as the module's health check (novox/hq ADR 0240, " +
"ADR 0341): unhealthy when Samba is not active, the module's process has not looked for five minutes, a " +
"share is refused or not served, or the configuration fails Samba's check; waits when a login's password " +
"is its secret not yet given; with why. (r)",
Run: func(map[string]any) (any, error) { return m.Health(), nil },
},
}
}
// SharesAnswer is what `shares` answers.
type SharesAnswer struct {
Mode string `json:"configuration"`
Shares []Served `json:"shares"`
Refused []Refused `json:"refused,omitempty"`
Hand []HandShare `json:"hand_written,omitempty"`
Logins []Login `json:"logins,omitempty"`
Service string `json:"service"`
Looked string `json:"looked,omitempty"`
Said []string `json:"said"`
Notes []string `json:"notes,omitempty"`
}
// Shares is the machine's SMB shares now.
func (m *Machine) Shares() SharesAnswer {
a := SharesAnswer{Shares: []Served{}, Service: m.UnitState(Unit)}
st, err := m.LastStatus()
if err != nil {
a.Notes = append(a.Notes, err.Error())
a.Said = append(a.Said, "the module's process has not looked yet")
return a
}
a.Mode, a.Shares, a.Refused, a.Hand, a.Logins, a.Looked = st.Mode, st.Shares, st.Refused, st.Hand, st.Logins, st.At
a.Notes = st.Problems
if st.Mode == "found" {
a.Said = append(a.Said, "the configuration is the hand-written one, kept as found; the module takes it over by adopt, a person's act")
} else {
a.Said = append(a.Said, "the configuration is the module's")
}
for _, s := range st.Shares {
state := "served"
if !s.Live {
state = "NOT served"
}
a.Said = append(a.Said, fmt.Sprintf("%s: %s, %s, every client as %s:%s, for %s — %s", s.Name, s.Path, s.Access, s.User, s.Group, strings.Join(s.Users, " "), state))
}
for _, h := range st.Hand {
if h.Counterpart != "" {
a.Said = append(a.Said, fmt.Sprintf("hand-written %s (%s): the setting's share %s takes its place", h.Name, h.Path, h.Counterpart))
} else {
a.Said = append(a.Said, fmt.Sprintf("hand-written %s (%s): %s", h.Name, h.Path, h.Why))
}
}
for _, l := range st.Logins {
a.Said = append(a.Said, fmt.Sprintf("login %s: password %s", l.Name, l.Password))
}
for _, r := range st.Refused {
a.Said = append(a.Said, "refused: "+r.Why)
}
a.Said = append(a.Said, "Samba is "+a.Service)
return a
}
// Sessions is what smbstatus says of the connected devices.
func (m *Machine) Sessions(ctx context.Context) (any, error) {
r, err := m.AsRoot(ctx, "", "smbstatus", "--json")
if err != nil {
return nil, err
}
var v struct {
Sessions map[string]struct {
Machine string `json:"remote_machine"`
Username string `json:"username"`
Dialect string `json:"session_dialect"`
} `json:"sessions"`
Tcons map[string]struct {
Service string `json:"service"`
Machine string `json:"machine"`
} `json:"tcons"`
}
if err := json.Unmarshal([]byte(r.Out), &v); err != nil {
return nil, fmt.Errorf("smbstatus answered what this cannot read: %v", err)
}
type session struct {
Device string `json:"device"`
Login string `json:"login"`
Protocol string `json:"protocol"`
Shares []string `json:"shares"`
}
var out []session
for _, s := range v.Sessions {
se := session{Device: s.Machine, Login: s.Username, Protocol: s.Dialect}
for _, t := range v.Tcons {
if t.Machine == s.Machine {
se.Shares = append(se.Shares, t.Service)
}
}
out = append(out, se)
}
return map[string]any{"sessions": out}, nil
}
// TestAnswer is what `test` answers.
type TestAnswer struct {
Share string `json:"share"`
Served bool `json:"served"`
Service string `json:"service"`
Listening bool `json:"listening_445"`
Said string `json:"said"`
}
// Test says whether one share is served now.
func (m *Machine) Test(ctx context.Context, share string) (TestAnswer, error) {
if share == "" {
return TestAnswer{}, fmt.Errorf("name the share")
}
a := TestAnswer{Share: share, Service: m.UnitState(Unit)}
r := m.Run(ctx, "", "ss", "-ltnH", "sport", "=", ":445")
a.Listening = strings.TrimSpace(r.Out) != ""
st, err := m.LastStatus()
if err != nil {
return a, err
}
for _, s := range st.Shares {
if s.Name == share {
a.Served = s.Live && a.Service == "active" && a.Listening
}
}
for _, rf := range st.Refused {
if rf.Name == share {
a.Said = rf.Why
return a, nil
}
}
switch {
case a.Served:
a.Said = fmt.Sprintf("%s is served on 445", share)
case st.Mode == "found":
a.Said = fmt.Sprintf("%s is not served by the module: the configuration is still the hand-written one", share)
default:
a.Said = fmt.Sprintf("%s is not served now: Samba is %s, 445 listening: %v", share, a.Service, a.Listening)
}
return a, nil
}
// Reload has Samba read its configuration again.
func (m *Machine) Reload(ctx context.Context) (any, error) {
if _, err := m.AsRoot(ctx, "", "smbcontrol", "smbd", "reload-config"); err != nil {
return nil, err
}
return m.Shares(), nil
}
// AdoptAnswer is what `adopt` answers.
type AdoptAnswer struct {
DryRun bool `json:"dry_run"`
Done bool `json:"done"`
Copy string `json:"copy,omitempty"`
Beside []string `json:"beside"`
Said string `json:"said"`
}
// AdoptWait is how long a confirmed adopt waits for the process to take over.
var AdoptWait = 45 * time.Second
// Adopt leaves a person's word for the process to take over, after a dry run that shows each hand-written share
// beside its rendering. It refuses while a hand-written share has no counterpart.
func (m *Machine) Adopt(confirm bool, why string) (AdoptAnswer, error) {
a := AdoptAnswer{DryRun: !confirm}
st, err := m.LastStatus()
if err != nil {
return a, err
}
if st.Mode == "adopted" {
return a, fmt.Errorf("the configuration is already the module's; nothing to take over")
}
served := map[string]Served{}
for _, s := range st.Shares {
served[s.Name] = s
}
var missing []string
for _, h := range st.Hand {
if h.Counterpart == "" {
missing = append(missing, fmt.Sprintf("%s (%s): %s", h.Name, h.Path, h.Why))
a.Beside = append(a.Beside, fmt.Sprintf("hand-written %s, %s, valid users %s → no counterpart", h.Name, h.Path, h.ValidUsers))
continue
}
s, ok := served[h.Counterpart]
if !ok {
missing = append(missing, fmt.Sprintf("%s: the setting's share %s is refused", h.Name, h.Counterpart))
continue
}
a.Beside = append(a.Beside, fmt.Sprintf("hand-written %s, %s, valid users %s, read only %v → the mesh's %s, %s, %s, valid users %s, every client as %s:%s",
h.Name, h.Path, h.ValidUsers, h.ReadOnly, s.Name, s.Path, s.Access, strings.Join(s.Users, " "), s.User, s.Group))
}
if len(missing) > 0 {
return a, fmt.Errorf("not taken over, so no share disappears: %s", strings.Join(missing, "; "))
}
plan := "keep a dated copy of the hand-written configuration beside it, write the mesh's own in its place once testparm passes it, have Samba read it, and stop NetBIOS"
if !confirm {
a.Said = "Dry run: would " + plan + "."
return a, nil
}
if why == "" {
return a, fmt.Errorf("say why: adopt is a person's act, and the why is kept with it")
}
raw, _ := json.MarshalIndent(Adoption{At: m.Now().UTC().Format(time.RFC3339), Why: why}, "", " ")
if err := m.Write(AdoptFile, string(raw)+"\n", 0o644); err != nil {
return a, err
}
deadline := m.Now().Add(AdoptWait)
for {
if st, err := m.LastStatus(); err == nil && st.Mode == "adopted" && st.Written != "" {
a.Done = true
if st.Adopted != nil {
a.Copy = st.Adopted.Copy
}
a.Said = fmt.Sprintf("Did: %s. Why: %s", plan, why)
return a, nil
}
if !m.Now().Before(deadline) {
a.Said = "The word is left for the module's process, which has not taken over yet; read shares for why."
return a, nil
}
time.Sleep(2 * time.Second)
}
}
// HealthAnswer is the module's health: mesh-sdk's answer, with what was found.
type HealthAnswer struct {
health.Answer
Found []string `json:"found,omitempty"`
}
// Health judges the shares.
func (m *Machine) Health() HealthAnswer {
var found []string
if s := m.UnitState(Unit); s != "active" {
found = append(found, fmt.Sprintf("Samba (%s) is %s.", Unit, s))
}
st, err := m.LastStatus()
if err != nil {
found = append(found, err.Error()+".")
return HealthAnswer{Answer: health.Unhealthy(strings.Join(found, " ")), Found: found}
}
if at, err := time.Parse(time.RFC3339, st.At); err != nil || m.Now().Sub(at) > Stale {
found = append(found, "The module's process has not looked for five minutes.")
}
for _, p := range st.Problems {
found = append(found, sentence(p))
}
for _, r := range st.Refused {
found = append(found, r.Why)
}
if st.Mode == "adopted" {
for _, s := range st.Shares {
if !s.Live {
found = append(found, fmt.Sprintf("The share %s (%s) is not served by Samba.", s.Name, s.Path))
}
}
}
if len(found) > 0 {
return HealthAnswer{Answer: health.Unhealthy(strings.Join(found, " "), st.Waits...), Found: found}
}
if len(st.Waits) > 0 {
var why []string
for _, w := range st.Waits {
why = append(why, fmt.Sprintf("%s waits for %s.", sentence(w.Part), w.What))
}
return HealthAnswer{Answer: health.Waiting(strings.Join(why, " "), st.Waits...)}
}
if st.Mode == "found" {
return HealthAnswer{Answer: health.Healthy("Samba serves the hand-written configuration, kept as found; the module takes it over by node-smb-server.adopt, a person's act.")}
}
var parts []string
for _, s := range st.Shares {
parts = append(parts, s.Name)
}
return HealthAnswer{Answer: health.Healthy(fmt.Sprintf("Samba is active and serves %s on 445.", strings.Join(parts, ", ")))}
}
func sentence(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
s = strings.ToUpper(s[:1]) + s[1:]
if !strings.HasSuffix(s, ".") {
s += "."
}
return s
}
-5
View File
@@ -1,5 +0,0 @@
module smbserver
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c
-8
View File
@@ -1,8 +0,0 @@
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=
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=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d h1:BR8uwv4BqyLaBwITuLylrd9DRUnu0D5DjJw0RuQSXbk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
-144
View File
@@ -1,144 +0,0 @@
{
"module": "smb-server",
"version": "1",
"upgrade": {
"policy": "record",
"why": "the folders the devices on the home network open: a build that breaks Samba's configuration leaves every device's share refused, and the gate on this one machine does not see the devices (hq ADR 0236, ADR 0341)"
},
"capabilities": [
"package-manager",
"service-manager"
],
"claims": [
{
"name": "node-smb-server",
"scope": "node",
"serves": [
"shares",
"sessions",
"test",
"reload",
"adopt"
]
}
],
"tools": [
"smb_health"
],
"settings": {
"shares": {
"kind": "preference",
"default": "none",
"why": "a machine serves no SMB share until its assignment names one: space-separated name=folder, each optionally :ro, one share per folder, every client acting as the folder's owner (hq ADR 0341)"
},
"users": {
"kind": "preference",
"default": "none",
"why": "the logins allowed on every share, space-separated; each login's password is the secret smb-password-<login>, and a login already in Samba's database whose secret is not given keeps its password (hq ADR 0341)"
}
},
"own-secrets": {
"smb-password-*": {
"path": "${dir:state}/smb-password-*.secret",
"issued-by": "outside"
}
},
"listens": [
{
"name": "smb",
"port": 445,
"protocol": "tcp",
"from": "mesh",
"fixed": true,
"why": "SMB 2.1 and later, on 445 alone: NetBIOS is off, so 137, 138 and 139 are never opened. The home server's assignment binds it with the reach local-network (hq ADR 0340, ADR 0341), so the devices on its own network reach it and nothing beyond"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "samba"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config-dir",
"type": "directory",
"path": "/etc/smb-server",
"mode": "0755"
},
{
"id": "run-dir",
"type": "directory",
"path": "/run/smb-server",
"mode": "0755"
},
{
"id": "lib-dir",
"type": "directory",
"path": "/var/lib/smb-server",
"mode": "0755"
},
{
"id": "adopt-dir",
"type": "directory",
"path": "/var/lib/smb-server/adopt",
"mode": "0755",
"owner": "${machine:account}"
},
{
"id": "config",
"type": "file",
"path": "/etc/smb-server/shares.conf",
"mode": "0644",
"content": "# Written by the mesh (module smb-server, novox/hq ADR 0341) from this machine's assignment.\n# Replaced on every push; change the settings, never this file.\n#\n# The shares: name=folder, or name=folder:ro, one share per folder. The users: the logins allowed. The\n# module's process serves each share with every client as the folder's owner, and writes Samba's\n# configuration only once a person adopted it (node-smb-server.adopt).\nshares=${setting:shares}\nusers=${setting:users}\npasswords=${dir:state}\n"
},
{
"id": "server",
"type": "service",
"unit": "smb.service",
"state": "running",
"boot": "enabled",
"health": {
"kind": "unit"
}
},
{
"id": "config-process",
"type": "process",
"name": "smb-server-config",
"artifact": "tools",
"run": [
"./smb-server",
"config"
],
"health": {
"kind": "tool",
"tool": "smb_health",
"interval": "60s",
"timeout": "10s",
"looks": 2,
"grace": "90s"
}
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/smb-server",
"binary": "smb-server",
"loads": [
"smb-server"
]
}
]
}
}