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 CreateUser(name string) error } type CommandRunner interface { Run(name string, args ...string) error } type RealUserManager struct{} func (RealUserManager) UserExists(name string) bool { _, err := user.Lookup(name) return err == nil } func (RealUserManager) CreateUser(name string) error { runner := OSCommandRunner{} return runner.Run("useradd", "-M", "-s", "/usr/sbin/nologin", name) } 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 reader *bufio.Reader writer *bufio.Writer } func NewApp(configPath string, users UserManager) *App { return &App{ configPath: configPath, users: users, reader: bufio.NewReader(os.Stdin), writer: bufio.NewWriter(os.Stdout), } } func (a *App) Run() error { doc, err := ParseConfigFile(a.configPath) if err != nil { return fmt.Errorf("read config: %w", err) } 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) 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 { backup := fmt.Sprintf("%s.bak.%s", a.configPath, time.Now().UTC().Format("20060102T150405Z")) if err := copyFile(a.configPath, backup); err != nil { return fmt.Errorf("create backup %s: %w", backup, err) } if err := os.WriteFile(a.configPath, []byte(doc.Serialize()), 0o644); err != nil { return fmt.Errorf("write config: %w", err) } a.printf("Config written to %s\n", a.configPath) a.printf("Backup saved to %s\n", backup) 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.users.CreateUser(name); err != nil { return fmt.Errorf("create user %s: %w", name, err) } a.printf("Created local user %s\n", name) a.println("If Samba authentication is required, add a Samba password with: smbpasswd -a " + name) } 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) }