512feaffd7
This is a backward-compatible MINOR bump (0.4.0 → 0.5.0).
BREAKING NOTES (for users upgrading from pre-0.5.0):
- The nfs_exports.clients column schema changed from []string to
[]NFSClient (per-host options). A migration (0005) transforms existing
string arrays into object arrays, taking export-level options as
defaults for each host.
- ValidateNFSClient now only accepts IPv4 (192.168.1.1) or IPv4/CIDR
(192.168.1.0/24). Hostnames, wildcards, netgroups are rejected.
- If you use NASCTL_IMPORT_ON_BOOT, re-import your /etc/exports to pick
up per-host options.
What changed:
- NFSClient type: {host, read_only, async, root_squash, subtree_check, advanced}
- NFSExport.Clients is now []NFSClient (was []string)
- export-level flags (ro/async/root_squash/subtree_check/advanced) are
preserved as template defaults for newly added hosts in the UI.
- buildExportLine generates: path host1(ro,sync,...) host2(rw,async,...) fsid=N
- ValidateNFSClient: strict IPv4/CIDR only (0-255 octets, /0-32 prefix)
- parseExportLine now parses per-host options from /etc/exports (previously
only the first host's options were kept, others were discarded)
- UI: per-host rows with toggles (ro/async/root_squash/subtree_check) and
advanced options (all_squash, secure, wdelay, hide, crossmnt)
161 lines
4.9 KiB
Go
161 lines
4.9 KiB
Go
package system
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
usernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]*[$]?$`)
|
|
shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
|
nfsOptionPattern = regexp.MustCompile(`^[a-z_]+$`)
|
|
nfsClientPattern = regexp.MustCompile(`^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(/\d{1,2})?$`)
|
|
allowedNFSOptions = map[string]bool{
|
|
"rw": true, "ro": true, "sync": true, "async": true,
|
|
"root_squash": true, "no_root_squash": true, "all_squash": true,
|
|
"no_all_squash": true, "subtree_check": true, "no_subtree_check": true,
|
|
"secure": true, "insecure": true, "wdelay": true, "no_wdelay": true,
|
|
"hide": true, "nohide": true, "crossmnt": true, "fsid": true,
|
|
}
|
|
)
|
|
|
|
// Run executes a command with explicit arguments (never via shell).
|
|
func Run(ctx context.Context, name string, args ...string) (stdout, stderr string, err error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
var outBuf, errBuf strings.Builder
|
|
cmd.Stdout = &outBuf
|
|
cmd.Stderr = &errBuf
|
|
err = cmd.Run()
|
|
return outBuf.String(), errBuf.String(), err
|
|
}
|
|
|
|
// RunWithInput executes a command feeding stdin from the provided string.
|
|
// Used for secrets (e.g. passwords) so they never appear in the argument list.
|
|
func RunWithInput(ctx context.Context, stdin, name string, args ...string) (stdout, stderr string, err error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Stdin = strings.NewReader(stdin)
|
|
var outBuf, errBuf strings.Builder
|
|
cmd.Stdout = &outBuf
|
|
cmd.Stderr = &errBuf
|
|
err = cmd.Run()
|
|
return outBuf.String(), errBuf.String(), err
|
|
}
|
|
|
|
// UserExists reports whether a system user is present (via `id -u`).
|
|
func UserExists(ctx context.Context, username string) bool {
|
|
_, _, err := Run(ctx, "id", "-u", username)
|
|
return err == nil
|
|
}
|
|
|
|
// ValidateUsername checks Linux username constraints.
|
|
func ValidateUsername(username string) error {
|
|
if username == "" || len(username) > 32 {
|
|
return fmt.Errorf("invalid username length")
|
|
}
|
|
if !usernamePattern.MatchString(username) {
|
|
return fmt.Errorf("invalid username: %q", username)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidatePath ensures path is absolute and does not contain traversal.
|
|
func ValidatePath(path string) error {
|
|
if path == "" {
|
|
return fmt.Errorf("path is required")
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
return fmt.Errorf("path must be absolute: %q", path)
|
|
}
|
|
clean := filepath.Clean(path)
|
|
if strings.Contains(clean, "..") {
|
|
return fmt.Errorf("path must not contain .. segments: %q", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateShareName checks Samba share name constraints.
|
|
func ValidateShareName(name string) error {
|
|
if name == "" || len(name) > 80 {
|
|
return fmt.Errorf("invalid share name length")
|
|
}
|
|
if !shareNamePattern.MatchString(name) {
|
|
return fmt.Errorf("invalid share name: %q", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidatePathAllowed validates a path and, if allowedRoots is non-empty,
|
|
// ensures the path is contained within one of the configured roots.
|
|
func ValidatePathAllowed(path string, allowedRoots []string) error {
|
|
if err := ValidatePath(path); err != nil {
|
|
return err
|
|
}
|
|
if len(allowedRoots) == 0 {
|
|
return nil
|
|
}
|
|
clean := filepath.Clean(path)
|
|
for _, root := range allowedRoots {
|
|
root = filepath.Clean(root)
|
|
if clean == root || strings.HasPrefix(clean, root+string(filepath.Separator)) {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("path %q is not within an allowed directory", path)
|
|
}
|
|
|
|
// ValidateNFSClient checks an NFS client/network specifier.
|
|
// Accepts IPv4 addresses (e.g. 192.168.1.20) and IPv4 CIDR (e.g. 192.168.1.0/24).
|
|
func ValidateNFSClient(client string) error {
|
|
if client == "" || len(client) > 255 {
|
|
return fmt.Errorf("invalid nfs client length")
|
|
}
|
|
|
|
var ipPart string
|
|
var prefix int = 32
|
|
|
|
if idx := strings.IndexByte(client, '/'); idx >= 0 {
|
|
ipPart = client[:idx]
|
|
if _, err := fmt.Sscanf(client[idx+1:], "%d", &prefix); err != nil || prefix < 0 || prefix > 32 {
|
|
return fmt.Errorf("invalid nfs client CIDR prefix %q (must be 0-32): %q", client[idx+1:], client)
|
|
}
|
|
} else {
|
|
ipPart = client
|
|
}
|
|
|
|
octets := strings.Split(ipPart, ".")
|
|
if len(octets) != 4 {
|
|
return fmt.Errorf("invalid nfs client %q: must be IPv4 or IPv4/CIDR", client)
|
|
}
|
|
for _, o := range octets {
|
|
var n int
|
|
if _, err := fmt.Sscanf(o, "%d", &n); err != nil || n < 0 || n > 255 {
|
|
return fmt.Errorf("invalid nfs client %q: octet %q out of range (0-255)", client, o)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateNFSOptions checks a comma-separated list of export options against
|
|
// a whitelist, rejecting anything that could break the exports file.
|
|
func ValidateNFSOptions(options string) error {
|
|
for _, opt := range strings.Split(options, ",") {
|
|
opt = strings.TrimSpace(opt)
|
|
if opt == "" {
|
|
continue
|
|
}
|
|
key := opt
|
|
if idx := strings.IndexByte(opt, '='); idx >= 0 {
|
|
key = opt[:idx]
|
|
}
|
|
if !nfsOptionPattern.MatchString(key) || !allowedNFSOptions[key] {
|
|
return fmt.Errorf("invalid nfs option: %q", opt)
|
|
}
|
|
}
|
|
return nil
|
|
}
|