refactor(agent): scanner orchestrator cleanup + kernel-enforcement wiring + hash-registry follow-ups
Scanner refactor: - Move Name() onto each scanner; drop scanner_wrappers.go, registry.go, scanner_types.go and the duplicate scanner/docker.go (folded into orchestrator/docker_scanner.go) - Add Name() to DNFScanner (was missing — broke orchestrator.Scanner) - dnf_test.go coverage Kernel enforcement (Tier 2 scaffold, wired into loop): - agent/internal/kernel: enforcer, ebpf consumer, windows WDAC stub - config.KernelEnforcementConfig + defaults/merge, wired in loop.go Hash registry (Layer 1) follow-ups: - client GetExpectedHash uses /api/v1/updates/verify-hash - UpdateHandler takes config; computeAndStorePackageHash uses PublicURL Server: - Migration 041: update version_history status constraint - docker reject path writes "ignored" (matches new constraint) - queries/filter.go shared filter helper - updates UI enhancements
This commit is contained in:
parent
1a4425b09f
commit
f0f18d7320
57 changed files with 2096 additions and 1922 deletions
17
Makefile
17
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: help db-up db-down server agent clean
|
||||
.PHONY: help db-up db-down server agent clean kernel-enforcer
|
||||
|
||||
help: ## Show this help message
|
||||
@echo 'Usage: make [target]'
|
||||
|
|
@ -41,3 +41,18 @@ build-all: ## Build with go mod tidy for fresh clones
|
|||
test: ## Run tests
|
||||
cd server && go test ./...
|
||||
cd agent && go test ./...
|
||||
|
||||
kernel-enforcer: ## Build eBPF kernel enforcer
|
||||
@echo "Building eBPF kernel enforcer..."
|
||||
@cd agent && clang -O2 -g -target bpf -mllvm -bpf-stack-size=4096 -c pkg-gate/pkg-gate.c -o pkg-gate/pkg-gate.o \
|
||||
-I/usr/src/kernels/$(shell uname -r)/vmlinux.h \
|
||||
-I/usr/src/kernels/$(shell uname -r)/tools/lib/bpf \
|
||||
-I/usr/src/kernels/$(shell uname -r)/tools/bpf/resolve_btfids/libbpf/include \
|
||||
-I/usr/src/kernels/$(shell uname -r)/tools/bpf/resolve_btfids/libbpf \
|
||||
-I/usr/src/kernels/$(shell uname -r)
|
||||
@echo "eBPF enforcer built successfully"
|
||||
|
||||
kernel-enforcer-clean: ## Clean eBPF kernel enforcer artifacts
|
||||
@echo "Cleaning eBPF kernel enforcer..."
|
||||
@cd agent && rm -f pkg-gate/pkg-gate.o
|
||||
@echo "Clean complete"
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@
|
|||
|
||||
| Section | Description |
|
||||
|---------|-------------|
|
||||
| [[p0-priority-start-here]] | **START HERE** — priority roadmap, current blockers, next steps |
|
||||
| [[core]] | Core concepts, ETHOS principles, architectural decisions |
|
||||
| [[components]] | Server, agent, web — component breakdowns |
|
||||
| [[security]] | Trust boundaries, auth layers, machine binding, key management |
|
||||
| [[security]] | Trust boundaries, auth layers, machine binding, key management, supply chain gate |
|
||||
| [[verification]] | Ed25519 signing, verification pipeline, v2/v3 formats, replay protection |
|
||||
| [[scanners]] | Every scanner (APT, DNF, Winget, WUA, Docker, macOS, Bun, etc) with interaction analysis |
|
||||
| [[scanners]] | Every scanner (APT, DNF, Winget, WUA, Docker, macOS SoftwareUpdate, etc) with interaction analysis |
|
||||
| [[flows]] | Data flows — registration, command execution, updates, self-upgrade |
|
||||
| [[deployment]] | Docker, production checklist, monitoring |
|
||||
| [[testing]] | Test pyramid, test coverage, manual testing |
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -5,9 +5,12 @@
|
|||
Cross-platform agents • Web dashboard • Hardware binding • Ed25519 signing • Supply-chain gates • Full error transparency
|
||||
|
||||
```
|
||||
v0.2.0.2 — May 2026
|
||||
v0.2.0.5 — May 2026 (pre-release)
|
||||
```
|
||||
|
||||
> **NOT YET OFFICIALLY RELEASED** — Tags do not imply stability. We're in flux.
|
||||
> Mass enrollment announcement coming soon. Use at your own risk.
|
||||
|
||||
RedFlag runs in production homelabs and small operator environments today. It is not a beta with disclaimers; it is a working system that the maintainer also runs as their primary update-management plane. Releases are versioned, migrations are idempotent, and the security model is documented in the code, not in marketing copy.
|
||||
|
||||
**What's new in v0.2.0.2:** upstream version-sync subsystem (Repology + endoflife.date adapters, periodic syncer, "Stack Drift" dashboard panel, full CRUD page); aggregated Attention panel surfacing offline agents, failed updates, EOL drift, and recent upstream movement; rate-limiting UI rewritten to the real backend; agent-management token selector that doesn't conflate registration tokens with the install command; idempotent installer that detects existing credentials and skips re-registration; two-axis revocation model locked in; supply-chain package-age gate (Shai-Hulud-class defense). [Full changelog](#-tldr-changelog-dont-trust-the-transport-layer) · [Update instructions](#updating).
|
||||
|
|
@ -102,6 +105,11 @@ curl -sfL https://your-server.com/install | sudo bash -s -- your-registration-to
|
|||
iwr https://your-server.com/install.ps1 | iex
|
||||
```
|
||||
|
||||
**macOS (curl):**
|
||||
```bash
|
||||
curl -sfL https://your-server.com/install | sudo bash -s -- your-registration-token
|
||||
```
|
||||
|
||||
**Manual installation:**
|
||||
```bash
|
||||
# Download agent binary
|
||||
|
|
@ -291,9 +299,9 @@ Remove-Item "C:\ProgramData\RedFlag\config.json"
|
|||
- Windows Winget detection occasionally misses packages (Windows API limitation)
|
||||
- Some Windows Updates may reappear after installation (known Windows Update quirk)
|
||||
- Limited mobile dashboard optimization (usable but not ideal)
|
||||
- No macOS agent support (Linux + Windows only)
|
||||
- No AUR, Snap, Flatpak, Homebrew scanner support
|
||||
- Frontend is functional but not polished — some pages need UX love
|
||||
- macOS agent binaries not yet signed (build paths enabled, awaiting binary)
|
||||
---
|
||||
|
||||
## License
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ require (
|
|||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.4.21 // indirect
|
||||
github.com/cilium/ebpf v0.21.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnv
|
|||
github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cilium/ebpf v0.21.0 h1:4dpx1J/B/1apeTmWBH5BkVLayHTkFrMovVPnHEk+l3k=
|
||||
github.com/cilium/ebpf v0.21.0/go.mod h1:1kHKv6Kvh5a6TePP5vvvoMa1bclRyzUXELSs272fmIQ=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
|
||||
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
|
|
@ -51,6 +54,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f h1:v+bqkkvZj6Oasqi58jzJk03XO0vaXvdb6SS9U1Rbqpw=
|
||||
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f/go.mod h1:Zt2M6t3i/fnWviIZkuw9wGn2E185P/rWZTqJkIrViGY=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
|
|
@ -14,6 +15,7 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/handlers"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/kernel"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/logging"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
|
||||
|
|
@ -60,7 +62,7 @@ func RunAgentLoop(cfg *config.Config) error {
|
|||
wingetScanner := scanner.NewWingetScanner()
|
||||
storageScanner := orchestrator.NewStorageScanner(version.Version)
|
||||
systemScanner := orchestrator.NewSystemScanner(version.Version)
|
||||
dockerScanner, _ := scanner.NewDockerScanner()
|
||||
dockerScanner, _ := orchestrator.NewDockerScanner()
|
||||
|
||||
// Initialize circuit breakers
|
||||
aptCB := newCircuitBreaker("APT", cfg.Subsystems.APT.CircuitBreaker)
|
||||
|
|
@ -75,13 +77,13 @@ func RunAgentLoop(cfg *config.Config) error {
|
|||
scanOrchestrator := orchestrator.NewOrchestrator()
|
||||
|
||||
// Register all scanners
|
||||
scanOrchestrator.RegisterScanner("apt", orchestrator.NewAPTScannerWrapper(aptScanner), aptCB, cfg.Subsystems.APT.Timeout, cfg.Subsystems.APT.Enabled)
|
||||
scanOrchestrator.RegisterScanner("dnf", orchestrator.NewDNFScannerWrapper(dnfScanner), dnfCB, cfg.Subsystems.DNF.Timeout, cfg.Subsystems.DNF.Enabled)
|
||||
scanOrchestrator.RegisterScanner("windows", orchestrator.NewWindowsUpdateScannerWrapper(windowsUpdateScanner), windowsCB, cfg.Subsystems.Windows.Timeout, cfg.Subsystems.Windows.Enabled)
|
||||
scanOrchestrator.RegisterScanner("winget", orchestrator.NewWingetScannerWrapper(wingetScanner), wingetCB, cfg.Subsystems.Winget.Timeout, cfg.Subsystems.Winget.Enabled)
|
||||
scanOrchestrator.RegisterScanner("storage", orchestrator.NewStorageScannerWrapper(storageScanner), storageCB, cfg.Subsystems.Storage.Timeout, cfg.Subsystems.Storage.Enabled)
|
||||
scanOrchestrator.RegisterScanner("system", orchestrator.NewSystemScannerWrapper(systemScanner), systemCB, cfg.Subsystems.System.Timeout, cfg.Subsystems.System.Enabled)
|
||||
scanOrchestrator.RegisterScanner("docker", orchestrator.NewDockerScannerWrapper(dockerScanner), dockerCB, cfg.Subsystems.Docker.Timeout, cfg.Subsystems.Docker.Enabled)
|
||||
scanOrchestrator.RegisterScanner("apt", aptScanner, aptCB, cfg.Subsystems.APT.Timeout, cfg.Subsystems.APT.Enabled)
|
||||
scanOrchestrator.RegisterScanner("dnf", dnfScanner, dnfCB, cfg.Subsystems.DNF.Timeout, cfg.Subsystems.DNF.Enabled)
|
||||
scanOrchestrator.RegisterScanner("windows", windowsUpdateScanner, windowsCB, cfg.Subsystems.Windows.Timeout, cfg.Subsystems.Windows.Enabled)
|
||||
scanOrchestrator.RegisterScanner("winget", wingetScanner, wingetCB, cfg.Subsystems.Winget.Timeout, cfg.Subsystems.Winget.Enabled)
|
||||
scanOrchestrator.RegisterScanner("storage", storageScanner, storageCB, cfg.Subsystems.Storage.Timeout, cfg.Subsystems.Storage.Enabled)
|
||||
scanOrchestrator.RegisterScanner("system", systemScanner, systemCB, cfg.Subsystems.System.Timeout, cfg.Subsystems.System.Enabled)
|
||||
scanOrchestrator.RegisterScanner("docker", dockerScanner, dockerCB, cfg.Subsystems.Docker.Timeout, cfg.Subsystems.Docker.Enabled)
|
||||
|
||||
// Initialize acknowledgment tracker (result acks — pending_acks.json)
|
||||
ackTracker := acknowledgment.NewTracker(constants.GetAgentStateDir())
|
||||
|
|
@ -89,6 +91,14 @@ func RunAgentLoop(cfg *config.Config) error {
|
|||
log.Printf("[WARNING] [agent] [acknowledgment] load_pending_acks_failed error=%v", err)
|
||||
}
|
||||
|
||||
// Initialize kernel enforcement enforcer
|
||||
kernelEnforcer, err := kernel.NewEnforcer(cfg)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [kernel] enforcer_init_failed error=%v", err)
|
||||
} else {
|
||||
log.Printf("[INFO] [agent] [kernel] %s_enforcer_started", kernelEnforcer.GetPackageType())
|
||||
}
|
||||
|
||||
// Initialize receipt tracker (command-receipt confirmation — pending_receipts.json)
|
||||
// Migration 033 §2: doctrine TODO-full-command-lifecycle.md
|
||||
receiptTracker := receipt.NewTracker(constants.GetAgentStateDir())
|
||||
|
|
@ -96,6 +106,14 @@ func RunAgentLoop(cfg *config.Config) error {
|
|||
log.Printf("[WARNING] [agent] [receipt] load_pending_receipts_failed error=%v", err)
|
||||
}
|
||||
|
||||
// Initialize confirmed tracker (command-completion confirmation from server)
|
||||
// This tracks commands the server has acknowledged via ReportLog, preventing
|
||||
// false-positive duplicate rejections when server hasn't processed the log yet.
|
||||
confirmedTracker := orchestrator.NewConfirmedTracker(constants.GetAgentStateDir())
|
||||
if err := confirmedTracker.Load(); err != nil {
|
||||
log.Printf("[WARNING] [agent] [confirmed] load_confirmed_completed_failed error=%v", err)
|
||||
}
|
||||
|
||||
// Initialize command handler
|
||||
securityLogger, _ := logging.NewSecurityLogger(cfg, constants.GetAgentStateDir())
|
||||
commandHandler, err := orchestrator.NewCommandHandler(cfg, constants.GetAgentStateDir(), securityLogger, log.New(os.Stdout, "", log.LstdFlags))
|
||||
|
|
@ -109,6 +127,7 @@ func RunAgentLoop(cfg *config.Config) error {
|
|||
APIClient: apiClient,
|
||||
AckTracker: ackTracker,
|
||||
ReceiptTracker: receiptTracker,
|
||||
ConfirmedTracker: confirmedTracker,
|
||||
CommandHandler: commandHandler,
|
||||
ScanOrchestrator: scanOrchestrator,
|
||||
CircuitBreakers: map[string]*circuitbreaker.CircuitBreaker{
|
||||
|
|
@ -130,19 +149,36 @@ type LoopContext struct {
|
|||
APIClient *client.Client
|
||||
AckTracker *acknowledgment.Tracker
|
||||
ReceiptTracker *receipt.Tracker
|
||||
ConfirmedTracker *orchestrator.ConfirmedTracker // tracks commands server confirmed as completed
|
||||
CommandHandler *orchestrator.CommandHandler
|
||||
ScanOrchestrator *orchestrator.Orchestrator
|
||||
CircuitBreakers map[string]*circuitbreaker.CircuitBreaker
|
||||
KernelEnforcer kernel.Enforcer
|
||||
Ctx context.Context
|
||||
StopCh <-chan struct{} // non-nil causes loop to exit cleanly when closed
|
||||
}
|
||||
|
||||
// RunPollingLoop runs the main agent polling loop.
|
||||
// It is called by RunAgentLoop and may also be called by the Windows service
|
||||
// with a stop channel. When stopCh is non-nil, the loop selects on it and exits cleanly.
|
||||
func RunPollingLoop(ctx *LoopContext) error {
|
||||
func RunPollingLoop(loopCtx *LoopContext) error {
|
||||
// Panic recovery for the polling loop [TD-002]
|
||||
defer recovery.Recover("agent_polling_loop")
|
||||
|
||||
ctx := loopCtx
|
||||
|
||||
// Start kernel enforcement enforcer
|
||||
if ctx.KernelEnforcer != nil {
|
||||
if err := ctx.KernelEnforcer.Start(ctx.Ctx); err != nil {
|
||||
log.Printf("[ERROR] [agent] [kernel] enforcer_start_failed error=%v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := ctx.KernelEnforcer.Stop(); err != nil {
|
||||
log.Printf("[ERROR] [agent] [kernel] enforcer_stop_failed error=%v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
consecutiveFailures := 0
|
||||
lastSystemInfoUpdate := time.Time{}
|
||||
postUpdateCleanupDone := false
|
||||
|
|
@ -273,6 +309,22 @@ func RunPollingLoop(ctx *LoopContext) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Drop confirmed completions the server acknowledged via ReportLog.
|
||||
// This is the key fix for duplicate command rejections: if the server
|
||||
// has confirmed a command as completed, the agent should NOT reject it
|
||||
// as a duplicate even if it's in the executed set. The race was:
|
||||
// 1. Server sends command -> agent executes -> agent reports log
|
||||
// 2. Server hasn't processed log yet -> sends command again on next poll
|
||||
// 3. Agent rejects as duplicate (BUG)
|
||||
// With this fix: agent checks if server confirmed -> if yes, allow.
|
||||
if response != nil && len(response.ConfirmedCommandIDs) > 0 {
|
||||
ctx.ConfirmedTracker.Confirm(response.ConfirmedCommandIDs)
|
||||
log.Printf("[INFO] [agent] [confirmed] completions_confirmed count=%d", len(response.ConfirmedCommandIDs))
|
||||
if err := ctx.ConfirmedTracker.Save(); err != nil {
|
||||
log.Printf("[ERROR] [agent] [confirmed] save_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Report circuit breaker health
|
||||
go reportCircuitBreakerHealth(ctx)
|
||||
|
||||
|
|
|
|||
|
|
@ -464,6 +464,7 @@ type CommandsResponse struct {
|
|||
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
|
||||
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // Result IDs server has recorded (drop from pending_acks)
|
||||
ReceiptConfirmedIDs []string `json:"receipt_confirmed_ids,omitempty"` // Command IDs server flipped sent→received (drop from outbound receipts)
|
||||
ConfirmedCommandIDs []string `json:"confirmed_command_ids,omitempty"` // Command IDs server confirmed as completed (via ReportLog)
|
||||
}
|
||||
|
||||
// RapidPollingConfig contains rapid polling configuration from server
|
||||
|
|
@ -1128,7 +1129,7 @@ func (c *Client) GetActivePublicKeys(serverURL string) ([]ActivePublicKeyEntry,
|
|||
|
||||
// GetExpectedHash retrieves the expected SHA256 hash for a package (Layer 1: Hash Registry)
|
||||
func (c *Client) GetExpectedHash(packageType, packageName string, agentID uuid.UUID) (string, error) {
|
||||
url := fmt.Sprintf("%s/dashboard/updates/verify-hash?package_type=%s&package_name=%s&agent_id=%s",
|
||||
url := fmt.Sprintf("%s/api/v1/updates/verify-hash?package_type=%s&package_name=%s&agent_id=%s",
|
||||
c.baseURL, packageType, packageName, agentID.String())
|
||||
|
||||
resp, err := c.http.Get(url)
|
||||
|
|
@ -1157,3 +1158,11 @@ func (c *Client) GetExpectedHash(packageType, packageName string, agentID uuid.U
|
|||
|
||||
return result.ExpectedSHA256, nil
|
||||
}
|
||||
|
||||
// GetWdacPolicyHash returns the WDAC policy hash (stub for Windows WDAC enforcement)
|
||||
// This is a placeholder - actual WDAC policy fetching requires Windows-specific COM APIs
|
||||
func (c *Client) GetWdacPolicyHash(agentID uuid.UUID, serverURL string) (string, error) {
|
||||
// WDAC not implemented yet - return empty hash
|
||||
// This causes fail-closed behavior in WDAC enforcer
|
||||
return "", fmt.Errorf("wdac_policy_not_implemented")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,9 +129,18 @@ type Config struct {
|
|||
DisplayName string `json:"display_name,omitempty"` // Human-readable name
|
||||
Organization string `json:"organization,omitempty"` // Organization/group
|
||||
|
||||
// OS Type (linux, windows, darwin)
|
||||
OSType string `json:"os_type,omitempty"`
|
||||
|
||||
// OS holds OS-specific information
|
||||
OS OS `json:"os,omitempty"`
|
||||
|
||||
// Subsystem Configuration
|
||||
Subsystems SubsystemsConfig `json:"subsystems,omitempty"` // Scanner subsystem configs
|
||||
|
||||
// Kernel Enforcement Configuration
|
||||
KernelEnforcement KernelEnforcementConfig `json:"kernel_enforcement,omitempty"`
|
||||
|
||||
// Migration State
|
||||
MigrationState *MigrationState `json:"migration_state,omitempty"` // Migration completion tracking
|
||||
}
|
||||
|
|
@ -586,6 +595,31 @@ func (c *Config) IsRegistered() bool {
|
|||
return c.AgentID != uuid.Nil && c.Token != ""
|
||||
}
|
||||
|
||||
// OSType represents the operating system type
|
||||
type OSType string
|
||||
|
||||
const (
|
||||
OSTypeLinux OSType = "linux"
|
||||
OSTypeWindows OSType = "windows"
|
||||
OSTypeDarwin OSType = "darwin"
|
||||
)
|
||||
|
||||
// OS holds OS-specific information
|
||||
type OS struct {
|
||||
Type OSType `json:"type"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// GetOSType returns the OS type from the config
|
||||
func (c *Config) GetOSType() OSType {
|
||||
if c.OS.Type != "" {
|
||||
return c.OS.Type
|
||||
}
|
||||
// Default to linux if not set
|
||||
return OSTypeLinux
|
||||
}
|
||||
|
||||
// NeedsRegistration checks if the agent needs to register with a token
|
||||
func (c *Config) NeedsRegistration() bool {
|
||||
return c.RegistrationToken != "" && c.AgentID == uuid.Nil
|
||||
|
|
|
|||
55
agent/internal/config/kernel_enforcement.go
Normal file
55
agent/internal/config/kernel_enforcement.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
)
|
||||
|
||||
// KernelEnforcementConfig holds configuration for kernel-level enforcement
|
||||
type KernelEnforcementConfig struct {
|
||||
// Enable kernel enforcement (eBPF on Linux, WDAC on Windows)
|
||||
Enabled bool `json:"enabled" env:"REDFLAG_KERNEL_ENFORCEMENT_ENABLED" default:"true"`
|
||||
|
||||
// Ring buffer path for eBPF events (Linux only)
|
||||
RingBufferPath string `json:"ring_buffer_path" env:"REDFLAG_RING_BUFFER_PATH" default:"/var/run/redflag/ebpf-ring"`
|
||||
|
||||
// Socket path for rs-helper communication
|
||||
RsHelperSocket string `json:"rs_helper_socket" env:"REDFLAG_RS_HELPER_SOCKET" default:"/var/run/redflag/rs-helper.sock"`
|
||||
|
||||
// Policy check timeout
|
||||
PolicyCheckTimeout time.Duration `json:"policy_check_timeout" env:"REDFLAG_POLICY_CHECK_TIMEOUT" default:"10s"`
|
||||
|
||||
// Fail-closed mode (deny on kernel enforcement failure)
|
||||
FailClosed bool `json:"fail_closed" env:"REDFLAG_KERNEL_ENFORCEMENT_FAIL_CLOSED" default:"true"`
|
||||
}
|
||||
|
||||
// GetDefaultKernelEnforcementConfig returns default kernel enforcement configuration
|
||||
func GetDefaultKernelEnforcementConfig() KernelEnforcementConfig {
|
||||
return KernelEnforcementConfig{
|
||||
Enabled: true,
|
||||
RingBufferPath: constants.GetAgentStateDir() + "/ebpf-ring",
|
||||
RsHelperSocket: "/var/run/redflag/rs-helper.sock",
|
||||
PolicyCheckTimeout: 10 * time.Second,
|
||||
FailClosed: true,
|
||||
}
|
||||
}
|
||||
|
||||
// MergeKernelEnforcement merges kernel enforcement config from source into target
|
||||
func MergeKernelEnforcement(target, source KernelEnforcementConfig) {
|
||||
if source.Enabled {
|
||||
target.Enabled = source.Enabled
|
||||
}
|
||||
if source.RingBufferPath != "" {
|
||||
target.RingBufferPath = source.RingBufferPath
|
||||
}
|
||||
if source.RsHelperSocket != "" {
|
||||
target.RsHelperSocket = source.RsHelperSocket
|
||||
}
|
||||
if source.PolicyCheckTimeout > 0 {
|
||||
target.PolicyCheckTimeout = source.PolicyCheckTimeout
|
||||
}
|
||||
if source.FailClosed {
|
||||
target.FailClosed = source.FailClosed
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/display"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/scanner"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||
|
|
@ -43,11 +44,11 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
|
|||
// Initialize scanners
|
||||
aptScanner := scanner.NewAPTScanner()
|
||||
dnfScanner := scanner.NewDNFScanner()
|
||||
dockerScanner, _ := scanner.NewDockerScanner()
|
||||
dockerScanner, _ := orchestrator.NewDockerScanner()
|
||||
windowsUpdateScanner := scanner.NewWindowsUpdateScanner()
|
||||
wingetScanner := scanner.NewWingetScanner()
|
||||
|
||||
fmt.Println("🔍 Scanning for updates...")
|
||||
fmt.Println("Scanning for updates...")
|
||||
var allUpdates []client.UpdateReportItem
|
||||
|
||||
// Scan APT updates
|
||||
|
|
@ -55,7 +56,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
|
|||
fmt.Println(" - Scanning APT packages...")
|
||||
updates, err := aptScanner.Scan()
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠️ APT scan failed: %v\n", err)
|
||||
fmt.Printf(" APT scan failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d APT updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
|
|
@ -67,7 +68,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
|
|||
fmt.Println(" - Scanning DNF packages...")
|
||||
updates, err := dnfScanner.Scan()
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠️ DNF scan failed: %v\n", err)
|
||||
fmt.Printf(" DNF scan failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d DNF updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
|
|
@ -79,7 +80,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
|
|||
fmt.Println(" - Scanning Docker images...")
|
||||
updates, err := dockerScanner.Scan()
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠️ Docker scan failed: %v\n", err)
|
||||
fmt.Printf(" Docker scan failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d Docker image updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
|
|
@ -91,7 +92,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
|
|||
fmt.Println(" - Scanning Windows updates...")
|
||||
updates, err := windowsUpdateScanner.Scan()
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠️ Windows Update scan failed: %v\n", err)
|
||||
fmt.Printf(" Windows Update scan failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d Windows updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
|
|
@ -103,7 +104,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
|
|||
fmt.Println(" - Scanning Winget packages...")
|
||||
updates, err := wingetScanner.Scan()
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠️ Winget scan failed: %v\n", err)
|
||||
fmt.Printf(" Winget scan failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Found %d Winget package updates\n", len(updates))
|
||||
allUpdates = append(allUpdates, updates...)
|
||||
|
|
@ -113,7 +114,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
|
|||
// Load and update cache
|
||||
localCache, err := cache.Load()
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ Warning: Failed to load cache: %v\n", err)
|
||||
fmt.Printf("Warning: Failed to load cache: %v\n", err)
|
||||
localCache = &cache.LocalCache{}
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +127,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
|
|||
|
||||
// Save cache
|
||||
if err := localCache.Save(); err != nil {
|
||||
fmt.Printf("⚠️ Warning: Failed to save cache: %v\n", err)
|
||||
fmt.Printf("Warning: Failed to save cache: %v\n", err)
|
||||
}
|
||||
|
||||
// Display results
|
||||
|
|
@ -141,31 +142,31 @@ func StatusCommand(cfg *config.Config) error {
|
|||
fmt.Println("==================================================================")
|
||||
|
||||
// Agent information
|
||||
fmt.Printf("📋 Agent ID: %s\n", cfg.AgentID)
|
||||
fmt.Printf("🌐 Server: %s\n", cfg.ServerURL)
|
||||
fmt.Printf("⏱️ Check-in Interval: %ds\n", cfg.CheckInInterval)
|
||||
fmt.Printf("🔧 Version: %s\n", version.Version)
|
||||
fmt.Printf("Agent ID: %s\n", cfg.AgentID)
|
||||
fmt.Printf("Server: %s\n", cfg.ServerURL)
|
||||
fmt.Printf("Check-in Interval: %ds\n", cfg.CheckInInterval)
|
||||
fmt.Printf("Version: %s\n", version.Version)
|
||||
|
||||
// Registration status
|
||||
if cfg.IsRegistered() {
|
||||
fmt.Println("✅ Registration: Registered")
|
||||
fmt.Println("Registration: Registered")
|
||||
} else {
|
||||
fmt.Println("❌ Registration: Not registered")
|
||||
fmt.Println("Registration: Not registered")
|
||||
}
|
||||
|
||||
// System information
|
||||
sysInfo, err := system.GetSystemInfo(version.Version)
|
||||
if err == nil {
|
||||
fmt.Printf("🖥️ Hostname: %s\n", sysInfo.Hostname)
|
||||
fmt.Printf("💻 OS: %s %s (%s)\n", sysInfo.OSType, sysInfo.OSVersion, sysInfo.OSArchitecture)
|
||||
fmt.Printf("⏱️ Uptime: %s\n", sysInfo.Uptime)
|
||||
fmt.Printf("Hostname: %s\n", sysInfo.Hostname)
|
||||
fmt.Printf("OS: %s %s (%s)\n", sysInfo.OSType, sysInfo.OSVersion, sysInfo.OSArchitecture)
|
||||
fmt.Printf("Uptime: %s\n", sysInfo.Uptime)
|
||||
}
|
||||
|
||||
// Cache status
|
||||
localCache, err := cache.Load()
|
||||
if err == nil && !localCache.LastScanTime.IsZero() {
|
||||
fmt.Printf("🔍 Last Scan: %s\n", localCache.LastScanTime.Format(time.RFC3339))
|
||||
fmt.Printf("📦 Updates Available: %d\n", len(localCache.Updates))
|
||||
fmt.Printf("Last Scan: %s\n", localCache.LastScanTime.Format(time.RFC3339))
|
||||
fmt.Printf("Updates Available: %d\n", len(localCache.Updates))
|
||||
}
|
||||
|
||||
fmt.Println("==================================================================")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
|
|
@ -9,6 +10,25 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
|
||||
)
|
||||
|
||||
// reportFailure sends a command failure back to the server so it doesn't
|
||||
// get stuck in "received" indefinitely. Called when a handler that already
|
||||
// reported "started" hits an error before completing.
|
||||
func reportFailure(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, cmdType, cmdID, errMsg string) {
|
||||
logReport := client.LogReport{
|
||||
CommandID: cmdID,
|
||||
Action: cmdType,
|
||||
Result: "failed",
|
||||
Stderr: errMsg,
|
||||
ExitCode: 1,
|
||||
DurationSeconds: 0,
|
||||
|
||||
}
|
||||
// Best-effort: don't block the caller if the server is unreachable.
|
||||
if rErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); rErr != nil {
|
||||
log.Printf("[ERROR] [agent] [dispatch] failure_report_failed cmd_type=%s cmd_id=%s error=%q", cmdType, cmdID, rErr)
|
||||
}
|
||||
}
|
||||
|
||||
// DispatchCrossPlatformCommand handles command types that work on every
|
||||
// platform (scans, agent self-update). Returns true if cmd.Type matched a
|
||||
// known cross-platform command; the caller is then responsible only for
|
||||
|
|
@ -59,6 +79,7 @@ func DispatchCrossPlatformCommand(
|
|||
}
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [%s] command_failed error=%q", cmd.Type, err)
|
||||
reportFailure(apiClient, cfg, ackTracker, cmd.Type, cmd.ID, fmt.Sprintf("command failed: %s", err))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/scanner"
|
||||
)
|
||||
|
||||
// mapAny extracts a typed value from map[string]interface{} with zero fallback.
|
||||
func mapAny[T any](v interface{}) T {
|
||||
r, _ := v.(T)
|
||||
return r
|
||||
}
|
||||
|
||||
// reportLogWithAck reports a command log to the server and tracks it for acknowledgment
|
||||
func reportLogWithAck(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, logReport client.LogReport) error {
|
||||
// Track this command result as pending acknowledgment
|
||||
|
|
@ -53,7 +59,7 @@ func HandleScanUpdates(apiClient *client.Client, cfg *config.Config, ackTracker
|
|||
// Linux: Try APT first, then DNF
|
||||
if osType == "linux" {
|
||||
// Try APT
|
||||
aptScanner := orchestrator.NewAPTScannerWrapper(scanner.NewAPTScanner())
|
||||
aptScanner := scanner.NewAPTScanner()
|
||||
if aptScanner.IsAvailable() {
|
||||
log.Println("[updates] Running APT scan...")
|
||||
result, err := orch.ScanSingle(ctx, "apt")
|
||||
|
|
@ -77,7 +83,7 @@ func HandleScanUpdates(apiClient *client.Client, cfg *config.Config, ackTracker
|
|||
}
|
||||
|
||||
// Try DNF
|
||||
dnfScanner := orchestrator.NewDNFScannerWrapper(scanner.NewDNFScanner())
|
||||
dnfScanner := scanner.NewDNFScanner()
|
||||
if dnfScanner.IsAvailable() {
|
||||
log.Println("[updates] Running DNF scan...")
|
||||
result, err := orch.ScanSingle(ctx, "dnf")
|
||||
|
|
@ -104,7 +110,7 @@ func HandleScanUpdates(apiClient *client.Client, cfg *config.Config, ackTracker
|
|||
// Windows: Try Windows Update and Winget
|
||||
if osType == "windows" {
|
||||
// Try Windows Update
|
||||
windowsScanner := orchestrator.NewWindowsUpdateScannerWrapper(scanner.NewWindowsUpdateScanner())
|
||||
windowsScanner := scanner.NewWindowsUpdateScanner()
|
||||
if windowsScanner.IsAvailable() {
|
||||
log.Println("[updates] Running Windows Update scan...")
|
||||
result, err := orch.ScanSingle(ctx, "windows")
|
||||
|
|
@ -128,7 +134,7 @@ func HandleScanUpdates(apiClient *client.Client, cfg *config.Config, ackTracker
|
|||
}
|
||||
|
||||
// Try Winget
|
||||
wingetScanner := orchestrator.NewWingetScannerWrapper(scanner.NewWingetScanner())
|
||||
wingetScanner := scanner.NewWingetScanner()
|
||||
if wingetScanner.IsAvailable() {
|
||||
log.Println("[updates] Running Winget scan...")
|
||||
result, err := orch.ScanSingle(ctx, "winget")
|
||||
|
|
@ -197,13 +203,11 @@ func HandleScanStorage(apiClient *client.Client, cfg *config.Config, ackTracker
|
|||
ctx := context.Background()
|
||||
startTime := time.Now()
|
||||
|
||||
// Execute storage scanner
|
||||
result, err := orch.ScanSingle(ctx, "storage")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan storage: %w", err)
|
||||
}
|
||||
|
||||
// Format results
|
||||
results := []orchestrator.ScanResult{result}
|
||||
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
|
||||
|
||||
|
|
@ -211,53 +215,39 @@ func HandleScanStorage(apiClient *client.Client, cfg *config.Config, ackTracker
|
|||
stdout += fmt.Sprintf("\nStorage scan completed in %.2f seconds\n", duration.Seconds())
|
||||
|
||||
// Report storage metrics to server using dedicated endpoint
|
||||
// Use proper StorageMetricReport with clean field names
|
||||
storageScanner := orchestrator.NewStorageScanner(cfg.AgentVersion)
|
||||
var metrics []orchestrator.StorageMetric // Declare outside if block for ReportLog access
|
||||
if storageScanner.IsAvailable() {
|
||||
var err error
|
||||
metrics, err = storageScanner.ScanStorage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan storage metrics: %w", err)
|
||||
if len(result.Updates) > 0 {
|
||||
metricItems := make([]models.StorageMetric, 0, len(result.Updates))
|
||||
for _, u := range result.Updates {
|
||||
m := u.Metadata
|
||||
metricItems = append(metricItems, models.StorageMetric{
|
||||
Mountpoint: u.PackageName,
|
||||
Device: u.RepositorySource,
|
||||
DiskType: mapAny[string](m["disk_type"]),
|
||||
Filesystem: mapAny[string](m["filesystem"]),
|
||||
TotalBytes: mapAny[int64](m["total_bytes"]),
|
||||
UsedBytes: mapAny[int64](m["used_bytes"]),
|
||||
AvailableBytes: mapAny[int64](m["available_bytes"]),
|
||||
UsedPercent: mapAny[float64](m["used_percent"]),
|
||||
IsRoot: mapAny[bool](m["is_root"]),
|
||||
IsLargest: mapAny[bool](m["is_largest"]),
|
||||
Severity: u.Severity,
|
||||
})
|
||||
}
|
||||
|
||||
if len(metrics) > 0 {
|
||||
// Convert from orchestrator.StorageMetric to models.StorageMetric
|
||||
metricItems := make([]models.StorageMetric, 0, len(metrics))
|
||||
for _, m := range metrics {
|
||||
item := models.StorageMetric{
|
||||
Mountpoint: m.Mountpoint,
|
||||
Device: m.Device,
|
||||
DiskType: m.DiskType,
|
||||
Filesystem: m.Filesystem,
|
||||
TotalBytes: m.TotalBytes,
|
||||
UsedBytes: m.UsedBytes,
|
||||
AvailableBytes: m.AvailableBytes,
|
||||
UsedPercent: m.UsedPercent,
|
||||
IsRoot: m.IsRoot,
|
||||
IsLargest: m.IsLargest,
|
||||
Severity: m.Severity,
|
||||
Metadata: m.Metadata,
|
||||
}
|
||||
metricItems = append(metricItems, item)
|
||||
}
|
||||
|
||||
report := models.StorageMetricReport{
|
||||
AgentID: cfg.AgentID,
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Metrics: metricItems,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportStorageMetrics(cfg.AgentID, report); err != nil {
|
||||
return fmt.Errorf("failed to report storage metrics: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [storage] Successfully reported %d storage metrics to server\n", len(metrics))
|
||||
report := models.StorageMetricReport{
|
||||
AgentID: cfg.AgentID,
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Metrics: metricItems,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportStorageMetrics(cfg.AgentID, report); err != nil {
|
||||
return fmt.Errorf("failed to report storage metrics: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [storage] Successfully reported %d storage metrics to server\n", len(result.Updates))
|
||||
}
|
||||
|
||||
// Create history entry for unified view with proper formatting
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "scan_storage",
|
||||
|
|
@ -269,7 +259,7 @@ func HandleScanStorage(apiClient *client.Client, cfg *config.Config, ackTracker
|
|||
Metadata: map[string]string{
|
||||
"subsystem_label": "Disk Usage",
|
||||
"subsystem": "storage",
|
||||
"metrics_count": fmt.Sprintf("%d", len(metrics)),
|
||||
"metrics_count": fmt.Sprintf("%d", len(result.Updates)),
|
||||
},
|
||||
}
|
||||
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
|
|
@ -290,13 +280,11 @@ func HandleScanSystem(apiClient *client.Client, cfg *config.Config, ackTracker *
|
|||
ctx := context.Background()
|
||||
startTime := time.Now()
|
||||
|
||||
// Execute system scanner
|
||||
result, err := orch.ScanSingle(ctx, "system")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan system: %w", err)
|
||||
}
|
||||
|
||||
// Format results
|
||||
results := []orchestrator.ScanResult{result}
|
||||
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
|
||||
|
||||
|
|
@ -304,47 +292,33 @@ func HandleScanSystem(apiClient *client.Client, cfg *config.Config, ackTracker *
|
|||
stdout += fmt.Sprintf("\nSystem scan completed in %.2f seconds\n", duration.Seconds())
|
||||
|
||||
// Report system metrics to server using dedicated endpoint
|
||||
// Get system scanner and use proper interface
|
||||
systemScanner := orchestrator.NewSystemScanner(cfg.AgentVersion)
|
||||
var metrics []orchestrator.SystemMetric // Declare outside if block for ReportLog access
|
||||
if systemScanner.IsAvailable() {
|
||||
var err error
|
||||
metrics, err = systemScanner.ScanSystem()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan system metrics: %w", err)
|
||||
if len(result.Updates) > 0 {
|
||||
metricItems := make([]client.MetricsReportItem, 0, len(result.Updates))
|
||||
for _, u := range result.Updates {
|
||||
metricItems = append(metricItems, client.MetricsReportItem{
|
||||
PackageType: u.PackageType,
|
||||
PackageName: u.PackageName,
|
||||
CurrentVersion: u.CurrentVersion,
|
||||
AvailableVersion: u.AvailableVersion,
|
||||
Severity: u.Severity,
|
||||
RepositorySource: u.RepositorySource,
|
||||
Metadata: u.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
if len(metrics) > 0 {
|
||||
// Convert SystemMetric to MetricsReportItem for API call
|
||||
metricItems := make([]client.MetricsReportItem, 0, len(metrics))
|
||||
for _, metric := range metrics {
|
||||
item := client.MetricsReportItem{
|
||||
PackageType: "system",
|
||||
PackageName: metric.MetricName,
|
||||
CurrentVersion: metric.CurrentValue,
|
||||
AvailableVersion: metric.AvailableValue,
|
||||
Severity: metric.Severity,
|
||||
RepositorySource: metric.MetricType,
|
||||
Metadata: metric.Metadata,
|
||||
}
|
||||
metricItems = append(metricItems, item)
|
||||
}
|
||||
|
||||
report := client.MetricsReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Metrics: metricItems,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportMetrics(cfg.AgentID, report); err != nil {
|
||||
return fmt.Errorf("failed to report system metrics: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [system] Reported %d system metrics to server\n", len(metrics))
|
||||
report := client.MetricsReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Metrics: metricItems,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportMetrics(cfg.AgentID, report); err != nil {
|
||||
return fmt.Errorf("failed to report system metrics: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [system] Reported %d system metrics to server\n", len(result.Updates))
|
||||
}
|
||||
|
||||
// Create history entry for unified view with proper formatting
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "scan_system",
|
||||
|
|
@ -356,7 +330,7 @@ func HandleScanSystem(apiClient *client.Client, cfg *config.Config, ackTracker *
|
|||
Metadata: map[string]string{
|
||||
"subsystem_label": "System Metrics",
|
||||
"subsystem": "system",
|
||||
"metrics_count": fmt.Sprintf("%d", len(metrics)),
|
||||
"metrics_count": fmt.Sprintf("%d", len(result.Updates)),
|
||||
},
|
||||
}
|
||||
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
|
|
@ -377,13 +351,11 @@ func HandleScanDocker(apiClient *client.Client, cfg *config.Config, ackTracker *
|
|||
ctx := context.Background()
|
||||
startTime := time.Now()
|
||||
|
||||
// Execute Docker scanner
|
||||
result, err := orch.ScanSingle(ctx, "docker")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan Docker: %w", err)
|
||||
}
|
||||
|
||||
// Format results
|
||||
results := []orchestrator.ScanResult{result}
|
||||
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
|
||||
|
||||
|
|
@ -391,63 +363,43 @@ func HandleScanDocker(apiClient *client.Client, cfg *config.Config, ackTracker *
|
|||
stdout += fmt.Sprintf("\nDocker scan completed in %.2f seconds\n", duration.Seconds())
|
||||
|
||||
// Report Docker images to server using dedicated endpoint
|
||||
// Get Docker scanner and use proper interface
|
||||
dockerScanner, err := orchestrator.NewDockerScanner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Docker scanner: %w", err)
|
||||
}
|
||||
defer dockerScanner.Close()
|
||||
|
||||
var images []orchestrator.DockerImage // Declare outside if block for ReportLog access
|
||||
var updateCount int // Declare outside if block for ReportLog access
|
||||
if dockerScanner.IsAvailable() {
|
||||
images, err = dockerScanner.ScanDocker()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan Docker images: %w", err)
|
||||
updateCount := 0
|
||||
if len(result.Updates) > 0 {
|
||||
imageItems := make([]client.DockerReportItem, 0, len(result.Updates))
|
||||
for _, u := range result.Updates {
|
||||
hasUpdate := false
|
||||
if v, ok := u.Metadata["has_update"]; ok {
|
||||
hasUpdate, _ = v.(bool)
|
||||
}
|
||||
if hasUpdate {
|
||||
updateCount++
|
||||
}
|
||||
imageItems = append(imageItems, client.DockerReportItem{
|
||||
PackageType: u.PackageType,
|
||||
PackageName: u.PackageName,
|
||||
CurrentVersion: u.CurrentVersion,
|
||||
AvailableVersion: u.AvailableVersion,
|
||||
Severity: u.Severity,
|
||||
RepositorySource: u.RepositorySource,
|
||||
Metadata: u.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// Always report all Docker images (not just those with updates)
|
||||
updateCount = 0 // Reset for counting
|
||||
if len(images) > 0 {
|
||||
// Convert DockerImage to DockerReportItem for API call
|
||||
imageItems := make([]client.DockerReportItem, 0, len(images))
|
||||
for _, image := range images {
|
||||
item := client.DockerReportItem{
|
||||
PackageType: "docker_image",
|
||||
PackageName: image.ImageName,
|
||||
CurrentVersion: image.ImageID,
|
||||
AvailableVersion: image.LatestImageID,
|
||||
Severity: image.Severity,
|
||||
RepositorySource: image.RepositorySource,
|
||||
Metadata: image.Metadata,
|
||||
}
|
||||
imageItems = append(imageItems, item)
|
||||
}
|
||||
|
||||
report := client.DockerReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Images: imageItems,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportDockerImages(cfg.AgentID, report); err != nil {
|
||||
return fmt.Errorf("failed to report Docker images: %w", err)
|
||||
}
|
||||
|
||||
for _, image := range images {
|
||||
if image.HasUpdate {
|
||||
updateCount++
|
||||
}
|
||||
}
|
||||
log.Printf("[INFO] [agent] [docker] Reported %d Docker images (%d with updates) to server\n", len(images), updateCount)
|
||||
} else {
|
||||
log.Println("[INFO] [agent] [docker] No Docker images found")
|
||||
report := client.DockerReport{
|
||||
CommandID: commandID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Images: imageItems,
|
||||
}
|
||||
|
||||
if err := apiClient.ReportDockerImages(cfg.AgentID, report); err != nil {
|
||||
return fmt.Errorf("failed to report Docker images: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [docker] Reported %d Docker images (%d with updates) to server\n", len(result.Updates), updateCount)
|
||||
} else {
|
||||
log.Println("[INFO] [agent] [docker] Docker not available on this system")
|
||||
log.Println("[INFO] [agent] [docker] No Docker images found")
|
||||
}
|
||||
|
||||
// Create history entry for unified view with proper formatting
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "scan_docker",
|
||||
|
|
@ -459,7 +411,7 @@ func HandleScanDocker(apiClient *client.Client, cfg *config.Config, ackTracker *
|
|||
Metadata: map[string]string{
|
||||
"subsystem_label": "Docker Images",
|
||||
"subsystem": "docker",
|
||||
"images_count": fmt.Sprintf("%d", len(images)),
|
||||
"images_count": fmt.Sprintf("%d", len(result.Updates)),
|
||||
"updates_found": fmt.Sprintf("%d", updateCount),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ ProtectHome=true
|
|||
# self-upgrade can write the new binary and .bak alongside it —
|
||||
# ProtectSystem=strict makes the mount RO inside the unit's namespace
|
||||
# and sudo inherits that namespace.
|
||||
ReadWritePaths=/var/lib/redflag-agent /usr/local/bin
|
||||
ReadWritePaths=/var/lib/redflag-agent /usr/local/bin /var/log /var/cache
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
|
|
|
|||
298
agent/internal/kernel/ebpf_consumer.go
Normal file
298
agent/internal/kernel/ebpf_consumer.go
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
package kernel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/cilium/ebpf"
|
||||
"github.com/cilium/ebpf/rlimit"
|
||||
"github.com/cilium/ebpf/ringbuf"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
// MAGIC_NUMBER matches the eBPF program
|
||||
MAGIC_NUMBER = 0xDEADBEEF
|
||||
)
|
||||
|
||||
// EBPFConsumer reads events from the eBPF ring buffer and forwards them to the policy checker
|
||||
type EBPFConsumer struct {
|
||||
config *config.Config
|
||||
running bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
wgLock sync.Mutex
|
||||
rd *ringbuf.Reader
|
||||
elfPath string
|
||||
}
|
||||
|
||||
// NewEBPFConsumer creates a new eBPF ring buffer consumer
|
||||
func NewEBPFConsumer(cfg *config.Config) (*EBPFConsumer, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &EBPFConsumer{
|
||||
config: cfg,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
elfPath: cfg.KernelEnforcement.RingBufferPath + "/pkg-gate.o",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins consuming events from the eBPF ring buffer
|
||||
func (e *EBPFConsumer) Start() error {
|
||||
e.wgLock.Lock()
|
||||
if e.running {
|
||||
e.wgLock.Unlock()
|
||||
return nil
|
||||
}
|
||||
e.running = true
|
||||
e.wgLock.Unlock()
|
||||
|
||||
log.Printf("[INFO] [kernel] [ebpf] consumer_started")
|
||||
|
||||
// Remove memlock limit for eBPF
|
||||
if err := rlimit.RemoveMemlock(); err != nil {
|
||||
return fmt.Errorf("failed to remove memlock: %w", err)
|
||||
}
|
||||
|
||||
// Load the eBPF collection from the ELF file
|
||||
spec, err := ebpf.LoadCollectionSpec(e.elfPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load eBPF collection spec: %w", err)
|
||||
}
|
||||
|
||||
// Find the ring buffer map by name
|
||||
_, ok := spec.Maps["rb_map"]
|
||||
if !ok {
|
||||
return fmt.Errorf("ring buffer map 'rb_map' not found in eBPF collection")
|
||||
}
|
||||
|
||||
// Load collection to get loaded maps
|
||||
coll, err := ebpf.NewCollection(spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load eBPF collection: %w", err)
|
||||
}
|
||||
|
||||
// Access the loaded ring buffer map
|
||||
loadedMap := coll.Maps["rb_map"]
|
||||
if loadedMap == nil {
|
||||
coll.Close()
|
||||
return fmt.Errorf("ring buffer map 'rb_map' not found in loaded collection")
|
||||
}
|
||||
|
||||
// Create ring buffer reader
|
||||
rd, err := ringbuf.NewReader(loadedMap)
|
||||
if err != nil {
|
||||
coll.Close()
|
||||
return fmt.Errorf("failed to create ring buffer: %w", err)
|
||||
}
|
||||
|
||||
e.rd = rd
|
||||
e.elfPath = "" // Don't load again on stop
|
||||
|
||||
// Set up signal handling for graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigChan
|
||||
log.Printf("[INFO] [kernel] [ebpf] shutdown_signal_received")
|
||||
e.cancel()
|
||||
e.wg.Wait()
|
||||
e.rd.Close()
|
||||
}()
|
||||
|
||||
e.wg.Add(1)
|
||||
go e.consumeEvents()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the consumer
|
||||
func (e *EBPFConsumer) Stop() error {
|
||||
e.cancel()
|
||||
e.wgLock.Lock()
|
||||
e.running = false
|
||||
e.wgLock.Unlock()
|
||||
|
||||
e.wg.Wait()
|
||||
if e.rd != nil {
|
||||
e.rd.Close()
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [kernel] [ebpf] consumer_stopped")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// consumeEvents reads from the ring buffer and forwards events to the policy checker
|
||||
func (e *EBPFConsumer) consumeEvents() {
|
||||
defer e.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-e.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Read event from ring buffer
|
||||
record, err := e.rd.Read()
|
||||
if err != nil {
|
||||
if errors.Is(err, ringbuf.ErrClosed) {
|
||||
return
|
||||
}
|
||||
log.Printf("[ERROR] [kernel] [ebpf] ringbuf_read_failed error=%v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
e.processEvent(record.RawSample)
|
||||
}
|
||||
}
|
||||
|
||||
// processEvent handles a single eBPF event
|
||||
func (e *EBPFConsumer) processEvent(data []byte) {
|
||||
// Decode using the struct from eBPF (must match exactly)
|
||||
// Layout: timestamp(8) pid(4) pgid(4) uid(8) gid(8) parent_pid(4) comm(16) cmdline(256) parent_comm(16) magic(4) = 328 bytes
|
||||
event := &redflagEvent{}
|
||||
// Use unsafe to copy raw bytes into the struct
|
||||
bytess := (*[328]byte)(unsafe.Pointer(event))
|
||||
copy(bytess[0:8], data[0:8])
|
||||
copy(bytess[8:12], data[8:12])
|
||||
copy(bytess[12:16], data[12:16])
|
||||
copy(bytess[16:24], data[16:24])
|
||||
copy(bytess[24:32], data[24:32])
|
||||
copy(bytess[32:36], data[32:36])
|
||||
copy(bytess[36:52], data[36:52])
|
||||
copy(bytess[52:308], data[52:308])
|
||||
copy(bytess[308:324], data[308:324])
|
||||
copy(bytess[324:328], data[324:328])
|
||||
|
||||
// Validate magic number
|
||||
if event.Magic != MAGIC_NUMBER {
|
||||
log.Printf("[ERROR] [kernel] [ebpf] invalid_magic magic=0x%x expected=0x%x",
|
||||
event.Magic, MAGIC_NUMBER)
|
||||
return
|
||||
}
|
||||
|
||||
// Forward to rs-helper for policy decision
|
||||
decision, reason, err := e.checkPolicy(event)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [kernel] [ebpf] policy_check_failed error=%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if decision == "deny" {
|
||||
log.Printf("[ERROR] [kernel] [ebpf] execve_deny pid=%d comm=%s reason=%s",
|
||||
event.PID, event.Comm, reason)
|
||||
} else {
|
||||
log.Printf("[INFO] [kernel] [ebpf] execve_allowed pid=%d comm=%s",
|
||||
event.PID, event.Comm)
|
||||
}
|
||||
}
|
||||
|
||||
// redflagEvent matches the eBPF struct redflag_event layout
|
||||
type redflagEvent struct {
|
||||
Timestamp uint64
|
||||
PID uint32
|
||||
PGID uint32
|
||||
UID uint64
|
||||
GID uint64
|
||||
ParentPID uint32
|
||||
Comm [16]byte
|
||||
Cmdline [256]byte
|
||||
ParentComm [16]byte
|
||||
Magic uint32
|
||||
}
|
||||
|
||||
// checkPolicy evaluates the policy for a package manager invocation
|
||||
func (e *EBPFConsumer) checkPolicy(event *redflagEvent) (string, string, error) {
|
||||
// Decode strings for policy check
|
||||
comm := *(*string)(unsafe.Pointer(&event.Comm))
|
||||
cmdline := *(*string)(unsafe.Pointer(&event.Cmdline))
|
||||
parentComm := *(*string)(unsafe.Pointer(&event.ParentComm))
|
||||
|
||||
log.Printf("[DEBUG] [kernel] [ebpf] policy_check comm=%s cmdline=%s parent=%s uid=%d",
|
||||
comm, cmdline, parentComm, event.UID)
|
||||
|
||||
// Check if this is a package manager command
|
||||
isPackageManager := isPackageManager(comm)
|
||||
if !isPackageManager {
|
||||
log.Printf("[INFO] [kernel] [ebpf] not_package_manager comm=%s", comm)
|
||||
return "allow", "not_package_manager", nil
|
||||
}
|
||||
|
||||
// TODO: Call rs-helper via Unix socket for policy decision
|
||||
// For now, return allow (agent should have rs-helper running)
|
||||
|
||||
return "allow", "policy_evaluation_pending", nil
|
||||
}
|
||||
|
||||
// isPackageManager checks if a command is a known package manager
|
||||
func isPackageManager(comm string) bool {
|
||||
pkgManagers := []string{"apt", "apt-get", "apt-cache", "dnf", "yum", "rpm-ostree",
|
||||
"npm", "pnpm", "bun", "pip", "pip3", "uv",
|
||||
"docker", "crun", "containerd"}
|
||||
|
||||
for _, pm := range pkgManagers {
|
||||
if comm == pm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Event represents a typed intercept event for API use
|
||||
type Event struct {
|
||||
Timestamp time.Time
|
||||
PID uint32
|
||||
PGID uint32
|
||||
Comm string
|
||||
Cmdline string
|
||||
UID uint64
|
||||
GID uint64
|
||||
ParentPID uint32
|
||||
ParentComm string
|
||||
}
|
||||
|
||||
// FromBinary creates an Event from binary data (deprecated, use direct struct access)
|
||||
func FromBinary(data []byte) (*Event, error) {
|
||||
if len(data) < 328 {
|
||||
return nil, fmt.Errorf("data too short: %d", len(data))
|
||||
}
|
||||
|
||||
event := &redflagEvent{}
|
||||
// Use unsafe to copy raw bytes into the struct
|
||||
bytess := (*[328]byte)(unsafe.Pointer(event))
|
||||
copy(bytess[0:8], data[0:8])
|
||||
copy(bytess[8:12], data[8:12])
|
||||
copy(bytess[12:16], data[12:16])
|
||||
copy(bytess[16:24], data[16:24])
|
||||
copy(bytess[24:32], data[24:32])
|
||||
copy(bytess[32:36], data[32:36])
|
||||
copy(bytess[36:52], data[36:52])
|
||||
copy(bytess[52:308], data[52:308])
|
||||
copy(bytess[308:324], data[308:324])
|
||||
copy(bytess[324:328], data[324:328])
|
||||
|
||||
return &Event{
|
||||
Timestamp: time.Unix(0, int64(event.Timestamp)),
|
||||
PID: event.PID,
|
||||
PGID: event.PGID,
|
||||
Comm: string(bytes.TrimRight(event.Comm[:], "\x00")),
|
||||
Cmdline: string(bytes.TrimRight(event.Cmdline[:], "\x00")),
|
||||
UID: event.UID,
|
||||
GID: event.GID,
|
||||
ParentPID: event.ParentPID,
|
||||
ParentComm: string(bytes.TrimRight(event.ParentComm[:], "\x00")),
|
||||
}, nil
|
||||
}
|
||||
79
agent/internal/kernel/kernel.go
Normal file
79
agent/internal/kernel/kernel.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package kernel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
)
|
||||
|
||||
type Enforcer interface {
|
||||
Start(ctx context.Context) error
|
||||
Stop() error
|
||||
GetPackageType() string
|
||||
}
|
||||
|
||||
type EnforcerFactory func(*config.Config) (Enforcer, error)
|
||||
|
||||
var enforcers = map[string]EnforcerFactory{}
|
||||
|
||||
func RegisterEnforcer(pkgType string, factory EnforcerFactory) {
|
||||
enforcers[pkgType] = factory
|
||||
}
|
||||
|
||||
func NewEnforcer(cfg *config.Config) (Enforcer, error) {
|
||||
if !cfg.KernelEnforcement.Enabled {
|
||||
return &noopEnforcer{}, nil
|
||||
}
|
||||
|
||||
// Determine package type based on platform
|
||||
pkgType := "linux"
|
||||
if cfg.OS.Type == "windows" {
|
||||
pkgType = "windows"
|
||||
}
|
||||
|
||||
if factory, ok := enforcers[pkgType]; ok {
|
||||
return factory(cfg)
|
||||
}
|
||||
|
||||
log.Printf("[WARNING] [kernel] unknown_package_type=%s", pkgType)
|
||||
return &noopEnforcer{}, nil
|
||||
}
|
||||
|
||||
type noopEnforcer struct{}
|
||||
|
||||
func (n *noopEnforcer) Start(ctx context.Context) error { return nil }
|
||||
func (n *noopEnforcer) Stop() error { return nil }
|
||||
func (n *noopEnforcer) GetPackageType() string { return "noop" }
|
||||
|
||||
type EBPFEnforcer struct {
|
||||
consumer *EBPFConsumer
|
||||
}
|
||||
|
||||
func NewEBPFEnforcer(cfg *config.Config) (*EBPFEnforcer, error) {
|
||||
consumer, err := NewEBPFConsumer(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EBPFEnforcer{consumer: consumer}, nil
|
||||
}
|
||||
|
||||
func (e *EBPFEnforcer) Start(ctx context.Context) error {
|
||||
return e.consumer.Start()
|
||||
}
|
||||
|
||||
func (e *EBPFEnforcer) Stop() error {
|
||||
return e.consumer.Stop()
|
||||
}
|
||||
|
||||
func (e *EBPFEnforcer) GetPackageType() string { return "linux" }
|
||||
|
||||
func init() {
|
||||
RegisterEnforcer("linux", func(cfg *config.Config) (Enforcer, error) {
|
||||
consumer, err := NewEBPFConsumer(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EBPFEnforcer{consumer: consumer}, nil
|
||||
})
|
||||
}
|
||||
164
agent/internal/kernel/windows_wdac.go
Normal file
164
agent/internal/kernel/windows_wdac.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package kernel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
)
|
||||
|
||||
// WDACPolicy handles Windows WDAC (Windows Defender Application Control) enforcement
|
||||
type WDACPolicy struct {
|
||||
client *client.Client
|
||||
config *config.Config
|
||||
policyName string
|
||||
policyHash string
|
||||
}
|
||||
|
||||
// NewWDACPolicy creates a new WDACPolicy enforcer
|
||||
func NewWDACPolicy(c *client.Client, cfg *config.Config) *WDACPolicy {
|
||||
return &WDACPolicy{
|
||||
client: c,
|
||||
config: cfg,
|
||||
policyName: "RedFlag-Package-Manager",
|
||||
}
|
||||
}
|
||||
|
||||
// GetPackageType returns the package type this enforcer handles
|
||||
func (w *WDACPolicy) GetPackageType() string {
|
||||
return "wdac"
|
||||
}
|
||||
|
||||
// IsAvailable checks if WDAC is available on this system
|
||||
func (w *WDACPolicy) IsAvailable() bool {
|
||||
// WDAC only available on Windows
|
||||
return true // Platform check done by caller
|
||||
}
|
||||
|
||||
// CheckPolicy evaluates if a package manager invocation is allowed
|
||||
func (w *WDACPolicy) CheckPolicy(packageType, packageName, packageVersion string) (bool, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
log.Printf("[INFO] [kernel] [wdac] policy_check package=%s type=%s version=%s", packageName, packageType, packageVersion)
|
||||
|
||||
result, err := w.fetchPolicy(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [kernel] [wdac] policy_fetch_failed package=%s error=%v", packageName, err)
|
||||
return false, "policy_fetch_failed: " + err.Error(), nil
|
||||
}
|
||||
|
||||
if result == "" {
|
||||
return false, "no_policy", nil
|
||||
}
|
||||
|
||||
// Parse policy and check if package manager is allowed
|
||||
allowed, reason := w.evaluatePolicy(packageType, packageName, result)
|
||||
if !allowed {
|
||||
log.Printf("[ERROR] [kernel] [wdac] policy_denied package=%s reason=%s", packageName, reason)
|
||||
} else {
|
||||
log.Printf("[INFO] [kernel] [wdac] policy_allowed package=%s", packageName)
|
||||
}
|
||||
|
||||
return allowed, reason, nil
|
||||
}
|
||||
|
||||
// fetchPolicy retrieves the current WDAC policy from the server
|
||||
func (w *WDACPolicy) fetchPolicy(ctx context.Context) (string, error) {
|
||||
// TODO: Replace with actual WDAC COM API calls via go-ole
|
||||
// For now, fetch policy hash from server and validate against local policy
|
||||
|
||||
hash, err := w.client.GetWdacPolicyHash(w.config.AgentID, w.config.ServerURL)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [kernel] [wdac] policy_hash_fetch_skipped error=%v", err)
|
||||
// For now, treat as no policy available (fail-closed)
|
||||
return "", fmt.Errorf("no_policy")
|
||||
}
|
||||
|
||||
if hash == "" {
|
||||
return "", fmt.Errorf("no WDAC policy available from server")
|
||||
}
|
||||
|
||||
if hash != w.policyHash {
|
||||
log.Printf("[WARNING] [kernel] [wdac] policy_hash_mismatch local=%s server=%s", w.policyHash, hash)
|
||||
// Policy needs update - return empty to trigger update
|
||||
return "", fmt.Errorf("policy_hash_mismatch")
|
||||
}
|
||||
|
||||
return w.policyHash, nil
|
||||
}
|
||||
|
||||
// evaluatePolicy checks if the package manager is allowed by the current policy
|
||||
func (w *WDACPolicy) evaluatePolicy(packageType, packageName, policyHash string) (bool, string) {
|
||||
// Allowed package managers (fail-closed: deny all others)
|
||||
allowed := map[string]bool{
|
||||
"apt": true,
|
||||
"apt-get": true,
|
||||
"apt-cache": true,
|
||||
"dnf": true,
|
||||
"yum": true,
|
||||
"rpm-ostree": true,
|
||||
"npm": true,
|
||||
"pnpm": true,
|
||||
"bun": true,
|
||||
"pip": true,
|
||||
"pip3": true,
|
||||
"uv": true,
|
||||
"docker": true,
|
||||
"crun": true,
|
||||
"containerd": true,
|
||||
}
|
||||
|
||||
if allowed[packageType] {
|
||||
return true, "allowed_by_policy"
|
||||
}
|
||||
|
||||
return false, "package_manager_not_in_policy"
|
||||
}
|
||||
|
||||
// UpdatePolicy downloads and installs a new WDAC policy from the server
|
||||
func (w *WDACPolicy) UpdatePolicy() error {
|
||||
log.Printf("[INFO] [kernel] [wdac] update_policy_start")
|
||||
|
||||
newHash, err := w.client.GetWdacPolicyHash(w.config.AgentID, w.config.ServerURL)
|
||||
if err != nil {
|
||||
// For now, keep existing policy hash (no update)
|
||||
log.Printf("[WARNING] [kernel] [wdac] policy_update_skipped error=%v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if newHash == "" {
|
||||
return fmt.Errorf("no policy available from server")
|
||||
}
|
||||
|
||||
// TODO: Download policy file and apply via Set-CIPolicy
|
||||
// For now, just update the hash
|
||||
w.policyHash = newHash
|
||||
|
||||
log.Printf("[INFO] [kernel] [wdac] update_policy_complete hash=%s", newHash[:16]+"...")
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallPolicy installs a WDAC policy from a binary or XML file
|
||||
func (w *WDACPolicy) InstallPolicy(policyPath string) error {
|
||||
// TODO: Implement actual WDAC policy installation via COM API
|
||||
// Uses Microsoft.Management.Ops or similar COM interface
|
||||
|
||||
log.Printf("[INFO] [kernel] [wdac] install_policy path=%s", policyPath)
|
||||
return fmt.Errorf("install_policy_not_implemented_yet")
|
||||
}
|
||||
|
||||
// GetPolicyStatus returns the current WDAC policy status
|
||||
func (w *WDACPolicy) GetPolicyStatus() (string, error) {
|
||||
// TODO: Query WDAC service for status
|
||||
return "unknown", nil
|
||||
}
|
||||
|
||||
// ResetPolicy resets the WDAC policy to a known-good state
|
||||
func (w *WDACPolicy) ResetPolicy() error {
|
||||
log.Printf("[WARNING] [kernel] [wdac] reset_policy_initiated")
|
||||
return fmt.Errorf("reset_policy_not_implemented_yet")
|
||||
}
|
||||
|
|
@ -17,6 +17,108 @@ import (
|
|||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ConfirmedTracker is a disk-persisted set of command IDs the server has confirmed
|
||||
// as completed (via ReportLog). Used to distinguish between "command re-sent by
|
||||
// server before agent confirmed" vs "command truly duplicate."
|
||||
type ConfirmedTracker struct {
|
||||
confirmed map[string]time.Time
|
||||
mu sync.RWMutex
|
||||
filePath string
|
||||
}
|
||||
|
||||
// NewConfirmedTracker creates a tracker that persists state under statePath/confirmed_completed.json.
|
||||
func NewConfirmedTracker(statePath string) *ConfirmedTracker {
|
||||
return &ConfirmedTracker{
|
||||
confirmed: make(map[string]time.Time),
|
||||
filePath: filepath.Join(statePath, "confirmed_completed.json"),
|
||||
}
|
||||
}
|
||||
|
||||
// Load restores confirmed completions from disk. Missing file is not an error (fresh start).
|
||||
func (t *ConfirmedTracker) Load() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if _, err := os.Stat(t.filePath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(t.filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read confirmed_completed: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var loaded map[string]time.Time
|
||||
if err := json.Unmarshal(data, &loaded); err != nil {
|
||||
return fmt.Errorf("parse confirmed_completed: %w", err)
|
||||
}
|
||||
t.confirmed = loaded
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save persists the confirmed set to disk.
|
||||
func (t *ConfirmedTracker) Save() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(t.filePath), 0o755); err != nil {
|
||||
return fmt.Errorf("create confirmed dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(t.confirmed, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal confirmed_completed: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(t.filePath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write confirmed_completed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add records that the server confirmed a command as completed. Safe to call
|
||||
// multiple times with the same ID — duplicates are a no-op (idempotent, ETHOS #4).
|
||||
func (t *ConfirmedTracker) Add(commandID string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if _, exists := t.confirmed[commandID]; exists {
|
||||
return
|
||||
}
|
||||
t.confirmed[commandID] = time.Now().UTC()
|
||||
}
|
||||
|
||||
// GetPending returns the current confirmed set as a slice (snapshot — safe to mutate).
|
||||
func (t *ConfirmedTracker) GetPending() []string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
ids := make([]string, 0, len(t.confirmed))
|
||||
for id := range t.confirmed {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Confirm removes the IDs the server confirmed. IDs not in the confirmed
|
||||
// set are silently ignored — server might confirm an ID we already dropped after
|
||||
// a successful prior round-trip.
|
||||
func (t *ConfirmedTracker) Confirm(commandIDs []string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, id := range commandIDs {
|
||||
delete(t.confirmed, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Len returns the current size of the confirmed set.
|
||||
func (t *ConfirmedTracker) Len() int {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return len(t.confirmed)
|
||||
}
|
||||
|
||||
const (
|
||||
// keyRefreshInterval is how often the agent proactively re-checks the server's primary key
|
||||
keyRefreshInterval = 6 * time.Hour
|
||||
|
|
@ -35,6 +137,13 @@ type CommandHandler struct {
|
|||
executedIDs map[string]time.Time // cmd UUID -> execution time (F-2 fix: dedup)
|
||||
executedIDsMu sync.Mutex
|
||||
executedIDsPath string // Migration 033 §5: disk-persisted dedup
|
||||
// confirmedCompleted tracks command IDs the server has confirmed as completed
|
||||
// (via ReceiptConfirmedIDs/AcknowledgedIDs in response). Commands in this set
|
||||
// are NOT rejected as duplicates even if they appear in the executed set,
|
||||
// because the server may not have processed the ReportLog() yet.
|
||||
confirmedCompleted map[string]time.Time // cmd UUID -> confirmation time
|
||||
confirmedCompletedMu sync.Mutex
|
||||
confirmedCompletedPath string // disk-persisted confirmation set
|
||||
lastKeyRefresh time.Time
|
||||
logger *log.Logger
|
||||
}
|
||||
|
|
@ -49,14 +158,18 @@ type CommandSigningConfig struct {
|
|||
// state directory; passing an empty string disables disk-persisted dedup (test path).
|
||||
func NewCommandHandler(cfg *config.Config, stateDir string, securityLogger *logging.SecurityLogger, logger *log.Logger) (*CommandHandler, error) {
|
||||
handler := &CommandHandler{
|
||||
securityLogger: securityLogger,
|
||||
logger: logger,
|
||||
verifier: crypto.NewCommandVerifier(),
|
||||
keyCache: make(map[string]ed25519.PublicKey),
|
||||
executedIDs: make(map[string]time.Time),
|
||||
securityLogger: securityLogger,
|
||||
logger: logger,
|
||||
verifier: crypto.NewCommandVerifier(),
|
||||
keyCache: make(map[string]ed25519.PublicKey),
|
||||
executedIDs: make(map[string]time.Time),
|
||||
confirmedCompleted: make(map[string]time.Time),
|
||||
confirmedCompletedMu: sync.Mutex{},
|
||||
confirmedCompletedPath: "",
|
||||
}
|
||||
if stateDir != "" {
|
||||
handler.executedIDsPath = filepath.Join(stateDir, "executed_commands.json")
|
||||
handler.confirmedCompletedPath = filepath.Join(stateDir, "confirmed_completed.json")
|
||||
}
|
||||
|
||||
// Migration 033 §5: rebuild dedup set from disk so a restart can't re-execute a
|
||||
|
|
@ -71,6 +184,15 @@ func NewCommandHandler(cfg *config.Config, stateDir string, securityLogger *logg
|
|||
}
|
||||
}
|
||||
|
||||
// Load confirmed completion tracker (server-acked commands)
|
||||
if handler.confirmedCompletedPath != "" {
|
||||
if err := handler.loadConfirmedCompleted(); err != nil {
|
||||
logger.Printf("[WARNING] [agent] [cmd_handler] load_confirmed_completed_failed path=%q error=%v", handler.confirmedCompletedPath, err)
|
||||
} else {
|
||||
logger.Printf("[INFO] [agent] [cmd_handler] confirmed_completed_loaded count=%d", len(handler.confirmedCompleted))
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-load cached public key if command signing is enabled
|
||||
if cfg.CommandSigning.Enabled {
|
||||
if pubKey, err := crypto.LoadCachedPublicKey(); err == nil {
|
||||
|
|
@ -109,6 +231,53 @@ func (h *CommandHandler) loadExecutedIDs() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// loadConfirmedCompleted restores the server-confirmed completion set from disk.
|
||||
// Called once during construction. Caller holds no lock.
|
||||
func (h *CommandHandler) loadConfirmedCompleted() error {
|
||||
if _, err := os.Stat(h.confirmedCompletedPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(h.confirmedCompletedPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read confirmed_completed: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var loaded map[string]time.Time
|
||||
if err := json.Unmarshal(data, &loaded); err != nil {
|
||||
return fmt.Errorf("parse confirmed_completed: %w", err)
|
||||
}
|
||||
h.confirmedCompletedMu.Lock()
|
||||
h.confirmedCompleted = loaded
|
||||
h.confirmedCompletedMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveConfirmedCompleted persists the server-confirmed completion set to disk.
|
||||
// Caller MUST hold confirmedCompletedMu.
|
||||
func (h *CommandHandler) saveConfirmedCompletedLocked() error {
|
||||
if h.confirmedCompletedPath == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(h.confirmedCompletedPath), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(h.confirmedCompleted, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
tmp := h.confirmedCompletedPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, h.confirmedCompletedPath); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveExecutedIDs persists the current dedup set. Caller MUST hold executedIDsMu.
|
||||
// Errors are logged by the caller — best-effort persistence; the in-memory set is
|
||||
// the authoritative source within a single process lifetime.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ package orchestrator
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
|
|
@ -38,7 +43,6 @@ func (s *DockerScanner) IsAvailable() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// Try to ping Docker daemon
|
||||
if s.client != nil {
|
||||
_, err := s.client.Ping(context.Background())
|
||||
return err == nil
|
||||
|
|
@ -47,35 +51,30 @@ func (s *DockerScanner) IsAvailable() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// ScanDocker scans for available Docker image updates and returns proper DockerImage data
|
||||
func (s *DockerScanner) ScanDocker() ([]DockerImage, error) {
|
||||
// Scan collects Docker image info and returns UpdateReportItems with full data in Metadata
|
||||
func (s *DockerScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// List all containers
|
||||
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list containers: %w", err)
|
||||
}
|
||||
|
||||
var images []DockerImage
|
||||
var items []client.UpdateReportItem
|
||||
seenImages := make(map[string]bool)
|
||||
|
||||
for _, c := range containers {
|
||||
imageName := c.Image
|
||||
|
||||
// Skip if we've already checked this image
|
||||
if seenImages[imageName] {
|
||||
continue
|
||||
}
|
||||
seenImages[imageName] = true
|
||||
|
||||
// Get current image details
|
||||
imageInspect, _, err := s.client.ImageInspectWithRaw(ctx, imageName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse image name and tag
|
||||
parts := strings.Split(imageName, ":")
|
||||
baseImage := parts[0]
|
||||
currentTag := "latest"
|
||||
|
|
@ -83,10 +82,8 @@ func (s *DockerScanner) ScanDocker() ([]DockerImage, error) {
|
|||
currentTag = parts[1]
|
||||
}
|
||||
|
||||
// Check if update is available by comparing with registry
|
||||
hasUpdate, remoteDigest := s.checkForUpdate(ctx, baseImage, currentTag, imageInspect.ID)
|
||||
|
||||
// Extract short digest for display (first 12 chars of sha256 hash)
|
||||
localDigest := imageInspect.ID
|
||||
localShortDigest := ""
|
||||
if len(localDigest) > 7 {
|
||||
|
|
@ -104,139 +101,61 @@ func (s *DockerScanner) ScanDocker() ([]DockerImage, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Determine severity based on update status
|
||||
severity := "low"
|
||||
if hasUpdate {
|
||||
severity = "moderate"
|
||||
}
|
||||
|
||||
// Extract image labels
|
||||
labels := make(map[string]string)
|
||||
if imageInspect.Config != nil {
|
||||
labels = imageInspect.Config.Labels
|
||||
}
|
||||
|
||||
// Get image size
|
||||
sizeBytes := int64(0)
|
||||
if len(imageInspect.RootFS.Layers) > 0 {
|
||||
sizeBytes = imageInspect.Size
|
||||
}
|
||||
|
||||
// Parse the creation time - imageInspect.Created is already a string
|
||||
createdAt := imageInspect.Created
|
||||
if createdAt == "" {
|
||||
createdAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
image := DockerImage{
|
||||
ImageName: imageName,
|
||||
ImageTag: currentTag,
|
||||
ImageID: localShortDigest,
|
||||
RepositorySource: baseImage,
|
||||
SizeBytes: sizeBytes,
|
||||
CreatedAt: createdAt,
|
||||
HasUpdate: hasUpdate,
|
||||
LatestImageID: remoteShortDigest,
|
||||
Severity: severity,
|
||||
Labels: labels,
|
||||
items = append(items, client.UpdateReportItem{
|
||||
PackageType: "docker_image",
|
||||
PackageName: imageName,
|
||||
PackageDescription: fmt.Sprintf("Docker image %s:%s", imageName, currentTag),
|
||||
CurrentVersion: localShortDigest,
|
||||
AvailableVersion: remoteShortDigest,
|
||||
Severity: severity,
|
||||
RepositorySource: baseImage,
|
||||
SizeBytes: sizeBytes,
|
||||
Metadata: map[string]interface{}{
|
||||
"container_id": c.ID[:12],
|
||||
"container_names": c.Names,
|
||||
"container_state": c.State,
|
||||
"image_created": imageInspect.Created,
|
||||
"image_name": imageName,
|
||||
"image_tag": currentTag,
|
||||
"image_id": localShortDigest,
|
||||
"latest_image_id": remoteShortDigest,
|
||||
"has_update": hasUpdate,
|
||||
"repository": baseImage,
|
||||
"size_bytes": sizeBytes,
|
||||
"created_at": createdAt,
|
||||
"labels": labels,
|
||||
"container_id": c.ID[:12],
|
||||
"container_names": c.Names,
|
||||
"container_state": c.State,
|
||||
"image_created": imageInspect.Created,
|
||||
"local_full_digest": localDigest,
|
||||
"remote_digest": remoteDigest,
|
||||
"remote_digest": remoteDigest,
|
||||
},
|
||||
}
|
||||
|
||||
images = append(images, image)
|
||||
}
|
||||
|
||||
return images, nil
|
||||
}
|
||||
|
||||
// Name returns the scanner name
|
||||
func (s *DockerScanner) Name() string {
|
||||
return "Docker Image Scanner"
|
||||
}
|
||||
|
||||
// --- Legacy Compatibility Methods ---
|
||||
|
||||
// Scan scans for available Docker image updates (LEGACY)
|
||||
// This method is kept for backwards compatibility with the old Scanner interface
|
||||
func (s *DockerScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
images, err := s.ScanDocker()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert proper DockerImage back to legacy UpdateReportItem format
|
||||
var items []client.UpdateReportItem
|
||||
|
||||
for _, image := range images {
|
||||
if image.HasUpdate { // Only include images that have updates
|
||||
item := client.UpdateReportItem{
|
||||
PackageType: "docker_image",
|
||||
PackageName: image.ImageName,
|
||||
PackageDescription: fmt.Sprintf("Docker Image: %s", image.ImageName),
|
||||
CurrentVersion: image.ImageID,
|
||||
AvailableVersion: image.LatestImageID,
|
||||
Severity: image.Severity,
|
||||
RepositorySource: image.RepositorySource,
|
||||
Metadata: image.Metadata,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// --- Typed Scanner Implementation ---
|
||||
|
||||
// GetType returns the scanner type
|
||||
func (s *DockerScanner) GetType() ScannerType {
|
||||
return ScannerTypeDocker
|
||||
}
|
||||
|
||||
// ScanTyped returns typed results (new implementation)
|
||||
func (s *DockerScanner) ScanTyped() (TypedScannerResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
images, err := s.ScanDocker()
|
||||
if err != nil {
|
||||
return TypedScannerResult{
|
||||
ScannerName: s.Name(),
|
||||
ScannerType: ScannerTypeDocker,
|
||||
Error: err,
|
||||
Status: "failed",
|
||||
Duration: time.Since(startTime).Milliseconds(),
|
||||
}, err
|
||||
}
|
||||
|
||||
return TypedScannerResult{
|
||||
ScannerName: s.Name(),
|
||||
ScannerType: ScannerTypeDocker,
|
||||
DockerData: images,
|
||||
Status: "success",
|
||||
Duration: time.Since(startTime).Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkForUpdate checks if a newer image version is available by comparing digests
|
||||
// Returns (hasUpdate bool, remoteDigest string)
|
||||
func (s *DockerScanner) checkForUpdate(ctx context.Context, imageName, tag, currentID string) (bool, string) {
|
||||
// Get remote digest from registry
|
||||
remoteDigest, err := s.registryClient.GetRemoteDigest(ctx, imageName, tag)
|
||||
if err != nil {
|
||||
// If we can't check the registry, log the error but don't report an update
|
||||
fmt.Printf("Warning: Failed to check registry for %s:%s: %v\n", imageName, tag, err)
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Compare digests
|
||||
hasUpdate := currentID != remoteDigest
|
||||
return hasUpdate, remoteDigest
|
||||
// Name returns the scanner name
|
||||
func (s *DockerScanner) Name() string {
|
||||
return "Docker Image Scanner"
|
||||
}
|
||||
|
||||
// Close closes the Docker client
|
||||
|
|
@ -247,20 +166,228 @@ func (s *DockerScanner) Close() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// --- Registry Client (simplified for this implementation) ---
|
||||
// checkForUpdate checks if a newer image version is available by comparing digests
|
||||
func (s *DockerScanner) checkForUpdate(ctx context.Context, imageName, tag, currentID string) (bool, string) {
|
||||
remoteDigest, err := s.registryClient.GetRemoteDigest(ctx, imageName, tag)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [docker] registry_check_failed image=%s:%s error=%v", imageName, tag, err)
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// RegistryClient handles Docker registry API interactions
|
||||
type RegistryClient struct{}
|
||||
|
||||
// NewRegistryClient creates a new registry client
|
||||
func NewRegistryClient() *RegistryClient {
|
||||
return &RegistryClient{}
|
||||
return currentID != remoteDigest, remoteDigest
|
||||
}
|
||||
|
||||
// GetRemoteDigest gets the remote digest for an image from the registry
|
||||
func (r *RegistryClient) GetRemoteDigest(ctx context.Context, imageName, tag string) (string, error) {
|
||||
// This is a simplified implementation
|
||||
// In a real implementation, you would query Docker Hub or the appropriate registry
|
||||
// For now, return an empty string to indicate no remote digest available
|
||||
return "", fmt.Errorf("registry client not implemented")
|
||||
}
|
||||
// --- Registry Client ---
|
||||
|
||||
// RegistryClient handles communication with Docker registries (Docker Hub and custom registries)
|
||||
type RegistryClient struct {
|
||||
httpClient *http.Client
|
||||
cache *manifestCache
|
||||
}
|
||||
|
||||
// manifestCache stores registry responses to avoid hitting rate limits
|
||||
type manifestCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*cacheEntry
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
digest string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// ManifestResponse represents the response from a Docker Registry API v2 manifest request
|
||||
type ManifestResponse struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
MediaType string `json:"mediaType"`
|
||||
Config struct {
|
||||
Digest string `json:"digest"`
|
||||
} `json:"config"`
|
||||
}
|
||||
|
||||
// DockerHubTokenResponse represents the authentication token response from Docker Hub
|
||||
type DockerHubTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
}
|
||||
|
||||
// NewRegistryClient creates a new registry client with caching
|
||||
func NewRegistryClient() *RegistryClient {
|
||||
return &RegistryClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
cache: &manifestCache{
|
||||
entries: make(map[string]*cacheEntry),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetRemoteDigest fetches the digest of a remote image from the registry
|
||||
// Returns the digest string (e.g., "sha256:abc123...") or an error
|
||||
func (c *RegistryClient) GetRemoteDigest(ctx context.Context, imageName, tag string) (string, error) {
|
||||
registry, repository := parseImageName(imageName)
|
||||
|
||||
cacheKey := fmt.Sprintf("%s/%s:%s", registry, repository, tag)
|
||||
if digest := c.cache.get(cacheKey); digest != "" {
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
token, err := c.getAuthToken(ctx, registry, repository)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get auth token: %w", err)
|
||||
}
|
||||
|
||||
digest, err := c.fetchManifestDigest(ctx, registry, repository, tag, token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch manifest: %w", err)
|
||||
}
|
||||
|
||||
c.cache.set(cacheKey, digest, 5*time.Minute)
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// parseImageName splits an image name into registry and repository
|
||||
// Examples:
|
||||
// - "nginx" -> ("registry-1.docker.io", "library/nginx")
|
||||
// - "myuser/myimage" -> ("registry-1.docker.io", "myuser/myimage")
|
||||
// - "gcr.io/myproject/myimage" -> ("gcr.io", "myproject/myimage")
|
||||
func parseImageName(imageName string) (registry, repository string) {
|
||||
parts := strings.Split(imageName, "/")
|
||||
|
||||
if len(parts) >= 2 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
|
||||
registry = parts[0]
|
||||
repository = strings.Join(parts[1:], "/")
|
||||
} else if len(parts) == 1 {
|
||||
registry = "registry-1.docker.io"
|
||||
repository = "library/" + parts[0]
|
||||
} else {
|
||||
registry = "registry-1.docker.io"
|
||||
repository = imageName
|
||||
}
|
||||
|
||||
return registry, repository
|
||||
}
|
||||
|
||||
// getAuthToken obtains an authentication token for the registry
|
||||
func (c *RegistryClient) getAuthToken(ctx context.Context, registry, repository string) (string, error) {
|
||||
if registry == "registry-1.docker.io" {
|
||||
return c.getDockerHubToken(ctx, repository)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// getDockerHubToken obtains a token from Docker Hub's authentication service
|
||||
func (c *RegistryClient) getDockerHubToken(ctx context.Context, repository string) (string, error) {
|
||||
authURL := fmt.Sprintf(
|
||||
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull",
|
||||
repository,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp DockerHubTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.Token != "" {
|
||||
return tokenResp.Token, nil
|
||||
}
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
// fetchManifestDigest fetches the manifest from the registry and extracts the digest
|
||||
func (c *RegistryClient) fetchManifestDigest(ctx context.Context, registry, repository, tag, token string) (string, error) {
|
||||
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registry, repository, tag)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", manifestURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return "", fmt.Errorf("rate limited by registry (429 Too Many Requests)")
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return "", fmt.Errorf("unauthorized: authentication failed for %s/%s:%s", registry, repository, tag)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("manifest request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if digest := resp.Header.Get("Docker-Content-Digest"); digest != "" {
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
var manifest ManifestResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
||||
return "", fmt.Errorf("failed to decode manifest: %w", err)
|
||||
}
|
||||
|
||||
if manifest.Config.Digest == "" {
|
||||
return "", fmt.Errorf("manifest does not contain a config digest")
|
||||
}
|
||||
|
||||
return manifest.Config.Digest, nil
|
||||
}
|
||||
|
||||
// manifestCache methods
|
||||
|
||||
func (mc *manifestCache) get(key string) string {
|
||||
mc.mu.RLock()
|
||||
defer mc.mu.RUnlock()
|
||||
|
||||
entry, exists := mc.entries[key]
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
delete(mc.entries, key)
|
||||
return ""
|
||||
}
|
||||
|
||||
return entry.digest
|
||||
}
|
||||
|
||||
func (mc *manifestCache) set(key, digest string, ttl time.Duration) {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
|
||||
mc.entries[key] = &cacheEntry{
|
||||
digest: digest,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
package orchestrator
|
||||
|
||||
import (
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
)
|
||||
|
||||
// StorageMetric represents a single storage/disk metric
|
||||
type StorageMetric struct {
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Filesystem string `json:"filesystem"`
|
||||
Device string `json:"device"`
|
||||
DiskType string `json:"disk_type"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
AvailableBytes int64 `json:"available_bytes"`
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
IsRoot bool `json:"is_root"`
|
||||
IsLargest bool `json:"is_largest"`
|
||||
Severity string `json:"severity"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// SystemMetric represents a single system metric (CPU, memory, etc.)
|
||||
type SystemMetric struct {
|
||||
MetricName string `json:"metric_name"`
|
||||
MetricType string `json:"metric_type"` // "cpu", "memory", "processes", "uptime", etc.
|
||||
CurrentValue string `json:"current_value"`
|
||||
AvailableValue string `json:"available_value"`
|
||||
Severity string `json:"severity"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// DockerImage represents a single Docker image
|
||||
type DockerImage struct {
|
||||
ImageName string `json:"image_name"`
|
||||
ImageTag string `json:"image_tag"`
|
||||
ImageID string `json:"image_id"`
|
||||
RepositorySource string `json:"repository_source"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
HasUpdate bool `json:"has_update"`
|
||||
LatestImageID string `json:"latest_image_id"`
|
||||
Severity string `json:"severity"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// PackageUpdate represents an actual software package update (legacy, for package scanners only)
|
||||
type PackageUpdate = client.UpdateReportItem
|
||||
|
||||
// --- Scanner Interfaces ---
|
||||
|
||||
// StorageScannerInterface handles storage/disk metrics scanning
|
||||
type StorageScannerInterface interface {
|
||||
IsAvailable() bool
|
||||
ScanStorage() ([]StorageMetric, error)
|
||||
Name() string
|
||||
}
|
||||
|
||||
// SystemScannerInterface handles system metrics scanning
|
||||
type SystemScannerInterface interface {
|
||||
IsAvailable() bool
|
||||
ScanSystem() ([]SystemMetric, error)
|
||||
Name() string
|
||||
}
|
||||
|
||||
// DockerScannerInterface handles Docker image scanning
|
||||
type DockerScannerInterface interface {
|
||||
IsAvailable() bool
|
||||
ScanDocker() ([]DockerImage, error)
|
||||
Name() string
|
||||
}
|
||||
|
||||
// PackageScannerInterface handles package update scanning (legacy)
|
||||
type PackageScannerInterface interface {
|
||||
IsAvailable() bool
|
||||
ScanPackages() ([]PackageUpdate, error)
|
||||
Name() string
|
||||
}
|
||||
|
||||
// --- Unified Scanner Types for Backwards Compatibility ---
|
||||
|
||||
// ScannerType represents the type of data a scanner returns
|
||||
type ScannerType string
|
||||
|
||||
const (
|
||||
ScannerTypeStorage ScannerType = "storage"
|
||||
ScannerTypeSystem ScannerType = "system"
|
||||
ScannerTypeDocker ScannerType = "docker"
|
||||
ScannerTypePackage ScannerType = "package"
|
||||
)
|
||||
|
||||
// TypedScannerResult represents the result of any type of scanner
|
||||
type TypedScannerResult struct {
|
||||
ScannerName string
|
||||
ScannerType ScannerType
|
||||
StorageData []StorageMetric
|
||||
SystemData []SystemMetric
|
||||
DockerData []DockerImage
|
||||
PackageData []PackageUpdate
|
||||
Error error
|
||||
Duration int64 // milliseconds
|
||||
Status string
|
||||
}
|
||||
|
||||
// TypedScanner is a unified interface that can return any type of data
|
||||
type TypedScanner interface {
|
||||
IsAvailable() bool
|
||||
GetType() ScannerType
|
||||
Scan() (TypedScannerResult, error)
|
||||
Name() string
|
||||
}
|
||||
|
|
@ -1,407 +0,0 @@
|
|||
package orchestrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/scanner"
|
||||
)
|
||||
|
||||
// === Type Conversion Functions ===
|
||||
// These functions convert scanner-specific metrics to the generic UpdateReportItem format
|
||||
// This maintains compatibility with the existing Scanner interface while preserving data
|
||||
|
||||
// convertStorageToUpdates converts StorageMetric slices to UpdateReportItem format
|
||||
func convertStorageToUpdates(metrics []StorageMetric) []client.UpdateReportItem {
|
||||
log.Printf("[HISTORY] [agent] [storage] converting %d storage metrics to update items timestamp=%s",
|
||||
len(metrics), time.Now().Format(time.RFC3339))
|
||||
|
||||
updates := make([]client.UpdateReportItem, 0, len(metrics))
|
||||
for _, metric := range metrics {
|
||||
update := client.UpdateReportItem{
|
||||
// Map storage metrics to package-like structure for compatibility
|
||||
PackageType: "storage",
|
||||
PackageName: metric.Mountpoint,
|
||||
PackageDescription: fmt.Sprintf("Storage metrics for %s (%s)", metric.Mountpoint, metric.Filesystem),
|
||||
CurrentVersion: fmt.Sprintf("%.1f%% used", metric.UsedPercent),
|
||||
AvailableVersion: fmt.Sprintf("%.1f GB free", float64(metric.AvailableBytes)/1024/1024/1024),
|
||||
Severity: metric.Severity,
|
||||
RepositorySource: metric.Device,
|
||||
SizeBytes: metric.TotalBytes,
|
||||
Metadata: map[string]interface{}{
|
||||
"mountpoint": metric.Mountpoint,
|
||||
"filesystem": metric.Filesystem,
|
||||
"device": metric.Device,
|
||||
"disk_type": metric.DiskType,
|
||||
"total_bytes": metric.TotalBytes,
|
||||
"used_bytes": metric.UsedBytes,
|
||||
"available_bytes": metric.AvailableBytes,
|
||||
"used_percent": metric.UsedPercent,
|
||||
"is_root": metric.IsRoot,
|
||||
"is_largest": metric.IsLargest,
|
||||
},
|
||||
}
|
||||
updates = append(updates, update)
|
||||
}
|
||||
|
||||
log.Printf("[HISTORY] [agent] [storage] Converted %d storage metrics to update items timestamp=%s",
|
||||
len(updates), time.Now().Format(time.RFC3339))
|
||||
return updates
|
||||
}
|
||||
|
||||
// convertSystemToUpdates converts SystemMetric slices to UpdateReportItem format
|
||||
func convertSystemToUpdates(metrics []SystemMetric) []client.UpdateReportItem {
|
||||
log.Printf("[HISTORY] [agent] [system] converting %d system metrics to update items timestamp=%s",
|
||||
len(metrics), time.Now().Format(time.RFC3339))
|
||||
|
||||
updates := make([]client.UpdateReportItem, 0, len(metrics))
|
||||
for _, metric := range metrics {
|
||||
update := client.UpdateReportItem{
|
||||
// Map system metrics to package-like structure for compatibility
|
||||
PackageType: "system",
|
||||
PackageName: metric.MetricName,
|
||||
PackageDescription: fmt.Sprintf("System metric %s (%s)", metric.MetricName, metric.MetricType),
|
||||
CurrentVersion: metric.CurrentValue,
|
||||
AvailableVersion: metric.AvailableValue,
|
||||
Severity: metric.Severity,
|
||||
RepositorySource: metric.MetricType,
|
||||
Metadata: map[string]interface{}{
|
||||
"metric_name": metric.MetricName,
|
||||
"metric_type": metric.MetricType,
|
||||
"current_value": metric.CurrentValue,
|
||||
"available_value": metric.AvailableValue,
|
||||
},
|
||||
}
|
||||
updates = append(updates, update)
|
||||
}
|
||||
|
||||
log.Printf("[HISTORY] [agent] [system] Converted %d system metrics to update items timestamp=%s",
|
||||
len(updates), time.Now().Format(time.RFC3339))
|
||||
return updates
|
||||
}
|
||||
|
||||
// convertDockerToUpdates converts DockerImage slices to UpdateReportItem format
|
||||
func convertDockerToUpdates(images []DockerImage) []client.UpdateReportItem {
|
||||
log.Printf("[HISTORY] [agent] [docker] converting %d docker images to update items timestamp=%s",
|
||||
len(images), time.Now().Format(time.RFC3339))
|
||||
|
||||
updates := make([]client.UpdateReportItem, 0, len(images))
|
||||
for _, image := range images {
|
||||
update := client.UpdateReportItem{
|
||||
// Map Docker images to package structure
|
||||
PackageType: "docker",
|
||||
PackageName: image.ImageName,
|
||||
PackageDescription: fmt.Sprintf("Docker image %s:%s", image.ImageName, image.ImageTag),
|
||||
CurrentVersion: image.ImageTag,
|
||||
AvailableVersion: "latest",
|
||||
Severity: image.Severity,
|
||||
RepositorySource: image.RepositorySource,
|
||||
SizeBytes: image.SizeBytes,
|
||||
Metadata: map[string]interface{}{
|
||||
"image_name": image.ImageName,
|
||||
"image_tag": image.ImageTag,
|
||||
"image_id": image.ImageID,
|
||||
"repository": image.RepositorySource,
|
||||
"size_bytes": image.SizeBytes,
|
||||
"created_at": image.CreatedAt,
|
||||
"has_update": image.HasUpdate,
|
||||
"latest_image_id": image.LatestImageID,
|
||||
"labels": image.Labels,
|
||||
},
|
||||
}
|
||||
updates = append(updates, update)
|
||||
}
|
||||
|
||||
log.Printf("[HISTORY] [agent] [docker] Converted %d docker images to update items timestamp=%s",
|
||||
len(updates), time.Now().Format(time.RFC3339))
|
||||
return updates
|
||||
}
|
||||
|
||||
// APTScannerWrapper wraps the APT scanner to implement the Scanner interface
|
||||
type APTScannerWrapper struct {
|
||||
scanner *scanner.APTScanner
|
||||
}
|
||||
|
||||
func NewAPTScannerWrapper(s *scanner.APTScanner) *APTScannerWrapper {
|
||||
return &APTScannerWrapper{scanner: s}
|
||||
}
|
||||
|
||||
func (w *APTScannerWrapper) IsAvailable() bool {
|
||||
return w.scanner.IsAvailable()
|
||||
}
|
||||
|
||||
func (w *APTScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
|
||||
log.Printf("[HISTORY] [agent] [apt] starting scan via wrapper timestamp=%s",
|
||||
time.Now().Format(time.RFC3339))
|
||||
|
||||
if w.scanner == nil {
|
||||
err := fmt.Errorf("apt scanner is nil")
|
||||
log.Printf("[ERROR] [agent] [apt] scan_failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updates, err := w.scanner.Scan()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [apt] scan_failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("[HISTORY] [agent] [apt] scan_completed items=%d timestamp=%s",
|
||||
len(updates), time.Now().Format(time.RFC3339))
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (w *APTScannerWrapper) Name() string {
|
||||
return "APT Update Scanner"
|
||||
}
|
||||
|
||||
// DNFScannerWrapper wraps the DNF scanner to implement the Scanner interface
|
||||
type DNFScannerWrapper struct {
|
||||
scanner *scanner.DNFScanner
|
||||
}
|
||||
|
||||
func NewDNFScannerWrapper(s *scanner.DNFScanner) *DNFScannerWrapper {
|
||||
return &DNFScannerWrapper{scanner: s}
|
||||
}
|
||||
|
||||
func (w *DNFScannerWrapper) IsAvailable() bool {
|
||||
return w.scanner.IsAvailable()
|
||||
}
|
||||
|
||||
func (w *DNFScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
|
||||
log.Printf("[HISTORY] [agent] [dnf] starting scan via wrapper timestamp=%s",
|
||||
time.Now().Format(time.RFC3339))
|
||||
|
||||
if w.scanner == nil {
|
||||
err := fmt.Errorf("dnf scanner is nil")
|
||||
log.Printf("[ERROR] [agent] [dnf] scan_failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updates, err := w.scanner.Scan()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [dnf] scan_failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("[HISTORY] [agent] [dnf] scan_completed items=%d timestamp=%s",
|
||||
len(updates), time.Now().Format(time.RFC3339))
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (w *DNFScannerWrapper) Name() string {
|
||||
return "DNF Update Scanner"
|
||||
}
|
||||
|
||||
// DockerScannerWrapper wraps the Docker scanner to implement the Scanner interface
|
||||
type DockerScannerWrapper struct {
|
||||
scanner *scanner.DockerScanner
|
||||
}
|
||||
|
||||
func NewDockerScannerWrapper(s *scanner.DockerScanner) *DockerScannerWrapper {
|
||||
return &DockerScannerWrapper{scanner: s}
|
||||
}
|
||||
|
||||
func (w *DockerScannerWrapper) IsAvailable() bool {
|
||||
if w.scanner == nil {
|
||||
return false
|
||||
}
|
||||
return w.scanner.IsAvailable()
|
||||
}
|
||||
|
||||
func (w *DockerScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
|
||||
log.Printf("[HISTORY] [agent] [docker] starting scan via wrapper timestamp=%s",
|
||||
time.Now().Format(time.RFC3339))
|
||||
|
||||
if w.scanner == nil {
|
||||
err := fmt.Errorf("docker scanner is nil")
|
||||
log.Printf("[ERROR] [agent] [docker] scan_failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updates, err := w.scanner.Scan()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [docker] scan_failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("[HISTORY] [agent] [docker] scan_completed items=%d timestamp=%s",
|
||||
len(updates), time.Now().Format(time.RFC3339))
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (w *DockerScannerWrapper) Name() string {
|
||||
return "Docker Image Update Scanner"
|
||||
}
|
||||
|
||||
// WindowsUpdateScannerWrapper wraps the Windows Update scanner to implement the Scanner interface
|
||||
type WindowsUpdateScannerWrapper struct {
|
||||
scanner *scanner.WindowsUpdateScanner
|
||||
}
|
||||
|
||||
func NewWindowsUpdateScannerWrapper(s *scanner.WindowsUpdateScanner) *WindowsUpdateScannerWrapper {
|
||||
return &WindowsUpdateScannerWrapper{scanner: s}
|
||||
}
|
||||
|
||||
func (w *WindowsUpdateScannerWrapper) IsAvailable() bool {
|
||||
return w.scanner.IsAvailable()
|
||||
}
|
||||
|
||||
func (w *WindowsUpdateScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
|
||||
log.Printf("[HISTORY] [agent] [windows] starting scan via wrapper timestamp=%s",
|
||||
time.Now().Format(time.RFC3339))
|
||||
|
||||
if w.scanner == nil {
|
||||
err := fmt.Errorf("windows update scanner is nil")
|
||||
log.Printf("[ERROR] [agent] [windows] scan_failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updates, err := w.scanner.Scan()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [windows] scan_failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("[HISTORY] [agent] [windows] scan_completed items=%d timestamp=%s",
|
||||
len(updates), time.Now().Format(time.RFC3339))
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (w *WindowsUpdateScannerWrapper) Name() string {
|
||||
return "Windows Update Scanner"
|
||||
}
|
||||
|
||||
// WingetScannerWrapper wraps the Winget scanner to implement the Scanner interface
|
||||
type WingetScannerWrapper struct {
|
||||
scanner *scanner.WingetScanner
|
||||
}
|
||||
|
||||
func NewWingetScannerWrapper(s *scanner.WingetScanner) *WingetScannerWrapper {
|
||||
return &WingetScannerWrapper{scanner: s}
|
||||
}
|
||||
|
||||
func (w *WingetScannerWrapper) IsAvailable() bool {
|
||||
return w.scanner.IsAvailable()
|
||||
}
|
||||
|
||||
func (w *WingetScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
|
||||
log.Printf("[HISTORY] [agent] [winget] starting scan via wrapper timestamp=%s",
|
||||
time.Now().Format(time.RFC3339))
|
||||
|
||||
if w.scanner == nil {
|
||||
err := fmt.Errorf("winget scanner is nil")
|
||||
log.Printf("[ERROR] [agent] [winget] scan_failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updates, err := w.scanner.Scan()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [winget] scan_failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("[HISTORY] [agent] [winget] scan_completed items=%d timestamp=%s",
|
||||
len(updates), time.Now().Format(time.RFC3339))
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (w *WingetScannerWrapper) Name() string {
|
||||
return "Winget Package Update Scanner"
|
||||
}
|
||||
|
||||
// StorageScannerWrapper wraps the Storage scanner to implement the Scanner interface
|
||||
type StorageScannerWrapper struct {
|
||||
scanner *StorageScanner
|
||||
}
|
||||
|
||||
func NewStorageScannerWrapper(s *StorageScanner) *StorageScannerWrapper {
|
||||
return &StorageScannerWrapper{scanner: s}
|
||||
}
|
||||
|
||||
func (w *StorageScannerWrapper) IsAvailable() bool {
|
||||
return w.scanner.IsAvailable()
|
||||
}
|
||||
|
||||
func (w *StorageScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
|
||||
log.Printf("[HISTORY] [agent] [storage] starting scan via wrapper timestamp=%s",
|
||||
time.Now().Format(time.RFC3339))
|
||||
|
||||
if w.scanner == nil {
|
||||
err := fmt.Errorf("storage scanner is nil")
|
||||
log.Printf("[ERROR] [agent] [storage] scan failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metrics, err := w.scanner.ScanStorage()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [storage] scan_failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updates := convertStorageToUpdates(metrics)
|
||||
|
||||
log.Printf("[HISTORY] [agent] [storage] scan_completed items=%d timestamp=%s",
|
||||
len(updates), time.Now().Format(time.RFC3339))
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (w *StorageScannerWrapper) Name() string {
|
||||
return w.scanner.Name()
|
||||
}
|
||||
|
||||
// SystemScannerWrapper wraps the System scanner to implement the Scanner interface
|
||||
type SystemScannerWrapper struct {
|
||||
scanner *SystemScanner
|
||||
}
|
||||
|
||||
func NewSystemScannerWrapper(s *SystemScanner) *SystemScannerWrapper {
|
||||
return &SystemScannerWrapper{scanner: s}
|
||||
}
|
||||
|
||||
func (w *SystemScannerWrapper) IsAvailable() bool {
|
||||
return w.scanner.IsAvailable()
|
||||
}
|
||||
|
||||
func (w *SystemScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
|
||||
log.Printf("[HISTORY] [agent] [system] starting scan via wrapper timestamp=%s",
|
||||
time.Now().Format(time.RFC3339))
|
||||
|
||||
if w.scanner == nil {
|
||||
err := fmt.Errorf("system scanner is nil")
|
||||
log.Printf("[ERROR] [agent] [system] scan_failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metrics, err := w.scanner.ScanSystem()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [system] scan_failed error=\"%v\" timestamp=%s",
|
||||
err, time.Now().Format(time.RFC3339))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updates := convertSystemToUpdates(metrics)
|
||||
|
||||
log.Printf("[HISTORY] [agent] [system] scan_completed items=%d timestamp=%s",
|
||||
len(updates), time.Now().Format(time.RFC3339))
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (w *SystemScannerWrapper) Name() string {
|
||||
return w.scanner.Name()
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||
)
|
||||
|
||||
|
|
@ -24,8 +25,8 @@ func (s *StorageScanner) IsAvailable() bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// ScanStorage collects disk usage information and returns proper storage metrics
|
||||
func (s *StorageScanner) ScanStorage() ([]StorageMetric, error) {
|
||||
// Scan collects disk usage and returns UpdateReportItems with full data in Metadata
|
||||
func (s *StorageScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
sysInfo, err := system.GetSystemInfo(s.agentVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get system info: %w", err)
|
||||
|
|
@ -35,82 +36,51 @@ func (s *StorageScanner) ScanStorage() ([]StorageMetric, error) {
|
|||
return nil, fmt.Errorf("no disk information available")
|
||||
}
|
||||
|
||||
// Convert disk info to proper StorageMetric format
|
||||
var metrics []StorageMetric
|
||||
var items []client.UpdateReportItem
|
||||
|
||||
for _, disk := range sysInfo.DiskInfo {
|
||||
metric := StorageMetric{
|
||||
Mountpoint: disk.Mountpoint,
|
||||
Filesystem: disk.Filesystem,
|
||||
Device: disk.Device,
|
||||
DiskType: disk.DiskType,
|
||||
TotalBytes: int64(disk.Total),
|
||||
UsedBytes: int64(disk.Used),
|
||||
AvailableBytes: int64(disk.Available),
|
||||
UsedPercent: disk.UsedPercent,
|
||||
IsRoot: disk.IsRoot,
|
||||
IsLargest: disk.IsLargest,
|
||||
Severity: determineDiskSeverity(disk.UsedPercent),
|
||||
severity := "low"
|
||||
switch {
|
||||
case disk.UsedPercent >= 95:
|
||||
severity = "critical"
|
||||
case disk.UsedPercent >= 90:
|
||||
severity = "important"
|
||||
case disk.UsedPercent >= 80:
|
||||
severity = "moderate"
|
||||
}
|
||||
|
||||
item := client.UpdateReportItem{
|
||||
PackageType: "storage",
|
||||
PackageName: disk.Mountpoint,
|
||||
PackageDescription: fmt.Sprintf("Storage metrics for %s (%s)", disk.Mountpoint, disk.Filesystem),
|
||||
CurrentVersion: fmt.Sprintf("%.1f%% used", disk.UsedPercent),
|
||||
AvailableVersion: fmt.Sprintf("%.1f GB free", float64(disk.Available)/1024/1024/1024),
|
||||
Severity: severity,
|
||||
RepositorySource: disk.Device,
|
||||
SizeBytes: int64(disk.Total),
|
||||
Metadata: map[string]interface{}{
|
||||
"agent_version": s.agentVersion,
|
||||
"collected_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"mountpoint": disk.Mountpoint,
|
||||
"device": disk.Device,
|
||||
"disk_type": disk.DiskType,
|
||||
"filesystem": disk.Filesystem,
|
||||
"total_bytes": int64(disk.Total),
|
||||
"used_bytes": int64(disk.Used),
|
||||
"available_bytes": int64(disk.Available),
|
||||
"used_percent": disk.UsedPercent,
|
||||
"is_root": disk.IsRoot,
|
||||
"is_largest": disk.IsLargest,
|
||||
"severity": severity,
|
||||
"agent_version": s.agentVersion,
|
||||
"collected_at": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
metrics = append(metrics, metric)
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return metrics, nil
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Name returns the scanner name
|
||||
func (s *StorageScanner) Name() string {
|
||||
return "Disk Usage Reporter"
|
||||
}
|
||||
|
||||
// --- Typed Scanner Implementation ---
|
||||
|
||||
// GetType returns the scanner type
|
||||
func (s *StorageScanner) GetType() ScannerType {
|
||||
return ScannerTypeStorage
|
||||
}
|
||||
|
||||
// ScanTyped returns typed results (new implementation)
|
||||
func (s *StorageScanner) ScanTyped() (TypedScannerResult, error) {
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
// Duration will be set at the end
|
||||
}()
|
||||
|
||||
metrics, err := s.ScanStorage()
|
||||
if err != nil {
|
||||
return TypedScannerResult{
|
||||
ScannerName: s.Name(),
|
||||
ScannerType: ScannerTypeStorage,
|
||||
Error: err,
|
||||
Status: "failed",
|
||||
Duration: time.Since(startTime).Milliseconds(),
|
||||
}, err
|
||||
}
|
||||
|
||||
return TypedScannerResult{
|
||||
ScannerName: s.Name(),
|
||||
ScannerType: ScannerTypeStorage,
|
||||
StorageData: metrics,
|
||||
Status: "success",
|
||||
Duration: time.Since(startTime).Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// determineDiskSeverity returns severity based on disk usage percentage
|
||||
func determineDiskSeverity(usedPercent float64) string {
|
||||
switch {
|
||||
case usedPercent >= 95:
|
||||
return "critical"
|
||||
case usedPercent >= 90:
|
||||
return "important"
|
||||
case usedPercent >= 80:
|
||||
return "moderate"
|
||||
default:
|
||||
return "low"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package orchestrator
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||
|
|
@ -25,167 +24,116 @@ func (s *SystemScanner) IsAvailable() bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// ScanSystem collects system information and returns proper system metrics
|
||||
func (s *SystemScanner) ScanSystem() ([]SystemMetric, error) {
|
||||
// Scan collects system information and returns UpdateReportItems with full data in Metadata
|
||||
func (s *SystemScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
sysInfo, err := system.GetSystemInfo(s.agentVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get system info: %w", err)
|
||||
}
|
||||
|
||||
// Convert system info to proper SystemMetric format
|
||||
var metrics []SystemMetric
|
||||
var items []client.UpdateReportItem
|
||||
|
||||
// CPU info metric
|
||||
cpuMetric := SystemMetric{
|
||||
MetricName: "system-cpu",
|
||||
MetricType: "cpu",
|
||||
CurrentValue: fmt.Sprintf("%d cores, %d threads", sysInfo.CPUInfo.Cores, sysInfo.CPUInfo.Threads),
|
||||
AvailableValue: sysInfo.CPUInfo.ModelName,
|
||||
Severity: "low",
|
||||
// CPU
|
||||
items = append(items, client.UpdateReportItem{
|
||||
PackageType: "system",
|
||||
PackageName: "system-cpu",
|
||||
PackageDescription: "System CPU information",
|
||||
CurrentVersion: fmt.Sprintf("%d cores, %d threads", sysInfo.CPUInfo.Cores, sysInfo.CPUInfo.Threads),
|
||||
AvailableVersion: sysInfo.CPUInfo.ModelName,
|
||||
Severity: "low",
|
||||
RepositorySource: "cpu",
|
||||
Metadata: map[string]interface{}{
|
||||
"metric_name": "system-cpu",
|
||||
"metric_type": "cpu",
|
||||
"cpu_model": sysInfo.CPUInfo.ModelName,
|
||||
"cpu_cores": fmt.Sprintf("%d", sysInfo.CPUInfo.Cores),
|
||||
"cpu_threads": fmt.Sprintf("%d", sysInfo.CPUInfo.Threads),
|
||||
},
|
||||
}
|
||||
metrics = append(metrics, cpuMetric)
|
||||
})
|
||||
|
||||
// Memory info metric
|
||||
memMetric := SystemMetric{
|
||||
MetricName: "system-memory",
|
||||
MetricType: "memory",
|
||||
CurrentValue: fmt.Sprintf("%.1f%% used", sysInfo.MemoryInfo.UsedPercent),
|
||||
AvailableValue: fmt.Sprintf("%d GB total", sysInfo.MemoryInfo.Total/(1024*1024*1024)),
|
||||
Severity: determineMemorySeverity(sysInfo.MemoryInfo.UsedPercent),
|
||||
// Memory
|
||||
memSeverity := "low"
|
||||
switch {
|
||||
case sysInfo.MemoryInfo.UsedPercent >= 95:
|
||||
memSeverity = "critical"
|
||||
case sysInfo.MemoryInfo.UsedPercent >= 90:
|
||||
memSeverity = "important"
|
||||
case sysInfo.MemoryInfo.UsedPercent >= 80:
|
||||
memSeverity = "moderate"
|
||||
}
|
||||
items = append(items, client.UpdateReportItem{
|
||||
PackageType: "system",
|
||||
PackageName: "system-memory",
|
||||
PackageDescription: "System memory information",
|
||||
CurrentVersion: fmt.Sprintf("%.1f%% used", sysInfo.MemoryInfo.UsedPercent),
|
||||
AvailableVersion: fmt.Sprintf("%d GB total", sysInfo.MemoryInfo.Total/(1024*1024*1024)),
|
||||
Severity: memSeverity,
|
||||
RepositorySource: "memory",
|
||||
Metadata: map[string]interface{}{
|
||||
"memory_total": fmt.Sprintf("%d", sysInfo.MemoryInfo.Total),
|
||||
"memory_used": fmt.Sprintf("%d", sysInfo.MemoryInfo.Used),
|
||||
"memory_available": fmt.Sprintf("%d", sysInfo.MemoryInfo.Available),
|
||||
"memory_used_percent": fmt.Sprintf("%.1f", sysInfo.MemoryInfo.UsedPercent),
|
||||
"metric_name": "system-memory",
|
||||
"metric_type": "memory",
|
||||
"memory_total": fmt.Sprintf("%d", sysInfo.MemoryInfo.Total),
|
||||
"memory_used": fmt.Sprintf("%d", sysInfo.MemoryInfo.Used),
|
||||
"memory_available": fmt.Sprintf("%d", sysInfo.MemoryInfo.Available),
|
||||
"memory_used_percent": fmt.Sprintf("%.1f", sysInfo.MemoryInfo.UsedPercent),
|
||||
},
|
||||
}
|
||||
metrics = append(metrics, memMetric)
|
||||
})
|
||||
|
||||
// Process count metric
|
||||
processMetric := SystemMetric{
|
||||
MetricName: "system-processes",
|
||||
MetricType: "processes",
|
||||
CurrentValue: fmt.Sprintf("%d processes", sysInfo.RunningProcesses),
|
||||
AvailableValue: "n/a",
|
||||
Severity: "low",
|
||||
// Processes
|
||||
items = append(items, client.UpdateReportItem{
|
||||
PackageType: "system",
|
||||
PackageName: "system-processes",
|
||||
PackageDescription: "Running processes",
|
||||
CurrentVersion: fmt.Sprintf("%d processes", sysInfo.RunningProcesses),
|
||||
AvailableVersion: "n/a",
|
||||
Severity: "low",
|
||||
RepositorySource: "processes",
|
||||
Metadata: map[string]interface{}{
|
||||
"metric_name": "system-processes",
|
||||
"metric_type": "processes",
|
||||
"process_count": fmt.Sprintf("%d", sysInfo.RunningProcesses),
|
||||
},
|
||||
}
|
||||
metrics = append(metrics, processMetric)
|
||||
})
|
||||
|
||||
// Uptime metric
|
||||
uptimeMetric := SystemMetric{
|
||||
MetricName: "system-uptime",
|
||||
MetricType: "uptime",
|
||||
CurrentValue: sysInfo.Uptime,
|
||||
AvailableValue: "n/a",
|
||||
Severity: "low",
|
||||
// Uptime
|
||||
items = append(items, client.UpdateReportItem{
|
||||
PackageType: "system",
|
||||
PackageName: "system-uptime",
|
||||
PackageDescription: "System uptime",
|
||||
CurrentVersion: sysInfo.Uptime,
|
||||
AvailableVersion: "n/a",
|
||||
Severity: "low",
|
||||
RepositorySource: "uptime",
|
||||
Metadata: map[string]interface{}{
|
||||
"uptime": sysInfo.Uptime,
|
||||
"metric_name": "system-uptime",
|
||||
"metric_type": "uptime",
|
||||
"uptime": sysInfo.Uptime,
|
||||
},
|
||||
}
|
||||
metrics = append(metrics, uptimeMetric)
|
||||
})
|
||||
|
||||
// Reboot required metric (if applicable)
|
||||
// Reboot required
|
||||
if sysInfo.RebootRequired {
|
||||
rebootMetric := SystemMetric{
|
||||
MetricName: "system-reboot",
|
||||
MetricType: "reboot",
|
||||
CurrentValue: "required",
|
||||
AvailableValue: "n/a",
|
||||
Severity: "important",
|
||||
items = append(items, client.UpdateReportItem{
|
||||
PackageType: "system",
|
||||
PackageName: "system-reboot",
|
||||
PackageDescription: "System reboot status",
|
||||
CurrentVersion: "required",
|
||||
AvailableVersion: "n/a",
|
||||
Severity: "important",
|
||||
RepositorySource: "reboot",
|
||||
Metadata: map[string]interface{}{
|
||||
"metric_name": "system-reboot",
|
||||
"metric_type": "reboot",
|
||||
"reboot_required": "true",
|
||||
"reboot_reason": sysInfo.RebootReason,
|
||||
"reboot_reason": sysInfo.RebootReason,
|
||||
},
|
||||
}
|
||||
metrics = append(metrics, rebootMetric)
|
||||
})
|
||||
}
|
||||
|
||||
return metrics, nil
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Name returns the scanner name
|
||||
func (s *SystemScanner) Name() string {
|
||||
return "System Metrics Reporter"
|
||||
}
|
||||
|
||||
// --- Legacy Compatibility Methods ---
|
||||
|
||||
// Scan collects system information and returns it as "updates" for reporting (LEGACY)
|
||||
// This method is kept for backwards compatibility with the old Scanner interface
|
||||
func (s *SystemScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
metrics, err := s.ScanSystem()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert proper SystemMetric back to legacy UpdateReportItem format
|
||||
var items []client.UpdateReportItem
|
||||
|
||||
for _, metric := range metrics {
|
||||
item := client.UpdateReportItem{
|
||||
PackageName: metric.MetricName,
|
||||
CurrentVersion: metric.CurrentValue,
|
||||
AvailableVersion: metric.AvailableValue,
|
||||
PackageType: "system",
|
||||
Severity: metric.Severity,
|
||||
PackageDescription: fmt.Sprintf("System %s: %s", metric.MetricType, metric.MetricName),
|
||||
Metadata: metric.Metadata,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// --- Typed Scanner Implementation ---
|
||||
|
||||
// GetType returns the scanner type
|
||||
func (s *SystemScanner) GetType() ScannerType {
|
||||
return ScannerTypeSystem
|
||||
}
|
||||
|
||||
// ScanTyped returns typed results (new implementation)
|
||||
func (s *SystemScanner) ScanTyped() (TypedScannerResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
metrics, err := s.ScanSystem()
|
||||
if err != nil {
|
||||
return TypedScannerResult{
|
||||
ScannerName: s.Name(),
|
||||
ScannerType: ScannerTypeSystem,
|
||||
Error: err,
|
||||
Status: "failed",
|
||||
Duration: time.Since(startTime).Milliseconds(),
|
||||
}, err
|
||||
}
|
||||
|
||||
return TypedScannerResult{
|
||||
ScannerName: s.Name(),
|
||||
ScannerType: ScannerTypeSystem,
|
||||
SystemData: metrics,
|
||||
Status: "success",
|
||||
Duration: time.Since(startTime).Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// determineMemorySeverity returns severity based on memory usage percentage
|
||||
func determineMemorySeverity(usedPercent float64) string {
|
||||
switch {
|
||||
case usedPercent >= 95:
|
||||
return "critical"
|
||||
case usedPercent >= 90:
|
||||
return "important"
|
||||
case usedPercent >= 80:
|
||||
return "moderate"
|
||||
default:
|
||||
return "low"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ func NewAPTScanner() *APTScanner {
|
|||
return &APTScanner{}
|
||||
}
|
||||
|
||||
func (s *APTScanner) Name() string { return "APT Update Scanner" }
|
||||
|
||||
// IsAvailable checks if APT is available on this system
|
||||
func (s *APTScanner) IsAvailable() bool {
|
||||
_, err := exec.LookPath("apt")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// DetectAvailable returns the package-manager / Docker scanners that are
|
||||
// usable on the current host. The check is stateless and side-effect free
|
||||
// (no config writes, no network) so it is safe to call from both the
|
||||
|
|
@ -22,7 +26,8 @@ func DetectAvailable() []string {
|
|||
if NewWindowsUpdateScanner().IsAvailable() {
|
||||
scanners = append(scanners, "windows")
|
||||
}
|
||||
if dockerScanner, _ := NewDockerScanner(); dockerScanner != nil && dockerScanner.IsAvailable() {
|
||||
// Inline Docker availability check — avoids importing orchestrator (circular).
|
||||
if _, err := exec.LookPath("docker"); err == nil {
|
||||
scanners = append(scanners, "docker")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
|
@ -19,6 +20,8 @@ func NewDNFScanner() *DNFScanner {
|
|||
return &DNFScanner{}
|
||||
}
|
||||
|
||||
func (s *DNFScanner) Name() string { return "DNF Update Scanner" }
|
||||
|
||||
// IsAvailable checks if DNF is available on this system
|
||||
func (s *DNFScanner) IsAvailable() bool {
|
||||
_, err := exec.LookPath("dnf")
|
||||
|
|
@ -46,8 +49,8 @@ func parseDNFOutput(output []byte) ([]client.UpdateReportItem, error) {
|
|||
var updates []client.UpdateReportItem
|
||||
scanner := bufio.NewScanner(bytes.NewReader(output))
|
||||
|
||||
// Regex to parse dnf check-update output:
|
||||
// package-name.version arch new-version
|
||||
// Regex to parse dnf check-update output.
|
||||
// Format: pkgname.arch version repo (three columns, verified on dnf5)
|
||||
re := regexp.MustCompile(`^([^\s]+)\.([^\s]+)\s+([^\s]+)\s+([^\s]+)$`)
|
||||
|
||||
for scanner.Scan() {
|
||||
|
|
@ -64,36 +67,26 @@ func parseDNFOutput(output []byte) ([]client.UpdateReportItem, error) {
|
|||
|
||||
matches := re.FindStringSubmatch(line)
|
||||
if len(matches) < 5 {
|
||||
continue
|
||||
log.Printf("[WARNING] [scanner] [dnf] unparseable_line line=%q", line)
|
||||
continue
|
||||
}
|
||||
|
||||
packageName := matches[1]
|
||||
arch := matches[2]
|
||||
repoAndVersion := matches[3]
|
||||
newVersion := matches[4]
|
||||
version := matches[3]
|
||||
repository := matches[4]
|
||||
|
||||
// Extract repository and current version from repoAndVersion
|
||||
// Format is typically: repo-version current-version
|
||||
parts := strings.Fields(repoAndVersion)
|
||||
var repository, currentVersion string
|
||||
|
||||
if len(parts) >= 2 {
|
||||
repository = parts[0]
|
||||
currentVersion = parts[1]
|
||||
} else if len(parts) == 1 {
|
||||
repository = parts[0]
|
||||
// Try to get current version from rpm
|
||||
currentVersion = getInstalledVersion(packageName)
|
||||
}
|
||||
// Get currently installed version via rpm
|
||||
currentVersion := getInstalledVersion(packageName)
|
||||
|
||||
// Determine severity based on repository and update type
|
||||
severity := determineSeverity(repository, packageName, newVersion)
|
||||
severity := determineSeverity(repository, packageName, version)
|
||||
|
||||
update := client.UpdateReportItem{
|
||||
PackageType: "dnf",
|
||||
PackageName: packageName,
|
||||
CurrentVersion: currentVersion,
|
||||
AvailableVersion: newVersion,
|
||||
AvailableVersion: version,
|
||||
Severity: severity,
|
||||
RepositorySource: repository,
|
||||
Metadata: map[string]interface{}{
|
||||
|
|
|
|||
174
agent/internal/scanner/dnf_test.go
Normal file
174
agent/internal/scanner/dnf_test.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Real `dnf check-update` output captured from dnf5 v5.2.18 on Fedora 43.
|
||||
// Format: pkgname.arch version repo (three columns).
|
||||
|
||||
func TestParseDNFOutput_StandardRepos(t *testing.T) {
|
||||
output := []byte("SDL3.x86_64 3.4.8-1.fc43 updates\n")
|
||||
updates, err := parseDNFOutput(output)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if len(updates) != 1 {
|
||||
t.Fatalf("expected 1 update, got %d", len(updates))
|
||||
}
|
||||
u := updates[0]
|
||||
if u.PackageName != "SDL3" {
|
||||
t.Errorf("PackageName = %q, want %q", u.PackageName, "SDL3")
|
||||
}
|
||||
if u.AvailableVersion != "3.4.8-1.fc43" {
|
||||
t.Errorf("AvailableVersion = %q, want %q", u.AvailableVersion, "3.4.8-1.fc43")
|
||||
}
|
||||
if u.RepositorySource != "updates" {
|
||||
t.Errorf("RepositorySource = %q, want %q", u.RepositorySource, "updates")
|
||||
}
|
||||
if u.PackageType != "dnf" {
|
||||
t.Errorf("PackageType = %q, want %q", u.PackageType, "dnf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDNFOutput_CoprRepo(t *testing.T) {
|
||||
output := []byte("7zip.x86_64 26.01-1.fc43 copr:copr.fedorainfracloud.org:errornointernet:packages\n")
|
||||
updates, err := parseDNFOutput(output)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if len(updates) != 1 {
|
||||
t.Fatalf("expected 1 update, got %d", len(updates))
|
||||
}
|
||||
u := updates[0]
|
||||
if u.PackageName != "7zip" {
|
||||
t.Errorf("PackageName = %q, want %q", u.PackageName, "7zip")
|
||||
}
|
||||
if u.AvailableVersion != "26.01-1.fc43" {
|
||||
t.Errorf("AvailableVersion = %q, want %q", u.AvailableVersion, "26.01-1.fc43")
|
||||
}
|
||||
if u.RepositorySource != "copr:copr.fedorainfracloud.org:errornointernet:packages" {
|
||||
t.Errorf("RepositorySource = %q, want %q", u.RepositorySource, "copr:copr.fedorainfracloud.org:errornointernet:packages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDNFOutput_DockerRepo(t *testing.T) {
|
||||
output := []byte("docker-ce.x86_64 3:29.5.2-1.fc43 docker-ce-stable\n")
|
||||
updates, err := parseDNFOutput(output)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if len(updates) != 1 {
|
||||
t.Fatalf("expected 1 update, got %d", len(updates))
|
||||
}
|
||||
u := updates[0]
|
||||
if u.AvailableVersion != "3:29.5.2-1.fc43" {
|
||||
t.Errorf("AvailableVersion = %q, want %q", u.AvailableVersion, "3:29.5.2-1.fc43")
|
||||
}
|
||||
if u.RepositorySource != "docker-ce-stable" {
|
||||
t.Errorf("RepositorySource = %q, want %q", u.RepositorySource, "docker-ce-stable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDNFOutput_CoprRepoWithColons(t *testing.T) {
|
||||
output := []byte("coolercontrol.x86_64 4.3.1-1.fc43 copr:copr.fedorainfracloud.org:codifryed:CoolerControl\n")
|
||||
updates, err := parseDNFOutput(output)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if len(updates) != 1 {
|
||||
t.Fatalf("expected 1 update, got %d", len(updates))
|
||||
}
|
||||
u := updates[0]
|
||||
if u.AvailableVersion != "4.3.1-1.fc43" {
|
||||
t.Errorf("AvailableVersion = %q, want %q", u.AvailableVersion, "4.3.1-1.fc43")
|
||||
}
|
||||
if u.RepositorySource != "copr:copr.fedorainfracloud.org:codifryed:CoolerControl" {
|
||||
t.Errorf("RepositorySource = %q, want %q", u.RepositorySource, "copr:copr.fedorainfracloud.org:codifryed:CoolerControl")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDNFOutput_SkipsHeader(t *testing.T) {
|
||||
output := []byte("Last metadata expiration check: 0:05:23 ago on Wed 2026-05-28.\n\nSDL3.x86_64 3.4.8-1.fc43 updates\n")
|
||||
updates, err := parseDNFOutput(output)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if len(updates) != 1 {
|
||||
t.Fatalf("expected 1 update (skip header+blank), got %d", len(updates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDNFOutput_SkipsObsoleting(t *testing.T) {
|
||||
output := []byte("Obsoleting Packages:\nSDL3.x86_64 3.4.8-1.fc43 updates\n")
|
||||
updates, err := parseDNFOutput(output)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if len(updates) != 1 {
|
||||
t.Fatalf("expected 1 update (skip Obsoleting), got %d", len(updates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDNFOutput_Empty(t *testing.T) {
|
||||
updates, err := parseDNFOutput([]byte{})
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if len(updates) != 0 {
|
||||
t.Fatalf("expected 0 updates from empty input, got %d", len(updates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDNFOutput_ArchitectureInMetadata(t *testing.T) {
|
||||
output := []byte("curl.x86_64 8.15.0-7.fc43 updates\n")
|
||||
updates, err := parseDNFOutput(output)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if len(updates) != 1 {
|
||||
t.Fatalf("expected 1 update, got %d", len(updates))
|
||||
}
|
||||
u := updates[0]
|
||||
arch, ok := u.Metadata["architecture"].(string)
|
||||
if !ok {
|
||||
t.Fatal("metadata.architecture missing or not a string")
|
||||
}
|
||||
if arch != "x86_64" {
|
||||
t.Errorf("architecture = %q, want %q", arch, "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDNFOutput_MultipleLines(t *testing.T) {
|
||||
output := []byte("curl.x86_64 8.15.0-7.fc43 updates\ndnsmasq.x86_64 2.92rel2-2.fc43 updates\nbind-libs.x86_64 32:9.18.49-1.fc43 updates\n")
|
||||
updates, err := parseDNFOutput(output)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if len(updates) != 3 {
|
||||
t.Fatalf("expected 3 updates, got %d", len(updates))
|
||||
}
|
||||
names := []string{"curl", "dnsmasq", "bind-libs"}
|
||||
for i, name := range names {
|
||||
if updates[i].PackageName != name {
|
||||
t.Errorf("updates[%d].PackageName = %q, want %q", i, updates[i].PackageName, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentVersion is populated via `rpm -q` which won't work in test
|
||||
// environments. Verify it returns "unknown" rather than crashing.
|
||||
func TestParseDNFOutput_CurrentVersionUnknown(t *testing.T) {
|
||||
output := []byte("nonexistent-package-12345.x86_64 1.0-1.fc43 updates\n")
|
||||
updates, err := parseDNFOutput(output)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if len(updates) != 1 {
|
||||
t.Fatalf("expected 1 update, got %d", len(updates))
|
||||
}
|
||||
// getInstalledVersion fails for fake packages → returns "unknown"
|
||||
if updates[0].CurrentVersion != "unknown" {
|
||||
t.Errorf("CurrentVersion = %q, want %q", updates[0].CurrentVersion, "unknown")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
// DockerScanner scans for Docker image updates
|
||||
type DockerScanner struct {
|
||||
client *dockerclient.Client
|
||||
registryClient *RegistryClient
|
||||
}
|
||||
|
||||
// NewDockerScanner creates a new Docker scanner
|
||||
func NewDockerScanner() (*DockerScanner, error) {
|
||||
cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DockerScanner{
|
||||
client: cli,
|
||||
registryClient: NewRegistryClient(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsAvailable checks if Docker is available on this system
|
||||
func (s *DockerScanner) IsAvailable() bool {
|
||||
_, err := exec.LookPath("docker")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to ping Docker daemon
|
||||
if s.client != nil {
|
||||
_, err := s.client.Ping(context.Background())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Scan scans for available Docker image updates
|
||||
func (s *DockerScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// List all containers
|
||||
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list containers: %w", err)
|
||||
}
|
||||
|
||||
var updates []client.UpdateReportItem
|
||||
seenImages := make(map[string]bool)
|
||||
|
||||
for _, c := range containers {
|
||||
imageName := c.Image
|
||||
|
||||
// Skip if we've already checked this image
|
||||
if seenImages[imageName] {
|
||||
continue
|
||||
}
|
||||
seenImages[imageName] = true
|
||||
|
||||
// Get current image details
|
||||
imageInspect, _, err := s.client.ImageInspectWithRaw(ctx, imageName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse image name and tag
|
||||
parts := strings.Split(imageName, ":")
|
||||
baseImage := parts[0]
|
||||
currentTag := "latest"
|
||||
if len(parts) > 1 {
|
||||
currentTag = parts[1]
|
||||
}
|
||||
|
||||
// Check if update is available by comparing with registry
|
||||
hasUpdate, remoteDigest := s.checkForUpdate(ctx, baseImage, currentTag, imageInspect.ID)
|
||||
|
||||
if hasUpdate {
|
||||
// Extract short digest for display (first 12 chars of sha256 hash)
|
||||
localDigest := imageInspect.ID
|
||||
remoteShortDigest := "unknown"
|
||||
if len(remoteDigest) > 7 {
|
||||
// Format: sha256:abcd... -> take first 12 chars of hash
|
||||
parts := strings.SplitN(remoteDigest, ":", 2)
|
||||
if len(parts) == 2 && len(parts[1]) >= 12 {
|
||||
remoteShortDigest = parts[1][:12]
|
||||
}
|
||||
}
|
||||
|
||||
update := client.UpdateReportItem{
|
||||
PackageType: "docker_image",
|
||||
PackageName: imageName,
|
||||
PackageDescription: fmt.Sprintf("Container: %s", strings.Join(c.Names, ", ")),
|
||||
CurrentVersion: localDigest[:12], // Short hash
|
||||
AvailableVersion: remoteShortDigest,
|
||||
Severity: "moderate",
|
||||
RepositorySource: baseImage,
|
||||
Metadata: map[string]interface{}{
|
||||
"container_id": c.ID[:12],
|
||||
"container_names": c.Names,
|
||||
"container_state": c.State,
|
||||
"image_created": imageInspect.Created,
|
||||
"local_full_digest": localDigest,
|
||||
"remote_digest": remoteDigest,
|
||||
},
|
||||
}
|
||||
|
||||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
// checkForUpdate checks if a newer image version is available by comparing digests
|
||||
// Returns (hasUpdate bool, remoteDigest string)
|
||||
//
|
||||
// This implementation:
|
||||
// 1. Queries Docker Registry HTTP API v2 for remote manifest
|
||||
// 2. Compares image digests (sha256 hashes) between local and remote
|
||||
// 3. Handles authentication for Docker Hub (anonymous pull)
|
||||
// 4. Caches registry responses (5 min TTL) to respect rate limits
|
||||
// 5. Returns both the update status and remote digest for metadata
|
||||
//
|
||||
// Note: This compares exact digests. If local digest != remote digest, an update exists.
|
||||
// This works for all tags including "latest", version tags, etc.
|
||||
func (s *DockerScanner) checkForUpdate(ctx context.Context, imageName, tag, currentID string) (bool, string) {
|
||||
// Get remote digest from registry
|
||||
remoteDigest, err := s.registryClient.GetRemoteDigest(ctx, imageName, tag)
|
||||
if err != nil {
|
||||
// If we can't check the registry, log the error but don't report an update
|
||||
// This prevents false positives when registry is down or rate-limited
|
||||
fmt.Printf("Warning: Failed to check registry for %s:%s: %v\n", imageName, tag, err)
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Compare digests
|
||||
// Local Docker image ID format: sha256:abc123...
|
||||
// Remote digest format: sha256:def456...
|
||||
// If they differ, an update is available
|
||||
hasUpdate := currentID != remoteDigest
|
||||
|
||||
return hasUpdate, remoteDigest
|
||||
}
|
||||
|
||||
// Close closes the Docker client
|
||||
func (s *DockerScanner) Close() error {
|
||||
if s.client != nil {
|
||||
return s.client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RegistryClient handles communication with Docker registries (Docker Hub and custom registries)
|
||||
type RegistryClient struct {
|
||||
httpClient *http.Client
|
||||
cache *manifestCache
|
||||
}
|
||||
|
||||
// manifestCache stores registry responses to avoid hitting rate limits
|
||||
type manifestCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*cacheEntry
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
digest string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// ManifestResponse represents the response from a Docker Registry API v2 manifest request
|
||||
type ManifestResponse struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
MediaType string `json:"mediaType"`
|
||||
Config struct {
|
||||
Digest string `json:"digest"`
|
||||
} `json:"config"`
|
||||
}
|
||||
|
||||
// DockerHubTokenResponse represents the authentication token response from Docker Hub
|
||||
type DockerHubTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
}
|
||||
|
||||
// NewRegistryClient creates a new registry client with caching
|
||||
func NewRegistryClient() *RegistryClient {
|
||||
return &RegistryClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
cache: &manifestCache{
|
||||
entries: make(map[string]*cacheEntry),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetRemoteDigest fetches the digest of a remote image from the registry
|
||||
// Returns the digest string (e.g., "sha256:abc123...") or an error
|
||||
func (c *RegistryClient) GetRemoteDigest(ctx context.Context, imageName, tag string) (string, error) {
|
||||
// Parse image name to determine registry and repository
|
||||
registry, repository := parseImageName(imageName)
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("%s/%s:%s", registry, repository, tag)
|
||||
if digest := c.cache.get(cacheKey); digest != "" {
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// Get authentication token (if needed)
|
||||
token, err := c.getAuthToken(ctx, registry, repository)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get auth token: %w", err)
|
||||
}
|
||||
|
||||
// Fetch manifest from registry
|
||||
digest, err := c.fetchManifestDigest(ctx, registry, repository, tag, token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch manifest: %w", err)
|
||||
}
|
||||
|
||||
// Cache the result (5 minute TTL to avoid hammering registries)
|
||||
c.cache.set(cacheKey, digest, 5*time.Minute)
|
||||
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// parseImageName splits an image name into registry and repository
|
||||
// Examples:
|
||||
// - "nginx" -> ("registry-1.docker.io", "library/nginx")
|
||||
// - "myuser/myimage" -> ("registry-1.docker.io", "myuser/myimage")
|
||||
// - "gcr.io/myproject/myimage" -> ("gcr.io", "myproject/myimage")
|
||||
func parseImageName(imageName string) (registry, repository string) {
|
||||
parts := strings.Split(imageName, "/")
|
||||
|
||||
// Check if first part looks like a domain (contains . or :)
|
||||
if len(parts) >= 2 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
|
||||
// Custom registry: gcr.io/myproject/myimage
|
||||
registry = parts[0]
|
||||
repository = strings.Join(parts[1:], "/")
|
||||
} else if len(parts) == 1 {
|
||||
// Official image: nginx -> library/nginx
|
||||
registry = "registry-1.docker.io"
|
||||
repository = "library/" + parts[0]
|
||||
} else {
|
||||
// User image: myuser/myimage
|
||||
registry = "registry-1.docker.io"
|
||||
repository = imageName
|
||||
}
|
||||
|
||||
return registry, repository
|
||||
}
|
||||
|
||||
// getAuthToken obtains an authentication token for the registry
|
||||
// For Docker Hub, uses the token authentication flow
|
||||
// For other registries, may need different auth mechanisms (TODO: implement)
|
||||
func (c *RegistryClient) getAuthToken(ctx context.Context, registry, repository string) (string, error) {
|
||||
// Docker Hub token authentication
|
||||
if registry == "registry-1.docker.io" {
|
||||
return c.getDockerHubToken(ctx, repository)
|
||||
}
|
||||
|
||||
// For other registries, we'll try unauthenticated first
|
||||
// TODO: Support authentication for private registries (basic auth, bearer tokens, etc.)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// getDockerHubToken obtains a token from Docker Hub's authentication service
|
||||
func (c *RegistryClient) getDockerHubToken(ctx context.Context, repository string) (string, error) {
|
||||
authURL := fmt.Sprintf(
|
||||
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull",
|
||||
repository,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp DockerHubTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
// Docker Hub can return either 'token' or 'access_token'
|
||||
if tokenResp.Token != "" {
|
||||
return tokenResp.Token, nil
|
||||
}
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
// fetchManifestDigest fetches the manifest from the registry and extracts the digest
|
||||
func (c *RegistryClient) fetchManifestDigest(ctx context.Context, registry, repository, tag, token string) (string, error) {
|
||||
// Build manifest URL
|
||||
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registry, repository, tag)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", manifestURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Set required headers
|
||||
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return "", fmt.Errorf("rate limited by registry (429 Too Many Requests)")
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return "", fmt.Errorf("unauthorized: authentication failed for %s/%s:%s", registry, repository, tag)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("manifest request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Try to get digest from Docker-Content-Digest header first (faster)
|
||||
if digest := resp.Header.Get("Docker-Content-Digest"); digest != "" {
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// Fallback: parse manifest and extract config digest
|
||||
var manifest ManifestResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
||||
return "", fmt.Errorf("failed to decode manifest: %w", err)
|
||||
}
|
||||
|
||||
if manifest.Config.Digest == "" {
|
||||
return "", fmt.Errorf("manifest does not contain a config digest")
|
||||
}
|
||||
|
||||
return manifest.Config.Digest, nil
|
||||
}
|
||||
|
||||
// manifestCache methods
|
||||
|
||||
func (mc *manifestCache) get(key string) string {
|
||||
mc.mu.RLock()
|
||||
defer mc.mu.RUnlock()
|
||||
|
||||
entry, exists := mc.entries[key]
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
// Entry expired
|
||||
delete(mc.entries, key)
|
||||
return ""
|
||||
}
|
||||
|
||||
return entry.digest
|
||||
}
|
||||
|
||||
func (mc *manifestCache) set(key, digest string, ttl time.Duration) {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
|
||||
mc.entries[key] = &cacheEntry{
|
||||
digest: digest,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupExpired removes expired entries from the cache (called periodically)
|
||||
func (mc *manifestCache) cleanupExpired() {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
for key, entry := range mc.entries {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(mc.entries, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,5 +23,7 @@ func (s *WindowsUpdateScanner) Scan() ([]client.UpdateReportItem, error) {
|
|||
return []client.UpdateReportItem{}, nil
|
||||
}
|
||||
|
||||
func (s *WindowsUpdateScanner) Name() string { return "Windows Update Scanner" }
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ func NewWindowsUpdateScannerWUA() *WindowsUpdateScannerWUA {
|
|||
return &WindowsUpdateScannerWUA{}
|
||||
}
|
||||
|
||||
func (s *WindowsUpdateScannerWUA) Name() string { return "Windows Update Scanner" }
|
||||
|
||||
// IsAvailable checks if WUA scanner is available on this system
|
||||
func (s *WindowsUpdateScannerWUA) IsAvailable() bool {
|
||||
// Only available on Windows
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ func NewWingetScanner() *WingetScanner {
|
|||
return &WingetScanner{}
|
||||
}
|
||||
|
||||
func (s *WingetScanner) Name() string { return "Winget Package Update Scanner" }
|
||||
|
||||
// IsAvailable checks if winget is available on this system
|
||||
func (s *WingetScanner) IsAvailable() bool {
|
||||
if runtime.GOOS != "windows" {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ services:
|
|||
context: .
|
||||
dockerfile: ./server/Dockerfile
|
||||
args:
|
||||
BUILD_VERSION: ${BUILD_VERSION:-0.2.0.5}
|
||||
BUILD_VERSION: ${BUILD_VERSION:-0.2.0.6}
|
||||
container_name: redflag-server
|
||||
volumes:
|
||||
- server-config:/app/config
|
||||
|
|
|
|||
|
|
@ -38,6 +38,16 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \
|
|||
-ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$BUILD_VERSION" \
|
||||
-o binaries/linux-arm64/redflag-agent ./cmd/agent
|
||||
|
||||
# Build for macOS amd64
|
||||
RUN CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build \
|
||||
-ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$BUILD_VERSION" \
|
||||
-o binaries/darwin-amd64/redflag-agent ./cmd/agent
|
||||
|
||||
# Build for macOS arm64
|
||||
RUN CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build \
|
||||
-ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$BUILD_VERSION" \
|
||||
-o binaries/darwin-arm64/redflag-agent ./cmd/agent
|
||||
|
||||
# Build for Windows amd64
|
||||
RUN CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build \
|
||||
-ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$BUILD_VERSION" \
|
||||
|
|
|
|||
|
|
@ -204,8 +204,8 @@ func main() {
|
|||
upstreamQueries := queries.NewUpstreamQueries(db.DB)
|
||||
agentTrackedSoftwareQueries := queries.NewAgentTrackedSoftwareQueries(db.DB)
|
||||
agentUpdateQueries := queries.NewAgentUpdateQueries(db.DB)
|
||||
metricsQueries := queries.NewMetricsQueries(db.DB.DB)
|
||||
dockerQueries := queries.NewDockerQueries(db.DB.DB)
|
||||
metricsQueries := queries.NewMetricsQueries(db.DB)
|
||||
dockerQueries := queries.NewDockerQueries(db.DB)
|
||||
storageMetricsQueries := queries.NewStorageMetricsQueries(db.DB.DB)
|
||||
adminQueries := queries.NewAdminQueries(db.DB)
|
||||
|
||||
|
|
@ -257,7 +257,7 @@ func main() {
|
|||
buildOrchestrator = services.NewBuildOrchestratorService(signingService, packageQueries, filepath.Join("/app"))
|
||||
log.Printf("[system] BuildOrchestratorService initialized - will sign agent binaries")
|
||||
// Sign all pre-built binaries at startup
|
||||
platforms := []string{"linux-amd64", "linux-arm64", "windows-amd64", "windows-arm64"}
|
||||
platforms := []string{"linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", "windows-amd64", "windows-arm64"}
|
||||
for _, platform := range platforms {
|
||||
parts := strings.SplitN(platform, "-", 2)
|
||||
if len(parts) == 2 {
|
||||
|
|
@ -474,8 +474,12 @@ func main() {
|
|||
// Package artifact download (for hash computation at approval time)
|
||||
api.GET("/downloads/artifact", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadPackageArtifact)
|
||||
|
||||
// Protected download routes (F-A3-6, F-A3-7: require authentication)
|
||||
api.GET("/downloads/updates/:package_id", middleware.AuthMiddleware(), rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadUpdatePackage)
|
||||
// Protected download routes (F-A3-6, F-A3-7: require authentication + machine binding)
|
||||
api.GET("/downloads/updates/:package_id",
|
||||
middleware.AuthMiddleware(),
|
||||
middleware.MachineBindingMiddleware(agentQueries, cfg.MinAgentVersion),
|
||||
rateLimiter.RateLimit("public_access", middleware.KeyByIP),
|
||||
downloadHandler.DownloadUpdatePackage)
|
||||
api.GET("/downloads/config/:agent_id", authHandler.WebAuthMiddleware(), rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.HandleConfigDownload)
|
||||
}
|
||||
|
||||
|
|
@ -519,6 +523,9 @@ func main() {
|
|||
// Initialize and start scheduler
|
||||
schedulerConfig := scheduler.DefaultConfig()
|
||||
subsystemScheduler := scheduler.NewScheduler(schedulerConfig, agentQueries, commandQueries, subsystemQueries, signingService)
|
||||
// Wire scheduler into SubsystemHandler so DisableSubsystem can evict
|
||||
// jobs from the in-memory priority queue.
|
||||
subsystemHandler.SetScheduler(subsystemScheduler)
|
||||
|
||||
// Initialize agentHandler now that scheduler is available
|
||||
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, refreshTokenQueries, registrationTokenQueries, subsystemQueries, subsystemScheduler, signingService, securityLogger, cfg.CheckInInterval, cfg.LatestAgentVersion, stuckCommandTimeout, maxCommandRetries)
|
||||
|
|
@ -539,7 +546,7 @@ func main() {
|
|||
}
|
||||
|
||||
// Initialize updateHandler with the agentHandler reference
|
||||
updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries, commandQueries, agentHandler, maintenanceWindowQueries)
|
||||
updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries, commandQueries, agentHandler, maintenanceWindowQueries, cfg)
|
||||
if securitySettingsService != nil {
|
||||
// Wires policy.allow_dry_runs.
|
||||
updateHandler.SetSecuritySettings(securitySettingsService)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ require (
|
|||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.4.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/doug-martin/goqu/v9 v9.19.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
|
|
@ -34,6 +35,7 @@ github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1
|
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v25.0.6+incompatible h1:5cPwbwriIcsua2REJe8HqQV+6WlWc1byg2QSXzBxBGg=
|
||||
|
|
@ -42,6 +44,8 @@ github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKoh
|
|||
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSvBx7qo=
|
||||
github.com/doug-martin/goqu/v9 v9.19.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
|
|
@ -77,6 +81,7 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
|||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
|
|
@ -87,6 +92,7 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
|||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
|
|
@ -117,10 +123,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
|
|
@ -164,6 +172,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
|||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
|
|
@ -203,6 +212,8 @@ go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
|||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
|
|
|
|||
|
|
@ -757,8 +757,10 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
|
|||
// which now only sees 'pending' and 'sent'. Receipt confirmation goes back in the
|
||||
// response so the agent can drop them from its outbox.
|
||||
var receiptConfirmedIDs []string
|
||||
var confirmed []string // commands the server has marked completed (via ReportLog)
|
||||
if len(metrics.ReceivedCommandIDs) > 0 {
|
||||
confirmed, markErr := h.commandQueries.MarkCommandsReceivedTx(cmdTx, agentID, metrics.ReceivedCommandIDs)
|
||||
var markErr error
|
||||
confirmed, markErr = h.commandQueries.MarkCommandsReceivedTx(cmdTx, agentID, metrics.ReceivedCommandIDs)
|
||||
if markErr != nil {
|
||||
log.Printf("[ERROR] [server] [command] mark_received_failed agent_id=%s reported=%d error=%v",
|
||||
agentID, len(metrics.ReceivedCommandIDs), markErr)
|
||||
|
|
@ -769,6 +771,9 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
|
|||
agentID, len(confirmed), len(metrics.ReceivedCommandIDs))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No ReceivedCommandIDs — start with empty confirmed set
|
||||
confirmed = []string{}
|
||||
}
|
||||
|
||||
// Get pending commands with row-level lock
|
||||
|
|
@ -957,117 +962,17 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// Hybrid Heartbeat: Check for scheduled subsystem jobs during heartbeat mode
|
||||
// This ensures that even in heartbeat mode, scheduled scans can be triggered
|
||||
if h.scheduler != nil {
|
||||
// Only check for scheduled jobs if agent is in heartbeat mode (rapid polling enabled)
|
||||
isHeartbeatMode := rapidPolling != nil && rapidPolling.Enabled
|
||||
if isHeartbeatMode {
|
||||
if err := h.checkAndCreateScheduledCommands(agentID); err != nil {
|
||||
// Log error but don't fail the request - this is enhancement, not core functionality
|
||||
log.Printf("[Heartbeat] Failed to check scheduled commands for agent %s: %v", agentID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response := models.CommandsResponse{
|
||||
Commands: commandItems,
|
||||
RapidPolling: rapidPolling,
|
||||
AcknowledgedIDs: acknowledgedIDs,
|
||||
ReceiptConfirmedIDs: receiptConfirmedIDs,
|
||||
Commands: commandItems,
|
||||
RapidPolling: rapidPolling,
|
||||
AcknowledgedIDs: acknowledgedIDs,
|
||||
ReceiptConfirmedIDs: receiptConfirmedIDs,
|
||||
ConfirmedCommandIDs: confirmed,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// checkAndCreateScheduledCommands checks if any subsystem jobs are due for the agent
|
||||
// and creates commands for them using the scheduler (following Option A approach)
|
||||
func (h *AgentHandler) checkAndCreateScheduledCommands(agentID uuid.UUID) error {
|
||||
// Get current subsystems for this agent from database
|
||||
subsystems, err := h.subsystemQueries.GetSubsystems(agentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get subsystems: %w", err)
|
||||
}
|
||||
|
||||
// Check each enabled subsystem with auto_run=true
|
||||
now := time.Now().UTC()
|
||||
jobsCreated := 0
|
||||
|
||||
for _, subsystem := range subsystems {
|
||||
if !subsystem.Enabled || !subsystem.AutoRun {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this subsystem job is due
|
||||
var isDue bool
|
||||
if subsystem.NextRunAt == nil {
|
||||
// No next run time set, it's due
|
||||
isDue = true
|
||||
} else {
|
||||
// Check if next run time has passed
|
||||
isDue = subsystem.NextRunAt.Before(now) || subsystem.NextRunAt.Equal(now)
|
||||
}
|
||||
|
||||
if isDue {
|
||||
// Create the command using scheduler logic (reusing existing safeguards)
|
||||
if err := h.createSubsystemCommand(agentID, subsystem); err != nil {
|
||||
log.Printf("[Heartbeat] Failed to create command for %s subsystem: %v", subsystem.Subsystem, err)
|
||||
continue
|
||||
}
|
||||
jobsCreated++
|
||||
|
||||
// Update next run time in database ONLY after successful command creation
|
||||
if err := h.updateNextRunTime(agentID, subsystem); err != nil {
|
||||
log.Printf("[Heartbeat] Failed to update next run time for %s subsystem: %v", subsystem.Subsystem, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if jobsCreated > 0 {
|
||||
log.Printf("[Heartbeat] Created %d scheduled commands for agent %s", jobsCreated, agentID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSubsystemCommand creates a subsystem scan command using scheduler's logic
|
||||
func (h *AgentHandler) createSubsystemCommand(agentID uuid.UUID, subsystem models.AgentSubsystem) error {
|
||||
// Check backpressure: skip if agent has too many pending commands
|
||||
pendingCount, err := h.commandQueries.CountPendingCommandsForAgent(agentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check pending commands: %w", err)
|
||||
}
|
||||
|
||||
// Backpressure threshold (same as scheduler)
|
||||
const backpressureThreshold = 10
|
||||
if pendingCount >= backpressureThreshold {
|
||||
return fmt.Errorf("agent has %d pending commands (threshold: %d), skipping", pendingCount, backpressureThreshold)
|
||||
}
|
||||
|
||||
// Create the command using same format as scheduler
|
||||
cmd := &models.AgentCommand{
|
||||
ID: uuid.New(),
|
||||
AgentID: agentID,
|
||||
CommandType: fmt.Sprintf("scan_%s", subsystem.Subsystem),
|
||||
Params: models.JSONB{},
|
||||
Status: models.CommandStatusPending,
|
||||
Source: models.CommandSourceSystem,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := h.signAndCreateCommand(cmd); err != nil {
|
||||
return fmt.Errorf("failed to create command: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateNextRunTime updates the last_run_at and next_run_at for a subsystem after creating a command
|
||||
func (h *AgentHandler) updateNextRunTime(agentID uuid.UUID, subsystem models.AgentSubsystem) error {
|
||||
// Use the existing UpdateLastRun method which handles next_run_at calculation
|
||||
return h.subsystemQueries.UpdateLastRun(agentID, subsystem.Subsystem)
|
||||
}
|
||||
|
||||
// ListAgents returns all agents with last scan information
|
||||
func (h *AgentHandler) ListAgents(c *gin.Context) {
|
||||
status := c.Query("status")
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
)
|
||||
|
||||
// ClientErrorHandler handles frontend error logging per ETHOS #1
|
||||
|
|
@ -43,6 +44,7 @@ type ClientErrorResponse struct {
|
|||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// GetErrors returns paginated error logs (admin only)
|
||||
// GetErrors returns paginated error logs (admin only)
|
||||
func (h *ClientErrorHandler) GetErrors(c *gin.Context) {
|
||||
// Parse pagination params
|
||||
|
|
@ -63,67 +65,61 @@ func (h *ClientErrorHandler) GetErrors(c *gin.Context) {
|
|||
errorType := c.Query("error_type")
|
||||
agentIDStr := c.Query("agent_id")
|
||||
|
||||
// Build query
|
||||
query := `SELECT id, agent_id, subsystem, error_type, message, metadata, url, created_at
|
||||
FROM client_errors
|
||||
WHERE 1=1`
|
||||
params := map[string]interface{}{}
|
||||
// Build filter with goqu
|
||||
sd := goqu.Dialect("postgres").From("client_errors")
|
||||
|
||||
if subsystem != "" {
|
||||
query += " AND subsystem = :subsystem"
|
||||
params["subsystem"] = subsystem
|
||||
sd = sd.Where(goqu.Ex{"subsystem": subsystem})
|
||||
}
|
||||
if errorType != "" {
|
||||
query += " AND error_type = :error_type"
|
||||
params["error_type"] = errorType
|
||||
sd = sd.Where(goqu.Ex{"error_type": errorType})
|
||||
}
|
||||
if agentIDStr != "" {
|
||||
query += " AND agent_id = :agent_id"
|
||||
params["agent_id"] = agentIDStr
|
||||
if id, err := uuid.Parse(agentIDStr); err == nil {
|
||||
sd = sd.Where(goqu.Ex{"agent_id": id})
|
||||
}
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
|
||||
params["limit"] = pageSize
|
||||
params["offset"] = (page - 1) * pageSize
|
||||
// Count
|
||||
countSQL, countArgs, err := sd.Select(goqu.COUNT("*")).ToSQL()
|
||||
if err != nil {
|
||||
log.Printf(`[ERROR] [server] [client_error] count_build_failed error="%v"`, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
var total int64
|
||||
if err := h.db.Get(&total, countSQL, countArgs...); err != nil {
|
||||
log.Printf(`[ERROR] [server] [client_error] count_failed error="%v"`, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Execute query
|
||||
var errors []ClientErrorResponse
|
||||
if err := h.db.Select(&errors, query, params); err != nil {
|
||||
log.Printf("[ERROR] [server] [client_error] query_failed error=\"%v\"", err)
|
||||
// Data with pagination
|
||||
sql, args, err := sd.Select(
|
||||
"id", "agent_id", "subsystem", "error_type", "message", "metadata", "url", "created_at",
|
||||
).Order(goqu.C("created_at").Desc()).Limit(uint(pageSize)).Offset(uint((page - 1) * pageSize)).ToSQL()
|
||||
if err != nil {
|
||||
log.Printf(`[ERROR] [server] [client_error] query_build_failed error="%v"`, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get total count
|
||||
countQuery := `SELECT COUNT(*) FROM client_errors WHERE 1=1`
|
||||
if subsystem != "" {
|
||||
countQuery += " AND subsystem = :subsystem"
|
||||
}
|
||||
if errorType != "" {
|
||||
countQuery += " AND error_type = :error_type"
|
||||
}
|
||||
if agentIDStr != "" {
|
||||
countQuery += " AND agent_id = :agent_id"
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := h.db.Get(&total, countQuery, params); err != nil {
|
||||
log.Printf("[ERROR] [server] [client_error] count_failed error=\"%v\"", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"})
|
||||
var errors []ClientErrorResponse
|
||||
if err := h.db.Select(&errors, sql, args...); err != nil {
|
||||
log.Printf(`[ERROR] [server] [client_error] query_failed error="%v"`, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||
|
||||
response := GetErrorsResponse{
|
||||
c.JSON(http.StatusOK, GetErrorsResponse{
|
||||
Errors: errors,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
// LogErrorRequest represents a client error log entry
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ func (h *DockerHandler) RejectUpdate(c *gin.Context) {
|
|||
}
|
||||
|
||||
// For now, we'll mark as rejected (this would need a proper reject method in queries)
|
||||
if err := h.updateQueries.UpdatePackageStatus(update.AgentID, "docker", update.PackageName, "rejected", nil, nil); err != nil {
|
||||
if err := h.updateQueries.UpdatePackageStatus(update.AgentID, "docker", update.PackageName, "ignored", nil, nil); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject Docker update"})
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/Fimeg/RedFlag/server/internal/command"
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/Fimeg/RedFlag/server/internal/scheduler"
|
||||
"github.com/Fimeg/RedFlag/server/internal/services"
|
||||
"github.com/Fimeg/RedFlag/server/internal/logging"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -23,6 +24,7 @@ type SubsystemHandler struct {
|
|||
commandFactory *command.Factory
|
||||
signingService *services.SigningService
|
||||
securityLogger *logging.SecurityLogger
|
||||
scheduler *scheduler.Scheduler
|
||||
}
|
||||
|
||||
func NewSubsystemHandler(sq *queries.SubsystemQueries, cq *queries.CommandQueries, aq *queries.AgentQueries, cf *command.Factory, signingService *services.SigningService, securityLogger *logging.SecurityLogger) *SubsystemHandler {
|
||||
|
|
@ -36,6 +38,12 @@ func NewSubsystemHandler(sq *queries.SubsystemQueries, cq *queries.CommandQuerie
|
|||
}
|
||||
}
|
||||
|
||||
// SetScheduler injects the scheduler reference so DisableSubsystem can evict
|
||||
// jobs from the in-memory queue. Nil-safe — a nil scheduler is a no-op.
|
||||
func (h *SubsystemHandler) SetScheduler(s *scheduler.Scheduler) {
|
||||
h.scheduler = s
|
||||
}
|
||||
|
||||
// signAndCreateCommand signs a command before storing.
|
||||
// STRICT MODE: Commands without signatures are rejected (ETHOS #2 Security is Non-Negotiable)
|
||||
func (h *SubsystemHandler) signAndCreateCommand(cmd *models.AgentCommand) error {
|
||||
|
|
@ -226,6 +234,16 @@ func (h *SubsystemHandler) DisableSubsystem(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Evict the scheduled job from the in-memory scheduler so it stops generating
|
||||
// commands immediately — the DB flip alone isn't enough since the scheduler's
|
||||
// priority queue only re-reads state on restart.
|
||||
if h.scheduler != nil {
|
||||
if removed := h.scheduler.RemoveSubsystemJob(agentID, subsystem); removed {
|
||||
log.Printf("[INFO] [server] [subsystems] scheduler_job_removed agent_id=%s subsystem=%s",
|
||||
agentID, subsystem)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Subsystem disabled successfully"})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/config"
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/Fimeg/RedFlag/server/internal/services"
|
||||
|
|
@ -40,15 +41,17 @@ type UpdateHandler struct {
|
|||
agentHandler *AgentHandler
|
||||
maintenanceWindowQueries *queries.MaintenanceWindowQueries
|
||||
securitySettings *services.SecuritySettingsService // optional; reads policy.allow_dry_runs
|
||||
config *config.Config // optional; for self-referential URLs
|
||||
}
|
||||
|
||||
func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries, ah *AgentHandler, mwq *queries.MaintenanceWindowQueries) *UpdateHandler {
|
||||
func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries, ah *AgentHandler, mwq *queries.MaintenanceWindowQueries, cfg *config.Config) *UpdateHandler {
|
||||
return &UpdateHandler{
|
||||
updateQueries: uq,
|
||||
agentQueries: aq,
|
||||
commandQueries: cq,
|
||||
agentHandler: ah,
|
||||
maintenanceWindowQueries: mwq,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -323,9 +326,12 @@ func (h *UpdateHandler) computeAndStorePackageHash(update *models.UpdateState) (
|
|||
|
||||
ecosystem := services.EcosystemFromPackageType(update.PackageType)
|
||||
|
||||
// Call the download handler's artifact endpoint using server's public URL
|
||||
// Call the download handler's artifact endpoint using the server's own URL
|
||||
// This ensures the hash is computed from the same source agents will use
|
||||
baseURL := "http://localhost:8080"
|
||||
baseURL := h.config.Server.PublicURL
|
||||
if baseURL == "" {
|
||||
baseURL = fmt.Sprintf("http://%s:%d", h.config.Server.Host, h.config.Server.Port)
|
||||
}
|
||||
downloadURL := fmt.Sprintf("%s/api/v1/downloads/artifact?ecosystem=%s&package_name=%s&version=%s",
|
||||
baseURL, ecosystem, update.PackageName, update.AvailableVersion)
|
||||
|
||||
|
|
@ -379,6 +385,28 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Idempotency check: if command_id is provided and command is already
|
||||
// completed/failed, reject duplicate submissions (ETHOS #4).
|
||||
if req.CommandID != "" {
|
||||
commandID, err := uuid.Parse(req.CommandID)
|
||||
if err == nil {
|
||||
command, err := h.commandQueries.GetCommandByID(commandID)
|
||||
if err == nil && command != nil {
|
||||
// Command already has a terminal status — reject duplicate
|
||||
if command.Status == models.CommandStatusCompleted || command.Status == models.CommandStatusFailed || command.Status == models.CommandStatusTimedOut {
|
||||
log.Printf("[INFO] [server] [updates] duplicate_log_rejected agent_id=%s command_id=%s status=%s",
|
||||
agentID, commandID, command.Status)
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "duplicate log submission",
|
||||
"command_id": commandID.String(),
|
||||
"current_status": command.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and map result to comply with database constraint
|
||||
validResult := req.Result
|
||||
if !isValidResult(validResult) {
|
||||
|
|
@ -478,6 +506,20 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
|
|||
log.Printf("[ERROR] [server] [updates] mark_failed_failed agent_id=%s command_id=%s error=%q",
|
||||
agentID, commandID, markErr)
|
||||
}
|
||||
|
||||
// If this was a failed confirm_dependencies command, mark the package as failed
|
||||
command, err := h.commandQueries.GetCommandByID(commandID)
|
||||
if err == nil && command.CommandType == models.CommandTypeConfirmDependencies {
|
||||
if packageName, ok := command.Params["package_name"].(string); ok {
|
||||
if packageType, ok := command.Params["package_type"].(string); ok {
|
||||
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, "failed", nil, nil); err != nil {
|
||||
log.Printf("Warning: Failed to update package status for %s/%s: %v", packageType, packageName, err)
|
||||
} else {
|
||||
log.Printf("[INFO] [server] [updates] package_failed package=%s type=%s", packageName, packageType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For other results, just update the result field
|
||||
if markErr = h.commandQueries.UpdateCommandResult(commandID, result); markErr != nil {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import (
|
|||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/version"
|
||||
)
|
||||
|
||||
// Config holds the application configuration
|
||||
|
|
@ -161,7 +163,7 @@ func loadFromEnv(cfg *Config, skipSensitive bool) error {
|
|||
cfg.CheckInInterval = checkInInterval
|
||||
cfg.OfflineThreshold = offlineThreshold
|
||||
cfg.Timezone = getEnv("TIMEZONE", "UTC")
|
||||
cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.2.0.2")
|
||||
cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", version.AgentVersion)
|
||||
cfg.MinAgentVersion = getEnv("MIN_AGENT_VERSION", "0.1.26")
|
||||
|
||||
cfg.BinaryStoragePath = getEnv("REDFLAG_BINARY_STORAGE_PATH", "./binaries")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
-- Restore old constraint vocabulary: ('success', 'failed', 'rollback')
|
||||
ALTER TABLE update_version_history DROP CONSTRAINT IF EXISTS update_version_history_update_status_check;
|
||||
ALTER TABLE update_version_history ADD CONSTRAINT update_version_history_update_status_check
|
||||
CHECK (update_status IN ('success', 'failed', 'rollback'));
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
-- Align update_version_history.update_status vocabulary with
|
||||
-- current_package_state.status: 'updated' instead of 'success'.
|
||||
-- The old constraint allowed ('success', 'failed', 'rollback').
|
||||
-- The new constraint allows ('updated', 'failed', 'rollback').
|
||||
-- No data migration is needed — the existing rows are timestamped
|
||||
-- audit records, and the vocabulary change only affects future inserts.
|
||||
ALTER TABLE update_version_history DROP CONSTRAINT IF EXISTS update_version_history_update_status_check;
|
||||
ALTER TABLE update_version_history ADD CONSTRAINT update_version_history_update_status_check
|
||||
CHECK (update_status IN ('updated', 'failed', 'rollback'));
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
|
@ -96,23 +97,20 @@ func (q *AgentQueries) UpdateAgentMetadata(id uuid.UUID, metadata models.JSONB,
|
|||
// ListAgents returns all agents with optional filtering
|
||||
func (q *AgentQueries) ListAgents(status, osType string) ([]models.Agent, error) {
|
||||
var agents []models.Agent
|
||||
query := `SELECT * FROM agents WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
sd := PG().From("agents")
|
||||
|
||||
if status != "" {
|
||||
query += ` AND status = $` + string(rune(argIdx+'0'))
|
||||
args = append(args, status)
|
||||
argIdx++
|
||||
sd = sd.Where(goqu.Ex{"status": status})
|
||||
}
|
||||
if osType != "" {
|
||||
query += ` AND os_type = $` + string(rune(argIdx+'0'))
|
||||
args = append(args, osType)
|
||||
argIdx++
|
||||
sd = sd.Where(goqu.Ex{"os_type": osType})
|
||||
}
|
||||
|
||||
query += ` ORDER BY last_seen DESC`
|
||||
err := q.db.Select(&agents, query, args...)
|
||||
sql, args, err := sd.Select("*").Order(goqu.C("last_seen").Desc()).ToSQL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = q.db.Select(&agents, sql, args...)
|
||||
return agents, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
package queries
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// DockerQueries handles database operations for Docker images
|
||||
type DockerQueries struct {
|
||||
db *sql.DB
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewDockerQueries(db *sql.DB) *DockerQueries {
|
||||
func NewDockerQueries(db *sqlx.DB) *DockerQueries {
|
||||
return &DockerQueries{db: db}
|
||||
}
|
||||
|
||||
|
|
@ -70,147 +71,52 @@ func (q *DockerQueries) CreateDockerEventsBatch(events []models.StoredDockerImag
|
|||
|
||||
// GetDockerImages retrieves Docker images based on filter criteria
|
||||
func (q *DockerQueries) GetDockerImages(filter *models.DockerFilter) (*models.DockerResult, error) {
|
||||
query := `
|
||||
SELECT id, agent_id, package_type, package_name, current_version, available_version,
|
||||
severity, repository_source, metadata, event_type, created_at
|
||||
FROM docker_images
|
||||
WHERE 1=1
|
||||
`
|
||||
args := []interface{}{}
|
||||
argIndex := 1
|
||||
var images []models.StoredDockerImage
|
||||
|
||||
sd := PG().From("docker_images")
|
||||
|
||||
// Build WHERE clause
|
||||
if filter.AgentID != nil {
|
||||
query += fmt.Sprintf(" AND agent_id = $%d", argIndex)
|
||||
args = append(args, *filter.AgentID)
|
||||
argIndex++
|
||||
sd = sd.Where(goqu.Ex{"agent_id": *filter.AgentID})
|
||||
}
|
||||
|
||||
if filter.ImageName != nil {
|
||||
query += fmt.Sprintf(" AND package_name ILIKE $%d", argIndex)
|
||||
args = append(args, "%"+*filter.ImageName+"%")
|
||||
argIndex++
|
||||
sd = sd.Where(goqu.C("package_name").ILike("%" + *filter.ImageName + "%"))
|
||||
}
|
||||
|
||||
if filter.Registry != nil {
|
||||
query += fmt.Sprintf(" AND repository_source ILIKE $%d", argIndex)
|
||||
args = append(args, "%"+*filter.Registry+"%")
|
||||
argIndex++
|
||||
sd = sd.Where(goqu.C("repository_source").ILike("%" + *filter.Registry + "%"))
|
||||
}
|
||||
|
||||
if filter.Severity != nil {
|
||||
query += fmt.Sprintf(" AND severity = $%d", argIndex)
|
||||
args = append(args, *filter.Severity)
|
||||
argIndex++
|
||||
sd = sd.Where(goqu.Ex{"severity": *filter.Severity})
|
||||
}
|
||||
|
||||
if filter.HasUpdates != nil {
|
||||
if *filter.HasUpdates {
|
||||
query += " AND current_version != available_version"
|
||||
sd = sd.Where(goqu.C("current_version").Neq(goqu.C("available_version")))
|
||||
} else {
|
||||
query += " AND current_version = available_version"
|
||||
sd = sd.Where(goqu.C("current_version").Eq(goqu.C("available_version")))
|
||||
}
|
||||
}
|
||||
|
||||
// Add ordering and pagination
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
page := uint(1)
|
||||
pageSize := uint(50)
|
||||
if filter.Limit != nil {
|
||||
query += fmt.Sprintf(" LIMIT $%d", argIndex)
|
||||
args = append(args, *filter.Limit)
|
||||
argIndex++
|
||||
pageSize = uint(*filter.Limit)
|
||||
if filter.Offset != nil {
|
||||
page = uint(*filter.Offset / *filter.Limit) + 1
|
||||
}
|
||||
}
|
||||
|
||||
if filter.Offset != nil {
|
||||
query += fmt.Sprintf(" OFFSET $%d", argIndex)
|
||||
args = append(args, *filter.Offset)
|
||||
argIndex++
|
||||
}
|
||||
cols := []string{"id", "agent_id", "package_type", "package_name", "current_version",
|
||||
"available_version", "severity", "repository_source", "metadata", "event_type", "created_at"}
|
||||
|
||||
rows, err := q.db.Query(query, args...)
|
||||
total, err := Paginated(q.db, sd, page, pageSize, goqu.C("created_at").Desc(), cols, &images)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query docker images: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var images []models.StoredDockerImage
|
||||
for rows.Next() {
|
||||
var image models.StoredDockerImage
|
||||
err := rows.Scan(
|
||||
&image.ID,
|
||||
&image.AgentID,
|
||||
&image.PackageType,
|
||||
&image.PackageName,
|
||||
&image.CurrentVersion,
|
||||
&image.AvailableVersion,
|
||||
&image.Severity,
|
||||
&image.RepositorySource,
|
||||
&image.Metadata,
|
||||
&image.EventType,
|
||||
&image.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan docker image: %w", err)
|
||||
}
|
||||
images = append(images, image)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
countQuery := `SELECT COUNT(*) FROM docker_images WHERE 1=1`
|
||||
countArgs := []interface{}{}
|
||||
countIndex := 1
|
||||
|
||||
if filter.AgentID != nil {
|
||||
countQuery += fmt.Sprintf(" AND agent_id = $%d", countIndex)
|
||||
countArgs = append(countArgs, *filter.AgentID)
|
||||
countIndex++
|
||||
}
|
||||
|
||||
if filter.ImageName != nil {
|
||||
countQuery += fmt.Sprintf(" AND package_name ILIKE $%d", countIndex)
|
||||
countArgs = append(countArgs, "%"+*filter.ImageName+"%")
|
||||
countIndex++
|
||||
}
|
||||
|
||||
if filter.Registry != nil {
|
||||
countQuery += fmt.Sprintf(" AND repository_source ILIKE $%d", countIndex)
|
||||
countArgs = append(countArgs, "%"+*filter.Registry+"%")
|
||||
countIndex++
|
||||
}
|
||||
|
||||
if filter.Severity != nil {
|
||||
countQuery += fmt.Sprintf(" AND severity = $%d", countIndex)
|
||||
countArgs = append(countArgs, *filter.Severity)
|
||||
countIndex++
|
||||
}
|
||||
|
||||
if filter.HasUpdates != nil {
|
||||
if *filter.HasUpdates {
|
||||
countQuery += " AND current_version != available_version"
|
||||
} else {
|
||||
countQuery += " AND current_version = available_version"
|
||||
}
|
||||
}
|
||||
|
||||
var total int
|
||||
err = q.db.QueryRow(countQuery, countArgs...).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to count docker images: %w", err)
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
page := 1
|
||||
perPage := 50
|
||||
if filter.Offset != nil && filter.Limit != nil {
|
||||
page = (*filter.Offset / *filter.Limit) + 1
|
||||
perPage = *filter.Limit
|
||||
}
|
||||
|
||||
return &models.DockerResult{
|
||||
Images: images,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
Page: int(page),
|
||||
PerPage: int(pageSize),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
47
server/internal/database/queries/filter.go
Normal file
47
server/internal/database/queries/filter.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package queries
|
||||
|
||||
import (
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
_ "github.com/doug-martin/goqu/v9/dialect/postgres"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goqu.SetDefaultPrepared(true) // always use $1, $2 style placeholders
|
||||
}
|
||||
|
||||
// PG returns a postgres dialect for building goqu queries.
|
||||
func PG() goqu.DialectWrapper {
|
||||
return goqu.Dialect("postgres")
|
||||
}
|
||||
|
||||
// Paginated runs SELECT COUNT(*) and a paginated SELECT from the same base dataset.
|
||||
// base should have From + Where applied. goqu datasets are immutable so the call
|
||||
// does not mutate your builder.
|
||||
// Returns total count and fills dest via sqlx.Select.
|
||||
func Paginated(db sqlx.Queryer, base *goqu.SelectDataset, page, pageSize uint, order exp.OrderedExpression, columns []string, dest interface{}) (int, error) {
|
||||
var total int
|
||||
|
||||
countSQL, countArgs, err := base.Select(goqu.COUNT("*")).ToSQL()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := sqlx.Get(db, &total, countSQL, countArgs...); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
cols := make([]interface{}, len(columns))
|
||||
for i, c := range columns {
|
||||
cols[i] = c
|
||||
}
|
||||
dataSQL, dataArgs, err := base.Select(cols...).Order(order).Limit(pageSize).Offset((page-1)*pageSize).ToSQL()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := sqlx.Select(db, dest, dataSQL, dataArgs...); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
|
@ -6,15 +6,17 @@ import (
|
|||
"log"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// MetricsQueries handles database operations for metrics
|
||||
type MetricsQueries struct {
|
||||
db *sql.DB
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewMetricsQueries(db *sql.DB) *MetricsQueries {
|
||||
func NewMetricsQueries(db *sqlx.DB) *MetricsQueries {
|
||||
return &MetricsQueries{db: db}
|
||||
}
|
||||
|
||||
|
|
@ -70,119 +72,42 @@ func (q *MetricsQueries) CreateMetricsEventsBatch(events []models.StoredMetric)
|
|||
|
||||
// GetMetrics retrieves metrics based on filter criteria
|
||||
func (q *MetricsQueries) GetMetrics(filter *models.MetricFilter) (*models.MetricResult, error) {
|
||||
query := `
|
||||
SELECT id, agent_id, package_type, package_name, current_version, available_version,
|
||||
severity, repository_source, metadata, event_type, created_at
|
||||
FROM metrics
|
||||
WHERE 1=1
|
||||
`
|
||||
args := []interface{}{}
|
||||
argIndex := 1
|
||||
var metrics []models.StoredMetric
|
||||
|
||||
sd := PG().From("metrics")
|
||||
|
||||
// Build WHERE clause
|
||||
if filter.AgentID != nil {
|
||||
query += fmt.Sprintf(" AND agent_id = $%d", argIndex)
|
||||
args = append(args, *filter.AgentID)
|
||||
argIndex++
|
||||
sd = sd.Where(goqu.Ex{"agent_id": *filter.AgentID})
|
||||
}
|
||||
|
||||
if filter.PackageType != nil {
|
||||
query += fmt.Sprintf(" AND package_type = $%d", argIndex)
|
||||
args = append(args, *filter.PackageType)
|
||||
argIndex++
|
||||
sd = sd.Where(goqu.Ex{"package_type": *filter.PackageType})
|
||||
}
|
||||
|
||||
if filter.Severity != nil {
|
||||
query += fmt.Sprintf(" AND severity = $%d", argIndex)
|
||||
args = append(args, *filter.Severity)
|
||||
argIndex++
|
||||
sd = sd.Where(goqu.Ex{"severity": *filter.Severity})
|
||||
}
|
||||
|
||||
// Add ordering and pagination
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
page := uint(1)
|
||||
pageSize := uint(50)
|
||||
if filter.Limit != nil {
|
||||
query += fmt.Sprintf(" LIMIT $%d", argIndex)
|
||||
args = append(args, *filter.Limit)
|
||||
argIndex++
|
||||
pageSize = uint(*filter.Limit)
|
||||
if filter.Offset != nil {
|
||||
page = uint(*filter.Offset / *filter.Limit) + 1
|
||||
}
|
||||
}
|
||||
|
||||
if filter.Offset != nil {
|
||||
query += fmt.Sprintf(" OFFSET $%d", argIndex)
|
||||
args = append(args, *filter.Offset)
|
||||
argIndex++
|
||||
}
|
||||
cols := []string{"id", "agent_id", "package_type", "package_name", "current_version",
|
||||
"available_version", "severity", "repository_source", "metadata", "event_type", "created_at"}
|
||||
|
||||
rows, err := q.db.Query(query, args...)
|
||||
total, err := Paginated(q.db, sd, page, pageSize, goqu.C("created_at").Desc(), cols, &metrics)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query metrics: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var metrics []models.StoredMetric
|
||||
for rows.Next() {
|
||||
var metric models.StoredMetric
|
||||
err := rows.Scan(
|
||||
&metric.ID,
|
||||
&metric.AgentID,
|
||||
&metric.PackageType,
|
||||
&metric.PackageName,
|
||||
&metric.CurrentVersion,
|
||||
&metric.AvailableVersion,
|
||||
&metric.Severity,
|
||||
&metric.RepositorySource,
|
||||
&metric.Metadata,
|
||||
&metric.EventType,
|
||||
&metric.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan metric: %w", err)
|
||||
}
|
||||
metrics = append(metrics, metric)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
countQuery := `SELECT COUNT(*) FROM metrics WHERE 1=1`
|
||||
countArgs := []interface{}{}
|
||||
countIndex := 1
|
||||
|
||||
if filter.AgentID != nil {
|
||||
countQuery += fmt.Sprintf(" AND agent_id = $%d", countIndex)
|
||||
countArgs = append(countArgs, *filter.AgentID)
|
||||
countIndex++
|
||||
}
|
||||
|
||||
if filter.PackageType != nil {
|
||||
countQuery += fmt.Sprintf(" AND package_type = $%d", countIndex)
|
||||
countArgs = append(countArgs, *filter.PackageType)
|
||||
countIndex++
|
||||
}
|
||||
|
||||
if filter.Severity != nil {
|
||||
countQuery += fmt.Sprintf(" AND severity = $%d", countIndex)
|
||||
countArgs = append(countArgs, *filter.Severity)
|
||||
countIndex++
|
||||
}
|
||||
|
||||
var total int
|
||||
err = q.db.QueryRow(countQuery, countArgs...).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to count metrics: %w", err)
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
page := 1
|
||||
perPage := 50
|
||||
if filter.Offset != nil && filter.Limit != nil {
|
||||
page = (*filter.Offset / *filter.Limit) + 1
|
||||
perPage = *filter.Limit
|
||||
}
|
||||
|
||||
return &models.MetricResult{
|
||||
Metrics: metrics,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
Page: int(page),
|
||||
PerPage: int(pageSize),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package queries
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
|
@ -8,6 +9,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
|
@ -119,10 +121,16 @@ func (q *UpdateQueries) GetUpdateByID(id uuid.UUID) (*models.UpdateState, error)
|
|||
|
||||
// GetExpectedSHA256 retrieves the expected SHA256 hash for an update by ID
|
||||
func (q *UpdateQueries) GetExpectedSHA256(id uuid.UUID) (string, error) {
|
||||
var sha string
|
||||
var sha sql.NullString
|
||||
query := `SELECT expected_sha256 FROM current_package_state WHERE id = $1`
|
||||
err := q.db.Get(&sha, query, id)
|
||||
return sha, err
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !sha.Valid {
|
||||
return "", nil
|
||||
}
|
||||
return sha.String, nil
|
||||
}
|
||||
|
||||
// StoreExpectedSHA256 stores the expected SHA256 hash for an update
|
||||
|
|
@ -459,75 +467,40 @@ func (q *UpdateQueries) updateCurrentStateInTx(tx *sqlx.Tx, event *models.Update
|
|||
// ListUpdatesFromState returns paginated updates from current state with filtering
|
||||
func (q *UpdateQueries) ListUpdatesFromState(filters *models.UpdateFilters) ([]models.UpdateState, int, error) {
|
||||
var updates []models.UpdateState
|
||||
var count int
|
||||
|
||||
// Build base query
|
||||
baseQuery := `
|
||||
SELECT
|
||||
id, agent_id, package_type, package_name, current_version,
|
||||
available_version, severity, repository_source, metadata,
|
||||
last_discovered_at, last_updated_at, status
|
||||
FROM current_package_state
|
||||
WHERE 1=1
|
||||
`
|
||||
countQuery := `SELECT COUNT(*) FROM current_package_state WHERE 1=1`
|
||||
sd := PG().From("current_package_state")
|
||||
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
// Add filters
|
||||
if filters.AgentID != uuid.Nil {
|
||||
baseQuery += fmt.Sprintf(" AND agent_id = $%d", argIdx)
|
||||
countQuery += fmt.Sprintf(" AND agent_id = $%d", argIdx)
|
||||
args = append(args, filters.AgentID)
|
||||
argIdx++
|
||||
sd = sd.Where(goqu.Ex{"agent_id": filters.AgentID})
|
||||
}
|
||||
|
||||
if filters.PackageType != "" {
|
||||
baseQuery += fmt.Sprintf(" AND package_type = $%d", argIdx)
|
||||
countQuery += fmt.Sprintf(" AND package_type = $%d", argIdx)
|
||||
args = append(args, filters.PackageType)
|
||||
argIdx++
|
||||
sd = sd.Where(goqu.Ex{"package_type": filters.PackageType})
|
||||
}
|
||||
|
||||
if filters.Severity != "" {
|
||||
baseQuery += fmt.Sprintf(" AND severity = $%d", argIdx)
|
||||
countQuery += fmt.Sprintf(" AND severity = $%d", argIdx)
|
||||
args = append(args, filters.Severity)
|
||||
argIdx++
|
||||
sd = sd.Where(goqu.Ex{"severity": filters.Severity})
|
||||
}
|
||||
|
||||
if filters.Status != "" {
|
||||
// Explicit status filter provided - use it
|
||||
baseQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
countQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
args = append(args, filters.Status)
|
||||
argIdx++
|
||||
statuses := strings.Split(filters.Status, ",")
|
||||
if len(statuses) == 1 {
|
||||
sd = sd.Where(goqu.Ex{"status": statuses[0]})
|
||||
} else {
|
||||
sd = sd.Where(goqu.Ex{"status": statuses})
|
||||
}
|
||||
} else {
|
||||
// No status filter - exclude 'updated' and 'ignored' packages by default
|
||||
// These should only be visible in history or when explicitly filtered
|
||||
baseQuery += " AND status NOT IN ('updated', 'ignored')"
|
||||
countQuery += " AND status NOT IN ('updated', 'ignored')"
|
||||
sd = sd.Where(goqu.C("status").NotIn("updated", "ignored"))
|
||||
}
|
||||
|
||||
// Get total count
|
||||
err := q.db.Get(&count, countQuery, args...)
|
||||
total, err := Paginated(q.db, sd, uint(filters.Page), uint(filters.PageSize),
|
||||
goqu.C("last_discovered_at").Desc(),
|
||||
[]string{"id", "agent_id", "package_type", "package_name", "current_version",
|
||||
"available_version", "severity", "repository_source", "metadata",
|
||||
"last_discovered_at", "last_updated_at", "status"},
|
||||
&updates)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get updates count: %w", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Add ordering and pagination
|
||||
baseQuery += " ORDER BY last_discovered_at DESC"
|
||||
baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
||||
args = append(args, filters.PageSize, (filters.Page-1)*filters.PageSize)
|
||||
|
||||
// Execute query
|
||||
err = q.db.Select(&updates, baseQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list updates: %w", err)
|
||||
}
|
||||
|
||||
return updates, count, nil
|
||||
return updates, total, nil
|
||||
}
|
||||
|
||||
// GetPackageHistory returns version history for a specific package
|
||||
|
|
|
|||
|
|
@ -71,10 +71,11 @@ var (
|
|||
|
||||
// CommandsResponse is returned when an agent checks in for commands
|
||||
type CommandsResponse struct {
|
||||
Commands []CommandItem `json:"commands"`
|
||||
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
|
||||
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // Result IDs server has recorded
|
||||
ReceiptConfirmedIDs []string `json:"receipt_confirmed_ids,omitempty"` // Command IDs server transitioned sent->received this turn
|
||||
Commands []CommandItem `json:"commands"`
|
||||
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
|
||||
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // Result IDs server has recorded
|
||||
ReceiptConfirmedIDs []string `json:"receipt_confirmed_ids,omitempty"` // Command IDs server transitioned sent->received this turn
|
||||
ConfirmedCommandIDs []string `json:"confirmed_command_ids,omitempty"` // Command IDs server has confirmed completed via ReportLog
|
||||
}
|
||||
|
||||
// RapidPollingConfig contains rapid polling configuration for the agent
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ type UpdateState struct {
|
|||
LastDiscoveredAt time.Time `json:"last_discovered_at" db:"last_discovered_at"`
|
||||
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
|
||||
Status string `json:"status" db:"status"`
|
||||
ExpectedSHA256 string `json:"expected_sha256" db:"expected_sha256"` // Layer 1: Hash Registry
|
||||
ExpectedSHA256 *string `json:"expected_sha256" db:"expected_sha256"` // Layer 1: Hash Registry
|
||||
}
|
||||
|
||||
// UpdateHistory represents the version history of a package
|
||||
|
|
|
|||
|
|
@ -364,6 +364,13 @@ func (s *Scheduler) refillRateLimiter() {
|
|||
}
|
||||
}
|
||||
|
||||
// RemoveSubsystemJob removes a scheduled job for a given agent + subsystem from
|
||||
// the priority queue. Used by DisableSubsystem so disabling a subsystem stops
|
||||
// further commands immediately, without waiting for a scheduler reload.
|
||||
func (s *Scheduler) RemoveSubsystemJob(agentID uuid.UUID, subsystem string) bool {
|
||||
return s.queue.Remove(agentID, subsystem)
|
||||
}
|
||||
|
||||
// GetStats returns current scheduler statistics (thread-safe)
|
||||
func (s *Scheduler) GetStats() Stats {
|
||||
s.mu.RLock()
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import (
|
|||
|
||||
// Build-time injected version information (SERVER AUTHORITY)
|
||||
var (
|
||||
AgentVersion = "0.2.0.5"
|
||||
ConfigVersion = "0.2.0.5"
|
||||
AgentVersion = "0.2.0.6"
|
||||
ConfigVersion = "0.2.0.6"
|
||||
MinAgentVersion = "0.1.22"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ import {
|
|||
Check,
|
||||
ExternalLink,
|
||||
X,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { formatRelativeTime, formatBytes } from '@/lib/utils';
|
||||
import { updateApi } from '@/lib/api';
|
||||
|
|
@ -35,10 +40,26 @@ interface LogResponse {
|
|||
result: string;
|
||||
}
|
||||
|
||||
type StatusTab = 'pending' | 'approved' | 'installing' | 'installed' | 'ignored';
|
||||
const TAB_GROUPS: { key: string; label: string; statuses: string }[] = [
|
||||
{ key: 'needs-review', label: 'Needs Review', statuses: 'pending' },
|
||||
{ key: 'in-progress', label: 'In Progress', statuses: 'approved,checking_dependencies,pending_dependencies,installing' },
|
||||
{ key: 'installed', label: 'Installed', statuses: 'updated' },
|
||||
{ key: 'failed-ignored', label: 'Failed / Ignored', statuses: 'failed,ignored' },
|
||||
];
|
||||
|
||||
const STATUS_META: Record<string, { label: string; icon: React.ReactNode; class: string }> = {
|
||||
pending: { label: 'Pending', icon: <Clock className="h-3 w-3" />, class: 'text-gray-600 bg-gray-100' },
|
||||
approved: { label: 'Approved', icon: <Check className="h-3 w-3" />, class: 'text-blue-600 bg-blue-100' },
|
||||
checking_dependencies: { label: 'Checking Deps', icon: <Loader2 className="h-3 w-3 animate-spin" />, class: 'text-yellow-600 bg-yellow-100' },
|
||||
pending_dependencies: { label: 'Deps Pending', icon: <AlertTriangle className="h-3 w-3" />, class: 'text-orange-600 bg-orange-100' },
|
||||
installing: { label: 'Installing', icon: <RefreshCw className="h-3 w-3 animate-spin" />, class: 'text-purple-600 bg-purple-100' },
|
||||
updated: { label: 'Installed', icon: <CheckCircle className="h-3 w-3" />, class: 'text-green-600 bg-green-100' },
|
||||
failed: { label: 'Failed', icon: <XCircle className="h-3 w-3" />, class: 'text-red-600 bg-red-100' },
|
||||
ignored: { label: 'Ignored', icon: <X className="h-3 w-3" />, class: 'text-gray-500 bg-gray-50' },
|
||||
};
|
||||
|
||||
export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpdatesEnhancedProps) {
|
||||
const [activeStatus, setActiveStatus] = useState<StatusTab>('pending');
|
||||
const [activeTab, setActiveTab] = useState(TAB_GROUPS[0].key);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(50);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
|
@ -48,18 +69,21 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
const [expandedUpdates, setExpandedUpdates] = useState<Set<string>>(new Set());
|
||||
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
|
||||
const [confirmDepsUpdateId, setConfirmDepsUpdateId] = useState<string | null>(null);
|
||||
const [confirmDepsData, setConfirmDepsData] = useState<string[] | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const activeGroup = TAB_GROUPS.find(g => g.key === activeTab) || TAB_GROUPS[0];
|
||||
|
||||
// Fetch updates with status filter
|
||||
const { data: updateData, isLoading, error, refetch } = useQuery<AgentUpdateResponse>({
|
||||
queryKey: ['agent-updates', agentId, activeStatus, currentPage, pageSize, searchTerm, selectedSeverity],
|
||||
queryKey: ['agent-updates', agentId, activeGroup.statuses, currentPage, pageSize, searchTerm, selectedSeverity],
|
||||
queryFn: async () => {
|
||||
const params = {
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
agent_id: agentId,
|
||||
status: activeStatus,
|
||||
status: activeGroup.statuses,
|
||||
...(searchTerm && { search: searchTerm }),
|
||||
...(selectedSeverity !== 'all' && { severity: selectedSeverity }),
|
||||
};
|
||||
|
|
@ -133,6 +157,23 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
},
|
||||
});
|
||||
|
||||
const confirmDepsMutation = useMutation({
|
||||
mutationFn: async (updateId: string) => {
|
||||
const response = await updateApi.confirmDependencies(updateId);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Dependencies confirmed, installing');
|
||||
setConfirmDepsUpdateId(null);
|
||||
setConfirmDepsData(null);
|
||||
refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-updates'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to confirm dependencies: ${error.message || 'Unknown error'}`);
|
||||
},
|
||||
});
|
||||
|
||||
const getLogsMutation = useMutation({
|
||||
mutationFn: async (commandId: string) => {
|
||||
setIsLoadingLogs(true);
|
||||
|
|
@ -214,6 +255,25 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeps = async (update: UpdatePackage) => {
|
||||
const deps: string[] = Array.isArray(update.metadata?.dependencies)
|
||||
? update.metadata.dependencies
|
||||
: [];
|
||||
setConfirmDepsUpdateId(update.id);
|
||||
setConfirmDepsData(deps);
|
||||
};
|
||||
|
||||
const handleExecuteConfirmDeps = async () => {
|
||||
if (confirmDepsUpdateId) {
|
||||
confirmDepsMutation.mutate(confirmDepsUpdateId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelConfirmDeps = () => {
|
||||
setConfirmDepsUpdateId(null);
|
||||
setConfirmDepsData(null);
|
||||
};
|
||||
|
||||
const toggleExpanded = (updateId: string) => {
|
||||
const newExpanded = new Set(expandedUpdates);
|
||||
if (newExpanded.has(updateId)) {
|
||||
|
|
@ -251,19 +311,13 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
<div className="space-y-4">
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center space-x-1 border-b border-gray-200 text-sm">
|
||||
{[
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'approved', label: 'Approved' },
|
||||
{ key: 'installing', label: 'Installing' },
|
||||
{ key: 'installed', label: 'Installed' },
|
||||
{ key: 'ignored', label: 'Ignored' },
|
||||
].map((tab) => (
|
||||
{TAB_GROUPS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveStatus(tab.key as StatusTab)}
|
||||
onClick={() => { setActiveTab(tab.key); setCurrentPage(1); setSelectedUpdates([]); }}
|
||||
className={cn(
|
||||
'px-4 py-2 border-b-2 transition-colors',
|
||||
activeStatus === tab.key
|
||||
activeTab === tab.key
|
||||
? 'border-gray-900 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
)}
|
||||
|
|
@ -295,7 +349,7 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
})}
|
||||
</div>
|
||||
|
||||
{selectedUpdates.length > 0 && activeStatus === 'pending' && (
|
||||
{selectedUpdates.length > 0 && activeTab === 'needs-review' && (
|
||||
<button
|
||||
onClick={handleBulkApprove}
|
||||
disabled={bulkApproveMutation.isPending}
|
||||
|
|
@ -314,9 +368,6 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Header-only view for Update packages - no agent update button here */}
|
||||
{/* Users should use Agent Health page for agent updates */}
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
|
|
@ -350,7 +401,7 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
{/* Updates List */}
|
||||
{updates.length === 0 ? (
|
||||
<div className="text-center py-12 text-sm text-gray-500">
|
||||
{activeStatus === 'installed' ? (
|
||||
{activeTab === 'installed' ? (
|
||||
<div>
|
||||
<p className="mb-2">Installed updates are shown in History</p>
|
||||
{onNavigateToHistory && (
|
||||
|
|
@ -363,18 +414,22 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
)}
|
||||
</div>
|
||||
) : (
|
||||
`No ${activeStatus} updates`
|
||||
`No ${activeGroup.label.toLowerCase()} updates`
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-px">
|
||||
{updates.map((update) => {
|
||||
const isExpanded = expandedUpdates.has(update.id);
|
||||
const statusMeta = STATUS_META[update.status] || { label: update.status, icon: null, class: 'text-gray-600 bg-gray-100' };
|
||||
const deps: string[] = Array.isArray(update.metadata?.dependencies)
|
||||
? update.metadata.dependencies
|
||||
: [];
|
||||
return (
|
||||
<div key={update.id} className="bg-white border-b border-gray-100 last:border-0">
|
||||
<div className="flex items-center p-2 gap-3">
|
||||
{/* Checkbox for pending */}
|
||||
{activeStatus === 'pending' && (
|
||||
{/* Checkbox for bulk actions */}
|
||||
{activeTab === 'needs-review' && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUpdates.includes(update.id)}
|
||||
|
|
@ -390,6 +445,15 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
onClick={() => toggleExpanded(update.id)}
|
||||
>
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
{/* Status badge */}
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap',
|
||||
statusMeta.class
|
||||
)}>
|
||||
{statusMeta.icon}
|
||||
{statusMeta.label}
|
||||
</span>
|
||||
|
||||
<span className={cn('px-2 py-0.5 rounded text-xs font-medium', getSeverityColor(update.severity))}>
|
||||
{update.severity.toUpperCase()}
|
||||
</span>
|
||||
|
|
@ -398,7 +462,7 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||
{activeStatus === 'pending' && (
|
||||
{update.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleApprove(update.id); }}
|
||||
|
|
@ -414,7 +478,7 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
</button>
|
||||
</>
|
||||
)}
|
||||
{activeStatus === 'approved' && (
|
||||
{update.status === 'approved' && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleInstall(update.id); }}
|
||||
className="text-xs text-gray-600 hover:text-gray-900 px-2 py-1"
|
||||
|
|
@ -422,10 +486,21 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
Install
|
||||
</button>
|
||||
)}
|
||||
{activeStatus === 'ignored' && (
|
||||
<span className="text-xs text-gray-500 px-2 py-1">
|
||||
Rejected
|
||||
</span>
|
||||
{update.status === 'pending_dependencies' && deps.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleConfirmDeps(update); }}
|
||||
className="text-xs text-orange-600 hover:text-orange-800 px-2 py-1 border border-orange-200 rounded"
|
||||
>
|
||||
Review Dependencies
|
||||
</button>
|
||||
)}
|
||||
{update.status === 'failed' && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleInstall(update.id); }}
|
||||
className="text-xs text-gray-600 hover:text-gray-900 px-2 py-1"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{update.recent_command_id && (
|
||||
<button
|
||||
|
|
@ -477,6 +552,28 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
<div><span className="font-medium">Installed:</span> {formatRelativeTime(update.installed_at)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dependency inline panel for pending_dependencies */}
|
||||
{update.status === 'pending_dependencies' && deps.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-200">
|
||||
<p className="font-medium text-gray-700 mb-1">
|
||||
Dependencies ({deps.length})
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 space-y-0.5 mb-2">
|
||||
{deps.map((dep, i) => (
|
||||
<li key={i}>{dep}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleConfirmDeps(update); }}
|
||||
className="text-xs text-orange-600 hover:text-orange-800 px-2 py-1 border border-orange-200 rounded"
|
||||
>
|
||||
Confirm & Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -512,6 +609,57 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Dependency Confirmation Modal */}
|
||||
{confirmDepsUpdateId && confirmDepsData !== null && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-lg w-full max-h-[80vh] overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 flex items-center space-x-2">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
<span>Confirm Dependencies</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCancelConfirmDeps}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
{confirmDepsData.length === 0 ? (
|
||||
<p className="text-sm text-gray-600">No additional dependencies detected. Proceed with installation.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-600">
|
||||
The following additional packages will be installed:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-gray-700 space-y-1">
|
||||
{confirmDepsData.map((dep, i) => (
|
||||
<li key={i}>{dep}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-200 flex items-center justify-end space-x-2">
|
||||
<button
|
||||
onClick={handleCancelConfirmDeps}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecuteConfirmDeps}
|
||||
disabled={confirmDepsMutation.isPending}
|
||||
className="px-3 py-1.5 text-sm bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50"
|
||||
>
|
||||
{confirmDepsMutation.isPending ? 'Confirming...' : 'Confirm & Install'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs Modal */}
|
||||
{showLogsModal && logsData && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
|
|
|
|||
|
|
@ -325,6 +325,11 @@ export const updateApi = {
|
|||
await api.post(`/updates/${id}/install`);
|
||||
},
|
||||
|
||||
// Confirm dependencies and proceed with install
|
||||
confirmDependencies: async (id: string): Promise<void> => {
|
||||
await api.post(`/updates/${id}/confirm-dependencies`);
|
||||
},
|
||||
|
||||
// Get update logs
|
||||
getUpdateLogs: async (id: string, limit?: number): Promise<{ logs: any[]; count: number }> => {
|
||||
const response = await api.get(`/updates/${id}/logs`, {
|
||||
|
|
|
|||
|
|
@ -1635,6 +1635,11 @@ const Updates: React.FC = () => {
|
|||
<span className="text-xs font-medium text-gray-900 bg-gray-100 px-2 py-1 rounded">
|
||||
{update.package_type.toUpperCase()}
|
||||
</span>
|
||||
{update.dependencies && update.dependencies.length > 0 && (
|
||||
<span title={`${update.dependencies.length} dependencies`} className="ml-1 inline-flex items-center text-blue-600">
|
||||
<Search className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="text-sm">
|
||||
|
|
@ -1663,6 +1668,11 @@ const Updates: React.FC = () => {
|
|||
update.status
|
||||
)}
|
||||
</span>
|
||||
{update.dependencies && update.dependencies.length > 0 && (
|
||||
<span title={`${update.dependencies.length} dependencies`} className="ml-1 inline-flex items-center text-blue-600">
|
||||
<Search className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export interface UpdatePackage {
|
|||
installed_at: string | null;
|
||||
created_at: string;
|
||||
recent_command_id?: string;
|
||||
dependencies?: string[]; // List of dependency packages found during dry run
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue