230 lines
6.4 KiB
TypeScript
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)} 剩余`;
|
|
}
|