From 93cce6b9fd7817257ad14939f9d5601282fe266c Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Fri, 18 Sep 2026 12:46:56 -0500 Subject: [PATCH] deploy/gen_env.sh: generate .env with openssl secrets Builds deploy/.env from .env.example, generating SECRET_KEY and both database passwords with openssl (base64/hex only, so nothing needs quoting in the env file or the compose parser). Derives TS_HOSTNAME and ALLOWED_HOSTS from the tailnet hostname, optionally sets CORS_ALLOWED_ORIGINS/CSRF_TRUSTED_ORIGINS for split deployments, forces DEBUG=False and writes the file with mode 600. Modes: --no-prompt (defaults only), --update (refresh hostnames/auth key while keeping the existing secrets, reading the stored FQDN from ALLOWED_HOSTS), --force (rotate everything, with the SECRET_KEY warning in the docs). Refuses to overwrite an existing file otherwise. Verified: all modes, updated FQDN preservation, mode 600, and docker compose config accepting the generated file. --- AGENTS.md | 3 +- deploy/README.md | 23 +++++++- deploy/gen_env.sh | 144 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100755 deploy/gen_env.sh diff --git a/AGENTS.md b/AGENTS.md index 45b4c0e..106b88c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,8 @@ Project constraints (do not regress): serve.*.tailnet.json + compose.tailnet*.yml are the same stacks without AllowFunnel (tailnet-only, no public exposure). Images are pushed to the Gitea registry with deploy/push_*.sh (multi-arch, :latest + :sha, GIT_HASH - baked in for the version pill). + baked in for the version pill); deploy/gen_env.sh generates .env with + openssl secrets (--update keeps SECRET_KEY, --force rotates it). - Security/permission tests live in backend/apps/core/tests and need a one-time grant: GRANT ALL ON `test_j621`.* TO 'j621'@'%'; diff --git a/deploy/README.md b/deploy/README.md index 1a9187f..231eec0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -37,6 +37,8 @@ docker compose -f compose.tailnet.yml up -d Notes: - Project names are `…-tailnet` so both sets can be installed side by side. +- Serve needs no tailnet policy change (Funnel requires the `funnel` node + attribute in your ACLs), so this set works as soon as the sidecar joins. - Both sets use the same `deploy/data` directory (library, database, logs). Run one set per data directory — two MariaDB instances on one data dir would corrupt it. Giving the tailnet set its own `TS_HOSTNAME` (or running it on a @@ -44,11 +46,30 @@ Notes: - Switching a host from funnel to tailnet (or back) is just starting the other compose file with the same `.env`. +## Environment file + +`gen_env.sh` builds `deploy/.env` from `.env.example`, generating `SECRET_KEY` +and both database passwords with `openssl`: + +```bash +./gen_env.sh # asks for the Tailscale auth key + hostname(s) +./gen_env.sh --no-prompt # secrets and defaults only; fill TS_AUTHKEY later +./gen_env.sh --update # refresh hostnames/authkey, keep the existing secrets +./gen_env.sh --force # regenerate everything, including SECRET_KEY +``` + +It derives `TS_HOSTNAME` and `ALLOWED_HOSTS` from the tailnet hostname you +give it, and can set `CORS_ALLOWED_ORIGINS`/`CSRF_TRUSTED_ORIGINS` when you +provide the frontend's hostname for a split deployment. `--update` is the safe +way to add hostnames later; `--force` rotates SECRET_KEY, which invalidates +signed media URLs and stored e621 API keys. The file is written with mode 600 +and is git-ignored. + ## Usage ```bash cd deploy -cp .env.example .env # fill in TS_AUTHKEY, SECRET_KEY, ALLOWED_HOSTS, ... +./gen_env.sh # or: cp .env.example .env and edit it yourself docker compose up -d # default: everything on one host, public funnel # or docker compose -f compose.frontend.yml up -d diff --git a/deploy/gen_env.sh b/deploy/gen_env.sh new file mode 100755 index 0000000..47b5b0c --- /dev/null +++ b/deploy/gen_env.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# Generate deploy/.env from .env.example with fresh secrets. +# +# ./gen_env.sh # interactive: asks for the auth key/hostnames +# ./gen_env.sh --no-prompt # secrets + defaults only +# ./gen_env.sh --update # refresh hostnames/authkey, KEEP existing secrets +# ./gen_env.sh --force # regenerate everything (new SECRET_KEY!) +# +# SECRET_KEY and the database passwords come from openssl. Rotating +# SECRET_KEY invalidates signed media URLs and stored e621 API keys, which is +# why --update keeps it. +set -euo pipefail +cd "$(dirname "$0")" + +FORCE=0 +UPDATE=0 +NO_PROMPT=0 +TS_AUTHKEY="" +FQDN="" +FRONTEND="" +DEFAULT_FQDN="j621.rainbow-herring.ts.net" + +usage() { + sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' +} + +while [ $# -gt 0 ]; do + case "$1" in + --force) FORCE=1 ;; + --update) UPDATE=1 ;; + --no-prompt) NO_PROMPT=1 ;; + --ts-authkey) TS_AUTHKEY="${2:?missing value}"; shift ;; + --hostname) FQDN="${2:?missing value}"; shift ;; + --frontend) FRONTEND="${2:?missing value}"; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; + esac + shift +done + +command -v openssl >/dev/null || { echo "openssl is required." >&2; exit 1; } +command -v python3 >/dev/null || { echo "python3 is required." >&2; exit 1; } +[ -f .env.example ] || { echo "Run me from the deploy/ directory." >&2; exit 1; } + +if [ -f .env ] && [ "$FORCE" -eq 0 ] && [ "$UPDATE" -eq 0 ]; then + echo "deploy/.env already exists." + echo " --update keeps the existing secrets and just refreshes the rest" + echo " --force regenerates everything, including SECRET_KEY and DB passwords" + exit 1 +fi + +existing() { grep -E "^$1=" .env 2>/dev/null | head -1 | cut -d= -f2- || true; } + +gen_key() { openssl rand -base64 48 | tr -d '\n'; } +gen_password() { openssl rand -hex 24; } + +if [ "$UPDATE" -eq 1 ] && [ -f .env ]; then + SECRET_KEY="$(existing SECRET_KEY)" + DB_PASSWORD="$(existing DB_PASSWORD)" + DB_ROOT_PASSWORD="$(existing DB_ROOT_PASSWORD)" + TS_AUTHKEY="${TS_AUTHKEY:-$(existing TS_AUTHKEY)}" + # TS_HOSTNAME is the short label; the full FQDN lives in ALLOWED_HOSTS. + EXISTING_HOSTS="$(existing ALLOWED_HOSTS)" + FQDN="${FQDN:-${EXISTING_HOSTS%%,*}}" +fi + +# Fill whatever is still missing. +SECRET_KEY="${SECRET_KEY:-$(gen_key)}" +DB_PASSWORD="${DB_PASSWORD:-$(gen_password)}" +DB_ROOT_PASSWORD="${DB_ROOT_PASSWORD:-$(gen_password)}" + +if [ "$NO_PROMPT" -eq 0 ]; then + if [ -z "$TS_AUTHKEY" ]; then + read -rp "Tailscale auth key (tskey-..., Enter to fill in later): " TS_AUTHKEY + fi + if [ -z "$FQDN" ]; then + read -rp "Tailnet hostname of this deployment [$DEFAULT_FQDN]: " FQDN + fi + FQDN="${FQDN:-$DEFAULT_FQDN}" + if [ -z "$FRONTEND" ]; then + read -rp "Frontend hostname for the split deploys (optional, Enter to skip): " FRONTEND + fi +fi +FQDN="${FQDN:-$DEFAULT_FQDN}" + +# Derive the rest from the tailnet hostname. +TS_HOSTNAME="${FQDN%%.*}" +ALLOWED_HOSTS="$FQDN,localhost,127.0.0.1" +CORS_ALLOWED_ORIGINS="${FRONTEND:+https://$FRONTEND}" +CSRF_TRUSTED_ORIGINS="${FRONTEND:+https://$FRONTEND}" + +J621_SECRET_KEY="$SECRET_KEY" \ +J621_DB_PASSWORD="$DB_PASSWORD" \ +J621_DB_ROOT_PASSWORD="$DB_ROOT_PASSWORD" \ +J621_TS_AUTHKEY="$TS_AUTHKEY" \ +J621_TS_HOSTNAME="$TS_HOSTNAME" \ +J621_ALLOWED_HOSTS="$ALLOWED_HOSTS" \ +J621_CORS_ALLOWED_ORIGINS="$CORS_ALLOWED_ORIGINS" \ +J621_CSRF_TRUSTED_ORIGINS="$CSRF_TRUSTED_ORIGINS" \ +python3 - <<'PY' +import os +import re +from pathlib import Path + +text = Path(".env.example").read_text() + +def apply(name): + value = os.environ.get(f"J621_{name}") + if not value: + return text + line = f"{name}={value}" + pattern = re.compile(rf"^(?:# )?{re.escape(name)}=.*$", re.M) + if pattern.search(text): + return pattern.sub(line, text, count=1) + return text + f"\n{line}\n" + +for name in ( + "SECRET_KEY", + "TS_AUTHKEY", + "TS_HOSTNAME", + "ALLOWED_HOSTS", + "CORS_ALLOWED_ORIGINS", + "CSRF_TRUSTED_ORIGINS", + "DB_PASSWORD", + "DB_ROOT_PASSWORD", +): + text = apply(name) + +text = re.sub(r"^DEBUG=.*$", "DEBUG=False", text, count=1, flags=re.M) +Path(".env").write_text(text) +PY + +chmod 600 .env + +echo +echo "Wrote deploy/.env (mode 600):" +echo " SECRET_KEY $([ "$UPDATE" -eq 1 ] && echo 'kept from the existing file' || echo 'generated with openssl')" +echo " DB passwords $([ "$UPDATE" -eq 1 ] && echo 'kept from the existing file' || echo 'generated with openssl')" +echo " TS_HOSTNAME $TS_HOSTNAME" +echo " ALLOWED_HOSTS $ALLOWED_HOSTS" +[ -n "$CORS_ALLOWED_ORIGINS" ] && echo " cross-origin $CORS_ALLOWED_ORIGINS" +[ -z "$TS_AUTHKEY" ] && echo " TS_AUTHKEY still empty - paste your Tailscale auth key before starting" +echo +echo "Next: docker compose -f compose.yml up -d (or a compose.tailnet*.yml variant)"