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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ npm run check:local-app

For parser, template, subscription output, or deployment changes, also run the focused test or selftest that covers the changed behavior.

## UI Colors

The app has a dark and a light theme with the same layout. Take colors from the theme tokens in `packages/ui/src/styles/theme.css` (for example `text-fg-60`, `bg-ink/5`, `border-ink/10`, `bg-surface`) instead of dark-only classes such as `text-white/60` or `bg-white/5`; `npm run lint` rejects the latter. Check UI changes in both themes.

## Documentation

- Keep README changes bilingual: update `README-CN.md` first, then sync `README.md`.
Expand Down
16 changes: 8 additions & 8 deletions local/app/dashboard/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export default function SettingsPage() {
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-bold mb-1">账户设置</h1>
<p className="text-white/50">本地管理员、订阅源安全和运行端点</p>
<p className="text-fg-50">本地管理员、订阅源安全和运行端点</p>
</div>
</div>

Expand All @@ -101,11 +101,11 @@ export default function SettingsPage() {
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-xs text-white/40">用户名</p>
<p className="text-xs text-fg-40">用户名</p>
<p className="mt-1 font-medium">{user?.username || "未登录"}</p>
</div>
<div>
<p className="text-xs text-white/40">已保存订阅</p>
<p className="text-xs text-fg-40">已保存订阅</p>
<p className="mt-1 font-medium">{user ? `${user.subscriptionCount} / ${user.quota.maxSubscriptions}` : "-"}</p>
</div>
<Button variant="destructive" className="gap-2" onClick={() => void handleLogout()} disabled={!user}>
Expand Down Expand Up @@ -141,14 +141,14 @@ export default function SettingsPage() {
</div>
<CardTitle className="text-base">运行端点</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-white/60">
<CardContent className="space-y-3 text-sm text-fg-60">
<div>
<p className="text-xs text-white/40">存活检查</p>
<code className="mt-1 block rounded-md bg-white/5 px-3 py-2 text-white/70">/api/health/live</code>
<p className="text-xs text-fg-40">存活检查</p>
<code className="mt-1 block rounded-md bg-ink/5 px-3 py-2 text-fg-70">/api/health/live</code>
</div>
<div>
<p className="text-xs text-white/40">就绪检查</p>
<code className="mt-1 block rounded-md bg-white/5 px-3 py-2 text-white/70">/api/health/ready</code>
<p className="text-xs text-fg-40">就绪检查</p>
<code className="mt-1 block rounded-md bg-ink/5 px-3 py-2 text-fg-70">/api/health/ready</code>
</div>
</CardContent>
</Card>
Expand Down
6 changes: 5 additions & 1 deletion local/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { MobileNav } from "@subboost/ui/components/layout/mobile-nav";
import { ScrollLockStabilizer } from "@subboost/ui/components/layout/scroll-lock-stabilizer";
import { ConfirmDialogHost } from "@subboost/ui/components/ui/confirm-dialog";
import { Toaster } from "@subboost/ui/components/ui/toaster";
import { THEME_INIT_SCRIPT } from "@subboost/ui/theme/theme-init-script";
import { LocalHeader } from "@local/components/local-header";
import { resolveAppVersionInfo } from "@subboost/server-core/app-version";
import {
Expand Down Expand Up @@ -42,7 +43,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
const { buildVersion } = resolveAppVersionInfo({ env: process.env, cwd: process.cwd() });

return (
<html lang="zh-CN" className="dark">
<html lang="zh-CN" data-theme="dark" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
</head>
<body className="font-sans">
<ScrollLockStabilizer />
<div className="min-h-screen bg-gradient-radial flex flex-col">
Expand Down
1 change: 1 addition & 0 deletions local/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"next": "^16.3.4",
"peggy": "^5.1.0",
"prisma": "^7.9.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-remove-scroll-bar": "^2.3.8",
Expand Down
8 changes: 4 additions & 4 deletions local/src/components/local-login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,11 @@ export function LocalLogin() {
<Link href="/" className="inline-flex items-center gap-2">
<Image src="/logo.png" alt="SubBoost" width={64} height={64} className="rounded-2xl shadow-lg shadow-blue-500/25" />
</Link>
<h1 className="text-2xl font-bold mt-4 text-white">欢迎使用 SubBoost</h1>
<p className="text-white/50 mt-2">{setupRequired ? "初始化本地管理员账号" : "登录以使用订阅管理功能"}</p>
<h1 className="text-2xl font-bold mt-4 text-fg">欢迎使用 SubBoost</h1>
<p className="text-fg-50 mt-2">{setupRequired ? "初始化本地管理员账号" : "登录以使用订阅管理功能"}</p>
</div>

<div className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl p-6 space-y-4">
<div className="bg-ink/5 backdrop-blur-sm border border-ink/10 rounded-2xl p-6 space-y-4">
{auth ? (
<form onSubmit={handleSubmit} className="space-y-3">
<FormField label="管理员账号">
Expand Down Expand Up @@ -158,7 +158,7 @@ export function LocalLogin() {
</Button>
</form>
) : (
<div className="h-36 animate-pulse rounded-xl bg-white/5" />
<div className="h-36 animate-pulse rounded-xl bg-ink/5" />
)}
</div>
</div>
Expand Down
39 changes: 39 additions & 0 deletions local/src/lib/source-import-completeness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { importSourceUrlDirect } from "./source-import";

const mocks = vi.hoisted(() => ({ settings: vi.fn(), lookup: vi.fn() }));
vi.mock("node:dns/promises", () => ({ lookup: mocks.lookup }));
vi.mock("./source-import-settings", () => ({ getAllowUnsafeSubscriptionSources: mocks.settings }));
const basic = "{name: ss, type: ss, server: local.subboost.test, port: 443, cipher: aes-128-gcm, password: secret}";
const anytls = "{name: tls, type: anytls, server: local.subboost.test, port: 8443, password: pass}";

describe("local guarded snapshot negotiation", () => {
beforeEach(() => {
mocks.settings.mockResolvedValue(false);
mocks.lookup.mockRejectedValue(new Error("synthetic unresolved hostname"));
});
afterEach(() => vi.unstubAllGlobals());

it("imports a hidden protocol through the actual local adapter and shared parser", async () => {
const fetch = vi.fn(async (_url, options) => {
const ua = options.headers["User-Agent"];
return new Response(`proxies: [${basic}${ua === "clash-verge/v2.5.2" ? ", " + anytls : ""}]`, { headers: { profile: ua } });
});
vi.stubGlobal("fetch", fetch);
const result = await importSourceUrlDirect({ url: "https://local.subboost.test/sub" });
expect(result).toMatchObject({ ok: true, parsedNodes: [expect.objectContaining({ type: "ss" }), expect.objectContaining({ type: "anytls" })], headers: { profile: "clash-verge/v2.5.2" } });
expect(fetch).toHaveBeenCalledTimes(4);
expect(mocks.settings).toHaveBeenCalledTimes(1);
expect(fetch.mock.calls.every(([, options]) => options.redirect === "manual" && options.signal instanceof AbortSignal)).toBe(true);
});

it("retains the valid candidate when later profiles redirect into a private address", async () => {
const fetch = vi.fn().mockResolvedValueOnce(new Response(`proxies: [${basic}]`))
.mockImplementation(async () => new Response(null, { status: 302, headers: { location: "http://127.0.0.1/private" } }));
vi.stubGlobal("fetch", fetch);
const result = await importSourceUrlDirect({ url: "https://local.subboost.test/sub" });
expect(result).toMatchObject({ ok: true, parsedNodes: [expect.objectContaining({ type: "ss" })] });
expect(fetch).toHaveBeenCalledTimes(4);
expect(fetch.mock.calls.every(([url]) => url === "https://local.subboost.test/sub")).toBe(true);
});
});
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-remove-scroll-bar": "^2.3.8",
"qrcode.react": "^4.2.0",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"./mihomo/*": "./src/mihomo/*.ts",
"./node-name-template": "./src/node-name-template.ts",
"./node-identity": "./src/node-identity.ts",
"./node-endpoint": "./src/node-endpoint.ts",
"./parser": "./src/parser/index.ts",
"./parser/*": "./src/parser/*.ts",
"./proxy-group-advanced": "./src/proxy-group-advanced.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/generator/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function generateDialerProxyGroups(
name: group.group.name,
groupType: group.group.type,
proxies: group.proxies,
testUrl,
testUrl: group.group.type === "url-test" ? group.group.testUrl || testUrl : testUrl,
testInterval,
strategy: group.group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY,
extraFields: providerUse,
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/generator/module-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,23 @@ describe("module rule helpers", () => {
});

it("detects rules moved to another module", () => {
expect(isModuleRuleMovedFrom("media", "youtube")).toBe(false);
expect(isModuleRuleMovedFrom("media", "youtube", { other: [null as never, { id: "hulu" }] })).toBe(false);
expect(isModuleRuleMovedFrom("media", "youtube", { other: [{ id: "youtube" }] })).toBe(true);
expect(isModuleRuleMovedFrom("media", "youtube", { media: [{ id: "youtube" }] })).toBe(false);
expect(isModuleRuleMovedFrom("", "youtube", { other: [{ id: "youtube" }] })).toBe(false);
expect(isModuleRuleMovedFrom("media", " ", { other: [{ id: "youtube" }] })).toBe(false);
expect(isModuleRuleMovedFrom("media", "youtube", { other: "bad" as never })).toBe(false);
});

it("preserves the preset label for incoming rules and keeps user additions custom", () => {
const incoming = { ...customRules[1], source: "preset" as const };
const target = { ...proxyModule, rules: [] };
expect(getEffectiveModuleRuleItems(target, { media: [incoming, customRules[0]] })
.map(({ id, source }) => ({ id, source }))).toEqual([
{ id: "hulu", source: "preset" },
{ id: "netflix", source: "custom" },
]);
expect(getEffectiveModuleRules(target, { media: [incoming] })).toEqual([customRules[1]]);
});
});
4 changes: 2 additions & 2 deletions packages/core/src/generator/module-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function getModuleRuleById(

export function getEffectiveModuleRuleItems(
module: ProxyGroupModule,
ruleSetsByTarget?: Record<string, ProxyGroupRule[]>,
ruleSetsByTarget?: Record<string, (ProxyGroupRule & { source?: EffectiveModuleRuleSource })[]>,
hiddenPresetRuleIds?: HiddenPresetRuleIds
): EffectiveModuleRule[] {
const excluded = getExcludedModuleRuleIds(module.id, hiddenPresetRuleIds);
Expand All @@ -107,7 +107,7 @@ export function getEffectiveModuleRuleItems(
for (const rule of extraRules) {
if (!rule?.id || seen.has(rule.id)) continue;
seen.add(rule.id);
out.push({ ...rule, source: "custom" });
out.push({ ...rule, source: rule.source ?? "custom" });
}

return out;
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/generator/proxy-groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ function customGroup(id: string, groupType: CustomProxyGroup["groupType"]): Cust
}

describe("proxy group generator", () => {
it("uses per-group test URLs only for automatic testing", () => {
const groups = generateProxyGroups({
nodes: [node("A")], enabledModules: ["select", "auto", "ai"],
ruleProviderBaseUrl: "https://local.subboost.test/rules", testUrl: "https://local.subboost.test/default", testInterval: 60,
proxyGroupAdvanced: { auto: { testUrl: "https://local.subboost.test/auto" }, ai: { testUrl: "https://local.subboost.test/ignored" } },
customProxyGroups: [
{ ...customGroup("url", "url-test"), advanced: { testUrl: "https://local.subboost.test/custom" } },
{ ...customGroup("filtered", "url-test"), memberSource: "filtered-nodes", advanced: { testUrl: "https://local.subboost.test/filtered" } },
{ ...customGroup("fallback", "fallback"), advanced: { testUrl: "https://local.subboost.test/ignored" } },
],
});
expect(groups.find((group) => group.name === "⚡ 自动选择")?.url).toBe("https://local.subboost.test/auto");
expect(groups.find((group) => group.name === "Custom url")?.url).toBe("https://local.subboost.test/custom");
expect(groups.find((group) => group.name === "Custom filtered")?.url).toBe("https://local.subboost.test/filtered");
expect(groups.find((group) => group.name === "Custom fallback")?.url).toBe("https://local.subboost.test/default");
expect(groups.filter((group) => group.type === "select").every((group) => group.url === undefined)).toBe(true);
});
it("generates module, custom, advanced-filtered, and provider-backed groups", () => {
const groups = generateProxyGroups({
nodes: [node("Node A"), node("Node B")],
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/generator/proxy-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,13 +230,14 @@ export function generateProxyGroups(options: ProxyGroupGenerateOptions): ProxyGr
groupType: ProxyGroupGroupType,
proxies: string[],
strategy?: LoadBalanceStrategy,
extraFields: Record<string, unknown> = providerUse
extraFields: Record<string, unknown> = providerUse,
groupTestUrl?: string,
): ProxyGroup =>
buildTypedProxyGroup({
name,
groupType,
proxies,
testUrl,
testUrl: groupType === "url-test" ? groupTestUrl || testUrl : testUrl,
testInterval,
strategy,
extraFields,
Expand Down Expand Up @@ -292,6 +293,7 @@ export function generateProxyGroups(options: ProxyGroupGenerateOptions): ProxyGr
id: module.id,
name: moduleName,
}),
undefined, providerUse, advanced?.testUrl,
));
break;

Expand Down Expand Up @@ -372,7 +374,7 @@ export function generateProxyGroups(options: ProxyGroupGenerateOptions): ProxyGr

if (usesFilteredNodeMembers(customGroup)) {
if (customGroup.groupType === "url-test" || customGroup.groupType === "fallback") {
return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames), undefined, {});
return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames), undefined, {}, customGroup.advanced?.testUrl);
}
if (customGroup.groupType === "load-balance") {
return createGeneratedProxyGroup(
Expand All @@ -392,7 +394,7 @@ export function generateProxyGroups(options: ProxyGroupGenerateOptions): ProxyGr
};
}
if (customGroup.groupType === "url-test") {
return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames));
return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames), undefined, providerUse, customGroup.advanced?.testUrl);
}
if (customGroup.groupType === "fallback") {
return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames));
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/generator/yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ const PROXY_FIELD_ORDER_PROTOCOL: Partial<Record<KnownNodeType, string[]>> = {
snell: ["psk", "version", "obfs-opts", "reuse"],
direct: ["udp", "ip-version", "interface-name", "routing-mark"],
dns: ["udp"],
reject: [],
mieru: ["username", "password", "transport", "port-range", "multiplexing", "handshake-mode"],
masque: ["username", "password"],
trusttunnel: ["username", "password", "health-check", "quic", "congestion-controller", "max-connections", "min-streams", "max-streams"],
sudoku: [
"key",
"aead-method",
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/mihomo/certificate-fingerprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/** Normalize a full-certificate SHA256 fingerprint, not a client uTLS name or HPKP pin. */
export function normalizeCertificateFingerprint(value: unknown): string | null {
if (typeof value !== "string") return null;
const raw = value.trim();
if (!raw) return null;
const withoutPrefix = raw
.replace(/^sha256\s+fingerprint\s*=\s*/i, "")
.replace(/^sha256[:=]\s*/i, "");
const compact = withoutPrefix.replace(/:/g, "").toLowerCase();
return /^[A-Fa-f0-9]{64}$/.test(compact) ? compact : null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ describe("Mihomo proxy sanitizer boundaries", () => {
(vlessWithoutReality["xhttp-opts"] as Record<string, Record<string, unknown>>)["download-settings"]["ech-opts"]
).not.toHaveProperty("query-server-name");

expect(isMihomoSupportedProxyNode({ type: "http", name: "HTTP" })).toBe(true);
expect(isMihomoSupportedProxyNode({ type: "http", name: "HTTP" })).toBe(false);
expect(
isMihomoSupportedProxyNode({
type: "wireguard",
Expand Down
Loading
Loading