Skip to content

Commit d2e7a6c

Browse files
authored
Add opt-in HTTP reverse-proxy tunnel path (#207)
1 parent 4acf558 commit d2e7a6c

File tree

3 files changed

+201
-22
lines changed

3 files changed

+201
-22
lines changed

tunnel/internal/client/client/client.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,18 @@ func (c *Client) Start(ctx context.Context, services ...string) error {
7070
continue
7171
}
7272
clientConfigs = append(clientConfigs, config.ClientConfig{
73-
ServerUrl: c.config.ServerUrl,
74-
SshUrl: c.config.SshUrl,
75-
TunnelUrl: c.config.TunnelUrl,
76-
SecretKey: c.config.SecretKey,
77-
Tunnel: tunnel,
78-
UseLocalHost: c.config.UseLocalHost,
79-
Debug: c.config.Debug,
80-
EnableRequestLogging: c.config.EnableRequestLogging,
81-
HealthCheckInterval: c.config.HealthCheckInterval,
82-
HealthCheckMaxRetries: c.config.HealthCheckMaxRetries,
83-
DisableTUI: c.config.DisableTUI,
73+
ServerUrl: c.config.ServerUrl,
74+
SshUrl: c.config.SshUrl,
75+
TunnelUrl: c.config.TunnelUrl,
76+
SecretKey: c.config.SecretKey,
77+
Tunnel: tunnel,
78+
UseLocalHost: c.config.UseLocalHost,
79+
Debug: c.config.Debug,
80+
EnableRequestLogging: c.config.EnableRequestLogging,
81+
HealthCheckInterval: c.config.HealthCheckInterval,
82+
HealthCheckMaxRetries: c.config.HealthCheckMaxRetries,
83+
DisableTUI: c.config.DisableTUI,
84+
EnableHttpReverseProxy: c.config.EnableHttpReverseProxy,
8485
})
8586
}
8687

tunnel/internal/client/config/config.go

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ type Config struct {
6868
HealthCheckInterval int `yaml:"health_check_interval"`
6969
HealthCheckMaxRetries int `yaml:"health_check_max_retries"`
7070
DisableTUI bool `yaml:"disable_tui"`
71+
EnableHttpReverseProxy bool `yaml:"enable_http_reverse_proxy"`
7172
DisableUpdateCheck bool `yaml:"disable_update_check"`
7273
}
7374

@@ -121,17 +122,18 @@ func (c Config) GetAdminAddress() string {
121122
}
122123

123124
type ClientConfig struct {
124-
ServerUrl string
125-
SshUrl string
126-
TunnelUrl string
127-
SecretKey string
128-
Tunnel Tunnel
129-
UseLocalHost bool
130-
Debug bool
131-
EnableRequestLogging bool
132-
HealthCheckInterval int
133-
HealthCheckMaxRetries int
134-
DisableTUI bool
125+
ServerUrl string
126+
SshUrl string
127+
TunnelUrl string
128+
SecretKey string
129+
Tunnel Tunnel
130+
UseLocalHost bool
131+
Debug bool
132+
EnableRequestLogging bool
133+
HealthCheckInterval int
134+
HealthCheckMaxRetries int
135+
DisableTUI bool
136+
EnableHttpReverseProxy bool
135137
}
136138

137139
func (c *ClientConfig) GetHttpTunnelAddr() string {

tunnel/internal/client/ssh/ssh.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"io"
1010
"net"
1111
"net/http"
12+
"net/http/httputil"
13+
"net/url"
1214
"os"
1315
"strings"
1416
"sync"
@@ -33,6 +35,45 @@ var (
3335
ErrLocalSetupIncomplete = fmt.Errorf("local setup incomplete")
3436
)
3537

38+
type requestLogContextKey struct{}
39+
40+
type requestLogData struct {
41+
id string
42+
request *http.Request
43+
body []byte
44+
}
45+
46+
type singleConnListener struct {
47+
conn net.Conn
48+
accepted bool
49+
closed bool
50+
mu sync.Mutex
51+
}
52+
53+
func (l *singleConnListener) Accept() (net.Conn, error) {
54+
l.mu.Lock()
55+
defer l.mu.Unlock()
56+
57+
if l.closed || l.accepted {
58+
return nil, net.ErrClosed
59+
}
60+
61+
l.accepted = true
62+
return l.conn, nil
63+
}
64+
65+
func (l *singleConnListener) Close() error {
66+
l.mu.Lock()
67+
defer l.mu.Unlock()
68+
69+
l.closed = true
70+
return nil
71+
}
72+
73+
func (l *singleConnListener) Addr() net.Addr {
74+
return l.conn.LocalAddr()
75+
}
76+
3677
type SshClient struct {
3778
config config.ClientConfig
3879
listener net.Listener
@@ -216,6 +257,141 @@ func (s *SshClient) startListenerForClient() error {
216257
}
217258

218259
func (s *SshClient) httpTunnel(src net.Conn, localEndpoint string) {
260+
if s.config.EnableHttpReverseProxy {
261+
s.httpTunnelReverseProxy(src, localEndpoint)
262+
return
263+
}
264+
265+
s.httpTunnelLegacy(src, localEndpoint)
266+
}
267+
268+
func (s *SshClient) httpTunnelReverseProxy(src net.Conn, localEndpoint string) {
269+
defer src.Close()
270+
271+
target := &url.URL{
272+
Scheme: "http",
273+
Host: localEndpoint,
274+
}
275+
276+
transport := &http.Transport{
277+
Proxy: http.ProxyFromEnvironment,
278+
ForceAttemptHTTP2: false,
279+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
280+
var d net.Dialer
281+
return d.DialContext(ctx, network, localEndpoint)
282+
},
283+
}
284+
defer transport.CloseIdleConnections()
285+
286+
proxy := httputil.NewSingleHostReverseProxy(target)
287+
proxy.Transport = transport
288+
289+
defaultDirector := proxy.Director
290+
proxy.Director = func(request *http.Request) {
291+
host := request.Host
292+
defaultDirector(request)
293+
request.Host = host
294+
}
295+
296+
proxy.ModifyResponse = func(response *http.Response) error {
297+
if !s.config.EnableRequestLogging {
298+
return nil
299+
}
300+
301+
if response.StatusCode == http.StatusSwitchingProtocols {
302+
return nil
303+
}
304+
305+
if strings.Contains(response.Header.Get("Content-Type"), "text/event-stream") {
306+
return nil
307+
}
308+
309+
logData, ok := response.Request.Context().Value(requestLogContextKey{}).(*requestLogData)
310+
if !ok || logData == nil || logData.request == nil {
311+
return nil
312+
}
313+
314+
responseBody, err := io.ReadAll(response.Body)
315+
if err != nil {
316+
if s.config.Debug {
317+
s.logDebug("Failed to read response body from reverse proxy", err)
318+
}
319+
return err
320+
}
321+
response.Body.Close()
322+
response.Body = io.NopCloser(bytes.NewBuffer(responseBody))
323+
324+
s.logHttpRequest(logData.id, logData.request, logData.body, response, responseBody)
325+
return nil
326+
}
327+
328+
proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, err error) {
329+
if s.config.Debug {
330+
s.logDebug("HTTP reverse proxy failed", err)
331+
}
332+
333+
htmlContent := utils.LocalServerNotOnline(localEndpoint)
334+
writer.Header().Set("X-Portr-Error", "true")
335+
writer.Header().Set("X-Portr-Error-Reason", "local-server-not-online")
336+
writer.Header().Set("Content-Type", "text/html")
337+
writer.WriteHeader(http.StatusServiceUnavailable)
338+
_, _ = writer.Write([]byte(htmlContent))
339+
}
340+
341+
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
342+
if request.Header.Get("X-Portr-Ping-Request") == "true" {
343+
writer.WriteHeader(http.StatusOK)
344+
return
345+
}
346+
347+
if !s.config.EnableRequestLogging {
348+
proxy.ServeHTTP(writer, request)
349+
return
350+
}
351+
352+
requestBody, err := io.ReadAll(request.Body)
353+
if err != nil {
354+
if s.config.Debug {
355+
s.logDebug("Failed to read request body for reverse proxy logging", err)
356+
}
357+
http.Error(writer, "Bad Request", http.StatusBadRequest)
358+
return
359+
}
360+
request.Body.Close()
361+
request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
362+
363+
requestForLog := request.Clone(context.Background())
364+
requestForLog.Header = request.Header.Clone()
365+
requestForLog.Host = request.Host
366+
if request.URL != nil {
367+
clonedURL := *request.URL
368+
requestForLog.URL = &clonedURL
369+
}
370+
371+
logCtx := context.WithValue(request.Context(), requestLogContextKey{}, &requestLogData{
372+
id: ulid.Make().String(),
373+
request: requestForLog,
374+
body: requestBody,
375+
})
376+
377+
proxy.ServeHTTP(writer, request.WithContext(logCtx))
378+
})
379+
380+
server := &http.Server{
381+
Handler: handler,
382+
ReadHeaderTimeout: 15 * time.Second,
383+
}
384+
385+
listener := &singleConnListener{conn: src}
386+
err := server.Serve(listener)
387+
if err != nil && err != net.ErrClosed {
388+
if s.config.Debug {
389+
s.logDebug("Reverse proxy tunnel closed with error", err)
390+
}
391+
}
392+
}
393+
394+
func (s *SshClient) httpTunnelLegacy(src net.Conn, localEndpoint string) {
219395
var dst net.Conn
220396

221397
defer src.Close()

0 commit comments

Comments
 (0)