Skip to content
Closed
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
7 changes: 6 additions & 1 deletion local/src/lib/source-import.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -244,11 +245,15 @@ async function fetchTextDirect(

export async function importSourceUrlDirect(request: SourceImportRequest): Promise<SourceImportResult> {
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: {
Expand Down
16 changes: 11 additions & 5 deletions local/src/lib/subscription-service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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("过滤后没有可用节点");
}
Expand Down Expand Up @@ -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) ?? {};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -313,6 +316,7 @@ export async function deleteSubscription(ownerId: string, id: string): Promise<b

export function buildSubscriptionFetchCallbacks() {
return {
resolveHosts: resolveSourceHosts,
fetchUrlNodes: async (source: SavedSource) => {
const imported = await importSourceUrlDirect({
url: source.content,
Expand All @@ -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,
};
Expand Down Expand Up @@ -436,7 +442,7 @@ export async function generateSubscriptionYaml(token: string): Promise<Generated
const secrets = readSubscriptionSecrets(row);
const { testUrl, testInterval } = getEffectiveTestOptions(secrets.config);
const proxyProviders = buildProxyProvidersFromConfig(secrets.config, { testUrl, testInterval });
if (secrets.nodes.length === 0 && !proxyProviders) return null;
if (secrets.nodes.length === 0 && !proxyProviders && !collectSourceConfigs(secrets.config.sources).some(source => hasUsableSourceConfig(source.config))) return null;
const yaml = generateClashYaml(
buildGenerateOptionsFromConfig(secrets.config, {
nodes: secrets.nodes,
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/generator/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { buildSourceProxyProviders, mergeSourceClashConfigs, type SourceClashConfig } from "./source-configs";
/**
* Clash 配置生成器 - 浏览器端运行
*/
Expand Down Expand Up @@ -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<string, unknown>;
template?: TemplateType;
Expand Down Expand Up @@ -200,9 +202,10 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig {
};
const hasExplicitBaseConfigYaml = typeof userConfig.dnsYaml === "string";

const allProxyProviders = { ...buildSourceProxyProviders(options.sourceConfigs ?? [], nodes), ...proxyProviders };
const resolvedProxyProviders =
proxyProviders && typeof proxyProviders === "object" && Object.keys(proxyProviders).length > 0
? proxyProviders
Object.keys(allProxyProviders).length > 0
? allProxyProviders
: undefined;
const proxyProviderNames = resolvedProxyProviders
? Object.keys(resolvedProxyProviders).map((k) => k.trim()).filter(Boolean).sort((a, b) => a.localeCompare(b))
Expand Down Expand Up @@ -590,7 +593,7 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig {
}),
};

return clashConfig as unknown as ClashConfig;
return mergeSourceClashConfigs(clashConfig, options.sourceConfigs ?? [], uniqueNodes) as unknown as ClashConfig;
}

/**
Expand Down
108 changes: 108 additions & 0 deletions packages/core/src/generator/source-configs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { ParsedNode } from "../types/node";
import { getNodeOriginName, getNodeSourceIds } from "../subscription/node-source-state";

export interface SourceClashConfig { id: string; config: Record<string, unknown>; resolvedHosts?: Record<string, string[]> }
const record = (value: unknown): value is Record<string, unknown> => 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<string>();
return [...left, ...right].filter(value => { const key = JSON.stringify(value); if (seen.has(key)) return false; seen.add(key); return true; });
}
return structuredClone(right);
}

// Imported providers feed SubBoost's own groups; upstream groups and rules are never exported.
export function buildSourceProxyProviders(sources: SourceClashConfig[], nodes: ParsedNode[]): Record<string, unknown> {
const providers: Record<string, unknown> = {};
for (const source of sources) {
const raw = source.config["proxy-providers"];
if (!record(raw)) continue;
const nodeNames = new Map(nodes.filter(node => getNodeSourceIds(node).includes(source.id))
.map(node => [getNodeOriginName(node), node.name]));
for (const [name, value] of Object.entries(raw)) {
if (!record(value)) continue;
const provider = structuredClone(value);
if (typeof provider.proxy === "string" && !["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"].includes(provider.proxy)) {
const target = nodeNames.get(provider.proxy);
if (target) provider.proxy = target;
else delete provider.proxy;
}
if (provider.type === "http") {
provider.path = `./source_providers/${source.id.replace(/[^a-zA-Z0-9_-]/g, "_")}_${name.replace(/[^a-zA-Z0-9_-]/g, "_")}.yaml`;
}
providers[`source:${source.id}:${name}`] = provider;
}
}
return providers;
}

export function mergeSourceClashConfigs(generated: Record<string, unknown>, sources: SourceClashConfig[], nodes: ParsedNode[]): Record<string, unknown> {
if (!sources.length) return generated;
let base: Record<string, unknown> = {};
const dnsSelfAddresses = new Set<string>();

for (const source of sources) {
const config = source.config;
const nodeNames = new Map<string, string>();
for (const node of nodes) if (getNodeSourceIds(node).includes(source.id)) nodeNames.set(getNodeOriginName(node), node.name);
const builtins = new Set(["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"]);
const policy = (name: string) => builtins.has(name) ? name : nodeNames.get(name);

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", "sub-rules", "proxy-providers", "rule-providers"].includes(key)));
if (Array.isArray(metadata.listeners)) {
metadata.listeners = metadata.listeners.flatMap(listener => {
if (!record(listener) || typeof listener.proxy !== "string") return [listener];
const target = policy(listener.proxy);
return target ? [{ ...listener, proxy: target }] : [];
});
}
base = merge(metadata, base) as Record<string, unknown>;
}
const result = merge(base, generated) as Record<string, unknown>;
// 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 : [];
});
}
return result;
}
18 changes: 4 additions & 14 deletions packages/core/src/parser/clash-yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>);
Expand All @@ -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) {
Expand All @@ -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<string, unknown>) } : {}),
};
} catch (e) {
return {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/parser/content-parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/parser/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/subscription/config-utils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -352,6 +353,7 @@ export function buildGenerateOptionsFromConfig(

return {
nodes: sanitizedNodes,
...(collectSourceConfigs(config.sources).length ? { sourceConfigs: collectSourceConfigs(config.sources) } : {}),
...(proxyProviders ? { proxyProviders } : {}),
template,
userConfig,
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/subscription/proxy-providers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { normalizeSourceSnapshot } from "./source-snapshot";
import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/url-input";

/**
Expand All @@ -23,7 +24,7 @@ export function buildProxyProvidersFromConfig(
if (!raw || typeof raw !== "object") continue;
const item = raw as Record<string, unknown>;
if (item.type !== "url") continue;
if (item.useProxyProviders !== true) continue;
if (item.useProxyProviders !== true || normalizeSourceSnapshot(item.sourceSnapshot)) continue;

const url =
typeof item.content === "string"
Expand Down
52 changes: 52 additions & 0 deletions packages/core/src/subscription/source-snapshot.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
headers?: Record<string, string>;
resolvedHosts?: Record<string, string[]>;
}

export function hasUsableSourceConfig(config: Record<string, unknown> | 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<string, unknown>;
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<string, unknown>;
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<string, unknown> : {};
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<string, unknown>, headers?: Record<string, string>, resolvedHosts?: Record<string, string[]>, previous?: SourceSnapshot): SourceSnapshot {
const oldHosts = previous?.config.hosts as Record<string, unknown> | undefined;
const newHosts = config?.hosts as Record<string, unknown> | 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<string, unknown>; resolvedHosts?: Record<string, string[]> }> {
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 } : {}) }] : [];
});
}
2 changes: 2 additions & 0 deletions packages/core/src/types/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,8 @@ export type ParsedNode =
| (BaseNode & { type: UnknownNodeType });

export interface ParseResult {
sourceConfig?: Record<string, unknown>;
resolvedHosts?: Record<string, string[]>;
nodes: ParsedNode[];
errors: string[];
totalParsed: number;
Expand Down
Loading