diff --git a/apps/api/src/routes/status_pages.ts b/apps/api/src/routes/status_pages.ts index b7fb905..e676881 100644 --- a/apps/api/src/routes/status_pages.ts +++ b/apps/api/src/routes/status_pages.ts @@ -17,6 +17,7 @@ const StatusPageBody = t.Object({ show_cert_expiry: t.Optional(t.Boolean()), bar_frequency: t.Optional(BarFrequency), bar_count: t.Optional(t.Number({ minimum: 1, maximum: 180 })), + custom_domain: t.Optional(t.Nullable(t.String({ maxLength: 253 }))), custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))), footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))), og_image_url: t.Optional(t.Nullable(t.String({ maxLength: 2048 }))), @@ -129,7 +130,7 @@ export const statusPages = new Elysia({ prefix: "/pages" }) account_id, slug, title, description, theme, password_hash, index_search, show_powered_by, show_response_time, show_cert_expiry, bar_frequency, bar_count, - custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s + custom_domain, custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s ) VALUES ( ${accountId}, ${body.slug}, ${body.title}, ${body.description ?? null}, @@ -137,7 +138,7 @@ export const statusPages = new Elysia({ prefix: "/pages" }) ${body.show_powered_by ?? true}, ${body.show_response_time ?? true}, ${body.show_cert_expiry ?? false}, ${body.bar_frequency ?? 'daily'}, ${body.bar_count ?? 90}, - ${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null}, + ${body.custom_domain || null}, ${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null}, ${body.analytics_html ?? null}, ${body.auto_refresh_s ?? 60} ) RETURNING * @@ -193,6 +194,7 @@ export const statusPages = new Elysia({ prefix: "/pages" }) show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry), bar_frequency = COALESCE(${body.bar_frequency ?? null}, bar_frequency), bar_count = COALESCE(${body.bar_count ?? null}, bar_count), + custom_domain = CASE WHEN ${body.custom_domain !== undefined} THEN ${body.custom_domain || null} ELSE custom_domain END, custom_css = CASE WHEN ${body.custom_css !== undefined} THEN ${css} ELSE custom_css END, footer_text = COALESCE(${body.footer_text ?? null}, footer_text), og_image_url = COALESCE(${body.og_image_url ?? null}, og_image_url), diff --git a/apps/shared/db.ts b/apps/shared/db.ts index 5f73341..06af9c7 100644 --- a/apps/shared/db.ts +++ b/apps/shared/db.ts @@ -132,6 +132,7 @@ export async function migrate(sql: any) { show_cert_expiry BOOLEAN NOT NULL DEFAULT false, bar_frequency TEXT NOT NULL DEFAULT 'daily', bar_count INTEGER NOT NULL DEFAULT 90, + custom_domain TEXT UNIQUE, custom_css TEXT, footer_text TEXT, og_image_url TEXT, diff --git a/apps/status/src/data.ts b/apps/status/src/data.ts index d15edd4..1a8568a 100644 --- a/apps/status/src/data.ts +++ b/apps/status/src/data.ts @@ -20,6 +20,7 @@ export interface StatusPageRow { show_cert_expiry: boolean; bar_frequency: BucketType; bar_count: number; + custom_domain: string | null; custom_css: string | null; footer_text: string | null; og_image_url: string | null; @@ -86,6 +87,16 @@ export async function loadStatusPage(slug: string): Promise { + const [row] = await sql`SELECT * FROM status_pages WHERE custom_domain = ${domain}`; + return row ?? null; +} + +export async function verifyDomain(domain: string): Promise { + const [row] = await sql<{ id: string }[]>`SELECT id FROM status_pages WHERE custom_domain = ${domain}`; + return !!row; +} + export async function loadGroups(pageId: string): Promise { return sql` SELECT id, name, position FROM status_page_groups diff --git a/apps/status/src/index.ts b/apps/status/src/index.ts index ba24cb6..9197309 100644 --- a/apps/status/src/index.ts +++ b/apps/status/src/index.ts @@ -3,7 +3,7 @@ import { Eta } from "eta"; import { resolve } from "path"; import { createHash } from "crypto"; import sql from "./db"; -import { loadStatusPage, loadPagePayload, loadMonitorDetail } from "./data"; +import { loadStatusPage, loadStatusPageByDomain, loadPagePayload, loadMonitorDetail, verifyDomain } from "./data"; import { renderRss } from "./render/rss"; import { renderBadge, badgeFromState } from "./render/badge"; import { cached } from "./cache"; @@ -88,6 +88,19 @@ function isAuthorised(page: { id: string; password_hash: string | null }, req: R // Memoirist (Elysia's router) treats `:slug.json` as a single parameter named // "slug.json" and refuses to coexist with `:slug`. Instead, use one `:slug` // route and dispatch on the suffix in the handler. +const STATUS_HOST = process.env.STATUS_HOST || "status.pingql.com"; + +function isCustomDomain(request: Request): string | null { + const host = new URL(request.url).hostname; + if (host === STATUS_HOST || host === "localhost" || host === "127.0.0.1") return null; + return host; +} + +async function resolveSlugFromDomain(domain: string): Promise { + const page = await loadStatusPageByDomain(domain); + return page?.slug ?? null; +} + function splitSlugAndFormat(raw: string): { slug: string; format: "html" | "json" | "rss" } { if (raw.endsWith(".json")) return { slug: raw.slice(0, -5), format: "json" }; if (raw.endsWith(".rss")) return { slug: raw.slice(0, -4), format: "rss" }; @@ -183,7 +196,48 @@ async function renderRssResp(slug: string, request: Request): Promise const app = new Elysia() // No status page lives at the root - show the same 404 visitors get for any // unknown slug, so a stray hit on the apex doesn't leak service identity. - .get("/", () => notFound()) + // Caddy on_demand TLS verification - confirms a domain is a valid custom domain + .get("/internal/verify-domain", async ({ query }) => { + const domain = (query as any)?.domain; + if (!domain) return new Response("missing domain", { status: 400 }); + const valid = await verifyDomain(domain); + return new Response(valid ? "ok" : "not found", { status: valid ? 200 : 404 }); + }) + + // Custom domain routes - serve at root when Host is a custom domain + .get("/", async ({ request }) => { + const domain = isCustomDomain(request); + if (!domain) return notFound(); + const slug = await resolveSlugFromDomain(domain); + if (!slug) return notFound(); + return renderHtml(slug, request); + }) + .get("/index.json", async ({ request }) => { + const domain = isCustomDomain(request); + if (!domain) return notFound(); + const slug = await resolveSlugFromDomain(domain); + if (!slug) return notFound(); + return renderJson(slug, request); + }) + .get("/index.rss", async ({ request }) => { + const domain = isCustomDomain(request); + if (!domain) return notFound(); + const slug = await resolveSlugFromDomain(domain); + if (!slug) return notFound(); + return renderRssResp(slug, request); + }) + .get("/badge.svg", async ({ request }) => { + const domain = isCustomDomain(request); + if (!domain) return notFound(); + const slug = await resolveSlugFromDomain(domain); + if (!slug) return notFound(); + const page = await loadStatusPage(slug); + if (!page || !isAuthorised(page, request)) return notFound(); + const payload = await cached("payload:" + slug, 15, () => loadPagePayload(slug)); + if (!payload) return notFound(); + const states = payload.monitors.map((m: any) => m.current_state); + return new Response(renderBadge(page.title, badgeFromState(states)), { headers: { "content-type": "image/svg+xml", "cache-control": page.password_hash ? "private, no-store" : "public, max-age=15" } }); + }) // Static expand.js - cached aggressively, hash-busted via query string. .get("/_static/expand.js", () => new Response(Bun.file(expandJsPath), { diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index a49847c..5f95184 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -744,6 +744,7 @@ export const dashboard = new Elysia() auto_refresh_s: Number(b.auto_refresh_s) || 60, og_image_url: b.og_image_url || null, password: b.password || undefined, + custom_domain: b.custom_domain || null, custom_css: b.custom_css || null, footer_text: b.footer_text || null, groups: groupsForApi, diff --git a/apps/web/src/views/docs.ejs b/apps/web/src/views/docs.ejs index ee697bd..130ded6 100644 --- a/apps/web/src/views/docs.ejs +++ b/apps/web/src/views/docs.ejs @@ -297,6 +297,7 @@ Content-Type: application/json bar_frequencystring?hourly or daily. Default daily. bar_countnumber?How many bars to show (1-180). Default 90. auto_refresh_snumber?Auto-refresh interval in seconds (10-3600). Default 60. + custom_domainstring?Custom domain for the page (e.g. status.example.com). CNAME to the status server IP. SSL is automatic. custom_cssstring?Custom CSS injected after the default stylesheet (up to 50KB). footer_textstring?Text shown in the page footer (up to 5000 chars). og_image_urlstring?OpenGraph image URL for social previews. diff --git a/apps/web/src/views/status-page-edit.ejs b/apps/web/src/views/status-page-edit.ejs index 17e1f8d..df78952 100644 --- a/apps/web/src/views/status-page-edit.ejs +++ b/apps/web/src/views/status-page-edit.ejs @@ -44,6 +44,13 @@

Public URL: status.pingql.com/

+
+ + +

Point a CNAME to status.pingql.com and enter the domain here. SSL is automatic.

+
+
/dev/null || ntpdate -s pool.ntp.org 2>/dev/null || timedatectl set-ntp true 2>/dev/null; echo \"$(hostname 2>/dev/null || echo $host): $(date -u +%H:%M:%S.%N)\"" diff --git a/setup-status.sh b/setup-status.sh new file mode 100644 index 0000000..b870639 --- /dev/null +++ b/setup-status.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# One-time setup for the dedicated status page VPS (Debian 13) +# Installs: git, bun, caddy, clones the repo, sets up systemd service +# Usage: ./setup-status.sh + +set -e + +SSH="ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519" +STATUS_HOST="root@46.225.155.167" + +echo "[setup] Setting up status page VPS..." + +$SSH $STATUS_HOST bash << 'REMOTE' +set -e + +# System packages +apt-get update +apt-get install -y git curl unzip + +# Install bun +if [ ! -f /root/.bun/bin/bun ]; then + curl -fsSL https://bun.sh/install | bash +fi +export PATH="/root/.bun/bin:$PATH" + +# Install caddy +if ! command -v caddy &>/dev/null; then + apt-get install -y debian-keyring debian-archive-keyring apt-transport-https + curl -1sLf 'https://dl.cloudflare.com/cloudflare-main.gpg' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg 2>/dev/null || true + curl -1sLf 'https://caddyserver.com/api/download' -o /usr/bin/caddy + chmod +x /usr/bin/caddy + # Caddy systemd service + caddy environ 2>/dev/null || true + cat > /etc/systemd/system/caddy.service << 'EOF' +[Unit] +Description=Caddy +After=network.target + +[Service] +ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile +ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF +fi + +# Clone repo +if [ ! -d /opt/pingql ]; then + git clone https://git-crush-pinto-befog.forehead-gate.com/ico/pingql.git /opt/pingql +else + cd /opt/pingql + git fetch origin && git reset --hard origin/main +fi + +# Install status app deps +cd /opt/pingql/apps/status +/root/.bun/bin/bun install + +# Caddyfile - reverse proxy to status app, auto HTTPS for any domain +mkdir -p /etc/caddy +cat > /etc/caddy/Caddyfile << 'EOF' +{ + on_demand_tls { + ask http://localhost:3003/internal/verify-domain + } +} + +status.pingql.com { + reverse_proxy localhost:3003 +} + +https:// { + tls { + on_demand + } + reverse_proxy localhost:3003 +} +EOF + +# Systemd service for the status app +cat > /etc/systemd/system/pingql-status.service << 'EOF' +[Unit] +Description=PingQL Status +After=network.target + +[Service] +WorkingDirectory=/opt/pingql/apps/status +ExecStart=/root/.bun/bin/bun run src/index.ts +Restart=on-failure +RestartSec=3 +EnvironmentFile=/opt/pingql/apps/status/.env + +[Install] +WantedBy=multi-user.target +EOF + +# Enable and start services +systemctl daemon-reload +systemctl enable caddy pingql-status +systemctl restart caddy +systemctl restart pingql-status + +echo "Setup complete. Services running." +echo " - Caddy: $(systemctl is-active caddy)" +echo " - Status: $(systemctl is-active pingql-status)" +REMOTE + +echo "[setup] Done. Don't forget to:" +echo " 1. Create .env at /opt/pingql/apps/status/.env with DATABASE_URL and SECRET" +echo " 2. Point status.pingql.com DNS to 46.225.155.167" +echo " 3. Update deploy.sh STATUS_HOST"