Files
im0o 7a764f5fe1 搭出前端骨架和入站列表
设计基调定为:层次靠底色和一像素描边而不是投影,一屏最多两层容器,间距给足。
tokens.css 顶部写了这三条规则,后面的组件都是它们的推论。

入站列表把两套写法合并成一张表:老式顶层端口字段(mixed-port 这些)和 listeners 数组。
只画 listeners 的话,一台只用 mixed-port 的机器打开会是空板,而那个端口正在跑——
这等于对用户撒谎。合并逻辑在 shared/inbounds.ts,带 10 个测试。

后端顺带能提供打包后的静态文件了,一个进程就能跑起来。
2026-08-10 04:10:17 +08:00

114 lines
5.0 KiB
TypeScript
Raw Permalink 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 { copyFile, 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()
// 每次从模板重置。不重置的话第二次运行会写入和上次完全相同的内容,
// 而「内容没变就不重载」是刻意的行为,会让「内核已重载」这一项看起来失败。
await copyFile('./dev/config.yaml', env.configPath)
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(
'前端页面不需要令牌',
(await fetch(`${base}/`)).status,
200,
)
// ── 配置读写 ────────────────────────────────────────────
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)