@algochad/archcoder 2.0.2
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 +113 -0
- package/bin/cli-entry.js +55 -0
- package/bin/cli-output.js +145 -0
- package/bin/cli.js +5108 -0
- package/bin/cli.test.js +56 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +67 -0
- package/dist/assets/MultiRunWindow-BZp3MjJP.js +1 -0
- package/dist/assets/SettingsWindow-DoGYXpX7.js +1 -0
- package/dist/assets/TerminalView-BN7BR5Ff.js +3 -0
- package/dist/assets/TimelineDialog-ZQ33oVQR.js +1 -0
- package/dist/assets/ToolOutputDialog-Blv3pnug.js +16 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/index-CtCEGYrr.css +1 -0
- package/dist/assets/index-o_d2wtWC.js +48 -0
- package/dist/assets/main-5QGBtzdq.css +1 -0
- package/dist/assets/main-B6oiMU86.js +8033 -0
- package/dist/assets/vendor--DbVqbJpV.css +1 -0
- package/dist/assets/vendor-.bun-HTKwyaEM.js +10086 -0
- package/dist/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/assets/worker-bqd4RMrj.js +155 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +67 -0
- package/dist/index.html +533 -0
- package/dist/logo-dark-192x192.png +0 -0
- package/dist/logo-dark-512x512.svg +16 -0
- package/dist/logo-light-192x192.png +0 -0
- package/dist/logo-light-512x512.svg +16 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/pwa-maskable-192.png +0 -0
- package/dist/pwa-maskable-512.png +0 -0
- package/dist/site.webmanifest +22 -0
- package/dist/sw.js +1 -0
- package/package.json +107 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +67 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +67 -0
- package/public/logo-dark-192x192.png +0 -0
- package/public/logo-dark-512x512.svg +16 -0
- package/public/logo-light-192x192.png +0 -0
- package/public/logo-light-512x512.svg +16 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/pwa-maskable-192.png +0 -0
- package/public/pwa-maskable-512.png +0 -0
- package/public/site.webmanifest +22 -0
- package/server/TERMINAL_INPUT_WS_PROTOCOL.md +44 -0
- package/server/index.d.ts +37 -0
- package/server/index.js +14694 -0
- package/server/lib/cloudflare-tunnel.js +650 -0
- package/server/lib/git/DOCUMENTATION.md +146 -0
- package/server/lib/git/credentials.js +74 -0
- package/server/lib/git/identity-storage.js +110 -0
- package/server/lib/git/index.js +6 -0
- package/server/lib/git/service.js +3117 -0
- package/server/lib/github/DOCUMENTATION.md +170 -0
- package/server/lib/github/auth.js +307 -0
- package/server/lib/github/device-flow.js +50 -0
- package/server/lib/github/index.js +24 -0
- package/server/lib/github/octokit.js +10 -0
- package/server/lib/github/pr-status.js +478 -0
- package/server/lib/github/repo/index.js +55 -0
- package/server/lib/installer/desktop.js +289 -0
- package/server/lib/installer/download.js +208 -0
- package/server/lib/installer/index.js +45 -0
- package/server/lib/installer/platform.js +100 -0
- package/server/lib/notifications/DOCUMENTATION.md +61 -0
- package/server/lib/notifications/index.js +1 -0
- package/server/lib/notifications/message.js +49 -0
- package/server/lib/notifications/message.test.js +59 -0
- package/server/lib/opencode/DOCUMENTATION.md +59 -0
- package/server/lib/opencode/agents.js +634 -0
- package/server/lib/opencode/auth.js +81 -0
- package/server/lib/opencode/commands.js +339 -0
- package/server/lib/opencode/index.js +66 -0
- package/server/lib/opencode/mcp.js +206 -0
- package/server/lib/opencode/providers.js +96 -0
- package/server/lib/opencode/shared.js +527 -0
- package/server/lib/opencode/skills.js +480 -0
- package/server/lib/opencode/tunnel-auth.js +591 -0
- package/server/lib/opencode/ui-auth.js +510 -0
- package/server/lib/package-manager.js +505 -0
- package/server/lib/quota/DOCUMENTATION.md +55 -0
- package/server/lib/quota/index.js +24 -0
- package/server/lib/quota/providers/claude.js +107 -0
- package/server/lib/quota/providers/codex.js +113 -0
- package/server/lib/quota/providers/copilot.js +165 -0
- package/server/lib/quota/providers/google/api.js +92 -0
- package/server/lib/quota/providers/google/auth.js +108 -0
- package/server/lib/quota/providers/google/index.js +124 -0
- package/server/lib/quota/providers/google/transforms.js +109 -0
- package/server/lib/quota/providers/index.js +152 -0
- package/server/lib/quota/providers/interface.js +55 -0
- package/server/lib/quota/providers/kimi.js +108 -0
- package/server/lib/quota/providers/minimax-cn-coding-plan.js +15 -0
- package/server/lib/quota/providers/minimax-coding-plan.js +15 -0
- package/server/lib/quota/providers/minimax-shared.js +136 -0
- package/server/lib/quota/providers/nanogpt.js +124 -0
- package/server/lib/quota/providers/ollama-cloud.js +112 -0
- package/server/lib/quota/providers/openai.js +91 -0
- package/server/lib/quota/providers/openrouter.js +92 -0
- package/server/lib/quota/providers/zai.js +91 -0
- package/server/lib/quota/utils/auth.js +46 -0
- package/server/lib/quota/utils/formatters.js +76 -0
- package/server/lib/quota/utils/index.js +10 -0
- package/server/lib/quota/utils/transformers.js +55 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
- package/server/lib/skills-catalog/cache.js +32 -0
- package/server/lib/skills-catalog/clawdhub/api.js +158 -0
- package/server/lib/skills-catalog/clawdhub/index.js +30 -0
- package/server/lib/skills-catalog/clawdhub/install.js +238 -0
- package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
- package/server/lib/skills-catalog/curated-sources.js +21 -0
- package/server/lib/skills-catalog/git.js +77 -0
- package/server/lib/skills-catalog/index.js +42 -0
- package/server/lib/skills-catalog/install.js +294 -0
- package/server/lib/skills-catalog/scan.js +221 -0
- package/server/lib/skills-catalog/source.js +85 -0
- package/server/lib/terminal/DOCUMENTATION.md +114 -0
- package/server/lib/terminal/index.js +12 -0
- package/server/lib/terminal/input-ws-protocol.js +66 -0
- package/server/lib/terminal/input-ws-protocol.test.js +138 -0
- package/server/lib/tts/DOCUMENTATION.md +134 -0
- package/server/lib/tts/index.js +16 -0
- package/server/lib/tts/service.js +162 -0
- package/server/lib/tts/summarization.js +171 -0
- package/server/lib/tunnels/index.js +166 -0
- package/server/lib/tunnels/providers/cloudflare.js +260 -0
- package/server/lib/tunnels/registry.js +51 -0
- package/server/lib/tunnels/types.js +219 -0
- package/server/lib/utils/lru.js +107 -0
- package/server/lib/utils/sse.js +121 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
const BOOTSTRAP_TOKEN_COOKIE_SAFE_BYTES = 32;
|
|
4
|
+
const TUNNEL_SESSION_COOKIE_NAME = 'oc_tunnel_session';
|
|
5
|
+
|
|
6
|
+
const CONNECT_RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
|
7
|
+
const CONNECT_RATE_LIMIT_LOCK_MS = 10 * 60 * 1000;
|
|
8
|
+
const CONNECT_RATE_LIMIT_MAX_ATTEMPTS = 20;
|
|
9
|
+
const CONNECT_RATE_LIMIT_NO_IP_MAX_ATTEMPTS = 5;
|
|
10
|
+
|
|
11
|
+
const parseCookies = (cookieHeader) => {
|
|
12
|
+
if (!cookieHeader || typeof cookieHeader !== 'string') {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return cookieHeader.split(';').reduce((acc, segment) => {
|
|
17
|
+
const [name, ...rest] = segment.split('=');
|
|
18
|
+
if (!name) {
|
|
19
|
+
return acc;
|
|
20
|
+
}
|
|
21
|
+
const key = name.trim();
|
|
22
|
+
if (!key) {
|
|
23
|
+
return acc;
|
|
24
|
+
}
|
|
25
|
+
const value = rest.join('=').trim();
|
|
26
|
+
acc[key] = decodeURIComponent(value || '');
|
|
27
|
+
return acc;
|
|
28
|
+
}, {});
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const isSecureRequest = (req) => {
|
|
32
|
+
if (req.secure) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const forwardedProto = req.headers['x-forwarded-proto'];
|
|
36
|
+
if (typeof forwardedProto === 'string') {
|
|
37
|
+
const firstProto = forwardedProto.split(',')[0]?.trim().toLowerCase();
|
|
38
|
+
return firstProto === 'https';
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const buildCookie = ({ name, value, maxAge, secure }) => {
|
|
44
|
+
const attributes = [
|
|
45
|
+
`${name}=${value}`,
|
|
46
|
+
'Path=/',
|
|
47
|
+
'HttpOnly',
|
|
48
|
+
'SameSite=Lax',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
if (typeof maxAge === 'number') {
|
|
52
|
+
attributes.push(`Max-Age=${Math.max(0, Math.floor(maxAge))}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const expires = maxAge === 0
|
|
56
|
+
? 'Thu, 01 Jan 1970 00:00:00 GMT'
|
|
57
|
+
: new Date(Date.now() + maxAge * 1000).toUTCString();
|
|
58
|
+
|
|
59
|
+
attributes.push(`Expires=${expires}`);
|
|
60
|
+
|
|
61
|
+
if (secure) {
|
|
62
|
+
attributes.push('Secure');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return attributes.join('; ');
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const nowTs = () => Date.now();
|
|
69
|
+
|
|
70
|
+
const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
|
|
71
|
+
|
|
72
|
+
const normalizeHost = (candidate) => {
|
|
73
|
+
if (typeof candidate !== 'string') {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const trimmed = candidate.trim().toLowerCase();
|
|
77
|
+
if (!trimmed) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return trimmed.replace(/:\d+$/, '');
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const normalizeIpCandidate = (candidate) => {
|
|
84
|
+
if (typeof candidate !== 'string') {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const trimmed = candidate.trim().toLowerCase();
|
|
89
|
+
if (!trimmed) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const withoutBrackets = trimmed.startsWith('[') && trimmed.endsWith(']')
|
|
94
|
+
? trimmed.slice(1, -1)
|
|
95
|
+
: trimmed;
|
|
96
|
+
|
|
97
|
+
const withoutZone = withoutBrackets.split('%')[0];
|
|
98
|
+
if (!withoutZone) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (withoutZone.startsWith('::ffff:')) {
|
|
103
|
+
const mappedIpv4 = withoutZone.slice('::ffff:'.length);
|
|
104
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(mappedIpv4)) {
|
|
105
|
+
return mappedIpv4;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return withoutZone;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const getSocketRemoteIp = (req) => {
|
|
113
|
+
const remoteAddress = req?.socket?.remoteAddress || req?.connection?.remoteAddress;
|
|
114
|
+
return normalizeIpCandidate(remoteAddress);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const isPrivateOrLoopbackIpv4 = (candidate) => {
|
|
118
|
+
const octets = candidate.split('.').map((part) => Number(part));
|
|
119
|
+
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const [first, second] = octets;
|
|
124
|
+
if (first === 127) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (first === 10) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
if (first === 172 && second >= 16 && second <= 31) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
if (first === 192 && second === 168) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (first === 169 && second === 254) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const isPrivateOrLoopbackIpv6 = (candidate) => {
|
|
143
|
+
if (candidate === '::1') {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (candidate.startsWith('fc') || candidate.startsWith('fd')) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return candidate.startsWith('fe8')
|
|
152
|
+
|| candidate.startsWith('fe9')
|
|
153
|
+
|| candidate.startsWith('fea')
|
|
154
|
+
|| candidate.startsWith('feb');
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const isPrivateOrLoopbackIp = (candidate) => {
|
|
158
|
+
const normalized = normalizeIpCandidate(candidate);
|
|
159
|
+
if (!normalized) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (normalized.includes(':')) {
|
|
164
|
+
return isPrivateOrLoopbackIpv6(normalized);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return isPrivateOrLoopbackIpv4(normalized);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const isLocalHost = (host, req) => {
|
|
171
|
+
if (!host) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (host === 'host.docker.internal') {
|
|
180
|
+
return isPrivateOrLoopbackIp(getSocketRemoteIp(req));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return false;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const getClientIp = (req) => {
|
|
187
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
188
|
+
if (typeof forwarded === 'string') {
|
|
189
|
+
const ip = forwarded.split(',')[0].trim();
|
|
190
|
+
if (ip.startsWith('::ffff:')) {
|
|
191
|
+
return ip.substring(7);
|
|
192
|
+
}
|
|
193
|
+
return ip;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const ip = req.ip || req.connection?.remoteAddress;
|
|
197
|
+
if (ip) {
|
|
198
|
+
if (ip.startsWith('::ffff:')) {
|
|
199
|
+
return ip.substring(7);
|
|
200
|
+
}
|
|
201
|
+
return ip;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const getRateLimitKey = (req) => {
|
|
207
|
+
const ip = getClientIp(req);
|
|
208
|
+
if (ip) {
|
|
209
|
+
return ip;
|
|
210
|
+
}
|
|
211
|
+
return 'connect-rate-limit:no-ip';
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const rateLimitMaxForKey = (key) => {
|
|
215
|
+
if (key === 'connect-rate-limit:no-ip') {
|
|
216
|
+
return CONNECT_RATE_LIMIT_NO_IP_MAX_ATTEMPTS;
|
|
217
|
+
}
|
|
218
|
+
return CONNECT_RATE_LIMIT_MAX_ATTEMPTS;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export const createTunnelAuth = () => {
|
|
222
|
+
let activeTunnelId = null;
|
|
223
|
+
let activeTunnelHost = null;
|
|
224
|
+
let activeTunnelMode = null;
|
|
225
|
+
let activeTunnelPublicUrl = null;
|
|
226
|
+
let bootstrapRecord = null;
|
|
227
|
+
|
|
228
|
+
const tunnelSessions = new Map();
|
|
229
|
+
const connectRateLimiter = new Map();
|
|
230
|
+
|
|
231
|
+
const clearTunnelSessionCookie = (req, res) => {
|
|
232
|
+
const secure = isSecureRequest(req);
|
|
233
|
+
const header = buildCookie({
|
|
234
|
+
name: TUNNEL_SESSION_COOKIE_NAME,
|
|
235
|
+
value: '',
|
|
236
|
+
maxAge: 0,
|
|
237
|
+
secure,
|
|
238
|
+
});
|
|
239
|
+
res.setHeader('Set-Cookie', header);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const setTunnelSessionCookie = (req, res, sessionId, ttlMs) => {
|
|
243
|
+
const secure = isSecureRequest(req);
|
|
244
|
+
const maxAge = Math.max(0, Math.floor(ttlMs / 1000));
|
|
245
|
+
const header = buildCookie({
|
|
246
|
+
name: TUNNEL_SESSION_COOKIE_NAME,
|
|
247
|
+
value: encodeURIComponent(sessionId),
|
|
248
|
+
maxAge,
|
|
249
|
+
secure,
|
|
250
|
+
});
|
|
251
|
+
res.setHeader('Set-Cookie', header);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const classifyRequestScope = (req) => {
|
|
255
|
+
const hostHeader = normalizeHost(typeof req.headers.host === 'string' ? req.headers.host : '');
|
|
256
|
+
const reqHost = normalizeHost(typeof req.hostname === 'string' ? req.hostname : '') || hostHeader;
|
|
257
|
+
|
|
258
|
+
if (activeTunnelHost && reqHost === activeTunnelHost) {
|
|
259
|
+
return 'tunnel';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (isLocalHost(reqHost, req)) {
|
|
263
|
+
return 'local';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!activeTunnelId) {
|
|
267
|
+
return 'local';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return 'unknown-public';
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const revokeBootstrapToken = () => {
|
|
274
|
+
if (!bootstrapRecord) {
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
if (bootstrapRecord.revokedAt) {
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
if (!bootstrapRecord.revokedAt) {
|
|
281
|
+
bootstrapRecord.revokedAt = nowTs();
|
|
282
|
+
}
|
|
283
|
+
return 1;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const invalidateTunnelSessions = (tunnelId, reason = 'tunnel-stopped') => {
|
|
287
|
+
const revokedAt = nowTs();
|
|
288
|
+
let count = 0;
|
|
289
|
+
for (const record of tunnelSessions.values()) {
|
|
290
|
+
if (record.tunnelId === tunnelId && !record.revokedAt) {
|
|
291
|
+
record.revokedAt = revokedAt;
|
|
292
|
+
record.revokedReason = reason;
|
|
293
|
+
count += 1;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return count;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const revokeTunnelArtifacts = (tunnelId) => {
|
|
300
|
+
const revokedBootstrapCount = bootstrapRecord && bootstrapRecord.tunnelId === tunnelId
|
|
301
|
+
? revokeBootstrapToken()
|
|
302
|
+
: 0;
|
|
303
|
+
const invalidatedSessionCount = invalidateTunnelSessions(tunnelId, 'tunnel-revoked');
|
|
304
|
+
return { revokedBootstrapCount, invalidatedSessionCount };
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const setActiveTunnel = ({ tunnelId, publicUrl, mode = null }) => {
|
|
308
|
+
activeTunnelId = tunnelId;
|
|
309
|
+
activeTunnelMode = mode;
|
|
310
|
+
activeTunnelPublicUrl = publicUrl || null;
|
|
311
|
+
try {
|
|
312
|
+
activeTunnelHost = normalizeHost(new URL(publicUrl).host);
|
|
313
|
+
} catch {
|
|
314
|
+
activeTunnelHost = null;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const clearActiveTunnel = () => {
|
|
319
|
+
if (activeTunnelId) {
|
|
320
|
+
revokeTunnelArtifacts(activeTunnelId);
|
|
321
|
+
}
|
|
322
|
+
activeTunnelId = null;
|
|
323
|
+
activeTunnelHost = null;
|
|
324
|
+
activeTunnelMode = null;
|
|
325
|
+
activeTunnelPublicUrl = null;
|
|
326
|
+
bootstrapRecord = null;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const isBootstrapRecordUsable = (record) => {
|
|
330
|
+
if (!record || record.revokedAt || record.usedAt) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
if (typeof record.expiresAt === 'number' && nowTs() >= record.expiresAt) {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const issueBootstrapToken = ({ ttlMs }) => {
|
|
340
|
+
if (!activeTunnelId) {
|
|
341
|
+
throw new Error('Tunnel is not active');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
revokeBootstrapToken();
|
|
345
|
+
|
|
346
|
+
const token = crypto.randomBytes(BOOTSTRAP_TOKEN_COOKIE_SAFE_BYTES).toString('base64url');
|
|
347
|
+
const issuedAt = nowTs();
|
|
348
|
+
const expiresAt = Number.isFinite(ttlMs) && ttlMs > 0 ? issuedAt + ttlMs : null;
|
|
349
|
+
|
|
350
|
+
bootstrapRecord = {
|
|
351
|
+
id: crypto.randomUUID(),
|
|
352
|
+
tunnelId: activeTunnelId,
|
|
353
|
+
tokenHash: hashToken(token),
|
|
354
|
+
issuedAt,
|
|
355
|
+
expiresAt,
|
|
356
|
+
usedAt: null,
|
|
357
|
+
revokedAt: null,
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
token,
|
|
362
|
+
expiresAt,
|
|
363
|
+
};
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const getBootstrapStatus = () => {
|
|
367
|
+
if (!isBootstrapRecordUsable(bootstrapRecord)) {
|
|
368
|
+
return {
|
|
369
|
+
hasBootstrapToken: false,
|
|
370
|
+
bootstrapExpiresAt: null,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
hasBootstrapToken: true,
|
|
376
|
+
bootstrapExpiresAt: bootstrapRecord.expiresAt,
|
|
377
|
+
};
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const checkConnectRateLimit = (req) => {
|
|
381
|
+
const key = getRateLimitKey(req);
|
|
382
|
+
const now = nowTs();
|
|
383
|
+
const maxAttempts = rateLimitMaxForKey(key);
|
|
384
|
+
const record = connectRateLimiter.get(key);
|
|
385
|
+
|
|
386
|
+
if (record?.lockedUntil && now < record.lockedUntil) {
|
|
387
|
+
return {
|
|
388
|
+
allowed: false,
|
|
389
|
+
retryAfter: Math.ceil((record.lockedUntil - now) / 1000),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!record || now - record.lastAttempt > CONNECT_RATE_LIMIT_WINDOW_MS) {
|
|
394
|
+
return { allowed: true, retryAfter: 0 };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (record.count >= maxAttempts) {
|
|
398
|
+
const lockedUntil = now + CONNECT_RATE_LIMIT_LOCK_MS;
|
|
399
|
+
connectRateLimiter.set(key, {
|
|
400
|
+
count: record.count + 1,
|
|
401
|
+
lastAttempt: now,
|
|
402
|
+
lockedUntil,
|
|
403
|
+
});
|
|
404
|
+
return {
|
|
405
|
+
allowed: false,
|
|
406
|
+
retryAfter: Math.ceil(CONNECT_RATE_LIMIT_LOCK_MS / 1000),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return { allowed: true, retryAfter: 0 };
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const recordConnectFailedAttempt = (req) => {
|
|
414
|
+
const key = getRateLimitKey(req);
|
|
415
|
+
const now = nowTs();
|
|
416
|
+
const record = connectRateLimiter.get(key);
|
|
417
|
+
|
|
418
|
+
if (!record || now - record.lastAttempt > CONNECT_RATE_LIMIT_WINDOW_MS) {
|
|
419
|
+
connectRateLimiter.set(key, { count: 1, lastAttempt: now, lockedUntil: null });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
connectRateLimiter.set(key, {
|
|
424
|
+
count: record.count + 1,
|
|
425
|
+
lastAttempt: now,
|
|
426
|
+
lockedUntil: record.lockedUntil || null,
|
|
427
|
+
});
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const clearConnectRateLimit = (req) => {
|
|
431
|
+
const key = getRateLimitKey(req);
|
|
432
|
+
connectRateLimiter.delete(key);
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const getTunnelSessionFromRequest = (req) => {
|
|
436
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
437
|
+
const token = cookies[TUNNEL_SESSION_COOKIE_NAME];
|
|
438
|
+
if (!token) {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
const session = tunnelSessions.get(token);
|
|
442
|
+
if (!session) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
if (session.revokedAt) {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
if (session.expiresAt <= nowTs()) {
|
|
449
|
+
if (!session.expiredAt) {
|
|
450
|
+
session.expiredAt = nowTs();
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
if (session.tunnelId !== activeTunnelId) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
session.lastSeenAt = nowTs();
|
|
458
|
+
return session;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const requireTunnelSession = (req, res, next) => {
|
|
462
|
+
const session = getTunnelSessionFromRequest(req);
|
|
463
|
+
if (session) {
|
|
464
|
+
return next();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
clearTunnelSessionCookie(req, res);
|
|
468
|
+
res.status(401).json({
|
|
469
|
+
error: 'Tunnel authentication required',
|
|
470
|
+
locked: true,
|
|
471
|
+
tunnelLocked: true,
|
|
472
|
+
});
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const exchangeBootstrapToken = ({ req, res, token, sessionTtlMs }) => {
|
|
476
|
+
const rateLimit = checkConnectRateLimit(req);
|
|
477
|
+
if (!rateLimit.allowed) {
|
|
478
|
+
return {
|
|
479
|
+
ok: false,
|
|
480
|
+
reason: 'rate-limited',
|
|
481
|
+
retryAfter: rateLimit.retryAfter,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!activeTunnelId || !bootstrapRecord) {
|
|
486
|
+
recordConnectFailedAttempt(req);
|
|
487
|
+
return { ok: false, reason: 'inactive' };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (!token || typeof token !== 'string') {
|
|
491
|
+
recordConnectFailedAttempt(req);
|
|
492
|
+
return { ok: false, reason: 'missing-token' };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (!isBootstrapRecordUsable(bootstrapRecord)) {
|
|
496
|
+
recordConnectFailedAttempt(req);
|
|
497
|
+
return { ok: false, reason: 'expired' };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (bootstrapRecord.tunnelId !== activeTunnelId) {
|
|
501
|
+
recordConnectFailedAttempt(req);
|
|
502
|
+
return { ok: false, reason: 'tunnel-mismatch' };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const incomingHash = hashToken(token);
|
|
506
|
+
const expected = bootstrapRecord.tokenHash;
|
|
507
|
+
const validHash = incomingHash.length === expected.length
|
|
508
|
+
&& crypto.timingSafeEqual(Buffer.from(incomingHash), Buffer.from(expected));
|
|
509
|
+
|
|
510
|
+
if (!validHash) {
|
|
511
|
+
recordConnectFailedAttempt(req);
|
|
512
|
+
return { ok: false, reason: 'invalid-token' };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
bootstrapRecord.usedAt = nowTs();
|
|
516
|
+
clearConnectRateLimit(req);
|
|
517
|
+
|
|
518
|
+
const sessionId = crypto.randomBytes(32).toString('base64url');
|
|
519
|
+
const createdAt = nowTs();
|
|
520
|
+
const expiresAt = createdAt + sessionTtlMs;
|
|
521
|
+
|
|
522
|
+
tunnelSessions.set(sessionId, {
|
|
523
|
+
sessionId,
|
|
524
|
+
tunnelId: activeTunnelId,
|
|
525
|
+
mode: activeTunnelMode,
|
|
526
|
+
publicUrl: activeTunnelPublicUrl,
|
|
527
|
+
createdAt,
|
|
528
|
+
lastSeenAt: createdAt,
|
|
529
|
+
expiresAt,
|
|
530
|
+
revokedAt: null,
|
|
531
|
+
revokedReason: null,
|
|
532
|
+
expiredAt: null,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
setTunnelSessionCookie(req, res, sessionId, sessionTtlMs);
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
ok: true,
|
|
539
|
+
sessionExpiresAt: expiresAt,
|
|
540
|
+
};
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const listTunnelSessions = () => {
|
|
544
|
+
const now = nowTs();
|
|
545
|
+
|
|
546
|
+
const sessions = [];
|
|
547
|
+
for (const record of tunnelSessions.values()) {
|
|
548
|
+
const isExpired = record.expiresAt <= now;
|
|
549
|
+
if (isExpired && !record.expiredAt) {
|
|
550
|
+
record.expiredAt = now;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const active = !record.revokedAt && !isExpired && record.tunnelId === activeTunnelId;
|
|
554
|
+
const status = active ? 'active' : 'inactive';
|
|
555
|
+
const inactiveReason = record.revokedAt ? (record.revokedReason || 'revoked') : (isExpired ? 'expired' : 'inactive');
|
|
556
|
+
|
|
557
|
+
sessions.push({
|
|
558
|
+
sessionId: record.sessionId,
|
|
559
|
+
tunnelId: record.tunnelId,
|
|
560
|
+
mode: record.mode,
|
|
561
|
+
publicUrl: record.publicUrl,
|
|
562
|
+
createdAt: record.createdAt,
|
|
563
|
+
lastSeenAt: record.lastSeenAt,
|
|
564
|
+
expiresAt: record.expiresAt,
|
|
565
|
+
revokedAt: record.revokedAt,
|
|
566
|
+
status,
|
|
567
|
+
inactiveReason: status === 'inactive' ? inactiveReason : null,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
sessions.sort((a, b) => b.createdAt - a.createdAt);
|
|
572
|
+
return sessions;
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
classifyRequestScope,
|
|
577
|
+
setActiveTunnel,
|
|
578
|
+
clearActiveTunnel,
|
|
579
|
+
revokeTunnelArtifacts,
|
|
580
|
+
issueBootstrapToken,
|
|
581
|
+
getBootstrapStatus,
|
|
582
|
+
requireTunnelSession,
|
|
583
|
+
getTunnelSessionFromRequest,
|
|
584
|
+
exchangeBootstrapToken,
|
|
585
|
+
listTunnelSessions,
|
|
586
|
+
clearTunnelSessionCookie,
|
|
587
|
+
getActiveTunnelId: () => activeTunnelId,
|
|
588
|
+
getActiveTunnelHost: () => activeTunnelHost,
|
|
589
|
+
getActiveTunnelMode: () => activeTunnelMode,
|
|
590
|
+
};
|
|
591
|
+
};
|