中间件设计
本文档基于以下源文件编写:
- internal/app/routers.go -- 中间件栈装配与路由注册
- internal/app/compress.go -- 预压缩静态资源服务
- internal/app/access_log.go -- slog 访问日志桥接
- internal/middleware/auth.go -- JWT 认证中间件
- internal/httputil/proxy.go -- 全局 HTTP 代理
- internal/httputil/basicauth.go -- URL 嵌入凭证提取
- internal/services/whitelist.go -- SSRF 防护白名单
目录
1. 中间件栈概览
Songloft 后端基于 Chi v5 路由框架,中间件在 setupBaseRouter 中按顺序注册,请求从上到下依次经过每层中间件,响应则反向返回。
┌─ 入站请求 ─┐
│ │
┌────▼────────────▼────┐
│ Compress (gzip) │ ← 响应压缩
├──────────────────────┤
│ AccessLog (slog) │ ← 结构化访问日志
├──────────────────────┤
│ Tracely Panic 上报 │ ← panic 异常上报
├──────────────────────┤
│ Recoverer │ ← panic 恢复,返回 500
├──────────────────────┤
│ RequestID │ ← 注入唯一请求 ID
├──────────────────────┤
│ CORS │ ← 跨域策略校验
└────────┬─────────────┘
│
┌─────────────┼─────────────┐
│ │ │
公开路由 认证路由组 插件路由组
(login/health) (AuthMiddleware) (AuthMiddleware
+ PublicPathChecker)全局中间件对所有请求生效;AuthMiddleware 仅在需要认证的路由组中通过 r.Group + r.Use 注入。
章节来源
- internal/app/routers.go:237-349 --
setupBaseRouter中间件注册顺序
2. 压缩中间件
压缩分为两层:Chi 内置 gzip 中间件处理动态响应,precompressedFS 处理构建时已预压缩的静态资源。
2.1 Chi gzip 中间件
通过 chi_middleware.Compress(5, ...) 注册,压缩等级 5(速度与压缩率平衡),仅对以下 MIME 类型生效:
text/html、text/css、text/plain、text/javascript、application/javascript、application/json、application/wasm、image/svg+xml、font/otf
音频文件(audio/*)和已压缩图片(image/png 等)不在列表中,避免无效压缩。
2.2 预压缩静态资源(precompressedFS)
构建时若安装了 brotli CLI,Makefile 会为前端静态资源生成 .br 和 .gz 预压缩文件。newPrecompressedFS 在启动时从 embed.FS 中加载这些文件到内存。
请求处理逻辑:
- 优先检查
Accept-Encoding是否包含br,命中则返回 brotli 压缩版本 - 其次检查
gzip,命中则返回 gzip 版本 - 均不支持时从 embed.FS 读取原始文件
- 预压缩缓存未命中时,fallback 到
http.FileServer+ Chi gzip 中间件
ETag 使用 CRC32 校验和生成,支持 If-None-Match 条件请求返回 304。addCustomEntry 用于运行时修改过的文件(如 base-path 注入后的 index.html)重新压缩并替换缓存。
章节来源
- internal/app/routers.go:239-249 -- Compress 中间件注册与 MIME 类型列表
- internal/app/compress.go:32-78 --
newPrecompressedFS加载逻辑 - internal/app/compress.go:106-146 --
serve方法:编码协商与 ETag
3. 访问日志
访问日志通过 slogLogFormatter 桥接到标准库 slog,替代 Chi 默认的 chi_middleware.Logger(后者直接写 log 包,不受运行时日志等级控制)。
日志字段
| 字段 | 说明 |
|---|---|
method | HTTP 方法(GET/POST 等) |
path | 请求路径 |
status | 响应状态码 |
bytes | 响应体字节数 |
dur_ms | 请求耗时(毫秒,微秒精度) |
remote | 客户端远程地址 |
request_id | Chi RequestID 中间件注入的唯一 ID(可选) |
日志级别为 Info,当管理员通过 /settings/log-level 将运行时等级调到 Warn 或 Error 时,访问日志自动静默。Panic 事件使用 Error 级别记录,附带完整调用栈。
章节来源
- internal/app/access_log.go:1-49 -- 完整实现
4. Panic 捕获与恢复
Panic 处理分为两层,注册顺序至关重要:
- Tracely 上报(外层)-- 在
defer recover中捕获 panic,向 Tracely 错误追踪服务上报ErrorPayload(包含 type、message、stack、URL),然后重新 panic - Chi Recoverer(内层)-- 捕获重新抛出的 panic,向客户端返回 500 响应,防止进程崩溃
这种设计确保异常既被上报到监控系统,又不会泄露到客户端。
章节来源
- internal/app/routers.go:255-275 -- Tracely 上报 + Recoverer 注册
5. 请求 ID
通过 chi_middleware.RequestID 为每个请求注入唯一标识符,写入请求上下文。访问日志中间件从上下文提取该 ID 写入 request_id 字段,用于分布式追踪和日志关联。
6. CORS 配置
CORS 中间件使用 go-chi/cors 包,通过 AllowOriginFunc 自定义来源校验:
| 来源规则 | 匹配范围 |
|---|---|
http://localhost:* | 本机开发(任意端口) |
http://127.0.0.1:* | 本机回环(任意端口) |
http://192.168.* / http://10.* / http://172.16.* | 局域网段(仅 HTTP) |
http(s)://hanxi.cc(:port) | 项目主域名(HTTP/HTTPS,任意端口) |
http(s)://*.hanxi.cc(:port) | 所有子域名(HTTP/HTTPS,任意端口) |
其他配置项:
- AllowedMethods: GET、HEAD、POST、PUT、DELETE、OPTIONS
- AllowedHeaders: Accept、Authorization、Content-Type
- AllowCredentials: true(允许携带 Cookie/Authorization)
- MaxAge: 300 秒(预检请求缓存 5 分钟)
章节来源
- internal/app/routers.go:279-337 -- CORS 中间件完整配置
7. JWT 认证中间件
AuthMiddleware 是路由组级别的认证守卫,保护所有需要登录的 API 端点。
7.1 Token 提取策略
按优先级从两个位置提取 JWT:
- Authorization Header --
Bearer <token>格式,标准 HTTP 认证方式 - Query Parameter 回退 --
?access_token=<token>,用于无法自定义请求头的场景(<img>标签、CachedNetworkImage、音频流 URL 等)
7.2 XiaoAi Quirk
小爱音箱固件存在一个已知缺陷:会将 URL 中的 & 替换为空格,导致 access_token 后的查询参数被合并进 token 值。中间件对此做了兼容处理:
- 检测
access_token值中是否包含空格(合法 JWT 不含空格) - 按空格拆分,第一段作为真实 token
- 后续段按
key=value格式解析,还原到r.URL.Query()中 - 重新编码
r.URL.RawQuery,保证下游 handler 拿到正确的查询参数
7.3 公开路径绕过(PublicPathChecker)
PublicPathChecker 是一个接口,实现者通过 IsPublicPath(path) bool 声明哪些路径无需认证。在中间件入口处,所有注册的 checker 依次检查请求路径,任一返回 true 则跳过 JWT 校验直接放行。
当前使用场景:JS 插件管理器实现了该接口,插件 manifest 中声明的 publicPaths(如 Subsonic /rest/* 兼容端点)在运行时注册为公开路径,支持热更新无需重启。
7.4 认证失败响应
统一返回 JSON 格式 {"error": "<message>", "detail": "<err>"} 配合 HTTP 401 状态码,符合项目 API 响应规范。
章节来源
- internal/middleware/auth.go:23-26 --
PublicPathChecker接口定义 - internal/middleware/auth.go:28-88 --
AuthMiddleware完整实现
8. 全局 HTTP 代理
internal/httputil/proxy.go 提供进程级的 HTTP 代理配置,所有通过 httputil.NewClient 创建的客户端自动使用代理。
核心组件
| 组件 | 说明 |
|---|---|
proxyConfig | 线程安全的代理 URL 持有者,sync.RWMutex 保护读写 |
sharedTransport | 全局共享的 *http.Transport,连接池配置:最大空闲连接 100、单主机最大 10、空闲超时 90 秒 |
SetGlobalProxy(rawURL) | 设置代理地址(支持 HTTP/HTTPS/SOCKS5),空字符串清除代理;调用后 CloseIdleConnections 清理旧连接 |
GetGlobalProxy() | 获取当前代理地址 |
NewClient(timeout) | 创建使用全局代理的 *http.Client |
Loopback 自动旁路
proxyFunc 在每次请求时检查目标主机名:localhost、127.0.0.1、::1 三个回环地址直接返回 nil(不走代理),避免内部请求被代理拦截。
URL Basic Auth 提取
httputil.ApplyBasicAuthFromURL 是一个辅助函数:从 req.URL.User 中提取用户名和密码,设置到 Authorization 请求头,然后清除 req.URL.User,防止凭证泄露到日志中。用于代理 URL 中嵌入认证信息的场景。
章节来源
- internal/httputil/proxy.go:1-84 -- 全局代理完整实现
- internal/httputil/basicauth.go:1-15 -- URL 凭证提取
9. SSRF 防护
services.IsHostnameAllowed 采用内网封禁策略:阻止访问所有内网地址以防止 SSRF 攻击,允许所有外网域名。该函数用于 HLS 代理等需要服务端发起外部请求的场景。
检查流程
- 主机名黑名单 --
localhost、*.local、空字符串直接拒绝 - DNS 解析 -- 对域名执行
net.LookupIP,解析失败则放行(交由后续 HTTP 请求自行报错) - IP 地址分类检查 -- 对解析出的每个 IP 逐一判断:
| 检查项 | 覆盖范围 |
|---|---|
IsLoopback | 127.0.0.0/8、::1 |
IsPrivate | 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、fc00::/7 |
IsLinkLocalUnicast/Multicast | 169.254.0.0/16、fe80::/10 |
IsUnspecified | 0.0.0.0、:: |
任一 IP 命中内网/保留地址范围即拒绝,全部通过才放行。
章节来源
- internal/services/whitelist.go:1-53 -- SSRF 防护完整实现
