feat: clash/ss suport
This commit is contained in:
@@ -8,7 +8,9 @@
|
||||
"Bash(scp server/src/routes/surge.ts ubuntu@118.195.187.179:/opt/1panel/apps/sub-router/server/src/routes/surge.ts)",
|
||||
"Bash(scp web/src/api.ts ubuntu@118.195.187.179:/opt/1panel/apps/sub-router/web/src/api.ts)",
|
||||
"Bash(scp web/src/components/Output.tsx ubuntu@118.195.187.179:/opt/1panel/apps/sub-router/web/src/components/Output.tsx)",
|
||||
"Bash(scp:*)"
|
||||
"Bash(scp:*)",
|
||||
"Bash(./node_modules/.bin/tsc --noEmit)",
|
||||
"Bash(../node_modules/.bin/tsc --noEmit)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
51
CLAUDE.md
51
CLAUDE.md
@@ -4,28 +4,31 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Sub Router is a Surge proxy subscription management system. It aggregates nodes from upstream subscriptions, allows adding custom static nodes and rules, and generates a combined Surge configuration file served at `/surge`.
|
||||
Sub Router is a Surge proxy subscription management system. It aggregates nodes from upstream subscriptions, allows adding custom static nodes and rules, and generates a combined Surge configuration file served at `/surge/:token`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development (starts both server and web dev server concurrently)
|
||||
npm run dev
|
||||
pnpm dev
|
||||
|
||||
# Server only (port 3456, hot-reload via tsx watch)
|
||||
npm run dev:server
|
||||
pnpm dev:server
|
||||
|
||||
# Frontend only (port 5173, proxies /api and /surge to :3456)
|
||||
npm run dev:web
|
||||
pnpm dev:web
|
||||
|
||||
# Build frontend for production
|
||||
npm run build:web
|
||||
pnpm build:web
|
||||
|
||||
# Production start (serves built frontend + API on port 3456)
|
||||
npm run start
|
||||
pnpm start
|
||||
|
||||
# Type check frontend
|
||||
cd web && npx tsc --noEmit
|
||||
cd web && pnpm exec tsc --noEmit
|
||||
|
||||
# Type check server
|
||||
cd server && pnpm exec tsc --noEmit
|
||||
|
||||
# Docker
|
||||
docker compose up -d
|
||||
@@ -39,7 +42,7 @@ docker compose up -d
|
||||
|
||||
1. User adds upstream subscription URLs → backend fetches and parses nodes into `fetched_nodes` table
|
||||
2. User adds static nodes via URI (ss://, vmess://, trojan://) → parsed and stored in `static_nodes` table
|
||||
3. `GET /surge` → `generator.ts` rebuilds the Surge config:
|
||||
3. `GET /surge/:token` → `generator.ts` rebuilds the Surge config:
|
||||
- Takes first enabled subscription's `raw_config` as base template
|
||||
- **Replaces** `[Proxy]` section entirely with only enabled nodes (static first, then fetched)
|
||||
- **Rebuilds** `[Proxy Group]` select groups with only enabled node names
|
||||
@@ -48,11 +51,23 @@ docker compose up -d
|
||||
|
||||
### Auth model
|
||||
|
||||
`/surge` endpoint requires no auth (for Surge client access). All `/api/*` routes require Bearer token auth, except `/api/auth/login` and `/api/auth/status`. Password is stored in the `config` table. Token is the raw password sent via `Authorization: Bearer <password>`.
|
||||
Two separate credentials exist:
|
||||
|
||||
- **Admin password**: Protects all `/api/*` routes (except `/api/auth/login` and `/api/auth/status`). Sent as `Authorization: Bearer <password>`. On first login with no password set, the submitted password becomes the password.
|
||||
- **Surge token**: UUID stored in `config` table as `surge_token`. Embedded in the Surge subscription URL itself (`/surge/:token`). Returns 404 for wrong tokens. Regeneratable via `POST /api/config/surge-token`.
|
||||
|
||||
### API routes
|
||||
|
||||
- `POST /api/auth/login`, `GET /api/auth/status` — auth (no auth required)
|
||||
- `GET /api/stats` — node/rule counts summary
|
||||
- `/api/subscriptions` → `subscriptions.ts` (CRUD + `/:id/fetch`, `/:id/nodes`)
|
||||
- `/api/nodes` → `nodes.ts` (fetched node toggle, static node CRUD)
|
||||
- `/api/rules` → `rules.ts` (CRUD + `/reorder`)
|
||||
- `/api/config` → `surge.ts` (`/surge-token` GET/POST, `/preview` GET)
|
||||
|
||||
### Database
|
||||
|
||||
SQLite with WAL mode at `server/data/sub-router.db`. Five tables: `subscriptions`, `fetched_nodes` (FK to subscriptions, CASCADE delete), `static_nodes`, `rules`, `config` (KV store for password).
|
||||
SQLite with WAL mode at `server/data/sub-router.db`. Five tables: `subscriptions`, `fetched_nodes` (FK to subscriptions, CASCADE delete), `static_nodes`, `rules`, `config` (KV store for `password` and `surge_token`).
|
||||
|
||||
### Parsers (`server/src/parsers/`)
|
||||
|
||||
@@ -61,14 +76,28 @@ Convert protocol URIs to Surge proxy lines. Each parser returns `{ name, type, s
|
||||
- **SS**: No TLS wrapping (Surge doesn't support SS over TLS; SS 2022 has built-in encryption)
|
||||
- **VMess/Trojan**: `skip-cert-verify=false` (strict TLS verification)
|
||||
|
||||
On re-fetch, enabled state is preserved by node name: existing name→enabled mapping is saved before delete, then reapplied to new rows.
|
||||
|
||||
When renaming a static node (`PUT /api/nodes/static/:id`), the `surge_line` prefix is also updated to match the new name.
|
||||
|
||||
### Frontend
|
||||
|
||||
Five panels: Subscriptions (CRUD + fetch trigger), Static Nodes (paste URI, custom naming, double-click to rename), Node Selector (per-subscription toggle with regex batch operations), Rules (CRUD + drag reorder), Output (subscription URL + config preview). Dark cyberpunk theme with CSS variables, JetBrains Mono font.
|
||||
|
||||
### Generator limitation
|
||||
|
||||
`rebuildProxyGroup` in `generator.ts` only updates the **first** `= select,` group (due to `handled = true` flag). If a Surge template has multiple select groups, only the first gets its node list replaced.
|
||||
|
||||
## Route ordering matters
|
||||
|
||||
In `server/src/routes/`: specific paths like `/reorder` and `/fetched/batch` must be registered **before** parameterized `/:id` routes to avoid being captured by Express route matching.
|
||||
|
||||
## Legacy file
|
||||
|
||||
`index.js` at the repo root is a pre-rewrite single-file prototype. It is not used by the current system and can be ignored.
|
||||
|
||||
## Deployment
|
||||
|
||||
The `Content-Disposition` header on `/surge` controls the config profile name in Surge client (currently `IPLC.MAX.conf`). Docker mounts `./data` volume for SQLite persistence. Certificate renewal uses acme.sh with `dns_cf` (Cloudflare DNS API validation).
|
||||
Multi-stage Docker build: frontend built on `node:22-slim`, output copied into production image alongside the server. The `./data` volume maps to `server/data/` inside the container for SQLite persistence.
|
||||
|
||||
The `Content-Disposition` header on `/surge/:token` controls the config profile name in Surge client (currently `IPLC.MAX.conf`). Certificate renewal uses acme.sh with `dns_cf` (Cloudflare DNS API validation).
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -50,9 +50,9 @@ export const auth = {
|
||||
// Subscriptions
|
||||
export const subscriptions = {
|
||||
list: () => request<any[]>('/subscriptions'),
|
||||
create: (name: string, url: string) => request<{ id: number }>('/subscriptions', {
|
||||
create: (name: string, url: string, urlClash?: string, urlSsr?: string) => request<{ id: number }>('/subscriptions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, url }),
|
||||
body: JSON.stringify({ name, url, url_clash: urlClash || null, url_ssr: urlSsr || null }),
|
||||
}),
|
||||
update: (id: number, data: any) => request<{ ok: boolean }>(`/subscriptions/${id}`, {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -4,10 +4,14 @@ import { config as configApi } from '../api';
|
||||
export default function Output() {
|
||||
const [preview, setPreview] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedSurge, setCopiedSurge] = useState(false);
|
||||
const [copiedClash, setCopiedClash] = useState(false);
|
||||
const [copiedSsr, setCopiedSsr] = useState(false);
|
||||
const [surgeToken, setSurgeToken] = useState('');
|
||||
|
||||
const surgeUrl = surgeToken ? `${window.location.origin}/surge/${surgeToken}` : '';
|
||||
const clashUrl = surgeToken ? `${window.location.origin}/clash/${surgeToken}` : '';
|
||||
const ssrUrl = surgeToken ? `${window.location.origin}/ssr/${surgeToken}` : '';
|
||||
|
||||
const loadToken = async () => {
|
||||
try {
|
||||
@@ -17,7 +21,7 @@ export default function Output() {
|
||||
};
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!confirm('重新生成后,旧的订阅链接将失效,Surge 客户端需要更新订阅地址。确定继续?')) return;
|
||||
if (!confirm('重新生成后,旧的订阅链接将失效(Surge / Clash / SSR 三条链接同时更新)。确定继续?')) return;
|
||||
try {
|
||||
const data = await configApi.regenerateSurgeToken();
|
||||
setSurgeToken(data.token);
|
||||
@@ -42,93 +46,88 @@ export default function Output() {
|
||||
loadPreview();
|
||||
}, []);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(surgeUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
const makeCopyHandler = (url: string, setter: (v: boolean) => void) => () => {
|
||||
navigator.clipboard.writeText(url);
|
||||
setter(true);
|
||||
setTimeout(() => setter(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={styles.title}>输出配置</h2>
|
||||
<p style={styles.subtitle}>Surge 客户端订阅链接和配置预览</p>
|
||||
<p style={styles.subtitle}>Surge / Clash / SSR 订阅链接和配置预览</p>
|
||||
|
||||
{/* Subscription URL */}
|
||||
<div style={{
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
Surge 订阅链接
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<code style={{
|
||||
flex: 1,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 13,
|
||||
color: 'var(--accent)',
|
||||
userSelect: 'all',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{surgeUrl || '加载中...'}
|
||||
</code>
|
||||
<button className="small" onClick={handleCopy} disabled={!surgeUrl}>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
<button className="small" onClick={handleRegenerate} disabled={!surgeUrl}>
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Surge URL */}
|
||||
<UrlCard
|
||||
label="SURGE 订阅链接"
|
||||
url={surgeUrl}
|
||||
copied={copiedSurge}
|
||||
onCopy={makeCopyHandler(surgeUrl, setCopiedSurge)}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
|
||||
{/* Clash URL */}
|
||||
<UrlCard
|
||||
label="CLASH / STASH 订阅链接"
|
||||
url={clashUrl}
|
||||
copied={copiedClash}
|
||||
onCopy={makeCopyHandler(clashUrl, setCopiedClash)}
|
||||
/>
|
||||
|
||||
{/* SSR URL */}
|
||||
<UrlCard
|
||||
label="SSR / QX / 小火箭 订阅链接"
|
||||
url={ssrUrl}
|
||||
copied={copiedSsr}
|
||||
onCopy={makeCopyHandler(ssrUrl, setCopiedSsr)}
|
||||
/>
|
||||
|
||||
{/* Preview */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
}}>
|
||||
配置预览
|
||||
</span>
|
||||
<span style={styles.sectionLabel}>SURGE 配置预览</span>
|
||||
<button className="small" onClick={loadPreview} disabled={loading}>
|
||||
{loading ? '加载中...' : '刷新预览'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<pre style={{
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: 16,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text-secondary)',
|
||||
overflow: 'auto',
|
||||
maxHeight: 'calc(100vh - 360px)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
<pre style={styles.pre}>
|
||||
{preview || '(empty)'}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UrlCard({
|
||||
label,
|
||||
url,
|
||||
copied,
|
||||
onCopy,
|
||||
onRegenerate,
|
||||
}: {
|
||||
label: string;
|
||||
url: string;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
onRegenerate?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardLabel}>{label}</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<code style={styles.urlCode}>{url || '加载中...'}</code>
|
||||
<button className="small" onClick={onCopy} disabled={!url}>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
{onRegenerate && (
|
||||
<button className="small" onClick={onRegenerate} disabled={!url}>
|
||||
重新生成
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontFamily: 'var(--font-mono)' as const,
|
||||
@@ -142,4 +141,50 @@ const styles = {
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: 20,
|
||||
},
|
||||
card: {
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
cardLabel: {
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--font-mono)' as const,
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: 8,
|
||||
},
|
||||
urlCode: {
|
||||
flex: 1,
|
||||
fontFamily: 'var(--font-mono)' as const,
|
||||
fontSize: 13,
|
||||
color: 'var(--accent)',
|
||||
userSelect: 'all' as const,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--font-mono)' as const,
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.1em',
|
||||
},
|
||||
pre: {
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: 16,
|
||||
fontFamily: 'var(--font-mono)' as const,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text-secondary)',
|
||||
overflow: 'auto',
|
||||
maxHeight: 'calc(100vh - 480px)',
|
||||
whiteSpace: 'pre-wrap' as const,
|
||||
wordBreak: 'break-all' as const,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,18 +3,46 @@ import { subscriptions as api } from '../api';
|
||||
|
||||
export default function Subscriptions() {
|
||||
const [subs, setSubs] = useState<any[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [fetching, setFetching] = useState<number | null>(null);
|
||||
|
||||
// Form state
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formUrl, setFormUrl] = useState('');
|
||||
const [formUrlClash, setFormUrlClash] = useState('');
|
||||
const [formUrlSsr, setFormUrlSsr] = useState('');
|
||||
|
||||
const load = () => api.list().then(setSubs).catch(console.error);
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!name.trim() || !url.trim()) return;
|
||||
await api.create(name.trim(), url.trim());
|
||||
setName('');
|
||||
setUrl('');
|
||||
const openForm = (sub?: any) => {
|
||||
setEditingId(sub?.id ?? null);
|
||||
setFormName(sub?.name ?? '');
|
||||
setFormUrl(sub?.url ?? '');
|
||||
setFormUrlClash(sub?.url_clash ?? '');
|
||||
setFormUrlSsr(sub?.url_ssr ?? '');
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setFormOpen(false);
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formName.trim() || !formUrl.trim()) return;
|
||||
if (editingId !== null) {
|
||||
await api.update(editingId, {
|
||||
name: formName.trim(),
|
||||
url: formUrl.trim(),
|
||||
url_clash: formUrlClash.trim() || null,
|
||||
url_ssr: formUrlSsr.trim() || null,
|
||||
});
|
||||
} else {
|
||||
await api.create(formName.trim(), formUrl.trim(), formUrlClash.trim(), formUrlSsr.trim());
|
||||
}
|
||||
closeForm();
|
||||
load();
|
||||
};
|
||||
|
||||
@@ -46,22 +74,63 @@ export default function Subscriptions() {
|
||||
<h2 style={styles.title}>订阅源管理</h2>
|
||||
<p style={styles.subtitle}>添加和管理上游订阅链接,点击刷新按钮抓取节点</p>
|
||||
|
||||
{/* Add form */}
|
||||
<div style={styles.form}>
|
||||
{/* Add button */}
|
||||
{!formOpen && (
|
||||
<button className="primary" style={{ marginBottom: 16 }} onClick={() => openForm()}>
|
||||
+ 添加订阅
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add / Edit form */}
|
||||
{formOpen && (
|
||||
<div style={styles.formCard}>
|
||||
<div style={styles.formTitle}>
|
||||
{editingId !== null ? '编辑订阅' : '添加订阅'}
|
||||
</div>
|
||||
<div style={styles.fieldGroup}>
|
||||
<label style={styles.label}>名称</label>
|
||||
<input
|
||||
placeholder="名称"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
style={{ width: 160 }}
|
||||
value={formName}
|
||||
onChange={e => setFormName(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<input
|
||||
placeholder="订阅 URL"
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button className="primary" onClick={handleAdd}>添加</button>
|
||||
</div>
|
||||
<div style={styles.fieldGroup}>
|
||||
<label style={styles.label}>Surge URL <span style={{ color: 'var(--accent)' }}>*</span></label>
|
||||
<input
|
||||
placeholder="https://..."
|
||||
value={formUrl}
|
||||
onChange={e => setFormUrl(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={styles.fieldGroup}>
|
||||
<label style={styles.label}>Clash / Stash URL <span style={styles.optional}>(选填)</span></label>
|
||||
<input
|
||||
placeholder="https://..."
|
||||
value={formUrlClash}
|
||||
onChange={e => setFormUrlClash(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={styles.fieldGroup}>
|
||||
<label style={styles.label}>SSR / QX / 小火箭 URL <span style={styles.optional}>(选填)</span></label>
|
||||
<input
|
||||
placeholder="https://..."
|
||||
value={formUrlSsr}
|
||||
onChange={e => setFormUrlSsr(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<button onClick={closeForm}>取消</button>
|
||||
<button className="primary" onClick={handleSubmit}>
|
||||
{editingId !== null ? '保存' : '添加'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<table>
|
||||
@@ -69,10 +138,10 @@ export default function Subscriptions() {
|
||||
<tr>
|
||||
<th style={{ width: 50 }}>状态</th>
|
||||
<th>名称</th>
|
||||
<th>URL</th>
|
||||
<th style={{ width: 120 }}>格式</th>
|
||||
<th style={{ width: 80 }}>节点数</th>
|
||||
<th style={{ width: 140 }}>最后抓取</th>
|
||||
<th style={{ width: 140 }}>操作</th>
|
||||
<th style={{ width: 170 }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -90,9 +159,15 @@ export default function Subscriptions() {
|
||||
{sub.name}
|
||||
</td>
|
||||
<td>
|
||||
<span className="ellipsis" style={{ maxWidth: 300, display: 'inline-block' }}>
|
||||
{sub.url}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
<span style={styles.badge} title={sub.url}>SURGE</span>
|
||||
{sub.url_clash && (
|
||||
<span style={{ ...styles.badge, ...styles.badgeClash }} title={sub.url_clash}>CLASH</span>
|
||||
)}
|
||||
{sub.url_ssr && (
|
||||
<span style={{ ...styles.badge, ...styles.badgeSsr }} title={sub.url_ssr}>SSR</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{sub.node_count || '—'}
|
||||
@@ -102,6 +177,9 @@ export default function Subscriptions() {
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button className="small" onClick={() => openForm(sub)}>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="small"
|
||||
onClick={() => handleFetch(sub.id)}
|
||||
@@ -142,9 +220,54 @@ const styles = {
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: 20,
|
||||
},
|
||||
form: {
|
||||
display: 'flex' as const,
|
||||
gap: 8,
|
||||
formCard: {
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
display: 'flex' as const,
|
||||
flexDirection: 'column' as const,
|
||||
gap: 12,
|
||||
},
|
||||
formTitle: {
|
||||
fontFamily: 'var(--font-mono)' as const,
|
||||
fontSize: 12,
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.08em',
|
||||
},
|
||||
fieldGroup: {
|
||||
display: 'flex' as const,
|
||||
flexDirection: 'column' as const,
|
||||
gap: 4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
color: 'var(--text-secondary)',
|
||||
fontFamily: 'var(--font-mono)' as const,
|
||||
},
|
||||
optional: {
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: 10,
|
||||
},
|
||||
badge: {
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--font-mono)' as const,
|
||||
padding: '1px 5px',
|
||||
borderRadius: 3,
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-secondary)',
|
||||
cursor: 'default' as const,
|
||||
userSelect: 'none' as const,
|
||||
},
|
||||
badgeClash: {
|
||||
color: '#4ade80',
|
||||
borderColor: '#4ade8040',
|
||||
},
|
||||
badgeSsr: {
|
||||
color: '#60a5fa',
|
||||
borderColor: '#60a5fa40',
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user