package main import ( "compress/gzip" "context" "crypto/subtle" "errors" "fmt" "io" "log/slog" "net/http" "os" "os/signal" "path/filepath" "strings" "sync" "syscall" "time" ) type Config struct { TargetURL string Username string Password string ListenAddr string PublicURL string EPGURLs []string EPGRefresh time.Duration PreferPlaylistEPG bool CacheDir string EPGContentType string } func (c *Config) epgEnabled() bool { return len(c.EPGURLs) > 0 || c.PreferPlaylistEPG } func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) slog.SetDefault(logger) cfg, err := loadConfig() if err != nil { slog.Error("config", "err", err) os.Exit(1) } if err := os.MkdirAll(cfg.CacheDir, 0o755); err != nil { slog.Error("cache dir", "err", err, "path", cfg.CacheDir) os.Exit(1) } refresher := NewRefresher(RefreshConfig{ TargetURL: cfg.TargetURL, PublicURL: cfg.PublicURL, EPGURLs: cfg.EPGURLs, PreferPlaylistEPG: cfg.PreferPlaylistEPG, CacheDir: cfg.CacheDir, Interval: cfg.EPGRefresh, }, &http.Client{Timeout: 5 * time.Minute}) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() refresher.Run(ctx) }() mux := http.NewServeMux() mux.HandleFunc("/healthz", healthz(refresher)) mux.HandleFunc("/playlist", basicAuth(cfg, servePlaylist(cfg, refresher))) mux.HandleFunc("/epg", serveEPG(cfg, refresher)) // unauthenticated — XMLTV clients auto-fetch url-tvg without creds mux.HandleFunc("/", basicAuth(cfg, servePlaylist(cfg, refresher))) // alias srv := &http.Server{Addr: cfg.ListenAddr, Handler: mux} go func() { <-ctx.Done() shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutCancel() _ = srv.Shutdown(shutCtx) }() slog.Info("pz8-relay listening", "addr", cfg.ListenAddr, "target", redactURL(cfg.TargetURL), "epg_enabled", cfg.epgEnabled(), "cache_dir", cfg.CacheDir, ) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("server", "err", err) os.Exit(1) } wg.Wait() } func loadConfig() (*Config, error) { cfg := &Config{ TargetURL: mustEnv("PZ8_RELAY_TARGET_URL"), Username: mustEnv("PZ8_RELAY_USERNAME"), Password: mustEnv("PZ8_RELAY_PASSWORD"), ListenAddr: envOr("PZ8_RELAY_LISTEN_ADDR", ":8080"), PublicURL: strings.TrimRight(os.Getenv("PZ8_RELAY_PUBLIC_URL"), "/"), CacheDir: envOr("PZ8_RELAY_CACHE_DIR", "/var/cache/pz8-relay"), EPGContentType: envOr("PZ8_RELAY_EPG_CONTENT_TYPE", "application/xml"), } if raw := os.Getenv("PZ8_RELAY_EPG_URLS"); raw != "" { for _, u := range strings.Split(raw, ",") { if u = strings.TrimSpace(u); u != "" { cfg.EPGURLs = append(cfg.EPGURLs, u) } } } d, err := time.ParseDuration(envOr("PZ8_RELAY_EPG_REFRESH", "12h")) if err != nil { return nil, fmt.Errorf("PZ8_RELAY_EPG_REFRESH: %w", err) } cfg.EPGRefresh = d cfg.PreferPlaylistEPG = strings.EqualFold(os.Getenv("PZ8_RELAY_PREFER_PLAYLIST_EPG"), "true") if cfg.epgEnabled() && cfg.PublicURL == "" { return nil, errors.New("PZ8_RELAY_PUBLIC_URL is required when EPG is enabled") } return cfg, nil } // --- HTTP handlers --- func basicAuth(cfg *Config, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, p, ok := r.BasicAuth() if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(cfg.Username)) != 1 || subtle.ConstantTimeCompare([]byte(p), []byte(cfg.Password)) != 1 { slog.Warn("unauthorized request", "client", clientIP(r), "path", r.URL.Path) w.Header().Set("WWW-Authenticate", `Basic realm="pz8-relay"`) http.Error(w, "unauthorized", http.StatusUnauthorized) return } next(w, r) } } func servePlaylist(cfg *Config, r *Refresher) http.HandlerFunc { path := filepath.Join(cfg.CacheDir, playlistFilename) return func(w http.ResponseWriter, req *http.Request) { if !r.Ready() { retryAfter(w, http.StatusServiceUnavailable, "warming up") return } f, err := os.Open(path) if err != nil { retryAfter(w, http.StatusServiceUnavailable, "playlist not yet available") return } defer f.Close() fi, err := f.Stat() if err != nil { http.Error(w, "stat", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") http.ServeContent(w, req, playlistFilename, fi.ModTime(), f) } } func serveEPG(cfg *Config, r *Refresher) http.HandlerFunc { path := filepath.Join(cfg.CacheDir, epgFilename) return func(w http.ResponseWriter, req *http.Request) { if !r.Ready() { retryAfter(w, http.StatusServiceUnavailable, "warming up") return } f, err := os.Open(path) if err != nil { retryAfter(w, http.StatusServiceUnavailable, "epg not yet available") return } defer f.Close() fi, err := f.Stat() if err != nil { http.Error(w, "stat", http.StatusInternalServerError) return } if clientAcceptsGzip(req) { w.Header().Set("Content-Encoding", "gzip") w.Header().Set("Content-Type", cfg.EPGContentType) http.ServeContent(w, req, epgFilename, fi.ModTime(), f) return } // Decompress on the fly. ServeContent can't help here because we // wrap the file in gzip.Reader, so handle If-Modified-Since manually. mod := fi.ModTime() if notModifiedSince(req, mod) { w.WriteHeader(http.StatusNotModified) return } gr, err := gzip.NewReader(f) if err != nil { slog.Error("epg cache corrupted", "err", err) http.Error(w, "epg unavailable", http.StatusInternalServerError) return } defer gr.Close() w.Header().Set("Content-Type", cfg.EPGContentType) w.Header().Set("Last-Modified", mod.UTC().Format(http.TimeFormat)) _, _ = io.Copy(w, gr) } } func healthz(r *Refresher) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { if !r.Ready() { http.Error(w, "warming up", http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) _, _ = io.WriteString(w, "ok\n") } } // --- helpers --- func clientAcceptsGzip(r *http.Request) bool { for _, v := range strings.Split(r.Header.Get("Accept-Encoding"), ",") { // crude but sufficient — no q-value parsing needed for our use if strings.EqualFold(strings.TrimSpace(strings.SplitN(v, ";", 2)[0]), "gzip") { return true } } return false } func notModifiedSince(r *http.Request, mod time.Time) bool { h := r.Header.Get("If-Modified-Since") if h == "" { return false } t, err := http.ParseTime(h) if err != nil { return false } return !mod.Truncate(time.Second).After(t) } func retryAfter(w http.ResponseWriter, code int, msg string) { w.Header().Set("Retry-After", "30") http.Error(w, msg, code) } // clientIP returns the original client address, trusting X-Forwarded-For // because this service runs behind Traefik. Do not expose directly. func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if i := strings.IndexByte(xff, ','); i >= 0 { return strings.TrimSpace(xff[:i]) } return strings.TrimSpace(xff) } return r.RemoteAddr } func redactURL(u string) string { // Cheap redaction: drop anything between "//" and "@" (userinfo). i := strings.Index(u, "://") if i < 0 { return u } rest := u[i+3:] if at := strings.Index(rest, "@"); at >= 0 { return u[:i+3] + "***@" + rest[at+1:] } return u } func mustEnv(key string) string { v := os.Getenv(key) if v == "" { slog.Error("missing required env var", "name", key) os.Exit(1) } return v } func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback }