hy2-panel/lib/subscription.ts
2026-05-06 00:10:45 +08:00

230 lines
6.4 KiB
TypeScript

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";
type ClashSettings = {
template: string;
extraProxyGroups: string;
profileName: string;
mainProxyGroupMemberSlugs: 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 renderProxy(node: Hy2NodeRow, authString: string) {
const lines = [
`- name: "${node.client_name || node.name}"`,
" type: hysteria2",
` server: ${node.server_host}`,
` port: ${node.server_port}`,
` password: "${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 renderGroupEntry(name: string, proxyNames: string[]) {
const quoted = proxyNames.map((name) => `"${name}"`).join(", ");
return [
` - name: ${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<string>();
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 authPassword = decryptText(input.user.auth_secret_encrypted);
if (!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 orderedEligibleNodes = orderNodesBySlugs(
eligibleNodes,
input.settings.mainProxyGroupMemberSlugs,
);
const nodeNameBySlug = new Map(
eligibleNodes.map((node) => [node.slug, node.client_name || node.name]),
);
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 renderedGroups = [
renderGroupEntry(input.settings.profileName || "FeieProxy", proxyNames),
...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,
"",
"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)} 剩余`;
}