#!/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 < [args] Mount and manage NFS shares from remote servers (nfs-common). Commands: mount One-shot mount (creates local-dir if needed) unmount Unmount the share (idempotent: rc 0 when nothing is mounted) list Show active NFS mounts persist Persistent mount via a systemd .mount unit (ordered after network-online.target) unpersist 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 (e.g. 10.0.0.5:/srv/data)" ;; *:*) ;; *) err "Invalid share '$what' — expected (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=? Sets PERSISTED_UNIT. persisted_nfs_at() { # — 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() { # cat </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() { # (dry-run preview) cat </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() { # — 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 " cmd_mount "$2" "$3" ;; unmount) [ $# -ge 2 ] || err "Usage: pos share nfs-client unmount " cmd_unmount "$2" ;; list) cmd_list ;; persist) [ $# -ge 3 ] || err "Usage: pos share nfs-client persist " cmd_persist "$2" "$3" ;; unpersist) [ $# -ge 2 ] || err "Usage: pos share nfs-client unpersist " cmd_unpersist "$2" ;; esac