Files
mesh-catalog/modules/nfs-server/cmd/nfs-server/main.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

162 lines
4.8 KiB
Go

// The nfs-server module's Go bundle (novox/hq ADR 0263): a node shares a folder with the mesh's machines
// only, one share per folder, every client mapped to the folder's owner.
//
// 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-nfs-server seat's verbs and the module's health, and beside them it puts
// the process's last look in the module's state `exports`, keyed by this machine (ADR 0201). Started as
// `nfs-server exports` it is the module's long-running process, as root: it renders the export file from
// the assignment's shares and has the kernel read it (process.go). stdout is the MCP channel; everything
// else is said on stderr.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"sort"
"syscall"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// ExportsState is the state the exports are kept in on the bus; MountsState the `mounts` modules' state,
// which says what each node wants of which machine.
const (
ExportsState = "exports"
MountsState = "mounts.shares"
)
func main() {
if len(os.Args) > 1 {
if os.Args[1] != "exports" || len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: nfs-server [exports]")
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
if err := Here().RunExports(ctx); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
m := Here()
go ShareExports(m)
if err := stdio.Serve("", Tools(m)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// ShareExports puts the exports in the module's state every Every, for as long as the bundle runs, so a
// consumer's module and a view read them from the bus rather than from this machine.
func ShareExports(m *Machine) {
machine := os.Getenv("MESH_NODE")
if machine == "" {
fmt.Fprintln(os.Stderr, "nfs-server: the runtime gave no machine name (MESH_NODE); the exports are not shared")
return
}
last := ""
put := func() {
m.TakeWants(machine)
_, err := stdio.State(ExportsState).Put(machine, m.Exports())
said := "put"
if err != nil {
said = err.Error()
}
if said != last {
fmt.Fprintln(os.Stderr, "nfs-server: exports state:", said)
}
last = said
}
// The SDK reaches the bus once Serve runs.
time.Sleep(2 * time.Second)
put()
for range time.Tick(Every) {
put()
}
}
// TakeWants reads every node's `mounts` state from the bus and writes what is asked of this machine to
// the wants file, for the root process. When the bus does not answer, the last wants stay.
func (m *Machine) TakeWants(machine string) {
state := stdio.State(MountsState)
keys, err := state.Keys()
if err != nil {
fmt.Fprintln(os.Stderr, "nfs-server: the mounts modules' state could not be listed:", err)
return
}
raw := map[string][]byte{}
for _, k := range keys {
e, err := state.Get(k)
if err != nil {
fmt.Fprintln(os.Stderr, "nfs-server: the mounts state of", k, "could not be read:", err)
return
}
if e != nil {
raw[k] = e.Value
}
}
wants, notes := WantsFromStates(machine, raw)
for _, n := range notes {
fmt.Fprintln(os.Stderr, "nfs-server:", n)
}
text, _ := json.MarshalIndent(WantsFromBus{Looked: m.Now().UTC().Format(time.RFC3339), Wants: wants}, "", " ")
have, _ := m.Read(WantsFile)
if wantsOf(have) == wantsOf(string(text)) {
return
}
if err := m.Write(WantsFile, string(text)+"\n"); err != nil {
fmt.Fprintln(os.Stderr, "nfs-server: the wants could not be written:", err)
}
}
// wantsOf is a wants file without its time, to compare two.
func wantsOf(text string) string {
var w WantsFromBus
if json.Unmarshal([]byte(text), &w) != nil {
return text
}
raw, _ := json.Marshal(w.Wants)
return string(raw)
}
// WantsFromStates is what each node's `mounts` state asks of this machine: its wants whose server is this
// machine, keyed by the node. A state that cannot be read is said and skipped.
func WantsFromStates(machine string, states map[string][]byte) (map[string][]Want, []string) {
out := map[string][]Want{}
var notes []string
keys := make([]string, 0, len(states))
for k := range states {
keys = append(keys, k)
}
sort.Strings(keys)
for _, node := range keys {
var v struct {
Wants []struct {
Server string `json:"server"`
Share string `json:"share"`
Access string `json:"access"`
} `json:"wants"`
}
if err := json.Unmarshal(states[node], &v); err != nil {
notes = append(notes, fmt.Sprintf("the mounts state of %s is not one this module reads: %v", node, err))
continue
}
for _, w := range v.Wants {
if w.Server != machine || !validName(w.Share) {
continue
}
access := "rw"
if w.Access == "ro" {
access = "ro"
}
out[node] = append(out[node], Want{Share: w.Share, Access: access})
}
}
return out, notes
}