mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
Phase 7: Cosmos deployment
- Dockerfile with a check target (typecheck and tests) and one image per service on a pinned Node 22.20 Alpine base, each with only its own production dependencies and a health check. - deploy/compose.yaml: gk-content, gk-admin and gk-site on homelab_network with no host ports, read-only root filesystems, no capabilities, the vault's Site folder mounted into the content service, and backup.dump=sqlite on it. - deploy/deploy.sh builds and checks the commit, restarts the stack, and goes back to the last good tag if it doesn't come up healthy; rollback.sh swaps tags. .gitea/workflows/deploy.yml runs it on a host runner on Cosmos. - docs/deploy.md runbook (settings, runner, mirror, NPM hosts, Turnstile, Healthchecks, backups, cutover) and the stack's inventory note in vault-kit. - Site: trusts X-Forwarded-Host/Proto for gregkilmer.com hosts so form posts pass Astro's origin check behind the proxy; rate limits read CF-Connecting-IP; site URL set for absolute links. - Content service: the held-change webhook carries a Discord-ready message; .env.example lists the core settings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user