9 Commits
Author SHA1 Message Date
im0o 3d7304dd22 入站详情可以测常用 AI 服务的可访问性
release / release (push) Successful in 29s
判定按状态码白名单而不是只认 200:探测不带鉴权,OpenAI 回 401、Anthropic 回 405
才是正常应答,说明 TLS 握手、路由、目标服务全都正常。清单和白名单照 sub2api 的
代理质量检查抄来。

失败文案不照抄 Bun:本机端口没人监听时它给的是「Unable to connect. Is the
computer able to access the url?」,把矛头指向目标网址,而真正的原因在本机。

对话框留在 InboundDetail 而不是提到 RouterPage——它不写配置文件也不改内核状态,
不需要走那条 commit 链路。firstPort 挪进 shared/inbounds.ts,两个页面都要用它了。
2026-08-31 03:59:08 +08:00
im0o 7d2a3afff0 接入品牌素材
release / release (push) Successful in 27s
标签页图标只声明 favicon.ico。它带 16/32/48/64 四个尺寸、是照着小尺寸调过
的;而这套素材里 .ico 是深底浅图、PNG 那一组是白底黑图,两个都声明的话浏览器
会按显示密度在两者间挑,同一个标签页会换样子。

素材里的 favicon.svg 没有采用:里面是一张 512px 的 PNG 用 base64 塞在
<image> 里,不是矢量,比 favicon.png 还大 3.4KB。
2026-08-14 14:06:24 +08:00
im0o ea0efd4468 令牌输错时给出提示
登录页在提交前后都是同一个组件,不重建、输入框也不清空,401 静默处理的话
点了「进入」界面一个字都不变,看着像没反应。

只在提交令牌时提示:存着的令牌失效而弹回登录页时,页面本身就是说明。
2026-08-14 14:06:24 +08:00
im0o 0f0ac3c4b7 订阅列表可以调顺序
每行末尾两个箭头,鼠标划过那一行才显现。proxy-providers 是映射,顺序只
影响界面呈现,内核不看;交换的是两个用户订阅在映射里的位置,中间夹着的
链式副本留在原地——它们不在列表上,跟着挪只会把 diff 撑大。

顺序写在配置文件里,所以走保存链路,不是运行时动作。
2026-08-14 14:06:11 +08:00
im0o 23aa28fbea 订阅详情可以立刻检查线路
走内核的 provider healthcheck:逐条 URLTest,测通与否和延迟一起记进历史
(adapter/provider/healthcheck.go:123-189)。用的是订阅自己的
health-check.url——这个接口不收 url 参数,设置页那个测速地址传不进去。

它是同步的,测完全部线路才回话,一批 10 条、每条最多 5 秒
(healthcheck.go:132、181、201),线路多又都测不通时远超透传默认的
15 秒,所以 proxyGet 的超时改成可传,健康检查这条路径放到 120 秒。
2026-08-14 14:05:24 +08:00
im0o 56546b20c5 新增入站不再预填订阅
来源一个都不选时 AUTO 和 FALLBACK 就是空组,内核会拒绝整份配置
(adapter/outboundgroup/parser.go:117-119),所以 applySetup 这时只建
SELECT、成员只有 DIRECT;用户在配置里选好订阅再保存,那两个组建回来。
链式代理打开时同样不预填入口订阅。

「改成逐条选线路」保持全给上:那一路本来就在把流量送到某个出站,
接管不该顺手把它改成直连。
2026-08-14 14:04:47 +08:00
im0o b1b251b897 legacy 入站可升级为命名入站
release / release (push) Successful in 26s
顶层端口字段没有 proxy 可写(listener/inbound/base.go:97),界面上
切不了订阅。升级删掉顶层键、建同端口的 listener:listen 按 allow-lan
推导后写死,全局规则里 MATCH 之前的部分复制成附加规则,来源照抄
MATCH 目标组的 use:,让流量去向尽量不变。udp 不写,命名入站解析时
默认就是开的(listener/parse.go:52)。
「默认」tag 随术语表改成 legacy,旧叫法进 avoid 列表。
2026-08-12 17:30:47 +08:00
im0o d6942c5702 订阅健康检查默认开启,编辑支持改名
内核没有默认的健康检查地址,url 为空时检查根本不会运行
(adapter/provider/healthcheck.go:196-199),所以默认值必须写全;
开关只翻 enable,手写的 url/interval 留着下次开启时还用。
改名和策略组 use: 的引用同步收在同一次变换里,链式副本由保存
链路的 syncGenerated 按新名字重建,缓存路径刻意不动——file 类型
的数据本体和副本认亲都靠它。
2026-08-12 17:30:07 +08:00
im0o b28fc572f7 主机内存总量改从 /proc/meminfo 读
os.totalmem() 底层是 sysinfo() 系统调用,LXC 里 lxcfs 只虚拟化 /proc
文件、拦不住系统调用,导致容器里显示的是宿主机的物理内存总量。
从 /proc/meminfo 读 MemTotal 才是容器限额,且与 MemAvailable 同源。
2026-08-12 16:38:19 +08:00
37 changed files with 1587 additions and 149 deletions
+6 -6
View File
@@ -10,9 +10,9 @@
mihomo 上的一个监听端口,客户端连上它把流量交进来。
_Avoid_: inbound、监听器、listener、进站
**默认入站**:
由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除。
_Avoid_: 主端口、老式端口、默认端口
**legacy 入站**:
由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除,可升级为命名入站。
_Avoid_: 默认入站、主端口、老式端口、默认端口
**命名入站**:
由 `listeners:` 声明的入站。数量不限,每个可以有自己的出站和附加规则。
@@ -102,6 +102,6 @@ _Avoid_: sub-rules、子规则、私有规则
Patchbay 负责读写的配置段落。其余段落原样保留、界面上也不呈现。
_Avoid_: 托管段落、受控段落
**默认路由**:
默认入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。
_Avoid_: 主路由、根路由
**legacy 路由**:
legacy 入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。
_Avoid_: 默认路由、主路由、根路由
+38 -3
View File
@@ -1,20 +1,24 @@
/**
* 主机信息与公网 IP 检测。
* 主机信息,以及经由某一路入站发出的探测。
*
* Patchbay 服务端和 mihomo 跑在同一台机器上,所以这两件事由它代答:
* Patchbay 服务端和 mihomo 跑在同一台机器上,所以这几件事由它代答:
* - 主机指标:mihomo 的管理接口只报自己的内存,CPU、主机内存、网关这些
* 从 os 模块和 /proc 里读(解析在 shared/host-info.ts,那边有测试)
* - 公网 IP 检测:浏览器没法「经由某个入站端口」发请求,服务端可以——把本机
* 对应端口设成代理去访问 ip.sb,返回的就是那一路在外面的地址
* - 可访问性测试:同一条路子,换成访问几个常用 AI 服务,判定在 shared/reachability.ts
*/
import os from 'node:os'
import {
cpuUsageBetween,
parseGateway,
parseMemAvailable,
parseMemTotal,
parseNameservers,
} from '../shared/host-info.ts'
import type { CpuTimes, ExitProbe, HostInfo } from '../shared/host-info.ts'
import { describeProbeFailure, judge } from '../shared/reachability.ts'
import type { ReachResult, ReachTarget } from '../shared/reachability.ts'
function cpuTimesNow(): CpuTimes {
let busy = 0
@@ -56,7 +60,7 @@ export async function hostInfo(): Promise<HostInfo> {
return {
cpuUsage,
cpuCount: os.cpus().length,
memTotal: os.totalmem(),
memTotal: parseMemTotal(meminfo) ?? os.totalmem(),
memAvailable: parseMemAvailable(meminfo) ?? os.freemem(),
// loadavg 在 Windows 上恒为 0,那不是「负载为零」而是「没有这个概念」
load1: process.platform === 'win32' ? null : load,
@@ -97,3 +101,34 @@ export async function probeExit(port: number): Promise<ExitProbe> {
return { ok: false, message: error instanceof Error ? error.message : String(error) }
}
}
/** 单个目标的探测超时,毫秒。超时的说法由服务端生成,所以只有这里需要这个数。 */
const REACH_TIMEOUT_MS = 10_000
/**
* 经由本机某个入站端口探测一个目标能不能访问。同 probeExit,只对说 HTTP 的入站有效。
*
* 不带任何鉴权头,判定全交给 shared/reachability.ts 的 judge()。正文只读前 8 KB:
* 判定只需要认拦截页开头的标记,而目标本身可能返回很大的 JSON。
*/
export async function probeReach(port: number, target: ReachTarget): Promise<ReachResult> {
const started = Date.now()
try {
const res = await fetch(target.url, {
proxy: `http://127.0.0.1:${String(port)}`,
headers: { Accept: 'application/json,text/html,*/*' },
signal: AbortSignal.timeout(REACH_TIMEOUT_MS),
})
const body = (await res.text()).slice(0, 8192)
const { verdict, message } = judge(target, res.status, res.headers, body)
return { target: target.id, verdict, status: res.status, ms: Date.now() - started, message }
} catch (error) {
return {
target: target.id,
verdict: 'unreachable',
status: null,
ms: Date.now() - started,
message: describeProbeFailure(port, REACH_TIMEOUT_MS, error),
}
}
}
+7 -2
View File
@@ -184,8 +184,13 @@ export async function updateGeoDatabases(env: Env): Promise<ValidateResult> {
* 持续吐 JSON 的分块流(内核源码 hub/route/server.go:380-383),给它们设超时等于
* 每隔一会儿就把连接掐断一次。
*/
export async function proxyGet(env: Env, path: string, stream = false): Promise<Response> {
return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(15_000) })
export async function proxyGet(
env: Env,
path: string,
stream = false,
timeoutMs = 15_000,
): Promise<Response> {
return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(timeoutMs) })
}
/** 透传一个写操作。目前只有断开连接和刷新订阅两处。 */
+25 -2
View File
@@ -24,7 +24,8 @@ import {
undoConfig,
} from './config-store.ts'
import { geoStatus, syncGeoFile } from './geo.ts'
import { hostInfo, probeExit } from './host.ts'
import { hostInfo, probeExit, probeReach } from './host.ts'
import { findTarget } from '../shared/reachability.ts'
import {
kernelVersion,
proxyGet,
@@ -283,6 +284,20 @@ function makeRoute(env: Env, token: string) {
return json(await probeExit(port))
}
// 经由某个入站端口探测一个目标能不能访问。一次一个目标,前端并发发起,
// 这样每一行各自落定,不必等最慢的那个才整表出现
if (path === '/api/host/reach' && req.method === 'GET') {
const port = Number(url.searchParams.get('port'))
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return json({ message: 'port 参数必须是 1-65535 的端口号' }, 400)
}
const target = findTarget(url.searchParams.get('target') ?? '')
if (!target) {
return json({ message: `没有名为 ${url.searchParams.get('target') ?? ''} 的探测目标` }, 400)
}
return json(await probeReach(port, target))
}
// ── 内核 ────────────────────────────────────────────
if (path === '/api/kernel' && req.method === 'GET') {
return json(await kernelVersion(env))
@@ -366,7 +381,15 @@ function makeRoute(env: Env, token: string) {
if (!allowed) {
return json({ message: `不允许访问该路径:${target}` }, 403)
}
const upstream = await proxyGet(env, target + url.search, STREAM_PATHS.has(target))
// 健康检查是同步的:内核测完才回话,一批 10 条、每条最多 5 秒
// (内核源码 adapter/provider/healthcheck.go:132、181、201),
// 整条订阅测且线路多又都测不通时会远超默认的 15 秒
const upstream = await proxyGet(
env,
target + url.search,
STREAM_PATHS.has(target),
HEALTHCHECK_PATH.test(target) ? 120_000 : undefined,
)
return new Response(upstream.body, {
status: upstream.status,
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
+1 -1
View File
@@ -159,7 +159,7 @@ describe('按入站聚合', () => {
])
})
it('默认入站用内核给的内部名,附上端口', () => {
it('legacy 入站用内核给的内部名,附上端口', () => {
const list = [conn({ metadata: meta({ inboundName: 'DEFAULT-MIXED', inboundPort: '17890' }) })]
expect(groupByInbound(list)[0]?.label).toBe('DEFAULT-MIXED(17890)')
})
+2 -2
View File
@@ -10,7 +10,7 @@ export interface ConnectionMeta {
host: string
destinationIP: string
destinationPort: string
/** 从哪一路入站进来:命名入站是它的名字,默认入站是内核造的 DEFAULT-* */
/** 从哪一路入站进来:命名入站是它的名字,legacy 入站是内核造的 DEFAULT-* */
inboundName: string
inboundPort: string
}
@@ -100,7 +100,7 @@ export function filterConnections(list: ConnectionItem[], query: string): Connec
/** 一路入站当前有多少条连接。 */
export interface InboundUse {
/** 命名入站显示名字;默认入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */
/** 命名入站显示名字;legacy 入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */
label: string
count: number
upload: number
+12
View File
@@ -3,9 +3,21 @@ import {
cpuUsageBetween,
parseGateway,
parseMemAvailable,
parseMemTotal,
parseNameservers,
} from './host-info.ts'
describe('parseMemTotal', () => {
test('取 MemTotal 并换算成字节', () => {
const meminfo = 'MemTotal: 2048000 kB\nMemFree: 100000 kB\nMemAvailable: 512000 kB\n'
expect(parseMemTotal(meminfo)).toBe(2048000 * 1024)
})
test('读不到 /proc/meminfo(非 Linux 平台)返回 null', () => {
expect(parseMemTotal('')).toBeNull()
})
})
describe('parseMemAvailable', () => {
test('取 MemAvailable 并换算成字节', () => {
const meminfo = 'MemTotal: 2048000 kB\nMemFree: 100000 kB\nMemAvailable: 512000 kB\n'
+19 -3
View File
@@ -11,6 +11,7 @@ export interface HostInfo {
/** 0-100。第一次请求没有参照快照,是 null */
cpuUsage: number | null
cpuCount: number
/** 内存总量。Linux 用 MemTotal,其余平台退回 totalmem */
memTotal: number
/** 还能用的内存。Linux 用 MemAvailable,其余平台退回 freemem */
memAvailable: number
@@ -37,13 +38,28 @@ export interface ExitProbe {
message?: string
}
/** /proc/meminfo 里的 MemAvailable(内核估算的「还能用多少」),换算成字节。 */
export function parseMemAvailable(meminfo: string): number | null {
const match = /^MemAvailable:\s+(\d+)\s*kB/m.exec(meminfo)
function meminfoBytes(meminfo: string, field: string): number | null {
const match = new RegExp(`^${field}:\\s+(\\d+)\\s*kB`, 'm').exec(meminfo)
if (!match) return null
return Number(match[1]) * 1024
}
/**
* /proc/meminfo 里的 MemTotal,换算成字节。
*
* 不能用 os.totalmem():它底层是 sysinfo() 系统调用,LXC 里的 lxcfs 只虚拟化
* /proc 下的文件、拦不住系统调用,所以 sysinfo() 返回的是宿主机的物理内存总量。
* 从 /proc/meminfo 读才是容器视角,且与 MemAvailable 出自同一份数据。
*/
export function parseMemTotal(meminfo: string): number | null {
return meminfoBytes(meminfo, 'MemTotal')
}
/** /proc/meminfo 里的 MemAvailable(内核估算的「还能用多少」),换算成字节。 */
export function parseMemAvailable(meminfo: string): number | null {
return meminfoBytes(meminfo, 'MemAvailable')
}
/**
* /proc/net/route 里的默认网关。
*
+93
View File
@@ -15,6 +15,7 @@ import {
proxyGroupName,
pruneGenerated,
readSetup,
upgradeLegacyInbound,
} from './inbound-groups.ts'
import type { OutboundSetup } from './inbound-groups.ts'
import { collectInbounds } from './inbounds.ts'
@@ -114,6 +115,28 @@ describe('一路入站的三个组', () => {
expect((groups[2] as ConfigObject).type).toBe('fallback')
})
it('一个来源都没选时只有 SELECT,成员只有 DIRECT', () => {
// AUTO 和 FALLBACK 只有 use: 没有点名成员,来源为空就是空组,内核会拒绝
// 整份配置(parser.go:117-119)。新增入站落在这个状态上,等用户自己挑订阅
const empty: OutboundSetup = { proxy: { sources: [], filter: null }, chain: null }
const groups = applySetup(base(), 'test1', empty)['proxy-groups'] as ConfigObject[]
expect(groups).toEqual([{ name: 'patchbay:test1', type: 'select', proxies: ['DIRECT'] }])
})
it('从空来源选上订阅后,AUTO 和 FALLBACK 建回来', () => {
const empty: OutboundSetup = { proxy: { sources: [], filter: null }, chain: null }
const data = applySetup(applySetup(base(), 'test1', empty), 'test1', plain)
const groups = data['proxy-groups'] as ConfigObject[]
expect(groups.map((g) => g.name)).toEqual([
'patchbay:test1',
'patchbay:test1 AUTO',
'patchbay:test1 FALLBACK',
])
// 反过来把订阅全取消,那两个组要跟着消失,否则留下的是内核不收的空组
const back = applySetup(data, 'test1', empty)['proxy-groups'] as ConfigObject[]
expect(back.map((g) => g.name)).toEqual(['patchbay:test1'])
})
it('tab 的顺序是 SELECT、AUTO、FALLBACK,只有 SELECT 能点', () => {
// 另外两个自己决定用哪条,手动指定会被它们自己撤销,见 outbound-chain.ts
const data = applySetup(base(), 'test1', plain)
@@ -427,3 +450,73 @@ describe('附加规则', () => {
expect((pruneGenerated(data)['sub-rules'] as ConfigObject).mine).toBeDefined()
})
})
describe('legacy 入站升级', () => {
/** 形状仿照目标机器:一个 legacy 混合端口,全局规则是内网直连加 MATCH 兜底。 */
function legacyBase(): ConfigObject {
return {
'mixed-port': 7890,
'allow-lan': true,
'proxy-providers': {
subs: { type: 'http', url: URL, path: './providers/subs.yaml' },
other: { type: 'http', url: `${URL}2`, path: './providers/other.yaml' },
},
'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: ['DIRECT'], use: ['subs'] }],
rules: [
'IP-CIDR,192.168.0.0/16,DIRECT,no-resolve',
'MATCH,PROXY',
'DOMAIN,dead.example,DIRECT',
],
}
}
const legacyInbound = (data: ConfigObject) => collectInbounds(data).find((i) => i.isDefault)!
it('删掉顶层键,建同端口的命名入站,listen 按 allow-lan 推导写死', () => {
const data = legacyBase()
const next = upgradeLegacyInbound(data, legacyInbound(data))!
expect(next['mixed-port']).toBeUndefined()
expect((next.listeners as ConfigObject[]).at(-1)).toMatchObject({
name: 'mixed-port',
type: 'mixed',
port: 7890,
listen: '0.0.0.0',
})
})
it('allow-lan 关着时 listen 写 127.0.0.1', () => {
const data = { ...legacyBase(), 'allow-lan': false }
const next = upgradeLegacyInbound(data, legacyInbound(data))!
expect(((next.listeners as ConfigObject[]).at(-1) as ConfigObject).listen).toBe('127.0.0.1')
})
it('全局规则里第一条 MATCH 之前的部分复制成附加规则,全局规则原文不动', () => {
const data = legacyBase()
const next = upgradeLegacyInbound(data, legacyInbound(data))!
expect(readSubRules(next, 'mixed-port')).toEqual(['IP-CIDR,192.168.0.0/16,DIRECT,no-resolve'])
expect(next.rules).toEqual(legacyBase().rules)
})
it('升级完就在专属组模式下,来源照抄 MATCH 目标组的 use', () => {
const data = legacyBase()
const next = upgradeLegacyInbound(data, legacyInbound(data))!
expect(readSetup(next, 'mixed-port')).toEqual({
proxy: { sources: ['subs'], filter: null },
chain: null,
})
expect(managedWiring(next, collectInbounds(next).find((i) => i.name === 'mixed-port')!)).toBe(
true,
)
})
it('MATCH 目标组没有 use 时退回全部订阅', () => {
const data = legacyBase()
;(data['proxy-groups'] as ConfigObject[])[0] = { name: 'PROXY', type: 'select', proxies: ['DIRECT'] }
const next = upgradeLegacyInbound(data, legacyInbound(data))!
expect(readSetup(next, 'mixed-port')?.proxy.sources).toEqual(['subs', 'other'])
})
it('已有同名入站时返回 null,不写出重名的 listener', () => {
const data = { ...legacyBase(), listeners: [{ name: 'mixed-port', type: 'mixed', port: 1 }] }
expect(upgradeLegacyInbound(data, legacyInbound(data))).toBeNull()
})
})
+62 -1
View File
@@ -18,7 +18,7 @@
* 副本的来源 path 和哪个用户订阅相同(读同一个缓存文件)
*/
import type { ConfigObject } from '../vendor/config-editor/index.ts'
import { resolveDestination } from './inbounds.ts'
import { fallbackTarget, legacyListenAddress, resolveDestination } from './inbounds.ts'
import type { Inbound } from './inbounds.ts'
import { lastSwitchableGroup, resolveChain } from './outbound-chain.ts'
import type { RuntimeNode } from './outbound-chain.ts'
@@ -390,6 +390,16 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
const { filter } = setup.proxy
let groups = groupsOf(next)
if (use.length === 0) {
// 一个来源都没选。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,来源为空就是空组,
// 内核会拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119),所以这时
// 只建 SELECT、成员只有 DIRECT。新增入站落在这个状态上,等用户自己挑订阅。
// filter 一并不写:没有来源可筛。
groups = upsert(groups, makeGroup(proxyGroupName(inbound), 'select', [], null, ['DIRECT']))
groups = groups.filter(
(g) => g.name !== autoGroupName(inbound) && g.name !== fallbackGroupName(inbound),
)
} else {
// AUTO 排第一位:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅刷新删掉时
// 也静默回退到第一个成员(内核源码 adapter/outboundgroup/selector.go:103-112)。这两种
// 情况都该落到自动测速上;排 DIRECT 就是静默直连,对按端口定向出站的场景是泄露。
@@ -403,6 +413,7 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
)
groups = upsert(groups, makeGroup(autoGroupName(inbound), 'url-test', use, filter))
groups = upsert(groups, makeGroup(fallbackGroupName(inbound), 'fallback', use, filter))
}
if (chained) {
groups = upsert(
@@ -433,6 +444,56 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
return next
}
/**
* 把一路 legacy 入站升级成命名入站,并直接进专属组模式。
*
* legacy 入站(顶层端口字段)没有 `proxy` 可写,只能顺全局规则走,在界面上
* 切不了订阅。升级 = 删掉顶层端口键,在 `listeners:` 里建一个行为等价的入站:
* 端口 原样
* listen 按 allow-lan 推导后写死。升级后改 allow-lan 不再影响它,这是升级的
* 语义变化之一,界面文案要说
* udp 不写。命名入站解析时默认就是开的(内核源码 listener/parse.go:52)
*
* 行为等价还差分流:它原来查全局规则,升级后整表改查自己的附加规则,所以把
* 全局规则里第一条 MATCH 之前的部分复制过来(MATCH 之后的行本来就永远轮不到)。
* 全局规则原文不动——那是用户手写的段落,何况可能还有别的 legacy 入站在用。
* 专属组的来源照抄原 MATCH 目标组的 `use:`,流量的实际去向尽量不变;
* 目标组没有 `use:` 时退回全部订阅。
*
* 同名入站已存在时返回 null,调用方据此报错而不是写出重名的 listener。
*/
export function upgradeLegacyInbound(data: ConfigObject, inbound: Inbound): ConfigObject | null {
if (inbound.legacyKey === null) return null
const port = Number(inbound.port)
if (!Number.isFinite(port) || port <= 0) return null
const listeners = Array.isArray(data.listeners) ? data.listeners : []
if (listeners.some((entry) => isPlainObject(entry) && entry.name === inbound.name)) return null
const next = { ...data }
delete next[inbound.legacyKey]
next.listeners = [
...listeners,
{ name: inbound.name, type: inbound.type, port, listen: legacyListenAddress(data) },
]
const rules: string[] = []
for (const rule of Array.isArray(data.rules) ? data.rules : []) {
if (typeof rule !== 'string') continue
if (rule.split(',')[0]?.trim().toUpperCase() === 'MATCH') break
rules.push(rule)
}
const target = fallbackTarget(data)
const providers = new Set(userProviders(data))
const uses = (target ? (groupUses(data, target) ?? []) : []).filter((name) => providers.has(name))
const sources = uses.length > 0 ? uses : userProviders(data)
return applySetup(applySubRules(next, inbound.name, rules), inbound.name, {
proxy: { sources, filter: null },
chain: null,
})
}
/** 配置里指向某个名字的地方有几处。用来判断生成的东西还有没有人在用。 */
function referencesTo(data: ConfigObject, name: string): number {
let count = 0
+19 -1
View File
@@ -6,6 +6,7 @@ import {
collectInbounds,
collectOutboundNames,
fallbackTarget,
firstPort,
isUnsetOutbound,
outboundLabel,
OUTBOUND_PRESETS,
@@ -42,7 +43,7 @@ describe('合并两套写法的入站', () => {
expect(list[1]!.listenerIndex).toBe(0)
})
it('默认入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
it('legacy 入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' })
})
})
@@ -164,3 +165,20 @@ describe('出站预设表是读取和显示的唯一约定', () => {
expect(isUnsetOutbound({ kind: 'named', name: 'AUTO' })).toBe(false)
})
})
describe('经由入站发请求时连哪个端口', () => {
it('单个端口直接用', () => {
expect(firstPort('7890')).toBe(7890)
})
it('多端口和区间取第一个数', () => {
expect(firstPort('200,302')).toBe(200)
expect(firstPort('401-429')).toBe(401)
})
it('解析不出或越界返回 null', () => {
expect(firstPort('')).toBeNull()
expect(firstPort('abc')).toBeNull()
expect(firstPort('70000')).toBeNull()
})
})
+11
View File
@@ -185,6 +185,17 @@ export function inboundStatKey(inbound: Inbound): string {
* `MATCH` 匹配一切,所以第一条 MATCH 之后的规则都到不了,它就是兜底出站。
* 没有 MATCH 时内核会直连,返回 null。
*/
/**
* 服务端要经由这一路入站发请求时,连本机的哪个端口。
* 命名入站的端口可以写成 "200,302" 或 "401-429",取第一个数字;解析不出返回 null。
*/
export function firstPort(port: string): number | null {
const match = /\d+/.exec(port)
if (!match) return null
const value = Number(match[0])
return value >= 1 && value <= 65535 ? value : null
}
export function fallbackTarget(data: ConfigObject): string | null {
const rules = Array.isArray(data.rules) ? data.rules : []
for (const rule of rules) {
+143 -3
View File
@@ -1,17 +1,28 @@
import { describe, expect, it } from 'bun:test'
import {
DEFAULT_HEALTH_CHECK,
DEFAULT_INTERVAL,
addFileSubscription,
addSubscription,
listSubscriptions,
moveSubscription,
providerNames,
removeSubscription,
renameSubscription,
setSubscriptionHealthCheck,
subscriptionPathConflict,
subscriptionReferences,
updateSubscription,
} from './providers.ts'
import { applySetup, chainProviderName, providerCachePath } from './inbound-groups.ts'
import {
applySetup,
chainProviderName,
providerCachePath,
pruneGenerated,
syncGenerated,
} from './inbound-groups.ts'
import type { OutboundSetup } from './inbound-groups.ts'
import { collectInbounds } from './inbounds.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts'
const URL = 'http://127.0.0.1:3001/token/download/V2House'
@@ -77,7 +88,7 @@ describe('读出订阅列表', () => {
})
describe('新增订阅', () => {
it('自动生成缓存路径和默认间隔', () => {
it('自动生成缓存路径、默认间隔和默认开启的健康检查', () => {
const next = addSubscription({}, 'subs2', URL)
expect(next['proxy-providers']).toEqual({
subs2: {
@@ -85,6 +96,7 @@ describe('新增订阅', () => {
url: URL,
path: './providers/subs2.yaml',
interval: DEFAULT_INTERVAL,
'health-check': DEFAULT_HEALTH_CHECK,
},
})
})
@@ -106,11 +118,97 @@ describe('新增订阅', () => {
it('从文件导入的订阅是 file 类型,没有地址和间隔', () => {
const next = addFileSubscription({}, 'subs2')
expect(next['proxy-providers']).toEqual({
subs2: { type: 'file', path: './providers/subs2.yaml' },
subs2: {
type: 'file',
path: './providers/subs2.yaml',
'health-check': DEFAULT_HEALTH_CHECK,
},
})
})
})
describe('健康检查开关', () => {
it('开启时缺的字段用默认值补齐', () => {
const data = base()
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
delete subs['health-check']
const next = setSubscriptionHealthCheck(data, 'subs', true)
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
expect(entry['health-check']).toEqual(DEFAULT_HEALTH_CHECK)
})
it('关闭只翻 enable,手写的 url 和 interval 留着', () => {
const data = base()
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
subs['health-check'] = { enable: true, url: 'https://example.com/ping', interval: 60 }
const next = setSubscriptionHealthCheck(data, 'subs', false)
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
expect(entry['health-check']).toEqual({
enable: false,
url: 'https://example.com/ping',
interval: 60,
})
// 再开启时它们原样回来
const back = setSubscriptionHealthCheck(next, 'subs', true)
expect(listSubscriptions(back)[0]?.healthCheck).toEqual({
url: 'https://example.com/ping',
interval: 60,
})
})
it('本来就没写又要关闭时不做改动', () => {
const data = base()
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
delete subs['health-check']
expect(setSubscriptionHealthCheck(data, 'subs', false)).toBe(data)
})
it('订阅不存在时不做改动', () => {
const data = base()
expect(setSubscriptionHealthCheck(data, 'ghost', true)).toBe(data)
})
})
describe('订阅改名', () => {
it('改映射键并同步策略组的 use 引用,path 不动', () => {
const next = renameSubscription(base(), 'subs', 'subs-new')
const providers = next['proxy-providers'] as ConfigObject
expect(Object.keys(providers)).toEqual(['subs-new'])
expect((providers['subs-new'] as ConfigObject).path).toBe('./providers/subs.yaml')
const group = (next['proxy-groups'] as ConfigObject[])[0]!
expect(group.use).toEqual(['subs-new'])
})
it('保持条目在映射里的位置', () => {
const data = addSubscription(addSubscription(base(), 'a', URL), 'b', URL)
const next = renameSubscription(data, 'a', 'z')
expect(Object.keys(next['proxy-providers'] as ConfigObject)).toEqual(['subs', 'z', 'b'])
})
it('改名后保存链路按新名字重建链式副本', () => {
const data = applySetup(
{ ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
'test1',
chainedSetup,
)
const renamed = renameSubscription(data, 'subs', 'subs2')
// 页面 commit() 每次落盘前都跑这两步
const synced = pruneGenerated(syncGenerated(renamed, collectInbounds(renamed)))
expect(providerNames(synced)).toContain(chainProviderName('test1', 'subs2'))
expect(providerNames(synced)).not.toContain(chainProviderName('test1', 'subs'))
// 引用检查也要认得出新名字,删除保护不能因为改名失效
expect(subscriptionReferences(synced, 'subs2')).not.toEqual([])
})
it('原名不存在、新名已占用或没改时都不做改动', () => {
const data = base()
expect(renameSubscription(data, 'ghost', 'x')).toBe(data)
const withX = addSubscription(data, 'x', URL)
expect(renameSubscription(withX, 'subs', 'x')).toBe(withX)
expect(renameSubscription(data, 'subs', 'subs')).toBe(data)
})
})
describe('编辑订阅', () => {
it('只改地址和间隔,其余字段原样保留', () => {
const next = updateSubscription(base(), 'subs', 'http://example.com/new', 3600)
@@ -183,6 +281,48 @@ describe('删除前的引用检查', () => {
})
})
describe('调整订阅顺序', () => {
/** 三个用户订阅,外加一份接在中间的订阅副本。 */
function three(): ConfigObject {
let data: ConfigObject = {}
for (const name of ['A', 'B', 'C']) data = addSubscription(data, name, `https://${name}.example`)
return data
}
it('上移和下移交换相邻两个订阅', () => {
expect(listSubscriptions(moveSubscription(three(), 'B', -1)).map((s) => s.name)).toEqual([
'B',
'A',
'C',
])
expect(listSubscriptions(moveSubscription(three(), 'B', 1)).map((s) => s.name)).toEqual([
'A',
'C',
'B',
])
})
it('到头了原样返回,配置对象不变', () => {
const data = three()
expect(moveSubscription(data, 'A', -1)).toBe(data)
expect(moveSubscription(data, 'C', 1)).toBe(data)
expect(moveSubscription(data, '不存在', 1)).toBe(data)
})
it('夹在中间的订阅副本留在原地', () => {
const withCopy = applySetup(
{ ...three(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
'test1',
{ proxy: { sources: ['B'], filter: null }, chain: { sources: ['B'], filter: null } },
)
const copy = chainProviderName('test1', 'B')
const before = Object.keys(withCopy['proxy-providers'] as ConfigObject).indexOf(copy)
const after = moveSubscription(withCopy, 'A', 1)
expect(listSubscriptions(after).map((s) => s.name)).toEqual(['B', 'A', 'C'])
expect(Object.keys(after['proxy-providers'] as ConfigObject).indexOf(copy)).toBe(before)
})
})
describe('缓存路径撞名', () => {
it('名字只差符号时净化成同一个路径,能认出来', () => {
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
+109 -3
View File
@@ -6,8 +6,8 @@
* 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责,
* 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。
*
* 订阅不提供改名:名字被策略组的 `use:` 和生成的副本引用,改名等于改一个被引用的
* 标识符,要同步更新所有引用,漏一处就是断引用。删了重建更简单。
* 改名必须走 renameSubscription:名字被策略组的 `use:` 和生成的副本引用,改名和
* 同步引用要在同一次变换里完成,散开做漏一处就是断引用。
*/
import type { ConfigObject } from '../vendor/config-editor/index.ts'
import { isGenerated, providerCachePath } from './inbound-groups.ts'
@@ -15,6 +15,19 @@ import { isGenerated, providerCachePath } from './inbound-groups.ts'
/** 新增订阅时的默认更新间隔,秒。一天一次。 */
export const DEFAULT_INTERVAL = 86400
/**
* 新增订阅或开启健康检查时写入的默认值。
*
* `url` 必须写:内核没有默认检查地址,为空时 interval 被强制归零、检查不会运行
* (内核源码 adapter/provider/healthcheck.go:196-199)。`interval` 与内核在
* enable 时的默认一致(adapter/provider/parser.go:66-68),写死一个更稳。
*/
export const DEFAULT_HEALTH_CHECK = {
enable: true,
url: 'https://www.gstatic.com/generate_204',
interval: 300,
}
/** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */
export interface Subscription {
name: string
@@ -104,6 +117,7 @@ export function addSubscription(
url,
path: providerCachePath(name),
...(interval !== null ? { interval } : {}),
'health-check': { ...DEFAULT_HEALTH_CHECK },
},
}
return next
@@ -137,7 +151,11 @@ export function addFileSubscription(data: ConfigObject, name: string): ConfigObj
const next = { ...data }
next['proxy-providers'] = {
...providersOf(data),
[name]: { type: 'file', path: providerCachePath(name) },
[name]: {
type: 'file',
path: providerCachePath(name),
'health-check': { ...DEFAULT_HEALTH_CHECK },
},
}
return next
}
@@ -162,6 +180,94 @@ export function updateSubscription(
return next
}
/**
* 开关一个订阅的健康检查。
*
* 关闭写 `enable: false` 而不是删掉整个键:用户手写的 url/interval 要留着,
* 下次开启时还用它们。开启时缺的字段用默认值补齐——url 不能缺,内核没有默认
* 检查地址(见 DEFAULT_HEALTH_CHECK 的注)。
*/
export function setSubscriptionHealthCheck(
data: ConfigObject,
name: string,
enable: boolean,
): ConfigObject {
const entry = providersOf(data)[name]
if (!isPlainObject(entry)) return data
const existing = isPlainObject(entry['health-check']) ? entry['health-check'] : null
// 本来就没写又要关闭,不必写一个 enable: false 的完整声明进去
if (!enable && existing === null) return data
const next = { ...data }
next['proxy-providers'] = {
...providersOf(data),
[name]: { ...entry, 'health-check': { ...DEFAULT_HEALTH_CHECK, ...existing, enable } },
}
return next
}
/**
* 改订阅的名字,并同步所有策略组 `use:` 里的引用。
*
* 缓存路径 `path` 保持不变:file 类型的数据本体就在旧路径上,链式代理的副本靠
* 「和原订阅同一个 path」认来源(见 inbound-groups.ts),路径一动这两件事都得另做迁移。
* 生成的副本这里不碰:写配置的链路每次都跑 syncGenerated/pruneGenerated,
* 会按新名字重建副本、清掉旧的。
*/
export function renameSubscription(
data: ConfigObject,
oldName: string,
newName: string,
): ConfigObject {
const providers = providersOf(data)
if (oldName === newName || !(oldName in providers) || newName in providers) return data
const next = { ...data }
// 逐项重建以保住条目在映射里的位置,diff 才不会把整段重排
next['proxy-providers'] = Object.fromEntries(
Object.entries(providers).map(([name, entry]) => [name === oldName ? newName : name, entry]),
)
if (Array.isArray(data['proxy-groups'])) {
next['proxy-groups'] = data['proxy-groups'].map((group) => {
if (!isPlainObject(group) || !Array.isArray(group.use)) return group
if (!group.use.includes(oldName)) return group
return { ...group, use: group.use.map((item) => (item === oldName ? newName : item)) }
})
}
return next
}
/**
* 把一个订阅在列表里上移或下移一位。
*
* 顺序只是呈现顺序,内核不看 `proxy-providers` 里条目的先后。交换的是两个用户订阅在
* 映射里的位置,中间夹着的副本留在原地——它们不在列表上,跟着挪只会把 diff 撑大。
* 已经在头(尾)还要上(下)移时原样返回,调用方据此不发起保存。
*/
export function moveSubscription(
data: ConfigObject,
name: string,
delta: -1 | 1,
): ConfigObject {
const entries = Object.entries(providersOf(data))
const keys = entries.map(([key]) => key)
const mine = keys.filter((key) => !isGenerated(key))
const at = mine.indexOf(name)
const target = mine[at + delta]
if (at < 0 || target === undefined) return data
const from = keys.indexOf(name)
const onto = keys.indexOf(target)
const order = [...entries]
order[from] = entries[onto]!
order[onto] = entries[from]!
const next = { ...data }
next['proxy-providers'] = Object.fromEntries(order)
return next
}
/** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */
export function removeSubscription(data: ConfigObject, name: string): ConfigObject {
const providers = { ...providersOf(data) }
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, test } from 'bun:test'
import {
describeProbeFailure,
findTarget,
isChallenge,
judge,
rayId,
REACH_TARGETS,
} from './reachability.ts'
const openai = REACH_TARGETS[0]!
const gemini = REACH_TARGETS[2]!
const headers = (init: Record<string, string> = {}) => new Headers(init)
describe('REACH_TARGETS', () => {
test('标识不重名,否则接口按 id 取目标会取错', () => {
const ids = REACH_TARGETS.map((t) => t.id)
expect(new Set(ids).size).toBe(ids.length)
})
test('findTarget 认不出来的标识返回 null', () => {
expect(findTarget('openai')?.label).toBe('OpenAI')
expect(findTarget('nonesuch')).toBeNull()
})
})
describe('judge', () => {
test('无鉴权探测拿到 401 算可达,并说明这是预期结果', () => {
const { verdict, message } = judge(openai, 401, headers(), '')
expect(verdict).toBe('reachable')
expect(message).toBe('HTTP 401,目标可达')
})
test('2xx 不需要解释,只报状态码', () => {
expect(judge(gemini, 200, headers(), '{}')).toEqual({
verdict: 'reachable',
message: 'HTTP 200',
})
})
test('白名单之外的状态码算不通', () => {
const { verdict, message } = judge(gemini, 403, headers(), '')
expect(verdict).toBe('unreachable')
expect(message).toBe('HTTP 403,不是预期的状态码')
})
test('429 单独说是限流,不和连不上混在一起', () => {
expect(judge(openai, 429, headers(), '').verdict).toBe('throttled')
})
test('Cloudflare 拦截优先于状态码白名单', () => {
// 429 在 openai 的白名单外,但结论要是 challenge 而不是 throttled:
// 前者要换出口 IP,后者只要等
const { verdict, message } = judge(
openai,
429,
headers({ 'cf-mitigated': 'challenge', 'cf-ray': '8f0a1b2c3d4e5f60-LAX' }),
'',
)
expect(verdict).toBe('challenge')
expect(message).toContain('8f0a1b2c3d4e5f60-LAX')
})
})
describe('isChallenge', () => {
test('只有 403 和 429 才可能是拦截页', () => {
const body = '<html>window._cf_chl_opt</html>'
expect(isChallenge(403, headers(), body)).toBe(true)
expect(isChallenge(429, headers(), body)).toBe(true)
expect(isChallenge(200, headers(), body)).toBe(false)
})
test('没有 cf-mitigated 头时认正文里的标记', () => {
expect(isChallenge(403, headers(), '<title>Just a Moment...</title>')).toBe(true)
})
test('普通的 403 不是拦截页', () => {
expect(isChallenge(403, headers(), '{"error":"forbidden"}')).toBe(false)
})
test('正文只看前 4 KB,标记在那之后不算', () => {
expect(isChallenge(403, headers(), 'x'.repeat(5000) + 'just a moment')).toBe(false)
})
})
describe('rayId', () => {
test('优先取响应头', () => {
expect(rayId(headers({ 'cf-ray': ' abc123-LAX ' }), 'cf-ray: zzz')).toBe('abc123-LAX')
})
test('没有响应头时从正文里找', () => {
expect(rayId(headers(), 'Cloudflare Ray ID: <code>cf-ray: abc123</code>')).toBe('abc123')
})
test('都没有就是空串', () => {
expect(rayId(headers(), 'nothing here')).toBe('')
})
})
describe('describeProbeFailure', () => {
test('超时说等了多久', () => {
const error = new Error('timed out')
error.name = 'TimeoutError'
expect(describeProbeFailure(7890, 10_000, error)).toBe('10 秒内没有响应')
})
test('本机端口没人监听时指向内核,不照抄 Bun 那句指向目标网址的话', () => {
const error = Object.assign(new Error('Unable to connect. Is the computer able to access the url?'), {
code: 'ConnectionRefused',
})
expect(describeProbeFailure(7890, 10_000, error)).toBe('本机 7890 端口没有响应,内核可能没在运行')
})
test('其余原因照原文给出,不猜', () => {
expect(describeProbeFailure(7890, 10_000, new Error('TLS handshake failed'))).toBe(
'连接失败:TLS handshake failed',
)
})
})
+158
View File
@@ -0,0 +1,158 @@
/**
* 可访问性测试:一路入站到几个常用 AI 服务通不通。
*
* 判定方式来自 sub2api 的代理质量检查(backend/internal/service/admin_proxy.go:337):
* 发一个**不带鉴权**的 GET,按状态码白名单判可达。这些接口没有 key 一定不返回 200,
* 但只要能返回 401/405 这类业务错误,就说明 TLS 握手、路由、目标服务全都正常——
* 这正是「这一路能不能用」要问的。用 200 当唯一标准反而永远测不通。
*
* 判定逻辑放在 shared/ 而不是 server/,是因为「什么状态码算通」是这个功能的全部内容,
* 它得能被 bun test 覆盖;server/ 那边只剩下发请求。
*/
/** 一个探测目标。 */
export interface ReachTarget {
/** 接口参数里用的标识 */
id: string
/** 界面上显示的服务名 */
label: string
url: string
/** 这些状态码都算目标可达 */
reachableStatuses: number[]
}
/**
* 预设目标清单,照抄 sub2api 的四家(admin_service.go:600)。
*
* Anthropic 的白名单最宽,是因为 /v1/messages 只接受 POST,用 GET 探它时不同的
* 边缘节点会回 400/404/405 中的任意一个,这几种都只说明请求到达了对面。
*/
export const REACH_TARGETS: ReachTarget[] = [
{
id: 'openai',
label: 'OpenAI',
url: 'https://api.openai.com/v1/models',
reachableStatuses: [200, 401],
},
{
id: 'anthropic',
label: 'Anthropic',
url: 'https://api.anthropic.com/v1/messages',
reachableStatuses: [200, 400, 401, 404, 405],
},
{
id: 'gemini',
label: 'Gemini',
url: 'https://generativelanguage.googleapis.com/$discovery/rest?version=v1beta',
reachableStatuses: [200],
},
{
id: 'grok',
label: 'Grok',
url: 'https://api.x.ai/v1/models',
reachableStatuses: [200, 401],
},
]
export function findTarget(id: string): ReachTarget | null {
return REACH_TARGETS.find((t) => t.id === id) ?? null
}
/**
* 一次探测的结论。
* reachable 通了
* challenge 网络通,但出口 IP 被 Cloudflare 拦下要人机验证——这一路发得出请求,用不了服务
* throttled 对面回了 429,通但有频控
* unreachable 没通,或者回了预期之外的状态码
*/
export type ReachVerdict = 'reachable' | 'challenge' | 'throttled' | 'unreachable'
export interface ReachResult {
target: string
verdict: ReachVerdict
/** 没拿到响应时(连接失败、超时)为 null */
status: number | null
/** 整个来回的毫秒数 */
ms: number
/** 说明这个结论怎么来的,直接显示给用户 */
message: string
}
/** Cloudflare 拦截页面里必然出现的标记之一。摘自 sub2api util/httputil/httputil.go:15。 */
const CHALLENGE_MARKERS = [
'window._cf_chl_opt',
'just a moment',
'enable javascript and cookies to continue',
'__cf_chl_',
'challenge-platform',
]
/**
* 这个响应是不是 Cloudflare 的人机验证。
*
* 只有 403 和 429 才可能是。cf-mitigated 头是 Cloudflare 自己给的明示,没有这个头时
* 退回去认拦截页面的正文标记——正文只看前 4 KB,验证页的标记都在最前面。
*/
export function isChallenge(status: number, headers: Headers, body: string): boolean {
if (status !== 403 && status !== 429) return false
if (headers.get('cf-mitigated')?.trim().toLowerCase() === 'challenge') return true
const preview = body.slice(0, 4096).toLowerCase()
return CHALLENGE_MARKERS.some((marker) => preview.includes(marker))
}
/** Cloudflare 的请求编号。报障时对面要这个,所以命中拦截要把它带出来。 */
export function rayId(headers: Headers, body: string): string {
const header = headers.get('cf-ray')?.trim()
if (header) return header
return /cf-ray[:\s=]+([a-z0-9-]+)/i.exec(body.slice(0, 8192))?.[1] ?? ''
}
/** 把一次拿到了响应的探测判成结论。连接根本没建立时不走这里,见 server/host.ts。 */
export function judge(
target: ReachTarget,
status: number,
headers: Headers,
body: string,
): { verdict: ReachVerdict; message: string } {
if (isChallenge(status, headers, body)) {
const ray = rayId(headers, body)
return {
verdict: 'challenge',
message: `出口 IP 被 Cloudflare 拦下要人机验证${ray ? `(cf-ray ${ray})` : ''}`,
}
}
if (target.reachableStatuses.includes(status)) {
return {
verdict: 'reachable',
// 白名单里的非 2xx 是无鉴权探测的预期结果,不解释一句会被当成失败
message: status >= 200 && status < 300 ? `HTTP ${status}` : `HTTP ${status},目标可达`,
}
}
if (status === 429) return { verdict: 'throttled', message: 'HTTP 429,目标在限流' }
return { verdict: 'unreachable', message: `HTTP ${status},不是预期的状态码` }
}
/**
* 把「连响应都没拿到」翻译成一句说明原因的话。
*
* 探测走的是本机的入站端口,所以连接失败有两种完全不同的来源:端口上根本没人监听
* (内核没运行),和端口通了但代理那一跳走不出去。Bun 对前者给的是
* 「Unable to connect. Is the computer able to access the url?」——这句话把矛头指向了
* 目标网址,而真正的原因在本机,照抄出来只会让人去查错方向。
*/
export function describeProbeFailure(port: number, timeoutMs: number, error: unknown): string {
if (error instanceof Error && error.name === 'TimeoutError') {
return `${String(timeoutMs / 1000)} 秒内没有响应`
}
const code = error instanceof Error ? (error as { code?: unknown }).code : undefined
if (code === 'ConnectionRefused') {
return `本机 ${String(port)} 端口没有响应,内核可能没在运行`
}
return `连接失败:${error instanceof Error ? error.message : String(error)}`
}
+15 -5
View File
@@ -53,7 +53,7 @@ function openInbound(id: string) {
* 选中的那一路入站丢失,改完一个设置还要重新找回原来的位置。所以只有首次加载显示占位,
* 之后都是原地更新。
*/
async function load() {
async function load(): Promise<'ok' | 'unauthorized' | 'error'> {
refreshing.value = true
loadError.value = ''
try {
@@ -64,22 +64,32 @@ async function load() {
needsToken.value = false
// 撤回/重做按钮的可用性跟着每次重读走
void refreshHistory()
return 'ok'
} catch (error) {
if (error instanceof ApiError && error.unauthorized) {
clearToken()
needsToken.value = true
} else {
loadError.value = error instanceof Error ? error.message : String(error)
return 'unauthorized'
}
loadError.value = error instanceof Error ? error.message : String(error)
return 'error'
} finally {
refreshing.value = false
booted.value = true
}
}
function submitToken(value: string) {
/**
* 提交登录页填的令牌。
*
* 401 必须当场说一声:登录页在提交前后都是它自己,组件不重建、输入框也不清空,
* 不提示的话点了「进入」界面一个字都不变,看着像没反应。
* 只在这里提示,不在 load() 里——存着的令牌失效时弹回登录页,页面本身就是说明。
*/
async function submitToken(value: string) {
setToken(value)
void load()
// 令牌在哪儿取,登录页上就写着,不必在提示里再说一遍
if ((await load()) === 'unauthorized') toast('令牌不正确', { kind: 'error' })
}
function logout() {
+21
View File
@@ -8,6 +8,7 @@ import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/ind
import type { ConnectionSnapshot } from '@shared/connections.ts'
import type { ExitProbe, HostInfo } from '@shared/host-info.ts'
import type { GeoFileStatus } from '@shared/geo.ts'
import type { ReachResult } from '@shared/reachability.ts'
import { DEFAULT_TEST_URL, testUrl } from './prefs'
const TOKEN_KEY = 'patchbay.token'
@@ -194,6 +195,13 @@ export const api = {
*/
probeExit: (port: number) => request<ExitProbe>('GET', `/api/host/exit?port=${String(port)}`),
/**
* 经由某个入站端口探测一个目标能不能访问。一次一个目标,调用方并发发起。
* 同样会发起真实的外网请求,只在用户点了测试时调。
*/
probeReach: (port: number, target: string) =>
request<ReachResult>('GET', `/api/host/reach?port=${String(port)}&target=${target}`),
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
@@ -310,6 +318,19 @@ export const api = {
refreshProvider: (name: string) =>
request<unknown>('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`),
/**
* 让内核立刻对一个订阅的全部线路做一次健康检查:逐条 URLTest,测通与否和延迟一起
* 记进历史(内核源码 adapter/provider/healthcheck.go:123-189)。
*
* 用的是这个订阅 `health-check.url` 里的地址而不是设置页的测速地址——内核这个接口
* 不收 url 参数。它是同步的,测完全部线路才回话,所以可能要等上几十秒。
*/
checkProviderLines: (name: string) =>
request<unknown>(
'GET',
`/api/kernel/providers/proxies/${encodeURIComponent(name)}/healthcheck`,
),
/**
* 每个规则组的运行时状态:装了多少条规则、上次什么时候更新。
* 配置文件里只有声明,这两个数字只有运行中的内核知道。
+1 -1
View File
@@ -45,7 +45,7 @@ function outboundOf(inbound: Inbound): { text: string; chained: boolean } {
<span class="type mono">{{ inboundTypeLabel(inbound.type) }}</span>
<span class="name">
{{ inbound.name }}
<span v-if="inbound.isDefault" class="tag">默认</span>
<span v-if="inbound.isDefault" class="tag">legacy</span>
</span>
<span :class="['out mono', isUnsetOutbound(inbound.outbound) && 'is-muted']">
<span v-if="outboundOf(inbound).chained" class="chain" title="经过链式代理">
+80 -28
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
/**
* 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间、健康检查开没开。
* 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间,末尾是调顺序的两个箭头。
*
* 只管显示和选中,不碰任何数据。
* 只管显示、选中和「往哪边挪一位」,不碰任何数据。
*/
import IconUp from '~icons/ri/arrow-up-line'
import IconDown from '~icons/ri/arrow-down-line'
defineProps<{
/** 一行要显示的东西,由页面按配置和运行时数据拼好 */
@@ -13,12 +15,15 @@ defineProps<{
lines: number | null
/** 上次拉取成功的时刻。null 表示不知道或从没拉取过 */
updated: Date | null
/** 健康检查开没开 */
healthCheck: boolean
}>
/** 配置正在保存。调顺序要写配置文件,保存期间不能再点 */
saving: boolean
}>()
const selected = defineModel<string | null>('selected', { required: true })
/** 把这个订阅在列表里挪一位。写配置文件,由页面走保存链路。 */
const emit = defineEmits<{ move: [name: string, delta: -1 | 1] }>()
const pad = (n: number) => String(n).padStart(2, '0')
function formatUpdated(at: Date): string {
@@ -28,22 +33,37 @@ function formatUpdated(at: Date): string {
<template>
<ol class="rows">
<li v-for="row in rows" :key="row.name">
<button
type="button"
:class="['line', selected === row.name && 'is-on']"
@click="selected = row.name"
<li
v-for="(row, i) in rows"
:key="row.name"
:class="['row', selected === row.name && 'is-on']"
>
<button type="button" class="line" @click="selected = row.name">
<span class="name">{{ row.name }}</span>
<span class="lines mono">
<template v-if="row.lines !== null">{{ row.lines }} 条</template>
<template v-else>—</template>
</span>
<span class="updated mono">{{ row.updated ? formatUpdated(row.updated) : '—' }}</span>
<span :class="['hc', !row.healthCheck && 'is-off']">
{{ row.healthCheck ? '健康检查' : '—' }}
</span>
</button>
<span class="acts">
<button
type="button"
title="上移"
:disabled="saving || i === 0"
@click="emit('move', row.name, -1)"
>
<IconUp />
</button>
<button
type="button"
title="下移"
:disabled="saving || i === rows.length - 1"
@click="emit('move', row.name, 1)"
>
<IconDown />
</button>
</span>
</li>
</ol>
</template>
@@ -63,27 +83,34 @@ function formatUpdated(at: Date): string {
border-top: 1px solid var(--c-border);
}
.row {
display: flex;
align-items: center;
padding-right: var(--s-3);
}
.row:hover {
background: var(--c-surface-sunken);
}
.row.is-on {
background: var(--c-accent-soft);
}
.line {
width: 100%;
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 4rem 6.5rem 4.5rem;
grid-template-columns: minmax(0, 1fr) 4rem 6.5rem;
align-items: center;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
padding: var(--s-3) var(--s-2) var(--s-3) var(--s-4);
border: none;
background: none;
cursor: pointer;
text-align: left;
}
.line:hover {
background: var(--c-surface-sunken);
}
.line.is-on {
background: var(--c-accent-soft);
}
.name {
min-width: 0;
overflow: hidden;
@@ -99,13 +126,38 @@ function formatUpdated(at: Date): string {
color: var(--c-text-soft);
}
.hc {
text-align: right;
font-size: var(--t-xs);
color: var(--c-text-soft);
/* 平时不显示:调顺序不是常用动作,每行常驻两个箭头只是噪声。
隐藏用 opacity 而不是 display,位置一直占着,鼠标划过时右边一列才不会跳 */
.acts {
flex: none;
display: inline-flex;
gap: 1px;
opacity: 0;
transition: opacity 0.15s ease;
}
.row:hover .acts,
.acts:focus-within {
opacity: 1;
}
.hc.is-off {
.acts button {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.acts button:hover:not(:disabled) {
background: var(--c-surface);
color: var(--c-text);
}
.acts button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
</style>
+313
View File
@@ -0,0 +1,313 @@
<script setup lang="ts">
/**
* 可访问性测试:这一路入站到几个常用 AI 服务通不通。
*
* 每个目标一个请求、并发发起,所以每一行各自落定,不用等最慢的那个才整表出现。
* 判定不在这里——「什么状态码算通」在 shared/reachability.ts,这里只管显示。
*
* 这是纯读的运行时动作:不写配置文件、不改内核状态,所以打开就直接开测,不设确认。
*/
import { ref, watch } from 'vue'
import { REACH_TARGETS } from '@shared/reachability.ts'
import type { ReachResult, ReachVerdict } from '@shared/reachability.ts'
import { api } from '../api'
import IconClose from '~icons/ri/close-line'
import IconRefresh from '~icons/ri/refresh-line'
const props = defineProps<{
open: boolean
/** 经由哪一路入站测。名字只用来显示,端口才是真正走的那个口 */
name: string
port: number
}>()
const emit = defineEmits<{ close: [] }>()
type Row = { state: 'testing' } | { state: 'done'; result: ReachResult }
const rows = ref<Record<string, Row>>({})
const running = ref(false)
/**
* 第几轮测试。重测和关掉重开都会让它加一,上一轮迟到的响应据此丢弃——
* 不这么做的话,换了一路入站再打开,上一路的结果会落到新的表里。
*/
let round = 0
async function runAll() {
if (props.port <= 0) return
round += 1
const mine = round
const port = props.port
running.value = true
rows.value = Object.fromEntries(REACH_TARGETS.map((t) => [t.id, { state: 'testing' } as Row]))
await Promise.all(
REACH_TARGETS.map(async (target) => {
const result = await api.probeReach(port, target.id).catch(
(error: unknown): ReachResult => ({
target: target.id,
verdict: 'unreachable',
status: null,
ms: 0,
message: error instanceof Error ? error.message : String(error),
}),
)
if (round !== mine) return
rows.value = { ...rows.value, [target.id]: { state: 'done', result } }
}),
)
if (round === mine) running.value = false
}
const dialog = ref<HTMLDialogElement | null>(null)
watch(
() => props.open,
(open) => {
if (open) {
dialog.value?.showModal()
void runAll()
} else {
dialog.value?.close()
}
},
)
/** 点在遮罩上就关闭。内容区的点击 target 是 .body 里的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
const VERDICT_LABEL: Record<ReachVerdict, string> = {
reachable: '通过',
throttled: '限流',
challenge: '人机验证',
unreachable: '失败',
}
/** 某个目标测完了的结果,还在测就是 null。模板里的分支只认这一个函数。 */
function resultOf(id: string): ReachResult | null {
const row = rows.value[id]
return row?.state === 'done' ? row.result : null
}
/** 目标地址显示成主机名加路径,完整 URL 太长会把这一行撑开。 */
function shortUrl(url: string): string {
const parsed = new URL(url)
return parsed.host + parsed.pathname
}
</script>
<template>
<dialog
ref="dialog"
class="dlg"
@click="onBackdrop"
@close="emit('close')"
@cancel.prevent="emit('close')"
>
<div class="body">
<header class="head">
<h2>
可访问性测试
<span class="via mono">{{ name }} : {{ port }}</span>
</h2>
<button type="button" class="icon" title="关闭" @click="emit('close')">
<IconClose />
</button>
</header>
<ul class="rows">
<li v-for="target in REACH_TARGETS" :key="target.id" class="row">
<div class="who">
<span class="label">{{ target.label }}</span>
<span class="url mono">{{ shortUrl(target.url) }}</span>
</div>
<template v-if="resultOf(target.id)">
<span :class="['verdict', resultOf(target.id)!.verdict]">
{{ VERDICT_LABEL[resultOf(target.id)!.verdict] }}
</span>
<span class="ms mono">{{ resultOf(target.id)!.ms }} ms</span>
<!-- 排在毫秒之后:它跨整行,写在前面会把毫秒挤到下一行去 -->
<span class="msg">{{ resultOf(target.id)!.message }}</span>
</template>
<span v-else class="waiting">测试中</span>
</li>
</ul>
<footer class="acts">
<button type="button" class="btn" :disabled="running" @click="void runAll()">
<IconRefresh /> 重新测试
</button>
</footer>
</div>
</dialog>
</template>
<style scoped>
.dlg {
width: min(560px, calc(100vw - 2 * var(--s-5)));
/* 内边距放在 .body 上:dialog 自己的内边距区域点了会被当成遮罩点击 */
padding: 0;
border: 1px solid var(--c-border-strong);
border-radius: var(--r-lg);
background: var(--c-surface);
color: var(--c-text);
}
.dlg::backdrop {
background: rgb(0 0 0 / 0.45);
}
.body {
padding: var(--s-5);
}
.head {
display: flex;
align-items: center;
gap: var(--s-3);
margin-bottom: var(--s-4);
}
.head h2 {
display: flex;
align-items: baseline;
gap: var(--s-3);
font-size: var(--t-lg);
font-weight: 600;
}
.via {
color: var(--c-text-faint);
font-size: var(--t-sm);
font-weight: 400;
}
.icon {
display: flex;
margin-left: auto;
padding: var(--s-1);
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-soft);
cursor: pointer;
}
.icon:hover {
color: var(--c-text);
}
.rows {
display: flex;
flex-direction: column;
list-style: none;
}
.row {
display: grid;
/* 服务名那列吃掉剩余宽度,结论和毫秒各自贴右:四行对齐才看得出哪个不一样 */
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--s-1) var(--s-3);
padding: var(--s-3) 0;
border-bottom: 1px solid var(--c-border);
}
.row:last-child {
border-bottom: none;
}
.who {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.label {
font-weight: 500;
}
.url {
overflow: hidden;
color: var(--c-text-faint);
font-size: var(--t-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
/* 每一行是各自的 grid,列宽不共享,所以结论和毫秒都定死一个下限,
否则「通过」和「人机验证」不一样长,四行的徽章就对不齐 */
.verdict {
min-width: 4.5em;
padding: 2px var(--s-2);
border-radius: var(--r-sm);
font-size: var(--t-xs);
text-align: center;
white-space: nowrap;
}
.verdict.reachable {
background: var(--c-ok-soft);
color: var(--c-ok);
}
.verdict.throttled,
.verdict.challenge {
background: var(--c-warn-soft);
color: var(--c-warn);
}
.verdict.unreachable {
background: var(--c-danger-soft);
color: var(--c-danger);
}
.ms {
min-width: 4.5em;
color: var(--c-text-soft);
font-size: var(--t-sm);
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
/* 结论那一句独占第二行,长了也不会把状态和毫秒挤出去 */
.msg {
grid-column: 1 / -1;
color: var(--c-text-soft);
font-size: var(--t-xs);
}
.waiting {
grid-column: 2 / -1;
color: var(--c-text-faint);
font-size: var(--t-sm);
text-align: right;
}
.acts {
display: flex;
justify-content: flex-end;
margin-top: var(--s-5);
}
.btn {
display: flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-2) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
color: inherit;
cursor: pointer;
}
.btn:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.btn:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
</style>
+1 -18
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'bun:test'
import { firstPort, kernelPorts, modeLabel } from './dashboard.ts'
import { kernelPorts, modeLabel } from './dashboard.ts'
describe('运行模式', () => {
it('三个已知模式有中文名', () => {
@@ -31,20 +31,3 @@ describe('代理端口', () => {
expect(kernelPorts({ port: '7890' })).toEqual([{ name: 'port', port: 7890 }])
})
})
describe('探测端口', () => {
it('单个端口直接用', () => {
expect(firstPort('7890')).toBe(7890)
})
it('多端口和区间取第一个数', () => {
expect(firstPort('200,302')).toBe(200)
expect(firstPort('401-429')).toBe(401)
})
it('解析不出或越界返回 null', () => {
expect(firstPort('')).toBeNull()
expect(firstPort('abc')).toBeNull()
expect(firstPort('70000')).toBeNull()
})
})
+1 -12
View File
@@ -20,7 +20,7 @@ export function modeLabel(mode: unknown): string {
}
}
/** 一个监听中的代理端口。name 用配置键原词,和路由页默认入站的命名一致。 */
/** 一个监听中的代理端口。name 用配置键原词,和路由页legacy 入站的命名一致。 */
export interface KernelPort {
name: string
port: number
@@ -38,14 +38,3 @@ export function kernelPorts(configs: Record<string, unknown>): KernelPort[] {
}
return result
}
/**
* 入站声明的端口在探测公网 IP 时用哪个数。
* 新式入站的端口可以写 "200,302" 或 "401-429",探测取第一个数字;解析不出返回 null。
*/
export function firstPort(port: string): number | null {
const match = /\d+/.exec(port)
if (!match) return null
const value = Number(match[0])
return value >= 1 && value <= 65535 ? value : null
}
+9
View File
@@ -4,6 +4,15 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<!-- 标签页图标只声明 .ico:它里面有 16/32/48/64 四个尺寸,是照着小尺寸调过的。
再声明一个大 PNG 的话,浏览器会按显示密度在两者之间挑,而这套素材的 .ico 是
深底浅图、PNG 是白底黑图,挑到哪个不一定,同一个标签页会换样子。 -->
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48 64x64" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<!-- 移动端浏览器地址栏的底色,跟着系统主题走,和 tokens.css 里的 --c-bg 一致 -->
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fbfbfc" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#16181c" />
<title>Patchbay</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+34
View File
@@ -0,0 +1,34 @@
{
"name": "Patchbay",
"short_name": "Patchbay",
"start_url": "/",
"display": "standalone",
"background_color": "#fbfbfc",
"theme_color": "#fbfbfc",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

+72 -5
View File
@@ -13,8 +13,11 @@ import {
addFileSubscription,
addSubscription,
listSubscriptions,
moveSubscription,
providerNames,
removeSubscription,
renameSubscription,
setSubscriptionHealthCheck,
subscriptionPathConflict,
subscriptionReferences,
updateSubscription,
@@ -48,6 +51,8 @@ const formEditing = ref<Subscription | null>(null)
const removing = ref<Subscription | null>(null)
/** 正在等内核重新拉取的订阅名 */
const refreshing = ref<string | null>(null)
/** 正在等内核测完线路的订阅名 */
const checking = ref<string | null>(null)
/**
* 每个订阅的运行时数据:当前线路、上次更新时间。
@@ -103,7 +108,6 @@ const rows = computed(() =>
name: s.name,
lines: info ? info.proxies.length : null,
updated: parseUpdated(info?.updatedAt),
healthCheck: s.healthCheck !== null,
}
}),
)
@@ -149,7 +153,8 @@ async function submitForm(draft: {
}) {
const editing = formEditing.value
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦(编辑不改名,只查新增)
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦
// (只查新增:改名不动缓存路径,不会制造新的撞车)
if (!editing) {
const path = providerCachePath(draft.name)
const clash = subscriptionPathConflict(props.data, draft.name, path)
@@ -173,8 +178,17 @@ async function submitForm(draft: {
}
}
// 编辑时先改名再改字段。名字没动时 renameSubscription 原样返回;
// file 类型只有名称可改,不能走 updateSubscription——它会写进 url
const next = editing
? updateSubscription(clone(), editing.name, draft.url, draft.interval)
? editing.type === 'file'
? renameSubscription(clone(), editing.name, draft.name)
: updateSubscription(
renameSubscription(clone(), editing.name, draft.name),
draft.name,
draft.url,
draft.interval,
)
: draft.file
? addFileSubscription(clone(), draft.name)
: addSubscription(clone(), draft.name, draft.url, draft.interval)
@@ -182,7 +196,8 @@ async function submitForm(draft: {
// 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了
if (!ok) return
formOpen.value = false
if (!editing) selectedName.value = draft.name
// 新增和改名都要把选中跟过去,否则选中名失效会被重置到第一行
selectedName.value = draft.name
}
function askRemove() {
@@ -205,6 +220,17 @@ async function removeConfirmed() {
await commit(removeSubscription(clone(), target.name), `已删除 ${target.name}`)
}
/** 开关选中订阅的健康检查。写配置文件,走 commit()。 */
async function toggleHealthCheck() {
const target = selected.value
if (!target || saving.value) return
const enable = target.healthCheck === null
await commit(
setSubscriptionHealthCheck(clone(), target.name, enable),
enable ? `已开启 ${target.name} 的健康检查` : `已关闭 ${target.name} 的健康检查`,
)
}
/** 让内核立刻重新拉取。运行时动作,不写配置文件,所以不走 commit()。 */
async function refresh() {
const target = selected.value
@@ -224,6 +250,39 @@ async function refresh() {
}
}
/**
* 让内核立刻测一遍选中订阅的线路。运行时动作,不写配置文件,所以不走 commit()。
*
* 内核是测完全部线路才回话的,线路多又都测不通时要等上几十秒,期间按钮转着。
*/
async function check() {
const target = selected.value
if (!target || checking.value !== null) return
checking.value = target.name
try {
await api.checkProviderLines(target.name)
await reloadProviders()
toast(`已检查 ${target.name} 的线路`)
} catch (error) {
toast(error instanceof ApiError ? error.message : String(error), {
kind: 'error',
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
})
} finally {
checking.value = null
}
}
/** 把一个订阅在列表里挪一位。顺序在配置文件里,所以要走 commit()。 */
async function move(name: string, delta: -1 | 1) {
if (saving.value) return
const before = clone()
const next = moveSubscription(before, name, delta)
// 到头了 moveSubscription 原样返回,这时不该发起一次什么都没改的保存
if (next === before) return
await commit(next, '顺序已保存')
}
const refreshingAll = ref(false)
/** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */
@@ -278,7 +337,12 @@ async function refreshAll() {
</header>
<div class="split">
<ProviderList v-model:selected="selectedName" :rows="rows" />
<ProviderList
v-model:selected="selectedName"
:rows="rows"
:saving="saving"
@move="move"
/>
<SubscriptionDetail
v-if="selected"
@@ -286,10 +350,13 @@ async function refreshAll() {
:info="providerInfo[selected.name]"
:online="kernel.online"
:refreshing="refreshing === selected.name"
:checking="checking === selected.name"
:saving="saving"
@edit="openEdit"
@remove="askRemove"
@refresh="refresh"
@check="check"
@toggle-health-check="toggleHealthCheck"
/>
</div>
+2 -2
View File
@@ -9,7 +9,7 @@
* 除了「检测公网 IP」,全部只读。
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { collectInbounds, inboundStatKey } from '@shared/inbounds.ts'
import { collectInbounds, firstPort, inboundStatKey } from '@shared/inbounds.ts'
import { flagEmoji, formatBytes, formatUptime } from '@shared/format.ts'
import { ACTIVE_BYTES, ROUTE_WINDOW_MS, bucketRates, lastRate, windowBytes } from '@shared/telemetry.ts'
import type { RouteSample } from '@shared/telemetry.ts'
@@ -29,7 +29,7 @@ import {
totals,
traffic,
} from '../telemetry'
import { firstPort, kernelPorts, modeLabel } from '../dashboard'
import { kernelPorts, modeLabel } from '../dashboard'
import { cssColor, themeTick } from '../theme'
import EChart from '../components/EChart.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
+64 -9
View File
@@ -11,6 +11,7 @@
* 两半的反馈方式必须不一样。它们长得一样的时候,用户分不清哪个动作要等、哪个不用。
*/
import { computed, nextTick, ref, watch } from 'vue'
import { firstPort } from '@shared/inbounds.ts'
import type { Inbound } from '@shared/inbounds.ts'
import {
autoGroupName,
@@ -29,9 +30,12 @@ import { isChainedLine, kernel, linesFrom, membersOf, providers, runtime } from
import { mergeMemberPreview } from '../member-preview'
import type { MemberPreview } from '../member-preview'
import OutboundCard from '../components/OutboundCard.vue'
import ReachDialog from '../components/ReachDialog.vue'
import SourcePicker from '../components/SourcePicker.vue'
import IconEdit from '~icons/ri/edit-line'
import IconReach from '~icons/ri/test-tube-line'
import IconDelete from '~icons/ri/delete-bin-line'
import IconUpgrade from '~icons/ri/arrow-up-circle-line'
import IconRight from '~icons/ri/arrow-right-s-line'
import IconOffline from '~icons/ri/cloud-off-line'
import IconAdd from '~icons/ri/add-line'
@@ -61,6 +65,8 @@ const props = defineProps<{
const emit = defineEmits<{
edit: []
remove: []
/** 把 legacy 入站升级成命名入站。写配置文件,由父组件先弹确认 */
upgrade: []
/** 切换某个策略组当前选中谁 */
select: [group: string, member: string]
test: [name: string, provider: string]
@@ -90,9 +96,27 @@ const lanMissing = computed(() => missingLanRules(props.subRules).length)
*/
const subMatchAt = computed(() => firstMatchIndex(props.subRules))
/** 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。 */
/** legacy 入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删,只能升级。 */
const locked = computed(() => props.inbound.isDefault)
/**
* 可访问性测试走本机的哪个端口,测不了就是 null。
*
* 服务端拿这个端口当 HTTP 代理去访问目标,所以只有说 HTTP 的入站可以测;
* 纯 socks 端口这条路走不通(同公网 IP 检测,server/host.ts)。
*/
const reachPort = computed(() =>
props.inbound.type === 'http' || props.inbound.type === 'mixed'
? firstPort(props.inbound.port)
: null,
)
/**
* 对话框留在这里而不是像别的对话框那样提到 RouterPage:它不写配置文件也不改内核状态,
* 不需要经过父组件那条 commit 链路,提上去只会多出一组只为传端口而存在的 props。
*/
const reachOpen = ref(false)
const activeTab = ref('')
watch(
() => props.panel.groups,
@@ -282,7 +306,9 @@ watch([() => props.inbound.id, () => JSON.stringify(props.setup)], reset, { imme
const chainOn = computed({
get: () => form.value.chain !== null,
set: (on: boolean) => {
form.value.chain = on ? { sources: [...props.subscriptions], filter: null } : null
// 入口订阅不预设:入口是「先落到哪一跳」,用户心里有具体的那一条,
// 把订阅全给上只是让他保存前先去取消勾选
form.value.chain = on ? { sources: [], filter: null } : null
},
})
@@ -423,9 +449,15 @@ const flowPreview = computed(
pendingEntry.value !== null,
)
/**
* 至少要选一个订阅。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,一个订阅都不选就是空组,
* 内核会直接拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119)。
* 在这里拦住,比让用户按下保存再读一段内核报错好。
* 两边都至少要选一个订阅。
*
* 入口组只有 `use:` 没有点名成员,一个订阅都不选就是空组,内核会直接拒绝整份配置
* (内核源码 adapter/outboundgroup/parser.go:117-119)。在这里拦住,比让用户按下
* 保存再读一段内核报错好。
*
* 代理这边空来源是能落盘的(applySetup 只建 SELECT、成员只有 DIRECT,新增入站就停在
* 这个状态),但那是「还没配」的起点,不该是取消勾选保存出来的结果——一路已经在走代理
* 的入站不能这么悄悄变成直连。
*/
const filled = (spec: { sources: string[] }) => spec.sources.length > 0
const complete = computed(
@@ -448,11 +480,19 @@ const complete = computed(
<div class="title">
<span class="port mono">{{ inbound.port }}</span>
<span class="name">{{ inbound.name }}</span>
<div v-if="!locked" class="acts">
<div class="acts">
<button v-if="reachPort !== null" type="button" @click="reachOpen = true">
<IconReach /> 测试
</button>
<template v-if="!locked">
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
<button type="button" class="danger" @click="emit('remove')">
<IconDelete /> 删除
</button>
</template>
<button v-else type="button" :disabled="saving" @click="emit('upgrade')">
<IconUpgrade /> 改为命名入站
</button>
</div>
</div>
@@ -578,7 +618,8 @@ const complete = computed(
</h3>
<!-- 草稿态:显示保存后的成员。现有成员点了立刻生效,保存后才加入的点了是预选 -->
<div v-if="chainDraft" class="grid">
<p v-if="chainDraft && form.chain.sources.length === 0" class="hint">还没有选订阅</p>
<div v-else-if="chainDraft" class="grid">
<OutboundCard
v-for="card in chainCards"
:key="card.name"
@@ -785,6 +826,15 @@ const complete = computed(
</template>
</div>
</Transition>
<!-- 放在 Transition 外面:切换入站时详情整块换掉,对话框不该跟着淡入淡出 -->
<ReachDialog
v-if="reachPort !== null"
:open="reachOpen"
:name="inbound.name"
:port="reachPort"
@close="reachOpen = false"
/>
</aside>
</template>
@@ -842,16 +892,21 @@ const complete = computed(
cursor: pointer;
}
.acts button:hover {
.acts button:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.acts button.danger:hover {
.acts button.danger:hover:not(:disabled) {
border-color: var(--c-danger);
color: var(--c-danger);
}
.acts button:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.facts {
display: grid;
grid-template-columns: auto 1fr;
+51 -2
View File
@@ -22,6 +22,7 @@ import {
sharedPeers,
syncGenerated,
readSetup,
upgradeLegacyInbound,
userProviders,
} from '@shared/inbound-groups.ts'
import { missingLanRules, moveRule, ruleProvidersOf } from '@shared/rules.ts'
@@ -53,12 +54,22 @@ const emit = defineEmits<{ saved: []; 'focus-done': [] }>()
const inbounds = computed(() => collectInbounds(props.data))
const subscriptions = computed(() => userProviders(props.data))
/** 刚接管一路入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。 */
/**
* 接管一路已有入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。
* 来源不留空,是因为这一路本来就在把流量送到某个出站上,接管不该顺手把它改成直连。
*/
const defaultSetup = (): OutboundSetup => ({
proxy: { sources: [...subscriptions.value], filter: null },
chain: null,
})
/**
* 新增入站时给它什么:一个订阅都不预设。全新的端口还没有人在用,替用户挑一批订阅
* 只会让他保存完再回来取消勾选。来源为空的专属组只有 DIRECT(见 applySetup),
* 用户在详情页的配置里选好订阅再保存。
*/
const emptySetup = (): OutboundSetup => ({ proxy: { sources: [], filter: null }, chain: null })
const selectedId = ref<string | null>(null)
const saving = ref(false)
const fields = ref<ListenerFields | null>(null)
@@ -177,6 +188,30 @@ async function adopt() {
await commit(applySetup(clone(), target.name, defaultSetup()), `${target.name} 已改为逐条选线路`)
}
/** 待确认升级的那一路 legacy 入站。非 null 时确认对话框打开着。 */
const upgrading = ref<Inbound | null>(null)
function askUpgrade() {
const target = selected.value
if (!target || target.legacyKey === null || saving.value) return
upgrading.value = target
}
async function upgradeConfirmed() {
const target = upgrading.value
upgrading.value = null
if (!target || saving.value) return
const next = upgradeLegacyInbound(clone(), target)
if (!next) {
toast(`已有名为 ${target.name} 的入站,无法升级`, { kind: 'error' })
return
}
// 新入站追加在 listeners 末尾,升级成功后把选中跟过去,否则选中会被重置到第一行
const index = Array.isArray(props.data.listeners) ? props.data.listeners.length : 0
const ok = await commit(next, `${target.name} 已改为命名入站`)
if (ok) selectedId.value = `listener:${String(index)}`
}
function openAdd() {
formEditing.value = null
formOpen.value = true
@@ -200,7 +235,7 @@ async function submitForm(entry: ConfigObject) {
// 新增的入站直接进专属组模式。否则新建完看到的是一块「这一路不由 Patchbay 管理」
// 的提示,还得再点一次才能用。
const name = String(entry.name)
const withGroup = editingIndex === null ? applySetup(next, name, defaultSetup()) : next
const withGroup = editingIndex === null ? applySetup(next, name, emptySetup()) : next
const ok = await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存')
// 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了
@@ -368,6 +403,7 @@ async function removeConfirmed() {
:saving="saving"
@edit="openEdit"
@remove="askRemove"
@upgrade="askUpgrade"
@select="selectMember"
@test="test"
@save="saveSetup"
@@ -400,6 +436,19 @@ async function removeConfirmed() {
@confirm="removeConfirmed"
/>
<ConfirmDialog
:open="upgrading !== null"
:message="
upgrading
? `将「${upgrading.name}」改为命名入站?端口不变,此后它的流量改查自己的附加规则` +
`(全局规则中 MATCH 之前的部分会复制过来),可以单独选订阅和线路。`
: ''
"
action="升级"
@close="upgrading = null"
@confirm="upgradeConfirmed"
/>
<RuleForm
:open="subFormOpen"
:editing="subEditing === null ? null : { index: subEditing, raw: subRules[subEditing]! }"
+70 -16
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
/**
* 选中那个订阅的详情:地址、更新间隔、健康检查,和它带来的线路。
* 选中那个订阅的详情:地址、更新间隔,和它带来的线路。
*
* 健康检查不在字段区展示,只是右上角一个开关动作:默认就是开着的,
* 值得占一行的只有「被谁关掉了」这件事,而按钮文案本身已经说明了状态。
*
* 线路列表只读。这些线路不在内核的全局名字表里,不能被入站直接引用,
* 只能经策略组的 `use:` 整批引入,所以这里没有「换线」可点。
@@ -13,6 +16,9 @@ import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line'
import IconRefresh from '~icons/ri/refresh-line'
import IconOffline from '~icons/ri/cloud-off-line'
import IconPulse from '~icons/ri/pulse-line'
import IconSync from '~icons/ri/loop-right-line'
import IconLoad from '~icons/ri/loader-4-line'
const props = defineProps<{
subscription: Subscription
@@ -21,6 +27,8 @@ const props = defineProps<{
online: boolean
/** 正在等内核重新拉取 */
refreshing: boolean
/** 正在等内核测完这个订阅的线路 */
checking: boolean
/** 配置正在保存 */
saving: boolean
}>()
@@ -30,6 +38,10 @@ const emit = defineEmits<{
remove: []
/** 让内核立刻重新拉取。运行时动作,不写配置文件 */
refresh: []
/** 让内核立刻测一遍这个订阅的线路。运行时动作,不写配置文件 */
check: []
/** 开关健康检查。写配置文件,走保存链路 */
toggleHealthCheck: []
}>()
const lines = computed(() => props.info?.proxies ?? [])
@@ -48,15 +60,13 @@ function delayClass(ms: number | null): string {
<div class="title">
<span class="name">{{ subscription.name }}</span>
<div class="acts">
<button type="button" :disabled="saving" @click="emit('toggleHealthCheck')">
<IconPulse /> {{ subscription.healthCheck ? '关闭健康检查' : '开启健康检查' }}
</button>
<button type="button" :disabled="!online || refreshing" @click="emit('refresh')">
<IconRefresh /> {{ refreshing ? '更新中' : '更新订阅' }}
</button>
<button
type="button"
:disabled="saving || subscription.type === 'file'"
:title="subscription.type === 'file' ? '文件导入的订阅没有可编辑的字段' : undefined"
@click="emit('edit')"
>
<button type="button" :disabled="saving" @click="emit('edit')">
<IconEdit /> 编辑
</button>
<button type="button" class="danger" :disabled="saving" @click="emit('remove')">
@@ -78,19 +88,23 @@ function delayClass(ms: number | null): string {
{{ subscription.interval !== null ? `每 ${subscription.interval} 秒` : '已关闭' }}
</dd>
</template>
<dt>健康检查</dt>
<dd class="mono">
<template v-if="subscription.healthCheck">
{{ subscription.healthCheck.url
}}{{ subscription.healthCheck.interval !== null ? `,每 ${subscription.healthCheck.interval} 秒` : '' }}
</template>
<template v-else>未开启</template>
</dd>
</dl>
</header>
<section class="block">
<h3>线路</h3>
<h3>
线路
<button
type="button"
class="check"
:title="checking ? '检查中' : '检查全部线路的延迟'"
:disabled="!online || checking || lines.length === 0"
@click="emit('check')"
>
<IconLoad v-if="checking" class="spin" />
<IconSync v-else />
</button>
</h3>
<p v-if="!online" class="hint"><IconOffline /> 内核未连接</p>
<p v-else-if="lines.length === 0" class="hint">这个订阅现在没有线路</p>
@@ -199,6 +213,8 @@ function delayClass(ms: number | null): string {
}
h3 {
display: flex;
align-items: center;
font-size: var(--t-sm);
font-weight: 600;
color: var(--c-text);
@@ -206,6 +222,38 @@ h3 {
margin-bottom: var(--s-3);
}
.check {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
width: 22px;
height: 22px;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.check:hover:not(:disabled) {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.check:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.hint {
display: flex;
align-items: center;
@@ -221,6 +269,12 @@ h3 {
padding: 0;
max-height: 420px;
overflow-y: auto;
/* 细杆加透明轨道,别让系统滚动条压在延迟列上;gutter 常驻,
线路数跨过滚动阈值时右缘不横跳 */
scrollbar-width: thin;
scrollbar-color: var(--c-border-strong) transparent;
scrollbar-gutter: stable;
padding-right: var(--s-2);
}
.row {
+11 -7
View File
@@ -3,8 +3,8 @@
* 新增或编辑一个订阅。
*
* 原生 <dialog>,同 InboundForm。缓存路径不写进表单:它由名字自动生成,
* 让用户填只会填出和链式代理对不上的路径。订阅不能改名——名字被策略组的 `use:`
* 和生成的副本引用,改名要同步所有引用,所以编辑时名字只是静态展示,想换名就删了重建。
* 让用户填只会填出和链式代理对不上的路径。编辑时名字也能改,引用同步和
* 副本重建由保存链路负责(renameSubscription + syncGenerated)。
*
* 新增时地址之外还能从文件导入:选了文件就是 file 类型的订阅,没有地址也没有
* 自动更新(内核靠文件监视跟进变化),相关字段随之收起。
@@ -28,7 +28,7 @@ const props = defineProps<{
const emit = defineEmits<{
close: []
/** 编辑时 name 是被编辑那个订阅的原名。file 非 null 表示从文件导入 */
/** 编辑时 name 和原名不同就是改名。file 非 null 表示从文件导入 */
submit: [
draft: {
name: string
@@ -66,6 +66,8 @@ watch(
const nameError = computed(() => {
const n = name.value.trim()
if (n === '') return ''
// 编辑时名字没动不算重名,takenNames 里有它自己
if (n === props.editing?.name) return ''
// 带这个前缀的会被当成 Patchbay 生成物:列表里不显示,pruneGenerated 还会把它清掉
if (n.startsWith(GENERATED_PREFIX)) return `名称不能以 ${GENERATED_PREFIX} 开头`
if (props.takenNames.includes(n)) return '名称已存在'
@@ -80,8 +82,9 @@ const intervalInvalid = computed(() => {
const canSubmit = computed(() => {
if (props.saving) return false
if (props.editing === null && (name.value.trim() === '' || nameError.value !== '')) return false
if (name.value.trim() === '' || nameError.value !== '') return false
if (file.value !== null) return true
if (props.editing?.type === 'file') return true
return url.value.trim() !== '' && !intervalInvalid.value
})
@@ -92,7 +95,7 @@ function onFile(fileName: string, content: string) {
function submit() {
if (!canSubmit.value) return
emit('submit', {
name: props.editing?.name ?? name.value.trim(),
name: name.value.trim(),
url: url.value.trim(),
interval: autoUpdate.value ? Number(interval.value.trim()) : null,
file: file.value,
@@ -130,7 +133,7 @@ function onBackdrop(event: MouseEvent) {
</header>
<div class="body">
<label v-if="!editing" class="row">
<label class="row">
<span class="k">名称</span>
<span class="v">
<input v-model="name" type="text" placeholder="subs" autocomplete="off" />
@@ -138,7 +141,8 @@ function onBackdrop(event: MouseEvent) {
</span>
</label>
<template v-if="file === null">
<!-- file 类型没有地址和自动更新,编辑它只有名称可改 -->
<template v-if="file === null && editing?.type !== 'file'">
<label class="row">
<span class="k">地址</span>
<span class="v">