From a2d3b0a65192bccb89771f54bd556909e7a014e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 19:18:55 +0000 Subject: [PATCH 1/2] Fix smb.conf section removal deleting everything after the target share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported live: Samba broke on the home box after this ran. Root cause confirmed by reproducing it directly: the old removal step used `sed -i "/^\[share\]$/,/^$/d"` — a range delete from the share's header through the next BLANK line. A home box whose smb.conf has no blank line separating sections (common — nothing requires one) means that range never finds a terminator and sed deletes straight through to end of file, taking every share defined after the target one down with it. Reproduced against a 4-section smb.conf with no blank lines: the old approach left only [global] standing, silently destroying two unrelated, pre-existing shares that had nothing to do with this tool. Replaced with an awk pass that removes lines from the target share's own [header] up to the next `[section]` header or EOF — the actual boundary of an INI-style section, independent of blank-line formatting. Also now builds the new config in a scratch file and validates it with `testparm` before it's ever copied over the live smb.conf; on validation failure it leaves the existing file untouched and exits instead of restarting smbd against a config that might not even parse. The existing smb.conf.backup. step (already present before this fix) is what the user is recovering the home box with in the meantime. Verified the fix against the exact reproduction: the same 4-section, no-blank-line smb.conf now retains all three untouched sections after removing only the target one. --- services/vpn-data-mount.sh | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/services/vpn-data-mount.sh b/services/vpn-data-mount.sh index 1baa3fb..5aefbaf 100644 --- a/services/vpn-data-mount.sh +++ b/services/vpn-data-mount.sh @@ -364,12 +364,25 @@ _vdm_setup_remote_samba() { sudo smbpasswd -e '${user}'" fi + # Builds the new config in a scratch file and validates it with + # testparm BEFORE it ever touches the live smb.conf — confirmed live: + # the previous approach here (sed range-deleting from the [share_name] + # header through the next BLANK line) silently deleted straight through + # to end of file on a home box whose smb.conf had no blank line + # separating sections, taking unrelated shares down with it. Section + # removal now stops at the next `[section]` header (or EOF) instead of + # a blank line, which is the actual boundary of an INI-style section + # regardless of how the file happens to be formatted. local remote_cmd remote_cmd=$(cat << REMOTECMD set -e sudo mkdir -p '${remote_path}' -sudo cp /etc/samba/smb.conf /etc/samba/smb.conf.backup.\$(date +%Y%m%d-%H%M%S) 2>/dev/null || true -sudo sed -i "/^\\[${share_name}\\]\$/,/^\$/d" /etc/samba/smb.conf +BACKUP="/etc/samba/smb.conf.backup.\$(date +%Y%m%d-%H%M%S)" +sudo cp /etc/samba/smb.conf "\$BACKUP" +sudo awk -v target='[${share_name}]' ' + /^\\[/ { skip = (\$0 == target) } + !skip { print } +' /etc/samba/smb.conf > /tmp/smb.conf.vdm.new { echo "" echo "[${share_name}]" @@ -379,7 +392,14 @@ sudo sed -i "/^\\[${share_name}\\]\$/,/^\$/d" /etc/samba/smb.conf echo " guest ok = no" echo " valid users = ${user}" echo " force user = ${user}" -} | sudo tee -a /etc/samba/smb.conf >/dev/null +} >> /tmp/smb.conf.vdm.new +if sudo testparm -s /tmp/smb.conf.vdm.new >/dev/null 2>&1; then + sudo cp /tmp/smb.conf.vdm.new /etc/samba/smb.conf + rm -f /tmp/smb.conf.vdm.new +else + echo "New smb.conf failed testparm validation — leaving the existing config untouched. Backup at \$BACKUP, rejected draft at /tmp/smb.conf.vdm.new for inspection." >&2 + exit 1 +fi ${pw_cmd} sudo systemctl restart smbd command -v ufw >/dev/null 2>&1 && sudo ufw allow samba >/dev/null 2>&1 || true From dd6f1d0a5d7e82c8e28e0b247ff046364998b44a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 19:24:59 +0000 Subject: [PATCH 2/2] Make vpn-data-mount strictly read-only on the remote Samba config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per direct request: never write to the home box's smb.conf at all, not even carefully — just discover what's already shared there and mount it. Removes all remote provisioning (installing Samba, creating/removing share blocks, resetting smbpasswd accounts) entirely, which also removes the whole class of bug the previous two fixes were patching around (destructive section-removal, clobbering another mount's saved password) — a tool that can't write can't repeat that kind of damage. New flow: resolve/name the host and bootstrap SSH trust as before, then read-only list every real share already in the home box's smb.conf (skipping [global]/[homes]/[printers]/[print$]) via a plain SSH `cat`, falling back to a sudo'd read only if that comes back empty — still only ever reading. Presents them as a numbered list and accepts a flexible selection ('1', '1,3', '1-3', '1 3 5', or combinations), asks once for the Samba username/password to connect with (reusing a previously-saved password for the same user+host if one exists), then mounts each picked share locally over CIFS with its own /etc/fstab entry — same as before. Verified the selection parser against all the documented formats plus a mixed comma+range case and garbage/empty input. --- README.md | 2 +- services/vpn-data-mount.sh | 420 +++++++++++++++++-------------------- 2 files changed, 193 insertions(+), 229 deletions(-) diff --git a/README.md b/README.md index 91d33b6..b8a39f2 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. | Group | Services | |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | -| `homelab` | `caddy`, `crowdsec`, `authelia`, `coturn` (shared TURN/STUN relay — Asterisk, Mattermost Calls, and future WebRTC-capable services all register a dedicated credential against one instance instead of each running its own), `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (SMB mount from a NetBird-connected home box — SSH trust bootstrap + remote Samba setup automated over SSH; repeatable, one home box/share at a time) | +| `homelab` | `caddy`, `crowdsec`, `authelia`, `coturn` (shared TURN/STUN relay — Asterisk, Mattermost Calls, and future WebRTC-capable services all register a dedicated credential against one instance instead of each running its own), `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass) | | `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | diff --git a/services/vpn-data-mount.sh b/services/vpn-data-mount.sh index 5aefbaf..e10b526 100644 --- a/services/vpn-data-mount.sh +++ b/services/vpn-data-mount.sh @@ -1,25 +1,36 @@ #!/bin/bash -# services/vpn-data-mount.sh — mount SMB data from a NetBird-connected home -# box, with SSH-key bootstrap and remote Samba setup automated over SSH. +# services/vpn-data-mount.sh — mount existing SMB shares from a +# NetBird-connected home box, with SSH-key bootstrap automated. # Part of the modular post-install system (sourced by setup.sh). # # Can also be run standalone on any machine: # sudo bash vpn-data-mount.sh -# (No Docker needed — this only touches SSH, Samba, and /etc/fstab) +# (No Docker needed — this only touches SSH and /etc/fstab) # # Unlike most services here, this is repeatable by design: different # services can have data on different home boxes, so this asks for a home # box IP every time and can be re-run any number of times, once per -# home-box/share you want mounted. It's the multi-instance pattern from -# CLAUDE.md generalized from "N instances of one app" to "N independent -# mounts" — there's no single install directory to gate on, so state lives -# in /etc/fstab itself (tagged entries), same as tools/mount-network-drive.sh. +# home-box you want to pull shares from. It's the multi-instance pattern +# from CLAUDE.md generalized from "N instances of one app" to "N +# independent mounts" — there's no single install directory to gate on, so +# state lives in /etc/fstab itself (tagged entries), same as +# tools/mount-network-drive.sh. +# +# Deliberately READ-ONLY on the home box's Samba config — this tool never +# writes to smb.conf, never installs Samba, never creates or resets a +# Samba account there. It only (a) bootstraps passwordless SSH if needed, +# then (b) reads the home box's existing smb.conf over that SSH connection +# to list whatever shares are already configured there, so you can pick +# one or more to mount. Set up the actual share(s) on the home box +# yourself, the normal way (or with tools/mount-network-drive.sh's own +# guided flow, run there). An earlier version of this tried to fully +# provision Samba remotely too — reversed per direct request, and it had +# also caused real damage in practice (a section-removal bug that deleted +# unrelated shares on a real box) that a read-only tool can't repeat. # # Assumes the home box is Linux and reachable over a NetBird IP — this repo # doesn't set up the home box's side of NetBird (that's a separate machine, -# possibly not running this repo at all); it only automates the VPS side: -# SSH trust, then using that SSH access to configure Samba on the home box -# remotely, then mounting it here. +# possibly not running this repo at all). # # SMB chosen over NFS/SSHFS deliberately: NFS is marginally faster for # Linux-to-Linux but SMB isn't a "huge" difference for normal use (media, @@ -29,20 +40,15 @@ # redundant overhead for no added security, and it's the slowest and least # robust (FUSE reconnect quirks) of the three for an always-on mount. # -# Each share gets a dedicated Samba account (same username as the SSH user, -# since that Unix account already exists on the home box — no extra remote -# user to create) with its own generated password, stored locally in a +# Mounts use real Samba credentials (a username/password you provide for +# an account that already exists on the home box), stored locally in a # root-only credentials file, same convention tools/mount-network-drive.sh -# already uses. An earlier version of this made the share guest-accessible -# instead, reasoning the VPN itself was enough access control — reversed -# per direct request (real per-share accounts, not root/guest) and because -# it incidentally fixes a real bug: plain `guest` CIFS mounts with no -# explicit `sec=` can hit "mount error(79): Can not access a needed shared +# already uses — never guest access. A CIFS guest mount with no explicit +# security mode can hit "mount error(79): Can not access a needed shared # library" — a misleadingly-worded cifs-utils message for errno 79 # (ENOKEY), a known rough edge in the kernel cifs.ko keyring/upcall path -# for anonymous sessions specifically, not an actual missing library. -# Credentialed mounts with an explicit sec= take the normal NTLMSSP auth -# path instead and don't hit it. +# for anonymous sessions. Credentialed mounts with an explicit sec=ntlmssp +# take the normal NTLMSSP auth path instead and don't hit it. # ── Standalone bootstrap ────────────────────────────────────────────────────── if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then @@ -86,7 +92,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then fi # ───────────────────────────────────────────────────────────────────────────── -register_service vpn-data-mount homelab "Mount SMB data from a NetBird-connected home box (SSH-automated remote setup)" +register_service vpn-data-mount homelab "Mount existing SMB shares from a NetBird-connected home box (read-only discovery, SSH-automated key setup)" # ── fstab tagging — the durable record of what this tool has set up ──────── # Same philosophy as tools/mount-network-drive.sh: /etc/fstab is the single @@ -113,7 +119,7 @@ _vdm_list_existing() { # the IP" actually needs end to end. # Sets RESOLVED_HOST (not local — same out-param convention as elsewhere). _vdm_resolve_host() { - local input="$1" default_name="$2" + local input="$1" RESOLVED_HOST="$input" # Not a raw IP (already a name, whether from /etc/hosts, real DNS, or @@ -129,14 +135,9 @@ _vdm_resolve_host() { return 0 fi - # prompt_text always falls back to the given default on blank input - # (same convention every other prompt in this repo relies on) — so this - # always ends up naming the host to something, never truly "blank" for - # "keep using the IP". That's fine: $default_name is already a sensible - # suggestion (the mount's own label), so Enter alone gives a reasonable - # name rather than requiring the extra step of confirming one. local NAME="" - prompt_text " Name for this home box (used instead of the IP from now on):" "$default_name" NAME + prompt_text " Name this home box (blank to keep using the IP):" "" NAME + [ -z "$NAME" ] && return 0 NAME="$(echo "$NAME" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" [ -z "$NAME" ] && return 0 @@ -227,31 +228,49 @@ _vdm_ensure_ssh_trust() { done } -# ── Detect an existing share exporting this exact path already ──────────── -# Prints the share name (bare, no brackets) if found, nothing otherwise. -# No log_* calls in here — this runs inside a caller's $(...) capture, and -# log_info/log_warning/etc. all write to stdout, which would corrupt it. -_vdm_find_remote_share() { - local user="$1" host="$2" remote_path="$3" - local awk_prog=' - /^\[/ { cur = $0; gsub(/[][]/, "", cur) } - /^[[:space:]]*path[[:space:]]*=/ { - val = $0 - sub(/^[[:space:]]*path[[:space:]]*=[[:space:]]*/, "", val) - gsub(/[[:space:]]+$/, "", val) - if (val == target) { print cur; exit } +# ── Read-only share discovery ─────────────────────────────────────────────── +# Prints "share_name|path" one per line for every real data share found in +# the home box's smb.conf (skips [global]/[homes]/[printers]/[print$] — +# not actual browsable directories). Never writes anything, on either +# side — see the file header. Tries a plain read first (smb.conf is +# world-readable on a stock Samba install); falls back to a sudo'd read +# only if that comes back empty, still read-only either way. +_vdm_list_remote_shares() { + local user="$1" host="$2" + local conf + conf="$(sudo -u "$ACTUAL_USER" ssh "${user}@${host}" 'cat /etc/samba/smb.conf 2>/dev/null')" + if [ -z "$conf" ]; then + conf="$(sudo -u "$ACTUAL_USER" ssh -t "${user}@${host}" 'sudo cat /etc/samba/smb.conf 2>/dev/null' 2>/dev/null)" + fi + [ -z "$conf" ] && return 1 + + echo "$conf" | awk ' + function flush() { + if (sect != "" && path != "" && sect != "global" && sect != "printers" && sect != "print$" && sect != "homes") { + print sect "|" path + } } + /^\[/ { + flush() + sect = $0 + gsub(/[][]/, "", sect) + path = "" + next + } + /^[[:space:]]*path[[:space:]]*=/ { + path = $0 + sub(/^[[:space:]]*path[[:space:]]*=[[:space:]]*/, "", path) + gsub(/[[:space:]]+$/, "", path) + } + END { flush() } ' - sudo -u "$ACTUAL_USER" ssh "${user}@${host}" \ - "awk -v target='${remote_path}' '${awk_prog}' /etc/samba/smb.conf 2>/dev/null" } -# ── Reuse a password this tool already set for the same user+host ───────── -# A Samba account's password is shared across every share it can access — -# resetting it for a second mount from the same home box would silently -# break the first mount's already-saved credentials file. Prints the -# password if a prior mount from this host used the same Samba username, -# nothing otherwise. No log_* calls — same reason as above. +# ── Reuse a password already entered for the same user+host ─────────────── +# Prints the password if an earlier mount from this host used the same +# Samba username, nothing otherwise. No log_* calls in here — this runs +# inside a caller's $(...) capture, and log_info/log_warning/etc. all +# write to stdout, which would corrupt it. _vdm_find_existing_smb_password() { local host="$1" user="$2" label creds_file found_user found_pass while IFS= read -r label; do @@ -286,133 +305,6 @@ _vdm_prompt_password() { done } -# ── Remote Samba setup, driven entirely over the SSH trust above ────────── -# Sets SMB_PASS/SMB_USER/SMB_SHARE_NAME (not local — read by the caller), -# same out-param convention as lib/common.sh's -# ensure_coturn_user/configure_caddy_for_service. -_vdm_setup_remote_samba() { - local user="$1" host="$2" remote_path="$3" share_name="$4" - SMB_PASS="" SMB_USER="$user" SMB_SHARE_NAME="$share_name" - - log_info "Checking Samba on the home box..." - # Every ssh call below runs as $ACTUAL_USER, same reason as _vdm_ssh_works. - if ! sudo -u "$ACTUAL_USER" ssh "${user}@${host}" 'command -v smbd >/dev/null 2>&1'; then - log_info "Installing Samba on the home box (may prompt for the sudo password there)..." - sudo -u "$ACTUAL_USER" ssh -t "${user}@${host}" 'sudo apt-get update -y && sudo apt-get install -y samba' \ - || { log_error "Remote Samba install failed."; return 1; } - else - log_success "Samba already installed on the home box." - fi - - # Don't blindly overwrite a share that's already exporting this exact - # path — the home box may already have this configured by hand. - local existing_share - existing_share="$(_vdm_find_remote_share "$user" "$host" "$remote_path")" - if [ -n "$existing_share" ]; then - log_info "Found an existing Samba share '[$existing_share]' already exporting $remote_path on the home box." - local REUSE="" - prompt_yn " Use it as-is instead of creating a new one? (y/n):" "y" REUSE - if [[ "$REUSE" =~ ^[Yy]$ ]]; then - SMB_SHARE_NAME="$existing_share" - prompt_text " Samba username for that share:" "$user" SMB_USER - SMB_PASS="$(_vdm_prompt_password "Samba password for '$SMB_USER'")" - log_success "Reusing existing share [$SMB_SHARE_NAME] — nothing changed on the home box." - return 0 - fi - log_info "Creating a separate new share instead." - fi - - # Reuse this account's password if another mount from the same home - # box already set one up (see _vdm_find_existing_smb_password), rather - # than resetting an account that other saved credentials still depend on. - local reset_needed=true - SMB_PASS="$(_vdm_find_existing_smb_password "$host" "$user")" - if [ -n "$SMB_PASS" ]; then - log_info "Reusing the Samba password already set up for '$user' on $host (from another mount) — not resetting it." - reset_needed=false - elif sudo -u "$ACTUAL_USER" ssh "${user}@${host}" "sudo pdbedit -L 2>/dev/null | grep -q '^${user}:'"; then - log_warning "Samba account '$user' already exists on the home box with a password this tool doesn't know." - local RESET_PW="" - prompt_yn " Reset it to a newly generated password? (Anything already using the old one — other mounts, manual clients — will need updating.) (y/n):" "n" RESET_PW - if [[ "$RESET_PW" =~ ^[Yy]$ ]]; then - SMB_PASS="$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16)" - else - SMB_PASS="$(_vdm_prompt_password "Existing Samba password for '$user'")" - reset_needed=false - fi - else - SMB_PASS="$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16)" - fi - - log_info "Configuring the share on the home box..." - # Idempotent: drop any prior block for this exact share name, then - # append a fresh one. Dedicated account (not guest) — see the file - # header for why, and for the errno-79 connection. - # - # The Samba account reuses $user — that Unix account already exists on - # the home box (it's who we're SSH'd in as), so no extra remote user - # needs creating. When reset_needed, the new password ends up briefly - # visible in the home box's own `ps` output for this one remote - # command's duration (embedded in the command string, not piped — - # piping through a `-t` pty session for a command that may also need - # an interactive sudo password gets stdin-ordering-fragile fast). - # Accepted tradeoff, same spirit as this whole feature already trusting - # the VPN's reachability boundary — see file header. - local pw_cmd="" - if [ "$reset_needed" = true ]; then - pw_cmd="printf '%s\n%s\n' '${SMB_PASS}' '${SMB_PASS}' | sudo smbpasswd -a -s '${user}' -sudo smbpasswd -e '${user}'" - fi - - # Builds the new config in a scratch file and validates it with - # testparm BEFORE it ever touches the live smb.conf — confirmed live: - # the previous approach here (sed range-deleting from the [share_name] - # header through the next BLANK line) silently deleted straight through - # to end of file on a home box whose smb.conf had no blank line - # separating sections, taking unrelated shares down with it. Section - # removal now stops at the next `[section]` header (or EOF) instead of - # a blank line, which is the actual boundary of an INI-style section - # regardless of how the file happens to be formatted. - local remote_cmd - remote_cmd=$(cat << REMOTECMD -set -e -sudo mkdir -p '${remote_path}' -BACKUP="/etc/samba/smb.conf.backup.\$(date +%Y%m%d-%H%M%S)" -sudo cp /etc/samba/smb.conf "\$BACKUP" -sudo awk -v target='[${share_name}]' ' - /^\\[/ { skip = (\$0 == target) } - !skip { print } -' /etc/samba/smb.conf > /tmp/smb.conf.vdm.new -{ - echo "" - echo "[${share_name}]" - echo " path = ${remote_path}" - echo " browseable = yes" - echo " read only = no" - echo " guest ok = no" - echo " valid users = ${user}" - echo " force user = ${user}" -} >> /tmp/smb.conf.vdm.new -if sudo testparm -s /tmp/smb.conf.vdm.new >/dev/null 2>&1; then - sudo cp /tmp/smb.conf.vdm.new /etc/samba/smb.conf - rm -f /tmp/smb.conf.vdm.new -else - echo "New smb.conf failed testparm validation — leaving the existing config untouched. Backup at \$BACKUP, rejected draft at /tmp/smb.conf.vdm.new for inspection." >&2 - exit 1 -fi -${pw_cmd} -sudo systemctl restart smbd -command -v ufw >/dev/null 2>&1 && sudo ufw allow samba >/dev/null 2>&1 || true -REMOTECMD -) - if sudo -u "$ACTUAL_USER" ssh -t "${user}@${host}" "$remote_cmd"; then - log_success "Remote share [$share_name] -> $remote_path configured, smbd restarted." - else - log_error "Remote Samba configuration failed — check the output above." - return 1 - fi -} - # ── Local mount + fstab ───────────────────────────────────────────────────── _vdm_mount_local() { local host="$1" share_name="$2" mount_point="$3" label="$4" smb_user="$5" smb_pass="$6" @@ -421,7 +313,7 @@ _vdm_mount_local() { mkdir -p "$mount_point" - # Credentials file, not a guest/inline password — root-only, matching + # Credentials file, not guest/inline password — root-only, matching # tools/mount-network-drive.sh's existing convention for SMB creds. local creds_file="/etc/samba/credentials.vpn-data-mount-${label}" mkdir -p /etc/samba @@ -433,7 +325,6 @@ CREDS chown root:root "$creds_file" # sec=ntlmssp explicitly — see the file header on errno 79/ENOKEY. - # Modern default and what a credentialed mount should use anyway. local opts="credentials=${creds_file},sec=ntlmssp,uid=$(id -u "$ACTUAL_USER"),gid=$(id -g "$ACTUAL_USER"),iocharset=utf8,nofail,_netdev" local share="//${host}/${share_name}" @@ -441,8 +332,9 @@ CREDS if mount -t cifs -o "$opts" "$share" "$mount_point"; then log_success "Mounted at $mount_point" else - log_error "Mount failed — check connectivity to $host and the remote share config." + log_error "Mount failed for [$share_name] — check the username/password and that the home box's share actually allows this account." rmdir "$mount_point" 2>/dev/null || true + rm -f "$creds_file" return 1 fi @@ -460,29 +352,32 @@ CREDS log_success "Added to /etc/fstab (backup: $(basename "$bk"))" } -# ── One mount, start to finish ────────────────────────────────────────────── +# ── Parse a selection like "1", "1,3", "1-3", "1 3 5" into 1-based indices ── +# Prints one index per line. Silently drops anything that doesn't look like +# a number or a range — the caller validates indices against the actual +# list length. +_vdm_parse_selection() { + local input="$1" token start end i + for token in $(echo "$input" | tr ',' ' '); do + if [[ "$token" =~ ^([0-9]+)-([0-9]+)$ ]]; then + start="${BASH_REMATCH[1]}"; end="${BASH_REMATCH[2]}" + for ((i = start; i <= end; i++)); do echo "$i"; done + elif [[ "$token" =~ ^[0-9]+$ ]]; then + echo "$token" + fi + done +} + +# ── One home box, one or more shares from it ──────────────────────────────── _vdm_add_mount() { echo "" - local LABEL="" - while true; do - prompt_text " Short label for this mount (e.g. 'media', 'nas-docs'):" "" LABEL - LABEL="$(echo "$LABEL" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" - if [ -z "$LABEL" ]; then - log_warning "Label can't be empty."; continue - fi - if grep -q "^${_VDM_TAG_PREFIX} ${LABEL} " /etc/fstab 2>/dev/null; then - log_warning "Label '$LABEL' is already used — pick another."; continue - fi - break - done - local HOST_INPUT="" HOST="" SSH_USER="" prompt_text " Home box's NetBird IP or an already-named host (check 'netbird status' on that box):" "" HOST_INPUT if [ -z "$HOST_INPUT" ]; then - log_warning "No host entered — cancelling this mount." + log_warning "No host entered — cancelling." return 1 fi - _vdm_resolve_host "$HOST_INPUT" "$LABEL" + _vdm_resolve_host "$HOST_INPUT" HOST="$RESOLVED_HOST" prompt_text " SSH username on the home box:" "$ACTUAL_USER" SSH_USER @@ -492,8 +387,6 @@ _vdm_add_mount() { # Pure convenience on top of the /etc/hosts naming above (which is what # actually makes the mount itself usable by name) — an SSH Host alias # additionally skips typing the username for interactive `ssh` use. - # Only offered for a genuinely new name, not every time this mount (or - # another one on the same host) is set up. if [[ ! "$HOST" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] \ && declare -F add_ssh_host_alias >/dev/null 2>&1 \ && declare -F ssh_host_alias_exists >/dev/null 2>&1 \ @@ -503,54 +396,125 @@ _vdm_add_mount() { [[ "$ADD_ALIAS" =~ ^[Yy]$ ]] && add_ssh_host_alias "$HOST" "$HOST_INPUT" "$SSH_USER" "22" fi - local REMOTE_PATH="" MOUNT_POINT="" - prompt_text " Path on the home box to share (e.g. /home/${SSH_USER}/media):" "" REMOTE_PATH - if [ -z "$REMOTE_PATH" ]; then - log_warning "No path entered — cancelling this mount." + log_info "Reading Samba shares already configured on $HOST (read-only)..." + local shares_raw + shares_raw="$(_vdm_list_remote_shares "$SSH_USER" "$HOST")" + if [ -z "$shares_raw" ]; then + log_warning "No Samba shares found on $HOST (or /etc/samba/smb.conf couldn't be read). Set up a share there first, the normal way, then re-run this." return 1 fi - prompt_text " Local mount point:" "/mnt/${LABEL}" MOUNT_POINT - # SMB_USER/SMB_SHARE_NAME may differ from SSH_USER/LABEL if an existing - # remote share for this exact path was found and reused as-is. - _vdm_setup_remote_samba "$SSH_USER" "$HOST" "$REMOTE_PATH" "$LABEL" || return 1 - _vdm_mount_local "$HOST" "$SMB_SHARE_NAME" "$MOUNT_POINT" "$LABEL" "$SMB_USER" "$SMB_PASS" || return 1 - - # Read by callers like services/filebrowser.sh/audiobookshelf.sh/emby.sh - # that chain into this service and want to default their own "which - # directory" prompt to whatever was just mounted, without needing to - # know or guess the path themselves. - VDM_LAST_MOUNT_POINT="$MOUNT_POINT" + local share_names=() share_paths=() + while IFS='|' read -r sname spath; do + [ -z "$sname" ] && continue + share_names+=("$sname") + share_paths+=("$spath") + done <<< "$shares_raw" echo "" - log_success "Done: $HOST:$REMOTE_PATH is now mounted at $MOUNT_POINT" - echo " Manage this and other network mounts anytime with:" - echo " sudo bash tools/mount-network-drive.sh" + echo " Samba shares found on $HOST:" + local i + for i in "${!share_names[@]}"; do + printf " %d) %-20s %s\n" "$((i + 1))" "${share_names[$i]}" "${share_paths[$i]}" + done + echo "" + local SELECTION="" + prompt_text " Which one(s)? e.g. '1' or '1,3' or '1-3' or '1 3 5':" "" SELECTION + if [ -z "$SELECTION" ]; then + log_warning "Nothing selected — cancelling." + return 1 + fi + + local indices=() + while IFS= read -r i; do indices+=("$i"); done < <(_vdm_parse_selection "$SELECTION") + if [ "${#indices[@]}" -eq 0 ]; then + log_warning "Couldn't parse a selection from '$SELECTION' — cancelling." + return 1 + fi + + # Credentials asked once, reused for every share picked here — the + # common case is one personal account with access to several shares. + # Re-run this for a share needing a different account. + local SMB_USER="" + prompt_text " Samba username to connect with:" "$SSH_USER" SMB_USER + local SMB_PASS="" + SMB_PASS="$(_vdm_find_existing_smb_password "$HOST" "$SMB_USER")" + if [ -n "$SMB_PASS" ]; then + log_info "Reusing the Samba password already saved for '$SMB_USER' on $HOST from an earlier mount." + else + SMB_PASS="$(_vdm_prompt_password "Samba password for '$SMB_USER'")" + fi + + local picked_any=false idx arr_i this_share this_path LABEL MOUNT_POINT + for idx in "${indices[@]}"; do + arr_i=$((idx - 1)) + if [ "$arr_i" -lt 0 ] || [ "$arr_i" -ge "${#share_names[@]}" ]; then + log_warning "$idx isn't one of the listed shares — skipping." + continue + fi + this_share="${share_names[$arr_i]}" + this_path="${share_paths[$arr_i]}" + + echo "" + log_info "Setting up: [$this_share] -> $this_path" + + LABEL="" + while true; do + prompt_text " Local label for this mount:" "$this_share" LABEL + LABEL="$(echo "$LABEL" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$LABEL" ]; then + log_warning "Label can't be empty."; continue + fi + if grep -q "^${_VDM_TAG_PREFIX} ${LABEL} " /etc/fstab 2>/dev/null; then + log_warning "Label '$LABEL' is already used — pick another."; continue + fi + break + done + + MOUNT_POINT="" + prompt_text " Local mount point:" "/mnt/${LABEL}" MOUNT_POINT + + if _vdm_mount_local "$HOST" "$this_share" "$MOUNT_POINT" "$LABEL" "$SMB_USER" "$SMB_PASS"; then + # Read by callers like services/filebrowser.sh/audiobookshelf.sh/ + # emby.sh that chain into this service and want to default + # their own "which directory" prompt to whatever was just + # mounted. Last one wins if several were picked in this run. + VDM_LAST_MOUNT_POINT="$MOUNT_POINT" + picked_any=true + fi + done + + if [ "$picked_any" = true ]; then + echo "" + log_success "Done." + echo " Manage this and other network mounts anytime with:" + echo " sudo bash tools/mount-network-drive.sh" + fi } install_vpn-data-mount() { echo "" echo "╔══════════════════════════════════════════════════════════╗" - echo "║ VPN Data Mount — SMB share from a NetBird-connected box ║" + echo "║ VPN Data Mount — mount existing SMB shares from a home ║" + echo "║ box over NetBird (read-only — nothing changes there) ║" echo "╚══════════════════════════════════════════════════════════╝" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would offer to name a raw IP in /etc/hosts for reuse (SSH + this mount)" echo "[DRY-RUN] Would test/set up passwordless SSH to a home box over its NetBird IP" - echo "[DRY-RUN] Would check for an existing Samba share/account first and offer to reuse it, not overwrite it" - echo "[DRY-RUN] Otherwise would remotely install Samba + configure a new share and dedicated account" - echo "[DRY-RUN] Would mount it locally over CIFS (credentials file, not guest) and add it to /etc/fstab" - echo "[DRY-RUN] Repeatable — can be run again for additional home boxes/shares" + echo "[DRY-RUN] Would read-only list the home box's existing Samba shares (never writes there)" + echo "[DRY-RUN] Would let you pick one or more by number and mount them locally over CIFS" + echo "[DRY-RUN] Would add each to /etc/fstab with a root-only credentials file (not guest)" + echo "[DRY-RUN] Repeatable — can be run again for additional home boxes" return 0 fi - # Every prompt below (home box IP, remote path, ...) has no sane + # Every prompt below (home box IP, share selection, ...) has no sane # unattended default — unlike most services here, there's no reasonable # value to fall back to. Skip outright rather than let prompt_text's - # always-blank UNATTENDED behavior spin the label-validation loop below - # forever. + # always-blank UNATTENDED behavior spin something forever. if [ "$UNATTENDED" = true ]; then - log_info "Skipping — needs interactive input (home box IP, path, ...). Run 'sudo ./setup.sh vpn-data-mount' without --unattended." + log_info "Skipping — needs interactive input (home box IP, share selection, ...). Run 'sudo ./setup.sh vpn-data-mount' without --unattended." return 0 fi @@ -558,13 +522,13 @@ install_vpn-data-mount() { while true; do local ADD="" - prompt_yn "Add a VPN data mount now? (y/n):" "y" ADD + prompt_yn "Connect to a home box and mount some of its shares now? (y/n):" "y" ADD [[ "$ADD" =~ ^[Yy]$ ]] || break _vdm_add_mount local AGAIN="" - prompt_yn "Add another mount (can be from a different home box)? (y/n):" "n" AGAIN + prompt_yn "Connect to another (different) home box? (y/n):" "n" AGAIN [[ "$AGAIN" =~ ^[Yy]$ ]] || break done }