client flow

This commit is contained in:
2026-03-19 19:40:45 +00:00
parent 8c00a42663
commit 93527bb725
5 changed files with 377 additions and 0 deletions

View File

@@ -24,6 +24,7 @@ The program:
- Prompts to add, edit, or delete a share. - Prompts to add, edit, or delete a share.
- Includes a user-management menu to verify all share accounts exist and to remove obviously unused share-style accounts. - Includes a user-management menu to verify all share accounts exist and to remove obviously unused share-style accounts.
- Includes a user-management menu to change a Samba user's password. - Includes a user-management menu to change a Samba user's password.
- Includes a client-mount workflow for connecting this computer to a share on another server with `cifs-utils`.
- Checks `valid users` entries against local accounts. - Checks `valid users` entries against local accounts.
- Offers to create missing local accounts with `useradd -M -s /usr/sbin/nologin <user>`. - Offers to create missing local accounts with `useradd -M -s /usr/sbin/nologin <user>`.
- If user creation fails because admin rights are needed, explains the issue and offers to retry with `sudo`. - If user creation fails because admin rights are needed, explains the issue and offers to retry with `sudo`.
@@ -32,6 +33,7 @@ The program:
- Before saving, offers to adjust share-folder ownership and permissions so connected users can actually read and write files. - Before saving, offers to adjust share-folder ownership and permissions so connected users can actually read and write files.
- If saving the Samba config or its backup needs admin rights, explains the issue and offers to retry with `sudo`. - If saving the Samba config or its backup needs admin rights, explains the issue and offers to retry with `sudo`.
- Writes a timestamped backup before saving changes. - Writes a timestamped backup before saving changes.
- Can append a CIFS mount entry to `/etc/fstab`, including an inline username and password if that is the setup you want.
If you create a local account for a Samba-authenticated share, you may still need to add the Samba password separately: If you create a local account for a Samba-authenticated share, you may still need to add the Samba password separately:

310
app.go
View File

@@ -60,6 +60,21 @@ type PasswdEntry struct {
Shell string Shell string
} }
type CIFSMountConfig struct {
Server string
Share string
MountPoint string
Username string
Password string
Domain string
UID string
GID string
FileMode string
DirMode string
AutoMount bool
ReadOnly bool
}
func NewApp(configPath string, users UserManager, runner CommandRunner, lookPath LookPathFunc) *App { func NewApp(configPath string, users UserManager, runner CommandRunner, lookPath LookPathFunc) *App {
return &App{ return &App{
configPath: configPath, configPath: configPath,
@@ -103,6 +118,7 @@ loaded:
a.println("[a] add share") a.println("[a] add share")
a.println("[e] edit share") a.println("[e] edit share")
a.println("[d] delete share") a.println("[d] delete share")
a.println("[m] set up client mount")
a.println("[u] manage users") a.println("[u] manage users")
a.println("[w] write config and exit") a.println("[w] write config and exit")
a.println("[q] quit without saving") a.println("[q] quit without saving")
@@ -126,6 +142,10 @@ loaded:
if err := a.deleteShare(doc); err != nil { if err := a.deleteShare(doc); err != nil {
return err return err
} }
case "m", "mount":
if err := a.setupClientMount(); err != nil {
return err
}
case "u", "users": case "u", "users":
if err := a.manageUsers(doc); err != nil { if err := a.manageUsers(doc); err != nil {
return err return err
@@ -140,6 +160,63 @@ loaded:
} }
} }
func (a *App) setupClientMount() error {
a.println("")
a.println("Client mount setup")
a.println("==================")
a.println("This will help connect this computer to a shared folder on another server.")
a.flush()
if err := a.ensureCIFSUtilsInstalled(); err != nil {
return err
}
cfg, err := a.collectCIFSMountConfig()
if err != nil {
return err
}
if err := a.ensureMountPoint(cfg.MountPoint); err != nil {
return err
}
line := buildFstabLine(cfg)
a.println("")
a.println("Suggested /etc/fstab line:")
a.println(line)
a.println("")
a.println("This will store the username and password directly in /etc/fstab.")
a.println("That is simple for home use, but less secure than a credentials file.")
a.flush()
addLine, err := a.confirm("Add this line to /etc/fstab now", true)
if err != nil {
return err
}
if !addLine {
return nil
}
if err := a.appendFstabLine(line); err != nil {
return err
}
a.println("")
a.println("The mount entry was added to /etc/fstab.")
mountNow, err := a.confirm("Try mounting it now", true)
if err != nil {
return err
}
if mountNow {
if err := a.mountCIFS(cfg.MountPoint); err != nil {
return err
}
a.println("The network share was mounted.")
}
return nil
}
func (a *App) manageUsers(doc *Document) error { func (a *App) manageUsers(doc *Document) error {
for { for {
a.println("") a.println("")
@@ -471,6 +548,192 @@ func (a *App) writeConfig(doc *Document) error {
return nil return nil
} }
func (a *App) ensureCIFSUtilsInstalled() error {
if a.lookPath != nil {
if _, err := a.lookPath("mount.cifs"); err == nil {
return nil
}
}
a.println("")
a.println("This computer does not seem to have the CIFS mount tools installed yet.")
plan, ok := DetectCIFSUtilsInstallPlan(a.lookPath, os.Geteuid() == 0)
if !ok {
a.println("I couldn't find a supported package manager automatically.")
a.println("Install cifs-utils for your Linux distribution, then try again.")
return errors.New("cifs-utils is not installed")
}
a.printf("I found %s and can try to install the CIFS client tools for you.\n", plan.ManagerName)
a.printf("This would run: %s\n", plan.DisplayCommand())
a.flush()
install, err := a.confirm("Install the CIFS client tools now", true)
if err != nil {
return err
}
if !install {
return errors.New("cifs-utils is required for client mounts")
}
if err := a.runner.Run(plan.Command[0], plan.Command[1:]...); err != nil {
return fmt.Errorf("install CIFS client tools with %s: %w", plan.ManagerName, err)
}
if a.lookPath != nil {
if _, err := a.lookPath("mount.cifs"); err != nil {
return fmt.Errorf("cifs-utils installation finished, but mount.cifs is still not available: %w", err)
}
}
return nil
}
func (a *App) collectCIFSMountConfig() (CIFSMountConfig, error) {
server, err := a.prompt("Server name or IP")
if err != nil {
return CIFSMountConfig{}, err
}
share, err := a.prompt("Share name on that server")
if err != nil {
return CIFSMountConfig{}, err
}
mountPointDefault := filepath.Join("/mnt", strings.TrimSpace(share))
mountPoint, err := a.promptDefault("Local mount folder", mountPointDefault)
if err != nil {
return CIFSMountConfig{}, err
}
username, err := a.prompt("Username for the remote share")
if err != nil {
return CIFSMountConfig{}, err
}
password, err := a.prompt("Password for the remote share")
if err != nil {
return CIFSMountConfig{}, err
}
domain, err := a.promptDefault("Domain or workgroup (optional)", "")
if err != nil {
return CIFSMountConfig{}, err
}
uid, err := a.promptDefault("Local owner username or uid (optional)", "")
if err != nil {
return CIFSMountConfig{}, err
}
gid, err := a.promptDefault("Local group name or gid (optional)", "")
if err != nil {
return CIFSMountConfig{}, err
}
fileMode, err := a.promptDefault("File permissions", "0664")
if err != nil {
return CIFSMountConfig{}, err
}
dirMode, err := a.promptDefault("Folder permissions", "0775")
if err != nil {
return CIFSMountConfig{}, err
}
autoMount, err := a.confirm("Mount automatically at startup", true)
if err != nil {
return CIFSMountConfig{}, err
}
readOnly, err := a.confirm("Make this mount read-only", false)
if err != nil {
return CIFSMountConfig{}, err
}
return CIFSMountConfig{
Server: strings.TrimSpace(server),
Share: strings.TrimSpace(share),
MountPoint: filepath.Clean(strings.TrimSpace(mountPoint)),
Username: strings.TrimSpace(username),
Password: strings.TrimSpace(password),
Domain: strings.TrimSpace(domain),
UID: strings.TrimSpace(uid),
GID: strings.TrimSpace(gid),
FileMode: strings.TrimSpace(fileMode),
DirMode: strings.TrimSpace(dirMode),
AutoMount: autoMount,
ReadOnly: readOnly,
}, nil
}
func (a *App) ensureMountPoint(path string) error {
info, err := os.Stat(path)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("mount folder exists but is not a directory: %s", path)
}
return nil
}
if !os.IsNotExist(err) {
return fmt.Errorf("check mount folder %s: %w", path, err)
}
a.println("")
a.printf("The local mount folder %s does not exist yet.\n", path)
a.println("I can create it now.")
a.flush()
create, err := a.confirm("Create this folder now", true)
if err != nil {
return err
}
if !create {
return errors.New("a mount folder is needed before continuing")
}
if err := a.createDirectory(path); err != nil {
return err
}
return nil
}
func (a *App) appendFstabLine(line string) error {
content := line + "\n"
file, err := os.OpenFile("/etc/fstab", os.O_APPEND|os.O_WRONLY, 0)
if err == nil {
defer file.Close()
if _, err := file.WriteString(content); err == nil {
return nil
} else if !shouldOfferPrivilegeRetry(err) {
return fmt.Errorf("append mount entry to /etc/fstab: %w", err)
}
} else if !shouldOfferPrivilegeRetry(err) {
return fmt.Errorf("open /etc/fstab for update: %w", err)
}
a.println("")
a.println("Updating /etc/fstab needs administrator permission.")
a.println("I can try again using sudo so you can enter your admin password.")
a.flush()
retry, err := a.confirm("Retry updating /etc/fstab with sudo", true)
if err != nil {
return err
}
if !retry {
return errors.New("updating /etc/fstab was cancelled")
}
tempPath, err := a.writeTempConfig(content)
if err != nil {
return fmt.Errorf("prepare fstab update: %w", err)
}
defer os.Remove(tempPath)
if err := a.runner.Run("sudo", "sh", "-c", fmt.Sprintf("cat %s >> /etc/fstab", shellQuote(tempPath))); err != nil {
return fmt.Errorf("append mount entry to /etc/fstab with sudo: %w", err)
}
return nil
}
func (a *App) mountCIFS(path string) error {
if err := a.runPrivilegedOrLocal("mount", []string{path}, "Mounting the network share needs administrator permission."); err != nil {
return fmt.Errorf("mount %s: %w", path, err)
}
return nil
}
func (a *App) ensureShareDirectories(doc *Document) error { func (a *App) ensureShareDirectories(doc *Document) error {
for _, section := range doc.ShareSections() { for _, section := range doc.ShareSections() {
cfg := ShareFromSection(section) cfg := ShareFromSection(section)
@@ -1054,6 +1317,53 @@ func friendlyWriteError(action, path string, err error) error {
return fmt.Errorf("%s %s: %w", action, path, err) return fmt.Errorf("%s %s: %w", action, path, err)
} }
func buildFstabLine(cfg CIFSMountConfig) string {
source := fmt.Sprintf("//%s/%s", cfg.Server, cfg.Share)
options := []string{
"username=" + escapeFstabValue(cfg.Username),
"password=" + escapeFstabValue(cfg.Password),
"iocharset=utf8",
}
if cfg.Domain != "" {
options = append(options, "domain="+escapeFstabValue(cfg.Domain))
}
if cfg.UID != "" {
options = append(options, "uid="+escapeFstabValue(cfg.UID))
}
if cfg.GID != "" {
options = append(options, "gid="+escapeFstabValue(cfg.GID))
}
if cfg.FileMode != "" {
options = append(options, "file_mode="+cfg.FileMode)
}
if cfg.DirMode != "" {
options = append(options, "dir_mode="+cfg.DirMode)
}
if cfg.ReadOnly {
options = append(options, "ro")
} else {
options = append(options, "rw")
}
if !cfg.AutoMount {
options = append(options, "noauto")
}
return fmt.Sprintf("%s %s cifs %s 0 0", source, cfg.MountPoint, strings.Join(options, ","))
}
func escapeFstabValue(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, ",", `\,`)
value = strings.ReplaceAll(value, " ", `\040`)
return value
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}
type ShareUserReference struct { type ShareUserReference struct {
User string User string
Shares []string Shares []string

View File

@@ -61,3 +61,47 @@ func DetectSambaInstallPlan(lookPath LookPathFunc, isRoot bool) (InstallPlan, bo
return InstallPlan{}, false return InstallPlan{}, false
} }
func DetectCIFSUtilsInstallPlan(lookPath LookPathFunc, isRoot bool) (InstallPlan, bool) {
sudoPrefix := []string{}
if !isRoot {
if _, err := lookPath("sudo"); err == nil {
sudoPrefix = []string{"sudo"}
}
}
type managerPlan struct {
bin string
name string
command []string
}
plans := []managerPlan{
{bin: "dnf", name: "dnf", command: []string{"dnf", "install", "-y", "cifs-utils"}},
{bin: "yum", name: "yum", command: []string{"yum", "install", "-y", "cifs-utils"}},
{bin: "pacman", name: "pacman", command: []string{"pacman", "-Sy", "--noconfirm", "cifs-utils"}},
{bin: "zypper", name: "zypper", command: []string{"zypper", "--non-interactive", "install", "cifs-utils"}},
{bin: "apk", name: "apk", command: []string{"apk", "add", "cifs-utils"}},
}
if _, err := lookPath("apt-get"); err == nil {
return InstallPlan{
ManagerName: "apt",
Command: append(
append([]string{}, sudoPrefix...),
"sh", "-c", "apt-get update && apt-get install -y cifs-utils",
),
}, true
}
for _, plan := range plans {
if _, err := lookPath(plan.bin); err == nil {
return InstallPlan{
ManagerName: plan.name,
Command: append(append([]string{}, sudoPrefix...), plan.command...),
}, true
}
}
return InstallPlan{}, false
}

View File

@@ -185,6 +185,27 @@ func TestUnusedAccountCandidates(t *testing.T) {
} }
} }
func TestBuildFstabLine(t *testing.T) {
line := buildFstabLine(CIFSMountConfig{
Server: "fileserver",
Share: "media",
MountPoint: "/mnt/media",
Username: "alice",
Password: "p@ ss,word",
UID: "1000",
GID: "1000",
FileMode: "0664",
DirMode: "0775",
AutoMount: true,
ReadOnly: false,
})
want := "//fileserver/media /mnt/media cifs username=alice,password=p@\\040ss\\,word,iocharset=utf8,uid=1000,gid=1000,file_mode=0664,dir_mode=0775,rw 0 0"
if line != want {
t.Fatalf("unexpected fstab line:\nwant: %s\ngot: %s", want, line)
}
}
type execErr string type execErr string
func (e execErr) Error() string { func (e execErr) Error() string {

Binary file not shown.