Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/server-core/src/subscription/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export * from "./response-headers";
export * from "./saved-sources";
export * from "./source-import";
export * from "./ssrf-ip";
export * from "./user-agents";
38 changes: 38 additions & 0 deletions packages/server-core/src/subscription/source-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ proxies:
password: secret
`;

function ssLink(name = "SS Node"): string {
const credential = Buffer.from("aes-128-gcm:secret").toString("base64url");
return `ss://${credential}@ss.example.com:8388#${encodeURIComponent(name)}`;
}

describe("importSubscriptionFromUrl", () => {
it("tries client user agents and keeps supplemental userinfo headers", async () => {
const fetchText = vi.fn(async (request: SourceImportTransportRequest): Promise<SourceImportTransportResult> => {
Expand Down Expand Up @@ -55,6 +60,39 @@ describe("importSubscriptionFromUrl", () => {
expect(fetchText).toHaveBeenCalledWith(expect.objectContaining({ userAgent: "mihomo/1.19.24" }));
});

it("continues to Mihomo when the v2rayN response is link-only and may be incomplete", async () => {
const fetchText = vi.fn(async (request: SourceImportTransportRequest): Promise<SourceImportTransportResult> => {
if (request.userAgent.startsWith("v2rayN/")) {
return { ok: true, content: ssLink("v2rayn"), headers: { "content-type": "text/plain" } };
}
return {
ok: true,
content: [
"proxies:",
" - name: clash-a",
" type: trojan",
" server: clash-a.example.com",
" port: 443",
" password: secret",
" - name: clash-b",
" type: trojan",
" server: clash-b.example.com",
" port: 443",
" password: secret",
].join("\n"),
headers: { "content-type": "text/yaml" },
};
});

const result = await importSubscriptionFromUrl({ url: "https://example.com/sub.yaml" }, { fetchText });

expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.parsedNodes.map((node) => node.name)).toEqual(["clash-a", "clash-b"]);
expect(fetchText).toHaveBeenCalledWith(expect.objectContaining({ userAgent: "v2rayN/7.20.4" }));
expect(fetchText).toHaveBeenCalledWith(expect.objectContaining({ userAgent: "mihomo/1.19.24" }));
});

it("returns structured network failures", async () => {
const result = await importSubscriptionFromUrl(
{ url: "https://example.com/sub.yaml" },
Expand Down
37 changes: 29 additions & 8 deletions packages/server-core/src/subscription/source-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,8 @@ import {
} from "@subboost/core/subscription/import-error";
import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/url-input";
import type { ParseResult, ParsedNode } from "@subboost/core/types/node";

export const SUBSCRIPTION_IMPORT_USER_AGENTS = [
"v2rayN/7.20.4",
"mihomo/1.19.24",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
] as const;
import { shouldTryClashMetaForV2raynPayload } from "./fetch-profile-heuristics";
import { SUBSCRIPTION_IMPORT_USER_AGENTS } from "./user-agents";

export type SourceImportPurpose = "content" | "userinfo";

Expand Down Expand Up @@ -105,6 +101,22 @@ function isUsableParsedAttempt(attempt: ParsedAttempt): attempt is Extract<Parse
return attempt.ok && attempt.parsed.nodes.length > 0 && !looksLikeClientUpdatePlaceholderNodes(attempt.parsed.nodes);
}

function shouldContinueAfterCleanAttempt(params: {
attempt: ParsedAttempt;
currentUserAgent: string;
nextUserAgent?: string;
}): boolean {
const { attempt, currentUserAgent, nextUserAgent } = params;
if (!isUsableParsedAttempt(attempt) || attempt.parsed.errors.length > 0) return true;
if (
currentUserAgent === SUBSCRIPTION_IMPORT_USER_AGENTS[0] &&
nextUserAgent === SUBSCRIPTION_IMPORT_USER_AGENTS[1]
) {
return shouldTryClashMetaForV2raynPayload(attempt.content, attempt.parsed);
}
return false;
}

function toFailure(attempt: ParsedAttempt | null, fallback = "获取 url 失败"): SourceImportFailure {
if (!attempt) {
return {
Expand Down Expand Up @@ -264,14 +276,23 @@ export async function importSubscriptionFromUrl(
const userAgents = options.userAgents?.length ? options.userAgents : SUBSCRIPTION_IMPORT_USER_AGENTS;
let best: ParsedAttempt | null = null;

for (const userAgent of userAgents) {
for (let index = 0; index < userAgents.length; index += 1) {
const userAgent = userAgents[index];
const attempt = await fetchAndParseWithUserAgent(url, userAgent, {
timeoutMs,
maxBytes,
fetchText: options.fetchText,
});
best = pickBetterAttempt(best, attempt);
if (isUsableParsedAttempt(attempt) && attempt.parsed.errors.length === 0) break;
if (
!shouldContinueAfterCleanAttempt({
attempt,
currentUserAgent: userAgent,
nextUserAgent: userAgents[index + 1],
})
) {
break;
}
}

if (!best || !best.ok || !isUsableParsedAttempt(best)) {
Expand Down
5 changes: 5 additions & 0 deletions packages/server-core/src/subscription/user-agents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const SUBSCRIPTION_IMPORT_USER_AGENTS = [
"v2rayN/7.20.4",
"mihomo/1.19.24",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
] as const;