From b1f277669389549181bc4c373899102f1c252444 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 11:24:03 +0200 Subject: [PATCH] WIP: smb-server module and nfs-server's local-network grant (hq issue 613, ADR 0341) Paused: the module's manifest test, README and a full merge-check run are still to do; tests pass locally for both modules. --- modules/nfs-server/README.md | 15 +- .../nfs-server/cmd/nfs-server/guards_test.go | 6 +- modules/nfs-server/cmd/nfs-server/localnet.go | 417 ++++++++++++++++++ .../cmd/nfs-server/localnet_test.go | 184 ++++++++ modules/nfs-server/cmd/nfs-server/nfs_test.go | 4 +- modules/nfs-server/cmd/nfs-server/process.go | 52 ++- modules/nfs-server/cmd/nfs-server/shares.go | 37 +- modules/nfs-server/cmd/nfs-server/tools.go | 30 +- modules/nfs-server/module.json | 6 +- modules/smb-server/cmd/smb-server/conf.go | 288 ++++++++++++ modules/smb-server/cmd/smb-server/machine.go | 279 ++++++++++++ modules/smb-server/cmd/smb-server/main.go | 38 ++ modules/smb-server/cmd/smb-server/process.go | 397 +++++++++++++++++ modules/smb-server/cmd/smb-server/smb_test.go | 301 +++++++++++++ modules/smb-server/cmd/smb-server/tools.go | 366 +++++++++++++++ modules/smb-server/go.mod | 5 + modules/smb-server/go.sum | 8 + modules/smb-server/module.json | 144 ++++++ 18 files changed, 2558 insertions(+), 19 deletions(-) create mode 100644 modules/nfs-server/cmd/nfs-server/localnet.go create mode 100644 modules/nfs-server/cmd/nfs-server/localnet_test.go create mode 100644 modules/smb-server/cmd/smb-server/conf.go create mode 100644 modules/smb-server/cmd/smb-server/machine.go create mode 100644 modules/smb-server/cmd/smb-server/main.go create mode 100644 modules/smb-server/cmd/smb-server/process.go create mode 100644 modules/smb-server/cmd/smb-server/smb_test.go create mode 100644 modules/smb-server/cmd/smb-server/tools.go create mode 100644 modules/smb-server/go.mod create mode 100644 modules/smb-server/go.sum create mode 100644 modules/smb-server/module.json diff --git a/modules/nfs-server/README.md b/modules/nfs-server/README.md index 3c17f47a..86eb15d8 100644 --- a/modules/nfs-server/README.md +++ b/modules/nfs-server/README.md @@ -17,7 +17,20 @@ another node requires. (`/etc/nfs-server/machines`, rendered by the mesh), never one a consumer gives, and it must lie inside the private network's range. The control node, or any node, gets a share only when both name it. A want that is not granted, and a grant nobody wants, are said by `exports`. Port 2049 is opened to the - mesh only; the home network is never named. + mesh by default. The machine's local network is reached only by a grant to `local-network` (below). +- **The local network, only when granted and admitted** (novox/hq ADR 0341, after ADR 0340). `grants` may + name `local-network` in a node's place: `library=ws:rw,local-network:ro`. The process then reads the + machine's links (`ip -j`) and takes the subnets on the links carrying its default route that lie wholly + in a private range (RFC 1918, fc00::/7); a public subnet, the private network's link and container + bridges are never counted, and no range is ever typed in a setting. It exports to such a subnet only + when the mesh's packet filter (table `inet mesh`, chain `input`, read with `nft -j list ruleset`) + admits 2049/tcp from all of it, which it does once the assignment binds 2049 with the reach + `local-network`. A grant the filter does not admit is not exported, said by `exports`, and makes the + module unhealthy, naming that act. Every device on the local network then acts as the folder's owner, + as the hand-written export it replaces already allowed. +- **A dataset's `sharenfs` is cleared only when nobody loses the folder.** `adopt` refuses while the + property, by its value or the file ZFS writes for it, exports to a client the mesh's live export does + not cover: for a property to the home network, the module's `local-network` export. - **Every client is the folder's owner.** NFS with `sec=sys` believes the user id a client sends, and one id names different accounts on different machines. Every line is `all_squash` with `anonuid` and `anongid` read from the folder's owner on this machine, so a file written from any node lands owned by diff --git a/modules/nfs-server/cmd/nfs-server/guards_test.go b/modules/nfs-server/cmd/nfs-server/guards_test.go index add729ff..cb7b9aae 100644 --- a/modules/nfs-server/cmd/nfs-server/guards_test.go +++ b/modules/nfs-server/cmd/nfs-server/guards_test.go @@ -11,7 +11,7 @@ import ( func TestStrayFindsAnExportOfTheModulesThatNoFileCarries(t *testing.T) { live := []Entry{{Path: "/srv/library", Client: "10.0.0.4"}} - if got := Stray(live, nil, nil, meshRange); len(got) != 1 { + if got := Stray(live, nil, nil, meshRange, nil); len(got) != 1 { t.Errorf("an export inside the mesh range that no file carries is the module's leftover: %+v", got) } } @@ -21,7 +21,7 @@ func TestStrayLeavesAnExportAnotherFileCarriesEvenInsideTheMeshRange(t *testing. // ZFS's own file exports the library to a client inside the mesh's range. foreign := ParseExports("/srv/library 10.0.0.9(rw,no_subtree_check)\n", "/etc/exports.d/zfs.exports") live := []Entry{{Path: "/srv/library", Client: "10.0.0.9", Options: "rw"}} - if got := Stray(live, nil, foreign, meshRange); len(got) != 0 { + if got := Stray(live, nil, foreign, meshRange, nil); len(got) != 0 { t.Errorf("what ZFS's file exports is not the module's, whatever its client: %+v", got) } } @@ -30,7 +30,7 @@ func TestStrayLeavesAnExportAnotherFileCarriesEvenInsideTheMeshRange(t *testing. func TestStrayLeavesAClientOutsideTheMeshRange(t *testing.T) { live := []Entry{{Path: "/srv/library", Client: "192.0.2.0/24"}, {Path: "/srv/library", Client: "192.0.2.7"}, {Path: "/srv/library", Client: "10.0.0.0/16"}} - if got := Stray(live, nil, nil, meshRange); len(got) != 0 { + if got := Stray(live, nil, nil, meshRange, nil); len(got) != 0 { t.Errorf("a client the module never exports to (outside or wider than the mesh's range) is not its leftover: %+v", got) } } diff --git a/modules/nfs-server/cmd/nfs-server/localnet.go b/modules/nfs-server/cmd/nfs-server/localnet.go new file mode 100644 index 00000000..5fa32d33 --- /dev/null +++ b/modules/nfs-server/cmd/nfs-server/localnet.go @@ -0,0 +1,417 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/netip" + "sort" + "strings" +) + +// A share granted to the machine's local network (novox/hq ADR 0341 clauses 8 and 9, after ADR 0340). +// +// **The grant names `local-network`, never a range.** `grants` may name `local-network` for a share beside +// node names: `library=ws:rw,local-network:ro`. The export goes to the subnets this machine's own local +// network is, by ADR 0340's rules: the networks on the links that carry its default route (the uplink), +// each wholly inside a private range (RFC 1918, or fc00::/7), never a public or link-local one, never the +// private network's own link or a container bridge. A range typed in a setting is never used. +// +// **A grant the filter does not admit is not exported, and is said.** ADR 0340 lets an assignment bind a +// port with the reach `local-network`, and the filter then admits it from those subnets on that port's +// rule alone. The module does not take the reach on trust: every pass it reads the mesh's filter (the +// table `inet mesh`, chain `input`) and exports to a local subnet only when 2049/tcp is admitted from all +// of it. Otherwise the share is not exported there, and the status and the health say so, naming the act +// (bind 2049 with the reach local-network) — a grant never reads as served while the filter drops it. +// +// **What this does not promise.** NFS with sec=sys trusts the client, so every device on the local +// network acts as the share's owner (all_squash, as for a node). That is what the hand-written export it +// replaces already allowed. + +// LocalNetwork is the name a grant gives the machine's local network. +const LocalNetwork = "local-network" + +// NFSPort is the port the filter must admit from a local subnet before a share is exported to it. +const NFSPort = 2049 + +// private are the ranges a local network must lie wholly inside (ADR 0340 §2). +var private = []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("fc00::/7"), +} + +// wholly says whether a network lies wholly inside one of the private ranges. +func wholly(p netip.Prefix) bool { + for _, r := range private { + if p.Bits() >= r.Bits() && r.Contains(p.Addr()) { + return true + } + } + return false +} + +// ipRoute is one route as `ip -j route show default` answers it. +type ipRoute struct { + Dev string `json:"dev"` +} + +// ipLink is one link as `ip -j addr show` answers it. +type ipLink struct { + Name string `json:"ifname"` + AddrInfo []struct { + Family string `json:"family"` + Local string `json:"local"` + Prefixlen int `json:"prefixlen"` + Scope string `json:"scope"` + } `json:"addr_info"` +} + +// LocalNetworksFrom is the machine's local networks from what `ip` answered: the links carrying a default +// route, and their addresses. A network not wholly private, or not global in scope, is left out, and why. +func LocalNetworksFrom(routes4, routes6, addrs string) ([]string, []string) { + uplinks := map[string]bool{} + for _, text := range []string{routes4, routes6} { + var rs []ipRoute + if strings.TrimSpace(text) == "" { + continue + } + if err := json.Unmarshal([]byte(text), &rs); err != nil { + continue + } + for _, r := range rs { + if r.Dev != "" { + uplinks[r.Dev] = true + } + } + } + var links []ipLink + if strings.TrimSpace(addrs) != "" { + _ = json.Unmarshal([]byte(addrs), &links) + } + seen := map[string]bool{} + var out, left []string + for _, l := range links { + if !uplinks[l.Name] { + continue + } + for _, a := range l.AddrInfo { + if a.Scope != "global" { + continue + } + addr, err := netip.ParseAddr(a.Local) + if err != nil { + continue + } + p, err := addr.Prefix(a.Prefixlen) + if err != nil { + continue + } + if !wholly(p) { + left = append(left, fmt.Sprintf("%s on %s is not wholly inside a private range, so it is not the local network", p, l.Name)) + continue + } + if !seen[p.String()] { + seen[p.String()] = true + out = append(out, p.String()) + } + } + } + sort.Strings(out) + return out, left +} + +// LocalNetworks is this machine's local networks now, read with `ip`, and what was left out or could not +// be read. +func (m *Machine) LocalNetworks(ctx context.Context) ([]string, []string) { + r4 := m.Run(ctx, "ip", "-j", "-4", "route", "show", "default") + r6 := m.Run(ctx, "ip", "-j", "-6", "route", "show", "default") + ad := m.Run(ctx, "ip", "-j", "addr", "show") + var notes []string + if r4.Code != 0 || ad.Code != 0 { + notes = append(notes, "this machine's links could not be read with ip: "+firstLine(r4.Err+" "+ad.Err)) + } + nets, left := LocalNetworksFrom(r4.Out, r6.Out, ad.Out) + return nets, append(notes, left...) +} + +// nftRule is one rule of `nft -j list ruleset`. +type nftRule struct { + Family string `json:"family"` + Table string `json:"table"` + Chain string `json:"chain"` + Expr []json.RawMessage `json:"expr"` +} + +// nftChain is one chain of `nft -j list ruleset`. +type nftChain struct { + Family string `json:"family"` + Table string `json:"table"` + Name string `json:"name"` + Policy string `json:"policy"` +} + +// Admitted says, for each subnet, whether the mesh's filter (table inet mesh, chain input) accepts tcp to +// the port from all of it, read from `nft -j list ruleset`. A rule counts only when every condition it has +// is one this reads — the protocol, the destination port and the source address — and its verdict is +// accept; a rule with any other condition (an interface, a connection state) is left out, so nothing is +// called admitted that may not be. With no such table, nothing is admitted, and why. +func Admitted(ruleset string, port int, subnets []string) (map[string]bool, string) { + out := map[string]bool{} + var doc struct { + Nftables []map[string]json.RawMessage `json:"nftables"` + } + if err := json.Unmarshal([]byte(ruleset), &doc); err != nil { + return out, "the packet filter's rules could not be read (nft -j list ruleset): " + err.Error() + } + var rules []nftRule + found := false + policy := "" + for _, item := range doc.Nftables { + if raw, ok := item["chain"]; ok { + var c nftChain + if json.Unmarshal(raw, &c) == nil && c.Family == "inet" && c.Table == "mesh" && c.Name == "input" { + found, policy = true, c.Policy + } + } + if raw, ok := item["rule"]; ok { + var r nftRule + if json.Unmarshal(raw, &r) == nil && r.Family == "inet" && r.Table == "mesh" && r.Chain == "input" { + rules = append(rules, r) + } + } + } + if !found { + return out, "the mesh's packet filter (table inet mesh, chain input) is not there, so no local network is admitted" + } + if policy == "accept" { + for _, s := range subnets { + out[s] = true + } + return out, "" + } + for _, r := range rules { + sources, ok := admitsPort(r.Expr, port) + if !ok { + continue + } + for _, s := range subnets { + p, err := netip.ParsePrefix(s) + if err != nil { + continue + } + if sources == nil { + out[s] = true + continue + } + for _, src := range sources { + if src.Bits() <= p.Bits() && src.Contains(p.Addr()) { + out[s] = true + } + } + } + } + return out, "" +} + +// admitsPort reads one rule: whether it accepts tcp to the port, and from which sources (nil: from +// anywhere). ok is false for a rule that does not accept that port, or that has a condition this does not +// read. +func admitsPort(exprs []json.RawMessage, port int) (sources []netip.Prefix, ok bool) { + accepts, portMatched, anywhere := false, false, true + for _, raw := range exprs { + var e map[string]json.RawMessage + if json.Unmarshal(raw, &e) != nil { + return nil, false + } + if _, is := e["accept"]; is { + accepts = true + continue + } + if _, is := e["counter"]; is { + continue + } + m, is := e["match"] + if !is { + return nil, false + } + var match struct { + Op string `json:"op"` + Left json.RawMessage `json:"left"` + Right json.RawMessage `json:"right"` + } + if json.Unmarshal(m, &match) != nil || (match.Op != "==" && match.Op != "in") { + return nil, false + } + var left struct { + Payload *struct { + Protocol string `json:"protocol"` + Field string `json:"field"` + } `json:"payload"` + Meta *struct { + Key string `json:"key"` + } `json:"meta"` + } + if json.Unmarshal(match.Left, &left) != nil { + return nil, false + } + switch { + case left.Meta != nil && left.Meta.Key == "l4proto": + var proto string + if json.Unmarshal(match.Right, &proto) != nil || proto != "tcp" { + return nil, false + } + case left.Payload != nil && left.Payload.Protocol == "tcp" && left.Payload.Field == "dport": + if !hasPort(match.Right, port) { + return nil, false + } + portMatched = true + case left.Payload != nil && (left.Payload.Protocol == "ip" || left.Payload.Protocol == "ip6") && left.Payload.Field == "saddr": + anywhere = false + sources = append(sources, prefixesOf(match.Right)...) + default: + return nil, false + } + } + if !accepts || !portMatched { + return nil, false + } + if anywhere { + return nil, true + } + if sources == nil { + sources = []netip.Prefix{} + } + return sources, true +} + +// hasPort reads a match's right side: a number, or a set holding it. +func hasPort(raw json.RawMessage, port int) bool { + var n int + if json.Unmarshal(raw, &n) == nil { + return n == port + } + var set struct { + Set []json.RawMessage `json:"set"` + } + if json.Unmarshal(raw, &set) == nil { + for _, item := range set.Set { + if json.Unmarshal(item, &n) == nil && n == port { + return true + } + } + } + return false +} + +// prefixesOf reads a match's right side as sources: an address, a prefix, or a set of either. +func prefixesOf(raw json.RawMessage) []netip.Prefix { + var s string + if json.Unmarshal(raw, &s) == nil { + if a, err := netip.ParseAddr(s); err == nil { + return []netip.Prefix{netip.PrefixFrom(a, a.BitLen())} + } + if p, err := netip.ParsePrefix(s); err == nil { + return []netip.Prefix{p.Masked()} + } + return nil + } + var pre struct { + Prefix *struct { + Addr string `json:"addr"` + Len int `json:"len"` + } `json:"prefix"` + Set []json.RawMessage `json:"set"` + } + if json.Unmarshal(raw, &pre) != nil { + return nil + } + if pre.Prefix != nil { + if a, err := netip.ParseAddr(pre.Prefix.Addr); err == nil { + if p, err := a.Prefix(pre.Prefix.Len); err == nil { + return []netip.Prefix{p} + } + } + return nil + } + var out []netip.Prefix + for _, item := range pre.Set { + out = append(out, prefixesOf(item)...) + } + return out +} + +// LocalNetworkAdmitted is what the process learns each pass: the machine's local subnets, which of them the +// filter admits 2049 from, and what it could not learn. +func (m *Machine) LocalNetworkAdmitted(ctx context.Context) (nets []string, admitted map[string]bool, notes []string) { + nets, notes = m.LocalNetworks(ctx) + if len(nets) == 0 { + return nets, map[string]bool{}, notes + } + r := m.Run(ctx, "nft", "-j", "list", "ruleset") + if r.Code != 0 { + return nets, map[string]bool{}, append(notes, "the packet filter's rules could not be read (nft -j list ruleset): "+firstLine(r.Err)) + } + admitted, note := Admitted(r.Out, NFSPort, nets) + if note != "" { + notes = append(notes, note) + } + return nets, admitted, notes +} + +// Covered says whether a client a hand-made export names is covered by the mesh's live export of the same +// path: the same address, or a network inside one the mesh exports to. "*" and a name are never covered. +func Covered(client string, mine []Grantee) bool { + var want netip.Prefix + if a, err := netip.ParseAddr(client); err == nil { + want = netip.PrefixFrom(a, a.BitLen()) + } else if p, err := netip.ParsePrefix(client); err == nil { + want = p.Masked() + } else { + return false + } + for _, g := range mine { + var have netip.Prefix + if a, err := netip.ParseAddr(g.Address); err == nil { + have = netip.PrefixFrom(a, a.BitLen()) + } else if p, err := netip.ParsePrefix(g.Address); err == nil { + have = p.Masked() + } else { + continue + } + if have.Bits() <= want.Bits() && have.Contains(want.Addr()) { + return true + } + } + return false +} + +// PropertyClients is who a dataset's sharenfs property exports to: every `@network` or address its value +// names, and "*" for a bare `on` or a value naming nobody, which nothing the mesh exports ever covers. +func PropertyClients(sharenfs string) []string { + v := strings.TrimSpace(sharenfs) + if v == "on" || v == "" { + return []string{"*"} + } + var out []string + for _, opt := range strings.Split(v, ",") { + key, list, ok := strings.Cut(strings.TrimSpace(opt), "=") + if key != "rw" && key != "ro" { + continue + } + if !ok { + return []string{"*"} // rw or ro alone: to everyone + } + for _, c := range strings.Split(list, ":") { + c = strings.TrimPrefix(strings.TrimSpace(c), "@") + if c != "" { + out = append(out, c) + } + } + } + if len(out) == 0 { + return []string{"*"} + } + return out +} diff --git a/modules/nfs-server/cmd/nfs-server/localnet_test.go b/modules/nfs-server/cmd/nfs-server/localnet_test.go new file mode 100644 index 00000000..c282648c --- /dev/null +++ b/modules/nfs-server/cmd/nfs-server/localnet_test.go @@ -0,0 +1,184 @@ +package main + +import ( + "context" + "reflect" + "strings" + "testing" +) + +// Defends novox/hq ADR 0341 clauses 8 to 10: a share granted to local-network is exported to the machine's +// own private subnets on its uplink, only those the mesh's filter admits 2049 from; a grant the filter does +// not admit is said and unhealthy; and sharenfs is cleared only once the mesh's export covers every client +// the property exported to. + +const homeNet = "192.168.1.0/24" + +// The home server's links, as `ip -j` answers them: the uplink with a private and a public address, the +// private network's link, and a container bridge. +const ( + routes4 = `[{"dst":"default","gateway":"192.168.1.1","dev":"enp6s0"}]` + addrs = `[ + {"ifname":"lo","addr_info":[{"family":"inet","local":"127.0.0.1","prefixlen":8,"scope":"host"}]}, + {"ifname":"enp6s0","addr_info":[ + {"family":"inet","local":"192.168.1.135","prefixlen":24,"scope":"global"}, + {"family":"inet","local":"198.51.100.7","prefixlen":24,"scope":"global"}, + {"family":"inet6","local":"fe80::1","prefixlen":64,"scope":"link"}]}, + {"ifname":"mesh0","addr_info":[{"family":"inet","local":"10.0.0.2","prefixlen":32,"scope":"global"}]}, + {"ifname":"docker0","addr_info":[{"family":"inet","local":"172.17.0.1","prefixlen":16,"scope":"global"}]}]` +) + +// rulesetAdmitting is the mesh's filter with 2049 admitted from the nodes and, when local is set, from it. +func rulesetAdmitting(local string) string { + src := `{"set":["10.0.0.1","10.0.0.3"]}` + if local != "" { + addr, bits, _ := strings.Cut(local, "/") + src = `{"set":["10.0.0.1","10.0.0.3",{"prefix":{"addr":"` + addr + `","len":` + bits + `}}]}` + } + return `{"nftables":[{"metainfo":{"version":"1.1"}}, + {"table":{"family":"inet","name":"mesh"}}, + {"chain":{"family":"inet","table":"mesh","name":"input","type":"filter","hook":"input","prio":0,"policy":"drop"}}, + {"rule":{"family":"inet","table":"mesh","chain":"input","expr":[{"match":{"op":"!=","left":{"meta":{"key":"iifname"}},"right":{"set":["enp6s0","mesh0"]}}},{"accept":null}]}}, + {"rule":{"family":"inet","table":"mesh","chain":"input","expr":[{"match":{"op":"==","left":{"payload":{"protocol":"ip","field":"saddr"}},"right":` + src + `}},{"match":{"op":"==","left":{"payload":{"protocol":"tcp","field":"dport"}},"right":2049}},{"accept":null}]}}, + {"rule":{"family":"inet","table":"mesh","chain":"input","expr":[{"match":{"op":"==","left":{"payload":{"protocol":"tcp","field":"dport"}},"right":445}},{"accept":null}]}}]}` +} + +func (f *fake) homeLinks(ruleset string) { + f.answers["ip -j -4 route show default"] = Ran{Out: routes4} + f.answers["ip -j -6 route show default"] = Ran{Out: "[]"} + f.answers["ip -j addr show"] = Ran{Out: addrs} + f.answers["nft -j list ruleset"] = Ran{Out: ruleset} +} + +func TestTheLocalNetworkIsTheUplinksPrivateSubnetsOnly(t *testing.T) { + nets, left := LocalNetworksFrom(routes4, "", addrs) + if !reflect.DeepEqual(nets, []string{homeNet}) { + t.Errorf("local networks %v; the uplink's private subnet alone, never the public one, the mesh's link or a bridge", nets) + } + if len(left) != 1 || !strings.Contains(left[0], "198.51.100.0/24") { + t.Errorf("the public subnet is not said as left out: %v", left) + } + if nets, _ := LocalNetworksFrom("", "", addrs); len(nets) != 0 { + t.Errorf("with no default route there is no uplink and no local network: %v", nets) + } +} + +func TestAdmittedReadsThePortsOwnRuleOnly(t *testing.T) { + got, note := Admitted(rulesetAdmitting(homeNet), 2049, []string{homeNet}) + if note != "" || !got[homeNet] { + t.Errorf("2049 admitted from the home network is not read as admitted: %v %q", got, note) + } + got, _ = Admitted(rulesetAdmitting(""), 2049, []string{homeNet}) + if got[homeNet] { + t.Error("2049 admitted from the nodes only is read as admitted from the home network") + } + // A rule open to every source on 445 says nothing of 2049, and the interface rule is not read as one. + if got, _ := Admitted(rulesetAdmitting(""), 445, []string{homeNet}); !got[homeNet] { + t.Error("a port open from anywhere is not admitted") + } + if got, note := Admitted(`{"nftables":[]}`, 2049, []string{homeNet}); got[homeNet] || note == "" { + t.Errorf("with no mesh filter a subnet is admitted, or nothing is said: %v %q", got, note) + } +} + +// grantedLocal is the library granted to ws and to the local network, ws wanting it. +func grantedLocal(f *fake, grant string) { + f.mountinfo("/", "/srv/library") + f.dir("/srv/library") + f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw,"+grant)) + f.serve("library") + f.kernelReads() +} + +func TestAShareGrantedToTheLocalNetworkGoesToItsSubnetWhenTheFilterAdmitsIt(t *testing.T) { + f := newFake(t) + grantedLocal(f, "local-network:ro") + f.homeLinks(rulesetAdmitting(homeNet)) + s := f.machine().Pass(context.Background(), nil) + text := f.read(ExportFile) + if !strings.Contains(text, " "+homeNet+"(ro,mp,sync,secure,no_subtree_check,all_squash,") { + t.Errorf("the library is not exported read-only to the home network, squashed:\n%s", text) + } + if strings.Contains(text, "198.51.100") || strings.Contains(text, "172.17") { + t.Errorf("a public subnet or a bridge is exported to:\n%s", text) + } + if len(s.Unreached) != 0 || !s.Shares[0].Live || len(s.Shares[0].To) != 2 { + t.Errorf("status: %+v", s) + } + if h := f.machine().Health(); !h.Healthy || !strings.Contains(h.Why, "local-network ("+homeNet+", ro)") { + t.Errorf("health: %+v", h) + } +} + +func TestAGrantTheFilterDoesNotAdmitIsNotExportedAndIsUnhealthy(t *testing.T) { + f := newFake(t) + grantedLocal(f, "local-network:rw") + f.homeLinks(rulesetAdmitting("")) + s := f.machine().Pass(context.Background(), nil) + if strings.Contains(f.read(ExportFile), homeNet) { + t.Errorf("the library is exported to a subnet the filter drops:\n%s", f.read(ExportFile)) + } + if len(s.Unreached) != 1 || !strings.Contains(s.Unreached[0], "reach local-network") { + t.Errorf("the unreached grant is not said with its act: %v", s.Unreached) + } + if h := f.machine().Health(); h.Healthy || !strings.Contains(h.Why, "does not admit 2049/tcp") { + t.Errorf("health: %+v", h) + } +} + +func TestWithNoPrivateNetworkOnTheUplinkALocalGrantGoesNowhereAndIsSaid(t *testing.T) { + f := newFake(t) + grantedLocal(f, "local-network:rw") + s := f.machine().Pass(context.Background(), nil) // ip answers nothing + if strings.Contains(f.read(ExportFile), "192.168") { + t.Errorf("exported to a local network that was not found:\n%s", f.read(ExportFile)) + } + if !strings.Contains(strings.Join(s.Said, "\n"), "reports no private network on its uplink") { + t.Errorf("not said: %v", s.Said) + } +} + +func TestAdoptWaitsUntilTheLocalNetworkExportCoversTheProperty(t *testing.T) { + f := newFake(t) + f.uid = 1000 + zfsWith(f, "rw=@"+homeNet) + f.file("/etc/exports.d/zfs.exports", "/srv/library "+homeNet+"(sec=sys,rw,no_subtree_check,mountpoint)\n") + grantedLocal(f, "local-network:rw") + f.homeLinks(rulesetAdmitting("")) // the filter does not admit the home network yet + f.machine().Pass(context.Background(), nil) + if _, err := f.machine().Adopt("/srv/library", false, ""); err == nil || !strings.Contains(err.Error(), "does not cover") { + t.Fatalf("adopt while the home network is not covered: %v", err) + } + f.homeLinks(rulesetAdmitting(homeNet)) + f.machine().Pass(context.Background(), nil) + f.ran = nil + dry, err := f.machine().Adopt("/srv/library", false, "") + if err != nil || !strings.Contains(dry.Said, "would set sharenfs=off") { + t.Fatalf("dry run once covered: %+v %v", dry, err) + } + for _, r := range f.ran { + if strings.Contains(r, "zfs set") { + t.Fatalf("a dry run changed something: %v", f.ran) + } + } +} + +func TestPropertyClientsAndCovered(t *testing.T) { + for v, want := range map[string]string{ + "rw=@192.168.1.0/24": "192.168.1.0/24", + "rw=@192.168.1.0/24:@10.0.0.3": "192.168.1.0/24 10.0.0.3", + "on": "*", + "rw,no_root_squash": "*", + "sec=sys,ro=@10.0.0.3": "10.0.0.3", + } { + if got := strings.Join(PropertyClients(v), " "); got != want { + t.Errorf("PropertyClients(%q) = %q, not %q", v, got, want) + } + } + mine := []Grantee{{Node: "ws", Address: "10.0.0.3"}, {Node: LocalNetwork, Address: homeNet}} + for client, want := range map[string]bool{"10.0.0.3": true, homeNet: true, "192.168.1.7": true, "192.168.0.0/16": false, "*": false, "10.0.0.4": false} { + if Covered(client, mine) != want { + t.Errorf("Covered(%q) is not %v", client, want) + } + } +} diff --git a/modules/nfs-server/cmd/nfs-server/nfs_test.go b/modules/nfs-server/cmd/nfs-server/nfs_test.go index dbc45970..8e260ced 100644 --- a/modules/nfs-server/cmd/nfs-server/nfs_test.go +++ b/modules/nfs-server/cmd/nfs-server/nfs_test.go @@ -259,7 +259,9 @@ func TestAdoptIsADryRunWithoutConfirmAndRefusesWhileTheMeshExportIsNotLive(t *te f := newFake(t) f.uid = 1000 f.dir("/srv/library") - zfsWith(f, "rw=@192.0.2.0/24") + // A property the mesh's export covers once live: the one node. One to the home network is + // localnet_test.go's. + zfsWith(f, "rw=@"+wsAddress) f.file(ConfigFile, config("library=/srv/library")) f.serve("library") // The kernel has not read the mesh's file: no exportfs effect. diff --git a/modules/nfs-server/cmd/nfs-server/process.go b/modules/nfs-server/cmd/nfs-server/process.go index c6c6172e..7a05890a 100644 --- a/modules/nfs-server/cmd/nfs-server/process.go +++ b/modules/nfs-server/cmd/nfs-server/process.go @@ -31,6 +31,11 @@ type Status struct { // Warnings make the module no less healthy but are said in its health: what it could not learn, and // how old what it relies on is. Warnings []string `json:"warnings,omitempty"` + // Local is the machine's local subnets this pass (ADR 0341): what a grant to local-network may reach. + Local []string `json:"local_networks,omitempty"` + // Unreached is each grant to the local network the packet filter does not admit: not exported, and the + // module is unhealthy until it is. + Unreached []string `json:"unreached,omitempty"` // Reloaded is when the process last had the kernel read the export files again, and why. Reloaded *time.Time `json:"reloaded,omitempty"` ReloadWhy string `json:"reload_why,omitempty"` @@ -61,8 +66,17 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status { if _, note := m.Backup(); note != "" { s.Warnings = append(s.Warnings, note) } + if grantsLocal(c) { + nets, admitted, notes := m.LocalNetworkAdmitted(ctx) + c.Local = &Local{Nets: nets, Admitted: admitted} + s.Local = nets + s.Said = append(s.Said, notes...) + } m.setWalk(ctx) exports, refused, said := m.Judge(c, roster, wants) + if c.Local != nil { + s.Unreached = c.Local.Unreached + } m.setWalk(nil) if ctx.Err() != nil { // Stopping: write nothing and reload nothing, so Stopped starts at once. @@ -100,7 +114,7 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status { } } if reload == "" { - if stray := Stray(live, mine, foreign, c.Range); len(stray) > 0 { + if stray := Stray(live, mine, foreign, c.Range, s.Local); len(stray) > 0 { reload = fmt.Sprintf("the kernel exports %s to %s, which the export file no longer carries", stray[0].Path, stray[0].Client) } } @@ -116,7 +130,7 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status { if note != "" && len(exports) > 0 { s.Problems = append(s.Problems, note) } - for _, e := range Stray(live, mine, foreign, c.Range) { + for _, e := range Stray(live, mine, foreign, c.Range, s.Local) { s.Problems = append(s.Problems, fmt.Sprintf("the kernel still exports %s to %s, which the module's export file "+ "no longer carries: a reload has to take it away (exportfs -ra as root)", e.Path, e.Client)) } @@ -256,21 +270,34 @@ func loud(what string) { } // Stray is what the kernel exports that is the module's and that its file no longer carries: an entry to a -// client inside the private network's range — the only clients the module ever exports to — that neither -// the module's file nor any other export file (/etc/exports, ZFS's own) has. What another file exports is -// not the module's, whatever its client. -func Stray(live, mine, foreign []Entry, rng string) []Entry { +// client inside the private network's range, or one of the machine's local subnets — the only clients the +// module ever exports to — that neither the module's file nor any other export file (/etc/exports, ZFS's +// own) has. What another file exports is not the module's, whatever its client. +func Stray(live, mine, foreign []Entry, rng string, local []string) []Entry { prefix, err := netip.ParsePrefix(rng) if err != nil { return nil } + ours := []netip.Prefix{prefix} + for _, n := range local { + if p, err := netip.ParsePrefix(n); err == nil { + ours = append(ours, p.Masked()) + } + } has := map[string]bool{} for _, e := range append(append([]Entry{}, mine...), foreign...) { has[path.Clean(e.Path)+" "+e.Client] = true } var out []Entry for _, e := range live { - if has[path.Clean(e.Path)+" "+e.Client] || !insideRange(e.Client, prefix) { + if has[path.Clean(e.Path)+" "+e.Client] { + continue + } + inside := false + for _, p := range ours { + inside = inside || insideRange(e.Client, p) + } + if !inside { continue } out = append(out, e) @@ -288,3 +315,14 @@ func insideRange(client string, prefix netip.Prefix) bool { } return false } + +// grantsLocal says whether any share is granted to the local network, so the process looks at the links +// and the filter only when a grant asks for them. +func grantsLocal(c Config) bool { + for _, nodes := range c.Grants { + if _, ok := nodes[LocalNetwork]; ok { + return true + } + } + return false +} diff --git a/modules/nfs-server/cmd/nfs-server/shares.go b/modules/nfs-server/cmd/nfs-server/shares.go index 073ee0cb..76ce9686 100644 --- a/modules/nfs-server/cmd/nfs-server/shares.go +++ b/modules/nfs-server/cmd/nfs-server/shares.go @@ -28,7 +28,8 @@ import ( // write to WantsFile). The access is the lower of the two: a grant of ro is never widened by a want. The // address is the mesh's own, from the roster the mesh renders (RosterFile), never one a consumer gave, // and it must lie in the private network's range. A share nobody is granted and wants is exported to -// nobody. The home network is never named. +// nobody. The local network is named only by a grant to `local-network`, and then only the subnets the +// packet filter admits 2049 from (localnet.go, novox/hq ADR 0341). // // **Never the backups, never root's.** A share is judged at its real path (symlinks resolved). It is // refused when it is the machine's root, is not there, is not a folder, is owned by root or root's group @@ -86,6 +87,17 @@ type Config struct { // Grants is, per share, the nodes this machine lets mount it and with which access. Grants map[string]map[string]string Range string + // Local is the machine's local network as the process found it this pass (localnet.go): nil when it was + // not looked at, and a grant to local-network then goes nowhere. + Local *Local +} + +// Local is the machine's local subnets, which of them the filter admits 2049 from, and — written by Judge — +// each local-network grant the filter does not admit. +type Local struct { + Nets []string + Admitted map[string]bool + Unreached []string } // Want is one share a node's mounts module wants of this machine, as it put it on the bus. @@ -172,7 +184,8 @@ func ParseShares(value string) ([]Share, []string) { } // ParseGrants reads the `grants` setting: `share=node:rw|ro[,node:rw|ro]`, entries separated by spaces; -// `none` grants nothing. +// `none` grants nothing. `local-network` in a node's place grants the machine's local network +// (localnet.go). func ParseGrants(value string) (map[string]map[string]string, []string) { out := map[string]map[string]string{} seen := map[string]bool{} @@ -457,8 +470,25 @@ func (m *Machine) Judge(c Config, roster map[string]string, wants map[string][]W e.To = append(e.To, Grantee{Node: node, Address: addr, Access: access}) } } + if g, ok := granted[LocalNetwork]; ok { + switch { + case c.Local == nil || len(c.Local.Nets) == 0: + said = append(said, fmt.Sprintf("%s is granted to the local network, and this machine reports no private network on its uplink, so it is exported to no local network", s.Name)) + default: + for _, n := range c.Local.Nets { + if !c.Local.Admitted[n] { + c.Local.Unreached = append(c.Local.Unreached, fmt.Sprintf("the share %s is granted to the local network %s, and the packet filter does not admit %d/tcp from it, so it is not exported there: bind nfs-server's %d with the reach local-network (novox/hq ADR 0340, ADR 0341)", s.Name, n, NFSPort, NFSPort)) + continue + } + e.To = append(e.To, Grantee{Node: LocalNetwork, Address: n, Access: lower(g, s.Access)}) + } + } + } grantedNodes := make([]string, 0, len(granted)) for n := range granted { + if n == LocalNetwork { + continue + } if !wanting[n] { grantedNodes = append(grantedNodes, n) } @@ -474,7 +504,7 @@ func (m *Machine) Judge(c Config, roster map[string]string, wants map[string][]W e.Clients = strings.Join(addrs, ",") e.Line = ExportLine(e) if len(e.To) == 0 { - said = append(said, fmt.Sprintf("%s is exported to no node: none is both granted it and wants it", s.Name)) + said = append(said, fmt.Sprintf("%s is exported to no node: none is both granted it and wants it, and no local network is granted and admitted", s.Name)) } out = append(out, e) } @@ -517,6 +547,7 @@ func ExportFileText(exports []Exported) string { b.WriteString("# Written by the mesh's module nfs-server (novox/hq ADR 0263). Rendered by the module's own process\n") b.WriteString("# from its assignment's `shares` and `grants` and the nodes that want each share; edit the settings,\n") b.WriteString("# never this file. One share per folder, to each granted and wanting node's private address only,\n") + b.WriteString("# and to a local subnet only when local-network is granted and the filter admits 2049 from it;\n") b.WriteString("# every client squashed to the folder's owner.\n") for _, e := range exports { if e.Line == "" { diff --git a/modules/nfs-server/cmd/nfs-server/tools.go b/modules/nfs-server/cmd/nfs-server/tools.go index 596bc2f8..72e6bd0c 100644 --- a/modules/nfs-server/cmd/nfs-server/tools.go +++ b/modules/nfs-server/cmd/nfs-server/tools.go @@ -127,7 +127,7 @@ func (m *Machine) Exports() ExportsAnswer { } a.Notes = append(a.Notes, st.Problems...) a.Notes = append(a.Notes, st.Warnings...) - a.Unmatched = st.Said + a.Unmatched = append(append([]string{}, st.Said...), st.Unreached...) } entries, sets, notes := m.Foreign() a.Notes = append(a.Notes, notes...) @@ -380,6 +380,31 @@ func (m *Machine) Adopt(p string, confirm bool, why string) (AdoptAnswer, error) if lnote != "" || !SharedLive(live, *mine) { return a, fmt.Errorf("the mesh's export of %s to %s is not live in the kernel yet, so taking over would unexport it; wait for the module's process, or call reload", p, grantees(mine.To)) } + // Every client the property exported to must be covered by the mesh's live export, so no client loses + // the folder in the take-over (novox/hq ADR 0341 clause 10): for a property to the home network, the + // module's local-network export of it. Who the property exports to is read from its value and from the + // file ZFS writes for it. + clients := PropertyClients(ds.ShareNFS) + entries, _, _ := m.Foreign() + for _, e := range entries { + if strings.Contains(e.From, "zfs") && path.Clean(e.Path) == p { + clients = append(clients, e.Client) + } + } + var uncovered []string + seenClient := map[string]bool{} + for _, c := range clients { + if seenClient[c] { + continue + } + seenClient[c] = true + if !Covered(c, mine.To) { + uncovered = append(uncovered, c) + } + } + if len(uncovered) > 0 { + return a, fmt.Errorf("the dataset's sharenfs exports %s to %s, which the mesh's export (to %s) does not cover, so taking over would unexport it for them: grant the share to local-network (and bind %d with the reach local-network) until the mesh's export covers them, or have a person decide they lose it", p, strings.Join(uncovered, ", "), grantees(mine.To), NFSPort) + } plan := fmt.Sprintf("set sharenfs=off on the dataset %s (now %q) and run exportfs -ra; %s stays exported as the share %s to %s, every client as %d:%d", ds.Name, ds.ShareNFS, p, mine.Name, grantees(mine.To), mine.UID, mine.GID) if !confirm { @@ -428,6 +453,9 @@ func (m *Machine) Health() Health { for _, r := range st.Refused { found = append(found, r.Why) } + for _, u := range st.Unreached { + found = append(found, sentence(u)) + } if st.ReloadError != "" { found = append(found, sentence("the kernel could not read the export files again: "+st.ReloadError)) } diff --git a/modules/nfs-server/module.json b/modules/nfs-server/module.json index d67cddde..5eace6e1 100644 --- a/modules/nfs-server/module.json +++ b/modules/nfs-server/module.json @@ -55,7 +55,7 @@ "grants": { "kind": "preference", "default": "none", - "why": "which nodes may mount which share, and how, is this machine's to say (hq ADR 0263, review of mesh-catalog #136): share=node:rw|ro[,node:rw|ro], entries separated by spaces; none exports to no node, whatever a node wants" + "why": "which nodes may mount which share, and how, is this machine's to say (hq ADR 0263, review of mesh-catalog #136): share=node:rw|ro[,node:rw|ro], entries separated by spaces; none exports to no node, whatever a node wants. local-network in a node's place grants this machine's local network, the private subnets on its uplink (hq ADR 0341)" } }, "facts": { @@ -74,7 +74,7 @@ "protocol": "tcp", "from": "mesh", "fixed": true, - "why": "the shares, to the mesh's machines only (hq ADR 0263): NFS version 4 alone, which needs no other port, and never the home network, where a device that is not a node could claim any user id" + "why": "the shares, to the mesh's machines (hq ADR 0263): NFS version 4 alone, which needs no other port. A machine whose grants name local-network binds this port with the reach local-network (hq ADR 0340, ADR 0341), and the module exports to a local subnet only while the filter admits it from there" } ], "resources": [ @@ -154,7 +154,7 @@ "type": "file", "path": "/etc/nfs-server/shares.conf", "mode": "0644", - "content": "# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment.\n# Replaced on every push; change the `shares` setting, never this file.\n#\n# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…],\n# which nodes may mount each and how. The module's process exports a share to a node's private address\n# only when it is granted here and that node's mounts module wants it, every client mapped to the\n# folder's owner, and only to addresses inside the range below.\nshares=${setting:shares}\ngrants=${setting:grants}\nrange=${machine:mesh-range}\n" + "content": "# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment.\n# Replaced on every push; change the `shares` setting, never this file.\n#\n# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…],\n# which nodes may mount each and how; local-network in a node's place grants this machine's local\n# network (novox/hq ADR 0341). The module's process exports a share to a node's private address only\n# when it is granted here and that node's mounts module wants it, and to a local subnet only when the\n# packet filter admits 2049 from it; every client mapped to the folder's owner.\nshares=${setting:shares}\ngrants=${setting:grants}\nrange=${machine:mesh-range}\n" } ], "build": { diff --git a/modules/smb-server/cmd/smb-server/conf.go b/modules/smb-server/cmd/smb-server/conf.go new file mode 100644 index 00000000..02f9390a --- /dev/null +++ b/modules/smb-server/cmd/smb-server/conf.go @@ -0,0 +1,288 @@ +package main + +import ( + "fmt" + "path" + "sort" + "strings" +) + +// The shares and logins, from the assignment, the hand-written configuration as found, and the module's +// rendering (novox/hq ADR 0341 clauses 2 to 4). +// +// **One share per folder, every client as its owner.** A share is a name and a path in the `shares` +// setting (`library=/srv/library`, `:ro` for read-only). It is served with `force user` and `force group` +// read from the real folder's owner, never written as numbers in a setting, so a file written by any client +// lands owned by the folder's owner. The refusals are ADR 0263's: the machine's root, a folder not there or +// not a folder, a folder owned by root or root's group, and a folder inside or holding a backup repository. +// +// **SMB 2.1 and later, on 445 alone.** NetBIOS is off (`disable netbios`, `smb ports = 445`), so 137, 138 and +// 139 are never served; the minimum protocol is SMB2_10. No guest: `map to guest = never`. + +// Share is one share the operator asked for. +type Share struct { + Name string `json:"name"` + Path string `json:"path"` + Access string `json:"access"` +} + +// Config is what the mesh rendered from the assignment. +type Config struct { + Shares []Share + Users []string + // Passwords is the folder the mesh places the logins' secrets in (smb-password-.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() +} diff --git a/modules/smb-server/cmd/smb-server/machine.go b/modules/smb-server/cmd/smb-server/machine.go new file mode 100644 index 00000000..1d05af0b --- /dev/null +++ b/modules/smb-server/cmd/smb-server/machine.go @@ -0,0 +1,279 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +// The machine as the module sees it (novox/hq ADR 0341). +// +// **Two processes, two accounts.** The tools run as the operator account (ADR 0175 §4) and read; what only +// root may do (`smbcontrol`, `smbstatus`) goes through `sudo -n`. The module's long-running process runs as +// root: it judges the shares, and once a person adopted the configuration it writes Samba's configuration +// file whole, sets the logins' passwords from their secrets, and stops NetBIOS. +// +// **It writes its own files and nothing else.** Samba's configuration (only after adoption), its dated copy +// of the hand-written one, the status, and the record of which passwords it set. Nothing here can create, +// chown, chmod or remove anything under a shared path: the machine offers no such call, and Write refuses +// every path but the module's (novox/hq ADR 0051). + +// The module's files on the machine. +const ( + // ConfigFile is what the mesh renders from the assignment: the shares, the logins, where the secrets are. + ConfigFile = "/etc/smb-server/shares.conf" + // SambaConf is Samba's configuration: the hand-written one until adoption, the module's after. + SambaConf = "/etc/samba/smb.conf" + // Candidate is the module's rendering, checked with testparm before it takes SambaConf's place. + Candidate = "/etc/samba/smb.conf.mesh-new" + // HandCopyPrefix begins the dated copy of the hand-written configuration kept at adoption. + HandCopyPrefix = "/etc/samba/smb.conf.hand-written-" + // StatusFile is the process's last look, for the tools. + StatusFile = "/run/smb-server/status.json" + // AdoptDir is where the adopt verb, as the operator account, leaves a person's word for the process. + AdoptDir = "/var/lib/smb-server/adopt" + // AdoptFile is that word: when, and why. + AdoptFile = AdoptDir + "/adopt.json" + // SetFile is which password the process last set for each login, as a digest, readable by root only. + SetFile = "/var/lib/smb-server/passwords.json" + // Unit is Samba's service; NMBUnit its NetBIOS name service, stopped once the module takes over. + Unit = "smb.service" + NMBUnit = "nmb.service" + // Port is the one port SMB is served on. + Port = 445 +) + +// Marker is the first line of a configuration the module wrote: how it tells its own from a person's. +const Marker = "# Written by the mesh (module smb-server, novox/hq ADR 0341)." + +// writable is every file the module may write. +func writable(p string) bool { + switch p { + case SambaConf, Candidate, StatusFile, AdoptFile, SetFile: + return true + } + return strings.HasPrefix(p, HandCopyPrefix) && !strings.Contains(strings.TrimPrefix(p, HandCopyPrefix), "/") +} + +// CommandTimeout bounds every command. +const CommandTimeout = 20 * time.Second + +// Ran is what one command did. +type Ran struct { + Out string + Err string + Code int +} + +// Runner runs one command, with what it reads on stdin; injected, so everything is tested over recorded +// answers. +type Runner func(ctx context.Context, input, name string, args ...string) Ran + +// ExecRunner runs a command on the machine, bounded by CommandTimeout. +func ExecRunner(ctx context.Context, input, name string, args ...string) Ran { + ctx, cancel := context.WithTimeout(ctx, CommandTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + if input != "" { + cmd.Stdin = strings.NewReader(input) + } + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + r := Ran{Out: stdout.String(), Err: strings.TrimSpace(stderr.String())} + if err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) { + r.Code = exit.ExitCode() + } else { + r.Code = -1 + if r.Err == "" { + r.Err = err.Error() + } + } + } + return r +} + +// Owner is who owns a path, and whether it is a directory. +type Owner struct { + UID, GID int + Dir bool +} + +// Machine is a filesystem root (the real one, or a test's tree), a runner, who this process is, and a clock. +type Machine struct { + Root string + Run Runner + UID int + Now func() time.Time + // ownerOf reads a file's owner from its inode; tests set it so their folders stand for an owner. + ownerOf func(info os.FileInfo) (uid, gid int) +} + +// Here is this machine. +func Here() *Machine { + return &Machine{Root: "/", Run: ExecRunner, UID: os.Getuid(), Now: time.Now, ownerOf: inodeOwner} +} + +func (m *Machine) at(p string) string { return filepath.Join(m.Root, p) } + +// Read reads a file. +func (m *Machine) Read(p string) (string, error) { + b, err := os.ReadFile(m.at(p)) + return string(b), err +} + +// Exists says whether a path is there. +func (m *Machine) Exists(p string) bool { + _, err := os.Lstat(m.at(p)) + return err == nil +} + +// Stat is a path's owner and whether it is a folder. +func (m *Machine) Stat(p string) (Owner, error) { + info, err := os.Stat(m.at(p)) + if err != nil { + return Owner{}, err + } + of := m.ownerOf + if of == nil { + of = inodeOwner + } + uid, gid := of(info) + return Owner{UID: uid, GID: gid, Dir: info.IsDir()}, nil +} + +// Real is a path with every symbolic link followed, as the machine sees it. +func (m *Machine) Real(p string) (string, error) { + real, err := filepath.EvalSymlinks(m.at(p)) + if err != nil { + return "", err + } + rel, err := filepath.Rel(m.Root, real) + if err != nil || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("%s leads outside the machine", p) + } + return path.Join("/", filepath.ToSlash(rel)), nil +} + +// Children are a folder's subfolders, by name; symbolic links are not followed. +func (m *Machine) Children(p string) []string { + entries, err := os.ReadDir(m.at(p)) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if e.Type()&fs.ModeSymlink == 0 && e.IsDir() { + out = append(out, e.Name()) + } + } + return out +} + +// Write writes one of the module's own files, and refuses every other path. +func (m *Machine) Write(p, content string, mode os.FileMode) error { + if !writable(p) { + return fmt.Errorf("%s is not one of the module's files; it writes nothing else", p) + } + if err := os.MkdirAll(filepath.Dir(m.at(p)), 0o755); err != nil { + return err + } + tmp := m.at(p) + ".tmp" + if err := os.WriteFile(tmp, []byte(content), mode); err != nil { + return err + } + return os.Rename(tmp, m.at(p)) +} + +// Place moves the checked candidate into Samba's configuration's place. +func (m *Machine) Place() error { return os.Rename(m.at(Candidate), m.at(SambaConf)) } + +// NameOf is the account or group name for an id, read from the machine's passwd or group file. +func (m *Machine) NameOf(file string, id int) string { + text, _ := m.Read(file) + for _, line := range strings.Split(text, "\n") { + f := strings.Split(line, ":") + if len(f) >= 3 && f[2] == strconv.Itoa(id) { + return f[0] + } + } + return "" +} + +// HasAccount says whether the machine has an account of that name. +func (m *Machine) HasAccount(name string) bool { + text, _ := m.Read("/etc/passwd") + for _, line := range strings.Split(text, "\n") { + if f := strings.SplitN(line, ":", 2); len(f) == 2 && f[0] == name { + return true + } + } + return false +} + +// Escalated is a command as the operator account runs what only root may: through sudo without a prompt. +func (m *Machine) Escalated(name string, args ...string) (string, []string) { + if m.UID == 0 { + return name, args + } + return "sudo", append([]string{"-n", name}, args...) +} + +// AsRoot runs what only root may do, and names a refusal by how it failed. +func (m *Machine) AsRoot(ctx context.Context, input, name string, args ...string) (Ran, error) { + program, argv := m.Escalated(name, args...) + r := m.Run(ctx, input, program, argv...) + if r.Code != 0 { + why := r.Err + if why == "" { + why = strings.TrimSpace(r.Out) + } + return r, fmt.Errorf("%s %s: exit %d: %s", name, strings.Join(args, " "), r.Code, why) + } + return r, nil +} + +// UnitState is what the service manager says of a unit: active, inactive, failed, … +func (m *Machine) UnitState(unit string) string { + r := m.Run(context.Background(), "", "systemctl", "is-active", unit) + if s := strings.TrimSpace(r.Out); s != "" { + return s + } + return "unknown" +} + +// UnitEnabled is what the service manager says of a unit's boot: enabled, disabled, … +func (m *Machine) UnitEnabled(unit string) string { + r := m.Run(context.Background(), "", "systemctl", "is-enabled", unit) + if s := strings.TrimSpace(r.Out); s != "" { + return s + } + return "unknown" +} + +func inodeOwner(info os.FileInfo) (uid, gid int) { + if st, ok := info.Sys().(*syscall.Stat_t); ok { + return int(st.Uid), int(st.Gid) + } + return -1, -1 +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/modules/smb-server/cmd/smb-server/main.go b/modules/smb-server/cmd/smb-server/main.go new file mode 100644 index 00000000..f5a47ff5 --- /dev/null +++ b/modules/smb-server/cmd/smb-server/main.go @@ -0,0 +1,38 @@ +// The smb-server module's Go bundle (novox/hq ADR 0341): a node serves folders over SMB to the devices on its +// own network, one share per folder, every client as the folder's owner, SMB 2.1 and later on 445 alone. +// +// Started with no arguments it is served by the node's runtime as the operator account, over MCP on stdio +// (ADR 0188, ADR 0193): the node-smb-server seat's verbs and the module's health. Started as +// `smb-server config` it is the module's long-running process, as root (process.go). stdout is the MCP +// channel; everything else is said on stderr. +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + if len(os.Args) > 1 { + if os.Args[1] != "config" || len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: smb-server [config]") + os.Exit(2) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) + defer stop() + if err := Here().RunConfig(ctx); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + if err := stdio.Serve("", Tools(Here())); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/modules/smb-server/cmd/smb-server/process.go b/modules/smb-server/cmd/smb-server/process.go new file mode 100644 index 00000000..705f0bd8 --- /dev/null +++ b/modules/smb-server/cmd/smb-server/process.go @@ -0,0 +1,397 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "path" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/health" +) + +// The module's process, as root: every Every it judges the shares, and — only once a person adopted the +// configuration — writes Samba's configuration whole, checks it with testparm before Samba reads it, sets the +// logins' passwords from their secrets and stops NetBIOS (novox/hq ADR 0341 clauses 3 to 7). Before adoption +// it reads the hand-written configuration, reports it, and writes nothing but its status. + +// Every is how often the process looks. +const Every = 30 * time.Second + +// Adoption is a person's word to take over, left by the adopt verb. +type Adoption struct { + At string `json:"at"` + Why string `json:"why"` + // Copy is the dated copy of the hand-written configuration, once the process kept it. + Copy string `json:"copy,omitempty"` +} + +// Login is one login of the `users` setting, as the process found it. +type Login struct { + Name string `json:"name"` + // Password is "set" (from its secret), "kept as found" (in Samba's database, no secret given), + // "waits" (no secret, not in Samba's database) or "refused" (why says). + Password string `json:"password"` + Why string `json:"why,omitempty"` +} + +// Status is the process's last look, as the tools read it. +type Status struct { + At string `json:"at"` + // Mode is "found" (the hand-written configuration serves, kept as found) or "adopted" (the module's). + Mode string `json:"mode"` + Shares []Served `json:"shares"` + Refused []Refused `json:"refused,omitempty"` + Hand []HandShare `json:"hand_written,omitempty"` + Logins []Login `json:"logins,omitempty"` + Problems []string `json:"problems,omitempty"` + Waits []health.Wait `json:"waits,omitempty"` + Adopted *Adoption `json:"adopted,omitempty"` + // Written is when the process last wrote Samba's configuration, and Reloaded when Samba last read it. + Written string `json:"written,omitempty"` + Reloaded string `json:"reloaded,omitempty"` + NetBIOS string `json:"netbios,omitempty"` +} + +// WalkDepth and WalkLimit bound the look below a share for a backup repository (ADR 0263 §3). +const ( + WalkDepth = 4 + WalkLimit = 200000 +) + +// isRepository says whether a folder is a restic repository: `config` beside `keys`, `snapshots`, `data`. +func (m *Machine) isRepository(dir string) bool { + if o, err := m.Stat(path.Join(dir, "config")); err != nil || o.Dir { + return false + } + for _, sub := range []string{"keys", "snapshots", "data"} { + if o, err := m.Stat(path.Join(dir, sub)); err != nil || !o.Dir { + return false + } + } + return true +} + +// repositoryNear finds a backup repository at, above or within WalkDepth levels below a folder; complete is +// false when the look below stopped at its bound. +func (m *Machine) repositoryNear(real string) (found string, complete bool) { + for p := real; ; p = path.Dir(p) { + if m.isRepository(p) { + return p, true + } + if p == "/" { + break + } + } + seen, level := 0, []string{real} + for depth := 1; depth <= WalkDepth && len(level) > 0; depth++ { + var next []string + for _, d := range level { + for _, c := range m.Children(d) { + if seen++; seen > WalkLimit { + return "", false + } + p := path.Join(d, c) + if m.isRepository(p) { + return p, true + } + next = append(next, p) + } + } + level = next + } + return "", true +} + +// Judge turns the asked-for shares into served shares, or refusals. +func (m *Machine) Judge(c Config) ([]Served, []Refused) { + var out []Served + var refused []Refused + for _, s := range c.Shares { + no := func(why string) { refused = append(refused, Refused{Name: s.Name, Path: s.Path, Why: why}) } + if !path.IsAbs(s.Path) || strings.ContainsAny(s.Path, "\n\r\"\\") { + no(fmt.Sprintf("The share %s names %q, which is not an absolute path a configuration can carry.", s.Name, s.Path)) + continue + } + real, err := m.Real(path.Clean(s.Path)) + if err != nil { + no(fmt.Sprintf("The share %s's folder %s is not there (%v); the module never creates it.", s.Name, s.Path, err)) + continue + } + if real == "/" { + no(fmt.Sprintf("The share %s is the machine's root, which is never shared.", s.Name)) + continue + } + o, err := m.Stat(real) + if err != nil || !o.Dir { + no(fmt.Sprintf("The share %s's path %s is not a folder.", s.Name, real)) + continue + } + if o.UID == 0 || o.GID == 0 { + no(fmt.Sprintf("The share %s's folder %s is owned by root (%d:%d), and every client would act as root there: give it an owner of its own.", s.Name, real, o.UID, o.GID)) + continue + } + if p, complete := m.repositoryNear(real); p != "" { + no(fmt.Sprintf("The share %s's folder %s is at, inside or above the backup repository %s, and backups are never shared.", s.Name, real, p)) + continue + } else if !complete { + no(fmt.Sprintf("The share %s's folder %s holds more folders than the module looks through (%d levels, at most %d), so it could not make sure no backup repository is under it.", s.Name, real, WalkDepth, WalkLimit)) + continue + } + user, group := m.NameOf("/etc/passwd", o.UID), m.NameOf("/etc/group", o.GID) + if user == "" || group == "" { + no(fmt.Sprintf("The share %s's folder %s is owned by %d:%d, which this machine names no account or group for, so no client can be made its owner.", s.Name, real, o.UID, o.GID)) + continue + } + if len(c.Users) == 0 { + no(fmt.Sprintf("The share %s is not served: the setting users names no login.", s.Name)) + continue + } + out = append(out, Served{Name: s.Name, Path: real, Access: s.Access, UID: o.UID, GID: o.GID, User: user, Group: group, Users: c.Users}) + } + return out, refused +} + +// PasswordSecret is the secret holding a login's password. +func PasswordSecret(user string) string { return "smb-password-" + user } + +// readSecret reads a login's password from its secret: one line; absent is not given. +func (m *Machine) readSecret(dir, user string) (string, bool, error) { + if dir == "" || !path.IsAbs(dir) { + return "", false, fmt.Errorf("the mesh gave no folder for the logins' secrets") + } + raw, err := m.Read(path.Join(dir, PasswordSecret(user)+".secret")) + if err != nil { + if !m.Exists(path.Join(dir, PasswordSecret(user)+".secret")) { + return "", false, nil + } + return "", false, err + } + pw := strings.TrimRight(raw, "\r\n") + if pw == "" || strings.ContainsAny(pw, "\r\n") { + return "", false, fmt.Errorf("its secret is not one line") + } + return pw, true, nil +} + +func digest(user, pw string) string { + h := sha256.Sum256([]byte(user + "\x00" + pw)) + return hex.EncodeToString(h[:]) +} + +// Logins judges every login of the setting and, when adopted, sets a password whose secret is new or changed +// (clause 5). A login in Samba's database with no secret given keeps its password; the module never removes +// or resets an entry by itself. +func (m *Machine) Logins(ctx context.Context, c Config, adopted bool) ([]Login, []health.Wait, []string) { + var logins []Login + var waits []health.Wait + var problems []string + set := map[string]string{} + if raw, err := m.Read(SetFile); err == nil { + _ = json.Unmarshal([]byte(raw), &set) + } + changed := false + for _, u := range c.Users { + l := Login{Name: u} + inDB := m.Run(ctx, "", "pdbedit", "-L", "-u", u).Code == 0 + pw, given, err := m.readSecret(c.Passwords, u) + switch { + case err != nil: + l.Password, l.Why = "refused", "its password "+PasswordSecret(u)+" could not be read: "+err.Error() + problems = append(problems, fmt.Sprintf("the login %s: %s", u, l.Why)) + case !given && inDB: + l.Password, l.Why = "kept as found", "no secret "+PasswordSecret(u)+" is given, so the password in Samba's database stands" + case !given: + l.Password, l.Why = "waits", "no secret "+PasswordSecret(u)+" is given and Samba's database has no such login" + waits = append(waits, health.Wait{Part: "the login " + u, Secret: PasswordSecret(u), What: "the password of the login " + u}) + case !m.HasAccount(u): + l.Password, l.Why = "refused", "this machine has no account "+u+"; the module creates no account" + problems = append(problems, fmt.Sprintf("the login %s: %s", u, l.Why)) + case !adopted: + l.Password, l.Why = "kept as found", "its secret is given and is set once the configuration is adopted" + case set[u] == digest(u, pw) && inDB: + l.Password = "set" + default: + // smbpasswd -s reads the new password twice from stdin; -a adds the login when it is not there. + args := []string{"-s", "-a", u} + if r := m.Run(ctx, pw+"\n"+pw+"\n", "smbpasswd", args...); r.Code != 0 { + l.Password, l.Why = "refused", "smbpasswd refused it: "+firstLine(r.Err) + problems = append(problems, fmt.Sprintf("the login %s: %s", u, l.Why)) + break + } + set[u], changed, l.Password = digest(u, pw), true, "set" + } + logins = append(logins, l) + } + if changed { + raw, _ := json.MarshalIndent(set, "", " ") + if err := m.Write(SetFile, string(raw)+"\n", 0o600); err != nil { + problems = append(problems, "which passwords were set could not be kept: "+err.Error()) + } + } + return logins, waits, problems +} + +// Pass is one look; what it did is in the status it answers, which it has written. +func (m *Machine) Pass(ctx context.Context) Status { + s := Status{At: m.Now().UTC().Format(time.RFC3339), Mode: "found", Shares: []Served{}} + text, err := m.Read(ConfigFile) + if err != nil { + s.Problems = append(s.Problems, fmt.Sprintf("the assignment's shares (%s) could not be read: %v", ConfigFile, err)) + } + c, problems := ParseConfig(text) + s.Problems = append(s.Problems, problems...) + served, refused := m.Judge(c) + s.Refused = refused + conf, _ := m.Read(SambaConf) + ours := strings.HasPrefix(conf, Marker) + if !ours { + s.Hand = HandShares(ParseSamba(conf), c.Shares) + } + var adoption *Adoption + if raw, err := m.Read(AdoptFile); err == nil { + var a Adoption + if json.Unmarshal([]byte(raw), &a) == nil && a.At != "" { + adoption = &a + } + } + if ours && adoption == nil { + // Written by the module before, and the word is gone: what it wrote stands, and is the module's. + adoption = &Adoption{At: "before", Why: "the configuration is already the module's"} + } + s.Adopted = adoption + if adoption != nil { + if !ours { + if missing := withoutCounterpart(s.Hand); len(missing) > 0 { + s.Problems = append(s.Problems, "the configuration is not taken over: "+strings.Join(missing, "; ")) + adoption = nil + } else if served == nil && len(c.Shares) > 0 { + s.Problems = append(s.Problems, "the configuration is not taken over: no share the settings name can be served") + adoption = nil + } + } + } + if adoption != nil { + s.Mode = "adopted" + m.takeOver(ctx, &s, conf, ours, served, adoption) + } else { + for i := range served { + served[i].Live = false + } + } + logins, waits, lp := m.Logins(ctx, c, s.Mode == "adopted") + s.Logins, s.Waits = logins, waits + s.Problems = append(s.Problems, lp...) + if s.Mode == "adopted" { + s.Shares = m.markLive(ctx, served) + } else { + s.Shares = served + } + raw, _ := json.MarshalIndent(s, "", " ") + if err := m.Write(StatusFile, string(raw)+"\n", 0o644); err != nil { + s.Problems = append(s.Problems, "the status file could not be written: "+err.Error()) + } + return s +} + +// withoutCounterpart is each hand-written share the settings do not take the place of, in words. +func withoutCounterpart(hand []HandShare) []string { + var out []string + for _, h := range hand { + if h.Counterpart == "" { + out = append(out, fmt.Sprintf("the hand-written share %s (%s) has no counterpart: %s", h.Name, h.Path, h.Why)) + } + } + return out +} + +// takeOver writes the module's configuration when it differs from what is there: the first time, a dated +// copy of the hand-written one is kept; every time, testparm checks the candidate before it takes the +// configuration's place and Samba reads it. Then NetBIOS is stopped. +func (m *Machine) takeOver(ctx context.Context, s *Status, conf string, ours bool, served []Served, a *Adoption) { + want := Render(served) + if conf != want { + if err := m.Write(Candidate, want, 0o644); err != nil { + s.Problems = append(s.Problems, "the module's configuration could not be written: "+err.Error()) + return + } + if r := m.Run(ctx, "", "testparm", "-s", m.at(Candidate)); r.Code != 0 { + s.Problems = append(s.Problems, "the module's configuration fails Samba's own check and is kept out of use: "+firstLine(r.Err)) + return + } + if !ours { + copyAt := HandCopyPrefix + m.Now().UTC().Format("20060102T150405Z") + if err := m.Write(copyAt, conf, 0o644); err != nil { + s.Problems = append(s.Problems, "the hand-written configuration could not be kept, so it is not replaced: "+err.Error()) + return + } + a.Copy = copyAt + if a.At != "before" { + raw, _ := json.MarshalIndent(a, "", " ") + _ = m.Write(AdoptFile, string(raw)+"\n", 0o644) + } + } + if err := m.Place(); err != nil { + s.Problems = append(s.Problems, "the module's configuration could not take its place: "+err.Error()) + return + } + s.Written = m.Now().UTC().Format(time.RFC3339) + if r := m.Run(ctx, "", "smbcontrol", "smbd", "reload-config"); r.Code != 0 { + s.Problems = append(s.Problems, "Samba did not read its configuration again: "+firstLine(r.Err)) + } else { + s.Reloaded = s.Written + } + } + if m.UnitState(NMBUnit) == "active" || m.UnitEnabled(NMBUnit) == "enabled" { + if r := m.Run(ctx, "", "systemctl", "disable", "--now", NMBUnit); r.Code != 0 { + s.Problems = append(s.Problems, "NetBIOS ("+NMBUnit+") could not be stopped: "+firstLine(r.Err)) + } + } + s.NetBIOS = m.UnitState(NMBUnit) +} + +// markLive marks each share Samba serves now, as testparm reads the configuration in use. +func (m *Machine) markLive(ctx context.Context, served []Served) []Served { + r := m.Run(ctx, "", "testparm", "-s", m.at(SambaConf)) + live := map[string]bool{} + if r.Code == 0 { + for _, sec := range ParseSamba(r.Out) { + live[strings.ToLower(sec.Name)] = true + } + } + for i := range served { + served[i].Live = live[strings.ToLower(served[i].Name)] && m.UnitState(Unit) == "active" + } + return served +} + +// RunConfig is the process: a pass now and every Every until the context ends. +func (m *Machine) RunConfig(ctx context.Context) error { + m.Pass(ctx) + t := time.NewTicker(Every) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-t.C: + m.Pass(ctx) + } + } +} + +// LastStatus is the process's last look, or why there is none. +func (m *Machine) LastStatus() (*Status, error) { + text, err := m.Read(StatusFile) + if err != nil { + return nil, fmt.Errorf("the module's process has written no status (%s): %v", StatusFile, err) + } + var s Status + if err := json.Unmarshal([]byte(text), &s); err != nil { + return nil, fmt.Errorf("the module's status file is not one: %v", err) + } + return &s, nil +} diff --git a/modules/smb-server/cmd/smb-server/smb_test.go b/modules/smb-server/cmd/smb-server/smb_test.go new file mode 100644 index 00000000..1b9ff01f --- /dev/null +++ b/modules/smb-server/cmd/smb-server/smb_test.go @@ -0,0 +1,301 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Defends novox/hq ADR 0341 clauses 2 to 7 and 11 to 13: an SMB share is served as its folder's owner, SMB +// 2.1 and later on 445 alone with NetBIOS off; before a person adopts it the hand-written configuration is +// kept as found and nothing is written; adoption keeps a dated copy, writes the module's configuration only +// once testparm passes it, and refuses while a hand-written share has no counterpart; a login without its +// secret keeps its password, and one with it is set once. + +type fake struct { + t *testing.T + root string + answers map[string]Ran + ran []string + inputs map[string]string + now time.Time +} + +func newFake(t *testing.T) *fake { + f := &fake{t: t, root: t.TempDir(), answers: map[string]Ran{}, inputs: map[string]string{}, now: time.Date(2026, 10, 13, 9, 30, 0, 0, time.UTC)} + f.answers["systemctl is-active smb.service"] = Ran{Out: "active\n"} + f.answers["systemctl is-active nmb.service"] = Ran{Out: "active\n"} + f.answers["systemctl is-enabled nmb.service"] = Ran{Out: "enabled\n"} + f.answers["pdbedit -L -u media"] = Ran{Out: "media:1001:\n"} + f.file("/etc/passwd", "root:x:0:0::/root:/bin/sh\nmedia:x:1001:2000::/home/media:/bin/sh\n") + f.file("/etc/group", "root:x:0:\nmedia:x:2000:\n") + return f +} + +func (f *fake) machine() *Machine { + return &Machine{Root: f.root, UID: 0, Now: func() time.Time { return f.now }, + ownerOf: func(info os.FileInfo) (int, int) { + if strings.HasPrefix(info.Name(), "rootowned") { + return 0, 0 + } + return 1001, 2000 + }, + Run: func(_ context.Context, input, name string, args ...string) Ran { + line := strings.Join(append([]string{name}, args...), " ") + line = strings.ReplaceAll(line, f.root, "") + f.ran = append(f.ran, line) + if input != "" { + f.inputs[line] = input + } + if line == "systemctl disable --now nmb.service" { + f.answers["systemctl is-active nmb.service"] = Ran{Out: "inactive\n"} + f.answers["systemctl is-enabled nmb.service"] = Ran{Out: "disabled\n"} + } + if strings.HasPrefix(line, "testparm -s ") { + if r, ok := f.answers["testparm"]; ok { + return r + } + return Ran{Out: f.read(strings.TrimPrefix(line, "testparm -s "))} + } + return f.answers[line] + }} +} + +func (f *fake) dir(p string) { + if err := os.MkdirAll(filepath.Join(f.root, p), 0o755); err != nil { + f.t.Fatal(err) + } +} + +func (f *fake) file(p, content string) { + f.dir(filepath.Dir(p)) + if err := os.WriteFile(filepath.Join(f.root, p), []byte(content), 0o644); err != nil { + f.t.Fatal(err) + } +} + +func (f *fake) read(p string) string { + b, _ := os.ReadFile(filepath.Join(f.root, p)) + return string(b) +} + +func (f *fake) ran1(prefix string) []string { + var out []string + for _, r := range f.ran { + if strings.HasPrefix(r, prefix) { + out = append(out, r) + } + } + return out +} + +// handWritten is the home server's configuration as issue 169's survey found it. +const handWritten = "[global]\n workgroup = WORKGROUP\n server string = home\n\n[media]\n path = /storage/media/\n valid users = media\n writable = yes\n" + +func (f *fake) home(shares, users string) { + f.dir("/storage/media") + f.file(ConfigFile, "shares="+shares+"\nusers="+users+"\npasswords=/var/lib/mesh/smb-server/state\n") + f.file(SambaConf, handWritten) +} + +func TestTheRenderingServesAsTheOwnerOnFourFortyFiveAloneWithoutNetBIOS(t *testing.T) { + text := Render([]Served{{Name: "media", Path: "/storage/media", Access: "rw", User: "media", Group: "media", Users: []string{"media"}}}) + for _, want := range []string{Marker, "server min protocol = SMB2_10", "disable netbios = yes", "smb ports = 445", "map to guest = never", + "[media]", "path = /storage/media", "read only = no", "valid users = media", "force user = media", "force group = media"} { + if !strings.Contains(text, want) { + t.Errorf("the rendering lacks %q:\n%s", want, text) + } + } + if strings.Contains(text, "139") || strings.Contains(text, "1001") { + t.Errorf("the rendering names 139, or an owner as a number:\n%s", text) + } +} + +func TestAShareIsRefusedAtRootRootOwnedMissingOrNearABackupAndNothingIsCreated(t *testing.T) { + f := newFake(t) + f.dir("/srv/rootowned") + f.dir("/srv/pool/library") + f.file("/srv/pool/library/deep/repo/config", "x") + for _, d := range []string{"keys", "snapshots", "data"} { + f.dir("/srv/pool/library/deep/repo/" + d) + } + f.dir("/srv/ok") + c, _ := ParseConfig("shares=root=/ owned=/srv/rootowned gone=/srv/gone lib=/srv/pool/library ok=/srv/ok:ro\nusers=media\n") + served, refused := f.machine().Judge(c) + why := map[string]string{} + for _, r := range refused { + why[r.Name] = r.Why + } + for name, says := range map[string]string{"root": "machine's root", "owned": "owned by root", "gone": "not there", "lib": "backup repository"} { + if !strings.Contains(why[name], says) { + t.Errorf("%s is not refused as %q: %q", name, says, why[name]) + } + } + if len(served) != 1 || served[0].Name != "ok" || served[0].Access != "ro" || served[0].User != "media" { + t.Errorf("served: %+v", served) + } + if f.machine().Exists("/srv/gone") { + t.Error("the module created a missing folder") + } +} + +func TestBeforeAdoptionTheHandWrittenConfigurationIsKeptAndNothingIsWritten(t *testing.T) { + f := newFake(t) + f.home("media=/storage/media", "media") + s := f.machine().Pass(context.Background()) + if s.Mode != "found" || f.read(SambaConf) != handWritten { + t.Fatalf("mode %s; the hand-written configuration changed:\n%s", s.Mode, f.read(SambaConf)) + } + if len(s.Hand) != 1 || s.Hand[0].Counterpart != "media" || s.Hand[0].ReadOnly { + t.Errorf("the hand-written share is not matched: %+v", s.Hand) + } + for _, r := range f.ran { + if strings.HasPrefix(r, "smbcontrol") || strings.HasPrefix(r, "systemctl disable") || strings.HasPrefix(r, "smbpasswd") { + t.Errorf("before adoption the process ran %q", r) + } + } + if s.Logins[0].Password != "kept as found" { + t.Errorf("the login: %+v", s.Logins) + } + if h := f.machine().Health(); !h.Healthy || !strings.Contains(h.Why, "adopt") { + t.Errorf("health before adoption: %+v", h) + } +} + +func TestAdoptRefusesWhileAHandWrittenShareHasNoCounterpart(t *testing.T) { + f := newFake(t) + f.home("library=/storage/media", "media") + f.machine().Pass(context.Background()) + if _, err := f.machine().Adopt(true, "take it over"); err == nil || !strings.Contains(err.Error(), "no share disappears") { + t.Fatalf("adopt with a hand-written share the settings do not name: %v", err) + } + if f.machine().Exists(AdoptFile) { + t.Error("a refused adopt left the word for the process") + } + f.file(ConfigFile, "shares=media=/storage/media\nusers=media\npasswords=/x\n") + f.machine().Pass(context.Background()) + dry, err := f.machine().Adopt(false, "") + if err != nil || !dry.DryRun || len(dry.Beside) != 1 || !strings.Contains(dry.Beside[0], "every client as media:media") { + t.Fatalf("dry run: %+v %v", dry, err) + } + if f.machine().Exists(AdoptFile) { + t.Error("a dry run left the word for the process") + } + if _, err := f.machine().Adopt(true, ""); err == nil { + t.Error("adopt with confirm and no why was done") + } +} + +func TestAdoptionKeepsADatedCopyChecksAndThenStopsNetBIOS(t *testing.T) { + f := newFake(t) + f.home("media=/storage/media", "media") + f.machine().Pass(context.Background()) + f.file(AdoptFile, `{"at":"2026-10-13T09:31:00Z","why":"the operator keeps the share"}`) + f.ran = nil + s := f.machine().Pass(context.Background()) + if s.Mode != "adopted" || len(s.Problems) != 0 { + t.Fatalf("status: %+v", s) + } + copyAt := HandCopyPrefix + "20261013T093000Z" + if f.read(copyAt) != handWritten { + t.Errorf("the dated copy is not the hand-written configuration: %q", f.read(copyAt)) + } + if !strings.HasPrefix(f.read(SambaConf), Marker) || !strings.Contains(f.read(SambaConf), "force user = media") { + t.Errorf("the module's configuration is not in place:\n%s", f.read(SambaConf)) + } + want := []string{"testparm -s " + Candidate, "smbcontrol smbd reload-config", "systemctl disable --now nmb.service"} + for _, w := range want { + if len(f.ran1(w)) != 1 { + t.Errorf("%q ran %d times: %v", w, len(f.ran1(w)), f.ran) + } + } + if !s.Shares[0].Live || s.NetBIOS != "inactive" { + t.Errorf("served: %+v netbios %s", s.Shares, s.NetBIOS) + } + // The next pass finds its own configuration and writes nothing again. + f.ran = nil + f.machine().Pass(context.Background()) + if len(f.ran1("smbcontrol")) != 0 || len(f.ran1("systemctl disable")) != 0 { + t.Errorf("an unchanged configuration was written or reloaded again: %v", f.ran) + } +} + +func TestAConfigurationFailingTestparmIsKeptOutOfUse(t *testing.T) { + f := newFake(t) + f.home("media=/storage/media", "media") + f.file(AdoptFile, `{"at":"2026-10-13T09:31:00Z","why":"take over"}`) + f.answers["testparm"] = Ran{Code: 1, Err: "Unknown parameter encountered"} + s := f.machine().Pass(context.Background()) + if f.read(SambaConf) != handWritten { + t.Errorf("a configuration testparm refused took the hand-written one's place") + } + if len(f.ran1("smbcontrol")) != 0 || !strings.Contains(strings.Join(s.Problems, " "), "fails Samba's own check") { + t.Errorf("not said, or reloaded: %v %v", s.Problems, f.ran) + } + if h := f.machine().Health(); h.Healthy { + t.Errorf("healthy with a refused configuration: %+v", h) + } +} + +func TestALoginKeepsItsPasswordWithoutASecretWaitsWhenNewAndIsSetOnce(t *testing.T) { + f := newFake(t) + f.home("media=/storage/media", "media guest2") + f.answers["pdbedit -L -u guest2"] = Ran{Code: 255} + f.file(AdoptFile, `{"at":"2026-10-13T09:31:00Z","why":"take over"}`) + s := f.machine().Pass(context.Background()) + got := map[string]string{} + for _, l := range s.Logins { + got[l.Name] = l.Password + } + if got["media"] != "kept as found" || got["guest2"] != "waits" || len(f.ran1("smbpasswd")) != 0 { + t.Fatalf("logins %v; smbpasswd ran %v", got, f.ran1("smbpasswd")) + } + h := f.machine().Health() + if h.State != "waits" || len(h.Waits) != 1 || h.Waits[0].Secret != "smb-password-guest2" { + t.Errorf("health: %+v", h) + } + // The secret given: set once from stdin, never on the command line, and not again while unchanged. + f.file("/etc/passwd", f.read("/etc/passwd")+"guest2:x:1002:2000::/:/bin/sh\n") + f.file("/var/lib/mesh/smb-server/state/smb-password-guest2.secret", "s3cret\n") + f.machine().Pass(context.Background()) + runs := f.ran1("smbpasswd") + if len(runs) != 1 || runs[0] != "smbpasswd -s -a guest2" || f.inputs[runs[0]] != "s3cret\ns3cret\n" { + t.Fatalf("smbpasswd: %v %q", runs, f.inputs) + } + f.answers["pdbedit -L -u guest2"] = Ran{Out: "guest2:1002:\n"} + f.machine().Pass(context.Background()) + if len(f.ran1("smbpasswd")) != 1 { + t.Errorf("an unchanged password was set again: %v", f.ran1("smbpasswd")) + } + var set map[string]string + _ = json.Unmarshal([]byte(f.read(SetFile)), &set) + if strings.Contains(f.read(SetFile), "s3cret") || set["guest2"] == "" { + t.Errorf("the record of set passwords holds the password, or nothing: %s", f.read(SetFile)) + } + if info, err := os.Stat(filepath.Join(f.root, SetFile)); err != nil || info.Mode().Perm() != 0o600 { + t.Errorf("the record of set passwords is not root's alone: %v", info.Mode()) + } +} + +func TestTheModuleWritesOnlyItsOwnFiles(t *testing.T) { + m := newFake(t).machine() + for _, p := range []string{"/etc/passwd", "/storage/media/x", "/etc/samba/other.conf", HandCopyPrefix + "x/../../evil"} { + if err := m.Write(p, "x", 0o644); err == nil { + t.Errorf("wrote %s", p) + } + } +} + +func TestParseSambaReadsSectionsAndAccess(t *testing.T) { + secs := ParseSamba("; c\n[global]\nworkgroup = W\n[a]\n path = /x\n read only = no\n[b]\n path = /y\n[c]\n path = /z\n writeable = yes\n") + if len(secs) != 4 || secs[1].Params["path"] != "/x" { + t.Fatalf("%+v", secs) + } + if readOnly(secs[1].Params) || !readOnly(secs[2].Params) || readOnly(secs[3].Params) { + t.Error("read only is not read as Samba reads it") + } +} diff --git a/modules/smb-server/cmd/smb-server/tools.go b/modules/smb-server/cmd/smb-server/tools.go new file mode 100644 index 00000000..3711309c --- /dev/null +++ b/modules/smb-server/cmd/smb-server/tools.go @@ -0,0 +1,366 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/health" +) + +// Seat is the role this module holds on its machine (novox/hq ADR 0341). +const Seat = "node-smb-server" + +// Stale is how old the process's last look may be before the tools say it stopped looking. +const Stale = 5 * time.Minute + +func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool { + return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run} +} + +func str(description string) map[string]any { + return map[string]any{"type": "string", "description": description} +} + +func arg(a map[string]any, k string) string { + v, _ := a[k].(string) + return strings.TrimSpace(v) +} + +// confirmed says whether an act was confirmed: the boolean true, or the string "true" the seat's schema passes. +func confirmed(a map[string]any) bool { + switch v := a["confirm"].(type) { + case bool: + return v + case string: + return strings.EqualFold(strings.TrimSpace(v), "true") + } + return false +} + +// Tools are the seat's verbs and the module's health. +func Tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + verb("shares", "Every SMB share this machine serves: its name, folder, rw or ro, the owner every client acts as "+ + "(uid, gid, names), the logins allowed and whether each login's password was set from its secret, is kept "+ + "as found or waits for it, and whether Samba serves it now; whether the configuration is the mesh's or still "+ + "the hand-written one, and each hand-written share with whether the settings take its place; the shares "+ + "refused, and why. A password is never shown. Replaces testparm -s, cat /etc/samba/smb.conf and pdbedit -L. (r)", + map[string]any{}, func(map[string]any) (any, error) { return m.Shares(), nil }), + verb("sessions", "Which devices are connected to which share now, with the login and protocol each uses, as "+ + "Samba knows them (smbstatus, as root). Replaces smbstatus. (r)", + map[string]any{}, func(map[string]any) (any, error) { return m.Sessions(context.Background()) }), + verb("test", "Whether this machine serves one share now: asked for and not refused, served by Samba, Samba "+ + "active, and 445 listening. Replaces smbclient -L. (r)", + map[string]any{"share": str("the share's name")}, + func(a map[string]any) (any, error) { return m.Test(context.Background(), arg(a, "share")) }), + verb("reload", "Have Samba read its configuration again now (smbcontrol smbd reload-config, as root). The "+ + "module's process does this itself whenever it writes the configuration; this is for after a change made "+ + "outside it. Changes no shared folder. Replaces smbcontrol smbd reload-config. (a)", + map[string]any{}, func(map[string]any) (any, error) { return m.Reload(context.Background()) }), + verb("adopt", "Take over Samba's hand-written configuration: the module's process keeps a dated copy of it "+ + "beside it and writes the mesh's own in its place, checked with testparm, then stops NetBIOS. Only when every "+ + "hand-written share has its counterpart in the setting shares (same name, same folder), so no share "+ + "disappears. Without confirm, a dry run: each hand-written share beside the module's rendering of it. A "+ + "person's act, with why. Replaces edit /etc/samba/smb.conf. (a)", + map[string]any{ + "confirm": str("\"true\" to do it; anything else, a dry run"), + "why": str("why, in a sentence (required with confirm)"), + }, + func(a map[string]any) (any, error) { return m.Adopt(confirmed(a), arg(a, "why")) }), + { + Name: "smb_health", + Description: "Whether this machine's SMB shares are served, as the module's health check (novox/hq ADR 0240, " + + "ADR 0341): unhealthy when Samba is not active, the module's process has not looked for five minutes, a " + + "share is refused or not served, or the configuration fails Samba's check; waits when a login's password " + + "is its secret not yet given; with why. (r)", + Run: func(map[string]any) (any, error) { return m.Health(), nil }, + }, + } +} + +// SharesAnswer is what `shares` answers. +type SharesAnswer struct { + Mode string `json:"configuration"` + Shares []Served `json:"shares"` + Refused []Refused `json:"refused,omitempty"` + Hand []HandShare `json:"hand_written,omitempty"` + Logins []Login `json:"logins,omitempty"` + Service string `json:"service"` + Looked string `json:"looked,omitempty"` + Said []string `json:"said"` + Notes []string `json:"notes,omitempty"` +} + +// Shares is the machine's SMB shares now. +func (m *Machine) Shares() SharesAnswer { + a := SharesAnswer{Shares: []Served{}, Service: m.UnitState(Unit)} + st, err := m.LastStatus() + if err != nil { + a.Notes = append(a.Notes, err.Error()) + a.Said = append(a.Said, "the module's process has not looked yet") + return a + } + a.Mode, a.Shares, a.Refused, a.Hand, a.Logins, a.Looked = st.Mode, st.Shares, st.Refused, st.Hand, st.Logins, st.At + a.Notes = st.Problems + if st.Mode == "found" { + a.Said = append(a.Said, "the configuration is the hand-written one, kept as found; the module takes it over by adopt, a person's act") + } else { + a.Said = append(a.Said, "the configuration is the module's") + } + for _, s := range st.Shares { + state := "served" + if !s.Live { + state = "NOT served" + } + a.Said = append(a.Said, fmt.Sprintf("%s: %s, %s, every client as %s:%s, for %s — %s", s.Name, s.Path, s.Access, s.User, s.Group, strings.Join(s.Users, " "), state)) + } + for _, h := range st.Hand { + if h.Counterpart != "" { + a.Said = append(a.Said, fmt.Sprintf("hand-written %s (%s): the setting's share %s takes its place", h.Name, h.Path, h.Counterpart)) + } else { + a.Said = append(a.Said, fmt.Sprintf("hand-written %s (%s): %s", h.Name, h.Path, h.Why)) + } + } + for _, l := range st.Logins { + a.Said = append(a.Said, fmt.Sprintf("login %s: password %s", l.Name, l.Password)) + } + for _, r := range st.Refused { + a.Said = append(a.Said, "refused: "+r.Why) + } + a.Said = append(a.Said, "Samba is "+a.Service) + return a +} + +// Sessions is what smbstatus says of the connected devices. +func (m *Machine) Sessions(ctx context.Context) (any, error) { + r, err := m.AsRoot(ctx, "", "smbstatus", "--json") + if err != nil { + return nil, err + } + var v struct { + Sessions map[string]struct { + Machine string `json:"remote_machine"` + Username string `json:"username"` + Dialect string `json:"session_dialect"` + } `json:"sessions"` + Tcons map[string]struct { + Service string `json:"service"` + Machine string `json:"machine"` + } `json:"tcons"` + } + if err := json.Unmarshal([]byte(r.Out), &v); err != nil { + return nil, fmt.Errorf("smbstatus answered what this cannot read: %v", err) + } + type session struct { + Device string `json:"device"` + Login string `json:"login"` + Protocol string `json:"protocol"` + Shares []string `json:"shares"` + } + var out []session + for _, s := range v.Sessions { + se := session{Device: s.Machine, Login: s.Username, Protocol: s.Dialect} + for _, t := range v.Tcons { + if t.Machine == s.Machine { + se.Shares = append(se.Shares, t.Service) + } + } + out = append(out, se) + } + return map[string]any{"sessions": out}, nil +} + +// TestAnswer is what `test` answers. +type TestAnswer struct { + Share string `json:"share"` + Served bool `json:"served"` + Service string `json:"service"` + Listening bool `json:"listening_445"` + Said string `json:"said"` +} + +// Test says whether one share is served now. +func (m *Machine) Test(ctx context.Context, share string) (TestAnswer, error) { + if share == "" { + return TestAnswer{}, fmt.Errorf("name the share") + } + a := TestAnswer{Share: share, Service: m.UnitState(Unit)} + r := m.Run(ctx, "", "ss", "-ltnH", "sport", "=", ":445") + a.Listening = strings.TrimSpace(r.Out) != "" + st, err := m.LastStatus() + if err != nil { + return a, err + } + for _, s := range st.Shares { + if s.Name == share { + a.Served = s.Live && a.Service == "active" && a.Listening + } + } + for _, rf := range st.Refused { + if rf.Name == share { + a.Said = rf.Why + return a, nil + } + } + switch { + case a.Served: + a.Said = fmt.Sprintf("%s is served on 445", share) + case st.Mode == "found": + a.Said = fmt.Sprintf("%s is not served by the module: the configuration is still the hand-written one", share) + default: + a.Said = fmt.Sprintf("%s is not served now: Samba is %s, 445 listening: %v", share, a.Service, a.Listening) + } + return a, nil +} + +// Reload has Samba read its configuration again. +func (m *Machine) Reload(ctx context.Context) (any, error) { + if _, err := m.AsRoot(ctx, "", "smbcontrol", "smbd", "reload-config"); err != nil { + return nil, err + } + return m.Shares(), nil +} + +// AdoptAnswer is what `adopt` answers. +type AdoptAnswer struct { + DryRun bool `json:"dry_run"` + Done bool `json:"done"` + Copy string `json:"copy,omitempty"` + Beside []string `json:"beside"` + Said string `json:"said"` +} + +// AdoptWait is how long a confirmed adopt waits for the process to take over. +var AdoptWait = 45 * time.Second + +// Adopt leaves a person's word for the process to take over, after a dry run that shows each hand-written share +// beside its rendering. It refuses while a hand-written share has no counterpart. +func (m *Machine) Adopt(confirm bool, why string) (AdoptAnswer, error) { + a := AdoptAnswer{DryRun: !confirm} + st, err := m.LastStatus() + if err != nil { + return a, err + } + if st.Mode == "adopted" { + return a, fmt.Errorf("the configuration is already the module's; nothing to take over") + } + served := map[string]Served{} + for _, s := range st.Shares { + served[s.Name] = s + } + var missing []string + for _, h := range st.Hand { + if h.Counterpart == "" { + missing = append(missing, fmt.Sprintf("%s (%s): %s", h.Name, h.Path, h.Why)) + a.Beside = append(a.Beside, fmt.Sprintf("hand-written %s, %s, valid users %s → no counterpart", h.Name, h.Path, h.ValidUsers)) + continue + } + s, ok := served[h.Counterpart] + if !ok { + missing = append(missing, fmt.Sprintf("%s: the setting's share %s is refused", h.Name, h.Counterpart)) + continue + } + a.Beside = append(a.Beside, fmt.Sprintf("hand-written %s, %s, valid users %s, read only %v → the mesh's %s, %s, %s, valid users %s, every client as %s:%s", + h.Name, h.Path, h.ValidUsers, h.ReadOnly, s.Name, s.Path, s.Access, strings.Join(s.Users, " "), s.User, s.Group)) + } + if len(missing) > 0 { + return a, fmt.Errorf("not taken over, so no share disappears: %s", strings.Join(missing, "; ")) + } + plan := "keep a dated copy of the hand-written configuration beside it, write the mesh's own in its place once testparm passes it, have Samba read it, and stop NetBIOS" + if !confirm { + a.Said = "Dry run: would " + plan + "." + return a, nil + } + if why == "" { + return a, fmt.Errorf("say why: adopt is a person's act, and the why is kept with it") + } + raw, _ := json.MarshalIndent(Adoption{At: m.Now().UTC().Format(time.RFC3339), Why: why}, "", " ") + if err := m.Write(AdoptFile, string(raw)+"\n", 0o644); err != nil { + return a, err + } + deadline := m.Now().Add(AdoptWait) + for { + if st, err := m.LastStatus(); err == nil && st.Mode == "adopted" && st.Written != "" { + a.Done = true + if st.Adopted != nil { + a.Copy = st.Adopted.Copy + } + a.Said = fmt.Sprintf("Did: %s. Why: %s", plan, why) + return a, nil + } + if !m.Now().Before(deadline) { + a.Said = "The word is left for the module's process, which has not taken over yet; read shares for why." + return a, nil + } + time.Sleep(2 * time.Second) + } +} + +// HealthAnswer is the module's health: mesh-sdk's answer, with what was found. +type HealthAnswer struct { + health.Answer + Found []string `json:"found,omitempty"` +} + +// Health judges the shares. +func (m *Machine) Health() HealthAnswer { + var found []string + if s := m.UnitState(Unit); s != "active" { + found = append(found, fmt.Sprintf("Samba (%s) is %s.", Unit, s)) + } + st, err := m.LastStatus() + if err != nil { + found = append(found, err.Error()+".") + return HealthAnswer{Answer: health.Unhealthy(strings.Join(found, " ")), Found: found} + } + if at, err := time.Parse(time.RFC3339, st.At); err != nil || m.Now().Sub(at) > Stale { + found = append(found, "The module's process has not looked for five minutes.") + } + for _, p := range st.Problems { + found = append(found, sentence(p)) + } + for _, r := range st.Refused { + found = append(found, r.Why) + } + if st.Mode == "adopted" { + for _, s := range st.Shares { + if !s.Live { + found = append(found, fmt.Sprintf("The share %s (%s) is not served by Samba.", s.Name, s.Path)) + } + } + } + if len(found) > 0 { + return HealthAnswer{Answer: health.Unhealthy(strings.Join(found, " "), st.Waits...), Found: found} + } + if len(st.Waits) > 0 { + var why []string + for _, w := range st.Waits { + why = append(why, fmt.Sprintf("%s waits for %s.", sentence(w.Part), w.What)) + } + return HealthAnswer{Answer: health.Waiting(strings.Join(why, " "), st.Waits...)} + } + if st.Mode == "found" { + return HealthAnswer{Answer: health.Healthy("Samba serves the hand-written configuration, kept as found; the module takes it over by node-smb-server.adopt, a person's act.")} + } + var parts []string + for _, s := range st.Shares { + parts = append(parts, s.Name) + } + return HealthAnswer{Answer: health.Healthy(fmt.Sprintf("Samba is active and serves %s on 445.", strings.Join(parts, ", ")))} +} + +func sentence(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return s + } + s = strings.ToUpper(s[:1]) + s[1:] + if !strings.HasSuffix(s, ".") { + s += "." + } + return s +} diff --git a/modules/smb-server/go.mod b/modules/smb-server/go.mod new file mode 100644 index 00000000..646ff3cd --- /dev/null +++ b/modules/smb-server/go.mod @@ -0,0 +1,5 @@ +module smbserver + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c diff --git a/modules/smb-server/go.sum b/modules/smb-server/go.sum new file mode 100644 index 00000000..e655124d --- /dev/null +++ b/modules/smb-server/go.sum @@ -0,0 +1,8 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d h1:BR8uwv4BqyLaBwITuLylrd9DRUnu0D5DjJw0RuQSXbk= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/smb-server/module.json b/modules/smb-server/module.json new file mode 100644 index 00000000..b2620785 --- /dev/null +++ b/modules/smb-server/module.json @@ -0,0 +1,144 @@ +{ + "module": "smb-server", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the folders the devices on the home network open: a build that breaks Samba's configuration leaves every device's share refused, and the gate on this one machine does not see the devices (hq ADR 0236, ADR 0341)" + }, + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-smb-server", + "scope": "node", + "serves": [ + "shares", + "sessions", + "test", + "reload", + "adopt" + ] + } + ], + "tools": [ + "smb_health" + ], + "settings": { + "shares": { + "kind": "preference", + "default": "none", + "why": "a machine serves no SMB share until its assignment names one: space-separated name=folder, each optionally :ro, one share per folder, every client acting as the folder's owner (hq ADR 0341)" + }, + "users": { + "kind": "preference", + "default": "none", + "why": "the logins allowed on every share, space-separated; each login's password is the secret smb-password-, 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" + ] + } + ] + } +}