v0.2.0.3: wire install flow, restore lost handlers, dedupe agent packages
Server / agent install pipeline: - Restore dry_run_update, confirm_dependencies, install_updates, enable_heartbeat, disable_heartbeat handlers on the agent side (lost in the TD-001 god-function refactor at 9da5134e); wire them through handlers/dispatch.go so the cross-platform agent loop dispatches them alongside scans and update_agent. - Wire JWT renewal into the polling loop on 401 (RenewToken existed in the client but was dead code in loop.go). - Self-update path now shells through sudo for cp/chmod/systemctl restart, matching the redflag-agent user's hardened systemd unit. Server build orchestrator: - BuildAndSignAgent now reuses the existing signed package row when the on-disk binary's checksum matches the stored one. Previously the server re-signed and inserted 4 fresh rows on every boot, leaving dozens of duplicate agent_update_packages entries. - CreateUpdatePackage is now ON CONFLICT (version, platform, architecture) DO UPDATE so a fresh build of the same version replaces in place. - New migration 037: dedupes existing rows (keep newest per tuple) and enforces UNIQUE (version, platform, architecture). - Drop dead verification.go endpoint stub — architecturally broken in a pull-only polling model. Dashboard: - AgentUpdatesModal filters packages to the selected agents' os_type and os_architecture, dedupes by (version, platform, arch), and renders platform/arch together so 32/64-bit differentiate visually. Drops the platform dropdown (now agent-driven). Install script template: - Fix server_public_key + initial_binary.sig ownership so the agent user can overwrite them; convert hex key to raw 32 bytes inline. - Add sudoers entries for the agent's self-update cp/chmod/systemctl path. Downloads handler resolves ?version=latest to AgentVersion so install scripts pull a signed package instead of a 404. Version bumped to 0.2.0.3 across versions.go, docker-compose, Makefile, downloads.go, security min_agent_version.
This commit is contained in:
parent
64245479af
commit
78d9131a9f
23 changed files with 592 additions and 501 deletions
2
Makefile
2
Makefile
|
|
@ -24,7 +24,7 @@ build-server: ## Build server binary
|
|||
cd server && go mod tidy && go build -o bin/server cmd/server/main.go
|
||||
|
||||
build-agent: ## Build agent binary with version injection
|
||||
cd agent && go mod tidy && go build -ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=0.2.0.2 -X github.com/Fimeg/RedFlag/agent/internal/version.ConfigVersion=0.2.0.2 -X github.com/Fimeg/RedFlag/agent/internal/version.BuildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)" -o bin/agent ./cmd/agent/
|
||||
cd agent && go mod tidy && go build -ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=0.2.0.3 -X github.com/Fimeg/RedFlag/agent/internal/version.ConfigVersion=0.2.0.3 -X github.com/Fimeg/RedFlag/agent/internal/version.BuildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)" -o bin/agent ./cmd/agent/
|
||||
|
||||
build-agent-simple: ## Build agent binary with simple script
|
||||
@./scripts/build-secure-agent.sh
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
|
|
@ -194,6 +195,20 @@ func runPollingLoop(ctx *loopContext) error {
|
|||
// Get commands from server
|
||||
response, err := ctx.apiClient.GetCommands(ctx.cfg.AgentID, metrics)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "401 Unauthorized") && ctx.cfg.RefreshToken != "" {
|
||||
log.Printf("[INFO] [agent] [auth] jwt_expired attempting_renewal agent_id=%s", ctx.cfg.AgentID)
|
||||
if renewErr := ctx.apiClient.RenewToken(ctx.cfg.AgentID, ctx.cfg.RefreshToken, version.Version); renewErr != nil {
|
||||
log.Printf("[ERROR] [agent] [auth] token_renewal_failed error=%v", renewErr)
|
||||
} else {
|
||||
ctx.cfg.Token = ctx.apiClient.GetToken()
|
||||
if saveErr := ctx.cfg.Save(constants.GetAgentConfigPath()); saveErr != nil {
|
||||
log.Printf("[WARNING] [agent] [auth] token_persist_failed error=%v", saveErr)
|
||||
}
|
||||
log.Printf("[INFO] [agent] [auth] token_renewed_successfully")
|
||||
consecutiveFailures = 0
|
||||
continue
|
||||
}
|
||||
}
|
||||
consecutiveFailures++
|
||||
backoffDelay := calculateBackoff(consecutiveFailures)
|
||||
log.Printf("[WARNING] Server unavailable (attempt %d), retrying in %s: %v", consecutiveFailures, backoffDelay, err)
|
||||
|
|
|
|||
|
|
@ -249,82 +249,38 @@ func CleanupPostUpdateBackup() {
|
|||
}
|
||||
|
||||
func createBackup(src, dst string) error {
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open source: %w", err)
|
||||
cmd := exec.Command("sudo", "cp", src, dst)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to create backup: %w (output: %s)", err, string(out))
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create backup: %w", err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := dstFile.ReadFrom(srcFile); err != nil {
|
||||
return fmt.Errorf("failed to copy backup: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(dst, 0755); err != nil {
|
||||
return fmt.Errorf("failed to set backup permissions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func restoreFromBackup(backup, target string) error {
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
if err := os.Remove(target); err != nil {
|
||||
return fmt.Errorf("failed to remove current binary: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return createBackup(backup, target)
|
||||
}
|
||||
|
||||
func installNewBinary(src, dst string) error {
|
||||
tempDst := dst + ".new"
|
||||
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open source binary: %w", err)
|
||||
cmd := exec.Command("sudo", "cp", src, dst)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to copy binary: %w (output: %s)", err, string(out))
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.Create(tempDst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp binary: %w", err)
|
||||
cmd = exec.Command("sudo", "chmod", "755", dst)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to set binary permissions: %w (output: %s)", err, string(out))
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := dstFile.ReadFrom(srcFile); err != nil {
|
||||
return fmt.Errorf("failed to copy binary: %w", err)
|
||||
}
|
||||
dstFile.Close()
|
||||
|
||||
if err := os.Chmod(tempDst, 0755); err != nil {
|
||||
return fmt.Errorf("failed to set binary permissions: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tempDst, dst); err != nil {
|
||||
os.Remove(tempDst)
|
||||
return fmt.Errorf("failed to atomically replace binary: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func restartAgentService() error {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
cmd = exec.Command("systemctl", "restart", "redflag-agent")
|
||||
if err := cmd.Run(); err == nil {
|
||||
log.Printf("[INFO] [agent] [service] systemd_service_restarted")
|
||||
return nil
|
||||
cmd := exec.Command("sudo", "systemctl", "restart", "redflag-agent")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to restart service: %w (output: %s)", err, string(out))
|
||||
}
|
||||
cmd = exec.Command("service", "redflag-agent", "restart")
|
||||
log.Printf("[INFO] [agent] [service] systemd_service_restarted")
|
||||
return nil
|
||||
|
||||
case "windows":
|
||||
stopCmd := exec.Command("sc", "stop", "RedFlagAgent")
|
||||
|
|
@ -334,19 +290,16 @@ func restartAgentService() error {
|
|||
log.Printf("[INFO] [agent] [service] service_stop_requested")
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
cmd = exec.Command("sc", "start", "RedFlagAgent")
|
||||
cmd := exec.Command("sc", "start", "RedFlagAgent")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to start service: %w", err)
|
||||
}
|
||||
log.Printf("[INFO] [agent] [service] service_start_requested")
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported OS for service restart: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to restart service: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [service] agent_service_restarted")
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Crypto helpers ---
|
||||
|
|
|
|||
|
|
@ -40,6 +40,16 @@ func DispatchCrossPlatformCommand(
|
|||
err = HandleScanWinget(apiClient, cfg, ackTracker, orch, cmd.ID)
|
||||
case "scan_updates":
|
||||
err = HandleScanUpdates(apiClient, cfg, ackTracker, orch, cmd.ID)
|
||||
case "install_updates":
|
||||
err = HandleInstallUpdates(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
|
||||
case "dry_run_update":
|
||||
err = HandleDryRunUpdate(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
|
||||
case "confirm_dependencies":
|
||||
err = HandleConfirmDependencies(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
|
||||
case "enable_heartbeat":
|
||||
err = HandleEnableHeartbeat(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
|
||||
case "disable_heartbeat":
|
||||
err = HandleDisableHeartbeat(apiClient, cfg, ackTracker, cmd.ID)
|
||||
case "update_agent":
|
||||
err = HandleUpdateAgent(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
|
||||
default:
|
||||
|
|
|
|||
206
agent/internal/handlers/dry_run.go
Normal file
206
agent/internal/handlers/dry_run.go
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/installer"
|
||||
)
|
||||
|
||||
// HandleDryRunUpdate runs a package-manager dry-run for the named update so
|
||||
// dependency resolution can be surfaced to the dashboard operator before the
|
||||
// real install fires. Reports dependencies via the dedicated endpoint and the
|
||||
// command result via the standard log path; the install itself does not happen
|
||||
// here — the operator's "Confirm" produces a separate confirm_dependencies
|
||||
// command (see HandleConfirmDependencies).
|
||||
func HandleDryRunUpdate(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
|
||||
packageType, _ := params["package_type"].(string)
|
||||
packageName, _ := params["package_name"].(string)
|
||||
updateID, _ := params["update_id"].(string)
|
||||
|
||||
if packageType == "" || packageName == "" {
|
||||
return fmt.Errorf("package_type and package_name parameters are required")
|
||||
}
|
||||
|
||||
inst, err := installer.InstallerFactory(packageType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", packageType, err)
|
||||
}
|
||||
|
||||
if !inst.IsAvailable() {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", packageType)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [installer] dry_run_start package=%s type=%s", packageName, packageType)
|
||||
|
||||
result, err := inst.DryRun(packageName)
|
||||
if err != nil {
|
||||
stdout, stderr, exitCode, duration := "", err.Error(), 1, 0
|
||||
if result != nil {
|
||||
stdout, stderr, exitCode, duration = result.Stdout, result.Stderr, result.ExitCode, result.DurationSeconds
|
||||
}
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "dry_run",
|
||||
Result: "failed",
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: duration,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Package Install",
|
||||
"subsystem": packageType,
|
||||
},
|
||||
}
|
||||
ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
|
||||
return fmt.Errorf("dry run failed: %w", err)
|
||||
}
|
||||
|
||||
// Mirror the result into the client's wire type and post dependencies.
|
||||
depReport := client.DependencyReport{
|
||||
PackageName: packageName,
|
||||
PackageType: packageType,
|
||||
Dependencies: result.Dependencies,
|
||||
UpdateID: updateID,
|
||||
DryRunResult: &client.InstallResult{
|
||||
Success: result.Success,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: result.ExitCode,
|
||||
DurationSeconds: result.DurationSeconds,
|
||||
Action: result.Action,
|
||||
PackagesInstalled: result.PackagesInstalled,
|
||||
ContainersUpdated: result.ContainersUpdated,
|
||||
Dependencies: result.Dependencies,
|
||||
IsDryRun: true,
|
||||
},
|
||||
}
|
||||
|
||||
if err := apiClient.ReportDependencies(cfg.AgentID, depReport); err != nil {
|
||||
log.Printf("[ERROR] [agent] [installer] report_dependencies_failed error=%v", err)
|
||||
return fmt.Errorf("failed to report dependencies: %w", err)
|
||||
}
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "dry_run",
|
||||
Result: "success",
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: result.ExitCode,
|
||||
DurationSeconds: result.DurationSeconds,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Package Install",
|
||||
"subsystem": packageType,
|
||||
},
|
||||
}
|
||||
if len(result.Dependencies) > 0 {
|
||||
logReport.Stdout += fmt.Sprintf("\nDependencies found: %v", result.Dependencies)
|
||||
}
|
||||
|
||||
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); reportErr != nil {
|
||||
log.Printf("[WARNING] [agent] [installer] report_dry_run_failed error=%v", reportErr)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [installer] dry_run_complete package=%s deps=%d duration=%ds",
|
||||
packageName, len(result.Dependencies), result.DurationSeconds)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleConfirmDependencies installs a package together with the dependencies
|
||||
// the operator confirmed during the dry-run review. Empty dependency list is
|
||||
// allowed — that path resolves to a simple UpdatePackage on the named target.
|
||||
func HandleConfirmDependencies(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
|
||||
packageType, _ := params["package_type"].(string)
|
||||
packageName, _ := params["package_name"].(string)
|
||||
|
||||
if packageType == "" || packageName == "" {
|
||||
return fmt.Errorf("package_type and package_name parameters are required")
|
||||
}
|
||||
|
||||
var dependencies []string
|
||||
if deps, ok := params["dependencies"].([]interface{}); ok {
|
||||
for _, dep := range deps {
|
||||
if s, ok := dep.(string); ok && s != "" {
|
||||
dependencies = append(dependencies, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inst, err := installer.InstallerFactory(packageType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", packageType, err)
|
||||
}
|
||||
|
||||
if !inst.IsAvailable() {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", packageType)
|
||||
}
|
||||
|
||||
var result *installer.InstallResult
|
||||
var action string
|
||||
|
||||
if len(dependencies) > 0 {
|
||||
action = "install_with_dependencies"
|
||||
log.Printf("[INFO] [agent] [installer] install_with_deps package=%s deps=%v", packageName, dependencies)
|
||||
allPackages := append([]string{packageName}, dependencies...)
|
||||
result, err = inst.InstallMultiple(allPackages)
|
||||
} else {
|
||||
action = "update"
|
||||
log.Printf("[INFO] [agent] [installer] update_package package=%s", packageName)
|
||||
result, err = inst.UpdatePackage(packageName)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
stdout, stderr, exitCode, duration := "", err.Error(), 1, 0
|
||||
if result != nil {
|
||||
stdout, stderr, exitCode, duration = result.Stdout, result.Stderr, result.ExitCode, result.DurationSeconds
|
||||
}
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: action,
|
||||
Result: "failed",
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: duration,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Package Install",
|
||||
"subsystem": packageType,
|
||||
},
|
||||
}
|
||||
ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
|
||||
return fmt.Errorf("installation failed: %w", err)
|
||||
}
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: result.Action,
|
||||
Result: "success",
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: result.ExitCode,
|
||||
DurationSeconds: result.DurationSeconds,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Package Install",
|
||||
"subsystem": packageType,
|
||||
},
|
||||
}
|
||||
if len(result.PackagesInstalled) > 0 {
|
||||
logReport.Stdout += fmt.Sprintf("\nPackages installed: %v", result.PackagesInstalled)
|
||||
}
|
||||
if len(dependencies) > 0 {
|
||||
logReport.Stdout += fmt.Sprintf("\nDependencies included: %v", dependencies)
|
||||
}
|
||||
|
||||
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); reportErr != nil {
|
||||
log.Printf("[WARNING] [agent] [installer] report_install_failed error=%v", reportErr)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [installer] install_complete action=%s package=%s duration=%ds",
|
||||
action, packageName, result.DurationSeconds)
|
||||
return nil
|
||||
}
|
||||
72
agent/internal/handlers/heartbeat.go
Normal file
72
agent/internal/handlers/heartbeat.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
)
|
||||
|
||||
// HandleEnableHeartbeat flips the agent into rapid-polling mode for the
|
||||
// duration the server requested. The polling loop reads RapidPollingEnabled
|
||||
// and RapidPollingUntil on every iteration (see agent/loop.go), so persisting
|
||||
// the change to config is enough — the next loop tick picks it up.
|
||||
func HandleEnableHeartbeat(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
|
||||
durationMinutes := 60
|
||||
if d, ok := params["duration_minutes"].(float64); ok && d > 0 {
|
||||
durationMinutes = int(d)
|
||||
}
|
||||
|
||||
cfg.RapidPollingEnabled = true
|
||||
cfg.RapidPollingUntil = time.Now().UTC().Add(time.Duration(durationMinutes) * time.Minute)
|
||||
|
||||
if err := cfg.Save(constants.GetAgentConfigPath()); err != nil {
|
||||
log.Printf("[WARNING] [agent] [heartbeat] config_save_failed error=%v", err)
|
||||
}
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "enable_heartbeat",
|
||||
Result: "success",
|
||||
Stdout: fmt.Sprintf("Heartbeat enabled for %d minutes", durationMinutes),
|
||||
ExitCode: 0,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[WARNING] [agent] [heartbeat] report_enable_failed error=%v", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [heartbeat] enabled duration_minutes=%d until=%s",
|
||||
durationMinutes, cfg.RapidPollingUntil.Format(time.RFC3339))
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleDisableHeartbeat clears rapid-polling state. Called by the server when
|
||||
// a multi-step flow finishes (or by the timeout reconciler if the flow stalls).
|
||||
func HandleDisableHeartbeat(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string) error {
|
||||
cfg.RapidPollingEnabled = false
|
||||
cfg.RapidPollingUntil = time.Time{}
|
||||
|
||||
if err := cfg.Save(constants.GetAgentConfigPath()); err != nil {
|
||||
log.Printf("[WARNING] [agent] [heartbeat] config_save_failed error=%v", err)
|
||||
}
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: "disable_heartbeat",
|
||||
Result: "success",
|
||||
Stdout: "Heartbeat disabled",
|
||||
ExitCode: 0,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
|
||||
log.Printf("[WARNING] [agent] [heartbeat] report_disable_failed error=%v", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [heartbeat] disabled")
|
||||
return nil
|
||||
}
|
||||
114
agent/internal/handlers/install.go
Normal file
114
agent/internal/handlers/install.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/installer"
|
||||
)
|
||||
|
||||
func HandleInstallUpdates(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
|
||||
packageType, _ := params["package_type"].(string)
|
||||
packageName, _ := params["package_name"].(string)
|
||||
|
||||
if packageType == "" {
|
||||
return fmt.Errorf("package_type parameter is required")
|
||||
}
|
||||
|
||||
inst, err := installer.InstallerFactory(packageType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", packageType, err)
|
||||
}
|
||||
|
||||
if !inst.IsAvailable() {
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", packageType)
|
||||
}
|
||||
|
||||
var result *installer.InstallResult
|
||||
var action string
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
if packageName != "" {
|
||||
action = "update"
|
||||
log.Printf("[INFO] [agent] [installer] updating_package package=%s type=%s", packageName, packageType)
|
||||
result, err = inst.UpdatePackage(packageName)
|
||||
} else {
|
||||
var packageNames []string
|
||||
for key, value := range params {
|
||||
if key == "package_type" {
|
||||
continue
|
||||
}
|
||||
if name, ok := value.(string); ok && name != "" {
|
||||
packageNames = append(packageNames, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(packageNames) > 0 {
|
||||
action = "install_multiple"
|
||||
log.Printf("[INFO] [agent] [installer] installing_multiple count=%d type=%s", len(packageNames), packageType)
|
||||
result, err = inst.InstallMultiple(packageNames)
|
||||
} else {
|
||||
action = "upgrade"
|
||||
log.Printf("[INFO] [agent] [installer] upgrading_all type=%s", packageType)
|
||||
result, err = inst.Upgrade()
|
||||
}
|
||||
}
|
||||
|
||||
duration := int(time.Since(startTime).Seconds())
|
||||
|
||||
if err != nil {
|
||||
stdout := ""
|
||||
stderr := err.Error()
|
||||
exitCode := 1
|
||||
if result != nil {
|
||||
stdout = result.Stdout
|
||||
stderr = result.Stderr
|
||||
exitCode = result.ExitCode
|
||||
}
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: action,
|
||||
Result: "failed",
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: exitCode,
|
||||
DurationSeconds: duration,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Package Install",
|
||||
"subsystem": packageType,
|
||||
},
|
||||
}
|
||||
ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
|
||||
return fmt.Errorf("[ERROR] [agent] [installer] install_failed action=%s type=%s error=%w", action, packageType, err)
|
||||
}
|
||||
|
||||
logReport := client.LogReport{
|
||||
CommandID: commandID,
|
||||
Action: result.Action,
|
||||
Result: "success",
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: result.ExitCode,
|
||||
DurationSeconds: result.DurationSeconds,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Package Install",
|
||||
"subsystem": packageType,
|
||||
},
|
||||
}
|
||||
|
||||
if len(result.PackagesInstalled) > 0 {
|
||||
logReport.Stdout += fmt.Sprintf("\nPackages installed: %v", result.PackagesInstalled)
|
||||
}
|
||||
|
||||
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); reportErr != nil {
|
||||
log.Printf("[WARNING] [agent] [installer] report_failed error=%v", reportErr)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [installer] install_complete action=%s type=%s duration=%ds", action, packageType, duration)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ services:
|
|||
context: .
|
||||
dockerfile: ./server/Dockerfile
|
||||
args:
|
||||
BUILD_VERSION: ${BUILD_VERSION:-0.2.0.2}
|
||||
BUILD_VERSION: ${BUILD_VERSION:-0.2.0.3}
|
||||
container_name: redflag-server
|
||||
volumes:
|
||||
- server-config:/app/config
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import (
|
|||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -386,12 +385,6 @@ func main() {
|
|||
// Initialize scanner config handler (for user-configurable scanner timeouts)
|
||||
scannerConfigHandler := handlers.NewScannerConfigHandler(db.DB)
|
||||
|
||||
// Initialize verification handler
|
||||
var verificationHandler *handlers.VerificationHandler
|
||||
if signingService != nil {
|
||||
verificationHandler = handlers.NewVerificationHandler(agentQueries, signingService)
|
||||
}
|
||||
|
||||
// Initialize update nonce service (for version upgrade middleware)
|
||||
var updateNonceService *services.UpdateNonceService
|
||||
if signingService != nil && cfg.SigningPrivateKey != "" {
|
||||
|
|
@ -548,13 +541,6 @@ func main() {
|
|||
agents.POST("/:id/dependencies", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportDependencies)
|
||||
agents.POST("/:id/system-info", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), agentHandler.ReportSystemInfo)
|
||||
agents.POST("/:id/rapid-mode", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), agentHandler.SetRapidPollingMode)
|
||||
agents.POST("/:id/verify-signature", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), func(c *gin.Context) {
|
||||
if verificationHandler == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "signature verification service not available"})
|
||||
return
|
||||
}
|
||||
verificationHandler.VerifySignature(c)
|
||||
})
|
||||
// BUG-013: DELETE /agents/:id intentionally NOT registered here.
|
||||
// It's an admin operation invoked from the dashboard, not an
|
||||
// agent-self-service endpoint. Lives under the web-auth group
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/Fimeg/RedFlag/server/internal/services"
|
||||
serverVersion "github.com/Fimeg/RedFlag/server/internal/version"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
|
@ -75,6 +76,9 @@ func (h *DownloadHandler) getServerURL(c *gin.Context) string {
|
|||
func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
|
||||
platform := c.Param("platform")
|
||||
version := c.Query("version") // Optional version parameter for signed binaries
|
||||
if version == "latest" {
|
||||
version = serverVersion.AgentVersion
|
||||
}
|
||||
|
||||
// Validate platform to prevent directory traversal
|
||||
validPlatforms := map[string]bool{
|
||||
|
|
@ -341,7 +345,7 @@ func (h *DownloadHandler) HandleConfigDownload(c *gin.Context) {
|
|||
// Credentials are preserved locally by the install script
|
||||
configTemplate := map[string]interface{}{
|
||||
"version": 5, // Current schema version (v5 as of 0.1.23+)
|
||||
"agent_version": "0.2.0.2",
|
||||
"agent_version": "0.2.0.3",
|
||||
"server_url": serverURL,
|
||||
|
||||
// Placeholder credentials - will be replaced by install script
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ func (h *SecurityHandler) MachineBindingStatus(c *gin.Context) {
|
|||
"timestamp": time.Now().UTC(),
|
||||
"checks": map[string]interface{}{
|
||||
"binding_enforced": true,
|
||||
"min_agent_version": "v0.2.0.2",
|
||||
"min_agent_version": "v0.2.0.3",
|
||||
"fingerprint_required": true,
|
||||
"recent_violations": 0,
|
||||
"bound_agents": 0,
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/Fimeg/RedFlag/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// VerificationHandler handles signature verification requests
|
||||
type VerificationHandler struct {
|
||||
agentQueries *queries.AgentQueries
|
||||
signingService *services.SigningService
|
||||
}
|
||||
|
||||
// NewVerificationHandler creates a new verification handler
|
||||
func NewVerificationHandler(aq *queries.AgentQueries, signingService *services.SigningService) *VerificationHandler {
|
||||
return &VerificationHandler{
|
||||
agentQueries: aq,
|
||||
signingService: signingService,
|
||||
}
|
||||
}
|
||||
|
||||
// VerifySignature handles POST /api/v1/agents/:id/verify-signature
|
||||
func (h *VerificationHandler) VerifySignature(c *gin.Context) {
|
||||
var req models.SignatureVerificationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the agent exists and matches the provided machine ID
|
||||
agent, err := h.agentQueries.GetAgentByID(req.AgentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify machine ID matches
|
||||
if agent.MachineID == nil || *agent.MachineID != req.MachineID {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "machine ID mismatch",
|
||||
"expected": agent.MachineID,
|
||||
"received": req.MachineID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify public key fingerprint matches
|
||||
if agent.PublicKeyFingerprint == nil || *agent.PublicKeyFingerprint != req.PublicKey {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "public key fingerprint mismatch",
|
||||
"expected": agent.PublicKeyFingerprint,
|
||||
"received": req.PublicKey,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
valid, err := h.verifyAgentSignature(req.BinaryPath, req.Signature)
|
||||
if err != nil {
|
||||
log.Printf("Signature verification failed for agent %s: %v", req.AgentID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "signature verification failed",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response := models.SignatureVerificationResponse{
|
||||
Valid: valid,
|
||||
AgentID: req.AgentID.String(),
|
||||
MachineID: req.MachineID,
|
||||
Fingerprint: req.PublicKey,
|
||||
Message: "Signature verification completed",
|
||||
}
|
||||
|
||||
if !valid {
|
||||
response.Message = "Invalid signature - binary may be tampered with"
|
||||
c.JSON(http.StatusUnauthorized, response)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// verifyAgentSignature verifies the signature of an agent binary
|
||||
func (h *VerificationHandler) verifyAgentSignature(binaryPath, signatureHex string) (bool, error) {
|
||||
// Decode the signature
|
||||
signature, err := hex.DecodeString(signatureHex)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid signature format: %w", err)
|
||||
}
|
||||
|
||||
if len(signature) != ed25519.SignatureSize {
|
||||
return false, fmt.Errorf("invalid signature size: expected %d bytes, got %d", ed25519.SignatureSize, len(signature))
|
||||
}
|
||||
|
||||
// Read the binary file
|
||||
content, err := readFileContent(binaryPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to read binary: %w", err)
|
||||
}
|
||||
|
||||
// Verify using the signing service
|
||||
valid, err := h.signingService.VerifySignature(content, signatureHex)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("verification failed: %w", err)
|
||||
}
|
||||
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
// readFileContent reads file content safely
|
||||
func readFileContent(filePath string) ([]byte, error) {
|
||||
// Basic path validation to prevent directory traversal
|
||||
if strings.Contains(filePath, "..") || strings.Contains(filePath, "~") {
|
||||
return nil, fmt.Errorf("invalid file path")
|
||||
}
|
||||
|
||||
// Only allow specific file patterns for security
|
||||
if !strings.HasSuffix(filePath, "/redflag-agent") && !strings.HasSuffix(filePath, "/redflag-agent.exe") {
|
||||
return nil, fmt.Errorf("invalid file type - only agent binaries are allowed")
|
||||
}
|
||||
|
||||
// For security, we won't actually read files in this handler
|
||||
// In a real implementation, this would verify the actual binary on the agent
|
||||
// For now, we'll simulate the verification process
|
||||
return []byte("simulated-binary-content"), nil
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
-- Migration 036 rollback: revert TIMESTAMPTZ columns back to TIMESTAMP.
|
||||
-- Drops zone awareness; values are converted using UTC interpretation so the wall-clock
|
||||
-- is preserved as it was written.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE agents
|
||||
ALTER COLUMN last_seen TYPE TIMESTAMP USING last_seen AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN last_version_check TYPE TIMESTAMP USING last_version_check AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN last_reboot_at TYPE TIMESTAMP USING last_reboot_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN update_initiated_at TYPE TIMESTAMP USING update_initiated_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE agent_specs
|
||||
ALTER COLUMN collected_at TYPE TIMESTAMP USING collected_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE update_packages
|
||||
ALTER COLUMN discovered_at TYPE TIMESTAMP USING discovered_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN approved_at TYPE TIMESTAMP USING approved_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN scheduled_for TYPE TIMESTAMP USING scheduled_for AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN installed_at TYPE TIMESTAMP USING installed_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE update_logs
|
||||
ALTER COLUMN executed_at TYPE TIMESTAMP USING executed_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN last_login TYPE TIMESTAMP USING last_login AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE agent_commands
|
||||
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN sent_at TYPE TIMESTAMP USING sent_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN completed_at TYPE TIMESTAMP USING completed_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN signed_at TYPE TIMESTAMP USING signed_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN expires_at TYPE TIMESTAMP USING expires_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE refresh_tokens
|
||||
ALTER COLUMN expires_at TYPE TIMESTAMP USING expires_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN last_used_at TYPE TIMESTAMP USING last_used_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE registration_tokens
|
||||
ALTER COLUMN expires_at TYPE TIMESTAMP USING expires_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN used_at TYPE TIMESTAMP USING used_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN revoked_at TYPE TIMESTAMP USING revoked_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE token_seats
|
||||
ALTER COLUMN used_at TYPE TIMESTAMP USING used_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE agent_subsystems
|
||||
ALTER COLUMN last_run_at TYPE TIMESTAMP USING last_run_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN next_run_at TYPE TIMESTAMP USING next_run_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE agent_update_packages
|
||||
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE security_settings
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE security_settings_audit
|
||||
ALTER COLUMN changed_at TYPE TIMESTAMP USING changed_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE security_incidents
|
||||
ALTER COLUMN resolved_at TYPE TIMESTAMP USING resolved_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE signing_keys
|
||||
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN deprecated_at TYPE TIMESTAMP USING deprecated_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE storage_metrics
|
||||
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE client_errors
|
||||
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE scanner_config
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE tracked_software
|
||||
ALTER COLUMN latest_at TYPE TIMESTAMP USING latest_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN eol_at TYPE TIMESTAMP USING eol_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN last_checked_at TYPE TIMESTAMP USING last_checked_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN last_synced_at TYPE TIMESTAMP USING last_synced_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC';
|
||||
|
||||
ALTER TABLE upstream_drift_events
|
||||
ALTER COLUMN observed_at TYPE TIMESTAMP USING observed_at AT TIME ZONE 'UTC';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
-- Migration 036: Convert remaining TIMESTAMP columns to TIMESTAMPTZ.
|
||||
-- Doctrine: tasks/CRITICAL-003-Time-Handling-Standardization.md.
|
||||
--
|
||||
-- Rationale: the codebase has fragmented between time.Now() (server-local Go time),
|
||||
-- time.Now().UTC(), SQL NOW(), and CURRENT_TIMESTAMP. Storing into plain TIMESTAMP
|
||||
-- columns loses zone awareness and creates drift any time the Postgres container or
|
||||
-- the Go process is not at UTC. This migration converts every persistence-side
|
||||
-- timestamp to TIMESTAMPTZ so the wire-format is unambiguous and Go callers can stop
|
||||
-- caring about zone at write time.
|
||||
--
|
||||
-- Existing values are interpreted as UTC (the conventional state in Docker stacks);
|
||||
-- if any are not actually UTC, the deployment has had a latent bug all along — this
|
||||
-- migration converts honestly rather than papering over it.
|
||||
--
|
||||
-- Already-TIMESTAMPTZ columns from prior migrations (003, 018, 019, 031, 033) are
|
||||
-- skipped — listed inline in comments for clarity.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- agents (initial schema + later additions)
|
||||
ALTER TABLE agents
|
||||
ALTER COLUMN last_seen TYPE TIMESTAMPTZ USING last_seen AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN last_version_check TYPE TIMESTAMPTZ USING last_version_check AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN last_reboot_at TYPE TIMESTAMPTZ USING last_reboot_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN update_initiated_at TYPE TIMESTAMPTZ USING update_initiated_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- agent_specs
|
||||
ALTER TABLE agent_specs
|
||||
ALTER COLUMN collected_at TYPE TIMESTAMPTZ USING collected_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- update_packages
|
||||
ALTER TABLE update_packages
|
||||
ALTER COLUMN discovered_at TYPE TIMESTAMPTZ USING discovered_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN approved_at TYPE TIMESTAMPTZ USING approved_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN scheduled_for TYPE TIMESTAMPTZ USING scheduled_for AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN installed_at TYPE TIMESTAMPTZ USING installed_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- update_logs
|
||||
ALTER TABLE update_logs
|
||||
ALTER COLUMN executed_at TYPE TIMESTAMPTZ USING executed_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- users
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN last_login TYPE TIMESTAMPTZ USING last_login AT TIME ZONE 'UTC';
|
||||
|
||||
-- agent_commands (received_at is already TIMESTAMPTZ from migration 033)
|
||||
ALTER TABLE agent_commands
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN sent_at TYPE TIMESTAMPTZ USING sent_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN completed_at TYPE TIMESTAMPTZ USING completed_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN signed_at TYPE TIMESTAMPTZ USING signed_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN expires_at TYPE TIMESTAMPTZ USING expires_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- refresh_tokens
|
||||
ALTER TABLE refresh_tokens
|
||||
ALTER COLUMN expires_at TYPE TIMESTAMPTZ USING expires_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN last_used_at TYPE TIMESTAMPTZ USING last_used_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- registration_tokens
|
||||
ALTER TABLE registration_tokens
|
||||
ALTER COLUMN expires_at TYPE TIMESTAMPTZ USING expires_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN used_at TYPE TIMESTAMPTZ USING used_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN revoked_at TYPE TIMESTAMPTZ USING revoked_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- token_seats
|
||||
ALTER TABLE token_seats
|
||||
ALTER COLUMN used_at TYPE TIMESTAMPTZ USING used_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- agent_subsystems
|
||||
ALTER TABLE agent_subsystems
|
||||
ALTER COLUMN last_run_at TYPE TIMESTAMPTZ USING last_run_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN next_run_at TYPE TIMESTAMPTZ USING next_run_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- agent_update_packages
|
||||
ALTER TABLE agent_update_packages
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- security_settings
|
||||
ALTER TABLE security_settings
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- security_settings_audit
|
||||
ALTER TABLE security_settings_audit
|
||||
ALTER COLUMN changed_at TYPE TIMESTAMPTZ USING changed_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- security_incidents
|
||||
ALTER TABLE security_incidents
|
||||
ALTER COLUMN resolved_at TYPE TIMESTAMPTZ USING resolved_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- signing_keys
|
||||
ALTER TABLE signing_keys
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN deprecated_at TYPE TIMESTAMPTZ USING deprecated_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- storage_metrics
|
||||
ALTER TABLE storage_metrics
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- client_errors (from migration 023)
|
||||
ALTER TABLE client_errors
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- scanner_config
|
||||
ALTER TABLE scanner_config
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- tracked_software (migration 035)
|
||||
ALTER TABLE tracked_software
|
||||
ALTER COLUMN latest_at TYPE TIMESTAMPTZ USING latest_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN eol_at TYPE TIMESTAMPTZ USING eol_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN last_checked_at TYPE TIMESTAMPTZ USING last_checked_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN last_synced_at TYPE TIMESTAMPTZ USING last_synced_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
|
||||
|
||||
-- upstream_drift_events (migration 035)
|
||||
ALTER TABLE upstream_drift_events
|
||||
ALTER COLUMN observed_at TYPE TIMESTAMPTZ USING observed_at AT TIME ZONE 'UTC';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- Reversal of 037: drop the uniqueness constraint. Deleted duplicate rows
|
||||
-- are not recreated — they were redundant signed artifacts of identical
|
||||
-- binaries and re-creating them would only re-introduce the original bug.
|
||||
|
||||
DROP INDEX IF EXISTS uq_agent_update_packages_version_platform_arch;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
-- Migration 037: deduplicate agent_update_packages and enforce uniqueness.
|
||||
--
|
||||
-- The BuildOrchestratorService used to re-sign and re-insert every platform
|
||||
-- binary at every server boot, accumulating 4 fresh rows per restart for the
|
||||
-- same artifact. The dashboard's "available packages" list ballooned to dozens
|
||||
-- of identical entries and forced operators to scroll past them when picking a
|
||||
-- target version.
|
||||
--
|
||||
-- The service-side fix lives in build_orchestrator.go (reuse the row if the
|
||||
-- on-disk checksum matches an existing signed package); this migration
|
||||
-- cleans up historical duplicates and adds the unique constraint that makes
|
||||
-- the duplication impossible at the schema level going forward.
|
||||
--
|
||||
-- Strategy: keep the newest row per (version, platform, architecture).
|
||||
-- created_at is the tiebreaker, id breaks ties when two inserts share a
|
||||
-- timestamp.
|
||||
|
||||
DELETE FROM agent_update_packages a
|
||||
USING agent_update_packages b
|
||||
WHERE a.version = b.version
|
||||
AND a.platform = b.platform
|
||||
AND a.architecture = b.architecture
|
||||
AND (a.created_at < b.created_at
|
||||
OR (a.created_at = b.created_at AND a.id < b.id));
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_update_packages_version_platform_arch
|
||||
ON agent_update_packages (version, platform, architecture);
|
||||
|
|
@ -20,7 +20,11 @@ func NewAgentUpdateQueries(db *sqlx.DB) *AgentUpdateQueries {
|
|||
return &AgentUpdateQueries{db: db}
|
||||
}
|
||||
|
||||
// CreateUpdatePackage stores a new signed update package
|
||||
// CreateUpdatePackage stores a signed update package. Upserts on
|
||||
// (version, platform, architecture): a fresh build of the same version
|
||||
// (different checksum / signature) replaces the row in place. Combined with
|
||||
// the unique constraint added in migration 037, this guarantees one row per
|
||||
// real artifact even across repeated dev builds.
|
||||
func (q *AgentUpdateQueries) CreateUpdatePackage(pkg *models.AgentUpdatePackage) error {
|
||||
query := `
|
||||
INSERT INTO agent_update_packages (
|
||||
|
|
@ -29,7 +33,16 @@ func (q *AgentUpdateQueries) CreateUpdatePackage(pkg *models.AgentUpdatePackage)
|
|||
) VALUES (
|
||||
:id, :version, :platform, :architecture, :binary_path, :signature,
|
||||
:checksum, :file_size, :created_by, :is_active
|
||||
) RETURNING id, created_at
|
||||
)
|
||||
ON CONFLICT (version, platform, architecture) DO UPDATE SET
|
||||
binary_path = EXCLUDED.binary_path,
|
||||
signature = EXCLUDED.signature,
|
||||
checksum = EXCLUDED.checksum,
|
||||
file_size = EXCLUDED.file_size,
|
||||
created_by = EXCLUDED.created_by,
|
||||
is_active = EXCLUDED.is_active,
|
||||
created_at = NOW()
|
||||
RETURNING id, created_at
|
||||
`
|
||||
|
||||
rows, err := q.db.NamedQuery(query, pkg)
|
||||
|
|
|
|||
|
|
@ -28,41 +28,51 @@ func NewBuildOrchestratorService(signingService *SigningService, packageQueries
|
|||
}
|
||||
}
|
||||
|
||||
// BuildAndSignAgent builds (or retrieves) and signs an agent binary
|
||||
// BuildAndSignAgent builds (or retrieves) and signs an agent binary.
|
||||
//
|
||||
// Idempotent across server restarts: if a signed package already exists for
|
||||
// (version, platform, architecture) and the on-disk binary's checksum matches,
|
||||
// the existing row is returned unchanged. Previously the server would re-sign
|
||||
// and insert a fresh row every boot, accumulating duplicate packages and
|
||||
// confusing the dashboard's "update available" list.
|
||||
func (s *BuildOrchestratorService) BuildAndSignAgent(version, platform, architecture string) (*models.AgentUpdatePackage, error) {
|
||||
// Determine binary name
|
||||
binaryName := "redflag-agent"
|
||||
if strings.HasPrefix(platform, "windows") {
|
||||
binaryName += ".exe"
|
||||
}
|
||||
|
||||
// Path to pre-built binary (directory format: binaries/{platform}-{arch}/)
|
||||
binaryPath := filepath.Join(s.agentDir, "binaries", platform+"-"+architecture, binaryName)
|
||||
|
||||
// Check if binary exists
|
||||
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("binary not found for platform %s: %w", platform, err)
|
||||
}
|
||||
|
||||
// Sign the binary if signing is enabled
|
||||
if s.signingService.IsEnabled() {
|
||||
// Compute the on-disk checksum once so we can compare against any
|
||||
// existing row before paying the cost of a fresh sign.
|
||||
diskChecksum, checksumErr := s.signingService.ComputeFileChecksum(binaryPath)
|
||||
if checksumErr != nil {
|
||||
log.Printf("[WARNING] [server] [build_orchestrator] checksum_failed path=%s error=%v — falling through to fresh sign", binaryPath, checksumErr)
|
||||
} else if existing, getErr := s.packageQueries.GetSignedPackage(version, platform, architecture); getErr == nil && existing != nil && existing.Checksum == diskChecksum && existing.Signature != "" {
|
||||
log.Printf("[INFO] [server] [build_orchestrator] package_reused version=%s platform=%s arch=%s id=%s", version, platform, architecture, existing.ID)
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
signedPackage, err := s.signingService.SignFile(binaryPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign agent binary: %w", err)
|
||||
}
|
||||
|
||||
// Set additional fields
|
||||
signedPackage.Version = version
|
||||
signedPackage.Platform = platform
|
||||
signedPackage.Architecture = architecture
|
||||
|
||||
// Store signed package in database
|
||||
err = s.packageQueries.StoreSignedPackage(signedPackage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store signed package: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Successfully signed and stored agent binary: %s (%s/%s)", signedPackage.ID, platform, architecture)
|
||||
log.Printf("[INFO] [server] [build_orchestrator] package_signed id=%s version=%s platform=%s arch=%s", signedPackage.ID, version, platform, architecture)
|
||||
return signedPackage, nil
|
||||
} else {
|
||||
log.Printf("Signing disabled, creating unsigned package entry")
|
||||
|
|
|
|||
|
|
@ -165,6 +165,26 @@ func (s *SigningService) GetAllActivePublicKeys(ctx context.Context) ([]models.S
|
|||
}, nil
|
||||
}
|
||||
|
||||
// ComputeFileChecksum returns the SHA-256 hex digest of the file at the given
|
||||
// path without signing it. Used by the build orchestrator to decide whether
|
||||
// an already-stored signed package is still valid for the on-disk binary
|
||||
// (so we can skip a redundant re-sign on every server boot).
|
||||
func (s *SigningService) ComputeFileChecksum(filePath string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(content)
|
||||
return hex.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
// SignFile signs a file and returns the signature and checksum
|
||||
func (s *SigningService) SignFile(filePath string) (*models.AgentUpdatePackage, error) {
|
||||
// Check if signing is enabled
|
||||
|
|
|
|||
|
|
@ -210,6 +210,16 @@ cat <<'DOCKER_EOF' | sudo tee -a "$SUDOERS_FILE" > /dev/null
|
|||
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
|
||||
DOCKER_EOF
|
||||
|
||||
# Add self-update commands
|
||||
cat <<'UPDATE_EOF' | sudo tee -a "$SUDOERS_FILE" > /dev/null
|
||||
# RedFlag Agent self-update permissions
|
||||
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/cp /tmp/redflag-update-*.bin /usr/local/bin/redflag-agent
|
||||
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/cp /usr/local/bin/redflag-agent /usr/local/bin/redflag-agent.bak
|
||||
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/cp /usr/local/bin/redflag-agent.bak /usr/local/bin/redflag-agent
|
||||
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/chmod 755 /usr/local/bin/redflag-agent
|
||||
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemctl restart redflag-agent
|
||||
UPDATE_EOF
|
||||
|
||||
sudo chmod 440 "$SUDOERS_FILE"
|
||||
if visudo -c -f "$SUDOERS_FILE" &>/dev/null; then
|
||||
echo "✓ Sudoers configuration installed and validated"
|
||||
|
|
@ -258,13 +268,15 @@ EXPECTED_SIGNATURE=$(grep -i "x-content-signature" "$TMP_HEADERS" | awk '{print
|
|||
if [ -n "$EXPECTED_SIGNATURE" ]; then
|
||||
echo "Signature received, saving for agent verification"
|
||||
echo "$EXPECTED_SIGNATURE" | sudo tee "${SERVER_KEY_DIR}/initial_binary.sig" > /dev/null
|
||||
sudo chown {{.AgentUser}}:{{.AgentUser}} "${SERVER_KEY_DIR}/initial_binary.sig"
|
||||
else
|
||||
echo "WARNING: Server did not provide signature header"
|
||||
fi
|
||||
|
||||
# Save server public key for TOFU model
|
||||
if [ -n "{{.ServerPublicKey}}" ]; then
|
||||
echo "{{.ServerPublicKey}}" | sudo tee "${SERVER_KEY_DIR}/server_public_key" > /dev/null
|
||||
echo -n "{{.ServerPublicKey}}" | python3 -c "import sys,binascii;sys.stdout.buffer.write(binascii.unhexlify(sys.stdin.read().strip()))" | sudo tee "${SERVER_KEY_DIR}/server_public_key" > /dev/null
|
||||
sudo chown {{.AgentUser}}:{{.AgentUser}} "${SERVER_KEY_DIR}/server_public_key"
|
||||
echo "Server public key saved for TOFU verification"
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import (
|
|||
|
||||
// Build-time injected version information (SERVER AUTHORITY)
|
||||
var (
|
||||
AgentVersion = "0.2.0.2"
|
||||
ConfigVersion = "0.2.0.2"
|
||||
AgentVersion = "0.2.0.3"
|
||||
ConfigVersion = "0.2.0.3"
|
||||
MinAgentVersion = "0.1.22"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -51,22 +51,44 @@ export function AgentUpdatesModal({
|
|||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const packages = packagesResponse?.packages || [];
|
||||
const allPackages = packagesResponse?.packages || [];
|
||||
|
||||
// Group packages by version
|
||||
const versions = [...new Set(packages.map(pkg => pkg.version))].sort((a, b) => b.localeCompare(a));
|
||||
const platforms = [...new Set(packages.map(pkg => pkg.platform))].sort();
|
||||
|
||||
// Filter packages based on selection
|
||||
const availablePackages = packages.filter(
|
||||
pkg => (!selectedVersion || pkg.version === selectedVersion) &&
|
||||
(!selectedPlatform || pkg.platform === selectedPlatform)
|
||||
);
|
||||
|
||||
// Get unique platform for selected agents (simplified - assumes all agents same platform)
|
||||
// Get unique platform/arch for selected agents (simplified - assumes all agents same platform)
|
||||
const agentPlatform = agents[0]?.os_type || 'linux';
|
||||
const agentArchitecture = agents[0]?.os_architecture || 'amd64';
|
||||
|
||||
// Restrict the candidate set to packages that actually match the selected
|
||||
// agents' platform+architecture. The server enforces this on POST too (it
|
||||
// returns 400 for an incompatible combo) — surface it here so the operator
|
||||
// never sees a row they can't actually install.
|
||||
const compatiblePackages = allPackages.filter(
|
||||
pkg => pkg.platform === agentPlatform && pkg.architecture === agentArchitecture
|
||||
);
|
||||
|
||||
// Dedupe by (version, platform, architecture) — the server signs binaries
|
||||
// at every boot, which historically left multiple rows per tuple. Keep the
|
||||
// most recent row so the operator picks from one entry per real artifact.
|
||||
const dedupedPackages = Object.values(
|
||||
compatiblePackages.reduce<Record<string, typeof compatiblePackages[number]>>((acc, pkg) => {
|
||||
const key = `${pkg.version}|${pkg.platform}|${pkg.architecture}`;
|
||||
const existing = acc[key];
|
||||
if (!existing || new Date(pkg.created_at) > new Date(existing.created_at)) {
|
||||
acc[key] = pkg;
|
||||
}
|
||||
return acc;
|
||||
}, {})
|
||||
).sort((a, b) => b.version.localeCompare(a.version));
|
||||
|
||||
const versions = [...new Set(dedupedPackages.map(pkg => pkg.version))].sort((a, b) => b.localeCompare(a));
|
||||
|
||||
const availablePackages = dedupedPackages.filter(
|
||||
pkg => !selectedVersion || pkg.version === selectedVersion
|
||||
);
|
||||
|
||||
// selectedPlatform is now driven entirely by agent platform; kept in state
|
||||
// for future multi-arch agent selection but unused in current filtering.
|
||||
void selectedPlatform;
|
||||
|
||||
// Update agents mutation
|
||||
const updateAgentsMutation = useMutation({
|
||||
mutationFn: async (packageId: string) => {
|
||||
|
|
@ -207,34 +229,19 @@ export function AgentUpdatesModal({
|
|||
Update Package Selection
|
||||
</h4>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Version</label>
|
||||
<select
|
||||
value={selectedVersion}
|
||||
onChange={(e) => setSelectedVersion(e.target.value)}
|
||||
className="w-full rounded-md border-gray-300 shadow-sm text-sm"
|
||||
>
|
||||
<option value="">All Versions</option>
|
||||
{versions.map(version => (
|
||||
<option key={version} value={version}>{version}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Platform</label>
|
||||
<select
|
||||
value={selectedPlatform}
|
||||
onChange={(e) => setSelectedPlatform(e.target.value)}
|
||||
className="w-full rounded-md border-gray-300 shadow-sm text-sm"
|
||||
>
|
||||
<option value="">All Platforms</option>
|
||||
{platforms.map(platform => (
|
||||
<option key={platform} value={platform}>{platform}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Filters — platform/arch fixed by selected agents; only version varies */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Version</label>
|
||||
<select
|
||||
value={selectedVersion}
|
||||
onChange={(e) => setSelectedVersion(e.target.value)}
|
||||
className="w-full rounded-md border-gray-300 shadow-sm text-sm"
|
||||
>
|
||||
<option value="">All Versions</option>
|
||||
{versions.map(version => (
|
||||
<option key={version} value={version}>{version}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Available Packages */}
|
||||
|
|
@ -264,7 +271,7 @@ export function AgentUpdatesModal({
|
|||
Version {pkg.version}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{pkg.platform} • {(pkg.file_size / 1024 / 1024).toFixed(1)} MB
|
||||
{pkg.platform}/{pkg.architecture} • {(pkg.file_size / 1024 / 1024).toFixed(1)} MB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export interface AgentUpdatePackage {
|
|||
id: string;
|
||||
version: string;
|
||||
platform: string;
|
||||
architecture: string;
|
||||
file_size: number;
|
||||
checksum: string;
|
||||
created_at: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue