新增 admin「渠道监控」模块(参考 BingZi-233/check-cx),独立于现有 Channel 体系。
admin 配置 + 后台定时调用上游 LLM chat completions 健康检查 + 所有登录用户只读可见。
后端:
- ent: channel_monitor + channel_monitor_history(AES-256-GCM 加密 api_key)
- service 按职责拆分:service/aggregator/validate/checker/runner/ssrf
- provider strategy map 替代 switch(openai/anthropic/gemini)
- repository batch 聚合(ListLatestForMonitorIDs + ComputeAvailabilityForMonitors)消除 N+1
- runner: ticker(5s) + pond worker pool(5) + inFlight 防并发 + TrySubmit 防雪崩
+ 凌晨 3 点 cron 清理 30 天历史
- SSRF 防护:强制 https + 私网/loopback/云元数据 IP 拒绝(127/8、10/8、172.16/12、
192.168/16、169.254/16、100.64/10、::1、fc00::/7、fe80::/10)+ DialContext
在 socket 层防 DNS rebinding
- API key sanitize:擦除 url.Error 与上游响应 body 中的 sk-/sk-ant-/AIza/JWT 模式
- APIKeyDecryptFailed 标志位 + 单 monitor 路径检测,避免空 key 调用上游
handler:
- admin: CRUD + 手动触发 + 历史接口(api_key 脱敏)
- user: 只读列表 + 状态详情(去除 api_key/endpoint)
- ParseChannelMonitorID 共用 + dto.ChannelMonitorExtraModelStatus 共用
前端:
- 路由 /admin/channels/{pricing,monitor} + /monitor(用户只读)
- AppSidebar 父项 expandOnly 支持
- ChannelMonitorView 拆为 8 个子组件 + ChannelStatusView 拆出 detail dialog
- composables/useChannelMonitorFormat + constants/channelMonitor 共享
- i18n monitorCommon namespace 消除 admin/user 两 view 重复
合规:所有文件符合 CLAUDE.md(Go ≤ 500 行 / Vue ≤ 300 行 / 函数 ≤ 30 行)
CI: go build / gofmt / golangci-lint(0 issues) / make test-unit / pnpm build 全绿
153 lines
4.4 KiB
Go
153 lines
4.4 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"net"
|
||
"strings"
|
||
)
|
||
|
||
// SSRF 防护 helper:
|
||
// - validateEndpoint 在 admin 提交时阻止 http/loopback/私网/云元数据 URL
|
||
// - safeDialContext 在 socket 层再次校验真实 IP,防止 DNS rebinding
|
||
//
|
||
// 已知 cloud metadata hostname 拒绝列表(小写比较)。
|
||
var monitorBlockedHostnames = map[string]struct{}{
|
||
"localhost": {},
|
||
"localhost.localdomain": {},
|
||
"metadata": {},
|
||
"metadata.google.internal": {},
|
||
"metadata.goog": {},
|
||
"instance-data": {},
|
||
"instance-data.ec2.internal": {},
|
||
}
|
||
|
||
// CIDR 列表:包含所有需要拒绝的 IPv4/IPv6 段。
|
||
// 解析时只 panic 一次(启动时确认),生产路径只做 Contains。
|
||
var monitorBlockedCIDRs = mustParseCIDRs([]string{
|
||
"127.0.0.0/8", // IPv4 loopback
|
||
"10.0.0.0/8", // RFC1918
|
||
"172.16.0.0/12", // RFC1918
|
||
"192.168.0.0/16", // RFC1918
|
||
"169.254.0.0/16", // link-local(含云元数据 169.254.169.254)
|
||
"100.64.0.0/10", // CGNAT
|
||
"0.0.0.0/8", // "this network"
|
||
"::1/128", // IPv6 loopback
|
||
"fc00::/7", // IPv6 ULA
|
||
"fe80::/10", // IPv6 link-local
|
||
"::/128", // IPv6 unspecified
|
||
})
|
||
|
||
// monitorDialer 共享 Dialer,与 net/http 默认值对齐。
|
||
var monitorDialer = &net.Dialer{
|
||
Timeout: monitorDialTimeout,
|
||
KeepAlive: monitorDialKeepAlive,
|
||
}
|
||
|
||
// mustParseCIDRs 在包初始化时解析 CIDR 字符串,失败 panic。
|
||
func mustParseCIDRs(cidrs []string) []*net.IPNet {
|
||
out := make([]*net.IPNet, 0, len(cidrs))
|
||
for _, c := range cidrs {
|
||
_, n, err := net.ParseCIDR(c)
|
||
if err != nil {
|
||
panic("channel_monitor_ssrf: invalid CIDR " + c + ": " + err.Error())
|
||
}
|
||
out = append(out, n)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// isBlockedHostname 判断 hostname 是否命中黑名单。
|
||
func isBlockedHostname(hostname string) bool {
|
||
if hostname == "" {
|
||
return true
|
||
}
|
||
_, blocked := monitorBlockedHostnames[strings.ToLower(hostname)]
|
||
return blocked
|
||
}
|
||
|
||
// isPrivateIP 判断 IP 是否落在禁止段(loopback/RFC1918/link-local/ULA 等)。
|
||
func isPrivateIP(ip net.IP) bool {
|
||
if ip == nil {
|
||
return true
|
||
}
|
||
if ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
|
||
return true
|
||
}
|
||
for _, n := range monitorBlockedCIDRs {
|
||
if n.Contains(ip) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isPrivateOrLoopbackHost 解析 hostname 的所有 A/AAAA 记录,
|
||
// 任一 IP 落在私网/loopback 段即认为不安全。
|
||
//
|
||
// hostname 是 IP 字面量时也走同一路径。
|
||
func isPrivateOrLoopbackHost(ctx context.Context, hostname string) (bool, error) {
|
||
if isBlockedHostname(hostname) {
|
||
return true, nil
|
||
}
|
||
// IP 字面量直接判断。
|
||
if ip := net.ParseIP(hostname); ip != nil {
|
||
return isPrivateIP(ip), nil
|
||
}
|
||
resolver := net.DefaultResolver
|
||
addrs, err := resolver.LookupIPAddr(ctx, hostname)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if len(addrs) == 0 {
|
||
return true, nil
|
||
}
|
||
for _, a := range addrs {
|
||
if isPrivateIP(a.IP) {
|
||
return true, nil
|
||
}
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
// safeDialContext 在真实 dial 前再次校验目标 IP,防止 DNS rebinding。
|
||
// 解析 hostname 后逐个 IP 尝试连接,命中私网即拒绝(即便 validateEndpoint 时返回的是公网 IP)。
|
||
func safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||
host, port, err := net.SplitHostPort(address)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 字面量 IP 走快速路径。
|
||
if ip := net.ParseIP(host); ip != nil {
|
||
if isPrivateIP(ip) {
|
||
return nil, &net.AddrError{Err: "blocked by SSRF policy", Addr: address}
|
||
}
|
||
return monitorDialer.DialContext(ctx, network, address)
|
||
}
|
||
if isBlockedHostname(host) {
|
||
return nil, &net.AddrError{Err: "blocked by SSRF policy", Addr: address}
|
||
}
|
||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(addrs) == 0 {
|
||
return nil, &net.AddrError{Err: "no addresses for host", Addr: host}
|
||
}
|
||
var lastErr error
|
||
for _, a := range addrs {
|
||
if isPrivateIP(a.IP) {
|
||
lastErr = &net.AddrError{Err: "blocked by SSRF policy", Addr: a.IP.String()}
|
||
continue
|
||
}
|
||
conn, err := monitorDialer.DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port))
|
||
if err == nil {
|
||
return conn, nil
|
||
}
|
||
lastErr = err
|
||
}
|
||
if lastErr == nil {
|
||
lastErr = &net.AddrError{Err: "no usable addresses", Addr: host}
|
||
}
|
||
return nil, lastErr
|
||
}
|