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:
+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