feat: clash/ss suport

This commit is contained in:
2026-04-13 22:19:30 +08:00
parent 699b038a6c
commit 35e95ee777
11 changed files with 665 additions and 129 deletions

View File

@@ -57,4 +57,14 @@ db.exec(`
);
`);
// Migration: add Clash and SSR URL/config columns to subscriptions
for (const sql of [
"ALTER TABLE subscriptions ADD COLUMN url_clash TEXT",
"ALTER TABLE subscriptions ADD COLUMN url_ssr TEXT",
"ALTER TABLE subscriptions ADD COLUMN raw_config_clash TEXT",
"ALTER TABLE subscriptions ADD COLUMN raw_config_ssr TEXT",
]) {
try { db.exec(sql); } catch { /* column already exists */ }
}
export default db;

View File

@@ -8,6 +8,8 @@ import rulesRouter from './routes/rules.js';
import surgeRouter from './routes/surge.js';
import db from './db.js';
import { generateSurgeConfig } from './services/generator.js';
import { generateClashConfig } from './services/clashGenerator.js';
import { generateSSRConfig } from './services/ssrGenerator.js';
// Ensure surge_token exists
function ensureSurgeToken(): string {
@@ -24,15 +26,17 @@ const PORT = parseInt(process.env.PORT || '3456', 10);
app.use(express.json());
function verifySurgeToken(token: string): boolean {
const row = db.prepare("SELECT value FROM config WHERE key = 'surge_token'").get() as any;
return !!row?.value && token === row.value;
}
// Surge endpoint (no auth, token-protected path)
app.get('/surge/:token', (req, res) => {
const row = db.prepare("SELECT value FROM config WHERE key = 'surge_token'").get() as any;
if (!row?.value || req.params.token !== row.value) {
return res.status(404).send('Not Found');
}
if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found');
const host = req.headers.host || 'localhost:3456';
const protocol = req.secure ? 'https' : 'http';
const hostUrl = `${protocol}://${host}/surge/${row.value}`;
const hostUrl = `${protocol}://${host}/surge/${req.params.token}`;
const config = generateSurgeConfig(hostUrl);
res.set({
'Content-Type': 'text/plain; charset=utf-8',
@@ -41,6 +45,28 @@ app.get('/surge/:token', (req, res) => {
res.send(config);
});
// Clash endpoint (no auth, token-protected path)
app.get('/clash/:token', (req, res) => {
if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found');
const config = generateClashConfig();
res.set({
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename=clash.yaml',
});
res.send(config);
});
// SSR endpoint (no auth, token-protected path)
app.get('/ssr/:token', (req, res) => {
if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found');
const config = generateSSRConfig();
res.set({
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename=proxy.txt',
});
res.send(config);
});
// Auth routes (no auth required)
app.post('/api/auth/login', (req, res) => {
const { password } = req.body;
@@ -104,7 +130,7 @@ app.get('/api/stats', (_req, res) => {
const webDist = path.join(__dirname, '..', '..', 'web', 'dist');
app.use(express.static(webDist));
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api') || req.path.startsWith('/surge')) return next();
if (req.path.startsWith('/api') || req.path.startsWith('/surge') || req.path.startsWith('/clash') || req.path.startsWith('/ssr')) return next();
res.sendFile(path.join(webDist, 'index.html'));
});

View File

@@ -0,0 +1,79 @@
/**
* Convert proxy URIs (ss://, vmess://, trojan://) to Clash YAML flow-style proxy lines.
* Returns a string like ` - {name: "NodeName", type: ss, server: ..., port: ..., ...}`
* or null if the URI is unsupported/invalid.
*/
export function uriToClashLine(uri: string): string | null {
try {
if (uri.startsWith('ss://')) return ssToClash(uri);
if (uri.startsWith('vmess://')) return vmessToClash(uri);
if (uri.startsWith('trojan://')) return trojanToClash(uri);
return null;
} catch {
return null;
}
}
/** Escape a string value for use inside double-quoted YAML flow style */
function esc(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function ssToClash(uri: string): string | null {
const url = new URL(uri);
const name = decodeURIComponent(url.hash.slice(1));
const server = url.hostname;
const port = url.port;
// userinfo is base64(method:password)
const decoded = Buffer.from(url.username, 'base64').toString();
const firstColon = decoded.indexOf(':');
if (firstColon === -1) return null;
const cipher = decoded.slice(0, firstColon);
const password = decoded.slice(firstColon + 1);
return ` - {name: "${esc(name)}", type: ss, server: ${server}, port: ${port}, cipher: ${cipher}, password: "${esc(password)}"}`;
}
function vmessToClash(uri: string): string | null {
const b64 = uri.replace('vmess://', '');
const json = JSON.parse(Buffer.from(b64, 'base64').toString());
const name = json.ps || 'VMess';
const server = json.add;
const port = json.port;
const uuid = json.id;
if (!server || !port || !uuid) return null;
let line = ` - {name: "${esc(name)}", type: vmess, server: ${server}, port: ${port}, uuid: ${uuid}, alterId: 0, cipher: auto`;
if (json.tls === 'tls') {
line += ', tls: true';
if (json.sni) line += `, sni: ${json.sni}`;
}
if (json.net === 'ws') {
line += ', network: ws';
const wsOpts: string[] = [];
if (json.path) wsOpts.push(`path: "${esc(json.path)}"`);
if (json.host) wsOpts.push(`headers: {Host: ${json.host}}`);
if (wsOpts.length > 0) {
line += `, ws-opts: {${wsOpts.join(', ')}}`;
}
}
line += '}';
return line;
}
function trojanToClash(uri: string): string | null {
const url = new URL(uri);
const name = decodeURIComponent(url.hash.slice(1));
const server = url.hostname;
const port = url.port;
const password = url.username;
const sni = url.searchParams.get('sni') || server;
return ` - {name: "${esc(name)}", type: trojan, server: ${server}, port: ${port}, password: "${esc(password)}", sni: ${sni}}`;
}

View File

@@ -12,23 +12,37 @@ router.get('/', (_req, res) => {
// POST /api/subscriptions
router.post('/', (req, res) => {
const { name, url } = req.body;
const { name, url, url_clash, url_ssr } = req.body;
if (!name || !url) {
return res.status(400).json({ error: 'name and url are required' });
}
const result = db.prepare('INSERT INTO subscriptions (name, url) VALUES (?, ?)').run(name, url);
const result = db.prepare(
'INSERT INTO subscriptions (name, url, url_clash, url_ssr) VALUES (?, ?, ?, ?)'
).run(name, url, url_clash || null, url_ssr || null);
res.json({ id: result.lastInsertRowid });
});
// PUT /api/subscriptions/:id
router.put('/:id', (req, res) => {
const { id } = req.params;
const { name, url, enabled } = req.body;
const sub = db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id);
const sub = db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id) as any;
if (!sub) return res.status(404).json({ error: 'not found' });
db.prepare('UPDATE subscriptions SET name = ?, url = ?, enabled = ? WHERE id = ?')
.run(name ?? (sub as any).name, url ?? (sub as any).url, enabled ?? (sub as any).enabled, id);
const { name, url, enabled } = req.body;
// Only update Clash/SSR URLs if explicitly included in request body
const url_clash = 'url_clash' in req.body ? (req.body.url_clash || null) : sub.url_clash;
const url_ssr = 'url_ssr' in req.body ? (req.body.url_ssr || null) : sub.url_ssr;
db.prepare(
'UPDATE subscriptions SET name = ?, url = ?, url_clash = ?, url_ssr = ?, enabled = ? WHERE id = ?'
).run(
name ?? sub.name,
url ?? sub.url,
url_clash,
url_ssr,
enabled ?? sub.enabled,
id
);
res.json({ ok: true });
});
@@ -50,11 +64,21 @@ router.post('/:id/fetch', async (req, res) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const rawConfig = await response.text();
const fetchOptional = (url: string | null) =>
url
? fetch(url).then(r => (r.ok ? r.text() : null)).catch(() => null)
: Promise.resolve(null);
const [rawClash, rawSsr] = await Promise.all([
fetchOptional(sub.url_clash),
fetchOptional(sub.url_ssr),
]);
// Save existing enabled states by node name
const existingNodes = db.prepare('SELECT name, enabled FROM fetched_nodes WHERE subscription_id = ?').all(id) as any[];
const enabledMap = new Map(existingNodes.map((n: any) => [n.name, n.enabled]));
// Parse nodes
// Parse nodes from primary Surge config
const nodes = parseSubscriptionContent(rawConfig);
// Replace nodes in transaction
@@ -67,8 +91,9 @@ router.post('/:id/fetch', async (req, res) => {
const enabled = enabledMap.get(node.name) ?? 1;
insert.run(id, node.name, node.type, node.server, node.port, node.surgeLine, enabled);
}
db.prepare('UPDATE subscriptions SET raw_config = ?, last_fetch = ?, node_count = ? WHERE id = ?')
.run(rawConfig, new Date().toISOString(), nodes.length, id);
db.prepare(
'UPDATE subscriptions SET raw_config = ?, raw_config_clash = ?, raw_config_ssr = ?, last_fetch = ?, node_count = ? WHERE id = ?'
).run(rawConfig, rawClash, rawSsr, new Date().toISOString(), nodes.length, id);
});
replace();

View File

@@ -0,0 +1,171 @@
import db from '../db.js';
import { uriToClashLine } from '../parsers/toClash.js';
export function generateClashConfig(): string {
const sub = db.prepare(
'SELECT raw_config_clash FROM subscriptions WHERE enabled = 1 AND raw_config_clash IS NOT NULL ORDER BY id LIMIT 1'
).get() as any;
if (!sub?.raw_config_clash) {
return '# No Clash config available. Add a subscription with a Clash URL and fetch it.';
}
const staticNodes = db.prepare(
'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
).all() as any[];
if (staticNodes.length === 0) {
return sub.raw_config_clash;
}
const clashLines = staticNodes
.map((n: any) => uriToClashLine(n.uri))
.filter((l): l is string => l !== null);
const staticNames = staticNodes.map((n: any) => n.name as string);
if (clashLines.length === 0) {
return sub.raw_config_clash;
}
let config = injectProxies(sub.raw_config_clash, clashLines);
config = injectProxyGroupNames(config, staticNames);
return config;
}
/**
* Inject static node YAML lines into the proxies: section.
* Inserts immediately after the `proxies:` key line.
* If no `proxies:` key exists, inserts a new proxies block before `proxy-groups:`.
*/
function injectProxies(config: string, clashLines: string[]): string {
const lines = config.split('\n');
const result: string[] = [];
let injected = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (!injected && trimmed === 'proxies:') {
result.push(lines[i]);
// Inject right after the `proxies:` key
for (const cl of clashLines) result.push(cl);
injected = true;
continue;
}
// If proxies: never appeared, inject before proxy-groups:
if (!injected && trimmed === 'proxy-groups:') {
result.push('proxies:');
for (const cl of clashLines) result.push(cl);
result.push('');
injected = true;
}
result.push(lines[i]);
}
// Fallback: append at end if neither key was found
if (!injected) {
result.push('');
result.push('proxies:');
for (const cl of clashLines) result.push(cl);
}
return result.join('\n');
}
/**
* Inject static node names into every `type: select` proxy-group's proxies sub-list.
* Handles both block sequence (` - name`) and inline array (`proxies: [a, b]`) formats.
*/
function injectProxyGroupNames(config: string, names: string[]): string {
const lines = config.split('\n');
const result: string[] = [];
let inProxyGroups = false;
let inSelectGroup = false;
let inProxiesSublist = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Detect top-level section changes
if (/^[a-zA-Z]/.test(line) && line.includes(':')) {
const key = line.split(':')[0].trim();
if (key === 'proxy-groups') {
inProxyGroups = true;
inSelectGroup = false;
inProxiesSublist = false;
} else if (!line.startsWith(' ') && !line.startsWith('-')) {
inProxyGroups = false;
inSelectGroup = false;
inProxiesSublist = false;
}
}
if (!inProxyGroups) {
result.push(line);
continue;
}
// New group entry: reset state
if (/^\s*-\s+name:/.test(line)) {
inSelectGroup = false;
inProxiesSublist = false;
result.push(line);
continue;
}
// Detect `type: select` within a group
if (trimmed === 'type: select') {
inSelectGroup = true;
result.push(line);
continue;
}
if (inSelectGroup) {
// Handle inline array: proxies: [node1, node2, ...]
if (/^\s+proxies:\s*\[/.test(line)) {
// Append names before the closing ]
const closeBracket = line.lastIndexOf(']');
if (closeBracket !== -1) {
const before = line.slice(0, closeBracket);
const after = line.slice(closeBracket);
result.push(before + ', ' + names.join(', ') + after);
} else {
result.push(line);
}
continue;
}
// Handle block sequence: ` proxies:` followed by ` - name` items
if (/^\s+proxies:\s*$/.test(line)) {
inProxiesSublist = true;
result.push(line);
continue;
}
if (inProxiesSublist) {
// Check if next line is still a proxies list item
const nextLine = lines[i + 1];
const isLastItem = !nextLine || !/^\s{4,}-\s/.test(nextLine);
result.push(line);
if (isLastItem && /^\s{4,}-\s/.test(line)) {
// Inject names after last item
const indent = line.match(/^(\s+)/)?.[1] || ' ';
for (const name of names) result.push(`${indent}- ${name}`);
inProxiesSublist = false;
}
continue;
}
}
result.push(line);
}
return result.join('\n');
}

View File

@@ -0,0 +1,26 @@
import db from '../db.js';
export function generateSSRConfig(): string {
const sub = db.prepare(
'SELECT raw_config_ssr FROM subscriptions WHERE enabled = 1 AND raw_config_ssr IS NOT NULL ORDER BY id LIMIT 1'
).get() as any;
if (!sub?.raw_config_ssr) {
return '';
}
const staticNodes = db.prepare(
'SELECT uri FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
).all() as any[];
// Decode base64 content → URI list
const decoded = Buffer.from(sub.raw_config_ssr.trim(), 'base64').toString();
const lines = decoded.split(/\r?\n/).map((l: string) => l.trim()).filter(Boolean);
// Append static node URIs
for (const node of staticNodes) {
if (node.uri) lines.push(node.uri);
}
return Buffer.from(lines.join('\n')).toString('base64');
}