@walkhi/code-relax 0.1.0-beta.23 → 0.1.0-beta.24
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/bin/cleanup-accounts.mjs +63 -0
- package/dist/src/self-relay/account-cleanup.mjs +30 -0
- package/dist/src/self-relay/admin-page.mjs +6 -12
- package/dist/src/self-relay/admin-server.mjs +17 -9
- package/dist/src/self-relay/client.mjs +45 -45
- package/dist/src/self-relay/connector.mjs +293 -289
- package/dist/src/self-relay/diagnostics.mjs +54 -54
- package/dist/src/self-relay/email-accounts.mjs +158 -141
- package/dist/src/self-relay/privacy-page.mjs +26 -0
- package/dist/src/self-relay/server.mjs +153 -67
- package/dist/src/self-relay/usage-meter.mjs +141 -136
- package/dist/src/server.mjs +15 -13
- package/package.json +1 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHmac } from 'node:crypto';
|
|
5
|
+
import mysql from 'mysql2/promise';
|
|
6
|
+
import { relayDiagnostics } from '../src/self-relay/diagnostics.mjs';
|
|
7
|
+
import { purgeCancelledAccounts } from '../src/self-relay/account-cleanup.mjs';
|
|
8
|
+
|
|
9
|
+
const config = JSON.parse(await fs.readFile('/etc/codex-relay/mysql.json', 'utf8'));
|
|
10
|
+
const account = JSON.parse(await fs.readFile('/etc/codex-relay/account.json', 'utf8'));
|
|
11
|
+
if (typeof account.codePepper !== 'string' || account.codePepper.length < 32) throw new Error('Invalid account cleanup configuration');
|
|
12
|
+
const pool = mysql.createPool({ ...config, connectionLimit: 1, dateStrings: true, timezone: 'Z' });
|
|
13
|
+
const startedAt = Date.now();
|
|
14
|
+
let stage = 'accounts';
|
|
15
|
+
const diagnostics = relayDiagnostics(pool, () => console.error('Account cleanup diagnostic write failed'));
|
|
16
|
+
const record = (event, detail) => diagnostics.record('account-cleanup', event, detail);
|
|
17
|
+
let purged = 0;
|
|
18
|
+
try {
|
|
19
|
+
await record('started', 'scheduled');
|
|
20
|
+
purged = await purgeCancelledAccounts(pool, value => createHmac('sha256', account.codePepper).update(value).digest('hex'));
|
|
21
|
+
await record('accounts-cleared', `count=${purged}`);
|
|
22
|
+
stage = 'backups';
|
|
23
|
+
|
|
24
|
+
// Only deployment SQL snapshots; never follow symlinks or remove release directories.
|
|
25
|
+
const root = '/opt/codex-relay/releases';
|
|
26
|
+
if (await fs.realpath(root) !== root) throw new Error('Unexpected backup root');
|
|
27
|
+
const files = [];
|
|
28
|
+
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
|
29
|
+
if (entry.isDirectory() && /^deploy-\d{8}T\d{6}Z$/.test(entry.name)) {
|
|
30
|
+
files.push(path.join(root, entry.name, 'database.sql'));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Pre-deployment snapshots observed on this installation, before the release tool existed.
|
|
34
|
+
for (const directory of ['before-email-20260925', 'before-usage-20260925']) {
|
|
35
|
+
files.push(path.join(root, directory, 'code_relax.sql'));
|
|
36
|
+
}
|
|
37
|
+
let backups = 0;
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
let stat;
|
|
40
|
+
try { stat = await fs.lstat(file); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
41
|
+
if (!stat.isFile() || stat.isSymbolicLink() || Date.now() - stat.mtimeMs < 86400000) continue;
|
|
42
|
+
if (await fs.realpath(file) !== file) throw new Error('Unexpected backup path');
|
|
43
|
+
await fs.unlink(file); backups++;
|
|
44
|
+
}
|
|
45
|
+
await record('backups-cleared', `count=${backups}`);
|
|
46
|
+
stage = 'recovery-logs';
|
|
47
|
+
// Rotate even on a quiet server so expired binlogs are actually purged.
|
|
48
|
+
const maintenance = await mysql.createConnection({ user: 'root', socketPath: '/var/run/mysqld/mysqld.sock' });
|
|
49
|
+
try {
|
|
50
|
+
const [settings] = await maintenance.query('SELECT @@GLOBAL.binlog_expire_logs_seconds AS seconds, @@GLOBAL.binlog_expire_logs_auto_purge AS autoPurge');
|
|
51
|
+
if (Number(settings[0].seconds) <= 0 || Number(settings[0].seconds) > 259200 || Number(settings[0].autoPurge) !== 1) {
|
|
52
|
+
throw new Error('MySQL recovery-log retention exceeds account cleanup policy');
|
|
53
|
+
}
|
|
54
|
+
await maintenance.query('FLUSH BINARY LOGS');
|
|
55
|
+
} finally { await maintenance.end(); }
|
|
56
|
+
await record('completed', `users=${purged};backups=${backups};ms=${Date.now() - startedAt}`);
|
|
57
|
+
console.log(JSON.stringify({ event: 'account-cleanup', purged, expiredBackups: backups, recoveryLogsRotated: true }));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// Codes and stage only: SQL errors may contain email addresses or credentials.
|
|
60
|
+
const code = String(error.code || 'operation-failed').toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 32);
|
|
61
|
+
await record('failed', `stage=${stage};code=${code}`);
|
|
62
|
+
throw error;
|
|
63
|
+
} finally { await pool.end(); }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Process one account per transaction; foreign keys keep incomplete deletion atomic.
|
|
2
|
+
export async function purgeCancelledAccounts(pool, scopeHash) {
|
|
3
|
+
let purged = 0;
|
|
4
|
+
for (;;) {
|
|
5
|
+
const db = await pool.getConnection();
|
|
6
|
+
try {
|
|
7
|
+
await db.beginTransaction();
|
|
8
|
+
const [rows] = await db.query(`SELECT id FROM users WHERE status='cancelled'
|
|
9
|
+
AND cancelled_at <= UTC_TIMESTAMP(3) - INTERVAL 1 DAY
|
|
10
|
+
ORDER BY cancelled_at LIMIT 1 FOR UPDATE`);
|
|
11
|
+
if (!rows.length) { await db.commit(); return purged; }
|
|
12
|
+
const id = rows[0].id;
|
|
13
|
+
const [identities] = await db.execute("SELECT normalized_value FROM user_login_identities WHERE user_id=? AND kind='email'", [id]);
|
|
14
|
+
for (const { normalized_value: email } of identities) {
|
|
15
|
+
await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
|
|
16
|
+
await db.execute('DELETE FROM email_send_limits WHERE scope_hash=?', [scopeHash(`email:${email}`)]);
|
|
17
|
+
await db.execute('DELETE FROM relay_admin_emails WHERE email=?', [email]);
|
|
18
|
+
}
|
|
19
|
+
for (const table of ['user_sessions', 'relay_usage_actions', 'relay_usage_sessions',
|
|
20
|
+
'relay_usage_user_days', 'relay_usage_budgets', 'relay_usage_entitlements',
|
|
21
|
+
'relay_usage_totals', 'user_login_identities']) {
|
|
22
|
+
await db.execute(`DELETE FROM ${table} WHERE user_id=?`, [id]);
|
|
23
|
+
}
|
|
24
|
+
await db.execute('UPDATE computers SET owner_user_id=NULL WHERE owner_user_id=?', [id]);
|
|
25
|
+
await db.execute('DELETE FROM users WHERE id=?', [id]);
|
|
26
|
+
await db.commit(); purged++;
|
|
27
|
+
} catch (error) { await db.rollback(); throw error; }
|
|
28
|
+
finally { db.release(); }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { adminUiTokens } from './admin-style.mjs';
|
|
2
2
|
|
|
3
|
-
export function adminPage() {
|
|
3
|
+
export function adminPage(basePath = '/admin') {
|
|
4
4
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="only light"><meta name="theme-color" content="#f5f7fa"><title>Code Relax 管理</title><style>${adminUiTokens}</style>
|
|
5
5
|
<style>@media(max-width:600px){body main{padding:12px 12px 40px}header.page-head{align-items:center;gap:8px;margin-bottom:8px}header.page-head h1{font-size:20px;line-height:1.2;margin:0}main .nav{gap:0;margin-bottom:12px}main .nav button{padding:8px 11px}main .stack,main .grid{gap:10px}main .card{padding:12px}main .hero strong{font-size:20px}main .throughput{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:10px}main .metrics{gap:6px}main .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:6px;min-width:0;padding:9px 10px}main .metric strong{font-size:17px;margin:0;white-space:nowrap}main .metric span{font-size:11px}main .card-head{margin-bottom:10px}}</style>
|
|
6
6
|
<style>@media(max-width:600px){main .throughput .metric strong{font-size:17px}}</style>
|
|
@@ -21,9 +21,8 @@ button{background:#e0f0e5;color:#235d3a;border-color:#a9cbb4}button:hover{backgr
|
|
|
21
21
|
#metrics{grid-template-columns:repeat(5,minmax(0,1fr))}.relay-heading{margin-bottom:14px}.resource-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.resource-head .hint{white-space:nowrap}.resource-card .throughput{border-top:1px solid #dce5df;padding-top:10px;margin-top:12px}.resource-card .throughput .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px}.resource-card .throughput .metric strong{font-size:16px;margin:0;white-space:nowrap}
|
|
22
22
|
@media(max-width:800px){#metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
23
23
|
@media(max-width:600px){.resource-card .throughput .metric strong{font-size:14px}}
|
|
24
|
-
</style><style>main:not(.authenticated)>:not(#adminLogin){display:none!important}#adminLogin{max-width:430px;margin:10vh auto}#adminLogin[hidden]{display:none}.usage-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}.usage-views,.usage-pages{display:flex;align-items:center;gap:8px}.usage-views button[aria-pressed=true]{background:#286e43;color:#fff}.usage-rows{display:grid;gap:8px}.usage-row{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--admin-row-border);border-radius:var(--admin-control-radius);background:var(--admin-row)}.usage-row-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;min-width:0}.usage-row-head span{min-width:0;overflow-wrap:anywhere}.usage-row-head small{flex-shrink:0;white-space:nowrap}.usage-row strong{font-variant-numeric:tabular-nums}.usage-row small{text-align:right}.usage-pages{margin-top:16px}</style></head><body><main>
|
|
25
|
-
<
|
|
26
|
-
<header class="page-head"><h1>中继管理</h1><nav class="nav" aria-label="管理页面"><button type="button" data-page="overview" aria-current="page">运行概览</button><button type="button" data-page="announcements">系统公告</button><button type="button" data-page="settings">容量设置</button><button type="button" data-page="usage">使用统计</button><button id="adminLogout" type="button" class="secondary">退出后台</button></nav></header>
|
|
24
|
+
</style><style>main:not(.authenticated)>:not(#adminLogin){display:none!important}#adminLogin{max-width:430px;margin:10vh auto}#adminLogin[hidden]{display:none}.usage-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}.usage-views,.usage-pages{display:flex;align-items:center;gap:8px}.usage-views button[aria-pressed=true]{background:#286e43;color:#fff}.usage-rows{display:grid;gap:8px}.usage-row{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--admin-row-border);border-radius:var(--admin-control-radius);background:var(--admin-row)}.usage-row-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;min-width:0}.usage-row-head span{min-width:0;overflow-wrap:anywhere}.usage-row-head small{flex-shrink:0;white-space:nowrap}.usage-row strong{font-variant-numeric:tabular-nums}.usage-row small{text-align:right}.usage-pages{margin-top:16px}</style></head><body><main class="authenticated">
|
|
25
|
+
<header class="page-head"><h1>中继管理</h1><nav class="nav" aria-label="管理页面"><button type="button" data-page="overview" aria-current="page">运行概览</button><button type="button" data-page="announcements">系统公告</button><button type="button" data-page="settings">容量设置</button><button type="button" data-page="usage">使用统计</button></nav></header>
|
|
27
26
|
<section id="overview" class="page stack"><section class="card" aria-label="中继连接与转发"><div class="relay-heading"><h2>中继连接与转发</h2><span id="healthText" class="state">读取中</span></div><div id="metrics" class="metrics"></div></section><div class="grid"><section class="card resource-card" aria-label="服务器资源"><div class="card-head resource-head"><h2>服务器资源</h2><span id="sampleTime" class="hint">等待采样</span></div><div id="serverMetrics" class="metrics"></div><div class="throughput" id="throughput"></div></section><section class="card quota-card"><div class="card-head"><h2>流量包</h2><span id="trafficNote" class="hint">等待更新</span></div><p id="trafficUsage" class="traffic-usage">—</p><div id="trafficTimeline" class="quota-timeline" hidden><div class="quota-heading"><strong id="trafficRemaining">流量剩余 —</strong><span id="trafficPace" class="quota-pace">—×</span><span id="timeRemaining">时间剩余 —</span></div><div id="quotaTrack" class="quota-track" role="img"><span id="quotaFill" class="quota-fill"></span><span id="quotaNow" class="quota-now"></span></div><div class="quota-period"><span id="trafficEnd">到期 —</span><span id="trafficStart">本期开始 —</span></div></div></section></div></section>
|
|
28
27
|
<section id="announcements" class="page" hidden><section class="card"><div class="announcement-toolbar"><div><h2>公告管理</h2><p id="announcementCount" class="hint">正在读取</p></div><button id="newAnnouncement" type="button">新建公告</button></div><div class="announcement-controls"><input id="announcementSearch" type="search" aria-label="搜索公告" placeholder="搜索标题或正文"><select id="announcementFilter" aria-label="筛选状态"><option value="all">全部状态</option><option value="active">发布中</option><option value="draft">草稿</option><option value="withdrawn">已撤下</option></select></div><div id="announcementList" class="announcement-list" style="margin-top:14px"></div></section></section>
|
|
29
28
|
<dialog id="announcementEditor" aria-labelledby="editorHeading"><div class="editor-head"><h2 id="editorHeading">新建公告</h2><button id="closeEditor" class="secondary" type="button" aria-label="关闭">关闭</button></div><div class="field"><label for="title">标题</label><input id="title" maxlength="80" placeholder="输入公告标题"></div><div class="field"><label for="message">正文</label><textarea id="message" maxlength="2000" placeholder="输入公告内容"></textarea></div><p id="editorNote" class="hint"></p><p id="editorError" class="status error" role="alert" hidden></p><div id="editorActions" class="editor-actions"><button id="saveDraft" class="secondary" type="button">保存草稿</button><button id="publish" type="button">发布公告</button></div></dialog>
|
|
@@ -31,7 +30,7 @@ button{background:#e0f0e5;color:#235d3a;border-color:#a9cbb4}button:hover{backgr
|
|
|
31
30
|
<section id="usage" class="page" hidden><section class="card"><div class="usage-toolbar"><div><h2>今日使用统计</h2><p id="usageCount" class="hint">正在读取</p></div><div class="usage-views"><button id="usageUsers" type="button" class="secondary" aria-pressed="true">用户</button><button id="usageComputers" type="button" class="secondary" aria-pressed="false">工作站</button><button id="usageRefresh" type="button" class="secondary">刷新</button></div></div><div id="usageRows" class="usage-rows"></div><div class="usage-pages"><button id="usagePrev" type="button" class="secondary">上一页</button><span id="usagePageText" class="hint"></span><button id="usageNext" type="button" class="secondary">下一页</button></div><p id="usageError" class="status error" role="alert"></p></section></section><p id="result" class="status" role="status" aria-live="polite"></p>
|
|
32
31
|
</main><script>
|
|
33
32
|
const el=id=>document.getElementById(id);let snapshot=null;
|
|
34
|
-
const request=async(path,options={})=>{const
|
|
33
|
+
const request=async(path,options={})=>{const response=await fetch(path,{...options,referrerPolicy:'no-referrer'});const body=await response.json();if(!response.ok)throw new Error(body.error||('HTTP '+response.status));return body};
|
|
35
34
|
function setResult(message,error=false){el('result').textContent=message;el('result').classList.toggle('error',error)}
|
|
36
35
|
function node(tag,className,value){const item=document.createElement(tag);if(className)item.className=className;if(value!==undefined)item.textContent=value;return item}
|
|
37
36
|
function metric(value,label,extra,tone){const item=node('div','metric'+(tone?' '+tone:''));item.append(node('strong','',value),node('span','',label));if(extra)item.append(node('small','',extra));return item}
|
|
@@ -76,11 +75,6 @@ el('saveLimit').addEventListener('click',()=>write('PUT','/admin/api/settings',{
|
|
|
76
75
|
el('newAnnouncement').addEventListener('click',()=>openEditor());el('closeEditor').addEventListener('click',()=>el('announcementEditor').close());el('announcementSearch').addEventListener('input',()=>{if(snapshot)renderAnnouncements(snapshot)});el('announcementFilter').addEventListener('change',()=>{if(snapshot)renderAnnouncements(snapshot)});
|
|
77
76
|
async function saveAnnouncement(publish){const title=el('title').value.trim(),message=el('message').value.trim();if(!title||!message){el('editorError').textContent='请填写标题和正文';el('editorError').hidden=false;return}if(publish&&snapshot?.announcement&&snapshot.announcement.id!==editorItem?.id&&!confirm('发布后将替换当前公告,确定发布?'))return;const path=editorMode==='draft'?'/admin/api/announcements/'+encodeURIComponent(editorItem.id):'/admin/api/announcements';const target=publish&&editorMode==='draft'?path+'/publish':path;const body={title,message};if(publish&&editorMode==='new')body.publish=true;const ok=await write('PUT',target,body,publish?'公告已发布':'草稿已保存');if(ok)el('announcementEditor').close()}
|
|
78
77
|
el('saveDraft').addEventListener('click',()=>saveAnnouncement(false));el('publish').addEventListener('click',()=>saveAnnouncement(true));
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
el('sendLoginCode').onclick=async()=>{const email=el('loginEmail').value.trim().toLowerCase();if(!email){showLogin('请输入邮箱');return}el('loginError').textContent='';try{const config=await request('/account/config');if(!window.TencentCaptcha){await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src='https://turing.captcha.qcloud.com/TJCaptcha.js';script.onload=resolve;script.onerror=reject;document.head.append(script)})}new TencentCaptcha(String(config.captchaAppId),async result=>{if(result.ret!==0)return;try{await request('/account/email/request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,ticket:result.ticket,randstr:result.randstr})});el('loginError').textContent='验证码已发送'}catch(error){el('loginError').textContent=error.message}}).show()}catch(error){el('loginError').textContent='人机验证不可用:'+error.message}};
|
|
82
|
-
el('verifyLogin').onclick=async()=>{const email=el('loginEmail').value.trim().toLowerCase(),code=el('loginCode').value.trim();if(!/^[0-9]{6}$/.test(code)){el('loginError').textContent='请输入 6 位验证码';return}try{const result=await request('/account/email/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,code})});localStorage.setItem('codeRelaxAdminToken',result.token);await authorize()}catch(error){el('loginError').textContent=error.message}};
|
|
83
|
-
el('adminLogout').onclick=()=>{localStorage.removeItem('codeRelaxAdminToken');showLogin()};
|
|
84
|
-
if(localStorage.getItem('codeRelaxAdminToken'))void authorize();else showLogin();setInterval(()=>{if(!document.hidden&&!el('overview').hidden&&document.querySelector('main').classList.contains('authenticated'))load()},5000);
|
|
85
|
-
</script></body></html>`;
|
|
78
|
+
showPage(['overview','announcements','settings','usage'].includes(location.hash.slice(1))?location.hash.slice(1):'overview');setInterval(()=>{if(!document.hidden&&!el('overview').hidden)load()},5000);
|
|
79
|
+
</script></body></html>`.replaceAll('/admin/api/', basePath + '/api/');
|
|
86
80
|
}
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
2
3
|
import { adminPage } from './admin-page.mjs';
|
|
3
4
|
import { startAdminCloud } from './admin-cloud.mjs';
|
|
4
5
|
|
|
5
6
|
function reply(response, status, body, type = 'application/json; charset=utf-8') {
|
|
6
7
|
const bytes = typeof body === 'string' ? body : JSON.stringify(body);
|
|
7
8
|
response.writeHead(status, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(bytes),
|
|
8
|
-
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'
|
|
9
|
+
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
|
|
10
|
+
'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex, nofollow', 'X-Frame-Options': 'DENY' });
|
|
9
11
|
response.end(bytes);
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.0.1:45850' } = {}) {
|
|
14
|
+
export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.0.1:45850', accessFile = '/etc/codex-relay/admin-access.json' } = {}) {
|
|
15
|
+
const { key } = JSON.parse(fs.readFileSync(accessFile, 'utf8'));
|
|
16
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(key)) throw new Error('Invalid admin access key');
|
|
17
|
+
const basePath = '/admin/' + key;
|
|
13
18
|
const cloud = startAdminCloud();
|
|
14
19
|
const relayOrigin = new URL(relayUrl);
|
|
15
20
|
if (relayOrigin.hostname !== '127.0.0.1' || relayOrigin.protocol !== 'http:' || relayOrigin.pathname !== '/') {
|
|
@@ -17,17 +22,20 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
|
|
|
17
22
|
}
|
|
18
23
|
const server = http.createServer(async (request, response) => {
|
|
19
24
|
const target = new URL(request.url || '/', 'http://localhost');
|
|
20
|
-
if (
|
|
21
|
-
if (request.
|
|
22
|
-
|
|
25
|
+
if (request.method === 'GET' && target.pathname === '/admin/health') { reply(response, 200, { ok: true }); return; }
|
|
26
|
+
if (request.headers['sec-fetch-site'] === 'cross-site' && request.method !== 'GET') { reply(response, 403, { error: 'Cross-site request rejected' }); return; }
|
|
27
|
+
if (target.pathname !== basePath && !target.pathname.startsWith(basePath + '/')) { reply(response, 404, { error: 'Not found' }); return; }
|
|
28
|
+
target.pathname = '/admin' + target.pathname.slice(basePath.length);
|
|
29
|
+
if (request.method === 'GET' && ['/admin', '/admin/'].includes(target.pathname)) {
|
|
30
|
+
reply(response, 200, adminPage(basePath), 'text/html; charset=utf-8'); return;
|
|
23
31
|
}
|
|
24
32
|
if (request.method === 'GET' && target.pathname === '/admin/usage') {
|
|
25
|
-
response.writeHead(302, { Location: '
|
|
33
|
+
response.writeHead(302, { Location: basePath + '#usage', 'Cache-Control': 'no-store' }); response.end(); return;
|
|
26
34
|
}
|
|
27
35
|
if (request.method === 'GET' && ['/admin/api/status', '/admin/api/session', '/admin/api/usage', '/admin/api/usage-diagnostics'].includes(target.pathname)) {
|
|
28
36
|
try {
|
|
29
37
|
const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
|
|
30
|
-
signal: AbortSignal.timeout(3000), headers: {
|
|
38
|
+
signal: AbortSignal.timeout(3000), headers: { 'X-Code-Relax-Admin-Key': key } });
|
|
31
39
|
const state = await upstream.json();
|
|
32
40
|
if (!upstream.ok) { reply(response, upstream.status, state); return; }
|
|
33
41
|
reply(response, 200, target.pathname === '/admin/api/status'
|
|
@@ -48,11 +56,11 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
|
|
|
48
56
|
if (size > 4096) { reply(response, 413, { error: 'Admin request too large' }); return; }
|
|
49
57
|
chunks.push(chunk);
|
|
50
58
|
}
|
|
51
|
-
const upstream = await fetch(new URL(target.pathname, relayOrigin), {
|
|
59
|
+
const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
|
|
52
60
|
method: request.method, signal: AbortSignal.timeout(3000),
|
|
53
61
|
headers: { 'Content-Type': request.headers['content-type'] || '',
|
|
54
62
|
'X-Code-Relax-Admin': request.headers['x-code-relax-admin'] || '',
|
|
55
|
-
|
|
63
|
+
'X-Code-Relax-Admin-Key': key },
|
|
56
64
|
body: Buffer.concat(chunks),
|
|
57
65
|
});
|
|
58
66
|
reply(response, upstream.status, await upstream.text());
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import { EventEmitter } from 'node:events';
|
|
2
|
-
import WebSocket from 'ws';
|
|
3
|
-
import { decode, relayAddress, send } from './wire.mjs';
|
|
4
|
-
|
|
5
|
-
// Headless mobile-side adapter for the local validation. No OpenAI credentials.
|
|
6
|
-
export function connectRelayClient({ relayUrl, room, code, credential, clientId, observe = false }) {
|
|
7
|
-
relayAddress(relayUrl);
|
|
8
|
-
const events = new EventEmitter(), pending = new Map();
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import { decode, relayAddress, send } from './wire.mjs';
|
|
4
|
+
|
|
5
|
+
// Headless mobile-side adapter for the local validation. No OpenAI credentials.
|
|
6
|
+
export function connectRelayClient({ relayUrl, room, code, credential, clientId, accountToken, observe = false }) {
|
|
7
|
+
relayAddress(relayUrl);
|
|
8
|
+
const events = new EventEmitter(), pending = new Map();
|
|
9
9
|
const socket = new WebSocket(relayUrl, { maxPayload: 64 * 1024 * 1024, perMessageDeflate: true });
|
|
10
10
|
let serial = 0, connected = false, pairedCredential = credential, pairedClientId = clientId;
|
|
11
|
-
const ready = new Promise((resolve, reject) => {
|
|
12
|
-
const timer = setTimeout(() => { reject(new Error('Relay connection timeout')); socket.terminate(); }, 7000);
|
|
13
|
-
socket.on('open', () => send(socket, observe ? { type: 'observe', room, credential, clientId }
|
|
14
|
-
: code ? { type: 'pair', room, code, credential, clientId } : { type: 'resume', room, credential, clientId }));
|
|
15
|
-
socket.on('error', error => { reject(error); events.emit('diagnostic', error.message); });
|
|
16
|
-
socket.on('close', () => {
|
|
17
|
-
clearTimeout(timer); connected = false; reject(new Error('Relay connection closed'));
|
|
18
|
-
for (const request of pending.values()) { clearTimeout(request.timer); request.reject(new Error('连接断开,操作结果未确认,不自动重发')); }
|
|
19
|
-
pending.clear(); events.emit('disconnected');
|
|
20
|
-
});
|
|
21
|
-
socket.on('message', (bytes, binary) => {
|
|
22
|
-
try {
|
|
23
|
-
const message = decode(bytes, binary);
|
|
11
|
+
const ready = new Promise((resolve, reject) => {
|
|
12
|
+
const timer = setTimeout(() => { reject(new Error('Relay connection timeout')); socket.terminate(); }, 7000);
|
|
13
|
+
socket.on('open', () => send(socket, observe ? { type: 'observe', room, credential, clientId, accountToken }
|
|
14
|
+
: code ? { type: 'pair', room, code, credential, clientId, accountToken } : { type: 'resume', room, credential, clientId, accountToken }));
|
|
15
|
+
socket.on('error', error => { reject(error); events.emit('diagnostic', error.message); });
|
|
16
|
+
socket.on('close', () => {
|
|
17
|
+
clearTimeout(timer); connected = false; reject(new Error('Relay connection closed'));
|
|
18
|
+
for (const request of pending.values()) { clearTimeout(request.timer); request.reject(new Error('连接断开,操作结果未确认,不自动重发')); }
|
|
19
|
+
pending.clear(); events.emit('disconnected');
|
|
20
|
+
});
|
|
21
|
+
socket.on('message', (bytes, binary) => {
|
|
22
|
+
try {
|
|
23
|
+
const message = decode(bytes, binary);
|
|
24
24
|
if (message.type === 'paired') { if (message.credential) pairedCredential = message.credential; if (message.clientId) pairedClientId = message.clientId; }
|
|
25
25
|
else if (message.type === 'observing') { clearTimeout(timer); connected = true; events.emit('observing', message); resolve({ room, credential: pairedCredential, clientId: pairedClientId }); }
|
|
26
26
|
else if (message.type === 'computer-info') events.emit('computer-info', { hostName: message.hostName });
|
|
@@ -29,33 +29,33 @@ export function connectRelayClient({ relayUrl, room, code, credential, clientId,
|
|
|
29
29
|
else if (message.type === 'official-computer-saved') events.emit('official-computer-saved', message.computer);
|
|
30
30
|
else if (message.type === 'announcement') events.emit('announcement', message.announcement);
|
|
31
31
|
else if (message.type === 'ready') { clearTimeout(timer); connected = true; resolve({ room, credential: pairedCredential, clientId: pairedClientId }); }
|
|
32
|
-
else if (message.type === 'data') {
|
|
33
|
-
const body = decode(message.payload, false), request = pending.get(body.id);
|
|
34
|
-
if (body.type === 'response' && request) { clearTimeout(request.timer); pending.delete(body.id); request.resolve(body); }
|
|
35
|
-
else events.emit('message', body);
|
|
36
|
-
} else throw new Error('Invalid relay message');
|
|
37
|
-
} catch (error) { reject(error); events.emit('diagnostic', error.message); socket.close(1008, 'Invalid response'); }
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
return {
|
|
41
|
-
ready, events,
|
|
42
|
-
request(path, { method = 'GET', body, timeoutMs = 5000 } = {}) {
|
|
43
|
-
if (!connected) return Promise.reject(new Error('Relay not ready'));
|
|
44
|
-
const id = ++serial;
|
|
45
|
-
return new Promise((resolve, reject) => {
|
|
46
|
-
const timer = setTimeout(() => { pending.delete(id); reject(new Error('请求超时,结果未确认,不自动重发')); }, timeoutMs);
|
|
47
|
-
pending.set(id, { resolve, reject, timer });
|
|
48
|
-
if (!send(socket, { type: 'data', payload: JSON.stringify({ type: 'request', id, path, method, ...(body ? { body } : {}) }) })) {
|
|
49
|
-
clearTimeout(timer); pending.delete(id); reject(new Error('Relay not connected'));
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
},
|
|
32
|
+
else if (message.type === 'data') {
|
|
33
|
+
const body = decode(message.payload, false), request = pending.get(body.id);
|
|
34
|
+
if (body.type === 'response' && request) { clearTimeout(request.timer); pending.delete(body.id); request.resolve(body); }
|
|
35
|
+
else events.emit('message', body);
|
|
36
|
+
} else throw new Error('Invalid relay message');
|
|
37
|
+
} catch (error) { reject(error); events.emit('diagnostic', error.message); socket.close(1008, 'Invalid response'); }
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
ready, events,
|
|
42
|
+
request(path, { method = 'GET', body, timeoutMs = 5000 } = {}) {
|
|
43
|
+
if (!connected) return Promise.reject(new Error('Relay not ready'));
|
|
44
|
+
const id = ++serial;
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new Error('请求超时,结果未确认,不自动重发')); }, timeoutMs);
|
|
47
|
+
pending.set(id, { resolve, reject, timer });
|
|
48
|
+
if (!send(socket, { type: 'data', payload: JSON.stringify({ type: 'request', id, path, method, ...(body ? { body } : {}) }) })) {
|
|
49
|
+
clearTimeout(timer); pending.delete(id); reject(new Error('Relay not connected'));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
53
|
send(message) { if (!connected) throw new Error('Relay not ready'); return send(socket, { type: 'data', payload: JSON.stringify(message) }); },
|
|
54
54
|
routeDirect() { if (!connected) throw new Error('Relay not ready'); return send(socket, { type: 'p2p-route', direct: 1 }); },
|
|
55
55
|
recordOfficialComputer(computer) {
|
|
56
56
|
if (!connected || !observe) throw new Error('Metadata channel not ready');
|
|
57
57
|
return send(socket, { type: 'official-computer', ...computer });
|
|
58
58
|
},
|
|
59
|
-
close() { socket.terminate(); },
|
|
60
|
-
};
|
|
61
|
-
}
|
|
59
|
+
close() { socket.terminate(); },
|
|
60
|
+
};
|
|
61
|
+
}
|