fix: redirect HTTP to HTTPS on same port for hive-ui

Uses a TLS-detecting listener that peeks the first byte of each
connection. TLS handshakes (0x16) proceed normally; plain HTTP
requests get a 301 redirect to the HTTPS URL. This handles
http://localhost:3000 gracefully instead of showing a cryptic error.
This commit is contained in:
Julian Sommer
2026-03-10 08:00:37 +10:00
parent 1efb4c4a3b
commit c9c3cda651
+119 -3
View File
@@ -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)
}