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