Compare commits

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