feat: 添加 WireGuard 设备管理并完善客户端分流

实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。

完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。
This commit is contained in:
YANG JIANKUAN
2026-11-02 22:44:23 +08:00
parent f741aeeccd
commit a5d980a5e9
80 changed files with 4777 additions and 51 deletions

View File

@@ -0,0 +1,27 @@
[
"*.anthropic.com",
"*.clau.de",
"*.claude.com",
"*.claudemcpclient.com",
"*.claudemcpcontent.com",
"*.claudeusercontent.com",
"*.ffsb.sg",
"a.claude.ai",
"anthropic.com",
"api.anthropic.com",
"api.ipify.org",
"assets.claude.ai",
"clau.de",
"claude.ai",
"claude.com",
"claudemcpclient.com",
"claudemcpcontent.com",
"claudeusercontent.com",
"ffsb.sg",
"icanhazip.com",
"ifconfig.me",
"ip.net.coffee",
"join.fufeishangban.com",
"pivot.claude.ai",
"www.claude.ai"
]

View File

@@ -3,6 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { env } from '../env.js';
import { migrateWireguard, validateWireguardSecrets } from '../services/wg-migrate.js';
import * as schema from './schema.js';
const BOOTSTRAP_SQL = `
@@ -86,11 +87,41 @@ CREATE TABLE IF NOT EXISTS sub_tokens (
token TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
node_ids TEXT NOT NULL DEFAULT '[]',
wireguard TEXT,
wg_device_id TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
last_access_at INTEGER,
access_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS wg_networks (id TEXT PRIMARY KEY, subnet TEXT NOT NULL, mtu INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS wg_gateways (
id TEXT PRIMARY KEY, network_id TEXT NOT NULL REFERENCES wg_networks(id), server TEXT NOT NULL,
port INTEGER NOT NULL, public_key TEXT NOT NULL UNIQUE, agent_hash TEXT, received_at INTEGER, boot_id TEXT
);
CREATE TABLE IF NOT EXISTS wg_devices (
id TEXT PRIMARY KEY, gateway_id TEXT NOT NULL REFERENCES wg_gateways(id), name TEXT NOT NULL, os TEXT NOT NULL,
address TEXT NOT NULL UNIQUE, public_key TEXT NOT NULL UNIQUE, secret TEXT NOT NULL, desired TEXT NOT NULL,
version INTEGER NOT NULL, applied_version INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, archived_at INTEGER
);
CREATE TABLE IF NOT EXISTS wg_jobs (
id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES wg_devices(id), action TEXT NOT NULL,
version INTEGER NOT NULL, state TEXT NOT NULL, old_public_key TEXT, error TEXT, created_at INTEGER NOT NULL, confirmed_at INTEGER,
UNIQUE(device_id,version)
);
CREATE TABLE IF NOT EXISTS wg_observations (
device_id TEXT PRIMARY KEY REFERENCES wg_devices(id), received_at INTEGER NOT NULL, boot_id TEXT NOT NULL,
handshake_at INTEGER, activity_at INTEGER, rx INTEGER NOT NULL, tx INTEGER NOT NULL, upload_rate INTEGER, download_rate INTEGER,
upload_total INTEGER NOT NULL DEFAULT 0, download_total INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS wg_events (
id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES wg_devices(id), message TEXT NOT NULL, created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS wg_subscription_bindings (
id TEXT PRIMARY KEY, token_id TEXT NOT NULL REFERENCES sub_tokens(id) ON DELETE CASCADE,
device_id TEXT NOT NULL REFERENCES wg_devices(id), credential_hash TEXT NOT NULL UNIQUE, purpose TEXT NOT NULL,
created_at INTEGER NOT NULL, last_access_at INTEGER, revoked_at INTEGER
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
@@ -100,9 +131,11 @@ CREATE TABLE IF NOT EXISTS settings (
export function createDb(dbPath?: string) {
const file = dbPath ?? path.join(env.dataDir, 'proxy-station.db');
if (file !== ':memory:') {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
}
const sqlite = new Database(file);
if (file !== ':memory:') fs.chmodSync(file, 0o600);
sqlite.pragma('secure_delete = ON');
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
sqlite.exec(BOOTSTRAP_SQL);
@@ -110,6 +143,8 @@ export function createDb(dbPath?: string) {
for (const stmt of [
'ALTER TABLE policy_groups ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1',
"ALTER TABLE sub_tokens ADD COLUMN node_ids TEXT NOT NULL DEFAULT '[]'",
'ALTER TABLE sub_tokens ADD COLUMN wireguard TEXT',
'ALTER TABLE sub_tokens ADD COLUMN wg_device_id TEXT',
]) {
try {
sqlite.exec(stmt);
@@ -117,7 +152,15 @@ export function createDb(dbPath?: string) {
// 列已存在
}
}
return drizzle(sqlite, { schema });
const database = drizzle(sqlite, { schema });
try {
validateWireguardSecrets(database);
migrateWireguard(database);
} catch (error) {
sqlite.close();
throw error;
}
return database;
}
export type Db = ReturnType<typeof createDb>;

View File

@@ -1,4 +1,4 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { integer, sqliteTable, text, unique } from 'drizzle-orm/sqlite-core';
export const nodes = sqliteTable('nodes', {
id: text('id').primaryKey(),
@@ -89,6 +89,8 @@ export const subTokens = sqliteTable('sub_tokens', {
name: text('name').notNull(),
/** 分配给该订阅的节点 id(JSON 数组);空数组 = 不限制,下发全部启用节点 */
nodeIds: text('node_ids').notNull().default('[]'),
wireguard: text('wireguard'),
wgDeviceId: text('wg_device_id'),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
createdAt: integer('created_at').notNull(),
lastAccessAt: integer('last_access_at'),
@@ -99,3 +101,46 @@ export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
});
// WireGuard identities are separate from subscriptions; archived addresses remain reserved.
export const wgNetworks = sqliteTable('wg_networks', {
id: text('id').primaryKey(), subnet: text('subnet').notNull(), mtu: integer('mtu').notNull(),
});
export const wgGateways = sqliteTable('wg_gateways', {
id: text('id').primaryKey(), networkId: text('network_id').notNull().references(() => wgNetworks.id),
server: text('server').notNull(), port: integer('port').notNull(), publicKey: text('public_key').notNull().unique(),
agentHash: text('agent_hash'), receivedAt: integer('received_at'), bootId: text('boot_id'),
});
export const wgDevices = sqliteTable('wg_devices', {
id: text('id').primaryKey(), gatewayId: text('gateway_id').notNull().references(() => wgGateways.id),
name: text('name').notNull(), os: text('os').notNull(), address: text('address').notNull().unique(),
publicKey: text('public_key').notNull().unique(), secret: text('secret').notNull(),
desired: text('desired', { enum: ['active', 'paused', 'archived'] }).notNull(),
version: integer('version').notNull(), appliedVersion: integer('applied_version').notNull().default(0),
createdAt: integer('created_at').notNull(), archivedAt: integer('archived_at'),
});
export const wgJobs = sqliteTable('wg_jobs', {
id: text('id').primaryKey(), deviceId: text('device_id').notNull().references(() => wgDevices.id),
action: text('action').notNull(), version: integer('version').notNull(),
state: text('state', { enum: ['pending', 'failed', 'confirmed'] }).notNull(),
oldPublicKey: text('old_public_key'), error: text('error'), createdAt: integer('created_at').notNull(),
confirmedAt: integer('confirmed_at'),
}, t => ({ deviceVersion: unique().on(t.deviceId, t.version) }));
export const wgObservations = sqliteTable('wg_observations', {
deviceId: text('device_id').primaryKey().references(() => wgDevices.id),
receivedAt: integer('received_at').notNull(), bootId: text('boot_id').notNull(),
handshakeAt: integer('handshake_at'), activityAt: integer('activity_at'),
rx: integer('rx').notNull(), tx: integer('tx').notNull(),
uploadRate: integer('upload_rate'), downloadRate: integer('download_rate'),
uploadTotal: integer('upload_total').notNull().default(0), downloadTotal: integer('download_total').notNull().default(0),
});
export const wgEvents = sqliteTable('wg_events', {
id: text('id').primaryKey(), deviceId: text('device_id').notNull().references(() => wgDevices.id),
message: text('message').notNull(), createdAt: integer('created_at').notNull(),
});
export const wgSubscriptionBindings = sqliteTable('wg_subscription_bindings', {
id: text('id').primaryKey(), tokenId: text('token_id').notNull().references(() => subTokens.id, { onDelete: 'cascade' }),
deviceId: text('device_id').notNull().references(() => wgDevices.id),
credentialHash: text('credential_hash').notNull().unique(), purpose: text('purpose').notNull(),
createdAt: integer('created_at').notNull(), lastAccessAt: integer('last_access_at'), revokedAt: integer('revoked_at'),
});

View File

@@ -13,11 +13,13 @@ import { groupsRoute } from './routes/groups.js';
import { rulesRoute, rulesetsRoute } from './routes/rules.js';
import { tokensRoute } from './routes/tokens.js';
import { subRoute } from './routes/sub.js';
import { wgRoute, wgAgentRoute, wgBindingRoute } from './routes/wg.js';
import { settingsRoute } from './routes/settings.js';
seedIfEmpty(db);
const app = new Hono();
app.use('*', async (c, next) => { c.header('referrer-policy', 'no-referrer'); await next(); });
app.get('/api/health', (c) => c.json({ ok: true, name: 'proxy-station' }));
@@ -29,6 +31,10 @@ app.route('/api/groups', groupsRoute);
app.route('/api/rules', rulesRoute);
app.route('/api/rulesets', rulesetsRoute);
app.route('/api/tokens', tokensRoute);
app.route('/api/tokens', wgBindingRoute);
app.route('/api/wg', wgRoute);
app.route('/wg-agent', wgAgentRoute);
app.all('/wg-agent/*', c => c.json({ error: 'not found' }, 404));
app.route('/api/settings', settingsRoute);
// 公开订阅端点:token 即凭证

View File

@@ -2,11 +2,16 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { getSetting, setSetting } from '../services/ir.js';
import { ffsbSettingsSchema } from '../services/generators/surge-ffsb.js';
import { db } from '../db/index.js';
const SURGE_SECTION_KEYS = ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script'];
const SURGE_SECTION_KEYS = ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script', 'FFSB'];
function unsafeSettings(sections: Record<string, string>): string | null {
if (sections.FFSB) {
try { ffsbSettingsSchema.parse(JSON.parse(sections.FFSB)); }
catch { return 'FFSB 设置无效'; }
}
for (const line of (sections.General ?? '').split(/\r?\n/)) {
const match = line.match(/^\s*(http-api|external-controller-access)\s*=\s*([^@]+)@/i);
if (match && ['surge', 'clashconnectrules'].includes(match[2].trim().replace(/^["']|["']$/g, '').toLowerCase())) return '控制接口不能使用公开模板口令;请关闭接口或使用独立凭据与访问限制';

View File

@@ -0,0 +1,64 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import YAML from 'yaml';
const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-sub-ruleset-'));
process.env.DATA_DIR = directory;
vi.mock('../services/rulesets/fetch.js', () => ({ fetchRulesetContent: vi.fn() }));
const { db } = await import('../db/index.js');
const { subTokens, rulesets, rules } = await import('../db/schema.js');
const { fetchRulesetContent } = await import('../services/rulesets/fetch.js');
const { subRoute } = await import('./sub.js');
const fetchContent = vi.mocked(fetchRulesetContent);
beforeEach(() => {
for (const table of [rules, rulesets, subTokens]) db.delete(table).run();
db.insert(subTokens).values({ id: 'token-id', token: 'placeholder-token', name: 'test', createdAt: 0 }).run();
db.insert(rulesets).values({ id: 'domains', name: 'domains', surgeUrl: 'https://example.com/domains.txt',
clashBehavior: 'domain', clashFormat: 'text', needsConvert: true }).run();
fetchContent.mockReset();
fetchContent.mockResolvedValue({ content: 'exact.example.com\n.suffix.example.com', fetchedAt: 0, stale: false });
});
afterAll(() => { db.$client.close(); rmSync(directory, { recursive: true, force: true }); });
describe('authenticated domain-set conversion subscriptions', () => {
it('serves an ordinary rule list and carries stale-cache diagnostics', async () => {
fetchContent.mockResolvedValueOnce({ content: 'exact.example.com\n.suffix.example.com', fetchedAt: 0, stale: true });
const response = await subRoute.request('/placeholder-token/ruleset/domains.list');
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/plain');
expect(response.headers.get('x-proxy-station-converted')).toBe('2/2');
expect(response.headers.get('x-proxy-station-stale')).toBe('1');
expect(await response.text()).toBe('DOMAIN,exact.example.com\nDOMAIN-SUFFIX,suffix.example.com\n');
});
it('keeps the existing Clash YAML conversion endpoint unchanged', async () => {
const response = await subRoute.request('/placeholder-token/ruleset/domains.yaml');
expect(response.status).toBe(200);
expect(YAML.parse(await response.text())).toEqual({ payload: ['exact.example.com', '+.suffix.example.com'] });
});
it('rejects unknown tokens, resources and non-domain list conversions before fetching', async () => {
db.insert(rulesets).values({ id: 'ip', name: 'ip', surgeUrl: 'https://example.com/ip.list', clashBehavior: 'ipcidr' }).run();
for (const url of ['/invalid/ruleset/domains.list', '/placeholder-token/ruleset/missing.list',
'/placeholder-token/ruleset/ip.list']) expect((await subRoute.request(url)).status).toBe(404);
expect(fetchContent).not.toHaveBeenCalled();
});
it('fails instead of returning partial or injected rules for an invalid upstream format', async () => {
fetchContent.mockResolvedValueOnce({ content: 'valid.example.com\nexample.com,REJECT', fetchedAt: 0, stale: false });
const response = await subRoute.request('/placeholder-token/ruleset/domains.list');
expect(response.status).toBe(502);
expect(await response.text()).not.toContain('valid.example.com');
});
it('uses the actual forwarded HTTPS origin for Shadowrocket conversion URLs', async () => {
db.insert(rules).values({ id: 'rule', seq: 0, type: 'DOMAIN-SET', value: 'domains', policy: 'REJECT', params: '[]' }).run();
const response = await subRoute.request('/placeholder-token/shadowrocket-conf', { headers: {
'x-forwarded-proto': 'https', 'x-forwarded-host': 'example.com',
} });
expect(await response.text()).toContain('RULE-SET,https://example.com/sub/placeholder-token/ruleset/domains.list,REJECT');
});
});

View File

@@ -5,11 +5,13 @@ import { db } from '../db/index.js';
import { rulesets, subTokens } from '../db/schema.js';
import { buildProfileIR } from '../services/ir.js';
import { fetchRulesetContent } from '../services/rulesets/fetch.js';
import { convertSurgeList } from '../services/rulesets/convert.js';
import { convertSurgeList, domainSetToRuleList } from '../services/rulesets/convert.js';
import { generateSurge } from '../services/generators/surge.js';
import { generateShadowrocket } from '../services/generators/shadowrocket.js';
import { generateShadowrocketConf } from '../services/generators/shadowrocket-conf.js';
import { generateClash } from '../services/generators/clash.js';
import { authorizedProfile, bindingProfile, migrateLegacy, WgError } from '../services/wg.js';
import { generateWireguard } from '../services/generators/wireguard.js';
function constantTimeEqual(a: string, b: string): boolean {
const ha = crypto.createHash('sha256').update(a).digest();
@@ -18,15 +20,21 @@ function constantTimeEqual(a: string, b: string): boolean {
}
export function findToken(token: string) {
migrateLegacy();
const rows = db.select().from(subTokens).where(eq(subTokens.enabled, true)).all();
return rows.find((r) => constantTimeEqual(r.token, token)) ?? null;
}
/** 该订阅可见的节点范围 */
function irFor(row: { nodeIds: string }) {
function proxyIrFor(row: { nodeIds: string }) {
return buildProfileIR({ nodeIds: JSON.parse(row.nodeIds) });
}
function irFor(row: { nodeIds: string; wgDeviceId: string | null }) {
return { ...proxyIrFor(row),
...(row.wgDeviceId ? { wireguard: authorizedProfile(row.wgDeviceId, true) } : {}) };
}
function touchToken(id: string, count: number) {
db.update(subTokens)
.set({ lastAccessAt: Date.now(), accessCount: count + 1 })
@@ -49,13 +57,30 @@ function baseUrl(c: { req: { url: string; header: (name: string) => string | und
}
export const subRoute = new Hono()
.onError((e, c) => e instanceof WgError ? c.text('not found', 404) : c.text('subscription unavailable', 503))
.use('*', async (c, next) => { c.header('referrer-policy', 'no-referrer'); c.header('cache-control', 'private, no-store'); await next(); })
.get('/:token/wireguard.conf', (c) => {
const row = findToken(c.req.param('token'));
if (!row?.wgDeviceId) return c.text('not found', 404);
touchToken(row.id, row.accessCount);
c.header('cache-control', 'private, no-store');
c.header('content-type', 'text/plain; charset=utf-8');
c.header('content-disposition', 'attachment; filename="wireguard.conf"');
return c.body(generateWireguard(authorizedProfile(row.wgDeviceId!, true)));
})
// Retired endpoint: explicitly return 404 rather than falling through to the SPA.
.get('/:token/wireguard-shadowrocket', (c) => c.text('not found', 404))
.get('/:token/surge', (c) => {
const row = findToken(c.req.param('token'));
if (!row) return c.text('not found', 404);
touchToken(row.id, row.accessCount);
const ir = irFor(row);
const selfUrl = `${baseUrl(c)}/sub/${row.token}/surge`;
const params = new URL(c.req.url).searchParams;
if ([...params.keys()].some(k => k !== 'wg') || params.getAll('wg').length > 1) return c.text('invalid parameters', 400);
const wg = params.get('wg');
const ir = wg !== null ? { ...buildProfileIR({ nodeIds: JSON.parse(row.nodeIds) }), wireguard: bindingProfile(row.id, wg) } : irFor(row);
const selfUrl = `${baseUrl(c)}/sub/${row.token}/surge${wg !== null ? `?wg=${encodeURIComponent(wg)}` : ''}`;
const { content } = generateSurge(ir, { selfUrl });
if (row.wgDeviceId || wg) c.header('cache-control', 'private, no-store');
c.header('content-type', 'text/plain; charset=utf-8');
c.header('content-disposition', 'attachment; filename="Proxy Station.conf"; filename*=UTF-8\'\'Proxy%20Station.conf');
return c.body(content);
@@ -64,7 +89,7 @@ export const subRoute = new Hono()
const row = findToken(c.req.param('token'));
if (!row) return c.text('not found', 404);
touchToken(row.id, row.accessCount);
const { content } = generateShadowrocket(irFor(row));
const { content } = generateShadowrocket(proxyIrFor(row));
c.header('content-type', 'text/plain; charset=utf-8');
return c.body(content);
})
@@ -73,7 +98,8 @@ export const subRoute = new Hono()
const row = findToken(c.req.param('token'));
if (!row) return c.text('not found', 404);
touchToken(row.id, row.accessCount);
const { content } = generateShadowrocketConf(irFor(row));
const convertBaseUrl = `${baseUrl(c)}/sub/${row.token}`;
const { content } = generateShadowrocketConf(proxyIrFor(row), { convertBaseUrl });
c.header('content-type', 'text/plain; charset=utf-8');
c.header('content-disposition', 'attachment; filename="Proxy Station Rules.conf"; filename*=UTF-8\'\'Proxy%20Station%20Rules.conf');
return c.body(content);
@@ -82,7 +108,10 @@ export const subRoute = new Hono()
const row = findToken(c.req.param('token'));
if (!row) return c.text('not found', 404);
touchToken(row.id, row.accessCount);
const ir = irFor(row);
const params = new URL(c.req.url).searchParams;
if ([...params.keys()].some(k => k !== 'wg') || params.getAll('wg').length > 1) return c.text('invalid parameters', 400);
const wg = params.get('wg');
const ir = wg !== null ? { ...proxyIrFor(row), wireguard: bindingProfile(row.id, wg, 'clash') } : proxyIrFor(row);
const convertBaseUrl = `${baseUrl(c)}/sub/${row.token}`;
const { content } = generateClash(ir, { convertBaseUrl });
c.header('content-type', 'text/yaml; charset=utf-8');
@@ -94,11 +123,21 @@ export const subRoute = new Hono()
.get('/:token/ruleset/:file', async (c) => {
const row = findToken(c.req.param('token'));
if (!row) return c.text('not found', 404);
const id = c.req.param('file').replace(/\.yaml$/, '');
const file = c.req.param('file');
const isRuleList = file.endsWith('.list');
const id = file.replace(/\.(yaml|list)$/, '');
const rs = db.select().from(rulesets).where(eq(rulesets.id, id)).get();
if (!rs) return c.text('not found', 404);
if (isRuleList && rs.clashBehavior !== 'domain') return c.text('not found', 404);
try {
const { content, stale } = await fetchRulesetContent(rs.surgeUrl);
if (isRuleList) {
const result = domainSetToRuleList(content);
c.header('content-type', 'text/plain; charset=utf-8');
c.header('x-proxy-station-converted', `${result.total}/${result.total}`);
if (stale) c.header('x-proxy-station-stale', '1');
return c.body(result.content);
}
const result = convertSurgeList(content, rs.clashBehavior as 'classical' | 'domain' | 'ipcidr');
c.header('content-type', 'text/yaml; charset=utf-8');
c.header('x-proxy-station-converted', `${result.converted}/${result.total}`);

View File

@@ -6,33 +6,38 @@ import { nanoid } from 'nanoid';
import { z } from 'zod';
import { CLIENTS, type TokenDto } from '@proxy-station/shared';
import { db } from '../db/index.js';
import { migrateLegacy } from '../services/wg.js';
import { subTokens } from '../db/schema.js';
/** 只返回路径,前端用当前页面的 origin 拼完整 URL */
function tokenUrls(token: string): TokenDto['urls'] {
function tokenUrls(token: string, wireguard: boolean): TokenDto['urls'] {
return {
...(Object.fromEntries(CLIENTS.map((c) => [c, `/sub/${token}/${c}`])) as Record<
(typeof CLIENTS)[number],
string
>),
shadowrocketConf: `/sub/${token}/shadowrocket-conf`,
...(wireguard ? { wireguard: `/sub/${token}/wireguard.conf` } : {}),
};
}
type TokenRow = typeof subTokens.$inferSelect;
function toDto(row: TokenRow): TokenDto {
return { ...row, nodeIds: JSON.parse(row.nodeIds), urls: tokenUrls(row.token) };
const { wireguard: _secret, ...safe } = row;
return { ...safe, nodeIds: JSON.parse(row.nodeIds), urls: tokenUrls(row.token, !!row.wgDeviceId) };
}
const tokenInput = z.object({
name: z.string().min(1).max(64),
/** 空数组 = 不限制,下发全部启用节点 */
nodeIds: z.array(z.string()).default([]),
});
}).strict();
export const tokensRoute = new Hono()
.use('*', async (c, next) => { c.header('cache-control', 'private, no-store'); await next(); })
.get('/', (c) => {
migrateLegacy();
const rows = db.select().from(subTokens).orderBy(asc(subTokens.createdAt)).all();
return c.json(rows.map(toDto));
})
@@ -43,15 +48,19 @@ export const tokensRoute = new Hono()
token: crypto.randomBytes(16).toString('hex'),
name,
nodeIds: JSON.stringify(nodeIds),
wireguard: null,
wgDeviceId: null,
enabled: true,
createdAt: Date.now(),
lastAccessAt: null,
accessCount: 0,
};
db.insert(subTokens).values(row).run();
return c.json(toDto(row), 201);
migrateLegacy();
return c.json(toDto(db.select().from(subTokens).where(eq(subTokens.id, row.id)).get()!), 201);
})
.put('/:id', zValidator('json', tokenInput), (c) => {
migrateLegacy();
const id = c.req.param('id');
const { name, nodeIds } = c.req.valid('json');
const res = db
@@ -60,6 +69,7 @@ export const tokensRoute = new Hono()
.where(eq(subTokens.id, id))
.run();
if (res.changes === 0) return c.json({ error: '订阅不存在' }, 404);
migrateLegacy();
return c.json(toDto(db.select().from(subTokens).where(eq(subTokens.id, id)).get()!));
})
.patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => {

View File

@@ -0,0 +1,280 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, renameSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import Database from 'better-sqlite3';
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import YAML from 'yaml';
const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-wg-devices-'));
process.env.DATA_DIR = directory;
const { db, createDb } = await import('../db/index.js');
const { subTokens, wgDevices, wgEvents, wgJobs, wgNetworks, wgGateways, wgObservations, wgSubscriptionBindings, nodes } = await import('../db/schema.js');
const { wgRoute, wgBindingRoute, wgAgentRoute } = await import('./wg.js');
const { tokensRoute } = await import('./tokens.js');
const { subRoute } = await import('./sub.js');
const { publicKey, decryptSecret } = await import('../services/wg-crypto.js');
const { listDevices, receiveReport } = await import('../services/wg.js');
const app = new Hono().route('/api/wg', wgRoute).route('/api/tokens', tokensRoute).route('/api/tokens', wgBindingRoute)
.route('/wg-agent', wgAgentRoute).route('/sub', subRoute);
const key = (n: number) => Buffer.alloc(32, n).toString('base64');
const legacyProfile = (address = '10.203.0.2', n = 2) => ({ server: 'wg.example.test', port: 51820, serverPublicKey: key(1),
privateKey: key(n), presharedKey: key(n + 10), address, subnet: '10.203.0.0/24', mtu: 1280, keepalive: 25, acceptIncoming: true });
function req(url: string, body?: unknown, method = 'POST', agentToken?: string) {
return app.request(url, { method, headers: { 'content-type': 'application/json', ...(agentToken ? { authorization: `Bearer ${agentToken}` } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }) });
}
async function setup() {
const response = await req('/api/wg/gateways', { server: 'wg.example.test', port: 51820, publicKey: key(1) });
expect(response.status).toBe(201);
return await response.json() as { id: string; agentToken: string };
}
async function create(name = 'Mac', id = crypto.randomUUID()) {
const response = await req('/api/wg/devices', { name, os: 'macOS', requestId: id });
expect(response.status).toBe(202); return await response.json() as any;
}
async function confirm(gateway: { id: string; agentToken: string }, peers: any[] = [], ok = true) {
const tasks = await (await req(`/wg-agent/${gateway.id}/tasks`, undefined, 'GET', gateway.agentToken)).json() as any;
const response = await req(`/wg-agent/${gateway.id}/report`, { bootId: 'epoch-1', peers,
receipts: tasks.tasks.map((t: any) => ({ id: t.id, version: t.version, runtime: ok, persistent: ok })) }, 'POST', gateway.agentToken);
expect(response.status).toBe(200); return tasks.tasks;
}
function token(id = 'shared', wireguard: unknown = null) {
db.insert(subTokens).values({ id, token: id + '-secret', name: id, nodeIds: '[]', wireguard: wireguard ? JSON.stringify(wireguard) : null, createdAt: 0 }).run();
}
async function bind(deviceId: string, id = 'shared') {
const response = await req(`/api/tokens/${id}/wg-bindings`, { deviceId });
expect(response.status).toBe(201); return await response.json() as { id: string; path: string };
}
beforeEach(() => {
vi.useRealTimers();
for (const table of [wgSubscriptionBindings, subTokens, wgEvents, wgObservations, wgJobs, wgDevices, wgGateways, wgNetworks, nodes]) db.delete(table).run();
});
afterAll(() => { vi.useRealTimers(); db.$client.close(); rmSync(directory, { recursive: true, force: true }); });
describe('WireGuard device lifecycle and subscription capabilities', () => {
it('allocates no demo identities, requires an explicitly configured gateway, reserves old addresses and deduplicates create requests', async () => {
expect(listDevices()).toEqual([]);
expect((await req('/api/wg/devices', { name: 'Mac', os: 'macOS', requestId: crypto.randomUUID() })).status).toBe(503);
await setup(); const device = await create();
expect(device.address).toBe('10.203.0.4'); expect(device.status).toBe('provisioning');
const repeated = await create('retry', device.id);
expect(repeated.id).toBe(device.id); expect(listDevices()).toHaveLength(1);
expect(db.select().from(wgJobs).all()).toHaveLength(1);
expect(JSON.stringify(device)).not.toContain('privateKey'); expect(JSON.stringify(device)).not.toContain('presharedKey');
const stored = db.select().from(wgDevices).get()!;
const profile = decryptSecret<any>(stored.secret);
expect(stored.secret).not.toContain(profile.privateKey); expect(stored.publicKey).toBe(publicKey(profile.privateKey));
expect(profile.acceptIncoming).toBe(true);
});
it('isolates A/B identities, preserves default URL/content, node scope and the complete managed update URL', async () => {
const gateway = await setup(); const a = await create('Mac'); const b = await create('iPad'); token(); token('other');
await confirm(gateway);
const defaultBefore = await (await app.request('/sub/shared-secret/surge')).text();
expect(defaultBefore).not.toContain('[WireGuard');
expect(defaultBefore).not.toContain('NJCQ WireGuard LAN');
const first = await bind(a.id); const second = await bind(b.id);
const response = await app.request(first.path, { headers: { 'x-forwarded-proto': 'https', 'x-forwarded-host': 'ps.example.test' } });
const aText = await response.text(); const bText = await (await app.request(second.path)).text();
const aProfile = decryptSecret<any>(db.select().from(wgDevices).where(eq(wgDevices.id, a.id)).get()!.secret);
const bProfile = decryptSecret<any>(db.select().from(wgDevices).where(eq(wgDevices.id, b.id)).get()!.secret);
expect(aText).toContain(aProfile.privateKey); expect(aText).not.toContain(bProfile.privateKey);
expect(bText).toContain(bProfile.privateKey); expect(bText).not.toContain(aProfile.privateKey);
expect(aText.split('\n')[0]).toBe(`#!MANAGED-CONFIG https://ps.example.test${first.path} interval=86400 strict=true`);
expect(response.headers.get('cache-control')).toBe('private, no-store'); expect(response.headers.get('referrer-policy')).toBe('no-referrer');
expect(await (await app.request(first.path)).text()).toContain(aProfile.privateKey);
expect(await (await app.request('/sub/shared-secret/surge')).text()).toBe(defaultBefore);
expect(await (await app.request('/sub/shared-secret/shadowrocket-conf')).text()).not.toContain('NJCQ WireGuard LAN');
expect(await (await app.request('/sub/shared-secret/clash')).text()).not.toContain('wireguard');
const exported = await (await req(`/api/wg/devices/${a.id}/exports`)).json() as any;
expect(exported.content).toContain(aProfile.privateKey); expect(exported.content).toContain(a.address);
expect(listDevices().find(d => d.id === a.id)!.status).not.toBe('online');
expect(db.select().from(subTokens).get()!.wireguard).toBeNull();
expect(db.select().from(wgSubscriptionBindings).get()!.credentialHash).not.toContain(new URL('http://test' + first.path).searchParams.get('wg')!);
});
it('retains subscription node restrictions when attaching a device and prevents revoked token access', async () => {
const gateway = await setup(); const device = await create(); await confirm(gateway); token();
for (const id of ['allowed', 'hidden']) db.insert(nodes).values({ id, name: id, protocol: 'trojan', server: 'example.test', port: 443,
password: 'placeholder', createdAt: 0, updatedAt: 0 }).run();
db.update(subTokens).set({ nodeIds: '["allowed"]' }).where(eq(subTokens.id, 'shared')).run();
const binding = await bind(device.id);
const content = await (await app.request(binding.path)).text();
expect(content).toContain('allowed = trojan'); expect(content).not.toContain('hidden = trojan');
expect(content.match(/^FINAL,/gm)).toHaveLength(1);
db.update(subTokens).set({ enabled: false }).where(eq(subTokens.id, 'shared')).run();
expect((await app.request(binding.path)).status).toBe(404);
expect((await req('/api/tokens/shared/wg-bindings', { deviceId: device.id })).status).toBe(404);
db.delete(subTokens).where(eq(subTokens.id, 'shared')).run();
expect(db.select().from(wgSubscriptionBindings).all()).toHaveLength(0);
});
it('delivers a purpose-bound FlClash identity without changing the default or public proxy groups', async () => {
const gateway = await setup(); const device = await create(); await confirm(gateway); token(); token('other');
const baseline = await (await app.request('/sub/shared-secret/clash')).text();
const surge = await bind(device.id);
const response = await req('/api/tokens/shared/wg-bindings', { deviceId: device.id, purpose: 'clash' });
expect(response.status).toBe(201);
const binding = await response.json() as { id: string; path: string };
const delivered = await app.request(binding.path);
expect(delivered.status).toBe(200);
expect(delivered.headers.get('cache-control')).toBe('private, no-store');
const doc = YAML.parse(await delivered.text());
const profile = decryptSecret<any>(db.select().from(wgDevices).where(eq(wgDevices.id, device.id)).get()!.secret);
const wg = doc.proxies.find((p: any) => p.type === 'wireguard');
expect(wg).toMatchObject({ ip: device.address, 'private-key': profile.privateKey, mtu: 1280, udp: true,
'persistent-keepalive': 25, peers: [{ server: profile.server, port: profile.port, 'public-key': profile.serverPublicKey,
'pre-shared-key': profile.presharedKey, 'allowed-ips': ['10.203.0.0/24'] }] });
expect(doc.rules[0]).toBe('IP-CIDR,10.203.0.0/24,NJCQ WireGuard LAN,no-resolve');
expect(doc.rules.at(-1)).toMatch(/^MATCH,/);
expect(await (await app.request('/sub/shared-secret/clash')).text()).toBe(baseline);
expect((await app.request(binding.path.replace('shared-secret', 'other-secret'))).status).toBe(404);
expect((await app.request(binding.path.replace('/clash?', '/surge?'))).status).toBe(404);
expect((await app.request(surge.path.replace('/surge?', '/clash?'))).status).toBe(404);
expect((await app.request('/sub/shared-secret/clash?wg=' + device.id)).status).toBe(404);
expect((await app.request(binding.path + '&wg=extra')).status).toBe(400);
const disposable = await (await req('/api/tokens/shared/wg-bindings', { deviceId: device.id, purpose: 'clash' })).json() as { id: string; path: string };
expect((await req(`/api/tokens/shared/wg-bindings/${disposable.id}`, undefined, 'DELETE')).status).toBe(200);
expect((await app.request(disposable.path)).status).toBe(404);
expect((await app.request(binding.path)).status).toBe(200);
expect((await app.request(surge.path)).status).toBe(200);
await req(`/api/wg/devices/${device.id}/actions`, { action: 'pause' });
expect((await app.request(binding.path)).status).toBe(404);
expect((await app.request('/sub/shared-secret/clash')).status).toBe(200);
});
it('rejects enumerated, forged, cross-subscription, empty, duplicate and unknown device parameters', async () => {
const gateway = await setup(); const device = await create(); await confirm(gateway); token(); token('other');
const binding = await bind(device.id);
for (const path of ['/sub/shared-secret/surge?wg=' + device.id, '/sub/shared-secret/surge?wg=1',
'/sub/shared-secret/surge?wg=', binding.path.replace('shared-secret', 'other-secret')]) {
expect((await app.request(path)).status).toBe(404);
}
expect((await app.request('/sub/shared-secret/surge?device=' + device.id)).status).toBe(400);
expect((await app.request(binding.path + '&wg=extra')).status).toBe(400);
expect((await req(`/api/tokens/other/wg-bindings/${binding.id}`, undefined, 'DELETE')).status).toBe(404);
expect((await req(`/api/tokens/shared/wg-bindings/${binding.id}`, undefined, 'DELETE')).status).toBe(200);
expect((await app.request(binding.path)).status).toBe(404);
});
it('does not mark pause/remove complete until both runtime and persistent receipts, retries the same task and never recycles IP', async () => {
const gateway = await setup(); const device = await create(); await confirm(gateway); token(); const binding = await bind(device.id);
const paused = await (await req(`/api/wg/devices/${device.id}/actions`, { action: 'pause' })).json() as any;
expect(paused.status).toBe('revoking');
const duplicate = await (await req(`/api/wg/devices/${device.id}/actions`, { action: 'pause' })).json() as any;
expect(duplicate.job.id).toBe(paused.job.id);
expect((await app.request(binding.path)).status).toBe(404);
expect((await req(`/api/wg/devices/${device.id}/exports`)).status).toBe(404);
const jobs = await confirm(gateway, [], false);
expect(listDevices()[0].status).toBe('error');
await req(`/api/wg/devices/${device.id}/actions`, { action: 'retry' });
const retried = await confirm(gateway); expect(retried[0].id).toBe(jobs[0].id);
expect(listDevices()[0].status).toBe('paused');
await req(`/api/wg/devices/${device.id}/actions`, { action: 'resume' });
expect(listDevices()[0].status).toBe('restoring'); expect((await app.request(binding.path)).status).toBe(404);
await confirm(gateway); expect((await app.request(binding.path)).status).toBe(200);
await req(`/api/wg/devices/${device.id}/actions`, { action: 'remove' });
expect(listDevices()[0].status).toBe('revoking'); await confirm(gateway);
expect(listDevices()).toHaveLength(0); expect((await app.request(binding.path)).status).toBe(404);
expect(db.select().from(wgSubscriptionBindings).get()!.revokedAt).not.toBeNull();
expect((await create('new')).address).not.toBe(device.address);
});
it('retains the IP on rotate, removes old key via a versioned task and preserves the existing binding', async () => {
const gateway = await setup(); const device = await create(); await confirm(gateway); token(); const binding = await bind(device.id);
const original = await (await app.request(binding.path)).text();
await req(`/api/wg/devices/${device.id}/actions`, { action: 'rotate' });
expect((await app.request(binding.path)).status).toBe(404);
const tasks = await confirm(gateway);
expect(tasks[0].oldPublicKey).toBe(device.publicKey); expect(tasks[0].publicKey).not.toBe(device.publicKey);
expect(tasks[0]).not.toHaveProperty('privateKey');
const updated = await (await app.request(binding.path)).text();
expect(updated).not.toBe(original); expect(updated).toContain(`self-ip = ${device.address}`);
await req(`/api/wg/devices/${device.id}`, { name: 'Renamed', os: 'macOS' }, 'PATCH');
expect((await app.request(binding.path)).status).toBe(200);
});
it('uses receiver time/handshake age, counts traffic from the device perspective, handles counter resets and stale samples', async () => {
vi.useFakeTimers(); vi.setSystemTime(new Date('2026-11-02T00:00:00Z'));
const gateway = await setup(); const device = await create(); await confirm(gateway);
const report = (rx: number, tx: number, age: number | null = 5, bootId = 'boot') => receiveReport(gateway.id, { bootId, receipts: [],
peers: [{ publicKey: device.publicKey, handshakeAge: age, rx, tx }] });
report(100, 200, null); expect(listDevices()[0].status).toBe('pending');
vi.advanceTimersByTime(15000); report(250, 500, null);
expect(listDevices()[0].status).toBe('online');
expect(listDevices()[0]).toMatchObject({ uploadRate: 10, downloadRate: 20, uploadTotal: 150, downloadTotal: 300 });
vi.advanceTimersByTime(15000); report(10, 20);
expect(listDevices()[0]).toMatchObject({ uploadRate: null, uploadTotal: 150 });
vi.advanceTimersByTime(46000);
expect(listDevices()[0]).toMatchObject({ status: 'unknown', uploadRate: null, downloadRate: null });
vi.advanceTimersByTime(180000); report(10, 20, 400);
expect(listDevices()[0].status).toBe('offline');
report(5, 6, null, 'new-boot'); expect(listDevices()[0].uploadTotal).toBe(150);
});
it('agent auth is independent, cannot acknowledge other gateways or stale job versions, and requires both confirmations', async () => {
const gateway = await setup(); const device = await create();
expect((await req(`/wg-agent/${gateway.id}/tasks`, undefined, 'GET')).status).toBe(401);
expect((await req(`/wg-agent/${gateway.id}/tasks`, undefined, 'GET', 'admin-token')).status).toBe(401);
const tasks = await (await req(`/wg-agent/${gateway.id}/tasks`, undefined, 'GET', gateway.agentToken)).json() as any;
const job = tasks.tasks[0];
await req(`/wg-agent/${gateway.id}/report`, { bootId: 'boot', peers: [], receipts: [{ id: job.id, version: 999, runtime: true, persistent: true }] }, 'POST', gateway.agentToken);
expect(listDevices()[0].status).toBe('provisioning');
await req(`/wg-agent/${gateway.id}/report`, { bootId: 'boot', peers: [], receipts: [{ id: job.id, version: job.version, runtime: true, persistent: false }] }, 'POST', gateway.agentToken);
expect(listDevices()[0].status).toBe('error');
expect((await req(`/api/wg/devices/${device.id}/exports`)).status).toBe(404);
});
it('migrates old key/IP once, retains legacy URLs without peer writes, and applies device lifecycle to old downloads', async () => {
token('ipad', { ...legacyProfile(), acceptIncoming: false }); token('mac', legacyProfile('10.203.0.3', 3)); token();
const before = await (await app.request('/sub/ipad-secret/wireguard.conf')).text();
expect(before).toContain(key(2)); expect(before).toContain('10.203.0.2');
expect(listDevices()).toHaveLength(2); expect(listDevices().map(d => d.address)).toEqual(['10.203.0.2', '10.203.0.3']);
expect(db.select().from(subTokens).all().every(t => t.wireguard === null)).toBe(true);
const list = await (await app.request('/api/tokens')).text(); expect(list).not.toContain(key(2)); expect(list).not.toContain(key(12));
expect(await (await app.request('/sub/shared-secret/surge')).text()).not.toContain('[WireGuard');
const gateway = await setup(); const tasks = await confirm(gateway);
expect(tasks.every((t: any) => t.action === 'adopt')).toBe(true);
const device = listDevices().find(d => d.address === '10.203.0.2')!;
const derived = await bind(device.id);
expect(await (await app.request(derived.path)).text()).toContain('accept-incoming = true');
expect(await (await app.request('/sub/ipad-secret/surge')).text()).not.toContain('accept-incoming = true');
await req(`/api/wg/devices/${device.id}/actions`, { action: 'pause' }); await confirm(gateway);
expect((await app.request('/sub/ipad-secret/wireguard.conf')).status).toBe(404);
expect((await app.request('/sub/ipad-secret/surge')).status).toBe(404);
expect((await app.request('/sub/mac-secret/wireguard.conf')).status).toBe(200);
});
it('fails closed if the encryption key is lost instead of creating a replacement', async () => {
await setup(); await create();
const file = path.join(directory, 'wg-master.key');
renameSync(file, file + '.saved');
try {
const response = await req('/api/wg/devices', { name: 'New', os: 'iOS', requestId: crypto.randomUUID() });
expect(response.status).toBe(500);
expect(existsSync(file)).toBe(false);
expect(db.select().from(wgDevices).all()).toHaveLength(1);
expect(() => createDb(path.join(directory, 'proxy-station.db'))).toThrow();
expect(existsSync(file)).toBe(false);
} finally { renameSync(file + '.saved', file); }
});
it('upgrades a legacy SQLite file and persists encrypted identities, legacy mapping and idempotent adoption jobs', () => {
const file = path.join(directory, 'legacy-with-keys.db'); const sqlite = new Database(file);
sqlite.exec('CREATE TABLE sub_tokens(id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE, name TEXT NOT NULL, wireguard TEXT, created_at INTEGER NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, last_access_at INTEGER, access_count INTEGER NOT NULL DEFAULT 0)');
sqlite.prepare('INSERT INTO sub_tokens(id,token,name,wireguard,created_at) VALUES(?,?,?,?,?)').run('ipad', 'old-secret', 'iPad', JSON.stringify(legacyProfile()), 1);
sqlite.close();
for (let i = 0; i < 2; i++) {
const upgraded = createDb(file);
expect(upgraded.select().from(subTokens).get()).toMatchObject({ token: 'old-secret', wireguard: null, wgDeviceId: 'legacy-ipad' });
expect(upgraded.select().from(wgDevices).get()).toMatchObject({ address: '10.203.0.2', publicKey: publicKey(key(2)) });
expect(upgraded.select().from(wgJobs).all()).toHaveLength(1);
expect(decryptSecret<any>(upgraded.select().from(wgDevices).get()!.secret).presharedKey).toBe(key(12));
upgraded.$client.close();
}
expect(readFileSync(path.join(directory, 'wg-master.key')).length).toBe(32);
});
});

99
server/src/routes/wg.ts Normal file
View File

@@ -0,0 +1,99 @@
import crypto from 'node:crypto';
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { and, desc, eq } from 'drizzle-orm';
import { db } from '../db/index.js';
import { wgDevices, wgEvents, wgGateways, wgJobs, wgNetworks, wgSubscriptionBindings } from '../db/schema.js';
import { credential, hashCredential } from '../services/wg-crypto.js';
import { authorizedProfile, createDevice, deviceAction, deviceDto, gatewayTasks, getDevice, issueBinding,
listDevices, migrateLegacy, receiveReport, WgError } from '../services/wg.js';
import { generateWireguard } from '../services/generators/wireguard.js';
const name = z.string().trim().min(1).max(64).regex(/^[^\x00-\x1f\x7f]+$/);
const os = z.enum(['macOS', 'iPadOS', 'iOS', 'Android', 'Windows', 'Linux', '其他']);
const key = z.string().regex(/^[A-Za-z0-9+/]{43}=$/);
export const wgRoute = new Hono();
wgRoute.use('*', async (c, next) => { c.header('cache-control', 'private, no-store'); c.header('referrer-policy', 'no-referrer'); await next(); });
wgRoute.onError((e, c) => e instanceof WgError ? c.json({ error: e.message }, e.status) : c.json({ error: '设备操作失败,请检查服务器配置' }, 500));
wgRoute.get('/devices', c => c.json(listDevices()));
wgRoute.post('/devices', zValidator('json', z.object({ name, os, requestId: z.string().uuid(), gatewayId: z.string().optional() }).strict()),
c => c.json(createDevice(c.req.valid('json')), 202));
wgRoute.get('/devices/:id', c => {
migrateLegacy(); const device = getDevice(c.req.param('id'));
const events = db.select({ message: wgEvents.message, createdAt: wgEvents.createdAt }).from(wgEvents)
.where(eq(wgEvents.deviceId, device.id)).orderBy(desc(wgEvents.createdAt)).limit(30).all();
return c.json({ ...deviceDto(device), events });
});
wgRoute.patch('/devices/:id', zValidator('json', z.object({ name, os }).strict()), c => {
const device = getDevice(c.req.param('id'));
if (device.archivedAt) throw new WgError('设备已移除');
db.update(wgDevices).set(c.req.valid('json')).where(eq(wgDevices.id, device.id)).run();
return c.json(deviceDto(getDevice(device.id)));
});
wgRoute.post('/devices/:id/actions', zValidator('json', z.object({ action: z.enum(['pause', 'resume', 'rotate', 'remove', 'retry']) }).strict()),
c => c.json(deviceAction(c.req.param('id'), c.req.valid('json').action), 202));
wgRoute.get('/jobs/:id', c => {
const job = db.select().from(wgJobs).where(eq(wgJobs.id, c.req.param('id'))).get();
return job ? c.json(job) : c.json({ error: '任务不存在' }, 404);
});
wgRoute.post('/devices/:id/exports', c => c.json({ filename: 'wireguard.conf', content: generateWireguard(authorizedProfile(c.req.param('id'))) }));
wgRoute.get('/gateways', c => {
migrateLegacy();
return c.json(db.select().from(wgGateways).all().map(g => {
const network = db.select().from(wgNetworks).where(eq(wgNetworks.id, g.networkId)).get()!;
return { id: g.id, server: g.server, port: g.port, publicKey: g.publicKey, subnet: network.subnet, mtu: network.mtu,
configured: !!g.agentHash, receivedAt: g.receivedAt };
}));
});
// Explicit administrator provisioning. Never seeds sample identities or sends remote commands.
wgRoute.post('/gateways', zValidator('json', z.object({ server: z.string().min(1).max(253).regex(/^[\w.-]+$/),
port: z.number().int().min(1).max(65535), publicKey: key }).strict()), c => {
migrateLegacy(); const input = c.req.valid('json');
const existing = db.select().from(wgGateways).where(eq(wgGateways.publicKey, input.publicKey)).get();
if (existing && (existing.server !== input.server || existing.port !== input.port)) throw new WgError('网关地址与已纳管身份不一致');
if (!existing && db.select().from(wgGateways).all().length) throw new WgError('请先核对现有 njcq 网关公钥');
const agentToken = credential(); const id = existing?.id ?? 'njcq';
db.transaction(() => {
if (existing) db.update(wgGateways).set({ agentHash: hashCredential(agentToken) }).where(eq(wgGateways.id, id)).run();
else {
db.insert(wgNetworks).values({ id, subnet: '10.203.0.0/24', mtu: 1280 }).run();
db.insert(wgGateways).values({ id, networkId: id, ...input, agentHash: hashCredential(agentToken) }).run();
}
});
return c.json({ id, agentToken }, 201);
});
export const wgBindingRoute = new Hono();
wgBindingRoute.onError((e, c) => e instanceof WgError ? c.json({ error: e.message }, e.status) : c.json({ error: '设备绑定失败' }, 500));
wgBindingRoute.use('*', async (c, next) => { c.header('cache-control', 'private, no-store'); c.header('referrer-policy', 'no-referrer'); await next(); });
wgBindingRoute.post('/:id/wg-bindings', zValidator('json', z.object({ deviceId: z.string().min(1), purpose: z.enum(['surge', 'clash']).default('surge') }).strict()),
c => c.json(issueBinding(c.req.param('id'), c.req.valid('json').deviceId, c.req.valid('json').purpose), 201));
wgBindingRoute.delete('/:id/wg-bindings/:bindingId', c => {
const result = db.update(wgSubscriptionBindings).set({ revokedAt: Date.now() })
.where(and(eq(wgSubscriptionBindings.id, c.req.param('bindingId')), eq(wgSubscriptionBindings.tokenId, c.req.param('id')))).run();
return result.changes ? c.json({ ok: true }) : c.json({ error: '设备链接不存在' }, 404);
});
const count = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
const reportSchema = z.object({ bootId: z.string().min(1).max(128),
receipts: z.array(z.object({ id: z.string(), version: count, runtime: z.boolean(), persistent: z.boolean(), error: z.string().max(256).optional() }).strict()).max(512),
peers: z.array(z.object({ publicKey: key, handshakeAge: count.nullable(), rx: count, tx: count }).strict()).max(1024),
}).strict().superRefine((v, ctx) => {
if (new Set(v.peers.map(p => p.publicKey)).size !== v.peers.length || new Set(v.receipts.map(p => p.id)).size !== v.receipts.length)
ctx.addIssue({ code: 'custom', message: 'Duplicate agent sample' });
});
// This route is outside /api and authenticates ONLY the dedicated gateway token.
export const wgAgentRoute = new Hono();
wgAgentRoute.use('/:id/*', async (c, next) => {
c.header('cache-control', 'private, no-store');
const token = c.req.header('authorization')?.replace(/^Bearer /, '') ?? '';
const gateway = db.select().from(wgGateways).where(eq(wgGateways.id, c.req.param('id')!)).get();
if (!gateway?.agentHash || !crypto.timingSafeEqual(Buffer.from(hashCredential(token)), Buffer.from(gateway.agentHash))) return c.json({ error: 'unauthorized' }, 401);
await next();
});
wgAgentRoute.get('/:id/tasks', c => c.json({ tasks: gatewayTasks(c.req.param('id')) }));
wgAgentRoute.post('/:id/report', zValidator('json', reportSchema), c => {
receiveReport(c.req.param('id'), c.req.valid('json')); return c.json({ ok: true });
});

View File

@@ -0,0 +1,137 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { wireguardProfileSchema } from '@proxy-station/shared';
const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-wireguard-'));
process.env.DATA_DIR = directory;
const { db, createDb } = await import('../db/index.js');
const { nodes, subTokens, wgDevices, wgJobs, wgObservations, wgEvents, wgSubscriptionBindings, wgGateways, wgNetworks } = await import('../db/schema.js');
const { tokensRoute } = await import('./tokens.js');
const { subRoute } = await import('./sub.js');
const { generateSurge } = await import('../services/generators/surge.js');
const { generateShadowrocketConf } = await import('../services/generators/shadowrocket-conf.js');
const key = (value: number) => Buffer.alloc(32, value).toString('base64');
const profile = (address = '10.203.0.2', value = 2) => ({
server: 'vpn.example.com', port: 51820, serverPublicKey: key(1), privateKey: key(value),
address, subnet: '10.203.0.0/24', acceptIncoming: false,
});
const request = (method: string, body: unknown) => ({ method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
beforeEach(() => {
for (const table of [wgSubscriptionBindings, subTokens, wgEvents, wgObservations, wgJobs, wgDevices, wgGateways, wgNetworks, nodes]) db.delete(table).run();
});
afterAll(() => { db.$client.close(); rmSync(directory, { recursive: true, force: true }); });
describe('per-device WireGuard subscriptions', () => {
it('isolates keys between device tokens and does not enable WireGuard on shared subscriptions', async () => {
for (const [token, wireguard] of [['ipad-token', profile()], ['mac-token', profile('10.203.0.3', 3)], ['shared-token', null]] as const) {
db.insert(subTokens).values({ id: token, token, name: token, createdAt: 0,
wireguard: wireguard ? JSON.stringify(wireguard) : null }).run();
}
const ipad = await subRoute.request('/ipad-token/wireguard.conf');
expect(ipad.headers.get('cache-control')).toBe('private, no-store');
const config = await ipad.text();
expect(config).toContain(`PrivateKey = ${key(2)}`);
expect(config).not.toContain(key(3));
expect(config).toContain('AllowedIPs = 10.203.0.0/24');
expect(config).not.toContain('0.0.0.0/0');
for (const token of ['shared-token', 'invalid-token']) {
expect((await subRoute.request(`/${token}/wireguard.conf`)).status).toBe(404);
}
expect(await (await subRoute.request('/shared-token/surge')).text()).not.toContain('[WireGuard');
for (const token of ['ipad-token', 'shared-token', 'invalid-token']) {
expect((await subRoute.request(`/${token}/wireguard-shadowrocket`)).status).toBe(404);
}
db.update(subTokens).set({ enabled: false }).run();
expect((await subRoute.request('/ipad-token/wireguard.conf')).status).toBe(404);
});
it('keeps Shadowrocket proxy downloads independent of legacy and paused WG identities', async () => {
for (const [token, wireguard] of [['device', { ...profile(), presharedKey: key(9) }], ['shared', null]] as const) {
db.insert(subTokens).values({ id: token, token, name: token, createdAt: 0,
wireguard: wireguard ? JSON.stringify(wireguard) : null }).run();
}
db.insert(nodes).values({ id: 'proxy', name: 'Example Proxy Direct', protocol: 'trojan', server: 'proxy.example.test',
port: 443, password: 'placeholder-password', createdAt: 0, updatedAt: 0 }).run();
const plainNodes = await (await subRoute.request('/shared/shadowrocket')).text();
expect(Buffer.from(plainNodes, 'base64').toString()).toContain('trojan://');
const plainConf = await (await subRoute.request('/shared/shadowrocket-conf')).text();
// A paused device must not gate the ordinary proxy subscription or trigger private-key retrieval.
db.update(wgDevices).set({ desired: 'paused' }).run();
for (const [endpoint, expected] of [['shadowrocket', plainNodes], ['shadowrocket-conf', plainConf]]) {
for (const query of ['', '?wg=not-a-device-credential']) {
const response = await subRoute.request(`/device/${endpoint}${query}`);
expect(response.status).toBe(200);
const content = await response.text();
expect(content).toBe(expected);
expect(content).not.toContain('WireGuard');
expect(content).not.toContain('10.203.0.');
expect(content).not.toContain(key(2));
expect(content).not.toContain(key(9));
}
}
const tokens = await (await tokensRoute.request('/')).json() as any[];
expect(tokens.every(t => t.urls.shadowrocketWireguard === undefined)).toBe(true);
expect(tokens.find(t => t.id === 'device').urls.wireguard).toBe('/sub/device/wireguard.conf');
});
it('prevents token edits from switching shared identity and preserves a legacy device on rename', async () => {
db.insert(subTokens).values({ id: 'ipad', token: 'ipad-secret', name: 'iPad', createdAt: 0, wireguard: JSON.stringify(profile()) }).run();
const created = await tokensRoute.request('/', request('POST', { name: 'shared' }));
expect(created.status).toBe(201);
const shared = await created.json() as any;
expect((await tokensRoute.request('/', request('POST', { name: 'duplicate', wireguard: profile() }))).status).toBe(400);
expect((await tokensRoute.request(`/${shared.id}`, request('PUT', { name: 'shared', wireguard: profile() }))).status).toBe(400);
const updated = await tokensRoute.request('/ipad', request('PUT', { name: 'iPad renamed', nodeIds: [] }));
const metadata = await updated.json() as any;
expect(metadata.wgDeviceId).toBeTruthy();
expect(metadata.wireguard).toBeUndefined();
expect(await (await subRoute.request('/ipad-secret/wireguard.conf')).text()).toContain(key(2));
});
it('rejects default/public routes, invalid keys, injection and addresses outside the subnet', () => {
for (const patch of [{ subnet: '0.0.0.0/0' }, { subnet: '8.8.8.0/24' }, { address: '10.204.0.2' },
{ address: '10.203.0.0' }, { privateKey: 'invalid' }, { server: 'vpn.example.com\n[Rule]' }]) {
expect(wireguardProfileSchema.safeParse({ ...profile(), ...patch }).success).toBe(false);
}
});
it('routes only the LAN before broad DIRECT rules, protects Internet groups and marks incoming requirements', () => {
const ir = { nodes: [], sections: { General: '' },
groups: [{ name: 'All', type: 'select' as const, members: [], includeAllNodes: true,
filterRegex: null, testUrl: null, interval: null, tolerance: null }],
rules: [{ type: 'IP-CIDR', value: '10.0.0.0/8', policy: 'DIRECT', params: [], ruleset: null },
{ type: 'FINAL', value: null, policy: 'DIRECT', params: [], ruleset: null }],
wireguard: wireguardProfileSchema.parse({ ...profile(), acceptIncoming: true }),
};
const surge = generateSurge(ir, { selfUrl: 'https://example.com/sub/placeholder/surge' }).content;
expect(surge.indexOf('IP-CIDR,10.203.0.0/24,NJCQ WireGuard LAN,no-resolve')).toBeLessThan(surge.indexOf('IP-CIDR,10.0.0.0/8'));
expect(surge).toContain('accept-incoming = true');
expect(surge).toContain('Surge Mac 6.11.0+');
expect(surge.split('[Proxy Group]')[1].split('[Rule]')[0]).not.toContain('NJCQ WireGuard LAN');
const dynamic = generateSurge({ ...ir, groups: [{ ...ir.groups[0], filterRegex: 'US|LAN' }] }, { selfUrl: 'https://example.test/sub/placeholder/surge' }).content;
const filter = dynamic.match(/policy-regex-filter=([^\n]+)/)![1];
const decodedFilter = filter.startsWith('"') ? JSON.parse(filter) : filter;
expect(new RegExp(decodedFilter).test('NJCQ WireGuard LAN')).toBe(false);
expect(new RegExp(decodedFilter).test('US Test Direct')).toBe(true);
expect(new RegExp(decodedFilter).test('prefix US Test Direct')).toBe(true);
expect(new RegExp(decodedFilter).test('prefix LAN')).toBe(true);
expect(new RegExp(decodedFilter).test('JP Direct')).toBe(false);
expect(generateShadowrocketConf(ir)).toEqual(generateShadowrocketConf({ ...ir, wireguard: undefined }));
});
it('upgrades an existing database without changing shared token identity', () => {
const file = path.join(directory, 'legacy.db');
const old = new Database(file);
old.exec("CREATE TABLE sub_tokens (id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE, name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, last_access_at INTEGER, access_count INTEGER NOT NULL DEFAULT 0); INSERT INTO sub_tokens(id,token,name,created_at) VALUES ('legacy','old-token','existing',0)");
old.close();
const migrated = createDb(file);
const token = migrated.select().from(subTokens).get()!;
expect(token.token).toBe('old-token');
expect(token.wireguard).toBeNull();
migrated.$client.close();
});
});

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import YAML from 'yaml';
import { generateClash } from './clash.js';
import type { ProfileIR, RuleIR } from '../ir.js';
const ai = { name: '🤖 AI', type: 'select' as const, testUrl: null, interval: null, tolerance: null,
filterRegex: null, includeAllNodes: false, members: [{ kind: 'builtin' as const, name: 'DIRECT' }] };
const rule = (type: string, value: string, policy: string): RuleIR => ({ type, value, policy, params: [], ruleset: null });
const set = (id: string, policy: string, behavior = 'classical'): RuleIR => ({
...rule('RULE-SET', id, policy), ruleset: { id, name: id, surgeUrl: `https://example.com/${id}.list`,
clashUrl: `https://example.com/${id}.yaml`, clashBehavior: behavior, clashFormat: 'yaml', needsConvert: false },
});
const base: ProfileIR = { nodes: [], groups: [ai], sections: {}, rules: [] };
const doc = (rules: RuleIR[], ir = base) => YAML.parse(generateClash({ ...ir, rules }, { convertBaseUrl: 'https://example.com/sub/token' }).content);
const defaults = ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query'];
const aiDns = [`https://1.1.1.1/dns-query#${encodeURIComponent(ai.name)}`];
describe('Clash AI DNS derived from ordered IR domain rules', () => {
it('keeps earlier domain/resource exceptions before broader AI rules and ignores IP/process conditions', () => {
const rules = [rule('DOMAIN', 'auth.ai.example', 'DIRECT'), set('Unbreak', 'DIRECT'),
rule('DOMAIN-SUFFIX', 'ai.example', ai.name), rule('DOMAIN-KEYWORD', 'ai-keyword', ai.name),
set('AI', ai.name), rule('IP-CIDR', '192.0.2.0/24', ai.name), rule('PROCESS-NAME', 'example', ai.name),
rule('DOMAIN', 'later.example', 'DIRECT')];
const before = structuredClone(rules);
const output = doc(rules);
expect(rules).toEqual(before);
expect(Object.keys(output.dns['nameserver-policy'])).toEqual(['rule-set:proxy-station-dns-1', 'rule-set:Unbreak',
'rule-set:proxy-station-dns-2', 'rule-set:AI']);
expect(Object.values(output.dns['nameserver-policy'])).toEqual([defaults, defaults, aiDns, aiDns]);
expect(output['rule-providers']['proxy-station-dns-1'].payload).toEqual(['DOMAIN,auth.ai.example']);
expect(output['rule-providers']['proxy-station-dns-2'].payload).toEqual([
'DOMAIN-SUFFIX,ai.example', 'DOMAIN-KEYWORD,ai-keyword']);
expect(output.dns.nameserver).toEqual(defaults);
expect(output.dns['proxy-server-nameserver']).toEqual(defaults);
expect(output.dns).not.toHaveProperty('respect-rules');
expect(output.rules).toEqual(['DOMAIN,auth.ai.example,DIRECT', 'RULE-SET,Unbreak,DIRECT',
'DOMAIN-SUFFIX,ai.example,🤖 AI', 'DOMAIN-KEYWORD,ai-keyword,🤖 AI', 'RULE-SET,AI,🤖 AI',
'IP-CIDR,192.0.2.0/24,🤖 AI', 'PROCESS-NAME,example,🤖 AI', 'DOMAIN,later.example,DIRECT']);
});
it('preserves interleaved resolvers, first resource occurrence and avoids provider name collisions', () => {
const output = doc([set('proxy-station-dns-1', 'DIRECT'), rule('DOMAIN-SUFFIX', 'overlap.example', ai.name),
rule('DOMAIN', 'specific.overlap.example', 'DIRECT'), set('proxy-station-dns-1', ai.name),
set('ip-only', ai.name, 'ipcidr'), rule('DOMAIN', 'last.example', ai.name)]);
const policy = output.dns['nameserver-policy'];
expect(policy['rule-set:proxy-station-dns-1']).toEqual(defaults);
expect(Object.keys(policy)).toEqual(['rule-set:proxy-station-dns-1', 'rule-set:proxy-station-dns-2',
'rule-set:proxy-station-dns-3', 'rule-set:proxy-station-dns-4']);
expect(policy).not.toHaveProperty('rule-set:ip-only');
expect(output['rule-providers']['proxy-station-dns-1'].type).toBe('http');
});
it('leaves DNS unchanged without a live AI group or an AI domain rule', () => {
expect(doc([rule('DOMAIN', 'example.com', 'DIRECT')]).dns).not.toHaveProperty('nameserver-policy');
expect(doc([rule('IP-CIDR', '192.0.2.0/24', ai.name)]).dns).not.toHaveProperty('proxy-server-nameserver');
expect(doc([rule('DOMAIN', 'example.com', ai.name)], { ...base, groups: [] }).dns)
.not.toHaveProperty('nameserver-policy');
});
});

View File

@@ -0,0 +1,60 @@
import type { ProfileIR } from '../ir.js';
const DOMAIN_TYPES = new Set(['DOMAIN', 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD']);
/**
* mihomo's ordered nameserver-policy + classical domain matchers preserve rule
* order, including an earlier exception before a broader AI domain. Plain trie
* keys alone would instead prefer the more specific domain, regardless of order.
* Sources: wiki.metacubex.one/config/dns/ and mihomo config/config.go, dns/resolver.go.
*/
export function clashAiDns(
ir: ProfileIR,
providers: Record<string, Record<string, unknown>>,
providerNames: Map<string, string>,
defaultNameservers: string[],
): Record<string, unknown> {
const ai = ir.groups.find((g) => /^[^\p{L}\p{N}]*AI$/u.test(g.name));
if (!ai) return {};
const lastAI = ir.rules.map((r, index) => r.policy === ai.name
&& (DOMAIN_TYPES.has(r.type) || (r.ruleset && r.ruleset.clashBehavior !== 'ipcidr')) ? index : -1)
.filter((index) => index >= 0).at(-1) ?? -1;
if (lastAI < 0) return {};
const aiNameservers = [`https://1.1.1.1/dns-query#${encodeURIComponent(ai.name)}`];
const policy: Record<string, string[]> = {};
let payload: string[] = [];
let aiBlock = false;
let index = 0;
const flush = () => {
if (!payload.length) return;
let name: string;
do { name = `proxy-station-dns-${++index}`; } while (name in providers);
providers[name] = { type: 'inline', behavior: 'classical', payload };
policy[`rule-set:${name}`] = aiBlock ? aiNameservers : defaultNameservers;
payload = [];
};
for (const rule of ir.rules.slice(0, lastAI + 1)) {
if (!rule.value) continue;
const isAI = rule.policy === ai.name;
if (rule.ruleset) {
if (rule.ruleset.clashBehavior === 'ipcidr') continue;
const name = providerNames.get(rule.ruleset.id);
if (!name) continue;
flush();
// A reused resource has the same domain membership; its first occurrence wins.
const key = `rule-set:${name}`;
if (!(key in policy)) policy[key] = isAI ? aiNameservers : defaultNameservers;
} else if (DOMAIN_TYPES.has(rule.type)) {
if (payload.length && isAI !== aiBlock) flush();
aiBlock = isAI;
payload.push(`${rule.type},${rule.value}`);
}
}
flush();
return {
'nameserver-policy': policy,
// Resolve node hostnames independently to prevent proxy/DNS recursion.
'proxy-server-nameserver': [...defaultNameservers],
};
}

View File

@@ -1,6 +1,8 @@
import YAML from 'yaml';
import { clientSupports, normalizeSsCipher, splitRuleExpression, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
import type { ProfileIR } from '../ir.js';
import { clashAiDns } from './clash-ai-dns.js';
import { WG_POLICY } from './wireguard.js';
export function clashProxy(n: NodeDto): Record<string, unknown> {
const base: Record<string, unknown> = {
@@ -119,6 +121,9 @@ export interface ClashOutput {
}
export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): ClashOutput {
if (ir.wireguard && [...ir.nodes, ...ir.groups].some(n => n.name === WG_POLICY)) {
throw new Error('WireGuard 组网策略名称与现有节点或组冲突');
}
const excluded: ExcludedNode[] = [];
const usable = ir.nodes.filter((n) => {
const verdict = clientSupports('clash', n);
@@ -128,6 +133,13 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
const usableNames = new Set(usable.map((n) => n.name));
const proxies = usable.map(clashProxy);
if (ir.wireguard) {
const wg = ir.wireguard;
proxies.push({ name: WG_POLICY, type: 'wireguard', ip: wg.address, 'private-key': wg.privateKey,
mtu: wg.mtu, udp: true, 'persistent-keepalive': wg.keepalive,
peers: [{ server: wg.server, port: wg.port, 'public-key': wg.serverPublicKey,
...(wg.presharedKey ? { 'pre-shared-key': wg.presharedKey } : {}), 'allowed-ips': [wg.subnet] }] });
}
const proxyGroups = ir.groups.map((g) => {
const group: Record<string, unknown> = { name: g.name, type: g.type };
@@ -136,10 +148,12 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
.map((m) => m.name);
if (g.filterRegex) {
group['include-all-proxies'] = true;
if (ir.wireguard) group['exclude-filter'] = `^${WG_POLICY}$`;
group.filter = g.filterRegex;
if (members.length) group.proxies = [...new Set(members)];
} else if (g.includeAllNodes) {
group['include-all-proxies'] = true;
if (ir.wireguard) group['exclude-filter'] = `^${WG_POLICY}$`;
if (members.length) group.proxies = [...new Set(members)];
} else {
group.proxies = members.length ? members : ['DIRECT'];
@@ -154,7 +168,7 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
// rule-providers:从启用规则引用的规则集生成,名称唯一化
const providerNameByRulesetId = new Map<string, string>();
const providers: Record<string, unknown> = {};
const providers: Record<string, Record<string, unknown>> = {};
const takenNames = new Set<string>();
const usedRulesets = ir.rules.flatMap((r) => (r.ruleset ? [r.ruleset] : []));
for (const rs of usedRulesets) {
@@ -175,6 +189,7 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
const skippedRules: string[] = [];
const ruleLines: string[] = [];
if (ir.wireguard) ruleLines.push(`IP-CIDR,${ir.wireguard.subnet},${WG_POLICY},no-resolve`);
for (const r of ir.rules) {
if (r.type === 'FINAL') {
ruleLines.push(`MATCH,${mapPolicy(r.policy)}`);
@@ -217,6 +232,8 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
ruleLines.push([r.type, r.value, policy, ...params].join(','));
}
const defaultNameservers = ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query'];
const aiDns = clashAiDns(ir, providers, providerNameByRulesetId, defaultNameservers);
const doc = {
'mixed-port': 7890,
'allow-lan': false,
@@ -228,7 +245,8 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
enable: true,
listen: '0.0.0.0:1053',
'enhanced-mode': 'fake-ip',
nameserver: ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query'],
nameserver: defaultNameservers,
...aiDns,
},
proxies,
'proxy-groups': proxyGroups,

View File

@@ -10,6 +10,7 @@ import { generateClash, clashProxy } from './clash.js';
import { generateShadowrocket } from './shadowrocket.js';
import { generateShadowrocketConf } from './shadowrocket-conf.js';
import { parseShareLink } from '../sharelink/parse.js';
import { wireguardProfileSchema } from '@proxy-station/shared';
const node: NodeDto = {
id: 'placeholder', name: '🇯🇵 JP VMess Direct', protocol: 'vmess',
@@ -84,6 +85,20 @@ describe('mobile client node subscriptions', () => {
});
describe('mobile client groups and rule semantics', () => {
it('keeps the WG identity out of dynamic public pools while preserving explicit members', () => {
const key = (n: number) => Buffer.alloc(32, n).toString('base64');
const wireguard = wireguardProfileSchema.parse({ server: 'wg.example.test', port: 51820, serverPublicKey: key(1),
privateKey: key(2), presharedKey: key(3), address: '10.203.0.4', subnet: '10.203.0.0/24' });
const result = YAML.parse(generate({ ...base, wireguard, groups: [group, { ...group, name: 'All', filterRegex: null }] }).content);
for (const g of result['proxy-groups']) {
expect(g['exclude-filter']).toBe('^NJCQ WireGuard LAN$');
expect(g.proxies).toEqual(['DIRECT', node.name, 'Source']);
}
expect(result.rules[0]).toBe('IP-CIDR,10.203.0.0/24,NJCQ WireGuard LAN,no-resolve');
expect(result.rules.at(-1)).toBe('MATCH,DIRECT');
expect(() => generate({ ...base, wireguard, groups: [{ ...group, name: 'NJCQ WireGuard LAN' }] })).toThrow();
});
it('preserves explicit members in dynamic groups, zero tolerance, and omits fallback tolerance', () => {
const source = { ...group, name: 'Source', filterRegex: null, includeAllNodes: false,
members: [{ kind: 'node' as const, name: node.name }] };
@@ -135,7 +150,10 @@ describe('mobile client groups and rule semantics', () => {
const nodes: NodeDto[] = [node, { ...node, name: 'Trojan', protocol: 'trojan' },
{ ...node, name: 'SS', protocol: 'shadowsocks' },
{ ...node, name: 'HY2', protocol: 'hysteria2', network: 'tcp', obfsType: 'salamander', obfsPassword: 'placeholder-obfs' }];
const ir: ProfileIR = { ...base, nodes, groups: [
const key = (n: number) => Buffer.alloc(32, n).toString('base64');
const wireguard = wireguardProfileSchema.parse({ server: '127.0.0.1', port: 51820, serverPublicKey: key(1),
privateKey: key(2), presharedKey: key(3), address: '10.203.0.4', subnet: '10.203.0.0/24' });
const ir: ProfileIR = { ...base, nodes, wireguard, groups: [
{ ...group, name: 'Source', filterRegex: null, includeAllNodes: false, members: [{ kind: 'node', name: node.name }] }, group,
], rules: [{ type: 'AND', value: '((OR,((PROTOCOL,TCP),(PROTOCOL,UDP))),(DOMAIN,example.com))',
policy: 'REJECT', params: [], ruleset: null }, final] };

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { generateShadowrocketConf } from './shadowrocket-conf.js';
import type { ProfileIR } from '../ir.js';
import { wireguardProfileSchema } from '@proxy-station/shared';
const ir: ProfileIR = {
nodes: [],
@@ -37,6 +38,23 @@ const ir: ProfileIR = {
};
describe('generateShadowrocketConf', () => {
it('ignores optional WireGuard identity without adding private routes, sections or group filters', () => {
const profile: ProfileIR = { ...ir, groups: [...ir.groups, {
...ir.groups[0], name: 'All Nodes', includeAllNodes: true, members: [],
}, { ...ir.groups[0], name: 'US', filterRegex: 'US|States', members: [] }] };
const key = (n: number) => Buffer.alloc(32, n).toString('base64');
const withWg = { ...profile, wireguard: wireguardProfileSchema.parse({ server: 'wg.example.test', port: 51820,
privateKey: key(2), serverPublicKey: key(1), presharedKey: key(3), address: '10.203.0.2',
subnet: '10.203.0.0/24', acceptIncoming: true }) };
const output = generateShadowrocketConf(withWg);
expect(output).toEqual(generateShadowrocketConf(profile));
expect(output.content).toContain('policy-regex-filter=.*');
expect(output.content).toContain('policy-regex-filter=US|States');
for (const secret of ['WireGuard', '10.203.0.', 'accept-incoming', key(1), key(2), key(3)]) {
expect(output.content).not.toContain(secret);
}
});
// ShadowRocket 不剥引号:带引号的策略名会匹配不到策略组,整条规则失效(真机与社区配置证实)
it('含空格/emoji 的策略名一律裸写,不加引号', () => {
const { content } = generateShadowrocketConf(ir);
@@ -45,4 +63,36 @@ describe('generateShadowrocketConf', () => {
expect(content).toContain('FINAL,🎯 NoAuto');
expect(content).toContain('🎯 NoAuto = select, 🇨🇳 Mainland, ⚡️ Automatic');
});
it('converts a domain set at the original priority without guessing native DOMAIN-SET support', () => {
const domainSet = { type: 'DOMAIN-SET', value: 'domain-set', policy: 'REJECT', params: [],
ruleset: { id: 'domain-set', name: 'domains', surgeUrl: 'https://example.com/domains.txt', clashUrl: null,
clashBehavior: 'domain', clashFormat: 'text', needsConvert: true } };
const profile = { ...ir, rules: [ir.rules[0], domainSet, ir.rules[1]] };
const result = generateShadowrocketConf(profile, { convertBaseUrl: 'https://example.com/sub/placeholder' });
expect(result.content).toContain('DOMAIN-SUFFIX,xiaohongshu.com,🇨🇳 Mainland\n'
+ 'RULE-SET,https://example.com/sub/placeholder/ruleset/domain-set.list,REJECT\nFINAL,🎯 NoAuto');
expect(result.skippedRules).toEqual([]);
expect(generateShadowrocketConf(profile).skippedRules).toHaveLength(1);
});
it('expands the documented LAN set with original DNS resolution flags and skips only macOS app paths', () => {
const result = generateShadowrocketConf({ ...ir, rules: [
{ type: 'RULE-SET', value: 'LAN', policy: 'DIRECT', params: ['no-resolve'], ruleset: null },
{ type: 'PROCESS-NAME', value: '/Applications/Example.app/', policy: 'DIRECT', params: [], ruleset: null },
{ type: 'PROCESS-NAME', value: 'example', policy: 'DIRECT', params: [], ruleset: null }, ir.rules[1],
] });
expect(result.content).not.toContain('RULE-SET,LAN');
expect(result.content).toContain('DOMAIN-SUFFIX,local,DIRECT,no-resolve');
expect(result.content).toContain('IP-CIDR,100.64.0.0/10,DIRECT,no-resolve');
expect(result.content).toContain('IP-CIDR6,fc00::/7,DIRECT,no-resolve');
expect(result.content).toContain('PROCESS-NAME,example,DIRECT');
expect(result.content).not.toContain('/Applications/');
expect(result.skippedRules).toEqual(['PROCESS-NAME,/Applications/Example.app/,DIRECT']);
const plain = generateShadowrocketConf({ ...ir, rules: [
{ type: 'RULE-SET', value: 'LAN', policy: 'DIRECT', params: [], ruleset: null }, ir.rules[1],
] }).content;
expect(plain).toContain('IP-CIDR,10.0.0.0/8,DIRECT\n');
expect(plain).not.toContain('no-resolve');
});
});

View File

@@ -1,4 +1,5 @@
import type { ProfileIR } from '../ir.js';
import { SHADOWROCKET_LAN_RULES } from './shadowrocket-lan.js';
/**
* ShadowRocket 的 conf 语法没有引号机制:策略名一律裸写,含空格/emoji 均可;
@@ -58,7 +59,7 @@ export interface ShadowrocketConfOutput {
* 策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛选,
* 因此订阅在 App 里叫什么名字都可以。
*/
export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput {
export function generateShadowrocketConf(ir: ProfileIR, opts: { convertBaseUrl?: string } = {}): ShadowrocketConfOutput {
const lines: string[] = [];
lines.push('# 由 Proxy Station 生成的 ShadowRocket 配置(策略组 + 规则)');
@@ -78,8 +79,8 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput
if (g.type === 'url-test' && g.tolerance !== null) parts.push(`tolerance=${g.tolerance}`);
}
// policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则
if (g.filterRegex) parts.push(`policy-regex-filter=${g.filterRegex}`);
else if (g.includeAllNodes) parts.push('policy-regex-filter=.*');
const filter = g.filterRegex || (g.includeAllNodes ? '.*' : null);
if (filter) parts.push(`policy-regex-filter=${filter}`);
lines.push(`${policyName(g.name)} = ${parts.join(', ')}`);
}
@@ -90,6 +91,20 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput
lines.push(['FINAL', policyName(r.policy)].join(','));
continue;
}
if (r.type === 'DOMAIN-SET' && r.ruleset?.clashBehavior === 'domain' && opts.convertBaseUrl) {
const url = `${opts.convertBaseUrl}/ruleset/${encodeURIComponent(r.ruleset.id)}.list`;
lines.push(['RULE-SET', url, policyName(r.policy), ...r.params].join(','));
continue;
}
if (r.type === 'RULE-SET' && !r.ruleset && r.value === 'LAN') {
lines.push(...SHADOWROCKET_LAN_RULES.map((rule) => [rule, policyName(r.policy), ...r.params].join(',')));
continue;
}
// An absolute macOS app-bundle path cannot identify an iOS application.
if (r.type === 'PROCESS-NAME' && /^\/Applications\/.*\.app(?:\/|$)/.test(r.value ?? '')) {
skippedRules.push(`${r.type},${r.value},${r.policy}`);
continue;
}
if (SR_UNSUPPORTED_RULE_TYPES.has(r.type)) {
skippedRules.push(`${r.type},${r.ruleset ? r.ruleset.name : r.value},${r.policy}`);
continue;

View File

@@ -0,0 +1,21 @@
/**
* Surge's documented LAN set expanded to ordinary Shadowrocket domain/CIDR rules.
* No guessed GEOIP,LAN or built-in RULE-SET,LAN syntax on the receiving client.
* Source: https://manual.nssurge.com/rules/ruleset.html#lan (reviewed 2026-10-13).
*/
export const SHADOWROCKET_LAN_RULES = [
'DOMAIN-SUFFIX,local',
'IP-CIDR,0.0.0.0/8',
'IP-CIDR,10.0.0.0/8',
'IP-CIDR,100.64.0.0/10',
'IP-CIDR,127.0.0.0/8',
'IP-CIDR,169.254.0.0/16',
'IP-CIDR,172.16.0.0/12',
'IP-CIDR,192.0.0.0/24',
'IP-CIDR,192.0.2.0/24',
'IP-CIDR,192.168.0.0/16',
'IP-CIDR,224.0.0.0/4',
'IP-CIDR6,::1/128',
'IP-CIDR6,fc00::/7',
'IP-CIDR6,fe80::/10',
];

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import type { GroupIR, ProfileIR } from '../ir.js';
import { surgeFfsb, ffsbSettingsSchema } from './surge-ffsb.js';
import { generateSurge } from './surge.js';
import { generateClash } from './clash.js';
import { generateShadowrocketConf } from './shadowrocket-conf.js';
import domains from '../../../assets/ffsb-domains.json';
const group = (name: string): GroupIR => ({ name, type: 'select', members: [{ kind: 'builtin', name: 'DIRECT' }], includeAllNodes: false, filterRegex: null, testUrl: null, interval: null, tolerance: null });
const config = { enabled: true, interface: 'utun100', dnsServer: '100.70.255.254', extraPolicy: '🧠 AI Nodes' };
const fixture = (): ProfileIR => ({
nodes: [], groups: [group('🦀 Claude'), group('🧠 AI Nodes')],
rules: [
{ type: 'DOMAIN-SUFFIX', value: 'claude.ai', policy: '🦀 Claude', params: [], ruleset: null },
{ type: 'DOMAIN', value: 'anthropic.auth0.com', policy: '🦀 Claude', params: [], ruleset: null },
{ type: 'IP-CIDR', value: '1.0.0.1/32', policy: '🦀 Claude', params: ['no-resolve'], ruleset: null },
{ type: 'FINAL', value: null, policy: 'DIRECT', params: [], ruleset: null },
],
sections: { FFSB: JSON.stringify(config), General: 'always-real-ip = example.ts.net, api.anthropic.com', Host: 'example.ts.net = server:100.100.100.100' },
});
describe('manual FFSB Surge adapter', () => {
it('limits selectable FFSB scope to the audited 25 entries and preserves the source IR', () => {
const ir = fixture(); const before = JSON.stringify(ir);
const adapted = surgeFfsb(ir).ir;
expect(domains).toHaveLength(25);
expect(new Set(domains).size).toBe(25);
expect(adapted.rules.filter(r => r.policy === '🦀 Claude').map(r => r.value)).toEqual(domains);
expect(adapted.rules.find(r => r.value === 'downloads.claude.ai' && r.policy === '🦀 Claude')).toBeUndefined();
expect(adapted.rules.find(r => r.type === 'DOMAIN-SUFFIX' && r.value === 'claude.ai')?.policy).toBe('Claude-Surge-Internal');
expect(adapted.rules.find(r => r.value === '1.0.0.1/32')?.policy).toBe('Claude-Surge-Internal');
expect(JSON.stringify(ir)).toBe(before);
});
it('emits one visible manual Claude group and keeps DNS/external process routing scoped', () => {
const text = generateSurge(fixture(), { selfUrl: 'https://example.com/sub/test/surge' }).content;
expect(text).toContain('🦀 Claude = select, DIRECT, "🦁 FFSB"');
expect(text).toContain('Claude-Surge-Internal = select, "🧠 AI Nodes", hidden=true');
expect(text).not.toContain('Claude = fallback');
expect(text).toContain('api.anthropic.com = server:100.70.255.254');
expect(text).toContain('always-real-ip = example.ts.net');
expect(text).toContain('example.ts.net = server:100.100.100.100');
expect(text.indexOf('PROCESS-NAME,/Applications/FFSB.app/Contents/MacOS/ffsb-service,DIRECT')).toBeLessThan(text.indexOf('DOMAIN,claude.ai,'));
expect(text.match(/^FINAL,/gm)).toHaveLength(1);
});
it('does not alter other client serializers or profiles without the opt-in', () => {
const ir = fixture(); const off = { ...ir, sections: { ...ir.sections, FFSB: '' } };
expect(generateClash(ir, { convertBaseUrl: 'https://example.com' })).toEqual(generateClash(off, { convertBaseUrl: 'https://example.com' }));
expect(generateShadowrocketConf(ir)).toEqual(generateShadowrocketConf(off));
expect(generateSurge(off, { selfUrl: 'https://example.com/surge' }).content).not.toContain('🦁 FFSB');
});
it('rejects invalid interface/DNS, name collisions, and extra-domain loops', () => {
expect(ffsbSettingsSchema.safeParse({ ...config, interface: 'utun100,foo=true' }).success).toBe(false);
expect(ffsbSettingsSchema.safeParse({ ...config, dnsServer: 'host.invalid' }).success).toBe(false);
const ir = fixture(); ir.groups.push(group('🦁 FFSB'));
expect(() => surgeFfsb(ir)).toThrow('conflicts');
ir.groups.pop(); ir.groups[1].members = [{ kind: 'group', name: '🦀 Claude' }];
expect(() => surgeFfsb(ir)).toThrow('must not reference');
});
it.skipIf(!existsSync('/Applications/Surge.app/Contents/Applications/surge-cli'))('passes the native Surge parser', () => {
const dir = mkdtempSync(join(tmpdir(), 'ffsb-native-'));
try {
const file = join(dir, 'test.conf');
writeFileSync(file, generateSurge(fixture(), { selfUrl: 'https://example.com/sub/test/surge' }).content, { mode: 0o600 });
const result = spawnSync('/Applications/Surge.app/Contents/Applications/surge-cli', ['--check', file], { encoding: 'utf8' });
expect(result.status, result.stdout + result.stderr).toBe(0);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
});

View File

@@ -0,0 +1,67 @@
import { z } from 'zod';
import domains from '../../../assets/ffsb-domains.json';
import type { ProfileIR } from '../ir.js';
import { surgeValue } from './surge-format.js';
export const FFSB_POLICY = '🦁 FFSB';
export const CLAUDE_GROUP = '🦀 Claude';
export const FFSB_INTERNAL = 'Claude-Surge-Internal';
export const ffsbSettingsSchema = z.object({
enabled: z.boolean(),
interface: z.string().regex(/^utun\d+$/),
dnsServer: z.string().ip({ version: 'v4' }),
extraPolicy: z.string().min(1),
}).strict();
/** Surge-only adapter; the stored cross-client IR is never mutated. */
export function surgeFfsb(ir: ProfileIR): {
ir: ProfileIR; proxyLines: string[]; hiddenGroups: string[]; extraMembers: Record<string, string[]>;
} {
const empty = { ir, proxyLines: [], hiddenGroups: [], extraMembers: {} };
if (!ir.sections.FFSB) return empty;
const config = ffsbSettingsSchema.parse(JSON.parse(ir.sections.FFSB));
if (!config.enabled) return empty;
const claude = ir.groups.find(g => g.name === CLAUDE_GROUP);
if (!claude) return empty;
if (claude.type !== 'select') throw new Error('FFSB requires a manual Claude group');
const extra = ir.groups.find(g => g.name === config.extraPolicy);
if (!extra || extra.name === CLAUDE_GROUP) throw new Error('FFSB requires an independent extra-domain group');
const byName = new Map(ir.groups.map(g => [g.name, g]));
const reachesClaude = (name: string, seen = new Set<string>()): boolean => {
if (name === CLAUDE_GROUP) return true;
if (seen.has(name)) return false;
seen.add(name);
return byName.get(name)?.members.some(m => m.kind === 'group' && reachesClaude(m.name, seen)) ?? false;
};
if (reachesClaude(extra.name)) throw new Error('FFSB extra-domain group must not reference Claude');
if ([...ir.nodes, ...ir.groups].some(x => [FFSB_POLICY, FFSB_INTERNAL].includes(x.name))) {
throw new Error('FFSB generated policy name conflicts with stored policy');
}
const outer = '/Applications/FFSB.app/Contents/MacOS/ffsb-service';
const rules = ir.rules.filter(r => !(r.type === 'PROCESS-NAME' && r.value === outer));
const scope = domains.map(d => ({
type: d.startsWith('*.') ? 'DOMAIN-WILDCARD' : 'DOMAIN', value: d,
policy: CLAUDE_GROUP, params: [], ruleset: null,
}));
// These domains must reach Surge as fake IPs, so OS host routes cannot bypass selection.
const general = (ir.sections.General ?? '').replace(/^always-real-ip\s*=\s*(.*)$/gmi, (_, values: string) =>
'always-real-ip = ' + values.split(',').map(v => v.trim()).filter(v => !domains.includes(v)).join(', '));
const adapted: ProfileIR = {
...ir,
groups: [...ir.groups, { ...extra, name: FFSB_INTERNAL, type: 'select', filterRegex: null,
includeAllNodes: false, members: [{ kind: 'group', name: extra.name }] }],
rules: [
{ type: 'PROCESS-NAME', value: outer, policy: 'DIRECT', params: [], ruleset: null },
...scope,
...rules.map(r => r.policy === CLAUDE_GROUP ? { ...r, policy: FFSB_INTERNAL } : r),
],
sections: { ...ir.sections, General: general,
Host: domains.map(d => `${d} = server:${config.dnsServer}`).join('\n') + '\n' + (ir.sections.Host ?? '') },
};
return {
ir: adapted,
proxyLines: [`${FFSB_POLICY} = direct, interface=${config.interface}, allow-other-interface=false, dns-follow-interface=true, test-url=https://api.ipify.org`],
hiddenGroups: [FFSB_INTERNAL],
extraMembers: { [CLAUDE_GROUP]: [surgeValue(FFSB_POLICY)] },
};
}

View File

@@ -1,7 +1,9 @@
import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
import type { ProfileIR } from '../ir.js';
import { surgeFfsb } from './surge-ffsb.js';
import { surgeAiDns } from './surge-ai-dns.js';
import { surgeDeclaration, surgeParam, surgeValue as quoteName } from './surge-format.js';
import { WG_POLICY, WG_SECTION, surgeWireguardSection } from './wireguard.js';
export function surgeNodeLine(n: NodeDto): string {
const parts: string[] = [];
@@ -57,6 +59,11 @@ export interface SurgeOutput {
}
export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOutput {
if (ir.wireguard && [...ir.nodes, ...ir.groups].some(n => n.name === WG_POLICY)) {
throw new Error('WireGuard 组网策略名称与现有节点或组冲突');
}
const ffsb = surgeFfsb(ir);
ir = ffsb.ir;
const dns = surgeAiDns(ir);
const excluded: ExcludedNode[] = [];
const usable = ir.nodes.filter((n) => {
@@ -74,6 +81,8 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu
lines.push(dns.general);
lines.push('', '[Proxy]');
lines.push(...ffsb.proxyLines);
if (ir.wireguard) lines.push(`${WG_POLICY} = wireguard, section-name=${WG_SECTION}`);
for (const n of usable) lines.push(surgeNodeLine(n));
lines.push('', '[Proxy Group]');
@@ -90,7 +99,9 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu
}
const sources = g.members.filter((m) => m.kind === 'group').map((m) => m.name);
if (sources.length) parts.push(`include-other-group=${quoteName(sources.join(','))}`);
parts.push(`policy-regex-filter=${quoteName(g.filterRegex)}`);
// Keep the original regex's unanchored search semantics while excluding only the WG policy.
const filter = ir.wireguard ? `^(?!${WG_POLICY}$).*?(?:${g.filterRegex})` : g.filterRegex;
parts.push(`policy-regex-filter=${quoteName(filter)}`);
} else if (g.includeAllNodes) {
// 「全部节点」组:显式展开当前启用且 Surge 支持的节点
parts.push(...[...new Set([...g.members.filter((m) => m.kind !== 'node' || usableNames.has(m.name)).map((m) => m.name), ...usable.map((n) => n.name)])].map(quoteName));
@@ -98,6 +109,8 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu
} else {
parts.push(...(memberNames.length ? memberNames : ['DIRECT']));
}
parts.push(...(ffsb.extraMembers[g.name] ?? []));
if (ffsb.hiddenGroups.includes(g.name)) parts.push('hidden=true');
if (g.type !== 'select') {
// Surge uses per-node test-url or General.proxy-test-url, never group url=.
if (g.interval) parts.push(`interval=${g.interval}`);
@@ -107,6 +120,10 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu
}
lines.push('', '[Rule]');
if (ir.wireguard) {
if (ir.wireguard.acceptIncoming) lines.push(`IP-CIDR,${ir.wireguard.address}/32,DIRECT,no-resolve`);
lines.push(`IP-CIDR,${ir.wireguard.subnet},${WG_POLICY},no-resolve`);
}
lines.push(...dns.rules);
for (const r of ir.rules.filter((r) => r.type !== 'FINAL')) {
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
@@ -123,5 +140,6 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu
}
}
if (ir.wireguard) lines.push('', surgeWireguardSection(ir.wireguard));
return { content: lines.join('\n') + '\n', excluded };
}

View File

@@ -0,0 +1,19 @@
import type { WireguardProfile } from '@proxy-station/shared';
export const WG_POLICY = 'NJCQ WireGuard LAN';
export const WG_SECTION = 'njcq-lan';
export function generateWireguard(wg: WireguardProfile): string {
return ['# 每台设备使用自己的配置;仅路由私有组网地址。', '[Interface]',
`PrivateKey = ${wg.privateKey}`, `Address = ${wg.address}/32`, `MTU = ${wg.mtu}`,
'', '[Peer]', `PublicKey = ${wg.serverPublicKey}`,
...(wg.presharedKey ? [`PresharedKey = ${wg.presharedKey}`] : []),
`Endpoint = ${wg.server}:${wg.port}`, `AllowedIPs = ${wg.subnet}`,
`PersistentKeepalive = ${wg.keepalive}`, ''].join('\n');
}
export function surgeWireguardSection(wg: WireguardProfile): string {
return [`[WireGuard ${WG_SECTION}]`, `private-key = ${wg.privateKey}`, `self-ip = ${wg.address}`,
`mtu = ${wg.mtu}`, ...(wg.acceptIncoming ? ['# 接受入站连接需要 Surge Mac 6.11.0+。', 'accept-incoming = true'] : []),
`peer = (public-key = ${wg.serverPublicKey}, allowed-ips = ${wg.subnet}, endpoint = ${wg.server}:${wg.port}, keepalive = ${wg.keepalive}${wg.presharedKey ? `, preshared-key = ${wg.presharedKey}` : ''})`].join('\n');
}

View File

@@ -1,7 +1,7 @@
import { asc, eq } from 'drizzle-orm';
import { db } from '../db/index.js';
import { groupMembers, nodes, policyGroups, rules, rulesets, settings } from '../db/schema.js';
import { BUILTIN_POLICIES, type NodeDto } from '@proxy-station/shared';
import { BUILTIN_POLICIES, type NodeDto, type WireguardProfile } from '@proxy-station/shared';
import { rowToDto } from '../routes/nodes.js';
export interface GroupIR {
@@ -33,6 +33,7 @@ export interface RuleIR {
}
export interface ProfileIR {
wireguard?: WireguardProfile;
nodes: NodeDto[]; // 仅启用节点,按 sortOrder
groups: GroupIR[];
rules: RuleIR[]; // 仅启用规则,按 seq
@@ -124,7 +125,7 @@ export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR {
});
const sections: Record<string, string> = {};
for (const key of ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) {
for (const key of ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script', 'FFSB']) {
const v = getSetting(`surge:${key}`);
if (v) sections[key] = v;
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import YAML from 'yaml';
import { convertSurgeList } from './convert.js';
import { convertSurgeList, domainSetToRuleList } from './convert.js';
describe('convertSurgeList', () => {
it('classical:保留支持类型与 no-resolve,丢弃 USER-AGENT', () => {
@@ -26,4 +26,15 @@ describe('convertSurgeList', () => {
const result = convertSurgeList('example.com\n.cdn.example.com', 'domain');
expect(YAML.parse(result.yaml).payload).toEqual(['example.com', '+.cdn.example.com']);
});
it('converts domain sets losslessly to ordinary rule lists without broadening exact hosts', () => {
const result = domainSetToRuleList('\uFEFF# comment\nexact.example.com\n.suffix.example.com\n// comment\n; comment\n');
expect(result).toEqual({ content: 'DOMAIN,exact.example.com\nDOMAIN-SUFFIX,suffix.example.com\n', total: 2 });
expect(domainSetToRuleList('# empty').content).toBe('');
});
it.each(['+.example.com', '*.example.com', 'example.com,REJECT', 'https://example.com', '<html>',
'DOMAIN,example.com', 'example.com = 192.0.2.1', '.', 'example..com'])('rejects unknown domain-set syntax: %s', (line) => {
expect(() => domainSetToRuleList(`valid.example.com\n${line}`)).toThrow('invalid domain set entry at 2');
});
});

View File

@@ -1,5 +1,22 @@
import YAML from 'yaml';
/** Exact DOMAIN-SET semantics from manual.nssurge.com/rules/domain.html. */
export function domainSetToRuleList(content: string): { content: string; total: number } {
const entries = content.split(/\r?\n/).map((line) => line.trim())
.filter((line) => line && !/^(#|\/\/|;)/.test(line));
const rules = entries.map((entry, index) => {
const suffix = entry.startsWith('.');
const domain = suffix ? entry.slice(1) : entry;
// Fail closed on unrecognized formats, rather than silently dropping coverage
// or accepting policy delimiters in an upstream resource.
if (!/^[\p{L}\p{N}_-]+(?:\.[\p{L}\p{N}_-]+)*\.?$/u.test(domain)) {
throw new Error(`invalid domain set entry at ${index + 1}`);
}
return `${suffix ? 'DOMAIN-SUFFIX' : 'DOMAIN'},${domain}`;
});
return { content: rules.join('\n') + (rules.length ? '\n' : ''), total: entries.length };
}
/** mihomo classical provider 支持的规则类型 */
const CLASSICAL_TYPES = new Set([
'DOMAIN',

View File

@@ -0,0 +1,54 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { env } from '../env.js';
// Back up this key together with SQLite. Never silently replace a lost key.
const initializedFiles = new Set<string>();
function masterKey(create = true): Buffer {
if (process.env.WG_MASTER_KEY) {
const key = Buffer.from(process.env.WG_MASTER_KEY, 'base64');
if (key.length !== 32) throw new Error('WG_MASTER_KEY must contain 32 bytes');
return key;
}
const file = path.join(env.dataDir, 'wg-master.key');
fs.mkdirSync(env.dataDir, { recursive: true, mode: 0o700 });
try { if (create && !initializedFiles.has(file)) fs.writeFileSync(file, crypto.randomBytes(32), { flag: 'wx', mode: 0o600 }); }
catch (e: any) { if (e.code !== 'EEXIST') throw e; }
const key = fs.readFileSync(file);
if (key.length !== 32) throw new Error('Invalid WireGuard encryption key');
fs.chmodSync(file, 0o600);
initializedFiles.add(file);
return key;
}
export function encryptSecret(value: unknown): string {
const nonce = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', masterKey(), nonce);
cipher.setAAD(Buffer.from('proxy-station/wireguard/v1'));
const data = Buffer.concat([cipher.update(JSON.stringify(value)), cipher.final()]);
return ['v1', nonce.toString('base64'), cipher.getAuthTag().toString('base64'), data.toString('base64')].join(':');
}
export function decryptSecret<T>(value: string): T {
const [version, nonce, tag, data] = value.split(':');
if (version !== 'v1') throw new Error('Unknown WireGuard encryption format');
const cipher = crypto.createDecipheriv('aes-256-gcm', masterKey(false), Buffer.from(nonce, 'base64'));
cipher.setAAD(Buffer.from('proxy-station/wireguard/v1'));
cipher.setAuthTag(Buffer.from(tag, 'base64'));
return JSON.parse(Buffer.concat([cipher.update(Buffer.from(data, 'base64')), cipher.final()]).toString());
}
export const credential = () => crypto.randomBytes(32).toString('base64url');
export const hashCredential = (value: string) => crypto.createHash('sha256').update(value).digest('hex');
export function publicKey(privateKey: string): string {
const key = crypto.createPrivateKey({ key: Buffer.concat([
Buffer.from('302e020100300506032b656e04220420', 'hex'), Buffer.from(privateKey, 'base64'),
]), format: 'der', type: 'pkcs8' });
return crypto.createPublicKey(key).export({ format: 'der', type: 'spki' }).subarray(-32).toString('base64');
}
export function newIdentity() {
const key = crypto.randomBytes(32);
key[0] &= 248; key[31] &= 127; key[31] |= 64;
return { privateKey: key.toString('base64'), presharedKey: crypto.randomBytes(32).toString('base64') };
}

View File

@@ -0,0 +1,40 @@
import { eq, isNotNull } from 'drizzle-orm';
import { wireguardProfileSchema } from '@proxy-station/shared';
import type { Db } from '../db/index.js';
import { subTokens, wgDevices, wgGateways, wgJobs, wgNetworks } from '../db/schema.js';
import { decryptSecret, encryptSecret, publicKey } from './wg-crypto.js';
/** Adopt only identities actually present in the old DB. No demo/device seeding or peer writes. */
export function validateWireguardSecrets(database: Db) {
for (const device of database.select().from(wgDevices).all()) decryptSecret(device.secret);
}
export function migrateWireguard(database: Db) {
database.transaction(tx => {
for (const token of tx.select().from(subTokens).where(isNotNull(subTokens.wireguard)).all()) {
if (!token.wireguard) throw new Error('Invalid empty legacy WireGuard profile');
const profile = wireguardProfileSchema.parse(JSON.parse(token.wireguard));
const key = publicKey(profile.privateKey);
let gateway = tx.select().from(wgGateways).where(eq(wgGateways.publicKey, profile.serverPublicKey)).get();
if (!gateway) {
const id = `legacy-${token.id}`;
tx.insert(wgNetworks).values({ id, subnet: profile.subnet, mtu: profile.mtu }).run();
gateway = { id, networkId: id, server: profile.server, port: profile.port, publicKey: profile.serverPublicKey,
agentHash: null, receivedAt: null, bootId: null };
tx.insert(wgGateways).values(gateway).run();
}
let device = tx.select().from(wgDevices).where(eq(wgDevices.publicKey, key)).get();
if (device && (device.address !== profile.address || device.gatewayId !== gateway.id || JSON.stringify(decryptSecret(device.secret)) !== JSON.stringify(profile))) throw new Error('Conflicting legacy WireGuard identity');
if (!device) {
device = { id: `legacy-${token.id}`, gatewayId: gateway.id, name: token.name,
os: profile.address === '10.203.0.3' ? 'macOS' : profile.address === '10.203.0.2' ? 'iPadOS' : '其他',
address: profile.address, publicKey: key, secret: encryptSecret(profile), desired: 'active',
version: 1, appliedVersion: 0, createdAt: token.createdAt, archivedAt: null };
tx.insert(wgDevices).values(device).run();
tx.insert(wgJobs).values({ id: `adopt-${token.id}`, deviceId: device.id, action: 'adopt', version: 1,
state: 'pending', createdAt: Date.now() }).run();
}
tx.update(subTokens).set({ wireguard: null, wgDeviceId: device.id }).where(eq(subTokens.id, token.id)).run();
}
});
}

177
server/src/services/wg.ts Normal file
View File

@@ -0,0 +1,177 @@
import { and, asc, desc, eq, isNull } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { wireguardProfileSchema, type WireguardProfile, type WgDeviceDto } from '@proxy-station/shared';
import { db } from '../db/index.js';
import { subTokens, wgDevices, wgEvents, wgGateways, wgJobs, wgNetworks, wgObservations, wgSubscriptionBindings } from '../db/schema.js';
import { credential, decryptSecret, encryptSecret, hashCredential, newIdentity, publicKey } from './wg-crypto.js';
import { migrateWireguard } from './wg-migrate.js';
export class WgError extends Error { constructor(message: string, public status: 404 | 409 | 503 = 409) { super(message); } }
export const migrateLegacy = () => migrateWireguard(db);
export function getDevice(id: string) {
const row = db.select().from(wgDevices).where(eq(wgDevices.id, id)).get();
if (!row) throw new WgError('设备不存在', 404);
return row;
}
export function profileFor(device: typeof wgDevices.$inferSelect) {
return wireguardProfileSchema.parse(decryptSecret<WireguardProfile>(device.secret));
}
export function canDistribute(device: typeof wgDevices.$inferSelect, legacy = false) {
const job = db.select().from(wgJobs).where(eq(wgJobs.deviceId, device.id)).orderBy(desc(wgJobs.version)).get();
return device.desired === 'active' && (device.appliedVersion === device.version ||
(legacy && device.version === 1 && job?.action === 'adopt' && job.state === 'pending'));
}
export function authorizedProfile(id: string, legacy = false) {
const device = getDevice(id);
if (!canDistribute(device, legacy)) throw new WgError('设备尚未生效、已暂停或已移除', 404);
const profile = profileFor(device);
// New device delivery always enables Surge inbound; legacy URLs retain their original setting.
return legacy ? profile : { ...profile, acceptIncoming: true };
}
export function deviceDto(device: typeof wgDevices.$inferSelect, now = Date.now()): WgDeviceDto {
const observation = db.select().from(wgObservations).where(eq(wgObservations.deviceId, device.id)).get();
const job = db.select().from(wgJobs).where(eq(wgJobs.deviceId, device.id)).orderBy(desc(wgJobs.version)).get();
const fresh = !!observation && now - observation.receivedAt <= 45_000;
let status: WgDeviceDto['status'];
if (device.appliedVersion !== device.version) status = job?.state === 'failed' ? 'error' :
device.desired === 'active' ? (job?.action === 'resume' ? 'restoring' : 'provisioning') : 'revoking';
else if (device.desired !== 'active') status = device.desired;
else if (!fresh) status = 'unknown';
else if (!observation.handshakeAt && !observation.activityAt) status = 'pending';
else status = now - Math.max(observation.handshakeAt ?? 0, observation.activityAt ?? 0) <= 180_000 ? 'online' : 'offline';
return { id: device.id, gatewayId: device.gatewayId, name: device.name, os: device.os, address: device.address,
publicKey: device.publicKey, status, canDistribute: canDistribute(device), createdAt: device.createdAt,
sampledAt: observation?.receivedAt ?? null, lastActivityAt: observation?.activityAt ?? observation?.handshakeAt ?? null,
uploadRate: fresh && status !== 'paused' ? observation.uploadRate : null,
downloadRate: fresh && status !== 'paused' ? observation.downloadRate : null,
uploadTotal: observation?.uploadTotal ?? 0, downloadTotal: observation?.downloadTotal ?? 0,
job: job ? { id: job.id, action: job.action, state: job.state, error: job.error } : null };
}
export function listDevices() { migrateLegacy(); return db.select().from(wgDevices).where(isNull(wgDevices.archivedAt)).orderBy(asc(wgDevices.createdAt)).all().map(d => deviceDto(d)); }
function event(id: string, message: string) { db.insert(wgEvents).values({ id: nanoid(), deviceId: id, message, createdAt: Date.now() }).run(); }
export function createDevice(input: { name: string; os: string; requestId: string; gatewayId?: string }) {
migrateLegacy();
return db.transaction(() => {
// Stable client request ID survives network timeout/double submission.
const prior = db.select().from(wgDevices).where(eq(wgDevices.id, input.requestId)).get();
if (prior) return deviceDto(prior);
if (db.select().from(wgDevices).where(and(eq(wgDevices.name, input.name), isNull(wgDevices.archivedAt))).get()) throw new WgError('已有同名设备,请用位置或用途区分');
const gateways = db.select().from(wgGateways).all();
const gateway = input.gatewayId ? gateways.find(g => g.id === input.gatewayId) : gateways.length === 1 ? gateways[0] : null;
if (!gateway?.agentHash) throw new WgError('请先配置网关及独立 agent 凭证', 503);
const network = db.select().from(wgNetworks).where(eq(wgNetworks.id, gateway.networkId)).get()!;
if (network.subnet !== '10.203.0.0/24') throw new WgError('当前仅开放 njcq 设备网络');
const used = new Set(db.select().from(wgDevices).all().map(d => d.address));
// .2/.3 reserved for the existing iPad/Mac even before adoption; never recycle archives.
const address = Array.from({ length: 250 }, (_, i) => `10.203.0.${i + 4}`).find(ip => !used.has(ip));
if (!address) throw new WgError('设备地址已用尽');
const profile = wireguardProfileSchema.parse({ server: gateway.server, port: gateway.port, serverPublicKey: gateway.publicKey,
...newIdentity(), address, subnet: network.subnet, mtu: network.mtu, acceptIncoming: true });
const device = { id: input.requestId, gatewayId: gateway.id, name: input.name, os: input.os, address,
publicKey: publicKey(profile.privateKey), secret: encryptSecret(profile), desired: 'active' as const,
version: 1, appliedVersion: 0, createdAt: Date.now(), archivedAt: null };
db.insert(wgDevices).values(device).run();
db.insert(wgJobs).values({ id: nanoid(), deviceId: device.id, action: 'create', version: 1, state: 'pending', createdAt: Date.now() }).run();
event(device.id, '已预留固定 IP 和身份,等待网关确认');
return deviceDto(device);
});
}
export function deviceAction(id: string, action: 'pause' | 'resume' | 'rotate' | 'remove' | 'retry') {
return db.transaction(() => {
const device = getDevice(id);
const previous = db.select().from(wgJobs).where(eq(wgJobs.deviceId, id)).orderBy(desc(wgJobs.version)).get();
if (device.archivedAt) throw new WgError('设备已移除');
if (device.appliedVersion !== device.version) {
if (previous?.action === action) return deviceDto(device);
if (action !== 'retry') throw new WgError('请先完成或重试当前网关任务');
db.update(wgJobs).set({ state: 'pending', error: null }).where(eq(wgJobs.id, previous!.id)).run();
return deviceDto(device);
}
if (action === 'retry') return deviceDto(device);
if (action === 'resume' && device.desired !== 'paused') throw new WgError('设备未暂停');
if (action === 'pause' && device.desired === 'paused') return deviceDto(device);
if (action === 'rotate' && device.desired !== 'active') throw new WgError('请先恢复设备');
const desired = action === 'pause' ? 'paused' : action === 'remove' ? 'archived' : 'active';
const patch = action === 'rotate' ? (() => {
const profile = { ...profileFor(device), ...newIdentity() };
return { publicKey: publicKey(profile.privateKey), secret: encryptSecret(profile) };
})() : {};
db.update(wgDevices).set({ ...patch, desired, version: device.version + 1 }).where(eq(wgDevices.id, id)).run();
db.insert(wgJobs).values({ id: nanoid(), deviceId: id, action, version: device.version + 1, state: 'pending',
oldPublicKey: action === 'rotate' ? device.publicKey : null, createdAt: Date.now() }).run();
event(id, `已请求 ${action},等待运行与持久配置确认`);
return deviceDto(getDevice(id));
});
}
export function issueBinding(tokenId: string, deviceId: string, purpose: 'surge' | 'clash' = 'surge') {
const token = db.select().from(subTokens).where(eq(subTokens.id, tokenId)).get();
if (!token?.enabled) throw new WgError('订阅不存在或已停用', 404);
authorizedProfile(deviceId);
const secret = credential(); const id = nanoid();
db.insert(wgSubscriptionBindings).values({ id, tokenId, deviceId, credentialHash: hashCredential(secret),
purpose, createdAt: Date.now() }).run();
return { id, deviceId, path: `/sub/${token.token}/${purpose}?wg=${secret}` };
}
export function bindingProfile(tokenId: string, secret: string, purpose: 'surge' | 'clash' = 'surge') {
if (!/^[\w-]{43}$/.test(secret)) throw new WgError('设备凭证无效', 404);
const binding = db.select().from(wgSubscriptionBindings).where(and(eq(wgSubscriptionBindings.credentialHash, hashCredential(secret)),
eq(wgSubscriptionBindings.tokenId, tokenId), eq(wgSubscriptionBindings.purpose, purpose), isNull(wgSubscriptionBindings.revokedAt))).get();
if (!binding) throw new WgError('设备凭证无效', 404);
const profile = authorizedProfile(binding.deviceId);
db.update(wgSubscriptionBindings).set({ lastAccessAt: Date.now() }).where(eq(wgSubscriptionBindings.id, binding.id)).run();
return profile;
}
// Agent requests contain public keys/PSKs but never client private keys.
export function gatewayTasks(gatewayId: string) {
const jobs = db.select().from(wgJobs).innerJoin(wgDevices, eq(wgJobs.deviceId, wgDevices.id))
.where(and(eq(wgDevices.gatewayId, gatewayId), eq(wgJobs.state, 'pending'))).all();
return jobs.map(({ wg_jobs: j, wg_devices: d }) => ({ id: j.id, deviceId: d.id, action: j.action,
version: j.version, publicKey: d.publicKey, oldPublicKey: j.oldPublicKey,
address: d.address, presharedKey: profileFor(d).presharedKey ?? null, desired: d.desired }));
}
export interface AgentReport {
bootId: string;
receipts: { id: string; version: number; runtime: boolean; persistent: boolean; error?: string }[];
peers: { publicKey: string; handshakeAge: number | null; rx: number; tx: number }[];
}
export function receiveReport(gatewayId: string, report: AgentReport) {
const now = Date.now();
db.transaction(() => {
db.update(wgGateways).set({ receivedAt: now, bootId: report.bootId }).where(eq(wgGateways.id, gatewayId)).run();
for (const receipt of report.receipts) {
const job = db.select().from(wgJobs).where(eq(wgJobs.id, receipt.id)).get();
if (!job || job.state === 'confirmed' || job.version !== receipt.version) continue;
const device = getDevice(job.deviceId);
if (device.gatewayId !== gatewayId || device.version !== receipt.version) continue;
const ok = receipt.runtime && receipt.persistent && !receipt.error;
db.update(wgJobs).set({ state: ok ? 'confirmed' : 'failed', error: ok ? null : '网关配置未确认,请重试任务',
confirmedAt: ok ? now : null }).where(eq(wgJobs.id, job.id)).run();
if (ok) {
db.update(wgDevices).set({ appliedVersion: job.version, ...(device.desired === 'archived' ? { archivedAt: now } : {}) })
.where(eq(wgDevices.id, device.id)).run();
if (device.desired === 'archived') db.update(wgSubscriptionBindings).set({ revokedAt: now }).where(eq(wgSubscriptionBindings.deviceId, device.id)).run();
if (job.action === 'rotate') db.delete(wgObservations).where(eq(wgObservations.deviceId, device.id)).run();
}
event(device.id, ok ? '网关确认:运行配置与持久配置均已生效' : '网关未确认配置,保留当前身份与任务供重试');
}
const peers = new Map(report.peers.map(p => [p.publicKey, p]));
for (const device of db.select().from(wgDevices).where(eq(wgDevices.gatewayId, gatewayId)).all()) {
const peer = peers.get(device.publicKey);
if (!peer || device.desired !== 'active') continue;
const old = db.select().from(wgObservations).where(eq(wgObservations.deviceId, device.id)).get();
const elapsed = old ? (now - old.receivedAt) / 1000 : 0;
const sameEpoch = old?.bootId === report.bootId && peer.rx >= old.rx && peer.tx >= old.tx;
const up = sameEpoch ? peer.rx - old!.rx : 0; // gateway rx = device upload
const down = sameEpoch ? peer.tx - old!.tx : 0;
const fresh = elapsed > 0 && elapsed <= 45;
const handshakeAt = peer.handshakeAge === null ? old?.handshakeAt ?? null : now - peer.handshakeAge * 1000;
const activityAt = (up || down) ? now : Math.max(old?.activityAt ?? 0, handshakeAt ?? 0) || null;
const row = { deviceId: device.id, receivedAt: now, bootId: report.bootId, handshakeAt, activityAt,
rx: peer.rx, tx: peer.tx, uploadRate: fresh && sameEpoch ? Math.round(up / elapsed) : null,
downloadRate: fresh && sameEpoch ? Math.round(down / elapsed) : null,
uploadTotal: (old?.uploadTotal ?? 0) + up, downloadTotal: (old?.downloadTotal ?? 0) + down };
db.insert(wgObservations).values(row).onConflictDoUpdate({ target: wgObservations.deviceId, set: row }).run();
}
});
}