Files
samba-configer/app.go

1534 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bufio"
"errors"
"fmt"
"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
users UserManager
runner CommandRunner
lookPath LookPathFunc
reader *bufio.Reader
writer *bufio.Writer
}
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
}
func NewApp(configPath string, users UserManager, runner CommandRunner, lookPath LookPathFunc) *App {
return &App{
configPath: configPath,
users: users,
runner: runner,
lookPath: lookPath,
reader: bufio.NewReader(os.Stdin),
writer: bufio.NewWriter(os.Stdout),
}
}
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) {
a.println("")
a.println("Samba setup assistant")
a.println("=====================")
a.println("[s] set up or edit shares on this computer")
a.println("[c] connect this computer to a share on another server")
a.println("[q] quit")
a.flush()
choice, err := a.prompt("What would you like to do")
if err != nil {
return "", err
}
switch strings.ToLower(choice) {
case "s", "server":
return "server", nil
case "c", "client":
return "client", nil
case "q", "quit":
return "quit", nil
default:
a.println("Unknown choice.")
return a.chooseStartupWorkflow()
}
}
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()
a.println("")
a.println("Samba share editor")
a.println("==================")
a.println("Current shares:")
if len(shareSections) == 0 {
a.println(" (none)")
} else {
for i, section := range shareSections {
cfg := ShareFromSection(section)
a.printf(" %d. %s -> %s\n", i+1, cfg.Name, cfg.Path)
}
}
a.println("")
a.println("[a] add share")
a.println("[e] edit share")
a.println("[d] delete share")
a.println("[m] set up client mount")
a.println("[u] manage users")
a.println("[w] write config and exit")
a.println("[q] quit without saving")
a.flush()
choice, err := a.prompt("Select an action")
if err != nil {
return err
}
switch strings.ToLower(choice) {
case "a", "add":
if err := a.addShare(doc); err != nil {
return err
}
case "e", "edit":
if err := a.editShare(doc); err != nil {
return err
}
case "d", "delete":
if err := a.deleteShare(doc); err != nil {
return err
}
case "m", "mount":
if err := a.setupClientMount(); 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":
return nil
default:
a.println("Unknown choice.")
}
}
}
func (a *App) setupClientMount() error {
a.println("")
a.println("Client mount setup")
a.println("==================")
a.println("This will help connect this computer to a shared folder on another server.")
a.flush()
if err := a.ensureCIFSUtilsInstalled(); err != nil {
return err
}
cfg, err := a.collectCIFSMountConfig()
if err != nil {
return err
}
if err := a.ensureMountPoint(cfg.MountPoint); err != nil {
return err
}
line := buildFstabLine(cfg)
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()
addLine, err := a.confirm("Add this line to /etc/fstab now", true)
if err != nil {
return err
}
if !addLine {
return nil
}
if err := a.appendFstabLine(line); err != nil {
return err
}
a.println("")
a.println("The mount entry was added to /etc/fstab.")
mountNow, err := a.confirm("Try mounting it now", true)
if err != nil {
return err
}
if mountNow {
if err := a.reloadSystemdIfNeeded(); err != nil {
return err
}
if err := a.mountCIFS(cfg.MountPoint); err != nil {
return err
}
a.println("The network share was mounted.")
}
return nil
}
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) {
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() (CIFSMountConfig, error) {
server, err := a.prompt("Server name or IP")
if err != nil {
return CIFSMountConfig{}, err
}
share, err := a.prompt("Share name on that server")
if err != nil {
return CIFSMountConfig{}, err
}
mountPointDefault := filepath.Join("/mnt", strings.TrimSpace(share))
mountPoint, err := a.promptDefault("Local mount folder", mountPointDefault)
if err != nil {
return CIFSMountConfig{}, err
}
username, err := a.prompt("Username for the remote share")
if err != nil {
return CIFSMountConfig{}, err
}
password, err := a.prompt("Password for the remote share")
if err != nil {
return CIFSMountConfig{}, err
}
domain, err := a.promptDefault("Domain or workgroup (optional)", "")
if err != nil {
return CIFSMountConfig{}, err
}
uid, err := a.promptDefault("Local owner username or uid (optional)", "")
if err != nil {
return CIFSMountConfig{}, err
}
gid, err := a.promptDefault("Local group name or gid (optional)", "")
if err != nil {
return CIFSMountConfig{}, err
}
fileMode, err := a.promptDefault("File permissions", "0664")
if err != nil {
return CIFSMountConfig{}, err
}
dirMode, err := a.promptDefault("Folder permissions", "0775")
if err != nil {
return CIFSMountConfig{}, err
}
autoMount, err := a.confirm("Mount automatically at startup", true)
if err != nil {
return CIFSMountConfig{}, err
}
readOnly, err := a.confirm("Make this mount read-only", false)
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 (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) appendFstabLine(line string) error {
content := line + "\n"
file, err := os.OpenFile("/etc/fstab", os.O_APPEND|os.O_WRONLY, 0)
if err == nil {
defer file.Close()
if _, err := file.WriteString(content); err == nil {
return nil
} else if !shouldOfferPrivilegeRetry(err) {
return fmt.Errorf("append mount entry to /etc/fstab: %w", err)
}
} else if !shouldOfferPrivilegeRetry(err) {
return fmt.Errorf("open /etc/fstab for update: %w", err)
}
a.println("")
a.println("Updating /etc/fstab needs administrator permission.")
a.println("I can try again using sudo so you can enter your admin password.")
a.flush()
retry, err := a.confirm("Retry updating /etc/fstab with sudo", true)
if err != nil {
return err
}
if !retry {
return errors.New("updating /etc/fstab was cancelled")
}
tempPath, err := a.writeTempConfig(content)
if err != nil {
return fmt.Errorf("prepare fstab update: %w", err)
}
defer os.Remove(tempPath)
if err := a.runner.Run("sudo", "sh", "-c", fmt.Sprintf("cat %s >> /etc/fstab", shellQuote(tempPath))); err != nil {
return fmt.Errorf("append mount entry to /etc/fstab with sudo: %w", 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.println("There are no shares to select.")
return nil, nil
}
raw, err := a.prompt("Share number")
if err != nil {
return nil, err
}
index, err := strconv.Atoi(raw)
if err != nil || index < 1 || index > len(shares) {
a.println("Invalid selection.")
return nil, nil
}
return shares[index-1], 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.printf("%s: ", label)
a.flush()
text, err := a.reader.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(text), nil
}
func (a *App) promptDefault(label, defaultValue string) (string, error) {
if defaultValue == "" {
return a.prompt(label)
}
a.printf("%s [%s]: ", label, defaultValue)
a.flush()
text, err := a.reader.ReadString('\n')
if err != nil {
return "", err
}
text = strings.TrimSpace(text)
if text == "" {
return defaultValue, nil
}
return text, nil
}
func (a *App) confirm(label string, defaultYes bool) (bool, error) {
suffix := "y/N"
if defaultYes {
suffix = "Y/n"
}
answer, err := a.prompt(fmt.Sprintf("%s [%s]", label, suffix))
if err != nil {
return false, err
}
if answer == "" {
return defaultYes, nil
}
switch strings.ToLower(answer) {
case "y", "yes":
return true, nil
case "n", "no":
return false, nil
default:
return false, errors.New("expected yes or no")
}
}
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 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, "'", `'\''`) + "'"
}
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")
}