Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1f2776693 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module smbserver
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user