Release v1.0.0 #major
release / goreleaser (push) Failing after 51s

This commit is contained in:
2026-06-02 23:12:36 -05:00
parent 276981a7df
commit d5e65fbb03
17 changed files with 3447 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
// Package server wires HTTP/WS routes and the UDP listener to the rest of
// the application. It owns the Fiber app and the autocert setup.
package server
import (
"context"
"crypto/subtle"
"encoding/base64"
"log/slog"
"net"
"os"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/websocket/v2"
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/hub"
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/recording"
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/stats"
)
// AuthMiddleware returns a Fiber handler enforcing HTTP Basic Auth with the
// given password. WebSocket upgrades skip auth because browsers cannot
// reliably supply Authorization headers on WS; the page itself is gated.
func AuthMiddleware(password string) fiber.Handler {
expected := "Basic " + base64.StdEncoding.EncodeToString([]byte(":"+password))
expectedBytes := []byte(expected)
return func(c *fiber.Ctx) error {
if websocket.IsWebSocketUpgrade(c) {
return c.Next()
}
deny := func() error {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).SendString("Unauthorized")
}
auth := c.Get("Authorization")
if auth == "" {
return deny()
}
if subtle.ConstantTimeCompare([]byte(auth), expectedBytes) == 1 {
return c.Next()
}
const scheme = "Basic "
if len(auth) < len(scheme) || auth[:len(scheme)] != scheme {
return deny()
}
decoded, err := base64.StdEncoding.DecodeString(auth[len(scheme):])
if err != nil {
decoded, err = base64.RawStdEncoding.DecodeString(auth[len(scheme):])
if err != nil {
return deny()
}
}
creds := strings.SplitN(string(decoded), ":", 2)
if len(creds) != 2 {
return deny()
}
if subtle.ConstantTimeCompare([]byte(creds[1]), []byte(password)) != 1 {
return deny()
}
return c.Next()
}
}
// ListenUDP reads UDP packets and submits them to the recorder and hub.
// It exits on ctx cancellation.
func ListenUDP(ctx context.Context, addr string, h *hub.Hub, rec *recording.Recorder) {
conn, err := net.ListenPacket("udp", addr)
if err != nil {
slog.Error("udp listen failed", "err", err)
os.Exit(1)
}
slog.Info("udp listening", "addr", addr)
go func() {
<-ctx.Done()
_ = conn.Close()
}()
buf := make([]byte, 65536)
for {
n, _, err := conn.ReadFrom(buf)
if err != nil {
if ctx.Err() != nil {
return
}
slog.Warn("udp read error", "err", err)
continue
}
stats.Global.UDPPackets.Add(1)
stats.Global.UDPBytes.Add(int64(n))
stats.Global.LastUDPPacketUnix.Store(time.Now().UnixNano())
data := buf[:n]
rec.Submit(data)
if h.Count() > 0 {
cp := make([]byte, n)
copy(cp, data)
h.Broadcast(cp)
}
}
}
+60
View File
@@ -0,0 +1,60 @@
package server
import (
"crypto/tls"
"errors"
"fmt"
"log/slog"
nethttp "net/http"
"github.com/gofiber/fiber/v2"
"golang.org/x/crypto/acme/autocert"
)
// Listen starts serving the app on either HTTPS w/ autocert (if domain is set)
// or plain HTTP. It returns a channel that receives any startup error.
//
// httpAddr is used both as the plain HTTP listener and as the ACME challenge
// listener when domain is set. tlsAddr is only used when domain is set.
func Listen(app *fiber.App, httpAddr, tlsAddr, domain, certDir string) <-chan error {
errCh := make(chan error, 2)
if domain == "" {
slog.Info("no domain set; HTTP only", "addr", httpAddr)
go func() {
if err := app.Listen(httpAddr); err != nil {
errCh <- fmt.Errorf("http: %w", err)
}
}()
return errCh
}
cm := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(domain),
Cache: autocert.DirCache(certDir),
}
if httpAddr != "" {
go func() {
slog.Info("starting HTTP server (ACME + redirect)", "addr", httpAddr)
srv := &nethttp.Server{Addr: httpAddr, Handler: cm.HTTPHandler(nil)}
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, nethttp.ErrServerClosed) {
errCh <- fmt.Errorf("http: %w", err)
}
}()
}
slog.Info("starting HTTPS server with autocert", "addr", tlsAddr, "domain", domain)
ln, err := tls.Listen("tcp", tlsAddr, &tls.Config{GetCertificate: cm.GetCertificate})
if err != nil {
errCh <- fmt.Errorf("tls listen: %w", err)
return errCh
}
go func() {
if err := app.Listener(ln); err != nil {
errCh <- fmt.Errorf("https: %w", err)
}
}()
return errCh
}
+176
View File
@@ -0,0 +1,176 @@
package server
import (
"database/sql"
"strconv"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/websocket/v2"
"log/slog"
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/assets"
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/hub"
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/recording"
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/stats"
)
// Options configures the Fiber app constructed by New.
type Options struct {
Password string
DBPath string
Verbose bool
Compress bool
Retention time.Duration
DB *sql.DB
Hub *hub.Hub
Recorder *recording.Recorder
}
// New constructs the Fiber application with all middleware and routes wired.
func New(opt Options) *fiber.App {
app := fiber.New(fiber.Config{DisableStartupMessage: true})
app.Use(logger.New(logger.Config{
Format: "${time} ${method} ${path} ${status}\n",
Next: func(c *fiber.Ctx) bool {
if opt.Verbose {
return false
}
// Suppress noisy WS access logs by default.
path := c.Path()
return len(path) >= 3 && path[:3] == "/ws"
},
}))
app.Use(AuthMiddleware(opt.Password))
app.Get("/", func(c *fiber.Ctx) error {
data, err := assets.FS.ReadFile("viewer.html")
if err != nil {
return fiber.ErrInternalServerError
}
c.Set("Content-Type", "text/html; charset=utf-8")
return c.Send(data)
})
// Live WebSocket.
app.Use("/ws", upgradeOnly())
app.Get("/ws", websocket.New(func(c *websocket.Conn) {
cl := opt.Hub.Add(c)
slog.Info("client connected", "addr", c.RemoteAddr().String(), "total", opt.Hub.Count())
defer func() {
opt.Hub.Remove(cl)
slog.Info("client disconnected", "addr", c.RemoteAddr().String(), "total", opt.Hub.Count())
}()
for {
if _, _, err := c.ReadMessage(); err != nil {
return
}
}
}))
// Recording API.
app.Get("/api/recordings", func(c *fiber.Ctx) error {
since := c.Query("since", "")
limit, _ := strconv.Atoi(c.Query("limit", "0"))
recs, err := recording.List(opt.DB, since, limit)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(recs)
})
app.Delete("/api/recordings/:id", func(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid id"})
}
n, err := recording.Delete(opt.DB, id)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
if n == 0 {
return c.Status(404).JSON(fiber.Map{"error": "not found"})
}
opt.Hub.PublishEvent(map[string]any{
"type": "event", "kind": "recording_deleted",
"recording": map[string]any{"id": id},
})
return c.JSON(fiber.Map{"ok": true})
})
app.Get("/api/stats", statsHandler(opt))
// Playback websocket.
app.Use("/ws/playback", upgradeOnly())
app.Get("/ws/playback", websocket.New(func(c *websocket.Conn) {
idStr := c.Query("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
_ = c.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid id"}`))
return
}
slog.Info("playback start", "id", id, "addr", c.RemoteAddr().String())
p, err := recording.NewPlayer(opt.DB, c, id)
if err != nil {
_ = c.WriteMessage(websocket.TextMessage, []byte(`{"error":"player init failed"}`))
return
}
p.Run()
slog.Info("playback end", "id", id, "addr", c.RemoteAddr().String())
}))
return app
}
func upgradeOnly() fiber.Handler {
return func(c *fiber.Ctx) error {
if websocket.IsWebSocketUpgrade(c) {
return c.Next()
}
return fiber.ErrUpgradeRequired
}
}
func statsHandler(opt Options) fiber.Handler {
return func(c *fiber.Ctx) error {
dbSize := recording.DBSize(opt.DBPath)
lastUDP := stats.Global.LastUDPPacketUnix.Load()
var lastAgeMs int64 = -1
if lastUDP > 0 {
lastAgeMs = (time.Now().UnixNano() - lastUDP) / int64(time.Millisecond)
}
before := stats.Global.BytesBeforeCompr.Load()
after := stats.Global.BytesAfterCompr.Load()
var compressionRatio float64
if after > 0 {
compressionRatio = float64(before) / float64(after)
}
curID, curName, curFrames, curElapsed := opt.Recorder.CurrentSession()
var current any
if curID != 0 {
current = map[string]any{
"id": curID, "name": curName,
"frames": curFrames, "elapsed_ms": curElapsed,
}
}
return c.JSON(fiber.Map{
"uptime_s": int64(time.Since(stats.Global.StartTime).Seconds()),
"udp_packets_total": stats.Global.UDPPackets.Load(),
"udp_bytes_total": stats.Global.UDPBytes.Load(),
"udp_last_packet_age_ms": lastAgeMs,
"ws_clients": opt.Hub.Count(),
"frames_written_total": stats.Global.FramesWritten.Load(),
"frames_dropped_total": stats.Global.FramesDropped.Load(),
"bytes_before_compr": before,
"bytes_after_compr": after,
"compression_ratio": compressionRatio,
"db_size_bytes": dbSize,
"current_recording": current,
"retention_hours": opt.Retention.Hours(),
"compression_enabled": opt.Compress,
})
}
}