#!/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}" 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) 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" if timeout 3 bash -c "exec 3<>/dev/tcp/${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}' } # 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 log "Nothing mounted at $where" exit 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"; exit 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 log "No persistent SMB mount for $where — nothing to do" exit 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 ───────────────────────────────────── menu_ask_mountpoint() { # stdout: absolute path · rc 1 cancelled local idx dir cand local -a cands=() if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then if idx="$(share_pick "Mountpoint" "${cands[@]}")"; then cand="${cands[$((idx - 1))]}" dir="${cand%% (*}" # strip "(mounted fstype)" annotation 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}" where="$(menu_ask_mountpoint)" || return 1 if [ "$mode" = "persist" ]; then cmd_persist "$what" "$where" "$user" else cmd_mount "$what" "$where" "$user" fi } menu_unmount() { local idx row where local -a targets=() if mapfile -t rows < <(findmnt -rnf -t cifs -o SOURCE,TARGET 2>/dev/null) && [ "${#rows[@]}" -gt 0 ]; then for row in "${rows[@]}"; do targets+=("${row##* }") # last field of raw mode = TARGET done idx="$(share_pick "Unmount which SMB mount?" "${targets[@]}")" || return 1 where="${targets[$((idx - 1))]}" else where="$(share_ask_value "Local mountpoint to unmount")" || return 1 [ -n "$where" ] || return 1 fi 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 1) menu_mount ephemeral ;; 2) menu_mount persist ;; 3) cmd_list ;; 4) menu_unmount ;; 5) menu_unpersist ;; 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