Skip to content

Commit db38e4b

Browse files
feat: implementation of non-transparent proxy (#128)
* feat: implementation of non-transparent proxy * fix: minor fix * fix: minor fix * test: uncomment CONNECT tests * fix: remove unnecessary check for CONNECT domain * fix: remove unnecessary code
1 parent 65a8bda commit db38e4b

File tree

3 files changed

+220
-8
lines changed

3 files changed

+220
-8
lines changed

e2e_tests/landjail/landjail_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,19 @@ func TestLandjail(t *testing.T) {
2929
})
3030

3131
// Test allowed HTTPS request
32-
// t.Run("HTTPSRequestThroughBoundary", func(t *testing.T) {
33-
// expectedResponse := `{"message":"👋"}
34-
//`
35-
// lt.ExpectAllowed("https://dev.coder.com/api/v2", expectedResponse)
36-
// })
32+
t.Run("HTTPSRequestThroughBoundary", func(t *testing.T) {
33+
expectedResponse := `{"message":"👋"}
34+
`
35+
lt.ExpectAllowed("https://dev.coder.com/api/v2", expectedResponse)
36+
})
3737

3838
// Test blocked HTTP request
3939
t.Run("HTTPBlockedDomainTest", func(t *testing.T) {
4040
lt.ExpectDeny("http://example.com")
4141
})
4242

4343
// Test blocked HTTPS request
44-
//t.Run("HTTPSBlockedDomainTest", func(t *testing.T) {
45-
// lt.ExpectDeny("https://example.com")
46-
//})
44+
t.Run("HTTPSBlockedDomainTest", func(t *testing.T) {
45+
lt.ExpectDeny("https://example.com")
46+
})
4747
}

proxy/connect.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package proxy
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"crypto/tls"
7+
"io"
8+
"net"
9+
"net/http"
10+
"net/url"
11+
"strconv"
12+
"strings"
13+
14+
"github.com/coder/boundary/audit"
15+
)
16+
17+
// handleCONNECT handles HTTP CONNECT requests for tunneling
18+
func (p *Server) handleCONNECT(conn net.Conn, req *http.Request) {
19+
// Extract target from CONNECT request
20+
// CONNECT requests have the target in req.Host (format: hostname:port)
21+
target := req.Host
22+
if target == "" {
23+
target = req.URL.Host
24+
}
25+
26+
p.logger.Debug("🔌 CONNECT request", "target", target)
27+
28+
// Send 200 Connection established response
29+
response := "HTTP/1.1 200 Connection established\r\n\r\n"
30+
_, err := conn.Write([]byte(response))
31+
if err != nil {
32+
p.logger.Error("Failed to send CONNECT response", "error", err)
33+
return
34+
}
35+
36+
p.logger.Debug("CONNECT tunnel established", "target", target)
37+
38+
// Handle the tunnel - decrypt TLS and process each HTTP request
39+
p.handleCONNECTTunnel(conn, target)
40+
}
41+
42+
// handleCONNECTTunnel handles the tunnel after CONNECT is established
43+
// It decrypts TLS traffic and processes each HTTP request separately
44+
func (p *Server) handleCONNECTTunnel(conn net.Conn, target string) {
45+
defer func() {
46+
err := conn.Close()
47+
if err != nil {
48+
p.logger.Error("Failed to close CONNECT tunnel", "error", err)
49+
}
50+
}()
51+
52+
// Wrap connection with TLS server to decrypt traffic
53+
tlsConn := tls.Server(conn, p.tlsConfig)
54+
55+
// Perform TLS handshake
56+
if err := tlsConn.Handshake(); err != nil {
57+
p.logger.Error("TLS handshake failed in CONNECT tunnel", "error", err)
58+
return
59+
}
60+
61+
p.logger.Debug("✅ TLS handshake successful in CONNECT tunnel")
62+
63+
// Process HTTP requests in a loop
64+
reader := bufio.NewReader(tlsConn)
65+
for {
66+
// Read HTTP request from tunnel
67+
req, err := http.ReadRequest(reader)
68+
if err != nil {
69+
if err == io.EOF {
70+
p.logger.Debug("CONNECT tunnel closed by client")
71+
break
72+
}
73+
p.logger.Error("Failed to read HTTP request from CONNECT tunnel", "error", err)
74+
break
75+
}
76+
77+
p.logger.Debug("🔒 HTTP Request in CONNECT tunnel", "method", req.Method, "url", req.URL.String(), "target", target)
78+
79+
// Process this request - check if allowed and forward to target
80+
p.processTunnelRequest(tlsConn, req, target)
81+
}
82+
}
83+
84+
// processTunnelRequest processes a single HTTP request from the CONNECT tunnel
85+
func (p *Server) processTunnelRequest(conn net.Conn, req *http.Request, targetHost string) {
86+
// Check if request should be allowed
87+
// Use the original request URL but evaluate against rules
88+
urlStr := req.Host + req.URL.String()
89+
result := p.ruleEngine.Evaluate(req.Method, urlStr)
90+
91+
// Audit the request
92+
p.auditor.AuditRequest(audit.Request{
93+
Method: req.Method,
94+
URL: req.URL.String(),
95+
Host: req.Host,
96+
Allowed: result.Allowed,
97+
Rule: result.Rule,
98+
})
99+
100+
if !result.Allowed {
101+
p.logger.Debug("Request in CONNECT tunnel blocked", "method", req.Method, "url", urlStr)
102+
p.writeBlockedResponse(conn, req)
103+
return
104+
}
105+
106+
// Forward request to target
107+
// The target is the original CONNECT target, but we use the request's host/path
108+
p.forwardTunnelRequest(conn, req, targetHost)
109+
}
110+
111+
// forwardTunnelRequest forwards a request from the tunnel to the target
112+
func (p *Server) forwardTunnelRequest(conn net.Conn, req *http.Request, targetHost string) {
113+
// Create HTTP client
114+
client := &http.Client{
115+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
116+
return http.ErrUseLastResponse // Don't follow redirects
117+
},
118+
}
119+
120+
// Extract hostname and port from targetHost
121+
hostname := targetHost
122+
port := "443" // Default HTTPS port
123+
if strings.Contains(targetHost, ":") {
124+
parts := strings.Split(targetHost, ":")
125+
hostname = parts[0]
126+
port = parts[1]
127+
}
128+
129+
scheme := "https"
130+
if port == "80" {
131+
scheme = "http"
132+
}
133+
134+
// Build target URL using the request's path but the CONNECT target's host
135+
// URL.Host can include port for connection, but Host header should not
136+
targetURL := &url.URL{
137+
Scheme: scheme,
138+
Host: targetHost, // Include port for connection
139+
Path: req.URL.Path,
140+
RawQuery: req.URL.RawQuery,
141+
}
142+
143+
var body = req.Body
144+
if req.Method == http.MethodGet || req.Method == http.MethodHead {
145+
body = nil
146+
}
147+
148+
newReq, err := http.NewRequest(req.Method, targetURL.String(), body)
149+
if err != nil {
150+
p.logger.Error("can't create HTTP request for tunnel", "error", err)
151+
return
152+
}
153+
154+
// Set Host header to just the hostname (without port)
155+
// The Host header should not include the port number for HTTPS
156+
newReq.Host = hostname
157+
158+
// Copy headers (but skip Host since we set it explicitly above)
159+
for name, values := range req.Header {
160+
// Skip connection-specific headers and Host header
161+
lowerName := strings.ToLower(name)
162+
if lowerName == "connection" || lowerName == "proxy-connection" || lowerName == "host" {
163+
continue
164+
}
165+
for _, value := range values {
166+
newReq.Header.Add(name, value)
167+
}
168+
}
169+
170+
// Make request to destination
171+
resp, err := client.Do(newReq)
172+
if err != nil {
173+
p.logger.Error("Failed to forward request from CONNECT tunnel", "error", err)
174+
return
175+
}
176+
177+
p.logger.Debug("Response from target", "status", resp.StatusCode, "target", targetHost)
178+
179+
// Read the body and set Content-Length
180+
bodyBytes, err := io.ReadAll(resp.Body)
181+
if err != nil {
182+
p.logger.Error("can't read response body from tunnel", "error", err)
183+
return
184+
}
185+
resp.Header.Set("Content-Length", strconv.Itoa(len(bodyBytes)))
186+
resp.ContentLength = int64(len(bodyBytes))
187+
err = resp.Body.Close()
188+
if err != nil {
189+
p.logger.Error("Failed to close response body", "error", err)
190+
return
191+
}
192+
resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
193+
194+
// Normalize to HTTP/1.1
195+
resp.Proto = "HTTP/1.1"
196+
resp.ProtoMajor = 1
197+
resp.ProtoMinor = 1
198+
199+
// Write response back to tunnel
200+
err = resp.Write(conn)
201+
if err != nil {
202+
p.logger.Error("Failed to write response to CONNECT tunnel", "error", err)
203+
return
204+
}
205+
206+
p.logger.Debug("Successfully forwarded response in CONNECT tunnel")
207+
}

proxy/proxy.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,11 @@ func (p *Server) handleHTTPConnection(conn net.Conn) {
218218
return
219219
}
220220

221+
if req.Method == http.MethodConnect {
222+
p.handleCONNECT(conn, req)
223+
return
224+
}
225+
221226
p.logger.Debug("🌐 HTTP Request: %s %s", req.Method, req.URL.String())
222227
p.processHTTPRequest(conn, req, false)
223228
}

0 commit comments

Comments
 (0)