onesvm-browser-server/server/internal/policy/robots.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

247 lines
7.8 KiB
Go
Raw 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.

// robots.go:robots.txt 缓存与 Disallow 校验(普通 key 遵守 robots,
// design-arch §5.4 域名策略行)。缓存落 SQLite robots_cache 表,TTL 24h。
package policy
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"onesvm.com/onesvm/browser-server/internal/config"
"onesvm.com/onesvm/browser-server/internal/store"
)
// RobotsTTL 缓存有效期(design 值 24h)。
const RobotsTTL = 24 * time.Hour
// RobotsChecker robots 缓存 + 校验器。
type RobotsChecker struct {
db *store.DB
cli *http.Client
redir func(ctx context.Context, rawURL string) *DeniedError // 每跳重验(ITER-3 FIX-3;可 nil)
}
// NewRobotsChecker 构造;client 超时建议 ≤10s(拉 robots 专用)。redir 非 nil 时挂
// CheckRedirect 每跳重验(ITER-3 FIX-3 接线:robots 拉取的 3xx 目标同样过 SSRF 门闩)。
func NewRobotsChecker(db *store.DB, cli *http.Client) *RobotsChecker {
return &RobotsChecker{db: db, cli: cli}
}
// SetRedirectCheck 挂每跳重验钩子(Engine 组装后注入,避免构造环依赖)。
func (r *RobotsChecker) SetRedirectCheck(fn func(ctx context.Context, rawURL string) *DeniedError) {
r.redir = fn
}
// Allowed 判断普通 key 是否可抓取 targetURL(ITER-3 FIX-4:路径级判定——
// 从 targetURL 提取 u.Path(缺省 /),matchRobots 按 UA 段 + AllowedPath 前缀规则判定)。
// 1. 查缓存(SQLite,TTL 内直接用);
// 2. 未命中拉 https://<host>/robots.txt(超时/非 200/无 body → 视为允许,fail-open 仅限 robots 拉取失败;
// 本判定不影响 SSRF/域名黑名单等 fail-closed 层);
// 3. User-agent 匹配段 + path 前缀规则(最长匹配,Allow 同长胜)。
func (r *RobotsChecker) Allowed(ctx context.Context, targetURL, userAgent string) (bool, error) {
host := parseHost(targetURL)
if host == "" {
return false, fmt.Errorf("policy: robots: 空 host")
}
body, err := r.cachedBody(ctx, host)
if err != nil {
return true, nil // 拉取失败不阻塞(见函数注释),仅记 warning 由调用方处理
}
return matchRobots(body, userAgent, pathOfURL(targetURL)), nil
}
// cachedBody 读缓存或拉取并写缓存。
func (r *RobotsChecker) cachedBody(ctx context.Context, host string) (string, error) {
now := config.Now()
var body string
var expires string
err := r.db.Raw().QueryRow(
`SELECT body, expires_at FROM robots_cache WHERE host = ?`, host).Scan(&body, &expires)
if err == nil {
if exp, perr := time.Parse(time.RFC3339, expires); perr == nil && now.Before(exp) {
return body, nil // TTL 内
}
}
fetched, ferr := r.fetch(ctx, host)
if ferr != nil {
// stale-on-error:TTL 过期但有旧值时容忍旧值(design §5.1 同思路)。
if err == nil && body != "" {
return body, nil
}
return "", ferr
}
_, _ = r.db.Raw().Exec(
`INSERT INTO robots_cache(host, body, fetched_at, expires_at) VALUES(?, ?, ?, ?)
ON CONFLICT(host) DO UPDATE SET body=excluded.body, fetched_at=excluded.fetched_at, expires_at=excluded.expires_at`,
host, fetched, now.Format(time.RFC3339), now.Add(RobotsTTL).Format(time.RFC3339))
return fetched, nil
}
// fetch 拉 robots.txt(≤256KB,10s 超时;重定向每跳重验——ITER-3 FIX-3)。
func (r *RobotsChecker) fetch(ctx context.Context, host string) (string, error) {
scheme := "https"
raw := scheme + "://" + host + "/robots.txt"
cli := r.cli
if r.redir != nil {
// 包一层每跳重验:3xx 目标 URL 过 CheckURL+ResolveCheck(fail-closed)。
cli = &http.Client{
Timeout: r.cli.Timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("policy: robots 重定向超 5 跳")
}
if d := r.redir(req.Context(), req.URL.String()); d != nil {
return fmt.Errorf("policy: robots 重定向拦截(%s): %s", d.RuleID, d.Reason)
}
return nil
},
}
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(cctx, http.MethodGet, raw, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "onesvm-browser-server-robots/1.0")
resp, err := cli.Do(req)
if err != nil {
return "", fmt.Errorf("policy: robots 拉取 %s: %w", raw, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("policy: robots %s 状态 %d", raw, resp.StatusCode)
}
limited := io.LimitReader(resp.Body, 256*1024)
b, err := io.ReadAll(limited)
if err != nil {
return "", fmt.Errorf("policy: robots 读取: %w", err)
}
return string(b), nil
}
// parseHost 提取 host(含端口归一到 authority)。
func parseHost(targetURL string) string {
s := strings.TrimPrefix(targetURL, "https://")
s = strings.TrimPrefix(s, "http://")
if i := strings.IndexAny(s, "/?#"); i >= 0 {
s = s[:i]
}
return s
}
// matchRobots 解析 robots.txt:取 * 段(或与 userAgent 匹配段)规则,
// 对 path 执行前缀匹配(ITER-3 FIX-4:路径级判定走 AllowedPath 语义)。
// 返回 true = 允许。实现按 Google robots 规范简化:最长匹配优先,Allow 胜同长 Disallow。
func matchRobots(body, userAgent, path string) bool {
ua := strings.ToLower(strings.TrimSpace(userAgent))
starRules := [][2]string{} // [prefix, allow|disallow]
uaRules := [][2]string{}
cur := -1 // -1=none, 0=star, 1=ua
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if i := strings.Index(line, "#"); i >= 0 {
line = line[:i]
}
if line == "" {
continue
}
k, v, ok := cutKV(line)
if !ok {
continue
}
k = strings.ToLower(k)
switch k {
case "user-agent":
agent := strings.ToLower(strings.TrimSpace(v))
if agent == "*" {
cur = 0
} else if ua != "" && strings.Contains(agent, ua) {
cur = 1
} else {
cur = -1
}
case "disallow":
switch cur {
case 0:
starRules = append(starRules, [2]string{v, "disallow"})
case 1:
uaRules = append(uaRules, [2]string{v, "disallow"})
}
case "allow":
switch cur {
case 0:
starRules = append(starRules, [2]string{v, "allow"})
case 1:
uaRules = append(uaRules, [2]string{v, "allow"})
}
}
}
rules := uaRules
if len(rules) == 0 {
rules = starRules
}
return evalRules(rules, path)
}
// evalRules 规则求值:把 UA 段规则序列重放为 robots 文本后走 AllowedPath
// (最长匹配前缀,allow 同长胜)——判定语义与缓存命中分支完全同源(ITER-3 FIX-4)。
func evalRules(rules [][2]string, path string) bool {
var b strings.Builder
for _, r := range rules {
if r[0] == "" && r[1] == "disallow" {
continue // 空 Disallow = 允许全部(Google 规范),不入规则序列
}
b.WriteString(strings.ToUpper(r[1]) + ": " + r[0] + "\n")
}
return AllowedPath(b.String(), path)
}
// AllowedPath 前缀级校验(最长匹配优先,allow 同长胜 disallow)。
func AllowedPath(body, path string) bool {
var best [2]string
has := false
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if i := strings.Index(line, "#"); i >= 0 {
line = line[:i]
}
k, v, ok := cutKV(line)
if !ok {
continue
}
k = strings.ToLower(k)
if k != "allow" && k != "disallow" {
continue
}
if v != "" && strings.HasPrefix(path, v) && len(v) >= len(best[0]) {
best = [2]string{v, k}
has = true
}
}
if !has {
return true
}
return best[1] == "allow"
}
func cutKV(line string) (string, string, bool) {
i := strings.Index(line, ":")
if i < 0 {
return "", "", false
}
return strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]), true
}
// pathOfURL 从 targetURL 提取请求路径(缺省 "/";ITER-3 FIX-4 替代 host_targetCache 占位)。
func pathOfURL(targetURL string) string {
u, err := url.Parse(targetURL)
if err != nil || u.Path == "" {
return "/"
}
return u.Path
}