Files
wild-central/main.go
Paul Payne f5a030fd44 Add bearer token API authentication
- 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
2026-07-14 12:45:22 +00:00

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)
})
}