diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5733a53 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +**/node_modules +**/dist +**/.astro +/data +services/*/data +out +.git +.env +**/.env +**/.env.* +!**/.env.example +legacy/node_modules +legacy/dist diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..445b091 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,24 @@ +# Gitea Actions on Cosmos: Gitea mirrors GitHub, and this builds, checks and +# restarts the GregKilmer.com stack on each new commit to master. +# It runs on a host-mode runner on Cosmos labelled "cosmos" (docs/deploy.md). +name: Deploy GregKilmer.com + +on: + push: + branches: [master] + # A pull mirror's sync may not raise a push event; this catches new commits + # anyway. deploy.sh does nothing when the commit is already running. + schedule: + - cron: "*/15 * * * *" + workflow_dispatch: + +concurrency: + group: gregkilmer-deploy + cancel-in-progress: false + +jobs: + deploy: + runs-on: cosmos + steps: + - uses: actions/checkout@v4 + - run: ./deploy/deploy.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fecebf7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,93 @@ +# Images for the GregKilmer.com stack on Cosmos. One file, one target per service: +# +# docker build --target check . runs `npm run check` (typecheck and tests) +# docker build --target content . the content service and MCP server +# docker build --target admin . the admin page (tailnet only) +# docker build --target site . the public site +# +# The Gitea Actions workflow builds check first, so a red check stops a deploy. +# See docs/deploy.md. + +# Pinned by version (Node 22.20, released 2025-09). Override with --build-arg to +# pull through a mirror. +ARG NODE_IMAGE=node:22.20.0-alpine + +# ---------- package manifests, then every workspace's dependencies ---------- +FROM ${NODE_IMAGE} AS manifests +WORKDIR /app +COPY package.json package-lock.json ./ +COPY packages/schema/package.json packages/schema/ +COPY packages/ui/package.json packages/ui/ +COPY services/content/package.json services/content/ +COPY services/admin/package.json services/admin/ +COPY services/site/package.json services/site/ +COPY tools/migrate/package.json tools/migrate/ + +FROM manifests AS deps +RUN npm ci --no-audit --no-fund + +# ---------- typecheck and tests ---------- +FROM deps AS check +COPY . . +RUN npm run check + +# ---------- the site's server build ---------- +FROM deps AS site-build +COPY packages packages +COPY services/site services/site +RUN npm run build -w @gk/site + +# ---------- each service's production dependencies only ---------- +FROM manifests AS content-deps +RUN npm ci --omit=dev --no-audit --no-fund -w @gk/content && npm cache clean --force + +FROM manifests AS admin-deps +RUN npm ci --omit=dev --no-audit --no-fund -w @gk/admin && npm cache clean --force + +FROM manifests AS site-deps +RUN npm ci --omit=dev --no-audit --no-fund -w @gk/site && npm cache clean --force + +# ---------- shared runtime base ---------- +FROM ${NODE_IMAGE} AS runtime +WORKDIR /app +ENV NODE_ENV=production HOST=0.0.0.0 +COPY package.json ./ +COPY packages/schema/package.json packages/schema/ +COPY packages/schema/src packages/schema/src + +# ---------- content service ---------- +FROM runtime AS content +COPY --from=content-deps /app/node_modules node_modules +COPY services/content/package.json services/content/ +COPY services/content/src services/content/src +# The vault's Site folder and the data folder (SQLite and saved form attachments). +ENV PORT=4310 SITE_DIR=/vault/Site DATA_DIR=/data +EXPOSE 4310 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -qO- http://127.0.0.1:4310/health >/dev/null || exit 1 +USER node +CMD ["node", "--disable-warning=ExperimentalWarning", "--import", "tsx", "services/content/src/main.ts"] + +# ---------- admin page ---------- +FROM runtime AS admin +COPY --from=admin-deps /app/node_modules node_modules +COPY services/admin/package.json services/admin/ +COPY services/admin/src services/admin/src +COPY services/admin/public services/admin/public +ENV PORT=4320 +EXPOSE 4320 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -qO- http://127.0.0.1:4320/health >/dev/null || exit 1 +USER node +CMD ["node", "--import", "tsx", "services/admin/src/main.ts"] + +# ---------- public site ---------- +FROM runtime AS site +COPY --from=site-deps /app/node_modules node_modules +COPY --from=site-build /app/services/site/dist services/site/dist +ENV PORT=4321 +EXPOSE 4321 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -qO- http://127.0.0.1:4321/ >/dev/null || exit 1 +USER node +CMD ["node", "services/site/dist/server/entry.mjs"] diff --git a/README.md b/README.md index 9a3ec68..91e93d4 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,17 @@ with the decisions behind it, is the | Path | What it is | | --- | --- | | `packages/schema` | The one definition of every note type: fields, folders, file names, and the checks for one note and for the whole folder. The migration, the content service, the MCP server, the site and any later editing tool all use it. | -| `vault-kit` | What goes into the vault: the Obsidian templates (generated from the schema) and the Site guide. | +| `vault-kit` | What goes into the vault: the Obsidian templates (generated from the schema), the Site guide and the stack's inventory note. | | `services/content` | The content service: watches the Site folder, runs the rules, keeps the last good version and its history, and serves the one write API. The MCP server for agents is part of it. See [docs/content-service.md](docs/content-service.md). | | `services/admin` | The admin page: the review queue with diffs, history and rollback, quick capture, store orders, draft previews and sync health. A client of the content service, on the tailnet only. See [docs/admin.md](docs/admin.md). | | `packages/ui` | The component library: the approved element rules and ornament kit as Astro components, with the brand fonts, seals and icons, and a gallery page that shows them all. See [docs/components.md](docs/components.md). | +| `services/site` | The public site: every page rendered from the content service's published notes, the resume PDF, and the inquiry and order forms. See [docs/site.md](docs/site.md). | +| `deploy`, `Dockerfile`, `.gitea` | The Cosmos stack: images, compose file, deploy and rollback scripts, and the Gitea Actions workflow. See [docs/deploy.md](docs/deploy.md). | | `tools/migrate` | The one-time migration from the old site's data into Site folder notes. | | `docs` | How the pieces fit. Start with [docs/content-model.md](docs/content-model.md). | | `legacy` | The old site, kept for reference only. Nothing outside it imports from it except the migration. | -Coming in later phases: the new site's pages, integrations and deploy. +Coming next: the cutover from the old site (phase 8). ## Commands diff --git a/deploy/admin.env.example b/deploy/admin.env.example new file mode 100644 index 0000000..0dea73d --- /dev/null +++ b/deploy/admin.env.example @@ -0,0 +1,3 @@ +# Admin page settings. On Cosmos: /home/greg/homelab/gregkilmer/admin.env +# The content service's admin token (the admin:… entry in content.env's CONTENT_TOKENS). +ADMIN_TOKEN= diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..6f110cb --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,66 @@ +# The GregKilmer.com stack on Cosmos: content service, admin page and public site. +# +# Lives at /home/greg/homelab/gregkilmer/compose.yaml (the Gitea Actions deploy +# copies it there from deploy/compose.yaml on every build). Settings: +# .env stack settings: GK_TAG, VAULT_SITE, PUID, PGID (deploy/stack.env.example) +# content.env content service secrets (services/content/.env.example) +# admin.env admin page settings (deploy/admin.env.example) +# site.env public site secrets (services/site/.env.example) +# +# No service publishes a host port. Nginx Proxy Manager reaches them by +# container name on homelab_network. See docs/deploy.md. + +name: gregkilmer + +x-common: &common + restart: unless-stopped + user: "${PUID:-1000}:${PGID:-1000}" + networks: [homelab_network] + read_only: true + tmpfs: [/tmp] + security_opt: ["no-new-privileges:true"] + cap_drop: [ALL] + +services: + gk-content: + <<: *common + image: gregkilmer/content:${GK_TAG:?set GK_TAG in .env} + container_name: gk-content + env_file: content.env + environment: + SITE_DIR: /vault/Site + DATA_DIR: /data + volumes: + # The vault's Site folder. Read-write: accepted API and MCP changes are + # written back into the notes. + - ${VAULT_SITE:?set VAULT_SITE in .env}:/vault/Site + # content.db (notes, history, review queue, saved forms, sync cache) and + # submissions/ (form attachments). Both are backed up. + - ./data/content:/data + labels: + backup.dump: sqlite + + gk-admin: + <<: *common + image: gregkilmer/admin:${GK_TAG} + container_name: gk-admin + env_file: admin.env + environment: + CONTENT_URL: http://gk-content:4310 + depends_on: + gk-content: { condition: service_healthy } + + gk-site: + <<: *common + image: gregkilmer/site:${GK_TAG} + container_name: gk-site + env_file: site.env + environment: + CONTENT_URL: http://gk-content:4310 + depends_on: + gk-content: { condition: service_healthy } + +networks: + homelab_network: + name: homelab_network + external: true diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..d9af268 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Builds the three images for the checked-out commit, runs the checks, and +# restarts the stack on it. If the new containers don't come up healthy, the +# stack goes back to the last good tag. Run from the repo root on Cosmos, by the +# Gitea Actions workflow or by hand. See docs/deploy.md. +set -euo pipefail + +STACK="${GK_STACK_DIR:-/home/greg/homelab/gregkilmer}" +ENV_FILE="$STACK/.env" +TAG="$(git rev-parse --short=12 HEAD)" +compose() { docker compose --project-directory "$STACK" -f "$STACK/compose.yaml" "$@"; } +setting() { grep -E "^$1=" "$ENV_FILE" | tail -n 1 | cut -d= -f2- || true; } +set_setting() { + if grep -qE "^$1=" "$ENV_FILE"; then sed -i "s|^$1=.*|$1=$2|" "$ENV_FILE"; else echo "$1=$2" >> "$ENV_FILE"; fi +} + +for f in .env content.env admin.env site.env; do + [ -f "$STACK/$f" ] || { echo "Missing $STACK/$f (see docs/deploy.md)"; exit 1; } +done + +CURRENT="$(setting GK_TAG)" +PREVIOUS="$(setting GK_PREVIOUS_TAG)" +if [ "$CURRENT" = "$TAG" ] && [ "${FORCE:-}" != "1" ]; then + echo "Already running $TAG; nothing to do (FORCE=1 to rebuild)." + exit 0 +fi + +echo "== Checks" +docker build --target check . + +echo "== Images for $TAG" +for t in content admin site; do + docker build --target "$t" -t "gregkilmer/$t:$TAG" . +done + +echo "== Deploy" +mkdir -p "$STACK/data/content" +cp deploy/compose.yaml "$STACK/compose.yaml" +set_setting GK_TAG "$TAG" +[ -n "$CURRENT" ] && set_setting GK_PREVIOUS_TAG "$CURRENT" + +if ! compose up -d --remove-orphans --wait --wait-timeout 120; then + echo "!! $TAG did not come up healthy" + compose ps + compose logs --tail 50 || true + if [ -n "$CURRENT" ]; then + echo "== Rolling back to $CURRENT" + set_setting GK_TAG "$CURRENT" + set_setting GK_PREVIOUS_TAG "$PREVIOUS" + compose up -d --wait --wait-timeout 120 + fi + exit 1 +fi + +echo "== Prune old images (keeping $TAG and ${CURRENT:-none})" +for t in content admin site; do + docker images "gregkilmer/$t" --format '{{.Tag}}' | while read -r old; do + [ "$old" = "$TAG" ] || [ "$old" = "$CURRENT" ] || docker rmi "gregkilmer/$t:$old" >/dev/null || true + done +done +echo "Deployed $TAG" diff --git a/deploy/rollback.sh b/deploy/rollback.sh new file mode 100755 index 0000000..79a4fec --- /dev/null +++ b/deploy/rollback.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Puts the stack back on the previous tag (GK_PREVIOUS_TAG) and swaps the two, +# so running it again rolls forward. See docs/deploy.md. +set -euo pipefail +STACK="${GK_STACK_DIR:-/home/greg/homelab/gregkilmer}" +ENV_FILE="$STACK/.env" +cur="$(grep -E '^GK_TAG=' "$ENV_FILE" | cut -d= -f2-)" +prev="$(grep -E '^GK_PREVIOUS_TAG=' "$ENV_FILE" | cut -d= -f2-)" +[ -n "$prev" ] || { echo "No GK_PREVIOUS_TAG in $ENV_FILE"; exit 1; } +sed -i "s|^GK_TAG=.*|GK_TAG=$prev|; s|^GK_PREVIOUS_TAG=.*|GK_PREVIOUS_TAG=$cur|" "$ENV_FILE" +docker compose --project-directory "$STACK" -f "$STACK/compose.yaml" up -d --wait --wait-timeout 120 +echo "Now running $prev (previous: $cur)" diff --git a/deploy/stack.env.example b/deploy/stack.env.example new file mode 100644 index 0000000..d7ad733 --- /dev/null +++ b/deploy/stack.env.example @@ -0,0 +1,9 @@ +# Stack settings for compose.yaml. On Cosmos: /home/greg/homelab/gregkilmer/.env +# The Gitea Actions deploy rewrites GK_TAG on every build; GK_PREVIOUS_TAG is the rollback. +GK_TAG= +GK_PREVIOUS_TAG= +# The vault's Site folder on the host. +VAULT_SITE=/home/greg/homelab/obsidian-vault/20-29_Makerspace/24_Homelab_Infrastructure/24.01_PersonalWebsite/Site +# The user the containers run as: the owner of the vault files (id -u greg, id -g greg). +PUID=1000 +PGID=1000 diff --git a/docs/content-service.md b/docs/content-service.md index d15a12c..acceec6 100644 --- a/docs/content-service.md +++ b/docs/content-service.md @@ -120,7 +120,7 @@ move or write a raw file. | `CONTENT_TOKENS` | none | `role:token` pairs, comma separated. Roles: reader, agent, editor, admin, intake. Tokens are 24 characters or more. | | `ALLOWED_EMAILS` | none | Addresses an agent may add without a hold. | | `HEALTHCHECK_URL` | none | Pinged after every full scan. | -| `NOTIFY_WEBHOOK_URL` | none | Receives `{ event: "held", held }` when a change is held. | +| `NOTIFY_WEBHOOK_URL` | none | Receives `{ content, event: "held", held }` when a change is held. `content` is a one-line message, so a Discord webhook URL works as is. | | `SCAN_MINUTES` | `5` | Full scan interval, on top of the file watch. | | `SUBMISSION_MAX_MB` | `10` | Largest attachment a form submission may carry. | | `SYNC_JOBS` | none | Public sync jobs to run, comma separated: `archidekt`, `bgg`, `covers`. See [Sync jobs](#sync-jobs). | diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..368c9d5 --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,114 @@ +# Deploying on Cosmos + +The stack is three containers on `homelab_network`, none with a host port: + +| Container | Image target | Port | Reached as | Who can reach it | +| --- | --- | --- | --- | --- | +| `gk-content` | `content` | 4310 | `content.gregkilmer.com` (API and `/mcp`), `http://gk-content:4310` on homelab_network | Tailnet only | +| `gk-admin` | `admin` | 4320 | `admin.gregkilmer.com` | Tailnet only | +| `gk-site` | `site` | 4321 | `beta.gregkilmer.com` until cutover, then `gregkilmer.com` | Tailnet only until cutover, then public through Cloudflare | + +The old site keeps serving gregkilmer.com until phase 8 (cutover). + +Files in the repo: + +- `Dockerfile`: one target per service, plus `check` (typecheck and every test). Node is pinned to `node:22.20.0-alpine`. +- `deploy/compose.yaml`: the stack. Containers run as the vault's owner, with a read-only root filesystem, no capabilities and `no-new-privileges`. +- `deploy/deploy.sh`: builds `check`, then the three images tagged with the commit, and restarts the stack. If the new containers aren't healthy within two minutes, it puts the last good tag back. Run it again on the same commit and it does nothing (`FORCE=1` rebuilds). +- `deploy/rollback.sh`: switches to the previous tag. Run it again to roll forward. +- `.gitea/workflows/deploy.yml`: runs `deploy.sh` on Cosmos for each new commit to master. + +## On Cosmos + +Everything lives in `/home/greg/homelab/gregkilmer`: + +``` +/home/greg/homelab/gregkilmer/ + compose.yaml copied from deploy/compose.yaml by every deploy + .env GK_TAG, GK_PREVIOUS_TAG, VAULT_SITE, PUID, PGID + content.env content service secrets and sync settings + admin.env ADMIN_TOKEN + site.env site secrets: tokens, Turnstile, SMTP + data/content/ content.db and submissions/ (backed up) +``` + +Kopia already covers `/home/greg`, so the `.env` files and `data/` are in the encrypted backups. + +## First-time setup + +1. **Folder and settings.** Run these from a checkout of the repo: + + ```sh + mkdir -p /home/greg/homelab/gregkilmer/data/content + cd /home/greg/homelab/gregkilmer + cp /deploy/stack.env.example .env + cp /services/content/.env.example content.env + cp /deploy/admin.env.example admin.env + cp /services/site/.env.example site.env + chmod 600 .env *.env + ``` + + In `.env`, check that `PUID` and `PGID` match `id -u greg` and `id -g greg`, so the content service can write notes back into the vault. + +2. **Tokens.** Make one per role with `openssl rand -hex 24`: + - In `content.env`, set `CONTENT_TOKENS=reader:,admin:,intake:,agent:`. + - In `admin.env`, set `ADMIN_TOKEN=`. + - In `site.env`, set `CONTENT_TOKEN=` and `CONTENT_INTAKE_TOKEN=`. + - Give agents ``. + + Leave `CONTENT_URL` out of `site.env`; compose sets it. + +3. **The rest of the settings.** Fill in Healthchecks, the Discord webhook, Turnstile, SMTP and the sync logins. The list is in `/mnt/project-files/website/phase6/setup-on-cosmos.md` and in each `.env.example`. + +4. **Gitea runner.** The workflow needs an `act_runner` on Cosmos with the label `cosmos` running in host mode (`cosmos:host`). It runs as a user in the `docker` group with write access to `/home/greg/homelab/gregkilmer`. + - A runner with the Docker socket is root-equivalent. Register it for this repository only, not for the whole instance. + +5. **Gitea mirror.** Mirror `github.com/Patiun/com.gregkilmer.www` into Gitea as a pull mirror and turn Actions on for it. + - A pull mirror's sync may not raise a push event. The workflow therefore also runs every 15 minutes and deploys only when master has moved. + - Check once that a new commit reaches Cosmos. If the schedule doesn't fire on a mirror either, run the workflow by hand from the Actions tab. + +6. **First deploy.** Run it from the Actions tab, or by hand from a checkout: `./deploy/deploy.sh`. Then check: + - `docker compose -f /home/greg/homelab/gregkilmer/compose.yaml ps` shows three healthy containers. + - `docker logs gk-content` says `Content service on 0.0.0.0:4310: N notes`. + +7. **Nginx Proxy Manager.** Add a proxy host for each hostname. Force SSL on each. For `content`, add `proxy_buffering off;` under Advanced, because MCP streams its responses. + + | Host | Forward to | Access list | + | --- | --- | --- | + | `admin.gregkilmer.com` | `http://gk-admin:4320` | Tailnet only | + | `content.gregkilmer.com` | `http://gk-content:4310` | Tailnet only | + | `beta.gregkilmer.com` | `http://gk-site:4321` | Tailnet only | + + - DNS: an A record for each pointing at 100.99.111.16 (TTL 300), the same as other tailnet hosts. + - Certificates: Let's Encrypt can't reach a tailnet-only host over HTTP, so use NPM's DNS challenge with your DNS provider. This is the same open item as the `*.patiun.com` certificate. + - Agents already on homelab_network can skip NPM and use `http://gk-content:4310/mcp`. + +8. **Turnstile.** Add `beta.gregkilmer.com` and `gregkilmer.com` to the widget's hostnames. + +9. **Healthchecks.** Create these checks: + - one for the content service's full scan (`HEALTHCHECK_URL`), every 5 minutes; + - one per sync job you turn on (Archidekt, Suwayomi, Bambuddy and Homebox hourly; BGG and Kavita daily; covers every 15 minutes). + +10. **Inventory note.** Copy `vault-kit/24.01_PersonalWebsite/GregKilmer.com stack.md` into the vault's `24.01_PersonalWebsite` folder, so `cosmos-inventory` stops listing the containers as undocumented. + +## Backups + +- `gk-content` carries `backup.dump=sqlite`. `cosmos-dump` finds `content.db` in its `/data` mount and copies it with `VACUUM INTO` before Kopia runs. + - `content.db` holds note history, the review queue, saved inquiries and orders, and the sync cache. + - The vault mount has no SQLite files, so nothing else is dumped. +- Form attachments are plain files in `data/content/submissions/`, which Kopia picks up with the rest of `/home/greg`. +- The plan called for a cache folder in `exclusions.conf`. None is needed: the sync cache and search index live inside `content.db` and are small. +- `gk-admin` and `gk-site` keep no data. + +## Cutover (phase 8) + +1. In NPM, point `gregkilmer.com` and `www.gregkilmer.com` at `http://gk-site:4321` with no access list, and retire the old `greg-profile-site` container. +2. Turn on Cloudflare's proxy for both records with SSL mode Full (strict). Cloudflare doesn't cache HTML by default, which is what the site needs: pages change within a minute of a note changing. +3. The site already trusts `X-Forwarded-Host` and `X-Forwarded-Proto` for gregkilmer.com and its subdomains (`security.allowedDomains` in `services/site/astro.config.mjs`), so form posts pass Astro's origin check behind the proxy. It reads the visitor's address from `CF-Connecting-IP` for the form rate limits. + +## Day to day + +- **Deploy:** merge to master on GitHub; Gitea mirrors it and the workflow deploys it. +- **Roll back:** `GK_STACK_DIR=/home/greg/homelab/gregkilmer ./deploy/rollback.sh` from a checkout, or set `GK_TAG` in `.env` to any image tag still on the host and run `docker compose up -d`. +- **Logs:** `docker logs gk-content`. Sync runs also appear on the admin page's Overview. +- **Restart one service:** `docker compose -f /home/greg/homelab/gregkilmer/compose.yaml restart gk-site`. diff --git a/services/content/.env.example b/services/content/.env.example index 6b4b803..3e016ce 100644 --- a/services/content/.env.example +++ b/services/content/.env.example @@ -1,5 +1,20 @@ # Content service settings. Copy to .env (never commit it) or set them in the # container's environment. See docs/content-service.md, "Running it". +# On Cosmos this file is /home/greg/homelab/gregkilmer/content.env (docs/deploy.md). + +# --- Core ----------------------------------------------------------------------- +# role:token pairs, 24+ random characters each (openssl rand -hex 24). +# reader: the site; admin: the admin page; intake: the site's forms; +# agent: AI agents over MCP; editor: future editing tools. +CONTENT_TOKENS= +# Healthchecks ping after every full scan of the Site folder. +HEALTHCHECK_URL= +# Discord (or any) webhook, told whenever a change is held for review. +NOTIFY_WEBHOOK_URL= +# Email addresses an agent may add to a note without a hold. +ALLOWED_EMAILS=greg@gregkilmer.com,contact@gregkilmer.com,orders@gregkilmer.com +# SCAN_MINUTES=5 +# SUBMISSION_MAX_MB=10 # --- Sync jobs (docs/content-service.md, "Sync jobs") ------------------------ # Public sources to run: archidekt (hourly), bgg (daily), covers (every 15 min). diff --git a/services/content/src/main.ts b/services/content/src/main.ts index d03a0e8..b1b7b2b 100644 --- a/services/content/src/main.ts +++ b/services/content/src/main.ts @@ -23,7 +23,12 @@ const service = new ContentService({ fetch(config.notifyWebhookUrl, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ event: "held", held: heldView(held) }), + // `content` is what a Discord webhook shows; other receivers read `held`. + body: JSON.stringify({ + content: `GregKilmer.com held ${held.path} (${held.reasons.map((r) => r.code).join(", ") || "waiting for review"}). Review it on the admin page.`, + event: "held", + held: heldView(held), + }), }).catch((err) => console.error("Notify failed:", err)); } }, diff --git a/services/site/astro.config.mjs b/services/site/astro.config.mjs index 7a616de..5bca5ad 100644 --- a/services/site/astro.config.mjs +++ b/services/site/astro.config.mjs @@ -4,11 +4,21 @@ import node from "@astrojs/node"; import { defineConfig } from "astro/config"; export default defineConfig({ + site: "https://gregkilmer.com", output: "server", adapter: node({ mode: "standalone" }), trailingSlash: "never", build: { format: "file", assets: "assets" }, devToolbar: { enabled: false }, + // Behind Nginx Proxy Manager the request arrives over plain http. Trusting + // X-Forwarded-Host/Proto for these hosts only lets the form posts pass + // Astro's same-origin check without trusting any other host a client sends. + security: { + allowedDomains: [ + { hostname: "gregkilmer.com", protocol: "https" }, + { hostname: "*.gregkilmer.com", protocol: "https" }, + ], + }, server: { host: true, port: Number(process.env.PORT ?? 4330) }, vite: { // cookie is bundled so the server never picks up express's older copy hoisted to the root. diff --git a/services/site/src/lib/forms.ts b/services/site/src/lib/forms.ts index 5335ba9..acccafe 100644 --- a/services/site/src/lib/forms.ts +++ b/services/site/src/lib/forms.ts @@ -468,8 +468,11 @@ export class RateLimiter { } } -/** The visitor's address: the proxy's X-Forwarded-For first, else the socket's. */ +/** The visitor's address: Cloudflare's CF-Connecting-IP, then the proxy's X-Forwarded-For, else the socket's. */ export function clientIp(headers: Headers, fallback?: string): string { + // Cloudflare sets CF-Connecting-IP to the visitor's address and overwrites any copy a client sends. + const cf = headers.get("cf-connecting-ip"); + if (cf) return cf.trim(); const fwd = headers.get("x-forwarded-for"); if (fwd) return fwd.split(",")[0]!.trim(); return headers.get("x-real-ip") ?? fallback ?? "unknown"; diff --git a/services/site/test/forms.test.ts b/services/site/test/forms.test.ts index 0632f4a..c73f916 100644 --- a/services/site/test/forms.test.ts +++ b/services/site/test/forms.test.ts @@ -257,5 +257,6 @@ describe("rate limit and address", () => { it("reads the forwarded address first", () => { expect(clientIp(new Headers({ "x-forwarded-for": "203.0.113.9, 10.0.0.1" }), "10.0.0.2")).toBe("203.0.113.9"); expect(clientIp(new Headers(), "10.0.0.2")).toBe("10.0.0.2"); + expect(clientIp(new Headers({ "cf-connecting-ip": "198.51.100.7", "x-forwarded-for": "1.2.3.4, 10.0.0.1" }))).toBe("198.51.100.7"); }); }); diff --git a/vault-kit/24.01_PersonalWebsite/GregKilmer.com stack.md b/vault-kit/24.01_PersonalWebsite/GregKilmer.com stack.md new file mode 100644 index 0000000..295df81 --- /dev/null +++ b/vault-kit/24.01_PersonalWebsite/GregKilmer.com stack.md @@ -0,0 +1,36 @@ +--- +tags: [homelab, service] +--- + +# GregKilmer.com stack + +The new GregKilmer.com: a content service that reads this folder's `Site/` notes, a tailnet-only admin page, and the public site. Repo: `github.com/Patiun/com.gregkilmer.www` (mirrored to Gitea, deployed by Gitea Actions). Runbook: `docs/deploy.md` in the repo. + +## Containers + +| Container | Role | Network | Host port | +| --- | --- | --- | --- | +| `gk-content` | Content service, sync jobs and MCP server | homelab_network | none | +| `gk-admin` | Admin page | homelab_network | none | +| `gk-site` | Public site | homelab_network | none | + +- Compose: `/home/greg/homelab/gregkilmer/compose.yaml` (copied from the repo's `deploy/compose.yaml` on each deploy) +- Settings and secrets: `/home/greg/homelab/gregkilmer/{.env,content.env,admin.env,site.env}` (600) + +## Data and backups + +- `/home/greg/homelab/gregkilmer/data/content/content.db`: note history, review queue, saved inquiries and orders, sync cache. Label `backup.dump=sqlite` on `gk-content`. +- `/home/greg/homelab/gregkilmer/data/content/submissions/`: form attachments, covered by Kopia. +- The vault's `Site/` folder is mounted read-write into `gk-content`, which writes accepted API and MCP changes back as notes. + +## Access + +- `admin.gregkilmer.com` to `gk-admin:4320`: Tailnet only. +- `content.gregkilmer.com` to `gk-content:4310` (API and `/mcp`): Tailnet only. +- `beta.gregkilmer.com` to `gk-site:4321`: Tailnet only until cutover. After cutover, `gregkilmer.com` is public through Cloudflare. + +## Health + +- Healthchecks: the content service's full scan every 5 minutes, plus one check per sync job. +- A Discord webhook is sent whenever a change is held for review. +- Each container has a Docker health check on `/health` (site: `/`).