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/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..1282b8d 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) } @@ -193,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") } @@ -224,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()) } @@ -304,7 +360,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/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/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) + } + } +} 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") + } +}