import { anytlsPassword } from "@/lib/anytls"; 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, getNodeProxyName } from "@/lib/store"; type ClashSettings = { template: string; extraProxyGroups: string; profileName: string; mainProxyGroupMemberSlugs: string[]; mainProxyGroupHiddenSlugs?: string[]; proxyGroups: ProxyGroupConfig[]; }; function readDefaultTemplate() { const filePath = path.join(process.cwd(), "app", "config", "config.yaml"); return fs.readFileSync(filePath, "utf8").trimEnd(); } function indent(text: string, spaces: number) { const prefix = " ".repeat(spaces); return text .split("\n") .map((line) => (line ? `${prefix}${line}` : line)) .join("\n"); } function renderHy2Proxy(node: Hy2NodeRow, authString: string) { const lines = [ `- name: ${JSON.stringify(node.client_name || node.name)}`, " type: hysteria2", ` server: ${node.server_host}`, ` port: ${node.server_port}`, ` password: ${JSON.stringify(authString)}`, ]; if (node.sni) { lines.push(` sni: ${node.sni}`); } if (node.skip_cert_verify) { lines.push(" skip-cert-verify: true"); } lines.push(" bandwidth:"); lines.push(` up: ${node.bandwidth_up_mbps} mbps`); lines.push(` down: ${node.bandwidth_down_mbps} mbps`); return lines.join("\n"); } function renderSubscriptionProxy( node: Hy2NodeRow, authString: string, user: Hy2UserRow, ) { if (node.node_type === "custom") { return renderCustomProxyYaml(node.custom_config_yaml); } if (node.node_type === "anytls") return [ `- name: ${JSON.stringify(node.client_name || node.name)}`, " type: anytls", ` server: ${JSON.stringify(node.server_host)}`, ` port: ${node.server_port}`, ` password: ${JSON.stringify(anytlsPassword(user))}`, ` sni: ${JSON.stringify(node.sni || node.server_host)}`, " skip-cert-verify: false", " udp: true", ].join("\n"); 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) => JSON.stringify(name)).join(", "); return [ ` - name: ${JSON.stringify(name)}`, " type: select", ` proxies: [${quoted}]`, ].join("\n"); } function orderNodesBySlugs(nodes: Hy2NodeRow[], orderedSlugs: string[]) { if (orderedSlugs.length === 0) { return nodes; } const nodesBySlug = new Map(nodes.map((node) => [node.slug, node])); const seen = new Set(); const orderedNodes: Hy2NodeRow[] = []; for (const slug of orderedSlugs) { const node = nodesBySlug.get(slug); if (!node || seen.has(slug)) { continue; } orderedNodes.push(node); seen.add(slug); } for (const node of nodes) { if (!seen.has(node.slug)) { orderedNodes.push(node); } } return orderedNodes; } export function encodeRFC5987ValueChars(value: string) { return encodeURIComponent(value) .replace( /['()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, ) .replace(/\*/g, "%2A") .replace(/%(7C|60|5E)/g, (_, code) => String.fromCharCode(Number.parseInt(code, 16)), ); } function buildAsciiFilenameFallback(value: string) { const ascii = value .normalize("NFKD") .replace(/[^\x20-\x7E]+/g, " ") .replace(/["\\]/g, "_") .replace(/\s+/g, " ") .trim(); return ascii || "subscription"; } export function buildSubscriptionHeaders( user: Hy2UserRow, profileName: string, ) { const expire = user.expires_at ? Math.round(new Date(user.expires_at).getTime() / 1000) : user.is_admin ? 4102444800 : Math.round(Date.now() / 1000) + 3600 * 24 * 365 * 10; const total = user.is_admin || user.traffic_limit_bytes == null ? "9223372036854775807" : String(user.traffic_limit_bytes); const fallbackFilename = buildAsciiFilenameFallback(profileName); return { "Content-Disposition": `attachment; filename="${fallbackFilename}"; filename*=UTF-8''${encodeRFC5987ValueChars(profileName)}`, "Content-Type": "text/yaml; charset=utf-8", "Profile-Update-Interval": "12", "subscription-userinfo": `upload=${user.used_tx_bytes}; download=${user.used_rx_bytes}; total=${total}; expire=${expire}`, }; } export function buildSubscriptionUrl(token: string, origin = getEnv().APP_URL) { return `${origin}/api/subscription/${token}/clash`; } export function buildNodeHy2Config( node: Hy2NodeRow, origin = getEnv().APP_URL, ) { const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin); return [ `listen: ${node.hy2_listen || `:${node.server_port}`}`, "tls:", " cert: /etc/hysteria/server.crt", " key: /etc/hysteria/server.key", "auth:", " type: http", " http:", ` url: ${authUrl}`, " insecure: false", "trafficStats:", ` listen: ${node.traffic_stats_listen}`, ` secret: ${node.traffic_stats_secret}`, ].join("\n"); } export function buildSubscriptionConfig(input: { user: Hy2UserRow; nodes: Hy2NodeRow[]; settings: ClashSettings; }) { 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 = authPassword ? `${input.user.username}:${authPassword}` : ""; const orderedEligibleNodes = orderNodesBySlugs( eligibleNodes, input.settings.mainProxyGroupMemberSlugs, ); const nodeNameBySlug = new Map( eligibleNodes.map((node) => [node.slug, getNodeProxyName(node)]), ); const hidden = new Set(input.settings.mainProxyGroupHiddenSlugs ?? []); const proxyNames = orderedEligibleNodes .filter((node) => !hidden.has(node.slug)) .map((node) => getNodeProxyName(node)); const proxiesBlock = orderedEligibleNodes .map((node) => renderSubscriptionProxy(node, authString, input.user)) .join("\n"); const defaultTemplate = input.settings.template.trimEnd() || readDefaultTemplate(); const renderedGroups = [ renderGroupEntry( input.settings.profileName || "FeieProxy", proxyNames.length ? proxyNames : ["DIRECT"], ), ...input.settings.proxyGroups .map((group) => { const members = group.memberSlugs .map((slug) => nodeNameBySlug.get(slug)) .filter((name): name is string => Boolean(name)); if (members.length === 0) { return null; } return renderGroupEntry(group.name, members); }) .filter((group): group is string => Boolean(group)), ].join("\n"); const extraGroups = input.settings.extraProxyGroups .trim() .replace(/^proxy-groups:\s*/i, ""); return [ defaultTemplate, "", proxiesBlock ? "proxies:" : "proxies: []", indent(proxiesBlock, 2), "", "proxy-groups:", renderedGroups, extraGroups ? `\n${extraGroups}` : "", "", ].join("\n"); } export function getDefaultClashTemplate() { return readDefaultTemplate(); } export function describeRemainingTraffic(user: Hy2UserRow) { if (user.is_admin || user.traffic_limit_bytes == null) { return "不限流量"; } const remaining = Math.max( user.traffic_limit_bytes - user.used_tx_bytes - user.used_rx_bytes, 0, ); return `${formatBytes(remaining)} 剩余`; }