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:
parent
e8cd4de44f
commit
99d97a07ee
46 changed files with 1879 additions and 1977 deletions
|
|
@ -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 }}
|
||||
|
|
|
|||
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "redflag-desktop"
|
||||
version = "0.2.8"
|
||||
version = "0.2.9"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || {
|
||||
|
|
|
|||
|
|
@ -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
1
helper/Cargo.lock
generated
|
|
@ -216,6 +216,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "redflag-helper"
|
||||
version = "0.2.8"
|
||||
version = "0.2.9"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
272
web/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 & 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 →
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
884
web/src/pages/settings/AgentsEnrollment.tsx
Normal file
884
web/src/pages/settings/AgentsEnrollment.tsx
Normal 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 & 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue