Files
mesh-catalog/modules/mounts/cmd/mounts/exports.go
T

282 lines
8.2 KiB
Go

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
}