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) }