commit b7987248c198964feecdee87a89327a91d4423bc Author: DCreason Date: Thu Mar 19 15:00:10 2026 +0000 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..5241cc6 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# samba-configer + +`samba-configer` is a small interactive terminal program for reading an existing Samba configuration, listing shares, and guiding add/edit/delete operations. + +It is implemented in Go with only the standard library so it builds into a single executable easily. + +## Build + +```bash +go build -o samba-configer . +``` + +## Run + +```bash +./samba-configer -config /etc/samba/smb.conf +``` + +The program: + +- Reads the Samba config and parses non-`[global]` sections as shares. +- Prompts to add, edit, or delete a share. +- Checks `valid users` entries against local accounts. +- Offers to create missing local accounts with `useradd -M -s /usr/sbin/nologin `. +- Writes a timestamped backup before saving changes. + +If you create a local account for a Samba-authenticated share, you may still need to add the Samba password separately: + +```bash +smbpasswd -a +``` diff --git a/app.go b/app.go new file mode 100644 index 0000000..bf8a27b --- /dev/null +++ b/app.go @@ -0,0 +1,384 @@ +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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f5562f0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module samba-configer + +go 1.25.0 diff --git a/main.go b/main.go new file mode 100644 index 0000000..4916c3b --- /dev/null +++ b/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" +) + +func main() { + configPath := flag.String("config", "/etc/samba/smb.conf", "path to smb.conf") + flag.Parse() + + app := NewApp(*configPath, RealUserManager{}) + if err := app.Run(); err != nil { + if errors.Is(err, ErrCancelled) { + fmt.Fprintln(os.Stderr, "cancelled") + os.Exit(1) + } + + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..22525fd --- /dev/null +++ b/parser.go @@ -0,0 +1,297 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "slices" + "strings" +) + +type Document struct { + Preamble []string + Sections []*Section +} + +type Section struct { + Name string + Lines []Line +} + +type Line struct { + Raw string + Key string + Value string + IsKV bool + Disabled bool +} + +func ParseConfigFile(path string) (*Document, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + return ParseConfig(string(data)) +} + +func ParseConfig(contents string) (*Document, error) { + doc := &Document{} + var current *Section + + scanner := bufio.NewScanner(strings.NewReader(contents)) + for scanner.Scan() { + raw := scanner.Text() + trimmed := strings.TrimSpace(raw) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + name := strings.TrimSpace(trimmed[1 : len(trimmed)-1]) + current = &Section{Name: name} + doc.Sections = append(doc.Sections, current) + continue + } + + line := parseLine(raw) + if current == nil { + doc.Preamble = append(doc.Preamble, raw) + continue + } + current.Lines = append(current.Lines, line) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return doc, nil +} + +func parseLine(raw string) Line { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";") { + return Line{Raw: raw} + } + + disabled := false + parseTarget := trimmed + if strings.HasPrefix(parseTarget, "#") || strings.HasPrefix(parseTarget, ";") { + disabled = true + parseTarget = strings.TrimSpace(parseTarget[1:]) + } + + key, value, ok := strings.Cut(parseTarget, "=") + if !ok { + return Line{Raw: raw} + } + + return Line{ + Raw: raw, + Key: strings.TrimSpace(strings.ToLower(key)), + Value: strings.TrimSpace(value), + IsKV: true, + Disabled: disabled, + } +} + +func (d *Document) ShareSections() []*Section { + var shares []*Section + for _, section := range d.Sections { + if !strings.EqualFold(section.Name, "global") { + shares = append(shares, section) + } + } + return shares +} + +func (d *Document) Section(name string) *Section { + for _, section := range d.Sections { + if strings.EqualFold(section.Name, name) { + return section + } + } + return nil +} + +func (d *Document) DeleteSection(name string) bool { + for i, section := range d.Sections { + if strings.EqualFold(section.Name, name) { + d.Sections = append(d.Sections[:i], d.Sections[i+1:]...) + return true + } + } + return false +} + +func (d *Document) UpsertSection(section *Section) { + for i, existing := range d.Sections { + if strings.EqualFold(existing.Name, section.Name) { + d.Sections[i] = section + return + } + } + d.Sections = append(d.Sections, section) +} + +func (d *Document) Serialize() string { + var b strings.Builder + for _, line := range d.Preamble { + b.WriteString(line) + b.WriteString("\n") + } + + if len(d.Preamble) > 0 && len(d.Sections) > 0 { + b.WriteString("\n") + } + + for i, section := range d.Sections { + b.WriteString(fmt.Sprintf("[%s]\n", section.Name)) + for _, line := range section.Lines { + if line.IsKV { + if line.Disabled { + b.WriteString(fmt.Sprintf("# %s = %s\n", line.Key, line.Value)) + } else { + b.WriteString(fmt.Sprintf(" %s = %s\n", line.Key, line.Value)) + } + continue + } + + b.WriteString(line.Raw) + b.WriteString("\n") + } + + if i < len(d.Sections)-1 { + b.WriteString("\n") + } + } + + return b.String() +} + +type ShareConfig struct { + Name string + Path string + Comment string + Browseable string + ReadOnly string + GuestOK string + ValidUsers []string +} + +func ShareFromSection(section *Section) ShareConfig { + cfg := ShareConfig{ + Name: section.Name, + Browseable: "yes", + ReadOnly: "no", + GuestOK: "no", + } + + for _, line := range section.Lines { + if !line.IsKV || line.Disabled { + continue + } + + switch line.Key { + case "path": + cfg.Path = line.Value + case "comment": + cfg.Comment = line.Value + case "browseable", "browsable": + cfg.Browseable = line.Value + case "read only", "readonly": + cfg.ReadOnly = line.Value + case "guest ok": + cfg.GuestOK = line.Value + case "valid users": + cfg.ValidUsers = splitUsers(line.Value) + } + } + + return cfg +} + +func BuildShareSection(existing *Section, cfg ShareConfig) *Section { + var lines []Line + if existing != nil { + lines = slices.Clone(existing.Lines) + } else { + lines = []Line{} + } + + section := &Section{ + Name: cfg.Name, + Lines: lines, + } + + section.SetValue("path", cfg.Path) + if cfg.Comment != "" { + section.SetValue("comment", cfg.Comment) + } else { + section.DeleteValue("comment") + } + section.SetValue("browseable", normalizeBoolish(cfg.Browseable, "yes")) + section.SetValue("read only", normalizeBoolish(cfg.ReadOnly, "no")) + section.SetValue("guest ok", normalizeBoolish(cfg.GuestOK, "no")) + if len(cfg.ValidUsers) > 0 { + section.SetValue("valid users", strings.Join(cfg.ValidUsers, ", ")) + } else { + section.DeleteValue("valid users") + } + + return section +} + +func (s *Section) SetValue(key, value string) { + canonical := strings.ToLower(strings.TrimSpace(key)) + for i, line := range s.Lines { + if line.IsKV && line.Key == canonical { + s.Lines[i].Value = value + s.Lines[i].Disabled = false + return + } + } + + s.Lines = append(s.Lines, Line{ + Key: canonical, + Value: value, + IsKV: true, + }) +} + +func (s *Section) DeleteValue(key string) { + canonical := strings.ToLower(strings.TrimSpace(key)) + dst := s.Lines[:0] + for _, line := range s.Lines { + if line.IsKV && line.Key == canonical { + continue + } + dst = append(dst, line) + } + s.Lines = dst +} + +func splitUsers(raw string) []string { + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' + }) + + var users []string + for _, field := range fields { + field = strings.TrimSpace(field) + if field != "" { + users = append(users, field) + } + } + return users +} + +func normalizeBoolish(value, fallback string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "yes", "true", "1": + return "yes" + case "no", "false", "0": + return "no" + case "": + return fallback + default: + return value + } +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..13cf2ff --- /dev/null +++ b/parser_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "strings" + "testing" +) + +func TestParseConfigFindsShares(t *testing.T) { + doc, err := ParseConfig(` +# comment +[global] + workgroup = WORKGROUP + +[media] + path = /srv/media + valid users = alice, bob + guest ok = no +`) + if err != nil { + t.Fatalf("ParseConfig() error = %v", err) + } + + shares := doc.ShareSections() + if len(shares) != 1 { + t.Fatalf("expected 1 share, got %d", len(shares)) + } + + cfg := ShareFromSection(shares[0]) + if cfg.Name != "media" { + t.Fatalf("expected share name media, got %q", cfg.Name) + } + if cfg.Path != "/srv/media" { + t.Fatalf("expected path /srv/media, got %q", cfg.Path) + } + if strings.Join(cfg.ValidUsers, ",") != "alice,bob" { + t.Fatalf("expected users alice,bob, got %v", cfg.ValidUsers) + } +} + +func TestBuildShareSectionUpdatesValues(t *testing.T) { + section := &Section{ + Name: "public", + Lines: []Line{ + {Key: "path", Value: "/old", IsKV: true}, + {Key: "comment", Value: "old", IsKV: true}, + }, + } + + updated := BuildShareSection(section, ShareConfig{ + Name: "public", + Path: "/srv/public", + Comment: "", + Browseable: "yes", + ReadOnly: "no", + GuestOK: "yes", + ValidUsers: []string{"alice"}, + }) + + cfg := ShareFromSection(updated) + if cfg.Path != "/srv/public" { + t.Fatalf("expected updated path, got %q", cfg.Path) + } + if cfg.Comment != "" { + t.Fatalf("expected comment removed, got %q", cfg.Comment) + } + if strings.Join(cfg.ValidUsers, ",") != "alice" { + t.Fatalf("expected alice, got %v", cfg.ValidUsers) + } +} + +func TestSerializeProducesSectionHeaders(t *testing.T) { + doc := &Document{ + Preamble: []string{"# smb.conf"}, + Sections: []*Section{ + BuildShareSection(nil, ShareConfig{ + Name: "docs", + Path: "/srv/docs", + Browseable: "yes", + ReadOnly: "no", + GuestOK: "no", + }), + }, + } + + out := doc.Serialize() + for _, want := range []string{ + "# smb.conf", + "[docs]", + "path = /srv/docs", + "browseable = yes", + } { + if !strings.Contains(out, want) { + t.Fatalf("serialized output missing %q:\n%s", want, out) + } + } +} diff --git a/samba-configer b/samba-configer new file mode 100755 index 0000000..73d852c Binary files /dev/null and b/samba-configer differ