proxy-pool/internal/checker/probe/probe.go
youfak 7740f6f14d
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
feat: report observed egress addresses
2026-08-02 08:46:10 +08:00

234 lines
8.4 KiB
Go

// Package probe executes one bounded Checker task and returns only a health
// fact. It has no Controller, Redis, or PostgreSQL dependency.
package probe
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strconv"
"strings"
"time"
controlplanev1 "proxy-pool/gen/controlplane/v1"
)
const (
FailureInvalidTask = "INVALID_TASK"
FailureDeadlineExceeded = "DEADLINE_EXCEEDED"
FailureProxyRequest = "PROXY_REQUEST"
FailureProxyAuth = "PROXY_AUTH"
FailureTargetHTTPStatus = "TARGET_HTTP_STATUS"
FailureEgressIdentity = "EGRESS_IDENTITY"
FailureUnsupportedProxy = "UNSUPPORTED_PROXY_PROTOCOL"
basicHandshakeProbeURL = "http://example.invalid/"
maximumEgressBodyBytes = 16 << 10
)
var errUnsupportedProtocol = errors.New("unsupported proxy protocol")
type Result struct {
Success bool
FailureClass string
ObservedEgressIP string
Latency time.Duration
}
// Executor constructs a short-lived HTTP transport for each task. That keeps
// credentials task-scoped and avoids keeping stale short-TTL proxy sessions
// alive after a task completes.
type Executor struct{}
func NewExecutor() *Executor { return &Executor{} }
// Execute reports protocol/authorization reachability for BASIC and requires
// a successful target response for TARGET. Every path returns one fact so a
// Checker can report failures without treating ordinary probe failures as
// control-plane errors.
func (executor *Executor) Execute(ctx context.Context, task *controlplanev1.CheckTask) Result {
started := time.Now()
if ctx == nil || executor == nil {
return Result{FailureClass: FailureInvalidTask}
}
deadline, target, proxyURL, err := prepare(task, started.UTC())
if err != nil {
if errors.Is(err, errUnsupportedProtocol) {
return Result{FailureClass: FailureUnsupportedProxy, Latency: probeLatency(started)}
}
return Result{FailureClass: FailureInvalidTask, Latency: probeLatency(started)}
}
probeContext, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
transport := &http.Transport{
DialContext: (&net.Dialer{}).DialContext,
ForceAttemptHTTP2: false,
TLSHandshakeTimeout: 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()
client := &http.Client{
Transport: transport,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
request, err := http.NewRequestWithContext(probeContext, http.MethodGet, target, nil)
if err != nil {
return Result{FailureClass: FailureInvalidTask, Latency: probeLatency(started)}
}
response, err := client.Do(request)
latency := probeLatency(started)
if err != nil {
if errors.Is(probeContext.Err(), context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
return Result{FailureClass: FailureDeadlineExceeded, Latency: latency}
}
if errors.Is(err, errSOCKS5Authentication) {
return Result{FailureClass: FailureProxyAuth, Latency: latency}
}
return Result{FailureClass: FailureProxyRequest, Latency: latency}
}
defer response.Body.Close()
if response.StatusCode == http.StatusProxyAuthRequired {
return Result{FailureClass: FailureProxyAuth, Latency: latency}
}
if task.GetLevel() == controlplanev1.CheckLevel_CHECK_LEVEL_BASIC {
return Result{Success: true, Latency: latency}
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusBadRequest {
return Result{FailureClass: FailureTargetHTTPStatus, Latency: latency}
}
if task.GetLevel() == controlplanev1.CheckLevel_CHECK_LEVEL_EGRESS {
body, err := io.ReadAll(io.LimitReader(response.Body, maximumEgressBodyBytes+1))
if err != nil || len(body) > maximumEgressBodyBytes {
return Result{FailureClass: FailureEgressIdentity, Latency: latency}
}
observedIP, ok := parseEgressIP(body)
if !ok {
return Result{FailureClass: FailureEgressIdentity, Latency: latency}
}
return Result{Success: true, ObservedEgressIP: observedIP, Latency: latency}
}
return Result{Success: true, Latency: latency}
}
func parseEgressIP(body []byte) (string, bool) {
if observed, ok := normalizeEgressIP(string(body)); ok {
return observed, true
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(body, &payload); err != nil {
return "", false
}
for _, key := range []string{"ip", "ip_address", "address", "origin", "query"} {
raw, exists := payload[key]
if !exists {
continue
}
var value string
if err := json.Unmarshal(raw, &value); err != nil {
continue
}
if observed, ok := normalizeEgressIP(value); ok {
return observed, true
}
}
return "", false
}
func normalizeEgressIP(value string) (string, bool) {
for _, item := range strings.FieldsFunc(strings.TrimSpace(value), func(character rune) bool {
return character == ',' || character == ';' || character == ' ' || character == '\t' || character == '\r' || character == '\n'
}) {
address, err := netip.ParseAddr(item)
if err == nil {
return address.Unmap().String(), true
}
}
return "", false
}
func prepare(task *controlplanev1.CheckTask, now time.Time) (time.Time, string, *url.URL, error) {
if task == nil || task.GetTaskId() == "" || task.GetProxyId() == "" || task.GetHost() == "" || task.GetPort() == 0 ||
task.GetTimeout() == nil || task.GetTimeout().CheckValid() != nil || task.GetTimeout().AsDuration() <= 0 ||
task.GetDeadline() == nil || task.GetDeadline().CheckValid() != nil || task.GetDeadline().AsTime().UTC().Compare(now) <= 0 ||
task.GetAttempt() == 0 || task.GetMaxAttempts() == 0 || task.GetAttempt() > task.GetMaxAttempts() ||
(task.GetSecretRef() == "") != (task.GetCredentialVersion() == "") {
return time.Time{}, "", nil, errors.New("invalid checker task")
}
proxyScheme := ""
switch task.GetProtocol() {
case controlplanev1.ProxyProtocol_PROXY_PROTOCOL_HTTP:
proxyScheme = "http"
case controlplanev1.ProxyProtocol_PROXY_PROTOCOL_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:
return time.Time{}, "", nil, errUnsupportedProtocol
}
deadline := task.GetDeadline().AsTime().UTC()
timeoutDeadline := now.Add(task.GetTimeout().AsDuration())
if timeoutDeadline.Before(deadline) {
deadline = timeoutDeadline
}
if !deadline.After(now) {
return time.Time{}, "", nil, errors.New("expired checker task")
}
proxyURL := &url.URL{Scheme: proxyScheme, Host: net.JoinHostPort(task.GetHost(), strconv.FormatUint(uint64(task.GetPort()), 10))}
if task.GetUsername() != "" || task.GetPassword() != "" {
proxyURL.User = url.UserPassword(task.GetUsername(), task.GetPassword())
}
target := basicHandshakeProbeURL
switch task.GetLevel() {
case controlplanev1.CheckLevel_CHECK_LEVEL_BASIC:
if task.GetRoutingName() != "" || task.GetTargetUrl() != "" {
return time.Time{}, "", nil, errors.New("invalid basic task")
}
case controlplanev1.CheckLevel_CHECK_LEVEL_TARGET:
if task.GetRoutingName() == "" {
return time.Time{}, "", nil, errors.New("target routing is required")
}
target = task.GetTargetUrl()
case controlplanev1.CheckLevel_CHECK_LEVEL_EGRESS:
if task.GetRoutingName() != "" || task.GetTargetUrl() == "" {
return time.Time{}, "", nil, errors.New("invalid egress task")
}
target = task.GetTargetUrl()
default:
return time.Time{}, "", nil, errors.New("unsupported task level")
}
parsedTarget, err := url.Parse(target)
if err != nil || parsedTarget.Scheme == "" || parsedTarget.Host == "" || parsedTarget.User != nil || parsedTarget.Fragment != "" ||
(parsedTarget.Scheme != "http" && parsedTarget.Scheme != "https") || strings.TrimSpace(task.GetHost()) != task.GetHost() {
return time.Time{}, "", nil, errors.New("invalid probe target")
}
return deadline, parsedTarget.String(), proxyURL, nil
}
func minDuration(left, right time.Duration) time.Duration {
if left <= 0 || left < right {
return left
}
return right
}
func probeLatency(started time.Time) time.Duration {
if latency := time.Since(started); latency > 0 {
return latency
}
return time.Nanosecond
}