diff --git a/hive/services/hiveui/hiveui.go b/hive/services/hiveui/hiveui.go index 2cf07294d..ceae58ec8 100644 --- a/hive/services/hiveui/hiveui.go +++ b/hive/services/hiveui/hiveui.go @@ -1,9 +1,14 @@ package hiveui import ( + "bufio" + "bytes" + "compress/gzip" "context" + "crypto/tls" "embed" "fmt" + "io" "io/fs" "log/slog" "net" @@ -16,7 +21,6 @@ import ( "syscall" "time" - "compress/gzip" "github.com/mulgadc/hive/hive/utils" ) @@ -206,8 +210,31 @@ func (svc *Service) launchService() error { } }() - slog.Info("Starting hive-ui service with HTTPS", "addr", addr) - return server.ListenAndServeTLS(svc.Config.TLSCert, svc.Config.TLSKey) + // Listen on the port and detect TLS vs plain HTTP on the same port. + // Plain HTTP connections get a 301 redirect to HTTPS instead of an error. + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("listen on %s: %w", addr, err) + } + + cert, err := tls.LoadX509KeyPair(svc.Config.TLSCert, svc.Config.TLSKey) + if err != nil { + return fmt.Errorf("load TLS keypair: %w", err) + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + } + + splitLn := &tlsSplitListener{ + Listener: ln, + port: svc.Config.Port, + tlsCfg: tlsConfig, + } + + slog.Info("Starting hive-ui service with HTTPS (auto-redirect HTTP)", "addr", addr) + return server.Serve(splitLn) } func securityHeadersMiddleware(next http.Handler) http.Handler { @@ -332,3 +359,92 @@ func gzipMiddleware(next http.Handler) http.Handler { next.ServeHTTP(grw, r) }) } + +const tlsRecordTypeHandshake = 0x16 + +// tlsSplitListener accepts connections and reads the first byte. If it looks +// like a TLS ClientHello (0x16), the connection is wrapped with TLS. Otherwise, +// a plain-HTTP redirect to HTTPS is sent and the connection is closed. +type tlsSplitListener struct { + net.Listener + port int + tlsCfg *tls.Config +} + +func (ln *tlsSplitListener) Accept() (net.Conn, error) { + for { + conn, err := ln.Listener.Accept() + if err != nil { + return nil, err + } + + // Set a deadline for the initial protocol detection byte so a + // slow/malicious client cannot block the accept loop. + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + + var buf [1]byte + if _, err := io.ReadFull(conn, buf[:]); err != nil { + conn.Close() + continue + } + + // Clear the deadline before handing off the connection. + _ = conn.SetReadDeadline(time.Time{}) + + if buf[0] == tlsRecordTypeHandshake { + // Prepend the peeked byte and upgrade to TLS. + merged := &prefixConn{ + Conn: conn, + r: io.MultiReader(bytes.NewReader(buf[:]), conn), + } + return tls.Server(merged, ln.tlsCfg), nil + } + + // Plain HTTP — send redirect and close. + go ln.redirectHTTP(conn, buf[0]) + } +} + +// redirectHTTP reads an HTTP request from conn (with the first byte already +// consumed into firstByte), writes a 301 redirect to HTTPS, and closes conn. +func (ln *tlsSplitListener) redirectHTTP(conn net.Conn, firstByte byte) { + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + + br := bufio.NewReader(io.MultiReader(bytes.NewReader([]byte{firstByte}), conn)) + req, err := http.ReadRequest(br) + if err != nil { + return + } + defer req.Body.Close() + + host := req.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + + // Sanitize host to prevent header injection via CRLF. + if strings.ContainsAny(host, "\r\n") { + return + } + + target := fmt.Sprintf("https://%s:%d%s", host, ln.port, req.URL.RequestURI()) + resp := &http.Response{ + StatusCode: http.StatusMovedPermanently, + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{"Location": {target}, "Connection": {"close"}}, + Body: http.NoBody, + } + _ = resp.Write(conn) +} + +// prefixConn wraps a net.Conn with a reader that replays prefixed bytes. +type prefixConn struct { + net.Conn + r io.Reader +} + +func (c *prefixConn) Read(b []byte) (int, error) { + return c.r.Read(b) +}