feat: clash/ss suport
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
|
||||
79
server/src/parsers/toClash.ts
Normal file
79
server/src/parsers/toClash.ts
Normal 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}}`;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
171
server/src/services/clashGenerator.ts
Normal file
171
server/src/services/clashGenerator.ts
Normal 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');
|
||||
}
|
||||
26
server/src/services/ssrGenerator.ts
Normal file
26
server/src/services/ssrGenerator.ts
Normal 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');
|
||||
}
|
||||
Reference in New Issue
Block a user