#!/usr/bin/env bash # # homelab-publish.sh — flip a locally-hosted TrueNAS app to public HTTPS. # # The "go public" switch from homelab-release-runbook.md §4. Idempotent and # additive: it never touches the running app stack, only adds the frp tunnel + # IONOS vhost + Let's Encrypt cert. Re-running is safe. To unpublish, remove the # frpc proxy block (or run with `--down`) — local-phase hosting keeps working. # # Usage: # ./homelab-publish.sh [subdomain] # ./homelab-publish.sh myapp 3001 30011 myapp.plate-software.de # # --down remove the frpc block + disable the IONOS vhosts (keep the cert) # --dry print what would happen without changing anything # # Prereqs (see runbook §1): # - SSH aliases `truenas` and `ionos` configured in ~/.ssh/config # - DNS A-record → 82.165.206.45 already propagated # - frpc on TrueNAS (systemd) reading /mnt/VM_SSD_Pool/frp/frpc.toml # - acme.sh + apache2 on IONOS; /var/www/html webroot for ACME challenge # set -euo pipefail # ── Fixed infra constants (runbook §1) ────────────────────────────────────── FRPC_TOML="/mnt/VM_SSD_Pool/frp/frpc.toml" VPS_IP="85.214.154.199" # frps host the IONOS vhost proxies to APACHE_SITES="/etc/apache2/sites-available" ACME_WEBROOT="/var/www/html" SSH_TRUENAS="${SSH_TRUENAS:-truenas}" SSH_IONOS="${SSH_IONOS:-ionos}" # ── Args ──────────────────────────────────────────────────────────────────── DOWN=0; DRY=0; POS=() for a in "$@"; do case "$a" in --down) DOWN=1 ;; --dry) DRY=1 ;; *) POS+=("$a") ;; esac done set -- "${POS[@]:-}" PROJECT="${1:-}" FRONTEND_PORT="${2:-}" FRP_REMOTE_PORT="${3:-}" SUBDOMAIN="${4:-${PROJECT}.plate-software.de}" if [[ -z "$PROJECT" || -z "$FRONTEND_PORT" || -z "$FRP_REMOTE_PORT" ]]; then echo "usage: $0 [subdomain] [--down] [--dry]" >&2 exit 2 fi VHOST_CONF="${APACHE_SITES}/${SUBDOMAIN}.conf" log() { printf '\033[1;34m▶ %s\033[0m\n' "$*"; } ok() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; } warn() { printf '\033[1;33m! %s\033[0m\n' "$*"; } run() { if [[ $DRY -eq 1 ]]; then echo " [dry] $*"; else eval "$@"; fi; } # ── Marker for the frpc block so we can find/replace/remove idempotently ───── BEGIN="# >>> homelab-publish ${PROJECT} >>>" END="# <<< homelab-publish ${PROJECT} <<<" frpc_block() { cat </dev/null; apache2ctl configtest && systemctl reload apache2\"" ok "vhost disabled (cert left in place for quick re-publish)" exit 0 fi # ── Pre-flight: DNS must point at IONOS ────────────────────────────────────── log "Pre-flight: checking DNS for ${SUBDOMAIN}" RESOLVED="$(dig +short "${SUBDOMAIN}" A | tail -n1 || true)" if [[ "$RESOLVED" != "82.165.206.45" ]]; then warn "DNS for ${SUBDOMAIN} = '${RESOLVED:-}' (expected 82.165.206.45)." warn "Create the A-record in the IONOS panel and let it propagate first." [[ $DRY -eq 1 ]] || exit 1 else ok "DNS → 82.165.206.45" fi # ── 1. frp tunnel (TrueNAS frpc) ───────────────────────────────────────────── log "frp: adding/updating proxy block on TrueNAS (${FRPC_TOML})" BLOCK="$(frpc_block)" # Remove any existing block for this project, then append the fresh one. run "$SSH_TRUENAS \"sed -i '/${BEGIN}/,/${END}/d' ${FRPC_TOML}\"" if [[ $DRY -eq 1 ]]; then echo " [dry] append to ${FRPC_TOML}:"; echo "${BLOCK}" | sed 's/^/ /' else printf '%s\n' "${BLOCK}" | $SSH_TRUENAS "cat >> ${FRPC_TOML}" fi run "$SSH_TRUENAS \"systemctl restart frpc\"" ok "frp tunnel ${FRONTEND_PORT} → frps:${FRP_REMOTE_PORT}" # ── 2. IONOS: HTTP vhost first (for ACME), then cert, then 443 ─────────────── log "IONOS: writing vhost ${VHOST_CONF}" VHOST="$(cat < ServerName ${SUBDOMAIN} Alias /.well-known/acme-challenge/ ${ACME_WEBROOT}/.well-known/acme-challenge/ ProxyPass /.well-known/acme-challenge/ ! RewriteEngine On RewriteCond %{REQUEST_URI} !^/\\.well-known/acme-challenge/ RewriteRule ^(.*)\$ https://%{HTTP_HOST}\$1 [R=301,L] ServerName ${SUBDOMAIN} SSLEngine on SSLCertificateFile /root/.acme.sh/${SUBDOMAIN}_ecc/${SUBDOMAIN}.cer SSLCertificateKeyFile /root/.acme.sh/${SUBDOMAIN}_ecc/${SUBDOMAIN}.key SSLCertificateChainFile /root/.acme.sh/${SUBDOMAIN}_ecc/ca.cer ProxyPreserveHost On ProxyPass / http://${VPS_IP}:${FRP_REMOTE_PORT}/ ProxyPassReverse / http://${VPS_IP}:${FRP_REMOTE_PORT}/ RequestHeader set X-Forwarded-Proto https RequestHeader set X-Real-IP %{REMOTE_ADDR}s EOF )" if [[ $DRY -eq 1 ]]; then echo " [dry] write ${VHOST_CONF}:"; echo "${VHOST}" | sed 's/^/ /' else printf '%s\n' "${VHOST}" | $SSH_IONOS "cat > ${VHOST_CONF}" fi # Enable HTTP side first so the ACME challenge can be served. If the cert files # don't exist yet, the 443 block would fail configtest — so issue the cert, then # enable. acme.sh writes the files; we (re)issue idempotently. log "IONOS: issuing/renewing Let's Encrypt cert (pinned CA — ZeroSSL stalls)" run "$SSH_IONOS \"acme.sh --set-default-ca --server letsencrypt\"" # Enable the site so the :80 ACME alias is live for webroot validation. run "$SSH_IONOS \"a2ensite ${SUBDOMAIN}.conf >/dev/null 2>&1 || true\"" # Issue only if not already present (idempotent); --force on renew handled by acme cron. run "$SSH_IONOS \"test -f /root/.acme.sh/${SUBDOMAIN}_ecc/${SUBDOMAIN}.cer || acme.sh --issue -d ${SUBDOMAIN} -w ${ACME_WEBROOT} --server letsencrypt\"" ok "cert present for ${SUBDOMAIN}" log "IONOS: enabling vhost + reloading Apache" run "$SSH_IONOS \"a2ensite ${SUBDOMAIN}.conf >/dev/null && apache2ctl configtest && systemctl reload apache2\"" ok "Apache serving https://${SUBDOMAIN}" # ── 3. Verify (force-resolve to dodge stale workstation DNS cache) ─────────── log "Verifying https://${SUBDOMAIN} (force-resolved to 82.165.206.45)" if [[ $DRY -eq 0 ]]; then CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \ --resolve "${SUBDOMAIN}:443:82.165.206.45" "https://${SUBDOMAIN}/" || echo 000)" if [[ "$CODE" =~ ^(200|301|302|307|308)$ ]]; then ok "Public HTTPS up — HTTP ${CODE}" else warn "Got HTTP ${CODE} — check: frpc running? app on :${FRONTEND_PORT}? frps:${FRP_REMOTE_PORT} reachable?" fi fi echo ok "DONE. ${SUBDOMAIN} → frps:${FRP_REMOTE_PORT} → frontend:${FRONTEND_PORT}" echo " Remember: record this in homelab-release-runbook.md §2 registry."