feat: 支持自定义订阅节点并兼容现有数据库

This commit is contained in:
2026-09-29 11:52:41 +08:00
parent 3faf960f35
commit ab7d1c937a
27 changed files with 2203 additions and 449 deletions
+60 -25
View File
@@ -1,14 +1,11 @@
import { renderCustomProxyYaml } from "@/lib/custom-proxy";
import fs from "node:fs";
import path from "node:path";
import { decryptText } from "@/lib/crypto";
import { getEnv } from "@/lib/env";
import type {
Hy2NodeRow,
Hy2UserRow,
ProxyGroupConfig,
} from "@/lib/store";
import { buildNodeAuthUrl, formatBytes } from "@/lib/store";
import type { Hy2NodeRow, Hy2UserRow, ProxyGroupConfig } from "@/lib/store";
import { buildNodeAuthUrl, formatBytes, getNodeProxyName } from "@/lib/store";
type ClashSettings = {
template: string;
@@ -31,13 +28,13 @@ function indent(text: string, spaces: number) {
.join("\n");
}
function renderProxy(node: Hy2NodeRow, authString: string) {
function renderHy2Proxy(node: Hy2NodeRow, authString: string) {
const lines = [
`- name: "${node.client_name || node.name}"`,
`- name: ${JSON.stringify(node.client_name || node.name)}`,
" type: hysteria2",
` server: ${node.server_host}`,
` port: ${node.server_port}`,
` password: "${authString}"`,
` password: ${JSON.stringify(authString)}`,
];
if (node.sni) {
@@ -55,10 +52,30 @@ function renderProxy(node: Hy2NodeRow, authString: string) {
return lines.join("\n");
}
function renderSubscriptionProxy(node: Hy2NodeRow, authString: string) {
if (node.node_type === "custom") {
return renderCustomProxyYaml(node.custom_config_yaml);
}
return renderHy2Proxy(node, authString);
}
function isEligibleSubscriptionNode(node: Hy2NodeRow) {
if (!node.enabled) {
return false;
}
if (node.node_type === "custom") {
return Boolean(node.custom_config_yaml.trim());
}
return Boolean(node.server_host && node.server_port > 0);
}
function renderGroupEntry(name: string, proxyNames: string[]) {
const quoted = proxyNames.map((name) => `"${name}"`).join(", ");
const quoted = proxyNames.map((name) => JSON.stringify(name)).join(", ");
return [
` - name: ${name}`,
` - name: ${JSON.stringify(name)}`,
" type: select",
` proxies: [${quoted}]`,
].join("\n");
@@ -94,7 +111,10 @@ function orderNodesBySlugs(nodes: Hy2NodeRow[], orderedSlugs: string[]) {
export function encodeRFC5987ValueChars(value: string) {
return encodeURIComponent(value)
.replace(/['()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
.replace(
/['()]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
)
.replace(/\*/g, "%2A")
.replace(/%(7C|60|5E)/g, (_, code) =>
String.fromCharCode(Number.parseInt(code, 16)),
@@ -112,7 +132,10 @@ function buildAsciiFilenameFallback(value: string) {
return ascii || "subscription";
}
export function buildSubscriptionHeaders(user: Hy2UserRow, profileName: string) {
export function buildSubscriptionHeaders(
user: Hy2UserRow,
profileName: string,
) {
const expire = user.expires_at
? Math.round(new Date(user.expires_at).getTime() / 1000)
: user.is_admin
@@ -137,7 +160,10 @@ export function buildSubscriptionUrl(token: string, origin = getEnv().APP_URL) {
return `${origin}/api/subscription/${token}/clash`;
}
export function buildNodeHy2Config(node: Hy2NodeRow, origin = getEnv().APP_URL) {
export function buildNodeHy2Config(
node: Hy2NodeRow,
origin = getEnv().APP_URL,
) {
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
return [
@@ -161,25 +187,34 @@ export function buildSubscriptionConfig(input: {
nodes: Hy2NodeRow[];
settings: ClashSettings;
}) {
const authPassword = decryptText(input.user.auth_secret_encrypted);
if (!authPassword) {
const eligibleNodes = input.nodes.filter((node) =>
isEligibleSubscriptionNode(node),
);
const needsUserAuth = eligibleNodes.some((node) => node.node_type === "hy2");
const authPassword = needsUserAuth
? decryptText(input.user.auth_secret_encrypted)
: null;
if (needsUserAuth && !authPassword) {
throw new Error("user_secret_unavailable");
}
const authString = `${input.user.username}:${authPassword}`;
const eligibleNodes = input.nodes.filter(
(node) => node.enabled && node.server_host && node.server_port > 0,
);
const authString = authPassword
? `${input.user.username}:${authPassword}`
: "";
const orderedEligibleNodes = orderNodesBySlugs(
eligibleNodes,
input.settings.mainProxyGroupMemberSlugs,
);
const nodeNameBySlug = new Map(
eligibleNodes.map((node) => [node.slug, node.client_name || node.name]),
eligibleNodes.map((node) => [node.slug, getNodeProxyName(node)]),
);
const proxyNames = orderedEligibleNodes.map((node) => node.client_name || node.name);
const proxiesBlock = orderedEligibleNodes.map((node) => renderProxy(node, authString)).join("\n");
const defaultTemplate = input.settings.template.trimEnd() || readDefaultTemplate();
const proxyNames = orderedEligibleNodes.map((node) => getNodeProxyName(node));
const proxiesBlock = orderedEligibleNodes
.map((node) => renderSubscriptionProxy(node, authString))
.join("\n");
const defaultTemplate =
input.settings.template.trimEnd() || readDefaultTemplate();
const renderedGroups = [
renderGroupEntry(input.settings.profileName || "FeieProxy", proxyNames),
...input.settings.proxyGroups
@@ -202,7 +237,7 @@ export function buildSubscriptionConfig(input: {
return [
defaultTemplate,
"",
"proxies:",
proxiesBlock ? "proxies:" : "proxies: []",
indent(proxiesBlock, 2),
"",
"proxy-groups:",