#!/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" # Cross-origin access: the optional split-deploy frontend plus the desktop # shell, which is always a different origin from the backend. On --update the # existing list is kept, so hand-added origins survive. CORS_ALLOWED_ORIGINS="" add_origin() { [ -n "${1:-}" ] || return 0 case ",$CORS_ALLOWED_ORIGINS," in *",$1,"*) ;; *) CORS_ALLOWED_ORIGINS="${CORS_ALLOWED_ORIGINS:+$CORS_ALLOWED_ORIGINS,}$1" ;; esac } if [ "$UPDATE" -eq 1 ]; then while IFS= read -r origin; do add_origin "$origin" done < <(existing CORS_ALLOWED_ORIGINS | tr ',' '\n') fi [ -n "$FRONTEND" ] && add_origin "https://$FRONTEND" add_origin "app://j621" 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)"