617 lines
15 KiB
Go
617 lines
15 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)
|
|
if err := a.offerSetSambaPassword(name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
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) offerSetSambaPassword(name string) error {
|
|
a.println("")
|
|
a.println("This share can use a Samba password for sign-in.")
|
|
a.println("I can set that up now and ask you to type the password you want to use.")
|
|
a.flush()
|
|
|
|
setPassword, err := a.confirm(fmt.Sprintf("Set a Samba password for %q now", name), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !setPassword {
|
|
a.println("You can do this later with: smbpasswd -a " + name)
|
|
return nil
|
|
}
|
|
|
|
return a.setSambaPassword(name)
|
|
}
|
|
|
|
func (a *App) setSambaPassword(name string) error {
|
|
if a.lookPath != nil {
|
|
if _, err := a.lookPath("smbpasswd"); err != nil {
|
|
a.println("I couldn't find the smbpasswd tool yet.")
|
|
a.println("If Samba was just installed, try opening the app again after the installation finishes.")
|
|
return fmt.Errorf("set Samba password for %s: smbpasswd command not found", name)
|
|
}
|
|
}
|
|
|
|
if err := a.runner.Run("smbpasswd", "-a", name); err == nil {
|
|
a.printf("Samba password set for %s\n", name)
|
|
return nil
|
|
} else if shouldOfferPrivilegeRetry(err) {
|
|
a.println("")
|
|
a.println("Setting a Samba password 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("set Samba password for %s: %w", name, err)
|
|
}
|
|
|
|
retry, promptErr := a.confirm("Retry Samba password setup with sudo", true)
|
|
if promptErr != nil {
|
|
return promptErr
|
|
}
|
|
if !retry {
|
|
return fmt.Errorf("set Samba password for %s: %w", name, err)
|
|
}
|
|
|
|
if sudoErr := a.runner.Run("sudo", "smbpasswd", "-a", name); sudoErr != nil {
|
|
return fmt.Errorf("set Samba password for %s with sudo: %w", name, sudoErr)
|
|
}
|
|
|
|
a.printf("Samba password set for %s\n", name)
|
|
return nil
|
|
} else {
|
|
return fmt.Errorf("set Samba password for %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)
|
|
}
|