'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const net = require('node:net'); const { spawn } = require('node:child_process'); async function freePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); }); }); } async function waitFor(url) { for (let i = 0; i < 60; i += 1) { try { const response = await fetch(url); if (response.ok) return; } catch { /* retry */ } await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error('test server did not start'); } test('admin can create nodes and manage shared-client lifecycle', async (t) => { const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nexusgate-api-')); const port = await freePort(); const child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), env: { ...process.env, NG_HOST: '127.0.0.1', NG_PORT: String(port), NG_DATA_FILE: path.join(dir, 'data.json'), NG_ADMIN_PASSWORD: 'test-password', NG_COOKIE_SECURE: 'false' }, stdio: ['ignore', 'pipe', 'pipe'] }); t.after(async () => { child.kill('SIGTERM'); await fs.promises.rm(dir, { recursive: true, force: true }); }); const base = `http://127.0.0.1:${port}`; await waitFor(`${base}/healthz`); const frontend = await fetch(`${base}/app.js`); assert.equal(frontend.headers.get('cache-control'), 'no-store'); const login = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'test-password' }) }); assert.equal(login.status, 200); const session = await login.json(); const cookie = login.headers.get('set-cookie').split(';')[0]; const request = async (pathname, method = 'GET', body) => { const response = await fetch(`${base}${pathname}`, { method, headers: { cookie, 'x-csrf-token': session.csrf, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }); const json = await response.json(); assert.ok(response.ok, JSON.stringify(json)); return json; }; const relay = (await request('/api/servers', 'POST', { name: '中转 01', role: 'relay', region: 'SG', publicAddress: 'relay.example.com' })).server; const exit = (await request('/api/servers', 'POST', { name: '落地 01', role: 'exit', region: 'JP', publicAddress: 'exit.example.com' })).server; const customer = (await request('/api/customers', 'POST', { name: '客户 A', ipLimit: 2, trafficLimitBytes: 1073741824 })).customer; const editedRelay = (await request(`/api/servers/${relay.id}`, 'PATCH', { name: '新加坡入口 01', publicAddressV6: '2001:db8::10' })).server; assert.equal(editedRelay.name, '新加坡入口 01'); const editedCustomer = (await request(`/api/customers/${customer.id}`, 'PATCH', { expiresAt: '2030-12-31T23:59:00+08:00', tags: ['VIP'] })).customer; assert.equal(editedCustomer.expiresAt, '2030-12-31T15:59:00.000Z'); // Shared node: one entry inbound serves many customers; deploy needs a client first. const node = (await request('/api/nodes', 'POST', { name: 'SG → JP', serverId: relay.id, protocol: 'vless-reality-vision', topology: 'forward', exitServerId: exit.id, exitProtocol: 'shadowsocks-2022-aes128' })).node; const prematureDeploy = await fetch(`${base}/api/nodes/${node.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' }); assert.equal(prematureDeploy.status, 400); assert.match((await prematureDeploy.json()).message, /至少一个客户/); await request(`/api/nodes/${node.id}/assign`, 'POST', { customerId: customer.id }); const premature = await fetch(`${base}/api/nodes/${node.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' }); assert.equal(premature.status, 409); const agentKeys = {}; for (const server of [relay, exit]) { const ticket = await request(`/api/servers/${server.id}/enrollment-token`, 'POST', {}); const response = await fetch(`${base}/api/agent/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: ticket.token, hostname: 'test-agent' }) }); assert.equal(response.status, 201); agentKeys[server.id] = (await response.json()).agentKey; } const certSync = await fetch(`${base}/api/agent/tls-domain`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' }, body: JSON.stringify({ domain: 'entry.example.com' }) }); assert.equal(certSync.status, 200); assert.equal((await request('/api/servers')).servers.find((item) => item.id === relay.id).tlsDomain, 'entry.example.com'); const idleDatabase = await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8'); for (let count = 0; count < 10; count += 1) { for (const server of [relay, exit]) { const headers = { authorization: `Bearer ${agentKeys[server.id]}`, 'content-type': 'application/json' }; const poll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers, body: '{}' }); assert.deepEqual(await poll.json(), { job: null }); const heartbeat = await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers, body: JSON.stringify({ version: '0.6.8', system: { memoryAvailable: 512 * 1024 * 1024 } }) }); assert.equal(heartbeat.status, 200); } } assert.equal(await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8'), idleDatabase); const badCertSync = await fetch(`${base}/api/agent/tls-domain`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' }, body: JSON.stringify({ domain: 'invalid/domain' }) }); assert.equal(badCertSync.status, 400); await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' }, body: JSON.stringify({ version: '0.4.0', engine: { status: 'error', detail: 'Xray config validation failed' } }) }); // A broken core can be repaired by applying a new resource; keep the Agent job queue available. const engineBlocked = await fetch(`${base}/api/nodes/${node.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' }); assert.equal(engineBlocked.status, 202); await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' }, body: JSON.stringify({ version: '0.4.0', engine: { status: 'ready', detail: 'active' } }) }); const jobs = await request('/api/jobs'); assert.equal(jobs.jobs.length, 2); assert.ok(jobs.jobs.every((job) => job.status === 'queued')); const exitPoll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[exit.id]}` } }); const exitJob = (await exitPoll.json()).job; const failed = await fetch(`${base}/api/agent/jobs/${exitJob.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[exit.id]}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: false, error: 'PrivateKey: example_secret Password (PublicKey): example_public' }) }); assert.equal(failed.status, 200); assert.doesNotMatch(JSON.stringify(await request('/api/jobs')), /example_secret/); assert.doesNotMatch(JSON.stringify(await request('/api/nodes')), /example_secret/); // Editing a live node rebuilds immediately (3x-ui style); protocol changes reissue client credentials. const changed = await request(`/api/nodes/${node.id}`, 'PATCH', { realityServerName: 'www.tesla.com' }); assert.equal(changed.node.status, 'deploying'); const customerB = (await request('/api/customers', 'POST', { name: '客户 B', ipLimit: 1, deviceLimit: 1 })).customer; const direct = (await request('/api/nodes', 'POST', { name: 'IPv6 单机节点', serverId: relay.id, protocol: 'shadowsocks-2022-aes256', networkMode: 'ipv6', topology: 'direct' })).node; await request(`/api/nodes/${direct.id}/assign`, 'POST', { customerId: customerB.id }); await request(`/api/nodes/${direct.id}/deploy`, 'POST', {}); const noActive = await fetch(`${base}/s/${customerB.subscriptionToken}/base64`); assert.equal(noActive.status, 503); const enrollment = await request(`/api/servers/${relay.id}/enrollment-token`, 'POST', {}); const enrolled = await fetch(`${base}/api/agent/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: enrollment.token, hostname: 'test-agent' }) }); const { agentKey } = await enrolled.json(); assert.equal(enrolled.status, 201); agentKeys[relay.id] = agentKey; // re-enrollment revokes the previous agent key const completeNext = async (key) => { const polled = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${key}` } }); const { job } = await polled.json(); assert.ok(job); const completed = await fetch(`${base}/api/agent/jobs/${job.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true, result: { artifacts: { realityPublicKey: 'test-public-key' } } }) }); assert.equal(completed.status, 200); return job; }; // Drain every queued job on both agents (entry + exit + rebuild from the PATCH above). for (let i = 0; i < 8; i += 1) { let drained = true; for (const key of Object.values(agentKeys)) { const polled = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${key}` } }); const { job } = await polled.json(); if (job) { drained = false; await fetch(`${base}/api/agent/jobs/${job.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true, result: { artifacts: { realityPublicKey: 'test-public-key' } } }) }); } } if (drained) break; } const activeNodes = (await request('/api/nodes')).nodes; assert.equal(activeNodes.find((item) => item.id === node.id).status, 'active'); assert.equal(activeNodes.find((item) => item.id === direct.id).status, 'active'); const usageReport = async (uplink, downlink, epoch = 'sing-box-start-1') => { const response = await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ epoch, samples: [{ kind: 'user', id: customerB.id, uplink, downlink }] }) }); assert.equal(response.status, 200); }; await usageReport(150, 350); await usageReport(150, 350); // Retry after a lost HTTP response must not double count. await usageReport(250, 900); await usageReport(150, 350); // A late report from the same process must not count a false reset. await usageReport(10, 20, 'sing-box-start-2'); // sing-box restarted, and its counters began from zero. await usageReport(250, 900, 'sing-box-start-1'); // The old request can arrive after the new process reported. const metered = (await request('/api/customers')).customers.find((item) => item.id === customerB.id); assert.equal(metered.usedBytes, 1180); assert.equal(metered.usedUplinkBytes, 260); assert.equal(metered.usedDownlinkBytes, 920); // Inbound-level traffic lands on the node, not the customer. await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ epoch: 'sing-box-start-2', samples: [{ kind: 'inbound', id: direct.id, uplink: 260, downlink: 920 }] }) }); const meteredNode = (await request('/api/nodes')).nodes.find((item) => item.id === direct.id); assert.deepEqual(meteredNode.meteredTraffic, { uplink: 260, downlink: 920 }); const raw = await fetch(`${base}/s/${customerB.subscriptionToken}/raw`); assert.equal(raw.status, 200); assert.match(raw.headers.get('subscription-userinfo'), /upload=260; download=920;/); await usageReport(1000, 2000, 'sing-box-start-3'); // A fast restart can exceed previous counters before polling. await usageReport(1000, 2000, 'sing-box-start-3'); assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).usedBytes, 4180); assert.match(await raw.text(), /^ss:\/\//); assert.equal(raw.headers.get('cache-control'), 'no-store, private'); const encoded = await fetch(`${base}/s/${customerB.subscriptionToken}/base64`); assert.match(Buffer.from(await encoded.text(), 'base64').toString('utf8'), /^ss:\/\//); const clash = await fetch(`${base}/s/${customerB.subscriptionToken}/clash`); const clashText = await clash.text(); assert.match(clashText, /^ - name: /m); assert.match(clashText, /^ type: "ss"$/m); const smart = await fetch(`${base}/s/${customerB.subscriptionToken}/clash-smart`); const smartText = await smart.text(); assert.match(smartText, /GEOSITE,category-ads-all,REJECT/); assert.match(smartText, /GEOSITE,facebook,Meta 服务/); const surge = await fetch(`${base}/s/${customerB.subscriptionToken}/surge`); assert.match(await surge.text(), /\[Proxy Group\]/); const singbox = await fetch(`${base}/s/${customerB.subscriptionToken}/singbox`); assert.equal((await singbox.json()).outbounds[1].type, 'shadowsocks'); const rotated = (await request(`/api/customers/${customerB.id}/rotate-subscription`, 'POST', {})).customer; assert.notEqual(rotated.subscriptionToken, customerB.subscriptionToken); assert.equal((await fetch(`${base}/s/${customerB.subscriptionToken}/raw`)).status, 404); assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`)).status, 200); const extraClient = await fetch(`${base}/s/${rotated.subscriptionToken}/raw`, { headers: { 'x-device-id': 'another-client', 'user-agent': 'Other Client/1.0' } }); assert.equal(extraClient.status, 429); const access = await request(`/api/customers/${customerB.id}/access`); assert.equal(access.subscriptionClients, 1); assert.ok(access.events.some((item) => item.status === 429 && item.reason.includes('超限'))); assert.ok(access.events.some((item) => item.status === 200 && item.bytes > 0)); assert.ok(access.events.every((item) => !('clientKey' in item))); assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).subscriptionClientCount, 1); await request(`/api/customers/${customerB.id}/access`, 'DELETE'); assert.equal((await request(`/api/customers/${customerB.id}/access`)).events.length, 0); assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`, { headers: { 'x-device-id': 'another-client' } })).status, 200); const nodeClients = (await request('/api/nodes')).nodeClients; const originalUri = nodeClients.find((item) => item.nodeId === direct.id && item.customerId === customerB.id).clientUri; await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 2000 }); assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`)).status, 403); const prematureResume = await fetch(`${base}/api/customers/${customerB.id}`, { method: 'PATCH', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: JSON.stringify({ status: 'active' }) }); assert.equal(prematureResume.status, 409); const deletePoll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } }); const deleteJob = (await deletePoll.json()).job; assert.equal(deleteJob.action, 'delete_resource'); await fetch(`${base}/api/agent/jobs/${deleteJob.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) }); assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft'); assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).status, 'suspended'); await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 }); const restoringSubscription = await fetch(`${base}/s/${rotated.subscriptionToken}/raw`); assert.equal(restoringSubscription.status, 503); assert.equal(restoringSubscription.headers.get('retry-after'), '15'); assert.match((await restoringSubscription.json()).message, /正在恢复/); const applyPoll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } }); const applyJob = (await applyPoll.json()).job; assert.equal(applyJob.action, 'apply_resource'); await fetch(`${base}/api/agent/jobs/${applyJob.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) }); const revivedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === direct.id && item.customerId === customerB.id).clientUri; assert.equal(revivedUri, originalUri); assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`, { headers: { 'x-device-id': 'another-client' } })).status, 200); // Raising the limit while a delete is already running still applies only after cleanup. await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 3000 }); const inFlight = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job; assert.equal(inFlight.action, 'delete_resource'); await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 }); await fetch(`${base}/api/agent/jobs/${inFlight.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) }); const afterDelete = (await request('/api/nodes')).nodes.find((item) => item.id === direct.id); assert.equal(afterDelete.status, 'deploying'); // resume queued the apply behind the running delete const reapply = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job; assert.equal(reapply.action, 'apply_resource'); await fetch(`${base}/api/agent/jobs/${reapply.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) }); // A queued deletion can be cancelled before it touches the existing node. await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 3000 }); await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 }); assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'active'); assert.equal((await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job, null); // A removed node keeps its client assignments; redeploying restores the same URI. await request(`/api/nodes/${direct.id}/remove`, 'POST', {}); const oldDelete = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job; await fetch(`${base}/api/agent/jobs/${oldDelete.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) }); assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft'); await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 3000 }); await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 }); assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft'); await request(`/api/nodes/${direct.id}/deploy`, 'POST', {}); const oldApply = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job; await fetch(`${base}/api/agent/jobs/${oldApply.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) }); const redeployedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === direct.id && item.customerId === customerB.id).clientUri; assert.equal(redeployedUri, originalUri); // A forward node suspends both exit and entry, and restores them with the same URI. const customerC = (await request('/api/customers', 'POST', { name: '配额恢复测试', trafficLimitBytes: 100 })).customer; const forward = (await request('/api/nodes', 'POST', { name: '保留节点', serverId: relay.id, protocol: 'vless-reality-vision', topology: 'forward', exitServerId: exit.id, exitProtocol: 'shadowsocks-2022-aes128' })).node; await request(`/api/nodes/${forward.id}/assign`, 'POST', { customerId: customerC.id }); await request(`/api/nodes/${forward.id}/deploy`, 'POST', {}); await completeNext(agentKeys[exit.id]); await completeNext(agentKey); const forwardUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === forward.id && item.customerId === customerC.id).clientUri; const forwardUsage = await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ epoch: 'fwd-1', samples: [{ kind: 'user', id: customerC.id, uplink: 2, downlink: 5 }] }) }); assert.equal(forwardUsage.status, 200); await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes: 1 }); assert.equal((await completeNext(agentKeys[exit.id])).action, 'delete_resource'); assert.equal((await completeNext(agentKey)).action, 'delete_resource'); assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === forward.id).status, 'draft'); await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes: 100 }); assert.equal((await completeNext(agentKey)).action, 'apply_resource'); assert.equal((await fetch(`${base}/s/${customerC.subscriptionToken}/raw`)).status, 503); assert.equal((await completeNext(agentKeys[exit.id])).action, 'apply_resource'); const forwardRevivedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === forward.id && item.customerId === customerC.id).clientUri; assert.equal(forwardRevivedUri, forwardUri); assert.equal((await fetch(`${base}/s/${customerC.subscriptionToken}/raw`)).status, 200); // AnyTLS is a separate entry engine, but follows the same quota lifecycle. await request(`/api/servers/${relay.id}`, 'PATCH', { tlsDomain: 'entry.example.com' }); const anyCustomer = (await request('/api/customers', 'POST', { name: 'AnyTLS 客户', trafficLimitBytes: 500 })).customer; const anyNode = (await request('/api/nodes', 'POST', { name: 'AnyTLS → VLESS', serverId: relay.id, protocol: 'anytls', topology: 'forward', exitServerId: exit.id, exitProtocol: 'vless-tcp' })).node; await request(`/api/nodes/${anyNode.id}/assign`, 'POST', { customerId: anyCustomer.id }); const oldAgent = await fetch(`${base}/api/nodes/${anyNode.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' }); assert.equal(oldAgent.status, 409); assert.match((await oldAgent.json()).message, /入口证书未由 Agent 确认/); await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ version: '0.6.5', engine: { status: 'ready', singBoxInstalled: false, certificates: ['entry.example.com'] } }) }); const missingEngine = await fetch(`${base}/api/nodes/${anyNode.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' }); assert.equal(missingEngine.status, 409); assert.match((await missingEngine.json()).message, /ng-agent engine install/); await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ version: '0.6.5', engine: { status: 'ready', singBoxInstalled: true, certificates: ['entry.example.com'] } }) }); await request(`/api/nodes/${anyNode.id}/deploy`, 'POST', {}); const anyExitJob = await completeNext(agentKeys[exit.id]); assert.equal(anyExitJob.payload.resource.inbounds[0].type, 'vless'); const anyEntryJob = await completeNext(agentKey); assert.equal(anyEntryJob.payload.resource.engine, 'sing-box'); assert.equal(anyEntryJob.payload.resource.outbounds[0].type, 'vless'); const anyUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === anyNode.id && item.customerId === anyCustomer.id).clientUri; assert.match(anyUri, /^anytls:\/\//); assert.equal(decodeURIComponent(anyUri.split('#')[1]), 'AnyTLS → VLESS'); assert.match(await (await fetch(`${base}/s/${anyCustomer.subscriptionToken}/clash`)).text(), /type: "anytls"/); const anyUsage = await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ samples: [{ kind: 'inbound', id: anyNode.id, uplink: 120, downlink: 400, epoch: 'sing-box:123:456' }] }) }); assert.equal(anyUsage.status, 200); assert.deepEqual((await request('/api/nodes')).nodes.find((item) => item.id === anyNode.id).meteredTraffic, { uplink: 120, downlink: 400 }); // Customer-level usage for the quota test below. await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ epoch: 'sb-user-1', samples: [{ kind: 'user', id: anyCustomer.id, uplink: 300, downlink: 300 }] }) }); await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes: 500 }); assert.equal((await completeNext(agentKeys[exit.id])).action, 'delete_resource'); assert.equal((await completeNext(agentKey)).action, 'delete_resource'); assert.equal((await fetch(`${base}/s/${anyCustomer.subscriptionToken}/raw`)).status, 403); await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes: 1000 }); const anyReapply = await completeNext(agentKey); assert.equal(anyReapply.payload.resource.engine, 'sing-box'); assert.equal((await completeNext(agentKeys[exit.id])).action, 'apply_resource'); const anyRevivedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === anyNode.id && item.customerId === anyCustomer.id).clientUri; assert.equal(anyRevivedUri, anyUri); // Exit-server source addresses belong to relay machines, never to client IP quota. await fetch(`${base}/api/agent/observations`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[exit.id]}`, 'content-type': 'application/json' }, body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '198.51.100.1' }] }) }); assert.equal((await request(`/api/customers/${customerB.id}/access`)).observedIps.length, 0); const observed = await fetch(`${base}/api/agent/observations`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '203.0.113.1' }] }) }); assert.equal(observed.status, 200); assert.equal((await request(`/api/customers/${customerB.id}/access`)).observedIps.length, 1); const excessIp = await fetch(`${base}/api/agent/observations`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '203.0.113.2' }] }) }); assert.equal(excessIp.status, 200); assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).suspendReason, 'ip_limit'); // Deleting a node removes it from the UI at once and hard-deletes after agents confirm. await request(`/api/nodes/${node.id}/remove`, 'POST', {}); const deleted = await request(`/api/nodes/${node.id}`, 'DELETE'); assert.equal(deleted.ok, true); const afterNodeDelete = await request('/api/nodes'); assert.equal(afterNodeDelete.nodes.some((item) => item.id === node.id), false); assert.equal(afterNodeDelete.nodeClients.some((item) => item.nodeId === node.id), false); const source = JSON.parse(await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8')); assert.equal(source.nodes.some((item) => item.id === node.id), true); const removedCustomer = await request(`/api/customers/${customer.id}`, 'DELETE'); assert.equal(removedCustomer.ok, true); assert.equal((await fetch(`${base}/s/${customer.subscriptionToken}/raw`)).status, 404); const blocked = await fetch(`${base}/api/servers/${exit.id}`, { method: 'DELETE', headers: { cookie, 'x-csrf-token': session.csrf } }); assert.equal(blocked.status, 409); const retry = await request(`/api/servers/${exit.id}/cleanup`, 'POST', {}); assert.equal(retry.queued, 0); const unsafeForget = await fetch(`${base}/api/servers/${exit.id}/forget`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: JSON.stringify({ confirm: 'FORGET', name: '落地 01' }) }); assert.equal(unsafeForget.status, 409); const renamed = await request('/api/account', 'PATCH', { username: 'owner_2', currentPassword: 'test-password', newPassword: 'new-password-456' }); assert.equal(renamed.reauthenticate, true); const relogin = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'owner_2', password: 'new-password-456' }) }); assert.equal(relogin.status, 200); });