Watch
1
0
Fork
You've already forked RedFlag
0

v0.2.9.0 — Windows desktop tray ships; unified Agents & Enrollment page

Desktop:
- Windows tray cross-compiled (cargo-xwin), installed with per-user
  autostart Run key; tray actions trigger_scan/approve_update wired to
  the local API
- Linux tray off the service child-spawn path — XDG autostart only, kills
  the double-launch
- signalDesktopRestart no longer no-ops on Windows (taskkill /F /IM)
- server serves /desktop/:platform/:arch

Web:
- TokenManagement + AgentManagement folded into one Agents & Enrollment
  settings page (useRegistrationTokens hook)

Agent/server:
- platform-aware self-update staging (constants/paths.go), no more
  hardcoded /var/lib/redflag
- consumer helper gated: sudo systemd-run on Linux, child proc elsewhere
- migration 060 drops the never-used token_seats table
- droppage of dead constructors and orphaned windows.go service methods
This commit is contained in:
Fimeg 2026-06-15 20:51:44 -04:00
commit 99d97a07ee
46 changed files with 1879 additions and 1977 deletions

View file

@ -341,6 +341,32 @@ jobs:
echo "Desktop version: $DESKTOP_VER"
echo "$DESKTOP_VER" | grep -q "v$VERSION" || { echo "::error::desktop binary reports $DESKTOP_VER, expected v$VERSION"; exit 1; }
- name: Build desktop (Tauri system tray - Windows cross-compile)
if: matrix.goos == 'windows' && matrix.goarch == 'amd64'
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
# Tauri v2 host build scripts need webkit2gtk even when cross-compiling.
sudo apt-get update -qq
sudo apt-get install -y -qq libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev clang lld llvm 2>/dev/null || true
# Install cargo-xwin for MSVC cross-compilation.
rustup target add x86_64-pc-windows-msvc
cargo install --locked cargo-xwin
# Build the desktop frontend.
cd web && npm ci --silent && npm run build:desktop && cd ..
# Cross-compile the desktop binary (bundling disabled — raw exe).
cd desktop
cargo xwin build --release --target x86_64-pc-windows-msvc
cp target/x86_64-pc-windows-msvc/release/redflag-desktop.exe \
../dist/redflag-desktop-${{ matrix.suffix }}.exe
echo "Desktop binary built: $(ls -lh ../dist/redflag-desktop-${{ matrix.suffix }}.exe)"
# Cross-compiled binary can't run here for version check, but the gate
# job validates the manifest hash before publish.
# Did it make it in: linux-amd64 binaries self-report the tag version.
# Cross-compiled binaries can't run here (wrong arch/OS), but the native
# ones must match.
@ -362,8 +388,12 @@ jobs:
cd dist
ls -la
if [ "${{ matrix.goos }}" = "windows" ]; then
# Windows: zip (no helper — it's Unix-only)
zip redflag-$VERSION-${{ matrix.suffix }}.zip redflag-server-${{ matrix.suffix }}.exe redflag-agent-${{ matrix.suffix }}.exe
# Windows: zip (no helper — it's Unix-only). Desktop binary is optional.
ZIP_FILES="redflag-server-${{ matrix.suffix }}.exe redflag-agent-${{ matrix.suffix }}.exe"
if [ -f "redflag-desktop-${{ matrix.suffix }}.exe" ]; then
ZIP_FILES="$ZIP_FILES redflag-desktop-${{ matrix.suffix }}.exe"
fi
zip redflag-$VERSION-${{ matrix.suffix }}.zip $ZIP_FILES
sha256sum redflag-$VERSION-${{ matrix.suffix }}.zip > checksums-$VERSION-${{ matrix.suffix }}.txt
else
tar czf redflag-$VERSION-${{ matrix.suffix }}.tar.gz redflag-*-${{ matrix.suffix }}

View file

@ -10,6 +10,41 @@ Format: version, date, then grouped by category (Added, Changed, Removed, Fixed,
---
## v0.2.9.0 (June 2026)
### Added
- Desktop tray ships for Windows — cross-compiled via cargo-xwin, installed by
the Windows installer with per-user autostart (registry Run key).
- Standalone tray actions: `trigger_scan` and `approve_update` Tauri invoke
commands wired to the local API (`/v1/actions/trigger-scan`,
`/v1/actions/approve-update`).
- Unified settings page (`Agents & Enrollment`) replaces separate Token and
Agent Management pages.
### Changed
- Desktop tray no longer child-spawned by the agent service on Linux — relies
solely on XDG autostart, killing the double-launch.
- Self-update staging paths are platform-aware (`constants/paths.go`) instead
of hardcoded `/var/lib/redflag/...`.
- Consumer helper invocation platform-gated: `sudo systemd-run` on Linux,
direct child process elsewhere.
- Desktop server route expanded to `/desktop/:platform/:arch` for multi-OS
binary serving.
- `signalDesktopRestart` works on Windows (`taskkill /F /IM`) instead of
no-opping.
### Fixed
- Desktop tray window_open now correctly tracks visibility: set `false` on
close-to-tray, set `true` on left-click tray icon.
### Removed
- Dead `NewEnforcer` (nil-logger wrapper) in kernel package — refactor
droppage from the LoopContext migration.
- Dead `NewMigrationExecutorWithEvents` — the log-only constructor is the
intended one for early-boot migration.
- Orphaned service methods in `service/windows.go` (renewTokenIfNeeded,
reportSystemInfo, reportLogWithAck, getConfigPath).
## v0.2.8.4 (June 2026)
### Security

View file

@ -9,6 +9,7 @@ import (
"math/rand"
"os"
"path/filepath"
"runtime"
"sync/atomic"
"time"
@ -52,17 +53,45 @@ func RunAgentLoop(cfg *config.Config) error {
// Panic recovery for the main agent loop [TD-002]
defer recovery.Recover("agent_main_loop")
// Initialize startup logger
startupLogger := startup.NewLogger(constants.GetAgentStateDir(), version.Version)
startupLogger.LogEvent(startup.EventTypeStartup, true, nil, map[string]interface{}{
"agent_id": cfg.AgentID.String(),
"server": cfg.ServerURL,
})
log.Printf("RedFlag Agent v%s starting...", version.Version)
log.Printf("Agent ID: %s Server: %s Interval: %ds",
cfg.AgentID, cfg.ServerURL, cfg.CheckInInterval)
loopCtx, err := NewLoopContext(cfg, LoopContextOptions{
Ctx: context.Background(),
EnableDesktop: true,
})
if err != nil {
return err
}
// Post-upgrade attestation and healthcheck run after the canonical context
// exists so any warning path can use the operational event channel.
handlers.RunUpgradeAttestation(loopCtx.APIClient, cfg, loopCtx.AckTracker)
if gaps := handlers.RunPostUpgradeHealthcheck(cfg); gaps > 0 {
log.Printf("[INFO] [agent] [healthcheck] gaps=%d — upgrade may be incomplete; re-run install script to reconcile", gaps)
}
return RunPollingLoop(loopCtx)
}
// LoopContextOptions controls platform-specific loop setup around the shared
// dependency graph. Windows service mode supplies StopCh; console mode enables
// the desktop manager.
type LoopContextOptions struct {
Ctx context.Context
StopCh <-chan struct{}
EnableDesktop bool
}
// NewLoopContext builds the canonical dependency graph for the agent polling
// loop. Both console mode and Windows service mode use this so operational
// event plumbing cannot silently diverge at the construction boundary.
func NewLoopContext(cfg *config.Config, opts LoopContextOptions) (*LoopContext, error) {
if opts.Ctx == nil {
opts.Ctx = context.Background()
}
apiClient := client.NewClient(cfg.ServerURL, cfg.Token)
// Apply the locally-configured stale-key window (env/config file) before the
@ -77,6 +106,70 @@ func RunAgentLoop(cfg *config.Config) error {
event.NewBuffer(filepath.Join(constants.GetAgentStateDir(), "events_buffer.json")),
cfg.AgentID,
)
config.InitLogger(teeLogger)
crypto.InitLogger(teeLogger)
recovery.SetHandler(func(component string, err interface{}, stack []byte) {
stackText := string(stack)
if len(stackText) > 8192 {
stackText = stackText[:8192]
}
errText := fmt.Sprint(err)
log.Printf("[CRITICAL] [%s] panic_recovered error=%q", component, err)
log.Printf("[CRITICAL] [%s] stack_trace=%s", component, stackText)
teeLogger.Log(event.LogParams{
Level: "CRITICAL",
System: "agent",
Component: "recovery",
EventType: models.EventTypeError,
EventSubtype: models.SubtypePanicRecovered,
Severity: models.SeverityCritical,
ServerComponent: "panic_recovery",
Message: fmt.Sprintf("Recovered panic in %s: %s", component, errText),
Metadata: map[string]interface{}{
"component": component,
"error": errText,
"stack": stackText,
},
})
})
startupLogger := startup.NewLogger(constants.GetAgentStateDir(), version.Version)
if err := startupLogger.LogEvent(startup.EventTypeStartup, true, nil, map[string]interface{}{
"agent_id": cfg.AgentID.String(),
"server": cfg.ServerURL,
}); err != nil {
teeLogger.Warning("agent", "startup", "startup", "startup_event_local_write_failed", map[string]interface{}{"error": err.Error()})
}
teeLogger.Log(event.LogParams{
Level: "INFO",
System: "agent",
Component: "startup",
EventType: models.EventTypeAgentStartup,
EventSubtype: models.SubtypeStarted,
Severity: models.SeverityInfo,
ServerComponent: "startup",
Message: "agent startup",
Metadata: map[string]interface{}{
"agent_id": cfg.AgentID.String(),
"server": cfg.ServerURL,
"version": version.Version,
},
})
defaults := config.GetDefaultSubsystemsConfig()
resolve := func(sub, def config.SubsystemConfig) config.SubsystemConfig {
if sub == (config.SubsystemConfig{}) {
return def
}
return sub
}
aptSub := resolve(cfg.Subsystems.APT, defaults.APT)
dnfSub := resolve(cfg.Subsystems.DNF, defaults.DNF)
windowsSub := resolve(cfg.Subsystems.Windows, defaults.Windows)
wingetSub := resolve(cfg.Subsystems.Winget, defaults.Winget)
storageSub := resolve(cfg.Subsystems.Storage, defaults.Storage)
systemSub := resolve(cfg.Subsystems.System, defaults.System)
dockerSub := resolve(cfg.Subsystems.Docker, defaults.Docker)
// Initialize scanners
aptScanner := scanner.NewAPTScanner()
@ -88,29 +181,33 @@ func RunAgentLoop(cfg *config.Config) error {
dockerScanner, _ := orchestrator.NewDockerScanner()
// Initialize circuit breakers
aptCB := newCircuitBreaker("APT", cfg.Subsystems.APT.CircuitBreaker)
dnfCB := newCircuitBreaker("DNF", cfg.Subsystems.DNF.CircuitBreaker)
windowsCB := newCircuitBreaker("Windows Update", cfg.Subsystems.Windows.CircuitBreaker)
wingetCB := newCircuitBreaker("Winget", cfg.Subsystems.Winget.CircuitBreaker)
storageCB := newCircuitBreaker("Storage", cfg.Subsystems.Storage.CircuitBreaker)
systemCB := newCircuitBreaker("System", cfg.Subsystems.System.CircuitBreaker)
dockerCB := newCircuitBreaker("Docker", cfg.Subsystems.Docker.CircuitBreaker)
aptCB := newCircuitBreaker("APT", aptSub.CircuitBreaker)
dnfCB := newCircuitBreaker("DNF", dnfSub.CircuitBreaker)
windowsCB := newCircuitBreaker("Windows Update", windowsSub.CircuitBreaker)
wingetCB := newCircuitBreaker("Winget", wingetSub.CircuitBreaker)
storageCB := newCircuitBreaker("Storage", storageSub.CircuitBreaker)
systemCB := newCircuitBreaker("System", systemSub.CircuitBreaker)
dockerCB := newCircuitBreaker("Docker", dockerSub.CircuitBreaker)
// Initialize orchestrator with event buffering
scanOrchestrator := orchestrator.NewOrchestratorWithEvents(teeLogger)
// Register all scanners
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)
scanOrchestrator.RegisterScanner("apt", aptScanner, aptCB, aptSub.Timeout, aptSub.Enabled)
scanOrchestrator.RegisterScanner("dnf", dnfScanner, dnfCB, dnfSub.Timeout, dnfSub.Enabled)
scanOrchestrator.RegisterScanner("windows", windowsUpdateScanner, windowsCB, windowsSub.Timeout, windowsSub.Enabled)
scanOrchestrator.RegisterScanner("winget", wingetScanner, wingetCB, wingetSub.Timeout, wingetSub.Enabled)
scanOrchestrator.RegisterScanner("storage", storageScanner, storageCB, storageSub.Timeout, storageSub.Enabled)
scanOrchestrator.RegisterScanner("system", systemScanner, systemCB, systemSub.Timeout, systemSub.Enabled)
if dockerScanner != nil {
scanOrchestrator.RegisterScanner("docker", dockerScanner, dockerCB, dockerSub.Timeout, dockerSub.Enabled)
} else {
teeLogger.Warning("agent", "docker", "docker", "docker_scanner_init_failed", nil)
}
// Register inventory scanners (DockerScanner implements both Scanner and InventoryScanner)
if dockerScanner != nil {
scanOrchestrator.RegisterInventoryScanner("docker", dockerScanner, dockerCB, cfg.Subsystems.Docker.Timeout, cfg.Subsystems.Docker.Enabled)
scanOrchestrator.RegisterInventoryScanner("docker", dockerScanner, dockerCB, dockerSub.Timeout, dockerSub.Enabled)
}
// Initialize acknowledgment tracker (result acks — pending_acks.json)
@ -146,33 +243,23 @@ func RunAgentLoop(cfg *config.Config) error {
securityLogger, _ := logging.NewSecurityLogger(cfg, constants.GetAgentStateDir())
commandHandler, err := orchestrator.NewCommandHandler(cfg, constants.GetAgentStateDir(), securityLogger, log.New(os.Stdout, "", log.LstdFlags))
if err != nil {
return fmt.Errorf("failed to initialize command handler: %w", err)
}
// Post-upgrade healthcheck: if the previous process restarted us as part of a
// self-upgrade, verify this binary is the version the swap installed and
// report a failed update_agent under the original command_id if not.
handlers.RunUpgradeAttestation(apiClient, cfg, ackTracker)
// Post-upgrade environment healthcheck: verify config key completeness,
// binary presence, and file permissions that the install script manages.
// Upgraded agents preserve their old config.json and may lack keys or
// artifacts added by newer releases. Warnings only — agent continues.
if gaps := handlers.RunPostUpgradeHealthcheck(cfg); gaps > 0 {
log.Printf("[INFO] [agent] [healthcheck] gaps=%d — upgrade may be incomplete; re-run install script to reconcile", gaps)
teeLogger.Critical("agent", "cmd_handler", "cmd_handler", fmt.Sprintf("init_failed error=%v", err), map[string]interface{}{"error": err.Error()})
return nil, fmt.Errorf("failed to initialize command handler: %w", err)
}
// Initialize desktop manager (spawns Tauri system tray + local UI)
desktopMgr := desktop.NewManager(
"", // auto-detect binary alongside agent
cfg.Desktop.Enabled,
cfg.Desktop.MaxRestarts,
cfg.Desktop.RestartDelaySec,
)
var desktopMgr *desktop.Manager
if opts.EnableDesktop {
desktopMgr = desktop.NewManager(
"", // auto-detect binary alongside agent
cfg.Desktop.Enabled,
cfg.Desktop.MaxRestarts,
cfg.Desktop.RestartDelaySec,
)
}
// Start the main loop
return RunPollingLoop(&LoopContext{
Ctx: context.Background(),
return &LoopContext{
Ctx: opts.Ctx,
Cfg: cfg,
APIClient: apiClient,
AckTracker: ackTracker,
@ -184,6 +271,8 @@ func RunAgentLoop(cfg *config.Config) error {
KernelEnforcer: kernelEnforcer,
SecurityLogger: securityLogger,
TeeLogger: teeLogger,
EventBuffer: teeLogger.Buffer(),
StopCh: opts.StopCh,
CircuitBreakers: map[string]*circuitbreaker.CircuitBreaker{
"apt": aptCB,
"dnf": dnfCB,
@ -193,7 +282,7 @@ func RunAgentLoop(cfg *config.Config) error {
"system": systemCB,
"docker": dockerCB,
},
})
}, nil
}
// LoopContext holds all dependencies for the polling loop.
@ -211,6 +300,7 @@ type LoopContext struct {
DesktopManager *desktop.Manager
SecurityLogger *logging.SecurityLogger
TeeLogger *event.TeeLogger
EventBuffer *event.Buffer
Ctx context.Context
StopCh <-chan struct{} // non-nil causes loop to exit cleanly when closed
}
@ -285,8 +375,10 @@ func RunPollingLoop(loopCtx *LoopContext) error {
defer localAPIServer.Stop()
}
// Start desktop app (connects to the local API socket above)
if ctx.DesktopManager != nil {
// Start desktop app (connects to the local API socket above).
// On Linux the tray is launched by XDG autostart via the user's desktop
// environment — the agent service must not spawn a second copy.
if ctx.DesktopManager != nil && runtime.GOOS != "linux" {
go ctx.DesktopManager.Start(ctx.Ctx)
defer ctx.DesktopManager.Stop()
}
@ -373,11 +465,15 @@ func RunPollingLoop(loopCtx *LoopContext) error {
if errors.Is(err, client.ErrMachineMismatch) {
// Terminal: the server no longer recognizes this host as the one the
// agent registered on — config moved or copied. Renewal can't fix it
// (renewal is now machine-bound too), so don't even try. Surface loudly;
// the client already buffered a critical machine_binding_rejected event.
// (renewal is now machine-bound too), so don't even try. Surface loudly.
// We keep polling rather than exit, so the agent stays visible and
// self-heals the moment an operator rebinds it server-side.
log.Printf("[ERROR] [agent] [auth] machine_id_mismatch identity_moved_or_copied re_registration_required agent_id=%s", ctx.Cfg.AgentID)
event.BufferSystemEvent(ctx.EventBuffer, ctx.Cfg.AgentID,
models.EventTypeError, "machine_id_mismatch", models.SeverityCritical,
models.ComponentAgent,
fmt.Sprintf("Server rejected check-in with machine ID mismatch: %v", err),
map[string]interface{}{"agent_id": ctx.Cfg.AgentID.String(), "server_url": ctx.Cfg.ServerURL})
} else if errors.Is(err, client.ErrUnauthorized) && ctx.Cfg.RefreshToken != "" {
log.Printf("[INFO] [agent] [auth] jwt_expired attempting_renewal agent_id=%s", ctx.Cfg.AgentID)
renewErr := ctx.APIClient.RenewToken(ctx.Cfg.AgentID, ctx.Cfg.RefreshToken, version.Version)
@ -399,9 +495,13 @@ func RunPollingLoop(loopCtx *LoopContext) error {
continue
case errors.Is(renewErr, client.ErrRefreshTokenInvalid):
// Terminal: the refresh token is dead. Backing off won't help —
// the agent needs re-registration. Surface it loudly; the client
// already buffered a critical refresh_token_invalid event.
// the agent needs re-registration. Surface it loudly.
log.Printf("[ERROR] [agent] [auth] refresh_token_invalid re_registration_required agent_id=%s error=%v", ctx.Cfg.AgentID, renewErr)
event.BufferSystemEvent(ctx.EventBuffer, ctx.Cfg.AgentID,
models.EventTypeError, "refresh_token_invalid", models.SeverityCritical,
models.ComponentAgent,
fmt.Sprintf("Refresh token rejected; re-registration required: %v", renewErr),
map[string]interface{}{"agent_id": ctx.Cfg.AgentID.String(), "server_url": ctx.Cfg.ServerURL})
class = classifyFailure(renewErr)
default:
// Transient renewal failure (network, 502). Fall through to backoff and retry.
@ -461,7 +561,8 @@ func RunPollingLoop(loopCtx *LoopContext) error {
}
// Log check-in success event [TD-003]
ctx.APIClient.BufferEvent(models.EventTypeAgentCheckIn, models.SubtypeSuccess, models.SeverityInfo,
event.BufferSystemEvent(ctx.EventBuffer, ctx.Cfg.AgentID,
models.EventTypeAgentCheckIn, models.SubtypeSuccess, models.SeverityInfo,
models.ComponentAgent, "Agent checked in successfully", map[string]interface{}{
"commands_received": len(response.Commands),
"rapid_polling": ctx.Cfg.RapidPollingEnabled && time.Now().Before(ctx.Cfg.RapidPollingUntil),
@ -495,7 +596,8 @@ func RunPollingLoop(loopCtx *LoopContext) error {
for _, d := range dropped {
log.Printf("[WARNING] [agent] [acknowledgment] result_ack_dropped command_id=%s reason=%s retries=%d age_s=%d",
d.CommandID, d.Reason, d.RetryCount, d.AgeSeconds)
ctx.APIClient.BufferEvent(models.EventTypeError, models.SubtypeFailed, models.SeverityWarning,
event.BufferSystemEvent(ctx.EventBuffer, ctx.Cfg.AgentID,
models.EventTypeError, models.SubtypeFailed, models.SeverityWarning,
models.ComponentAgent,
fmt.Sprintf("Abandoned delivery of command result %s (%s) — server never confirmed receipt", d.CommandID, d.Reason),
map[string]interface{}{
@ -513,7 +615,8 @@ func RunPollingLoop(loopCtx *LoopContext) error {
if dropped := ctx.ReceiptTracker.Cleanup(); len(dropped) > 0 {
for _, d := range dropped {
log.Printf("[WARNING] [agent] [receipt] receipt_dropped command_id=%s age_s=%d", d.CommandID, d.AgeSeconds)
ctx.APIClient.BufferEvent(models.EventTypeError, models.SubtypeFailed, models.SeverityWarning,
event.BufferSystemEvent(ctx.EventBuffer, ctx.Cfg.AgentID,
models.EventTypeError, models.SubtypeFailed, models.SeverityWarning,
models.ComponentAgent,
fmt.Sprintf("Abandoned receipt confirmation for command %s — server never acknowledged receipt before max-age", d.CommandID),
map[string]interface{}{
@ -672,7 +775,10 @@ func reportCircuitBreakerHealth(ctx *LoopContext) {
// reportBufferedEvents sends buffered events to the server [TD-003]
func reportBufferedEvents(ctx *LoopContext) {
events, err := ctx.APIClient.GetBufferedEvents()
if ctx.EventBuffer == nil {
return
}
events, err := ctx.EventBuffer.ReadBufferedEvents()
if err != nil {
log.Printf("[WARNING] Failed to get buffered events: %v", err)
return
@ -695,6 +801,11 @@ func reportBufferedEvents(ctx *LoopContext) {
if accepted > 0 {
log.Printf("[INFO] Successfully reported %d buffered event(s)", accepted)
}
if accepted+rejected > 0 {
if err := ctx.EventBuffer.Clear(); err != nil {
log.Printf("[WARNING] Failed to clear reported event buffer: %v", err)
}
}
}
// reportSecurityEvents sends buffered security events to the server.
@ -822,7 +933,7 @@ func processCommands(ctx *LoopContext, commands []client.Command) {
// Dispatch cross-platform commands (scans + update_agent) via the
// shared dispatcher so the cross-platform agent loop and the
// Windows service path stay aligned.
if !handlers.DispatchCrossPlatformCommand(ctx.APIClient, ctx.Cfg, ctx.AckTracker, ctx.ScanOrchestrator, cmd) {
if !handlers.DispatchCrossPlatformCommand(ctx.APIClient, ctx.Cfg, ctx.AckTracker, ctx.ScanOrchestrator, ctx.EventBuffer, cmd) {
log.Printf("Command type %s has no registered handler", cmd.Type)
}
}()

View file

@ -10,13 +10,11 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/event"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/Fimeg/RedFlag/agent/internal/system"
"github.com/gofrs/uuid/v5"
@ -51,8 +49,6 @@ type Client struct {
RapidPollingUntil time.Time
machineID string // Cached machine ID for security binding
refreshToken string // Most recent refresh token (rotated on each renew, migration 045)
eventBuffer *event.Buffer
agentID uuid.UUID
}
// newHTTPClient returns an *http.Client with the given timeout and a transport
@ -95,65 +91,6 @@ func NewClient(baseURL, token string) *Client {
}
}
// NewClientWithEventBuffer creates a new API client with event buffering capability
func NewClientWithEventBuffer(baseURL, token string, statePath string, agentID uuid.UUID) *Client {
client := NewClient(baseURL, token)
client.agentID = agentID
// Initialize event buffer if state path is provided
if statePath != "" {
eventBufferPath := filepath.Join(statePath, "events_buffer.json")
client.eventBuffer = event.NewBuffer(eventBufferPath)
}
return client
}
// BufferEvent buffers a system event for later reporting [TD-003]
// Public method for use by agent loop and other components
func (c *Client) BufferEvent(eventType, eventSubtype, severity, component, message string, metadata map[string]interface{}) {
c.bufferEventInternal(eventType, eventSubtype, severity, component, message, metadata)
}
// bufferEventInternal is the internal implementation
func (c *Client) bufferEventInternal(eventType, eventSubtype, severity, component, message string, metadata map[string]interface{}) {
if c.eventBuffer == nil {
return // Event buffering not enabled
}
// Use agent ID if available, otherwise create event with nil agent ID
var agentIDPtr *uuid.UUID
if c.agentID != uuid.Nil {
agentIDPtr = &c.agentID
}
event := &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: eventType,
EventSubtype: eventSubtype,
Severity: severity,
Component: component,
Message: message,
Metadata: metadata,
CreatedAt: time.Now().UTC(),
}
// Buffer the event (best effort - don't fail if buffering fails)
if err := c.eventBuffer.BufferEvent(event); err != nil {
// Don't fail if buffering fails - just log
_ = err
}
}
// GetBufferedEvents returns all buffered events and clears the buffer
func (c *Client) GetBufferedEvents() ([]*models.SystemEvent, error) {
if c.eventBuffer == nil {
return nil, nil // Event buffering not enabled
}
return c.eventBuffer.GetBufferedEvents()
}
// ReportEvents sends buffered events to the server [TD-003]
// Returns (accepted, rejected, error)
func (c *Client) ReportEvents(agentID uuid.UUID, events []*models.SystemEvent) (int, int, error) {
@ -410,25 +347,11 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
body, err := json.Marshal(req)
if err != nil {
// Buffer registration failure event
c.bufferEventInternal("registration_failure", "marshal_error", "error", "client",
fmt.Sprintf("Failed to marshal registration request: %v", err),
map[string]interface{}{
"error": err.Error(),
"hostname": req.Hostname,
})
return nil, err
}
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
// Buffer registration failure event
c.bufferEventInternal("registration_failure", "request_creation_error", "error", "client",
fmt.Sprintf("Failed to create registration request: %v", err),
map[string]interface{}{
"error": err.Error(),
"hostname": req.Hostname,
})
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
@ -441,14 +364,6 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
resp, err := c.http.Do(httpReq)
if err != nil {
// Buffer registration failure event
c.bufferEventInternal("registration_failure", "network_error", "error", "client",
fmt.Sprintf("Registration request failed: %v", err),
map[string]interface{}{
"error": err.Error(),
"hostname": req.Hostname,
"server_url": c.baseURL,
})
return nil, err
}
defer resp.Body.Close()
@ -456,34 +371,16 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
errorMsg := fmt.Sprintf("registration failed: %s - %s", resp.Status, string(bodyBytes))
// Buffer registration failure event
c.bufferEventInternal("registration_failure", "api_error", "error", "client",
errorMsg,
map[string]interface{}{
"status_code": resp.StatusCode,
"response_body": string(bodyBytes),
"hostname": req.Hostname,
"server_url": c.baseURL,
})
return nil, fmt.Errorf("%s", errorMsg)
}
var result RegisterResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// Buffer registration failure event
c.bufferEventInternal("registration_failure", "decode_error", "error", "client",
fmt.Sprintf("Failed to decode registration response: %v", err),
map[string]interface{}{
"error": err.Error(),
"hostname": req.Hostname,
})
return nil, err
}
// Update client token and agent ID
// Update client token
c.token = result.Token
c.agentID = result.AgentID
return &result, nil
}
@ -513,25 +410,11 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion
body, err := json.Marshal(renewalReq)
if err != nil {
// Buffer token renewal failure event
c.bufferEventInternal("token_renewal_failure", "marshal_error", "error", "client",
fmt.Sprintf("Failed to marshal token renewal request: %v", err),
map[string]interface{}{
"error": err.Error(),
"agent_id": agentID.String(),
})
return err
}
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
// Buffer token renewal failure event
c.bufferEventInternal("token_renewal_failure", "request_creation_error", "error", "client",
fmt.Sprintf("Failed to create token renewal request: %v", err),
map[string]interface{}{
"error": err.Error(),
"agent_id": agentID.String(),
})
return err
}
httpReq.Header.Set("Content-Type", "application/json")
@ -539,14 +422,6 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion
resp, err := c.http.Do(httpReq)
if err != nil {
// Buffer token renewal failure event
c.bufferEventInternal("token_renewal_failure", "network_error", "error", "client",
fmt.Sprintf("Token renewal request failed: %v", err),
map[string]interface{}{
"error": err.Error(),
"agent_id": agentID.String(),
"server_url": c.baseURL,
})
return err
}
defer resp.Body.Close()
@ -558,26 +433,9 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion
// A 401/403 on the renew endpoint means the refresh token itself is no
// longer valid (expired, revoked, or machine unbound). That's terminal —
// no amount of retrying recovers it, the agent must be re-registered.
// Emit a distinct, higher-severity event so it isn't lost among transient
// renewal failures, and wrap the terminal sentinel so the loop can react.
terminal := resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden
subtype := "api_error"
severity := "error"
if terminal {
subtype = "refresh_token_invalid"
severity = "critical"
}
c.bufferEventInternal("token_renewal_failure", subtype, severity, "client",
errorMsg,
map[string]interface{}{
"status_code": resp.StatusCode,
"response_body": string(bodyBytes),
"agent_id": agentID.String(),
"server_url": c.baseURL,
})
if terminal {
// Wrap the terminal sentinel so the loop can react and buffer the
// operational event through its own producer-owned event buffer.
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%w: %s", ErrRefreshTokenInvalid, string(bodyBytes))
}
return fmt.Errorf("%s", errorMsg)
@ -585,13 +443,6 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion
var result TokenRenewalResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// Buffer token renewal failure event
c.bufferEventInternal("token_renewal_failure", "decode_error", "error", "client",
fmt.Sprintf("Failed to decode token renewal response: %v", err),
map[string]interface{}{
"error": err.Error(),
"agent_id": agentID.String(),
})
return err
}
@ -707,15 +558,8 @@ func (c *Client) GetCommands(agentID uuid.UUID, metrics *SystemMetrics) (*Comman
}
if resp.StatusCode == http.StatusForbidden {
// Machine binding rejected us — this config is being used from a host
// it wasn't registered on. Loud, not silent: buffer a critical event so
// the operator sees a possible cloned identity, not just a backoff line.
c.bufferEventInternal("machine_binding_rejected", "machine_id_mismatch", "critical", "client",
fmt.Sprintf("Server rejected check-in with 403 (machine ID mismatch): %s", string(bodyBytes)),
map[string]interface{}{
"status_code": resp.StatusCode,
"agent_id": agentID.String(),
"server_url": c.baseURL,
})
// it wasn't registered on. Return a sentinel; the loop owns buffering
// the critical operational event through the agent event buffer.
return nil, fmt.Errorf("%w: %s", ErrMachineMismatch, string(bodyBytes))
}
return nil, fmt.Errorf("failed to get commands: %s - %s", resp.Status, string(bodyBytes))
@ -1035,10 +879,10 @@ func (c *Client) ReportStorageMetrics(agentID uuid.UUID, report models.StorageMe
// ProcessScanReport represents a full process scan result
type ProcessScanReport struct {
AgentID uuid.UUID `json:"agent_id"`
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Snapshot system.FullProcessSnapshot `json:"snapshot"`
AgentID uuid.UUID `json:"agent_id"`
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Snapshot system.FullProcessSnapshot `json:"snapshot"`
}
// ReportProcessScan sends a full process scan to the server via dedicated endpoint

View file

@ -341,6 +341,11 @@ func getDefaultConfig() *Config {
MaxEnvKeys: 200,
MaxListeningPorts: 100,
},
Desktop: DesktopConfig{
Enabled: true,
MaxRestarts: 3,
RestartDelaySec: 5,
},
Tags: []string{},
Metadata: make(map[string]string),
}

View file

@ -120,3 +120,47 @@ func GetLegacyAgentConfigPath() string {
func GetLegacyAgentStatePath() string {
return LegacyStatePath
}
// Staging directory for self-update binaries (pending-upgrade.bin etc.).
// Mirrors /var/lib/redflag/agent on Linux, C:\ProgramData\RedFlag\agent on Windows.
func GetAgentStagingDir() string {
return filepath.Join(GetBaseDir(), AgentDir)
}
// GetAgentStagingPath returns the staging path for a self-update binary.
// suffix is e.g. "pending-upgrade.bin", "pending-helper.bin", "pending-desktop.bin".
func GetAgentStagingPath(suffix string) string {
return filepath.Join(GetBaseDir(), AgentDir, suffix)
}
// Binary install paths — /usr/local/bin / C:\Program Files\RedFlag.
func GetBinaryInstallDir() string {
if runtime.GOOS == "windows" {
return `C:\Program Files\RedFlag`
}
return "/usr/local/bin"
}
func GetAgentBinaryPath() string {
name := "redflag-agent"
if runtime.GOOS == "windows" {
name = "redflag-agent.exe"
}
return filepath.Join(GetBinaryInstallDir(), name)
}
func GetHelperBinaryPath() string {
name := "redflag-helper"
if runtime.GOOS == "windows" {
name = "redflag-helper.exe"
}
return filepath.Join(GetBinaryInstallDir(), name)
}
func GetDesktopBinaryPath() string {
name := "redflag-desktop"
if runtime.GOOS == "windows" {
name = "redflag-desktop.exe"
}
return filepath.Join(GetBinaryInstallDir(), name)
}

View file

@ -80,6 +80,19 @@ func (b *Buffer) BufferEvent(event *models.SystemEvent) error {
// GetBufferedEvents retrieves and clears the buffer
func (b *Buffer) GetBufferedEvents() ([]*models.SystemEvent, error) {
events, err := b.ReadBufferedEvents()
if err != nil {
return nil, err
}
if err := b.Clear(); err != nil {
// Log warning but don't fail - events were still retrieved
fmt.Printf("Warning: Failed to clear buffer file: %v\n", err)
}
return events, nil
}
// ReadBufferedEvents retrieves buffered events without clearing them.
func (b *Buffer) ReadBufferedEvents() ([]*models.SystemEvent, error) {
b.mu.Lock()
defer b.mu.Unlock()
@ -97,15 +110,20 @@ func (b *Buffer) GetBufferedEvents() ([]*models.SystemEvent, error) {
return nil, fmt.Errorf("failed to unmarshal events: %w", err)
}
// Clear buffer file after reading
if err := os.Remove(b.filePath); err != nil && !os.IsNotExist(err) {
// Log warning but don't fail - events were still retrieved
fmt.Printf("Warning: Failed to clear buffer file: %v\n", err)
}
return events, nil
}
// Clear removes the current buffer file.
func (b *Buffer) Clear() error {
b.mu.Lock()
defer b.mu.Unlock()
if err := os.Remove(b.filePath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// SetMaxSize sets the maximum number of events to buffer
func (b *Buffer) SetMaxSize(size int) {
b.mu.Lock()
@ -132,4 +150,4 @@ func (b *Buffer) GetStats() (int, error) {
}
return len(events), nil
}
}

View file

@ -37,6 +37,14 @@ func NewTeeLogger(buffer *Buffer, agentID uuid.UUID) *TeeLogger {
return &TeeLogger{buffer: buffer, agentID: agentID}
}
// Buffer returns the operational event buffer used by this logger.
func (l *TeeLogger) Buffer() *Buffer {
if l == nil {
return nil
}
return l.buffer
}
// LogParams carries all structured fields for a dual-output event.
type LogParams struct {
Level string // ETHOS level tag: "INFO", "WARNING", "ERROR", "CRITICAL"

View file

@ -225,14 +225,14 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
// restarts the agent — the agent holds no sudo for cp/chmod/systemctl.
func installAgentViaHelper(tempBinaryPath, helperBinaryPath string, params map[string]interface{}, commandID, targetVersion string, startTime time.Time) error {
// Must match the helper's DEFAULT_AGENT_UPGRADE_SOURCE.
const upgradeStagingPath = "/var/lib/redflag/agent/pending-upgrade.bin"
upgradeStagingPath := constants.GetAgentStagingPath("pending-upgrade.bin")
if err := copyFile(tempBinaryPath, upgradeStagingPath); err != nil {
return fmt.Errorf("failed to stage new binary for helper: %w", err)
}
// Stage the helper binary if we downloaded one. The helper will
// self-update from this staged copy before installing the new agent.
const helperStagingPath = "/var/lib/redflag/agent/pending-helper.bin"
helperStagingPath := constants.GetAgentStagingPath("pending-helper.bin")
hasHelper := false
if helperBinaryPath != "" {
if err := copyFile(helperBinaryPath, helperStagingPath); err != nil {

View file

@ -7,6 +7,7 @@ import (
"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/event"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
)
@ -21,7 +22,6 @@ func reportFailure(apiClient *client.Client, cfg *config.Config, ackTracker *ack
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 {
@ -40,6 +40,7 @@ func DispatchCrossPlatformCommand(
cfg *config.Config,
ackTracker *acknowledgment.Tracker,
orch *orchestrator.Orchestrator,
eventBuffer *event.Buffer,
cmd client.Command,
) bool {
var err error
@ -61,11 +62,11 @@ func DispatchCrossPlatformCommand(
case "scan_updates":
err = HandleScanUpdates(apiClient, cfg, ackTracker, orch, cmd.ID)
case "install_updates":
err = HandleInstallUpdates(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
err = HandleInstallUpdates(apiClient, cfg, ackTracker, eventBuffer, 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)
err = HandleConfirmDependencies(apiClient, cfg, ackTracker, eventBuffer, cmd.Params, cmd.ID)
case "enable_heartbeat":
err = HandleEnableHeartbeat(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
case "disable_heartbeat":

View file

@ -7,6 +7,7 @@ import (
"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/event"
"github.com/Fimeg/RedFlag/agent/internal/installer"
"github.com/Fimeg/RedFlag/agent/internal/models"
)
@ -172,15 +173,15 @@ func resolveClosureHashes(packageType, packageName, targetVersion string, depend
// 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) (err error) {
func HandleConfirmDependencies(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, eventBuffer *event.Buffer, params map[string]interface{}, commandID string) (err error) {
packageType, _ := params["package_type"].(string)
packageName, _ := params["package_name"].(string)
// Every error return below surfaces on the History channel as a failed
// install event (ETHOS #1), inward via the client event buffer.
// install event (ETHOS #1), inward via the operational event buffer.
defer func() {
if err != nil {
emitInstallEvent(apiClient, models.SubtypeFailed, models.SeverityError, packageType, packageName, commandID, err.Error())
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeFailed, models.SeverityError, packageType, packageName, commandID, err.Error())
}
}()
@ -279,7 +280,7 @@ func HandleConfirmDependencies(apiClient *client.Client, cfg *config.Config, ack
log.Printf("[WARNING] [agent] [installer] report_install_failed error=%v", reportErr)
}
emitInstallEvent(apiClient, models.SubtypeSuccess, models.SeverityInfo, packageType, packageName, commandID,
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeSuccess, models.SeverityInfo, packageType, packageName, commandID,
fmt.Sprintf("%s of %s completed in %ds", action, packageName, result.DurationSeconds))
log.Printf("[INFO] [agent] [installer] install_complete action=%s package=%s duration=%ds",

View file

@ -9,17 +9,19 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/cache"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/event"
"github.com/Fimeg/RedFlag/agent/internal/installer"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/gofrs/uuid/v5"
)
// emitInstallEvent records a package-install outcome on the system-event
// channel so it reaches the History page (ETHOS #1: errors are history).
// It routes inward through the client's event buffer, which persists to disk
// and flushes to the server independently of the command-result path — a
// It routes inward through the operational event buffer, which persists to disk
// and flushes to the server independently of the command-result path. A
// failed install is auditable even if the result report is lost.
func emitInstallEvent(apiClient *client.Client, subtype, severity, packageType, packageName, commandID, message string) {
apiClient.BufferEvent(
func emitInstallEvent(buffer *event.Buffer, agentID uuid.UUID, subtype, severity, packageType, packageName, commandID, message string) {
event.BufferSystemEvent(buffer, agentID,
models.EventTypeAgentInstall, subtype, severity,
packageType+"_installer", message,
map[string]interface{}{
@ -46,7 +48,7 @@ func FetchExpectedHash(apiClient *client.Client, cfg *config.Config, packageName
return apiClient.GetExpectedHash(packageType, packageName, cfg.AgentID)
}
func HandleInstallUpdates(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) (err error) {
func HandleInstallUpdates(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, eventBuffer *event.Buffer, params map[string]interface{}, commandID string) (err error) {
packageType, _ := params["package_type"].(string)
packageName, _ := params["package_name"].(string)
@ -55,7 +57,7 @@ func HandleInstallUpdates(apiClient *client.Client, cfg *config.Config, ackTrack
// verification, the install itself).
defer func() {
if err != nil {
emitInstallEvent(apiClient, models.SubtypeFailed, models.SeverityError, packageType, packageName, commandID, err.Error())
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeFailed, models.SeverityError, packageType, packageName, commandID, err.Error())
}
}()
@ -132,18 +134,18 @@ func HandleInstallUpdates(apiClient *client.Client, cfg *config.Config, ackTrack
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,
},
}
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)
}
@ -170,7 +172,7 @@ func HandleInstallUpdates(apiClient *client.Client, cfg *config.Config, ackTrack
log.Printf("[WARNING] [agent] [installer] report_failed error=%v", reportErr)
}
emitInstallEvent(apiClient, models.SubtypeSuccess, models.SeverityInfo, packageType, packageName, commandID,
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeSuccess, models.SeverityInfo, packageType, packageName, commandID,
fmt.Sprintf("%s of %s completed in %ds", result.Action, packageName, duration))
log.Printf("[INFO] [agent] [installer] install_complete action=%s type=%s duration=%ds", action, packageType, duration)

View file

@ -22,10 +22,6 @@ func RegisterEnforcer(pkgType string, factory EnforcerFactory) {
enforcers[pkgType] = factory
}
func NewEnforcer(cfg *config.Config) (Enforcer, error) {
return NewEnforcerWithLogger(cfg, nil)
}
func NewEnforcerWithLogger(cfg *config.Config, teeLogger *event.TeeLogger) (Enforcer, error) {
if !cfg.KernelEnforcement.Enabled {
return &noopEnforcer{}, nil

View file

@ -14,26 +14,26 @@ import (
// MigrationPlan represents a complete migration plan
type MigrationPlan struct {
Detection *MigrationDetection `json:"detection"`
TargetVersion string `json:"target_version"`
Config *FileDetectionConfig `json:"config"`
BackupPath string `json:"backup_path"`
EstimatedDuration time.Duration `json:"estimated_duration"`
RiskLevel string `json:"risk_level"` // low, medium, high
Detection *MigrationDetection `json:"detection"`
TargetVersion string `json:"target_version"`
Config *FileDetectionConfig `json:"config"`
BackupPath string `json:"backup_path"`
EstimatedDuration time.Duration `json:"estimated_duration"`
RiskLevel string `json:"risk_level"` // low, medium, high
}
// MigrationResult represents the result of a migration execution
type MigrationResult struct {
Success bool `json:"success"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Duration time.Duration `json:"duration"`
BackupPath string `json:"backup_path"`
MigratedFiles []string `json:"migrated_files"`
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
AppliedChanges []string `json:"applied_changes"`
RollbackAvailable bool `json:"rollback_available"`
Success bool `json:"success"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Duration time.Duration `json:"duration"`
BackupPath string `json:"backup_path"`
MigratedFiles []string `json:"migrated_files"`
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
AppliedChanges []string `json:"applied_changes"`
RollbackAvailable bool `json:"rollback_available"`
}
// MigrationExecutor handles the execution of migration plans
@ -53,16 +53,6 @@ func NewMigrationExecutor(plan *MigrationPlan, configPath string) *MigrationExec
}
}
// NewMigrationExecutorWithEvents creates a new migration executor with event buffering
func NewMigrationExecutorWithEvents(plan *MigrationPlan, logger *event.TeeLogger, configPath string) *MigrationExecutor {
return &MigrationExecutor{
plan: plan,
result: &MigrationResult{},
logger: logger,
stateManager: NewStateManager(configPath),
}
}
// ExecuteMigration executes the complete migration plan
func (e *MigrationExecutor) ExecuteMigration() (*MigrationResult, error) {
e.result.StartTime = time.Now().UTC()
@ -589,4 +579,4 @@ func copyFile(src, dst string) error {
}
return os.Chmod(dst, sourceInfo.Mode())
}
}

View file

@ -10,7 +10,6 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/circuitbreaker"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/event"
"github.com/gofrs/uuid/v5"
)
// Scanner represents a generic update scanner
@ -75,17 +74,6 @@ type Orchestrator struct {
mu sync.RWMutex
}
// NewOrchestrator creates a new scanner orchestrator with a log-only TeeLogger
// (nil buffer, no agent ID). Callers that have a TeeLogger should prefer
// NewOrchestratorWithEvents to attach the agent event buffer.
func NewOrchestrator() *Orchestrator {
return &Orchestrator{
scanners: make(map[string]*ScannerConfig),
inventoryScanners: make(map[string]*InventoryScannerConfig),
logger: event.NewTeeLogger(nil, uuid.Nil),
}
}
// NewOrchestratorWithEvents creates a new scanner orchestrator with event buffering
func NewOrchestratorWithEvents(logger *event.TeeLogger) *Orchestrator {
return &Orchestrator{

View file

@ -35,7 +35,7 @@ func Recover(component string) {
func RecoverWithCallback(component string, callback func(interface{}, []byte)) {
if r := recover(); r != nil {
stack := debug.Stack()
log.Printf("[CRITICAL] [%s] panic_recovered error=%q", component, r)
defaultHandler(component, r, stack)
if callback != nil {
callback(r, stack)
}

View file

@ -8,24 +8,12 @@ import (
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/agent"
"github.com/Fimeg/RedFlag/agent/internal/circuitbreaker"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/Fimeg/RedFlag/agent/internal/kernel"
"github.com/Fimeg/RedFlag/agent/internal/logging"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
"github.com/Fimeg/RedFlag/agent/internal/receipt"
"github.com/Fimeg/RedFlag/agent/internal/recovery"
"github.com/Fimeg/RedFlag/agent/internal/scanner"
"github.com/Fimeg/RedFlag/agent/internal/system"
"github.com/Fimeg/RedFlag/agent/internal/version"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/debug"
"golang.org/x/sys/windows/svc/eventlog"
@ -33,14 +21,14 @@ import (
)
var (
elog debug.Log
elog debug.Log
serviceName = "RedFlagAgent"
)
type redflagService struct {
agent *config.Config
stop chan struct{}
commandHandler *orchestrator.CommandHandler
agent *config.Config
stop chan struct{}
loopCtx *agent.LoopContext
}
func (s *redflagService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) {
@ -114,23 +102,17 @@ loop:
func (s *redflagService) initialize() error {
log.Printf("[INFO] [windows] [service] initialization_starting")
// Initialize security logger (non-critical - log and continue on failure)
securityLogger, err := logging.NewSecurityLogger(s.agent, constants.GetAgentStateDir())
loopCtx, err := agent.NewLoopContext(s.agent, agent.LoopContextOptions{
Ctx: context.Background(),
StopCh: s.stop,
EnableDesktop: false,
})
if err != nil {
log.Printf("[ERROR] [agent] [cmd_handler] security_logger_init_failed error=\"%v\"", err)
elog.Error(1, fmt.Sprintf("Security logger init failed: %v", err))
securityLogger = nil
log.Printf("[ERROR] [windows] [service] context_init_failed error=\"%v\"", err)
elog.Error(1, fmt.Sprintf("Agent context init failed: %v", err))
return fmt.Errorf("failed to initialize agent context: %w", err)
}
// CRITICAL: Initialize command handler with signature verification
// If this fails, we MUST NOT allow the service to run without verification (ETHOS #2)
commandHandler, err := orchestrator.NewCommandHandler(s.agent, constants.GetAgentStateDir(), securityLogger, log.New(os.Stdout, "", log.LstdFlags))
if err != nil {
log.Printf("[ERROR] [agent] [cmd_handler] init_failed error=\"%v\"", err)
elog.Error(1, fmt.Sprintf("Command handler init failed: %v", err))
return fmt.Errorf("failed to initialize command handler: %w", err)
}
s.commandHandler = commandHandler
s.loopCtx = loopCtx
log.Printf("[INFO] [windows] [service] initialization_complete")
return nil
@ -143,99 +125,13 @@ func (s *redflagService) runAgent() {
log.Printf("[INFO] [agent] [service] starting agent_id=%s server=%s interval=%ds",
s.agent.AgentID, s.agent.ServerURL, s.agent.CheckInInterval)
// Initialize API client
apiClient := client.NewClient(s.agent.ServerURL, s.agent.Token)
// Initialize acknowledgment tracker (result acks — pending_acks.json)
ackTracker := acknowledgment.NewTracker(constants.GetAgentStateDir())
if err := ackTracker.Load(); err != nil {
log.Printf("Warning: Failed to load pending acknowledgments: %v", err)
elog.Warning(1, fmt.Sprintf("Failed to load pending acknowledgments: %v", err))
} else {
pendingCount := len(ackTracker.GetPending())
if pendingCount > 0 {
log.Printf("[ACK] Loaded %d pending acknowledgments", pendingCount)
elog.Info(1, fmt.Sprintf("Loaded %d pending acknowledgments", pendingCount))
}
if s.loopCtx == nil {
log.Printf("[ERROR] [agent] [service] polling_loop_missing_context")
elog.Error(1, "Polling loop missing agent context")
return
}
// Initialize receipt tracker (command-receipt confirmation — pending_receipts.json)
receiptTracker := receipt.NewTracker(constants.GetAgentStateDir())
if err := receiptTracker.Load(); err != nil {
log.Printf("[WARNING] [agent] [receipt] load_pending_receipts_failed error=%v", err)
}
// Initialize confirmed completion tracker
confirmedTracker := orchestrator.NewConfirmedTracker(constants.GetAgentStateDir())
if err := confirmedTracker.Load(); err != nil {
log.Printf("[WARNING] [agent] [confirmed] load_confirmed_completed_failed error=%v", err)
}
// Initialize Windows-appropriate scanners and circuit breakers.
// Subsystem config uses zero values (circuit breaker disabled) for any
// fields absent from the Windows config.json — safe per circuitbreaker.Execute.
defaults := config.GetDefaultSubsystemsConfig()
resolve := func(sub, def config.SubsystemConfig) config.SubsystemConfig {
if sub == (config.SubsystemConfig{}) {
return def
}
return sub
}
winSub := resolve(s.agent.Subsystems.Windows, defaults.Windows)
wingetSub := resolve(s.agent.Subsystems.Winget, defaults.Winget)
storageSub := resolve(s.agent.Subsystems.Storage, defaults.Storage)
systemSub := resolve(s.agent.Subsystems.System, defaults.System)
mkCB := func(name string, cb config.CircuitBreakerConfig) *circuitbreaker.CircuitBreaker {
return circuitbreaker.New(name, circuitbreaker.Config{
FailureThreshold: cb.FailureThreshold,
FailureWindow: cb.FailureWindow,
OpenDuration: cb.OpenDuration,
HalfOpenAttempts: cb.HalfOpenAttempts,
})
}
windowsCB := mkCB("Windows Update", winSub.CircuitBreaker)
wingetCB := mkCB("Winget", wingetSub.CircuitBreaker)
storageCB := mkCB("Storage", storageSub.CircuitBreaker)
systemCB := mkCB("System", systemSub.CircuitBreaker)
scanOrchestrator := orchestrator.NewOrchestrator()
storageScanner := orchestrator.NewStorageScanner(version.Version)
systemScanner := orchestrator.NewSystemScanner(version.Version)
scanOrchestrator.RegisterScanner("windows", scanner.NewWindowsUpdateScanner(), windowsCB, winSub.Timeout, winSub.Enabled)
scanOrchestrator.RegisterScanner("winget", scanner.NewWingetScanner(), wingetCB, wingetSub.Timeout, wingetSub.Enabled)
scanOrchestrator.RegisterScanner("storage", storageScanner, storageCB, storageSub.Timeout, storageSub.Enabled)
scanOrchestrator.RegisterScanner("system", systemScanner, systemCB, systemSub.Timeout, systemSub.Enabled)
// Initialize kernel enforcer (Windows WDAC path; non-fatal if unavailable)
kernelEnforcer, err := kernel.NewEnforcer(s.agent)
if err != nil {
log.Printf("[WARNING] [agent] [kernel] enforcer_init_failed error=%v", err)
kernelEnforcer = nil
} else {
log.Printf("[INFO] [agent] [kernel] %s_enforcer_started", kernelEnforcer.GetPackageType())
}
// Use the shared polling loop (CRITICAL-007) with a fully-initialized
// LoopContext. s.stop is passed as StopCh so the loop exits on service stop.
if err := agent.RunPollingLoop(&agent.LoopContext{
Ctx: context.Background(),
Cfg: s.agent,
APIClient: apiClient,
AckTracker: ackTracker,
ReceiptTracker: receiptTracker,
ConfirmedTracker: confirmedTracker,
CommandHandler: s.commandHandler,
ScanOrchestrator: scanOrchestrator,
KernelEnforcer: kernelEnforcer,
CircuitBreakers: map[string]*circuitbreaker.CircuitBreaker{
"windows": windowsCB,
"winget": wingetCB,
"storage": storageCB,
"system": systemCB,
},
StopCh: s.stop,
}); err != nil {
if err := agent.RunPollingLoop(s.loopCtx); err != nil {
log.Printf("[ERROR] [agent] [service] polling_loop_failed error=%v", err)
elog.Error(1, fmt.Sprintf("Polling loop failed: %v", err))
}
@ -292,9 +188,9 @@ func InstallService() error {
// Create service with proper configuration
s, err = m.CreateService(serviceName, exePath, mgr.Config{
DisplayName: "RedFlag Update Agent",
Description: "RedFlag agent for automated system updates and monitoring",
StartType: mgr.StartAutomatic,
DisplayName: "RedFlag Update Agent",
Description: "RedFlag agent for automated system updates and monitoring",
StartType: mgr.StartAutomatic,
Dependencies: []string{"Tcpip", "Dnscache"},
})
if err != nil {
@ -437,135 +333,6 @@ func ServiceStatus() error {
return nil
}
// Helper functions - these implement the same functionality as in main.go but adapted for service mode
// Polling interval selection and reconnect backoff now live in the shared
// loop (internal/agent/loop.go), which runAgent() delegates to via
// agent.RunPollingLoop. The previous per-service copies were removed as part
// of the CRITICAL-007 deduplication so there is one resilience implementation.
// getConfigPath returns the platform-specific config path
func (s *redflagService) getConfigPath() string {
return constants.GetAgentConfigPath()
}
// renewTokenIfNeeded handles 401 errors by renewing the agent token using refresh token
func (s *redflagService) renewTokenIfNeeded(apiClient *client.Client, err error) (*client.Client, error) {
if err != nil && strings.Contains(err.Error(), "401 Unauthorized") {
log.Printf("[INFO] [agent] [service]Access token expired - attempting renewal with refresh token...")
elog.Info(1, "Access token expired - attempting renewal with refresh token")
// Check if we have a refresh token
if s.agent.RefreshToken == "" {
log.Printf("[ERROR] [agent] [service]No refresh token available - re-registration required")
elog.Error(1, "No refresh token available - re-registration required")
return nil, fmt.Errorf("refresh token missing - please re-register agent")
}
// Create temporary client without token for renewal
tempClient := client.NewClient(s.agent.ServerURL, "")
// Attempt to renew access token using refresh token
if err := tempClient.RenewToken(s.agent.AgentID, s.agent.RefreshToken, version.Version); err != nil {
log.Printf("[ERROR] [agent] [service]Refresh token renewal failed: %v", err)
elog.Error(1, fmt.Sprintf("Refresh token renewal failed: %v", err))
log.Printf("[WARNING] [agent] [service]Refresh token may be expired (>90 days) - re-registration required")
return nil, fmt.Errorf("refresh token renewal failed: %w - please re-register agent", err)
}
// Update config with new access token (agent ID and refresh token stay the same!)
s.agent.Token = tempClient.GetToken()
// Save updated config
configPath := s.getConfigPath()
if err := s.agent.Save(configPath); err != nil {
log.Printf("[WARNING] [agent] [service]Warning: Failed to save renewed access token: %v", err)
elog.Error(1, fmt.Sprintf("Failed to save renewed access token: %v", err))
}
log.Printf("[INFO] [agent] [service]Access token renewed successfully - agent ID maintained: %s", s.agent.AgentID)
elog.Info(1, fmt.Sprintf("Access token renewed successfully - agent ID maintained: %s", s.agent.AgentID))
return tempClient, nil
}
// Return original client if no 401 error
return apiClient, nil
}
// reportSystemInfo collects and reports detailed system information to the server
func (s *redflagService) reportSystemInfo(apiClient *client.Client) error {
// Collect detailed system information
sysInfo, err := system.GetSystemInfo(version.Version)
if err != nil {
return fmt.Errorf("failed to get system info: %w", err)
}
// Create system info report
report := client.SystemInfoReport{
Timestamp: time.Now().UTC(),
CPUModel: sysInfo.CPUInfo.ModelName,
CPUCores: sysInfo.CPUInfo.Cores,
CPUThreads: sysInfo.CPUInfo.Threads,
MemoryTotal: sysInfo.MemoryInfo.Total,
DiskTotal: uint64(0),
DiskUsed: uint64(0),
IPAddress: sysInfo.IPAddress,
Processes: sysInfo.RunningProcesses,
Uptime: sysInfo.Uptime,
Metadata: make(map[string]interface{}),
}
// Add primary disk info
if len(sysInfo.DiskInfo) > 0 {
primaryDisk := sysInfo.DiskInfo[0]
report.DiskTotal = primaryDisk.Total
report.DiskUsed = primaryDisk.Used
report.Metadata["disk_mount"] = primaryDisk.Mountpoint
report.Metadata["disk_filesystem"] = primaryDisk.Filesystem
}
// Add collection timestamp and additional metadata
report.Metadata["collected_at"] = time.Now().UTC().Format(time.RFC3339)
report.Metadata["hostname"] = sysInfo.Hostname
report.Metadata["os_type"] = sysInfo.OSType
report.Metadata["os_version"] = sysInfo.OSVersion
report.Metadata["os_architecture"] = sysInfo.OSArchitecture
// Add any existing metadata from system info
for key, value := range sysInfo.Metadata {
report.Metadata[key] = value
}
// Report to server
if err := apiClient.ReportSystemInfo(s.agent.AgentID, report); err != nil {
return fmt.Errorf("failed to report system info: %w", err)
}
return nil
}
// reportLogWithAck reports a command log to the server and tracks it for acknowledgment
// This ensures at-least-once delivery of command results
func (s *redflagService) reportLogWithAck(apiClient *client.Client, ackTracker *acknowledgment.Tracker, logReport client.LogReport) error {
// Track this command result as pending acknowledgment
ackTracker.Add(logReport.CommandID)
// Save acknowledgment state immediately
if err := ackTracker.Save(); err != nil {
log.Printf("Warning: Failed to save acknowledgment for command %s: %v", logReport.CommandID, err)
elog.Warning(1, fmt.Sprintf("Failed to save acknowledgment for command %s: %v", logReport.CommandID, err))
}
// Report the log to the server
if err := apiClient.ReportLog(s.agent.AgentID, logReport); err != nil {
// If reporting failed, increment retry count but don't remove from pending
ackTracker.IncrementRetry(logReport.CommandID)
return err
}
return nil
}
func RunConsole(cfg *config.Config) error {
log.Printf("[INFO] [agent] [service]RedFlag Agent starting in console mode...")
log.Printf("Press Ctrl+C to stop")
@ -605,4 +372,4 @@ func RunConsole(cfg *config.Config) error {
log.Printf("Agent stopped")
return nil
}
}

View file

@ -14,6 +14,16 @@ import (
"strings"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
// Staging paths are derived from constants.GetAgentStagingPath so they are
// platform-aware. The cross-language contract with the Rust helper
// (helper/src/main.rs DEFAULT_* constants) uses the same suffix values.
var (
agentSelfStagingPath = constants.GetAgentStagingPath("pending-upgrade.bin")
helperSelfStagingPath = constants.GetAgentStagingPath("pending-helper.bin")
desktopSelfStagingPath = constants.GetAgentStagingPath("pending-desktop.bin")
)
const (
@ -23,20 +33,6 @@ const (
selfUpdateOperation = "upgrade"
agentSelfStagingPath = "/var/lib/redflag/agent/pending-upgrade.bin"
// helperSelfStagingPath is a cross-language contract: the Go agent stages the
// new helper binary here, and the Rust helper reads it on self-upgrade.
// The Rust counterpart is DEFAULT_HELPER_SELF_SOURCE in helper/src/main.rs.
// Both must be updated together if this path changes.
helperSelfStagingPath = "/var/lib/redflag/agent/pending-helper.bin"
// desktopSelfStagingPath is a cross-language contract: the agent stages the
// new desktop binary here and the Rust helper reads it on desktop-self
// install. The Rust counterpart is DEFAULT_DESKTOP_SELF_SOURCE in
// helper/src/main.rs. Both must be updated together if this path changes.
desktopSelfStagingPath = "/var/lib/redflag/agent/pending-desktop.bin"
maxSelfUpdateBinarySize = 500 * 1024 * 1024
)
@ -240,21 +236,31 @@ func desktopBinaryPath() (string, error) {
}
func signalDesktopRestart(target string) error {
if runtime.GOOS != "linux" {
name := filepath.Base(target)
var cmd *exec.Cmd
switch {
case runtime.GOOS == "linux":
cmd = exec.Command("pkill", "-x", name)
case runtime.GOOS == "windows":
cmd = exec.Command("taskkill", "/F", "/IM", name)
default:
return nil
}
name := filepath.Base(target)
cmd := exec.Command("pkill", "-x", name)
out, err := cmd.CombinedOutput()
if err == nil {
log.Printf("[INFO] [agent] [supplychain] desktop_restart_signal_sent binary=%s", name)
return nil
}
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
log.Printf("[INFO] [agent] [supplychain] desktop_restart_signal_skipped binary=%s reason=not_running", name)
return nil
// pkill exit 1 = no process matched; taskkill exit 128 = process not found.
// Both are non-errors — the tray wasn't running.
if exitErr, ok := err.(*exec.ExitError); ok {
if (runtime.GOOS == "linux" && exitErr.ExitCode() == 1) ||
(runtime.GOOS == "windows" && exitErr.ExitCode() == 128) {
log.Printf("[INFO] [agent] [supplychain] desktop_restart_signal_skipped binary=%s reason=not_running", name)
return nil
}
}
return fmt.Errorf("pkill -x %s failed: %w output=%s", name, err, strings.TrimSpace(string(out)))
return fmt.Errorf("signal restart %s failed: %w output=%s", name, err, strings.TrimSpace(string(out)))
}
func copyRegularFile(src, dst string) error {

View file

@ -20,6 +20,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
@ -170,17 +171,23 @@ func (e *Executor) Execute(ctx context.Context, token *capability.Token, extraAr
defer os.Remove(tokPath)
defer os.Remove(resPath) // best-effort removal of stale results
// Invoke the helper via sudo systemd-run --wait (no --pipe) so it runs as a
// transient service with its own mount namespace, escaping the agent's
// ProtectSystem=strict sandbox. Without --pipe, no SCM_RIGHTS fd-passing
// crosses dbus, working around dbus-broker 37's MSG_CTRUNC bug. The token
// and result paths are passed as arguments so the helper reads/writes files
// on the real filesystem that both the agent and the transient unit can see.
args := []string{"systemd-run", "--wait",
"--property=ProtectSystem=no",
"--", e.BinaryPath, "--token-file", tokPath, "--result-file", resPath}
args = append(args, extraArgs...)
cmd := exec.CommandContext(runCtx, "sudo", args...)
// Invoke the helper. On Linux this goes through sudo systemd-run --wait
// so the helper escapes the agent's ProtectSystem=strict sandbox. On
// Windows the helper runs as a direct child process (both run as SYSTEM).
// Without --pipe, no SCM_RIGHTS fd-passing crosses dbus, working around
// dbus-broker 37's MSG_CTRUNC bug.
var cmd *exec.Cmd
if runtime.GOOS == "linux" {
args := []string{"systemd-run", "--wait",
"--property=ProtectSystem=no",
"--", e.BinaryPath, "--token-file", tokPath, "--result-file", resPath}
args = append(args, extraArgs...)
cmd = exec.CommandContext(runCtx, "sudo", args...)
} else {
args := []string{"--token-file", tokPath, "--result-file", resPath}
args = append(args, extraArgs...)
cmd = exec.CommandContext(runCtx, e.BinaryPath, args...)
}
var stderr bytes.Buffer
cmd.Stderr = &stderr

View file

@ -1,6 +1,6 @@
[package]
name = "redflag-desktop"
version = "0.2.8"
version = "0.2.9"
edition = "2021"
publish = false

View file

@ -95,6 +95,31 @@ fn local_status() -> Result<LocalSnapshot, String> {
Ok(LocalSnapshot { identity, status })
}
#[derive(Debug, Serialize, Deserialize)]
struct TriggerScanResponse {
accepted: bool,
#[serde(default)]
error: Option<String>,
}
#[tauri::command]
fn trigger_scan() -> Result<TriggerScanResponse, String> {
let body = local_post("/v1/actions/trigger-scan", b"")?;
serde_json::from_str(&body).map_err(|err| format!("decode trigger-scan response: {err}"))
}
#[derive(Debug, Serialize, Deserialize)]
struct ApproveUpdateRequest {
update_id: String,
}
#[tauri::command]
fn approve_update(request: ApproveUpdateRequest) -> Result<String, String> {
let body_bytes =
serde_json::to_vec(&request).map_err(|err| format!("encode approve-update request: {err}"))?;
local_post("/v1/actions/approve-update", &body_bytes)
}
fn local_get_json<T: for<'de> Deserialize<'de>>(path: &str) -> Result<T, String> {
let body = local_get(path)?;
serde_json::from_str(&body).map_err(|err| format!("decode local API response: {err}"))
@ -267,13 +292,14 @@ fn main() {
let window_open = Arc::new(AtomicBool::new(true));
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![local_status])
.invoke_handler(tauri::generate_handler![local_status, trigger_scan, approve_update])
.setup(move |app| {
let show_i = MenuItem::with_id(app, "show", "Show RedFlag", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_i, &quit_i])?;
let window_open_clone = window_open.clone();
let window_open_tray = window_open.clone();
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
@ -286,18 +312,32 @@ fn main() {
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
.on_tray_icon_event(move |tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
window_open_tray.store(true, Ordering::Relaxed);
show_main_window(tray.app_handle());
}
})
.build(app)?;
// Hide to tray on close instead of exiting; track visibility.
let window_open_close = window_open.clone();
if let Some(window) = app.get_webview_window("main") {
let value = window.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
window_open_close.store(false, Ordering::Relaxed);
let _ = value.hide();
}
});
}
// Spawn health reporting thread — POSTs to /v1/desktop every 30s.
let window_open_health = window_open.clone();
std::thread::spawn(move || {

View file

@ -21,7 +21,7 @@ services:
context: .
dockerfile: ./server/Dockerfile
args:
BUILD_VERSION: ${BUILD_VERSION:-0.2.8.4}
BUILD_VERSION: ${BUILD_VERSION:-0.2.9.0}
container_name: redflag-server
volumes:
- server-config:/app/config

1
helper/Cargo.lock generated
View file

@ -216,6 +216,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"subtle",
]
[[package]]

View file

@ -1,6 +1,6 @@
[package]
name = "redflag-helper"
version = "0.2.8"
version = "0.2.9"
edition = "2021"
publish = false

View file

@ -1,4 +1,6 @@
// redflag-helper — capability-token executor (keystone).
// Windows target: stub binary (self-upgrade not yet ported — handled by installer).
#![cfg_attr(windows, allow(dead_code, unused_variables))]
//
// Reads one Ed25519-signed capability token from stdin, verifies it against a
// locally pinned trusted keyring, verifies every artifact hash it can reach on
@ -13,6 +15,7 @@
use std::collections::BTreeSet;
use std::fs;
use std::io::Read;
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::process::Command;
@ -23,6 +26,15 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
#[cfg(windows)]
fn main() {
eprintln!(
"redflag-helper: Windows self-upgrade not yet implemented. \
Desktop and agent updates on Windows are handled by the installer."
);
std::process::exit(0);
}
const SUPPORTED_TOKEN_VERSION: u32 = 1;
// Exit codes double as deny taxonomy. 0 = the one operation ran and exited 0.
@ -235,19 +247,22 @@ fn validate_trusted_path_as(path: &Path, required_uid: u32) -> Result<(), Denial
format!("{} — symlinked trust inputs are refused", path.display()),
));
}
if meta.uid() != required_uid {
return Err(Denial::new(
EXIT_TRUST_PATH,
"trusted_path_wrong_owner",
format!("{} uid={} required={}", path.display(), meta.uid(), required_uid),
));
}
if meta.mode() & 0o022 != 0 {
return Err(Denial::new(
EXIT_TRUST_PATH,
"trusted_path_writable",
format!("{} mode={:o} — group/other write on a trust input", path.display(), meta.mode() & 0o7777),
));
#[cfg(unix)]
{
if meta.uid() != required_uid {
return Err(Denial::new(
EXIT_TRUST_PATH,
"trusted_path_wrong_owner",
format!("{} uid={} required={}", path.display(), meta.uid(), required_uid),
));
}
if meta.mode() & 0o022 != 0 {
return Err(Denial::new(
EXIT_TRUST_PATH,
"trusted_path_writable",
format!("{} mode={:o} — group/other write on a trust input", path.display(), meta.mode() & 0o7777),
));
}
}
Ok(())
}
@ -602,12 +617,10 @@ fn emit_result_to_file(result: &PolicyResult, path: &str) {
// The result directory is 0700 agent-owned, which already blocks other
// local users. The file itself must be world-readable because the helper
// runs as root (via systemd-run) and the agent needs to read it.
match std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o644)
.open(path)
let mut o = std::fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)] { o.mode(0o644); }
match o.open(path)
{
Ok(mut f) => {
if let Err(e) = std::io::Write::write_all(&mut f, json.as_bytes()) {
@ -672,6 +685,7 @@ fn atomic_replace_binary(staged: &str, install: &str, op: &'static str) -> Resul
let tmp = format!("{}.new", install);
fs::copy(staged, &tmp)
.map_err(|e| Denial::new(EXIT_EXEC_FAILED, op, format!("{} -> {}: {}", staged, tmp, e)))?;
#[cfg(unix)]
if let Err(e) = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755)) {
let _ = fs::remove_file(&tmp);
return Err(Denial::new(EXIT_EXEC_FAILED, op, format!("chmod {}: {}", tmp, e)));
@ -1278,13 +1292,16 @@ fn load_mint_key(path: &Path) -> Result<SigningKey, Denial> {
let meta = fs::metadata(path).map_err(|e| {
Denial::new(EXIT_MINT_KEY, "mint_key_unavailable", format!("{}: {}", path.display(), e))
})?;
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
return Err(Denial::new(
EXIT_MINT_KEY,
"mint_key_permissions_unsafe",
format!("{} mode={:o} — must not be group/other accessible", path.display(), mode & 0o777),
));
#[cfg(unix)]
{
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
return Err(Denial::new(
EXIT_MINT_KEY,
"mint_key_permissions_unsafe",
format!("{} mode={:o} — must not be group/other accessible", path.display(), mode & 0o777),
));
}
}
let raw = fs::read_to_string(path)
.map_err(|e| Denial::new(EXIT_MINT_KEY, "mint_key_read_failed", format!("{}: {}", path.display(), e)))?;
@ -1434,11 +1451,10 @@ fn mint_journal_append(journal: &Path, entry: &serde_json::Value) -> Result<(),
})?;
}
let line = format!("{}\n", entry);
let mut f = fs::OpenOptions::new()
.append(true)
.create(true)
.mode(0o640)
.open(journal)
let mut j = fs::OpenOptions::new();
j.append(true).create(true);
#[cfg(unix)] { j.mode(0o640); }
let mut f = j.open(journal)
.map_err(|e| Denial::new(EXIT_INTERNAL, "mint_journal_open_failed", format!("{}: {}", journal.display(), e)))?;
std::io::Write::write_all(&mut f, line.as_bytes())
.map_err(|e| Denial::new(EXIT_INTERNAL, "mint_journal_write_failed", format!("{}: {}", journal.display(), e)))
@ -1533,11 +1549,10 @@ fn run_mint_init_key(paths: &MintPaths) -> Result<(), Denial> {
Denial::new(EXIT_INTERNAL, "mint_key_dir_failed", format!("{}: {}", parent.display(), e))
})?;
}
let mut f = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&paths.key_path)
let mut k = fs::OpenOptions::new();
k.write(true).create_new(true);
#[cfg(unix)] { k.mode(0o600); }
let mut f = k.open(&paths.key_path)
.map_err(|e| Denial::new(EXIT_MINT_KEY, "mint_key_create_failed", format!("{}: {}", paths.key_path.display(), e)))?;
std::io::Write::write_all(&mut f, hex::encode(seed).as_bytes())
.map_err(|e| Denial::new(EXIT_MINT_KEY, "mint_key_write_failed", e.to_string()))?;
@ -1653,7 +1668,10 @@ fn run_mint_cli(args: &[String]) -> i32 {
};
// 0644: the tokens dir is agent-owned 0700; the unprivileged agent
// must read the token back to feed the normal consumer path.
match fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o644).open(token_out) {
let mut t = fs::OpenOptions::new();
t.write(true).create(true).truncate(true);
#[cfg(unix)] { t.mode(0o644); }
match t.open(token_out) {
Ok(mut f) => {
if let Err(e) = std::io::Write::write_all(&mut f, json.as_bytes()) {
log_error(&format!("mint_token_write_failed path={} error={}", token_out, e));
@ -2051,6 +2069,7 @@ fn run_verify_binary(args: &[String]) -> i32 {
}
}
#[cfg(unix)]
fn main() {
let args: Vec<String> = std::env::args().collect();

View file

@ -310,7 +310,7 @@ func main() {
}
// Sign desktop binaries (Tauri system tray + local UI shell).
// Same pattern as helper: stored under "desktop-linux", listed in
// Same pattern as helper: stored under "desktop-<os>", listed in
// the release manifest. Missing binary is non-fatal — installer skips.
desktopArches := []string{"amd64"}
for _, arch := range desktopArches {
@ -320,6 +320,12 @@ func main() {
} else {
log.Printf("[system] Signed desktop binary: desktop-linux-%s", arch)
}
winDesktopPath := filepath.Join("/app", "binaries", "windows-"+arch, "redflag-desktop.exe")
if _, err := buildOrchestrator.SignExistingBinary(winDesktopPath, version.AgentVersion, "desktop-windows", arch); err != nil {
log.Printf("[WARNING] Failed to sign desktop binary (desktop-windows-%s): %v", arch, err)
} else {
log.Printf("[system] Signed desktop binary: desktop-windows-%s", arch)
}
}
} else {
log.Printf("[WARNING] BuildOrchestratorService not initialized - signing disabled")
@ -616,7 +622,7 @@ func main() {
// Desktop app (Tauri system tray + local UI shell). Optional — 404
// if not built for this arch, installer skips gracefully.
api.GET("/desktop/:arch", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadDesktop)
api.GET("/desktop/:platform/:arch", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadDesktop)
// Package artifact download (for hash computation at approval time)
api.GET("/downloads/artifact", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadPackageArtifact)

View file

@ -351,22 +351,25 @@ func (h *DownloadHandler) DownloadHelper(c *gin.Context) {
}
// DownloadDesktop serves the Tauri desktop app (system tray + local UI shell).
// The desktop binary is optional — if not built for a given arch, returns 404
// gracefully so the installer can skip it.
// The desktop binary is optional — if not built for a given platform/arch,
// returns 404 gracefully so the installer can skip it.
func (h *DownloadHandler) DownloadDesktop(c *gin.Context) {
platform := c.Param("platform")
arch := c.Param("arch")
version := c.Query("version")
if version == "" || version == "latest" {
version = serverVersion.AgentVersion
}
validPlatform := map[string]bool{"linux": true, "windows": true}
validArch := map[string]bool{"amd64": true, "arm64": true}
if !validArch[arch] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or unsupported architecture"})
if !validPlatform[platform] || !validArch[arch] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or unsupported platform/architecture"})
return
}
signedPackage, err := h.packageQueries.GetSignedPackage(version, "desktop-linux", arch)
pkgName := "desktop-" + platform
signedPackage, err := h.packageQueries.GetSignedPackage(version, pkgName, arch)
if err != nil || signedPackage == nil {
// Desktop binary is optional — 404 lets the installer skip gracefully.
c.JSON(http.StatusNotFound, gin.H{"error": "No desktop binary available", "arch": arch, "version": version})

View file

@ -254,11 +254,13 @@ func (h *RegistrationTokenHandler) GetAgentsBoundToToken(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"agents": agents, "count": len(agents)})
}
// RevokeRegistrationToken revokes a registration token
// RevokeRegistrationToken revokes a registration token by its UUID.
// The route param (:token) carries the row UUID from the UI — the secret
// plaintext never travels on the wire for this operation.
func (h *RegistrationTokenHandler) RevokeRegistrationToken(c *gin.Context) {
token := c.Param("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Token is required"})
tokenID, err := uuid.FromString(c.Param("token"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token id (expected UUID)"})
return
}
@ -273,10 +275,9 @@ func (h *RegistrationTokenHandler) RevokeRegistrationToken(c *gin.Context) {
reason = "Revoked via API"
}
err := h.tokenQueries.RevokeRegistrationToken(token, reason)
if err != nil {
if err.Error() == "token not found or already used/revoked" {
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found or already used/revoked"})
if err := h.tokenQueries.RevokeRegistrationTokenByID(tokenID, reason); err != nil {
if err.Error() == "token not found" {
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token"})
}

View file

@ -0,0 +1,14 @@
-- Migration 060 rollback: Recreate token_seats (originally from migration 036).
-- This table was never used by application code, so the rollback exists only
-- to satisfy the migration runner's down-path contract.
CREATE TABLE IF NOT EXISTS token_seats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_id UUID NOT NULL REFERENCES registration_tokens(id) ON DELETE CASCADE,
seat_number INT NOT NULL,
used_by_agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
used_at TIMESTAMPTZ,
UNIQUE(token_id, seat_number)
);
CREATE INDEX IF NOT EXISTS idx_token_seats_token_id ON token_seats(token_id);
CREATE INDEX IF NOT EXISTS idx_token_seats_agent_id ON token_seats(used_by_agent_id);

View file

@ -0,0 +1,5 @@
-- Migration 060: Drop the token_seats table.
-- Created by migration 036 but never referenced by any Go code outside test files.
-- Seat tracking lives on registration_tokens.seats_used (incremented by the
-- mark_registration_token_used stored procedure). token_seats is dead weight.
DROP TABLE IF EXISTS token_seats CASCADE;

View file

@ -308,7 +308,7 @@ func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) (
return tokens, nil
}
// RevokeRegistrationToken revokes a token (can revoke tokens in any status).
// RevokeRegistrationToken revokes a token by its plaintext value.
//
// INVARIANT — no hidden cascade: this only flips the token row to status='revoked'.
// It deliberately does NOT touch refresh_tokens for agents that previously used
@ -345,6 +345,40 @@ func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string)
return nil
}
// RevokeRegistrationTokenByID revokes a token by its UUID primary key.
//
// The UI sends the row UUID (not the plaintext token string), so this is the
// correct path for operator-initiated revokes from the dashboard. The same
// no-cascade invariant applies: only the registration_tokens row is flipped;
// agents already enrolled keep their refresh tokens until explicitly revoked
// via RevokeAllAgentTokens.
func (q *RegistrationTokenQueries) RevokeRegistrationTokenByID(id uuid.UUID, reason string) error {
query := `
UPDATE registration_tokens
SET status = 'revoked',
revoked = true,
revoked_at = NOW(),
revoked_reason = $1
WHERE id = $2
`
result, err := q.db.Exec(query, reason, id)
if err != nil {
return fmt.Errorf("failed to revoke token: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("token not found")
}
return nil
}
// DeleteRegistrationToken permanently deletes a token from the database
func (q *RegistrationTokenQueries) DeleteRegistrationToken(tokenID uuid.UUID) error {
query := `DELETE FROM registration_tokens WHERE id = $1`

View file

@ -1,7 +1,7 @@
package queries_test
// registration_tokens_no_cascade_test.go — Lock the no-cascade invariant on
// RevokeRegistrationToken.
// RevokeRegistrationToken and RevokeRegistrationTokenByID.
//
// The registration_token and refresh_token credentials are deliberately kept
// on separate lifecycles (see docs/AGENT_LIFECYCLE.md "Revocation"). Revoking
@ -31,10 +31,21 @@ const revokeRegistrationTokenQuery = `
revoked = true,
revoked_at = NOW(),
revoked_reason = $1
WHERE token = $2
WHERE token_hash = $2
`
// cascadeIndicators lists SQL tokens that would mean RevokeRegistrationToken
// revokeRegistrationTokenByIDQuery is a verbatim copy of the query in
// queries/registration_tokens.go RevokeRegistrationTokenByID. Keep in sync.
const revokeRegistrationTokenByIDQuery = `
UPDATE registration_tokens
SET status = 'revoked',
revoked = true,
revoked_at = NOW(),
revoked_reason = $1
WHERE id = $2
`
// cascadeIndicators lists SQL tokens that would mean a revoke function
// is reaching into agent credentials. Presence of ANY of these in the query
// body indicates the invariant has been violated.
var cascadeIndicators = []string{
@ -67,3 +78,29 @@ func TestRevokeRegistrationTokenOnlyTouchesRegistrationTokensTable(t *testing.T)
"revocation behavior changed. Sync with registration_tokens.go.")
}
}
func TestRevokeRegistrationTokenByIDHasNoRefreshTokenCascade(t *testing.T) {
q := strings.ToLower(revokeRegistrationTokenByIDQuery)
for _, ind := range cascadeIndicators {
if strings.Contains(q, strings.ToLower(ind)) {
t.Errorf("RevokeRegistrationTokenByID query touches %q — hidden cascade. "+
"The registration_token and refresh_token lifecycles must stay separate. "+
"See docs/AGENT_LIFECYCLE.md 'Revocation'.", ind)
}
}
}
func TestRevokeRegistrationTokenByIDOnlyTouchesRegistrationTokensTable(t *testing.T) {
q := strings.ToLower(revokeRegistrationTokenByIDQuery)
if !strings.Contains(q, "registration_tokens") {
t.Fatal("by-ID query no longer mentions registration_tokens — copy in this test is stale")
}
if !strings.Contains(q, "status = 'revoked'") {
t.Error("by-ID query no longer sets status='revoked' — copy in this test is stale, or the " +
"revocation behavior changed. Sync with registration_tokens.go.")
}
// Must key on id, not token_hash or plaintext token
if !strings.Contains(q, "where id =") {
t.Error("by-ID query does not filter by id — either the copy is stale or the wrong query was used")
}
}

View file

@ -56,7 +56,7 @@ var PublicPathSet = map[string]bool{
"/api/v1/install/:platform": true,
"/api/v1/manifest": true,
"/api/v1/helper/:arch": true,
"/api/v1/desktop/:arch": true,
"/api/v1/desktop/:platform/:arch": true,
"/api/v1/downloads/artifact": true,
}

View file

@ -637,7 +637,93 @@ if (-not $SkipServiceInstall) {
Start-Service -Name $ServiceName
}
# Step 7: Download and install desktop app (system tray + local dashboard).
# The desktop binary is optional — the installer proceeds without it if the
# server has no build for this platform.
$DesktopBinary = "redflag-desktop.exe"
$DesktopURL = "$ServerUrl/api/v1/desktop/windows/amd64?version=$Version"
$DesktopBinaryPath = Join-Path $InstallDir $DesktopBinary
Write-Host
Write-Host "Downloading desktop app (system tray + local dashboard)..." -ForegroundColor Yellow
try {
$TmpDesktop = Join-Path $env:TEMP "redflag-desktop-download.exe"
$DesktopResp = Invoke-WebRequest -Uri $DesktopURL -OutFile $TmpDesktop -UseBasicParsing -PassThru
# Verify desktop binary hash against the signed release manifest.
$DesktopHash = (Get-FileHash -Path $TmpDesktop -Algorithm SHA256).Hash.ToLower()
$ManifestDesktop = $Manifest.artifacts | Where-Object { $_.platform -eq "desktop-windows" -and $_.architecture -eq $ArchTag }
if ($ManifestDesktop) {
if ($DesktopHash -ne $ManifestDesktop.sha256.ToLower()) {
Write-Error "Desktop binary hash does not match signed manifest — refusing to install."
Write-Error " expected: $($ManifestDesktop.sha256.ToLower())"
Write-Error " actual: $DesktopHash"
Remove-Item $TmpDesktop -Force
exit 1
}
Write-Host "✓ Desktop hash verified against signed manifest" -ForegroundColor Green
} else {
Write-Host "[WARN] [installer] [desktop] No manifest entry for desktop-windows/$ArchTag — hash not verified" -ForegroundColor Yellow
}
Move-Item -Path $TmpDesktop -Destination $DesktopBinaryPath -Force
Write-Host "✓ Desktop app installed to $DesktopBinaryPath" -ForegroundColor Green
} catch {
if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) {
Write-Host " Desktop app not available for this platform — skipping." -ForegroundColor Gray
} else {
Write-Host "[WARN] [installer] [desktop] Download failed: $($_.Exception.Message) — skipping desktop install." -ForegroundColor Yellow
}
}
# Step 8: Register desktop autostart for the interactive user.
# The Run key launches redflag-desktop.exe on logon so the tray icon appears.
# Must target HKCU (per-user); the installer runs elevated so we resolve the
# original non-elevated user via the calling process chain.
if (Test-Path $DesktopBinaryPath) {
Write-Host "Registering desktop autostart..." -ForegroundColor Yellow
try {
# Walk up the process tree to find the non-elevated caller.
$InteractiveSID = $null
$CurrentPID = [System.Diagnostics.Process]::GetCurrentProcess().Id
$Visited = @{}
while ($CurrentPID -and !$Visited[$CurrentPID]) {
$Visited[$CurrentPID] = $true
try {
$Proc = Get-CimInstance Win32_Process -Filter "ProcessId = $CurrentPID" -ErrorAction Stop
if ($Proc.Name -in @('explorer.exe', 'pwsh.exe', 'powershell.exe') -and $Proc.SessionId -ne 0) {
$InteractiveSID = ([System.Security.Principal.NTAccount]$Proc.GetOwner().User).Translate([System.Security.Principal.SecurityIdentifier]).Value
break
}
$CurrentPID = $Proc.ParentProcessId
} catch { break }
}
if (-not $InteractiveSID) {
# Fallback: use the SID of the active console session user.
$ConsoleSession = (Get-CimInstance Win32_ComputerSystem).UserName
if ($ConsoleSession) {
$InteractiveSID = ([System.Security.Principal.NTAccount]$ConsoleSession).Translate([System.Security.Principal.SecurityIdentifier]).Value
}
}
if ($InteractiveSID) {
$RunKey = "registry::HKEY_USERS\$InteractiveSID\Software\Microsoft\Windows\CurrentVersion\Run"
New-Item -Path $RunKey -Force | Out-Null
Set-ItemProperty -Path $RunKey -Name "RedFlagDesktop" -Value "`"$DesktopBinaryPath`"" -Type String
Write-Host "✓ Desktop autostart registered for interactive user" -ForegroundColor Green
} else {
Write-Host "[WARN] [installer] [desktop] Could not resolve interactive user SID — autostart not registered." -ForegroundColor Yellow
Write-Host " To launch manually: $DesktopBinaryPath" -ForegroundColor Gray
}
} catch {
Write-Host "[WARN] [installer] [desktop] Autostart registration failed: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host " To launch manually: $DesktopBinaryPath" -ForegroundColor Gray
}
}
Write-Host
Write-Host "✓ Installation complete!" -ForegroundColor Green
Write-Host "Agent is running. Check status with: Get-Service $ServiceName"
Write-Host "View logs with: Get-Content $ConfigDir\logs\agent.log -Tail 100 -Wait"
if (Test-Path $DesktopBinaryPath) {
Write-Host "Desktop tray app installed. Launch it from the Start Menu or reboot."
}

View file

@ -15,8 +15,8 @@ import (
// tag — the release gate enforces this. ldflags may override at build time;
// the release pipeline injects the tag so binaries and source agree.
var (
AgentVersion = "0.2.8.4"
ConfigVersion = "0.2.8.4"
AgentVersion = "0.2.9.0"
ConfigVersion = "0.2.9.0"
MinAgentVersion = "0.1.22"
)

272
web/package-lock.json generated
View file

@ -19,7 +19,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^6.30.0",
"react-router-dom": "6.30.4",
"tailwind-merge": "^2.0.0",
"zustand": "^5.0.8"
},
@ -341,9 +341,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@ -359,9 +359,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@ -377,9 +377,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@ -395,9 +395,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@ -413,9 +413,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@ -431,9 +431,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@ -449,9 +449,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@ -467,9 +467,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@ -485,9 +485,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@ -503,9 +503,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@ -521,9 +521,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@ -539,9 +539,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@ -557,9 +557,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@ -575,9 +575,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@ -593,9 +593,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@ -611,9 +611,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@ -629,9 +629,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@ -647,9 +647,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@ -665,9 +665,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@ -683,9 +683,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@ -701,9 +701,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@ -719,9 +719,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@ -737,9 +737,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@ -755,9 +755,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@ -773,9 +773,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@ -791,9 +791,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@ -1090,9 +1090,9 @@
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@ -3267,9 +3267,9 @@
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@ -3282,32 +3282,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escalade": {
@ -3690,16 +3690,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@ -3941,9 +3941,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@ -4172,10 +4172,20 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@ -5366,12 +5376,12 @@
"peer": true
},
"node_modules/react-router": {
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.2"
"@remix-run/router": "1.23.3"
},
"engines": {
"node": ">=14.0.0"
@ -5381,13 +5391,13 @@
}
},
"node_modules/react-router-dom": {
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.2",
"react-router": "6.30.3"
"@remix-run/router": "1.23.3",
"react-router": "6.30.4"
},
"engines": {
"node": ">=14.0.0"

View file

@ -23,7 +23,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^6.30.0",
"react-router-dom": "6.30.4",
"tailwind-merge": "^2.0.0",
"zustand": "^5.0.8"
},

View file

@ -18,9 +18,8 @@ const Docker = lazy(() => import('@/pages/Docker'));
const LiveOperations = lazy(() => import('@/pages/LiveOperations'));
const History = lazy(() => import('@/pages/History'));
const Settings = lazy(() => import('@/pages/Settings'));
const TokenManagement = lazy(() => import('@/pages/TokenManagement'));
const RateLimiting = lazy(() => import('@/pages/RateLimiting'));
const AgentManagement = lazy(() => import('@/pages/settings/AgentManagement'));
const AgentsEnrollment = lazy(() => import('@/pages/settings/AgentsEnrollment'));
const MaintenanceWindows = lazy(() => import('@/pages/settings/MaintenanceWindows'));
const UpstreamTracking = lazy(() => import('@/pages/settings/UpstreamTracking'));
const General = lazy(() => import('@/pages/settings/General'));
@ -158,9 +157,9 @@ const App: React.FC = () => {
<Route path="/history" element={<History />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/general" element={<General />} />
<Route path="/settings/tokens" element={<TokenManagement />} />
<Route path="/settings/tokens" element={<Navigate to="/settings/agents" replace />} />
<Route path="/settings/rate-limiting" element={<RateLimiting />} />
<Route path="/settings/agents" element={<AgentManagement />} />
<Route path="/settings/agents" element={<AgentsEnrollment />} />
<Route path="/settings/polling" element={<AgentPolling />} />
<Route path="/settings/security" element={<SecuritySettings />} />
<Route path="/settings/security/:tab" element={<SecuritySettings />} />

View file

@ -14,8 +14,10 @@ export const registrationTokenKeys = {
details: () => [...registrationTokenKeys.all, 'detail'] as const,
detail: (id: string) => [...registrationTokenKeys.details(), id] as const,
stats: () => [...registrationTokenKeys.all, 'stats'] as const,
boundAgents: (tokenId: string) => [...registrationTokenKeys.all, 'bound-agents', tokenId] as const,
};
// Hooks
export const useRegistrationTokens = (params?: {
page?: number;
@ -117,4 +119,39 @@ export const useCleanupRegistrationTokens = () => {
toast.error(error.response?.data?.message || 'Failed to cleanup registration tokens');
},
});
};
// useBoundAgents fetches agents that enrolled with a specific registration token.
// Enabled only when tokenId is non-empty so it doesn't fire on empty selection.
export const useBoundAgents = (tokenId: string) => {
return useQuery({
queryKey: registrationTokenKeys.boundAgents(tokenId),
queryFn: () => adminApi.tokens.getBoundAgents(tokenId),
enabled: !!tokenId,
staleTime: 1000 * 30,
});
};
// useRevokeAgent invalidates an agent's refresh tokens (explicit per-agent action,
// not a side effect of token revocation). Invalidates: bound-agents for the
// selected token, token list, and stats so seat counts refresh.
export const useRevokeAgent = (selectedTokenId?: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ agentId, reason }: { agentId: string; reason?: string }) =>
adminApi.agents.revoke(agentId, reason),
onSuccess: () => {
toast.success('Agent revoked');
if (selectedTokenId) {
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.boundAgents(selectedTokenId) });
}
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.lists() });
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.stats() });
},
onError: (error: any) => {
console.error('Failed to revoke agent:', error);
toast.error(error.response?.data?.error || 'Failed to revoke agent');
},
});
};

View file

@ -868,6 +868,23 @@ export const adminApi = {
const response = await api.post('/admin/registration-tokens/cleanup');
return response.data;
},
// Get agents bound to a registration token (by token UUID)
getBoundAgents: async (id: string): Promise<{ agents: import('@/types').BoundAgent[]; count: number }> => {
const response = await api.get(`/admin/registration-tokens/${id}/agents`);
return response.data;
},
},
// Admin agent operations (revoke, etc.)
agents: {
// Revoke an agent — invalidates refresh tokens so the agent can no longer
// check in. Enrolled agents are NOT affected by token revocation; this is
// the explicit per-agent path.
revoke: async (agentId: string, reason?: string): Promise<{ status: string; agent_id: string }> => {
const response = await api.post(`/admin/agents/${agentId}/revoke`, { reason });
return response.data;
},
},
// Signing key management — Ed25519 key rotation roster.

View file

@ -17,6 +17,7 @@ import {
Download,
CheckCircle,
AlertCircle,
Ban,
Power,
MonitorPlay,
Upload,
@ -37,6 +38,7 @@ import type { Column } from '@/components/primitives';
import { useDebounce } from '@/hooks/useDebounce';
import { useColumnSort } from '@/hooks/useColumnSort';
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
import { useRevokeAgent } from '@/hooks/useRegistrationTokens';
import { useActiveCommands, useCancelCommand, useCaptureScreenshot, useCommand } from '@/hooks/useCommands';
import { useHeartbeatStatus } from '@/hooks/useHeartbeat';
import { agentApi } from '@/lib/api';
@ -249,6 +251,7 @@ const Agents: React.FC = () => {
const scanMultipleMutation = useScanMultipleAgents();
const unregisterAgentMutation = useUnregisterAgent();
const revokeAgentMutation = useRevokeAgent();
// Active commands for live status
const { data: activeCommandsData, refetch: refetchActiveCommands } = useActiveCommands();
@ -356,6 +359,22 @@ const Agents: React.FC = () => {
}
};
// Revoke an agent — invalidates its refresh tokens so it can no longer check
// in. Distinct from Remove (which deletes the record). Enrolled agents are not
// affected by key revocation; this is the explicit per-agent path.
const handleRevokeAgent = async (agentId: string, hostname: string) => {
if (!(await confirm({
title: 'Revoke agent',
body: `Revoke agent "${hostname}"? Its refresh tokens are invalidated so it can no longer check in. The agent record and its history are kept; re-enrolling needs a new registration key.`,
confirmLabel: 'Revoke agent',
danger: true,
}))) return;
revokeAgentMutation.mutate(
{ agentId },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agents'] }) },
);
};
// Handle command cancellation
const handleCancelCommand = async (commandId: string) => {
try {
@ -534,6 +553,14 @@ const Agents: React.FC = () => {
>
<Upload className="h-4 w-4" />
</button>
<button
onClick={() => handleRevokeAgent(agent.id, agent.hostname)}
disabled={revokeAgentMutation.isPending}
className="text-gray-400 hover:text-orange-600"
title="Revoke agent (invalidate refresh tokens)"
>
<Ban className="h-4 w-4" />
</button>
<button
onClick={() => handleRemoveAgent(agent.id, agent.hostname)}
disabled={unregisterAgentMutation.isPending}
@ -552,7 +579,7 @@ const Agents: React.FC = () => {
</div>
),
},
] as Column<typeof sortedAgents[number]>[], [navigate, handleRemoveAgent, unregisterAgentMutation.isPending]);
] as Column<typeof sortedAgents[number]>[], [navigate, handleRemoveAgent, handleRevokeAgent, unregisterAgentMutation.isPending, revokeAgentMutation.isPending]);
// Agent detail view
if (id && selectedAgent) {

View file

@ -4,7 +4,6 @@ import {
Shield,
Lock,
SlidersHorizontal,
Settings as SettingsIcon,
ArrowRight,
CheckCircle,
Activity,
@ -43,15 +42,15 @@ const Settings: React.FC = () => {
</Link>
<Link
to="/settings/tokens"
to="/settings/agents"
className="card block hover:border-blue-300 hover:shadow-sm transition-all"
>
<div className="flex items-center justify-between mb-4">
<Shield className="w-8 h-8 text-blue-600" />
<ArrowRight className="w-5 h-5 text-gray-400" />
</div>
<h3 className="font-semibold text-gray-900">Registration Tokens</h3>
<p className="text-sm text-gray-600 mt-1">Manage agent registration tokens</p>
<h3 className="font-semibold text-gray-900">Agents &amp; Enrollment</h3>
<p className="text-sm text-gray-600 mt-1">Deploy agents, manage keys and revocation</p>
</Link>
<Link
@ -66,18 +65,6 @@ const Settings: React.FC = () => {
<p className="text-sm text-gray-600 mt-1">Configure API rate limits</p>
</Link>
<Link
to="/settings/agents"
className="card block hover:border-purple-300 hover:shadow-sm transition-all"
>
<div className="flex items-center justify-between mb-4">
<SettingsIcon className="w-8 h-8 text-purple-600" />
<ArrowRight className="w-5 h-5 text-gray-400" />
</div>
<h3 className="font-semibold text-gray-900">Agent Management</h3>
<p className="text-sm text-gray-600 mt-1">Deploy and configure agents</p>
</Link>
<Link
to="/settings/polling"
className="card block hover:border-amber-300 hover:shadow-sm transition-all"
@ -146,7 +133,7 @@ const Settings: React.FC = () => {
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Token Overview</h2>
<Link
to="/settings/tokens"
to="/settings/agents"
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
Manage all

View file

@ -1,533 +0,0 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useConfirm } from '@/components/primitives';
import {
Shield,
Plus,
RefreshCw,
Trash2,
Copy,
AlertTriangle,
CheckCircle,
Clock,
Users
} from 'lucide-react';
import { SearchInput, Pagination } from '@/components/primitives';
import {
useRegistrationTokens,
useCreateRegistrationToken,
useRevokeRegistrationToken,
useDeleteRegistrationToken,
useRegistrationTokenStats,
useCleanupRegistrationTokens
} from '../hooks/useRegistrationTokens';
import { RegistrationToken, CreateRegistrationTokenRequest } from '@/types';
import { formatDateTime } from '@/lib/utils';
import { tokenStatusColor } from '@/components/primitives/statusColors';
const TokenManagement: React.FC = () => {
const navigate = useNavigate();
const confirm = useConfirm();
// Filters and search
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'used' | 'expired' | 'revoked'>('all');
const [showCreateForm, setShowCreateForm] = useState(false);
const [createdToken, setCreatedToken] = useState<{ token: string; install_command: string } | null>(null);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 50;
// Token management
const { data: tokensData, isLoading, refetch } = useRegistrationTokens({
page: currentPage,
page_size: pageSize,
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
label: searchTerm || undefined,
});
const { data: stats } = useRegistrationTokenStats();
const createToken = useCreateRegistrationToken();
const revokeToken = useRevokeRegistrationToken();
const deleteToken = useDeleteRegistrationToken();
const cleanupTokens = useCleanupRegistrationTokens();
// Reset page when filters change
React.useEffect(() => {
setCurrentPage(1);
}, [searchTerm, statusFilter]);
// Form state
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
label: '',
expires_in: '168h', // Default 7 days
max_seats: 1, // Default 1 seat
});
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault();
createToken.mutate(formData, {
onSuccess: (data: any) => {
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
setShowCreateForm(false);
setCreatedToken({ token: data.token, install_command: data.install_command });
refetch();
},
});
};
const handleRevokeToken = async (tokenId: string, tokenLabel: string) => {
if (!(await confirm({
title: 'Revoke Token',
body: `Revoke token "${tokenLabel}"? Agents using it will need to re-register.`,
confirmLabel: 'Revoke',
danger: true,
}))) return;
revokeToken.mutate(tokenId, { onSuccess: () => refetch() });
};
const handleDeleteToken = async (tokenId: string, tokenLabel: string) => {
if (!(await confirm({
title: 'PERMANENTLY DELETE Token',
body: `PERMANENTLY DELETE token "${tokenLabel}"? This cannot be undone!`,
confirmLabel: 'Delete',
danger: true,
}))) return;
deleteToken.mutate(tokenId, { onSuccess: () => refetch() });
};
const handleCleanup = async () => {
if (!(await confirm({
title: 'Cleanup Expired Tokens',
body: 'Clean up all expired tokens? This cannot be undone.',
confirmLabel: 'Clean Up',
danger: true,
}))) return;
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
};
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
// Show success feedback
};
const getStatusText = (token: RegistrationToken) => {
if (token.status === 'revoked') return 'Revoked';
if (token.status === 'expired') return 'Expired';
if (token.status === 'used') return 'Used';
if (token.status === 'active') return 'Active';
const s = String(token.status);
return s.charAt(0).toUpperCase() + s.slice(1);
};
const filteredTokens = tokensData?.tokens || [];
return (
<div className="max-w-7xl mx-auto px-6 py-8">
<button
onClick={() => navigate('/settings')}
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
>
Back to Settings
</button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Registration Tokens</h1>
<p className="mt-2 text-gray-600">Manage agent registration tokens and monitor their usage</p>
</div>
<div className="flex gap-3">
<button
onClick={handleCleanup}
disabled={cleanupTokens.isPending}
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${cleanupTokens.isPending ? 'animate-spin' : ''}`} />
Cleanup Expired
</button>
<button
onClick={() => refetch()}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Create Token
</button>
</div>
</div>
</div>
{/* Statistics Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<div className="card card-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Tokens</p>
<p className="text-2xl font-bold text-gray-900">{stats.total_tokens}</p>
</div>
<Shield className="w-8 h-8 text-blue-600" />
</div>
</div>
<div className="card card-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Active</p>
<p className="text-2xl font-bold text-green-600">{stats.active_tokens}</p>
</div>
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
</div>
<div className="card card-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Used</p>
<p className="text-2xl font-bold text-blue-600">{stats.used_tokens}</p>
</div>
<Users className="w-8 h-8 text-blue-600" />
</div>
</div>
<div className="card card-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Expired</p>
<p className="text-2xl font-bold text-gray-600">{stats.expired_tokens}</p>
</div>
<Clock className="w-8 h-8 text-gray-600" />
</div>
</div>
<div className="card card-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Seats Used</p>
<p className="text-2xl font-bold text-purple-600">
{stats.total_seats_used}/{stats.total_seats_available || '∞'}
</p>
</div>
<Users className="w-8 h-8 text-purple-600" />
</div>
</div>
</div>
)}
{/* Create Token Form */}
{showCreateForm && (
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Create New Registration Token</h3>
<form onSubmit={handleCreateToken} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Label *</label>
<input
type="text"
required
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
placeholder="e.g., Production Servers, Development Team"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Expires In</label>
<select
value={formData.expires_in}
onChange={(e) => setFormData({ ...formData, expires_in: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="24h">24 hours</option>
<option value="72h">3 days</option>
<option value="168h">7 days (1 week)</option>
</select>
<p className="mt-1 text-xs text-gray-500">Maximum 7 days per server security policy</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats (Agents)</label>
<input
type="number"
min="1"
max="100"
value={formData.max_seats || 1}
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
placeholder="1"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">Number of agents that can use this token</p>
</div>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={createToken.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{createToken.isPending ? 'Creating...' : 'Create Token'}
</button>
<button
type="button"
onClick={() => setShowCreateForm(false)}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* Created token reveal — shown once, dismissed by the operator */}
{createdToken && (
<div className="bg-green-50 border border-green-300 rounded-lg p-6 mb-8">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<h3 className="text-lg font-semibold text-green-900">Token Created</h3>
</div>
<button
onClick={() => setCreatedToken(null)}
className="text-green-600 hover:text-green-800 text-sm"
>
Dismiss
</button>
</div>
<p className="text-sm text-green-800 mb-3">
Copy this token now. It cannot be retrieved again only a hash is stored.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-green-700 mb-1">Token</label>
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-sm bg-white border border-green-200 px-3 py-2 rounded select-all">
{createdToken.token}
</code>
<button
onClick={() => copyToClipboard(createdToken.token)}
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
title="Copy token"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-green-700 mb-1">Install command</label>
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-xs bg-white border border-green-200 px-3 py-2 rounded select-all overflow-x-auto">
{createdToken.install_command}
</code>
<button
onClick={() => copyToClipboard(createdToken.install_command)}
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
title="Copy install command"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
)}
{/* Filters and Search */}
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-8">
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1">
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search by label..."
/>
</div>
<div className="flex gap-2">
<button
onClick={() => setStatusFilter('all')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'all'
? 'bg-gray-100 text-gray-800 border border-gray-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
All
</button>
<button
onClick={() => setStatusFilter('active')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'active'
? 'bg-green-100 text-green-800 border border-green-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
Active
</button>
<button
onClick={() => setStatusFilter('used')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'used'
? 'bg-blue-100 text-blue-800 border border-blue-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
Used
</button>
<button
onClick={() => setStatusFilter('expired')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'expired'
? 'bg-red-100 text-red-800 border border-red-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
Expired
</button>
</div>
</div>
</div>
{/* Tokens List */}
<div className="bg-white rounded-lg border border-gray-200">
{isLoading ? (
<div className="p-12 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-gray-600">Loading tokens...</p>
</div>
) : filteredTokens.length === 0 ? (
<div className="p-12 text-center">
<Shield className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No tokens found</h3>
<p className="text-gray-600">
{searchTerm || statusFilter !== 'all'
? 'Try adjusting your search or filter criteria'
: 'Create your first token to begin registering agents'}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Token
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Label
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Seats
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Expires
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Used
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredTokens.map((token) => (
<tr key={token.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-mono text-sm text-gray-500 bg-gray-100 px-3 py-2 rounded">
{token.id.slice(0, 8)}...
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{token.label}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className={`badge badge-lg ${tokenStatusColor(token.status)}`}>
{getStatusText(token)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{token.seats_used}/{token.max_seats} used
{token.seats_used >= token.max_seats && (
<span className="ml-2 text-xs text-red-600">(Full)</span>
)}
{token.seats_used < token.max_seats && token.status === 'active' && (
<span className="ml-2 text-xs text-green-600">({token.max_seats - token.seats_used} available)</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(token.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(token.expires_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{token.used_at ? formatDateTime(token.used_at) : 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
{token.status === 'active' && (
<button
onClick={() => handleRevokeToken(token.id, token.label || 'this token')}
disabled={revokeToken.isPending}
className="text-orange-600 hover:text-orange-800 disabled:opacity-50"
title="Revoke token (soft delete)"
>
<AlertTriangle className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleDeleteToken(token.id, token.label || 'this token')}
disabled={deleteToken.isPending}
className="text-red-600 hover:text-red-800 disabled:opacity-50"
title="Permanently delete token"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Pagination — Pagination primitive */}
{tokensData && tokensData.total > pageSize && (
<div className="mt-6">
<Pagination
page={currentPage}
total={tokensData.total}
pageSize={pageSize}
onChange={setCurrentPage}
/>
</div>
)}
</div>
);
};
export default TokenManagement;

View file

@ -1,636 +0,0 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
Terminal,
Check,
Copy,
Shield,
Server,
Monitor,
Laptop,
AlertTriangle,
Key,
Code
} from 'lucide-react';
import { useRegistrationTokens } from '@/hooks/useRegistrationTokens';
import { toast } from 'react-hot-toast';
import { useServerKeySecurity } from '@/hooks/useSecurity';
const AgentManagement: React.FC = () => {
const navigate = useNavigate();
const [selectedPlatform, setSelectedPlatform] = useState<string>('linux');
const [selectedTokenId, setSelectedTokenId] = useState<string>('');
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
const { data: tokens } = useRegistrationTokens({ is_active: true });
const { data: serverKeySecurity, isLoading: isLoadingServerKeySecurity, refetch: refetchServerKeySecurity } = useServerKeySecurity();
const [generatingKeys, setGeneratingKeys] = useState(false);
const availableTokens = React.useMemo(() => {
if (!tokens?.tokens || !Array.isArray(tokens.tokens)) return [];
return tokens.tokens.filter(
(t) => !t.revoked && t.status !== 'revoked' && t.status !== 'expired' && t.seats_used < t.max_seats,
);
}, [tokens]);
const selectedToken = React.useMemo(
() => availableTokens.find((t) => t.id === selectedTokenId) ?? null,
[availableTokens, selectedTokenId],
);
// Drop a stale selection if the token list changes underneath us, but never
// auto-pick — operator chooses explicitly.
React.useEffect(() => {
if (selectedTokenId && !availableTokens.some((t) => t.id === selectedTokenId)) {
setSelectedTokenId('');
}
}, [availableTokens, selectedTokenId]);
const platforms = [
{
id: 'linux',
name: 'Linux',
icon: Server,
description: 'Ubuntu, Debian, RHEL, CentOS, AlmaLinux, Rocky Linux (AMD64 + ARM64)',
downloadUrl: '/api/v1/downloads/linux-amd64',
installScript: '/api/v1/install/linux',
extensions: ['amd64', 'arm64'],
color: 'orange',
available: true,
},
{
id: 'windows',
name: 'Windows',
icon: Monitor,
description: 'Windows 10/11, Server 2019/2022 (AMD64 + ARM64)',
downloadUrl: '/api/v1/downloads/windows-amd64',
installScript: '/api/v1/install/windows',
extensions: ['amd64', 'arm64'],
color: 'blue',
available: true,
},
{
id: 'macos',
name: 'macOS',
icon: Laptop,
description: 'macOS 12+ (Apple Silicon + Intel)',
downloadUrl: '/api/v1/downloads/macos-arm64',
installScript: '/api/v1/install/macos',
extensions: ['arm64', 'amd64'],
color: 'gray',
available: false,
}
];
const getServerUrl = () => {
// Use the host:port the browser is actually on — that's always reachable
// by the agent machine. The nginx proxy (:31336) forwards /api/ to the
// server; direct API access (:31337) also works.
const { protocol, hostname, port } = window.location;
const portSuffix = port ? `:${port}` : '';
return `${protocol}//${hostname}${portSuffix}`;
};
const generateInstallCommand = (platform: typeof platforms[0]) => {
if (!selectedToken?.token) return '';
const serverUrl = getServerUrl();
const token = selectedToken.token;
// SEC-002: token travels in the X-Registration-Token header, never the
// URL — query strings land in shell history, process lists, and access logs.
switch (platform.id) {
case 'linux':
case 'macos':
return `curl -sfL -H "X-Registration-Token: ${token}" "${serverUrl}${platform.installScript}" | sudo bash`;
case 'windows':
// irm returns the response body as a string, so the pipe into iex runs
// the script directly — no temp file, no params (token is baked in at
// render time). Must be pasted into an elevated PowerShell.
return `irm "${serverUrl}${platform.installScript}" -Headers @{'X-Registration-Token'='${token}'} | iex`;
default:
return '';
}
};
const copyToClipboard = async (text: string, commandId: string) => {
try {
if (!text || text.trim() === '') {
toast.error('No command to copy');
return;
}
await navigator.clipboard.writeText(text);
setCopiedCommand(commandId);
toast.success('Command copied to clipboard!');
setTimeout(() => setCopiedCommand(null), 2000);
} catch (error) {
console.error('Copy failed:', error);
toast.error('Failed to copy command. Please copy manually.');
}
};
const formatTokenOptionLabel = (t: typeof availableTokens[number]) => {
const prefix = (t.token ?? t.id).slice(0, 12);
const seats = `${t.seats_used}/${t.max_seats} seats`;
const label = t.label ? ` · ${t.label}` : '';
let expiry = '';
if (t.expires_at) {
const days = Math.round((new Date(t.expires_at).getTime() - Date.now()) / 86400000);
expiry = ` · expires ${days}d`;
}
return `${prefix}${label} · ${seats}${expiry}`;
};
const selectedPlatformData = platforms.find(p => p.id === selectedPlatform);
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<button
onClick={() => navigate('/settings')}
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
>
Back to Settings
</button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">Agent Management</h1>
<p className="mt-2 text-gray-600">Deploy and configure RedFlag agents across your infrastructure</p>
</div>
<Link
to="/settings/tokens"
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Shield className="w-4 h-4" />
Manage Tokens
</Link>
</div>
</div>
{/* Token Selector */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
<div className="flex items-start gap-4">
<Shield className="w-6 h-6 text-blue-600 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-blue-900 mb-2">Choose a Registration Token</h3>
{availableTokens.length === 0 ? (
<>
<p className="text-blue-700 mb-4">
No registration tokens with available seats. Create one to enroll new agents existing agents are unaffected.
</p>
<Link
to="/settings/tokens"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<Shield className="w-4 h-4" />
Generate Registration Token
</Link>
</>
) : (
<>
<p className="text-blue-700 mb-3">
Each token defines a group of agents. Pick the one this install belongs to the
selection is baked into the command below.
</p>
<div className="flex items-center gap-3 flex-wrap">
<select
value={selectedTokenId}
onChange={(e) => setSelectedTokenId(e.target.value)}
className="px-3 py-2 border border-blue-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[420px]"
>
<option value=""> Select a token ({availableTokens.length} available) </option>
{availableTokens.map((t) => (
<option key={t.id} value={t.id}>
{formatTokenOptionLabel(t)}
</option>
))}
</select>
<Link
to="/settings/tokens"
className="text-sm text-blue-600 hover:text-blue-800 underline"
>
Manage tokens
</Link>
</div>
{selectedToken && (
<div className="mt-3 text-xs text-blue-800 bg-blue-100 rounded px-3 py-2 inline-block">
ID: <code className="font-mono">{selectedToken.id.slice(0, 8)}</code>
{selectedToken.label && <> · {selectedToken.label}</>}
· {selectedToken.seats_used}/{selectedToken.max_seats} seats used
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Platform Selection */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold text-gray-900 mb-6">1. Select Target Platform</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{platforms.map((platform) => {
const Icon = platform.icon;
return (
<button
key={platform.id}
onClick={() => setSelectedPlatform(platform.id)}
className={`p-6 border-2 rounded-lg transition-all ${
selectedPlatform === platform.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between mb-4">
<Icon className={`w-8 h-8 ${
platform.id === 'linux' ? 'text-orange-600' :
platform.id === 'windows' ? 'text-blue-600' : 'text-gray-600'
}`} />
{selectedPlatform === platform.id && (
<Check className="w-5 h-5 text-blue-600" />
)}
</div>
<h3 className="font-semibold text-gray-900 mb-2">{platform.name}</h3>
<p className="text-sm text-gray-600">{platform.description}</p>
</button>
);
})}
</div>
</div>
{/* Installation Methods */}
{selectedPlatformData && (
<div className="space-y-8">
{/* One-Liner Installation */}
{!selectedPlatformData.available ? (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
<Terminal className="w-8 h-8 text-gray-400 mx-auto mb-3" />
<h2 className="text-lg font-semibold text-gray-700 mb-1">
{selectedPlatformData.name} agent coming soon
</h2>
<p className="text-sm text-gray-500">
The {selectedPlatformData.name} installer isn't available yet. Linux and Windows are ready today.
</p>
</div>
) : !selectedToken ? (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
<Terminal className="w-8 h-8 text-gray-400 mx-auto mb-3" />
<h2 className="text-lg font-semibold text-gray-700 mb-1">
Select a token above to generate the install command
</h2>
<p className="text-sm text-gray-500">
Pick a registration token and the one-liner for {selectedPlatformData.name} appears here.
</p>
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-gray-900">2. One-Liner Installation (Recommended)</h2>
<p className="text-gray-600 mt-1">
Automatically downloads and configures the agent for {selectedPlatformData.name}
</p>
</div>
<Terminal className="w-6 h-6 text-gray-400" />
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Installation Command{' '}
{selectedPlatformData.id === 'windows' && (
<span className="text-blue-600">(Run in PowerShell as Administrator)</span>
)}
</label>
<div className="relative">
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<code>{generateInstallCommand(selectedPlatformData)}</code>
</pre>
<button
onClick={() => copyToClipboard(generateInstallCommand(selectedPlatformData), 'one-liner')}
className="absolute top-2 right-2 p-2 bg-gray-700 text-white rounded hover:bg-gray-600 transition-colors"
>
{copiedCommand === 'one-liner' ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
<div className="alert alert-warning">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-600 mt-0.5" />
<div>
<h4 className="font-medium text-yellow-900">Before Running</h4>
<ul className="text-sm text-yellow-700 mt-1 space-y-1">
{selectedPlatformData.id === 'windows' ? (
<>
<li> Open <strong>PowerShell as Administrator</strong></li>
<li> The script will download and install the agent to <code className="code code-warning">%ProgramFiles%\RedFlag</code></li>
<li> A Windows service will be created and started automatically</li>
<li> Script is idempotent - safe to re-run for upgrades</li>
</>
) : (
<>
<li> Run this command as <strong>root</strong> (use sudo)</li>
<li> The script will create a dedicated <code className="code code-warning">redflag-agent</code> user</li>
<li> Limited sudo access will be configured via <code className="code code-warning">/etc/sudoers.d/redflag-agent</code></li>
<li> Systemd service will be installed and enabled automatically</li>
<li> Script is idempotent - safe to re-run for upgrades</li>
</>
)}
</ul>
</div>
</div>
</div>
</div>
</div>
)}
{/* Security Information */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-gray-900">3. Security Information</h2>
<p className="text-gray-600 mt-1">
Understanding the security model and installation details
</p>
</div>
<Shield className="w-6 h-6 text-gray-400" />
</div>
<div className="space-y-6">
{/* Server Signing Key */}
<div>
<h4 className="font-medium text-gray-900 mb-3">🔑 Server Signing Key</h4>
{isLoadingServerKeySecurity ? (
<div className="text-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<p className="text-sm text-gray-500 mt-2">Loading key status...</p>
</div>
) : serverKeySecurity?.has_private_key ? (
<div className="space-y-3">
<div className="alert alert-success rounded-md p-3">
<p className="text-sm text-green-800">
Server has a private key for signing agent updates.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Public Key Fingerprint
</label>
<input
readOnly
value={serverKeySecurity.public_key_fingerprint}
className="block w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-md font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Algorithm
</label>
<input
readOnly
value={serverKeySecurity.algorithm?.toUpperCase()}
className="block w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-md text-sm"
/>
</div>
</div>
) : (
<div className="space-y-3">
<div className="alert alert-warning rounded-md p-3">
<p className="text-sm text-yellow-800">
Your server is missing a private key. Generate one to enable secure agent updates.
</p>
</div>
<button
type="button"
onClick={async () => {
setGeneratingKeys(true);
try {
const response = await fetch('/api/setup/generate-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to generate keys');
}
toast.success('Signing keys generated successfully! Please restart your server.');
refetchServerKeySecurity(); // Refresh status
} catch (error: any) {
toast.error(error.message || 'Failed to generate keys');
} finally {
setGeneratingKeys(false);
}
}}
disabled={generatingKeys}
className="w-full py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{generatingKeys ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Generating Keys...
</>
) : (
<>
<Key className="h-4 w-4 mr-2" />
Generate Signing Keys
</>
)}
</button>
</div>
)}
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3">🛡 Security Model</h4>
<p className="text-sm text-gray-600 mb-4">
The installation script follows the principle of least privilege by creating a dedicated system user with minimal permissions:
</p>
<div className="alert alert-info space-y-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
<span className="text-sm text-blue-800"><strong>System User:</strong> <code className="code code-info">redflag-agent</code> with no login shell</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
<span className="text-sm text-blue-800"><strong>Sudo Access:</strong> Limited to package management commands only</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
<span className="text-sm text-blue-800"><strong>Systemd Service:</strong> Runs with security hardening (ProtectSystem, ProtectHome)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
<span className="text-sm text-blue-800"><strong>Configuration:</strong> Secured in <code className="code code-info">/etc/redflag/config.json</code> with restricted permissions</span>
</div>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3">📁 Installation Files</h4>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700 space-y-1">
{`Binary: /usr/local/bin/redflag-agent
Config: /etc/redflag/config.json
Service: /etc/systemd/system/redflag-agent.service
Sudoers: /etc/sudoers.d/redflag-agent
Home Dir: /var/lib/redflag-agent
Logs: journalctl -u redflag-agent`}
</pre>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3"> Sudoers Configuration</h4>
<p className="text-sm text-gray-600 mb-2">
The agent gets sudo access only for these specific commands:
</p>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-xs text-gray-700 overflow-x-auto">
{`# APT (Debian/Ubuntu)
/usr/bin/apt-get update
/usr/bin/apt-get install -y *
/usr/bin/apt-get upgrade -y *
/usr/bin/apt-get install --dry-run --yes *
# DNF (RHEL/Fedora/Rocky/Alma)
/usr/bin/dnf makecache
/usr/bin/dnf install -y *
/usr/bin/dnf upgrade -y *
/usr/bin/dnf install --assumeno --downloadonly *
# Docker
/usr/bin/docker pull *
/usr/bin/docker image inspect *
/usr/bin/docker manifest inspect *`}
</pre>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3">🔄 Updates and Upgrades</h4>
<p className="text-sm text-gray-600">
The installation script is <strong>idempotent</strong> - it's safe to run multiple times.
RedFlag agents update themselves automatically when new versions are released.
If you need to manually reinstall or upgrade, simply run the same one-liner command.
</p>
</div>
</div>
</div>
{/* Advanced Configuration */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-gray-900">4. Advanced Configuration</h2>
<p className="text-gray-600 mt-1">
Additional agent configuration options
</p>
</div>
<Code className="w-6 h-6 text-gray-400" />
</div>
<div className="space-y-6">
{/* Configuration Options */}
<div>
<h4 className="font-medium text-gray-900 mb-3">Command Line Options</h4>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700">
{`./redflag-agent [options]
Options:
--server <url> Server URL (default: http://localhost:8080)
--token <token> Registration token
--proxy-http <url> HTTP proxy URL
--proxy-https <url> HTTPS proxy URL
--log-level <level> Log level (debug, info, warn, error)
--organization <name> Organization name
--tags <tags> Comma-separated tags
--name <display> Display name for the agent
--insecure-tls Skip TLS certificate verification`}
</pre>
</div>
</div>
{/* Environment Variables */}
<div>
<h4 className="font-medium text-gray-900 mb-3">Environment Variables</h4>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700">
{`REDFLAG_SERVER_URL="https://your-server.com"
REDFLAG_REGISTRATION_TOKEN="your-token-here"
REDFLAG_HTTP_PROXY="http://proxy.company.com:8080"
REDFLAG_HTTPS_PROXY="https://proxy.company.com:8080"
REDFLAG_NO_PROXY="localhost,127.0.0.1"
REDFLAG_LOG_LEVEL="info"
REDFLAG_ORGANIZATION="IT Department"`}
</pre>
</div>
</div>
{/* Configuration File */}
<div>
<h4 className="font-medium text-gray-900 mb-3">Configuration File</h4>
<p className="text-sm text-gray-600 mb-3">
After installation, the agent configuration is stored at <code>/etc/redflag/agent/config.json</code> (Linux) or
<code>%ProgramData%\RedFlag\config.json</code> (Windows):
</p>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700 overflow-x-auto">
{`{
"server_url": "https://your-server.com",
"registration_token": "your-token-here",
"proxy": {
"enabled": true,
"http": "http://proxy.company.com:8080",
"https": "https://proxy.company.com:8080",
"no_proxy": "localhost,127.0.0.1"
},
"network": {
"timeout": "30s",
"retry_count": 3,
"retry_delay": "5s"
},
"tls": {
"insecure_skip_verify": false
},
"logging": {
"level": "info",
"max_size": 100,
"max_backups": 3
},
"tags": ["production", "webserver"],
"organization": "IT Department",
"display_name": "Web Server 01"
}`}
</pre>
</div>
</div>
</div>
</div>
{/* Next Steps */}
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<div className="flex items-start gap-4">
<Check className="w-6 h-6 text-green-600 mt-1" />
<div>
<h3 className="font-semibold text-green-900 mb-2">Next Steps</h3>
<ol className="text-sm text-green-800 space-y-2">
<li>1. Deploy agents to your target machines using the methods above</li>
<li>2. Monitor agent registration in the <Link to="/agents" className="underline">Agents dashboard</Link></li>
<li>3. Configure update policies and scanning schedules</li>
<li>4. Review agent status and system information</li>
</ol>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default AgentManagement;

View file

@ -0,0 +1,884 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
Shield,
Plus,
RefreshCw,
Trash2,
Copy,
AlertTriangle,
CheckCircle,
Clock,
Users,
Key,
Terminal,
Server,
Monitor,
Laptop,
} from 'lucide-react';
import { SearchInput, useConfirm } from '@/components/primitives';
import { tokenStatusColor } from '@/components/primitives/statusColors';
import {
useRegistrationTokens,
useCreateRegistrationToken,
useRevokeRegistrationToken,
useDeleteRegistrationToken,
useRegistrationTokenStats,
useCleanupRegistrationTokens,
useBoundAgents,
useRevokeAgent,
} from '@/hooks/useRegistrationTokens';
import { useServerKeySecurity } from '@/hooks/useSecurity';
import type { RegistrationToken, CreateRegistrationTokenRequest, BoundAgent } from '@/types';
import { formatDateTime, formatRelativeTime, isOnline, cn } from '@/lib/utils';
import toast from 'react-hot-toast';
// Install one-liner generation — matches what /api/v1/install/{linux,windows,macos}
// expects. Token travels in the X-Registration-Token header, never the URL
// (SEC-002: query strings land in shell history, process lists, access logs).
const PLATFORMS = [
{
id: 'linux',
name: 'Linux',
icon: Server,
installScript: '/api/v1/install/linux',
available: true,
description: 'Ubuntu, Debian, RHEL, Fedora, Rocky, Alma (AMD64 + ARM64)',
},
{
id: 'windows',
name: 'Windows',
icon: Monitor,
installScript: '/api/v1/install/windows',
available: true,
description: 'Windows 10/11, Server 2019/2022 (AMD64 + ARM64)',
},
{
id: 'macos',
name: 'macOS',
icon: Laptop,
installScript: '/api/v1/install/macos',
available: false,
description: 'macOS 12+ (Apple Silicon + Intel) — coming soon',
},
] as const;
function getServerUrl(): string {
// The host:port the browser is on is always reachable by the agent machine.
const { protocol, hostname, port } = window.location;
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
}
function generateInstallCommand(platformId: string, token: string | undefined): string {
if (!token) return '';
const serverUrl = getServerUrl();
const script = PLATFORMS.find((p) => p.id === platformId)?.installScript;
if (!script) return '';
if (platformId === 'windows') {
return `irm "${serverUrl}${script}" -Headers @{'X-Registration-Token'='${token}'} | iex`;
}
return `curl -sfL -H "X-Registration-Token: ${token}" "${serverUrl}${script}" | sudo bash`;
}
type StatusFilter = 'all' | 'active' | 'used' | 'expired' | 'revoked';
const STATUS_DOT: Record<string, string> = {
active: 'bg-green-500',
used: 'bg-blue-500',
expired: 'bg-amber-500',
revoked: 'bg-gray-400',
};
const getStatusText = (token: RegistrationToken): string => {
const s = String(token.status);
return s.charAt(0).toUpperCase() + s.slice(1);
};
const tokenLabel = (t: RegistrationToken): string => t.label || `token ${t.id.slice(0, 8)}`;
const AgentsEnrollment: React.FC = () => {
const navigate = useNavigate();
const confirm = useConfirm();
// Key list — fetch all (incl. revoked/expired) so the operator sees the full
// roster; status chips filter client-side.
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const { data: tokensData, isLoading, refetch } = useRegistrationTokens({
page: 1,
page_size: 100,
label: searchTerm || undefined,
});
const { data: stats } = useRegistrationTokenStats();
const createToken = useCreateRegistrationToken();
const revokeToken = useRevokeRegistrationToken();
const deleteToken = useDeleteRegistrationToken();
const cleanupTokens = useCleanupRegistrationTokens();
// Selection + panels
const [selectedTokenId, setSelectedTokenId] = useState<string>('');
const [showInstall, setShowInstall] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [revealToken, setRevealToken] = useState(false);
const [createdToken, setCreatedToken] = useState<{ token: string; install_command: string } | null>(null);
// Install panel state (separate from detail selection — install picks a live
// token to enroll a NEW agent with; detail inspects any token)
const [installPlatform, setInstallPlatform] = useState<string>('linux');
const [installTokenId, setInstallTokenId] = useState<string>('');
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
// Create-key form
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
label: '',
expires_in: '168h',
max_seats: 1,
});
// Signing keys (preserved from the old Agent Management page)
const { data: serverKeySecurity, isLoading: isLoadingServerKeySecurity, refetch: refetchServerKeySecurity } =
useServerKeySecurity();
const [generatingKeys, setGeneratingKeys] = useState(false);
const allTokens = tokensData?.tokens || [];
// Client-side status filter
const filteredTokens = allTokens.filter((t) => {
if (statusFilter !== 'all' && t.status !== statusFilter) return false;
return true;
});
// Auto-select the first key for the detail pane once the list lands, but never
// override an explicit operator selection.
React.useEffect(() => {
if (!selectedTokenId && filteredTokens.length > 0) {
setSelectedTokenId(filteredTokens[0].id);
}
}, [filteredTokens, selectedTokenId]);
// Drop a stale selection if the token list changes underneath us.
React.useEffect(() => {
if (selectedTokenId && !allTokens.some((t) => t.id === selectedTokenId)) {
setSelectedTokenId(allTokens[0]?.id ?? '');
}
}, [allTokens, selectedTokenId]);
const selectedToken = allTokens.find((t) => t.id === selectedTokenId) || null;
// Active tokens with available seats — what the install panel offers.
const availableTokens = React.useMemo(
() =>
allTokens.filter(
(t) => !t.revoked && t.status === 'active' && t.seats_used < t.max_seats,
),
[allTokens],
);
const installToken = availableTokens.find((t) => t.id === installTokenId) ?? null;
React.useEffect(() => {
if (installTokenId && !availableTokens.some((t) => t.id === installTokenId)) {
setInstallTokenId('');
}
}, [availableTokens, installTokenId]);
const { data: boundAgentsData, isLoading: boundAgentsLoading } = useBoundAgents(selectedTokenId || '');
const revokeAgentMutation = useRevokeAgent(selectedTokenId || '');
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault();
createToken.mutate(formData, {
onSuccess: (data: any) => {
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
setShowCreate(false);
setCreatedToken({ token: data.token, install_command: data.install_command });
refetch();
},
});
};
const handleRevokeToken = async (tokenId: string, label: string) => {
// No cascade: revoking a key only stops new enrollments. Enrolled agents
// keep working — revoke them individually. (Test-enforced in the server.)
if (
!(await confirm({
title: 'Revoke registration key',
body: `Revoke key "${label}"? No new agents can enroll with it. Agents already enrolled with this key keep working — revoke them individually below if their access must end.`,
confirmLabel: 'Revoke key',
danger: true,
}))
)
return;
revokeToken.mutate(tokenId, { onSuccess: () => refetch() });
};
const handleDeleteToken = async (tokenId: string, label: string) => {
if (
!(await confirm({
title: 'PERMANENTLY DELETE key',
body: `PERMANENTLY DELETE key "${label}"? This cannot be undone.`,
confirmLabel: 'Delete',
danger: true,
}))
)
return;
deleteToken.mutate(tokenId, { onSuccess: () => refetch() });
};
const handleRevokeAgent = async (agentId: string, hostname: string) => {
if (
!(await confirm({
title: 'Revoke agent',
body: `Revoke agent "${hostname}"? Its refresh tokens are invalidated so it can no longer check in. The agent record and its history are kept.`,
confirmLabel: 'Revoke agent',
danger: true,
}))
)
return;
revokeAgentMutation.mutate({ agentId });
};
const handleCleanup = async () => {
if (
!(await confirm({
title: 'Cleanup expired keys',
body: 'Clean up all expired keys? This cannot be undone.',
confirmLabel: 'Clean Up',
danger: true,
}))
)
return;
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
};
const copyToClipboard = async (text: string, id: string) => {
if (!text || !text.trim()) {
toast.error('Nothing to copy');
return;
}
try {
await navigator.clipboard.writeText(text);
setCopiedCommand(id);
toast.success('Copied to clipboard');
setTimeout(() => setCopiedCommand(null), 2000);
} catch {
toast.error('Failed to copy. Copy manually.');
}
};
const generateKeys = async () => {
setGeneratingKeys(true);
try {
const response = await fetch('/api/setup/generate-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) throw new Error('Failed to generate keys');
toast.success('Signing keys generated. Restart the server.');
refetchServerKeySecurity();
} catch (error: any) {
toast.error(error.message || 'Failed to generate keys');
} finally {
setGeneratingKeys(false);
}
};
const installCommand = generateInstallCommand(installPlatform, installToken?.token);
const boundAgents = boundAgentsData?.agents || [];
return (
<div className="max-w-7xl mx-auto px-6 py-8">
<button onClick={() => navigate('/settings')} className="text-sm text-gray-500 hover:text-gray-700 mb-4">
Back to Settings
</button>
{/* Header */}
<div className="mb-6 flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">Agents &amp; Enrollment</h1>
<p className="mt-2 text-gray-600">
Enroll agents and manage registration keys. Select a key to see who enrolled with it.
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowInstall((v) => !v)}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<Terminal className="w-4 h-4" />
{showInstall ? 'Hide install' : 'Install agent'}
</button>
<button
onClick={() => setShowCreate((v) => !v)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
New key
</button>
</div>
</div>
{/* Install panel */}
{showInstall && (
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Install a new agent</h2>
<button onClick={() => setShowInstall(false)} className="text-sm text-gray-500 hover:text-gray-700">
close
</button>
</div>
{availableTokens.length === 0 ? (
<div className="text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded-lg p-4">
No registration keys with available seats.{' '}
<button onClick={() => setShowCreate(true)} className="text-blue-600 hover:text-blue-800 underline">
Create a key
</button>{' '}
first existing agents are unaffected.
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-gray-600">Enroll with key:</span>
<select
value={installTokenId}
onChange={(e) => setInstallTokenId(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[320px]"
>
<option value=""> Select a key ({availableTokens.length} available) </option>
{availableTokens.map((t) => (
<option key={t.id} value={t.id}>
{(t.token ?? t.id).slice(0, 12)}{t.label ? ` · ${t.label}` : ''} · {t.seats_used}/{t.max_seats} seats
</option>
))}
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{PLATFORMS.map((p) => {
const Icon = p.icon;
const selected = installPlatform === p.id;
return (
<button
key={p.id}
onClick={() => setInstallPlatform(p.id)}
disabled={!p.available}
className={cn(
'p-4 border-2 rounded-lg text-left transition-all disabled:opacity-50 disabled:cursor-not-allowed',
selected ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300',
)}
>
<div className="flex items-center justify-between mb-2">
<Icon className={cn('w-6 h-6', p.id === 'linux' ? 'text-orange-600' : p.id === 'windows' ? 'text-blue-600' : 'text-gray-600')} />
{selected && <CheckCircle className="w-4 h-4 text-blue-600" />}
</div>
<div className="font-medium text-gray-900">{p.name}</div>
<div className="text-xs text-gray-500 mt-1">{p.description}</div>
</button>
);
})}
</div>
{installToken ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Installation command
{installPlatform === 'windows' && <span className="text-blue-600"> (Run in PowerShell as Administrator)</span>}
</label>
<div className="relative">
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<code>{installCommand}</code>
</pre>
<button
onClick={() => copyToClipboard(installCommand, 'install')}
className="absolute top-2 right-2 p-2 bg-gray-700 text-white rounded hover:bg-gray-600"
title="Copy command"
>
{copiedCommand === 'install' ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
) : (
<div className="text-sm text-gray-500 bg-gray-50 border border-gray-200 rounded-lg p-4">
Select a key above to generate the one-liner.
</div>
)}
</div>
)}
</div>
)}
{/* Create-key form */}
{showCreate && (
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Create new registration key</h2>
<form onSubmit={handleCreateToken} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Label *</label>
<input
type="text"
required
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
placeholder="e.g., Production Servers"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Expires In</label>
<select
value={formData.expires_in}
onChange={(e) => setFormData({ ...formData, expires_in: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="24h">24 hours</option>
<option value="72h">3 days</option>
<option value="168h">7 days (1 week)</option>
</select>
<p className="mt-1 text-xs text-gray-500">Maximum 7 days per server security policy</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats (Agents)</label>
<input
type="number"
min="1"
max="100"
value={formData.max_seats || 1}
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">Number of agents that can enroll with this key</p>
</div>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={createToken.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{createToken.isPending ? 'Creating...' : 'Create key'}
</button>
<button
type="button"
onClick={() => setShowCreate(false)}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* Created-key reveal — shown once, dismissed by the operator */}
{createdToken && (
<div className="bg-green-50 border border-green-300 rounded-lg p-6 mb-6">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<h3 className="text-lg font-semibold text-green-900">Key created</h3>
</div>
<button onClick={() => setCreatedToken(null)} className="text-green-600 hover:text-green-800 text-sm">
Dismiss
</button>
</div>
<p className="text-sm text-green-800 mb-3">
Copy this key now. It cannot be retrieved again only a hash is stored.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-green-700 mb-1">Token</label>
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-sm bg-white border border-green-200 px-3 py-2 rounded select-all">
{createdToken.token}
</code>
<button
onClick={() => copyToClipboard(createdToken.token, 'created-token')}
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
title="Copy token"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-green-700 mb-1">Install command</label>
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-xs bg-white border border-green-200 px-3 py-2 rounded select-all overflow-x-auto">
{createdToken.install_command}
</code>
<button
onClick={() => copyToClipboard(createdToken.install_command, 'created-cmd')}
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
title="Copy install command"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
)}
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
<StatCard label="Total keys" value={stats.total_tokens} icon={Shield} />
<StatCard label="Active" value={stats.active_tokens} valueClass="text-green-600" icon={CheckCircle} />
<StatCard label="Used" value={stats.used_tokens} valueClass="text-blue-600" icon={Users} />
<StatCard label="Expired" value={stats.expired_tokens} valueClass="text-gray-600" icon={Clock} />
<StatCard
label="Seats used"
value={`${stats.total_seats_used}/${stats.total_seats_available || '∞'}`}
valueClass="text-purple-600"
icon={Users}
/>
</div>
)}
{/* Master-detail */}
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6">
{/* LEFT: key list */}
<div className="bg-white rounded-lg border border-gray-200 flex flex-col max-h-[70vh]">
<div className="p-4 border-b border-gray-200 space-y-3">
<SearchInput value={searchTerm} onChange={setSearchTerm} placeholder="Search by label..." />
<div className="flex flex-wrap gap-1">
{(['all', 'active', 'used', 'expired', 'revoked'] as StatusFilter[]).map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={cn(
'px-2.5 py-1 rounded-md text-xs capitalize transition-colors border',
statusFilter === s
? 'bg-gray-100 text-gray-800 border-gray-300'
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50',
)}
>
{s}
</button>
))}
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">{filteredTokens.length} keys</span>
<button
onClick={handleCleanup}
disabled={cleanupTokens.isPending}
className="text-xs text-orange-600 hover:text-orange-800 inline-flex items-center gap-1 disabled:opacity-50"
title="Clean up expired keys"
>
<RefreshCw className={cn('w-3 h-3', cleanupTokens.isPending && 'animate-spin')} />
Cleanup expired
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-8 text-center">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<p className="mt-2 text-sm text-gray-500">Loading keys...</p>
</div>
) : filteredTokens.length === 0 ? (
<div className="p-8 text-center">
<Shield className="w-10 h-10 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">
{searchTerm || statusFilter !== 'all' ? 'No keys match.' : 'No keys yet. Create one to enroll agents.'}
</p>
</div>
) : (
filteredTokens.map((t) => {
const selected = t.id === selectedTokenId;
return (
<button
key={t.id}
onClick={() => {
setSelectedTokenId(t.id);
setRevealToken(false);
}}
className={cn(
'w-full text-left px-4 py-3 border-b border-gray-100 transition-colors',
selected ? 'bg-blue-50 border-l-2 border-l-blue-500' : 'hover:bg-gray-50 border-l-2 border-l-transparent',
)}
>
<div className={cn('font-medium truncate', t.status === 'revoked' ? 'text-gray-400 line-through' : 'text-gray-900')}>
{tokenLabel(t)}
</div>
<div className="mt-1 flex items-center gap-1.5 text-xs text-gray-500">
<span className={cn('inline-block w-2 h-2 rounded-full', STATUS_DOT[t.status] || 'bg-gray-400')} />
<span>{getStatusText(t)}</span>
<span>·</span>
<span>
{t.seats_used}/{t.max_seats} seats
</span>
{t.status === 'active' && t.expires_at && (
<>
<span>·</span>
<span>
{Math.round((new Date(t.expires_at).getTime() - Date.now()) / 86400000)}d left
</span>
</>
)}
</div>
</button>
);
})
)}
</div>
</div>
{/* RIGHT: detail */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
{!selectedToken ? (
<div className="text-center py-16">
<Shield className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">Select a key to see its details and enrolled agents.</p>
</div>
) : (
<div className="space-y-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h2 className={cn('text-xl font-semibold truncate', selectedToken.status === 'revoked' ? 'text-gray-400 line-through' : 'text-gray-900')}>
{tokenLabel(selectedToken)}
</h2>
<div className="mt-1">
<span className={cn('badge badge-lg', tokenStatusColor(selectedToken.status))}>
{getStatusText(selectedToken)}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{selectedToken.status === 'active' && (
<button
onClick={() => handleRevokeToken(selectedToken.id, tokenLabel(selectedToken))}
disabled={revokeToken.isPending}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-orange-700 bg-orange-50 border border-orange-200 rounded-md hover:bg-orange-100 disabled:opacity-50"
title="Revoke key — stops new enrollments, enrolled agents keep working"
>
<AlertTriangle className="w-4 h-4" />
Revoke key
</button>
)}
<button
onClick={() => handleDeleteToken(selectedToken.id, tokenLabel(selectedToken))}
disabled={deleteToken.isPending}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-700 bg-red-50 border border-red-200 rounded-md hover:bg-red-100 disabled:opacity-50"
title="Permanently delete key"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
</div>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div>
<dt className="text-gray-500">Token</dt>
<dd className="mt-0.5 font-mono text-gray-900 break-all">
{selectedToken.token ? (
<span className="inline-flex items-center gap-2">
<code>{revealToken ? selectedToken.token : `rk_${'•'.repeat(20)}${selectedToken.token.slice(-4)}`}</code>
<button
onClick={() => setRevealToken((v) => !v)}
className="text-xs text-blue-600 hover:text-blue-800"
>
{revealToken ? 'hide' : 'reveal'}
</button>
<button
onClick={() => copyToClipboard(selectedToken.token!, 'detail-token')}
className="text-gray-400 hover:text-gray-600"
title="Copy token"
>
{copiedCommand === 'detail-token' ? <CheckCircle className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</span>
) : (
<span className="text-gray-400 italic">
Not retrievable ({selectedToken.status} keys aren't decryptable)
</span>
)}
</dd>
</div>
<div>
<dt className="text-gray-500">Seats</dt>
<dd className="mt-0.5 text-gray-900">
{selectedToken.seats_used} used / {selectedToken.max_seats}
{selectedToken.seats_used >= selectedToken.max_seats && (
<span className="ml-2 text-xs text-red-600">(Full)</span>
)}
</dd>
</div>
<div>
<dt className="text-gray-500">Created</dt>
<dd className="mt-0.5 text-gray-900">
{formatDateTime(selectedToken.created_at)}
{selectedToken.created_by && <span className="text-gray-500"> by {selectedToken.created_by}</span>}
</dd>
</div>
<div>
<dt className="text-gray-500">Expires</dt>
<dd className="mt-0.5 text-gray-900">{formatDateTime(selectedToken.expires_at)}</dd>
</div>
<div>
<dt className="text-gray-500">Last used</dt>
<dd className="mt-0.5 text-gray-900">
{selectedToken.used_at ? formatDateTime(selectedToken.used_at) : 'Never'}
</dd>
</div>
</dl>
<div className="alert alert-warning">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800">
Revoking this key stops new enrollments. Agents already enrolled with it keep working revoke them individually below if access must end.
</p>
</div>
</div>
{/* Bound agents */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-gray-900">
Agents enrolled with this key
{boundAgentsData && <span className="ml-1 text-gray-500">({boundAgentsData.count})</span>}
</h3>
<button onClick={() => refetch()} className="text-xs text-gray-400 hover:text-gray-600 inline-flex items-center gap-1">
<RefreshCw className="w-3 h-3" />
Refresh
</button>
</div>
{boundAgentsLoading ? (
<div className="py-6 text-center">
<div className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
</div>
) : boundAgents.length === 0 ? (
<div className="py-8 text-center bg-gray-50 rounded-lg border border-gray-200">
<Users className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">No agents have enrolled with this key yet.</p>
</div>
) : (
<div className="overflow-x-auto border border-gray-200 rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Hostname</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">OS</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Last seen</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Enrolled</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{boundAgents.map((ba: BoundAgent) => {
const online = isOnline(ba.last_seen);
return (
<tr key={ba.agent_id} className="hover:bg-gray-50">
<td className="px-4 py-2.5">
<Link to={`/agents/${ba.agent_id}`} className="font-mono text-sm text-blue-600 hover:text-blue-800">
{ba.hostname}
</Link>
</td>
<td className="px-4 py-2.5 text-sm text-gray-600 capitalize">{ba.os_type}</td>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-1.5 text-sm text-gray-700">
<span className={cn('inline-block w-2 h-2 rounded-full', online ? 'bg-green-500' : 'bg-gray-400')} />
{online ? 'online' : 'offline'}
</span>
</td>
<td className="px-4 py-2.5 text-sm text-gray-600">{formatRelativeTime(ba.last_seen)}</td>
<td className="px-4 py-2.5 text-sm text-gray-600">{formatDateTime(ba.used_at)}</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => handleRevokeAgent(ba.agent_id, ba.hostname)}
disabled={revokeAgentMutation.isPending}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-red-700 bg-red-50 border border-red-200 rounded hover:bg-red-100 disabled:opacity-50"
title="Revoke agent — invalidates refresh tokens"
>
<AlertTriangle className="w-3 h-3" />
Revoke agent
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Signing keys — preserved capability from the old Agent Management page */}
<div className="border-t border-gray-200 pt-5">
<h3 className="text-sm font-semibold text-gray-900 mb-3">🔑 Server signing key</h3>
{isLoadingServerKeySecurity ? (
<div className="py-3 text-center">
<div className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
</div>
) : serverKeySecurity?.has_private_key ? (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="alert alert-success rounded-md p-2.5 flex-1">
<p className="text-sm text-green-800 inline-flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Server has a private key for signing agent updates.
</p>
</div>
<code className="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded font-mono">
{serverKeySecurity.public_key_fingerprint}
</code>
</div>
) : (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="alert alert-warning rounded-md p-2.5 flex-1">
<p className="text-sm text-amber-800">
Server is missing a private key generate one to enable secure agent updates.
</p>
</div>
<button
onClick={generateKeys}
disabled={generatingKeys}
className="inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md disabled:opacity-50"
>
{generatingKeys ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Generating...
</>
) : (
<>
<Key className="w-4 h-4" />
Generate signing keys
</>
)}
</button>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
};
const StatCard: React.FC<{
label: string;
value: React.ReactNode;
valueClass?: string;
icon: React.ComponentType<{ className?: string }>;
}> = ({ label, value, valueClass, icon: Icon }) => (
<div className="card card-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">{label}</p>
<p className={cn('text-2xl font-bold text-gray-900', valueClass)}>{value}</p>
</div>
<Icon className="w-7 h-7 text-gray-400" />
</div>
</div>
);
export default AgentsEnrollment;

View file

@ -477,6 +477,17 @@ export interface ApiError {
details?: any;
}
// BoundAgent — per-seat view returned by GET /admin/registration-tokens/:id/agents.
// Fields match the Go BoundAgent struct in queries/registration_tokens.go.
export interface BoundAgent {
agent_id: string;
hostname: string;
os_type: string;
status: string;
last_seen: string;
used_at: string;
}
// Registration Token types
export interface RegistrationToken {
id: string;