From f71f5eeef854daa14a08d82a5f225a488e4bfe9b Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 30 Oct 2026 18:40:32 +0700 Subject: [PATCH] feat(ops): signed read-only relay status dashboard + HSTS/CAA hardening --- scripts/host/relay-health.sh | 43 ++++++++ scripts/quota-proxy.mjs | 36 ++++++ src/AppRouter.tsx | 2 + src/components/AdminReviewCard.tsx | 49 ++++++--- src/pages/OpsPage.tsx | 171 +++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 17 deletions(-) create mode 100644 src/pages/OpsPage.tsx diff --git a/scripts/host/relay-health.sh b/scripts/host/relay-health.sh index d292b48..9c783cd 100644 --- a/scripts/host/relay-health.sh +++ b/scripts/host/relay-health.sh @@ -78,6 +78,49 @@ else ISSUES+=("could not read relay TLS certificate") fi +# Machine-readable snapshot for the signed admin dashboard. Aggregate numbers +# only: no IPs, pubkeys, paths or secrets. +local_relay=$(code -H 'Accept: application/nostr+json' http://127.0.0.1:7777/) +public_relay=$(code -H 'Accept: application/nostr+json' https://relay.skate-spots.org/) +push_status=$(code https://push.skate-spots.org/health) +quota_status=$(code http://127.0.0.1:8790/health) +version=$(curl -s -m 8 -H 'Accept: application/nostr+json' https://relay.skate-spots.org/ \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("version",""))' 2>/dev/null || true) +svc_on() { systemctl is-active --quiet "$1" && echo true || echo false; } +STATUS_DISK="${disk:-0}" STATUS_AVAIL="${avail:-0}" STATUS_MEDIA="${total_bytes:-0}" \ +STATUS_CORDN_REJECTS="${cordn_rejects:-0}" STATUS_CORDN_ANNOUNCE="${cordn_announce:-0}" \ +STATUS_TLS_DAYS="${days:-0}" STATUS_LOCAL="${local_relay:-000}" STATUS_PUBLIC="${public_relay:-000}" \ +STATUS_PUSH="${push_status:-000}" STATUS_QUOTA="${quota_status:-000}" STATUS_VERSION="$version" \ +STATUS_SERVICES="$(printf 'relay=%s\nai=%s\npush=%s\ncvm=%s\nquota=%s\ncaddy=%s\n' \ + "$(svc_on skatespots-relay.service)" "$(svc_on skatespots-ai.service)" \ + "$(svc_on skatespots-push.service)" "$(svc_on skatespots-cvm.service)" \ + "$(svc_on skatespots-quota.service)" "$(svc_on caddy.service)")" \ +STATUS_ISSUES="$(printf '%s\n' "${ISSUES[@]}")" \ +python3 - <<'PY' +import datetime, json, os +services = {} +for line in os.environ.get('STATUS_SERVICES','').splitlines(): + if '=' in line: + k,v = line.split('=',1); services[k] = v == 'true' +issues = [x for x in os.environ.get('STATUS_ISSUES','').splitlines() if x] +payload = { + 'updatedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(), + 'relay': {'local': os.environ.get('STATUS_LOCAL'), 'public': os.environ.get('STATUS_PUBLIC')}, + 'version': os.environ.get('STATUS_VERSION',''), + 'cordn': {'announces20m': int(os.environ.get('STATUS_CORDN_ANNOUNCE','0') or 0), + 'rejects15m': int(os.environ.get('STATUS_CORDN_REJECTS','0') or 0)}, + 'storage': {'diskPct': int(os.environ.get('STATUS_DISK','0') or 0), + 'memAvailMiB': int(os.environ.get('STATUS_AVAIL','0') or 0), + 'mediaBytes': int(os.environ.get('STATUS_MEDIA','0') or 0)}, + 'tls': {'days': int(os.environ.get('STATUS_TLS_DAYS','0') or 0)}, + 'services': services, + 'issues': issues, +} +path = '/home/ubuntu/skatespots-quota/status.json' +with open(path,'w') as f: json.dump(payload,f) +os.chmod(path, 0o644) +PY + sig="" [ "${#ISSUES[@]}" -gt 0 ] && sig=$(printf '%s\n' "${ISSUES[@]}" | sort | tr '\n' '|') prev="" diff --git a/scripts/quota-proxy.mjs b/scripts/quota-proxy.mjs index d83b3d9..a7f008f 100644 --- a/scripts/quota-proxy.mjs +++ b/scripts/quota-proxy.mjs @@ -46,6 +46,7 @@ const IP_HOURLY_BYTES = Number(process.env.IP_HOURLY_BYTES || 500 * 1024 * 1024) const IP_HOURLY_UPLOADS = Number(process.env.IP_HOURLY_UPLOADS || 100); const TOTAL_BYTES = Number(process.env.TOTAL_BYTES || 20 * 1024 * 1024 * 1024); const STATE_PATH = process.env.QUOTA_STATE || '/home/ubuntu/skatespots-quota/usage.json'; +const STATUS_PATH = process.env.STATUS_PATH || '/home/ubuntu/skatespots-quota/status.json'; const ADMIN_PUBKEYS = new Set( String(process.env.ADMIN_PUBKEYS || '') .split(',').map((v) => v.trim().toLowerCase()).filter(Boolean), @@ -163,8 +164,43 @@ function forward(req, res, body) { upstream.on('error', () => json(res, 502, { error: 'Media relay unavailable.' })); if (body) upstream.end(body); else req.pipe(upstream); } +function opsAuth(req) { + const event = decodeToken(req.headers.authorization); + if (!event || event.kind !== 27235 || !verifyEvent(event)) return false; + if (!ADMIN_PUBKEYS.has(String(event.pubkey).toLowerCase())) return false; + if (tag(event, 'method') !== 'GET') return false; + if (tag(event, 'u') !== 'https://relay.skate-spots.org/ops') return false; + const exp = Number(tag(event, 'expiration') || 0); + if (exp && exp < Math.floor(Date.now() / 1000)) return false; + return true; +} + const server = http.createServer(async (req, res) => { if (req.url === '/health') { json(res, 200, { ok: true }); return; } + if (req.url === '/ops') { + if (req.method === 'OPTIONS') { + res.writeHead(204, { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, OPTIONS', + 'access-control-allow-headers': 'Authorization, Content-Type', + }); + res.end(); + return; + } + if (req.method !== 'GET' || !opsAuth(req)) { json(res, 401, { error: 'Admin authorization required.' }); return; } + try { + const raw = readFileSync(STATUS_PATH, 'utf8'); + res.writeHead(200, { + 'content-type': 'application/json', + 'cache-control': 'no-store', + 'access-control-allow-origin': '*', + }); + res.end(raw); + } catch { + json(res, 503, { error: 'Status snapshot unavailable.' }); + } + return; + } if (req.method !== 'PUT' || req.url !== '/upload') { forward(req, res); return; } const contentType = String(req.headers['content-type'] || '').toLowerCase().split(';')[0]; diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 0aacf5d..24fa98b 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -23,6 +23,7 @@ const DiyPage = lazyWithRetry(() => import("./pages/DiyPage")); const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage")); const DeckDesignerPage = lazyWithRetry(() => import("./pages/DeckDesignerPage")); const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage")); +const OpsPage = lazyWithRetry(() => import("./pages/OpsPage")); const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage")); const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage")); const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage")); @@ -159,6 +160,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/AdminReviewCard.tsx b/src/components/AdminReviewCard.tsx index e10f4c6..f8d159e 100644 --- a/src/components/AdminReviewCard.tsx +++ b/src/components/AdminReviewCard.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom'; import { useMemo } from 'react'; -import { ShieldAlert, ArrowRight } from 'lucide-react'; +import { ShieldAlert, ArrowRight, Activity } from 'lucide-react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSkateSpots } from '@/hooks/useSkateSpots'; import { isAdminPubkey } from '@/lib/admins'; @@ -24,22 +24,37 @@ export function AdminReviewCard() { if (!isAdmin) return null; return ( - -
- -
-

{t('settingsUi.adminReview.title')}

-

- {flaggedCount > 0 - ? t('settingsUi.adminReview.flagged', { count: flaggedCount, s: flaggedCount === 1 ? '' : 's' }) - : t('settingsUi.adminReview.clean')} -

+
+ +
+ +
+

{t('settingsUi.adminReview.title')}

+

+ {flaggedCount > 0 + ? t('settingsUi.adminReview.flagged', { count: flaggedCount, s: flaggedCount === 1 ? '' : 's' }) + : t('settingsUi.adminReview.clean')} +

+
+
- -
- + + +
+ +
+

Operator status

+

Read-only relay, Cordn, storage and TLS health.

+
+ +
+ +
); } diff --git a/src/pages/OpsPage.tsx b/src/pages/OpsPage.tsx new file mode 100644 index 0000000..c6612b0 --- /dev/null +++ b/src/pages/OpsPage.tsx @@ -0,0 +1,171 @@ +import { useCallback, useState } from 'react'; +import { useSeoMeta } from '@unhead/react'; +import { Activity, AlertTriangle, CheckCircle2, Loader2, RefreshCw, Server, ShieldAlert } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { isAdminPubkey } from '@/lib/admins'; +import { useTranslation } from '@/contexts/language'; + +const OPS_URL = 'https://relay.skate-spots.org/ops'; + +interface OpsStatus { + updatedAt: string; + relay: { local: string; public: string }; + version: string; + cordn: { announces20m: number; rejects15m: number }; + storage: { diskPct: number; memAvailMiB: number; mediaBytes: number }; + tls: { days: number }; + services: Record; + issues: string[]; +} + +function Metric({ label, value, good = true }: { label: string; value: string; good?: boolean }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +/** + * Admin-only, read-only relay operator snapshot. The endpoint requires a + * NIP-98 signature from one of the two relay admin pubkeys; this page never + * exposes a restart/config action. + * + * Labels are intentionally English-only: this is an internal operator tool, + * not a public product surface. + */ +export default function OpsPage() { + const { user } = useCurrentUser(); + const { t } = useTranslation(); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + useSeoMeta({ + title: 'Operator status — SkateSpots', + description: 'Private relay health snapshot.', + }); + + const load = useCallback(async () => { + if (!user) return; + setLoading(true); + setError(null); + try { + const event = await user.signer.signEvent({ + kind: 27235, + content: '', + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['u', OPS_URL], + ['method', 'GET'], + ['expiration', String(Math.floor(Date.now() / 1000) + 120)], + ], + }); + const response = await fetch(OPS_URL, { + headers: { authorization: `Nostr ${btoa(JSON.stringify(event))}` }, + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`); + setStatus(body as OpsStatus); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [user]); + + if (!isAdminPubkey(user?.pubkey)) { + return ( +
+ +

Admins only

+

This page is restricted to the two relay admin npubs.

+
+ ); + } + + return ( +
+
+
+
+ + Private operator surface +
+

Relay status

+

Read-only snapshot, signed by your admin npub.

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {!status ? ( + + + +

Load the signed snapshot to inspect the relay.

+

With Amber this requires one approval.

+
+
+ ) : ( + <> +
+ + {status.issues.length === 0 ? ( + <> All checks passing + ) : ( + <> {status.issues.length} issue(s) + )} + + Updated {new Date(status.updatedAt).toLocaleString()} +
+ +
+ + + + 300} /> + 14} /> + + 0} /> + +
+ + + Services + + {Object.entries(status.services).map(([name, up]) => ( +
+ + {name} +
+ ))} +
+
+ + {status.issues.length > 0 && ( + + Issues + + {status.issues.map((issue) =>

• {issue}

)} +
+
+ )} + + )} + +

{t('settingsUi.settingsPage.advanced')} · read-only

+
+ ); +}