@tapflowio/relay 0.24.0 → 0.25.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/dist/RelayServer.d.ts +45 -0
- package/dist/RelayServer.d.ts.map +1 -1
- package/dist/RelayServer.js +245 -30
- package/dist/RelayServer.js.map +1 -1
- package/dist/api/apps.d.ts.map +1 -1
- package/dist/api/apps.js +6 -16
- package/dist/api/apps.js.map +1 -1
- package/dist/api/auth.d.ts.map +1 -1
- package/dist/api/auth.js +3 -12
- package/dist/api/auth.js.map +1 -1
- package/dist/api/builds.d.ts.map +1 -1
- package/dist/api/builds.js +7 -5
- package/dist/api/builds.js.map +1 -1
- package/dist/api/comments.d.ts.map +1 -1
- package/dist/api/comments.js +41 -15
- package/dist/api/comments.js.map +1 -1
- package/dist/api/invitations.d.ts +1 -1
- package/dist/api/invitations.d.ts.map +1 -1
- package/dist/api/invitations.js +7 -3
- package/dist/api/invitations.js.map +1 -1
- package/dist/api/passwordReset.js +2 -2
- package/dist/api/team.d.ts +2 -2
- package/dist/api/team.d.ts.map +1 -1
- package/dist/api/team.js +6 -2
- package/dist/api/team.js.map +1 -1
- package/dist/api/tokens.d.ts +1 -1
- package/dist/api/tokens.d.ts.map +1 -1
- package/dist/api/tokens.js +26 -7
- package/dist/api/tokens.js.map +1 -1
- package/dist/api/webhooks.d.ts.map +1 -1
- package/dist/api/webhooks.js +11 -5
- package/dist/api/webhooks.js.map +1 -1
- package/dist/lib/clientAddress.d.ts +7 -0
- package/dist/lib/clientAddress.d.ts.map +1 -1
- package/dist/lib/clientAddress.js +15 -0
- package/dist/lib/clientAddress.js.map +1 -1
- package/dist/lib/connectionAuth.d.ts +57 -2
- package/dist/lib/connectionAuth.d.ts.map +1 -1
- package/dist/lib/connectionAuth.js +65 -6
- package/dist/lib/connectionAuth.js.map +1 -1
- package/dist/middleware/auth.d.ts +44 -4
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +99 -27
- package/dist/middleware/auth.js.map +1 -1
- package/package.json +3 -3
- package/public/assets/AppCenter-D_1rFI40.js +26 -0
- package/public/assets/AppCenter-D_1rFI40.js.br +0 -0
- package/public/assets/AppCenter-D_1rFI40.js.gz +0 -0
- package/public/assets/Default-DF_X2_3I.js +1 -0
- package/public/assets/Default-DF_X2_3I.js.br +0 -0
- package/public/assets/Default-DF_X2_3I.js.gz +0 -0
- package/public/assets/{Invite-EVMTsuNz.js → Invite-DETTWdYp.js} +1 -1
- package/public/assets/Invite-DETTWdYp.js.br +0 -0
- package/public/assets/Invite-DETTWdYp.js.gz +0 -0
- package/public/assets/{MacResources-fCn40Tf5.js → MacResources--1Enisxs.js} +1 -1
- package/public/assets/MacResources--1Enisxs.js.br +0 -0
- package/public/assets/MacResources--1Enisxs.js.gz +0 -0
- package/public/assets/{QASession-CL-eyFxG.js → QASession-BjoD9Toj.js} +3 -3
- package/public/assets/QASession-BjoD9Toj.js.br +0 -0
- package/public/assets/QASession-BjoD9Toj.js.gz +0 -0
- package/public/assets/{ResetPassword-D-SFg4ap.js → ResetPassword-B9miyXTP.js} +1 -1
- package/public/assets/ResetPassword-B9miyXTP.js.br +0 -0
- package/public/assets/ResetPassword-B9miyXTP.js.gz +0 -0
- package/public/assets/{Setup-ULjvIfAi.js → Setup-CywZCqHU.js} +1 -1
- package/public/assets/Setup-CywZCqHU.js.br +0 -0
- package/public/assets/Setup-CywZCqHU.js.gz +0 -0
- package/public/assets/{Team-DH91GD4w.js → Team-CxdX_9mY.js} +2 -2
- package/public/assets/Team-CxdX_9mY.js.br +0 -0
- package/public/assets/Team-CxdX_9mY.js.gz +0 -0
- package/public/assets/Tokens-BQnnawvG.js +1 -0
- package/public/assets/Tokens-BQnnawvG.js.br +0 -0
- package/public/assets/Tokens-BQnnawvG.js.gz +0 -0
- package/public/assets/alert-dialog-CuPdBkxF.js +1 -0
- package/public/assets/alert-dialog-CuPdBkxF.js.br +0 -0
- package/public/assets/alert-dialog-CuPdBkxF.js.gz +0 -0
- package/public/assets/{badge-B4QAWrzA.js → badge-CSVsKOK_.js} +1 -1
- package/public/assets/badge-CSVsKOK_.js.br +0 -0
- package/public/assets/badge-CSVsKOK_.js.gz +0 -0
- package/public/assets/{build-format-CtmRXihN.js → build-format-l9BrlbZl.js} +1 -1
- package/public/assets/build-format-l9BrlbZl.js.br +0 -0
- package/public/assets/build-format-l9BrlbZl.js.gz +0 -0
- package/public/assets/{dialog-BAFN_ONP.js → dialog-B6jt7-2f.js} +3 -3
- package/public/assets/dialog-B6jt7-2f.js.br +0 -0
- package/public/assets/dialog-B6jt7-2f.js.gz +0 -0
- package/public/assets/{index-COOWQXpW.js → index-Cm17I31a.js} +32 -32
- package/public/assets/index-Cm17I31a.js.br +0 -0
- package/public/assets/index-Cm17I31a.js.gz +0 -0
- package/public/assets/index-D_P99bPX.css +1 -0
- package/public/assets/index-D_P99bPX.css.br +0 -0
- package/public/assets/{index-ozCdBYxk.css.gz → index-D_P99bPX.css.gz} +0 -0
- package/public/assets/{list-view-Rrqp8TkG.js → list-view-Bp6pvsde.js} +1 -1
- package/public/assets/list-view-Bp6pvsde.js.br +0 -0
- package/public/assets/list-view-Bp6pvsde.js.gz +0 -0
- package/public/assets/{pencil-CAHyK7Qf.js → pencil-g-WiK1F2.js} +1 -1
- package/public/assets/pencil-g-WiK1F2.js.br +0 -0
- package/public/assets/pencil-g-WiK1F2.js.gz +0 -0
- package/public/assets/resource-health-BbeyA8an.js +1 -0
- package/public/assets/resource-health-BbeyA8an.js.br +0 -0
- package/public/assets/resource-health-BbeyA8an.js.gz +0 -0
- package/public/assets/{trash-CkO-5TRA.js → trash-DHPg15P4.js} +1 -1
- package/public/assets/trash-DHPg15P4.js.br +0 -0
- package/public/assets/trash-DHPg15P4.js.gz +0 -0
- package/public/index.html +2 -2
- package/public/index.html.br +0 -0
- package/public/index.html.gz +0 -0
- package/public/assets/AppCenter-BHz6vh5p.js +0 -26
- package/public/assets/AppCenter-BHz6vh5p.js.br +0 -0
- package/public/assets/AppCenter-BHz6vh5p.js.gz +0 -0
- package/public/assets/Default-bCqU0Qz9.js +0 -1
- package/public/assets/Default-bCqU0Qz9.js.br +0 -0
- package/public/assets/Default-bCqU0Qz9.js.gz +0 -0
- package/public/assets/Invite-EVMTsuNz.js.br +0 -0
- package/public/assets/Invite-EVMTsuNz.js.gz +0 -0
- package/public/assets/MacResources-fCn40Tf5.js.br +0 -0
- package/public/assets/MacResources-fCn40Tf5.js.gz +0 -0
- package/public/assets/QASession-CL-eyFxG.js.br +0 -0
- package/public/assets/QASession-CL-eyFxG.js.gz +0 -0
- package/public/assets/ResetPassword-D-SFg4ap.js.br +0 -0
- package/public/assets/ResetPassword-D-SFg4ap.js.gz +0 -0
- package/public/assets/Setup-ULjvIfAi.js.br +0 -0
- package/public/assets/Setup-ULjvIfAi.js.gz +0 -0
- package/public/assets/Team-DH91GD4w.js.br +0 -0
- package/public/assets/Team-DH91GD4w.js.gz +0 -0
- package/public/assets/Tokens-DImLpOVZ.js +0 -1
- package/public/assets/Tokens-DImLpOVZ.js.br +0 -0
- package/public/assets/Tokens-DImLpOVZ.js.gz +0 -0
- package/public/assets/alert-dialog-CwcUyDZQ.js +0 -1
- package/public/assets/alert-dialog-CwcUyDZQ.js.br +0 -0
- package/public/assets/alert-dialog-CwcUyDZQ.js.gz +0 -0
- package/public/assets/badge-B4QAWrzA.js.br +0 -0
- package/public/assets/badge-B4QAWrzA.js.gz +0 -0
- package/public/assets/build-format-CtmRXihN.js.br +0 -0
- package/public/assets/build-format-CtmRXihN.js.gz +0 -0
- package/public/assets/dialog-BAFN_ONP.js.br +0 -0
- package/public/assets/dialog-BAFN_ONP.js.gz +0 -0
- package/public/assets/index-COOWQXpW.js.br +0 -0
- package/public/assets/index-COOWQXpW.js.gz +0 -0
- package/public/assets/index-ozCdBYxk.css +0 -1
- package/public/assets/index-ozCdBYxk.css.br +0 -0
- package/public/assets/list-view-Rrqp8TkG.js.br +0 -0
- package/public/assets/list-view-Rrqp8TkG.js.gz +0 -0
- package/public/assets/pencil-CAHyK7Qf.js.br +0 -0
- package/public/assets/pencil-CAHyK7Qf.js.gz +0 -0
- package/public/assets/resource-health-BRpSwarg.js +0 -1
- package/public/assets/resource-health-BRpSwarg.js.br +0 -0
- package/public/assets/resource-health-BRpSwarg.js.gz +0 -0
- package/public/assets/trash-CkO-5TRA.js.br +0 -0
- package/public/assets/trash-CkO-5TRA.js.gz +0 -0
package/dist/RelayServer.d.ts
CHANGED
|
@@ -27,6 +27,13 @@ export declare function ownerKeyFor(userId: number | string | undefined, claimed
|
|
|
27
27
|
user: string;
|
|
28
28
|
minted: boolean;
|
|
29
29
|
};
|
|
30
|
+
export declare const LOGS_FORBIDDEN_MESSAGE = "Logs are only available on the relay host. Run `tapflow logs` there.";
|
|
31
|
+
/**
|
|
32
|
+
* `?lines=` for `/api/v1/logs`: an integer in [1, 500], 100 when absent or not a number. A bare
|
|
33
|
+
* `Number()` let `abc` through as NaN, and `slice(-NaN)` returns the whole buffer; `-5` became
|
|
34
|
+
* `slice(5)`, everything but the oldest five.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseLogLines(raw: string | null): number;
|
|
30
37
|
export declare class RelayServer {
|
|
31
38
|
private readonly options;
|
|
32
39
|
private httpServer;
|
|
@@ -98,6 +105,23 @@ export declare class RelayServer {
|
|
|
98
105
|
/** Per-socket throttle state for `logInboundRejection`. A `WeakMap` so a closed socket's entry goes
|
|
99
106
|
* with the socket — there is no cleanup to forget, unlike the maps keyed by session id nearby. */
|
|
100
107
|
private readonly rejectionLog;
|
|
108
|
+
/**
|
|
109
|
+
* The credential each remote socket was accepted with, re-checked by `revalidateSockets` on every
|
|
110
|
+
* heartbeat and after every write that can take access away (`onAuthChanged`). Local sockets have
|
|
111
|
+
* no entry and are never re-checked: they needed no credential.
|
|
112
|
+
*/
|
|
113
|
+
private readonly principals;
|
|
114
|
+
/** For a `first-message` socket: what its first frame may make of it. Absent means anything (local). */
|
|
115
|
+
private readonly firstMessageGrants;
|
|
116
|
+
/**
|
|
117
|
+
* Sockets the relay closed because their credential stopped being valid. Their frames are dropped
|
|
118
|
+
* from the moment of the close rather than from the peer's close reply, and an agent socket among
|
|
119
|
+
* them skips the reconnect grace: the agent will be refused when it comes back, so holding its
|
|
120
|
+
* sessions for 15 s would only postpone the same end.
|
|
121
|
+
*/
|
|
122
|
+
private readonly revokedSockets;
|
|
123
|
+
/** Per-credential throttle for `logConnectionRejection`: agents retry forever on a refusal. */
|
|
124
|
+
private readonly connectionRejectLog;
|
|
101
125
|
private agentHolds;
|
|
102
126
|
private stopping;
|
|
103
127
|
private wsExternal;
|
|
@@ -138,7 +162,28 @@ export declare class RelayServer {
|
|
|
138
162
|
address(): string | import("net").AddressInfo | null;
|
|
139
163
|
/** The tunnel listener's address, or null when this relay was given no `tunnelPort`. */
|
|
140
164
|
tunnelAddress(): string | import("net").AddressInfo | null;
|
|
165
|
+
/**
|
|
166
|
+
* An install upgrading to a relay that checks an agent token's owner stops connecting agents whose
|
|
167
|
+
* token belongs to a since-demoted Admin. Say how many at boot, with the fix, rather than leaving the
|
|
168
|
+
* operator to read 1008s off each agent.
|
|
169
|
+
*/
|
|
170
|
+
private warnOrphanedAgentTokens;
|
|
141
171
|
private runHeartbeat;
|
|
172
|
+
/**
|
|
173
|
+
* Injected into the handlers whose write can take access away — member update and removal, token
|
|
174
|
+
* revocation, invitation accept — and called after the write commits. It carries no argument on
|
|
175
|
+
* purpose: the sweep re-derives every socket's standing from the database, so no handler has to know
|
|
176
|
+
* which sockets its change affects.
|
|
177
|
+
*/
|
|
178
|
+
private readonly onAuthChanged;
|
|
179
|
+
private revalidateSockets;
|
|
180
|
+
private closeForAuth;
|
|
181
|
+
/**
|
|
182
|
+
* One line per refused credential per interval. Agents retry forever on a refusal, so logging each
|
|
183
|
+
* attempt would fill the 500-line buffer with one agent's reconnects. Keyed by PAT id when there is
|
|
184
|
+
* one and by address otherwise; the first refusal of each is always written.
|
|
185
|
+
*/
|
|
186
|
+
private logConnectionRejection;
|
|
142
187
|
/**
|
|
143
188
|
* Has this socket answered recently enough to be treated as present?
|
|
144
189
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"AAuBA,OAAO,EAA4D,KAAK,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAwBjH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAQvD;AAGD;;gFAEgF;AAChF,eAAO,MAAM,uBAAuB,MAAM,CAAA;AAC1C;;sGAEsG;AACtG,eAAO,MAAM,iCAAiC,MAAM,CAAA;AAqEpD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,EACnC,OAAO,EAAE,MAAM,GAAG,IAAI,GACrB;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CAUhD;
|
|
1
|
+
{"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"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"}
|
package/dist/RelayServer.js
CHANGED
|
@@ -8,9 +8,9 @@ import { WebSocketServer, WebSocket } from 'ws';
|
|
|
8
8
|
import { SessionManager } from './SessionManager.js';
|
|
9
9
|
import { directionOf, parseInbound } from '@tapflowio/protocol/validate';
|
|
10
10
|
import { Router, json } from './router.js';
|
|
11
|
-
import { requireViewAuth, requireAuth, getAuth, verifyPat } from './middleware/auth.js';
|
|
12
|
-
import { classifyConnection } from './lib/connectionAuth.js';
|
|
13
|
-
import { isTunnelIngress, markTunnelIngress, resolveClientAddress } from './lib/clientAddress.js';
|
|
11
|
+
import { requireViewAuth, requireAuth, getAuth, verifyPat, touchPat, findPat, findUser } from './middleware/auth.js';
|
|
12
|
+
import { AGENT_SCOPE, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, revalidatePrincipal } from './lib/connectionAuth.js';
|
|
13
|
+
import { isTunnelIngress, markTunnelIngress, resolveClientAddress, resolveRequestClient } from './lib/clientAddress.js';
|
|
14
14
|
import { BuildTicketStore } from './lib/buildTickets.js';
|
|
15
15
|
import { resolveBuildFile } from './lib/buildFiles.js';
|
|
16
16
|
import { resolveCorsHeaders } from './lib/cors.js';
|
|
@@ -142,6 +142,22 @@ export function ownerKeyFor(userId, claimed) {
|
|
|
142
142
|
}
|
|
143
143
|
/** One line per rejecting socket per second, at most. See `RelayServer.logInboundRejection`. */
|
|
144
144
|
const REJECT_LOG_INTERVAL_MS = 1_000;
|
|
145
|
+
/** One line per refused credential per minute. See `RelayServer.logConnectionRejection`. */
|
|
146
|
+
const CONNECTION_REJECT_LOG_INTERVAL_MS = 60_000;
|
|
147
|
+
export const LOGS_FORBIDDEN_MESSAGE = 'Logs are only available on the relay host. Run `tapflow logs` there.';
|
|
148
|
+
/**
|
|
149
|
+
* `?lines=` for `/api/v1/logs`: an integer in [1, 500], 100 when absent or not a number. A bare
|
|
150
|
+
* `Number()` let `abc` through as NaN, and `slice(-NaN)` returns the whole buffer; `-5` became
|
|
151
|
+
* `slice(5)`, everything but the oldest five.
|
|
152
|
+
*/
|
|
153
|
+
export function parseLogLines(raw) {
|
|
154
|
+
if (raw === null || raw.trim() === '')
|
|
155
|
+
return 100;
|
|
156
|
+
const n = Number(raw);
|
|
157
|
+
if (!Number.isFinite(n))
|
|
158
|
+
return 100;
|
|
159
|
+
return Math.min(500, Math.max(1, Math.trunc(n)));
|
|
160
|
+
}
|
|
145
161
|
/** What the door refused, as one line. The throttling that decides whether to write it is the
|
|
146
162
|
* caller's, because it is per socket and this function has no state. */
|
|
147
163
|
function describeRejection(failure, suppressed) {
|
|
@@ -228,6 +244,23 @@ export class RelayServer {
|
|
|
228
244
|
/** Per-socket throttle state for `logInboundRejection`. A `WeakMap` so a closed socket's entry goes
|
|
229
245
|
* with the socket — there is no cleanup to forget, unlike the maps keyed by session id nearby. */
|
|
230
246
|
rejectionLog = new WeakMap();
|
|
247
|
+
/**
|
|
248
|
+
* The credential each remote socket was accepted with, re-checked by `revalidateSockets` on every
|
|
249
|
+
* heartbeat and after every write that can take access away (`onAuthChanged`). Local sockets have
|
|
250
|
+
* no entry and are never re-checked: they needed no credential.
|
|
251
|
+
*/
|
|
252
|
+
principals = new WeakMap();
|
|
253
|
+
/** For a `first-message` socket: what its first frame may make of it. Absent means anything (local). */
|
|
254
|
+
firstMessageGrants = new WeakMap();
|
|
255
|
+
/**
|
|
256
|
+
* Sockets the relay closed because their credential stopped being valid. Their frames are dropped
|
|
257
|
+
* from the moment of the close rather than from the peer's close reply, and an agent socket among
|
|
258
|
+
* them skips the reconnect grace: the agent will be refused when it comes back, so holding its
|
|
259
|
+
* sessions for 15 s would only postpone the same end.
|
|
260
|
+
*/
|
|
261
|
+
revokedSockets = new WeakSet();
|
|
262
|
+
/** Per-credential throttle for `logConnectionRejection`: agents retry forever on a refusal. */
|
|
263
|
+
connectionRejectLog = new Map();
|
|
231
264
|
// Agent sockets whose sessions are being held open, and the timer that gives up on each.
|
|
232
265
|
// Keyed by the dead socket, never by session id: a rebind moves sessions off that socket, so an
|
|
233
266
|
// expiry that fires late has nothing left to evict. That is the invariant — releasing the hold
|
|
@@ -331,7 +364,7 @@ export class RelayServer {
|
|
|
331
364
|
this.router.post('/api/v1/auth/reset-password', handleDoReset);
|
|
332
365
|
// invitations
|
|
333
366
|
this.router.get('/api/v1/invitations/verify', handleVerify);
|
|
334
|
-
this.router.post('/api/v1/invitations/accept', (req, res) => handleAccept(req, res, u));
|
|
367
|
+
this.router.post('/api/v1/invitations/accept', (req, res) => handleAccept(req, res, u, this.onAuthChanged));
|
|
335
368
|
// apps
|
|
336
369
|
this.router.get('/api/v1/apps', handleListApps);
|
|
337
370
|
this.router.post('/api/v1/apps', handleCreateApp);
|
|
@@ -362,13 +395,13 @@ export class RelayServer {
|
|
|
362
395
|
// team
|
|
363
396
|
this.router.get('/api/v1/team/members', handleListMembers);
|
|
364
397
|
this.router.post('/api/v1/team/invite', (req, res) => handleInvite(req, res, this.options.tunnel));
|
|
365
|
-
this.router.patch('/api/v1/team/members/:id', handleUpdateMember);
|
|
366
|
-
this.router.delete('/api/v1/team/members/:id', handleDeleteMember);
|
|
398
|
+
this.router.patch('/api/v1/team/members/:id', (req, res, params) => handleUpdateMember(req, res, params, this.onAuthChanged));
|
|
399
|
+
this.router.delete('/api/v1/team/members/:id', (req, res, params) => handleDeleteMember(req, res, params, this.onAuthChanged));
|
|
367
400
|
this.router.post('/api/v1/team/members/:id/send-reset', (req, res, params) => handleSendMemberReset(req, res, params, this.options.tunnel));
|
|
368
401
|
// tokens
|
|
369
402
|
this.router.get('/api/v1/tokens', handleListTokens);
|
|
370
403
|
this.router.post('/api/v1/tokens', handleCreateToken);
|
|
371
|
-
this.router.delete('/api/v1/tokens/:id', handleRevokeToken);
|
|
404
|
+
this.router.delete('/api/v1/tokens/:id', (req, res, params) => handleRevokeToken(req, res, params, this.onAuthChanged));
|
|
372
405
|
// settings
|
|
373
406
|
this.router.get('/api/v1/settings', handleGetSettings);
|
|
374
407
|
this.router.patch('/api/v1/settings', (req, res) => handleUpdateSettings(req, res, u));
|
|
@@ -378,10 +411,16 @@ export class RelayServer {
|
|
|
378
411
|
this.router.post('/api/v1/recordings/upload', (req, res) => handleUploadRecording(req, res, this.recordingsDir));
|
|
379
412
|
this.router.get('/api/v1/recordings', (req, res) => handleListRecordings(req, res));
|
|
380
413
|
this.router.get('/api/v1/recordings/:filename', (req, res) => handleDownloadRecording(req, res, this.recordingsDir));
|
|
381
|
-
// logs
|
|
414
|
+
// logs — the relay host only. The buffer holds the addresses of refused connections, nothing
|
|
415
|
+
// remote reads it (`tapflow logs` is its only consumer), and every line is also in the relay's own
|
|
416
|
+
// output. Same locality rule and proxy list as `auth/init` and the WebSocket handshake, so the tunnel
|
|
417
|
+
// listener is remote here too.
|
|
382
418
|
this.router.get('/api/v1/logs', (req, res) => {
|
|
419
|
+
if (!resolveRequestClient(req, this.options.trustedProxies ?? []).isLocal) {
|
|
420
|
+
return json(res, 403, { error: LOGS_FORBIDDEN_MESSAGE });
|
|
421
|
+
}
|
|
383
422
|
const url = new URL(req.url ?? '/', `http://localhost`);
|
|
384
|
-
const lines =
|
|
423
|
+
const lines = parseLogLines(url.searchParams.get('lines'));
|
|
385
424
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
386
425
|
res.end(JSON.stringify(this.logBuffer.slice(-lines)));
|
|
387
426
|
});
|
|
@@ -420,6 +459,7 @@ export class RelayServer {
|
|
|
420
459
|
// Creates this install's JWT secret if it has none. Here rather than at import, so commands
|
|
421
460
|
// that never run a relay stop leaving one behind — and a write failure is still a boot failure.
|
|
422
461
|
getJwtSecret();
|
|
462
|
+
this.warnOrphanedAgentTokens();
|
|
423
463
|
purgeExpiredRecordings(this.recordingsDir);
|
|
424
464
|
this.purgeRecordingsTimer = setInterval(() => purgeExpiredRecordings(this.recordingsDir), 24 * 60 * 60 * 1000);
|
|
425
465
|
this.purgeRecordingsTimer.unref();
|
|
@@ -529,8 +569,28 @@ export class RelayServer {
|
|
|
529
569
|
tunnelAddress() {
|
|
530
570
|
return this.tunnelServer?.address() ?? null;
|
|
531
571
|
}
|
|
572
|
+
/**
|
|
573
|
+
* An install upgrading to a relay that checks an agent token's owner stops connecting agents whose
|
|
574
|
+
* token belongs to a since-demoted Admin. Say how many at boot, with the fix, rather than leaving the
|
|
575
|
+
* operator to read 1008s off each agent.
|
|
576
|
+
*/
|
|
577
|
+
warnOrphanedAgentTokens() {
|
|
578
|
+
const row = getDb().prepare(`
|
|
579
|
+
SELECT COUNT(*) AS n FROM personal_access_tokens pat JOIN users u ON u.id = pat.user_id
|
|
580
|
+
WHERE u.role <> 'Admin'
|
|
581
|
+
AND (',' || REPLACE(pat.scope, ' ', '') || ',') LIKE '%,${AGENT_SCOPE},%'
|
|
582
|
+
AND (pat.expires_at IS NULL OR datetime(pat.expires_at) > datetime('now'))
|
|
583
|
+
`).get();
|
|
584
|
+
if (row.n > 0) {
|
|
585
|
+
logger.warn(`${row.n} agent token(s) belong to a member who is no longer an Admin and will be refused. An Admin must issue a new agent token (Dashboard → Settings → Tokens) for each agent that uses one.`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
532
588
|
// Terminate sockets that missed the previous pong; ping the rest. Covers all roles via wss.clients.
|
|
589
|
+
// Every socket's credential is re-checked on the same sweep: this is the guarantee that a removed
|
|
590
|
+
// member, a revoked or expired token, or an expired session cookie loses an open socket within one
|
|
591
|
+
// interval, whatever path the change took. `onAuthChanged` is the same check run early.
|
|
533
592
|
runHeartbeat(clients = this.wss.clients) {
|
|
593
|
+
this.revalidateSockets(clients);
|
|
534
594
|
for (const ws of clients) {
|
|
535
595
|
if (!this.isAlive(ws)) {
|
|
536
596
|
ws.terminate();
|
|
@@ -540,6 +600,77 @@ export class RelayServer {
|
|
|
540
600
|
ws.ping();
|
|
541
601
|
}
|
|
542
602
|
}
|
|
603
|
+
/**
|
|
604
|
+
* Injected into the handlers whose write can take access away — member update and removal, token
|
|
605
|
+
* revocation, invitation accept — and called after the write commits. It carries no argument on
|
|
606
|
+
* purpose: the sweep re-derives every socket's standing from the database, so no handler has to know
|
|
607
|
+
* which sockets its change affects.
|
|
608
|
+
*/
|
|
609
|
+
onAuthChanged = () => {
|
|
610
|
+
this.revalidateSockets(this.wss.clients);
|
|
611
|
+
};
|
|
612
|
+
revalidateSockets(clients) {
|
|
613
|
+
const now = Math.floor(Date.now() / 1000);
|
|
614
|
+
for (const ws of clients) {
|
|
615
|
+
const principal = this.principals.get(ws);
|
|
616
|
+
if (!principal || this.revokedSockets.has(ws) || ws.readyState !== WebSocket.OPEN)
|
|
617
|
+
continue;
|
|
618
|
+
let reason;
|
|
619
|
+
try {
|
|
620
|
+
const pat = principal.via === 'pat' ? findPat(principal.patId) : null;
|
|
621
|
+
const role = this.wsRoles.get(ws);
|
|
622
|
+
reason = revalidatePrincipal(principal, role, {
|
|
623
|
+
userExists: principal.via === 'cookie' ? findUser(principal.userId) !== null : true,
|
|
624
|
+
pat,
|
|
625
|
+
}, now);
|
|
626
|
+
// A socket that has not introduced itself yet takes what a fresh handshake would give it now.
|
|
627
|
+
if (reason === null && role === undefined && pat) {
|
|
628
|
+
const again = classifyConnection({ isLocal: false, hasCookieAuth: false, patScopes: pat.scopes, patOwnerRole: pat.ownerRole });
|
|
629
|
+
if (again.action === 'accept') {
|
|
630
|
+
this.firstMessageGrants.set(ws, again.role === 'browser'
|
|
631
|
+
? { mayBrowse: true, mayRegisterAgent: false }
|
|
632
|
+
: { mayBrowse: again.mayBrowse, mayRegisterAgent: again.mayRegisterAgent });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
catch (err) {
|
|
637
|
+
// A database fault is not a revocation: closing every socket on a transient `SQLITE_BUSY` would
|
|
638
|
+
// disconnect the whole team. The next sweep asks again.
|
|
639
|
+
logger.error('socket re-validation failed:', err);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (reason !== null)
|
|
643
|
+
this.closeForAuth(ws, reason);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
closeForAuth(ws, reason) {
|
|
647
|
+
this.revokedSockets.add(ws);
|
|
648
|
+
this.pushLog(`WS connection closed — ${reason}`);
|
|
649
|
+
ws.close(1008, reason);
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* One line per refused credential per interval. Agents retry forever on a refusal, so logging each
|
|
653
|
+
* attempt would fill the 500-line buffer with one agent's reconnects. Keyed by PAT id when there is
|
|
654
|
+
* one and by address otherwise; the first refusal of each is always written.
|
|
655
|
+
*/
|
|
656
|
+
logConnectionRejection(addr, reason, patId) {
|
|
657
|
+
const key = patId !== undefined ? `pat:${patId}` : `addr:${addr}`;
|
|
658
|
+
const now = Date.now();
|
|
659
|
+
const state = this.connectionRejectLog.get(key);
|
|
660
|
+
if (state && now - state.at < CONNECTION_REJECT_LOG_INTERVAL_MS) {
|
|
661
|
+
state.suppressed++;
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (this.connectionRejectLog.size > 1000) {
|
|
665
|
+
for (const [k, v] of this.connectionRejectLog) {
|
|
666
|
+
if (now - v.at >= CONNECTION_REJECT_LOG_INTERVAL_MS)
|
|
667
|
+
this.connectionRejectLog.delete(k);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const also = state && state.suppressed > 0 ? ` (+${state.suppressed} more in the last minute)` : '';
|
|
671
|
+
this.connectionRejectLog.set(key, { at: now, suppressed: 0 });
|
|
672
|
+
this.pushLog(`WS connection rejected from ${addr} — ${reason}${also}`);
|
|
673
|
+
}
|
|
543
674
|
/**
|
|
544
675
|
* Has this socket answered recently enough to be treated as present?
|
|
545
676
|
*
|
|
@@ -700,17 +831,50 @@ export class RelayServer {
|
|
|
700
831
|
trustedProxies: this.options.trustedProxies ?? [],
|
|
701
832
|
viaTunnel,
|
|
702
833
|
});
|
|
703
|
-
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
834
|
+
// **Everything that reads the database is inside this try.** `getAuth` and `verifyPat` both query,
|
|
835
|
+
// and this handler runs synchronously in the `connection` event: a throw here is uncaught and takes
|
|
836
|
+
// the relay process down with every session on it. A fault closes this one socket instead (1011),
|
|
837
|
+
// and is never read as "no credentials".
|
|
838
|
+
let decision;
|
|
839
|
+
let userId;
|
|
840
|
+
try {
|
|
841
|
+
// Resolved once: `hasCookieAuth`, the owner key and the principal all read this one answer. Two
|
|
842
|
+
// calls were two queries, with a window where they could disagree.
|
|
843
|
+
const cookie = getAuth(request);
|
|
844
|
+
// DB lookup — only when the connection can't be classified without it (remote, no cookie).
|
|
845
|
+
const pat = !isLocal && !cookie ? verifyPat(request) : null;
|
|
846
|
+
decision = classifyConnection({
|
|
847
|
+
isLocal,
|
|
848
|
+
hasCookieAuth: cookie !== null,
|
|
849
|
+
patScopes: pat?.scopes ?? null,
|
|
850
|
+
patOwnerRole: pat?.ownerRole ?? null,
|
|
851
|
+
});
|
|
852
|
+
if (decision.action === 'reject') {
|
|
853
|
+
this.logConnectionRejection(addr, decision.reason, pat?.patId);
|
|
854
|
+
ws.close(1008, decision.reason);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
// Only an accepted token was used; a refused one keeps its "last used".
|
|
858
|
+
if (pat)
|
|
859
|
+
touchPat(pat.patId);
|
|
860
|
+
userId = cookie?.userId ?? pat?.userId;
|
|
861
|
+
if (!isLocal) {
|
|
862
|
+
if (cookie)
|
|
863
|
+
this.principals.set(ws, { via: 'cookie', userId: cookie.userId, jwtExp: cookie.exp });
|
|
864
|
+
else if (pat)
|
|
865
|
+
this.principals.set(ws, { via: 'pat', userId: pat.userId, patId: pat.patId });
|
|
866
|
+
}
|
|
867
|
+
if (decision.role === 'first-message') {
|
|
868
|
+
this.firstMessageGrants.set(ws, { mayBrowse: decision.mayBrowse, mayRegisterAgent: decision.mayRegisterAgent });
|
|
869
|
+
}
|
|
870
|
+
else if (pat?.scopes.includes(AGENT_SCOPE)) {
|
|
871
|
+
// A `view,agent` token whose owner is no longer an Admin: browsing only, and a handshake is told why.
|
|
872
|
+
this.firstMessageGrants.set(ws, { mayBrowse: true, mayRegisterAgent: false });
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
catch (err) {
|
|
876
|
+
logger.error(`WebSocket handshake failed for ${addr}:`, err);
|
|
877
|
+
ws.close(1011, 'Internal error');
|
|
714
878
|
return;
|
|
715
879
|
}
|
|
716
880
|
this.wsExternal.set(ws, isExternalAddress(addr));
|
|
@@ -722,8 +886,8 @@ export class RelayServer {
|
|
|
722
886
|
// so every agent, `mcp-server` and `flow-runner` connection would have been `anon:` — and the whole
|
|
723
887
|
// point of pairing the claim with a user is that a leaked client id is useless to anyone else. Two
|
|
724
888
|
// processes on the same PAT are the same principal, which is right; two on different PATs are not,
|
|
725
|
-
// which is what this restores.
|
|
726
|
-
this.ownerKey.set(ws, ownerKeyFor(
|
|
889
|
+
// which is what this restores.
|
|
890
|
+
this.ownerKey.set(ws, ownerKeyFor(userId, claimed));
|
|
727
891
|
// Heartbeat liveness: a fresh socket counts as having just answered, and each pong renews it.
|
|
728
892
|
this.lastPongAt.set(ws, Date.now());
|
|
729
893
|
ws.on('pong', () => this.lastPongAt.set(ws, Date.now()));
|
|
@@ -731,6 +895,10 @@ export class RelayServer {
|
|
|
731
895
|
this.wsRoles.set(ws, 'browser');
|
|
732
896
|
// 'first-message' → role is determined by the first message (agent:register / stream:register)
|
|
733
897
|
ws.on('message', (data, isBinary) => {
|
|
898
|
+
// Closed for a credential that stopped being valid: nothing it sends after that counts, even
|
|
899
|
+
// before its close reply arrives.
|
|
900
|
+
if (this.revokedSockets.has(ws))
|
|
901
|
+
return;
|
|
734
902
|
if (isBinary) {
|
|
735
903
|
// Binary frames arrive on the dedicated stream WS, route to the session's browser
|
|
736
904
|
const session = this.sessions.getByStreamSocket(ws);
|
|
@@ -804,9 +972,9 @@ export class RelayServer {
|
|
|
804
972
|
return;
|
|
805
973
|
if (!inbound.ok) {
|
|
806
974
|
// **After the role gate, never before it**, so a browser spoofing an agent-only type gets 1008
|
|
807
|
-
// and no reply. That is the whole of the claim: an agent- or stream-role socket
|
|
808
|
-
// malformed browser request passes the gate and *is*
|
|
809
|
-
// `
|
|
975
|
+
// and no reply. That is the whole of the claim: an agent- or stream-role socket whose token also
|
|
976
|
+
// carries `view` (or a local one) sending a malformed browser request passes the gate and *is*
|
|
977
|
+
// answered — without `view` it was already closed above. Harmless, and worth stating rather than implying
|
|
810
978
|
// otherwise — such a socket already gets an answer on the well-formed path (`refuseInput` tells
|
|
811
979
|
// it `not-session-owner`), and this reply says strictly less than that one.
|
|
812
980
|
if (inbound.reason === 'bad-payload')
|
|
@@ -888,22 +1056,40 @@ export class RelayServer {
|
|
|
888
1056
|
// inverts the check below: a socket whose first frame is `screenshot:done` would be handed the
|
|
889
1057
|
// `agent` role and then waved through the gate that exists to refuse exactly that.
|
|
890
1058
|
const handshake = type === 'agent:register' || type === 'stream:register';
|
|
1059
|
+
// What the credentials allow the first frame to make of this socket (`classifyConnection`). No entry
|
|
1060
|
+
// is a socket that may become anything: a local one.
|
|
1061
|
+
const grants = this.firstMessageGrants.get(ws);
|
|
891
1062
|
if (!this.wsRoles.has(ws)) {
|
|
892
1063
|
// A handshake that did not parse confers nothing. Returning here rather than falling through to
|
|
893
1064
|
// `browser` is what keeps an agent whose register is malformed from being closed with
|
|
894
1065
|
// `Forbidden` — the caller has already logged it, and the next frame still gets to introduce it.
|
|
895
1066
|
if (handshake && !inbound.ok)
|
|
896
1067
|
return false;
|
|
1068
|
+
if (handshake && grants && !grants.mayRegisterAgent) {
|
|
1069
|
+
ws.close(1008, WS_AGENT_OWNER_REASON);
|
|
1070
|
+
return false;
|
|
1071
|
+
}
|
|
897
1072
|
if (type === 'agent:register')
|
|
898
1073
|
this.wsRoles.set(ws, 'agent');
|
|
899
1074
|
else if (type === 'stream:register')
|
|
900
1075
|
this.wsRoles.set(ws, 'stream');
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
1076
|
+
// A first message that is not an agent/stream handshake makes the socket a browser (e.g. the
|
|
1077
|
+
// dashboard opened on the same machine) — but only when its credentials could have opened a
|
|
1078
|
+
// browser socket directly. An `agent`-only token used to land here as a browser too.
|
|
1079
|
+
else if (grants && !grants.mayBrowse) {
|
|
1080
|
+
ws.close(1008, WS_SCOPE_REASON);
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
// This fallback is what puts such a socket under the gate below.
|
|
904
1084
|
else
|
|
905
1085
|
this.wsRoles.set(ws, 'browser');
|
|
906
1086
|
}
|
|
1087
|
+
// A `view,agent` token whose owner lost Admin was accepted as a browser; its handshake is told why
|
|
1088
|
+
// it cannot register rather than a bare `Forbidden`.
|
|
1089
|
+
if (handshake && this.wsRoles.get(ws) === 'browser' && grants && !grants.mayRegisterAgent) {
|
|
1090
|
+
ws.close(1008, WS_AGENT_OWNER_REASON);
|
|
1091
|
+
return false;
|
|
1092
|
+
}
|
|
907
1093
|
// Browser sockets must not spoof agent control messages.
|
|
908
1094
|
//
|
|
909
1095
|
// This was a hand-copied array of 29 literals with two type assertions holding it against the
|
|
@@ -913,6 +1099,17 @@ export class RelayServer {
|
|
|
913
1099
|
ws.close(1008, 'Forbidden');
|
|
914
1100
|
return false;
|
|
915
1101
|
}
|
|
1102
|
+
// The other direction of the same rule. An agent or stream socket on a token without `view` may
|
|
1103
|
+
// not act as a browser after its handshake either — `session:start`, `input:*`, `device:*` — or an
|
|
1104
|
+
// `agent`-only token would drive devices just by registering first. No agent needs this: their
|
|
1105
|
+
// text frames go through `sendMsg`/`sendOn`, typed `AgentToRelay | AgentToBrowser`, and the stream
|
|
1106
|
+
// socket sends only `stream:register`; none of those types is browser-direction (checked against
|
|
1107
|
+
// `BrowserToRelay` when this was added). Local agents carry no grants and are not gated.
|
|
1108
|
+
const role = this.wsRoles.get(ws);
|
|
1109
|
+
if ((role === 'agent' || role === 'stream') && grants && !grants.mayBrowse && directionOf(type) === 'browser') {
|
|
1110
|
+
ws.close(1008, WS_SCOPE_REASON);
|
|
1111
|
+
return false;
|
|
1112
|
+
}
|
|
916
1113
|
return true;
|
|
917
1114
|
}
|
|
918
1115
|
/**
|
|
@@ -1453,9 +1650,17 @@ export class RelayServer {
|
|
|
1453
1650
|
*/
|
|
1454
1651
|
holdAgentSocket(ws) {
|
|
1455
1652
|
// Shutting down: nothing is coming back, and arming a timer here would leave it running after
|
|
1456
|
-
// `stop()` resolved.
|
|
1653
|
+
// `stop()` resolved. Closed by the relay for a credential that is no longer valid: the agent will be
|
|
1654
|
+
// refused when it returns, so a hold would only postpone the same end.
|
|
1457
1655
|
if (this.stopping)
|
|
1458
1656
|
return this.evictAgentSocket(ws);
|
|
1657
|
+
if (this.revokedSockets.has(ws)) {
|
|
1658
|
+
const evicted = this.evictAgentSocket(ws);
|
|
1659
|
+
// `evictAgentSocket` keeps the resource entry when there were no sessions to end.
|
|
1660
|
+
if (!evicted)
|
|
1661
|
+
this.sessions.removeResources(ws);
|
|
1662
|
+
return evicted;
|
|
1663
|
+
}
|
|
1459
1664
|
const sessions = this.sessions.getAllByAgentSocket(ws);
|
|
1460
1665
|
if (sessions.length === 0) {
|
|
1461
1666
|
// No sessions to hold, but the socket may still own a resource entry — `evictAgentSocket`
|
|
@@ -2340,8 +2545,18 @@ export class RelayServer {
|
|
|
2340
2545
|
}
|
|
2341
2546
|
}
|
|
2342
2547
|
if (url.startsWith('/uploads/')) {
|
|
2343
|
-
|
|
2548
|
+
// Outside the router, so outside its catch: the auth check reads the database, and a throw here
|
|
2549
|
+
// would be an unhandled rejection that ends the process.
|
|
2550
|
+
try {
|
|
2551
|
+
if (!requireViewAuth(req, res))
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
catch (err) {
|
|
2555
|
+
logger.error('upload auth failed:', err);
|
|
2556
|
+
if (!res.headersSent)
|
|
2557
|
+
json(res, 500, { error: 'Internal server error' });
|
|
2344
2558
|
return;
|
|
2559
|
+
}
|
|
2345
2560
|
this.serveUpload(req, res);
|
|
2346
2561
|
return;
|
|
2347
2562
|
}
|