summaryrefslogtreecommitdiff
path: root/internal/handlers/claudomator_proxy.go
blob: f3f0cd23a243e95eb92c65ef5884e8682edc6d05 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
package handlers

import (
	"io"
	"net"
	"net/http"
	"net/http/httputil"
	"net/url"
	"strings"
)

// NewClaudomatorProxy returns an http.Handler that reverse-proxies requests to
// targetURL, stripping the "/claudomator" prefix from the path. WebSocket
// upgrade requests are handled via raw TCP hijacking to support long-lived
// connections.
func NewClaudomatorProxy(targetURL, allowedOrigin string) http.Handler {
	target, err := url.Parse(targetURL)
	if err != nil {
		panic("claudomator: invalid target URL: " + err.Error())
	}

	rp := &httputil.ReverseProxy{
		Director: func(req *http.Request) {
			req.URL.Scheme = target.Scheme
			req.URL.Host = target.Host

			// Strip /claudomator prefix
			stripped := strings.TrimPrefix(req.URL.Path, "/claudomator")
			if stripped == "" {
				stripped = "/"
			}
			req.URL.Path = stripped

			if req.URL.RawPath != "" {
				rawStripped := strings.TrimPrefix(req.URL.RawPath, "/claudomator")
				if rawStripped == "" {
					rawStripped = "/"
				}
				req.URL.RawPath = rawStripped
			}
		},
		ModifyResponse: func(resp *http.Response) error {
			// Preserve Service-Worker-Allowed header
			if swa := resp.Header.Get("Service-Worker-Allowed"); swa != "" {
				resp.Header.Set("Service-Worker-Allowed", swa)
			}
			return nil
		},
	}

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
			if allowedOrigin != "" && r.Header.Get("Origin") != allowedOrigin {
				http.Error(w, "forbidden", http.StatusForbidden)
				return
			}
			proxyWebSocket(w, r, target)
			return
		}
		rp.ServeHTTP(w, r)
	})
}

// proxyWebSocket handles WebSocket upgrade via raw TCP hijacking.
func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) {
	// Determine host:port for dialing
	host := target.Host
	if target.Port() == "" {
		switch target.Scheme {
		case "https":
			host += ":443"
		default:
			host += ":80"
		}
	}

	upstream, err := net.Dial("tcp", host)
	if err != nil {
		http.Error(w, "bad gateway", http.StatusBadGateway)
		return
	}
	defer upstream.Close()

	// Rewrite path on the request before forwarding
	r.URL.Scheme = target.Scheme
	r.URL.Host = target.Host
	stripped := strings.TrimPrefix(r.URL.Path, "/claudomator")
	if stripped == "" {
		stripped = "/"
	}
	r.URL.Path = stripped
	if r.URL.RawPath != "" {
		rawStripped := strings.TrimPrefix(r.URL.RawPath, "/claudomator")
		if rawStripped == "" {
			rawStripped = "/"
		}
		r.URL.RawPath = rawStripped
	}
	r.RequestURI = r.URL.RequestURI()

	// Write the HTTP request to the upstream connection
	if err := r.Write(upstream); err != nil {
		http.Error(w, "bad gateway", http.StatusBadGateway)
		return
	}

	// Hijack the client connection
	hijacker, ok := w.(http.Hijacker)
	if !ok {
		http.Error(w, "websocket not supported", http.StatusInternalServerError)
		return
	}
	clientConn, _, err := hijacker.Hijack()
	if err != nil {
		return
	}
	defer clientConn.Close()

	// Bidirectional copy — no deadlines so long-lived WS connections survive
	done := make(chan struct{}, 2)
	go func() {
		_, _ = io.Copy(upstream, clientConn)
		done <- struct{}{}
	}()
	go func() {
		_, _ = io.Copy(clientConn, upstream)
		done <- struct{}{}
	}()
	<-done
}