diff --git a/examples/main.go b/examples/main.go index c1eb484..f9d009f 100644 --- a/examples/main.go +++ b/examples/main.go @@ -33,6 +33,7 @@ func main() { dbPath := flag.String("db", "./data/derby.db", "Path to SQLite database file") noWeb := flag.Bool("no-web", false, "Disable web interface") noTerminal := flag.Bool("no-terminal", false, "Disable terminal interface") + useHTTP2 := flag.Bool("http2", true, "Enable HTTP/2 (requires TLS)") flag.Parse() // Create a new derby clock connection @@ -73,7 +74,7 @@ func main() { wg.Add(1) go func() { defer wg.Done() - startWebInterface(clock, webEvents, *webPort, *dbPath, ctx) + startWebInterface(clock, webEvents, *webPort, *dbPath, *useHTTP2, ctx) }() } @@ -101,13 +102,13 @@ func main() { } // startWebInterface initializes and runs the web interface -func startWebInterface(clock *derby.DerbyClock, events <-chan derby.Event, webPort int, dbPath string, ctx context.Context) { +func startWebInterface(clock *derby.DerbyClock, events <-chan derby.Event, webPort int, dbPath string, useHTTP2 bool, ctx context.Context) { logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, })) // Create and start the web server - server, err := web.NewServer(clock, events, dbPath, webPort, logger) + server, err := web.NewServer(clock, events, dbPath, webPort, useHTTP2, logger) if err != nil { logger.Error("Error creating web server", "error", err) return diff --git a/web/server.go b/web/server.go index 275fb08..f590839 100644 --- a/web/server.go +++ b/web/server.go @@ -1,15 +1,25 @@ package web import ( + "bytes" "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "embed" "encoding/json" + "encoding/pem" "fmt" "io/fs" "log" "log/slog" + "math/big" + "net" "net/http" "net/url" + "os" "strconv" "strings" "sync" @@ -42,10 +52,11 @@ type Server struct { logger *slog.Logger db *db.DB adminEvents chan string + useHTTP2 bool } // NewServer creates a new web server -func NewServer(clock *derby.DerbyClock, events <-chan derby.Event, dbPath string, port int, logger *slog.Logger) (*Server, error) { +func NewServer(clock *derby.DerbyClock, events <-chan derby.Event, dbPath string, port int, useHTTP2 bool, logger *slog.Logger) (*Server, error) { // Initialize database database, err := db.New(dbPath) if err != nil { @@ -66,6 +77,7 @@ func NewServer(clock *derby.DerbyClock, events <-chan derby.Event, dbPath string logger: logger, db: database, adminEvents: make(chan string, 10), + useHTTP2: useHTTP2, } // Set up routes @@ -218,34 +230,114 @@ func (s *Server) routes() { } -// Start starts the web server +// Start starts the web server with HTTP/2 support func (s *Server) Start() error { addr := fmt.Sprintf(":%d", s.port) + + // Configure TLS + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + PreferServerCipherSuites: true, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + }, + NextProtos: []string{"h2", "http/1.1"}, + } + s.server = &http.Server{ - Addr: addr, - Handler: s.router, - // Add these settings to handle multiple concurrent connections - ReadTimeout: 120 * time.Second, // Longer timeout for SSE - WriteTimeout: 120 * time.Second, // Longer timeout for SSE - IdleTimeout: 120 * time.Second, - MaxHeaderBytes: 1 << 20, // 1 MB + Addr: addr, + Handler: s.router, + TLSConfig: tlsConfig, + ReadTimeout: 120 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 120 * time.Second, } - // Log middleware configuration - s.logger.Info("Chi router middleware configuration", - "timeout", "120s", - "recoverer", true, - "logger", true) + // Check if certificate files exist + certFile := "server.crt" + keyFile := "server.key" + + certExists := fileExists(certFile) + keyExists := fileExists(keyFile) + + s.logger.Info("Starting web server", "port", s.port, "http2", true) + + if certExists && keyExists { + // Start HTTPS server with HTTP/2 support + s.logger.Info("Using TLS with HTTP/2", "certFile", certFile, "keyFile", keyFile) + return s.server.ListenAndServeTLS(certFile, keyFile) + } else { + // Generate self-signed certificate for development + s.logger.Info("Generating self-signed certificate for HTTP/2") + cert, key, err := generateSelfSignedCert() + if err != nil { + s.logger.Error("Failed to generate self-signed certificate", "error", err) - // Start server in a goroutine - go func() { - s.logger.Info("Web server starting", "port", s.port) - if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - s.logger.Error("HTTP server error", "error", err) + // Fall back to HTTP/1.1 + s.logger.Info("Falling back to HTTP/1.1 (no TLS)") + s.server.TLSConfig = nil + return s.server.ListenAndServe() } - }() - return nil + // Write certificate files + if err := os.WriteFile(certFile, cert, 0600); err != nil { + s.logger.Error("Failed to write certificate file", "error", err) + return err + } + + if err := os.WriteFile(keyFile, key, 0600); err != nil { + s.logger.Error("Failed to write key file", "error", err) + return err + } + + s.logger.Info("Self-signed certificate generated", "certFile", certFile, "keyFile", keyFile) + return s.server.ListenAndServeTLS(certFile, keyFile) + } +} + +// Helper function to check if a file exists +func fileExists(filename string) bool { + _, err := os.Stat(filename) + return err == nil +} + +// generateSelfSignedCert creates a self-signed certificate for development +func generateSelfSignedCert() ([]byte, []byte, error) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, err + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Derby Race Manager"}, + CommonName: "localhost", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + DNSNames: []string{"localhost"}, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return nil, nil, err + } + + certPEM := &bytes.Buffer{} + pem.Encode(certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + + keyPEM := &bytes.Buffer{} + pem.Encode(keyPEM, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + + return certPEM.Bytes(), keyPEM.Bytes(), nil } // Shutdown gracefully shuts down the server diff --git a/web/templates/index.html b/web/templates/index.html index a04f46c..6e05276 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -186,6 +186,19 @@ }); } }); + + // Update URLs to use https when connecting to SSE + document.addEventListener('DOMContentLoaded', function() { + // Check if we're already on HTTPS + const protocol = window.location.protocol; + if (protocol === 'https:') { + console.log('Already using HTTPS'); + } else { + console.log('Using HTTP, SSE connections may be limited'); + // Optionally redirect to HTTPS + // window.location.href = 'https://' + window.location.host + window.location.pathname; + } + }); \ No newline at end of file