diff --git a/docs/SETUP-LICENSE-SERVER.md b/docs/SETUP-LICENSE-SERVER.md index d5cb97b..1a95bdc 100644 --- a/docs/SETUP-LICENSE-SERVER.md +++ b/docs/SETUP-LICENSE-SERVER.md @@ -133,6 +133,16 @@ signing key and Postmark token live there as mode-400 files — never in environment-variable-via-systemd-EnvironmentFile, never in the docker-compose file, never anywhere `root` doesn't need to look. +> **Gotcha — secret file ownership UID.** Docker compose's +> `uid:`/`gid:`/`mode:` long-form on `secrets:` is silently ignored +> for **file-based** secrets (it's a swarm-mode-only feature). The +> file inside the container appears with whatever ownership it has +> on the host, and the API runs as UID 10001 (the `app` user from +> the Dockerfile). So chown the actual files to **10001** (a numeric +> UID that doesn't exist on the host — that's fine, chown accepts +> it) and rely on the parent dir's mode 750 + ownership for host-side +> access control. See §3 below for the corrected `chown` step. + ### Firewall recommendation (separate decision) The box currently runs without UFW. Enabling it now would affect all @@ -181,24 +191,26 @@ services: retries: 5 api: - image: datatools-license-api:latest # built locally; see §4 + build: + context: ./app + dockerfile: server/Dockerfile + image: datatools-license-api:latest container_name: datatools-api restart: unless-stopped depends_on: postgres: condition: service_healthy environment: - DATABASE_URL: postgresql://datatools_api@postgres:5432/datatools_licenses - DATATOOLS_LICENSE_PUBKEY: ${DATATOOLS_LICENSE_PUBKEY} - POSTMARK_TOKEN_FILE: /run/secrets/postmark_token - DATATOOLS_LICENSE_PRIVKEY_FILE: /run/secrets/license_privkey - GUMROAD_WEBHOOK_SECRET_FILE: /run/secrets/gumroad_secret + DATABASE_URL: postgresql+psycopg://datatools_api@postgres:5432/datatools_licenses PG_PASSWORD_FILE: /run/secrets/pg_password + DATATOOLS_ADMIN_TOKEN_FILE: /run/secrets/admin_token + # PR 2 adds: POSTMARK_TOKEN_FILE, GUMROAD_WEBHOOK_SECRET_FILE. + # Production keypair (replaces in-tree dev key): set + # DATATOOLS_LICENSE_PRIVKEY_FILE: /run/secrets/license_privkey + # and DATATOOLS_LICENSE_PUBKEY: before shipping v1.0. secrets: - pg_password - - postmark_token - - license_privkey - - gumroad_secret + - admin_token ports: - "127.0.0.1:8090:8000" # localhost-only; nginx is the only path in healthcheck: @@ -208,10 +220,10 @@ services: retries: 3 secrets: - pg_password: { file: ./secrets/pg_password } - postmark_token: { file: ./secrets/postmark_token } - license_privkey: { file: ./secrets/license_privkey } - gumroad_secret: { file: ./secrets/gumroad_secret } + pg_password: { file: ./secrets/pg_password } + admin_token: { file: ./secrets/admin_token } + # PR 2 adds: postmark_token, gumroad_secret. Production keypair + # rotation adds: license_privkey. volumes: datatools_pg_data: @@ -219,7 +231,8 @@ volumes: ``` Populate the secrets (each file should contain the value with no -trailing newline): +trailing newline). For PR 1, only `pg_password` and `admin_token` +are required; the rest land in PR 2 / production key rotation. ```bash cd /srv/datatools-license @@ -227,18 +240,20 @@ cd /srv/datatools-license # Random 32-char hex DB password openssl rand -hex 32 > secrets/pg_password -# Postmark server API token from §1b -echo -n "" > secrets/postmark_token +# Random admin Bearer token (CLI auth). Save this — you'll need it +# on your laptop to talk to /internal/* via the SSH tunnel. +openssl rand -hex 32 > secrets/admin_token -# Ed25519 private signing key (from password manager OR generate-keypair.py) -echo -n "" > secrets/license_privkey +# --- PR 2 / production-key follow-ups (skip for PR 1 bring-up) --- +# echo -n "" > secrets/postmark_token +# echo -n "" > secrets/license_privkey +# openssl rand -hex 32 > secrets/gumroad_secret -# Gumroad webhook secret from §1d (generate when needed; placeholder OK for PR 1) -openssl rand -hex 32 > secrets/gumroad_secret - -# Lock everything down. +# Lock everything down. The numeric 10001 matches the in-container +# `app` user (Dockerfile-defined), letting the API read the file +# while keeping host-side access gated by the parent dir's mode 750. chmod 400 secrets/* -chown -R datatools-api:datatools-api secrets/ +chown 10001:10001 secrets/* ``` The corresponding **public** key for `DATATOOLS_LICENSE_PUBKEY` goes @@ -281,6 +296,21 @@ curl -s http://127.0.0.1:8090/health ## 5. nginx config +> **Gotcha — nginx version syntax.** Ubuntu 24.04 ships nginx 1.24, +> which uses the legacy `listen 443 ssl http2;` form. The standalone +> `http2 on;` directive arrived in nginx 1.25 and will error on 1.24 +> with `unknown directive "http2"`. The config below uses the 1.24 +> form. +> +> **Bring-up sequence.** This config references a TLS cert at +> `/etc/letsencrypt/live/datatools.unalogix.com/`, which doesn't +> exist on a fresh install — nginx would refuse to start. The +> working sequence is: (a) install a temporary HTTP-only config +> that serves `.well-known/acme-challenge/` and returns 503 for +> everything else, (b) `nginx -s reload`, (c) run `certbot +> certonly --webroot`, (d) replace with the HTTPS config below, +> (e) `nginx -s reload` again. See §6. + `/etc/nginx/sites-available/unalogix` — **new file**, do not merge into `invixiom`: @@ -293,7 +323,7 @@ server { } server { - listen 443 ssl http2; + listen 443 ssl http2; # nginx 1.24 syntax (Ubuntu 24.04) server_name datatools.unalogix.com; ssl_certificate /etc/letsencrypt/live/datatools.unalogix.com/fullchain.pem; @@ -309,7 +339,7 @@ server { # License operations subdomain. server { - listen 443 ssl http2; + listen 443 ssl http2; # nginx 1.24 syntax (Ubuntu 24.04) server_name licenses.datatools.unalogix.com; ssl_certificate /etc/letsencrypt/live/datatools.unalogix.com/fullchain.pem;