From e5949c3967599029da90940f97fdc36d90f808dc Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Thu, 20 Aug 2026 15:04:03 +0400 Subject: [PATCH] fix(deploy): provision tenant API domains Reconcile TLS, exact CORS, and backend proxying before release activation so every storefront uses its derived API host. --- .github/workflows/deploy.yml | 30 ++++++ docs/DEPLOYMENT.md | 20 +++- docs/backend/BACKEND-HANDOFF.md | 11 +- docs/backend/README.md | 2 + docs/backend/TENANT-API-DOMAIN-HANDOFF.md | 124 ++++++++++++++++++++++ scripts/deploy/add-domain.sh | 11 ++ scripts/deploy/configure-api-domain.sh | 107 +++++++++++++++++++ scripts/deploy/server-setup.sh | 16 ++- 8 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 docs/backend/TENANT-API-DOMAIN-HANDOFF.md create mode 100755 scripts/deploy/configure-api-domain.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f21f13c..ae91d83 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -82,6 +82,36 @@ jobs: printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts + - name: Reconcile tenant API domains + env: + HOST: ${{ secrets.DEPLOY_HOST }} + USER: ${{ secrets.DEPLOY_USER }} + STOREFRONT_DOMAINS: ${{ secrets.STOREFRONT_DOMAINS }} + CERTBOT_EMAIL: ${{ secrets.CERTBOT_EMAIL }} + BACKEND_UPSTREAM: ${{ secrets.BACKEND_UPSTREAM }} + run: | + set -euo pipefail + test -n "$HOST" || { echo "secret DEPLOY_HOST is empty" >&2; exit 1; } + test -n "$USER" || { echo "secret DEPLOY_USER is empty" >&2; exit 1; } + test -n "$STOREFRONT_DOMAINS" || { echo "secret STOREFRONT_DOMAINS is empty" >&2; exit 1; } + test -n "$CERTBOT_EMAIL" || { echo "secret CERTBOT_EMAIL is empty" >&2; exit 1; } + BACKEND_UPSTREAM="${BACKEND_UPSTREAM:-https://127.0.0.1:445}" + [[ "$CERTBOT_EMAIL" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || { + echo "CERTBOT_EMAIL is invalid" >&2; exit 1; + } + [[ "$BACKEND_UPSTREAM" =~ ^https?://[A-Za-z0-9.:-]+$ ]] || { + echo "BACKEND_UPSTREAM is invalid" >&2; exit 1; + } + + SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes" + for domain in $STOREFRONT_DOMAINS; do + [[ "$domain" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || { + echo "invalid storefront domain: $domain" >&2; exit 1; + } + $SSH "$USER@$HOST" sudo /usr/local/sbin/marketplaces-configure-api-domain \ + --domain "$domain" --email "$CERTBOT_EMAIL" --upstream "$BACKEND_UPSTREAM" + done + - name: Upload release env: HOST: ${{ secrets.DEPLOY_HOST }} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 15f63bb..80a0c91 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,6 +1,9 @@ # Deployment — server provisioning, CD, TLS -Frontend only. The backend service (`:8080`) is a separate developer's responsibility. API hostnames are separate reverse proxies and will return `502` until their upstream exists. +Frontend deployment plus API-domain edge configuration. The backend service is a +separate developer's responsibility. API hostnames are separate reverse proxies +and return `502` until their configured upstream exists (production currently +defaults to `https://127.0.0.1:445`). **Multi-tenant, one bundle.** Every customer domain is served by the same build. The SPA resolves its tenant from the `Host` header ([BACKEND-HANDOFF §1a](backend/BACKEND-HANDOFF.md)). One deploy updates every domain simultaneously — there is no per-tenant build and no per-tenant deploy. @@ -18,6 +21,7 @@ nested names such as `api.store1.example.com`. |---|---| | `scripts/deploy/server-setup.sh` | One-time server provisioning. Idempotent. Run as root. | | `scripts/deploy/add-domain.sh` | Attach one domain + issue TLS. Run per domain, as root, after DNS resolves. | +| `scripts/deploy/configure-api-domain.sh` | Configure `api.` TLS, exact CORS, backend proxy, and JSON bootstrap verification. | | `.github/workflows/deploy.yml` | CD: build → upload → atomic swap → verify. Triggers on push to `main`. | --- @@ -56,7 +60,10 @@ Copy `scripts/deploy/` to the server and run: sudo bash server-setup.sh --pubkey "$(cat marketplaces_deploy.pub)" ``` -This installs nginx + certbot, creates a **key-only** `deploy` user with no password, writes the catch-all nginx config, opens 80/443/OpenSSH in ufw, and grants `deploy` exactly one sudo right: `systemctl reload nginx`. +This installs nginx + certbot, creates a **key-only** `deploy` user with no +password, writes the catch-all nginx config, opens 80/443/OpenSSH in ufw, and +installs a root-owned, argument-validating API-domain helper. The deploy user may +run that helper and reload nginx, but cannot replace the helper. Verify before continuing: @@ -82,6 +89,15 @@ The output is the `DEPLOY_KNOWN_HOSTS` secret. Pinning it means a rebuilt or imp | `DEPLOY_USER` | `deploy` | | `DEPLOY_SSH_KEY` | contents of the **private** key file | | `DEPLOY_KNOWN_HOSTS` | output of `ssh-keyscan -H ` | +| `STOREFRONT_DOMAINS` | space-separated full hosts, e.g. `gorbushka.market store1.example.com` | +| `CERTBOT_EMAIL` | operations email used for Let's Encrypt | +| `BACKEND_UPSTREAM` | optional; defaults to `https://127.0.0.1:445` | + +Before deploying, point every derived API hostname at the server. For the +example above, DNS must resolve both `api.gorbushka.market` and +`api.store1.example.com`. The workflow deliberately stops before release +activation if DNS, certificate issuance, nginx validation, or the JSON +`/bootstrap` check fails. ### 3.5 Deploy diff --git a/docs/backend/BACKEND-HANDOFF.md b/docs/backend/BACKEND-HANDOFF.md index d245a27..0dd56b6 100644 --- a/docs/backend/BACKEND-HANDOFF.md +++ b/docs/backend/BACKEND-HANDOFF.md @@ -8,6 +8,10 @@ Single entry point for a backend developer picking this up cold. Written 2026-08 ## 1a. Multi-tenancy — the thing that shapes every endpoint +The final executable infrastructure/backend contract is +[TENANT-API-DOMAIN-HANDOFF.md](TENANT-API-DOMAIN-HANDOFF.md). Follow it for +hostname normalization, CORS, nginx, TLS, CI secrets, and acceptance checks. + One deployed bundle serves **every customer domain**. There is no per-tenant build. The chain is: 1. [`TenantResolverService`](../../src/app/core/config/tenant-resolver.service.ts) reads the complete current browser hostname and protocol (localhost still uses the development proxy). @@ -87,7 +91,12 @@ npm install # pulls @marketplaces/auth over git, no credentials needed npm run build # -> dist/dexarmarket ``` -Angular 22, Node 20+. nginx serves `/srv/marketplaces/current/frontend`, so deploying means copying `dist/dexarmarket` there — **manually, today.** There is no CD pipeline. Because of the multi-tenant design (§1a), one such deploy updates every domain at once. +Angular 22, Node 24+. nginx serves `/srv/marketplaces/current/frontend`. +[`deploy.yml`](../../.github/workflows/deploy.yml) builds and atomically deploys +pushes to `main`; one deployment updates every domain at once. Before activation, +the workflow reconciles TLS, exact CORS, and reverse proxying for every host in +`STOREFRONT_DOMAINS`. Production deployment requires the documented CI secrets +and the one-time [`server-setup.sh`](../../scripts/deploy/server-setup.sh) run. ## 7. Known open decisions diff --git a/docs/backend/README.md b/docs/backend/README.md index 8c28705..4ebcab7 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -2,6 +2,8 @@ > **New here? Start with [BACKEND-HANDOFF.md](BACKEND-HANDOFF.md)** — reading order, current infrastructure state, auth surface, and what a working dev environment still needs. > +> **Implementing tenant routing/nginx? [TENANT-API-DOMAIN-HANDOFF.md](TENANT-API-DOMAIN-HANDOFF.md)** is the final host normalization, CORS, TLS, reverse-proxy, CI-secret, and acceptance contract. +> > **Want every endpoint in one place? [FRONTEND-API-SURFACE-COMPLETE.md](FRONTEND-API-SURFACE-COMPLETE.md)** — the final handoff doc. Generated directly from source, all 90 endpoints the frontend currently calls plus 3 response-shape additions on existing endpoints, marked Specified / Inferred / Undocumented against the contracts below. Use it to see gaps across all contracts at once; use the individual Phase/Track docs for full entity shapes and invariants. This directory is the complete set of wire contracts for building the backend behind [Product Plan v3.1](../PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md). Each doc specifies entities, endpoints, and invariants only — never DB schema or service boundaries, which stay backend's own call. diff --git a/docs/backend/TENANT-API-DOMAIN-HANDOFF.md b/docs/backend/TENANT-API-DOMAIN-HANDOFF.md new file mode 100644 index 0000000..a0be7f5 --- /dev/null +++ b/docs/backend/TENANT-API-DOMAIN-HANDOFF.md @@ -0,0 +1,124 @@ +# Final handoff: tenant API domains + +This is the required production contract between the shared frontend, nginx, +and the backend. It supersedes any fixed `api.dexarmarket.ru` or same-origin +`/backend` routing proposal. + +## 1. Deterministic hostname rule + +The frontend prefixes the **complete** storefront hostname with `api.`: + +| Storefront | API origin | Bootstrap | +|---|---|---| +| `example.com` | `https://api.example.com` | `https://api.example.com/bootstrap` | +| `store1.example.com` | `https://api.store1.example.com` | `https://api.store1.example.com/bootstrap` | +| `www.example.com` | `https://api.www.example.com` | `https://api.www.example.com/bootstrap` | + +Bootstrap, auth, legacy routes, and `/api/...` routes all use this origin. +Localhost is the only exception and continues through the local `/api` proxy. + +## 2. Backend changes required + +For every request received publicly on `api.`: + +1. Behind the trusted project nginx, use `X-Storefront-Host`. nginx deliberately + sends the same storefront value as upstream `Host` for compatibility with the + currently live backend and preserves the public API hostname in + `X-Forwarded-Host`. +2. Without that trusted proxy, normalize the request `Host`: lowercase, remove + the port, remove exactly one leading `api.` label when present, and retain + every remaining label. +3. Resolve that normalized storefront hostname through the tenant-domain + registry. Do not infer a tenant from only the first label. +4. Reject unknown, disabled, or unverified domains with `403` before reading + tenant data. Never fall back to the default/Dexar tenant. +5. Bind the authenticated session to the resolved tenant and reject a mismatch. +6. Trust `X-Storefront-Host` / `X-Forwarded-*` only from the known nginx proxy; + direct clients can forge them. +7. Return JSON for `/bootstrap`, including a tenant identity that matches the + normalized storefront domain. HTML or a default tenant response is a fault. + +Pseudo-code: + +```text +if request.remoteAddress is trustedProxy: + storefrontHost = lower(stripPort(request.header["X-Storefront-Host"])) +else: + requestHost = lower(stripPort(request.host)) + storefrontHost = removeAtMostOnePrefix(requestHost, "api.") +tenant = registry.findVerifiedDomain(storefrontHost) ?? forbidden() +request.tenant = tenant +``` + +## 3. CORS contract + +For API host `api.`, allow exactly: + +```http +Access-Control-Allow-Origin: https:// +Access-Control-Allow-Credentials: true +Vary: Origin +Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS +Access-Control-Allow-Headers: Authorization, Content-Type, AdminWebSessionID, X-Requested-With +``` + +Answer valid preflight requests with `204`. Do not use `*` together with +credentials. nginx applies this policy now; the backend should enforce the same +allowlist when it is reached without that proxy. + +## 4. nginx and TLS + +Run the idempotent project script as root: + +```bash +scripts/deploy/configure-api-domain.sh \ + --domain gorbushka.market \ + --email ops@example.com \ + --upstream https://127.0.0.1:445 +``` + +It creates `api.gorbushka.market`, issues/renews its certificate, configures +CORS, and proxies all paths to the backend. Upstream receives +`Host: gorbushka.market`, `X-Forwarded-Host: api.gorbushka.market`, and +`X-Storefront-Host: gorbushka.market`; the script then reloads nginx and verifies +that `/bootstrap` returns a JSON object. + +For `store1.example.com`, both DNS and TLS must exist for +`api.store1.example.com`. A certificate for `*.example.com` does **not** cover +that two-label-deep hostname. + +## 5. CI/CD contract + +`deploy.yml` runs the same root-owned configurator before activating a frontend +release. Required production secrets: + +| Secret | Example | +|---|---| +| `DEPLOY_HOST` | server hostname/IP | +| `DEPLOY_USER` | `deploy` | +| `DEPLOY_SSH_KEY` | private deploy key | +| `DEPLOY_KNOWN_HOSTS` | pinned SSH host-key line | +| `STOREFRONT_DOMAINS` | `gorbushka.market store1.example.com` | +| `CERTBOT_EMAIL` | operations email | +| `BACKEND_UPSTREAM` | `https://127.0.0.1:445` (optional default) | + +One-time provisioning must first run `server-setup.sh`; it installs the helper +as root-owned `/usr/local/sbin/marketplaces-configure-api-domain` and grants the +deploy user permission to run only that validated command plus nginx reload. + +## 6. Acceptance checks + +For every storefront domain, all of these must pass: + +```bash +curl -fsS https://api.example.com/bootstrap | jq -e 'type == "object"' +curl -i -X OPTIONS https://api.example.com/bootstrap \ + -H 'Origin: https://example.com' \ + -H 'Access-Control-Request-Method: GET' +``` + +- Frontend bundle contains no fixed marketplace API hostname. +- Root and nested storefronts call their matching `api.`. +- Unknown API hosts return `403`, not the default tenant. +- `/bootstrap` returns JSON and the correct tenant. +- API responses never return the Angular `index.html` fallback. diff --git a/scripts/deploy/add-domain.sh b/scripts/deploy/add-domain.sh index 839be8e..49719de 100755 --- a/scripts/deploy/add-domain.sh +++ b/scripts/deploy/add-domain.sh @@ -115,6 +115,17 @@ certbot --nginx "${CERT_ARGS[@]}" \ nginx -t systemctl reload nginx +echo "==> companion API domain(s)" +CONFIGURE_API="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/configure-api-domain.sh" +[[ -x "$CONFIGURE_API" ]] || { + echo "ERROR: configure-api-domain.sh must be executable and next to add-domain.sh" >&2 + exit 1 +} +"$CONFIGURE_API" --domain "$DOMAIN" --email "$EMAIL" +if [[ $WITH_WWW -eq 1 ]]; then + "$CONFIGURE_API" --domain "www.$DOMAIN" --email "$EMAIL" +fi + echo "==> renewal timer" systemctl enable --now certbot.timer systemctl status certbot.timer --no-pager | head -3 || true diff --git a/scripts/deploy/configure-api-domain.sh b/scripts/deploy/configure-api-domain.sh new file mode 100755 index 0000000..9fa531f --- /dev/null +++ b/scripts/deploy/configure-api-domain.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Configure api. as the TLS/CORS reverse proxy for one tenant. +# Idempotent. Run as root after both storefront and API DNS records resolve here. + +set -euo pipefail + +DOMAIN="" +EMAIL="" +UPSTREAM="https://127.0.0.1:445" + +while [[ $# -gt 0 ]]; do + case "$1" in + --domain) DOMAIN="$2"; shift 2 ;; + --email) EMAIL="$2"; shift 2 ;; + --upstream) UPSTREAM="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; } +[[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || { + echo "--domain must be a valid lowercase hostname" >&2; exit 2; +} +[[ "$EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] || { + echo "--email must be valid" >&2; exit 2; +} +[[ "$UPSTREAM" =~ ^https?://[a-zA-Z0-9.:-]+$ ]] || { + echo "--upstream must be an http(s) origin without a path" >&2; exit 2; +} + +API_DOMAIN="api.$DOMAIN" +CONF="/etc/nginx/sites-available/$API_DOMAIN" + +echo "==> checking DNS for $API_DOMAIN" +getent hosts "$API_DOMAIN" >/dev/null || { + echo "ERROR: $API_DOMAIN does not resolve; create DNS before provisioning TLS" >&2 + exit 1 +} + +cat > "$CONF" < verifying https://$API_DOMAIN/bootstrap" +bootstrap_tmp="$(mktemp)" +trap 'rm -f "$bootstrap_tmp"' EXIT +content_type="$(curl --resolve "$API_DOMAIN:443:127.0.0.1" -fsS \ + -o "$bootstrap_tmp" -w '%{content_type}' \ + "https://$API_DOMAIN/bootstrap")" +[[ "$content_type" == application/json* ]] || { + echo "ERROR: $API_DOMAIN/bootstrap returned $content_type, expected application/json" >&2 + exit 1 +} +jq -e 'type == "object"' "$bootstrap_tmp" >/dev/null +rm -f "$bootstrap_tmp" +trap - EXIT + +echo "configured: $DOMAIN -> https://$API_DOMAIN -> $UPSTREAM" diff --git a/scripts/deploy/server-setup.sh b/scripts/deploy/server-setup.sh index b434655..546c445 100755 --- a/scripts/deploy/server-setup.sh +++ b/scripts/deploy/server-setup.sh @@ -133,8 +133,17 @@ nginx -t systemctl enable --now nginx systemctl reload nginx -echo "==> dynamic domain reconciler" SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +echo "==> tenant API-domain configurator" +if [[ -f "$SRC_DIR/configure-api-domain.sh" ]]; then + install -m 755 -o root -g root "$SRC_DIR/configure-api-domain.sh" \ + /usr/local/sbin/marketplaces-configure-api-domain +else + echo "configure-api-domain.sh not found next to server-setup.sh" >&2 + exit 1 +fi + +echo "==> dynamic domain reconciler" install -d -m 755 "$BASE/bin" /etc/marketplaces "/var/lib/marketplaces" if [[ -f "$SRC_DIR/sync-domains.sh" ]]; then install -m 755 "$SRC_DIR/sync-domains.sh" "$BASE/bin/sync-domains.sh" @@ -172,9 +181,10 @@ else echo " sync-domains.sh not found next to this script - skipping" fi -echo "==> sudoers: let the deploy user reload nginx, nothing else" +echo "==> sudoers: deployment reload plus validated tenant API provisioning" cat > /etc/sudoers.d/marketplaces-deploy <