add user management flow
This commit is contained in:
330
app.go
330
app.go
@@ -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("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) {
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
BIN
samba-configer
BIN
samba-configer
Binary file not shown.
Reference in New Issue
Block a user