1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
/* E2E: runs the REAL src/gateway.js in Node.
Shims: WebSocketPair (linked in-process pair), ctx.waitUntil, DO binding.
Backend: REAL WebSocket echo server (test/echo-server.mjs) over TCP —
gateway's outbound `new WebSocket()` is Node's real client.
Client: synthetic VLESS client speaking through the shimmed pair.
Run: node test/e2e.mjs
*/
import assert from 'node:assert/strict';
// ---------- WebSocketPair shim (must be installed before importing gateway) ----------
class FakeEndpoint extends EventTarget {
constructor() { super(); this._peer = null; this.readyState = 1; }
accept() {}
send(data) {
const buf = data instanceof ArrayBuffer ? data.slice(0)
: ArrayBuffer.isView(data) ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
: new TextEncoder().encode(String(data)).buffer;
queueMicrotask(() => this._peer.dispatchEvent(Object.assign(new Event('message'), { data: buf })));
}
close(code = 1000, reason = '') {
const mk = () => Object.assign(new Event('close'), { code, reason });
queueMicrotask(() => { this._peer.dispatchEvent(mk()); this.dispatchEvent(mk()); });
}
}
globalThis.WebSocketPair = class {
constructor() {
const a = new FakeEndpoint(), b = new FakeEndpoint();
a._peer = b; b._peer = a;
this[0] = a; this[1] = b; // [client, server] like the real API
globalThis.__lastPair = this;
}
};
const { default: gateway } = await import('../src/gateway.js');
const { Accounting } = await import('../src/accounting.js');
const { buildVlessHeader, uuidToBytes, newUuid } = await import('../src/vless.js');
const { makeSqliteState } = await import('./sqlite-adapter.mjs');
const { startEchoServer } = await import('./echo-server.mjs');
const ECHO_PORT = 18765;
const BACKEND_UUID = newUuid();
const ADMIN = 'test-secret';
const accounting = new Accounting(makeSqliteState(), {});
const fakeStub = { fetch: (url, init) => accounting.fetch(new Request(url, init)) };
const env = {
BACKEND_WS_URL: `ws://127.0.0.1:${ECHO_PORT}/`,
BACKEND_UUID, ADMIN_SECRET: ADMIN, FLUSH_MS: '1000',
ACCOUNTING: { idFromName: () => 'main', get: () => fakeStub },
};
const ctx = { waitUntil(p) { p.catch(() => {}); } };
const aheaders = { Authorization: 'Bearer ' + ADMIN, 'Content-Type': 'application/json' };
const results = [];
function check(name, cond, extra = '') {
results.push({ name, ok: !!cond, extra });
console.log((cond ? '✔' : '✘') + ' ' + name + (extra ? ' — ' + extra : ''));
}
async function admin(method, path, body) {
const r = await gateway.fetch(new Request('http://gw' + path, {
method, headers: aheaders, body: body ? JSON.stringify(body) : undefined,
}), env, ctx);
return { status: r.status, json: await r.json() };
}
// open a "client connection": returns { client, messages, closes }
async function openConn() {
let res;
try {
res = await gateway.fetch(new Request('http://gw/', { headers: { Upgrade: 'websocket' } }), env, ctx);
} catch (e) {
// Node's undici Response rejects status 101 (real Workers allow it).
// The pair + listeners are already set up before the Response is built.
if (!/init\["status"\]/.test(e.message)) throw e;
res = { status: 101 };
}
assert.equal(res.status, 101);
const client = globalThis.__lastPair[0];
const st = { messages: [], closes: [] };
client.addEventListener('message', (e) => st.messages.push(new Uint8Array(e.data)));
client.addEventListener('close', (e) => st.closes.push({ code: e.code, reason: e.reason }));
return { client, st };
}
const wait = (ms) => new Promise(r => setTimeout(r, ms));
async function waitFor(cond, ms, label) {
const t0 = Date.now();
while (Date.now() - t0 < ms) { if (await cond()) return true; await wait(100); }
throw new Error('timeout waiting for: ' + label);
}
// ---------- run ----------
const echoEvents = [];
const echoSrv = await startEchoServer(ECHO_PORT, uuidToBytes(BACKEND_UUID), (e) => echoEvents.push(e));
// 1. create user via admin
const cu = await admin('POST', '/admin/users', { name: 'e2e-user', quotaGB: 10, expiryDays: 30 });
check('admin create user', cu.status === 200 && cu.json.uuid && cu.json.subLink.includes(cu.json.uuid), cu.json.subLink);
const userUuid = cu.json.uuid;
// 2. connect + pipe data (wait for auth echo first: message boundaries aren't preserved)
const { client, st } = await openConn();
const hdr = buildVlessHeader(userUuid, { port: 80, addr: [9, 9, 9, 9] });
const p1 = new Uint8Array(1000).fill(0x41), p2 = new Uint8Array(2000).fill(0x42);
client.send(hdr);
await waitFor(() => st.messages.length >= 1, 5000, 'auth echo');
client.send(p1);
await waitFor(() => st.messages.length >= 2, 5000, 'p1 echo');
client.send(p2);
await waitFor(() => st.messages.length >= 3, 5000, 'p2 echo');
const echoed = st.messages.reduce((a, m) => a + m.length, 0);
check('echo roundtrip', echoed === hdr.length + 3000, `echoed=${echoed}`);
check('backend saw rewritten BACKEND_UUID', echoEvents.some(e => e.type === 'header' && e.ok),
JSON.stringify(echoEvents));
// 3. byte accounting (flush every 1s in test env)
await wait(1500);
const lu = await admin('GET', '/admin/users');
const row = lu.json.find(u => u.uuid === userUuid);
const expected = hdr.length + 3000;
check('accounting up bytes exact', row.usedUp === expected, `usedUp=${row.usedUp} expected=${expected}`);
check('accounting down bytes exact', row.usedDown === expected, `usedDown=${row.usedDown}`);
// 4. unknown UUID rejected
const { client: c2, st: st2 } = await openConn();
c2.send(buildVlessHeader(newUuid()));
await waitFor(() => st2.closes.length > 0, 3000, 'reject close');
check('unknown uuid -> 4403', st2.closes[0]?.code === 4403, `code=${st2.closes[0]?.code} reason=${st2.closes[0]?.reason}`);
// 5. bad header rejected
const { client: c3, st: st3 } = await openConn();
const bad = buildVlessHeader(userUuid); bad[0] = 0x01;
c3.send(bad);
await waitFor(() => st3.closes.length > 0, 3000, 'bad header close');
check('bad version -> 4400', st3.closes[0]?.code === 4400, `code=${st3.closes[0]?.code}`);
// 6. quota cutoff mid-connection
const q = await admin('POST', '/admin/users', { name: 'quota-user', quotaGB: 0.001 }); // ~1MB
const quuid = q.json.uuid;
const { client: c4, st: st4 } = await openConn();
c4.send(buildVlessHeader(quuid));
await waitFor(() => st4.messages.length >= 1, 5000, 'quota auth echo');
c4.send(new Uint8Array(1_300_000).fill(0x43)); // 1.3MB > 1MB quota
await waitFor(() => st4.closes.length > 0, 8000, 'quota close');
check('quota exceeded -> 4403 close', st4.closes[0]?.code === 4403, `code=${st4.closes[0]?.code} reason=${st4.closes[0]?.reason}`);
const vq = await accounting.validate({ uuid: quuid });
check('quota user now blocked', vq.reason === 'quota-exceeded');
// 7. admin auth
const unauth = await gateway.fetch(new Request('http://gw/admin/users'), env, ctx);
check('admin without token -> 401', unauth.status === 401);
echoSrv.close();
const failed = results.filter(r => !r.ok);
console.log(`\nE2E: ${results.length - failed.length}/${results.length} passed`);
process.exit(failed.length ? 1 : 0);