package main import ( "context" "fmt" "log/slog" "net/http" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "github.com/gorilla/mux" v1 "github.com/wild-cloud/wild-central/internal/api/v1" "github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/frontend" "github.com/wild-cloud/wild-central/internal/logging" "github.com/wild-cloud/wild-central/internal/natsbus" ) var startTime time.Time // Version is set at build time via -ldflags "-X main.Version=x.y.z". var Version = "dev" func splitAndTrim(s string, sep string) []string { parts := strings.Split(s, sep) result := make([]string, 0, len(parts)) for _, part := range parts { if trimmed := strings.TrimSpace(part); trimmed != "" { result = append(result, trimmed) } } return result } func buildAllowedOrigins(dataDir string) []string { if corsOrigins := os.Getenv("WILD_CENTRAL_CORS_ORIGINS"); corsOrigins != "" { origins := splitAndTrim(corsOrigins, ",") slog.Info("CORS configured with explicit origins", "origins", origins) return origins } allowedOrigins := []string{ "http://localhost", "http://localhost:80", "http://127.0.0.1", "http://127.0.0.1:80", } if hostname, err := os.Hostname(); err == nil && hostname != "" { for _, port := range []string{"", ":80", ":5173", ":5174"} { for _, suffix := range []string{"", ".local", ".lan"} { allowedOrigins = append(allowedOrigins, fmt.Sprintf("http://%s%s%s", hostname, suffix, port), ) } } } allowedOrigins = append(allowedOrigins, "http://localhost:5173", "http://localhost:5174", "http://localhost:3000", "http://127.0.0.1:5173", "http://127.0.0.1:5174", "http://127.0.0.1:3000", ) // Add the configured Central domain (e.g. https://central.payne.io) if cfg, err := config.LoadState(filepath.Join(dataDir, "state.yaml")); err == nil && cfg.Cloud.Central.Domain != "" { allowedOrigins = append(allowedOrigins, "https://"+cfg.Cloud.Central.Domain) } return allowedOrigins } func main() { slog.SetDefault(slog.New(logging.NewConsoleHandler(os.Stderr, &slog.HandlerOptions{ Level: slog.LevelInfo, }))) startTime = time.Now() dataDir := os.Getenv("WILD_CENTRAL_DATA_DIR") if dataDir == "" { dataDir = "/var/lib/wild-central" } slog.Info("configured directories", "dataDir", dataDir) // Start embedded NATS JetStream server natsDataDir := filepath.Join(dataDir, "nats") natsPort := 4222 if v := os.Getenv("WILD_CENTRAL_NATS_PORT"); v != "" { fmt.Sscanf(v, "%d", &natsPort) } natsSrv, err := natsbus.Start(natsbus.Config{ Port: natsPort, DataDir: natsDataDir, }) if err != nil { slog.Error("failed to start NATS server", "error", err) os.Exit(1) } allowedOrigins := buildAllowedOrigins(dataDir) api, err := v1.NewAPI(dataDir, Version, allowedOrigins) if err != nil { slog.Error("failed to initialize API", "error", err) natsSrv.Shutdown() os.Exit(1) } ctx, cancel := context.WithCancel(context.Background()) api.StartCentralStatusBroadcaster(startTime) slog.Info("central status broadcaster started") api.StartDDNS(ctx) api.StartDNSFilter(ctx) router := mux.NewRouter() api.RegisterRoutes(router) router.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"status":"ok"}`) }).Methods("GET") router.HandleFunc("/api/v1/status", func(w http.ResponseWriter, r *http.Request) { api.StatusHandler(w, r, startTime, dataDir) }).Methods("GET") // Frontend: serve web UI staticDir := os.Getenv("WILD_CENTRAL_STATIC_DIR") if staticDir == "" { staticDir = "/var/www/html/wild-central" } viteURL := os.Getenv("WILD_CENTRAL_VITE_URL") if viteURL == "" { viteURL = "http://localhost:5173" } router.PathPrefix("/").Handler(frontend.Handler(staticDir, viteURL)) handler := corsHandler(router, allowedOrigins) host := "0.0.0.0" port := 5055 if v := os.Getenv("WILD_CENTRAL_PORT"); v != "" { fmt.Sscanf(v, "%d", &port) } // Tell the API what port it's running on, register Central as a domain // (if a domain is configured), and reconcile all networking. api.SetPort(port) api.EnsureCentralDomain() api.Reconcile() addr := fmt.Sprintf("%s:%d", host, port) slog.Info("wild-central started", "addr", addr, "version", Version) sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) go func() { if err := http.ListenAndServe(addr, handler); err != nil { slog.Error("server failed to start", "error", err) os.Exit(1) } }() sig := <-sigChan slog.Info("shutdown signal received", "signal", sig) cancel() natsSrv.Shutdown() slog.Info("wild-central stopped") } func corsHandler(handler http.Handler, allowedOrigins []string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") for _, allowed := range allowedOrigins { if origin == allowed { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, X-CSRF-Token") w.Header().Set("Access-Control-Expose-Headers", "Link") w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Max-Age", "300") break } } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } handler.ServeHTTP(w, r) }) }