feat: probe SOCKS5 upstreams
Some checks are pending
ci / proto (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
ci / race (push) Waiting to run
ci / integration (push) Waiting to run

This commit is contained in:
youfak 2026-08-02 07:59:39 +08:00
parent 9aacc75454
commit c7e77a6f84
11 changed files with 418 additions and 21 deletions

View File

@ -80,7 +80,7 @@ flowchart LR
## 当前完成度 ## 当前完成度
截至 **2026-07-31**,实施计划中可直接勾选的检查项为 **53 / 7471.6%**。详情见 截至 **2026-08-02**,实施计划中可直接勾选的检查项为 **53 / 7471.6%**。详情见
[实施计划](docs/development/implementation-plan.md)和 [实施计划](docs/development/implementation-plan.md)和
[交付完成度审计](docs/requirements/completion-audit.md)。 [交付完成度审计](docs/requirements/completion-audit.md)。
@ -88,10 +88,11 @@ flowchart LR
提取与限流、Controller 的 Admin/Distribution/Metrics 监听,以及 PostgreSQL 提取与限流、Controller 的 Admin/Distribution/Metrics 监听,以及 PostgreSQL
管理状态WorkerControlPlane 的 Register、Snapshot ACK、Runtime 心跳接收和 管理状态WorkerControlPlane 的 Register、Snapshot ACK、Runtime 心跳接收和
Redis 会话栅栏,以及 Gateway Outcome 上报的有界队列、序列确认与重试; Redis 会话栅栏,以及 Gateway Outcome 上报的有界队列、序列确认与重试;
Controller 的 Redis 共享 BASIC 检查任务、按上游的有界调度、Checker 探测和 Controller 的 Redis 共享 BASIC 检查任务、按上游的有界调度、HTTP/HTTPS/SOCKS5
Checker 探测和
Observation 状态归并。 Observation 状态归并。
- **部分完成**EGRESS 与 TARGET 的任务编排和配置建模、SOCKS5 探测Docker - **部分完成**EGRESS 与 TARGET 的任务编排和配置建模Docker Compose/Kubernetes
Compose/Kubernetes 运行时 mTLS Overlay。 运行时 mTLS Overlay。
- **待完成**loadgen、故障演练和代表性集群压测。 - **待完成**loadgen、故障演练和代表性集群压测。
检查项数量不等于生产就绪度。静态部署清单与 protobuf descriptor 验证也不代表 检查项数量不等于生产就绪度。静态部署清单与 protobuf descriptor 验证也不代表
@ -168,7 +169,7 @@ Checker 的参数也可通过 `PROXY_POOL_CONTROL_PLANE_ADDRESS`、
`PROXY_POOL_CHECKER_ID`、`PROXY_POOL_CHECKER_INSTANCE_ID` 与 `PROXY_POOL_CHECKER_ID`、`PROXY_POOL_CHECKER_INSTANCE_ID` 与
`PROXY_POOL_CHECKER_MAX_IN_FLIGHT` 提供。它不会访问 Redis/PostgreSQL生产 `PROXY_POOL_CHECKER_MAX_IN_FLIGHT` 提供。它不会访问 Redis/PostgreSQL生产
Controller 在启用控制面时装配 Redis 共享任务 broker并按启用的 Upstream 调度 Controller 在启用控制面时装配 Redis 共享任务 broker并按启用的 Upstream 调度
HTTP/HTTPS BASIC 检查。调度监督器每轮读取已发布配置,因此 reload 后的上游启停、 HTTP/HTTPS/SOCKS5 BASIC 检查。调度监督器每轮读取已发布配置,因此 reload 后的上游启停、
检查间隔、抖动、超时、重试次数和 `maxInFlight` 会在下一轮生效;新启用的上游无需 检查间隔、抖动、超时、重试次数和 `maxInFlight` 会在下一轮生效;新启用的上游无需
重启 Controller。EGRESS 与 TARGET 尚未进入生产调度loadgen 命令也尚未实现。 重启 Controller。EGRESS 与 TARGET 尚未进入生产调度loadgen 命令也尚未实现。
@ -203,7 +204,7 @@ HTTP/HTTPS BASIC 检查。调度监督器每轮读取已发布配置,因此 re
ownership 索引,以及 Gateway 快照客户端。 ownership 索引,以及 Gateway 快照客户端。
- **P0 - Checker 健康链**BASIC 的共享调度、实际探测、Observation reducer 和 - **P0 - Checker 健康链**BASIC 的共享调度、实际探测、Observation reducer 和
`FETCHED -> AVAILABLE / SUSPECT / UNHEALTHY` 状态链已完成;继续补齐 EGRESS、 `FETCHED -> AVAILABLE / SUSPECT / UNHEALTHY` 状态链已完成;继续补齐 EGRESS、
TARGET 和 SOCKS5 TARGET。
- **P1 - Gateway 与 Routing**Gateway 进程、快照凭据分发、五种 Routing 策略与 - **P1 - Gateway 与 Routing**Gateway 进程、快照凭据分发、五种 Routing 策略与
`onUnavailable` 已接入;动态容量调整和 Drain 闭环待完成。 `onUnavailable` 已接入;动态容量调整和 Drain 闭环待完成。
- **P1 - 可观测与部署**:低基数业务指标、完整 Compose/Kubernetes 进程拓扑, - **P1 - 可观测与部署**:低基数业务指标、完整 Compose/Kubernetes 进程拓扑,

View File

@ -43,7 +43,7 @@ Redis 只保留每个当前会话的一条序列和摘要;原始 Outcome、代
Redis 或 PostgreSQL。Gateway 只将结果写入本地有界队列,队列满时丢弃样本,不等待控制面 Redis 或 PostgreSQL。Gateway 只将结果写入本地有界队列,队列满时丢弃样本,不等待控制面
或存储。Checker 的有界任务领取、任务租约归属校验和 Observation 上报已经由 或存储。Checker 的有界任务领取、任务租约归属校验和 Observation 上报已经由
gRPC 契约测试覆盖Controller 已装配 Redis 共享 due-index、生产任务 broker 与独立 gRPC 契约测试覆盖Controller 已装配 Redis 共享 due-index、生产任务 broker 与独立
Checker 的 HTTP/HTTPS BASIC 探测进程。EGRESS 和 TARGET 尚未进入生产调度。 Checker 的 HTTP/HTTPS/SOCKS5 BASIC 探测进程。EGRESS 和 TARGET 尚未进入生产调度。
`100,000 QPS` 仍是未验证的设计目标。 `100,000 QPS` 仍是未验证的设计目标。
`WatchSnapshots` 建立时校验当前 session每次签发快照引用时也把 `session_id` `WatchSnapshots` 建立时校验当前 session每次签发快照引用时也把 `session_id`
@ -183,10 +183,9 @@ Routing 决定 AVAILABLE、SUSPECT 或 UNHEALTHY并更新 Redis 活动池,
`proxy-checker` 使用固定大小 worker-pool 执行每个 pull 批次,任务数不超过该请求的 `proxy-checker` 使用固定大小 worker-pool 执行每个 pull 批次,任务数不超过该请求的
`max_in_flight`;每次尝试都受 `deadline``timeout` 的较小值约束,失败可在同一 `max_in_flight`;每次尝试都受 `deadline``timeout` 的较小值约束,失败可在同一
deadline 内最多执行到 `max_attempts`。BASIC 针对 HTTP/HTTPS Proxy 验证到 Proxy 的 deadline 内最多执行到 `max_attempts`。BASIC 针对 HTTP/HTTPS/SOCKS5 Proxy 验证到 Proxy 的
请求/认证握手EGRESS 与 TARGET 通过 Proxy 请求任务指定的 HTTP/HTTPS 目标并将 请求/认证握手EGRESS 与 TARGET 通过 Proxy 请求任务指定的 HTTP/HTTPS 目标并将
非成功状态作为事实。EGRESS 的出口身份响应解析、SOCKS5 与 EGRESS/TARGET 生产调度 非成功状态作为事实。EGRESS 的出口身份响应解析与 EGRESS/TARGET 生产调度仍待后续实现。
仍待后续实现。
## 7. 兼容与演进 ## 7. 兼容与演进

View File

@ -202,7 +202,7 @@ Checker 同样使用独立的可拨号地址:`proxy-checker` 的 `-control-pla
`PROXY_POOL_*` 环境变量提供。mTLS 模式下该命令读取 `checkerTLS`,明文 fixture `PROXY_POOL_*` 环境变量提供。mTLS 模式下该命令读取 `checkerTLS`,明文 fixture
模式只接受回环 Controller 地址。Checker 只从 gRPC 领取任务并批量上报事实,不读取 模式只接受回环 Controller 地址。Checker 只从 gRPC 领取任务并批量上报事实,不读取
Redis/PostgreSQLController 在生产启动拓扑中装配 Redis 共享任务队列,当前调度 Redis/PostgreSQLController 在生产启动拓扑中装配 Redis 共享任务队列,当前调度
HTTP/HTTPS BASIC 检查。调度监督器在每轮从已发布配置读取启用的上游Admin reload HTTP/HTTPS/SOCKS5 BASIC 检查。调度监督器在每轮从已发布配置读取启用的上游Admin reload
发布后,上游启停和有效 `check` 策略会在下一轮生效,新启用的上游无需重启 Controller。 发布后,上游启停和有效 `check` 策略会在下一轮生效,新启用的上游无需重启 Controller。
EGRESS 和 TARGET 的生产调度仍在后续实施范围。 EGRESS 和 TARGET 的生产调度仍在后续实施范围。

View File

@ -47,7 +47,7 @@ Controller 是首版模块化单体。Provider、Pool、Routing 和 Extraction
### proxy-checker ### proxy-checker
Checker 只产生 Observation。它从认证 gRPC 流领取有界任务,用固定 worker-pool 在任务 Checker 只产生 Observation。它从认证 gRPC 流领取有界任务,用固定 worker-pool 在任务
deadline 内执行 HTTP/HTTPS BASIC、EGRESS 和 TARGET 探测并微批上报EGRESS 的 deadline 内执行 HTTP/HTTPS/SOCKS5 BASIC、EGRESS 和 TARGET 探测并微批上报EGRESS 的
任务 URL 仅在执行期使用,回传全局事实不包含该 URL。最终状态迁移仍由 Controller 的 任务 URL 仅在执行期使用,回传全局事实不包含该 URL。最终状态迁移仍由 Controller 的
确定性 reducer 完成,避免多个检查实例同时写 Proxy 状态。Checker 不访问 Redis 或 PostgreSQL。 确定性 reducer 完成,避免多个检查实例同时写 Proxy 状态。Checker 不访问 Redis 或 PostgreSQL。

View File

@ -283,10 +283,10 @@ Checker Observation 上报 RPC 已复用既有控制面监听接入 Controller
有界 pull并通过通用任务 broker 契约完成能力协商、同 Checker 并发窗口、租约到期回收、 有界 pull并通过通用任务 broker 契约完成能力协商、同 Checker 并发窗口、租约到期回收、
领取者加不可预测 lease token 的栅栏和完成后重放;任务凭据仅由认证流在执行期下发。 领取者加不可预测 lease token 的栅栏和完成后重放;任务凭据仅由认证流在执行期下发。
Redis 共享 due-index/租约持久化、每 Upstream 的跨副本 in-flight 限制和生产 broker 已完成; Redis 共享 due-index/租约持久化、每 Upstream 的跨副本 in-flight 限制和生产 broker 已完成;
`proxy-checker` 独立进程、固定大小 worker-pool、任务期重试/微批上报和 HTTP/HTTPS `proxy-checker` 独立进程、固定大小 worker-pool、任务期重试/微批上报和 HTTP/HTTPS/SOCKS5
BASIC 探测器已完成并有测试。TARGET 探测器具备任务执行能力,但尚无生产任务调度; BASIC 探测器已完成并有测试。TARGET 探测器具备任务执行能力,但尚无生产任务调度;
EGRESS 已具备任务 URL 传输、HTTP/HTTPS 探测和全局事实回传契约,但出口身份响应解析、 EGRESS 已具备任务 URL 传输、HTTP/HTTPS 探测和全局事实回传契约,但出口身份响应解析、
SOCKS5、EGRESS/TARGET 多维任务索引及部署运行态仍未实现, EGRESS/TARGET 多维任务索引及部署运行态仍未实现,
因此本任务保持未完成。 因此本任务保持未完成。
补充进度2026-08-02BASIC 调度已改为配置驱动监督器。它每轮读取已发布快照并复用 补充进度2026-08-02BASIC 调度已改为配置驱动监督器。它每轮读取已发布快照并复用

View File

@ -24,7 +24,7 @@
`cmd/proxy-controller` 已完成配置单次加载、PostgreSQL 迁移、Redis 活动池、 `cmd/proxy-controller` 已完成配置单次加载、PostgreSQL 迁移、Redis 活动池、
Distribution/Admin/Metrics 独立监听和有界停机装配。Provider 自动补池、分布式 Distribution/Admin/Metrics 独立监听和有界停机装配。Provider 自动补池、分布式
配额、动态重载和 Admin 低基数统计已装配Controller 已装配 Redis BASIC 任务 broker 配额、动态重载和 Admin 低基数统计已装配Controller 已装配 Redis BASIC 任务 broker
`proxy-checker` 可执行 HTTP/HTTPS BASIC 探测。EGRESS/TARGET 调度、loadgen 与完整 `proxy-checker` 可执行 HTTP/HTTPS/SOCKS5 BASIC 探测。EGRESS/TARGET 调度、loadgen 与完整
mTLS 环境 Overlay 仍属于 `implementation-plan.md` 后续任务。 mTLS 环境 Overlay 仍属于 `implementation-plan.md` 后续任务。
因此 Compose/Kubernetes 资产当前仍用于评审网络、资源、探针和依赖关系,不能 因此 Compose/Kubernetes 资产当前仍用于评审网络、资源、探针和依赖关系,不能
视为完整可运行拓扑。 视为完整可运行拓扑。

View File

@ -62,7 +62,7 @@ Outcome 已实现为 Gateway 本地有界队列、微批确认重试和 Controll
记录归并,不改写 Proxy 全局状态。Controller 公用 Reducer 已作为 Observation 的唯一状态 记录归并,不改写 Proxy 全局状态。Controller 公用 Reducer 已作为 Observation 的唯一状态
归并边界Checker Observation RPC 已在同一控制面监听以独立 SPIFFE 身份接入;有界任务领取、 归并边界Checker Observation RPC 已在同一控制面监听以独立 SPIFFE 身份接入;有界任务领取、
租约归属与任务期凭据传输已由通用契约和 gRPC 往返测试覆盖。Redis 共享 due-index、 租约归属与任务期凭据传输已由通用契约和 gRPC 往返测试覆盖。Redis 共享 due-index、
任务/租约持久化、按上游的 in-flight 限制以及独立 Checker 的 HTTP/HTTPS BASIC 执行 任务/租约持久化、按上游的 in-flight 限制以及独立 Checker 的 HTTP/HTTPS/SOCKS5 BASIC 执行
进程已经闭环EGRESS、TARGET 的多维任务索引与生产调度尚未实现。Snapshot 签发在 Redis 中原子匹配当前 进程已经闭环EGRESS、TARGET 的多维任务索引与生产调度尚未实现。Snapshot 签发在 Redis 中原子匹配当前
`session_id`,重注册会清除旧引用,迟到旧 Stream 不会覆盖新 session。Controller `session_id`,重注册会清除旧引用,迟到旧 Stream 不会覆盖新 session。Controller
在最近成功下发的 Snapshot `valid_until` 到达时关闭流Gateway 的公用 在最近成功下发的 Snapshot `valid_until` 到达时关闭流Gateway 的公用
@ -114,9 +114,9 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
6. Worker 基础网络快照流、Proxy/Gateway Routing/凭据 Snapshot payload、Gateway Snapshot 6. Worker 基础网络快照流、Proxy/Gateway Routing/凭据 Snapshot payload、Gateway Snapshot
客户端和进程装配、同版本 Routing 编译/动态匹配、五种策略上游选择与 reject/wait/direct 已完成; 客户端和进程装配、同版本 Routing 编译/动态匹配、五种策略上游选择与 reject/wait/direct 已完成;
Outcome 上报已完成基础观测链Redis ownership drain/ACK/过期回收及按 Worker 的可下发索引已完成。 Outcome 上报已完成基础观测链Redis ownership drain/ACK/过期回收及按 Worker 的可下发索引已完成。
7. BASIC Checker 调度与 HTTP/HTTPS 探测器、全局与 TARGET Profile 的 Memory/Redis 7. BASIC Checker 调度与 HTTP/HTTPS/SOCKS5 探测器、全局与 TARGET Profile 的 Memory/Redis
原子归并、Controller Reducer 和 Observation 上报 RPC 已完成EGRESS、TARGET 原子归并、Controller Reducer 和 Observation 上报 RPC 已完成EGRESS、TARGET
生产任务调度、SOCKS5 探测与 REMOVE 编排仍待实现。 生产任务调度与 REMOVE 编排仍待实现。
8. Admin/Distribution 细粒度授权和审计查询Distribution 分布式限流已完成。 8. Admin/Distribution 细粒度授权和审计查询Distribution 分布式限流已完成。
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。 9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
10. 将 reject/wait/direct 接入 Distribution 运行链,补齐 Sequential 持久化恢复、跨实例 CAS 10. 将 reject/wait/direct 接入 Distribution 运行链,补齐 Sequential 持久化恢复、跨实例 CAS

View File

@ -80,7 +80,7 @@
| ID | 最终需求 | 来源 | 验证证据 | | ID | 最终需求 | 来源 | 验证证据 |
|---|---|---|---| |---|---|---|---|
| HEALTH-001 | 全局健康与 Routing/目标健康分离 | 221-270, 8679-8708 | `domain/health` 已将 BASIC/EGRESS 全局 Reducer 与 TARGET Profile Reducer 分离TARGET 在 Memory 和 Redis 独立、随代理 TTL 归并,不改写 Proxy 全局状态Routing 消费待实现 | | HEALTH-001 | 全局健康与 Routing/目标健康分离 | 221-270, 8679-8708 | `domain/health` 已将 BASIC/EGRESS 全局 Reducer 与 TARGET Profile Reducer 分离TARGET 在 Memory 和 Redis 独立、随代理 TTL 归并,不改写 Proxy 全局状态Routing 消费待实现 |
| HEALTH-002 | 健康调度有 jitter、maxInFlight 和分级频率 | 8679-8736 | 配置有效合并、URL 校验、稳定抖动/优先级 Planner、有界 Scheduler tick、Redis due-index、跨副本 in-flight 原子限制,以及 HTTP/HTTPS BASIC 生产执行器已完成EGRESS/TARGET 多维任务索引与调度待实现 | | HEALTH-002 | 健康调度有 jitter、maxInFlight 和分级频率 | 8679-8736 | 配置有效合并、URL 校验、稳定抖动/优先级 Planner、有界 Scheduler tick、Redis due-index、跨副本 in-flight 原子限制,以及 HTTP/HTTPS/SOCKS5 BASIC 生产执行器已完成EGRESS/TARGET 多维任务索引与调度待实现 |
| HEALTH-003 | 失败分级 SUSPECT -> UNHEALTHY -> REMOVE | 8679-8736 | Controller 公用 Reducer 已通过 Memory/Redis 活动池原子提交全局连续失败、精确重放和成功恢复BASIC 任务调度已完成REMOVE 编排待实现 | | HEALTH-003 | 失败分级 SUSPECT -> UNHEALTHY -> REMOVE | 8679-8736 | Controller 公用 Reducer 已通过 Memory/Redis 活动池原子提交全局连续失败、精确重放和成功恢复BASIC 任务调度已完成REMOVE 编排待实现 |
| SEC-001 | API 认证与 Proxy 认证分离Secret 统一脱敏 | 7528-8111, 8904-8945 | Config 脱敏、Provider Store -> SecretRef -> Gateway Resolver 跨包测试与格式化泄漏回归测试 | | SEC-001 | API 认证与 Proxy 认证分离Secret 统一脱敏 | 7528-8111, 8904-8945 | Config 脱敏、Provider Store -> SecretRef -> Gateway Resolver 跨包测试与格式化泄漏回归测试 |
| SEC-002 | 非回环监听无保护时严格模式启动失败 | 8112-8441 | 配置校验测试 | | SEC-002 | 非回环监听无保护时严格模式启动失败 | 8112-8441 | 配置校验测试 |

View File

@ -59,12 +59,16 @@ func (executor *Executor) Execute(ctx context.Context, task *controlplanev1.Chec
probeContext, cancel := context.WithDeadline(ctx, deadline) probeContext, cancel := context.WithDeadline(ctx, deadline)
defer cancel() defer cancel()
transport := &http.Transport{ transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
DialContext: (&net.Dialer{}).DialContext, DialContext: (&net.Dialer{}).DialContext,
ForceAttemptHTTP2: false, ForceAttemptHTTP2: false,
TLSHandshakeTimeout: minDuration(time.Until(deadline), 10*time.Second), TLSHandshakeTimeout: minDuration(time.Until(deadline), 10*time.Second),
ResponseHeaderTimeout: minDuration(time.Until(deadline), 10*time.Second), ResponseHeaderTimeout: minDuration(time.Until(deadline), 10*time.Second),
} }
if proxyURL.Scheme == "socks5" {
transport.DialContext = newSOCKS5Dialer(proxyURL).DialContext
} else {
transport.Proxy = http.ProxyURL(proxyURL)
}
defer transport.CloseIdleConnections() defer transport.CloseIdleConnections()
client := &http.Client{ client := &http.Client{
Transport: transport, Transport: transport,
@ -80,6 +84,9 @@ func (executor *Executor) Execute(ctx context.Context, task *controlplanev1.Chec
if errors.Is(probeContext.Err(), context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { if errors.Is(probeContext.Err(), context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
return Result{FailureClass: FailureDeadlineExceeded, Latency: latency} return Result{FailureClass: FailureDeadlineExceeded, Latency: latency}
} }
if errors.Is(err, errSOCKS5Authentication) {
return Result{FailureClass: FailureProxyAuth, Latency: latency}
}
return Result{FailureClass: FailureProxyRequest, Latency: latency} return Result{FailureClass: FailureProxyRequest, Latency: latency}
} }
_ = response.Body.Close() _ = response.Body.Close()
@ -109,6 +116,13 @@ func prepare(task *controlplanev1.CheckTask, now time.Time) (time.Time, string,
proxyScheme = "http" proxyScheme = "http"
case controlplanev1.ProxyProtocol_PROXY_PROTOCOL_HTTPS: case controlplanev1.ProxyProtocol_PROXY_PROTOCOL_HTTPS:
proxyScheme = "https" proxyScheme = "https"
case controlplanev1.ProxyProtocol_PROXY_PROTOCOL_SOCKS5:
if task.GetUsername() != "" || task.GetPassword() != "" {
if len(task.GetUsername()) == 0 || len(task.GetUsername()) > 255 || len(task.GetPassword()) == 0 || len(task.GetPassword()) > 255 {
return time.Time{}, "", nil, errors.New("invalid SOCKS5 credentials")
}
}
proxyScheme = "socks5"
default: default:
return time.Time{}, "", nil, errUnsupportedProtocol return time.Time{}, "", nil, errUnsupportedProtocol
} }

View File

@ -2,10 +2,13 @@ package probe
import ( import (
"context" "context"
"encoding/binary"
"io"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv" "strconv"
"strings"
"testing" "testing"
"time" "time"
@ -77,7 +80,7 @@ func TestExecutorRejectsEgressTaskWithRoutingProfile(t *testing.T) {
func TestExecutorReportsUnsupportedProtocolAsFact(t *testing.T) { func TestExecutorReportsUnsupportedProtocolAsFact(t *testing.T) {
now := time.Now().UTC() now := time.Now().UTC()
result := NewExecutor().Execute(context.Background(), &controlplanev1.CheckTask{ result := NewExecutor().Execute(context.Background(), &controlplanev1.CheckTask{
TaskId: "task-a", ProxyId: "proxy-a", Protocol: controlplanev1.ProxyProtocol_PROXY_PROTOCOL_SOCKS5, TaskId: "task-a", ProxyId: "proxy-a", Protocol: controlplanev1.ProxyProtocol_PROXY_PROTOCOL_UNSPECIFIED,
Host: "proxy.example", Port: 1080, Level: controlplanev1.CheckLevel_CHECK_LEVEL_BASIC, Host: "proxy.example", Port: 1080, Level: controlplanev1.CheckLevel_CHECK_LEVEL_BASIC,
Timeout: durationpb.New(time.Second), Attempt: 1, MaxAttempts: 1, Deadline: timestamppb.New(now.Add(time.Second)), Timeout: durationpb.New(time.Second), Attempt: 1, MaxAttempts: 1, Deadline: timestamppb.New(now.Add(time.Second)),
}) })
@ -86,6 +89,41 @@ func TestExecutorReportsUnsupportedProtocolAsFact(t *testing.T) {
} }
} }
func TestExecutorBasicSupportsAuthenticatedSOCKS5Proxy(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Host != "example.invalid" {
t.Errorf("request host = %q", request.Host)
}
response.WriteHeader(http.StatusBadGateway)
}))
defer target.Close()
proxy := newSOCKS5TestProxy(t, target.URL, "user", "secret")
task := validSOCKS5Task(t, proxy.address, "user", "secret")
result := NewExecutor().Execute(context.Background(), task)
if !result.Success || result.FailureClass != "" || result.Latency <= 0 {
t.Fatalf("Execute(SOCKS5 BASIC) = %+v", result)
}
select {
case address := <-proxy.requested:
if address != "example.invalid:80" {
t.Fatalf("SOCKS5 requested address = %q", address)
}
case <-time.After(time.Second):
t.Fatal("SOCKS5 proxy did not receive CONNECT request")
}
}
func TestExecutorReportsSOCKS5AuthenticationFailure(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer target.Close()
proxy := newSOCKS5TestProxy(t, target.URL, "user", "secret")
result := NewExecutor().Execute(context.Background(), validSOCKS5Task(t, proxy.address, "user", "wrong"))
if result.Success || result.FailureClass != FailureProxyAuth || result.Latency <= 0 {
t.Fatalf("Execute(SOCKS5 auth failure) = %+v", result)
}
}
func validTask(t *testing.T, proxyAddress string, level controlplanev1.CheckLevel) *controlplanev1.CheckTask { func validTask(t *testing.T, proxyAddress string, level controlplanev1.CheckLevel) *controlplanev1.CheckTask {
t.Helper() t.Helper()
host, port := splitProxyAddress(t, proxyAddress) host, port := splitProxyAddress(t, proxyAddress)
@ -97,3 +135,168 @@ func validTask(t *testing.T, proxyAddress string, level controlplanev1.CheckLeve
Deadline: timestamppb.New(now.Add(time.Second)), Deadline: timestamppb.New(now.Add(time.Second)),
} }
} }
func validSOCKS5Task(t *testing.T, address, username, password string) *controlplanev1.CheckTask {
t.Helper()
host, rawPort, err := net.SplitHostPort(address)
if err != nil {
t.Fatalf("net.SplitHostPort(%q): %v", address, err)
}
port, err := strconv.ParseUint(rawPort, 10, 16)
if err != nil {
t.Fatalf("strconv.ParseUint(%q): %v", rawPort, err)
}
now := time.Now().UTC()
return &controlplanev1.CheckTask{
TaskId: "task-socks", ProxyId: "proxy-socks", Protocol: controlplanev1.ProxyProtocol_PROXY_PROTOCOL_SOCKS5,
Host: host, Port: uint32(port), Level: controlplanev1.CheckLevel_CHECK_LEVEL_BASIC, Username: username, Password: password,
Timeout: durationpb.New(time.Second), Attempt: 1, MaxAttempts: 1, Deadline: timestamppb.New(now.Add(time.Second)),
}
}
type socks5TestProxy struct {
address string
target string
username string
password string
requested chan string
listener net.Listener
}
func newSOCKS5TestProxy(t *testing.T, target, username, password string) *socks5TestProxy {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen(): %v", err)
}
proxy := &socks5TestProxy{
address: listener.Addr().String(), target: target, username: username, password: password,
requested: make(chan string, 1), listener: listener,
}
go func() {
for {
connection, err := listener.Accept()
if err != nil {
return
}
go proxy.serve(connection)
}
}()
t.Cleanup(func() { _ = listener.Close() })
return proxy
}
func (proxy *socks5TestProxy) serve(connection net.Conn) {
defer connection.Close()
if _, err := readSOCKS5Greeting(connection); err != nil {
return
}
method := byte(0x00)
if proxy.username != "" || proxy.password != "" {
method = 0x02
}
if _, err := connection.Write([]byte{0x05, method}); err != nil {
return
}
if method == 0x02 {
username, password, err := readSOCKS5Credentials(connection)
if err != nil {
return
}
if username != proxy.username || password != proxy.password {
_, _ = connection.Write([]byte{0x01, 0x01})
return
}
if _, err := connection.Write([]byte{0x01, 0x00}); err != nil {
return
}
}
address, err := readSOCKS5ConnectRequest(connection)
if err != nil {
return
}
proxy.requested <- address
targetAddress := strings.TrimPrefix(proxy.target, "http://")
target, err := net.Dial("tcp", targetAddress)
if err != nil {
_, _ = connection.Write([]byte{0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
return
}
defer target.Close()
if _, err := connection.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
return
}
done := make(chan struct{}, 2)
go func() { _, _ = io.Copy(target, connection); done <- struct{}{} }()
go func() { _, _ = io.Copy(connection, target); done <- struct{}{} }()
<-done
}
func readSOCKS5Greeting(reader io.Reader) ([]byte, error) {
header := make([]byte, 2)
if _, err := io.ReadFull(reader, header); err != nil || header[0] != 0x05 || header[1] == 0 {
return nil, io.ErrUnexpectedEOF
}
methods := make([]byte, header[1])
_, err := io.ReadFull(reader, methods)
return methods, err
}
func readSOCKS5Credentials(reader io.Reader) (string, string, error) {
header := make([]byte, 2)
if _, err := io.ReadFull(reader, header); err != nil || header[0] != 0x01 || header[1] == 0 {
return "", "", io.ErrUnexpectedEOF
}
username := make([]byte, header[1])
if _, err := io.ReadFull(reader, username); err != nil {
return "", "", err
}
passwordLength := make([]byte, 1)
if _, err := io.ReadFull(reader, passwordLength); err != nil || passwordLength[0] == 0 {
return "", "", io.ErrUnexpectedEOF
}
password := make([]byte, passwordLength[0])
if _, err := io.ReadFull(reader, password); err != nil {
return "", "", err
}
return string(username), string(password), nil
}
func readSOCKS5ConnectRequest(reader io.Reader) (string, error) {
header := make([]byte, 4)
if _, err := io.ReadFull(reader, header); err != nil || header[0] != 0x05 || header[1] != 0x01 || header[2] != 0x00 {
return "", io.ErrUnexpectedEOF
}
var host string
switch header[3] {
case 0x01:
address := make([]byte, net.IPv4len)
if _, err := io.ReadFull(reader, address); err != nil {
return "", err
}
host = net.IP(address).String()
case 0x03:
length := make([]byte, 1)
if _, err := io.ReadFull(reader, length); err != nil || length[0] == 0 {
return "", io.ErrUnexpectedEOF
}
address := make([]byte, length[0])
if _, err := io.ReadFull(reader, address); err != nil {
return "", err
}
host = string(address)
case 0x04:
address := make([]byte, net.IPv6len)
if _, err := io.ReadFull(reader, address); err != nil {
return "", err
}
host = net.IP(address).String()
default:
return "", io.ErrUnexpectedEOF
}
port := make([]byte, 2)
if _, err := io.ReadFull(reader, port); err != nil {
return "", err
}
return net.JoinHostPort(host, strconv.Itoa(int(binary.BigEndian.Uint16(port)))), nil
}

View File

@ -0,0 +1,180 @@
package probe
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"net/url"
"strconv"
"time"
)
const (
socks5Version = 0x05
socks5NoAuth = 0x00
socks5UserPassword = 0x02
socks5NoAcceptable = 0xff
socks5ConnectCommand = 0x01
socks5AddressIPv4 = 0x01
socks5AddressDomain = 0x03
socks5AddressIPv6 = 0x04
)
var errSOCKS5Authentication = errors.New("SOCKS5 authentication failed")
type socks5Dialer struct {
proxyAddress string
username string
password string
dialer net.Dialer
}
func newSOCKS5Dialer(proxyURL *url.URL) socks5Dialer {
username := ""
password := ""
if proxyURL.User != nil {
username = proxyURL.User.Username()
password, _ = proxyURL.User.Password()
}
return socks5Dialer{proxyAddress: proxyURL.Host, username: username, password: password}
}
func (dialer socks5Dialer) DialContext(ctx context.Context, _ string, address string) (net.Conn, error) {
connection, err := dialer.dialer.DialContext(ctx, "tcp", dialer.proxyAddress)
if err != nil {
return nil, err
}
if deadline, hasDeadline := ctx.Deadline(); hasDeadline {
if err := connection.SetDeadline(deadline); err != nil {
_ = connection.Close()
return nil, err
}
defer connection.SetDeadline(time.Time{})
}
if err := dialer.connect(connection, address); err != nil {
_ = connection.Close()
return nil, err
}
return connection, nil
}
func (dialer socks5Dialer) connect(connection net.Conn, address string) error {
method := byte(socks5NoAuth)
if dialer.username != "" || dialer.password != "" {
method = socks5UserPassword
}
if _, err := connection.Write([]byte{socks5Version, 0x01, method}); err != nil {
return err
}
selection := make([]byte, 2)
if _, err := io.ReadFull(connection, selection); err != nil {
return err
}
if selection[0] != socks5Version || selection[1] == socks5NoAcceptable {
return fmt.Errorf("invalid SOCKS5 method selection")
}
if selection[1] == socks5UserPassword {
if method != socks5UserPassword {
return errSOCKS5Authentication
}
if err := socks5Authenticate(connection, dialer.username, dialer.password); err != nil {
return err
}
} else if selection[1] != method {
return fmt.Errorf("unsupported SOCKS5 authentication method %d", selection[1])
}
request, err := socks5ConnectRequest(address)
if err != nil {
return err
}
if _, err := connection.Write(request); err != nil {
return err
}
return readSOCKS5ConnectReply(connection)
}
func socks5Authenticate(connection net.Conn, username, password string) error {
if len(username) == 0 || len(username) > 255 || len(password) == 0 || len(password) > 255 {
return errSOCKS5Authentication
}
request := make([]byte, 0, len(username)+len(password)+3)
request = append(request, 0x01, byte(len(username)))
request = append(request, username...)
request = append(request, byte(len(password)))
request = append(request, password...)
if _, err := connection.Write(request); err != nil {
return err
}
reply := make([]byte, 2)
if _, err := io.ReadFull(connection, reply); err != nil {
return err
}
if reply[0] != 0x01 || reply[1] != 0x00 {
return errSOCKS5Authentication
}
return nil
}
func socks5ConnectRequest(address string) ([]byte, error) {
host, rawPort, err := net.SplitHostPort(address)
if err != nil || host == "" {
return nil, errors.New("invalid SOCKS5 target address")
}
port, err := strconv.ParseUint(rawPort, 10, 16)
if err != nil || port == 0 {
return nil, errors.New("invalid SOCKS5 target port")
}
request := []byte{socks5Version, socks5ConnectCommand, 0x00}
if parsed := net.ParseIP(host); parsed != nil {
if ipv4 := parsed.To4(); ipv4 != nil {
request = append(request, socks5AddressIPv4)
request = append(request, ipv4...)
} else {
request = append(request, socks5AddressIPv6)
request = append(request, parsed.To16()...)
}
} else {
if len(host) > 255 {
return nil, errors.New("SOCKS5 target hostname is too long")
}
request = append(request, socks5AddressDomain, byte(len(host)))
request = append(request, host...)
}
encodedPort := make([]byte, 2)
binary.BigEndian.PutUint16(encodedPort, uint16(port))
return append(request, encodedPort...), nil
}
func readSOCKS5ConnectReply(reader io.Reader) error {
header := make([]byte, 4)
if _, err := io.ReadFull(reader, header); err != nil {
return err
}
if header[0] != socks5Version || header[1] != 0x00 || header[2] != 0x00 {
return fmt.Errorf("SOCKS5 CONNECT failed with status %d", header[1])
}
addressLength := 0
switch header[3] {
case socks5AddressIPv4:
addressLength = net.IPv4len
case socks5AddressIPv6:
addressLength = net.IPv6len
case socks5AddressDomain:
length := make([]byte, 1)
if _, err := io.ReadFull(reader, length); err != nil {
return err
}
addressLength = int(length[0])
if addressLength == 0 {
return errors.New("invalid SOCKS5 bound hostname")
}
default:
return errors.New("invalid SOCKS5 bound address type")
}
boundAddressAndPort := make([]byte, addressLength+2)
_, err := io.ReadFull(reader, boundAddressAndPort)
return err
}