init: init proj
This commit is contained in:
125
server/src/db/index.ts
Normal file
125
server/src/db/index.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { env } from '../env.js';
|
||||
import * as schema from './schema.js';
|
||||
|
||||
const BOOTSTRAP_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
protocol TEXT NOT NULL,
|
||||
server TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
network TEXT NOT NULL DEFAULT 'tcp',
|
||||
ws_path TEXT,
|
||||
ws_host TEXT,
|
||||
tls INTEGER NOT NULL DEFAULT 0,
|
||||
sni TEXT,
|
||||
skip_cert_verify INTEGER NOT NULL DEFAULT 0,
|
||||
uuid TEXT,
|
||||
alter_id INTEGER NOT NULL DEFAULT 0,
|
||||
security TEXT NOT NULL DEFAULT 'auto',
|
||||
password TEXT,
|
||||
method TEXT,
|
||||
obfs_type TEXT,
|
||||
obfs_password TEXT,
|
||||
up_mbps INTEGER,
|
||||
down_mbps INTEGER,
|
||||
region TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
extra TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS policy_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
type TEXT NOT NULL,
|
||||
test_url TEXT,
|
||||
interval INTEGER,
|
||||
tolerance INTEGER,
|
||||
filter_regex TEXT,
|
||||
include_all_nodes INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'custom'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id TEXT NOT NULL REFERENCES policy_groups(id) ON DELETE CASCADE,
|
||||
member_kind TEXT NOT NULL,
|
||||
node_id TEXT REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
ref_name TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
value TEXT,
|
||||
policy TEXT NOT NULL,
|
||||
params TEXT NOT NULL DEFAULT '[]',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
source TEXT NOT NULL DEFAULT 'custom'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS rulesets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
surge_url TEXT NOT NULL UNIQUE,
|
||||
clash_url TEXT,
|
||||
clash_behavior TEXT NOT NULL DEFAULT 'classical',
|
||||
clash_format TEXT NOT NULL DEFAULT 'yaml',
|
||||
needs_convert INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ruleset_cache (
|
||||
url TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
etag TEXT,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sub_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
node_ids TEXT NOT NULL DEFAULT '[]',
|
||||
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 settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
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 });
|
||||
}
|
||||
const sqlite = new Database(file);
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
sqlite.exec(BOOTSTRAP_SQL);
|
||||
// 轻量迁移:旧库补列
|
||||
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 '[]'",
|
||||
]) {
|
||||
try {
|
||||
sqlite.exec(stmt);
|
||||
} catch {
|
||||
// 列已存在
|
||||
}
|
||||
}
|
||||
return drizzle(sqlite, { schema });
|
||||
}
|
||||
|
||||
export type Db = ReturnType<typeof createDb>;
|
||||
|
||||
export const db = createDb();
|
||||
101
server/src/db/schema.ts
Normal file
101
server/src/db/schema.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const nodes = sqliteTable('nodes', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull().unique(),
|
||||
protocol: text('protocol', { enum: ['vmess', 'trojan', 'shadowsocks', 'hysteria2'] }).notNull(),
|
||||
server: text('server').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
network: text('network', { enum: ['tcp', 'ws'] }).notNull().default('tcp'),
|
||||
wsPath: text('ws_path'),
|
||||
wsHost: text('ws_host'),
|
||||
tls: integer('tls', { mode: 'boolean' }).notNull().default(false),
|
||||
sni: text('sni'),
|
||||
skipCertVerify: integer('skip_cert_verify', { mode: 'boolean' }).notNull().default(false),
|
||||
uuid: text('uuid'),
|
||||
alterId: integer('alter_id').notNull().default(0),
|
||||
security: text('security').notNull().default('auto'),
|
||||
password: text('password'),
|
||||
method: text('method'),
|
||||
obfsType: text('obfs_type'),
|
||||
obfsPassword: text('obfs_password'),
|
||||
upMbps: integer('up_mbps'),
|
||||
downMbps: integer('down_mbps'),
|
||||
region: text('region'),
|
||||
tags: text('tags').notNull().default('[]'),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
extra: text('extra').notNull().default('{}'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const policyGroups = sqliteTable('policy_groups', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull().unique(),
|
||||
type: text('type', { enum: ['select', 'url-test', 'fallback'] }).notNull(),
|
||||
testUrl: text('test_url'),
|
||||
interval: integer('interval'),
|
||||
tolerance: integer('tolerance'),
|
||||
filterRegex: text('filter_regex'),
|
||||
includeAllNodes: integer('include_all_nodes', { mode: 'boolean' }).notNull().default(false),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
source: text('source', { enum: ['template', 'custom'] }).notNull().default('custom'),
|
||||
});
|
||||
|
||||
export const groupMembers = sqliteTable('group_members', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
groupId: text('group_id')
|
||||
.notNull()
|
||||
.references(() => policyGroups.id, { onDelete: 'cascade' }),
|
||||
memberKind: text('member_kind', { enum: ['node', 'group', 'builtin'] }).notNull(),
|
||||
nodeId: text('node_id').references(() => nodes.id, { onDelete: 'cascade' }),
|
||||
refName: text('ref_name'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
});
|
||||
|
||||
export const rules = sqliteTable('rules', {
|
||||
id: text('id').primaryKey(),
|
||||
seq: integer('seq').notNull(),
|
||||
type: text('type').notNull(),
|
||||
value: text('value'),
|
||||
policy: text('policy').notNull(),
|
||||
params: text('params').notNull().default('[]'),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
source: text('source', { enum: ['template', 'custom'] }).notNull().default('custom'),
|
||||
});
|
||||
|
||||
export const rulesets = sqliteTable('rulesets', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
surgeUrl: text('surge_url').notNull().unique(),
|
||||
clashUrl: text('clash_url'),
|
||||
clashBehavior: text('clash_behavior').notNull().default('classical'),
|
||||
clashFormat: text('clash_format').notNull().default('yaml'),
|
||||
needsConvert: integer('needs_convert', { mode: 'boolean' }).notNull().default(false),
|
||||
});
|
||||
|
||||
export const rulesetCache = sqliteTable('ruleset_cache', {
|
||||
url: text('url').primaryKey(),
|
||||
content: text('content').notNull(),
|
||||
etag: text('etag'),
|
||||
fetchedAt: integer('fetched_at').notNull(),
|
||||
});
|
||||
|
||||
export const subTokens = sqliteTable('sub_tokens', {
|
||||
id: text('id').primaryKey(),
|
||||
token: text('token').notNull().unique(),
|
||||
name: text('name').notNull(),
|
||||
/** 分配给该订阅的节点 id(JSON 数组);空数组 = 不限制,下发全部启用节点 */
|
||||
nodeIds: text('node_ids').notNull().default('[]'),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
lastAccessAt: integer('last_access_at'),
|
||||
accessCount: integer('access_count').notNull().default(0),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
131
server/src/db/seed.ts
Normal file
131
server/src/db/seed.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { env } from '../env.js';
|
||||
import type { Db } from './index.js';
|
||||
import { policyGroups, groupMembers, rules, rulesets, settings } from './schema.js';
|
||||
import { parseSurgeConf, type ParsedGroup } from '../services/template/surge-parser.js';
|
||||
import { mapRulesetUrl, rulesetNameFromUrl } from '../services/rulesets/mapping.js';
|
||||
|
||||
const TEMPLATE_PATH = path.resolve(env.serverRoot, 'assets/surge-template.conf');
|
||||
|
||||
const BUILTIN_POLICIES = new Set(['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP', 'REJECT-TINYGIF']);
|
||||
|
||||
function seedGroups(db: Db, groups: ParsedGroup[], source: 'template' | 'custom') {
|
||||
groups.forEach((g, i) => {
|
||||
const groupId = nanoid(10);
|
||||
db.insert(policyGroups)
|
||||
.values({
|
||||
id: groupId,
|
||||
name: g.name,
|
||||
type: g.type,
|
||||
testUrl: g.testUrl ?? null,
|
||||
interval: g.interval ?? null,
|
||||
tolerance: g.tolerance ?? null,
|
||||
filterRegex: g.filterRegex ?? null,
|
||||
// AllServer 在模板中挂上游订阅(policy-path),在本系统中即「全部节点」组
|
||||
includeAllNodes: !!(g.hasPolicyPath || g.includeAllProxies),
|
||||
sortOrder: i,
|
||||
source,
|
||||
})
|
||||
.run();
|
||||
const members = [...g.members];
|
||||
if (g.includeOtherGroup && !members.includes(g.includeOtherGroup)) {
|
||||
members.push(g.includeOtherGroup);
|
||||
}
|
||||
members.forEach((m, j) => {
|
||||
db.insert(groupMembers)
|
||||
.values({
|
||||
groupId,
|
||||
memberKind: BUILTIN_POLICIES.has(m) ? 'builtin' : 'group',
|
||||
nodeId: null,
|
||||
refName: m,
|
||||
sortOrder: j,
|
||||
})
|
||||
.run();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) {
|
||||
const text = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
||||
const parsed = parseSurgeConf(text);
|
||||
|
||||
const custom = opts.keepCustom
|
||||
? db.select().from(rules).where(eq(rules.source, 'custom')).all()
|
||||
: [];
|
||||
db.delete(rules).where(eq(rules.source, 'template')).run();
|
||||
if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run();
|
||||
|
||||
let seq = 0;
|
||||
for (const r of parsed.rules) {
|
||||
let value = r.value;
|
||||
if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(value)) {
|
||||
const existing = db.select().from(rulesets).where(eq(rulesets.surgeUrl, value)).get();
|
||||
if (existing) {
|
||||
value = existing.id;
|
||||
} else {
|
||||
const mapping = mapRulesetUrl(value, r.type);
|
||||
const rulesetId = nanoid(10);
|
||||
db.insert(rulesets)
|
||||
.values({
|
||||
id: rulesetId,
|
||||
name: rulesetNameFromUrl(value),
|
||||
surgeUrl: value,
|
||||
clashUrl: mapping.clashUrl,
|
||||
clashBehavior: mapping.clashBehavior,
|
||||
clashFormat: mapping.clashFormat,
|
||||
needsConvert: mapping.needsConvert,
|
||||
})
|
||||
.run();
|
||||
value = rulesetId;
|
||||
}
|
||||
}
|
||||
db.insert(rules)
|
||||
.values({
|
||||
id: nanoid(10),
|
||||
seq: seq++,
|
||||
type: r.type,
|
||||
value,
|
||||
policy: r.policy,
|
||||
params: JSON.stringify(r.params),
|
||||
enabled: true,
|
||||
source: 'template',
|
||||
})
|
||||
.run();
|
||||
}
|
||||
// 自定义规则重新插回:保持原相对顺序,排在 FINAL 之前
|
||||
if (custom.length > 0) {
|
||||
const finalRow = db.select().from(rules).where(eq(rules.type, 'FINAL')).get();
|
||||
let base = finalRow ? finalRow.seq : seq;
|
||||
// 给自定义行腾出 seq 空间:FINAL 及之后行整体后移
|
||||
for (const c of custom.sort((a, b) => a.seq - b.seq)) {
|
||||
db.insert(rules)
|
||||
.values({ ...c, seq: base++ })
|
||||
.run();
|
||||
}
|
||||
if (finalRow) db.update(rules).set({ seq: base }).where(eq(rules.id, finalRow.id)).run();
|
||||
}
|
||||
}
|
||||
|
||||
export function seedIfEmpty(db: Db) {
|
||||
const hasGroups = db.select().from(policyGroups).limit(1).all().length > 0;
|
||||
if (hasGroups) return;
|
||||
|
||||
const text = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
||||
const parsed = parseSurgeConf(text);
|
||||
|
||||
seedGroups(db, parsed.groups, 'template');
|
||||
seedRulesFromTemplate(db, { keepCustom: false });
|
||||
|
||||
for (const [name, content] of Object.entries(parsed.sections)) {
|
||||
db.insert(settings)
|
||||
.values({ key: `surge:${name}`, value: content })
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
console.log(
|
||||
`seeded from template: ${parsed.groups.length} groups, ${parsed.rules.length} rules, sections: ${Object.keys(parsed.sections).join(', ')}`
|
||||
);
|
||||
}
|
||||
11
server/src/env.ts
Normal file
11
server/src/env.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export const env = {
|
||||
port: Number(process.env.PORT || 3000),
|
||||
dataDir: path.resolve(serverRoot, process.env.DATA_DIR || './data'),
|
||||
adminToken: process.env.ADMIN_TOKEN || '',
|
||||
serverRoot,
|
||||
};
|
||||
54
server/src/index.ts
Normal file
54
server/src/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { Hono } from 'hono';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { env } from './env.js';
|
||||
import { db } from './db/index.js';
|
||||
import { seedIfEmpty } from './db/seed.js';
|
||||
import { adminAuth } from './middleware/admin-auth.js';
|
||||
import { nodesRoute } from './routes/nodes.js';
|
||||
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 { settingsRoute } from './routes/settings.js';
|
||||
|
||||
seedIfEmpty(db);
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/api/health', (c) => c.json({ ok: true, name: 'proxy-station' }));
|
||||
|
||||
app.use('/api/*', adminAuth);
|
||||
app.route('/api/nodes', nodesRoute);
|
||||
app.route('/api/groups', groupsRoute);
|
||||
app.route('/api/rules', rulesRoute);
|
||||
app.route('/api/rulesets', rulesetsRoute);
|
||||
app.route('/api/tokens', tokensRoute);
|
||||
app.route('/api/settings', settingsRoute);
|
||||
|
||||
// 公开订阅端点:token 即凭证
|
||||
app.route('/sub', subRoute);
|
||||
|
||||
// 未匹配的接口路径返回 404,不落到下面的 SPA 回退(否则会拿到一页 HTML)
|
||||
app.all('/api/*', (c) => c.json({ error: '接口不存在' }, 404));
|
||||
|
||||
// 生产模式:托管 web 构建产物(web/dist 相对 server 根目录)
|
||||
const webDist = path.resolve(env.serverRoot, '../web/dist');
|
||||
if (fs.existsSync(webDist)) {
|
||||
const relRoot = path.relative(process.cwd(), webDist);
|
||||
app.use('/*', serveStatic({ root: relRoot }));
|
||||
app.get('*', serveStatic({ path: path.join(relRoot, 'index.html') }));
|
||||
}
|
||||
|
||||
// 监听 0.0.0.0:局域网内的手机等设备可直接访问管理界面与订阅
|
||||
serve({ fetch: app.fetch, port: env.port, hostname: '0.0.0.0' }, (info) => {
|
||||
const lan = Object.values(os.networkInterfaces())
|
||||
.flat()
|
||||
.filter((i): i is NonNullable<typeof i> => !!i && i.family === 'IPv4' && !i.internal)
|
||||
.map((i) => i.address);
|
||||
console.log(`proxy-station server listening on http://localhost:${info.port}`);
|
||||
for (const addr of lan) console.log(` http://${addr}:${info.port}`);
|
||||
});
|
||||
31
server/src/middleware/admin-auth.ts
Normal file
31
server/src/middleware/admin-auth.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { env } from '../env.js';
|
||||
|
||||
/** 本机或 RFC1918 私有网段(局域网) */
|
||||
function isLocalOrPrivate(addr: string): boolean {
|
||||
const ip = addr.replace(/^::ffff:/, '');
|
||||
if (!ip || ip === '127.0.0.1' || ip === '::1') return true;
|
||||
if (/^10\./.test(ip)) return true;
|
||||
if (/^192\.168\./.test(ip)) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/* 鉴权:设置了 ADMIN_TOKEN 则要求 Bearer;未设置时仅放行本机与局域网来源
|
||||
*(本机/局域网开发免配置,公网部署必须设 token)。
|
||||
*/
|
||||
export const adminAuth: MiddlewareHandler = async (c, next) => {
|
||||
if (env.adminToken) {
|
||||
const header = c.req.header('Authorization') || '';
|
||||
if (header !== `Bearer ${env.adminToken}`) {
|
||||
return c.json({ error: '未授权' }, 401);
|
||||
}
|
||||
} else {
|
||||
const addr = c.env?.incoming?.socket?.remoteAddress ?? '';
|
||||
if (!isLocalOrPrivate(addr)) {
|
||||
return c.json({ error: '服务未设置 ADMIN_TOKEN,仅允许本机与局域网访问管理接口' }, 403);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
};
|
||||
107
server/src/routes/groups.ts
Normal file
107
server/src/routes/groups.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { groupInputSchema, type GroupDto } from '@proxy-station/shared';
|
||||
import { db } from '../db/index.js';
|
||||
import { groupMembers, policyGroups, rules } from '../db/schema.js';
|
||||
|
||||
function loadGroups(): GroupDto[] {
|
||||
const groups = db.select().from(policyGroups).orderBy(asc(policyGroups.sortOrder)).all();
|
||||
const members = db.select().from(groupMembers).orderBy(asc(groupMembers.sortOrder)).all();
|
||||
return groups.map((g) => ({
|
||||
...g,
|
||||
members: members
|
||||
.filter((m) => m.groupId === g.id)
|
||||
.map((m) => ({ id: m.id, kind: m.memberKind, nodeId: m.nodeId, refName: m.refName, sortOrder: m.sortOrder })),
|
||||
}));
|
||||
}
|
||||
|
||||
function replaceMembers(groupId: string, members: z.output<typeof groupInputSchema>['members']) {
|
||||
db.delete(groupMembers).where(eq(groupMembers.groupId, groupId)).run();
|
||||
members.forEach((m, i) => {
|
||||
db.insert(groupMembers)
|
||||
.values({
|
||||
groupId,
|
||||
memberKind: m.kind,
|
||||
nodeId: m.kind === 'node' ? (m.nodeId ?? null) : null,
|
||||
refName: m.kind === 'node' ? null : (m.refName ?? null),
|
||||
sortOrder: i,
|
||||
})
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
export const groupsRoute = new Hono()
|
||||
.get('/', (c) => c.json(loadGroups()))
|
||||
.post('/', zValidator('json', groupInputSchema), (c) => {
|
||||
const input = c.req.valid('json');
|
||||
const id = nanoid(10);
|
||||
try {
|
||||
db.insert(policyGroups)
|
||||
.values({
|
||||
id,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
testUrl: input.testUrl ?? null,
|
||||
interval: input.interval ?? null,
|
||||
tolerance: input.tolerance ?? null,
|
||||
filterRegex: input.filterRegex || null,
|
||||
includeAllNodes: input.includeAllNodes,
|
||||
sortOrder: input.sortOrder,
|
||||
source: 'custom',
|
||||
})
|
||||
.run();
|
||||
} catch (e: any) {
|
||||
if (String(e.message).includes('UNIQUE')) return c.json({ error: `策略组「${input.name}」已存在` }, 409);
|
||||
throw e;
|
||||
}
|
||||
replaceMembers(id, input.members);
|
||||
return c.json({ id }, 201);
|
||||
})
|
||||
.put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => {
|
||||
const { ids } = c.req.valid('json');
|
||||
ids.forEach((id, i) => db.update(policyGroups).set({ sortOrder: i }).where(eq(policyGroups.id, id)).run());
|
||||
return c.json({ ok: true });
|
||||
})
|
||||
.patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => {
|
||||
const res = db
|
||||
.update(policyGroups)
|
||||
.set({ enabled: c.req.valid('json').enabled })
|
||||
.where(eq(policyGroups.id, c.req.param('id')))
|
||||
.run();
|
||||
if (res.changes === 0) return c.json({ error: '策略组不存在' }, 404);
|
||||
return c.json({ ok: true });
|
||||
})
|
||||
.put('/:id', zValidator('json', groupInputSchema), (c) => {
|
||||
const id = c.req.param('id');
|
||||
const existing = db.select().from(policyGroups).where(eq(policyGroups.id, id)).get();
|
||||
if (!existing) return c.json({ error: '策略组不存在' }, 404);
|
||||
const input = c.req.valid('json');
|
||||
const oldName = existing.name;
|
||||
db.update(policyGroups)
|
||||
.set({
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
testUrl: input.testUrl ?? null,
|
||||
interval: input.interval ?? null,
|
||||
tolerance: input.tolerance ?? null,
|
||||
filterRegex: input.filterRegex || null,
|
||||
includeAllNodes: input.includeAllNodes,
|
||||
})
|
||||
.where(eq(policyGroups.id, id))
|
||||
.run();
|
||||
replaceMembers(id, input.members);
|
||||
// 组名变更时同步引用它的成员与规则策略
|
||||
if (oldName !== input.name) {
|
||||
db.update(groupMembers).set({ refName: input.name }).where(eq(groupMembers.refName, oldName)).run();
|
||||
db.update(rules).set({ policy: input.name }).where(eq(rules.policy, oldName)).run();
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
})
|
||||
.delete('/:id', (c) => {
|
||||
const res = db.delete(policyGroups).where(eq(policyGroups.id, c.req.param('id'))).run();
|
||||
if (res.changes === 0) return c.json({ error: '策略组不存在' }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
132
server/src/routes/nodes.ts
Normal file
132
server/src/routes/nodes.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { nodeInputSchema, type NodeDto } from '@proxy-station/shared';
|
||||
import { db } from '../db/index.js';
|
||||
import { nodes } from '../db/schema.js';
|
||||
import { encodeShareLink, parseShareLinkBatch } from '../services/sharelink/parse.js';
|
||||
|
||||
type NodeRow = typeof nodes.$inferSelect;
|
||||
|
||||
export function rowToDto(row: NodeRow): NodeDto {
|
||||
return { ...row, tags: JSON.parse(row.tags) } as unknown as NodeDto;
|
||||
}
|
||||
|
||||
function dtoToRowPatch(input: z.output<typeof nodeInputSchema>) {
|
||||
return {
|
||||
name: input.name,
|
||||
protocol: input.protocol,
|
||||
server: input.server,
|
||||
port: input.port,
|
||||
network: input.network,
|
||||
wsPath: input.wsPath ?? null,
|
||||
wsHost: input.wsHost ?? null,
|
||||
tls: input.tls,
|
||||
sni: input.sni ?? null,
|
||||
skipCertVerify: input.skipCertVerify,
|
||||
uuid: input.uuid ?? null,
|
||||
alterId: input.alterId,
|
||||
security: input.security,
|
||||
password: input.password ?? null,
|
||||
method: input.method ?? null,
|
||||
obfsType: input.obfsType ?? null,
|
||||
obfsPassword: input.obfsPassword ?? null,
|
||||
upMbps: input.upMbps ?? null,
|
||||
downMbps: input.downMbps ?? null,
|
||||
region: input.region ?? null,
|
||||
tags: JSON.stringify(input.tags),
|
||||
enabled: input.enabled,
|
||||
sortOrder: input.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
/** 名称冲突时自动追加 -2 / -3 … 后缀 */
|
||||
function uniqueName(base: string, taken: Set<string>): string {
|
||||
if (!taken.has(base)) return base;
|
||||
for (let i = 2; ; i++) {
|
||||
const candidate = `${base}-${i}`;
|
||||
if (!taken.has(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
export const nodesRoute = new Hono()
|
||||
.get('/', (c) => {
|
||||
const rows = db.select().from(nodes).orderBy(asc(nodes.sortOrder), asc(nodes.name)).all();
|
||||
return c.json(rows.map(rowToDto));
|
||||
})
|
||||
.post('/', zValidator('json', nodeInputSchema), (c) => {
|
||||
const input = c.req.valid('json');
|
||||
const now = Date.now();
|
||||
const row = { id: nanoid(10), ...dtoToRowPatch(input), extra: '{}', createdAt: now, updatedAt: now };
|
||||
try {
|
||||
db.insert(nodes).values(row).run();
|
||||
} catch (e: any) {
|
||||
if (String(e.message).includes('UNIQUE')) return c.json({ error: `节点名「${input.name}」已存在` }, 409);
|
||||
throw e;
|
||||
}
|
||||
return c.json(rowToDto(row as NodeRow), 201);
|
||||
})
|
||||
.post(
|
||||
'/import',
|
||||
zValidator('json', z.object({ text: z.string().min(1), commit: z.boolean().default(false) })),
|
||||
(c) => {
|
||||
const { text, commit } = c.req.valid('json');
|
||||
const parsed = parseShareLinkBatch(text);
|
||||
const taken = new Set(db.select({ name: nodes.name }).from(nodes).all().map((r) => r.name));
|
||||
const now = Date.now();
|
||||
const results = parsed.map((item) => {
|
||||
if (!item.node) return { ok: false as const, uri: item.uri, error: item.error };
|
||||
const name = uniqueName(item.node.name, taken);
|
||||
taken.add(name);
|
||||
const node = { ...item.node, name };
|
||||
if (commit) {
|
||||
const validated = nodeInputSchema.parse(node);
|
||||
const row = { id: nanoid(10), ...dtoToRowPatch(validated), extra: '{}', createdAt: now, updatedAt: now };
|
||||
db.insert(nodes).values(row).run();
|
||||
}
|
||||
return { ok: true as const, uri: item.uri, node };
|
||||
});
|
||||
return c.json({
|
||||
committed: commit,
|
||||
total: results.length,
|
||||
succeeded: results.filter((r) => r.ok).length,
|
||||
items: results,
|
||||
});
|
||||
}
|
||||
)
|
||||
.get('/:id/uri', (c) => {
|
||||
const row = db.select().from(nodes).where(eq(nodes.id, c.req.param('id'))).get();
|
||||
if (!row) return c.json({ error: '节点不存在' }, 404);
|
||||
const dto = rowToDto(row);
|
||||
return c.json({ uri: encodeShareLink({ ...dto, obfsType: dto.obfsType as 'salamander' | null }) });
|
||||
})
|
||||
.put('/:id', zValidator('json', nodeInputSchema), (c) => {
|
||||
const id = c.req.param('id');
|
||||
const existing = db.select().from(nodes).where(eq(nodes.id, id)).get();
|
||||
if (!existing) return c.json({ error: '节点不存在' }, 404);
|
||||
const input = c.req.valid('json');
|
||||
try {
|
||||
db.update(nodes)
|
||||
.set({ ...dtoToRowPatch(input), updatedAt: Date.now() })
|
||||
.where(eq(nodes.id, id))
|
||||
.run();
|
||||
} catch (e: any) {
|
||||
if (String(e.message).includes('UNIQUE')) return c.json({ error: `节点名「${input.name}」已存在` }, 409);
|
||||
throw e;
|
||||
}
|
||||
return c.json(rowToDto(db.select().from(nodes).where(eq(nodes.id, id)).get()!));
|
||||
})
|
||||
.patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { enabled } = c.req.valid('json');
|
||||
const res = db.update(nodes).set({ enabled, updatedAt: Date.now() }).where(eq(nodes.id, id)).run();
|
||||
if (res.changes === 0) return c.json({ error: '节点不存在' }, 404);
|
||||
return c.json({ ok: true });
|
||||
})
|
||||
.delete('/:id', (c) => {
|
||||
const res = db.delete(nodes).where(eq(nodes.id, c.req.param('id'))).run();
|
||||
if (res.changes === 0) return c.json({ error: '节点不存在' }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
116
server/src/routes/rules.ts
Normal file
116
server/src/routes/rules.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { ruleInputSchema, type RuleDto } from '@proxy-station/shared';
|
||||
import { db } from '../db/index.js';
|
||||
import { rules, rulesets } from '../db/schema.js';
|
||||
import { seedRulesFromTemplate } from '../db/seed.js';
|
||||
|
||||
function loadRules(): RuleDto[] {
|
||||
const rows = db.select().from(rules).orderBy(asc(rules.seq)).all();
|
||||
const sets = new Map(db.select().from(rulesets).all().map((r) => [r.id, r]));
|
||||
return rows.map((r) => {
|
||||
const rs = r.value ? sets.get(r.value) : undefined;
|
||||
return {
|
||||
...r,
|
||||
params: JSON.parse(r.params),
|
||||
rulesetName: rs?.name ?? null,
|
||||
rulesetUrl: rs?.surgeUrl ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function nextSeq(): number {
|
||||
const rows = db.select({ seq: rules.seq }).from(rules).all();
|
||||
return rows.length ? Math.max(...rows.map((r) => r.seq)) + 1 : 0;
|
||||
}
|
||||
|
||||
export const rulesRoute = new Hono()
|
||||
.get('/', (c) => c.json(loadRules()))
|
||||
.post('/', zValidator('json', ruleInputSchema), (c) => {
|
||||
const input = c.req.valid('json');
|
||||
// 新自定义规则插到 FINAL 之前
|
||||
const finalRow = db.select().from(rules).where(eq(rules.type, 'FINAL')).get();
|
||||
let seq: number;
|
||||
if (finalRow) {
|
||||
seq = finalRow.seq;
|
||||
db.update(rules).set({ seq: finalRow.seq + 1 }).where(eq(rules.id, finalRow.id)).run();
|
||||
} else {
|
||||
seq = nextSeq();
|
||||
}
|
||||
const id = nanoid(10);
|
||||
db.insert(rules)
|
||||
.values({
|
||||
id,
|
||||
seq,
|
||||
type: input.type,
|
||||
value: input.value ?? null,
|
||||
policy: input.policy,
|
||||
params: JSON.stringify(input.params),
|
||||
enabled: input.enabled,
|
||||
source: 'custom',
|
||||
})
|
||||
.run();
|
||||
return c.json({ id }, 201);
|
||||
})
|
||||
.put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => {
|
||||
const { ids } = c.req.valid('json');
|
||||
ids.forEach((id, i) => db.update(rules).set({ seq: i }).where(eq(rules.id, id)).run());
|
||||
return c.json({ ok: true });
|
||||
})
|
||||
.post('/reset', (c) => {
|
||||
seedRulesFromTemplate(db, { keepCustom: true });
|
||||
return c.json({ ok: true, rules: loadRules().length });
|
||||
})
|
||||
.put('/:id', zValidator('json', ruleInputSchema), (c) => {
|
||||
const id = c.req.param('id');
|
||||
const existing = db.select().from(rules).where(eq(rules.id, id)).get();
|
||||
if (!existing) return c.json({ error: '规则不存在' }, 404);
|
||||
const input = c.req.valid('json');
|
||||
db.update(rules)
|
||||
.set({
|
||||
type: input.type,
|
||||
value: input.value ?? null,
|
||||
policy: input.policy,
|
||||
params: JSON.stringify(input.params),
|
||||
enabled: input.enabled,
|
||||
})
|
||||
.where(eq(rules.id, id))
|
||||
.run();
|
||||
return c.json({ ok: true });
|
||||
})
|
||||
.patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => {
|
||||
const res = db
|
||||
.update(rules)
|
||||
.set({ enabled: c.req.valid('json').enabled })
|
||||
.where(eq(rules.id, c.req.param('id')))
|
||||
.run();
|
||||
if (res.changes === 0) return c.json({ error: '规则不存在' }, 404);
|
||||
return c.json({ ok: true });
|
||||
})
|
||||
.delete('/:id', (c) => {
|
||||
const res = db.delete(rules).where(eq(rules.id, c.req.param('id'))).run();
|
||||
if (res.changes === 0) return c.json({ error: '规则不存在' }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export const rulesetsRoute = new Hono()
|
||||
.get('/', (c) => {
|
||||
const rows = db.select().from(rulesets).all();
|
||||
return c.json(rows);
|
||||
})
|
||||
.get('/:id/content', async (c) => {
|
||||
const rs = db.select().from(rulesets).where(eq(rulesets.id, c.req.param('id'))).get();
|
||||
if (!rs) return c.json({ error: '规则集不存在' }, 404);
|
||||
const refresh = c.req.query('refresh') === '1';
|
||||
try {
|
||||
const { fetchRulesetContent } = await import('../services/rulesets/fetch.js');
|
||||
const { content, fetchedAt, stale } = await fetchRulesetContent(rs.surgeUrl, { refresh });
|
||||
const lineCount = content.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith('#')).length;
|
||||
return c.json({ content, fetchedAt, stale, lineCount });
|
||||
} catch (e: any) {
|
||||
return c.json({ error: `拉取失败:${e.message}` }, 502);
|
||||
}
|
||||
});
|
||||
22
server/src/routes/settings.ts
Normal file
22
server/src/routes/settings.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { getSetting, setSetting } from '../services/ir.js';
|
||||
|
||||
const SURGE_SECTION_KEYS = ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script'];
|
||||
|
||||
export const settingsRoute = new Hono()
|
||||
.get('/', (c) => {
|
||||
const surgeSections: Record<string, string> = {};
|
||||
for (const key of SURGE_SECTION_KEYS) surgeSections[key] = getSetting(`surge:${key}`);
|
||||
return c.json({ surgeSections });
|
||||
})
|
||||
.put('/', zValidator('json', z.object({ surgeSections: z.record(z.string()).optional() })), (c) => {
|
||||
const input = c.req.valid('json');
|
||||
if (input.surgeSections) {
|
||||
for (const [key, value] of Object.entries(input.surgeSections)) {
|
||||
if (SURGE_SECTION_KEYS.includes(key)) setSetting(`surge:${key}`, value);
|
||||
}
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
104
server/src/routes/sub.ts
Normal file
104
server/src/routes/sub.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Hono } from 'hono';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import crypto from 'node:crypto';
|
||||
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 { generateSurge } from '../services/generators/surge.js';
|
||||
import { generateShadowrocket } from '../services/generators/shadowrocket.js';
|
||||
import { generateShadowrocketConf } from '../services/generators/shadowrocket-conf.js';
|
||||
import { SHADOWROCKET_SUB_NAME } from '@proxy-station/shared';
|
||||
import { generateClash } from '../services/generators/clash.js';
|
||||
|
||||
function constantTimeEqual(a: string, b: string): boolean {
|
||||
const ha = crypto.createHash('sha256').update(a).digest();
|
||||
const hb = crypto.createHash('sha256').update(b).digest();
|
||||
return crypto.timingSafeEqual(ha, hb);
|
||||
}
|
||||
|
||||
export function findToken(token: string) {
|
||||
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 }) {
|
||||
return buildProfileIR({ nodeIds: JSON.parse(row.nodeIds) });
|
||||
}
|
||||
|
||||
function touchToken(id: string, count: number) {
|
||||
db.update(subTokens)
|
||||
.set({ lastAccessAt: Date.now(), accessCount: count + 1 })
|
||||
.where(eq(subTokens.id, id))
|
||||
.run();
|
||||
}
|
||||
|
||||
/** 订阅 URL 基址:直接取本次请求的 origin——客户端能请求到这里,origin 就一定可达 */
|
||||
function baseUrl(c: { req: { url: string } }): string {
|
||||
return new URL(c.req.url).origin;
|
||||
}
|
||||
|
||||
export const subRoute = new Hono()
|
||||
.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 { content } = generateSurge(ir, { selfUrl });
|
||||
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);
|
||||
})
|
||||
.get('/:token/shadowrocket', (c) => {
|
||||
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));
|
||||
c.header('content-type', 'text/plain; charset=utf-8');
|
||||
return c.body(content);
|
||||
})
|
||||
// ShadowRocket 的策略组与规则(节点走上面的节点订阅端点)
|
||||
.get('/:token/shadowrocket-conf', (c) => {
|
||||
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), {
|
||||
subscriptionName: SHADOWROCKET_SUB_NAME,
|
||||
});
|
||||
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);
|
||||
})
|
||||
.get('/:token/clash', (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 convertBaseUrl = `${baseUrl(c)}/sub/${row.token}`;
|
||||
const { content } = generateClash(ir, { convertBaseUrl });
|
||||
c.header('content-type', 'text/yaml; charset=utf-8');
|
||||
c.header('content-disposition', 'attachment; filename="Proxy Station.yaml"; filename*=UTF-8\'\'Proxy%20Station.yaml');
|
||||
c.header('profile-update-interval', '24');
|
||||
return c.body(content);
|
||||
})
|
||||
// Clash 兜底转换端点:无 Clash 等价 URL 的规则集,由本服务拉取 Surge 版并转换
|
||||
.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 rs = db.select().from(rulesets).where(eq(rulesets.id, id)).get();
|
||||
if (!rs) return c.text('not found', 404);
|
||||
try {
|
||||
const { content, stale } = await fetchRulesetContent(rs.surgeUrl);
|
||||
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}`);
|
||||
if (stale) c.header('x-proxy-station-stale', '1');
|
||||
return c.body(result.yaml);
|
||||
} catch (e: any) {
|
||||
return c.text(`upstream fetch failed: ${e.message}`, 502);
|
||||
}
|
||||
});
|
||||
78
server/src/routes/tokens.ts
Normal file
78
server/src/routes/tokens.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import crypto from 'node:crypto';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { CLIENTS, type TokenDto } from '@proxy-station/shared';
|
||||
import { db } from '../db/index.js';
|
||||
import { subTokens } from '../db/schema.js';
|
||||
|
||||
/** 只返回路径,前端用当前页面的 origin 拼完整 URL */
|
||||
function tokenUrls(token: string): TokenDto['urls'] {
|
||||
return {
|
||||
...(Object.fromEntries(CLIENTS.map((c) => [c, `/sub/${token}/${c}`])) as Record<
|
||||
(typeof CLIENTS)[number],
|
||||
string
|
||||
>),
|
||||
shadowrocketConf: `/sub/${token}/shadowrocket-conf`,
|
||||
};
|
||||
}
|
||||
|
||||
type TokenRow = typeof subTokens.$inferSelect;
|
||||
|
||||
function toDto(row: TokenRow): TokenDto {
|
||||
return { ...row, nodeIds: JSON.parse(row.nodeIds), urls: tokenUrls(row.token) };
|
||||
}
|
||||
|
||||
const tokenInput = z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
/** 空数组 = 不限制,下发全部启用节点 */
|
||||
nodeIds: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const tokensRoute = new Hono()
|
||||
.get('/', (c) => {
|
||||
const rows = db.select().from(subTokens).orderBy(asc(subTokens.createdAt)).all();
|
||||
return c.json(rows.map(toDto));
|
||||
})
|
||||
.post('/', zValidator('json', tokenInput), (c) => {
|
||||
const { name, nodeIds } = c.req.valid('json');
|
||||
const row: TokenRow = {
|
||||
id: nanoid(10),
|
||||
token: crypto.randomBytes(16).toString('hex'),
|
||||
name,
|
||||
nodeIds: JSON.stringify(nodeIds),
|
||||
enabled: true,
|
||||
createdAt: Date.now(),
|
||||
lastAccessAt: null,
|
||||
accessCount: 0,
|
||||
};
|
||||
db.insert(subTokens).values(row).run();
|
||||
return c.json(toDto(row), 201);
|
||||
})
|
||||
.put('/:id', zValidator('json', tokenInput), (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { name, nodeIds } = c.req.valid('json');
|
||||
const res = db
|
||||
.update(subTokens)
|
||||
.set({ name, nodeIds: JSON.stringify(nodeIds) })
|
||||
.where(eq(subTokens.id, id))
|
||||
.run();
|
||||
if (res.changes === 0) return c.json({ error: '订阅不存在' }, 404);
|
||||
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) => {
|
||||
const res = db
|
||||
.update(subTokens)
|
||||
.set({ enabled: c.req.valid('json').enabled })
|
||||
.where(eq(subTokens.id, c.req.param('id')))
|
||||
.run();
|
||||
if (res.changes === 0) return c.json({ error: '订阅不存在' }, 404);
|
||||
return c.json({ ok: true });
|
||||
})
|
||||
.delete('/:id', (c) => {
|
||||
const res = db.delete(subTokens).where(eq(subTokens.id, c.req.param('id'))).run();
|
||||
if (res.changes === 0) return c.json({ error: '订阅不存在' }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
192
server/src/services/generators/clash.ts
Normal file
192
server/src/services/generators/clash.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import YAML from 'yaml';
|
||||
import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||||
import type { ProfileIR, RuleIR } from '../ir.js';
|
||||
|
||||
export function clashProxy(n: NodeDto): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = { name: n.name, server: n.server, port: n.port };
|
||||
switch (n.protocol) {
|
||||
case 'vmess': {
|
||||
Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher: n.security || 'auto' });
|
||||
if (n.tls) {
|
||||
base.tls = true;
|
||||
if (n.sni) base.servername = n.sni;
|
||||
}
|
||||
if (n.network === 'ws') {
|
||||
base.network = 'ws';
|
||||
base['ws-opts'] = {
|
||||
path: n.wsPath,
|
||||
...(n.wsHost ? { headers: { Host: n.wsHost } } : {}),
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'trojan': {
|
||||
Object.assign(base, { type: 'trojan', password: n.password, sni: n.sni || n.server });
|
||||
if (n.network === 'ws') {
|
||||
base.network = 'ws';
|
||||
base['ws-opts'] = {
|
||||
path: n.wsPath,
|
||||
...(n.wsHost ? { headers: { Host: n.wsHost } } : {}),
|
||||
};
|
||||
}
|
||||
if (n.skipCertVerify) base['skip-cert-verify'] = true;
|
||||
break;
|
||||
}
|
||||
case 'shadowsocks': {
|
||||
Object.assign(base, { type: 'ss', cipher: normalizeSsCipher(n.method || ''), password: n.password });
|
||||
if (n.network === 'ws') {
|
||||
// SS over WS+TLS 在 Clash 侧唯一可行方案是 v2ray-plugin
|
||||
base.plugin = 'v2ray-plugin';
|
||||
base['plugin-opts'] = {
|
||||
mode: 'websocket',
|
||||
tls: n.tls,
|
||||
host: n.wsHost || n.sni || n.server,
|
||||
path: n.wsPath,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'hysteria2': {
|
||||
Object.assign(base, { type: 'hysteria2', password: n.password });
|
||||
if (n.sni) base.sni = n.sni;
|
||||
if (n.obfsType === 'salamander' && n.obfsPassword) {
|
||||
base.obfs = 'salamander';
|
||||
base['obfs-password'] = n.obfsPassword;
|
||||
}
|
||||
if (n.upMbps) base.up = `${n.upMbps} Mbps`;
|
||||
if (n.downMbps) base.down = `${n.downMbps} Mbps`;
|
||||
if (n.skipCertVerify) base['skip-cert-verify'] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Surge 独有策略 → mihomo 等价策略 */
|
||||
function mapPolicy(policy: string): string {
|
||||
if (policy === 'REJECT-NO-DROP' || policy === 'REJECT-TINYGIF') return 'REJECT';
|
||||
return policy;
|
||||
}
|
||||
|
||||
/** Surge 逻辑规则里的子类型 → mihomo 命名 */
|
||||
function mapLogicValue(value: string): string {
|
||||
return value.replace(/\bPROTOCOL\b/g, 'NETWORK');
|
||||
}
|
||||
|
||||
const CLASH_UNSUPPORTED_RULE_TYPES = new Set(['URL-REGEX', 'USER-AGENT']);
|
||||
|
||||
export interface ClashOutput {
|
||||
content: string;
|
||||
excluded: ExcludedNode[];
|
||||
skippedRules: string[];
|
||||
}
|
||||
|
||||
export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): ClashOutput {
|
||||
const excluded: ExcludedNode[] = [];
|
||||
const usable = ir.nodes.filter((n) => {
|
||||
const verdict = clientSupports('clash', n);
|
||||
if (!verdict.ok) excluded.push({ name: n.name, reason: verdict.reason! });
|
||||
return verdict.ok;
|
||||
});
|
||||
const usableNames = new Set(usable.map((n) => n.name));
|
||||
|
||||
const proxies = usable.map(clashProxy);
|
||||
|
||||
const proxyGroups = ir.groups.map((g) => {
|
||||
const group: Record<string, unknown> = { name: g.name, type: g.type };
|
||||
const members = g.members
|
||||
.filter((m) => m.kind !== 'node' || usableNames.has(m.name))
|
||||
.map((m) => m.name);
|
||||
if (g.filterRegex) {
|
||||
group['include-all-proxies'] = true;
|
||||
group.filter = g.filterRegex;
|
||||
} else if (g.includeAllNodes) {
|
||||
group['include-all-proxies'] = true;
|
||||
const extra = members.filter((m) => !usableNames.has(m));
|
||||
if (extra.length) group.proxies = extra;
|
||||
} else {
|
||||
group.proxies = members.length ? members : ['DIRECT'];
|
||||
}
|
||||
if (g.type !== 'select') {
|
||||
group.url = g.testUrl || 'http://cp.cloudflare.com/generate_204';
|
||||
group.interval = g.interval ?? 300;
|
||||
if (g.tolerance) group.tolerance = g.tolerance;
|
||||
}
|
||||
return group;
|
||||
});
|
||||
|
||||
// rule-providers:从启用规则引用的规则集生成,名称唯一化
|
||||
const providerNameByRulesetId = new Map<string, string>();
|
||||
const providers: Record<string, unknown> = {};
|
||||
const takenNames = new Set<string>();
|
||||
const usedRulesets = ir.rules.flatMap((r) => (r.ruleset ? [r.ruleset] : []));
|
||||
for (const rs of usedRulesets) {
|
||||
if (providerNameByRulesetId.has(rs.id)) continue;
|
||||
let name = rs.name;
|
||||
for (let i = 2; takenNames.has(name); i++) name = `${rs.name}-${i}`;
|
||||
takenNames.add(name);
|
||||
providerNameByRulesetId.set(rs.id, name);
|
||||
const url = rs.needsConvert || !rs.clashUrl ? `${opts.convertBaseUrl}/ruleset/${rs.id}.yaml` : rs.clashUrl;
|
||||
providers[name] = {
|
||||
type: 'http',
|
||||
url,
|
||||
behavior: rs.clashBehavior,
|
||||
format: rs.needsConvert || !rs.clashUrl ? 'yaml' : rs.clashFormat === 'yaml' ? 'yaml' : 'text',
|
||||
interval: 86400,
|
||||
};
|
||||
}
|
||||
|
||||
const skippedRules: string[] = [];
|
||||
const ruleLines: string[] = [];
|
||||
for (const r of ir.rules) {
|
||||
if (r.type === 'FINAL') {
|
||||
ruleLines.push(`MATCH,${mapPolicy(r.policy)}`);
|
||||
continue;
|
||||
}
|
||||
if (CLASH_UNSUPPORTED_RULE_TYPES.has(r.type)) {
|
||||
skippedRules.push(`${r.type},${r.value},${r.policy}`);
|
||||
continue;
|
||||
}
|
||||
const policy = mapPolicy(r.policy);
|
||||
if (r.ruleset) {
|
||||
ruleLines.push(`RULE-SET,${providerNameByRulesetId.get(r.ruleset.id)},${policy}`);
|
||||
continue;
|
||||
}
|
||||
// Surge 内置规则集(非 URL):LAN → 私网 GEOIP;SYSTEM 无对应物,跳过
|
||||
if (r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') {
|
||||
if (r.value === 'LAN') {
|
||||
ruleLines.push(`GEOIP,private,${policy},no-resolve`);
|
||||
} else {
|
||||
skippedRules.push(`${r.type},${r.value},${r.policy}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (r.type === 'AND' || r.type === 'OR' || r.type === 'NOT') {
|
||||
ruleLines.push(`${r.type},${mapLogicValue(r.value || '')},${policy}`);
|
||||
continue;
|
||||
}
|
||||
const params = r.params.filter((p) => p === 'no-resolve');
|
||||
ruleLines.push([r.type, r.value, policy, ...params].join(','));
|
||||
}
|
||||
|
||||
const doc = {
|
||||
'mixed-port': 7890,
|
||||
'allow-lan': false,
|
||||
mode: 'rule',
|
||||
'log-level': 'info',
|
||||
'unified-delay': true,
|
||||
ipv6: false,
|
||||
dns: {
|
||||
enable: true,
|
||||
listen: '0.0.0.0:1053',
|
||||
'enhanced-mode': 'fake-ip',
|
||||
nameserver: ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query'],
|
||||
},
|
||||
proxies,
|
||||
'proxy-groups': proxyGroups,
|
||||
'rule-providers': providers,
|
||||
rules: ruleLines,
|
||||
};
|
||||
|
||||
return { content: YAML.stringify(doc, { lineWidth: 0 }), excluded, skippedRules };
|
||||
}
|
||||
110
server/src/services/generators/shadowrocket-conf.ts
Normal file
110
server/src/services/generators/shadowrocket-conf.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { ProfileIR } from '../ir.js';
|
||||
|
||||
/** 成员名含空格/逗号时加引号 */
|
||||
function quoteName(name: string): string {
|
||||
return /[\s,]/.test(name) ? `"${name}"` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* ShadowRocket 的 [General] 只保留它认识的通用键——模板里的 Surge 专有键
|
||||
* (all-hybrid / udp-priority / http-api / compatibility-mode 等)会被过滤掉。
|
||||
*/
|
||||
const SR_GENERAL_KEYS = new Set([
|
||||
'bypass-system',
|
||||
'skip-proxy',
|
||||
'bypass-tun',
|
||||
'dns-server',
|
||||
'fallback-dns-server',
|
||||
'ipv6',
|
||||
'prefer-ipv6',
|
||||
'dns-direct-system',
|
||||
'icmp-auto-reply',
|
||||
'always-real-ip',
|
||||
'hijack-dns',
|
||||
'udp-policy-not-supported-behaviour',
|
||||
'interface-mode',
|
||||
]);
|
||||
|
||||
function shadowrocketGeneral(surgeGeneral: string): string {
|
||||
const lines: string[] = [];
|
||||
for (const raw of surgeGeneral.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
|
||||
const key = line.split('=')[0].trim();
|
||||
if (SR_GENERAL_KEYS.has(key)) lines.push(line);
|
||||
}
|
||||
// ShadowRocket 的常用默认值,模板里没有就补上
|
||||
if (!lines.some((l) => l.startsWith('bypass-system'))) lines.push('bypass-system = true');
|
||||
if (!lines.some((l) => l.startsWith('ipv6'))) lines.push('ipv6 = false');
|
||||
if (!lines.some((l) => l.startsWith('dns-server'))) lines.push('dns-server = 223.5.5.5, 119.29.29.29');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** ShadowRocket 不认识的规则类型 */
|
||||
const SR_UNSUPPORTED_RULE_TYPES = new Set(['DOMAIN-SET', 'AND', 'OR', 'NOT', 'URL-REGEX']);
|
||||
|
||||
export interface ShadowrocketConfOutput {
|
||||
content: string;
|
||||
skippedRules: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 ShadowRocket 配置文件:策略组 + 规则,不含节点。
|
||||
* 节点由「节点订阅」单独导入,策略组通过 `use=true` 引用该订阅的名称,
|
||||
* 因此用户在 App 里给订阅起的名字必须与 subscriptionName 一致。
|
||||
*/
|
||||
export function generateShadowrocketConf(
|
||||
ir: ProfileIR,
|
||||
opts: { subscriptionName: string }
|
||||
): ShadowrocketConfOutput {
|
||||
const sub = quoteName(opts.subscriptionName);
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('# 由 Proxy Station 生成的 ShadowRocket 配置(策略组 + 规则)');
|
||||
lines.push(`# 节点请另行导入「节点订阅」,并在 App 中将其命名为:${opts.subscriptionName}`);
|
||||
lines.push('');
|
||||
lines.push('[General]');
|
||||
lines.push(shadowrocketGeneral(ir.sections['General'] ?? ''));
|
||||
|
||||
lines.push('', '[Proxy Group]');
|
||||
for (const g of ir.groups) {
|
||||
const parts: string[] = [g.type];
|
||||
if (g.filterRegex) {
|
||||
// 地区组:引用订阅 + 正则筛选(ShadowRocket 原生支持 policy-regex-filter)
|
||||
parts.push(sub, 'use=true', `policy-regex-filter=${g.filterRegex}`);
|
||||
} else if (g.includeAllNodes) {
|
||||
// 收纳全部节点:引用订阅
|
||||
parts.push(sub, 'use=true');
|
||||
} else {
|
||||
const members = g.members.map((m) => quoteName(m.name));
|
||||
parts.push(...(members.length ? members : ['DIRECT']));
|
||||
}
|
||||
if (g.type !== 'select') {
|
||||
parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`);
|
||||
if (g.interval) parts.push(`interval=${g.interval}`);
|
||||
if (g.tolerance) parts.push(`tolerance=${g.tolerance}`);
|
||||
}
|
||||
lines.push(`${g.name} = ${parts.join(', ')}`);
|
||||
}
|
||||
|
||||
const skippedRules: string[] = [];
|
||||
lines.push('', '[Rule]');
|
||||
for (const r of ir.rules) {
|
||||
if (r.type === 'FINAL') {
|
||||
lines.push(['FINAL', quoteName(r.policy)].join(','));
|
||||
continue;
|
||||
}
|
||||
if (SR_UNSUPPORTED_RULE_TYPES.has(r.type)) {
|
||||
skippedRules.push(`${r.type},${r.ruleset ? r.ruleset.name : r.value},${r.policy}`);
|
||||
continue;
|
||||
}
|
||||
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
|
||||
lines.push([r.type, value, quoteName(r.policy), ...r.params].join(','));
|
||||
}
|
||||
|
||||
for (const section of ['Host', 'URL Rewrite', 'MITM']) {
|
||||
if (ir.sections[section]) lines.push('', `[${section}]`, ir.sections[section]);
|
||||
}
|
||||
|
||||
return { content: lines.join('\n') + '\n', skippedRules };
|
||||
}
|
||||
27
server/src/services/generators/shadowrocket.ts
Normal file
27
server/src/services/generators/shadowrocket.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { clientSupports, type ExcludedNode } from '@proxy-station/shared';
|
||||
import type { ProfileIR } from '../ir.js';
|
||||
import { encodeShareLink } from '../sharelink/parse.js';
|
||||
|
||||
export interface ShadowrocketOutput {
|
||||
content: string; // base64 后的 URI 列表(ShadowRocket 原生订阅格式)
|
||||
excluded: ExcludedNode[];
|
||||
uris: string[];
|
||||
}
|
||||
|
||||
export function generateShadowrocket(ir: ProfileIR): ShadowrocketOutput {
|
||||
const excluded: ExcludedNode[] = [];
|
||||
const uris: string[] = [];
|
||||
for (const n of ir.nodes) {
|
||||
const verdict = clientSupports('shadowrocket', n);
|
||||
if (!verdict.ok) {
|
||||
excluded.push({ name: n.name, reason: verdict.reason! });
|
||||
continue;
|
||||
}
|
||||
uris.push(encodeShareLink({ ...n, obfsType: n.obfsType as 'salamander' | null }));
|
||||
}
|
||||
return {
|
||||
content: Buffer.from(uris.join('\n'), 'utf8').toString('base64'),
|
||||
excluded,
|
||||
uris,
|
||||
};
|
||||
}
|
||||
124
server/src/services/generators/surge.ts
Normal file
124
server/src/services/generators/surge.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||||
import type { ProfileIR } from '../ir.js';
|
||||
|
||||
/** 成员名含空格/逗号时按 Surge 习惯加引号 */
|
||||
function quoteName(name: string): string {
|
||||
return /[\s,]/.test(name) ? `"${name}"` : name;
|
||||
}
|
||||
|
||||
export function surgeNodeLine(n: NodeDto): string {
|
||||
const parts: string[] = [];
|
||||
switch (n.protocol) {
|
||||
case 'vmess': {
|
||||
parts.push('vmess', n.server, String(n.port), `username=${n.uuid}`);
|
||||
if (n.network === 'ws') {
|
||||
parts.push('ws=true', `ws-path=${n.wsPath}`);
|
||||
if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`);
|
||||
}
|
||||
if (n.tls) {
|
||||
parts.push('tls=true');
|
||||
if (n.sni) parts.push(`sni=${n.sni}`);
|
||||
}
|
||||
// alterId=0 的 AEAD 头必须显式打开,否则 Surge 连不上且症状隐晦
|
||||
parts.push('vmess-aead=true');
|
||||
break;
|
||||
}
|
||||
case 'trojan': {
|
||||
parts.push('trojan', n.server, String(n.port), `password=${n.password}`);
|
||||
if (n.network === 'ws') {
|
||||
parts.push('ws=true', `ws-path=${n.wsPath}`);
|
||||
if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`);
|
||||
}
|
||||
if (n.sni) parts.push(`sni=${n.sni}`);
|
||||
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
|
||||
break;
|
||||
}
|
||||
case 'shadowsocks': {
|
||||
parts.push('ss', n.server, String(n.port), `encrypt-method=${normalizeSsCipher(n.method || '')}`, `password=${n.password}`);
|
||||
break;
|
||||
}
|
||||
case 'hysteria2': {
|
||||
parts.push('hysteria2', n.server, String(n.port), `password=${n.password}`);
|
||||
if (n.sni) parts.push(`sni=${n.sni}`);
|
||||
// Surge 的 salamander 混淆参数名(sub-router 生产验证过)
|
||||
if (n.obfsType === 'salamander' && n.obfsPassword) parts.push(`salamander-password=${n.obfsPassword}`);
|
||||
if (n.downMbps) parts.push(`download-bandwidth=${n.downMbps}`);
|
||||
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return `${n.name} = ${parts.join(', ')}`;
|
||||
}
|
||||
|
||||
export interface SurgeOutput {
|
||||
content: string;
|
||||
excluded: ExcludedNode[];
|
||||
}
|
||||
|
||||
export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOutput {
|
||||
const excluded: ExcludedNode[] = [];
|
||||
const usable = ir.nodes.filter((n) => {
|
||||
const verdict = clientSupports('surge', n);
|
||||
if (!verdict.ok) excluded.push({ name: n.name, reason: verdict.reason! });
|
||||
return verdict.ok;
|
||||
});
|
||||
const usableNames = new Set(usable.map((n) => n.name));
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`#!MANAGED-CONFIG ${opts.selfUrl} interval=86400 strict=true`);
|
||||
lines.push('# 由 Proxy Station 生成,请勿手动编辑(改动会在下次更新时被覆盖)');
|
||||
lines.push('');
|
||||
lines.push('[General]');
|
||||
lines.push(ir.sections['General'] ?? '');
|
||||
|
||||
lines.push('', '[Proxy]');
|
||||
for (const n of usable) lines.push(surgeNodeLine(n));
|
||||
|
||||
lines.push('', '[Proxy Group]');
|
||||
for (const g of ir.groups) {
|
||||
const parts: string[] = [g.type];
|
||||
const memberNames = g.members
|
||||
.filter((m) => m.kind !== 'node' || usableNames.has(m.name))
|
||||
.map((m) => quoteName(m.name));
|
||||
if (g.filterRegex) {
|
||||
// 按名称正则动态筛选:节点来源可以是全部节点,也可以是另一个组
|
||||
if (g.includeAllNodes) {
|
||||
parts.push('include-all-proxies=true');
|
||||
} else {
|
||||
const source = g.members.find((m) => m.kind === 'group');
|
||||
if (source) parts.push(`include-other-group=${quoteName(source.name)}`);
|
||||
}
|
||||
parts.push(`policy-regex-filter=${g.filterRegex}`);
|
||||
} else if (g.includeAllNodes) {
|
||||
// 「全部节点」组:显式展开当前启用且 Surge 支持的节点
|
||||
parts.push(...usable.map((n) => quoteName(n.name)), ...memberNames.filter((m) => !usableNames.has(m)));
|
||||
if (usable.length === 0) parts.push('DIRECT');
|
||||
} else {
|
||||
parts.push(...(memberNames.length ? memberNames : ['DIRECT']));
|
||||
}
|
||||
if (g.type !== 'select') {
|
||||
parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`);
|
||||
if (g.interval) parts.push(`interval=${g.interval}`);
|
||||
if (g.tolerance) parts.push(`tolerance=${g.tolerance}`);
|
||||
}
|
||||
lines.push(`${g.name} = ${parts.join(', ')}`);
|
||||
}
|
||||
|
||||
lines.push('', '[Rule]');
|
||||
for (const r of ir.rules) {
|
||||
if (r.type === 'FINAL') {
|
||||
lines.push(['FINAL', quoteName(r.policy), ...r.params].join(','));
|
||||
continue;
|
||||
}
|
||||
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
|
||||
lines.push([r.type, value, quoteName(r.policy), ...r.params].join(','));
|
||||
}
|
||||
|
||||
for (const section of ['Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) {
|
||||
if (ir.sections[section]) {
|
||||
lines.push('', `[${section}]`, ir.sections[section]);
|
||||
}
|
||||
}
|
||||
|
||||
return { content: lines.join('\n') + '\n', excluded };
|
||||
}
|
||||
131
server/src/services/ir.ts
Normal file
131
server/src/services/ir.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { db } from '../db/index.js';
|
||||
import { groupMembers, nodes, policyGroups, rules, rulesets, settings } from '../db/schema.js';
|
||||
import type { NodeDto } from '@proxy-station/shared';
|
||||
import { rowToDto } from '../routes/nodes.js';
|
||||
|
||||
export interface GroupIR {
|
||||
name: string;
|
||||
type: 'select' | 'url-test' | 'fallback';
|
||||
testUrl: string | null;
|
||||
interval: number | null;
|
||||
tolerance: number | null;
|
||||
filterRegex: string | null;
|
||||
includeAllNodes: boolean;
|
||||
/** 有序成员:节点名 / 组名 / DIRECT 等内置策略 */
|
||||
members: { kind: 'node' | 'group' | 'builtin'; name: string }[];
|
||||
}
|
||||
|
||||
export interface RuleIR {
|
||||
type: string;
|
||||
value: string | null;
|
||||
policy: string;
|
||||
params: string[];
|
||||
ruleset: {
|
||||
id: string;
|
||||
name: string;
|
||||
surgeUrl: string;
|
||||
clashUrl: string | null;
|
||||
clashBehavior: string;
|
||||
clashFormat: string;
|
||||
needsConvert: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ProfileIR {
|
||||
nodes: NodeDto[]; // 仅启用节点,按 sortOrder
|
||||
groups: GroupIR[];
|
||||
rules: RuleIR[]; // 仅启用规则,按 seq
|
||||
sections: Record<string, string>; // Surge 原文段落(General/Host/…)
|
||||
}
|
||||
|
||||
export function getSetting(key: string): string {
|
||||
const row = db.select().from(settings).where(eq(settings.key, key)).get();
|
||||
return row?.value ?? '';
|
||||
}
|
||||
|
||||
export function setSetting(key: string, value: string) {
|
||||
db.insert(settings)
|
||||
.values({ key, value })
|
||||
.onConflictDoUpdate({ target: settings.key, set: { value } })
|
||||
.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param opts.nodeIds 订阅分配的节点范围;为空或未提供时下发全部启用节点
|
||||
*/
|
||||
export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR {
|
||||
const allowed = opts.nodeIds && opts.nodeIds.length > 0 ? new Set(opts.nodeIds) : null;
|
||||
const nodeRows = db
|
||||
.select()
|
||||
.from(nodes)
|
||||
.where(eq(nodes.enabled, true))
|
||||
.orderBy(asc(nodes.sortOrder), asc(nodes.name))
|
||||
.all()
|
||||
.map(rowToDto)
|
||||
.filter((n) => !allowed || allowed.has(n.id));
|
||||
const nodeById = new Map(nodeRows.map((n) => [n.id, n]));
|
||||
|
||||
const memberRows = db.select().from(groupMembers).orderBy(asc(groupMembers.sortOrder)).all();
|
||||
const allGroupRows = db.select().from(policyGroups).orderBy(asc(policyGroups.sortOrder)).all();
|
||||
// 停用的组从输出中剔除;引用它的成员与规则一并跳过,保证产物有效
|
||||
const groupRows = allGroupRows.filter((g) => g.enabled);
|
||||
const liveNames = new Set(groupRows.map((g) => g.name));
|
||||
const BUILTIN_POLICIES = new Set(['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP', 'REJECT-TINYGIF']);
|
||||
/** 组已被删除或停用时,引用它的成员/规则应当跳过 */
|
||||
const isDeadRef = (name: string) => !liveNames.has(name) && !BUILTIN_POLICIES.has(name);
|
||||
const groups: GroupIR[] = groupRows.map((g) => ({
|
||||
name: g.name,
|
||||
type: g.type,
|
||||
testUrl: g.testUrl,
|
||||
interval: g.interval,
|
||||
tolerance: g.tolerance,
|
||||
filterRegex: g.filterRegex,
|
||||
includeAllNodes: g.includeAllNodes,
|
||||
members: memberRows
|
||||
.filter((m) => m.groupId === g.id)
|
||||
.flatMap((m): GroupIR['members'] => {
|
||||
if (m.memberKind === 'node') {
|
||||
const node = m.nodeId ? nodeById.get(m.nodeId) : undefined;
|
||||
return node ? [{ kind: 'node', name: node.name }] : []; // 被禁用/删除的节点成员跳过
|
||||
}
|
||||
if (!m.refName) return [];
|
||||
if (m.memberKind === 'group' && isDeadRef(m.refName)) return [];
|
||||
return [{ kind: m.memberKind, name: m.refName }];
|
||||
}),
|
||||
}));
|
||||
|
||||
const setRows = new Map(db.select().from(rulesets).all().map((r) => [r.id, r]));
|
||||
const ruleRows = db
|
||||
.select()
|
||||
.from(rules)
|
||||
.where(eq(rules.enabled, true))
|
||||
.orderBy(asc(rules.seq))
|
||||
.all()
|
||||
.filter((r) => r.type === 'FINAL' || !isDeadRef(r.policy))
|
||||
// FINAL 必须保留:其策略组被停用或删除时兜底 DIRECT
|
||||
.map((r) => (r.type === 'FINAL' && isDeadRef(r.policy) ? { ...r, policy: 'DIRECT' } : r));
|
||||
const ruleIRs: RuleIR[] = ruleRows.map((r) => {
|
||||
const rs = r.value ? setRows.get(r.value) : undefined;
|
||||
return {
|
||||
type: r.type,
|
||||
value: r.value,
|
||||
policy: r.policy,
|
||||
params: JSON.parse(r.params),
|
||||
ruleset: rs ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const sections: Record<string, string> = {};
|
||||
for (const key of ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) {
|
||||
const v = getSetting(`surge:${key}`);
|
||||
if (v) sections[key] = v;
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: nodeRows,
|
||||
groups,
|
||||
rules: ruleIRs,
|
||||
sections,
|
||||
};
|
||||
}
|
||||
29
server/src/services/rulesets/convert.test.ts
Normal file
29
server/src/services/rulesets/convert.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import YAML from 'yaml';
|
||||
import { convertSurgeList } from './convert.js';
|
||||
|
||||
describe('convertSurgeList', () => {
|
||||
it('classical:保留支持类型与 no-resolve,丢弃 USER-AGENT', () => {
|
||||
const input = [
|
||||
'# 注释',
|
||||
'DOMAIN,example.com',
|
||||
'DOMAIN-SUFFIX,example.org',
|
||||
'IP-CIDR,10.0.0.0/8,no-resolve',
|
||||
'USER-AGENT,Foo*',
|
||||
'URL-REGEX,^http://bad',
|
||||
].join('\n');
|
||||
const result = convertSurgeList(input, 'classical');
|
||||
expect(YAML.parse(result.yaml).payload).toEqual([
|
||||
'DOMAIN,example.com',
|
||||
'DOMAIN-SUFFIX,example.org',
|
||||
'IP-CIDR,10.0.0.0/8,no-resolve',
|
||||
]);
|
||||
expect(result.dropped).toHaveLength(2);
|
||||
expect(result.total).toBe(5);
|
||||
});
|
||||
|
||||
it('domain(domainset):前导点转 +.', () => {
|
||||
const result = convertSurgeList('example.com\n.cdn.example.com', 'domain');
|
||||
expect(YAML.parse(result.yaml).payload).toEqual(['example.com', '+.cdn.example.com']);
|
||||
});
|
||||
});
|
||||
65
server/src/services/rulesets/convert.ts
Normal file
65
server/src/services/rulesets/convert.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import YAML from 'yaml';
|
||||
|
||||
/** mihomo classical provider 支持的规则类型 */
|
||||
const CLASSICAL_TYPES = new Set([
|
||||
'DOMAIN',
|
||||
'DOMAIN-SUFFIX',
|
||||
'DOMAIN-KEYWORD',
|
||||
'DOMAIN-REGEX',
|
||||
'IP-CIDR',
|
||||
'IP-CIDR6',
|
||||
'IP-ASN',
|
||||
'GEOIP',
|
||||
'PROCESS-NAME',
|
||||
'PROCESS-PATH',
|
||||
'DST-PORT',
|
||||
'SRC-PORT',
|
||||
]);
|
||||
|
||||
export interface ConvertResult {
|
||||
yaml: string;
|
||||
total: number;
|
||||
converted: number;
|
||||
dropped: string[]; // 被丢弃的行(Clash 不支持的类型)
|
||||
}
|
||||
|
||||
/**
|
||||
* Surge .list/.conf 规则集 → Clash rule-provider yaml。
|
||||
* behavior=domain 时按 domainset 处理(每行一个域名,.former 前缀转 +.)。
|
||||
*/
|
||||
export function convertSurgeList(content: string, behavior: 'classical' | 'domain' | 'ipcidr'): ConvertResult {
|
||||
const lines = content
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('#') && !l.startsWith('//') && !l.startsWith(';'));
|
||||
|
||||
const payload: string[] = [];
|
||||
const dropped: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (behavior === 'domain') {
|
||||
// Surge domainset:`example.com` 或 `.example.com`(含子域)→ Clash `example.com` / `+.example.com`
|
||||
payload.push(line.startsWith('.') ? `+${line}` : line);
|
||||
continue;
|
||||
}
|
||||
if (behavior === 'ipcidr') {
|
||||
payload.push(line);
|
||||
continue;
|
||||
}
|
||||
const parts = line.split(',').map((p) => p.trim());
|
||||
const type = parts[0];
|
||||
if (!CLASSICAL_TYPES.has(type)) {
|
||||
dropped.push(line);
|
||||
continue;
|
||||
}
|
||||
const params = parts.slice(2).filter((p) => p === 'no-resolve');
|
||||
payload.push([type, parts[1], ...params].join(','));
|
||||
}
|
||||
|
||||
return {
|
||||
yaml: YAML.stringify({ payload }, { lineWidth: 0 }),
|
||||
total: lines.length,
|
||||
converted: payload.length,
|
||||
dropped,
|
||||
};
|
||||
}
|
||||
48
server/src/services/rulesets/fetch.ts
Normal file
48
server/src/services/rulesets/fetch.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import { db } from '../../db/index.js';
|
||||
import { rulesetCache } from '../../db/schema.js';
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// 规则集拉取一律直连(显式 Agent,隔离环境注入的 HTTPS_PROXY)
|
||||
function dispatcher() {
|
||||
return new Agent();
|
||||
}
|
||||
|
||||
export interface FetchResult {
|
||||
content: string;
|
||||
fetchedAt: number;
|
||||
stale: boolean; // 拉取失败时回落陈旧缓存
|
||||
}
|
||||
|
||||
export async function fetchRulesetContent(url: string, opts: { refresh?: boolean } = {}): Promise<FetchResult> {
|
||||
const cached = db.select().from(rulesetCache).where(eq(rulesetCache.url, url)).get();
|
||||
const fresh = cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS;
|
||||
if (cached && fresh && !opts.refresh) {
|
||||
return { content: cached.content, fetchedAt: cached.fetchedAt, stale: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { 'user-agent': 'proxy-station/0.1' };
|
||||
if (cached?.etag) headers['if-none-match'] = cached.etag;
|
||||
const res = await undiciFetch(url, { dispatcher: dispatcher(), headers });
|
||||
if (res.status === 304 && cached) {
|
||||
const fetchedAt = Date.now();
|
||||
db.update(rulesetCache).set({ fetchedAt }).where(eq(rulesetCache.url, url)).run();
|
||||
return { content: cached.content, fetchedAt, stale: false };
|
||||
}
|
||||
if (res.status !== 200) throw new Error(`HTTP ${res.status}`);
|
||||
const content = await res.text();
|
||||
const etag = res.headers.get('etag');
|
||||
const fetchedAt = Date.now();
|
||||
db.insert(rulesetCache)
|
||||
.values({ url, content, etag, fetchedAt })
|
||||
.onConflictDoUpdate({ target: rulesetCache.url, set: { content, etag, fetchedAt } })
|
||||
.run();
|
||||
return { content, fetchedAt, stale: false };
|
||||
} catch (e) {
|
||||
if (cached) return { content: cached.content, fetchedAt: cached.fetchedAt, stale: true };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
53
server/src/services/rulesets/mapping.ts
Normal file
53
server/src/services/rulesets/mapping.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Surge 规则集 URL → Clash 等价 URL 的静态映射。
|
||||
* 映射不到的返回 needsConvert=true,走自托管兜底转换端点。
|
||||
*/
|
||||
|
||||
export interface RulesetMapping {
|
||||
clashUrl: string | null;
|
||||
clashBehavior: 'classical' | 'domain' | 'ipcidr';
|
||||
clashFormat: 'yaml' | 'text';
|
||||
needsConvert: boolean;
|
||||
}
|
||||
|
||||
export function mapRulesetUrl(surgeUrl: string, ruleType: 'RULE-SET' | 'DOMAIN-SET'): RulesetMapping {
|
||||
// skk.moe:List/{non_ip|ip|domainset}/{name}.conf → Clash/{同路径}/{name}.txt
|
||||
const skk = surgeUrl.match(/^https:\/\/ruleset\.skk\.moe\/List\/(non_ip|ip|domainset)\/([\w-]+)\.conf$/);
|
||||
if (skk) {
|
||||
const [, dir, name] = skk;
|
||||
return {
|
||||
clashUrl: `https://ruleset.skk.moe/Clash/${dir}/${name}.txt`,
|
||||
clashBehavior: dir === 'domainset' ? 'domain' : 'classical',
|
||||
clashFormat: 'text',
|
||||
needsConvert: false,
|
||||
};
|
||||
}
|
||||
|
||||
// blackmatrix7:rule/Surge/{Name}/{Name}.list → rule/Clash/{Name}/{Name}.yaml
|
||||
const bm7 = surgeUrl.match(
|
||||
/^https:\/\/raw\.githubusercontent\.com\/blackmatrix7\/ios_rule_script\/master\/rule\/Surge\/([\w-]+)\/([\w-]+)\.list$/
|
||||
);
|
||||
if (bm7) {
|
||||
const [, dir, name] = bm7;
|
||||
return {
|
||||
clashUrl: `https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/${dir}/${name}.yaml`,
|
||||
clashBehavior: 'classical',
|
||||
clashFormat: 'yaml',
|
||||
needsConvert: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 其余来源(zxfccmm4/Profiles、Semporia/TikTok-Unlock 等):走兜底转换
|
||||
return {
|
||||
clashUrl: null,
|
||||
clashBehavior: ruleType === 'DOMAIN-SET' ? 'domain' : 'classical',
|
||||
clashFormat: 'yaml',
|
||||
needsConvert: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 从 URL 提取展示名:最后一段文件名去扩展名 */
|
||||
export function rulesetNameFromUrl(url: string): string {
|
||||
const last = url.split('/').pop() || url;
|
||||
return last.replace(/\.(list|conf|txt|yaml)$/, '');
|
||||
}
|
||||
44
server/src/services/sharelink/hysteria2.ts
Normal file
44
server/src/services/sharelink/hysteria2.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { inferTags, readInsecure } from './util.js';
|
||||
import type { ParsedShareLink } from './types.js';
|
||||
|
||||
// hysteria2://auth@host:port?sni=s&insecure=1&obfs=salamander&obfs-password=x#name
|
||||
// hy2:// 是等价别名
|
||||
export function parseHysteria2(uri: string): ParsedShareLink {
|
||||
const url = new URL(uri);
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port || '443', 10);
|
||||
const user = decodeURIComponent(url.username);
|
||||
const pass = decodeURIComponent(url.password);
|
||||
const auth = pass ? `${user}:${pass}` : user;
|
||||
if (!server || !auth) throw new Error('hysteria2 链接缺少 host/auth');
|
||||
const name = decodeURIComponent(url.hash.slice(1)) || 'Hysteria2';
|
||||
const params = url.searchParams;
|
||||
const obfs = params.get('obfs');
|
||||
|
||||
return {
|
||||
name,
|
||||
protocol: 'hysteria2',
|
||||
server,
|
||||
port,
|
||||
network: 'tcp',
|
||||
tls: true,
|
||||
sni: params.get('sni') || null,
|
||||
skipCertVerify: readInsecure(params) || !!(params.get('pinSHA256') || params.get('pinsha256')),
|
||||
password: auth,
|
||||
obfsType: obfs === 'salamander' ? 'salamander' : null,
|
||||
obfsPassword: params.get('obfs-password') || params.get('obfs_password') || null,
|
||||
tags: inferTags(name),
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeHysteria2(n: ParsedShareLink): string {
|
||||
const params = new URLSearchParams();
|
||||
if (n.sni) params.set('sni', n.sni);
|
||||
if (n.skipCertVerify) params.set('insecure', '1');
|
||||
if (n.obfsType === 'salamander' && n.obfsPassword) {
|
||||
params.set('obfs', 'salamander');
|
||||
params.set('obfs-password', n.obfsPassword);
|
||||
}
|
||||
const qs = params.toString();
|
||||
return `hysteria2://${encodeURIComponent(n.password || '')}@${n.server}:${n.port}${qs ? `?${qs}` : ''}#${encodeURIComponent(n.name)}`;
|
||||
}
|
||||
167
server/src/services/sharelink/parse.test.ts
Normal file
167
server/src/services/sharelink/parse.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { encodeShareLink, parseShareLink } from './parse.js';
|
||||
|
||||
const PLACEHOLDER_UUID = '00000000-1111-2222-3333-444444444444';
|
||||
const PLACEHOLDER_PASS = 'placeholder-password';
|
||||
|
||||
describe('vmess', () => {
|
||||
const json = {
|
||||
v: '2',
|
||||
ps: 'Edge Node XX-vmess-direct',
|
||||
add: 'cc.xx.example.com',
|
||||
port: '443',
|
||||
id: PLACEHOLDER_UUID,
|
||||
aid: '0',
|
||||
scy: 'auto',
|
||||
net: 'ws',
|
||||
type: 'none',
|
||||
host: 'cc.xx.example.com',
|
||||
path: '/abc123',
|
||||
tls: 'tls',
|
||||
sni: 'cc.xx.example.com',
|
||||
alpn: '',
|
||||
};
|
||||
const uri = 'vmess://' + Buffer.from(JSON.stringify(json)).toString('base64');
|
||||
|
||||
it('解析 edge-nodes 交付格式', () => {
|
||||
const n = parseShareLink(uri);
|
||||
expect(n).toMatchObject({
|
||||
protocol: 'vmess',
|
||||
server: 'cc.xx.example.com',
|
||||
port: 443,
|
||||
network: 'ws',
|
||||
wsPath: '/abc123',
|
||||
wsHost: 'cc.xx.example.com',
|
||||
tls: true,
|
||||
sni: 'cc.xx.example.com',
|
||||
uuid: PLACEHOLDER_UUID,
|
||||
alterId: 0,
|
||||
tags: ['direct'],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trip 一致', () => {
|
||||
const n = parseShareLink(uri);
|
||||
expect(parseShareLink(encodeShareLink(n))).toEqual(n);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trojan', () => {
|
||||
const uri = `trojan://${PLACEHOLDER_PASS}@cc.xx.example.com:443?security=tls&type=ws&host=cc.xx.example.com&path=%2F4688c6&sni=cc.xx.example.com#Edge%20Node%20XX-trojan-warp-clean-exit`;
|
||||
|
||||
it('解析 edge-nodes 交付格式', () => {
|
||||
const n = parseShareLink(uri);
|
||||
expect(n).toMatchObject({
|
||||
protocol: 'trojan',
|
||||
server: 'cc.xx.example.com',
|
||||
port: 443,
|
||||
network: 'ws',
|
||||
wsPath: '/4688c6',
|
||||
wsHost: 'cc.xx.example.com',
|
||||
tls: true,
|
||||
password: PLACEHOLDER_PASS,
|
||||
tags: ['warp'],
|
||||
});
|
||||
expect(n.name).toBe('Edge Node XX-trojan-warp-clean-exit');
|
||||
});
|
||||
|
||||
it('round-trip 一致', () => {
|
||||
const n = parseShareLink(uri);
|
||||
expect(parseShareLink(encodeShareLink(n))).toEqual(n);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shadowsocks', () => {
|
||||
const userinfo = Buffer.from(`chacha20-poly1305:${PLACEHOLDER_PASS}`).toString('base64url');
|
||||
const plugin = encodeURIComponent('v2ray-plugin;tls;mode=websocket;host=cc.xx.example.com;path=/22a3d7');
|
||||
const uri = `ss://${userinfo}@cc.xx.example.com:443?plugin=${plugin}#ss-direct`;
|
||||
|
||||
it('解析 SIP002 + v2ray-plugin(SS over WS+TLS)', () => {
|
||||
const n = parseShareLink(uri);
|
||||
expect(n).toMatchObject({
|
||||
protocol: 'shadowsocks',
|
||||
server: 'cc.xx.example.com',
|
||||
port: 443,
|
||||
network: 'ws',
|
||||
wsPath: '/22a3d7',
|
||||
wsHost: 'cc.xx.example.com',
|
||||
tls: true,
|
||||
method: 'chacha20-poly1305',
|
||||
password: PLACEHOLDER_PASS,
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trip 一致', () => {
|
||||
const n = parseShareLink(uri);
|
||||
expect(parseShareLink(encodeShareLink(n))).toEqual(n);
|
||||
});
|
||||
|
||||
it('解析纯 TCP 的 SIP002', () => {
|
||||
const n = parseShareLink(`ss://${userinfo}@1.2.3.4:8388#plain`);
|
||||
expect(n).toMatchObject({ network: 'tcp', tls: false, server: '1.2.3.4', port: 8388 });
|
||||
});
|
||||
|
||||
it('解析 Xray 风格参数(3x-ui 导出格式)', () => {
|
||||
const uri = `ss://${userinfo}@cc.xx.example.com:443?type=ws&security=tls&path=%2F22a3d7&sni=cc.xx.example.com#ss-warp`;
|
||||
expect(parseShareLink(uri)).toMatchObject({
|
||||
network: 'ws',
|
||||
wsPath: '/22a3d7',
|
||||
wsHost: 'cc.xx.example.com',
|
||||
tls: true,
|
||||
sni: 'cc.xx.example.com',
|
||||
method: 'chacha20-poly1305',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hysteria2', () => {
|
||||
const uri = `hysteria2://${PLACEHOLDER_PASS}@hy2.xx.example.com:443?sni=hy2.xx.example.com&obfs=salamander&obfs-password=obfs-placeholder#hy2-direct`;
|
||||
|
||||
it('解析含 salamander obfs 的链接', () => {
|
||||
const n = parseShareLink(uri);
|
||||
expect(n).toMatchObject({
|
||||
protocol: 'hysteria2',
|
||||
server: 'hy2.xx.example.com',
|
||||
port: 443,
|
||||
tls: true,
|
||||
password: PLACEHOLDER_PASS,
|
||||
obfsType: 'salamander',
|
||||
obfsPassword: 'obfs-placeholder',
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trip 一致', () => {
|
||||
const n = parseShareLink(uri);
|
||||
expect(parseShareLink(encodeShareLink(n))).toEqual(n);
|
||||
});
|
||||
|
||||
it('hy2:// 别名可解析', () => {
|
||||
expect(parseShareLink(uri.replace('hysteria2://', 'hy2://')).protocol).toBe('hysteria2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('节点命名规范', () => {
|
||||
// 与 web/src/utils/nodeName.ts 的 TRAILING_EGRESS 保持一致
|
||||
const strip = (name: string) => name.replace(/[\s·・_-]+(direct|warp|直连)\s*$/iu, '').trim();
|
||||
|
||||
it('界面显示名剥掉末尾出口词', () => {
|
||||
expect(strip('🇺🇸 US LA Trojan Warp')).toBe('🇺🇸 US LA Trojan');
|
||||
expect(strip('🇸🇬 SG VMess Direct')).toBe('🇸🇬 SG VMess');
|
||||
expect(strip('🇸🇬 SG · HY2 · 直连')).toBe('🇸🇬 SG · HY2'); // 兼容旧格式
|
||||
expect(strip('无出口词的节点')).toBe('无出口词的节点');
|
||||
});
|
||||
|
||||
it('新命名仍能被地区组正则命中', () => {
|
||||
const sg = /坡|🇸🇬|新加坡|狮城|SG|Singapore/u;
|
||||
const us = /美|🇺🇸|美国|US|States|American/u;
|
||||
expect(sg.test('🇸🇬 SG VMess Direct')).toBe(true);
|
||||
expect(us.test('🇺🇸 US RN HY2 Warp')).toBe(true);
|
||||
expect(sg.test('🇺🇸 US LA Trojan Warp')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('批量与异常', () => {
|
||||
it('未知协议抛错', () => {
|
||||
expect(() => parseShareLink('vless://x@y:1')).toThrow();
|
||||
});
|
||||
});
|
||||
44
server/src/services/sharelink/parse.ts
Normal file
44
server/src/services/sharelink/parse.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { parseVmess, encodeVmess } from './vmess.js';
|
||||
import { parseTrojan, encodeTrojan } from './trojan.js';
|
||||
import { parseSs, encodeSs } from './ss.js';
|
||||
import { parseHysteria2, encodeHysteria2 } from './hysteria2.js';
|
||||
import type { ParsedShareLink } from './types.js';
|
||||
|
||||
export type { ParsedShareLink };
|
||||
|
||||
export function parseShareLink(uri: string): ParsedShareLink {
|
||||
const s = uri.trim();
|
||||
if (s.startsWith('vmess://')) return parseVmess(s);
|
||||
if (s.startsWith('trojan://')) return parseTrojan(s);
|
||||
if (s.startsWith('ss://')) return parseSs(s);
|
||||
if (s.startsWith('hysteria2://') || s.startsWith('hy2://')) return parseHysteria2(s);
|
||||
throw new Error('不支持的链接协议(支持 vmess:// trojan:// ss:// hysteria2://)');
|
||||
}
|
||||
|
||||
export function encodeShareLink(n: ParsedShareLink): string {
|
||||
switch (n.protocol) {
|
||||
case 'vmess':
|
||||
return encodeVmess(n);
|
||||
case 'trojan':
|
||||
return encodeTrojan(n);
|
||||
case 'shadowsocks':
|
||||
return encodeSs(n);
|
||||
case 'hysteria2':
|
||||
return encodeHysteria2(n);
|
||||
}
|
||||
}
|
||||
|
||||
/** 多行文本 → 逐行解析(跳过空行与注释行) */
|
||||
export function parseShareLinkBatch(text: string): { uri: string; node?: ParsedShareLink; error?: string }[] {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('#') && !l.startsWith('//'))
|
||||
.map((uri) => {
|
||||
try {
|
||||
return { uri, node: parseShareLink(uri) };
|
||||
} catch (e: any) {
|
||||
return { uri, error: e.message || '解析失败' };
|
||||
}
|
||||
});
|
||||
}
|
||||
102
server/src/services/sharelink/ss.ts
Normal file
102
server/src/services/sharelink/ss.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { b64decode, inferTags, normalizeWsPath } from './util.js';
|
||||
import type { ParsedShareLink } from './types.js';
|
||||
|
||||
// SIP002: ss://base64url(method:password)@host:port?plugin=v2ray-plugin%3Btls%3Bmode%3Dwebsocket%3Bpath%3D%2Fx%3Bhost%3Dh#name
|
||||
// 旧格式: ss://base64(method:password@host:port)#name
|
||||
export function parseSs(uri: string): ParsedShareLink {
|
||||
const url = new URL(uri);
|
||||
let method: string;
|
||||
let password: string;
|
||||
let server: string;
|
||||
let port: number;
|
||||
|
||||
if (url.username) {
|
||||
const decoded = b64decode(decodeURIComponent(url.username));
|
||||
const idx = decoded.indexOf(':');
|
||||
if (idx === -1) throw new Error('ss userinfo 缺少 method:password');
|
||||
method = decoded.slice(0, idx);
|
||||
password = decoded.slice(idx + 1);
|
||||
server = url.hostname;
|
||||
port = parseInt(url.port, 10);
|
||||
} else {
|
||||
// 整体 base64 的旧格式
|
||||
const decoded = b64decode(uri.slice('ss://'.length).split('#')[0]);
|
||||
const at = decoded.lastIndexOf('@');
|
||||
if (at === -1) throw new Error('无法解析 ss 链接');
|
||||
const [m, ...rest] = decoded.slice(0, at).split(':');
|
||||
method = m;
|
||||
password = rest.join(':');
|
||||
const hostPort = decoded.slice(at + 1);
|
||||
const colon = hostPort.lastIndexOf(':');
|
||||
server = hostPort.slice(0, colon);
|
||||
port = parseInt(hostPort.slice(colon + 1), 10);
|
||||
}
|
||||
if (!server || !port || !method) throw new Error('ss 链接缺少 server/port/method');
|
||||
|
||||
const name = decodeURIComponent(url.hash.slice(1)) || 'Shadowsocks';
|
||||
const node: ParsedShareLink = {
|
||||
name,
|
||||
protocol: 'shadowsocks',
|
||||
server,
|
||||
port,
|
||||
network: 'tcp',
|
||||
tls: false,
|
||||
method,
|
||||
password,
|
||||
tags: inferTags(name),
|
||||
};
|
||||
|
||||
// Xray 风格参数(3x-ui 导出即此形式):type=ws&security=tls&path=&host=&sni=
|
||||
const params = url.searchParams;
|
||||
if (params.get('type') === 'ws') {
|
||||
node.network = 'ws';
|
||||
node.wsPath = normalizeWsPath(params.get('path'));
|
||||
node.wsHost = params.get('host') || params.get('sni') || null;
|
||||
}
|
||||
if (params.get('security') === 'tls') {
|
||||
node.tls = true;
|
||||
node.sni = params.get('sni') || node.wsHost || null;
|
||||
}
|
||||
|
||||
// v2ray-plugin 的 websocket/tls 参数(ShadowRocket、ClashMeta 通用写法)
|
||||
const plugin = params.get('plugin');
|
||||
if (plugin && plugin.includes('v2ray-plugin')) {
|
||||
const opts = new Map(
|
||||
plugin
|
||||
.split(';')
|
||||
.slice(1)
|
||||
.map((kv) => {
|
||||
const eq = kv.indexOf('=');
|
||||
return eq === -1 ? [kv, 'true'] : [kv.slice(0, eq), kv.slice(eq + 1)];
|
||||
}) as [string, string][]
|
||||
);
|
||||
if (opts.get('mode') === 'websocket') {
|
||||
node.network = 'ws';
|
||||
node.wsPath = normalizeWsPath(opts.get('path'));
|
||||
node.wsHost = opts.get('host') || null;
|
||||
}
|
||||
if (opts.has('tls')) {
|
||||
node.tls = true;
|
||||
node.sni = opts.get('host') || null;
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
export function encodeSs(n: ParsedShareLink): string {
|
||||
const userinfo = Buffer.from(`${n.method}:${n.password}`, 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
let uri = `ss://${userinfo}@${n.server}:${n.port}`;
|
||||
if (n.network === 'ws') {
|
||||
const parts = ['v2ray-plugin'];
|
||||
if (n.tls) parts.push('tls');
|
||||
parts.push('mode=websocket');
|
||||
if (n.wsHost) parts.push(`host=${n.wsHost}`);
|
||||
if (n.wsPath) parts.push(`path=${n.wsPath}`);
|
||||
uri += `?plugin=${encodeURIComponent(parts.join(';'))}`;
|
||||
}
|
||||
return `${uri}#${encodeURIComponent(n.name)}`;
|
||||
}
|
||||
41
server/src/services/sharelink/trojan.ts
Normal file
41
server/src/services/sharelink/trojan.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { inferTags, normalizeWsPath, readInsecure } from './util.js';
|
||||
import type { ParsedShareLink } from './types.js';
|
||||
|
||||
// trojan://password@host:port?security=tls&type=ws&host=h&path=%2Fx&sni=s#name
|
||||
export function parseTrojan(uri: string): ParsedShareLink {
|
||||
const url = new URL(uri);
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port || '443', 10);
|
||||
const password = decodeURIComponent(url.username);
|
||||
if (!server || !password) throw new Error('trojan 链接缺少 host/password');
|
||||
const name = decodeURIComponent(url.hash.slice(1)) || 'Trojan';
|
||||
const params = url.searchParams;
|
||||
const network = params.get('type') === 'ws' ? 'ws' : 'tcp';
|
||||
|
||||
return {
|
||||
name,
|
||||
protocol: 'trojan',
|
||||
server,
|
||||
port,
|
||||
network,
|
||||
wsPath: network === 'ws' ? normalizeWsPath(params.get('path')) : null,
|
||||
wsHost: network === 'ws' ? params.get('host') : null,
|
||||
tls: true,
|
||||
sni: params.get('sni') || server,
|
||||
skipCertVerify: readInsecure(params),
|
||||
password,
|
||||
tags: inferTags(name),
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeTrojan(n: ParsedShareLink): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('security', 'tls');
|
||||
if (n.network === 'ws') {
|
||||
params.set('type', 'ws');
|
||||
if (n.wsHost) params.set('host', n.wsHost);
|
||||
if (n.wsPath) params.set('path', n.wsPath);
|
||||
}
|
||||
if (n.sni) params.set('sni', n.sni);
|
||||
return `trojan://${encodeURIComponent(n.password || '')}@${n.server}:${n.port}?${params.toString()}#${encodeURIComponent(n.name)}`;
|
||||
}
|
||||
23
server/src/services/sharelink/types.ts
Normal file
23
server/src/services/sharelink/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Protocol } from '@proxy-station/shared';
|
||||
|
||||
/** 分享链接解析出的节点字段(与 nodeInputSchema 对齐,未落库) */
|
||||
export interface ParsedShareLink {
|
||||
name: string;
|
||||
protocol: Protocol;
|
||||
server: string;
|
||||
port: number;
|
||||
network: 'tcp' | 'ws';
|
||||
wsPath?: string | null;
|
||||
wsHost?: string | null;
|
||||
tls: boolean;
|
||||
sni?: string | null;
|
||||
skipCertVerify?: boolean;
|
||||
uuid?: string | null;
|
||||
alterId?: number;
|
||||
security?: string;
|
||||
password?: string | null;
|
||||
method?: string | null;
|
||||
obfsType?: 'salamander' | null;
|
||||
obfsPassword?: string | null;
|
||||
tags?: string[];
|
||||
}
|
||||
27
server/src/services/sharelink/util.ts
Normal file
27
server/src/services/sharelink/util.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export function b64decode(s: string): string {
|
||||
return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
export function b64encode(s: string): string {
|
||||
return Buffer.from(s, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
/** WS path 统一保证以 / 开头 */
|
||||
export function normalizeWsPath(p: string | null | undefined): string | null {
|
||||
if (!p) return null;
|
||||
return p.startsWith('/') ? p : `/${p}`;
|
||||
}
|
||||
|
||||
export function readInsecure(params: URLSearchParams): boolean {
|
||||
const v = params.get('insecure') || params.get('allowInsecure') || params.get('skip-cert-verify');
|
||||
return v === '1' || v === 'true';
|
||||
}
|
||||
|
||||
/** 从节点名推断出口 tag(warp/direct),供前端线路矩阵归位 */
|
||||
export function inferTags(name: string): string[] {
|
||||
const lower = name.toLowerCase();
|
||||
const tags: string[] = [];
|
||||
if (lower.includes('warp')) tags.push('warp');
|
||||
else if (lower.includes('direct')) tags.push('direct');
|
||||
return tags;
|
||||
}
|
||||
50
server/src/services/sharelink/vmess.ts
Normal file
50
server/src/services/sharelink/vmess.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { b64decode, inferTags, normalizeWsPath } from './util.js';
|
||||
import type { ParsedShareLink } from './types.js';
|
||||
|
||||
// vmess://base64({"v":"2","ps":name,"add":host,"port":"443","id":uuid,"aid":"0",
|
||||
// "scy":"auto","net":"ws","type":"none","host":wsHost,"path":"/x","tls":"tls","sni":sni})
|
||||
export function parseVmess(uri: string): ParsedShareLink {
|
||||
const json = JSON.parse(b64decode(uri.slice('vmess://'.length)));
|
||||
const name = json.ps || 'VMess';
|
||||
const server = String(json.add || '');
|
||||
const port = parseInt(String(json.port || '0'), 10);
|
||||
const uuid = String(json.id || '');
|
||||
if (!server || !port || !uuid) throw new Error('vmess 链接缺少 add/port/id');
|
||||
|
||||
const network = json.net === 'ws' ? 'ws' : 'tcp';
|
||||
return {
|
||||
name,
|
||||
protocol: 'vmess',
|
||||
server,
|
||||
port,
|
||||
network,
|
||||
wsPath: network === 'ws' ? normalizeWsPath(json.path) : null,
|
||||
wsHost: network === 'ws' ? json.host || null : null,
|
||||
tls: json.tls === 'tls',
|
||||
sni: json.sni || null,
|
||||
uuid,
|
||||
alterId: parseInt(String(json.aid ?? '0'), 10) || 0,
|
||||
security: json.scy || 'auto',
|
||||
tags: inferTags(name),
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeVmess(n: ParsedShareLink): string {
|
||||
const json: Record<string, string> = {
|
||||
v: '2',
|
||||
ps: n.name,
|
||||
add: n.server,
|
||||
port: String(n.port),
|
||||
id: n.uuid || '',
|
||||
aid: String(n.alterId ?? 0),
|
||||
scy: n.security || 'auto',
|
||||
net: n.network === 'ws' ? 'ws' : 'tcp',
|
||||
type: 'none',
|
||||
host: n.wsHost || '',
|
||||
path: n.wsPath || '',
|
||||
tls: n.tls ? 'tls' : '',
|
||||
sni: n.sni || '',
|
||||
alpn: '',
|
||||
};
|
||||
return 'vmess://' + Buffer.from(JSON.stringify(json)).toString('base64');
|
||||
}
|
||||
55
server/src/services/template/surge-parser.test.ts
Normal file
55
server/src/services/template/surge-parser.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSurgeConf, splitTopLevel } from './surge-parser.js';
|
||||
|
||||
describe('splitTopLevel', () => {
|
||||
it('引号内逗号不切分', () => {
|
||||
expect(splitTopLevel('select, "Hong Kong", Taiwan')).toEqual(['select', '"Hong Kong"', 'Taiwan']);
|
||||
});
|
||||
it('括号内逗号不切分(AND 复合规则)', () => {
|
||||
expect(splitTopLevel('AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP')).toEqual([
|
||||
'AND',
|
||||
'((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com))',
|
||||
'REJECT-NO-DROP',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSurgeConf', () => {
|
||||
const conf = `
|
||||
[General]
|
||||
ipv6 = false
|
||||
|
||||
[Proxy Group]
|
||||
Auto = url-test, include-other-group=All, policy-regex-filter=SG|Singapore, url=http://x/gen, interval=300, tolerance=50, icon-url=http://icon
|
||||
Pick = select, Auto, "Hong Kong", DIRECT
|
||||
All = select, policy-path="placeholder", hidden=0
|
||||
|
||||
[Rule]
|
||||
RULE-SET,https://example.com/a.list,Pick
|
||||
DOMAIN-SUFFIX,example.com,"Hong Kong",no-resolve
|
||||
FINAL,Pick,dns-failed
|
||||
|
||||
[MITM]
|
||||
hostname = *.example.com
|
||||
`;
|
||||
const parsed = parseSurgeConf(conf);
|
||||
|
||||
it('组解析:类型、成员、regex、include-other-group', () => {
|
||||
const auto = parsed.groups.find((g) => g.name === 'Auto')!;
|
||||
expect(auto).toMatchObject({ type: 'url-test', filterRegex: 'SG|Singapore', includeOtherGroup: 'All', interval: 300, tolerance: 50 });
|
||||
const pick = parsed.groups.find((g) => g.name === 'Pick')!;
|
||||
expect(pick.members).toEqual(['Auto', 'Hong Kong', 'DIRECT']);
|
||||
expect(parsed.groups.find((g) => g.name === 'All')!.hasPolicyPath).toBe(true);
|
||||
});
|
||||
|
||||
it('规则解析:RULE-SET、带引号策略、FINAL 参数', () => {
|
||||
expect(parsed.rules[0]).toMatchObject({ type: 'RULE-SET', value: 'https://example.com/a.list', policy: 'Pick' });
|
||||
expect(parsed.rules[1]).toMatchObject({ type: 'DOMAIN-SUFFIX', policy: 'Hong Kong', params: ['no-resolve'] });
|
||||
expect(parsed.rules[2]).toMatchObject({ type: 'FINAL', policy: 'Pick', params: ['dns-failed'] });
|
||||
});
|
||||
|
||||
it('其他段落原文保留', () => {
|
||||
expect(parsed.sections['MITM']).toBe('hostname = *.example.com');
|
||||
expect(parsed.sections['General']).toBe('ipv6 = false');
|
||||
});
|
||||
});
|
||||
159
server/src/services/template/surge-parser.ts
Normal file
159
server/src/services/template/surge-parser.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 解析 Surge .conf 模板:分段原文 + [Proxy Group] / [Rule] 的结构化结果。
|
||||
* 只解析本项目建模所需的字段,未识别的组参数原样忽略。
|
||||
*/
|
||||
|
||||
export interface ParsedGroup {
|
||||
name: string;
|
||||
type: 'select' | 'url-test' | 'fallback';
|
||||
members: string[]; // 组名或 DIRECT/REJECT,顺序保留
|
||||
testUrl?: string;
|
||||
interval?: number;
|
||||
tolerance?: number;
|
||||
filterRegex?: string;
|
||||
includeOtherGroup?: string;
|
||||
includeAllProxies?: boolean;
|
||||
hasPolicyPath?: boolean; // 上游订阅占位(AllServer)→ 我们的「全部节点」组
|
||||
}
|
||||
|
||||
export interface ParsedRule {
|
||||
type: string;
|
||||
value: string | null;
|
||||
policy: string;
|
||||
params: string[]; // 如 no-resolve / dns-failed / extended-matching
|
||||
}
|
||||
|
||||
export interface ParsedSurgeConf {
|
||||
sections: Record<string, string>; // 段名(不含中括号)→ 原文
|
||||
groups: ParsedGroup[];
|
||||
rules: ParsedRule[];
|
||||
}
|
||||
|
||||
/** 按逗号切分,但忽略引号与括号内部的逗号 */
|
||||
export function splitTopLevel(line: string): string[] {
|
||||
const out: string[] = [];
|
||||
let cur = '';
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (const ch of line) {
|
||||
if (quote) {
|
||||
cur += ch;
|
||||
if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
cur += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') depth++;
|
||||
if (ch === ')') depth--;
|
||||
if (ch === ',' && depth === 0) {
|
||||
out.push(cur.trim());
|
||||
cur = '';
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
if (cur.trim()) out.push(cur.trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
function unquote(s: string): string {
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const KNOWN_GROUP_TYPES = new Set(['select', 'url-test', 'fallback']);
|
||||
/** 这些参数不建模,序列化 Surge 时也不透传(icon 等纯装饰) */
|
||||
const RULE_PARAM_WHITELIST = new Set(['no-resolve', 'dns-failed', 'extended-matching', 'force-remote-dns']);
|
||||
|
||||
export function parseSurgeConf(text: string): ParsedSurgeConf {
|
||||
const sections: Record<string, string[]> = {};
|
||||
let current = '';
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const trimmed = raw.trim();
|
||||
const m = trimmed.match(/^\[(.+)\]$/);
|
||||
if (m) {
|
||||
current = m[1];
|
||||
sections[current] = [];
|
||||
continue;
|
||||
}
|
||||
if (current) sections[current].push(raw);
|
||||
}
|
||||
|
||||
const groups: ParsedGroup[] = [];
|
||||
for (const raw of sections['Proxy Group'] ?? []) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
|
||||
const eq = line.indexOf('=');
|
||||
if (eq === -1) continue;
|
||||
const name = line.slice(0, eq).trim();
|
||||
const parts = splitTopLevel(line.slice(eq + 1));
|
||||
const type = parts[0];
|
||||
if (!KNOWN_GROUP_TYPES.has(type)) continue;
|
||||
|
||||
const group: ParsedGroup = { name, type: type as ParsedGroup['type'], members: [] };
|
||||
for (const part of parts.slice(1)) {
|
||||
const kvEq = part.indexOf('=');
|
||||
// 无 = 的项是成员(可能带引号)
|
||||
if (kvEq === -1 || part.startsWith('"') || part.startsWith("'")) {
|
||||
group.members.push(unquote(part));
|
||||
continue;
|
||||
}
|
||||
const key = part.slice(0, kvEq).trim();
|
||||
const value = unquote(part.slice(kvEq + 1).trim());
|
||||
switch (key) {
|
||||
case 'url':
|
||||
group.testUrl = value;
|
||||
break;
|
||||
case 'interval':
|
||||
group.interval = parseInt(value, 10) || undefined;
|
||||
break;
|
||||
case 'tolerance':
|
||||
group.tolerance = parseInt(value, 10) || undefined;
|
||||
break;
|
||||
case 'policy-regex-filter':
|
||||
group.filterRegex = value;
|
||||
break;
|
||||
case 'include-other-group':
|
||||
group.includeOtherGroup = value;
|
||||
break;
|
||||
case 'include-all-proxies':
|
||||
group.includeAllProxies = value === '1' || value === 'true';
|
||||
break;
|
||||
case 'policy-path':
|
||||
group.hasPolicyPath = true;
|
||||
break;
|
||||
// no-alert / hidden / icon-url / update-interval / timeout 等不建模
|
||||
}
|
||||
}
|
||||
groups.push(group);
|
||||
}
|
||||
|
||||
const rules: ParsedRule[] = [];
|
||||
for (const raw of sections['Rule'] ?? []) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
|
||||
const parts = splitTopLevel(line);
|
||||
const type = parts[0];
|
||||
if (!type) continue;
|
||||
if (type === 'FINAL') {
|
||||
rules.push({ type, value: null, policy: unquote(parts[1] ?? 'DIRECT'), params: parts.slice(2) });
|
||||
continue;
|
||||
}
|
||||
if (parts.length < 3) continue;
|
||||
const params = parts.slice(3).filter((p) => RULE_PARAM_WHITELIST.has(p));
|
||||
rules.push({ type, value: parts[1], policy: unquote(parts[2]), params });
|
||||
}
|
||||
|
||||
const rawSections: Record<string, string> = {};
|
||||
for (const [name, lines] of Object.entries(sections)) {
|
||||
if (name === 'Proxy' || name === 'Proxy Group' || name === 'Rule') continue;
|
||||
rawSections[name] = lines.join('\n').trim();
|
||||
}
|
||||
|
||||
return { sections: rawSections, groups, rules };
|
||||
}
|
||||
Reference in New Issue
Block a user