build / build (push) Successful in 1m7s
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>
165 lines
4.4 KiB
Go
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
|
|
}
|