Skip to content
Closed
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ forward_proxy {
ports 80 443
hide_ip
hide_via
websocket
disable_insecure_upstreams_check
probe_resistance secret-link-kWWL9Q.com # alternatively you can use a real domain, such as caddyserver.com
serve_pac /secret-proxy.pac
Expand Down Expand Up @@ -160,6 +161,7 @@ forward_proxy {
Specifies ports forwardproxy will whitelist for all requests. Other ports will be forbidden.

Default: no restrictions.

- acl {
acl_directive
...
Expand Down Expand Up @@ -202,6 +204,18 @@ forward_proxy {

Default deny rules intend to prohibit access to localhost and local networks and may be expanded in future.

### WebSocket

- `websocket`
Accepts Naive's WebSocket tunnel protocol on WebSocket upgrade requests. This is useful behind
CDNs that do not forward HTTP CONNECT. The first binary message selects the target
(`version, address type, address, port`), the server replies with one status byte, and subsequent
binary messages carry the target TCP stream. Naive Variant 1 padding is negotiated with the
existing `Padding` headers. Authentication, ACL, ports, dial timeout, and upstream settings are
shared with the normal forward proxy.

Default: disabled.

### Timeouts

- `dial_timeout [integer]`
Expand Down
7 changes: 7 additions & 0 deletions caddyfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
}
h.DisableInsecureUpstreamsCheck = true

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

case "probe_resistance":
args := d.RemainingArgs()
if len(args) > 1 {
Expand Down
15 changes: 13 additions & 2 deletions forwardproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import (
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/caddyserver/forwardproxy/httpclient"
"github.com/gorilla/websocket"
"go.uber.org/zap"
"golang.org/x/net/proxy"
)
Expand All @@ -68,6 +69,9 @@ type Handler struct {
// If true, the strict check preventing HTTP upstreams will be disabled.
DisableInsecureUpstreamsCheck bool `json:"disable_insecure_upstreams_check,omitempty"`

// If true, WebSocket upgrade requests are accepted as tunnel transports.
WebSocket bool `json:"websocket,omitempty"`

// Host(s) (and ports) of the proxy. When you configure a client,
// you will give it the host (and port) of the proxy to use.
Hosts caddyhttp.MatchHost `json:"hosts,omitempty"`
Expand Down Expand Up @@ -258,9 +262,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
if h.ProbeResistance != nil && len(h.ProbeResistance.Domain) > 0 && reqHost == h.ProbeResistance.Domain {
return serveHiddenPage(w, authErr)
}
if h.Hosts.Match(r) && (r.Method != http.MethodConnect || authErr != nil) {
webSocketTunnel := h.WebSocket && websocket.IsWebSocketUpgrade(r)
if h.Hosts.Match(r) &&
((r.Method != http.MethodConnect && !webSocketTunnel) || authErr != nil) {
// Always pass non-CONNECT requests to hostname
// Pass CONNECT requests only if probe resistance is enabled and not authenticated
// Pass CONNECT and WebSocket tunnel requests only if they are not
// authenticated
if h.shouldServePACFile(r) {
return h.servePacFile(w, r)
}
Expand All @@ -276,6 +283,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
return caddyhttp.Error(http.StatusProxyAuthRequired, authErr)
}

if webSocketTunnel {
return h.serveWebSocket(w, r)
}

if r.ProtoMajor != 1 && r.ProtoMajor != 2 && r.ProtoMajor != 3 {
return caddyhttp.Error(http.StatusHTTPVersionNotSupported,
fmt.Errorf("unsupported HTTP major version: %d", r.ProtoMajor))
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ toolchain go1.22.2

require (
github.com/caddyserver/caddy/v2 v2.8.4
github.com/gorilla/websocket v1.5.3
go.uber.org/zap v1.27.0
golang.org/x/net v0.25.0
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4=
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
Expand Down
171 changes: 171 additions & 0 deletions websocket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package forwardproxy

import (
"encoding/binary"
"errors"
"fmt"
"math/rand"
"net"
"net/http"
"strconv"
"strings"
"time"

"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/gorilla/websocket"
)

var websocketUpgrader = websocket.Upgrader{
EnableCompression: false,
CheckOrigin: func(*http.Request) bool { return true },
}

func (h Handler) serveWebSocket(w http.ResponseWriter, r *http.Request) error {
responseHeader := http.Header{}
padding := wantsVariant1Padding(r.Header)
if padding {
responseHeader.Set("Padding", randomPaddingHeader())
responseHeader.Set("Padding-Type-Reply", "1")
}
conn, err := websocketUpgrader.Upgrade(w, r, responseHeader)
if err != nil {
return caddyhttp.Error(http.StatusInternalServerError, err)
}
defer conn.Close()
conn.SetReadLimit(1024 * 1024)

timeout := time.Duration(h.DialTimeout)
if timeout <= 0 {
timeout = 30 * time.Second
}
conn.SetReadDeadline(time.Now().Add(timeout))
messageType, payload, err := conn.ReadMessage()
if err != nil {
return nil
}
if messageType != websocket.BinaryMessage {
_ = conn.WriteMessage(websocket.BinaryMessage, []byte{1})
return nil
}
host, port, err := parseWebSocketTarget(payload)
if err != nil {
_ = conn.WriteMessage(websocket.BinaryMessage, []byte{1})
return nil
}

targetConn, err := h.dialContextCheckACL(
r.Context(), "tcp", net.JoinHostPort(host, strconv.Itoa(int(port))))
if err != nil || targetConn == nil {
_ = conn.WriteMessage(websocket.BinaryMessage, []byte{4})
return nil
}
defer targetConn.Close()
if err := conn.WriteMessage(websocket.BinaryMessage, []byte{0}); err != nil {
return nil
}
conn.SetReadDeadline(time.Time{})

return dualStream(targetConn, &websocketReader{conn: conn},
websocketWriter{conn: conn}, padding)
}

func wantsVariant1Padding(header http.Header) bool {
if header.Get("Padding") == "" {
return false
}
for _, value := range header.Values("Padding-Type-Request") {
for _, part := range strings.Split(value, ",") {
if strings.TrimSpace(part) == "1" {
return true
}
}
}
return false
}

func randomPaddingHeader() string {
paddingLen := rand.Intn(32) + 30
padding := make([]byte, paddingLen)
bits := rand.Uint64()
for i := range padding {
if i < 16 {
padding[i] = "!#$()+<>?@[]^`{}"[bits&15]
bits >>= 4
} else {
padding[i] = '~'
}
}
return string(padding)
}

func parseWebSocketTarget(payload []byte) (string, uint16, error) {
if len(payload) < 4 || payload[0] != 1 {
return "", 0, errors.New("invalid target header")
}
var host string
var portOffset int
switch payload[1] {
case 1:
if len(payload) < 8 {
return "", 0, errors.New("short IPv4 address")
}
host, portOffset = net.IP(payload[2:6]).String(), 6
case 3:
length := int(payload[2])
if len(payload) < 4+length {
return "", 0, errors.New("short domain")
}
host, portOffset = string(payload[3:3+length]), 3+length
case 4:
if len(payload) < 20 {
return "", 0, errors.New("short IPv6 address")
}
host, portOffset = net.IP(payload[2:18]).String(), 18
default:
return "", 0, fmt.Errorf("unsupported address type %d", payload[1])
}
if len(payload) != portOffset+2 {
return "", 0, errors.New("invalid target header length")
}
return host, binary.BigEndian.Uint16(payload[portOffset:]), nil
}

type websocketReader struct {
conn *websocket.Conn
buffer []byte
}

func (r *websocketReader) Read(p []byte) (int, error) {
for len(r.buffer) == 0 {
messageType, payload, err := r.conn.ReadMessage()
if err != nil {
return 0, err
}
if messageType != websocket.BinaryMessage {
return 0, errors.New("non-binary WebSocket message")
}
r.buffer = payload
}
n := copy(p, r.buffer)
r.buffer = r.buffer[n:]
return n, nil
}

func (*websocketReader) Close() error { return nil }

type websocketWriter struct {
conn *websocket.Conn
}

func (w websocketWriter) Write(p []byte) (int, error) {
if err := w.conn.WriteMessage(websocket.BinaryMessage, p); err != nil {
return 0, err
}
return len(p), nil
}

func (w websocketWriter) CloseWrite() error {
return w.conn.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
time.Now().Add(10*time.Second))
}
Loading