feat: add custom domains
This commit is contained in:
parent
34470197d1
commit
b80258f17e
|
|
@ -17,6 +17,7 @@ const StatusPageBody = t.Object({
|
||||||
show_cert_expiry: t.Optional(t.Boolean()),
|
show_cert_expiry: t.Optional(t.Boolean()),
|
||||||
bar_frequency: t.Optional(BarFrequency),
|
bar_frequency: t.Optional(BarFrequency),
|
||||||
bar_count: t.Optional(t.Number({ minimum: 1, maximum: 180 })),
|
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 }))),
|
custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))),
|
||||||
footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))),
|
footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))),
|
||||||
og_image_url: t.Optional(t.Nullable(t.String({ maxLength: 2048 }))),
|
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,
|
account_id, slug, title, description, theme, password_hash, index_search,
|
||||||
show_powered_by, show_response_time, show_cert_expiry,
|
show_powered_by, show_response_time, show_cert_expiry,
|
||||||
bar_frequency, bar_count,
|
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 (
|
VALUES (
|
||||||
${accountId}, ${body.slug}, ${body.title}, ${body.description ?? null},
|
${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_powered_by ?? true}, ${body.show_response_time ?? true},
|
||||||
${body.show_cert_expiry ?? false},
|
${body.show_cert_expiry ?? false},
|
||||||
${body.bar_frequency ?? 'daily'}, ${body.bar_count ?? 90},
|
${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}
|
${body.analytics_html ?? null}, ${body.auto_refresh_s ?? 60}
|
||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
|
|
@ -193,6 +194,7 @@ export const statusPages = new Elysia({ prefix: "/pages" })
|
||||||
show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry),
|
show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry),
|
||||||
bar_frequency = COALESCE(${body.bar_frequency ?? null}, bar_frequency),
|
bar_frequency = COALESCE(${body.bar_frequency ?? null}, bar_frequency),
|
||||||
bar_count = COALESCE(${body.bar_count ?? null}, bar_count),
|
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,
|
custom_css = CASE WHEN ${body.custom_css !== undefined} THEN ${css} ELSE custom_css END,
|
||||||
footer_text = COALESCE(${body.footer_text ?? null}, footer_text),
|
footer_text = COALESCE(${body.footer_text ?? null}, footer_text),
|
||||||
og_image_url = COALESCE(${body.og_image_url ?? null}, og_image_url),
|
og_image_url = COALESCE(${body.og_image_url ?? null}, og_image_url),
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ export async function migrate(sql: any) {
|
||||||
show_cert_expiry BOOLEAN NOT NULL DEFAULT false,
|
show_cert_expiry BOOLEAN NOT NULL DEFAULT false,
|
||||||
bar_frequency TEXT NOT NULL DEFAULT 'daily',
|
bar_frequency TEXT NOT NULL DEFAULT 'daily',
|
||||||
bar_count INTEGER NOT NULL DEFAULT 90,
|
bar_count INTEGER NOT NULL DEFAULT 90,
|
||||||
|
custom_domain TEXT UNIQUE,
|
||||||
custom_css TEXT,
|
custom_css TEXT,
|
||||||
footer_text TEXT,
|
footer_text TEXT,
|
||||||
og_image_url TEXT,
|
og_image_url TEXT,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export interface StatusPageRow {
|
||||||
show_cert_expiry: boolean;
|
show_cert_expiry: boolean;
|
||||||
bar_frequency: BucketType;
|
bar_frequency: BucketType;
|
||||||
bar_count: number;
|
bar_count: number;
|
||||||
|
custom_domain: string | null;
|
||||||
custom_css: string | null;
|
custom_css: string | null;
|
||||||
footer_text: string | null;
|
footer_text: string | null;
|
||||||
og_image_url: string | null;
|
og_image_url: string | null;
|
||||||
|
|
@ -86,6 +87,16 @@ export async function loadStatusPage(slug: string): Promise<StatusPageRow | null
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadStatusPageByDomain(domain: string): Promise<StatusPageRow | null> {
|
||||||
|
const [row] = await sql<StatusPageRow[]>`SELECT * FROM status_pages WHERE custom_domain = ${domain}`;
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyDomain(domain: string): Promise<boolean> {
|
||||||
|
const [row] = await sql<{ id: string }[]>`SELECT id FROM status_pages WHERE custom_domain = ${domain}`;
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadGroups(pageId: string): Promise<GroupRow[]> {
|
export async function loadGroups(pageId: string): Promise<GroupRow[]> {
|
||||||
return sql<GroupRow[]>`
|
return sql<GroupRow[]>`
|
||||||
SELECT id, name, position FROM status_page_groups
|
SELECT id, name, position FROM status_page_groups
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Eta } from "eta";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import sql from "./db";
|
import sql from "./db";
|
||||||
import { loadStatusPage, loadPagePayload, loadMonitorDetail } from "./data";
|
import { loadStatusPage, loadStatusPageByDomain, loadPagePayload, loadMonitorDetail, verifyDomain } from "./data";
|
||||||
import { renderRss } from "./render/rss";
|
import { renderRss } from "./render/rss";
|
||||||
import { renderBadge, badgeFromState } from "./render/badge";
|
import { renderBadge, badgeFromState } from "./render/badge";
|
||||||
import { cached } from "./cache";
|
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
|
// Memoirist (Elysia's router) treats `:slug.json` as a single parameter named
|
||||||
// "slug.json" and refuses to coexist with `:slug`. Instead, use one `:slug`
|
// "slug.json" and refuses to coexist with `:slug`. Instead, use one `:slug`
|
||||||
// route and dispatch on the suffix in the handler.
|
// 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<string | null> {
|
||||||
|
const page = await loadStatusPageByDomain(domain);
|
||||||
|
return page?.slug ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function splitSlugAndFormat(raw: string): { slug: string; format: "html" | "json" | "rss" } {
|
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(".json")) return { slug: raw.slice(0, -5), format: "json" };
|
||||||
if (raw.endsWith(".rss")) return { slug: raw.slice(0, -4), format: "rss" };
|
if (raw.endsWith(".rss")) return { slug: raw.slice(0, -4), format: "rss" };
|
||||||
|
|
@ -183,7 +196,48 @@ async function renderRssResp(slug: string, request: Request): Promise<Response>
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
// No status page lives at the root - show the same 404 visitors get for any
|
// 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.
|
// 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.
|
// Static expand.js - cached aggressively, hash-busted via query string.
|
||||||
.get("/_static/expand.js", () => new Response(Bun.file(expandJsPath), {
|
.get("/_static/expand.js", () => new Response(Bun.file(expandJsPath), {
|
||||||
|
|
|
||||||
|
|
@ -744,6 +744,7 @@ export const dashboard = new Elysia()
|
||||||
auto_refresh_s: Number(b.auto_refresh_s) || 60,
|
auto_refresh_s: Number(b.auto_refresh_s) || 60,
|
||||||
og_image_url: b.og_image_url || null,
|
og_image_url: b.og_image_url || null,
|
||||||
password: b.password || undefined,
|
password: b.password || undefined,
|
||||||
|
custom_domain: b.custom_domain || null,
|
||||||
custom_css: b.custom_css || null,
|
custom_css: b.custom_css || null,
|
||||||
footer_text: b.footer_text || null,
|
footer_text: b.footer_text || null,
|
||||||
groups: groupsForApi,
|
groups: groupsForApi,
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,7 @@ Content-Type: application/json
|
||||||
<tr><td>bar_frequency</td><td>string?</td><td><code>hourly</code> or <code>daily</code>. Default <code>daily</code>.</td></tr>
|
<tr><td>bar_frequency</td><td>string?</td><td><code>hourly</code> or <code>daily</code>. Default <code>daily</code>.</td></tr>
|
||||||
<tr><td>bar_count</td><td>number?</td><td>How many bars to show (1-180). Default 90.</td></tr>
|
<tr><td>bar_count</td><td>number?</td><td>How many bars to show (1-180). Default 90.</td></tr>
|
||||||
<tr><td>auto_refresh_s</td><td>number?</td><td>Auto-refresh interval in seconds (10-3600). Default 60.</td></tr>
|
<tr><td>auto_refresh_s</td><td>number?</td><td>Auto-refresh interval in seconds (10-3600). Default 60.</td></tr>
|
||||||
|
<tr><td>custom_domain</td><td>string?</td><td>Custom domain for the page (e.g. <code>status.example.com</code>). CNAME to the status server IP. SSL is automatic.</td></tr>
|
||||||
<tr><td>custom_css</td><td>string?</td><td>Custom CSS injected after the default stylesheet (up to 50KB).</td></tr>
|
<tr><td>custom_css</td><td>string?</td><td>Custom CSS injected after the default stylesheet (up to 50KB).</td></tr>
|
||||||
<tr><td>footer_text</td><td>string?</td><td>Text shown in the page footer (up to 5000 chars).</td></tr>
|
<tr><td>footer_text</td><td>string?</td><td>Text shown in the page footer (up to 5000 chars).</td></tr>
|
||||||
<tr><td>og_image_url</td><td>string?</td><td>OpenGraph image URL for social previews.</td></tr>
|
<tr><td>og_image_url</td><td>string?</td><td>OpenGraph image URL for social previews.</td></tr>
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,13 @@
|
||||||
<p class="text-xs text-gray-600 mt-1">Public URL: <span class="text-blue-400 font-mono">status.pingql.com/<your-slug></span></p>
|
<p class="text-xs text-gray-600 mt-1">Public URL: <span class="text-blue-400 font-mono">status.pingql.com/<your-slug></span></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Custom domain <span class="text-gray-600">(optional)</span></label>
|
||||||
|
<input name="custom_domain" type="text" value="<%= p.custom_domain || '' %>" placeholder="status.example.com"
|
||||||
|
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-sm">
|
||||||
|
<p class="text-xs text-gray-600 mt-1">Point a CNAME to <span class="text-blue-400 font-mono">status.pingql.com</span> and enter the domain here. SSL is automatic.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">Title</label>
|
<label class="block text-sm text-gray-400 mb-1.5">Title</label>
|
||||||
<input name="title" type="text" required value="<%= p.title || '' %>" placeholder="My App Status"
|
<input name="title" type="text" required value="<%= p.title || '' %>" placeholder="My App Status"
|
||||||
|
|
|
||||||
15
deploy.sh
15
deploy.sh
|
|
@ -5,8 +5,6 @@
|
||||||
# Example: ./deploy.sh all
|
# Example: ./deploy.sh all
|
||||||
# Example: ./deploy.sh nuke-db (wipes all data — NOT included in "all")
|
# Example: ./deploy.sh nuke-db (wipes all data — NOT included in "all")
|
||||||
#
|
#
|
||||||
# Note: status (the public status pages service) currently shares the web host.
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SSH="ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519"
|
SSH="ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519"
|
||||||
|
|
@ -14,6 +12,7 @@ SSH="ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519"
|
||||||
DB_HOST="root@142.132.190.209"
|
DB_HOST="root@142.132.190.209"
|
||||||
API_HOST="root@88.99.123.102"
|
API_HOST="root@88.99.123.102"
|
||||||
WEB_HOST="root@78.47.43.36"
|
WEB_HOST="root@78.47.43.36"
|
||||||
|
STATUS_HOST="root@46.225.155.167"
|
||||||
MONITOR_HOSTS=("root@5.78.178.12" "root@49.13.118.44")
|
MONITOR_HOSTS=("root@5.78.178.12" "root@49.13.118.44")
|
||||||
|
|
||||||
deploy_db() {
|
deploy_db() {
|
||||||
|
|
@ -79,10 +78,8 @@ REMOTE
|
||||||
}
|
}
|
||||||
|
|
||||||
deploy_status() {
|
deploy_status() {
|
||||||
# Public status pages service. Co-located on the web host for now; promote to
|
echo "[status] Deploying to status-eu-central..."
|
||||||
# its own VPS once traffic justifies it.
|
$SSH $STATUS_HOST bash << 'REMOTE'
|
||||||
echo "[status] Deploying to web-eu-central (co-located)..."
|
|
||||||
$SSH $WEB_HOST bash << 'REMOTE'
|
|
||||||
cd /opt/pingql
|
cd /opt/pingql
|
||||||
git fetch origin && git reset --hard origin/main
|
git fetch origin && git reset --hard origin/main
|
||||||
cd apps/status
|
cd apps/status
|
||||||
|
|
@ -128,8 +125,8 @@ stop_app() {
|
||||||
$SSH $WEB_HOST "systemctl stop pingql-web && echo 'pingql-web stopped'"
|
$SSH $WEB_HOST "systemctl stop pingql-web && echo 'pingql-web stopped'"
|
||||||
;;
|
;;
|
||||||
status)
|
status)
|
||||||
echo "[stop] Stopping pingql-status on web-eu-central..."
|
echo "[stop] Stopping pingql-status on status-eu-central..."
|
||||||
$SSH $WEB_HOST "systemctl stop pingql-status && echo 'pingql-status stopped'"
|
$SSH $STATUS_HOST "systemctl stop pingql-status && echo 'pingql-status stopped'"
|
||||||
;;
|
;;
|
||||||
monitor)
|
monitor)
|
||||||
echo "[stop] Stopping monitors on all hosts..."
|
echo "[stop] Stopping monitors on all hosts..."
|
||||||
|
|
@ -175,7 +172,7 @@ fi
|
||||||
|
|
||||||
sync_time() {
|
sync_time() {
|
||||||
echo "[sync] Syncing time on all servers..."
|
echo "[sync] Syncing time on all servers..."
|
||||||
ALL_HOSTS=("$DB_HOST" "$API_HOST" "$WEB_HOST" "${MONITOR_HOSTS[@]}")
|
ALL_HOSTS=("$DB_HOST" "$API_HOST" "$WEB_HOST" "$STATUS_HOST" "${MONITOR_HOSTS[@]}")
|
||||||
for host in "${ALL_HOSTS[@]}"; do
|
for host in "${ALL_HOSTS[@]}"; do
|
||||||
(
|
(
|
||||||
$SSH "$host" "chronyc makestep 2>/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)\""
|
$SSH "$host" "chronyc makestep 2>/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)\""
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
Loading…
Reference in New Issue