282 lines
8.2 KiB
Go
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
|
|
}
|