Add nasctl: Go NAS control plane with React frontend
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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(`^[a-zA-Z0-9_.:/\-\*]+$`)
|
||||
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.
|
||||
func ValidateNFSClient(client string) error {
|
||||
if client == "" || len(client) > 255 {
|
||||
return fmt.Errorf("invalid nfs client length")
|
||||
}
|
||||
if !nfsClientPattern.MatchString(client) {
|
||||
return fmt.Errorf("invalid nfs client: %q", client)
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user