Files
samba-configer/app.go

551 lines
13 KiB
Go

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 {
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) 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)
a.println("If Samba authentication is required, add a Samba password with: smbpasswd -a " + name)
}
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) 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 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)
}