Debug ohne Embed
All checks were successful
release-tag / release-image (push) Successful in 1m56s

This commit is contained in:
2025-09-21 17:55:34 +02:00
parent d24eeb1c58
commit 4f8a2fd178
13 changed files with 47 additions and 47 deletions

View File

@@ -1,34 +1,22 @@
# ---------- Build Stage ---------- # --- Build ---
FROM golang:1.24-alpine AS build FROM golang:1.24-alpine AS build
WORKDIR /src WORKDIR /src
# SystemDeps nur für Build
RUN apk add --no-cache git ca-certificates tzdata && update-ca-certificates RUN apk add --no-cache git ca-certificates tzdata && update-ca-certificates
# Module separat cachen
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Quellcode
COPY . . COPY . .
# statisch bauen (kein CGO), mit kleinen Binaries
ENV CGO_ENABLED=0 ENV CGO_ENABLED=0
RUN --mount=type=cache,target=/root/.cache/go-build \ RUN --mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -ldflags="-s -w" -o /out/dashboard ./cmd/dashboard go build -trimpath -ldflags="-s -w" -o /out/dashboard ./cmd/dashboard
# ---------- Runtime Stage ---------- # --- Runtime ---
# Distroless ist sehr klein/sicher; enthält CAZertifikate für HTTPSCalls
FROM gcr.io/distroless/base-debian12:nonroot FROM gcr.io/distroless/base-debian12:nonroot
WORKDIR /app WORKDIR /app
# Expose Port
EXPOSE 8080 EXPOSE 8080
# Binärdatei
# Copy Binary + benötigte Zeitzonen/Certs sind in Distroless bereits enthalten
COPY --from=build /out/dashboard /app/dashboard COPY --from=build /out/dashboard /app/dashboard
# Statische Dateien:
# Security: läuft als nonroot User (Distroless nonroot UID 65532) COPY web /app/web
ENV WEB_ROOT=/app/web
USER nonroot:nonroot USER nonroot:nonroot
# Healthcheck via Startkommando ist nicht möglich in Distroless per Compose lösen
ENTRYPOINT ["/app/dashboard"] ENTRYPOINT ["/app/dashboard"]

View File

@@ -10,6 +10,7 @@ import (
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -18,7 +19,6 @@ import (
"github.com/goccy/go-json" "github.com/goccy/go-json"
"git.send.nrw/sendnrw/nginx-stream-server/internal/mtx" "git.send.nrw/sendnrw/nginx-stream-server/internal/mtx"
"git.send.nrw/sendnrw/nginx-stream-server/internal/ui"
) )
var ( var (
@@ -28,6 +28,11 @@ var (
streamsCSV = os.Getenv("STREAMS") streamsCSV = os.Getenv("STREAMS")
basicUser = os.Getenv("BASIC_AUTH_USER") basicUser = os.Getenv("BASIC_AUTH_USER")
basicPass = os.Getenv("BASIC_AUTH_PASS") basicPass = os.Getenv("BASIC_AUTH_PASS")
// Root für Webassets (per ENV überschreibbar)
webRoot = env("WEB_ROOT", "web")
// Templates werden beim Start geladen
tpls *template.Template
) )
func init() { func init() {
@@ -45,10 +50,21 @@ func env(k, def string) string {
return def return def
} }
func mustLoadTemplates() *template.Template {
pattern := filepath.Join(webRoot, "templates", "*.html")
t, err := template.ParseGlob(pattern)
if err != nil {
log.Fatalf("templates laden fehlgeschlagen (%s): %v", pattern, err)
}
return t
}
func main() { func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
tpls = mustLoadTemplates()
r := chi.NewRouter() r := chi.NewRouter()
// Strenge CSP (alles lokal)
r.Use(func(next http.Handler) http.Handler { r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Security-Policy", w.Header().Set("Content-Security-Policy",
@@ -59,7 +75,6 @@ func main() {
"font-src 'self'", "font-src 'self'",
"script-src 'self'", "script-src 'self'",
"connect-src 'self'", "connect-src 'self'",
// optional: "media-src 'self'",
}, "; "), }, "; "),
) )
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
@@ -69,11 +84,13 @@ func main() {
}) })
}) })
// API
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(httprate.LimitByIP(30, time.Minute)) r.Use(httprate.LimitByIP(30, time.Minute))
r.Get("/api/streams", apiStreams) r.Get("/api/streams", apiStreams)
}) })
// Optional Basic Auth für Seiten
if basicUser != "" { if basicUser != "" {
creds := basicUser + ":" + basicPass creds := basicUser + ":" + basicPass
r.Group(func(p chi.Router) { r.Group(func(p chi.Router) {
@@ -96,18 +113,20 @@ func main() {
r.Get("/{name}", pageStream) r.Get("/{name}", pageStream)
} }
// /static → echtes Dateisystem
staticDir := http.Dir(filepath.Join(webRoot, "static"))
r.Handle("/static/*",
http.StripPrefix("/static",
http.FileServer(staticDir),
),
)
// HLS-Reverse-Proxy
up, _ := url.Parse(mtxHLS) up, _ := url.Parse(mtxHLS)
proxy := httputil.NewSingleHostReverseProxy(up) proxy := httputil.NewSingleHostReverseProxy(up)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) { proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
http.Error(w, "upstream error", http.StatusBadGateway) http.Error(w, "upstream error", http.StatusBadGateway)
} }
r.Handle("/static/*",
http.StripPrefix("/static",
http.FileServer(http.FS(ui.StaticFS)),
),
)
r.Handle("/hls/*", http.StripPrefix("/hls", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Handle("/hls/*", http.StripPrefix("/hls", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "..") { if strings.Contains(r.URL.Path, "..") {
http.NotFound(w, r) http.NotFound(w, r)
@@ -116,9 +135,10 @@ func main() {
proxy.ServeHTTP(w, r) proxy.ServeHTTP(w, r)
}))) })))
// Health
r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })
log.Printf("Dashboard listening on %s (API=%s HLS=%s)\n", listen, mtxAPI, mtxHLS) log.Printf("Dashboard listening on %s (API=%s HLS=%s, WEB_ROOT=%s)\n", listen, mtxAPI, mtxHLS, webRoot)
if err := http.ListenAndServe(listen, r); err != nil { if err := http.ListenAndServe(listen, r); err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -158,22 +178,24 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
} }
func pageIndex(w http.ResponseWriter, r *http.Request) { func pageIndex(w http.ResponseWriter, r *http.Request) {
b, _ := ui.FS.ReadFile("index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(b) if err := tpls.ExecuteTemplate(w, "index.html", nil); err != nil {
http.Error(w, err.Error(), 500)
}
} }
func pageStream(w http.ResponseWriter, r *http.Request) { func pageStream(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name") name := chi.URLParam(r, "name")
tpl, err := template.ParseFS(ui.FS, "stream.html")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
tpl.Execute(w, map[string]any{"Name": name, "JSONName": fmt.Sprintf("%q", name)}) if err := tpls.ExecuteTemplate(w, "stream.html", map[string]any{
"Name": name,
"JSONName": fmt.Sprintf("%q", name),
}); err != nil {
http.Error(w, err.Error(), 500)
}
} }
// Basic helper bleibt, falls du Basic Auth nutzt
func basic(creds string) string { func basic(creds string) string {
const tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" const tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
b := []byte(creds) b := []byte(creds)

View File

@@ -1,10 +0,0 @@
package ui
import "embed"
//go:embed *.html
var FS embed.FS
// Embed nur echte Dateien; keine leeren Ordner!
//go:embed static/**/*.js static/**/*.css static/**/*.woff2
var StaticFS embed.FS