mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user