feat: add admin REST API layer for frontend management panel
Implement JWT-protected /admin/* routes on the relay server: - Auth: login, logout, me, setup/status, setup/init (first-time config) - Dashboard: stats overview (connections, devices, cookies, domains) - Cookies: paginated list with domain/search filter, detail, delete, batch delete - Devices: list with online status, revoke - Settings: get/update (sync interval, max devices, theme) Uses scrypt for password hashing and jsonwebtoken for JWT. Adds listAll/revoke to DeviceRegistry, getAll/getById/deleteById to CookieBlobStore, disconnect to ConnectionManager. Updates frontend to use /admin/* endpoints. All 38 existing tests pass. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
102
src/relay/admin/auth.ts
Normal file
102
src/relay/admin/auth.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import crypto from "node:crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
const SCRYPT_KEYLEN = 64;
|
||||
const SCRYPT_COST = 16384;
|
||||
const SCRYPT_BLOCK_SIZE = 8;
|
||||
const SCRYPT_PARALLELISM = 1;
|
||||
|
||||
export interface AdminUser {
|
||||
username: string;
|
||||
passwordHash: string; // scrypt hash, hex
|
||||
salt: string; // hex
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
syncIntervalMs: number;
|
||||
maxDevices: number;
|
||||
autoSync: boolean;
|
||||
theme: "light" | "dark" | "system";
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AdminSettings = {
|
||||
syncIntervalMs: 30_000,
|
||||
maxDevices: 10,
|
||||
autoSync: true,
|
||||
theme: "system",
|
||||
};
|
||||
|
||||
/**
|
||||
* In-memory admin state. Stores admin user, JWT secret, and settings.
|
||||
* In production this would be persisted to disk/database.
|
||||
*/
|
||||
export class AdminStore {
|
||||
private adminUser: AdminUser | null = null;
|
||||
private jwtSecret: string;
|
||||
private settings: AdminSettings = { ...DEFAULT_SETTINGS };
|
||||
|
||||
constructor() {
|
||||
this.jwtSecret = crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
get isSetUp(): boolean {
|
||||
return this.adminUser !== null;
|
||||
}
|
||||
|
||||
/** First-time setup: create the admin account. */
|
||||
async setup(username: string, password: string): Promise<void> {
|
||||
if (this.adminUser) throw new Error("Already configured");
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = await this.hashPassword(password, salt);
|
||||
this.adminUser = {
|
||||
username,
|
||||
passwordHash: hash,
|
||||
salt,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Authenticate and return a JWT. */
|
||||
async login(username: string, password: string): Promise<string> {
|
||||
if (!this.adminUser) throw new Error("Not configured");
|
||||
if (this.adminUser.username !== username) throw new Error("Invalid credentials");
|
||||
const hash = await this.hashPassword(password, this.adminUser.salt);
|
||||
if (hash !== this.adminUser.passwordHash) throw new Error("Invalid credentials");
|
||||
return jwt.sign({ sub: username, role: "admin" }, this.jwtSecret, { expiresIn: "24h" });
|
||||
}
|
||||
|
||||
/** Verify a JWT and return the payload. */
|
||||
verifyToken(token: string): { sub: string; role: string } {
|
||||
return jwt.verify(token, this.jwtSecret) as { sub: string; role: string };
|
||||
}
|
||||
|
||||
getUser(): { username: string; createdAt: string } | null {
|
||||
if (!this.adminUser) return null;
|
||||
return { username: this.adminUser.username, createdAt: this.adminUser.createdAt };
|
||||
}
|
||||
|
||||
getSettings(): AdminSettings {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
updateSettings(patch: Partial<AdminSettings>): AdminSettings {
|
||||
Object.assign(this.settings, patch);
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
private hashPassword(password: string, salt: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(
|
||||
password,
|
||||
Buffer.from(salt, "hex"),
|
||||
SCRYPT_KEYLEN,
|
||||
{ N: SCRYPT_COST, r: SCRYPT_BLOCK_SIZE, p: SCRYPT_PARALLELISM },
|
||||
(err, derived) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derived.toString("hex"));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
328
src/relay/admin/routes.ts
Normal file
328
src/relay/admin/routes.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import http from "node:http";
|
||||
import type { AdminStore } from "./auth.js";
|
||||
import type { ConnectionManager } from "../connections.js";
|
||||
import type { CookieBlobStore } from "../store.js";
|
||||
import type { DeviceRegistry } from "../tokens.js";
|
||||
|
||||
export interface AdminDeps {
|
||||
adminStore: AdminStore;
|
||||
connections: ConnectionManager;
|
||||
cookieStore: CookieBlobStore;
|
||||
deviceRegistry: DeviceRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /admin/* routes. Returns true if the route was handled.
|
||||
*/
|
||||
export function handleAdminRoute(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): boolean {
|
||||
const url = req.url ?? "";
|
||||
const method = req.method ?? "";
|
||||
|
||||
if (!url.startsWith("/admin/")) return false;
|
||||
|
||||
// --- Public routes (no auth) ---
|
||||
|
||||
if (method === "GET" && url === "/admin/setup/status") {
|
||||
json(res, 200, { isSetUp: deps.adminStore.isSetUp });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method === "POST" && url === "/admin/setup/init") {
|
||||
handleSetupInit(req, res, deps);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method === "POST" && url === "/admin/auth/login") {
|
||||
handleLogin(req, res, deps);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Protected routes ---
|
||||
|
||||
const user = authenticate(req, deps.adminStore);
|
||||
if (!user) {
|
||||
json(res, 401, { error: "Unauthorized" });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method === "POST" && url === "/admin/auth/logout") {
|
||||
json(res, 200, { ok: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method === "GET" && url === "/admin/auth/me") {
|
||||
const info = deps.adminStore.getUser();
|
||||
json(res, 200, info);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method === "GET" && url === "/admin/dashboard") {
|
||||
handleDashboard(res, deps);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cookie management
|
||||
if (method === "GET" && url.startsWith("/admin/cookies")) {
|
||||
handleCookieList(req, res, deps);
|
||||
return true;
|
||||
}
|
||||
if (method === "DELETE" && url.startsWith("/admin/cookies/")) {
|
||||
handleCookieDeleteById(req, res, deps);
|
||||
return true;
|
||||
}
|
||||
if (method === "DELETE" && url === "/admin/cookies") {
|
||||
handleCookieBatchDelete(req, res, deps);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Device management
|
||||
if (method === "GET" && url === "/admin/devices") {
|
||||
handleDeviceList(res, deps);
|
||||
return true;
|
||||
}
|
||||
if (method === "POST" && url.match(/^\/admin\/devices\/[^/]+\/revoke$/)) {
|
||||
handleDeviceRevoke(req, res, deps);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Settings
|
||||
if (method === "GET" && url === "/admin/settings") {
|
||||
json(res, 200, deps.adminStore.getSettings());
|
||||
return true;
|
||||
}
|
||||
if (method === "PATCH" && url === "/admin/settings") {
|
||||
handleSettingsUpdate(req, res, deps);
|
||||
return true;
|
||||
}
|
||||
|
||||
json(res, 404, { error: "Admin route not found" });
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Auth helpers ---
|
||||
|
||||
function authenticate(
|
||||
req: http.IncomingMessage,
|
||||
store: AdminStore,
|
||||
): { sub: string; role: string } | null {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth?.startsWith("Bearer ")) return null;
|
||||
try {
|
||||
return store.verifyToken(auth.slice(7));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Route handlers ---
|
||||
|
||||
function handleSetupInit(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
readBody(req, async (body) => {
|
||||
try {
|
||||
const { username, password } = JSON.parse(body);
|
||||
if (!username || !password) {
|
||||
json(res, 400, { error: "Missing username or password" });
|
||||
return;
|
||||
}
|
||||
if (deps.adminStore.isSetUp) {
|
||||
json(res, 409, { error: "Already configured" });
|
||||
return;
|
||||
}
|
||||
await deps.adminStore.setup(username, password);
|
||||
const token = await deps.adminStore.login(username, password);
|
||||
json(res, 201, { token, username });
|
||||
} catch {
|
||||
json(res, 400, { error: "Invalid request" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleLogin(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
readBody(req, async (body) => {
|
||||
try {
|
||||
const { username, password } = JSON.parse(body);
|
||||
if (!username || !password) {
|
||||
json(res, 400, { error: "Missing username or password" });
|
||||
return;
|
||||
}
|
||||
const token = await deps.adminStore.login(username, password);
|
||||
json(res, 200, { token });
|
||||
} catch {
|
||||
json(res, 401, { error: "Invalid credentials" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleDashboard(res: http.ServerResponse, deps: AdminDeps): void {
|
||||
const devices = deps.deviceRegistry.listAll();
|
||||
const onlineDeviceIds = devices
|
||||
.filter((d) => deps.connections.isOnline(d.deviceId))
|
||||
.map((d) => d.deviceId);
|
||||
|
||||
const allCookies = deps.cookieStore.getAll();
|
||||
|
||||
const domains = new Set(allCookies.map((c) => c.domain));
|
||||
|
||||
json(res, 200, {
|
||||
connections: deps.connections.connectedCount,
|
||||
totalDevices: devices.length,
|
||||
onlineDevices: onlineDeviceIds.length,
|
||||
totalCookies: allCookies.length,
|
||||
uniqueDomains: domains.size,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCookieList(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
const parsed = new URL(req.url ?? "", `http://${req.headers.host}`);
|
||||
|
||||
// Check if this is a single cookie detail request: /admin/cookies/:id
|
||||
const idMatch = parsed.pathname.match(/^\/admin\/cookies\/([a-f0-9]+)$/);
|
||||
if (idMatch) {
|
||||
const cookie = deps.cookieStore.getById(idMatch[1]);
|
||||
if (!cookie) {
|
||||
json(res, 404, { error: "Cookie not found" });
|
||||
return;
|
||||
}
|
||||
json(res, 200, cookie);
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = parsed.searchParams.get("domain") ?? undefined;
|
||||
const search = parsed.searchParams.get("q") ?? undefined;
|
||||
const page = parseInt(parsed.searchParams.get("page") ?? "1", 10);
|
||||
const limit = Math.min(parseInt(parsed.searchParams.get("limit") ?? "50", 10), 200);
|
||||
|
||||
let cookies = deps.cookieStore.getAll();
|
||||
|
||||
if (domain) {
|
||||
cookies = cookies.filter((c) => c.domain === domain);
|
||||
}
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
cookies = cookies.filter(
|
||||
(c) => c.domain.toLowerCase().includes(q) || c.cookieName.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
const total = cookies.length;
|
||||
const offset = (page - 1) * limit;
|
||||
const items = cookies.slice(offset, offset + limit);
|
||||
|
||||
json(res, 200, { items, total, page, limit });
|
||||
}
|
||||
|
||||
function handleCookieDeleteById(
|
||||
_req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
const parsed = new URL(_req.url ?? "", `http://${_req.headers.host}`);
|
||||
const idMatch = parsed.pathname.match(/^\/admin\/cookies\/([a-f0-9]+)$/);
|
||||
if (!idMatch) {
|
||||
json(res, 400, { error: "Invalid cookie ID" });
|
||||
return;
|
||||
}
|
||||
const deleted = deps.cookieStore.deleteById(idMatch[1]);
|
||||
json(res, 200, { deleted });
|
||||
}
|
||||
|
||||
function handleCookieBatchDelete(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
readBody(req, (body) => {
|
||||
try {
|
||||
const { ids } = JSON.parse(body) as { ids: string[] };
|
||||
if (!ids || !Array.isArray(ids)) {
|
||||
json(res, 400, { error: "Missing ids array" });
|
||||
return;
|
||||
}
|
||||
let count = 0;
|
||||
for (const id of ids) {
|
||||
if (deps.cookieStore.deleteById(id)) count++;
|
||||
}
|
||||
json(res, 200, { deleted: count });
|
||||
} catch {
|
||||
json(res, 400, { error: "Invalid JSON" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): void {
|
||||
const devices = deps.deviceRegistry.listAll().map((d) => ({
|
||||
deviceId: d.deviceId,
|
||||
name: d.name,
|
||||
platform: d.platform,
|
||||
createdAt: d.createdAt,
|
||||
online: deps.connections.isOnline(d.deviceId),
|
||||
}));
|
||||
json(res, 200, { devices });
|
||||
}
|
||||
|
||||
function handleDeviceRevoke(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
const parsed = new URL(req.url ?? "", `http://${req.headers.host}`);
|
||||
const match = parsed.pathname.match(/^\/admin\/devices\/([^/]+)\/revoke$/);
|
||||
if (!match) {
|
||||
json(res, 400, { error: "Invalid device ID" });
|
||||
return;
|
||||
}
|
||||
const deviceId = match[1];
|
||||
const revoked = deps.deviceRegistry.revoke(deviceId);
|
||||
if (revoked) {
|
||||
deps.connections.disconnect(deviceId);
|
||||
}
|
||||
json(res, 200, { revoked });
|
||||
}
|
||||
|
||||
function handleSettingsUpdate(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: AdminDeps,
|
||||
): void {
|
||||
readBody(req, (body) => {
|
||||
try {
|
||||
const patch = JSON.parse(body);
|
||||
const updated = deps.adminStore.updateSettings(patch);
|
||||
json(res, 200, updated);
|
||||
} catch {
|
||||
json(res, 400, { error: "Invalid JSON" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function json(res: http.ServerResponse, status: number, data: unknown): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function readBody(req: http.IncomingMessage, cb: (body: string) => void): void {
|
||||
let data = "";
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
if (data.length > 64 * 1024) req.destroy();
|
||||
});
|
||||
req.on("end", () => cb(data));
|
||||
}
|
||||
@@ -61,6 +61,15 @@ export class ConnectionManager {
|
||||
return conn !== undefined && conn.ws.readyState === 1;
|
||||
}
|
||||
|
||||
/** Forcibly disconnect a device. */
|
||||
disconnect(deviceId: string): void {
|
||||
const conn = this.connections.get(deviceId);
|
||||
if (conn) {
|
||||
conn.ws.close(4004, "Revoked");
|
||||
this.connections.delete(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get count of connected devices. */
|
||||
get connectedCount(): number {
|
||||
return this.connections.size;
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
MESSAGE_TYPES,
|
||||
PING_INTERVAL_MS,
|
||||
} from "../protocol/spec.js";
|
||||
import { AdminStore } from "./admin/auth.js";
|
||||
import { handleAdminRoute } from "./admin/routes.js";
|
||||
|
||||
export interface RelayServerConfig {
|
||||
port: number;
|
||||
@@ -50,6 +52,7 @@ export class RelayServer {
|
||||
readonly cookieStore: CookieBlobStore;
|
||||
readonly deviceRegistry: DeviceRegistry;
|
||||
readonly agentRegistry: AgentRegistry;
|
||||
readonly adminStore: AdminStore;
|
||||
private pendingAuths = new Map<WebSocket, PendingAuth>();
|
||||
private authenticatedDevices = new Map<WebSocket, string>();
|
||||
private pingIntervals = new Map<WebSocket, ReturnType<typeof setInterval>>();
|
||||
@@ -60,6 +63,7 @@ export class RelayServer {
|
||||
this.cookieStore = new CookieBlobStore();
|
||||
this.deviceRegistry = new DeviceRegistry();
|
||||
this.agentRegistry = new AgentRegistry();
|
||||
this.adminStore = new AdminStore();
|
||||
|
||||
this.httpServer = http.createServer(this.handleHttp.bind(this));
|
||||
this.wss = new WebSocketServer({ server: this.httpServer });
|
||||
@@ -99,6 +103,17 @@ export class RelayServer {
|
||||
const url = req.url ?? "";
|
||||
const method = req.method ?? "";
|
||||
|
||||
// Admin routes
|
||||
if (url.startsWith("/admin/")) {
|
||||
handleAdminRoute(req, res, {
|
||||
adminStore: this.adminStore,
|
||||
connections: this.connections,
|
||||
cookieStore: this.cookieStore,
|
||||
deviceRegistry: this.deviceRegistry,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Health
|
||||
if (method === "GET" && url === "/health") {
|
||||
this.json(res, 200, { status: "ok", connections: this.connections.connectedCount });
|
||||
|
||||
@@ -80,6 +80,38 @@ export class CookieBlobStore {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get all stored blobs across all devices. */
|
||||
getAll(): EncryptedCookieBlob[] {
|
||||
const result: EncryptedCookieBlob[] = [];
|
||||
for (const deviceMap of this.store.values()) {
|
||||
result.push(...deviceMap.values());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get a single blob by its ID. */
|
||||
getById(id: string): EncryptedCookieBlob | null {
|
||||
for (const deviceMap of this.store.values()) {
|
||||
for (const blob of deviceMap.values()) {
|
||||
if (blob.id === id) return blob;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Delete a blob by its ID. Returns true if found and deleted. */
|
||||
deleteById(id: string): boolean {
|
||||
for (const deviceMap of this.store.values()) {
|
||||
for (const [key, blob] of deviceMap) {
|
||||
if (blob.id === id) {
|
||||
deviceMap.delete(key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Get all blobs updated after a given timestamp (for polling). */
|
||||
getUpdatedSince(deviceIds: string[], since: string): EncryptedCookieBlob[] {
|
||||
const result: EncryptedCookieBlob[] = [];
|
||||
|
||||
@@ -72,6 +72,28 @@ export class DeviceRegistry {
|
||||
const paired = this.getPairedDevices(deviceId);
|
||||
return [deviceId, ...paired];
|
||||
}
|
||||
|
||||
/** List all registered devices. */
|
||||
listAll(): DeviceInfo[] {
|
||||
return Array.from(this.devices.values());
|
||||
}
|
||||
|
||||
/** Revoke a device: remove its token and registration. Returns true if it existed. */
|
||||
revoke(deviceId: string): boolean {
|
||||
const device = this.devices.get(deviceId);
|
||||
if (!device) return false;
|
||||
this.tokenToDevice.delete(device.token);
|
||||
this.devices.delete(deviceId);
|
||||
// Clean up pairings
|
||||
const paired = this.pairings.get(deviceId);
|
||||
if (paired) {
|
||||
for (const peerId of paired) {
|
||||
this.pairings.get(peerId)?.delete(deviceId);
|
||||
}
|
||||
this.pairings.delete(deviceId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user