mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
1010 lines
33 KiB
TypeScript
1010 lines
33 KiB
TypeScript
import { createHmac, timingSafeEqual } from "crypto";
|
|
import { prisma } from "@/lib/prisma";
|
|
import type { Protocol } from "@prisma/client";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
interface PanelClient {
|
|
id?: string;
|
|
password?: string;
|
|
auth?: string;
|
|
email?: string;
|
|
flow?: string;
|
|
security?: string;
|
|
method?: string;
|
|
}
|
|
|
|
export interface ProxyNodeContext {
|
|
email?: string;
|
|
uuid: string;
|
|
inbound: {
|
|
protocol: Protocol;
|
|
port: number;
|
|
tag: string;
|
|
settings: unknown;
|
|
streamSettings: unknown;
|
|
server: {
|
|
name: string;
|
|
panelUrl: string | null;
|
|
};
|
|
};
|
|
}
|
|
|
|
interface LinkTarget {
|
|
address: string;
|
|
port: number;
|
|
securityOverride?: string;
|
|
remark?: string;
|
|
}
|
|
|
|
export type SubscriptionOutputFormat = "base64" | "uri" | "clash";
|
|
|
|
export interface SubscriptionTrafficStats {
|
|
upload?: bigint | number | null;
|
|
download?: bigint | number | null;
|
|
total?: bigint | number | null;
|
|
expire?: Date | null;
|
|
}
|
|
|
|
const CLASH_FORMAT_ALIASES = new Set(["clash", "clash-meta", "mihomo", "yaml", "yml"]);
|
|
const URI_FORMAT_ALIASES = new Set(["uri", "raw", "plain", "text"]);
|
|
const BASE64_FORMAT_ALIASES = new Set(["base64", "v2ray", "generic"]);
|
|
|
|
function normalizeSubscriptionFormat(raw: string | null): SubscriptionOutputFormat | null {
|
|
if (!raw) return null;
|
|
const normalized = raw.trim().toLowerCase();
|
|
if (CLASH_FORMAT_ALIASES.has(normalized)) return "clash";
|
|
if (URI_FORMAT_ALIASES.has(normalized)) return "uri";
|
|
if (BASE64_FORMAT_ALIASES.has(normalized)) return "base64";
|
|
return null;
|
|
}
|
|
|
|
function isClashLikeUserAgent(userAgent: string | null | undefined) {
|
|
if (!userAgent) return false;
|
|
return /clash|mihomo|verge|stash|flclash|nyanpasu|openclash/i.test(userAgent);
|
|
}
|
|
|
|
export function resolveSubscriptionFormat(
|
|
searchParams: URLSearchParams,
|
|
userAgent?: string | null,
|
|
): SubscriptionOutputFormat {
|
|
return normalizeSubscriptionFormat(searchParams.get("format") ?? searchParams.get("target"))
|
|
?? (isClashLikeUserAgent(userAgent) ? "clash" : "base64");
|
|
}
|
|
|
|
export function getSubscriptionContentType(format: SubscriptionOutputFormat) {
|
|
return format === "clash" ? "text/yaml; charset=utf-8" : "text/plain; charset=utf-8";
|
|
}
|
|
|
|
export function getSubscriptionFilename(baseName: string, format: SubscriptionOutputFormat) {
|
|
return `${baseName}.${format === "clash" ? "yaml" : "txt"}`;
|
|
}
|
|
|
|
function bigintHeaderValue(value: bigint | number | null | undefined) {
|
|
if (typeof value === "bigint") return value.toString();
|
|
if (typeof value === "number" && Number.isFinite(value)) return Math.max(0, Math.floor(value)).toString();
|
|
return "0";
|
|
}
|
|
|
|
export function buildSubscriptionUserInfo(stats: SubscriptionTrafficStats | null | undefined) {
|
|
if (!stats) return null;
|
|
const expire = stats.expire ? Math.floor(stats.expire.getTime() / 1000) : 0;
|
|
return [
|
|
`upload=${bigintHeaderValue(stats.upload)}`,
|
|
`download=${bigintHeaderValue(stats.download)}`,
|
|
`total=${bigintHeaderValue(stats.total)}`,
|
|
`expire=${expire}`,
|
|
].join("; ");
|
|
}
|
|
|
|
function getAggregateSubscriptionSecret() {
|
|
const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET ?? process.env.DATABASE_URL;
|
|
if (!secret) {
|
|
throw new Error("缺少订阅链接签名密钥,请配置 NEXTAUTH_SECRET");
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
export function getAggregateSubscriptionToken(userId: string): string {
|
|
return createHmac("sha256", getAggregateSubscriptionSecret())
|
|
.update(`aggregate-subscription:${userId}`)
|
|
.digest("base64url");
|
|
}
|
|
|
|
export function verifyAggregateSubscriptionToken(userId: string, token: string): boolean {
|
|
const expected = getAggregateSubscriptionToken(userId);
|
|
try {
|
|
return timingSafeEqual(Buffer.from(expected), Buffer.from(token));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function asRecord(value: unknown): JsonRecord | null {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : null;
|
|
}
|
|
|
|
function asArray(value: unknown): unknown[] {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
function asString(value: unknown): string | null {
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
function asBoolean(value: unknown): boolean {
|
|
return value === true;
|
|
}
|
|
|
|
function asNumber(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function stringList(value: unknown): string[] {
|
|
if (Array.isArray(value)) {
|
|
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
|
|
}
|
|
if (typeof value === "string" && value.length > 0) {
|
|
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function firstString(value: unknown): string | null {
|
|
return stringList(value)[0] ?? asString(value);
|
|
}
|
|
|
|
function getDeep(value: unknown, key: string): unknown {
|
|
const record = asRecord(value);
|
|
if (record) {
|
|
if (key in record) return record[key];
|
|
for (const child of Object.values(record)) {
|
|
const result = getDeep(child, key);
|
|
if (result !== undefined) return result;
|
|
}
|
|
}
|
|
|
|
for (const child of asArray(value)) {
|
|
const result = getDeep(child, key);
|
|
if (result !== undefined) return result;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function addParam(params: URLSearchParams, key: string, value: unknown) {
|
|
const normalized = typeof value === "number" ? String(value) : asString(value);
|
|
if (normalized) params.set(key, normalized);
|
|
}
|
|
|
|
function addParamList(params: URLSearchParams, key: string, value: unknown) {
|
|
const values = stringList(value);
|
|
if (values.length > 0) params.set(key, values.join(","));
|
|
}
|
|
|
|
function getDisplayName(nodeClient: ProxyNodeContext, target?: LinkTarget): string {
|
|
const settings = asRecord(nodeClient.inbound.settings);
|
|
const base = asString(settings?.displayName) ?? `${nodeClient.inbound.server.name}-${nodeClient.inbound.tag}`;
|
|
return target?.remark ? `${base}-${target.remark}` : base;
|
|
}
|
|
|
|
function isWildcardListen(value: string | null) {
|
|
return !value || value === "0.0.0.0" || value === "::" || value === "::0";
|
|
}
|
|
|
|
function getSettings(nodeClient: ProxyNodeContext): JsonRecord {
|
|
return asRecord(nodeClient.inbound.settings) ?? {};
|
|
}
|
|
|
|
function getStream(nodeClient: ProxyNodeContext): JsonRecord {
|
|
return asRecord(nodeClient.inbound.streamSettings) ?? {};
|
|
}
|
|
|
|
function getPanelListen(settings: JsonRecord): string | null {
|
|
const meta = asRecord(settings._jboard);
|
|
return asString(meta?.listen) ?? null;
|
|
}
|
|
|
|
function getServerAddress(nodeClient: ProxyNodeContext): string {
|
|
const settings = getSettings(nodeClient);
|
|
const listen = getPanelListen(settings);
|
|
if (!isWildcardListen(listen)) return listen!;
|
|
|
|
const server = nodeClient.inbound.server;
|
|
if (!server.panelUrl) return server.name;
|
|
try {
|
|
const withProtocol = /^https?:\/\//i.test(server.panelUrl)
|
|
? server.panelUrl
|
|
: `http://${server.panelUrl}`;
|
|
return new URL(withProtocol).hostname || server.name;
|
|
} catch {
|
|
return server.name;
|
|
}
|
|
}
|
|
|
|
function formatHost(host: string) {
|
|
if (host.includes(":") && !host.startsWith("[")) return `[${host}]`;
|
|
return host;
|
|
}
|
|
|
|
function getClients(settings: JsonRecord): PanelClient[] {
|
|
return asArray(settings.clients)
|
|
.map((item) => asRecord(item) as PanelClient | null)
|
|
.filter((item): item is PanelClient => item != null);
|
|
}
|
|
|
|
function findClient(nodeClient: ProxyNodeContext): PanelClient | null {
|
|
const settings = getSettings(nodeClient);
|
|
return getClients(settings).find((client) => {
|
|
return client.email === nodeClient.email
|
|
|| client.id === nodeClient.uuid
|
|
|| client.password === nodeClient.uuid
|
|
|| client.auth === nodeClient.uuid;
|
|
}) ?? null;
|
|
}
|
|
|
|
function firstClientValue(settings: JsonRecord, key: keyof PanelClient): string | null {
|
|
for (const client of getClients(settings)) {
|
|
const value = asString(client[key]);
|
|
if (value) return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getHeaderValue(headers: unknown, headerName: string): string | null {
|
|
const record = asRecord(headers);
|
|
if (!record) return null;
|
|
|
|
const matchedKey = Object.keys(record).find((key) => key.toLowerCase() === headerName.toLowerCase());
|
|
return matchedKey ? firstString(record[matchedKey]) : null;
|
|
}
|
|
|
|
function applyPathAndHost(settings: unknown, params: URLSearchParams) {
|
|
const record = asRecord(settings);
|
|
if (!record) return;
|
|
|
|
addParam(params, "path", record.path);
|
|
addParam(params, "host", asString(record.host) ?? getHeaderValue(record.headers, "host"));
|
|
}
|
|
|
|
function applyPathAndHostObj(settings: unknown, obj: JsonRecord) {
|
|
const record = asRecord(settings);
|
|
if (!record) return;
|
|
|
|
const path = asString(record.path);
|
|
const host = asString(record.host) ?? getHeaderValue(record.headers, "host");
|
|
if (path) obj.path = path;
|
|
if (host) obj.host = host;
|
|
}
|
|
|
|
function applyTcpParams(stream: JsonRecord, params: URLSearchParams) {
|
|
const tcp = asRecord(stream.tcpSettings);
|
|
const header = asRecord(tcp?.header);
|
|
if (!header || asString(header.type) !== "http") return;
|
|
|
|
const request = asRecord(header.request);
|
|
addParam(params, "path", firstString(request?.path));
|
|
addParam(params, "host", getHeaderValue(request?.headers, "host"));
|
|
params.set("headerType", "http");
|
|
}
|
|
|
|
function applyTcpObj(stream: JsonRecord, obj: JsonRecord) {
|
|
const tcp = asRecord(stream.tcpSettings);
|
|
const header = asRecord(tcp?.header);
|
|
const type = asString(header?.type) ?? "none";
|
|
obj.type = type;
|
|
if (!header || type !== "http") return;
|
|
|
|
const request = asRecord(header.request);
|
|
const path = firstString(request?.path);
|
|
const host = getHeaderValue(request?.headers, "host");
|
|
if (path) obj.path = path;
|
|
if (host) obj.host = host;
|
|
}
|
|
|
|
function applyKcpParams(stream: JsonRecord, params: URLSearchParams) {
|
|
const kcp = asRecord(stream.kcpSettings);
|
|
const header = asRecord(kcp?.header);
|
|
addParam(params, "headerType", header?.type);
|
|
addParam(params, "seed", kcp?.seed);
|
|
addParam(params, "mtu", asNumber(kcp?.mtu));
|
|
addParam(params, "tti", asNumber(kcp?.tti));
|
|
}
|
|
|
|
function applyKcpObj(stream: JsonRecord, obj: JsonRecord) {
|
|
const kcp = asRecord(stream.kcpSettings);
|
|
const header = asRecord(kcp?.header);
|
|
const headerType = asString(header?.type);
|
|
const seed = asString(kcp?.seed);
|
|
if (headerType && headerType !== "none") obj.type = headerType;
|
|
if (seed) obj.path = seed;
|
|
const mtu = asNumber(kcp?.mtu);
|
|
const tti = asNumber(kcp?.tti);
|
|
if (mtu) obj.mtu = mtu;
|
|
if (tti) obj.tti = tti;
|
|
}
|
|
|
|
function applyGrpcParams(stream: JsonRecord, params: URLSearchParams) {
|
|
const grpc = asRecord(stream.grpcSettings);
|
|
if (!grpc) return;
|
|
addParam(params, "serviceName", grpc.serviceName);
|
|
addParam(params, "authority", grpc.authority);
|
|
if (asBoolean(grpc.multiMode)) params.set("mode", "multi");
|
|
}
|
|
|
|
function applyGrpcObj(stream: JsonRecord, obj: JsonRecord) {
|
|
const grpc = asRecord(stream.grpcSettings);
|
|
if (!grpc) return;
|
|
const serviceName = asString(grpc.serviceName);
|
|
const authority = asString(grpc.authority);
|
|
if (serviceName) obj.path = serviceName;
|
|
if (authority) obj.authority = authority;
|
|
if (asBoolean(grpc.multiMode)) obj.type = "multi";
|
|
}
|
|
|
|
function applyNetworkParams(stream: JsonRecord, network: string, params: URLSearchParams) {
|
|
switch (network) {
|
|
case "tcp":
|
|
applyTcpParams(stream, params);
|
|
break;
|
|
case "kcp":
|
|
applyKcpParams(stream, params);
|
|
break;
|
|
case "ws":
|
|
applyPathAndHost(stream.wsSettings, params);
|
|
break;
|
|
case "grpc":
|
|
applyGrpcParams(stream, params);
|
|
break;
|
|
case "httpupgrade":
|
|
applyPathAndHost(stream.httpupgradeSettings, params);
|
|
break;
|
|
case "xhttp": {
|
|
const xhttp = asRecord(stream.xhttpSettings);
|
|
applyPathAndHost(xhttp, params);
|
|
addParam(params, "mode", xhttp?.mode);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyNetworkObj(stream: JsonRecord, network: string, obj: JsonRecord) {
|
|
obj.net = network;
|
|
switch (network) {
|
|
case "tcp":
|
|
applyTcpObj(stream, obj);
|
|
break;
|
|
case "kcp":
|
|
applyKcpObj(stream, obj);
|
|
break;
|
|
case "ws":
|
|
applyPathAndHostObj(stream.wsSettings, obj);
|
|
break;
|
|
case "grpc":
|
|
applyGrpcObj(stream, obj);
|
|
break;
|
|
case "httpupgrade":
|
|
applyPathAndHostObj(stream.httpupgradeSettings, obj);
|
|
break;
|
|
case "xhttp": {
|
|
const xhttp = asRecord(stream.xhttpSettings);
|
|
applyPathAndHostObj(xhttp, obj);
|
|
const mode = asString(xhttp?.mode);
|
|
if (mode) obj.type = mode;
|
|
break;
|
|
}
|
|
default:
|
|
obj.type = "none";
|
|
}
|
|
}
|
|
|
|
function hasJsonContent(value: unknown): boolean {
|
|
if (Array.isArray(value)) return value.some(hasJsonContent);
|
|
const record = asRecord(value);
|
|
if (record) return Object.values(record).some(hasJsonContent);
|
|
if (typeof value === "string") return value.length > 0;
|
|
return typeof value === "number" || value === true;
|
|
}
|
|
|
|
function applyFinalMaskParams(stream: JsonRecord, params: URLSearchParams) {
|
|
const finalmask = asRecord(stream.finalmask);
|
|
if (!finalmask || !hasJsonContent(finalmask)) return;
|
|
params.set("fm", JSON.stringify(finalmask));
|
|
}
|
|
|
|
function applyFinalMaskObj(stream: JsonRecord, obj: JsonRecord) {
|
|
const finalmask = asRecord(stream.finalmask);
|
|
if (!finalmask || !hasJsonContent(finalmask)) return;
|
|
obj.fm = JSON.stringify(finalmask);
|
|
}
|
|
|
|
function applyTlsParams(stream: JsonRecord, params: URLSearchParams, includeInsecure = false) {
|
|
params.set("security", "tls");
|
|
const tls = asRecord(stream.tlsSettings);
|
|
const nested = asRecord(tls?.settings);
|
|
addParamList(params, "alpn", tls?.alpn);
|
|
addParam(params, "sni", tls?.serverName ?? nested?.serverName);
|
|
addParam(params, "fp", nested?.fingerprint ?? tls?.fingerprint);
|
|
addParam(params, "ech", nested?.echConfigList ?? tls?.echConfigList);
|
|
if (includeInsecure && (asBoolean(nested?.allowInsecure) || asBoolean(tls?.allowInsecure))) {
|
|
params.set("insecure", "1");
|
|
}
|
|
}
|
|
|
|
function applyTlsObj(stream: JsonRecord, obj: JsonRecord) {
|
|
const tls = asRecord(stream.tlsSettings);
|
|
const nested = asRecord(tls?.settings);
|
|
const alpn = stringList(tls?.alpn);
|
|
const sni = asString(tls?.serverName) ?? asString(nested?.serverName);
|
|
const fp = asString(nested?.fingerprint) ?? asString(tls?.fingerprint);
|
|
if (alpn.length > 0) obj.alpn = alpn.join(",");
|
|
if (sni) obj.sni = sni;
|
|
if (fp) obj.fp = fp;
|
|
}
|
|
|
|
function applyRealityParams(stream: JsonRecord, params: URLSearchParams) {
|
|
params.set("security", "reality");
|
|
const reality = asRecord(stream.realitySettings);
|
|
const nested = asRecord(reality?.settings);
|
|
addParam(params, "sni", firstString(reality?.serverNames) ?? reality?.serverName);
|
|
addParam(params, "pbk", nested?.publicKey ?? reality?.publicKey);
|
|
addParam(params, "sid", firstString(reality?.shortIds) ?? reality?.shortId);
|
|
addParam(params, "fp", nested?.fingerprint ?? reality?.fingerprint);
|
|
addParam(params, "spx", nested?.spiderX ?? reality?.spiderX);
|
|
addParam(params, "pqv", nested?.mldsa65Verify ?? reality?.mldsa65Verify);
|
|
}
|
|
|
|
function buildStreamParams(
|
|
stream: JsonRecord,
|
|
options: {
|
|
settings?: JsonRecord;
|
|
includeEncryption?: boolean;
|
|
includeSecurityNone?: boolean;
|
|
includeInsecure?: boolean;
|
|
flow?: string | null;
|
|
securityOverride?: string;
|
|
} = {},
|
|
): URLSearchParams {
|
|
const params = new URLSearchParams();
|
|
const network = asString(stream.network) ?? "tcp";
|
|
const security = options.securityOverride && options.securityOverride !== "same"
|
|
? options.securityOverride
|
|
: (asString(stream.security) ?? "none");
|
|
|
|
params.set("type", network);
|
|
if (options.includeEncryption) {
|
|
params.set("encryption", asString(options.settings?.encryption) ?? "none");
|
|
}
|
|
|
|
applyNetworkParams(stream, network, params);
|
|
applyFinalMaskParams(stream, params);
|
|
|
|
if (security === "tls") {
|
|
applyTlsParams(stream, params, options.includeInsecure);
|
|
} else if (security === "reality") {
|
|
applyRealityParams(stream, params);
|
|
} else if (options.includeSecurityNone) {
|
|
params.set("security", "none");
|
|
}
|
|
|
|
if ((security === "tls" || security === "reality") && network === "tcp" && options.flow) {
|
|
params.set("flow", options.flow);
|
|
}
|
|
|
|
return params;
|
|
}
|
|
|
|
function appendQueryAndHash(base: string, params: URLSearchParams, label: string) {
|
|
const query = params.toString();
|
|
return `${base}${query ? `?${query}` : ""}#${encodeURIComponent(label)}`;
|
|
}
|
|
|
|
function stripUndefined<T extends JsonRecord>(value: T): T {
|
|
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null && item !== "")) as T;
|
|
}
|
|
|
|
function yamlQuote(value: string) {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function toYaml(value: unknown, indent = 0): string {
|
|
const pad = " ".repeat(indent);
|
|
if (Array.isArray(value)) {
|
|
if (value.length === 0) return `${pad}[]`;
|
|
return value.map((item) => {
|
|
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
const record = item as JsonRecord;
|
|
const entries = Object.entries(record);
|
|
if (entries.length === 0) return `${pad}- {}`;
|
|
const [firstKey, firstValue] = entries[0];
|
|
const firstLine = `${pad}- ${firstKey}: ${yamlInline(firstValue)}`;
|
|
const rest = entries.slice(1).map(([key, child]) => `${pad} ${key}: ${yamlBlock(child, indent + 2)}`);
|
|
return [firstLine, ...rest].join("\n");
|
|
}
|
|
return `${pad}- ${yamlInline(item)}`;
|
|
}).join("\n");
|
|
}
|
|
|
|
const record = asRecord(value);
|
|
if (!record) return `${pad}${yamlInline(value)}`;
|
|
const entries = Object.entries(record);
|
|
if (entries.length === 0) return `${pad}{}`;
|
|
return entries.map(([key, child]) => `${pad}${key}: ${yamlBlock(child, indent)}`).join("\n");
|
|
}
|
|
|
|
function yamlBlock(value: unknown, indent: number) {
|
|
if (value && typeof value === "object") {
|
|
const nested = toYaml(value, indent + 2);
|
|
return `\n${nested}`;
|
|
}
|
|
return yamlInline(value);
|
|
}
|
|
|
|
function yamlInline(value: unknown): string {
|
|
if (typeof value === "string") return yamlQuote(value);
|
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
if (typeof value === "boolean") return value ? "true" : "false";
|
|
if (value == null) return "null";
|
|
return yamlQuote(String(value));
|
|
}
|
|
|
|
function getTargets(nodeClient: ProxyNodeContext): LinkTarget[] {
|
|
const stream = getStream(nodeClient);
|
|
const proxies: LinkTarget[] = [];
|
|
|
|
for (const item of asArray(stream.externalProxy)) {
|
|
const record = asRecord(item);
|
|
if (!record) continue;
|
|
|
|
const address = asString(record.dest);
|
|
const port = asNumber(record.port);
|
|
if (!address || !port) continue;
|
|
|
|
const securityOverride = asString(record.forceTls);
|
|
const remark = asString(record.remark);
|
|
proxies.push({
|
|
address,
|
|
port,
|
|
...(securityOverride ? { securityOverride } : {}),
|
|
...(remark ? { remark } : {}),
|
|
});
|
|
}
|
|
|
|
if (proxies.length > 0) return proxies;
|
|
return [{ address: getServerAddress(nodeClient), port: nodeClient.inbound.port }];
|
|
}
|
|
|
|
function getStreamSecurity(stream: JsonRecord, target?: LinkTarget) {
|
|
return target?.securityOverride && target.securityOverride !== "same"
|
|
? target.securityOverride
|
|
: (asString(stream.security) ?? "none");
|
|
}
|
|
|
|
function getTlsSettings(stream: JsonRecord) {
|
|
const tls = asRecord(stream.tlsSettings);
|
|
const nested = asRecord(tls?.settings);
|
|
return { tls, nested };
|
|
}
|
|
|
|
function getRealitySettings(stream: JsonRecord) {
|
|
const reality = asRecord(stream.realitySettings);
|
|
const nested = asRecord(reality?.settings);
|
|
return { reality, nested };
|
|
}
|
|
|
|
function applyClashTls(proxy: JsonRecord, stream: JsonRecord, security: string) {
|
|
if (security !== "tls" && security !== "reality") return;
|
|
|
|
proxy.tls = true;
|
|
if (security === "tls") {
|
|
const { tls, nested } = getTlsSettings(stream);
|
|
const serverName = asString(tls?.serverName) ?? asString(nested?.serverName);
|
|
const fingerprint = asString(nested?.fingerprint) ?? asString(tls?.fingerprint);
|
|
const alpn = stringList(tls?.alpn);
|
|
if (serverName) proxy.servername = serverName;
|
|
if (fingerprint) proxy["client-fingerprint"] = fingerprint;
|
|
if (alpn.length > 0) proxy.alpn = alpn;
|
|
if (asBoolean(nested?.allowInsecure) || asBoolean(tls?.allowInsecure)) {
|
|
proxy["skip-cert-verify"] = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const { reality, nested } = getRealitySettings(stream);
|
|
const serverName = firstString(reality?.serverNames) ?? asString(reality?.serverName);
|
|
const publicKey = asString(nested?.publicKey) ?? asString(reality?.publicKey);
|
|
const shortId = firstString(reality?.shortIds) ?? asString(reality?.shortId);
|
|
const fingerprint = asString(nested?.fingerprint) ?? asString(reality?.fingerprint);
|
|
const spiderX = asString(nested?.spiderX) ?? asString(reality?.spiderX);
|
|
if (serverName) proxy.servername = serverName;
|
|
if (fingerprint) proxy["client-fingerprint"] = fingerprint;
|
|
proxy["reality-opts"] = stripUndefined({
|
|
"public-key": publicKey,
|
|
"short-id": shortId,
|
|
"spider-x": spiderX,
|
|
});
|
|
}
|
|
|
|
function getPathAndHost(settings: unknown) {
|
|
const record = asRecord(settings);
|
|
if (!record) return { path: null, host: null };
|
|
return {
|
|
path: asString(record.path),
|
|
host: asString(record.host) ?? getHeaderValue(record.headers, "host"),
|
|
};
|
|
}
|
|
|
|
function applyClashTransport(proxy: JsonRecord, stream: JsonRecord) {
|
|
const network = asString(stream.network) ?? "tcp";
|
|
if (network === "tcp") {
|
|
const tcp = asRecord(stream.tcpSettings);
|
|
const header = asRecord(tcp?.header);
|
|
if (asString(header?.type) !== "http") return;
|
|
const request = asRecord(header?.request);
|
|
proxy.network = "http";
|
|
proxy["http-opts"] = stripUndefined({
|
|
path: stringList(request?.path),
|
|
headers: stripUndefined({ Host: stringList(asRecord(request?.headers)?.Host ?? asRecord(request?.headers)?.host) }),
|
|
});
|
|
return;
|
|
}
|
|
|
|
proxy.network = network;
|
|
if (network === "ws") {
|
|
const { path, host } = getPathAndHost(stream.wsSettings);
|
|
proxy["ws-opts"] = stripUndefined({
|
|
path,
|
|
headers: host ? { Host: host } : undefined,
|
|
});
|
|
} else if (network === "grpc") {
|
|
const grpc = asRecord(stream.grpcSettings);
|
|
proxy["grpc-opts"] = stripUndefined({
|
|
"grpc-service-name": asString(grpc?.serviceName),
|
|
});
|
|
} else if (network === "httpupgrade") {
|
|
const { path, host } = getPathAndHost(stream.httpupgradeSettings);
|
|
proxy["httpupgrade-opts"] = stripUndefined({
|
|
path,
|
|
headers: host ? { Host: host } : undefined,
|
|
});
|
|
} else if (network === "xhttp") {
|
|
const { path, host } = getPathAndHost(stream.xhttpSettings);
|
|
proxy["xhttp-opts"] = stripUndefined({
|
|
path,
|
|
headers: host ? { Host: host } : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
function buildClashProxy(nodeClient: ProxyNodeContext, target: LinkTarget, client: PanelClient | null) {
|
|
const protocol = nodeClient.inbound.protocol.toLowerCase();
|
|
const stream = getStream(nodeClient);
|
|
const settings = getSettings(nodeClient);
|
|
const security = getStreamSecurity(stream, target);
|
|
const base: JsonRecord = {
|
|
name: getDisplayName(nodeClient, target),
|
|
server: target.address,
|
|
port: target.port,
|
|
udp: true,
|
|
};
|
|
|
|
let proxy: JsonRecord | null = null;
|
|
if (protocol === "vmess") {
|
|
proxy = {
|
|
...base,
|
|
type: "vmess",
|
|
uuid: asString(client?.id) ?? nodeClient.uuid,
|
|
alterId: asNumber(settings.alterId) ?? 0,
|
|
cipher: getVmessSecurity(settings, client),
|
|
};
|
|
} else if (protocol === "vless") {
|
|
proxy = stripUndefined({
|
|
...base,
|
|
type: "vless",
|
|
uuid: asString(client?.id) ?? nodeClient.uuid,
|
|
flow: getVlessFlow(settings, client),
|
|
});
|
|
} else if (protocol === "trojan") {
|
|
proxy = {
|
|
...base,
|
|
type: "trojan",
|
|
password: asString(client?.password) ?? nodeClient.uuid,
|
|
};
|
|
} else if (protocol === "shadowsocks") {
|
|
const method = asString(settings.method) ?? asString(client?.method) ?? "chacha20-ietf-poly1305";
|
|
const inboundPassword = asString(settings.password) ?? asString(settings.serverKey);
|
|
const clientPassword = asString(client?.password) ?? nodeClient.uuid;
|
|
proxy = {
|
|
...base,
|
|
type: "ss",
|
|
cipher: method,
|
|
password: method.startsWith("2022-") && inboundPassword ? `${inboundPassword}:${clientPassword}` : clientPassword,
|
|
};
|
|
} else if (protocol === "hysteria2") {
|
|
const obfsPassword = findSalamanderPassword(stream);
|
|
proxy = stripUndefined({
|
|
...base,
|
|
type: asNumber(settings.version) === 1 ? "hysteria" : "hysteria2",
|
|
password: asString(client?.auth) ?? nodeClient.uuid,
|
|
obfs: obfsPassword ? "salamander" : undefined,
|
|
"obfs-password": obfsPassword,
|
|
});
|
|
}
|
|
|
|
if (!proxy) return null;
|
|
applyClashTransport(proxy, stream);
|
|
applyClashTls(proxy, stream, security);
|
|
return stripUndefined(proxy);
|
|
}
|
|
|
|
function dedupeProxyNames(proxies: JsonRecord[]) {
|
|
const seen = new Map<string, number>();
|
|
return proxies.map((proxy) => {
|
|
const name = asString(proxy.name) ?? "J-Board";
|
|
const count = seen.get(name) ?? 0;
|
|
seen.set(name, count + 1);
|
|
return count === 0 ? proxy : { ...proxy, name: `${name} ${count + 1}` };
|
|
});
|
|
}
|
|
|
|
export function buildClashSubscriptionYaml(nodeClients: ProxyNodeContext[]): string {
|
|
const proxies = dedupeProxyNames(
|
|
nodeClients.flatMap((nodeClient) => {
|
|
const client = findClient(nodeClient);
|
|
return getTargets(nodeClient)
|
|
.map((target) => buildClashProxy(nodeClient, target, client))
|
|
.filter((item): item is JsonRecord => item != null);
|
|
}),
|
|
);
|
|
const proxyNames = proxies.map((proxy) => asString(proxy.name) ?? "J-Board");
|
|
const selectableNames = proxyNames.length > 0 ? proxyNames : ["DIRECT"];
|
|
const config = {
|
|
"mixed-port": 7890,
|
|
"allow-lan": false,
|
|
mode: "rule",
|
|
"log-level": "info",
|
|
ipv6: true,
|
|
proxies,
|
|
"proxy-groups": [
|
|
{
|
|
name: "节点选择",
|
|
type: "select",
|
|
proxies: ["自动选择", ...selectableNames, "DIRECT"],
|
|
},
|
|
{
|
|
name: "自动选择",
|
|
type: "url-test",
|
|
proxies: selectableNames,
|
|
url: "https://www.gstatic.com/generate_204",
|
|
interval: 300,
|
|
},
|
|
],
|
|
rules: ["MATCH,节点选择"],
|
|
};
|
|
|
|
return `${toYaml(config)}\n`;
|
|
}
|
|
|
|
function getVmessSecurity(settings: JsonRecord, client: PanelClient | null) {
|
|
return asString(client?.security) ?? firstClientValue(settings, "security") ?? "auto";
|
|
}
|
|
|
|
function getVlessFlow(settings: JsonRecord, client: PanelClient | null) {
|
|
return asString(client?.flow) ?? firstClientValue(settings, "flow");
|
|
}
|
|
|
|
function buildVmessUri(nodeClient: ProxyNodeContext, target: LinkTarget, client: PanelClient | null) {
|
|
const stream = getStream(nodeClient);
|
|
const settings = getSettings(nodeClient);
|
|
const network = asString(stream.network) ?? "tcp";
|
|
const security = target.securityOverride && target.securityOverride !== "same"
|
|
? target.securityOverride
|
|
: (asString(stream.security) ?? "none");
|
|
const label = getDisplayName(nodeClient, target);
|
|
const obj: JsonRecord = {
|
|
v: "2",
|
|
ps: label,
|
|
add: target.address,
|
|
port: target.port,
|
|
id: asString(client?.id) ?? nodeClient.uuid,
|
|
scy: getVmessSecurity(settings, client),
|
|
tls: security,
|
|
type: "none",
|
|
};
|
|
|
|
applyNetworkObj(stream, network, obj);
|
|
applyFinalMaskObj(stream, obj);
|
|
if (security === "tls") applyTlsObj(stream, obj);
|
|
|
|
return `vmess://${Buffer.from(JSON.stringify(obj, null, 2)).toString("base64")}`;
|
|
}
|
|
|
|
function buildVlessUri(nodeClient: ProxyNodeContext, target: LinkTarget, client: PanelClient | null) {
|
|
const stream = getStream(nodeClient);
|
|
const settings = getSettings(nodeClient);
|
|
const params = buildStreamParams(stream, {
|
|
settings,
|
|
includeEncryption: true,
|
|
includeSecurityNone: true,
|
|
flow: getVlessFlow(settings, client),
|
|
securityOverride: target.securityOverride,
|
|
});
|
|
const uuid = asString(client?.id) ?? nodeClient.uuid;
|
|
return appendQueryAndHash(
|
|
`vless://${uuid}@${formatHost(target.address)}:${target.port}`,
|
|
params,
|
|
getDisplayName(nodeClient, target),
|
|
);
|
|
}
|
|
|
|
function buildTrojanUri(nodeClient: ProxyNodeContext, target: LinkTarget, client: PanelClient | null) {
|
|
const stream = getStream(nodeClient);
|
|
const params = buildStreamParams(stream, {
|
|
includeSecurityNone: true,
|
|
flow: asString(client?.flow),
|
|
securityOverride: target.securityOverride,
|
|
});
|
|
const password = asString(client?.password) ?? nodeClient.uuid;
|
|
return appendQueryAndHash(
|
|
`trojan://${password}@${formatHost(target.address)}:${target.port}`,
|
|
params,
|
|
getDisplayName(nodeClient, target),
|
|
);
|
|
}
|
|
|
|
function buildShadowsocksUri(nodeClient: ProxyNodeContext, target: LinkTarget, client: PanelClient | null) {
|
|
const settings = getSettings(nodeClient);
|
|
const stream = getStream(nodeClient);
|
|
const method = asString(settings.method) ?? asString(client?.method) ?? "chacha20-ietf-poly1305";
|
|
const inboundPassword = asString(settings.password) ?? asString(settings.serverKey);
|
|
const clientPassword = asString(client?.password) ?? nodeClient.uuid;
|
|
const password = method.startsWith("2022-") && inboundPassword
|
|
? `${inboundPassword}:${clientPassword}`
|
|
: clientPassword;
|
|
const encoded = Buffer.from(`${method}:${password}`).toString("base64");
|
|
const params = buildStreamParams(stream, {
|
|
securityOverride: target.securityOverride,
|
|
});
|
|
|
|
return appendQueryAndHash(
|
|
`ss://${encoded}@${formatHost(target.address)}:${target.port}`,
|
|
params,
|
|
getDisplayName(nodeClient, target),
|
|
);
|
|
}
|
|
|
|
function findSalamanderPassword(stream: JsonRecord): string | null {
|
|
const finalmask = asRecord(stream.finalmask);
|
|
for (const mask of asArray(finalmask?.udp)) {
|
|
const record = asRecord(mask);
|
|
if (record?.type !== "salamander") continue;
|
|
const settings = asRecord(record.settings);
|
|
const password = asString(settings?.password);
|
|
if (password) return password;
|
|
}
|
|
return asString(getDeep(stream, "obfsPassword")) ?? null;
|
|
}
|
|
|
|
function buildHysteriaUri(nodeClient: ProxyNodeContext, target: LinkTarget, client: PanelClient | null) {
|
|
const stream = getStream(nodeClient);
|
|
const settings = getSettings(nodeClient);
|
|
const protocol = asNumber(settings.version) === 1 ? "hysteria" : "hysteria2";
|
|
const params = new URLSearchParams();
|
|
const auth = asString(client?.auth) ?? nodeClient.uuid;
|
|
const obfsPassword = findSalamanderPassword(stream);
|
|
|
|
applyTlsParams(stream, params, true);
|
|
applyFinalMaskParams(stream, params);
|
|
if (obfsPassword) {
|
|
params.set("obfs", "salamander");
|
|
params.set("obfs-password", obfsPassword);
|
|
}
|
|
|
|
return appendQueryAndHash(
|
|
`${protocol}://${auth}@${formatHost(target.address)}:${target.port}`,
|
|
params,
|
|
getDisplayName(nodeClient, target),
|
|
);
|
|
}
|
|
|
|
export function buildSingleNodeUri(nodeClient: ProxyNodeContext): string {
|
|
const protocol = nodeClient.inbound.protocol.toLowerCase();
|
|
const client = findClient(nodeClient);
|
|
const links = getTargets(nodeClient).map((target) => {
|
|
switch (protocol) {
|
|
case "vmess":
|
|
return buildVmessUri(nodeClient, target, client);
|
|
case "vless":
|
|
return buildVlessUri(nodeClient, target, client);
|
|
case "trojan":
|
|
return buildTrojanUri(nodeClient, target, client);
|
|
case "shadowsocks":
|
|
return buildShadowsocksUri(nodeClient, target, client);
|
|
case "hysteria2":
|
|
return buildHysteriaUri(nodeClient, target, client);
|
|
default:
|
|
return "";
|
|
}
|
|
}).filter(Boolean);
|
|
|
|
return links.join("\n");
|
|
}
|
|
|
|
export async function generateSingleNodeUri(subscriptionId: string): Promise<string> {
|
|
const sub = await prisma.userSubscription.findUniqueOrThrow({
|
|
where: { id: subscriptionId },
|
|
include: {
|
|
nodeClient: {
|
|
include: {
|
|
inbound: { include: { server: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (sub.status !== "ACTIVE") return "";
|
|
if (!sub.nodeClient) return "";
|
|
return buildSingleNodeUri(sub.nodeClient);
|
|
}
|
|
|
|
function encodeSubscriptionContent(uri: string, format: SubscriptionOutputFormat) {
|
|
if (!uri) return "";
|
|
return format === "base64" ? Buffer.from(uri).toString("base64") : uri;
|
|
}
|
|
|
|
export async function generateSubscriptionContent(
|
|
subscriptionId: string,
|
|
format: SubscriptionOutputFormat = "base64",
|
|
): Promise<string> {
|
|
const sub = await prisma.userSubscription.findUniqueOrThrow({
|
|
where: { id: subscriptionId },
|
|
include: {
|
|
nodeClient: {
|
|
include: {
|
|
inbound: { include: { server: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (sub.status !== "ACTIVE" || !sub.nodeClient) return "";
|
|
if (format === "clash") return buildClashSubscriptionYaml([sub.nodeClient]);
|
|
return encodeSubscriptionContent(buildSingleNodeUri(sub.nodeClient), format);
|
|
}
|
|
|
|
export async function generateAggregateSubscriptionContent(
|
|
userId: string,
|
|
format: SubscriptionOutputFormat = "base64",
|
|
): Promise<string> {
|
|
const subscriptions = await prisma.userSubscription.findMany({
|
|
where: {
|
|
userId,
|
|
status: "ACTIVE",
|
|
endDate: { gt: new Date() },
|
|
plan: { type: "PROXY" },
|
|
nodeClient: { isNot: null },
|
|
},
|
|
include: {
|
|
nodeClient: {
|
|
include: {
|
|
inbound: { include: { server: true } },
|
|
},
|
|
},
|
|
},
|
|
orderBy: [{ endDate: "asc" }, { createdAt: "asc" }],
|
|
});
|
|
|
|
const nodeClients = subscriptions
|
|
.map((subscription) => subscription.nodeClient)
|
|
.filter((nodeClient): nodeClient is NonNullable<typeof nodeClient> => nodeClient != null);
|
|
|
|
if (format === "clash") return buildClashSubscriptionYaml(nodeClients);
|
|
|
|
const content = nodeClients
|
|
.map((nodeClient) => buildSingleNodeUri(nodeClient))
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
return encodeSubscriptionContent(content, format);
|
|
}
|