diff --git a/README.md b/README.md index b8a39f2..2d4af54 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`, `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 ` instead of typing `ssh user@1.2.3.4` diff --git a/services/mattermost.sh b/services/mattermost.sh index 83bdfaf..a2347a2 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -335,9 +335,6 @@ install_mattermost() { [ -n "$DB_PASS" ] || DB_PASS=$(generate_password 32) local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" - local UID_VAL GID_VAL - UID_VAL=$(id -u "$ACTUAL_USER") - GID_VAL=$(id -g "$ACTUAL_USER") # Compute SITE_URL — extra instances default to a distinct subdomain so # they don't collide with the first instance's. @@ -489,15 +486,22 @@ TURN_HOST=$TURN_HOST_VAL TURN_PORT=$TURN_PORT_VAL TURN_USERNAME=$TURN_USERNAME_VAL TURN_PASSWORD=$TURN_PASSWORD_VAL - -# PUID/PGID for file ownership -PUID=$UID_VAL -PGID=$GID_VAL EOF chmod 600 .env mkdir -p data logs config plugins db chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + # mattermost/mattermost-team-edition's image runs as a fixed UID/GID + # 2000 baked in at build time — unlike some other images in this repo, + # it does NOT read PUID/PGID env vars (that's a LinuxServer.io s6-overlay + # convention this image doesn't use; a previous version of this file set + # them anyway, which did nothing). Confirmed live: leaving ./data, + # ./logs, ./config, ./plugins owned by ACTUAL_USER instead of 2000:2000 + # makes the container fail on its very first start with "could not + # create config file: open /mattermost/config/config.json: permission + # denied" and crash-loop — db (postgres:15-alpine) isn't affected, its + # entrypoint fixes ownership itself on startup when it needs to. + chown -R 2000:2000 data logs config plugins # ── Firewall ───────────────────────────────────────────────────────────── if command -v ufw &>/dev/null; then diff --git a/services/vpn-data-mount.sh b/services/vpn-data-mount.sh index a2a9ba8..170a669 100644 --- a/services/vpn-data-mount.sh +++ b/services/vpn-data-mount.sh @@ -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 ── @@ -308,6 +332,46 @@ _vdm_find_existing_smb_password() { return 1 } +# ── Already mounted? Find its label+mount point instead of re-prompting ─── +# Prints "label|mount_point" if this exact host+share is already tagged in +# /etc/fstab, nothing otherwise. No log_* calls — same $(...) capture +# reason as _vdm_find_existing_smb_password above. +_vdm_find_existing_mount() { + local host="$1" share_name="$2" + grep -E "^${_VDM_TAG_PREFIX} [^ ]+ — ${host}:${share_name} -> " /etc/fstab 2>/dev/null \ + | sed -E "s/^${_VDM_TAG_PREFIX} ([^ ]+) — [^ ]+ -> (.*)\$/\1|\2/" \ + | head -1 +} + +# ── Tear down an existing mount so it can be redone from scratch ────────── +# Decrypt layer first (it sits on top of the CIFS mount — systemctl stop +# runs the unit's own ExecStop, which unmounts it) if one exists for this +# label, then the CIFS mount, its credentials file, and its /etc/fstab +# tag+entry (backed up first, same as every other /etc/fstab write in this +# file — a fixed ",+1d" range: the tag line plus exactly the one mount line +# that always immediately follows it, never an open-ended range to the next +# blank line or EOF; see the file's own history for why that distinction +# matters). +_vdm_remove_mount() { + local label="$1" mount_point="$2" + local unit="vpn-data-mount-decrypt-${label}.service" + + if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then + systemctl disable --now "$unit" >/dev/null 2>&1 + rm -f "/etc/systemd/system/${unit}" "/usr/local/sbin/vpn-data-mount-decrypt-${label}.sh" + systemctl daemon-reload + fi + + umount "$mount_point" 2>/dev/null || true + rmdir "$mount_point" 2>/dev/null || true + rm -f "/etc/samba/credentials.vpn-data-mount-${label}" + + local bk="/etc/fstab.backup.$(date +%Y%m%d-%H%M%S)" + cp /etc/fstab "$bk" + sed -i "/^${_VDM_TAG_PREFIX} ${label} — /,+1d" /etc/fstab + log_success "Removed the existing mount for '$label' (fstab backup: $(basename "$bk"))" +} + # ── 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 @@ -400,6 +464,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-