Files
mesh-catalog/modules/nfs-server/cmd/nfs-server/tools.go
T
jochen 8738c02d00 nfs-server: export a share only to the nodes granted and wanting it, and never root's, a symlinked or nested backup, or an unmounted dataset
The review of #136 found a share could squash every client to root, be
judged at a symlink's name rather than its folder, sit inside or above a
backup repository deeper than one level, export an unmounted dataset's
empty folder, go to every address in the private network's range, and
stay exported after the module left. Each is now refused or closed, with
a test that reproduced it first:

- a folder owned by uid or gid 0 is refused;
- the path is resolved through symlinks and every check runs on the real
  folder, which the export line names;
- a share inside a backup repository (any parent) or holding one within
  four levels is refused, and a look cut short by its bound refuses;
- every line carries mp, or mp=<its filesystem's mount point>;
- a share goes only to the nodes this machine's grants setting names and
  whose mounts module wants it on the bus, at the lower access, at the
  roster's address inside the range; nothing without wants;
- the process is declared before its config, so on unassignment it finds
  the config gone, removes its export file and lets the shares go;
- confirm accepts the seat's string "true".
2026-10-08 20:20:05 +02:00

475 lines
16 KiB
Go

package main
import (
"context"
"fmt"
"net/netip"
"path"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Seat is the role this module holds on its machine (novox/hq ADR 0263).
const Seat = "node-nfs-server"
// Stale is how old the process's last look may be before the tools say it stopped looking.
const Stale = 5 * time.Minute
// verb is one of the seat's verbs, listed as `<seat>.<verb>` so the runtime serves it on the seat's
// subject, as <node>/node-nfs-server.<verb>.
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)
}
func flag(a map[string]any, k string) bool {
switch v := a[k].(type) {
case bool:
return v
case string:
return strings.EqualFold(strings.TrimSpace(v), "true")
}
return false
}
// 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 { return flag(a, "confirm") }
// Tools are the seat's verbs and the module's health.
func Tools(m *Machine) []stdio.Tool {
return []stdio.Tool{
verb("exports", "Every share this machine exports for the mesh: its name, folder, rw or ro, the owner every "+
"client is mapped to (uid, gid), the nodes it is exported to (each granted by this machine and wanting it, "+
"at its private address, with its access) and whether the kernel holds it now; what does not match "+
"between the grants and the wants; the shares the assignment asked for that were refused, and why; and every export "+
"on the machine that is not the mesh's (/etc/exports, the other files in /etc/exports.d, a ZFS dataset's "+
"sharenfs). Replaces exportfs -v, cat /etc/exports and zfs get sharenfs. (r)",
map[string]any{}, func(map[string]any) (any, error) { return m.Exports(), nil }),
verb("clients", "Which machines have a share of this machine mounted now: each NFS client the kernel knows, "+
"with its address and the name it gave, or — when the kernel's list cannot be read — the connections "+
"to port 2049. Replaces cat /proc/fs/nfsd/clients/*/info and ss -tn sport = :2049. (r)",
map[string]any{}, func(map[string]any) (any, error) { return m.Clients(), nil }),
verb("test", "Whether this machine exports one share for the mesh now: the share is asked for and not "+
"refused, the kernel holds it for every node it is exported to, and the NFS server is active; with an "+
"address, also whether that address is inside the range and the share is exported to it. Replaces showmount -e. (r)",
map[string]any{"share": str("the share's name"), "address": str("a client's address on the private network (optional)")},
func(a map[string]any) (any, error) { return m.Test(arg(a, "share"), arg(a, "address")) }),
verb("reload", "Have the kernel read the export files again now (exportfs -ra), and answer the mesh's shares "+
"as it then holds them. The module's process does this itself whenever its file changes or a share is "+
"missing; this is for after a change made outside it. Replaces exportfs -ra. (a)",
map[string]any{}, func(map[string]any) (any, error) { return m.Reload() }),
verb("adopt", "Take over the export a ZFS dataset's sharenfs property makes for a folder the mesh now "+
"exports: sets sharenfs=off on that one dataset and nothing else, then exportfs -ra. Only when the "+
"mesh's own export of the same folder is live in the kernel to at least one node, so the folder is never "+
"unexported for everyone; unless confirm is true, a dry run that says what it would do. A person's act, with why. Replaces zfs set "+
"sharenfs=off. (a)",
map[string]any{
"path": str("the shared folder, as the share names it"),
"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(arg(a, "path"), confirmed(a), arg(a, "why")) }),
{
Name: "nfs_health",
Description: "Whether this machine's shares are served, as the module's health check (novox/hq ADR 0240, " +
"ADR 0263): unhealthy when the NFS server is not active, the module's process has not looked for five " +
"minutes, a share the assignment asks for is refused or missing from the kernel's table, or the " +
"export file could not be written or read again; with why, one sentence per finding. (r)",
Run: func(map[string]any) (any, error) { return m.Health(), nil },
},
}
}
// ExportsAnswer is what `exports` answers.
type ExportsAnswer struct {
Shares []Exported `json:"shares"`
Refused []Refused `json:"refused,omitempty"`
Foreign []Entry `json:"foreign"`
Service string `json:"service"`
Range string `json:"range,omitempty"`
// Unmatched is what does not match between this machine's grants and the nodes' wants.
Unmatched []string `json:"unmatched,omitempty"`
Looked string `json:"looked,omitempty"`
Said []string `json:"said"`
Notes []string `json:"notes,omitempty"`
}
// Exports is the machine's exports now.
func (m *Machine) Exports() ExportsAnswer {
a := ExportsAnswer{Shares: []Exported{}, Foreign: []Entry{}, Service: m.ServiceState()}
st, err := m.LastStatus()
if err != nil {
a.Notes = append(a.Notes, err.Error())
} else {
a.Range, a.Refused = st.Range, st.Refused
a.Looked = st.At.Format(time.RFC3339)
live, note := m.Live()
if note != "" {
a.Notes = append(a.Notes, note)
}
for _, e := range st.Shares {
if note == "" {
e.Live = SharedLive(live, e)
}
a.Shares = append(a.Shares, e)
}
a.Notes = append(a.Notes, st.Problems...)
a.Unmatched = st.Said
}
entries, sets, notes := m.Foreign()
a.Notes = append(a.Notes, notes...)
a.Foreign = append(a.Foreign, entries...)
for _, d := range sets {
a.Foreign = append(a.Foreign, Entry{Path: d.Mountpoint, Client: d.ShareNFS, From: "zfs sharenfs on " + d.Name})
}
for _, e := range a.Shares {
if len(e.To) == 0 {
a.Said = append(a.Said, fmt.Sprintf("%s: %s, exported to no node", e.Name, e.Path))
continue
}
state := "live"
if !e.Live {
state = "NOT in the kernel's table"
}
a.Said = append(a.Said, fmt.Sprintf("%s: %s to %s, every client as %d:%d — %s", e.Name, e.Path, grantees(e.To), e.UID, e.GID, state))
}
a.Said = append(a.Said, a.Unmatched...)
for _, r := range a.Refused {
a.Said = append(a.Said, "refused: "+r.Why)
}
for _, f := range a.Foreign {
a.Said = append(a.Said, fmt.Sprintf("not the mesh's: %s to %s (%s)", f.Path, f.Client, f.From))
}
if len(a.Shares) == 0 && len(a.Refused) == 0 {
a.Said = append(a.Said, "the mesh exports nothing here")
}
a.Said = append(a.Said, "the NFS server is "+a.Service)
return a
}
// Client is one machine that has a share mounted.
type Client struct {
Address string `json:"address"`
Name string `json:"name,omitempty"`
Minor string `json:"minor_version,omitempty"`
}
// ClientsAnswer is what `clients` answers.
type ClientsAnswer struct {
Clients []Client `json:"clients"`
From string `json:"from"`
Notes []string `json:"notes,omitempty"`
}
// ParseClientInfo reads one /proc/fs/nfsd/clients/<id>/info.
func ParseClientInfo(text string) Client {
var c Client
for _, line := range strings.Split(text, "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
v = strings.Trim(strings.TrimSpace(v), `"`)
switch strings.TrimSpace(k) {
case "address":
c.Address = v
case "name":
c.Name = v
case "minor version":
c.Minor = v
}
}
return c
}
// Clients are the NFS clients the kernel knows, or the connections to 2049.
func (m *Machine) Clients() ClientsAnswer {
a := ClientsAnswer{Clients: []Client{}}
const dir = "/proc/fs/nfsd/clients"
infos := m.Glob(dir + "/*/info")
if len(infos) == 0 && m.UID != 0 {
if r, err := m.AsRoot("find", dir, "-maxdepth", "2", "-name", "info"); err == nil {
for _, l := range strings.Fields(r.Out) {
infos = append(infos, l)
}
} else {
a.Notes = append(a.Notes, err.Error())
}
}
for _, f := range infos {
text, err := m.Read(f)
if err != nil && m.UID != 0 {
r, e := m.AsRoot("cat", f)
text, err = r.Out, e
}
if err != nil {
a.Notes = append(a.Notes, f+": "+err.Error())
continue
}
a.Clients = append(a.Clients, ParseClientInfo(text))
}
if len(infos) > 0 {
a.From = dir
return a
}
r := m.Run(context.Background(), "ss", "-tnH", "state", "established", "( sport = :2049 )")
a.From = "connections to port 2049"
for _, line := range strings.Split(r.Out, "\n") {
f := strings.Fields(line)
if len(f) >= 4 {
a.Clients = append(a.Clients, Client{Address: f[len(f)-1]})
}
}
return a
}
// TestAnswer is what `test` answers.
type TestAnswer struct {
Share string `json:"share"`
Path string `json:"path,omitempty"`
Access string `json:"access,omitempty"`
Live bool `json:"live"`
Service string `json:"service"`
InRange *bool `json:"in_range,omitempty"`
OK bool `json:"ok"`
Why string `json:"why"`
}
// Test says whether one share is served now.
func (m *Machine) Test(share, address string) (TestAnswer, error) {
if share == "" {
return TestAnswer{}, fmt.Errorf("name the share")
}
a := TestAnswer{Share: share, Service: m.ServiceState()}
st, err := m.LastStatus()
if err != nil {
a.Why = err.Error()
return a, nil
}
for _, r := range st.Refused {
if r.Name == share {
a.Path, a.Why = r.Path, r.Why
return a, nil
}
}
var found *Exported
for i := range st.Shares {
if st.Shares[i].Name == share {
found = &st.Shares[i]
}
}
if found == nil {
a.Why = fmt.Sprintf("This machine's assignment asks for no share named %s.", share)
return a, nil
}
a.Path, a.Access = found.Path, found.Access
live, note := m.Live()
a.Live = note == "" && SharedLive(live, *found)
var why []string
if address != "" {
addr, err1 := netip.ParseAddr(address)
prefix, err2 := netip.ParsePrefix(st.Range)
in := err1 == nil && err2 == nil && prefix.Contains(addr)
a.InRange = &in
if !in {
why = append(why, fmt.Sprintf("%s is not inside the private network's range %s.", address, st.Range))
} else {
to := false
for _, g := range found.To {
if g.Address == address {
to = true
a.Access = g.Access
}
}
if !to {
why = append(why, fmt.Sprintf("The share %s is not exported to %s: no node there is both granted it and wants it.", share, address))
} else if note != "" || !IsLive(live, found.Path, address) {
why = append(why, fmt.Sprintf("The kernel does not export %s to %s now.", found.Path, address))
}
}
}
if len(found.To) == 0 {
why = append(why, fmt.Sprintf("The share %s is exported to no node.", share))
} else if !a.Live {
why = append(why, fmt.Sprintf("The kernel does not export %s to every node it is meant for (%s) now.", found.Path, grantees(found.To)))
}
if a.Service != "active" {
why = append(why, "The NFS server is "+a.Service+".")
}
a.OK = len(why) == 0
if a.OK {
a.Why = fmt.Sprintf("The share %s (%s) is exported to %s and the NFS server is active.", share, found.Path, grantees(found.To))
} else {
a.Why = strings.Join(why, " ")
}
return a, nil
}
// Reload has the kernel read the export files again.
func (m *Machine) Reload() (any, error) {
if _, err := m.AsRoot("exportfs", "-ra"); err != nil {
return nil, err
}
return m.Exports(), nil
}
// AdoptAnswer is what `adopt` answers.
type AdoptAnswer struct {
Path string `json:"path"`
Dataset string `json:"dataset,omitempty"`
Was string `json:"sharenfs_was,omitempty"`
Done bool `json:"done"`
DryRun bool `json:"dry_run"`
Said string `json:"said"`
}
// Adopt clears one dataset's sharenfs once the mesh's own export of its folder is live.
func (m *Machine) Adopt(p string, confirm bool, why string) (AdoptAnswer, error) {
if p == "" {
return AdoptAnswer{}, fmt.Errorf("name the shared folder (path)")
}
p = path.Clean(p)
a := AdoptAnswer{Path: p, DryRun: !confirm}
sets, note := m.Datasets()
if note != "" {
return a, fmt.Errorf("%s", note)
}
var ds *Dataset
for i := range sets {
if path.Clean(sets[i].Mountpoint) == p {
ds = &sets[i]
}
}
if ds == nil {
return a, fmt.Errorf("no ZFS dataset is mounted at %s, so there is no sharenfs to take over", p)
}
a.Dataset, a.Was = ds.Name, ds.ShareNFS
if ds.ShareNFS == "off" || ds.ShareNFS == "-" || ds.ShareNFS == "" {
return a, fmt.Errorf("the dataset %s already has sharenfs off; nothing to take over", ds.Name)
}
st, err := m.LastStatus()
if err != nil {
return a, err
}
var mine *Exported
for i := range st.Shares {
if st.Shares[i].Path == p {
mine = &st.Shares[i]
}
}
if mine == nil {
return a, fmt.Errorf("the mesh does not export %s: add it to this machine's nfs-server `shares` setting first, so the folder is never unexported", p)
}
if len(mine.To) == 0 {
return a, fmt.Errorf("the mesh exports %s to no node yet (no node is both granted it and wants it), so taking over would unexport it for everyone", p)
}
live, lnote := m.Live()
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))
}
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 {
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")
}
if _, err := m.AsRoot("zfs", "set", "sharenfs=off", ds.Name); err != nil {
return a, err
}
if _, err := m.AsRoot("exportfs", "-ra"); err != nil {
return a, fmt.Errorf("sharenfs is off on %s, and exportfs -ra failed: %v", ds.Name, err)
}
a.Done = true
a.Said = fmt.Sprintf("Did: %s. Why: %s", plan, why)
return a, nil
}
// Health is the module's health, as the node-engine asks it.
type Health struct {
Healthy bool `json:"healthy"`
Why string `json:"why"`
Found []string `json:"found,omitempty"`
}
// Health judges the shares.
func (m *Machine) Health() Health {
var found []string
service := m.ServiceState()
if service != "active" {
found = append(found, fmt.Sprintf("The NFS server (%s) is %s.", Unit, service))
}
st, err := m.LastStatus()
if err != nil {
found = append(found, err.Error()+".")
return Health{Healthy: false, Why: strings.Join(found, " "), Found: found}
}
if age := m.Now().Sub(st.At); age > Stale {
found = append(found, fmt.Sprintf("The module's process last looked %s ago, at %s.", age.Round(time.Second), st.At.Local().Format("15:04")))
}
for _, p := range st.Problems {
found = append(found, sentence(p))
}
for _, r := range st.Refused {
found = append(found, r.Why)
}
if st.ReloadError != "" {
found = append(found, sentence("the kernel could not read the export files again: "+st.ReloadError))
}
live, note := m.Live()
for _, e := range st.Shares {
if note == "" && !SharedLive(live, e) {
found = append(found, fmt.Sprintf("The share %s (%s) is not in the kernel's export table.", e.Name, e.Path))
}
}
sort.Strings(found)
if len(found) > 0 {
return Health{Healthy: false, Why: strings.Join(found, " "), Found: found}
}
var parts []string
for _, e := range st.Shares {
if len(e.To) > 0 {
parts = append(parts, fmt.Sprintf("%s to %s", e.Name, grantees(e.To)))
}
}
if len(parts) == 0 {
return Health{Healthy: true, Why: "The NFS server is active, and the mesh exports nothing to any node here."}
}
return Health{Healthy: true, Why: fmt.Sprintf("The NFS server is active and exports %s.", 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
}
// grantees says who a share goes to: `ws (10.0.0.3, rw), laptop (10.0.0.4, ro)`.
func grantees(to []Grantee) string {
var out []string
for _, g := range to {
out = append(out, fmt.Sprintf("%s (%s, %s)", g.Node, g.Address, g.Access))
}
return strings.Join(out, ", ")
}