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.
512 lines
17 KiB
Bash
Executable File
512 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
# POS: share nfs-client — Mount NFS 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"
|
|
|
|
# Env seam (testable): where persistent .mount units are written.
|
|
UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}"
|
|
|
|
command -v mount.nfs &>/dev/null || err "mount.nfs not found (install nfs-common)"
|
|
command -v systemd-escape &>/dev/null || err "systemd-escape not found"
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: pos share nfs-client <command> [args]
|
|
|
|
Mount and manage NFS shares from remote servers (nfs-common).
|
|
|
|
Commands:
|
|
mount <server:export> <local-dir> One-shot mount (creates local-dir if needed)
|
|
unmount <local-dir> Unmount the share (idempotent: rc 0 when
|
|
nothing is mounted)
|
|
list Show active NFS mounts
|
|
persist <server:export> <local-dir> Persistent mount via a systemd .mount unit
|
|
(ordered after network-online.target)
|
|
unpersist <local-dir> Stop, disable and remove the mount unit
|
|
menu Interactive browser (server → export → mountpoint)
|
|
|
|
Run without arguments to open the interactive menu.
|
|
|
|
Examples:
|
|
pos share nfs-client mount 100.100.100.1:/srv/media /mnt/nfs/media
|
|
pos share nfs-client persist 100.100.100.1:/srv/media /mnt/nfs/media
|
|
pos share nfs-client list
|
|
pos share nfs-client unmount /mnt/nfs/media
|
|
pos share nfs-client menu
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
validate_share() {
|
|
local what="$1"
|
|
case "$what" in
|
|
/*) err "Invalid share '$what' — expected <server:export> (e.g. 10.0.0.5:/srv/data)" ;;
|
|
*:*) ;;
|
|
*) err "Invalid share '$what' — expected <server:export> (e.g. 10.0.0.5:/srv/data)" ;;
|
|
esac
|
|
}
|
|
|
|
validate_dir() {
|
|
local where="$1"
|
|
case "$where" in
|
|
/*) ;;
|
|
*) err "Mount point must be an absolute path: $where" ;;
|
|
esac
|
|
}
|
|
|
|
cmd_mount() {
|
|
local what="$1" where="$2"
|
|
validate_share "$what"
|
|
validate_dir "$where"
|
|
|
|
sudo mkdir -p "$where"
|
|
sudo mount -t nfs -o rw,noatime "$what" "$where"
|
|
log "Mounted $what at $where"
|
|
}
|
|
|
|
cmd_unmount() {
|
|
local where="$1"
|
|
validate_dir "$where"
|
|
|
|
if ! findmnt -r -n -o TARGET -t nfs,nfs4 2>/dev/null | grep -qxF "$where"; then
|
|
# Idempotent no-op — rc 0 whether or not a unit exists. A persisted
|
|
# boot-time .mount that is not currently mounted usually means the
|
|
# unit failed or was stopped; say so instead of a bare nothing-to-do.
|
|
if persisted_nfs_at "$where"; then
|
|
warn "$where has a persistent NFS mount unit (${PERSISTED_UNIT}) — not currently mounted."
|
|
log "Check it: systemctl status ${PERSISTED_UNIT%.mount} — or remove the persistence: menu option 5 (pos share nfs-client unpersist $where)"
|
|
else
|
|
log "$where is not mounted as NFS — nothing to do"
|
|
fi
|
|
return 0
|
|
fi
|
|
sudo umount "$where"
|
|
log "Unmounted $where"
|
|
}
|
|
|
|
# Persisted Type=nfs/nfs4 unit declaring Where=<path>? Sets PERSISTED_UNIT.
|
|
persisted_nfs_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=nfs' "$uf" || continue
|
|
if [ "$(sed -n 's/^Where=//p' "$uf")" = "$1" ]; then
|
|
PERSISTED_UNIT="$(basename "$uf")"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
cmd_list() {
|
|
local out
|
|
if out="$(findmnt -t nfs,nfs4 2>/dev/null)" && [ "$(grep -c . <<<"$out")" -gt 1 ]; then
|
|
printf '%s\n' "$out"
|
|
else
|
|
echo "No NFS mounts"
|
|
fi
|
|
}
|
|
|
|
write_mount_unit() { # <unit_file> <what> <where>
|
|
cat <<UNIT | sudo tee "$1" >/dev/null
|
|
[Unit]
|
|
Description=NFS mount of ${2} at ${3}
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Mount]
|
|
What=${2}
|
|
Where=${3}
|
|
Type=nfs
|
|
Options=defaults,_netdev,rw,noatime
|
|
UNIT
|
|
}
|
|
|
|
show_mount_unit() { # <what> <where> (dry-run preview)
|
|
cat <<UNIT
|
|
[Unit]
|
|
Description=NFS mount of ${1} at ${2}
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Mount]
|
|
What=${1}
|
|
Where=${2}
|
|
Type=nfs
|
|
Options=defaults,_netdev,rw,noatime
|
|
UNIT
|
|
}
|
|
|
|
cmd_persist() {
|
|
local what="$1" where="$2"
|
|
validate_share "$what"
|
|
validate_dir "$where"
|
|
|
|
unit="$(systemd-escape --path --suffix=mount "$where")"
|
|
unit_file="${UNIT_DIR}/${unit}"
|
|
|
|
if [ -f "$unit_file" ]; then
|
|
if [ -t 0 ]; then
|
|
# FLAGGED DELTA: interactive overwrite now confirms first.
|
|
confirm "Unit ${unit} already exists — overwrite?" n ||
|
|
{ warn "Aborted — ${unit_file} left untouched"; return 1; }
|
|
else
|
|
warn "Overwriting existing unit ${unit}"
|
|
fi
|
|
fi
|
|
|
|
sudo mkdir -p "$where"
|
|
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
|
log "(dry-run) would write ${unit_file}:"
|
|
show_mount_unit "$what" "$where"
|
|
else
|
|
write_mount_unit "$unit_file" "$what" "$where"
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now "$unit"
|
|
|
|
# Verify the export actually mounted; roll back the unit if not.
|
|
local i mounted=1
|
|
for i in 1 2 3 4 5; do
|
|
if findmnt -r -n -o TARGET -t nfs,nfs4 2>/dev/null | grep -qxF "$where"; then
|
|
mounted=0
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
if [ "$mounted" -ne 0 ]; then
|
|
warn "Unit enabled but ${where} never appeared among NFS mounts — rolling back"
|
|
sudo systemctl disable "$unit" 2>/dev/null || true
|
|
sudo systemctl stop "$unit" 2>/dev/null || true
|
|
sudo rm -f "$unit_file"
|
|
sudo systemctl daemon-reload
|
|
err "Persistent mount failed — unit removed (${unit})"
|
|
fi
|
|
fi
|
|
log "Persistent NFS mount: ${what} → ${where} (${unit})"
|
|
notify_send "NFS mount persisted: ${what} → ${where}"
|
|
}
|
|
|
|
cmd_unpersist() {
|
|
local where="$1"
|
|
validate_dir "$where"
|
|
|
|
unit="$(systemd-escape --path --suffix=mount "$where")"
|
|
unit_file="${UNIT_DIR}/${unit}"
|
|
|
|
if [ ! -f "$unit_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. Mirror of cmd_unmount's guidance for
|
|
# a persisted unit under a non-escape-derived filename.
|
|
if persisted_nfs_at "$where"; then
|
|
warn "$where has a persistent NFS 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 nfs-client list"
|
|
else
|
|
warn "No systemd mount unit for $where (${unit})"
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
sudo systemctl disable "$unit" 2>/dev/null || true
|
|
sudo systemctl stop "$unit" 2>/dev/null || true
|
|
sudo rm -f "$unit_file"
|
|
sudo systemctl daemon-reload
|
|
log "Removed persistent NFS mount: $where"
|
|
notify_send "NFS persistent mount removed: $where"
|
|
}
|
|
|
|
# ── Interactive menu flows ─────────────────────────────────────
|
|
|
|
# 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_pick_export() { # <host> — stdout: server:export · rc 1 cancelled
|
|
local host="$1" idx exp
|
|
local -a exports=()
|
|
if mapfile -t exports < <(share_nfs_exports "$host") && [ "${#exports[@]}" -gt 0 ]; then
|
|
if idx="$(share_pick "Pick export on ${host}" "${exports[@]}")"; then
|
|
exp="${exports[$((idx - 1))]}"
|
|
else
|
|
return 1
|
|
fi
|
|
else
|
|
exp="$(share_ask_value "Export path on ${host} (e.g. /srv/media)")" || return 1
|
|
[ -n "$exp" ] || { warn "No export path given"; return 1; }
|
|
fi
|
|
case "$exp" in
|
|
/*) echo "${host}:${exp}" ;;
|
|
*) echo "${host}:/${exp}" ;;
|
|
esac
|
|
}
|
|
|
|
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() {
|
|
local mode="$1" host what where
|
|
host="$(share_ask_value "NFS server (host or IP)")" || return 1
|
|
[ -n "$host" ] || { warn "No server given"; return 1; }
|
|
|
|
if ! share_port_probe "$host" 2049; then
|
|
warn "${host} does not answer on TCP/2049 (nfsd down, or a firewall blocks it)."
|
|
confirm "Try anyway?" n || return 1
|
|
fi
|
|
|
|
what="$(menu_pick_export "$host")" || return 1
|
|
where="$(menu_ask_mountpoint "${what#*:}")" || return 1 # ${what#*:} = server-side export path
|
|
|
|
if [ "$mode" = "persist" ]; then
|
|
cmd_persist "$what" "$where"
|
|
else
|
|
cmd_mount "$what" "$where"
|
|
fi
|
|
}
|
|
|
|
menu_unmount() {
|
|
local idx row src where i
|
|
local -a tgts=() srcs=() items=()
|
|
# Same enumeration source as the `list` view (findmnt -t nfs,nfs4),
|
|
# 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 nfs,nfs4 2>/dev/null |
|
|
awk '{ src=$NF; $NF=""; sub(/[ \t]+$/, ""); print $0 "|" src }')
|
|
if [ "${#tgts[@]}" -eq 0 ]; then
|
|
log "No active NFS 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 NFS 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
|
|
local -a items=() paths=()
|
|
for uf in "${UNIT_DIR}"/*.mount; do
|
|
grep -q '^Type=nfs' "$uf" 2>/dev/null || continue
|
|
where="$(sed -n 's/^Where=//p' "$uf")"
|
|
[ -n "$where" ] || continue
|
|
paths+=("$where")
|
|
items+=("$where")
|
|
done
|
|
if [ "${#items[@]}" -gt 0 ]; then
|
|
idx="$(share_pick "Remove which persistent NFS mount?" "${items[@]}")" || return 1
|
|
where="${paths[$((idx - 1))]}"
|
|
else
|
|
where="$(share_ask_value "Local mountpoint whose unit 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 "NFS client" \
|
|
"Mount an export (one-shot)" \
|
|
"Persist an export (systemd .mount unit)" \
|
|
"List active NFS 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
|
|
}
|
|
|
|
cmd="${1:-}"
|
|
case "$cmd" in
|
|
-h|--help) usage ;;
|
|
""|menu)
|
|
run_menu
|
|
exit 0
|
|
;;
|
|
mount|unmount|list|persist|unpersist) ;;
|
|
*) err "Unknown command '$cmd' (see --help)" ;;
|
|
esac
|
|
|
|
case "$cmd" in
|
|
mount)
|
|
[ $# -ge 3 ] || err "Usage: pos share nfs-client mount <server:export> <local-dir>"
|
|
cmd_mount "$2" "$3"
|
|
;;
|
|
|
|
unmount)
|
|
[ $# -ge 2 ] || err "Usage: pos share nfs-client unmount <local-dir>"
|
|
cmd_unmount "$2"
|
|
;;
|
|
|
|
list)
|
|
cmd_list
|
|
;;
|
|
|
|
persist)
|
|
[ $# -ge 3 ] || err "Usage: pos share nfs-client persist <server:export> <local-dir>"
|
|
cmd_persist "$2" "$3"
|
|
;;
|
|
|
|
unpersist)
|
|
[ $# -ge 2 ] || err "Usage: pos share nfs-client unpersist <local-dir>"
|
|
cmd_unpersist "$2"
|
|
;;
|
|
esac
|