feat: add proxy pool design and core architecture

This commit is contained in:
youfak 2026-07-28 20:10:54 +08:00
parent dab16fda12
commit 7ce9778bdf
93 changed files with 7314 additions and 12 deletions

34
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: ci
on:
push:
pull_request:
permissions:
contents: read
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go vet ./...
- run: go test -timeout 60s ./...
- run: go build ./...
race:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go test -race -timeout 60s ./internal/...

1
.gitignore vendored
View File

@ -9,6 +9,7 @@ coverage/
*.out
*.test
*.prof
.tmp-proto/
# Local configuration and secrets
.env

57
README.md Normal file
View File

@ -0,0 +1,57 @@
# Proxy Pool
面向多供应商代理资源的集中管理与高并发转发平台。系统同时提供:
- **Gateway**:系统选择上游代理并代转发 HTTP 与 HTTPS CONNECT。
- **Distribution API**:把真实代理一次性、独占地发放给调用方。
- **Admin API**:查询、启停、切换和配置重载。
- **Controller / Checker**:管理供应商获取、健康、容量、状态与数据面快照。
> 当前仓库交付的是从 `对话内容.md` 全量重建的设计基线、机器契约、
> 项目骨架和关键并发领域实现。100,000 QPS 是集群设计目标,尚需在目标
> 网络和代理规模下完成压测证明。
## 快速导航
- [产品设计](docs/design/product-design.md)
- [总体架构](docs/design/architecture.md)
- [项目结构](docs/design/project-structure.md)
- [需求追踪](docs/requirements/traceability.md)
- [交付完成度审计](docs/requirements/completion-audit.md)
- [开发指南](docs/development/guide.md)
- [实施计划](docs/development/implementation-plan.md)
- [配置参考](docs/configuration/reference.md)
- [Distribution API](docs/api/distribution.md)
- [控制面协议](docs/api/control-plane.md)
- [安全模型](docs/security/security-model.md)
- [测试策略](docs/testing/strategy.md)
- [运维手册](docs/operations/runbook.md)
- [生产就绪检查](docs/operations/production-readiness.md)
## 本地验证
```powershell
go mod tidy
go test ./...
go vet ./...
go build ./...
```
Windows PowerShell 可运行:
```powershell
./scripts/verify.ps1
```
竞态检测需要启用 CGO 并提供可用的 C 编译器CI 的 Linux race job 负责
执行该质量门禁。
## 不变量
1. Gateway 热路径不访问 PostgreSQL、Redis 或 Provider API。
2. Proxy 容量使用 `Reserved -> Active` 原子转换,禁止超卖。
3. Distribution 成功时原子执行 `AVAILABLE -> EXTRACTED`,不提供 Lease、
Release 或 Renewal。
4. `pool.maxSize` 是当前未提取库存上限;`fetch.maxTotal` 是累计获取额度。
5. CONNECT 向客户端提交 200 后不透明重放。
6. 公开监听必须有认证或 CIDR 访问保护。

202
api/openapi/admin.yaml Normal file
View File

@ -0,0 +1,202 @@
openapi: 3.1.0
info:
title: Proxy Pool Admin API
version: 1.0.0
description: 运维状态与受控变更接口。该入口必须与 Distribution 分端口和权限。
servers:
- url: http://127.0.0.1:8082
tags:
- name: Status
- name: Upstreams
- name: Routing
- name: Configuration
security:
- AdminApiKey: []
- BasicAuth: []
- BearerAuth: []
paths:
/api/v1/status:
get:
tags: [Status]
operationId: getStatus
summary: 获取控制面摘要状态
responses:
'200':
description: 不含 Proxy 地址、Client 标识或 Secret 的聚合状态
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/v1/upstreams/{name}/enable:
post:
tags: [Upstreams]
operationId: enableUpstream
summary: 启用 Upstream
parameters:
- $ref: '#/components/parameters/UpstreamName'
- $ref: '#/components/parameters/RequestID'
responses:
'200': {$ref: '#/components/responses/MutationResult'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
/api/v1/upstreams/{name}/disable:
post:
tags: [Upstreams]
operationId: disableUpstream
summary: 禁用 Upstream 并使已有资源自然 Drain
parameters:
- $ref: '#/components/parameters/UpstreamName'
- $ref: '#/components/parameters/RequestID'
responses:
'200': {$ref: '#/components/responses/MutationResult'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
/api/v1/routing/{name}/switch:
post:
tags: [Routing]
operationId: switchRouting
summary: 原子切换 Sequential Routing 当前 Upstream
parameters:
- name: name
in: path
required: true
schema: {type: string, minLength: 1, maxLength: 128}
- $ref: '#/components/parameters/RequestID'
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [expectedCurrent, target]
properties:
expectedCurrent: {type: string}
target: {type: string}
reason: {type: string, maxLength: 512}
responses:
'200': {$ref: '#/components/responses/MutationResult'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
/api/v1/config/reload:
post:
tags: [Configuration]
operationId: reloadConfiguration
summary: 严格校验并原子发布新配置快照
parameters:
- $ref: '#/components/parameters/RequestID'
responses:
'200': {$ref: '#/components/responses/MutationResult'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
'422':
description: 新配置无效,旧配置继续运行
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
components:
securitySchemes:
AdminApiKey: {type: apiKey, in: header, name: X-Admin-Key}
BasicAuth: {type: http, scheme: basic}
BearerAuth: {type: http, scheme: bearer}
parameters:
UpstreamName:
name: name
in: path
required: true
schema: {type: string, minLength: 1, maxLength: 128}
RequestID:
name: X-Request-ID
in: header
required: false
schema: {type: string, maxLength: 128}
schemas:
Status:
type: object
additionalProperties: false
required: [configVersion, snapshotVersion, upstreams, workers]
properties:
configVersion: {type: string}
snapshotVersion: {type: integer, minimum: 0}
upstreams:
type: array
items:
type: object
additionalProperties: false
required: [name, enabled, available, checking, suspect, draining, extracted]
properties:
name: {type: string}
enabled: {type: boolean}
available: {type: integer, minimum: 0}
checking: {type: integer, minimum: 0}
suspect: {type: integer, minimum: 0}
draining: {type: integer, minimum: 0}
extracted: {type: integer, minimum: 0}
consecutiveEmptyFetch: {type: integer, minimum: 0}
fetchErrorCount: {type: integer, minimum: 0}
workers:
type: array
items:
type: object
additionalProperties: false
required: [id, zone, connected, snapshotVersion]
properties:
id: {type: string}
zone: {type: string}
connected: {type: boolean}
snapshotVersion: {type: integer, minimum: 0}
staleSeconds: {type: integer, minimum: 0}
MutationResult:
type: object
additionalProperties: false
required: [requestId, changed, version]
properties:
requestId: {type: string}
changed: {type: boolean}
version: {type: integer, minimum: 0}
message: {type: string}
Problem:
type: object
required: [type, title, status, code]
properties:
type: {type: string, format: uri}
title: {type: string}
status: {type: integer}
code: {type: string}
detail: {type: string}
requestId: {type: string}
responses:
MutationResult:
description: 操作已提交或目标状态原本已满足
content:
application/json:
schema: {$ref: '#/components/schemas/MutationResult'}
Unauthorized:
description: 管理入口认证失败
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
Forbidden:
description: 调用主体无该管理权限
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
NotFound:
description: Upstream 或 Routing 不存在
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
Conflict:
description: 预期版本或 expectedCurrent 与权威状态不一致
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}

View File

@ -0,0 +1,63 @@
package openapi
import (
"os"
"testing"
"go.yaml.in/yaml/v4"
)
type document struct {
OpenAPI string `yaml:"openapi"`
Paths map[string]map[string]any `yaml:"paths"`
}
func TestDistributionContract(t *testing.T) {
spec := readDocument(t, "proxy-pool.yaml")
if spec.OpenAPI != "3.1.0" {
t.Fatalf("openapi version = %q, want 3.1.0", spec.OpenAPI)
}
extraction, ok := spec.Paths["/api/v1/proxies/extract"]
if !ok {
t.Fatal("exclusive extraction path is missing")
}
if _, ok := extraction["post"]; !ok {
t.Fatal("exclusive extraction must use POST")
}
for path := range spec.Paths {
if path == "/api/v1/leases" || path == "/api/v1/proxies/release" || path == "/api/v1/proxies/renew" {
t.Fatalf("lease/release path is forbidden: %s", path)
}
}
}
func TestAdminContract(t *testing.T) {
spec := readDocument(t, "admin.yaml")
for _, path := range []string{
"/api/v1/status",
"/api/v1/upstreams/{name}/enable",
"/api/v1/upstreams/{name}/disable",
"/api/v1/routing/{name}/switch",
"/api/v1/config/reload",
} {
if _, ok := spec.Paths[path]; !ok {
t.Errorf("admin path is missing: %s", path)
}
}
}
func readDocument(t *testing.T, path string) document {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var spec document
if err := yaml.Unmarshal(content, &spec); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
if spec.OpenAPI != "3.1.0" {
t.Fatalf("%s openapi version = %q, want 3.1.0", path, spec.OpenAPI)
}
return spec
}

351
api/openapi/proxy-pool.yaml Normal file
View File

@ -0,0 +1,351 @@
openapi: 3.1.0
info:
title: Proxy Pool HTTP API
version: 1.0.0
description: |
Distribution API performs one-time exclusive extraction. A successful
operation atomically transitions every returned proxy from AVAILABLE to
EXTRACTED. Extracted proxies are never allocated again and there is no
release, renew, or lease API.
servers:
- url: http://127.0.0.1:8081
description: Distribution API
tags:
- name: Distribution
- name: Health
paths:
/api/v1/proxies/extract:
post:
tags: [Distribution]
operationId: extractProxies
summary: 一次性独占提取代理
description: |
服务端先完成筛选、行锁定、AVAILABLE -> EXTRACTED 状态更新和审计记录
写入,再返回代理。相同代理不会返回给两个成功请求。
`partial` 允许实际返回数量小于请求数量;`allOrNothing` 数量不足时不
提取任何代理并返回 409。未传 `fulfillment` 时使用服务端配置,默认
为 `partial`。`Idempotency-Key` 可避免客户端因响应丢失重试而再次消耗
库存。
security:
- ApiKeyAuth: []
- BasicAuth: []
- BearerAuth: []
- {}
parameters:
- $ref: '#/components/parameters/RequestID'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ExtractRequest'
examples:
partial:
value:
count: 5
fulfillment: partial
filters:
protocols: [http]
regions: [shanghai]
allOrNothing:
value:
count: 10
fulfillment: allOrNothing
filters:
allowedUpstreams: [provider-a, provider-b]
responses:
'200':
description: 提取事务已提交;返回的代理已永久退出可分配池
headers:
X-Request-ID:
$ref: '#/components/headers/RequestID'
content:
application/json:
schema:
$ref: '#/components/schemas/ExtractResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
description: allOrNothing 模式下符合条件的库存不足,未提取任何代理
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
example:
type: https://proxy-pool.local/problems/insufficient-proxies
title: Insufficient proxies
status: 409
code: INSUFFICIENT_PROXIES
detail: requested 10 proxies but only 6 are currently eligible
requestId: req_01J4EXAMPLE
'422':
$ref: '#/components/responses/UnprocessableEntity'
'429':
$ref: '#/components/responses/TooManyRequests'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/health/live:
get:
tags: [Health]
operationId: getLiveness
summary: 进程存活探针
security: []
responses:
'200':
description: 进程存活
content:
application/json:
schema:
$ref: '#/components/schemas/Health'
/health/ready:
get:
tags: [Health]
operationId: getReadiness
summary: Distribution 就绪探针
description: PostgreSQL 不可用或权威状态不可写时返回 503。
security: []
responses:
'200':
description: 可接受提取请求
content:
application/json:
schema:
$ref: '#/components/schemas/Health'
'503':
$ref: '#/components/responses/ServiceUnavailable'
components:
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
BasicAuth:
type: http
scheme: basic
BearerAuth:
type: http
scheme: bearer
parameters:
RequestID:
name: X-Request-ID
in: header
required: false
description: 调用方请求标识;缺省时由服务端生成。
schema:
type: string
maxLength: 128
IdempotencyKey:
name: Idempotency-Key
in: header
required: false
description: |
同一客户端在幂等记录保留期内重用该键会得到首次提交结果,不会再次
提取。建议所有会自动重试的客户端提供。
schema:
type: string
minLength: 8
maxLength: 128
headers:
RequestID:
description: 服务端最终使用的请求标识。
schema:
type: string
schemas:
ExtractRequest:
type: object
additionalProperties: false
required: [count]
properties:
count:
type: integer
minimum: 1
maximum: 1000
description: 仍受服务端 maxCountPerRequest 限制。
fulfillment:
type: string
enum: [partial, allOrNothing]
description: 缺省时使用服务端配置;默认 partial。
filters:
$ref: '#/components/schemas/ExtractFilters'
ExtractFilters:
type: object
additionalProperties: false
properties:
protocols:
type: array
uniqueItems: true
items:
type: string
enum: [http, https, socks5]
regions:
type: array
uniqueItems: true
items:
type: string
carriers:
type: array
uniqueItems: true
items:
type: string
allowedUpstreams:
type: array
uniqueItems: true
items:
type: string
ExtractResponse:
type: object
additionalProperties: false
required: [requestId, requested, returned, proxies]
properties:
requestId:
type: string
requested:
type: integer
minimum: 1
returned:
type: integer
minimum: 0
proxies:
type: array
items:
$ref: '#/components/schemas/ExtractedProxy'
ExtractedProxy:
type: object
additionalProperties: false
required:
- id
- protocol
- host
- port
- url
- upstream
- expiresAt
- remainingTtlSeconds
- extractedAt
properties:
id:
type: string
example: px_01J4EXAMPLE
protocol:
type: string
enum: [http, https, socks5]
host:
type: string
example: 192.0.2.10
port:
type: integer
minimum: 1
maximum: 65535
username:
type: string
password:
type: string
format: password
description: 真实代理凭据,只出现在提取成功响应中。
url:
type: string
format: uri
description: 含真实代理凭据的连接 URL必须按敏感数据处理。
example: http://USER:PASSWORD@192.0.2.10:8080
region:
type: string
carrier:
type: string
upstream:
type: string
expiresAt:
type: string
format: date-time
remainingTtlSeconds:
type: integer
minimum: 0
extractedAt:
type: string
format: date-time
Health:
type: object
additionalProperties: false
required: [status]
properties:
status:
type: string
enum: [ok, degraded]
version:
type: string
configVersion:
type: string
Problem:
type: object
additionalProperties: true
required: [type, title, status, code]
properties:
type:
type: string
format: uri
title:
type: string
status:
type: integer
code:
type: string
detail:
type: string
requestId:
type: string
invalidParams:
type: array
items:
type: object
required: [name, reason]
properties:
name:
type: string
reason:
type: string
responses:
BadRequest:
description: 请求体、Header 或 JSON 格式无效
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
Unauthorized:
description: 所配置的认证方法未通过
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
Forbidden:
description: 来源访问控制或客户端权限拒绝
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
UnprocessableEntity:
description: 参数语法有效但违反业务约束
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
TooManyRequests:
description: 超过全局或客户端速率限制
headers:
Retry-After:
schema:
type: integer
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
ServiceUnavailable:
description: 权威存储不可用或服务正在排空
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'

View File

@ -0,0 +1,249 @@
syntax = "proto3";
package proxy_pool.controlplane.v1;
option go_package = "github.com/proxy-pool/proxy-pool/gen/controlplane/v1;controlplanev1";
import "google/protobuf/duration.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
// WorkerControlPlane distributes immutable, worker-specific snapshots. The
// gateway hot path does not call this service for individual requests.
service WorkerControlPlane {
rpc RegisterWorker(RegisterWorkerRequest) returns (RegisterWorkerResponse);
rpc WatchSnapshots(WatchSnapshotsRequest) returns (stream SnapshotEnvelope);
rpc AcknowledgeSnapshot(AcknowledgeSnapshotRequest) returns (google.protobuf.Empty);
rpc ReportOutcomes(stream OutcomeBatch) returns (ReportOutcomesResponse);
rpc ReportRuntime(ReportRuntimeRequest) returns (ReportRuntimeResponse);
}
// CheckerControlPlane hands bounded check work to independently scalable
// checker processes. Observations are facts; only the Controller reducer may
// change authoritative proxy state.
service CheckerControlPlane {
rpc StreamCheckTasks(StreamCheckTasksRequest) returns (stream CheckTask);
rpc ReportObservations(ObservationBatch) returns (ReportObservationsResponse);
}
message RegisterWorkerRequest {
string worker_id = 1;
string instance_id = 2;
string zone = 3;
uint32 supported_protocol_version = 4;
map<string, string> labels = 5;
}
message RegisterWorkerResponse {
string worker_id = 1;
string session_id = 2;
uint64 ownership_epoch = 3;
google.protobuf.Duration heartbeat_interval = 4;
google.protobuf.Duration max_stale_age = 5;
}
message WatchSnapshotsRequest {
string worker_id = 1;
string session_id = 2;
uint64 last_applied_version = 3;
bytes last_checksum = 4;
}
message SnapshotEnvelope {
oneof payload {
WorkerSnapshot full = 1;
SnapshotDelta delta = 2;
}
}
message WorkerSnapshot {
uint64 version = 1;
uint64 ownership_epoch = 2;
google.protobuf.Timestamp generated_at = 3;
google.protobuf.Timestamp valid_until = 4;
bytes checksum = 5;
repeated RoutingRule routing = 6;
repeated OwnedProxy proxies = 7;
}
message SnapshotDelta {
uint64 base_version = 1;
uint64 version = 2;
uint64 ownership_epoch = 3;
google.protobuf.Timestamp generated_at = 4;
bytes checksum = 5;
repeated RoutingRule upserted_routing = 6;
repeated string removed_routing_names = 7;
repeated OwnedProxy upserted_proxies = 8;
repeated string removed_proxy_ids = 9;
}
message RoutingRule {
string name = 1;
bool enabled = 2;
string host_regex = 3;
repeated string methods = 4;
string path_regex = 5;
map<string, string> headers = 6;
repeated string upstreams = 7;
RoutingStrategy strategy = 8;
UnavailableAction on_unavailable = 9;
}
message RoutingStrategy {
StrategyType type = 1;
string current_upstream = 2;
map<string, uint32> weights = 3;
}
enum StrategyType {
STRATEGY_TYPE_UNSPECIFIED = 0;
STRATEGY_TYPE_SEQUENTIAL = 1;
STRATEGY_TYPE_RANDOM = 2;
STRATEGY_TYPE_ROUND_ROBIN = 3;
STRATEGY_TYPE_WEIGHTED = 4;
STRATEGY_TYPE_LEAST_CONNECTIONS = 5;
}
enum UnavailableAction {
UNAVAILABLE_ACTION_UNSPECIFIED = 0;
UNAVAILABLE_ACTION_REJECT = 1;
UNAVAILABLE_ACTION_WAIT = 2;
UNAVAILABLE_ACTION_DIRECT = 3;
}
message OwnedProxy {
string id = 1;
string upstream = 2;
ProxyProtocol protocol = 3;
string host = 4;
uint32 port = 5;
string username = 6;
string credential_version = 7;
string secret_ref = 8;
google.protobuf.Timestamp expires_at = 9;
uint32 max_concurrency = 10;
map<string, string> tags = 11;
uint64 ownership_epoch = 12;
}
enum ProxyProtocol {
PROXY_PROTOCOL_UNSPECIFIED = 0;
PROXY_PROTOCOL_HTTP = 1;
PROXY_PROTOCOL_HTTPS = 2;
PROXY_PROTOCOL_SOCKS5 = 3;
}
message AcknowledgeSnapshotRequest {
string worker_id = 1;
string session_id = 2;
uint64 version = 3;
uint64 ownership_epoch = 4;
bytes checksum = 5;
bool applied = 6;
string error_code = 7;
string error_message = 8;
}
message OutcomeBatch {
string worker_id = 1;
string session_id = 2;
uint64 sequence = 3;
repeated ProxyOutcome outcomes = 4;
}
message ProxyOutcome {
string proxy_id = 1;
string routing_name = 2;
OutcomeStage stage = 3;
bool success = 4;
google.protobuf.Duration latency = 5;
string error_class = 6;
google.protobuf.Timestamp observed_at = 7;
}
enum OutcomeStage {
OUTCOME_STAGE_UNSPECIFIED = 0;
OUTCOME_STAGE_DIAL = 1;
OUTCOME_STAGE_PROXY_HANDSHAKE = 2;
OUTCOME_STAGE_RESPONSE_HEADERS = 3;
OUTCOME_STAGE_TUNNEL = 4;
}
message ReportOutcomesResponse {
uint64 accepted_through_sequence = 1;
}
message ReportRuntimeRequest {
string worker_id = 1;
string session_id = 2;
uint64 snapshot_version = 3;
uint64 ownership_epoch = 4;
repeated ProxyRuntime counters = 5;
google.protobuf.Timestamp observed_at = 6;
}
message ProxyRuntime {
string proxy_id = 1;
uint32 reserved = 2;
uint32 active = 3;
bool draining = 4;
}
message ReportRuntimeResponse {
uint64 accepted_ownership_epoch = 1;
repeated string revoke_proxy_ids = 2;
bool require_full_snapshot = 3;
}
message StreamCheckTasksRequest {
string checker_id = 1;
string instance_id = 2;
uint32 max_in_flight = 3;
repeated CheckLevel supported_levels = 4;
}
message CheckTask {
string task_id = 1;
string proxy_id = 2;
ProxyProtocol protocol = 3;
string host = 4;
uint32 port = 5;
string secret_ref = 6;
CheckLevel level = 7;
string routing_name = 8;
string target_url = 9;
google.protobuf.Duration timeout = 10;
uint32 attempt = 11;
google.protobuf.Timestamp deadline = 12;
}
enum CheckLevel {
CHECK_LEVEL_UNSPECIFIED = 0;
CHECK_LEVEL_BASIC = 1;
CHECK_LEVEL_EGRESS = 2;
CHECK_LEVEL_TARGET = 3;
}
message ObservationBatch {
string checker_id = 1;
repeated HealthObservation observations = 2;
}
message HealthObservation {
string task_id = 1;
string proxy_id = 2;
CheckLevel level = 3;
string routing_name = 4;
string target_url = 5;
bool success = 6;
string failure_class = 7;
google.protobuf.Duration latency = 8;
string observed_egress_ip = 9;
google.protobuf.Timestamp observed_at = 10;
}
message ReportObservationsResponse {
uint32 accepted = 1;
uint32 rejected = 2;
}

217
configs/proxy-pool.yaml Normal file
View File

@ -0,0 +1,217 @@
version: 1
security:
requireProtectionOnPublicListen: true
defaults:
fetch:
requestInterval: 1s
timeout: 5s
maxAttempts: 3
maxInFlight: 1
maxResponseBytes: 1048576
templateTimeout: 100ms
retry:
initial: 500ms
max: 30s
jitter: 20
check:
interval: 30s
jitter: 20
maxInFlight: 200
timeout: 3s
maxAttempts: 2
maxConsecutiveFailures: 3
urls:
- http://connect.rom.miui.com/generate_204
gateway:
enabled: true
listen: 0.0.0.0:8080
access:
allowCIDRs:
- 10.0.0.0/8
- 172.16.0.0/12
trustedProxies: []
auth:
mode: usernamePassword
username: "${GATEWAY_USER}"
password: "${GATEWAY_PASSWORD}"
limits:
maxConcurrentConnections: 200000
retry:
maxAttempts: 2
retryMethods: [GET, HEAD]
destinationPolicy:
denyPrivateNetworks: true
denyLoopback: true
denyLinkLocal: true
denyCIDRs:
- 169.254.169.254/32
distribution:
enabled: true
listen: 0.0.0.0:8081
access:
allowCIDRs:
- 10.0.0.0/8
- 172.16.0.0/12
trustedProxies: []
auth:
mode: apiKey
header: X-API-Key
token: "${DISTRIBUTION_API_KEY}"
limits:
requestsPerMinute: 6000
requestsPerMinutePerClient: 600
clientIdentification:
mode: authenticatedClientOrSourceIP
extraction:
fulfillment: partial
maxCountPerRequest: 100
minRemainingTTL: 30s
maxHealthCheckAge: 15s
reserveForGateway: 100
admin:
enabled: true
listen: 127.0.0.1:8082
auth:
mode: none
metrics:
enabled: true
listen: 127.0.0.1:9090
storage:
postgresURL: "${POSTGRES_URL}"
redisURL: "${REDIS_URL}"
routing:
- name: gateway-default
enabled: true
purpose: gateway
match:
hostRegex: '.*'
upstreams: [provider-a, provider-b]
strategy:
type: sequential
switchAfterEmptyFetch: 5
endBehavior: stayLast
onUnavailable:
action: reject
- name: extract-default
enabled: true
purpose: extract
match:
hostRegex: '.*'
upstreams: [provider-a, provider-b]
strategy:
type: sequential
switchAfterEmptyFetch: 5
endBehavior: stayLast
onUnavailable:
action: reject
upstreams:
provider-a:
enabled: true
exposure: [gateway, extract]
provider:
billingMode: fetch
protocols: [http, https]
api:
url: https://provider-a.example/api/proxies
method: GET
auth:
type: apiKey
location: header
name: X-Provider-Key
value: "${PROVIDER_A_TOKEN}"
query:
count: '100'
template: '{{.}}'
proxyAuth:
type: response
pool:
maxSize: 10000
shrinkDelay: 30s
capacity:
maxConcurrencyPerProxy: 20
lifecycle:
ttl: 5m
allocationSafetyMargin: 30s
fetch:
requestInterval: 1s
timeout: 5s
maxAttempts: 3
maxInFlight: 1
maxTotal: 1000000
maxResponseBytes: 1048576
templateTimeout: 100ms
retry:
initial: 500ms
max: 30s
jitter: 20
check:
interval: 30s
jitter: 20
maxInFlight: 200
timeout: 3s
maxAttempts: 2
maxConsecutiveFailures: 3
urls:
- http://connect.rom.miui.com/generate_204
provider-b:
enabled: true
exposure: [gateway, extract]
provider:
billingMode: subscription
protocols: [http]
api:
url: https://provider-b.example/api/proxies
method: POST
auth:
type: basic
username: "${PROVIDER_B_USER}"
password: "${PROVIDER_B_PASSWORD}"
headers:
Content-Type: application/json
body:
type: json
value:
count: '100'
template: '{{.}}'
proxyAuth:
type: static
username: "${PROXY_USER}"
password: "${PROXY_PASSWORD}"
pool:
maxSize: 5000
shrinkDelay: 30s
capacity:
maxConcurrencyPerProxy: 10
lifecycle:
ttl: 2m
allocationSafetyMargin: 20s
fetch:
requestInterval: 2s
timeout: 5s
maxAttempts: 3
maxInFlight: 1
maxTotal: 0
maxResponseBytes: 1048576
templateTimeout: 100ms
retry:
initial: 1s
max: 30s
jitter: 20
check:
interval: 20s
jitter: 20
maxInFlight: 100
timeout: 3s
maxAttempts: 2
maxConsecutiveFailures: 3
urls:
- http://connect.rom.miui.com/generate_204

17
deploy/README.md Normal file
View File

@ -0,0 +1,17 @@
# 部署拓扑模板
本目录描述 Proxy Pool 目标运行拓扑,覆盖 Compose、HAProxy、Prometheus、
Grafana 与 Kubernetes。配置已通过静态展开但当前仓库的 `cmd/proxy-*`
运行时装配仍在实施计划中,因此不要把这些清单视为当前可部署发行版。
当前可执行验证:
```powershell
docker compose -f deploy/docker-compose.yml config --quiet
kubectl kustomize deploy/kubernetes/base | Out-Null
go run ./deploy/tools/configcheck deploy/config/local.yaml
```
运行时完成后,还必须通过 `production-readiness.md` 中的一致性、安全、恢复、
竞态与容量门禁,才能构建镜像并发布。

179
deploy/config/local.yaml Normal file
View File

@ -0,0 +1,179 @@
version: 1
security:
requireProtectionOnPublicListen: true
gateway:
enabled: true
listen: 0.0.0.0:8080
access:
allowCIDRs: [172.16.0.0/12]
trustedProxies: [172.16.0.0/12]
auth:
mode: usernamePassword
username: local-gateway
password: env:PROXY_POOL_GATEWAY_PASSWORD
limits:
maxConcurrentConnections: 20000
requestsPerMinutePerClient: 60000
retry:
maxAttempts: 2
retryMethods: [GET, HEAD]
destinationPolicy:
denyPrivateNetworks: true
denyLoopback: true
denyLinkLocal: true
denyCIDRs: [169.254.169.254/32]
distribution:
enabled: true
listen: 0.0.0.0:8081
access:
allowCIDRs: [172.16.0.0/12]
trustedProxies: [172.16.0.0/12]
auth:
mode: apiKey
header: X-API-Key
token: env:PROXY_POOL_EXTRACT_TOKEN
limits:
requestsPerMinute: 6000
requestsPerMinutePerClient: 600
clientIdentification:
mode: trustedProxyOrRemoteIP
extraction:
fulfillment: partial
maxCountPerRequest: 100
minRemainingTTL: 30s
maxHealthCheckAge: 30s
reserveForGateway: 1000
admin:
enabled: true
listen: 0.0.0.0:8082
access:
allowCIDRs: [172.16.0.0/12]
auth:
mode: apiKey
header: X-Admin-Token
token: env:PROXY_POOL_ADMIN_TOKEN
metrics:
enabled: true
listen: 0.0.0.0:9090
storage:
postgresURL: postgres://proxy_pool:local-only-change-me@postgres:5432/proxy_pool?sslmode=disable
redisURL: redis://redis:6379/0
routing:
- name: gateway-default
enabled: true
purpose: gateway
upstreams: [provider-a, provider-b]
strategy:
type: sequential
switchAfterEmptyFetch: 5
endBehavior: stayLast
onUnavailable:
action: reject
- name: extract-default
enabled: true
purpose: extract
upstreams: [provider-a, provider-b]
strategy:
type: sequential
switchAfterEmptyFetch: 5
endBehavior: stayLast
onUnavailable:
action: reject
upstreams:
provider-a:
enabled: true
exposure: [gateway, extract]
provider:
billingMode: fetch
protocols: [http]
api:
url: https://provider-a.invalid/api/proxies
method: GET
auth:
mode: apiKey
header: Authorization
token: env:PROVIDER_A_TOKEN
template: '{{ . }}'
proxyAuth:
mode: response
pool:
maxSize: 5000
shrinkDelay: 30s
capacity:
maxConcurrencyPerProxy: 20
lifecycle:
ttl: 5m
allocationSafetyMargin: 20s
fetch:
requestInterval: 1s
timeout: 3s
maxAttempts: 3
maxInFlight: 1
maxTotal: 100000
maxResponseBytes: 4194304
templateTimeout: 100ms
retry:
initial: 500ms
max: 30s
jitter: 20
check:
interval: 30s
jitter: 20
maxInFlight: 200
timeout: 3s
maxAttempts: 2
maxConsecutiveFailures: 3
urls: [https://example.com/]
provider-b:
enabled: true
exposure: [gateway, extract]
provider:
billingMode: fetch
protocols: [http]
api:
url: https://provider-b.invalid/api/proxies
method: GET
auth:
mode: apiKey
header: Authorization
token: env:PROVIDER_B_TOKEN
template: '{{ . }}'
proxyAuth:
mode: response
pool:
maxSize: 5000
shrinkDelay: 30s
capacity:
maxConcurrencyPerProxy: 20
lifecycle:
ttl: 5m
allocationSafetyMargin: 20s
fetch:
requestInterval: 1s
timeout: 3s
maxAttempts: 3
maxInFlight: 1
maxTotal: 100000
maxResponseBytes: 4194304
templateTimeout: 100ms
retry:
initial: 500ms
max: 30s
jitter: 20
check:
interval: 30s
jitter: 20
maxInFlight: 200
timeout: 3s
maxAttempts: 2
maxConsecutiveFailures: 3
urls: [https://example.com/]

165
deploy/docker-compose.yml Normal file
View File

@ -0,0 +1,165 @@
name: proxy-pool
x-app: &app
build:
context: ..
dockerfile: deploy/docker/Dockerfile
image: proxy-pool:local
restart: unless-stopped
networks: [frontend, backend]
volumes:
- ./config/local.yaml:/etc/proxy-pool/config.yaml:ro
environment:
PROXY_POOL_CONFIG: /etc/proxy-pool/config.yaml
PROXY_POOL_GATEWAY_PASSWORD: ${PROXY_POOL_GATEWAY_PASSWORD:?set PROXY_POOL_GATEWAY_PASSWORD}
PROXY_POOL_EXTRACT_TOKEN: ${PROXY_POOL_EXTRACT_TOKEN:?set PROXY_POOL_EXTRACT_TOKEN}
PROXY_POOL_ADMIN_TOKEN: ${PROXY_POOL_ADMIN_TOKEN:?set PROXY_POOL_ADMIN_TOKEN}
PROVIDER_A_TOKEN: ${PROVIDER_A_TOKEN:?set PROVIDER_A_TOKEN}
PROVIDER_B_TOKEN: ${PROVIDER_B_TOKEN:?set PROVIDER_B_TOKEN}
stop_grace_period: 45s
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
services:
gateway-a:
<<: *app
command: ["proxy-gateway"]
expose: ["8080", "9090"]
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9090/readyz"]
interval: 5s
timeout: 2s
retries: 12
start_period: 10s
gateway-b:
<<: *app
command: ["proxy-gateway"]
expose: ["8080", "9090"]
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9090/readyz"]
interval: 5s
timeout: 2s
retries: 12
start_period: 10s
controller:
<<: *app
command: ["proxy-controller"]
expose: ["8081", "8082", "9090"]
ports:
- "127.0.0.1:8081:8081"
- "127.0.0.1:8082:8082"
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9090/readyz"]
interval: 5s
timeout: 2s
retries: 12
start_period: 15s
checker:
<<: *app
command: ["proxy-checker"]
expose: ["9090"]
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9090/readyz"]
interval: 10s
timeout: 2s
retries: 12
start_period: 15s
haproxy:
image: haproxy:3.2-alpine
restart: unless-stopped
networks: [frontend]
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:8404:8404"
volumes:
- ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
depends_on:
gateway-a:
condition: service_healthy
gateway-b:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8404/healthz"]
interval: 5s
timeout: 2s
retries: 6
postgres:
image: postgres:18-alpine
restart: unless-stopped
networks: [backend]
environment:
POSTGRES_DB: proxy_pool
POSTGRES_USER: proxy_pool
POSTGRES_PASSWORD: local-only-change-me
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U proxy_pool -d proxy_pool"]
interval: 5s
timeout: 3s
retries: 12
redis:
image: redis:8.2-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1"]
networks: [backend]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 12
prometheus:
image: prom/prometheus:v3.5.0
restart: unless-stopped
networks: [frontend, backend]
ports:
- "127.0.0.1:9091:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./prometheus/rules:/etc/prometheus/rules:ro
- prometheus-data:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
- --web.enable-lifecycle
grafana:
image: grafana/grafana:12.1.0
restart: unless-stopped
networks: [backend]
ports:
- "127.0.0.1:3000:3000"
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: local-only-change-me
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana-data:/var/lib/grafana
depends_on: [prometheus]
networks:
frontend: {}
backend:
internal: true
volumes:
postgres-data: {}
redis-data: {}
prometheus-data: {}
grafana-data: {}

30
deploy/docker/Dockerfile Normal file
View File

@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG TARGETOS=linux
ARG TARGETARCH=amd64
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/proxy-gateway ./cmd/proxy-gateway && \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/proxy-controller ./cmd/proxy-controller && \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/proxy-checker ./cmd/proxy-checker && \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/proxy-loadgen ./cmd/proxy-loadgen
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl tini && \
rm -rf /var/lib/apt/lists/* && \
useradd --uid 10001 --create-home --shell /usr/sbin/nologin proxy-pool
COPY --from=build /out/ /usr/local/bin/
USER 10001:10001
ENTRYPOINT ["/usr/bin/tini", "--"]

View File

@ -0,0 +1,21 @@
{
"annotations": {"list": []},
"editable": true,
"graphTooltip": 1,
"panels": [
{"type":"timeseries","title":"Gateway QPS","gridPos":{"h":8,"w":8,"x":0,"y":0},"targets":[{"expr":"sum(rate(proxy_pool_gateway_requests_total[1m]))","legendFormat":"QPS"}]},
{"type":"timeseries","title":"Gateway p99","gridPos":{"h":8,"w":8,"x":8,"y":0},"targets":[{"expr":"histogram_quantile(0.99, sum by (le) (rate(proxy_pool_gateway_request_duration_seconds_bucket[5m])))","legendFormat":"p99"}]},
{"type":"timeseries","title":"Available Slots","gridPos":{"h":8,"w":8,"x":16,"y":0},"targets":[{"expr":"sum(proxy_pool_available_slots)","legendFormat":"slots"}]},
{"type":"timeseries","title":"Provider Fetch","gridPos":{"h":8,"w":12,"x":0,"y":8},"targets":[{"expr":"sum by (result) (rate(proxy_pool_provider_fetch_total[5m]))","legendFormat":"{{result}}"}]},
{"type":"timeseries","title":"Extraction","gridPos":{"h":8,"w":12,"x":12,"y":8},"targets":[{"expr":"sum by (result) (rate(proxy_pool_extraction_total[5m]))","legendFormat":"{{result}}"}]},
{"type":"timeseries","title":"Snapshot Age","gridPos":{"h":8,"w":12,"x":0,"y":16},"targets":[{"expr":"max by (worker) (proxy_pool_snapshot_age_seconds)","legendFormat":"{{worker}}"}]},
{"type":"timeseries","title":"Checker Queue","gridPos":{"h":8,"w":12,"x":12,"y":16},"targets":[{"expr":"sum(proxy_pool_checker_queue_depth)","legendFormat":"depth"}]}
],
"schemaVersion": 41,
"tags": ["proxy-pool"],
"templating": {"list": []},
"time": {"from":"now-6h","to":"now"},
"title": "Proxy Pool Overview",
"uid": "proxy-pool-overview",
"version": 1
}

View File

@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: proxy-pool
orgId: 1
folder: Proxy Pool
type: file
disableDeletion: true
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards

View File

@ -0,0 +1,10 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false

View File

@ -0,0 +1,39 @@
global
log stdout format raw local0
maxconn 100000
hard-stop-after 45s
defaults
log global
mode tcp
option tcplog
timeout connect 3s
timeout client 2m
timeout server 2m
timeout tunnel 1h
frontend proxy_gateway
bind :8080
default_backend gateway_workers
backend gateway_workers
balance leastconn
option tcp-check
default-server inter 2s fall 3 rise 2 slowstart 10s
server gateway-a gateway-a:8080 check resolvers docker init-addr libc,none
server gateway-b gateway-b:8080 check resolvers docker init-addr libc,none
resolvers docker
nameserver dns 127.0.0.11:53
resolve_retries 3
timeout resolve 1s
timeout retry 1s
hold valid 10s
frontend stats
mode http
bind :8404
http-request use-service prometheus-exporter if { path /metrics }
http-request return status 200 content-type text/plain string ok if { path /healthz }
stats enable
stats uri /stats

View File

@ -0,0 +1,47 @@
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: {name: proxy-gateway, namespace: proxy-pool}
spec:
scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: proxy-gateway}
minReplicas: 6
maxReplicas: 60
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- {type: Percent, value: 100, periodSeconds: 30}
- {type: Pods, value: 8, periodSeconds: 30}
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 600
policies: [{type: Percent, value: 10, periodSeconds: 60}]
metrics:
- type: Resource
resource:
name: cpu
target: {type: Utilization, averageUtilization: 55}
- type: Resource
resource:
name: memory
target: {type: Utilization, averageUtilization: 65}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: {name: proxy-checker, namespace: proxy-pool}
spec:
scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: proxy-checker}
minReplicas: 3
maxReplicas: 30
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies: [{type: Percent, value: 100, periodSeconds: 30}]
scaleDown:
stabilizationWindowSeconds: 300
policies: [{type: Percent, value: 20, periodSeconds: 60}]
metrics:
- type: Resource
resource:
name: cpu
target: {type: Utilization, averageUtilization: 60}

View File

@ -0,0 +1,21 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: proxy-gateway, namespace: proxy-pool}
spec:
maxUnavailable: 1
selector: {matchLabels: {app.kubernetes.io/name: proxy-gateway}}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: proxy-controller, namespace: proxy-pool}
spec:
minAvailable: 2
selector: {matchLabels: {app.kubernetes.io/name: proxy-controller}}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: proxy-checker, namespace: proxy-pool}
spec:
minAvailable: 2
selector: {matchLabels: {app.kubernetes.io/name: proxy-checker}}

View File

@ -0,0 +1,66 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: proxy-checker
namespace: proxy-pool
labels: {app.kubernetes.io/name: proxy-checker, app.kubernetes.io/part-of: proxy-pool}
spec:
replicas: 3
minReadySeconds: 5
revisionHistoryLimit: 3
strategy:
type: RollingUpdate
rollingUpdate: {maxUnavailable: 1, maxSurge: 1}
selector:
matchLabels: {app.kubernetes.io/name: proxy-checker}
template:
metadata:
labels: {app.kubernetes.io/name: proxy-checker, app.kubernetes.io/part-of: proxy-pool}
annotations: {prometheus.io/scrape: "true", prometheus.io/port: "9090", prometheus.io/path: /metrics}
spec:
serviceAccountName: proxy-pool
automountServiceAccountToken: false
terminationGracePeriodSeconds: 45
securityContext: {runAsNonRoot: true, seccompProfile: {type: RuntimeDefault}}
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector: {matchLabels: {app.kubernetes.io/name: proxy-checker}}
containers:
- name: checker
image: REGISTRY/proxy-pool:VERSION
imagePullPolicy: IfNotPresent
command: [proxy-checker]
env:
- {name: PROXY_POOL_CONFIG, value: /etc/proxy-pool/config.yaml}
envFrom:
- secretRef: {name: proxy-pool-secrets}
ports:
- {name: metrics, containerPort: 9090}
readinessProbe:
httpGet: {path: /readyz, port: metrics}
periodSeconds: 5
timeoutSeconds: 2
livenessProbe:
httpGet: {path: /livez, port: metrics}
periodSeconds: 10
timeoutSeconds: 2
lifecycle:
preStop: {exec: {command: [sh, -c, "sleep 3"]}}
resources:
requests: {cpu: "1", memory: 512Mi}
limits: {cpu: "2", memory: 1Gi}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL]}
volumeMounts:
- {name: config, mountPath: /etc/proxy-pool, readOnly: true}
- {name: tmp, mountPath: /tmp}
volumes:
- name: config
configMap: {name: proxy-pool-config}
- name: tmp
emptyDir: {sizeLimit: 64Mi}

View File

@ -0,0 +1,172 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: proxy-pool-config
namespace: proxy-pool
data:
config.yaml: |
version: 1
security:
requireProtectionOnPublicListen: true
gateway:
enabled: true
listen: 0.0.0.0:8080
access:
allowCIDRs: [0.0.0.0/0]
trustedProxies: []
auth:
mode: usernamePassword
username: env:PROXY_POOL_GATEWAY_USERNAME
password: env:PROXY_POOL_GATEWAY_PASSWORD
limits:
maxConcurrentConnections: 100000
requestsPerMinutePerClient: 60000
retry:
maxAttempts: 2
retryMethods: [GET, HEAD]
destinationPolicy:
denyPrivateNetworks: true
denyLoopback: true
denyLinkLocal: true
denyCIDRs: [169.254.169.254/32]
distribution:
enabled: true
listen: 0.0.0.0:8081
access:
allowCIDRs: [10.0.0.0/8]
trustedProxies: [10.0.0.0/8]
auth:
mode: apiKey
header: X-API-Key
token: env:PROXY_POOL_EXTRACT_TOKEN
limits:
requestsPerMinute: 30000
requestsPerMinutePerClient: 3000
clientIdentification:
mode: trustedProxyOrRemoteIP
extraction:
fulfillment: partial
maxCountPerRequest: 100
minRemainingTTL: 30s
maxHealthCheckAge: 30s
reserveForGateway: 5000
admin:
enabled: true
listen: 0.0.0.0:8082
access:
allowCIDRs: [10.0.0.0/8]
auth:
mode: apiKey
header: X-Admin-Token
token: env:PROXY_POOL_ADMIN_TOKEN
metrics:
enabled: true
listen: 0.0.0.0:9090
storage:
postgresURL: env:PROXY_POOL_POSTGRES_URL
redisURL: env:PROXY_POOL_REDIS_URL
routing:
- name: gateway-default
enabled: true
purpose: gateway
upstreams: [provider-a, provider-b]
strategy:
type: sequential
switchAfterEmptyFetch: 5
endBehavior: stayLast
onUnavailable:
action: reject
- name: extract-default
enabled: true
purpose: extract
upstreams: [provider-a, provider-b]
strategy:
type: sequential
switchAfterEmptyFetch: 5
endBehavior: stayLast
onUnavailable:
action: reject
upstreams:
provider-a:
enabled: true
exposure: [gateway, extract]
provider:
billingMode: fetch
protocols: [http]
api:
url: https://PROVIDER_A_HOST/api/proxies
method: GET
auth:
mode: apiKey
header: Authorization
token: env:PROVIDER_A_TOKEN
template: '{{ . }}'
proxyAuth:
mode: response
pool:
maxSize: 25000
shrinkDelay: 30s
capacity:
maxConcurrencyPerProxy: 20
lifecycle:
ttl: 5m
allocationSafetyMargin: 20s
fetch:
requestInterval: 1s
timeout: 3s
maxAttempts: 3
maxInFlight: 1
maxTotal: 1000000
maxResponseBytes: 4194304
templateTimeout: 100ms
retry: {initial: 500ms, max: 30s, jitter: 20}
check:
interval: 30s
jitter: 20
maxInFlight: 500
timeout: 3s
maxAttempts: 2
maxConsecutiveFailures: 3
urls: [https://example.com/]
provider-b:
enabled: true
exposure: [gateway, extract]
provider:
billingMode: fetch
protocols: [http]
api:
url: https://PROVIDER_B_HOST/api/proxies
method: GET
auth:
mode: apiKey
header: Authorization
token: env:PROVIDER_B_TOKEN
template: '{{ . }}'
proxyAuth:
mode: response
pool:
maxSize: 25000
shrinkDelay: 30s
capacity:
maxConcurrencyPerProxy: 20
lifecycle:
ttl: 5m
allocationSafetyMargin: 20s
fetch:
requestInterval: 1s
timeout: 3s
maxAttempts: 3
maxInFlight: 1
maxTotal: 1000000
maxResponseBytes: 4194304
templateTimeout: 100ms
retry: {initial: 500ms, max: 30s, jitter: 20}
check:
interval: 30s
jitter: 20
maxInFlight: 500
timeout: 3s
maxAttempts: 2
maxConsecutiveFailures: 3
urls: [https://example.com/]

View File

@ -0,0 +1,92 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: proxy-controller
namespace: proxy-pool
labels: {app.kubernetes.io/name: proxy-controller, app.kubernetes.io/part-of: proxy-pool}
spec:
replicas: 3
minReadySeconds: 10
revisionHistoryLimit: 3
strategy:
type: RollingUpdate
rollingUpdate: {maxUnavailable: 1, maxSurge: 1}
selector:
matchLabels: {app.kubernetes.io/name: proxy-controller}
template:
metadata:
labels: {app.kubernetes.io/name: proxy-controller, app.kubernetes.io/part-of: proxy-pool}
annotations: {prometheus.io/scrape: "true", prometheus.io/port: "9090", prometheus.io/path: /metrics}
spec:
serviceAccountName: proxy-pool
automountServiceAccountToken: false
terminationGracePeriodSeconds: 60
securityContext: {runAsNonRoot: true, seccompProfile: {type: RuntimeDefault}}
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector: {matchLabels: {app.kubernetes.io/name: proxy-controller}}
containers:
- name: controller
image: REGISTRY/proxy-pool:VERSION
imagePullPolicy: IfNotPresent
command: [proxy-controller]
env:
- {name: PROXY_POOL_CONFIG, value: /etc/proxy-pool/config.yaml}
envFrom:
- secretRef: {name: proxy-pool-secrets}
ports:
- {name: distribution, containerPort: 8081}
- {name: admin, containerPort: 8082}
- {name: control, containerPort: 8443}
- {name: metrics, containerPort: 9090}
readinessProbe:
httpGet: {path: /readyz, port: metrics}
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet: {path: /livez, port: metrics}
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
startupProbe:
httpGet: {path: /livez, port: metrics}
periodSeconds: 2
failureThreshold: 45
lifecycle:
preStop: {exec: {command: [sh, -c, "sleep 5"]}}
resources:
requests: {cpu: "1", memory: 1Gi}
limits: {cpu: "2", memory: 2Gi}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL]}
volumeMounts:
- {name: config, mountPath: /etc/proxy-pool, readOnly: true}
- {name: tmp, mountPath: /tmp}
volumes:
- name: config
configMap: {name: proxy-pool-config}
- name: tmp
emptyDir: {sizeLimit: 64Mi}
---
apiVersion: v1
kind: Service
metadata:
name: proxy-controller
namespace: proxy-pool
labels: {app.kubernetes.io/name: proxy-controller}
spec:
type: ClusterIP
selector: {app.kubernetes.io/name: proxy-controller}
ports:
- {name: distribution, port: 8081, targetPort: distribution}
- {name: admin, port: 8082, targetPort: admin}
- {name: control, port: 8443, targetPort: control}
- {name: metrics, port: 9090, targetPort: metrics}

View File

@ -0,0 +1,92 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: proxy-gateway
namespace: proxy-pool
labels: {app.kubernetes.io/name: proxy-gateway, app.kubernetes.io/part-of: proxy-pool}
spec:
replicas: 6
minReadySeconds: 10
revisionHistoryLimit: 3
strategy:
type: RollingUpdate
rollingUpdate: {maxUnavailable: 1, maxSurge: 2}
selector:
matchLabels: {app.kubernetes.io/name: proxy-gateway}
template:
metadata:
labels: {app.kubernetes.io/name: proxy-gateway, app.kubernetes.io/part-of: proxy-pool}
annotations: {prometheus.io/scrape: "true", prometheus.io/port: "9090", prometheus.io/path: /metrics}
spec:
serviceAccountName: proxy-pool
automountServiceAccountToken: false
terminationGracePeriodSeconds: 60
securityContext: {runAsNonRoot: true, seccompProfile: {type: RuntimeDefault}}
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector: {matchLabels: {app.kubernetes.io/name: proxy-gateway}}
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector: {matchLabels: {app.kubernetes.io/name: proxy-gateway}}
containers:
- name: gateway
image: REGISTRY/proxy-pool:VERSION
imagePullPolicy: IfNotPresent
command: [proxy-gateway]
env:
- {name: PROXY_POOL_CONFIG, value: /etc/proxy-pool/config.yaml}
envFrom:
- secretRef: {name: proxy-pool-secrets}
ports:
- {name: proxy, containerPort: 8080, protocol: TCP}
- {name: metrics, containerPort: 9090, protocol: TCP}
readinessProbe:
httpGet: {path: /readyz, port: metrics}
periodSeconds: 3
timeoutSeconds: 1
failureThreshold: 3
livenessProbe:
httpGet: {path: /livez, port: metrics}
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
startupProbe:
httpGet: {path: /livez, port: metrics}
periodSeconds: 2
failureThreshold: 30
lifecycle:
preStop: {exec: {command: [sh, -c, "sleep 5"]}}
resources:
requests: {cpu: "2", memory: 1Gi}
limits: {cpu: "4", memory: 2Gi}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL]}
volumeMounts:
- {name: config, mountPath: /etc/proxy-pool, readOnly: true}
- {name: tmp, mountPath: /tmp}
volumes:
- name: config
configMap: {name: proxy-pool-config}
- name: tmp
emptyDir: {sizeLimit: 64Mi}
---
apiVersion: v1
kind: Service
metadata:
name: proxy-gateway
namespace: proxy-pool
labels: {app.kubernetes.io/name: proxy-gateway}
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: nlb
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector: {app.kubernetes.io/name: proxy-gateway}
ports:
- {name: proxy, port: 8080, targetPort: proxy, protocol: TCP}

View File

@ -0,0 +1,17 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- serviceaccount.yaml
- configmap.yaml
- gateway.yaml
- controller.yaml
- checker.yaml
- availability.yaml
- autoscaling.yaml
- networkpolicy.yaml
images:
- name: REGISTRY/proxy-pool
newName: REGISTRY/proxy-pool
newTag: VERSION

View File

@ -0,0 +1,10 @@
apiVersion: v1
kind: Namespace
metadata:
name: proxy-pool
labels:
app.kubernetes.io/part-of: proxy-pool
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted

View File

@ -0,0 +1,52 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny, namespace: proxy-pool}
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: gateway-traffic, namespace: proxy-pool}
spec:
podSelector: {matchLabels: {app.kubernetes.io/name: proxy-gateway}}
policyTypes: [Ingress, Egress]
ingress:
- ports: [{port: 8080, protocol: TCP}]
- from:
- namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: monitoring}}
ports: [{port: 9090, protocol: TCP}]
egress:
- to:
- namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}
ports: [{port: 53, protocol: UDP}, {port: 53, protocol: TCP}]
- to:
- podSelector: {matchLabels: {app.kubernetes.io/name: proxy-controller}}
ports: [{port: 8443, protocol: TCP}]
# Gateway 需要连接任意公网目标;应用层 DestinationPolicy 仍拒绝私网、回环和元数据地址。
- to:
- ipBlock:
cidr: 0.0.0.0/0
except: [10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: control-plane-traffic, namespace: proxy-pool}
spec:
podSelector:
matchExpressions:
- {key: app.kubernetes.io/name, operator: In, values: [proxy-controller, proxy-checker]}
policyTypes: [Ingress, Egress]
ingress:
- from:
- namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: proxy-pool}}
- from:
- namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: monitoring}}
ports: [{port: 9090, protocol: TCP}]
egress:
- to:
- namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}
ports: [{port: 53, protocol: UDP}, {port: 53, protocol: TCP}]
# Provider、健康目标及外部托管 PostgreSQL/Redis 的精确网段应在环境 Overlay 收紧。
- to: [{ipBlock: {cidr: 0.0.0.0/0}}]

View File

@ -0,0 +1,16 @@
apiVersion: v1
kind: Secret
metadata:
name: proxy-pool-secrets
namespace: proxy-pool
type: Opaque
stringData:
PROXY_POOL_GATEWAY_USERNAME: GATEWAY_USER
PROXY_POOL_GATEWAY_PASSWORD: GATEWAY_PASSWORD
PROXY_POOL_EXTRACT_TOKEN: EXTRACT_TOKEN
PROXY_POOL_ADMIN_TOKEN: ADMIN_TOKEN
PROXY_POOL_POSTGRES_URL: postgres://USER:PASSWORD@POSTGRES_HOST:5432/proxy_pool?sslmode=verify-full
PROXY_POOL_REDIS_URL: rediss://:PASSWORD@REDIS_HOST:6379/0
PROVIDER_A_TOKEN: PROVIDER_A_TOKEN
PROVIDER_B_TOKEN: PROVIDER_B_TOKEN

View File

@ -0,0 +1,7 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: proxy-pool
namespace: proxy-pool
automountServiceAccountToken: false

View File

@ -0,0 +1,24 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
environment: local
rule_files:
- /etc/prometheus/rules/*.yml
scrape_configs:
- job_name: proxy-gateway
static_configs:
- targets: [gateway-a:9090, gateway-b:9090]
- job_name: proxy-controller
static_configs:
- targets: [controller:9090]
- job_name: proxy-checker
static_configs:
- targets: [checker:9090]
- job_name: haproxy
metrics_path: /metrics
static_configs:
- targets: [haproxy:8404]

View File

@ -0,0 +1,48 @@
groups:
- name: proxy-pool
rules:
- alert: ProxyPoolGatewayHighErrorRate
expr: |
sum(rate(proxy_pool_gateway_requests_total{result="error"}[5m]))
/ clamp_min(sum(rate(proxy_pool_gateway_requests_total[5m])), 1) > 0.02
for: 10m
labels:
severity: warning
annotations:
summary: Gateway 错误率持续高于 2%
- alert: ProxyPoolGatewaySnapshotStale
expr: proxy_pool_snapshot_age_seconds > 60
for: 2m
labels:
severity: critical
annotations:
summary: Gateway Snapshot 已超过安全陈旧时间
- alert: ProxyPoolNoAvailableSlots
expr: sum(proxy_pool_available_slots) == 0
for: 1m
labels:
severity: critical
annotations:
summary: Gateway 可分配容量耗尽
- alert: ProxyPoolProviderFetchErrors
expr: sum by (upstream) (rate(proxy_pool_provider_fetch_total{result="error"}[10m])) > 0.2
for: 10m
labels:
severity: warning
annotations:
summary: Provider Fetch 错误持续发生
- alert: ProxyPoolCheckerBacklog
expr: proxy_pool_checker_queue_depth > 10000
for: 5m
labels:
severity: warning
annotations:
summary: Checker 队列积压
- alert: ProxyPoolExtractionConflict
expr: sum(rate(proxy_pool_extraction_total{result="conflict"}[5m])) > 0
for: 5m
labels:
severity: warning
annotations:
summary: 独占提取发生持续事务冲突

View File

@ -0,0 +1,29 @@
// configcheck 使用与进程启动相同的严格加载器校验部署配置。
package main
import (
"fmt"
"os"
"github.com/proxy-pool/proxy-pool/internal/config"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: configcheck CONFIG_FILE")
os.Exit(2)
}
file, err := os.Open(os.Args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "open config: %v\n", err)
os.Exit(1)
}
defer file.Close()
if _, err := config.Load(file); err != nil {
fmt.Fprintf(os.Stderr, "invalid config: %v\n", err)
os.Exit(1)
}
fmt.Printf("valid config: %s\n", os.Args[1])
}

536
diagrams/README.md Normal file
View File

@ -0,0 +1,536 @@
# Proxy Pool Mermaid 图集
本图集依据最终需求语义绘制。图中的 100k QPS 是待压测验证的集群目标Extract
均表示一次性独占发放,不存在 Lease、Renew 或 Release。
## 01 系统上下文
```mermaid
flowchart LR
Client[Gateway Client] --> LB[Layer 4 Load Balancer]
ExtractClient[Extract Client] --> Dist[Distribution API]
Operator[Operator] --> Admin[Admin API]
LB --> Gateway[Gateway Cluster]
Gateway --> Internet[Target via Proxy]
Dist --> Controller[Controller Cluster]
Admin --> Controller
Controller --> Provider[Provider APIs]
Controller --> Checker[Checker Cluster]
Controller --> PG[(PostgreSQL)]
Controller --> Redis[(Redis)]
```
## 02 进程职责边界
```mermaid
flowchart TB
subgraph DataPlane[Data Plane]
G[proxy-gateway]
Snap[Immutable Snapshot]
G --> Snap
end
subgraph ControlPlane[Control Plane]
C[proxy-controller]
K[proxy-checker]
C <--> K
end
subgraph Tools[Tools]
L[proxy-loadgen]
end
C -->|Snapshot and ownership| G
G -->|batched outcomes| C
L --> G
```
## 03 领域模块依赖
```mermaid
flowchart TD
Cmd[cmd assembly] --> Gateway[gateway modules]
Cmd --> Controller[controller modules]
Cmd --> Adapters[adapters]
Gateway --> Domain[domain]
Controller --> Domain
Adapters --> Domain
Domain -. no import .-> HTTP[(HTTP)]
Domain -. no import .-> SQL[(SQL)]
Domain -. no import .-> Redis[(Redis)]
```
## 04 Gateway 请求路径
```mermaid
sequenceDiagram
participant C as Client
participant G as Gateway
participant D as Dispatcher
participant T as Transport
participant P as Proxy
C->>G: HTTP request
G->>G: auth and admission
G->>D: acquire route
D->>D: reserve capacity with CAS
D-->>G: allocation
G->>T: execute
T->>P: dial and handshake
P-->>T: response
T-->>C: stream response
T->>D: release active and report
```
## 05 CONNECT 提交点
```mermaid
stateDiagram-v2
[*] --> Accepted
Accepted --> Reserved: Acquire
Reserved --> Dialing: dial proxy
Dialing --> Cancelled: fail before commit
Dialing --> Active: proxy CONNECT succeeds
Active --> TunnelCommitted: send 200 to client
TunnelCommitted --> Closed: stream ends
Cancelled --> [*]
Closed --> [*]
note right of TunnelCommitted: transparent replay forbidden
```
## 06 HTTP 安全重试决策
```mermaid
flowchart TD
F[Attempt failed] --> H{Headers sent to client?}
H -->|yes| Stop[Do not retry]
H -->|no| M{Method allowed?}
M -->|no| Stop
M -->|GET or HEAD| A{Attempts remain?}
A -->|no| Stop
A -->|yes| X[Exclude failed Proxy]
X --> N[Acquire another Proxy]
```
## 07 Routing 首条命中
```mermaid
flowchart TD
Req[RouteRequest] --> R1{Rule 1 matches?}
R1 -->|yes| U1[Use Rule 1 upstream strategy]
R1 -->|no| R2{Rule 2 matches?}
R2 -->|yes| U2[Use Rule 2 upstream strategy]
R2 -->|no| RN{Default rule matches?}
RN -->|yes| UN[Use default strategy]
RN -->|no| Reject[Apply onUnavailable]
```
## 08 Sequential 原子切换
```mermaid
sequenceDiagram
participant F1 as Fetch goroutine 1
participant F2 as Fetch goroutine 2
participant U as Upstream A counter
participant R as Routing state
F1->>U: empty reaches threshold
F2->>U: concurrent empty
U->>R: depleted generation 7
U->>R: depleted generation 7
R->>R: CAS A to B succeeds once
R-->>F1: current B
R-->>F2: current B
```
## 09 Provider Fetch 调度
```mermaid
flowchart TD
Signal[Capacity signal] --> SF{Fetch already running?}
SF -->|yes| Merge[Merge into bounded signal]
SF -->|no| Leader[Acquire logical leader]
Leader --> Demand[Recompute slot demand]
Demand --> Limit{Below maxSize and maxTotal?}
Limit -->|no| Done[Stop]
Limit -->|yes| Rate[Wait requestInterval]
Rate --> Call[Call Provider under maxInFlight]
Call --> Classify[Classify result]
```
## 10 Fetch 结果分类
```mermaid
flowchart LR
Response[Provider response] --> Transport{Transport and auth valid?}
Transport -->|no| Error[Error and backoff]
Transport -->|yes| Parse{Template and parse valid?}
Parse -->|no| Error
Parse -->|yes| Legal{Legal candidates count}
Legal -->|zero| Empty[Empty plus one]
Legal -->|positive| New{New after dedupe?}
New -->|none| Duplicate[Duplicate-only and reset Empty]
New -->|some| Success[Success and reset Empty]
```
## 11 退避与 Retry-After
```mermaid
stateDiagram-v2
[*] --> Ready
Ready --> Calling: rate token acquired
Calling --> Ready: success or empty
Calling --> RetryAfter: HTTP 429
Calling --> Backoff: timeout or server error
RetryAfter --> Ready: provider deadline reached
Backoff --> Ready: exponential delay plus jitter
Backoff --> Open: max attempts exhausted
Open --> Ready: next scheduled cycle
```
## 12 Pool Reconcile
```mermaid
flowchart TD
Inventory[Managed inventory] --> Count[Count states and pending expected]
Capacity[Available slots] --> Need[Compute demand]
Count --> Bound{pool maxSize reached?}
Need --> Bound
Bound -->|yes| NoFetch[Do not fetch]
Bound -->|no| Quota{fetch maxTotal reached?}
Quota -->|yes| NoFetch
Quota -->|no| Fetch[Schedule bounded fetch]
```
## 13 Proxy 生命周期
```mermaid
stateDiagram-v2
[*] --> FETCHED
FETCHED --> CHECKING
CHECKING --> AVAILABLE: passed
CHECKING --> UNHEALTHY: exhausted
AVAILABLE --> SUSPECT: meaningful failure
SUSPECT --> AVAILABLE: recheck passed
SUSPECT --> UNHEALTHY: failures reached
AVAILABLE --> DRAINING: expiry or revoke
AVAILABLE --> EXTRACTED: exclusive transaction
DRAINING --> EXPIRED: capacity zero
UNHEALTHY --> REMOVED
EXTRACTED --> EXPIRED: TTL reached
EXPIRED --> REMOVED
```
## 14 原子容量转换
```mermaid
flowchart LR
C0[active A reserved R] --> Check{A plus R below limit?}
Check -->|no| Full[Reject candidate]
Check -->|yes CAS| Reserved[active A reserved R plus 1]
Reserved -->|Commit| Active[active A plus 1 reserved R]
Reserved -->|Cancel| C0
Active -->|Release| C0
```
## 15 Worker 所有权
```mermaid
flowchart TB
Controller[Controller allocator] -->|epoch 12| W1[Worker 1]
Controller -->|epoch 12| W2[Worker 2]
P1[Proxy shard A] --> W1
P2[Proxy shard B] --> W2
U[Unowned inventory] --> Controller
W1 -. cannot allocate .-> P2
W2 -. cannot allocate .-> P1
```
## 16 Snapshot 发布与 ACK
```mermaid
sequenceDiagram
participant DB as PostgreSQL
participant C as Controller
participant W as Worker
DB->>C: outbox revision 42
C->>C: build worker snapshot
C->>W: epoch 8 version 42 checksum
W->>W: validate and build indexes
W->>W: atomic swap
W-->>C: ACK epoch 8 version 42
C->>DB: mark outbox delivered
```
## 17 Snapshot 缺口恢复
```mermaid
flowchart TD
Delta[Receive delta version 45] --> Current{Current version is 44?}
Current -->|yes| Check[Verify checksum and epoch]
Current -->|no current 42| Reject[Reject delta]
Reject --> Full[Request full snapshot]
Full --> Build[Build indexes in background]
Check --> Apply[Apply delta atomically]
Build --> Apply
```
## 18 Snapshot 陈旧状态
```mermaid
stateDiagram-v2
[*] --> Fresh
Fresh --> StaleAllowed: controller disconnected
StaleAllowed --> Fresh: valid snapshot received
StaleAllowed --> DrainOnly: maxStaleAge exceeded
DrainOnly --> Fresh: full snapshot and new epoch
DrainOnly --> Stopped: existing traffic drained
```
## 19 健康任务调度
```mermaid
flowchart TD
Proxies[Proxy inventory] --> Priority{State priority}
Priority -->|new| New[Immediate basic check]
Priority -->|SUSPECT| Fast[Fast recheck]
Priority -->|stable AVAILABLE| Normal[Normal interval]
New --> Jitter[Stable hash plus jitter]
Fast --> Jitter
Normal --> Jitter
Jitter --> Bound[maxInFlight semaphore]
Bound --> Checker[Checker workers]
```
## 20 健康 Observation Reducer
```mermaid
flowchart LR
Obs[Health Observation] --> Scope{Scope}
Scope -->|global| Global[Global health reducer]
Scope -->|route target| Target[Target profile reducer]
Global --> Consecutive[Consecutive outcome state]
Consecutive --> Transition[AVAILABLE SUSPECT UNHEALTHY]
Target --> RouteHealth[Only affected route health]
RouteHealth -. no direct global delete .-> Transition
```
## 21 Extract partial
```mermaid
sequenceDiagram
participant C as Client
participant API as Distribution
participant DB as PostgreSQL
C->>API: count 10 fulfillment partial
API->>DB: lock eligible rows
DB-->>API: 6 rows
API->>DB: update 6 to EXTRACTED and audit
DB-->>API: commit
API-->>C: requested 10 returned 6
```
## 22 Extract allOrNothing
```mermaid
sequenceDiagram
participant C as Client
participant API as Distribution
participant DB as PostgreSQL
C->>API: count 10 fulfillment allOrNothing
API->>DB: lock eligible rows
DB-->>API: only 6 rows
API->>DB: rollback entire transaction
API-->>C: insufficient inventory and returned 0
```
## 23 Gateway 所有权回收后提取
```mermaid
sequenceDiagram
participant C as Controller
participant W as Gateway Worker
participant DB as PostgreSQL
C->>W: mark Proxy DRAINING at epoch 13
W->>W: stop new allocations
W-->>C: ACK active 0 reserved 0
C->>DB: clear worker ownership
C->>DB: AVAILABLE to EXTRACTED plus audit
DB-->>C: committed exclusive result
```
## 24 reserveForGateway 不变量
```mermaid
flowchart TD
Eligible[Eligible unowned count] --> Formula[extractable equals eligible minus reserve]
Reserve[reserveForGateway] --> Formula
Requested[requested count] --> Min[return min requested and extractable]
Formula --> Min
Min --> Result{fulfillment}
Result -->|partial| Commit[Commit available quantity]
Result -->|allOrNothing insufficient| Rollback[Return zero]
```
## 25 PostgreSQL 独占事务
```mermaid
flowchart TD
Begin[BEGIN] --> Select[SELECT eligible FOR UPDATE SKIP LOCKED]
Select --> Enough{Quantity satisfies mode?}
Enough -->|no allOrNothing| Rollback[ROLLBACK]
Enough -->|yes or partial| Update[UPDATE AVAILABLE to EXTRACTED]
Update --> Audit[INSERT extraction records]
Audit --> Outbox[INSERT outbox]
Outbox --> Commit[COMMIT]
Commit --> Return[Return proxies with expiry]
```
## 26 并发提取互斥
```mermaid
sequenceDiagram
participant A as Extract request A
participant DB as PostgreSQL
participant B as Extract request B
A->>DB: lock rows 1 to 10
B->>DB: skip locked rows 1 to 10
B->>DB: lock rows 11 to 20
A->>DB: commit EXTRACTED 1 to 10
B->>DB: commit EXTRACTED 11 to 20
Note over A,B: no Proxy returned twice
```
## 27 Outbox 一致性
```mermaid
sequenceDiagram
participant C as Controller transaction
participant DB as PostgreSQL
participant P as Publisher
participant W as Worker
C->>DB: state change plus outbox
DB-->>C: atomic commit
P->>DB: read undelivered event
P->>W: publish versioned event
W-->>P: idempotent ACK
P->>DB: mark delivered
```
## 28 配置热更新
```mermaid
flowchart LR
File[Read revision] --> Parse[Strict parse]
Parse --> Validate[References regex and security]
Validate --> Build[Build immutable config]
Build --> Diff[Diff tasks and resources]
Diff --> Swap[Atomic swap]
Swap --> Drain[Drain removed resources]
Validate -->|error| Keep[Keep old revision]
```
## 29 Gateway 优雅停机
```mermaid
sequenceDiagram
participant K as Kubernetes
participant G as Gateway
participant LB as Load Balancer
K->>G: SIGTERM
G->>G: readiness false
LB->>LB: remove endpoint
G->>G: reject new connections
G->>G: drain requests and tunnels
G->>G: release active capacities
G-->>K: exit before grace timeout
```
## 30 Controller 优雅停机
```mermaid
flowchart TD
Term[SIGTERM] --> NotReady[Readiness false]
NotReady --> StopWrites[Stop new Extract and Admin writes]
StopWrites --> StopFetch[Stop new Fetch]
StopFetch --> FinishTx[Commit or rollback current transactions]
FinishTx --> Flush[Flush outbox and reports]
Flush --> Lease[Release Provider leader lease]
Lease --> Exit[Close pools and exit]
```
## 31 Kubernetes 故障域拓扑
```mermaid
flowchart TB
LB[Load Balancer] --> ZA
LB --> ZB
LB --> ZC
subgraph ZA[Zone A]
GA1[Gateway]
CA[Controller]
KA[Checker]
end
subgraph ZB[Zone B]
GB1[Gateway]
CB[Controller]
KB[Checker]
end
subgraph ZC[Zone C]
GC1[Gateway]
CC[Controller]
KC[Checker]
end
```
## 32 Gateway 扩缩决策
```mermaid
flowchart TD
Metrics[QPS CPU memory connections latency] --> HPA[HPA decision]
HPA --> Up{Above target?}
Up -->|yes| ScaleUp[Scale up quickly]
Up -->|no| Stable{Stable below target for 10m?}
Stable -->|no| Hold[Hold replicas]
Stable -->|yes| Capacity{Failure-domain headroom remains?}
Capacity -->|no| Hold
Capacity -->|yes| ScaleDown[Scale down at most 10 percent]
```
## 33 网络信任边界
```mermaid
flowchart LR
Internet --> NLB[Public NLB]
NLB --> Gateway[Gateway 8080]
Ingress[Private Ingress] --> Distribution[Distribution 8081]
Operator[Operator VPN] --> Admin[Admin 8082]
Monitor[Monitoring namespace] --> Metrics[Metrics 9090]
Gateway --> PublicTargets[Public targets only]
Controller[Controller] --> Stores[External PG and Redis]
Controller --> Providers[Provider APIs]
```
## 34 可观测信号流
```mermaid
flowchart LR
Gateway -->|bounded metrics| Prom[Prometheus]
Controller -->|bounded metrics| Prom
Checker -->|bounded metrics| Prom
Prom --> Grafana[Grafana dashboards]
Prom --> Alerts[Alert rules]
Gateway -->|sampled redacted logs| Logs[Log backend]
Controller -->|audit events| Audit[(Audit storage)]
Alerts --> OnCall[On-call]
```
## 35 100k QPS 验证流程
```mermaid
flowchart TD
Baseline[Measure one Worker on production shape] --> Formula[Compute replicas at 60 percent target]
Formula --> Warmup[Warm Snapshot and connections]
Warmup --> Steady[Run 10k steady]
Steady --> Ramp[Step ramp to 100k]
Ramp --> Peak[Hold 100k peak window]
Peak --> Failure[Remove largest failure domain]
Failure --> Verify[Verify SLO and all invariants]
Verify --> Evidence[Archive raw metrics config and image digest]
```

34
docs/adr/README.md Normal file
View File

@ -0,0 +1,34 @@
# 架构决策记录
## ADR-001控制面与数据面分离
**状态:** 接受。
Gateway 只依赖本地不可变 SnapshotProvider、数据库、配置重载和健康聚合
位于 Controller/Checker。该选择隔离外部 I/O 抖动,并允许数据面按 QPS、
控制面按 Provider/库存规模独立扩容。
## ADR-002Distribution 使用独占提取
**状态:** 接受并覆盖早期 Lease 方案。
成功响应前在一个事务中执行 `AVAILABLE -> EXTRACTED`。系统保存审计事实,
但不提供 release、renew 或使用跟踪。这样契合“拿走真实代理后平台不再管理”
的最终产品语义,并消除重复发放。
## ADR-003单 Worker 所有权
**状态:** 接受。
一个 Proxy 同一时刻至多归属一个 WorkerGateway 在本地维护 Active/Reserved。
Distribution 只提取无所有权 Proxy回收时执行 drain/ACK/归零/解除所有权。
该选择避免每请求访问 Redis 做全局并发计数。
## ADR-004PostgreSQL 权威、Redis 可重建
**状态:** 接受。
Proxy 状态、Extraction、配置版本和 Outbox 由 PostgreSQL 持久化。Redis 只
承担 Leader、短期速率与心跳等协调Redis 丢失后可从权威状态恢复,避免
双写状态成为不可判定的事实源。

18
docs/api/admin.md Normal file
View File

@ -0,0 +1,18 @@
# Admin API
Admin API 使用独立监听器与权限,契约位于 `api/openapi/admin.yaml`。公网部署
不得与 Distribution 复用认证 Token推荐只绑定管理网段或回环地址。
## 端点
- `GET /api/v1/status`:返回配置/快照版本、Upstream 聚合计数和 Worker 状态。
- `POST /api/v1/upstreams/{name}/enable`:启用 Upstream。
- `POST /api/v1/upstreams/{name}/disable`:停止新 Fetch/分配并自然 Drain。
- `POST /api/v1/routing/{name}/switch`:用 expectedCurrent 做 CAS 手工切换。
- `POST /api/v1/config/reload`:严格解析并原子发布新配置。
所有写操作写审计记录并返回最终 Request ID 与版本。Enable/Disable 对目标状态
幂等Routing Switch 必须携带 `expectedCurrent`,避免并发操作跳过多个供应商。
配置重载校验失败返回 422旧配置继续运行。Status 只返回低基数聚合信息,
不得返回 Proxy 地址、凭据、Client 标识或完整 Provider URL。

103
docs/api/control-plane.md Normal file
View File

@ -0,0 +1,103 @@
# Control Plane gRPC API
## 1. 契约范围
Proto 源文件位于 `api/proto/controlplane/v1/controlplane.proto`,包含两项
内部服务:
- `WorkerControlPlane`Worker 注册、Snapshot/Delta 分发、ACK、运行态与结果
批量上报。
- `CheckerControlPlane`:健康检查任务流和 Observation 批量上报。
该协议不承载 Client 的独占提取,也没有 extraction lease/release。Proxy 的
`AVAILABLE -> EXTRACTED` 只在 Controller 权威事务中完成。
## 2. Worker 会话
```mermaid
sequenceDiagram
participant W as Gateway Worker
participant C as Controller
W->>C: RegisterWorker(worker, instance, zone)
C-->>W: session + ownershipEpoch + maxStaleAge
W->>C: WatchSnapshots(lastVersion, checksum)
C-->>W: full WorkerSnapshot
W->>W: validate + build immutable snapshot
W->>W: atomic swap
W->>C: AcknowledgeSnapshot(version, epoch, checksum)
loop bounded interval
W->>C: ReportRuntime(active, reserved, draining)
W->>C: ReportOutcomes(batch sequence)
end
```
`worker_id` 是逻辑节点,`instance_id` 区分进程重启,`session_id` 防止旧进程
继续上报。所有权 `epoch` 小于 Controller 当前值的数据必须拒绝。
## 3. Snapshot 与 Delta
完整 Snapshot 包含:
- 单调 `version`、`ownership_epoch`、生成时间和有效期。
- 对该 Worker 可见的有序 Routing。
- 仅归该 Worker 所有的 Proxy 与每个 Proxy 的容量。
- 内容 `checksum`
Delta 声明 `base_version`。Worker 只有在本地版本恰好等于 base 且 checksum
验证成功时才能应用;否则丢弃 Delta 并请求完整 Snapshot。构建在后台完成
热路径只读取一次原子指针。
超过 `max_stale_age` 仍未取得有效快照时Worker 停止接收新流量并排空已有
请求。控制面中断不能让 Worker 查询 PostgreSQL 或 Redis 补偿热路径。
## 4. 所有权与 Drain
同一 Proxy 同时只归一个 Worker。Controller 回收用于独占提取的 Proxy 时:
1. 新 Snapshot 标记或移除该 Proxy使 Worker 停止新预留。
2. Worker 上报 `draining=true` 以及 Active/Reserved。
3. 两个计数都归零后 Controller 清除所有权。
4. 无所有权 Proxy 才能进入 Distribution 提取事务。
Worker 崩溃时必须等待所有权 epoch/有效期失效后再转移避免双主。Proto 中
`ReportRuntimeResponse.revoke_proxy_ids` 是加速 Drain 的控制信号,不绕过
Snapshot 版本和权威持久化。
## 5. Outcome 上报
Outcome 按 Worker 单调 `sequence` 批量上报。Controller 返回已接受的最大序号,
从而支持有限重试和去重。阶段区分:
- `DIAL`:连接 Proxy 地址失败。
- `PROXY_HANDSHAKE`HTTP CONNECT 或 SOCKS 握手失败。
- `RESPONSE_HEADERS`:目标响应头前失败。
- `TUNNEL`:隧道建立后结束或失败。
Outcome 是 Observation不直接让 Worker 修改 PostgreSQL 状态。异步上报队列
必须有界;队列满时丢弃低价值样本并计指标,不能反压 Gateway 热路径。
## 6. Checker 任务
Checker 注册自身最大并发与支持层级Controller 发送有 deadline 的任务:
- `BASIC`:基础连通和协议握手。
- `EGRESS`:出口身份与匿名性。
- `TARGET`:针对 Routing/目标组的可达性。
Checker 只返回 `HealthObservation`。Controller reducer 按 Proxy、检查层级和
Routing 决定 AVAILABLE、SUSPECT 或 UNHEALTHY避免多个 Checker 并发写状态。
## 7. 兼容与演进
- Proto 字段号一旦发布不得复用。
- 删除字段使用 `reserved` 保留名称和编号。
- 新枚举值必须让旧接收方按 UNSPECIFIED/拒绝策略处理。
- Worker 注册携带 `supported_protocol_version`,不兼容时注册失败而不是静默
降级。
- Stream 断开后使用带 jitter 的有界指数退避,禁止紧密重连。
## 8. 传输安全
集群环境使用 mTLS证书身份绑定 Worker/Checker 类型和环境。服务端校验
消息中的逻辑 ID 与证书授权一致,设置单消息大小、流持续时间、并发 Stream
和上报批次上限。`secret_ref` 是受控引用,不在 Proto 中传播真实密码。

200
docs/api/distribution.md Normal file
View File

@ -0,0 +1,200 @@
# Distribution API
## 1. 行为契约
Distribution API 只提供一次性独占提取:
```text
筛选 AVAILABLE
-> 锁定候选
-> 校验 Gateway 预留、TTL、健康与过滤条件
-> 原子 AVAILABLE -> EXTRACTED
-> 写 Extraction Record
-> 提交事务
-> 返回真实代理地址
```
事务提交前不得把地址写给 Client。返回成功后该 Proxy 不再参与 Gateway、
再次提取或可用库存统计。系统不跟踪 Client 是否使用、使用并发或何时停止,
也不提供 release、renew、status 或租约端点。
HTTP 契约源文件:`api/openapi/proxy-pool.yaml`。
## 2. 提取请求
```http
POST /api/v1/proxies/extract HTTP/1.1
Host: 127.0.0.1:8081
Content-Type: application/json
X-API-Key: TOKEN
X-Request-ID: req_01J4EXAMPLE
Idempotency-Key: extract-01J4EXAMPLE
{
"count": 5,
"fulfillment": "partial",
"filters": {
"protocols": ["http"],
"regions": ["shanghai"],
"carriers": ["telecom"],
"allowedUpstreams": ["provider-a"]
}
}
```
PowerShell 调用示例:
```powershell
$body = @{
count = 5
fulfillment = "partial"
filters = @{ protocols = @("http"); regions = @("shanghai") }
} | ConvertTo-Json -Depth 4
Invoke-RestMethod `
-Method Post `
-Uri http://127.0.0.1:8081/api/v1/proxies/extract `
-Headers @{ "X-API-Key" = "TOKEN"; "Idempotency-Key" = "extract-SERIAL" } `
-ContentType application/json `
-Body $body
```
请求约束:
- `count` 至少为 1且不超过服务端 `maxCountPerRequest`
- `fulfillment` 省略时使用服务端配置,默认 `partial`
- 所有过滤数组执行“数组内 OR、不同维度 AND”。空数组等同不限制。
- `allowedUpstreams` 只能缩小 Client 可访问的 Upstream 集,不能扩大权限。
## 3. 成功响应
```json
{
"requestId": "req_01J4EXAMPLE",
"requested": 5,
"returned": 2,
"proxies": [
{
"id": "px_01J4A",
"protocol": "http",
"host": "192.0.2.10",
"port": 8080,
"username": "USER",
"password": "PASSWORD",
"url": "http://USER:PASSWORD@192.0.2.10:8080",
"region": "shanghai",
"carrier": "telecom",
"upstream": "provider-a",
"expiresAt": "2026-07-28T10:30:00Z",
"remainingTtlSeconds": 83,
"extractedAt": "2026-07-28T10:28:37Z"
}
]
}
```
`returned` 必须等于 `proxies` 数组长度。`partial` 模式中 `returned` 可以为
零;这仍表示请求语法有效,只是当前没有可提取库存。
返回的 `password``url` 含真实凭据。Client 必须限制日志、追踪和错误上报
对响应体的采集。服务端访问日志只记录数量、过滤摘要、Client、Upstream 和
Request ID不记录地址或凭据。
## 4. 数量语义
### 4.1 partial
锁定的符合条件数量少于 `count` 时,提交实际数量:
```text
requested=10, eligible=6, reserve=0 -> returned=6
```
### 4.2 allOrNothing
锁定数量不足时整个事务回滚:
```json
{
"type": "https://proxy-pool.local/problems/insufficient-proxies",
"title": "Insufficient proxies",
"status": 409,
"code": "INSUFFICIENT_PROXIES",
"detail": "requested 10 proxies but only 6 are currently eligible",
"requestId": "req_01J4EXAMPLE"
}
```
冲突响应后,之前被该请求临时锁定的 Proxy 仍为 AVAILABLE。
## 5. 资格过滤与 Gateway 预留
候选必须同时满足:
1. 权威状态是 AVAILABLE。
2. 没有 Worker 所有权,或已完成 Drain 且 Active/Reserved 均为零。
3. 剩余 TTL 不低于 `minRemainingTTL`
4. 最近健康检查不早于 `maxHealthCheckAge`
5. protocol、region、carrier、Upstream 满足过滤与 Client 权限。
6. 提取后符合条件的共享库存不低于 `reserveForGateway`
Worker-owned Proxy 不得直接提取。Controller 需要先发布 DRAINING等待 Worker
确认没有 Active/Reserved再清除所有权并进入提取事务。快照延迟时仍禁止
Gateway 与 Client 同时获得同一 Proxy。
## 6. 并发与事务
PostgreSQL Adapter 应在一个事务内使用 `FOR UPDATE SKIP LOCKED` 获取候选,
更新状态并写审计记录。所有状态更新必须包含 `state = 'AVAILABLE'` 前置条件。
关键不变量:
- 两个并发成功响应的 Proxy ID 集合交集为空。
- 状态更新或审计写入任一步失败,整个批次不返回。
- `allOrNothing` 不足时零行变为 EXTRACTED。
- PostgreSQL 不可写时返回 503不以内存结果冒充成功。
## 7. 幂等
提取是消耗库存的写操作。客户端收到超时后盲目重试可能再次提取一批不同
Proxy因此自动重试应提供稳定的 `Idempotency-Key`
服务端幂等记录至少包含Client ID、Key、请求体摘要、提交结果和过期时间。
同一 Client、同一 Key、相同摘要返回首次结果摘要不同返回 409。幂等记录和
Extraction Record 必须与状态更新处在相同事务边界或由同一权威恢复流程保证。
## 8. 认证、识别与限制
认证由部署配置决定OpenAPI 同时声明 API Key、Basic、Bearer 和无认证场景。
无认证并不关闭来源 CIDR、Client 识别和限流:
- 直连请求使用来源 IP 形成匿名 Client。
- 只有来源属于 `trustedProxies` 时才接受转发头。
- 全局和每 Client 限流在查询库存前执行。
- 过滤条件、数量、请求体和 Header 都有长度/数量上限。
## 9. 错误模型
所有非 2xx 响应使用 `application/problem+json`
- `400`JSON、Header 或基本格式无效。
- `401`:认证失败。
- `403`:来源控制、权限或 Upstream 访问被拒绝。
- `409`allOrNothing 库存不足,或幂等 Key 冲突。
- `422`:数量、枚举或过滤组合违反业务约束。
- `429`:全局或 Client 速率限制,响应 `Retry-After`
- `503`PostgreSQL 不可写、服务排空或权威状态不可用。
错误响应不得包含 Provider Secret、Proxy 凭据、SQL 或内部拓扑。
## 10. 审计记录
每个被提交的 Proxy 对应一条 Extraction Record
```text
proxyId, clientId, sourceIP, requestId, upstream, extractedAt, expiresAt
```
无认证时 `clientId` 使用 `anonymous` 或稳定匿名标识并保留 `sourceIP`。记录只
用于审计、排错和计费事实不承担资源归还语义。Proxy 到期后可以清理运行
记录,但 Extraction Record 按审计保留策略归档。

View File

@ -0,0 +1,35 @@
# 配置样例索引
`examples/config` 下每个文件都是可独立加载的 Version 1 完整配置,不是 YAML
片段。示例域名使用保留的 `.example` 后缀Secret 使用带引号的
`${ENVIRONMENT_VARIABLE}` 占位符。
1. `01-local-all.yaml`:本机同时启用 Gateway 与独占提取。
2. `02-gateway-only.yaml`:仅 GatewayGET/HEAD 安全重试。
3. `03-extract-only.yaml`:仅一次性独占提取。
4. `04-public-gateway-basic-auth.yaml`:公网 Gateway 基础认证。
5. `05-public-extract-api-key.yaml`:公网提取 API Key 认证。
6. `06-internal-cidr-no-auth.yaml`内网无认证CIDR 访问控制。
7. `07-auth-any.yaml`IP 白名单或 API Key 任一通过。
8. `08-sequential-failover.yaml`:连续 Empty 后从 A 切到 B。
9. `09-weighted-routing.yaml`70/30 Upstream 权重。
10. `10-round-robin-routing.yaml`Upstream 轮询。
11. `11-random-routing.yaml`Upstream 随机选择。
12. `12-least-connections-routing.yaml`:按本地连接容量选择。
13. `13-extract-all-or-nothing.yaml`:数量不足时整批回滚。
14. `14-gateway-reserve.yaml`:共享池为 Gateway 保留库存。
15. `15-strict-ttl-health.yaml`:严格 TTL 与健康新鲜度过滤。
16. `16-provider-basic-auth.yaml`Provider Basic Auth。
17. `17-provider-api-key.yaml`Provider Header API Key。
18. `18-provider-post-json.yaml`Provider JSON POST 与有界解析。
19. `19-socks5-upstream.yaml`SOCKS5 Upstream 模型。
20. `20-fetch-billing-quota.yaml`:当前库存与累计计费额度分离。
批量验证:
```powershell
go test -count=1 ./examples/config
```
项目的配置测试应遍历该目录,以严格加载器解析并校验每个文件。示例中的
Provider URL 不是连通性测试目标;配置验证只校验语法、引用和不变量。

View File

@ -0,0 +1,329 @@
# 配置参考
## 1. 加载规则
主配置格式为 YAML根字段 `version` 当前固定为 `1`。加载器启用严格字段
检查,拼写错误或未来版本字段不会被静默忽略。推荐启动命令显式传入配置路径:
```powershell
go run ./cmd/proxy-controller -config configs/proxy-pool.yaml
```
所有时间值使用 Go duration例如 `500ms`、`30s`、`5m`。示例中的
`${TOKEN}`、`${PASSWORD}`、`${POSTGRES_URL}` 等由加载器从同名环境变量
展开;这些值不得写入日志、指标、配置转储或错误响应。生产配置优先使用
环境变量或 Secret 文件,不提交明文值。
加载与热更新必须遵循同一顺序:
```text
读取文件 -> 严格解析 -> 字段校验 -> 引用/正则校验
-> 构建不可变快照 -> 计算差异 -> 原子替换
```
新配置任何一步失败时保留旧快照。删除或禁用 Upstream 只停止新 Fetch 和新
分配,已有连接进入 Drain不强制中断。
## 2. 根结构
```yaml
version: 1
defaults: {}
security: {}
gateway: {}
distribution: {}
admin: {}
metrics: {}
storage: {}
routing: []
upstreams: {}
```
- `defaults`Fetch 与 Check 的公共建议值。生产配置仍建议在每个启用的
Upstream 显式写出关键限制,避免继承关系不清。
- `security`:跨入口启动保护。
- `gateway`HTTP/HTTPS CONNECT 数据面入口。
- `distribution`:一次性独占提取入口。
- `admin`:运维管理入口,必须与 Distribution 分端口。
- `metrics`Prometheus 入口。
- `storage`Controller 使用的 PostgreSQL 与 Redis 地址。
- `routing`:有序 Routing 列表,自上而下首条命中停止。
- `upstreams`:全局共享的 Upstream 运行时定义。
## 3. 安全与监听器
```yaml
security:
requireProtectionOnPublicListen: true
```
启用严格保护时,只要 Gateway、Distribution 或 Admin 满足以下全部条件,
启动即失败:
1. 入口已启用且监听地址不是回环地址。
2. `auth.mode: none`
3. `access.allowCIDRs` 为空。
监听器公共字段:
```yaml
enabled: true
listen: 0.0.0.0:8081
access:
allowCIDRs: [10.0.0.0/8]
trustedProxies: [10.10.0.10/32]
auth:
mode: apiKey
header: X-API-Key
token: "${DISTRIBUTION_API_KEY}"
limits:
maxConcurrentConnections: 100000
requestsPerMinute: 6000
requestsPerMinutePerClient: 600
```
`trustedProxies` 只决定何时接受 `Forwarded``X-Forwarded-For`,不能替代
`allowCIDRs`。来自非可信代理的转发头必须忽略。
### 3.1 认证模式
- `none`:无身份认证,访问控制与限流仍生效。
- `usernamePassword`:使用 `username``password`
- `apiKey`:使用 `header``token`
- `ipWhitelist`:使用 `cidrs`
- `any``methods` 中任一方法成功即可;方法字段名仍是 `mode`
Gateway、Distribution 与 Provider API 的认证是三套独立边界。改变其中一套
不得连带改变另外两套。
## 4. Gateway
```yaml
gateway:
enabled: true
listen: 127.0.0.1:8080
auth: {mode: none}
limits:
maxConcurrentConnections: 50000
retry:
maxAttempts: 2
retryMethods: [GET, HEAD]
destinationPolicy:
denyPrivateNetworks: true
denyLoopback: true
denyLinkLocal: true
denyCIDRs: [169.254.169.254/32]
```
- `retryMethods` 默认只应包含幂等方法。POST、PUT、PATCH、DELETE 不自动重试。
- CONNECT 向 Client 写出 `200 Connection Established` 后不透明重放。
- 目的地址策略必须在 DNS 解析前后都执行,防止 DNS Rebinding。
- `maxConcurrentConnections` 是入口准入上限,不是 Proxy 容量上限。
## 5. Distribution
```yaml
distribution:
enabled: true
listen: 127.0.0.1:8081
auth: {mode: none}
clientIdentification: {mode: sourceIP}
limits:
requestsPerMinute: 60
requestsPerMinutePerClient: 30
extraction:
fulfillment: partial
maxCountPerRequest: 20
minRemainingTTL: 30s
maxHealthCheckAge: 15s
reserveForGateway: 5
```
Extraction 是固定的一次性独占行为,**没有** `mode`、`leaseDuration`、
`release``renew` 配置。事务提交后执行 `AVAILABLE -> EXTRACTED`,该 Proxy
不再由 Gateway 或 Distribution 分配。
- `fulfillment``partial` 或 `allOrNothing`,默认语义为 `partial`
- `maxCountPerRequest`:单次请求硬上限。
- `minRemainingTTL`:剩余寿命低于此值时不参与提取。
- `maxHealthCheckAge`:最近检查早于此窗口时不参与提取。
- `reserveForGateway`:提取后必须留给 Gateway 的最低符合条件库存数量。
`partial` 会提交实际可得数量;`allOrNothing` 数量不足时事务回滚,一个也不
提取。认证关闭时仍应使用 `sourceIP` 识别匿名 Client 并执行全局/来源限流。
## 6. Routing
```yaml
routing:
- name: api-post
enabled: true
purpose: gateway
match:
hostRegex: '^api\\.example\\.com$'
methods: [POST]
pathRegex: '^/v1/'
headers: {X-Tenant: premium}
upstreams: [provider-a, provider-b]
strategy:
type: sequential
switchAfterEmptyFetch: 5
endBehavior: stayLast
onUnavailable:
action: reject
waitTimeout: 0s
```
- Routing 列表有序,首条匹配后停止。
- `purpose``gateway``extract`
- `strategy.type` 支持 `sequential`、`random`、`roundRobin`、`weighted`、
`leastConnections`
- `weighted` 使用 `weights` 映射,键必须引用本 Routing 的 Upstream。
- `sequential` 必须设置大于零的 `switchAfterEmptyFetch`
- `onUnavailable.action``reject`、`wait` 或 `direct`;默认建议 `reject`
Sequential 的空计数属于 Upstream当前索引属于 Routing。只有 Provider 响应
成功、模板成功且合法候选为零时才增加空计数。错误不改变空计数;重复候选
会重置空计数但增加独立 duplicate 指标。
## 7. Upstream
```yaml
upstreams:
provider-a:
enabled: true
exposure: [gateway, extract]
provider:
billingMode: fetch
protocols: [http, https]
api:
url: https://provider-a.example/proxies
method: GET
auth:
type: apiKey
location: header
name: X-Provider-Key
value: "${PROVIDER_API_KEY}"
headers: {}
query: {count: '100'}
body: {type: json, value: {}}
template: '{{.}}'
proxyAuth: {type: response}
pool: {maxSize: 1000, shrinkDelay: 30s}
capacity: {maxConcurrencyPerProxy: 10}
lifecycle: {ttl: 2m, allocationSafetyMargin: 15s}
fetch:
requestInterval: 1s
timeout: 3s
maxAttempts: 3
maxInFlight: 1
maxTotal: 100000
maxResponseBytes: 1048576
templateTimeout: 100ms
retry: {initial: 500ms, max: 30s, jitter: 20}
check:
interval: 30s
jitter: 20
maxInFlight: 100
timeout: 2s
maxAttempts: 2
maxConsecutiveFailures: 3
urls: [http://connect.rom.miui.com/generate_204]
```
### 7.1 Provider 与代理认证
- `api.auth` 用于系统访问 Provider API。
- `proxyAuth` 用于最终连接被获取的 Proxy。
- Provider `api.auth.type` 支持 `none`、`basic`、`bearer`、`apiKey`。
- `apiKey` 使用 `location: header|query``name`、`value`,程序负责 Header
设置或 Query URL 编码。
- `basic` 使用 `username`、`password``bearer` 使用 `token`
- `proxyAuth.type: response` 表示凭据来自 Provider 响应。
- `proxyAuth.type: static` 使用配置中的 `username`、`password`。
- `proxyAuth.type: ipWhitelist` 表示 Provider 按出口 IP 放行,不配置账号密码。
Provider API 认证**不使用**入口的 `auth.mode`,代理连接认证也不使用
`mode`。三者的字段和凭据不可互相回退:
```yaml
# 对外 Distribution/Gateway/Admin
auth:
mode: apiKey
header: X-API-Key
token: "${CLIENT_API_KEY}"
# 请求 Provider API
auth:
type: bearer
token: "${PROVIDER_API_TOKEN}"
# 连接 Provider 返回的 Proxy
proxyAuth:
type: static
username: "${PROVIDER_PROXY_USER}"
password: "${PROVIDER_PROXY_PASSWORD}"
```
### 7.2 Pool 与累计额度
- `pool.maxSize`:当前系统维护且尚未 EXTRACTED 的 Proxy 硬上限,包括
FETCHED、CHECKING、AVAILABLE、SUSPECT、DRAINING 和 pending expected。
- `fetch.maxTotal`:当前运行或计费周期内,从 Provider 成功获取的累计上限;
`0` 表示不设置累计上限。
`fetch.maxTotal` 不得小于 `pool.maxSize`。提取一个 Proxy 会释放当前库存位置,
但不会恢复累计获取额度。
### 7.3 Fetch 限制
- `requestInterval`:同一 Provider 请求间隔。
- `timeout`:单次调用超时。
- `maxAttempts`:单次补池动作最大尝试次数。
- `maxInFlight`:同一 Provider 同时在途请求数。
- `maxResponseBytes`:读取响应的硬上限。
- `templateTimeout`:模板解析执行上限。
- `retry`错误退避HTTP 429 还必须尊重 `Retry-After`
大量缺池信号必须合并成 singleflight 或容量为 1 的通知,不能按 Gateway 请求
数量线性触发 Provider API。
### 7.4 生命周期与健康
- 明确绝对过期时间优先于响应 TTL响应 TTL 优先于配置 `lifecycle.ttl`
- 距离过期不足 `allocationSafetyMargin` 时停止新分配。
- `check.jitter` 为调度抖动百分比,避免所有 Proxy 同时探测。
- 第一次有意义失败进入 SUSPECT达到 `maxConsecutiveFailures` 后才进入
UNHEALTHY。
## 8. 存储、Admin 与 Metrics
```yaml
admin:
enabled: true
listen: 127.0.0.1:8082
auth: {mode: none}
metrics:
enabled: true
listen: 127.0.0.1:9090
storage:
postgresURL: "${POSTGRES_URL}"
redisURL: "${REDIS_URL}"
```
PostgreSQL 是 Proxy 生命周期、Routing 选择、Worker 所有权和 Extraction Record
的权威存储。Redis 只承载可重建的短期协调状态,不能成为独占提取的唯一事实
来源。Metrics 标签禁止 Proxy IP、Client ID、Session、完整 URL 和 Request ID。
## 9. 启动前校验清单
1. `version` 必须为 `1`,未知字段拒绝。
2. 所有启用监听器具有合法 `host:port`
3. 非回环监听器满足认证或来源 CIDR 保护。
4. Routing 名称唯一,正则可编译,引用的 Upstream 存在。
5. Sequential 阈值大于零,`onUnavailable.action` 明确。
6. 启用的 Upstream 有正数 `pool.maxSize`、并发和 Fetch 限制。
7. `allocationSafetyMargin < ttl`
8. `fetch.maxTotal == 0``fetch.maxTotal >= pool.maxSize`
9. Distribution 的 fulfillment 合法,单次数量大于零。
10. Secret 未写入日志可见配置转储。

View File

@ -0,0 +1,93 @@
# Proxy Pool 产品设计文案
## 1. 产品定位
Proxy Pool 把多个供应商的动态代理统一成一个可运营的资源系统。平台面对
两类不同使用方式:需要平台代转发流量的应用使用 Gateway需要拿到真实
代理并自行建立连接的应用使用 Distribution API。
两类入口共享 Provider、健康、TTL 和路由配置,但资源分配语义不同:
- Gateway 只在一次请求或隧道生命周期内占用代理并发,结束后释放容量。
- Distribution 一旦返回代理,该代理即永久离开系统可分配池。
## 2. 目标用户
- **业务调用方**:通过稳定入口使用代理,不感知供应商差异。
- **代理直提调用方**:按协议、区域、运营商或 Upstream 条件独占提取。
- **平台管理员**:管理 Provider、Routing、容量、健康和故障切换。
- **SRE**:依据低基数指标、审计记录和运行手册进行容量与故障管理。
## 3. 核心价值
### 3.1 供应商差异收敛
Provider Adapter 负责请求格式、认证和响应解析。标准化后Routing、健康、
容量和业务入口只依赖统一 Proxy 模型,不把供应商字段传入核心域。
### 3.2 高并发热路径隔离
Gateway Worker 只读取本地不可变快照并维护本地容量计数。供应商延迟、
数据库抖动和控制面重载不会成为每请求依赖。
### 3.3 明确且可审计的资源语义
系统区分“短时使用容量”和“一次性独占提取”。每次 Extraction 保存请求、
调用方、来源、Upstream、提取时间和过期时间审计事实但不追踪提取后的
实际使用,也不存在归还接口。
## 4. 主要流程
### 4.1 Gateway 请求
1. 接入层完成认证、来源识别、限流和目标地址检查。
2. Routing 按配置顺序首条命中。
3. Dispatcher 从本地快照筛选 Upstream、协议、标签、TTL 和健康条件。
4. 原子预留 Proxy 容量,建立到上游代理的连接。
5. 建连成功后转为 Active传输结束后释放失败则取消预留。
6. GET/HEAD 仅在响应提交前按策略重试CONNECT 建立后不重放。
### 4.2 独占提取
1. 调用方提交数量、过滤条件和 fulfillment。
2. Controller 校验调用方限额、TTL、健康新鲜度及 Gateway 保留量。
3. 在一个数据库事务中锁定候选并执行 `AVAILABLE -> EXTRACTED`
4. `partial` 尽量返回;`allOrNothing` 数量不足时零提取。
5. 响应返回代理 URL、`expiresAt` 和 `remainingTtlSeconds`
### 4.3 Sequential 切换
1. Provider 响应成功且解析成功,但合法候选为零,才累计 Empty。
2. 网络、认证、HTTP、模板或解析失败只计 Error。
3. 全部候选均重复时计 DuplicateOnly并重置连续 Empty。
4. 达到阈值后,引用该 Upstream 的 Routing 原子前进一次。
5. 旧 Upstream 已有 Proxy 继续耗尽,不因切换被直接删除。
## 5. 失败体验
- 无候选时严格执行 Routing 的 `reject`、`wait` 或 `direct`,默认拒绝。
- Distribution 部分满足用 200 返回实际数量;全有或全无不足时返回冲突状态。
- Provider 故障进入退避,不让调用请求触发同步 Provider 获取。
- 快照版本断档时 Worker 保留最后一份完整快照并请求全量重同步。
- 控制面不可用时,现有 Worker 可在快照有效期内继续服务,停止接收新配置。
## 6. 容量目标与服务指标
- 集群峰值目标100,000 QPS。
- Worker 副本数:
```text
required_workers = ceil(peak_qps / (measured_worker_qps * target_utilization))
+ failure_domain_spares
```
- `measured_worker_qps` 必须来自目标协议占比、代理 RTT、连接复用率和安全策略
均接近生产的压测。
- 设计阶段不承诺单 Worker QPS也不以平均值替代 P95/P99 和错误率。
## 7. 首版范围
首版包含 HTTP 正向代理、HTTPS CONNECT、REST Distribution/Admin、Provider
适配、健康与生命周期、Sequential 等路由策略、PostgreSQL 权威状态、Redis
可重建协调和集群快照。SOCKS5、跨地域主动主动和高级成本优化保留扩展边界。

View File

@ -0,0 +1,96 @@
# Proxy Pool 项目架构
## 1. 仓库结构
```text
proxy-pool/
├── cmd/
│ ├── proxy-gateway/ # 数据面进程
│ ├── proxy-controller/ # 控制面与 HTTP API
│ ├── proxy-checker/ # 健康检查执行器
│ └── proxy-loadgen/ # 可复现容量测试
├── internal/
│ ├── config/ # 严格配置解析和校验
│ ├── domain/ # 无传输、无存储依赖的领域模型
│ ├── gateway/ # snapshot、dispatch、server、transport
│ ├── controller/ # provider、pool、routing、extraction、health
│ ├── adapters/ # PostgreSQL、Redis、Provider API、内存适配
│ └── platform/ # 日志、指标、停机和进程装配
├── api/ # OpenAPI 与 Protobuf 契约
├── configs/ # 默认配置
├── examples/ # 可校验配置场景
├── deploy/ # Compose 与 Kubernetes
├── docs/ # 设计、开发、API、测试、运维
├── diagrams/ # Mermaid 图集
├── scripts/ # 验证和生成脚本
└── test/ # fixture、集成、端到端和负载测试
```
## 2. 进程边界
### proxy-gateway
`gateway -> dispatch -> transport` 是数据面主调用链。`dispatch` 包含筛选、
选择、session 与重试资格等热路径决策;`transport` 独占连接池、上游握手和
隧道生命周期。任何包都不得从热路径反向调用 Controller 存储。
### proxy-controller
Controller 是首版模块化单体。Provider、Pool、Routing 和 Extraction 共享
事务边界和状态演进,避免过早拆成分布式事务。对外端口定义在领域或控制器
模块,具体 PostgreSQL/Redis/HTTP 实现在 `adapters`
### proxy-checker
Checker 只产生 Observation。最终状态迁移由 Controller 的确定性 reducer
完成,避免多个检查实例同时写 Proxy 状态。
### proxy-loadgen
负载工具生成 HTTP、CONNECT、连接复用与故障注入场景输出延迟分位数、
错误类别、连接数、CPU、RSS、GC 与吞吐。它是 100k QPS 结论的证据工具,
不是业务进程。
## 3. 依赖方向
```text
cmd -> controller/gateway/checker -> domain
|
+------------> port interfaces
adapters -------------------------> port interfaces
platform -------------------------> standard library / observability SDK
```
硬性规则:
1. `domain` 不导入 HTTP、SQL、Redis、配置或平台包。
2. `gateway/dispatch` 只依赖本地 Snapshot 与领域类型。
3. `adapters` 实现端口,不被领域层反向引用。
4. 配置先解析、校验、编译为运行时对象,再原子发布。
5. Secret 仅通过引用进入运行时,不进入唯一键、指标或日志字段。
## 4. 数据所有权
- PostgreSQLProxy 生命周期、Extraction 审计、配置版本和 Outbox 的权威源。
- RedisLeader、分布式速率、Worker 心跳等可丢失且可重建状态。
- Worker仅拥有分配给自己的 Proxy 本地容量计数和不可变快照。
- Controller拥有 Provider 调度、Routing 运行态与 Worker 所有权编排。
- Checker不拥有 Proxy 状态,只拥有执行中的检查任务。
## 5. 一致性边界
- Extraction 使用 PostgreSQL 单事务和行锁跳过锁定候选,提交后才响应。
- Worker 所有权采用 `worker + epoch + version + expiry`,同一 Proxy 至多归属
一个 Worker。
- 从 Worker 回收 Proxy 时先 Drain等待 ACK 且 Active/Reserved 为零,再
解除所有权;只有无所有权 Proxy 能被 Distribution 提取。
- Snapshot 为整代不可变对象,通过校验和和严格版本序列原子替换。
## 6. 扩展规则
- 新 Provider增加 Adapter不修改 Proxy/Pool/Routing 领域语义。
- 新入口协议:在 Gateway 增加 ingress adapter只有存在第二种上游协议
执行方式时再抽象 egress adapter。
- 新路由策略:实现同一策略端口,并提供确定性单测和并发不变量测试。
- 新存储:实现已有 repository port不把驱动类型泄漏到控制器。

65
docs/development/guide.md Normal file
View File

@ -0,0 +1,65 @@
# 开发指南
## 1. 环境
- Go 1.26 或 `go.mod` 指定版本。
- PostgreSQL 与 Redis 仅用于适配器集成测试,领域单测不依赖外部服务。
- 运行 `go test -race` 需要 CGO 和 C 编译器。
- Docker Compose 用于本地完整拓扑Docker 不应成为普通单测前置条件。
## 2. 开发循环
1. 在 `traceability.md` 中找到需求 ID。
2. 先写会失败的单测或契约测试,确认失败原因与需求一致。
3. 实现最小完整行为,不增加空接口或预留目录。
4. 运行目标包测试,再运行全仓库测试和静态检查。
5. 更新需求证据、相关文档和配置示例。
```powershell
go test ./internal/domain/proxy
go test ./...
go vet ./...
go build ./...
```
## 3. 包设计规则
- 领域包使用业务语言,不使用 Controller、HTTP 或数据库 DTO。
- 模块接口应隐藏内部策略步骤,避免把 filter/scorer/picker 拆成浅接口链。
- 时间逻辑注入 `now` 或 Clock测试禁止依赖真实睡眠。
- Provider 调度使用有界信号、singleflight、超时和抖动退避。
- 后台队列必须有容量、溢出策略、关闭语义和指标。
- 并发计数必须以不变量测试证明,不只检查最终值。
## 4. 配置变更
新增字段时同时修改:
1. `internal/config` 类型、默认值与校验。
2. `configs/default.yaml`
3. `docs/configuration/reference.md`
4. 至少一个有效示例和一个无效测试。
5. 配置版本兼容说明;不静默忽略未知字段。
## 5. API 变更
- OpenAPI 是 REST 契约源Protobuf 是 Controller/Worker 契约源。
- 先更新契约和兼容性测试,再修改 handler。
- Distribution 不得出现 lease、release、renew、return 等资源归还语义。
- 错误响应包含稳定 code 和 requestId不向调用方暴露 Secret 或内部栈。
## 6. 并发与性能
- Gateway 请求路径不得出现远程存储访问和无界 goroutine 创建。
- Snapshot 构建在后台完成,发布后只读;请求只做一次原子指针读取。
- Proxy 容量由同一个打包原子值保存 Active/Reserved避免分开检查再写入。
- 性能优化必须附基准100k QPS 结论必须附完整环境和负载模型。
## 7. 提交前检查
```powershell
./scripts/verify.ps1
```
审查还要确认:无 Secret 日志、无 Proxy IP 高基数标签、无默认 direct、无
Extraction Lease API、无把重复结果误计为 Empty 的逻辑。

View File

@ -0,0 +1,61 @@
# 生产就绪检查表
## 架构与一致性
- [ ] Gateway 热路径依赖审计确认无 PostgreSQL、Redis、Provider 或模板执行。
- [ ] 每个 Proxy 同一时刻最多归属一个 Workerownership epoch 单调。
- [ ] Reserved -> Active 使用单个原子转换,无超卖与负计数。
- [ ] Sequential 并发 Empty 只切换一次,旧 Upstream Proxy 自然耗尽。
- [ ] `pool.maxSize``fetch.maxTotal` 分别按当前库存和累计获取计数。
- [ ] Extract 只有 `AVAILABLE -> EXTRACTED`OpenAPI 不存在 release/renew。
- [ ] Extract 状态更新和审计记录位于同一数据库事务。
- [ ] `partial``allOrNothing` 均通过并发事务测试。
- [ ] `reserveForGateway` 在所有提取路径上统一执行。
## 安全
- [ ] 非回环监听均配置 Auth 或 allowCIDRs严格模式已开启。
- [ ] Gateway、Distribution、Admin 凭据和权限相互独立。
- [ ] trusted proxy 只包含受控 LoadBalancer/Ingress 网段。
- [ ] 解析前后均拦截私网、回环、链路本地、元数据地址与 DNS Rebinding。
- [ ] Secret 由外部密钥系统注入镜像、ConfigMap、日志没有明文。
- [ ] Pod 以非 root、只读根文件系统、无 Linux capabilities 运行。
- [ ] NetworkPolicy 默认拒绝,外部数据库/Redis/Provider 网段已收紧。
- [ ] Provider 模板有响应大小、执行时间、函数与外部访问限制。
## 可用性
- [ ] PostgreSQL 和 Redis 跨可用区,有监控、备份和恢复演练证据。
- [ ] Gateway、Controller、Checker 均跨主机/可用区分散。
- [ ] PDB、优雅终止与最大连接时长的组合经过驱逐测试。
- [ ] Controller 断线时 Gateway 在 `maxStaleAge` 内继续,超限拒绝新请求。
- [ ] Worker 崩溃后 ownership 只在租约过期后再分配。
- [ ] Snapshot 版本缺口触发全量同步,旧 Delta 被拒绝。
## 性能
- [ ] 单 Worker 基准使用生产同规格硬件、网络、TLS 和 Snapshot 规模。
- [ ] 完成 10k 稳态、100k 峰值、CONNECT 活跃连接和建连速率独立测试。
- [ ] 在最大可用区失效时仍满足容量和延迟 SLO。
- [ ] 目标利用率不高于 60%HPA 缩容稳定窗口不低于 10 分钟。
- [ ] 队列、buffer、日志和结果上报全部有界。
- [ ] p99 Dispatch 预算、端到端延迟、错误率、CPU、RSS、FD 和网络有原始证据。
## 观测与值班
- [ ] 仪表盘覆盖 Gateway、Routing、Upstream、Provider、Extract、Snapshot、Checker。
- [ ] 告警有负责人、严重级别、Runbook 链接和演练记录。
- [ ] 指标无 Proxy IP、Client ID、Session、完整 URL 或 request ID 高基数标签。
- [ ] 日志脱敏已用真实 Secret fixture 验证。
- [ ] 值班人员完成 PostgreSQL、Redis、Snapshot、容量与 Extract 故障演练。
## 发布门禁
- [ ] `go test ./...`、race、vet、build 全部通过。
- [ ] OpenAPI/Proto 兼容检查通过。
- [ ] Compose、Kustomize、Prometheus、HAProxy 配置静态校验通过。
- [ ] 数据库迁移已在生产数据量副本上演练,并有回退或前向修复方案。
- [ ] 100k QPS 验证报告包含环境、命令、版本、场景、原始指标和结论。
任一关键一致性、安全或恢复项未完成时,不标记生产就绪。

249
docs/operations/runbook.md Normal file
View File

@ -0,0 +1,249 @@
# Proxy Pool 运维手册
## 1. 运行边界
- Gateway 是数据面,正常请求热路径不访问 PostgreSQL、Redis 或 Provider。
- Controller 是权威控制面,负责 Fetch、生命周期、所有权、Snapshot、Extract
和审计;多个副本只有一个 Provider 逻辑 Leader。
- Checker 执行有界健康探测,只上报 Observation最终状态由 Controller
reducer 决定。
- Extract 是一次性独占发放。提交后状态为 `EXTRACTED`,没有 Lease、续租或
Release 接口。
- `reserveForGateway` 是共享池硬约束Extract 不得把 Gateway 库存清空。
- 集群峰值 100,000 QPS 是设计目标,只有完成本文容量验收后才能作为已验证
能力对外承诺。
## 2. 本地拓扑模板
当前仓库交付设计、契约、部署拓扑和关键领域实现;`cmd/proxy-*` 的完整运行时
装配属于 `implementation-plan.md` 后续任务。此处 Compose/Kubernetes 资产用于
评审网络、资源、探针和依赖关系,当前只执行静态渲染,不把模板写成可运行服务。
### 2.1 前置条件
- Docker Engine 25+Compose v2.30+。
- 至少 8 CPU、16 GiB 内存和 20 GiB 可用磁盘。
- 本地端口 `3000`、`8080`、`8081`、`8082`、`8404`、`9091` 未占用。
### 2.2 配置凭据
`deploy/config/local.yaml` 只用于本机拓扑验证。进入运行时实施阶段后,再通过
密钥系统提供下列变量,并把 Provider 地址替换为测试 fixture
```powershell
$env:PROXY_POOL_GATEWAY_PASSWORD = "LOCAL_GATEWAY_PASSWORD"
$env:PROXY_POOL_EXTRACT_TOKEN = "LOCAL_EXTRACT_TOKEN"
$env:PROXY_POOL_ADMIN_TOKEN = "LOCAL_ADMIN_TOKEN"
$env:PROVIDER_A_TOKEN = "PROVIDER_A_TOKEN"
$env:PROVIDER_B_TOKEN = "PROVIDER_B_TOKEN"
```
### 2.3 静态检查
```powershell
docker compose -f deploy/docker-compose.yml config
kubectl kustomize deploy/kubernetes/base > rendered.yaml
```
目标拓扑入口:
- Gateway`127.0.0.1:8080`
- Distribution`http://127.0.0.1:8081`
- Admin`http://127.0.0.1:8082`
- HAProxy 状态:`http://127.0.0.1:8404/stats`
- Prometheus`http://127.0.0.1:9091`
- Grafana`http://127.0.0.1:3000`
`deploy/config/local.yaml` 中的 `.invalid` Provider 是故障演示占位。未替换时
Fetch 应表现为 Error 与退避,不应增加 Empty 计数,也不影响已有 Proxy。
## 3. Kubernetes 发布
本节是运行时实施完成后的发布规格,不代表当前代码已达到生产就绪。
### 3.1 准备
1. 使用托管 PostgreSQL 和 Redis分别配置 TLS、备份、监控和多可用区。
2. 复制 `secret.example.yaml` 到环境私密配置系统,由 External Secrets、SOPS
或密钥管理平台生成 `proxy-pool-secrets`,不要提交真实 Secret。
3. 在环境 Overlay 替换镜像、Provider 地址、允许网段、外部存储地址、资源量
和 LoadBalancer 注解。
4. 根据集群 CNI 能力收紧 NetworkPolicy 的外部网段。
5. 在预发布环境完成数据库向前兼容迁移,再发布 Controller。
### 3.2 服务端应用顺序
```bash
kubectl apply -f deploy/kubernetes/base/namespace.yaml
kubectl -n proxy-pool apply -f ENVIRONMENT_SECRET.yaml
kubectl apply -k deploy/kubernetes/base
kubectl -n proxy-pool rollout status deployment/proxy-controller --timeout=5m
kubectl -n proxy-pool rollout status deployment/proxy-checker --timeout=5m
kubectl -n proxy-pool rollout status deployment/proxy-gateway --timeout=10m
```
### 3.3 探针语义
- `/livez`:进程事件循环仍可运行。数据库或 Redis 短暂失败不得导致 Gateway
liveness 失败。
- `/readyz`进程可以接收新工作。Gateway 只有在持有未超过 `maxStaleAge`
的完整 Snapshot 且仍有准入能力时才 Ready。
- Controller 只有在配置有效、存储可用、迁移兼容且控制接口已监听时才 Ready。
- Checker 在任务消费与结果上报通道可用时 Ready。
- `/metrics`独立于业务入口NetworkPolicy 仅允许监控命名空间访问。
探针不得执行 Provider 请求或完整数据库扫描。
### 3.4 发布顺序与兼容性
1. 先做向前兼容数据库迁移。
2. 发布 Controller确认旧 Worker 仍能消费旧 Snapshot 协议版本。
3. 发布 Checker。
4. 逐批发布 Gateway每次至少保留 PDB 要求的健康副本。
5. 观察 30 分钟,再清理已经无人读取的旧字段或旧迁移。
回滚只能回到仍兼容当前 Schema 和 Snapshot 版本的镜像。涉及不可逆数据迁移时,
必须使用前向修复。
## 4. 容量规划
```text
worker_replicas =
ceil(peak_qps / (tested_worker_qps * target_utilization))
+ largest_failure_domain_replicas
```
- `peak_qps`:当前目标为 100,000。
- `tested_worker_qps`:在同 CPU、内存、网络、Go 版本、Snapshot 规模、TLS 与
上游响应模型下测出的单 Pod 持续能力。
- `target_utilization`:不高于 0.60给突发、GC 和故障转移留空间。
- `largest_failure_domain_replicas`:最大单可用区失效时丢失的副本数。
禁止用 CPU 核数直接推算 QPS也禁止把短时峰值当持续容量。Kubernetes 基线的
6 个 Gateway 副本只是初始值,必须由压测结果调整。
HPA 使用 CPU/内存作为保护性信号;生产环境建议通过 Prometheus Adapter 加入:
- 每 Pod Gateway QPS。
- 活跃连接数和建连速率。
- p99 Dispatch 延迟。
- 拒绝率和 Available Slots。
连接型工作负载缩容至少稳定 10 分钟,终止前先 NotReady再等待现有隧道排空。
## 5. 日常检查
每班次检查:
1. Gateway QPS、错误率、p95/p99 和活跃连接。
2. Snapshot age、epoch/version、重同步与 ACK 延迟。
3. Available Slots、Reserved、Active、各 Proxy 状态数量。
4. Provider Success、Empty、Duplicate-only、Error、429 与退避。
5. Checker 队列、SUSPECT 数、检查延迟和目标级失败。
6. Extract requested/returned、insufficient、冲突和审计写入。
7. PostgreSQL 连接、锁等待、事务失败、WAL 与备份。
8. Redis 延迟、内存、主从状态和 Leader 租约抖动。
Prometheus 标签禁止包含 Proxy IP、Client ID、Session、完整 URL、request ID。
需要逐请求调查时使用受控、脱敏且采样的结构化日志。
## 6. 优雅停机
### Gateway
1. readiness 立即失败,停止新连接。
2. 停止应用新 Snapshot但保留当前不可变版本。
3. 等待 HTTP 请求和 CONNECT 隧道排空。
4. 到达 60 秒上限后关闭残余连接,保证容量 reservation 被释放。
### Controller
1. 停止接收新的 Extract/Admin 写请求。
2. 停止发起 Fetch释放 Provider Leader 租约。
3. 完成已进入数据库事务的 Extract 或回滚。
4. 刷新 outbox、审计和 Worker ACK再关闭连接池。
### Checker
1. 停止领取新任务。
2. 在 45 秒内完成或取消现有探测。
3. 批量上报已完成 Observation未完成任务由队列重新投递。
## 7. 故障处置
### 7.1 Snapshot 陈旧
症状:`ProxyPoolGatewaySnapshotStale`、Worker 重同步增加、Gateway Ready 下降。
1. 检查 Controller、控制流和 outbox 延迟。
2. 确认 Worker epoch 与 Controller epoch禁止手工降低 epoch。
3. 缺版本时强制完整 Snapshot不要继续应用 Delta。
4. `maxStaleAge` 内允许旧 Snapshot 服务;超限自动拒绝新流量并排空。
5. 不得通过无限增大 `maxStaleAge` 隐藏控制面故障。
### 7.2 PostgreSQL 不可用
1. Gateway 继续使用最后有效 Snapshot。
2. Controller 将 Distribution 和权威写操作置为不可用,避免返回未提交代理。
3. Provider Fetch 停止写入;已有流量不受影响。
4. 恢复后核对迁移、事务回滚、outbox backlog 与 Extract 审计连续性。
### 7.3 Redis 不可用
1. Gateway 不受影响。
2. Controller 停止需要分布式互斥的高风险工作,防止多个 Fetch Leader。
3. 本地限流只作为临时降级,不能声称满足全局额度。
4. 恢复后确认 Leader 唯一、租约 epoch 单调和重复 Fetch 去重。
### 7.4 Provider 故障
1. 超时、DNS、认证、非预期 HTTP、响应超限与模板错误全部计 Error。
2. 429 尊重 `Retry-After`,其余 Error 使用指数退避和 jitter。
3. Error 不增加 Empty合法候选为零才增加 Empty。
4. Duplicate-only 重置 Empty 并记录独立指标。
5. 达到 Empty 阈值后每条受影响 Routing 只原子切换一次。
### 7.5 Gateway 容量耗尽
1. 检查 Available Slots而不是只看 Proxy 数量。
2. 确认是否大量容量停留在 Reserved排查 Commit/Cancel 泄漏。
3. 检查 TTL safety margin、健康状态和 Worker 所有权是否导致候选被过滤。
4. 快速拒绝新请求,禁止无界等待或把压力转移到 Controller。
5. 扩容 Gateway 前确认存在可分配 Proxy 所有权切片。
### 7.6 Extract 库存不足
1. `partial` 返回实际数量;`allOrNothing` 不足时整批回滚。
2. 检查 TTL、health age、filter、Worker ownership 和 `reserveForGateway`
3. 不得降低 `reserveForGateway` 到导致 Gateway 容量告警的水平。
4. 回收 Worker-owned Proxy 必须先 DRAINING、等待 active/reserved 为零、清除
ownership再执行 `AVAILABLE -> EXTRACTED` 事务。
5. 已提取代理没有 Release客户端归还请求只记录为无效调用不恢复库存。
### 7.7 Checker 积压
1. 优先新 Proxy 与 SUSPECT 复检。
2. 降低稳定 AVAILABLE 的普通复检频率。
3. 检查目标超时、DNS 与出口网络,再按任务延迟扩容 Checker。
4. 队列必须有上限;不得无限积压耗尽内存或 Redis。
## 8. 备份与恢复
- PostgreSQL每日全量、连续 WAL/PITR至少每季度做恢复演练。
- Redis仅保存可重建协调状态不得把 Redis 备份当权威业务备份。
- 配置:版本化保存校验通过的不可变 Revision 与校验和。
- Secret由密钥平台版本化日志和备份中不得出现明文。
恢复顺序PostgreSQL -> Redis -> Controller -> Checker -> Gateway。恢复后验证
Proxy 状态、Extraction Record、Worker ownership epoch、outbox 和配置 Revision
单调一致,再开放 Gateway 与 Distribution。
## 9. Secret 轮换
1. 创建新 Secret 版本,不覆盖旧值。
2. Provider/API 凭据支持双版本重叠时先发布新版本。
3. 更新配置 Revision确认 Controller 成功重建 Adapter。
4. 观察 Fetch Error、认证失败与 Snapshot ACK。
5. 所有副本应用后撤销旧 Secret。
Proxy 凭据轮换必须增加 `credentialVersion`,确保唯一键不会把新旧凭据错误合并。

View File

@ -0,0 +1,72 @@
# 交付完成度审计
本文区分设计证据、机器契约、已运行验证和后续实施,防止把架构目标描述成
已完成产品。
## 1. 本次已交付
### 设计与开发文档
- 全量需求追踪、覆盖关系和统一领域语言。
- 产品设计、总体架构、项目结构、四项 ADR。
- 开发、配置、Distribution/Admin API、控制面协议、安全、测试、运维文档。
- 20 个配置场景和 35 张 Mermaid 架构/流程/状态/故障图。
- 版本化文档包 `proxy-pool-docs-v1.0.zip`,包含 50 个条目。
### 机器契约
- Distribution OpenAPI一次性独占提取、partial/allOrNothing、幂等键、
TTL/健康过滤结果与标准错误。
- Admin OpenAPI状态、Upstream 启停、Routing 切换和配置重载。
- ProtobufWorker 注册、全量/增量 Snapshot、ACK、运行态/结果上报、Checker
任务与 Observation。
### 核心参考实现
- `CFG-*`YAML v4 未知字段拒绝、监听保护、引用/上限/认证边界校验21 份
配置持续测试。
- `PROXY-* / CAP-*`唯一键、TTL 优先级、状态迁移与 Active/Reserved 打包
原子计数1,000 goroutine 不超卖测试。
- `ROUTE-001 / ROUTE-004`:首条命中规则与 Concurrent Sequential 单次切换。
- `FETCH-005 / FETCH-006`Valid、Empty、DuplicateOnly、Error 分类。
- `DIST-001..003 / DIST-006..007`内存事务模型验证独占提取、满足模式、TTL、
健康时效与 Gateway 保留量1,000 并发不重复。
- `OPS-001`:完整 Snapshot 目标、epoch/version、校验和验证及原子替换。
- `CAP-001 / GW 热路径边界`:本地 Dispatch 条件过滤与原子容量预留。
## 2. 已执行验证
```text
go test ./... PASS
go vet ./... PASS
go build ./... PASS
protoc descriptor compilation PASS
docker compose config PASS
kubectl kustomize PASS
configuration examples 21/21 PASS
Mermaid blocks 35
```
Windows 环境为 `CGO_ENABLED=0` 且没有 C 编译器,`go test -race` 在本机未执行;
CI 已配置 Linux race job。Docker/Kubernetes 仅完成静态验证,没有把目标拓扑
作为已运行系统。
## 3. 后续实现范围
以下已有设计、接口或部署位置,但尚无端到端生产实现:
1. `cmd/proxy-gateway/controller/checker/loadgen` 进程装配。
2. HTTP 正向代理、HTTPS CONNECT、连接池、安全重试与隧道转发。
3. Provider Adapter、模板沙箱、singleflight、Leader、退避和累计额度执行器。
4. PostgreSQL repository、Extraction 行锁事务、Outbox 和迁移。
5. Redis Leader、速率限制、心跳与可重建协调适配器。
6. Worker ownership drain/ACK/过期回收和网络快照流。
7. Checker 调度、探测器和健康 reducer。
8. Admin/Distribution handler、鉴权、限流和审计查询。
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
## 4. 容量结论
100,000 QPS 是集群设计输入,不是本次验证结果。只有实现上述运行时,并在
记录协议比例、代理 RTT、连接复用、Worker 规格、故障域、CPU/RSS/FD、延迟
分位数和错误率的环境中通过持续压测后,才能声明已验证容量。

View File

@ -0,0 +1,45 @@
# 安全模型
## 1. 信任边界
- Gateway、Distribution、Admin 和 Metrics 为四个独立监听边界。
- Provider API 和上游 Proxy 属于外部不可信网络。
- Worker 与 Controller 通道必须进行双向身份校验并绑定 cluster/worker。
- PostgreSQL 保存权威状态Redis 数据默认按可重建缓存与协调信息处理。
## 2. 入口控制
- 严格模式下,非回环监听必须配置认证或 CIDR allowlist。
- 认证关闭不代表匿名状态消失:仍按可信代理链解析来源并形成 Client ID。
- Access、Auth、Rate Limit 和 Client Identification 相互独立。
- Admin 使用独立凭据,不能复用普通 Gateway 或 Distribution 凭据。
## 3. 目标地址策略
在解析和每次连接前同时检查:
- 私网、回环、链路本地、组播、保留地址和云元数据地址。
- 域名解析出的全部 A/AAAA 地址,而不是只检查原始 Host。
- 重定向或重试后的新目标,防止 DNS Rebinding 和策略绕过。
- CONNECT 的端口 allowlist 与规范化 host:port。
## 4. Secret 处理
- 配置只保存环境变量或文件引用,不在日志中输出解析后的值。
- Proxy 唯一键包含 username 与 credentialVersion不包含密码或 SecretRef 内容。
- 指标标签不得使用 token、Proxy URL、Client ID、session 或完整目标 URL。
- Provider 响应和模板错误只记录分类、Upstream 和 requestId。
## 5. Provider 模板
- 限制响应体大小、模板执行时间和输出候选数量。
- 模板函数采用白名单,禁止文件、网络、进程和环境变量访问。
- URL、Header、Query 和 Body 分别结构化编码,不拼接未转义字符串。
- 认证失败与限流响应分类处理429 尊重有上限的 Retry-After。
## 6. 审计
Extraction 审计记录至少包含 requestId、Client、来源、Proxy ID、Upstream、
提取时间和到期时间。日志脱敏不影响审计关联,但审计接口自身必须受 Admin
权限保护并具备保留期限。

View File

@ -0,0 +1,69 @@
# 故障注入矩阵
## 执行规则
- 先在隔离环境建立 15 分钟稳定基线,再注入单一故障。
- 每次只改变一个变量,记录开始/恢复时间与所有指标。
- 任何数据库写故障后都核对 Proxy 状态、审计和 outbox而非只看 HTTP 状态码。
- 故障恢复后至少观察两个健康检查周期和一个 Snapshot 完整发布周期。
## 场景
### Provider
- DNS NXDOMAIN、连接超时、TLS 失败、401、429、500。
- 响应超过 `maxResponseBytes`、模板超过 `templateTimeout`、非法 Proxy。
- 合法空列表、全部重复、部分重复加部分新增。
预期:只有合法空列表增加 Empty429 尊重 Retry-After其余 Error 退避;
duplicate-only 不触发 Sequential 切换。
### PostgreSQL
- 断开 60 秒、连接池耗尽、锁等待、事务提交失败、只读切换。
预期Gateway 继续使用最后 SnapshotExtract 不返回未提交记录;恢复后 outbox
补发且不重复应用。
### Redis
- 断开、延迟 2 秒、Leader key 丢失、主从切换。
预期Gateway 无影响Provider Fetch 不出现多个有效 Leader全局限流明确降级
恢复后 epoch 单调。
### Controller
- 杀死 Leader、滚动重启全部副本、阻断 Worker 控制流。
预期:`maxStaleAge` 内 Gateway 继续之后停止新流量Leader 切换不重复计费
FetchDelta 缺口触发完整 Snapshot。
### Gateway
- 杀死一个 Pod、驱逐一个节点、丢失一个可用区、耗尽 FD。
预期LoadBalancer 摘除 NotReady Podownership 租约过期前不分给新 Worker
剩余容量满足已验证故障域目标。
### Checker
- 慢目标、DNS 延迟、队列积压、杀死一半 Pod。
预期:优先新 Proxy 和 SUSPECT稳定 Proxy 降频队列有界Observation 重投
不导致非法状态回退。
### Distribution
- 100 个并发请求争用相同 Proxy数据库在事务提交时断开Gateway 同时满载。
预期:每个 Proxy 最多返回一次提交失败不返回代理allOrNothing 整批回滚;
池中始终剩余 `reserveForGateway`;没有 Release 恢复路径。
### 配置与 Secret
- 未知字段、错误正则、缺失 Upstream、公开监听无保护、凭据轮换失败。
预期:新 Revision 整体拒绝,旧不可变 Snapshot 继续;认证错误计 Error 而不是
Empty日志不出现 Secret。

49
docs/testing/strategy.md Normal file
View File

@ -0,0 +1,49 @@
# 测试策略
## 1. 分层
- **领域单测**状态机、TTL、路由、容量、Fetch 分类和 Extraction 原子性。
- **契约测试**配置、OpenAPI、Protobuf 和 Provider Adapter fixture。
- **集成测试**PostgreSQL 事务、Redis Leader/限流、Outbox 与重建。
- **端到端测试**HTTP、CONNECT、Admin、Distribution 和优雅停机。
- **负载测试**Worker 调度微基准、50k 隧道 soak、集群 100k QPS 场景。
## 2. 必测不变量
1. 1,000 个并发预留不突破单 Proxy 最大并发。
2. 同一 Proxy 在并发 Extraction 中最多出现一次。
3. `allOrNothing` 不足时不消耗任何候选。
4. 连续 4 次 Empty 后 Valid 不切换;连续 5 次只从 A 切到 B。
5. Error 和 DuplicateOnly 不累计 Empty。
6. 100 个缺池信号只形成一个合并 Provider reconcile。
7. 并发 Fetch 不突破 `pool.maxSize``fetch.maxTotal`
8. TTL safety margin 内不再分配。
9. Snapshot 版本断档、目标错误或校验和错误不替换当前视图。
10. 非幂等 HTTP 和已建立 CONNECT 不自动重放。
11. 所有 Upstream 不可用时严格执行显式策略。
## 3. 基础质量门禁
```powershell
gofmt -l .
go vet ./...
go test ./...
go test -race ./internal/...
go build ./...
```
单条测试命令超时 60 秒。依赖真实等待的用例必须改为 fake clock集成和
soak 测试单独标记,不混入快速单测。
## 4. 100k QPS 验收
测试报告必须记录:
- CPU、内存、内核、网卡、文件描述符和 conntrack 配置。
- Worker 数量、故障域、目标利用率和负载均衡算法。
- HTTP/CONNECT 比例、keep-alive、请求/响应大小、上游 RTT 与失败率。
- Proxy 总数、每 Proxy 容量、Routing 数和 Snapshot 更新频率。
- 持续时间、P50/P95/P99、成功率、重试率、GC、RSS 和 goroutine 数。
只有在代表性环境持续达到目标且满足错误率和延迟门槛后,才能把“设计目标”
改为“已验证容量”。

View File

@ -0,0 +1,130 @@
# 测试与容量验证策略
## 1. 原则
- 先证明领域不变量,再证明 Adapter 契约,最后证明跨进程行为。
- 并发测试必须在 race detector 下运行,不能只依赖单线程示例。
- 时间、随机、网络、Provider 和存储均通过可替换接口或 fixture 控制。
- 100,000 QPS 是待验证的集群目标,不以架构图、副本数或短时峰值替代证据。
- 性能通过与正确性通过相互独立;高 QPS 下出现超卖、重复 Extract 或状态
回退时,结果一律失败。
## 2. 测试分层
### 单元测试
- Proxy 唯一键、TTL 优先级、状态机和 safety margin。
- Atomic Capacity 的 Reserve、Commit、Cancel、Release 与幂等错误。
- Routing first-match 与五种策略。
- Fetch Success、Empty、Duplicate-only、Error 分类。
- Backoff、jitter、Retry-After、requestInterval、maxInFlight。
- Extraction eligibility、partial、allOrNothing、health age、TTL、reserve。
- 配置严格字段、交叉引用、正则、监听保护和独立计数语义。
### 契约测试
- Provider 响应模板的大小、超时、函数白名单和解析边界。
- PostgreSQL `FOR UPDATE SKIP LOCKED` 并发批量提取。
- Outbox 状态更新、发布与幂等重放。
- Redis Leader 租约、限流和失联恢复。
- Snapshot/Delta/ACK/Report 的版本与校验和兼容性。
- OpenAPI 错误模型、认证矩阵、批量 fulfillment。
### 集成与端到端
- HTTP 正向代理成功、上游连接前失败和安全重试。
- HTTPS CONNECT 建立后不透明重放。
- Controller Fetch -> Check -> AVAILABLE -> Worker Snapshot -> Gateway 转发。
- Distribution 原子提取后 Gateway 不再分配同一 Proxy。
- 配置热更新失败保留旧 Revision成功后新请求使用新 Snapshot。
## 3. 必测的 11 类场景
1. **并发容量**1000 协程争用同一 Proxy始终满足
`active + reserved <= effectiveMaxConcurrency`
2. **Reservation 生命周期**Dial 成功/失败、超时、取消和重复 Release 均不
泄漏或产生负计数。
3. **singleflight**100 个缺池信号只产生一个有效 Fetch 调度。
4. **Provider 限流**requestInterval、maxInFlight、timeout、重试和 429
`Retry-After` 在虚拟时钟下准确。
5. **Fetch 分类**Error 不动 Empty合法空响应 Empty++duplicate-only
重置 EmptySuccess 重置 Empty。
6. **Sequential 竞态**:达到阈值时多协程只能将 A 切到 B 一次,不能越过 B。
7. **Drain**:切换或禁用 Upstream 后停止新分配,已有连接完成后才回收。
8. **Extract 竞态**:多个请求并发提取同一候选集合,每个 Proxy 最多返回一次。
9. **Extract 批量语义**partial 提交实际数量allOrNothing 不足时状态和审计
全回滚;始终保留 `reserveForGateway`
10. **Worker ownership**:回收过程严格经过 DRAINING、ACK、active/reserved=0、
unowned旧 Snapshot 不可再分配。
11. **控制面故障**Redis、PostgreSQL、Controller、Checker 与 Provider 分别
失效时,行为与 Runbook 一致Gateway 热路径不被同步依赖拖垮。
## 4. 测试命令
所有后台测试都设置 60 秒上限:
```powershell
go test -timeout 60s ./...
go test -timeout 60s -race ./internal/...
go vet ./...
go build ./...
```
需要 PostgreSQL/Redis 的测试使用独立数据库和短生命周期容器,不复用开发数据。
测试结束后验证没有残留 Worker ownership、Leader 租约或未提交 Extraction Record。
## 5. 负载模型
必须分开运行,避免不同瓶颈互相掩盖:
### HTTP QPS
- GET/HEAD 占比、响应体大小、Keep-Alive 复用率与生产预测一致。
- 依次运行 10k 稳态、阶梯升压和 100k 峰值。
- 同时记录端到端与 Gateway 内部 Dispatch 延迟。
### CONNECT
- 分开测试活跃隧道数和每秒新建隧道数。
- 包含短连接、长连接、半关闭、Client 取消和上游主动断开。
- 验证 200 已发送后不发生透明重放。
### Snapshot
- 1k、10k、100k Proxy Snapshot测构建、校验、原子切换、内存峰值和 GC。
- 在满负载下发布 Snapshot确认请求线程不参与索引构建。
### Extract
- 小批 partial、大批 allOrNothing、高冲突 filter 和库存不足。
- 与 Gateway 同时运行,持续检查 `reserveForGateway` 和无重复返回。
### 故障负载
- 负载运行中断开 Controller、Redis、PostgreSQL、一个 Worker 和一个可用区。
- Provider 注入 DNS、超时、500、429、超大响应、模板错误和合法空响应。
- Checker 注入慢目标与队列积压。
## 6. 通过条件
业务 SLO 由产品最终确认,但至少满足以下工程门槛:
- 无容量超卖、负计数、重复 Extract、审计缺失或状态非法回退。
- 100k 峰值期间无进程 OOM、FD 耗尽、无界队列或全局锁热点。
- Gateway 热路径在 PostgreSQL、Redis、Provider 失效时不发起同步访问。
- p99 Dispatch 小于 100 微秒的设计预算需要在 100k Proxy Snapshot 下单独证明。
- 最大故障域丢失后,剩余容量仍满足约定 SLO否则增加副本或降低承诺容量。
- Snapshot 超过 `maxStaleAge` 后 Gateway 拒绝新流量,已有连接按时排空。
- 所有数据、命令、Git SHA、镜像 digest、环境和原始指标可以复现。
## 7. 测试报告模板
```text
版本Git SHA / image digest / Go version
环境节点、CPU、内存、NIC、内核、Kubernetes/CNI
配置Snapshot 规模、Proxy 容量、路由、重试、日志级别
场景:协议、连接复用、响应体、持续时间、升压曲线、故障注入
结果QPS、建连速率、active、p50/p95/p99、错误、CPU、RSS、GC、FD、网络
不变量capacity、ownership、extraction、audit、reserve 检查结果
结论:通过/失败,以及适用边界
```

View File

@ -0,0 +1,53 @@
# 本机同时启用 Gateway 与一次性独占提取 API。
version: 1
security:
requireProtectionOnPublicListen: true
gateway:
enabled: true
listen: 127.0.0.1:8080
auth: {mode: none}
retry:
maxAttempts: 2
retryMethods: [GET, HEAD]
distribution:
enabled: true
listen: 127.0.0.1:8081
auth: {mode: none}
clientIdentification: {mode: sourceIP}
extraction:
fulfillment: partial
maxCountPerRequest: 20
minRemainingTTL: 30s
maxHealthCheckAge: 15s
reserveForGateway: 5
routing:
- name: gateway-default
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: leastConnections}
onUnavailable: {action: reject}
- name: extract-default
enabled: true
purpose: extract
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: random}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [gateway, extract]
provider: {billingMode: fetch, protocols: [http]}
api:
url: https://provider-a.example/proxies
method: GET
auth: {type: none}
template: '{{.}}'
proxyAuth: {type: response}
pool: {maxSize: 100}
capacity: {maxConcurrencyPerProxy: 10}
lifecycle: {ttl: 2m, allocationSafetyMargin: 10s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1, maxTotal: 1000}
check: {interval: 30s, jitter: 20, maxInFlight: 50, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,33 @@
# 仅提供本机 HTTP/CONNECT 网关。
version: 1
security: {requireProtectionOnPublicListen: true}
gateway:
enabled: true
listen: 127.0.0.1:8080
auth: {mode: none}
limits: {maxConcurrentConnections: 20000}
retry: {maxAttempts: 2, retryMethods: [GET, HEAD]}
destinationPolicy:
denyPrivateNetworks: true
denyLoopback: true
denyLinkLocal: true
routing:
- name: gateway
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: leastConnections}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [gateway]
provider: {billingMode: subscription, protocols: [http, https]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 2000}
capacity: {maxConcurrencyPerProxy: 20}
lifecycle: {ttl: 5m, allocationSafetyMargin: 30s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}
check: {interval: 30s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,35 @@
# 仅提供一次性独占提取;没有 lease/release 配置。
version: 1
security: {requireProtectionOnPublicListen: true}
distribution:
enabled: true
listen: 127.0.0.1:8081
auth: {mode: none}
clientIdentification: {mode: sourceIP}
limits: {requestsPerMinute: 60, requestsPerMinutePerClient: 30}
extraction:
fulfillment: partial
maxCountPerRequest: 50
minRemainingTTL: 30s
maxHealthCheckAge: 15s
reserveForGateway: 0
routing:
- name: extract
enabled: true
purpose: extract
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: random}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [extract]
provider: {billingMode: fetch, protocols: [http]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 500}
capacity: {maxConcurrencyPerProxy: 1}
lifecycle: {ttl: 2m, allocationSafetyMargin: 10s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1, maxTotal: 10000}
check: {interval: 15s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,37 @@
# 公网 Gateway 使用用户名密码,并拒绝内网/回环目标。
version: 1
security: {requireProtectionOnPublicListen: true}
gateway:
enabled: true
listen: 0.0.0.0:8080
auth:
mode: usernamePassword
username: "${GATEWAY_USER}"
password: "${GATEWAY_PASSWORD}"
limits: {maxConcurrentConnections: 50000}
retry: {maxAttempts: 2, retryMethods: [GET, HEAD]}
destinationPolicy:
denyPrivateNetworks: true
denyLoopback: true
denyLinkLocal: true
denyCIDRs: [169.254.169.254/32]
routing:
- name: public-gateway
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: leastConnections}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [gateway]
provider: {billingMode: subscription, protocols: [http]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 3000}
capacity: {maxConcurrencyPerProxy: 20}
lifecycle: {ttl: 5m, allocationSafetyMargin: 30s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}
check: {interval: 30s, jitter: 20, maxInFlight: 200, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,38 @@
# 公网提取 API 使用 X-API-Key成功后代理立即 EXTRACTED。
version: 1
security: {requireProtectionOnPublicListen: true}
distribution:
enabled: true
listen: 0.0.0.0:8081
auth:
mode: apiKey
header: X-API-Key
token: "${DISTRIBUTION_API_KEY}"
clientIdentification: {mode: authenticatedClient}
limits: {requestsPerMinute: 6000, requestsPerMinutePerClient: 300}
extraction:
fulfillment: partial
maxCountPerRequest: 100
minRemainingTTL: 45s
maxHealthCheckAge: 10s
reserveForGateway: 0
routing:
- name: public-extract
enabled: true
purpose: extract
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: random}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [extract]
provider: {billingMode: fetch, protocols: [http]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 1000}
capacity: {maxConcurrencyPerProxy: 1}
lifecycle: {ttl: 3m, allocationSafetyMargin: 30s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1, maxTotal: 100000}
check: {interval: 15s, jitter: 20, maxInFlight: 200, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,45 @@
# 内网关闭认证,但用来源 CIDR 独立保护两个入口。
version: 1
security: {requireProtectionOnPublicListen: true}
gateway:
enabled: true
listen: 0.0.0.0:8080
access: {allowCIDRs: [10.0.0.0/8, 192.168.0.0/16]}
auth: {mode: none}
distribution:
enabled: true
listen: 0.0.0.0:8081
access:
allowCIDRs: [10.0.0.0/8, 192.168.0.0/16]
trustedProxies: [10.10.0.10/32]
auth: {mode: none}
clientIdentification: {mode: sourceIP}
limits: {requestsPerMinutePerClient: 30}
extraction: {fulfillment: partial, maxCountPerRequest: 10, minRemainingTTL: 30s, maxHealthCheckAge: 15s, reserveForGateway: 10}
routing:
- name: shared-gateway
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: leastConnections}
onUnavailable: {action: reject}
- name: shared-extract
enabled: true
purpose: extract
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: random}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [gateway, extract]
provider: {billingMode: subscription, protocols: [http]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 1000}
capacity: {maxConcurrencyPerProxy: 10}
lifecycle: {ttl: 2m, allocationSafetyMargin: 15s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}
check: {interval: 20s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,37 @@
# 来源在可信网段或提供 API Key任一方法通过即可。
version: 1
security: {requireProtectionOnPublicListen: true}
distribution:
enabled: true
listen: 0.0.0.0:8081
auth:
mode: any
methods:
- mode: ipWhitelist
cidrs: [10.0.0.0/8]
- mode: apiKey
header: X-API-Key
value: "${DISTRIBUTION_API_KEY}"
clientIdentification: {mode: authenticatedClientOrSourceIP}
limits: {requestsPerMinute: 600, requestsPerMinutePerClient: 60}
extraction: {fulfillment: partial, maxCountPerRequest: 20, minRemainingTTL: 30s, maxHealthCheckAge: 15s, reserveForGateway: 0}
routing:
- name: extract
enabled: true
purpose: extract
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: random}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [extract]
provider: {billingMode: fetch, protocols: [http]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 500}
capacity: {maxConcurrencyPerProxy: 1}
lifecycle: {ttl: 2m, allocationSafetyMargin: 15s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1, maxTotal: 10000}
check: {interval: 15s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,38 @@
# Provider 返回连续 5 次合法空结果后Routing 从 A 原子前进到 B。
version: 1
security: {requireProtectionOnPublicListen: true}
gateway: {enabled: true, listen: '127.0.0.1:8080', auth: {mode: none}}
routing:
- name: sequential-gateway
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-a, provider-b]
strategy:
type: sequential
switchAfterEmptyFetch: 5
endBehavior: stayLast
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [gateway]
provider: {billingMode: fetch, protocols: [http]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 500}
capacity: {maxConcurrencyPerProxy: 10}
lifecycle: {ttl: 2m, allocationSafetyMargin: 15s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1, maxTotal: 10000}
check: {interval: 30s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}
provider-b:
enabled: true
exposure: [gateway]
provider: {billingMode: subscription, protocols: [http]}
api: {url: https://provider-b.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 500}
capacity: {maxConcurrencyPerProxy: 10}
lifecycle: {ttl: 3m, allocationSafetyMargin: 15s}
fetch: {requestInterval: 2s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}
check: {interval: 30s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,37 @@
# 70/30 权重只决定 Upstream 选择,不复制 Upstream 运行态。
version: 1
security: {requireProtectionOnPublicListen: true}
gateway: {enabled: true, listen: '127.0.0.1:8080', auth: {mode: none}}
routing:
- name: weighted-gateway
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-a, provider-b]
strategy:
type: weighted
weights: {provider-a: 70, provider-b: 30}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [gateway]
provider: {billingMode: subscription, protocols: [http]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 1000}
capacity: {maxConcurrencyPerProxy: 10}
lifecycle: {ttl: 2m, allocationSafetyMargin: 15s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}
check: {interval: 30s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}
provider-b:
enabled: true
exposure: [gateway]
provider: {billingMode: subscription, protocols: [http]}
api: {url: https://provider-b.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 1000}
capacity: {maxConcurrencyPerProxy: 10}
lifecycle: {ttl: 2m, allocationSafetyMargin: 15s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}
check: {interval: 30s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,31 @@
# 在可用 Upstream 间轮询。
version: 1
security: {requireProtectionOnPublicListen: true}
gateway: {enabled: true, listen: '127.0.0.1:8080', auth: {mode: none}}
routing:
- name: round-robin
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-a, provider-b]
strategy: {type: roundRobin}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [gateway]
provider: {billingMode: subscription, protocols: [http]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 1000}
capacity: {maxConcurrencyPerProxy: 10}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}
provider-b:
enabled: true
exposure: [gateway]
provider: {billingMode: subscription, protocols: [http]}
api: {url: https://provider-b.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 1000}
capacity: {maxConcurrencyPerProxy: 10}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}

View File

@ -0,0 +1,31 @@
# 每次从可用 Upstream 集合随机选择。
version: 1
security: {requireProtectionOnPublicListen: true}
gateway: {enabled: true, listen: '127.0.0.1:8080', auth: {mode: none}}
routing:
- name: random-gateway
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-a, provider-b]
strategy: {type: random}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [gateway]
provider: {billingMode: subscription, protocols: [http]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 1000}
capacity: {maxConcurrencyPerProxy: 10}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}
provider-b:
enabled: true
exposure: [gateway]
provider: {billingMode: subscription, protocols: [http]}
api: {url: https://provider-b.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 1000}
capacity: {maxConcurrencyPerProxy: 10}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}

View File

@ -0,0 +1,28 @@
# Gateway 按本地 Active + Reserved 选择剩余容量最高的 Proxy。
version: 1
security: {requireProtectionOnPublicListen: true}
gateway:
enabled: true
listen: 127.0.0.1:8080
auth: {mode: none}
limits: {maxConcurrentConnections: 100000}
routing:
- name: least-connections
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: leastConnections}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [gateway]
provider: {billingMode: subscription, protocols: [http, https]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 10000}
capacity: {maxConcurrencyPerProxy: 50}
lifecycle: {ttl: 5m, allocationSafetyMargin: 30s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}
check: {interval: 30s, jitter: 20, maxInFlight: 500, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,34 @@
# 数量不足时返回 409事务不改变任何 Proxy 状态。
version: 1
security: {requireProtectionOnPublicListen: true}
distribution:
enabled: true
listen: 127.0.0.1:8081
auth: {mode: none}
clientIdentification: {mode: sourceIP}
extraction:
fulfillment: allOrNothing
maxCountPerRequest: 100
minRemainingTTL: 30s
maxHealthCheckAge: 15s
reserveForGateway: 0
routing:
- name: extract
enabled: true
purpose: extract
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: random}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [extract]
provider: {billingMode: fetch, protocols: [http]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 500}
capacity: {maxConcurrencyPerProxy: 1}
lifecycle: {ttl: 2m, allocationSafetyMargin: 15s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1, maxTotal: 50000}
check: {interval: 15s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,42 @@
# 共享池至少为 Gateway 保留 100 个符合提取条件的可用 Proxy。
version: 1
security: {requireProtectionOnPublicListen: true}
gateway: {enabled: true, listen: '127.0.0.1:8080', auth: {mode: none}}
distribution:
enabled: true
listen: 127.0.0.1:8081
auth: {mode: none}
clientIdentification: {mode: sourceIP}
extraction:
fulfillment: partial
maxCountPerRequest: 100
minRemainingTTL: 30s
maxHealthCheckAge: 15s
reserveForGateway: 100
routing:
- name: gateway
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: leastConnections}
onUnavailable: {action: reject}
- name: extract
enabled: true
purpose: extract
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: random}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [gateway, extract]
provider: {billingMode: subscription, protocols: [http]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 2000}
capacity: {maxConcurrencyPerProxy: 10}
lifecycle: {ttl: 3m, allocationSafetyMargin: 20s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}
check: {interval: 15s, jitter: 20, maxInFlight: 200, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,34 @@
# 只发放剩余 TTL 至少 60 秒且 5 秒内检查过的 Proxy。
version: 1
security: {requireProtectionOnPublicListen: true}
distribution:
enabled: true
listen: 127.0.0.1:8081
auth: {mode: none}
clientIdentification: {mode: sourceIP}
extraction:
fulfillment: partial
maxCountPerRequest: 20
minRemainingTTL: 60s
maxHealthCheckAge: 5s
reserveForGateway: 0
routing:
- name: fresh-extract
enabled: true
purpose: extract
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: random}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [extract]
provider: {billingMode: fetch, protocols: [http]}
api: {url: https://provider-a.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 1000}
capacity: {maxConcurrencyPerProxy: 1}
lifecycle: {ttl: 5m, allocationSafetyMargin: 60s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1, maxTotal: 100000}
check: {interval: 5s, jitter: 20, maxInFlight: 500, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,31 @@
# Provider API 认证与对外 Client 认证相互独立。
version: 1
security: {requireProtectionOnPublicListen: true}
gateway: {enabled: true, listen: '127.0.0.1:8080', auth: {mode: none}}
routing:
- name: gateway
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: leastConnections}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [gateway]
provider: {billingMode: subscription, protocols: [http]}
api:
url: https://provider-a.example/proxies
method: GET
auth:
type: basic
username: "${PROVIDER_API_USER}"
password: "${PROVIDER_API_PASSWORD}"
template: '{{.}}'
proxyAuth: {type: response}
pool: {maxSize: 1000}
capacity: {maxConcurrencyPerProxy: 10}
lifecycle: {ttl: 2m, allocationSafetyMargin: 15s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1}
check: {interval: 30s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,32 @@
# Provider API Key 放在专用 Header日志必须统一脱敏。
version: 1
security: {requireProtectionOnPublicListen: true}
gateway: {enabled: true, listen: '127.0.0.1:8080', auth: {mode: none}}
routing:
- name: gateway
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: leastConnections}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [gateway]
provider: {billingMode: fetch, protocols: [http]}
api:
url: https://provider-a.example/proxies
method: GET
auth:
type: apiKey
location: header
name: X-Provider-Key
value: "${PROVIDER_API_KEY}"
template: '{{.}}'
proxyAuth: {type: response}
pool: {maxSize: 500}
capacity: {maxConcurrencyPerProxy: 10}
lifecycle: {ttl: 2m, allocationSafetyMargin: 15s}
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1, maxTotal: 10000}
check: {interval: 30s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,40 @@
# Provider 使用 JSON POST供应商重试由独立 Fetch 策略控制。
version: 1
security: {requireProtectionOnPublicListen: true}
gateway: {enabled: true, listen: '127.0.0.1:8080', auth: {mode: none}}
routing:
- name: gateway
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-a]
strategy: {type: leastConnections}
onUnavailable: {action: reject}
upstreams:
provider-a:
enabled: true
exposure: [gateway]
provider: {billingMode: fetch, protocols: [http]}
api:
url: https://provider-a.example/v1/orders
method: POST
auth: {type: apiKey, location: header, name: X-Provider-Key, value: "${PROVIDER_API_KEY}"}
headers: {Content-Type: application/json}
body:
type: json
value: {count: '100', protocol: http}
template: '{{.}}'
proxyAuth: {type: response}
pool: {maxSize: 1000}
capacity: {maxConcurrencyPerProxy: 10}
lifecycle: {ttl: 3m, allocationSafetyMargin: 20s}
fetch:
requestInterval: 2s
timeout: 5s
maxAttempts: 3
maxInFlight: 1
maxTotal: 100000
maxResponseBytes: 1048576
templateTimeout: 100ms
retry: {initial: 1s, max: 30s, jitter: 20}
check: {interval: 30s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,27 @@
# SOCKS5 上游保留在统一 Proxy 模型和策略契约内。
version: 1
security: {requireProtectionOnPublicListen: true}
gateway: {enabled: true, listen: '127.0.0.1:8080', auth: {mode: none}}
routing:
- name: socks-upstream
enabled: true
purpose: gateway
match: {hostRegex: '.*'}
upstreams: [provider-socks]
strategy: {type: leastConnections}
onUnavailable: {action: reject}
upstreams:
provider-socks:
enabled: true
exposure: [gateway]
provider: {billingMode: subscription, protocols: [socks5]}
api: {url: https://provider-socks.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth:
type: static
username: "${SOCKS_USER}"
password: "${SOCKS_PASSWORD}"
pool: {maxSize: 1000}
capacity: {maxConcurrencyPerProxy: 20}
lifecycle: {ttl: 10m, allocationSafetyMargin: 60s}
fetch: {requestInterval: 5s, timeout: 5s, maxAttempts: 3, maxInFlight: 1}
check: {interval: 30s, jitter: 20, maxInFlight: 100, timeout: 3s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,35 @@
# pool.maxSize 限当前未提取库存fetch.maxTotal 限计费周期累计获取数。
version: 1
security: {requireProtectionOnPublicListen: true}
distribution:
enabled: true
listen: 127.0.0.1:8081
auth: {mode: none}
clientIdentification: {mode: sourceIP}
extraction: {fulfillment: partial, maxCountPerRequest: 20, minRemainingTTL: 30s, maxHealthCheckAge: 15s, reserveForGateway: 0}
routing:
- name: billed-extract
enabled: true
purpose: extract
match: {hostRegex: '.*'}
upstreams: [provider-metered]
strategy: {type: random}
onUnavailable: {action: reject}
upstreams:
provider-metered:
enabled: true
exposure: [extract]
provider: {billingMode: fetch, protocols: [http]}
api: {url: https://provider-metered.example/proxies, method: GET, auth: {type: none}, template: '{{.}}'}
proxyAuth: {type: response}
pool: {maxSize: 100}
capacity: {maxConcurrencyPerProxy: 1}
lifecycle: {ttl: 2m, allocationSafetyMargin: 15s}
fetch:
requestInterval: 1s
timeout: 3s
maxAttempts: 3
maxInFlight: 1
maxTotal: 1000
retry: {initial: 500ms, max: 30s, jitter: 20}
check: {interval: 15s, jitter: 20, maxInFlight: 100, timeout: 2s, maxAttempts: 2, maxConsecutiveFailures: 3}

View File

@ -0,0 +1,64 @@
package configexamples
import (
"os"
"path/filepath"
"strings"
"testing"
projectconfig "github.com/proxy-pool/proxy-pool/internal/config"
)
func TestAllExamplesLoadStrictly(t *testing.T) {
entries, err := filepath.Glob("*.yaml")
if err != nil {
t.Fatalf("glob examples: %v", err)
}
if len(entries) < 20 {
t.Fatalf("configuration examples = %d, want at least 20", len(entries))
}
for _, path := range entries {
path := path
t.Run(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), func(t *testing.T) {
file, err := os.Open(path)
if err != nil {
t.Fatalf("open %s: %v", path, err)
}
defer file.Close()
cfg, err := projectconfig.Load(file)
if err != nil {
t.Fatalf("load %s: %v", path, err)
}
assertExplicitProviderAuth(t, cfg)
})
}
}
func TestMainConfigurationLoadsStrictly(t *testing.T) {
path := filepath.Join("..", "..", "configs", "proxy-pool.yaml")
file, err := os.Open(path)
if err != nil {
t.Fatalf("open %s: %v", path, err)
}
defer file.Close()
cfg, err := projectconfig.Load(file)
if err != nil {
t.Fatalf("load %s: %v", path, err)
}
assertExplicitProviderAuth(t, cfg)
}
func assertExplicitProviderAuth(t *testing.T, cfg *projectconfig.Config) {
t.Helper()
for name, upstream := range cfg.Upstreams {
if !upstream.Enabled {
continue
}
if upstream.API.Auth.Type == "" {
t.Errorf("upstream %s must explicitly set api.auth.type", name)
}
if upstream.ProxyAuth.Type == "" {
t.Errorf("upstream %s must explicitly set proxyAuth.type", name)
}
}
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module github.com/proxy-pool/proxy-pool
go 1.26.0
require go.yaml.in/yaml/v4 v4.0.0-rc.3

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=

244
internal/config/config.go Normal file
View File

@ -0,0 +1,244 @@
package config
import (
"fmt"
"time"
)
type Duration time.Duration
func (d *Duration) UnmarshalText(text []byte) error {
value, err := time.ParseDuration(string(text))
if err != nil {
return fmt.Errorf("parse duration %q: %w", text, err)
}
*d = Duration(value)
return nil
}
func (d Duration) Value() time.Duration { return time.Duration(d) }
type Config struct {
Version int `yaml:"version"`
Defaults Defaults `yaml:"defaults"`
Security Security `yaml:"security"`
Gateway Listener `yaml:"gateway"`
Distribution Distribution `yaml:"distribution"`
Admin Listener `yaml:"admin"`
Metrics Metrics `yaml:"metrics"`
Storage Storage `yaml:"storage"`
Routing []Routing `yaml:"routing"`
Upstreams map[string]Upstream `yaml:"upstreams"`
}
type Defaults struct {
Fetch Fetch `yaml:"fetch"`
Check Check `yaml:"check"`
}
type Security struct {
RequireProtectionOnPublicListen bool `yaml:"requireProtectionOnPublicListen"`
}
type Listener struct {
Enabled bool `yaml:"enabled"`
Listen string `yaml:"listen"`
Access Access `yaml:"access"`
Auth Auth `yaml:"auth"`
Limits Limits `yaml:"limits"`
Retry Retry `yaml:"retry"`
DestinationPolicy DestinationPolicy `yaml:"destinationPolicy"`
}
type Distribution struct {
Listener `yaml:",inline"`
ClientIdentification ClientIdentification `yaml:"clientIdentification"`
Extraction Extraction `yaml:"extraction"`
}
type Access struct {
AllowCIDRs []string `yaml:"allowCIDRs"`
TrustedProxies []string `yaml:"trustedProxies"`
}
type Auth struct {
Mode string `yaml:"mode"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Token string `yaml:"token"`
Header string `yaml:"header"`
CIDRs []string `yaml:"cidrs"`
Methods []AuthMethod `yaml:"methods"`
}
type AuthMethod struct {
Mode string `yaml:"mode"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Header string `yaml:"header"`
Value string `yaml:"value"`
CIDRs []string `yaml:"cidrs"`
}
type Limits struct {
MaxConcurrentConnections int `yaml:"maxConcurrentConnections"`
RequestsPerMinute int `yaml:"requestsPerMinute"`
RequestsPerMinutePerClient int `yaml:"requestsPerMinutePerClient"`
}
type Retry struct {
MaxAttempts int `yaml:"maxAttempts"`
RetryMethods []string `yaml:"retryMethods"`
}
type DestinationPolicy struct {
DenyPrivateNetworks bool `yaml:"denyPrivateNetworks"`
DenyLoopback bool `yaml:"denyLoopback"`
DenyLinkLocal bool `yaml:"denyLinkLocal"`
DenyCIDRs []string `yaml:"denyCIDRs"`
}
type ClientIdentification struct {
Mode string `yaml:"mode"`
}
type Extraction struct {
Fulfillment string `yaml:"fulfillment"`
MaxCountPerRequest int `yaml:"maxCountPerRequest"`
MinRemainingTTL Duration `yaml:"minRemainingTTL"`
MaxHealthCheckAge Duration `yaml:"maxHealthCheckAge"`
ReserveForGateway int `yaml:"reserveForGateway"`
}
type Metrics struct {
Enabled bool `yaml:"enabled"`
Listen string `yaml:"listen"`
}
type Storage struct {
PostgresURL string `yaml:"postgresURL"`
RedisURL string `yaml:"redisURL"`
}
type Routing struct {
Name string `yaml:"name"`
Enabled bool `yaml:"enabled"`
Purpose string `yaml:"purpose"`
Match RoutingMatch `yaml:"match"`
Upstreams []string `yaml:"upstreams"`
Strategy Strategy `yaml:"strategy"`
OnUnavailable OnUnavailable `yaml:"onUnavailable"`
}
type RoutingMatch struct {
HostRegex string `yaml:"hostRegex"`
Methods []string `yaml:"methods"`
PathRegex string `yaml:"pathRegex"`
Headers map[string]string `yaml:"headers"`
}
type Strategy struct {
Type string `yaml:"type"`
SwitchAfterEmptyFetch int `yaml:"switchAfterEmptyFetch"`
EndBehavior string `yaml:"endBehavior"`
Weights map[string]int `yaml:"weights"`
}
type OnUnavailable struct {
Action string `yaml:"action"`
WaitTimeout Duration `yaml:"waitTimeout"`
}
type Upstream struct {
Enabled bool `yaml:"enabled"`
Exposure []string `yaml:"exposure"`
Provider Provider `yaml:"provider"`
API ProviderAPI `yaml:"api"`
ProxyAuth ProxyAuth `yaml:"proxyAuth"`
Pool Pool `yaml:"pool"`
Capacity Capacity `yaml:"capacity"`
Lifecycle Lifecycle `yaml:"lifecycle"`
Fetch Fetch `yaml:"fetch"`
Check Check `yaml:"check"`
}
type Provider struct {
BillingMode string `yaml:"billingMode"`
Protocols []string `yaml:"protocols"`
}
type ProviderAPI struct {
URL string `yaml:"url"`
Method string `yaml:"method"`
Auth ProviderAuth `yaml:"auth"`
Headers map[string]string `yaml:"headers"`
Query map[string]string `yaml:"query"`
Body APIBody `yaml:"body"`
Template string `yaml:"template"`
}
type ProviderAuth struct {
Type string `yaml:"type"`
Username string `yaml:"username"`
Password string `yaml:"password"`
PasswordFile string `yaml:"passwordFile"`
Token string `yaml:"token"`
TokenFile string `yaml:"tokenFile"`
Location string `yaml:"location"`
Name string `yaml:"name"`
Value string `yaml:"value"`
ValueFile string `yaml:"valueFile"`
}
type APIBody struct {
Type string `yaml:"type"`
Value map[string]string `yaml:"value"`
}
type ProxyAuth struct {
Type string `yaml:"type"`
Username string `yaml:"username"`
Password string `yaml:"password"`
PasswordFile string `yaml:"passwordFile"`
}
type Pool struct {
MaxSize int `yaml:"maxSize"`
ShrinkDelay Duration `yaml:"shrinkDelay"`
}
type Capacity struct {
MaxConcurrencyPerProxy int `yaml:"maxConcurrencyPerProxy"`
}
type Lifecycle struct {
TTL Duration `yaml:"ttl"`
AllocationSafetyMargin Duration `yaml:"allocationSafetyMargin"`
}
type Fetch struct {
RequestInterval Duration `yaml:"requestInterval"`
Timeout Duration `yaml:"timeout"`
MaxAttempts int `yaml:"maxAttempts"`
MaxInFlight int `yaml:"maxInFlight"`
MaxTotal int `yaml:"maxTotal"`
MaxResponseBytes int64 `yaml:"maxResponseBytes"`
TemplateTimeout Duration `yaml:"templateTimeout"`
Retry Backoff `yaml:"retry"`
}
type Backoff struct {
Initial Duration `yaml:"initial"`
Max Duration `yaml:"max"`
Jitter int `yaml:"jitter"`
}
type Check struct {
Interval Duration `yaml:"interval"`
Jitter int `yaml:"jitter"`
MaxInFlight int `yaml:"maxInFlight"`
Timeout Duration `yaml:"timeout"`
MaxAttempts int `yaml:"maxAttempts"`
MaxConsecutiveFailures int `yaml:"maxConsecutiveFailures"`
URLs []string `yaml:"urls"`
}

View File

@ -0,0 +1,145 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
const validConfig = `
version: 1
security:
requireProtectionOnPublicListen: true
gateway:
enabled: true
listen: 127.0.0.1:8080
auth:
mode: none
distribution:
enabled: true
listen: 127.0.0.1:8081
auth:
mode: none
extraction:
fulfillment: partial
maxCountPerRequest: 20
minRemainingTTL: 30s
maxHealthCheckAge: 15s
reserveForGateway: 5
routing:
- name: extract
enabled: true
purpose: extract
upstreams: [provider-a]
strategy:
type: sequential
switchAfterEmptyFetch: 5
onUnavailable:
action: reject
upstreams:
provider-a:
enabled: true
exposure: [gateway, extract]
provider:
billingMode: fetch
protocols: [http]
api:
url: https://provider.example/proxies
method: GET
template: '{{.}}'
auth:
type: none
proxyAuth:
type: response
pool:
maxSize: 100
capacity:
maxConcurrencyPerProxy: 10
lifecycle:
ttl: 120s
allocationSafetyMargin: 10s
fetch:
requestInterval: 1s
timeout: 3s
maxAttempts: 5
maxInFlight: 1
maxTotal: 1000
check:
interval: 30s
jitter: 20
maxInFlight: 100
timeout: 2s
maxAttempts: 2
maxConsecutiveFailures: 3
urls: [http://connect.rom.miui.com/generate_204]
`
func TestLoadStrictValidConfiguration(t *testing.T) {
cfg, err := Load(strings.NewReader(validConfig))
if err != nil {
t.Fatalf("Load(): %v", err)
}
if cfg.Version != 1 || cfg.Upstreams["provider-a"].Pool.MaxSize != 100 {
t.Fatalf("unexpected config: %+v", cfg)
}
}
func TestLoadRejectsUnknownFields(t *testing.T) {
_, err := Load(strings.NewReader(validConfig + "unknownField: true\n"))
if err == nil || !strings.Contains(err.Error(), "unknownField") {
t.Fatalf("Load() error = %v, want unknown field error", err)
}
}
func TestValidateRejectsUnprotectedPublicListener(t *testing.T) {
cfg, err := Load(strings.NewReader(strings.Replace(validConfig,
"listen: 127.0.0.1:8080", "listen: 0.0.0.0:8080", 1)))
if err == nil || !strings.Contains(err.Error(), "gateway") || !strings.Contains(err.Error(), "public") {
t.Fatalf("Load() error = %v, want unprotected public listener error", err)
}
if cfg != nil {
t.Fatal("invalid config must not be returned")
}
}
func TestValidateRejectsMissingUpstreamReference(t *testing.T) {
broken := strings.Replace(validConfig, "upstreams: [provider-a]", "upstreams: [missing]", 1)
_, err := Load(strings.NewReader(broken))
if err == nil || !strings.Contains(err.Error(), "missing") {
t.Fatalf("Load() error = %v, want missing upstream error", err)
}
}
func TestValidateSeparatesPoolAndFetchLimits(t *testing.T) {
broken := strings.Replace(validConfig, "maxTotal: 1000", "maxTotal: 50", 1)
_, err := Load(strings.NewReader(broken))
if err == nil || !strings.Contains(err.Error(), "maxTotal") {
t.Fatalf("Load() error = %v, want maxTotal validation error", err)
}
}
func TestShippedConfigurationsAreValid(t *testing.T) {
paths, err := filepath.Glob(filepath.Join("..", "..", "examples", "config", "*.yaml"))
if err != nil {
t.Fatalf("Glob(): %v", err)
}
paths = append(paths, filepath.Join("..", "..", "configs", "proxy-pool.yaml"))
if len(paths) != 21 {
t.Fatalf("configuration count = %d, want 21", len(paths))
}
for _, path := range paths {
path := path
t.Run(filepath.Base(path), func(t *testing.T) {
file, err := os.Open(path)
if err != nil {
t.Fatalf("Open(): %v", err)
}
defer file.Close()
if _, err := Load(file); err != nil {
t.Fatalf("Load(): %v", err)
}
})
}
}

22
internal/config/load.go Normal file
View File

@ -0,0 +1,22 @@
package config
import (
"fmt"
"io"
"go.yaml.in/yaml/v4"
)
func Load(reader io.Reader) (*Config, error) {
decoder := yaml.NewDecoder(reader)
decoder.KnownFields(true)
var cfg Config
if err := decoder.Decode(&cfg); err != nil {
return nil, fmt.Errorf("decode configuration: %w", err)
}
if err := Validate(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}

193
internal/config/validate.go Normal file
View File

@ -0,0 +1,193 @@
package config
import (
"fmt"
"net"
"net/url"
"regexp"
"strings"
)
func Validate(cfg *Config) error {
if cfg == nil {
return fmt.Errorf("validate configuration: nil config")
}
if cfg.Version != 1 {
return fmt.Errorf("validate configuration: version must be 1")
}
listeners := []struct {
name string
item Listener
}{
{name: "gateway", item: cfg.Gateway},
{name: "distribution", item: cfg.Distribution.Listener},
{name: "admin", item: cfg.Admin},
}
for _, listener := range listeners {
if err := validateListener(listener.name, listener.item, cfg.Security); err != nil {
return err
}
}
for name, upstream := range cfg.Upstreams {
if err := validateUpstream(name, upstream); err != nil {
return err
}
}
seen := make(map[string]struct{}, len(cfg.Routing))
for index, route := range cfg.Routing {
if route.Name == "" {
return fmt.Errorf("validate routing[%d]: name is required", index)
}
if _, ok := seen[route.Name]; ok {
return fmt.Errorf("validate routing %q: duplicate name", route.Name)
}
seen[route.Name] = struct{}{}
if route.Match.HostRegex != "" {
if _, err := regexp.Compile(route.Match.HostRegex); err != nil {
return fmt.Errorf("validate routing %q hostRegex: %w", route.Name, err)
}
}
if route.Match.PathRegex != "" {
if _, err := regexp.Compile(route.Match.PathRegex); err != nil {
return fmt.Errorf("validate routing %q pathRegex: %w", route.Name, err)
}
}
for _, upstream := range route.Upstreams {
if _, ok := cfg.Upstreams[upstream]; !ok {
return fmt.Errorf("validate routing %q: upstream %q does not exist", route.Name, upstream)
}
}
if route.Strategy.Type == "sequential" && route.Strategy.SwitchAfterEmptyFetch <= 0 {
return fmt.Errorf("validate routing %q: switchAfterEmptyFetch must be greater than zero", route.Name)
}
if route.OnUnavailable.Action == "" {
return fmt.Errorf("validate routing %q: onUnavailable.action is required", route.Name)
}
}
if cfg.Distribution.Enabled {
if cfg.Distribution.Extraction.MaxCountPerRequest <= 0 {
return fmt.Errorf("validate distribution: maxCountPerRequest must be greater than zero")
}
if cfg.Distribution.Extraction.Fulfillment != "partial" && cfg.Distribution.Extraction.Fulfillment != "allOrNothing" {
return fmt.Errorf("validate distribution: fulfillment must be partial or allOrNothing")
}
}
return nil
}
func validateListener(name string, listener Listener, security Security) error {
if !listener.Enabled {
return nil
}
if listener.Listen == "" {
return fmt.Errorf("validate %s: listen is required", name)
}
host, _, err := net.SplitHostPort(listener.Listen)
if err != nil {
return fmt.Errorf("validate %s listen: %w", name, err)
}
if security.RequireProtectionOnPublicListen && isPublicHost(host) && listener.Auth.Mode == "none" && len(listener.Access.AllowCIDRs) == 0 {
return fmt.Errorf("validate %s: unprotected public listener is forbidden", name)
}
for _, cidr := range listener.Access.AllowCIDRs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
return fmt.Errorf("validate %s allowCIDRs %q: %w", name, cidr, err)
}
}
return nil
}
func validateUpstream(name string, upstream Upstream) error {
if !upstream.Enabled {
return nil
}
if upstream.Pool.MaxSize <= 0 {
return fmt.Errorf("validate upstream %q: pool.maxSize must be greater than zero", name)
}
if upstream.Fetch.MaxTotal > 0 && upstream.Fetch.MaxTotal < upstream.Pool.MaxSize {
return fmt.Errorf("validate upstream %q: fetch.maxTotal cannot be lower than pool.maxSize", name)
}
if upstream.Capacity.MaxConcurrencyPerProxy <= 0 {
return fmt.Errorf("validate upstream %q: maxConcurrencyPerProxy must be greater than zero", name)
}
if upstream.Lifecycle.TTL > 0 && upstream.Lifecycle.AllocationSafetyMargin >= upstream.Lifecycle.TTL {
return fmt.Errorf("validate upstream %q: allocationSafetyMargin must be lower than ttl", name)
}
if upstream.Fetch.RequestInterval < 0 || upstream.Fetch.MaxInFlight <= 0 || upstream.Fetch.MaxAttempts <= 0 {
return fmt.Errorf("validate upstream %q: fetch limits must be positive", name)
}
if upstream.API.URL != "" {
parsed, err := url.Parse(upstream.API.URL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("validate upstream %q: api.url is invalid", name)
}
}
if err := validateProviderAuth(name, upstream.API.Auth); err != nil {
return err
}
if err := validateProxyAuth(name, upstream.ProxyAuth); err != nil {
return err
}
if len(upstream.Exposure) == 0 {
return fmt.Errorf("validate upstream %q: exposure is required", name)
}
for _, exposure := range upstream.Exposure {
if exposure != "gateway" && exposure != "extract" {
return fmt.Errorf("validate upstream %q: unsupported exposure %q", name, exposure)
}
}
return nil
}
func validateProviderAuth(upstream string, auth ProviderAuth) error {
switch auth.Type {
case "", "none":
return nil
case "basic":
if auth.Username == "" || (auth.Password == "" && auth.PasswordFile == "") {
return fmt.Errorf("validate upstream %q: basic api.auth requires username and password", upstream)
}
case "bearer":
if auth.Token == "" && auth.TokenFile == "" {
return fmt.Errorf("validate upstream %q: bearer api.auth requires token", upstream)
}
case "apiKey":
if auth.Location != "header" && auth.Location != "query" {
return fmt.Errorf("validate upstream %q: apiKey location must be header or query", upstream)
}
if auth.Name == "" || (auth.Value == "" && auth.ValueFile == "") {
return fmt.Errorf("validate upstream %q: apiKey requires name and value", upstream)
}
default:
return fmt.Errorf("validate upstream %q: unsupported api.auth type %q", upstream, auth.Type)
}
return nil
}
func validateProxyAuth(upstream string, auth ProxyAuth) error {
switch auth.Type {
case "response", "ipWhitelist":
return nil
case "static":
if auth.Username == "" || (auth.Password == "" && auth.PasswordFile == "") {
return fmt.Errorf("validate upstream %q: static proxyAuth requires username and password", upstream)
}
case "":
return fmt.Errorf("validate upstream %q: proxyAuth.type is required", upstream)
default:
return fmt.Errorf("validate upstream %q: unsupported proxyAuth type %q", upstream, auth.Type)
}
return nil
}
func isPublicHost(host string) bool {
host = strings.Trim(host, "[]")
if host == "localhost" {
return false
}
ip := net.ParseIP(host)
if ip == nil {
return true
}
return !ip.IsLoopback()
}

View File

@ -0,0 +1,149 @@
package extraction
import (
"context"
"errors"
"sort"
"sync"
"time"
)
type Fulfillment string
const (
Partial Fulfillment = "partial"
AllOrNothing Fulfillment = "allOrNothing"
)
type State string
const (
Available State = "AVAILABLE"
Extracted State = "EXTRACTED"
)
var ErrInsufficientProxies = errors.New("insufficient proxies")
type Candidate struct {
ID string
Protocol string
Region string
Carrier string
Upstream string
URL string
State State
ExpiresAt time.Time
LastCheckedAt time.Time
}
type Command struct {
Requested int
Fulfillment Fulfillment
Now time.Time
MinRemainingTTL time.Duration
MaxHealthCheckAge time.Duration
ReserveForGateway int
Protocols []string
Regions []string
Carriers []string
Upstreams []string
}
type Record struct {
ProxyID string
ClientID string
SourceIP string
RequestID string
Upstream string
ExtractedAt time.Time
ExpiresAt time.Time
}
type Result struct {
Requested int
Returned int
Items []Candidate
}
type Store interface {
Extract(context.Context, Command) (Result, error)
}
type MemoryStore struct {
mu sync.Mutex
candidates map[string]Candidate
}
func NewMemoryStore(candidates []Candidate) *MemoryStore {
items := make(map[string]Candidate, len(candidates))
for _, candidate := range candidates {
items[candidate.ID] = candidate
}
return &MemoryStore{candidates: items}
}
func (s *MemoryStore) Extract(_ context.Context, command Command) (Result, error) {
s.mu.Lock()
defer s.mu.Unlock()
result := Result{Requested: command.Requested}
if command.Requested <= 0 {
return result, nil
}
eligible := make([]Candidate, 0, len(s.candidates))
for _, candidate := range s.candidates {
if eligibleForExtraction(candidate, command) {
eligible = append(eligible, candidate)
}
}
sort.Slice(eligible, func(i, j int) bool {
return eligible[i].ExpiresAt.After(eligible[j].ExpiresAt)
})
available := len(eligible) - command.ReserveForGateway
if available < 0 {
available = 0
}
if command.Fulfillment == AllOrNothing && available < command.Requested {
return result, ErrInsufficientProxies
}
count := command.Requested
if count > available {
count = available
}
for i := 0; i < count; i++ {
candidate := eligible[i]
candidate.State = Extracted
s.candidates[candidate.ID] = candidate
result.Items = append(result.Items, candidate)
}
result.Returned = len(result.Items)
return result, nil
}
func eligibleForExtraction(candidate Candidate, command Command) bool {
if candidate.State != Available {
return false
}
if !candidate.ExpiresAt.IsZero() && candidate.ExpiresAt.Sub(command.Now) < command.MinRemainingTTL {
return false
}
if command.MaxHealthCheckAge > 0 && command.Now.Sub(candidate.LastCheckedAt) > command.MaxHealthCheckAge {
return false
}
return matches(command.Protocols, candidate.Protocol) &&
matches(command.Regions, candidate.Region) &&
matches(command.Carriers, candidate.Carrier) &&
matches(command.Upstreams, candidate.Upstream)
}
func matches(allowed []string, value string) bool {
if len(allowed) == 0 {
return true
}
for _, candidate := range allowed {
if candidate == value {
return true
}
}
return false
}

View File

@ -0,0 +1,83 @@
package extraction
import (
"context"
"sync"
"testing"
"time"
)
func TestMemoryStoreNeverExtractsProxyTwice(t *testing.T) {
now := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC)
store := NewMemoryStore([]Candidate{
{ID: "p1", State: Available, ExpiresAt: now.Add(time.Minute), LastCheckedAt: now},
})
var wg sync.WaitGroup
results := make(chan string, 1000)
for range 1000 {
wg.Add(1)
go func() {
defer wg.Done()
result, err := store.Extract(context.Background(), Command{
Requested: 1,
Fulfillment: Partial,
Now: now,
MinRemainingTTL: 30 * time.Second,
MaxHealthCheckAge: 10 * time.Second,
})
if err != nil {
t.Errorf("Extract(): %v", err)
return
}
for _, item := range result.Items {
results <- item.ID
}
}()
}
wg.Wait()
close(results)
count := 0
for id := range results {
if id != "p1" {
t.Fatalf("unexpected proxy %q", id)
}
count++
}
if count != 1 {
t.Fatalf("proxy extracted %d times, want exactly once", count)
}
}
func TestAllOrNothingDoesNotConsumePartialInventory(t *testing.T) {
now := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC)
store := NewMemoryStore([]Candidate{
{ID: "p1", State: Available, ExpiresAt: now.Add(time.Minute), LastCheckedAt: now},
})
result, err := store.Extract(context.Background(), Command{
Requested: 2,
Fulfillment: AllOrNothing,
Now: now,
MinRemainingTTL: 30 * time.Second,
MaxHealthCheckAge: 10 * time.Second,
})
if err != ErrInsufficientProxies {
t.Fatalf("Extract() error = %v, want ErrInsufficientProxies", err)
}
if len(result.Items) != 0 {
t.Fatalf("Extract() returned %d items, want 0", len(result.Items))
}
partial, err := store.Extract(context.Background(), Command{
Requested: 1,
Fulfillment: Partial,
Now: now,
MinRemainingTTL: 30 * time.Second,
MaxHealthCheckAge: 10 * time.Second,
})
if err != nil || len(partial.Items) != 1 {
t.Fatalf("inventory was consumed by failed all-or-nothing: result=%+v err=%v", partial, err)
}
}

View File

@ -0,0 +1,134 @@
package proxy
import (
"errors"
"sync/atomic"
)
const counterMask = uint64(1<<32 - 1)
var (
ErrReservationCommitted = errors.New("reservation is already committed")
ErrReservationFinished = errors.New("reservation is already finished")
)
type Capacity struct {
max atomic.Uint32
counters atomic.Uint64
}
func NewCapacity(max int64) *Capacity {
capacity := &Capacity{}
if max < 0 || max > int64(counterMask) {
max = 0
}
capacity.max.Store(uint32(max))
return capacity
}
func (c *Capacity) SetMax(max int64) bool {
if max < 0 || max > int64(counterMask) {
return false
}
c.max.Store(uint32(max))
return true
}
func (c *Capacity) Max() int64 { return int64(c.max.Load()) }
func (c *Capacity) Reserve() (*Reservation, bool) {
for {
current := c.counters.Load()
active, reserved := unpack(current)
if active+reserved >= c.max.Load() {
return nil, false
}
next := pack(active, reserved+1)
if c.counters.CompareAndSwap(current, next) {
return &Reservation{capacity: c}, true
}
}
}
func (c *Capacity) Active() int64 {
active, _ := unpack(c.counters.Load())
return int64(active)
}
func (c *Capacity) Reserved() int64 {
_, reserved := unpack(c.counters.Load())
return int64(reserved)
}
func (c *Capacity) commit() {
for {
current := c.counters.Load()
active, reserved := unpack(current)
if reserved == 0 {
return
}
if c.counters.CompareAndSwap(current, pack(active+1, reserved-1)) {
return
}
}
}
func (c *Capacity) cancel() {
for {
current := c.counters.Load()
active, reserved := unpack(current)
if reserved == 0 || c.counters.CompareAndSwap(current, pack(active, reserved-1)) {
return
}
}
}
func (c *Capacity) release() {
for {
current := c.counters.Load()
active, reserved := unpack(current)
if active == 0 || c.counters.CompareAndSwap(current, pack(active-1, reserved)) {
return
}
}
}
func pack(active, reserved uint32) uint64 {
return uint64(reserved)<<32 | uint64(active)
}
func unpack(value uint64) (active, reserved uint32) {
return uint32(value & counterMask), uint32(value >> 32)
}
type Reservation struct {
capacity *Capacity
state atomic.Uint32
}
func (r *Reservation) Commit() error {
if !r.state.CompareAndSwap(0, 1) {
if r.state.Load() == 1 {
return ErrReservationCommitted
}
return ErrReservationFinished
}
r.capacity.commit()
return nil
}
func (r *Reservation) Cancel() error {
if !r.state.CompareAndSwap(0, 2) {
return ErrReservationFinished
}
r.capacity.cancel()
return nil
}
func (r *Reservation) Release() error {
if !r.state.CompareAndSwap(1, 2) {
return ErrReservationFinished
}
r.capacity.release()
return nil
}

View File

@ -0,0 +1,78 @@
package proxy
import (
"fmt"
"net"
"strconv"
"strings"
"time"
)
type Scheme string
const (
SchemeHTTP Scheme = "http"
SchemeHTTPS Scheme = "https"
SchemeSOCKS5 Scheme = "socks5"
)
type Proxy struct {
ID string
Scheme Scheme
Host string
Port uint16
Username string
CredentialVersion string
SecretRef string
SourceUpstream string
CreatedAt time.Time
ExpiresAt *time.Time
LastCheckedAt *time.Time
LastSuccessAt *time.Time
Latency time.Duration
MaxConcurrency int64
State State
Tags map[string]string
}
func (p Proxy) UniqueKey() string {
host := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(p.Host)), ".")
return strings.Join([]string{
string(p.Scheme),
host,
strconv.FormatUint(uint64(p.Port), 10),
p.Username,
p.CredentialVersion,
}, "|")
}
func (p Proxy) Address() string {
return net.JoinHostPort(p.Host, strconv.FormatUint(uint64(p.Port), 10))
}
func (p *Proxy) Transition(next State) error {
if p == nil {
return fmt.Errorf("transition proxy: nil proxy")
}
if !CanTransition(p.State, next) {
return fmt.Errorf("transition proxy: %s -> %s is not allowed", p.State, next)
}
p.State = next
return nil
}
func EffectiveExpiry(now time.Time, expiresAt *time.Time, responseTTL, configuredTTL time.Duration) *time.Time {
if expiresAt != nil {
value := expiresAt.UTC()
return &value
}
if responseTTL > 0 {
value := now.UTC().Add(responseTTL)
return &value
}
if configuredTTL > 0 {
value := now.UTC().Add(configuredTTL)
return &value
}
return nil
}

View File

@ -0,0 +1,129 @@
package proxy
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestUniqueKeyIncludesCredentialVersionButNotPassword(t *testing.T) {
p := Proxy{
Scheme: SchemeHTTP,
Host: "EXAMPLE.COM",
Port: 8080,
Username: "alice",
CredentialVersion: "v2",
SecretRef: "secret-password",
}
got := p.UniqueKey()
want := "http|example.com|8080|alice|v2"
if got != want {
t.Fatalf("UniqueKey() = %q, want %q", got, want)
}
if contains(got, p.SecretRef) {
t.Fatal("unique key leaked the proxy password reference")
}
}
func TestEffectiveExpiryPrecedence(t *testing.T) {
now := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC)
explicit := now.Add(90 * time.Second)
tests := []struct {
name string
expiresAt *time.Time
response time.Duration
configured time.Duration
want *time.Time
}{
{name: "explicit timestamp", expiresAt: &explicit, response: 2 * time.Minute, configured: 3 * time.Minute, want: &explicit},
{name: "response ttl", response: 2 * time.Minute, configured: 3 * time.Minute, want: timePtr(now.Add(2 * time.Minute))},
{name: "configured ttl", configured: 3 * time.Minute, want: timePtr(now.Add(3 * time.Minute))},
{name: "non expiring"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := EffectiveExpiry(now, tt.expiresAt, tt.response, tt.configured)
if !equalTimePtr(got, tt.want) {
t.Fatalf("EffectiveExpiry() = %v, want %v", got, tt.want)
}
})
}
}
func TestStateMachineRejectsIllegalTransition(t *testing.T) {
p := Proxy{State: StateFetched}
if err := p.Transition(StateChecking); err != nil {
t.Fatalf("FETCHED -> CHECKING: %v", err)
}
if err := p.Transition(StateAvailable); err != nil {
t.Fatalf("CHECKING -> AVAILABLE: %v", err)
}
if err := p.Transition(StateFetched); err == nil {
t.Fatal("AVAILABLE -> FETCHED must be rejected")
}
}
func TestCapacityNeverOversubscribes(t *testing.T) {
capacity := NewCapacity(8)
var acquired atomic.Int64
var peak atomic.Int64
var wg sync.WaitGroup
for range 1000 {
wg.Add(1)
go func() {
defer wg.Done()
reservation, ok := capacity.Reserve()
if !ok {
return
}
active := acquired.Add(1)
for {
old := peak.Load()
if active <= old || peak.CompareAndSwap(old, active) {
break
}
}
if err := reservation.Commit(); err != nil {
t.Errorf("Commit(): %v", err)
}
acquired.Add(-1)
if err := reservation.Release(); err != nil {
t.Errorf("Release(): %v", err)
}
}()
}
wg.Wait()
if peak.Load() > 8 {
t.Fatalf("peak reservations = %d, exceeds 8", peak.Load())
}
if got := capacity.Active(); got != 0 {
t.Fatalf("active = %d, want 0", got)
}
if got := capacity.Reserved(); got != 0 {
t.Fatalf("reserved = %d, want 0", got)
}
}
func contains(s, part string) bool {
for i := 0; i+len(part) <= len(s); i++ {
if s[i:i+len(part)] == part {
return true
}
}
return false
}
func timePtr(value time.Time) *time.Time { return &value }
func equalTimePtr(a, b *time.Time) bool {
if a == nil || b == nil {
return a == b
}
return a.Equal(*b)
}

View File

@ -0,0 +1,40 @@
package proxy
type State string
const (
StateFetched State = "FETCHED"
StateChecking State = "CHECKING"
StateAvailable State = "AVAILABLE"
StateSuspect State = "SUSPECT"
StateDraining State = "DRAINING"
StateUnhealthy State = "UNHEALTHY"
StateExtracted State = "EXTRACTED"
StateExpired State = "EXPIRED"
StateRemoved State = "REMOVED"
)
var transitions = map[State]map[State]struct{}{
StateFetched: set(StateChecking, StateExpired, StateRemoved),
StateChecking: set(StateAvailable, StateUnhealthy, StateExpired, StateRemoved),
StateAvailable: set(StateSuspect, StateDraining, StateExtracted, StateExpired),
StateSuspect: set(StateAvailable, StateUnhealthy, StateDraining, StateExpired),
StateDraining: set(StateExpired, StateUnhealthy, StateRemoved),
StateUnhealthy: set(StateChecking, StateRemoved, StateExpired),
StateExtracted: set(StateExpired, StateRemoved),
StateExpired: set(StateRemoved),
StateRemoved: {},
}
func CanTransition(current, next State) bool {
_, ok := transitions[current][next]
return ok
}
func set(states ...State) map[State]struct{} {
result := make(map[State]struct{}, len(states))
for _, state := range states {
result[state] = struct{}{}
}
return result
}

View File

@ -0,0 +1,62 @@
package routing
import (
"sync"
"testing"
)
func TestRuleSetUsesFirstMatchingRule(t *testing.T) {
rules, err := Compile([]Rule{
{Name: "specific", Match: Match{HostRegex: `(^|\.)jd\.com$`}, Upstreams: []string{"jd"}},
{Name: "default", Match: Match{HostRegex: `.*`}, Action: ActionReject},
})
if err != nil {
t.Fatalf("Compile(): %v", err)
}
got, ok := rules.Match(Request{Host: "api.jd.com", Method: "GET", Path: "/"})
if !ok || got.Name != "specific" {
t.Fatalf("Match() = %q, %v; want specific, true", got.Name, ok)
}
}
func TestSequentialSwitchesOnceAtThreshold(t *testing.T) {
sequence, err := NewSequential([]string{"a", "b", "c"}, 5)
if err != nil {
t.Fatalf("NewSequential(): %v", err)
}
for range 4 {
sequence.ObserveEmpty("a")
}
if got := sequence.Current(); got != "a" {
t.Fatalf("Current() = %q before threshold, want a", got)
}
var wg sync.WaitGroup
for range 100 {
wg.Add(1)
go func() {
defer wg.Done()
sequence.ObserveEmpty("a")
}()
}
wg.Wait()
if got := sequence.Current(); got != "b" {
t.Fatalf("Current() = %q after concurrent threshold, want b", got)
}
}
func TestSequentialValidFetchResetsEmptyCount(t *testing.T) {
sequence, _ := NewSequential([]string{"a", "b"}, 5)
for range 4 {
sequence.ObserveEmpty("a")
}
sequence.ObserveValid("a")
for range 4 {
sequence.ObserveEmpty("a")
}
if got := sequence.Current(); got != "a" {
t.Fatalf("Current() = %q, want a after reset", got)
}
}

View File

@ -0,0 +1,118 @@
package routing
import (
"fmt"
"regexp"
"slices"
"strings"
)
type Action string
const (
ActionProxy Action = "proxy"
ActionDirect Action = "direct"
ActionReject Action = "reject"
)
type Match struct {
HostRegex string
Methods []string
PathRegex string
Headers map[string]string
}
type Rule struct {
Name string
Match Match
Upstreams []string
Action Action
}
type Request struct {
Host string
Method string
Path string
Headers map[string]string
}
type compiledRule struct {
rule Rule
host *regexp.Regexp
path *regexp.Regexp
}
type RuleSet struct {
rules []compiledRule
}
func Compile(rules []Rule) (*RuleSet, error) {
compiled := make([]compiledRule, 0, len(rules))
for _, rule := range rules {
if rule.Name == "" {
return nil, fmt.Errorf("compile routing: rule name is required")
}
host, err := regexp.Compile(rule.Match.HostRegex)
if err != nil {
return nil, fmt.Errorf("compile routing %q host: %w", rule.Name, err)
}
var path *regexp.Regexp
if rule.Match.PathRegex != "" {
path, err = regexp.Compile(rule.Match.PathRegex)
if err != nil {
return nil, fmt.Errorf("compile routing %q path: %w", rule.Name, err)
}
}
if rule.Action == "" && len(rule.Upstreams) > 0 {
rule.Action = ActionProxy
}
compiled = append(compiled, compiledRule{rule: rule, host: host, path: path})
}
return &RuleSet{rules: compiled}, nil
}
func (r *RuleSet) Match(request Request) (Rule, bool) {
if r == nil {
return Rule{}, false
}
host := strings.ToLower(strings.TrimSuffix(request.Host, "."))
method := strings.ToUpper(request.Method)
for _, candidate := range r.rules {
if !candidate.host.MatchString(host) {
continue
}
if len(candidate.rule.Match.Methods) > 0 && !containsFold(candidate.rule.Match.Methods, method) {
continue
}
if candidate.path != nil && !candidate.path.MatchString(request.Path) {
continue
}
if !headersMatch(candidate.rule.Match.Headers, request.Headers) {
continue
}
return candidate.rule, true
}
return Rule{}, false
}
func containsFold(values []string, target string) bool {
return slices.ContainsFunc(values, func(value string) bool {
return strings.EqualFold(value, target)
})
}
func headersMatch(expected, actual map[string]string) bool {
for name, value := range expected {
matched := false
for actualName, actualValue := range actual {
if strings.EqualFold(name, actualName) && actualValue == value {
matched = true
break
}
}
if !matched {
return false
}
}
return true
}

View File

@ -0,0 +1,61 @@
package routing
import (
"fmt"
"sync"
)
type Sequential struct {
mu sync.RWMutex
upstreams []string
threshold int
current int
empty map[string]int
}
func NewSequential(upstreams []string, threshold int) (*Sequential, error) {
if len(upstreams) == 0 {
return nil, fmt.Errorf("sequential strategy requires at least one upstream")
}
if threshold <= 0 {
return nil, fmt.Errorf("sequential threshold must be greater than zero")
}
return &Sequential{
upstreams: append([]string(nil), upstreams...),
threshold: threshold,
empty: make(map[string]int, len(upstreams)),
}, nil
}
func (s *Sequential) Current() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.upstreams[s.current]
}
func (s *Sequential) ObserveEmpty(upstream string) bool {
s.mu.Lock()
defer s.mu.Unlock()
s.empty[upstream]++
if s.upstreams[s.current] != upstream || s.empty[upstream] < s.threshold {
return false
}
if s.current+1 >= len(s.upstreams) {
return false
}
s.current++
return true
}
func (s *Sequential) ObserveValid(upstream string) {
s.mu.Lock()
defer s.mu.Unlock()
s.empty[upstream] = 0
}
func (s *Sequential) EmptyCount(upstream string) int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.empty[upstream]
}

View File

@ -0,0 +1,23 @@
package upstream
type FetchClass string
const (
FetchValid FetchClass = "valid"
FetchEmpty FetchClass = "empty"
FetchDuplicateOnly FetchClass = "duplicate_only"
FetchError FetchClass = "error"
)
func ClassifyFetchResult(callErr, parseErr error, validCount, newCount int) FetchClass {
if callErr != nil || parseErr != nil {
return FetchError
}
if validCount <= 0 {
return FetchEmpty
}
if newCount <= 0 {
return FetchDuplicateOnly
}
return FetchValid
}

View File

@ -0,0 +1,35 @@
package upstream
import "testing"
func TestClassifyFetchResult(t *testing.T) {
tests := []struct {
name string
callErr error
parseErr error
valid int
newCount int
want FetchClass
}{
{name: "network error", callErr: errFixture, want: FetchError},
{name: "parse error", parseErr: errFixture, want: FetchError},
{name: "empty response", want: FetchEmpty},
{name: "duplicates are not empty", valid: 3, want: FetchDuplicateOnly},
{name: "new proxy", valid: 3, newCount: 1, want: FetchValid},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ClassifyFetchResult(tt.callErr, tt.parseErr, tt.valid, tt.newCount)
if got != tt.want {
t.Fatalf("ClassifyFetchResult() = %q, want %q", got, tt.want)
}
})
}
}
type fixtureError string
func (e fixtureError) Error() string { return string(e) }
const errFixture = fixtureError("fixture")

View File

@ -0,0 +1,109 @@
package dispatch
import (
"errors"
"sync/atomic"
"time"
proxyDomain "github.com/proxy-pool/proxy-pool/internal/domain/proxy"
"github.com/proxy-pool/proxy-pool/internal/gateway/snapshot"
)
var ErrNoCandidate = errors.New("no local proxy candidate is available")
type Request struct {
Now time.Time
Scheme proxyDomain.Scheme
Upstreams []string
RequiredTags map[string]string
Exclude map[string]struct{}
SafetyMargin time.Duration
}
type Lease struct {
Proxy proxyDomain.Proxy
Epoch uint64
Version uint64
reserved *proxyDomain.Reservation
}
func (l *Lease) Commit() error { return l.reserved.Commit() }
func (l *Lease) Cancel() error { return l.reserved.Cancel() }
func (l *Lease) Release() error { return l.reserved.Release() }
type Dispatcher struct {
store *snapshot.Store
cursor atomic.Uint64
}
func New(store *snapshot.Store) *Dispatcher {
return &Dispatcher{store: store}
}
func (d *Dispatcher) Acquire(request Request) (*Lease, error) {
if d == nil || d.store == nil {
return nil, ErrNoCandidate
}
view := d.store.Current()
if view == nil || len(view.Entries) == 0 {
return nil, ErrNoCandidate
}
if request.Now.IsZero() {
request.Now = time.Now().UTC()
}
start := int((d.cursor.Add(1) - 1) % uint64(len(view.Entries)))
for offset := 0; offset < len(view.Entries); offset++ {
entry := view.Entries[(start+offset)%len(view.Entries)]
if !eligible(entry.Proxy, request) {
continue
}
reservation, ok := entry.Runtime.Reserve()
if !ok {
continue
}
return &Lease{
Proxy: entry.Proxy,
Epoch: view.Epoch,
Version: view.Version,
reserved: reservation,
}, nil
}
return nil, ErrNoCandidate
}
func eligible(candidate proxyDomain.Proxy, request Request) bool {
if candidate.State != proxyDomain.StateAvailable {
return false
}
if request.Scheme != "" && candidate.Scheme != request.Scheme {
return false
}
if _, excluded := request.Exclude[candidate.ID]; excluded {
return false
}
if candidate.ExpiresAt != nil && !candidate.ExpiresAt.After(request.Now.Add(request.SafetyMargin)) {
return false
}
if !contains(request.Upstreams, candidate.SourceUpstream) {
return false
}
for key, value := range request.RequiredTags {
if candidate.Tags[key] != value {
return false
}
}
return true
}
func contains(allowed []string, value string) bool {
if len(allowed) == 0 {
return true
}
for _, candidate := range allowed {
if candidate == value {
return true
}
}
return false
}

View File

@ -0,0 +1,90 @@
package dispatch
import (
"errors"
"sync"
"testing"
"time"
proxyDomain "github.com/proxy-pool/proxy-pool/internal/domain/proxy"
"github.com/proxy-pool/proxy-pool/internal/gateway/snapshot"
)
func TestAcquireFiltersAndReservesLocalCapacity(t *testing.T) {
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
expiresSoon := now.Add(5 * time.Second)
expiresLater := now.Add(time.Minute)
store := snapshot.NewStore("cluster-a", "worker-a")
proxies := []proxyDomain.Proxy{
{ID: "wrong-upstream", Scheme: proxyDomain.SchemeHTTP, SourceUpstream: "b", State: proxyDomain.StateAvailable, MaxConcurrency: 1, ExpiresAt: &expiresLater},
{ID: "expiring", Scheme: proxyDomain.SchemeHTTP, SourceUpstream: "a", State: proxyDomain.StateAvailable, MaxConcurrency: 1, ExpiresAt: &expiresSoon},
{ID: "selected", Scheme: proxyDomain.SchemeHTTP, SourceUpstream: "a", State: proxyDomain.StateAvailable, MaxConcurrency: 1, ExpiresAt: &expiresLater, Tags: map[string]string{"region": "cn-east"}},
}
envelope := snapshot.Envelope{ClusterID: "cluster-a", WorkerID: "worker-a", Epoch: 1, Version: 1, Full: true, Proxies: proxies}
envelope.Checksum = snapshot.Checksum(proxies)
if err := store.Apply(envelope); err != nil {
t.Fatalf("Apply(): %v", err)
}
dispatcher := New(store)
lease, err := dispatcher.Acquire(Request{
Now: now,
Scheme: proxyDomain.SchemeHTTP,
Upstreams: []string{"a"},
RequiredTags: map[string]string{"region": "cn-east"},
SafetyMargin: 10 * time.Second,
})
if err != nil {
t.Fatalf("Acquire(): %v", err)
}
if lease.Proxy.ID != "selected" {
t.Fatalf("selected proxy = %q, want selected", lease.Proxy.ID)
}
if err := lease.Commit(); err != nil {
t.Fatalf("Commit(): %v", err)
}
if err := lease.Release(); err != nil {
t.Fatalf("Release(): %v", err)
}
}
func TestAcquireNeverOversubscribesSnapshotProxy(t *testing.T) {
store := snapshot.NewStore("cluster-a", "worker-a")
proxies := []proxyDomain.Proxy{{ID: "p1", Scheme: proxyDomain.SchemeHTTP, State: proxyDomain.StateAvailable, MaxConcurrency: 8}}
envelope := snapshot.Envelope{ClusterID: "cluster-a", WorkerID: "worker-a", Epoch: 1, Version: 1, Full: true, Proxies: proxies}
envelope.Checksum = snapshot.Checksum(proxies)
if err := store.Apply(envelope); err != nil {
t.Fatalf("Apply(): %v", err)
}
dispatcher := New(store)
var wg sync.WaitGroup
leases := make(chan *Lease, 1000)
for range 1000 {
wg.Add(1)
go func() {
defer wg.Done()
lease, err := dispatcher.Acquire(Request{Now: time.Now(), Scheme: proxyDomain.SchemeHTTP})
if err == nil {
leases <- lease
return
}
if !errors.Is(err, ErrNoCandidate) {
t.Errorf("Acquire(): %v", err)
}
}()
}
wg.Wait()
close(leases)
count := 0
for lease := range leases {
count++
if err := lease.Cancel(); err != nil {
t.Errorf("Cancel(): %v", err)
}
}
if count != 8 {
t.Fatalf("reserved = %d, want 8", count)
}
}

View File

@ -0,0 +1,149 @@
package snapshot
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"sync"
"sync/atomic"
proxyDomain "github.com/proxy-pool/proxy-pool/internal/domain/proxy"
)
var (
ErrWrongTarget = errors.New("snapshot targets another cluster or worker")
ErrResyncRequired = errors.New("snapshot sequence requires a full resync")
ErrChecksumMismatch = errors.New("snapshot checksum mismatch")
)
type Envelope struct {
ClusterID string
WorkerID string
Epoch uint64
Version uint64
Full bool
Checksum string
Proxies []proxyDomain.Proxy
}
type Entry struct {
Proxy proxyDomain.Proxy
Runtime *proxyDomain.Capacity
}
type View struct {
ClusterID string
WorkerID string
Epoch uint64
Version uint64
Checksum string
Entries []Entry
}
type Store struct {
clusterID string
workerID string
current atomic.Pointer[View]
mu sync.Mutex
runtimes map[string]*proxyDomain.Capacity
}
func NewStore(clusterID, workerID string) *Store {
return &Store{
clusterID: clusterID,
workerID: workerID,
runtimes: make(map[string]*proxyDomain.Capacity),
}
}
func (s *Store) Current() *View {
if s == nil {
return nil
}
return s.current.Load()
}
func (s *Store) Apply(envelope Envelope) error {
if s == nil {
return fmt.Errorf("apply snapshot: nil store")
}
if envelope.ClusterID != s.clusterID || envelope.WorkerID != s.workerID {
return ErrWrongTarget
}
if !envelope.Full || envelope.Epoch == 0 || envelope.Version == 0 {
return ErrResyncRequired
}
if envelope.Checksum != Checksum(envelope.Proxies) {
return ErrChecksumMismatch
}
s.mu.Lock()
defer s.mu.Unlock()
current := s.current.Load()
if current != nil {
switch {
case envelope.Epoch < current.Epoch:
return ErrResyncRequired
case envelope.Epoch == current.Epoch && envelope.Version != current.Version+1:
return ErrResyncRequired
case envelope.Epoch > current.Epoch && envelope.Version != 1:
return ErrResyncRequired
}
}
proxies := cloneAndSort(envelope.Proxies)
entries := make([]Entry, 0, len(proxies))
for _, descriptor := range proxies {
runtime := s.runtimes[descriptor.ID]
if runtime == nil {
runtime = proxyDomain.NewCapacity(descriptor.MaxConcurrency)
s.runtimes[descriptor.ID] = runtime
} else {
runtime.SetMax(descriptor.MaxConcurrency)
}
entries = append(entries, Entry{Proxy: descriptor, Runtime: runtime})
}
next := &View{
ClusterID: envelope.ClusterID,
WorkerID: envelope.WorkerID,
Epoch: envelope.Epoch,
Version: envelope.Version,
Checksum: envelope.Checksum,
Entries: entries,
}
s.current.Store(next)
return nil
}
func Checksum(proxies []proxyDomain.Proxy) string {
canonical := cloneAndSort(proxies)
encoded, err := json.Marshal(canonical)
if err != nil {
panic(fmt.Sprintf("encode snapshot checksum: %v", err))
}
digest := sha256.Sum256(encoded)
return hex.EncodeToString(digest[:])
}
func cloneAndSort(source []proxyDomain.Proxy) []proxyDomain.Proxy {
cloned := make([]proxyDomain.Proxy, len(source))
for index, descriptor := range source {
cloned[index] = descriptor
if descriptor.Tags != nil {
cloned[index].Tags = make(map[string]string, len(descriptor.Tags))
for key, value := range descriptor.Tags {
cloned[index].Tags[key] = value
}
}
}
sort.Slice(cloned, func(i, j int) bool {
return cloned[i].ID < cloned[j].ID
})
return cloned
}

View File

@ -0,0 +1,80 @@
package snapshot
import (
"errors"
"testing"
proxyDomain "github.com/proxy-pool/proxy-pool/internal/domain/proxy"
)
func TestStoreAppliesCompleteSnapshotsInOrder(t *testing.T) {
store := NewStore("cluster-a", "worker-a")
first := Envelope{
ClusterID: "cluster-a",
WorkerID: "worker-a",
Epoch: 1,
Version: 1,
Full: true,
Proxies: []proxyDomain.Proxy{{
ID: "p1",
Scheme: proxyDomain.SchemeHTTP,
Host: "127.0.0.1",
Port: 18080,
MaxConcurrency: 2,
State: proxyDomain.StateAvailable,
}},
}
first.Checksum = Checksum(first.Proxies)
if err := store.Apply(first); err != nil {
t.Fatalf("Apply(first): %v", err)
}
view := store.Current()
if view == nil || view.Version != 1 || len(view.Entries) != 1 {
t.Fatalf("Current() = %+v", view)
}
if view.Entries[0].Runtime == nil {
t.Fatal("snapshot entry has no local runtime capacity")
}
second := first
second.Version = 2
second.Proxies = append([]proxyDomain.Proxy(nil), first.Proxies...)
second.Proxies[0].Host = "localhost"
second.Checksum = Checksum(second.Proxies)
if err := store.Apply(second); err != nil {
t.Fatalf("Apply(second): %v", err)
}
if got := store.Current().Entries[0].Proxy.Host; got != "localhost" {
t.Fatalf("host = %q, want localhost", got)
}
}
func TestStoreRejectsWrongWorkerVersionGapAndChecksum(t *testing.T) {
store := NewStore("cluster-a", "worker-a")
base := Envelope{ClusterID: "cluster-a", WorkerID: "worker-a", Epoch: 1, Version: 1, Full: true}
base.Checksum = Checksum(base.Proxies)
if err := store.Apply(base); err != nil {
t.Fatalf("Apply(base): %v", err)
}
wrongWorker := base
wrongWorker.Version = 2
wrongWorker.WorkerID = "worker-b"
if err := store.Apply(wrongWorker); !errors.Is(err, ErrWrongTarget) {
t.Fatalf("wrong worker error = %v, want ErrWrongTarget", err)
}
gap := base
gap.Version = 3
if err := store.Apply(gap); !errors.Is(err, ErrResyncRequired) {
t.Fatalf("version gap error = %v, want ErrResyncRequired", err)
}
badChecksum := base
badChecksum.Version = 2
badChecksum.Checksum = "bad"
if err := store.Apply(badChecksum); !errors.Is(err, ErrChecksumMismatch) {
t.Fatalf("checksum error = %v, want ErrChecksumMismatch", err)
}
}

View File

@ -7,5 +7,17 @@
- 已按主题定位配置定稿、实施方案、Distribution API、认证、安全、并发、
故障语义和最终 Exclusive Extraction 修订。
- 已建立新的任务计划与事实记录,旧网页摘要不再作为需求证据。
- 尚未创建实现代码。
- 已建立需求追踪矩阵、统一领域语言、产品设计、总体架构、项目结构、ADR、
开发、配置、API、安全、测试与运维文档。
- 已提供 20 个严格校验的配置示例、Distribution/Admin OpenAPI、Controller/
Worker/Checker Protobuf 契约和 35 张 Mermaid 图。
- 已实现并测试 Proxy 状态/TTL/唯一键、打包原子容量、首条路由、Sequential
并发切换、Fetch 分类、一次性独占提取、严格配置、Snapshot 与本地 Dispatch。
- `go test ./...`、`go vet ./...`、`go build ./...` 通过。
- Protobuf 描述符编译、Compose 静态展开、Kustomize 渲染、Grafana JSON 与
配置示例校验通过。
- Windows 当前 `CGO_ENABLED=0` 且无 C 编译器race 测试由 Linux CI 承担。
- 100,000 QPS 仍是未验证设计目标;运行进程、存储适配器、完整网络转发与
代表性集群压测尚未实施,已在完成审计中明确列出。
- 已生成 `proxy-pool-docs-v1.0.zip`,包含 50 个条目SHA-256 为
`A6882B71196210CE3594A10992C2A0A73EA3A1CF31D8A570709CA999133CE104`

BIN
proxy-pool-docs-v1.0.zip Normal file

Binary file not shown.

31
scripts/verify.ps1 Normal file
View File

@ -0,0 +1,31 @@
$ErrorActionPreference = "Stop"
function Invoke-Step {
param(
[string]$Name,
[scriptblock]$Command
)
Write-Host "==> $Name"
& $Command
if ($LASTEXITCODE -ne 0) {
throw "$Name failed with exit code $LASTEXITCODE"
}
}
$unformatted = gofmt -l .
if ($unformatted) {
$unformatted | ForEach-Object { Write-Host $_ }
throw "gofmt check failed"
}
Invoke-Step "go vet" { go vet ./... }
Invoke-Step "unit tests" { go test -timeout 60s ./... }
if ((go env CGO_ENABLED) -eq "1") {
Invoke-Step "race tests" { go test -race -timeout 60s ./internal/... }
} else {
Write-Host "==> race tests skipped: CGO_ENABLED is not 1"
}
Invoke-Step "build" { go build ./... }

View File

@ -15,13 +15,15 @@
## 阶段
1. [已完成] 完整读取对话并识别覆盖关系
2. [进行中] 建立需求追踪矩阵与统一领域模型
3. [待开始] 编写总体设计、详细设计和 ADR
4. [待开始] 编写开发、配置、API、测试和运维文档
5. [待开始] 搭建 Go 模块、命令、核心包、契约和部署目录
6. [待开始] 实现核心状态机、路由、容量、提取与配置校验
7. [待开始] 执行单元测试、竞态检查、静态检查和构建
8. [待开始] 按需求矩阵逐项审计并打包交付
2. [已完成] 建立需求追踪矩阵与统一领域模型
3. [已完成] 编写总体设计、产品设计、项目结构和 ADR
4. [已完成] 编写开发、配置、API、测试、安全和运维文档
5. [已完成] 搭建 Go 模块、核心包、契约和部署拓扑目录
6. [已完成] 实现状态机、首条路由、Sequential、原子容量、独占提取、
Fetch 分类、严格配置、不可变快照和本地调度参考实现
7. [已完成] 执行单元测试、静态检查、构建和静态部署/契约验证;本机因
`CGO_ENABLED=0` 且无 C 编译器未运行 race保留给 Linux CI
8. [已完成] 按需求矩阵逐项审计并生成版本化文档包
## 串并行关系
@ -40,6 +42,7 @@
## 已知环境限制
- Docker CLI 已安装,但 Linux daemon 状态需在集成验证前再次确认。
- 当前仓库尚无提交;`对话内容.md` 和 `.gitignore` 为现有文件。
- Docker Compose 配置与 Kubernetes Kustomize 已完成静态渲染验证;未启动
目标运行拓扑。
- `cmd/proxy-*`、Provider 调度、PostgreSQL/Redis 适配器、Gateway Transport
与 Checker 运行时属于后续实施范围,见完成审计。