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:
parent
2c3551c639
commit
86da7471ec
34 changed files with 2345 additions and 831 deletions
27
README.md
27
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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{}{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
116
server/internal/api/handlers/upstream.go
Normal file
116
server/internal/api/handlers/upstream.go
Normal 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})
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
162
server/internal/database/queries/upstream.go
Normal file
162
server/internal/database/queries/upstream.go
Normal 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
|
||||
}
|
||||
50
server/internal/models/tracked_software.go
Normal file
50
server/internal/models/tracked_software.go
Normal 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"`
|
||||
}
|
||||
203
server/internal/services/package_age.go
Normal file
203
server/internal/services/package_age.go
Normal 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
|
||||
}
|
||||
123
server/internal/services/package_age_test.go
Normal file
123
server/internal/services/package_age_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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{}) {
|
||||
|
|
|
|||
112
server/internal/services/upstream/endoflife.go
Normal file
112
server/internal/services/upstream/endoflife.go
Normal 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
|
||||
}
|
||||
94
server/internal/services/upstream/repology.go
Normal file
94
server/internal/services/upstream/repology.go
Normal 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
|
||||
}
|
||||
73
server/internal/services/upstream/source.go
Normal file
73
server/internal/services/upstream/source.go
Normal 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}
|
||||
169
server/internal/services/upstream/syncer.go
Normal file
169
server/internal/services/upstream/syncer.go
Normal 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"
|
||||
}
|
||||
95
web/src/components/StackDriftPanel.tsx
Normal file
95
web/src/components/StackDriftPanel.tsx
Normal 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;
|
||||
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
|||
77
web/src/hooks/useUpstream.ts
Normal file
77
web/src/hooks/useUpstream.ts
Normal 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');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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');
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue