This commit is contained in:
26
Dockerfile
26
Dockerfile
@@ -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
|
||||||
# System‑Deps 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 CA‑Zertifikate für HTTPS‑Calls
|
|
||||||
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
|
||||||
|
ENTRYPOINT ["/app/dashboard"]
|
||||||
# Healthcheck via Startkommando ist nicht möglich in Distroless – per Compose lösen
|
|
||||||
ENTRYPOINT ["/app/dashboard"]
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user