- Auto-generate random 32-char bearer token on first startup, stored in secrets.yaml as api.bearerToken - BearerAuthMiddleware checks Authorization: Bearer <token> on all /api/ endpoints except /health, /health/reconcile, /events (SSE), and non-API paths (frontend static files) - Development mode (WILD_CENTRAL_ENV=development) skips auth entirely - Web app ApiClient: add setToken/clearToken/hasToken methods, persist token in localStorage, automatically include Authorization header on all API requests - Token can be found in secrets.yaml for CLI/automation use
238 lines
6.6 KiB
Go
238 lines
6.6 KiB
Go
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"
|
|
"github.com/wild-cloud/wild-central/internal/secrets"
|
|
)
|
|
|
|
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)
|
|
}
|
|
|
|
// Load or generate NATS auth token from secrets
|
|
secretsMgr := secrets.NewManager(filepath.Join(dataDir, "secrets.yaml"))
|
|
natsToken, _ := secretsMgr.GetSecret("nats.authToken")
|
|
if natsToken == "" {
|
|
natsToken, _ = secrets.GenerateSecret(32)
|
|
_ = secretsMgr.SetSecret("nats.authToken", natsToken)
|
|
slog.Info("generated NATS auth token", "component", "startup")
|
|
}
|
|
|
|
natsSrv, err := natsbus.Start(natsbus.Config{
|
|
Port: natsPort,
|
|
DataDir: natsDataDir,
|
|
AuthToken: natsToken,
|
|
})
|
|
if err != nil {
|
|
slog.Error("failed to start NATS server", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Load or generate API bearer token
|
|
apiToken, _ := secretsMgr.GetSecret("api.bearerToken")
|
|
if apiToken == "" {
|
|
apiToken, _ = secrets.GenerateSecret(32)
|
|
_ = secretsMgr.SetSecret("api.bearerToken", apiToken)
|
|
slog.Info("generated API bearer token", "component", "startup")
|
|
}
|
|
|
|
isDev := os.Getenv("WILD_CENTRAL_ENV") == "development"
|
|
if isDev {
|
|
slog.Info("development mode: API authentication disabled", "component", "startup")
|
|
}
|
|
|
|
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, apiToken, isDev)
|
|
|
|
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.CheckPrerequisites()
|
|
api.EnsureCentralDomain()
|
|
api.Reconcile()
|
|
|
|
// Start periodic convergence loop — continuously drives system toward
|
|
// desired state, recovering from daemon crashes and config drift.
|
|
api.StartConvergenceLoop(ctx, 5*time.Minute)
|
|
|
|
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)
|
|
})
|
|
}
|