1534 lines
38 KiB
Go
1534 lines
38 KiB
Go
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"os/user"
|
||
"path/filepath"
|
||
"slices"
|
||
"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
|
||
}
|
||
|
||
type PasswdEntry struct {
|
||
Name string
|
||
UID int
|
||
GID int
|
||
Home string
|
||
Shell string
|
||
}
|
||
|
||
type CIFSMountConfig struct {
|
||
Server string
|
||
Share string
|
||
MountPoint string
|
||
Username string
|
||
Password string
|
||
Domain string
|
||
UID string
|
||
GID string
|
||
FileMode string
|
||
DirMode string
|
||
AutoMount bool
|
||
ReadOnly bool
|
||
}
|
||
|
||
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 {
|
||
for {
|
||
choice, err := a.chooseStartupWorkflow()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
switch choice {
|
||
case "server":
|
||
return a.runServerWorkflow()
|
||
case "client":
|
||
return a.setupClientMount()
|
||
case "quit":
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
|
||
func (a *App) chooseStartupWorkflow() (string, error) {
|
||
a.println("")
|
||
a.println("Samba setup assistant")
|
||
a.println("=====================")
|
||
a.println("[s] set up or edit shares on this computer")
|
||
a.println("[c] connect this computer to a share on another server")
|
||
a.println("[q] quit")
|
||
a.flush()
|
||
|
||
choice, err := a.prompt("What would you like to do")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
switch strings.ToLower(choice) {
|
||
case "s", "server":
|
||
return "server", nil
|
||
case "c", "client":
|
||
return "client", nil
|
||
case "q", "quit":
|
||
return "quit", nil
|
||
default:
|
||
a.println("Unknown choice.")
|
||
return a.chooseStartupWorkflow()
|
||
}
|
||
}
|
||
|
||
func (a *App) runServerWorkflow() 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("[m] set up client mount")
|
||
a.println("[u] manage users")
|
||
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 "m", "mount":
|
||
if err := a.setupClientMount(); err != nil {
|
||
return err
|
||
}
|
||
case "u", "users":
|
||
if err := a.manageUsers(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) setupClientMount() error {
|
||
a.println("")
|
||
a.println("Client mount setup")
|
||
a.println("==================")
|
||
a.println("This will help connect this computer to a shared folder on another server.")
|
||
a.flush()
|
||
|
||
if err := a.ensureCIFSUtilsInstalled(); err != nil {
|
||
return err
|
||
}
|
||
|
||
cfg, err := a.collectCIFSMountConfig()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := a.ensureMountPoint(cfg.MountPoint); err != nil {
|
||
return err
|
||
}
|
||
|
||
line := buildFstabLine(cfg)
|
||
a.println("")
|
||
a.println("Suggested /etc/fstab line:")
|
||
a.println(line)
|
||
a.println("")
|
||
a.println("This will store the username and password directly in /etc/fstab.")
|
||
a.println("That is simple for home use, but less secure than a credentials file.")
|
||
a.flush()
|
||
|
||
addLine, err := a.confirm("Add this line to /etc/fstab now", true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !addLine {
|
||
return nil
|
||
}
|
||
|
||
if err := a.appendFstabLine(line); err != nil {
|
||
return err
|
||
}
|
||
|
||
a.println("")
|
||
a.println("The mount entry was added to /etc/fstab.")
|
||
mountNow, err := a.confirm("Try mounting it now", true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if mountNow {
|
||
if err := a.reloadSystemdIfNeeded(); err != nil {
|
||
return err
|
||
}
|
||
if err := a.mountCIFS(cfg.MountPoint); err != nil {
|
||
return err
|
||
}
|
||
a.println("The network share was mounted.")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (a *App) manageUsers(doc *Document) error {
|
||
for {
|
||
a.println("")
|
||
a.println("User management")
|
||
a.println("===============")
|
||
a.println("[c] check share accounts")
|
||
a.println("[p] change a Samba password")
|
||
a.println("[x] delete an unused account")
|
||
a.println("[b] back")
|
||
a.flush()
|
||
|
||
choice, err := a.prompt("Select an action")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
switch strings.ToLower(choice) {
|
||
case "c", "check":
|
||
if err := a.checkShareAccounts(doc); err != nil {
|
||
return err
|
||
}
|
||
case "p", "password":
|
||
if err := a.changeSambaPassword(doc); err != nil {
|
||
return err
|
||
}
|
||
case "x", "delete":
|
||
if err := a.deleteUnusedAccount(doc); err != nil {
|
||
return err
|
||
}
|
||
case "b", "back":
|
||
return nil
|
||
default:
|
||
a.println("Unknown choice.")
|
||
}
|
||
}
|
||
}
|
||
|
||
func (a *App) checkShareAccounts(doc *Document) error {
|
||
references := shareUserReferences(doc)
|
||
if len(references) == 0 {
|
||
a.println("No accounts are listed in any share.")
|
||
return nil
|
||
}
|
||
|
||
a.println("")
|
||
a.println("Accounts used by the current shares:")
|
||
for _, ref := range references {
|
||
status := "missing"
|
||
if a.users.UserExists(ref.User) {
|
||
status = "present"
|
||
}
|
||
a.printf(" %s [%s] used by: %s\n", ref.User, status, strings.Join(ref.Shares, ", "))
|
||
}
|
||
|
||
var missing []string
|
||
for _, ref := range references {
|
||
if !a.users.UserExists(ref.User) {
|
||
missing = append(missing, ref.User)
|
||
}
|
||
}
|
||
|
||
if len(missing) == 0 {
|
||
a.println("")
|
||
a.println("All listed share accounts already exist.")
|
||
return nil
|
||
}
|
||
|
||
a.println("")
|
||
a.println("Some accounts are missing and those users may not be able to sign in.")
|
||
if err := a.ensureUsers(missing); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *App) deleteUnusedAccount(doc *Document) error {
|
||
entries, err := readPasswdEntries()
|
||
if err != nil {
|
||
return fmt.Errorf("read local users: %w", err)
|
||
}
|
||
|
||
candidates := unusedAccountCandidates(entries, shareUsers(doc))
|
||
if len(candidates) == 0 {
|
||
a.println("I couldn't find any obvious unused share accounts to delete.")
|
||
a.println("For safety, system accounts are excluded from this list.")
|
||
return nil
|
||
}
|
||
|
||
a.println("")
|
||
a.println("Accounts that look unused by the current shares:")
|
||
for i, entry := range candidates {
|
||
a.printf(" %d. %s (shell: %s)\n", i+1, entry.Name, entry.Shell)
|
||
}
|
||
a.println("Only accounts that are not listed in any share are shown here.")
|
||
a.flush()
|
||
|
||
raw, err := a.prompt("Account number to delete")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
index, err := strconv.Atoi(raw)
|
||
if err != nil || index < 1 || index > len(candidates) {
|
||
a.println("Invalid selection.")
|
||
return nil
|
||
}
|
||
|
||
entry := candidates[index-1]
|
||
a.println("")
|
||
a.printf("This will remove the local account %q.\n", entry.Name)
|
||
a.println("This is only safe if nobody needs this account for Samba or anything else.")
|
||
a.flush()
|
||
|
||
confirm, err := a.confirm("Delete this account now", false)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !confirm {
|
||
a.println("Delete cancelled.")
|
||
return nil
|
||
}
|
||
|
||
if err := a.deleteUser(entry.Name); err != nil {
|
||
return err
|
||
}
|
||
|
||
a.printf("Deleted account %s\n", entry.Name)
|
||
return nil
|
||
}
|
||
|
||
func (a *App) changeSambaPassword(doc *Document) error {
|
||
if a.lookPath != nil {
|
||
if _, err := a.lookPath("smbpasswd"); err != nil {
|
||
a.println("I couldn't find the smbpasswd tool yet.")
|
||
a.println("Install Samba first, then try this again.")
|
||
return nil
|
||
}
|
||
}
|
||
|
||
references := shareUserReferences(doc)
|
||
if len(references) == 0 {
|
||
a.println("No accounts are listed in any share.")
|
||
return nil
|
||
}
|
||
|
||
a.println("")
|
||
a.println("Accounts used by the current shares:")
|
||
for i, ref := range references {
|
||
status := "missing"
|
||
if a.users.UserExists(ref.User) {
|
||
status = "present"
|
||
}
|
||
a.printf(" %d. %s [%s] used by: %s\n", i+1, ref.User, status, strings.Join(ref.Shares, ", "))
|
||
}
|
||
a.flush()
|
||
|
||
raw, err := a.prompt("Account number to update")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
index, err := strconv.Atoi(raw)
|
||
if err != nil || index < 1 || index > len(references) {
|
||
a.println("Invalid selection.")
|
||
return nil
|
||
}
|
||
|
||
ref := references[index-1]
|
||
if !a.users.UserExists(ref.User) {
|
||
a.println("")
|
||
a.printf("The local account %q does not exist yet.\n", ref.User)
|
||
a.println("I can create it first so a Samba password can be set.")
|
||
a.flush()
|
||
|
||
create, promptErr := a.confirm("Create this account now", true)
|
||
if promptErr != nil {
|
||
return promptErr
|
||
}
|
||
if !create {
|
||
return nil
|
||
}
|
||
|
||
if err := a.createUser(ref.User); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
a.println("")
|
||
a.printf("I’ll open the password setup for %q now.\n", ref.User)
|
||
a.println("You’ll be asked to type the new Samba password.")
|
||
a.flush()
|
||
return a.setSambaPassword(ref.User)
|
||
}
|
||
|
||
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 {
|
||
if err := a.ensureShareDirectories(doc); err != nil {
|
||
return err
|
||
}
|
||
|
||
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) ensureCIFSUtilsInstalled() error {
|
||
if a.lookPath != nil {
|
||
if _, err := a.lookPath("mount.cifs"); err == nil {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
a.println("")
|
||
a.println("This computer does not seem to have the CIFS mount tools installed yet.")
|
||
|
||
plan, ok := DetectCIFSUtilsInstallPlan(a.lookPath, os.Geteuid() == 0)
|
||
if !ok {
|
||
a.println("I couldn't find a supported package manager automatically.")
|
||
a.println("Install cifs-utils for your Linux distribution, then try again.")
|
||
return errors.New("cifs-utils is not installed")
|
||
}
|
||
|
||
a.printf("I found %s and can try to install the CIFS client tools for you.\n", plan.ManagerName)
|
||
a.printf("This would run: %s\n", plan.DisplayCommand())
|
||
a.flush()
|
||
|
||
install, err := a.confirm("Install the CIFS client tools now", true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !install {
|
||
return errors.New("cifs-utils is required for client mounts")
|
||
}
|
||
|
||
if err := a.runner.Run(plan.Command[0], plan.Command[1:]...); err != nil {
|
||
return fmt.Errorf("install CIFS client tools with %s: %w", plan.ManagerName, err)
|
||
}
|
||
|
||
if a.lookPath != nil {
|
||
if _, err := a.lookPath("mount.cifs"); err != nil {
|
||
return fmt.Errorf("cifs-utils installation finished, but mount.cifs is still not available: %w", err)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (a *App) collectCIFSMountConfig() (CIFSMountConfig, error) {
|
||
server, err := a.prompt("Server name or IP")
|
||
if err != nil {
|
||
return CIFSMountConfig{}, err
|
||
}
|
||
share, err := a.prompt("Share name on that server")
|
||
if err != nil {
|
||
return CIFSMountConfig{}, err
|
||
}
|
||
mountPointDefault := filepath.Join("/mnt", strings.TrimSpace(share))
|
||
mountPoint, err := a.promptDefault("Local mount folder", mountPointDefault)
|
||
if err != nil {
|
||
return CIFSMountConfig{}, err
|
||
}
|
||
username, err := a.prompt("Username for the remote share")
|
||
if err != nil {
|
||
return CIFSMountConfig{}, err
|
||
}
|
||
password, err := a.prompt("Password for the remote share")
|
||
if err != nil {
|
||
return CIFSMountConfig{}, err
|
||
}
|
||
domain, err := a.promptDefault("Domain or workgroup (optional)", "")
|
||
if err != nil {
|
||
return CIFSMountConfig{}, err
|
||
}
|
||
uid, err := a.promptDefault("Local owner username or uid (optional)", "")
|
||
if err != nil {
|
||
return CIFSMountConfig{}, err
|
||
}
|
||
gid, err := a.promptDefault("Local group name or gid (optional)", "")
|
||
if err != nil {
|
||
return CIFSMountConfig{}, err
|
||
}
|
||
fileMode, err := a.promptDefault("File permissions", "0664")
|
||
if err != nil {
|
||
return CIFSMountConfig{}, err
|
||
}
|
||
dirMode, err := a.promptDefault("Folder permissions", "0775")
|
||
if err != nil {
|
||
return CIFSMountConfig{}, err
|
||
}
|
||
autoMount, err := a.confirm("Mount automatically at startup", true)
|
||
if err != nil {
|
||
return CIFSMountConfig{}, err
|
||
}
|
||
readOnly, err := a.confirm("Make this mount read-only", false)
|
||
if err != nil {
|
||
return CIFSMountConfig{}, err
|
||
}
|
||
|
||
return CIFSMountConfig{
|
||
Server: strings.TrimSpace(server),
|
||
Share: strings.TrimSpace(share),
|
||
MountPoint: filepath.Clean(strings.TrimSpace(mountPoint)),
|
||
Username: strings.TrimSpace(username),
|
||
Password: strings.TrimSpace(password),
|
||
Domain: strings.TrimSpace(domain),
|
||
UID: strings.TrimSpace(uid),
|
||
GID: strings.TrimSpace(gid),
|
||
FileMode: strings.TrimSpace(fileMode),
|
||
DirMode: strings.TrimSpace(dirMode),
|
||
AutoMount: autoMount,
|
||
ReadOnly: readOnly,
|
||
}, nil
|
||
}
|
||
|
||
func (a *App) ensureMountPoint(path string) error {
|
||
info, err := os.Stat(path)
|
||
if err == nil {
|
||
if !info.IsDir() {
|
||
return fmt.Errorf("mount folder exists but is not a directory: %s", path)
|
||
}
|
||
return nil
|
||
}
|
||
if !os.IsNotExist(err) {
|
||
return fmt.Errorf("check mount folder %s: %w", path, err)
|
||
}
|
||
|
||
a.println("")
|
||
a.printf("The local mount folder %s does not exist yet.\n", path)
|
||
a.println("I can create it now.")
|
||
a.flush()
|
||
|
||
create, err := a.confirm("Create this folder now", true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !create {
|
||
return errors.New("a mount folder is needed before continuing")
|
||
}
|
||
|
||
if err := a.createDirectory(path); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *App) appendFstabLine(line string) error {
|
||
content := line + "\n"
|
||
file, err := os.OpenFile("/etc/fstab", os.O_APPEND|os.O_WRONLY, 0)
|
||
if err == nil {
|
||
defer file.Close()
|
||
if _, err := file.WriteString(content); err == nil {
|
||
return nil
|
||
} else if !shouldOfferPrivilegeRetry(err) {
|
||
return fmt.Errorf("append mount entry to /etc/fstab: %w", err)
|
||
}
|
||
} else if !shouldOfferPrivilegeRetry(err) {
|
||
return fmt.Errorf("open /etc/fstab for update: %w", err)
|
||
}
|
||
|
||
a.println("")
|
||
a.println("Updating /etc/fstab needs administrator permission.")
|
||
a.println("I can try again using sudo so you can enter your admin password.")
|
||
a.flush()
|
||
|
||
retry, err := a.confirm("Retry updating /etc/fstab with sudo", true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !retry {
|
||
return errors.New("updating /etc/fstab was cancelled")
|
||
}
|
||
|
||
tempPath, err := a.writeTempConfig(content)
|
||
if err != nil {
|
||
return fmt.Errorf("prepare fstab update: %w", err)
|
||
}
|
||
defer os.Remove(tempPath)
|
||
|
||
if err := a.runner.Run("sudo", "sh", "-c", fmt.Sprintf("cat %s >> /etc/fstab", shellQuote(tempPath))); err != nil {
|
||
return fmt.Errorf("append mount entry to /etc/fstab with sudo: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *App) mountCIFS(path string) error {
|
||
if err := a.runPrivilegedOrLocal("mount", []string{path}, "Mounting the network share needs administrator permission."); err != nil {
|
||
return fmt.Errorf("mount %s: %w", path, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *App) reloadSystemdIfNeeded() error {
|
||
if _, err := os.Stat("/run/systemd/system"); err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("check for systemd: %w", err)
|
||
}
|
||
|
||
if a.lookPath != nil {
|
||
if _, err := a.lookPath("systemctl"); err != nil {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
if err := a.runPrivilegedOrLocal("systemctl", []string{"daemon-reload"}, "Refreshing system mount settings needs administrator permission."); err != nil {
|
||
return fmt.Errorf("reload systemd after updating /etc/fstab: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *App) ensureShareDirectories(doc *Document) error {
|
||
for _, section := range doc.ShareSections() {
|
||
cfg := ShareFromSection(section)
|
||
if strings.TrimSpace(cfg.Path) == "" {
|
||
continue
|
||
}
|
||
|
||
info, err := os.Stat(cfg.Path)
|
||
if err == nil {
|
||
if info.IsDir() {
|
||
if err := a.offerAdjustDirectoryPermissions(cfg); err != nil {
|
||
return err
|
||
}
|
||
continue
|
||
}
|
||
return fmt.Errorf("share %q path exists but is not a directory: %s", cfg.Name, cfg.Path)
|
||
}
|
||
|
||
if !os.IsNotExist(err) {
|
||
return fmt.Errorf("check share %q path %s: %w", cfg.Name, cfg.Path, err)
|
||
}
|
||
|
||
a.println("")
|
||
a.printf("The folder for share %q does not exist yet.\n", cfg.Name)
|
||
a.printf("Missing folder: %s\n", cfg.Path)
|
||
a.println("I can create it now so the share is ready to use.")
|
||
a.flush()
|
||
|
||
create, promptErr := a.confirm("Create this folder now", true)
|
||
if promptErr != nil {
|
||
return promptErr
|
||
}
|
||
if !create {
|
||
return fmt.Errorf("share %q needs an existing folder before saving", cfg.Name)
|
||
}
|
||
|
||
if err := a.createDirectory(cfg.Path); err != nil {
|
||
return fmt.Errorf("create folder for share %q: %w", cfg.Name, err)
|
||
}
|
||
|
||
a.printf("Created folder %s\n", cfg.Path)
|
||
if err := a.offerAdjustDirectoryPermissions(cfg); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
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) deleteUser(name string) error {
|
||
if err := a.runPrivilegedOrLocal("userdel", []string{name}, "Deleting a Linux user needs administrator permission."); err != nil {
|
||
return fmt.Errorf("delete user %s: %w", name, err)
|
||
}
|
||
|
||
if a.lookPath != nil {
|
||
if _, err := a.lookPath("smbpasswd"); err == nil {
|
||
removeSamba, promptErr := a.confirm("Also remove the Samba password for this account", true)
|
||
if promptErr != nil {
|
||
return promptErr
|
||
}
|
||
if removeSamba {
|
||
if err := a.runPrivilegedOrLocal("smbpasswd", []string{"-x", name}, "Removing the Samba password needs administrator permission."); err != nil {
|
||
return fmt.Errorf("remove Samba password for %s: %w", name, err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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 (a *App) createDirectory(path string) error {
|
||
if err := os.MkdirAll(path, 0o755); err == nil {
|
||
return nil
|
||
} else if shouldOfferPrivilegeRetry(err) {
|
||
a.println("")
|
||
a.println("Creating that folder 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 folder %s: %w", path, err)
|
||
}
|
||
|
||
retry, promptErr := a.confirm("Retry folder creation with sudo", true)
|
||
if promptErr != nil {
|
||
return promptErr
|
||
}
|
||
if !retry {
|
||
return fmt.Errorf("create folder %s: %w", path, err)
|
||
}
|
||
|
||
if sudoErr := a.runner.Run("sudo", "mkdir", "-p", path); sudoErr != nil {
|
||
return fmt.Errorf("create folder %s with sudo: %w", path, sudoErr)
|
||
}
|
||
if sudoErr := a.runner.Run("sudo", "chmod", "755", path); sudoErr != nil {
|
||
return fmt.Errorf("set permissions on folder %s with sudo: %w", path, sudoErr)
|
||
}
|
||
return nil
|
||
} else {
|
||
return fmt.Errorf("create folder %s: %w", path, err)
|
||
}
|
||
}
|
||
|
||
func (a *App) offerAdjustDirectoryPermissions(cfg ShareConfig) error {
|
||
mode, owner, group, explanation := recommendSharePermissions(cfg)
|
||
|
||
a.println("")
|
||
a.printf("The share folder %s may need permissions adjusted for file operations.\n", cfg.Path)
|
||
a.println(explanation)
|
||
if owner != "" || group != "" {
|
||
a.printf("Suggested owner/group: %s:%s\n", defaultString(owner, "unchanged"), defaultString(group, "unchanged"))
|
||
}
|
||
a.printf("Suggested permissions: %s\n", mode)
|
||
a.println("I can apply these recommended permissions now.")
|
||
a.flush()
|
||
|
||
adjust, err := a.confirm("Adjust this folder now", true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !adjust {
|
||
return nil
|
||
}
|
||
|
||
if err := a.applyDirectoryPermissions(cfg.Path, owner, group, mode); err != nil {
|
||
return fmt.Errorf("adjust permissions for share %q: %w", cfg.Name, err)
|
||
}
|
||
|
||
a.printf("Updated permissions for %s\n", cfg.Path)
|
||
return nil
|
||
}
|
||
|
||
func recommendSharePermissions(cfg ShareConfig) (mode, owner, group, explanation string) {
|
||
mode = "0770"
|
||
group = "users"
|
||
explanation = "Members of the allowed account list should usually be able to read and write here."
|
||
|
||
if strings.EqualFold(normalizeBoolish(cfg.ReadOnly, "no"), "yes") {
|
||
mode = "0755"
|
||
explanation = "This share is marked read-only, so a safer folder mode is recommended."
|
||
}
|
||
|
||
if strings.EqualFold(normalizeBoolish(cfg.GuestOK, "no"), "yes") {
|
||
mode = "0777"
|
||
group = ""
|
||
explanation = "This share allows guests, so wider permissions are usually needed for uploads and edits."
|
||
return mode, "", group, explanation
|
||
}
|
||
|
||
if len(cfg.ValidUsers) == 1 {
|
||
owner = cfg.ValidUsers[0]
|
||
group = cfg.ValidUsers[0]
|
||
explanation = "This share is limited to one account, so making that account the owner is the simplest setup."
|
||
return mode, owner, group, explanation
|
||
}
|
||
|
||
if len(cfg.ValidUsers) > 1 {
|
||
explanation = "This share has multiple allowed accounts, so a shared writable group-style setup is recommended."
|
||
}
|
||
|
||
return mode, owner, group, explanation
|
||
}
|
||
|
||
func (a *App) applyDirectoryPermissions(path, owner, group, mode string) error {
|
||
chownTarget := owner
|
||
if group != "" {
|
||
if chownTarget == "" {
|
||
chownTarget = ":" + group
|
||
} else {
|
||
chownTarget = chownTarget + ":" + group
|
||
}
|
||
}
|
||
|
||
if err := a.runPrivilegedOrLocal("chmod", []string{mode, path}, "Changing folder permissions needs administrator permission."); err != nil {
|
||
return err
|
||
}
|
||
|
||
if chownTarget != "" {
|
||
if err := a.runPrivilegedOrLocal("chown", []string{chownTarget, path}, "Changing folder ownership needs administrator permission."); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (a *App) runPrivilegedOrLocal(command string, args []string, privilegeMessage string) error {
|
||
if err := a.runner.Run(command, args...); err == nil {
|
||
return nil
|
||
} else if shouldOfferPrivilegeRetry(err) {
|
||
a.println("")
|
||
a.println(privilegeMessage)
|
||
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("%s %s: %w", command, strings.Join(args, " "), err)
|
||
}
|
||
|
||
retry, promptErr := a.confirm("Retry with sudo", true)
|
||
if promptErr != nil {
|
||
return promptErr
|
||
}
|
||
if !retry {
|
||
return fmt.Errorf("%s %s: %w", command, strings.Join(args, " "), err)
|
||
}
|
||
|
||
if sudoErr := a.runner.Run("sudo", append([]string{command}, args...)...); sudoErr != nil {
|
||
return fmt.Errorf("%s %s with sudo: %w", command, strings.Join(args, " "), sudoErr)
|
||
}
|
||
return nil
|
||
} else {
|
||
return fmt.Errorf("%s %s: %w", command, strings.Join(args, " "), err)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
func buildFstabLine(cfg CIFSMountConfig) string {
|
||
source := fmt.Sprintf("//%s/%s", cfg.Server, cfg.Share)
|
||
|
||
options := []string{
|
||
"username=" + escapeFstabValue(cfg.Username),
|
||
"password=" + escapeFstabValue(cfg.Password),
|
||
"iocharset=utf8",
|
||
}
|
||
|
||
if cfg.Domain != "" {
|
||
options = append(options, "domain="+escapeFstabValue(cfg.Domain))
|
||
}
|
||
if cfg.UID != "" {
|
||
options = append(options, "uid="+escapeFstabValue(cfg.UID))
|
||
}
|
||
if cfg.GID != "" {
|
||
options = append(options, "gid="+escapeFstabValue(cfg.GID))
|
||
}
|
||
if cfg.FileMode != "" {
|
||
options = append(options, "file_mode="+cfg.FileMode)
|
||
}
|
||
if cfg.DirMode != "" {
|
||
options = append(options, "dir_mode="+cfg.DirMode)
|
||
}
|
||
if cfg.ReadOnly {
|
||
options = append(options, "ro")
|
||
} else {
|
||
options = append(options, "rw")
|
||
}
|
||
if !cfg.AutoMount {
|
||
options = append(options, "noauto")
|
||
}
|
||
|
||
return fmt.Sprintf("%s %s cifs %s 0 0", source, cfg.MountPoint, strings.Join(options, ","))
|
||
}
|
||
|
||
func escapeFstabValue(value string) string {
|
||
value = strings.ReplaceAll(value, `\`, `\\`)
|
||
value = strings.ReplaceAll(value, ",", `\,`)
|
||
value = strings.ReplaceAll(value, " ", `\040`)
|
||
return value
|
||
}
|
||
|
||
func shellQuote(value string) string {
|
||
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
|
||
}
|
||
|
||
type ShareUserReference struct {
|
||
User string
|
||
Shares []string
|
||
}
|
||
|
||
func shareUsers(doc *Document) map[string]struct{} {
|
||
users := make(map[string]struct{})
|
||
for _, section := range doc.ShareSections() {
|
||
cfg := ShareFromSection(section)
|
||
for _, user := range cfg.ValidUsers {
|
||
users[user] = struct{}{}
|
||
}
|
||
}
|
||
return users
|
||
}
|
||
|
||
func shareUserReferences(doc *Document) []ShareUserReference {
|
||
refs := map[string][]string{}
|
||
for _, section := range doc.ShareSections() {
|
||
cfg := ShareFromSection(section)
|
||
for _, user := range cfg.ValidUsers {
|
||
refs[user] = append(refs[user], cfg.Name)
|
||
}
|
||
}
|
||
|
||
var out []ShareUserReference
|
||
for user, shares := range refs {
|
||
out = append(out, ShareUserReference{User: user, Shares: shares})
|
||
}
|
||
slices.SortFunc(out, func(a, b ShareUserReference) int {
|
||
return strings.Compare(a.User, b.User)
|
||
})
|
||
return out
|
||
}
|
||
|
||
func readPasswdEntries() ([]PasswdEntry, error) {
|
||
data, err := os.ReadFile("/etc/passwd")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var entries []PasswdEntry
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
if strings.TrimSpace(line) == "" {
|
||
continue
|
||
}
|
||
|
||
parts := strings.Split(line, ":")
|
||
if len(parts) < 7 {
|
||
continue
|
||
}
|
||
|
||
uid, err := strconv.Atoi(parts[2])
|
||
if err != nil {
|
||
continue
|
||
}
|
||
gid, err := strconv.Atoi(parts[3])
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
entries = append(entries, PasswdEntry{
|
||
Name: parts[0],
|
||
UID: uid,
|
||
GID: gid,
|
||
Home: parts[5],
|
||
Shell: parts[6],
|
||
})
|
||
}
|
||
|
||
return entries, nil
|
||
}
|
||
|
||
func unusedAccountCandidates(entries []PasswdEntry, active map[string]struct{}) []PasswdEntry {
|
||
var candidates []PasswdEntry
|
||
for _, entry := range entries {
|
||
if _, ok := active[entry.Name]; ok {
|
||
continue
|
||
}
|
||
if !looksLikeSafeShareAccount(entry) {
|
||
continue
|
||
}
|
||
candidates = append(candidates, entry)
|
||
}
|
||
|
||
slices.SortFunc(candidates, func(a, b PasswdEntry) int {
|
||
return strings.Compare(a.Name, b.Name)
|
||
})
|
||
return candidates
|
||
}
|
||
|
||
func looksLikeSafeShareAccount(entry PasswdEntry) bool {
|
||
if entry.UID < 1000 {
|
||
return false
|
||
}
|
||
|
||
shell := strings.TrimSpace(entry.Shell)
|
||
return strings.HasSuffix(shell, "/nologin") || strings.HasSuffix(shell, "/false")
|
||
}
|