@kraki/head 0.1.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 +21 -0
- package/dist/auth.d.ts +104 -0
- package/dist/auth.js +213 -0
- package/dist/auth.js.map +1 -0
- package/dist/channel-manager.d.ts +97 -0
- package/dist/channel-manager.js +215 -0
- package/dist/channel-manager.js.map +1 -0
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +156 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +31 -0
- package/dist/logger.js +103 -0
- package/dist/logger.js.map +1 -0
- package/dist/router.d.ts +30 -0
- package/dist/router.js +217 -0
- package/dist/router.js.map +1 -0
- package/dist/server.d.ts +78 -0
- package/dist/server.js +678 -0
- package/dist/server.js.map +1 -0
- package/dist/storage.d.ts +112 -0
- package/dist/storage.js +372 -0
- package/dist/storage.js.map +1 -0
- package/package.json +42 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
|
+
import { randomBytes, createVerify } from 'crypto';
|
|
3
|
+
import { GitHubAuthProvider } from './auth.js';
|
|
4
|
+
import { getLogger } from './logger.js';
|
|
5
|
+
/**
|
|
6
|
+
* Import a compact base64 public key to PEM format.
|
|
7
|
+
*/
|
|
8
|
+
function importPublicKey(compactKey) {
|
|
9
|
+
const lines = compactKey.match(/.{1,64}/g) ?? [];
|
|
10
|
+
return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----\n`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Verify a challenge-response signature.
|
|
14
|
+
*/
|
|
15
|
+
function verifySignature(nonce, signature, publicKeyPem) {
|
|
16
|
+
const verify = createVerify('SHA256');
|
|
17
|
+
verify.update(nonce);
|
|
18
|
+
return verify.verify(publicKeyPem, signature, 'base64');
|
|
19
|
+
}
|
|
20
|
+
const DEFAULT_MAX_PAYLOAD = 10 * 1024 * 1024;
|
|
21
|
+
const DEFAULT_PING_INTERVAL = 30_000;
|
|
22
|
+
const DEFAULT_PONG_TIMEOUT = 10_000; // 10MB
|
|
23
|
+
/**
|
|
24
|
+
* Basic message validation guard.
|
|
25
|
+
* Checks structural requirements — not full schema validation.
|
|
26
|
+
*/
|
|
27
|
+
function isValidMessage(msg) {
|
|
28
|
+
if (typeof msg !== 'object' || msg === null)
|
|
29
|
+
return false;
|
|
30
|
+
const obj = msg;
|
|
31
|
+
if (typeof obj.type !== 'string' || obj.type.length === 0)
|
|
32
|
+
return false;
|
|
33
|
+
if ('payload' in obj && (typeof obj.payload !== 'object' || obj.payload === null))
|
|
34
|
+
return false;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
/** Validate auth message shape */
|
|
38
|
+
function isValidAuth(msg) {
|
|
39
|
+
if (!msg.device || typeof msg.device !== 'object')
|
|
40
|
+
return false;
|
|
41
|
+
const dev = msg.device;
|
|
42
|
+
if (typeof dev.name !== 'string' || typeof dev.role !== 'string')
|
|
43
|
+
return false;
|
|
44
|
+
if (dev.role !== 'tentacle' && dev.role !== 'app')
|
|
45
|
+
return false;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
/** Validate create_session payload */
|
|
49
|
+
function isValidCreateSession(msg) {
|
|
50
|
+
if (!msg.payload || typeof msg.payload !== 'object')
|
|
51
|
+
return false;
|
|
52
|
+
const p = msg.payload;
|
|
53
|
+
if (typeof p.targetDeviceId !== 'string')
|
|
54
|
+
return false;
|
|
55
|
+
if (typeof p.model !== 'string')
|
|
56
|
+
return false;
|
|
57
|
+
if (typeof p.requestId !== 'string')
|
|
58
|
+
return false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
/** Validate encrypted envelope */
|
|
62
|
+
function isValidEncrypted(msg) {
|
|
63
|
+
return typeof msg.iv === 'string' && typeof msg.ciphertext === 'string'
|
|
64
|
+
&& typeof msg.tag === 'string' && typeof msg.keys === 'object' && msg.keys !== null;
|
|
65
|
+
}
|
|
66
|
+
export class HeadServer {
|
|
67
|
+
wss;
|
|
68
|
+
cm;
|
|
69
|
+
router;
|
|
70
|
+
options;
|
|
71
|
+
clients = new Map();
|
|
72
|
+
pingTimer = null;
|
|
73
|
+
/** Dedup: track recently seen client message IDs to prevent duplicates */
|
|
74
|
+
recentClientMsgIds = new Set();
|
|
75
|
+
dedupCleanupTimer = null;
|
|
76
|
+
constructor(cm, router, options) {
|
|
77
|
+
this.cm = cm;
|
|
78
|
+
this.router = router;
|
|
79
|
+
this.options = options;
|
|
80
|
+
this.wss = new WebSocketServer({
|
|
81
|
+
noServer: true,
|
|
82
|
+
maxPayload: options.maxPayload ?? DEFAULT_MAX_PAYLOAD,
|
|
83
|
+
});
|
|
84
|
+
this.wss.on('connection', (ws, req) => this.onConnection(ws, req));
|
|
85
|
+
this.startPingInterval();
|
|
86
|
+
this.startDedupCleanup();
|
|
87
|
+
}
|
|
88
|
+
/** Resolve the auth provider (multi-provider or legacy single). */
|
|
89
|
+
getAuthProvider() {
|
|
90
|
+
if (this.options.authProviders?.size) {
|
|
91
|
+
// Return first provider as default (actual selection happens per-request)
|
|
92
|
+
return this.options.authProviders.values().next().value;
|
|
93
|
+
}
|
|
94
|
+
return this.options.authProvider;
|
|
95
|
+
}
|
|
96
|
+
/** Get supported auth mode names. */
|
|
97
|
+
getAuthModes() {
|
|
98
|
+
if (this.options.authModes?.length)
|
|
99
|
+
return this.options.authModes;
|
|
100
|
+
if (this.options.authProviders?.size)
|
|
101
|
+
return [...this.options.authProviders.keys()];
|
|
102
|
+
return [this.options.authProvider?.name ?? 'open'];
|
|
103
|
+
}
|
|
104
|
+
/** Resolve auth provider by mode name (for multi-provider). */
|
|
105
|
+
getAuthProviderForMode(mode) {
|
|
106
|
+
if (mode && this.options.authProviders?.has(mode)) {
|
|
107
|
+
return this.options.authProviders.get(mode);
|
|
108
|
+
}
|
|
109
|
+
return this.getAuthProvider();
|
|
110
|
+
}
|
|
111
|
+
/** Get the GitHub OAuth client ID if configured (for auth_info_response). */
|
|
112
|
+
getGitHubClientId() {
|
|
113
|
+
const ghProvider = this.findGitHubProvider();
|
|
114
|
+
if (ghProvider?.oauthConfigured) {
|
|
115
|
+
return { githubClientId: ghProvider.getClientId() };
|
|
116
|
+
}
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
/** Find the GitHubAuthProvider in the provider chain (unwrapping throttle). */
|
|
120
|
+
findGitHubProvider() {
|
|
121
|
+
const provider = this.options.authProviders?.get('github') ?? this.options.authProvider;
|
|
122
|
+
if (!provider)
|
|
123
|
+
return undefined;
|
|
124
|
+
if (provider instanceof GitHubAuthProvider)
|
|
125
|
+
return provider;
|
|
126
|
+
if ('inner' in provider) {
|
|
127
|
+
const inner = provider.inner;
|
|
128
|
+
if (inner instanceof GitHubAuthProvider)
|
|
129
|
+
return inner;
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
/** Resolve the channel owner's user info for auth_ok. */
|
|
134
|
+
getChannelOwnerUser(channelId) {
|
|
135
|
+
const channel = this.cm.getStorage().getChannel(channelId);
|
|
136
|
+
if (!channel)
|
|
137
|
+
return undefined;
|
|
138
|
+
const user = this.cm.getStorage().getUser(channel.ownerId);
|
|
139
|
+
if (!user)
|
|
140
|
+
return undefined;
|
|
141
|
+
return { id: user.userId, login: user.username, provider: user.provider, email: user.email };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Attach to an HTTP server for upgrade handling.
|
|
145
|
+
*/
|
|
146
|
+
attach(server) {
|
|
147
|
+
server.on('upgrade', (req, socket, head) => {
|
|
148
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
149
|
+
this.wss.emit('connection', ws, req);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Accept a raw WebSocket connection (for testing without HTTP server).
|
|
155
|
+
*/
|
|
156
|
+
acceptConnection(ws) {
|
|
157
|
+
this.onConnection(ws);
|
|
158
|
+
}
|
|
159
|
+
onConnection(ws, req) {
|
|
160
|
+
const ip = req?.socket?.remoteAddress ?? req?.headers['x-forwarded-for']?.toString() ?? 'unknown';
|
|
161
|
+
const state = { authenticated: false, ip, alive: true };
|
|
162
|
+
const logger = getLogger();
|
|
163
|
+
this.clients.set(ws, state);
|
|
164
|
+
logger.debug('WebSocket connected', { ip });
|
|
165
|
+
ws.on('pong', () => {
|
|
166
|
+
state.alive = true;
|
|
167
|
+
});
|
|
168
|
+
ws.on('message', (data) => {
|
|
169
|
+
state.alive = true;
|
|
170
|
+
try {
|
|
171
|
+
const msg = JSON.parse(data.toString());
|
|
172
|
+
if (!isValidMessage(msg)) {
|
|
173
|
+
this.sendError(ws, 'Invalid message format');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
this.onMessage(ws, state, msg).catch((err) => {
|
|
177
|
+
getLogger().error('Unhandled error in message handler', { error: err.message });
|
|
178
|
+
this.sendError(ws, 'Internal server error');
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
this.sendError(ws, 'Invalid JSON');
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
ws.on('close', () => {
|
|
186
|
+
if (state.deviceId && state.channelId) {
|
|
187
|
+
const device = this.cm.disconnectDevice(state.deviceId);
|
|
188
|
+
if (device) {
|
|
189
|
+
logger.info('Device disconnected', { deviceId: state.deviceId, name: device.name });
|
|
190
|
+
this.router.broadcastNotice(state.channelId, {
|
|
191
|
+
type: 'head_notice',
|
|
192
|
+
event: 'device_offline',
|
|
193
|
+
data: { deviceId: state.deviceId },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
this.clients.delete(ws);
|
|
198
|
+
});
|
|
199
|
+
ws.on('error', () => {
|
|
200
|
+
ws.close();
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
async onMessage(ws, state, msg) {
|
|
204
|
+
// Handle ping/pong
|
|
205
|
+
if (msg.type === 'ping') {
|
|
206
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Dedup: if client provides a clientMsgId, check for duplicates
|
|
210
|
+
if (typeof msg.clientMsgId === 'string') {
|
|
211
|
+
if (this.trackClientMsgId(msg.clientMsgId)) {
|
|
212
|
+
getLogger().debug('Duplicate message dropped', { clientMsgId: msg.clientMsgId });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// One-shot pairing token request — no device registration, handled before auth
|
|
217
|
+
if (msg.type === 'request_pairing_token') {
|
|
218
|
+
await this.handleRequestPairingToken(ws, state, msg);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// Must auth first
|
|
222
|
+
if (!state.authenticated) {
|
|
223
|
+
if (msg.type === 'auth_info') {
|
|
224
|
+
ws.send(JSON.stringify({
|
|
225
|
+
type: 'auth_info_response',
|
|
226
|
+
authModes: this.getAuthModes(),
|
|
227
|
+
e2e: this.options.e2e,
|
|
228
|
+
pairing: this.options.pairingEnabled !== false,
|
|
229
|
+
...this.getGitHubClientId(),
|
|
230
|
+
}));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (msg.type === 'auth') {
|
|
234
|
+
if (!isValidAuth(msg)) {
|
|
235
|
+
this.sendError(ws, 'Invalid auth message: device.name and device.role required');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
await this.handleAuth(ws, state, msg);
|
|
239
|
+
}
|
|
240
|
+
else if (msg.type === 'auth_response' && state.pendingNonce) {
|
|
241
|
+
this.handleChallengeResponse(ws, state, msg);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
this.sendError(ws, 'Must authenticate first');
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// Validate type-specific messages
|
|
249
|
+
if (msg.type === 'encrypted' && !isValidEncrypted(msg)) {
|
|
250
|
+
this.sendError(ws, 'Invalid encrypted message: iv, ciphertext, tag, keys required');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (msg.type === 'create_session' && !isValidCreateSession(msg)) {
|
|
254
|
+
this.sendError(ws, 'Invalid create_session: requestId, targetDeviceId, model required');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Handle control messages
|
|
258
|
+
if (msg.type === 'replay') {
|
|
259
|
+
const replay = msg;
|
|
260
|
+
if (!state.deviceId)
|
|
261
|
+
return;
|
|
262
|
+
this.router.replay(state.deviceId, replay.afterSeq, replay.sessionId);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (msg.type === 'mark_read') {
|
|
266
|
+
if (!state.channelId)
|
|
267
|
+
return;
|
|
268
|
+
const { sessionId, seq } = msg;
|
|
269
|
+
if (sessionId && typeof seq === 'number') {
|
|
270
|
+
this.cm.getStorage().markRead(state.channelId, sessionId, seq);
|
|
271
|
+
// Notify other devices so they can sync unread state
|
|
272
|
+
this.router.broadcastNotice(state.channelId, {
|
|
273
|
+
type: 'head_notice',
|
|
274
|
+
event: 'read_state_updated',
|
|
275
|
+
data: { sessionId, lastSeq: seq },
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (msg.type === 'create_pairing_token') {
|
|
281
|
+
this.handleCreatePairingToken(ws, state);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (msg.type === 'delete_session') {
|
|
285
|
+
this.handleDeleteSession(ws, state, msg);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// Handle producer, consumer, and encrypted messages
|
|
289
|
+
if (state.deviceId) {
|
|
290
|
+
this.router.handleMessage(state.deviceId, msg);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
handleCreatePairingToken(ws, state) {
|
|
294
|
+
const logger = getLogger();
|
|
295
|
+
if (!(this.options.pairingEnabled ?? true)) {
|
|
296
|
+
this.sendError(ws, 'Pairing is disabled on this relay');
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (!state.channelId) {
|
|
300
|
+
this.sendError(ws, 'Not authenticated');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const token = `pt_${randomBytes(32).toString('hex')}`;
|
|
304
|
+
const ttl = this.options.pairingTtl ?? 300;
|
|
305
|
+
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
306
|
+
this.cm.getStorage().createPairingToken(token, state.channelId, expiresAt);
|
|
307
|
+
logger.info('Pairing token created', { channelId: state.channelId, ttl });
|
|
308
|
+
ws.send(JSON.stringify({
|
|
309
|
+
type: 'pairing_token_created',
|
|
310
|
+
token,
|
|
311
|
+
expiresIn: ttl,
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
314
|
+
handleDeleteSession(ws, state, msg) {
|
|
315
|
+
const logger = getLogger();
|
|
316
|
+
if (!state.channelId) {
|
|
317
|
+
this.sendError(ws, 'Not authenticated');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (!msg.sessionId || typeof msg.sessionId !== 'string') {
|
|
321
|
+
this.sendError(ws, 'sessionId required for delete_session');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const session = this.cm.getStorage().getSessionById(msg.sessionId);
|
|
325
|
+
if (!session || session.channelId !== state.channelId) {
|
|
326
|
+
this.sendError(ws, 'Session not found');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
this.cm.deleteSession(msg.sessionId, state.channelId);
|
|
330
|
+
logger.info('Session deleted', { sessionId: msg.sessionId, channelId: state.channelId });
|
|
331
|
+
this.router.broadcastNotice(state.channelId, {
|
|
332
|
+
type: 'head_notice',
|
|
333
|
+
event: 'session_removed',
|
|
334
|
+
data: { sessionId: msg.sessionId },
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* One-shot pairing token request. Validates auth token inline,
|
|
339
|
+
* creates a pairing token, responds, and requires no device registration.
|
|
340
|
+
*/
|
|
341
|
+
async handleRequestPairingToken(ws, state, msg) {
|
|
342
|
+
const logger = getLogger();
|
|
343
|
+
if (!(this.options.pairingEnabled ?? true)) {
|
|
344
|
+
this.sendError(ws, 'Pairing is disabled on this relay');
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (!msg.token) {
|
|
348
|
+
this.sendError(ws, 'Token required for pairing request');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const authResult = await this.getAuthProvider().authenticate({
|
|
352
|
+
token: msg.token,
|
|
353
|
+
ip: state.ip,
|
|
354
|
+
});
|
|
355
|
+
if (!authResult.ok) {
|
|
356
|
+
ws.send(JSON.stringify({ type: 'auth_error', message: authResult.message }));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const channelId = this.cm.getOrCreateChannel(authResult.user);
|
|
360
|
+
const token = `pt_${randomBytes(32).toString('hex')}`;
|
|
361
|
+
const ttl = this.options.pairingTtl ?? 300;
|
|
362
|
+
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
363
|
+
this.cm.getStorage().createPairingToken(token, channelId, expiresAt);
|
|
364
|
+
logger.info('Pairing token created (one-shot)', { channelId, ttl });
|
|
365
|
+
ws.send(JSON.stringify({
|
|
366
|
+
type: 'pairing_token_created',
|
|
367
|
+
token,
|
|
368
|
+
expiresIn: ttl,
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
async handleAuth(ws, state, msg) {
|
|
372
|
+
const logger = getLogger();
|
|
373
|
+
// Try pairing token auth first
|
|
374
|
+
if (msg.pairingToken) {
|
|
375
|
+
this.handlePairingAuth(ws, state, msg);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
// Try challenge-response for returning devices with a known deviceId + publicKey
|
|
379
|
+
if (msg.device.deviceId && !msg.token && !msg.channelKey && !msg.githubCode) {
|
|
380
|
+
const device = this.cm.getStorage().getDevice(msg.device.deviceId);
|
|
381
|
+
if (device && device.publicKey) {
|
|
382
|
+
// Known device with public key — issue challenge
|
|
383
|
+
const nonce = randomBytes(32).toString('hex');
|
|
384
|
+
state.pendingNonce = nonce;
|
|
385
|
+
state.pendingDeviceId = msg.device.deviceId;
|
|
386
|
+
state.pendingDeviceInfo = {
|
|
387
|
+
encryptionKey: msg.device.encryptionKey,
|
|
388
|
+
capabilities: msg.device.capabilities,
|
|
389
|
+
};
|
|
390
|
+
logger.debug('Issuing auth challenge', { deviceId: msg.device.deviceId });
|
|
391
|
+
ws.send(JSON.stringify({ type: 'auth_challenge', nonce }));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// Route to GitHub provider when an OAuth code is provided
|
|
396
|
+
const githubCode = msg.githubCode;
|
|
397
|
+
const authProvider = githubCode
|
|
398
|
+
? this.getAuthProviderForMode('github')
|
|
399
|
+
: this.getAuthProvider();
|
|
400
|
+
const authResult = await authProvider.authenticate({
|
|
401
|
+
token: msg.token,
|
|
402
|
+
channelKey: msg.channelKey,
|
|
403
|
+
githubCode,
|
|
404
|
+
ip: state.ip,
|
|
405
|
+
});
|
|
406
|
+
if (!authResult.ok) {
|
|
407
|
+
logger.warn('Auth rejected', { ip: state.ip, reason: authResult.message });
|
|
408
|
+
ws.send(JSON.stringify({ type: 'auth_error', message: authResult.message }));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const channelId = this.cm.getOrCreateChannel(authResult.user);
|
|
412
|
+
let deviceId;
|
|
413
|
+
try {
|
|
414
|
+
deviceId = this.cm.registerDevice({
|
|
415
|
+
channelId,
|
|
416
|
+
name: msg.device.name,
|
|
417
|
+
role: msg.device.role,
|
|
418
|
+
send: (data) => {
|
|
419
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
420
|
+
ws.send(data);
|
|
421
|
+
},
|
|
422
|
+
kind: msg.device.kind,
|
|
423
|
+
publicKey: msg.device.publicKey,
|
|
424
|
+
encryptionKey: msg.device.encryptionKey,
|
|
425
|
+
capabilities: msg.device.capabilities,
|
|
426
|
+
clientDeviceId: msg.device.deviceId,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
logger.warn('Device registration failed', { ip: state.ip, error: err.message });
|
|
431
|
+
ws.send(JSON.stringify({ type: 'auth_error', message: err.message }));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
state.authenticated = true;
|
|
435
|
+
state.deviceId = deviceId;
|
|
436
|
+
state.channelId = channelId;
|
|
437
|
+
logger.info('Device authenticated', {
|
|
438
|
+
deviceId,
|
|
439
|
+
name: msg.device.name,
|
|
440
|
+
role: msg.device.role,
|
|
441
|
+
user: authResult.user.login,
|
|
442
|
+
ip: state.ip,
|
|
443
|
+
});
|
|
444
|
+
ws.send(JSON.stringify({
|
|
445
|
+
type: 'auth_ok',
|
|
446
|
+
channel: channelId,
|
|
447
|
+
deviceId,
|
|
448
|
+
e2e: this.options.e2e,
|
|
449
|
+
devices: this.cm.getDeviceSummaries(channelId),
|
|
450
|
+
sessions: this.cm.getSessionSummaries(channelId),
|
|
451
|
+
readState: this.cm.getStorage().getReadState(channelId),
|
|
452
|
+
user: this.getChannelOwnerUser(channelId),
|
|
453
|
+
...this.getGitHubClientId(),
|
|
454
|
+
}));
|
|
455
|
+
// Notify other devices
|
|
456
|
+
this.router.broadcastNotice(channelId, {
|
|
457
|
+
type: 'head_notice',
|
|
458
|
+
event: 'device_online',
|
|
459
|
+
data: {
|
|
460
|
+
device: {
|
|
461
|
+
id: deviceId,
|
|
462
|
+
name: msg.device.name,
|
|
463
|
+
role: msg.device.role,
|
|
464
|
+
kind: msg.device.kind,
|
|
465
|
+
publicKey: msg.device.publicKey,
|
|
466
|
+
encryptionKey: msg.device.encryptionKey,
|
|
467
|
+
capabilities: msg.device.capabilities,
|
|
468
|
+
online: true,
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
handlePairingAuth(ws, state, msg) {
|
|
474
|
+
const logger = getLogger();
|
|
475
|
+
if (!(this.options.pairingEnabled ?? true)) {
|
|
476
|
+
ws.send(JSON.stringify({ type: 'auth_error', message: 'Pairing is disabled. Use OAuth to authenticate.' }));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const pairingToken = msg.pairingToken;
|
|
480
|
+
const channelId = this.cm.getStorage().consumePairingToken(pairingToken);
|
|
481
|
+
if (!channelId) {
|
|
482
|
+
logger.warn('Pairing auth failed: invalid or expired token', { ip: state.ip });
|
|
483
|
+
ws.send(JSON.stringify({ type: 'auth_error', message: 'Invalid or expired pairing token' }));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
let deviceId;
|
|
487
|
+
try {
|
|
488
|
+
deviceId = this.cm.registerDevice({
|
|
489
|
+
channelId,
|
|
490
|
+
name: msg.device.name,
|
|
491
|
+
role: msg.device.role,
|
|
492
|
+
send: (data) => {
|
|
493
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
494
|
+
ws.send(data);
|
|
495
|
+
},
|
|
496
|
+
kind: msg.device.kind,
|
|
497
|
+
publicKey: msg.device.publicKey,
|
|
498
|
+
encryptionKey: msg.device.encryptionKey,
|
|
499
|
+
capabilities: msg.device.capabilities,
|
|
500
|
+
clientDeviceId: msg.device.deviceId,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
logger.warn('Device registration failed (pairing)', { ip: state.ip, error: err.message });
|
|
505
|
+
ws.send(JSON.stringify({ type: 'auth_error', message: err.message }));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
state.authenticated = true;
|
|
509
|
+
state.deviceId = deviceId;
|
|
510
|
+
state.channelId = channelId;
|
|
511
|
+
logger.info('Device paired via token', { deviceId, name: msg.device.name, ip: state.ip });
|
|
512
|
+
ws.send(JSON.stringify({
|
|
513
|
+
type: 'auth_ok',
|
|
514
|
+
channel: channelId,
|
|
515
|
+
deviceId,
|
|
516
|
+
e2e: this.options.e2e,
|
|
517
|
+
devices: this.cm.getDeviceSummaries(channelId),
|
|
518
|
+
sessions: this.cm.getSessionSummaries(channelId),
|
|
519
|
+
readState: this.cm.getStorage().getReadState(channelId),
|
|
520
|
+
user: this.getChannelOwnerUser(channelId),
|
|
521
|
+
...this.getGitHubClientId(),
|
|
522
|
+
}));
|
|
523
|
+
this.router.broadcastNotice(channelId, {
|
|
524
|
+
type: 'head_notice',
|
|
525
|
+
event: 'device_online',
|
|
526
|
+
data: {
|
|
527
|
+
device: {
|
|
528
|
+
id: deviceId,
|
|
529
|
+
name: msg.device.name,
|
|
530
|
+
role: msg.device.role,
|
|
531
|
+
kind: msg.device.kind,
|
|
532
|
+
publicKey: msg.device.publicKey,
|
|
533
|
+
encryptionKey: msg.device.encryptionKey,
|
|
534
|
+
capabilities: msg.device.capabilities,
|
|
535
|
+
online: true,
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
handleChallengeResponse(ws, state, msg) {
|
|
541
|
+
const logger = getLogger();
|
|
542
|
+
const deviceId = state.pendingDeviceId;
|
|
543
|
+
const nonce = state.pendingNonce;
|
|
544
|
+
const pendingInfo = state.pendingDeviceInfo;
|
|
545
|
+
state.pendingNonce = undefined;
|
|
546
|
+
state.pendingDeviceId = undefined;
|
|
547
|
+
state.pendingDeviceInfo = undefined;
|
|
548
|
+
if (!deviceId || !nonce) {
|
|
549
|
+
ws.send(JSON.stringify({ type: 'auth_error', message: 'No pending challenge' }));
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const device = this.cm.getStorage().getDevice(deviceId);
|
|
553
|
+
if (!device || !device.publicKey) {
|
|
554
|
+
ws.send(JSON.stringify({ type: 'auth_error', message: 'Device not found' }));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
// Verify signature
|
|
558
|
+
const publicKeyPem = importPublicKey(device.publicKey);
|
|
559
|
+
const valid = verifySignature(nonce, msg.signature, publicKeyPem);
|
|
560
|
+
if (!valid) {
|
|
561
|
+
logger.warn('Challenge-response auth failed', { deviceId, ip: state.ip });
|
|
562
|
+
ws.send(JSON.stringify({ type: 'auth_error', message: 'Invalid signature' }));
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
// Use encryptionKey from the auth message (may be new/updated)
|
|
566
|
+
const encryptionKey = pendingInfo?.encryptionKey ?? device.encryptionKey ?? undefined;
|
|
567
|
+
let registeredId;
|
|
568
|
+
try {
|
|
569
|
+
registeredId = this.cm.registerDevice({
|
|
570
|
+
channelId: device.channelId,
|
|
571
|
+
name: device.name,
|
|
572
|
+
role: device.role,
|
|
573
|
+
send: (data) => {
|
|
574
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
575
|
+
ws.send(data);
|
|
576
|
+
},
|
|
577
|
+
kind: device.kind ?? undefined,
|
|
578
|
+
publicKey: device.publicKey ?? undefined,
|
|
579
|
+
encryptionKey,
|
|
580
|
+
clientDeviceId: deviceId,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
logger.warn('Device registration failed (challenge)', { deviceId, error: err.message });
|
|
585
|
+
ws.send(JSON.stringify({ type: 'auth_error', message: err.message }));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
state.authenticated = true;
|
|
589
|
+
state.deviceId = registeredId;
|
|
590
|
+
state.channelId = device.channelId;
|
|
591
|
+
logger.info('Device authenticated via challenge-response', { deviceId, ip: state.ip });
|
|
592
|
+
ws.send(JSON.stringify({
|
|
593
|
+
type: 'auth_ok',
|
|
594
|
+
channel: device.channelId,
|
|
595
|
+
deviceId: registeredId,
|
|
596
|
+
e2e: this.options.e2e,
|
|
597
|
+
devices: this.cm.getDeviceSummaries(device.channelId),
|
|
598
|
+
sessions: this.cm.getSessionSummaries(device.channelId),
|
|
599
|
+
readState: this.cm.getStorage().getReadState(device.channelId),
|
|
600
|
+
user: this.getChannelOwnerUser(device.channelId),
|
|
601
|
+
...this.getGitHubClientId(),
|
|
602
|
+
}));
|
|
603
|
+
this.router.broadcastNotice(device.channelId, {
|
|
604
|
+
type: 'head_notice',
|
|
605
|
+
event: 'device_online',
|
|
606
|
+
data: {
|
|
607
|
+
device: {
|
|
608
|
+
id: registeredId,
|
|
609
|
+
name: device.name,
|
|
610
|
+
role: device.role,
|
|
611
|
+
kind: device.kind ?? undefined,
|
|
612
|
+
publicKey: device.publicKey ?? undefined,
|
|
613
|
+
encryptionKey,
|
|
614
|
+
online: true,
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
sendError(ws, message) {
|
|
620
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
621
|
+
ws.send(JSON.stringify({ type: 'server_error', message }));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
startPingInterval() {
|
|
625
|
+
const interval = this.options.pingInterval ?? DEFAULT_PING_INTERVAL;
|
|
626
|
+
if (interval <= 0)
|
|
627
|
+
return;
|
|
628
|
+
const timeout = this.options.pongTimeout ?? DEFAULT_PONG_TIMEOUT;
|
|
629
|
+
const logger = getLogger();
|
|
630
|
+
this.pingTimer = setInterval(() => {
|
|
631
|
+
for (const [ws, state] of this.clients) {
|
|
632
|
+
if (!state.alive) {
|
|
633
|
+
logger.warn('Device ping timeout, terminating', { deviceId: state.deviceId, ip: state.ip });
|
|
634
|
+
ws.terminate();
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
state.alive = false;
|
|
638
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
639
|
+
ws.ping();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}, interval);
|
|
643
|
+
}
|
|
644
|
+
startDedupCleanup() {
|
|
645
|
+
// Clear dedup set periodically and bound max size
|
|
646
|
+
this.dedupCleanupTimer = setInterval(() => {
|
|
647
|
+
this.recentClientMsgIds.clear();
|
|
648
|
+
}, 5 * 60_000);
|
|
649
|
+
}
|
|
650
|
+
/** Guard against unbounded dedup set growth */
|
|
651
|
+
trackClientMsgId(id) {
|
|
652
|
+
if (this.recentClientMsgIds.has(id))
|
|
653
|
+
return true; // duplicate
|
|
654
|
+
if (this.recentClientMsgIds.size > 10_000) {
|
|
655
|
+
this.recentClientMsgIds.clear(); // safety valve
|
|
656
|
+
}
|
|
657
|
+
this.recentClientMsgIds.add(id);
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Close the server and all connections.
|
|
662
|
+
*/
|
|
663
|
+
close() {
|
|
664
|
+
if (this.pingTimer) {
|
|
665
|
+
clearInterval(this.pingTimer);
|
|
666
|
+
this.pingTimer = null;
|
|
667
|
+
}
|
|
668
|
+
if (this.dedupCleanupTimer) {
|
|
669
|
+
clearInterval(this.dedupCleanupTimer);
|
|
670
|
+
this.dedupCleanupTimer = null;
|
|
671
|
+
}
|
|
672
|
+
for (const ws of this.clients.keys()) {
|
|
673
|
+
ws.close();
|
|
674
|
+
}
|
|
675
|
+
this.wss.close();
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
//# sourceMappingURL=server.js.map
|