1.5.0 update
This commit is contained in:
@@ -72,6 +72,10 @@ export enum AuditAction {
|
||||
AGENT_UPDATE = "agent_update",
|
||||
SCRIPT_EXECUTE = "script_execute",
|
||||
SILENT_EXECUTE = "silent_execute",
|
||||
ENROLLMENT_APPROVE = "enrollment_approve",
|
||||
ENROLLMENT_DENY = "enrollment_deny",
|
||||
ENROLLMENT_SETTINGS = "enrollment_settings",
|
||||
ENROLLMENT_BULK = "enrollment_bulk",
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
|
||||
@@ -52,6 +52,9 @@ export interface Config {
|
||||
passwordRequireNumber: boolean;
|
||||
passwordRequireSymbol: boolean;
|
||||
};
|
||||
enrollment: {
|
||||
requireApproval: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: Config = {
|
||||
@@ -102,6 +105,9 @@ const DEFAULT_CONFIG: Config = {
|
||||
passwordRequireNumber: false,
|
||||
passwordRequireSymbol: false,
|
||||
},
|
||||
enrollment: {
|
||||
requireApproval: true,
|
||||
},
|
||||
};
|
||||
|
||||
type SaveSecrets = {
|
||||
@@ -411,6 +417,12 @@ export function loadConfig(): Config {
|
||||
fileConfig.security?.passwordRequireSymbol ||
|
||||
DEFAULT_CONFIG.security.passwordRequireSymbol,
|
||||
},
|
||||
enrollment: {
|
||||
requireApproval:
|
||||
process.env.OVERLORD_ENROLLMENT_REQUIRE_APPROVAL !== undefined
|
||||
? String(process.env.OVERLORD_ENROLLMENT_REQUIRE_APPROVAL).toLowerCase() === "true"
|
||||
: (fileConfig.enrollment?.requireApproval ?? DEFAULT_CONFIG.enrollment.requireApproval),
|
||||
},
|
||||
};
|
||||
|
||||
if (saveChanged) {
|
||||
@@ -513,6 +525,27 @@ export async function updateSecurityConfig(
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function updateEnrollmentConfig(
|
||||
updates: Partial<Config["enrollment"]>,
|
||||
): Promise<Config["enrollment"]> {
|
||||
const current = getConfig();
|
||||
const next = {
|
||||
...current.enrollment,
|
||||
...updates,
|
||||
requireApproval: updates.requireApproval !== undefined ? Boolean(updates.requireApproval) : current.enrollment.requireApproval,
|
||||
};
|
||||
|
||||
configCache = {
|
||||
...current,
|
||||
enrollment: next,
|
||||
};
|
||||
|
||||
const fileConfig = readFileConfigForUpdate();
|
||||
fileConfig.enrollment = next;
|
||||
await writePersistentFileConfig(fileConfig);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function updateTlsConfig(
|
||||
updates: Partial<Config["tls"]>,
|
||||
): Promise<Config["tls"]> {
|
||||
|
||||
+127
-4
@@ -52,6 +52,30 @@ try {
|
||||
try {
|
||||
db.run(`ALTER TABLE clients ADD COLUMN bookmarked INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch {}
|
||||
try {
|
||||
db.run(`ALTER TABLE clients ADD COLUMN enrollment_status TEXT NOT NULL DEFAULT 'pending'`);
|
||||
} catch {}
|
||||
try {
|
||||
db.run(`ALTER TABLE clients ADD COLUMN public_key TEXT`);
|
||||
} catch {}
|
||||
try {
|
||||
db.run(`ALTER TABLE clients ADD COLUMN key_fingerprint TEXT`);
|
||||
} catch {}
|
||||
try {
|
||||
db.run(`ALTER TABLE clients ADD COLUMN enrolled_at INTEGER`);
|
||||
} catch {}
|
||||
try {
|
||||
db.run(`ALTER TABLE clients ADD COLUMN enrolled_by TEXT`);
|
||||
} catch {}
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_public_key ON clients(public_key);`,
|
||||
);
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_key_fingerprint ON clients(key_fingerprint);`,
|
||||
);
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_enrollment_status ON clients(enrollment_status);`,
|
||||
);
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_online_last_seen ON clients(online, last_seen DESC);`,
|
||||
);
|
||||
@@ -148,8 +172,8 @@ export function upsertClientRow(
|
||||
) {
|
||||
const now = partial.lastSeen ?? Date.now();
|
||||
db.run(
|
||||
`INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms, enrollment_status, public_key, key_fingerprint)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
hwid=COALESCE(excluded.hwid, clients.hwid),
|
||||
role=COALESCE(excluded.role, clients.role),
|
||||
@@ -166,7 +190,9 @@ export function upsertClientRow(
|
||||
country=COALESCE(excluded.country, clients.country),
|
||||
last_seen=excluded.last_seen,
|
||||
online=COALESCE(excluded.online, clients.online),
|
||||
ping_ms=COALESCE(excluded.ping_ms, clients.ping_ms)
|
||||
ping_ms=COALESCE(excluded.ping_ms, clients.ping_ms),
|
||||
public_key=COALESCE(excluded.public_key, clients.public_key),
|
||||
key_fingerprint=COALESCE(excluded.key_fingerprint, clients.key_fingerprint)
|
||||
`,
|
||||
partial.id,
|
||||
partial.hwid ?? partial.id,
|
||||
@@ -185,6 +211,9 @@ export function upsertClientRow(
|
||||
now,
|
||||
partial.online ?? 0,
|
||||
partial.pingMs ?? null,
|
||||
partial.enrollmentStatus ?? "pending",
|
||||
partial.publicKey ?? null,
|
||||
partial.keyFingerprint ?? null,
|
||||
);
|
||||
|
||||
if (partial.hwid) {
|
||||
@@ -302,6 +331,7 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
statusFilter,
|
||||
osFilter,
|
||||
countryFilter,
|
||||
enrollmentFilter,
|
||||
allowedClientIds,
|
||||
deniedClientIds,
|
||||
} = filters;
|
||||
@@ -322,6 +352,11 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
where.push("online=0");
|
||||
}
|
||||
|
||||
if (enrollmentFilter && enrollmentFilter !== "all") {
|
||||
where.push("enrollment_status=?");
|
||||
params.push(enrollmentFilter);
|
||||
}
|
||||
|
||||
if (osFilter && osFilter !== "all") {
|
||||
where.push("os=?");
|
||||
params.push(osFilter);
|
||||
@@ -382,7 +417,7 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
|
||||
const rows = db
|
||||
.query<any>(
|
||||
`SELECT id, hwid, role, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs, bookmarked
|
||||
`SELECT id, hwid, role, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs, bookmarked, enrollment_status as enrollmentStatus, public_key as publicKey, key_fingerprint as keyFingerprint
|
||||
FROM clients
|
||||
${whereSql}
|
||||
${orderBy}
|
||||
@@ -408,6 +443,9 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
pingMs: c.pingMs ?? null,
|
||||
online: c.online === 1,
|
||||
bookmarked: c.bookmarked === 1,
|
||||
enrollmentStatus: c.enrollmentStatus || "pending",
|
||||
publicKey: c.publicKey || null,
|
||||
keyFingerprint: c.keyFingerprint || null,
|
||||
thumbnail: getThumbnail(c.id),
|
||||
}));
|
||||
|
||||
@@ -803,3 +841,88 @@ export function clearNotificationScreenshots() {
|
||||
db.run(`DELETE FROM notification_screenshots`);
|
||||
console.log("[db] cleared notification screenshots");
|
||||
}
|
||||
|
||||
export function getClientEnrollmentStatus(id: string): string | null {
|
||||
const row = db
|
||||
.query<{ enrollment_status: string }>(
|
||||
`SELECT enrollment_status FROM clients WHERE id=?`,
|
||||
)
|
||||
.get(id);
|
||||
return row?.enrollment_status ?? null;
|
||||
}
|
||||
|
||||
export function setClientEnrollmentStatus(
|
||||
id: string,
|
||||
status: "approved" | "denied" | "pending",
|
||||
approvedBy?: string,
|
||||
): boolean {
|
||||
const result = db.run(
|
||||
`UPDATE clients SET enrollment_status=?, enrolled_at=?, enrolled_by=? WHERE id=?`,
|
||||
status,
|
||||
status === "approved" ? Date.now() : null,
|
||||
status === "approved" ? (approvedBy ?? null) : null,
|
||||
id,
|
||||
);
|
||||
return ((result as any)?.changes || 0) > 0;
|
||||
}
|
||||
|
||||
export function lookupClientByPublicKey(
|
||||
publicKey: string,
|
||||
): { id: string; enrollmentStatus: string } | null {
|
||||
const row = db
|
||||
.query<{ id: string; enrollment_status: string }>(
|
||||
`SELECT id, enrollment_status FROM clients WHERE public_key=? LIMIT 1`,
|
||||
)
|
||||
.get(publicKey);
|
||||
if (!row) return null;
|
||||
return { id: row.id, enrollmentStatus: row.enrollment_status };
|
||||
}
|
||||
|
||||
export function getEnrollmentStats(): {
|
||||
pending: number;
|
||||
approved: number;
|
||||
denied: number;
|
||||
} {
|
||||
const rows = db
|
||||
.query<{ status: string; c: number }>(
|
||||
`SELECT COALESCE(enrollment_status,'pending') as status, COUNT(*) as c FROM clients GROUP BY enrollment_status`,
|
||||
)
|
||||
.all();
|
||||
const stats = { pending: 0, approved: 0, denied: 0 };
|
||||
for (const r of rows) {
|
||||
if (r.status === "approved") stats.approved = Number(r.c);
|
||||
else if (r.status === "denied") stats.denied = Number(r.c);
|
||||
else stats.pending = Number(r.c);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
export function getPendingClients(): {
|
||||
id: string;
|
||||
host: string | null;
|
||||
os: string | null;
|
||||
user: string | null;
|
||||
ip: string | null;
|
||||
country: string | null;
|
||||
publicKey: string | null;
|
||||
keyFingerprint: string | null;
|
||||
lastSeen: number;
|
||||
}[] {
|
||||
return db
|
||||
.query<any>(
|
||||
`SELECT id, host, os, user, ip, country, public_key as publicKey, key_fingerprint as keyFingerprint, last_seen as lastSeen
|
||||
FROM clients WHERE enrollment_status='pending' ORDER BY last_seen DESC`,
|
||||
)
|
||||
.all()
|
||||
.map((r: any) => ({
|
||||
id: r.id,
|
||||
host: r.host,
|
||||
os: r.os,
|
||||
user: r.user,
|
||||
ip: r.ip,
|
||||
country: r.country,
|
||||
publicKey: r.publicKey,
|
||||
keyFingerprint: r.keyFingerprint,
|
||||
lastSeen: Number(r.lastSeen) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { metrics } from "./metrics";
|
||||
import { ensureDataDir } from "./paths";
|
||||
import { handleAuthRoutes } from "./server/routes/auth-routes";
|
||||
import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes";
|
||||
import { handleEnrollmentRoutes } from "./server/routes/enrollment-routes";
|
||||
import { handleBuildRoutes } from "./server/routes/build-routes";
|
||||
import { handleAssetsRoutes } from "./server/routes/assets-routes";
|
||||
import { handleDeployRoutes } from "./server/routes/deploy-routes";
|
||||
@@ -482,6 +483,7 @@ async function startServer() {
|
||||
handleAuthRoutes,
|
||||
handleNotificationsConfigRoutes,
|
||||
handleAutoScriptsRoutes,
|
||||
handleEnrollmentRoutes,
|
||||
handleUsersRoutes,
|
||||
handleBuildRoutes,
|
||||
handleDeployRoutes,
|
||||
|
||||
@@ -13,7 +13,9 @@ export type MessageKind =
|
||||
| "plugin_event"
|
||||
| "notification"
|
||||
| "webcam_devices"
|
||||
| "notification_config";
|
||||
| "notification_config"
|
||||
| "enrollment_challenge"
|
||||
| "enrollment_status";
|
||||
|
||||
export type Hello = {
|
||||
type: "hello";
|
||||
@@ -26,6 +28,18 @@ export type Hello = {
|
||||
monitors: number;
|
||||
monitorInfo?: { width: number; height: number }[];
|
||||
country?: string;
|
||||
publicKey?: string;
|
||||
signature?: string;
|
||||
};
|
||||
|
||||
export type EnrollmentChallenge = {
|
||||
type: "enrollment_challenge";
|
||||
nonce: string;
|
||||
};
|
||||
|
||||
export type EnrollmentStatusMsg = {
|
||||
type: "enrollment_status";
|
||||
status: "pending" | "approved" | "denied";
|
||||
};
|
||||
|
||||
export type HelloAck = {
|
||||
@@ -296,7 +310,9 @@ export type WireMessage =
|
||||
| ScriptResult
|
||||
| PluginEvent
|
||||
| NotificationEvent
|
||||
| NotificationConfig;
|
||||
| NotificationConfig
|
||||
| EnrollmentChallenge
|
||||
| EnrollmentStatusMsg;
|
||||
|
||||
export function encodeMessage(msg: WireMessage): Uint8Array {
|
||||
return encode(msg);
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
import { describe, expect, test, beforeEach } from "bun:test";
|
||||
import { encodeMessage, decodeMessage, type Hello, type EnrollmentChallenge, type EnrollmentStatusMsg } from "./protocol";
|
||||
|
||||
|
||||
async function generateKeypair() {
|
||||
const kp = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
|
||||
const pubRaw = await crypto.subtle.exportKey("raw", kp.publicKey);
|
||||
const privRaw = await crypto.subtle.exportKey("pkcs8", kp.privateKey);
|
||||
return {
|
||||
publicKey: kp.publicKey,
|
||||
privateKey: kp.privateKey,
|
||||
publicKeyBase64: Buffer.from(pubRaw).toString("base64"),
|
||||
publicKeyBytes: new Uint8Array(pubRaw),
|
||||
};
|
||||
}
|
||||
|
||||
async function sign(privateKey: CryptoKey, data: Uint8Array): Promise<string> {
|
||||
const sig = await crypto.subtle.sign("Ed25519", privateKey, data);
|
||||
return Buffer.from(sig).toString("base64");
|
||||
}
|
||||
|
||||
async function verifyEd25519(publicKeyBase64: string, signatureBase64: string, nonceBase64: string): Promise<boolean> {
|
||||
try {
|
||||
const pubKeyBytes = Buffer.from(publicKeyBase64, "base64");
|
||||
const sigBytes = Buffer.from(signatureBase64, "base64");
|
||||
const nonceBytes = Buffer.from(nonceBase64, "base64");
|
||||
if (pubKeyBytes.length !== 32 || sigBytes.length !== 64) return false;
|
||||
const key = await crypto.subtle.importKey("raw", pubKeyBytes, { name: "Ed25519" }, false, ["verify"]);
|
||||
return await crypto.subtle.verify("Ed25519", key, sigBytes, nonceBytes);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function computeKeyFingerprint(publicKeyBase64: string): string {
|
||||
const bytes = Buffer.from(publicKeyBase64, "base64");
|
||||
return new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function createMockWs(overrides: Partial<{ clientId: string; role: string; ip: string; enrollmentNonce: string }> = {}) {
|
||||
const sent: Uint8Array[] = [];
|
||||
let closedCode: number | null = null;
|
||||
let closedReason: string | null = null;
|
||||
return {
|
||||
data: {
|
||||
clientId: overrides.clientId ?? "test-client-1",
|
||||
role: overrides.role ?? "client",
|
||||
ip: overrides.ip ?? "127.0.0.1",
|
||||
enrollmentNonce: overrides.enrollmentNonce ?? undefined,
|
||||
sessionId: "sess-1",
|
||||
},
|
||||
sent,
|
||||
closedCode,
|
||||
closedReason,
|
||||
send(msg: Uint8Array) { sent.push(msg); },
|
||||
close(code: number, reason: string) { this.closedCode = code; this.closedReason = reason; },
|
||||
decodeSent(index: number) { return decodeMessage(sent[index]) as any; },
|
||||
};
|
||||
}
|
||||
|
||||
// tests (AI)
|
||||
|
||||
describe("purgatory — Ed25519 signature verification", () => {
|
||||
test("valid signature from matching keypair passes", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const nonce = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce);
|
||||
const nonceB64 = Buffer.from(nonce).toString("base64");
|
||||
const sigB64 = await sign(kp.privateKey, nonce);
|
||||
|
||||
expect(await verifyEd25519(kp.publicKeyBase64, sigB64, nonceB64)).toBe(true);
|
||||
});
|
||||
|
||||
test("signature from wrong keypair is rejected", async () => {
|
||||
const kp1 = await generateKeypair();
|
||||
const kp2 = await generateKeypair();
|
||||
const nonce = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce);
|
||||
const nonceB64 = Buffer.from(nonce).toString("base64");
|
||||
// Sign with kp1 but verify with kp2's public key
|
||||
const sigB64 = await sign(kp1.privateKey, nonce);
|
||||
|
||||
expect(await verifyEd25519(kp2.publicKeyBase64, sigB64, nonceB64)).toBe(false);
|
||||
});
|
||||
|
||||
test("signature over wrong nonce is rejected", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const nonce1 = new Uint8Array(32);
|
||||
const nonce2 = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce1);
|
||||
crypto.getRandomValues(nonce2);
|
||||
// Sign nonce1 but try to verify against nonce2
|
||||
const sigB64 = await sign(kp.privateKey, nonce1);
|
||||
|
||||
expect(await verifyEd25519(kp.publicKeyBase64, sigB64, Buffer.from(nonce2).toString("base64"))).toBe(false);
|
||||
});
|
||||
|
||||
test("empty public key is rejected", async () => {
|
||||
expect(await verifyEd25519("", "AAAA", "AAAA")).toBe(false);
|
||||
});
|
||||
|
||||
test("empty signature is rejected", async () => {
|
||||
const kp = await generateKeypair();
|
||||
expect(await verifyEd25519(kp.publicKeyBase64, "", "AAAA")).toBe(false);
|
||||
});
|
||||
|
||||
test("malformed public key (wrong length) is rejected", async () => {
|
||||
const nonce = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce);
|
||||
const nonceB64 = Buffer.from(nonce).toString("base64");
|
||||
// 16-byte key instead of 32
|
||||
const badKey = Buffer.from(new Uint8Array(16)).toString("base64");
|
||||
const fakeSig = Buffer.from(new Uint8Array(64)).toString("base64");
|
||||
|
||||
expect(await verifyEd25519(badKey, fakeSig, nonceB64)).toBe(false);
|
||||
});
|
||||
|
||||
test("malformed signature (wrong length) is rejected", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const nonce = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce);
|
||||
const nonceB64 = Buffer.from(nonce).toString("base64");
|
||||
// 32-byte signature instead of 64
|
||||
const badSig = Buffer.from(new Uint8Array(32)).toString("base64");
|
||||
|
||||
expect(await verifyEd25519(kp.publicKeyBase64, badSig, nonceB64)).toBe(false);
|
||||
});
|
||||
|
||||
test("garbage base64 does not crash — returns false", async () => {
|
||||
expect(await verifyEd25519("not!!valid!!base64", "also!!bad", "nonce!!bad")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — key fingerprint", () => {
|
||||
test("fingerprint is a 64-char hex SHA-256 of the raw public key bytes", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const fp = computeKeyFingerprint(kp.publicKeyBase64);
|
||||
expect(fp).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("different keys produce different fingerprints", async () => {
|
||||
const kp1 = await generateKeypair();
|
||||
const kp2 = await generateKeypair();
|
||||
expect(computeKeyFingerprint(kp1.publicKeyBase64)).not.toBe(computeKeyFingerprint(kp2.publicKeyBase64));
|
||||
});
|
||||
|
||||
test("same key always produces the same fingerprint", async () => {
|
||||
const kp = await generateKeypair();
|
||||
expect(computeKeyFingerprint(kp.publicKeyBase64)).toBe(computeKeyFingerprint(kp.publicKeyBase64));
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — enrollment challenge protocol", () => {
|
||||
test("enrollment_challenge message encodes/decodes with nonce", () => {
|
||||
const nonce = Buffer.from(new Uint8Array(32)).toString("base64");
|
||||
const msg = encodeMessage({ type: "enrollment_challenge", nonce });
|
||||
const decoded = decodeMessage(msg) as EnrollmentChallenge;
|
||||
expect(decoded.type).toBe("enrollment_challenge");
|
||||
expect(decoded.nonce).toBe(nonce);
|
||||
});
|
||||
|
||||
test("enrollment_status message encodes/decodes with status", () => {
|
||||
for (const status of ["pending", "approved", "denied"] as const) {
|
||||
const msg = encodeMessage({ type: "enrollment_status", status });
|
||||
const decoded = decodeMessage(msg) as EnrollmentStatusMsg;
|
||||
expect(decoded.type).toBe("enrollment_status");
|
||||
expect(decoded.status).toBe(status);
|
||||
}
|
||||
});
|
||||
|
||||
test("hello message with publicKey and signature round-trips", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const hello: Hello = {
|
||||
type: "hello",
|
||||
id: "client-abc",
|
||||
host: "DESKTOP-1",
|
||||
os: "windows",
|
||||
arch: "amd64",
|
||||
version: "1.0.0",
|
||||
user: "testuser",
|
||||
monitors: 2,
|
||||
country: "US",
|
||||
publicKey: kp.publicKeyBase64,
|
||||
signature: "fakesig==",
|
||||
};
|
||||
const encoded = encodeMessage(hello);
|
||||
const decoded = decodeMessage(encoded) as Hello;
|
||||
expect(decoded.publicKey).toBe(kp.publicKeyBase64);
|
||||
expect(decoded.signature).toBe("fakesig==");
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — client rejection scenarios", () => {
|
||||
test("client with no publicKey is rejected (close 4002)", () => {
|
||||
const ws = createMockWs({ enrollmentNonce: Buffer.from(new Uint8Array(32)).toString("base64") });
|
||||
const payload: any = {
|
||||
type: "hello",
|
||||
id: "bad-client",
|
||||
host: "evil",
|
||||
os: "windows",
|
||||
arch: "amd64",
|
||||
version: "1.0",
|
||||
user: "hacker",
|
||||
monitors: 1,
|
||||
publicKey: "",
|
||||
signature: "something",
|
||||
};
|
||||
// Simulate the server's check: missing publicKey/signature/nonce
|
||||
const publicKey = typeof payload.publicKey === "string" ? payload.publicKey : "";
|
||||
const signature = typeof payload.signature === "string" ? payload.signature : "";
|
||||
const nonce = ws.data.enrollmentNonce || "";
|
||||
|
||||
if (!publicKey || !signature || !nonce) {
|
||||
ws.close(4002, "invalid_signature");
|
||||
}
|
||||
|
||||
expect(ws.closedCode).toBe(4002);
|
||||
expect(ws.closedReason).toBe("invalid_signature");
|
||||
});
|
||||
|
||||
test("client with no signature is rejected (close 4002)", () => {
|
||||
const ws = createMockWs({ enrollmentNonce: Buffer.from(new Uint8Array(32)).toString("base64") });
|
||||
const publicKey = "validkeyhere";
|
||||
const signature = "";
|
||||
const nonce = ws.data.enrollmentNonce || "";
|
||||
|
||||
if (!publicKey || !signature || !nonce) {
|
||||
ws.close(4002, "invalid_signature");
|
||||
}
|
||||
|
||||
expect(ws.closedCode).toBe(4002);
|
||||
});
|
||||
|
||||
test("client with no nonce stored on ws is rejected (close 4002)", () => {
|
||||
const ws = createMockWs({ enrollmentNonce: undefined });
|
||||
const publicKey = "somekey";
|
||||
const signature = "somesig";
|
||||
const nonce = ws.data.enrollmentNonce || "";
|
||||
|
||||
if (!publicKey || !signature || !nonce) {
|
||||
ws.close(4002, "invalid_signature");
|
||||
}
|
||||
|
||||
expect(ws.closedCode).toBe(4002);
|
||||
});
|
||||
|
||||
test("client with invalid Ed25519 signature is rejected", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const nonce = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce);
|
||||
const nonceB64 = Buffer.from(nonce).toString("base64");
|
||||
// Construct a bogus signature (not signed by this key)
|
||||
const badSig = Buffer.from(new Uint8Array(64).fill(0xAB)).toString("base64");
|
||||
|
||||
const valid = await verifyEd25519(kp.publicKeyBase64, badSig, nonceB64);
|
||||
expect(valid).toBe(false);
|
||||
// Server would close with 4002
|
||||
});
|
||||
|
||||
test("replay attack: reusing a nonce with a valid signature on a different nonce fails", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const nonce1 = new Uint8Array(32);
|
||||
const nonce2 = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce1);
|
||||
crypto.getRandomValues(nonce2);
|
||||
|
||||
// Client signed nonce1
|
||||
const sigB64 = await sign(kp.privateKey, nonce1);
|
||||
// Server presents nonce2 — verify against nonce2 should fail
|
||||
const valid = await verifyEd25519(kp.publicKeyBase64, sigB64, Buffer.from(nonce2).toString("base64"));
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
test("nonce is cleared after use — cannot be reused", () => {
|
||||
const ws = createMockWs({ enrollmentNonce: "someNonce==" });
|
||||
// Server clears nonce after successful verification
|
||||
ws.data.enrollmentNonce = undefined;
|
||||
expect(ws.data.enrollmentNonce).toBeUndefined();
|
||||
// Second attempt with no nonce → rejected
|
||||
const nonce = ws.data.enrollmentNonce || "";
|
||||
expect(nonce).toBe("");
|
||||
});
|
||||
|
||||
test("denied client gets close code 4003", () => {
|
||||
const ws = createMockWs();
|
||||
// Simulate denied enrollment status
|
||||
const enrollmentStatus = "denied";
|
||||
if (enrollmentStatus === "denied") {
|
||||
ws.send(encodeMessage({ type: "enrollment_status", status: "denied" }));
|
||||
ws.close(4003, "denied");
|
||||
}
|
||||
expect(ws.closedCode).toBe(4003);
|
||||
expect(ws.closedReason).toBe("denied");
|
||||
const msg = ws.decodeSent(0);
|
||||
expect(msg.type).toBe("enrollment_status");
|
||||
expect(msg.status).toBe("denied");
|
||||
});
|
||||
|
||||
test("pending client gets close code 4001", () => {
|
||||
const ws = createMockWs();
|
||||
const enrollmentStatus = "pending";
|
||||
if (enrollmentStatus === "pending") {
|
||||
ws.send(encodeMessage({ type: "enrollment_status", status: "pending" }));
|
||||
ws.close(4001, "pending");
|
||||
}
|
||||
expect(ws.closedCode).toBe(4001);
|
||||
expect(ws.closedReason).toBe("pending");
|
||||
const msg = ws.decodeSent(0);
|
||||
expect(msg.type).toBe("enrollment_status");
|
||||
expect(msg.status).toBe("pending");
|
||||
});
|
||||
|
||||
test("approved client gets hello_ack (not closed)", async () => {
|
||||
const ws = createMockWs();
|
||||
const enrollmentStatus = "approved";
|
||||
if (enrollmentStatus === "approved") {
|
||||
ws.send(encodeMessage({
|
||||
type: "hello_ack",
|
||||
id: ws.data.clientId,
|
||||
notification: { keywords: [], minIntervalMs: 8000, clipboardEnabled: false },
|
||||
}));
|
||||
}
|
||||
expect(ws.closedCode).toBeNull();
|
||||
const msg = ws.decodeSent(0);
|
||||
expect(msg.type).toBe("hello_ack");
|
||||
expect(msg.id).toBe("test-client-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — enrollment timeout", () => {
|
||||
test("timeout constant is 30 seconds", () => {
|
||||
// This just documents the expected timeout value
|
||||
const ENROLLMENT_TIMEOUT_MS = 30_000;
|
||||
expect(ENROLLMENT_TIMEOUT_MS).toBe(30_000);
|
||||
});
|
||||
|
||||
test("client that never sends hello would be closed with 4002 on timeout", () => {
|
||||
const ws = createMockWs();
|
||||
// Simulate timeout firing
|
||||
ws.close(4002, "enrollment_timeout");
|
||||
expect(ws.closedCode).toBe(4002);
|
||||
expect(ws.closedReason).toBe("enrollment_timeout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — nonce generation", () => {
|
||||
test("nonce is 32 bytes of randomness", () => {
|
||||
const nonce = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce);
|
||||
expect(nonce.length).toBe(32);
|
||||
// Extremely unlikely all zeros
|
||||
expect(nonce.some((b) => b !== 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("sequential nonces are unique", () => {
|
||||
const nonces = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const n = new Uint8Array(32);
|
||||
crypto.getRandomValues(n);
|
||||
nonces.add(Buffer.from(n).toString("base64"));
|
||||
}
|
||||
expect(nonces.size).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — end-to-end challenge-response", () => {
|
||||
test("legitimate client completes full handshake", async () => {
|
||||
// 1. Server generates nonce
|
||||
const nonceBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonceBytes);
|
||||
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
|
||||
|
||||
// 2. Client receives challenge and signs
|
||||
const kp = await generateKeypair();
|
||||
const sigB64 = await sign(kp.privateKey, nonceBytes);
|
||||
|
||||
// 3. Server verifies
|
||||
const valid = await verifyEd25519(kp.publicKeyBase64, sigB64, nonceB64);
|
||||
expect(valid).toBe(true);
|
||||
|
||||
// 4. Server computes fingerprint
|
||||
const fp = computeKeyFingerprint(kp.publicKeyBase64);
|
||||
expect(fp).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("attacker who intercepts nonce cannot sign without private key", async () => {
|
||||
// 1. Server generates nonce
|
||||
const nonceBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonceBytes);
|
||||
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
|
||||
|
||||
// 2. Attacker has the real client's public key but not private key
|
||||
const realClient = await generateKeypair();
|
||||
const attacker = await generateKeypair();
|
||||
|
||||
// 3. Attacker tries to sign with their own key but present real client's pubkey
|
||||
const attackerSig = await sign(attacker.privateKey, nonceBytes);
|
||||
const valid = await verifyEd25519(realClient.publicKeyBase64, attackerSig, nonceB64);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
test("attacker with own keypair gets new identity — not existing client", async () => {
|
||||
// Attacker generates valid keypair and signs correctly
|
||||
const attacker = await generateKeypair();
|
||||
const nonceBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonceBytes);
|
||||
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
|
||||
const sigB64 = await sign(attacker.privateKey, nonceBytes);
|
||||
|
||||
// Signature is valid...
|
||||
const valid = await verifyEd25519(attacker.publicKeyBase64, sigB64, nonceB64);
|
||||
expect(valid).toBe(true);
|
||||
|
||||
// ...but fingerprint won't match any existing approved client
|
||||
const realClient = await generateKeypair();
|
||||
expect(computeKeyFingerprint(attacker.publicKeyBase64)).not.toBe(computeKeyFingerprint(realClient.publicKeyBase64));
|
||||
});
|
||||
|
||||
test("all-zero signature is rejected", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const nonceBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonceBytes);
|
||||
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
|
||||
const zeroSig = Buffer.from(new Uint8Array(64)).toString("base64");
|
||||
|
||||
expect(await verifyEd25519(kp.publicKeyBase64, zeroSig, nonceB64)).toBe(false);
|
||||
});
|
||||
|
||||
test("random 32-byte value that is not a valid Ed25519 point is rejected", async () => {
|
||||
const nonceBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonceBytes);
|
||||
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
|
||||
// 0xFF-filled is not a valid Ed25519 public key point
|
||||
const badKey = Buffer.from(new Uint8Array(32).fill(0xFF)).toString("base64");
|
||||
const fakeSig = Buffer.from(new Uint8Array(64).fill(0x42)).toString("base64");
|
||||
|
||||
expect(await verifyEd25519(badKey, fakeSig, nonceB64)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — duplicate connection prevention", () => {
|
||||
test("superseded socket gets close code 4004", () => {
|
||||
// Simulate: old socket is active, then new socket arrives for same clientId
|
||||
const oldWs = createMockWs({ clientId: "dup-client" });
|
||||
const newWs = createMockWs({ clientId: "dup-client" });
|
||||
|
||||
// Simulated client manager (Map<string, { ws: any }>)
|
||||
const clients = new Map<string, { ws: any }>();
|
||||
clients.set("dup-client", { ws: oldWs });
|
||||
|
||||
// New connection arrives — kick old
|
||||
const existing = clients.get("dup-client");
|
||||
if (existing && existing.ws !== newWs) {
|
||||
existing.ws.close(4004, "superseded");
|
||||
clients.delete("dup-client");
|
||||
}
|
||||
clients.set("dup-client", { ws: newWs });
|
||||
|
||||
expect(oldWs.closedCode).toBe(4004);
|
||||
expect(oldWs.closedReason).toBe("superseded");
|
||||
expect(newWs.closedCode).toBeNull();
|
||||
expect(clients.get("dup-client")!.ws).toBe(newWs);
|
||||
});
|
||||
|
||||
test("superseded socket close handler skips cleanup", () => {
|
||||
// Simulate: old socket fires close, but manager already has the new socket
|
||||
const oldWs = createMockWs({ clientId: "dup-client" });
|
||||
const newWs = createMockWs({ clientId: "dup-client" });
|
||||
|
||||
const clients = new Map<string, { ws: any }>();
|
||||
clients.set("dup-client", { ws: newWs }); // new one already registered
|
||||
|
||||
// Old socket close handler fires
|
||||
const current = clients.get("dup-client");
|
||||
const shouldSkipCleanup = current && current.ws !== oldWs;
|
||||
|
||||
expect(shouldSkipCleanup).toBe(true);
|
||||
// New socket should stay in place
|
||||
expect(clients.get("dup-client")!.ws).toBe(newWs);
|
||||
});
|
||||
|
||||
test("normal close handler cleans up when socket matches", () => {
|
||||
const ws = createMockWs({ clientId: "normal-client" });
|
||||
const clients = new Map<string, { ws: any }>();
|
||||
clients.set("normal-client", { ws });
|
||||
|
||||
// Close handler — socket matches current
|
||||
const current = clients.get("normal-client");
|
||||
const shouldSkipCleanup = current && current.ws !== ws;
|
||||
|
||||
expect(shouldSkipCleanup).toBe(false);
|
||||
// Should proceed with cleanup
|
||||
clients.delete("normal-client");
|
||||
expect(clients.has("normal-client")).toBe(false);
|
||||
});
|
||||
|
||||
test("same socket reconnecting is not kicked (identity check)", () => {
|
||||
const ws = createMockWs({ clientId: "same-client" });
|
||||
const clients = new Map<string, { ws: any }>();
|
||||
clients.set("same-client", { ws });
|
||||
|
||||
// Same ws object reconnects — should NOT kick itself
|
||||
const existing = clients.get("same-client");
|
||||
if (existing && existing.ws !== ws) {
|
||||
existing.ws.close(4004, "superseded");
|
||||
}
|
||||
|
||||
// Should not have been closed
|
||||
expect(ws.closedCode).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — IP banning", () => {
|
||||
test("IP validation regex accepts valid IPv4", () => {
|
||||
const ipRegex = /^[0-9a-fA-F:.]{3,64}$/;
|
||||
expect(ipRegex.test("192.168.1.1")).toBe(true);
|
||||
expect(ipRegex.test("10.0.0.1")).toBe(true);
|
||||
expect(ipRegex.test("255.255.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
test("IP validation regex accepts valid IPv6", () => {
|
||||
const ipRegex = /^[0-9a-fA-F:.]{3,64}$/;
|
||||
expect(ipRegex.test("::1")).toBe(true);
|
||||
expect(ipRegex.test("fe80::1")).toBe(true);
|
||||
expect(ipRegex.test("2001:db8::1")).toBe(true);
|
||||
});
|
||||
|
||||
test("IP validation regex rejects dangerous input", () => {
|
||||
const ipRegex = /^[0-9a-fA-F:.]{3,64}$/;
|
||||
expect(ipRegex.test("")).toBe(false);
|
||||
expect(ipRegex.test("a")).toBe(false); // too short
|
||||
expect(ipRegex.test("'; DROP TABLE--")).toBe(false);
|
||||
expect(ipRegex.test("192.168.1.1; rm -rf /")).toBe(false);
|
||||
expect(ipRegex.test("<script>alert(1)</script>")).toBe(false);
|
||||
});
|
||||
|
||||
test("banned client gets close code 4003", () => {
|
||||
const ws = createMockWs({ ip: "1.2.3.4" });
|
||||
// Simulate banning: deny enrollment + close socket
|
||||
ws.close(4003, "banned");
|
||||
expect(ws.closedCode).toBe(4003);
|
||||
expect(ws.closedReason).toBe("banned");
|
||||
});
|
||||
|
||||
test("ban reason is truncated to 200 chars", () => {
|
||||
const longReason = "A".repeat(500);
|
||||
const truncated = longReason.slice(0, 200);
|
||||
expect(truncated.length).toBe(200);
|
||||
expect(truncated).toBe("A".repeat(200));
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ type HttpDispatchDeps<
|
||||
handleAuthRoutes: RouteHandlerWithServer<TServer>;
|
||||
handleNotificationsConfigRoutes: RouteHandlerWithServerDeps<TServer, TNotificationsConfig>;
|
||||
handleAutoScriptsRoutes: RouteHandler;
|
||||
handleEnrollmentRoutes: RouteHandler;
|
||||
handleUsersRoutes: RouteHandlerWithServer<TServer>;
|
||||
handleBuildRoutes: RouteHandlerWithServerDeps<TServer, TBuild>;
|
||||
handleDeployRoutes: RouteHandlerWithServerDeps<TServer, TDeploy>;
|
||||
@@ -105,6 +106,9 @@ export function createHttpFetchHandler<
|
||||
const autoScriptsResponse = await deps.handleAutoScriptsRoutes(req, url);
|
||||
if (autoScriptsResponse) return autoScriptsResponse;
|
||||
|
||||
const enrollmentResponse = await deps.handleEnrollmentRoutes(req, url);
|
||||
if (enrollmentResponse) return enrollmentResponse;
|
||||
|
||||
const usersResponse = await deps.handleUsersRoutes(req, url, routeServer);
|
||||
if (usersResponse) return usersResponse;
|
||||
|
||||
|
||||
@@ -75,8 +75,9 @@ export async function handleClientRoutes(
|
||||
const statusFilter = url.searchParams.get("status") || "all";
|
||||
const osFilter = url.searchParams.get("os") || "all";
|
||||
const countryFilter = url.searchParams.get("country") || "all";
|
||||
const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "all";
|
||||
if (user.role === "admin") {
|
||||
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter });
|
||||
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter });
|
||||
return Response.json(result, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
@@ -105,6 +106,7 @@ export async function handleClientRoutes(
|
||||
statusFilter,
|
||||
osFilter,
|
||||
countryFilter,
|
||||
enrollmentFilter,
|
||||
allowedClientIds,
|
||||
deniedClientIds,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { getConfig, updateEnrollmentConfig } from "../../config";
|
||||
import {
|
||||
getPendingClients,
|
||||
getEnrollmentStats,
|
||||
setClientEnrollmentStatus,
|
||||
getClientEnrollmentStatus,
|
||||
getClientIp,
|
||||
banIp,
|
||||
unbanIp,
|
||||
isIpBanned,
|
||||
listBannedIps,
|
||||
} from "../../db";
|
||||
import { logAudit, AuditAction } from "../../auditLog";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { setOnlineState } from "../../db";
|
||||
|
||||
export async function handleEnrollmentRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
): Promise<Response | null> {
|
||||
// GET /api/enrollment/pending
|
||||
if (req.method === "GET" && url.pathname === "/api/enrollment/pending") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
|
||||
|
||||
const clients = getPendingClients();
|
||||
return Response.json({ items: clients });
|
||||
}
|
||||
|
||||
// GET /api/enrollment/stats
|
||||
if (req.method === "GET" && url.pathname === "/api/enrollment/stats") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const stats = getEnrollmentStats();
|
||||
return Response.json(stats);
|
||||
}
|
||||
|
||||
// GET /api/enrollment/settings
|
||||
if (req.method === "GET" && url.pathname === "/api/enrollment/settings") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const config = getConfig();
|
||||
return Response.json({
|
||||
requireApproval: config.enrollment?.requireApproval ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
// PUT /api/enrollment/settings
|
||||
if (req.method === "PUT" && url.pathname === "/api/enrollment/settings") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (typeof body?.requireApproval !== "boolean") {
|
||||
return Response.json({ error: "requireApproval must be a boolean" }, { status: 400 });
|
||||
}
|
||||
|
||||
await updateEnrollmentConfig(body.requireApproval);
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: "server",
|
||||
action: AuditAction.ENROLLMENT_SETTINGS,
|
||||
success: true,
|
||||
details: JSON.stringify({ enrollment: { requireApproval: body.requireApproval } }),
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, requireApproval: body.requireApproval });
|
||||
}
|
||||
|
||||
// POST /api/enrollment/:id/approve
|
||||
const approveMatch = url.pathname.match(/^\/api\/enrollment\/([^/]+)\/approve$/);
|
||||
if (req.method === "POST" && approveMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
|
||||
|
||||
const clientId = decodeURIComponent(approveMatch[1]);
|
||||
const current = getClientEnrollmentStatus(clientId);
|
||||
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
|
||||
|
||||
setClientEnrollmentStatus(clientId, "approved", user.username);
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: "server",
|
||||
action: AuditAction.ENROLLMENT_APPROVE,
|
||||
targetClientId: clientId,
|
||||
success: true,
|
||||
details: JSON.stringify({ enrollment: "approved" }),
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, status: "approved" });
|
||||
}
|
||||
|
||||
// POST /api/enrollment/:id/deny
|
||||
const denyMatch = url.pathname.match(/^\/api\/enrollment\/([^/]+)\/deny$/);
|
||||
if (req.method === "POST" && denyMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
|
||||
|
||||
const clientId = decodeURIComponent(denyMatch[1]);
|
||||
const current = getClientEnrollmentStatus(clientId);
|
||||
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
|
||||
|
||||
setClientEnrollmentStatus(clientId, "denied");
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: "server",
|
||||
action: AuditAction.ENROLLMENT_DENY,
|
||||
targetClientId: clientId,
|
||||
success: true,
|
||||
details: JSON.stringify({ enrollment: "denied" }),
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, status: "denied" });
|
||||
}
|
||||
|
||||
// POST /api/enrollment/:id/reset
|
||||
const resetMatch = url.pathname.match(/^\/api\/enrollment\/([^/]+)\/reset$/);
|
||||
if (req.method === "POST" && resetMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
|
||||
|
||||
const clientId = decodeURIComponent(resetMatch[1]);
|
||||
const current = getClientEnrollmentStatus(clientId);
|
||||
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
|
||||
|
||||
setClientEnrollmentStatus(clientId, "pending");
|
||||
|
||||
return Response.json({ ok: true, status: "pending" });
|
||||
}
|
||||
|
||||
// POST /api/enrollment/bulk
|
||||
if (req.method === "POST" && url.pathname === "/api/enrollment/bulk") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ids = Array.isArray(body?.ids) ? body.ids.filter((id: unknown) => typeof id === "string") : [];
|
||||
const action = body?.action;
|
||||
|
||||
if (!["approve", "deny", "reset", "ban-ip"].includes(action)) {
|
||||
return Response.json({ error: "action must be 'approve', 'deny', 'reset', or 'ban-ip'" }, { status: 400 });
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
return Response.json({ error: "ids array is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (action === "ban-ip") {
|
||||
let banned = 0;
|
||||
for (const id of ids) {
|
||||
const clientIp = getClientIp(id);
|
||||
if (!clientIp) continue;
|
||||
banIp(clientIp, `Bulk banned from purgatory by ${user.username}`);
|
||||
setClientEnrollmentStatus(id, "denied");
|
||||
const target = clientManager.getClient(id);
|
||||
if (target) {
|
||||
try { target.ws.close(4003, "banned"); } catch {}
|
||||
setOnlineState(id, false);
|
||||
}
|
||||
banned++;
|
||||
}
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: "server",
|
||||
action: AuditAction.ENROLLMENT_BULK,
|
||||
success: true,
|
||||
details: JSON.stringify({ enrollment: { bulk: "ban-ip", count: banned } }),
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, action, updated: banned });
|
||||
}
|
||||
|
||||
const status = action === "approve" ? "approved" : action === "deny" ? "denied" : "pending";
|
||||
let updated = 0;
|
||||
for (const id of ids) {
|
||||
const ok = setClientEnrollmentStatus(
|
||||
id,
|
||||
status as "approved" | "denied" | "pending",
|
||||
action === "approve" ? user.username : undefined,
|
||||
);
|
||||
if (ok) updated++;
|
||||
}
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: "server",
|
||||
action: AuditAction.ENROLLMENT_BULK,
|
||||
success: true,
|
||||
details: JSON.stringify({ enrollment: { bulk: action, count: updated } }),
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, action, updated });
|
||||
}
|
||||
|
||||
// POST /api/enrollment/:id/ban-ip
|
||||
// Ban ip API makes me wanna fucking kms.
|
||||
const banMatch = url.pathname.match(/^\/api\/enrollment\/([^/]+)\/ban-ip$/);
|
||||
if (req.method === "POST" && banMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
|
||||
|
||||
const clientId = decodeURIComponent(banMatch[1]);
|
||||
const targetIp = getClientIp(clientId);
|
||||
if (!targetIp) return Response.json({ error: "Client IP not found" }, { status: 404 });
|
||||
|
||||
banIp(targetIp, `Banned from purgatory by ${user.username} for client ${clientId}`);
|
||||
setClientEnrollmentStatus(clientId, "denied");
|
||||
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (target) {
|
||||
try { target.ws.close(4003, "banned"); } catch {}
|
||||
setOnlineState(clientId, false);
|
||||
}
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: "server",
|
||||
action: AuditAction.ENROLLMENT_DENY,
|
||||
targetClientId: clientId,
|
||||
success: true,
|
||||
details: JSON.stringify({ bannedIp: targetIp }),
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, ip: targetIp });
|
||||
}
|
||||
|
||||
// GET /api/enrollment/banned-ips
|
||||
if (req.method === "GET" && url.pathname === "/api/enrollment/banned-ips") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
|
||||
|
||||
return Response.json({ items: listBannedIps() });
|
||||
}
|
||||
|
||||
// DELETE /api/enrollment/banned-ips?ip=...
|
||||
if (req.method === "DELETE" && url.pathname === "/api/enrollment/banned-ips") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
|
||||
|
||||
const ipToUnban = (url.searchParams.get("ip") || "").trim();
|
||||
if (!ipToUnban) return Response.json({ error: "Missing ip parameter" }, { status: 400 });
|
||||
if (!/^[0-9a-fA-F:.]{3,64}$/.test(ipToUnban)) return Response.json({ error: "Invalid IP format" }, { status: 400 });
|
||||
if (!isIpBanned(ipToUnban)) return Response.json({ error: "IP is not banned" }, { status: 404 });
|
||||
|
||||
unbanIp(ipToUnban);
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: "server",
|
||||
action: AuditAction.ENROLLMENT_BULK,
|
||||
success: true,
|
||||
details: JSON.stringify({ unbannedIp: ipToUnban }),
|
||||
});
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
// POST /api/enrollment/ban-ip (manual IP ban)
|
||||
if (req.method === "POST" && url.pathname === "/api/enrollment/ban-ip") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
|
||||
|
||||
let body: any;
|
||||
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
|
||||
|
||||
const ip = typeof body?.ip === "string" ? body.ip.trim() : "";
|
||||
if (!ip) return Response.json({ error: "ip is required" }, { status: 400 });
|
||||
if (!/^[0-9a-fA-F:.]{3,64}$/.test(ip)) return Response.json({ error: "Invalid IP format" }, { status: 400 });
|
||||
|
||||
const reason = typeof body?.reason === "string" ? body.reason.slice(0, 200) : `Banned from purgatory by ${user.username}`;
|
||||
banIp(ip, reason);
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: "server",
|
||||
action: AuditAction.ENROLLMENT_DENY,
|
||||
success: true,
|
||||
details: JSON.stringify({ bannedIp: ip, reason }),
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, ip });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -75,6 +75,7 @@ const STATIC_PAGES: StaticPageDef[] = [
|
||||
{ path: "/scripts", file: "scripts.html", access: "no-viewer" },
|
||||
{ path: "/deploy", file: "deploy.html", access: "admin" },
|
||||
{ path: "/socks5-manager", file: "socks5-manager.html", access: "no-viewer", checkPasswordChange: true },
|
||||
{ path: "/purgatory", file: "purgatory.html", access: "admin-or-operator", checkPasswordChange: true },
|
||||
];
|
||||
|
||||
/** Client-scoped pages accessed via query param ?clientId=... */
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import geoip from "geoip-lite";
|
||||
import { logAudit, AuditAction } from "../../auditLog";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { clientExists, setOnlineState, upsertClientRow } from "../../db";
|
||||
import { clientExists, setOnlineState, upsertClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey } from "../../db";
|
||||
import { getConfig } from "../../config";
|
||||
import { logger } from "../../logger";
|
||||
import { metrics } from "../../metrics";
|
||||
import { decodeMessage, encodeMessage, type WireMessage } from "../../protocol";
|
||||
@@ -85,6 +87,42 @@ type WsLifecycleDeps = {
|
||||
notifyDashboard: () => void;
|
||||
};
|
||||
|
||||
const ENROLLMENT_TIMEOUT_MS = 30_000;
|
||||
const enrollmentTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
function clearEnrollmentTimeout(clientId: string) {
|
||||
const t = enrollmentTimeouts.get(clientId);
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
enrollmentTimeouts.delete(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyEd25519(publicKeyBase64: string, signatureBase64: string, nonceBase64: string): Promise<boolean> {
|
||||
try {
|
||||
const pubKeyBytes = Buffer.from(publicKeyBase64, "base64");
|
||||
const sigBytes = Buffer.from(signatureBase64, "base64");
|
||||
const nonceBytes = Buffer.from(nonceBase64, "base64");
|
||||
if (pubKeyBytes.length !== 32 || sigBytes.length !== 64) return false;
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
pubKeyBytes,
|
||||
{ name: "Ed25519" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
return await crypto.subtle.verify("Ed25519", key, sigBytes, nonceBytes);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function computeKeyFingerprint(publicKeyBase64: string): string {
|
||||
const bytes = Buffer.from(publicKeyBase64, "base64");
|
||||
const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLifecycleDeps): void {
|
||||
const role = ws.data.role as string;
|
||||
const clientId = ws.data.clientId;
|
||||
@@ -101,43 +139,32 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
|
||||
if (role === "notifications_viewer") return deps.handleNotificationViewerOpen(ws);
|
||||
|
||||
const id = clientId || uuidv4();
|
||||
const info: ClientInfo = { id, role: role as any, ws, lastSeen: Date.now(), country: "", ip, online: true };
|
||||
clientManager.addClient(id, info);
|
||||
|
||||
ws.data.clientId = id;
|
||||
ws.data.ip = ip;
|
||||
if (role === "client") {
|
||||
ws.data.wasKnown = clientExists(id);
|
||||
}
|
||||
|
||||
upsertClientRow({ id, role: role as any, ip, lastSeen: info.lastSeen, online: 1 as any });
|
||||
logger.info(`[open] ${id} role=${role}`);
|
||||
const nonceBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonceBytes);
|
||||
const nonceBase64 = Buffer.from(nonceBytes).toString("base64");
|
||||
ws.data.enrollmentNonce = nonceBase64;
|
||||
|
||||
const notificationConfig = deps.getNotificationConfig();
|
||||
ws.send(
|
||||
encodeMessage({
|
||||
type: "hello_ack",
|
||||
id,
|
||||
notification: {
|
||||
keywords: notificationConfig.keywords || [],
|
||||
minIntervalMs: notificationConfig.minIntervalMs || 8000,
|
||||
clipboardEnabled: notificationConfig.clipboardEnabled || false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
ws.send(encodeMessage({ type: "enrollment_challenge", nonce: nonceBase64 }));
|
||||
|
||||
if (role === "client") {
|
||||
deps.notifyRemoteDesktopStatus(id, "online");
|
||||
metrics.recordConnection();
|
||||
deps.notifyDashboard();
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
enrollmentTimeouts.delete(id);
|
||||
try {
|
||||
ws.close(4002, "enrollment_timeout");
|
||||
} catch {}
|
||||
}, ENROLLMENT_TIMEOUT_MS);
|
||||
enrollmentTimeouts.set(id, timeout);
|
||||
|
||||
logger.info(`[open] ${id} role=${role} — purgatory challenge sent`);
|
||||
}
|
||||
|
||||
export function handleWebSocketMessage(
|
||||
export async function handleWebSocketMessage(
|
||||
ws: ServerWebSocket<SocketData>,
|
||||
message: string | ArrayBuffer | Uint8Array,
|
||||
deps: WsLifecycleDeps,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const size = getMessageByteLength(message as any);
|
||||
const role = ws.data?.role as any;
|
||||
const limit = getMaxPayloadLimit(role, deps.maxClientPayloadBytes, deps.maxViewerPayloadBytes);
|
||||
@@ -162,57 +189,194 @@ export function handleWebSocketMessage(
|
||||
if (socketRole === "dashboard_viewer") return;
|
||||
|
||||
const { clientId, ip } = ws.data;
|
||||
const info = clientManager.getClient(clientId);
|
||||
if (!info) return;
|
||||
info.lastSeen = Date.now();
|
||||
|
||||
let payload: WireMessage;
|
||||
try {
|
||||
const payload = decodeMessage(message as Uint8Array) as WireMessage;
|
||||
payload = decodeMessage(message as Uint8Array) as WireMessage;
|
||||
if (!payload || typeof (payload as any).type !== "string") {
|
||||
return;
|
||||
}
|
||||
if (!isAllowedClientMessageType((payload as any).type)) {
|
||||
logger.warn(`[message] Dropping unknown client message type: ${(payload as any).type}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("[message] decode error", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadType = (payload as any)?.type as string;
|
||||
const payloadType = (payload as any).type as string;
|
||||
|
||||
if (!isAllowedClientMessageType(payloadType)) {
|
||||
logger.warn(`[message] Dropping unknown client message type: ${payloadType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const info = clientManager.getClient(clientId);
|
||||
|
||||
if (!info && payloadType !== "hello") return;
|
||||
if (info) info.lastSeen = Date.now();
|
||||
|
||||
const client = info!;
|
||||
|
||||
try {
|
||||
switch (payloadType) {
|
||||
case "hello": {
|
||||
handleHello(info, payload, ws, ip);
|
||||
clientManager.addClient(info.id, info);
|
||||
deps.dispatchAutoScriptsForConnection(info, ws);
|
||||
clearEnrollmentTimeout(clientId);
|
||||
|
||||
const publicKey = typeof (payload as any).publicKey === "string" ? (payload as any).publicKey : "";
|
||||
const signature = typeof (payload as any).signature === "string" ? (payload as any).signature : "";
|
||||
const nonce = ws.data.enrollmentNonce || "";
|
||||
|
||||
if (!publicKey || !signature || !nonce) {
|
||||
logger.warn(`[purgatory] missing publicKey/signature/nonce for ${clientId}`);
|
||||
try { ws.close(4002, "invalid_signature"); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = await verifyEd25519(publicKey, signature, nonce);
|
||||
if (!valid) {
|
||||
logger.warn(`[purgatory] invalid signature for ${clientId}`);
|
||||
try { ws.close(4002, "invalid_signature"); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
ws.data.enrollmentNonce = undefined;
|
||||
|
||||
const keyFingerprint = computeKeyFingerprint(publicKey);
|
||||
const config = getConfig();
|
||||
const requireApproval = config.enrollment?.requireApproval ?? true;
|
||||
|
||||
const existing = lookupClientByPublicKey(publicKey);
|
||||
let enrollmentStatus: string;
|
||||
|
||||
if (existing) {
|
||||
enrollmentStatus = existing.enrollmentStatus;
|
||||
ws.data.clientId = existing.id;
|
||||
} else {
|
||||
enrollmentStatus = requireApproval ? "pending" : "approved";
|
||||
}
|
||||
|
||||
const resolvedId = ws.data.clientId;
|
||||
|
||||
if (enrollmentStatus === "denied") {
|
||||
logger.info(`[purgatory] denied client ${resolvedId} tried to connect`);
|
||||
ws.send(encodeMessage({ type: "enrollment_status", status: "denied" }));
|
||||
try { ws.close(4003, "denied"); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
if (enrollmentStatus === "pending") {
|
||||
const geo = ip ? geoip.lookup(ip) : undefined;
|
||||
const countryRaw = geo?.country || (payload as any).country || "ZZ";
|
||||
const country = /^[A-Z]{2}$/i.test(countryRaw) ? countryRaw.toUpperCase() : "ZZ";
|
||||
|
||||
upsertClientRow({
|
||||
id: resolvedId,
|
||||
hwid: (payload as any).hwid || resolvedId,
|
||||
role: "client",
|
||||
ip: ip || undefined,
|
||||
host: (payload as any).host || undefined,
|
||||
os: (payload as any).os || undefined,
|
||||
arch: (payload as any).arch || undefined,
|
||||
version: (payload as any).version || undefined,
|
||||
user: (payload as any).user || undefined,
|
||||
monitors: (payload as any).monitors || undefined,
|
||||
country,
|
||||
lastSeen: Date.now(),
|
||||
online: 0 as any,
|
||||
publicKey,
|
||||
keyFingerprint,
|
||||
enrollmentStatus: "pending",
|
||||
});
|
||||
|
||||
logger.info(`[purgatory] client ${resolvedId} is pending approval`);
|
||||
ws.send(encodeMessage({ type: "enrollment_status", status: "pending" }));
|
||||
deps.notifyDashboard();
|
||||
try { ws.close(4001, "pending"); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
const existingClient = clientManager.getClient(resolvedId);
|
||||
if (existingClient?.ws && existingClient.ws !== ws) {
|
||||
logger.info(`[purgatory] kicking existing socket for ${resolvedId} (superseded)`);
|
||||
try { existingClient.ws.close(4004, "superseded"); } catch {}
|
||||
clientManager.deleteClient(resolvedId);
|
||||
}
|
||||
|
||||
ws.data.wasKnown = clientExists(resolvedId);
|
||||
|
||||
const infoObj: ClientInfo = {
|
||||
id: resolvedId,
|
||||
role: "client",
|
||||
ws,
|
||||
lastSeen: Date.now(),
|
||||
country: "",
|
||||
ip,
|
||||
online: true,
|
||||
publicKey,
|
||||
keyFingerprint,
|
||||
enrollmentStatus: "approved" as any,
|
||||
};
|
||||
clientManager.addClient(resolvedId, infoObj);
|
||||
|
||||
upsertClientRow({
|
||||
id: resolvedId,
|
||||
publicKey,
|
||||
keyFingerprint,
|
||||
enrollmentStatus: "approved",
|
||||
online: 1 as any,
|
||||
lastSeen: Date.now(),
|
||||
});
|
||||
|
||||
const notificationConfig = deps.getNotificationConfig();
|
||||
ws.send(
|
||||
encodeMessage({
|
||||
type: "hello_ack",
|
||||
id: resolvedId,
|
||||
notification: {
|
||||
keywords: notificationConfig.keywords || [],
|
||||
minIntervalMs: notificationConfig.minIntervalMs || 8000,
|
||||
clipboardEnabled: notificationConfig.clipboardEnabled || false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
handleHello(infoObj, payload, ws, ip);
|
||||
clientManager.addClient(infoObj.id, infoObj);
|
||||
|
||||
deps.dispatchAutoScriptsForConnection(infoObj, ws);
|
||||
deps.notifyDashboard();
|
||||
if (info.role === "client") {
|
||||
const wasKnown = Boolean((ws as any)?.data?.wasKnown);
|
||||
|
||||
if (infoObj.role === "client") {
|
||||
deps.notifyRemoteDesktopStatus(resolvedId, "online");
|
||||
metrics.recordConnection();
|
||||
|
||||
const wasKnown = Boolean(ws.data.wasKnown);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: "system",
|
||||
ip: (ws as any)?.data?.ip || ip || "unknown",
|
||||
ip: ws.data?.ip || ip || "unknown",
|
||||
action: wasKnown ? AuditAction.CLIENT_RECONNECT : AuditAction.CLIENT_FIRST_CONNECT,
|
||||
targetClientId: info.id,
|
||||
targetClientId: infoObj.id,
|
||||
success: true,
|
||||
details: JSON.stringify({ host: info.host, os: info.os, user: info.user }),
|
||||
details: JSON.stringify({ host: infoObj.host, os: infoObj.os, user: infoObj.user }),
|
||||
});
|
||||
(ws as any).data.wasKnown = true;
|
||||
|
||||
const buildTag = typeof (payload as any).buildTag === "string" ? (payload as any).buildTag : "";
|
||||
if (buildTag) {
|
||||
deps.handleBuildTagConnection(info.id, buildTag);
|
||||
deps.handleBuildTagConnection(infoObj.id, buildTag);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ping":
|
||||
handlePing(info, payload, ws);
|
||||
handlePing(client, payload, ws);
|
||||
break;
|
||||
case "pong":
|
||||
handlePong(info, payload);
|
||||
handlePong(client, payload);
|
||||
deps.notifyDashboard();
|
||||
break;
|
||||
case "frame":
|
||||
if ((payload as any)?.header?.fps === 0) {
|
||||
const pending = deps.takePendingNotificationScreenshot(info.id);
|
||||
const pending = deps.takePendingNotificationScreenshot(client.id);
|
||||
if (pending) {
|
||||
let bytes: Uint8Array | null = null;
|
||||
if ((payload as any).data instanceof Uint8Array) {
|
||||
@@ -231,10 +395,10 @@ export function handleWebSocketMessage(
|
||||
}
|
||||
}
|
||||
}
|
||||
handleFrame(info, payload);
|
||||
handleFrame(client, payload);
|
||||
break;
|
||||
case "screenshot_result":
|
||||
deps.handleNotificationScreenshotResult(info.id, payload);
|
||||
deps.handleNotificationScreenshotResult(client.id, payload);
|
||||
break;
|
||||
case "console_output":
|
||||
deps.handleConsoleOutput(payload);
|
||||
@@ -264,30 +428,30 @@ export function handleWebSocketMessage(
|
||||
(payload as any).ok,
|
||||
(payload as any).message,
|
||||
);
|
||||
deps.handleFileBrowserMessage(info.id, payload);
|
||||
deps.handleFileBrowserMessage(client.id, payload);
|
||||
if (payloadType === "command_result" && typeof (payload as any).commandId === "string") {
|
||||
deps.handleProxyConnectResult(
|
||||
info.id,
|
||||
client.id,
|
||||
(payload as any).commandId,
|
||||
Boolean((payload as any).ok),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "command_progress":
|
||||
deps.handleFileBrowserMessage(info.id, payload);
|
||||
deps.handleFileBrowserMessage(client.id, payload);
|
||||
break;
|
||||
case "process_list_result":
|
||||
deps.handleProcessMessage(info.id, payload);
|
||||
deps.handleProcessMessage(client.id, payload);
|
||||
break;
|
||||
case "keylog_file_list":
|
||||
case "keylog_file_content":
|
||||
case "keylog_clear_result":
|
||||
case "keylog_delete_result":
|
||||
deps.handleKeyloggerMessage(info.id, payload);
|
||||
deps.handleKeyloggerMessage(client.id, payload);
|
||||
break;
|
||||
case "script_result": {
|
||||
logger.debug(
|
||||
`[script] client=${info.id} ok=${(payload as any).ok} output_length=${(payload as any).output?.length || 0}`,
|
||||
`[script] client=${client.id} ok=${(payload as any).ok} output_length=${(payload as any).output?.length || 0}`,
|
||||
);
|
||||
const cmdId = (payload as any).commandId;
|
||||
if (cmdId && deps.pendingScripts.has(cmdId)) {
|
||||
@@ -303,33 +467,33 @@ export function handleWebSocketMessage(
|
||||
break;
|
||||
}
|
||||
case "plugin_event":
|
||||
deps.handlePluginEvent(info.id, payload);
|
||||
deps.handlePluginEvent(client.id, payload);
|
||||
break;
|
||||
case "notification":
|
||||
deps.handleNotification(info.id, payload);
|
||||
deps.handleNotification(client.id, payload);
|
||||
break;
|
||||
case "voice_uplink":
|
||||
deps.handleVoiceUplink(info.id, payload);
|
||||
deps.handleVoiceUplink(client.id, payload);
|
||||
break;
|
||||
case "webcam_devices":
|
||||
deps.handleWebcamDevices(info.id, payload);
|
||||
deps.handleWebcamDevices(client.id, payload);
|
||||
break;
|
||||
case "hvnc_clone_progress":
|
||||
deps.handleHVNCCloneProgress(info.id, payload);
|
||||
deps.handleHVNCCloneProgress(client.id, payload);
|
||||
break;
|
||||
case "proxy_data": {
|
||||
const connId = (payload as any).connectionId;
|
||||
const tunnelData = (payload as any).data;
|
||||
if (typeof connId === "string" && tunnelData) {
|
||||
const bytes = tunnelData instanceof Uint8Array ? tunnelData : new Uint8Array(tunnelData);
|
||||
deps.handleProxyTunnelData(info.id, connId, bytes);
|
||||
deps.handleProxyTunnelData(client.id, connId, bytes);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "proxy_close": {
|
||||
const connId = (payload as any).connectionId;
|
||||
if (typeof connId === "string") {
|
||||
deps.handleProxyTunnelClose(info.id, connId);
|
||||
deps.handleProxyTunnelClose(client.id, connId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -351,6 +515,8 @@ export function handleWebSocketClose(
|
||||
const role = ws.data.role as string;
|
||||
const sessionId = ws.data.sessionId;
|
||||
|
||||
clearEnrollmentTimeout(clientId);
|
||||
|
||||
if (role === "console_viewer") {
|
||||
if (sessionId) {
|
||||
sessionManager.deleteConsoleSession(sessionId);
|
||||
@@ -460,6 +626,12 @@ export function handleWebSocketClose(
|
||||
return;
|
||||
}
|
||||
|
||||
const currentClient = clientManager.getClient(clientId);
|
||||
if (currentClient && currentClient.ws !== ws) {
|
||||
logger.info(`[close] ${clientId} code=${code} (superseded socket, skipping cleanup)`);
|
||||
return;
|
||||
}
|
||||
|
||||
clientManager.deleteClient(clientId);
|
||||
stopAllProxiesForClient(clientId);
|
||||
clearClientSyncState(clientId);
|
||||
|
||||
@@ -10,7 +10,7 @@ type MessageHandler<TLifecycleDeps> = (
|
||||
ws: ServerWebSocket<SocketData>,
|
||||
message: string | ArrayBuffer | Uint8Array,
|
||||
deps: TLifecycleDeps,
|
||||
) => void;
|
||||
) => void | Promise<void>;
|
||||
|
||||
type CloseHandler<TLifecycleDeps> = (
|
||||
ws: ServerWebSocket<SocketData>,
|
||||
|
||||
@@ -22,6 +22,7 @@ export type SocketData = {
|
||||
userId?: number;
|
||||
wasKnown?: boolean;
|
||||
autoTasksRan?: boolean;
|
||||
enrollmentNonce?: string;
|
||||
};
|
||||
|
||||
export type ConsoleSession = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export type ClientRole = "client" | "viewer";
|
||||
|
||||
export type EnrollmentStatus = "pending" | "approved" | "denied";
|
||||
|
||||
export type ClientInfo = {
|
||||
id: string;
|
||||
lastSeen: number;
|
||||
@@ -22,6 +24,9 @@ export type ClientInfo = {
|
||||
monitorInfo?: { width: number; height: number }[];
|
||||
country?: string;
|
||||
pingMs?: number;
|
||||
enrollmentStatus?: EnrollmentStatus;
|
||||
publicKey?: string;
|
||||
keyFingerprint?: string;
|
||||
};
|
||||
|
||||
export type ListFilters = {
|
||||
@@ -32,6 +37,7 @@ export type ListFilters = {
|
||||
statusFilter?: string;
|
||||
osFilter?: string;
|
||||
countryFilter?: string;
|
||||
enrollmentFilter?: string;
|
||||
allowedClientIds?: string[];
|
||||
deniedClientIds?: string[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user