diff --git a/app.go b/app.go index 0012b44..bbd449e 100644 --- a/app.go +++ b/app.go @@ -45,6 +45,7 @@ func (OSCommandRunner) Run(name string, args ...string) error { type App struct { configPath string + fstabPath string users UserManager runner CommandRunner lookPath LookPathFunc @@ -75,9 +76,22 @@ type CIFSMountConfig struct { 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, @@ -206,63 +220,55 @@ 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.reloadSystemdIfNeeded(); err != nil { + for { + entries, err := a.loadCIFSMountEntries() + if err != nil { return err } - if err := a.mountCIFS(cfg.MountPoint); err != nil { + + a.println("") + a.println("Client mount setup") + a.println("==================") + a.println("This computer can connect to Samba or CIFS shares listed in /etc/fstab.") + a.println("Current client mounts:") + if len(entries) == 0 { + a.println(" (none)") + } else { + for i, entry := range entries { + a.printf(" %d. %s -> %s\n", i+1, entry.DisplayName, entry.MountPoint) + } + } + a.println("") + a.println("[a] add client mount") + a.println("[e] edit client mount") + a.println("[d] delete client mount") + a.println("[b] back") + a.flush() + + choice, err := a.prompt("Select an action") + if err != nil { return err } - a.println("The network share was mounted.") - } - return nil + switch strings.ToLower(choice) { + case "a", "add": + if err := a.addClientMount(); err != nil { + return err + } + case "e", "edit": + if err := a.editClientMount(entries); err != nil { + return err + } + case "d", "delete": + if err := a.deleteClientMount(entries); err != nil { + return err + } + case "b", "back": + return nil + default: + a.println("Unknown choice.") + } + } } func (a *App) manageUsers(doc *Document) error { @@ -638,53 +644,68 @@ func (a *App) ensureCIFSUtilsInstalled() error { return nil } -func (a *App) collectCIFSMountConfig() (CIFSMountConfig, error) { - server, err := a.prompt("Server name or IP") +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.prompt("Share name on that server") + 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.prompt("Username for the remote share") + username, err := a.promptDefault("Username for the remote share", defaults.Username) if err != nil { return CIFSMountConfig{}, err } - password, err := a.prompt("Password for the remote share") + 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)", "") + 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)", "") + 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)", "") + gid, err := a.promptDefault("Local group name or gid (optional)", defaults.GID) if err != nil { return CIFSMountConfig{}, err } - fileMode, err := a.promptDefault("File permissions", "0664") + fileModeDefault := defaults.FileMode + if fileModeDefault == "" { + fileModeDefault = "0664" + } + fileMode, err := a.promptDefault("File permissions", fileModeDefault) if err != nil { return CIFSMountConfig{}, err } - dirMode, err := a.promptDefault("Folder permissions", "0775") + dirModeDefault := defaults.DirMode + if dirModeDefault == "" { + dirModeDefault = "0775" + } + dirMode, err := a.promptDefault("Folder permissions", dirModeDefault) if err != nil { return CIFSMountConfig{}, err } - autoMount, err := a.confirm("Mount automatically at startup", true) + 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", false) + readOnly, err := a.confirm("Make this mount read-only", defaults.ReadOnly) if err != nil { return CIFSMountConfig{}, err } @@ -736,45 +757,6 @@ func (a *App) ensureMountPoint(path string) error { 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) @@ -1432,6 +1414,414 @@ 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.println("There are no client mounts to select.") + return nil, nil + } + + selection, err := a.prompt("Select mount number") + if err != nil { + return nil, err + } + + index, err := strconv.Atoi(selection) + if err != nil || index < 1 || index > len(entries) { + a.println("Invalid selection.") + return nil, nil + } + + entry := entries[index-1] + 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 diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..5ab3f9f --- /dev/null +++ b/app_test.go @@ -0,0 +1,88 @@ +package main + +import "testing" + +func TestParseCIFSMountEntries(t *testing.T) { + contents := `# comment +UUID=1234 / ext4 defaults 0 1 +//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 +//nas/backups /srv/backups smbfs username=bob,password=secret,noauto,ro 0 0 +` + + entries := parseCIFSMountEntries(contents) + if len(entries) != 2 { + t.Fatalf("expected 2 CIFS entries, got %d", len(entries)) + } + + if entries[0].LineIndex != 2 { + t.Fatalf("unexpected first line index: %d", entries[0].LineIndex) + } + if entries[0].Source != "//fileserver/media" { + t.Fatalf("unexpected first source: %q", entries[0].Source) + } + if entries[0].MountPoint != "/mnt/media" { + t.Fatalf("unexpected first mount point: %q", entries[0].MountPoint) + } + if entries[1].FSType != "smbfs" { + t.Fatalf("unexpected second fs type: %q", entries[1].FSType) + } +} + +func TestCIFSMountConfigFromEntry(t *testing.T) { + entry, ok := parseFstabMountLine("//fileserver/media /mnt/media cifs username=alice,password=p@\\040ss\\,word,domain=WORKGROUP,uid=1000,gid=1000,file_mode=0664,dir_mode=0775,noauto,ro 0 0", 0) + if !ok { + t.Fatal("expected line to parse") + } + + cfg, ok := cifsMountConfigFromEntry(entry) + if !ok { + t.Fatal("expected config conversion to succeed") + } + + if cfg.Server != "fileserver" || cfg.Share != "media" { + t.Fatalf("unexpected source parsing: %+v", cfg) + } + if cfg.Password != "p@ ss,word" { + t.Fatalf("unexpected password: %q", cfg.Password) + } + if cfg.Domain != "WORKGROUP" { + t.Fatalf("unexpected domain: %q", cfg.Domain) + } + if cfg.AutoMount { + t.Fatal("expected noauto to disable automount") + } + if !cfg.ReadOnly { + t.Fatal("expected ro to set read-only") + } +} + +func TestUpdateFstabContentsAddEditDelete(t *testing.T) { + initial := "# header\nUUID=1234 / ext4 defaults 0 1\n//old/share /mnt/share cifs username=old,password=old,rw 0 0\n" + + added, err := updateFstabContents(initial, "//new/share /mnt/new cifs username=new,password=new,rw 0 0", -1) + if err != nil { + t.Fatalf("add entry: %v", err) + } + wantAdded := "# header\nUUID=1234 / ext4 defaults 0 1\n//old/share /mnt/share cifs username=old,password=old,rw 0 0\n//new/share /mnt/new cifs username=new,password=new,rw 0 0\n" + if added != wantAdded { + t.Fatalf("unexpected add result:\nwant: %q\ngot: %q", wantAdded, added) + } + + edited, err := updateFstabContents(initial, "//old/share /mnt/share cifs username=alice,password=secret,ro 0 0", 2) + if err != nil { + t.Fatalf("edit entry: %v", err) + } + wantEdited := "# header\nUUID=1234 / ext4 defaults 0 1\n//old/share /mnt/share cifs username=alice,password=secret,ro 0 0\n" + if edited != wantEdited { + t.Fatalf("unexpected edit result:\nwant: %q\ngot: %q", wantEdited, edited) + } + + deleted, err := updateFstabContents(initial, "", 2) + if err != nil { + t.Fatalf("delete entry: %v", err) + } + wantDeleted := "# header\nUUID=1234 / ext4 defaults 0 1\n" + if deleted != wantDeleted { + t.Fatalf("unexpected delete result:\nwant: %q\ngot: %q", wantDeleted, deleted) + } +} diff --git a/samba-configer b/samba-configer index 3b171e5..8dfec29 100755 Binary files a/samba-configer and b/samba-configer differ