From 4179cb51566de528b35735f1db25070c878ae095 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 13 May 2026 22:57:53 +0000 Subject: [PATCH] docs(license): self-hosted server runbook + multi-tenancy plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SETUP-LICENSE-SERVER.md — end-to-end install runbook for the license server on the existing invixiom box (Ubuntu 24.04). Covers DNS, system packages, Postgres + API in Docker, dedicated system user, secrets layout under /srv/datatools-license/secrets (mode 400), nginx config in a separate sites-available/unalogix file, Let's Encrypt cert issuance, smoke tests, backups, monitoring, key rotation, and rollback. Multi-tenancy is explicit at every layer: separate DNS zone (unalogix.com vs invixiom.com), separate nginx file, separate TLS cert, dedicated backend ports (8090 for the API, 5433 for Postgres, both localhost-only), separate docker compose project and volume. No invixiom service is touched. LICENSE-SERVER.md updated: hosting choice moved from "Fly.io / Render" (rejected) to self-hosted (decided). Points at the new runbook for ops specifics. ADMIN.md pointer table updated. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ADMIN.md | 1 + docs/LICENSE-SERVER.md | 8 +- docs/SETUP-LICENSE-SERVER.md | 553 +++++++++++++++++++++++++++++++++++ 3 files changed, 559 insertions(+), 3 deletions(-) create mode 100644 docs/SETUP-LICENSE-SERVER.md diff --git a/docs/ADMIN.md b/docs/ADMIN.md index 61dfc91..55948ce 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -266,3 +266,4 @@ independent secure locations. | `~/.datatools/license.json` | Where activated licenses are stored on each machine | | `~/.datatools-creator/issued.jsonl` | Creator-side issuance log (one JSON line per mint) | | `docs/LICENSE-SERVER.md` | Design for the future online issuance + record-keeping system | +| `docs/SETUP-LICENSE-SERVER.md` | Self-hosted server install runbook (DNS, Docker, nginx, TLS, backups) | diff --git a/docs/LICENSE-SERVER.md b/docs/LICENSE-SERVER.md index 36bd01f..3b5282d 100644 --- a/docs/LICENSE-SERVER.md +++ b/docs/LICENSE-SERVER.md @@ -223,9 +223,11 @@ across any of them — that's the whole point. ## Open questions -- **Hosting choice.** A managed Postgres + small Python app is well - under $20/mo at the expected volume. Fly.io and Render are the - obvious candidates; AWS is overkill at this scale. +- **Hosting choice.** *Decided: self-hosted* on the existing + `46.225.166.142` box alongside the `*.invixiom.com` services. + Runbook in `SETUP-LICENSE-SERVER.md`. Operator owns uptime, + backups, TLS renewal, and key custody — see that doc's + "Operational concerns" section. - **Per-seat or per-device limits?** v1 says no. Revisit if/when abuse is observable. - **Email delivery.** Postmark or SES — both fine. Pick whichever the diff --git a/docs/SETUP-LICENSE-SERVER.md b/docs/SETUP-LICENSE-SERVER.md new file mode 100644 index 0000000..d5cb97b --- /dev/null +++ b/docs/SETUP-LICENSE-SERVER.md @@ -0,0 +1,553 @@ +# SETUP — Self-hosted license server runbook + +End-to-end build instructions for `licenses.datatools.unalogix.com` on +the existing invixiom box (Ubuntu 24.04, public IP `46.225.166.142`). + +Audience: creator/operator. Read top to bottom on first install; use as +a reference thereafter. + +Companions: +- `LICENSE-SERVER.md` — the architecture / design rationale +- `ADMIN.md` — day-2 ops (minting comps, looking at the issuance log) + +--- + +## 0. Multi-tenancy: where this lands among existing services + +This box already hosts the `*.invixiom.com` family (kasm, files, lifeos, +code, gitea) via one shared nginx + one shared Let's Encrypt cert. +DataTools is intentionally separated from that stack at every layer: + +| Layer | Existing | New | +|---|---|---| +| **DNS zone** | `invixiom.com` | `unalogix.com` (different TLD) | +| **nginx file** | `/etc/nginx/sites-available/invixiom` | `/etc/nginx/sites-available/unalogix` | +| **nginx symlink** | `sites-enabled/invixiom` | `sites-enabled/unalogix` | +| **TLS cert** | `letsencrypt/live/kasm.invixiom.com[-0001]` | `letsencrypt/live/datatools.unalogix.com` | +| **Backend port** | 8000, 8002, 8003, 8080, 8081, 8443 | **8090** (mint API), **5433** (Postgres, localhost-only) | +| **Docker compose project** | per-service (kasm, lifeos, gitea) | `datatools-license` | +| **Docker volume** | per service | `datatools_pg_data` | +| **Filesystem root** | various | `/srv/datatools-license/` | +| **System user** | various | `datatools-api` (UID auto-assigned, no shell) | + +Nothing in the invixiom stack is read, modified, or referenced by the +datatools stack. Restart, upgrade, or remove either without affecting +the other. + +--- + +## 1. Pre-flight checklist (off-box, before any commands run) + +These have to be done by the operator outside this box. The build +won't proceed without them. + +### 1a. DNS records + +In your `unalogix.com` registrar / DNS panel, add: + +``` +A datatools.unalogix.com 46.225.166.142 +A licenses.datatools.unalogix.com 46.225.166.142 +``` + +Verify before continuing: + +```bash +dig +short datatools.unalogix.com +dig +short licenses.datatools.unalogix.com +# Both should print: 46.225.166.142 +``` + +DNS propagation can take 1–60 minutes. Let's Encrypt won't issue +certs until DNS resolves correctly. + +### 1b. Postmark account (transactional email) + +1. Sign up at https://postmarkapp.com (free 100 emails/mo, $15/mo for + the volume range we'll be in). +2. Verify the `unalogix.com` domain (DNS TXT/CNAME records — Postmark + will tell you exactly what to add). +3. Create a Server, copy the **Server API Token**. Stash it; we'll put + it in the app's `.env`. +4. Configure the sender address: `licenses@datatools.unalogix.com`. + +If you prefer SES, Mailgun, Resend, etc. — fine, just swap the +adapter (see §6). Postmark is the recommended default. + +### 1c. Cloudflare in front (recommended) + +Move `unalogix.com` DNS hosting to Cloudflare and enable proxy ("orange +cloud") on both subdomains. Gets you free DDoS protection, WAF, and rate +limiting. **Origin TLS still goes through Let's Encrypt on this box**; +Cloudflare adds a second TLS hop in front. Cert renewal still works +because we use HTTP-01 challenge on the origin, which Cloudflare +proxies transparently. + +If you skip this, the public webhook endpoint is directly hammerable. +Not catastrophic at low scale, but the free protection is worth taking. + +### 1d. Gumroad webhook secret + +In Gumroad's seller dashboard → Settings → Advanced → "Ping URL": + +``` +URL: https://licenses.datatools.unalogix.com/webhooks/gumroad +Secret: +``` + +Don't enter this until §10 ("PR 2 cutover") — the endpoint won't exist +yet during the Mint API build. + +--- + +## 2. One-time host setup + +Run as `root` (or via `sudo`). + +```bash +# Update apt cache and pull in the bits the rest of the doc needs. +apt-get update +apt-get install -y \ + docker-compose-plugin \ + certbot \ + python3-certbot-nginx \ + postgresql-client-16 # for psql to reach the containerized DB + +# Sanity check: docker + compose v2 are already installed via Docker CE. +docker --version +docker compose version + +# Create the system user the app process will run as (no shell, no home). +adduser --system --group --no-create-home --shell /usr/sbin/nologin datatools-api + +# Filesystem layout under /srv (separate from /opt to make the +# multi-tenant boundary obvious on disk). +install -d -o datatools-api -g datatools-api -m 750 /srv/datatools-license +install -d -o datatools-api -g datatools-api -m 750 /srv/datatools-license/app +install -d -o datatools-api -g datatools-api -m 750 /srv/datatools-license/secrets +install -d -o datatools-api -g datatools-api -m 750 /srv/datatools-license/backups +``` + +The `secrets/` dir is mode 750 owned by `datatools-api`. The private +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. + +### Firewall recommendation (separate decision) + +The box currently runs without UFW. Enabling it now would affect all +existing services. Two options: + +- **(A) Don't enable UFW.** Leave the cloud provider's network firewall + as the perimeter. This is the current state. +- **(B) Enable UFW with `allow 22, 80, 443` only.** Forces every Docker + service to bind to `127.0.0.1` (some currently bind `0.0.0.0`). Will + break any direct-port access until those binds are updated. + +Default for this runbook: **(A)**. Revisit independently of the +DataTools rollout. The DataTools containers always bind to `127.0.0.1` +regardless. + +--- + +## 3. Database (Postgres in Docker) + +Postgres lives inside the datatools compose project — separate from +every other service on the box, separate volume, separate port, +localhost-only binding. + +`/srv/datatools-license/compose.yml`: + +```yaml +services: + postgres: + image: postgres:16-alpine + container_name: datatools-postgres + restart: unless-stopped + environment: + POSTGRES_DB: datatools_licenses + POSTGRES_USER: datatools_api + POSTGRES_PASSWORD_FILE: /run/secrets/pg_password + secrets: + - pg_password + volumes: + - datatools_pg_data:/var/lib/postgresql/data + ports: + - "127.0.0.1:5433:5432" # localhost-only, non-default port + healthcheck: + test: ["CMD-SHELL", "pg_isready -U datatools_api -d datatools_licenses"] + interval: 10s + timeout: 3s + retries: 5 + + api: + image: datatools-license-api:latest # built locally; see §4 + 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 + PG_PASSWORD_FILE: /run/secrets/pg_password + secrets: + - pg_password + - postmark_token + - license_privkey + - gumroad_secret + ports: + - "127.0.0.1:8090:8000" # localhost-only; nginx is the only path in + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 3s + 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 } + +volumes: + datatools_pg_data: + name: datatools_pg_data +``` + +Populate the secrets (each file should contain the value with no +trailing newline): + +```bash +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 + +# Ed25519 private signing key (from password manager OR generate-keypair.py) +echo -n "" > secrets/license_privkey + +# Gumroad webhook secret from §1d (generate when needed; placeholder OK for PR 1) +openssl rand -hex 32 > secrets/gumroad_secret + +# Lock everything down. +chmod 400 secrets/* +chown -R datatools-api:datatools-api secrets/ +``` + +The corresponding **public** key for `DATATOOLS_LICENSE_PUBKEY` goes +in `/srv/datatools-license/.env` (it's not secret — it's already in +every shipped binary): + +```bash +echo "DATATOOLS_LICENSE_PUBKEY=" > /srv/datatools-license/.env +chmod 640 /srv/datatools-license/.env +chown datatools-api:datatools-api /srv/datatools-license/.env +``` + +--- + +## 4. App image build + +The Mint API source lives in this repo under `server/` (new directory +introduced by PR 1). Build the Docker image: + +```bash +cd /srv/datatools-license/app +git clone https://git.invixiom.com/giteadmin/datatools-dev.git . +docker build -t datatools-license-api:latest -f server/Dockerfile server/ +``` + +Schema bootstrap (one-time, after first `docker compose up`): + +```bash +docker compose exec api alembic upgrade head +``` + +Smoke test: + +```bash +curl -s http://127.0.0.1:8090/health +# expects: {"status":"ok","db":"ok"} +``` + +--- + +## 5. nginx config + +`/etc/nginx/sites-available/unalogix` — **new file**, do not merge +into `invixiom`: + +```nginx +# Marketing / product site (datatools.unalogix.com) — static for now. +server { + listen 80; + server_name datatools.unalogix.com licenses.datatools.unalogix.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name datatools.unalogix.com; + + ssl_certificate /etc/letsencrypt/live/datatools.unalogix.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/datatools.unalogix.com/privkey.pem; + + root /srv/datatools-license/site; # static landing page; create later + index index.html; + + location / { + try_files $uri $uri/ =404; + } +} + +# License operations subdomain. +server { + listen 443 ssl http2; + server_name licenses.datatools.unalogix.com; + + ssl_certificate /etc/letsencrypt/live/datatools.unalogix.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/datatools.unalogix.com/privkey.pem; + + # Block /internal/* from the public side as defense-in-depth. + # (The app also enforces this server-side; this is layered.) + location /internal/ { + return 404; + } + + location / { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Gumroad webhook payloads are tiny but tighten anyway. + client_max_body_size 1m; + + # Basic rate limiting: 30 req/min/IP on /webhooks/* and /portal/*. + # Tune in nginx.conf with a `limit_req_zone` directive. + # limit_req zone=licenses burst=10 nodelay; + } +} +``` + +Enable + reload: + +```bash +ln -s /etc/nginx/sites-available/unalogix /etc/nginx/sites-enabled/unalogix +nginx -t # validate +systemctl reload nginx +``` + +--- + +## 6. TLS cert + +Use the standalone http-01 challenge (nginx-plugin works too; this is +slightly more explicit): + +```bash +certbot certonly \ + --webroot -w /var/www/html \ + -d datatools.unalogix.com \ + -d licenses.datatools.unalogix.com \ + --agree-tos \ + --email michael.dombaugh@gmail.com \ + --non-interactive +``` + +Cert lands at `/etc/letsencrypt/live/datatools.unalogix.com/`. +Auto-renewal is already configured by the certbot package (systemd +timer `certbot.timer`). Confirm: + +```bash +systemctl list-timers certbot.timer +``` + +--- + +## 7. Bring it up + +```bash +cd /srv/datatools-license +docker compose up -d +docker compose ps # both services should be 'running (healthy)' +docker compose logs -f api +``` + +Public smoke test: + +```bash +curl -s https://licenses.datatools.unalogix.com/health +# expects: {"status":"ok","db":"ok"} +``` + +--- + +## 8. Verification — end-to-end internal mint + +From your laptop (NOT the server), open an SSH tunnel for the internal +endpoint: + +```bash +ssh -L 8090:127.0.0.1:8090 michael@46.225.166.142 -N +# Leave running; in another terminal: + +curl -X POST http://127.0.0.1:8090/internal/mint \ + -H "Authorization: Bearer $DATATOOLS_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name":"Test Buyer", + "email":"test@example.com", + "tier":"core", + "years":1, + "source":"manual", + "notes":"smoke test" + }' +``` + +Expected: 200 + a `DTLIC2:...` blob + a row inserted in the `licenses` +table. Confirm with: + +```bash +docker compose exec postgres \ + psql -U datatools_api -d datatools_licenses \ + -c "SELECT license_key, email, tier, source FROM licenses;" +``` + +Then **revoke the test row** before going further: + +```bash +docker compose exec postgres \ + psql -U datatools_api -d datatools_licenses \ + -c "DELETE FROM licenses WHERE email = 'test@example.com';" +``` + +--- + +## 9. Operational concerns + +### Backups (Postgres → off-site) + +`/etc/cron.daily/datatools-license-backup`: + +```bash +#!/bin/bash +set -euo pipefail +TS=$(date -u +%Y%m%dT%H%M%SZ) +OUT=/srv/datatools-license/backups/db-${TS}.sql.gz +docker compose -f /srv/datatools-license/compose.yml exec -T postgres \ + pg_dump -U datatools_api datatools_licenses | gzip > "$OUT" +chmod 600 "$OUT" +# Off-site copy — pick one: +# rclone copy "$OUT" remote:datatools-license-backups/ +# aws s3 cp "$OUT" s3://datatools-backups/db/ --sse AES256 +find /srv/datatools-license/backups -name 'db-*.sql.gz' -mtime +30 -delete +``` + +Pick an off-site target. Without one, a disk failure loses every +customer record. Test the restore at least once on a staging copy. + +### Monitoring + +External uptime probe (free): +1. UptimeRobot account → add monitor for `https://licenses.datatools.unalogix.com/health`. +2. 5-minute interval, alert to email/SMS. + +Container health is already handled by `restart: unless-stopped` + +healthcheck. To see recent failures: + +```bash +docker compose ps # last health-check status +docker compose logs api --tail 200 +journalctl -u docker --since '1 hour ago' | grep datatools +``` + +### Log rotation + +Docker handles container logs; cap their size in +`/etc/docker/daemon.json`: + +```json +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } +} +``` + +Then `systemctl restart docker` (this restarts all containers — schedule +during a quiet window). + +### Key rotation (future) + +If the private signing key is ever compromised: + +1. Generate a new keypair (`scripts/generate_keypair.py`). +2. Build and ship a desktop release with the new pubkey embedded. +3. Update `/srv/datatools-license/secrets/license_privkey` and + `/srv/datatools-license/.env`'s pubkey. +4. `docker compose restart api`. +5. Re-issue every active license (script that queries the DB, calls + `/internal/mint`, emails buyers). Old blobs will fail verification + in the new desktop build. + +Plan a 90-day overlap window where the desktop verifies against +*both* keys before retiring the old pubkey. (Verification logic +change to the desktop app — not in scope for PR 1.) + +--- + +## 10. PR cutover sequence + +This runbook covers the box-level scaffolding. Application code lands +in three independently shippable PRs: + +| PR | Adds | Ship gate | Webhook live? | +|---|---|---|---| +| **1** | Source-agnostic Mint API + Postgres + `datatools-admin mint` CLI | Operator can mint a comp license through the server | No | +| **2** | Gumroad adapter + webhook receiver + email send | Real Gumroad sale auto-mints + emails buyer | **Yes** (enable in Gumroad dashboard at this PR's deploy) | +| **3** | Renewal / re-delivery portal | Buyer self-services renewals and lost-blob re-delivery | (unchanged) | + +§1d (Gumroad webhook URL) is **filled in during PR 2's deploy**, not +before. Until then the endpoint returns 404. + +--- + +## 11. Rollback + +Each component is independently reversible. + +```bash +# Stop and remove containers (DB volume persists) +docker compose -f /srv/datatools-license/compose.yml down + +# Full teardown including DB (DESTRUCTIVE — backup first) +docker compose -f /srv/datatools-license/compose.yml down -v + +# Remove nginx site +rm /etc/nginx/sites-enabled/unalogix +nginx -t && systemctl reload nginx + +# Revoke + delete TLS cert +certbot delete --cert-name datatools.unalogix.com + +# Remove filesystem +rm -rf /srv/datatools-license # NOTE: includes secrets dir; backup first + +# Remove system user +deluser datatools-api +delgroup datatools-api +``` + +DNS records can stay or be removed — they're not on this host.