smarter client flow

This commit is contained in:
2026-03-19 20:52:30 +00:00
parent ec517923c2
commit e88e3a4bb0
3 changed files with 581 additions and 103 deletions

596
app.go
View File

@@ -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