From 437b5b285843fee823a477678293ebbe1c97df54 Mon Sep 17 00:00:00 2001 From: Tim Trautmann Date: Fri, 29 May 2026 09:48:32 -0700 Subject: [PATCH] Add compose.shared-host.yaml, gs config, gs logs, webhook-secret guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compose.shared-host.yaml — for boxes that already run a reverse proxy (your PDS Caddy, an existing nginx, Traefik, …). Drops the bundled Caddy service from the default compose; binds the app to 127.0.0.1:8080 so the host's reverse proxy reaches it without port-fighting. README gains Caddy + nginx site-block snippets. gs config — view-only command that prints every stored config value grouped by area (Setup / PDS / Ghost / Bridge / Publication). Secrets (atproto_app_password, ghost_admin_api_key, webhook_secret) are shown as "(set, N chars)" — confirms presence and length without leaking the bytes. gs logs — tails /data/webhook.log with pretty colorized output. The log file is JSON-Lines, append-only, one delivery per line. Written by the webhook handler on every accepted/rejected delivery (status: ok / failed / ignored / rejected). Persistent across container restarts so post- mortem is easy. Uses BusyBox tail -F for the actual follow. Setup keeps the existing webhook_secret by default on re-runs (with a confirm() prompt to opt into rotation). Previously the secret was regenerated unconditionally — meaning a re-run-for-credential-rotation silently invalidated all four already-configured Ghost webhooks. --- README.md | 40 ++++++++++++++++++++++ compose.shared-host.yaml | 32 ++++++++++++++++++ src/cli.ts | 12 +++++++ src/commands/config.ts | 61 +++++++++++++++++++++++++++++++++ src/commands/logs.ts | 73 ++++++++++++++++++++++++++++++++++++++++ src/commands/setup.ts | 15 +++++++-- src/lib/webhook-log.ts | 27 +++++++++++++++ src/routes/webhooks.ts | 14 ++++++-- 8 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 compose.shared-host.yaml create mode 100644 src/commands/config.ts create mode 100644 src/commands/logs.ts create mode 100644 src/lib/webhook-log.ts diff --git a/README.md b/README.md index f628fbb..eb8ae82 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,46 @@ docker compose exec app gs set-theme # change the theme palette The webhook receiver runs continuously as the container's main process; the CLI shares the same SQLite, so changes take effect immediately without restart. +## Deploying alongside another service + +If the box already runs a reverse proxy for something else (your PDS, another site, anything terminating TLS on 80/443), you don't want ghoststandard's bundled Caddy fighting for the same ports. Use **`compose.shared-host.yaml`** instead — it omits the Caddy service and binds the app to `127.0.0.1:8080` so your existing proxy can reach it. + +Point a DNS record at the host, then add a site block to whatever's already running on 80/443: + +**Caddy** (your existing `Caddyfile`): + +```caddy +bridge.example.com { + reverse_proxy localhost:8080 +} +``` + +**nginx** (e.g., `/etc/nginx/sites-available/bridge.conf`): + +```nginx +server { + listen 443 ssl http2; + server_name bridge.example.com; + # … your usual ssl_certificate / ssl_certificate_key lines … + location / { + proxy_pass http://127.0.0.1:8080; + 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; + } +} +``` + +Then: + +```bash +docker compose -f compose.shared-host.yaml up -d +docker compose -f compose.shared-host.yaml exec app gs setup +``` + +The setup wizard's "public URL of this bridge" prompt is where you tell ghoststandard which hostname Ghost will hit (`https://bridge.example.com`). + ## Architecture ``` diff --git a/compose.shared-host.yaml b/compose.shared-host.yaml new file mode 100644 index 0000000..b9bf34a --- /dev/null +++ b/compose.shared-host.yaml @@ -0,0 +1,32 @@ +# Shared-host deployment — for boxes that already run a reverse proxy +# (Caddy, nginx, Traefik, Cloudflare Tunnel, …) for some other service. The +# bundled Caddy from compose.yaml is omitted; the app binds to 127.0.0.1:8080 +# and your existing proxy handles TLS termination + the public hostname. +# +# Typical setup: +# 1. Point bridge.example.com (or whatever) at this host's public IP. +# 2. Add a site block to your existing reverse proxy that proxies the +# hostname to localhost:8080. See README for Caddy + nginx snippets. +# 3. docker compose -f compose.shared-host.yaml up -d +# 4. docker compose -f compose.shared-host.yaml exec app gs setup +# +# Use this when you can't (or don't want to) give ghoststandard the 80/443 +# ports. Use the default compose.yaml when ghoststandard owns the box. + +services: + app: + build: . + restart: unless-stopped + environment: + GS_DB_PATH: /data/ghoststandard.sqlite + GS_PORT: "8080" + volumes: + - gs_data:/data + - ./assets:/data/assets:ro + ports: + # Bind to loopback only — your host reverse proxy reaches us through + # 127.0.0.1:8080. Public access goes through the proxy on 443. + - "127.0.0.1:8080:8080" + +volumes: + gs_data: diff --git a/src/cli.ts b/src/cli.ts index 61e1bce..0f212d7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,6 +21,8 @@ import { runBackfill } from './commands/backfill.js'; import { runStatus } from './commands/status.js'; import { runSetIcon } from './commands/set-icon.js'; import { runSetTheme } from './commands/set-theme.js'; +import { runConfig } from './commands/config.js'; +import { runLogs } from './commands/logs.js'; function printHelp(): void { const cmd = pc.cyan; @@ -34,6 +36,8 @@ ${pc.bold('Commands:')} ${cmd('unsync')} ${arg('')} Delete one PDS record + mapping ${cmd('backfill')} Re-sync every published post ${cmd('status')} Show health, counts, last sync + ${cmd('config')} Print all stored config (secrets masked) + ${cmd('logs')} Tail webhook delivery log ${cmd('set-icon')} ${arg('')} Replace the publication icon ${cmd('set-theme')} ${arg('[preset]')} Change the publication theme palette ${arg('(no arg → interactive; or pass: skip, light, dark)')} @@ -87,6 +91,14 @@ async function main(): Promise { await runStatus(); return; + case 'config': + await runConfig(); + return; + + case 'logs': + await runLogs(); + return; + case 'set-icon': { const path = rest[0]; if (!path) { diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..725f5d1 --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,61 @@ +// `gs config` — print every stored config value, with secrets masked. +// View-only: editing happens via `gs setup` (full re-run) or the dedicated +// `gs set-icon` / `gs set-theme` commands. Useful as "show me what the bridge +// is actually using right now". + +import pc from 'picocolors'; +import { getConfig } from '../lib/db.js'; + +const SECRET_KEYS = new Set([ + 'atproto_app_password', + 'ghost_admin_api_key', + 'webhook_secret', +]); + +// Don't show the secret bytes — just confirm presence and length. Length is +// informative for "did I paste it short?" without leaking entropy. +function format(key: string, value: string | null): string { + if (value === null || value === '') return pc.dim('(unset)'); + if (SECRET_KEYS.has(key)) return pc.dim(`(set, ${value.length} chars)`); + return pc.cyan(value); +} + +type Group = { + heading: string; + keys: string[]; +}; + +const GROUPS: Group[] = [ + { + heading: 'Setup', + keys: ['setup_complete'], + }, + { + heading: 'PDS', + keys: ['atproto_service', 'atproto_handle', 'atproto_app_password'], + }, + { + heading: 'Ghost', + keys: ['ghost_url', 'ghost_admin_api_key'], + }, + { + heading: 'Bridge', + keys: ['bridge_url', 'webhook_secret'], + }, + { + heading: 'Publication', + keys: ['publication_name', 'publication_description', 'publication_theme', 'publication_icon_path', 'publication_at_uri'], + }, +]; + +export async function runConfig(): Promise { + console.log(pc.bold('\nghoststandard config') + pc.dim(' (view-only)')); + for (const group of GROUPS) { + console.log('\n ' + pc.bold(group.heading)); + for (const key of group.keys) { + const padded = key.padEnd(26); + console.log(` ${pc.dim(padded)} ${format(key, getConfig(key))}`); + } + } + console.log(''); +} diff --git a/src/commands/logs.ts b/src/commands/logs.ts new file mode 100644 index 0000000..cb0a00c --- /dev/null +++ b/src/commands/logs.ts @@ -0,0 +1,73 @@ +// `gs logs` — tail the webhook delivery log with pretty formatting. +// +// The log file (/data/webhook.log) is append-only JSON-Lines, one delivery +// per line, written by routes/webhooks.ts. We shell out to `tail -F` to do +// the actual follow-with-rotation handling; on Alpine that's BusyBox's tail +// which supports -F. The JSON is parsed and colorized as it streams. + +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import pc from 'picocolors'; + +const LOG_PATH = process.env.GS_WEBHOOK_LOG ?? '/data/webhook.log'; +const LAST_N = 50; + +interface WebhookLogEntry { + ts: string; + action: string; + postId?: string; + slug?: string; + status: string; + atUri?: string; + error?: string; + reason?: string; +} + +function colorAction(action: string, status: string): string { + if (status === 'ok') return pc.green(action); + if (status === 'failed') return pc.red(action); + if (status === 'rejected') return pc.red(action); + return pc.dim(action); +} + +function printLine(json: string): void { + if (!json.trim()) return; + let entry: WebhookLogEntry; + try { + entry = JSON.parse(json); + } catch { + console.log(json); + return; + } + const ts = pc.dim(entry.ts ?? ''); + const action = colorAction(entry.action, entry.status).padEnd(20); + const slug = entry.slug ? pc.cyan(entry.slug) : pc.dim('(no slug)'); + const detail = entry.atUri ?? entry.error ?? entry.reason ?? ''; + console.log(`${ts} ${action} ${slug} ${pc.dim(detail)}`); +} + +export async function runLogs(): Promise { + if (!existsSync(LOG_PATH)) { + console.log(pc.dim('No webhook deliveries logged yet — publish a post in Ghost to generate one.')); + console.log(pc.dim(`(Log file will appear at ${LOG_PATH}.)`)); + return; + } + + console.log(pc.dim(`Tailing ${LOG_PATH} — Ctrl+C to stop\n`)); + const child = spawn('tail', ['-F', '-n', String(LAST_N), LOG_PATH], { + stdio: ['ignore', 'pipe', 'inherit'], + }); + + let buffer = ''; + child.stdout.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + let nl: number; + while ((nl = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + printLine(line); + } + }); + child.on('exit', (code) => process.exit(code ?? 0)); + process.on('SIGINT', () => child.kill('SIGINT')); +} diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 4efad19..9e8627c 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -16,7 +16,7 @@ import { input, password, confirm, select } from '@inquirer/prompts'; import ora from 'ora'; import pc from 'picocolors'; -import { setConfig } from '../lib/db.js'; +import { getConfig, setConfig } from '../lib/db.js'; import { upsertPublication } from '../lib/atproto.js'; import { fetchGhostIcon, fetchGhostSettings, type GhostSettings } from '../lib/ghost.js'; import { @@ -291,7 +291,18 @@ export async function runSetup(): Promise { // ─────────────────────────────────────────────────────────────────────── // 7. Persist // ─────────────────────────────────────────────────────────────────────── - const webhook_secret = randomBytes(32).toString('hex'); + // Webhook secret: keep the existing one by default on re-runs so the four + // already-configured Ghost webhooks keep working. Operator opts in to + // regeneration only when they actually want to rotate. + const existingSecret = getConfig('webhook_secret'); + let webhook_secret = existingSecret ?? randomBytes(32).toString('hex'); + if (existingSecret) { + const rotate = await confirm({ + message: 'A webhook secret already exists. Regenerate it? (you\'ll need to update Ghost\'s webhook configs)', + default: false, + }); + if (rotate) webhook_secret = randomBytes(32).toString('hex'); + } setConfig('atproto_service', atproto_service); setConfig('atproto_handle', atproto_handle); setConfig('atproto_app_password', atproto_app_password); diff --git a/src/lib/webhook-log.ts b/src/lib/webhook-log.ts new file mode 100644 index 0000000..fe89049 --- /dev/null +++ b/src/lib/webhook-log.ts @@ -0,0 +1,27 @@ +// Append-only one-line-per-delivery log of webhook activity. Lives at +// /data/webhook.log so it's persistent across container restarts and +// readable from `gs logs`. Best-effort writes — if the disk is full or +// the file is locked, we don't crash the webhook handler. + +import { appendFileSync } from 'node:fs'; + +const LOG_PATH = process.env.GS_WEBHOOK_LOG ?? '/data/webhook.log'; + +export interface WebhookLogEntry { + action: 'sync' | 'delete' | 'ignore' | 'rejected'; + postId?: string; + slug?: string; + status: 'ok' | 'failed' | 'ignored' | 'rejected'; + atUri?: string; + error?: string; + reason?: string; +} + +export function logWebhookDelivery(entry: WebhookLogEntry): void { + const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n'; + try { + appendFileSync(LOG_PATH, line); + } catch { + // Best-effort; don't take the webhook down if logging fails. + } +} diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 1a77fa0..010e151 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -16,6 +16,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; import { getConfig } from '../lib/db.js'; import { deletePost, syncPost } from '../lib/atproto.js'; +import { logWebhookDelivery } from '../lib/webhook-log.js'; export const webhookRouter = new Hono(); @@ -43,6 +44,7 @@ webhookRouter.post('/ghost', async (c) => { const secret = getConfig('webhook_secret'); if (!secret) { console.warn('[webhook/ghost] rejected: setup not complete (no webhook_secret)'); + logWebhookDelivery({ action: 'rejected', status: 'rejected', reason: 'setup not complete' }); return c.json({ error: 'setup not complete' }, 503); } @@ -106,15 +108,21 @@ webhookRouter.post('/ghost', async (c) => { try { if (action === 'ignore') { + logWebhookDelivery({ action: 'ignore', postId, slug, status: 'ignored', reason: 'draft' }); return c.json({ ok: true, ignored: 'draft' }); } - const result = action === 'sync' - ? await syncPost(postId) - : await deletePost(postId); + if (action === 'sync') { + const result = await syncPost(postId); + logWebhookDelivery({ action: 'sync', postId, slug, status: 'ok', atUri: result.uri }); + return c.json({ ok: true, action, ...result }); + } + const result = await deletePost(postId); + logWebhookDelivery({ action: 'delete', postId, slug, status: 'ok', atUri: result.atUri }); return c.json({ ok: true, action, ...result }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[webhook/ghost] ${action} ${postId} failed:`, msg); + logWebhookDelivery({ action: action as 'sync' | 'delete', postId, slug, status: 'failed', error: msg }); if (isPermanentError(err)) { return c.json({ ok: false, permanent: true, error: msg }, 200); } -- 2.51.2