package main import ( "bufio" "errors" "fmt" "os" "os/exec" "os/user" "path/filepath" "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 } 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 { 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("[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 "w", "write": return a.writeConfig(doc) case "q", "quit": return nil default: a.println("Unknown choice.") } } } 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) 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() { 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) } 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) 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 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) }