init
This commit is contained in:
31
README.md
Normal file
31
README.md
Normal 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
384
app.go
Normal 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)
|
||||||
|
}
|
||||||
24
main.go
Normal file
24
main.go
Normal 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
297
parser.go
Normal 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
96
parser_test.go
Normal 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
BIN
samba-configer
Executable file
Binary file not shown.
Reference in New Issue
Block a user