1961 lines
50 KiB
Go
1961 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: "Set up or edit shares on this computer", Description: "Create, edit, and save Samba share definitions."},
|
|
{Key: "c", Value: "client", Label: "Connect this computer to a remote share", Description: "Manage CIFS client mounts and mount points."},
|
|
{Key: "q", Value: "quit", Label: "Quit", Description: "Exit without changing anything."},
|
|
},
|
|
)
|
|
}
|
|
|
|
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", Description: "Create a new share definition."},
|
|
{Key: "e", Value: "edit", Label: "Edit share", Description: "Update an existing share."},
|
|
{Key: "d", Value: "delete", Label: "Delete share", Description: "Remove a share definition."},
|
|
{Key: "m", Value: "mount", Label: "Set up client mount", Description: "Jump to the remote-mount workflow."},
|
|
{Key: "u", Value: "users", Label: "Manage users", Description: "Check accounts, passwords, and cleanup."},
|
|
{Key: "w", Value: "write", Label: "Write config and exit", Description: "Save smb.conf and leave the app."},
|
|
{Key: "q", Value: "quit", Label: "Quit without saving", Description: "Leave the editor immediately."},
|
|
},
|
|
)
|
|
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 client mount", Description: "Create a new CIFS mount entry."},
|
|
{Key: "e", Value: "edit", Label: "Edit client mount", Description: "Change an existing mount definition."},
|
|
{Key: "d", Value: "delete", Label: "Delete client mount", Description: "Remove a saved mount entry."},
|
|
{Key: "b", Value: "back", Label: "Back", Description: "Return to the previous menu."},
|
|
},
|
|
)
|
|
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 share accounts", Description: "Create missing local users referenced by shares."},
|
|
{Key: "p", Value: "password", Label: "Change a Samba password", Description: "Set or update a Samba credential."},
|
|
{Key: "x", Value: "delete", Label: "Delete an unused account", Description: "Remove unused share-style accounts."},
|
|
{Key: "b", Value: "back", Label: "Back", Description: "Return to the share editor."},
|
|
},
|
|
)
|
|
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", Description: "Leave accounts unchanged."})
|
|
|
|
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", Description: "Return without changing a password."})
|
|
|
|
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 on that server", 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("Username for the remote share", defaults.Username)
|
|
if err != nil {
|
|
return CIFSMountConfig{}, err
|
|
}
|
|
password, err := a.promptDefault("Password for the remote share", defaults.Password)
|
|
if err != nil {
|
|
return CIFSMountConfig{}, err
|
|
}
|
|
domain, err := a.promptDefault("Domain or workgroup (optional)", defaults.Domain)
|
|
if err != nil {
|
|
return CIFSMountConfig{}, err
|
|
}
|
|
uid, err := a.promptDefault("Local owner username or uid (optional)", defaults.UID)
|
|
if err != nil {
|
|
return CIFSMountConfig{}, err
|
|
}
|
|
gid, err := a.promptDefault("Local group name or gid (optional)", 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 (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", Description: "Continue with this action."},
|
|
{Key: "n", Value: "no", Label: "No", Description: "Leave things as they are."},
|
|
}
|
|
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(CIFSMountConfig{})
|
|
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", Description: "Return to the previous menu."})
|
|
|
|
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")
|
|
}
|