480 lines
16 KiB
Go
480 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.Notes = append(a.Notes, st.Warnings...)
|
|
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)
|
|
warned := ""
|
|
for _, w := range st.Warnings {
|
|
warned += " Warning: " + sentence(w)
|
|
}
|
|
if len(found) > 0 {
|
|
return Health{Healthy: false, Why: strings.Join(found, " ") + warned, 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." + warned}
|
|
}
|
|
return Health{Healthy: true, Why: fmt.Sprintf("The NFS server is active and exports %s.", strings.Join(parts, "; ")) + warned}
|
|
}
|
|
|
|
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, ", ")
|
|
}
|