This commit is contained in:
2026-03-19 15:00:10 +00:00
commit b7987248c1
7 changed files with 835 additions and 0 deletions

31
README.md Normal file
View File

@@ -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 <user>`.
- 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 <username>
```

384
app.go Normal file
View File

@@ -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)
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module samba-configer
go 1.25.0

24
main.go Normal file
View File

@@ -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)
}
}

297
parser.go Normal file
View File

@@ -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
}
}

96
parser_test.go Normal file
View File

@@ -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)
}
}
}

BIN
samba-configer Executable file

Binary file not shown.