From afb51f1d61926acc38582fecec5efe457c82025e Mon Sep 17 00:00:00 2001 From: nevaforget Date: Tue, 28 Apr 2026 15:39:17 +0200 Subject: [PATCH] feat: add --remote-dir flag and use ssh alias as mount label - New `-r` / `--remote-dir` flag to mount a specific remote subdirectory; empty default preserves prior home-dir behaviour. - Validate the flag value via a dedicated `rxRemoteDir` allowlist before it reaches the sshfs argv. - Use the ssh_config alias (not the resolved HostName) as the local mountpoint name and as the sshfs source. File managers now show the human-readable label instead of the raw IP. - Validate `args[0]` against `rxHostUser` since it now flows into argv. - Rename `verify_mount_dir` parameter `hostname -> name` and `mount_sshfs` first parameter `hostname -> alias` for clarity. --- DECISIONS.md | 47 ++++++++++++++++++++++++++++++++++++++++++++++- README.md | 3 ++- main.go | 36 +++++++++++++++++++++++++++++------- main_test.go | 14 +++++++++++++- 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 0043029..9e91687 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,6 +1,51 @@ # Decisions -## 2026-04-26 – Audit remediation: cache flag, host-key policy, argv hardening +## 2026-04-28 – Use ssh_config alias as label for mountpoint and sshfs source +- **Who**: Dom, ClaudeCode +- **Why**: File managers showed the resolved `HostName` (often a raw IP) for + both the mount source (`user@10.0.0.5:`) and the mountpoint directory + (`~/Servers/10.0.0.5/`). The ssh_config alias is the human-readable label; + using it makes mounts identifiable in Nautilus/Dolphin/etc. +- **Tradeoffs**: + - sshfs now resolves the alias internally via `~/.ssh/config` instead of + receiving a pre-resolved IP. Our explicit `-o IdentityFile=` and `-p port` + still win as overrides — the resolved values were redundant but kept as a + belt-and-suspenders sanity check (early failure if config is malformed). + - The CLI argument flows into the sshfs argv, so it must be validated. + Added `validate_ssh_field("alias", args[0], rxHostUser)` immediately after + arg parsing to gate injection-shaped input. + - Existing mounts under `~/Servers//` are now orphaned. User must + manually `fusermount -u` and `rmdir` the IP-named directories. Documented + in the README pointer. +- **How**: + - `args[0]` captured into `alias`, validated via `rxHostUser`. + - `verify_mount_dir(alias)` instead of `(hostname)`; param renamed to `name` + for clarity since it no longer represents a resolved hostname. + - `mount_sshfs` first arg renamed `hostname → alias`; argv source string + becomes `user+"@"+alias+":"+remoteDir`. + +## 2026-04-28 – Add `-r` / `--remote-dir` flag for custom remote path +- **Who**: Dom, ClaudeCode +- **Why**: Mounting only the remote home was too restrictive; sometimes a + specific subdirectory (e.g. `/var/www`, `~/projects`) is the actual target. +- **Tradeoffs**: + - Two flag aliases (`-r` and `--remote-dir`) registered against the same + target via `flag.StringVar`. Doubles the `flag.PrintDefaults` output but + matches typical short/long convention. + - `rxRemoteDir` deliberately stricter than `rxIdentityFile` — no `:`, `@`, + `+`, `=`, `%` since remote paths rarely need them and looser allowlists + invite injection-shaped surprises in the `user@host:path` argv slot. + - Local mount path stays `~/Servers/` regardless of remote dir — one + host = one mountpoint, even if `-r` differs between invocations. Re-mount + requires `fusermount -u` first. +- **How**: + - `rDir` package-level `string`, registered in `init()` as `r` and + `remote-dir`; validated against `rxRemoteDir = ^[A-Za-z0-9/~][A-Za-z0-9._/~-]*$`. + - `mount_sshfs` signature gains `remoteDir string`; appended to the + `user@host:` argv slot. Empty string preserves the previous home-dir + default. + - `-v` output includes `Remote:` line when `rDir` is set. + - Tests extended with eight `rxRemoteDir` boundary cases. - **Who**: Dom, ClaudeCode - **Why**: Audit found `kernel_cache` causes stale reads on a network FS; `StrictHostKeyChecking` was implicit (depended on system default); diff --git a/README.md b/README.md index d79904d..236d496 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ Quickly mount remote systems via SSHFS based on your ssh_config -Static mount dir is currently `~/Servers/` +Static mount dir is currently `~/Servers/` (uses the ssh_config alias as label, not the resolved IP). # Install @@ -36,6 +36,7 @@ sshfsc | ---- | ----------- | | `-e` | open mountpoint in your editor | | `-v` | verbose: print resolved ssh_config fields (HostName, User, Port, IdentityFile) | +| `-r`, `--remote-dir ` | remote directory to mount (default: remote home) | By default only the resolved mount path is printed. Use `-v` for the full ssh_config dump. diff --git a/main.go b/main.go index 23e1f4f..c1c9149 100644 --- a/main.go +++ b/main.go @@ -20,13 +20,20 @@ import ( var ( rxHostUser = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]*$`) rxIdentityFile = regexp.MustCompile(`^[A-Za-z0-9/~][A-Za-z0-9._@/:+=~%-]*$`) + rxRemoteDir = regexp.MustCompile(`^[A-Za-z0-9/~][A-Za-z0-9._/~-]*$`) ) var ( eFlag = flag.Bool("e", false, "open mountpoint in your editor") vFlag = flag.Bool("v", false, "verbose: print resolved ssh_config fields") + rDir string ) +func init() { + flag.StringVar(&rDir, "r", "", "remote directory to mount (default: remote home)") + flag.StringVar(&rDir, "remote-dir", "", "remote directory to mount (default: remote home)") +} + func main() { flag.Usage = func() { fmt.Fprintf(os.Stderr, @@ -43,6 +50,12 @@ func main() { os.Exit(2) } + alias := args[0] + if err := validate_ssh_field("alias", alias, rxHostUser); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(3) + } + hostname, err := lookup_ssh_field(args[0], "HostName") if err != nil { fmt.Fprintln(os.Stderr, err) @@ -96,8 +109,14 @@ func main() { fmt.Fprintf(os.Stderr, "ssh config Port %q is not a valid port number\n", port) os.Exit(3) } + if rDir != "" { + if err := validate_ssh_field("remote-dir", rDir, rxRemoteDir); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(3) + } + } - mount, err := verify_mount_dir(hostname) + mount, err := verify_mount_dir(alias) if err != nil { fmt.Fprintln(os.Stderr, "verify_mount_dir() failed:", err) os.Exit(4) @@ -109,6 +128,9 @@ func main() { fmt.Println("Port: ", port) fmt.Println("Ifile: ", ifile) fmt.Println("Mount: ", mount) + if rDir != "" { + fmt.Println("Remote: ", rDir) + } fmt.Println("---") } else { fmt.Println("Mount: ", mount) @@ -120,7 +142,7 @@ func main() { os.Exit(5) } if !chkmount { - if err := mount_sshfs(hostname, user, ifile, port, mount); err != nil { + if err := mount_sshfs(alias, user, ifile, port, mount, rDir); err != nil { fmt.Fprintln(os.Stderr, "mount_sshfs() failed:", err) os.Exit(6) } @@ -160,7 +182,7 @@ func run_editor(mount string) error { return cmd.Start() } -func verify_mount_dir(hostname string) (string, error) { +func verify_mount_dir(name string) (string, error) { homedir, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve home dir: %w", err) @@ -173,9 +195,9 @@ func verify_mount_dir(hostname string) (string, error) { if err != nil { return "", fmt.Errorf("resolve base %q: %w", base, err) } - mount := filepath.Clean(filepath.Join(realBase, hostname)) + mount := filepath.Clean(filepath.Join(realBase, name)) if !strings.HasPrefix(mount, realBase+string(os.PathSeparator)) { - return "", fmt.Errorf("hostname %q escapes mount base %q", hostname, realBase) + return "", fmt.Errorf("name %q escapes mount base %q", name, realBase) } if info, err := os.Lstat(mount); err == nil && info.Mode()&os.ModeSymlink != 0 { return "", fmt.Errorf("mount path %q is a symlink", mount) @@ -186,7 +208,7 @@ func verify_mount_dir(hostname string) (string, error) { return mount, nil } -func mount_sshfs(hostname string, user string, ifile string, port string, mount string) error { +func mount_sshfs(alias string, user string, ifile string, port string, mount string, remoteDir string) error { cmd := exec.Command("sshfs", "-p", port, "-o", "IdentityFile="+ifile, "-o", "idmap=user", @@ -201,7 +223,7 @@ func mount_sshfs(hostname string, user string, ifile string, port string, mount "-o", "StrictHostKeyChecking=accept-new", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", - user+"@"+hostname+":", mount) + user+"@"+alias+":"+remoteDir, mount) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() diff --git a/main_test.go b/main_test.go index df0f086..a3a5d53 100644 --- a/main_test.go +++ b/main_test.go @@ -92,12 +92,24 @@ func TestValidateSSHFieldRegexes(t *testing.T) { {"identityfile leading dot rejected", ".ssh/id", "ifile", false}, {"identityfile leading colon rejected", ":weird", "ifile", false}, {"identityfile empty rejected", "", "ifile", false}, + + {"remote-dir absolute", "/var/www", "rdir", true}, + {"remote-dir tilde", "~/work", "rdir", true}, + {"remote-dir relative", "projects/foo", "rdir", true}, + {"remote-dir leading dash rejected", "-rf", "rdir", false}, + {"remote-dir leading dot rejected", "./x", "rdir", false}, + {"remote-dir space rejected", "/var /www", "rdir", false}, + {"remote-dir comma rejected", "/a,b", "rdir", false}, + {"remote-dir empty rejected", "", "rdir", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var rx = rxHostUser - if tc.rx == "ifile" { + switch tc.rx { + case "ifile": rx = rxIdentityFile + case "rdir": + rx = rxRemoteDir } err := validate_ssh_field("X", tc.value, rx) if tc.ok && err != nil {