@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/index.js
CHANGED
|
@@ -41,6 +41,12 @@ var WS_PATH_DASHBOARD = "/ws/dashboard";
|
|
|
41
41
|
var CHANNEL_SERVER_NAME = "claude-alarm";
|
|
42
42
|
var CHANNEL_SERVER_VERSION = "0.1.0";
|
|
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;
|
|
@@ -771,8 +785,66 @@ function logStartup(context) {
|
|
|
771
785
|
`);
|
|
772
786
|
}
|
|
773
787
|
|
|
788
|
+
// src/hub/auth.ts
|
|
789
|
+
import crypto from "crypto";
|
|
790
|
+
var SESSION_COOKIE = "ca_session";
|
|
791
|
+
var COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
|
|
792
|
+
function safeEqual(a, b) {
|
|
793
|
+
if (typeof a !== "string") return false;
|
|
794
|
+
const x = Buffer.from(a);
|
|
795
|
+
const y = Buffer.from(b);
|
|
796
|
+
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
797
|
+
}
|
|
798
|
+
function dashboardCookieValue(token) {
|
|
799
|
+
return crypto.createHmac("sha256", token).update("claude-alarm-dashboard").digest("hex");
|
|
800
|
+
}
|
|
801
|
+
function parseCookies(header) {
|
|
802
|
+
const out = {};
|
|
803
|
+
for (const part of (header ?? "").split(";")) {
|
|
804
|
+
const i = part.indexOf("=");
|
|
805
|
+
if (i <= 0) continue;
|
|
806
|
+
const raw = part.slice(i + 1).trim();
|
|
807
|
+
let value = raw;
|
|
808
|
+
try {
|
|
809
|
+
value = decodeURIComponent(raw);
|
|
810
|
+
} catch {
|
|
811
|
+
}
|
|
812
|
+
out[part.slice(0, i).trim()] = value;
|
|
813
|
+
}
|
|
814
|
+
return out;
|
|
815
|
+
}
|
|
816
|
+
function sessionCookieHeader(token, secure) {
|
|
817
|
+
return `${SESSION_COOKIE}=${dashboardCookieValue(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${COOKIE_MAX_AGE}${secure ? "; Secure" : ""}`;
|
|
818
|
+
}
|
|
819
|
+
function isAuthorized(req, token, allowQueryToken) {
|
|
820
|
+
const auth = req.headers.authorization;
|
|
821
|
+
if (auth?.startsWith("Bearer ") && safeEqual(auth.slice(7), token)) return true;
|
|
822
|
+
if (safeEqual(parseCookies(req.headers.cookie)[SESSION_COOKIE], dashboardCookieValue(token))) return true;
|
|
823
|
+
if (allowQueryToken) {
|
|
824
|
+
const q = new URL(req.url ?? "/", "http://hub").searchParams.get("token");
|
|
825
|
+
if (safeEqual(q, token)) return true;
|
|
826
|
+
}
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
function isCrossOrigin(req) {
|
|
830
|
+
const origin = req.headers.origin;
|
|
831
|
+
if (!origin) return false;
|
|
832
|
+
try {
|
|
833
|
+
return new URL(origin).host !== req.headers.host;
|
|
834
|
+
} catch {
|
|
835
|
+
return true;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
function isJsonRequest(req) {
|
|
839
|
+
return String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json");
|
|
840
|
+
}
|
|
841
|
+
function isSecureRequest(req) {
|
|
842
|
+
return req.headers["x-forwarded-proto"] === "https";
|
|
843
|
+
}
|
|
844
|
+
|
|
774
845
|
// src/hub/server.ts
|
|
775
846
|
var __dirname = path4.dirname(fileURLToPath(import.meta.url));
|
|
847
|
+
var MAX_WS_PAYLOAD = 16 * 1024 * 1024;
|
|
776
848
|
var HubServer = class {
|
|
777
849
|
httpServer;
|
|
778
850
|
wssChannel;
|
|
@@ -790,6 +862,7 @@ var HubServer = class {
|
|
|
790
862
|
heartbeatInterval;
|
|
791
863
|
channelAlive = /* @__PURE__ */ new Map();
|
|
792
864
|
// sessionId -> alive flag
|
|
865
|
+
socketOwners = /* @__PURE__ */ new WeakMap();
|
|
793
866
|
host;
|
|
794
867
|
port;
|
|
795
868
|
token;
|
|
@@ -812,22 +885,23 @@ var HubServer = class {
|
|
|
812
885
|
this.initTelegram(fullConfig.telegram);
|
|
813
886
|
}
|
|
814
887
|
this.httpServer = http.createServer((req, res) => this.handleHttp(req, res));
|
|
815
|
-
this.wssChannel = new WebSocketServer({ noServer: true });
|
|
888
|
+
this.wssChannel = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
|
|
816
889
|
this.wssChannel.on("connection", (ws, req) => this.handleChannelConnection(ws, req));
|
|
817
890
|
this.wssChannel.on("error", (err) => logger.warn(`Channel WebSocket server error: ${err.message}`));
|
|
818
|
-
this.wssDashboard = new WebSocketServer({ noServer: true });
|
|
891
|
+
this.wssDashboard = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
|
|
819
892
|
this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
|
|
820
893
|
this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
|
|
821
894
|
this.httpServer.on("upgrade", (req, socket, head) => {
|
|
822
895
|
socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
|
|
823
|
-
const url = new URL(req.url,
|
|
896
|
+
const url = new URL(req.url ?? "/", "http://hub");
|
|
824
897
|
const pathname = url.pathname;
|
|
825
|
-
if (
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
898
|
+
if (isCrossOrigin(req)) {
|
|
899
|
+
this.rejectUpgrade(socket, 403, "Forbidden", pathname, req);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if (!this.authorized(req, true)) {
|
|
903
|
+
this.rejectUpgrade(socket, 401, "Unauthorized", pathname, req);
|
|
904
|
+
return;
|
|
831
905
|
}
|
|
832
906
|
if (pathname === WS_PATH_CHANNEL) {
|
|
833
907
|
this.wssChannel.handleUpgrade(req, socket, head, (ws) => {
|
|
@@ -879,31 +953,28 @@ var HubServer = class {
|
|
|
879
953
|
}
|
|
880
954
|
// --- HTTP Handler ---
|
|
881
955
|
handleHttp(req, res) {
|
|
882
|
-
const url = new URL(req.url,
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
886
|
-
}
|
|
887
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
888
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
889
|
-
if (req.method === "OPTIONS") {
|
|
890
|
-
res.writeHead(204);
|
|
891
|
-
res.end();
|
|
956
|
+
const url = new URL(req.url ?? "/", "http://hub");
|
|
957
|
+
if (isCrossOrigin(req)) {
|
|
958
|
+
this.jsonResponse(res, 403, { error: "Cross-origin request rejected" });
|
|
892
959
|
return;
|
|
893
960
|
}
|
|
894
|
-
if (
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
898
|
-
if (bearerToken !== this.token) {
|
|
899
|
-
this.jsonResponse(res, 401, { error: "Unauthorized" });
|
|
900
|
-
return;
|
|
901
|
-
}
|
|
902
|
-
}
|
|
961
|
+
if (req.method === "POST" && !isJsonRequest(req)) {
|
|
962
|
+
this.jsonResponse(res, 415, { error: "Content-Type must be application/json" });
|
|
963
|
+
return;
|
|
903
964
|
}
|
|
904
965
|
if (url.pathname === "/" && req.method === "GET") {
|
|
905
|
-
this.serveDashboard(res);
|
|
906
|
-
|
|
966
|
+
this.serveDashboard(req, res, url);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (url.pathname === "/api/login" && req.method === "POST") {
|
|
970
|
+
this.handleLogin(req, res);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (!this.authorized(req, false)) {
|
|
974
|
+
this.jsonResponse(res, 401, { error: "Unauthorized" });
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (url.pathname === "/api/sessions" && req.method === "GET") {
|
|
907
978
|
this.jsonResponse(res, 200, { sessions: this.sessions.getAll() });
|
|
908
979
|
} else if (url.pathname === "/api/status" && req.method === "GET") {
|
|
909
980
|
this.jsonResponse(res, 200, {
|
|
@@ -954,7 +1025,13 @@ var HubServer = class {
|
|
|
954
1025
|
this.jsonResponse(res, 404, { error: "Not found" });
|
|
955
1026
|
}
|
|
956
1027
|
}
|
|
957
|
-
serveDashboard(res) {
|
|
1028
|
+
serveDashboard(req, res, url) {
|
|
1029
|
+
const linkToken = url.searchParams.get("token");
|
|
1030
|
+
if (linkToken !== null && this.token && safeEqual(linkToken, this.token)) {
|
|
1031
|
+
res.writeHead(302, { Location: "/", "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
|
|
1032
|
+
res.end();
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
958
1035
|
const candidates = [
|
|
959
1036
|
path4.join(__dirname, "..", "dashboard", "index.html"),
|
|
960
1037
|
// from dist/hub/
|
|
@@ -981,6 +1058,20 @@ var HubServer = class {
|
|
|
981
1058
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
982
1059
|
res.end("<html><body><h1>claude-alarm</h1><p>Dashboard HTML not found. Reinstall the package.</p></body></html>");
|
|
983
1060
|
}
|
|
1061
|
+
async handleLogin(req, res) {
|
|
1062
|
+
const body = await this.readBody(req);
|
|
1063
|
+
if (!this.token) {
|
|
1064
|
+
res.writeHead(204);
|
|
1065
|
+
res.end();
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (typeof body?.token !== "string" || !safeEqual(body.token, this.token)) {
|
|
1069
|
+
this.jsonResponse(res, 401, { error: "Unauthorized" });
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
res.writeHead(204, { "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
|
|
1073
|
+
res.end();
|
|
1074
|
+
}
|
|
984
1075
|
async handleApiSend(req, res) {
|
|
985
1076
|
const body = await this.readBody(req);
|
|
986
1077
|
if (!body) {
|
|
@@ -1057,6 +1148,22 @@ var HubServer = class {
|
|
|
1057
1148
|
});
|
|
1058
1149
|
}
|
|
1059
1150
|
handleChannelMessage(ws, isLocal, msg) {
|
|
1151
|
+
if (msg.type === "register") {
|
|
1152
|
+
const id = msg.session.id;
|
|
1153
|
+
const owned = this.socketOwners.get(ws);
|
|
1154
|
+
if (owned && owned !== id) {
|
|
1155
|
+
logger.warn(`Rejected register for ${id}: connection already owns ${owned}`);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
const holder = this.channelSockets.get(id);
|
|
1159
|
+
if (holder && holder !== ws) {
|
|
1160
|
+
this.socketOwners.delete(holder);
|
|
1161
|
+
holder.terminate();
|
|
1162
|
+
}
|
|
1163
|
+
this.socketOwners.set(ws, id);
|
|
1164
|
+
} else if ("sessionId" in msg && this.socketOwners.get(ws) !== msg.sessionId) {
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1060
1167
|
switch (msg.type) {
|
|
1061
1168
|
case "register": {
|
|
1062
1169
|
const session = msg.session;
|
|
@@ -1065,7 +1172,7 @@ var HubServer = class {
|
|
|
1065
1172
|
this.sessions.register(session);
|
|
1066
1173
|
this.channelSockets.set(session.id, ws);
|
|
1067
1174
|
if (isLocal) this.localChannels.add(session.id);
|
|
1068
|
-
logger.info(`Session registered: ${session.id} (${session.name}, channel: ${session.channelEnabled ?? false})`);
|
|
1175
|
+
logger.info(`Session registered: ${session.id} (${session.name}, channel: ${session.channelEnabled ?? false}, peer: ${session.peerName ?? "-"})`);
|
|
1069
1176
|
this.broadcastToDashboards({
|
|
1070
1177
|
type: isReregister ? "session_updated" : "session_connected",
|
|
1071
1178
|
session
|
|
@@ -1079,6 +1186,14 @@ var HubServer = class {
|
|
|
1079
1186
|
}
|
|
1080
1187
|
break;
|
|
1081
1188
|
}
|
|
1189
|
+
case "peer_name": {
|
|
1190
|
+
const updated = this.sessions.setPeerName(msg.sessionId, msg.peerName);
|
|
1191
|
+
if (updated) {
|
|
1192
|
+
logger.info(`Peer name for ${msg.sessionId}: ${msg.peerName ?? "-"}`);
|
|
1193
|
+
this.broadcastToDashboards({ type: "session_updated", session: updated });
|
|
1194
|
+
}
|
|
1195
|
+
break;
|
|
1196
|
+
}
|
|
1082
1197
|
case "notify": {
|
|
1083
1198
|
this.sessions.updateActivity(msg.sessionId);
|
|
1084
1199
|
const notifySession = this.sessions.get(msg.sessionId);
|
|
@@ -1399,6 +1514,18 @@ var HubServer = class {
|
|
|
1399
1514
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1400
1515
|
res.end(JSON.stringify(body));
|
|
1401
1516
|
}
|
|
1517
|
+
authorized(req, allowQueryToken) {
|
|
1518
|
+
return !this.token || isAuthorized(req, this.token, allowQueryToken);
|
|
1519
|
+
}
|
|
1520
|
+
rejectUpgrade(socket, status, text, pathname, req) {
|
|
1521
|
+
logger.warn(`Rejected ${pathname} upgrade (${status}) from ${req.socket.remoteAddress}`);
|
|
1522
|
+
socket.write(`HTTP/1.1 ${status} ${text}\r
|
|
1523
|
+
Connection: close\r
|
|
1524
|
+
Content-Length: 0\r
|
|
1525
|
+
\r
|
|
1526
|
+
`);
|
|
1527
|
+
socket.destroy();
|
|
1528
|
+
}
|
|
1402
1529
|
isLocalRequest(req) {
|
|
1403
1530
|
const addr = req.socket.remoteAddress;
|
|
1404
1531
|
return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
@@ -1445,12 +1572,13 @@ if (process.argv[1] && (process.argv[1].endsWith("hub/server.js") || process.arg
|
|
|
1445
1572
|
// src/channel/hub-client.ts
|
|
1446
1573
|
import WebSocket2 from "ws";
|
|
1447
1574
|
var HubClient = class {
|
|
1448
|
-
constructor(sessionId, sessionName, hubHost = DEFAULT_HUB_HOST, hubPort = DEFAULT_HUB_PORT, token) {
|
|
1575
|
+
constructor(sessionId, sessionName, hubHost = DEFAULT_HUB_HOST, hubPort = DEFAULT_HUB_PORT, token, getPeerName = () => void 0) {
|
|
1449
1576
|
this.sessionId = sessionId;
|
|
1450
1577
|
this.sessionName = sessionName;
|
|
1451
1578
|
this.hubHost = hubHost;
|
|
1452
1579
|
this.hubPort = hubPort;
|
|
1453
1580
|
this.token = token;
|
|
1581
|
+
this.getPeerName = getPeerName;
|
|
1454
1582
|
}
|
|
1455
1583
|
ws = null;
|
|
1456
1584
|
reconnectTimer = null;
|
|
@@ -1475,7 +1603,8 @@ var HubClient = class {
|
|
|
1475
1603
|
connectedAt: Date.now(),
|
|
1476
1604
|
lastActivity: Date.now(),
|
|
1477
1605
|
cwd: process.cwd(),
|
|
1478
|
-
channelEnabled: true
|
|
1606
|
+
channelEnabled: true,
|
|
1607
|
+
peerName: this.getPeerName()
|
|
1479
1608
|
}
|
|
1480
1609
|
};
|
|
1481
1610
|
this.ws.send(JSON.stringify(registration));
|