feat: add --remote-dir flag and use ssh alias as mount label
Update PKGBUILD version / update-pkgver (push) Successful in 3s
Update PKGBUILD version / update-pkgver (push) Successful in 3s
- 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.
This commit is contained in:
+46
-1
@@ -1,6 +1,51 @@
|
|||||||
# Decisions
|
# 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/<ip>/` 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/<host>` 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
|
- **Who**: Dom, ClaudeCode
|
||||||
- **Why**: Audit found `kernel_cache` causes stale reads on a network FS;
|
- **Why**: Audit found `kernel_cache` causes stale reads on a network FS;
|
||||||
`StrictHostKeyChecking` was implicit (depended on system default);
|
`StrictHostKeyChecking` was implicit (depended on system default);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
Quickly mount remote systems via SSHFS based on your ssh_config
|
Quickly mount remote systems via SSHFS based on your ssh_config
|
||||||
|
|
||||||
Static mount dir is currently `~/Servers/<Host>`
|
Static mount dir is currently `~/Servers/<Host>` (uses the ssh_config alias as label, not the resolved IP).
|
||||||
|
|
||||||
# Install
|
# Install
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ sshfsc <Host>
|
|||||||
| ---- | ----------- |
|
| ---- | ----------- |
|
||||||
| `-e` | open mountpoint in your editor |
|
| `-e` | open mountpoint in your editor |
|
||||||
| `-v` | verbose: print resolved ssh_config fields (HostName, User, Port, IdentityFile) |
|
| `-v` | verbose: print resolved ssh_config fields (HostName, User, Port, IdentityFile) |
|
||||||
|
| `-r`, `--remote-dir <path>` | remote directory to mount (default: remote home) |
|
||||||
|
|
||||||
By default only the resolved mount path is printed. Use `-v` for the full
|
By default only the resolved mount path is printed. Use `-v` for the full
|
||||||
ssh_config dump.
|
ssh_config dump.
|
||||||
|
|||||||
@@ -20,13 +20,20 @@ import (
|
|||||||
var (
|
var (
|
||||||
rxHostUser = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]*$`)
|
rxHostUser = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]*$`)
|
||||||
rxIdentityFile = 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 (
|
var (
|
||||||
eFlag = flag.Bool("e", false, "open mountpoint in your editor")
|
eFlag = flag.Bool("e", false, "open mountpoint in your editor")
|
||||||
vFlag = flag.Bool("v", false, "verbose: print resolved ssh_config fields")
|
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() {
|
func main() {
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
fmt.Fprintf(os.Stderr,
|
fmt.Fprintf(os.Stderr,
|
||||||
@@ -43,6 +50,12 @@ func main() {
|
|||||||
os.Exit(2)
|
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")
|
hostname, err := lookup_ssh_field(args[0], "HostName")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
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)
|
fmt.Fprintf(os.Stderr, "ssh config Port %q is not a valid port number\n", port)
|
||||||
os.Exit(3)
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "verify_mount_dir() failed:", err)
|
fmt.Fprintln(os.Stderr, "verify_mount_dir() failed:", err)
|
||||||
os.Exit(4)
|
os.Exit(4)
|
||||||
@@ -109,6 +128,9 @@ func main() {
|
|||||||
fmt.Println("Port: ", port)
|
fmt.Println("Port: ", port)
|
||||||
fmt.Println("Ifile: ", ifile)
|
fmt.Println("Ifile: ", ifile)
|
||||||
fmt.Println("Mount: ", mount)
|
fmt.Println("Mount: ", mount)
|
||||||
|
if rDir != "" {
|
||||||
|
fmt.Println("Remote: ", rDir)
|
||||||
|
}
|
||||||
fmt.Println("---")
|
fmt.Println("---")
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Mount: ", mount)
|
fmt.Println("Mount: ", mount)
|
||||||
@@ -120,7 +142,7 @@ func main() {
|
|||||||
os.Exit(5)
|
os.Exit(5)
|
||||||
}
|
}
|
||||||
if !chkmount {
|
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)
|
fmt.Fprintln(os.Stderr, "mount_sshfs() failed:", err)
|
||||||
os.Exit(6)
|
os.Exit(6)
|
||||||
}
|
}
|
||||||
@@ -160,7 +182,7 @@ func run_editor(mount string) error {
|
|||||||
return cmd.Start()
|
return cmd.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func verify_mount_dir(hostname string) (string, error) {
|
func verify_mount_dir(name string) (string, error) {
|
||||||
homedir, err := os.UserHomeDir()
|
homedir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("resolve home dir: %w", err)
|
return "", fmt.Errorf("resolve home dir: %w", err)
|
||||||
@@ -173,9 +195,9 @@ func verify_mount_dir(hostname string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("resolve base %q: %w", base, err)
|
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)) {
|
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 {
|
if info, err := os.Lstat(mount); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||||
return "", fmt.Errorf("mount path %q is a symlink", mount)
|
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
|
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,
|
cmd := exec.Command("sshfs", "-p", port,
|
||||||
"-o", "IdentityFile="+ifile,
|
"-o", "IdentityFile="+ifile,
|
||||||
"-o", "idmap=user",
|
"-o", "idmap=user",
|
||||||
@@ -201,7 +223,7 @@ func mount_sshfs(hostname string, user string, ifile string, port string, mount
|
|||||||
"-o", "StrictHostKeyChecking=accept-new",
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "ServerAliveInterval=15",
|
"-o", "ServerAliveInterval=15",
|
||||||
"-o", "ServerAliveCountMax=3",
|
"-o", "ServerAliveCountMax=3",
|
||||||
user+"@"+hostname+":", mount)
|
user+"@"+alias+":"+remoteDir, mount)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
|
|||||||
+13
-1
@@ -92,12 +92,24 @@ func TestValidateSSHFieldRegexes(t *testing.T) {
|
|||||||
{"identityfile leading dot rejected", ".ssh/id", "ifile", false},
|
{"identityfile leading dot rejected", ".ssh/id", "ifile", false},
|
||||||
{"identityfile leading colon rejected", ":weird", "ifile", false},
|
{"identityfile leading colon rejected", ":weird", "ifile", false},
|
||||||
{"identityfile empty rejected", "", "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 {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
var rx = rxHostUser
|
var rx = rxHostUser
|
||||||
if tc.rx == "ifile" {
|
switch tc.rx {
|
||||||
|
case "ifile":
|
||||||
rx = rxIdentityFile
|
rx = rxIdentityFile
|
||||||
|
case "rdir":
|
||||||
|
rx = rxRemoteDir
|
||||||
}
|
}
|
||||||
err := validate_ssh_field("X", tc.value, rx)
|
err := validate_ssh_field("X", tc.value, rx)
|
||||||
if tc.ok && err != nil {
|
if tc.ok && err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user