From c4acd442930d393f74e2ca6912bcf9e83aa19d92 Mon Sep 17 00:00:00 2001 From: Zetanova Date: Wed, 15 Apr 2026 15:39:45 +0200 Subject: [PATCH 1/2] feat: add resolve subdirective for custom address resolution Add a resolve subdirective that allows mapping hostnames to specific addresses before dialing. Supports wildcard patterns and automatically allows localhost targets in the ACL. Bypasses ACL checks only for resolved addresses, not globally. --- caddyfile.go | 7 +++++ forwardproxy.go | 57 +++++++++++++++++++++++++++++++++++++++- resolve_test.go | 70 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 resolve_test.go diff --git a/caddyfile.go b/caddyfile.go index b673f17..42116c1 100644 --- a/caddyfile.go +++ b/caddyfile.go @@ -184,6 +184,13 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } h.Upstream = args[0] + case "resolve": + args := d.RemainingArgs() + if len(args) != 2 { + return d.Err("resolve expects exactly two arguments: ") + } + h.Resolve = append(h.Resolve, ResolveEntry{From: args[0], To: args[1]}) + case "acl": for nesting := d.Nesting(); d.NextBlock(nesting); { aclDirective := d.Val() diff --git a/forwardproxy.go b/forwardproxy.go index e41a557..5cd0e5a 100644 --- a/forwardproxy.go +++ b/forwardproxy.go @@ -90,6 +90,13 @@ type Handler struct { // Optionally configure an upstream proxy to use. Upstream string `json:"upstream,omitempty"` + // Custom address resolution entries. Each entry maps a host:port pattern + // to a dial address, similar to curl's --resolve flag. Patterns support + // wildcard prefixes (e.g., *.example.com:443). When the proxy needs to + // dial a target matching a pattern, it connects to the specified address + // instead, without DNS resolution of the original hostname. + Resolve []ResolveEntry `json:"resolve,omitempty"` + // Access control list. ACL []ACLRule `json:"acl,omitempty"` @@ -108,6 +115,40 @@ type Handler struct { AuthCredentials [][]byte `json:"auth_credentials,omitempty"` // slice with base64-encoded credentials } +// ResolveEntry maps a host:port pattern to a dial address. +// The pattern may use a wildcard prefix (e.g., *.example.com:443). +type ResolveEntry struct { + // The host:port pattern to match (e.g., "example.com:443", "*.local:443"). + From string `json:"from"` + // The address:port to dial instead (e.g., "127.0.0.1:443"). + To string `json:"to"` +} + +// resolveAddress checks if hostPort matches any resolve entry and returns +// the mapped address if found, or the original hostPort if not. +func (h Handler) resolveAddress(hostPort string) string { + for _, entry := range h.Resolve { + if matchHostPort(entry.From, hostPort) { + return entry.To + } + } + return hostPort +} + +// matchHostPort matches a hostPort against a pattern that may contain +// a wildcard prefix (e.g., *.example.com:443 matches sub.example.com:443). +func matchHostPort(pattern, hostPort string) bool { + if pattern == hostPort { + return true + } + if !strings.HasPrefix(pattern, "*.") { + return false + } + // Wildcard: *.example.com:443 matches anything.example.com:443 + suffix := pattern[1:] // ".example.com:443" + return strings.HasSuffix(hostPort, suffix) +} + // CaddyModule returns the Caddy module information. func (Handler) CaddyModule() caddy.ModuleInfo { return caddy.ModuleInfo{ @@ -184,6 +225,10 @@ func (h *Handler) Provision(ctx caddy.Context) error { } h.dialContext = dialer.DialContext h.httpTransport.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) { + resolved := h.resolveAddress(address) + if resolved != address { + return h.dialContext(ctx, network, resolved) + } return h.dialContextCheckACL(ctx, network, address) } @@ -304,7 +349,17 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht if hostPort == "" { hostPort = r.Host } - targetConn, err := h.dialContextCheckACL(ctx, "tcp", hostPort) + + // Resolve configured address mappings. When resolve matches, + // bypass ACL — the target is explicitly configured, not user-controlled. + dialAddr := h.resolveAddress(hostPort) + var targetConn net.Conn + var err error + if dialAddr != hostPort { + targetConn, err = h.dialContext(ctx, "tcp", dialAddr) + } else { + targetConn, err = h.dialContextCheckACL(ctx, "tcp", hostPort) + } if err != nil { return err } diff --git a/resolve_test.go b/resolve_test.go new file mode 100644 index 0000000..8683ae3 --- /dev/null +++ b/resolve_test.go @@ -0,0 +1,70 @@ +package forwardproxy + +import "testing" + +func TestMatchHostPort(t *testing.T) { + tests := []struct { + pattern string + hostPort string + want bool + }{ + // Exact match + {"example.com:443", "example.com:443", true}, + {"example.com:443", "other.com:443", false}, + {"example.com:443", "example.com:80", false}, + + // Wildcard match + {"*.example.com:443", "sub.example.com:443", true}, + {"*.example.com:443", "deep.sub.example.com:443", true}, + {"*.example.com:443", "example.com:443", false}, + {"*.example.com:443", "sub.example.com:80", false}, + {"*.example.com:443", "sub.other.com:443", false}, + + // Wildcard with different ports + {"*.something.test:443", "test.something.test:443", true}, + {"*.something.test:443", "fleet.something.test:443", true}, + {"*.something.test:443", "test.something.test:80", false}, + + // No wildcard, no match + {"example.com:443", "sub.example.com:443", false}, + } + + for _, tt := range tests { + got := matchHostPort(tt.pattern, tt.hostPort) + if got != tt.want { + t.Errorf("matchHostPort(%q, %q) = %v, want %v", tt.pattern, tt.hostPort, got, tt.want) + } + } +} + +func TestResolveAddress(t *testing.T) { + h := Handler{ + Resolve: []ResolveEntry{ + {From: "*.something.test:443", To: "127.0.0.1:443"}, + {From: "specific.host:8080", To: "10.0.0.1:8080"}, + }, + } + + tests := []struct { + input string + want string + }{ + // Wildcard match + {"test.something.test:443", "127.0.0.1:443"}, + {"fleet.something.test:443", "127.0.0.1:443"}, + + // Exact match + {"specific.host:8080", "10.0.0.1:8080"}, + + // No match — returned as-is + {"other.com:443", "other.com:443"}, + {"test.something.test:80", "test.something.test:80"}, + } + + for _, tt := range tests { + got := h.resolveAddress(tt.input) + if got != tt.want { + t.Errorf("resolveAddress(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} From dbdfe5fd1d3413b4743b97fefcb97b22243abde6 Mon Sep 17 00:00:00 2001 From: Zetanova Date: Sun, 18 Oct 2026 18:09:37 +0200 Subject: [PATCH 2/2] Support SOCKS5 upstreams over Unix sockets Handle the socks5://unix://absolute-path URI using the existing SOCKS dialer, preserving target-name resolution and context cancellation. Cover the handshake, tunnel, unavailable socket and malformed configuration. Await HTTP/2 test workers and report their failures safely. --- README.md | 3 ++ forwardproxy.go | 15 +++++- httpclient_test.go | 10 ++-- upstream_unix_test.go | 121 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 upstream_unix_test.go diff --git a/README.md b/README.md index ad870b2..6a895b4 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,9 @@ By default, forwardproxy will reuse connections by using Go's built-in connectio Default: no upstream proxy. +For a local SOCKS5 server on a Unix socket, use `upstream socks5://unix://run/tor/socks.sock` (absolute socket path `/run/tor/socks.sock`). Target hostnames, including `.onion` names, are resolved by the SOCKS server. This form accepts no credentials, query, or fragment; ordinary TCP upstreams keep their existing syntax and security checks. + + ## Get forwardproxy ### Download prebuilt binary Binaries are at https://caddyserver.com/download diff --git a/forwardproxy.go b/forwardproxy.go index 5cd0e5a..1282b8d 100644 --- a/forwardproxy.go +++ b/forwardproxy.go @@ -238,8 +238,13 @@ func (h *Handler) Provision(ctx caddy.Context) error { return fmt.Errorf("bad upstream URL: %v", err) } h.upstream = upstreamURL + unixSOCKS := upstreamURL.Scheme == "socks5" && upstreamURL.Host == "unix:" + if unixSOCKS && (!strings.HasPrefix(upstreamURL.Path, "//") || len(upstreamURL.Path) <= 2 || + upstreamURL.User != nil || upstreamURL.RawQuery != "" || upstreamURL.Fragment != "") { + return errors.New("Unix SOCKS5 upstream requires socks5://unix:// without credentials, query, or fragment") + } - if !h.DisableInsecureUpstreamsCheck && !isLocalhost(h.upstream.Hostname()) && h.upstream.Scheme != "https" { + if !h.DisableInsecureUpstreamsCheck && !unixSOCKS && !isLocalhost(h.upstream.Hostname()) && h.upstream.Scheme != "https" { return errors.New("insecure schemes are only allowed to localhost upstreams") } @@ -269,7 +274,13 @@ func (h *Handler) Provision(ctx caddy.Context) error { proxy.RegisterDialerType("https", registerHTTPDialer) proxy.RegisterDialerType("http", registerHTTPDialer) - upstreamDialer, err := proxy.FromURL(h.upstream, dialer) + var upstreamDialer proxy.Dialer + if unixSOCKS { + // Keep target-name resolution at the SOCKS server (including .onion). + upstreamDialer, err = proxy.SOCKS5("unix", strings.TrimPrefix(upstreamURL.Path, "/"), nil, dialer) + } else { + upstreamDialer, err = proxy.FromURL(h.upstream, dialer) + } if err != nil { return errors.New("failed to create proxy to upstream: " + err.Error()) } diff --git a/httpclient_test.go b/httpclient_test.go index 700a5a1..752e2e8 100644 --- a/httpclient_test.go +++ b/httpclient_test.go @@ -89,13 +89,16 @@ func TestHttpClientH2Multiplexing(t *testing.T) { // always dial localhost for testing purposes conn, err := dialer.Dial("tcp", caddyTestTarget.addr) if err != nil { - t.Fatal(err) + t.Error(err) + return } response, err := getResourceViaProxyConn(conn, caddyTestTarget.addr, resource, httpTargetVer, credentialsCorrect) if err != nil { - t.Fatal(httpProxyVer, httpTargetVer, err) + t.Error(httpProxyVer, httpTargetVer, err) + return } else if err = responseExpected(response, caddyTestTarget.contents[resource]); err != nil { - t.Fatal(httpProxyVer, httpTargetVer, err) + t.Error(httpProxyVer, httpTargetVer, err) + return } } } @@ -107,4 +110,5 @@ func TestHttpClientH2Multiplexing(t *testing.T) { go _test() time.Sleep(sleepInterval) } + wg.Wait() } diff --git a/upstream_unix_test.go b/upstream_unix_test.go new file mode 100644 index 0000000..ea66fef --- /dev/null +++ b/upstream_unix_test.go @@ -0,0 +1,121 @@ +package forwardproxy + +import ( + "context" + "encoding/binary" + "io" + "net" + "path/filepath" + "testing" + "time" + + "github.com/caddyserver/caddy/v2" +) + +// TestUnixSOCKSUpstream is the #677 regression for Avon's emitted upstream URI. +// A real Unix listener receives the unresolvable target as a SOCKS5 domain name. +func TestUnixSOCKSUpstream(t *testing.T) { + socket := filepath.Join(t.TempDir(), "socks.sock") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + result := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + result <- err + return + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(3 * time.Second)) + greeting := make([]byte, 3) + if _, err = io.ReadFull(conn, greeting); err != nil { + result <- err + return + } + if string(greeting) != "\x05\x01\x00" { + result <- &net.AddrError{Err: "unexpected SOCKS greeting", Addr: string(greeting)} + return + } + if _, err = conn.Write([]byte{5, 0}); err != nil { + result <- err + return + } + header := make([]byte, 5) + if _, err = io.ReadFull(conn, header); err != nil { + result <- err + return + } + address := make([]byte, int(header[4])+2) + if _, err = io.ReadFull(conn, address); err != nil { + result <- err + return + } + if string(header[:4]) != "\x05\x01\x00\x03" || string(address[:len(address)-2]) != "unresolvable.onion" || binary.BigEndian.Uint16(address[len(address)-2:]) != 443 { + result <- &net.AddrError{Err: "unexpected SOCKS target", Addr: string(address)} + return + } + _, err = conn.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 1}) + if err == nil { + _, err = io.CopyN(conn, conn, 4) + } + result <- err + }() + h := &Handler{Upstream: "socks5://unix:/" + socket} + if err := h.Provision(caddy.Context{Context: context.Background()}); err != nil { + t.Fatal(err) + } + t.Cleanup(h.httpTransport.CloseIdleConnections) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + conn, err := h.dialContext(ctx, "tcp", "unresolvable.onion:443") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(2 * time.Second)) + if _, err := conn.Write([]byte("ping")); err != nil { + t.Fatal(err) + } + response := make([]byte, 4) + if _, err := io.ReadFull(conn, response); err != nil || string(response) != "ping" { + t.Fatalf("tunnel response %q: %v", response, err) + } + if err := <-result; err != nil { + t.Fatal(err) + } +} + +func TestUnixSOCKSUpstreamInvalid(t *testing.T) { + for _, upstream := range []string{ + "socks5://unix://", "socks5://unix:/relative", + "socks5://unix://tmp/socks?query", "socks5://unix://tmp/socks#fragment", + "socks5://user:password@unix://tmp/socks", + } { + t.Run(upstream, func(t *testing.T) { + h := &Handler{Upstream: upstream, DisableInsecureUpstreamsCheck: true} + if err := h.Provision(caddy.Context{Context: context.Background()}); err == nil { + t.Fatal("invalid Unix SOCKS upstream must fail provisioning") + } + }) + } +} + +func TestUnixSOCKSUpstreamUnavailable(t *testing.T) { + h := &Handler{Upstream: "socks5://unix:/" + filepath.Join(t.TempDir(), "missing.sock"), DisableInsecureUpstreamsCheck: true} + if err := h.Provision(caddy.Context{Context: context.Background()}); err != nil { + t.Fatal(err) + } + defer h.httpTransport.CloseIdleConnections() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + conn, err := h.dialContext(ctx, "tcp", "unresolvable.onion:443") + if conn != nil { + conn.Close() + } + if err == nil { + t.Fatal("missing SOCKS socket must fail closed") + } +}