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
4 changes: 3 additions & 1 deletion packages/core/src/generator/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { 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 @@ -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;
}

/**
Expand Down
154 changes: 154 additions & 0 deletions packages/core/src/generator/source-configs.ts
Original file line number Diff line number Diff line change
@@ -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<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);
}

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 groups: Record<string, unknown>[] = [];
const sourceEntrypoints: string[] = [];
const importedRules: string[] = [];
const proxyProviders: Record<string, unknown> = {};
const ruleProviders: Record<string, unknown> = {};
const dnsSelfAddresses = new Set<string>();
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<string, string>();
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<string, unknown> = { ...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)));
Comment thread
QAQLeonard marked this conversation as resolved.
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<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 : [];
});
}
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;
}
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
Loading