From 42e484c47cfb3caaae3de8e895db09bbf5e11a5d Mon Sep 17 00:00:00 2001 From: jbergner Date: Sun, 21 Sep 2025 14:58:13 +0200 Subject: [PATCH] staging --- .gitea/workflows/registry.yml | 51 ++++++++++ Dockerfile | 34 +++++++ README.md | 1 - cmd/dashboard/main.go | 174 ++++++++++++++++++++++++++++++++++ compose.yml | 45 +++++++++ go.mod | 15 +++ go.sum | 12 +++ internal/mtx/mtx.go | 81 ++++++++++++++++ internal/ui/index.html | 63 ++++++++++++ internal/ui/stream.html | 56 +++++++++++ internal/ui/templates.go | 6 ++ mediamtx.yml | 29 ++++++ 12 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/registry.yml create mode 100644 Dockerfile create mode 100644 cmd/dashboard/main.go create mode 100644 compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/mtx/mtx.go create mode 100644 internal/ui/index.html create mode 100644 internal/ui/stream.html create mode 100644 internal/ui/templates.go create mode 100644 mediamtx.yml diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..20912ac --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-fast + env: + DOCKER_ORG: ${{ vars.DOCKER_ORG }} + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."${{ vars.DOCKER_REGISTRY }}"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..89ec0d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# ---------- Build Stage ---------- +FROM golang:1.22-alpine AS build +WORKDIR /src +# System‑Deps nur für Build +RUN apk add --no-cache git ca-certificates tzdata && update-ca-certificates + +# Module separat cachen +COPY go.mod go.sum ./ +RUN go mod download + +# Quellcode +COPY . . + +# statisch bauen (kein CGO), mit kleinen Binaries +ENV CGO_ENABLED=0 +RUN --mount=type=cache,target=/root/.cache/go-build \ + go build -trimpath -ldflags="-s -w" -o /out/dashboard ./cmd/dashboard + +# ---------- Runtime Stage ---------- +# Distroless ist sehr klein/sicher; enthält CA‑Zertifikate für HTTPS‑Calls +FROM gcr.io/distroless/base-debian12:nonroot +WORKDIR /app + +# Expose Port +EXPOSE 8080 + +# Copy Binary + benötigte Zeitzonen/Certs sind in Distroless bereits enthalten +COPY --from=build /out/dashboard /app/dashboard + +# Security: läuft als nonroot User (Distroless nonroot UID 65532) +USER nonroot:nonroot + +# Healthcheck via Startkommando ist nicht möglich in Distroless – per Compose lösen +ENTRYPOINT ["/app/dashboard"] \ No newline at end of file diff --git a/README.md b/README.md index e5a4556..575fea0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1 @@ # nginx-stream-server - diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go new file mode 100644 index 0000000..1dd8559 --- /dev/null +++ b/cmd/dashboard/main.go @@ -0,0 +1,174 @@ +package main + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/httprate" + "github.com/goccy/go-json" + + "git.send.nrw/sendnrw/nginx-stream-server/internal/mtx" + "git.send.nrw/sendnrw/nginx-stream-server/internal/ui" +) + +var ( + listen = env("LISTEN", ":8080") + mtxAPI = env("MTX_API", "http://127.0.0.1:9997") + mtxHLS = env("MTX_HLS", "http://127.0.0.1:8888") + streamsCSV = os.Getenv("STREAMS") + basicUser = os.Getenv("BASIC_AUTH_USER") + basicPass = os.Getenv("BASIC_AUTH_PASS") +) + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + + r := chi.NewRouter() + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "SAMEORIGIN") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; font-src https://fonts.gstatic.com; script-src 'self' https://cdn.jsdelivr.net") + next.ServeHTTP(w, req) + }) + }) + + r.Group(func(r chi.Router) { + r.Use(httprate.LimitByIP(30, time.Minute)) + r.Get("/api/streams", apiStreams) + }) + + if basicUser != "" { + creds := basicUser + ":" + basicPass + r.Group(func(p chi.Router) { + p.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + a := strings.SplitN(r.Header.Get("Authorization"), " ", 2) + if len(a) == 2 && a[0] == "Basic" && a[1] == basic(creds) { + next.ServeHTTP(w, r) + return + } + w.Header().Set("WWW-Authenticate", "Basic realm=Restricted") + http.Error(w, "auth required", http.StatusUnauthorized) + }) + }) + p.Get("/", pageIndex) + p.Get("/{name}", pageStream) + }) + } else { + r.Get("/", pageIndex) + r.Get("/{name}", pageStream) + } + + up, _ := url.Parse(mtxHLS) + proxy := httputil.NewSingleHostReverseProxy(up) + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) { + http.Error(w, "upstream error", http.StatusBadGateway) + } + r.Handle("/hls/*", http.StripPrefix("/hls", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "..") { + http.NotFound(w, r) + return + } + proxy.ServeHTTP(w, r) + }))) + + 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) + if err := http.ListenAndServe(listen, r); err != nil { + log.Fatal(err) + } +} + +func apiStreams(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + c := &mtx.Client{BaseURL: mtxAPI, User: os.Getenv("MTX_API_USER"), Pass: os.Getenv("MTX_API_PASS")} + pl, err := c.Paths(ctx) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + allowed := map[string]bool{} + if streamsCSV != "" { + for _, s := range strings.Split(streamsCSV, ",") { + allowed[strings.TrimSpace(s)] = true + } + } + type item struct { + Name string `json:"name"` + Live bool `json:"live"` + Viewers int `json:"viewers"` + } + out := struct { + Items []item `json:"items"` + }{} + for _, p := range pl.Items { + if len(allowed) > 0 && !allowed[p.Name] { + continue + } + out.Items = append(out.Items, item{Name: p.Name, Live: p.Live(), Viewers: p.Viewers()}) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(out) +} + +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.Write(b) +} + +func pageStream(w http.ResponseWriter, r *http.Request) { + 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") + tpl.Execute(w, map[string]any{"Name": name, "JSONName": fmt.Sprintf("%q", name)}) +} + +func basic(creds string) string { + const tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + b := []byte(creds) + var out []byte + for i := 0; i < len(b); i += 3 { + var v uint32 + n := 0 + for j := 0; j < 3; j++ { + v <<= 8 + if i+j < len(b) { + v |= uint32(b[i+j]) + n++ + } + } + for j := 0; j < 4; j++ { + if j <= n { + out = append(out, tbl[(v>>(18-6*uint(j)))&0x3f]) + } else { + out = append(out, '=') + } + } + } + return string(out) +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..c154260 --- /dev/null +++ b/compose.yml @@ -0,0 +1,45 @@ +version: "3.9" + +services: + mediamtx: + image: bluenviron/mediamtx:1.9.2 + container_name: mediamtx + restart: unless-stopped + ports: + - "1935:1935" # RTMP ingest + - "8888:8888" # HLS/HTTP (optional extern, Dashboard proxyt ohnehin) + volumes: + - ./mediamtx.yml:/mediamtx.yml:ro + command: ["/mediamtx", "/mediamtx.yml"] + + dashboard: + build: + context: . + dockerfile: Dockerfile + container_name: go-stream-dashboard + restart: unless-stopped + environment: + # Dashboard + - LISTEN=:8080 + - BASIC_AUTH_USER=${BASIC_AUTH_USER:-} + - BASIC_AUTH_PASS=${BASIC_AUTH_PASS:-} + - STREAMS=${STREAMS:-} + # MediaMTX Endpunkte (im Compose‑Netzwerk erreichbar) + - MTX_API=http://mediamtx:9997 + - MTX_HLS=http://mediamtx:8888 + - MTX_API_USER=${MTX_API_USER:-admin} + - MTX_API_PASS=${MTX_API_PASS:-starkes-passwort} + ports: + - "8080:8080" + depends_on: + - mediamtx + healthcheck: + test: ["CMD", "/app/dashboard", "-healthcheck"] # optionaler Schalter, siehe unten + interval: 15s + timeout: 3s + retries: 5 + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5814038 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module git.send.nrw/sendnrw/nginx-stream-server + +go 1.24.4 + +require ( + github.com/go-chi/chi/v5 v5.2.3 + github.com/go-chi/httprate v0.15.0 + github.com/goccy/go-json v0.10.5 +) + +require ( + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d53d7ba --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= +github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/mtx/mtx.go b/internal/mtx/mtx.go new file mode 100644 index 0000000..c4ff912 --- /dev/null +++ b/internal/mtx/mtx.go @@ -0,0 +1,81 @@ +package mtx + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// Minimaler Client für MediaMTX Control API v3 +// Standard-Ports: API :9997, HLS :8888, RTMP :1935 + +type Client struct { + BaseURL string // z.B. http://127.0.0.1:9997 + HTTP *http.Client + User string + Pass string +} + +type PathsList struct { + Items []PathItem `json:"items"` +} + +type PathItem struct { + Name string `json:"name"` + // publisher kann nil sein, wenn keiner sendet + Publisher *SessionRef `json:"publisher"` + // readSessions sind aktive Zuschauer + ReadSessions []SessionRef `json:"readSessions"` + // BytesIn/Out können je nach Version fehlen – deshalb optional halten + BytesReceived *int64 `json:"bytesReceived,omitempty"` + BytesSent *int64 `json:"bytesSent,omitempty"` +} + +type SessionRef struct { + ID string `json:"id"` + // Bitrate und weitere Felder variieren je nach Version, wir zeigen defensiv nur das Nötigste an +} + +func (c *Client) do(ctx context.Context, method, path string, out any) error { + if c.HTTP == nil { + c.HTTP = &http.Client{Timeout: 5 * time.Second} + } + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, nil) + if err != nil { + return err + } + if c.User != "" || c.Pass != "" { + req.SetBasicAuth(c.User, c.Pass) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("mtx api %s: %s", resp.Status, string(b)) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) Paths(ctx context.Context) (*PathsList, error) { + var out PathsList + err := c.do(ctx, http.MethodGet, "/v3/paths/list", &out) + if err != nil { + return nil, err + } + return &out, nil +} + +func (p PathItem) Live() bool { return p.Publisher != nil } +func (p PathItem) Viewers() int { return len(p.ReadSessions) } + +// HLS-URL Form: http://:8888/ (Playlist wird automatisch geliefert) +func HLSURL(publicHost, path string) string { + return fmt.Sprintf("%s/%s", publicHost, path) +} diff --git a/internal/ui/index.html b/internal/ui/index.html new file mode 100644 index 0000000..7035c83 --- /dev/null +++ b/internal/ui/index.html @@ -0,0 +1,63 @@ + + + + + +Streams + + + + + + +
+
+

🎬 Streams

+
+ +Neu laden +
+
+

RTMP Ingest: rtmp://HOST/<name> · HLS: http(s)://HOST/hls/<name>

+
+
+ + + \ No newline at end of file diff --git a/internal/ui/stream.html b/internal/ui/stream.html new file mode 100644 index 0000000..333b225 --- /dev/null +++ b/internal/ui/stream.html @@ -0,0 +1,56 @@ + + + + + +{{.Name}} + + + + +
+← Zurück +

{{.Name}} Offline

+
+ +
+ +Zuschauer: 0 +
+
+
+ + + + \ No newline at end of file diff --git a/internal/ui/templates.go b/internal/ui/templates.go new file mode 100644 index 0000000..b9ac8f1 --- /dev/null +++ b/internal/ui/templates.go @@ -0,0 +1,6 @@ +package ui + +import "embed" + +//go:embed *.html +var FS embed.FS diff --git a/mediamtx.yml b/mediamtx.yml new file mode 100644 index 0000000..9ee0da6 --- /dev/null +++ b/mediamtx.yml @@ -0,0 +1,29 @@ +# lauscht auf den Standard-Ports +rtmp: yes +hls: yes +# optional: enable ll-hls: yes +httpAddress: :8888 +rtmpAddress: :1935 + + +# Control API (für Dashboard‑Status) +api: yes +apiAddress: :9997 +# Auth für API empfehlenswert +apiUser: admin +apiPass: starkes-passwort + + +# Metriken (optional) +metrics: yes +metricsAddress: :9998 + + +# Allgemeine Pfadeinstellungen (alle Streams) +paths: + stream1: + publishUser: ingest1 + publishPass: supersecret1 + stream2: + publishUser: ingest2 + publishPass: supersecret2 \ No newline at end of file