#!/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 < [args] Mount and manage SMB/CIFS shares from remote servers (cifs-utils). Commands: mount [user] One-shot mount (creates local-dir if needed) unmount Unmount the share (idempotent: rc 0 when nothing is mounted) list Show active + persistent SMB mounts persist [user] Persistent mount via systemd .mount + .automount units (mounts on first access — never blocks boot) unpersist 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 (e.g. //10.0.0.5/media)" ;; *) err "Invalid share '$1' — expected (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=? 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() { # — 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 - 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 2. user missing from the Samba passdb: pos share smb-server adduser 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/" ;; *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 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() { # [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 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() { # [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 </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 </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 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() { # — 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 `n=new`, and typing n runs the # create-new-dir flow below. share_pick cannot intercept `n` (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}], n=new, 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 ;; n | N) made="$(ask_new_mountpoint)" && { echo "$made"; return 0; } continue # declined/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 } # Create-new-dir flow behind the picker's `n` key. Validates the shape # (absolute, no trailing slash), confirm-gates the creation, then mkdir -p. # Any decline, invalid input, EOF or mkdir failure is a warning + rc 1 — # the picker redraws, the tool never aborts. ask_new_mountpoint() { # stdout: created dir · 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 "New 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 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 dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1 [ -n "$dir" ] || { warn "No mountpoint given"; return 1; } echo "$dir" } 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 [user]" cmd_mount "$2" "$3" "${4:-}" ;; unmount) [ $# -ge 2 ] || err "Usage: pos share smb-client unmount " cmd_unmount "$2" ;; list) cmd_list ;; persist) [ $# -ge 3 ] || err "Usage: pos share smb-client persist [user]" cmd_persist "$2" "$3" "${4:-}" ;; unpersist) [ $# -ge 2 ] || err "Usage: pos share smb-client unpersist " cmd_unpersist "$2" ;; esac