feat(ops): signed read-only relay status dashboard + HSTS/CAA hardening

This commit is contained in:
SkateSpots 2026-10-30 18:40:32 +07:00
parent 1f27311903
commit f71f5eeef8
5 changed files with 284 additions and 17 deletions

View file

@ -78,6 +78,49 @@ else
ISSUES+=("could not read relay TLS certificate") ISSUES+=("could not read relay TLS certificate")
fi 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="" sig=""
[ "${#ISSUES[@]}" -gt 0 ] && sig=$(printf '%s\n' "${ISSUES[@]}" | sort | tr '\n' '|') [ "${#ISSUES[@]}" -gt 0 ] && sig=$(printf '%s\n' "${ISSUES[@]}" | sort | tr '\n' '|')
prev="" prev=""

View file

@ -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 IP_HOURLY_UPLOADS = Number(process.env.IP_HOURLY_UPLOADS || 100);
const TOTAL_BYTES = Number(process.env.TOTAL_BYTES || 20 * 1024 * 1024 * 1024); 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 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( const ADMIN_PUBKEYS = new Set(
String(process.env.ADMIN_PUBKEYS || '') String(process.env.ADMIN_PUBKEYS || '')
.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean), .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.' })); upstream.on('error', () => json(res, 502, { error: 'Media relay unavailable.' }));
if (body) upstream.end(body); else req.pipe(upstream); 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) => { const server = http.createServer(async (req, res) => {
if (req.url === '/health') { json(res, 200, { ok: true }); return; } 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; } if (req.method !== 'PUT' || req.url !== '/upload') { forward(req, res); return; }
const contentType = String(req.headers['content-type'] || '').toLowerCase().split(';')[0]; const contentType = String(req.headers['content-type'] || '').toLowerCase().split(';')[0];

View file

@ -23,6 +23,7 @@ const DiyPage = lazyWithRetry(() => import("./pages/DiyPage"));
const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage")); const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage"));
const DeckDesignerPage = lazyWithRetry(() => import("./pages/DeckDesignerPage")); const DeckDesignerPage = lazyWithRetry(() => import("./pages/DeckDesignerPage"));
const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage")); const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
const OpsPage = lazyWithRetry(() => import("./pages/OpsPage"));
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage")); const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage")); const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage")); const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage"));
@ -159,6 +160,7 @@ export function AppRouter() {
<Route path="/diy/deck-cost" element={<DeckCostPage />} /> <Route path="/diy/deck-cost" element={<DeckCostPage />} />
<Route path="/diy/deck-designer" element={<DeckDesignerPage />} /> <Route path="/diy/deck-designer" element={<DeckDesignerPage />} />
<Route path="/review" element={<AdminReviewPage />} /> <Route path="/review" element={<AdminReviewPage />} />
<Route path="/ops" element={<OpsPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/create-spot" element={<CreateSpotPage />} /> <Route path="/create-spot" element={<CreateSpotPage />} />
<Route path="/create-shop" element={<CreateShopPage />} /> <Route path="/create-shop" element={<CreateShopPage />} />

View file

@ -1,6 +1,6 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { ShieldAlert, ArrowRight } from 'lucide-react'; import { ShieldAlert, ArrowRight, Activity } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useSkateSpots } from '@/hooks/useSkateSpots';
import { isAdminPubkey } from '@/lib/admins'; import { isAdminPubkey } from '@/lib/admins';
@ -24,22 +24,37 @@ export function AdminReviewCard() {
if (!isAdmin) return null; if (!isAdmin) return null;
return ( return (
<Link <div className="space-y-2">
to="/review" <Link
className="block rounded-2xl border border-amber-500/20 bg-white/[0.02] transition-colors hover:border-amber-500/40" to="/review"
> className="block rounded-2xl border border-amber-500/20 bg-white/[0.02] transition-colors hover:border-amber-500/40"
<div className="flex items-center gap-3 px-4 py-3.5"> >
<ShieldAlert className="h-5 w-5 shrink-0 text-amber-400" /> <div className="flex items-center gap-3 px-4 py-3.5">
<div className="min-w-0 flex-1"> <ShieldAlert className="h-5 w-5 shrink-0 text-amber-400" />
<p className="text-sm font-bold text-amber-400">{t('settingsUi.adminReview.title')}</p> <div className="min-w-0 flex-1">
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="text-sm font-bold text-amber-400">{t('settingsUi.adminReview.title')}</p>
{flaggedCount > 0 <p className="mt-0.5 text-xs text-muted-foreground">
? t('settingsUi.adminReview.flagged', { count: flaggedCount, s: flaggedCount === 1 ? '' : 's' }) {flaggedCount > 0
: t('settingsUi.adminReview.clean')} ? t('settingsUi.adminReview.flagged', { count: flaggedCount, s: flaggedCount === 1 ? '' : 's' })
</p> : t('settingsUi.adminReview.clean')}
</p>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
</div> </div>
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" /> </Link>
</div> <Link
</Link> to="/ops"
className="block rounded-2xl border border-cyan-500/20 bg-white/[0.02] transition-colors hover:border-cyan-500/40"
>
<div className="flex items-center gap-3 px-4 py-3.5">
<Activity className="h-5 w-5 shrink-0 text-cyan-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-cyan-400">Operator status</p>
<p className="mt-0.5 text-xs text-muted-foreground">Read-only relay, Cordn, storage and TLS health.</p>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
</div>
</Link>
</div>
); );
} }

171
src/pages/OpsPage.tsx Normal file
View file

@ -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<string, boolean>;
issues: string[];
}
function Metric({ label, value, good = true }: { label: string; value: string; good?: boolean }) {
return (
<div className="rounded-xl border border-white/10 bg-black/40 p-3">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">{label}</p>
<p className={`mt-1 text-lg font-black ${good ? 'text-white' : 'text-red-400'}`}>{value}</p>
</div>
);
}
/**
* 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<OpsStatus | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="container max-w-lg px-4 py-16 text-center">
<ShieldAlert className="mx-auto mb-3 h-10 w-10 text-red-400/60" />
<h1 className="text-xl font-black uppercase text-white">Admins only</h1>
<p className="mt-2 text-sm text-gray-500">This page is restricted to the two relay admin npubs.</p>
</div>
);
}
return (
<div className="container max-w-3xl space-y-4 px-4 py-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-[#ccff00]">
<Activity className="h-4 w-4" />
<span className="text-[10px] font-black uppercase tracking-widest">Private operator surface</span>
</div>
<h1 className="mt-1 text-3xl font-black uppercase leading-none text-white">Relay status</h1>
<p className="mt-1 text-xs text-gray-500">Read-only snapshot, signed by your admin npub.</p>
</div>
<Button size="sm" variant="outline" onClick={() => void load()} disabled={loading} className="gap-2">
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
Load status
</Button>
</div>
{error && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-300">
<AlertTriangle className="mr-1 inline h-3.5 w-3.5" /> {error}
</div>
)}
{!status ? (
<Card className="border-white/10 bg-black/50">
<CardContent className="space-y-2 py-10 text-center">
<Server className="mx-auto h-10 w-10 text-gray-600" />
<p className="text-sm text-gray-400">Load the signed snapshot to inspect the relay.</p>
<p className="text-[11px] text-gray-600">With Amber this requires one approval.</p>
</CardContent>
</Card>
) : (
<>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 text-xs text-gray-500">
{status.issues.length === 0 ? (
<><CheckCircle2 className="h-3.5 w-3.5 text-green-400" /> All checks passing</>
) : (
<><AlertTriangle className="h-3.5 w-3.5 text-amber-400" /> {status.issues.length} issue(s)</>
)}
</span>
<span className="text-[10px] text-gray-600">Updated {new Date(status.updatedAt).toLocaleString()}</span>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<Metric label="Relay" value={`${status.relay.public} / ${status.relay.local}`} good={status.relay.public === '200' && status.relay.local === '200'} />
<Metric label="Version" value={status.version || '—'} />
<Metric label="Disk" value={`${status.storage.diskPct}%`} good={status.storage.diskPct < 85} />
<Metric label="Memory free" value={`${status.storage.memAvailMiB} MiB`} good={status.storage.memAvailMiB > 300} />
<Metric label="TLS" value={`${status.tls.days}d`} good={status.tls.days > 14} />
<Metric label="Media" value={`${(status.storage.mediaBytes / 1024 / 1024).toFixed(1)} MiB`} />
<Metric label="Cordn announces" value={String(status.cordn.announces20m)} good={status.cordn.announces20m > 0} />
<Metric label="Cordn 429s / 15m" value={String(status.cordn.rejects15m)} good={status.cordn.rejects15m < 40} />
</div>
<Card className="border-white/10 bg-black/50">
<CardHeader className="pb-2"><CardTitle className="text-sm text-white">Services</CardTitle></CardHeader>
<CardContent className="grid grid-cols-2 gap-2 text-xs sm:grid-cols-3">
{Object.entries(status.services).map(([name, up]) => (
<div key={name} className="flex items-center gap-2 text-gray-400">
<span className={`h-1.5 w-1.5 rounded-full ${up ? 'bg-green-400' : 'bg-red-400'}`} />
{name}
</div>
))}
</CardContent>
</Card>
{status.issues.length > 0 && (
<Card className="border-amber-500/25 bg-amber-500/[0.04]">
<CardHeader className="pb-2"><CardTitle className="text-sm text-amber-300">Issues</CardTitle></CardHeader>
<CardContent className="space-y-1 text-xs text-amber-200/80">
{status.issues.map((issue) => <p key={issue}>• {issue}</p>)}
</CardContent>
</Card>
)}
</>
)}
<p className="text-center text-[10px] text-gray-700">{t('settingsUi.settingsPage.advanced')} · read-only</p>
</div>
);
}