单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
192 lines
6.3 KiB
Go
192 lines
6.3 KiB
Go
// Package gateway 统一出口网关(design-arch §2):
|
||
// MCP POST /mcp(2026 无状态)+ HTTP 兜底 /v1/search /v1/read + admin 面 + healthz/readyz。
|
||
//
|
||
// 请求管线(design §5.4):入参校验 → 认证 → scope → 429 令牌桶 → 402 配额预扣
|
||
// → 403 合规预检(SSRF/域名/robots,denied 落审计)→ POST scheduler /enqueue
|
||
// → 轮询 /result → 统一信封。scheduler 不可达 503+Retry-After,gateway 零落盘。
|
||
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"onesvm.com/onesvm/browser-server/internal/contract"
|
||
)
|
||
|
||
// 入参边界(mcp-usage §2 参数表 + T7 边界纪律)。
|
||
const (
|
||
MaxResultsCap = 20 // max_results 上限
|
||
MaxCharsCap = 100_000 // max_chars 上限(默认 20000,允许显式调大但不越限)
|
||
MaxQueryRunes = 512 // query 最大字符数
|
||
MaxURLLen = 2048 // url 最大长度
|
||
MaxFormats = 8 // formats 数组上限
|
||
DefaultMaxChars = 20_000 // mcp-usage §2.2 默认截断
|
||
ReadTimeoutS = 30 // read 默认任务超时(job 参数带)
|
||
SearchTimeoutS = 30 // search 任务超时
|
||
WaitBudget = 120 * time.Second // 等待结果硬顶
|
||
PollInterval = 200 * time.Millisecond
|
||
)
|
||
|
||
// formatsWhitelist formats 白名单(特权项另有 scope 校验)。
|
||
var formatsWhitelist = map[string]bool{
|
||
"markdown": true, "links": true, "images": true,
|
||
"html": true, "screenshot": true,
|
||
}
|
||
|
||
// formatScope 需要特权的 format。
|
||
var formatScope = map[string]string{
|
||
"html": "rawHtml", "screenshot": "screenshot",
|
||
}
|
||
|
||
// validateSearch 入参校验(SearchInput)。
|
||
func validateSearch(in *contract.SearchInput) error {
|
||
in.Query = strings.TrimSpace(in.Query)
|
||
if in.Query == "" {
|
||
return fmt.Errorf("query 必填")
|
||
}
|
||
if n := len([]rune(in.Query)); n > MaxQueryRunes {
|
||
return fmt.Errorf("query 超长(≤%d 字符)", MaxQueryRunes)
|
||
}
|
||
if _, err := contract.ValidRegion(in.Region); err != nil {
|
||
return err
|
||
}
|
||
if in.MaxResults < 0 || in.MaxResults > MaxResultsCap {
|
||
return fmt.Errorf("max_results 须在 1..%d", MaxResultsCap)
|
||
}
|
||
if in.MaxResults == 0 {
|
||
in.MaxResults = 5 // mcp-usage §2.1 默认
|
||
}
|
||
if in.TimeRange != nil {
|
||
switch *in.TimeRange {
|
||
case "day", "week", "month", "year":
|
||
default:
|
||
return fmt.Errorf("time_range 须为 day|week|month|year")
|
||
}
|
||
}
|
||
if in.Lang != nil && len(*in.Lang) > 16 {
|
||
return fmt.Errorf("lang 超长(≤16 字符)")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validateRead 入参校验(ReadInput);返回所需 scope 检查项(特权 formats/extract)。
|
||
func validateRead(in *contract.ReadInput) error {
|
||
in.URL = strings.TrimSpace(in.URL)
|
||
if in.URL == "" {
|
||
return fmt.Errorf("url 必填")
|
||
}
|
||
if len(in.URL) > MaxURLLen {
|
||
return fmt.Errorf("url 超长(≤%d 字符)", MaxURLLen)
|
||
}
|
||
if _, err := contract.ValidRegion(in.Region); err != nil {
|
||
return err
|
||
}
|
||
if len(in.Formats) > MaxFormats {
|
||
return fmt.Errorf("formats 项数超限(≤%d)", MaxFormats)
|
||
}
|
||
if len(in.Formats) == 0 {
|
||
in.Formats = contract.DefaultFormats()
|
||
}
|
||
for _, f := range in.Formats {
|
||
if !formatsWhitelist[f] {
|
||
return fmt.Errorf("formats 含不支持项 %q", f)
|
||
}
|
||
}
|
||
if in.MaxChars < 0 || in.MaxChars > MaxCharsCap {
|
||
return fmt.Errorf("max_chars 须在 1..%d", MaxCharsCap)
|
||
}
|
||
if in.MaxChars == 0 {
|
||
in.MaxChars = DefaultMaxChars
|
||
}
|
||
if in.Extract != nil && len(in.Extract.Schema) == 0 {
|
||
return fmt.Errorf("extract.schema 必填")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// privilegedRead read 请求触发的特权 scope 集合(extract/screenshot/rawHtml)。
|
||
func privilegedRead(in *contract.ReadInput) []string {
|
||
var need []string
|
||
if in.Extract != nil {
|
||
need = append(need, "extract")
|
||
}
|
||
for _, f := range in.Formats {
|
||
if s, ok := formatScope[f]; ok {
|
||
need = append(need, s)
|
||
}
|
||
}
|
||
return need
|
||
}
|
||
|
||
// jobTimeoutS 任务执行超时(job 参数带,gateway 等待另算 120s 硬顶)。
|
||
func jobTimeoutS(intent string) int {
|
||
if intent == "read" {
|
||
return ReadTimeoutS
|
||
}
|
||
return SearchTimeoutS
|
||
}
|
||
|
||
// enqueueResult scheduler /enqueue 响应(W2 内部契约,与 W3 对齐;见回执 §4)。
|
||
// 只约束 gateway 实际读取的字段:ack 与 job_id/request_id/queued_position。
|
||
type enqueueResult struct {
|
||
OK bool `json:"ok"`
|
||
JobID int64 `json:"job_id"`
|
||
RequestID string `json:"request_id"`
|
||
QueuedPosition int `json:"queued_position"`
|
||
// 拒绝面(503/429 由 HTTP 状态承载,body 亦带 code 便于断言)
|
||
Error *contract.ErrBody `json:"error,omitempty"`
|
||
}
|
||
|
||
// resultState /result 响应(ITER-1 F1 统一形状,golden:contract.ResultShapeDone/
|
||
// ResultShapeAccepted/ResultShapeFailed 双端同源):
|
||
// 终态(done/failed/dead)HTTP 200 + {request_id,status,envelope};
|
||
// 非终态(queued/running)HTTP 202 + {request_id,status,position?}。
|
||
type resultState struct {
|
||
RequestID string `json:"request_id"`
|
||
Status string `json:"status"` // queued|running|done|failed|dead|unknown
|
||
Position int `json:"position,omitempty"`
|
||
Envelope json.RawMessage `json:"envelope,omitempty"` // 终态完整统一信封(嵌套)
|
||
}
|
||
|
||
// waitResult 轮询终态(200ms 间隔,120s 硬顶)。
|
||
// 返回终态信封原始字节;等待预算耗尽返回 timeout 错误。
|
||
func waitResult(ctx context.Context, sc *SchedulerClient, requestID string) (json.RawMessage, error) {
|
||
deadline := time.Now().Add(WaitBudget)
|
||
for {
|
||
st, err := sc.Result(ctx, requestID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
switch st.Status {
|
||
case contract.JobDone:
|
||
if len(st.Envelope) > 0 {
|
||
return st.Envelope, nil
|
||
}
|
||
return nil, fmt.Errorf("scheduler 终态缺 envelope")
|
||
case contract.JobFailed, contract.JobDead:
|
||
// scheduler 侧任务失败:若带信封错误则透传,否则归一 upstream。
|
||
if len(st.Envelope) > 0 {
|
||
return st.Envelope, nil
|
||
}
|
||
return nil, fmt.Errorf("scheduler 任务终态 %s", st.Status)
|
||
}
|
||
if time.Now().After(deadline) {
|
||
return nil, errWaitTimeout
|
||
}
|
||
sleep := PollInterval
|
||
if st.Status == contract.JobRunning {
|
||
sleep = PollInterval
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
case <-time.After(sleep):
|
||
}
|
||
}
|
||
}
|
||
|
||
// errWaitTimeout 等待硬顶超时(error.code=timeout)。
|
||
var errWaitTimeout = fmt.Errorf("等待结果超时(120s 硬顶)")
|