@sleep2agi/commhub-server 0.6.0 → 0.8.0-preview.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +5 -5
- package/bin/commhub.ts +11 -2
- package/package.json +9 -3
- package/src/auth.ts +49 -6
- package/src/db-adapter.ts +7 -9
- package/src/db.ts +79 -2
- package/src/index.ts +137 -39
- package/src/password-dict.ts +100 -0
- package/src/push.ts +17 -19
- package/src/tools.ts +77 -34
package/src/index.ts
CHANGED
|
@@ -3,12 +3,26 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
|
|
|
3
3
|
import { z } from "zod/v4";
|
|
4
4
|
import { registerTools } from "./tools.js";
|
|
5
5
|
import { db, logTaskEvent, logAudit } from "./db.js";
|
|
6
|
-
import { createSSEStream, pushEvent,
|
|
7
|
-
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
6
|
+
import { createSSEStream, pushEvent, getSSEStats } from "./push.js";
|
|
7
|
+
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
8
8
|
|
|
9
9
|
const PORT = Number(process.env.PORT) || 9200;
|
|
10
|
-
const HOST = process.env.HOST || "
|
|
10
|
+
const HOST = process.env.HOST || "127.0.0.1";
|
|
11
11
|
const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
|
|
12
|
+
const DEV_OPEN = process.argv.includes("--dev-open") || process.env.COMMHUB_DEV_OPEN === "1";
|
|
13
|
+
const TMUX_ENABLED = process.env.COMMHUB_ENABLE_TMUX === "1";
|
|
14
|
+
const SECURITY_LABEL = DEV_OPEN ? "⚠️ DEV OPEN MODE" : "🔒 secured";
|
|
15
|
+
const TMUX_ALLOWLIST = new Set(
|
|
16
|
+
(process.env.COMMHUB_TMUX_ALLOWLIST || "")
|
|
17
|
+
.split(",")
|
|
18
|
+
.map((s) => s.trim())
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
);
|
|
21
|
+
let masterTokenDeprecationLogged = false;
|
|
22
|
+
|
|
23
|
+
if (AUTH_TOKEN) {
|
|
24
|
+
console.warn("[commhub] COMMHUB_AUTH_TOKEN is deprecated and will be removed in v1.0. See RFC-001.");
|
|
25
|
+
}
|
|
12
26
|
|
|
13
27
|
// Read version from package.json so banners and /health stay in sync.
|
|
14
28
|
const SERVER_VERSION = (() => {
|
|
@@ -70,10 +84,19 @@ function createServer(clientIP?: string, enforceNetworkId?: string | null, enfor
|
|
|
70
84
|
}
|
|
71
85
|
|
|
72
86
|
// ── Auth helper ─────────────────────────────────────
|
|
73
|
-
function
|
|
87
|
+
function requestToken(req: Request): string {
|
|
74
88
|
const header = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
75
89
|
const url = new URL(req.url);
|
|
76
|
-
|
|
90
|
+
return header || url.searchParams.get("token") || "";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isLegacyAuthToken(req: Request): boolean {
|
|
94
|
+
const token = requestToken(req);
|
|
95
|
+
return !!AUTH_TOKEN && token === AUTH_TOKEN;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function requireAuth(req: Request): Response | null {
|
|
99
|
+
const token = requestToken(req);
|
|
77
100
|
|
|
78
101
|
// V3: check api_tokens first
|
|
79
102
|
if (token) {
|
|
@@ -82,17 +105,55 @@ function requireAuth(req: Request): Response | null {
|
|
|
82
105
|
}
|
|
83
106
|
|
|
84
107
|
// Legacy: check global COMMHUB_AUTH_TOKEN
|
|
85
|
-
if (!AUTH_TOKEN) return null; //
|
|
86
|
-
if (token === AUTH_TOKEN)
|
|
108
|
+
if (!AUTH_TOKEN && DEV_OPEN) return null; // explicit local/dev open mode
|
|
109
|
+
if (token === AUTH_TOKEN) {
|
|
110
|
+
const u = new URL(req.url);
|
|
111
|
+
const readOnlyApi = u.pathname.startsWith("/api/") && (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS");
|
|
112
|
+
if (!readOnlyApi) return Response.json({ ok: false, error: "master-token auth is deprecated; use admin utok_" }, { status: 401 });
|
|
113
|
+
if (!masterTokenDeprecationLogged) {
|
|
114
|
+
console.warn("[commhub] master-token auth is deprecated and will be removed in v1.0. See RFC-001.");
|
|
115
|
+
masterTokenDeprecationLogged = true;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
87
119
|
|
|
88
120
|
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
89
121
|
}
|
|
90
122
|
|
|
123
|
+
function getClientIP(req: Request, server?: any): string {
|
|
124
|
+
const direct = server?.requestIP?.(req)?.address;
|
|
125
|
+
if (direct) return direct;
|
|
126
|
+
const fwd = req.headers.get("x-forwarded-for");
|
|
127
|
+
return fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isLocalhostIP(ip: string): boolean {
|
|
131
|
+
return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1" || ip === "localhost";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isTmuxAllowedIP(ip: string): boolean {
|
|
135
|
+
return isLocalhostIP(ip) || TMUX_ALLOWLIST.has(ip);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function requireAdminAuth(req: Request): Response | null {
|
|
139
|
+
const token = requestToken(req);
|
|
140
|
+
if (!token) return Response.json({ ok: false, error: "auth required" }, { status: 401 });
|
|
141
|
+
const resolved = resolveToken(token);
|
|
142
|
+
if (!resolved) return Response.json({ ok: false, error: "invalid token" }, { status: 401 });
|
|
143
|
+
if (resolved.user.role !== "admin") return Response.json({ ok: false, error: "admin required" }, { status: 403 });
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function requireTmuxAccess(req: Request, server?: any): Response | null {
|
|
148
|
+
if (!TMUX_ENABLED) return Response.json({ ok: false, error: "tmux disabled" }, { status: 404 });
|
|
149
|
+
const ip = getClientIP(req, server);
|
|
150
|
+
if (!isTmuxAllowedIP(ip)) return Response.json({ ok: false, error: "tmux access denied from this ip" }, { status: 403 });
|
|
151
|
+
return requireAdminAuth(req);
|
|
152
|
+
}
|
|
153
|
+
|
|
91
154
|
// Extract user + network + token-binding identity from request token.
|
|
92
155
|
function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string; tokenName: string | null } | null {
|
|
93
|
-
const
|
|
94
|
-
const url = new URL(req.url);
|
|
95
|
-
const token = header || url.searchParams.get("token") || "";
|
|
156
|
+
const token = requestToken(req);
|
|
96
157
|
if (!token) return null;
|
|
97
158
|
const resolved = resolveToken(token);
|
|
98
159
|
if (!resolved) return null;
|
|
@@ -243,17 +304,16 @@ Bun.serve({
|
|
|
243
304
|
// ── WebSocket: tmux terminal ──
|
|
244
305
|
const wsMatch = url.pathname.match(/^\/ws\/tmux\/([a-zA-Z0-9_-]+)$/);
|
|
245
306
|
if (wsMatch) {
|
|
246
|
-
const
|
|
247
|
-
if (
|
|
248
|
-
if (server.upgrade(req, { data: { tmuxName: wsMatch[1] } })) return;
|
|
307
|
+
const tmuxErr = requireTmuxAccess(req, server);
|
|
308
|
+
if (tmuxErr) return withCors(req, tmuxErr);
|
|
309
|
+
if (server.upgrade(req, { data: { tmuxName: wsMatch[1] } } as any)) return;
|
|
249
310
|
}
|
|
250
311
|
|
|
251
312
|
// ── MCP Streamable HTTP endpoint ──
|
|
252
313
|
if (url.pathname === "/mcp") {
|
|
253
314
|
const authErr = requireAuth(req);
|
|
254
315
|
if (authErr) return withCors(req, authErr);
|
|
255
|
-
const
|
|
256
|
-
const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
|
|
316
|
+
const clientIP = getClientIP(req, server);
|
|
257
317
|
// V3: resolve token → enforce network_id in all MCP tools.
|
|
258
318
|
// utok_ (user token, not network-bound) is allowed — the tool layer
|
|
259
319
|
// scopes to the user's accessible networks. Without this Dashboard
|
|
@@ -268,11 +328,11 @@ Bun.serve({
|
|
|
268
328
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
269
329
|
sessionIdGenerator: undefined,
|
|
270
330
|
});
|
|
271
|
-
const
|
|
272
|
-
await
|
|
331
|
+
const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias);
|
|
332
|
+
await mcpServer.connect(transport);
|
|
273
333
|
const response = await transport.handleRequest(req);
|
|
274
334
|
// Disconnect after response to prevent McpServer leak
|
|
275
|
-
setImmediate(() =>
|
|
335
|
+
setImmediate(() => mcpServer.close().catch(() => {}));
|
|
276
336
|
return response;
|
|
277
337
|
}
|
|
278
338
|
|
|
@@ -283,7 +343,31 @@ Bun.serve({
|
|
|
283
343
|
const authErr = requireAuth(req);
|
|
284
344
|
if (authErr) return authErr;
|
|
285
345
|
const sessionName = decodeURIComponent(eventsMatch[1]);
|
|
286
|
-
|
|
346
|
+
const authCtx = resolveRequestAuth(req);
|
|
347
|
+
const scopedNetId = authCtx?.networkId || url.searchParams.get("network_id");
|
|
348
|
+
if (!authCtx && isLegacyAuthToken(req)) {
|
|
349
|
+
if (scopedNetId) {
|
|
350
|
+
const session = db.get<any>(
|
|
351
|
+
"SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
352
|
+
sessionName, scopedNetId
|
|
353
|
+
);
|
|
354
|
+
if (!session) return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
|
|
355
|
+
}
|
|
356
|
+
return createSSEStream(sessionName, scopedNetId);
|
|
357
|
+
}
|
|
358
|
+
if (!authCtx || !scopedNetId) {
|
|
359
|
+
return withCors(req, Response.json({ ok: false, error: "network-scoped token required for SSE" }, { status: 403 }));
|
|
360
|
+
}
|
|
361
|
+
const role = getUserNetworkRole(authCtx.userId, scopedNetId);
|
|
362
|
+
if (!role) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
363
|
+
const session = db.get<any>(
|
|
364
|
+
"SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
365
|
+
sessionName, scopedNetId
|
|
366
|
+
);
|
|
367
|
+
if (!session && authCtx.networkId !== scopedNetId) {
|
|
368
|
+
return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
|
|
369
|
+
}
|
|
370
|
+
return createSSEStream(sessionName, scopedNetId);
|
|
287
371
|
}
|
|
288
372
|
|
|
289
373
|
// ── V3: License endpoints ──
|
|
@@ -343,14 +427,14 @@ Bun.serve({
|
|
|
343
427
|
if (url.pathname === "/api/auth/login" && req.method === "POST") {
|
|
344
428
|
const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
|
345
429
|
if (!checkRateLimit(clientIP, 10)) {
|
|
346
|
-
logAudit(null, null, "login_rate_limited", "auth",
|
|
430
|
+
logAudit(null, null, "login_rate_limited", "auth", undefined, clientIP);
|
|
347
431
|
return withCors(req, Response.json({ ok: false, error: "too many attempts, try again later" }, { status: 429 }));
|
|
348
432
|
}
|
|
349
433
|
try {
|
|
350
434
|
const body = await req.json() as any;
|
|
351
435
|
const result = login(body.username, body.password);
|
|
352
436
|
if (result.ok) logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
|
|
353
|
-
else logAudit(null, body.username, "login_failed", "user",
|
|
437
|
+
else logAudit(null, body.username, "login_failed", "user", undefined, "invalid credentials");
|
|
354
438
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
|
|
355
439
|
} catch (e: any) {
|
|
356
440
|
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
@@ -397,9 +481,14 @@ Bun.serve({
|
|
|
397
481
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
398
482
|
try {
|
|
399
483
|
const body = await req.json() as any;
|
|
400
|
-
const result = changePassword(resolved.user.user_id, body.old_password, body.new_password);
|
|
401
|
-
if (result.ok)
|
|
402
|
-
|
|
484
|
+
const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId);
|
|
485
|
+
if (result.ok) {
|
|
486
|
+
const issued = issueUserToken(resolved.user.user_id, "password-change");
|
|
487
|
+
if (resolved.tokenId) revokeToken(resolved.user.user_id, resolved.tokenId);
|
|
488
|
+
logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
|
|
489
|
+
return withCors(req, Response.json({ ...result, token: issued.token, token_id: issued.token_id }));
|
|
490
|
+
}
|
|
491
|
+
return withCors(req, Response.json(result, { status: 400 }));
|
|
403
492
|
} catch (e: any) {
|
|
404
493
|
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
405
494
|
}
|
|
@@ -631,7 +720,9 @@ Bun.serve({
|
|
|
631
720
|
sessions_count: count?.cnt ?? 0,
|
|
632
721
|
sse_connections: sse.total,
|
|
633
722
|
sse_sessions: sse.sessions,
|
|
634
|
-
auth:
|
|
723
|
+
auth: DEV_OPEN ? "dev-open" : "user-token",
|
|
724
|
+
security: DEV_OPEN ? "dev-open" : "secured",
|
|
725
|
+
tmux: TMUX_ENABLED ? "enabled" : "disabled",
|
|
635
726
|
v3_auth: true,
|
|
636
727
|
multi_network: true,
|
|
637
728
|
license: license?.type || "none",
|
|
@@ -734,7 +825,7 @@ Bun.serve({
|
|
|
734
825
|
let sessionSql = "SELECT 1 FROM sessions WHERE alias = ?1";
|
|
735
826
|
if (taskNetId) { sessionSql += " AND network_id = ?2"; sessionParams.push(taskNetId); }
|
|
736
827
|
const targetSession = db.get<any>(sessionSql, ...sessionParams);
|
|
737
|
-
if (targetSession) pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession });
|
|
828
|
+
if (targetSession) pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession }, taskNetId);
|
|
738
829
|
return withCors(req, Response.json({ ok: true, message_id: id }));
|
|
739
830
|
}
|
|
740
831
|
|
|
@@ -773,13 +864,17 @@ Bun.serve({
|
|
|
773
864
|
);
|
|
774
865
|
ids.push(id);
|
|
775
866
|
}
|
|
776
|
-
|
|
867
|
+
for (const t of targets) {
|
|
868
|
+
pushEvent(t.alias, { type: "broadcast", inbox_count: 1 }, t.network_id);
|
|
869
|
+
}
|
|
777
870
|
return withCors(req, Response.json({ ok: true, recipients: targets.length, message_ids: ids }));
|
|
778
871
|
}
|
|
779
872
|
|
|
780
873
|
// ── REST: tmux capture-pane ──
|
|
781
874
|
const tmuxCapture = url.pathname.match(/^\/api\/tmux\/([a-zA-Z0-9_-]+)$/);
|
|
782
875
|
if (tmuxCapture && req.method === "GET") {
|
|
876
|
+
const tmuxErr = requireTmuxAccess(req, server);
|
|
877
|
+
if (tmuxErr) return withCors(req, tmuxErr);
|
|
783
878
|
const name = tmuxCapture[1];
|
|
784
879
|
const lines = Number(url.searchParams.get("lines")) || 30;
|
|
785
880
|
try {
|
|
@@ -802,6 +897,8 @@ Bun.serve({
|
|
|
802
897
|
// ── REST: tmux send-keys ──
|
|
803
898
|
const tmuxSend = url.pathname.match(/^\/api\/tmux\/([a-zA-Z0-9_-]+)\/send$/);
|
|
804
899
|
if (tmuxSend && req.method === "POST") {
|
|
900
|
+
const tmuxErr = requireTmuxAccess(req, server);
|
|
901
|
+
if (tmuxErr) return withCors(req, tmuxErr);
|
|
805
902
|
const name = tmuxSend[1];
|
|
806
903
|
let body: { text?: string; enter?: boolean };
|
|
807
904
|
try { body = await req.json(); } catch {
|
|
@@ -988,14 +1085,14 @@ Endpoints:
|
|
|
988
1085
|
POST /mcp - MCP Streamable HTTP (for Claude Code / Codex)
|
|
989
1086
|
GET /events/:session - SSE realtime push (Agent subscribes here)
|
|
990
1087
|
GET /health - Health check
|
|
991
|
-
GET /api/status - All sessions
|
|
992
|
-
POST /api/task - Send task via REST
|
|
993
|
-
GET /api/tasks - Tasks table (V2)
|
|
994
|
-
GET /api/completions - Recent completions
|
|
995
|
-
GET /api/tmux/:name - Capture tmux pane output ${
|
|
996
|
-
POST /api/tmux/:name/send - Send keys to tmux ${
|
|
997
|
-
|
|
998
|
-
|
|
1088
|
+
GET /api/status - All sessions (auth required)
|
|
1089
|
+
POST /api/task - Send task via REST (auth required)
|
|
1090
|
+
GET /api/tasks - Tasks table (V2) (auth required)
|
|
1091
|
+
GET /api/completions - Recent completions (auth required)
|
|
1092
|
+
GET /api/tmux/:name - Capture tmux pane output (${TMUX_ENABLED ? "admin + localhost/allowlist required" : "disabled"})
|
|
1093
|
+
POST /api/tmux/:name/send - Send keys to tmux (${TMUX_ENABLED ? "admin + localhost/allowlist required" : "disabled"})
|
|
1094
|
+
|
|
1095
|
+
Security: ${SECURITY_LABEL}
|
|
999
1096
|
`,
|
|
1000
1097
|
{ status: 200, headers: { "Content-Type": "text/plain" } }
|
|
1001
1098
|
));
|
|
@@ -1004,7 +1101,7 @@ Auth: ${AUTH_TOKEN ? "Bearer token enabled (set COMMHUB_AUTH_TOKEN)" : "disabled
|
|
|
1004
1101
|
// ── WebSocket handler for tmux terminal streaming ──
|
|
1005
1102
|
websocket: {
|
|
1006
1103
|
open(ws) {
|
|
1007
|
-
const { tmuxName } = ws.data as { tmuxName: string };
|
|
1104
|
+
const { tmuxName } = ws.data as unknown as { tmuxName: string };
|
|
1008
1105
|
console.log(`[ws] tmux terminal opened: ${tmuxName}`);
|
|
1009
1106
|
let lastOutput = "";
|
|
1010
1107
|
|
|
@@ -1049,7 +1146,7 @@ Auth: ${AUTH_TOKEN ? "Bearer token enabled (set COMMHUB_AUTH_TOKEN)" : "disabled
|
|
|
1049
1146
|
},
|
|
1050
1147
|
|
|
1051
1148
|
async message(ws, message) {
|
|
1052
|
-
const { tmuxName } = ws.data as { tmuxName: string };
|
|
1149
|
+
const { tmuxName } = ws.data as unknown as { tmuxName: string };
|
|
1053
1150
|
try {
|
|
1054
1151
|
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message));
|
|
1055
1152
|
|
|
@@ -1075,7 +1172,7 @@ Auth: ${AUTH_TOKEN ? "Bearer token enabled (set COMMHUB_AUTH_TOKEN)" : "disabled
|
|
|
1075
1172
|
},
|
|
1076
1173
|
|
|
1077
1174
|
close(ws) {
|
|
1078
|
-
const { tmuxName } = ws.data as { tmuxName: string };
|
|
1175
|
+
const { tmuxName } = ws.data as unknown as { tmuxName: string };
|
|
1079
1176
|
console.log(`[ws] tmux terminal closed: ${tmuxName}`);
|
|
1080
1177
|
const interval = wsTmuxIntervals.get(ws);
|
|
1081
1178
|
if (interval) { clearInterval(interval); wsTmuxIntervals.delete(ws); }
|
|
@@ -1096,7 +1193,8 @@ console.log(`
|
|
|
1096
1193
|
╔══════════════════════════════════════════════════╗
|
|
1097
1194
|
║ CommHub MCP Server v${SERVER_VERSION} ║
|
|
1098
1195
|
║ Transport: Streamable HTTP (Bun native) ║
|
|
1099
|
-
║
|
|
1196
|
+
║ Security: ${SECURITY_LABEL}${" ".repeat(Math.max(0, 33 - SECURITY_LABEL.length))}║
|
|
1197
|
+
║ Tmux: ${TMUX_ENABLED ? "ENABLED (admin + localhost/allowlist)" : "DISABLED (set COMMHUB_ENABLE_TMUX=1)"}${" ".repeat(Math.max(0, TMUX_ENABLED ? 0 : 2))}║
|
|
1100
1198
|
║ ║
|
|
1101
1199
|
║ MCP: http://${HOST}:${PORT}/mcp ║
|
|
1102
1200
|
║ REST: http://${HOST}:${PORT}/api ║
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const WEAK_PASSWORDS_RAW = `
|
|
2
|
+
123456
|
|
3
|
+
password
|
|
4
|
+
123456789
|
|
5
|
+
12345
|
|
6
|
+
12345678
|
|
7
|
+
qwerty
|
|
8
|
+
1234567
|
|
9
|
+
111111
|
|
10
|
+
1234567890
|
|
11
|
+
123123
|
|
12
|
+
abc123
|
|
13
|
+
1234
|
|
14
|
+
password1
|
|
15
|
+
iloveyou
|
|
16
|
+
1q2w3e4r
|
|
17
|
+
000000
|
|
18
|
+
qwerty123
|
|
19
|
+
zaq12wsx
|
|
20
|
+
dragon
|
|
21
|
+
sunshine
|
|
22
|
+
princess
|
|
23
|
+
letmein
|
|
24
|
+
monkey
|
|
25
|
+
football
|
|
26
|
+
baseball
|
|
27
|
+
welcome
|
|
28
|
+
admin
|
|
29
|
+
login
|
|
30
|
+
master
|
|
31
|
+
hello
|
|
32
|
+
freedom
|
|
33
|
+
whatever
|
|
34
|
+
trustno1
|
|
35
|
+
qazwsx
|
|
36
|
+
654321
|
|
37
|
+
superman
|
|
38
|
+
batman
|
|
39
|
+
passw0rd
|
|
40
|
+
password123
|
|
41
|
+
asdfgh
|
|
42
|
+
zxcvbnm
|
|
43
|
+
qwertyuiop
|
|
44
|
+
1qaz2wsx
|
|
45
|
+
lovely
|
|
46
|
+
flower
|
|
47
|
+
hunter
|
|
48
|
+
shadow
|
|
49
|
+
buster
|
|
50
|
+
soccer
|
|
51
|
+
hockey
|
|
52
|
+
killer
|
|
53
|
+
george
|
|
54
|
+
charlie
|
|
55
|
+
andrew
|
|
56
|
+
michael
|
|
57
|
+
jessica
|
|
58
|
+
michelle
|
|
59
|
+
pepper
|
|
60
|
+
jordan
|
|
61
|
+
harley
|
|
62
|
+
ranger
|
|
63
|
+
ginger
|
|
64
|
+
joshua
|
|
65
|
+
maggie
|
|
66
|
+
mustang
|
|
67
|
+
computer
|
|
68
|
+
internet
|
|
69
|
+
secret
|
|
70
|
+
summer
|
|
71
|
+
winter
|
|
72
|
+
spring
|
|
73
|
+
autumn
|
|
74
|
+
orange
|
|
75
|
+
banana
|
|
76
|
+
cookie
|
|
77
|
+
coffee
|
|
78
|
+
matrix
|
|
79
|
+
starwars
|
|
80
|
+
pokemon
|
|
81
|
+
naruto
|
|
82
|
+
888888
|
|
83
|
+
666666
|
|
84
|
+
5201314
|
|
85
|
+
121212
|
|
86
|
+
112233
|
|
87
|
+
159753
|
|
88
|
+
987654321
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
const weak = new Set(WEAK_PASSWORDS_RAW.trim().split(/\s+/).map((p) => p.toLowerCase()));
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i <= 999; i++) {
|
|
94
|
+
weak.add(String(i).padStart(6, "0"));
|
|
95
|
+
weak.add(`password${i}`);
|
|
96
|
+
weak.add(`qwerty${i}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const WEAK_PASSWORDS = weak;
|
|
100
|
+
|
package/src/push.ts
CHANGED
|
@@ -15,23 +15,24 @@ function ts(): string {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** 创建 SSE Response 并注册到 clients map */
|
|
18
|
-
export function createSSEStream(sessionName: string): Response {
|
|
18
|
+
export function createSSEStream(sessionName: string, networkId?: string | null): Response {
|
|
19
19
|
const encoder = new TextEncoder();
|
|
20
20
|
let ctrl: ReadableStreamDefaultController;
|
|
21
|
+
const key = clientKey(sessionName, networkId);
|
|
21
22
|
|
|
22
23
|
const stream = new ReadableStream({
|
|
23
24
|
start(controller) {
|
|
24
25
|
ctrl = controller;
|
|
25
26
|
const client: SSEClient = { controller, encoder };
|
|
26
27
|
|
|
27
|
-
if (!clients.has(
|
|
28
|
-
clients.set(
|
|
28
|
+
if (!clients.has(key)) {
|
|
29
|
+
clients.set(key, []);
|
|
29
30
|
}
|
|
30
|
-
clients.get(
|
|
31
|
-
console.log(`[${ts()}] SSE ← ${
|
|
31
|
+
clients.get(key)!.push(client);
|
|
32
|
+
console.log(`[${ts()}] SSE ← ${key} connected (${clients.get(key)!.length} clients)`);
|
|
32
33
|
|
|
33
34
|
// 发送初始心跳
|
|
34
|
-
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "connected", session: sessionName })}\n\n`));
|
|
35
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "connected", session: sessionName, network_id: networkId ?? null })}\n\n`));
|
|
35
36
|
|
|
36
37
|
// Periodic keepalive every 30s to prevent proxy/LB idle timeout
|
|
37
38
|
const keepalive = setInterval(() => {
|
|
@@ -45,15 +46,15 @@ export function createSSEStream(sessionName: string): Response {
|
|
|
45
46
|
},
|
|
46
47
|
cancel() {
|
|
47
48
|
// 断线清理
|
|
48
|
-
const arr = clients.get(
|
|
49
|
+
const arr = clients.get(key);
|
|
49
50
|
if (arr) {
|
|
50
51
|
const idx = arr.findIndex(c => c.controller === ctrl);
|
|
51
52
|
if (idx !== -1) {
|
|
52
53
|
clearInterval((arr[idx] as any)._keepalive);
|
|
53
54
|
arr.splice(idx, 1);
|
|
54
55
|
}
|
|
55
|
-
if (arr.length === 0) clients.delete(
|
|
56
|
-
console.log(`[${ts()}] SSE ✕ ${
|
|
56
|
+
if (arr.length === 0) clients.delete(key);
|
|
57
|
+
console.log(`[${ts()}] SSE ✕ ${key} disconnected (${arr.length} remaining)`);
|
|
57
58
|
}
|
|
58
59
|
},
|
|
59
60
|
});
|
|
@@ -67,9 +68,13 @@ export function createSSEStream(sessionName: string): Response {
|
|
|
67
68
|
});
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
function clientKey(sessionName: string, networkId?: string | null): string {
|
|
72
|
+
return `${networkId || "global"}:${sessionName}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
70
75
|
/** 推送事件给指定 session 的所有 SSE 连接 */
|
|
71
|
-
export function pushEvent(sessionName: string, event: Record<string, unknown
|
|
72
|
-
const arr = clients.get(sessionName);
|
|
76
|
+
export function pushEvent(sessionName: string, event: Record<string, unknown>, networkId?: string | null): void {
|
|
77
|
+
const arr = clients.get(clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined)));
|
|
73
78
|
if (!arr || arr.length === 0) return;
|
|
74
79
|
|
|
75
80
|
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
@@ -87,14 +92,7 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>):
|
|
|
87
92
|
for (let i = dead.length - 1; i >= 0; i--) {
|
|
88
93
|
arr.splice(dead[i], 1);
|
|
89
94
|
}
|
|
90
|
-
if (arr.length === 0) clients.delete(sessionName);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** 广播给多个 session */
|
|
94
|
-
export function pushBroadcast(sessionNames: string[], event: Record<string, unknown>): void {
|
|
95
|
-
for (const name of sessionNames) {
|
|
96
|
-
pushEvent(name, event);
|
|
97
|
-
}
|
|
95
|
+
if (arr.length === 0) clients.delete(clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined)));
|
|
98
96
|
}
|
|
99
97
|
|
|
100
98
|
/** 获取当前 SSE 连接统计 */
|