This commit is contained in:
174
cmd/dashboard/main.go
Normal file
174
cmd/dashboard/main.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user