feat: split web and api into separate apps

This commit is contained in:
M1
2026-03-18 09:33:46 +04:00
parent ba437e3c5a
commit 841a852491
10 changed files with 593 additions and 34 deletions
+68
View File
@@ -0,0 +1,68 @@
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql");
export default sql;
export async function migrate() {
await sql`
CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
email_hash TEXT,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`
CREATE TABLE IF NOT EXISTS monitors (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL,
url TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'GET',
request_headers JSONB,
request_body TEXT,
timeout_ms INTEGER NOT NULL DEFAULT 30000,
interval_s INTEGER NOT NULL DEFAULT 60,
query JSONB,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`
CREATE TABLE IF NOT EXISTS pings (
id BIGSERIAL PRIMARY KEY,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
scheduled_at TIMESTAMPTZ,
jitter_ms INTEGER,
status_code INTEGER,
latency_ms INTEGER,
up BOOLEAN NOT NULL,
error TEXT,
meta JSONB
)
`;
// Migrations for existing deployments
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS jitter_ms INTEGER`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
await sql`
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
label TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ
)
`;
console.log("DB ready");
}
+27
View File
@@ -0,0 +1,27 @@
import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { ingest } from "./routes/pings";
import { monitors } from "./routes/monitors";
import { account } from "./routes/auth";
import { internal } from "./routes/internal";
import { migrate } from "./db";
await migrate();
const app = new Elysia()
.use(cors({
origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"],
credentials: true,
}))
.get("/", () => ({
name: "PingQL API",
version: "1",
docs: "https://pingql.com/docs",
}))
.use(account)
.use(monitors)
.use(ingest)
.use(internal)
.listen(3001);
console.log(`PingQL API running at http://localhost:${app.server?.port}`);
+330
View File
@@ -0,0 +1,330 @@
/**
* PingQL Query Engine — TypeScript implementation
*
* MongoDB-inspired query language for evaluating HTTP response conditions.
* Mirrors the Rust implementation but also powers the visual query builder.
*/
// ── Types ──────────────────────────────────────────────────────────────
export interface QueryField {
name: string;
description: string;
type: "number" | "string" | "boolean" | "object";
operators: string[];
}
export interface EvalContext {
status: number;
body: string;
headers: Record<string, string>;
latency_ms?: number;
cert_expiry_days?: number;
}
export interface ValidationError {
path: string;
message: string;
}
// ── Available fields ───────────────────────────────────────────────────
export function getAvailableFields(): QueryField[] {
return [
{
name: "status",
description: "HTTP status code (e.g. 200, 404, 500)",
type: "number",
operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"],
},
{
name: "body",
description: "Response body as text",
type: "string",
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"],
},
{
name: "headers.*",
description: "Response header value (e.g. headers.content-type)",
type: "string",
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"],
},
{
name: "$select",
description: "CSS selector — returns text content of first matching element",
type: "string",
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex"],
},
{
name: "$json",
description: "JSONPath expression evaluated against response body (e.g. $.data.status)",
type: "string",
operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$contains", "$regex"],
},
{
name: "$responseTime",
description: "Request latency in milliseconds",
type: "number",
operators: ["$eq", "$gt", "$gte", "$lt", "$lte"],
},
{
name: "$certExpiry",
description: "Days until SSL certificate expires",
type: "number",
operators: ["$eq", "$gt", "$gte", "$lt", "$lte"],
},
];
}
// ── Evaluate ───────────────────────────────────────────────────────────
export function evaluate(query: unknown, ctx: EvalContext): boolean {
if (query === null || query === undefined) return true;
if (typeof query !== "object" || Array.isArray(query)) {
throw new Error("Query must be an object");
}
const q = query as Record<string, unknown>;
// $consider — "up" (default) or "down": flips result if conditions match
if ("$consider" in q) {
const consider = q.$consider as string;
const rest = Object.fromEntries(Object.entries(q).filter(([k]) => k !== "$consider"));
const matches = evaluate(rest, ctx);
return consider === "down" ? !matches : matches;
}
// $and
if ("$and" in q) {
const clauses = q.$and;
if (!Array.isArray(clauses)) throw new Error("$and expects array");
return clauses.every((c) => evaluate(c, ctx));
}
// $or
if ("$or" in q) {
const clauses = q.$or;
if (!Array.isArray(clauses)) throw new Error("$or expects array");
return clauses.some((c) => evaluate(c, ctx));
}
// $not
if ("$not" in q) {
return !evaluate(q.$not, ctx);
}
// $responseTime
if ("$responseTime" in q) {
const val = ctx.latency_ms ?? 0;
return evalCondition(q.$responseTime, val);
}
// $certExpiry
if ("$certExpiry" in q) {
const val = ctx.cert_expiry_days ?? Infinity;
return evalCondition(q.$certExpiry, val);
}
// $select — { "$select": { "css.selector": { "$op": val } } }
if ("$select" in q) {
// Server-side: no DOM parser available, pass through (Rust runner evaluates)
// Validate structure only
const selMap = q.$select as Record<string, unknown>;
if (typeof selMap !== "object" || Array.isArray(selMap)) throw new Error("$select expects { selector: condition }");
return true;
}
// $json — { "$json": { "$.path": { "$op": val } } }
if ("$json" in q) {
const pathMap = q.$json as Record<string, unknown>;
if (typeof pathMap !== "object" || Array.isArray(pathMap)) throw new Error("$json expects { path: condition }");
for (const [path, condition] of Object.entries(pathMap)) {
const resolved = resolveJsonPath(ctx.body, path);
if (!evalCondition(condition, resolved)) return false;
}
return true;
}
// Field-level checks
for (const [field, condition] of Object.entries(q)) {
if (field.startsWith("$")) continue; // skip unknown $ ops
const fieldVal = resolveField(field, ctx);
if (!evalCondition(condition, fieldVal)) return false;
}
return true;
}
function resolveField(field: string, ctx: EvalContext): unknown {
switch (field) {
case "status":
case "status_code":
return ctx.status;
case "body":
return ctx.body;
default:
if (field.startsWith("headers.")) {
const key = field.slice(8).toLowerCase();
return ctx.headers[key] ?? null;
}
return null;
}
}
function resolveJsonPath(body: string, expr: string): unknown {
try {
const obj = JSON.parse(body);
// Simple dot-notation JSONPath: $.foo.bar[0].baz
const path = expr.replace(/^\$\.?/, "");
if (!path) return obj;
const parts = path.split(/\.|\[(\d+)\]/).filter(Boolean);
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined) return null;
current = (current as Record<string, unknown>)[part];
}
return current ?? null;
} catch {
return null;
}
}
function evalCondition(condition: unknown, fieldVal: unknown): boolean {
if (condition === null || condition === undefined) return true;
// Direct equality shorthand: { "status": 200 }
if (typeof condition === "number" || typeof condition === "string" || typeof condition === "boolean") {
return fieldVal === condition;
}
if (typeof condition === "object" && !Array.isArray(condition)) {
const ops = condition as Record<string, unknown>;
for (const [op, opVal] of Object.entries(ops)) {
if (!evalOp(op, fieldVal, opVal)) return false;
}
return true;
}
return true;
}
function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean {
switch (op) {
case "$eq":
return fieldVal === opVal;
case "$ne":
return fieldVal !== opVal;
case "$gt":
return toNum(fieldVal) > toNum(opVal);
case "$gte":
return toNum(fieldVal) >= toNum(opVal);
case "$lt":
return toNum(fieldVal) < toNum(opVal);
case "$lte":
return toNum(fieldVal) <= toNum(opVal);
case "$contains":
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.includes(opVal);
case "$startsWith":
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.startsWith(opVal);
case "$endsWith":
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.endsWith(opVal);
case "$regex": {
if (typeof fieldVal !== "string" || typeof opVal !== "string") return false;
if (opVal.length > 200) return false;
try {
return new RegExp(opVal).test(fieldVal);
} catch {
return false;
}
}
case "$exists":
return opVal ? fieldVal !== null && fieldVal !== undefined : fieldVal === null || fieldVal === undefined;
case "$in":
return Array.isArray(opVal) && opVal.includes(fieldVal);
default:
return true; // unknown op — skip
}
}
function toNum(v: unknown): number {
return typeof v === "number" ? v : Number(v) || 0;
}
// ── Validate ───────────────────────────────────────────────────────────
const VALID_OPS = new Set([
"$eq", "$ne", "$gt", "$gte", "$lt", "$lte",
"$contains", "$startsWith", "$endsWith", "$regex",
"$exists", "$in",
"$select", "$json",
"$and", "$or", "$not",
"$responseTime", "$certExpiry",
]);
const VALID_FIELDS = new Set([
"status", "status_code", "body",
]);
export function validateQuery(query: unknown, path = ""): ValidationError[] {
const errors: ValidationError[] = [];
if (query === null || query === undefined) return errors;
if (typeof query !== "object" || Array.isArray(query)) {
errors.push({ path: path || "$", message: "Query must be an object" });
return errors;
}
const q = query as Record<string, unknown>;
for (const [key, value] of Object.entries(q)) {
const keyPath = path ? `${path}.${key}` : key;
if (key === "$and" || key === "$or") {
if (!Array.isArray(value)) {
errors.push({ path: keyPath, message: `${key} expects an array` });
} else {
value.forEach((clause, i) => {
errors.push(...validateQuery(clause, `${keyPath}[${i}]`));
});
}
} else if (key === "$not") {
errors.push(...validateQuery(value, keyPath));
} else if (key === "$responseTime" || key === "$certExpiry") {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
errors.push({ path: keyPath, message: `${key} expects an operator object (e.g. { "$lt": 500 })` });
} else {
for (const op of Object.keys(value as Record<string, unknown>)) {
if (!VALID_OPS.has(op)) {
errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` });
}
}
}
} else if (key === "$select" || key === "$json") {
if (typeof value !== "string") {
errors.push({ path: keyPath, message: `${key} expects a string` });
}
} else if (key.startsWith("$")) {
// It's an operator inside a field condition — skip validation here
} else {
// Field name
if (!VALID_FIELDS.has(key) && !key.startsWith("headers.")) {
errors.push({ path: keyPath, message: `Unknown field: ${key}. Use status, body, or headers.*` });
}
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
const ops = value as Record<string, unknown>;
for (const op of Object.keys(ops)) {
if (!op.startsWith("$")) continue;
if (!VALID_OPS.has(op)) {
errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` });
}
if (op === "$regex" && typeof ops[op] === "string" && (ops[op] as string).length > 200) {
errors.push({ path: `${keyPath}.${op}`, message: "Regex pattern too long (max 200 characters)" });
}
}
}
}
}
return errors;
}
+148
View File
@@ -0,0 +1,148 @@
import { Elysia, t } from "elysia";
import { createHash } from "crypto";
import sql from "../db";
function generateKey(): string {
return crypto.randomUUID();
}
function hashEmail(email: string): string {
return createHash("sha256").update(email.toLowerCase().trim()).digest("hex");
}
async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> {
const [account] = await sql`SELECT id FROM accounts WHERE key = ${key}`;
if (account) return { accountId: account.id, keyId: null };
const [apiKey] = await sql`SELECT id, account_id FROM api_keys WHERE key = ${key}`;
if (apiKey) {
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {});
return { accountId: apiKey.account_id, keyId: apiKey.id };
}
return null;
}
export { resolveKey };
export function requireAuth(app: Elysia) {
return app
.derive(async ({ headers, cookie, set }) => {
const authHeader = headers["authorization"] ?? "";
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
const cookieKey = cookie?.pingql_key?.value;
const key = bearer || cookieKey;
if (!key) {
set.status = 401;
return { accountId: null as string | null, keyId: null as string | null };
}
const resolved = await resolveKey(key);
if (resolved) return { accountId: resolved.accountId, keyId: resolved.keyId };
set.status = 401;
return { accountId: null as string | null, keyId: null as string | null };
})
.onBeforeHandle(({ accountId, set }) => {
if (!accountId) {
set.status = 401;
return { error: "Invalid or missing account key" };
}
});
}
const COOKIE_OPTS = {
httpOnly: true,
secure: process.env.NODE_ENV !== "development",
sameSite: "lax" as const,
path: "/",
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
maxAge: 60 * 60 * 24 * 365,
};
export const account = new Elysia({ prefix: "/account" })
.post("/login", async ({ body, cookie, set }) => {
const key = (body.key as string)?.trim();
if (!key) { set.status = 400; return { error: "Key required" }; }
const resolved = await resolveKey(key);
if (!resolved) {
set.status = 401;
if ((body as any)._form) { set.redirect = "/dashboard?error=invalid"; return; }
return { error: "Invalid account key" };
}
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
if ((body as any)._form) { set.redirect = "/dashboard/home"; return; }
return { ok: true };
}, { detail: { hide: true } })
.get("/logout", ({ cookie, set }) => {
cookie.pingql_key.set({ value: "", ...COOKIE_OPTS, maxAge: 0 });
set.redirect = "/dashboard";
}, { detail: { hide: true } })
.post("/register", async ({ body, cookie }) => {
const key = generateKey();
const emailHash = body.email ? hashEmail(body.email) : null;
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
return {
key,
...(body.email ? { email_registered: true } : { email_registered: false }),
};
}, {
body: t.Object({
email: t.Optional(t.String({ format: "email", description: "Optional. Used for account recovery only." })),
}),
})
.use(requireAuth)
.get("/settings", async ({ accountId }) => {
const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`;
const keys = await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
return {
account_id: acc.id,
has_email: !!acc.email_hash,
created_at: acc.created_at,
api_keys: keys,
};
})
.post("/email", async ({ accountId, body }) => {
const emailHash = body.email ? hashEmail(body.email) : null;
await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`;
return { ok: true };
}, {
body: t.Object({
email: t.Optional(t.Nullable(t.String({ description: "Email for account recovery only." }))),
}),
})
.post("/reset-key", async ({ accountId, cookie }) => {
const key = generateKey();
await sql`UPDATE accounts SET key = ${key} WHERE id = ${accountId}`;
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
return { key, message: "Primary key rotated. Your old key is now invalid." };
})
.post("/keys", async ({ accountId, body }) => {
const key = generateKey();
const [created] = await sql`INSERT INTO api_keys (key, account_id, label) VALUES (${key}, ${accountId}, ${body.label}) RETURNING id`;
return { key, id: created.id, label: body.label };
}, {
body: t.Object({
label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }),
}),
})
.delete("/keys/:id", async ({ accountId, params, error }) => {
const [deleted] = await sql`
DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id
`;
if (!deleted) return error(404, { error: "Key not found" });
return { deleted: true };
});
+52
View File
@@ -0,0 +1,52 @@
/// Internal endpoints used by the Rust monitor runner.
/// Protected by MONITOR_TOKEN — not exposed to users.
import { Elysia } from "elysia";
import sql from "../db";
export async function pruneOldPings(retentionDays = 90) {
const result = await sql`DELETE FROM pings WHERE checked_at < now() - ${retentionDays + ' days'}::interval`;
return result.count;
}
// Run retention cleanup every hour
setInterval(() => {
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
pruneOldPings(days).catch((err) => console.error("Retention cleanup failed:", err));
}, 60 * 60 * 1000);
export const internal = new Elysia({ prefix: "/internal", detail: { hide: true } })
.derive(({ headers, error }) => {
if (headers["x-monitor-token"] !== process.env.MONITOR_TOKEN)
return error(401, { error: "Unauthorized" });
return {};
})
// Returns monitors that are due for a check.
// scheduled_at = last_checked_at + interval_s (ideal fire time), so jitter = actual_start - scheduled_at
.get("/due", async () => {
const monitors = await sql`
SELECT m.id, m.url, m.method, m.request_headers, m.request_body, m.timeout_ms, m.interval_s, m.query,
CASE
WHEN last.checked_at IS NULL THEN now()
ELSE last.checked_at + (m.interval_s || ' seconds')::interval
END AS scheduled_at
FROM monitors m
LEFT JOIN LATERAL (
SELECT checked_at FROM pings
WHERE monitor_id = m.id
ORDER BY checked_at DESC LIMIT 1
) last ON true
WHERE m.enabled = true
AND (last.checked_at IS NULL
OR last.checked_at < now() - (m.interval_s || ' seconds')::interval)
`;
return monitors;
})
// Manual retention cleanup trigger
.post("/prune", async () => {
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
const deleted = await pruneOldPings(days);
return { deleted, retention_days: days };
});
+118
View File
@@ -0,0 +1,118 @@
import { Elysia, t } from "elysia";
import { requireAuth } from "./auth";
import sql from "../db";
import { validateMonitorUrl } from "../utils/ssrf";
const MonitorBody = t.Object({
name: t.String({ description: "Human-readable name" }),
url: t.String({ format: "uri", description: "URL to check" }),
method: t.Optional(t.String({ default: "GET", description: "HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD" })),
request_headers: t.Optional(t.Any({ description: "Request headers as key-value object" })),
request_body: t.Optional(t.Nullable(t.String({ description: "Request body for POST/PUT/PATCH" }))),
timeout_ms: t.Optional(t.Number({ minimum: 1000, maximum: 60000, default: 30000, description: "Request timeout in ms" })),
interval_s: t.Optional(t.Number({ minimum: 1, default: 60, description: "Check interval in seconds" })),
query: t.Optional(t.Any({ description: "PingQL query — filter conditions for up/down" })),
});
export const monitors = new Elysia({ prefix: "/monitors" })
.use(requireAuth)
// List monitors
.get("/", async ({ accountId }) => {
return sql`SELECT * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC`;
}, { detail: { summary: "List monitors", tags: ["monitors"] } })
// Create monitor
.post("/", async ({ accountId, body, error }) => {
// SSRF protection
const ssrfError = await validateMonitorUrl(body.url);
if (ssrfError) return error(400, { error: ssrfError });
const [monitor] = await sql`
INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, query)
VALUES (
${accountId}, ${body.name}, ${body.url},
${(body.method ?? 'GET').toUpperCase()},
${body.request_headers ? sql.json(body.request_headers) : null},
${body.request_body ?? null},
${body.timeout_ms ?? 30000},
${body.interval_s ?? 60},
${body.query ? sql.json(body.query) : null}
)
RETURNING *
`;
return monitor;
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
// Get monitor + recent status
.get("/:id", async ({ accountId, params, error }) => {
const [monitor] = await sql`
SELECT * FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
`;
if (!monitor) return error(404, { error: "Not found" });
const results = await sql`
SELECT * FROM pings WHERE monitor_id = ${params.id}
ORDER BY checked_at DESC LIMIT 100
`;
return { ...monitor, results };
}, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
// Update monitor
.patch("/:id", async ({ accountId, params, body, error }) => {
// SSRF protection on URL change
if (body.url) {
const ssrfError = await validateMonitorUrl(body.url);
if (ssrfError) return error(400, { error: ssrfError });
}
const [monitor] = await sql`
UPDATE monitors SET
name = COALESCE(${body.name ?? null}, name),
url = COALESCE(${body.url ?? null}, url),
method = COALESCE(${body.method ? body.method.toUpperCase() : null}, method),
request_headers = COALESCE(${body.request_headers ? sql.json(body.request_headers) : null}, request_headers),
request_body = COALESCE(${body.request_body ?? null}, request_body),
timeout_ms = COALESCE(${body.timeout_ms ?? null}, timeout_ms),
interval_s = COALESCE(${body.interval_s ?? null}, interval_s),
query = COALESCE(${body.query ? sql.json(body.query) : null}, query)
WHERE id = ${params.id} AND account_id = ${accountId}
RETURNING *
`;
if (!monitor) return error(404, { error: "Not found" });
return monitor;
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
// Delete monitor
.delete("/:id", async ({ accountId, params, error }) => {
const [deleted] = await sql`
DELETE FROM monitors WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id
`;
if (!deleted) return error(404, { error: "Not found" });
return { deleted: true };
}, { detail: { summary: "Delete monitor", tags: ["monitors"] } })
// Toggle enabled
.post("/:id/toggle", async ({ accountId, params, error }) => {
const [monitor] = await sql`
UPDATE monitors SET enabled = NOT enabled
WHERE id = ${params.id} AND account_id = ${accountId}
RETURNING id, enabled
`;
if (!monitor) return error(404, { error: "Not found" });
return monitor;
}, { detail: { summary: "Toggle monitor on/off", tags: ["monitors"] } })
// Check history
.get("/:id/pings", async ({ accountId, params, query, error }) => {
const [monitor] = await sql`
SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
`;
if (!monitor) return error(404, { error: "Not found" });
const limit = Math.min(Number(query.limit ?? 100), 1000);
return sql`
SELECT * FROM pings
WHERE monitor_id = ${params.id}
ORDER BY checked_at DESC LIMIT ${limit}
`;
}, { detail: { summary: "Get ping history", tags: ["monitors"] } });
+114
View File
@@ -0,0 +1,114 @@
import { Elysia, t } from "elysia";
import sql from "../db";
import { resolveKey } from "./auth";
// ── SSE bus ───────────────────────────────────────────────────────────────────
type SSEController = ReadableStreamDefaultController<Uint8Array>;
const bus = new Map<string, Set<SSEController>>(); // keyed by accountId
const enc = new TextEncoder();
function publish(accountId: string, data: object) {
const subs = bus.get(accountId);
if (!subs?.size) return;
const msg = enc.encode(`data: ${JSON.stringify(data)}\n\n`);
for (const ctrl of subs) {
try { ctrl.enqueue(msg); } catch { subs.delete(ctrl); }
}
}
function makeSSEStream(accountId: string): Response {
let ctrl: SSEController;
let heartbeat: Timer;
const stream = new ReadableStream<Uint8Array>({
start(c) {
ctrl = c;
if (!bus.has(accountId)) bus.set(accountId, new Set());
bus.get(accountId)!.add(ctrl);
ctrl.enqueue(enc.encode(": connected\n\n"));
heartbeat = setInterval(() => {
try { ctrl.enqueue(enc.encode(": heartbeat\n\n")); } catch { clearInterval(heartbeat); }
}, 10_000);
},
cancel() {
clearInterval(heartbeat);
bus.get(accountId)?.delete(ctrl);
if (bus.get(accountId)?.size === 0) bus.delete(accountId);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
// ── Routes ────────────────────────────────────────────────────────────────────
export const ingest = new Elysia()
// Internal: called by Rust monitor runner
.post("/internal/ingest", async ({ body, headers, error }) => {
const token = headers["x-monitor-token"];
if (token !== process.env.MONITOR_TOKEN) return error(401, { error: "Unauthorized" });
const meta = body.meta ? { ...body.meta } : {};
if (body.cert_expiry_days != null) meta.cert_expiry_days = body.cert_expiry_days;
const scheduledAt = body.scheduled_at ? new Date(body.scheduled_at) : null;
const jitterMs = body.jitter_ms ?? null;
const [ping] = await sql`
INSERT INTO pings (monitor_id, scheduled_at, jitter_ms, status_code, latency_ms, up, error, meta)
VALUES (
${body.monitor_id},
${scheduledAt},
${jitterMs},
${body.status_code ?? null},
${body.latency_ms ?? null},
${body.up},
${body.error ?? null},
${Object.keys(meta).length > 0 ? sql.json(meta) : null}
)
RETURNING *
`;
// Look up account and publish to account-level bus
const [monitor] = await sql`SELECT account_id FROM monitors WHERE id = ${body.monitor_id}`;
if (monitor) publish(monitor.account_id, ping);
return { ok: true };
}, {
body: t.Object({
monitor_id: t.String(),
scheduled_at: t.Optional(t.Nullable(t.String())),
jitter_ms: t.Optional(t.Nullable(t.Number())),
status_code: t.Optional(t.Number()),
latency_ms: t.Optional(t.Number()),
up: t.Boolean(),
error: t.Optional(t.Nullable(t.String())),
cert_expiry_days: t.Optional(t.Nullable(t.Number())),
meta: t.Optional(t.Any()),
}),
detail: { hide: true },
})
// SSE: single stream for all of the account's monitors
.get("/account/stream", async ({ headers, cookie }) => {
const authHeader = headers["authorization"] ?? "";
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
const key = bearer ?? cookie?.pingql_key?.value;
if (!key) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
const resolved = await resolveKey(key);
if (!resolved) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
const limit = Number(process.env.MAX_SSE_PER_ACCOUNT ?? 10);
if ((bus.get(resolved.accountId)?.size ?? 0) >= limit) {
return new Response(JSON.stringify({ error: "Too many connections" }), { status: 429 });
}
return makeSSEStream(resolved.accountId);
}, { detail: { hide: true } });
+95
View File
@@ -0,0 +1,95 @@
import { createHash } from "crypto";
import dns from "dns/promises";
const BLOCKED_TLDS = [".local", ".internal", ".corp", ".lan"];
const BLOCKED_HOSTNAMES = ["localhost", "localhost."];
/**
* Checks whether an IP address is in a private/reserved range.
*/
function isPrivateIP(ip: string): boolean {
// IPv4
if (ip === "0.0.0.0") return true;
if (ip.startsWith("127.")) return true; // 127.0.0.0/8
if (ip.startsWith("10.")) return true; // 10.0.0.0/8
if (ip.startsWith("192.168.")) return true; // 192.168.0.0/16
if (ip.startsWith("169.254.")) return true; // 169.254.0.0/16 (link-local + cloud metadata)
// 172.16.0.0/12: 172.16.x.x 172.31.x.x
if (ip.startsWith("172.")) {
const second = parseInt(ip.split(".")[1] ?? "", 10);
if (second >= 16 && second <= 31) return true;
}
// IPv6
if (ip === "::1" || ip === "::") return true;
if (ip.toLowerCase().startsWith("fe80")) return true; // fe80::/10
if (ip.toLowerCase().startsWith("fd00:ec2::254")) return true; // AWS EC2 metadata
if (ip.toLowerCase() === "::ffff:127.0.0.1") return true;
if (ip.toLowerCase().startsWith("::ffff:")) {
// IPv4-mapped IPv6 — extract the IPv4 part and re-check
const v4 = ip.slice(7);
return isPrivateIP(v4);
}
return false;
}
/**
* Validates a monitor URL is safe to fetch (not targeting internal resources).
* Returns null if safe, or an error string if blocked.
*/
export async function validateMonitorUrl(url: string): Promise<string | null> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return "Invalid URL";
}
// Only allow http and https
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return `Blocked scheme: ${parsed.protocol} — only http: and https: are allowed`;
}
const hostname = parsed.hostname.toLowerCase();
// Block localhost by name
if (BLOCKED_HOSTNAMES.includes(hostname)) {
return "Blocked hostname: localhost is not allowed";
}
// Block non-public TLDs
for (const tld of BLOCKED_TLDS) {
if (hostname.endsWith(tld)) {
return `Blocked TLD: ${tld} is not allowed`;
}
}
// Resolve DNS and check all IPs
try {
const ips: string[] = [];
try {
const v4 = await dns.resolve4(hostname);
ips.push(...v4);
} catch {}
try {
const v6 = await dns.resolve6(hostname);
ips.push(...v6);
} catch {}
if (ips.length === 0) {
return "Could not resolve hostname";
}
for (const ip of ips) {
if (isPrivateIP(ip)) {
return `Blocked: ${hostname} resolves to private/reserved IP ${ip}`;
}
}
} catch {
return "DNS resolution failed";
}
return null;
}