feat: add invalid_users directive for Samba shares

Samba shares now support an 'invalid users' list (deny list), written
as 'invalid users = u1,u2' in smb.conf. The UI shows a ChipPicker
for valid_users and invalid_users, mutually exclusive, sourced from
the system user list.

feat: add ImportSystemUsers for fresh installations

When NASCTL_IMPORT_ON_BOOT=true, nasctl now imports existing system
users from /etc/passwd (UID 1000-60000) and /etc/group (supplemental
groups), and detects which have Samba accounts via 'pdbedit -L'.
Imported users are marked dirty so the admin can review before applying.
New POST /api/import/users endpoint for manual re-import.

This mirrors the existing import-on-boot flow for smb.conf and /etc/exports.
This commit is contained in:
2026-07-06 15:17:32 -04:00
parent 3e290164c5
commit 03e9368a91
18 changed files with 505 additions and 77 deletions
+155
View File
@@ -0,0 +1,155 @@
package importer
import (
"bufio"
"context"
"os"
"sort"
"strconv"
"strings"
"github.com/darroyo/nasctl/internal/db"
"github.com/darroyo/nasctl/internal/system"
)
const (
UserMinUID = 1000
UserMaxUID = 60000
)
var excludedUsernames = map[string]bool{
"nobody": true,
"nogroup": true,
"sshd": true,
"systemd": true,
"messagebus": true,
"polkitd": true,
}
func ImportSystemUsers(ctx context.Context, adminUsername string) ([]db.User, error) {
passwdMap, err := readPasswd()
if err != nil {
return nil, err
}
groupMap, err := readGroups()
if err != nil {
return nil, err
}
smbUsers, err := listSMBUsers(ctx)
if err != nil {
smbUsers = map[string]bool{}
}
var users []db.User
for username, uid := range passwdMap {
if uid < UserMinUID || uid > UserMaxUID {
continue
}
if excludedUsernames[username] {
continue
}
if username == adminUsername {
continue
}
users = append(users, db.User{
Username: username,
Groups: groupMap[username],
SMBEnabled: smbUsers[username],
Disabled: false,
})
}
sort.Slice(users, func(i, j int) bool {
return users[i].Username < users[j].Username
})
return users, nil
}
func readPasswd() (map[string]int, error) {
f, err := os.Open("/etc/passwd")
if err != nil {
return nil, err
}
defer f.Close()
result := make(map[string]int)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.Split(line, ":")
if len(parts) < 3 {
continue
}
username := parts[0]
uid, err := strconv.Atoi(parts[2])
if err != nil {
continue
}
result[username] = uid
}
if err := scanner.Err(); err != nil {
return nil, err
}
return result, nil
}
func readGroups() (map[string][]string, error) {
f, err := os.Open("/etc/group")
if err != nil {
return nil, err
}
defer f.Close()
result := make(map[string][]string)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.Split(line, ":")
if len(parts) < 4 {
continue
}
membersStr := strings.TrimSpace(parts[3])
if membersStr == "" {
continue
}
for _, member := range strings.Split(membersStr, ",") {
member = strings.TrimSpace(member)
if member != "" {
result[member] = append(result[member], parts[0])
}
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return result, nil
}
func listSMBUsers(ctx context.Context) (map[string]bool, error) {
stdout, _, err := system.Run(ctx, "pdbedit", "-L")
if err != nil {
return map[string]bool{}, nil
}
result := make(map[string]bool)
for _, line := range strings.Split(stdout, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) < 1 {
continue
}
result[parts[0]] = true
}
return result, nil
}