Files
mihomo-patchbay/scripts/api-smoke.ts
T
im0o ada547adf7 加上 HTTP 接口层和令牌认证
接口分三类:配置读写走保护链路,内核状态和只读透传,静态字段表。
透传用白名单而不是黑名单——写操作属于运行时面板,等做到那步再单独开,
现在不留一个什么都能转发的口子。

令牌即便在只监听本机时也强制校验:同一台机器上跑着容器和别的服务,
「只监听 127.0.0.1」不等于「只有你能访问」。比较用常数时间,避免靠响应耗时猜。

scripts/api-smoke.ts 对着真服务和真内核跑 17 项检查,端到端确认过
改配置之后内核会重载、新端口真的被监听起来。
2026-08-10 03:37:49 +08:00

105 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 接口冒烟测试:对着真的服务和真的内核,把每类响应走一遍。
*
* 重点在认证和边界——这两块出错的后果比功能不好用严重得多:令牌校验写歪了等于没有认证,
* 透传白名单漏了等于把内核的全部接口暴露出去。
*
* 前置条件:
* 1. 本地测试内核在跑(见 .env.example)
* 2. Patchbay 在跑:bun run server/index.ts
*
* 用法:bun run scripts/api-smoke.ts [令牌]
* 不给令牌就从配置文件旁边的 .patchbay-token 读。
*/
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { readEnv } from '../server/env.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts'
const env = readEnv()
const base = `http://${env.host}:${String(env.port)}`
const token =
process.argv[2] ??
(await readFile(join(dirname(env.configPath), '.patchbay-token'), 'utf8')).trim()
let failures = 0
function check(name: string, actual: unknown, expected: unknown) {
if (JSON.stringify(actual) === JSON.stringify(expected)) {
console.log(`✓ ${name}`)
} else {
failures += 1
console.log(`✗ ${name}\n 期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)}`)
}
}
async function call(method: string, path: string, body?: unknown, auth = true) {
const res = await fetch(base + path, {
method,
headers: {
...(auth ? { Authorization: `Bearer ${token}` } : {}),
'Content-Type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
})
const text = await res.text()
return { status: res.status, body: text ? (JSON.parse(text) as Record<string, unknown>) : null }
}
const clone = (v: unknown) => JSON.parse(JSON.stringify(v)) as ConfigObject
// ── 认证与边界 ──────────────────────────────────────────
check('没带令牌被拒', (await call('GET', '/api/config', undefined, false)).status, 401)
check(
'令牌不对被拒',
(await fetch(`${base}/api/config`, { headers: { Authorization: 'Bearer wrong' } })).status,
401,
)
check('白名单外的透传被拒', (await call('GET', '/api/kernel/configs/geo')).status, 403)
check('不存在的接口', (await call('GET', '/api/nope')).status, 404)
check('非 api 路径', (await call('GET', '/index.html')).status, 404)
// ── 配置读写 ────────────────────────────────────────────
const snapshot = await call('GET', '/api/config')
check('读配置', snapshot.status, 200)
const data = snapshot.body!.data as ConfigObject
const revision = snapshot.body!.revision as string
const stale = clone(data)
stale.mode = 'global'
check(
'基准过时被拒',
(await call('PUT', '/api/config', { data: stale, baseRevision: '过时的指纹' })).status,
409,
)
const dangling = clone(data)
dangling.listeners = [{ name: '冒烟-断引用', type: 'mixed', port: 17899, proxy: '不存在的出站' }]
const danglingResult = await call('PUT', '/api/config', { data: dangling, baseRevision: revision })
check('断引用被自己的检查拦下', danglingResult.status, 422)
check('拦在 self-check 这一步', danglingResult.body?.stage, 'self-check')
const badType = clone(data)
badType.listeners = [{ name: '冒烟-怪类型', type: '不是合法类型', port: 17898 }]
const badTypeResult = await call('PUT', '/api/config', { data: badType, baseRevision: revision })
check('非法类型被内核拦下', badTypeResult.status, 422)
check('拦在 kernel-check 这一步', badTypeResult.body?.stage, 'kernel-check')
const good = clone(data)
good.listeners = [{ name: '冒烟-正常', type: 'mixed', port: 17896, proxy: 'PROXY' }]
const goodResult = await call('PUT', '/api/config', { data: good, baseRevision: revision })
check('正常写入', goodResult.status, 200)
check('内核已重载', goodResult.body?.reloaded, true)
// ── 其余 ────────────────────────────────────────────────
check('备份列表可读', (await call('GET', '/api/config/backups')).status, 200)
check('内核状态可读', (await call('GET', '/api/kernel')).body?.online, true)
check('内核透传可用', (await call('GET', '/api/kernel/version')).status, 200)
check(
'字段表可读',
Object.keys(((await call('GET', '/api/meta/listener-fields')).body?.types ?? {}) as object).length,
20,
)
console.log(failures === 0 ? '\n全部通过' : `\n${String(failures)} 项没通过`)
process.exit(failures === 0 ? 0 : 1)