267 lines
7.2 KiB
Go
267 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// hopByHop headers that must not be forwarded by a proxy (RFC 7230 §6.1).
|
|
var hopByHopHeaders = map[string]bool{
|
|
"connection": true,
|
|
"keep-alive": true,
|
|
"proxy-authenticate": true,
|
|
"proxy-authorization": true,
|
|
"te": true,
|
|
"trailers": true,
|
|
"transfer-encoding": true,
|
|
"upgrade": true,
|
|
"proxy-connection": true, // non-standard but common
|
|
}
|
|
|
|
// Proxy is the HTTP/HTTPS proxy handler.
|
|
//
|
|
// For plain HTTP requests to gated hosts:
|
|
// browser → Proxy → (session token from phone) → endpoint directly → browser
|
|
//
|
|
// For HTTPS CONNECT to gated hosts:
|
|
// browser → Proxy → MITM TLS → (session token from phone) → endpoint directly → browser
|
|
//
|
|
// For non-gated hosts:
|
|
// browser → Proxy → internet (transparent, no auth)
|
|
type Proxy struct {
|
|
phone *PhoneClient
|
|
gated *GatedHosts
|
|
mitm *MITM
|
|
verbose bool
|
|
}
|
|
|
|
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodConnect {
|
|
p.handleConnect(w, r)
|
|
} else {
|
|
p.handleHTTP(w, r)
|
|
}
|
|
}
|
|
|
|
// handleHTTP handles plain HTTP proxy requests.
|
|
// For gated hosts: acquires a session token from the phone, adds it as
|
|
// Authorization: Bearer, then calls the endpoint directly.
|
|
func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Host == "" {
|
|
http.Error(w, "not a proxy request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
host := r.URL.Hostname()
|
|
port := r.URL.Port()
|
|
if port == "" {
|
|
port = "80"
|
|
}
|
|
isGated := p.gated.IsGated(host, port)
|
|
p.logf("HTTP %s %s (gated=%v)", r.Method, r.URL, isGated)
|
|
|
|
// Build outgoing request. RequestURI must be empty for http.Client/RoundTrip.
|
|
out := r.Clone(r.Context())
|
|
out.RequestURI = ""
|
|
stripHopByHop(out.Header)
|
|
|
|
if isGated {
|
|
token, err := p.phone.EnsureSession()
|
|
if err != nil {
|
|
http.Error(w, "auth: "+err.Error(), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
out.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
resp, err := http.DefaultTransport.RoundTrip(out)
|
|
if err != nil {
|
|
http.Error(w, "upstream: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
copyHeaders(w.Header(), resp.Header)
|
|
w.WriteHeader(resp.StatusCode)
|
|
io.Copy(w, resp.Body)
|
|
}
|
|
|
|
// handleConnect handles HTTPS CONNECT tunnels.
|
|
// For gated hosts: does TLS MITM so Authorization can be injected into each
|
|
// inner HTTP request before it is forwarded to the actual server.
|
|
// For non-gated hosts: transparent byte-level tunnel.
|
|
func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
|
host, portStr, err := net.SplitHostPort(r.Host)
|
|
if err != nil {
|
|
// No port — default to 443.
|
|
host = r.Host
|
|
portStr = "443"
|
|
}
|
|
if portStr == "" {
|
|
portStr = "443"
|
|
}
|
|
target := net.JoinHostPort(host, portStr)
|
|
isGated := p.gated.IsGated(host, portStr)
|
|
p.logf("CONNECT %s (gated=%v)", target, isGated)
|
|
|
|
hijacker, ok := w.(http.Hijacker)
|
|
if !ok {
|
|
http.Error(w, "hijack not supported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
clientConn, _, err := hijacker.Hijack()
|
|
if err != nil {
|
|
log.Printf("hijack: %v", err)
|
|
return
|
|
}
|
|
|
|
if isGated {
|
|
p.handleGatedConnect(clientConn, host, target)
|
|
} else {
|
|
p.handleDirectConnect(clientConn, target)
|
|
}
|
|
}
|
|
|
|
// handleDirectConnect tunnels bytes transparently — no auth, no inspection.
|
|
func (p *Proxy) handleDirectConnect(clientConn net.Conn, target string) {
|
|
defer clientConn.Close()
|
|
|
|
upConn, err := net.DialTimeout("tcp", target, 10*time.Second)
|
|
if err != nil {
|
|
fmt.Fprintf(clientConn, "HTTP/1.1 502 Bad Gateway\r\n\r\n")
|
|
return
|
|
}
|
|
defer upConn.Close()
|
|
|
|
fmt.Fprintf(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n")
|
|
pipe(clientConn, upConn)
|
|
}
|
|
|
|
// handleGatedConnect performs MITM TLS interception for a gated HTTPS host.
|
|
// Flow:
|
|
// 1. Acquire session token from phone (triggers FIDO2 card interaction).
|
|
// 2. Respond 200 to browser.
|
|
// 3. Wrap browser connection in TLS using a dynamically issued leaf cert.
|
|
// 4. Read each inner HTTP request, inject Authorization: Bearer, forward to
|
|
// the actual server over a fresh TLS connection.
|
|
// 5. Write the server response back to the browser.
|
|
func (p *Proxy) handleGatedConnect(clientConn net.Conn, hostname, target string) {
|
|
defer clientConn.Close()
|
|
|
|
token, err := p.phone.EnsureSession()
|
|
if err != nil {
|
|
fmt.Fprintf(clientConn, "HTTP/1.1 407 Proxy Authentication Required\r\n\r\n")
|
|
log.Printf("gated CONNECT %s: auth failed: %v", hostname, err)
|
|
return
|
|
}
|
|
|
|
if _, err := fmt.Fprintf(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
|
|
return
|
|
}
|
|
|
|
// Wrap the browser-side connection in TLS (we present a MITM cert).
|
|
tlsClient := tls.Server(clientConn, p.mitm.TLSConfig(hostname))
|
|
if err := tlsClient.Handshake(); err != nil {
|
|
p.logf("MITM TLS handshake for %s: %v", hostname, err)
|
|
return
|
|
}
|
|
defer tlsClient.Close()
|
|
|
|
// Serve inner HTTP requests on the now-decrypted browser stream.
|
|
clientBuf := bufio.NewReader(tlsClient)
|
|
for {
|
|
req, err := http.ReadRequest(clientBuf)
|
|
if err != nil {
|
|
if err != io.EOF {
|
|
p.logf("read inner request from browser (%s): %v", hostname, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Connection", "close") // no keep-alive to upstream
|
|
stripHopByHop(req.Header)
|
|
|
|
if err := p.forwardToUpstream(tlsClient, req, hostname, target); err != nil {
|
|
p.logf("forward to upstream %s: %v", target, err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// forwardToUpstream dials the real server over TLS, sends req, writes the
|
|
// response back to clientConn.
|
|
func (p *Proxy) forwardToUpstream(clientConn io.Writer, req *http.Request, hostname, target string) error {
|
|
upConn, err := tls.Dial("tcp", target, &tls.Config{
|
|
ServerName: hostname,
|
|
// TODO: pin server CA cert for production; accept any for now.
|
|
InsecureSkipVerify: true,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(clientConn, "HTTP/1.1 502 Bad Gateway\r\n\r\n")
|
|
return fmt.Errorf("dial: %w", err)
|
|
}
|
|
defer upConn.Close()
|
|
|
|
// Reconstruct a minimal absolute request for writing on the wire.
|
|
req.URL.Scheme = "https"
|
|
req.URL.Host = hostname
|
|
req.RequestURI = ""
|
|
|
|
if err := req.Write(upConn); err != nil {
|
|
return fmt.Errorf("write request: %w", err)
|
|
}
|
|
|
|
resp, err := http.ReadResponse(bufio.NewReader(upConn), req)
|
|
if err != nil {
|
|
return fmt.Errorf("read response: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
return resp.Write(clientConn)
|
|
}
|
|
|
|
// pipe copies bytes bidirectionally between two connections until either closes.
|
|
func pipe(a, b net.Conn) {
|
|
done := make(chan struct{}, 2)
|
|
go func() { io.Copy(a, b); done <- struct{}{} }()
|
|
go func() { io.Copy(b, a); done <- struct{}{} }()
|
|
<-done
|
|
}
|
|
|
|
// stripHopByHop removes hop-by-hop headers and any headers named in Connection.
|
|
func stripHopByHop(h http.Header) {
|
|
if conn := h.Get("Connection"); conn != "" {
|
|
for _, name := range strings.Split(conn, ",") {
|
|
h.Del(strings.TrimSpace(name))
|
|
}
|
|
}
|
|
for name := range hopByHopHeaders {
|
|
h.Del(name)
|
|
}
|
|
}
|
|
|
|
// copyHeaders copies non-hop-by-hop headers from src to dst.
|
|
func copyHeaders(dst, src http.Header) {
|
|
for k, vs := range src {
|
|
if !hopByHopHeaders[strings.ToLower(k)] {
|
|
for _, v := range vs {
|
|
dst.Add(k, v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Proxy) logf(format string, args ...any) {
|
|
if p.verbose {
|
|
log.Printf(format, args...)
|
|
}
|
|
}
|