orbit-codex-bridge 0.0.0-stage → 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/README.md +189 -2
- package/cli.mjs +244 -0
- package/lib/desktop.mjs +404 -0
- package/lib/device-auth.mjs +69 -0
- package/lib/rpc.mjs +203 -0
- package/lib/store.mjs +162 -0
- package/orbit +4 -0
- package/package.json +33 -4
- package/public/admin.css +67 -0
- package/public/admin.html +23 -0
- package/public/admin.js +186 -0
- package/public/app.js +1035 -0
- package/public/connect.html +56 -0
- package/public/connect.js +126 -0
- package/public/icon.svg +1 -0
- package/public/index.html +38 -0
- package/public/layout.css +73 -0
- package/public/manifest.webmanifest +1 -0
- package/public/style.css +40 -0
- package/relay-agent.mjs +181 -0
- package/relay-server.mjs +684 -0
- package/relay-store.mjs +368 -0
- package/server.mjs +485 -0
package/relay-server.mjs
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
9
|
+
import { RelayStore } from './relay-store.mjs';
|
|
10
|
+
import { DeviceAuthorization } from './lib/device-auth.mjs';
|
|
11
|
+
|
|
12
|
+
const root = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const maxBody = 12 * 1024 * 1024;
|
|
14
|
+
const userSessionLifetime = 12 * 60 * 60 * 1000;
|
|
15
|
+
const adminSessionLifetime = 8 * 60 * 60 * 1000;
|
|
16
|
+
const safeError = error => String(error?.message || error).slice(0, 700);
|
|
17
|
+
|
|
18
|
+
function equalSecret(left, right) {
|
|
19
|
+
const a = Buffer.from(String(left || ''));
|
|
20
|
+
const b = Buffer.from(String(right || ''));
|
|
21
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cookieValue(request, name) {
|
|
25
|
+
return (request.headers.cookie || '').split(';').map(part => part.trim())
|
|
26
|
+
.find(part => part.startsWith(`${name}=`))?.slice(name.length + 1) || '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function originFor(request) {
|
|
30
|
+
const protocol = request.headers['x-forwarded-proto']?.toString().split(',')[0].trim()
|
|
31
|
+
|| (request.socket.encrypted ? 'https' : 'http');
|
|
32
|
+
return `${protocol}://${request.headers.host}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function secureTransport(request) {
|
|
36
|
+
return Boolean(request.socket.encrypted)
|
|
37
|
+
|| request.headers['x-forwarded-proto']?.toString().split(',')[0].trim() === 'https';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clientIp(request) {
|
|
41
|
+
const remote = request.socket.remoteAddress || 'unknown';
|
|
42
|
+
const trustedProxy = ['127.0.0.1','::1','::ffff:127.0.0.1'].includes(remote);
|
|
43
|
+
const forwarded = request.headers['x-forwarded-for']?.toString().split(',').map(value => value.trim()).filter(Boolean).at(-1);
|
|
44
|
+
return trustedProxy && forwarded && net.isIP(forwarded) ? forwarded : remote;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sameOrigin(request, requireOrigin = false) {
|
|
48
|
+
const origin = request.headers.origin;
|
|
49
|
+
return origin ? origin === originFor(request) : !requireOrigin;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rejectUpgrade(socket, status = 403, message = 'Forbidden') {
|
|
53
|
+
socket.end(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function deviceCookieName(deviceId) {
|
|
57
|
+
return `orbit_device_${deviceId.replaceAll('-','')}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function localCookie(request, deviceId) {
|
|
61
|
+
const value = cookieValue(request, deviceCookieName(deviceId));
|
|
62
|
+
return /^[a-zA-Z0-9_-]{20,100}$/.test(value) ? `orbit_session=${value}` : '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function responseHeaders(headers, deviceId, secureCookie) {
|
|
66
|
+
const safe = { 'Content-Type':'application/json; charset=utf-8' };
|
|
67
|
+
const cookies = headers?.['set-cookie'];
|
|
68
|
+
const values = Array.isArray(cookies) ? cookies : cookies ? [cookies] : [];
|
|
69
|
+
const paired = values.find(value => typeof value === 'string' && /^orbit_session=([a-zA-Z0-9_-]{20,100}|);/.test(value));
|
|
70
|
+
if (paired) {
|
|
71
|
+
const token = paired.split(';')[0].slice('orbit_session='.length);
|
|
72
|
+
safe['Set-Cookie'] = `${deviceCookieName(deviceId)}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${token ? 43200 : 0}${secureCookie ? '; Secure' : ''}`;
|
|
73
|
+
}
|
|
74
|
+
return safe;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isLocalApi(pathname, method) {
|
|
78
|
+
if (method === 'GET') return ['/api/status','/api/threads','/api/models'].includes(pathname)
|
|
79
|
+
|| /^\/api\/threads\/[a-zA-Z0-9_-]{8,120}\/messages$/.test(pathname);
|
|
80
|
+
return method === 'POST' && ['/api/pair','/api/logout','/api/pair/qr'].includes(pathname);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function json(response, status, body, headers = {}) {
|
|
84
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...headers });
|
|
85
|
+
response.end(JSON.stringify(body));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function readBody(request) {
|
|
89
|
+
let size = 0;
|
|
90
|
+
const chunks = [];
|
|
91
|
+
for await (const chunk of request) {
|
|
92
|
+
size += chunk.length;
|
|
93
|
+
if (size > maxBody) throw new Error('请求体过大。');
|
|
94
|
+
chunks.push(chunk);
|
|
95
|
+
}
|
|
96
|
+
return Buffer.concat(chunks);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function readJson(request) {
|
|
100
|
+
try {
|
|
101
|
+
const body = await readBody(request);
|
|
102
|
+
if (body.length > 65536) throw new Error('管理请求过大。');
|
|
103
|
+
const result = JSON.parse(body.toString() || '{}');
|
|
104
|
+
if (!result || Array.isArray(result) || typeof result !== 'object') throw new Error('请求必须是 JSON 对象。');
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
catch (error) { if (error instanceof SyntaxError) throw new Error('JSON 请求格式无效。'); throw error; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function staticTarget(pathname) {
|
|
111
|
+
const files = {
|
|
112
|
+
'/': ['public/index.html', 'text/html'],
|
|
113
|
+
'/index.html': ['public/index.html', 'text/html'],
|
|
114
|
+
'/connect': ['public/connect.html', 'text/html'],
|
|
115
|
+
'/connect.js': ['public/connect.js', 'text/javascript'],
|
|
116
|
+
'/admin': ['public/admin.html', 'text/html'],
|
|
117
|
+
'/admin/': ['public/admin.html', 'text/html'],
|
|
118
|
+
'/admin.js': ['public/admin.js', 'text/javascript'],
|
|
119
|
+
'/admin.css': ['public/admin.css', 'text/css'],
|
|
120
|
+
'/app.js': ['public/app.js', 'text/javascript'],
|
|
121
|
+
'/style.css': ['public/style.css', 'text/css'],
|
|
122
|
+
'/layout.css': ['public/layout.css', 'text/css'],
|
|
123
|
+
'/icon.svg': ['public/icon.svg', 'image/svg+xml'],
|
|
124
|
+
'/manifest.webmanifest': ['public/manifest.webmanifest', 'application/manifest+json'],
|
|
125
|
+
'/vendor/marked.js': ['node_modules/marked/lib/marked.umd.js', 'text/javascript'],
|
|
126
|
+
'/vendor/purify.js': ['node_modules/dompurify/dist/purify.min.js', 'text/javascript']
|
|
127
|
+
};
|
|
128
|
+
return files[pathname] || null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createRelayServer(options = {}) {
|
|
132
|
+
const adminToken = options.adminToken || process.env.ORBIT_RELAY_ADMIN_TOKEN || '';
|
|
133
|
+
if (adminToken.length < 24) throw new Error('必须设置长度至少 24 位的 ORBIT_RELAY_ADMIN_TOKEN。');
|
|
134
|
+
const host = options.host || process.env.ORBIT_RELAY_HOST || '127.0.0.1';
|
|
135
|
+
let port = Number(options.port ?? process.env.ORBIT_RELAY_PORT ?? 8443);
|
|
136
|
+
const secureCookie = options.secureCookie ?? true;
|
|
137
|
+
const registrationEnabled = options.registrationEnabled ?? process.env.ORBIT_RELAY_REGISTRATION_ENABLED !== 'false';
|
|
138
|
+
const store = options.store || new RelayStore(options.dbPath);
|
|
139
|
+
store.syncAdminToken(adminToken);
|
|
140
|
+
const requireSecureTransport = options.requireSecureTransport ?? process.env.NODE_ENV === 'production';
|
|
141
|
+
const agents = new Map();
|
|
142
|
+
const pendingHttp = new Map();
|
|
143
|
+
const browserSockets = new Map();
|
|
144
|
+
const loginAttempts = new Map();
|
|
145
|
+
const deviceAuthorization = new DeviceAuthorization(options.deviceAuthOptions);
|
|
146
|
+
let registrationsInFlight = 0;
|
|
147
|
+
let closing = false;
|
|
148
|
+
const httpServer = options.server || (
|
|
149
|
+
process.env.ORBIT_RELAY_TLS_CERT && process.env.ORBIT_RELAY_TLS_KEY
|
|
150
|
+
? https.createServer({
|
|
151
|
+
cert: fs.readFileSync(process.env.ORBIT_RELAY_TLS_CERT),
|
|
152
|
+
key: fs.readFileSync(process.env.ORBIT_RELAY_TLS_KEY)
|
|
153
|
+
})
|
|
154
|
+
: http.createServer()
|
|
155
|
+
);
|
|
156
|
+
const wss = new WebSocketServer({ noServer:true, maxPayload:maxBody });
|
|
157
|
+
|
|
158
|
+
function secureSuffix() { return secureCookie ? '; Secure' : ''; }
|
|
159
|
+
|
|
160
|
+
function session(request, role) {
|
|
161
|
+
const name = role === 'admin' ? 'orbit_admin' : 'orbit_cloud_session';
|
|
162
|
+
const raw = cookieValue(request,name);
|
|
163
|
+
const value = store.getSession(raw,role);
|
|
164
|
+
return value ? { ...value, raw } : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function userSession(request) { return session(request,'user'); }
|
|
168
|
+
function adminSession(request) { return session(request,'admin'); }
|
|
169
|
+
|
|
170
|
+
function loginKey(request,role,username) {
|
|
171
|
+
return `${role}:${String(username||'').trim().toLowerCase()}:${clientIp(request)}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function loginAllowed(key, limit = 12) {
|
|
175
|
+
const entry=loginAttempts.get(key);
|
|
176
|
+
if(!entry)return true;
|
|
177
|
+
if(entry.reset<=Date.now()){loginAttempts.delete(key);return true}
|
|
178
|
+
return entry.count<limit;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function recordAttempt(key) {
|
|
182
|
+
if(loginAttempts.size>=10000&&!loginAttempts.has(key))loginAttempts.delete(loginAttempts.keys().next().value);
|
|
183
|
+
const entry=loginAttempts.get(key)||{count:0,reset:Date.now()+5*60*1000};
|
|
184
|
+
entry.count++;loginAttempts.set(key,entry);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function clearLoginFailures(key) {
|
|
188
|
+
loginAttempts.delete(key);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function online(deviceId) {
|
|
192
|
+
const entry=agents.get(deviceId);
|
|
193
|
+
return Boolean(entry && entry.socket.readyState===WebSocket.OPEN);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function publicDevice(device) {
|
|
197
|
+
return {id:device.id,name:device.name,status:device.status,lastSeen:device.lastSeen,online:online(device.id),createdAt:device.createdAt};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sessionCookie(name, token, lifetime) {
|
|
201
|
+
return `${name}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(lifetime/1000)}${secureSuffix()}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function sendAgent(agent,message) {
|
|
205
|
+
if(!agent||agents.get(agent.deviceId)!==agent||agent.socket.readyState!==WebSocket.OPEN)throw new Error('这台电脑当前不在线。');
|
|
206
|
+
agent.socket.send(JSON.stringify(message));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function failPendingForDevice(deviceId,message) {
|
|
210
|
+
for(const [requestId,pending] of pendingHttp){
|
|
211
|
+
if(pending.deviceId!==deviceId)continue;
|
|
212
|
+
clearTimeout(pending.timer);pendingHttp.delete(requestId);
|
|
213
|
+
pending.reject(new Error(message));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function closeDeviceConnections(deviceId,code=1008,reason='设备已撤销。') {
|
|
218
|
+
const agent=agents.get(deviceId);
|
|
219
|
+
if(agent){agents.delete(deviceId);agent.socket.close(code,reason);}
|
|
220
|
+
failPendingForDevice(deviceId,reason);
|
|
221
|
+
for(const [bridgeId,browser] of browserSockets){
|
|
222
|
+
if(browser.deviceId===deviceId)closeBrowserSocket(bridgeId,code,reason);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function closeUserConnections(userId,code=1008,reason='账号授权已撤销。') {
|
|
227
|
+
for(const device of store.listDevices(userId))closeDeviceConnections(device.id,code,reason);
|
|
228
|
+
closeUserBrowsers(userId,code,reason);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function closeUserBrowsers(userId,code=1008,reason='账号授权已撤销。') {
|
|
232
|
+
for(const [bridgeId,browser]of browserSockets)if(browser.userId===userId)closeBrowserSocket(bridgeId,code,reason);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function closeBrowserSocket(bridgeId,code=1011,reason='电脑端连接已断开。') {
|
|
236
|
+
const entry=browserSockets.get(bridgeId);
|
|
237
|
+
if(!entry)return;
|
|
238
|
+
browserSockets.delete(bridgeId);
|
|
239
|
+
try { sendAgent(entry.agent,{type:'ws-close',bridgeId,code:1000,reason:'Browser authorization ended'}); } catch {}
|
|
240
|
+
if(entry.socket.readyState===WebSocket.OPEN)entry.socket.close(code,reason);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function chooseDevice(request,user) {
|
|
244
|
+
if(user.expiresAt && user.expiresAt<=Date.now())throw Object.assign(new Error('账号已到期。'),{status:403});
|
|
245
|
+
const devices=store.devicesForUser(user.id);
|
|
246
|
+
if(!devices.length)throw Object.assign(new Error('账号还没有绑定电脑,请联系管理员签发设备配对码。'),{status:409});
|
|
247
|
+
const requested=String(request.headers['x-orbit-device-id'] || '').trim();
|
|
248
|
+
if(requested){
|
|
249
|
+
const device=devices.find(item=>item.id===requested);
|
|
250
|
+
if(!device)throw Object.assign(new Error('目标电脑不存在或不属于当前账号。'),{status:403});
|
|
251
|
+
return device;
|
|
252
|
+
}
|
|
253
|
+
if(devices.length!==1)throw Object.assign(new Error('此账号绑定了多台电脑,请先在工作台选择目标设备。'),{status:409,code:'device_selection_required'});
|
|
254
|
+
return devices[0];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function proxyHttp(request,response,authenticated) {
|
|
258
|
+
let device;
|
|
259
|
+
try{device=chooseDevice(request,authenticated.user)}
|
|
260
|
+
catch(error){return json(response,error.status||409,{error:safeError(error),code:error.code||'device_unavailable'})}
|
|
261
|
+
const agent=agents.get(device.id);
|
|
262
|
+
if(!agent||agent.socket.readyState!==WebSocket.OPEN)return json(response,503,{error:`电脑“${device.name}”当前不在线。`});
|
|
263
|
+
const body=await readBody(request);
|
|
264
|
+
if (!store.hasSession(authenticated.tokenHash,authenticated.user.id)) return json(response,401,{error:'请先登录云端工作台。',code:'cloud_login_required'});
|
|
265
|
+
chooseDevice(request, store.getUser(authenticated.user.id));
|
|
266
|
+
const requestId=crypto.randomUUID();
|
|
267
|
+
const headers={};
|
|
268
|
+
for(const name of ['accept','content-type','host','origin','user-agent'])if(request.headers[name])headers[name]=request.headers[name];
|
|
269
|
+
headers.cookie = localCookie(request,device.id);
|
|
270
|
+
if(pendingHttp.size>=500)return json(response,429,{error:'请求过多,请稍后重试。'});
|
|
271
|
+
const promise=new Promise((resolve,reject)=>{
|
|
272
|
+
const timer=setTimeout(()=>{pendingHttp.delete(requestId);reject(new Error('电脑端请求超时。'))},30000);
|
|
273
|
+
pendingHttp.set(requestId,{resolve,reject,timer,deviceId:device.id,agent,userId:authenticated.user.id,sessionHash:authenticated.tokenHash});
|
|
274
|
+
});
|
|
275
|
+
try{
|
|
276
|
+
sendAgent(agent,{type:'http-request',requestId,method:request.method,path:request.url,headers,body:body.toString('base64')});
|
|
277
|
+
const result=await promise;
|
|
278
|
+
if (!store.hasSession(authenticated.tokenHash,authenticated.user.id)) return json(response,401,{error:'请先登录云端工作台。',code:'cloud_login_required'});
|
|
279
|
+
response.writeHead(result.statusCode,responseHeaders(result.headers,device.id,secureCookie));
|
|
280
|
+
response.end(Buffer.from(result.body||'','base64'));
|
|
281
|
+
}catch(error){if(!response.destroyed)json(response,502,{error:safeError(error)})}
|
|
282
|
+
finally {
|
|
283
|
+
const pending=pendingHttp.get(requestId);
|
|
284
|
+
if(pending){clearTimeout(pending.timer);pendingHttp.delete(requestId)}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function createLogin(request,response,role) {
|
|
289
|
+
if(!sameOrigin(request,true))return json(response,403,{error:'来源校验失败。'});
|
|
290
|
+
const body=await readJson(request);
|
|
291
|
+
const throttleKey=loginKey(request,role,role==='admin'?'admin':body.username);
|
|
292
|
+
const addressKey=`login-ip:${clientIp(request)}`;
|
|
293
|
+
if(!loginAllowed(throttleKey)||!loginAllowed(addressKey))return json(response,429,{error:'登录尝试过多,请稍后重试。'});
|
|
294
|
+
if(role==='admin'){
|
|
295
|
+
if(!equalSecret(body.token,adminToken)){recordAttempt(throttleKey);recordAttempt(addressKey);return json(response,401,{error:'管理员密钥不正确。'})}
|
|
296
|
+
clearLoginFailures(throttleKey);
|
|
297
|
+
const created=store.setSession(null,'admin',null,Date.now()+adminSessionLifetime);
|
|
298
|
+
store.audit('admin','admin.login',null,{});
|
|
299
|
+
return json(response,200,{ok:true},{'Set-Cookie':sessionCookie('orbit_admin',created.token,adminSessionLifetime)});
|
|
300
|
+
}
|
|
301
|
+
const user=await store.authenticateUser(body.username,body.password);
|
|
302
|
+
if(!user){recordAttempt(throttleKey);recordAttempt(addressKey);return json(response,401,{error:'用户名或密码不正确,或账号已停用。'})}
|
|
303
|
+
clearLoginFailures(throttleKey);
|
|
304
|
+
const created=store.setSession(null,'user',user.id);
|
|
305
|
+
store.audit(`user:${user.id}`,'user.login',user.id,{});
|
|
306
|
+
return json(response,200,{ok:true},{'Set-Cookie':sessionCookie('orbit_cloud_session',created.token,userSessionLifetime)});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function register(request,response) {
|
|
310
|
+
if(!sameOrigin(request,true))return json(response,403,{error:'来源校验失败。'});
|
|
311
|
+
if(!registrationEnabled)return json(response,403,{error:'当前暂停自助注册,请联系管理员开通账号。',code:'registration_disabled'});
|
|
312
|
+
const addressKey=`register-ip:${clientIp(request)}`;
|
|
313
|
+
if(!loginAllowed(addressKey,6)||registrationsInFlight>=4){
|
|
314
|
+
return json(response,429,{error:'注册请求过多,请五分钟后重试。'},{'Retry-After':'300'});
|
|
315
|
+
}
|
|
316
|
+
recordAttempt(addressKey);
|
|
317
|
+
registrationsInFlight++;
|
|
318
|
+
try {
|
|
319
|
+
const body=await readJson(request);
|
|
320
|
+
if(Object.keys(body).some(key=>!['username','displayName','password'].includes(key))){
|
|
321
|
+
return json(response,400,{error:'注册只接受用户名、显示名称和密码。'});
|
|
322
|
+
}
|
|
323
|
+
const user=await store.registerUser(body);
|
|
324
|
+
const created=store.setSession(null,'user',user.id);
|
|
325
|
+
return json(response,201,{user},{'Set-Cookie':sessionCookie('orbit_cloud_session',created.token,userSessionLifetime)});
|
|
326
|
+
} finally {
|
|
327
|
+
registrationsInFlight--;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function requireAdmin(request,response) {
|
|
332
|
+
const value=adminSession(request);
|
|
333
|
+
if(!value)json(response,401,{error:'请先登录管理员后台。'});
|
|
334
|
+
return value;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function adminApi(request,response,url) {
|
|
338
|
+
if(request.method!=='GET'&&!sameOrigin(request,true))return json(response,403,{error:'来源校验失败。'});
|
|
339
|
+
if(url.pathname==='/api/admin/login'&&request.method==='POST')return createLogin(request,response,'admin');
|
|
340
|
+
if(url.pathname==='/api/admin/logout'&&request.method==='POST'){
|
|
341
|
+
const current=adminSession(request);if(current){store.revokeSession(current.raw);store.audit('admin','admin.logout',null,{})}
|
|
342
|
+
return json(response,200,{ok:true},{'Set-Cookie':'orbit_admin=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0'+secureSuffix()});
|
|
343
|
+
}
|
|
344
|
+
const administrator=requireAdmin(request,response);if(!administrator)return;
|
|
345
|
+
if(url.pathname==='/api/admin/overview'&&request.method==='GET'){
|
|
346
|
+
return json(response,200,store.overview([...agents.keys()]));
|
|
347
|
+
}
|
|
348
|
+
if(url.pathname==='/api/admin/users'&&request.method==='GET'){
|
|
349
|
+
const page=Number(url.searchParams.get('page')||1);
|
|
350
|
+
const pageSize=Number(url.searchParams.get('pageSize')||25);
|
|
351
|
+
const search=url.searchParams.get('search')||'';
|
|
352
|
+
const status=url.searchParams.get('status')||'all';
|
|
353
|
+
if(!Number.isSafeInteger(page)||page<1||page>1000000
|
|
354
|
+
||!Number.isSafeInteger(pageSize)||pageSize<1||pageSize>100
|
|
355
|
+
||search.length>100||!['all','active','suspended','expired'].includes(status)){
|
|
356
|
+
return json(response,400,{error:'用户列表筛选条件无效。'});
|
|
357
|
+
}
|
|
358
|
+
const result=store.listUsers({search,status,page,pageSize});
|
|
359
|
+
const devicesByUser=new Map();
|
|
360
|
+
for(const device of store.listDevicesForUsers(result.users.map(user=>user.id))){
|
|
361
|
+
const devices=devicesByUser.get(device.userId)||[];
|
|
362
|
+
devices.push(publicDevice(device));
|
|
363
|
+
devicesByUser.set(device.userId,devices);
|
|
364
|
+
}
|
|
365
|
+
return json(response,200,{
|
|
366
|
+
...result,
|
|
367
|
+
users:result.users.map(user=>({...user,devices:devicesByUser.get(user.id)||[]}))
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
if(url.pathname==='/api/admin/users'&&request.method==='POST'){
|
|
371
|
+
const body=await readJson(request);
|
|
372
|
+
const password=body.password||crypto.randomBytes(18).toString('base64url');
|
|
373
|
+
const user=await store.createUser({...body,password});
|
|
374
|
+
return json(response,201,{user,initialPassword:password});
|
|
375
|
+
}
|
|
376
|
+
const resetMatch=url.pathname.match(/^\/api\/admin\/users\/([^/]+)\/reset-password$/);
|
|
377
|
+
if(resetMatch&&request.method==='POST'){
|
|
378
|
+
const id=decodeURIComponent(resetMatch[1]);const body=await readJson(request);
|
|
379
|
+
const password=body.password||crypto.randomBytes(18).toString('base64url');
|
|
380
|
+
if(!await store.resetPassword(id,password))return json(response,404,{error:'用户不存在。'});
|
|
381
|
+
closeUserBrowsers(id,1008,'账号密码已重置,请重新登录。');
|
|
382
|
+
return json(response,200,{ok:true,initialPassword:password});
|
|
383
|
+
}
|
|
384
|
+
const userDevices=url.pathname.match(/^\/api\/admin\/users\/([^/]+)\/devices$/);
|
|
385
|
+
if(userDevices&&request.method==='GET'){
|
|
386
|
+
const userId=decodeURIComponent(userDevices[1]);if(!store.getUser(userId))return json(response,404,{error:'用户不存在。'});
|
|
387
|
+
return json(response,200,{devices:store.listDevices(userId).map(device=>({...publicDevice(device),username:device.username}))});
|
|
388
|
+
}
|
|
389
|
+
if(userDevices&&request.method==='POST'){
|
|
390
|
+
const userId=decodeURIComponent(userDevices[1]);const body=await readJson(request);
|
|
391
|
+
const issued=store.issueDevice(userId,body.name);
|
|
392
|
+
return json(response,201,{device:publicDevice(issued.device),agentToken:issued.agentToken});
|
|
393
|
+
}
|
|
394
|
+
const userMatch=url.pathname.match(/^\/api\/admin\/users\/([^/]+)$/);
|
|
395
|
+
if(userMatch&&request.method==='PATCH'){
|
|
396
|
+
const id=decodeURIComponent(userMatch[1]);const body=await readJson(request);
|
|
397
|
+
const user=store.updateUser(id,body);
|
|
398
|
+
if(!user)return json(response,404,{error:'用户不存在。'});
|
|
399
|
+
if(user.status==='suspended'||(user.expiresAt&&user.expiresAt<=Date.now()))closeUserConnections(id,1008,'账号已停用或到期。');
|
|
400
|
+
return json(response,200,{user});
|
|
401
|
+
}
|
|
402
|
+
const deviceMatch=url.pathname.match(/^\/api\/admin\/devices\/([^/]+)$/);
|
|
403
|
+
if(deviceMatch&&request.method==='PATCH'){
|
|
404
|
+
const id=decodeURIComponent(deviceMatch[1]);const body=await readJson(request);
|
|
405
|
+
const device=store.setDeviceStatus(id,body.status,body.name);
|
|
406
|
+
if(!device)return json(response,404,{error:'设备不存在。'});
|
|
407
|
+
if(device.status==='revoked')closeDeviceConnections(id,1008,'设备访问已撤销。');
|
|
408
|
+
return json(response,200,{device:publicDevice(device)});
|
|
409
|
+
}
|
|
410
|
+
if(url.pathname==='/api/admin/audit'&&request.method==='GET'){
|
|
411
|
+
const limit=Number(url.searchParams.get('limit')||100);
|
|
412
|
+
return json(response,200,{events:store.listAudit(Number.isSafeInteger(limit)?limit:100)});
|
|
413
|
+
}
|
|
414
|
+
return json(response,404,{error:'管理员接口不存在。'});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
httpServer.on('request',async(request,response)=>{
|
|
418
|
+
response.setHeader('Cache-Control','no-store');
|
|
419
|
+
response.setHeader('X-Content-Type-Options','nosniff');
|
|
420
|
+
response.setHeader('X-Frame-Options','DENY');
|
|
421
|
+
response.setHeader('Referrer-Policy','no-referrer');
|
|
422
|
+
response.setHeader('Content-Security-Policy',"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
|
423
|
+
try{
|
|
424
|
+
const url=new URL(request.url,originFor(request));
|
|
425
|
+
if(request.method==='GET'&&url.pathname==='/healthz')return json(response,200,{ok:true});
|
|
426
|
+
if(requireSecureTransport&&!secureTransport(request))return json(response,426,{error:'生产环境要求 HTTPS。'});
|
|
427
|
+
if(url.pathname.startsWith('/api/admin/'))return await adminApi(request,response,url);
|
|
428
|
+
if(url.pathname==='/api/cloud/config'&&request.method==='GET'){
|
|
429
|
+
return json(response,200,{registrationEnabled,registrationPlan:'free',registrationMaxDevices:1});
|
|
430
|
+
}
|
|
431
|
+
if(url.pathname==='/api/cloud/register'&&request.method==='POST')return await register(request,response);
|
|
432
|
+
if(url.pathname==='/api/cloud/device-auth/start'&&request.method==='POST'){
|
|
433
|
+
if(!sameOrigin(request))return json(response,403,{error:'来源校验失败。'});
|
|
434
|
+
const key=`device-auth:${clientIp(request)}`;
|
|
435
|
+
if(!loginAllowed(key,6))return json(response,429,{error:'连接请求过多,请五分钟后重试。'});
|
|
436
|
+
recordAttempt(key);
|
|
437
|
+
const body=await readJson(request);
|
|
438
|
+
const created=deviceAuthorization.start(body.name);
|
|
439
|
+
return json(response,201,{...created,verificationUri:`${originFor(request)}/connect?code=${created.userCode}`});
|
|
440
|
+
}
|
|
441
|
+
if(url.pathname==='/api/cloud/device-auth/poll'&&request.method==='POST'){
|
|
442
|
+
if(!sameOrigin(request))return json(response,403,{error:'来源校验失败。'});
|
|
443
|
+
const body=await readJson(request);
|
|
444
|
+
const value=deviceAuthorization.poll(body.deviceCode);
|
|
445
|
+
if(value.status===410)return json(response,410,{error:'连接请求已失效,请重新运行 orbit login。'});
|
|
446
|
+
return json(response,value.status,value.result||{pending:true});
|
|
447
|
+
}
|
|
448
|
+
if(url.pathname==='/api/cloud/device-auth/ack'&&request.method==='POST'){
|
|
449
|
+
if(!sameOrigin(request))return json(response,403,{error:'来源校验失败。'});
|
|
450
|
+
const body=await readJson(request);
|
|
451
|
+
if(!deviceAuthorization.acknowledge(body.deviceCode))return json(response,410,{error:'连接请求已失效。'});
|
|
452
|
+
return json(response,200,{ok:true});
|
|
453
|
+
}
|
|
454
|
+
if(url.pathname==='/api/cloud/device-auth/request'&&request.method==='GET'){
|
|
455
|
+
const current=userSession(request);if(!current)return json(response,401,{error:'请先登录。',code:'cloud_login_required'});
|
|
456
|
+
const userKey=`device-auth-view-user:${current.user.id}`;
|
|
457
|
+
const addressKey=`device-auth-view-ip:${clientIp(request)}`;
|
|
458
|
+
if(!loginAllowed(userKey,60)||!loginAllowed(addressKey,300)){
|
|
459
|
+
return json(response,429,{error:'查看连接请求过多,请稍后再试。'},{'Retry-After':'60'});
|
|
460
|
+
}
|
|
461
|
+
recordAttempt(userKey);
|
|
462
|
+
recordAttempt(addressKey);
|
|
463
|
+
const value=deviceAuthorization.preview(url.searchParams.get('code'));
|
|
464
|
+
return value?json(response,200,value):json(response,410,{error:'连接请求已失效,请在电脑重新运行 orbit login。'});
|
|
465
|
+
}
|
|
466
|
+
if(url.pathname==='/api/cloud/device-auth/approve'&&request.method==='POST'){
|
|
467
|
+
if(!sameOrigin(request,true))return json(response,403,{error:'来源校验失败。'});
|
|
468
|
+
const current=userSession(request);if(!current)return json(response,401,{error:'请先登录。',code:'cloud_login_required'});
|
|
469
|
+
const userKey=`device-auth-approve-user:${current.user.id}`;
|
|
470
|
+
const addressKey=`device-auth-approve-ip:${clientIp(request)}`;
|
|
471
|
+
if(!loginAllowed(userKey,12)||!loginAllowed(addressKey,60)){
|
|
472
|
+
return json(response,429,{error:'电脑确认尝试过多,请稍后重试。'},{'Retry-After':'300'});
|
|
473
|
+
}
|
|
474
|
+
recordAttempt(userKey);
|
|
475
|
+
recordAttempt(addressKey);
|
|
476
|
+
const body=await readJson(request);
|
|
477
|
+
deviceAuthorization.approve(body.userCode, name=>{
|
|
478
|
+
const issued=store.issueDevice(current.user.id,name,`user:${current.user.id}`);
|
|
479
|
+
return {device:publicDevice(issued.device),agentToken:issued.agentToken};
|
|
480
|
+
});
|
|
481
|
+
return json(response,200,{ok:true});
|
|
482
|
+
}
|
|
483
|
+
if(url.pathname==='/api/cloud/device-auth/revoke'&&request.method==='POST'){
|
|
484
|
+
if(!sameOrigin(request))return json(response,403,{error:'来源校验失败。'});
|
|
485
|
+
const match=request.headers.authorization?.match(/^Bearer ([a-zA-Z0-9_-]{32,100})$/);
|
|
486
|
+
if(!match)return json(response,401,{error:'电脑凭证无效。'});
|
|
487
|
+
const body=await readJson(request);
|
|
488
|
+
if(Object.keys(body).some(key=>key!=='deviceId')||typeof body.deviceId!=='string'){
|
|
489
|
+
return json(response,400,{error:'撤销请求格式无效。'});
|
|
490
|
+
}
|
|
491
|
+
const device=store.authenticateDevice(match[1],body.deviceId);
|
|
492
|
+
if(!device)return json(response,404,{error:'电脑凭证已失效或设备不存在。'});
|
|
493
|
+
store.setDeviceStatus(device.id,'revoked',undefined,`user:${device.userId}`);
|
|
494
|
+
closeDeviceConnections(device.id,1008,'设备访问已撤销。');
|
|
495
|
+
return json(response,200,{ok:true});
|
|
496
|
+
}
|
|
497
|
+
if(url.pathname==='/api/cloud/login'&&request.method==='POST')return await createLogin(request,response,'user');
|
|
498
|
+
if(url.pathname==='/api/cloud/logout'&&request.method==='POST'){
|
|
499
|
+
if(!sameOrigin(request,true))return json(response,403,{error:'来源校验失败。'});
|
|
500
|
+
const current=userSession(request);if(current){store.revokeSession(current.raw);store.audit(`user:${current.user.id}`,'user.logout',current.user.id,{});closeUserBrowsers(current.user.id,1008,'用户已退出登录。')}
|
|
501
|
+
return json(response,200,{ok:true},{'Set-Cookie':'orbit_cloud_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0'+secureSuffix()});
|
|
502
|
+
}
|
|
503
|
+
if(url.pathname==='/api/cloud/me'&&request.method==='GET'){
|
|
504
|
+
const current=userSession(request);if(!current)return json(response,401,{error:'请先登录。',code:'cloud_login_required'});
|
|
505
|
+
return json(response,200,{user:current.user,devices:store.devicesForUser(current.user.id).map(device=>publicDevice(device))});
|
|
506
|
+
}
|
|
507
|
+
if(url.pathname==='/api/cloud/devices'&&request.method==='POST'){
|
|
508
|
+
if(!sameOrigin(request,true))return json(response,403,{error:'来源校验失败。'});
|
|
509
|
+
const current=userSession(request);if(!current)return json(response,401,{error:'请先登录。',code:'cloud_login_required'});
|
|
510
|
+
const body=await readJson(request);
|
|
511
|
+
if(Object.keys(body).some(key=>key!=='name'))return json(response,400,{error:'添加电脑只接受设备名称。'});
|
|
512
|
+
const issued=store.issueDevice(current.user.id,body.name,`user:${current.user.id}`);
|
|
513
|
+
return json(response,201,{device:publicDevice(issued.device),agentToken:issued.agentToken});
|
|
514
|
+
}
|
|
515
|
+
const revokeDevice=url.pathname.match(/^\/api\/cloud\/devices\/([a-zA-Z0-9_-]{8,100})\/revoke$/);
|
|
516
|
+
if(revokeDevice&&request.method==='POST'){
|
|
517
|
+
if(!sameOrigin(request,true))return json(response,403,{error:'来源校验失败。'});
|
|
518
|
+
const current=userSession(request);if(!current)return json(response,401,{error:'请先登录。',code:'cloud_login_required'});
|
|
519
|
+
const owned=store.listDevices(current.user.id).find(device=>device.id===revokeDevice[1]);
|
|
520
|
+
if(!owned)return json(response,404,{error:'电脑不存在。'});
|
|
521
|
+
store.setDeviceStatus(owned.id,'revoked',undefined,`user:${current.user.id}`);
|
|
522
|
+
closeDeviceConnections(owned.id,1008,'设备访问已撤销。');
|
|
523
|
+
return json(response,200,{ok:true});
|
|
524
|
+
}
|
|
525
|
+
if(url.pathname==='/api/cloud/choose-device'&&request.method==='POST'){
|
|
526
|
+
if(!sameOrigin(request,true))return json(response,403,{error:'来源校验失败。'});
|
|
527
|
+
const current=userSession(request);if(!current)return json(response,401,{error:'请先登录。'});
|
|
528
|
+
const body=await readJson(request);
|
|
529
|
+
const device=store.devicesForUser(current.user.id).find(item=>item.id===body.deviceId);
|
|
530
|
+
if(!device)return json(response,403,{error:'设备不属于当前账号。'});
|
|
531
|
+
return json(response,200,{device:publicDevice(device)});
|
|
532
|
+
}
|
|
533
|
+
if(isLocalApi(url.pathname,request.method)){
|
|
534
|
+
if(request.method!=='GET'&&!sameOrigin(request,true))return json(response,403,{error:'来源校验失败。'});
|
|
535
|
+
const current=userSession(request);if(!current)return json(response,401,{error:'请先登录云端工作台。',code:'cloud_login_required'});
|
|
536
|
+
const devices=store.devicesForUser(current.user.id);
|
|
537
|
+
if(devices.length>Number(current.user.maxDevices))return json(response,403,{error:'设备数量超出套餐额度。'});
|
|
538
|
+
return await proxyHttp(request,response,current);
|
|
539
|
+
}
|
|
540
|
+
const file=staticTarget(url.pathname);
|
|
541
|
+
if(file&&['GET','HEAD'].includes(request.method)){
|
|
542
|
+
const [relative,type]=file;const filename=path.join(root,relative);
|
|
543
|
+
if(!fs.existsSync(filename))return json(response,404,{error:'静态文件不存在。'});
|
|
544
|
+
response.writeHead(200,{'Content-Type':`${type}; charset=utf-8`});
|
|
545
|
+
if(request.method==='HEAD')return response.end();
|
|
546
|
+
return fs.createReadStream(filename).pipe(response);
|
|
547
|
+
}
|
|
548
|
+
return json(response,404,{error:'页面不存在。'});
|
|
549
|
+
}catch(error){if(!response.headersSent)json(response,error.status||400,{error:safeError(error)});else response.end()}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
httpServer.on('upgrade',(request,socket,head)=>{
|
|
553
|
+
let url;try{url=new URL(request.url,originFor(request))}catch{return rejectUpgrade(socket)}
|
|
554
|
+
if(requireSecureTransport&&!secureTransport(request))return rejectUpgrade(socket,400,'TLS Required');
|
|
555
|
+
if(url.pathname==='/agent'){
|
|
556
|
+
wss.handleUpgrade(request,socket,head,websocket=>{
|
|
557
|
+
let agent=null;
|
|
558
|
+
websocket.on('error',()=>{});
|
|
559
|
+
const authTimer=setTimeout(()=>websocket.close(1008,'Agent authentication timeout'),8000);
|
|
560
|
+
websocket.on('message',(raw,isBinary)=>{
|
|
561
|
+
let message;try{message=JSON.parse(raw.toString())}catch{return websocket.close(1003,'Invalid message')}
|
|
562
|
+
if(!message||typeof message!=='object')return websocket.close(1003,'Invalid message');
|
|
563
|
+
if(!agent){
|
|
564
|
+
if(message.type!=='agent-auth'){clearTimeout(authTimer);return websocket.close(1008,'Agent authentication failed')}
|
|
565
|
+
agent=store.authenticateDevice(message.token,message.deviceId);
|
|
566
|
+
if(!agent){clearTimeout(authTimer);return websocket.close(1008,'Agent authentication failed')}
|
|
567
|
+
clearTimeout(authTimer);
|
|
568
|
+
const entry={socket:websocket,deviceId:agent.id,userId:agent.userId,name:agent.name,isAlive:true};
|
|
569
|
+
const previous=agents.get(agent.id);
|
|
570
|
+
if(previous&&previous.socket!==websocket)closeDeviceConnections(agent.id,1012,'Replaced by a newer agent');
|
|
571
|
+
agents.set(agent.id,entry);
|
|
572
|
+
store.audit(`user:${agent.userId}`,'device.connected',agent.id,{});
|
|
573
|
+
websocket.send(JSON.stringify({type:'agent-ready',deviceId:agent.id,name:agent.name}));
|
|
574
|
+
websocket.on('pong',()=>{entry.isAlive=true;store.touchDevice(agent.id)});
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if(agents.get(agent.id)?.socket!==websocket)return websocket.close(1008,'Agent replaced or revoked');
|
|
578
|
+
if(message.type==='http-response'){
|
|
579
|
+
const pending=pendingHttp.get(message.requestId);
|
|
580
|
+
if(!pending||pending.agent.socket!==websocket)return;
|
|
581
|
+
if(!Number.isInteger(message.statusCode)||message.statusCode<200||message.statusCode>599||typeof message.body!=='string')return websocket.close(1003,'Invalid HTTP response');
|
|
582
|
+
clearTimeout(pending.timer);pendingHttp.delete(message.requestId);pending.resolve(message);return;
|
|
583
|
+
}
|
|
584
|
+
const browser=browserSockets.get(message.bridgeId);
|
|
585
|
+
if(!browser||browser.agent.socket!==websocket)return;
|
|
586
|
+
if(message.type==='ws-opened')return;
|
|
587
|
+
if(message.type==='ws-data'&&typeof message.data==='string'&&browser.socket.readyState===WebSocket.OPEN)browser.socket.send(
|
|
588
|
+
message.dataType==='binary'?Buffer.from(message.data,'base64'):Buffer.from(message.data,'base64'),
|
|
589
|
+
{binary:message.dataType==='binary'}
|
|
590
|
+
);
|
|
591
|
+
else if(message.type==='ws-close')closeBrowserSocket(message.bridgeId,1000,'电脑端连接已关闭。');
|
|
592
|
+
else if(message.type==='ws-error')closeBrowserSocket(message.bridgeId,1011,'电脑端 WebSocket 错误。');
|
|
593
|
+
});
|
|
594
|
+
websocket.on('close',()=>{
|
|
595
|
+
clearTimeout(authTimer);
|
|
596
|
+
if(!closing&&agent&&agents.get(agent.id)?.socket===websocket){
|
|
597
|
+
agents.delete(agent.id);store.audit(`user:${agent.userId}`,'device.disconnected',agent.id,{});
|
|
598
|
+
failPendingForDevice(agent.id,'电脑端代理已断开。');
|
|
599
|
+
for(const [bridgeId,browser]of browserSockets)if(browser.deviceId===agent.id)closeBrowserSocket(bridgeId,1011,'电脑端代理已断开。');
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if(url.pathname!=='/socket'||!sameOrigin(request,true))return rejectUpgrade(socket);
|
|
606
|
+
const current=userSession(request);if(!current)return rejectUpgrade(socket);
|
|
607
|
+
let device;
|
|
608
|
+
const deviceId=url.searchParams.get('deviceId');
|
|
609
|
+
const deviceRequest={headers:{'x-orbit-device-id':deviceId||''}};
|
|
610
|
+
try{device=chooseDevice(deviceRequest,current.user)}catch{return rejectUpgrade(socket,403,'Device Not Allowed')}
|
|
611
|
+
const agent=agents.get(device.id);
|
|
612
|
+
if(!agent||agent.socket.readyState!==WebSocket.OPEN)return rejectUpgrade(socket,503,'Agent Unavailable');
|
|
613
|
+
wss.handleUpgrade(request,socket,head,websocket=>{
|
|
614
|
+
websocket.on('error',()=>{});
|
|
615
|
+
const bridgeId=crypto.randomUUID();
|
|
616
|
+
const browserEntry={socket:websocket,agent,deviceId:device.id,userId:current.user.id,sessionHash:current.tokenHash,isAlive:true};
|
|
617
|
+
browserSockets.set(bridgeId,browserEntry);
|
|
618
|
+
websocket.on('pong',()=>{browserEntry.isAlive=true});
|
|
619
|
+
sendAgent(agent,{type:'ws-open',bridgeId,path:'/socket',headers:{
|
|
620
|
+
cookie:localCookie(request,device.id),origin:originFor(request),
|
|
621
|
+
host:request.headers.host||'',deviceId:device.id
|
|
622
|
+
}});
|
|
623
|
+
websocket.on('message',(data,isBinary)=>{
|
|
624
|
+
const user=store.getUser(current.user.id);
|
|
625
|
+
if(!store.hasSession(current.tokenHash,current.user.id)||user?.status!=='active'||user.expiresAt&&user.expiresAt<=Date.now()){
|
|
626
|
+
closeBrowserSocket(bridgeId,1008,'登录已失效。');return;
|
|
627
|
+
}
|
|
628
|
+
try { sendAgent(agent,{type:'ws-data',bridgeId,dataType:isBinary?'binary':'text',data:Buffer.from(data).toString('base64')}); }
|
|
629
|
+
catch { closeBrowserSocket(bridgeId,1011,'电脑端已断开。'); }
|
|
630
|
+
});
|
|
631
|
+
websocket.on('close',()=>{
|
|
632
|
+
browserSockets.delete(bridgeId);
|
|
633
|
+
try{sendAgent(agent,{type:'ws-close',bridgeId,code:1000,reason:'浏览器关闭连接。'})}catch{}
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const heartbeat=setInterval(()=>{
|
|
639
|
+
store.cleanup();
|
|
640
|
+
deviceAuthorization.cleanup();
|
|
641
|
+
for(const agent of agents.values()){
|
|
642
|
+
const user=store.getUser(agent.userId);
|
|
643
|
+
const device=store.listDevices(agent.userId).find(entry=>entry.id===agent.id&&entry.status==='active');
|
|
644
|
+
if(!user||user.status!=='active'||(user.expiresAt&&user.expiresAt<=Date.now())||!device){closeDeviceConnections(agent.id,1008,'用户或设备访问已撤销。');continue}
|
|
645
|
+
if(!agent.isAlive){agent.socket.terminate();continue}
|
|
646
|
+
agent.isAlive=false;agent.socket.ping();
|
|
647
|
+
}
|
|
648
|
+
for(const [bridgeId,browser]of browserSockets){
|
|
649
|
+
if(!store.hasSession(browser.sessionHash,browser.userId)){closeBrowserSocket(bridgeId,1008,'登录已失效。');continue}
|
|
650
|
+
const user=store.getUser(browser.userId);
|
|
651
|
+
if(!user||user.status!=='active'||(user.expiresAt&&user.expiresAt<=Date.now())){closeBrowserSocket(bridgeId,1008,'账号已停用或到期。');continue}
|
|
652
|
+
if(!browser.isAlive){closeBrowserSocket(bridgeId,1001,'浏览器连接超时。');continue}
|
|
653
|
+
browser.isAlive=false;browser.socket.ping();
|
|
654
|
+
}
|
|
655
|
+
for(const [key,attempt]of loginAttempts)if(attempt.reset<=Date.now())loginAttempts.delete(key);
|
|
656
|
+
},30000);
|
|
657
|
+
heartbeat.unref();
|
|
658
|
+
|
|
659
|
+
async function listen(){
|
|
660
|
+
await new Promise((resolve,reject)=>{httpServer.once('error',reject);httpServer.listen(port,host,resolve)});
|
|
661
|
+
port=httpServer.address().port;
|
|
662
|
+
return{host,port,secure:httpServer instanceof https.Server};
|
|
663
|
+
}
|
|
664
|
+
async function close(){
|
|
665
|
+
if(closing)return;
|
|
666
|
+
closing=true;
|
|
667
|
+
clearInterval(heartbeat);
|
|
668
|
+
for(const agent of agents.values())failPendingForDevice(agent.deviceId,'中继服务停止。');
|
|
669
|
+
for(const client of wss.clients)client.terminate();
|
|
670
|
+
agents.clear();browserSockets.clear();
|
|
671
|
+
await new Promise(resolve=>wss.close(resolve));
|
|
672
|
+
httpServer.closeAllConnections();
|
|
673
|
+
await new Promise(resolve=>httpServer.close(resolve));
|
|
674
|
+
store.close();
|
|
675
|
+
}
|
|
676
|
+
return{listen,close,server:httpServer,store,agents,browserSockets};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
|
|
680
|
+
const app=createRelayServer();const info=await app.listen();
|
|
681
|
+
console.log(`Orbit relay listening on ${info.host}:${info.port}${info.secure?' · TLS':' · behind HTTPS proxy'}`);
|
|
682
|
+
process.once('SIGINT',()=>app.close().then(()=>process.exit(0)));
|
|
683
|
+
process.once('SIGTERM',()=>app.close().then(()=>process.exit(0)));
|
|
684
|
+
}
|