From 560d682a3158bf829e395e3ef879f00737579fc1 Mon Sep 17 00:00:00 2001 From: Anders Pitman Date: Fri, 27 Nov 2020 15:36:07 -0700 Subject: [PATCH] Implement client TLS termination Managed to reuse the same proxy function the server uses. --- boringproxy.go | 87 +++++++------------------------------------------- client.go | 62 +++++++++++++++++++++++++++-------- database.go | 2 +- http_proxy.go | 74 ++++++++++++++++++++++++++++++++++++++++++ sni.go | 6 ++-- 5 files changed, 137 insertions(+), 94 deletions(-) create mode 100644 http_proxy.go diff --git a/boringproxy.go b/boringproxy.go index 0490893..dd9a8e3 100644 --- a/boringproxy.go +++ b/boringproxy.go @@ -2,13 +2,11 @@ package main import ( "bufio" - "bytes" "crypto/tls" "flag" "fmt" "github.com/caddyserver/certmagic" "io" - "io/ioutil" "log" "net" "net/http" @@ -122,7 +120,16 @@ func Listen() { webUiHandler.handleWebUiRequest(w, r) } } else { - p.proxyRequest(w, r) + + tunnel, exists := db.GetTunnel(r.Host) + if !exists { + errMessage := fmt.Sprintf("No tunnel attached to %s", r.Host) + w.WriteHeader(500) + io.WriteString(w, errMessage) + return + } + + proxyRequest(w, r, tunnel, httpClient, tunnel.TunnelPort) } }) @@ -163,7 +170,7 @@ func (p *BoringProxy) handleConnection(clientConn net.Conn) { tunnel, exists := p.db.GetTunnel(clientHello.ServerName) - if exists && tunnel.TlsPassthrough { + if exists && (tunnel.TlsTermination == "client" || tunnel.TlsTermination == "passthrough") { p.passthroughRequest(passConn, tunnel) } else { p.httpListener.PassConn(passConn) @@ -198,78 +205,6 @@ func (p *BoringProxy) passthroughRequest(conn net.Conn, tunnel Tunnel) { wg.Wait() } -func (p *BoringProxy) proxyRequest(w http.ResponseWriter, r *http.Request) { - - tunnel, exists := p.db.GetTunnel(r.Host) - if !exists { - errMessage := fmt.Sprintf("No tunnel attached to %s", r.Host) - w.WriteHeader(500) - io.WriteString(w, errMessage) - return - } - - if tunnel.AuthUsername != "" || tunnel.AuthPassword != "" { - username, password, ok := r.BasicAuth() - if !ok { - w.Header()["WWW-Authenticate"] = []string{"Basic"} - w.WriteHeader(401) - return - } - - if username != tunnel.AuthUsername || password != tunnel.AuthPassword { - w.Header()["WWW-Authenticate"] = []string{"Basic"} - w.WriteHeader(401) - // TODO: should probably use a better form of rate limiting - time.Sleep(2 * time.Second) - return - } - } - - downstreamReqHeaders := r.Header.Clone() - - upstreamAddr := fmt.Sprintf("localhost:%d", tunnel.TunnelPort) - upstreamUrl := fmt.Sprintf("http://%s%s", upstreamAddr, r.URL.RequestURI()) - - body, err := ioutil.ReadAll(r.Body) - if err != nil { - errMessage := fmt.Sprintf("%s", err) - w.WriteHeader(500) - io.WriteString(w, errMessage) - return - } - - upstreamReq, err := http.NewRequest(r.Method, upstreamUrl, bytes.NewReader(body)) - if err != nil { - errMessage := fmt.Sprintf("%s", err) - w.WriteHeader(500) - io.WriteString(w, errMessage) - return - } - - upstreamReq.Header = downstreamReqHeaders - - upstreamReq.Header["X-Forwarded-Host"] = []string{r.Host} - upstreamReq.Host = fmt.Sprintf("%s:%d", tunnel.ClientAddress, tunnel.ClientPort) - - upstreamRes, err := p.httpClient.Do(upstreamReq) - if err != nil { - errMessage := fmt.Sprintf("%s", err) - w.WriteHeader(502) - io.WriteString(w, errMessage) - return - } - defer upstreamRes.Body.Close() - - downstreamResHeaders := w.Header() - - for k, v := range upstreamRes.Header { - downstreamResHeaders[k] = v - } - - w.WriteHeader(upstreamRes.StatusCode) - io.Copy(w, upstreamRes.Body) -} - func redirectTLS(w http.ResponseWriter, r *http.Request) { url := fmt.Sprintf("https://%s:443%s", r.Host, r.RequestURI) http.Redirect(w, r, url, http.StatusMovedPermanently) diff --git a/client.go b/client.go index 2417bd5..71a6dfc 100644 --- a/client.go +++ b/client.go @@ -7,6 +7,7 @@ import ( "errors" "flag" "fmt" + "github.com/caddyserver/certmagic" "golang.org/x/crypto/ssh" "io" "io/ioutil" @@ -29,6 +30,7 @@ type BoringProxyClient struct { user string cancelFuncs map[string]context.CancelFunc cancelFuncsMutex *sync.Mutex + certConfig *certmagic.Config } func NewBoringProxyClient() *BoringProxyClient { @@ -39,6 +41,9 @@ func NewBoringProxyClient() *BoringProxyClient { user := flagSet.String("user", "admin", "user") flagSet.Parse(os.Args[2:]) + certmagic.DefaultACME.DisableHTTPChallenge = true + certConfig := certmagic.NewDefault() + httpClient := &http.Client{} tunnels := make(map[string]Tunnel) cancelFuncs := make(map[string]context.CancelFunc) @@ -54,6 +59,7 @@ func NewBoringProxyClient() *BoringProxyClient { user: *user, cancelFuncs: cancelFuncs, cancelFuncsMutex: cancelFuncsMutex, + certConfig: certConfig, } } @@ -218,21 +224,49 @@ func (c *BoringProxyClient) BoreTunnel(tunnel Tunnel) context.CancelFunc { } //defer listener.Close() - go func() { - for { - conn, err := listener.Accept() - if err != nil { - // TODO: Currently assuming an error means the - // tunnel was manually deleted, but there - // could be other errors that we should be - // attempting to recover from rather than - // breaking. - break - //continue - } - go c.handleConnection(conn, tunnel.ClientAddress, tunnel.ClientPort) + if tunnel.TlsTermination == "client" { + // TODO: There's still quite a bit of duplication with what the server does. Could we + // encapsulate it into a type? + err = c.certConfig.ManageSync([]string{tunnel.Domain}) + if err != nil { + log.Println("CertMagic error at startup") + log.Println(err) } - }() + + tlsConfig := &tls.Config{ + GetCertificate: c.certConfig.GetCertificate, + NextProtos: []string{"h2", "acme-tls/1"}, + } + tlsListener := tls.NewListener(listener, tlsConfig) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + proxyRequest(w, r, tunnel, c.httpClient, tunnel.ClientPort) + }) + + // TODO: It seems inefficient to make a separate HTTP server for each TLS-passthrough tunnel, + // but the code is much simpler. The only alternative I've thought of so far involves storing + // all the tunnels in a mutexed map and retrieving them from a single HTTP server, same as the + // boringproxy server does. + go http.Serve(tlsListener, nil) + + } else { + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + // TODO: Currently assuming an error means the + // tunnel was manually deleted, but there + // could be other errors that we should be + // attempting to recover from rather than + // breaking. + break + //continue + } + go c.handleConnection(conn, tunnel.ClientAddress, tunnel.ClientPort) + } + }() + } <-ctx.Done() listener.Close() diff --git a/database.go b/database.go index cbffead..5ddf7ca 100644 --- a/database.go +++ b/database.go @@ -50,7 +50,7 @@ type Tunnel struct { AuthUsername string `json:"auth_username"` AuthPassword string `json:"auth_password"` CssId string `json:"css_id"` - TlsPassthrough bool `json:"tls_passthrough"` + TlsTermination string `json:"tls_termination"` } func NewDatabase() (*Database, error) { diff --git a/http_proxy.go b/http_proxy.go new file mode 100644 index 0000000..e3418ec --- /dev/null +++ b/http_proxy.go @@ -0,0 +1,74 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "net/http" + "time" +) + +func proxyRequest(w http.ResponseWriter, r *http.Request, tunnel Tunnel, httpClient *http.Client, port int) { + + if tunnel.AuthUsername != "" || tunnel.AuthPassword != "" { + username, password, ok := r.BasicAuth() + if !ok { + w.Header()["WWW-Authenticate"] = []string{"Basic"} + w.WriteHeader(401) + return + } + + if username != tunnel.AuthUsername || password != tunnel.AuthPassword { + w.Header()["WWW-Authenticate"] = []string{"Basic"} + w.WriteHeader(401) + // TODO: should probably use a better form of rate limiting + time.Sleep(2 * time.Second) + return + } + } + + downstreamReqHeaders := r.Header.Clone() + + upstreamAddr := fmt.Sprintf("localhost:%d", port) + upstreamUrl := fmt.Sprintf("http://%s%s", upstreamAddr, r.URL.RequestURI()) + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + errMessage := fmt.Sprintf("%s", err) + w.WriteHeader(500) + io.WriteString(w, errMessage) + return + } + + upstreamReq, err := http.NewRequest(r.Method, upstreamUrl, bytes.NewReader(body)) + if err != nil { + errMessage := fmt.Sprintf("%s", err) + w.WriteHeader(500) + io.WriteString(w, errMessage) + return + } + + upstreamReq.Header = downstreamReqHeaders + + upstreamReq.Header["X-Forwarded-Host"] = []string{r.Host} + upstreamReq.Host = fmt.Sprintf("%s:%d", tunnel.ClientAddress, tunnel.ClientPort) + + upstreamRes, err := httpClient.Do(upstreamReq) + if err != nil { + errMessage := fmt.Sprintf("%s", err) + w.WriteHeader(502) + io.WriteString(w, errMessage) + return + } + defer upstreamRes.Body.Close() + + downstreamResHeaders := w.Header() + + for k, v := range upstreamRes.Header { + downstreamResHeaders[k] = v + } + + w.WriteHeader(upstreamRes.StatusCode) + io.Copy(w, upstreamRes.Body) +} diff --git a/sni.go b/sni.go index 7370703..4ca0e49 100644 --- a/sni.go +++ b/sni.go @@ -1,5 +1,4 @@ -// NOTE: The code in this file was mostly copied from this very helpful -// article: +// NOTE: A lot of this code was copied from this very helpful article: // https://www.agwa.name/blog/post/writing_an_sni_proxy_in_go package main @@ -92,7 +91,8 @@ func (c ProxyConn) CloseWrite() error { return c.conn.(*net.TCPConn).C func (c ProxyConn) Read(p []byte) (int, error) { return c.reader.Read(p) } func (c ProxyConn) Write(p []byte) (int, error) { return c.conn.Write(p) } -// TODO: is this safe? Will it actually close properly? +// TODO: is this safe? Will it actually close properly, or does it need to be +// connected to the reader somehow? func (c ProxyConn) Close() error { return c.conn.Close() } func (c ProxyConn) LocalAddr() net.Addr { return c.conn.LocalAddr() } func (c ProxyConn) RemoteAddr() net.Addr { return c.conn.RemoteAddr() }