feat(ops): signed read-only relay status dashboard + HSTS/CAA hardening
This commit is contained in:
parent
1f27311903
commit
f71f5eeef8
5 changed files with 284 additions and 17 deletions
|
|
@ -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=""
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/diy/deck-cost" element={<DeckCostPage />} />
|
||||
<Route path="/diy/deck-designer" element={<DeckDesignerPage />} />
|
||||
<Route path="/review" element={<AdminReviewPage />} />
|
||||
<Route path="/ops" element={<OpsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/create-spot" element={<CreateSpotPage />} />
|
||||
<Route path="/create-shop" element={<CreateShopPage />} />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Link
|
||||
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="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-amber-400">{t('settingsUi.adminReview.title')}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{flaggedCount > 0
|
||||
? t('settingsUi.adminReview.flagged', { count: flaggedCount, s: flaggedCount === 1 ? '' : 's' })
|
||||
: t('settingsUi.adminReview.clean')}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
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="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-amber-400">{t('settingsUi.adminReview.title')}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{flaggedCount > 0
|
||||
? t('settingsUi.adminReview.flagged', { count: flaggedCount, s: flaggedCount === 1 ? '' : 's' })
|
||||
: t('settingsUi.adminReview.clean')}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
</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
171
src/pages/OpsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue