Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5a70f93
Handle padding protocol directly
klzgrad May 23, 2020
235e0fb
Ignore old padding format of "..."
klzgrad Jun 13, 2020
650996e
Revert "Ignore old padding format of "...""
klzgrad Jun 6, 2021
f36ce98
Fix coding style
klzgrad Jun 6, 2021
ff60d3b
Don't send garbage paddings
klzgrad Jun 6, 2021
fd36315
Add SUoT support
nekohasekai Apr 3, 2022
97c50f4
Update dependencies
nekohasekai May 11, 2022
057884a
docs: fix `dial_timeout` parameter documentation (#171)
PrintNow Jun 18, 2025
ff17e8b
Merge upstream changes from klzgrad's repository
Aug 14, 2025
e40436a
Resolve go.mod conflicts and update dependencies
Aug 14, 2025
bc34e5a
Fix build issues and restore UoT functionality\n\n- Resolved merge co…
Aug 14, 2025
c67e8e5
Bump actions/checkout from 4 to 5 (#174)
dependabot[bot] Aug 14, 2025
ba97d9d
Bump github.com/golang/glog from 1.2.0 to 1.2.4 (#175)
dependabot[bot] Aug 15, 2025
5c8bfd1
docs: Update README.md to reflect UoT support and fix merge conflicts
Aug 15, 2025
f92c1a3
add UDP in HTTP
imgk Aug 27, 2025
e99851e
Merge imgk/udpinhttp branch
aUsernameWoW Aug 29, 2025
e212ae9
Revert to previous version with sing dependency and UoT support
aUsernameWoW Aug 29, 2025
4dfc5d0
Route UDP-over-TCP through SOCKS5 upstream when configured
aUsernameWoW May 8, 2026
a3c38f3
Bump actions/setup-go from 5 to 6 (#178)
dependabot[bot] Sep 5, 2025
33dfc84
Bump github.com/quic-go/quic-go from 0.44.0 to 0.49.1 (#182)
dependabot[bot] Oct 13, 2025
b7bed57
Add :443 to the quickstart (#190)
unbeatable-101 Mar 21, 2026
1d755b2
Match UoT framing version to client magic address
aUsernameWoW May 9, 2026
429de2d
Restore CONNECT-UDP / MASQUE server (RFC 9298)
aUsernameWoW May 11, 2026
83e3509
Adapt MASQUE handler to quic-go v0.54 http3.Stream struct
aUsernameWoW May 11, 2026
acb70c0
Add passthrough_uot toggle for SOCKS5 UoT chaining
aUsernameWoW May 13, 2026
a7cc6fa
Fix passthrough_uot dial: bypass x/net SOCKS5 port validation
aUsernameWoW May 13, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ jobs:
OS_LABEL: windows-latest
runs-on: ${{ matrix.OS_LABEL }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: '~1.22.0'
check-latest: true
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- name: Fetch Repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: '${{ matrix.go-version }}'
- name: Run test
Expand Down
44 changes: 39 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

This package registers the `http.handlers.forward_proxy` module, which acts as an HTTPS proxy for accessing remote networks.

## :rocket: Quick Build

To quickly build Caddy with this fork of forwardproxy, which includes support for sing-box UoT (UDP over TCP) v1 & v2:

```shell
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
~/go/bin/xcaddy build --with github.com/caddyserver/forwardproxy@caddy2=github.com/aUsernameWoW/forwardproxy@naive
```

## :warning: Experimental!

This module is EXPERIMENTAL. We need more users to test this module for bugs and weaknesses before we recommend its use from within surveilled networks or regions with active censorship. Do not rely on this code in situations where personal safety, freedom, or privacy are at risk.
Expand All @@ -23,6 +32,7 @@ We are also seeking experienced maintainers who have experience with these kinds
- Access control lists
- Optional probe resistance
- PAC file
- UDP over TCP (UoT) support for sing-box clients (compatible with sing-box UoT v1 & v2)


## Introduction
Expand All @@ -43,7 +53,7 @@ $ xcaddy build --with github.com/caddyserver/forwardproxy
Most people prefer the [Caddyfile](https://caddyserver.com/docs/caddyfile) for configuration. You can stand up a simple, wide-open unauthenticated forward proxy like this:

```
example.com {
:443, example.com {
# UNAUTHENTICATED! USE ONLY FOR TESTING
forward_proxy
}
Expand All @@ -58,7 +68,7 @@ The `forward_proxy` has a default [directive order](https://caddyserver.com/docs
order forward_proxy first
}

example.com {
:443, example.com {
# UNAUTHENTICATED! USE ONLY FOR TESTING
forward_proxy
}
Expand Down Expand Up @@ -92,12 +102,17 @@ forward_proxy {
probe_resistance secret-link-kWWL9Q.com # alternatively you can use a real domain, such as caddyserver.com
serve_pac /secret-proxy.pac

dial_timeout 30
dial_timeout 30s

max_idle_conns 50
max_idle_conns_per_host 2

upstream https://user:password@extra-upstream-hop.com
# When upstream is socks5:// and the upstream itself understands
# sing UoT — currently this is essentially only sing-box (and other
# sing-based tools) — enable passthrough to skip local UoT decoding
# and forward the magic-address CONNECT to upstream as-is.
# passthrough_uot

acl {
allow *.caddyserver.com
Expand Down Expand Up @@ -204,8 +219,8 @@ forward_proxy {

### Timeouts

- `dial_timeout [integer]`
Sets timeout (in seconds) for establishing TCP connection to target website. Affects all requests.
- `dial_timeout [Duration]`
Sets timeout (with units, e.g. 30s) for establishing TCP connection to target website. Affects all requests.

Default: 30 seconds.

Expand Down Expand Up @@ -237,6 +252,25 @@ By default, forwardproxy will reuse connections by using Go's built-in connectio
Supported schemes to localhost: socks5, http, https (certificate check is ignored).

Default: no upstream proxy.
- `passthrough_uot`
When set, the sing UoT magic addresses (`sp.v2.udp-over-tcp.arpa`,
`sp.udp-over-tcp.arpa`) are forwarded to the upstream as ordinary
CONNECT targets instead of being decoded into real UDP locally.
Only valid when `upstream` is a `socks5://` URL — Caddy will refuse
to start otherwise.

In practice the upstream must speak sing UoT for the passed-through
request to be parsed correctly; today that effectively means **only
sing-box (and other sing-based tools)**. Pointing this at a generic
SOCKS5 server that doesn't recognise the magic address will just
produce CONNECT failures (or a TCP stream of UoT bytes the upstream
has no idea what to do with).

When enabled, this avoids the local parse → SOCKS5 UDP ASSOCIATE
round-trip in fully UoT-aware chains.

Default: off; UoT is decoded locally (and, if a SOCKS5 upstream is
set, re-emitted via UDP ASSOCIATE).

## Get forwardproxy
### Download prebuilt binary
Expand Down
17 changes: 17 additions & 0 deletions caddyfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,23 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
}
h.Upstream = args[0]

case "udp_uri_template":
args := d.RemainingArgs()
if len(args) != 1 {
return d.ArgErr()
}
if h.URITemplate != "" {
return d.Err("udp_uri_template directive specified more than once")
}
h.URITemplate = args[0]

case "passthrough_uot":
args := d.RemainingArgs()
if len(args) != 0 {
return d.ArgErr()
}
h.PassthroughUoT = true

case "acl":
for nesting := d.Nesting(); d.NextBlock(nesting); {
aclDirective := d.Val()
Expand Down
113 changes: 111 additions & 2 deletions forwardproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ import (
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/caddyserver/forwardproxy/httpclient"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/common/uot"
"github.com/sagernet/sing/protocol/socks"
"go.uber.org/zap"
"golang.org/x/net/proxy"
)
Expand Down Expand Up @@ -91,6 +95,14 @@ type Handler struct {
// Optionally configure an upstream proxy to use.
Upstream string `json:"upstream,omitempty"`

// When true (and Upstream is a socks/socks5 URL), the sing UoT magic
// address (sp.v2.udp-over-tcp.arpa / sp.udp-over-tcp.arpa) is passed
// through to the SOCKS5 upstream as a regular CONNECT target instead
// of being decoded into real UDP locally. Useful when the upstream is
// itself UoT-aware (e.g. sing-box) and you want to avoid the local
// parse + UDP ASSOCIATE round-trip.
PassthroughUoT bool `json:"passthrough_uot,omitempty"`

// Access control list.
ACL []ACLRule `json:"acl,omitempty"`

Expand All @@ -102,11 +114,25 @@ type Handler struct {
// overridden dialContext allows us to redirect requests to upstream proxy
dialContext func(ctx context.Context, network, address string) (net.Conn, error)
upstream *url.URL // address of upstream proxy
// socksClient is set only when upstream is socks5://. UDP-over-TCP
// listens on this client (UDP ASSOCIATE) so audit/routing upstreams see
// UDP traffic instead of having it leak directly out of this process.
// In PassthroughUoT mode it also carries the magic-address CONNECT,
// because the stdlib x/net SOCKS5 dialer rejects the port-0 target sing
// uses for the UoT magic address.
socksClient *socks.Client

aclRules []aclRule

// TODO: temporary/deprecated - we should try to reuse existing authentication modules instead!
AuthCredentials [][]byte `json:"auth_credentials,omitempty"` // slice with base64-encoded credentials

// MASQUE / RFC 9298 CONNECT-UDP server. Active when no upstream is set.
udpProxyServer udpProxyServer

// URI template used to match incoming connect-udp requests (RFC 6570 style).
// Defaults to https://{host}/.well-known/masque/udp/{target_host}/{target_port}/
URITemplate string `json:"udp_uri_template,omitempty"`
}

// CaddyModule returns the Caddy module information.
Expand Down Expand Up @@ -239,6 +265,34 @@ func (h *Handler) Provision(ctx caddy.Context) error {
return upstreamDialer.Dial(network, address)
}
}

// SOCKS5 upstreams can also carry UDP via UDP ASSOCIATE, and in
// PassthroughUoT mode they also carry the magic-address CONNECT
// (which the stdlib x/net SOCKS5 dialer would reject because of the
// port-0 target). Build a sing socks.Client so both paths have a
// non-validating dialer available.
switch strings.ToLower(h.upstream.Scheme) {
case "socks", "socks5":
client, err := socks.NewClientFromURL(N.SystemDialer, h.Upstream)
if err != nil {
return fmt.Errorf("build SOCKS5 client for upstream: %w", err)
}
h.socksClient = client
default:
if h.PassthroughUoT {
return fmt.Errorf("passthrough_uot requires a socks/socks5 upstream, got %q", h.upstream.Scheme)
}
}
} else if h.PassthroughUoT {
return errors.New("passthrough_uot requires an upstream to be configured")
}

// MASQUE / RFC 9298: init the connect-udp handler. Active when no
// upstream is set; coexists with UoT on regular CONNECT tunnels.
var err error
h.udpProxyServer, err = newUDPProxyServer(h.URITemplate, h.logger)
if err != nil {
return fmt.Errorf("create udp proxy: %w", err)
}

return nil
Expand Down Expand Up @@ -293,6 +347,17 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
ctx = context.WithValue(ctx, httpclient.ContextKeyHeader{}, ctxHeader)
}

// RFC 9298 — try CONNECT-UDP / MASQUE before the regular CONNECT path.
// tryUDPoverHTTP returns (false, _) when the request isn't a connect-udp
// request, in which case we fall through to the normal proxy path.
isUDPoverHTTP, udpErr := h.tryUDPoverHTTP(w, r)
if isUDPoverHTTP {
if udpErr != nil {
return fmt.Errorf("handle UDP over HTTP error: %w", udpErr)
}
return nil
}

if r.Method == http.MethodConnect {
if r.ProtoMajor == 2 || r.ProtoMajor == 3 {
if len(r.URL.Scheme) > 0 || len(r.URL.Path) > 0 {
Expand Down Expand Up @@ -514,6 +579,50 @@ func (h Handler) dialContextCheckACL(ctx context.Context, network, hostPort stri
return nil, caddyhttp.Error(http.StatusBadRequest, err)
}

if host == uot.MagicAddress || host == uot.LegacyMagicAddress {
if h.PassthroughUoT && h.socksClient != nil {
// Forward the magic-address CONNECT untouched so the upstream's own
// UoT detection fires. sing's socks client is used here because the
// stdlib x/net SOCKS5 dialer (h.dialContext) rejects port 0.
conn, err := h.socksClient.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(hostPort))
if err != nil {
return nil, caddyhttp.Error(http.StatusBadGateway,
fmt.Errorf("upstream SOCKS5 UoT passthrough failed: %w", err))
}
return conn, nil
}
var pc net.PacketConn
switch {
case h.socksClient != nil:
// SOCKS5 upstream — route UDP through UDP ASSOCIATE so the upstream
// observes (and can filter / audit) the actual UDP traffic.
var err error
pc, err = h.socksClient.ListenPacket(ctx, M.Socksaddr{})
if err != nil {
return nil, caddyhttp.Error(http.StatusBadGateway,
fmt.Errorf("upstream SOCKS5 UDP ASSOCIATE failed: %w", err))
}
case h.upstream != nil:
// HTTP CONNECT upstreams cannot tunnel UDP. Refuse rather than
// silently leaking the UDP traffic directly out of this process.
return nil, caddyhttp.Error(http.StatusBadGateway,
errors.New("UDP-over-TCP cannot be tunneled through an HTTP CONNECT upstream"))
default:
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, err
}
pc = udpConn
}
// The framing differs between v1 and v2; pick the one matching the magic
// the client used so legacy clients don't desync against a v2 server.
version := uot.Version
if host == uot.LegacyMagicAddress {
version = uot.LegacyVersion
}
return uot.NewServerConn(pc, version), nil
}

if h.upstream != nil {
// if upstreaming -- do not resolve locally nor check acl
conn, err = h.dialContext(ctx, network, hostPort)
Expand Down Expand Up @@ -679,8 +788,8 @@ func dualStream(target net.Conn, clientReader io.ReadCloser, clientWriter io.Wri
go stream(target, clientReader, RemovePadding)
return stream(clientWriter, target, AddPadding)
}
go stream(target, clientReader, NoPadding) //nolint: errcheck
return stream(clientWriter, target, NoPadding)
go stream(target, clientReader, RemovePadding) //nolint: errcheck
return stream(clientWriter, target, AddPadding)
}

type closeWriter interface {
Expand Down
Loading
Loading