530 lines
18 KiB
Go
530 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}
|
|
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=<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()
|
|
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()
|
|
}
|