#!/bin/bash # 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 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 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). # # 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, # docs, moderate datasets — the gap shows up mainly on many-small-files # workloads). SSHFS was ruled out because the VPN tunnel already encrypts # everything — SSHFS's own SSH-layer encryption on top of that is pure # 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. # # 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 — never guest access. # # "mount error(79): Can not access a needed shared library" is NOT a # credentials problem, guest-vs-authenticated problem, or a mislabeled # ENOKEY — errno 79 is literally ELIBACC, and mount.cifs prints glibc's # literal strerror() text for it. Confirmed live: this recurred identically # with a real Samba account and a verified, correctly-captured password # (see the read()/IFS note above — that was a real bug too, just not this # one), and again after keyutils was already installed — so it's not that # either, at least not on every host. Two independent real causes share # this exact errno/message, both fixed defensively below: # 1. `keyutils` missing on the client. cifs-utils hard-depends on the # libkeyutils1 *library* but only Recommends the keyutils *package* # (/sbin/request-key + /etc/request-key.d/*.conf, what the kernel's # upcall actually invokes) — minimal cloud images that disable # install-recommends silently skip it. # 2. The hardcoded `iocharset=utf8` mount option needs the kernel's # nls_utf8 module. Confirmed live on a stock Ubuntu 6.8.0-137-generic # VPS kernel: `modprobe nls_utf8` → "FATAL: Module nls_utf8 not found" # — not loadable, not built in, just absent from that kernel build. # Every mount asking for that codepage fails with errno 79 regardless # of credentials. `_vdm_mount_local` probes for it with a harmless # `modprobe` and only adds `iocharset=utf8` if it actually loads; # otherwise it warns and mounts without it (kernel falls back to its # build's nls_default — fine for ASCII-heavy filenames, the common # case for a home-data share; non-ASCII filenames may not round-trip # perfectly on a kernel missing this module, which is a kernel # limitation this script can't paper over further). # # Optional: a gocryptfs decrypt layer on top of the plain CIFS mount, for # when "available to the VPS" and "opaque to the VPS's operator/anyone with # disk access to it" both matter. Set up the encrypted store on the home # box first with tools/gocryptfs-setup-home.sh (that tool's header explains # the actual threat model this buys you and, just as importantly, doesn't). # The short version: the home box encrypts before anything ever crosses # SMB, so the VPS's CIFS mount only ever holds ciphertext; this script's # decrypt layer then fetches the passphrase fresh over the same SSH trust # already used for share discovery — piped straight into gocryptfs, never # written to this VPS's own disk — and mounts a decrypted view alongside # the raw ciphertext mount. Whatever's actively reading through that # decrypted view still sees plaintext live, same as any mount anywhere; # nothing changes that. What changes is everything else: a snapshot, # backup, or disk-level look at this VPS while the passphrase isn't # actively loaded shows only ciphertext. # ── Standalone bootstrap ────────────────────────────────────────────────────── if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" _COMMON="$_SELF_DIR/../lib/common.sh" if [[ -f "$_COMMON" ]]; then # shellcheck source=../lib/common.sh source "$_COMMON" else log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } prompt_yn() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } register_service() { :; } fi ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" DRY_RUN="${DRY_RUN:-false}" UNATTENDED="${UNATTENDED:-false}" _RUN_STANDALONE=1 fi # ───────────────────────────────────────────────────────────────────────────── 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 # source of truth, no separate state file to drift out of sync with it. _VDM_TAG_PREFIX="# vpn-data-mount:" _vdm_list_existing() { local entries entries="$(grep "^${_VDM_TAG_PREFIX}" /etc/fstab 2>/dev/null || true)" if [ -n "$entries" ]; then echo "" log_info "Already-configured VPN data mounts:" echo "$entries" | sed "s|^${_VDM_TAG_PREFIX}| •|" echo "" fi local units units="$(systemctl list-unit-files 'vpn-data-mount-decrypt-*.service' --no-legend 2>/dev/null | awk '{print $1}')" if [ -n "$units" ]; then log_info "Decrypt layers configured:" echo "$units" | sed 's/^/ • /' echo "" fi } # ── Name a raw IP so it can be used everywhere instead of typing it again ── # Deliberately /etc/hosts, not ~/.ssh/config: an SSH Host alias only helps # the `ssh` command itself resolve a name — mount.cifs (and everything # else) never consults ~/.ssh/config at all, so an alias alone wouldn't let # the actual CIFS mount address use a name. /etc/hosts is the one mechanism # that makes a name resolve for both, which is what "use a name instead of # the IP" actually needs end to end. # Sets RESOLVED_HOST (not local — same out-param convention as elsewhere). _vdm_resolve_host() { local input="$1" RESOLVED_HOST="$input" # Not a raw IP (already a name, whether from /etc/hosts, real DNS, or # just typed that way) — nothing to do. [[ "$input" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 0 # Already named from an earlier vpn-data-mount run against this same IP? local existing existing="$(awk -v ip="$input" '$1==ip && /# vpn-data-mount/ {print $2; exit}' /etc/hosts 2>/dev/null)" if [ -n "$existing" ]; then log_info "Already named '$existing' in /etc/hosts from an earlier mount — using that." RESOLVED_HOST="$existing" return 0 fi local 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 if grep -qE "^\S+[[:space:]]+${NAME}([[:space:]]|\$)" /etc/hosts 2>/dev/null; then log_warning "'$NAME' is already used for a different address in /etc/hosts — keeping the IP instead." return 0 fi echo "${input} ${NAME} # vpn-data-mount" >> /etc/hosts log_success "Added to /etc/hosts: $NAME -> $input (works for SSH, this mount, and anything else on this box)" RESOLVED_HOST="$NAME" } # ── SSH trust: test first, only bootstrap if actually needed ────────────── # Covers "the home box and VPS already share a key via GitHub import (or any # other means)" for free — if it already works, nothing below runs at all. _vdm_ssh_works() { local user="$1" host="$2" # Runs as $ACTUAL_USER, not root (this whole script runs as root) — the # SSH key lives in $ACTUAL_HOME/.ssh, so root's own bare `ssh` would look # in the wrong home directory entirely and never find it. sudo -u "$ACTUAL_USER" ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \ "${user}@${host}" true 2>/dev/null } _vdm_ensure_ssh_trust() { local user="$1" host="$2" if _vdm_ssh_works "$user" "$host"; then log_success "Passwordless SSH to ${user}@${host} already works — nothing to set up." return 0 fi log_info "No passwordless SSH to ${user}@${host} yet — setting it up." local keyfile="$ACTUAL_HOME/.ssh/id_ed25519" if [ ! -f "$keyfile" ]; then log_info "No SSH key found at $keyfile — generating one." sudo -u "$ACTUAL_USER" mkdir -p "$ACTUAL_HOME/.ssh" sudo -u "$ACTUAL_USER" ssh-keygen -t ed25519 -N "" -f "$keyfile" -C "${ACTUAL_USER}@$(hostname)-vpn-data-mount" \ || { log_error "Key generation failed."; return 1; } chmod 700 "$ACTUAL_HOME/.ssh" chmod 600 "$keyfile" chmod 644 "${keyfile}.pub" fi echo "" echo " This box's public key (needs to end up in ${user}'s authorized_keys" echo " on the home box, one way or another):" echo "" sed 's/^/ /' "${keyfile}.pub" echo "" while true; do echo " How do you want to get it there?" echo " 1) Try now with ssh-copy-id (needs password login enabled on the home box)" echo " 2) I'll add it myself — paste it into ~/.ssh/authorized_keys there, or add it" echo " to your GitHub account and run 'ssh-import-id gh:' on the home box" echo " (same mechanism this repo's own base.sh setup uses)" echo " 3) Cancel this mount" echo "" local CHOICE="" prompt_text " Choice [1/2/3]:" "1" CHOICE case "$CHOICE" in 1) sudo -u "$ACTUAL_USER" ssh-copy-id -i "${keyfile}.pub" "${user}@${host}" \ || log_warning "ssh-copy-id failed — password auth may be disabled on the home box. Try option 2." ;; 2) echo "" read -r -p " Press Enter once the key is in place on the home box: " _ ;; 3|c|C) log_info "Cancelled." return 1 ;; *) log_warning "Invalid choice." continue ;; esac if _vdm_ssh_works "$user" "$host"; then log_success "Passwordless SSH to ${user}@${host} confirmed." return 0 fi log_warning "Still can't connect without a password — try again, or cancel." done } # ── 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() } ' } # ── 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 [ -z "$label" ] && continue creds_file="/etc/samba/credentials.vpn-data-mount-${label}" [ -f "$creds_file" ] || continue found_user="$(grep '^username=' "$creds_file" | cut -d= -f2-)" [ "$found_user" = "$user" ] || continue found_pass="$(grep '^password=' "$creds_file" | cut -d= -f2-)" [ -n "$found_pass" ] && { printf '%s' "$found_pass"; return 0; } done < <(grep -E "^${_VDM_TAG_PREFIX} [^ ]+ — ${host}:" /etc/fstab 2>/dev/null \ | sed -E "s/^${_VDM_TAG_PREFIX} ([^ ]+) .*/\1/") return 1 } # ── Already mounted? Find its label+mount point instead of re-prompting ─── # Prints "label|mount_point" if this exact host+share is already tagged in # /etc/fstab, nothing otherwise. No log_* calls — same $(...) capture # reason as _vdm_find_existing_smb_password above. _vdm_find_existing_mount() { local host="$1" share_name="$2" grep -E "^${_VDM_TAG_PREFIX} [^ ]+ — ${host}:${share_name} -> " /etc/fstab 2>/dev/null \ | sed -E "s/^${_VDM_TAG_PREFIX} ([^ ]+) — [^ ]+ -> (.*)\$/\1|\2/" \ | head -1 } # ── Tear down an existing mount so it can be redone from scratch ────────── # Decrypt layer first (it sits on top of the CIFS mount — systemctl stop # runs the unit's own ExecStop, which unmounts it) if one exists for this # label, then the CIFS mount, its credentials file, and its /etc/fstab # tag+entry (backed up first, same as every other /etc/fstab write in this # file — a fixed ",+1d" range: the tag line plus exactly the one mount line # that always immediately follows it, never an open-ended range to the next # blank line or EOF; see the file's own history for why that distinction # matters). _vdm_remove_mount() { local label="$1" mount_point="$2" local unit="vpn-data-mount-decrypt-${label}.service" if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then systemctl disable --now "$unit" >/dev/null 2>&1 rm -f "/etc/systemd/system/${unit}" "/usr/local/sbin/vpn-data-mount-decrypt-${label}.sh" systemctl daemon-reload fi umount "$mount_point" 2>/dev/null || true rmdir "$mount_point" 2>/dev/null || true rm -f "/etc/samba/credentials.vpn-data-mount-${label}" local bk="/etc/fstab.backup.$(date +%Y%m%d-%H%M%S)" cp /etc/fstab "$bk" sed -i "/^${_VDM_TAG_PREFIX} ${label} — /,+1d" /etc/fstab log_success "Removed the existing mount for '$label' (fstab backup: $(basename "$bk"))" } # ── Every configured mount, for the removal menu below ───────────────────── # Prints "label|host:share|mount_point" one per line. Unlike # _vdm_find_existing_mount, not scoped to a particular host+share. _vdm_list_all_mounts() { grep -E "^${_VDM_TAG_PREFIX} " /etc/fstab 2>/dev/null \ | sed -E "s/^${_VDM_TAG_PREFIX} ([^ ]+) — ([^ ]+) -> (.*)\$/\1|\2|\3/" } # ── Pick one or more configured mounts by number and remove them ────────── _vdm_remove_mount_interactive() { local entries=() while IFS= read -r line; do [ -z "$line" ] && continue entries+=("$line") done < <(_vdm_list_all_mounts) if [ "${#entries[@]}" -eq 0 ]; then log_info "No vpn-data-mount mounts configured — nothing to remove." return 0 fi while true; do echo "" echo " Configured mounts:" local i label hostshare point for i in "${!entries[@]}"; do IFS='|' read -r label hostshare point <<< "${entries[$i]}" printf " %d) %-15s %-28s -> %s\n" "$((i + 1))" "$label" "$hostshare" "$point" done echo "" local CHOICE="" prompt_text " Remove which one? (number, or blank to stop):" "" CHOICE [ -z "$CHOICE" ] && break if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] || [ "$CHOICE" -lt 1 ] || [ "$CHOICE" -gt "${#entries[@]}" ]; then log_warning "'$CHOICE' isn't one of the listed mounts." continue fi IFS='|' read -r label hostshare point <<< "${entries[$((CHOICE - 1))]}" local CONFIRM="" prompt_yn " Remove '$label' ($point)? This unmounts it, removes its credentials, and removes it from /etc/fstab (backed up first). (y/n):" "n" CONFIRM if [[ "$CONFIRM" =~ ^[Yy]$ ]]; then _vdm_remove_mount "$label" "$point" unset "entries[$((CHOICE - 1))]" entries=("${entries[@]}") fi [ "${#entries[@]}" -eq 0 ] && break local AGAIN="" prompt_yn " Remove another? (y/n):" "n" AGAIN [[ "$AGAIN" =~ ^[Yy]$ ]] || break done } # ── Prompt for a password twice, hidden, matching ────────────────────────── # Prints the password on success. No log_* calls — same reason as above; # uses plain stderr output instead so it's visible without corrupting a # caller's $(...) capture. _vdm_prompt_password() { local prompt="$1" pw1="" pw2="" while true; do # IFS= matters here, not just -s/-r: plain `read -r pw1` (no IFS=) # silently strips leading/trailing whitespace even into a single # variable — confirmed live, a password with a leading/trailing # space (copy-pasted from a password manager, a stray keystroke) # got quietly trimmed on the way in, so the credentials file ended # up holding a DIFFERENT password than the one actually set on the # Samba account. That surfaces as a cryptic mount failure, not an # obvious "wrong password" — nothing here could tell the two apart. echo -n " ${prompt}: " >&2 IFS= read -r -s pw1; echo "" >&2 echo -n " Confirm: " >&2 IFS= read -r -s pw2; echo "" >&2 if [ -n "$pw1" ] && [ "$pw1" = "$pw2" ]; then # Length only, never the password itself — lets you catch a # silently-stripped character (or a typo) yourself before the # mount attempt fails with an unhelpful error. echo " Captured (${#pw1} characters)." >&2 printf '%s' "$pw1" return 0 fi echo " Passwords didn't match or were empty — try again." >&2 done } # ── Local mount + fstab ───────────────────────────────────────────────────── _vdm_mount_local() { local host="$1" share_name="$2" mount_point="$3" label="$4" smb_user="$5" smb_pass="$6" command -v mount.cifs >/dev/null 2>&1 || apt-get install -y cifs-utils -qq # Explicit, not left to cifs-utils' Recommends — see file header on # errno 79/ELIBACC. dpkg -s (not command -v: keyutils ships no binary # this script calls directly, just the request-key handler files). dpkg -s keyutils >/dev/null 2>&1 || apt-get install -y keyutils -qq mkdir -p "$mount_point" # 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 cat > "$creds_file" << CREDS username=${smb_user} password=${smb_pass} CREDS chmod 600 "$creds_file" chown root:root "$creds_file" # sec=ntlmssp explicitly — see the file header on errno 79. local opts="credentials=${creds_file},sec=ntlmssp,uid=$(id -u "$ACTUAL_USER"),gid=$(id -g "$ACTUAL_USER"),nofail,_netdev" # iocharset=utf8 only if the kernel can actually load nls_utf8 — see # the file header. modprobe on an already-loaded/built-in module is a # harmless no-op, so this is safe to call unconditionally. if modprobe nls_utf8 >/dev/null 2>&1; then opts="${opts},iocharset=utf8" else log_warning "This kernel ($(uname -r)) has no nls_utf8 module — mounting without iocharset=utf8. Non-ASCII filenames may not display correctly; try 'sudo apt-get install --reinstall linux-modules-$(uname -r)' to see if it restores the module." fi local share="//${host}/${share_name}" log_info "Testing mount..." if mount -t cifs -o "$opts" "$share" "$mount_point"; then log_success "Mounted at $mount_point" else 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 if grep -qs "$mount_point" /etc/fstab; then log_warning "$mount_point already in /etc/fstab — skipping fstab entry." return 0 fi local bk="/etc/fstab.backup.$(date +%Y%m%d-%H%M%S)" cp /etc/fstab "$bk" { echo "" echo "${_VDM_TAG_PREFIX} ${label} — ${host}:${share_name} -> ${mount_point}" printf '%-40s %-25s %-6s %s 0 0\n' "$share" "$mount_point" "cifs" "$opts" } >> /etc/fstab log_success "Added to /etc/fstab (backup: $(basename "$bk"))" } # ── Optional gocryptfs decrypt layer on top of an already-mounted share ──── # See the file header for the threat model. Fully opt-in, per-share, off by # default — a plain unencrypted share works exactly as before. _vdm_ensure_gocryptfs() { command -v gocryptfs >/dev/null 2>&1 || apt-get install -y gocryptfs -qq # -allow_other needs this uncommented for a non-root mount; harmless # (and unnecessary, since we always mount as root below) otherwise — # cheap to guarantee rather than leave as a silent gotcha if that ever # changes. local conf="/etc/fuse.conf" [ -f "$conf" ] || touch "$conf" if grep -q '^#user_allow_other' "$conf"; then sed -i 's/^#user_allow_other/user_allow_other/' "$conf" elif ! grep -q '^user_allow_other' "$conf"; then echo "user_allow_other" >> "$conf" fi } # Generates /etc/systemd/system/vpn-data-mount-decrypt-