init
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
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;
|
||||
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");
|
||||
}
|
||||
|
||||
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 nodeNameBySlug = new Map(
|
||||
eligibleNodes.map((node) => [node.slug, node.client_name || node.name]),
|
||||
);
|
||||
const proxyNames = eligibleNodes.map((node) => node.client_name || node.name);
|
||||
const proxiesBlock = eligibleNodes.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)} 剩余`;
|
||||
}
|
||||
Reference in New Issue
Block a user