diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 68c7291a..72653630 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -322,6 +322,9 @@ var defaultSeats = append([]Seat{ // node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go). {Name: NFSServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()}, {Name: MountsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()}, + // A machine's SMB shares for the devices on its own network (novox/hq ADR 0341, smb.go): its holder + // writes the SMB configuration, serves 445 alone and maps every client to the shared folder's owner. + {Name: SMBServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0341", Serves: smbServerVerbs()}, // A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds // no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool. {Name: ReadsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()}, diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 537a6a2f..59ac1f23 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -48,7 +48,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - // Forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired + // Forty-two with node-smb-server (novox/hq ADR 0341); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired // into node-uplink (novox/hq ADR 0223); thirty-seven // since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with // node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215); @@ -59,8 +59,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { // node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203, // ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired // mesh-build-machine row goes, when no registered manifest claims it. - if len(Seats()) != 41 { - t.Errorf("the mesh defines %d seats rather than 41; the set is closed, so a change here is "+ + if len(Seats()) != 42 { + t.Errorf("the mesh defines %d seats rather than 42; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } } diff --git a/internal/catalogue/shares.go b/internal/catalogue/shares.go index 2ee52e89..6a0c412c 100644 --- a/internal/catalogue/shares.go +++ b/internal/catalogue/shares.go @@ -31,9 +31,10 @@ func nfsServerVerbs() []Verb { {Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " + "read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " + "node's private address and access (only the nodes the server grants it to and that ask for it), " + - "what is granted and not asked for or asked for and not granted, and whether the kernel holds it " + - "now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in " + - "/etc/exports.", + "the subnets of this machine's local network it is exported to when the server grants it " + + "local-network (novox/hq ADR 0341), what is granted and not asked for or asked for and not " + + "granted, and whether the kernel holds it now. Also the exports found that are not the mesh's: a " + + "dataset's sharenfs property, a line in /etc/exports.", Input: schema(map[string]string{}, nil), Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}}, {Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " + @@ -55,8 +56,10 @@ func nfsServerVerbs() []Verb { Input: schema(map[string]string{}, nil), Replaces: []string{"exportfs -ra"}}, {Name: "adopt", Description: "Take over an export a person made by hand: clear a dataset's sharenfs " + - "property for a path the mesh's own export now serves — only when that export is live. Without " + - "confirm, says what it would do and changes nothing.", + "property for a path the mesh's own export now serves — only when that export is live and covers " + + "every client the property exported to: for a property exporting to the local network, the " + + "mesh's local-network export of the path (novox/hq ADR 0341). Without confirm, says what it " + + "would do and changes nothing.", Input: schema(map[string]string{ "path": "the shared path whose hand-made export is taken over", "confirm": "\"true\": change it (needs why); anything else is a dry run", diff --git a/internal/catalogue/smb.go b/internal/catalogue/smb.go new file mode 100644 index 00000000..bad7c8f1 --- /dev/null +++ b/internal/catalogue/smb.go @@ -0,0 +1,53 @@ +package catalogue + +// A machine's SMB shares, served to the devices on its own network that are not nodes (novox/hq +// ADR 0341). +// +// **One holder per machine.** Two modules writing one machine's Samba configuration is the conflict a +// seat exists to refuse, so the role is a node seat. It delivers nothing: its clients are devices that +// are not nodes, and a node that wants the same files mounts the NFS share (ADR 0263), so no provision +// is answered through it. +// +// **The data is the operator's** (ADR 0051). The holder never creates, chowns or removes anything under +// a shared path, and every client acts as the shared folder's owner. Its verbs read, and the one that +// takes over the configuration a person wrote by hand does it only on a person's word, keeping a dated +// copy of what it replaces. + +// SMBServerSeat is the role of the machine that shares folders over SMB with its local network +// (novox/hq ADR 0341). +const SMBServerSeat = "node-smb-server" + +// smbServerVerbs is the contract every holder of node-smb-server serves (novox/hq ADR 0341). +func smbServerVerbs() []Verb { + return []Verb{ + {Name: "shares", Description: "Every SMB share this machine serves: its name, its path, read-write " + + "or read-only, the owner every client acts as (uid and gid), the logins allowed and whether each " + + "login's password was given or is kept as found, and whether the service serves it now. Also " + + "whether the configuration is the mesh's or still the hand-written one, and each hand-written " + + "share with whether the settings name it. A password is never shown.", + Input: schema(map[string]string{}, nil), + Replaces: []string{"testparm -s", "cat /etc/samba/smb.conf", "pdbedit -L"}}, + {Name: "sessions", Description: "Which devices are connected to which share now, with the login and " + + "the protocol version each uses, as the SMB service knows them.", + Input: schema(map[string]string{}, nil), + Replaces: []string{"smbstatus"}}, + {Name: "test", Description: "Whether this machine serves one share now and whether its SMB service " + + "is up and listening on 445: what a device on the local network is answered.", + Input: schema(map[string]string{"share": "the share, by its name"}, []string{"share"}), + Replaces: []string{"smbclient -L"}}, + {Name: "reload", Description: "Have the SMB service read its configuration again now (smbcontrol " + + "reload-config) and answer the shares as it then serves them. The module's own process writes the " + + "configuration; this is for after a change made outside it. Changes no shared path.", + Input: schema(map[string]string{}, nil), + Replaces: []string{"smbcontrol smbd reload-config", "systemctl reload smb"}}, + {Name: "adopt", Description: "Take over the SMB configuration a person wrote by hand: keep a dated " + + "copy of it beside it and write the mesh's own in its place — only when every hand-written share " + + "has its counterpart in the settings, so no share disappears. Without confirm, says what it would " + + "do and changes nothing: each hand-written share beside the mesh's rendering of it.", + Input: schema(map[string]string{ + "confirm": "\"true\": change it (needs why); anything else is a dry run", + "why": "why, for the record", + }, nil, "confirm"), + Replaces: []string{"edit /etc/samba/smb.conf"}}, + } +} diff --git a/internal/catalogue/smb_test.go b/internal/catalogue/smb_test.go new file mode 100644 index 00000000..92fb168e --- /dev/null +++ b/internal/catalogue/smb_test.go @@ -0,0 +1,95 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Defends novox/hq ADR 0341: a machine's SMB shares are one node seat, node-smb-server, with its verbs +// required of every holder, delivering nothing — its clients are devices that are not nodes. + +func TestTheSMBServerIsANodeSeatWithItsVerbs(t *testing.T) { + s, ok := SeatNamed(SMBServerSeat) + if !ok { + t.Fatalf("%s is not in the mesh's set", SMBServerSeat) + } + if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0341" || s.Delivers != "" || s.Replicated { + t.Errorf("%s is %+v; a node seat under ADR 0341 that delivers nothing", SMBServerSeat, s) + } + var got []string + for _, v := range s.Serves { + got = append(got, v.Name) + if v.Optional { + t.Errorf("%s.%s is optional; its first holder serves it", SMBServerSeat, v.Name) + } + if v.Description == "" || v.Input["type"] != "object" || len(v.Replaces) == 0 { + t.Errorf("%s.%s has no description, no object schema or says it replaces nothing", SMBServerSeat, v.Name) + } + } + if strings.Join(got, " ") != "shares sessions test reload adopt" { + t.Errorf("%s serves %v", SMBServerSeat, got) + } +} + +// Taking over the hand-written configuration is a person's act (ADR 0341 clause 7): a dry run unless the +// string switch says "true", and it says it keeps a dated copy and refuses to drop a share. +func TestTheSMBAdoptIsADryRunUnlessConfirmed(t *testing.T) { + s, _ := SeatNamed(SMBServerSeat) + for _, v := range s.Serves { + if v.Name != "adopt" { + continue + } + props, _ := v.Input["properties"].(map[string]any) + confirm, _ := props["confirm"].(map[string]any) + if confirm == nil || confirm["enum"] == nil || confirm["type"] != "string" { + t.Errorf("adopt has no string confirm switch: %v", v.Input) + } + for _, says := range []string{"Without confirm, says what it would do and changes nothing", "dated copy", "no share disappears"} { + if !strings.Contains(v.Description, says) { + t.Errorf("adopt does not say %q: %s", says, v.Description) + } + } + return + } + t.Error("node-smb-server has no adopt") +} + +// A holder serving every verb holds the seat; a verb too many, one too few or the mesh's scope is refused, +// as the catalogue's smb-server module claims it. +func TestAHolderOfTheSMBSeatServesEveryVerbAndNothingElse(t *testing.T) { + seat, _ := SeatNamed(SMBServerSeat) + verbs := []string{"shares", "sessions", "test", "reload", "adopt"} + holder := Manifest{Module: "smb-server", Version: "1", + Claims: []Claim{{Name: SMBServerSeat, Scope: ScopeNode, Serves: verbs}}} + if err := CanHold(holder, seat); err != nil { + t.Errorf("smb-server serving every verb is refused: %v", err) + } + short := holder + short.Claims = []Claim{{Name: SMBServerSeat, Scope: ScopeNode, Serves: verbs[:4]}} + if err := CanHold(short, seat); err == nil || !strings.Contains(err.Error(), "adopt") { + t.Errorf("leaving adopt out was accepted: %v", err) + } + mesh := holder + mesh.Claims = []Claim{{Name: SMBServerSeat, Scope: ScopeMesh, Serves: verbs}} + if err := CanHold(mesh, seat); err == nil { + t.Error("claiming a node seat at the mesh's scope was accepted") + } +} + +// node-nfs-server's adopt clears sharenfs only once the mesh's export covers every client the property +// exported to, the local network included (ADR 0341 clause 10), and exports names the local-network grant. +func TestTheNFSVerbsSayTheLocalNetworkGrant(t *testing.T) { + s, _ := SeatNamed(NFSServerSeat) + for _, v := range s.Serves { + switch v.Name { + case "adopt": + if !strings.Contains(v.Description, "covers every client the property exported to") { + t.Errorf("nfs adopt does not wait for the export to cover every client: %s", v.Description) + } + case "exports": + if !strings.Contains(v.Description, "local-network") { + t.Errorf("nfs exports does not say the local-network grant: %s", v.Description) + } + } + } +}