feat: run compose control plane with mtls
This commit is contained in:
parent
3bcc7298a2
commit
7e73b5ad1c
1
.gitignore
vendored
1
.gitignore
vendored
@ -17,6 +17,7 @@ coverage/
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
configs/local.yaml
|
configs/local.yaml
|
||||||
|
deploy/.control-plane-tls/
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|
||||||
|
|||||||
18
README.md
18
README.md
@ -128,7 +128,8 @@ flowchart LR
|
|||||||
Checker 探测和
|
Checker 探测和
|
||||||
Observation 状态归并;Provider 连续空结果的代次化自动 Sequential 切换、禁用候选过滤、
|
Observation 状态归并;Provider 连续空结果的代次化自动 Sequential 切换、禁用候选过滤、
|
||||||
末端 `stop` 的 CAS 路由停用和 Snapshot 即时刷新。
|
末端 `stop` 的 CAS 路由停用和 Snapshot 即时刷新。
|
||||||
- **部分完成**:Docker Compose/Kubernetes 运行时 mTLS Overlay。
|
- **部分完成**:Kubernetes 运行时 mTLS Overlay;Compose 已具备本地的
|
||||||
|
Controller/Gateway/Checker mTLS 运行链路。
|
||||||
- **待完成**:故障演练和代表性集群压测;现有 HTTP、CONNECT 长连接和 Extract
|
- **待完成**:故障演练和代表性集群压测;现有 HTTP、CONNECT 长连接和 Extract
|
||||||
场景只提供可复现的负载工具,不构成容量验证结论。
|
场景只提供可复现的负载工具,不构成容量验证结论。
|
||||||
|
|
||||||
@ -167,6 +168,19 @@ Redis、PostgreSQL 和 Controller fixture 脚本会使用 Docker 启动隔离依
|
|||||||
./scripts/test-controller.ps1
|
./scripts/test-controller.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
本地 Compose 还会启动 Controller、两个固定身份的 Gateway 和一个 Checker。首次启动前
|
||||||
|
生成仅用于本机的 7 天 mTLS 证书;输出目录受 `.gitignore` 保护,脚本拒绝写入非空目录:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/generate-local-controlplane-certs.ps1
|
||||||
|
docker compose -f deploy/docker-compose.yml up -d --build
|
||||||
|
docker compose -f deploy/docker-compose.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Gateway 只有取得有效 Snapshot 后才会通过 `/readyz`;Checker 在首次成功领取控制面任务
|
||||||
|
批次后才会通过 `/readyz`,空批次也代表连接和身份验证已经成功。后续任务领取失败会立即
|
||||||
|
撤销 Checker 的就绪状态。
|
||||||
|
|
||||||
PostgreSQL 与 Redis 是 Controller 的启动依赖。当前可运行的 Controller 入口如下,
|
PostgreSQL 与 Redis 是 Controller 的启动依赖。当前可运行的 Controller 入口如下,
|
||||||
将 `CONFIG_FILE` 替换为实际配置路径,并确保其中的 PostgreSQL 与 Redis 地址可从
|
将 `CONFIG_FILE` 替换为实际配置路径,并确保其中的 PostgreSQL 与 Redis 地址可从
|
||||||
进程所在网络访问:
|
进程所在网络访问:
|
||||||
@ -180,7 +194,7 @@ go run ./cmd/proxy-controller -config CONFIG_FILE
|
|||||||
改用宿主机可达的存储地址。
|
改用宿主机可达的存储地址。
|
||||||
|
|
||||||
本地配置中的 `.invalid` Provider URL 是故障演示占位,不会提供真实代理。
|
本地配置中的 `.invalid` Provider URL 是故障演示占位,不会提供真实代理。
|
||||||
Gateway 已提供启动命令;需要先启用 Controller `controlPlane` 并配置匹配的 mTLS
|
Gateway 也可独立启动;需要启用 Controller `controlPlane` 并配置匹配的 mTLS
|
||||||
证书(回环 fixture 可使用明文),再提供独立的拨号地址和 Worker 身份:
|
证书(回环 fixture 可使用明文),再提供独立的拨号地址和 Worker 身份:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@ -5,10 +5,18 @@ Grafana 与 Kubernetes。镜像会构建 `proxy-controller`、`proxy-gateway`
|
|||||||
`proxy-checker`;Controller 已装配 Redis BASIC 检查任务,Checker 可通过认证
|
`proxy-checker`;Controller 已装配 Redis BASIC 检查任务,Checker 可通过认证
|
||||||
控制面执行 HTTP/HTTPS BASIC 探测。
|
控制面执行 HTTP/HTTPS BASIC 探测。
|
||||||
|
|
||||||
默认 Compose 和 Kubernetes Base 仍将 `controlPlane.enabled` 保持为 `false`。
|
Compose 使用 `config/local.yaml` 启用 Controller、两个 Gateway 和一个 Checker 的
|
||||||
跨节点控制面必须由环境 Overlay 提供 Controller 可拨号地址、独立 Worker/Checker
|
mTLS 控制面。`generate-local-controlplane-certs.ps1` 为三个固定工作负载生成不同的
|
||||||
身份及 mTLS 证书,因此这些默认清单不能视为完整的生产发布配置。`checker.yaml`
|
SPIFFE URI 证书,私钥只写入被忽略的 `deploy/.control-plane-tls/`。运行前先执行:
|
||||||
保留为 Overlay 模板,尚未加入 Base。
|
|
||||||
|
```powershell
|
||||||
|
./scripts/generate-local-controlplane-certs.ps1
|
||||||
|
docker compose -f deploy/docker-compose.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Kubernetes Base 仍保持 `controlPlane.enabled: false`,并保留 `checker.yaml` 作为
|
||||||
|
环境 Overlay 模板。生产 Overlay 必须为每个弹性 Gateway/Checker 工作负载配置唯一
|
||||||
|
身份与证书轮换,不能复用 Compose 的固定开发证书。
|
||||||
|
|
||||||
当前可执行验证:
|
当前可执行验证:
|
||||||
|
|
||||||
|
|||||||
@ -17,12 +17,14 @@ type composeDocument struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type composeService struct {
|
type composeService struct {
|
||||||
Image string `yaml:"image"`
|
Image string `yaml:"image"`
|
||||||
Command []string `yaml:"command"`
|
Command []string `yaml:"command"`
|
||||||
Ports []string `yaml:"ports"`
|
Ports []string `yaml:"ports"`
|
||||||
Volumes []string `yaml:"volumes"`
|
Expose []string `yaml:"expose"`
|
||||||
Tmpfs []string `yaml:"tmpfs"`
|
Volumes []string `yaml:"volumes"`
|
||||||
DependsOn any `yaml:"depends_on"`
|
Tmpfs []string `yaml:"tmpfs"`
|
||||||
|
Environment map[string]string `yaml:"environment"`
|
||||||
|
DependsOn any `yaml:"depends_on"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPostgresIntegrationFixtureIsIsolatedAndEphemeral(t *testing.T) {
|
func TestPostgresIntegrationFixtureIsIsolatedAndEphemeral(t *testing.T) {
|
||||||
@ -133,6 +135,65 @@ func TestLocalGatewaysDoNotDependOnControlPlaneStorage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLocalComposeProvidesAuthenticatedControlPlaneForEveryWorker(t *testing.T) {
|
||||||
|
document := loadComposeDocument(t)
|
||||||
|
controller := document.Services["controller"]
|
||||||
|
if !slices.Contains(controller.Expose, "8443") || !slices.Contains(controller.Volumes, "./.control-plane-tls/controller:/run/proxy-pool-tls/server:ro") {
|
||||||
|
t.Fatalf("controller does not expose and mount control-plane TLS: expose=%v volumes=%v", controller.Expose, controller.Volumes)
|
||||||
|
}
|
||||||
|
for _, expected := range []struct {
|
||||||
|
name string
|
||||||
|
identity string
|
||||||
|
instance string
|
||||||
|
certificate string
|
||||||
|
}{
|
||||||
|
{name: "gateway-a", identity: "gateway-a", instance: "gateway-a-1", certificate: "gateway-a"},
|
||||||
|
{name: "gateway-b", identity: "gateway-b", instance: "gateway-b-1", certificate: "gateway-b"},
|
||||||
|
{name: "checker", identity: "checker-a", instance: "checker-a-1", certificate: "checker-a"},
|
||||||
|
} {
|
||||||
|
service, ok := document.Services[expected.name]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("docker-compose.yml has no %s", expected.name)
|
||||||
|
}
|
||||||
|
if service.Environment["PROXY_POOL_CONTROL_PLANE_ADDRESS"] != "controller:8443" ||
|
||||||
|
!slices.Contains(service.Volumes, "./.control-plane-tls/"+expected.certificate+":/run/proxy-pool-tls/client:ro") ||
|
||||||
|
!composeDependsOn(service.DependsOn, "controller") {
|
||||||
|
t.Errorf("%s control-plane wiring = env=%v volumes=%v dependsOn=%v", expected.name, service.Environment, service.Volumes, service.DependsOn)
|
||||||
|
}
|
||||||
|
if expected.name == "checker" {
|
||||||
|
if service.Environment["PROXY_POOL_CHECKER_ID"] != expected.identity ||
|
||||||
|
service.Environment["PROXY_POOL_CHECKER_INSTANCE_ID"] != expected.instance ||
|
||||||
|
service.Environment["PROXY_POOL_CHECKER_MAX_IN_FLIGHT"] != "200" || !slices.Contains(service.Expose, "9090") {
|
||||||
|
t.Errorf("checker identity or metrics wiring = env=%v expose=%v", service.Environment, service.Expose)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if service.Environment["PROXY_POOL_WORKER_ID"] != expected.identity ||
|
||||||
|
service.Environment["PROXY_POOL_INSTANCE_ID"] != expected.instance ||
|
||||||
|
service.Environment["PROXY_POOL_CLUSTER_ID"] != "compose-local" || service.Environment["PROXY_POOL_ZONE"] != "compose-local" {
|
||||||
|
t.Errorf("%s identity wiring = %v", expected.name, service.Environment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalControlPlaneCertificateGeneratorUsesDistinctSPIFFERoles(t *testing.T) {
|
||||||
|
payload, err := os.ReadFile("../scripts/generate-local-controlplane-certs.ps1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read generate-local-controlplane-certs.ps1: %v", err)
|
||||||
|
}
|
||||||
|
script := string(payload)
|
||||||
|
for _, required := range []string{
|
||||||
|
"OutputDirectory must be empty", "DNS:controller",
|
||||||
|
"spiffe://proxy-pool.local/development/worker/gateway-a",
|
||||||
|
"spiffe://proxy-pool.local/development/worker/gateway-b",
|
||||||
|
"spiffe://proxy-pool.local/development/checker/checker-a",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(script, required) {
|
||||||
|
t.Errorf("certificate generator missing %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeploymentEntrypointsExistInSource(t *testing.T) {
|
func TestDeploymentEntrypointsExistInSource(t *testing.T) {
|
||||||
dockerfile, err := os.ReadFile("docker/Dockerfile")
|
dockerfile, err := os.ReadFile("docker/Dockerfile")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -160,13 +221,13 @@ func TestDeploymentEntrypointsExistInSource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKubernetesBaseExcludesUnimplementedProcessManifests(t *testing.T) {
|
func TestKubernetesBaseLeavesControlPlaneClientsForMTLSOverlay(t *testing.T) {
|
||||||
payload, err := os.ReadFile("kubernetes/base/kustomization.yaml")
|
payload, err := os.ReadFile("kubernetes/base/kustomization.yaml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read kustomization.yaml: %v", err)
|
t.Fatalf("read kustomization.yaml: %v", err)
|
||||||
}
|
}
|
||||||
if strings.Contains(string(payload), "checker.yaml") {
|
if strings.Contains(string(payload), "checker.yaml") {
|
||||||
t.Fatal("kubernetes base includes checker.yaml before proxy-checker exists")
|
t.Fatal("kubernetes base includes checker.yaml without per-workload mTLS identity overlay")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -64,7 +64,30 @@ admin:
|
|||||||
token: "${PROXY_POOL_ADMIN_TOKEN}"
|
token: "${PROXY_POOL_ADMIN_TOKEN}"
|
||||||
|
|
||||||
controlPlane:
|
controlPlane:
|
||||||
enabled: false
|
enabled: true
|
||||||
|
listen: 0.0.0.0:8443
|
||||||
|
protocolVersion: 1
|
||||||
|
heartbeatInterval: 10s
|
||||||
|
sessionTTL: 30s
|
||||||
|
maxStaleAge: 30s
|
||||||
|
maxMessageBytes: 4194304
|
||||||
|
maxRuntimeCounters: 100000
|
||||||
|
maxConcurrentStreams: 1000
|
||||||
|
tls:
|
||||||
|
mode: mtls
|
||||||
|
certFile: /run/proxy-pool-tls/server/tls.crt
|
||||||
|
keyFile: /run/proxy-pool-tls/server/tls.key
|
||||||
|
clientCAFile: /run/proxy-pool-tls/server/ca.crt
|
||||||
|
trustDomain: proxy-pool.local
|
||||||
|
environment: development
|
||||||
|
gatewayTLS:
|
||||||
|
certFile: /run/proxy-pool-tls/client/tls.crt
|
||||||
|
keyFile: /run/proxy-pool-tls/client/tls.key
|
||||||
|
serverCAFile: /run/proxy-pool-tls/client/ca.crt
|
||||||
|
checkerTLS:
|
||||||
|
certFile: /run/proxy-pool-tls/client/tls.crt
|
||||||
|
keyFile: /run/proxy-pool-tls/client/tls.key
|
||||||
|
serverCAFile: /run/proxy-pool-tls/client/ca.crt
|
||||||
|
|
||||||
metrics:
|
metrics:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@ -15,8 +15,6 @@ x-app: &app
|
|||||||
image: proxy-pool:local
|
image: proxy-pool:local
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks: [frontend, backend]
|
networks: [frontend, backend]
|
||||||
volumes:
|
|
||||||
- ./config/local.yaml:/etc/proxy-pool/config.yaml:ro
|
|
||||||
environment: *app-environment
|
environment: *app-environment
|
||||||
stop_grace_period: 45s
|
stop_grace_period: 45s
|
||||||
|
|
||||||
@ -24,6 +22,19 @@ services:
|
|||||||
gateway-a:
|
gateway-a:
|
||||||
<<: *app
|
<<: *app
|
||||||
command: ["proxy-gateway"]
|
command: ["proxy-gateway"]
|
||||||
|
environment:
|
||||||
|
<<: *app-environment
|
||||||
|
PROXY_POOL_CONTROL_PLANE_ADDRESS: controller:8443
|
||||||
|
PROXY_POOL_CLUSTER_ID: compose-local
|
||||||
|
PROXY_POOL_WORKER_ID: gateway-a
|
||||||
|
PROXY_POOL_INSTANCE_ID: gateway-a-1
|
||||||
|
PROXY_POOL_ZONE: compose-local
|
||||||
|
volumes:
|
||||||
|
- ./config/local.yaml:/etc/proxy-pool/config.yaml:ro
|
||||||
|
- ./.control-plane-tls/gateway-a:/run/proxy-pool-tls/client:ro
|
||||||
|
depends_on:
|
||||||
|
controller:
|
||||||
|
condition: service_healthy
|
||||||
expose: ["8080", "9090"]
|
expose: ["8080", "9090"]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9090/readyz"]
|
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9090/readyz"]
|
||||||
@ -35,6 +46,19 @@ services:
|
|||||||
gateway-b:
|
gateway-b:
|
||||||
<<: *app
|
<<: *app
|
||||||
command: ["proxy-gateway"]
|
command: ["proxy-gateway"]
|
||||||
|
environment:
|
||||||
|
<<: *app-environment
|
||||||
|
PROXY_POOL_CONTROL_PLANE_ADDRESS: controller:8443
|
||||||
|
PROXY_POOL_CLUSTER_ID: compose-local
|
||||||
|
PROXY_POOL_WORKER_ID: gateway-b
|
||||||
|
PROXY_POOL_INSTANCE_ID: gateway-b-1
|
||||||
|
PROXY_POOL_ZONE: compose-local
|
||||||
|
volumes:
|
||||||
|
- ./config/local.yaml:/etc/proxy-pool/config.yaml:ro
|
||||||
|
- ./.control-plane-tls/gateway-b:/run/proxy-pool-tls/client:ro
|
||||||
|
depends_on:
|
||||||
|
controller:
|
||||||
|
condition: service_healthy
|
||||||
expose: ["8080", "9090"]
|
expose: ["8080", "9090"]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9090/readyz"]
|
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9090/readyz"]
|
||||||
@ -49,12 +73,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app-environment
|
<<: *app-environment
|
||||||
PROXY_POOL_CONFIG_FINGERPRINT_KEY: ${PROXY_POOL_CONFIG_FINGERPRINT_KEY:?set PROXY_POOL_CONFIG_FINGERPRINT_KEY}
|
PROXY_POOL_CONFIG_FINGERPRINT_KEY: ${PROXY_POOL_CONFIG_FINGERPRINT_KEY:?set PROXY_POOL_CONFIG_FINGERPRINT_KEY}
|
||||||
|
volumes:
|
||||||
|
- ./config/local.yaml:/etc/proxy-pool/config.yaml:ro
|
||||||
|
- ./.control-plane-tls/controller:/run/proxy-pool-tls/server:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
expose: ["8081", "8082", "9090"]
|
expose: ["8081", "8082", "8443", "9090"]
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8081:8081"
|
- "127.0.0.1:8081:8081"
|
||||||
- "127.0.0.1:8082:8082"
|
- "127.0.0.1:8082:8082"
|
||||||
@ -65,6 +92,29 @@ services:
|
|||||||
retries: 12
|
retries: 12
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
|
|
||||||
|
checker:
|
||||||
|
<<: *app
|
||||||
|
command: ["proxy-checker"]
|
||||||
|
environment:
|
||||||
|
<<: *app-environment
|
||||||
|
PROXY_POOL_CONTROL_PLANE_ADDRESS: controller:8443
|
||||||
|
PROXY_POOL_CHECKER_ID: checker-a
|
||||||
|
PROXY_POOL_CHECKER_INSTANCE_ID: checker-a-1
|
||||||
|
PROXY_POOL_CHECKER_MAX_IN_FLIGHT: "200"
|
||||||
|
volumes:
|
||||||
|
- ./config/local.yaml:/etc/proxy-pool/config.yaml:ro
|
||||||
|
- ./.control-plane-tls/checker-a:/run/proxy-pool-tls/client:ro
|
||||||
|
depends_on:
|
||||||
|
controller:
|
||||||
|
condition: service_healthy
|
||||||
|
expose: ["9090"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9090/readyz"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
haproxy:
|
haproxy:
|
||||||
image: haproxy:3.2-alpine
|
image: haproxy:3.2-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@ -14,6 +14,9 @@ scrape_configs:
|
|||||||
- job_name: proxy-controller
|
- job_name: proxy-controller
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: [controller:9090]
|
- targets: [controller:9090]
|
||||||
|
- job_name: proxy-checker
|
||||||
|
static_configs:
|
||||||
|
- targets: [checker:9090]
|
||||||
- job_name: haproxy
|
- job_name: haproxy
|
||||||
metrics_path: /metrics
|
metrics_path: /metrics
|
||||||
static_configs:
|
static_configs:
|
||||||
|
|||||||
@ -306,9 +306,9 @@ controlPlane:
|
|||||||
Gateway 连接 Controller 时使用独立启动参数而非 `controlPlane.listen`。至少设置
|
Gateway 连接 Controller 时使用独立启动参数而非 `controlPlane.listen`。至少设置
|
||||||
`PROXY_POOL_CONTROL_PLANE_ADDRESS`、`PROXY_POOL_CLUSTER_ID`、
|
`PROXY_POOL_CONTROL_PLANE_ADDRESS`、`PROXY_POOL_CLUSTER_ID`、
|
||||||
`PROXY_POOL_WORKER_ID`、`PROXY_POOL_INSTANCE_ID` 和 `PROXY_POOL_ZONE`,详见
|
`PROXY_POOL_WORKER_ID`、`PROXY_POOL_INSTANCE_ID` 和 `PROXY_POOL_ZONE`,详见
|
||||||
[控制面协议](../api/control-plane.md#9-gateway-启动参数)。基础 Compose/Kubernetes
|
[控制面协议](../api/control-plane.md#9-gateway-启动参数)。Compose 本地模板使用固定
|
||||||
模板保持 `controlPlane.enabled: false`,环境 Overlay 挂载 mTLS 证书并启用后才可启动
|
Worker/Checker 身份和被忽略的开发证书目录;Kubernetes Base 保持
|
||||||
Gateway。
|
`controlPlane.enabled: false`,必须由具备每工作负载唯一身份的 mTLS Overlay 启用。
|
||||||
|
|
||||||
Checker 同样使用独立的可拨号地址:`proxy-checker` 的 `-control-plane`、
|
Checker 同样使用独立的可拨号地址:`proxy-checker` 的 `-control-plane`、
|
||||||
`-checker-id`、`-instance-id` 和 `-max-in-flight` 可由对应的
|
`-checker-id`、`-instance-id` 和 `-max-in-flight` 可由对应的
|
||||||
|
|||||||
@ -50,6 +50,13 @@ $env:PROVIDER_A_TOKEN = "PROVIDER_A_TOKEN"
|
|||||||
$env:PROVIDER_B_TOKEN = "PROVIDER_B_TOKEN"
|
$env:PROVIDER_B_TOKEN = "PROVIDER_B_TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
首次启动 Compose 前,生成不入库的本地控制面证书。生成器要求目标目录为空,避免覆写
|
||||||
|
已有密钥:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\generate-local-controlplane-certs.ps1
|
||||||
|
```
|
||||||
|
|
||||||
### 2.3 静态检查
|
### 2.3 静态检查
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@ -57,6 +64,10 @@ docker compose -f deploy/docker-compose.yml config
|
|||||||
kubectl kustomize deploy/kubernetes/base > rendered.yaml
|
kubectl kustomize deploy/kubernetes/base > rendered.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Compose 启动后,Gateway 的 `/readyz` 需要先接收有效 Snapshot;Checker 的 `/readyz`
|
||||||
|
需要成功建立一次任务领取流,并会在后续领取失败时回到未就绪。两者的 `/livez` 只表示
|
||||||
|
进程仍在运行。
|
||||||
|
|
||||||
本地 Compose 的 Redis 只作为可重建短效状态 fixture,固定使用
|
本地 Compose 的 Redis 只作为可重建短效状态 fixture,固定使用
|
||||||
`--appendonly no --save ""`,且不挂载 `/data` 或命名卷。真实 Redis 8.2 契约可
|
`--appendonly no --save ""`,且不挂载 `/data` 或命名卷。真实 Redis 8.2 契约可
|
||||||
通过 `.\scripts\test-redis.ps1` 执行;脚本使用唯一命名空间并在结束时定向清理,
|
通过 `.\scripts\test-redis.ps1` 执行;脚本使用唯一命名空间并在结束时定向清理,
|
||||||
|
|||||||
@ -8,9 +8,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
|
|
||||||
@ -20,11 +24,15 @@ import (
|
|||||||
"proxy-pool/internal/config"
|
"proxy-pool/internal/config"
|
||||||
"proxy-pool/internal/controlplane/clienttransport"
|
"proxy-pool/internal/controlplane/clienttransport"
|
||||||
"proxy-pool/internal/domain/workerruntime"
|
"proxy-pool/internal/domain/workerruntime"
|
||||||
|
"proxy-pool/internal/platform/httpserver"
|
||||||
|
"proxy-pool/internal/platform/lifecycle"
|
||||||
|
platformMetrics "proxy-pool/internal/platform/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInvalidOptions = errors.New("invalid checker bootstrap options")
|
ErrInvalidOptions = errors.New("invalid checker bootstrap options")
|
||||||
ErrStartup = errors.New("checker startup failed")
|
ErrStartup = errors.New("checker startup failed")
|
||||||
|
ErrNotReady = errors.New("checker control plane is not ready")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@ -36,6 +44,8 @@ type Options struct {
|
|||||||
MaxInFlight int
|
MaxInFlight int
|
||||||
SupportedLevels []controlplanev1.CheckLevel
|
SupportedLevels []controlplanev1.CheckLevel
|
||||||
GRPCTransport credentials.TransportCredentials
|
GRPCTransport credentials.TransportCredentials
|
||||||
|
MetricsListener net.Listener
|
||||||
|
HTTP httpserver.Options
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(ctx context.Context, options Options) error {
|
func Run(ctx context.Context, options Options) error {
|
||||||
@ -49,11 +59,23 @@ func Run(ctx context.Context, options Options) error {
|
|||||||
if !configuration.ControlPlane.Enabled {
|
if !configuration.ControlPlane.Enabled {
|
||||||
return errors.Join(ErrInvalidOptions, errors.New("controlPlane must be enabled"))
|
return errors.Join(ErrInvalidOptions, errors.New("controlPlane must be enabled"))
|
||||||
}
|
}
|
||||||
|
runtime, err := newRuntime(ctx, configuration, options)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", ErrStartup, err)
|
||||||
|
}
|
||||||
|
defer runtime.Close()
|
||||||
|
return runtime.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRuntime(ctx context.Context, configuration *config.Config, options Options) (*runtime, error) {
|
||||||
|
if ctx == nil || configuration == nil {
|
||||||
|
return nil, ErrInvalidOptions
|
||||||
|
}
|
||||||
transport, err := clienttransport.New(
|
transport, err := clienttransport.New(
|
||||||
configuration.ControlPlane, options.ControlPlaneAddress, configuration.ControlPlane.CheckerTLS, options.GRPCTransport, "checkerTLS",
|
configuration.ControlPlane, options.ControlPlaneAddress, configuration.ControlPlane.CheckerTLS, options.GRPCTransport, "checkerTLS",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %w", ErrInvalidOptions, err)
|
return nil, fmt.Errorf("%w: %w", ErrInvalidOptions, err)
|
||||||
}
|
}
|
||||||
connection, err := grpc.NewClient(options.ControlPlaneAddress,
|
connection, err := grpc.NewClient(options.ControlPlaneAddress,
|
||||||
grpc.WithTransportCredentials(transport),
|
grpc.WithTransportCredentials(transport),
|
||||||
@ -63,19 +85,40 @@ func Run(ctx context.Context, options Options) error {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: dial control plane: %w", ErrStartup, err)
|
return nil, fmt.Errorf("dial control plane: %w", err)
|
||||||
}
|
}
|
||||||
defer connection.Close()
|
closeConnection := true
|
||||||
|
defer func() {
|
||||||
|
if closeConnection {
|
||||||
|
_ = connection.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
client := controlplane.NewGeneratedClient(controlplanev1.NewCheckerControlPlaneClient(connection))
|
client := controlplane.NewGeneratedClient(controlplanev1.NewCheckerControlPlaneClient(connection))
|
||||||
|
readiness := &controlPlaneReadiness{}
|
||||||
runner, err := controlplane.NewRunner(client, probe.NewExecutor(), controlplane.Options{
|
runner, err := controlplane.NewRunner(client, probe.NewExecutor(), controlplane.Options{
|
||||||
CheckerID: options.CheckerID, InstanceID: options.InstanceID, MaxInFlight: options.MaxInFlight,
|
CheckerID: options.CheckerID, InstanceID: options.InstanceID, MaxInFlight: options.MaxInFlight,
|
||||||
SupportedLevels: append([]controlplanev1.CheckLevel(nil), options.SupportedLevels...),
|
SupportedLevels: append([]controlplanev1.CheckLevel(nil), options.SupportedLevels...),
|
||||||
ReportBatchSize: options.MaxInFlight,
|
ReportBatchSize: options.MaxInFlight, OnSuccessfulPull: readiness.MarkReady, OnFailedPull: readiness.MarkUnavailable,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: build checker runner: %w", ErrStartup, err)
|
return nil, fmt.Errorf("build checker runner: %w", err)
|
||||||
}
|
}
|
||||||
return runner.Run(ctx)
|
runners := []lifecycle.Runner{runner}
|
||||||
|
if configuration.Metrics.Enabled {
|
||||||
|
metrics, metricsErr := newMetricsRuntime(ctx, configuration.Metrics, options, readiness)
|
||||||
|
if metricsErr != nil {
|
||||||
|
return nil, metricsErr
|
||||||
|
}
|
||||||
|
runners = append(runners, metrics)
|
||||||
|
} else if options.MetricsListener != nil {
|
||||||
|
return nil, ErrInvalidOptions
|
||||||
|
}
|
||||||
|
group, err := lifecycle.NewGroup(runners...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
closeConnection = false
|
||||||
|
return &runtime{connection: connection, group: group}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateOptions(ctx context.Context, options Options) error {
|
func validateOptions(ctx context.Context, options Options) error {
|
||||||
@ -88,6 +131,87 @@ func validateOptions(ctx context.Context, options Options) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type runtime struct {
|
||||||
|
connection *grpc.ClientConn
|
||||||
|
group *lifecycle.Group
|
||||||
|
}
|
||||||
|
|
||||||
|
func (runtime *runtime) Run(ctx context.Context) error {
|
||||||
|
if runtime == nil || runtime.connection == nil || runtime.group == nil || ctx == nil {
|
||||||
|
return ErrInvalidOptions
|
||||||
|
}
|
||||||
|
return runtime.group.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (runtime *runtime) Close() {
|
||||||
|
if runtime != nil && runtime.connection != nil {
|
||||||
|
_ = runtime.connection.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type controlPlaneReadiness struct {
|
||||||
|
ready atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (readiness *controlPlaneReadiness) MarkReady() {
|
||||||
|
if readiness != nil {
|
||||||
|
readiness.ready.Store(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (readiness *controlPlaneReadiness) MarkUnavailable() {
|
||||||
|
if readiness != nil {
|
||||||
|
readiness.ready.Store(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (readiness *controlPlaneReadiness) Ready(ctx context.Context) error {
|
||||||
|
if ctx == nil || readiness == nil || !readiness.ready.Load() {
|
||||||
|
return ErrNotReady
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type metricsRuntime struct {
|
||||||
|
listener net.Listener
|
||||||
|
handler http.Handler
|
||||||
|
options httpserver.Options
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMetricsRuntime(
|
||||||
|
ctx context.Context,
|
||||||
|
configuration config.Metrics,
|
||||||
|
options Options,
|
||||||
|
readiness *controlPlaneReadiness,
|
||||||
|
) (*metricsRuntime, error) {
|
||||||
|
if ctx == nil || !configuration.Enabled || readiness == nil {
|
||||||
|
return nil, ErrInvalidOptions
|
||||||
|
}
|
||||||
|
handler, err := platformMetrics.NewHandler(platformMetrics.Dependencies{
|
||||||
|
Gatherer: prometheus.DefaultGatherer, Readiness: readiness,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build checker metrics handler: %w", err)
|
||||||
|
}
|
||||||
|
listener := options.MetricsListener
|
||||||
|
if listener == nil {
|
||||||
|
listener, err = (&net.ListenConfig{}).Listen(ctx, "tcp", configuration.Listen)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listen checker metrics: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &metricsRuntime{listener: listener, handler: handler, options: options.HTTP}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (runtime *metricsRuntime) Run(ctx context.Context) error {
|
||||||
|
if runtime == nil || runtime.listener == nil || runtime.handler == nil || ctx == nil {
|
||||||
|
return ErrInvalidOptions
|
||||||
|
}
|
||||||
|
return httpserver.Serve(ctx, runtime.options, httpserver.Endpoint{
|
||||||
|
Name: "metrics", Listener: runtime.listener, Handler: runtime.handler,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func loadConfiguration(ctx context.Context, path string, resolver config.Resolver) (*config.Config, error) {
|
func loadConfiguration(ctx context.Context, path string, resolver config.Resolver) (*config.Config, error) {
|
||||||
if ctx == nil || strings.TrimSpace(path) != path || path == "" || nilInterface(resolver) {
|
if ctx == nil || strings.TrimSpace(path) != path || path == "" || nilInterface(resolver) {
|
||||||
return nil, ErrInvalidOptions
|
return nil, ErrInvalidOptions
|
||||||
|
|||||||
55
internal/checker/bootstrap/bootstrap_test.go
Normal file
55
internal/checker/bootstrap/bootstrap_test.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"proxy-pool/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestControlPlaneReadinessTransitionsAfterSuccessfulPull(t *testing.T) {
|
||||||
|
readiness := &controlPlaneReadiness{}
|
||||||
|
if err := readiness.Ready(context.Background()); !errors.Is(err, ErrNotReady) {
|
||||||
|
t.Fatalf("Ready() before pull = %v, want ErrNotReady", err)
|
||||||
|
}
|
||||||
|
readiness.MarkReady()
|
||||||
|
if err := readiness.Ready(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Ready() after successful pull = %v", err)
|
||||||
|
}
|
||||||
|
readiness.MarkUnavailable()
|
||||||
|
if err := readiness.Ready(context.Background()); !errors.Is(err, ErrNotReady) {
|
||||||
|
t.Fatalf("Ready() after failed pull = %v, want ErrNotReady", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsRuntimeUsesControlPlaneReadiness(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Listen(): %v", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
readiness := &controlPlaneReadiness{}
|
||||||
|
runtime, err := newMetricsRuntime(context.Background(), config.Metrics{Enabled: true, Listen: listener.Addr().String()}, Options{
|
||||||
|
MetricsListener: listener,
|
||||||
|
}, readiness)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newMetricsRuntime(): %v", err)
|
||||||
|
}
|
||||||
|
assertCheckerProbe(t, runtime.handler, "/livez", http.StatusOK, "live\n")
|
||||||
|
assertCheckerProbe(t, runtime.handler, "/readyz", http.StatusServiceUnavailable, "unavailable\n")
|
||||||
|
readiness.MarkReady()
|
||||||
|
assertCheckerProbe(t, runtime.handler, "/readyz", http.StatusOK, "ready\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCheckerProbe(t *testing.T, handler http.Handler, path string, wantStatus int, wantBody string) {
|
||||||
|
t.Helper()
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
if recorder.Code != wantStatus || recorder.Body.String() != wantBody {
|
||||||
|
t.Fatalf("GET %s = %d %q, want %d %q", path, recorder.Code, recorder.Body.String(), wantStatus, wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,14 +44,16 @@ type Executor interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
CheckerID string
|
CheckerID string
|
||||||
InstanceID string
|
InstanceID string
|
||||||
MaxInFlight int
|
MaxInFlight int
|
||||||
SupportedLevels []controlplanev1.CheckLevel
|
SupportedLevels []controlplanev1.CheckLevel
|
||||||
ReportBatchSize int
|
ReportBatchSize int
|
||||||
PollInterval time.Duration
|
PollInterval time.Duration
|
||||||
RetryDelay time.Duration
|
RetryDelay time.Duration
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
|
OnSuccessfulPull func()
|
||||||
|
OnFailedPull func()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runner consumes at most one server-bounded pull per RunOnce. Run adds a
|
// Runner consumes at most one server-bounded pull per RunOnce. Run adds a
|
||||||
@ -93,6 +95,9 @@ func (runner *Runner) Run(ctx context.Context) error {
|
|||||||
if err != nil && ctx.Err() != nil {
|
if err != nil && ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
if err != nil && runner.options.OnFailedPull != nil {
|
||||||
|
runner.options.OnFailedPull()
|
||||||
|
}
|
||||||
wait := runner.options.PollInterval
|
wait := runner.options.PollInterval
|
||||||
if err != nil {
|
if err != nil {
|
||||||
wait = runner.options.RetryDelay
|
wait = runner.options.RetryDelay
|
||||||
@ -118,9 +123,13 @@ func (runner *Runner) RunOnce(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tasks, err := collectTasks(stream, runner.options.MaxInFlight)
|
tasks, err := collectTasks(stream, runner.options.MaxInFlight)
|
||||||
if err != nil || len(tasks) == 0 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
runner.observeSuccessfulPull()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
observations := runner.executeTasks(ctx, tasks)
|
observations := runner.executeTasks(ctx, tasks)
|
||||||
for start := 0; start < len(observations); start += runner.options.ReportBatchSize {
|
for start := 0; start < len(observations); start += runner.options.ReportBatchSize {
|
||||||
end := start + runner.options.ReportBatchSize
|
end := start + runner.options.ReportBatchSize
|
||||||
@ -137,9 +146,16 @@ func (runner *Runner) RunOnce(ctx context.Context) error {
|
|||||||
return ErrObservationsRejected
|
return ErrObservationsRejected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
runner.observeSuccessfulPull()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (runner *Runner) observeSuccessfulPull() {
|
||||||
|
if runner != nil && runner.options.OnSuccessfulPull != nil {
|
||||||
|
runner.options.OnSuccessfulPull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func collectTasks(stream TaskStream, maximum int) ([]*controlplanev1.CheckTask, error) {
|
func collectTasks(stream TaskStream, maximum int) ([]*controlplanev1.CheckTask, error) {
|
||||||
if stream == nil || maximum <= 0 {
|
if stream == nil || maximum <= 0 {
|
||||||
return nil, ErrInvalidRunner
|
return nil, ErrInvalidRunner
|
||||||
|
|||||||
@ -2,8 +2,10 @@ package controlplane
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -67,6 +69,51 @@ func TestRunnerOmitsEgressProbeURLFromGlobalObservation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunnerMarksReadyAfterSuccessfulEmptyPull(t *testing.T) {
|
||||||
|
var successfulPulls atomic.Int64
|
||||||
|
runner, err := NewRunner(&clientStub{stream: &taskStreamStub{}}, &executorStub{}, Options{
|
||||||
|
CheckerID: "checker-a", InstanceID: "instance-a", MaxInFlight: 1,
|
||||||
|
SupportedLevels: []controlplanev1.CheckLevel{controlplanev1.CheckLevel_CHECK_LEVEL_BASIC},
|
||||||
|
Now: time.Now, OnSuccessfulPull: func() { successfulPulls.Add(1) },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRunner(): %v", err)
|
||||||
|
}
|
||||||
|
if err := runner.RunOnce(context.Background()); err != nil {
|
||||||
|
t.Fatalf("RunOnce(): %v", err)
|
||||||
|
}
|
||||||
|
if successfulPulls.Load() != 1 {
|
||||||
|
t.Fatalf("successful pulls = %d, want 1", successfulPulls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerMarksUnavailableAfterPullFailure(t *testing.T) {
|
||||||
|
failed := make(chan struct{})
|
||||||
|
var markOnce sync.Once
|
||||||
|
runner, err := NewRunner(&clientStub{streamErr: errors.New("control plane unavailable")}, &executorStub{}, Options{
|
||||||
|
CheckerID: "checker-a", InstanceID: "instance-a", MaxInFlight: 1,
|
||||||
|
SupportedLevels: []controlplanev1.CheckLevel{controlplanev1.CheckLevel_CHECK_LEVEL_BASIC},
|
||||||
|
Now: time.Now, RetryDelay: time.Millisecond,
|
||||||
|
OnFailedPull: func() { markOnce.Do(func() { close(failed) }) },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRunner(): %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
completed := make(chan error, 1)
|
||||||
|
go func() { completed <- runner.Run(ctx) }()
|
||||||
|
select {
|
||||||
|
case <-failed:
|
||||||
|
cancel()
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Runner did not mark failed pull")
|
||||||
|
}
|
||||||
|
if err := <-completed; !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("Run() = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func checkerTask(id string, now time.Time) *controlplanev1.CheckTask {
|
func checkerTask(id string, now time.Time) *controlplanev1.CheckTask {
|
||||||
return &controlplanev1.CheckTask{
|
return &controlplanev1.CheckTask{
|
||||||
TaskId: id, ProxyId: id + "-proxy", Protocol: controlplanev1.ProxyProtocol_PROXY_PROTOCOL_HTTP,
|
TaskId: id, ProxyId: id + "-proxy", Protocol: controlplanev1.ProxyProtocol_PROXY_PROTOCOL_HTTP,
|
||||||
@ -76,12 +123,13 @@ func checkerTask(id string, now time.Time) *controlplanev1.CheckTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type clientStub struct {
|
type clientStub struct {
|
||||||
stream TaskStream
|
stream TaskStream
|
||||||
batches []*controlplanev1.ObservationBatch
|
streamErr error
|
||||||
|
batches []*controlplanev1.ObservationBatch
|
||||||
}
|
}
|
||||||
|
|
||||||
func (stub *clientStub) StreamCheckTasks(context.Context, *controlplanev1.StreamCheckTasksRequest) (TaskStream, error) {
|
func (stub *clientStub) StreamCheckTasks(context.Context, *controlplanev1.StreamCheckTasksRequest) (TaskStream, error) {
|
||||||
return stub.stream, nil
|
return stub.stream, stub.streamErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (stub *clientStub) ReportObservations(_ context.Context, batch *controlplanev1.ObservationBatch) (*controlplanev1.ReportObservationsResponse, error) {
|
func (stub *clientStub) ReportObservations(_ context.Context, batch *controlplanev1.ObservationBatch) (*controlplanev1.ReportObservationsResponse, error) {
|
||||||
|
|||||||
138
scripts/generate-local-controlplane-certs.ps1
Normal file
138
scripts/generate-local-controlplane-certs.ps1
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
param(
|
||||||
|
[string]$OutputDirectory = (Join-Path (Split-Path -Parent $PSScriptRoot) "deploy/.control-plane-tls")
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
|
||||||
|
throw "OutputDirectory is required"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $OutputDirectory) {
|
||||||
|
$existing = @(Get-ChildItem -Force -LiteralPath $OutputDirectory)
|
||||||
|
if ($existing.Count -gt 0) {
|
||||||
|
throw "OutputDirectory must be empty: $OutputDirectory"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
New-Item -ItemType Directory -Path $OutputDirectory | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-Pem {
|
||||||
|
param(
|
||||||
|
[string]$Label,
|
||||||
|
[byte[]]$Bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
$base64 = [Convert]::ToBase64String($Bytes, [Base64FormattingOptions]::InsertLineBreaks)
|
||||||
|
return "-----BEGIN $Label-----`n$base64`n-----END $Label-----`n"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-PemFile {
|
||||||
|
param(
|
||||||
|
[string]$Path,
|
||||||
|
[string]$Label,
|
||||||
|
[byte[]]$Bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
[IO.File]::WriteAllText($Path, (ConvertTo-Pem -Label $Label -Bytes $Bytes), [Text.Encoding]::ASCII)
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-CertificateRequest {
|
||||||
|
param(
|
||||||
|
[string]$CommonName,
|
||||||
|
[System.Security.Cryptography.RSA]$Key,
|
||||||
|
[bool]$IsCertificateAuthority,
|
||||||
|
[string]$ExtendedKeyUsage,
|
||||||
|
[string]$SubjectAlternativeName
|
||||||
|
)
|
||||||
|
|
||||||
|
$request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
|
||||||
|
"CN=$CommonName",
|
||||||
|
$Key,
|
||||||
|
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
|
||||||
|
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
|
||||||
|
)
|
||||||
|
$request.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new(
|
||||||
|
$IsCertificateAuthority, $false, 0, $true
|
||||||
|
))
|
||||||
|
if ($IsCertificateAuthority) {
|
||||||
|
$request.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new(
|
||||||
|
[System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign -bor
|
||||||
|
[System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::CrlSign,
|
||||||
|
$true
|
||||||
|
))
|
||||||
|
return $request
|
||||||
|
}
|
||||||
|
$request.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new(
|
||||||
|
[System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature -bor
|
||||||
|
[System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment,
|
||||||
|
$true
|
||||||
|
))
|
||||||
|
$usage = [System.Security.Cryptography.OidCollection]::new()
|
||||||
|
if ($ExtendedKeyUsage -eq "serverAuth") {
|
||||||
|
$null = $usage.Add([System.Security.Cryptography.Oid]::new("1.3.6.1.5.5.7.3.1"))
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$null = $usage.Add([System.Security.Cryptography.Oid]::new("1.3.6.1.5.5.7.3.2"))
|
||||||
|
}
|
||||||
|
$request.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($usage, $true))
|
||||||
|
$san = [System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder]::new()
|
||||||
|
if ($SubjectAlternativeName.StartsWith("DNS:")) {
|
||||||
|
$san.AddDnsName($SubjectAlternativeName.Substring(4))
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$san.AddUri([Uri]$SubjectAlternativeName.Substring(4))
|
||||||
|
}
|
||||||
|
$request.CertificateExtensions.Add($san.Build())
|
||||||
|
return $request
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-LeafCertificate {
|
||||||
|
param(
|
||||||
|
[string]$Name,
|
||||||
|
[string]$CommonName,
|
||||||
|
[string]$ExtendedKeyUsage,
|
||||||
|
[string]$SubjectAlternativeName
|
||||||
|
)
|
||||||
|
|
||||||
|
$directory = Join-Path $OutputDirectory $Name
|
||||||
|
New-Item -ItemType Directory -Path $directory | Out-Null
|
||||||
|
$key = [System.Security.Cryptography.RSA]::Create(2048)
|
||||||
|
try {
|
||||||
|
$request = New-CertificateRequest -CommonName $CommonName -Key $key -IsCertificateAuthority $false -ExtendedKeyUsage $ExtendedKeyUsage -SubjectAlternativeName $SubjectAlternativeName
|
||||||
|
$serial = [byte[]]::new(16)
|
||||||
|
[System.Security.Cryptography.RandomNumberGenerator]::Fill($serial)
|
||||||
|
$certificate = $request.Create($caCertificate, $notBefore, $notAfter, $serial)
|
||||||
|
try {
|
||||||
|
Write-PemFile -Path (Join-Path $directory "tls.crt") -Label "CERTIFICATE" -Bytes $certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)
|
||||||
|
Write-PemFile -Path (Join-Path $directory "tls.key") -Label "PRIVATE KEY" -Bytes $key.ExportPkcs8PrivateKey()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$certificate.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$key.Dispose()
|
||||||
|
}
|
||||||
|
Copy-Item -LiteralPath $caCertificatePath -Destination (Join-Path $directory "ca.crt")
|
||||||
|
}
|
||||||
|
|
||||||
|
$notBefore = [DateTimeOffset]::UtcNow.AddMinutes(-5)
|
||||||
|
$notAfter = $notBefore.AddDays(7)
|
||||||
|
$caKey = [System.Security.Cryptography.RSA]::Create(2048)
|
||||||
|
$caRequest = New-CertificateRequest -CommonName "proxy-pool-local-control-plane-ca" -Key $caKey -IsCertificateAuthority $true -ExtendedKeyUsage "" -SubjectAlternativeName ""
|
||||||
|
$caCertificate = $caRequest.CreateSelfSigned($notBefore, $notAfter)
|
||||||
|
$caCertificatePath = Join-Path $OutputDirectory "ca.crt"
|
||||||
|
Write-PemFile -Path $caCertificatePath -Label "CERTIFICATE" -Bytes $caCertificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)
|
||||||
|
Write-PemFile -Path (Join-Path $OutputDirectory "ca.key") -Label "PRIVATE KEY" -Bytes $caKey.ExportPkcs8PrivateKey()
|
||||||
|
|
||||||
|
New-LeafCertificate -Name "controller" -CommonName "proxy-pool-controller" -ExtendedKeyUsage "serverAuth" -SubjectAlternativeName "DNS:controller"
|
||||||
|
New-LeafCertificate -Name "gateway-a" -CommonName "proxy-pool-gateway-a" -ExtendedKeyUsage "clientAuth" -SubjectAlternativeName "URI:spiffe://proxy-pool.local/development/worker/gateway-a"
|
||||||
|
New-LeafCertificate -Name "gateway-b" -CommonName "proxy-pool-gateway-b" -ExtendedKeyUsage "clientAuth" -SubjectAlternativeName "URI:spiffe://proxy-pool.local/development/worker/gateway-b"
|
||||||
|
New-LeafCertificate -Name "checker-a" -CommonName "proxy-pool-checker-a" -ExtendedKeyUsage "clientAuth" -SubjectAlternativeName "URI:spiffe://proxy-pool.local/development/checker/checker-a"
|
||||||
|
|
||||||
|
$caCertificate.Dispose()
|
||||||
|
$caKey.Dispose()
|
||||||
|
|
||||||
|
Write-Host "Generated local control-plane certificates in $OutputDirectory"
|
||||||
Loading…
Reference in New Issue
Block a user