Files
vantage-app/server/cmd/main.go
T

421 lines
15 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
// Embeds the IANA zone database in the binary. Load-bearing: server/Dockerfile
// builds on Alpine, which ships no zoneinfo, so without this
// time.LoadLocation("Europe/London") fails in production and every workflow
// schedule silently falls back to UTC.
_ "time/tzdata"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
grpcserver "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/mcp"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/metricsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/monitorsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/patchsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/vulnsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
"github.com/gin-gonic/gin"
)
// @title Vantage API
// @version 1.0
// @description The Vantage control plane REST API. Authenticate with a browser session cookie, or with an API token created under Settings → API tokens.
// @BasePath /api
// Each @securityDefinitions.apikey block below is deliberately its own
// comment group, separated by a real blank line rather than a bare "//": Go's
// parser only splits ast.CommentGroups on an actual blank line, and
// swag v2.0.0-rc5's parseSecAttributesV3 resolves a scheme's map key by
// scanning from the start of whatever comment group it was handed - so three
// stacked blocks sharing one group all collapse onto the first block's name.
// Three groups means three independent scans, each finding its own name.
// @securityDefinitions.apikey cookieAuth
// @in cookie
// @name km_session
// @securityDefinitions.apikey bearerAuth
// @in header
// @name Authorization
// @description An API token, sent as "Bearer vt_…". Scoped and optionally expiring.
// @securityDefinitions.apikey esoAuth
// @in header
// @name Authorization
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
func main() {
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
// Two flags, so the schema work can be lifted out of the serving pods.
//
// Under Docker Compose neither is set and nothing changes: one process
// migrates and then serves. Under Kubernetes with more than one replica
// that is unsafe - every pod would run MigrateOrgToInstance at once, and
// renaming collections while a sibling reads them is not a race anyone
// wins. The chart therefore runs a pre-upgrade Job with MIGRATE_ONLY and
// starts the Deployment with SKIP_MIGRATIONS.
migrateOnly := boolEnv("VANTAGE_MIGRATE_ONLY")
skipMigrations := boolEnv("VANTAGE_SKIP_MIGRATIONS")
// Not required in migrate-only mode: that process never serves gRPC, and
// demanding it would put an agent-facing address in a Job that has no
// business knowing one.
if !migrateOnly && os.Getenv("GRPC_HOST") == "" {
log.Fatal("GRPC_HOST is required (host:port agents dial for gRPC)")
}
// DB name comes from the MONGO_URI path; "vantage" is the fallback.
if err := db.Connect(mongoURI, "vantage"); err != nil {
log.Fatalf("failed to connect to MongoDB: %v", err)
}
log.Println("connected to MongoDB")
if migrateOnly || !skipMigrations {
runSchemaSetup()
} else {
log.Println("VANTAGE_SKIP_MIGRATIONS set: assuming migrations ran elsewhere")
}
if migrateOnly {
log.Println("VANTAGE_MIGRATE_ONLY set: schema setup complete, exiting")
return
}
serve()
}
// runSchemaSetup performs every write that must happen exactly once before the
// application serves: migrations, index builders and default step seeding. It
// is fatal on anything that would leave the schema half-moved.
func runSchemaSetup() {
// Migrations 0001 to 0003 still speak the pre-rename shape (orgs, org_id),
// so they must run before 0004 renames everything underneath them.
if err := services.RunMigrations(); err != nil {
log.Fatalf("migration failed: %v", err)
}
// 0002 must precede 0003: 0003 can create a "default" org, which pushes
// 0002 into its ambiguous multi-org branch.
if err := services.MigrateSettingsOrg(); err != nil {
log.Fatalf("settings org migration failed: %v", err)
}
if err := services.MigrateMissedOrgScopes(); err != nil {
log.Fatalf("missed org scope migration failed: %v", err)
}
// 0004 renames orgs to instances. It must run BEFORE the index builders:
// EnsureAuthIndexes creates instances.slug, which would create an empty
// instances collection and make 0004 refuse to rename onto it.
migCtx, migCancel := context.WithTimeout(context.Background(), 10*time.Minute)
migErr := services.MigrateOrgToInstance(migCtx, db.Database)
migCancel()
if migErr != nil {
log.Fatalf("instance rename migration failed: %v", migErr)
}
assertCtx, assertCancel := context.WithTimeout(context.Background(), 30*time.Second)
assertErr := services.AssertNoScopedCollectionMissed(assertCtx, db.Database)
assertCancel()
if assertErr != nil {
log.Fatalf("scoped collection check failed: %v", assertErr)
}
if err := services.EnsureAuthIndexes(); err != nil {
log.Fatalf("failed to ensure auth indexes: %v", err)
}
if err := services.EnsureMFAIndexes(); err != nil {
log.Fatalf("failed to ensure mfa indexes: %v", err)
}
if err := services.EnsureAPITokenIndexes(); err != nil {
log.Fatalf("api token indexes: %v", err)
}
// 0005 runs AFTER EnsureAuthIndexes: the unique (instance_id, provider_id)
// index must exist before anything inserts providers, or a concurrent
// re-run could double-insert before the index is there to refuse it.
if err := services.MigrateAuthProviders(); err != nil {
log.Fatalf("auth provider migration failed: %v", err)
}
if err := services.EnsureSecretIndexes(); err != nil {
log.Printf("warning: failed to ensure secret indexes: %v", err)
}
if err := services.EnsureServerIndexes(); err != nil {
log.Printf("warning: failed to ensure server indexes: %v", err)
}
if err := services.EnsureSettingsIndexes(); err != nil {
log.Fatalf("failed to ensure settings indexes: %v", err)
}
if err := services.EnsureWorkflowIndexes(); err != nil {
log.Printf("warning: failed to ensure workflow indexes: %v", err)
}
if err := services.EnsureMonitorSampleIndexes(); err != nil {
log.Printf("warning: failed to ensure monitor sample indexes: %v", err)
}
if err := services.EnsureMonitorServerStateIndexes(); err != nil {
log.Printf("warning: %v", err)
}
if err := services.EnsureVulnIndexes(); err != nil {
log.Printf("warning: failed to ensure vuln indexes: %v", err)
}
if err := services.EnsureWorkloadIndexes(); err != nil {
log.Printf("warning: failed to ensure workload indexes: %v", err)
}
if err := services.EnsureStatusPageIndexes(); err != nil {
log.Printf("warning: failed to ensure status page indexes: %v", err)
}
if err := services.EnsurePatchIndexes(); err != nil {
log.Printf("warning: patch indexes: %v", err)
}
if err := services.EnsureAuditIndexes(); err != nil {
log.Printf("warning: failed to ensure audit indexes: %v", err)
}
if instanceIDs, err := services.ListInstanceIDs(); err != nil {
log.Printf("warning: failed to list instances for default step seeding: %v", err)
} else {
for _, instanceID := range instanceIDs {
if created, updated, err := services.SeedDefaultSteps(instanceID); err != nil {
log.Printf("warning: failed to seed default steps for instance %s: %v", instanceID, err)
} else {
log.Printf("default steps seeded for instance %s: %d created, %d updated", instanceID, created, updated)
}
}
}
}
// apiVersion mirrors the @version annotation on the swagger block above,
// which is the only version string this server already establishes - there is
// no separate runtime build-version constant to reuse instead. Nothing ties
// the two together mechanically, so change them in the same commit: this is
// the value mcp.SetVersion reports to MCP clients, and it must keep agreeing
// with "// @version" above or the two will read as two different servers.
const apiVersion = "1.0"
func serve() {
mcp.SetVersion(apiVersion)
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
redisUser := os.Getenv("REDIS_USERNAME")
redisPass := os.Getenv("REDIS_PASSWORD")
if err := auth.InitRedis(redisAddr, redisUser, redisPass); err != nil {
log.Fatalf("failed to connect to Redis: %v", err)
}
log.Println("connected to Redis")
services.RedisClient = auth.Redis()
// The bus carries agent commands and step results between replicas. It is
// not optional even on a single-replica deployment: dispatch takes the same
// path either way, so the code exercised in production is the code
// exercised everywhere.
if err := bus.Init(redisAddr, redisUser, redisPass); err != nil {
log.Fatalf("failed to connect the message bus: %v", err)
}
log.Printf("message bus ready as node %s", bus.NodeID())
// Cancelled on SIGTERM/SIGINT. Everything below that takes a context - the
// housekeeping jobs, the leader lock - stops when the pod is asked to.
ctx, shutdown := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer shutdown()
stopGRPC, err := grpcserver.StartGRPC(9090)
if err != nil {
log.Fatalf("gRPC server error: %v", err)
}
// Everything below runs on exactly one replica at a time.
//
// These are cluster-singleton jobs, not per-pod work: N replicas would mean
// every monitor check firing N times, every incident notification delivered
// to the customer N times, every retention sweep deleting concurrently, and
// N reapers racing to purge the same instance. They share one lock rather
// than holding four, because they are one role - housekeeping - and
// splitting them would only spread that role across pods for no benefit.
bus.RunAsLeader(ctx, "housekeeping", func(jobCtx context.Context) {
services.StartLogSweeper(jobCtx)
services.StartAuditSweeper(jobCtx)
services.StartReaper(jobCtx)
monitorsched.Start(jobCtx)
metricsched.Start(jobCtx)
workflowsched.Start(jobCtx, workflowsched.Deps{
TriggerWorkflow: services.TriggerWorkflow,
LogEvent: services.LogEvent,
})
patchsched.Start(jobCtx, patchsched.Deps{
LookupWindow: services.LookupWindow,
CountTargets: services.CountPolicyTargets,
StartPolicyRun: func(p models.PatchPolicy, windowEnd time.Time) error {
_, err := services.StartPolicyRun(p, windowEnd, models.PatchSourceSchedule, "schedule")
return err
},
AdvanceRuns: services.AdvancePatchRuns,
LogEvent: services.LogEvent,
})
vulnsched.Start(jobCtx, vulnsched.Deps{
LogEvent: services.LogEvent,
SendDigest: services.SendVulnDigest,
})
services.StartVulnSweeper(jobCtx)
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for {
select {
case <-jobCtx.Done():
return
case <-ticker.C:
if err := services.MarkOfflineServers(); err != nil {
log.Printf("mark offline error: %v", err)
}
}
}
})
r := gin.New()
// Without this gin trusts every proxy and ClientIP() is whatever the
// caller wrote in X-Forwarded-For. That was survivable while ClientIP()
// only produced audit strings; the public status limiter makes it load
// bearing. Empty means trust nobody, which is correct for a direct
// exposure and wrong behind a proxy - hence the explicit setting.
if err := r.SetTrustedProxies(api.TrustedProxies()); err != nil {
log.Fatalf("trusted proxies: %v", err)
}
r.Use(gin.Recovery())
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{
SkipPaths: []string{"/api/console/tunnel"},
// Heartbeat URLs carry a credential; the request log must not.
Formatter: func(p gin.LogFormatterParams) string {
return fmt.Sprintf("[GIN] %v | %3d | %13v | %15s | %-7s %#v\n%s",
p.TimeStamp.Format("2006/01/02 - 15:04:05"),
p.StatusCode, p.Latency, p.ClientIP, p.Method,
api.MaskLogPath(p.Path), p.ErrorMessage)
},
}))
r.Use(corsMiddleware())
services.SetStatusRedis(auth.Redis())
api.RegisterRoutes(r)
if err := api.AssertScopeMapComplete(r); err != nil {
log.Fatalf("api scope map: %v", err)
}
if err := api.AssertServerScopeMapComplete(apiRoutes(r)); err != nil {
log.Fatalf("api server scope map: %v", err)
}
srv := &http.Server{Addr: ":8080", Handler: r}
go func() {
log.Println("REST server listening on :8080")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("REST server error: %v", err)
}
}()
<-ctx.Done()
log.Println("shutdown signal received")
// gRPC first, and this ordering is the point of the whole exercise. Stopping
// it runs each CommandStream handler's deferred release, which clears that
// agent's presence claim; until that happens another replica will keep
// dispatching commands to this process. Draining HTTP first would leave the
// claims held for the length of the drain.
stopGRPC()
drainCtx, cancelDrain := context.WithTimeout(context.Background(), httpDrainTimeout)
defer cancelDrain()
if err := srv.Shutdown(drainCtx); err != nil {
log.Printf("REST server shutdown: %v", err)
}
log.Println("shutdown complete")
}
// How long in-flight REST requests are given to finish. Console tunnels are
// long-lived WebSockets that will not end on their own, so this is a ceiling
// rather than a target; the relays behind them are already gone by this point.
const httpDrainTimeout = 10 * time.Second
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// boolEnv reads a flag env var. Anything other than a recognised truthy value
// is false, so a typo leaves the safe default (migrate here, serve here) rather
// than silently skipping schema setup.
func boolEnv(key string) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "1", "true", "yes", "on":
return true
}
return false
}
// apiRoutes lists every registered /api route as "METHOD /path", which is the
// whole input AssertServerScopeMapComplete now takes.
//
// It replaces a substring filter that fed in only routes whose path contained
// "server", ":serverId", "console" or "assign". That filter could only ever
// catch a route whose *path* named a server, and a route can act on one named
// in its body, in a query parameter, or derived by the handler - it caught one
// of the leaks found in the final review of the MCP feature, and none of the
// eleven found during implementation. Declaring every route is more typing
// once and no maintenance after: a new route fails boot until somebody answers
// "does this touch server data?" for it.
func apiRoutes(r *gin.Engine) []string {
var out []string
for _, route := range r.Routes() {
if !strings.HasPrefix(route.Path, "/api/") {
continue
}
out = append(out, route.Method+" "+route.Path)
}
return out
}