Files
samba-configer/app.go

2007 lines
50 KiB
Go

package main
import (
"bufio"
"errors"
"fmt"
"io"
"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
fstabPath string
users UserManager
runner CommandRunner
lookPath LookPathFunc
input io.Reader
output io.Writer
writer *bufio.Writer
theme tuiTheme
}
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
}
type FstabMountEntry struct {
LineIndex int
Source string
MountPoint string
FSType string
Options []string
Dump string
Pass string
RawLine string
DisplayName string
}
func NewApp(configPath string, users UserManager, runner CommandRunner, lookPath LookPathFunc) *App {
return &App{
configPath: configPath,
fstabPath: "/etc/fstab",
users: users,
runner: runner,
lookPath: lookPath,
input: os.Stdin,
output: os.Stdout,
writer: bufio.NewWriter(os.Stdout),
theme: newTUITheme(),
}
}
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) {
return a.chooseMenu(
"Choose A Workflow",
"Server shares and client mounts in one place.",
[]string{
"Use the server tools to edit smb.conf and manage share accounts.",
"Use the client tools to add or maintain CIFS mounts in /etc/fstab.",
},
[]menuOption{
{Key: "s", Value: "server", Label: "Server shares"},
{Key: "c", Value: "client", Label: "Client mounts"},
{Key: "q", Value: "quit", Label: "Quit"},
},
)
}
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()
intro := []string{"Current shares:"}
if len(shareSections) == 0 {
intro = append(intro, " none yet")
} else {
for _, section := range shareSections {
cfg := ShareFromSection(section)
intro = append(intro, fmt.Sprintf(" %s -> %s", cfg.Name, cfg.Path))
}
}
choice, err := a.chooseMenu(
"Server Share Editor",
fmt.Sprintf("Working against %s", a.configPath),
intro,
[]menuOption{
{Key: "a", Value: "add", Label: "Add share"},
{Key: "e", Value: "edit", Label: "Edit share"},
{Key: "d", Value: "delete", Label: "Delete share"},
{Key: "m", Value: "mount", Label: "Client mounts"},
{Key: "u", Value: "users", Label: "Users"},
{Key: "w", Value: "write", Label: "Write and exit"},
{Key: "q", Value: "quit", Label: "Quit"},
},
)
if err != nil {
if errors.Is(err, ErrCancelled) {
return nil
}
return err
}
switch choice {
case "add":
if err := a.addShare(doc); err != nil {
return err
}
case "edit":
if err := a.editShare(doc); err != nil {
return err
}
case "delete":
if err := a.deleteShare(doc); err != nil {
return err
}
case "mount":
if err := a.setupClientMount(); err != nil {
return err
}
case "users":
if err := a.manageUsers(doc); err != nil {
return err
}
case "write":
return a.writeConfig(doc)
case "quit":
return nil
}
}
}
func (a *App) setupClientMount() error {
for {
entries, err := a.loadCIFSMountEntries()
if err != nil {
return err
}
intro := []string{
"Remote shares are persisted in /etc/fstab so they can mount cleanly on boot.",
"Current client mounts:",
}
if len(entries) == 0 {
intro = append(intro, " none yet")
} else {
for _, entry := range entries {
intro = append(intro, fmt.Sprintf(" %s -> %s", entry.DisplayName, entry.MountPoint))
}
}
choice, err := a.chooseMenu(
"Client Mount Setup",
fmt.Sprintf("Editing %s", a.fstabPath),
intro,
[]menuOption{
{Key: "a", Value: "add", Label: "Add mount"},
{Key: "e", Value: "edit", Label: "Edit mount"},
{Key: "d", Value: "delete", Label: "Delete mount"},
{Key: "b", Value: "back", Label: "Back"},
},
)
if err != nil {
if errors.Is(err, ErrCancelled) {
return nil
}
return err
}
switch choice {
case "add":
if err := a.addClientMount(); err != nil {
return err
}
case "edit":
if err := a.editClientMount(entries); err != nil {
return err
}
case "delete":
if err := a.deleteClientMount(entries); err != nil {
return err
}
case "back":
return nil
}
}
}
func (a *App) manageUsers(doc *Document) error {
for {
choice, err := a.chooseMenu(
"User Management",
"Keep local Linux accounts and Samba credentials aligned with your shares.",
[]string{
fmt.Sprintf("%d share accounts referenced in the current config.", len(shareUserReferences(doc))),
},
[]menuOption{
{Key: "c", Value: "check", Label: "Check accounts"},
{Key: "p", Value: "password", Label: "Change password"},
{Key: "x", Value: "delete", Label: "Delete account"},
{Key: "b", Value: "back", Label: "Back"},
},
)
if err != nil {
if errors.Is(err, ErrCancelled) {
return nil
}
return err
}
switch choice {
case "check":
if err := a.checkShareAccounts(doc); err != nil {
return err
}
case "password":
if err := a.changeSambaPassword(doc); err != nil {
return err
}
case "delete":
if err := a.deleteUnusedAccount(doc); err != nil {
return err
}
case "back":
return nil
}
}
}
func (a *App) checkShareAccounts(doc *Document) error {
references := shareUserReferences(doc)
if len(references) == 0 {
a.showMessage("info", "No accounts are listed in any share.")
return nil
}
a.showPanel("Accounts Used By Current Shares", "", nil)
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.showMessage("success", "All listed share accounts already exist.")
return nil
}
a.showMessage("warn", "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.showPanel("Unused Account Cleanup", "No obvious removable share accounts were found.", []string{
"For safety, system accounts are excluded from this list.",
})
return nil
}
options := make([]menuOption, 0, len(candidates)+1)
for i, entry := range candidates {
options = append(options, menuOption{
Key: fmt.Sprintf("%d", i+1),
Value: entry.Name,
Label: entry.Name,
Description: "shell: " + strings.TrimSpace(entry.Shell),
})
}
options = append(options, menuOption{Key: "b", Value: "", Label: "Back"})
choice, err := a.chooseMenu(
"Delete An Unused Account",
"Only accounts that are not listed in any share are shown here.",
nil,
options,
)
if err != nil {
return err
}
if choice == "" {
return nil
}
a.showPanel("Delete Account", "", []string{
fmt.Sprintf("This will remove the local account %q.", choice),
"This is only safe if nobody needs this account for Samba or anything else.",
})
confirm, err := a.confirm("Delete this account now", false)
if err != nil {
return err
}
if !confirm {
a.showMessage("info", "Delete cancelled.")
return nil
}
if err := a.deleteUser(choice); err != nil {
return err
}
a.showMessage("success", "Deleted account "+choice)
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.showMessage("info", "No accounts are listed in any share.")
return nil
}
options := make([]menuOption, 0, len(references)+1)
for i, ref := range references {
status := "missing"
if a.users.UserExists(ref.User) {
status = "present"
}
options = append(options, menuOption{
Key: fmt.Sprintf("%d", i+1),
Value: ref.User,
Label: ref.User + " [" + status + "]",
Description: "used by: " + strings.Join(ref.Shares, ", "),
})
}
options = append(options, menuOption{Key: "b", Value: "", Label: "Back"})
choice, err := a.chooseMenu(
"Choose An Account",
"Select the local user whose Samba password you want to change.",
nil,
options,
)
if err != nil {
return err
}
if choice == "" {
return nil
}
var ref ShareUserReference
for _, candidate := range references {
if candidate.User == choice {
ref = candidate
break
}
}
if !a.users.UserExists(ref.User) {
a.showPanel("Local User Missing", "", []string{
fmt.Sprintf("The local account %q does not exist yet.", ref.User),
"I can create it first so a Samba password can be set.",
})
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.showPanel("Password Update", "", []string{
fmt.Sprintf("I will open the password setup for %q now.", ref.User),
"You will be asked to type the new Samba password.",
})
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(defaults CIFSMountConfig) (CIFSMountConfig, error) {
server, err := a.promptDefault("Server name or IP", defaults.Server)
if err != nil {
return CIFSMountConfig{}, err
}
share, err := a.promptDefault("Share name", defaults.Share)
if err != nil {
return CIFSMountConfig{}, err
}
mountPointDefault := filepath.Join("/mnt", strings.TrimSpace(share))
if defaults.MountPoint != "" {
mountPointDefault = defaults.MountPoint
}
mountPoint, err := a.promptDefault("Local mount folder", mountPointDefault)
if err != nil {
return CIFSMountConfig{}, err
}
username, err := a.promptDefault("Remote username", defaults.Username)
if err != nil {
return CIFSMountConfig{}, err
}
password, err := a.promptDefault("Remote password", defaults.Password)
if err != nil {
return CIFSMountConfig{}, err
}
domain, err := a.promptDefault("Domain or workgroup", defaults.Domain)
if err != nil {
return CIFSMountConfig{}, err
}
uid, err := a.promptDefault("Local owner username or uid", defaults.UID)
if err != nil {
return CIFSMountConfig{}, err
}
gid, err := a.promptDefault("Local group name or gid", defaults.GID)
if err != nil {
return CIFSMountConfig{}, err
}
fileModeDefault := defaults.FileMode
if fileModeDefault == "" {
fileModeDefault = "0664"
}
fileMode, err := a.promptDefault("File permissions", fileModeDefault)
if err != nil {
return CIFSMountConfig{}, err
}
dirModeDefault := defaults.DirMode
if dirModeDefault == "" {
dirModeDefault = "0775"
}
dirMode, err := a.promptDefault("Folder permissions", dirModeDefault)
if err != nil {
return CIFSMountConfig{}, err
}
autoMountDefault := true
if defaults.Server != "" || defaults.Share != "" || defaults.MountPoint != "" {
autoMountDefault = defaults.AutoMount
}
autoMount, err := a.confirm("Mount automatically at startup", autoMountDefault)
if err != nil {
return CIFSMountConfig{}, err
}
readOnly, err := a.confirm("Make this mount read-only", defaults.ReadOnly)
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 defaultCIFSMountConfig() CIFSMountConfig {
uid, gid := defaultLocalMountOwnerGroup(os.Getenv, user.Current, user.Lookup, user.LookupGroupId)
return CIFSMountConfig{
UID: uid,
GID: gid,
}
}
func defaultLocalMountOwnerGroup(
getenv func(string) string,
currentUser func() (*user.User, error),
lookupUser func(string) (*user.User, error),
lookupGroupID func(string) (*user.Group, error),
) (uid string, gid string) {
resolveGroup := func(groupID string) string {
if strings.TrimSpace(groupID) == "" {
return ""
}
group, err := lookupGroupID(groupID)
if err != nil {
return strings.TrimSpace(groupID)
}
return strings.TrimSpace(group.Name)
}
if sudoUser := strings.TrimSpace(getenv("SUDO_USER")); sudoUser != "" {
uid = sudoUser
if sudoGID := strings.TrimSpace(getenv("SUDO_GID")); sudoGID != "" {
return uid, resolveGroup(sudoGID)
}
if u, err := lookupUser(sudoUser); err == nil {
return uid, resolveGroup(u.Gid)
}
return uid, ""
}
u, err := currentUser()
if err != nil {
return "", ""
}
uid = strings.TrimSpace(u.Username)
gid = resolveGroup(u.Gid)
return uid, gid
}
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) 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.showMessage("info", "There are no shares to select.")
return nil, nil
}
options := make([]menuOption, 0, len(shares)+1)
for i, share := range shares {
cfg := ShareFromSection(share)
options = append(options, menuOption{
Key: fmt.Sprintf("%d", i+1),
Value: share.Name,
Label: cfg.Name,
Description: cfg.Path,
})
}
options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return to the previous menu."})
raw, err := a.chooseMenu("Choose A Share", "Select the share you want to work with.", nil, options)
if err != nil {
return nil, err
}
if raw == "" {
return nil, nil
}
return doc.Section(raw), 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.flush()
return runTextPrompt(a.input, a.output, a.theme, "Enter A Value", "Field-by-field configuration", label, "")
}
func (a *App) promptDefault(label, defaultValue string) (string, error) {
a.flush()
return runTextPrompt(a.input, a.output, a.theme, "Enter A Value", "Field-by-field configuration", label, defaultValue)
}
func (a *App) confirm(label string, defaultYes bool) (bool, error) {
options := []menuOption{
{Key: "y", Value: "yes", Label: "Yes"},
{Key: "n", Value: "no", Label: "No"},
}
if !defaultYes {
options[0], options[1] = options[1], options[0]
}
answer, err := a.chooseMenu("Confirm Action", label, nil, options)
if err != nil {
return false, err
}
switch answer {
case "yes":
return true, nil
case "no":
return false, nil
default:
return defaultYes, nil
}
}
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 (a *App) chooseMenu(title, subtitle string, intro []string, options []menuOption) (string, error) {
a.flush()
choice, err := runMenuPrompt(a.input, a.output, a.theme, title, subtitle, intro, options)
a.flush()
return choice, err
}
func (a *App) showPanel(title, subtitle string, lines []string) {
body := a.theme.renderSection(84, "", lines)
fmt.Fprintln(a.writer, a.theme.renderFrame(84, title, subtitle, body, ""))
a.flush()
}
func (a *App) showMessage(kind, message string) {
fmt.Fprintln(a.writer, a.theme.renderFrame(84, "Status", "", a.theme.renderMessage(84, kind, message), ""))
a.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, "'", `'\''`) + "'"
}
func (a *App) loadCIFSMountEntries() ([]FstabMountEntry, error) {
data, err := os.ReadFile(a.fstabPath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", a.fstabPath, err)
}
return parseCIFSMountEntries(string(data)), nil
}
func parseCIFSMountEntries(contents string) []FstabMountEntry {
lines := strings.Split(contents, "\n")
entries := make([]FstabMountEntry, 0)
for index, line := range lines {
entry, ok := parseFstabMountLine(line, index)
if ok {
entries = append(entries, entry)
}
}
return entries
}
func parseFstabMountLine(line string, index int) (FstabMountEntry, bool) {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
return FstabMountEntry{}, false
}
fields := strings.Fields(trimmed)
if len(fields) < 4 {
return FstabMountEntry{}, false
}
fsType := strings.ToLower(fields[2])
if fsType != "cifs" && fsType != "smbfs" {
return FstabMountEntry{}, false
}
entry := FstabMountEntry{
LineIndex: index,
Source: fields[0],
MountPoint: unescapeFstabValue(fields[1]),
FSType: fields[2],
Options: splitFstabOptions(fields[3]),
RawLine: line,
DisplayName: fmt.Sprintf(
"%s (%s)",
unescapeFstabValue(fields[0]),
unescapeFstabValue(fields[2]),
),
}
if len(fields) > 4 {
entry.Dump = fields[4]
}
if len(fields) > 5 {
entry.Pass = fields[5]
}
if entry.Dump == "" {
entry.Dump = "0"
}
if entry.Pass == "" {
entry.Pass = "0"
}
if entry.DisplayName == " (cifs)" || entry.DisplayName == " (smbfs)" {
entry.DisplayName = entry.MountPoint
}
return entry, true
}
func splitFstabOptions(value string) []string {
if value == "" {
return nil
}
var options []string
var current strings.Builder
escaped := false
for _, r := range value {
switch {
case escaped:
current.WriteRune(r)
escaped = false
case r == '\\':
current.WriteRune(r)
escaped = true
case r == ',':
options = append(options, current.String())
current.Reset()
default:
current.WriteRune(r)
}
}
options = append(options, current.String())
return options
}
func unescapeFstabValue(value string) string {
value = strings.ReplaceAll(value, `\040`, " ")
value = strings.ReplaceAll(value, `\,`, ",")
value = strings.ReplaceAll(value, `\\`, `\`)
return value
}
func (a *App) addClientMount() error {
if err := a.ensureCIFSUtilsInstalled(); err != nil {
return err
}
cfg, err := a.collectCIFSMountConfig(defaultCIFSMountConfig())
if err != nil {
return err
}
if err := a.ensureMountPoint(cfg.MountPoint); err != nil {
return err
}
line := buildFstabLine(cfg)
if err := a.confirmAndWriteClientMount(line, -1, "Add this line to /etc/fstab now"); err != nil {
return err
}
a.println("")
a.println("The mount entry was added to /etc/fstab.")
return a.offerMountNow(cfg)
}
func (a *App) editClientMount(entries []FstabMountEntry) error {
entry, err := a.selectCIFSMount(entries)
if err != nil || entry == nil {
return err
}
cfg, ok := cifsMountConfigFromEntry(*entry)
if !ok {
return fmt.Errorf("could not read mount details from %s", entry.RawLine)
}
if err := a.ensureCIFSUtilsInstalled(); err != nil {
return err
}
updated, err := a.collectCIFSMountConfig(cfg)
if err != nil {
return err
}
if err := a.ensureMountPoint(updated.MountPoint); err != nil {
return err
}
line := buildFstabLine(updated)
if err := a.confirmAndWriteClientMount(line, entry.LineIndex, "Save these changes to /etc/fstab now"); err != nil {
return err
}
a.println("")
a.println("The mount entry was updated in /etc/fstab.")
return a.offerMountNow(updated)
}
func (a *App) deleteClientMount(entries []FstabMountEntry) error {
entry, err := a.selectCIFSMount(entries)
if err != nil || entry == nil {
return err
}
confirm, err := a.confirm(fmt.Sprintf("Delete the mount for %s", entry.DisplayName), false)
if err != nil {
return err
}
if !confirm {
a.println("Delete cancelled.")
return nil
}
if err := a.writeFstabLineChange("", entry.LineIndex); err != nil {
return err
}
a.println("The mount entry was removed from /etc/fstab.")
return a.reloadSystemdIfNeeded()
}
func (a *App) selectCIFSMount(entries []FstabMountEntry) (*FstabMountEntry, error) {
if len(entries) == 0 {
a.showMessage("info", "There are no client mounts to select.")
return nil, nil
}
options := make([]menuOption, 0, len(entries)+1)
for i, entry := range entries {
options = append(options, menuOption{
Key: fmt.Sprintf("%d", i+1),
Value: strconv.Itoa(i),
Label: entry.DisplayName,
Description: entry.MountPoint,
})
}
options = append(options, menuOption{Key: "b", Value: "", Label: "Back"})
selection, err := a.chooseMenu("Choose A Client Mount", "Select the saved mount entry you want to change.", nil, options)
if err != nil {
return nil, err
}
if selection == "" {
return nil, nil
}
index, err := strconv.Atoi(selection)
if err != nil || index < 0 || index >= len(entries) {
return nil, nil
}
entry := entries[index]
return &entry, nil
}
func cifsMountConfigFromEntry(entry FstabMountEntry) (CIFSMountConfig, bool) {
server, share, ok := splitCIFSSource(entry.Source)
if !ok {
return CIFSMountConfig{}, false
}
cfg := CIFSMountConfig{
Server: server,
Share: share,
MountPoint: entry.MountPoint,
AutoMount: true,
}
for _, option := range entry.Options {
key, value, hasValue := strings.Cut(option, "=")
switch strings.ToLower(key) {
case "username":
if hasValue {
cfg.Username = unescapeFstabValue(value)
}
case "password":
if hasValue {
cfg.Password = unescapeFstabValue(value)
}
case "domain":
if hasValue {
cfg.Domain = unescapeFstabValue(value)
}
case "uid":
if hasValue {
cfg.UID = unescapeFstabValue(value)
}
case "gid":
if hasValue {
cfg.GID = unescapeFstabValue(value)
}
case "file_mode":
if hasValue {
cfg.FileMode = value
}
case "dir_mode":
if hasValue {
cfg.DirMode = value
}
case "ro":
cfg.ReadOnly = true
case "rw":
cfg.ReadOnly = false
case "noauto":
cfg.AutoMount = false
}
}
return cfg, true
}
func splitCIFSSource(source string) (server, share string, ok bool) {
unescaped := unescapeFstabValue(source)
if !strings.HasPrefix(unescaped, "//") {
return "", "", false
}
parts := strings.SplitN(strings.TrimPrefix(unescaped, "//"), "/", 2)
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return "", "", false
}
return parts[0], parts[1], true
}
func (a *App) confirmAndWriteClientMount(line string, lineIndex int, promptLabel string) error {
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()
confirm, err := a.confirm(promptLabel, true)
if err != nil {
return err
}
if !confirm {
return nil
}
if err := a.writeFstabLineChange(line, lineIndex); err != nil {
return err
}
return a.reloadSystemdIfNeeded()
}
func (a *App) offerMountNow(cfg CIFSMountConfig) error {
mountNow, err := a.confirm("Try mounting it now", true)
if err != nil {
return err
}
if !mountNow {
return nil
}
if err := a.mountCIFS(cfg.MountPoint); err != nil {
return err
}
a.println("The network share was mounted.")
return nil
}
func (a *App) writeFstabLineChange(line string, lineIndex int) error {
data, err := os.ReadFile(a.fstabPath)
if err != nil {
return fmt.Errorf("read %s: %w", a.fstabPath, err)
}
updated, err := updateFstabContents(string(data), line, lineIndex)
if err != nil {
return err
}
return a.writeFstabContents(updated)
}
func updateFstabContents(contents, line string, lineIndex int) (string, error) {
hasTrailingNewline := strings.HasSuffix(contents, "\n")
lines := strings.Split(contents, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
switch {
case lineIndex < 0:
lines = append(lines, line)
case lineIndex >= len(lines):
return "", fmt.Errorf("mount entry line %d is out of range", lineIndex)
case line == "":
lines = append(lines[:lineIndex], lines[lineIndex+1:]...)
default:
lines[lineIndex] = line
}
updated := strings.Join(lines, "\n")
if hasTrailingNewline || updated != "" {
updated += "\n"
}
return updated, nil
}
func (a *App) writeFstabContents(serialized string) error {
backup := fmt.Sprintf("%s.bak.%s", a.fstabPath, time.Now().UTC().Format("20060102T150405Z"))
if err := copyFile(a.fstabPath, backup); err != nil {
if shouldOfferPrivilegeRetry(err) {
return a.writeFstabWithPrivilegeRetry(serialized, backup)
}
return friendlyWriteError("create backup", backup, err)
}
if err := os.WriteFile(a.fstabPath, []byte(serialized), 0o644); err != nil {
if shouldOfferPrivilegeRetry(err) {
return a.writeFstabWithPrivilegeRetry(serialized, backup)
}
return friendlyWriteError("write config", a.fstabPath, err)
}
return nil
}
func (a *App) writeFstabWithPrivilegeRetry(serialized, backup string) error {
a.println("")
a.println("Updating /etc/fstab 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("write config", a.fstabPath, os.ErrPermission)
}
retry, err := a.confirm("Retry updating /etc/fstab with sudo", true)
if err != nil {
return err
}
if !retry {
return friendlyWriteError("write config", a.fstabPath, os.ErrPermission)
}
tempPath, err := a.writeTempConfig(serialized)
if err != nil {
return fmt.Errorf("prepare fstab for sudo save: %w", err)
}
defer os.Remove(tempPath)
if err := a.runner.Run("sudo", "cp", a.fstabPath, backup); err != nil {
return friendlyWriteError("create backup", backup, err)
}
if err := a.runner.Run("sudo", "install", "-m", "644", tempPath, a.fstabPath); err != nil {
return friendlyWriteError("write config", a.fstabPath, err)
}
return nil
}
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")
}