单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
6.6 KiB
Go
185 lines
6.6 KiB
Go
// subscription.go:多订阅容灾拉取 + TTL 缓存 + stale-on-error(design §5.1)。
|
||
//
|
||
// 安全边界(与 bench/proxy/lib.py / up.sh 对齐):
|
||
// - 仅 http/https、超时 45s、UA clash-meta/browser-server、capped read 8MB;
|
||
// - 订阅 URL 只进环境变量,解析文本只在内存;URL 与凭据永不入日志。
|
||
package proxymanager
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// 订阅拉取参数(design §5.1 / up.sh 同值)。
|
||
const (
|
||
subFetchTimeout = 45 * time.Second
|
||
subMaxBytes = 8 * 1024 * 1024 // 8MB capped read 防 OOM
|
||
subUA = "clash-meta/browser-server"
|
||
subTTLCache = 10 * time.Minute
|
||
)
|
||
|
||
// subClient 订阅拉取客户端(独立于 httpx:订阅是机场站点非目标站,
|
||
// 不走 Chrome UA 与 Content-Type 白名单)。
|
||
type subClient struct {
|
||
hc *http.Client
|
||
}
|
||
|
||
func newSubClient() *subClient {
|
||
hc := &http.Client{
|
||
Timeout: subFetchTimeout,
|
||
Transport: &http.Transport{
|
||
TLSHandshakeTimeout: 10 * time.Second,
|
||
ResponseHeaderTimeout: subFetchTimeout,
|
||
MaxIdleConns: 4,
|
||
IdleConnTimeout: 60 * time.Second,
|
||
},
|
||
// 不跟随跨协议跳转;订阅源应直接给最终地址(up.sh 用 curl -L 语义)。
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
if len(via) >= 5 {
|
||
return errors.New("subscription: 重定向超 5 跳")
|
||
}
|
||
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||
return errors.New("subscription: 仅允许 http/https 跳转")
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
// 订阅拉取专用 bootstrap 出口(非密钥;缺省空=直连)。2026-09-02 实测定时
|
||
// mgr1 直连机场订阅被墙(Mac 可通),复用 mgr3 central-proxy :7890 作
|
||
// bootstrap(deploy-preset §1 扩展期布局预告);探活仍走自家 mihomo,
|
||
// 此代理仅用于订阅拉取一跳,不污染其它出站。
|
||
if proxyURL := os.Getenv("BROWSER_SERVER_SUB_FETCH_PROXY"); proxyURL != "" {
|
||
if u, err := url.Parse(proxyURL); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
|
||
hc.Transport.(*http.Transport).Proxy = http.ProxyURL(u)
|
||
}
|
||
}
|
||
return &subClient{hc: hc}
|
||
}
|
||
|
||
// fetch 拉取单个订阅 URL:校验 scheme → GET → capped read。
|
||
func (s *subClient) fetch(ctx context.Context, rawURL string) ([]byte, error) {
|
||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||
return nil, fmt.Errorf("subscription: URL 必须为 http(s):已拒绝")
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("subscription: 请求构造失败: %w", err)
|
||
}
|
||
req.Header.Set("User-Agent", subUA)
|
||
req.Header.Set("Accept", "*/*")
|
||
resp, err := s.hc.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("subscription: 拉取失败(URL 不记日志): %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("subscription: HTTP %d", resp.StatusCode)
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, subMaxBytes+1))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("subscription: 读取失败: %w", err)
|
||
}
|
||
if int64(len(body)) > subMaxBytes {
|
||
return nil, fmt.Errorf("subscription: 响应超上限")
|
||
}
|
||
return body, nil
|
||
}
|
||
|
||
// subscriptionCache 多订阅容灾 + TTL 缓存 + stale-on-error。
|
||
//
|
||
// 容灾语义:按 PROXY_SUB_URLS 顺序逐个拉取,第一个成功者生效;
|
||
// 全部失败时若有 stale 缓存则续用(stale-on-error),否则 fail-closed。
|
||
type subscriptionCache struct {
|
||
mu sync.Mutex
|
||
client *subClient
|
||
urls []string
|
||
text []byte // 最近一次成功正文(stale-on-error 备份)
|
||
fetched time.Time // 正文拉取时刻
|
||
stale bool // 当前正文是否已过 TTL(stale 状态)
|
||
lastErrs []string // 最近一轮各订阅失败摘要(不含 URL)
|
||
}
|
||
|
||
func newSubscriptionCache(urls []string) *subscriptionCache {
|
||
return &subscriptionCache{client: newSubClient(), urls: urls}
|
||
}
|
||
|
||
// Get 返回订阅正文;fresh(TTL 内)直接用缓存;
|
||
// 过期则重拉(全部失败时回退 stale 正文,stale=true);
|
||
// 无任何正文且失败 → fail-closed 错误。
|
||
//
|
||
// 脱敏:错误摘要只保留序号 + 首行原因;Go http.Client 错误会内嵌完整
|
||
// URL(含 query token),必须剥离,订阅 URL 永不出本类型。
|
||
func (c *subscriptionCache) Get(ctx context.Context) (text []byte, stale bool, err error) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if c.text != nil && time.Since(c.fetched) < subTTLCache {
|
||
return c.text, false, nil
|
||
}
|
||
var lastErrs []string
|
||
for _, u := range c.urls {
|
||
body, ferr := c.client.fetch(ctx, u)
|
||
if ferr == nil {
|
||
c.text, c.fetched, c.stale, c.lastErrs = body, time.Now(), false, nil
|
||
return c.text, false, nil
|
||
}
|
||
lastErrs = append(lastErrs, sanitizeSubErr(ferr, len(lastErrs)+1))
|
||
}
|
||
c.lastErrs = lastErrs
|
||
if c.text != nil {
|
||
c.stale = true
|
||
return c.text, true, nil
|
||
}
|
||
return nil, false, fmt.Errorf("subscription: 全部订阅拉取失败且无缓存(fail-closed): %s",
|
||
strings.Join(lastErrs, "; "))
|
||
}
|
||
|
||
// sanitizeSubErr 失败摘要脱敏:仅保留错误类型词与订阅序号,
|
||
// 剥离 URL/网络细节(含 Go 标准库内嵌的完整 URL 与 token)。
|
||
func sanitizeSubErr(err error, idx int) string {
|
||
msg := err.Error()
|
||
switch {
|
||
case strings.Contains(msg, "context deadline exceeded"), strings.Contains(msg, "Client.Timeout"):
|
||
return fmt.Sprintf("订阅#%d 失败: 超时", idx)
|
||
case strings.Contains(msg, "HTTP "):
|
||
// 仅保留状态码
|
||
i := strings.Index(msg, "HTTP ")
|
||
code := msg[i+5:]
|
||
if len(code) > 3 {
|
||
code = code[:3]
|
||
}
|
||
return fmt.Sprintf("订阅#%d 失败: HTTP %s", idx, strings.TrimSpace(code))
|
||
case strings.Contains(msg, "超上限"):
|
||
return fmt.Sprintf("订阅#%d 失败: 响应超 8MB 上限", idx)
|
||
case strings.Contains(msg, "connection refused"), strings.Contains(msg, "no such host"),
|
||
strings.Contains(msg, "i/o timeout"), strings.Contains(msg, "reset"):
|
||
return fmt.Sprintf("订阅#%d 失败: 网络不可达", idx)
|
||
case strings.Contains(msg, "必须为 http(s)"):
|
||
return fmt.Sprintf("订阅#%d 失败: URL scheme 非法", idx)
|
||
case strings.Contains(msg, "重定向"):
|
||
return fmt.Sprintf("订阅#%d 失败: 重定向超限", idx)
|
||
default:
|
||
return fmt.Sprintf("订阅#%d 失败: 拉取异常", idx)
|
||
}
|
||
}
|
||
|
||
// Stale 当前缓存是否处于 stale-on-error 状态。
|
||
func (c *subscriptionCache) Stale() bool {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.stale
|
||
}
|
||
|
||
// LastErrors 最近一轮拉取失败摘要(无 URL)。
|
||
func (c *subscriptionCache) LastErrors() []string {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return append([]string(nil), c.lastErrs...)
|
||
}
|