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".
This commit is contained in:
jochen
2026-10-08 20:20:05 +02:00
parent d5adc1d525
commit 8738c02d00
10 changed files with 1127 additions and 116 deletions
+81 -4
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
@@ -21,6 +23,9 @@ type Status struct {
Shares []Exported `json:"shares"`
Refused []Refused `json:"refused,omitempty"`
Problems []string `json:"problems,omitempty"`
// Said is what does not match between the grants and the wants: a node wanting a share it is not
// granted, a grant nobody wants, a share exported to no node.
Said []string `json:"said,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"`
@@ -40,8 +45,17 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
c, problems := ParseConfig(text)
s.Problems = append(s.Problems, problems...)
s.Range = c.Range
exports, refused := m.Judge(c)
roster, note := m.Roster()
if note != "" {
s.Problems = append(s.Problems, note)
}
wants, note := m.Wants()
if note != "" {
s.Said = append(s.Said, note)
}
exports, refused, said := m.Judge(c, roster, wants)
s.Refused = refused
s.Said = append(s.Said, said...)
want := ExportFileText(exports)
have, _ := m.Read(ExportFile)
@@ -56,7 +70,7 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
live, note := m.Live()
if reload == "" {
for _, e := range exports {
if !IsLive(live, e.Path, e.Clients) {
if !SharedLive(live, e) {
reload = fmt.Sprintf("the share %s was not in the kernel's table", e.Name)
break
}
@@ -75,7 +89,7 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
s.Problems = append(s.Problems, note)
}
for i := range exports {
exports[i].Live = IsLive(live, exports[i].Path, exports[i].Clients)
exports[i].Live = SharedLive(live, exports[i])
}
s.Shares = exports
raw, _ := json.MarshalIndent(s, "", " ")
@@ -85,7 +99,7 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
return s
}
// RunExports is the process: a pass now and every Every until the context ends.
// RunExports is the process: a pass now and every Every until the context ends, then Stopped.
func (m *Machine) RunExports(ctx context.Context) error {
last := m.Pass(ctx, nil)
t := time.NewTicker(Every)
@@ -93,6 +107,7 @@ func (m *Machine) RunExports(ctx context.Context) error {
for {
select {
case <-ctx.Done():
m.Stopped()
return nil
case <-t.C:
last = m.Pass(ctx, &last)
@@ -112,3 +127,65 @@ func (m *Machine) LastStatus() (*Status, error) {
}
return &s, nil
}
// Roster is the mesh's machines by name, with their private addresses, as the mesh rendered them.
func (m *Machine) Roster() (map[string]string, string) {
text, err := m.Read(RosterFile)
if err != nil {
return map[string]string{}, fmt.Sprintf("the mesh's roster (%s) could not be read: %v; no node's address is known, so nothing is exported", RosterFile, err)
}
out := map[string]string{}
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) != 2 || strings.HasPrefix(f[0], "#") {
continue
}
out[f[0]] = f[1]
}
return out, ""
}
// Wants is every node's wants of this machine, as the tools last took them from the bus. With none taken
// yet, nothing is wanted, and nothing is exported.
func (m *Machine) Wants() (map[string][]Want, string) {
text, err := m.Read(WantsFile)
if err != nil {
return map[string][]Want{}, "no node's wants have been taken from the bus yet, so nothing is exported"
}
var w WantsFromBus
if err := json.Unmarshal([]byte(text), &w); err != nil {
return map[string][]Want{}, fmt.Sprintf("the wants taken from the bus (%s) could not be read: %v; nothing is exported", WantsFile, err)
}
if w.Wants == nil {
w.Wants = map[string][]Want{}
}
return w.Wants, ""
}
// SharedLive says whether the kernel exports a share to every node it is meant for.
func SharedLive(live []Entry, e Exported) bool {
for _, g := range e.To {
if !IsLive(live, e.Path, g.Address) {
return false
}
}
return true
}
// Stopped is the process ending. When the module was unassigned — the node-engine removes the module's
// files in reverse declaration order, so the config file, declared after this process, is already gone
// when the process is stopped (mesh-host internal/apply: orphans removed in reverse order) — the
// module's export file goes and the kernel lets the shares go. A plain restart, a push or the machine
// shutting down leaves the config file, and the exports stay.
func (m *Machine) Stopped() {
if _, err := m.Read(ConfigFile); err == nil || !os.IsNotExist(err) {
return
}
if err := m.unexport(); err != nil {
fmt.Fprintln(os.Stderr, "nfs-server:", err)
return
}
if _, err := m.AsRoot("exportfs", "-ra"); err != nil {
fmt.Fprintln(os.Stderr, "nfs-server: the export file is gone, and exportfs -ra failed:", err)
}
}