diff --git a/README.md b/README.md index 7b8224f..f5f8ff3 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,20 @@ pnpm typecheck # 仅类型检查,不运行 `/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。 +## 调用鉴权(AUTH_KEY) + +设置环境变量 `AUTH_KEY` 后,全部业务接口要求携带调用秘钥(未设置则不鉴权): + +``` +Authorization: Bearer # 亦接受裸值 +X-Api-Key: +?key= # 查询参数(播放器直接拉流场景) +``` + +豁免路径:`/health`、`/`、`/docs`、`/redoc`、`/openapi.json`。秘钥错误返回 401。 +启用鉴权时 `/url` 返回的代理流地址(如 qs 解密流)会自动附带 `?key=`,播放器可直接拉流。 + + ## 外部音源 设置 `SOURCE_ENABLED=1` 后取流优先走外部音源(默认未设置时直接走 SDK 官方直连接口;是否启用由服务端在 `src/server.ts` 决策)。两种脚本都放 `sources/` 目录(不入库),按文件名混排成候选链:取流失败自动切换下一个,某平台连续失败 3 次的脚本降权冷却 5 分钟;整条音源链失败后回落 SDK 直连、再失败走酷我兜底(见上文取流链)。音源候选链只覆盖 kw/kg/tx/wy(音源脚本协议无 qs 平台,`/url/qs` 始终走 SDK)。音源模式下登录态不参与取流,登录自动续期不会启动(手动 `POST /login/refresh` 仍可用)。详见 [docs/source-ivm.md](docs/source-ivm.md) 与 [docs/source-simple.md](docs/source-simple.md)。 diff --git a/src/http/auth-gate.ts b/src/http/auth-gate.ts new file mode 100644 index 0000000..cb90bf9 --- /dev/null +++ b/src/http/auth-gate.ts @@ -0,0 +1,40 @@ +// 全局调用秘钥鉴权 —— AUTH_KEY 环境变量启用(未设置则完全不鉴权) +// 客户端携带方式(任一即可): +// Authorization: Bearer (也接受裸值不带 Bearer 前缀) +// X-Api-Key: +// ?key=(查询参数,兼容播放器/下载器直接拉流无法带自定义头的场景) +// 豁免:/health、文档(/、/docs*、/redoc、/openapi.json)与 CORS 预检 +import crypto from 'node:crypto' +import type { FastifyRequest, FastifyReply } from 'fastify' + +// 无敏感信息且会被监控系统高频探测的路径豁免鉴权 +const PUBLIC_PATHS = [/^\/health$/, /^\/$/, /^\/docs(\/|$)/, /^\/redoc$/, /^\/openapi\.json$/] + +const safeEqual = (a: string, b: string) => { + const bufA = Buffer.from(a) + const bufB = Buffer.from(b) + return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB) +} + +export const registerAuth = (fastify: any, authKey: string) => { + fastify.addHook('onRequest', async(request: FastifyRequest, reply: FastifyReply) => { + if (request.method === 'OPTIONS') return + const pathname = new URL(request.url, 'http://localhost').pathname + if (PUBLIC_PATHS.some(re => re.test(pathname))) return + + const authorization = String(request.headers.authorization || '') + const bearer = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : authorization.trim() + const provided = [ + bearer, + String(request.headers['x-api-key'] || ''), + String((request.query as any)?.key || ''), + ].find(Boolean) + + if (!provided || !safeEqual(provided, authKey)) { + return reply.code(401).send({ + code: 401, + msg: '无效或缺少调用秘钥:请在 Authorization: Bearer 、X-Api-Key 头或 ?key= 参数中携带', + }) + } + }) +} diff --git a/src/routes.ts b/src/routes.ts index 5ef73f3..487917c 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -136,9 +136,10 @@ export const musicRoutes = async(fastify: any) => { // 取流链:外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底(跨平台匹配) const finish = (res: any, via: string) => { if (!res?.url) throw new Error('获取播放地址失败') - // qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器) + // qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器); + // 启用 AUTH_KEY 时附带 ?key=(播放器直接拉流无法携带自定义头) const url = String(res.url).startsWith('/') - ? `${request.protocol}://${request.headers.host}${res.url}` + ? `${request.protocol}://${request.headers.host}${res.url}${process.env.AUTH_KEY ? `${String(res.url).includes('?') ? '&' : '?'}key=${encodeURIComponent(process.env.AUTH_KEY)}` : ''}` : res.url // type 用实际音质(kw 等通道可能降级);via 标记兜底链中真正命中的通道 return { type: res.type || quality, url, ...(via !== source ? { via } : {}) } diff --git a/src/server.ts b/src/server.ts index 462d834..7910f8b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -80,6 +80,14 @@ fastify.addHook('onRoute', (routeOptions) => { fastify.register(cors, { origin: '*' }) +// ---- 调用秘钥(AUTH_KEY 环境变量启用;未设置则不鉴权)---- +// 详见 src/http/auth-gate.ts;文档/健康检查豁免,业务接口要求 Bearer / X-Api-Key / ?key= +const authKey = process.env.AUTH_KEY +if (authKey) { + const { registerAuth } = await import('./http/auth-gate.ts') + registerAuth(fastify, authKey) +} + fastify.addHook('onRequest', async(_request, reply) => { reply.header('Cache-Control', 'no-store') }) @@ -204,6 +212,7 @@ if (!useSource) { export const startServer = async(port = PORT) => { await fastify.listen({ port: Number(port), host: '0.0.0.0' }) console.log(`音乐服务器已启动: http://localhost:${port}`) + console.log(`调用鉴权: ${authKey ? `已启用(AUTH_KEY,Bearer / X-Api-Key / ?key=)` : '未启用(设置 AUTH_KEY 环境变量开启)'}`) console.log(`取流通道: ${useSource ? '外部音源脚本(sources/,SOURCE_ENABLED)' : 'SDK 官方直连接口'}`) console.log(`接口文档: http://localhost:${port}/docs (Swagger UI)`) console.log(`ReDoc 文档: http://localhost:${port}/redoc`) diff --git a/test/smoke-test.ts b/test/smoke-test.ts index 02271a9..daa3e41 100644 --- a/test/smoke-test.ts +++ b/test/smoke-test.ts @@ -76,12 +76,12 @@ const test = async(name: string, fn: () => Promise) => { } /** - * 启动本地服务器(指定端口)并返回进程句柄 + * 启动本地服务器(指定端口,可覆盖环境变量)并返回进程句柄 */ -const startLocalServer = (port: number) => new Promise((resolve, reject) => { +const startLocalServer = (port: number, envExtra: Record = {}) => new Promise((resolve, reject) => { const proc = spawn(process.execPath, ['src/server.ts'], { cwd: process.cwd(), - env: { ...process.env, PORT: String(port) }, + env: { ...process.env, PORT: String(port), ...envExtra }, stdio: ['ignore', 'pipe', 'pipe'], }) let stdout = '' @@ -94,7 +94,7 @@ const startLocalServer = (port: number) => new Promise((resolve, reject) => { }, 10000) const check = () => { - http.get(`${BASE}/health`, res => { + http.get(`http://localhost:${port}/health`, res => { res.resume() if (res.statusCode === 200) { clearTimeout(timer) @@ -608,6 +608,54 @@ await test('登录状态含 xm 字段', async() => { return `xm.logged=${r.data.xm.logged}` }) +// ---- 调用秘钥鉴权(独立实例带 AUTH_KEY 起测;主实例未设 key 时本组自动跳过)---- +if (!externalServer && !process.env.AUTH_KEY) { + const AUTH_TEST_KEY = 'smoke-test-secret-key' + const authPort = await new Promise(resolve => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const p = (srv.address() as any).port + srv.close(() => resolve(p)) + }) + }) + const authBase = `http://localhost:${authPort}` + const authServer: any = await startLocalServer(authPort, { AUTH_KEY: AUTH_TEST_KEY }) + const authFetch = (path: string, init: RequestInit = {}) => fetch(authBase + path, init) + + await test('AUTH_KEY 无秘钥 → 401', async() => { + const r = await authFetch('/sources') + if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`) + return 'ok' + }) + await test('AUTH_KEY 错误秘钥 → 401', async() => { + const r = await authFetch('/sources', { headers: { Authorization: 'Bearer wrong-key' } }) + if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`) + return 'ok' + }) + await test('AUTH_KEY Bearer 通过', async() => { + const r = await authFetch('/sources', { headers: { Authorization: `Bearer ${AUTH_TEST_KEY}` } }) + if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`) + return 'ok' + }) + await test('AUTH_KEY X-Api-Key 通过', async() => { + const r = await authFetch('/sources', { headers: { 'X-Api-Key': AUTH_TEST_KEY } }) + if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`) + return 'ok' + }) + await test('AUTH_KEY ?key= 通过', async() => { + const r = await authFetch(`/sources?key=${encodeURIComponent(AUTH_TEST_KEY)}`) + if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`) + return 'ok' + }) + await test('AUTH_KEY /health 豁免', async() => { + const r = await authFetch('/health') + if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`) + return 'ok' + }) + + authServer.kill() +} + // 汇总 const passed = results.filter(r => r.ok).length const failed = results.filter(r => !r.ok)