35eb90a58b
gates / consistency-and-conventions (push) Successful in 22s
User report: smb-client/nfs-client mountpoint step could only auto-suggest
candidates, or create a fresh dir behind a hidden 'n=new' key — no way to
type an arbitrary existing path as the mountpoint, so the manual option
was effectively invisible (candidates from /media etc. always populated
the picker, hiding the typing path entirely). Designer framing: capability
gap + discoverability gap; backend already handled arbitrary paths (CLI
cmd_mount + ensure_mountpoint), only the interactive menu blocked it.
Change (identical in bin/pos-share-smb-client and bin/pos-share-nfs-client):
- pick_mountpoint hint 'n=new' -> 't=type'; key arm n -> t
- ask_new_mountpoint generalized to ask_mountpoint: an existing
directory is now used AS-IS (no create, no confirm); a non-existent
path keeps the 'Create mountpoint?' confirm + sudo mkdir flow; existing
non-directory rejected ('has a file there'); shape checks and system-path
refusal unchanged; stream contract (display->stderr, path->stdout) kept
- menu_ask_mountpoint empty-candidate fall-through now routes through the
same ask_mountpoint validator (single source of truth)
Docs: DOC/howto/share.md NFS+SMB mountpoint sections updated from n=new to
t=type and describe existing-path-without-create behavior.
Scoped to the two client files + howto doc; persistence/automount units,
unmount/remove flows, cmd_* CLIs, share_folder_candidates, and
lib/menu-lib.sh untouched.
Verified: 9-scenario smoke matrix x2 files (~19 assertions each: existing
dir as-is, new-dir confirm+create, decline, relative/trailing-slash/system/
empty rejections, non-dir reject, mkdir-fail), make gen idempotent, make
check OK, make lint 0 FAIL/0 WARN, make test 17 files / 299 checks green,
bash -n clean, git diff --check clean. Designer ACCEPT framing+spec;
Reviewer ACCEPT after doc fix.
774 lines
29 KiB
Bash
Executable File
774 lines
29 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
# POS: share smb-client — Mount SMB/CIFS shares (ephemeral or persistent systemd mount units)
|
|
# POS_SUBCMDS: mount unmount list persist unpersist menu
|
|
|
|
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
|
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
|
|
source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh"
|
|
|
|
SMB_CREDS_DIR="${SMB_CREDS_DIR:-/etc/samba/credentials}"
|
|
UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}"
|
|
SMB_PORT="${SMB_PORT:-445}"
|
|
SMB_CONF="${SMB_CONF:-/etc/samba/smb.conf}"
|
|
command -v mount.cifs &>/dev/null || err "mount.cifs not found (install cifs-utils)"
|
|
command -v systemd-escape &>/dev/null || err "systemd-escape not found"
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: pos share smb-client <command> [args]
|
|
|
|
Mount and manage SMB/CIFS shares from remote servers (cifs-utils).
|
|
|
|
Commands:
|
|
mount <//server/share> <local-dir> [user] One-shot mount (creates local-dir if needed)
|
|
unmount <local-dir> Unmount the share (idempotent: rc 0 when
|
|
nothing is mounted)
|
|
list Show active + persistent SMB mounts
|
|
persist <//server/share> <local-dir> [user] Persistent mount via systemd .mount + .automount
|
|
units (mounts on first access — never blocks boot)
|
|
unpersist <local-dir> Stop, disable and remove the mount/automount units
|
|
|
|
Safety net before anything is mounted:
|
|
- server reachability on port 445 is probed first (fast fail with hints,
|
|
before any password is asked)
|
|
- an already-mounted target refuses a second mount instead of stacking
|
|
- mounting over a non-empty directory asks for confirmation first
|
|
- every successful mount is read-tested immediately; a mount that cannot
|
|
be listed is rolled back automatically
|
|
- mount failures are translated into targeted fixes (password, chmod o+x
|
|
traversal, wrong share name, firewall/down server) instead of raw
|
|
kernel errors
|
|
|
|
With no user, guest access is attempted. With a user, you are prompted for
|
|
the Samba password — one-shot mounts use a throwaway chmod-600 credentials
|
|
file; persistent mounts keep one at /etc/samba/credentials/.
|
|
|
|
Examples:
|
|
pos share smb-client mount //100.100.100.1/media /mnt/smb/media
|
|
pos share smb-client persist //100.100.100.1/media /mnt/smb/media bob
|
|
pos share smb-client list
|
|
pos share smb-client unmount /mnt/smb/media
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
cmd="${1:-}"
|
|
case "$cmd" in
|
|
-h|--help) usage ;;
|
|
""|menu|mount|unmount|list|persist|unpersist) ;;
|
|
*) err "Unknown command '$cmd' (see --help)" ;;
|
|
esac
|
|
|
|
validate_share() {
|
|
case "$1" in
|
|
//*/*) ;;
|
|
//*) err "Invalid share '$1' — missing share name: expected <//server/share> (e.g. //10.0.0.5/media)" ;;
|
|
*) err "Invalid share '$1' — expected <//server/share> (e.g. //10.0.0.5/media)" ;;
|
|
esac
|
|
}
|
|
|
|
validate_dir() {
|
|
case "$1" in
|
|
/*) ;;
|
|
*) err "Mount point must be an absolute path: $1" ;;
|
|
esac
|
|
}
|
|
|
|
# Split //server/share into server + share; normalizes trailing slashes.
|
|
split_share() {
|
|
local rest="$1"
|
|
rest="${rest#//}"
|
|
SHARE_PATH="/${rest#*/}"
|
|
SERVER="${rest%%/*}"
|
|
SHARE_PATH="${SHARE_PATH%/}"
|
|
[ -n "$SERVER" ] || err "No server in share '$1'"
|
|
[ -n "${SHARE_PATH#/}" ] || err "No share name in '$1'"
|
|
}
|
|
|
|
# TCP connect probe against the server before touching passwords or mounts.
|
|
probe_server() {
|
|
local host="$1"
|
|
# host/port are positional args, never interpolated into the command
|
|
# source — a hostile share/address string stays a literal argument.
|
|
if timeout 3 bash -c 'exec 3<>/dev/tcp/$1/$2' _ "$host" "$SMB_PORT" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
err "Server ${host} not reachable on port ${SMB_PORT} (SMB) — nothing was mounted.
|
|
Checks:
|
|
- is the address right? ping ${host}
|
|
- is the server up and sharing? on the server: pos share smb-server status
|
|
- port filtered? from here: pos network checkport ${host}:${SMB_PORT}
|
|
- Tailscale host? make sure 'tailscale status' lists it (up + recent)"
|
|
}
|
|
|
|
# Source currently cifs-mounted at $1 (empty string when none).
|
|
mounted_src() {
|
|
findmnt -rnf -t cifs -o SOURCE,TARGET 2>/dev/null | awk -v t="$1" '$2 == t {print $1; exit}'
|
|
}
|
|
|
|
# Persisted Type=cifs unit declaring Where=<path>? An idle automount never
|
|
# appears in findmnt, so this is the only way to tell "idle automount" apart
|
|
# from "nothing configured". Sets PERSISTED_UNIT to the unit file basename.
|
|
persisted_smb_at() { # <path> — rc 0 persisted · rc 1 not persisted
|
|
local uf
|
|
PERSISTED_UNIT=""
|
|
for uf in "${UNIT_DIR}"/*.mount; do
|
|
[ -f "$uf" ] || continue
|
|
grep -q '^Type=cifs$' "$uf" || continue
|
|
if [ "$(sed -n 's/^Where=//p' "$uf")" = "$1" ]; then
|
|
PERSISTED_UNIT="$(basename "$uf")"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Create the mountpoint if needed; refuse to silently shadow a non-empty dir.
|
|
ensure_mountpoint() {
|
|
local where="$1"
|
|
if [ -e "$where" ] && [ ! -d "$where" ]; then
|
|
err "$where exists and is not a directory — pick another mount point"
|
|
fi
|
|
if [ -d "$where" ] && [ -n "$(ls -A "$where" 2>/dev/null)" ]; then
|
|
warn "$where is not empty — its contents are hidden while the share is mounted."
|
|
confirm "Mount over it anyway?" n || err "Aborted — nothing was mounted"
|
|
elif [ ! -d "$where" ]; then
|
|
run sudo mkdir -p "$where"
|
|
log "Created mount point $where"
|
|
fi
|
|
}
|
|
|
|
# Prompt for the Samba password (no echo) and write a fresh chmod-600
|
|
# credentials file; prints its path, caller removes it. Test seam:
|
|
# SMB_PW_FILE redirects the password read (never advertised).
|
|
make_creds() {
|
|
local user="$1" pw tmp pw_in="${SMB_PW_FILE:-/dev/tty}"
|
|
read -rsp "Samba password for $user: " pw <"$pw_in" || true
|
|
echo >&2
|
|
[ -n "$pw" ] || err "empty password"
|
|
tmp="$(mktemp)"
|
|
chmod 600 "$tmp"
|
|
printf 'username=%s\npassword=%s\n' "$user" "$pw" > "$tmp"
|
|
printf '%s' "$tmp"
|
|
}
|
|
|
|
mount_opts() {
|
|
printf 'uid=%s,gid=%s' "$(id -u)" "$(id -g)"
|
|
}
|
|
|
|
# Translate a failed mount attempt into the fix the human actually needs.
|
|
diagnose_mount_failure() {
|
|
local msg="$1"
|
|
case "$msg" in
|
|
*LOGON_FAILURE*|*WRONG_PASSWORD*)
|
|
err "Server rejected the login (wrong user/password).
|
|
- retry with the correct Samba user: pos share smb-client mount $what $where <user>
|
|
- does the account exist at all? on the server: pos share smb-server status" ;;
|
|
*ACCESS_DENIED*|*"error(13)"*|*"Permission denied"*|*STATUS_CANNOT_DELETE*)
|
|
err "Access denied by the server (login worked, share said no).
|
|
On the server side, in order:
|
|
1. traversal: every parent dir of the share needs o+x — the server tool
|
|
prints the exact dirs when you re-share; quick fix:
|
|
chmod o+x <parent-dir>
|
|
2. user missing from the Samba passdb: pos share smb-server adduser <user>
|
|
3. share restricted: 'valid users' must contain your user (pos share smb-server list)" ;;
|
|
*BAD_NETWORK_NAME*)
|
|
err "The server has no share named '${SHARE_PATH#/}'.
|
|
- typo in the share name? on the server: pos share smb-server list
|
|
- correct syntax: //$SERVER/<share>" ;;
|
|
*CONNECTION_REFUSED*|*"error(111)"*)
|
|
err "${SERVER} refused the connection — smbd is down or a firewall rejects port ${SMB_PORT}.
|
|
on the server: pos share smb-server enable / sudo ufw allow Samba" ;;
|
|
*HOST_UNREACHABLE*|*"error(101)"*|*"error(113)"*|*"No route to host"*|*"Network is unreachable"*)
|
|
err "${SERVER} is unreachable (routing/host down).
|
|
- Tailscale host? 'tailscale status' must list it as online
|
|
- probe from here: pos network checkport ${SERVER}:${SMB_PORT}" ;;
|
|
*IO_TIMEOUT*|*"Host is down"*|*"error(112)"*|*"timed out"*)
|
|
err "${SERVER} accepted TCP but never answered the SMB handshake (usually a dropping firewall).
|
|
on the server: sudo ufw allow Samba / pos share smb-server status" ;;
|
|
*)
|
|
err "Mount failed:
|
|
$msg
|
|
More causes + fixes: DOC/howto/share.md (SMB troubleshooting)" ;;
|
|
esac
|
|
}
|
|
|
|
# Mount attempt with captured stderr (so failures can be diagnosed).
|
|
try_mount() {
|
|
local what="$1" where="$2" opts="$3"
|
|
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
|
log "(dry-run) mount -t cifs $what $where -o $opts"
|
|
return 0
|
|
fi
|
|
MOUNT_ERR="$(sudo mount -t cifs "$what" "$where" -o "$opts" 2>&1)" || return 1
|
|
}
|
|
|
|
# Post-mount proof: the mount must actually list. A mount that cannot be
|
|
# read is worse than no mount — roll it back.
|
|
read_test_or_rollback() {
|
|
local where="$1" what="$2"
|
|
if [ "${DRY_RUN:-0}" -eq 1 ]; then return 0; fi
|
|
if timeout 5 ls -A "$where" >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
warn "Mounted, but $where cannot be listed — rolling back."
|
|
sudo umount "$where" 2>/dev/null || sudo umount -l "$where" 2>/dev/null || true
|
|
err "Share '${what}' mounted but reading it failed, so it was unmounted again.
|
|
Login works but file permissions don't — on the server:
|
|
- ls -la <shared-path> as root: do others/the owner have r-x?
|
|
- the connecting user must have unix-level access to the folder itself
|
|
Details: DOC/howto/share.md (SMB troubleshooting)"
|
|
}
|
|
|
|
cmd_mount() { # <//server/share> <local-dir> [user]
|
|
local what="$1" where="$2" user="${3:-}"
|
|
local src creds opts
|
|
validate_share "$what"
|
|
validate_dir "$where"
|
|
split_share "$what"
|
|
|
|
probe_server "$SERVER"
|
|
|
|
if src="$(mounted_src "$where")"; [ -n "$src" ]; then
|
|
err "$where is already mounted (source: ${src:-unknown}) — nothing done.
|
|
Unmount it first: pos share smb-client unmount $where"
|
|
fi
|
|
|
|
ensure_mountpoint "$where"
|
|
|
|
if [ -n "$user" ]; then
|
|
creds="$(make_creds "$user")"
|
|
trap 'rm -f "$creds"' EXIT
|
|
opts="credentials=$creds,$(mount_opts)"
|
|
try_mount "$what" "$where" "$opts" || {
|
|
rm -f "$creds"
|
|
trap - EXIT
|
|
diagnose_mount_failure "${MOUNT_ERR:-}"
|
|
}
|
|
rm -f "$creds"
|
|
trap - EXIT
|
|
else
|
|
warn "No user — attempting guest mount (works only if the server allows guest access)"
|
|
try_mount "$what" "$where" "guest,$(mount_opts)" || diagnose_mount_failure "${MOUNT_ERR:-}"
|
|
fi
|
|
|
|
read_test_or_rollback "$where" "$what"
|
|
log "Mounted $what at $where (read test passed)"
|
|
notify_send "SMB mounted: $what → $where"
|
|
}
|
|
|
|
cmd_unmount() { # <local-dir>
|
|
local where="$1" src out
|
|
validate_dir "$where"
|
|
|
|
src="$(mounted_src "$where")"
|
|
if [ -z "$src" ]; then
|
|
# Idempotent no-op — rc 0 whether or not anything is configured.
|
|
if persisted_smb_at "$where"; then
|
|
warn "$where is a persisted automount — not currently mounted."
|
|
log "Access it once (e.g.: ls $where) to auto-mount it, or remove the persistence first: menu option 5 (pos share smb-client unpersist $where)"
|
|
else
|
|
log "Nothing mounted at $where"
|
|
fi
|
|
return 0
|
|
fi
|
|
if ! out="$(sudo umount "$where" 2>&1)"; then
|
|
if grep -qE "busy|in use" <<<"$out"; then
|
|
warn "$where is busy (${src})"
|
|
confirm "Force a lazy unmount now?" y && { run sudo umount -l "$where"; log "Lazy-unmounted $where"; return 0; }
|
|
err "Still mounted. Find the blocker: sudo lsof +D $where (or fuser -vm $where)"
|
|
fi
|
|
err "Unmount failed: $out"
|
|
fi
|
|
log "Unmounted $where"
|
|
notify_send "SMB unmounted: $where"
|
|
}
|
|
|
|
cmd_list() {
|
|
local found=0 active_mounts unit what where
|
|
local -a persistent=()
|
|
active_mounts="$(findmnt -t cifs 2>/dev/null || true)"
|
|
if [ -n "$active_mounts" ]; then
|
|
printf 'Active mounts:\n'
|
|
printf '%s\n' "$active_mounts"
|
|
found=1
|
|
fi
|
|
for unit in "${UNIT_DIR}"/*.mount; do
|
|
[ -e "$unit" ] || continue
|
|
grep -q '^Type=cifs$' "$unit" || continue
|
|
what="$(sed -n 's/^What=//p' "$unit")"
|
|
where="$(sed -n 's/^Where=//p' "$unit")"
|
|
[ -n "$what" ] && [ -n "$where" ] || continue
|
|
persistent+=("$where|$what")
|
|
done
|
|
if [ "${#persistent[@]}" -gt 0 ]; then
|
|
found=1
|
|
printf 'Persistent (automount):\n'
|
|
for entry in "${persistent[@]}"; do
|
|
printf ' %-44s %s\n' "${entry%%|*}" "${entry#*|}"
|
|
done
|
|
fi
|
|
[ "$found" -eq 1 ] || echo "No SMB mounts"
|
|
}
|
|
|
|
cmd_persist() { # <//server/share> <local-dir> [user]
|
|
local what="$1" where="$2" user="${3:-}"
|
|
local src unit auto_unit unit_file auto_file opts creds_file tmp out verified
|
|
validate_share "$what"
|
|
validate_dir "$where"
|
|
split_share "$what"
|
|
|
|
probe_server "$SERVER"
|
|
|
|
if src="$(mounted_src "$where")"; [ -n "$src" ]; then
|
|
err "$where is already mounted (source: ${src:-unknown}) — nothing done.
|
|
Active mount + automount units conflict; unmount first:
|
|
pos share smb-client unmount $where"
|
|
fi
|
|
|
|
unit="$(systemd-escape --path --suffix=mount "$where")"
|
|
auto_unit="${unit%.mount}.automount"
|
|
unit_file="${UNIT_DIR}/${unit}"
|
|
auto_file="${UNIT_DIR}/${auto_unit}"
|
|
|
|
if [ -e "$unit_file" ] || [ -e "$auto_file" ]; then
|
|
warn "Units for $where already exist — they will be REPLACED:"
|
|
if [ -e "$unit_file" ]; then warn " ${unit_file}"; fi
|
|
if [ -e "$auto_file" ]; then warn " ${auto_file}"; fi
|
|
confirm "Replace them?" n || err "Aborted — units left untouched"
|
|
fi
|
|
|
|
opts="$(mount_opts),_netdev,noexec"
|
|
creds_file=""
|
|
if [ -n "$user" ]; then
|
|
creds_file="$SMB_CREDS_DIR/$(basename "$where")"
|
|
sudo mkdir -p "$SMB_CREDS_DIR"
|
|
tmp="$(make_creds "$user")"
|
|
sudo install -m 600 "$tmp" "$creds_file"
|
|
rm -f "$tmp"
|
|
opts="credentials=$creds_file,$opts"
|
|
else
|
|
warn "No user — persisting a guest mount (works only if the server allows guest access)"
|
|
opts="guest,$opts"
|
|
fi
|
|
|
|
rollback_persist() {
|
|
warn "Rolling back everything this command created…"
|
|
sudo systemctl disable "$auto_unit" 2>/dev/null || true
|
|
sudo systemctl stop "$auto_unit" 2>/dev/null || true
|
|
sudo systemctl disable "$unit" 2>/dev/null || true
|
|
sudo systemctl stop "$unit" 2>/dev/null || true
|
|
sudo rm -f "$unit_file" "$auto_file"
|
|
if [ -n "$creds_file" ]; then sudo rm -f "$creds_file"; fi
|
|
sudo systemctl daemon-reload 2>/dev/null || true
|
|
}
|
|
|
|
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
|
log "(dry-run) write $unit_file + $auto_file (Type=cifs, Options=$opts)"
|
|
log "(dry-run) daemon-reload + enable --now $auto_unit"
|
|
exit 0
|
|
fi
|
|
|
|
cat <<UNIT | sudo tee "$unit_file" >/dev/null
|
|
[Unit]
|
|
Description=SMB mount of ${what} at ${where}
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Mount]
|
|
What=${what}
|
|
Where=${where}
|
|
Type=cifs
|
|
Options=${opts}
|
|
UNIT
|
|
cat <<UNIT | sudo tee "$auto_file" >/dev/null
|
|
[Unit]
|
|
Description=Automount of SMB share ${what} at ${where}
|
|
|
|
[Automount]
|
|
Where=${where}
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
UNIT
|
|
sudo systemctl daemon-reload
|
|
|
|
# Enable, then prove the automount actually serves the share before
|
|
# declaring victory — a broken unit here would bite months later.
|
|
if ! out="$(sudo systemctl enable --now "$auto_unit" 2>&1)"; then
|
|
rollback_persist
|
|
err "Could not enable $auto_unit: $out"
|
|
fi
|
|
|
|
ls "$where" >/dev/null 2>&1 || true # poke the automount
|
|
verified=0
|
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
|
if [ -n "$(mounted_src "$where")" ]; then verified=1; break; fi
|
|
sleep 0.5
|
|
done
|
|
if [ "$verified" -ne 1 ]; then
|
|
rollback_persist
|
|
err "Automount did not trigger for $where — units removed again.
|
|
Check: systemctl status $auto_unit"
|
|
fi
|
|
|
|
# Same read-proof as one-shot mounts: catch permission problems now.
|
|
if ! timeout 5 ls -A "$where" >/dev/null 2>&1; then
|
|
rollback_persist
|
|
err "Automount triggered but $where is not readable — units + credentials removed.
|
|
Login works but file permissions don't — fix unix perms on the server
|
|
(the shared folder itself needs r-x for the connecting user).
|
|
Details: DOC/howto/share.md (SMB troubleshooting)"
|
|
fi
|
|
|
|
log "Persistent SMB mount (automount): ${what} → ${where} (${auto_unit})"
|
|
log "Verified: automount triggers and the share is readable"
|
|
notify_send "SMB mount persisted: ${what} → ${where}"
|
|
}
|
|
|
|
cmd_unpersist() { # <local-dir>
|
|
local where="$1" unit auto_unit unit_file auto_file
|
|
validate_dir "$where"
|
|
|
|
unit="$(systemd-escape --path --suffix=mount "$where")"
|
|
auto_unit="${unit%.mount}.automount"
|
|
unit_file="${UNIT_DIR}/${unit}"
|
|
auto_file="${UNIT_DIR}/${auto_unit}"
|
|
|
|
if [ ! -f "$unit_file" ] && [ ! -f "$auto_file" ]; then
|
|
# Idempotent no-op — rc 0 whether or not anything is configured.
|
|
# Return, not exit: a bogus typed path reached from the menu must
|
|
# not kill the whole session. A persisted unit can also live under
|
|
# a non-escape-derived filename (Where= still matches) — point at
|
|
# it instead of a bare nothing-to-do.
|
|
if persisted_smb_at "$where"; then
|
|
warn "$where has a persistent SMB mount unit (${PERSISTED_UNIT}) under a non-standard unit name."
|
|
log "Check it: systemctl status ${PERSISTED_UNIT%.mount} — or find it in the list: pos share smb-client list"
|
|
else
|
|
log "No persistent SMB mount for $where — nothing to do"
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
sudo systemctl disable "$auto_unit" 2>/dev/null || true
|
|
sudo systemctl stop "$auto_unit" 2>/dev/null || true
|
|
sudo systemctl disable "$unit" 2>/dev/null || true
|
|
sudo systemctl stop "$unit" 2>/dev/null || true
|
|
sudo rm -f "$unit_file" "$auto_file"
|
|
sudo rm -f "$SMB_CREDS_DIR/$(basename "$where")"
|
|
sudo rmdir "$SMB_CREDS_DIR" 2>/dev/null || true
|
|
sudo systemctl daemon-reload
|
|
|
|
if [ -n "$(mounted_src "$where")" ]; then
|
|
warn "$where is still mounted (something holds it open)"
|
|
if confirm "Force a lazy unmount now?" y; then run sudo umount -l "$where"; fi
|
|
fi
|
|
log "Removed persistent SMB mount: $where"
|
|
notify_send "SMB persistent mount removed: $where"
|
|
}
|
|
|
|
# ── Interactive menu flows ─────────────────────────────────────
|
|
|
|
# Best-effort resolution of the directory BEHIND share <$2> on host <$1>.
|
|
# The underlying path of a remote SMB share is not remotely discoverable;
|
|
# it is only locally knowable when THIS machine is the server (testparm
|
|
# answers from the local config). Anything else stays unresolved (rc 1)
|
|
# and the mountpoint picker silently skips the "(as on server)" suggestion.
|
|
smb_server_path() { # <host> <share> — stdout: server-side dir · rc 1 = unresolved
|
|
local host="${1,,}" share="$2" name p ips=""
|
|
local -a names=("localhost" "127.0.0.1" "::1" "$(hostname)")
|
|
names+=("$(hostname -f 2>/dev/null || true)")
|
|
ips="$(hostname -I 2>/dev/null)" || true
|
|
# shellcheck disable=SC2086 — $ips is an intentional space-split IP list
|
|
for name in "${names[@]}" ${ips}; do
|
|
if [ "$name" = "$host" ]; then
|
|
share_require_bin testparm "" || return 1
|
|
[ -f "$SMB_CONF" ] || return 1
|
|
p="$(testparm -s --parameter-name=path --section-name="$share" "$SMB_CONF" 2>/dev/null)" || return 1
|
|
[ -n "$p" ] || return 1
|
|
printf '%s\n' "$p"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Mountpoint picker — local port of the share_pick primitive with exactly
|
|
# two deltas: the hint line offers `t=type`, and typing t runs the
|
|
# manual-entry flow below. share_pick cannot intercept `t` (it filters on
|
|
# it) and lib/menu-lib.sh is shared, so the fork lives here. Rendering of
|
|
# numbered picks / text filter / 0=back is byte-identical to menu_pick.
|
|
# stdout: chosen item text (or the freshly created dir) · rc 1 = back/cancel.
|
|
pick_mountpoint() {
|
|
local prompt="$1"; shift
|
|
local -a items=("$@")
|
|
if [ "${#items[@]}" -eq 0 ]; then
|
|
return 1
|
|
fi
|
|
if ! [ -t 0 ]; then
|
|
printf '[!] Interactive picker needs a terminal.\n' >&2
|
|
return 1
|
|
fi
|
|
local filter="" ans i n total=${#items[@]} made
|
|
local -a shown=()
|
|
while true; do
|
|
shown=()
|
|
for ((i = 0; i < total; i++)); do
|
|
if [ -z "$filter" ] || [[ "${items[$i],,}" == *"${filter,,}"* ]]; then
|
|
shown+=("${items[$i]}")
|
|
fi
|
|
done
|
|
n=${#shown[@]}
|
|
{
|
|
echo
|
|
if [ -n "$filter" ]; then
|
|
printf -- "-- %d of %d match '%s' --\n" "$n" "$total" "$filter"
|
|
else
|
|
printf -- "-- %d available --\n" "$total"
|
|
fi
|
|
if [ "$n" -eq 0 ]; then
|
|
printf '[!] no matches — enter nothing or / to clear the filter\n' >&2
|
|
else
|
|
for ((i = 0; i < n; i++)); do
|
|
printf ' %2d) %s\n' $((i + 1)) "${shown[$i]}"
|
|
done
|
|
fi
|
|
} >&2
|
|
if ! read -rp "${prompt} [1-${n}], t=type, text=filter, 0=back " ans; then
|
|
return 1 # EOF — cancel
|
|
fi
|
|
case "$ans" in
|
|
"") [ -z "$filter" ] || filter="" ; continue ;;
|
|
"/") filter="" ; continue ;;
|
|
0 | q | Q | b | B) return 1 ;;
|
|
t | T)
|
|
made="$(ask_mountpoint)" && { echo "$made"; return 0; }
|
|
continue # cancelled/invalid/mkdir-failed → redraw
|
|
;;
|
|
*[!0-9]*)
|
|
filter="$ans"
|
|
continue
|
|
;;
|
|
*)
|
|
if (( ans >= 1 && ans <= n )); then
|
|
echo "${shown[$((ans - 1))]}"
|
|
return 0
|
|
fi
|
|
echo "Unknown choice." >&2
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# Manual entry flow behind the picker's `t` key. Validates the shape
|
|
# (absolute, no trailing slash, system-path refusal), then branches:
|
|
# existing dir → use as-is; new dir → confirm-gate the creation + mkdir -p.
|
|
# Any decline, invalid input, EOF or mkdir failure is a warning + rc 1 —
|
|
# the picker redraws, the tool never aborts.
|
|
ask_mountpoint() { # stdout: absolute path (existing or newly created) · rc 1 = cancelled/failed
|
|
# NOTE: runs inside $( ) from the picker — every display line MUST go to
|
|
# stderr (menu-lib contract: display → stderr, result → stdout).
|
|
local dir
|
|
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
|
|
case "$dir" in
|
|
/*) ;;
|
|
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;;
|
|
esac
|
|
case "$dir" in
|
|
*/) warn "'$dir' must not end with a slash" >&2; return 1 ;;
|
|
esac
|
|
case "$dir" in
|
|
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
|
|
warn "Refusing system path as mountpoint" >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
if [ -d "$dir" ]; then
|
|
echo "$dir"
|
|
return 0
|
|
fi
|
|
if [ -e "$dir" ]; then
|
|
warn "$dir exists and is not a directory — pick another mount point" >&2
|
|
return 1
|
|
fi
|
|
confirm "Create mountpoint ${dir}?" n || return 1
|
|
if ! run sudo mkdir -p "$dir"; then
|
|
warn "Could not create ${dir}" >&2
|
|
return 1
|
|
fi
|
|
log "Created mount point $dir" >&2
|
|
echo "$dir"
|
|
}
|
|
|
|
menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancelled
|
|
local srv="${1:-}" idx res cand dir known=0
|
|
local -a cands=() dirs=()
|
|
mapfile -t cands < <(share_folder_candidates)
|
|
for cand in "${cands[@]}"; do
|
|
dirs+=("${cand%% (*}") # bare path (strip "(mounted fstype)" note)
|
|
done
|
|
# Same-as-server suggestion: unless the server-side path already exists
|
|
# among the local candidates, append it as a synthetic pick so mounting
|
|
# at a mirrored path is a normal selection.
|
|
if [ -n "$srv" ] && [ "${#dirs[@]}" -gt 0 ] &&
|
|
printf '%s\n' "${dirs[@]}" | grep -qxF -- "$srv"; then
|
|
known=1
|
|
fi
|
|
if [ -n "$srv" ] && [ "$known" -eq 0 ]; then
|
|
cands+=("${srv} (as on server)")
|
|
fi
|
|
if [ "${#cands[@]}" -gt 0 ]; then
|
|
if res="$(pick_mountpoint "Mountpoint" "${cands[@]}")"; then
|
|
dir="${res%% (*}" # strip "(as on server)"/mount note
|
|
case "$dir" in
|
|
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
|
|
warn "Refusing system path as mountpoint"
|
|
return 1
|
|
;;
|
|
*)
|
|
echo "$dir"
|
|
return 0
|
|
;;
|
|
esac
|
|
fi
|
|
fi
|
|
ask_mountpoint
|
|
}
|
|
|
|
menu_mount() { # ephemeral|persist
|
|
local mode="$1" host idx share what where
|
|
host="$(share_ask_value "SMB server (host or IP)")" || return 1
|
|
[ -n "$host" ] || { warn "No server given"; return 1; }
|
|
|
|
# Empty answer = guest enumeration (with an interactive auth retry inside
|
|
# the helper); a named user authenticates right away. SMB_AUTH_USER tells
|
|
# us which account ended up being used so the mount reuses it.
|
|
local user=""
|
|
read -rp "Samba user (empty = try guest): " user || return 1
|
|
SMB_AUTH_USER=""
|
|
|
|
local -a shares=()
|
|
if mapfile -t shares < <(share_smb_shares "$host" "$user") && [ "${#shares[@]}" -gt 0 ]; then
|
|
idx="$(share_pick "Pick share on ${host}" "${shares[@]}")" || return 1
|
|
share="${shares[$((idx - 1))]}"
|
|
else
|
|
share="$(share_ask_value "Share name on ${host} (e.g. media)")" || return 1
|
|
[ -n "$share" ] || { warn "No share name given"; return 1; }
|
|
fi
|
|
user="${SMB_AUTH_USER:-$user}"
|
|
|
|
what="//${host}/${share}"
|
|
# Underlying server-side dir, when knowable (this machine is the server);
|
|
# empty → picker silently skips the "(as on server)" suggestion.
|
|
local srv_path=""
|
|
srv_path="$(smb_server_path "$host" "$share")" || srv_path=""
|
|
where="$(menu_ask_mountpoint "$srv_path")" || return 1
|
|
|
|
if [ "$mode" = "persist" ]; then
|
|
cmd_persist "$what" "$where" "$user"
|
|
else
|
|
cmd_mount "$what" "$where" "$user"
|
|
fi
|
|
}
|
|
|
|
menu_unmount() {
|
|
local idx row src where i
|
|
local -a tgts=() srcs=() items=()
|
|
# Same enumeration source as the `list` view's active section
|
|
# (findmnt -t cifs), reduced to TARGET|SOURCE rows.
|
|
while IFS= read -r row; do
|
|
[ -n "$row" ] || continue
|
|
tgts+=("${row%%|*}")
|
|
srcs+=("${row#*|}")
|
|
done < <(findmnt -rn -o TARGET,SOURCE -t cifs 2>/dev/null |
|
|
awk '{ src=$NF; $NF=""; sub(/[ \t]+$/, ""); print $0 "|" src }')
|
|
if [ "${#tgts[@]}" -eq 0 ]; then
|
|
log "No active SMB mounts"
|
|
where="$(share_ask_value "Local mountpoint to unmount")" || return 1
|
|
[ -n "$where" ] || return 1
|
|
cmd_unmount "$where"
|
|
return 0
|
|
fi
|
|
for ((i = 0; i < ${#tgts[@]}; i++)); do
|
|
items+=("${tgts[$i]} ← ${srcs[$i]}")
|
|
done
|
|
idx="$(share_pick "Unmount which SMB mount?" "${items[@]}")" || return 1
|
|
where="${tgts[$((idx - 1))]}"
|
|
src="${srcs[$((idx - 1))]}"
|
|
confirm "Unmount ${where} (from ${src})?" n || { log "Cancelled"; return 1; }
|
|
cmd_unmount "$where"
|
|
}
|
|
|
|
menu_unpersist() {
|
|
local idx uf where unit_w
|
|
local -a paths=()
|
|
for uf in "${UNIT_DIR}"/*.mount; do
|
|
grep -q '^Type=cifs$' "$uf" 2>/dev/null || continue
|
|
unit_w="$(sed -n 's/^Where=//p' "$uf")"
|
|
[ -n "$unit_w" ] || continue
|
|
paths+=("$unit_w")
|
|
done
|
|
if [ "${#paths[@]}" -gt 0 ]; then
|
|
idx="$(share_pick "Remove which persistent SMB mount?" "${paths[@]}")" || return 1
|
|
where="${paths[$((idx - 1))]}"
|
|
else
|
|
where="$(share_ask_value "Local mountpoint whose units to remove")" || return 1
|
|
[ -n "$where" ] || return 1
|
|
fi
|
|
cmd_unpersist "$where"
|
|
}
|
|
|
|
run_menu() {
|
|
share_menu_guard || exit 1
|
|
while true; do
|
|
local choice
|
|
choice="$(share_menu_run "SMB client" \
|
|
"Mount a share (one-shot)" \
|
|
"Persist a share (automount units)" \
|
|
"List active + persistent mounts" \
|
|
"Unmount a mounted share" \
|
|
"Remove a persistent mount")" || return 0
|
|
case "$choice" in
|
|
# Handlers return nonzero on cancel/back — normalized here so a
|
|
# cancel can never reach set -e and kill the whole session.
|
|
1) menu_mount ephemeral || true ;;
|
|
2) menu_mount persist || true ;;
|
|
3) cmd_list || true ;;
|
|
4) menu_unmount || true ;;
|
|
5) menu_unpersist || true ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
case "$cmd" in
|
|
""|menu)
|
|
run_menu
|
|
exit 0
|
|
;;
|
|
|
|
mount)
|
|
[ $# -ge 3 ] || err "Usage: pos share smb-client mount <//server/share> <local-dir> [user]"
|
|
cmd_mount "$2" "$3" "${4:-}"
|
|
;;
|
|
|
|
unmount)
|
|
[ $# -ge 2 ] || err "Usage: pos share smb-client unmount <local-dir>"
|
|
cmd_unmount "$2"
|
|
;;
|
|
|
|
list)
|
|
cmd_list
|
|
;;
|
|
|
|
persist)
|
|
[ $# -ge 3 ] || err "Usage: pos share smb-client persist <//server/share> <local-dir> [user]"
|
|
cmd_persist "$2" "$3" "${4:-}"
|
|
;;
|
|
|
|
unpersist)
|
|
[ $# -ge 2 ] || err "Usage: pos share smb-client unpersist <local-dir>"
|
|
cmd_unpersist "$2"
|
|
;;
|
|
esac
|