Files
nasmon/internal/shares/validate.go
T
a.gritsenkoandClaude Opus 5.5 b331bc736d
build / build (push) Successful in 1m7s
Shares: allow any folder, not only ZFS datasets
SMB shares and NFS exports may now point to any existing directory
(USB disks under /mnt or /media, /srv, home folders...) instead of only
ZFS dataset mountpoints. System trees (/etc, /usr, /var, /boot, /proc,
/root, ...) are refused, also after resolving symlinks. The path
suggestions list mounted data filesystems from /proc/self/mounts in
addition to ZFS mountpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:48:31 +03:00

165 lines
4.4 KiB
Go

package shares
import (
"errors"
"fmt"
"net"
"path"
"regexp"
"strings"
)
var (
reShareName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$`)
reWorkgroup = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,14}$`)
reUser = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
reHost = regexp.MustCompile(`^[A-Za-z0-9*][A-Za-z0-9*.-]{0,253}$`)
reComment = regexp.MustCompile(`^[^\r\n\[\]]{0,120}$`)
)
// reserved Samba section names that must not be used as share names.
var reservedShares = map[string]bool{"global": true, "homes": true, "printers": true, "print$": true}
func ValidateShareName(s string) error {
if !reShareName.MatchString(s) {
return errors.New("share name: 1-80 characters, letters, digits, _ . - and must start with a letter or digit")
}
if reservedShares[strings.ToLower(s)] {
return fmt.Errorf("share name %q is reserved by Samba", s)
}
return nil
}
func ValidateWorkgroup(s string) error {
if !reWorkgroup.MatchString(s) {
return errors.New("workgroup: 1-15 characters, letters, digits, _ -")
}
return nil
}
func ValidateUserName(s string) error {
if !reUser.MatchString(s) {
return errors.New("user name: lowercase letters, digits, _ -, up to 32 characters")
}
if s == "root" || s == "nobody" {
return fmt.Errorf("user %s cannot be an SMB user", s)
}
return nil
}
func ValidateComment(s string) error {
if !reComment.MatchString(s) {
return errors.New("comment: up to 120 characters, no [ ] or line breaks")
}
return nil
}
// systemRoots are OS trees that must never be shared.
var systemRoots = []string{"/etc", "/proc", "/sys", "/dev", "/boot", "/run", "/usr", "/bin", "/sbin",
"/lib", "/lib32", "/lib64", "/libx32", "/var", "/root", "/snap", "/lost+found"}
// ValidateNotSystem refuses the root filesystem and system directories.
func ValidateNotSystem(p string) error {
if p == "/" {
return errors.New("refusing to share the root filesystem")
}
if UnderRoots(p, systemRoots) {
return fmt.Errorf("%s is a system directory and cannot be shared", p)
}
return nil
}
// ValidatePath accepts an absolute, clean, non-system path without control
// characters. Existence and symlinks are checked by the caller.
func ValidatePath(p string) error {
if p == "" {
return errors.New("path is required")
}
if !strings.HasPrefix(p, "/") || path.Clean(p) != p || strings.ContainsAny(p, "\"\\\r\n\t") {
return errors.New("path must be absolute and normalised (no .., trailing / or quotes)")
}
return ValidateNotSystem(p)
}
// UnderRoots reports whether p equals or lies below one of the roots.
func UnderRoots(p string, roots []string) bool {
for _, r := range roots {
if r == "" || r == "/" {
continue
}
if p == r || strings.HasPrefix(p, r+"/") {
return true
}
}
return false
}
// ValidateClient accepts *, a host name (wildcards allowed), an IP or a CIDR.
func ValidateClient(c string) error {
if c == "*" {
return nil
}
if strings.Contains(c, "/") {
if _, _, err := net.ParseCIDR(c); err != nil {
return fmt.Errorf("client %q: bad network, use e.g. 192.168.1.0/24", c)
}
return nil
}
if net.ParseIP(c) != nil || reHost.MatchString(c) {
return nil
}
return fmt.Errorf("client %q: use a host name, IP address, network (CIDR) or *", c)
}
func (s SMBShare) Validate() error {
if err := ValidateShareName(s.Name); err != nil {
return err
}
if err := ValidatePath(s.Path); err != nil {
return err
}
if err := ValidateComment(s.Comment); err != nil {
return err
}
if !s.Guest && len(s.Users) == 0 {
return fmt.Errorf("share %s: allow guests or pick at least one user", s.Name)
}
for _, u := range s.Users {
if err := ValidateUserName(u); err != nil {
return err
}
}
return nil
}
func (e NFSExport) Validate() error {
if err := ValidatePath(e.Path); err != nil {
return err
}
if len(e.Clients) == 0 {
return fmt.Errorf("export %s: at least one client is required (or *)", e.Path)
}
for _, c := range e.Clients {
if err := ValidateClient(c); err != nil {
return err
}
}
switch e.Squash {
case "root", "all", "none":
default:
return errors.New("squash must be root, all or none")
}
return nil
}
// SplitList turns "a, b;c\nd" into ["a","b","c","d"].
func SplitList(s string) []string {
var out []string
for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ';' || r == '\n' || r == ' ' || r == '\t' || r == '\r' }) {
if f = strings.TrimSpace(f); f != "" {
out = append(out, f)
}
}
return out
}