Files
pangolin/server/cmd/server/main.go
T

228 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"crypto/tls"
"encoding/json"
"flag"
"log"
"log/slog"
"net"
"net/http"
"os"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"github.com/wangjia/pangolin/server/api/gen"
"github.com/wangjia/pangolin/server/internal/admin"
"github.com/wangjia/pangolin/server/internal/db"
"github.com/wangjia/pangolin/server/internal/httpapi"
"github.com/wangjia/pangolin/server/internal/mtls"
"github.com/wangjia/pangolin/server/internal/nodes"
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
"github.com/wangjia/pangolin/server/internal/redisutil"
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
)
func main() {
addr := flag.String("addr", "", "HTTP listen address (default :8080, overridden by ADDR env)")
flag.Parse()
if *addr == "" {
if v := os.Getenv("ADDR"); v != "" {
*addr = v
} else {
*addr = ":8080"
}
}
// Admin backend (separate internal listener). Started only when explicitly
// configured so the public API can run on its own. See internal/admin.
startAdminIfConfigured()
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Health check — outside the versioned API.
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
// --- Probe ingest route (optional) ---
// Requires:
// PROBE_SECRETS JSON object mapping probeId → HMAC secret, e.g.
// '{"probe-sg-01":"s3cr3t1","probe-jp-01":"s3cr3t2"}'
// REDIS_ADDR Redis address, default 127.0.0.1:6379
// REDIS_PASSWORD Redis password (optional)
//
// If PROBE_SECRETS is empty the route is not registered and the server
// starts normally without the probe ingest endpoint.
probeSecretsJSON := os.Getenv("PROBE_SECRETS")
if probeSecretsJSON != "" {
redisAddr := getenvDefault("REDIS_ADDR", "127.0.0.1:6379")
rdb, err := redisutil.New(redisAddr, os.Getenv("REDIS_PASSWORD"), 0)
if err != nil {
log.Printf("probe: redis connect failed (%v) probe route disabled", err)
} else {
var secretMap map[string]string
if err := json.Unmarshal([]byte(probeSecretsJSON), &secretMap); err != nil {
log.Fatalf("probe: invalid PROBE_SECRETS JSON: %v", err)
}
reg := probe.NewMapRegistry(secretMap)
st := probe.NewStore(rdb)
h := probe.NewIngestHandler(reg, st)
r.Post("/probe/report", h.ServeHTTP)
log.Printf("probe ingest route registered (%d probe(s))", len(secretMap))
}
}
// Mount all /v1/... routes. HandlerFromMuxWithBaseURL registers every route
// from the OpenAPI spec onto the provided chi router with the given prefix,
// so the router itself is the http.Handler we serve.
gen.HandlerFromMuxWithBaseURL(&httpapi.UnimplementedServer{}, r, "/v1")
// ─── gRPC agent server (optional) ────────────────────────────────────────
// All five env vars must be set to activate the gRPC listener.
grpcAddr := os.Getenv("GRPC_ADDR")
caKeyPath := os.Getenv("CA_KEY_PATH")
caCertPath := os.Getenv("CA_CERT_PATH")
grpcCertPath := os.Getenv("GRPC_CERT_PATH")
grpcKeyPath := os.Getenv("GRPC_KEY_PATH")
dbDSN := os.Getenv("DB_DSN")
if grpcAddr != "" && caKeyPath != "" && caCertPath != "" &&
grpcCertPath != "" && grpcKeyPath != "" && dbDSN != "" {
redisAddr := getenvDefault("REDIS_ADDR", "127.0.0.1:6379")
go startGRPC(grpcAddr, caKeyPath, caCertPath, grpcCertPath, grpcKeyPath,
redisAddr, os.Getenv("REDIS_PASSWORD"), dbDSN)
} else if grpcAddr != "" {
slog.Warn("grpc server disabled: one or more required env vars are missing",
"GRPC_ADDR", grpcAddr,
"CA_KEY_PATH_set", caKeyPath != "",
"CA_CERT_PATH_set", caCertPath != "",
"GRPC_CERT_PATH_set", grpcCertPath != "",
"GRPC_KEY_PATH_set", grpcKeyPath != "",
"DB_DSN_set", dbDSN != "")
}
// ─── Start HTTP server (blocking) ────────────────────────────────────────
log.Printf("pangolin server listening on %s", *addr)
if err := http.ListenAndServe(*addr, r); err != nil {
log.Fatalf("server error: %v", err)
}
}
// startAdminIfConfigured launches the admin listener in a background goroutine
// when ADMIN_SECRET_KEY and DB_DSN are present. The admin port binds an
// internal address only (enforced by admin.FromEnv) and is fronted by an IP
// allowlist + two-factor login.
func startAdminIfConfigured() {
if os.Getenv("ADMIN_SECRET_KEY") == "" || os.Getenv("DB_DSN") == "" {
log.Printf("admin backend disabled (set DB_DSN and ADMIN_SECRET_KEY to enable)")
return
}
cfg, err := admin.FromEnv()
if err != nil {
log.Fatalf("admin config: %v", err)
}
database, err := db.Open(os.Getenv("DB_DSN"))
if err != nil {
log.Fatalf("admin db: %v", err)
}
rdb, err := redisutil.New(
getenvDefault("REDIS_ADDR", "127.0.0.1:6379"),
os.Getenv("REDIS_PASSWORD"), 0)
if err != nil {
log.Fatalf("admin redis: %v", err)
}
svc := admin.BuildServices(database, rdb, 5, cfg.LoginLockDuration)
handler, err := admin.NewHandler(cfg, database, rdb, svc, log.Default())
if err != nil {
log.Fatalf("admin handler: %v", err)
}
go func() {
log.Printf("admin backend listening on %s (internal only)", cfg.Listen)
if err := http.ListenAndServe(cfg.Listen, handler); err != nil {
log.Fatalf("admin server error: %v", err)
}
}()
}
// startGRPC initialises and starts the mTLS gRPC agent server.
// Must be called in a goroutine. Calls log.Fatalf on unrecoverable errors.
func startGRPC(
addr, caKeyPath, caCertPath, grpcCertPath, grpcKeyPath,
redisAddr, redisPassword, dbDSN string,
) {
// Pangolin Node CA (signs + verifies node client certs).
ca, err := mtls.NewCA(mtls.CAConfig{
KeyPath: caKeyPath,
CertPath: caCertPath,
})
if err != nil {
log.Fatalf("grpc: load CA: %v", err)
}
// Redis (hub command queue, load cache, CRL, bootstrap tokens).
rdb, err := redisutil.New(redisAddr, redisPassword, 0)
if err != nil {
log.Fatalf("grpc: redis connect: %v", err)
}
// CRL + one-time bootstrap token manager.
crl := mtls.NewCRL(rdb, nil)
tokens := mtls.NewBootstrapTokenManager(rdb)
// MySQL for node catalogue and usage accumulation.
sqlDB, err := db.Open(dbDSN)
if err != nil {
log.Fatalf("grpc: db open: %v", err)
}
// Nodes service (wires Hub, LoadCache, Handler).
store := nodes.NewSQLNodeStore(sqlDB)
svc := nodes.NewService(ca, tokens, rdb, store)
svc.Hub().Start(context.Background()) // pub/sub goroutine runs for process lifetime
// gRPC transport TLS (server's own cert, typically from Let's Encrypt).
serverCert, err := tls.LoadX509KeyPair(grpcCertPath, grpcKeyPath)
if err != nil {
log.Fatalf("grpc: load transport TLS cert: %v", err)
}
tlsCfg := mtls.NewServerTLSConfig(ca, crl)
tlsCfg.Certificates = []tls.Certificate{serverCert}
// Build gRPC server with mTLS transport + identity interceptors.
srv := grpc.NewServer(
grpc.Creds(credentials.NewTLS(tlsCfg)),
grpc.ChainUnaryInterceptor(mtls.UnaryServerInterceptor()),
grpc.ChainStreamInterceptor(mtls.StreamServerInterceptor()),
)
agentv1.RegisterAgentServiceServer(srv, svc.Handler())
lis, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("grpc: listen %s: %v", addr, err)
}
slog.Info("grpc agent server listening", "addr", addr)
if err := srv.Serve(lis); err != nil {
log.Fatalf("grpc: serve: %v", err)
}
}
func getenvDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}