Watch
1
0
Fork
You've already forked RedFlag
0

feat: install/sync/RL hardening + upstream version sync subsystem

Agent + install:
- linux installer adds redflag-agent to docker group (idempotent;
  unblocks container scanner detection)
- install.sh / linux.sh.tmpl / windows.ps1.tmpl: detect existing
  refresh_token and skip --register (Flow 2: upgrade in place)
- agent_update.go: remove dead post-restart watchdog; add
  CleanupPostUpdateBackup() called after first successful check-in

Server + token model:
- 409 machine-already-registered now returns existing_agent_id,
  hostname, last_seen, remediation guidance
- RevokeAgent admin handler (invalidates refresh_tokens only)
- GetAgentsBoundToToken query + no-cascade invariant test
- Two-axis revocation locked in: registration_token revoke does NOT
  cascade to agent refresh_tokens

Supply chain (Shai-Hulud defense feature 1):
- services/package_age.go: npm + PyPI registry probes,
  EvaluatePackageAgeGate decision matrix (warn/block/off x
  above/below/unknown)
- ApproveUpdate / ApproveUpdates wired to age gate; stores
  package_published_at + supply_chain_age_check in metadata
- security_settings_service: supply_chain category defaults
  (min_package_age_hours=24, gate_enforcement=warn)

Rate limiting UI rewrite:
- Frontend was expecting per-endpoint configs + usage/summary
  routes that don't exist; backend has 6 named categories
- Rewrote RateLimiting.tsx, useRateLimits.ts, api.ts, types,
  Settings overview card to the real shape (Requests + Window in
  seconds + Enabled per category)

Agent Management UI:
- Replaced auto-pick-first-token with explicit dropdown showing
  prefix, label, seats_used/max_seats, expiry
- One-liner panel only renders when a token is selected; no more
  YOUR_REGISTRATION_TOKEN placeholder in copyable command
- Zero-token state surfaces "Generate Registration Token" CTA

Upstream version sync (new subsystem):
- Migration 035: tracked_software + upstream_drift_events
- ReleaseSource interface + Registry + Repology adapter +
  endoflife.date adapter
- Periodic Syncer goroutine with on-demand SyncOne; classifies
  drift severity (minor/major/eol); appends drift events
- /admin/upstream CRUD + drift + sync-now routes
- Dashboard "Stack Drift" panel (count + worst-3 + EOL flag)

Migration 035 runs idempotently on next startup.
This commit is contained in:
Fimeg 2026-05-23 15:12:20 -04:00
commit 86da7471ec
34 changed files with 2345 additions and 831 deletions

View file

@ -21,7 +21,7 @@ That said, it works well for its intended use case. Issues and feedback welcome!
Cross-platform agents • Web dashboard • Hardware binding • Ed25519 signing • Full error transparency • No enterprise BS
```
v0.2.0 - May 2026
v0.2.0.0 - May 2026
```
**Latest:** Maintenance windows for gating installs by schedule, OSV.dev supply chain checks for npm/PyPI packages, TD-series security refactors (panic recovery, error transparency, main.go modularization), install URL auto-detection fix, Ed25519 key rotation support, replay attack protection, and the setup wizard now includes the agent-facing URL field. See the [changelog](#-tldr-changelog-dont-trust-the-transport-layer) below. [Update instructions here](#updating).
@ -132,12 +132,31 @@ Get registration tokens from the web dashboard under **Settings → Token Manage
### Updating
To update to the latest version:
To update the server stack:
```bash
git pull && docker-compose down && docker-compose build --no-cache && docker-compose up -d
```
#### Agent self-update
Agents accept an `update_agent` command from the dashboard. The flow:
download → SHA-256 → Ed25519 signature → atomic binary swap → service restart.
Completion is reconciled server-side once the new binary reports its version.
**Requires a real service manager on the agent host** — Linux: `systemd` (unit
`redflag-agent`); Windows: SCM (service `RedFlagAgent`). Container-only agent
deployments (no `systemctl`, no `sc`) cannot self-update through this path: the
restart command will fail and the deferred rollback restores the previous
binary. For containerized agents, redeploy with the new image instead.
If a self-update times out without the new version attesting (`agent_update`
system event with subtype `timed_out`, severity `error`), the previous binary
is preserved at `<binary>.bak` on the agent host
(`/usr/local/bin/redflag-agent.bak` on Linux,
`C:\Program Files\RedFlag\redflag-agent.exe.bak` on Windows). Restore manually
and restart the service.
---
<details>
@ -164,7 +183,7 @@ docker-compose down -v --remove-orphans && \
**Warning:** This deletes everything - all agents, update history, configurations. You'll need to handle existing agents:
**Option 1 - Re-register agents:**
- Remove agent config: `sudo rm /etc/aggregator/config.json` (Linux) or `C:\ProgramData\RedFlag\config.json` (Windows)
- Remove agent config: `sudo rm /etc/redflag/agent/config.json` (Linux) or `C:\ProgramData\RedFlag\agent\config.json` (Windows)
- Re-run the one-liner installer with new registration token
- Scripts handle override/update automatically (one agent per OS install)
@ -441,7 +460,7 @@ If you're looking for an enterprise-grade solution with SLAs and support contrac
## 📜 **TLDR Changelog: Don't trust the transport layer**
**v0.2.0 (May 2026)**:
**v0.2.0.0 (May 2026)**:
- ✅ Maintenance windows for scheduling/gating install operations
- ✅ Supply chain vulnerability checks (OSV.dev) for npm/PyPI at approval time
- ✅ Ed25519 key rotation with TTL-based auto-refresh

View file

@ -211,8 +211,8 @@ register_agent "$SERVER_URL"
echo ""
echo "Step 5: Setting config file permissions..."
chown redflag-agent:redflag-agent /etc/aggregator/config.json
chmod 600 /etc/aggregator/config.json
chown redflag-agent:redflag-agent /etc/redflag/agent/config.json
chmod 600 /etc/redflag/agent/config.json
echo ""
echo "Step 6: Installing sudoers configuration..."

View file

@ -142,6 +142,7 @@ type loopContext struct {
func runPollingLoop(ctx *loopContext) error {
consecutiveFailures := 0
lastSystemInfoUpdate := time.Time{}
postUpdateCleanupDone := false
for {
// Calculate jitter
@ -202,6 +203,14 @@ func runPollingLoop(ctx *loopContext) error {
consecutiveFailures = 0
// First successful check-in after boot — if a post-update .bak is
// sitting on disk, the new binary has now proven viability and the
// backup is safe to drop. See agent_update.go cleanup contract.
if !postUpdateCleanupDone {
handlers.CleanupPostUpdateBackup()
postUpdateCleanupDone = true
}
// Log check-in success event [TD-003]
ctx.apiClient.BufferEvent(models.EventTypeAgentCheckIn, models.SubtypeSuccess, models.SeverityInfo,
models.ComponentAgent, "Agent checked in successfully", map[string]interface{}{

View file

@ -133,16 +133,21 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
log.Printf("[ERROR] [agent] [upgrade] backup_failed error=%v", err)
} else {
defer func() {
if !updateSuccess {
log.Printf("[tunturi_ed25519] Rollback: restoring from backup...")
if restoreErr := restoreFromBackup(backupPath, currentBinaryPath); restoreErr != nil {
log.Printf("[tunturi_ed25519] CRITICAL: Failed to restore backup: %v", restoreErr)
} else {
log.Printf("[INFO] [agent] [upgrade] rollback_success")
}
if updateSuccess {
// Binary swap and restart dispatch succeeded. systemd's SIGTERM
// is imminent and may abort this defer before completion. .bak
// stays on disk: the new binary cleans it up after first
// successful check-in attestation (cleanupPostUpdateBackup),
// or the operator restores from it if the new binary fails to
// start. Completion is owned by server-side
// timeout.reconcileAgentUpdates.
return
}
log.Printf("[INFO] [agent] [upgrade] rollback_start reason=pre_restart_error")
if restoreErr := restoreFromBackup(backupPath, currentBinaryPath); restoreErr != nil {
log.Printf("[ERROR] [agent] [upgrade] rollback_failed error=%v", restoreErr)
} else {
log.Printf("[INFO] [agent] [upgrade] update_success cleanup_backup")
os.Remove(backupPath)
log.Printf("[INFO] [agent] [upgrade] rollback_success")
}
}()
}
@ -152,41 +157,21 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
return fmt.Errorf("failed to install new binary: %w", err)
}
log.Printf("[INFO] [agent] [upgrade] restart_start")
// Past the point of no return on disk. The watchdog-and-final-log-report
// pattern used to live here; it could not survive systemd's SIGTERM and
// has been removed. Server-side reconcileAgentUpdates closes the command
// when the new binary reports its version on next check-in.
updateSuccess = true
log.Printf("[INFO] [agent] [upgrade] install_complete duration_seconds=%d", int(time.Since(updateStartTime).Seconds()))
log.Printf("[INFO] [agent] [upgrade] restart_dispatch")
if err := restartAgentService(); err != nil {
// Binary is swapped on disk, but restart failed. The current process
// keeps running on the OLD code; .bak remains for manual restore on
// next manual boot.
return fmt.Errorf("failed to restart agent: %w", err)
}
log.Printf("[INFO] [agent] [upgrade] watchdog_start timeout_seconds=%d", 300)
updateSuccess = waitForUpdateConfirmation(apiClient, cfg, ackTracker, version, 5*time.Minute)
success := updateSuccess
finalLogReport := client.LogReport{
CommandID: commandID,
Action: "update_agent",
Result: map[bool]string{true: "success", false: "failure"}[success],
Stdout: fmt.Sprintf("Agent update to version %s %s\n", version, map[bool]string{true: "completed successfully", false: "failed"}[success]),
Stderr: map[bool]string{true: "", false: "Update verification timeout or restart failure"}[success],
ExitCode: map[bool]int{true: 0, false: 1}[success],
DurationSeconds: int(time.Since(updateStartTime).Seconds()),
Metadata: map[string]string{
"subsystem_label": "Agent Update",
"subsystem": "agent",
"target_version": version,
"success": map[bool]string{true: "true", false: "false"}[success],
},
}
if err := ReportLogWithAck(apiClient, cfg, ackTracker, finalLogReport); err != nil {
log.Printf("[ERROR] [agent] [update] report_completion_failed error=%v", err)
}
if success {
log.Printf("[INFO] [agent] [upgrade] agent_updated version=%s", version)
} else {
return fmt.Errorf("agent update verification failed")
}
return nil
}
@ -235,6 +220,34 @@ func getCurrentBinaryPath() (string, error) {
return execPath, nil
}
// CleanupPostUpdateBackup removes a leftover .bak sibling of the running
// binary if one exists. The update handler intentionally leaves .bak on disk
// across systemd restart because the deferred cleanup cannot survive SIGTERM;
// callers should invoke this once after the first successful server check-in,
// when the new binary has proven it can boot and reach the control plane.
// No-op when no backup is present.
func CleanupPostUpdateBackup() {
execPath, err := os.Executable()
if err != nil {
log.Printf("[WARNING] [agent] [upgrade] cleanup_executable_path_failed error=%v", err)
return
}
backupPath := execPath + ".bak"
info, err := os.Stat(backupPath)
if os.IsNotExist(err) {
return
}
if err != nil {
log.Printf("[WARNING] [agent] [upgrade] cleanup_stat_failed path=%s error=%v", backupPath, err)
return
}
if err := os.Remove(backupPath); err != nil {
log.Printf("[WARNING] [agent] [upgrade] cleanup_remove_failed path=%s error=%v", backupPath, err)
return
}
log.Printf("[INFO] [agent] [upgrade] post_update_backup_removed path=%s size_bytes=%d", backupPath, info.Size())
}
func createBackup(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
@ -336,35 +349,6 @@ func restartAgentService() error {
return nil
}
func waitForUpdateConfirmation(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, expectedVersion string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
pollInterval := 15 * time.Second
log.Printf("[tunturi_ed25519] Watchdog: waiting for version %s confirmation (timeout: %v)...", expectedVersion, timeout)
for time.Now().Before(deadline) {
agent, err := apiClient.GetAgent(cfg.AgentID.String())
if err != nil {
log.Printf("[tunturi_ed25519] Watchdog: failed to poll server: %v (retrying...)", err)
time.Sleep(pollInterval)
continue
}
if agent != nil && agent.CurrentVersion == expectedVersion {
log.Printf("[INFO] [agent] [upgrade] watchdog_version_confirmed version=%s", expectedVersion)
return true
}
log.Printf("[tunturi_ed25519] Watchdog: Current version: %s, Expected: %s (polling...)",
agent.CurrentVersion, expectedVersion)
time.Sleep(pollInterval)
}
log.Printf("[ERROR] [agent] [upgrade] watchdog_timeout duration=%v version_not_confirmed", timeout)
log.Printf("[tunturi_ed25519] Rollback initiated")
return false
}
// --- Crypto helpers ---
func deriveKeyFromNonce(nonce string) []byte {

View file

@ -21,6 +21,7 @@ import (
"github.com/Fimeg/RedFlag/server/internal/logging"
"github.com/Fimeg/RedFlag/server/internal/scheduler"
"github.com/Fimeg/RedFlag/server/internal/services"
"github.com/Fimeg/RedFlag/server/internal/services/upstream"
"github.com/Fimeg/RedFlag/server/internal/version"
"github.com/gin-gonic/gin"
)
@ -201,6 +202,7 @@ func main() {
registrationTokenQueries := queries.NewRegistrationTokenQueries(db.DB)
subsystemQueries := queries.NewSubsystemQueries(db.DB)
maintenanceWindowQueries := queries.NewMaintenanceWindowQueries(db.DB)
upstreamQueries := queries.NewUpstreamQueries(db.DB)
agentUpdateQueries := queries.NewAgentUpdateQueries(db.DB)
metricsQueries := queries.NewMetricsQueries(db.DB.DB)
dockerQueries := queries.NewDockerQueries(db.DB.DB)
@ -354,6 +356,13 @@ func main() {
dockerHandler := handlers.NewDockerHandler(updateQueries, agentQueries, commandQueries, signingService, securityLogger)
registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg)
maintenanceWindowHandler := handlers.NewMaintenanceWindowHandler(maintenanceWindowQueries)
upstreamRegistry := upstream.NewRegistry()
upstreamRegistry.Register(upstream.NewRepology())
upstreamRegistry.Register(upstream.NewEndOfLife())
upstreamSyncer := upstream.NewSyncer(upstreamQueries, upstreamRegistry, time.Hour, 6*time.Hour, 50)
upstreamSyncer.Start(context.Background())
upstreamHandler := handlers.NewUpstreamHandler(upstreamQueries, upstreamSyncer, upstreamRegistry)
rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter)
// ISSUE-002: Pass signingService for install script signature verification
downloadHandler := handlers.NewDownloadHandler(filepath.Join("/app"), cfg, packageQueries, signingService)
@ -654,6 +663,13 @@ func main() {
admin.POST("/registration-tokens/cleanup", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.CleanupExpiredTokens)
admin.GET("/registration-tokens/stats", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetTokenStats)
admin.GET("/registration-tokens/validate", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ValidateRegistrationToken)
// Token-detail expansion: which agents took seats on this token.
// Spec: docs/AGENT_LIFECYCLE.md "Operator surfaces".
admin.GET("/registration-tokens/:token/agents", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetAgentsBoundToToken)
// Per-agent revocation (explicit; no cascade from token revocation).
// Spec: docs/AGENT_LIFECYCLE.md "Revocation".
admin.POST("/agents/:id/revoke", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentHandler.RevokeAgent)
// Machine ID Rebind (F-D1-2: recovery from machine ID mismatch)
admin.POST("/agents/:id/rebind-machine-id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentHandler.RebindMachineID)
@ -677,6 +693,14 @@ func main() {
admin.PUT("/maintenance-windows/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.UpdateWindow)
admin.DELETE("/maintenance-windows/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.DeleteWindow)
admin.GET("/maintenance-windows/check", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.CheckWindow)
// Upstream version sync (Repology + endoflife.date adapters)
admin.GET("/upstream", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.List)
admin.GET("/upstream/drift", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.ListDrifted)
admin.GET("/upstream/drift/events", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.RecentDrift)
admin.POST("/upstream", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.Create)
admin.DELETE("/upstream/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.Delete)
admin.POST("/upstream/:id/sync", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.SyncNow)
}
// Security Health Check endpoints

View file

@ -204,10 +204,22 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
// Validate machine ID and public key fingerprint if provided
if req.MachineID != "" {
// Check if machine ID is already registered to another agent
// Check if machine ID is already registered to another agent. When this
// fires from a re-run of the install URL on an already-registered host,
// the operator should be using the install.sh upgrade-in-place path
// (which reads the local refresh_token and skips this call). A 409 here
// means either the local config was lost (Reclaim, not yet implemented)
// or the operator genuinely wants a new identity on the same hardware
// (Re-register — explicit dashboard action). See docs/AGENT_LIFECYCLE.md.
existingAgent, err := h.agentQueries.GetAgentByMachineID(req.MachineID)
if err == nil && existingAgent != nil && existingAgent.ID.String() != "" {
c.JSON(http.StatusConflict, gin.H{"error": "machine ID already registered to another agent"})
c.JSON(http.StatusConflict, gin.H{
"error": "machine ID already registered to another agent",
"existing_agent_id": existingAgent.ID.String(),
"existing_hostname": existingAgent.Hostname,
"existing_last_seen": existingAgent.LastSeen.UTC().Format(time.RFC3339),
"remediation": "If this host already has /etc/redflag/agent/config.json with a refresh_token, the install URL upgrades in place — re-run it. If local config was lost, no Reclaim path exists yet; remove the existing agent from the dashboard to re-register fresh.",
})
return
}
}
@ -1760,3 +1772,77 @@ func (h *AgentHandler) ReportCircuitBreakerStats(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "healthy"})
}
}
// RevokeAgent invalidates an agent's refresh tokens so the agent loses
// authenticated access on its next renewal. Spec: docs/AGENT_LIFECYCLE.md
// "Revocation" — this is the explicit per-agent path, deliberately separate
// from registration-token revocation (no cascade in either direction).
//
// Surfaced from two places in the UI:
// - Settings → Token Management → expand a token → per-bound-agent revoke
// - Settings → Agents → row action menu → Revoke Agent
// Both call this endpoint.
//
// Behavior: revokes all refresh tokens for the agent_id and writes a
// system_event. The agent row itself is kept for history; the agent goes
// offline on its own at next renew failure. To fully remove the agent, use
// the existing Remove Agent endpoint.
func (h *AgentHandler) RevokeAgent(c *gin.Context) {
idParam := c.Param("id")
agentID, err := uuid.Parse(idParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent id"})
return
}
var request struct {
Reason string `json:"reason"`
}
c.ShouldBindJSON(&request) // optional
agent, err := h.agentQueries.GetAgentByID(agentID)
if err != nil || agent == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
if err := h.refreshTokenQueries.RevokeAllAgentTokens(agentID); err != nil {
log.Printf("[ERROR] [server] [revoke_agent] revoke_failed agent_id=%s error=%v", agentID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke agent"})
return
}
reason := request.Reason
if reason == "" {
reason = "revoked via API"
}
event := &models.SystemEvent{
ID: uuid.New(),
AgentID: &agentID,
EventType: "agent_revoked",
EventSubtype: "by_operator",
Severity: "warning",
Component: "agent",
Message: fmt.Sprintf("Agent %s revoked: %s", agent.Hostname, reason),
Metadata: map[string]interface{}{
"agent_id": agentID.String(),
"hostname": agent.Hostname,
"reason": reason,
},
CreatedAt: time.Now(),
}
if err := h.agentQueries.CreateSystemEvent(event); err != nil {
log.Printf("[WARNING] [server] [revoke_agent] system_event_failed agent_id=%s error=%v", agentID, err)
}
log.Printf("[INFO] [server] [revoke_agent] agent_revoked agent_id=%s hostname=%s reason=%q",
agentID, agent.Hostname, reason)
c.JSON(http.StatusOK, gin.H{
"status": "revoked",
"agent_id": agentID.String(),
"hostname": agent.Hostname,
"message": "refresh tokens invalidated; agent will go offline on next renewal attempt",
})
}

View file

@ -210,6 +210,29 @@ func (h *RegistrationTokenHandler) GetActiveRegistrationTokens(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"tokens": tokens})
}
// GetAgentsBoundToToken returns the agents that have consumed seats on a
// given registration token, for the token-detail expansion in Settings →
// Token Management. Spec: docs/AGENT_LIFECYCLE.md "Operator surfaces".
//
// Behavior: the audit ledger (registration_token_usage) is the source of
// truth. This endpoint does NOT cross-reference current agent state beyond
// the join — a deleted agent (CASCADE on agents.id) simply won't appear.
func (h *RegistrationTokenHandler) GetAgentsBoundToToken(c *gin.Context) {
// :token in the route here is the registration_tokens.id UUID, not the
// secret token string. Mirrors the /registration-tokens/delete/:id pattern.
tokenID, err := uuid.Parse(c.Param("token"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token id (expected UUID)"})
return
}
agents, err := h.tokenQueries.GetAgentsBoundToToken(tokenID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch bound agents"})
return
}
c.JSON(http.StatusOK, gin.H{"agents": agents, "count": len(agents)})
}
// RevokeRegistrationToken revokes a registration token
func (h *RegistrationTokenHandler) RevokeRegistrationToken(c *gin.Context) {
token := c.Param("token")

View file

@ -199,18 +199,15 @@ func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
// Run supply chain check for npm/PyPI packages
var vulns []services.VulnerabilityInfo
var ageDecision services.PackageAgeGateDecision
if services.NeedsSupplyChainCheck(update.PackageType) {
result := services.CheckOSVVulnerabilities(
update.PackageName,
services.EcosystemFromPackageType(update.PackageType),
update.AvailableVersion,
)
ecosystem := services.EcosystemFromPackageType(update.PackageType)
result := services.CheckOSVVulnerabilities(update.PackageName, ecosystem, update.AvailableVersion)
if result != nil && len(result.Vulnerabilities) > 0 {
vulns = result.Vulnerabilities
log.Printf("[WARNING] [supply_chain] vulnerabilities_found id=%s pkg=%s count=%d",
id, update.PackageName, len(vulns))
// Store vulnerabilities in metadata for the UI
vulnJSON, _ := json.Marshal(vulns)
if update.Metadata == nil {
update.Metadata = make(models.JSONB)
@ -218,10 +215,48 @@ func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
update.Metadata["supply_chain_vulns"] = string(vulnJSON)
update.Metadata["supply_chain_checked_at"] = result.CheckedAt.Format(time.RFC3339)
}
// Time-delayed update gate (Shai-Hulud defense). Independent of OSV;
// runs even when there are no known CVEs because freshness itself is
// the signal — worm waves propagate inside the first-24h window.
minAgeHours, enforcement := services.PackageAgeGateConfig()
if enforcement != "off" {
age := services.GetPackagePublishDate(update.PackageName, ecosystem, update.AvailableVersion)
ageDecision = services.EvaluatePackageAgeGate(age, minAgeHours, enforcement)
if !ageDecision.Unknown {
if update.Metadata == nil {
update.Metadata = make(models.JSONB)
}
update.Metadata["package_published_at"] = ageDecision.PublishedAt.UTC().Format(time.RFC3339)
update.Metadata["package_age_hours"] = ageDecision.AgeHours
update.Metadata["supply_chain_age_check"] = map[string]interface{}{
"min_age_hours": ageDecision.MinAgeHours,
"enforcement": ageDecision.Enforcement,
"source": ageDecision.Source,
"blocked": ageDecision.ShouldBlock,
}
}
if ageDecision.ShouldBlock {
log.Printf("[WARNING] [supply_chain] approval_blocked_by_age_gate id=%s pkg=%s age_hours=%.2f min=%.2f",
id, update.PackageName, ageDecision.AgeHours, ageDecision.MinAgeHours)
c.JSON(http.StatusConflict, gin.H{
"error": "approval blocked by supply chain age gate",
"reason": ageDecision.WarnMessage,
"package": update.PackageName,
"version": update.AvailableVersion,
"published_at": ageDecision.PublishedAt.UTC().Format(time.RFC3339),
"age_hours": ageDecision.AgeHours,
"min_age_hours": ageDecision.MinAgeHours,
"override_hint": "set REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT=warn (or off) and retry; or wait until threshold passes",
})
return
}
}
}
// Proceed with approval regardless of findings (sovereignty principle)
if len(vulns) > 0 {
// Proceed with approval (sovereignty principle: vulns + warn-mode age findings
// don't block; only "block" enforcement of the age gate does, handled above).
if len(vulns) > 0 || (!ageDecision.Unknown && ageDecision.WarnMessage != "") {
if err := h.updateQueries.ApproveUpdateWithVulns(id, "admin", update.Metadata); err != nil {
log.Printf("[ERROR] [server] [updates] approve_update_failed id=%s error=%v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to approve update: %v", err)})
@ -239,6 +274,14 @@ func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
if len(vulns) > 0 {
response["warnings"] = vulns
}
if !ageDecision.Unknown && ageDecision.WarnMessage != "" {
response["age_warning"] = gin.H{
"message": ageDecision.WarnMessage,
"age_hours": ageDecision.AgeHours,
"min_age_hours": ageDecision.MinAgeHours,
"published_at": ageDecision.PublishedAt.UTC().Format(time.RFC3339),
}
}
c.JSON(http.StatusOK, response)
}
@ -480,13 +523,25 @@ func (h *UpdateHandler) ApproveUpdates(c *gin.Context) {
}
type warning struct {
UpdateID string `json:"update_id"`
PackageName string `json:"package_name"`
Vulnerabilities []services.VulnerabilityInfo `json:"vulnerabilities"`
UpdateID string `json:"update_id"`
PackageName string `json:"package_name"`
Vulnerabilities []services.VulnerabilityInfo `json:"vulnerabilities,omitempty"`
AgeWarning string `json:"age_warning,omitempty"`
AgeHours float64 `json:"age_hours,omitempty"`
}
type blocked struct {
UpdateID string `json:"update_id"`
PackageName string `json:"package_name"`
Reason string `json:"reason"`
AgeHours float64 `json:"age_hours"`
}
warnings := make([]warning, 0)
blockedList := make([]blocked, 0)
approved := 0
// Bulk reads the gate config once — applies to every item in the batch.
gateMin, gateEnforcement := services.PackageAgeGateConfig()
for _, idStr := range req.UpdateIDs {
id, err := uuid.Parse(idStr)
if err != nil {
@ -502,12 +557,10 @@ func (h *UpdateHandler) ApproveUpdates(c *gin.Context) {
}
var vulns []services.VulnerabilityInfo
var ageDecision services.PackageAgeGateDecision
if services.NeedsSupplyChainCheck(update.PackageType) {
result := services.CheckOSVVulnerabilities(
update.PackageName,
services.EcosystemFromPackageType(update.PackageType),
update.AvailableVersion,
)
ecosystem := services.EcosystemFromPackageType(update.PackageType)
result := services.CheckOSVVulnerabilities(update.PackageName, ecosystem, update.AvailableVersion)
if result != nil && len(result.Vulnerabilities) > 0 {
vulns = result.Vulnerabilities
log.Printf("[WARNING] [supply_chain] vulnerabilities_found id=%s pkg=%s count=%d",
@ -520,19 +573,50 @@ func (h *UpdateHandler) ApproveUpdates(c *gin.Context) {
update.Metadata["supply_chain_vulns"] = string(vulnJSON)
update.Metadata["supply_chain_checked_at"] = result.CheckedAt.Format(time.RFC3339)
}
if gateEnforcement != "off" {
age := services.GetPackagePublishDate(update.PackageName, ecosystem, update.AvailableVersion)
ageDecision = services.EvaluatePackageAgeGate(age, gateMin, gateEnforcement)
if !ageDecision.Unknown {
if update.Metadata == nil {
update.Metadata = make(models.JSONB)
}
update.Metadata["package_published_at"] = ageDecision.PublishedAt.UTC().Format(time.RFC3339)
update.Metadata["package_age_hours"] = ageDecision.AgeHours
update.Metadata["supply_chain_age_check"] = map[string]interface{}{
"min_age_hours": ageDecision.MinAgeHours,
"enforcement": ageDecision.Enforcement,
"source": ageDecision.Source,
"blocked": ageDecision.ShouldBlock,
}
}
if ageDecision.ShouldBlock {
log.Printf("[WARNING] [supply_chain] bulk_approval_blocked_by_age_gate id=%s pkg=%s age_hours=%.2f",
id, update.PackageName, ageDecision.AgeHours)
blockedList = append(blockedList, blocked{
UpdateID: idStr,
PackageName: update.PackageName,
Reason: ageDecision.WarnMessage,
AgeHours: ageDecision.AgeHours,
})
continue
}
}
}
if len(vulns) > 0 {
needsMeta := len(vulns) > 0 || (!ageDecision.Unknown && ageDecision.WarnMessage != "")
if needsMeta {
if err := h.updateQueries.ApproveUpdateWithVulns(id, "admin", update.Metadata); err != nil {
log.Printf("[ERROR] [server] [updates] bulk_approve_failed id=%s error=%v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to approve update %s: %v", idStr, err)})
return
}
warnings = append(warnings, warning{
UpdateID: idStr,
PackageName: update.PackageName,
Vulnerabilities: vulns,
})
w := warning{UpdateID: idStr, PackageName: update.PackageName, Vulnerabilities: vulns}
if !ageDecision.Unknown && ageDecision.WarnMessage != "" {
w.AgeWarning = ageDecision.WarnMessage
w.AgeHours = ageDecision.AgeHours
}
warnings = append(warnings, w)
} else {
if err := h.updateQueries.ApproveUpdate(id, "admin"); err != nil {
log.Printf("[ERROR] [server] [updates] bulk_approve_failed id=%s error=%v", id, err)
@ -550,6 +634,10 @@ func (h *UpdateHandler) ApproveUpdates(c *gin.Context) {
if len(warnings) > 0 {
response["warnings"] = warnings
}
if len(blockedList) > 0 {
response["blocked"] = blockedList
response["blocked_count"] = len(blockedList)
}
c.JSON(http.StatusOK, response)
}

View file

@ -0,0 +1,116 @@
package handlers
import (
"net/http"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/Fimeg/RedFlag/server/internal/services/upstream"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// UpstreamHandler exposes the tracked_software CRUD + sync-now action.
// The dashboard's Stack Drift widget reads from ListDrifted.
type UpstreamHandler struct {
queries *queries.UpstreamQueries
syncer *upstream.Syncer
registry *upstream.Registry
}
func NewUpstreamHandler(q *queries.UpstreamQueries, s *upstream.Syncer, r *upstream.Registry) *UpstreamHandler {
return &UpstreamHandler{queries: q, syncer: s, registry: r}
}
// List returns every tracked_software row. The dashboard panel filters
// client-side; admin pages use the full list.
func (h *UpstreamHandler) List(c *gin.Context) {
rows, err := h.queries.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tracked software"})
return
}
c.JSON(http.StatusOK, gin.H{
"software": rows,
"sources": h.registry.Names(),
})
}
// ListDrifted is the dashboard feed — only rows where current != latest.
func (h *UpstreamHandler) ListDrifted(c *gin.Context) {
rows, err := h.queries.ListDrifted()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list drifted software"})
return
}
c.JSON(http.StatusOK, gin.H{"software": rows})
}
func (h *UpstreamHandler) Create(c *gin.Context) {
var in models.TrackedSoftwareInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
return
}
if _, ok := h.registry.Get(in.Source); !ok {
c.JSON(http.StatusBadRequest, gin.H{
"error": "unknown source",
"available_sources": h.registry.Names(),
})
return
}
row, err := h.queries.Create(in)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Kick off an immediate sync so the new row gets populated without
// waiting a full tick. Fire-and-forget; the result writes to DB.
go func() {
_ = h.syncer.SyncOne(c.Request.Context(), row.ID)
}()
c.JSON(http.StatusCreated, row)
}
func (h *UpstreamHandler) Delete(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.queries.Delete(id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// SyncNow forces a sync for a single tracked_software row. Blocks on the
// fetch so the operator sees the result inline.
func (h *UpstreamHandler) SyncNow(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.syncer.SyncOne(c.Request.Context(), id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
row, err := h.queries.GetByID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, row)
}
// RecentDrift returns the most recent drift events for the dashboard.
func (h *UpstreamHandler) RecentDrift(c *gin.Context) {
events, err := h.queries.RecentDriftEvents(25)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"events": events})
}

View file

@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_upstream_drift_events_recent;
DROP INDEX IF EXISTS idx_upstream_drift_events_software;
DROP TABLE IF EXISTS upstream_drift_events;
DROP INDEX IF EXISTS idx_tracked_software_drift;
DROP INDEX IF EXISTS idx_tracked_software_enabled;
DROP TABLE IF EXISTS tracked_software;

View file

@ -0,0 +1,59 @@
-- Migration 035: Upstream version sync subsystem
--
-- Operators who own their full stack want to compare their deployed versions
-- against canonical upstream releases (Repology, endoflife.date, npm/PyPI/etc.).
-- This is parallel to OSV.dev vulnerability tracking and package-age gating —
-- a third axis on the same supply-chain surface.
--
-- The category these tools fall under: upstream release monitoring
-- (Anitya / release-monitoring.org, Repology, nvchecker, endoflife.date).
-- We bundle our own minimal version of it via pluggable ReleaseSource
-- adapters; see server/internal/services/upstream/.
CREATE TABLE IF NOT EXISTS tracked_software (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
ecosystem TEXT NOT NULL, -- 'system' | 'container' | 'language'
source TEXT NOT NULL, -- 'repology' | 'endoflife' | 'github' | 'npm' | 'pypi' | 'anitya'
source_ref TEXT NOT NULL, -- the source's identifier (e.g. "nginx", "postgres", "vercel/next.js")
current_version TEXT, -- deployed/observed locally (nullable until first observation)
latest_version TEXT, -- canonical upstream latest (filled by syncer)
latest_at TIMESTAMP, -- when upstream published latest_version
eol_at TIMESTAMP, -- nullable; from endoflife.date when applicable
last_checked_at TIMESTAMP, -- last attempted sync
last_synced_at TIMESTAMP, -- last successful sync
last_error TEXT, -- last sync error message (for visibility)
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (source, source_ref)
);
CREATE INDEX IF NOT EXISTS idx_tracked_software_enabled
ON tracked_software (enabled, last_checked_at NULLS FIRST)
WHERE enabled = TRUE;
CREATE INDEX IF NOT EXISTS idx_tracked_software_drift
ON tracked_software (latest_version, current_version)
WHERE current_version IS NOT NULL
AND latest_version IS NOT NULL
AND current_version <> latest_version;
-- Drift events are append-only. Each row records a moment when the syncer
-- observed a change in latest_version (upstream moved) so we can show a
-- timeline on the dashboard and reason about cadence.
CREATE TABLE IF NOT EXISTS upstream_drift_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tracked_software_id UUID NOT NULL REFERENCES tracked_software(id) ON DELETE CASCADE,
observed_at TIMESTAMP NOT NULL DEFAULT NOW(),
drift_severity TEXT NOT NULL, -- 'minor' | 'major' | 'eol'
from_version TEXT,
to_version TEXT,
note TEXT
);
CREATE INDEX IF NOT EXISTS idx_upstream_drift_events_software
ON upstream_drift_events (tracked_software_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_upstream_drift_events_recent
ON upstream_drift_events (observed_at DESC);

View file

@ -116,6 +116,39 @@ func (q *RegistrationTokenQueries) MarkTokenUsed(token string, agentID uuid.UUID
return nil
}
// BoundAgent is the per-seat view returned alongside a registration token —
// "which agent is in this seat?" Joins agents to the audit ledger and surfaces
// the fields an operator needs to decide whether to revoke an individual seat.
type BoundAgent struct {
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
Hostname string `json:"hostname" db:"hostname"`
OSType string `json:"os_type" db:"os_type"`
Status string `json:"status" db:"status"`
LastSeen time.Time `json:"last_seen" db:"last_seen"`
UsedAt time.Time `json:"used_at" db:"used_at"`
}
// GetAgentsBoundToToken returns the agents that consumed seats on a given
// registration token, ordered by when they registered. Powers the token-detail
// expansion in the dashboard (docs/AGENT_LIFECYCLE.md "Operator surfaces").
//
// Does not reveal credentials — just hostnames + status. Authorization is the
// caller's responsibility (route should sit behind admin middleware).
func (q *RegistrationTokenQueries) GetAgentsBoundToToken(tokenID uuid.UUID) ([]BoundAgent, error) {
var agents []BoundAgent
query := `
SELECT a.id AS agent_id, a.hostname, a.os_type, a.status, a.last_seen, u.used_at
FROM registration_token_usage u
JOIN agents a ON a.id = u.agent_id
WHERE u.token_id = $1
ORDER BY u.used_at ASC
`
if err := q.db.Select(&agents, query, tokenID); err != nil {
return nil, fmt.Errorf("failed to get agents bound to token: %w", err)
}
return agents, nil
}
// GetActiveRegistrationTokens returns all active tokens that haven't expired
func (q *RegistrationTokenQueries) GetActiveRegistrationTokens() ([]RegistrationToken, error) {
var tokens []RegistrationToken
@ -156,7 +189,15 @@ func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) (
return tokens, nil
}
// RevokeRegistrationToken revokes a token (can revoke tokens in any status)
// RevokeRegistrationToken revokes a token (can revoke tokens in any status).
//
// INVARIANT — no hidden cascade: this only flips the token row to status='revoked'.
// It deliberately does NOT touch refresh_tokens for agents that previously used
// this token to register. The registration_token and refresh_token credentials
// have separate lifecycles by design (see docs/AGENT_LIFECYCLE.md "Revocation").
// To revoke a bound agent's access, call RefreshTokenQueries.RevokeAllAgentTokens
// — that path is surfaced as an explicit per-agent operator action, not a side
// effect of revoking the issuing token.
func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string) error {
query := `
UPDATE registration_tokens

View file

@ -0,0 +1,69 @@
package queries_test
// registration_tokens_no_cascade_test.go — Lock the no-cascade invariant on
// RevokeRegistrationToken.
//
// The registration_token and refresh_token credentials are deliberately kept
// on separate lifecycles (see docs/AGENT_LIFECYCLE.md "Revocation"). Revoking
// a registration token only blocks future installs; it must NOT touch the
// refresh_tokens of agents that previously used the token to register.
//
// This test is a static check on the query text — no live database required.
// If a future change introduces a cascade (joining refresh_tokens into the
// UPDATE, deleting bound rows, calling out to revoke refresh tokens by side
// effect), this test fails loudly and points the contributor at the spec.
//
// If a cascade ever becomes the desired behavior, that is a deliberate spec
// change: update docs/AGENT_LIFECYCLE.md first, then this test, then the code.
//
// Run: cd server && go test ./internal/database/queries/... -v -run TestRevokeRegistrationToken
import (
"strings"
"testing"
)
// revokeRegistrationTokenQuery is a verbatim copy of the query in
// queries/registration_tokens.go RevokeRegistrationToken. Keep in sync.
const revokeRegistrationTokenQuery = `
UPDATE registration_tokens
SET status = 'revoked',
revoked = true,
revoked_at = NOW(),
revoked_reason = $1
WHERE token = $2
`
// cascadeIndicators lists SQL tokens that would mean RevokeRegistrationToken
// is reaching into agent credentials. Presence of ANY of these in the query
// body indicates the invariant has been violated.
var cascadeIndicators = []string{
"refresh_tokens",
"agent_id",
"DELETE",
"revoke_agent",
"RevokeAll",
}
func TestRevokeRegistrationTokenHasNoRefreshTokenCascade(t *testing.T) {
q := strings.ToLower(revokeRegistrationTokenQuery)
for _, ind := range cascadeIndicators {
if strings.Contains(q, strings.ToLower(ind)) {
t.Errorf("RevokeRegistrationToken query touches %q — this is a hidden cascade. "+
"The registration_token and refresh_token lifecycles must stay separate. "+
"See docs/AGENT_LIFECYCLE.md 'Revocation'. To revoke a bound agent, callers "+
"must invoke RefreshTokenQueries.RevokeAllAgentTokens explicitly.", ind)
}
}
}
func TestRevokeRegistrationTokenOnlyTouchesRegistrationTokensTable(t *testing.T) {
q := strings.ToLower(revokeRegistrationTokenQuery)
if !strings.Contains(q, "registration_tokens") {
t.Fatal("query no longer mentions registration_tokens — copy in this test is stale")
}
if !strings.Contains(q, "status = 'revoked'") {
t.Error("query no longer sets status='revoked' — copy in this test is stale, or the " +
"revocation behavior changed. Sync with registration_tokens.go.")
}
}

View file

@ -0,0 +1,162 @@
package queries
import (
"fmt"
"time"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type UpstreamQueries struct {
db *sqlx.DB
}
func NewUpstreamQueries(db *sqlx.DB) *UpstreamQueries {
return &UpstreamQueries{db: db}
}
func (q *UpstreamQueries) List() ([]models.TrackedSoftware, error) {
var rows []models.TrackedSoftware
err := q.db.Select(&rows, `SELECT * FROM tracked_software ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("upstream: list tracked_software: %w", err)
}
return rows, nil
}
// ListDrifted returns only rows where current_version != latest_version
// (and both are known). This is what the dashboard panel renders.
func (q *UpstreamQueries) ListDrifted() ([]models.TrackedSoftware, error) {
var rows []models.TrackedSoftware
err := q.db.Select(&rows, `
SELECT * FROM tracked_software
WHERE current_version IS NOT NULL
AND latest_version IS NOT NULL
AND current_version <> latest_version
ORDER BY (eol_at IS NOT NULL AND eol_at < NOW()) DESC,
updated_at DESC`)
if err != nil {
return nil, fmt.Errorf("upstream: list drifted: %w", err)
}
return rows, nil
}
// DueForSync returns enabled tracked_software rows whose last_checked_at is
// older than the given threshold (or never). Caller iterates and dispatches
// per source.
func (q *UpstreamQueries) DueForSync(staleAfter time.Duration, limit int) ([]models.TrackedSoftware, error) {
if limit <= 0 {
limit = 50
}
cutoff := time.Now().Add(-staleAfter)
var rows []models.TrackedSoftware
err := q.db.Select(&rows, `
SELECT * FROM tracked_software
WHERE enabled = TRUE
AND (last_checked_at IS NULL OR last_checked_at < $1)
ORDER BY last_checked_at NULLS FIRST
LIMIT $2`, cutoff, limit)
if err != nil {
return nil, fmt.Errorf("upstream: due_for_sync: %w", err)
}
return rows, nil
}
func (q *UpstreamQueries) GetByID(id uuid.UUID) (*models.TrackedSoftware, error) {
var row models.TrackedSoftware
err := q.db.Get(&row, `SELECT * FROM tracked_software WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("upstream: not found: %w", err)
}
return &row, nil
}
func (q *UpstreamQueries) Create(in models.TrackedSoftwareInput) (*models.TrackedSoftware, error) {
enabled := true
if in.Enabled != nil {
enabled = *in.Enabled
}
var row models.TrackedSoftware
err := q.db.Get(&row, `
INSERT INTO tracked_software (name, ecosystem, source, source_ref, current_version, enabled)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
in.Name, in.Ecosystem, in.Source, in.SourceRef, in.CurrentVersion, enabled)
if err != nil {
return nil, fmt.Errorf("upstream: create: %w", err)
}
return &row, nil
}
func (q *UpstreamQueries) Delete(id uuid.UUID) error {
res, err := q.db.Exec(`DELETE FROM tracked_software WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("upstream: delete: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("upstream: not found")
}
return nil
}
// ApplySyncResult writes back the result of a successful or failed fetch.
// On success: latest_version + latest_at + eol_at updated, last_error cleared.
// On failure: only last_checked_at + last_error updated (preserve stale data).
func (q *UpstreamQueries) ApplySyncResult(id uuid.UUID, latestVersion string, latestAt, eolAt *time.Time) error {
_, err := q.db.Exec(`
UPDATE tracked_software
SET latest_version = $2,
latest_at = $3,
eol_at = $4,
last_checked_at = NOW(),
last_synced_at = NOW(),
last_error = NULL,
updated_at = NOW()
WHERE id = $1`, id, latestVersion, latestAt, eolAt)
if err != nil {
return fmt.Errorf("upstream: apply_sync_result: %w", err)
}
return nil
}
func (q *UpstreamQueries) ApplySyncError(id uuid.UUID, errMsg string) error {
_, err := q.db.Exec(`
UPDATE tracked_software
SET last_checked_at = NOW(),
last_error = $2,
updated_at = NOW()
WHERE id = $1`, id, errMsg)
if err != nil {
return fmt.Errorf("upstream: apply_sync_error: %w", err)
}
return nil
}
func (q *UpstreamQueries) InsertDriftEvent(softwareID uuid.UUID, severity string, fromV, toV *string, note *string) error {
_, err := q.db.Exec(`
INSERT INTO upstream_drift_events (tracked_software_id, drift_severity, from_version, to_version, note)
VALUES ($1, $2, $3, $4, $5)`,
softwareID, severity, fromV, toV, note)
if err != nil {
return fmt.Errorf("upstream: insert_drift_event: %w", err)
}
return nil
}
func (q *UpstreamQueries) RecentDriftEvents(limit int) ([]models.UpstreamDriftEvent, error) {
if limit <= 0 {
limit = 25
}
var rows []models.UpstreamDriftEvent
err := q.db.Select(&rows, `
SELECT * FROM upstream_drift_events
ORDER BY observed_at DESC
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("upstream: recent_drift: %w", err)
}
return rows, nil
}

View file

@ -0,0 +1,50 @@
package models
import (
"time"
"github.com/google/uuid"
)
// TrackedSoftware is a single piece of software the operator wants the
// server to keep an eye on. The (source, source_ref) pair uniquely
// identifies what upstream registry to ask and what to ask it for.
type TrackedSoftware struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Ecosystem string `db:"ecosystem" json:"ecosystem"`
Source string `db:"source" json:"source"`
SourceRef string `db:"source_ref" json:"source_ref"`
CurrentVersion *string `db:"current_version" json:"current_version,omitempty"`
LatestVersion *string `db:"latest_version" json:"latest_version,omitempty"`
LatestAt *time.Time `db:"latest_at" json:"latest_at,omitempty"`
EOLAt *time.Time `db:"eol_at" json:"eol_at,omitempty"`
LastCheckedAt *time.Time `db:"last_checked_at" json:"last_checked_at,omitempty"`
LastSyncedAt *time.Time `db:"last_synced_at" json:"last_synced_at,omitempty"`
LastError *string `db:"last_error" json:"last_error,omitempty"`
Enabled bool `db:"enabled" json:"enabled"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// TrackedSoftwareInput is the create/update payload from the admin API.
type TrackedSoftwareInput struct {
Name string `json:"name" binding:"required"`
Ecosystem string `json:"ecosystem" binding:"required"`
Source string `json:"source" binding:"required"`
SourceRef string `json:"source_ref" binding:"required"`
CurrentVersion *string `json:"current_version,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// UpstreamDriftEvent records a point-in-time observation that latest_version
// moved (or that an EOL date passed). Append-only.
type UpstreamDriftEvent struct {
ID uuid.UUID `db:"id" json:"id"`
TrackedSoftwareID uuid.UUID `db:"tracked_software_id" json:"tracked_software_id"`
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
DriftSeverity string `db:"drift_severity" json:"drift_severity"`
FromVersion *string `db:"from_version" json:"from_version,omitempty"`
ToVersion *string `db:"to_version" json:"to_version,omitempty"`
Note *string `db:"note" json:"note,omitempty"`
}

View file

@ -0,0 +1,203 @@
package services
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// PackageAgeResult is what GetPackagePublishDate returns. PublishedAt is the
// upstream maintainer's release timestamp for the specific version. Source
// identifies which registry produced it.
//
// Fail-open semantics match CheckOSVVulnerabilities: nil result means we
// couldn't determine the age (registry down, ecosystem unsupported, version
// not found). Callers decide what to do with "unknown" — the gating policy
// in the approve handler treats unknown as "allow" so that infrastructure
// issues never block legitimate work.
type PackageAgeResult struct {
PublishedAt time.Time `json:"published_at"`
Source string `json:"source"`
}
var packageRegistryHTTPClient = &http.Client{Timeout: 10 * time.Second}
// GetPackagePublishDate queries the upstream registry for a specific package
// version's release timestamp. Currently supports npm (registry.npmjs.org)
// and PyPI (pypi.org). Returns nil on any failure — callers must handle that.
//
// This is intentionally separate from OSV.dev: OSV is "what's wrong with this
// version", publish-date is "how old is it." Conflating them means an OSV
// outage would also blind us to recency, which would weaken the Shai-Hulud
// defense (worm waves rely on fresh-release windows).
func GetPackagePublishDate(pkgName, ecosystem, version string) *PackageAgeResult {
switch ecosystem {
case "npm":
return fetchNpmPublishDate(pkgName, version)
case "PyPI":
return fetchPyPIPublishDate(pkgName, version)
default:
return nil
}
}
// fetchNpmPublishDate hits https://registry.npmjs.org/<name>/<version>.
// Response shape: { "time": { "<version>": "RFC3339 timestamp" }, ... }
// We request the version-specific URL but the response still includes the
// full time map; pull the requested version.
func fetchNpmPublishDate(pkgName, version string) *PackageAgeResult {
// The package name may contain a scope ("@scope/name") which must stay
// path-encoded but not URL-escaped at the slash. PathEscape on each
// segment, then join.
endpoint := fmt.Sprintf("https://registry.npmjs.org/%s", url.PathEscape(pkgName))
resp, err := packageRegistryHTTPClient.Get(endpoint)
if err != nil {
log.Printf("[WARNING] [supply_chain] npm_fetch_failed pkg=%s error=%v", pkgName, err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("[WARNING] [supply_chain] npm_status_non_ok pkg=%s status=%d", pkgName, resp.StatusCode)
return nil
}
var body struct {
Time map[string]string `json:"time"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
log.Printf("[WARNING] [supply_chain] npm_decode_failed pkg=%s error=%v", pkgName, err)
return nil
}
tsStr, ok := body.Time[version]
if !ok {
log.Printf("[WARNING] [supply_chain] npm_version_not_in_time_map pkg=%s version=%s", pkgName, version)
return nil
}
ts, err := time.Parse(time.RFC3339, tsStr)
if err != nil {
log.Printf("[WARNING] [supply_chain] npm_parse_time_failed pkg=%s version=%s ts=%q error=%v", pkgName, version, tsStr, err)
return nil
}
return &PackageAgeResult{PublishedAt: ts, Source: "registry.npmjs.org"}
}
// fetchPyPIPublishDate hits https://pypi.org/pypi/<name>/<version>/json.
// Response includes a urls[] array with upload_time_iso_8601 per dist file;
// take the earliest as the canonical publish moment for the version.
func fetchPyPIPublishDate(pkgName, version string) *PackageAgeResult {
endpoint := fmt.Sprintf("https://pypi.org/pypi/%s/%s/json", url.PathEscape(pkgName), url.PathEscape(version))
resp, err := packageRegistryHTTPClient.Get(endpoint)
if err != nil {
log.Printf("[WARNING] [supply_chain] pypi_fetch_failed pkg=%s error=%v", pkgName, err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("[WARNING] [supply_chain] pypi_status_non_ok pkg=%s status=%d", pkgName, resp.StatusCode)
return nil
}
var body struct {
URLs []struct {
UploadTimeISO string `json:"upload_time_iso_8601"`
} `json:"urls"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
log.Printf("[WARNING] [supply_chain] pypi_decode_failed pkg=%s error=%v", pkgName, err)
return nil
}
if len(body.URLs) == 0 {
log.Printf("[WARNING] [supply_chain] pypi_no_urls_for_version pkg=%s version=%s", pkgName, version)
return nil
}
var earliest time.Time
for _, u := range body.URLs {
ts, err := time.Parse(time.RFC3339Nano, u.UploadTimeISO)
if err != nil {
continue
}
if earliest.IsZero() || ts.Before(earliest) {
earliest = ts
}
}
if earliest.IsZero() {
return nil
}
return &PackageAgeResult{PublishedAt: earliest, Source: "pypi.org"}
}
// PackageAgeGateDecision is the verdict the approve handler uses to decide
// whether to block, warn, or pass an approval.
type PackageAgeGateDecision struct {
PublishedAt time.Time
AgeHours float64
MinAgeHours float64
Enforcement string // "off" | "warn" | "block"
ShouldBlock bool
WarnMessage string
Source string
Unknown bool // true when we couldn't determine the publish date — caller treats as allow
}
// PackageAgeGateConfig reads the supply_chain gate config. For v0.2.0.0 this
// pulls from env (REDFLAG_SUPPLY_CHAIN_*) with defaults baked in; the
// SecuritySettingsService DB-backed override path can layer in later — see
// security_settings_service.go getDefaultSettings "supply_chain".
//
// Defaults are intentional: 24h soak window, "warn" enforcement. The window
// is the documented threshold below which Shai-Hulud-class worms statistically
// announce themselves; warn-by-default preserves operator sovereignty.
func PackageAgeGateConfig() (minAgeHours float64, enforcement string) {
minAgeHours = 24.0
enforcement = "warn"
if v := os.Getenv("REDFLAG_SUPPLY_CHAIN_MIN_PACKAGE_AGE_HOURS"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 {
minAgeHours = f
}
}
if v := os.Getenv("REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT"); v != "" {
switch strings.ToLower(v) {
case "off", "warn", "block":
enforcement = strings.ToLower(v)
}
}
return
}
// EvaluatePackageAgeGate computes the gate decision for a single package
// version against the configured min-age threshold. Pure logic, no HTTP — the
// fetch happens once at the call site and the result is passed in.
func EvaluatePackageAgeGate(age *PackageAgeResult, minAgeHours float64, enforcement string) PackageAgeGateDecision {
dec := PackageAgeGateDecision{
MinAgeHours: minAgeHours,
Enforcement: enforcement,
}
if age == nil {
dec.Unknown = true
return dec
}
dec.PublishedAt = age.PublishedAt
dec.Source = age.Source
dec.AgeHours = time.Since(age.PublishedAt).Hours()
if dec.AgeHours >= minAgeHours {
return dec
}
// Below the threshold — surface a clear message either way.
dec.WarnMessage = fmt.Sprintf(
"package published %.1fh ago (threshold %.1fh) — Shai-Hulud-class supply chain attacks propagate inside this window",
dec.AgeHours, minAgeHours,
)
if enforcement == "block" {
dec.ShouldBlock = true
}
return dec
}

View file

@ -0,0 +1,123 @@
package services
import (
"testing"
"time"
)
// TestEvaluatePackageAgeGate exercises the gate's three-way decision matrix:
// off / warn / block × (above-threshold / below-threshold / unknown).
//
// Pure logic — no network. The registry-fetching functions are tested
// against real endpoints by integration tests when they exist; the
// EvaluatePackageAgeGate function is the policy core that determines
// approval behavior and must be deterministic.
func TestEvaluatePackageAgeGate_BelowThresholdBlock(t *testing.T) {
age := &PackageAgeResult{
PublishedAt: time.Now().Add(-2 * time.Hour),
Source: "registry.npmjs.org",
}
dec := EvaluatePackageAgeGate(age, 24.0, "block")
if !dec.ShouldBlock {
t.Errorf("expected block, got pass; decision=%+v", dec)
}
if dec.WarnMessage == "" {
t.Error("expected warn message even on block path; got empty")
}
if dec.Unknown {
t.Error("unknown should be false when age is provided")
}
}
func TestEvaluatePackageAgeGate_BelowThresholdWarn(t *testing.T) {
age := &PackageAgeResult{
PublishedAt: time.Now().Add(-2 * time.Hour),
Source: "registry.npmjs.org",
}
dec := EvaluatePackageAgeGate(age, 24.0, "warn")
if dec.ShouldBlock {
t.Errorf("warn enforcement must not block; decision=%+v", dec)
}
if dec.WarnMessage == "" {
t.Error("expected warn message under warn enforcement; got empty")
}
}
func TestEvaluatePackageAgeGate_AboveThreshold(t *testing.T) {
age := &PackageAgeResult{
PublishedAt: time.Now().Add(-72 * time.Hour),
Source: "pypi.org",
}
dec := EvaluatePackageAgeGate(age, 24.0, "block")
if dec.ShouldBlock {
t.Errorf("above-threshold package must not be blocked; decision=%+v", dec)
}
if dec.WarnMessage != "" {
t.Errorf("above-threshold must produce no warning; got %q", dec.WarnMessage)
}
}
func TestEvaluatePackageAgeGate_UnknownAgeIsAllow(t *testing.T) {
// Fail-open: when the registry probe couldn't tell us when the package
// was published (network, ecosystem unsupported, version missing), we
// must NOT block — infrastructure issues should never gate legitimate
// approvals. This is part of the sovereignty contract.
dec := EvaluatePackageAgeGate(nil, 24.0, "block")
if !dec.Unknown {
t.Error("expected unknown=true when age is nil")
}
if dec.ShouldBlock {
t.Error("unknown age must not trigger block even under block enforcement")
}
if dec.WarnMessage != "" {
t.Errorf("unknown age must not produce a warning message; got %q", dec.WarnMessage)
}
}
func TestEvaluatePackageAgeGate_OffMeansOff(t *testing.T) {
// "off" enforcement should not be reached by the gate (caller checks
// PackageAgeGateConfig and skips), but if it is, behavior must be safe:
// no block, no warn. Treat as a degenerate case of warn-without-message.
age := &PackageAgeResult{
PublishedAt: time.Now().Add(-1 * time.Hour),
Source: "registry.npmjs.org",
}
dec := EvaluatePackageAgeGate(age, 24.0, "off")
if dec.ShouldBlock {
t.Error("off enforcement must not block even below threshold")
}
}
func TestPackageAgeGateConfig_Defaults(t *testing.T) {
// Env vars cleared in this test process should yield defaults.
t.Setenv("REDFLAG_SUPPLY_CHAIN_MIN_PACKAGE_AGE_HOURS", "")
t.Setenv("REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT", "")
minAge, enf := PackageAgeGateConfig()
if minAge != 24.0 {
t.Errorf("default min age should be 24, got %v", minAge)
}
if enf != "warn" {
t.Errorf("default enforcement should be warn, got %q", enf)
}
}
func TestPackageAgeGateConfig_EnvOverride(t *testing.T) {
t.Setenv("REDFLAG_SUPPLY_CHAIN_MIN_PACKAGE_AGE_HOURS", "48")
t.Setenv("REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT", "block")
minAge, enf := PackageAgeGateConfig()
if minAge != 48.0 {
t.Errorf("env override min age should be 48, got %v", minAge)
}
if enf != "block" {
t.Errorf("env override enforcement should be block, got %q", enf)
}
}
func TestPackageAgeGateConfig_InvalidEnforcementFallsBack(t *testing.T) {
t.Setenv("REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT", "panic")
_, enf := PackageAgeGateConfig()
if enf != "warn" {
t.Errorf("invalid enforcement should fall back to warn, got %q", enf)
}
}

View file

@ -315,6 +315,16 @@ func (s *SecuritySettingsService) getDefaultSettings() map[string]map[string]int
"log_failures": true,
"alert_on_failure": true,
},
// Shai-Hulud-class defenses. min_package_age_hours is the soak window
// applied at approval time; gate_enforcement chooses how strictly to
// apply it: "off" disables the check, "warn" attaches a warning to the
// approval response (default — sovereignty principle), "block" rejects
// approval with 409. See docs (post-v0.2.0.0 writeup) for the threat
// model.
"supply_chain": {
"min_package_age_hours": 24.0,
"gate_enforcement": "warn",
},
}
}

View file

@ -116,6 +116,20 @@ else
echo "✓ User $AGENT_USER created"
fi
# Grant docker socket access so the container scanner can reach the daemon.
# Sudoers entries below only cover pull/inspect; IsAvailable() pings the
# unix socket, which is root:docker. Membership is the standard pattern.
if getent group docker >/dev/null 2>&1; then
if id -nG "$AGENT_USER" 2>/dev/null | tr ' ' '\n' | grep -qx docker; then
echo "✓ $AGENT_USER already in docker group"
else
sudo usermod -aG docker "$AGENT_USER"
echo "✓ Added $AGENT_USER to docker group (socket access for container scanner)"
fi
else
echo "[INFO] [installer] [docker] docker group absent — container scanner will report unavailable"
fi
# Create home directory structure
if [ ! -d "$AGENT_HOME" ]; then
# Create nested directory structure
@ -427,8 +441,26 @@ sudo chmod 750 "{{.AgentLogDir}}"
sudo chown {{.AgentUser}}:{{.AgentUser}} "${SERVER_KEY_DIR}"
sudo chmod 755 "${SERVER_KEY_DIR}"
# Register agent with server (if token provided)
if [ -n "{{.RegistrationToken}}" ]; then
# Decide install flow per docs/AGENT_LIFECYCLE.md:
# Fresh Install → no usable local config → call --register
# Upgrade In Place → local config has non-empty refresh_token → skip --register
# Token in URL is ignored on the upgrade path; refresh_token authenticates.
EXISTING_REFRESH_TOKEN=""
if [ -f "${AGENT_CONFIG_DIR}/config.json" ]; then
if command -v jq &>/dev/null; then
EXISTING_REFRESH_TOKEN="$(sudo jq -r '.refresh_token // ""' "${AGENT_CONFIG_DIR}/config.json" 2>/dev/null || echo "")"
else
# jq absent — fall back to a tolerant grep. The JSON is written by us
# at install time; the field is on its own line. This is intentionally
# tight, not a general JSON parser.
EXISTING_REFRESH_TOKEN="$(sudo grep -oE '"refresh_token"[[:space:]]*:[[:space:]]*"[^"]+"' "${AGENT_CONFIG_DIR}/config.json" 2>/dev/null | sed -E 's/.*"refresh_token"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')"
fi
fi
if [ -n "${EXISTING_REFRESH_TOKEN}" ]; then
echo "[INFO] [installer] [register] Upgrade in place — existing credentials detected, skipping registration"
echo "[INFO] [installer] [register] Token in URL is ignored on the upgrade path; refresh_token authenticates"
elif [ -n "{{.RegistrationToken}}" ]; then
echo "[INFO] [installer] [register] Registering agent with server..."
if sudo -u "{{.AgentUser}}" "${INSTALL_DIR}/${SERVICE_NAME}" --server "{{.ServerURL}}" --token "{{.RegistrationToken}}" --register; then
echo "[SUCCESS] [installer] [register] Agent registered successfully"

View file

@ -286,9 +286,28 @@ if (Test-Path $ConfigPath) {
Write-Host "Setting file permissions..." -ForegroundColor Yellow
icacls $ConfigPath /inheritance:r /grant:r "SYSTEM:(OI)(CI)F" /grant:r "Administrators:(OI)(CI)F" | Out-Null
# Register agent with server (if token provided)
# Decide install flow per docs/AGENT_LIFECYCLE.md:
# Fresh Install → no usable local config → call --register
# Upgrade In Place → local config has non-empty refresh_token → skip --register
# Token in URL is ignored on the upgrade path; refresh_token authenticates.
$AgentBinary = Join-Path $InstallDir "redflag-agent.exe"
if ("{{.RegistrationToken}}" -ne "") {
$ExistingRefreshToken = ""
if (Test-Path $ConfigPath) {
try {
$ExistingConfig = Get-Content -Raw -Path $ConfigPath -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if ($ExistingConfig.refresh_token) {
$ExistingRefreshToken = [string]$ExistingConfig.refresh_token
}
} catch {
# Malformed config — treat as no credentials present, fall through to register.
Write-Host "[WARN] [installer] [register] Could not parse existing config; treating as fresh install" -ForegroundColor Yellow
}
}
if ($ExistingRefreshToken -ne "") {
Write-Host "[INFO] [installer] [register] Upgrade in place - existing credentials detected, skipping registration" -ForegroundColor Cyan
Write-Host "[INFO] [installer] [register] Token in URL is ignored on the upgrade path; refresh_token authenticates" -ForegroundColor Gray
} elseif ("{{.RegistrationToken}}" -ne "") {
Write-Host "[INFO] [installer] [register] Registering agent with server..." -ForegroundColor Cyan
$RegisterProcess = Start-Process -FilePath $AgentBinary -ArgumentList "--server", "{{.ServerURL}}", "--token", "{{.RegistrationToken}}", "--register" -Wait -PassThru -NoNewWindow
if ($RegisterProcess.ExitCode -eq 0) {

View file

@ -367,19 +367,34 @@ func (ts *TimeoutService) reconcileAgentUpdates() {
continue
}
// Timeout — version never matched. Clear the flag so the operator can retry.
// Timeout — version never matched. The new binary either never started
// or never reached the server. Clear the flag so the operator can retry;
// surface remediation context because automatic rollback from .bak is
// not available across the systemd restart boundary (the deferred
// rollback in agent_update.go cannot survive SIGTERM).
if err := ts.agentQueries.ClearAgentUpdating(agent.ID); err != nil {
log.Printf("[ERROR] [server] [timeout] clear_updating_failed agent_id=%s error=%v", agent.ID, err)
continue
}
ageMessage := fmt.Sprintf("Agent update timed out after %v without version attestation (current=%s, target=%s)",
ts.updateTimeout, agent.CurrentVersion, target)
ts.recordUpdateEvent(agent.ID, "timed_out", "warning", ageMessage,
backupHint := expectedBackupPath(agent.OSType)
silentSince := ""
if agent.UpdateInitiatedAt != nil && agent.LastSeen.Before(*agent.UpdateInitiatedAt) {
silentSince = fmt.Sprintf(" agent has not checked in since update was initiated at %s.", agent.UpdateInitiatedAt.UTC().Format(time.RFC3339))
}
ageMessage := fmt.Sprintf(
"Agent update timed out after %v without version attestation (current=%s, target=%s).%s Manual rollback may be required: restore %s on the agent host and restart the service.",
ts.updateTimeout, agent.CurrentVersion, target, silentSince, backupHint,
)
ts.recordUpdateEvent(agent.ID, "timed_out", "error", ageMessage,
map[string]interface{}{
"current_version": agent.CurrentVersion,
"target_version": target,
"threshold": ts.updateTimeout.String(),
"reconciled_by": "timeout_service",
"current_version": agent.CurrentVersion,
"target_version": target,
"threshold": ts.updateTimeout.String(),
"reconciled_by": "timeout_service",
"backup_path_hint": backupHint,
"agent_silent": agent.UpdateInitiatedAt != nil && agent.LastSeen.Before(*agent.UpdateInitiatedAt),
"last_seen": agent.LastSeen.UTC().Format(time.RFC3339),
"update_initiated_at": formatTimePtr(agent.UpdateInitiatedAt),
})
timeouts++
}
@ -388,6 +403,25 @@ func (ts *TimeoutService) reconcileAgentUpdates() {
len(stuck), successes, timeouts, ts.updateTimeout)
}
// expectedBackupPath returns the canonical .bak path written by the agent
// installer for a given OS. Used in operator-facing remediation messages when
// reconcileAgentUpdates can't confirm the new binary is alive.
func expectedBackupPath(osType string) string {
switch osType {
case "windows":
return `C:\Program Files\RedFlag\redflag-agent.exe.bak`
default:
return "/usr/local/bin/redflag-agent.bak"
}
}
func formatTimePtr(t *time.Time) interface{} {
if t == nil {
return nil
}
return t.UTC().Format(time.RFC3339)
}
// recordUpdateEvent writes a system_event row for an update lifecycle transition. Best
// effort — a failure to log shouldn't block the reconciler from continuing on the next agent.
func (ts *TimeoutService) recordUpdateEvent(agentID uuid.UUID, subtype, severity, message string, metadata map[string]interface{}) {

View file

@ -0,0 +1,112 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// EndOfLife adapter — queries https://endoflife.date/api/{product}.json.
// Each entry is a release cycle; the first entry is canonical "current."
// source_ref is the endoflife.date product slug (e.g. "postgresql", "nginx",
// "nodejs", "ubuntu"). See https://endoflife.date/docs/api.
//
// What this adapter contributes over Repology: an EOL date. Most operators
// don't actually care about being one minor behind upstream — they care
// about being on a branch that's about to stop getting security fixes.
type EndOfLife struct{}
func NewEndOfLife() *EndOfLife { return &EndOfLife{} }
func (EndOfLife) Name() string { return "endoflife" }
// Cycle is what the endoflife.date API returns per release line. The "eol"
// and "releaseDate" fields can be either an ISO-8601 date string or a
// boolean (e.g. "eol": false on a still-supported branch). Capture both
// shapes via json.RawMessage and decode lazily.
type cycle struct {
Cycle string `json:"cycle"`
ReleaseDate json.RawMessage `json:"releaseDate"`
EOL json.RawMessage `json:"eol"`
Latest string `json:"latest"`
LatestRelease json.RawMessage `json:"latestReleaseDate"`
}
func (EndOfLife) Fetch(ctx context.Context, ref string) (*Release, error) {
if ref == "" {
return nil, fmt.Errorf("endoflife: empty source_ref")
}
endpoint := fmt.Sprintf("https://endoflife.date/api/%s.json", url.PathEscape(strings.ToLower(ref)))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("endoflife: build request: %w", err)
}
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://github.com/Fimeg/RedFlag)")
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("endoflife: fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("endoflife: product %q not found", ref)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("endoflife: status %d for %q", resp.StatusCode, ref)
}
var cycles []cycle
if err := json.NewDecoder(resp.Body).Decode(&cycles); err != nil {
return nil, fmt.Errorf("endoflife: decode: %w", err)
}
if len(cycles) == 0 {
return nil, fmt.Errorf("endoflife: empty cycle list for %q", ref)
}
// First entry is the newest release line. "latest" is the specific
// version at the head of that line.
head := cycles[0]
version := head.Latest
if version == "" {
version = head.Cycle
}
published := decodeMaybeDate(head.LatestRelease)
if published == nil {
published = decodeMaybeDate(head.ReleaseDate)
}
eol := decodeMaybeDate(head.EOL)
return &Release{
Version: version,
PublishedAt: published,
EOLAt: eol,
SourceURL: fmt.Sprintf("https://endoflife.date/%s", url.PathEscape(strings.ToLower(ref))),
}, nil
}
// decodeMaybeDate handles endoflife.date's quirk of returning either a
// date string or a boolean for the same field. Boolean false / true (with
// no date attached) collapse to nil — we don't know when, only that there
// is or isn't an EOL.
func decodeMaybeDate(raw json.RawMessage) *time.Time {
if len(raw) == 0 {
return nil
}
var s string
if err := json.Unmarshal(raw, &s); err == nil && s != "" {
// API uses "2026-11-01" form most often.
for _, layout := range []string{"2006-01-02", time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return &t
}
}
}
return nil
}

View file

@ -0,0 +1,94 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
)
// Repology adapter — queries https://repology.org/api/v1/project/{name},
// which returns an array of package observations across every distro /
// upstream channel Repology indexes. We collapse it to "the newest version
// observed across all sources" which is the closest analog to "canonical
// upstream latest." See https://repology.org/api/v1.
//
// source_ref is the Repology project name (lowercase, lower-kebab). For
// example "nginx", "postgresql", "node". Some projects use a normalized
// slug that differs from the package name — operators paste the Repology
// URL slug literally.
type Repology struct{}
func NewRepology() *Repology { return &Repology{} }
func (Repology) Name() string { return "repology" }
type repologyEntry struct {
Repo string `json:"repo"`
Version string `json:"version"`
Status string `json:"status"` // "newest" | "outdated" | "devel" | ...
OrigVersion string `json:"origversion"`
}
func (r Repology) Fetch(ctx context.Context, ref string) (*Release, error) {
if ref == "" {
return nil, fmt.Errorf("repology: empty source_ref")
}
endpoint := fmt.Sprintf("https://repology.org/api/v1/project/%s", url.PathEscape(strings.ToLower(ref)))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("repology: build request: %w", err)
}
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://github.com/Fimeg/RedFlag)")
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("repology: fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("repology: project %q not found", ref)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("repology: status %d for %q", resp.StatusCode, ref)
}
var entries []repologyEntry
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
return nil, fmt.Errorf("repology: decode: %w", err)
}
if len(entries) == 0 {
return nil, fmt.Errorf("repology: no observations for %q", ref)
}
// Repology marks the canonical winner with status "newest". Prefer it.
// Fall back to the lexicographically-highest "devel" or "unique"; finally
// to entries[0] (Repology pre-sorts). We don't try to be cleverer than
// Repology itself — it already did the version comparison work.
for _, e := range entries {
if e.Status == "newest" && e.Version != "" {
return &Release{
Version: e.Version,
SourceURL: fmt.Sprintf("https://repology.org/project/%s/versions", url.PathEscape(strings.ToLower(ref))),
}, nil
}
}
for _, e := range entries {
if (e.Status == "devel" || e.Status == "unique") && e.Version != "" {
log.Printf("[INFO] [upstream] [repology] no 'newest' for %s, using %s (%s)", ref, e.Version, e.Status)
return &Release{
Version: e.Version,
SourceURL: fmt.Sprintf("https://repology.org/project/%s/versions", url.PathEscape(strings.ToLower(ref))),
}, nil
}
}
return &Release{
Version: entries[0].Version,
SourceURL: fmt.Sprintf("https://repology.org/project/%s/versions", url.PathEscape(strings.ToLower(ref))),
}, nil
}

View file

@ -0,0 +1,73 @@
// Package upstream tracks canonical upstream releases for software the
// operator has chosen to monitor. The category name in the wild is
// "upstream release monitoring" — see Anitya (release-monitoring.org),
// Repology, nvchecker, and endoflife.date.
//
// Each upstream registry is wrapped behind ReleaseSource so the syncer can
// dispatch by source name without knowing the registry's wire format.
// Fail-open semantics mirror OSV and package-age: if a fetch fails we
// surface the error on tracked_software.last_error but do NOT mutate
// latest_version (stale-is-better-than-wrong).
package upstream
import (
"context"
"net/http"
"sync"
"time"
)
// Release is the normalized shape every adapter returns. Source-specific
// shape lives inside each adapter and is collapsed here.
type Release struct {
Version string
PublishedAt *time.Time
EOLAt *time.Time // nil unless the source supplies one (endoflife.date)
SourceURL string
}
// ReleaseSource is the pluggable contract every registry adapter implements.
// Name() must be stable — it's the value stored in tracked_software.source
// and used to look up the adapter at sync time.
type ReleaseSource interface {
Name() string
Fetch(ctx context.Context, ref string) (*Release, error)
}
// Registry holds the live adapters. Adapters self-register at construction
// time so main.go composes the set explicitly.
type Registry struct {
mu sync.RWMutex
sources map[string]ReleaseSource
}
func NewRegistry() *Registry {
return &Registry{sources: make(map[string]ReleaseSource)}
}
func (r *Registry) Register(s ReleaseSource) {
r.mu.Lock()
defer r.mu.Unlock()
r.sources[s.Name()] = s
}
func (r *Registry) Get(name string) (ReleaseSource, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
s, ok := r.sources[name]
return s, ok
}
func (r *Registry) Names() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.sources))
for n := range r.sources {
names = append(names, n)
}
return names
}
// httpClient is shared across adapters so connection pooling holds and one
// adapter can't starve the others on a slow registry.
var httpClient = &http.Client{Timeout: 15 * time.Second}

View file

@ -0,0 +1,169 @@
package upstream
import (
"context"
"log"
"strings"
"time"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/google/uuid"
)
// Syncer walks tracked_software at a configurable interval, dispatches each
// row to the appropriate ReleaseSource adapter, writes the result back, and
// emits a drift event when latest_version moves.
//
// Scoping note: this is the v0.2.0.0 scaffold. The loop deliberately makes
// one HTTP request per row sequentially. When the list grows past ~50 we
// should pool, batch, and respect Repology's rate hints (Retry-After). For
// now: simple, observable, correct.
type Syncer struct {
queries *queries.UpstreamQueries
registry *Registry
interval time.Duration
staleness time.Duration
batch int
shutdown chan struct{}
}
func NewSyncer(q *queries.UpstreamQueries, r *Registry, interval, staleness time.Duration, batch int) *Syncer {
if interval <= 0 {
interval = time.Hour
}
if staleness <= 0 {
staleness = 6 * time.Hour
}
if batch <= 0 {
batch = 50
}
return &Syncer{
queries: q,
registry: r,
interval: interval,
staleness: staleness,
batch: batch,
shutdown: make(chan struct{}),
}
}
func (s *Syncer) Start(ctx context.Context) {
go s.loop(ctx)
}
func (s *Syncer) Stop() {
close(s.shutdown)
}
func (s *Syncer) loop(ctx context.Context) {
log.Printf("[INFO] [upstream] [syncer] started interval=%s staleness=%s batch=%d sources=%v",
s.interval, s.staleness, s.batch, s.registry.Names())
// Run once immediately so a freshly-added row gets synced without
// waiting a full interval. Then settle into the cadence.
s.tick(ctx)
t := time.NewTicker(s.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
log.Printf("[INFO] [upstream] [syncer] context cancelled, stopping")
return
case <-s.shutdown:
log.Printf("[INFO] [upstream] [syncer] shutdown received, stopping")
return
case <-t.C:
s.tick(ctx)
}
}
}
func (s *Syncer) tick(ctx context.Context) {
rows, err := s.queries.DueForSync(s.staleness, s.batch)
if err != nil {
log.Printf("[ERROR] [upstream] [syncer] due_for_sync failed: %v", err)
return
}
if len(rows) == 0 {
return
}
log.Printf("[INFO] [upstream] [syncer] processing %d due rows", len(rows))
for _, row := range rows {
s.syncOne(ctx, row)
}
}
// SyncOne performs an on-demand sync for a single row (called by the
// admin "sync now" endpoint). Returns the error so the handler can surface
// it to the operator instead of only the DB.
func (s *Syncer) SyncOne(ctx context.Context, id uuid.UUID) error {
row, err := s.queries.GetByID(id)
if err != nil {
return err
}
s.syncOne(ctx, *row)
return nil
}
func (s *Syncer) syncOne(ctx context.Context, row models.TrackedSoftware) {
source, ok := s.registry.Get(row.Source)
if !ok {
msg := "unknown source: " + row.Source
log.Printf("[WARN] [upstream] [syncer] %s for %s/%s", msg, row.Name, row.SourceRef)
_ = s.queries.ApplySyncError(row.ID, msg)
return
}
fetchCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
release, err := source.Fetch(fetchCtx, row.SourceRef)
if err != nil {
log.Printf("[WARN] [upstream] [syncer] fetch failed source=%s ref=%s err=%v", row.Source, row.SourceRef, err)
if dbErr := s.queries.ApplySyncError(row.ID, err.Error()); dbErr != nil {
log.Printf("[ERROR] [upstream] [syncer] could not record fetch error: %v", dbErr)
}
return
}
priorLatest := ""
if row.LatestVersion != nil {
priorLatest = *row.LatestVersion
}
if err := s.queries.ApplySyncResult(row.ID, release.Version, release.PublishedAt, release.EOLAt); err != nil {
log.Printf("[ERROR] [upstream] [syncer] apply_sync_result failed: %v", err)
return
}
// Emit drift event when latest_version actually moved. The cheap heuristic
// for severity: major if the first dotted segment changed, else minor.
// "eol" overrides if endoflife.date now says the deployed version's branch
// is past EOL.
if priorLatest != "" && priorLatest != release.Version {
severity := classifyDrift(priorLatest, release.Version)
from, to := priorLatest, release.Version
var note *string
if release.EOLAt != nil && row.CurrentVersion != nil && release.EOLAt.Before(time.Now()) {
severity = "eol"
n := "deployed branch past upstream EOL"
note = &n
}
if err := s.queries.InsertDriftEvent(row.ID, severity, &from, &to, note); err != nil {
log.Printf("[ERROR] [upstream] [syncer] insert_drift_event failed: %v", err)
} else {
log.Printf("[INFO] [upstream] [syncer] drift name=%s severity=%s %s -> %s", row.Name, severity, from, release.Version)
}
}
}
func classifyDrift(from, to string) string {
fParts := strings.SplitN(from, ".", 2)
tParts := strings.SplitN(to, ".", 2)
if len(fParts) > 0 && len(tParts) > 0 && fParts[0] != tParts[0] {
return "major"
}
return "minor"
}

View file

@ -0,0 +1,95 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { GitBranch, AlertOctagon, ArrowRight } from 'lucide-react';
import { useDriftedSoftware } from '@/hooks/useUpstream';
import { TrackedSoftware } from '@/types';
// StackDriftPanel surfaces software whose deployed version doesn't match
// the canonical upstream release that the syncer pulled from Repology /
// endoflife.date. Past-EOL rows surface first, then everything else by
// most-recently-updated.
const StackDriftPanel: React.FC = () => {
const { data: drifted, isPending } = useDriftedSoftware();
const drift: TrackedSoftware[] = drifted ?? [];
const eolCount = drift.filter((s) => s.eol_at && new Date(s.eol_at) < new Date()).length;
const worst3 = drift.slice(0, 3);
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-gray-900">Stack Drift</h2>
<GitBranch className="h-5 w-5 text-gray-400" />
</div>
{isPending ? (
<div className="text-center py-6">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
</div>
) : drift.length === 0 ? (
<div className="text-center py-6">
<p className="text-sm text-gray-600">
Nothing being tracked yet, or everything matches upstream.
</p>
<Link
to="/settings/upstream"
className="mt-3 inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
>
Track something <ArrowRight className="w-4 h-4" />
</Link>
</div>
) : (
<>
<div className="flex items-baseline gap-6 mb-4">
<div>
<p className="text-3xl font-bold text-gray-900">{drift.length}</p>
<p className="text-xs text-gray-600">behind upstream</p>
</div>
{eolCount > 0 && (
<div>
<p className="text-3xl font-bold text-red-600">{eolCount}</p>
<p className="text-xs text-red-600">past EOL</p>
</div>
)}
</div>
<ul className="space-y-2">
{worst3.map((s) => {
const eolPassed = s.eol_at && new Date(s.eol_at) < new Date();
return (
<li
key={s.id}
className={`flex items-center justify-between p-2 rounded ${
eolPassed ? 'bg-red-50 border border-red-200' : 'bg-gray-50'
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{eolPassed && <AlertOctagon className="w-4 h-4 text-red-600 flex-shrink-0" />}
<span className="text-sm font-medium text-gray-900 truncate">{s.name}</span>
<span className="text-xs text-gray-400">({s.source})</span>
</div>
<p className="text-xs text-gray-600 mt-0.5 font-mono">
{s.current_version ?? '?'} {s.latest_version ?? '?'}
</p>
</div>
</li>
);
})}
</ul>
{drift.length > 3 && (
<Link
to="/settings/upstream"
className="mt-3 inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
>
View all {drift.length} <ArrowRight className="w-4 h-4" />
</Link>
)}
</>
)}
</div>
);
};
export default StackDriftPanel;

View file

@ -1,25 +1,19 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { adminApi } from '@/lib/api';
import {
RateLimitConfig,
} from '@/types';
import { RateLimitSettings } from '@/types';
// Query keys
export const rateLimitKeys = {
all: ['rate-limits'] as const,
configs: () => [...rateLimitKeys.all, 'configs'] as const,
settings: () => [...rateLimitKeys.all, 'settings'] as const,
stats: () => [...rateLimitKeys.all, 'stats'] as const,
usage: () => [...rateLimitKeys.all, 'usage'] as const,
summary: () => [...rateLimitKeys.all, 'summary'] as const,
};
// Hooks
export const useRateLimitConfigs = () => {
export const useRateLimitSettings = () => {
return useQuery({
queryKey: rateLimitKeys.configs(),
queryFn: () => adminApi.rateLimits.getConfigs(),
staleTime: 1000 * 60 * 5, // 5 minutes
queryKey: rateLimitKeys.settings(),
queryFn: () => adminApi.rateLimits.getSettings(),
staleTime: 1000 * 60 * 5,
});
};
@ -27,84 +21,42 @@ export const useRateLimitStats = () => {
return useQuery({
queryKey: rateLimitKeys.stats(),
queryFn: () => adminApi.rateLimits.getStats(),
staleTime: 1000 * 30, // 30 seconds
refetchInterval: 1000 * 30, // Refresh every 30 seconds for real-time monitoring
staleTime: 1000 * 30,
refetchInterval: 1000 * 30,
});
};
export const useRateLimitUsage = () => {
return useQuery({
queryKey: rateLimitKeys.usage(),
queryFn: () => adminApi.rateLimits.getUsage(),
staleTime: 1000 * 15, // 15 seconds
refetchInterval: 1000 * 15, // Refresh every 15 seconds for live usage
});
};
export const useRateLimitSummary = () => {
return useQuery({
queryKey: rateLimitKeys.summary(),
queryFn: () => adminApi.rateLimits.getSummary(),
staleTime: 1000 * 60, // 1 minute
refetchInterval: 1000 * 60, // Refresh every minute
});
};
export const useUpdateRateLimitConfig = () => {
export const useUpdateRateLimitSettings = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ endpoint, config }: { endpoint: string; config: Partial<RateLimitConfig> }) =>
adminApi.rateLimits.updateConfig(endpoint, config),
onSuccess: (_, { endpoint }) => {
toast.success(`Rate limit configuration for ${endpoint} updated successfully`);
queryClient.invalidateQueries({ queryKey: rateLimitKeys.configs() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.stats() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.usage() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.summary() });
},
onError: (error: any, { endpoint }) => {
console.error(`Failed to update rate limit config for ${endpoint}:`, error);
toast.error(error.response?.data?.message || `Failed to update rate limit configuration for ${endpoint}`);
},
});
};
export const useUpdateAllRateLimitConfigs = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (configs: RateLimitConfig[]) =>
adminApi.rateLimits.updateAllConfigs(configs),
mutationFn: (settings: RateLimitSettings) =>
adminApi.rateLimits.updateSettings(settings),
onSuccess: () => {
toast.success('All rate limit configurations updated successfully');
queryClient.invalidateQueries({ queryKey: rateLimitKeys.configs() });
toast.success('Rate limit settings saved');
queryClient.invalidateQueries({ queryKey: rateLimitKeys.settings() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.stats() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.usage() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.summary() });
},
onError: (error: any) => {
console.error('Failed to update rate limit configurations:', error);
toast.error(error.response?.data?.message || 'Failed to update rate limit configurations');
console.error('Failed to update rate limit settings:', error);
toast.error(error.response?.data?.error || 'Failed to update rate limit settings');
},
});
};
export const useResetRateLimitConfigs = () => {
export const useResetRateLimitSettings = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => adminApi.rateLimits.resetConfigs(),
mutationFn: () => adminApi.rateLimits.resetSettings(),
onSuccess: () => {
toast.success('Rate limit configurations reset to defaults successfully');
queryClient.invalidateQueries({ queryKey: rateLimitKeys.configs() });
toast.success('Rate limit settings reset to defaults');
queryClient.invalidateQueries({ queryKey: rateLimitKeys.settings() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.stats() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.usage() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.summary() });
},
onError: (error: any) => {
console.error('Failed to reset rate limit configurations:', error);
toast.error(error.response?.data?.message || 'Failed to reset rate limit configurations');
console.error('Failed to reset rate limit settings:', error);
toast.error(error.response?.data?.error || 'Failed to reset rate limit settings');
},
});
};
@ -114,15 +66,13 @@ export const useCleanupRateLimits = () => {
return useMutation({
mutationFn: () => adminApi.rateLimits.cleanup(),
onSuccess: (result) => {
toast.success(`Cleaned up ${result.cleaned} expired rate limit entries`);
onSuccess: () => {
toast.success('Rate limit entries cleanup completed');
queryClient.invalidateQueries({ queryKey: rateLimitKeys.stats() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.usage() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.summary() });
},
onError: (error: any) => {
console.error('Failed to cleanup rate limits:', error);
toast.error(error.response?.data?.message || 'Failed to cleanup rate limits');
toast.error(error.response?.data?.error || 'Failed to cleanup rate limits');
},
});
};
};

View file

@ -0,0 +1,77 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { adminApi } from '@/lib/api';
import { CreateTrackedSoftwareRequest } from '@/types';
export const upstreamKeys = {
all: ['upstream'] as const,
list: () => [...upstreamKeys.all, 'list'] as const,
drift: () => [...upstreamKeys.all, 'drift'] as const,
events: () => [...upstreamKeys.all, 'events'] as const,
};
export const useTrackedSoftware = () => {
return useQuery({
queryKey: upstreamKeys.list(),
queryFn: () => adminApi.upstream.list(),
staleTime: 1000 * 60,
});
};
export const useDriftedSoftware = () => {
return useQuery({
queryKey: upstreamKeys.drift(),
queryFn: () => adminApi.upstream.listDrifted(),
staleTime: 1000 * 60,
refetchInterval: 1000 * 60 * 5,
});
};
export const useRecentDriftEvents = () => {
return useQuery({
queryKey: upstreamKeys.events(),
queryFn: () => adminApi.upstream.recentDriftEvents(),
staleTime: 1000 * 60,
});
};
export const useAddTrackedSoftware = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateTrackedSoftwareRequest) => adminApi.upstream.create(input),
onSuccess: (row) => {
toast.success(`Tracking ${row.name} — initial sync queued`);
qc.invalidateQueries({ queryKey: upstreamKeys.all });
},
onError: (err: any) => {
toast.error(err.response?.data?.error || 'Failed to add tracked software');
},
});
};
export const useRemoveTrackedSoftware = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.upstream.remove(id),
onSuccess: () => {
toast.success('Removed from tracking');
qc.invalidateQueries({ queryKey: upstreamKeys.all });
},
onError: (err: any) => {
toast.error(err.response?.data?.error || 'Failed to remove tracked software');
},
});
};
export const useSyncTrackedSoftware = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.upstream.syncNow(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: upstreamKeys.all });
},
onError: (err: any) => {
toast.error(err.response?.data?.error || 'Sync failed');
},
});
};

View file

@ -15,10 +15,11 @@ import {
RegistrationToken,
CreateRegistrationTokenRequest,
RegistrationTokenStats,
RateLimitConfig,
RateLimitStats,
RateLimitUsage,
RateLimitSummary,
RateLimitSettings,
RateLimitStatsResponse,
TrackedSoftware,
CreateTrackedSoftwareRequest,
UpstreamDriftEvent,
AgentSubsystem,
SubsystemConfig,
SubsystemStats
@ -683,64 +684,62 @@ export const adminApi = {
},
},
// Upstream version sync (Repology + endoflife.date)
upstream: {
list: async (): Promise<{ software: TrackedSoftware[]; sources: string[] }> => {
const response = await api.get('/admin/upstream');
return response.data;
},
listDrifted: async (): Promise<TrackedSoftware[]> => {
const response = await api.get('/admin/upstream/drift');
return response.data.software ?? [];
},
recentDriftEvents: async (): Promise<UpstreamDriftEvent[]> => {
const response = await api.get('/admin/upstream/drift/events');
return response.data.events ?? [];
},
create: async (input: CreateTrackedSoftwareRequest): Promise<TrackedSoftware> => {
const response = await api.post('/admin/upstream', input);
return response.data;
},
remove: async (id: string): Promise<void> => {
await api.delete(`/admin/upstream/${id}`);
},
syncNow: async (id: string): Promise<TrackedSoftware> => {
const response = await api.post(`/admin/upstream/${id}/sync`);
return response.data;
},
},
// Rate Limiting Management
rateLimits: {
// Get all rate limit configurations
getConfigs: async (): Promise<RateLimitConfig[]> => {
getSettings: async (): Promise<RateLimitSettings> => {
const response = await api.get('/admin/rate-limits');
// Backend returns { settings: {...}, updated_at: "..." }
// Transform settings object to array format expected by frontend
const settings = response.data.settings || {};
const configs: RateLimitConfig[] = Object.entries(settings).map(([endpoint, config]: [string, any]) => ({
...config,
endpoint,
updated_at: response.data.updated_at, // Preserve update timestamp
}));
return configs;
return response.data.settings;
},
// Update rate limit configuration
updateConfig: async (endpoint: string, config: Partial<RateLimitConfig>): Promise<RateLimitConfig> => {
const response = await api.put(`/admin/rate-limits/${endpoint}`, config);
return response.data;
updateSettings: async (settings: RateLimitSettings): Promise<RateLimitSettings> => {
const response = await api.put('/admin/rate-limits', settings);
return response.data.settings;
},
// Update all rate limit configurations
updateAllConfigs: async (configs: RateLimitConfig[]): Promise<RateLimitConfig[]> => {
const response = await api.put('/admin/rate-limits', { configs });
return response.data;
},
// Reset rate limit configurations to defaults
resetConfigs: async (): Promise<RateLimitConfig[]> => {
resetSettings: async (): Promise<RateLimitSettings> => {
const response = await api.post('/admin/rate-limits/reset');
return response.data;
return response.data.settings;
},
// Get rate limit statistics
getStats: async (): Promise<RateLimitStats[]> => {
getStats: async (): Promise<RateLimitStatsResponse> => {
const response = await api.get('/admin/rate-limits/stats');
return response.data;
},
// Get rate limit usage
getUsage: async (): Promise<RateLimitUsage[]> => {
const response = await api.get('/admin/rate-limits/usage');
return response.data;
},
// Get rate limit summary
getSummary: async (): Promise<RateLimitSummary> => {
const response = await api.get('/admin/rate-limits/summary');
return response.data;
},
// Cleanup expired rate limit data
cleanup: async (): Promise<{ cleaned: number }> => {
const response = await api.post('/admin/rate-limits/cleanup');
return response.data;
cleanup: async (): Promise<void> => {
await api.post('/admin/rate-limits/cleanup');
},
},

View file

@ -12,6 +12,7 @@ import {
import { useDashboardStats } from '@/hooks/useStats';
import { useServerKeySecurity } from '@/hooks/useSecurity';
import StackDriftPanel from '@/components/StackDriftPanel';
const Dashboard: React.FC = () => {
const { data: stats, isPending, error } = useDashboardStats();
@ -139,6 +140,10 @@ const Dashboard: React.FC = () => {
})}
</div>
<div className="mb-8">
<StackDriftPanel />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Severity breakdown */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">

View file

@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Shield,
@ -6,137 +6,128 @@ import {
Save,
RotateCcw,
Activity,
TrendingUp,
BarChart3,
Settings as SettingsIcon,
Eye,
Search,
} from 'lucide-react';
import {
useRateLimitConfigs,
useRateLimitSettings,
useRateLimitStats,
useRateLimitUsage,
useRateLimitSummary,
useUpdateAllRateLimitConfigs,
useResetRateLimitConfigs,
useCleanupRateLimits
useUpdateRateLimitSettings,
useResetRateLimitSettings,
useCleanupRateLimits,
} from '../hooks/useRateLimits';
import { RateLimitConfig } from '@/types';
import { RateLimitCategory, RateLimitSettings } from '@/types';
// Helper function to format date/time strings
const formatDateTime = (dateString: string): string => {
try {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch (error) {
return dateString;
}
};
const NS_PER_SECOND = 1_000_000_000;
interface CategoryMeta {
key: RateLimitCategory;
title: string;
description: string;
routesNote: string;
}
const CATEGORIES: CategoryMeta[] = [
{
key: 'agent_registration',
title: 'Agent Registration',
description: 'New agents enrolling with the server. Low limit prevents enrollment floods.',
routesNote: 'POST /agents/register',
},
{
key: 'agent_checkin',
title: 'Agent Check-In',
description: 'Agents polling for queued commands. Raise this if you operate over high-latency links and want faster command pickup.',
routesNote: 'GET /agents/:id/commands',
},
{
key: 'agent_reports',
title: 'Agent Reports',
description: 'Telemetry uploads — update lists, system info, metrics, events, docker images.',
routesNote: 'POST /agents/:id/{updates,system-info,metrics,events,...}',
},
{
key: 'admin_token_generation',
title: 'Admin: Token Generation',
description: 'Creating new registration tokens. Low limit guards the enrollment-credential surface.',
routesNote: 'POST /admin/registration-tokens',
},
{
key: 'admin_operations',
title: 'Admin: General Operations',
description: 'All other admin API calls — viewing agents, dashboards, settings.',
routesNote: 'most /admin/* routes',
},
{
key: 'public_access',
title: 'Public Access',
description: 'Unauthenticated and web routes — login, public key, system info, agent downloads.',
routesNote: '/auth/login, /public-key, /downloads/:platform, /install/:platform',
},
];
const RateLimiting: React.FC = () => {
const navigate = useNavigate();
const [editingMode, setEditingMode] = useState(false);
const [editingConfigs, setEditingConfigs] = useState<RateLimitConfig[]>([]);
const [showAdvanced, setShowAdvanced] = useState(false);
// Search and filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
// Queries
const { data: configs, refetch: refetchConfigs } = useRateLimitConfigs();
const { data: settings, refetch: refetchSettings, isLoading } = useRateLimitSettings();
const { data: stats } = useRateLimitStats();
const { data: usage } = useRateLimitUsage();
const { data: summary } = useRateLimitSummary();
const updateSettings = useUpdateRateLimitSettings();
const resetSettings = useResetRateLimitSettings();
const cleanup = useCleanupRateLimits();
// Mutations
const updateAllConfigs = useUpdateAllRateLimitConfigs();
const resetConfigs = useResetRateLimitConfigs();
const cleanupLimits = useCleanupRateLimits();
const [editing, setEditing] = useState<RateLimitSettings | null>(null);
const [dirty, setDirty] = useState(false);
React.useEffect(() => {
if (configs && Array.isArray(configs)) {
setEditingConfigs([...configs]);
useEffect(() => {
if (settings) {
setEditing(JSON.parse(JSON.stringify(settings)));
setDirty(false);
}
}, [configs]);
}, [settings]);
// Filtered configurations for display
const filteredConfigs = useMemo(() => {
if (!configs || !Array.isArray(configs)) return [];
return configs.filter((config) => {
const matchesSearch = searchTerm === '' ||
config.endpoint.toLowerCase().includes(searchTerm.toLowerCase()) ||
config.method.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' ||
(statusFilter === 'enabled' && config.enabled) ||
(statusFilter === 'disabled' && !config.enabled);
return matchesSearch && matchesStatus;
});
}, [configs, searchTerm, statusFilter]);
const handleConfigChange = (index: number, field: keyof RateLimitConfig, value: any) => {
const updatedConfigs = [...editingConfigs];
updatedConfigs[index] = { ...updatedConfigs[index], [field]: value };
setEditingConfigs(updatedConfigs);
const handleField = (
cat: RateLimitCategory,
field: 'requests' | 'window' | 'enabled',
value: number | boolean,
) => {
if (!editing) return;
const next = { ...editing, [cat]: { ...editing[cat], [field]: value } };
setEditing(next);
setDirty(true);
};
const handleSaveAllConfigs = () => {
updateAllConfigs.mutate(editingConfigs, {
onSuccess: () => {
setEditingMode(false);
refetchConfigs();
}
});
const handleSave = () => {
if (!editing) return;
updateSettings.mutate(editing, { onSuccess: () => setDirty(false) });
};
const handleResetConfigs = () => {
if (confirm('Reset all rate limit configurations to defaults? This will overwrite your custom settings.')) {
resetConfigs.mutate(undefined, {
onSuccess: () => {
setEditingMode(false);
refetchConfigs();
}
});
}
const handleReset = () => {
if (!confirm('Reset all rate limits to defaults? Any custom values will be lost.')) return;
resetSettings.mutate();
};
const handleCleanup = () => {
if (confirm('Clean up expired rate limit data?')) {
cleanupLimits.mutate(undefined, {
onSuccess: () => {
// Refetch stats and usage after cleanup
}
});
}
if (!confirm('Clean up expired rate-limit counters? Active limits are unaffected.')) return;
cleanup.mutate();
};
const getUsagePercentage = (endpoint: string) => {
const endpointUsage = usage?.find(u => u.endpoint === endpoint);
if (!endpointUsage) return 0;
return (endpointUsage.current / endpointUsage.limit) * 100;
const handleDiscard = () => {
if (!settings) return;
setEditing(JSON.parse(JSON.stringify(settings)));
setDirty(false);
};
const getUsageColor = (percentage: number) => {
if (percentage >= 90) return 'text-red-600 bg-red-100';
if (percentage >= 70) return 'text-yellow-600 bg-yellow-100';
return 'text-green-600 bg-green-100';
};
const formatEndpointName = (endpoint: string) => {
return endpoint.split('/').pop()?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) || endpoint;
};
if (isLoading || !editing) {
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-gray-600">Loading rate limit settings...</p>
</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-6 py-8">
<div className="max-w-6xl mx-auto px-6 py-8">
<button
onClick={() => navigate('/settings')}
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
@ -144,31 +135,25 @@ const RateLimiting: React.FC = () => {
Back to Settings
</button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Rate Limiting</h1>
<p className="mt-2 text-gray-600">Configure API rate limits and monitor system usage</p>
<p className="mt-2 text-gray-600">
Six request-rate categories. Each route on the server falls under exactly one category; tune the limit and the time window per category.
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<SettingsIcon className="w-4 h-4" />
{showAdvanced ? 'Simple View' : 'Advanced View'}
</button>
<button
onClick={handleCleanup}
disabled={cleanupLimits.isPending}
disabled={cleanup.isPending}
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${cleanupLimits.isPending ? 'animate-spin' : ''}`} />
Cleanup Data
<RefreshCw className={`w-4 h-4 ${cleanup.isPending ? 'animate-spin' : ''}`} />
Cleanup Counters
</button>
<button
onClick={() => refetchConfigs()}
onClick={() => refetchSettings()}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<RefreshCw className="w-4 h-4" />
@ -178,448 +163,159 @@ const RateLimiting: React.FC = () => {
</div>
</div>
{/* Summary Cards */}
{summary && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
{stats && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Active Endpoints</p>
<p className="text-2xl font-bold text-gray-900">{summary.active_endpoints}</p>
<p className="text-xs text-gray-500">of {summary.total_endpoints} total</p>
<p className="text-sm text-gray-600">Configured Categories</p>
<p className="text-2xl font-bold text-gray-900">{stats.total_configured_limits}</p>
</div>
<Shield className="w-8 h-8 text-blue-600" />
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Requests/Min</p>
<p className="text-2xl font-bold text-gray-900">{summary.total_requests_per_minute}</p>
</div>
<Activity className="w-8 h-8 text-green-600" />
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Avg Utilization</p>
<p className="text-2xl font-bold text-blue-600">
{Math.round(summary.average_utilization)}%
</p>
</div>
<BarChart3 className="w-8 h-8 text-purple-600" />
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Most Active</p>
<p className="text-lg font-bold text-gray-900 truncate">
{formatEndpointName(summary.most_active_endpoint)}
</p>
</div>
<TrendingUp className="w-8 h-8 text-orange-600" />
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Status</p>
<p className="text-lg font-bold text-green-600">Enabled</p>
<p className="text-sm text-gray-600">Enabled</p>
<p className="text-2xl font-bold text-green-600">{stats.enabled_limits}</p>
</div>
<Shield className="w-8 h-8 text-green-600" />
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Requests / Window</p>
<p className="text-2xl font-bold text-gray-900">{stats.total_requests_per_minute}</p>
<p className="text-xs text-gray-500">summed across all categories</p>
</div>
<Activity className="w-8 h-8 text-purple-600" />
</div>
</div>
</div>
)}
{/* Controls */}
{(editingMode || editingConfigs.length > 0) && (
{dirty && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<p className="text-sm text-blue-800">
You have unsaved changes. Click "Save All Changes" to apply them.
</p>
<p className="text-sm text-blue-800">You have unsaved changes.</p>
<div className="flex gap-2">
<button
onClick={handleSaveAllConfigs}
disabled={updateAllConfigs.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
onClick={handleSave}
disabled={updateSettings.isPending}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
<Save className="w-4 h-4 inline mr-1" />
{updateAllConfigs.isPending ? 'Saving...' : 'Save All Changes'}
<Save className="w-4 h-4" />
{updateSettings.isPending ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={() => {
if (configs && Array.isArray(configs)) {
setEditingConfigs([...configs]);
}
setEditingMode(false);
}}
onClick={handleDiscard}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
>
Discard Changes
Discard
</button>
</div>
</div>
</div>
)}
{/* Rate Limit Configurations */}
<div className="bg-white rounded-lg border border-gray-200 mb-8">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Rate Limit Configurations</h2>
<div className="flex gap-2">
{!editingMode && (
<button
onClick={() => setEditingMode(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<SettingsIcon className="w-4 h-4 inline mr-1" />
Edit All
</button>
)}
<button
onClick={handleResetConfigs}
disabled={resetConfigs.isPending}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50"
>
<RotateCcw className="w-4 h-4 inline mr-1" />
Reset to Defaults
</button>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 mb-6">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Categories</h2>
<button
onClick={handleReset}
disabled={resetSettings.isPending}
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50"
>
<RotateCcw className="w-4 h-4" />
Reset to Defaults
</button>
</div>
{/* Search and Filter Controls */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Search by endpoint or method..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="divide-y divide-gray-200">
{CATEGORIES.map((cat) => {
const cfg = editing[cat.key];
if (!cfg) return null;
const windowSeconds = Math.round(cfg.window / NS_PER_SECOND);
return (
<div key={cat.key} className="px-6 py-5">
<div className="flex items-start justify-between gap-6">
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h3 className="font-semibold text-gray-900">{cat.title}</h3>
<label className="inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={cfg.enabled}
onChange={(e) => handleField(cat.key, 'enabled', e.target.checked)}
className="rounded border-gray-300"
/>
<span className={cfg.enabled ? 'text-green-700' : 'text-gray-500'}>
{cfg.enabled ? 'enabled' : 'disabled'}
</span>
</label>
</div>
<p className="text-sm text-gray-600 mb-2">{cat.description}</p>
<p className="text-xs text-gray-400 font-mono">{cat.routesNote}</p>
</div>
<div className="flex items-end gap-4 flex-shrink-0">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Requests
</label>
<input
type="number"
min={1}
max={1000}
value={cfg.requests}
onChange={(e) =>
handleField(cat.key, 'requests', parseInt(e.target.value || '0', 10))
}
className="w-24 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Window (seconds)
</label>
<input
type="number"
min={1}
max={86400}
value={windowSeconds}
onChange={(e) =>
handleField(
cat.key,
'window',
Math.max(1, parseInt(e.target.value || '0', 10)) * NS_PER_SECOND,
)
}
className="w-28 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="text-xs text-gray-500 pb-2 whitespace-nowrap">
= {(cfg.requests / Math.max(windowSeconds, 1)).toFixed(2)} req/s
</div>
</div>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setStatusFilter('all')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'all'
? 'bg-gray-100 text-gray-800 border border-gray-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
All
</button>
<button
onClick={() => setStatusFilter('enabled')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'enabled'
? 'bg-green-100 text-green-800 border border-green-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
Enabled
</button>
<button
onClick={() => setStatusFilter('disabled')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'disabled'
? 'bg-red-100 text-red-800 border border-red-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
Disabled
</button>
</div>
</div>
{/* Filter results summary */}
{configs && (
<div className="mt-3 text-sm text-gray-600">
Showing {filteredConfigs.length} of {configs.length} configurations
</div>
)}
);
})}
</div>
{filteredConfigs.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Endpoint
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Current Usage
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Requests/Min
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Window (min)
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Max Requests
</th>
{showAdvanced && (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Burst Allowance
</th>
)}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredConfigs.map((config) => {
const originalIndex = editingConfigs.findIndex(c => c.endpoint === config.endpoint);
const usagePercentage = getUsagePercentage(config.endpoint);
const endpointUsage = usage?.find(u => u.endpoint === config.endpoint);
return (
<tr key={config.endpoint} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{formatEndpointName(config.endpoint)}
</div>
<div className="text-xs text-gray-500">
{config.endpoint}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{endpointUsage && (
<div>
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${getUsageColor(usagePercentage)}`}>
<div className={`w-2 h-2 rounded-full mr-1 ${
usagePercentage >= 90 ? 'bg-red-500' :
usagePercentage >= 70 ? 'bg-yellow-500' : 'bg-green-500'
}`}></div>
{endpointUsage.current} / {endpointUsage.limit}
({Math.round(usagePercentage)}%)
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div
className={`h-2 rounded-full transition-all ${
usagePercentage >= 90 ? 'bg-red-500' :
usagePercentage >= 70 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
></div>
</div>
{endpointUsage && (
<div className="flex items-center gap-2 mt-1">
<Eye className="w-3 h-3 text-gray-400" />
<span className="text-xs text-gray-500">
Window: {formatDateTime(endpointUsage.window_start)} - {formatDateTime(endpointUsage.window_end)}
</span>
</div>
)}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingMode ? (
<input
type="number"
min="1"
value={config.requests_per_minute}
onChange={(e) => handleConfigChange(originalIndex, 'requests_per_minute', parseInt(e.target.value))}
className="w-24 px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<span className="text-sm text-gray-900">{config.requests_per_minute}</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingMode ? (
<input
type="number"
min="1"
value={config.window_minutes}
onChange={(e) => handleConfigChange(originalIndex, 'window_minutes', parseInt(e.target.value))}
className="w-20 px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<span className="text-sm text-gray-900">{config.window_minutes}</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingMode ? (
<input
type="number"
min="1"
value={config.max_requests}
onChange={(e) => handleConfigChange(originalIndex, 'max_requests', parseInt(e.target.value))}
className="w-24 px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<span className="text-sm text-gray-900">{config.max_requests}</span>
)}
</td>
{showAdvanced && (
<td className="px-6 py-4 whitespace-nowrap">
{editingMode ? (
<input
type="number"
min="0"
value={config.burst_allowance}
onChange={(e) => handleConfigChange(originalIndex, 'burst_allowance', parseInt(e.target.value))}
className="w-24 px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<span className="text-sm text-gray-900">{config.burst_allowance}</span>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
) : configs && configs.length > 0 ? (
<div className="p-12 text-center">
<Activity className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No configurations found</h3>
<p className="text-gray-600">
{searchTerm || statusFilter !== 'all'
? 'Try adjusting your search or filter criteria'
: 'No rate limit configurations available'}
</p>
</div>
) : (
<div className="p-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-gray-600">Loading rate limit configurations...</p>
</div>
)}
</div>
{/* Rate Limit Statistics */}
{stats && Array.isArray(stats) && stats.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Rate Limit Statistics</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{stats.map((stat) => (
<div key={stat.endpoint} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900">
{formatEndpointName(stat.endpoint)}
</h4>
<Activity className="w-4 h-4 text-yellow-500" />
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Current Requests:</span>
<span className="font-medium">{stat.current_requests}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Limit:</span>
<span className="font-medium">{stat.limit}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Blocked:</span>
<span className="font-medium text-red-600">{stat.blocked_requests}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Window:</span>
<span className="font-medium text-xs">
{new Date(stat.window_start).toLocaleTimeString()} - {new Date(stat.window_end).toLocaleTimeString()}
</span>
</div>
</div>
{stat.top_clients && Array.isArray(stat.top_clients) && stat.top_clients.length > 0 && (
<div className="mt-4 pt-3 border-t border-gray-200">
<p className="text-xs text-gray-600 mb-2">Top Clients:</p>
<div className="space-y-1">
{stat.top_clients.slice(0, 3).map((client, index) => (
<div key={index} className="flex justify-between text-xs">
<span className="text-gray-500 truncate mr-2">{client.identifier}</span>
<span className="font-medium">{client.request_count}</span>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
{/* Usage Monitoring */}
{usage && Array.isArray(usage) && usage.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Usage Monitoring</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{usage.map((endpointUsage) => (
<div key={endpointUsage.endpoint} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900">
{formatEndpointName(endpointUsage.endpoint)}
</h4>
<BarChart3 className="w-4 h-4 text-blue-500" />
</div>
<div className="space-y-3">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Usage</span>
<span className="font-medium">
{endpointUsage.current} / {endpointUsage.limit}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all ${
(endpointUsage.current / endpointUsage.limit) * 100 >= 90 ? 'bg-red-500' :
(endpointUsage.current / endpointUsage.limit) * 100 >= 70 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${Math.min((endpointUsage.current / endpointUsage.limit) * 100, 100)}%` }}
></div>
</div>
</div>
<div className="text-xs text-gray-600 space-y-1">
<div>Remaining: {endpointUsage.remaining} requests</div>
<div>Reset: {formatDateTime(endpointUsage.reset_time)}</div>
<div>Window: {endpointUsage.window_minutes} minutes</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
<p className="font-medium mb-1">Limits are in-memory.</p>
<p>
The server keeps rate-limit counters in memory and reloads defaults on restart. Changes
made here apply immediately to live traffic but are not persisted across restarts in this
version.
</p>
</div>
</div>
);
};
export default RateLimiting;
export default RateLimiting;

View file

@ -12,7 +12,7 @@ import {
import { useSettingsStore } from '@/lib/store';
import { useTimezones, useTimezone, useUpdateTimezone } from '../hooks/useSettings';
import { useRegistrationTokenStats } from '../hooks/useRegistrationTokens';
import { useRateLimitSummary } from '../hooks/useRateLimits';
import { useRateLimitStats } from '../hooks/useRateLimits';
const Settings: React.FC = () => {
const { autoRefresh, refreshInterval, setAutoRefresh, setRefreshInterval } = useSettingsStore();
@ -25,7 +25,7 @@ const Settings: React.FC = () => {
// Statistics for overview
const { data: tokenStats } = useRegistrationTokenStats();
const { data: rateLimitSummary } = useRateLimitSummary();
const { data: rateLimitStats } = useRateLimitStats();
React.useEffect(() => {
if (currentTimezone?.timezone) {
@ -162,30 +162,28 @@ const Settings: React.FC = () => {
Configure
</Link>
</div>
{rateLimitSummary ? (
{rateLimitStats ? (
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-2xl font-bold text-gray-900">{rateLimitSummary.active_endpoints}</p>
<p className="text-sm text-gray-600">Active Endpoints</p>
<p className="text-2xl font-bold text-gray-900">{rateLimitStats.total_configured_limits}</p>
<p className="text-sm text-gray-600">Categories</p>
</div>
<div>
<p className="text-2xl font-bold text-green-600">
{rateLimitSummary.total_requests_per_minute}
</p>
<p className="text-sm text-gray-600">Requests/Min</p>
<p className="text-2xl font-bold text-green-600">{rateLimitStats.enabled_limits}</p>
<p className="text-sm text-gray-600">Enabled</p>
</div>
<div>
<p className="text-2xl font-bold text-blue-600">
{Math.round(rateLimitSummary.average_utilization)}%
{rateLimitStats.total_requests_per_minute}
</p>
<p className="text-sm text-gray-600">Avg Utilization</p>
<p className="text-sm text-gray-600">Total Requests / Window</p>
</div>
<div>
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<p className="text-lg font-bold text-green-600">Enabled</p>
<p className="text-lg font-bold text-green-600">Active</p>
</div>
<p className="text-sm text-gray-600">System Protected</p>
<p className="text-sm text-gray-600">In-memory counters</p>
</div>
</div>
) : (

View file

@ -20,10 +20,31 @@ const AgentManagement: React.FC = () => {
const navigate = useNavigate();
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
const [selectedPlatform, setSelectedPlatform] = useState<string>('linux');
const [selectedTokenId, setSelectedTokenId] = useState<string>('');
const { data: tokens } = useRegistrationTokens({ is_active: true });
const { data: serverKeySecurity, isLoading: isLoadingServerKeySecurity, refetch: refetchServerKeySecurity } = useServerKeySecurity();
const [generatingKeys, setGeneratingKeys] = useState(false);
const availableTokens = React.useMemo(() => {
if (!tokens?.tokens || !Array.isArray(tokens.tokens)) return [];
return tokens.tokens.filter(
(t) => !t.revoked && t.status !== 'revoked' && t.status !== 'expired' && t.seats_used < t.max_seats,
);
}, [tokens]);
const selectedToken = React.useMemo(
() => availableTokens.find((t) => t.id === selectedTokenId) ?? null,
[availableTokens, selectedTokenId],
);
// Drop a stale selection if the token list changes underneath us, but never
// auto-pick — operator chooses explicitly.
React.useEffect(() => {
if (selectedTokenId && !availableTokens.some((t) => t.id === selectedTokenId)) {
setSelectedTokenId('');
}
}, [availableTokens, selectedTokenId]);
const platforms = [
{
id: 'linux',
@ -55,34 +76,31 @@ const AgentManagement: React.FC = () => {
return `${protocol}//${hostname}${port}`;
};
const getActiveToken = () => {
// Defensive null checking to prevent crashes
if (!tokens || !tokens.tokens || !Array.isArray(tokens.tokens) || tokens.tokens.length === 0) {
return 'YOUR_REGISTRATION_TOKEN';
}
return tokens.tokens[0]?.token || 'YOUR_REGISTRATION_TOKEN';
};
const generateInstallCommand = (platform: typeof platforms[0]) => {
if (!selectedToken) return '';
const serverUrl = getServerUrl();
const token = getActiveToken();
const token = selectedToken.token;
if (platform.id === 'linux') {
if (token !== 'YOUR_REGISTRATION_TOKEN') {
return `curl -sfL "${serverUrl}${platform.installScript}?token=${token}" | sudo bash`;
} else {
return `curl -sfL "${serverUrl}${platform.installScript}" | sudo bash`;
}
return `curl -sfL "${serverUrl}${platform.installScript}?token=${token}" | sudo bash`;
} else if (platform.id === 'windows') {
if (token !== 'YOUR_REGISTRATION_TOKEN') {
return `iwr "${serverUrl}${platform.installScript}?token=${token}" -UseBasicParsing -OutFile install.ps1; powershell -ExecutionPolicy Bypass -File install.ps1`;
} else {
return `iwr "${serverUrl}${platform.installScript}" -UseBasicParsing -OutFile install.ps1; powershell -ExecutionPolicy Bypass -File install.ps1`;
}
return `iwr "${serverUrl}${platform.installScript}?token=${token}" -UseBasicParsing -OutFile install.ps1; powershell -ExecutionPolicy Bypass -File install.ps1`;
}
return '';
};
const formatTokenOptionLabel = (t: typeof availableTokens[number]) => {
const prefix = t.token.slice(0, 12);
const seats = `${t.seats_used}/${t.max_seats} seats`;
const label = t.label ? ` · ${t.label}` : '';
let expiry = '';
if (t.expires_at) {
const days = Math.round((new Date(t.expires_at).getTime() - Date.now()) / 86400000);
expiry = ` · expires ${days}d`;
}
return `${prefix}${label} · ${seats}${expiry}`;
};
const copyToClipboard = async (text: string, commandId: string) => {
try {
if (!text || text.trim() === '') {
@ -127,36 +145,57 @@ const AgentManagement: React.FC = () => {
</div>
</div>
{/* Token Status */}
{/* Token Selector */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
<div className="flex items-start gap-4">
<Shield className="w-6 h-6 text-blue-600 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-blue-900 mb-2">Registration Token Required</h3>
<p className="text-blue-700 mb-4">
Agents need a registration token to enroll with the server. You have {tokens?.tokens?.length || 0} active token(s).
</p>
{!tokens?.tokens || tokens.tokens.length === 0 ? (
<Link
to="/settings/tokens"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<Shield className="w-4 h-4" />
Generate Registration Token
</Link>
) : (
<div className="flex items-center gap-4">
<div>
<p className="text-sm text-blue-600 font-medium">Active Token:</p>
<code className="text-xs bg-blue-100 px-2 py-1 rounded">{tokens?.tokens?.[0]?.token || 'N/A'}</code>
</div>
<h3 className="font-semibold text-blue-900 mb-2">Choose a Registration Token</h3>
{availableTokens.length === 0 ? (
<>
<p className="text-blue-700 mb-4">
No registration tokens with available seats. Create one to enroll new agents existing agents are unaffected.
</p>
<Link
to="/settings/tokens"
className="text-sm text-blue-600 hover:text-blue-800 underline"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
View all tokens
<Shield className="w-4 h-4" />
Generate Registration Token
</Link>
</div>
</>
) : (
<>
<p className="text-blue-700 mb-3">
Each token defines a group of agents. Pick the one this install belongs to the
selection is baked into the command below.
</p>
<div className="flex items-center gap-3 flex-wrap">
<select
value={selectedTokenId}
onChange={(e) => setSelectedTokenId(e.target.value)}
className="px-3 py-2 border border-blue-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[420px]"
>
<option value=""> Select a token ({availableTokens.length} available) </option>
{availableTokens.map((t) => (
<option key={t.id} value={t.id}>
{formatTokenOptionLabel(t)}
</option>
))}
</select>
<Link
to="/settings/tokens"
className="text-sm text-blue-600 hover:text-blue-800 underline"
>
Manage tokens
</Link>
</div>
{selectedToken && (
<div className="mt-3 text-xs text-blue-800 bg-blue-100 rounded px-3 py-2 inline-block">
Token: <code className="font-mono">{selectedToken.token}</code>
</div>
)}
</>
)}
</div>
</div>
@ -199,7 +238,8 @@ const AgentManagement: React.FC = () => {
{selectedPlatformData && (
<div className="space-y-8">
{/* One-Liner Installation */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
{selectedToken ? (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-gray-900">2. One-Liner Installation (Recommended)</h2>
@ -259,7 +299,16 @@ const AgentManagement: React.FC = () => {
</div>
</div>
</div>
</div>
</div>
) : (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
<Terminal className="w-8 h-8 text-gray-400 mx-auto mb-3" />
<h2 className="text-lg font-semibold text-gray-700 mb-1">Select a token above to generate the install command</h2>
<p className="text-sm text-gray-500">
Reference information for the {selectedPlatformData.name} agent is shown below regardless.
</p>
</div>
)}
{/* Security Information */}
<div className="bg-white border border-gray-200 rounded-lg p-6">

View file

@ -351,47 +351,86 @@ export interface RegistrationTokenStats {
}
// Rate Limiting types
//
// The server exposes six fixed categories (not per-endpoint). Each route in
// main.go declares which category it falls under at registration time:
// - agent_registration: new agent enrollment (anti-flood)
// - agent_checkin: agent polling for commands
// - agent_reports: agent telemetry uploads (updates, system info, metrics)
// - admin_token_generation: creating registration tokens
// - admin_operations: general admin API calls
// - public_access: login, public-key, info, downloads
//
// `window` is the Go server's time.Duration serialized as integer nanoseconds.
export type RateLimitCategory =
| 'agent_registration'
| 'agent_checkin'
| 'agent_reports'
| 'admin_token_generation'
| 'admin_operations'
| 'public_access';
export interface RateLimitConfig {
endpoint: string;
method: string;
requests: number;
window: number; // nanoseconds (Go time.Duration on the wire)
enabled: boolean;
requests_per_minute: number;
window_minutes: number;
max_requests: number;
burst_allowance: number;
metadata: Record<string, any>;
}
export interface RateLimitStats {
endpoint: string;
current_requests: number;
limit: number;
window_start: string;
window_end: string;
blocked_requests: number;
top_clients: Array<{
identifier: string;
request_count: number;
}>;
}
export type RateLimitSettings = Record<RateLimitCategory, RateLimitConfig>;
export interface RateLimitUsage {
endpoint: string;
limit: number;
current: number;
remaining: number;
reset_time: string;
window_minutes: number;
window_start: string;
window_end: string;
}
export interface RateLimitSummary {
total_endpoints: number;
active_endpoints: number;
export interface RateLimitStatsResponse {
total_configured_limits: number;
enabled_limits: number;
total_requests_per_minute: number;
most_active_endpoint: string;
average_utilization: number;
settings: RateLimitSettings;
}
// Upstream version sync — tracks software the operator wants compared
// against canonical upstream releases (Repology, endoflife.date, ...).
// See server/internal/services/upstream/ and migration 035.
export type UpstreamSource =
| 'repology'
| 'endoflife'
| 'anitya'
| 'github'
| 'npm'
| 'pypi';
export interface TrackedSoftware {
id: string;
name: string;
ecosystem: string;
source: UpstreamSource;
source_ref: string;
current_version?: string | null;
latest_version?: string | null;
latest_at?: string | null;
eol_at?: string | null;
last_checked_at?: string | null;
last_synced_at?: string | null;
last_error?: string | null;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface CreateTrackedSoftwareRequest {
name: string;
ecosystem: string;
source: UpstreamSource;
source_ref: string;
current_version?: string;
enabled?: boolean;
}
export interface UpstreamDriftEvent {
id: string;
tracked_software_id: string;
observed_at: string;
drift_severity: 'minor' | 'major' | 'eol';
from_version?: string | null;
to_version?: string | null;
note?: string | null;
}
// Subsystem types