Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ ShowDebugToBlueTeam = false # Show check debug info to teams
Port = 80 # Server port (443 default with SSL)
LogoImage = "/static/assets/quotient.svg"
LogFile = "" # Optional log file path
StartPaused = true # Start with scoring paused
StartPaused = true # Start with the engine paused (no preflight or scored rounds until resumed)

# Round timing
Delay = 60 # Seconds between rounds (default: 60)
Expand All @@ -271,6 +271,8 @@ AttemptDelay = 0 # Seconds to wait before retrying a failed attempt (

Every attempt, plus the waits between them, must fit in the shortest round: `Attempts * Timeout + (Attempts - 1) * AttemptDelay` must be less than `Delay - Jitter`.

In rvb events, rounds that run while the competition is not started and no scored round has been stored are **preflight rounds**. They run the same checks a scored round would (inactive teams, disabled team services and checks without seeded credentials are skipped alike), but their results are stored apart and never count toward points, uptime or SLAs. Leave `StartPaused = false` to have preflight rounds run as soon as the engine is up. `GET /api/engine/preflight` returns the latest preflight round, including one that collected no results, so you can confirm every team is green before handing over; it is admin-only because the stored error and debug text can contain credentials. Stopping a competition that has stored scored rounds does not bring preflight rounds back; `GET /api/engine/reset` while stopped (or stopping before the first scored round is stored) re-enters preflight, since the decision is made from the stored rounds. Injects keep their anchor: a reset does not forget when the competition first started. In custom checks the `ROUND` placeholder is the preflight round number during preflight and the scored round number afterwards, so both start at 1.

#### UI Settings

```toml
Expand Down
2 changes: 1 addition & 1 deletion docs/custom-checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Custom checks are defined under a `[[box.custom]]` section in `event.conf`. The

| Placeholder | Description |
|-------------|-------------|
| `ROUND` | Current round number |
| `ROUND` | Current round number. During preflight rounds (while the competition is not started and no scored round has been stored, so also after `GET /api/engine/reset` while stopped) this is the preflight round number, which also starts at 1, so a script must not assume it is unique across preflight and scored rounds |
| `TARGET` | Hostname or IP address (with team identifier substituted) |
| `TEAMIDENTIFIER` | The team's unique identifier (e.g., "01") |
| `USERNAME` | A username from the configured credlists |
Expand Down
1 change: 1 addition & 0 deletions engine/checks/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type Result struct {
Points int `json:"points,omitempty"`
ServiceType string `json:"service_type,omitempty"`
RoundID uint `json:"round_id"`
Preflight bool `json:"preflight,omitempty"`

// Added for runner visualization
RunnerID string `json:"runner_id,omitempty"`
Expand Down
9 changes: 6 additions & 3 deletions engine/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ func (d *DB) migrate() {
// box schema must come first for automigrate to work
&VulnSchema{}, &BoxSchema{}, &VectorSchema{}, &AttackSchema{}, &CompetitionStateSchema{},
// credential schemas for PCR management
&OriginalCredentialSchema{}, &CredentialSchema{}, &PCRHistorySchema{})
&OriginalCredentialSchema{}, &CredentialSchema{}, &PCRHistorySchema{},
&PreflightRoundSchema{}, &PreflightCheckSchema{})
if err != nil {
log.Fatalln("Failed to auto migrate:", err)
}
Expand Down Expand Up @@ -147,9 +148,11 @@ func (d *DB) AddTeams(conf *config.ConfigSettings) error {
return nil
}

// ResetScores drops scored and preflight rounds so the engine re-enters
// preflight once the competition is stopped. Competition state is kept: the
// start anchor of imported injects survives a reset.
func (d *DB) ResetScores() error {
// truncate servicecheckschemas, slaschemas, and roundschemas with cascade
if err := d.db.Exec("TRUNCATE TABLE service_check_schemas, round_schemas, sla_schemas CASCADE").Error; err != nil {
if err := d.db.Exec("TRUNCATE TABLE service_check_schemas, round_schemas, sla_schemas, preflight_check_schemas, preflight_round_schemas CASCADE").Error; err != nil {
return err
}

Expand Down
51 changes: 51 additions & 0 deletions engine/db/preflight.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package db

import (
"errors"
"time"

"gorm.io/gorm"
)

// Preflight rounds are kept apart from round_schemas so they never count toward points, uptime or SLAs.
type PreflightRoundSchema struct {
ID uint
StartTime time.Time
Checks []PreflightCheckSchema `gorm:"foreignKey:RoundID"`
}

type PreflightCheckSchema struct {
ID uint `gorm:"primarykey"`
RoundID uint `gorm:"index"`
TeamID uint
Team TeamSchema
ServiceName string
ServiceType string
Result bool
Error string
Debug string
}

func (d *DB) CreatePreflightRound(round PreflightRoundSchema) error {
return d.db.Create(&round).Error
}

// GetLastPreflightRoundID is 0 when no preflight round has been stored.
func (d *DB) GetLastPreflightRoundID() (uint, error) {
var last uint
err := d.db.Model(&PreflightRoundSchema{}).Select("COALESCE(MAX(id), 0)").Scan(&last).Error
return last, err
}

// GetLatestPreflightRound returns the zero round when none has been stored.
func (d *DB) GetLatestPreflightRound() (PreflightRoundSchema, error) {
var round PreflightRoundSchema
err := d.db.
Preload("Checks", func(tx *gorm.DB) *gorm.DB { return tx.Order("team_id, service_name") }).
Preload("Checks.Team").
Order("id desc").First(&round).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return PreflightRoundSchema{}, nil
}
return round, err
}
7 changes: 7 additions & 0 deletions engine/db/rounds.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ func (d *DB) GetLastRound() (RoundSchema, error) {
return round, nil
}

// HasScoredRound reports whether any scored round has been stored.
func (d *DB) HasScoredRound() (bool, error) {
var n int64
err := d.db.Table("round_schemas").Limit(1).Count(&n).Error
return n > 0, err
}

func (d *DB) RefreshScoresMaterializedView() error {
// Use concurrent refresh to avoid blocking reads
return d.db.Exec("REFRESH MATERIALIZED VIEW CONCURRENTLY cumulative_scores").Error
Expand Down
102 changes: 81 additions & 21 deletions engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,17 +144,19 @@ func (se *ScoringEngine) Start() {
default:
}

slog.Info("Starting round", "round", se.CurrentRound)
se.CurrentRoundStartTime = time.Now()
se.NextRoundStartTime = time.Now().Add(time.Duration(se.Config.MiscSettings.Delay) * time.Second)

// run the round logic
var err error
switch se.Config.RequiredSettings.EventType {
case "koth":
err = se.koth()
case "rvb":
err = se.rvb()
if se.preflightPending() {
err = se.preflight()
} else {
err = se.rvb()
}
default:
slog.Error("Unknown event type", "eventType", se.Config.RequiredSettings.EventType)
}
Expand All @@ -163,11 +165,7 @@ func (se *ScoringEngine) Start() {
return
}

slog.Info(fmt.Sprintf("Round %d complete", se.CurrentRound))
se.CurrentRound++

se.RedisClient.Publish(context.Background(), "events", "round_finish")
slog.Info(fmt.Sprintf("Round %d will start in %s, sleeping...", se.CurrentRound, time.Until(se.NextRoundStartTime).String()))
slog.Info(fmt.Sprintf("Next round will start in %s, sleeping...", time.Until(se.NextRoundStartTime).String()))
select {
case <-stop:
return
Expand Down Expand Up @@ -364,11 +362,77 @@ func (se *ScoringEngine) koth() error {
// enginePauseWg.Wait()

// do koth stuff
se.finishScoredRound()
return nil
}

// perform a round of rvb
func (se *ScoringEngine) rvb() error {
slog.Info("Starting round", "round", se.CurrentRound)
results, err := se.runChecks(se.CurrentRound, false)
if err != nil {
return err
}
se.processCollectedResults(results)
se.finishScoredRound()
return nil
}

func (se *ScoringEngine) finishScoredRound() {
slog.Info(fmt.Sprintf("Round %d complete", se.CurrentRound))
se.CurrentRound++
se.RedisClient.Publish(context.Background(), "events", "round_finish")
}

// preflightPending reads the DB rather than CurrentRound, which also advances
// on rounds that stored nothing and is reseeded on restart. A DB read error
// runs a preflight round, delaying scoring by one round.
func (se *ScoringEngine) preflightPending() bool {
if se.DB.GetCompetitionStarted() {
return false
}
scored, err := se.DB.HasScoredRound()
if err != nil {
slog.Error("failed to check for scored rounds, running a preflight round", "error", err)
return true
}
return !scored
}

// preflight runs the same checks as rvb but stores the results apart from
// scoring.
func (se *ScoringEngine) preflight() error {
last, err := se.DB.GetLastPreflightRoundID()
if err != nil {
return err
}
round := db.PreflightRoundSchema{ID: last + 1, StartTime: se.CurrentRoundStartTime}
slog.Info("Starting preflight round", "preflight_round", round.ID)
results, err := se.runChecks(round.ID, true)
if err != nil {
return err
}
if len(results) == 0 {
slog.Warn("No results collected for preflight round", "preflight_round", round.ID)
}
for _, result := range results {
round.Checks = append(round.Checks, db.PreflightCheckSchema{
TeamID: result.TeamID,
ServiceName: sanitizeDBString(result.ServiceName),
ServiceType: result.ServiceType,
Result: result.Status,
Error: sanitizeDBString(result.Error),
Debug: sanitizeDBString(result.Debug),
})
}
if err := se.DB.CreatePreflightRound(round); err != nil {
slog.Error("failed to save preflight round", "preflight_round", round.ID, "error", err)
}
slog.Info(fmt.Sprintf("Preflight round %d complete", round.ID))
return nil
}

func (se *ScoringEngine) runChecks(roundID uint, preflight bool) ([]checks.Result, error) {
// reassign the next round start time with jitter
// double the jitter and subtract it to get a random number between -jitter and jitter
randomJitter := rand.Intn(2*se.Config.MiscSettings.Jitter) - se.Config.MiscSettings.Jitter // #nosec G404 -- non-crypto randomization for timing jitter
Expand All @@ -382,18 +446,17 @@ func (se *ScoringEngine) rvb() error {
defer events.Close()
eventsChannel := events.Channel()

// do rvb stuff
teams, err := se.DB.GetTeams()
if err != nil {
slog.Error("failed to get teams:", "error", err)
return err
return nil, err
}

runners := 0
ctx, cancel := context.WithTimeout(context.Background(), time.Until(se.NextRoundStartTime))
defer cancel()

slog.Debug("Starting service checks", "round", se.CurrentRound)
slog.Debug("Starting service checks", "round", roundID, "preflight", preflight)
allRunners := se.Config.AllChecks()
slog.Info("Service check configuration",
"boxes", len(se.Config.Box),
Expand All @@ -409,12 +472,11 @@ func (se *ScoringEngine) rvb() error {
// results would otherwise be counted as this round's.
for _, key := range []string{"tasks", "results"} {
if stale := se.RedisClient.LLen(ctx, key).Val(); stale > 0 {
slog.Warn("Clearing stale entries from queue", "queue", key, "count", stale, "round", se.CurrentRound)
slog.Warn("Clearing stale entries from queue", "queue", key, "count", stale, "round", roundID, "preflight", preflight)
se.RedisClient.Del(ctx, key)
}
}

// 1) Enqueue
for _, team := range teams {
if !team.Active {
continue
Expand Down Expand Up @@ -443,7 +505,8 @@ func (se *ScoringEngine) rvb() error {
TeamIdentifier: team.Identifier,
ServiceType: r.GetType(),
ServiceName: r.GetName(),
RoundID: se.CurrentRound,
RoundID: roundID,
Preflight: preflight,
Deadline: se.NextRoundStartTime,
Attempts: r.GetAttempts(),
AttemptDelay: r.GetAttemptDelay(),
Expand Down Expand Up @@ -483,7 +546,6 @@ func (se *ScoringEngine) rvb() error {
}
slog.Info("Enqueued checks", "count", runners)

// 2) Collect results from Redis
results := make([]checks.Result, 0, runners)
timeoutCtx, cancel := context.WithTimeout(ctx, time.Until(se.NextRoundStartTime))
defer cancel()
Expand All @@ -496,7 +558,7 @@ COLLECTION:
slog.Info("Received message", "message", msg.Payload)
if msg.Payload == "reset" {
slog.Info("Reset event received, quitting...")
return fmt.Errorf("reset event received")
return nil, fmt.Errorf("reset event received")
} else {
continue
}
Expand Down Expand Up @@ -530,8 +592,8 @@ COLLECTION:
slog.Error("Failed to unmarshal check result", "error", err)
continue
}
if result.RoundID != se.CurrentRound {
slog.Warn("Ignoring out of round result", "receivedRound", result.RoundID, "currentRound", se.CurrentRound)
if result.RoundID != roundID || result.Preflight != preflight {
slog.Warn("Ignoring out of round result", "receivedRound", result.RoundID, "receivedPreflight", result.Preflight, "currentRound", roundID, "currentPreflight", preflight)
continue
}
results = append(results, result)
Expand All @@ -540,9 +602,7 @@ COLLECTION:
}
}

// 3) Process all collected results
se.processCollectedResults(results)
return nil
return results, nil
}

func sanitizeDBString(s string) string {
Expand Down
Loading
Loading