add user management flow
This commit is contained in:
330
app.go
330
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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user