@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user