Files
ubuntu-post-install/services/vpn-data-mount.sh
T
Claude a23d5d6fc7 Add optional client-side encryption layer for vpn-data-mount
The VPS side of a plain SMB mount necessarily sees plaintext while it's
mounted and in use — that's unavoidable for data a VPS service actually
needs to read. What's avoidable is everything else: a disk image,
backup, or provider-side look at the VPS while the mount isn't actively
in use showing your actual files instead of ciphertext.

tools/gocryptfs-setup-home.sh (new): standalone tool for the home box.
Creates a gocryptfs-encrypted directory and passphrase file; the user
points their existing Samba share's `path =` at the cipherdir (manual
step — same read-only stance on remote Samba config vpn-data-mount.sh
already takes, this tool doesn't touch smb.conf either).

services/vpn-data-mount.sh: after mounting a share over CIFS as before,
optionally offers a gocryptfs decrypt layer on top. Fetches the
passphrase fresh over the same SSH trust already used for share
discovery, pipes it straight into gocryptfs, and never writes it to the
VPS's own disk. A generated systemd unit (via a wrapper script, not one
long quoted ExecStart= one-liner — avoids stacking systemd's own
word-splitting on top of bash -c's) keeps the decrypted view coming back
on boot, re-fetching the passphrase each time rather than caching it.

Fully opt-in and per-share — a plain unencrypted mount works exactly as
before if declined.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-10 23:06:13 +00:00

731 lines
33 KiB
Bash

#!/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:<user>' 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
}
# ── 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-<label>.service so the
# decrypt layer comes back after a reboot the same way the fstab CIFS mount
# does. RequiresMountsFor sequences it after that mount (whether it's an
# fstab-generated mount unit or comes up some other way) instead of racing
# it. The passphrase fetch happens fresh on every start — nothing from it
# persists in the unit file itself or anywhere else on this disk.
_vdm_write_decrypt_unit() {
local host="$1" ssh_user="$2" label="$3" passfile="$4" cifs_mount_point="$5" decrypted_point="$6"
local wrapper="/usr/local/sbin/vpn-data-mount-decrypt-${label}.sh"
local unit="/etc/systemd/system/vpn-data-mount-decrypt-${label}.service"
# A wrapper script, not one long quoted ExecStart= one-liner — a single
# ExecStart string embedding an SSH-piped-to-gocryptfs pipeline needs
# two independent layers of quoting (systemd's own ExecStart= word
# splitting, then bash -c's), which is exactly the kind of thing that
# looks right and silently breaks on the one path/host with a space or
# an unusual character in it. A plain script just uses normal bash
# quoting once.
cat > "$wrapper" << WRAPPER
#!/bin/bash
set -e
sudo -u "${ACTUAL_USER}" ssh -o BatchMode=yes -o ConnectTimeout=10 "${ssh_user}@${host}" "cat '${passfile}'" \\
| gocryptfs -passfile /dev/stdin -allow_other "${cifs_mount_point}" "${decrypted_point}"
WRAPPER
chmod 700 "$wrapper"
chown root:root "$wrapper"
cat > "$unit" << UNIT
[Unit]
Description=vpn-data-mount gocryptfs decrypt layer: ${label}
After=network-online.target
Wants=network-online.target
RequiresMountsFor=${cifs_mount_point}
[Service]
Type=forking
ExecStart=${wrapper}
ExecStop=/bin/fusermount -u ${decrypted_point}
Restart=on-failure
RestartSec=15
User=root
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
if systemctl enable --now "vpn-data-mount-decrypt-${label}.service" >/dev/null 2>&1; then
log_success "Decrypt layer will remount automatically on boot (systemd unit: vpn-data-mount-decrypt-${label})"
else
log_warning "Couldn't enable the boot-time systemd unit — you'll need to re-run the decrypt step manually after a reboot."
fi
}
_vdm_setup_decrypt_layer() {
local host="$1" ssh_user="$2" label="$3" cifs_mount_point="$4"
echo ""
local WANT=""
prompt_yn " Is [$label] a gocryptfs-encrypted share (set up on the home box with tools/gocryptfs-setup-home.sh)? Layer decryption on top? (y/n):" "n" WANT
[[ "$WANT" =~ ^[Yy]$ ]] || return 0
_vdm_ensure_gocryptfs
local passfile=""
prompt_text " Path to the passphrase file on the home box (printed at the end of gocryptfs-setup-home.sh):" "" passfile
if [ -z "$passfile" ]; then
log_warning "No path given — skipping the decrypt layer for [$label]."
return 0
fi
if ! sudo -u "$ACTUAL_USER" ssh -o BatchMode=yes -o ConnectTimeout=5 "${ssh_user}@${host}" "test -f '$passfile'" 2>/dev/null; then
log_warning "Can't see $passfile on ${ssh_user}@${host} — skipping the decrypt layer for [$label]. Run tools/gocryptfs-setup-home.sh there first, or check the path."
return 0
fi
local decrypted_point=""
prompt_text " Local mount point for the decrypted view:" "${cifs_mount_point}-decrypted" decrypted_point
mkdir -p "$decrypted_point"
if mountpoint -q "$decrypted_point" 2>/dev/null; then
log_info "$decrypted_point is already mounted — leaving it as-is, just (re)writing the boot-time unit."
else
if ! sudo -u "$ACTUAL_USER" ssh -o BatchMode=yes "${ssh_user}@${host}" "cat '$passfile'" \
| gocryptfs -passfile /dev/stdin -allow_other "$cifs_mount_point" "$decrypted_point"; then
log_error "gocryptfs mount failed for [$label] — check the passphrase file on the home box and that $cifs_mount_point actually holds a gocryptfs store (gocryptfs.conf present at its root)."
rmdir "$decrypted_point" 2>/dev/null || true
return 1
fi
log_success "Decrypted view mounted at $decrypted_point"
fi
_vdm_write_decrypt_unit "$host" "$ssh_user" "$label" "$passfile" "$cifs_mount_point" "$decrypted_point"
# Point chained callers (filebrowser.sh etc.) at the decrypted view
# instead of the raw ciphertext mount, same out-param as elsewhere.
VDM_LAST_MOUNT_POINT="$decrypted_point"
}
# ── 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 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."
return 1
fi
_vdm_resolve_host "$HOST_INPUT"
HOST="$RESOLVED_HOST"
prompt_text " SSH username on the home box:" "$ACTUAL_USER" SSH_USER
_vdm_ensure_ssh_trust "$SSH_USER" "$HOST" || return 1
# 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.
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 \
&& ! ssh_host_alias_exists "$HOST"; then
local ADD_ALIAS=""
prompt_yn " Also add '$HOST' as an SSH alias (ssh $HOST, no username needed)? (y/n):" "y" ADD_ALIAS
[[ "$ADD_ALIAS" =~ ^[Yy]$ ]] && add_ssh_host_alias "$HOST" "$HOST_INPUT" "$SSH_USER" "22"
fi
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
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 ""
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_setup_decrypt_layer overwrites this with the decrypted
# view's path instead, if one gets set up for this share.
VDM_LAST_MOUNT_POINT="$MOUNT_POINT"
picked_any=true
_vdm_setup_decrypt_layer "$HOST" "$SSH_USER" "$LABEL" "$MOUNT_POINT"
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 — 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 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] Would offer a gocryptfs decrypt layer per share (opt-in, requires the home box already set up via tools/gocryptfs-setup-home.sh)"
echo "[DRY-RUN] Repeatable — can be run again for additional home boxes"
return 0
fi
# 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 something forever.
if [ "$UNATTENDED" = true ]; then
log_info "Skipping — needs interactive input (home box IP, share selection, ...). Run 'sudo ./setup.sh vpn-data-mount' without --unattended."
return 0
fi
_vdm_list_existing
while true; do
local 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 "Connect to another (different) home box? (y/n):" "n" AGAIN
[[ "$AGAIN" =~ ^[Yy]$ ]] || break
done
}
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_vpn-data-mount