DEV Community

Cover image for How to Build an HTTP/HTTPS + SOCKS5 Proxy in Go (with Logging, Metrics & Docker)
Silver_dev
Silver_dev

Posted on

How to Build an HTTP/HTTPS + SOCKS5 Proxy in Go (with Logging, Metrics & Docker)

I recently needed to build a proxy server, and Go was a natural choice for the job — its standard library and lightweight goroutines make network services a joy to write. In this post, I'll walk through the whole process step by step: from the first prototype to a Dockerized setup with logging and Prometheus metrics.

What We're Building

The proxy accepts two types of connections:

  1. HTTP/HTTPS forward proxy — built on net/http and httputil.ReverseProxy (for regular requests), plus manual handling of the CONNECT method (for HTTPS tunnels).
  2. SOCKS5 proxy — using the popular github.com/armon/go-socks5 library (golang.org/x/net/proxy is client-side only, so a server implementation would have to be written from scratch).

By the end, we'll have a single binary that runs both proxies, logs every request, exposes Prometheus metrics, and ships with a docker-compose.yml that brings up Prometheus and Grafana alongside it.

Prerequisites: a recent version of Go (1.25 was used here), curl for testing, and Docker if you want the containerized setup.

Step 1: The HTTP/HTTPS Forward Proxy

First, I sketched out the code for the HTTP/HTTPS forward proxy:

package main

import (
    "crypto/tls"
    "fmt"
    "io"
    "net"
    "net/http"
    "net/http/httputil"
    "net/url"
    "strings"
)

// HTTPProxy holds the proxy configuration (can be extended later)
type HTTPProxy struct {
    transport *http.Transport
}

// NewHTTPProxy creates a new proxy instance with a custom transport
func NewHTTPProxy() *HTTPProxy {
    return &HTTPProxy{
        transport: &http.Transport{
            // InsecureSkipVerify is for testing only — remove in production
            TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
            Proxy:           http.ProxyFromEnvironment,
        },
    }
}

// ServeHTTP implements http.Handler
func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // If this is a CONNECT request, handle the HTTPS tunnel
    if r.Method == http.MethodConnect {
        p.handleTunnel(w, r)
        return
    }

    // For regular HTTP requests, use ReverseProxy
    p.handleHTTP(w, r)
}

// handleTunnel establishes a tunnel for CONNECT (HTTPS) requests
func (p *HTTPProxy) handleTunnel(w http.ResponseWriter, r *http.Request) {
    // Target host from the request (e.g., "example.com:443")
    target := r.Host
    if !strings.Contains(target, ":") {
        target += ":443" // default to 443 if no port is specified
    }

    // Connect to the target server
    destConn, err := net.Dial("tcp", target)
    if err != nil {
        http.Error(w, err.Error(), http.StatusServiceUnavailable)
        return
    }
    defer destConn.Close()

    // Reply to the client with "200 Connection Established"
    w.WriteHeader(http.StatusOK)
    hijacker, ok := w.(http.Hijacker)
    if !ok {
        http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
        return
    }
    clientConn, _, err := hijacker.Hijack()
    if err != nil {
        http.Error(w, err.Error(), http.StatusServiceUnavailable)
        return
    }
    defer clientConn.Close()

    // Copy data in both directions (client <-> target server)
    go io.Copy(destConn, clientConn)
    io.Copy(clientConn, destConn)
}

// handleHTTP uses ReverseProxy for regular HTTP requests
func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
    // Build the target URL from the request.
    // For a forward proxy, the request URL contains the full address 
    //(e.g., http://example.com/foo)
    targetURL, err := url.Parse(r.URL.String())
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    // If no scheme is specified, infer it from the port (usually 80)
    if targetURL.Scheme == "" {
        if r.URL.Port() == "443" {
            targetURL.Scheme = "https"
        } else {
            targetURL.Scheme = "http"
        }
    }

    // Create a ReverseProxy with a custom Director that rewrites the request
    proxy := &httputil.ReverseProxy{
        Director: func(req *http.Request) {
            // Forward to the target host
            req.URL.Scheme = targetURL.Scheme
            req.URL.Host = targetURL.Host
            req.URL.Path = targetURL.Path
            req.URL.RawQuery = targetURL.RawQuery
            req.Host = targetURL.Host // important for virtual hosts
            // Drop proxy-specific headers (if needed)
            req.Header.Del("Proxy-Connection")
        },
        Transport: p.transport,
    }

    // Serve the request
    proxy.ServeHTTP(w, r)
}

func main() {
    proxy := NewHTTPProxy()
    server := &http.Server{
        Addr:    ":8080",
        Handler: proxy,
    }

    fmt.Println("HTTP/HTTPS forward proxy is running on :8080")
    if err := server.ListenAndServe(); err != nil {
        fmt.Println("Error:", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

For CONNECT requests, the proxy hijacks the connection and pipes raw bytes in both directions, so the encrypted TLS session passes through untouched.

Step 2: The SOCKS5 Proxy

Next, the SOCKS5 proxy (using github.com/armon/go-socks5):

package main

import (
    "fmt"
    "log"
    "net"

    "github.com/armon/go-socks5"
)

func main() {
    // Create the SOCKS5 configuration (authentication, logging, etc. can be added here)
    conf := &socks5.Config{
        // You can plug in a custom Dialer here, e.g., to enforce restrictions
        // Logger: log.New(os.Stdout, "socks5: ", log.LstdFlags),
    }

    // Create the server
    server, err := socks5.New(conf)
    if err != nil {
        log.Fatalf("Failed to create SOCKS5 server: %v", err)
    }

    // Start listening on port 1080
    addr := ":1080"
    listener, err := net.Listen("tcp", addr)
    if err != nil {
        log.Fatalf("Failed to start listener: %v", err)
    }

    fmt.Printf("SOCKS5 proxy is running on %s\n", addr)
    if err := server.Serve(listener); err != nil {
        log.Fatalf("Server error: %v", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Running and testing

Everything works nicely. Launch it with:

// The proxy will listen on http://localhost:8080
go run http_proxy.go

// The proxy will listen on socks5://localhost:1080
go run socks5_proxy.go
Enter fullscreen mode Exit fullscreen mode

Testing it is straightforward:

# HTTP request
curl -x http://localhost:8080 http://example.com

# HTTPS request (via CONNECT)
curl -x http://localhost:8080 https://example.com

# SOCKS5 proxy
curl -x socks5://localhost:1080 https://example.com
Enter fullscreen mode Exit fullscreen mode

Putting it all together

Now let's run both proxies from a single process. The project structure:

main.go
http_proxy.go
socks5_proxy.go
Enter fullscreen mode Exit fullscreen mode

The entry point uses a cancellable context for graceful shutdown:

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer cancel()

    g, ctx := errgroup.WithContext(ctx)

    g.Go(func() error { return runSocks5(ctx) })
    g.Go(func() error { return runHTTPProxy(ctx, ":8080") })

    if err := g.Wait(); err != nil && err != context.Canceled {
        log.Fatalf("Proxies stopped with an error: %v", err)
    }
    log.Println("Proxies shut down")
}
Enter fullscreen mode Exit fullscreen mode

errgroup runs both proxies concurrently and tears everything down cleanly on SIGINT/SIGTERM.

Adding Logging

Great — now let's add logging for the SOCKS5 proxy. The go-socks5 library has built-in support for this: the Logger field in socks5.Config. You can pass in any logger that satisfies the expected interface — in practice, anything with a Printf method, such as *log.Logger (if you're on slog, wrap it with slog.NewLogLogger).

func runSocks5(ctx context.Context) error {
    // Create a logger with the desired prefix and flags
    logger := log.New(os.Stdout, "[SOCKS5] ", log.LstdFlags|log.Lshortfile)

    conf := &socks5.Config{
        Logger: logger, // Pass in the logger
    }
    server, err := socks5.New(conf)
    if err != nil {
        return fmt.Errorf("creating SOCKS5: %w", err)
    }

    // ... the rest of the code as before
}
Enter fullscreen mode Exit fullscreen mode

And the same for the HTTP proxy:

func runHTTPProxy(ctx context.Context, addr string) error {
    proxy := NewHTTPProxy()
    logger := log.New(os.Stdout, "[HTTP] ", log.LstdFlags|log.Lshortfile)

    server := &http.Server{
        Addr:     addr,
        Handler:  proxy,
        ErrorLog: logger,
    }
    // ... the rest of the code as before
}
Enter fullscreen mode Exit fullscreen mode

http.Server.ErrorLog covers server-level errors (TLS handshakes, Accept failures, panics), while socks5.Config.Logger logs protocol errors, authentication issues, and read/write failures.

Now let's log a bit more detail: the method, URL, and client IP. Add this to ServeHTTP

func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Log every request
    log.Printf("[HTTP] %s %s from %s", r.Method, r.URL.String(), r.RemoteAddr)

    if r.Method == http.MethodConnect {
        p.handleTunnel(w, r)
        return
    }
    p.handleHTTP(w, r)
}
Enter fullscreen mode Exit fullscreen mode

One caveat: if your proxy runs behind a load balancer or another proxy, r.RemoteAddr will show the address of the previous hop. In that case, use the X-Forwarded-For header instead:

clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
    clientIP = r.RemoteAddr
}
log.Printf("[HTTP] %s %s from %s", r.Method, r.URL.String(), clientIP)
Enter fullscreen mode Exit fullscreen mode

Next, let's add proper middleware that logs the response status, processing time, and response size. For this we need a couple of wrapper types:

type loggingResponseWriter struct {
    http.ResponseWriter
    statusCode int
    size       int
}

func newLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
    return &loggingResponseWriter{
        ResponseWriter: w,
        statusCode:     http.StatusOK, // default to 200 unless explicitly set
    }
}

func (lrw *loggingResponseWriter) WriteHeader(code int) {
    lrw.statusCode = code
    lrw.ResponseWriter.WriteHeader(code)
}

func (lrw *loggingResponseWriter) Write(b []byte) (int, error) {
    n, err := lrw.ResponseWriter.Write(b)
    lrw.size += n
    return n, err
}

// Hijack is required to support CONNECT tunnels
func (lrw *loggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
    if hijacker, ok := lrw.ResponseWriter.(http.Hijacker); ok {
        return hijacker.Hijack()
    }
    return nil, nil, fmt.Errorf("ResponseWriter does not support Hijack")
}
Enter fullscreen mode Exit fullscreen mode

The final changes:

func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    start := time.Now()

    // Wrap the ResponseWriter for logging
    lrw := newLoggingResponseWriter(w)

    // Handle the request through the existing methods
    if r.Method == http.MethodConnect {
        p.handleTunnel(lrw, r)
    } else {
        p.handleHTTP(lrw, r)
    }

    // Measure the duration and log it
    duration := time.Since(start)
    clientIP := r.RemoteAddr
    if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
        clientIP = forwarded
    }
    // Use the standard log or your own logger
    log.Printf("[HTTP] %s %s from %s — status: %d, size: %d bytes, duration: %v",
        r.Method, r.URL.String(), clientIP, lrw.statusCode, lrw.size, duration)
}
Enter fullscreen mode Exit fullscreen mode

Sample output:

[HTTP] GET http://detectportal.firefox.com/canonical.html from 127.0.0.1:12495 — status: 200, size: 90 bytes, duration: 157.5029ms
[HTTP] GET http://detectportal.firefox.com/success.txt?ipv4 from 127.0.0.1:12497 — status: 200, size: 8 bytes, duration: 143.6892ms
[HTTP] GET http://detectportal.firefox.com/success.txt?ipv6 from 127.0.0.1:12498 — status: 200, size: 8 bytes, duration: 151.2498ms
Enter fullscreen mode Exit fullscreen mode

Let's add similar logging for SOCKS5, so we can see the command (CONNECT, BIND, UDP), the target address, the client IP, the session duration, and the status (success/failure).

Here's what we add:

type loggingListener struct {
    net.Listener
    logger *log.Logger
}

func (l loggingListener) Accept() (net.Conn, error) {
    conn, err := l.Listener.Accept()
    if err != nil {
        return nil, err
    }
    clientAddr := conn.RemoteAddr().String()
    l.logger.Printf("Accepted connection from %s", clientAddr)
    // Store the start time in the wrapper
    return &loggingConn{
        Conn:       conn,
        logger:     l.logger,
        clientAddr: clientAddr,
        startTime:  time.Now(),
    }, nil
}

type loggingConn struct {
    net.Conn
    logger     *log.Logger
    clientAddr string
    startTime  time.Time
}

func (c *loggingConn) Close() error {
    err := c.Conn.Close()
    duration := time.Since(c.startTime)
    c.logger.Printf("Connection from %s closed, total duration: %v", c.clientAddr, duration)
    return err
}
Enter fullscreen mode Exit fullscreen mode

And the full runSocks5 with logging wired in:

func runSocks5(ctx context.Context) error {
    logger := log.New(os.Stdout, "[SOCKS5] ", log.LstdFlags)

    rawListener, err := net.Listen("tcp", ":1080")
    if err != nil {
        return fmt.Errorf("SOCKS5 listener: %w", err)
    }

    // Logging listener
    listener := loggingListener{
        Listener: rawListener,
        logger:   logger,
    }

    conf := &socks5.Config{
        Logger: logger,
        Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
            // Log the target address and connection setup time
            start := time.Now()
            conn, err := net.Dial(network, addr)
            duration := time.Since(start)
            if err != nil {
                logger.Printf("Dial to %s failed: %v (duration: %v)", addr, err, duration)
                return nil, err
            }
            logger.Printf("Dial to %s succeeded (duration: %v)", addr, duration)
            return conn, nil
        },
    }

    server, err := socks5.New(conf)
    if err != nil {
        return fmt.Errorf("creating SOCKS5: %w", err)
    }

    errCh := make(chan error, 1)
    go func() {
        errCh <- server.Serve(listener)
    }()

    logger.Printf("SOCKS5 is running on :1080")

    select {
    case <-ctx.Done():
        rawListener.Close()
        <-errCh
        logger.Printf("SOCKS5 stopped")
        return ctx.Err()
    case err := <-errCh:
        return err
    }
}
Enter fullscreen mode Exit fullscreen mode

Sample output:

[SOCKS5] 2025/03/15 12:34:56 Accepted connection from 192.168.1.100:54321
[SOCKS5] 2025/03/15 12:34:56 Dial to 2.23.167.41:443 succeeded (duration: 40.6345ms)
[SOCKS5] 2025/03/15 12:35:10 Connection from 192.168.1.100:54321 closed, total duration: 14s
Enter fullscreen mode Exit fullscreen mode

Adding metrics

Let's add a /metrics endpoint with a single metric — the number of active TCP connections (covering both HTTP and SOCKS5). That gives us basic load monitoring. We'll create a metrics.go file and add github.com/prometheus/client_golang to the project.

In metrics.go:

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
    "net"
    "sync/atomic"
)

var (
    activeConnections = prometheus.NewGauge(prometheus.GaugeOpts{
        Name: "proxy_active_connections",
        Help: "Current number of active TCP connections",
    })
)

func init() {
    prometheus.MustRegister(activeConnections)
}

// metricsListener is a wrapper that counts active connections
type metricsListener struct {
    net.Listener
}

func (ml metricsListener) Accept() (net.Conn, error) {
    conn, err := ml.Listener.Accept()
    if err != nil {
        return nil, err
    }
    activeConnections.Inc()
    return &metricsConn{Conn: conn}, nil
}

type metricsConn struct {
    net.Conn
}

func (mc *metricsConn) Close() error {
    err := mc.Conn.Close()
    activeConnections.Dec()
    return err
}

func runMetrics(ctx context.Context) error {
    mux := http.NewServeMux()
    mux.Handle("/metrics", promhttp.Handler())
    server := &http.Server{
        Addr:    ":9090",
        Handler: mux,
    }

    errCh := make(chan error, 1)
    go func() {
        errCh <- server.ListenAndServe()
    }()

    select {
    case <-ctx.Done():
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        server.Shutdown(shutdownCtx)
        return ctx.Err()
    case err := <-errCh:
        return err
    }
}
Enter fullscreen mode Exit fullscreen mode

Integrating into runHTTPProxy:

func runHTTPProxy(ctx context.Context, addr string) error {
    logger := log.New(os.Stdout, "[HTTP] ", log.LstdFlags)

    rawListener, err := net.Listen("tcp", addr)
    if err != nil {
        return err
    }
    listener := metricsListener{Listener: rawListener}

    proxy := NewHTTPProxy()
    server := &http.Server{
        Handler:  proxy,
        ErrorLog: logger,
    }

    errCh := make(chan error, 1)
    go func() {
        errCh <- server.Serve(listener)
    }()

    select {
    case <-ctx.Done():
        // graceful shutdown
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        server.Shutdown(shutdownCtx)
        return ctx.Err()
    case err := <-errCh:
        return err
    }
}
Enter fullscreen mode Exit fullscreen mode

Integrating into runSocks5 — here we also use metricsListener instead of loggingListener:

func runSocks5(ctx context.Context) error {
    logger := log.New(os.Stdout, "[SOCKS5] ", log.LstdFlags)

    rawListener, err := net.Listen("tcp", ":1080")
    if err != nil {
        return err
    }

    listener := metricsListener{Listener: rawListener}

    conf := &socks5.Config{
        Logger: logger,
        Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
            // Log the target address and connection setup time
            start := time.Now()
            conn, err := net.Dial(network, addr)
            duration := time.Since(start)
            if err != nil {
                logger.Printf("Dial to %s failed: %v (duration: %v)", addr, err, duration)
                return nil, err
            }
            logger.Printf("Dial to %s succeeded (duration: %v)", addr, duration)
            return conn, nil
        },
    }

    server, err := socks5.New(conf)
    if err != nil {
        return err
    }

    errCh := make(chan error, 1)
    go func() {
        errCh <- server.Serve(listener)
    }()

    select {
    case <-ctx.Done():
        rawListener.Close()
        <-errCh
        logger.Printf("SOCKS5 stopped")
        return ctx.Err()
    case err := <-errCh:
        return err
    }
}
Enter fullscreen mode Exit fullscreen mode

And the /metrics server itself:

func runMetrics(ctx context.Context) error {
    mux := http.NewServeMux()
    mux.Handle("/metrics", promhttp.Handler())
    server := &http.Server{
        Addr:    ":9090",
        Handler: mux,
    }

    errCh := make(chan error, 1)
    go func() {
        errCh <- server.ListenAndServe()
    }()

    select {
    case <-ctx.Done():
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        server.Shutdown(shutdownCtx)
        return ctx.Err()
    case err := <-errCh:
        return err
    }
}
Enter fullscreen mode Exit fullscreen mode

We add a third goroutine in main:

g.Go(func() error { return runMetrics(ctx) })
Enter fullscreen mode Exit fullscreen mode

Containerizing with Docker

The last piece is the Dockerfile:

FROM golang:1.25-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -o proxy .

FROM alpine:latest
RUN apk --no-cache add ca-certificates && \
    addgroup -S appgroup && \
    adduser -S appuser -G appgroup

WORKDIR /app
COPY --from=builder /app/proxy .
RUN chown appuser:appgroup /app/proxy && chmod 755 /app/proxy

EXPOSE 8080 1080 9090

USER appuser

CMD ["./proxy"]
Enter fullscreen mode Exit fullscreen mode

And a docker-compose.yml that brings up the proxy alongside Prometheus and Grafana:

version: '3.8'

services:
  proxy:
    build: .
    container_name: go-proxy
    ports:
      - "8080:8080"   # HTTP proxy
      - "1080:1080"   # SOCKS5
      - "9090:9090"   # metrics
    restart: unless-stopped
    networks:
      - monitoring

  prometheus:
    image: prom/prometheus:latest
    container_name: prometheus
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    ports:
      - "9091:9090"   # We forward it to a different port to avoid conflicts with proxy metrics.
    restart: unless-stopped
    networks:
      - monitoring
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'

  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
      - grafana_data:/var/lib/grafana
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    ports:
      - "3000:3000"
    restart: unless-stopped
    networks:
      - monitoring

networks:
  monitoring:

volumes:
  prometheus_data:
  grafana_data:
Enter fullscreen mode Exit fullscreen mode

Final files:

main.go:

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer cancel()

    g, ctx := errgroup.WithContext(ctx)

    g.Go(func() error { return runSocks5(ctx) })
    g.Go(func() error { return runHTTPProxy(ctx, ":8080") })
    g.Go(func() error { return runMetrics(ctx) })

    if err := g.Wait(); err != nil && err != context.Canceled {
        log.Fatalf("Proxies stopped with an error: %v", err)
    }
    log.Println("Proxies shut down")
}
Enter fullscreen mode Exit fullscreen mode

http_proxy.go:

type loggingResponseWriter struct {
    http.ResponseWriter
    statusCode int
    size       int
}

func newLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
    return &loggingResponseWriter{
        ResponseWriter: w,
        statusCode:     http.StatusOK,
    }
}

func (lrw *loggingResponseWriter) WriteHeader(code int) {
    lrw.statusCode = code
    lrw.ResponseWriter.WriteHeader(code)
}

func (lrw *loggingResponseWriter) Write(b []byte) (int, error) {
    n, err := lrw.ResponseWriter.Write(b)
    lrw.size += n
    return n, err
}

func (lrw *loggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
    if hijacker, ok := lrw.ResponseWriter.(http.Hijacker); ok {
        return hijacker.Hijack()
    }
    return nil, nil, fmt.Errorf("ResponseWriter does not support Hijack")
}

type HTTPProxy struct {
    transport *http.Transport
}

func NewHTTPProxy() *HTTPProxy {
    return &HTTPProxy{
        transport: &http.Transport{
            TLSClientConfig: &tls.Config{},
            Proxy:           http.ProxyFromEnvironment,
        },
    }
}

func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    start := time.Now()

    lrw := newLoggingResponseWriter(w)

    if r.Method == http.MethodConnect {
        p.handleTunnel(lrw, r)
    } else {
        p.handleHTTP(lrw, r)
    }

    duration := time.Since(start)
    clientIP := r.RemoteAddr
    if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
        clientIP = forwarded
    }
    log.Printf("[HTTP] %s %s from %s — status: %d, size: %d bytes, duration: %v",
        r.Method, r.URL.String(), clientIP, lrw.statusCode, lrw.size, duration)
}

func (p *HTTPProxy) handleTunnel(w http.ResponseWriter, r *http.Request) {
    target := r.Host
    if !strings.Contains(target, ":") {
        target += ":443" // default to 443 if no port is specified
    }

    destConn, err := net.Dial("tcp", target)
    if err != nil {
        http.Error(w, err.Error(), http.StatusServiceUnavailable)
        return
    }
    defer destConn.Close()

    w.WriteHeader(http.StatusOK)
    hijacker, ok := w.(http.Hijacker)
    if !ok {
        http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
        return
    }
    clientConn, _, err := hijacker.Hijack()
    if err != nil {
        http.Error(w, err.Error(), http.StatusServiceUnavailable)
        return
    }
    defer clientConn.Close()

    go io.Copy(destConn, clientConn)
    io.Copy(clientConn, destConn)
}

func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
    targetURL, err := url.Parse(r.URL.String())
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    if targetURL.Scheme == "" {
        if r.URL.Port() == "443" {
            targetURL.Scheme = "https"
        } else {
            targetURL.Scheme = "http"
        }
    }

    proxy := &httputil.ReverseProxy{
        Director: func(req *http.Request) {
            req.URL.Scheme = targetURL.Scheme
            req.URL.Host = targetURL.Host
            req.URL.Path = targetURL.Path
            req.URL.RawQuery = targetURL.RawQuery
            req.Host = targetURL.Host
            req.Header.Del("Proxy-Connection")
        },
        Transport: p.transport,
    }

    proxy.ServeHTTP(w, r)
}

func runHTTPProxy(ctx context.Context, addr string) error {
    logger := log.New(os.Stdout, "[HTTP] ", log.LstdFlags)

    rawListener, err := net.Listen("tcp", addr)
    if err != nil {
        return err
    }
    listener := metricsListener{Listener: rawListener}

    proxy := NewHTTPProxy()
    server := &http.Server{
        Handler:  proxy,
        ErrorLog: logger,
    }

    errCh := make(chan error, 1)
    go func() {
        errCh <- server.Serve(listener)
    }()

    select {
    case <-ctx.Done():
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        server.Shutdown(shutdownCtx)
        return ctx.Err()
    case err := <-errCh:
        return err
    }
}
Enter fullscreen mode Exit fullscreen mode

socks5_proxy.go:

type contextKey string

const (
    startTimeKey  contextKey = "start_time"
    clientAddrKey contextKey = "client_addr"
    cmdKey        contextKey = "command"
)

type loggingListener struct {
    net.Listener
    logger *log.Logger
}

func (l loggingListener) Accept() (net.Conn, error) {
    conn, err := l.Listener.Accept()
    if err != nil {
        return nil, err
    }

    clientAddr := conn.RemoteAddr().String()
    l.logger.Printf("Accepted connection from %s", clientAddr)

    ctx := context.WithValue(context.Background(), startTimeKey, time.Now())
    ctx = context.WithValue(ctx, clientAddrKey, clientAddr)

    return &loggingConn{
        Conn:   conn,
        ctx:    ctx,
        logger: l.logger,
    }, nil
}

type loggingConn struct {
    net.Conn
    ctx    context.Context
    logger *log.Logger
}

func (c *loggingConn) Close() error {
    err := c.Conn.Close()
    start, ok := c.ctx.Value(startTimeKey).(time.Time)
    if ok {
        duration := time.Since(start)
        clientAddr, _ := c.ctx.Value(clientAddrKey).(string)
        c.logger.Printf("Connection closed from %s, duration: %v", clientAddr, duration)
    }
    return err
}

func runSocks5(ctx context.Context) error {
    logger := log.New(os.Stdout, "[SOCKS5] ", log.LstdFlags)

    rawListener, err := net.Listen("tcp", ":1080")
    if err != nil {
        return err
    }

    listener := metricsListener{Listener: rawListener}

    conf := &socks5.Config{
        Logger: logger,
        Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
            start := time.Now()
            conn, err := net.Dial(network, addr)
            duration := time.Since(start)
            if err != nil {
                logger.Printf("Dial to %s failed: %v (duration: %v)", addr, err, duration)
                return nil, err
            }
            logger.Printf("Dial to %s succeeded (duration: %v)", addr, duration)
            return conn, nil
        },
    }

    server, err := socks5.New(conf)
    if err != nil {
        return err
    }

    errCh := make(chan error, 1)
    go func() {
        errCh <- server.Serve(listener)
    }()

    select {
    case <-ctx.Done():
        rawListener.Close()
        <-errCh
        logger.Printf("SOCKS5 stopped")
        return ctx.Err()
    case err := <-errCh:
        return err
    }
}
Enter fullscreen mode Exit fullscreen mode

metrics.go:

var (
    activeConnections = prometheus.NewGauge(prometheus.GaugeOpts{
        Name: "proxy_active_connections",
        Help: "Current number of active TCP connections",
    })
)

func init() {
    prometheus.MustRegister(activeConnections)
}

type metricsListener struct {
    net.Listener
}

func (ml metricsListener) Accept() (net.Conn, error) {
    conn, err := ml.Listener.Accept()
    if err != nil {
        return nil, err
    }
    activeConnections.Inc()
    return &metricsConn{Conn: conn}, nil
}

type metricsConn struct {
    net.Conn
}

func (mc *metricsConn) Close() error {
    err := mc.Conn.Close()
    activeConnections.Dec()
    return err
}

func runMetrics(ctx context.Context) error {
    mux := http.NewServeMux()
    mux.Handle("/metrics", promhttp.Handler())
    server := &http.Server{
        Addr:    ":9090",
        Handler: mux,
    }

    errCh := make(chan error, 1)
    go func() {
        errCh <- server.ListenAndServe()
    }()

    select {
    case <-ctx.Done():
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        server.Shutdown(shutdownCtx)
        return ctx.Err()
    case err := <-errCh:
        return err
    }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
szp2005 profile image
szp2005

Worth knowing before you ship this to a VPS: reputation APIs mislabel whole cloud ranges. proxycheck and ip-api flag AWS and Hetzner blocks wholesale. That's why our scoring needs two independent specialized sources agreeing before it calls a datacenter IP a proxy, while one is enough for residential. ASN matches on M247 or Datacamp stand alone.