Files
pz8-relay/main.go
T

299 lines
7.6 KiB
Go
Raw Normal View History

2026-05-06 00:11:40 +02:00
package main
import (
2026-05-06 11:28:19 +02:00
"compress/gzip"
"context"
2026-05-06 00:11:40 +02:00
"crypto/subtle"
2026-05-06 11:28:19 +02:00
"errors"
"fmt"
"io"
2026-05-06 01:45:38 +02:00
"log/slog"
2026-05-06 00:11:40 +02:00
"net/http"
"os"
2026-05-06 11:28:19 +02:00
"os/signal"
"path/filepath"
"strings"
2026-05-06 11:28:19 +02:00
"sync"
"syscall"
"time"
2026-05-06 00:11:40 +02:00
)
2026-05-06 11:28:19 +02:00
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
}
2026-05-06 00:11:40 +02:00
func main() {
2026-05-06 01:48:00 +02:00
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
2026-05-06 01:45:38 +02:00
slog.SetDefault(logger)
2026-05-06 11:28:19 +02:00
cfg, err := loadConfig()
if err != nil {
slog.Error("config", "err", err)
os.Exit(1)
}
2026-05-06 00:11:40 +02:00
2026-05-06 11:28:19 +02:00
if err := os.MkdirAll(cfg.CacheDir, 0o755); err != nil {
slog.Error("cache dir", "err", err, "path", cfg.CacheDir)
os.Exit(1)
2026-05-06 00:11:40 +02:00
}
2026-05-06 11:28:19 +02:00
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", basicAuth(cfg, serveEPG(cfg, refresher)))
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)
2026-05-06 01:45:38 +02:00
os.Exit(1)
2026-05-06 00:11:40 +02:00
}
2026-05-06 11:28:19 +02:00
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")
2026-05-06 00:11:40 +02:00
2026-05-06 11:28:19 +02:00
if cfg.epgEnabled() && cfg.PublicURL == "" {
return nil, errors.New("PZ8_RELAY_PUBLIC_URL is required when EPG is enabled")
2026-05-06 00:11:40 +02:00
}
2026-05-06 11:28:19 +02:00
return cfg, nil
}
// --- HTTP handlers ---
2026-05-06 00:11:40 +02:00
2026-05-06 11:28:19 +02:00
func basicAuth(cfg *Config, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
2026-05-06 00:11:40 +02:00
u, p, ok := r.BasicAuth()
2026-05-06 11:28:19 +02:00
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)
2026-05-06 00:11:40 +02:00
w.Header().Set("WWW-Authenticate", `Basic realm="pz8-relay"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
2026-05-06 11:28:19 +02:00
next(w, r)
}
}
2026-05-06 01:50:56 +02:00
2026-05-06 11:28:19 +02:00
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)
}
}
2026-05-06 00:11:40 +02:00
2026-05-06 11:28:19 +02:00
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")
2026-05-06 01:45:38 +02:00
}
2026-05-06 00:11:40 +02:00
}
2026-05-06 11:28:19 +02:00
// --- 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
}
2026-05-06 11:28:19 +02:00
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
}
2026-05-06 00:11:40 +02:00
func mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
2026-05-06 01:45:38 +02:00
slog.Error("missing required env var", "name", key)
os.Exit(1)
2026-05-06 00:11:40 +02:00
}
return v
}
2026-05-06 11:28:19 +02:00
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}