add user management flow

This commit is contained in:
2026-03-19 18:18:54 +00:00
parent 38045b0a39
commit b7da60fb0a
3 changed files with 379 additions and 0 deletions

330
app.go
View File

@@ -8,6 +8,7 @@ import (
"os/exec" "os/exec"
"os/user" "os/user"
"path/filepath" "path/filepath"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -51,6 +52,14 @@ type App struct {
writer *bufio.Writer 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 { func NewApp(configPath string, users UserManager, runner CommandRunner, lookPath LookPathFunc) *App {
return &App{ return &App{
configPath: configPath, configPath: configPath,
@@ -94,6 +103,7 @@ loaded:
a.println("[a] add share") a.println("[a] add share")
a.println("[e] edit share") a.println("[e] edit share")
a.println("[d] delete share") a.println("[d] delete share")
a.println("[u] manage users")
a.println("[w] write config and exit") a.println("[w] write config and exit")
a.println("[q] quit without saving") a.println("[q] quit without saving")
a.flush() a.flush()
@@ -116,6 +126,10 @@ loaded:
if err := a.deleteShare(doc); err != nil { if err := a.deleteShare(doc); err != nil {
return err return err
} }
case "u", "users":
if err := a.manageUsers(doc); err != nil {
return err
}
case "w", "write": case "w", "write":
return a.writeConfig(doc) return a.writeConfig(doc)
case "q", "quit": 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("Ill open the password setup for %q now.\n", ref.User)
a.println("Youll be asked to type the new Samba password.")
a.flush()
return a.setSambaPassword(ref.User)
}
func (a *App) handleMissingConfig() (*Document, error) { func (a *App) handleMissingConfig() (*Document, error) {
a.println("") a.println("")
a.println("Samba is not set up yet on this computer.") 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) { func (a *App) prompt(label string) (string, error) {
a.printf("%s: ", label) a.printf("%s: ", label)
a.flush() a.flush()
@@ -823,3 +1053,103 @@ func friendlyWriteError(action, path string, err error) error {
} }
return fmt.Errorf("%s %s: %w", action, path, err) 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")
}

View File

@@ -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 type execErr string
func (e execErr) Error() string { func (e execErr) Error() string {

Binary file not shown.