diff --git a/.env.example b/.env.example index 24439ad..01bb94b 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,8 @@ PROXY_METRICS_PORT=9090 PROXY_HEALTH_PORT=8081 PROXY_SHARED_HEALTH_PORT=true PROXY_SHARED_METRICS_PORT=true + +# Proxy Configuration (optional) +# PROXY_URL=http://proxy.example.com:8080 +# PROXY_URL=socks5://proxy.example.com:1080 +# PROXY_AUTH=username:password diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index fab89d1..9673264 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -38,9 +38,9 @@ jobs: uses: docker/build-push-action@v5 with: context: . - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/386,linux/s390x,linux/riscv64,linux/ppc64le,linux/arm/v7 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + cache-to: type=gha,mode=max diff --git a/README.md b/README.md index 4d91409..4011c25 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ variables take precedence when both are provided. | `PROXY_HEADER_QUEUES` | `-header-queues` | X-Amz-Security-Token | Comma-separated headers for dedicated queues | | `PROXY_PERSISTENT_HEADERS` | `-persistent-headers` | (empty) | Persistent headers that cannot be overwritten 🔒 | | `PROXY_TIMEOUT` | `-timeout` | 0 | Request timeout in seconds (0 = infinite ⏳) | +| `PROXY_URL` | `-proxy-url` | (empty) | Proxy URL for TARGET_HOST connections 🌐 | +| `PROXY_AUTH` | `-proxy-auth` | (empty) | Proxy authentication (username:password) 🔐 | ### Persistent Headers 🔒 @@ -183,6 +185,64 @@ export PROXY_PERSISTENT_HEADERS="User-Agent:mobile,X-Custom:always-present" ./proxy-queue -target-host=api.example.com ``` +### Proxy Configuration 🌐 + +The proxy-queue can route all TARGET_HOST connections through an upstream proxy server. This is useful when your network requires proxy access to reach external services, or when you need to add an additional layer of routing. + +#### Supported Proxy Types + +| Proxy Type | URL Format | Features | +|---------------|------------------------------------|----------------------------------------------------| +| **HTTP** | `http://proxy.example.com:8080` | ✅ HTTP requests
❌ Socket connections | +| **HTTPS** | `https://proxy.example.com:8080` | ✅ HTTP requests
❌ Socket connections | +| **SOCKS5** | `socks5://proxy.example.com:1080` | ✅ HTTP requests
✅ Socket connections | + +#### Configuration Methods + +```bash +# Command line flags +./proxy-queue \ + -target-host=api.example.com \ + -proxy-url=socks5://proxy.company.com:1080 \ + -proxy-auth=username:password + +# Environment variables +export PROXY_URL=http://corporate-proxy:8080 +export PROXY_AUTH=myuser:mypass +./proxy-queue -target-host=api.example.com + +# Docker environment +docker run -e PROXY_URL=socks5://proxy:1080 -e PROXY_AUTH=user:pass proxy-queue +``` + +#### Authentication Support + +The proxy supports username/password authentication for all proxy types: + +```bash +# Include auth in URL (HTTP/HTTPS only) +export PROXY_URL=http://username:password@proxy.example.com:8080 + +# Separate auth parameter (recommended for security) +export PROXY_URL=socks5://proxy.example.com:1080 +export PROXY_AUTH=username:password +``` + +#### Security Features + +- **No credential logging**: Proxy authentication is never logged in plaintext +- **Fallback behavior**: If proxy connection fails, falls back to direct connection +- **Connection validation**: Invalid proxy URLs are detected and logged +- **Debug visibility**: Proxy usage is logged at debug level for troubleshooting + +#### Proxy vs Direct Connection + +| Connection Type | Proxy Required | Fallback Behavior | +|-----------------|----------------|--------------------------------------| +| **HTTP/HTTPS** | Optional | Falls back to direct connection | +| **Socket** | Optional | Falls back to direct connection | +| **Both** | Optional | Each connection type handles its own | + ### Header-Based Queue Routing 📤 The proxy supports routing requests to dedicated queues based on specific HTTP headers. This is particularly diff --git a/build/proxy-queue-darwin-amd64 b/build/proxy-queue-darwin-amd64 index 03447b6..cc55fa4 100755 Binary files a/build/proxy-queue-darwin-amd64 and b/build/proxy-queue-darwin-amd64 differ diff --git a/build/proxy-queue-darwin-arm64 b/build/proxy-queue-darwin-arm64 index ce7a78c..642d7e3 100755 Binary files a/build/proxy-queue-darwin-arm64 and b/build/proxy-queue-darwin-arm64 differ diff --git a/build/proxy-queue-linux-amd64 b/build/proxy-queue-linux-amd64 index 73b556d..218dd78 100755 Binary files a/build/proxy-queue-linux-amd64 and b/build/proxy-queue-linux-amd64 differ diff --git a/build/proxy-queue-linux-arm64 b/build/proxy-queue-linux-arm64 index 3990017..347f25c 100755 Binary files a/build/proxy-queue-linux-arm64 and b/build/proxy-queue-linux-arm64 differ diff --git a/build/proxy-queue-windows-amd64.exe b/build/proxy-queue-windows-amd64.exe index 08685eb..ee0b1b2 100755 Binary files a/build/proxy-queue-windows-amd64.exe and b/build/proxy-queue-windows-amd64.exe differ diff --git a/build/proxy-queue-windows-arm64.exe b/build/proxy-queue-windows-arm64.exe index d1dd01a..59a6f3e 100755 Binary files a/build/proxy-queue-windows-arm64.exe and b/build/proxy-queue-windows-arm64.exe differ diff --git a/docker-compose.yml b/docker-compose.yml index e9650db..6e0d633 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: proxy-queue: - image: thanhlvcom/proxy-queue:latest + image: thanhlvcom/proxy-queue:v1.2.0 ports: - "6789:6789" # HTTP/HTTPS proxy - "6799:6799" # Socket proxy @@ -23,9 +23,13 @@ services: - PROXY_HEADER_QUEUES=X-Amz-Security-Token,Authorization - PROXY_PERSISTENT_HEADERS=x-token-test:token-1234 - PROXY_LOG_LEVEL=info + # Optional proxy configuration (uncomment and configure as needed) + # - PROXY_URL=http://corporate-proxy:8080 + # - PROXY_URL=socks5://proxy.company.com:1080 + # - PROXY_AUTH=username:password restart: always healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:6789/health"] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8081/health"] interval: 30s timeout: 10s retries: 3 diff --git a/go.mod b/go.mod index b22c0f3..780c20a 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.7 require ( github.com/prometheus/client_golang v1.23.2 github.com/sirupsen/logrus v1.9.3 + golang.org/x/net v0.43.0 ) require ( diff --git a/go.sum b/go.sum index 89ab2f9..2122c78 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/main.go b/main.go index 86d11f6..2ed86a0 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/sirupsen/logrus" + "golang.org/x/net/proxy" ) type Config struct { @@ -39,6 +40,8 @@ type Config struct { HeaderQueues []string // Headers to create separate queues for (e.g., ["X-Amz-Security-Token", "X-Amz-Content-Sha256"]) PersistentHeaders map[string]string // Headers that are always added and cannot be overwritten by clients (e.g., {"User-Agent": "mobile"}) Timeout time.Duration // Request timeout (0 = infinite) + ProxyURL string // Proxy URL (e.g., "http://proxy:8080", "socks5://proxy:1080") + ProxyAuth string // Proxy authentication in format "username:password" } type ProxyRequest struct { @@ -612,6 +615,8 @@ func main() { headerQueues = flag.String("header-queues", "X-Amz-Security-Token", "Comma-separated list of headers to create separate queues for (e.g., 'X-Amz-Security-Token,Authorization')") persistentHeaders = flag.String("persistent-headers", "", "Comma-separated list of headers to always add (format: 'key1:value1,key2:value2')") timeout = flag.Int("timeout", 0, "Request timeout in seconds (0 = infinite ⏳)") + proxyURL = flag.String("proxy-url", "", "Proxy URL for TARGET_HOST connections (e.g., 'http://proxy:8080', 'socks5://proxy:1080')") + proxyAuth = flag.String("proxy-auth", "", "Proxy authentication in format 'username:password'") ) flag.Parse() @@ -637,6 +642,8 @@ func main() { HeaderQueues: headerQueuesList, PersistentHeaders: persistentHeadersMap, Timeout: time.Duration(getIntFromEnvOrFlag("PROXY_TIMEOUT", timeout, "timeout", 0)) * time.Second, + ProxyURL: getStringFromEnvOrFlag("PROXY_URL", proxyURL, "proxy-url", ""), + ProxyAuth: getStringFromEnvOrFlag("PROXY_AUTH", proxyAuth, "proxy-auth", ""), } ctx, cancel := context.WithCancel(context.Background()) @@ -662,6 +669,8 @@ func main() { "header_queues": config.HeaderQueues, "persistent_headers": config.PersistentHeaders, "timeout": config.Timeout, + "proxy_url": config.ProxyURL, + "proxy_auth_set": config.ProxyAuth != "", }, "timestamp": time.Now().UTC().Format(time.RFC3339Nano), }).Debug("🚀 Proxy Queue Manager Configuration Loaded") @@ -713,6 +722,8 @@ func main() { "header_queues": config.HeaderQueues, "persistent_headers": config.PersistentHeaders, "timeout": config.Timeout, + "proxy_url": config.ProxyURL, + "proxy_auth_set": config.ProxyAuth != "", "timestamp": time.Now().UTC().Format(time.RFC3339Nano), }).Info("🎯 Proxy server started and ready to accept connections") @@ -798,6 +809,115 @@ func startHealthServer(queueManager *QueueManager, config *Config) { } } +func (pq *ProxyQueue) createDialerWithProxy() func(network, address string) (net.Conn, error) { + if pq.config.ProxyURL == "" { + return net.Dial + } + + proxyURL, err := url.Parse(pq.config.ProxyURL) + if err != nil { + pq.logger.WithFields(logrus.Fields{ + "proxy_url": pq.config.ProxyURL, + "error": err, + }).Error("Failed to parse proxy URL for socket connection, using direct connection") + return net.Dial + } + + switch proxyURL.Scheme { + case "http", "https": + pq.logger.Warn("HTTP proxy not supported for socket connections, using direct connection") + return net.Dial + + case "socks5": + var auth *proxy.Auth + if pq.config.ProxyAuth != "" { + parts := strings.SplitN(pq.config.ProxyAuth, ":", 2) + if len(parts) == 2 { + auth = &proxy.Auth{ + User: parts[0], + Password: parts[1], + } + } + } + + dialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct) + if err != nil { + pq.logger.WithFields(logrus.Fields{ + "proxy_url": pq.config.ProxyURL, + "error": err, + }).Error("Failed to create SOCKS5 proxy dialer for socket connection, using direct connection") + return net.Dial + } + + pq.logger.WithField("proxy_url", pq.config.ProxyURL).Debug("🧦 Using SOCKS5 proxy for socket connection") + return dialer.Dial + + default: + pq.logger.WithField("proxy_scheme", proxyURL.Scheme).Warn("Unsupported proxy scheme for socket connection, using direct connection") + return net.Dial + } +} + +func (pq *ProxyQueue) createTransportWithProxy() *http.Transport { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + + if pq.config.ProxyURL != "" { + proxyURL, err := url.Parse(pq.config.ProxyURL) + if err != nil { + pq.logger.WithFields(logrus.Fields{ + "proxy_url": pq.config.ProxyURL, + "error": err, + }).Error("Failed to parse proxy URL, using direct connection") + return transport + } + + switch proxyURL.Scheme { + case "http", "https": + if pq.config.ProxyAuth != "" { + parts := strings.SplitN(pq.config.ProxyAuth, ":", 2) + if len(parts) == 2 { + proxyURL.User = url.UserPassword(parts[0], parts[1]) + } + } + transport.Proxy = http.ProxyURL(proxyURL) + pq.logger.WithField("proxy_url", pq.config.ProxyURL).Debug("🌐 Using HTTP(S) proxy") + + case "socks5": + var auth *proxy.Auth + if pq.config.ProxyAuth != "" { + parts := strings.SplitN(pq.config.ProxyAuth, ":", 2) + if len(parts) == 2 { + auth = &proxy.Auth{ + User: parts[0], + Password: parts[1], + } + } + } + + dialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct) + if err != nil { + pq.logger.WithFields(logrus.Fields{ + "proxy_url": pq.config.ProxyURL, + "error": err, + }).Error("Failed to create SOCKS5 proxy dialer, using direct connection") + return transport + } + + transport.Dial = dialer.Dial + pq.logger.WithField("proxy_url", pq.config.ProxyURL).Debug("🧦 Using SOCKS5 proxy") + + default: + pq.logger.WithField("proxy_scheme", proxyURL.Scheme).Warn("Unsupported proxy scheme, using direct connection") + } + } + + return transport +} + func (pq *ProxyQueue) getHealthStatus() map[string]interface{} { pq.mu.RLock() running := pq.running @@ -886,12 +1006,8 @@ func (pq *ProxyQueue) processHTTPRequest(req ProxyRequest) { } client := &http.Client{ - Timeout: timeout, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - }, + Timeout: timeout, + Transport: pq.createTransportWithProxy(), } // Forward the request @@ -1176,7 +1292,39 @@ func (pq *ProxyQueue) processSocketRequest(req ProxyRequest) { connectionStartTime := time.Now() - targetConn, err := net.DialTimeout("tcp", targetAddr, socketTimeout) + // Use proxy-aware dialer + dialer := pq.createDialerWithProxy() + + // Create a context with timeout for the connection + ctx, cancel := context.WithTimeout(context.Background(), socketTimeout) + defer cancel() + + // Channel to handle the connection result + connChan := make(chan net.Conn, 1) + errChan := make(chan error, 1) + + go func() { + conn, err := dialer("tcp", targetAddr) + if err != nil { + errChan <- err + } else { + connChan <- conn + } + }() + + var targetConn net.Conn + var err error + + select { + case targetConn = <-connChan: + // Connection successful + case err = <-errChan: + // Connection failed + case <-ctx.Done(): + // Timeout + err = fmt.Errorf("connection timeout after %v", socketTimeout) + } + connectionTime := time.Since(connectionStartTime) if err != nil { pq.logger.WithFields(logrus.Fields{