// moonrelay — self-hosted streaming manager backend. // // In Docker mode the React SPA is embedded at build time via go:embed. // Set MOONRELAY_NO_EMBED=1 to skip the SPA (API-only / dev mode). // // Environment variables: // MOONRELAY_PORT - HTTP listen port (default: 8080) // MOONRELAY_HOST - HTTP listen address (default: 0.0.0.0) // TS_AUTHKEY - Tailscale auth key (optional; prompts login if unset) // TS_HOSTNAME - Tailscale device hostname (default: moonrelay) // MOONLIGHT_PATH - Override path to moonlight binary // MOONRELAY_NO_TS - Set "1" to disable Tailscale (LAN-only) package main import ( "context" "embed" "fmt" "io/fs" "log" "net" "net/http" "os" "os/signal" "syscall" "moonrelay/internal/api" "moonrelay/internal/discovery" "moonrelay/internal/moonlight" tsnode "moonrelay/internal/tailscale" ) // ui holds the React production build, copied here by the Dockerfile // before `go build`. The path must match where the build output lands. // //go:embed ui var uiFiles embed.FS func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() port := env("MOONRELAY_PORT", "8080") host := env("MOONRELAY_HOST", "0.0.0.0") noTS := os.Getenv("MOONRELAY_NO_TS") == "1" // --- Moonlight launcher --- launcher, err := moonlight.New(os.Getenv("MOONLIGHT_PATH")) if err != nil { log.Printf("Warning: %v", err) launcher, _ = moonlight.New("") } // --- mDNS discovery --- disc := discovery.New() go disc.Run(ctx) // --- Tailscale node (optional) --- var ts *tsnode.Node if !noTS { log.Println("Starting embedded Tailscale node…") ts, err = tsnode.New(ctx, tsnode.Config{ Hostname: env("TS_HOSTNAME", "moonrelay"), AuthKey: os.Getenv("TS_AUTHKEY"), }) if err != nil { log.Printf("Warning: Tailscale failed to start (%v) — running in LAN-only mode", err) ts = nil } else { if addr := ts.LocalAddr(); addr != "" { log.Printf("Tailscale connected: %s", addr) } else { log.Println("Tailscale: waiting for auth (check logs for login URL)") } } } // --- Embedded SPA --- // Strip the "ui/" prefix so the FS root contains index.html directly. uiRoot, err := fs.Sub(uiFiles, "ui") if err != nil { log.Fatalf("Failed to access embedded UI: %v", err) } // --- HTTP server --- srv := api.New(disc, launcher, ts, uiRoot) addr := fmt.Sprintf("%s:%s", host, port) ln, err := net.Listen("tcp", addr) if err != nil { log.Fatalf("Cannot listen on %s: %v", addr, err) } log.Printf("Moonlight Relay listening on http://%s", addr) httpSrv := &http.Server{Handler: srv.Handler()} go func() { if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed { log.Printf("HTTP server error: %v", err) } }() <-ctx.Done() log.Println("Shutting down…") _ = httpSrv.Close() if ts != nil { _ = ts.Close() } } func env(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback }