diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0b74632 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# MinerOps repository-root Docker build context +.git +.env +.env.* +!.env.example +!.env.dev.example +**/__pycache__/ +**/*.pyc +**/*.pyo +**/*.dump +**/*.sql.gz +backend/app/main.py.bak* diff --git a/agents/windows/Enroll-MinerOpsAgent.ps1 b/agents/windows/Enroll-MinerOpsAgent.ps1 new file mode 100644 index 0000000..7e2668e --- /dev/null +++ b/agents/windows/Enroll-MinerOpsAgent.ps1 @@ -0,0 +1,323 @@ +#Requires -RunAsAdministrator +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" + +$Target = "C:\MinerOpsAgent" +$ConfigPath = Join-Path $Target "agent-config.json" +$PendingConfigPath = Join-Path $Target "agent-config.json.pending" +$RecoveryConfigPath = Join-Path $Target "agent-config.recovery.json" +$EnrollmentPath = Join-Path $PSScriptRoot "enrollment.json" +$Installer = Join-Path $PSScriptRoot "install.ps1" + +$RequiredFiles = @( + "install.ps1", + "MinerOpsAgentService.ps1", + "MinerOpsAgentTray.ps1", + "MinerOpsAgentGUI.ps1", + "MinerOpsApi3EnrichmentPoller.ps1", + "MinerOpsPoolPoller.ps1", + "MinerOpsPoolPollerTask.ps1", + "EnrichOneMiner.ps1", + "MinerOpsControl.ps1", + "VERSION", + "enrollment.json" +) + +function Protect-MinerOpsCredentialFile { + param( + [Parameter(Mandatory = $true)] + [string] $Path + ) + + $acl = New-Object System.Security.AccessControl.FileSecurity + $acl.SetAccessRuleProtection($true, $false) + + $identities = @( + (New-Object System.Security.Principal.SecurityIdentifier("S-1-5-18")), + (New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-544")), + [System.Security.Principal.WindowsIdentity]::GetCurrent().User + ) + + foreach ($identity in $identities) { + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $identity, + [System.Security.AccessControl.FileSystemRights]::FullControl, + [System.Security.AccessControl.InheritanceFlags]::None, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ) + + $acl.AddAccessRule($rule) + } + + Set-Acl -LiteralPath $Path -AclObject $acl +} + +function ConvertFrom-MinerOpsSecureString { + param( + [Parameter(Mandatory = $true)] + [Security.SecureString] $SecureValue + ) + + $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR( + $SecureValue + ) + + try { + return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) + } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) + } +} + +# Validate the complete package before modifying the Agent host or consuming +# the single-use enrollment token. +foreach ($name in $RequiredFiles) { + $source = Join-Path $PSScriptRoot $name + + if (!(Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Enrollment package is missing $name" + } +} + +if (Test-Path -LiteralPath $ConfigPath) { + throw @" +C:\MinerOpsAgent\agent-config.json already exists. +This enrollment bootstrapper is for a new Agent identity and will not overwrite +an existing identity. Use the normal MinerOps upgrade procedure instead. +"@ +} + +foreach ($recoveryPath in @($PendingConfigPath, $RecoveryConfigPath)) { + if (Test-Path -LiteralPath $recoveryPath) { + throw @" +A previous enrollment recovery file exists at: +$recoveryPath + +Do not create or redeem another enrollment until that recovery state has been +reviewed. +"@ + } +} + +$enrollment = Get-Content -LiteralPath $EnrollmentPath -Raw | + ConvertFrom-Json + +$HubUrl = ([string] $enrollment.hub_url).TrimEnd("/") +$EnrollmentToken = ([string] $enrollment.token).Trim() + +$parsedHub = $null + +if ( + -not [Uri]::TryCreate( + $HubUrl, + [UriKind]::Absolute, + [ref] $parsedHub + ) -or + $parsedHub.Scheme -notin @("http", "https") +) { + throw "enrollment.json contains an invalid MinerOps Hub URL." +} + +if ( + $EnrollmentToken.Length -lt 32 -or + $EnrollmentToken.Length -gt 256 +) { + throw "enrollment.json contains an invalid enrollment token." +} + +Write-Host "" +Write-Host "MinerOps Windows Agent enrollment" +Write-Host "Hub: $HubUrl" +Write-Host "" +Write-Host "API3 credentials stay only on this Windows Agent." +Write-Host "Leave the account blank if API3 features are not being configured yet." +Write-Host "" + +$Api3Account = (Read-Host "WhatsMiner API3 account").Trim() +$Api3PasswordPlain = "" + +if ($Api3Account) { + $Api3PasswordSecure = Read-Host ` + "WhatsMiner API3 password" ` + -AsSecureString + + $Api3PasswordPlain = ConvertFrom-MinerOpsSecureString ` + -SecureValue $Api3PasswordSecure +} + +$Redeemed = $false +$ConfigPersisted = $false + +try { + New-Item -ItemType Directory -Force -Path $Target | Out-Null + + # Create protected empty files BEFORE token redemption. If persistence is + # not possible, fail before consuming the single-use token. + [IO.File]::WriteAllText($PendingConfigPath, "") + Protect-MinerOpsCredentialFile -Path $PendingConfigPath + + [IO.File]::WriteAllText($RecoveryConfigPath, "") + Protect-MinerOpsCredentialFile -Path $RecoveryConfigPath + + $requestBody = @{ + token = $EnrollmentToken + } | ConvertTo-Json -Compress + + Write-Host "" + Write-Host "Redeeming one-time MinerOps enrollment..." + + $response = Invoke-RestMethod ` + -Method Post ` + -Uri "$HubUrl/api/agent/enroll" ` + -ContentType "application/json" ` + -Body $requestBody ` + -TimeoutSec 30 + + $Redeemed = $true + + if (-not $response.data) { + throw "MinerOps enrollment returned no Agent data." + } + + $agent = $response.data + + foreach ($required in @( + "agent_id", + "agent_secret", + "site_id", + "site_name", + "agent_zone_id", + "agent_zone_name" + )) { + if (-not ([string] $agent.$required).Trim()) { + throw "MinerOps enrollment response is missing $required." + } + } + + $ranges = @() + + foreach ($cidr in @($agent.ip_ranges)) { + $network = ([string] $cidr).Trim() + + if (-not $network) { + continue + } + + $ranges += [pscustomobject]@{ + name = "$($agent.agent_zone_name) - $network" + cidr = $network + enabled = $true + api_port = 4028 + api3_port = 4433 + } + } + + $config = [ordered]@{ + hub_url = $HubUrl + agent_id = [string] $agent.agent_id + site_id = [string] $agent.site_id + agent_zone_id = [string] $agent.agent_zone_id + site_name = [string] $agent.site_name + agent_secret = [string] $agent.agent_secret + poll_interval_seconds = 120 + ip_ranges = @($ranges) + api3_account = $Api3Account + api3_password = $Api3PasswordPlain + api3_port = 4433 + } + + $configJson = $config | ConvertTo-Json -Depth 20 + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + + # Write a protected recovery copy first. If anything after redemption + # fails, the permanent Agent credential is not lost with the consumed token. + [IO.File]::WriteAllText( + $RecoveryConfigPath, + $configJson + [Environment]::NewLine, + $utf8NoBom + ) + + [IO.File]::WriteAllText( + $PendingConfigPath, + $configJson + [Environment]::NewLine, + $utf8NoBom + ) + + Move-Item ` + -LiteralPath $PendingConfigPath ` + -Destination $ConfigPath + + $ConfigPersisted = $true + + Remove-Item -LiteralPath $RecoveryConfigPath -Force + + # The extracted bootstrap token is no longer needed once permanent + # credentials have been persisted locally. + Remove-Item -LiteralPath $EnrollmentPath -Force + + $EnrollmentToken = $null + $requestBody = $null + $Api3PasswordPlain = $null + + Write-Host "Permanent Agent identity saved locally." + Write-Host "Installing MinerOps Agent runtime..." + + & $Installer + + $mainTask = Get-ScheduledTask ` + -TaskName "MinerOpsAgent" ` + -ErrorAction Stop + + if ([string] $mainTask.State -ne "Running") { + Start-ScheduledTask -TaskName "MinerOpsAgent" -ErrorAction Stop + } + + Write-Host "" + Write-Host "MinerOps Agent enrollment completed." + Write-Host "Agent ID: $($agent.agent_id)" + Write-Host "Site: $($agent.site_name)" + Write-Host "Agent Zone: $($agent.agent_zone_name)" + Write-Host "" + Write-Host "Delete the downloaded ZIP and extracted setup folder after verification." +} +catch { + if (-not $Redeemed) { + foreach ($temporaryPath in @( + $PendingConfigPath, + $RecoveryConfigPath + )) { + if (Test-Path -LiteralPath $temporaryPath) { + Remove-Item ` + -LiteralPath $temporaryPath ` + -Force ` + -ErrorAction SilentlyContinue + } + } + } + elseif ($ConfigPersisted) { + Write-Warning @" +The enrollment token was consumed and the permanent Agent identity WAS saved +to C:\MinerOpsAgent\agent-config.json. + +Do not create a replacement enrollment. Correct the installation problem and +rerun install.ps1 from this package. +"@ + } + else { + Write-Warning @" +The enrollment token was consumed. + +A protected credential recovery file may exist at: +C:\MinerOpsAgent\agent-config.recovery.json + +Do not create or redeem another enrollment until that recovery file has been +reviewed. +"@ + } + + throw +} diff --git a/agents/windows/README.md b/agents/windows/README.md index 9a4a3c0..9664599 100644 --- a/agents/windows/README.md +++ b/agents/windows/README.md @@ -30,9 +30,12 @@ API3 account and password values are local secrets. Missing credentials make miner control unavailable. Passwords, API tokens, salts, and the Agent secret must not be written to normal logs or returned to MinerOps command history. -The Internal Alpha supports one Agent identity protected by the deployment -shared secret. A second active Agent requires per-Agent credentials before it -may be enabled. +New Agent identities use per-Agent credentials created through secure +one-time enrollment. The permanent Agent secret is returned only to the +enrolling Agent and is stored locally in `agent-config.json`; MinerOps stores +only its hash. The legacy deployment shared secret remains only for explicitly +retained compatibility behavior and must not authenticate a newly enrolled +per-Agent identity. ## Legacy Compatibility @@ -43,35 +46,52 @@ Agent queue; the modular control backend does not connect directly to miners. ## Deployment -Deploy Agent compatibility before enabling Hub enforcement of the -`X-MinerOps-Agent-ID` header: +### New Agent enrollment -1. Copy this directory to a temporary folder on the Windows Agent host. -2. Create or update `C:\MinerOpsAgent\agent-config.json` locally. Do not pass - secrets as installer or scheduled-task arguments. -3. Run `install.ps1` from an elevated Windows PowerShell session. -4. Confirm the four scheduled tasks are registered and that Agent check-in - reports the expected non-secret `VERSION`. -5. Enable the matching Agent ID and shared secret on the Hub. +For a new Agent, use Agent Management in MinerOps: -The installer copies only managed runtime code to `C:\MinerOpsAgent` and -preserves `agent-config.json`, `agent-state.json`, `logs\`, backups, probe -output, and other runtime data. During an upgrade, existing definitions for -`MinerOpsAgent`, `MinerOpsAgentTray`, `MinerOpsApi3Enrichment`, and -`MinerOpsPoolPoller` are authoritative and are not rewritten. Missing tasks are -created for a fresh or incomplete installation without hard-coded user names. +1. Choose the Site and an enabled, unassigned Agent Zone. +2. Enter a descriptive Agent name. +3. Create a Windows enrollment. +4. Download MinerOpsAgent-Windows.zip before the enrollment expires. +5. Copy the ZIP to the intended Windows machine and extract it. +6. Open Windows PowerShell as Administrator in the extracted + MinerOpsAgent-Windows directory. +7. Run: -If `MinerOpsAgent` was running before an upgrade, the installer stops only that + .\Enroll-MinerOpsAgent.ps1 + +8. Enter the local WhatsMiner API3 account/password when prompted, or leave + the account blank if API3 functionality is not being configured yet. +9. The bootstrapper exchanges the short-lived token with POST /api/agent/enroll, + saves the resulting permanent Agent identity locally, and hands off to the + safe runtime installer. +10. Verify the Agent appears in Agent Management and checks in successfully. +11. Delete the downloaded ZIP and extracted setup directory. + +Do not place enrollment.json, enrollment tokens, Agent secrets, or API3 +credentials in screenshots, chat, tickets, logs, or source control. + +### Existing Agent upgrade + +For an already enrolled/configured Agent, do not run the enrollment +bootstrapper. Copy the new canonical Agent files to a temporary directory and +run install.ps1 from an elevated Windows PowerShell session. + +The installer copies only managed runtime code to C:\MinerOpsAgent and +preserves agent-config.json, agent-state.json, logs\, backups, probe output, +and other runtime data. Existing Scheduled Task definitions are authoritative +and are not rewritten. Missing tasks are created for a fresh or incomplete +installation without hard-coded user names. + +If MinerOpsAgent was running before an upgrade, the installer stops only that Scheduled Task, waits for it to stop, replaces the managed files, and starts it -again. A previously stopped Agent remains stopped. The tray and periodic tasks -are not unnecessarily restarted. +again. A previously stopped Agent remains stopped. Before replacing runtime +files, the installer copies them to a timestamped backup under +C:\MinerOpsAgent\backups. -Before replacing existing runtime files, the installer copies them to a -timestamped folder under `C:\MinerOpsAgent\backups`. A failed deployment -automatically restores those managed files, removes tasks created by that -failed attempt, and restores the main Agent's prior running state. For a manual -rollback, stop `MinerOpsAgent`, restore the selected backup's runtime files, -and restart it only if it was previously running. `uninstall.ps1` unregisters -the four tasks and removes runtime code while preserving local configuration, -state, logs, and backups. +A failed deployment restores managed files, removes tasks created by that +failed attempt, and restores the main Agent's prior running state. +uninstall.ps1 unregisters the four MinerOps Scheduled Tasks and removes runtime +code while preserving local configuration, state, logs, and backups. diff --git a/agents/windows/tests/Test-MinerOpsEnrollment.ps1 b/agents/windows/tests/Test-MinerOpsEnrollment.ps1 new file mode 100644 index 0000000..cd3dbd3 --- /dev/null +++ b/agents/windows/tests/Test-MinerOpsEnrollment.ps1 @@ -0,0 +1,117 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path $PSScriptRoot -Parent +$bootstrap = Join-Path $root "Enroll-MinerOpsAgent.ps1" + +$tokens = $null +$parseErrors = $null + +[System.Management.Automation.Language.Parser]::ParseFile( + $bootstrap, + [ref] $tokens, + [ref] $parseErrors +) | Out-Null + +if ($parseErrors.Count -gt 0) { + throw "Enroll-MinerOpsAgent.ps1 parser errors: $($parseErrors.Message -join '; ')" +} + +$source = Get-Content -LiteralPath $bootstrap -Raw + +function Assert-SourceContains { + param( + [Parameter(Mandatory)][string] $Pattern, + [Parameter(Mandatory)][string] $Message + ) + + if ($source -notmatch $Pattern) { + throw $Message + } +} + +Assert-SourceContains 'enrollment\.json' ` + "Bootstrap enrollment file is not explicit." + +Assert-SourceContains '/api/agent/enroll' ` + "Bootstrap does not use the secure enrollment endpoint." + +Assert-SourceContains 'Read-Host[\s\S]*-AsSecureString' ` + "API3 password is not prompted as a SecureString." + +Assert-SourceContains 'agent-config\.json\.pending' ` + "Atomic pending config path is missing." + +Assert-SourceContains 'agent-config\.recovery\.json' ` + "Post-redemption credential recovery path is missing." + +Assert-SourceContains 'Protect-MinerOpsCredentialFile' ` + "Credential file ACL protection is missing." + +Assert-SourceContains 'S-1-5-18' ` + "SYSTEM ACL entry is missing." + +Assert-SourceContains 'S-1-5-32-544' ` + "Administrators ACL entry is missing." + +Assert-SourceContains 'WindowsIdentity\]::GetCurrent\(\)\.User' ` + "Installing user ACL entry is missing." + +Assert-SourceContains 'Move-Item[\s\S]*\$ConfigPath' ` + "Atomic final config move is missing." + +Assert-SourceContains '& \$Installer' ` + "Bootstrap does not hand off to the safe installer." + +Assert-SourceContains 'Start-ScheduledTask -TaskName "MinerOpsAgent"' ` + "Fresh Agent startup is missing." + +Assert-SourceContains 'Remove-Item -LiteralPath \$EnrollmentPath' ` + "Consumed extracted enrollment token is not removed." + +$existingConfigGuard = $source.IndexOf( + 'Test-Path -LiteralPath $ConfigPath' +) + +$redeemCall = $source.IndexOf( + '"$HubUrl/api/agent/enroll"' +) + +if ( + $existingConfigGuard -lt 0 -or + $redeemCall -lt 0 -or + $existingConfigGuard -gt $redeemCall +) { + throw "Existing Agent identity must be rejected before token redemption." +} + +$configMove = $source.IndexOf( + '-Destination $ConfigPath' +) + +$installerCall = $source.IndexOf( + '& $Installer' +) + +if ( + $configMove -lt 0 -or + $installerCall -lt 0 -or + $configMove -gt $installerCall +) { + throw "Permanent Agent credentials must be persisted before installation." +} + +foreach ($sensitiveVariable in @( + 'EnrollmentToken', + 'Api3PasswordPlain' +)) { + if ( + $source -match ( + '(?im)Write-(Host|Output|Verbose|Information).*?\$' + + $sensitiveVariable + ) + ) { + throw "$sensitiveVariable must not be written to output." + } +} + +Write-Host "MinerOps enrollment bootstrap source safety tests passed." diff --git a/backend/Dockerfile b/backend/Dockerfile index f45d4c4..ca576c5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,15 +5,18 @@ WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 -COPY requirements.txt /app/requirements.txt +COPY backend/requirements.txt /app/requirements.txt RUN pip install --no-cache-dir --upgrade pip \ && pip install --no-cache-dir -r /app/requirements.txt -COPY app /app/app -COPY alembic.ini /app/alembic.ini -COPY alembic /app/alembic +COPY backend/app /app/app +COPY backend/alembic.ini /app/alembic.ini +COPY backend/alembic /app/alembic + +# Canonical Agent source used to construct short-lived enrollment packages. +COPY agents/windows /app/agents/windows EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/agent_enrollment/models.py b/backend/app/agent_enrollment/models.py index 4186840..a938b8c 100644 --- a/backend/app/agent_enrollment/models.py +++ b/backend/app/agent_enrollment/models.py @@ -92,3 +92,7 @@ class AgentEnrollResponse(BaseModel): data: AgentEnrollCredentials meta: dict[str, object] = Field(default_factory=dict) error: None = None + + +class AgentWindowsPackageRequest(AgentEnrollRequest): + """Short-lived token used to construct a Windows bootstrap package.""" diff --git a/backend/app/agent_enrollment/router.py b/backend/app/agent_enrollment/router.py index ee58989..087c58a 100644 --- a/backend/app/agent_enrollment/router.py +++ b/backend/app/agent_enrollment/router.py @@ -3,7 +3,7 @@ from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from app.agent_enrollment.dependencies import get_agent_enrollment_service from app.agent_enrollment.exceptions import ( @@ -17,8 +17,15 @@ from app.agent_enrollment.models import ( AgentEnrollmentListResponse, AgentEnrollRequest, AgentEnrollResponse, + AgentWindowsPackageRequest, ) from app.agent_enrollment.service import AgentEnrollmentService +from app.agent_packages.windows import ( + AgentWindowsPackageError, + AgentWindowsPackageSourceError, + build_windows_agent_package, + normalize_hub_url, +) from app.identity import require_permission @@ -85,6 +92,50 @@ def list_agent_enrollments( ) + +@router.post("/api/agent-enrollments/windows-package") +def download_windows_agent_package( + package_request: AgentWindowsPackageRequest, + http_request: Request, + current_user: EnrollmentManager, +) -> Response: + del current_user + + browser_origin = http_request.headers.get("origin", "").strip() + + try: + hub_url = normalize_hub_url(browser_origin) + except AgentWindowsPackageError as exc: + raise HTTPException( + status_code=400, + detail=( + "Windows Agent package downloads require " + "a valid browser Origin" + ), + ) from exc + + try: + package = build_windows_agent_package( + enrollment_token=package_request.token, + hub_url=hub_url, + ) + except AgentWindowsPackageSourceError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except AgentWindowsPackageError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return Response( + content=package, + media_type="application/zip", + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + "Content-Disposition": ( + 'attachment; filename="MinerOpsAgent-Windows.zip"' + ), + }, + ) + @router.post( "/api/agent/enroll", response_model=AgentEnrollResponse, diff --git a/backend/app/agent_packages/__init__.py b/backend/app/agent_packages/__init__.py new file mode 100644 index 0000000..9eab608 --- /dev/null +++ b/backend/app/agent_packages/__init__.py @@ -0,0 +1 @@ +"""Downloadable MinerOps Agent deployment packages.""" diff --git a/backend/app/agent_packages/windows.py b/backend/app/agent_packages/windows.py new file mode 100644 index 0000000..9d65b10 --- /dev/null +++ b/backend/app/agent_packages/windows.py @@ -0,0 +1,164 @@ +"""Build short-lived Windows Agent enrollment packages in memory.""" + +from __future__ import annotations + +import io +import json +import os +import zipfile +from pathlib import Path +from urllib.parse import urlsplit + + +WINDOWS_PACKAGE_ROOT = "MinerOpsAgent-Windows" + +WINDOWS_PACKAGE_FILES = ( + "Enroll-MinerOpsAgent.ps1", + "install.ps1", + "uninstall.ps1", + "MinerOpsAgentService.ps1", + "MinerOpsAgentTray.ps1", + "MinerOpsAgentGUI.ps1", + "MinerOpsApi3EnrichmentPoller.ps1", + "MinerOpsPoolPoller.ps1", + "MinerOpsPoolPollerTask.ps1", + "EnrichOneMiner.ps1", + "MinerOpsControl.ps1", + "VERSION", + "README.md", +) + +DEFAULT_WINDOWS_AGENT_SOURCE = Path( + os.environ.get( + "MINEROPS_WINDOWS_AGENT_SOURCE", + "/app/agents/windows", + ) +) + + +class AgentWindowsPackageError(RuntimeError): + """Base Windows package construction error.""" + + +class AgentWindowsPackageSourceError(AgentWindowsPackageError): + """Canonical Windows Agent source is unavailable or incomplete.""" + + +def normalize_hub_url(value: str) -> str: + candidate = value.strip() + parsed = urlsplit(candidate) + + if parsed.scheme not in {"http", "https"}: + raise AgentWindowsPackageError( + "MinerOps Hub URL must use http or https" + ) + + if not parsed.netloc: + raise AgentWindowsPackageError( + "MinerOps Hub URL must include a host" + ) + + if parsed.username or parsed.password: + raise AgentWindowsPackageError( + "MinerOps Hub URL must not contain credentials" + ) + + if parsed.query or parsed.fragment: + raise AgentWindowsPackageError( + "MinerOps Hub URL must not contain query or fragment data" + ) + + if parsed.path not in {"", "/"}: + raise AgentWindowsPackageError( + "MinerOps Hub URL must be an origin without a path" + ) + + return f"{parsed.scheme}://{parsed.netloc}" + + +def build_windows_agent_package( + *, + enrollment_token: str, + hub_url: str, + source_root: Path | None = None, +) -> bytes: + token = enrollment_token.strip() + + if not 32 <= len(token) <= 256: + raise AgentWindowsPackageError( + "Enrollment token has an invalid length" + ) + + normalized_hub_url = normalize_hub_url(hub_url) + + root = ( + Path(source_root) + if source_root is not None + else DEFAULT_WINDOWS_AGENT_SOURCE + ) + + if not root.is_dir(): + raise AgentWindowsPackageSourceError( + "Canonical Windows Agent source is unavailable" + ) + + missing = [ + name + for name in WINDOWS_PACKAGE_FILES + if not (root / name).is_file() + ] + + if missing: + raise AgentWindowsPackageSourceError( + "Canonical Windows Agent source is incomplete: " + + ", ".join(missing) + ) + + enrollment = { + "hub_url": normalized_hub_url, + "token": token, + } + + instructions = """MinerOps Windows Agent + +1. Extract this ZIP on the target Windows machine. +2. Open Windows PowerShell as Administrator. +3. Change to the extracted MinerOpsAgent-Windows folder. +4. Run: + + .\\Enroll-MinerOpsAgent.ps1 + +The enrollment credential is short-lived and single-use. +Do not post enrollment.json in chat, tickets, screenshots, or logs. +Delete the ZIP and extracted setup folder after successful installation. +""" + + buffer = io.BytesIO() + + with zipfile.ZipFile( + buffer, + mode="w", + compression=zipfile.ZIP_DEFLATED, + ) as archive: + for name in WINDOWS_PACKAGE_FILES: + archive.write( + root / name, + arcname=f"{WINDOWS_PACKAGE_ROOT}/{name}", + ) + + archive.writestr( + f"{WINDOWS_PACKAGE_ROOT}/enrollment.json", + json.dumps( + enrollment, + indent=2, + sort_keys=True, + ) + + "\n", + ) + + archive.writestr( + f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt", + instructions, + ) + + return buffer.getvalue() diff --git a/backend/app/agent_registry/__init__.py b/backend/app/agent_registry/__init__.py new file mode 100644 index 0000000..26ec0b1 --- /dev/null +++ b/backend/app/agent_registry/__init__.py @@ -0,0 +1 @@ +"""Read-only Agent registry projection.""" diff --git a/backend/app/agent_registry/dependencies.py b/backend/app/agent_registry/dependencies.py new file mode 100644 index 0000000..49d05e2 --- /dev/null +++ b/backend/app/agent_registry/dependencies.py @@ -0,0 +1,8 @@ +"""Dependencies for the Agent registry read model.""" + +from app.agent_registry.repository import AgentRegistryRepository +from app.agent_registry.service import AgentRegistryService + + +def get_agent_registry_service() -> AgentRegistryService: + return AgentRegistryService(AgentRegistryRepository()) diff --git a/backend/app/agent_registry/models.py b/backend/app/agent_registry/models.py new file mode 100644 index 0000000..ea5eec3 --- /dev/null +++ b/backend/app/agent_registry/models.py @@ -0,0 +1,62 @@ +"""Typed read models for Agent Management.""" + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, Field + + +CredentialState = Literal["legacy", "active", "revoked"] +RoutingState = Literal["assigned", "unassigned", "disabled"] + + +class AgentRegistrySite(BaseModel): + id: UUID + name: str + + +class AgentRegistryZone(BaseModel): + id: UUID + site_id: UUID + site_name: str + name: str + description: str | None = None + is_enabled: bool + assigned_agent_id: str | None = None + networks: list[str] = Field(default_factory=list) + + +class AgentRegistryAgent(BaseModel): + agent_id: str + display_name: str | None = None + + site_id: UUID | None = None + site_name: str + + agent_zone_id: UUID | None = None + agent_zone_name: str | None = None + agent_zone_enabled: bool | None = None + + hostname: str | None = None + version: str | None = None + ip_address: str | None = None + last_checkin_at: datetime + + credential_state: CredentialState + credential_created_at: datetime | None = None + credential_revoked_at: datetime | None = None + + routing_state: RoutingState + + +class AgentRegistryData(BaseModel): + agents: list[AgentRegistryAgent] = Field(default_factory=list) + sites: list[AgentRegistrySite] = Field(default_factory=list) + zones: list[AgentRegistryZone] = Field(default_factory=list) + + +class AgentRegistryResponse(BaseModel): + data: AgentRegistryData + meta: dict[str, object] = Field(default_factory=dict) + error: None = None diff --git a/backend/app/agent_registry/repository.py b/backend/app/agent_registry/repository.py new file mode 100644 index 0000000..4cd37a4 --- /dev/null +++ b/backend/app/agent_registry/repository.py @@ -0,0 +1,127 @@ +"""PostgreSQL reads for the durable Agent registry.""" + +import psycopg +from psycopg.rows import dict_row + +from app.config import settings + + +def default_connection_factory(): + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + row_factory=dict_row, + ) + + +class AgentRegistryRepository: + def __init__(self, connection_factory=default_connection_factory): + self.connection_factory = connection_factory + + def list_registry(self) -> dict[str, list[dict]]: + connection = self.connection_factory() + + try: + with connection.cursor() as cur: + cur.execute( + """ + SELECT + site.id, + site.name + FROM sites AS site + WHERE EXISTS ( + SELECT 1 + FROM agent_zones AS zone + WHERE zone.site_id = site.id + ) + ORDER BY lower(site.name), site.id + """ + ) + sites = cur.fetchall() + + cur.execute( + """ + SELECT + zone.id, + zone.site_id, + site.name AS site_name, + zone.name, + zone.description, + zone.is_enabled, + assigned.agent_id AS assigned_agent_id, + COALESCE( + array_agg( + network.network::text + ORDER BY network.network::text + ) FILTER (WHERE network.id IS NOT NULL), + ARRAY[]::text[] + ) AS networks + FROM agent_zones AS zone + JOIN sites AS site + ON site.id = zone.site_id + LEFT JOIN minerops_site_agents AS assigned + ON assigned.agent_zone_id = zone.id + LEFT JOIN agent_zone_network_ranges AS network + ON network.agent_zone_id = zone.id + GROUP BY + zone.id, + zone.site_id, + site.name, + zone.name, + zone.description, + zone.is_enabled, + assigned.agent_id + ORDER BY + lower(site.name), + lower(zone.name), + zone.id + """ + ) + zones = cur.fetchall() + + cur.execute( + """ + SELECT + agent.agent_id, + agent.display_name, + agent.site_id, + COALESCE(site.name, agent.site_name) AS site_name, + agent.agent_zone_id, + zone.name AS agent_zone_name, + zone.is_enabled AS agent_zone_enabled, + agent.hostname, + agent.version, + agent.ip_address, + agent.last_checkin_at, + agent.credential_created_at, + agent.credential_revoked_at, + CASE + WHEN agent.credential_revoked_at IS NOT NULL + THEN 'revoked' + WHEN agent.agent_secret_hash IS NOT NULL + THEN 'active' + ELSE 'legacy' + END AS credential_state + FROM minerops_site_agents AS agent + LEFT JOIN sites AS site + ON site.id = agent.site_id + LEFT JOIN agent_zones AS zone + ON zone.id = agent.agent_zone_id + ORDER BY + lower(COALESCE(site.name, agent.site_name)), + lower(COALESCE(agent.display_name, agent.agent_id)), + agent.agent_id + """ + ) + agents = cur.fetchall() + + return { + "sites": sites, + "zones": zones, + "agents": agents, + } + finally: + connection.close() diff --git a/backend/app/agent_registry/router.py b/backend/app/agent_registry/router.py new file mode 100644 index 0000000..94eab00 --- /dev/null +++ b/backend/app/agent_registry/router.py @@ -0,0 +1,48 @@ +"""Read-only Agent Management API.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Response + +from app.agent_registry.dependencies import get_agent_registry_service +from app.agent_registry.models import AgentRegistryResponse +from app.agent_registry.service import AgentRegistryService +from app.identity import require_permission + + +router = APIRouter(tags=["agents"]) + +RegistryService = Annotated[ + AgentRegistryService, + Depends(get_agent_registry_service), +] + +AgentViewer = Annotated[ + dict, + Depends(require_permission("agents.view")), +] + + +@router.get( + "/api/agents", + response_model=AgentRegistryResponse, +) +def list_agents( + response: Response, + current_user: AgentViewer, + service: RegistryService, +) -> AgentRegistryResponse: + del current_user + + response.headers["Cache-Control"] = "no-store" + + data = service.list_registry() + + return AgentRegistryResponse( + data=data, + meta={ + "agent_count": len(data.agents), + "site_count": len(data.sites), + "zone_count": len(data.zones), + }, + ) diff --git a/backend/app/agent_registry/service.py b/backend/app/agent_registry/service.py new file mode 100644 index 0000000..f200e6c --- /dev/null +++ b/backend/app/agent_registry/service.py @@ -0,0 +1,52 @@ +"""Application service for the Agent Management read model.""" + +from app.agent_registry.models import ( + AgentRegistryAgent, + AgentRegistryData, + AgentRegistrySite, + AgentRegistryZone, +) + + +class AgentRegistryService: + def __init__(self, repository): + self.repository = repository + + def list_registry(self) -> AgentRegistryData: + rows = self.repository.list_registry() + + sites = [ + AgentRegistrySite(**row) + for row in rows["sites"] + ] + + zones = [ + AgentRegistryZone(**row) + for row in rows["zones"] + ] + + agents: list[AgentRegistryAgent] = [] + + for row in rows["agents"]: + zone_id = row.get("agent_zone_id") + zone_enabled = row.get("agent_zone_enabled") + + if zone_id is None: + routing_state = "unassigned" + elif zone_enabled is False: + routing_state = "disabled" + else: + routing_state = "assigned" + + agents.append( + AgentRegistryAgent( + **row, + routing_state=routing_state, + ) + ) + + return AgentRegistryData( + agents=agents, + sites=sites, + zones=zones, + ) diff --git a/backend/app/frontend/agent_management_view.py b/backend/app/frontend/agent_management_view.py new file mode 100644 index 0000000..35a99f0 --- /dev/null +++ b/backend/app/frontend/agent_management_view.py @@ -0,0 +1,274 @@ +"""Semantic HTML for the Internal Alpha Agent Management page.""" + + +def agent_management_content(can_manage_agents: bool) -> str: + management = """ +
+
+
+

Agent deployment

+

Add Agent

+

+ Create a short-lived one-time enrollment for an Agent Zone. + Windows is the currently supported deployment platform. +

+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +

+
+ + +
+ """ if can_manage_agents else """ +
+
+
+

Agent deployment

+

Agent enrollment

+
+
+

+ Your account can view Agent status but cannot create Agent + enrollments. +

+
+ """ + + return f""" +
+ Loading Agents + Requesting Agent registry and enrollment information. +
+ + + + + + +""" diff --git a/backend/app/frontend/router.py b/backend/app/frontend/router.py index 458be5b..494e653 100644 --- a/backend/app/frontend/router.py +++ b/backend/app/frontend/router.py @@ -18,6 +18,7 @@ DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))] MapUser = Annotated[dict, Depends(require_permission("locations.view"))] RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))] MinerUser = Annotated[dict, Depends(require_permission("miners.view"))] +AgentUser = Annotated[dict, Depends(require_permission("agents.view"))] def render_page(view: str, current_user: dict, **context) -> str: @@ -92,6 +93,27 @@ def miner_detail_script() -> Response: headers={"Cache-Control": "no-store"}, ) +@router.get( + "/assets/minerops-agent-management.js", + include_in_schema=False, +) +def agent_management_script() -> Response: + return Response( + (STATIC_ROOT / "agent-management.js").read_text(encoding="utf-8"), + media_type="text/javascript", + headers={"Cache-Control": "no-store"}, + ) + + +@router.get( + "/agent/management", + response_class=HTMLResponse, + include_in_schema=False, +) +def agent_management_page(current_user: AgentUser) -> str: + return render_page("agent_management", current_user) + + @router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False) def dashboard_page(current_user: DashboardUser) -> str: return render_page("dashboard", current_user) diff --git a/backend/app/frontend/static/agent-management.js b/backend/app/frontend/static/agent-management.js new file mode 100644 index 0000000..61a8dd2 --- /dev/null +++ b/backend/app/frontend/static/agent-management.js @@ -0,0 +1,491 @@ +(function () { + "use strict"; + + if (document.body.dataset.view !== "agent_management") return; + + const ids = [ + "agentManagementLoading", + "agentManagementError", + "agentManagementErrorText", + "agentManagementRetry", + "agentManagementContent", + "agentManagementRefresh", + "agentCount", + "agentZoneCount", + "agentWaitingEnrollmentCount", + "agentRows", + "agentEmpty", + "agentZoneRows", + "agentZoneEmpty", + "agentEnrollmentRows", + "agentEnrollmentEmpty", + "agentCreateForm", + "agentSite", + "agentZone", + "agentDisplayName", + "agentPlatform", + "agentCreateSubmit", + "agentCreateStatus", + "agentEnrollmentResult", + "agentEnrollmentToken", + "agentEnrollmentExpires", + "agentEnrollmentDownload", + "agentEnrollmentCopy", + "agentEnrollmentDismiss", + "agentEnrollmentCopyStatus" + ]; + + const el = {}; + + ids.forEach(function (id) { + el[id] = document.getElementById(id); + }); + + let registry = { + agents: [], + sites: [], + zones: [] + }; + + let enrollments = []; + + function text(value, fallback) { + if (value === null || value === undefined || value === "") { + return fallback || "Unavailable"; + } + return String(value); + } + + function dateTime(value) { + if (!value) return "Never"; + const parsed = new Date(value); + if (Number.isNaN(parsed.valueOf())) return "Unavailable"; + return parsed.toLocaleString(); + } + + function words(value) { + return text(value, "Unknown") + .replaceAll("_", " ") + .replace(/\b\w/g, function (letter) { + return letter.toUpperCase(); + }); + } + + function cell(value, className) { + const node = document.createElement("td"); + if (className) node.className = className; + node.textContent = value; + return node; + } + + function setState(name) { + el.agentManagementLoading.hidden = name !== "loading"; + el.agentManagementError.hidden = name !== "error"; + el.agentManagementContent.hidden = name !== "ready"; + } + + async function api(path, options) { + const response = await fetch(path, Object.assign({ + credentials: "same-origin", + cache: "no-store", + headers: { + "Accept": "application/json" + } + }, options || {})); + + const payload = await response.json().catch(function () { + return {}; + }); + + if (!response.ok) { + const detail = payload && payload.detail + ? String(payload.detail) + : "Request failed with status " + response.status + "."; + throw new Error(detail); + } + + return payload; + } + + function renderAgents() { + el.agentRows.replaceChildren(); + + registry.agents.forEach(function (agent) { + const row = document.createElement("tr"); + + const identity = document.createElement("td"); + const name = document.createElement("strong"); + const id = document.createElement("span"); + + name.textContent = text(agent.display_name, "Unnamed Agent"); + id.className = "secondary"; + id.textContent = agent.agent_id; + + identity.append(name, id); + + const location = [ + text(agent.site_name, "Unknown site"), + text(agent.agent_zone_name, "No Agent Zone") + ].join(" / "); + + const host = [ + text(agent.hostname, "Unknown host"), + text(agent.ip_address, "No IP") + ].join(" / "); + + row.append( + identity, + cell(location), + cell(host), + cell(text(agent.version, "Unknown")), + cell(dateTime(agent.last_checkin_at)), + cell(words(agent.credential_state)), + cell(words(agent.routing_state)) + ); + + el.agentRows.appendChild(row); + }); + + el.agentEmpty.hidden = registry.agents.length !== 0; + } + + function renderZones() { + el.agentZoneRows.replaceChildren(); + + registry.zones.forEach(function (zone) { + const row = document.createElement("tr"); + + const networks = Array.isArray(zone.networks) && zone.networks.length + ? zone.networks.join(", ") + : "No networks recorded"; + + row.append( + cell(zone.site_name), + cell(zone.name), + cell(networks), + cell(text(zone.assigned_agent_id, "Unassigned")), + cell(zone.is_enabled ? "Enabled" : "Disabled") + ); + + el.agentZoneRows.appendChild(row); + }); + + el.agentZoneEmpty.hidden = registry.zones.length !== 0; + } + + function renderEnrollments() { + el.agentEnrollmentRows.replaceChildren(); + + enrollments.forEach(function (enrollment) { + const row = document.createElement("tr"); + + row.append( + cell(enrollment.display_name), + cell( + enrollment.site_name + " / " + + enrollment.agent_zone_name + ), + cell(words(enrollment.status)), + cell(dateTime(enrollment.created_at)), + cell(dateTime(enrollment.expires_at)), + cell(text(enrollment.enrolled_agent_id, "Not enrolled")) + ); + + el.agentEnrollmentRows.appendChild(row); + }); + + el.agentEnrollmentEmpty.hidden = enrollments.length !== 0; + } + + function populateSites() { + if (!el.agentSite) return; + + const selected = el.agentSite.value; + + el.agentSite.replaceChildren(); + + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Choose a site"; + el.agentSite.appendChild(placeholder); + + registry.sites.forEach(function (site) { + const option = document.createElement("option"); + option.value = site.id; + option.textContent = site.name; + el.agentSite.appendChild(option); + }); + + if ([...el.agentSite.options].some(function (option) { + return option.value === selected; + })) { + el.agentSite.value = selected; + } + + populateZones(); + } + + function populateZones() { + if (!el.agentZone || !el.agentSite) return; + + const siteId = el.agentSite.value; + const selected = el.agentZone.value; + + el.agentZone.replaceChildren(); + + const placeholder = document.createElement("option"); + placeholder.value = ""; + + if (!siteId) { + placeholder.textContent = "Choose a site first"; + el.agentZone.disabled = true; + el.agentZone.appendChild(placeholder); + return; + } + + placeholder.textContent = "Choose an Agent Zone"; + el.agentZone.appendChild(placeholder); + + const available = registry.zones.filter(function (zone) { + return ( + zone.site_id === siteId && + zone.is_enabled === true && + !zone.assigned_agent_id + ); + }); + + available.forEach(function (zone) { + const option = document.createElement("option"); + option.value = zone.id; + option.textContent = zone.name; + el.agentZone.appendChild(option); + }); + + el.agentZone.disabled = available.length === 0; + + if ([...el.agentZone.options].some(function (option) { + return option.value === selected; + })) { + el.agentZone.value = selected; + } + } + + function renderSummary() { + el.agentCount.textContent = String(registry.agents.length); + el.agentZoneCount.textContent = String(registry.zones.length); + + const waiting = enrollments.filter(function (item) { + return item.status === "waiting"; + }).length; + + el.agentWaitingEnrollmentCount.textContent = String(waiting); + } + + function render() { + renderSummary(); + renderAgents(); + renderZones(); + renderEnrollments(); + populateSites(); + } + + async function refreshData(showLoading) { + if (showLoading !== false) setState("loading"); + + try { + const results = await Promise.all([ + api("/api/agents"), + api("/api/agent-enrollments") + ]); + + registry = results[0] && results[0].data + ? results[0].data + : { agents: [], sites: [], zones: [] }; + + enrollments = results[1] && Array.isArray(results[1].data) + ? results[1].data + : []; + + render(); + setState("ready"); + } catch (error) { + el.agentManagementErrorText.textContent = + error.message || "Agent information could not be loaded."; + setState("error"); + } + } + + async function createEnrollment(event) { + event.preventDefault(); + + if (!el.agentCreateForm) return; + + el.agentCreateSubmit.disabled = true; + el.agentCreateStatus.textContent = "Creating enrollment..."; + + try { + const payload = await api("/api/agent-enrollments", { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + site_id: el.agentSite.value, + agent_zone_id: el.agentZone.value, + display_name: el.agentDisplayName.value.trim() + }) + }); + + const enrollment = payload.data; + + el.agentEnrollmentToken.textContent = + enrollment.enrollment_token; + + el.agentEnrollmentExpires.textContent = + "Expires " + dateTime(enrollment.expires_at); + + el.agentEnrollmentCopyStatus.textContent = ""; + el.agentEnrollmentResult.hidden = false; + el.agentCreateStatus.textContent = "Enrollment created."; + + el.agentDisplayName.value = ""; + + await refreshData(false); + } catch (error) { + el.agentCreateStatus.textContent = + error.message || "Enrollment could not be created."; + } finally { + el.agentCreateSubmit.disabled = false; + } + } + + async function downloadWindowsPackage() { + const token = el.agentEnrollmentToken.textContent; + + if (!token) { + el.agentEnrollmentCopyStatus.textContent = + "No enrollment token is available to download."; + return; + } + + el.agentEnrollmentDownload.disabled = true; + el.agentEnrollmentCopyStatus.textContent = + "Building Windows Agent package..."; + + try { + const response = await fetch( + "/api/agent-enrollments/windows-package", + { + method: "POST", + credentials: "same-origin", + cache: "no-store", + headers: { + "Accept": "application/zip", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: token + }) + } + ); + + if (!response.ok) { + const payload = await response.json().catch(function () { + return {}; + }); + + throw new Error( + payload && payload.detail + ? String(payload.detail) + : "Package request failed with status " + + response.status + "." + ); + } + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = downloadUrl; + link.download = "MinerOpsAgent-Windows.zip"; + + document.body.appendChild(link); + link.click(); + link.remove(); + + window.URL.revokeObjectURL(downloadUrl); + + dismissToken(); + + el.agentCreateStatus.textContent = + "Windows Agent package downloaded. " + + "The on-screen token has been cleared."; + } catch (error) { + el.agentEnrollmentCopyStatus.textContent = + error.message || "Windows package could not be downloaded."; + } finally { + el.agentEnrollmentDownload.disabled = false; + } + } + + async function copyToken() { + const token = el.agentEnrollmentToken.textContent; + + if (!token) return; + + try { + await navigator.clipboard.writeText(token); + el.agentEnrollmentCopyStatus.textContent = + "Enrollment token copied."; + } catch (error) { + el.agentEnrollmentCopyStatus.textContent = + "Copy was unavailable. Select and copy the token manually."; + } + } + + function dismissToken() { + el.agentEnrollmentToken.textContent = ""; + el.agentEnrollmentExpires.textContent = ""; + el.agentEnrollmentCopyStatus.textContent = ""; + el.agentEnrollmentResult.hidden = true; + } + + el.agentManagementRetry.addEventListener("click", function () { + refreshData(true); + }); + + if (el.agentManagementRefresh) { + el.agentManagementRefresh.addEventListener("click", function () { + refreshData(true); + }); + } + + if (el.agentSite) { + el.agentSite.addEventListener("change", populateZones); + } + + if (el.agentCreateForm) { + el.agentCreateForm.addEventListener("submit", createEnrollment); + } + + if (el.agentEnrollmentDownload) { + el.agentEnrollmentDownload.addEventListener( + "click", + downloadWindowsPackage + ); + } + + if (el.agentEnrollmentCopy) { + el.agentEnrollmentCopy.addEventListener("click", copyToken); + } + + if (el.agentEnrollmentDismiss) { + el.agentEnrollmentDismiss.addEventListener( + "click", + dismissToken + ); + } + + refreshData(true); +}()); diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css index d405bb4..365f5e4 100644 --- a/backend/app/frontend/static/app.css +++ b/backend/app/frontend/static/app.css @@ -1771,3 +1771,86 @@ button.map-position:hover { flex-direction: column; } } + +/* Agent Management v1 */ + +.agent-summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 20px; +} + +.agent-summary-card { + display: grid; + gap: 6px; +} + +.agent-summary-card span { + font-size: 0.85rem; +} + +.agent-summary-card strong { + font-size: 2rem; +} + +.agent-management-columns { + display: grid; + gap: 20px; + margin-bottom: 20px; +} + +.agent-table td:first-child { + display: grid; + gap: 4px; +} + +.agent-create-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.agent-create-form .agent-form-actions, +.agent-create-form #agentCreateStatus { + grid-column: 1 / -1; +} + +.agent-form-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.agent-enrollment-result { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid var(--line); +} + +.agent-token-box { + overflow-wrap: anywhere; + padding: 14px; + margin: 12px 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-muted); +} + +.agent-token-box code { + user-select: all; +} + +@media (max-width: 760px) { + .agent-summary-grid, + .agent-create-form { + grid-template-columns: 1fr; + } +} + +/* Agent Management action visibility */ +.agent-management-columns .quiet-button, +.agent-enrollment-panel .quiet-button { + color: var(--ink); +} diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py index c5908bc..7b63db3 100644 --- a/backend/app/frontend/views.py +++ b/backend/app/frontend/views.py @@ -6,6 +6,7 @@ from typing import Callable from app.frontend.miner_detail_view import miner_detail_content from app.frontend.monitoring_trends_view import monitoring_trends_content from app.frontend.system_health_view import system_health_content +from app.frontend.agent_management_view import agent_management_content NAVIGATION = ( @@ -15,6 +16,7 @@ NAVIGATION = ( ("repairs", "Repair Center", "/operations/repairs", "repairs.view"), ("miners", "Miners", "/assets/miners", "miners.view"), ("system_health", "System Health", "/system-health", "system_health.view"), + ("agent_management", "Agents", "/agent/management", "agents.view"), ) PAGE_DETAILS = { @@ -53,6 +55,11 @@ PAGE_DETAILS = { "System Health", "Inspect application services, operational data, and maintenance facts.", ), + "agent_management": ( + "Administration / Agents", + "Agent Management", + "Inspect Agent identities, routing boundaries, and secure enrollment.", + ), } @@ -462,7 +469,9 @@ def render_app_page( else '' ) content = ( - system_health_content() + agent_management_content(has_permission("agents.manage")) + if view == "agent_management" + else system_health_content() if view == "system_health" else miner_detail_content() if view == "miner_detail" diff --git a/backend/app/identity.py b/backend/app/identity.py index 10a687d..c470bec 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -779,10 +779,14 @@ def required_api_permission(method: str, path: str) -> str | None: return "repairs.read" if component_request(path): return "components.create" if method == "POST" else "components.read" + if path == "/api/agents": + return "agents.view" if path == "/api/agent-zones": return "agents.view" if method == "GET" else "agents.manage" if path == "/api/agent-enrollments": return "agents.view" if method == "GET" else "agents.manage" + if path == "/api/agent-enrollments/windows-package": + return "agents.manage" if repair_parts_request(path): return "repairs.read" if method == "GET" else "repairs.parts.manage" if path.startswith("/api/admin/"): diff --git a/backend/app/main.py b/backend/app/main.py index 509d8f8..ef76a33 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,7 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError from app.config import settings from app.agent_commands.dependencies import require_agent_identity from app.agent_commands.router import router as agent_commands_router +from app.agent_registry.router import router as agent_registry_router from app.agent_zones.dependencies import get_agent_zone_service from app.agent_zones.models import MinerZoneAssociationResult from app.agent_zones.service import AgentZoneService @@ -73,6 +74,7 @@ app.include_router(frontend_router) app.include_router(inventory_router) app.include_router(miner_control_router) app.include_router(agent_commands_router) +app.include_router(agent_registry_router) app.include_router(agent_zones_router) app.include_router(agent_enrollment_router) app.include_router(components_router) diff --git a/backend/tests/test_agent_management_frontend.py b/backend/tests/test_agent_management_frontend.py new file mode 100644 index 0000000..382e240 --- /dev/null +++ b/backend/tests/test_agent_management_frontend.py @@ -0,0 +1,175 @@ +"""Frontend contract tests for Agent Management.""" + +import unittest +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.frontend.router import router +from app.identity import get_current_user, required_page_permission + + +STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static" + + +class AgentManagementFrontendTests(unittest.TestCase): + def setUp(self): + self.app = FastAPI() + self.app.include_router(router) + self.client = TestClient(self.app) + + def authenticate(self, permissions): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": "00000000-0000-4000-8000-000000009001", + "username": "agent-test-user", + "display_name": "Agent Test User", + "role": "Test Role", + "permissions": tuple(permissions), + } + + def test_page_requires_agents_view(self): + self.authenticate(("unrelated.permission",)) + self.assertEqual( + self.client.get("/agent/management").status_code, + 403, + ) + + self.authenticate(("agents.view",)) + response = self.client.get("/agent/management") + + self.assertEqual(response.status_code, 200) + self.assertIn("Agent Management", response.text) + self.assertIn('href="/agent/management"', response.text) + + def test_manage_controls_follow_agents_manage_permission(self): + self.authenticate(("agents.view",)) + + restricted = self.client.get("/agent/management").text + + self.assertNotIn('id="agentCreateForm"', restricted) + restricted_text = " ".join(restricted.split()) + self.assertIn( + "cannot create Agent enrollments.", + restricted_text, + ) + + self.authenticate(("agents.view", "agents.manage")) + + managed = self.client.get("/agent/management").text + + self.assertIn('id="agentCreateForm"', managed) + self.assertIn('id="agentSite"', managed) + self.assertIn('id="agentZone"', managed) + self.assertIn('id="agentDisplayName"', managed) + self.assertIn("Linux x64 — coming later", managed) + self.assertIn("Linux ARM64 / Raspberry Pi — coming later", managed) + + def test_route_and_asset_contract(self): + self.assertEqual( + required_page_permission("/agent/management"), + "agents.view", + ) + + self.authenticate(("agents.view",)) + + page = self.client.get("/agent/management") + self.assertIn( + "/assets/minerops-agent-management.js", + page.text, + ) + + asset = self.client.get( + "/assets/minerops-agent-management.js" + ) + + self.assertEqual(asset.status_code, 200) + self.assertEqual( + asset.headers.get("cache-control"), + "no-store", + ) + + def test_javascript_is_secret_conscious_and_safe_dom_only(self): + source = ( + STATIC_ROOT / "agent-management.js" + ).read_text(encoding="utf-8") + + self.assertIn('api("/api/agents")', source) + self.assertIn('api("/api/agent-enrollments")', source) + self.assertIn( + 'api("/api/agent-enrollments", {', + source, + ) + + for unsafe in ( + "innerHTML", + "outerHTML", + "insertAdjacentHTML", + "localStorage", + "sessionStorage", + "console.log", + ): + self.assertNotIn(unsafe, source) + + self.assertIn( + "el.agentEnrollmentToken.textContent", + source, + ) + self.assertIn( + "el.agentEnrollmentToken.textContent = \"\"", + source, + ) + + def test_agent_management_quiet_actions_have_visible_text(self): + css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8") + + self.assertIn( + ".agent-management-columns .quiet-button,", + css, + ) + self.assertIn( + ".agent-enrollment-panel .quiet-button {", + css, + ) + self.assertIn( + "color: var(--ink);", + css, + ) + + def test_windows_package_download_is_ephemeral(self): + self.authenticate(("agents.view", "agents.manage")) + + page = self.client.get("/agent/management").text + + self.assertIn('id="agentEnrollmentDownload"', page) + self.assertIn("Download Windows package", page) + + source = ( + STATIC_ROOT / "agent-management.js" + ).read_text(encoding="utf-8") + + self.assertIn( + '"/api/agent-enrollments/windows-package"', + source, + ) + self.assertIn( + 'link.download = "MinerOpsAgent-Windows.zip"', + source, + ) + self.assertIn( + "window.URL.revokeObjectURL(downloadUrl)", + source, + ) + self.assertIn("dismissToken();", source) + + for forbidden in ( + "localStorage", + "sessionStorage", + "agent_secret", + "api3_password", + ): + self.assertNotIn(forbidden, source) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_agent_registry.py b/backend/tests/test_agent_registry.py new file mode 100644 index 0000000..86eaede --- /dev/null +++ b/backend/tests/test_agent_registry.py @@ -0,0 +1,120 @@ +"""Focused tests for the Agent Management read model.""" + +import unittest +from uuid import UUID + +from app.agent_registry.service import AgentRegistryService +from app.identity import required_api_permission + + +SITE_ID = UUID("00000000-0000-4000-8000-000000001800") +ZONE_ID = UUID("00000000-0000-4000-8000-000000001801") + + +class FakeAgentRegistryRepository: + def list_registry(self): + return { + "sites": [ + { + "id": SITE_ID, + "name": "Test Site", + } + ], + "zones": [ + { + "id": ZONE_ID, + "site_id": SITE_ID, + "site_name": "Test Site", + "name": "Network A", + "description": None, + "is_enabled": True, + "assigned_agent_id": "agent-test", + "networks": ["10.50.0.0/24"], + } + ], + "agents": [ + { + "agent_id": "agent-test", + "display_name": "Test Agent", + "site_id": SITE_ID, + "site_name": "Test Site", + "agent_zone_id": ZONE_ID, + "agent_zone_name": "Network A", + "agent_zone_enabled": True, + "hostname": "test-agent", + "version": "1.0.0-alpha", + "ip_address": "10.50.0.10", + "last_checkin_at": "2026-08-26T20:00:00+00:00", + "credential_state": "active", + "credential_created_at": "2026-08-26T19:00:00+00:00", + "credential_revoked_at": None, + } + ], + } + + +class AgentRegistryServiceTests(unittest.TestCase): + def test_projection_is_factual_and_zone_aware(self): + data = AgentRegistryService( + FakeAgentRegistryRepository() + ).list_registry() + + self.assertEqual(len(data.agents), 1) + self.assertEqual(len(data.sites), 1) + self.assertEqual(len(data.zones), 1) + + agent = data.agents[0] + self.assertEqual(agent.agent_id, "agent-test") + self.assertEqual(agent.credential_state, "active") + self.assertEqual(agent.routing_state, "assigned") + self.assertEqual( + data.zones[0].networks, + ["10.50.0.0/24"], + ) + + def test_unassigned_and_disabled_routing_are_not_fabricated_online_states(self): + repository = FakeAgentRegistryRepository() + + repository.list_registry = lambda: { + "sites": [], + "zones": [], + "agents": [ + { + "agent_id": "legacy", + "display_name": None, + "site_id": None, + "site_name": "Legacy Site", + "agent_zone_id": None, + "agent_zone_name": None, + "agent_zone_enabled": None, + "hostname": None, + "version": None, + "ip_address": None, + "last_checkin_at": "2026-08-26T20:00:00+00:00", + "credential_state": "legacy", + "credential_created_at": None, + "credential_revoked_at": None, + } + ], + } + + data = AgentRegistryService(repository).list_registry() + + self.assertEqual( + data.agents[0].routing_state, + "unassigned", + ) + self.assertEqual( + data.agents[0].credential_state, + "legacy", + ) + + def test_api_permission_mapping_is_explicit(self): + self.assertEqual( + required_api_permission("GET", "/api/agents"), + "agents.view", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_agent_registry_postgres.py b/backend/tests/test_agent_registry_postgres.py new file mode 100644 index 0000000..7f7ac06 --- /dev/null +++ b/backend/tests/test_agent_registry_postgres.py @@ -0,0 +1,194 @@ +"""PostgreSQL integration test for the Agent registry projection.""" + +import unittest +from uuid import uuid4 + +from app.agent_registry.repository import ( + AgentRegistryRepository, + default_connection_factory, +) +from app.agent_registry.service import AgentRegistryService + + +class AgentRegistryPostgresTests(unittest.TestCase): + def setUp(self): + self.site_id = uuid4() + self.zone_id = uuid4() + self.agent_id = f"agent-registry-{uuid4().hex}" + + connection = default_connection_factory() + + try: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO sites (id, name) + VALUES (%s, %s) + """, + ( + self.site_id, + "Registry Integration Site", + ), + ) + + cur.execute( + """ + INSERT INTO agent_zones ( + id, + site_id, + name, + description, + is_enabled + ) + VALUES (%s, %s, %s, %s, true) + """, + ( + self.zone_id, + self.site_id, + "Registry Network", + "Integration-test network", + ), + ) + + cur.execute( + """ + INSERT INTO agent_zone_network_ranges ( + agent_zone_id, + network + ) + VALUES (%s, '10.251.0.0/24'::cidr) + """, + (self.zone_id,), + ) + + cur.execute( + """ + INSERT INTO minerops_site_agents ( + agent_id, + site_name, + site_id, + agent_zone_id, + display_name, + hostname, + version, + ip_address, + agent_secret_hash, + credential_created_at + ) + VALUES ( + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + now() + ) + """, + ( + self.agent_id, + "Registry Integration Site", + self.site_id, + self.zone_id, + "Registry Test Agent", + "registry-agent", + "1.0.0-alpha", + "10.251.0.10", + "a" * 64, + ), + ) + + connection.commit() + finally: + connection.close() + + def tearDown(self): + connection = default_connection_factory() + + try: + with connection.cursor() as cur: + cur.execute( + """ + DELETE FROM minerops_site_agents + WHERE agent_id = %s + """, + (self.agent_id,), + ) + + cur.execute( + """ + DELETE FROM agent_zone_network_ranges + WHERE agent_zone_id = %s + """, + (self.zone_id,), + ) + + cur.execute( + """ + DELETE FROM agent_zones + WHERE id = %s + """, + (self.zone_id,), + ) + + cur.execute( + """ + DELETE FROM sites + WHERE id = %s + """, + (self.site_id,), + ) + + connection.commit() + finally: + connection.close() + + def test_registry_joins_site_zone_network_and_redacts_secret_hash(self): + data = AgentRegistryService( + AgentRegistryRepository() + ).list_registry() + + agents = [ + item + for item in data.agents + if item.agent_id == self.agent_id + ] + + self.assertEqual(len(agents), 1) + + agent = agents[0] + + self.assertEqual( + agent.site_name, + "Registry Integration Site", + ) + self.assertEqual( + agent.agent_zone_name, + "Registry Network", + ) + self.assertEqual(agent.credential_state, "active") + self.assertEqual(agent.routing_state, "assigned") + + zones = [ + zone + for zone in data.zones + if zone.id == self.zone_id + ] + + self.assertEqual(len(zones), 1) + self.assertEqual( + zones[0].networks, + ["10.251.0.0/24"], + ) + + serialized = data.model_dump_json() + + self.assertNotIn("agent_secret_hash", serialized) + self.assertNotIn("a" * 64, serialized) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_agent_transport_isolation_postgres.py b/backend/tests/test_agent_transport_isolation_postgres.py new file mode 100644 index 0000000..2834c49 --- /dev/null +++ b/backend/tests/test_agent_transport_isolation_postgres.py @@ -0,0 +1,633 @@ +"""Runtime PostgreSQL isolation tests for Agent-owned machine transport.""" + +import asyncio +import json +import os +import unittest +from uuid import UUID + +import psycopg +from fastapi import HTTPException +from starlette.requests import Request + +from app.agent_zones.repository import AgentZoneRepository +from app.agent_zones.service import AgentZoneService +from app.config import settings +from app.main import ( + minerops_agent_api3_enrichment, + minerops_agent_miner_enrichment_v2, + minerops_agent_miner_enrichment_v3, +) + + +SITE_A = UUID("00000000-0000-4000-8000-000000001710") +SITE_B = UUID("00000000-0000-4000-8000-000000001711") +ZONE_A = UUID("00000000-0000-4000-8000-000000001712") +ZONE_B = UUID("00000000-0000-4000-8000-000000001713") +MINER_A = UUID("00000000-0000-4000-8000-000000001714") +MINER_B = UUID("00000000-0000-4000-8000-000000001715") + +AGENT_A = "agent-transport-isolation-a" +AGENT_B = "agent-transport-isolation-b" + +IP_A = "198.51.100.171" +IP_B = "198.51.100.172" + +MAC_A = "02:00:00:00:17:A1" +MAC_B = "02:00:00:00:17:B1" + + +def connect(): + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +def json_request(path, payload): + body = json.dumps(payload).encode("utf-8") + delivered = False + + async def receive(): + nonlocal delivered + + if delivered: + return { + "type": "http.request", + "body": b"", + "more_body": False, + } + + delivered = True + + return { + "type": "http.request", + "body": body, + "more_body": False, + } + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": path, + "raw_path": path.encode("ascii"), + "query_string": b"", + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 12345), + "server": ("testserver", 80), + } + + return Request(scope, receive) + + +class AgentTransportIsolationPostgresTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + if os.environ.get( + "MINEROPS_TRANSPORT_ISOLATION_DISPOSABLE" + ) != "1": + raise unittest.SkipTest( + "Runtime Agent transport isolation is executed " + "by its dedicated disposable PostgreSQL runner." + ) + + connection = connect() + + try: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO sites (id, name) + VALUES + (%s, 'Transport Isolation Site A'), + (%s, 'Transport Isolation Site B') + ON CONFLICT (id) DO NOTHING + """, + (SITE_A, SITE_B), + ) + + cur.execute( + """ + INSERT INTO agent_zones ( + id, + site_id, + name, + is_enabled + ) + VALUES + ( + %s, + %s, + 'Transport Isolation Zone A', + true + ), + ( + %s, + %s, + 'Transport Isolation Zone B', + true + ) + ON CONFLICT (id) DO UPDATE + SET is_enabled = true + """, + (ZONE_A, SITE_A, ZONE_B, SITE_B), + ) + + cur.execute( + """ + INSERT INTO minerops_site_agents ( + agent_id, + site_id, + agent_zone_id, + site_name, + last_payload + ) + VALUES + ( + %s, + %s, + %s, + 'Transport Isolation Site A', + '{}'::jsonb + ), + ( + %s, + %s, + %s, + 'Transport Isolation Site B', + '{}'::jsonb + ) + ON CONFLICT (agent_id) DO UPDATE + SET + site_id = EXCLUDED.site_id, + agent_zone_id = EXCLUDED.agent_zone_id, + site_name = EXCLUDED.site_name, + last_payload = EXCLUDED.last_payload + """, + ( + AGENT_A, + SITE_A, + ZONE_A, + AGENT_B, + SITE_B, + ZONE_B, + ), + ) + + connection.commit() + + finally: + connection.close() + + @classmethod + def tearDownClass(cls): + # Immutable identity/history rows intentionally are not deleted. + # The disposable Compose runner destroys the database volume. + pass + + @staticmethod + def _delete_optional_rows(cur, table, predicate, params): + cur.execute( + "SELECT to_regclass(%s)", + (f"public.{table}",), + ) + + if cur.fetchone()[0] is not None: + cur.execute( + f"DELETE FROM {table} WHERE {predicate}", + params, + ) + + def setUp(self): + self.zone_service = AgentZoneService( + AgentZoneRepository(connect) + ) + + connection = connect() + + try: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO miners ( + id, + site_id, + agent_zone_id, + asset_tag, + current_mac, + current_ip, + hostname, + miner_sn, + model, + miner_type, + api_version, + firmware_version, + platform, + status, + notes, + api3_supported + ) + VALUES + ( + %s, + %s, + %s, + 'TRANSPORT-ZONE-A', + %s, + %s::inet, + 'zone-a-before', + 'ZONE-A-SERIAL', + 'Zone A Model', + 'Zone A Model', + '1.0-a', + 'fw-a', + 'platform-a', + 'online', + 'Zone A baseline', + false + ), + ( + %s, + %s, + %s, + 'TRANSPORT-ZONE-B', + %s, + %s::inet, + 'zone-b-before', + 'ZONE-B-SERIAL', + 'Zone B Model', + 'Zone B Model', + '1.0-b', + 'fw-b', + 'platform-b', + 'online', + 'Zone B baseline', + false + ) + ON CONFLICT (id) DO NOTHING + """, + ( + MINER_A, + SITE_A, + ZONE_A, + MAC_A, + IP_A, + MINER_B, + SITE_B, + ZONE_B, + MAC_B, + IP_B, + ), + ) + + connection.commit() + + finally: + connection.close() + + def _table_exists(self, cur, table): + cur.execute( + "SELECT to_regclass(%s)", + (f"public.{table}",), + ) + return cur.fetchone()[0] is not None + + def _snapshot_rows(self, cur, table): + cur.execute( + f""" + SELECT to_jsonb(t) + FROM {table} AS t + WHERE miner_id IN (%s, %s) + ORDER BY to_jsonb(t)::text + """, + (MINER_A, MINER_B), + ) + + return [row[0] for row in cur.fetchall()] + + def _count_optional_miner_rows(self, cur, table, miner_id): + if not self._table_exists(cur, table): + return 0 + + cur.execute( + f""" + SELECT COUNT(*) + FROM {table} + WHERE miner_id = %s + """, + (miner_id,), + ) + + return cur.fetchone()[0] + + def _capture_inventory_state(self): + connection = connect() + + try: + with connection.cursor() as cur: + cur.execute( + """ + SELECT id::text, to_jsonb(m) + FROM miners AS m + WHERE id IN (%s, %s) + ORDER BY id + """, + (MINER_A, MINER_B), + ) + + miners = { + row[0]: row[1] + for row in cur.fetchall() + } + + ownerships = self._snapshot_rows( + cur, + "miner_mac_ownerships", + ) + + identities = self._snapshot_rows( + cur, + "miner_network_identities", + ) + + counts = {} + + for table in ( + "miner_status_snapshots", + "miner_api3_enrichment_snapshots", + "miner_api3_hashboard_snapshots", + "miner_pool_snapshots", + ): + counts[(table, "A")] = ( + self._count_optional_miner_rows( + cur, + table, + MINER_A, + ) + ) + counts[(table, "B")] = ( + self._count_optional_miner_rows( + cur, + table, + MINER_B, + ) + ) + + return { + "miners": miners, + "mac_ownerships": ownerships, + "network_identities": identities, + "snapshot_counts": counts, + } + + finally: + connection.close() + + def _cross_zone_api3_probe_count(self): + connection = connect() + + try: + with connection.cursor() as cur: + if not self._table_exists( + cur, + "miner_api3_enrichment_snapshots", + ): + return 0 + + cur.execute( + """ + SELECT COUNT(*) + FROM miner_api3_enrichment_snapshots + WHERE agent_id = %s + AND ( + host(ip_address) = %s + OR upper(mac_address) = upper(%s) + ) + """, + (AGENT_A, IP_B, MAC_B), + ) + + return cur.fetchone()[0] + + finally: + connection.close() + + def test_v2_cannot_address_other_zone_miner_directly(self): + before = self._capture_inventory_state() + + request = json_request( + "/api/agent/miner-enrichment-v2", + { + "miner_id": str(MINER_B), + "mac_address": MAC_B, + "hostname": "zone-a-must-not-write-this", + "model": "cross-zone-v2-probe", + }, + ) + + with self.assertRaises(HTTPException) as context: + asyncio.run( + minerops_agent_miner_enrichment_v2( + request, + AGENT_A, + self.zone_service, + ) + ) + + self.assertEqual(context.exception.status_code, 404) + + after = self._capture_inventory_state() + + self.assertEqual(after, before) + + def test_v2_cross_zone_mac_merge_cannot_mutate_either_miner(self): + before = self._capture_inventory_state() + + request = json_request( + "/api/agent/miner-enrichment-v2", + { + "miner_id": str(MINER_A), + "ip_address": IP_A, + "mac_address": MAC_B, + "hostname": "cross-zone-merge-probe", + "miner_sn": "MUST-NOT-CROSS-ZONES", + "model": "Cross Zone Merge Probe", + }, + ) + + error = None + + try: + asyncio.run( + minerops_agent_miner_enrichment_v2( + request, + AGENT_A, + self.zone_service, + ) + ) + except Exception as exc: + error = exc + + self.assertIsNotNone( + error, + "A Zone A miner must not successfully claim a Zone B MAC", + ) + + after = self._capture_inventory_state() + + self.assertEqual(after, before) + + def test_v3_cannot_match_or_mutate_other_zone_miner(self): + before = self._capture_inventory_state() + + request = json_request( + "/api/agent/miner-enrichment-v3", + { + "miner_id": str(MINER_B), + "ip_address": IP_B, + "mac_address": MAC_B, + "hostname": "zone-a-v3-cross-zone-probe", + "miner_sn": "V3-MUST-NOT-MUTATE", + "model": "V3 Cross Zone Probe", + "api_version": "99.99", + "firmware_version": "cross-zone", + "platform": "cross-zone", + "raw_version": {"cross_zone": True}, + "raw_miner_info": {"cross_zone": True}, + }, + ) + + result = asyncio.run( + minerops_agent_miner_enrichment_v3( + request, + AGENT_A, + self.zone_service, + ) + ) + + self.assertFalse(result["matched"]) + + after = self._capture_inventory_state() + + self.assertEqual(after, before) + + def test_api3_ingestion_cannot_associate_or_mutate_other_zone_miner(self): + before = self._capture_inventory_state() + + self.assertEqual( + self._cross_zone_api3_probe_count(), + 0, + ) + + request = json_request( + "/api/agent/api3-enrichment", + { + "agent_id": AGENT_B, + "site_name": "spoofed-zone-b", + "items": [ + { + "ip": IP_B, + "mac": MAC_B, + "api3_supported": True, + "api3_error": None, + "device_info": { + "msg": { + "network": { + "ip": IP_B, + "mac": MAC_B, + }, + "miner": { + "type": "Cross Zone API3 Probe", + "miner-sn": "API3-MUST-NOT-MUTATE", + }, + "system": { + "api": "99.99", + "apiswitch": "on", + "fwversion": "cross-zone-fw", + "platform": "cross-zone-platform", + }, + "power": { + "sn": "cross-zone-power", + "model": "cross-zone-psu", + }, + } + }, + "miner_setting": { + "msg": { + "power-mode": "high", + } + }, + "miner_status": { + "msg": { + "summary": { + "hash-average": 999, + "power-realtime": 9999, + "power-rate": 99, + "chip-temp-avg": 99, + "chip-temp-max": 100, + }, + "pools": [ + { + "id": 0, + "status": "Alive", + "account": "cross-zone-worker", + "url": "stratum+tcp://invalid.example:3333", + } + ], + "edevs": [ + { + "slot": 0, + "hash-average": 999, + "factory-hash": 999, + "freq": 999, + "effective-chips": 999, + } + ], + } + }, + "fan_setting": { + "msg": { + "mode": "cross-zone", + } + }, + "custom_data": { + "msg": { + "CustomerSn": "CROSS-ZONE-CUSTOMER", + } + }, + } + ], + }, + ) + + asyncio.run( + minerops_agent_api3_enrichment( + request, + AGENT_A, + self.zone_service, + ) + ) + + after = self._capture_inventory_state() + + self.assertEqual(after, before) + + self.assertEqual( + self._cross_zone_api3_probe_count(), + 0, + ( + "Agent A must not persist API3 enrichment associated " + "with Zone B identity data" + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_agent_windows_package.py b/backend/tests/test_agent_windows_package.py new file mode 100644 index 0000000..e578d2d --- /dev/null +++ b/backend/tests/test_agent_windows_package.py @@ -0,0 +1,198 @@ +"""Tests for downloadable Windows Agent enrollment packages.""" + +import io +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.agent_enrollment.router import router +from app.agent_packages.windows import ( + AgentWindowsPackageError, + AgentWindowsPackageSourceError, + WINDOWS_PACKAGE_FILES, + WINDOWS_PACKAGE_ROOT, + build_windows_agent_package, + normalize_hub_url, +) +from app.identity import get_current_user, required_api_permission + + +TOKEN = "t" * 43 + + +class WindowsPackageBuilderTests(unittest.TestCase): + def source_tree(self, root: Path): + for name in WINDOWS_PACKAGE_FILES: + (root / name).write_text(f"source:{name}\n") + + def test_package_contains_only_generic_source_plus_bootstrap_material(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.source_tree(root) + + package = build_windows_agent_package( + enrollment_token=TOKEN, + hub_url="https://minerops.example.test", + source_root=root, + ) + + with zipfile.ZipFile(io.BytesIO(package)) as archive: + names = set(archive.namelist()) + + expected = { + f"{WINDOWS_PACKAGE_ROOT}/{name}" + for name in WINDOWS_PACKAGE_FILES + } + + expected.update( + { + f"{WINDOWS_PACKAGE_ROOT}/enrollment.json", + f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt", + } + ) + + self.assertEqual(names, expected) + + enrollment = json.loads( + archive.read( + f"{WINDOWS_PACKAGE_ROOT}/enrollment.json" + ) + ) + + self.assertEqual(enrollment["token"], TOKEN) + self.assertEqual( + enrollment["hub_url"], + "https://minerops.example.test", + ) + + serialized = json.dumps(enrollment) + + self.assertNotIn("agent_secret", serialized) + self.assertNotIn("api3_password", serialized) + self.assertNotIn("api3_account", serialized) + self.assertNotIn("agent-config.json", "\n".join(names)) + + def test_missing_canonical_source_fails_closed(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.source_tree(root) + + (root / WINDOWS_PACKAGE_FILES[0]).unlink() + + with self.assertRaises(AgentWindowsPackageSourceError): + build_windows_agent_package( + enrollment_token=TOKEN, + hub_url="https://minerops.example.test", + source_root=root, + ) + + def test_hub_url_is_origin_only(self): + self.assertEqual( + normalize_hub_url("https://minerops.example.test/"), + "https://minerops.example.test", + ) + + for invalid in ( + "ftp://minerops.example.test", + "https://user:pass@minerops.example.test", + "https://minerops.example.test/path", + "https://minerops.example.test/?token=x", + ): + with self.subTest(value=invalid): + with self.assertRaises(AgentWindowsPackageError): + normalize_hub_url(invalid) + + +class WindowsPackageRouteTests(unittest.TestCase): + def setUp(self): + self.app = FastAPI() + self.app.include_router(router) + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": "00000000-0000-4000-8000-000000009100", + "username": "package-test", + "display_name": "Package Test", + "role": "Test", + "permissions": tuple(permissions), + } + + def test_permission_mapping_is_manage_only(self): + self.assertEqual( + required_api_permission( + "POST", + "/api/agent-enrollments/windows-package", + ), + "agents.manage", + ) + + def test_view_only_user_cannot_download_package(self): + self.authenticate(("agents.view",)) + + response = self.client.post( + "/api/agent-enrollments/windows-package", + headers={"Origin": "http://testserver"}, + json={ + "token": TOKEN, + }, + ) + + self.assertEqual(response.status_code, 403) + + def test_manager_download_is_same_origin_and_not_cacheable(self): + self.authenticate(("agents.view", "agents.manage")) + + response = self.client.post( + "/api/agent-enrollments/windows-package", + headers={"Origin": "http://testserver"}, + json={ + "token": TOKEN, + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.headers["content-type"], + "application/zip", + ) + self.assertEqual( + response.headers["cache-control"], + "no-store", + ) + self.assertIn( + 'filename="MinerOpsAgent-Windows.zip"', + response.headers["content-disposition"], + ) + + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + enrollment = json.loads( + archive.read( + f"{WINDOWS_PACKAGE_ROOT}/enrollment.json" + ) + ) + self.assertEqual(enrollment["token"], TOKEN) + + def test_package_requires_browser_origin(self): + self.authenticate(("agents.view", "agents.manage")) + + response = self.client.post( + "/api/agent-enrollments/windows-package", + json={ + "token": TOKEN, + }, + ) + + self.assertEqual(response.status_code, 400) + + +if __name__ == "__main__": + unittest.main() diff --git a/compose.dev.yaml b/compose.dev.yaml index 0fb51e0..c115a96 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -32,7 +32,8 @@ services: backend: build: - context: ./backend + context: . + dockerfile: backend/Dockerfile env_file: - .env.dev command: diff --git a/docker-compose.yml b/docker-compose.yml index 59e70b5..424084b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,8 @@ backend: build: - context: ./backend + context: . + dockerfile: backend/Dockerfile container_name: minerops-backend restart: unless-stopped env_file: @@ -51,4 +52,4 @@ networks: minerops-net: - driver: bridge + driver: bridge diff --git a/scripts/test-agent-management.sh b/scripts/test-agent-management.sh new file mode 100755 index 0000000..d7cb929 --- /dev/null +++ b/scripts/test-agent-management.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +project_name="${MINEROPS_AGENT_MANAGEMENT_TEST_PROJECT:-minerops-agent-management-test-$$}" + +compose=( + docker compose + --project-name "${project_name}" + --env-file .env.dev + -f compose.dev.yaml +) + +cleanup() { + status=$? + + if (( status != 0 )); then + "${compose[@]}" logs \ + --no-color \ + --tail 120 \ + postgres || true + fi + + "${compose[@]}" down \ + --volumes \ + --remove-orphans \ + >/dev/null 2>&1 || true + + exit "${status}" +} + +trap cleanup EXIT + +[[ -f .env.dev ]] || { + echo "Missing .env.dev" >&2 + exit 1 +} + +for source_file in \ + backend/app/agent_registry/__init__.py \ + backend/app/agent_registry/models.py \ + backend/app/agent_registry/repository.py \ + backend/app/agent_registry/service.py \ + backend/app/agent_registry/dependencies.py \ + backend/app/agent_registry/router.py \ + backend/tests/test_agent_registry.py \ + backend/tests/test_agent_registry_postgres.py \ + backend/tests/test_agent_management_frontend.py \ + backend/app/frontend/agent_management_view.py \ + backend/app/frontend/static/agent-management.js +do + [[ -r "${source_file}" ]] || { + echo "Missing ${source_file}" >&2 + exit 1 + } +done + +"${compose[@]}" up --detach postgres + +for attempt in $(seq 1 60); do + if "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + >/dev/null 2>&1 + then + break + fi + + (( attempt < 60 )) || { + echo "PostgreSQL did not become ready" >&2 + exit 1 + } + + sleep 1 +done + +"${compose[@]}" build backend + +"${compose[@]}" run --rm --no-deps \ + backend alembic upgrade head + +"${compose[@]}" run --rm --no-deps \ + -v "${repo_root}/backend/tests:/app/tests:ro" \ + backend python -m unittest -v \ + tests.test_agent_registry \ + tests.test_agent_registry_postgres \ + tests.test_agent_management_frontend + +echo "Agent Management tests passed." diff --git a/scripts/test-agent-transport-isolation.sh b/scripts/test-agent-transport-isolation.sh new file mode 100755 index 0000000..a6c2cbd --- /dev/null +++ b/scripts/test-agent-transport-isolation.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +PROJECT="minerops-agent-transport-isolation-test-$$" + +compose=( + docker compose + --project-name "$PROJECT" + --env-file .env.dev + -f compose.dev.yaml +) + +cleanup() { + "${compose[@]}" down \ + --volumes \ + --remove-orphans \ + >/dev/null 2>&1 || true +} + +trap cleanup EXIT + +echo "=== START DISPOSABLE POSTGRES ===" +"${compose[@]}" up --detach postgres + +echo +echo "=== WAIT FOR POSTGRES ===" + +ready=0 + +for _ in $(seq 1 30); do + if "${compose[@]}" exec -T postgres \ + sh -c 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + >/dev/null 2>&1 + then + ready=1 + break + fi + + sleep 1 +done + +if [ "$ready" -ne 1 ]; then + echo "PostgreSQL did not become ready." + exit 1 +fi + +echo +echo "=== BUILD BACKEND ===" +"${compose[@]}" build backend + +echo +echo "=== MIGRATE DATABASE ===" +"${compose[@]}" run --rm backend \ + alembic upgrade head + +echo +echo "=== RUNTIME AGENT ZONE ISOLATION ===" +"${compose[@]}" run --rm \ + --volume "$PWD/backend/tests:/app/tests:ro" \ + --env MINEROPS_TRANSPORT_ISOLATION_DISPOSABLE=1 \ + backend \ + python -m unittest -v \ + tests.test_agent_transport_isolation_postgres + +echo +echo "Runtime Agent transport Zone-isolation tests passed." diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index f4db3be..9bceae8 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -132,7 +132,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0016_agent_zones"* ]] +[[ "${current_revision}" == *"0017_agent_enrollment"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ diff --git a/scripts/test-windows-agent-package.sh b/scripts/test-windows-agent-package.sh new file mode 100755 index 0000000..4a5f42a --- /dev/null +++ b/scripts/test-windows-agent-package.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +project_name="minerops-windows-package-test-$$" + +compose=( + docker compose + --project-name "${project_name}" + --env-file .env.dev + -f compose.dev.yaml +) + +cleanup() { + status=$? + "${compose[@]}" down --volumes --remove-orphans \ + >/dev/null 2>&1 || true + exit "${status}" +} + +trap cleanup EXIT + +echo "=== BUILD BACKEND WITH CANONICAL AGENT SOURCE ===" +"${compose[@]}" build backend + +echo +echo "=== BACKEND PACKAGE TESTS ===" +"${compose[@]}" run --rm --no-deps \ + -v "${repo_root}/backend/tests:/app/tests:ro" \ + backend python -m unittest -v \ + tests.test_agent_windows_package + +echo +echo "=== VERIFY CANONICAL SOURCE IS IN IMAGE ===" +"${compose[@]}" run --rm --no-deps \ + backend sh -ec ' + test -f /app/agents/windows/Enroll-MinerOpsAgent.ps1 + test -f /app/agents/windows/install.ps1 + test -f /app/agents/windows/MinerOpsAgentService.ps1 + test -f /app/agents/windows/VERSION + ' + +echo +echo "=== OFFICIAL POWERSHELL VALIDATION ===" +docker run --rm \ + -v "${repo_root}/agents/windows:/work:ro" \ + mcr.microsoft.com/powershell:7.4-debian-12 \ + pwsh -NoLogo -NoProfile -Command ' + $ErrorActionPreference = "Stop" + + foreach ($script in @( + "/work/Enroll-MinerOpsAgent.ps1", + "/work/install.ps1" + )) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $script, + [ref]$tokens, + [ref]$errors + ) | Out-Null + + if ($errors.Count -gt 0) { + throw "$script parser errors: $($errors.Message -join "; ")" + } + + Write-Host "Parsed $script" + } + + & /work/tests/Test-MinerOpsEnrollment.ps1 + & /work/tests/Test-MinerOpsInstaller.ps1 + ' + +echo +echo "Windows Agent package foundation tests passed."