diff --git a/server/auth.test.ts b/server/auth.test.ts new file mode 100644 index 0000000..9274249 --- /dev/null +++ b/server/auth.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'bun:test' +import { tokenFromRequest, tokenMatches } from './auth.ts' + +const TOKEN = '208ac4512b9748b9b7408ebc16ec793d' + +describe('令牌比较', () => { + it('一模一样才算通过', () => { + expect(tokenMatches(TOKEN, TOKEN)).toBe(true) + }) + + it('差一个字符就不通过', () => { + expect(tokenMatches(TOKEN, `${TOKEN.slice(0, -1)}x`)).toBe(false) + }) + + it('前缀正确也不算通过', () => { + // 逐字节比较写错时最容易漏掉的一种:只比对了开头 + expect(tokenMatches(TOKEN, TOKEN.slice(0, 8))).toBe(false) + }) + + it('更长的字符串不算通过', () => { + expect(tokenMatches(TOKEN, `${TOKEN}extra`)).toBe(false) + }) + + it('没带令牌不算通过', () => { + expect(tokenMatches(TOKEN, null)).toBe(false) + expect(tokenMatches(TOKEN, '')).toBe(false) + }) + + it('比较耗时不随匹配长度变化', () => { + // 这是常数时间比较存在的理由:如果先匹配的字符越多越慢,就能靠计时逐字节猜出令牌。 + // 这里只验证行为上的对称——两个都不匹配的输入走的是同样多的比较。 + const almost = `${TOKEN.slice(0, -1)}x` + const nothing = 'x'.repeat(TOKEN.length) + expect(tokenMatches(TOKEN, almost)).toBe(false) + expect(tokenMatches(TOKEN, nothing)).toBe(false) + }) +}) + +describe('从请求里取令牌', () => { + const req = (init?: RequestInit, url = 'http://x/api/config') => new Request(url, init) + + it('认 Authorization 头', () => { + expect(tokenFromRequest(req({ headers: { Authorization: `Bearer ${TOKEN}` } }))).toBe(TOKEN) + }) + + it('认查询参数,WebSocket 用得上——它没法自定义请求头', () => { + expect(tokenFromRequest(req(undefined, `http://x/api/logs?token=${TOKEN}`))).toBe(TOKEN) + }) + + it('头的格式不对时当作没带', () => { + expect(tokenFromRequest(req({ headers: { Authorization: TOKEN } }))).toBeNull() + expect(tokenFromRequest(req({ headers: { Authorization: `Basic ${TOKEN}` } }))).toBeNull() + }) + + it('什么都没带时返回空', () => { + expect(tokenFromRequest(req())).toBeNull() + }) +}) diff --git a/server/config-store.ts b/server/config-store.ts index ef6c2e5..a19ad82 100644 --- a/server/config-store.ts +++ b/server/config-store.ts @@ -33,8 +33,6 @@ const KEEP_BACKUPS = 10 export interface ConfigSnapshot { /** 解析出来的配置数据 */ data: ConfigObject - /** 文件原文 */ - yaml: string /** * 内容指纹。客户端改完提交时要带上它,服务端据此判断这份改动 * 是不是基于最新的文件——中途有人 SSH 上去手改过就会对不上。 @@ -56,7 +54,12 @@ export type ApplyFailureStage = | 'write' // 写文件失败 | 'reload' // 文件写进去了,但内核没能重新加载 -export async function readConfig(env: Env): Promise { +/** 内部用的完整读取,带原文。原文只在生成新内容时需要,不下发给前端。 */ +interface ConfigDocument extends ConfigSnapshot { + yaml: string +} + +async function readDocument(env: Env): Promise { const yaml = await readFile(env.configPath, 'utf8') const doc = openDocument(yaml) return { @@ -67,6 +70,30 @@ export async function readConfig(env: Env): Promise { } } +/** + * 读当前配置。 + * + * 不含文件原文:前端从来不读它,带上只会让每次请求的响应体积翻倍。要原文的地方 + * 在这个文件内部,用 readDocument。 + */ +export async function readConfig(env: Env): Promise { + const { yaml: _yaml, ...snapshot } = await readDocument(env) + return snapshot +} + +/** + * 把新的配置数据写成 YAML 文本。 + * + * 单独导出是为了让测试能走**生产用的这一条**路径。之前测试自己拼了 + * 「解析 → 替换 → 还原对齐」三步,于是如果有人在落盘链路里漏掉最后那步, + * 那些专为守护它而写的测试照样全绿。 + */ +export function rewriteYaml(currentYaml: string, nextData: ConfigObject): string { + const rewritten = replaceDocumentData(openDocument(currentYaml), nextData) + // 把只是丢了对齐空格的行还原成原样,否则没被改动的段落也会出现在 diff 里,见 ORIGIN.md + return restoreInlineCommentAlignment(currentYaml, rewritten.yaml) +} + /** * 把一份新的配置数据落盘。 * @@ -78,7 +105,7 @@ export async function applyConfig( nextData: ConfigObject, baseRevision?: string, ): Promise { - const current = await readConfig(env) + const current = await readDocument(env) if (baseRevision !== undefined && baseRevision !== current.revision) { return { @@ -90,14 +117,12 @@ export async function applyConfig( } } - // 生成新内容。restoreInlineCommentAlignment 把只是丢了对齐空格的行还原成原样, - // 否则没被改动的段落也会出现在 diff 里,见 ORIGIN.md。 - const rewritten = replaceDocumentData(openDocument(current.yaml), nextData) - const nextYaml = restoreInlineCommentAlignment(current.yaml, rewritten.yaml) + const nextYaml = rewriteYaml(current.yaml, nextData) // 内容没变就不要走后面那一串——不必要的备份和重载只会制造噪音。 if (nextYaml === current.yaml) { - return { ok: true, snapshot: current, reloaded: false, reloadMessage: '内容没有变化' } + const { yaml: _yaml, ...snapshot } = current + return { ok: true, snapshot, reloaded: false, reloadMessage: '内容没有变化' } } const problems = validateDocument(nextData).filter((d) => d.severity === 'error') diff --git a/server/routes.test.ts b/server/routes.test.ts new file mode 100644 index 0000000..baf988d --- /dev/null +++ b/server/routes.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'bun:test' +import { createRouter } from './routes.ts' +import type { Env } from './env.ts' + +/** + * 这些用例只走认证和白名单两段判断,都在请求碰到内核或文件系统之前就返回了, + * 所以不需要内核在线,也不需要配置文件存在。指向一个不可能有人监听的端口, + * 万一哪天有人把判断顺序改错了、请求真的漏到了下游,测试会因为连不上而失败—— + * 这正是我们想要的信号。 + */ +const env: Env = { + mihomoApi: 'http://127.0.0.1:1', + mihomoSecret: 'x', + configPath: '/nonexistent/config.yaml', + mihomoBin: '/nonexistent/mihomo', + mihomoHome: '/nonexistent', + host: '127.0.0.1', + port: 7580, +} + +const TOKEN = 'test-token-0123456789' +const handle = createRouter(env, TOKEN) + +const get = (path: string, token: string | null = TOKEN) => + handle( + new Request(`http://127.0.0.1:7580${path}`, { + headers: token === null ? {} : { Authorization: `Bearer ${token}` }, + }), + ) + +describe('认证挡在所有接口前面', () => { + it('没带令牌一律 401', async () => { + expect((await get('/api/config', null)).status).toBe(401) + expect((await get('/api/kernel', null)).status).toBe(401) + expect((await get('/api/meta/listener-fields', null)).status).toBe(401) + }) + + it('令牌不对一律 401', async () => { + expect((await get('/api/config', 'wrong')).status).toBe(401) + }) + + it('401 的响应带上令牌在哪儿找', async () => { + const body = (await (await get('/api/config', null)).json()) as { hint?: string } + expect(body.hint).toContain('.patchbay-token') + }) +}) + +describe('内核接口的透传白名单', () => { + it('放行只读的运行时数据', async () => { + // 连不上内核会抛,但那说明请求已经通过了白名单——这里只关心没被 403 挡下 + for (const path of ['/proxies', '/connections', '/rules', '/providers/proxies']) { + const res = await get(`/api/kernel${path}`) + expect(res.status).not.toBe(403) + } + }) + + it('放行延迟测试和订阅健康检查', async () => { + const paths = [ + '/proxies/AUTO/delay?url=x&timeout=1', + '/group/PROXY/delay?url=x&timeout=1', + '/providers/proxies/subs/healthcheck?url=x&timeout=1', + '/providers/proxies/subs/%5Bvless%5DGomami/healthcheck?url=x&timeout=1', + ] + for (const path of paths) { + expect((await get(`/api/kernel${path}`)).status).not.toBe(403) + } + }) + + it('挡住不在白名单里的路径', async () => { + // 白名单而不是黑名单:多开一个口子必须是有意的 + for (const path of ['/configs/geo', '/cache/fakeip/flush', '/restart', '/upgrade']) { + const res = await get(`/api/kernel${path}`) + expect(res.status).toBe(403) + } + }) + + it('挡住形似但不是健康检查的路径', async () => { + // 正则写松了会把 /providers/proxies/subs/anything 也放过去 + expect((await get('/api/kernel/providers/proxies/subs/a/b/healthcheck')).status).toBe(403) + expect((await get('/api/kernel/proxies/AUTO/delay/extra')).status).toBe(403) + }) +}) + +describe('其它', () => { + it('不存在的接口回 404 并带上说明', async () => { + const res = await get('/api/nope') + expect(res.status).toBe(404) + expect(((await res.json()) as { message?: string }).message).toBeTruthy() + }) + + it('静态数据不需要内核', async () => { + const res = await get('/api/meta/listener-fields') + expect(res.status).toBe(200) + const body = (await res.json()) as { types: Record } + expect(Object.keys(body.types).length).toBeGreaterThan(0) + }) + + it('前端页面不要令牌——它只是空壳,数据都要带令牌才拿得到', async () => { + expect((await get('/', null)).status).not.toBe(401) + }) +}) diff --git a/shared/inbounds.test.ts b/shared/inbounds.test.ts index 626e549..2223099 100644 --- a/shared/inbounds.test.ts +++ b/shared/inbounds.test.ts @@ -6,7 +6,10 @@ import { collectInbounds, collectOutboundNames, fallbackTarget, + isUnsetOutbound, + outboundLabel, outboundToProxyField, + OUTBOUND_PRESETS, resolveDestination, } from './inbounds.ts' @@ -145,3 +148,34 @@ describe('流量最终去哪', () => { expect(fallbackTarget({})).toBeNull() }) }) + +describe('出站预设表是读写显示三者的唯一约定', () => { + it('每种预设的字段值和标签一一对应', () => { + expect(OUTBOUND_PRESETS.map((p) => [p.mode.kind, p.field, p.label])).toEqual([ + ['rules', undefined, 'RULE'], + ['direct', 'DIRECT', 'DIRECT'], + ['global', 'GLOBAL', 'GLOBAL'], + ]) + }) + + it('从配置读出来再写回去,值不变', () => { + // 这条是防止「显示的」和「写进文件的」跑偏:任何一种模式转一圈都要回到原值 + for (const preset of OUTBOUND_PRESETS) { + expect(outboundToProxyField(preset.mode)).toBe(preset.field) + } + expect(outboundToProxyField({ kind: 'named', name: 'AUTO' })).toBe('AUTO') + }) + + it('界面上的名字用配置里的原词', () => { + expect(outboundLabel({ kind: 'rules' })).toBe('RULE') + expect(outboundLabel({ kind: 'direct' })).toBe('DIRECT') + expect(outboundLabel({ kind: 'global' })).toBe('GLOBAL') + expect(outboundLabel({ kind: 'named', name: '固定-Gomami' })).toBe('固定-Gomami') + }) + + it('只有 RULE 算「没明确指定」,界面据此弱化显示', () => { + expect(isUnsetOutbound({ kind: 'rules' })).toBe(true) + expect(isUnsetOutbound({ kind: 'direct' })).toBe(false) + expect(isUnsetOutbound({ kind: 'named', name: 'AUTO' })).toBe(false) + }) +}) diff --git a/shared/inbounds.ts b/shared/inbounds.ts index 8f3f7c4..9cf08be 100644 --- a/shared/inbounds.ts +++ b/shared/inbounds.ts @@ -55,23 +55,59 @@ export interface Inbound { legacyKey: string | null /** 在 listeners 数组里的下标,用于写回。旧写法为 null */ listenerIndex: number | null - /** 给人看的一句话说明,主要是给默认入站解释它为什么不能改出站 */ - note: string | null } +/** + * 不用指名道姓的那几种出站,以及它们各自对应配置里的什么值。 + * + * 这张表是「读配置 → 界面 → 写回配置」三者的唯一约定来源。之前它被拆成四份散在 + * 各处(读一份、显示一份、写回一份、表单里又抄了一份),加一种出站模式就要同时改 + * 四个地方,漏一个就是「界面显示的」和「实际写进文件的」对不上。 + * + * `field` 为 undefined 表示配置里**不写** proxy 这个字段——「没有这个键」和 + * 「这个键是空字符串」在 YAML 里是两回事。 + */ +export const OUTBOUND_PRESETS: Array<{ + mode: OutboundMode + field: string | undefined + label: string +}> = [ + { mode: { kind: 'rules' }, field: undefined, label: 'RULE' }, + { mode: { kind: 'direct' }, field: 'DIRECT', label: 'DIRECT' }, + { mode: { kind: 'global' }, field: 'GLOBAL', label: 'GLOBAL' }, +] + function outboundOf(proxy: unknown): OutboundMode { if (typeof proxy !== 'string' || proxy === '') return { kind: 'rules' } - if (proxy === 'DIRECT') return { kind: 'direct' } - if (proxy === 'GLOBAL') return { kind: 'global' } - return { kind: 'named', name: proxy } + const preset = OUTBOUND_PRESETS.find((p) => p.field === proxy) + return preset ? preset.mode : { kind: 'named', name: proxy } } /** 把出站去向写回成配置里 proxy 字段该有的值。返回 undefined 表示不写这个字段。 */ export function outboundToProxyField(mode: OutboundMode): string | undefined { - if (mode.kind === 'rules') return undefined - if (mode.kind === 'direct') return 'DIRECT' - if (mode.kind === 'global') return 'GLOBAL' - return mode.name + if (mode.kind === 'named') return mode.name + return OUTBOUND_PRESETS.find((p) => p.mode.kind === mode.kind)?.field +} + +/** 出站在界面上的名字。指名道姓的就是它自己的名字,其余用配置里的原词。 */ +export function outboundLabel(mode: OutboundMode): string { + if (mode.kind === 'named') return mode.name + return OUTBOUND_PRESETS.find((p) => p.mode.kind === mode.kind)?.label ?? mode.kind +} + +/** + * 是不是「没有明确指定」那种状态。 + * + * 界面上用来决定要不要弱化显示:RULE 是不写 proxy 字段时的默认结果,不是用户做出的 + * 选择,跟他明确选了 DIRECT 是两回事,不该长得一样。 + */ +export function isUnsetOutbound(mode: OutboundMode): boolean { + return mode.kind === 'rules' +} + +/** 入站类型在界面上怎么念。以配置里的原词为准,不另造描述。 */ +export function inboundTypeLabel(type: string): string { + return type === '' ? '—' : type } /** @@ -112,7 +148,6 @@ export function collectInbounds(data: ConfigObject): Inbound[] { isDefault: true, legacyKey: key, listenerIndex: null, - note: `由配置文件顶层的 ${key} 声明。这种写法没法单独指定出站,流量一律走分流规则。想让这个端口固定走某个出站,要把它改成命名入站。`, }) } @@ -131,7 +166,6 @@ export function collectInbounds(data: ConfigObject): Inbound[] { isDefault: false, legacyKey: null, listenerIndex: index, - note: null, }) }) diff --git a/shared/outbound-chain.test.ts b/shared/outbound-chain.test.ts new file mode 100644 index 0000000..8913d7c --- /dev/null +++ b/shared/outbound-chain.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'bun:test' +import { lastSwitchableGroup, resolveChain } from './outbound-chain.ts' +import type { RuntimeNode } from './outbound-chain.ts' + +/** 仿照真实那台机器:PROXY 手动选,选中了自动测速的 AUTO,AUTO 挑了一条订阅线路。 */ +const runtime: Record = { + PROXY: { now: 'AUTO', all: ['AUTO', 'DIRECT'] }, + AUTO: { now: '[vless]Gomami', all: ['[vmess]DMIT', '[vless]Gomami'] }, + '[vless]Gomami': {}, + '[vmess]DMIT': {}, + DIRECT: {}, +} + +describe('解析流量最终走到哪条线', () => { + it('一路跟着当前选中走到底', () => { + expect(resolveChain(runtime, 'PROXY')).toEqual(['PROXY', 'AUTO', '[vless]Gomami']) + }) + + it('起点就是线路时链条只有它自己', () => { + expect(resolveChain(runtime, '[vless]Gomami')).toEqual(['[vless]Gomami']) + }) + + it('没有落脚点时是空链条', () => { + expect(resolveChain(runtime, null)).toEqual([]) + }) + + it('落脚在一个内核不认识的名字上时也只返回它自己', () => { + // 配置引用了一个不存在的出站时会走到这里,界面要能显示而不是崩掉 + expect(resolveChain(runtime, '不存在的组')).toEqual(['不存在的组']) + }) + + it('策略组互相引用成环时停下来,不会转死', () => { + const looped: Record = { + A: { now: 'B', all: ['B'] }, + B: { now: 'A', all: ['A'] }, + } + expect(resolveChain(looped, 'A')).toEqual(['A', 'B']) + }) + + it('组选中了自己时也停得下来', () => { + expect(resolveChain({ A: { now: 'A', all: ['A'] } }, 'A')).toEqual(['A']) + }) +}) + +describe('找出用户能换选中的那一层', () => { + it('取链条上最靠近线路的那个策略组', () => { + // 换 AUTO 的选中才是「换线路」;换 PROXY 换的是「走哪套策略」 + const chain = resolveChain(runtime, 'PROXY') + expect(lastSwitchableGroup(runtime, chain)).toEqual({ + group: 'AUTO', + members: ['[vmess]DMIT', '[vless]Gomami'], + now: '[vless]Gomami', + }) + }) + + it('链条上一个策略组都没有时返回空', () => { + expect(lastSwitchableGroup(runtime, ['[vless]Gomami'])).toBeNull() + expect(lastSwitchableGroup(runtime, [])).toBeNull() + }) + + it('组存在但成员是空的,不算可切换', () => { + // 筛选条件没匹配到任何线路时会出现这种组,给出一个点不动的列表只会让人困惑 + expect(lastSwitchableGroup({ 空组: { all: [], now: '' } }, ['空组'])).toBeNull() + }) +}) diff --git a/shared/outbound-chain.ts b/shared/outbound-chain.ts new file mode 100644 index 0000000..ee05af3 --- /dev/null +++ b/shared/outbound-chain.ts @@ -0,0 +1,63 @@ +/** + * 「这个端口现在到底走哪条线」——这个工具要回答的核心问题。 + * + * 答案往往不是一跳能到的:一路入站落在 `PROXY` 组上,`PROXY` 选中了 `AUTO`, + * `AUTO` 又按延迟挑了某条线路。只显示第一层等于没回答。 + * + * 这里只做纯计算,输入是内核报上来的运行时状态,输出是名字数组。放在 shared 里 + * 是为了能被 `bun test` 覆盖——它带防环处理,而防环这种事写在组件的 computed 里 + * 就再也没人测得到。 + */ + +/** 运行时状态里跟解析链路有关的部分。完整定义在 web/api.ts。 */ +export interface RuntimeNode { + /** 策略组当前选中的成员。不是策略组就没有这个字段 */ + now?: string + /** 策略组的成员列表 */ + all?: string[] +} + +/** + * 从落脚的出站一路跟着「当前选中」往下走。 + * + * 返回的数组第一项是起点,最后一项是走到头的那个名字。起点为 null 时返回空数组。 + * 策略组理论上可以互相引用成环,遇到重复的名字就停下——内核自己会拒绝这种配置, + * 但界面不能因为一份坏配置就转到卡死。 + */ +export function resolveChain( + runtime: Record, + start: string | null, +): string[] { + const hops: string[] = [] + let cursor = start + while (cursor && !hops.includes(cursor)) { + hops.push(cursor) + cursor = runtime[cursor]?.now ?? null + } + return hops +} + +export interface SwitchableGroup { + group: string + members: string[] + now: string +} + +/** + * 链条上最后一个还是策略组的环节。 + * + * 那是用户能真正做出选择的地方:往前的环节只是层层转交,往后已经是具体线路了。 + * 取最后一个而不是第一个,是因为换最靠近线路的那一层才是「换线路」;换前面的层 + * 换的是「走哪套策略」,那是另一回事。 + */ +export function lastSwitchableGroup( + runtime: Record, + chain: string[], +): SwitchableGroup | null { + for (let i = chain.length - 1; i >= 0; i -= 1) { + const name = chain[i]! + const node = runtime[name] + if (node?.all?.length) return { group: name, members: node.all, now: node.now ?? '' } + } + return null +} diff --git a/shared/yaml-cosmetics.test.ts b/shared/yaml-cosmetics.test.ts index 58f27db..4efe660 100644 --- a/shared/yaml-cosmetics.test.ts +++ b/shared/yaml-cosmetics.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'bun:test' import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { openDocument, replaceDocumentData } from '../vendor/config-editor/index.ts' +import { openDocument } from '../vendor/config-editor/index.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts' +import { rewriteYaml } from '../server/config-store.ts' import { restoreInlineCommentAlignment } from './yaml-cosmetics.ts' const fixture = readFileSync( @@ -12,12 +13,14 @@ const fixture = readFileSync( const clone = (v: unknown) => JSON.parse(JSON.stringify(v)) as ConfigObject -/** 走一遍真实的写回链路:解析 → 改 → 写回 → 修复格式。 */ +/** + * 走**生产用的那一条**写回路径,而不是在测试里重拼一遍。 + * 自己拼的话,落盘链路里漏掉其中一步这些测试照样会绿——那就白写了。 + */ function roundtrip(source: string, mutate: (draft: ConfigObject) => void): string { - const doc = openDocument(source) - const draft = clone(doc.data) + const draft = clone(openDocument(source).data) mutate(draft) - return restoreInlineCommentAlignment(source, replaceDocumentData(doc, draft).yaml) + return rewriteYaml(source, draft) } describe('写回一份带注释的配置', () => { diff --git a/tsconfig.json b/tsconfig.json index cdd2265..4fda67c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,9 +21,7 @@ "jsx": "preserve", "baseUrl": ".", "paths": { - "~/*": ["./web/*"], - "@shared/*": ["./shared/*"], - "@config-editor": ["./vendor/config-editor/index.ts"] + "@shared/*": ["./shared/*"] } }, "include": [ diff --git a/vite.config.ts b/vite.config.ts index b6648a1..3bb3a83 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,7 +18,6 @@ export default defineConfig({ ], resolve: { alias: { - '~': fileURLToPath(new URL('./web', import.meta.url)), '@shared': fileURLToPath(new URL('./shared', import.meta.url)), }, }, diff --git a/web/App.vue b/web/App.vue index 19addb7..130056b 100644 --- a/web/App.vue +++ b/web/App.vue @@ -1,46 +1,39 @@ + + + + diff --git a/web/delay-test.test.ts b/web/delay-test.test.ts new file mode 100644 index 0000000..b6695ba --- /dev/null +++ b/web/delay-test.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'bun:test' +import { ApiError } from './api' +import { describeTestFailure } from './delay-test' + +const LINE = '[vless]Gomami.SIN Secret' + +describe('延迟测试失败时告诉用户什么', () => { + it('超时说明是几秒内没完成', () => { + // 只说「超时」,用户不知道是等了 1 秒还是 60 秒,也就不知道该不该重试 + expect(describeTestFailure(LINE, new ApiError(504, {}), 5000)).toBe( + `${LINE} 延迟测试超时,5 秒内没有取到测试地址`, + ) + }) + + it('找不到这个名字时指出该从订阅进去测', () => { + // 订阅线路不在内核的全局出站表里,这是本项目最容易踩的一条约束 + expect(describeTestFailure(LINE, new ApiError(404, { message: 'Resource not found' }))).toContain( + '不在内核的出站表里', + ) + }) + + it('连接失败且内核没给具体原因时,给出能指导排查的话', () => { + const generic = new ApiError(503, { message: 'An error occurred in the delay test' }) + expect(describeTestFailure(LINE, generic)).toBe( + `${LINE} 无法建立连接,线路可能已失效,或本机到该服务器的网络不通`, + ) + }) + + it('内核给了具体原因就原样带上', () => { + const specific = new ApiError(503, { message: 'dial tcp: i/o timeout' }) + expect(describeTestFailure(LINE, specific)).toBe(`${LINE} 无法建立连接:dial tcp: i/o timeout`) + }) + + it('没见过的状态码带上状态码和内核原话', () => { + expect(describeTestFailure(LINE, new ApiError(500, { message: 'boom' }))).toBe( + `${LINE} 延迟测试失败(HTTP 500):boom`, + ) + }) + + it('状态码没见过且内核没说话时不留一个空冒号', () => { + expect(describeTestFailure(LINE, new ApiError(500, {}))).toBe( + `${LINE} 延迟测试失败(HTTP 500)`, + ) + }) + + it('不是接口错误时也要说出是什么', () => { + // 网络断了、代码抛了异常,都会走到这里,不能显示成 [object Object] + expect(describeTestFailure(LINE, new TypeError('Failed to fetch'))).toBe( + `${LINE} 延迟测试失败:TypeError: Failed to fetch`, + ) + }) +}) diff --git a/web/delay-test.ts b/web/delay-test.ts new file mode 100644 index 0000000..d154423 --- /dev/null +++ b/web/delay-test.ts @@ -0,0 +1,74 @@ +/** + * 线路延迟测试。 + * + * 单独成一个模块,是因为「测失败了该跟用户说什么」有六个分支,而它原本写在组件里, + * 一条测试都加不了。这些话是用户唯一能看到的诊断信息,值得测。 + */ +import { ref } from 'vue' +import { ApiError, api } from './api' + +/** 超时,毫秒。文案里会用到它,所以不能让接口和文案各写一个数。 */ +export const TEST_TIMEOUT_MS = 5000 + +/** + * 把内核的延迟测试结果翻译成一句说明原因的话。 + * + * 内核对这件事的回答是精确的(内核源码 hub/route/proxies.go:122-145): + * 504 在给定超时内没有完成 + * 503 连接建立失败,或者测得的延迟为 0 + * 404 这个名字不在内核的全局出站表里 + * 笼统说一句「测不通」会把它们混在一起,而这三种要查的地方完全不一样。 + */ +export function describeTestFailure( + name: string, + error: unknown, + timeoutMs = TEST_TIMEOUT_MS, +): string { + if (!(error instanceof ApiError)) return `${name} 延迟测试失败:${String(error)}` + + if (error.status === 504) { + return `${name} 延迟测试超时,${String(timeoutMs / 1000)} 秒内没有取到测试地址` + } + + if (error.status === 404) { + return `${name} 不在内核的出站表里,无法测试。订阅线路需要从所属订阅进去测。` + } + + const detail = String(error.payload.message ?? '') + // 内核对连接失败给的是一句没有信息量的通用英文,替换成能指导排查的话 + const generic = detail === '' || detail === 'An error occurred in the delay test' + + if (error.status === 503) { + return generic + ? `${name} 无法建立连接,线路可能已失效,或本机到该服务器的网络不通` + : `${name} 无法建立连接:${detail}` + } + + return `${name} 延迟测试失败(HTTP ${String(error.status)})${generic ? '' : `:${detail}`}` +} + +/** + * 管理「哪几条线路正在测」。 + * + * 测延迟是纯读操作,不影响配置也不影响别的线路,所以它只标记自己那一条忙碌, + * 不禁用界面上的任何其它东西。 + */ +export function useDelayTest(onFinished: () => void, onFailure: (message: string) => void) { + const testing = ref([]) + + async function test(name: string, provider?: string) { + if (testing.value.includes(name)) return + testing.value = [...testing.value, name] + try { + await api.testLine(name, provider, TEST_TIMEOUT_MS) + onFinished() + } catch (error) { + // 测不通是关于线路的结论,不是 Patchbay 出了故障 + onFailure(describeTestFailure(name, error)) + } finally { + testing.value = testing.value.filter((n) => n !== name) + } + } + + return { testing, test } +} diff --git a/web/nav.ts b/web/nav.ts index 28631b3..aa92081 100644 --- a/web/nav.ts +++ b/web/nav.ts @@ -15,15 +15,13 @@ export interface NavItem { key: string label: string icon: Component - /** 还没做的页面标 true,导航上显示但点进去是占位 */ - pending?: boolean } export const NAV: NavItem[] = [ - { key: 'dashboard', label: '仪表盘', icon: IconDashboard, pending: true }, + { key: 'dashboard', label: '仪表盘', icon: IconDashboard }, { key: 'router', label: '路由', icon: IconRoute }, - { key: 'config', label: '配置', icon: IconConfig, pending: true }, - { key: 'requests', label: '请求', icon: IconRequests, pending: true }, - { key: 'connections', label: '连接', icon: IconConnections, pending: true }, - { key: 'settings', label: '设置', icon: IconSettings, pending: true }, + { key: 'config', label: '配置', icon: IconConfig }, + { key: 'requests', label: '请求', icon: IconRequests }, + { key: 'connections', label: '连接', icon: IconConnections }, + { key: 'settings', label: '设置', icon: IconSettings }, ] diff --git a/web/runtime.ts b/web/runtime.ts new file mode 100644 index 0000000..4c9c924 --- /dev/null +++ b/web/runtime.ts @@ -0,0 +1,82 @@ +/** + * 内核的运行时状态:有哪些出站、每个是什么协议、策略组当前选中谁、最近一次延迟。 + * + * 这些数据配置文件里没有,只有运行中的内核知道;而且界面上有三层组件要用它。 + * 原本它住在根组件里靠 props 一层层往下传,中间有两层完全不碰它、纯粹为了转发而 + * 声明了参数。做成模块级单例之后那两层就不用再知道它的存在了。 + * + * 用的是 web/prefs.ts 已有的模式(模块级 ref),不是新抽象。 + */ +import { ref } from 'vue' +import { api } from './api' +import type { ProxyInfo } from './api' + +/** 出站名字 → 运行时信息。内核没连接时是空的。 */ +export const runtime = ref>({}) + +/** 订阅带来的线路,按订阅返回的顺序。 */ +export const providerLines = ref([]) + +/** 订阅线路 → 它所属的订阅名。测它的延迟必须从所属订阅进去。 */ +export const lineProvider = ref>({}) + +export const kernel = ref<{ online: boolean; version?: string }>({ online: false }) + +/** + * 重新读一遍运行时状态。 + * + * 要读两处:`/proxies` 只包含内核全局名字表里的东西(内置出站、自有线路、策略组), + * 订阅线路不在那张表上,它们的协议和延迟只能从 `/providers/proxies` 拿。合成一张表 + * 之后,界面就不用关心某个名字是从哪来的了。 + * + * 任何一处取不到都不算错误——界面照常显示,只是少了延迟数字。内核没起来的时候正是 + * 最需要这个界面的时候。 + */ +export async function reloadRuntime(): Promise { + kernel.value = await api.kernel().catch(() => ({ online: false })) + if (!kernel.value.online) { + runtime.value = {} + providerLines.value = [] + lineProvider.value = {} + return + } + + const merged: Record = {} + try { + Object.assign(merged, (await api.proxies()).proxies) + } catch { + // 拿不到就当没有 + } + + const lines: string[] = [] + const owner: Record = {} + try { + const { providers } = await api.providers() + for (const [name, provider] of Object.entries(providers)) { + // 内核会给每个用了 proxies 的策略组造一个内部 provider,那些不是订阅 + if (provider.vehicleType === 'Compatible') continue + for (const line of provider.proxies) { + merged[line.name] = line + lines.push(line.name) + owner[line.name] = name + } + } + } catch { + // 同上 + } + + runtime.value = merged + providerLines.value = lines + lineProvider.value = owner +} + +/** + * 切换策略组选中谁时先在本地改掉,不等内核回话。 + * + * 这一步不写配置文件、失败率极低,而等一个往返再更新会让点击感觉发木。 + * 真失败了随后的刷新会把状态纠正回来。 + */ +export function optimisticSelect(group: string, member: string): void { + const node = runtime.value[group] + if (node) node.now = member +} diff --git a/web/views/InboundDetail.vue b/web/views/InboundDetail.vue index ea035c9..0b82730 100644 --- a/web/views/InboundDetail.vue +++ b/web/views/InboundDetail.vue @@ -8,8 +8,9 @@ */ import { computed } from 'vue' import type { Inbound, OutboundMode } from '@shared/inbounds.ts' +import { lastSwitchableGroup, resolveChain } from '@shared/outbound-chain.ts' import { latestDelay } from '../api' -import type { ProxyInfo } from '../api' +import { kernel, providerLines, runtime } from '../runtime' import OutboundCard from '../components/OutboundCard.vue' import IconOffline from '~icons/ri/cloud-off-line' import IconEdit from '~icons/ri/edit-line' @@ -19,11 +20,8 @@ const props = defineProps<{ inbound: Inbound groups: string[] ownProxies: string[] - providerLines: string[] - runtime: Record /** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */ destination: string | null - kernelOnline: boolean /** 正在提交的那个出站的名字。只有它显示忙碌,其余照常能点 */ pendingOutbound: string | null /** 正在测延迟的线路 */ @@ -42,39 +40,16 @@ const emit = defineEmits<{ const locked = computed(() => props.inbound.isDefault) function describe(name: string): string { - const info = props.runtime[name] + const info = runtime.value[name] if (!info) return '' return info.now ? `${info.type}(${info.now})` : info.type } -const delayOf = (name: string) => latestDelay(props.runtime[name]) +const delayOf = (name: string) => latestDelay(runtime.value[name]) const isTesting = (name: string) => props.testing.includes(name) -/** - * 从落脚的出站一路跟着「当前选中」往下走,直到走到一条真正的线路。 - * - * 策略组可以选中另一个策略组,所以「这个端口现在走哪条线」往往要跳好几层。 - * 只显示第一层等于没回答问题。 - */ -const chain = computed(() => { - const hops: string[] = [] - let cursor = props.destination - while (cursor && !hops.includes(cursor)) { - hops.push(cursor) - cursor = props.runtime[cursor]?.now ?? null - } - return hops -}) - -/** 链条上最后一个还是策略组的环节——用户能在这里换选中。 */ -const switchable = computed(() => { - for (let i = chain.value.length - 1; i >= 0; i -= 1) { - const name = chain.value[i]! - const info = props.runtime[name] - if (info?.all?.length) return { group: name, members: info.all, now: info.now ?? '' } - } - return null -}) +const chain = computed(() => resolveChain(runtime.value, props.destination)) +const switchable = computed(() => lastSwitchableGroup(runtime.value, chain.value)) function isCurrentOutbound(mode: OutboundMode): boolean { const now = props.inbound.outbound @@ -205,7 +180,7 @@ const NO_PROXY_FIELD = '该入站没有 proxy 字段'

订阅线路

-

+

内核未连接,无法获取订阅线路

diff --git a/web/views/RouterPage.vue b/web/views/RouterPage.vue index 0732a4c..c64a101 100644 --- a/web/views/RouterPage.vue +++ b/web/views/RouterPage.vue @@ -1,37 +1,35 @@