diff --git a/local/src/lib/source-import.ts b/local/src/lib/source-import.ts index f4e7abb..f85becb 100644 --- a/local/src/lib/source-import.ts +++ b/local/src/lib/source-import.ts @@ -1,3 +1,4 @@ +import { resolveSourceHosts } from "@subboost/server-core/subscription/source-hosts"; import { lookup } from "node:dns/promises"; import { isIP } from "node:net"; import { @@ -244,11 +245,15 @@ async function fetchTextDirect( export async function importSourceUrlDirect(request: SourceImportRequest): Promise { const allowUnsafeSubscriptionSources = await getAllowUnsafeSubscriptionSources(); - return importSubscriptionFromUrl(request, { + const result = await importSubscriptionFromUrl(request, { timeoutMs: DEFAULT_TIMEOUT_MS, maxBytes: DEFAULT_MAX_BYTES, fetchText: (transportRequest) => fetchTextDirect(transportRequest, allowUnsafeSubscriptionSources), }); + if (result.ok && result.sourceConfig?.hosts) { + return { ...result, resolvedHosts: await resolveSourceHosts(result.sourceConfig) }; + } + return result; } export async function fetchSourceUserInfoHeadersDirect(source: { diff --git a/local/src/lib/subscription-service.ts b/local/src/lib/subscription-service.ts index ab567c6..e620756 100644 --- a/local/src/lib/subscription-service.ts +++ b/local/src/lib/subscription-service.ts @@ -1,7 +1,9 @@ +import { resolveSourceHosts } from "@subboost/server-core/subscription/source-hosts"; import { randomBytes, randomUUID } from "node:crypto"; import { generateClashYaml } from "@subboost/core/generator"; import { buildGenerateOptionsFromConfig, getEffectiveTestOptions } from "@subboost/core/subscription/config-utils"; import { buildProxyProvidersFromConfig } from "@subboost/core/subscription/proxy-providers"; +import { collectSourceConfigs, hasUsableSourceConfig } from "@subboost/core/subscription/source-snapshot"; import type { SubscriptionResponseInfo } from "@subboost/core/subscription/subscription-response-info"; import type { ParsedNode } from "@subboost/core/types/node"; import { @@ -149,7 +151,7 @@ function assertNodeNameFilterKeepsOutput( const options = buildGenerateOptionsFromConfig(config, { nodes }); const hasProxyProviders = Boolean( options.proxyProviders && Object.keys(options.proxyProviders).length > 0 - ); + ) || collectSourceConfigs(config.sources).some(source => hasUsableSourceConfig(source.config)); if (options.nodes.length === 0 && !hasProxyProviders) { throw new Error("过滤后没有可用节点"); } @@ -213,9 +215,10 @@ export async function createSubscription(ownerId: string, body: unknown): Promis const urls = normalizeSubscriptionUrlList(body.urls); const nodes = validateLocalSubscriptionNodes(body.nodes); - if (urls.length === 0 && nodes.length === 0) throw new Error("At least one URL or node is required."); - const config = buildLocalSubscriptionConfig(body); + if (urls.length === 0 && nodes.length === 0 && !collectSourceConfigs(config.sources).some(source => hasUsableSourceConfig(source.config))) { + throw new Error("At least one URL or node is required."); + } assertNodeNameFilterKeepsOutput(nodes, config); const autoUpdateInterval = normalizeLocalAutoUpdateIntervalSeconds(body.autoUpdateInterval); const subscriptionInfo = normalizeSubscriptionInfoForPersistence(body.subscriptionInfo) ?? {}; @@ -266,7 +269,7 @@ export async function updateSubscription(ownerId: string, id: string, body: unkn if (hasUrls || hasNodes || hasConfig) { const nextUrls = hasUrls ? normalizeSubscriptionUrlList(body.urls) : currentSecrets.urls; - if (nextUrls.length === 0 && nextNodes.length === 0) { + if (nextUrls.length === 0 && nextNodes.length === 0 && !collectSourceConfigs(nextConfig.sources).some(source => hasUsableSourceConfig(source.config))) { throw new Error("At least one URL or node is required."); } assertNodeNameFilterKeepsOutput(nextNodes, nextConfig); @@ -313,6 +316,7 @@ export async function deleteSubscription(ownerId: string, id: string): Promise { const imported = await importSourceUrlDirect({ url: source.content, @@ -323,6 +327,8 @@ export function buildSubscriptionFetchCallbacks() { return { ok: true, nodes: imported.parsedNodes, + ...(imported.sourceConfig ? { sourceConfig: imported.sourceConfig } : {}), + ...(imported.resolvedHosts ? { resolvedHosts: imported.resolvedHosts } : {}), errors: imported.parseErrors, headers: imported.headers, }; @@ -436,7 +442,7 @@ export async function generateSubscriptionYaml(token: string): Promise hasUsableSourceConfig(source.config))) return null; const yaml = generateClashYaml( buildGenerateOptionsFromConfig(secrets.config, { nodes: secrets.nodes, diff --git a/packages/core/src/generator/index.ts b/packages/core/src/generator/index.ts index e554f99..bf15db6 100644 --- a/packages/core/src/generator/index.ts +++ b/packages/core/src/generator/index.ts @@ -1,3 +1,4 @@ +import { mergeSourceClashConfigs, type SourceClashConfig } from "./source-configs"; /** * Clash 配置生成器 - 浏览器端运行 */ @@ -40,6 +41,7 @@ import { chooseFallbackPolicyTarget, withBuiltinPolicyTargets } from "./policy-t import { resolveGroupListenerEntries, type GroupListenerTargetResolution } from "./group-listeners"; export interface GenerateOptions { + sourceConfigs?: SourceClashConfig[]; nodes: ParsedNode[]; proxyProviders?: Record; template?: TemplateType; @@ -590,7 +592,7 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { }), }; - return clashConfig as unknown as ClashConfig; + return mergeSourceClashConfigs(clashConfig, options.sourceConfigs ?? [], uniqueNodes) as unknown as ClashConfig; } /** diff --git a/packages/core/src/generator/source-configs.ts b/packages/core/src/generator/source-configs.ts new file mode 100644 index 0000000..6056dd6 --- /dev/null +++ b/packages/core/src/generator/source-configs.ts @@ -0,0 +1,154 @@ +import type { ParsedNode } from "../types/node"; +import { getNodeOriginName, getNodeSourceIds } from "../subscription/node-source-state"; + +export interface SourceClashConfig { id: string; config: Record; resolvedHosts?: Record } +const record = (value: unknown): value is Record => Boolean(value) && typeof value === "object" && !Array.isArray(value); + +// Subscription order resolves collisions; explicit SubBoost settings take precedence. +function merge(left: unknown, right: unknown): unknown { + if (record(left) && record(right)) { + const out = structuredClone(left); + for (const [key, value] of Object.entries(right)) out[key] = key in out ? merge(out[key], value) : structuredClone(value); + return out; + } + if (Array.isArray(left) && Array.isArray(right)) { + const seen = new Set(); + return [...left, ...right].filter(value => { const key = JSON.stringify(value); if (seen.has(key)) return false; seen.add(key); return true; }); + } + return structuredClone(right); +} + +export function mergeSourceClashConfigs(generated: Record, sources: SourceClashConfig[], nodes: ParsedNode[]): Record { + if (!sources.length) return generated; + let base: Record = {}; + const groups: Record[] = []; + const sourceEntrypoints: string[] = []; + const importedRules: string[] = []; + const proxyProviders: Record = {}; + const ruleProviders: Record = {}; + const dnsSelfAddresses = new Set(); + const occupied = new Set([ + ...nodes.map(node => node.name), + ...(Array.isArray(generated["proxy-groups"]) ? generated["proxy-groups"].filter(record).map(group => String(group.name)) : []), + ]); + + for (const source of sources) { + const config = source.config; + const prefix = `source:${source.id}:`; + const unique = (name: string) => { + let candidate = prefix + name; + let suffix = 1; + while (occupied.has(candidate)) candidate = prefix + name + ` (${++suffix})`; + occupied.add(candidate); + return candidate; + }; + const nodeNames = new Map(); + for (const node of nodes) if (getNodeSourceIds(node).includes(source.id)) nodeNames.set(getNodeOriginName(node), node.name); + const rawGroups = Array.isArray(config["proxy-groups"]) ? config["proxy-groups"].filter(record) : []; + const groupNames = new Map(rawGroups.filter(group => typeof group.name === "string").map(group => [String(group.name), unique(String(group.name))])); + const entrypoint = rawGroups.find(group => typeof group.name === "string"); + if (entrypoint) sourceEntrypoints.push(groupNames.get(String(entrypoint.name))!); + const proxyNames = new Map(Object.keys(record(config["proxy-providers"]) ? config["proxy-providers"] : {}).map(name => [name, prefix + name])); + const ruleNames = new Map(Object.keys(record(config["rule-providers"]) ? config["rule-providers"] : {}).map(name => [name, prefix + name])); + const builtins = new Set(["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"]); + const policy = (name: string) => builtins.has(name) ? name : groupNames.get(name) ?? nodeNames.get(name); + + for (const [key, names, target] of [["proxy-providers", proxyNames, proxyProviders], ["rule-providers", ruleNames, ruleProviders]] as const) { + const providers = config[key]; + if (!record(providers)) continue; + for (const [name, value] of Object.entries(providers)) { + const next = record(value) ? { ...value } : value; + if (record(next)) { + if (typeof next.proxy === "string") next.proxy = policy(next.proxy) ?? "DIRECT"; + if (next.type === "http") { + const extension = next.format === "mrs" ? "mrs" : "yaml"; + next.path = `./source_providers/${source.id.replace(/[^a-zA-Z0-9_-]/g, "_")}_${name.replace(/[^a-zA-Z0-9_-]/g, "_")}.${extension}`; + } + } + target[names.get(name)!] = next; + } + } + for (const group of rawGroups) { + if (typeof group.name !== "string") continue; + const entries = Array.isArray(group.proxies) ? group.proxies.filter((name): name is string => typeof name === "string").map(policy).filter((name): name is string => Boolean(name)) : []; + const use = Array.isArray(group.use) ? group.use.filter((name): name is string => typeof name === "string").map(name => proxyNames.get(name)).filter((name): name is string => Boolean(name)) : []; + if (group["include-all-proxies"] === true) entries.push(...nodeNames.values()); + if (group["include-all-providers"] === true) use.push(...proxyNames.values()); + const next: Record = { ...group, name: groupNames.get(group.name), proxies: [...new Set(entries.length || use.length ? entries : ["DIRECT"])], ...(use.length ? { use: [...new Set(use)] } : {}) }; + delete next["include-all-proxies"]; + delete next["include-all-providers"]; + groups.push(next); + } + for (const rule of Array.isArray(config.rules) ? config.rules : []) { + if (typeof rule !== "string") continue; + const parts = rule.split(","); + if (["MATCH", "FINAL"].includes(parts[0])) continue; // Only one final catch-all can be active. + const targetIndex = parts.length - (parts.at(-1) === "no-resolve" ? 2 : 1); + const destination = policy(parts[targetIndex]); + if (!destination) continue; + parts[targetIndex] = destination; + if (parts[0] === "RULE-SET") { + const provider = ruleNames.get(parts[1]); + if (!provider) continue; + parts[1] = provider; + } + importedRules.push(parts.join(",")); + } + if (record(config.dns) && typeof config.dns.listen === "string") { + const listener = config.dns.listen.replace(/^0\.0\.0\.0:/, "127.0.0.1:"); + dnsSelfAddresses.add("udp://" + listener); + } + const metadata = Object.fromEntries(Object.entries(config).filter(([key]) => !["proxies", "proxy-groups", "rules", "proxy-providers", "rule-providers"].includes(key))); + if (Array.isArray(metadata.listeners)) { + metadata.listeners = metadata.listeners.map(listener => record(listener) && typeof listener.proxy === "string" + ? { ...listener, proxy: policy(listener.proxy) ?? "DIRECT" } : listener); + } + base = merge(metadata, base) as Record; + } + const result = merge(base, generated) as Record; + // Keep original aliases in the snapshot, but export IP hosts entries so clients do + // not need a separate domain-alias resolver or rely on a rewritten DNS section. + if (record(result.hosts)) { + for (const source of [...sources].reverse()) { + const originalHosts = record(source.config.hosts) ? source.config.hosts : {}; + for (const [name, addresses] of Object.entries(source.resolvedHosts ?? {})) { + if (addresses.length && JSON.stringify(result.hosts[name]) === JSON.stringify(originalHosts[name])) { + result.hosts[name] = addresses; + } + // A client parser may restore the original domain alias. Also close the + // alias chain with an IP entry, which Mihomo's outbound resolver follows + // independently of the client's DNS settings. + let target = originalHosts[name]; + const visited = new Set([name]); + while (typeof target === "string" && !target.includes(":") && !/^\d+\.\d+\.\d+\.\d+$/.test(target) && !visited.has(target)) { + visited.add(target); + const next = originalHosts[target]; + if (next !== undefined) { target = next; continue; } + if (addresses.length && result.hosts[target] === undefined) result.hosts[target] = addresses; + break; + } + } + } + } + if (record(result.hosts) && Object.keys(result.hosts).length && record(result.dns)) result.dns["use-hosts"] = true; + // Original configurations can refer to their own DNS port. The merged config has one listener. + if (record(result.dns) && Array.isArray(result.dns["proxy-server-nameserver"])) { + const dns = result.dns; + const listen = typeof dns.listen === "string" ? dns.listen.replace(/^0\.0\.0\.0:/, "127.0.0.1:") : undefined; + result.dns["proxy-server-nameserver"] = result.dns["proxy-server-nameserver"].flatMap(server => { + if (typeof server !== "string" || !dnsSelfAddresses.has(server)) return [server]; + return listen ? ["udp://" + listen] : Array.isArray(dns.nameserver) ? dns.nameserver : []; + }); + } + if (Object.keys(proxyProviders).length) result["proxy-providers"] = { ...proxyProviders, ...(record(result["proxy-providers"]) ? result["proxy-providers"] : {}) }; + if (Object.keys(ruleProviders).length) result["rule-providers"] = { ...ruleProviders, ...(record(result["rule-providers"]) ? result["rule-providers"] : {}) }; + result["proxy-groups"] = [...(Array.isArray(generated["proxy-groups"]) ? generated["proxy-groups"].map(group => { + if (!record(group) || group.type !== "select") return group; + return { ...group, proxies: [...new Set([...(Array.isArray(group.proxies) ? group.proxies : []), ...sourceEntrypoints])] }; + }) : []), ...groups]; + const rules = Array.isArray(generated.rules) ? generated.rules : []; + const catchAll = rules.findIndex(rule => typeof rule === "string" && /^(MATCH|FINAL),/.test(rule)); + const split = catchAll < 0 ? rules.length : catchAll; + result.rules = [...new Set([...rules.slice(0, split), ...importedRules, ...rules.slice(split)])]; + return result; +} diff --git a/packages/core/src/parser/clash-yaml.ts b/packages/core/src/parser/clash-yaml.ts index 4837c9e..a75b7d8 100644 --- a/packages/core/src/parser/clash-yaml.ts +++ b/packages/core/src/parser/clash-yaml.ts @@ -201,7 +201,7 @@ export function parseClashYaml(content: string): ParseResult { ); } } - } else { + } else if (!("proxy-providers" in config)) { // 2) 单个 proxy 对象(用户直接粘贴了一条节点 YAML) try { const node = normalizeNode(config as unknown as Record); @@ -211,19 +211,6 @@ export function parseClashYaml(content: string): ParseResult { } } - const proxyProviders = config["proxy-providers"]; - const hasProxyProviders = - proxyProviders && - typeof proxyProviders === "object" && - !Array.isArray(proxyProviders) && - Object.keys(proxyProviders).length > 0; - - // 注意:proxy-providers 需要在服务器端拉取,浏览器端无法直接获取 - if (hasProxyProviders) { - errors.push( - "检测到 proxy-providers 配置,由于浏览器限制无法自动拉取,请直接粘贴节点内容" - ); - } } else if (Array.isArray(parsed)) { // 3) 仅粘贴了 proxies 列表(YAML 顶层为数组) for (const proxy of parsed) { @@ -246,6 +233,9 @@ export function parseClashYaml(content: string): ParseResult { errors, totalParsed: nodes.length, totalFailed: errors.length, + ...(parsed && typeof parsed === "object" && !Array.isArray(parsed) + && ["hosts", "dns", "proxy-groups", "proxy-providers", "rules", "rule-providers"].some(key => Object.prototype.hasOwnProperty.call(parsed, key)) + ? { sourceConfig: structuredClone(parsed as Record) } : {}), }; } catch (e) { return { diff --git a/packages/core/src/parser/content-parsers.ts b/packages/core/src/parser/content-parsers.ts index 924ae1b..e4aeebe 100644 --- a/packages/core/src/parser/content-parsers.ts +++ b/packages/core/src/parser/content-parsers.ts @@ -198,7 +198,7 @@ export function parseSubscriptionContentByRegistry(content: string): ParseResult if (parser.name === "link-lines") { return buildParseResult(result.nodes, [...accumulatedErrors, ...result.errors]); } - if (result.nodes.length > 0) return result; + if (result.nodes.length > 0 || result.sourceConfig) return result; accumulatedErrors.push(...result.errors); } catch (error) { if (parser.name === "clash-yaml") { diff --git a/packages/core/src/parser/normalize.ts b/packages/core/src/parser/normalize.ts index c87d975..7b03842 100644 --- a/packages/core/src/parser/normalize.ts +++ b/packages/core/src/parser/normalize.ts @@ -78,6 +78,8 @@ export function normalizeParseResult(result: ParseResult, priorErrors: string[] ); return { + ...(result.sourceConfig ? { sourceConfig: result.sourceConfig } : {}), + ...(result.resolvedHosts ? { resolvedHosts: result.resolvedHosts } : {}), nodes: deduped.nodes, errors, totalParsed: deduped.nodes.length, diff --git a/packages/core/src/subscription/config-utils.ts b/packages/core/src/subscription/config-utils.ts index d813ab1..2ad29a1 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -1,3 +1,4 @@ +import { collectSourceConfigs } from "./source-snapshot"; import type { GenerateOptions } from "@subboost/core/generator"; import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import type { DialerProxyGroup } from "@subboost/core/types/template-config"; @@ -352,6 +353,7 @@ export function buildGenerateOptionsFromConfig( return { nodes: sanitizedNodes, + ...(collectSourceConfigs(config.sources).length ? { sourceConfigs: collectSourceConfigs(config.sources) } : {}), ...(proxyProviders ? { proxyProviders } : {}), template, userConfig, diff --git a/packages/core/src/subscription/proxy-providers.ts b/packages/core/src/subscription/proxy-providers.ts index c412567..55ecd12 100644 --- a/packages/core/src/subscription/proxy-providers.ts +++ b/packages/core/src/subscription/proxy-providers.ts @@ -1,3 +1,4 @@ +import { normalizeSourceSnapshot } from "./source-snapshot"; import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/url-input"; /** @@ -23,7 +24,7 @@ export function buildProxyProvidersFromConfig( if (!raw || typeof raw !== "object") continue; const item = raw as Record; if (item.type !== "url") continue; - if (item.useProxyProviders !== true) continue; + if (item.useProxyProviders !== true || normalizeSourceSnapshot(item.sourceSnapshot)) continue; const url = typeof item.content === "string" diff --git a/packages/core/src/subscription/source-snapshot.ts b/packages/core/src/subscription/source-snapshot.ts new file mode 100644 index 0000000..efaccc2 --- /dev/null +++ b/packages/core/src/subscription/source-snapshot.ts @@ -0,0 +1,52 @@ +import type { ParsedNode } from "../types/node"; + +/** Last successful import, persisted with its source rather than with a whole subscription. */ +export interface SourceSnapshot { + nodes: ParsedNode[]; + config: Record; + headers?: Record; + resolvedHosts?: Record; +} + +export function hasUsableSourceConfig(config: Record | undefined): boolean { + const providers = config?.["proxy-providers"]; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return false; + return Object.values(providers).some(provider => { + if (!provider || typeof provider !== "object" || Array.isArray(provider)) return false; + const value = provider as Record; + return (value.type === "http" && typeof value.url === "string" && /^https?:\/\//.test(value.url)) + || (value.type === "file" && typeof value.path === "string" && Boolean(value.path.trim())) + || (value.type === "inline" && Array.isArray(value.payload) && value.payload.length > 0); + }); +} + +export function normalizeSourceSnapshot(value: unknown): SourceSnapshot | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + if (!Array.isArray(record.nodes)) return undefined; + if (!record.nodes.every(node => node && typeof node === "object" && typeof node.name === "string" && typeof node.type === "string")) return undefined; + const config = record.config && typeof record.config === "object" && !Array.isArray(record.config) + ? record.config as Record : {}; + if (record.nodes.length === 0 && !hasUsableSourceConfig(config)) return undefined; + const headers = record.headers && typeof record.headers === "object" && !Array.isArray(record.headers) + ? Object.fromEntries(Object.entries(record.headers).filter((entry): entry is [string, string] => typeof entry[1] === "string")) : undefined; + const resolvedHosts = record.resolvedHosts && typeof record.resolvedHosts === "object" && !Array.isArray(record.resolvedHosts) + ? Object.fromEntries(Object.entries(record.resolvedHosts).filter((entry): entry is [string, string[]] => Array.isArray(entry[1]) && entry[1].every(value => typeof value === "string"))) : undefined; + return { nodes: record.nodes as ParsedNode[], config, ...(headers ? { headers } : {}), ...(resolvedHosts ? { resolvedHosts } : {}) }; +} + +export function createSourceSnapshot(nodes: ParsedNode[], config?: Record, headers?: Record, resolvedHosts?: Record, previous?: SourceSnapshot): SourceSnapshot { + const oldHosts = previous?.config.hosts as Record | undefined; + const newHosts = config?.hosts as Record | undefined; + const retained = Object.fromEntries(Object.entries(previous?.resolvedHosts ?? {}).filter(([name]) => JSON.stringify(oldHosts?.[name]) === JSON.stringify(newHosts?.[name]))); + const addresses = { ...retained, ...resolvedHosts }; + return structuredClone({ nodes, config: config ?? {}, ...(headers ? { headers } : {}), ...(Object.keys(addresses).length ? { resolvedHosts: addresses } : {}) }); +} + +export function collectSourceConfigs(sources: unknown): Array<{ id: string; config: Record; resolvedHosts?: Record }> { + if (!Array.isArray(sources)) return []; + return sources.flatMap(source => { + const snapshot = normalizeSourceSnapshot(source?.sourceSnapshot); + return snapshot && typeof source.id === "string" ? [{ id: source.id, config: snapshot.config, ...(snapshot.resolvedHosts ? { resolvedHosts: snapshot.resolvedHosts } : {}) }] : []; + }); +} diff --git a/packages/core/src/types/node.ts b/packages/core/src/types/node.ts index 4406608..56c481a 100644 --- a/packages/core/src/types/node.ts +++ b/packages/core/src/types/node.ts @@ -396,6 +396,8 @@ export type ParsedNode = | (BaseNode & { type: UnknownNodeType }); export interface ParseResult { + sourceConfig?: Record; + resolvedHosts?: Record; nodes: ParsedNode[]; errors: string[]; totalParsed: number; diff --git a/packages/server-core/src/subscription/auto-update-state.ts b/packages/server-core/src/subscription/auto-update-state.ts index 37746bc..c339729 100644 --- a/packages/server-core/src/subscription/auto-update-state.ts +++ b/packages/server-core/src/subscription/auto-update-state.ts @@ -84,10 +84,8 @@ export function resolveAutomaticRefreshFailureAnalysis(params: { snapshot: RefreshNodeSnapshotResult; failedAt: Date; }): AutomaticRefreshFailureAnalysis { - const refreshableSources = params.snapshot.savedSources.filter( - (source) => !(source.type === "url" && source.useProxyProviders === true) - ); - const failureState = + const refreshableSources = params.snapshot.savedSources; + let failureState = params.snapshot.failedSourceCount > 0 ? updateAutoUpdateFailureSourceState({ previousStateRaw: params.currentState.failureSourceState, @@ -96,6 +94,11 @@ export function resolveAutomaticRefreshFailureAnalysis(params: { failedAt: params.failedAt, }) : null; + // Cached output stays usable while upstream sources recover. A failed source + // must not stop successful sources from being refreshed on the next cycle. + if (failureState && params.snapshot.nodes.length > 0) { + failureState = { ...failureState, shouldDisableAutoUpdate: false }; + } const failureReason = failureState?.disableSource?.reason ?? failureState?.stableFailedSources[0]?.reason ?? diff --git a/packages/server-core/src/subscription/refresh-cache-result.ts b/packages/server-core/src/subscription/refresh-cache-result.ts index 466ee59..3ae4f17 100644 --- a/packages/server-core/src/subscription/refresh-cache-result.ts +++ b/packages/server-core/src/subscription/refresh-cache-result.ts @@ -4,6 +4,7 @@ import { getEffectiveTestOptions, } from "@subboost/core/subscription/config-utils"; import { buildProxyProvidersFromConfig } from "@subboost/core/subscription/proxy-providers"; +import { collectSourceConfigs, hasUsableSourceConfig } from "@subboost/core/subscription/source-snapshot"; import { resolveNodeNameFilter } from "@subboost/core/subscription/node-name-filter"; import { reconcileNodeNameReferences } from "@subboost/core/subscription/node-name-references"; import type { ParsedNode } from "@subboost/core/types/node"; @@ -47,13 +48,13 @@ export function prepareRefreshCacheResult(params: { const { testUrl, testInterval } = getEffectiveTestOptions(params.config); const proxyProviders = params.proxyProviders ?? - buildProxyProvidersFromConfig(params.config, { + buildProxyProvidersFromConfig({ ...params.config, sources: params.snapshot.savedSources }, { testUrl, testInterval, }); const hasProxyProviders = Boolean( proxyProviders && Object.keys(proxyProviders).length > 0 - ); + ) || collectSourceConfigs(params.snapshot.savedSources).some(source => hasUsableSourceConfig(source.config)); const common = { proxyProviders, nodeCount: params.snapshot.nodes.length, @@ -63,7 +64,7 @@ export function prepareRefreshCacheResult(params: { params.config.nodeNameFilter ); - if (params.snapshot.refreshableSourceCount > 0 && params.snapshot.refreshedSourceCount === 0) { + if (params.snapshot.refreshableSourceCount > 0 && params.snapshot.refreshedSourceCount === 0 && params.snapshot.nodes.length === 0 && !hasProxyProviders) { return { ok: false, reason: "all_sources_failed", diff --git a/packages/server-core/src/subscription/refresh-node-snapshot.ts b/packages/server-core/src/subscription/refresh-node-snapshot.ts index 4e5fbf5..e7d5036 100644 --- a/packages/server-core/src/subscription/refresh-node-snapshot.ts +++ b/packages/server-core/src/subscription/refresh-node-snapshot.ts @@ -1,3 +1,4 @@ +import { createSourceSnapshot, hasUsableSourceConfig } from "@subboost/core/subscription/source-snapshot"; import { parseSubscription } from "@subboost/core/parser"; import { stripImportedNodeControlFields } from "@subboost/core/subscription/imported-node-controls"; import { @@ -5,7 +6,6 @@ import { normalizeNodeOriginName, } from "@subboost/core/subscription/node-source-state"; import { - detachSourceNodesFromState, type DeletedNodeDescriptor, mergeParsedSourceNodes, prepareSourceParsedNodes, @@ -30,6 +30,8 @@ import { normalizeSavedSourcesForPersistence, type SavedSource, type SavedSource type UrlNodeFetchResult = { ok: boolean; nodes: ParsedNode[]; + sourceConfig?: Record; + resolvedHosts?: Record; errors?: string[]; headers?: Record; error?: string; @@ -57,6 +59,7 @@ export type RefreshNodeSnapshotOptions = { config: Record; urls: string[]; storedNodes: ParsedNode[]; + resolveHosts?: (config: Record | undefined) => Promise>; fetchUrlNodes: (source: SavedSource) => Promise; fetchUrlUserInfo?: (source: SavedSource) => Promise | undefined>; }; @@ -134,13 +137,13 @@ export async function refreshNodeSnapshot( const subscriptionInfo: SubscriptionResponseInfo = {}; const profileWebPageUrlState: StableMetadataState = { conflicted: false }; const planNameState: StableMetadataState = { conflicted: false }; - const attemptedUrlFetch = savedSources.some((source) => source.type === "url" && !source.useProxyProviders); + const attemptedUrlFetch = savedSources.some((source) => source.type === "url"); let usedUrlFetch = false; let refreshableSourceCount = 0; let refreshedSourceCount = 0; let refreshedUrlSourceCount = 0; let refreshedStaticSourceCount = 0; - let detachedSourceCount = 0; + const detachedSourceCount = 0; let failedSourceCount = 0; const failedSources: RefreshNodeSnapshotFailedSource[] = []; let renameMap = new Map(); @@ -190,59 +193,25 @@ export async function refreshNodeSnapshot( }); }; - const mergeSourceSubscriptionInfo = (sourceId: string, info: SubscriptionUserInfo | undefined) => { - updateSourceSubscriptionInfo(sourceId, info); - const normalized = normalizeSubscriptionUserInfo(info); - if (hasSubscriptionUserInfo(normalized)) { - mergeSubscriptionUserInfo(subscriptionInfo, normalized); - } - }; - const shouldFetchSupplementalUserInfoForSource = (source: SavedSource): boolean => { return Boolean(source.userinfoUrl || source.userinfoUserAgent); }; for (const source of savedSources) { - if (source.type === "url" && source.useProxyProviders) { - const detached = detachSourceNodesFromState(currentNodes, source.id); - if (detached.nodes.length !== currentNodes.length) { - detachedSourceCount += 1; - refreshedSourceCount += 1; - } - currentNodes = detached.nodes; - if ( - typeof options.fetchUrlUserInfo === "function" && - shouldFetchSupplementalUserInfoForSource(source) - ) { - const headers = await options.fetchUrlUserInfo(source); - if (headers) { - mergeResponseMetadata(headers); - const header = headers["subscription-userinfo"]; - mergeSourceSubscriptionInfo( - source.id, - header ? resolveSubscriptionUserInfo(parseSubscriptionUserInfo(header)) : undefined - ); - } - } - continue; - } - refreshableSourceCount += 1; if (source.type === "url") { - const fetched = await options.fetchUrlNodes(source); - mergeResponseMetadata(fetched.headers); + let fetched: UrlNodeFetchResult; + try { fetched = await options.fetchUrlNodes(source); } + catch (error) { fetched = { ok: false, nodes: [], error: error instanceof Error ? error.message : "获取失败" }; } + fetched ??= { ok: false, nodes: [], error: "获取失败" }; const userInfoHeader = fetched.headers?.["subscription-userinfo"]; const rawUserInfo = userInfoHeader ? parseSubscriptionUserInfo(userInfoHeader) : undefined; const resolvedUserInfo = resolveSubscriptionUserInfo( rawUserInfo, fetched.ok ? fetched.nodes : [] ); - if (hasSubscriptionUserInfo(resolvedUserInfo)) { - mergeSubscriptionUserInfo(subscriptionInfo, resolvedUserInfo); - } - - if (!fetched.ok || fetched.nodes.length === 0) { + if (!fetched.ok || (fetched.nodes.length === 0 && !hasUsableSourceConfig(fetched.sourceConfig))) { const errorInfo = fetched.errorInfo ?? null; const firstParseError = Array.isArray(fetched.errors) && typeof fetched.errors[0] === "string" @@ -261,16 +230,20 @@ export async function refreshNodeSnapshot( }); continue; } + mergeResponseMetadata(fetched.headers); updateSourceSubscriptionInfo(source.id, resolvedUserInfo); + const resolvedHosts = fetched.resolvedHosts ?? await options.resolveHosts?.(fetched.sourceConfig); + refreshedSavedSources = refreshedSavedSources.map(item => item.id === source.id + ? { ...item, sourceSnapshot: createSourceSnapshot(fetched.nodes, fetched.sourceConfig, fetched.headers, resolvedHosts, source.sourceSnapshot) } : item); const parsedNodes = prepareSourceParsedNodes(fetched.nodes, { - currentTag: source.tag, - currentNameTemplate: source.nameTemplate, + currentTag: source.useProxyProviders ? undefined : source.tag, + currentNameTemplate: source.useProxyProviders ? undefined : source.nameTemplate, }); const merged = mergeParsedSourceNodes(currentNodes, parsedNodes, deletedNodeNames, { sourceId: source.id, - currentTag: source.tag, - currentNameTemplate: source.nameTemplate, + currentTag: source.useProxyProviders ? undefined : source.tag, + currentNameTemplate: source.useProxyProviders ? undefined : source.nameTemplate, lastTag: source.lastParsedTag, lastNameTemplate: source.lastParsedNameTemplate, treatAsNewSource: Boolean( @@ -293,23 +266,23 @@ export async function refreshNodeSnapshot( try { const parsed = parseSubscription(source.content); const resolvedUserInfo = resolveSubscriptionUserInfo(undefined, parsed.nodes); - if (hasSubscriptionUserInfo(resolvedUserInfo)) { - mergeSubscriptionUserInfo(subscriptionInfo, resolvedUserInfo); - } - if (parsed.nodes.length === 0) { + if (parsed.nodes.length === 0 && !hasUsableSourceConfig(parsed.sourceConfig)) { recordFailedSource(source, "未解析到可用节点", { errorCategory: "parse" }); continue; } updateSourceSubscriptionInfo(source.id, resolvedUserInfo); + const resolvedHosts = await options.resolveHosts?.(parsed.sourceConfig); + refreshedSavedSources = refreshedSavedSources.map(item => item.id === source.id + ? { ...item, sourceSnapshot: createSourceSnapshot(parsed.nodes, parsed.sourceConfig, undefined, resolvedHosts) } : item); const parsedNodes = prepareSourceParsedNodes(parsed.nodes, { - currentTag: source.tag, - currentNameTemplate: source.nameTemplate, + currentTag: source.useProxyProviders ? undefined : source.tag, + currentNameTemplate: source.useProxyProviders ? undefined : source.nameTemplate, }); const merged = mergeParsedSourceNodes(currentNodes, parsedNodes, deletedNodeNames, { sourceId: source.id, - currentTag: source.tag, - currentNameTemplate: source.nameTemplate, + currentTag: source.useProxyProviders ? undefined : source.tag, + currentNameTemplate: source.useProxyProviders ? undefined : source.nameTemplate, lastTag: source.lastParsedTag, lastNameTemplate: source.lastParsedNameTemplate, treatAsNewSource: false, @@ -328,27 +301,50 @@ export async function refreshNodeSnapshot( } } - if ( - typeof options.fetchUrlUserInfo === "function" && - usedUrlFetch && - ( - !hasSubscriptionUserInfo(subscriptionInfo) || - savedSources.some((source) => source.type === "url" && shouldFetchSupplementalUserInfoForSource(source)) - ) - ) { + // A failed source retains its successful data and response metadata. + for (let index = 0; index < refreshedSavedSources.length; index++) { + const source = refreshedSavedSources[index]; + if (!failedSources.some(failed => failed.id === source.id)) continue; + mergeResponseMetadata(source.sourceSnapshot?.headers); + // A closed upstream retains the original successful response. Its hostname + // aliases still follow normal DNS changes, independently of subscription fetch. + if (source.sourceSnapshot && options.resolveHosts) { + const addresses = await options.resolveHosts(source.sourceSnapshot.config).catch(() => ({})); + refreshedSavedSources[index] = { + ...source, + sourceSnapshot: createSourceSnapshot(source.sourceSnapshot.nodes, source.sourceSnapshot.config, + source.sourceSnapshot.headers, addresses, source.sourceSnapshot), + }; + } + if (source.sourceSnapshot && !currentNodes.some(node => { + const ids = (node as unknown as Record)._sourceIds; + return Array.isArray(ids) && ids.includes(source.id); + })) { + const prepared = prepareSourceParsedNodes(source.sourceSnapshot.nodes, { currentTag: source.useProxyProviders ? undefined : source.tag, currentNameTemplate: source.useProxyProviders ? undefined : source.nameTemplate }); + currentNodes = mergeParsedSourceNodes(currentNodes, prepared, deletedNodeNames, { + sourceId: source.id, currentTag: source.useProxyProviders ? undefined : source.tag, currentNameTemplate: source.useProxyProviders ? undefined : source.nameTemplate, + lastTag: source.lastParsedTag, lastNameTemplate: source.lastParsedNameTemplate, + treatAsNewSource: false, deletedNodes, + }).nodes; + } + } + + if (options.fetchUrlUserInfo) { for (const source of savedSources) { - if (source.type !== "url" || source.useProxyProviders) continue; - if (!shouldFetchSupplementalUserInfoForSource(source) && hasSubscriptionUserInfo(subscriptionInfo)) continue; - const headers = await options.fetchUrlUserInfo(source); + if (source.type !== "url") continue; + const configured = shouldFetchSupplementalUserInfoForSource(source); + if (!configured && (source.useProxyProviders || !usedUrlFetch + || refreshedSavedSources.some(item => hasSubscriptionUserInfo(item.subscriptionUserInfo)))) continue; + const headers = await options.fetchUrlUserInfo(source).catch(() => undefined); if (!headers) continue; mergeResponseMetadata(headers); - const header = headers["subscription-userinfo"]; - mergeSourceSubscriptionInfo( - source.id, - header ? resolveSubscriptionUserInfo(parseSubscriptionUserInfo(header)) : undefined - ); + const info = resolveSubscriptionUserInfo(parseSubscriptionUserInfo(headers["subscription-userinfo"] ?? "")); + if (hasSubscriptionUserInfo(info)) updateSourceSubscriptionInfo(source.id, info); } } + for (const source of refreshedSavedSources) { + if (source.subscriptionUserInfo) mergeSubscriptionUserInfo(subscriptionInfo, source.subscriptionUserInfo); + } if (!profileWebPageUrlState.conflicted && profileWebPageUrlState.value) { subscriptionInfo.profileWebPageUrl = profileWebPageUrlState.value; diff --git a/packages/server-core/src/subscription/saved-sources.ts b/packages/server-core/src/subscription/saved-sources.ts index 4c29add..b374587 100644 --- a/packages/server-core/src/subscription/saved-sources.ts +++ b/packages/server-core/src/subscription/saved-sources.ts @@ -5,12 +5,15 @@ import { type SubscriptionUserInfo, } from "@subboost/core/subscription/subscription-userinfo"; +import { normalizeSourceSnapshot, type SourceSnapshot } from "@subboost/core/subscription/source-snapshot"; + export type SavedSourceType = "url" | "yaml" | "nodes"; export type SavedSource = { id: string; type: SavedSourceType; content: string; + sourceSnapshot?: SourceSnapshot; useProxyProviders?: boolean; userinfoUrl?: string; userinfoUserAgent?: string; @@ -80,6 +83,7 @@ export function normalizeSavedSourcesForPersistence( const userinfoUrl = toTrimmedString(record.userinfoUrl); const userinfoUserAgent = toTrimmedString(record.userinfoUserAgent); const subscriptionUserInfo = normalizeSavedSourceUserInfo(record.subscriptionUserInfo); + const sourceSnapshot = normalizeSourceSnapshot(record.sourceSnapshot); const tag = toTrimmedString(record.tag); const nameTemplate = toTrimmedString(record.nameTemplate); const lastParsedContent = toTrimmedString(record.lastParsedContent); @@ -94,6 +98,7 @@ export function normalizeSavedSourcesForPersistence( ...(type === "url" && userinfoUrl ? { userinfoUrl: normalizeUrlContent(userinfoUrl) } : {}), ...(type === "url" && userinfoUserAgent ? { userinfoUserAgent } : {}), ...(subscriptionUserInfo ? { subscriptionUserInfo } : {}), + ...(sourceSnapshot ? { sourceSnapshot } : {}), ...(tag ? { tag } : {}), ...(nameTemplate ? { nameTemplate } : {}), ...(lastParsedContent diff --git a/packages/server-core/src/subscription/snapshot-comparison.ts b/packages/server-core/src/subscription/snapshot-comparison.ts index fcfbdef..198c869 100644 --- a/packages/server-core/src/subscription/snapshot-comparison.ts +++ b/packages/server-core/src/subscription/snapshot-comparison.ts @@ -3,6 +3,7 @@ import { isSubscriptionInfoNodeName } from "@subboost/core/subscription/info-nod import { stableJsonStringify } from "@subboost/core/node-identity"; import { isMihomoSupportedProxyNode, sanitizeMihomoProxyNode } from "@subboost/core/mihomo/proxy-sanitizer"; import { looksLikeClientUpdatePlaceholderNodes } from "@subboost/core/parser/placeholder"; +import { hasUsableSourceConfig } from "@subboost/core/subscription/source-snapshot"; export type SnapshotRelation = "equivalent" | "next-superset" | "current-superset" | "divergent" | "field-conflict"; export type SubscriptionSnapshotComparison = { @@ -86,8 +87,9 @@ function covers(left: Snapshot, right: Snapshot): boolean { return true; } -export function hasUsableSubscriptionSnapshot(parsed: Pick): boolean { - return !looksLikeClientUpdatePlaceholderNodes(parsed.nodes) && parsed.nodes.some((node) => nodeFields(node) !== null); +export function hasUsableSubscriptionSnapshot(parsed: Pick): boolean { + return !looksLikeClientUpdatePlaceholderNodes(parsed.nodes) + && (parsed.nodes.some((node) => nodeFields(node) !== null) || hasUsableSourceConfig(parsed.sourceConfig)); } export function compareSubscriptionSnapshots( diff --git a/packages/server-core/src/subscription/source-hosts.ts b/packages/server-core/src/subscription/source-hosts.ts new file mode 100644 index 0000000..ee2fa57 --- /dev/null +++ b/packages/server-core/src/subscription/source-hosts.ts @@ -0,0 +1,63 @@ +import { Resolver } from "node:dns/promises"; +import { isIP } from "node:net"; +import { resolveHostnameByDoh } from "./doh-resolver"; +import { isBenchmarkReservedIp } from "./ssrf-ip"; + +const MAX_HOST_ENTRIES = 512; +const MAX_DNS_LOOKUPS = 128; +const MAX_HOST_VALUES = 4096; + +/** Resolve domain aliases during a normal source refresh; never alter proxy server fields. */ +export async function resolveSourceHosts( + config: Record | undefined, + lookup?: (name: string) => Promise, +): Promise> { + const hosts = config?.hosts; + if (!hosts || typeof hosts !== "object" || Array.isArray(hosts)) return {}; + const entries = Object.entries(hosts as Record).slice(0, MAX_HOST_ENTRIES); + const result: Record = {}; + const pending = new Map>(); + const deadline = Date.now() + 10000; + let visitedValues = 0; + const resolve = lookup ?? (async (name: string) => { + const resolver = new Resolver({ timeout: 3000, tries: 1 }); + const ipv4 = await resolver.resolve4(name).catch(() => []); + if (ipv4.length && !ipv4.some(isBenchmarkReservedIp)) return ipv4; + // A desktop TUN may answer system DNS with fake-IP addresses. Do not export + // those addresses as real proxy entries; recheck using the existing DoH path. + const verified = await resolveHostnameByDoh(name, { timeoutMs: 3000 }).catch(() => []); + if (verified.length) return verified.filter(ip => !isBenchmarkReservedIp(ip) && !ip.toLowerCase().startsWith("fdfe:dcba:9876:")); + return (await resolver.resolve6(name).catch(() => [])).filter(ip => !ip.toLowerCase().startsWith("fdfe:dcba:9876:")); + }); + const visit = async (value: unknown, seen: Set, depth = 0): Promise => { + if (++visitedValues > MAX_HOST_VALUES || depth > 32 || Date.now() >= deadline) return []; + if (Array.isArray(value)) { + const addresses: string[] = []; + for (const item of value) { + if (visitedValues >= MAX_HOST_VALUES || Date.now() >= deadline) break; + addresses.push(...await visit(item, new Set(seen), depth + 1)); + } + return addresses; + } + if (typeof value !== "string" || !value.trim()) return []; + const name = value.trim(); + if (isIP(name)) return [name]; + if (seen.has(name) || seen.size > 32) return []; + seen.add(name); + if (Object.prototype.hasOwnProperty.call(hosts, name)) return visit((hosts as Record)[name], seen, depth + 1); + if (!pending.has(name)) { + if (pending.size >= MAX_DNS_LOOKUPS) return []; + pending.set(name, resolve(name).catch(() => [])); + } + return pending.get(name)!; + }; + let index = 0; + await Promise.all(Array.from({ length: Math.min(4, entries.length) }, async () => { + while (index < entries.length) { + const [name, target] = entries[index++]; + const ips = [...new Set((await visit(target, new Set([name]))).filter(ip => Boolean(isIP(ip))))]; + if (ips.length) result[name] = ips; + } + })); + return result; +} diff --git a/packages/server-core/src/subscription/source-import.ts b/packages/server-core/src/subscription/source-import.ts index c5c46e9..a758a9e 100644 --- a/packages/server-core/src/subscription/source-import.ts +++ b/packages/server-core/src/subscription/source-import.ts @@ -46,6 +46,8 @@ export type SourceImportSuccess = { content: string; headers: Record; parsedNodes: ParsedNode[]; + sourceConfig?: Record; + resolvedHosts?: Record; parseErrors: string[]; diagnostics?: SubscriptionSnapshotComparison[]; }; @@ -61,7 +63,7 @@ export type SourceImportFailure = { export type SourceImportResult = SourceImportSuccess | SourceImportFailure; export function buildSourceImportParseResult( - result: Pick + result: Pick ): ParseResult { const nodes = result.parsedNodes; const errors = result.parseErrors; @@ -70,6 +72,8 @@ export function buildSourceImportParseResult( errors, totalParsed: nodes.length, totalFailed: errors.length, + ...(result.sourceConfig ? { sourceConfig: result.sourceConfig } : {}), + ...(result.resolvedHosts ? { resolvedHosts: result.resolvedHosts } : {}), }; } @@ -264,6 +268,7 @@ export async function importSubscriptionFromUrl( const budget = createSubscriptionRequestBudget(timeoutMs); const diagnostics: SubscriptionSnapshotComparison[] = []; let best: ParsedAttempt | null = null; + const sourceConfigs: Record[] = []; for (let index = 0; index < userAgents.length; index += 1) { const userAgent = userAgents[index]; @@ -274,6 +279,7 @@ export async function importSubscriptionFromUrl( maxBytes, fetchText: options.fetchText, }); + if (attempt.ok && isUsableParsedAttempt(attempt) && attempt.parsed.sourceConfig) sourceConfigs.push(attempt.parsed.sourceConfig); best = pickBetterAttempt(best, attempt, diagnostics); } @@ -287,11 +293,27 @@ export async function importSubscriptionFromUrl( fetchText: options.fetchText, }, url).catch(() => ({})); + // Profile negotiation may choose richer nodes from a URI-only response. Keep + // metadata obtained from a valid YAML response instead of dropping its hosts, + // DNS and other sections merely because the winning format has no such fields. + const mergeConfig = (left: Record, right: Record): Record => { + const merged = structuredClone(left); + for (const [key, value] of Object.entries(right)) { + const old = merged[key]; + merged[key] = old && value && typeof old === "object" && typeof value === "object" && !Array.isArray(old) && !Array.isArray(value) + ? mergeConfig(old as Record, value as Record) : structuredClone(value); + } + return merged; + }; + let sourceConfig = sourceConfigs.reduce((config, next) => mergeConfig(next, config), {}); + if (best.parsed.sourceConfig) sourceConfig = mergeConfig(sourceConfig, best.parsed.sourceConfig); + return { ok: true, content: best.content, headers: { ...best.headers, ...supplementalHeaders }, parsedNodes: best.parsed.nodes, + ...(Object.keys(sourceConfig).length ? { sourceConfig } : {}), parseErrors: best.parsed.errors, diagnostics, }; diff --git a/packages/ui/src/product/converter/source-editor-dialog.tsx b/packages/ui/src/product/converter/source-editor-dialog.tsx index bf5c288..6ad911a 100644 --- a/packages/ui/src/product/converter/source-editor-dialog.tsx +++ b/packages/ui/src/product/converter/source-editor-dialog.tsx @@ -86,16 +86,16 @@ export function SourceEditorDialog({

proxy-providers 模式

- 部分订阅限制 CN IP 导入,url 无法在 SubBoost 内拉取解析。开启后 SubBoost - 不再拉取/解析该 url,而是在最终配置中写入{" "} - proxy-providers,交由客户端自行拉取节点。 + 开启后优先尝试在 SubBoost 导入完整订阅。成功时更新该源的节点和配置; + 失败时保留上次成功的数据。只有从未成功导入过的源才交由客户端通过 + proxy-providers 拉取。

-

注意开启后:

+

尚无成功缓存、使用客户端拉取时:

  • 无法在预览中查看/管理该 url 的节点
  • 无法将这些节点用于中转代理组、分流组高级模式等高级功能
  • -
  • 节点命名模板与 tag 在该模式下不生效
  • +
  • 此模式保留原节点名称,节点命名模板与 tag 不生效

diff --git a/packages/ui/src/product/home/use-editing-subscription-loader.ts b/packages/ui/src/product/home/use-editing-subscription-loader.ts index 56f2aae..f015bc7 100644 --- a/packages/ui/src/product/home/use-editing-subscription-loader.ts +++ b/packages/ui/src/product/home/use-editing-subscription-loader.ts @@ -1,4 +1,5 @@ "use client"; +import { normalizeSourceSnapshot } from "@subboost/core/subscription/source-snapshot"; import * as React from "react"; import type { SubscriptionSource } from "@subboost/ui/store/config-store"; @@ -195,6 +196,7 @@ export function useEditingSubscriptionLoader({ tag: typeof tag === "string" && tag.trim() ? tag.trim() : undefined, nameTemplate: typeof nameTemplate === "string" && nameTemplate.trim() ? nameTemplate.trim() : undefined, subscriptionUserInfo: hasSubscriptionUserInfo(subscriptionUserInfo) ? subscriptionUserInfo : undefined, + sourceSnapshot: normalizeSourceSnapshot((item as Record).sourceSnapshot), useProxyProviders: t === "url" && useProxyProviders === true ? true : undefined, userinfoUrl: normalizedUserinfoUrl, userinfoUserAgent: @@ -216,6 +218,7 @@ export function useEditingSubscriptionLoader({ tag?: string; nameTemplate?: string; subscriptionUserInfo?: SubscriptionUserInfo; + sourceSnapshot?: unknown; useProxyProviders?: boolean; userinfoUrl?: string; userinfoUserAgent?: string; @@ -281,6 +284,7 @@ export function useEditingSubscriptionLoader({ ...(lastParsedContent ? { lastParsedContent } : {}), ...(typeof s.tag === "string" && s.tag.trim() ? { tag: s.tag.trim() } : {}), ...(typeof s.nameTemplate === "string" && s.nameTemplate.trim() ? { nameTemplate: s.nameTemplate.trim() } : {}), + ...(normalizeSourceSnapshot((s as unknown as Record).sourceSnapshot) ? { sourceSnapshot: normalizeSourceSnapshot((s as unknown as Record).sourceSnapshot) } : {}), ...(hasSubscriptionUserInfo(s.subscriptionUserInfo) ? { subscriptionUserInfo: s.subscriptionUserInfo } : {}), ...(s.type === "url" && s.useProxyProviders ? { useProxyProviders: true } : {}), ...(s.type === "url" && typeof s.userinfoUrl === "string" && s.userinfoUrl.trim() diff --git a/packages/ui/src/product/home/use-subscription-link.tsx b/packages/ui/src/product/home/use-subscription-link.tsx index 4bb6f2d..41a3fa2 100644 --- a/packages/ui/src/product/home/use-subscription-link.tsx +++ b/packages/ui/src/product/home/use-subscription-link.tsx @@ -1,4 +1,5 @@ "use client"; +import { normalizeSourceSnapshot } from "@subboost/core/subscription/source-snapshot"; import * as React from "react"; import { toast, ToastAction } from "@subboost/ui/components/ui/toaster"; @@ -362,6 +363,7 @@ export function useSubscriptionLink({ ...(typeof s.lastParsedTag === "string" && s.lastParsedTag.trim() ? { lastParsedTag: s.lastParsedTag.trim() } : {}), + ...(normalizeSourceSnapshot(s.sourceSnapshot) ? { sourceSnapshot: normalizeSourceSnapshot(s.sourceSnapshot) } : {}), ...(typeof s.lastParsedNameTemplate === "string" && s.lastParsedNameTemplate.trim() ? { lastParsedNameTemplate: s.lastParsedNameTemplate.trim() } : {}), diff --git a/packages/ui/src/store/config-store/auth-handoff.ts b/packages/ui/src/store/config-store/auth-handoff.ts index 458a2d6..b4c8795 100644 --- a/packages/ui/src/store/config-store/auth-handoff.ts +++ b/packages/ui/src/store/config-store/auth-handoff.ts @@ -1,4 +1,5 @@ import type { ConfigState, SourceType, SubscriptionSource } from "./definitions"; +import { normalizeSourceSnapshot } from "@subboost/core/subscription/source-snapshot"; import { initialState } from "./definitions"; import { safeParseJsonObject } from "@subboost/core/json"; import { normalizeNodeNameFilterConfig } from "@subboost/core/subscription/node-name-filter"; @@ -63,6 +64,7 @@ function sourceArray(value: unknown): SubscriptionSource[] | undefined { id: item.id, type: item.type, content: item.content, + ...(normalizeSourceSnapshot(item.sourceSnapshot) ? { sourceSnapshot: normalizeSourceSnapshot(item.sourceSnapshot) } : {}), ...(typeof item.name === "string" ? { name: item.name } : {}), ...(typeof item.lastParsedContent === "string" ? { lastParsedContent: item.lastParsedContent } : {}), ...(typeof item.lastParsedTag === "string" ? { lastParsedTag: item.lastParsedTag } : {}), diff --git a/packages/ui/src/store/config-store/definitions.ts b/packages/ui/src/store/config-store/definitions.ts index fb93be5..681581c 100644 --- a/packages/ui/src/store/config-store/definitions.ts +++ b/packages/ui/src/store/config-store/definitions.ts @@ -60,7 +60,10 @@ export const PRESET_RELAY_NAMES = [ // 订阅源类型 export type SourceType = "url" | "yaml" | "nodes"; +import type { SourceSnapshot } from "@subboost/core/subscription/source-snapshot"; + export interface SubscriptionSource { + sourceSnapshot?: SourceSnapshot; id: string; type: SourceType; content: string; @@ -153,6 +156,8 @@ export async function fetchUrlContentInBrowser( errors: parseErrors, totalParsed: parsedNodes.length, totalFailed: parseErrors.length, + ...(data.parseResult?.sourceConfig ? { sourceConfig: data.parseResult.sourceConfig } : {}), + ...(data.parseResult?.resolvedHosts ? { resolvedHosts: data.parseResult.resolvedHosts } : {}), } : undefined, }; diff --git a/packages/ui/src/store/config-store/generated-yaml.ts b/packages/ui/src/store/config-store/generated-yaml.ts index 7d915b4..aad7c2c 100644 --- a/packages/ui/src/store/config-store/generated-yaml.ts +++ b/packages/ui/src/store/config-store/generated-yaml.ts @@ -1,3 +1,4 @@ +import { collectSourceConfigs, normalizeSourceSnapshot } from "@subboost/core/subscription/source-snapshot"; import { generateClashYaml } from "@subboost/core/generator"; import { stripImportedNodeControlFieldsFromList } from "@subboost/core/subscription/imported-node-controls"; import { resolveNodeNameFilter } from "@subboost/core/subscription/node-name-filter"; @@ -10,7 +11,7 @@ function buildProxyProvidersFromSources( const out: Record = {}; for (const source of state.sources) { - if (!source || source.type !== "url" || !source.useProxyProviders) continue; + if (!source || source.type !== "url" || !source.useProxyProviders || normalizeSourceSnapshot(source.sourceSnapshot)) continue; const url = typeof source.content === "string" ? source.content.trim() : ""; if (!url) continue; @@ -57,6 +58,7 @@ function buildGenerateClashYamlOptions( ): GenerateClashYamlOptions { return { nodes: stripImportedNodeControlFieldsFromList(effectiveNodes), + ...(collectSourceConfigs(state.sources).length ? { sourceConfigs: collectSourceConfigs(state.sources) } : {}), proxyProviders, template: state.template, userConfig: { @@ -91,7 +93,7 @@ export function computeGeneratedYamlResult(state: ConfigState): GeneratedYamlRes try { const { effectiveNodes } = resolveNodeNameFilter(state.nodes, state.nodeNameFilter); - const hasPreviewContent = effectiveNodes.length > 0 || Boolean(proxyProviders); + const hasPreviewContent = effectiveNodes.length > 0 || Boolean(proxyProviders) || collectSourceConfigs(state.sources).length > 0; const yaml = generateClashYaml( buildGenerateClashYamlOptions(state, proxyProviders, effectiveNodes) ); diff --git a/packages/ui/src/store/config-store/source-actions.ts b/packages/ui/src/store/config-store/source-actions.ts index ee97a3c..aa80df8 100644 --- a/packages/ui/src/store/config-store/source-actions.ts +++ b/packages/ui/src/store/config-store/source-actions.ts @@ -1,8 +1,8 @@ +import { createSourceSnapshot, hasUsableSourceConfig, type SourceSnapshot } from "@subboost/core/subscription/source-snapshot"; import type { ParsedNode, ParseResult } from "@subboost/core/types/node"; import { parseSubscription } from "@subboost/core/parser"; import { buildNodeContentKey, buildScopedNodeIdentityKey } from "@subboost/core/node-identity"; import { - detachSourceNodesFromState, mergeParsedSourceNodes, prepareSourceParsedNodes, } from "@subboost/core/subscription/source-node-refresh"; @@ -48,6 +48,13 @@ function pickUrlFetchParseResult(fetched: Awaited pastedSourceId ? withNodeSourceId(node, pastedSourceId) : node); setAndGenerateConfig((state) => { const normalizeOriginName = (node: ParsedNode): ParsedNode => { @@ -183,6 +191,7 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate return { nodes: [...existingNormalized, ...uniqueNewNodes], + ...(pastedSourceId ? { sources: [...state.sources, { id: pastedSourceId, type: "yaml" as const, content, parsed: true, nodeCount: result.nodes.length, sourceSnapshot: createSourceSnapshot(result.nodes, result.sourceConfig) }] } : {}), parseErrors: result.errors, isLoading: false, }; @@ -211,8 +220,8 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate const treatAsNewSource = source.type === "url" && Boolean(lastParsedContent) && lastParsedContent !== currentSourceContent; - const currentTag = typeof source.tag === "string" ? source.tag.trim() : ""; - const currentNameTemplate = typeof source.nameTemplate === "string" ? source.nameTemplate.trim() : ""; + const currentTag = !source.useProxyProviders && typeof source.tag === "string" ? source.tag.trim() : ""; + const currentNameTemplate = !source.useProxyProviders && typeof source.nameTemplate === "string" ? source.nameTemplate.trim() : ""; const lastTag = typeof source.lastParsedTag === "string" ? source.lastParsedTag.trim() : ""; const lastNameTemplate = typeof source.lastParsedNameTemplate === "string" ? source.lastParsedNameTemplate.trim() : ""; @@ -228,70 +237,17 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate let contentToParse = source.content; let subscriptionUserInfo: SubscriptionUserInfo | undefined; let prefetchedParseResult: ParseResult | null = null; - - // URL 源的 proxy-providers 模式:不拉取/解析节点,仅标记为已导入并移除该源原节点 - if (source.type === "url" && source.useProxyProviders) { - const rawUrl = source.content.trim(); - const normalizedUrl = tryNormalizeSubscriptionUrlInput(rawUrl); - if (!normalizedUrl) { - throw new Error("无效的 url 格式"); - } - const parsed = new URL(normalizedUrl); - if (!["http:", "https:"].includes(parsed.protocol)) { - throw new Error("只支持 HTTP/HTTPS url"); - } - - if (discardStaleSingle(operation)) return; - - setAndGenerateConfig((state) => { - const baseNodes = detachSourceNodesFromState(state.nodes, sourceId).nodes; - - const availableNames = new Set(baseNodes.map((n) => n.name)); - const nextListenerPorts: Record = {}; - for (const [name, port] of Object.entries(state.listenerPorts)) { - if (!availableNames.has(name)) continue; - if (typeof port !== "number" || !Number.isInteger(port)) continue; - nextListenerPorts[name] = port; - } - - const nextDialerProxyGroups = filterDialerProxyGroupsByAvailableNames( - state.dialerProxyGroups, availableNames, state - ); - - return { - nodes: baseNodes, - listenerPorts: nextListenerPorts, - dialerProxyGroups: nextDialerProxyGroups, - sources: state.sources.map((s) => - s.id === sourceId - ? { - ...s, - parsing: false, - parsed: true, - nodeCount: undefined, - subscriptionUserInfo: undefined, - error: undefined, - errorInfo: undefined, - lastParsedContent: normalizedUrl, - lastParsedTag: currentTag || undefined, - lastParsedNameTemplate: currentNameTemplate || undefined, - } - : s - ), - parseErrors: [], - }; - }); - importOperations.finishSingle(operation); - return; - } + let responseHeaders: Record | undefined; // 如果是 url,需要先获取内容 if (source.type === "url") { + if (source.useProxyProviders) validateSourceUrl(source.content); const fetched = await fetchUrlContentInBrowser(source.content, { userinfoUrl: source.userinfoUrl, userinfoUserAgent: source.userinfoUserAgent, }); contentToParse = fetched.content; + responseHeaders = fetched.headers; prefetchedParseResult = pickUrlFetchParseResult(fetched); const header = fetched.headers["subscription-userinfo"]; if (typeof header === "string" && header.trim()) { @@ -311,7 +267,7 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate const result = prefetchedParseResult ?? parseSubscription(contentToParse); const resolvedSubscriptionUserInfo = resolveSubscriptionUserInfo(subscriptionUserInfo, result.nodes); - if (result.nodes.length === 0) { + if (result.nodes.length === 0 && !hasUsableSourceConfig(result.sourceConfig)) { const errorMsg = result.errors[0] ?? "未解析到有效节点"; throw new Error(errorMsg); } @@ -358,6 +314,7 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate parsing: false, parsed: true, nodeCount: result.nodes.length, + sourceSnapshot: createSourceSnapshot(result.nodes, result.sourceConfig, responseHeaders, result.resolvedHosts, source.sourceSnapshot), subscriptionUserInfo: hasSubscriptionUserInfo(resolvedSubscriptionUserInfo) ? resolvedSubscriptionUserInfo : undefined, @@ -399,7 +356,6 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate ...s, parsing: false, parsed: false, - subscriptionUserInfo: undefined, error: info.message, errorInfo: info, } @@ -425,6 +381,7 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate string, { parsed: boolean; + sourceSnapshot?: SourceSnapshot; nodeCount?: number; subscriptionUserInfo?: SubscriptionUserInfo; error?: string; @@ -442,47 +399,18 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate let contentToParse = source.content; let subscriptionUserInfo: SubscriptionUserInfo | undefined; let prefetchedParseResult: ParseResult | null = null; - - // URL 源的 proxy-providers 模式:不拉取/解析节点,仅更新导入状态(节点由客户端拉取) - if (source.type === "url" && source.useProxyProviders) { - const rawUrl = source.content.trim(); - try { - const normalizedUrl = tryNormalizeSubscriptionUrlInput(rawUrl); - if (!normalizedUrl) { - throw new Error("无效的 url 格式"); - } - const parsed = new URL(normalizedUrl); - if (!["http:", "https:"].includes(parsed.protocol)) { - throw new Error("只支持 HTTP/HTTPS url"); - } - } catch (error) { - const baseMessage = error instanceof Error ? error.message : "无效的 url 格式"; - const message = `${baseMessage}`; - const safeUrl = maskUrlForPublicDisplay(rawUrl); - allErrors.push(sanitizePublicErrorText(`url ${safeUrl} 解析失败: ${message}`)); - sourceMeta.set(source.id, { parsed: false, error: message }); - continue; - } - - const currentTag = typeof source.tag === "string" ? source.tag.trim() : ""; - const currentNameTemplate = typeof source.nameTemplate === "string" ? source.nameTemplate.trim() : ""; - sourceMeta.set(source.id, { - parsed: true, - lastParsedContent: tryNormalizeSubscriptionUrlInput(rawUrl) ?? rawUrl, - lastParsedTag: currentTag || undefined, - lastParsedNameTemplate: currentNameTemplate || undefined, - }); - continue; - } + let responseHeaders: Record | undefined; // 如果是 url,需要先获取内容 if (source.type === "url") { try { + if (source.useProxyProviders) validateSourceUrl(source.content); const fetched = await fetchUrlContentInBrowser(source.content, { userinfoUrl: source.userinfoUrl, userinfoUserAgent: source.userinfoUserAgent, }); contentToParse = fetched.content; + responseHeaders = fetched.headers; prefetchedParseResult = pickUrlFetchParseResult(fetched); const header = fetched.headers["subscription-userinfo"]; if (typeof header === "string" && header.trim()) { @@ -519,9 +447,10 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate // 解析内容 const result = prefetchedParseResult ?? parseSubscription(contentToParse); + if (result.nodes.length === 0 && !hasUsableSourceConfig(result.sourceConfig)) throw new Error(result.errors[0] ?? "未解析到有效节点"); const resolvedSubscriptionUserInfo = resolveSubscriptionUserInfo(subscriptionUserInfo, result.nodes); - const currentTag = typeof source.tag === "string" ? source.tag.trim() : ""; - const currentNameTemplate = typeof source.nameTemplate === "string" ? source.nameTemplate.trim() : ""; + const currentTag = !source.useProxyProviders && typeof source.tag === "string" ? source.tag.trim() : ""; + const currentNameTemplate = !source.useProxyProviders && typeof source.nameTemplate === "string" ? source.nameTemplate.trim() : ""; allNodes.push( ...prepareSourceParsedNodes(result.nodes, { currentTag, @@ -540,6 +469,7 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate sourceMeta.set(source.id, { parsed: true, nodeCount: result.nodes.length, + sourceSnapshot: createSourceSnapshot(result.nodes, result.sourceConfig, responseHeaders, result.resolvedHosts, source.sourceSnapshot), subscriptionUserInfo: hasSubscriptionUserInfo(resolvedSubscriptionUserInfo) ? resolvedSubscriptionUserInfo : undefined, @@ -572,6 +502,19 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate } } + for (const source of sources) { + if (sourceMeta.get(source.id)?.parsed || !source.sourceSnapshot) continue; + if (get().nodes.some(node => getNodeSourceIds(node).includes(source.id))) continue; + allNodes.push(...prepareSourceParsedNodes(source.sourceSnapshot.nodes, { currentTag: source.useProxyProviders ? undefined : source.tag, currentNameTemplate: source.useProxyProviders ? undefined : source.nameTemplate }).map(node => withNodeSourceId(node, source.id))); + } + const retainedSourceIds = new Set(sources.filter(source => !sourceMeta.get(source.id)?.parsed).map(source => source.id)); + for (const node of get().nodes) { + const ids = getNodeSourceIds(node); + const retained = ids.filter(id => retainedSourceIds.has(id)); + if (retained.length) allNodes.push(mergeNodeSourceIds(node, new Set(retained))); + else if (!ids.length) allNodes.push(node); + } + // 去重节点(基于 originName + 节点内容) const uniqueNodeMap = new Map(); for (const node of allNodes) { @@ -673,7 +616,7 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate parsed: meta.parsed, parsing: false, ...(typeof meta.nodeCount === "number" ? { nodeCount: meta.nodeCount } : {}), - ...(meta.subscriptionUserInfo ? { subscriptionUserInfo: meta.subscriptionUserInfo } : { subscriptionUserInfo: undefined }), + ...(meta.parsed ? { subscriptionUserInfo: meta.subscriptionUserInfo, sourceSnapshot: meta.sourceSnapshot } : {}), ...(meta.error ? { error: meta.error } : { error: undefined }), ...(meta.errorInfo ? { errorInfo: meta.errorInfo } : { errorInfo: undefined }), ...(typeof meta.lastParsedContent === "string" && meta.lastParsedContent.trim()