// 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:///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 }