From 21b32cb8aab41ad007ad2cd6886336824416b6a1 Mon Sep 17 00:00:00 2001 From: sbruens Date: Mon, 23 Sep 2024 16:06:35 -0400 Subject: [PATCH] feat(caddy): add a WebSockets handler --- caddy/README.md | 14 +++- caddy/config_example.json | 37 +++++++++- caddy/go.mod | 3 +- caddy/go.sum | 4 -- caddy/websocket_handler.go | 144 +++++++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 caddy/websocket_handler.go diff --git a/caddy/README.md b/caddy/README.md index 188a9625..7e171366 100644 --- a/caddy/README.md +++ b/caddy/README.md @@ -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. diff --git a/caddy/config_example.json b/caddy/config_example.json index 2d198890..ec6cbdc6 100644 --- a/caddy/config_example.json +++ b/caddy/config_example.json @@ -10,7 +10,42 @@ "apps": { "http": { "servers": { - "": { + "srv0": { + "listen": [":8000"], + "routes": [ + { + "match": [ + { + "path": [ + "/tcp" + ] + } + ], + "handle": [ + { + "handler": "websocket", + "backend": ":9000" + } + ] + }, + { + "match": [ + { + "path": [ + "/udp" + ] + } + ], + "handle": [ + { + "handler": "websocket", + "backend": "udp/[::]:9000" + } + ] + } + ] + }, + "metrics": { "listen": [ ":9091" ], diff --git a/caddy/go.mod b/caddy/go.mod index 87ff8e8e..48ba2eb0 100644 --- a/caddy/go.mod +++ b/caddy/go.mod @@ -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 ( @@ -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 @@ -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 diff --git a/caddy/go.sum b/caddy/go.sum index 7ade3938..613b9072 100644 --- a/caddy/go.sum +++ b/caddy/go.sum @@ -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= @@ -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= diff --git a/caddy/websocket_handler.go b/caddy/websocket_handler.go new file mode 100644 index 00000000..a4b95b59 --- /dev/null +++ b/caddy/websocket_handler.go @@ -0,0 +1,144 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +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.websocket" + +func init() { + caddy.RegisterModule(ModuleRegistration{ + ID: wsModuleName, + New: func() caddy.Module { return new(WebSocketHandler) }, + }) +} + +// WebSocketHandler implements a middleware Caddy handler that proxies +// WebSockets 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 implements caddy.Provisioner. +func (h *WebSocketHandler) Provision(ctx caddy.Context) error { + h.logger = ctx.Slogger() + return nil +} + +// Validate implements caddy.Validator. +func (h *WebSocketHandler) Validate() error { + if h.Backend == "" { + return errors.New("must specify `backend`") + } + return nil +} + +// ServeHTTP implements caddyhttp.MiddlewareHandler. +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) +}