package main import ( "bufio" "errors" "fmt" "io" "os" "os/exec" "os/user" "path/filepath" "slices" "strconv" "strings" "time" ) var ErrCancelled = errors.New("cancelled") type UserManager interface { UserExists(name string) bool } type CommandRunner interface { Run(name string, args ...string) error } type LookPathFunc func(file string) (string, error) type RealUserManager struct{} func (RealUserManager) UserExists(name string) bool { _, err := user.Lookup(name) return err == nil } type OSCommandRunner struct{} func (OSCommandRunner) Run(name string, args ...string) error { cmd := exec.Command(name, args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } type App struct { configPath string fstabPath string users UserManager runner CommandRunner lookPath LookPathFunc input io.Reader output io.Writer writer *bufio.Writer theme tuiTheme } type PasswdEntry struct { Name string UID int GID int Home string Shell string } type CIFSMountConfig struct { Server string Share string MountPoint string Username string Password string Domain string UID string GID string FileMode string DirMode string AutoMount bool ReadOnly bool } type FstabMountEntry struct { LineIndex int Source string MountPoint string FSType string Options []string Dump string Pass string RawLine string DisplayName string } func NewApp(configPath string, users UserManager, runner CommandRunner, lookPath LookPathFunc) *App { return &App{ configPath: configPath, fstabPath: "/etc/fstab", users: users, runner: runner, lookPath: lookPath, input: os.Stdin, output: os.Stdout, writer: bufio.NewWriter(os.Stdout), theme: newTUITheme(), } } func (a *App) Run() error { for { choice, err := a.chooseStartupWorkflow() if err != nil { return err } switch choice { case "server": return a.runServerWorkflow() case "client": return a.setupClientMount() case "quit": return nil } } } func (a *App) chooseStartupWorkflow() (string, error) { return a.chooseMenu( "Choose A Workflow", "Server shares and client mounts in one place.", []string{ "Use the server tools to edit smb.conf and manage share accounts.", "Use the client tools to add or maintain CIFS mounts in /etc/fstab.", }, []menuOption{ {Key: "s", Value: "server", Label: "Set up or edit shares on this computer", Description: "Create, edit, and save Samba share definitions."}, {Key: "c", Value: "client", Label: "Connect this computer to a remote share", Description: "Manage CIFS client mounts and mount points."}, {Key: "q", Value: "quit", Label: "Quit", Description: "Exit without changing anything."}, }, ) } func (a *App) runServerWorkflow() error { doc, err := ParseConfigFile(a.configPath) if err != nil { if os.IsNotExist(err) { doc, err = a.handleMissingConfig() if err == nil { goto loaded } } return fmt.Errorf("read config: %w", err) } loaded: for { shareSections := doc.ShareSections() intro := []string{"Current shares:"} if len(shareSections) == 0 { intro = append(intro, " none yet") } else { for _, section := range shareSections { cfg := ShareFromSection(section) intro = append(intro, fmt.Sprintf(" %s -> %s", cfg.Name, cfg.Path)) } } choice, err := a.chooseMenu( "Server Share Editor", fmt.Sprintf("Working against %s", a.configPath), intro, []menuOption{ {Key: "a", Value: "add", Label: "Add share", Description: "Create a new share definition."}, {Key: "e", Value: "edit", Label: "Edit share", Description: "Update an existing share."}, {Key: "d", Value: "delete", Label: "Delete share", Description: "Remove a share definition."}, {Key: "m", Value: "mount", Label: "Set up client mount", Description: "Jump to the remote-mount workflow."}, {Key: "u", Value: "users", Label: "Manage users", Description: "Check accounts, passwords, and cleanup."}, {Key: "w", Value: "write", Label: "Write config and exit", Description: "Save smb.conf and leave the app."}, {Key: "q", Value: "quit", Label: "Quit without saving", Description: "Leave the editor immediately."}, }, ) if err != nil { if errors.Is(err, ErrCancelled) { return nil } return err } switch choice { case "add": if err := a.addShare(doc); err != nil { return err } case "edit": if err := a.editShare(doc); err != nil { return err } case "delete": if err := a.deleteShare(doc); err != nil { return err } case "mount": if err := a.setupClientMount(); err != nil { return err } case "users": if err := a.manageUsers(doc); err != nil { return err } case "write": return a.writeConfig(doc) case "quit": return nil } } } func (a *App) setupClientMount() error { for { entries, err := a.loadCIFSMountEntries() if err != nil { return err } intro := []string{ "Remote shares are persisted in /etc/fstab so they can mount cleanly on boot.", "Current client mounts:", } if len(entries) == 0 { intro = append(intro, " none yet") } else { for _, entry := range entries { intro = append(intro, fmt.Sprintf(" %s -> %s", entry.DisplayName, entry.MountPoint)) } } choice, err := a.chooseMenu( "Client Mount Setup", fmt.Sprintf("Editing %s", a.fstabPath), intro, []menuOption{ {Key: "a", Value: "add", Label: "Add client mount", Description: "Create a new CIFS mount entry."}, {Key: "e", Value: "edit", Label: "Edit client mount", Description: "Change an existing mount definition."}, {Key: "d", Value: "delete", Label: "Delete client mount", Description: "Remove a saved mount entry."}, {Key: "b", Value: "back", Label: "Back", Description: "Return to the previous menu."}, }, ) if err != nil { if errors.Is(err, ErrCancelled) { return nil } return err } switch choice { case "add": if err := a.addClientMount(); err != nil { return err } case "edit": if err := a.editClientMount(entries); err != nil { return err } case "delete": if err := a.deleteClientMount(entries); err != nil { return err } case "back": return nil } } } func (a *App) manageUsers(doc *Document) error { for { choice, err := a.chooseMenu( "User Management", "Keep local Linux accounts and Samba credentials aligned with your shares.", []string{ fmt.Sprintf("%d share accounts referenced in the current config.", len(shareUserReferences(doc))), }, []menuOption{ {Key: "c", Value: "check", Label: "Check share accounts", Description: "Create missing local users referenced by shares."}, {Key: "p", Value: "password", Label: "Change a Samba password", Description: "Set or update a Samba credential."}, {Key: "x", Value: "delete", Label: "Delete an unused account", Description: "Remove unused share-style accounts."}, {Key: "b", Value: "back", Label: "Back", Description: "Return to the share editor."}, }, ) if err != nil { if errors.Is(err, ErrCancelled) { return nil } return err } switch choice { case "check": if err := a.checkShareAccounts(doc); err != nil { return err } case "password": if err := a.changeSambaPassword(doc); err != nil { return err } case "delete": if err := a.deleteUnusedAccount(doc); err != nil { return err } case "back": return nil } } } func (a *App) checkShareAccounts(doc *Document) error { references := shareUserReferences(doc) if len(references) == 0 { a.showMessage("info", "No accounts are listed in any share.") return nil } a.showPanel("Accounts Used By Current Shares", "", nil) 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.showMessage("success", "All listed share accounts already exist.") return nil } a.showMessage("warn", "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.showPanel("Unused Account Cleanup", "No obvious removable share accounts were found.", []string{ "For safety, system accounts are excluded from this list.", }) return nil } options := make([]menuOption, 0, len(candidates)+1) for i, entry := range candidates { options = append(options, menuOption{ Key: fmt.Sprintf("%d", i+1), Value: entry.Name, Label: entry.Name, Description: "shell: " + strings.TrimSpace(entry.Shell), }) } options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Leave accounts unchanged."}) choice, err := a.chooseMenu( "Delete An Unused Account", "Only accounts that are not listed in any share are shown here.", nil, options, ) if err != nil { return err } if choice == "" { return nil } a.showPanel("Delete Account", "", []string{ fmt.Sprintf("This will remove the local account %q.", choice), "This is only safe if nobody needs this account for Samba or anything else.", }) confirm, err := a.confirm("Delete this account now", false) if err != nil { return err } if !confirm { a.showMessage("info", "Delete cancelled.") return nil } if err := a.deleteUser(choice); err != nil { return err } a.showMessage("success", "Deleted account "+choice) 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.showMessage("info", "No accounts are listed in any share.") return nil } options := make([]menuOption, 0, len(references)+1) for i, ref := range references { status := "missing" if a.users.UserExists(ref.User) { status = "present" } options = append(options, menuOption{ Key: fmt.Sprintf("%d", i+1), Value: ref.User, Label: ref.User + " [" + status + "]", Description: "used by: " + strings.Join(ref.Shares, ", "), }) } options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return without changing a password."}) choice, err := a.chooseMenu( "Choose An Account", "Select the local user whose Samba password you want to change.", nil, options, ) if err != nil { return err } if choice == "" { return nil } var ref ShareUserReference for _, candidate := range references { if candidate.User == choice { ref = candidate break } } if !a.users.UserExists(ref.User) { a.showPanel("Local User Missing", "", []string{ fmt.Sprintf("The local account %q does not exist yet.", ref.User), "I can create it first so a Samba password can be set.", }) 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.showPanel("Password Update", "", []string{ fmt.Sprintf("I will open the password setup for %q now.", ref.User), "You will be asked to type the new Samba password.", }) return a.setSambaPassword(ref.User) } func (a *App) handleMissingConfig() (*Document, error) { a.println("") a.println("Samba is not set up yet on this computer.") a.printf("I couldn't find the Samba config file at %s.\n", a.configPath) plan, ok := DetectSambaInstallPlan(a.lookPath, os.Geteuid() == 0) if !ok { a.println("I also couldn't find a supported package manager automatically.") a.println("Install the Samba server package for your Linux distribution, then run this tool again.") a.flush() return nil, os.ErrNotExist } a.println("") a.printf("I found %s and can try to install the Samba server for you.\n", plan.ManagerName) a.printf("This would run: %s\n", plan.DisplayCommand()) a.println("You may be asked for your administrator password.") a.flush() install, err := a.confirm("Install Samba server now", true) if err != nil { return nil, err } if !install { return nil, os.ErrNotExist } if err := a.runner.Run(plan.Command[0], plan.Command[1:]...); err != nil { return nil, fmt.Errorf("install Samba with %s: %w", plan.ManagerName, err) } doc, err := ParseConfigFile(a.configPath) if err != nil { return nil, fmt.Errorf("Samba installation finished, but the config file is still missing at %s: %w", a.configPath, err) } a.println("Samba was installed and the config file is now available.") a.flush() return doc, nil } func (a *App) addShare(doc *Document) error { cfg, err := a.collectShareConfig(ShareConfig{}) if err != nil { return err } if doc.Section(cfg.Name) != nil { a.println("A share with that name already exists.") return nil } if err := a.ensureUsers(cfg.ValidUsers); err != nil { return err } doc.UpsertSection(BuildShareSection(nil, cfg)) a.println("Share added.") return nil } func (a *App) editShare(doc *Document) error { section, err := a.selectShare(doc) if err != nil || section == nil { return err } originalName := section.Name cfg, err := a.collectShareConfig(ShareFromSection(section)) if err != nil { return err } if !strings.EqualFold(originalName, cfg.Name) && doc.Section(cfg.Name) != nil { a.println("A share with that new name already exists.") return nil } if err := a.ensureUsers(cfg.ValidUsers); err != nil { return err } if !strings.EqualFold(originalName, cfg.Name) { doc.DeleteSection(originalName) } doc.UpsertSection(BuildShareSection(section, cfg)) a.println("Share updated.") return nil } func (a *App) deleteShare(doc *Document) error { section, err := a.selectShare(doc) if err != nil || section == nil { return err } confirm, err := a.confirm(fmt.Sprintf("Delete share %q", section.Name), false) if err != nil { return err } if !confirm { a.println("Delete cancelled.") return nil } doc.DeleteSection(section.Name) a.println("Share deleted.") return nil } func (a *App) writeConfig(doc *Document) error { if err := a.ensureShareDirectories(doc); err != nil { return err } backup := fmt.Sprintf("%s.bak.%s", a.configPath, time.Now().UTC().Format("20060102T150405Z")) serialized := doc.Serialize() if err := copyFile(a.configPath, backup); err != nil { if shouldOfferPrivilegeRetry(err) { return a.writeConfigWithPrivilegeRetry(serialized, backup) } return friendlyWriteError("create backup", backup, err) } if err := os.WriteFile(a.configPath, []byte(serialized), 0o644); err != nil { if shouldOfferPrivilegeRetry(err) { return a.writeConfigWithPrivilegeRetry(serialized, backup) } return friendlyWriteError("write config", a.configPath, err) } a.printf("Config written to %s\n", a.configPath) a.printf("Backup saved to %s\n", backup) return nil } func (a *App) ensureCIFSUtilsInstalled() error { if a.lookPath != nil { if _, err := a.lookPath("mount.cifs"); err == nil { return nil } } a.println("") a.println("This computer does not seem to have the CIFS mount tools installed yet.") plan, ok := DetectCIFSUtilsInstallPlan(a.lookPath, os.Geteuid() == 0) if !ok { a.println("I couldn't find a supported package manager automatically.") a.println("Install cifs-utils for your Linux distribution, then try again.") return errors.New("cifs-utils is not installed") } a.printf("I found %s and can try to install the CIFS client tools for you.\n", plan.ManagerName) a.printf("This would run: %s\n", plan.DisplayCommand()) a.flush() install, err := a.confirm("Install the CIFS client tools now", true) if err != nil { return err } if !install { return errors.New("cifs-utils is required for client mounts") } if err := a.runner.Run(plan.Command[0], plan.Command[1:]...); err != nil { return fmt.Errorf("install CIFS client tools with %s: %w", plan.ManagerName, err) } if a.lookPath != nil { if _, err := a.lookPath("mount.cifs"); err != nil { return fmt.Errorf("cifs-utils installation finished, but mount.cifs is still not available: %w", err) } } return nil } func (a *App) collectCIFSMountConfig(defaults CIFSMountConfig) (CIFSMountConfig, error) { server, err := a.promptDefault("Server name or IP", defaults.Server) if err != nil { return CIFSMountConfig{}, err } share, err := a.promptDefault("Share name on that server", defaults.Share) if err != nil { return CIFSMountConfig{}, err } mountPointDefault := filepath.Join("/mnt", strings.TrimSpace(share)) if defaults.MountPoint != "" { mountPointDefault = defaults.MountPoint } mountPoint, err := a.promptDefault("Local mount folder", mountPointDefault) if err != nil { return CIFSMountConfig{}, err } username, err := a.promptDefault("Username for the remote share", defaults.Username) if err != nil { return CIFSMountConfig{}, err } password, err := a.promptDefault("Password for the remote share", defaults.Password) if err != nil { return CIFSMountConfig{}, err } domain, err := a.promptDefault("Domain or workgroup (optional)", defaults.Domain) if err != nil { return CIFSMountConfig{}, err } uid, err := a.promptDefault("Local owner username or uid (optional)", defaults.UID) if err != nil { return CIFSMountConfig{}, err } gid, err := a.promptDefault("Local group name or gid (optional)", defaults.GID) if err != nil { return CIFSMountConfig{}, err } fileModeDefault := defaults.FileMode if fileModeDefault == "" { fileModeDefault = "0664" } fileMode, err := a.promptDefault("File permissions", fileModeDefault) if err != nil { return CIFSMountConfig{}, err } dirModeDefault := defaults.DirMode if dirModeDefault == "" { dirModeDefault = "0775" } dirMode, err := a.promptDefault("Folder permissions", dirModeDefault) if err != nil { return CIFSMountConfig{}, err } autoMountDefault := true if defaults.Server != "" || defaults.Share != "" || defaults.MountPoint != "" { autoMountDefault = defaults.AutoMount } autoMount, err := a.confirm("Mount automatically at startup", autoMountDefault) if err != nil { return CIFSMountConfig{}, err } readOnly, err := a.confirm("Make this mount read-only", defaults.ReadOnly) if err != nil { return CIFSMountConfig{}, err } return CIFSMountConfig{ Server: strings.TrimSpace(server), Share: strings.TrimSpace(share), MountPoint: filepath.Clean(strings.TrimSpace(mountPoint)), Username: strings.TrimSpace(username), Password: strings.TrimSpace(password), Domain: strings.TrimSpace(domain), UID: strings.TrimSpace(uid), GID: strings.TrimSpace(gid), FileMode: strings.TrimSpace(fileMode), DirMode: strings.TrimSpace(dirMode), AutoMount: autoMount, ReadOnly: readOnly, }, nil } func defaultCIFSMountConfig() CIFSMountConfig { uid, gid := defaultLocalMountOwnerGroup(os.Getenv, user.Current, user.Lookup, user.LookupGroupId) return CIFSMountConfig{ UID: uid, GID: gid, } } func defaultLocalMountOwnerGroup( getenv func(string) string, currentUser func() (*user.User, error), lookupUser func(string) (*user.User, error), lookupGroupID func(string) (*user.Group, error), ) (uid string, gid string) { resolveGroup := func(groupID string) string { if strings.TrimSpace(groupID) == "" { return "" } group, err := lookupGroupID(groupID) if err != nil { return strings.TrimSpace(groupID) } return strings.TrimSpace(group.Name) } if sudoUser := strings.TrimSpace(getenv("SUDO_USER")); sudoUser != "" { uid = sudoUser if sudoGID := strings.TrimSpace(getenv("SUDO_GID")); sudoGID != "" { return uid, resolveGroup(sudoGID) } if u, err := lookupUser(sudoUser); err == nil { return uid, resolveGroup(u.Gid) } return uid, "" } u, err := currentUser() if err != nil { return "", "" } uid = strings.TrimSpace(u.Username) gid = resolveGroup(u.Gid) return uid, gid } func (a *App) ensureMountPoint(path string) error { info, err := os.Stat(path) if err == nil { if !info.IsDir() { return fmt.Errorf("mount folder exists but is not a directory: %s", path) } return nil } if !os.IsNotExist(err) { return fmt.Errorf("check mount folder %s: %w", path, err) } a.println("") a.printf("The local mount folder %s does not exist yet.\n", path) a.println("I can create it now.") a.flush() create, err := a.confirm("Create this folder now", true) if err != nil { return err } if !create { return errors.New("a mount folder is needed before continuing") } if err := a.createDirectory(path); err != nil { return err } return nil } func (a *App) mountCIFS(path string) error { if err := a.runPrivilegedOrLocal("mount", []string{path}, "Mounting the network share needs administrator permission."); err != nil { return fmt.Errorf("mount %s: %w", path, err) } return nil } func (a *App) reloadSystemdIfNeeded() error { if _, err := os.Stat("/run/systemd/system"); err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("check for systemd: %w", err) } if a.lookPath != nil { if _, err := a.lookPath("systemctl"); err != nil { return nil } } if err := a.runPrivilegedOrLocal("systemctl", []string{"daemon-reload"}, "Refreshing system mount settings needs administrator permission."); err != nil { return fmt.Errorf("reload systemd after updating /etc/fstab: %w", err) } return nil } func (a *App) ensureShareDirectories(doc *Document) error { for _, section := range doc.ShareSections() { cfg := ShareFromSection(section) if strings.TrimSpace(cfg.Path) == "" { continue } info, err := os.Stat(cfg.Path) if err == nil { if info.IsDir() { if err := a.offerAdjustDirectoryPermissions(cfg); err != nil { return err } continue } return fmt.Errorf("share %q path exists but is not a directory: %s", cfg.Name, cfg.Path) } if !os.IsNotExist(err) { return fmt.Errorf("check share %q path %s: %w", cfg.Name, cfg.Path, err) } a.println("") a.printf("The folder for share %q does not exist yet.\n", cfg.Name) a.printf("Missing folder: %s\n", cfg.Path) a.println("I can create it now so the share is ready to use.") a.flush() create, promptErr := a.confirm("Create this folder now", true) if promptErr != nil { return promptErr } if !create { return fmt.Errorf("share %q needs an existing folder before saving", cfg.Name) } if err := a.createDirectory(cfg.Path); err != nil { return fmt.Errorf("create folder for share %q: %w", cfg.Name, err) } a.printf("Created folder %s\n", cfg.Path) if err := a.offerAdjustDirectoryPermissions(cfg); err != nil { return err } } return nil } func (a *App) selectShare(doc *Document) (*Section, error) { shares := doc.ShareSections() if len(shares) == 0 { a.showMessage("info", "There are no shares to select.") return nil, nil } options := make([]menuOption, 0, len(shares)+1) for i, share := range shares { cfg := ShareFromSection(share) options = append(options, menuOption{ Key: fmt.Sprintf("%d", i+1), Value: share.Name, Label: cfg.Name, Description: cfg.Path, }) } options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return to the previous menu."}) raw, err := a.chooseMenu("Choose A Share", "Select the share you want to work with.", nil, options) if err != nil { return nil, err } if raw == "" { return nil, nil } return doc.Section(raw), nil } func (a *App) collectShareConfig(existing ShareConfig) (ShareConfig, error) { name, err := a.promptDefault("Share name", existing.Name) if err != nil { return ShareConfig{}, err } name = strings.TrimSpace(name) if name == "" { return ShareConfig{}, errors.New("share name cannot be empty") } path, err := a.promptDefault("Path", existing.Path) if err != nil { return ShareConfig{}, err } path = strings.TrimSpace(path) if path == "" { return ShareConfig{}, errors.New("path cannot be empty") } comment, err := a.promptDefault("Comment", existing.Comment) if err != nil { return ShareConfig{}, err } browseable, err := a.promptDefault("Browseable (yes/no)", defaultString(existing.Browseable, "yes")) if err != nil { return ShareConfig{}, err } readOnly, err := a.promptDefault("Read only (yes/no)", defaultString(existing.ReadOnly, "no")) if err != nil { return ShareConfig{}, err } guestOK, err := a.promptDefault("Guest ok (yes/no)", defaultString(existing.GuestOK, "no")) if err != nil { return ShareConfig{}, err } usersDefault := strings.Join(existing.ValidUsers, ", ") usersRaw, err := a.promptDefault("Valid users (comma or space separated, blank for none)", usersDefault) if err != nil { return ShareConfig{}, err } return ShareConfig{ Name: name, Path: filepath.Clean(path), Comment: strings.TrimSpace(comment), Browseable: browseable, ReadOnly: readOnly, GuestOK: guestOK, ValidUsers: splitUsers(usersRaw), }, nil } func (a *App) ensureUsers(users []string) error { for _, name := range users { if a.users.UserExists(name) { continue } create, err := a.confirm(fmt.Sprintf("User %q does not exist. Create it with useradd", name), true) if err != nil { return err } if !create { continue } if err := a.createUser(name); err != nil { return err } a.printf("Created local user %s\n", name) if err := a.offerSetSambaPassword(name); err != nil { return err } } return nil } func (a *App) createUser(name string) error { args := []string{"-M", "-s", "/usr/sbin/nologin", name} if err := a.runner.Run("useradd", args...); err == nil { return nil } else if shouldOfferPrivilegeRetry(err) { a.println("") a.println("Creating a Linux user usually needs administrator permission.") a.println("I can try again using sudo so you can enter your admin password.") a.flush() canUseSudo := false if a.lookPath != nil { _, sudoErr := a.lookPath("sudo") canUseSudo = sudoErr == nil } if !canUseSudo { return fmt.Errorf("create user %s: %w", name, err) } retry, promptErr := a.confirm("Retry user creation with sudo", true) if promptErr != nil { return promptErr } if !retry { return fmt.Errorf("create user %s: %w", name, err) } if sudoErr := a.runner.Run("sudo", append([]string{"useradd"}, args...)...); sudoErr != nil { return fmt.Errorf("create user %s with sudo: %w", name, sudoErr) } return nil } else { return fmt.Errorf("create user %s: %w", name, err) } } func (a *App) offerSetSambaPassword(name string) error { a.println("") a.println("This share can use a Samba password for sign-in.") a.println("I can set that up now and ask you to type the password you want to use.") a.flush() setPassword, err := a.confirm(fmt.Sprintf("Set a Samba password for %q now", name), true) if err != nil { return err } if !setPassword { a.println("You can do this later with: smbpasswd -a " + name) return nil } return a.setSambaPassword(name) } func (a *App) setSambaPassword(name string) error { if a.lookPath != nil { if _, err := a.lookPath("smbpasswd"); err != nil { a.println("I couldn't find the smbpasswd tool yet.") a.println("If Samba was just installed, try opening the app again after the installation finishes.") return fmt.Errorf("set Samba password for %s: smbpasswd command not found", name) } } if err := a.runner.Run("smbpasswd", "-a", name); err == nil { a.printf("Samba password set for %s\n", name) return nil } else if shouldOfferPrivilegeRetry(err) { a.println("") a.println("Setting a Samba password usually needs administrator permission.") a.println("I can try again using sudo so you can enter your admin password.") a.flush() canUseSudo := false if a.lookPath != nil { _, sudoErr := a.lookPath("sudo") canUseSudo = sudoErr == nil } if !canUseSudo { return fmt.Errorf("set Samba password for %s: %w", name, err) } retry, promptErr := a.confirm("Retry Samba password setup with sudo", true) if promptErr != nil { return promptErr } if !retry { return fmt.Errorf("set Samba password for %s: %w", name, err) } if sudoErr := a.runner.Run("sudo", "smbpasswd", "-a", name); sudoErr != nil { return fmt.Errorf("set Samba password for %s with sudo: %w", name, sudoErr) } a.printf("Samba password set for %s\n", name) return nil } else { return fmt.Errorf("set Samba password for %s: %w", name, err) } } 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.flush() return runTextPrompt(a.input, a.output, a.theme, "Enter A Value", "Field-by-field configuration", label, "") } func (a *App) promptDefault(label, defaultValue string) (string, error) { a.flush() return runTextPrompt(a.input, a.output, a.theme, "Enter A Value", "Field-by-field configuration", label, defaultValue) } func (a *App) confirm(label string, defaultYes bool) (bool, error) { options := []menuOption{ {Key: "y", Value: "yes", Label: "Yes", Description: "Continue with this action."}, {Key: "n", Value: "no", Label: "No", Description: "Leave things as they are."}, } if !defaultYes { options[0], options[1] = options[1], options[0] } answer, err := a.chooseMenu("Confirm Action", label, nil, options) if err != nil { return false, err } switch answer { case "yes": return true, nil case "no": return false, nil default: return defaultYes, nil } } func (a *App) println(line string) { fmt.Fprintln(a.writer, line) } func (a *App) printf(format string, args ...any) { fmt.Fprintf(a.writer, format, args...) } func (a *App) flush() { _ = a.writer.Flush() } func (a *App) chooseMenu(title, subtitle string, intro []string, options []menuOption) (string, error) { a.flush() choice, err := runMenuPrompt(a.input, a.output, a.theme, title, subtitle, intro, options) a.flush() return choice, err } func (a *App) showPanel(title, subtitle string, lines []string) { body := a.theme.renderSection(84, "", lines) fmt.Fprintln(a.writer, a.theme.renderFrame(84, title, subtitle, body, "")) a.flush() } func (a *App) showMessage(kind, message string) { fmt.Fprintln(a.writer, a.theme.renderFrame(84, "Status", "", a.theme.renderMessage(84, kind, message), "")) a.flush() } func defaultString(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value } func copyFile(src, dst string) error { data, err := os.ReadFile(src) if err != nil { return err } return os.WriteFile(dst, data, 0o644) } func shouldOfferPrivilegeRetry(err error) bool { if os.Geteuid() == 0 { return false } text := strings.ToLower(err.Error()) return strings.Contains(text, "permission denied") || strings.Contains(text, "operation not permitted") || strings.Contains(text, "exit status") } func (a *App) writeConfigWithPrivilegeRetry(serialized, backup string) error { a.println("") a.println("Saving Samba settings usually needs administrator permission.") a.println("I can try again using sudo so you can enter your admin password.") a.flush() canUseSudo := false if a.lookPath != nil { _, sudoErr := a.lookPath("sudo") canUseSudo = sudoErr == nil } if !canUseSudo { return friendlyWriteError("save Samba settings", a.configPath, os.ErrPermission) } retry, err := a.confirm("Retry saving with sudo", true) if err != nil { return err } if !retry { return friendlyWriteError("save Samba settings", a.configPath, os.ErrPermission) } tempPath, err := a.writeTempConfig(serialized) if err != nil { return fmt.Errorf("prepare config for sudo save: %w", err) } defer os.Remove(tempPath) if err := a.runner.Run("sudo", "cp", a.configPath, backup); err != nil { return friendlyWriteError("create backup", backup, err) } if err := a.runner.Run("sudo", "install", "-m", "644", tempPath, a.configPath); err != nil { return friendlyWriteError("write config", a.configPath, err) } a.printf("Config written to %s\n", a.configPath) a.printf("Backup saved to %s\n", backup) return nil } func (a *App) writeTempConfig(serialized string) (string, error) { file, err := os.CreateTemp("", "samba-configer-*.conf") if err != nil { return "", err } defer file.Close() if _, err := file.WriteString(serialized); err != nil { return "", err } return file.Name(), nil } func (a *App) createDirectory(path string) error { if err := os.MkdirAll(path, 0o755); err == nil { return nil } else if shouldOfferPrivilegeRetry(err) { a.println("") a.println("Creating that folder needs administrator permission.") a.println("I can try again using sudo so you can enter your admin password.") a.flush() canUseSudo := false if a.lookPath != nil { _, sudoErr := a.lookPath("sudo") canUseSudo = sudoErr == nil } if !canUseSudo { return fmt.Errorf("create folder %s: %w", path, err) } retry, promptErr := a.confirm("Retry folder creation with sudo", true) if promptErr != nil { return promptErr } if !retry { return fmt.Errorf("create folder %s: %w", path, err) } if sudoErr := a.runner.Run("sudo", "mkdir", "-p", path); sudoErr != nil { return fmt.Errorf("create folder %s with sudo: %w", path, sudoErr) } if sudoErr := a.runner.Run("sudo", "chmod", "755", path); sudoErr != nil { return fmt.Errorf("set permissions on folder %s with sudo: %w", path, sudoErr) } return nil } else { return fmt.Errorf("create folder %s: %w", path, err) } } func (a *App) offerAdjustDirectoryPermissions(cfg ShareConfig) error { mode, owner, group, explanation := recommendSharePermissions(cfg) a.println("") a.printf("The share folder %s may need permissions adjusted for file operations.\n", cfg.Path) a.println(explanation) if owner != "" || group != "" { a.printf("Suggested owner/group: %s:%s\n", defaultString(owner, "unchanged"), defaultString(group, "unchanged")) } a.printf("Suggested permissions: %s\n", mode) a.println("I can apply these recommended permissions now.") a.flush() adjust, err := a.confirm("Adjust this folder now", true) if err != nil { return err } if !adjust { return nil } if err := a.applyDirectoryPermissions(cfg.Path, owner, group, mode); err != nil { return fmt.Errorf("adjust permissions for share %q: %w", cfg.Name, err) } a.printf("Updated permissions for %s\n", cfg.Path) return nil } func recommendSharePermissions(cfg ShareConfig) (mode, owner, group, explanation string) { mode = "0770" group = "users" explanation = "Members of the allowed account list should usually be able to read and write here." if strings.EqualFold(normalizeBoolish(cfg.ReadOnly, "no"), "yes") { mode = "0755" explanation = "This share is marked read-only, so a safer folder mode is recommended." } if strings.EqualFold(normalizeBoolish(cfg.GuestOK, "no"), "yes") { mode = "0777" group = "" explanation = "This share allows guests, so wider permissions are usually needed for uploads and edits." return mode, "", group, explanation } if len(cfg.ValidUsers) == 1 { owner = cfg.ValidUsers[0] group = cfg.ValidUsers[0] explanation = "This share is limited to one account, so making that account the owner is the simplest setup." return mode, owner, group, explanation } if len(cfg.ValidUsers) > 1 { explanation = "This share has multiple allowed accounts, so a shared writable group-style setup is recommended." } return mode, owner, group, explanation } func (a *App) applyDirectoryPermissions(path, owner, group, mode string) error { chownTarget := owner if group != "" { if chownTarget == "" { chownTarget = ":" + group } else { chownTarget = chownTarget + ":" + group } } if err := a.runPrivilegedOrLocal("chmod", []string{mode, path}, "Changing folder permissions needs administrator permission."); err != nil { return err } if chownTarget != "" { if err := a.runPrivilegedOrLocal("chown", []string{chownTarget, path}, "Changing folder ownership needs administrator permission."); err != nil { return err } } return nil } func (a *App) runPrivilegedOrLocal(command string, args []string, privilegeMessage string) error { if err := a.runner.Run(command, args...); err == nil { return nil } else if shouldOfferPrivilegeRetry(err) { a.println("") a.println(privilegeMessage) a.println("I can try again using sudo so you can enter your admin password.") a.flush() canUseSudo := false if a.lookPath != nil { _, sudoErr := a.lookPath("sudo") canUseSudo = sudoErr == nil } if !canUseSudo { return fmt.Errorf("%s %s: %w", command, strings.Join(args, " "), err) } retry, promptErr := a.confirm("Retry with sudo", true) if promptErr != nil { return promptErr } if !retry { return fmt.Errorf("%s %s: %w", command, strings.Join(args, " "), err) } if sudoErr := a.runner.Run("sudo", append([]string{command}, args...)...); sudoErr != nil { return fmt.Errorf("%s %s with sudo: %w", command, strings.Join(args, " "), sudoErr) } return nil } else { return fmt.Errorf("%s %s: %w", command, strings.Join(args, " "), err) } } func friendlyWriteError(action, path string, err error) error { if errors.Is(err, os.ErrPermission) || strings.Contains(strings.ToLower(err.Error()), "permission denied") { return fmt.Errorf("%s %s: administrator permission is needed", action, path) } return fmt.Errorf("%s %s: %w", action, path, err) } func buildFstabLine(cfg CIFSMountConfig) string { source := fmt.Sprintf("//%s/%s", cfg.Server, cfg.Share) options := []string{ "username=" + escapeFstabValue(cfg.Username), "password=" + escapeFstabValue(cfg.Password), "iocharset=utf8", } if cfg.Domain != "" { options = append(options, "domain="+escapeFstabValue(cfg.Domain)) } if cfg.UID != "" { options = append(options, "uid="+escapeFstabValue(cfg.UID)) } if cfg.GID != "" { options = append(options, "gid="+escapeFstabValue(cfg.GID)) } if cfg.FileMode != "" { options = append(options, "file_mode="+cfg.FileMode) } if cfg.DirMode != "" { options = append(options, "dir_mode="+cfg.DirMode) } if cfg.ReadOnly { options = append(options, "ro") } else { options = append(options, "rw") } if !cfg.AutoMount { options = append(options, "noauto") } return fmt.Sprintf("%s %s cifs %s 0 0", source, cfg.MountPoint, strings.Join(options, ",")) } func escapeFstabValue(value string) string { value = strings.ReplaceAll(value, `\`, `\\`) value = strings.ReplaceAll(value, ",", `\,`) value = strings.ReplaceAll(value, " ", `\040`) return value } func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" } func (a *App) loadCIFSMountEntries() ([]FstabMountEntry, error) { data, err := os.ReadFile(a.fstabPath) if err != nil { return nil, fmt.Errorf("read %s: %w", a.fstabPath, err) } return parseCIFSMountEntries(string(data)), nil } func parseCIFSMountEntries(contents string) []FstabMountEntry { lines := strings.Split(contents, "\n") entries := make([]FstabMountEntry, 0) for index, line := range lines { entry, ok := parseFstabMountLine(line, index) if ok { entries = append(entries, entry) } } return entries } func parseFstabMountLine(line string, index int) (FstabMountEntry, bool) { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { return FstabMountEntry{}, false } fields := strings.Fields(trimmed) if len(fields) < 4 { return FstabMountEntry{}, false } fsType := strings.ToLower(fields[2]) if fsType != "cifs" && fsType != "smbfs" { return FstabMountEntry{}, false } entry := FstabMountEntry{ LineIndex: index, Source: fields[0], MountPoint: unescapeFstabValue(fields[1]), FSType: fields[2], Options: splitFstabOptions(fields[3]), RawLine: line, DisplayName: fmt.Sprintf( "%s (%s)", unescapeFstabValue(fields[0]), unescapeFstabValue(fields[2]), ), } if len(fields) > 4 { entry.Dump = fields[4] } if len(fields) > 5 { entry.Pass = fields[5] } if entry.Dump == "" { entry.Dump = "0" } if entry.Pass == "" { entry.Pass = "0" } if entry.DisplayName == " (cifs)" || entry.DisplayName == " (smbfs)" { entry.DisplayName = entry.MountPoint } return entry, true } func splitFstabOptions(value string) []string { if value == "" { return nil } var options []string var current strings.Builder escaped := false for _, r := range value { switch { case escaped: current.WriteRune(r) escaped = false case r == '\\': current.WriteRune(r) escaped = true case r == ',': options = append(options, current.String()) current.Reset() default: current.WriteRune(r) } } options = append(options, current.String()) return options } func unescapeFstabValue(value string) string { value = strings.ReplaceAll(value, `\040`, " ") value = strings.ReplaceAll(value, `\,`, ",") value = strings.ReplaceAll(value, `\\`, `\`) return value } func (a *App) addClientMount() error { if err := a.ensureCIFSUtilsInstalled(); err != nil { return err } cfg, err := a.collectCIFSMountConfig(defaultCIFSMountConfig()) if err != nil { return err } if err := a.ensureMountPoint(cfg.MountPoint); err != nil { return err } line := buildFstabLine(cfg) if err := a.confirmAndWriteClientMount(line, -1, "Add this line to /etc/fstab now"); err != nil { return err } a.println("") a.println("The mount entry was added to /etc/fstab.") return a.offerMountNow(cfg) } func (a *App) editClientMount(entries []FstabMountEntry) error { entry, err := a.selectCIFSMount(entries) if err != nil || entry == nil { return err } cfg, ok := cifsMountConfigFromEntry(*entry) if !ok { return fmt.Errorf("could not read mount details from %s", entry.RawLine) } if err := a.ensureCIFSUtilsInstalled(); err != nil { return err } updated, err := a.collectCIFSMountConfig(cfg) if err != nil { return err } if err := a.ensureMountPoint(updated.MountPoint); err != nil { return err } line := buildFstabLine(updated) if err := a.confirmAndWriteClientMount(line, entry.LineIndex, "Save these changes to /etc/fstab now"); err != nil { return err } a.println("") a.println("The mount entry was updated in /etc/fstab.") return a.offerMountNow(updated) } func (a *App) deleteClientMount(entries []FstabMountEntry) error { entry, err := a.selectCIFSMount(entries) if err != nil || entry == nil { return err } confirm, err := a.confirm(fmt.Sprintf("Delete the mount for %s", entry.DisplayName), false) if err != nil { return err } if !confirm { a.println("Delete cancelled.") return nil } if err := a.writeFstabLineChange("", entry.LineIndex); err != nil { return err } a.println("The mount entry was removed from /etc/fstab.") return a.reloadSystemdIfNeeded() } func (a *App) selectCIFSMount(entries []FstabMountEntry) (*FstabMountEntry, error) { if len(entries) == 0 { a.showMessage("info", "There are no client mounts to select.") return nil, nil } options := make([]menuOption, 0, len(entries)+1) for i, entry := range entries { options = append(options, menuOption{ Key: fmt.Sprintf("%d", i+1), Value: strconv.Itoa(i), Label: entry.DisplayName, Description: entry.MountPoint, }) } options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return to the previous menu."}) selection, err := a.chooseMenu("Choose A Client Mount", "Select the saved mount entry you want to change.", nil, options) if err != nil { return nil, err } if selection == "" { return nil, nil } index, err := strconv.Atoi(selection) if err != nil || index < 0 || index >= len(entries) { return nil, nil } entry := entries[index] return &entry, nil } func cifsMountConfigFromEntry(entry FstabMountEntry) (CIFSMountConfig, bool) { server, share, ok := splitCIFSSource(entry.Source) if !ok { return CIFSMountConfig{}, false } cfg := CIFSMountConfig{ Server: server, Share: share, MountPoint: entry.MountPoint, AutoMount: true, } for _, option := range entry.Options { key, value, hasValue := strings.Cut(option, "=") switch strings.ToLower(key) { case "username": if hasValue { cfg.Username = unescapeFstabValue(value) } case "password": if hasValue { cfg.Password = unescapeFstabValue(value) } case "domain": if hasValue { cfg.Domain = unescapeFstabValue(value) } case "uid": if hasValue { cfg.UID = unescapeFstabValue(value) } case "gid": if hasValue { cfg.GID = unescapeFstabValue(value) } case "file_mode": if hasValue { cfg.FileMode = value } case "dir_mode": if hasValue { cfg.DirMode = value } case "ro": cfg.ReadOnly = true case "rw": cfg.ReadOnly = false case "noauto": cfg.AutoMount = false } } return cfg, true } func splitCIFSSource(source string) (server, share string, ok bool) { unescaped := unescapeFstabValue(source) if !strings.HasPrefix(unescaped, "//") { return "", "", false } parts := strings.SplitN(strings.TrimPrefix(unescaped, "//"), "/", 2) if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { return "", "", false } return parts[0], parts[1], true } func (a *App) confirmAndWriteClientMount(line string, lineIndex int, promptLabel string) error { a.println("") a.println("Suggested /etc/fstab line:") a.println(line) a.println("") a.println("This will store the username and password directly in /etc/fstab.") a.println("That is simple for home use, but less secure than a credentials file.") a.flush() confirm, err := a.confirm(promptLabel, true) if err != nil { return err } if !confirm { return nil } if err := a.writeFstabLineChange(line, lineIndex); err != nil { return err } return a.reloadSystemdIfNeeded() } func (a *App) offerMountNow(cfg CIFSMountConfig) error { mountNow, err := a.confirm("Try mounting it now", true) if err != nil { return err } if !mountNow { return nil } if err := a.mountCIFS(cfg.MountPoint); err != nil { return err } a.println("The network share was mounted.") return nil } func (a *App) writeFstabLineChange(line string, lineIndex int) error { data, err := os.ReadFile(a.fstabPath) if err != nil { return fmt.Errorf("read %s: %w", a.fstabPath, err) } updated, err := updateFstabContents(string(data), line, lineIndex) if err != nil { return err } return a.writeFstabContents(updated) } func updateFstabContents(contents, line string, lineIndex int) (string, error) { hasTrailingNewline := strings.HasSuffix(contents, "\n") lines := strings.Split(contents, "\n") if len(lines) > 0 && lines[len(lines)-1] == "" { lines = lines[:len(lines)-1] } switch { case lineIndex < 0: lines = append(lines, line) case lineIndex >= len(lines): return "", fmt.Errorf("mount entry line %d is out of range", lineIndex) case line == "": lines = append(lines[:lineIndex], lines[lineIndex+1:]...) default: lines[lineIndex] = line } updated := strings.Join(lines, "\n") if hasTrailingNewline || updated != "" { updated += "\n" } return updated, nil } func (a *App) writeFstabContents(serialized string) error { backup := fmt.Sprintf("%s.bak.%s", a.fstabPath, time.Now().UTC().Format("20060102T150405Z")) if err := copyFile(a.fstabPath, backup); err != nil { if shouldOfferPrivilegeRetry(err) { return a.writeFstabWithPrivilegeRetry(serialized, backup) } return friendlyWriteError("create backup", backup, err) } if err := os.WriteFile(a.fstabPath, []byte(serialized), 0o644); err != nil { if shouldOfferPrivilegeRetry(err) { return a.writeFstabWithPrivilegeRetry(serialized, backup) } return friendlyWriteError("write config", a.fstabPath, err) } return nil } func (a *App) writeFstabWithPrivilegeRetry(serialized, backup string) error { a.println("") a.println("Updating /etc/fstab usually needs administrator permission.") a.println("I can try again using sudo so you can enter your admin password.") a.flush() canUseSudo := false if a.lookPath != nil { _, sudoErr := a.lookPath("sudo") canUseSudo = sudoErr == nil } if !canUseSudo { return friendlyWriteError("write config", a.fstabPath, os.ErrPermission) } retry, err := a.confirm("Retry updating /etc/fstab with sudo", true) if err != nil { return err } if !retry { return friendlyWriteError("write config", a.fstabPath, os.ErrPermission) } tempPath, err := a.writeTempConfig(serialized) if err != nil { return fmt.Errorf("prepare fstab for sudo save: %w", err) } defer os.Remove(tempPath) if err := a.runner.Run("sudo", "cp", a.fstabPath, backup); err != nil { return friendlyWriteError("create backup", backup, err) } if err := a.runner.Run("sudo", "install", "-m", "644", tempPath, a.fstabPath); err != nil { return friendlyWriteError("write config", a.fstabPath, err) } return nil } 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") }