diff --git a/README.md b/README.md index 66e0f03..c4becb0 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ The program: - 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 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. - Offers to create missing local accounts with `useradd -M -s /usr/sbin/nologin `. - 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. - 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. +- 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: diff --git a/app.go b/app.go index 63b7a39..7686572 100644 --- a/app.go +++ b/app.go @@ -60,6 +60,21 @@ type PasswdEntry struct { Shell string } +type CIFSMountConfig struct { + Server string + Share string + MountPoint string + Username string + Password string + Domain string + UID string + GID string + FileMode string + DirMode string + AutoMount bool + ReadOnly bool +} + func NewApp(configPath string, users UserManager, runner CommandRunner, lookPath LookPathFunc) *App { return &App{ configPath: configPath, @@ -103,6 +118,7 @@ loaded: a.println("[a] add share") a.println("[e] edit share") a.println("[d] delete share") + a.println("[m] set up client mount") a.println("[u] manage users") a.println("[w] write config and exit") a.println("[q] quit without saving") @@ -126,6 +142,10 @@ loaded: if err := a.deleteShare(doc); err != nil { return err } + case "m", "mount": + if err := a.setupClientMount(); err != nil { + return err + } case "u", "users": if err := a.manageUsers(doc); err != nil { return err @@ -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 { for { a.println("") @@ -471,6 +548,192 @@ func (a *App) writeConfig(doc *Document) error { 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 { for _, section := range doc.ShareSections() { cfg := ShareFromSection(section) @@ -1054,6 +1317,53 @@ func friendlyWriteError(action, path string, err error) error { return fmt.Errorf("%s %s: %w", action, path, err) } +func buildFstabLine(cfg CIFSMountConfig) string { + source := fmt.Sprintf("//%s/%s", cfg.Server, cfg.Share) + + options := []string{ + "username=" + escapeFstabValue(cfg.Username), + "password=" + escapeFstabValue(cfg.Password), + "iocharset=utf8", + } + + if cfg.Domain != "" { + options = append(options, "domain="+escapeFstabValue(cfg.Domain)) + } + if cfg.UID != "" { + options = append(options, "uid="+escapeFstabValue(cfg.UID)) + } + if cfg.GID != "" { + options = append(options, "gid="+escapeFstabValue(cfg.GID)) + } + if cfg.FileMode != "" { + options = append(options, "file_mode="+cfg.FileMode) + } + if cfg.DirMode != "" { + options = append(options, "dir_mode="+cfg.DirMode) + } + if cfg.ReadOnly { + options = append(options, "ro") + } else { + options = append(options, "rw") + } + if !cfg.AutoMount { + options = append(options, "noauto") + } + + return fmt.Sprintf("%s %s cifs %s 0 0", source, cfg.MountPoint, strings.Join(options, ",")) +} + +func escapeFstabValue(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, ",", `\,`) + value = strings.ReplaceAll(value, " ", `\040`) + return value +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} + type ShareUserReference struct { User string Shares []string diff --git a/install.go b/install.go index d36917c..8c1523a 100644 --- a/install.go +++ b/install.go @@ -61,3 +61,47 @@ func DetectSambaInstallPlan(lookPath LookPathFunc, isRoot bool) (InstallPlan, bo 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 +} diff --git a/parser_test.go b/parser_test.go index d1ec753..5cfbf42 100644 --- a/parser_test.go +++ b/parser_test.go @@ -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 func (e execErr) Error() string { diff --git a/samba-configer b/samba-configer index 73fef15..df4c50c 100755 Binary files a/samba-configer and b/samba-configer differ