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
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue