control-api: every route now carries a mandatory access level (admin / agent / open) in a route table. All /api/v1/admin/* require the admin token; the write calls of validator-agent and prober (self-check, events, results, complete) require a separate static agent token; register, heartbeat and fetching the assignment stay open. Tokens come from env vars, are compared in constant time and never logged. An empty token leaves that level open with a startup warning (backward compatible). validator-agent / prober: apiclient sends the agent token only to control-api. admin-dashboard: login/password (from env) with a stateless HMAC session cookie, Origin-based CSRF check, per-IP brute-force throttle, HX-Redirect for htmx polls, logout in the sidebar; the dashboard calls control-api with the admin token. Login page layout fixed after review. Also: env plumbing in docker-compose/rxprod-compose/systemd/config examples, e2e script with token assertions, tests, docs (API, SETUP, USAGE, DASHBOARD, README), plan and review under docs/changes/, bin/ rebuilt with new SHA256SUMS. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
90 lines
2.6 KiB
Go
90 lines
2.6 KiB
Go
// Command validator-agent runs on a validator VM. It is stateless: every
|
|
// decision it makes is driven by polling the Control API, per the
|
|
// deployment constraint that validators must be able to restart freely
|
|
// without any local state to reconcile.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"cloudipvalidator/internal/agentcore"
|
|
"cloudipvalidator/internal/config"
|
|
)
|
|
|
|
func main() {
|
|
configPath := flag.String("config", "configs/validator-agent.yaml", "path to validator-agent config file")
|
|
stubPorts := flag.String("stub-ports", "", "TEST ONLY: comma-separated TCP ports to accept-and-close on, standing in for the validator's real listening services in the offline end-to-end harness (see docs/LOCAL_E2E.md)")
|
|
flag.Parse()
|
|
|
|
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
|
|
cfg, err := config.LoadValidatorAgent(*configPath)
|
|
if err != nil {
|
|
log.Error("load config", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
if *stubPorts != "" {
|
|
startStubListeners(ctx, log, *stubPorts)
|
|
}
|
|
|
|
token := os.Getenv(cfg.ControlAPITokenEnv)
|
|
if token == "" {
|
|
log.Warn("agent token is not set: result/event/complete calls will be rejected by an authenticated control-api", "env", cfg.ControlAPITokenEnv)
|
|
}
|
|
agent := agentcore.New(cfg, log).WithToken(token)
|
|
if err := agent.Run(ctx); err != nil && err != context.Canceled {
|
|
log.Error("agent stopped", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// startStubListeners binds trivial accept-and-close TCP listeners on the
|
|
// given ports, standing in for the base-minimum services (22/80/443/8080)
|
|
// a real validator would already run, so an offline prober has something
|
|
// to successfully connect to. ICMP needs no stub: the kernel answers echo
|
|
// requests to any locally-bound address on its own.
|
|
func startStubListeners(ctx context.Context, log *slog.Logger, portsCSV string) {
|
|
for _, p := range strings.Split(portsCSV, ",") {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
port, err := strconv.Atoi(p)
|
|
if err != nil {
|
|
log.Error("invalid stub port", "value", p, "err", err)
|
|
continue
|
|
}
|
|
ln, err := net.Listen("tcp", ":"+strconv.Itoa(port))
|
|
if err != nil {
|
|
log.Error("stub listener", "port", port, "err", err)
|
|
continue
|
|
}
|
|
log.Info("stub listener up", "port", port)
|
|
go func() {
|
|
<-ctx.Done()
|
|
ln.Close()
|
|
}()
|
|
go func() {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
conn.Close()
|
|
}
|
|
}()
|
|
}
|
|
}
|