From 25b87870726de544f816cf088298d82d3c4d9191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Matczuk?= Date: Mon, 27 Jan 2025 11:53:00 +0100 Subject: [PATCH 1/5] e2e/udp: add UDP echo server --- e2e/Makefile | 1 + e2e/udp/.gitignore | 1 + e2e/udp/Containerfile | 9 ++++++++ e2e/udp/Makefile | 11 +++++++++ e2e/udp/cmd/server/server.go | 45 ++++++++++++++++++++++++++++++++++++ e2e/udp/service.go | 28 ++++++++++++++++++++++ 6 files changed, 95 insertions(+) create mode 100644 e2e/udp/.gitignore create mode 100644 e2e/udp/Containerfile create mode 100644 e2e/udp/Makefile create mode 100644 e2e/udp/cmd/server/server.go create mode 100644 e2e/udp/service.go diff --git a/e2e/Makefile b/e2e/Makefile index d46245e9..3e8bbb40 100644 --- a/e2e/Makefile +++ b/e2e/Makefile @@ -9,6 +9,7 @@ all: update-helper-images update-test-image run-e2e update-helper-images: @$(MAKE) -C dns update-image @$(MAKE) -C sc-2450 update-image + @$(MAKE) -C udp update-image .PHONY: update-test-image update-test-image: diff --git a/e2e/udp/.gitignore b/e2e/udp/.gitignore new file mode 100644 index 00000000..d9acc32a --- /dev/null +++ b/e2e/udp/.gitignore @@ -0,0 +1 @@ +/server \ No newline at end of file diff --git a/e2e/udp/Containerfile b/e2e/udp/Containerfile new file mode 100644 index 00000000..2ed0a24a --- /dev/null +++ b/e2e/udp/Containerfile @@ -0,0 +1,9 @@ +FROM alpine + +RUN apk add --no-cache netcat-openbsd + +COPY server /usr/bin/server +ENTRYPOINT ["/usr/bin/server"] + +HEALTHCHECK --interval=1s --timeout=3s \ + CMD nc -z localhost 5005 || exit 1 diff --git a/e2e/udp/Makefile b/e2e/udp/Makefile new file mode 100644 index 00000000..2c062d0e --- /dev/null +++ b/e2e/udp/Makefile @@ -0,0 +1,11 @@ +CONTAINER_RUNTIME ?= docker + +.PHONY: server +server: + @CGO_ENABLED=0 GOOS=linux go build ./cmd/server + +export BUILDAH_FORMAT=docker + +.PHONY: update-image +update-image: server + @$(CONTAINER_RUNTIME) buildx build --network host -t e2e-udp -f Containerfile . diff --git a/e2e/udp/cmd/server/server.go b/e2e/udp/cmd/server/server.go new file mode 100644 index 00000000..31041637 --- /dev/null +++ b/e2e/udp/cmd/server/server.go @@ -0,0 +1,45 @@ +// Copyright 2022-2024 Sauce Labs Inc., all rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package main + +import ( + "flag" + "log" + "net" +) + +var ( + address = flag.String("address", "0.0.0.0:5005", "UDP address to listen on") + bufSize = flag.Int("bufsize", 1024, "Size of the buffer to read data into") +) + +func main() { + log.Println("Listening on UDP port", *address) + + conn, err := net.ListenPacket("udp", *address) + if err != nil { + log.Fatalf("Error starting UDP listener: %s", err) + } + defer conn.Close() + + msgCnt := uint64(0) + buffer := make([]byte, *bufSize) + for { + msgCnt++ + + n, caddr, err := conn.ReadFrom(buffer) + if err != nil { + log.Printf("Error reading from connection: %s", err) + continue + } + log.Printf("Recv(%d) %s: %s\n", msgCnt, caddr, string(buffer[:n])) + + if _, err := conn.WriteTo(buffer[:n], caddr); err != nil { + log.Printf("Error writing to connection %s: %s", caddr, err) + } + } +} diff --git a/e2e/udp/service.go b/e2e/udp/service.go new file mode 100644 index 00000000..c6c62ef6 --- /dev/null +++ b/e2e/udp/service.go @@ -0,0 +1,28 @@ +// Copyright Sauce Labs Inc., all rights reserved. + +package packetdrop + +import ( + "github.com/saucelabs/forwarder/utils/compose" +) + +type service compose.Service + +const ( + Image = "e2e-udp" + ServiceName = "udp" +) + +func Service() *service { + return &service{ + Name: ServiceName, + Image: Image, + Environment: map[string]string{}, + Privileged: true, + Network: map[string]compose.ServiceNetwork{}, + } +} + +func (s *service) Service() *compose.Service { + return (*compose.Service)(s) +} From dbe7b06cde7aa3862b44195af0e1338332155447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Matczuk?= Date: Mon, 27 Jan 2025 11:55:08 +0100 Subject: [PATCH 2/5] e2e/udp: add run target for local testing --- e2e/udp/Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/e2e/udp/Makefile b/e2e/udp/Makefile index 2c062d0e..b05353a5 100644 --- a/e2e/udp/Makefile +++ b/e2e/udp/Makefile @@ -9,3 +9,7 @@ export BUILDAH_FORMAT=docker .PHONY: update-image update-image: server @$(CONTAINER_RUNTIME) buildx build --network host -t e2e-udp -f Containerfile . + +.PHONY: run +run: + @$(CONTAINER_RUNTIME) run --rm -p 5005:5005/udp e2e-udp \ No newline at end of file From 62e0f9bf8a3ba3936e9666cd8ae4503c95f12b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Matczuk?= Date: Tue, 28 Jan 2025 10:33:21 +0100 Subject: [PATCH 3/5] martian: extract newConnectResponseStatus() from newConnectResponse() --- internal/martian/proxy_connect.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/martian/proxy_connect.go b/internal/martian/proxy_connect.go index 94f48355..5e5d6de2 100644 --- a/internal/martian/proxy_connect.go +++ b/internal/martian/proxy_connect.go @@ -171,10 +171,13 @@ func (p *Proxy) connectSOCKS5(req *http.Request, proxyURL *url.URL) (*http.Respo } func newConnectResponse(req *http.Request) *http.Response { - ok := http.StatusOK + return newConnectResponseStatus(req, http.StatusOK) +} + +func newConnectResponseStatus(req *http.Request, statusCode int) *http.Response { return &http.Response{ - Status: fmt.Sprintf("%d %s", ok, http.StatusText(ok)), - StatusCode: ok, + Status: fmt.Sprintf("%d %s", statusCode, http.StatusText(statusCode)), + StatusCode: statusCode, Proto: req.Proto, ProtoMajor: req.ProtoMajor, ProtoMinor: req.ProtoMinor, From 2b233c32f1cc340de074c469e78a3db9b7e38109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Matczuk?= Date: Tue, 28 Jan 2025 10:40:44 +0100 Subject: [PATCH 4/5] martian: basic support for UPD over HTTP --- internal/martian/copy.go | 3 ++ internal/martian/proxy.go | 4 ++ internal/martian/proxy_test.go | 95 ++++++++++++++++++++++++++++++++++ internal/martian/proxy_udp.go | 92 ++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 internal/martian/proxy_udp.go diff --git a/internal/martian/copy.go b/internal/martian/copy.go index 01a377ab..5238067d 100644 --- a/internal/martian/copy.go +++ b/internal/martian/copy.go @@ -20,6 +20,7 @@ import ( "bufio" "context" "io" + "net" "sync" "github.com/saucelabs/forwarder/internal/martian/log" @@ -72,6 +73,8 @@ func (c copier) copy(ctx context.Context, donec chan<- struct{}) { closeErr = cw.CloseWrite() } else if pw, ok := c.dst.(*io.PipeWriter); ok { closeErr = pw.Close() + } else if uc, ok := c.dst.(*net.UDPConn); ok { + closeErr = uc.Close() } else { log.Errorf(ctx, "cannot close write side of %s tunnel (%T)", c.name, c.dst) } diff --git a/internal/martian/proxy.go b/internal/martian/proxy.go index 8fd3078e..fd762a7c 100644 --- a/internal/martian/proxy.go +++ b/internal/martian/proxy.go @@ -415,6 +415,10 @@ func (p *Proxy) roundTrip(req *http.Request) (*http.Response, error) { return proxyutil.NewResponse(200, http.NoBody, req), nil } + if isUDPMasque(req) { + return p.roundTripUDPMasque(req) + } + res, err := p.rt.RoundTrip(req) if err != nil { return nil, err diff --git a/internal/martian/proxy_test.go b/internal/martian/proxy_test.go index 126feb00..141faa24 100644 --- a/internal/martian/proxy_test.go +++ b/internal/martian/proxy_test.go @@ -28,6 +28,7 @@ import ( "net/http" "net/url" "os" + "strconv" "strings" "testing" "time" @@ -1543,6 +1544,100 @@ func TestIntegrationTransparentMITM(t *testing.T) { } } +func TestIntegrationUDPMasque(t *testing.T) { + const bufSize = 64 + + // UDP echo server. + udpEchoServer := func(conn net.PacketConn) error { + buf := make([]byte, bufSize) + for { + n, caddr, err := conn.ReadFrom(buf) + if err != nil { + if errors.Is(err, net.ErrClosed) { + err = nil + } + return fmt.Errorf("conn.ReadFrom(): got %v, want no error", err) + } + + t.Logf("Recv %s: %s\n", caddr, string(buf[:n])) + + if _, err := conn.WriteTo(buf[:n], caddr); err != nil { + return fmt.Errorf("conn.WriteTo(): got %v, want no error", err) + } + } + } + + uc, err := net.ListenPacket("udp", "localhost:0") + if err != nil { + t.Fatalf("net.ListenPacket(): got %v, want no error", err) + } + defer uc.Close() + + go udpEchoServer(uc) + + h := testHelper{ + Proxy: func(p *Proxy) { + p.AllowHTTP = true + }, + } + + conn, cancel := h.proxyConn(t) + defer cancel() + + // GET /.well-known/masque/udp// HTTP/1.1 + udpMasqueURL := "/.well-known/masque/udp/127.0.0.1/" + strconv.Itoa(uc.LocalAddr().(*net.UDPAddr).Port) + req, err := http.NewRequest(http.MethodGet, udpMasqueURL, http.NoBody) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "connect-udp") + + if err := req.Write(conn); err != nil { + t.Fatalf("req.Write(): got %v, want no error", err) + } + + br := bufio.NewReader(conn) + + res, err := http.ReadResponse(br, req) + if err != nil { + t.Fatalf("http.ReadResponse(): got %v, want no error", err) + } + defer res.Body.Close() + + if res.StatusCode != 101 { + t.Fatalf("res.StatusCode: got %d, want 101", res.StatusCode) + } + if got, want := res.Header.Get("Connection"), "Upgrade"; got != want { + t.Errorf("res.Header.Get(%q): got %q, want %q", "Connection", got, want) + } + if got, want := res.Header.Get("Upgrade"), "connect-udp"; got != want { + t.Errorf("res.Header.Get(%q): got %q, want %q", "Upgrade", got, want) + } + + assertUDPEcho := func(msg string) { + if _, err := conn.Write([]byte(msg)); err != nil { + t.Fatalf("conn.Write(): got %v, want no error", err) + } + + buf := make([]byte, bufSize) + n, err := br.Read(buf) + if err != nil { + t.Fatalf("conn.Read(): got %v, want no error", err) + } + t.Logf("Recv: %s\n", string(buf[:n])) + + if string(buf[:n]) != msg { + t.Errorf("conn.Read(): got %q, want %q", buf[:n], msg) + } + } + + assertUDPEcho("hello") + assertUDPEcho("world") + conn.(*net.TCPConn).CloseWrite() + +} + func TestIntegrationFailedRoundTrip(t *testing.T) { t.Parallel() diff --git a/internal/martian/proxy_udp.go b/internal/martian/proxy_udp.go new file mode 100644 index 00000000..a45c7e9a --- /dev/null +++ b/internal/martian/proxy_udp.go @@ -0,0 +1,92 @@ +// Copyright 2022-2024 Sauce Labs Inc., all rights reserved. +// +// Copyright 2015 Google Inc. All rights reserved. +// +// 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 martian + +import ( + "errors" + "fmt" + "net" + "net/http" + "strings" +) + +const udpMasqueURLPathPrefix = "/.well-known/masque/udp/" + +// isUDPMasque checks if the request is a UDP Masque request as specified in +// https://www.rfc-editor.org/rfc/rfc9298.html#section-2. +// +// For example: +// - https://example.org/.well-known/masque/udp/{target_host}/{target_port}/ +// - https://proxy.example.org:4443/masque?h={target_host}&p={target_port} +// - https://proxy.example.org:4443/masque{?target_host,target_port} +// +// For simplicity, we only support the first format. +func isUDPMasque(req *http.Request) bool { + return strings.HasPrefix(req.URL.Path, "/.well-known/masque/udp/") +} + +func udpMasqueHostPort(req *http.Request) (host, port string) { + var ok bool + host, port, ok = strings.Cut(req.URL.Path[len(udpMasqueURLPathPrefix):], "/") + if !ok { + host, port = "", "" + } + return +} + +// validateUDPMasque makes sure the request is a valid UDP Masque request as specified in +// https://www.rfc-editor.org/rfc/rfc9298.html#name-http-11-request. +// +// Requirements: +// - The method SHALL be "GET". +// - The request SHALL include a single Host header field containing the origin of the UDP proxy. +// - The request SHALL include a Connection header field with value "Upgrade" (note that this requirement is case-insensitive as per Section 7.6.1 of [HTTP]). +// - The request SHALL include an Upgrade header field with value "connect-udp". +func validateUDPMasque(req *http.Request) error { + host, port := udpMasqueHostPort(req) + if host == "" || port == "" { + return errors.New("missing target host or port") + } + if req.Method != http.MethodGet { + return errors.New("invalid method") + } + if req.Header.Get("Connection") != "Upgrade" { + return errors.New("missing Connection: Upgrade header") + } + if req.Header.Get("Upgrade") != "connect-udp" { + return errors.New("missing Upgrade: connect-udp header") + } + return nil +} + +func (p *Proxy) roundTripUDPMasque(req *http.Request) (*http.Response, error) { + if err := validateUDPMasque(req); err != nil { + return nil, fmt.Errorf("invalid UDP Masque request: %w", err) + } + + host, port := udpMasqueHostPort(req) + conn, err := p.DialContext(req.Context(), "udp", net.JoinHostPort(host, port)) + if err != nil { + return nil, err + } + + resp := newConnectResponseStatus(req, http.StatusSwitchingProtocols) + resp.Header.Set("Connection", "Upgrade") + resp.Header.Set("Upgrade", "connect-udp") + resp.Body = conn + return resp, nil +} From c4d57fa9ad322f84f3408f813fec48685da13c78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Matczuk?= Date: Tue, 28 Jan 2025 11:01:00 +0100 Subject: [PATCH 5/5] DROPME e2e/udp/cmd/server: server test WIP === RUN TestUDPOverHTTP server_test.go:41: 1 h 4 ello server_test.go:41: 5 hello server_test.go:41: 5 hello server_test.go:41: 5 hello server_test.go:41: 5 hello server_test.go:41: 5 hello server_test.go:41: 5 hello server_test.go:41: 5 hello server_test.go:41: 5 hello server_test.go:41: 5 hello --- PASS: TestUDPOverHTTP (1.12s) PASS --- e2e/udp/cmd/server/server_test.go | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 e2e/udp/cmd/server/server_test.go diff --git a/e2e/udp/cmd/server/server_test.go b/e2e/udp/cmd/server/server_test.go new file mode 100644 index 00000000..6ad8c309 --- /dev/null +++ b/e2e/udp/cmd/server/server_test.go @@ -0,0 +1,49 @@ +// Copyright 2022-2024 Sauce Labs Inc., all rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package main + +import ( + "io" + "net/http" + "testing" + "time" +) + +func TestUDPOverHTTP(t *testing.T) { + pr, pw := io.Pipe() + defer pr.Close() + defer pw.Close() + + req, err := http.NewRequest(http.MethodGet, "http://localhost:3128/.well-known/masque/udp/localhost/5005", pr) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "connect-udp") + req.ContentLength = -1 + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + + go func() { + for i := 0; i < 10; i++ { + pw.Write([]byte("hello")) + time.Sleep(100 * time.Millisecond) + } + }() + + buf := make([]byte, 1000) + for i := 0; i < 10; i++ { + n, err := resp.Body.Read(buf) + if err != nil { + t.Fatal(err) + } + t.Log(string(buf[:n])) + } +}