Skip to content

Commit

Permalink
feat(caddy): add a WebSockets handler
Browse files Browse the repository at this point in the history
  • Loading branch information
sbruens committed Sep 23, 2024
1 parent cb5965f commit 23e1d37
Show file tree
Hide file tree
Showing 5 changed files with 179 additions and 9 deletions.
14 changes: 12 additions & 2 deletions caddy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,20 @@ From this directory, build and run a custom binary with `xcaddy`:
xcaddy run --config config_example.json --watch
```

In a separate window, confirm you can fetch a page using this server:
In a separate window, confirm you can fetch a page over Shadowsocks:

```sh
go run github.com/Jigsaw-Code/outline-sdk/x/examples/fetch -transport "ss://chacha20-ietf-poly1305:Secret1@:9000" http://ipinfo.io
go run github.com/Jigsaw-Code/outline-sdk/x/examples/fetch \
-transport "ss://chacha20-ietf-poly1305:Secret1@:9000" \
http://ipinfo.io
```

Or Shadowsocks over Websockets:

```sh
go run github.com/Jigsaw-Code/outline-sdk/x/examples/fetch \
-transport "ws:tcp_path=/tcp&udp_path=/udp|ss://chacha20-ietf-poly1305:Secret1@:8000" \
http://ipinfo.io
```

Prometheus metrics are available on http://localhost:9091/metrics.
37 changes: 36 additions & 1 deletion caddy/config_example.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,42 @@
"apps": {
"http": {
"servers": {
"": {
"srv0": {
"listen": [":8000"],
"routes": [
{
"match": [
{
"path": [
"/tcp"
]
}
],
"handle": [
{
"handler": "websockets",
"backend": ":9000"
}
]
},
{
"match": [
{
"path": [
"/udp"
]
}
],
"handle": [
{
"handler": "websockets",
"backend": "udp/[::]:9000"
}
]
}
]
},
"metrics": {
"listen": [
":9091"
],
Expand Down
3 changes: 1 addition & 2 deletions caddy/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/caddyserver/caddy/v2 v2.8.4
github.com/mholt/caddy-l4 v0.0.0-20240812213304-afa78d72257b
github.com/prometheus/client_golang v1.20.0
golang.org/x/net v0.26.0
)

require (
Expand Down Expand Up @@ -57,7 +58,6 @@ require (
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/libdns/libdns v0.2.2 // indirect
github.com/lmittmann/tint v1.0.5 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
Expand Down Expand Up @@ -109,7 +109,6 @@ require (
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/term v0.21.0 // indirect
Expand Down
4 changes: 0 additions & 4 deletions caddy/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/Jigsaw-Code/outline-sdk v0.0.16 h1:WbHmv80FKDIpzEmR3GehTbq5CibYTLvcxIIpMMILiEs=
github.com/Jigsaw-Code/outline-sdk v0.0.16/go.mod h1:e1oQZbSdLJBBuHgfeQsgEkvkuyIePPwstUeZRGq0KO8=
github.com/Jigsaw-Code/outline-ss-server v1.7.1 h1:KLrolmZZfuBx48GM4XblH0XoTK+wMWsEbx/QDZOuibs=
github.com/Jigsaw-Code/outline-ss-server v1.7.1/go.mod h1:cKPicPWlLWZKJfkQ3CBpQm8a3gXrA2+dpQvsECqBVz8=
github.com/Jigsaw-Code/outline-ss-server v1.7.2 h1:A//m3KNsguZwhI6AJyFl0Gj8SpwZM7cy+FPoxCNzz28=
github.com/Jigsaw-Code/outline-ss-server v1.7.2/go.mod h1:cKPicPWlLWZKJfkQ3CBpQm8a3gXrA2+dpQvsECqBVz8=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
Expand Down Expand Up @@ -273,8 +271,6 @@ github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw=
github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
Expand Down
130 changes: 130 additions & 0 deletions caddy/websockets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package caddy

import (
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"time"

"github.com/Jigsaw-Code/outline-sdk/transport"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"golang.org/x/net/websocket"
)

const wsModuleName = "http.handlers.websockets"

func init() {
caddy.RegisterModule(ModuleRegistration{
ID: wsModuleName,
New: func() caddy.Module { return new(WebsocketHandler) },
})
}

// WebsocketHandler implements a middleware Caddy handler that proxies WebSocket
// connections.
type WebsocketHandler struct {
// The address of the backend to connect to.
Backend string `json:"backend,omitempty"`

logger *slog.Logger
}

var (
_ caddy.Provisioner = (*WebsocketHandler)(nil)
_ caddy.Validator = (*WebsocketHandler)(nil)
_ caddyhttp.MiddlewareHandler = (*WebsocketHandler)(nil)
)

func (*WebsocketHandler) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{ID: wsModuleName}
}

// Provision sets up the module.
func (h *WebsocketHandler) Provision(ctx caddy.Context) error {
h.logger = ctx.Slogger()
return nil
}

// Validate ensures the module has a valid configuration.
func (h *WebsocketHandler) Validate() error {
if h.Backend == "" {
return errors.New("must specify `backend`")
}
return nil
}

// ServeHTTP implements the caddyhttp.MiddlewareHandler interface.
func (h WebsocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
h.logger.Info("handling websocket connection", slog.String("path", r.URL.Path))

backendAddr, err := caddy.ParseNetworkAddress(h.Backend)
if err != nil {
return fmt.Errorf("unable to parse `backend` network address: %v", err)
}

var handler func(wsConn *websocket.Conn)
switch backendAddr.Network {
case "tcp":
streamDialer := &transport.TCPDialer{}
endpoint := transport.StreamDialerEndpoint{Dialer: streamDialer, Address: backendAddr.JoinHostPort(0)}
handler = func(wsConn *websocket.Conn) {
targetConn, err := endpoint.ConnectStream(r.Context())
if err != nil {
h.logger.Error("failed to connect to backend", slog.Any("err", err))
w.WriteHeader(http.StatusBadGateway)
return
}
defer targetConn.Close()

go func() {
io.Copy(targetConn, wsConn)
targetConn.CloseWrite()
}()

io.Copy(wsConn, targetConn)
wsConn.Close()
}
case "udp":
packetDialer := &transport.UDPDialer{}
endpoint := transport.PacketDialerEndpoint{Dialer: packetDialer, Address: backendAddr.JoinHostPort(0)}
handler = func(wsConn *websocket.Conn) {
targetConn, err := endpoint.ConnectPacket(r.Context())
if err != nil {
h.logger.Error("failed to connect to backend", slog.Any("err", err))
w.WriteHeader(http.StatusBadGateway)
return
}
// Expire connection after 5 minutes of idle time, as per
// https://datatracker.ietf.org/doc/html/rfc4787#section-4.3
targetConn = &natConn{targetConn, 5 * time.Minute}

go func() {
io.Copy(targetConn, wsConn)
targetConn.Close()
}()

io.Copy(wsConn, targetConn)
wsConn.Close()
}
default:
return fmt.Errorf("unsupported `backend` network: %v", backendAddr.Network)
}

websocket.Server{Handler: handler}.ServeHTTP(w, r)
return nil
}

type natConn struct {
net.Conn
mappingTimeout time.Duration
}

// Consider ReadFrom/WriteTo
func (c *natConn) Write(b []byte) (int, error) {
c.Conn.SetDeadline(time.Now().Add(c.mappingTimeout))
return c.Conn.Write(b)
}

0 comments on commit 23e1d37

Please sign in to comment.