package main import ( "context" "fmt" "sort" "strings" ) // Export is one path offered to one client over NFS. type Export struct { Path string `json:"path"` Client string `json:"client"` Options string `json:"options,omitempty"` File string `json:"file"` Line int `json:"line,omitempty"` } // ParseExports reads an exports(5) file: a path, then clients each with its options in brackets; a // `-options` word sets the default for the clients after it; a backslash continues a line. func ParseExports(text, file string) []Export { out := []Export{} lines := strings.Split(text, "\n") for i := 0; i < len(lines); i++ { start := i + 1 line := lines[i] for strings.HasSuffix(strings.TrimRight(line, " \t"), `\`) && i+1 < len(lines) { line = strings.TrimSuffix(strings.TrimRight(line, " \t"), `\`) + " " + lines[i+1] i++ } if c := strings.Index(line, "#"); c >= 0 { line = line[:c] } f := splitExportLine(line) if len(f) == 0 { continue } path := unescape(strings.Trim(f[0], `"`)) defaults := "" clients := 0 for _, w := range f[1:] { if strings.HasPrefix(w, "-") { defaults = strings.TrimPrefix(w, "-") continue } client, opts := w, defaults if o := strings.Index(w, "("); o >= 0 { client = w[:o] opts = strings.TrimSuffix(w[o+1:], ")") } if client == "" { client = "*" } out = append(out, Export{Path: path, Client: client, Options: opts, File: file, Line: start}) clients++ } if clients == 0 { out = append(out, Export{Path: path, Client: "*", Options: defaults, File: file, Line: start}) } } return out } // splitExportLine splits on blanks outside double quotes. func splitExportLine(line string) []string { var out []string var cur strings.Builder quoted := false for _, r := range line { switch { case r == '"': quoted = !quoted cur.WriteRune(r) case (r == ' ' || r == '\t') && !quoted: if cur.Len() > 0 { out = append(out, cur.String()) cur.Reset() } default: cur.WriteRune(r) } } if cur.Len() > 0 { out = append(out, cur.String()) } return out } // ZFSShare is one dataset's sharenfs or sharesmb property. type ZFSShare struct { Dataset string `json:"dataset"` Property string `json:"property"` Value string `json:"value"` Source string `json:"source"` } // ParseZFSGet reads `zfs get -H -o name,property,value,source`. func ParseZFSGet(text string) []ZFSShare { out := []ZFSShare{} for _, line := range strings.Split(text, "\n") { f := strings.Split(line, "\t") if len(f) < 4 { continue } out = append(out, ZFSShare{Dataset: f[0], Property: f[1], Value: f[2], Source: f[3]}) } return out } // SMBShare is one Samba share: what it serves and to whom, never a password. type SMBShare struct { Name string `json:"name"` Path string `json:"path,omitempty"` Settings map[string]string `json:"settings"` } // smbKept are the share settings answered. Everything else in a section — and the [global] section // whole — is left out, so no password, password backend or secret a file may hold is ever answered. var smbKept = map[string]bool{ "comment": true, "valid users": true, "invalid users": true, "read only": true, "writable": true, "writeable": true, "write ok": true, "write list": true, "read list": true, "force user": true, "force group": true, "guest ok": true, "public": true, "browseable": true, "browsable": true, "hosts allow": true, "hosts deny": true, "available": true, "create mask": true, "directory mask": true, } // ParseSMBConf reads smb.conf's share sections. func ParseSMBConf(text string) []SMBShare { out := []SMBShare{} var cur *SMBShare for _, raw := range strings.Split(text, "\n") { line := strings.TrimSpace(raw) if line == "" || line[0] == '#' || line[0] == ';' { continue } if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { name := strings.TrimSpace(line[1 : len(line)-1]) if strings.EqualFold(name, "global") { cur = nil continue } out = append(out, SMBShare{Name: name, Settings: map[string]string{}}) cur = &out[len(out)-1] continue } if cur == nil { continue } k, v, ok := strings.Cut(line, "=") if !ok { continue } key := strings.Join(strings.Fields(strings.ToLower(k)), " ") v = strings.TrimSpace(v) if key == "path" { cur.Path = v continue } if smbKept[key] { cur.Settings[key] = v } } return out } // ExportsReport is what mounts_exports answers. type ExportsReport struct { Said []string `json:"said"` Services map[string]string `json:"services"` Exports []Export `json:"exports"` Live []Export `json:"live_exports"` ZFS []ZFSShare `json:"zfs_shares,omitempty"` SMB []SMBShare `json:"smb_shares"` Notes []string `json:"notes,omitempty"` } // Exports reads what this machine shares over the network. func (m *Machine) Exports() ExportsReport { r := ExportsReport{Said: []string{}, Services: map[string]string{}, Exports: []Export{}, Live: []Export{}, SMB: []SMBShare{}} ctx := context.Background() units := []string{"nfs-server.service", "smb.service"} active, err := m.Run(ctx, "systemctl", append([]string{"is-active"}, units...)...) states := strings.Fields(active) for i, u := range units { if i < len(states) { r.Services[u] = states[i] } else if err != nil { r.Services[u] = "not read" } } files := []string{"/etc/exports"} files = append(files, m.glob("/etc/exports.d/*.exports")...) for _, f := range files { text, err := m.read(f) if err != nil { r.Notes = append(r.Notes, unreadable(f, err)) continue } r.Exports = append(r.Exports, ParseExports(text, f)...) } if etab, err := m.read("/var/lib/nfs/etab"); err != nil { r.Notes = append(r.Notes, unreadable("/var/lib/nfs/etab", err)+" — the kernel's live exports are not known") } else { for _, line := range strings.Split(etab, "\n") { path, rest, ok := strings.Cut(line, "\t") if !ok { continue } for _, e := range ParseExports("x "+rest, "/var/lib/nfs/etab") { r.Live = append(r.Live, Export{Path: unescape(path), Client: e.Client, Options: e.Options, File: e.File}) } } } if m.LookPath != nil && m.LookPath("zfs") { out, err := m.Run(ctx, "zfs", "get", "-H", "-o", "name,property,value,source", "-t", "filesystem", "sharenfs,sharesmb") if err != nil { r.Notes = append(r.Notes, fmt.Sprintf("the ZFS share properties were not read: %v", err)) } for _, z := range ParseZFSGet(out) { if z.Value != "off" && z.Value != "-" { r.ZFS = append(r.ZFS, z) } } } if conf, err := m.read("/etc/samba/smb.conf"); err != nil { r.Notes = append(r.Notes, unreadable("/etc/samba/smb.conf", err)) } else { r.SMB = ParseSMBConf(conf) } r.Said = r.say() return r } func (r ExportsReport) say() []string { said := []string{} for _, u := range []string{"nfs-server.service", "smb.service"} { said = append(said, fmt.Sprintf("%s: %s", u, r.Services[u])) } live := map[string]bool{} for _, e := range r.Live { live[e.Path+" "+e.Client] = true } for _, e := range r.Exports { line := fmt.Sprintf("NFS %s to %s (%s), in %s", e.Path, e.Client, e.Options, e.File) if len(r.Live) > 0 && !live[e.Path+" "+e.Client] { line += " — NOT in the kernel's live exports" } said = append(said, line) } declared := map[string]bool{} for _, e := range r.Exports { declared[e.Path+" "+e.Client] = true } for _, e := range r.Live { if !declared[e.Path+" "+e.Client] { said = append(said, fmt.Sprintf("NFS %s to %s is live but in no exports file (set by hand with exportfs, or by ZFS before its file was written)", e.Path, e.Client)) } } for _, z := range r.ZFS { said = append(said, fmt.Sprintf("ZFS %s %s=%s (%s)", z.Dataset, z.Property, z.Value, z.Source)) } sort.SliceStable(r.SMB, func(i, j int) bool { return r.SMB[i].Name < r.SMB[j].Name }) for _, s := range r.SMB { line := fmt.Sprintf("SMB [%s] %s", s.Name, s.Path) if v := s.Settings["valid users"]; v != "" { line += ", for " + v } if v := s.Settings["force user"]; v != "" { line += ", writes as " + v } said = append(said, line) } if len(r.Exports) == 0 && len(r.Live) == 0 && len(r.ZFS) == 0 && len(r.SMB) == 0 { said = append(said, "this machine shares nothing over NFS or SMB") } return said }