Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4306170626 | |||
| e6a02e5bf7 | |||
| 8edddc5a28 | |||
| 3f3c631057 | |||
| afb51f1d61 | |||
| d01a358f35 | |||
| 967d5d74cc | |||
| 64a65031b8 | |||
| 70181d9215 | |||
| ba895624c3 | |||
| af3375b343 |
@@ -13,7 +13,9 @@ jobs:
|
|||||||
runs-on: moonarch
|
runs-on: moonarch
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source repo
|
- name: Checkout source repo
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
git clone --bare http://gitea:3000/nevaforget/sshfs_connect.git source.git
|
git clone --bare http://gitea:3000/nevaforget/sshfs_connect.git source.git
|
||||||
cd source.git
|
cd source.git
|
||||||
PKGVER=$(git describe --long --tags | sed 's/^v//;s/-/.r/;s/-/./')
|
PKGVER=$(git describe --long --tags | sed 's/^v//;s/-/.r/;s/-/./')
|
||||||
@@ -21,8 +23,23 @@ jobs:
|
|||||||
echo "$PKGVER" > /tmp/pkgver
|
echo "$PKGVER" > /tmp/pkgver
|
||||||
|
|
||||||
- name: Update PKGBUILD
|
- name: Update PKGBUILD
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PKGBUILD_TOKEN: ${{ secrets.PKGBUILD_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${PKGBUILD_TOKEN:-}" ]; then
|
||||||
|
echo "ERROR: PKGBUILD_TOKEN secret is empty or unset."
|
||||||
|
echo "Set it under Repo Settings -> Actions -> Secrets."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
PKGVER=$(cat /tmp/pkgver)
|
PKGVER=$(cat /tmp/pkgver)
|
||||||
|
if [ -z "$PKGVER" ]; then
|
||||||
|
echo "ERROR: PKGVER from previous step is empty."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
|
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
|
||||||
cd pkgbuilds
|
cd pkgbuilds
|
||||||
|
|
||||||
@@ -34,10 +51,13 @@ jobs:
|
|||||||
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" sshfsc-git/PKGBUILD
|
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" sshfsc-git/PKGBUILD
|
||||||
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" sshfsc-git/.SRCINFO
|
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" sshfsc-git/.SRCINFO
|
||||||
echo "Updated pkgver: $OLD_VER → $PKGVER"
|
echo "Updated pkgver: $OLD_VER -> $PKGVER"
|
||||||
|
|
||||||
git config user.name "pkgver-bot"
|
git config user.name "pkgver-bot"
|
||||||
git config user.email "gitea@moonarch.de"
|
git config user.email "gitea@moonarch.de"
|
||||||
git add sshfsc-git/PKGBUILD sshfsc-git/.SRCINFO
|
git add sshfsc-git/PKGBUILD sshfsc-git/.SRCINFO
|
||||||
git commit -m "chore(sshfsc-git): bump pkgver to $PKGVER"
|
git commit -m "chore(sshfsc-git): bump pkgver to $PKGVER"
|
||||||
git -c http.extraHeader="Authorization: token ${{ secrets.PKGBUILD_TOKEN }}" push
|
|
||||||
|
echo "--- pushing ---"
|
||||||
|
git -c http.extraHeader="Authorization: token ${PKGBUILD_TOKEN}" push --verbose origin HEAD:main
|
||||||
|
echo "--- push done ---"
|
||||||
|
|||||||
+2
-1
@@ -1 +1,2 @@
|
|||||||
sshfs_connect
|
sshfs_connect
|
||||||
|
sshfsc
|
||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
# Decisions
|
||||||
|
|
||||||
|
## 2026-05-04 – Delegate `-l` output to `findmnt -t fuse.sshfs`
|
||||||
|
- **Who**: Dom, ClaudeCode
|
||||||
|
- **Why**: The custom `-l` output ("just the alias, one per line") was
|
||||||
|
uselessly minimal — no mountpoint, no source, no options. Reinventing a
|
||||||
|
table format for a single command was the wrong call when `findmnt` from
|
||||||
|
`util-linux` already produces a familiar, well-formatted view of fuse.sshfs
|
||||||
|
mounts.
|
||||||
|
- **Tradeoffs**:
|
||||||
|
- External dependency on `findmnt` (part of `util-linux`, present on every
|
||||||
|
Arch and most other Linux systems by default — no real adoption cost).
|
||||||
|
- Output is mountpoint-first, not alias-first. The alias is only visible as
|
||||||
|
the last path segment under the SOURCE column / TARGET basename.
|
||||||
|
- Lists *all* fuse.sshfs mounts on the system, not just sshfsc-managed
|
||||||
|
ones under `$XDG_RUNTIME_DIR/sshfs/`. In practice this is what the user
|
||||||
|
wants ("show me all sshfs mounts"); a future `-r` (restrict) flag could
|
||||||
|
narrow it if needed.
|
||||||
|
- `findmnt` exits 1 when nothing matches its filter — we treat that as
|
||||||
|
success-with-empty-output, not a failure.
|
||||||
|
- **How**:
|
||||||
|
- `list_mounts` rewritten as a thin wrapper around `exec.Command("findmnt",
|
||||||
|
"-t", "fuse.sshfs")` with stdout piped to the caller's writer.
|
||||||
|
- Exit-code 1 from `findmnt` is swallowed via `errors.As(err, *exec.ExitError)`
|
||||||
|
+ `ExitCode() == 1`.
|
||||||
|
- `errors.Is(err, exec.ErrNotFound)` produces a clear "install util-linux"
|
||||||
|
message instead of a cryptic exec error.
|
||||||
|
- Tests `TestListMountsMissingBase` and `TestListMountsFiltersUnmounted`
|
||||||
|
removed: they covered the old in-Go enumeration logic that no longer
|
||||||
|
exists. Mocking `findmnt` would be alibi-testing — the wrapper is two
|
||||||
|
branches over `cmd.Run()`'s error.
|
||||||
|
|
||||||
|
## 2026-05-04 – Detect mounts via `/proc/self/mountinfo` instead of `stat`
|
||||||
|
- **Who**: Dom, ClaudeCode
|
||||||
|
- **Why**: `mountinfo.Mounted(path)` from `github.com/moby/sys/mountinfo` works
|
||||||
|
by `lstat`-ing the path. When a sshfs connection dies, the FUSE endpoint
|
||||||
|
stays in `/proc/mounts` but every `stat` on the mountpoint returns EIO.
|
||||||
|
All three sshfsc paths broke as a result:
|
||||||
|
- `-l` silently filtered the dead mount out (`Mounted` returned an error,
|
||||||
|
code skipped on `merr != nil`)
|
||||||
|
- mount path failed with `mkdir … file exists` because `MkdirAll` stats
|
||||||
|
before creating
|
||||||
|
- `-u` failed with `lstat … input/output error` before reaching
|
||||||
|
`fusermount`
|
||||||
|
Net effect: a stale mount was a dead end — couldn't be listed, couldn't be
|
||||||
|
unmounted, blocked re-mount.
|
||||||
|
- **Tradeoffs**:
|
||||||
|
- Switched mount detection to `mountinfo.GetMounts(SingleEntryFilter(path))`,
|
||||||
|
which parses `/proc/self/mountinfo` and never touches the path. Robust to
|
||||||
|
EIO on the mount target.
|
||||||
|
- `list_mounts` now uses `PrefixFilter(base)` and trims the base prefix.
|
||||||
|
Direct children only — nested mounts under an alias dir would be skipped
|
||||||
|
on purpose; sshfsc only manages one level.
|
||||||
|
- `verify_mount_dir` skips `MkdirAll` when the path is already a mountpoint.
|
||||||
|
Re-running `sshfsc <alias>` against a stale mount no longer errors on
|
||||||
|
mkdir; main's existing `is_mounted_at` check then prints "Already mounted"
|
||||||
|
and returns. The user can `sshfsc -u <alias>` to recover.
|
||||||
|
- `unmount_sshfs` falls back to `fusermount -uz` (lazy) when `-u` fails.
|
||||||
|
Lazy unmount detaches the FS from the tree immediately and lets dangling
|
||||||
|
refs settle later — exactly what stale FUSE needs. Plain `-u` first
|
||||||
|
keeps the clean path noise-free.
|
||||||
|
- **How**:
|
||||||
|
- New helper `is_mounted_at(path)`; replaces `mountinfo.Mounted` at every
|
||||||
|
call site (`main`, `verify_mount_dir`, `unmount_sshfs`).
|
||||||
|
- `list_mounts` rewritten around `PrefixFilter`. Trims `base + "/"` and
|
||||||
|
skips entries containing further separators.
|
||||||
|
- `run_fusermount(flag, mount)` extracted to keep `-u` and `-uz` calls
|
||||||
|
tidy. `unmount_sshfs` retries with `-uz` on first-call error.
|
||||||
|
- New tests: `TestIsMountedAtFalseOnPlainDir`,
|
||||||
|
`TestIsMountedAtFalseOnMissingPath`. Existing tests stay green —
|
||||||
|
`mountinfo.GetMounts` returns empty under the test tmpdirs.
|
||||||
|
|
||||||
|
## 2026-05-04 – Add `-l` (list) and `-u` (unmount) flags
|
||||||
|
- **Who**: Dom, ClaudeCode
|
||||||
|
- **Why**: Mounts under `$XDG_RUNTIME_DIR/sshfs/<alias>` had to be listed and
|
||||||
|
torn down with `ls` and `fusermount -u` by hand. A second `sshfsc <alias>`
|
||||||
|
call only prints `!!! Already mounted` and exits, so the tool gave no first-
|
||||||
|
class way to undo what it set up. `-l` and `-u` close that gap without
|
||||||
|
changing the default mount flow.
|
||||||
|
- **Tradeoffs**:
|
||||||
|
- Flag-based UX over subcommands keeps the existing `-e`/`-v`/`-r` style.
|
||||||
|
`-l` and `-u` are mutually exclusive (exit 2 when both passed). `-u` reuses
|
||||||
|
the positional `<Host>` argument; no separate value flag needed.
|
||||||
|
- `-l` only lists *real* mounts (verified via `mountinfo.Mounted`). Stale
|
||||||
|
empty dirs left behind by failed mounts are silently filtered, not
|
||||||
|
surfaced — keeps the output a true list of teardownable targets. Cleanup
|
||||||
|
of stale dirs is not implemented; tmpfs reclaims them on logout.
|
||||||
|
- `-u` shells out to `fusermount -u` and best-effort `os.Remove`s the empty
|
||||||
|
mount dir afterwards. A failed `Remove` warns but does not fail the
|
||||||
|
command — the unmount itself already succeeded.
|
||||||
|
- `mount_base(create bool)` returns `fs.ErrNotExist` when `create=false` and
|
||||||
|
the base is missing. `list_mounts` swallows that into "no mounts";
|
||||||
|
`unmount_sshfs` translates it into `not mounted: <alias>` so the caller
|
||||||
|
sees a single coherent error regardless of whether the alias-dir, the
|
||||||
|
base, or `XDG_RUNTIME_DIR` was missing.
|
||||||
|
- **How**:
|
||||||
|
- `verify_mount_dir` split into `mount_base(create bool)` (XDG resolve +
|
||||||
|
optional mkdir + EvalSymlinks) and `mount_path(base, name)` (join +
|
||||||
|
traversal + symlink guard, no mkdir). The original `verify_mount_dir`
|
||||||
|
becomes a thin wrapper that composes both and creates the mountpoint —
|
||||||
|
only the mount path needs that mkdir.
|
||||||
|
- `list_mounts(io.Writer)` reads the base dir, runs `mountinfo.Mounted` per
|
||||||
|
entry, prints alias names that pass. Takes a writer so tests can capture
|
||||||
|
output without stdout redirection.
|
||||||
|
- `unmount_sshfs(alias)` validates against `rxHostUser`, resolves the
|
||||||
|
mountpoint, checks `mountinfo.Mounted`, then `exec.Command("fusermount",
|
||||||
|
"-u", mount).Run()` and `os.Remove(mount)`.
|
||||||
|
- `main()` flow: after `flag.Parse()`, branch on `*lFlag` / `*uFlag` before
|
||||||
|
the existing mount path. Mutex check up front. Exit codes 8 (list) and 9
|
||||||
|
(unmount) extend the existing 2..7 range.
|
||||||
|
- Tests: `TestListMountsMissingBase`, `TestListMountsFiltersUnmounted`,
|
||||||
|
`TestUnmountRejectsBadAlias`, `TestUnmountNotMountedMissingBase`,
|
||||||
|
`TestUnmountNotMountedExistingDir`. The real `fusermount` path is not
|
||||||
|
exercised in unit tests — the not-mounted check returns before the exec.
|
||||||
|
|
||||||
|
## 2026-04-28 – Move mount base from `~/Servers/` to `$XDG_RUNTIME_DIR/sshfs/`
|
||||||
|
- **Who**: Dom, ClaudeCode
|
||||||
|
- **Why**: `~/Servers/` is non-standard. XDG Base Directory spec defines
|
||||||
|
`$XDG_RUNTIME_DIR` (= `/run/user/<uid>/`) for "non-essential runtime files"
|
||||||
|
— ephemeral, user-owned, session-bezogen. sshfs mounts fit that definition
|
||||||
|
exactly. systemd-logind creates the directory at login with mode 0700, so
|
||||||
|
permissions are correct by construction.
|
||||||
|
- **Tradeoffs**:
|
||||||
|
- Filemanager sidebar visibility is unchanged — gvfs surfaces FUSE mounts
|
||||||
|
via `/proc/mounts` regardless of mountpoint location.
|
||||||
|
- Tab-completion in `~/` no longer reaches mounts; users targeting the
|
||||||
|
mount via shell need the explicit `$XDG_RUNTIME_DIR/sshfs/<alias>` path.
|
||||||
|
Acceptable given Dom's primary access is the filemanager sidebar.
|
||||||
|
- tmpfs-backed → mounts and their containing dir vanish on logout/reboot.
|
||||||
|
No more orphaned empty dirs. Tradeoff: bookmarks pinned to the absolute
|
||||||
|
path stay valid because UID is stable across sessions, but the directory
|
||||||
|
is gone between logins until the next mount recreates it.
|
||||||
|
- Fallback to `/run/user/<uid>/` when `$XDG_RUNTIME_DIR` is unset (e.g.
|
||||||
|
cron, non-login shells without systemd-logind). Falls auf einem System
|
||||||
|
ohne systemd `/run/user/<uid>/` nicht existiert, schlägt `MkdirAll` mit
|
||||||
|
klarem Fehler fehl — kein Silent-Fallback auf `~/Servers/`.
|
||||||
|
- **How**:
|
||||||
|
- `verify_mount_dir` reads `$XDG_RUNTIME_DIR`, falls back to
|
||||||
|
`/run/user/<uid>/` via `os.Getuid()` + `strconv.Itoa`.
|
||||||
|
- Base = `<runtime>/sshfs`, created with `MkdirAll(0700)`.
|
||||||
|
- Existing path-traversal guard (`EvalSymlinks` + `HasPrefix`) and symlink
|
||||||
|
rejection (`Lstat`) carry over unchanged.
|
||||||
|
- Tests switched from `t.Setenv("HOME", ...)` to
|
||||||
|
`t.Setenv("XDG_RUNTIME_DIR", ...)`.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- **Why**: Audit found `kernel_cache` causes stale reads on a network FS;
|
||||||
|
`StrictHostKeyChecking` was implicit (depended on system default);
|
||||||
|
`ssh_config`-sourced strings flowed into the sshfs argv without validation,
|
||||||
|
allowing comma- or leading-dash injection if `~/.ssh/config` is attacker-influenced.
|
||||||
|
- **Tradeoffs**:
|
||||||
|
- `auto_cache` invalidates the page cache on `open(2)` when mtime/size
|
||||||
|
differ → marginal perf hit vs. correctness on a network FS.
|
||||||
|
- `accept-new` for `StrictHostKeyChecking` preserves first-connection UX
|
||||||
|
(TOFU) but locks subsequent reconnects to the recorded key.
|
||||||
|
- Allowlist regexes for HostName/User/IdentityFile are restrictive but match
|
||||||
|
real-world SSH naming. Rejected inputs surface a clear error.
|
||||||
|
- **How**:
|
||||||
|
- Replaced `-o kernel_cache` with `-o auto_cache` in `mount_sshfs`.
|
||||||
|
- Added explicit `-o StrictHostKeyChecking=accept-new`.
|
||||||
|
- Added `lookup_ssh_field` (uses `GetStrict`, surfaces parse errors) and
|
||||||
|
`validate_ssh_field` with per-field allowlists; Port parsed via `strconv.Atoi`.
|
||||||
|
- Added `main_test.go` covering `verify_mount_dir` and the allowlist regexes.
|
||||||
|
- Hardened `.gitea/workflows/update-pkgver.yaml` with an empty-PKGVER guard.
|
||||||
|
|
||||||
|
## 2026-04-26 – LOW-severity audit findings: cleanup pass + one Won't-Fix
|
||||||
|
- **Who**: Dom, ClaudeCode
|
||||||
|
- **Why**: Followup on the LOW-severity findings from the same audit.
|
||||||
|
- **How**:
|
||||||
|
- Q-L1: dead `if len(port) == 0 { port = "22" }` block already removed by Q-M1
|
||||||
|
refactor (port now flows through `lookup_ssh_field` → `strconv.Atoi`).
|
||||||
|
- Q-L2: `run_editor` returns `error` and `main` exits 7 on editor failure
|
||||||
|
instead of swallowing it.
|
||||||
|
- Q-L3: ABOUTME header added to `main.go` per global CLAUDE.md convention.
|
||||||
|
- P-L1: `golang.org/x/sys` bumped `v0.1.0 → v0.43.0` via `go get … && go mod tidy`.
|
||||||
|
`go` directive moved from 1.23.4 to 1.25.0 (required by the new dep).
|
||||||
|
- S-L2: added `-v` flag; default output now prints only the mount path.
|
||||||
|
`-v` restores the previous full HostName/User/Port/IdentityFile/Mount block.
|
||||||
|
- **Won't-Fix**:
|
||||||
|
- **S-L1** — `.gitea/workflows/update-pkgver.yaml` clones over
|
||||||
|
`http://gitea:3000/...`. This URL only resolves inside the Docker network
|
||||||
|
between the Gitea-act-runner container and the Gitea container. External
|
||||||
|
traffic still terminates TLS at the reverse proxy. An attacker on the
|
||||||
|
internal Docker bridge has already compromised the host; TLS between
|
||||||
|
containers does not help in that scenario. Documenting as accepted risk.
|
||||||
|
|
||||||
|
## 2026-04-26 – Second-round audit: defense-in-depth + UX
|
||||||
|
- **Who**: Dom, ClaudeCode
|
||||||
|
- **Why**: Re-audit (Quality + Performance + Security in parallel) surfaced five
|
||||||
|
remaining LOW findings after agent-output gegencheck. Two MEDIUM claims were
|
||||||
|
filtered out as false positives: missing `Port` already defaults to `"22"` via
|
||||||
|
`ssh_config.Default()` (verified in library source), and `lookup_ssh_field`
|
||||||
|
does not parse `~/.ssh/config` four times — `kevinburke/ssh_config` caches via
|
||||||
|
`sync.Once`, so four `GetStrict` calls = one parse + three in-memory walks.
|
||||||
|
An additional MEDIUM claim about a regex range bug (`%-` interpreted as range)
|
||||||
|
was empirically refuted (only `-` matched, not the implied range), but a
|
||||||
|
legitimate defense-in-depth gap remained: the regex still allowed leading `-`
|
||||||
|
in IdentityFile values.
|
||||||
|
- **Tradeoffs**:
|
||||||
|
- `run_editor` switched to `cmd.Start()` — drops editor exit-status
|
||||||
|
propagation in exchange for non-blocking UX on Sublime cold-start. Errors
|
||||||
|
from missing binary or fork failure are still returned.
|
||||||
|
- `verify_mount_dir` now `MkdirAll`s the base separately before
|
||||||
|
`EvalSymlinks`. `EvalSymlinks` requires the path to exist; without the
|
||||||
|
upfront `MkdirAll`, first run on a fresh system would fail.
|
||||||
|
- `rxIdentityFile` first-character class restricted to `[A-Za-z0-9/~]`. Plain
|
||||||
|
relative paths like `.ssh/id` are rejected — unusual but technically valid
|
||||||
|
in `ssh_config`. If a real config uses relative IdentityFile values, the
|
||||||
|
user gets a clear error and can switch to absolute or `~`-prefixed.
|
||||||
|
- **How**:
|
||||||
|
- **S-L1**: anchored `rxIdentityFile` to `^[A-Za-z0-9/~][A-Za-z0-9._@/:+=~%-]*$`.
|
||||||
|
- **S-L2**: `verify_mount_dir` resolves base via `filepath.EvalSymlinks` and
|
||||||
|
rejects existing mount paths that are symlinks (`os.Lstat`).
|
||||||
|
- **Q-L1**: `flag.Usage` override now prints the positional `<Host>` argument
|
||||||
|
in the help output.
|
||||||
|
- **Q-L2**: inline comment at the `run_editor` call site documents the
|
||||||
|
deliberate fall-through on already-mounted hosts.
|
||||||
|
- **P-L2**: `run_editor` uses `cmd.Start()` instead of `cmd.Run()`.
|
||||||
|
- Tests extended: rxIdentityFile boundary cases, `TestVerifyMountDirRejectsSymlink`,
|
||||||
|
existing `TestVerifyMountDir` paths now compared against `EvalSymlinks(base)`.
|
||||||
@@ -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>`
|
Mounts land under `$XDG_RUNTIME_DIR/sshfs/<Host>` (typically `/run/user/$UID/sshfs/<Host>`), using the ssh_config alias as label, not the resolved IP. The directory is auto-cleaned on logout (tmpfs).
|
||||||
|
|
||||||
# Install
|
# Install
|
||||||
|
|
||||||
@@ -22,21 +22,31 @@ install -Dm755 sshfsc /usr/local/bin/sshfsc
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
|
|
||||||
- [SSHFS](https://wiki.archlinux.org/title/SSHFS)
|
- [SSHFS](https://wiki.archlinux.org/title/SSHFS)
|
||||||
- [Go](https://wiki.archlinux.org/title/Go) (build-time)
|
- `findmnt` from `util-linux` (for `-l`)
|
||||||
|
- [Go](https://wiki.archlinux.org/title/Go) >= 1.25 (build-time)
|
||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
sshfsc <Host>
|
sshfsc <Host> # mount
|
||||||
|
sshfsc -u <Host> # unmount
|
||||||
|
sshfsc -l # list active mounts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Arguments
|
## Arguments
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
| ------------- | ------------- |
|
| ---- | ----------- |
|
||||||
| -e | open mountpoint in your editor |
|
| `-e` | open mountpoint in your editor |
|
||||||
|
| `-v` | verbose: print resolved ssh_config fields (HostName, User, Port, IdentityFile) |
|
||||||
|
| `-r`, `--remote-dir <path>` | remote directory to mount (default: remote home) |
|
||||||
|
| `-l` | list active fuse.sshfs mounts via `findmnt` and exit |
|
||||||
|
| `-u` | unmount the given `<Host>` and exit (mutually exclusive with `-l`) |
|
||||||
|
|
||||||
Editor Sublime-Text (subl) is currently hardcoded. [See](https://gitea.moonarch.de/nevaforget/sshfs_connect/issues/1)
|
By default only the resolved mount path is printed. Use `-v` for the full
|
||||||
|
ssh_config dump.
|
||||||
|
|
||||||
|
Editor Sublime-Text (`subl`) is currently hardcoded. [See](https://gitea.moonarch.de/nevaforget/sshfs_connect/issues/1)
|
||||||
|
|
||||||
# Example ssh config
|
# Example ssh config
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
module sshfsc
|
module sshfsc
|
||||||
|
|
||||||
go 1.23.4
|
go 1.25.0
|
||||||
|
|
||||||
require github.com/kevinburke/ssh_config v1.2.0
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/moby/sys/mountinfo v0.7.2 // indirect
|
github.com/kevinburke/ssh_config v1.2.0
|
||||||
golang.org/x/sys v0.1.0 // indirect
|
github.com/moby/sys/mountinfo v0.7.2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.43.0 // indirect
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4
|
|||||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||||
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
||||||
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
||||||
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
|||||||
@@ -1,98 +1,364 @@
|
|||||||
|
// ABOUTME: CLI tool that mounts remote filesystems via sshfs based on
|
||||||
|
// ABOUTME: ~/.ssh/config entries, with optional editor launch on the mountpoint.
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"github.com/moby/sys/mountinfo"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/kevinburke/ssh_config"
|
"github.com/kevinburke/ssh_config"
|
||||||
"flag"
|
"github.com/moby/sys/mountinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
var eFlag = flag.Bool("e", false, "open mountpoint in your editor")
|
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")
|
||||||
|
lFlag = flag.Bool("l", false, "list active mounts and exit")
|
||||||
|
uFlag = flag.Bool("u", false, "unmount the given <Host> and exit")
|
||||||
|
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() {
|
||||||
|
fmt.Fprintf(os.Stderr,
|
||||||
|
"usage: sshfsc [flags] <Host>\n\n"+
|
||||||
|
"Mount a remote home directory via sshfs based on ~/.ssh/config entries.\n"+
|
||||||
|
"Use -l to list active mounts; -u <Host> to unmount.\n\n"+
|
||||||
|
"Flags:\n")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
args := flag.Args()
|
args := flag.Args()
|
||||||
|
|
||||||
|
if *lFlag && *uFlag {
|
||||||
|
fmt.Fprintln(os.Stderr, "-l and -u are mutually exclusive")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *lFlag {
|
||||||
|
if err := list_mounts(os.Stdout); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "list_mounts() failed:", err)
|
||||||
|
os.Exit(8)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if *uFlag {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "-u requires <Host> argument")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if err := unmount_sshfs(args[0]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "unmount_sshfs() failed:", err)
|
||||||
|
os.Exit(9)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
fmt.Println("No hostname specified.")
|
fmt.Fprintln(os.Stderr, "No hostname specified.")
|
||||||
os.Exit(80)
|
os.Exit(2)
|
||||||
} else {
|
}
|
||||||
hostname := ssh_config.Get(args[0], "HostName")
|
|
||||||
user := ssh_config.Get(args[0], "User")
|
|
||||||
port := ssh_config.Get(args[0], "Port")
|
|
||||||
ifile := ssh_config.Get(args[0], "IdentityFile")
|
|
||||||
|
|
||||||
if len(hostname) == 0 || len(user) == 0 || len(ifile) == 0 {
|
alias := args[0]
|
||||||
fmt.Println("Hostname not found in ~/.ssh_config")
|
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)
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
user, err := lookup_ssh_field(args[0], "User")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
port, err := lookup_ssh_field(args[0], "Port")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
ifile, err := lookup_ssh_field(args[0], "IdentityFile")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hostname == "" || user == "" || ifile == "" {
|
||||||
|
var missing []string
|
||||||
|
if hostname == "" {
|
||||||
|
missing = append(missing, "HostName")
|
||||||
|
}
|
||||||
|
if user == "" {
|
||||||
|
missing = append(missing, "User")
|
||||||
|
}
|
||||||
|
if ifile == "" {
|
||||||
|
missing = append(missing, "IdentityFile")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "ssh config %q missing required field(s): %s\n",
|
||||||
|
args[0], strings.Join(missing, ", "))
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validate_ssh_field("HostName", hostname, rxHostUser); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
if err := validate_ssh_field("User", user, rxHostUser); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
if err := validate_ssh_field("IdentityFile", ifile, rxIdentityFile); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
if p, perr := strconv.Atoi(port); perr != nil || p < 1 || p > 65535 {
|
||||||
|
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)
|
os.Exit(3)
|
||||||
} else {
|
|
||||||
mount := verify_mount_dir(hostname)
|
|
||||||
|
|
||||||
fmt.Println("Hostname: ",hostname)
|
|
||||||
fmt.Println("User: ", user)
|
|
||||||
fmt.Println("Port: ", port)
|
|
||||||
fmt.Println("Ifile: ", ifile)
|
|
||||||
fmt.Println("Mount: ", mount)
|
|
||||||
fmt.Println("---")
|
|
||||||
|
|
||||||
chkmount, chkmount_err := mountinfo.Mounted(mount)
|
|
||||||
if chkmount_err != nil {
|
|
||||||
fmt.Println("mountinfo.Mounted() failed with %s\n", chkmount_err)
|
|
||||||
}
|
|
||||||
if chkmount == false {
|
|
||||||
mount_sshfs(hostname, user, ifile, port, mount)
|
|
||||||
} else {
|
|
||||||
fmt.Println("!!! Already mounted")
|
|
||||||
}
|
|
||||||
run_editor(mount)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mount, err := verify_mount_dir(alias)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "verify_mount_dir() failed:", err)
|
||||||
|
os.Exit(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *vFlag {
|
||||||
|
fmt.Println("Hostname: ", hostname)
|
||||||
|
fmt.Println("User: ", user)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
chkmount, chkmount_err := is_mounted_at(mount)
|
||||||
|
if chkmount_err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "is_mounted_at() failed with %s\n", chkmount_err)
|
||||||
|
os.Exit(5)
|
||||||
|
}
|
||||||
|
if !chkmount {
|
||||||
|
if err := mount_sshfs(alias, user, ifile, port, mount, rDir); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "mount_sshfs() failed:", err)
|
||||||
|
os.Exit(6)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("!!! Already mounted")
|
||||||
|
}
|
||||||
|
// run_editor fires on -e regardless of mount state: re-invoking with -e
|
||||||
|
// re-opens the editor on an already-mounted server.
|
||||||
|
if err := run_editor(mount); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "run_editor() failed:", err)
|
||||||
|
os.Exit(7)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run_editor(mount string) {
|
func lookup_ssh_field(alias, field string) (string, error) {
|
||||||
if(*eFlag == true) {
|
v, err := ssh_config.GetStrict(alias, field)
|
||||||
cmd := exec.Command("subl", mount)
|
if err != nil {
|
||||||
cmd.Stdout = os.Stdout
|
return "", fmt.Errorf("parse ~/.ssh/config: %w", err)
|
||||||
cmd.Stderr = os.Stderr
|
}
|
||||||
err := cmd.Run()
|
return v, nil
|
||||||
if err != nil {
|
}
|
||||||
fmt.Println("run_editor() failed with\n",err)
|
|
||||||
|
func validate_ssh_field(name, value string, allow *regexp.Regexp) error {
|
||||||
|
if !allow.MatchString(value) {
|
||||||
|
return fmt.Errorf("ssh config %s %q contains disallowed characters", name, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func run_editor(mount string) error {
|
||||||
|
if !*eFlag {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cmd := exec.Command("subl", mount)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mount_base resolves the per-user sshfs mount base under $XDG_RUNTIME_DIR.
|
||||||
|
// When create is true, the base directory is mkdir'd; otherwise a missing
|
||||||
|
// base bubbles up as fs.ErrNotExist so callers can decide how to react.
|
||||||
|
func mount_base(create bool) (string, error) {
|
||||||
|
runtime := os.Getenv("XDG_RUNTIME_DIR")
|
||||||
|
if runtime == "" {
|
||||||
|
runtime = filepath.Join("/run/user", strconv.Itoa(os.Getuid()))
|
||||||
|
}
|
||||||
|
base := filepath.Join(runtime, "sshfs")
|
||||||
|
if create {
|
||||||
|
if err := os.MkdirAll(base, 0700); err != nil {
|
||||||
|
return "", fmt.Errorf("create base %q: %w", base, err)
|
||||||
|
}
|
||||||
|
} else if _, err := os.Stat(base); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
realBase, err := filepath.EvalSymlinks(base)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve base %q: %w", base, err)
|
||||||
|
}
|
||||||
|
return realBase, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mount_path joins base + name with traversal and symlink guards.
|
||||||
|
// It does not create the mountpoint directory.
|
||||||
|
func mount_path(base, name string) (string, error) {
|
||||||
|
mount := filepath.Clean(filepath.Join(base, name))
|
||||||
|
if !strings.HasPrefix(mount, base+string(os.PathSeparator)) {
|
||||||
|
return "", fmt.Errorf("name %q escapes mount base %q", name, base)
|
||||||
|
}
|
||||||
|
if info, err := os.Lstat(mount); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return "", fmt.Errorf("mount path %q is a symlink", mount)
|
||||||
|
}
|
||||||
|
return mount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verify_mount_dir(name string) (string, error) {
|
||||||
|
base, err := mount_base(true)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
mount, err := mount_path(base, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// If the path is already a mountpoint (possibly stale), skip MkdirAll —
|
||||||
|
// it would stat the target, which fails with EIO on stale FUSE mounts.
|
||||||
|
if ok, _ := is_mounted_at(mount); ok {
|
||||||
|
return mount, nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(mount, 0700); err != nil {
|
||||||
|
return "", fmt.Errorf("create mount dir %q: %w", mount, err)
|
||||||
|
}
|
||||||
|
return mount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// is_mounted_at reports whether path is a mountpoint per /proc/self/mountinfo.
|
||||||
|
// Unlike mountinfo.Mounted, this does not stat the path, so stale FUSE mounts
|
||||||
|
// (whose targets return EIO on stat) are still detected.
|
||||||
|
func is_mounted_at(path string) (bool, error) {
|
||||||
|
mounts, err := mountinfo.GetMounts(mountinfo.SingleEntryFilter(path))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return len(mounts) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func list_mounts(out io.Writer) error {
|
||||||
|
cmd := exec.Command("findmnt", "-t", "fuse.sshfs")
|
||||||
|
cmd.Stdout = out
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
err := cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// findmnt exits 1 when no matching mount exists — not an error here.
|
||||||
|
var ee *exec.ExitError
|
||||||
|
if errors.As(err, &ee) && ee.ExitCode() == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, exec.ErrNotFound) {
|
||||||
|
return fmt.Errorf("findmnt not found (install util-linux)")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("findmnt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func run_fusermount(flag, mount string) error {
|
||||||
|
cmd := exec.Command("fusermount", flag, mount)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmount_sshfs(alias string) error {
|
||||||
|
if err := validate_ssh_field("alias", alias, rxHostUser); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
base, err := mount_base(false)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("not mounted: %s", alias)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mount, err := mount_path(base, alias)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ok, err := is_mounted_at(mount)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check mount %q: %w", mount, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("not mounted: %s", alias)
|
||||||
|
}
|
||||||
|
if err := run_fusermount("-u", mount); err != nil {
|
||||||
|
// stale FUSE mounts often need lazy unmount; -u fails with
|
||||||
|
// "Transport endpoint is not connected" on those.
|
||||||
|
if err2 := run_fusermount("-uz", mount); err2 != nil {
|
||||||
|
return fmt.Errorf("fusermount -u %q: %w (lazy retry: %v)", mount, err, err2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if err := os.Remove(mount); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: remove empty mount dir %q: %v\n", mount, err)
|
||||||
func verify_mount_dir(hostname string)(mount string) {
|
|
||||||
homedir, homedirerr := os.UserHomeDir()
|
|
||||||
if homedirerr != nil {
|
|
||||||
fmt.Println( homedirerr )
|
|
||||||
}
|
}
|
||||||
mount = homedir+"/Servers/"+hostname
|
fmt.Println("Unmounted:", alias)
|
||||||
os.MkdirAll(mount, os.ModePerm)
|
return nil
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mount_sshfs(hostname string, user string, ifile string, port string, mount string) {
|
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",
|
||||||
"-o", "cache=yes",
|
"-o", "cache=yes",
|
||||||
"-o", "kernel_cache",
|
"-o", "auto_cache",
|
||||||
"-o", "attr_timeout=60",
|
"-o", "attr_timeout=60",
|
||||||
"-o", "entry_timeout=60",
|
"-o", "entry_timeout=60",
|
||||||
"-o", "negative_timeout=20",
|
"-o", "negative_timeout=20",
|
||||||
"-o", "Ciphers=aes128-gcm@openssh.com",
|
"-o", "Ciphers=aes128-gcm@openssh.com",
|
||||||
"-o", "Compression=no",
|
"-o", "Compression=no",
|
||||||
"-o", "reconnect",
|
"-o", "reconnect",
|
||||||
|
"-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
|
||||||
err := cmd.Run()
|
return cmd.Run()
|
||||||
if err != nil {
|
|
||||||
fmt.Println("mount_sshfs() failed with\n",err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
// ABOUTME: Unit tests for sshfsc helpers — path-traversal guard in
|
||||||
|
// ABOUTME: verify_mount_dir (XDG_RUNTIME_DIR-rooted) and field allowlist regexes.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerifyMountDir(t *testing.T) {
|
||||||
|
runtime := t.TempDir()
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", runtime)
|
||||||
|
base := filepath.Join(runtime, "sshfs")
|
||||||
|
if err := os.MkdirAll(base, 0700); err != nil {
|
||||||
|
t.Fatalf("setup base: %v", err)
|
||||||
|
}
|
||||||
|
realBase, err := filepath.EvalSymlinks(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve base: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
hostname string
|
||||||
|
wantErr string // substring; "" means no error
|
||||||
|
wantPath string // only checked when wantErr == ""
|
||||||
|
}{
|
||||||
|
{"normal alias", "server1", "", filepath.Join(realBase, "server1")},
|
||||||
|
{"trailing slash", "server1/", "", filepath.Join(realBase, "server1")},
|
||||||
|
{"dot in name", "db.prod", "", filepath.Join(realBase, "db.prod")},
|
||||||
|
{"parent traversal", "../etc", "escapes mount base", ""},
|
||||||
|
{"deep traversal", "../../tmp/x", "escapes mount base", ""},
|
||||||
|
{"empty string", "", "escapes mount base", ""},
|
||||||
|
{"single dot", ".", "escapes mount base", ""},
|
||||||
|
// nested path lands under base — accepted by guard. The hostname allowlist
|
||||||
|
// (rxHostUser) rejects slashes upstream; this test pins guard behaviour.
|
||||||
|
{"nested path stays under base", "sub/dir", "", filepath.Join(realBase, "sub/dir")},
|
||||||
|
// absolute-looking input does not escape because filepath.Join keeps it
|
||||||
|
// under base. Same upstream allowlist applies.
|
||||||
|
{"absolute-looking input", "/etc/passwd", "", filepath.Join(realBase, "etc/passwd")},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := verify_mount_dir(tc.hostname)
|
||||||
|
if tc.wantErr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("want error containing %q, got nil (path=%q)", tc.wantErr, got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("error %q does not contain %q", err.Error(), tc.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tc.wantPath {
|
||||||
|
t.Fatalf("path = %q, want %q", got, tc.wantPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSSHFieldRegexes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
rx string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"plain host", "myserver", "host", true},
|
||||||
|
{"host with dots", "db.prod.example.com", "host", true},
|
||||||
|
{"host with hyphen", "edge-01", "host", true},
|
||||||
|
{"host leading dash rejected", "-rogue", "host", false},
|
||||||
|
{"host with comma rejected", "a,b", "host", false},
|
||||||
|
{"host with space rejected", "a b", "host", false},
|
||||||
|
{"host with slash rejected", "a/b", "host", false},
|
||||||
|
{"host empty rejected", "", "host", false},
|
||||||
|
|
||||||
|
{"identityfile tilde", "~/.ssh/id_ed25519", "ifile", true},
|
||||||
|
{"identityfile absolute", "/home/dom/.ssh/id_rsa", "ifile", true},
|
||||||
|
{"identityfile letter start", "id_rsa", "ifile", true},
|
||||||
|
{"identityfile digit start", "0key", "ifile", true},
|
||||||
|
{"identityfile dash mid accepted", "~/.ssh/id-host", "ifile", true},
|
||||||
|
{"identityfile with comma rejected", "~/.ssh/id,allow_other", "ifile", false},
|
||||||
|
{"identityfile with space rejected", "~/.ssh/id rsa", "ifile", false},
|
||||||
|
{"identityfile leading dash rejected", "-Ffoo", "ifile", false},
|
||||||
|
{"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
|
||||||
|
switch tc.rx {
|
||||||
|
case "ifile":
|
||||||
|
rx = rxIdentityFile
|
||||||
|
case "rdir":
|
||||||
|
rx = rxRemoteDir
|
||||||
|
}
|
||||||
|
err := validate_ssh_field("X", tc.value, rx)
|
||||||
|
if tc.ok && err != nil {
|
||||||
|
t.Fatalf("want accept, got error: %v", err)
|
||||||
|
}
|
||||||
|
if !tc.ok && err == nil {
|
||||||
|
t.Fatalf("want reject, got accept")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyMountDirRejectsSymlink(t *testing.T) {
|
||||||
|
runtime := t.TempDir()
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", runtime)
|
||||||
|
base := filepath.Join(runtime, "sshfs")
|
||||||
|
if err := os.MkdirAll(base, 0700); err != nil {
|
||||||
|
t.Fatalf("setup base: %v", err)
|
||||||
|
}
|
||||||
|
target := t.TempDir()
|
||||||
|
if err := os.Symlink(target, filepath.Join(base, "evil")); err != nil {
|
||||||
|
t.Fatalf("create symlink: %v", err)
|
||||||
|
}
|
||||||
|
_, err := verify_mount_dir("evil")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "is a symlink") {
|
||||||
|
t.Fatalf("want symlink rejection, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmountRejectsBadAlias(t *testing.T) {
|
||||||
|
runtime := t.TempDir()
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", runtime)
|
||||||
|
err := unmount_sshfs("../etc")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "disallowed characters") {
|
||||||
|
t.Fatalf("want validation error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmountNotMountedMissingBase(t *testing.T) {
|
||||||
|
runtime := t.TempDir()
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", runtime)
|
||||||
|
err := unmount_sshfs("myhost")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "not mounted") {
|
||||||
|
t.Fatalf("want not-mounted error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmountNotMountedExistingDir(t *testing.T) {
|
||||||
|
runtime := t.TempDir()
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", runtime)
|
||||||
|
base := filepath.Join(runtime, "sshfs")
|
||||||
|
if err := os.MkdirAll(filepath.Join(base, "myhost"), 0700); err != nil {
|
||||||
|
t.Fatalf("setup mount dir: %v", err)
|
||||||
|
}
|
||||||
|
err := unmount_sshfs("myhost")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "not mounted") {
|
||||||
|
t.Fatalf("want not-mounted error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsMountedAtFalseOnPlainDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ok, err := is_mounted_at(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("plain tempdir should not be a mountpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsMountedAtFalseOnMissingPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ok, err := is_mounted_at(filepath.Join(dir, "nope"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error (no stat should happen): %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("missing path should not be a mountpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user