feat: 添加 WireGuard 设备管理并完善客户端分流
实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。 完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。
This commit is contained in:
27
server/assets/ffsb-domains.json
Normal file
27
server/assets/ffsb-domains.json
Normal 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"
|
||||
]
|
||||
@@ -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>;
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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 即凭证
|
||||
|
||||
@@ -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 '控制接口不能使用公开模板口令;请关闭接口或使用独立凭据与访问限制';
|
||||
|
||||
64
server/src/routes/sub-ruleset.test.ts
Normal file
64
server/src/routes/sub-ruleset.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
280
server/src/routes/wg-devices.test.ts
Normal file
280
server/src/routes/wg-devices.test.ts
Normal 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
99
server/src/routes/wg.ts
Normal 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 });
|
||||
});
|
||||
137
server/src/routes/wireguard.test.ts
Normal file
137
server/src/routes/wireguard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
59
server/src/services/generators/clash-ai-dns.test.ts
Normal file
59
server/src/services/generators/clash-ai-dns.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
60
server/src/services/generators/clash-ai-dns.ts
Normal file
60
server/src/services/generators/clash-ai-dns.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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] };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
21
server/src/services/generators/shadowrocket-lan.ts
Normal file
21
server/src/services/generators/shadowrocket-lan.ts
Normal 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',
|
||||
];
|
||||
70
server/src/services/generators/surge-ffsb.test.ts
Normal file
70
server/src/services/generators/surge-ffsb.test.ts
Normal 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 }); }
|
||||
});
|
||||
});
|
||||
67
server/src/services/generators/surge-ffsb.ts
Normal file
67
server/src/services/generators/surge-ffsb.ts
Normal 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)] },
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
19
server/src/services/generators/wireguard.ts
Normal file
19
server/src/services/generators/wireguard.ts
Normal 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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
54
server/src/services/wg-crypto.ts
Normal file
54
server/src/services/wg-crypto.ts
Normal 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') };
|
||||
}
|
||||
40
server/src/services/wg-migrate.ts
Normal file
40
server/src/services/wg-migrate.ts
Normal 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
177
server/src/services/wg.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user