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
This commit is contained in:
Claude
2026-08-10 23:06:13 +00:00
parent 158186a28c
commit a23d5d6fc7
3 changed files with 335 additions and 1 deletions
+33 -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`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass) |
| `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` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) |
| `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` |
@@ -327,6 +327,38 @@ Password authentication is only offered to be disabled if at least one key
import actually succeeded in that run — never blindly, so you can't get
locked out by declining every import prompt.
## Client-side encryption for vpn-data-mount
A plain SMB mount over the VPN protects the data in transit, but the VPS
itself — its disk, page cache, and anyone with access to the machine (the
host, an attacker who compromises it, a compelled disclosure) — sees
plaintext while it's mounted. `vpn-data-mount`'s optional decrypt layer
closes most of that gap by encrypting on the home box, before anything
ever crosses the network:
1. On the home box, run `sudo bash tools/gocryptfs-setup-home.sh`. It
creates an encrypted directory (a "cipherdir") and a passphrase file,
and prints the exact next step. Point your existing Samba share's
`path =` at the cipherdir itself — this tool never touches smb.conf,
same read-only stance `vpn-data-mount` itself takes on the VPS side.
2. On the VPS, `sudo ./setup.sh vpn-data-mount` as usual. After it mounts
the share over CIFS, it asks whether to layer gocryptfs decryption on
top — say yes and give it the passphrase file's path. It fetches that
file fresh over the same SSH trust already set up for share discovery,
pipes it straight into `gocryptfs`, and never writes it to the VPS's
own disk. A systemd unit keeps the decrypted view coming back on boot,
fetching the passphrase again each time rather than caching it.
What this changes: a disk image, backup, or provider-side look at the VPS
while the passphrase isn't actively loaded shows only ciphertext. What it
doesn't change: anything actually reading through the decrypted mount
while it's live still sees plaintext, same as any data in active use
anywhere — that part isn't a software problem this (or any) tool can
solve out from under the machine actually using the data.
Skip this entirely if it's not worth the added moving part — `vpn-data-mount`
works exactly the same without it, plain CIFS, nothing to opt into.
## SSH Host aliases
`~/.ssh/config` lets you `ssh <alias>` instead of typing `ssh user@1.2.3.4`
+146
View File
@@ -71,6 +71,22 @@
# 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
@@ -130,6 +146,14 @@ _vdm_list_existing() {
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 ──
@@ -400,6 +424,124 @@ CREDS
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
@@ -527,8 +669,11 @@ _vdm_add_mount() {
# 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
@@ -553,6 +698,7 @@ install_vpn-data-mount() {
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
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# gocryptfs-setup-home.sh — Set up a gocryptfs-encrypted directory on this
# box (the "home box" in vpn-data-mount.sh's terms) so a remote VPS can
# mount it over SMB and only ever handle ciphertext.
#
# Usage:
# sudo bash gocryptfs-setup-home.sh
#
# What this buys you: files placed in the encrypted store below are
# encrypted on THIS box, before they ever cross the network. Your existing
# Samba share, if its `path =` points at the encrypted store (a manual
# smb.conf edit — see "Next steps" at the end of this script; this tool
# doesn't touch smb.conf, matching vpn-data-mount.sh's own read-only stance
# on remote Samba config), only ever transmits ciphertext over SMB. The
# VPS's CIFS mount, and its own disk/page cache, only ever hold ciphertext
# too. services/vpn-data-mount.sh's optional decrypt-layer step then fetches
# the passphrase file this script creates below, fresh, over the same SSH
# trust already used for share discovery — never storing it on the VPS's
# disk — and decrypts on the fly into a second mountpoint there.
#
# What this does NOT buy you: privacy from whatever's actively reading
# through that decrypted view on the VPS while it's mounted there — if a
# service on the VPS needs to read the plaintext to do its job (serve a
# file, transcode video, whatever), that service (and root on that VPS)
# sees plaintext for as long as it's mounted, same as any live-in-use data
# anywhere. What changes is everything else: a disk image, backup, or
# provider-side look at the VPS while the passphrase isn't actively loaded
# shows only ciphertext, instead of showing your actual files.
#
# This also does NOT migrate any existing plainly-shared data into the new
# encrypted store automatically — that's a real "am I about to overwrite/
# lose something" decision this script shouldn't make for you. If you have
# an existing share you want to convert, mount the plaintext view this
# script offers to create, move your files into it yourself (they land
# encrypted in the cipherdir as you do), then repoint your Samba share's
# `path =` at the cipherdir once you're done.
set -euo pipefail
info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; }
ok() { printf '\033[0;32m[OK]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
err() { printf '\033[0;31m[ERROR]\033[0m %s\n' "$*" >&2; }
[[ "$(id -u)" == "0" ]] || { err "Run with sudo: sudo bash $0"; exit 1; }
ACTUAL_USER="${SUDO_USER:-$USER}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" | cut -d: -f6)"
# Prompts twice, hidden, matching — prints the password on success. IFS=
# matters, not just -s/-r: a plain `read -r pw1` silently strips leading/
# trailing whitespace even into a single variable, which would make the
# passphrase saved to the passfile different from the one actually set on
# the store — see services/vpn-data-mount.sh's own note on this, same bug.
prompt_password_confirm() {
local prompt="$1" pw1="" pw2=""
while true; do
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
echo " Captured (${#pw1} characters)." >&2
printf '%s' "$pw1"
return 0
fi
echo " Passwords didn't match or were empty — try again." >&2
done
}
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " gocryptfs setup — client-side encryption for a vpn-data-mount share"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo " This creates an encrypted directory (a 'cipherdir') on this box."
echo " Point your Samba share's 'path =' at the cipherdir itself, not the"
echo " plaintext view — the VPS should only ever receive ciphertext."
echo ""
command -v gocryptfs >/dev/null 2>&1 || {
info "Installing gocryptfs..."
apt-get update -qq
apt-get install -y gocryptfs -qq
}
CIPHERDIR=""
read -r -p " Directory to create as the encrypted store [${ACTUAL_HOME}/encrypted-share]: " CIPHERDIR
CIPHERDIR="${CIPHERDIR:-${ACTUAL_HOME}/encrypted-share}"
DEFAULT_PASSFILE="${ACTUAL_HOME}/.gocryptfs-$(basename "$CIPHERDIR").pass"
PASSFILE=""
read -r -p " Where to save the passphrase file for the VPS to fetch [${DEFAULT_PASSFILE}]: " PASSFILE
PASSFILE="${PASSFILE:-$DEFAULT_PASSFILE}"
if [[ -f "${CIPHERDIR}/gocryptfs.conf" ]]; then
warn "${CIPHERDIR} is already a gocryptfs store — leaving it as-is."
[[ -f "$PASSFILE" ]] || warn "But $PASSFILE doesn't exist — the VPS-side decrypt step needs it. Re-run and use the same passphrase you set this store up with, or create $PASSFILE by hand with 'username=...' replaced by just the passphrase on one line."
else
if [[ -f "$PASSFILE" ]]; then
err "$PASSFILE already exists but $CIPHERDIR isn't an initialized store — refusing to guess whether they're supposed to match. Remove one or point at a different path and re-run."
exit 1
fi
echo ""
echo " Set a passphrase for this store now. This is the ONE thing that"
echo " decrypts it — write it down somewhere safe. There is no recovery"
echo " if it's lost; everything inside becomes permanently unreadable."
echo ""
PASSPHRASE="$(prompt_password_confirm "Passphrase for the new encrypted store")"
mkdir -p "$(dirname "$PASSFILE")"
printf '%s\n' "$PASSPHRASE" > "$PASSFILE"
chmod 600 "$PASSFILE"
chown "$ACTUAL_USER:$ACTUAL_USER" "$PASSFILE"
mkdir -p "$CIPHERDIR"
chown "$ACTUAL_USER:$ACTUAL_USER" "$CIPHERDIR"
sudo -u "$ACTUAL_USER" gocryptfs -passfile "$PASSFILE" -init "$CIPHERDIR"
ok "Encrypted store created at $CIPHERDIR"
ok "Passphrase saved to $PASSFILE (owner-only, 600)"
warn "Anyone who can read $PASSFILE can decrypt everything in $CIPHERDIR — it's exactly as sensitive as the data itself. It only needs to be readable by $ACTUAL_USER and root; treat SSH access to this account accordingly, since that's how the VPS fetches it."
fi
echo ""
MOUNTPOINT=""
read -r -p " Also mount the plaintext view locally now, so you can use it here? Enter a mount point, or leave blank to skip: " MOUNTPOINT
if [[ -n "$MOUNTPOINT" ]]; then
mkdir -p "$MOUNTPOINT"
chown "$ACTUAL_USER:$ACTUAL_USER" "$MOUNTPOINT"
if sudo -u "$ACTUAL_USER" gocryptfs -passfile "$PASSFILE" "$CIPHERDIR" "$MOUNTPOINT"; then
ok "Mounted plaintext view at $MOUNTPOINT (unmount later with: fusermount -u $MOUNTPOINT)"
else
warn "Mount failed — the store and passfile are still set up correctly, this just means you'll need to mount it yourself: gocryptfs -passfile $PASSFILE $CIPHERDIR $MOUNTPOINT"
fi
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Next steps"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo " 1. Point your Samba share's 'path =' at the cipherdir itself:"
echo " $CIPHERDIR"
echo " (not the plaintext mount point above, if you made one) — this"
echo " tool doesn't edit smb.conf for you, same as vpn-data-mount.sh's"
echo " own read-only stance on the VPS side. If this share doesn't"
echo " exist yet, tools/mount-network-drive.sh has no share-creation"
echo " flow either — use Samba's own tools (smb.conf + smbpasswd)."
echo ""
echo " 2. On the VPS, run (or re-run) 'sudo ./setup.sh vpn-data-mount'."
echo " After it mounts this share over CIFS as usual, say yes when it"
echo " asks whether to layer gocryptfs decryption on top, and give it"
echo " this path when asked for the passphrase file:"
echo " $PASSFILE"
echo ""