Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions caddyfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: <from_host:port> <to_addr:port>")
}
h.Resolve = append(h.Resolve, ResolveEntry{From: args[0], To: args[1]})

case "acl":
for nesting := d.Nesting(); d.NextBlock(nesting); {
aclDirective := d.Val()
Expand Down
72 changes: 69 additions & 3 deletions forwardproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand All @@ -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{
Expand Down Expand Up @@ -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)
}

Expand All @@ -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://<absolute-path> 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")
}

Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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
}
Expand Down
10 changes: 7 additions & 3 deletions httpclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand All @@ -107,4 +110,5 @@ func TestHttpClientH2Multiplexing(t *testing.T) {
go _test()
time.Sleep(sleepInterval)
}
wg.Wait()
}
70 changes: 70 additions & 0 deletions resolve_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
121 changes: 121 additions & 0 deletions upstream_unix_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}