From 03e9368a9146ea8ee9d88ca85f474ffd66e6a860 Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Mon, 6 Jul 2026 15:17:32 -0400 Subject: [PATCH] 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. --- Makefile | 2 +- cmd/nasctl/main.go | 6 +- .../migrations/0007_samba_invalid_users.sql | 1 + internal/db/models.go | 21 +-- internal/db/queries.go | 29 +++- internal/db/queries_import.go | 10 +- internal/db/queries_users.go | 29 ++++ internal/importer/import.go | 62 ++++++- internal/importer/users.go | 155 ++++++++++++++++++ internal/modules/samba/samba.go | 16 +- internal/modules/samba/smb.conf.tmpl | 3 + internal/web/auth.go | 3 + internal/web/handlers_import.go | 49 +++++- internal/web/handlers_samba.go | 35 ++-- internal/web/router.go | 1 + web/src/api.ts | 4 + web/src/pages/Samba.tsx | 136 ++++++++++++--- web/src/pages/Users.tsx | 20 ++- 18 files changed, 505 insertions(+), 77 deletions(-) create mode 100644 internal/db/migrations/0007_samba_invalid_users.sql create mode 100644 internal/importer/users.go diff --git a/Makefile b/Makefile index ef89bb3..d5ae7f0 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY=nasctl -VERSION?=0.5.3 +VERSION?=0.6.0 GO?=go LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_FLAGS=CGO_ENABLED=0 diff --git a/cmd/nasctl/main.go b/cmd/nasctl/main.go index 3af0fbd..32f52d8 100644 --- a/cmd/nasctl/main.go +++ b/cmd/nasctl/main.go @@ -59,13 +59,16 @@ func main() { } if *importOnBoot { - result := importer.ImportOnBoot(nil, database, *smbConfPath, *exportsPath) + result := importer.ImportOnBoot(nil, database, *smbConfPath, *exportsPath, *adminUser) if result.SambaImported > 0 { log.Printf("[importer] imported %d existing samba shares — review and apply", result.SambaImported) } if result.NFSImported > 0 { log.Printf("[importer] imported %d existing nfs exports — review and apply", result.NFSImported) } + if result.UsersImported > 0 { + log.Printf("[importer] imported %d system users — review and apply", result.UsersImported) + } } sambaModule := samba.New(samba.Config{ @@ -87,6 +90,7 @@ func main() { Auth: auth, SMBConfPath: *smbConfPath, ExportsPath: *exportsPath, + AdminUsername: *adminUser, UploadMaxBytes: *uploadMaxBytes, PreviewMaxBytes: *previewMaxBytes, }) diff --git a/internal/db/migrations/0007_samba_invalid_users.sql b/internal/db/migrations/0007_samba_invalid_users.sql new file mode 100644 index 0000000..54ecee1 --- /dev/null +++ b/internal/db/migrations/0007_samba_invalid_users.sql @@ -0,0 +1 @@ +ALTER TABLE samba_shares ADD COLUMN invalid_users TEXT NOT NULL DEFAULT '[]'; diff --git a/internal/db/models.go b/internal/db/models.go index 7c9f6ee..c1d8a1c 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -25,16 +25,17 @@ type Admin struct { } type SambaShare struct { - ID int64 `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - Comment string `json:"comment"` - ReadOnly bool `json:"read_only"` - GuestOK bool `json:"guest_ok"` - ValidUsers []string `json:"valid_users"` - ValidGroups []string `json:"valid_groups"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Comment string `json:"comment"` + ReadOnly bool `json:"read_only"` + GuestOK bool `json:"guest_ok"` + ValidUsers []string `json:"valid_users"` + ValidGroups []string `json:"valid_groups"` + InvalidUsers []string `json:"invalid_users"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type NFSClient struct { diff --git a/internal/db/queries.go b/internal/db/queries.go index 0f429b0..0f16521 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -42,7 +42,7 @@ func scanSambaShare(row interface { }) (SambaShare, error) { var share SambaShare var readOnly, guestOK int - var validUsers, validGroups, createdAt, updatedAt string + var validUsers, validGroups, invalidUsers, createdAt, updatedAt string if err := row.Scan( &share.ID, &share.Name, @@ -52,6 +52,7 @@ func scanSambaShare(row interface { &guestOK, &validUsers, &validGroups, + &invalidUsers, &createdAt, &updatedAt, ); err != nil { @@ -68,6 +69,10 @@ func scanSambaShare(row interface { if err != nil { return SambaShare{}, err } + share.InvalidUsers, err = decodeJSONStrings(invalidUsers) + if err != nil { + return SambaShare{}, err + } share.CreatedAt = parseTime(createdAt) share.UpdatedAt = parseTime(updatedAt) return share, nil @@ -170,7 +175,7 @@ func (d *DB) ListApplyLog(limit int) ([]ApplyLogEntry, error) { func (d *DB) ListSambaShares() ([]SambaShare, error) { rows, err := d.conn.Query(` - SELECT id, name, path, comment, read_only, guest_ok, valid_users, valid_groups, created_at, updated_at + SELECT id, name, path, comment, read_only, guest_ok, valid_users, valid_groups, invalid_users, created_at, updated_at FROM samba_shares ORDER BY name ASC`) if err != nil { return nil, fmt.Errorf("list samba shares: %w", err) @@ -190,7 +195,7 @@ func (d *DB) ListSambaShares() ([]SambaShare, error) { func (d *DB) GetSambaShare(id int64) (SambaShare, error) { row := d.conn.QueryRow(` - SELECT id, name, path, comment, read_only, guest_ok, valid_users, valid_groups, created_at, updated_at + SELECT id, name, path, comment, read_only, guest_ok, valid_users, valid_groups, invalid_users, created_at, updated_at FROM samba_shares WHERE id = ?`, id) share, err := scanSambaShare(row) if err == sql.ErrNoRows { @@ -211,6 +216,10 @@ func (d *DB) CreateSambaShare(share SambaShare) (SambaShare, error) { if err != nil { return SambaShare{}, err } + invalidUsers, err := encodeJSONStrings(share.InvalidUsers) + if err != nil { + return SambaShare{}, err + } readOnly := 0 if share.ReadOnly { readOnly = 1 @@ -220,9 +229,9 @@ func (d *DB) CreateSambaShare(share SambaShare) (SambaShare, error) { guestOK = 1 } result, err := d.conn.Exec(` - INSERT INTO samba_shares (name, path, comment, read_only, guest_ok, valid_users, valid_groups) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, + INSERT INTO samba_shares (name, path, comment, read_only, guest_ok, valid_users, valid_groups, invalid_users) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, invalidUsers, ) if err != nil { return SambaShare{}, fmt.Errorf("create samba share: %w", err) @@ -243,6 +252,10 @@ func (d *DB) UpdateSambaShare(id int64, share SambaShare) (SambaShare, error) { if err != nil { return SambaShare{}, err } + invalidUsers, err := encodeJSONStrings(share.InvalidUsers) + if err != nil { + return SambaShare{}, err + } readOnly := 0 if share.ReadOnly { readOnly = 1 @@ -253,9 +266,9 @@ func (d *DB) UpdateSambaShare(id int64, share SambaShare) (SambaShare, error) { } result, err := d.conn.Exec(` UPDATE samba_shares - SET name = ?, path = ?, comment = ?, read_only = ?, guest_ok = ?, valid_users = ?, valid_groups = ?, updated_at = datetime('now') + SET name = ?, path = ?, comment = ?, read_only = ?, guest_ok = ?, valid_users = ?, valid_groups = ?, invalid_users = ?, updated_at = datetime('now') WHERE id = ?`, - share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, id, + share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, invalidUsers, id, ) if err != nil { return SambaShare{}, fmt.Errorf("update samba share: %w", err) diff --git a/internal/db/queries_import.go b/internal/db/queries_import.go index 8a179e4..5ed9bd4 100644 --- a/internal/db/queries_import.go +++ b/internal/db/queries_import.go @@ -24,6 +24,10 @@ func (d *DB) ReplaceSambaShares(shares []SambaShare) error { if err != nil { return err } + invalidUsers, err := encodeJSONStrings(share.InvalidUsers) + if err != nil { + return err + } readOnly := 0 if share.ReadOnly { readOnly = 1 @@ -33,9 +37,9 @@ func (d *DB) ReplaceSambaShares(shares []SambaShare) error { guestOK = 1 } _, err = tx.Exec(` - INSERT INTO samba_shares (name, path, comment, read_only, guest_ok, valid_users, valid_groups) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, + INSERT INTO samba_shares (name, path, comment, read_only, guest_ok, valid_users, valid_groups, invalid_users) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, invalidUsers, ) if err != nil { return fmt.Errorf("insert samba share %s: %w", share.Name, err) diff --git a/internal/db/queries_users.go b/internal/db/queries_users.go index 85fba6d..a95d4a8 100644 --- a/internal/db/queries_users.go +++ b/internal/db/queries_users.go @@ -201,6 +201,35 @@ func (d *DB) ClearDeletedUser(username string) error { return nil } +func (d *DB) ReplaceUsers(users []User) error { + tx, err := d.conn.Begin() + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(`DELETE FROM system_users`); err != nil { + return fmt.Errorf("clear system_users: %w", err) + } + + for _, u := range users { + groups, err := encodeJSONStrings(u.Groups) + if err != nil { + return err + } + _, err = tx.Exec(` + INSERT INTO system_users (username, groups, smb_enabled, disabled, pending_password) + VALUES (?, ?, ?, ?, '')`, + u.Username, groups, boolToInt(u.SMBEnabled), boolToInt(u.Disabled), + ) + if err != nil { + return fmt.Errorf("insert user %s: %w", u.Username, err) + } + } + + return tx.Commit() +} + func (d *DB) GetAdminByUsername(username string) (Admin, error) { row := d.conn.QueryRow( `SELECT id, username, password_hash, created_at, updated_at FROM admins WHERE username = ?`, diff --git a/internal/importer/import.go b/internal/importer/import.go index 11bbe93..9783417 100644 --- a/internal/importer/import.go +++ b/internal/importer/import.go @@ -8,34 +8,42 @@ import ( "github.com/darroyo/nasctl/internal/db" "github.com/darroyo/nasctl/internal/modules/nfs" "github.com/darroyo/nasctl/internal/modules/samba" + "github.com/darroyo/nasctl/internal/modules/users" ) type ImportResult struct { SambaImported int NFSImported int + UsersImported int SambaSkipped bool - NFSSkipped bool + NFSSkipped bool + UsersSkipped bool SambaError string NFSError string + UsersError string } type ImporterDB interface { ListSambaShares() ([]db.SambaShare, error) ListNFSExports() ([]db.NFSExport, error) + ListUsers() ([]db.User, error) ReplaceSambaShares(shares []db.SambaShare) error ReplaceNFSExports(exports []db.NFSExport) error + ReplaceUsers(users []db.User) error GetSetting(key string) (string, bool, error) SetSetting(key, value string) error MarkDirty(module string) error } -func ImportOnBoot(ctx context.Context, database ImporterDB, smbPath, exportsPath string) ImportResult { +func ImportOnBoot(ctx context.Context, database ImporterDB, smbPath, exportsPath, adminUsername string) ImportResult { result := ImportResult{} sambaDone, _, _ := database.GetSetting("import.samba.done") nfsDone, _, _ := database.GetSetting("import.nfs.done") + usersDone, _, _ := database.GetSetting("import.users.done") - if sambaDone == "true" && nfsDone == "true" { + allDone := sambaDone == "true" && nfsDone == "true" && usersDone == "true" + if allDone { return result } @@ -59,8 +67,19 @@ func ImportOnBoot(ctx context.Context, database ImporterDB, smbPath, exportsPath } } - if result.SambaImported > 0 || result.NFSImported > 0 { - log.Printf("[importer] imported %d samba shares, %d nfs exports", result.SambaImported, result.NFSImported) + if usersDone != "true" { + ur := importUsers(ctx, database, adminUsername) + result.UsersImported = ur.count + result.UsersSkipped = ur.skipped + result.UsersError = ur.err + if ur.err == "" { + _ = database.SetSetting("import.users.done", "true") + } + } + + if result.SambaImported > 0 || result.NFSImported > 0 || result.UsersImported > 0 { + log.Printf("[importer] imported %d samba shares, %d nfs exports, %d users", + result.SambaImported, result.NFSImported, result.UsersImported) } return result @@ -69,13 +88,14 @@ func ImportOnBoot(ctx context.Context, database ImporterDB, smbPath, exportsPath func ResetImportFlags(ctx context.Context, database ImporterDB) error { _ = database.SetSetting("import.samba.done", "") _ = database.SetSetting("import.nfs.done", "") + _ = database.SetSetting("import.users.done", "") return nil } type importStep struct { - count int + count int skipped bool - err string + err string } func importSamba(ctx context.Context, database ImporterDB, path string) importStep { @@ -133,3 +153,31 @@ func importNFS(ctx context.Context, database ImporterDB, path string) importStep return importStep{count: len(exports)} } + +func importUsers(ctx context.Context, database ImporterDB, adminUsername string) importStep { + imported, err := ImportSystemUsers(ctx, adminUsername) + if err != nil { + return importStep{err: err.Error()} + } + if imported == nil { + return importStep{skipped: true} + } + + existing, err := database.ListUsers() + if err != nil { + return importStep{err: err.Error()} + } + if len(existing) > 0 { + return importStep{skipped: true} + } + + if err := database.ReplaceUsers(imported); err != nil { + return importStep{err: err.Error()} + } + + if err := database.MarkDirty(users.ModuleName); err != nil { + return importStep{count: len(imported), err: fmt.Sprintf("imported but could not mark dirty: %v", err)} + } + + return importStep{count: len(imported)} +} diff --git a/internal/importer/users.go b/internal/importer/users.go new file mode 100644 index 0000000..8a0152c --- /dev/null +++ b/internal/importer/users.go @@ -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 +} diff --git a/internal/modules/samba/samba.go b/internal/modules/samba/samba.go index 431b41a..8d39c8d 100644 --- a/internal/modules/samba/samba.go +++ b/internal/modules/samba/samba.go @@ -37,6 +37,7 @@ type templateShare struct { GuestOK bool ValidUsers []string ValidGroups []string + InvalidUsers []string } type templateData struct { @@ -109,13 +110,14 @@ func (m *Module) renderConfig(shares []db.SambaShare) ([]byte, error) { data := templateData{Shares: make([]templateShare, 0, len(shares))} for _, share := range shares { data.Shares = append(data.Shares, templateShare{ - Name: share.Name, - Path: share.Path, - Comment: share.Comment, - ReadOnly: share.ReadOnly, - GuestOK: share.GuestOK, - ValidUsers: share.ValidUsers, - ValidGroups: share.ValidGroups, + Name: share.Name, + Path: share.Path, + Comment: share.Comment, + ReadOnly: share.ReadOnly, + GuestOK: share.GuestOK, + ValidUsers: share.ValidUsers, + ValidGroups: share.ValidGroups, + InvalidUsers: share.InvalidUsers, }) } diff --git a/internal/modules/samba/smb.conf.tmpl b/internal/modules/samba/smb.conf.tmpl index 6b6dd87..34ee6ae 100644 --- a/internal/modules/samba/smb.conf.tmpl +++ b/internal/modules/samba/smb.conf.tmpl @@ -20,5 +20,8 @@ {{- if .ValidGroups}} valid groups = {{join .ValidGroups ","}} {{- end}} +{{- if .InvalidUsers}} + invalid users = {{join .InvalidUsers ","}} +{{- end}} {{end}} diff --git a/internal/web/auth.go b/internal/web/auth.go index 5bef0dc..dc7de80 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -196,6 +196,7 @@ type Server struct { Auth *AuthService SMBConfPath string ExportsPath string + AdminUsername string UploadMaxBytes int64 PreviewMaxBytes int64 } @@ -205,6 +206,7 @@ type Options struct { Auth *AuthService SMBConfPath string ExportsPath string + AdminUsername string UploadMaxBytes int64 PreviewMaxBytes int64 } @@ -223,6 +225,7 @@ func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server { Auth: opts.Auth, SMBConfPath: opts.SMBConfPath, ExportsPath: opts.ExportsPath, + AdminUsername: opts.AdminUsername, UploadMaxBytes: opts.UploadMaxBytes, PreviewMaxBytes: opts.PreviewMaxBytes, } diff --git a/internal/web/handlers_import.go b/internal/web/handlers_import.go index b8e6e2b..ffc2a2a 100644 --- a/internal/web/handlers_import.go +++ b/internal/web/handlers_import.go @@ -8,11 +8,13 @@ import ( "github.com/darroyo/nasctl/internal/importer" "github.com/darroyo/nasctl/internal/modules/nfs" "github.com/darroyo/nasctl/internal/modules/samba" + "github.com/darroyo/nasctl/internal/modules/users" ) type importStatus struct { - Samba moduleImportStatus `json:"samba"` - NFS moduleImportStatus `json:"nfs"` + Samba moduleImportStatus `json:"samba"` + NFS moduleImportStatus `json:"nfs"` + Users moduleImportStatus `json:"users"` } type moduleImportStatus struct { @@ -24,9 +26,11 @@ type moduleImportStatus struct { func (s *Server) handleImportStatus(w http.ResponseWriter, r *http.Request) { sambaDone, _, _ := s.DB.GetSetting("import.samba.done") nfsDone, _, _ := s.DB.GetSetting("import.nfs.done") + usersDone, _, _ := s.DB.GetSetting("import.users.done") shares, _ := s.DB.ListSambaShares() exports, _ := s.DB.ListNFSExports() + userList, _ := s.DB.ListUsers() status := importStatus{ Samba: moduleImportStatus{ @@ -37,6 +41,10 @@ func (s *Server) handleImportStatus(w http.ResponseWriter, r *http.Request) { Done: nfsDone == "true", Count: len(exports), }, + Users: moduleImportStatus{ + Done: usersDone == "true", + Count: len(userList), + }, } if ts, ok, _ := s.DB.GetSetting("import.samba.at"); ok { @@ -45,6 +53,9 @@ func (s *Server) handleImportStatus(w http.ResponseWriter, r *http.Request) { if ts, ok, _ := s.DB.GetSetting("import.nfs.at"); ok { status.NFS.LastImportAt = ts } + if ts, ok, _ := s.DB.GetSetting("import.users.at"); ok { + status.Users.LastImportAt = ts + } writeJSON(w, http.StatusOK, status) } @@ -115,9 +126,43 @@ func (s *Server) handleImportNFS(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"imported": len(exports)}) } +func (s *Server) handleImportUsers(w http.ResponseWriter, r *http.Request) { + if err := importer.ResetImportFlags(r.Context(), s.DB); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + imported, err := importer.ImportSystemUsers(r.Context(), s.AdminUsername) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if len(imported) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"imported": 0, "message": "no system users found to import"}) + return + } + + if err := s.DB.ReplaceUsers(imported); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if err := s.DB.MarkDirty(users.ModuleName); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + _ = s.DB.SetSetting("import.users.done", "true") + _ = s.DB.SetSetting("import.users.at", time.Now().Format(time.RFC3339)) + + writeJSON(w, http.StatusOK, map[string]any{"imported": len(imported)}) +} + type dbExporter interface { ReplaceSambaShares(shares []db.SambaShare) error ReplaceNFSExports(exports []db.NFSExport) error + ReplaceUsers(users []db.User) error MarkDirty(module string) error } diff --git a/internal/web/handlers_samba.go b/internal/web/handlers_samba.go index d398a58..41e9f32 100644 --- a/internal/web/handlers_samba.go +++ b/internal/web/handlers_samba.go @@ -24,13 +24,14 @@ func writeError(w http.ResponseWriter, status int, message string) { } type sambaShareRequest struct { - Name string `json:"name"` - Path string `json:"path"` - Comment string `json:"comment"` - ReadOnly bool `json:"read_only"` - GuestOK bool `json:"guest_ok"` - ValidUsers []string `json:"valid_users"` - ValidGroups []string `json:"valid_groups"` + Name string `json:"name"` + Path string `json:"path"` + Comment string `json:"comment"` + ReadOnly bool `json:"read_only"` + GuestOK bool `json:"guest_ok"` + ValidUsers []string `json:"valid_users"` + ValidGroups []string `json:"valid_groups"` + InvalidUsers []string `json:"invalid_users"` } func (req sambaShareRequest) validate(allowedRoots []string) error { @@ -45,18 +46,24 @@ func (req sambaShareRequest) validate(allowedRoots []string) error { return err } } + for _, user := range req.InvalidUsers { + if err := system.ValidateUsername(user); err != nil { + return err + } + } return nil } func (req sambaShareRequest) toModel() db.SambaShare { return db.SambaShare{ - Name: req.Name, - Path: req.Path, - Comment: req.Comment, - ReadOnly: req.ReadOnly, - GuestOK: req.GuestOK, - ValidUsers: req.ValidUsers, - ValidGroups: req.ValidGroups, + Name: req.Name, + Path: req.Path, + Comment: req.Comment, + ReadOnly: req.ReadOnly, + GuestOK: req.GuestOK, + ValidUsers: req.ValidUsers, + ValidGroups: req.ValidGroups, + InvalidUsers: req.InvalidUsers, } } diff --git a/internal/web/router.go b/internal/web/router.go index 5a213ca..db871b6 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -39,6 +39,7 @@ func NewRouter(s *Server) chi.Router { protected.Get("/import/status", s.handleImportStatus) protected.Post("/import/samba", s.handleImportSamba) protected.Post("/import/nfs", s.handleImportNFS) + protected.Post("/import/users", s.handleImportUsers) protected.Route("/samba/shares", func(shares chi.Router) { shares.Get("/", s.handleListSambaShares) diff --git a/web/src/api.ts b/web/src/api.ts index d0b1153..d1b0c4d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -7,6 +7,7 @@ export interface SambaShare { guest_ok: boolean; valid_users: string[]; valid_groups: string[]; + invalid_users: string[]; } export interface NFSClient { @@ -213,6 +214,9 @@ export const api = { createWatchedMount: (path: string) => request("POST", "/system/watched-mounts", { path }), deleteWatchedMount: (id: number) => request("DELETE", `/system/watched-mounts/${id}`), + // import + importUsers: () => request<{ imported: number; message?: string }>("POST", "/import/users"), + // files fileCapabilities: () => request("GET", "/files/capabilities"), fileRoots: () => request<{ roots: string[]; unrestricted: boolean }>("GET", "/files/roots"), diff --git a/web/src/pages/Samba.tsx b/web/src/pages/Samba.tsx index a5e42bb..f88afbd 100644 --- a/web/src/pages/Samba.tsx +++ b/web/src/pages/Samba.tsx @@ -1,5 +1,5 @@ import { FormEvent, useEffect, useState } from "react"; -import { api, SambaShare } from "../api"; +import { api, SambaShare, User } from "../api"; import { useDirty } from "../DirtyContext"; import Modal from "../components/Modal"; import PathField from "../components/PathField"; @@ -12,12 +12,73 @@ const empty: Partial = { guest_ok: false, valid_users: [], valid_groups: [], + invalid_users: [], }; +type ChipPickerProps = { + label: string; + options: User[]; + selected: string[]; + onChange: (selected: string[]) => void; + allowRemove?: boolean; +}; + +function ChipPicker({ label, options, selected, onChange }: ChipPickerProps) { + const available = options.filter((u) => !selected.includes(u.username)); + + return ( +
+ + {selected.length > 0 && ( +
+ {selected.map((username) => { + const user = options.find((u) => u.username === username); + const isSMB = user?.smb_enabled; + return ( + + {username} + {isSMB && *} + + + ); + })} +
+ )} + {available.length > 0 && ( +
+ {available.map((user) => ( + + ))} +
+ )} + {options.length === 0 && ( +

No hay usuarios del sistema. Créalos primero.

+ )} +
+ ); +} + export default function Samba() { const [shares, setShares] = useState([]); const [editing, setEditing] = useState | null>(null); const [error, setError] = useState(null); + const [systemUsers, setSystemUsers] = useState([]); const { refresh } = useDirty(); async function load() { @@ -25,10 +86,24 @@ export default function Samba() { setShares(res.shares ?? []); } + async function loadSystemUsers() { + try { + const res = await api.listUsers(); + setSystemUsers(res.users ?? []); + } catch { + setSystemUsers([]); + } + } + useEffect(() => { load(); }, []); + function openEditor(share: Partial | null) { + setEditing(share ? { ...share } : { ...empty }); + loadSystemUsers(); + } + async function save(e: FormEvent) { e.preventDefault(); if (!editing) return; @@ -58,7 +133,7 @@ export default function Samba() {

Shares SMB / Samba

-
@@ -82,9 +157,25 @@ export default function Samba() { {s.path} {s.read_only ? "Solo lectura" : "Lectura/Escritura"} {s.guest_ok ? "Sí" : "No"} - {s.valid_users.join(", ") || "—"} + +
+ {s.valid_users.map((u) => ( + + {u} + + ))} + {s.invalid_users.map((u) => ( + + !{u} + + ))} + {s.valid_users.length === 0 && s.invalid_users.length === 0 && ( + + )} +
+ -
@@ -133,22 +224,25 @@ export default function Samba() { onChange={(e) => setEditing({ ...editing, comment: e.target.value })} />
-
- - - setEditing({ - ...editing, - valid_users: e.target.value - .split(",") - .map((v) => v.trim()) - .filter(Boolean), - }) - } - /> -
+ + + setEditing({ ...editing, valid_users: selected }) + } + /> + + + setEditing({ ...editing, invalid_users: selected }) + } + /> +