From 0e42de1cdaf26131ae0e68966f47a9124dd755a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 17:56:50 +0000 Subject: [PATCH] Add vpn-data-mount: SMB mount from a NetBird-connected home box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 3 +- services/base.sh | 30 +++- services/vpn-data-mount.sh | 335 +++++++++++++++++++++++++++++++++++++ setup.sh | 10 +- 4 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 services/vpn-data-mount.sh diff --git a/README.md b/README.md index 3556e22..7d0d012 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. | Group | Services | |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | -| `homelab` | `caddy`, `crowdsec`, `authelia`, `coturn` (shared TURN/STUN relay — Asterisk, Mattermost Calls, and future WebRTC-capable services all register a dedicated credential against one instance instead of each running its own), `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine` | +| `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 diff --git a/services/base.sh b/services/base.sh index e872547..45acf21 100644 --- a/services/base.sh +++ b/services/base.sh @@ -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." diff --git a/services/vpn-data-mount.sh b/services/vpn-data-mount.sh new file mode 100644 index 0000000..db9fba8 --- /dev/null +++ b/services/vpn-data-mount.sh @@ -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:' 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 diff --git a/setup.sh b/setup.sh index 9c1255e..6279097 100755 --- a/setup.sh +++ b/setup.sh @@ -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 "-" 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