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
731 lines
33 KiB
Bash
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
|