feat(admin): comprehensive admin interface (users, tunnels, metrics, audit, reserved subdomains)
Adds an authenticated admin surface gated by auth.users.app_metadata.role==='admin'. - lib/auth/admin-guard.ts: requireAdmin() (pages) + requireAdminApi() (routes) - middleware.ts: defense-in-depth /admin and /api/admin guarding - API: users (list/detail/role/ban/delete), tunnels (list + active/quota/reset/reassign/regenerate-token/delete), metrics, audit log, reserved subdomains - Self-lockout prevention (no self demote/ban/delete) - Best-effort Redis kill-switch via dependency-free net-socket client (REDIS_URL) - admin_audit_log + reserved_subdomains migration (RLS on, service-role only) - Admin UI (overview, users, tunnels, reserved, audit) + conditional nav link
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
||||
|
||||
export type AdminMetrics = {
|
||||
totalUsers: number;
|
||||
totalTunnels: number;
|
||||
activeTunnels: number;
|
||||
inactiveTunnels: number;
|
||||
overQuota: number;
|
||||
bytesUsedTotal: number;
|
||||
quotaTotal: number;
|
||||
signups7d: number;
|
||||
signups30d: number;
|
||||
recentlyActive: number;
|
||||
};
|
||||
|
||||
type TunnelAgg = {
|
||||
is_active: boolean;
|
||||
bytes_used: number;
|
||||
quota_bytes: number;
|
||||
last_seen_at: string | null;
|
||||
};
|
||||
|
||||
export async function computeMetrics(): Promise<AdminMetrics> {
|
||||
const admin = getSupabaseAdmin();
|
||||
|
||||
const { data: tunnelsData } = await admin
|
||||
.from('tunnels')
|
||||
.select('is_active, bytes_used, quota_bytes, last_seen_at');
|
||||
const tunnels = (tunnelsData ?? []) as TunnelAgg[];
|
||||
|
||||
const now = Date.now();
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
let activeTunnels = 0;
|
||||
let inactiveTunnels = 0;
|
||||
let overQuota = 0;
|
||||
let bytesUsedTotal = 0;
|
||||
let quotaTotal = 0;
|
||||
let recentlyActive = 0;
|
||||
for (const t of tunnels) {
|
||||
if (t.is_active) activeTunnels++;
|
||||
else inactiveTunnels++;
|
||||
if (t.quota_bytes > 0 && t.bytes_used >= t.quota_bytes) overQuota++;
|
||||
bytesUsedTotal += Number(t.bytes_used) || 0;
|
||||
quotaTotal += Number(t.quota_bytes) || 0;
|
||||
if (t.last_seen_at && now - new Date(t.last_seen_at).getTime() <= day) {
|
||||
recentlyActive++;
|
||||
}
|
||||
}
|
||||
|
||||
let totalUsers = 0;
|
||||
let signups7d = 0;
|
||||
let signups30d = 0;
|
||||
const perPage = 1000;
|
||||
for (let page = 1; page <= 50; page++) {
|
||||
const { data, error } = await admin.auth.admin.listUsers({ page, perPage });
|
||||
if (error) break;
|
||||
const users = data.users;
|
||||
if (users.length === 0) break;
|
||||
totalUsers += users.length;
|
||||
for (const u of users) {
|
||||
const created = new Date(u.created_at).getTime();
|
||||
if (now - created <= 7 * day) signups7d++;
|
||||
if (now - created <= 30 * day) signups30d++;
|
||||
}
|
||||
if (users.length < perPage) break;
|
||||
}
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
totalTunnels: tunnels.length,
|
||||
activeTunnels,
|
||||
inactiveTunnels,
|
||||
overQuota,
|
||||
bytesUsedTotal,
|
||||
quotaTotal,
|
||||
signups7d,
|
||||
signups30d,
|
||||
recentlyActive,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
||||
import { RESERVED_SUBDOMAINS } from '@/lib/validation';
|
||||
|
||||
/**
|
||||
* Returns true if the subdomain is reserved in EITHER the hardcoded fallback
|
||||
* set or the reserved_subdomains DB table.
|
||||
*/
|
||||
export async function isSubdomainReserved(subdomain: string): Promise<boolean> {
|
||||
if (RESERVED_SUBDOMAINS.has(subdomain)) return true;
|
||||
const admin = getSupabaseAdmin();
|
||||
const { data } = await admin
|
||||
.from('reserved_subdomains')
|
||||
.select('name')
|
||||
.eq('name', subdomain)
|
||||
.maybeSingle<{ name: string }>();
|
||||
return !!data;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Small manual validators for admin API inputs (zod is intentionally not a
|
||||
* dependency). Each returns a discriminated result.
|
||||
*/
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isUuid(v: unknown): v is string {
|
||||
return typeof v === 'string' && UUID_RE.test(v);
|
||||
}
|
||||
|
||||
export function parseBoolean(v: unknown): boolean | null {
|
||||
if (typeof v === 'boolean') return v;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parsePositiveInt(
|
||||
v: unknown,
|
||||
max: number,
|
||||
): { ok: true; value: number } | { ok: false; error: string } {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v) || !Number.isInteger(v)) {
|
||||
return { ok: false, error: 'must be an integer' };
|
||||
}
|
||||
if (v <= 0) return { ok: false, error: 'must be positive' };
|
||||
if (v > max) return { ok: false, error: `must be <= ${max}` };
|
||||
return { ok: true, value: v };
|
||||
}
|
||||
|
||||
export function parsePageParam(v: string | null, fallback = 1): number {
|
||||
const n = v ? Number(v) : NaN;
|
||||
if (!Number.isFinite(n) || n < 1) return fallback;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
export function parsePerPageParam(
|
||||
v: string | null,
|
||||
fallback = 25,
|
||||
max = 100,
|
||||
): number {
|
||||
const n = v ? Number(v) : NaN;
|
||||
if (!Number.isFinite(n) || n < 1) return fallback;
|
||||
return Math.min(max, Math.floor(n));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
import { createSupabaseServerClient } from '@/lib/supabase/server';
|
||||
|
||||
export function isAdmin(user: User | null | undefined): boolean {
|
||||
return user?.app_metadata?.role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* For server components / pages. Redirects to /login if unauthenticated,
|
||||
* or /dashboard if authenticated but not an admin (does not leak /admin).
|
||||
*/
|
||||
export async function requireAdmin(): Promise<User> {
|
||||
const supabase = createSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect('/login');
|
||||
if (!isAdmin(user)) redirect('/dashboard');
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* For route handlers. Returns the authenticated admin user, or a NextResponse
|
||||
* (401 when no session, 403 when not admin) that the caller must return.
|
||||
*/
|
||||
export async function requireAdminApi(): Promise<
|
||||
{ ok: true; user: User } | { ok: false; response: NextResponse }
|
||||
> {
|
||||
const supabase = createSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'unauthorized' }, { status: 401 }),
|
||||
};
|
||||
}
|
||||
if (!isAdmin(user)) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
|
||||
};
|
||||
}
|
||||
return { ok: true, user };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
||||
|
||||
export type AuditEntry = {
|
||||
action: string;
|
||||
target_type?: string | null;
|
||||
target_id?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort audit logging. Never throws — a failed audit write must not
|
||||
* break the underlying admin operation.
|
||||
*/
|
||||
export async function logAdminAction(
|
||||
actor: User,
|
||||
entry: AuditEntry,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const admin = getSupabaseAdmin();
|
||||
await admin.from('admin_audit_log').insert({
|
||||
actor_id: actor.id,
|
||||
actor_email: actor.email ?? null,
|
||||
action: entry.action,
|
||||
target_type: entry.target_type ?? null,
|
||||
target_id: entry.target_id ?? null,
|
||||
details: entry.details ?? {},
|
||||
});
|
||||
} catch (e) {
|
||||
// Swallow — audit failures should not surface to the client.
|
||||
console.error('[audit] failed to write audit log', e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function formatBytes(n: number): string {
|
||||
const num = Number(n) || 0;
|
||||
if (num < 1024) return `${num} B`;
|
||||
const units = ['KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
||||
let v = num / 1024;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(2)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatDate(s: string | null | undefined): string {
|
||||
if (!s) return '—';
|
||||
const d = new Date(s);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
return d.toLocaleString();
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import net from 'node:net';
|
||||
|
||||
/**
|
||||
* Minimal, dependency-free Redis client implementing just enough of the RESP
|
||||
* protocol to issue a single SET command. Used for the best-effort live
|
||||
* kill-switch (tunnel:active:<subdomain>). Every failure mode is swallowed —
|
||||
* Redis being unavailable must NEVER break an admin operation.
|
||||
*
|
||||
* REDIS_URL format: redis://[:password@]host:port[/db]
|
||||
*/
|
||||
|
||||
type RedisTarget = {
|
||||
host: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
db?: number;
|
||||
};
|
||||
|
||||
function parseRedisUrl(raw: string): RedisTarget | null {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
if (u.protocol !== 'redis:' && u.protocol !== 'rediss:') return null;
|
||||
const host = u.hostname || '127.0.0.1';
|
||||
const port = u.port ? Number(u.port) : 6379;
|
||||
const password = u.password ? decodeURIComponent(u.password) : undefined;
|
||||
const dbStr = u.pathname.replace(/^\//, '');
|
||||
const db = dbStr ? Number(dbStr) : undefined;
|
||||
if (!Number.isFinite(port)) return null;
|
||||
return { host, port, password, db: Number.isFinite(db) ? db : undefined };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCommand(args: string[]): string {
|
||||
let out = `*${args.length}\r\n`;
|
||||
for (const a of args) {
|
||||
out += `$${Buffer.byteLength(a)}\r\n${a}\r\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort SET. Resolves true on apparent success, false otherwise.
|
||||
* Never rejects.
|
||||
*/
|
||||
export function redisSet(key: string, value: string): Promise<boolean> {
|
||||
const url = process.env.REDIS_URL;
|
||||
if (!url) return Promise.resolve(false);
|
||||
const target = parseRedisUrl(url);
|
||||
if (!target) return Promise.resolve(false);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const done = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const commands: string[] = [];
|
||||
if (target.password) commands.push(encodeCommand(['AUTH', target.password]));
|
||||
if (target.db !== undefined && target.db > 0) {
|
||||
commands.push(encodeCommand(['SELECT', String(target.db)]));
|
||||
}
|
||||
commands.push(encodeCommand(['SET', key, value]));
|
||||
|
||||
const socket = net.createConnection(
|
||||
{ host: target.host, port: target.port },
|
||||
() => {
|
||||
try {
|
||||
socket.write(commands.join(''));
|
||||
} catch {
|
||||
done(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
socket.setTimeout(1500, () => done(false));
|
||||
socket.on('error', () => done(false));
|
||||
|
||||
let buf = '';
|
||||
let expectedReplies = commands.length;
|
||||
socket.on('data', (chunk) => {
|
||||
buf += chunk.toString('utf8');
|
||||
// Count complete simple replies (lines terminated by \r\n).
|
||||
const lines = buf.split('\r\n').filter((l) => l.length > 0);
|
||||
if (lines.length >= expectedReplies) {
|
||||
const last = lines[lines.length - 1];
|
||||
// +OK for SET success.
|
||||
done(last.startsWith('+OK') || last.startsWith('+'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user