Add vpn-data-mount: SMB mount from a NetBird-connected home box

Offered right after NetBird setup during required/base setup, matching
the requested flow (base packages -> NetBird -> data mount). Repeatable
by design rather than a one-shot step, since different services can have
data on different home boxes — asks for a home box IP every time and can
be run again for additional boxes/shares.

Flow: test for existing passwordless SSH first (covers "both boxes already
share a key via GitHub import, or any other means" for free — if it
already works, nothing else runs). If not, generate an SSH keypair and
offer ssh-copy-id or a manual/GitHub-import fallback (ssh-import-id, the
same mechanism base.sh's own SSH setup already uses) — needed because a
home box that took base.sh's "disable password login" option won't accept
ssh-copy-id at all. Once passwordless SSH works, use it to remotely
install and configure Samba on the home box for a chosen path, then mount
it locally over CIFS with a tagged /etc/fstab entry.

SMB over NFS/SSHFS per this session's direction: not a "huge" speed gap
for normal use, and SSHFS's own encryption is redundant overhead once the
VPN tunnel already encrypts everything. Guest-accessible (no separate
Samba credentials) since the VPN is the real access control — only
NetBird-connected peers can reach the home box's NetBird IP at all.

Also:
- cifs-utils added to base.sh's always-installed packages, same reasoning
  as Docker/Compose being unconditional there instead of installed lazily
  on first mount.
- is_installed()/install_count() in setup.sh gained a vpn-data-mount case
  (state lives in tagged /etc/fstab entries, not $DOCKER_DIR, since this
  isn't a Docker service) — mirrors wordpress's "count real instances"
  handling rather than a flat 0/1.
- Every SSH call in the new service explicitly runs as $ACTUAL_USER
  (sudo -u), not root — the script itself runs as root throughout, but the
  SSH key lives in $ACTUAL_HOME/.ssh, so a bare `ssh` call would silently
  use root's own ~/.ssh instead and never find it. Caught by review before
  this shipped, not after.
- UNATTENDED mode skips outright with a message instead of spinning
  forever on prompt_text's always-blank default under --unattended, since
  none of this flow's prompts (home box IP, remote path, ...) have a
  sane non-interactive default.
This commit is contained in:
Claude
2026-08-10 17:56:50 +00:00
parent 63c227f101
commit 0e42de1cda
4 changed files with 373 additions and 5 deletions
+2 -1
View File
@@ -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` |
| `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) |
| `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` |
@@ -196,6 +196,7 @@ homelab
sms-inbound
security-dashboard
sunshine
vpn-data-mount
utilities
actualbudget
+28 -2
View File
@@ -18,6 +18,7 @@ install_base() {
echo "[DRY-RUN] Would offer SSH key import from GitHub/Launchpad"
echo "[DRY-RUN] Would offer to disable SSH password auth"
echo "[DRY-RUN] Would offer NetBird install with --allow-server-ssh"
echo "[DRY-RUN] Would offer to mount SMB data from a NetBird-connected home box (if NetBird is present)"
echo "[DRY-RUN] Would offer Caddy reverse proxy install (full repo only)"
echo "[DRY-RUN] Would offer CrowdSec intrusion prevention install (full repo only)"
echo "[DRY-RUN] Would offer to add SSH Host aliases to ~/.ssh/config"
@@ -26,10 +27,14 @@ install_base() {
run_cmd apt-get update -y
# Core utilities present on every install.
# Core utilities present on every install. cifs-utils here (not lazily
# installed on first use, the way tools/mount-network-drive.sh and
# vpn-data-mount.sh's own local-mount step would otherwise do it) so SMB
# mounts work immediately whenever they're set up later, same reasoning
# as Docker/Compose being unconditional here instead of on-demand.
run_cmd apt-get install -y \
net-tools ncdu git curl wget htop btop tree zip unzip \
ca-certificates gnupg jq rsync ssh-import-id \
ca-certificates gnupg jq rsync ssh-import-id cifs-utils \
|| log_warning "Some essential packages failed to install"
# glow — terminal markdown reader (charmbracelet). Not in Ubuntu repos,
@@ -51,6 +56,14 @@ install_base() {
# ── NetBird ──────────────────────────────────────────────────────────────
_base_setup_netbird
# ── VPN-connected data mount ────────────────────────────────────────────
# Only offered if NetBird is actually present (installed just now, or
# already there from a prior run) — chained here rather than folded into
# _base_setup_netbird itself since it's independently repeatable (see
# services/vpn-data-mount.sh's own header) and users may want to run it
# again later for another home box without re-touching NetBird at all.
_base_setup_vpn_mount
# ── Caddy + CrowdSec ──────────────────────────────────────────────────────
# Not this script's own install — just an early, recommended nudge toward
# two services most other things in this repo end up wanting (a reverse
@@ -214,6 +227,19 @@ _base_setup_netbird() {
fi
}
_base_setup_vpn_mount() {
command -v netbird >/dev/null 2>&1 || return 0
# Only available when the full repo is sourced (setup.sh loads every
# services/*.sh up front) — a standalone copy of base.sh doesn't have
# install_vpn-data-mount, so skip silently rather than error.
declare -F install_vpn-data-mount >/dev/null 2>&1 || return 0
local SETUP_MOUNT=""
prompt_yn "Mount data from a NetBird-connected home box now? (y/n):" "n" SETUP_MOUNT
[[ "$SETUP_MOUNT" =~ ^[Yy]$ ]] || return 0
install_vpn-data-mount
}
_base_setup_caddy() {
if [[ -d "$DOCKER_DIR/caddy" ]]; then
log_info "Caddy already installed."
+335
View File
@@ -0,0 +1,335 @@
#!/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.
# 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)
#
# 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.
#
# 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.
#
# 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.
#
# The share is guest-accessible (no separate Samba username/password to
# manage) because the VPN is the actual access control here — only
# NetBird-connected peers can reach the home box's NetBird IP at all, so a
# second credential layer on top of that doesn't add real security, just
# more secrets to lose track of.
# ── 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 SMB data from a NetBird-connected home box (SSH-automated remote 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
}
# ── 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
}
# ── Remote Samba setup, driven entirely over the SSH trust above ──────────
_vdm_setup_remote_samba() {
local user="$1" host="$2" remote_path="$3" share_name="$4"
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
log_info "Configuring the share on the home box..."
# Idempotent: drop any prior block for this exact share name, then
# append a fresh one. Guest-accessible — see the file header for why.
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
{
echo ""
echo "[${share_name}]"
echo " path = ${remote_path}"
echo " browseable = yes"
echo " read only = no"
echo " guest ok = yes"
echo " force user = \$(whoami)"
} | sudo tee -a /etc/samba/smb.conf >/dev/null
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 and 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"
command -v mount.cifs >/dev/null 2>&1 || apt-get install -y cifs-utils -qq
mkdir -p "$mount_point"
local opts="guest,uid=$(id -u "$ACTUAL_USER"),gid=$(id -g "$ACTUAL_USER"),iocharset=utf8,nofail,_netdev"
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 — check connectivity to $host and the remote share config."
rmdir "$mount_point" 2>/dev/null || true
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"))"
}
# ── One mount, start to finish ──────────────────────────────────────────────
_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="" SSH_USER=""
prompt_text " Home box's NetBird IP (or hostname — check 'netbird status' on that box):" "" HOST
if [ -z "$HOST" ]; then
log_warning "No host entered — cancelling this mount."
return 1
fi
prompt_text " SSH username on the home box:" "$ACTUAL_USER" SSH_USER
_vdm_ensure_ssh_trust "$SSH_USER" "$HOST" || return 1
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."
return 1
fi
prompt_text " Local mount point:" "/mnt/${LABEL}" MOUNT_POINT
_vdm_setup_remote_samba "$SSH_USER" "$HOST" "$REMOTE_PATH" "$LABEL" || return 1
_vdm_mount_local "$HOST" "$LABEL" "$MOUNT_POINT" "$LABEL" || return 1
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"
}
install_vpn-data-mount() {
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ VPN Data Mount — SMB share from a NetBird-connected box ║"
echo "╚══════════════════════════════════════════════════════════╝"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would test/set up passwordless SSH to a home box over its NetBird IP"
echo "[DRY-RUN] Would remotely install+configure Samba there for a chosen path"
echo "[DRY-RUN] Would mount it locally over CIFS and add it to /etc/fstab"
echo "[DRY-RUN] Repeatable — can be run again for additional home boxes/shares"
return 0
fi
# Every prompt below (home box IP, remote path, ...) 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.
if [ "$UNATTENDED" = true ]; then
log_info "Skipping — needs interactive input (home box IP, path, ...). Run 'sudo ./setup.sh vpn-data-mount' without --unattended."
return 0
fi
_vdm_list_existing
while true; do
local ADD=""
prompt_yn "Add a VPN data mount 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
[[ "$AGAIN" =~ ^[Yy]$ ]] || break
done
}
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_vpn-data-mount
+8 -2
View File
@@ -105,6 +105,9 @@ is_installed() {
# $DOCKER_DIR/wordpress dir the default case below could match) —
# [installed] means "at least one site exists", not any specific one.
wordpress) compgen -G "$DOCKER_DIR/wordpress-*" >/dev/null 2>&1 ;;
# Not a Docker service — state lives in tagged /etc/fstab entries
# (services/vpn-data-mount.sh's own convention), not $DOCKER_DIR.
vpn-data-mount) grep -q '^# vpn-data-mount:' /etc/fstab 2>/dev/null ;;
*) [ -e "$DOCKER_DIR/$1" ] ;;
esac
}
@@ -114,14 +117,17 @@ is_installed() {
# CLAUDE.md (a base install plus any number of "<name>-<suffix>" siblings,
# e.g. mattermost + mattermost-team-b). Only the default case knows that
# naming convention; the specially-cased services above aren't part of the
# multi-instance pattern (wordpress is the one exception and already counts
# sites directly), so for those this just mirrors is_installed() as 0 or 1.
# multi-instance pattern (wordpress and vpn-data-mount are the exceptions
# and already count sites/mounts directly), so for those this just mirrors
# is_installed() as 0 or 1.
install_count() {
case "$1" in
base|glow|crowdsec|security-dashboard|kdeconnect|silent-send|sync-cc|sky-cam|sky-cam-frigate|asterisk|pstn-trunk|sms-inbound|ssh-config)
is_installed "$1" && echo 1 || echo 0 ;;
wordpress)
find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name 'wordpress-*' -type d 2>/dev/null | wc -l ;;
vpn-data-mount)
grep -c '^# vpn-data-mount:' /etc/fstab 2>/dev/null || echo 0 ;;
*)
local c=0
[ -e "$DOCKER_DIR/$1" ] && c=1