diff --git a/app.go b/app.go index 92cff65..63b7a39 100644 --- a/app.go +++ b/app.go @@ -8,6 +8,7 @@ import ( "os/exec" "os/user" "path/filepath" + "slices" "strconv" "strings" "time" @@ -51,6 +52,14 @@ type App struct { writer *bufio.Writer } +type PasswdEntry struct { + Name string + UID int + GID int + Home string + Shell string +} + func NewApp(configPath string, users UserManager, runner CommandRunner, lookPath LookPathFunc) *App { return &App{ configPath: configPath, @@ -94,6 +103,7 @@ loaded: a.println("[a] add share") a.println("[e] edit share") a.println("[d] delete share") + a.println("[u] manage users") a.println("[w] write config and exit") a.println("[q] quit without saving") a.flush() @@ -116,6 +126,10 @@ loaded: if err := a.deleteShare(doc); err != nil { return err } + case "u", "users": + if err := a.manageUsers(doc); err != nil { + return err + } case "w", "write": return a.writeConfig(doc) case "q", "quit": @@ -126,6 +140,200 @@ loaded: } } +func (a *App) manageUsers(doc *Document) error { + for { + a.println("") + a.println("User management") + a.println("===============") + a.println("[c] check share accounts") + a.println("[p] change a Samba password") + a.println("[x] delete an unused account") + a.println("[b] back") + a.flush() + + choice, err := a.prompt("Select an action") + if err != nil { + return err + } + + switch strings.ToLower(choice) { + case "c", "check": + if err := a.checkShareAccounts(doc); err != nil { + return err + } + case "p", "password": + if err := a.changeSambaPassword(doc); err != nil { + return err + } + case "x", "delete": + if err := a.deleteUnusedAccount(doc); err != nil { + return err + } + case "b", "back": + return nil + default: + a.println("Unknown choice.") + } + } +} + +func (a *App) checkShareAccounts(doc *Document) error { + references := shareUserReferences(doc) + if len(references) == 0 { + a.println("No accounts are listed in any share.") + return nil + } + + a.println("") + a.println("Accounts used by the current shares:") + for _, ref := range references { + status := "missing" + if a.users.UserExists(ref.User) { + status = "present" + } + a.printf(" %s [%s] used by: %s\n", ref.User, status, strings.Join(ref.Shares, ", ")) + } + + var missing []string + for _, ref := range references { + if !a.users.UserExists(ref.User) { + missing = append(missing, ref.User) + } + } + + if len(missing) == 0 { + a.println("") + a.println("All listed share accounts already exist.") + return nil + } + + a.println("") + a.println("Some accounts are missing and those users may not be able to sign in.") + if err := a.ensureUsers(missing); err != nil { + return err + } + return nil +} + +func (a *App) deleteUnusedAccount(doc *Document) error { + entries, err := readPasswdEntries() + if err != nil { + return fmt.Errorf("read local users: %w", err) + } + + candidates := unusedAccountCandidates(entries, shareUsers(doc)) + if len(candidates) == 0 { + a.println("I couldn't find any obvious unused share accounts to delete.") + a.println("For safety, system accounts are excluded from this list.") + return nil + } + + a.println("") + a.println("Accounts that look unused by the current shares:") + for i, entry := range candidates { + a.printf(" %d. %s (shell: %s)\n", i+1, entry.Name, entry.Shell) + } + a.println("Only accounts that are not listed in any share are shown here.") + a.flush() + + raw, err := a.prompt("Account number to delete") + if err != nil { + return err + } + + index, err := strconv.Atoi(raw) + if err != nil || index < 1 || index > len(candidates) { + a.println("Invalid selection.") + return nil + } + + entry := candidates[index-1] + a.println("") + a.printf("This will remove the local account %q.\n", entry.Name) + a.println("This is only safe if nobody needs this account for Samba or anything else.") + a.flush() + + confirm, err := a.confirm("Delete this account now", false) + if err != nil { + return err + } + if !confirm { + a.println("Delete cancelled.") + return nil + } + + if err := a.deleteUser(entry.Name); err != nil { + return err + } + + a.printf("Deleted account %s\n", entry.Name) + return nil +} + +func (a *App) changeSambaPassword(doc *Document) error { + if a.lookPath != nil { + if _, err := a.lookPath("smbpasswd"); err != nil { + a.println("I couldn't find the smbpasswd tool yet.") + a.println("Install Samba first, then try this again.") + return nil + } + } + + references := shareUserReferences(doc) + if len(references) == 0 { + a.println("No accounts are listed in any share.") + return nil + } + + a.println("") + a.println("Accounts used by the current shares:") + for i, ref := range references { + status := "missing" + if a.users.UserExists(ref.User) { + status = "present" + } + a.printf(" %d. %s [%s] used by: %s\n", i+1, ref.User, status, strings.Join(ref.Shares, ", ")) + } + a.flush() + + raw, err := a.prompt("Account number to update") + if err != nil { + return err + } + + index, err := strconv.Atoi(raw) + if err != nil || index < 1 || index > len(references) { + a.println("Invalid selection.") + return nil + } + + ref := references[index-1] + if !a.users.UserExists(ref.User) { + a.println("") + a.printf("The local account %q does not exist yet.\n", ref.User) + a.println("I can create it first so a Samba password can be set.") + a.flush() + + create, promptErr := a.confirm("Create this account now", true) + if promptErr != nil { + return promptErr + } + if !create { + return nil + } + + if err := a.createUser(ref.User); err != nil { + return err + } + } + + a.println("") + a.printf("I’ll open the password setup for %q now.\n", ref.User) + a.println("You’ll be asked to type the new Samba password.") + a.flush() + return a.setSambaPassword(ref.User) +} + func (a *App) handleMissingConfig() (*Document, error) { a.println("") a.println("Samba is not set up yet on this computer.") @@ -516,6 +724,28 @@ func (a *App) setSambaPassword(name string) error { } } +func (a *App) deleteUser(name string) error { + if err := a.runPrivilegedOrLocal("userdel", []string{name}, "Deleting a Linux user needs administrator permission."); err != nil { + return fmt.Errorf("delete user %s: %w", name, err) + } + + if a.lookPath != nil { + if _, err := a.lookPath("smbpasswd"); err == nil { + removeSamba, promptErr := a.confirm("Also remove the Samba password for this account", true) + if promptErr != nil { + return promptErr + } + if removeSamba { + if err := a.runPrivilegedOrLocal("smbpasswd", []string{"-x", name}, "Removing the Samba password needs administrator permission."); err != nil { + return fmt.Errorf("remove Samba password for %s: %w", name, err) + } + } + } + } + + return nil +} + func (a *App) prompt(label string) (string, error) { a.printf("%s: ", label) a.flush() @@ -823,3 +1053,103 @@ func friendlyWriteError(action, path string, err error) error { } return fmt.Errorf("%s %s: %w", action, path, err) } + +type ShareUserReference struct { + User string + Shares []string +} + +func shareUsers(doc *Document) map[string]struct{} { + users := make(map[string]struct{}) + for _, section := range doc.ShareSections() { + cfg := ShareFromSection(section) + for _, user := range cfg.ValidUsers { + users[user] = struct{}{} + } + } + return users +} + +func shareUserReferences(doc *Document) []ShareUserReference { + refs := map[string][]string{} + for _, section := range doc.ShareSections() { + cfg := ShareFromSection(section) + for _, user := range cfg.ValidUsers { + refs[user] = append(refs[user], cfg.Name) + } + } + + var out []ShareUserReference + for user, shares := range refs { + out = append(out, ShareUserReference{User: user, Shares: shares}) + } + slices.SortFunc(out, func(a, b ShareUserReference) int { + return strings.Compare(a.User, b.User) + }) + return out +} + +func readPasswdEntries() ([]PasswdEntry, error) { + data, err := os.ReadFile("/etc/passwd") + if err != nil { + return nil, err + } + + var entries []PasswdEntry + for _, line := range strings.Split(string(data), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + + parts := strings.Split(line, ":") + if len(parts) < 7 { + continue + } + + uid, err := strconv.Atoi(parts[2]) + if err != nil { + continue + } + gid, err := strconv.Atoi(parts[3]) + if err != nil { + continue + } + + entries = append(entries, PasswdEntry{ + Name: parts[0], + UID: uid, + GID: gid, + Home: parts[5], + Shell: parts[6], + }) + } + + return entries, nil +} + +func unusedAccountCandidates(entries []PasswdEntry, active map[string]struct{}) []PasswdEntry { + var candidates []PasswdEntry + for _, entry := range entries { + if _, ok := active[entry.Name]; ok { + continue + } + if !looksLikeSafeShareAccount(entry) { + continue + } + candidates = append(candidates, entry) + } + + slices.SortFunc(candidates, func(a, b PasswdEntry) int { + return strings.Compare(a.Name, b.Name) + }) + return candidates +} + +func looksLikeSafeShareAccount(entry PasswdEntry) bool { + if entry.UID < 1000 { + return false + } + + shell := strings.TrimSpace(entry.Shell) + return strings.HasSuffix(shell, "/nologin") || strings.HasSuffix(shell, "/false") +} diff --git a/parser_test.go b/parser_test.go index 6b2c2fa..d1ec753 100644 --- a/parser_test.go +++ b/parser_test.go @@ -136,6 +136,55 @@ func TestDetectSambaInstallPlanWithoutSudo(t *testing.T) { } } +func TestShareUserReferences(t *testing.T) { + doc := &Document{ + Sections: []*Section{ + BuildShareSection(nil, ShareConfig{ + Name: "media", + Path: "/srv/media", + ValidUsers: []string{"alice", "bob"}, + }), + BuildShareSection(nil, ShareConfig{ + Name: "photos", + Path: "/srv/photos", + ValidUsers: []string{"alice"}, + }), + }, + } + + refs := shareUserReferences(doc) + if len(refs) != 2 { + t.Fatalf("expected 2 refs, got %d", len(refs)) + } + if refs[0].User != "alice" || strings.Join(refs[0].Shares, ",") != "media,photos" { + t.Fatalf("unexpected first ref: %+v", refs[0]) + } + if refs[1].User != "bob" || strings.Join(refs[1].Shares, ",") != "media" { + t.Fatalf("unexpected second ref: %+v", refs[1]) + } +} + +func TestUnusedAccountCandidates(t *testing.T) { + entries := []PasswdEntry{ + {Name: "alice", UID: 1001, Shell: "/usr/sbin/nologin"}, + {Name: "bob", UID: 1002, Shell: "/bin/bash"}, + {Name: "daemon", UID: 1, Shell: "/usr/sbin/nologin"}, + {Name: "carol", UID: 1003, Shell: "/bin/false"}, + } + + active := map[string]struct{}{ + "alice": {}, + } + + candidates := unusedAccountCandidates(entries, active) + if len(candidates) != 1 { + t.Fatalf("expected 1 candidate, got %d", len(candidates)) + } + if candidates[0].Name != "carol" { + t.Fatalf("unexpected candidate: %+v", candidates[0]) + } +} + type execErr string func (e execErr) Error() string { diff --git a/samba-configer b/samba-configer index 3e41694..73fef15 100755 Binary files a/samba-configer and b/samba-configer differ