diff --git a/.gitignore b/.gitignore index 70e7699..a478899 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,9 @@ node_modules .env.local .vercel .env*.local +todo/ +dist/ + +# Go compiled binaries +packages/http-proxy-server/http-proxy-server +packages/http-proxy-server/public/ \ No newline at end of file diff --git a/packages/http-proxy-server/go.mod b/packages/http-proxy-server/go.mod new file mode 100644 index 0000000..2746f7a --- /dev/null +++ b/packages/http-proxy-server/go.mod @@ -0,0 +1,8 @@ +module github.com/vercel/sandbox/http-proxy-server + +go 1.22.0 + +require ( + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 +) diff --git a/packages/http-proxy-server/go.sum b/packages/http-proxy-server/go.sum new file mode 100644 index 0000000..73bbf57 --- /dev/null +++ b/packages/http-proxy-server/go.sum @@ -0,0 +1,4 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/packages/http-proxy-server/main.go b/packages/http-proxy-server/main.go new file mode 100644 index 0000000..efb0e49 --- /dev/null +++ b/packages/http-proxy-server/main.go @@ -0,0 +1,144 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + + "github.com/vercel/sandbox/http-proxy-server/proxy" + "github.com/vercel/sandbox/http-proxy-server/ws" +) + +const defaultConfigPath = "/tmp/vercel/http-proxy/config.json" + +// ConnectionInfo is output to stdout as JSON for the TS client to parse. +type ConnectionInfo struct { + WsPort int `json:"wsPort"` + ProxyPort int `json:"proxyPort"` + Token string `json:"token"` +} + +func main() { + var ( + help = flag.Bool("help", false, "Show help") + wsPort = flag.Int("ws-port", 0, "Port for WebSocket server (0 for auto)") + proxyPort = flag.Int("proxy-port", 0, "Port for HTTP proxy on localhost (0 for auto)") + token = flag.String("token", "", "Authentication token (auto-generated if empty)") + debug = flag.Bool("debug", false, "Enable debug logging") + ) + + flag.Parse() + + if *help { + printUsage() + return + } + + logLevel := new(slog.LevelVar) + logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: logLevel, + })) + + if *debug { + logLevel.Set(slog.LevelDebug) + logger.Debug("Debug logging enabled") + } + + // Start WebSocket server + wsServer, err := ws.NewServer(logger.With("component", "ws"), *token, *wsPort) + if err != nil { + logger.Error("Failed to create WebSocket server", "error", err) + os.Exit(1) + } + + go func() { + logger.Info("WebSocket server starting", "port", wsServer.Port) + if err := wsServer.ListenAndServe(); err != nil { + logger.Error("WebSocket server error", "error", err) + os.Exit(1) + } + }() + + // Generate CA for HTTPS MITM and install in system trust store + ca, err := proxy.NewCA() + if err != nil { + logger.Error("Failed to generate CA", "error", err) + os.Exit(1) + } + + certPath := "/etc/pki/ca-trust/source/anchors/vc-proxy-ca.pem" + if err := os.MkdirAll(filepath.Dir(certPath), 0755); err != nil { + logger.Warn("Failed to create cert dir (may need sudo)", "error", err) + } + if err := os.WriteFile(certPath, ca.CertPEM(), 0644); err != nil { + logger.Warn("Failed to write CA cert (may need sudo)", "error", err) + } else { + // Update the system trust store + cmd := exec.Command("update-ca-trust", "extract") + if out, err := cmd.CombinedOutput(); err != nil { + logger.Warn("Failed to update CA trust", "error", err, "output", string(out)) + } else { + logger.Info("Installed MITM CA certificate in system trust store") + } + } + + // Start HTTP proxy server + proxyServer, err := proxy.NewServer(logger.With("component", "proxy"), wsServer.Hub, ca, *proxyPort) + if err != nil { + logger.Error("Failed to create proxy server", "error", err) + os.Exit(1) + } + + // Output connection info as JSON to stdout (TS client reads this) + info := ConnectionInfo{ + WsPort: wsServer.Port, + ProxyPort: proxyServer.Port, + Token: wsServer.Token, + } + infoJSON, _ := json.Marshal(info) + fmt.Println(string(infoJSON)) + + // Also persist to config file so subsequent clients can connect + if err := os.MkdirAll(filepath.Dir(defaultConfigPath), 0755); err != nil { + logger.Error("Failed to create config directory", "error", err) + } else if err := os.WriteFile(defaultConfigPath, infoJSON, 0644); err != nil { + logger.Error("Failed to write config file", "error", err) + } + + // Wait for the TS client to send "ready" before accepting proxy requests + go func() { + <-wsServer.Hub.Ready() + logger.Info("TS client ready, proxy is now active") + }() + + logger.Info("HTTP proxy server starting", "port", proxyServer.Port) + if err := proxyServer.ListenAndServe(); err != nil { + logger.Error("Proxy server error", "error", err) + os.Exit(1) + } +} + +func printUsage() { + fmt.Printf(`HTTP Proxy Server - WebSocket Tunnel + +USAGE: + http-proxy-server [OPTIONS] + +OPTIONS: + --ws-port Port for WebSocket server (0 for auto, default: 0) + --proxy-port Port for HTTP proxy on localhost (0 for auto, default: 0) + --token Authentication token (auto-generated if empty) + --debug Enable debug logging + --help Show this help message + +ARCHITECTURE: + Programs inside the sandbox use HTTP_PROXY=http://:x@localhost: + to route requests through this proxy. The proxy forwards each request over WebSocket + to an external TypeScript client, which calls a per-session callback and returns the + response. +`) +} diff --git a/packages/http-proxy-server/package.json b/packages/http-proxy-server/package.json new file mode 100644 index 0000000..8787e96 --- /dev/null +++ b/packages/http-proxy-server/package.json @@ -0,0 +1,13 @@ +{ + "name": "@vercel/http-proxy-server", + "version": "0.0.1", + "private": true, + "license": "Apache-2.0", + "scripts": { + "vercel-build": "./scripts/build-binaries.sh && ./scripts/build.sh", + "build": "turbo run build:install-script", + "test": "go test ./...", + "build:binaries": "scripts/build-binaries.sh", + "build:install-script": "scripts/build.sh" + } +} diff --git a/packages/http-proxy-server/protocol/messages.go b/packages/http-proxy-server/protocol/messages.go new file mode 100644 index 0000000..4599db3 --- /dev/null +++ b/packages/http-proxy-server/protocol/messages.go @@ -0,0 +1,96 @@ +package protocol + +import "encoding/json" + +// Message types sent between the Go proxy and the TypeScript client. + +const ( + TypeRequest = "request" + TypeResponse = "response" + TypeConnect = "connect" + TypeConnectResponse = "connect-response" + TypeReady = "ready" + TypeError = "error" + TypeRegister = "register" + TypeRegisterAck = "register-ack" + TypeUnregister = "unregister" +) + +// Envelope is the top-level JSON frame on the WebSocket. +type Envelope struct { + Type string `json:"type"` +} + +// ProxyRequest is sent from Go → TS when an HTTP request arrives. +type ProxyRequest struct { + Type string `json:"type"` // "request" + RequestID string `json:"requestId"` + SessionID string `json:"sessionId"` + Method string `json:"method"` + URL string `json:"url"` + Headers map[string][]string `json:"headers"` + Body string `json:"body,omitempty"` // base64 +} + +// ProxyResponse is sent from TS → Go with the callback result. +type ProxyResponse struct { + Type string `json:"type"` // "response" + RequestID string `json:"requestId"` + Status int `json:"status"` + Headers map[string][]string `json:"headers,omitempty"` + Body string `json:"body,omitempty"` // base64 +} + +// ConnectRequest is sent from Go → TS for HTTPS CONNECT tunneling. +type ConnectRequest struct { + Type string `json:"type"` // "connect" + RequestID string `json:"requestId"` + SessionID string `json:"sessionId"` + Host string `json:"host"` +} + +// ConnectResponse is sent from TS → Go to allow/deny a CONNECT. +type ConnectResponse struct { + Type string `json:"type"` // "connect-response" + RequestID string `json:"requestId"` + Allow bool `json:"allow"` +} + +// ReadyMessage is sent from TS → Go on initial handshake. +type ReadyMessage struct { + Type string `json:"type"` // "ready" +} + +// ErrorMessage is sent from TS → Go when a callback fails. +type ErrorMessage struct { + Type string `json:"type"` // "error" + RequestID string `json:"requestId"` + Message string `json:"message"` +} + +// RegisterMessage is sent from TS → Go to claim ownership of sessions. +type RegisterMessage struct { + Type string `json:"type"` // "register" + SessionIDs []string `json:"sessionIds"` +} + +// RegisterAckMessage is sent from Go → TS to confirm registration. +type RegisterAckMessage struct { + Type string `json:"type"` // "register-ack" + SessionIDs []string `json:"sessionIds"` +} + +// UnregisterMessage is sent from TS → Go to release session ownership. +type UnregisterMessage struct { + Type string `json:"type"` // "unregister" + SessionIDs []string `json:"sessionIds"` +} + +// ParseType extracts the message type from a raw JSON frame. +func ParseType(data []byte) (string, error) { + var env Envelope + if err := json.Unmarshal(data, &env); err != nil { + return "", err + } + return env.Type, nil +} diff --git a/packages/http-proxy-server/protocol/messages_test.go b/packages/http-proxy-server/protocol/messages_test.go new file mode 100644 index 0000000..e990215 --- /dev/null +++ b/packages/http-proxy-server/protocol/messages_test.go @@ -0,0 +1,204 @@ +package protocol + +import ( + "encoding/json" + "testing" +) + +func TestParseType(t *testing.T) { + tests := []struct { + name string + input string + expected string + wantErr bool + }{ + {"request", `{"type":"request","requestId":"abc"}`, TypeRequest, false}, + {"response", `{"type":"response","requestId":"abc"}`, TypeResponse, false}, + {"connect", `{"type":"connect","requestId":"abc"}`, TypeConnect, false}, + {"connect-response", `{"type":"connect-response","requestId":"abc"}`, TypeConnectResponse, false}, + {"ready", `{"type":"ready"}`, TypeReady, false}, + {"error", `{"type":"error","requestId":"abc"}`, TypeError, false}, + {"empty type", `{"type":""}`, "", false}, + {"invalid json", `not json`, "", true}, + {"empty input", ``, "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseType([]byte(tt.input)) + if tt.wantErr { + if err == nil { + t.Error("expected error but got none") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.expected { + t.Errorf("got %q, want %q", got, tt.expected) + } + }) + } +} + +func TestProxyRequestRoundtrip(t *testing.T) { + req := ProxyRequest{ + Type: TypeRequest, + RequestID: "req-123", + SessionID: "sess-456", + Method: "POST", + URL: "http://example.com/api", + Headers: map[string][]string{"Content-Type": {"application/json"}}, + Body: "aGVsbG8=", // base64("hello") + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ProxyRequest + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.Type != req.Type { + t.Errorf("type: got %q, want %q", decoded.Type, req.Type) + } + if decoded.RequestID != req.RequestID { + t.Errorf("requestId: got %q, want %q", decoded.RequestID, req.RequestID) + } + if decoded.SessionID != req.SessionID { + t.Errorf("sessionId: got %q, want %q", decoded.SessionID, req.SessionID) + } + if decoded.Method != req.Method { + t.Errorf("method: got %q, want %q", decoded.Method, req.Method) + } + if decoded.URL != req.URL { + t.Errorf("url: got %q, want %q", decoded.URL, req.URL) + } + if decoded.Body != req.Body { + t.Errorf("body: got %q, want %q", decoded.Body, req.Body) + } +} + +func TestProxyResponseRoundtrip(t *testing.T) { + resp := ProxyResponse{ + Type: TypeResponse, + RequestID: "req-123", + Status: 201, + Headers: map[string][]string{"X-Custom": {"value1", "value2"}}, + Body: "d29ybGQ=", // base64("world") + } + + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ProxyResponse + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.Status != resp.Status { + t.Errorf("status: got %d, want %d", decoded.Status, resp.Status) + } + if decoded.RequestID != resp.RequestID { + t.Errorf("requestId: got %q, want %q", decoded.RequestID, resp.RequestID) + } + if len(decoded.Headers["X-Custom"]) != 2 { + t.Errorf("headers: got %v, want 2 values", decoded.Headers["X-Custom"]) + } +} + +func TestConnectRequestRoundtrip(t *testing.T) { + req := ConnectRequest{ + Type: TypeConnect, + RequestID: "req-789", + SessionID: "sess-abc", + Host: "example.com:443", + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ConnectRequest + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.Host != req.Host { + t.Errorf("host: got %q, want %q", decoded.Host, req.Host) + } +} + +func TestConnectResponseRoundtrip(t *testing.T) { + resp := ConnectResponse{ + Type: TypeConnectResponse, + RequestID: "req-789", + Allow: true, + } + + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ConnectResponse + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.Allow != true { + t.Error("allow: got false, want true") + } +} + +func TestErrorMessageRoundtrip(t *testing.T) { + msg := ErrorMessage{ + Type: TypeError, + RequestID: "req-err", + Message: "something went wrong", + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ErrorMessage + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.Message != msg.Message { + t.Errorf("message: got %q, want %q", decoded.Message, msg.Message) + } +} + +func TestEmptyBodyOmitted(t *testing.T) { + req := ProxyRequest{ + Type: TypeRequest, + RequestID: "req-1", + SessionID: "sess-1", + Method: "GET", + URL: "http://example.com", + Headers: map[string][]string{}, + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + // body should be omitted when empty + var raw map[string]interface{} + json.Unmarshal(data, &raw) + if _, exists := raw["body"]; exists { + t.Error("expected body to be omitted from JSON when empty") + } +} diff --git a/packages/http-proxy-server/proxy/mitm.go b/packages/http-proxy-server/proxy/mitm.go new file mode 100644 index 0000000..a61e6e6 --- /dev/null +++ b/packages/http-proxy-server/proxy/mitm.go @@ -0,0 +1,133 @@ +package proxy + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "sync" + "time" +) + +// CA holds an in-memory certificate authority for MITM proxying. +type CA struct { + cert *x509.Certificate + key *ecdsa.PrivateKey + certPEM []byte + certDER []byte + + // Cache of generated leaf certificates keyed by hostname + leafCache sync.Map // map[string]*tls.Certificate +} + +// NewCA generates a new ECDSA P-256 CA certificate and key pair. +func NewCA() (*CA, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating CA key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("generating serial: %w", err) + } + + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + Organization: []string{"Vercel"}, + CommonName: "Vercel Sandbox Proxy CA", + }, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 0, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return nil, fmt.Errorf("creating CA certificate: %w", err) + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, fmt.Errorf("parsing CA certificate: %w", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + return &CA{ + cert: cert, + key: key, + certPEM: certPEM, + certDER: certDER, + }, nil +} + +// CertPEM returns the PEM-encoded CA certificate for installation in trust stores. +func (ca *CA) CertPEM() []byte { + return ca.certPEM +} + +// TLSConfigForHost returns a *tls.Config that presents a leaf certificate +// for the given hostname, signed by this CA. Leaf certs are cached. +func (ca *CA) TLSConfigForHost(hostname string) (*tls.Config, error) { + leaf, err := ca.leafCert(hostname) + if err != nil { + return nil, err + } + return &tls.Config{ + Certificates: []tls.Certificate{*leaf}, + }, nil +} + +func (ca *CA) leafCert(hostname string) (*tls.Certificate, error) { + if cached, ok := ca.leafCache.Load(hostname); ok { + return cached.(*tls.Certificate), nil + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating leaf key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("generating serial: %w", err) + } + + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + Organization: []string{"Vercel Sandbox Proxy"}, + CommonName: hostname, + }, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + }, + DNSNames: []string{hostname}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, ca.cert, &key.PublicKey, ca.key) + if err != nil { + return nil, fmt.Errorf("creating leaf certificate: %w", err) + } + + leaf := &tls.Certificate{ + Certificate: [][]byte{certDER, ca.certDER}, + PrivateKey: key, + } + + ca.leafCache.Store(hostname, leaf) + return leaf, nil +} diff --git a/packages/http-proxy-server/proxy/server.go b/packages/http-proxy-server/proxy/server.go new file mode 100644 index 0000000..025c225 --- /dev/null +++ b/packages/http-proxy-server/proxy/server.go @@ -0,0 +1,355 @@ +package proxy + +import ( + "bufio" + "crypto/tls" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/vercel/sandbox/http-proxy-server/protocol" + "github.com/vercel/sandbox/http-proxy-server/ws" +) + +const requestTimeout = 30 * time.Second + +// Server is the HTTP proxy that listens on localhost. +// Programs inside the sandbox set HTTP_PROXY to point here. +type Server struct { + Port int + CA *CA + hub *ws.Hub + logger *slog.Logger + server *http.Server + listener net.Listener +} + +func NewServer(logger *slog.Logger, hub *ws.Hub, ca *CA, port int) (*Server, error) { + s := &Server{ + hub: hub, + logger: logger, + CA: ca, + } + + addr := fmt.Sprintf("127.0.0.1:%d", port) + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("listening on %s: %v", addr, err) + } + + s.Port = ln.Addr().(*net.TCPAddr).Port + s.listener = ln + s.server = &http.Server{ + Handler: s, + ReadTimeout: requestTimeout + 5*time.Second, + WriteTimeout: requestTimeout + 5*time.Second, + } + + return s, nil +} + +func (s *Server) ListenAndServe() error { + return s.server.Serve(s.listener) +} + +// ServeHTTP handles both plain HTTP proxy requests and CONNECT tunneling. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + s.handleConnect(w, r) + return + } + s.handleHTTP(w, r) +} + +func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { + sessionID := ExtractSessionID(r.Header.Get("Proxy-Authorization")) + requestID := uuid.New().String() + + logger := s.logger.With("requestId", requestID, "sessionId", sessionID, "method", r.Method, "url", r.URL.String()) + logger.Debug("Proxying HTTP request") + + // Read the request body + var bodyBase64 string + if r.Body != nil { + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + logger.Error("Failed to read request body", "error", err) + http.Error(w, "failed to read body", http.StatusBadGateway) + return + } + if len(bodyBytes) > 0 { + bodyBase64 = base64.StdEncoding.EncodeToString(bodyBytes) + } + } + + // Strip hop-by-hop headers and Proxy-Authorization + headers := make(map[string][]string) + for k, v := range r.Header { + switch k { + case "Proxy-Authorization", "Proxy-Connection": + continue + default: + headers[k] = v + } + } + + msg := protocol.ProxyRequest{ + Type: protocol.TypeRequest, + RequestID: requestID, + SessionID: sessionID, + Method: r.Method, + URL: r.URL.String(), + Headers: headers, + Body: bodyBase64, + } + + respData, err := s.hub.SendToSessionAndWait(sessionID, requestID, msg) + if err != nil { + logger.Error("Failed to get response from TS client", "error", err) + http.Error(w, "proxy error: "+err.Error(), http.StatusBadGateway) + return + } + + // Check if it's an error message + msgType, _ := protocol.ParseType(respData) + if msgType == protocol.TypeError { + var errMsg protocol.ErrorMessage + json.Unmarshal(respData, &errMsg) + logger.Error("TS client returned error", "message", errMsg.Message) + http.Error(w, errMsg.Message, http.StatusBadGateway) + return + } + + var resp protocol.ProxyResponse + if err := json.Unmarshal(respData, &resp); err != nil { + logger.Error("Failed to parse response", "error", err) + http.Error(w, "invalid response from handler", http.StatusBadGateway) + return + } + + // Write response headers + for k, vals := range resp.Headers { + for _, v := range vals { + w.Header().Add(k, v) + } + } + + w.WriteHeader(resp.Status) + + // Write response body + if resp.Body != "" { + bodyBytes, err := base64.StdEncoding.DecodeString(resp.Body) + if err != nil { + logger.Error("Failed to decode response body", "error", err) + return + } + w.Write(bodyBytes) + } +} + +func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { + sessionID := ExtractSessionID(r.Header.Get("Proxy-Authorization")) + + // Extract hostname (strip port) + hostname := r.Host + if h, _, err := net.SplitHostPort(hostname); err == nil { + hostname = h + } + + logger := s.logger.With("sessionId", sessionID, "host", r.Host, "hostname", hostname) + logger.Debug("Proxying CONNECT request") + + // Ask the handler whether to allow this CONNECT + connectRequestID := uuid.New().String() + msg := protocol.ConnectRequest{ + Type: protocol.TypeConnect, + RequestID: connectRequestID, + SessionID: sessionID, + Host: r.Host, + } + + respData, err := s.hub.SendToSessionAndWait(sessionID, connectRequestID, msg) + if err != nil { + logger.Error("Failed to get connect response", "error", err) + http.Error(w, "proxy error", http.StatusBadGateway) + return + } + + msgType, _ := protocol.ParseType(respData) + if msgType == protocol.TypeError { + var errMsg protocol.ErrorMessage + json.Unmarshal(respData, &errMsg) + http.Error(w, errMsg.Message, http.StatusForbidden) + return + } + + var resp protocol.ConnectResponse + if err := json.Unmarshal(respData, &resp); err != nil { + logger.Error("Failed to parse connect response", "error", err) + http.Error(w, "invalid response", http.StatusBadGateway) + return + } + + if !resp.Allow { + http.Error(w, "CONNECT denied by proxy handler", http.StatusForbidden) + return + } + + // MITM: hijack the connection and do TLS termination + hijacker, ok := w.(http.Hijacker) + if !ok { + logger.Error("ResponseWriter does not support hijacking") + http.Error(w, "hijack not supported", http.StatusInternalServerError) + return + } + + clientConn, _, err := hijacker.Hijack() + if err != nil { + logger.Error("Failed to hijack connection", "error", err) + return + } + defer clientConn.Close() + + // Tell the client the tunnel is established + clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + + // Generate a TLS cert for this hostname and do a TLS handshake + tlsConfig, err := s.CA.TLSConfigForHost(hostname) + if err != nil { + logger.Error("Failed to generate TLS config", "error", err, "hostname", hostname) + return + } + + tlsConn := tls.Server(clientConn, tlsConfig) + if err := tlsConn.Handshake(); err != nil { + logger.Error("TLS handshake failed", "error", err) + return + } + defer tlsConn.Close() + + // Read HTTP requests from the decrypted TLS connection + reader := bufio.NewReader(tlsConn) + for { + req, err := http.ReadRequest(reader) + if err != nil { + if err != io.EOF { + logger.Debug("Error reading request from TLS conn", "error", err) + } + return + } + + // Reconstruct the full URL (the request inside TLS has a relative path) + if !strings.HasPrefix(req.URL.String(), "http") { + req.URL.Scheme = "https" + req.URL.Host = r.Host + } + + s.handleDecryptedRequest(logger, sessionID, tlsConn, req) + } +} + +// handleDecryptedRequest handles an HTTP request read from a decrypted TLS connection. +// It serializes the request, sends it to the JS handler, and writes the response back. +func (s *Server) handleDecryptedRequest(logger *slog.Logger, sessionID string, w io.Writer, r *http.Request) { + requestID := uuid.New().String() + logger = logger.With("requestId", requestID, "method", r.Method, "url", r.URL.String()) + logger.Debug("Proxying decrypted HTTPS request") + + var bodyBase64 string + if r.Body != nil { + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + logger.Error("Failed to read request body", "error", err) + writeHTTPResponse(w, 502, "failed to read body") + return + } + if len(bodyBytes) > 0 { + bodyBase64 = base64.StdEncoding.EncodeToString(bodyBytes) + } + } + + headers := make(map[string][]string) + for k, v := range r.Header { + headers[k] = v + } + + msg := protocol.ProxyRequest{ + Type: protocol.TypeRequest, + RequestID: requestID, + SessionID: sessionID, + Method: r.Method, + URL: r.URL.String(), + Headers: headers, + Body: bodyBase64, + } + + respData, err := s.hub.SendToSessionAndWait(sessionID, requestID, msg) + if err != nil { + logger.Error("Failed to get response from handler", "error", err) + writeHTTPResponse(w, 502, "proxy error: "+err.Error()) + return + } + + msgType, _ := protocol.ParseType(respData) + if msgType == protocol.TypeError { + var errMsg protocol.ErrorMessage + json.Unmarshal(respData, &errMsg) + writeHTTPResponse(w, 502, errMsg.Message) + return + } + + var proxyResp protocol.ProxyResponse + if err := json.Unmarshal(respData, &proxyResp); err != nil { + logger.Error("Failed to parse response", "error", err) + writeHTTPResponse(w, 502, "invalid response from handler") + return + } + + // Build and write the HTTP response + var body []byte + if proxyResp.Body != "" { + body, err = base64.StdEncoding.DecodeString(proxyResp.Body) + if err != nil { + logger.Error("Failed to decode response body", "error", err) + writeHTTPResponse(w, 502, "invalid response body") + return + } + } + + resp := &http.Response{ + StatusCode: proxyResp.Status, + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header), + ContentLength: int64(len(body)), + } + for k, vals := range proxyResp.Headers { + for _, v := range vals { + resp.Header.Add(k, v) + } + } + if body != nil { + resp.Body = io.NopCloser(strings.NewReader(string(body))) + } + resp.Write(w) +} + +func writeHTTPResponse(w io.Writer, status int, body string) { + resp := &http.Response{ + StatusCode: status, + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header), + ContentLength: int64(len(body)), + Body: io.NopCloser(strings.NewReader(body)), + } + resp.Header.Set("Content-Type", "text/plain") + resp.Write(w) +} diff --git a/packages/http-proxy-server/proxy/server_test.go b/packages/http-proxy-server/proxy/server_test.go new file mode 100644 index 0000000..48b7843 --- /dev/null +++ b/packages/http-proxy-server/proxy/server_test.go @@ -0,0 +1,448 @@ +package proxy + +import ( + "bufio" + "crypto/tls" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/vercel/sandbox/http-proxy-server/protocol" + "github.com/vercel/sandbox/http-proxy-server/ws" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) +} + +// startTestStack spins up a WS server + HTTP proxy connected to the same hub. +// Returns the proxy port, WS URL, and a cleanup function. +func startTestStack(t *testing.T) (proxyPort int, wsURL string, hub *ws.Hub) { + t.Helper() + + logger := testLogger() + token := "test-token" + + // Create WS server + wsServer, err := ws.NewServer(logger, token, 0) + if err != nil { + t.Fatalf("create ws server: %v", err) + } + hub = wsServer.Hub + + go wsServer.ListenAndServe() + // Give it a moment to start + time.Sleep(50 * time.Millisecond) + + // Create CA and proxy server + ca, err := NewCA() + if err != nil { + t.Fatalf("create CA: %v", err) + } + proxyServer, err := NewServer(logger, hub, ca, 0) + if err != nil { + t.Fatalf("create proxy server: %v", err) + } + proxyPort = proxyServer.Port + + go proxyServer.ListenAndServe() + time.Sleep(50 * time.Millisecond) + + wsURL = fmt.Sprintf("ws://localhost:%d/ws?token=%s", wsServer.Port, token) + return +} + +// connectMockClient connects a WebSocket client to the hub, sends ready, and registers sessions. +func connectMockClient(t *testing.T, wsURL string, sessionIDs ...string) *websocket.Conn { + t.Helper() + + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial ws: %v", err) + } + + // Send ready + ready, _ := json.Marshal(protocol.ReadyMessage{Type: protocol.TypeReady}) + conn.WriteMessage(websocket.TextMessage, ready) + + // Register sessions + if len(sessionIDs) > 0 { + reg, _ := json.Marshal(protocol.RegisterMessage{Type: protocol.TypeRegister, SessionIDs: sessionIDs}) + conn.WriteMessage(websocket.TextMessage, reg) + } + time.Sleep(50 * time.Millisecond) + return conn +} + +// readRequest reads a ProxyRequest from the WebSocket. +func readRequest(t *testing.T, conn *websocket.Conn) protocol.ProxyRequest { + t.Helper() + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, data, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read ws message: %v", err) + } + var req protocol.ProxyRequest + if err := json.Unmarshal(data, &req); err != nil { + t.Fatalf("unmarshal request: %v (data: %s)", err, string(data)) + } + return req +} + +// sendResponse sends a ProxyResponse back through the WebSocket. +func sendResponse(t *testing.T, conn *websocket.Conn, requestID string, status int, body string) { + t.Helper() + var bodyBase64 string + if body != "" { + bodyBase64 = base64.StdEncoding.EncodeToString([]byte(body)) + } + resp := protocol.ProxyResponse{ + Type: protocol.TypeResponse, + RequestID: requestID, + Status: status, + Headers: map[string][]string{"X-Test": {"passed"}}, + Body: bodyBase64, + } + data, _ := json.Marshal(resp) + conn.WriteMessage(websocket.TextMessage, data) +} + +func TestProxyHTTPRequest(t *testing.T) { + proxyPort, wsURL, _ := startTestStack(t) + wsConn := connectMockClient(t, wsURL, "my-session") + defer wsConn.Close() + + // Make a proxied HTTP request in a goroutine + done := make(chan *http.Response, 1) + go func() { + proxyURL := fmt.Sprintf("http://my-session:x@127.0.0.1:%d", proxyPort) + transport := &http.Transport{ + Proxy: func(r *http.Request) (*url.URL, error) { + return url.Parse(proxyURL) + }, + } + client := &http.Client{Transport: transport} + resp, err := client.Get("http://example.com/test") + if err != nil { + t.Errorf("proxy request failed: %v", err) + done <- nil + return + } + done <- resp + }() + + // Read the proxied request from WS + req := readRequest(t, wsConn) + if req.SessionID != "my-session" { + t.Errorf("sessionId: got %q, want %q", req.SessionID, "my-session") + } + if req.Method != "GET" { + t.Errorf("method: got %q, want GET", req.Method) + } + if !strings.Contains(req.URL, "example.com/test") { + t.Errorf("url: got %q, want to contain example.com/test", req.URL) + } + + // Send response back + sendResponse(t, wsConn, req.RequestID, 200, "hello proxy") + + resp := <-done + if resp == nil { + t.Fatal("no response received") + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("status: got %d, want 200", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != "hello proxy" { + t.Errorf("body: got %q, want %q", string(body), "hello proxy") + } + if resp.Header.Get("X-Test") != "passed" { + t.Errorf("header X-Test: got %q, want %q", resp.Header.Get("X-Test"), "passed") + } +} + +func TestProxyPOSTWithBody(t *testing.T) { + proxyPort, wsURL, _ := startTestStack(t) + wsConn := connectMockClient(t, wsURL, "post-session") + defer wsConn.Close() + + done := make(chan *http.Response, 1) + go func() { + proxyURL := fmt.Sprintf("http://post-session:x@127.0.0.1:%d", proxyPort) + transport := &http.Transport{ + Proxy: func(r *http.Request) (*url.URL, error) { + return url.Parse(proxyURL) + }, + } + client := &http.Client{Transport: transport} + resp, err := client.Post("http://example.com/api", "text/plain", strings.NewReader("request-body")) + if err != nil { + t.Errorf("proxy POST failed: %v", err) + done <- nil + return + } + done <- resp + }() + + req := readRequest(t, wsConn) + if req.Method != "POST" { + t.Errorf("method: got %q, want POST", req.Method) + } + // Decode body + bodyBytes, err := base64.StdEncoding.DecodeString(req.Body) + if err != nil { + t.Fatalf("decode body: %v", err) + } + if string(bodyBytes) != "request-body" { + t.Errorf("body: got %q, want %q", string(bodyBytes), "request-body") + } + + sendResponse(t, wsConn, req.RequestID, 201, "created") + + resp := <-done + if resp == nil { + t.Fatal("no response") + } + defer resp.Body.Close() + if resp.StatusCode != 201 { + t.Errorf("status: got %d, want 201", resp.StatusCode) + } +} + +func TestProxyErrorResponse(t *testing.T) { + proxyPort, wsURL, _ := startTestStack(t) + wsConn := connectMockClient(t, wsURL, "err-session") + defer wsConn.Close() + + done := make(chan *http.Response, 1) + go func() { + proxyURL := fmt.Sprintf("http://err-session:x@127.0.0.1:%d", proxyPort) + transport := &http.Transport{ + Proxy: func(r *http.Request) (*url.URL, error) { + return url.Parse(proxyURL) + }, + } + client := &http.Client{Transport: transport} + resp, err := client.Get("http://example.com/fail") + if err != nil { + t.Errorf("request failed: %v", err) + done <- nil + return + } + done <- resp + }() + + req := readRequest(t, wsConn) + + // Send error response + errMsg := protocol.ErrorMessage{ + Type: protocol.TypeError, + RequestID: req.RequestID, + Message: "handler denied", + } + data, _ := json.Marshal(errMsg) + wsConn.WriteMessage(websocket.TextMessage, data) + + resp := <-done + if resp == nil { + t.Fatal("no response") + } + defer resp.Body.Close() + if resp.StatusCode != 502 { + t.Errorf("status: got %d, want 502", resp.StatusCode) + } +} + +func TestProxyCONNECTMITM(t *testing.T) { + proxyPort, wsURL, hub := startTestStack(t) + wsConn := connectMockClient(t, wsURL, "connect-session") + defer wsConn.Close() + + // Get the CA cert from the proxy server to build a TLS trust pool + // We need to find the CA through the hub → proxy path. For testing, + // extract it from the startTestStack's proxyServer. + // Instead, we'll just skip TLS verification in the test client. + + done := make(chan string, 1) + go func() { + conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", proxyPort)) + if err != nil { + done <- "dial error: " + err.Error() + return + } + defer conn.Close() + + // Send CONNECT + authValue := base64.StdEncoding.EncodeToString([]byte("connect-session:x")) + fmt.Fprintf(conn, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nProxy-Authorization: Basic %s\r\n\r\n", authValue) + + // Read 200 Connection Established + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + response := string(buf[:n]) + if !strings.Contains(response, "200") { + done <- "no 200: " + response + return + } + + // Do TLS handshake (skip verify since we don't have the CA cert pool in-test) + tlsConn := tls.Client(conn, &tls.Config{ + InsecureSkipVerify: true, + ServerName: "example.com", + }) + if err := tlsConn.Handshake(); err != nil { + done <- "tls handshake: " + err.Error() + return + } + defer tlsConn.Close() + + // Send an HTTP request over the TLS connection + fmt.Fprintf(tlsConn, "GET /path HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n") + + // Read the full HTTP response + resp, err := http.ReadResponse(bufio.NewReader(tlsConn), nil) + if err != nil { + done <- "read response: " + err.Error() + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + done <- string(body) + }() + + // Read CONNECT request from WS → allow it + wsConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, data, err := wsConn.ReadMessage() + if err != nil { + t.Fatalf("read ws connect: %v", err) + } + var connectReq protocol.ConnectRequest + json.Unmarshal(data, &connectReq) + + allowResp := protocol.ConnectResponse{ + Type: protocol.TypeConnectResponse, + RequestID: connectReq.RequestID, + Allow: true, + } + respData, _ := json.Marshal(allowResp) + wsConn.WriteMessage(websocket.TextMessage, respData) + + // Now read the inner HTTP request (MITM'd) + wsConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, data, err = wsConn.ReadMessage() + if err != nil { + t.Fatalf("read ws request: %v", err) + } + var proxyReq protocol.ProxyRequest + if err := json.Unmarshal(data, &proxyReq); err != nil { + t.Fatalf("unmarshal proxy request: %v (data: %s)", err, string(data)) + } + + if proxyReq.Method != "GET" { + t.Errorf("method: got %q, want GET", proxyReq.Method) + } + if !strings.Contains(proxyReq.URL, "example.com") || !strings.Contains(proxyReq.URL, "/path") { + t.Errorf("url: got %q, want to contain example.com and /path", proxyReq.URL) + } + + // Send response + body := base64.StdEncoding.EncodeToString([]byte("mitm-works")) + httpResp := protocol.ProxyResponse{ + Type: protocol.TypeResponse, + RequestID: proxyReq.RequestID, + Status: 200, + Body: body, + } + httpRespData, _ := json.Marshal(httpResp) + wsConn.WriteMessage(websocket.TextMessage, httpRespData) + + result := <-done + if !strings.Contains(result, "mitm-works") { + t.Errorf("response: got %q, want to contain 'mitm-works'", result) + } + + _ = hub // suppress unused +} + +func TestProxyCONNECTDenied(t *testing.T) { + proxyPort, wsURL, _ := startTestStack(t) + wsConn := connectMockClient(t, wsURL, "") + defer wsConn.Close() + + done := make(chan string, 1) + go func() { + conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", proxyPort)) + if err != nil { + done <- "" + return + } + defer conn.Close() + + fmt.Fprintf(conn, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n") + + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + done <- string(buf[:n]) + }() + + // Read CONNECT from WS + wsConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, data, err := wsConn.ReadMessage() + if err != nil { + t.Fatalf("read ws: %v", err) + } + var connectReq protocol.ConnectRequest + json.Unmarshal(data, &connectReq) + + // Deny + denyResp := protocol.ConnectResponse{ + Type: protocol.TypeConnectResponse, + RequestID: connectReq.RequestID, + Allow: false, + } + respData, _ := json.Marshal(denyResp) + wsConn.WriteMessage(websocket.TextMessage, respData) + + response := <-done + if !strings.Contains(response, "403") { + t.Errorf("expected 403, got: %s", response) + } +} + +func TestProxyNoWsClient(t *testing.T) { + proxyPort, _, _ := startTestStack(t) + // Don't connect a WS client + + proxyURL := fmt.Sprintf("http://session:x@127.0.0.1:%d", proxyPort) + transport := &http.Transport{ + Proxy: func(r *http.Request) (*url.URL, error) { + return url.Parse(proxyURL) + }, + } + client := &http.Client{ + Transport: transport, + Timeout: 5 * time.Second, + } + resp, err := client.Get("http://example.com/test") + if err != nil { + // Connection error is expected when no WS client + return + } + defer resp.Body.Close() + if resp.StatusCode != 502 { + t.Errorf("expected 502, got %d", resp.StatusCode) + } +} diff --git a/packages/http-proxy-server/proxy/session.go b/packages/http-proxy-server/proxy/session.go new file mode 100644 index 0000000..757011a --- /dev/null +++ b/packages/http-proxy-server/proxy/session.go @@ -0,0 +1,33 @@ +package proxy + +import ( + "encoding/base64" + "strings" +) + +// ExtractSessionID pulls the session ID from a Proxy-Authorization header. +// The HTTP_PROXY URL is http://:x@host:port, so clients send +// a Proxy-Authorization: Basic header where the username is the session ID. +func ExtractSessionID(proxyAuth string) string { + if proxyAuth == "" { + return "" + } + + // Expect "Basic " + const prefix = "Basic " + if !strings.HasPrefix(proxyAuth, prefix) { + return "" + } + + decoded, err := base64.StdEncoding.DecodeString(proxyAuth[len(prefix):]) + if err != nil { + return "" + } + + // Format is "sessionId:x" — take everything before the first ':' + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) == 0 { + return "" + } + return parts[0] +} diff --git a/packages/http-proxy-server/proxy/session_test.go b/packages/http-proxy-server/proxy/session_test.go new file mode 100644 index 0000000..899b4e3 --- /dev/null +++ b/packages/http-proxy-server/proxy/session_test.go @@ -0,0 +1,54 @@ +package proxy + +import ( + "encoding/base64" + "testing" +) + +func TestExtractSessionID(t *testing.T) { + tests := []struct { + name string + header string + expected string + }{ + { + name: "valid session", + header: "Basic " + base64.StdEncoding.EncodeToString([]byte("my-session-id:x")), + expected: "my-session-id", + }, + { + name: "uuid session", + header: "Basic " + base64.StdEncoding.EncodeToString([]byte("550e8400-e29b-41d4-a716-446655440000:x")), + expected: "550e8400-e29b-41d4-a716-446655440000", + }, + { + name: "empty header", + header: "", + expected: "", + }, + { + name: "no Basic prefix", + header: "Bearer token123", + expected: "", + }, + { + name: "invalid base64", + header: "Basic not-valid-base64!!!", + expected: "", + }, + { + name: "no colon in decoded", + header: "Basic " + base64.StdEncoding.EncodeToString([]byte("sessiononly")), + expected: "sessiononly", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractSessionID(tt.header) + if got != tt.expected { + t.Errorf("ExtractSessionID(%q) = %q, want %q", tt.header, got, tt.expected) + } + }) + } +} diff --git a/packages/http-proxy-server/scripts/build-binaries.sh b/packages/http-proxy-server/scripts/build-binaries.sh new file mode 100755 index 0000000..4886897 --- /dev/null +++ b/packages/http-proxy-server/scripts/build-binaries.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -e + +if [ "$VERCEL_URL" != "" ] && ! command -v go &>/dev/null; then + curl https://raw.githubusercontent.com/canha/golang-tools-install-script/refs/heads/master/goinstall.sh | bash + source "$HOME/.bashrc" +fi + +PIDS=() +go build -o http-proxy-server -ldflags "-w" & +PIDS+=($!) +GOOS="linux" GOARCH="amd64" go build -o public/linux-x86_64 -ldflags "-w" & +PIDS+=($!) +GOOS="linux" GOARCH="arm64" go build -o public/linux-arm64 -ldflags "-w" & +PIDS+=($!) + +for PID in "${PIDS[@]}"; do + wait "$PID" +done diff --git a/packages/http-proxy-server/scripts/build.sh b/packages/http-proxy-server/scripts/build.sh new file mode 100755 index 0000000..00db46e --- /dev/null +++ b/packages/http-proxy-server/scripts/build.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -e + +sed "s@{{VERCEL_URL}}@$VERCEL_URL@" templates/install > public/install diff --git a/packages/http-proxy-server/templates/install b/packages/http-proxy-server/templates/install new file mode 100644 index 0000000..2a7ef9a --- /dev/null +++ b/packages/http-proxy-server/templates/install @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +ARCH=$(uname -m) +case "$ARCH" in + x86_64) BINARY="linux-x86_64" ;; + aarch64) BINARY="linux-arm64" ;; + arm64) BINARY="linux-arm64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +URL="https://{{VERCEL_URL}}/public/$BINARY" +DEST="/usr/local/bin/vc-http-proxy-server" + +curl -fsSL "$URL" -o "$DEST" +chmod +x "$DEST" +echo "Installed vc-http-proxy-server to $DEST" diff --git a/packages/http-proxy-server/turbo.json b/packages/http-proxy-server/turbo.json new file mode 100644 index 0000000..86b2f86 --- /dev/null +++ b/packages/http-proxy-server/turbo.json @@ -0,0 +1,20 @@ +{ + "extends": ["//"], + "tasks": { + "build:binaries": { + "inputs": ["go.mod", "go.sum", "*.go", "**/*.go", "scripts"], + "outputs": ["public", "http-proxy-server"] + }, + "build:install-script": { + "dependsOn": ["build:binaries"], + "env": ["VERCEL_URL"], + "inputs": ["templates", "scripts"], + "outputs": ["public", "http-proxy-server"] + }, + "test": { + "dependsOn": ["build"], + "inputs": ["**/*.go", "http-proxy-server"], + "outputs": [] + } + } +} diff --git a/packages/http-proxy-server/ws/hub.go b/packages/http-proxy-server/ws/hub.go new file mode 100644 index 0000000..781dfb8 --- /dev/null +++ b/packages/http-proxy-server/ws/hub.go @@ -0,0 +1,260 @@ +package ws + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sync" + + "github.com/gorilla/websocket" + "github.com/vercel/sandbox/http-proxy-server/protocol" +) + +var upgrader = websocket.Upgrader{} + +// ClientState tracks a connected WS client and its registered sessions. +type ClientState struct { + conn *websocket.Conn + writeMu sync.Mutex + sessionIDs map[string]bool +} + +func (cs *ClientState) WriteJSON(msg any) error { + cs.writeMu.Lock() + defer cs.writeMu.Unlock() + data, err := json.Marshal(msg) + if err != nil { + return err + } + return cs.conn.WriteMessage(websocket.TextMessage, data) +} + +// Hub manages multiple WebSocket clients from external TS processes. +// Each client registers the session IDs it owns. The HTTP proxy routes +// requests to the correct client based on session ID. +type Hub struct { + token []byte + logger *slog.Logger + + // Multiple connected WS clients + clients map[*websocket.Conn]*ClientState + clientsMu sync.RWMutex + + // Session → client routing + sessions map[string]*ClientState + sessionsMu sync.RWMutex + + // Pending HTTP requests waiting for a response, keyed by requestId + pending sync.Map // map[string]chan []byte + + // Signals that at least one TS client has sent "ready" + ready chan struct{} + readyOnce sync.Once +} + +func NewHub(token string, logger *slog.Logger) *Hub { + return &Hub{ + token: []byte(token), + logger: logger, + ready: make(chan struct{}), + clients: make(map[*websocket.Conn]*ClientState), + sessions: make(map[string]*ClientState), + } +} + +// Ready returns a channel that is closed when the first TS client sends "ready". +func (h *Hub) Ready() <-chan struct{} { + return h.ready +} + +// SendToSession sends a JSON message to the WS client that owns the given session. +func (h *Hub) SendToSession(sessionID string, msg any) error { + h.sessionsMu.RLock() + cs, ok := h.sessions[sessionID] + h.sessionsMu.RUnlock() + + if !ok || cs == nil { + return fmt.Errorf("no client registered for session %s", sessionID) + } + + return cs.WriteJSON(msg) +} + +// SendToSessionAndWait sends a request to the session owner and blocks until a response arrives. +func (h *Hub) SendToSessionAndWait(sessionID string, requestID string, msg any) ([]byte, error) { + ch := make(chan []byte, 1) + h.pending.Store(requestID, ch) + defer h.pending.Delete(requestID) + + if err := h.SendToSession(sessionID, msg); err != nil { + return nil, err + } + + data, ok := <-ch + if !ok { + return nil, fmt.Errorf("response channel closed for request %s", requestID) + } + return data, nil +} + +// Resolve delivers a response to a pending request. +func (h *Hub) Resolve(requestID string, data []byte) { + val, ok := h.pending.Load(requestID) + if !ok { + h.logger.Warn("No pending request for response", "requestId", requestID) + return + } + ch := val.(chan []byte) + ch <- data +} + +func (h *Hub) registerSessions(cs *ClientState, sessionIDs []string) { + h.sessionsMu.Lock() + defer h.sessionsMu.Unlock() + for _, id := range sessionIDs { + h.sessions[id] = cs + cs.sessionIDs[id] = true + } + h.logger.Debug("Sessions registered", "count", len(sessionIDs)) +} + +func (h *Hub) unregisterSessions(cs *ClientState, sessionIDs []string) { + h.sessionsMu.Lock() + defer h.sessionsMu.Unlock() + for _, id := range sessionIDs { + if h.sessions[id] == cs { + delete(h.sessions, id) + } + delete(cs.sessionIDs, id) + } +} + +func (h *Hub) removeClient(conn *websocket.Conn) { + h.clientsMu.Lock() + cs, ok := h.clients[conn] + if ok { + delete(h.clients, conn) + } + h.clientsMu.Unlock() + + if ok && cs != nil { + // Clean up all session mappings for this client + h.sessionsMu.Lock() + for id := range cs.sessionIDs { + if h.sessions[id] == cs { + delete(h.sessions, id) + } + } + h.sessionsMu.Unlock() + } +} + +// HandleWebSocket is the HTTP handler for the /ws endpoint. +func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if subtle.ConstantTimeCompare([]byte(token), h.token) != 1 { + h.logger.Warn("Unauthorized WebSocket connection attempt") + w.WriteHeader(http.StatusUnauthorized) + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + h.logger.Error("WebSocket upgrade failed", "error", err) + return + } + + cs := &ClientState{ + conn: conn, + sessionIDs: make(map[string]bool), + } + + h.clientsMu.Lock() + h.clients[conn] = cs + h.clientsMu.Unlock() + + h.logger.Info("TS client connected", "remoteAddr", r.RemoteAddr) + + defer func() { + h.removeClient(conn) + conn.Close() + h.logger.Info("TS client disconnected", "remoteAddr", r.RemoteAddr) + }() + + // Read loop: dispatch incoming messages + for { + _, data, err := conn.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + break + } + h.logger.Error("Error reading from TS client", "error", err) + break + } + + msgType, err := protocol.ParseType(data) + if err != nil { + h.logger.Error("Failed to parse message type", "error", err) + continue + } + + switch msgType { + case protocol.TypeReady: + h.readyOnce.Do(func() { + h.logger.Info("TS client sent ready") + close(h.ready) + }) + + case protocol.TypeRegister: + var msg protocol.RegisterMessage + if err := json.Unmarshal(data, &msg); err != nil { + h.logger.Error("Failed to parse register message", "error", err) + continue + } + h.registerSessions(cs, msg.SessionIDs) + // Send ack so the client knows registration is complete + ack := protocol.RegisterAckMessage{Type: protocol.TypeRegisterAck, SessionIDs: msg.SessionIDs} + ackData, _ := json.Marshal(ack) + cs.writeMu.Lock() + cs.conn.WriteMessage(websocket.TextMessage, ackData) + cs.writeMu.Unlock() + + case protocol.TypeUnregister: + var msg protocol.UnregisterMessage + if err := json.Unmarshal(data, &msg); err != nil { + h.logger.Error("Failed to parse unregister message", "error", err) + continue + } + h.unregisterSessions(cs, msg.SessionIDs) + + case protocol.TypeResponse: + var resp protocol.ProxyResponse + if err := json.Unmarshal(data, &resp); err != nil { + h.logger.Error("Failed to parse response", "error", err) + continue + } + h.Resolve(resp.RequestID, data) + + case protocol.TypeConnectResponse: + var resp protocol.ConnectResponse + if err := json.Unmarshal(data, &resp); err != nil { + h.logger.Error("Failed to parse connect response", "error", err) + continue + } + h.Resolve(resp.RequestID, data) + + case protocol.TypeError: + var errMsg protocol.ErrorMessage + if err := json.Unmarshal(data, &errMsg); err != nil { + h.logger.Error("Failed to parse error message", "error", err) + continue + } + h.Resolve(errMsg.RequestID, data) + + default: + h.logger.Warn("Unknown message type from TS client", "type", msgType) + } + } +} diff --git a/packages/http-proxy-server/ws/hub_test.go b/packages/http-proxy-server/ws/hub_test.go new file mode 100644 index 0000000..d0371c7 --- /dev/null +++ b/packages/http-proxy-server/ws/hub_test.go @@ -0,0 +1,314 @@ +package ws + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/vercel/sandbox/http-proxy-server/protocol" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) +} + +func TestHubResolve(t *testing.T) { + hub := NewHub("test-token", testLogger()) + + requestID := "req-123" + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + time.Sleep(50 * time.Millisecond) + response := protocol.ProxyResponse{ + Type: protocol.TypeResponse, + RequestID: requestID, + Status: 200, + } + data, _ := json.Marshal(response) + hub.Resolve(requestID, data) + }() + + ch := make(chan []byte, 1) + hub.pending.Store(requestID, ch) + defer hub.pending.Delete(requestID) + + wg.Wait() + + select { + case data := <-ch: + var resp protocol.ProxyResponse + if err := json.Unmarshal(data, &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Status != 200 { + t.Errorf("status: got %d, want 200", resp.Status) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for response") + } +} + +func TestHubResolveUnknownRequestDoesNotPanic(t *testing.T) { + hub := NewHub("test-token", testLogger()) + hub.Resolve("nonexistent-request-id", []byte(`{}`)) +} + +func TestHubReadyChannel(t *testing.T) { + hub := NewHub("test-token", testLogger()) + + select { + case <-hub.Ready(): + t.Fatal("ready channel should not be closed yet") + default: + } + + hub.readyOnce.Do(func() { close(hub.ready) }) + + select { + case <-hub.Ready(): + case <-time.After(100 * time.Millisecond): + t.Fatal("ready channel should be closed") + } +} + +func TestHubRejectsInvalidToken(t *testing.T) { + hub := NewHub("correct-token", testLogger()) + + server := httptest.NewServer(http.HandlerFunc(hub.HandleWebSocket)) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws?token=wrong-token" + _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err == nil { + t.Fatal("expected connection to be rejected") + } + if resp != nil && resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", resp.StatusCode) + } +} + +func TestHubAcceptsValidToken(t *testing.T) { + hub := NewHub("correct-token", testLogger()) + + server := httptest.NewServer(http.HandlerFunc(hub.HandleWebSocket)) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws?token=correct-token" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("expected connection to succeed: %v", err) + } + defer conn.Close() +} + +func TestHubWebSocketReadyMessage(t *testing.T) { + hub := NewHub("token", testLogger()) + + server := httptest.NewServer(http.HandlerFunc(hub.HandleWebSocket)) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws?token=token" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + ready := protocol.ReadyMessage{Type: protocol.TypeReady} + data, _ := json.Marshal(ready) + conn.WriteMessage(websocket.TextMessage, data) + + select { + case <-hub.Ready(): + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for ready") + } +} + +func TestHubWebSocketResponseRouting(t *testing.T) { + hub := NewHub("token", testLogger()) + + server := httptest.NewServer(http.HandlerFunc(hub.HandleWebSocket)) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws?token=token" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + requestID := "test-req-1" + ch := make(chan []byte, 1) + hub.pending.Store(requestID, ch) + + resp := protocol.ProxyResponse{ + Type: protocol.TypeResponse, + RequestID: requestID, + Status: 404, + } + data, _ := json.Marshal(resp) + conn.WriteMessage(websocket.TextMessage, data) + + select { + case received := <-ch: + var parsed protocol.ProxyResponse + if err := json.Unmarshal(received, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed.Status != 404 { + t.Errorf("status: got %d, want 404", parsed.Status) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for response on channel") + } +} + +// --- Multi-client tests --- + +func TestHubMultiClientSessionRouting(t *testing.T) { + hub := NewHub("token", testLogger()) + + server := httptest.NewServer(http.HandlerFunc(hub.HandleWebSocket)) + defer server.Close() + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws?token=token" + + // Connect client A + connA, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial A: %v", err) + } + defer connA.Close() + + // Connect client B + connB, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial B: %v", err) + } + defer connB.Close() + + // Client A registers session-a + regA, _ := json.Marshal(protocol.RegisterMessage{Type: protocol.TypeRegister, SessionIDs: []string{"session-a"}}) + connA.WriteMessage(websocket.TextMessage, regA) + + // Client B registers session-b + regB, _ := json.Marshal(protocol.RegisterMessage{Type: protocol.TypeRegister, SessionIDs: []string{"session-b"}}) + connB.WriteMessage(websocket.TextMessage, regB) + + time.Sleep(50 * time.Millisecond) // Let registrations propagate + + // Read from both clients in goroutines + receivedA := make(chan string, 1) + go func() { + connA.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, data, err := connA.ReadMessage() + if err != nil { + receivedA <- "error: " + err.Error() + return + } + receivedA <- string(data) + }() + + receivedB := make(chan string, 1) + go func() { + connB.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, data, err := connB.ReadMessage() + if err != nil { + receivedB <- "error: " + err.Error() + return + } + receivedB <- string(data) + }() + + // Send to session-a → should go to client A + msg := protocol.ProxyRequest{ + Type: protocol.TypeRequest, + RequestID: "req-1", + SessionID: "session-a", + Method: "GET", + URL: "http://example.com/a", + } + if err := hub.SendToSession("session-a", msg); err != nil { + t.Fatalf("send to session-a: %v", err) + } + + // Send to session-b → should go to client B + msg2 := protocol.ProxyRequest{ + Type: protocol.TypeRequest, + RequestID: "req-2", + SessionID: "session-b", + Method: "GET", + URL: "http://example.com/b", + } + if err := hub.SendToSession("session-b", msg2); err != nil { + t.Fatalf("send to session-b: %v", err) + } + + // Verify routing + dataA := <-receivedA + if !strings.Contains(dataA, "session-a") || !strings.Contains(dataA, "example.com/a") { + t.Errorf("client A got wrong message: %s", dataA) + } + + dataB := <-receivedB + if !strings.Contains(dataB, "session-b") || !strings.Contains(dataB, "example.com/b") { + t.Errorf("client B got wrong message: %s", dataB) + } +} + +func TestHubClientDisconnectCleansUpSessions(t *testing.T) { + hub := NewHub("token", testLogger()) + + server := httptest.NewServer(http.HandlerFunc(hub.HandleWebSocket)) + defer server.Close() + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws?token=token" + + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + + // Register sessions + reg, _ := json.Marshal(protocol.RegisterMessage{Type: protocol.TypeRegister, SessionIDs: []string{"s1", "s2"}}) + conn.WriteMessage(websocket.TextMessage, reg) + time.Sleep(50 * time.Millisecond) + + // Verify sessions are registered + hub.sessionsMu.RLock() + if len(hub.sessions) != 2 { + t.Errorf("expected 2 sessions, got %d", len(hub.sessions)) + } + hub.sessionsMu.RUnlock() + + // Disconnect + conn.Close() + time.Sleep(100 * time.Millisecond) + + // Verify sessions are cleaned up + hub.sessionsMu.RLock() + if len(hub.sessions) != 0 { + t.Errorf("expected 0 sessions after disconnect, got %d", len(hub.sessions)) + } + hub.sessionsMu.RUnlock() +} + +func TestHubUnregisteredSessionReturnsError(t *testing.T) { + hub := NewHub("token", testLogger()) + + err := hub.SendToSession("nonexistent-session", protocol.ProxyRequest{}) + if err == nil { + t.Fatal("expected error for unregistered session") + } +} diff --git a/packages/http-proxy-server/ws/server.go b/packages/http-proxy-server/ws/server.go new file mode 100644 index 0000000..b09e3fe --- /dev/null +++ b/packages/http-proxy-server/ws/server.go @@ -0,0 +1,64 @@ +package ws + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "log/slog" + "net" + "net/http" + "time" +) + +type Server struct { + Port int + Token string + Hub *Hub + httpServer *http.Server + listener net.Listener +} + +func generateToken() string { + bytes := make([]byte, 32) + rand.Read(bytes) + return base64.URLEncoding.EncodeToString(bytes) +} + +func NewServer(logger *slog.Logger, token string, port int) (*Server, error) { + if token == "" { + token = generateToken() + } + + hub := NewHub(token, logger) + + mux := http.NewServeMux() + mux.HandleFunc("/ws", hub.HandleWebSocket) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + addr := fmt.Sprintf(":%d", port) + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("listening on %s: %v", addr, err) + } + + httpServer := &http.Server{ + Handler: mux, + ReadTimeout: 120 * time.Second, + WriteTimeout: 120 * time.Second, + } + + return &Server{ + Port: ln.Addr().(*net.TCPAddr).Port, + Token: token, + Hub: hub, + httpServer: httpServer, + listener: ln, + }, nil +} + +func (s *Server) ListenAndServe() error { + return s.httpServer.Serve(s.listener) +} diff --git a/packages/http-proxy-tunnel/README.md b/packages/http-proxy-tunnel/README.md new file mode 100644 index 0000000..224672f --- /dev/null +++ b/packages/http-proxy-tunnel/README.md @@ -0,0 +1,178 @@ +# @vercel/http-proxy-tunnel + +Intercept and control HTTP requests from inside a [Vercel Sandbox](https://vercel.com/docs/sandbox). A Go-based HTTP proxy runs inside the sandbox, tunneling every request over WebSocket to your TypeScript callback where you can inspect, modify, or block it. + +## Quick Start + +```ts +import { Sandbox } from "@vercel/sandbox"; +import { createWsProxy } from "@vercel/http-proxy-tunnel"; + +// Create a sandbox with an exposed port for the WebSocket +const sandbox = await Sandbox.create({ ports: [5000] }); + +// Attach the proxy (uploads binary, starts server, connects WebSocket) +const proxy = createWsProxy(); +await proxy.attach(sandbox, { wsPort: 5000 }); + +// Register a handler — returns a unique HTTP_PROXY URL +const httpProxy = proxy.handle((request) => { + console.log(`Intercepted: ${request.method} ${request.url}`); + return new Response("blocked", { status: 403 }); +}); + +// Run a command that uses the proxy +const result = await sandbox.runCommand({ + cmd: "curl", + args: ["-s", "http://example.com"], + env: { + HTTP_PROXY: httpProxy, + http_proxy: httpProxy, + }, +}); + +console.log(await result.stdout()); // "blocked" + +await proxy.close(); +await sandbox.stop(); +``` + +## How It Works + +``` +Inside Sandbox Outside Sandbox +┌─────────────────────────┐ +│ node app.js │ +│ ↓ HTTP_PROXY │ +│ Go Proxy (localhost) │ ──── WebSocket ────→ TS Client A (sessions 1, 2) +│ │ ──── WebSocket ────→ TS Client B (sessions 3, 4) +└─────────────────────────┘ +``` + +1. `proxy.attach()` uploads a Go binary to the sandbox and starts it +2. The binary runs an HTTP proxy on localhost and a WebSocket server on an exposed port +3. The TypeScript client connects to the WebSocket from outside +4. Programs inside the sandbox set `HTTP_PROXY` to route traffic through the proxy +5. Each HTTP request is serialized, sent over WebSocket to the client that owns that session, and your callback returns the response +6. Multiple clients can share one proxy server — each registers its own sessions + +## API + +### `createWsProxy(): WsProxy` + +Creates a new proxy instance. + +### `proxy.attach(sandbox, options): Promise` + +Connects to the proxy server inside the sandbox. If no server is running, uploads the Go binary, installs it, and starts it. If another client already started the server, connects to the existing one. + +**Options:** + +| Option | Type | Required | Description | +|---|---|---|---| +| `wsPort` | `number` | Yes | Exposed port for the WebSocket server. Must be in the sandbox's `ports` list. | +| `proxyPort` | `number` | No | Port for the HTTP proxy inside the sandbox. Auto-assigned if omitted. | +| `debug` | `boolean` | No | Enable debug logging on the Go server. | +| `signal` | `AbortSignal` | No | Cancel the attach operation. | + +### `proxy.handle(handler, connectHandler?): string` + +Registers a callback for proxied requests and returns the `HTTP_PROXY` URL to use. + +Each call creates a unique session, so different `runCommand` calls can have different handlers. + +**Parameters:** + +- `handler: (request: Request) => Response | Promise` — handles HTTP requests using standard Web API types +- `connectHandler?: (host: string) => boolean | Promise` — controls HTTPS `CONNECT` tunneling (allow/deny by hostname) + +**Returns:** An `HTTP_PROXY` URL string like `http://:x@127.0.0.1:` + +### `proxy.removeHandle(httpProxyValue): void` + +Removes a previously registered handler by its `HTTP_PROXY` URL value. + +### `proxy.close(): Promise` + +Disconnects the WebSocket. If this client started the proxy server, also stops it. If another client started the server, the server keeps running for other clients. + +## Multiple Sessions + +Each `proxy.handle()` call returns a unique `HTTP_PROXY` value, so different commands can have independent request handling: + +```ts +const allowAll = proxy.handle((req) => { + return fetch(req); // pass through +}); + +const blockExternal = proxy.handle((req) => { + const url = new URL(req.url); + if (url.hostname !== "api.internal.com") { + return new Response("Forbidden", { status: 403 }); + } + return fetch(req); +}); + +// These commands see different proxy behavior +await sandbox.runCommand({ cmd: "node", args: ["trusted.js"], env: { HTTP_PROXY: allowAll } }); +await sandbox.runCommand({ cmd: "node", args: ["untrusted.js"], env: { HTTP_PROXY: blockExternal } }); +``` + +## HTTPS Support + +HTTPS requests use the HTTP `CONNECT` method to establish a tunnel. You can control this with the optional `connectHandler`: + +```ts +const httpProxy = proxy.handle( + (req) => new Response("ok"), // handles plain HTTP + (host) => host === "api.github.com", // only allow HTTPS to GitHub +); +``` + +If no `connectHandler` is provided, all HTTPS tunnels are allowed. The proxy cannot inspect encrypted HTTPS traffic — only the target hostname is visible. + +## Multiple Clients + +Multiple independent proxy clients can share the same sandbox. The second `attach()` detects the running server and connects to it — no duplicate binary or port conflict: + +```ts +const sandbox = await Sandbox.create({ ports: [5000] }); + +const proxyA = createWsProxy(); +await proxyA.attach(sandbox, { wsPort: 5000 }); // starts the server + +const proxyB = createWsProxy(); +await proxyB.attach(sandbox, { wsPort: 5000 }); // connects to existing server + +const handleA = proxyA.handle(() => new Response("from A")); +const handleB = proxyB.handle(() => new Response("from B")); + +// Each command routes to the correct client +await sandbox.runCommand({ cmd: "curl", args: ["-s", "http://x.com"], env: { HTTP_PROXY: handleA } }); +await sandbox.runCommand({ cmd: "curl", args: ["-s", "http://x.com"], env: { HTTP_PROXY: handleB } }); +``` + +Session ownership is tracked via `register`/`unregister` messages over the WebSocket. When a client disconnects, its sessions are automatically cleaned up. + +## Re-attaching + +The proxy supports re-attaching to the same sandbox after disconnecting: + +```ts +await proxy.close(); + +// Later... +const proxy2 = createWsProxy(); +await proxy2.attach(sandbox, { wsPort: 5000 }); +``` + +The Go binary is only uploaded once — subsequent attaches reuse the installed binary. + +## Architecture + +- **Go binary** (`packages/http-proxy-server/`): HTTP proxy + WebSocket server running inside the sandbox +- **TypeScript client** (`packages/http-proxy-tunnel/`): WebSocket client that routes requests to JS callbacks +- **Multi-client**: The Go server accepts multiple WebSocket connections. Each client registers its session IDs. The proxy routes each request to the client that owns the session. +- **Session routing**: Uses HTTP proxy authentication (`Proxy-Authorization: Basic`) to map requests to the correct callback. The session ID is encoded as the username in the `HTTP_PROXY` URL. +- **Protocol**: JSON messages over WebSocket for request/response serialization, with base64-encoded bodies. +- **Config persistence**: The server writes connection info to `/tmp/vercel/http-proxy/config.json` inside the sandbox so subsequent clients can discover and connect to the running server. diff --git a/packages/http-proxy-tunnel/package.json b/packages/http-proxy-tunnel/package.json new file mode 100644 index 0000000..1d86925 --- /dev/null +++ b/packages/http-proxy-tunnel/package.json @@ -0,0 +1,45 @@ +{ + "name": "@vercel/http-proxy-tunnel", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.18.3" + }, + "devDependencies": { + "@types/ws": "^8.18.1", + "@types/node": "22.15.12", + "@vercel/http-proxy-server": "workspace:*", + "@vercel/sandbox": "workspace:*", + "tsdown": "catalog:", + "typescript": "5.8.3", + "vitest": "catalog:" + }, + "peerDependencies": { + "@vercel/sandbox": "workspace:*" + }, + "peerDependenciesMeta": { + "@vercel/sandbox": { + "optional": true + } + } +} diff --git a/packages/http-proxy-tunnel/src/index.ts b/packages/http-proxy-tunnel/src/index.ts new file mode 100644 index 0000000..9038cfd --- /dev/null +++ b/packages/http-proxy-tunnel/src/index.ts @@ -0,0 +1,42 @@ +import { WsProxy } from "./ws-proxy.js"; +export { WsProxy }; +export type { + ProxyHandler, + ProxyHandle, + ConnectHandler, + AttachOptions, + ConnectionInfo, +} from "./types.js"; +export type { + ProxyRequest, + ProxyResponse, + ConnectRequest, + ConnectResponse, + ReadyMessage, + ErrorMessage, + RegisterMessage, + UnregisterMessage, +} from "./protocol.js"; + +/** + * Create a new WsProxy instance. + * + * @example + * ```ts + * import { createWsProxy } from "@vercel/http-proxy-tunnel"; + * + * const proxy = createWsProxy(); + * await proxy.attach(sandbox, { wsPort: 5000 }); + * + * const result = await sandbox.runCommand({ + * cmd: "curl", + * args: ["-s", "http://example.com"], + * env: { + * HTTP_PROXY: proxy.handle((req) => new Response("intercepted")), + * }, + * }); + * ``` + */ +export function createWsProxy(): WsProxy { + return new WsProxy(); +} diff --git a/packages/http-proxy-tunnel/src/protocol.ts b/packages/http-proxy-tunnel/src/protocol.ts new file mode 100644 index 0000000..422986b --- /dev/null +++ b/packages/http-proxy-tunnel/src/protocol.ts @@ -0,0 +1,120 @@ +/** + * WebSocket protocol message types matching the Go server's protocol package. + * All messages are JSON text frames. + */ + +export interface ProxyRequest { + type: "request"; + requestId: string; + sessionId: string; + method: string; + url: string; + headers: Record; + body?: string; // base64 +} + +export interface ProxyResponse { + type: "response"; + requestId: string; + status: number; + headers?: Record; + body?: string; // base64 +} + +export interface ConnectRequest { + type: "connect"; + requestId: string; + sessionId: string; + host: string; +} + +export interface ConnectResponse { + type: "connect-response"; + requestId: string; + allow: boolean; +} + +export interface ReadyMessage { + type: "ready"; +} + +export interface ErrorMessage { + type: "error"; + requestId: string; + message: string; +} + +export interface RegisterMessage { + type: "register"; + sessionIds: string[]; +} + +export interface UnregisterMessage { + type: "unregister"; + sessionIds: string[]; +} + +export type IncomingMessage = ProxyRequest | ConnectRequest; +export type OutgoingMessage = + | ProxyResponse + | ConnectResponse + | ReadyMessage + | ErrorMessage + | RegisterMessage + | UnregisterMessage; + +/** + * Convert a standard Request to a ProxyResponse by calling a handler callback. + */ +export async function requestToProtocol( + msg: ProxyRequest, +): Promise { + const headers = new Headers(); + for (const [key, values] of Object.entries(msg.headers)) { + for (const value of values) { + headers.append(key, value); + } + } + + let body: BodyInit | undefined; + if (msg.body) { + body = Buffer.from(msg.body, "base64"); + } + + return new Request(msg.url, { + method: msg.method, + headers, + body: + msg.method !== "GET" && msg.method !== "HEAD" ? body : undefined, + }); +} + +/** + * Convert a Response from a callback into a ProxyResponse protocol message. + */ +export async function responseToProtocol( + requestId: string, + response: Response, +): Promise { + const headers: Record = {}; + response.headers.forEach((value, key) => { + if (!headers[key]) { + headers[key] = []; + } + headers[key].push(value); + }); + + let body: string | undefined; + const bodyBuffer = await response.arrayBuffer(); + if (bodyBuffer.byteLength > 0) { + body = Buffer.from(bodyBuffer).toString("base64"); + } + + return { + type: "response", + requestId, + status: response.status, + headers, + body, + }; +} diff --git a/packages/http-proxy-tunnel/src/types.ts b/packages/http-proxy-tunnel/src/types.ts new file mode 100644 index 0000000..2325fec --- /dev/null +++ b/packages/http-proxy-tunnel/src/types.ts @@ -0,0 +1,67 @@ +/** + * Callback that handles proxied HTTP requests. + * Receives a standard Request and must return a Response. + */ +export type ProxyHandler = ( + request: Request, +) => Response | Promise; + +/** + * Callback that handles HTTPS CONNECT requests. + * Receives the target host:port and returns whether to allow the tunnel. + */ +export type ConnectHandler = (host: string) => boolean | Promise; + +/** + * Options for attaching the proxy to a sandbox. + */ +export interface AttachOptions { + /** + * The port on the sandbox to use for the WebSocket server. + * Must be one of the ports exposed during sandbox creation. + */ + wsPort: number; + + /** + * Optional port for the HTTP proxy inside the sandbox. + * If 0 or omitted, the server picks a free port automatically. + */ + proxyPort?: number; + + /** + * Enable debug logging on the Go server. + */ + debug?: boolean; + + /** + * AbortSignal to cancel the attach operation. + */ + signal?: AbortSignal; +} + +/** + * Returned by `proxy.handle()`. Contains the proxy URL and a + * ready-made `env` record for passing to `runCommand`. + */ +export interface ProxyHandle { + /** The raw proxy URL (e.g. `http://:x@127.0.0.1:`). */ + url: string; + /** Env vars to spread into `runCommand({ env: { ...handle.env } })`. */ + env: { + HTTP_PROXY: string; + http_proxy: string; + HTTPS_PROXY: string; + https_proxy: string; + }; + /** Returns the URL string (for backward compat / string coercion). */ + toString(): string; +} + +/** + * Connection info output by the Go binary on stdout. + */ +export interface ConnectionInfo { + wsPort: number; + proxyPort: number; + token: string; +} diff --git a/packages/http-proxy-tunnel/src/ws-proxy.ts b/packages/http-proxy-tunnel/src/ws-proxy.ts new file mode 100644 index 0000000..c12df9c --- /dev/null +++ b/packages/http-proxy-tunnel/src/ws-proxy.ts @@ -0,0 +1,415 @@ +import WebSocket from "ws"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import type { Sandbox, Command } from "@vercel/sandbox"; +import type { + ProxyHandler, + ProxyHandle, + ConnectHandler, + AttachOptions, + ConnectionInfo, +} from "./types.js"; +import { + type IncomingMessage, + type OutgoingMessage, + type ProxyRequest, + type ConnectRequest, + requestToProtocol, + responseToProtocol, +} from "./protocol.js"; + +const SERVER_BIN_NAME = "vc-http-proxy-server"; + +interface Session { + sessionId: string; + handler: ProxyHandler; + connectHandler?: ConnectHandler; +} + +/** + * WsProxy manages an HTTP proxy inside a Vercel Sandbox. + * It uploads a Go binary, starts it, connects via WebSocket, + * and routes proxied requests to per-session JS callbacks. + */ +export class WsProxy { + private ws: WebSocket | null = null; + private command: Command | null = null; + private sandbox: Sandbox | null = null; + private proxyPort: number = 0; + private sessions: Map = new Map(); + private attached: boolean = false; + + /** + * Upload the Go binary to the sandbox, start it, and connect via WebSocket. + * If a proxy server is already running (from another client), connects to it. + */ + async attach(sandbox: Sandbox, opts: AttachOptions): Promise { + this.sandbox = sandbox; + + // Check if server is already running by reading config file + const existingInfo = await this.readExistingConfig(sandbox, opts.signal); + + let connectionInfo: ConnectionInfo; + + if (existingInfo) { + connectionInfo = existingInfo; + // Try connecting to the existing server + const domain = sandbox.domain(opts.wsPort); + const wsUrl = `wss://${domain.replace(/^https?:\/\//, "")}/ws?token=${connectionInfo.token}`; + try { + await this.connectWebSocket(wsUrl); + this.proxyPort = connectionInfo.proxyPort; + this.attached = true; + return; + } catch { + // Stale config — server is gone. Clean up and start fresh. + await sandbox.runCommand({ + cmd: "rm", + args: ["-f", WsProxy.CONFIG_PATH], + signal: opts.signal, + }).catch(() => {}); + } + } + + // Install binary if not already present + await this.ensureBinaryInstalled(sandbox, opts.signal); + + // Start the server + const args = [`--ws-port=${opts.wsPort}`]; + if (opts.proxyPort) { + args.push(`--proxy-port=${opts.proxyPort}`); + } + if (opts.debug) { + args.push("--debug"); + } + + this.command = await sandbox.runCommand({ + cmd: SERVER_BIN_NAME, + args, + sudo: true, // needs root for CA cert installation in trust store + detached: true, + signal: opts.signal, + }); + + connectionInfo = await this.readConnectionInfo(opts.signal); + this.proxyPort = connectionInfo.proxyPort; + + const domain = sandbox.domain(opts.wsPort); + const wsUrl = `wss://${domain.replace(/^https?:\/\//, "")}/ws?token=${connectionInfo.token}`; + + await this.connectWebSocket(wsUrl); + this.attached = true; + } + + /** + * Register a request handler and return a proxy handle. + * Each call creates a unique session so different `runCommand` calls + * can have different handlers. + * + * The returned object has: + * - `url`: the raw proxy URL string + * - `env`: a record with all proxy env vars set (HTTP_PROXY, http_proxy, HTTPS_PROXY, https_proxy) + * - `toString()`: returns the URL (for backward compat / string coercion) + */ + async handle( + handler: ProxyHandler, + connectHandler?: ConnectHandler, + ): Promise { + if (!this.attached) { + throw new Error( + "WsProxy is not attached. Call attach() before handle().", + ); + } + + const sessionId = randomUUID(); + this.sessions.set(sessionId, { sessionId, handler, connectHandler }); + + // Tell the Go server we own this session and wait for ack + await this.registerAndWait(sessionId); + + const url = `http://${sessionId}:x@127.0.0.1:${this.proxyPort}`; + return { + url, + env: { + HTTP_PROXY: url, + http_proxy: url, + HTTPS_PROXY: url, + https_proxy: url, + }, + toString() { + return url; + }, + }; + } + + private registerAndWait(sessionId: string): Promise { + return new Promise((resolve) => { + const onMessage = (data: WebSocket.RawData) => { + try { + const msg = JSON.parse(data.toString()); + if ( + msg.type === "register-ack" && + msg.sessionIds?.includes(sessionId) + ) { + this.ws?.removeListener("message", onMessage); + resolve(); + } + } catch { + // ignore + } + }; + this.ws?.on("message", onMessage); + this.send({ type: "register", sessionIds: [sessionId] }); + }); + } + + /** + * Remove a previously registered handler by its HTTP_PROXY value. + */ + removeHandle(handle: ProxyHandle | string): void { + const url = typeof handle === "string" ? handle : handle.url; + const match = url.match(/^http:\/\/([^:]+):x@/); + if (match) { + const sessionId = match[1]; + this.sessions.delete(sessionId); + this.send({ type: "unregister", sessionIds: [sessionId] }); + } + } + + /** + * Disconnect WebSocket and stop the proxy server. + */ + async close(): Promise { + this.sessions.clear(); + this.attached = false; + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + if (this.command) { + try { + await this.command.kill(); + } catch { + // Ignore kill errors + } + this.command = null; + + // Clean up config file since we started the server + if (this.sandbox) { + await this.sandbox.runCommand({ + cmd: "rm", + args: ["-f", WsProxy.CONFIG_PATH], + }).catch(() => {}); + } + } + this.sandbox = null; + } + + // ------- Private methods ------- + + private static readonly CONFIG_PATH = "/tmp/vercel/http-proxy/config.json"; + + private async readExistingConfig( + sandbox: Sandbox, + signal?: AbortSignal, + ): Promise { + try { + // Fast sentinel check — avoids full cat+parse if server isn't running + const check = await sandbox.runCommand({ + cmd: "test", + args: ["-f", WsProxy.CONFIG_PATH], + signal, + }); + if (check.exitCode !== 0) return null; + + const result = await sandbox.runCommand({ + cmd: "cat", + args: [WsProxy.CONFIG_PATH], + signal, + }); + if (result.exitCode === 0) { + const stdout = await result.stdout(); + if (stdout.trim()) { + return JSON.parse(stdout.trim()) as ConnectionInfo; + } + } + } catch { + // Config doesn't exist or is invalid + } + return null; + } + + private async ensureBinaryInstalled( + sandbox: Sandbox, + signal?: AbortSignal, + ): Promise { + const check = await sandbox.runCommand({ + cmd: "command", + args: ["-v", SERVER_BIN_NAME], + signal, + }); + + if (check.exitCode === 0) { + return; // Already installed + } + + // Upload the binary + const pathname = `/tmp/vc-http-proxy-server-${randomUUID()}`; + const currentPath = import.meta.url; + const binaryPath = + process.env.VERCEL_DEV !== "0" + ? new URL("../dist/http-proxy-server-linux-x86_64", currentPath) + : new URL("./http-proxy-server-linux-x86_64", currentPath); + + const content = await fs.readFile(binaryPath); + await sandbox.writeFiles([{ path: pathname, content }], { signal }); + + await sandbox.runCommand({ + cmd: "bash", + args: [ + "-c", + `mv "${pathname}" /usr/local/bin/${SERVER_BIN_NAME}; chmod +x /usr/local/bin/${SERVER_BIN_NAME}`, + ], + sudo: true, + signal, + }); + } + + private async readConnectionInfo( + signal?: AbortSignal, + ): Promise { + if (!this.command) { + throw new Error("Server command not started"); + } + + // Read stdout to get the JSON connection info + for await (const log of this.command.logs({ signal })) { + if (log.stream === "stdout" && log.data.trim()) { + try { + return JSON.parse(log.data.trim()) as ConnectionInfo; + } catch { + // Not JSON yet, keep reading + } + } + } + + throw new Error("Server exited without outputting connection info"); + } + + private async connectWebSocket(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + + ws.on("open", () => { + this.ws = ws; + // Send ready message + const ready: OutgoingMessage = { type: "ready" }; + ws.send(JSON.stringify(ready)); + resolve(); + }); + + ws.on("message", (data) => { + this.handleMessage(data.toString()); + }); + + ws.on("error", (err) => { + if (!this.ws) { + reject(err); + } + }); + + ws.on("close", () => { + this.ws = null; + this.attached = false; + }); + }); + } + + private async handleMessage(raw: string): Promise { + let msg: IncomingMessage; + try { + msg = JSON.parse(raw); + } catch { + return; + } + + switch (msg.type) { + case "request": + await this.handleProxyRequest(msg); + break; + case "connect": + await this.handleConnectRequest(msg); + break; + } + } + + private async handleProxyRequest(msg: ProxyRequest): Promise { + const session = this.sessions.get(msg.sessionId); + + if (!session) { + this.send({ + type: "error", + requestId: msg.requestId, + message: `No handler registered for session ${msg.sessionId}`, + }); + return; + } + + try { + const request = await requestToProtocol(msg); + const response = await session.handler(request); + const protoResponse = await responseToProtocol( + msg.requestId, + response, + ); + this.send(protoResponse); + } catch (err) { + this.send({ + type: "error", + requestId: msg.requestId, + message: + err instanceof Error ? err.message : "Unknown handler error", + }); + } + } + + private async handleConnectRequest(msg: ConnectRequest): Promise { + const session = this.sessions.get(msg.sessionId); + + if (!session) { + this.send({ + type: "error", + requestId: msg.requestId, + message: `No handler registered for session ${msg.sessionId}`, + }); + return; + } + + try { + let allow = true; + if (session.connectHandler) { + allow = await session.connectHandler(msg.host); + } + this.send({ + type: "connect-response", + requestId: msg.requestId, + allow, + }); + } catch (err) { + this.send({ + type: "error", + requestId: msg.requestId, + message: + err instanceof Error ? err.message : "Unknown handler error", + }); + } + } + + private send(msg: OutgoingMessage): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } +} diff --git a/packages/http-proxy-tunnel/test/integration.test.ts b/packages/http-proxy-tunnel/test/integration.test.ts new file mode 100644 index 0000000..0f08adb --- /dev/null +++ b/packages/http-proxy-tunnel/test/integration.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, beforeAll, afterAll, afterEach, it } from "vitest"; +import { Sandbox } from "@vercel/sandbox"; +import { createWsProxy, type WsProxy } from "../src/index"; + +const WS_PORT = 5000; + +describe.skipIf(process.env.RUN_INTEGRATION_TESTS !== "1")( + "WsProxy integration", + () => { + let sandbox: Sandbox; + let proxy: WsProxy; + + beforeAll(async () => { + sandbox = await Sandbox.create({ ports: [WS_PORT] }); + }, 30_000); + + afterAll(async () => { + await sandbox?.stop().catch(() => {}); + }); + + afterEach(async () => { + await proxy?.close().catch(() => {}); + }); + + it( + "full proxy flow: attach, handle, runCommand with HTTP_PROXY, intercept request", + async () => { + proxy = createWsProxy(); + await proxy.attach(sandbox, { wsPort: WS_PORT }); + + const httpProxy = await proxy.handle( + (req) => new Response(`intercepted: ${req.url}`), + ); + + const result = await sandbox.runCommand({ + cmd: "curl", + args: ["-s", "--max-time", "10", "http://example.com/test"], + env: httpProxy.env, + }); + + const stdout = await result.stdout(); + expect(stdout).toContain("intercepted: http://example.com/test"); + }, + 60_000, + ); + + it( + "multiple sessions with different callbacks", + async () => { + proxy = createWsProxy(); + await proxy.attach(sandbox, { wsPort: WS_PORT }); + + const proxyA = await proxy.handle(() => new Response("response-a")); + const proxyB = await proxy.handle(() => new Response("response-b")); + + const [resultA, resultB] = await Promise.all([ + sandbox.runCommand({ + cmd: "curl", + args: ["-s", "--max-time", "10", "http://example.com/a"], + env: proxyA.env, + }), + sandbox.runCommand({ + cmd: "curl", + args: ["-s", "--max-time", "10", "http://example.com/b"], + env: proxyB.env, + }), + ]); + + expect(await resultA.stdout()).toBe("response-a"); + expect(await resultB.stdout()).toBe("response-b"); + }, + 60_000, + ); + + it( + "CONNECT deny blocks HTTPS", + async () => { + proxy = createWsProxy(); + await proxy.attach(sandbox, { wsPort: WS_PORT }); + + const httpProxy = await proxy.handle( + () => new Response("ok"), + () => false, // deny all CONNECT + ); + + const result = await sandbox.runCommand({ + cmd: "curl", + args: [ + "-s", + "--max-time", + "10", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "https://example.com", + ], + env: httpProxy.env, + }); + + const output = await result.output(); + expect(output).not.toBe("200"); + }, + 60_000, + ); + + it( + "removeHandle cleans up session", + async () => { + proxy = createWsProxy(); + await proxy.attach(sandbox, { wsPort: WS_PORT }); + + const httpProxy = await proxy.handle(() => new Response("ok")); + proxy.removeHandle(httpProxy); + + const result = await sandbox.runCommand({ + cmd: "curl", + args: [ + "-s", + "--max-time", + "10", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "http://example.com", + ], + env: httpProxy.env, + }); + + const stdout = await result.stdout(); + expect(stdout).not.toBe("200"); + }, + 60_000, + ); + + it( + "re-attach after close", + async () => { + proxy = createWsProxy(); + await proxy.attach(sandbox, { wsPort: WS_PORT }); + await proxy.close(); + + // Re-attach + proxy = createWsProxy(); + await proxy.attach(sandbox, { wsPort: WS_PORT }); + + const httpProxy = await proxy.handle(() => new Response("re-attached")); + const result = await sandbox.runCommand({ + cmd: "curl", + args: ["-s", "--max-time", "10", "http://example.com"], + env: httpProxy.env, + }); + + expect(await result.stdout()).toBe("re-attached"); + }, + 60_000, + ); + + it( + "two independent proxy clients on the same sandbox", + async () => { + const proxyA = createWsProxy(); + await proxyA.attach(sandbox, { wsPort: WS_PORT }); + + const proxyB = createWsProxy(); + await proxyB.attach(sandbox, { wsPort: WS_PORT }); + + const handleA = await proxyA.handle(() => new Response("from-A")); + const handleB = await proxyB.handle(() => new Response("from-B")); + + const [resultA, resultB] = await Promise.all([ + sandbox.runCommand({ + cmd: "curl", + args: ["-s", "--max-time", "10", "http://example.com/a"], + env: handleA.env, + }), + sandbox.runCommand({ + cmd: "curl", + args: ["-s", "--max-time", "10", "http://example.com/b"], + env: handleB.env, + }), + ]); + + expect(await resultA.stdout()).toBe("from-A"); + expect(await resultB.stdout()).toBe("from-B"); + + await proxyA.close(); + await proxyB.close(); + }, + 60_000, + ); + }, +); + +describe.skipIf(process.env.RUN_INTEGRATION_TESTS !== "1")( + "WsProxy with deny-all network policy", + () => { + let sandbox: Sandbox; + let proxy: WsProxy; + + beforeAll(async () => { + sandbox = await Sandbox.create({ + ports: [WS_PORT], + networkPolicy: "deny-all", + }); + }, 30_000); + + afterAll(async () => { + await proxy?.close().catch(() => {}); + await sandbox?.stop().catch(() => {}); + }); + + it( + "proxy intercepts requests even with no internet access", + async () => { + // First verify that direct internet access is blocked + const directResult = await sandbox.runCommand({ + cmd: "curl", + args: ["-s", "--max-time", "5", "-o", "/dev/null", "-w", "%{http_code}", "http://example.com"], + }); + const directOutput = await directResult.stdout(); + // curl should fail (exit code != 0) or return 000 (connection failed) + expect( + directResult.exitCode !== 0 || directOutput === "000", + `Expected direct request to fail, but got exit=${directResult.exitCode} output=${directOutput}`, + ).toBe(true); + + // Now use the proxy — requests should succeed via the WS tunnel + proxy = createWsProxy(); + await proxy.attach(sandbox, { wsPort: WS_PORT }); + + const httpProxy = await proxy.handle( + (req) => new Response(`proxied: ${new URL(req.url).hostname}`), + ); + + const result = await sandbox.runCommand({ + cmd: "curl", + args: ["-s", "--max-time", "10", "http://example.com/test"], + env: httpProxy.env, + }); + + expect(await result.stdout()).toBe("proxied: example.com"); + }, + 60_000, + ); + + it( + "proxy handler can fetch real HTTPS URLs on behalf of the sandbox", + async () => { + if (!proxy) { + proxy = createWsProxy(); + await proxy.attach(sandbox, { wsPort: WS_PORT }); + } + + const httpProxy = await proxy.handle(async (req) => { + // The sandbox has no internet, but the handler runs outside + // the sandbox and CAN fetch. The MITM proxy decrypts the + // HTTPS request so we see the full URL here. + return fetch(req.url); + }); + + const result = await sandbox.runCommand({ + cmd: "curl", + args: ["-s", "--max-time", "10", "https://vercel.com/robots.txt"], + env: httpProxy.env, + }); + + const stdout = await result.stdout(); + expect(stdout).toContain("User-Agent"); + expect(stdout).toContain("Sitemap"); + }, + 60_000, + ); + }, +); diff --git a/packages/http-proxy-tunnel/test/proxy.test.ts b/packages/http-proxy-tunnel/test/proxy.test.ts new file mode 100644 index 0000000..0c08466 --- /dev/null +++ b/packages/http-proxy-tunnel/test/proxy.test.ts @@ -0,0 +1,449 @@ +import { + describe, + expect, + onTestFailed, + onTestFinished, + test, + vi, +} from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import { type Readable } from "node:stream"; +import * as http from "node:http"; +import WebSocket from "ws"; + +interface ConnectionInfo { + wsPort: number; + proxyPort: number; + token: string; +} + +interface ProxyRequest { + type: "request"; + requestId: string; + sessionId: string; + method: string; + url: string; + headers: Record; + body?: string; +} + +interface ConnectRequest { + type: "connect"; + requestId: string; + sessionId: string; + host: string; +} + +function testStream(stream: Readable) { + const buffer: Buffer[] = []; + onTestFailed(() => { + const output = Buffer.concat(buffer).toString("utf-8"); + console.log("=== Process stderr ==="); + console.log(output); + console.log(`=== End stderr (${new Date().toISOString()}) ===`); + }); + stream.on("data", (chunk: Buffer | string) => { + buffer.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + return buffer; +} + +async function startProxy(): Promise<{ + proc: ChildProcess; + info: ConnectionInfo; + stderrBuffer: Buffer[]; +}> { + const binaryPath = new URL( + "../../http-proxy-server/http-proxy-server", + import.meta.url, + ).pathname; + + const proc = spawn(binaryPath, ["--debug"], { + stdio: ["ignore", "pipe", "pipe"], + }); + + const stderrBuffer = testStream(proc.stderr!); + + onTestFinished(() => { + proc.kill(); + }); + + // Read connection info JSON from stdout + const info = await new Promise((resolve, reject) => { + let data = ""; + proc.stdout!.on("data", (chunk: Buffer) => { + data += chunk.toString(); + const lines = data.split("\n"); + for (const line of lines) { + if (line.trim()) { + try { + resolve(JSON.parse(line.trim())); + return; + } catch { + // not JSON yet + } + } + } + }); + proc.on("error", reject); + proc.on("exit", (code) => { + if (code !== null) reject(new Error(`Process exited with code ${code}`)); + }); + setTimeout( + () => reject(new Error("Timed out waiting for connection info")), + 10_000, + ); + }); + + return { proc, info, stderrBuffer }; +} + +async function connectClient( + wsPort: number, + token: string, +): Promise<{ + ws: WebSocket; + messages: (ProxyRequest | ConnectRequest)[]; +}> { + const ws = new WebSocket( + `ws://localhost:${wsPort}/ws?token=${token}`, + ); + + await new Promise((resolve, reject) => { + ws.on("open", resolve); + ws.on("error", reject); + }); + + onTestFinished(() => { + ws.close(); + }); + + const messages: (ProxyRequest | ConnectRequest)[] = []; + ws.on("message", (data) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.type === "request" || msg.type === "connect") { + messages.push(msg); + } + } catch { + // ignore + } + }); + + // Send ready + ws.send(JSON.stringify({ type: "ready" })); + + // Small delay for ready to propagate + await new Promise((r) => setTimeout(r, 50)); + + return { ws, messages }; +} + +function makeProxiedRequest( + proxyPort: number, + sessionId: string, + targetUrl: string, + method = "GET", + body?: string, +): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const url = new URL(targetUrl); + const auth = Buffer.from(`${sessionId}:x`).toString("base64"); + + const req = http.request( + { + host: "127.0.0.1", + port: proxyPort, + path: targetUrl, + method, + headers: { + Host: url.host, + "Proxy-Authorization": `Basic ${auth}`, + ...(body ? { "Content-Length": Buffer.byteLength(body) } : {}), + }, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => + resolve({ + statusCode: res.statusCode!, + headers: res.headers, + body: data, + }), + ); + }, + ); + req.on("error", reject); + if (body) req.write(body); + req.end(); + }); +} + +function registerSessions(ws: WebSocket, sessionIds: string[]) { + ws.send(JSON.stringify({ type: "register", sessionIds })); +} + +describe("http-proxy-server", () => { + test("proxies HTTP request and returns callback response", async () => { + const { info } = await startProxy(); + const { ws, messages } = await connectClient(info.wsPort, info.token); + registerSessions(ws, ["my-session"]); + + // Handle incoming requests on the WS side + const originalOnMessage = ws.listeners("message")[0] as Function; + ws.removeAllListeners("message"); + ws.on("message", (data) => { + // Call original to populate messages array + originalOnMessage(data); + + const msg = JSON.parse(data.toString()); + if (msg.type === "request") { + const body = Buffer.from("hello proxy").toString("base64"); + ws.send( + JSON.stringify({ + type: "response", + requestId: msg.requestId, + status: 200, + headers: { "X-Test": ["passed"] }, + body, + }), + ); + } + }); + + const result = await makeProxiedRequest( + info.proxyPort, + "my-session", + "http://example.com/test", + ); + + expect(result.statusCode).toBe(200); + expect(result.body).toBe("hello proxy"); + expect(result.headers["x-test"]).toBe("passed"); + + await vi.waitFor(() => { + expect(messages.length).toBeGreaterThanOrEqual(1); + const req = messages[0] as ProxyRequest; + expect(req.sessionId).toBe("my-session"); + expect(req.method).toBe("GET"); + expect(req.url).toContain("example.com/test"); + }); + }); + + test("routes to correct session based on Proxy-Authorization", async () => { + const { info } = await startProxy(); + const { ws } = await connectClient(info.wsPort, info.token); + registerSessions(ws, ["session-a", "session-b"]); + + ws.removeAllListeners("message"); + ws.on("message", (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "request") { + const responseBody = + msg.sessionId === "session-a" ? "response-a" : "response-b"; + ws.send( + JSON.stringify({ + type: "response", + requestId: msg.requestId, + status: 200, + body: Buffer.from(responseBody).toString("base64"), + }), + ); + } + }); + + const [resultA, resultB] = await Promise.all([ + makeProxiedRequest(info.proxyPort, "session-a", "http://example.com/a"), + makeProxiedRequest(info.proxyPort, "session-b", "http://example.com/b"), + ]); + + expect(resultA.body).toBe("response-a"); + expect(resultB.body).toBe("response-b"); + }); + + test("handles POST with body", async () => { + const { info } = await startProxy(); + const { ws } = await connectClient(info.wsPort, info.token); + registerSessions(ws, ["post-session"]); + + let receivedBody = ""; + ws.removeAllListeners("message"); + ws.on("message", (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "request") { + receivedBody = msg.body + ? Buffer.from(msg.body, "base64").toString() + : ""; + ws.send( + JSON.stringify({ + type: "response", + requestId: msg.requestId, + status: 201, + body: Buffer.from("created").toString("base64"), + }), + ); + } + }); + + const result = await makeProxiedRequest( + info.proxyPort, + "post-session", + "http://example.com/api", + "POST", + "request-body", + ); + + expect(result.statusCode).toBe(201); + expect(result.body).toBe("created"); + expect(receivedBody).toBe("request-body"); + }); + + test("returns 502 when WS client sends error message", async () => { + const { info } = await startProxy(); + const { ws } = await connectClient(info.wsPort, info.token); + registerSessions(ws, ["err-session"]); + + ws.removeAllListeners("message"); + ws.on("message", (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "request") { + ws.send( + JSON.stringify({ + type: "error", + requestId: msg.requestId, + message: "denied by handler", + }), + ); + } + }); + + const result = await makeProxiedRequest( + info.proxyPort, + "err-session", + "http://example.com/fail", + ); + + expect(result.statusCode).toBe(502); + expect(result.body).toContain("denied by handler"); + }); + + test("CONNECT request — denied", async () => { + const { info } = await startProxy(); + const { ws } = await connectClient(info.wsPort, info.token); + registerSessions(ws, [""]); // empty session for unauthenticated CONNECT + + ws.removeAllListeners("message"); + ws.on("message", (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "connect") { + ws.send( + JSON.stringify({ + type: "connect-response", + requestId: msg.requestId, + allow: false, + }), + ); + } + }); + + const response = await new Promise((resolve) => { + const socket = new (require("net").Socket)(); + socket.connect(info.proxyPort, "127.0.0.1", () => { + socket.write( + "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n", + ); + }); + let data = ""; + socket.on("data", (chunk: Buffer) => { + data += chunk.toString(); + if (data.includes("\r\n\r\n")) { + socket.destroy(); + resolve(data); + } + }); + }); + + expect(response).toContain("403"); + }); + + test("rejects WebSocket with bad token", async () => { + const { info } = await startProxy(); + + const ws = new WebSocket( + `ws://localhost:${info.wsPort}/ws?token=wrong-token`, + ); + + const error = await new Promise((resolve) => { + ws.on("error", resolve); + ws.on("unexpected-response", (_req, res) => { + expect(res.statusCode).toBe(401); + resolve(new Event("rejected")); + }); + }); + + expect(error).toBeTruthy(); + }); + + test("returns 502 when no WS client connected", async () => { + const { info } = await startProxy(); + // Don't connect a WS client + + const result = await makeProxiedRequest( + info.proxyPort, + "session", + "http://example.com/test", + ).catch((err) => ({ statusCode: 502, headers: {}, body: err.message })); + + expect(result.statusCode).toBe(502); + }); + + test("multi-client: two WS clients with registered sessions route independently", async () => { + const { info } = await startProxy(); + + // Connect client A + const clientA = await connectClient(info.wsPort, info.token); + // Connect client B + const clientB = await connectClient(info.wsPort, info.token); + + // Register sessions + clientA.ws.send(JSON.stringify({ type: "register", sessionIds: ["session-a"] })); + clientB.ws.send(JSON.stringify({ type: "register", sessionIds: ["session-b"] })); + await new Promise((r) => setTimeout(r, 50)); + + // Set up handlers + clientA.ws.removeAllListeners("message"); + clientA.ws.on("message", (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "request") { + clientA.ws.send(JSON.stringify({ + type: "response", + requestId: msg.requestId, + status: 200, + body: Buffer.from("from-A").toString("base64"), + })); + } + }); + + clientB.ws.removeAllListeners("message"); + clientB.ws.on("message", (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "request") { + clientB.ws.send(JSON.stringify({ + type: "response", + requestId: msg.requestId, + status: 200, + body: Buffer.from("from-B").toString("base64"), + })); + } + }); + + const [resultA, resultB] = await Promise.all([ + makeProxiedRequest(info.proxyPort, "session-a", "http://example.com/a"), + makeProxiedRequest(info.proxyPort, "session-b", "http://example.com/b"), + ]); + + expect(resultA.body).toBe("from-A"); + expect(resultB.body).toBe("from-B"); + }); +}); diff --git a/packages/http-proxy-tunnel/tsconfig.json b/packages/http-proxy-tunnel/tsconfig.json new file mode 100644 index 0000000..4c1910f --- /dev/null +++ b/packages/http-proxy-tunnel/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ESNext"], + "strict": true, + "target": "ES2020", + "declaration": true, + "sourceMap": true, + "module": "ES2020", + "moduleResolution": "node", + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "vitest.config.ts"] +} diff --git a/packages/http-proxy-tunnel/tsdown.config.ts b/packages/http-proxy-tunnel/tsdown.config.ts new file mode 100644 index 0000000..b708785 --- /dev/null +++ b/packages/http-proxy-tunnel/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "dist", + format: ["esm", "cjs"], + outExtensions: ({ format }) => ({ + js: format === "cjs" ? ".cjs" : ".js", + }), + sourcemap: true, + dts: true, + target: "es2020", +}); diff --git a/packages/http-proxy-tunnel/vitest.config.ts b/packages/http-proxy-tunnel/vitest.config.ts new file mode 100644 index 0000000..2c01d68 --- /dev/null +++ b/packages/http-proxy-tunnel/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + testTimeout: 30_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72494ac..da8463f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,36 @@ importers: specifier: ^5.0.0 version: 5.8.3 + packages/http-proxy-server: {} + + packages/http-proxy-tunnel: + dependencies: + ws: + specifier: ^8.18.3 + version: 8.18.3 + devDependencies: + '@types/node': + specifier: 22.15.12 + version: 22.15.12 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + '@vercel/http-proxy-server': + specifier: workspace:* + version: link:../http-proxy-server + '@vercel/sandbox': + specifier: workspace:* + version: link:../vercel-sandbox + tsdown: + specifier: 'catalog:' + version: 0.16.6(typescript@5.8.3) + typescript: + specifier: 5.8.3 + version: 5.8.3 + vitest: + specifier: 'catalog:' + version: 3.2.1(@types/debug@4.1.12)(@types/node@22.15.12)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1) + packages/pty-tunnel: dependencies: debug: