merge main into dev

This commit is contained in:
JetSprow
2026-04-29 15:32:12 +10:00
36 changed files with 681 additions and 124 deletions

View File

@@ -6,7 +6,7 @@ import { spawn } from "child_process";
function getDatabaseUrl() {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error("DATABASE_URL 未配置");
throw new Error("数据库备份失败:DATABASE_URL 未配置,无法连接数据库");
}
return url;
}

View File

@@ -5,7 +5,7 @@ import { ThreeXUIAdapter } from "./three-x-ui";
export function createPanelAdapter(server: NodeServer): NodePanelAdapter {
const panelType = server.panelType ?? "3x-ui";
if (panelType !== "3x-ui") {
throw new Error(`Unsupported panel type: ${panelType}`);
throw new Error(`节点 ${server.name} 面板类型不支持:${panelType},当前仅支持 3x-ui`);
}
if (!server.panelUrl || !server.panelUsername || !server.panelPassword) {
throw new Error(`节点 ${server.name} 未配置 3x-ui 面板信息`);

View File

@@ -89,7 +89,7 @@ function errorMessage(error: unknown): string {
}
if (error.message) return error.message;
}
return "未知错误";
return "未知错误:没有收到面板或网络层返回的具体错误内容";
}
async function repairJBoardClientSubIds(

View File

@@ -114,7 +114,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter {
if (!res.ok) {
throw new Error(data.msg || `3x-ui API HTTP ${res.status}`);
}
if (!data.success) throw new Error(data.msg || "3x-ui API error");
if (!data.success) throw new Error(data.msg || `3x-ui 接口返回失败但没有错误消息:${path}`);
return data.obj as T;
}
@@ -182,7 +182,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter {
return false;
}
throw new Error(`登录失败:${lastMessage || "未知错误"}`);
throw new Error(`登录失败:${lastMessage || "面板没有返回具体错误内容,请检查地址、账号密码和面板状态"}`);
}
async getInbounds(): Promise<PanelInbound[]> {
@@ -223,7 +223,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter {
enable: boolean,
): Promise<void> {
const inbound = await this.getInbound(inboundId);
if (!inbound) throw new Error("Inbound not found");
if (!inbound) throw new Error(`3x-ui 入站不存在:面板入站 ID ${inboundId} 未找到,请重新同步节点入站`);
const settings = parseInboundSettings(inbound.settings);
const client = settings.clients?.find((item) => {
@@ -232,7 +232,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter {
|| item.auth === clientCredential
|| item.email === clientCredential;
});
if (!client) throw new Error("Client not found");
if (!client) throw new Error(`3x-ui 客户端不存在:${clientCredential},请重新同步流量或重置订阅访问`);
client.enable = enable;
await this.jsonRequest(`/panel/api/inbounds/updateClient/${encodeURIComponent(this.getClientPrimaryKey(inbound.protocol, client))}`, {
@@ -327,7 +327,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter {
case "hysteria2":
return JSON.stringify({ clients: [{ ...base, auth: params.uuid }] });
default:
throw new Error(`Unsupported protocol: ${params.protocol}`);
throw new Error(`3x-ui 客户端配置失败:不支持的协议 ${params.protocol}`);
}
}
}

View File

@@ -42,7 +42,7 @@ export async function provisionSubscriptionWithDb(
return applyTrafficTopup(order, db);
}
throw new Error(`Unsupported order kind: ${String(order.kind)}`);
throw new Error(`开通订阅失败:不支持的订单类型 ${String(order.kind)}`);
}
async function getNewPurchaseItems(order: PaidOrder, db: DbClient): Promise<NewOrderItem[]> {
@@ -191,7 +191,7 @@ async function applyRenewal(order: PaidOrder, db: DbClient): Promise<string[]> {
throw new Error("续费目标订阅与订单不匹配");
}
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
throw new Error("续费失败:目标订阅已过期或不可用");
throw new Error(`续费失败:目标订阅状态为 ${subscription.status},到期时间为 ${subscription.endDate.toISOString()}`);
}
const now = new Date();

View File

@@ -37,6 +37,66 @@ interface LinkTarget {
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) {
@@ -439,6 +499,55 @@ function appendQueryAndHash(base: string, params: URLSearchParams, label: string
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[] = [];
@@ -465,6 +574,217 @@ function getTargets(nodeClient: ProxyNodeContext): LinkTarget[] {
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";
}
@@ -627,13 +947,35 @@ export async function generateSingleNodeUri(subscriptionId: string): Promise<str
return buildSingleNodeUri(sub.nodeClient);
}
export async function generateSubscriptionContent(subscriptionId: string): Promise<string> {
const uri = await generateSingleNodeUri(subscriptionId);
function encodeSubscriptionContent(uri: string, format: SubscriptionOutputFormat) {
if (!uri) return "";
return Buffer.from(uri).toString("base64");
return format === "base64" ? Buffer.from(uri).toString("base64") : uri;
}
export async function generateAggregateSubscriptionContent(userId: string): Promise<string> {
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,
@@ -652,10 +994,16 @@ export async function generateAggregateSubscriptionContent(userId: string): Prom
orderBy: [{ endDate: "asc" }, { createdAt: "asc" }],
});
const content = subscriptions
.map((subscription) => subscription.nodeClient ? buildSingleNodeUri(subscription.nodeClient) : "")
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 content ? Buffer.from(content).toString("base64") : "";
return encodeSubscriptionContent(content, format);
}