Compare commits

..
Author SHA1 Message Date
jochen b1f2776693 WIP: smb-server module and nfs-server's local-network grant (hq issue 613, ADR 0341)
Paused: the module's manifest test, README and a full merge-check run are
still to do; tests pass locally for both modules.
2026-10-13 11:24:03 +02:00
44 changed files with 2635 additions and 994 deletions
@@ -285,25 +285,6 @@ 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":`+
@@ -495,22 +476,3 @@ func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
}
}
// The tips preference reaches the managed settings as spinnerTipsEnabled, either way, and is left out when the
// mesh filled none: set in one call through the controller, never at the terminal.
func TestTheTipsPreferenceIsRenderedAsSpinnerTipsEnabled(t *testing.T) {
off, on := false, true
for _, c := range []struct {
tips *bool
want any
}{{&off, false}, {&on, true}, {nil, nil}} {
out := Render(Facts{Console: "x"}, Settings{Preferences: Preferences{Tips: c.tips}}, nil, "/h", nil, Config{})
var managed map[string]any
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &managed); err != nil {
t.Fatal(err)
}
if got, has := managed["spinnerTipsEnabled"]; got != c.want || has != (c.want != nil) {
t.Errorf("tips %v rendered spinnerTipsEnabled %v (present %v)", c.tips, got, has)
}
}
}
@@ -124,7 +124,6 @@ func HomeScopeRefused(p Paths, target string) string {
func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") }
func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") }
func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") }
func (p Paths) preferences() string { return filepath.Join(p.State, "preferences.json") }
func (p Paths) apiKey() string { return filepath.Join(p.keyDir(), "api-key") }
func (p Paths) helper() string { return filepath.Join(p.keyDir(), "api-key-helper") }
@@ -219,7 +218,6 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
}
var settings Settings
readJSON(p.Settings, &settings)
readJSON(p.preferences(), &settings.Preferences)
var binding *Binding
var b Binding
if readJSON(p.binding(), &b) {
@@ -300,9 +298,6 @@ 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.
@@ -438,15 +433,6 @@ 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
}
@@ -42,15 +42,6 @@ type Settings struct {
MCPServers map[string]map[string]any `json:"mcp_servers"`
// ManagedSettings are keys of the agent's managed settings the operator sets, for the mesh or a node.
ManagedSettings map[string]any `json:"managed_settings"`
// Preferences are this module's own preferences, from its untrusted preferences file: set in one call
// with the controller's `settings` verb, for the mesh or a node, like any module's preference (ADR 0262).
Preferences Preferences `json:"-"`
}
// Preferences are the module's preferences as the mesh filled them; nil is a value the file did not carry.
type Preferences struct {
// Tips is whether Claude Code shows a tip while it works (`spinnerTipsEnabled`).
Tips *bool `json:"tips"`
}
// Binding is the licence this node holds, as it was last applied.
@@ -130,11 +121,6 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
// The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered
// through this module's tools is laid over it (novox/hq issue 340).
managed := mergeSettings(map[string]any{}, settings.ManagedSettings)
// The module's preferences: keys of how a session looks, never of what it may do, so they are set in one
// call through the controller rather than at the terminal, and laid over the operator's keys of the same name.
if settings.Preferences.Tips != nil {
managed["spinnerTipsEnabled"] = *settings.Preferences.Tips
}
// The mesh's own keys are laid last: a setting never replaces them.
managed["attribution"] = map[string]any{"commit": "", "pr": ""}
managed["allowAllClaudeAiMcps"] = true
-16
View File
@@ -1,13 +1,6 @@
{
"module": "claude-code",
"version": "1",
"settings": {
"tips": {
"kind": "preference",
"default": true,
"why": "Claude Code shows a tip while it works; false turns tips off in every session on the machine, written as spinnerTipsEnabled in its managed settings"
}
},
"slug": "agent",
"capabilities": [
"package-manager"
@@ -153,15 +146,6 @@
"owner": "${machine:account}",
"merge": "json",
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n"
},
{
"id": "preferences",
"trusted": false,
"type": "file",
"path": "${dir:state}/preferences.json",
"mode": "0600",
"owner": "${machine:account}",
"content": "{\n \"tips\": ${setting:tips}\n}\n"
}
],
"shell": [
@@ -253,7 +253,6 @@ 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) {
@@ -261,8 +260,7 @@ func verb(name, description string, input map[string]any, run func(ctx context.C
if err != nil {
return nil, err
}
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)})
return run(ctx, m, a)
return run(context.Background(), m, a)
}}
}
@@ -290,7 +288,7 @@ func tools() []stdio.Tool {
if err != nil {
return nil, err
}
return m.Bind(ctx, c, l)
return m.Bind(ctx, c, l, "bind")
}),
verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it. 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")},
@@ -299,7 +297,7 @@ func tools() []stdio.Tool {
if err != nil {
return nil, err
}
return m.Bind(ctx, c, l)
return m.Bind(ctx, c, l, "switch")
}),
verb("release", "Unbind a consumer. Its node keeps its last token, which expires within hours.",
map[string]any{"consumer": consumer},
@@ -308,7 +306,7 @@ func tools() []stdio.Tool {
if err != nil {
return nil, err
}
return m.Release(ctx, c)
return m.Release(ctx, c, "release")
}),
verb("refresh", "Refresh a licence now, or every due licence when none is named; under each licence's lease, so it never races the daemon. Answers the outcome, never a token.",
map[string]any{"licence": str("a licence; absent for every due one")},
@@ -59,54 +59,6 @@ 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).
@@ -315,12 +267,11 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
if err != nil {
return err
}
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 {
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its account's report"})
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation}); err != nil {
return err
}
m.Log("bound %s to %s, the licence its account already has", rep.Node, l.Name)
}
return nil
}
@@ -400,21 +351,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 {
moved, err := m.Store.Bind(ctx, c.Node, l.Name)
if err != nil {
if _, err := m.Store.Bind(ctx, c.Node, l.Name); err != nil {
return "", err
}
from := ""
if b != nil {
from = b.Licence
}
m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause)
_ = 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)
}
}
for _, rep := range reports {
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
@@ -423,9 +374,8 @@ 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 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.Store.Bind(ctx, rep.Node, l.Name); err == nil {
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its login"})
}
}
if err := m.publishAdvance(ctx, l); err != nil {
@@ -449,17 +399,14 @@ 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 with the
// cause ctx carries, so the node says why it fetched again (novox/hq issue 625).
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state.
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
bindings, err := m.Store.Advance(ctx, l.Name)
if err != nil {
return err
}
c := CauseOf(ctx)
for _, b := range bindings {
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 {
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
return err
}
}
@@ -535,19 +482,7 @@ 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
}
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})
_ = m.Store.Audit(ctx, "rotated", map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt})
if err := m.publishAdvance(ctx, *l); err != nil {
return nil, err
}
@@ -659,9 +594,8 @@ 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, 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) {
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation.
func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[string]any, error) {
l, err := m.Store.Licence(ctx, licence)
if err != nil {
return nil, err
@@ -677,21 +611,19 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
if err != nil {
return nil, err
}
from := ""
from, what := "", "bound"
if before != nil {
from = before.Licence
from, what = before.Licence, "switched"
}
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 {
_ = m.Store.Audit(ctx, what, map[string]any{"consumer": consumer, "licence": licence, "from": from, "by": by})
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
return nil, err
}
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
}
// Release unbinds a consumer; its node keeps its last token, which expires within hours.
func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any, error) {
func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]any, error) {
was, err := m.Store.Binding(ctx, consumer)
if err != nil {
return nil, err
@@ -702,9 +634,7 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any,
if err := m.DeleteBinding(ctx, consumer); err != nil {
return nil, err
}
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})
_ = m.Store.Audit(ctx, "released", map[string]any{"consumer": consumer, "licence": was.Licence, "by": by})
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
}
@@ -755,10 +685,7 @@ 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
}
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})
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from})
if err := m.publishAdvance(ctx, l); err != nil {
return nil, err
}
@@ -132,13 +132,8 @@ 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)
}
// 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)
}
if !strings.HasPrefix(strings.Join(mm.events, "|"), "licence.adopted") {
t.Fatalf("events %v", mm.events)
}
}
@@ -239,13 +234,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"); err != nil {
if _, err := mm.m.Bind(ctx, "laptop", "api", "test"); err != nil {
t.Fatal(err)
}
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
}
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
if _, err := mm.m.Release(ctx, "laptop", "test"); err != nil {
t.Fatal(err)
}
if _, ok := mm.state["laptop"]; ok {
@@ -459,73 +454,3 @@ 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.14-0.20261012082113-9747f3e1b1ac
git.novox.be/novox/mesh-sdk/go v0.1.7
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.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=
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+1 -9
View File
@@ -8,11 +8,7 @@
],
"contributes": {
"postgres-database": {
"name": "claude_licences",
"readable": [
"audit",
"binding"
]
"name": "claude_licences"
}
},
"binds": {
@@ -63,10 +59,6 @@
"emits": [
"licence.adopted",
"licence.refused",
"licence.bound",
"licence.switched",
"licence.released",
"licence.rotated",
"licence.failing",
"usage.read"
],
-26
View File
@@ -1,26 +0,0 @@
# 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
@@ -1,49 +0,0 @@
{
"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,20 +44,6 @@ 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,15 +15,10 @@ 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" +
"|- " + asPrinted + "\n`- ::1\n"
"|- file:/var/lib/mesh-fail2ban/own-addresses\n`- ::1\n"
// home is the operator's home uplink in these tests (RFC 5737).
const home = "203.0.113.5"
@@ -158,7 +153,7 @@ func appendLog(t *testing.T, root, text string) {
func daemon(missing bool) Runner {
gitea := ignoring
if missing {
gitea = strings.Replace(ignoring, "|- "+asPrinted+"\n", "", 1)
gitea = strings.Replace(ignoring, "|- file:/var/lib/mesh-fail2ban/own-addresses\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)
@@ -173,7 +168,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, asPrinted)+
appendLog(t, root, logAt(now.Add(-48*time.Hour), "gitea", home, IgnoredBy)+
logAt(now.Add(-time.Minute), "sshd", "192.168.1.1", "ip"))
s := w.Look(context.Background())
if err := WriteState(root, s); err != nil {
@@ -188,7 +183,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, asPrinted))
loop.WriteString(logAt(now.Add(time.Duration(i)*15*time.Second), "gitea", home, IgnoredBy))
}
appendLog(t, root, loop.String()[:loop.Len()-10]) // the daemon is still writing the last line
clock = now.Add(10 * time.Minute)
@@ -308,21 +303,3 @@ 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")
}
}
+2 -11
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 !namesOurList(listed(out)) {
if !contains(listed(out), IgnoredBy) {
s.Missing = append(s.Missing, j)
}
}
@@ -115,15 +115,6 @@ 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 {
@@ -171,7 +162,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 || !isOurList(m[4]) {
if m == nil || m[4] != IgnoredBy {
return
}
at, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local)
-9
View File
@@ -905,15 +905,6 @@ 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 provisioner/teams.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"build": "tsc client.ts token.ts index.ts provisioner/index.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": {
+20 -9
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 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.
// 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.
//
// **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,9 +24,11 @@
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { GiteaAdmin } from "../client.js";
import { grant, holds } from "./teams.js";
// Which team a consumer joins — `packages` to publish, `readers` to read only — is teams.ts's.
// 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";
const gitea = GiteaAdmin.fromEnv();
@@ -36,10 +38,19 @@ 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 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);
// 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);
},
async remove(p: { as: string }): Promise<void> {
@@ -49,6 +60,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 holds(gitea, p);
return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password);
},
});
-64
View File
@@ -1,64 +0,0 @@
// 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
@@ -1,56 +0,0 @@
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", "provisioner/teams.ts", "tools/index.ts"]
"include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
}
+1 -15
View File
@@ -5,20 +5,6 @@
"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": [
{
@@ -47,7 +33,7 @@
"type": "file",
"path": "${dir:state}/lab.env",
"mode": "0600",
"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"
"content": "MESH_LAB_FORGE=${setting:forge}\n"
},
{
"id": "git",
-3
View File
@@ -4,9 +4,6 @@
"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
@@ -1,196 +0,0 @@
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");
});
+3 -69
View File
@@ -4,13 +4,7 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
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";
import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
@@ -18,10 +12,6 @@ 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",
@@ -36,17 +26,7 @@ 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" });
// **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 };
return { ok: check.status === 0, output: `${check.stdout}${check.stderr}`.trim() };
},
},
{
@@ -79,7 +59,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, credentialOf(forge)) };
return { started: true, run: start(work, forge, tests, refs) };
},
},
{
@@ -109,52 +89,6 @@ 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 "";
+10 -150
View File
@@ -7,141 +7,11 @@
// 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, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, readdirSync, 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. 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}`)} `;
}
/** 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"];
export interface RunStatus {
id: string;
@@ -208,8 +78,7 @@ 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>,
credential?: string): string {
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>): string {
const dir = runDir(work, id);
const setState = (state: string) =>
`node -e ${shellQuote(
@@ -218,19 +87,15 @@ export function script(work: string, id: string, forge: string, tests: string[],
const clones = REPOSITORIES.map((repo) => {
const ref = refs[repo] ?? "main";
return [
`git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
`git 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))`,
)}
@@ -239,7 +104,9 @@ ${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"))}
${NPM_REPOSITORIES.map((repo) => installStep(join(dir, repo), repo !== "mesh-lab")).join("\n")}
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)
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)
@@ -250,8 +117,6 @@ 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"))}
@@ -262,17 +127,13 @@ 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>,
credential?: string): RunStatus {
export function start(work: string, forge: string, tests: string[], refs: Record<string, 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));
// 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 });
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs), { 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).
@@ -280,7 +141,6 @@ 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();
+14 -1
View File
@@ -17,7 +17,20 @@ 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 only; the home network is never named.
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.
- **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); len(got) != 1 {
if got := Stray(live, nil, nil, meshRange, nil); 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); len(got) != 0 {
if got := Stray(live, nil, foreign, meshRange, nil); 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); len(got) != 0 {
if got := Stray(live, nil, nil, meshRange, nil); 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)
}
}
@@ -0,0 +1,417 @@
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
}
@@ -0,0 +1,184 @@
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,7 +259,9 @@ func TestAdoptIsADryRunWithoutConfirmAndRefusesWhileTheMeshExportIsNotLive(t *te
f := newFake(t)
f.uid = 1000
f.dir("/srv/library")
zfsWith(f, "rw=@192.0.2.0/24")
// 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)
f.file(ConfigFile, config("library=/srv/library"))
f.serve("library")
// The kernel has not read the mesh's file: no exportfs effect.
+45 -7
View File
@@ -31,6 +31,11 @@ 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"`
@@ -61,8 +66,17 @@ 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.
@@ -100,7 +114,7 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
}
}
if reload == "" {
if stray := Stray(live, mine, foreign, c.Range); len(stray) > 0 {
if stray := Stray(live, mine, foreign, c.Range, s.Local); 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)
}
}
@@ -116,7 +130,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) {
for _, e := range Stray(live, mine, foreign, c.Range, s.Local) {
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))
}
@@ -256,21 +270,34 @@ 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 — 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 {
// 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 {
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] || !insideRange(e.Client, prefix) {
if has[path.Clean(e.Path)+" "+e.Client] {
continue
}
inside := false
for _, p := range ours {
inside = inside || insideRange(e.Client, p)
}
if !inside {
continue
}
out = append(out, e)
@@ -288,3 +315,14 @@ 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
}
+34 -3
View File
@@ -28,7 +28,8 @@ 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 home network is never named.
// 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).
//
// **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
@@ -86,6 +87,17 @@ 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.
@@ -172,7 +184,8 @@ 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.
// `none` grants nothing. `local-network` in a node's place grants the machine's local network
// (localnet.go).
func ParseGrants(value string) (map[string]map[string]string, []string) {
out := map[string]map[string]string{}
seen := map[string]bool{}
@@ -457,8 +470,25 @@ 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)
}
@@ -474,7 +504,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", s.Name))
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))
}
out = append(out, e)
}
@@ -517,6 +547,7 @@ 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 == "" {
+29 -1
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 = st.Said
a.Unmatched = append(append([]string{}, st.Said...), st.Unreached...)
}
entries, sets, notes := m.Foreign()
a.Notes = append(a.Notes, notes...)
@@ -380,6 +380,31 @@ 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 {
@@ -428,6 +453,9 @@ 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"
"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)"
}
},
"facts": {
@@ -74,7 +74,7 @@
"protocol": "tcp",
"from": "mesh",
"fixed": true,
"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"
"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"
}
],
"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. 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"
"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"
}
],
"build": {
+288
View File
@@ -0,0 +1,288 @@
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()
}
@@ -0,0 +1,279 @@
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
@@ -0,0 +1,38 @@
// 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)
}
}
@@ -0,0 +1,397 @@
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
}
@@ -0,0 +1,301 @@
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
@@ -0,0 +1,366 @@
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
@@ -0,0 +1,5 @@
module smbserver
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c
+8
View File
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,144 @@
{
"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"
]
}
]
}
}