Watch
1
0
Fork
You've already forked RedFlag
0

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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