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=`). 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=", 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} walk := m.walking() for depth := 1; depth <= WalkDepth && len(level) > 0; depth++ { var next []string for _, d := range level { if walk != nil && walk.Err() != nil { return "", false // ended by the process stopping: unfinished, so refused } 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=` 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() backup, _ := m.Backup() // judged on every pass, never kept: see backup.go backup.Paths = m.realPaths(backup.Paths) 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 p, how := nearBackup(real, backup); p != "" { no(fmt.Sprintf("The share %s's folder %s%s %s %s, where this machine's backup seat (%s) keeps its data, and backups are never exported.", s.Name, real, via, how, p, backup.Holder)) 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() }