@delt/claude-alarm 0.8.2 → 0.9.1
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/README.md +43 -4
- package/dist/channel/server.js +69 -5
- package/dist/channel/server.js.map +1 -1
- package/dist/cli.js +280 -69
- package/dist/cli.js.map +1 -1
- package/dist/dashboard/index.html +239 -42
- package/dist/hub/server.js +160 -33
- package/dist/hub/server.js.map +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +164 -35
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/src/dashboard/index.html +239 -42
package/dist/hub/server.js
CHANGED
|
@@ -41,6 +41,12 @@ var UPLOADS_DIR = path.join(CONFIG_DIR, "uploads");
|
|
|
41
41
|
var WS_PATH_CHANNEL = "/ws/channel";
|
|
42
42
|
var WS_PATH_DASHBOARD = "/ws/dashboard";
|
|
43
43
|
|
|
44
|
+
// src/shared/peer-name.ts
|
|
45
|
+
var SAFE_PEER_NAME = /^[^\u0000-\u001f\u007f"\\[\]]{1,128}$/;
|
|
46
|
+
function isValidPeerName(name) {
|
|
47
|
+
return typeof name === "string" && name.trim() !== "" && SAFE_PEER_NAME.test(name);
|
|
48
|
+
}
|
|
49
|
+
|
|
44
50
|
// src/hub/session-manager.ts
|
|
45
51
|
var SessionManager = class {
|
|
46
52
|
sessions = /* @__PURE__ */ new Map();
|
|
@@ -64,7 +70,7 @@ var SessionManager = class {
|
|
|
64
70
|
} else {
|
|
65
71
|
session.displayName = baseName;
|
|
66
72
|
}
|
|
67
|
-
this.sessions.set(session.id, { ...session });
|
|
73
|
+
this.sessions.set(session.id, { ...session, peerName: isValidPeerName(session.peerName) ? session.peerName : void 0 });
|
|
68
74
|
}
|
|
69
75
|
unregister(sessionId) {
|
|
70
76
|
const session = this.sessions.get(sessionId);
|
|
@@ -79,6 +85,13 @@ var SessionManager = class {
|
|
|
79
85
|
}
|
|
80
86
|
return session;
|
|
81
87
|
}
|
|
88
|
+
setPeerName(sessionId, peerName) {
|
|
89
|
+
const session = this.sessions.get(sessionId);
|
|
90
|
+
if (session) {
|
|
91
|
+
session.peerName = isValidPeerName(peerName) ? peerName : void 0;
|
|
92
|
+
}
|
|
93
|
+
return session;
|
|
94
|
+
}
|
|
82
95
|
updateActivity(sessionId) {
|
|
83
96
|
const session = this.sessions.get(sessionId);
|
|
84
97
|
if (session) {
|
|
@@ -508,6 +521,7 @@ ${previewHtml}${truncNote}`;
|
|
|
508
521
|
}
|
|
509
522
|
async handleCallbackQuery(query) {
|
|
510
523
|
if (!query.data) return;
|
|
524
|
+
if (String(query.message?.chat.id) !== String(this.config.chatId)) return;
|
|
511
525
|
if (query.data.startsWith("sess:")) {
|
|
512
526
|
await this.handleSessionSelectCallback(query);
|
|
513
527
|
return;
|
|
@@ -747,8 +761,66 @@ function logStartup(context) {
|
|
|
747
761
|
`);
|
|
748
762
|
}
|
|
749
763
|
|
|
764
|
+
// src/hub/auth.ts
|
|
765
|
+
import crypto from "crypto";
|
|
766
|
+
var SESSION_COOKIE = "ca_session";
|
|
767
|
+
var COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
|
|
768
|
+
function safeEqual(a, b) {
|
|
769
|
+
if (typeof a !== "string") return false;
|
|
770
|
+
const x = Buffer.from(a);
|
|
771
|
+
const y = Buffer.from(b);
|
|
772
|
+
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
773
|
+
}
|
|
774
|
+
function dashboardCookieValue(token) {
|
|
775
|
+
return crypto.createHmac("sha256", token).update("claude-alarm-dashboard").digest("hex");
|
|
776
|
+
}
|
|
777
|
+
function parseCookies(header) {
|
|
778
|
+
const out = {};
|
|
779
|
+
for (const part of (header ?? "").split(";")) {
|
|
780
|
+
const i = part.indexOf("=");
|
|
781
|
+
if (i <= 0) continue;
|
|
782
|
+
const raw = part.slice(i + 1).trim();
|
|
783
|
+
let value = raw;
|
|
784
|
+
try {
|
|
785
|
+
value = decodeURIComponent(raw);
|
|
786
|
+
} catch {
|
|
787
|
+
}
|
|
788
|
+
out[part.slice(0, i).trim()] = value;
|
|
789
|
+
}
|
|
790
|
+
return out;
|
|
791
|
+
}
|
|
792
|
+
function sessionCookieHeader(token, secure) {
|
|
793
|
+
return `${SESSION_COOKIE}=${dashboardCookieValue(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${COOKIE_MAX_AGE}${secure ? "; Secure" : ""}`;
|
|
794
|
+
}
|
|
795
|
+
function isAuthorized(req, token, allowQueryToken) {
|
|
796
|
+
const auth = req.headers.authorization;
|
|
797
|
+
if (auth?.startsWith("Bearer ") && safeEqual(auth.slice(7), token)) return true;
|
|
798
|
+
if (safeEqual(parseCookies(req.headers.cookie)[SESSION_COOKIE], dashboardCookieValue(token))) return true;
|
|
799
|
+
if (allowQueryToken) {
|
|
800
|
+
const q = new URL(req.url ?? "/", "http://hub").searchParams.get("token");
|
|
801
|
+
if (safeEqual(q, token)) return true;
|
|
802
|
+
}
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
function isCrossOrigin(req) {
|
|
806
|
+
const origin = req.headers.origin;
|
|
807
|
+
if (!origin) return false;
|
|
808
|
+
try {
|
|
809
|
+
return new URL(origin).host !== req.headers.host;
|
|
810
|
+
} catch {
|
|
811
|
+
return true;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
function isJsonRequest(req) {
|
|
815
|
+
return String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json");
|
|
816
|
+
}
|
|
817
|
+
function isSecureRequest(req) {
|
|
818
|
+
return req.headers["x-forwarded-proto"] === "https";
|
|
819
|
+
}
|
|
820
|
+
|
|
750
821
|
// src/hub/server.ts
|
|
751
822
|
var __dirname = path4.dirname(fileURLToPath(import.meta.url));
|
|
823
|
+
var MAX_WS_PAYLOAD = 16 * 1024 * 1024;
|
|
752
824
|
var HubServer = class {
|
|
753
825
|
httpServer;
|
|
754
826
|
wssChannel;
|
|
@@ -766,6 +838,7 @@ var HubServer = class {
|
|
|
766
838
|
heartbeatInterval;
|
|
767
839
|
channelAlive = /* @__PURE__ */ new Map();
|
|
768
840
|
// sessionId -> alive flag
|
|
841
|
+
socketOwners = /* @__PURE__ */ new WeakMap();
|
|
769
842
|
host;
|
|
770
843
|
port;
|
|
771
844
|
token;
|
|
@@ -788,22 +861,23 @@ var HubServer = class {
|
|
|
788
861
|
this.initTelegram(fullConfig.telegram);
|
|
789
862
|
}
|
|
790
863
|
this.httpServer = http.createServer((req, res) => this.handleHttp(req, res));
|
|
791
|
-
this.wssChannel = new WebSocketServer({ noServer: true });
|
|
864
|
+
this.wssChannel = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
|
|
792
865
|
this.wssChannel.on("connection", (ws, req) => this.handleChannelConnection(ws, req));
|
|
793
866
|
this.wssChannel.on("error", (err) => logger.warn(`Channel WebSocket server error: ${err.message}`));
|
|
794
|
-
this.wssDashboard = new WebSocketServer({ noServer: true });
|
|
867
|
+
this.wssDashboard = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
|
|
795
868
|
this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
|
|
796
869
|
this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
|
|
797
870
|
this.httpServer.on("upgrade", (req, socket, head) => {
|
|
798
871
|
socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
|
|
799
|
-
const url = new URL(req.url,
|
|
872
|
+
const url = new URL(req.url ?? "/", "http://hub");
|
|
800
873
|
const pathname = url.pathname;
|
|
801
|
-
if (
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
874
|
+
if (isCrossOrigin(req)) {
|
|
875
|
+
this.rejectUpgrade(socket, 403, "Forbidden", pathname, req);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (!this.authorized(req, true)) {
|
|
879
|
+
this.rejectUpgrade(socket, 401, "Unauthorized", pathname, req);
|
|
880
|
+
return;
|
|
807
881
|
}
|
|
808
882
|
if (pathname === WS_PATH_CHANNEL) {
|
|
809
883
|
this.wssChannel.handleUpgrade(req, socket, head, (ws) => {
|
|
@@ -855,31 +929,28 @@ var HubServer = class {
|
|
|
855
929
|
}
|
|
856
930
|
// --- HTTP Handler ---
|
|
857
931
|
handleHttp(req, res) {
|
|
858
|
-
const url = new URL(req.url,
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
862
|
-
}
|
|
863
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
864
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
865
|
-
if (req.method === "OPTIONS") {
|
|
866
|
-
res.writeHead(204);
|
|
867
|
-
res.end();
|
|
932
|
+
const url = new URL(req.url ?? "/", "http://hub");
|
|
933
|
+
if (isCrossOrigin(req)) {
|
|
934
|
+
this.jsonResponse(res, 403, { error: "Cross-origin request rejected" });
|
|
868
935
|
return;
|
|
869
936
|
}
|
|
870
|
-
if (
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
874
|
-
if (bearerToken !== this.token) {
|
|
875
|
-
this.jsonResponse(res, 401, { error: "Unauthorized" });
|
|
876
|
-
return;
|
|
877
|
-
}
|
|
878
|
-
}
|
|
937
|
+
if (req.method === "POST" && !isJsonRequest(req)) {
|
|
938
|
+
this.jsonResponse(res, 415, { error: "Content-Type must be application/json" });
|
|
939
|
+
return;
|
|
879
940
|
}
|
|
880
941
|
if (url.pathname === "/" && req.method === "GET") {
|
|
881
|
-
this.serveDashboard(res);
|
|
882
|
-
|
|
942
|
+
this.serveDashboard(req, res, url);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (url.pathname === "/api/login" && req.method === "POST") {
|
|
946
|
+
this.handleLogin(req, res);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (!this.authorized(req, false)) {
|
|
950
|
+
this.jsonResponse(res, 401, { error: "Unauthorized" });
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (url.pathname === "/api/sessions" && req.method === "GET") {
|
|
883
954
|
this.jsonResponse(res, 200, { sessions: this.sessions.getAll() });
|
|
884
955
|
} else if (url.pathname === "/api/status" && req.method === "GET") {
|
|
885
956
|
this.jsonResponse(res, 200, {
|
|
@@ -930,7 +1001,13 @@ var HubServer = class {
|
|
|
930
1001
|
this.jsonResponse(res, 404, { error: "Not found" });
|
|
931
1002
|
}
|
|
932
1003
|
}
|
|
933
|
-
serveDashboard(res) {
|
|
1004
|
+
serveDashboard(req, res, url) {
|
|
1005
|
+
const linkToken = url.searchParams.get("token");
|
|
1006
|
+
if (linkToken !== null && this.token && safeEqual(linkToken, this.token)) {
|
|
1007
|
+
res.writeHead(302, { Location: "/", "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
|
|
1008
|
+
res.end();
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
934
1011
|
const candidates = [
|
|
935
1012
|
path4.join(__dirname, "..", "dashboard", "index.html"),
|
|
936
1013
|
// from dist/hub/
|
|
@@ -957,6 +1034,20 @@ var HubServer = class {
|
|
|
957
1034
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
958
1035
|
res.end("<html><body><h1>claude-alarm</h1><p>Dashboard HTML not found. Reinstall the package.</p></body></html>");
|
|
959
1036
|
}
|
|
1037
|
+
async handleLogin(req, res) {
|
|
1038
|
+
const body = await this.readBody(req);
|
|
1039
|
+
if (!this.token) {
|
|
1040
|
+
res.writeHead(204);
|
|
1041
|
+
res.end();
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (typeof body?.token !== "string" || !safeEqual(body.token, this.token)) {
|
|
1045
|
+
this.jsonResponse(res, 401, { error: "Unauthorized" });
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
res.writeHead(204, { "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
|
|
1049
|
+
res.end();
|
|
1050
|
+
}
|
|
960
1051
|
async handleApiSend(req, res) {
|
|
961
1052
|
const body = await this.readBody(req);
|
|
962
1053
|
if (!body) {
|
|
@@ -1033,6 +1124,22 @@ var HubServer = class {
|
|
|
1033
1124
|
});
|
|
1034
1125
|
}
|
|
1035
1126
|
handleChannelMessage(ws, isLocal, msg) {
|
|
1127
|
+
if (msg.type === "register") {
|
|
1128
|
+
const id = msg.session.id;
|
|
1129
|
+
const owned = this.socketOwners.get(ws);
|
|
1130
|
+
if (owned && owned !== id) {
|
|
1131
|
+
logger.warn(`Rejected register for ${id}: connection already owns ${owned}`);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const holder = this.channelSockets.get(id);
|
|
1135
|
+
if (holder && holder !== ws) {
|
|
1136
|
+
this.socketOwners.delete(holder);
|
|
1137
|
+
holder.terminate();
|
|
1138
|
+
}
|
|
1139
|
+
this.socketOwners.set(ws, id);
|
|
1140
|
+
} else if ("sessionId" in msg && this.socketOwners.get(ws) !== msg.sessionId) {
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1036
1143
|
switch (msg.type) {
|
|
1037
1144
|
case "register": {
|
|
1038
1145
|
const session = msg.session;
|
|
@@ -1041,7 +1148,7 @@ var HubServer = class {
|
|
|
1041
1148
|
this.sessions.register(session);
|
|
1042
1149
|
this.channelSockets.set(session.id, ws);
|
|
1043
1150
|
if (isLocal) this.localChannels.add(session.id);
|
|
1044
|
-
logger.info(`Session registered: ${session.id} (${session.name}, channel: ${session.channelEnabled ?? false})`);
|
|
1151
|
+
logger.info(`Session registered: ${session.id} (${session.name}, channel: ${session.channelEnabled ?? false}, peer: ${session.peerName ?? "-"})`);
|
|
1045
1152
|
this.broadcastToDashboards({
|
|
1046
1153
|
type: isReregister ? "session_updated" : "session_connected",
|
|
1047
1154
|
session
|
|
@@ -1055,6 +1162,14 @@ var HubServer = class {
|
|
|
1055
1162
|
}
|
|
1056
1163
|
break;
|
|
1057
1164
|
}
|
|
1165
|
+
case "peer_name": {
|
|
1166
|
+
const updated = this.sessions.setPeerName(msg.sessionId, msg.peerName);
|
|
1167
|
+
if (updated) {
|
|
1168
|
+
logger.info(`Peer name for ${msg.sessionId}: ${msg.peerName ?? "-"}`);
|
|
1169
|
+
this.broadcastToDashboards({ type: "session_updated", session: updated });
|
|
1170
|
+
}
|
|
1171
|
+
break;
|
|
1172
|
+
}
|
|
1058
1173
|
case "notify": {
|
|
1059
1174
|
this.sessions.updateActivity(msg.sessionId);
|
|
1060
1175
|
const notifySession = this.sessions.get(msg.sessionId);
|
|
@@ -1375,6 +1490,18 @@ var HubServer = class {
|
|
|
1375
1490
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1376
1491
|
res.end(JSON.stringify(body));
|
|
1377
1492
|
}
|
|
1493
|
+
authorized(req, allowQueryToken) {
|
|
1494
|
+
return !this.token || isAuthorized(req, this.token, allowQueryToken);
|
|
1495
|
+
}
|
|
1496
|
+
rejectUpgrade(socket, status, text, pathname, req) {
|
|
1497
|
+
logger.warn(`Rejected ${pathname} upgrade (${status}) from ${req.socket.remoteAddress}`);
|
|
1498
|
+
socket.write(`HTTP/1.1 ${status} ${text}\r
|
|
1499
|
+
Connection: close\r
|
|
1500
|
+
Content-Length: 0\r
|
|
1501
|
+
\r
|
|
1502
|
+
`);
|
|
1503
|
+
socket.destroy();
|
|
1504
|
+
}
|
|
1378
1505
|
isLocalRequest(req) {
|
|
1379
1506
|
const addr = req.socket.remoteAddress;
|
|
1380
1507
|
return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|