Files
mesh-catalog/modules/nfs-server/cmd/nfs-server/shares.go
T

520 lines
18 KiB
Go

package main
import (
"fmt"
"net/netip"
"path"
"sort"
"strings"
"time"
)
// The shares, from the assignment, and the export lines made from them (novox/hq ADR 0263).
//
// **One share per folder.** A share is a name and a path the operator gives in the `shares` setting:
// `library=/srv/library downloads=/srv/downloads`, `:ro` after a path for read-only. The path is the
// operator's data (novox/hq ADR 0051): the module reads its owner and exports it, and never creates,
// chowns, chmods or removes it.
//
// **Every client is the folder's owner.** NFS with sec=sys believes the user id the client sends, and the
// same id names different accounts on different machines. So every line squashes every client
// (`all_squash`) to the owner of the shared folder as this machine reads it (`anonuid`, `anongid`): a file
// written from any node lands owned by the folder's owner, and no client acts as any other account here.
// Root is squashed with everyone else.
//
// **To the nodes that ask, as far as this machine allows** (review of mesh-catalog #136, point 5). A share
// is exported to a node's address only when this machine's `grants` setting names that node for it, and
// that node's `mounts` module wants it from this machine (its state `shares` on the bus, which the tools
// 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.
//
// **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
// (squashing to root would make every client root there), lies inside a backup repository, or holds one
// within WalkDepth levels below — and when that look is cut short by its bound, it is refused rather
// than exported.
//
// **Only while its filesystem is mounted.** Every line carries `mp`: a share that is a filesystem of its
// own is exported only while it is mounted, and a folder inside one only while that filesystem is
// (`mp=<its mount point>`). Without it, an unmounted dataset would export the empty folder under it.
// Share is one share the operator asked for.
type Share struct {
Name string `json:"name"`
Path string `json:"path"`
ReadOnly bool `json:"-"`
Access string `json:"access"`
}
// Grantee is one node a share is exported to: its name, its address on the private network, and the
// access it is given.
type Grantee struct {
Node string `json:"node"`
Address string `json:"address"`
Access string `json:"access"`
}
// Exported is a share as this machine exports it.
type Exported struct {
Name string `json:"name"`
Path string `json:"path"`
Access string `json:"access"`
UID int `json:"uid"`
GID int `json:"gid"`
// Clients is the addresses it is exported to, separated by commas; empty when no node is both granted
// it and wants it. To says who each is.
Clients string `json:"clients"`
To []Grantee `json:"to"`
// Mp is the export's mp option: "mp", "mp=<mount point>", or empty for a folder of the root filesystem.
Mp string `json:"mp,omitempty"`
Line string `json:"line"`
Live bool `json:"live"`
}
// Refused is a share that is not exported, and why.
type Refused struct {
Name string `json:"name"`
Path string `json:"path"`
Why string `json:"why"`
}
// Config is what the mesh rendered from the assignment.
type Config struct {
Shares []Share
// Grants is, per share, the nodes this machine lets mount it and with which access.
Grants map[string]map[string]string
Range string
}
// Want is one share a node's mounts module wants of this machine, as it put it on the bus.
type Want struct {
Share string `json:"share"`
Access string `json:"access"`
}
// WantsFromBus is the file the tools write from the bus: every node's wants of this machine.
type WantsFromBus struct {
Looked string `json:"looked,omitempty"`
Wants map[string][]Want `json:"wants"`
}
// ParseConfig reads the config file: `shares=`, `grants=` and `range=` lines, comments and blanks
// ignored.
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 = shares
problems = append(problems, bad...)
case "grants":
grants, bad := ParseGrants(value)
c.Grants = grants
problems = append(problems, bad...)
case "range":
c.Range = strings.TrimSpace(value)
default:
problems = append(problems, fmt.Sprintf("%s names %q, which this module does not read", ConfigFile, key))
}
}
if c.Range == "" {
problems = append(problems, fmt.Sprintf("%s gives no private network range, so nothing can be exported", ConfigFile))
} else if _, err := netip.ParsePrefix(c.Range); err != nil {
problems = append(problems, fmt.Sprintf("the private network's range %q is not a range: %v", c.Range, err))
c.Range = ""
}
return c, problems
}
// ParseShares reads the `shares` setting: `name=path` or `name=path:ro`, separated by spaces or commas.
func ParseShares(value string) ([]Share, []string) {
var shares []Share
var problems []string
seen := map[string]bool{}
for _, item := range strings.FieldsFunc(value, func(r rune) bool { return r == ' ' || r == ',' || r == '\t' }) {
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.ReadOnly, s.Access = strings.TrimSuffix(p, ":ro"), true, "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
}
// ParseGrants reads the `grants` setting: `share=node:rw|ro[,node:rw|ro]`, entries separated by spaces;
// `none` grants nothing.
func ParseGrants(value string) (map[string]map[string]string, []string) {
out := map[string]map[string]string{}
seen := map[string]bool{}
var problems []string
value = strings.TrimSpace(value)
if value == "" || value == "none" {
return out, nil
}
for _, item := range strings.Fields(value) {
share, list, ok := strings.Cut(item, "=")
if !ok || !validName(share) || list == "" {
problems = append(problems, fmt.Sprintf("the grant %q is not share=node:rw|ro[,node:rw|ro]", item))
continue
}
if seen[share] {
problems = append(problems, fmt.Sprintf("the share %q is granted twice; one entry per share", share))
continue
}
seen[share] = true
nodes := map[string]string{}
good := true
for _, g := range strings.Split(list, ",") {
node, access, ok := strings.Cut(g, ":")
if !ok || !validName(node) || (access != "rw" && access != "ro") {
problems = append(problems, fmt.Sprintf("the grant %q of share %s is not node:rw or node:ro", g, share))
good = false
break
}
nodes[node] = access
}
if good {
out[share] = nodes
}
}
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
}
// IsBackupRepository says whether a directory is a restic repository: a `config` file beside `keys`,
// `snapshots` and `data`. It looks at names only.
func (m *Machine) IsBackupRepository(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
}
// WalkDepth is how many levels below a share are looked through for a backup repository; WalkLimit how
// many folders at most, after which the share is refused rather than exported unchecked.
const (
WalkDepth = 4
WalkLimit = 200000
)
// LookKept is how long a look below a share that found no backup repository is believed. The look walks up
// to WalkLimit folders, which on a large library is too much for every 30 s pass; a repository put under a
// share is found within this long. A look that found one, or was cut short, is never kept: the share stays
// refused and is looked at again on the next pass.
const LookKept = time.Hour
// repositoryBelow finds a backup repository within WalkDepth levels below dir. complete is false when
// the look stopped at its bound before it saw everything.
func (m *Machine) repositoryBelow(dir string) (found string, complete bool) {
now := time.Now()
if m.Now != nil {
now = m.Now()
}
m.cleanMu.Lock()
defer m.cleanMu.Unlock()
if at, kept := m.clean[dir]; kept && now.Sub(at) < LookKept {
return "", true
}
found, complete = m.walkBelow(dir)
if found == "" && complete {
if m.clean == nil {
m.clean = map[string]time.Time{}
}
m.clean[dir] = now
} else {
delete(m.clean, dir)
}
return found, complete
}
// walkBelow is the look itself, breadth first, bounded by WalkDepth and WalkLimit.
func (m *Machine) walkBelow(dir string) (found string, complete bool) {
limit := m.WalkLimit
if limit <= 0 {
limit = WalkLimit
}
seen := 0
level := []string{dir}
for depth := 1; depth <= WalkDepth && len(level) > 0; depth++ {
var next []string
for _, d := range level {
for _, child := range m.Children(d) {
seen++
if seen > limit {
return "", false
}
p := path.Join(d, child)
if m.IsBackupRepository(p) {
return p, true
}
next = append(next, p)
}
}
level = next
}
return "", true
}
// mpFor is the export's mp option for a real path: `mp` when it is a mount point of its own, `mp=<the
// mount point holding it>` when that is not the root filesystem, else nothing.
func mpFor(points []string, real string) string {
best := ""
for _, p := range points {
if p == real {
return "mp"
}
if p != "/" && strings.HasPrefix(real, p+"/") && len(p) > len(best) {
best = p
}
}
if best == "" {
return ""
}
return "mp=" + best
}
// lower is the narrower of two accesses: ro wins.
func lower(a, b string) string {
if a == "ro" || b == "ro" {
return "ro"
}
return "rw"
}
// Judge turns the asked-for shares into export lines, or refusals, for the nodes granted and wanting
// each; what does not match is said.
func (m *Machine) Judge(c Config, roster map[string]string, wants map[string][]Want) ([]Exported, []Refused, []string) {
var out []Exported
var refused []Refused
var said []string
var prefix netip.Prefix
if c.Range != "" {
prefix, _ = netip.ParsePrefix(c.Range)
}
points := m.MountPoints()
shares := map[string]bool{}
for _, s := range c.Shares {
shares[s.Name] = true
}
for _, s := range c.Shares {
no := func(why string) { refused = append(refused, Refused{Name: s.Name, Path: s.Path, Why: why}) }
clean := path.Clean(s.Path)
switch {
case !path.IsAbs(s.Path):
no(fmt.Sprintf("The share %s names %q, which is not an absolute path.", s.Name, s.Path))
continue
case strings.ContainsAny(s.Path, " \t\"\\#"):
no(fmt.Sprintf("The share %s's path %q has a character an export line cannot carry.", s.Name, s.Path))
continue
}
real, err := m.Real(clean)
if err != nil {
no(fmt.Sprintf("The share %s's folder %s is not there (%v); the module never creates it.", s.Name, clean, err))
continue
}
via := ""
if real != clean {
via = fmt.Sprintf(" (%s is a symlink to %s)", clean, real)
}
if real == "/" {
no(fmt.Sprintf("The share %s is the machine's root%s, which is never exported.", s.Name, via))
continue
}
if strings.ContainsAny(real, " \t\"\\#") {
no(fmt.Sprintf("The share %s's real path %q has a character an export line cannot carry.", s.Name, real))
continue
}
o, err := m.Stat(real)
if err != nil {
no(fmt.Sprintf("The share %s's folder %s is not there (%v); the module never creates it.", s.Name, real, err))
continue
}
if !o.Dir {
no(fmt.Sprintf("The share %s's path %s%s is not a folder.", s.Name, real, via))
continue
}
if o.UID == 0 || o.GID == 0 {
no(fmt.Sprintf("The share %s's folder %s%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, via, o.UID, o.GID))
continue
}
if m.IsBackupRepository(real) {
no(fmt.Sprintf("The share %s's folder %s%s is a backup repository, and backups are never exported.", s.Name, real, via))
continue
}
inside := ""
for p := path.Dir(real); ; p = path.Dir(p) {
if m.IsBackupRepository(p) {
inside = p
break
}
if p == "/" {
break
}
}
if inside != "" {
no(fmt.Sprintf("The share %s's folder %s%s is inside the backup repository %s, and backups are never exported.", s.Name, real, via, inside))
continue
}
holds, complete := m.repositoryBelow(real)
if holds != "" {
no(fmt.Sprintf("The share %s's folder %s%s holds a backup repository (%s), and backups are never exported: share the folders beside it instead.", s.Name, real, via, holds))
continue
}
if !complete {
no(fmt.Sprintf("The share %s's folder %s%s holds more folders than the module looks through (%d levels, at most %d folders), so it could not make sure no backup repository is under it: share smaller folders.", s.Name, real, via, WalkDepth, m.limit()))
continue
}
if c.Range == "" {
no(fmt.Sprintf("The share %s is not exported: the private network's range is not known.", s.Name))
continue
}
e := Exported{Name: s.Name, Path: real, Access: s.Access, UID: o.UID, GID: o.GID, Mp: mpFor(points, real), To: []Grantee{}}
granted := c.Grants[s.Name]
nodes := make([]string, 0, len(wants))
for n := range wants {
nodes = append(nodes, n)
}
sort.Strings(nodes)
wanting := map[string]bool{}
for _, node := range nodes {
for _, w := range wants[node] {
if w.Share != s.Name {
continue
}
wanting[node] = true
g, ok := granted[node]
if !ok {
said = append(said, fmt.Sprintf("%s wants %s, and this machine's grants do not name it, so it is not exported to it", node, s.Name))
continue
}
addr, known := roster[node]
if !known {
said = append(said, fmt.Sprintf("%s wants %s, and the mesh's roster names no such machine, so it is not exported to it", node, s.Name))
continue
}
a, err := netip.ParseAddr(addr)
if err != nil || !prefix.Contains(a) {
said = append(said, fmt.Sprintf("%s's address %s is not inside the private network's range %s, so %s is not exported to it", node, addr, c.Range, s.Name))
continue
}
access := lower(lower(g, w.Access), s.Access)
e.To = append(e.To, Grantee{Node: node, Address: addr, Access: access})
}
}
grantedNodes := make([]string, 0, len(granted))
for n := range granted {
if !wanting[n] {
grantedNodes = append(grantedNodes, n)
}
}
sort.Strings(grantedNodes)
for _, n := range grantedNodes {
said = append(said, fmt.Sprintf("%s is granted to %s, which does not want it", s.Name, n))
}
var addrs []string
for _, g := range e.To {
addrs = append(addrs, g.Address)
}
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))
}
out = append(out, e)
}
for share := range c.Grants {
if !shares[share] {
said = append(said, fmt.Sprintf("the grants name %s, which is not one of this machine's shares", share))
}
}
sort.Strings(said)
return out, refused, said
}
func (m *Machine) limit() int {
if m.WalkLimit > 0 {
return m.WalkLimit
}
return WalkLimit
}
// ExportLine is one share's line in the export file, or empty when it goes to no node.
func ExportLine(e Exported) string {
if len(e.To) == 0 {
return ""
}
var b strings.Builder
b.WriteString(e.Path)
for _, g := range e.To {
opts := g.Access
if e.Mp != "" {
opts += "," + e.Mp
}
fmt.Fprintf(&b, " %s(%s,sync,secure,no_subtree_check,all_squash,anonuid=%d,anongid=%d)", g.Address, opts, e.UID, e.GID)
}
return b.String()
}
// ExportFileText is the whole export file.
func ExportFileText(exports []Exported) string {
var b strings.Builder
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("# every client squashed to the folder's owner.\n")
for _, e := range exports {
if e.Line == "" {
fmt.Fprintf(&b, "# share %s: exported to no node\n", e.Name)
continue
}
fmt.Fprintf(&b, "# share %s\n%s\n", e.Name, e.Line)
}
return b.String()
}