diff --git a/Borealis.ps1 b/Borealis.ps1 index 8405af7..501ebd7 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -1030,13 +1030,40 @@ switch ($choice) { Run-Step "Create Borealis Virtual Python Environment" { if (-not (Test-Path "$venvFolder\Scripts\Activate")) { & $pythonExe -m venv $venvFolder | Out-Null } if (Test-Path $dataSource) { - Remove-Item $dataDestination -Recurse -Force -ErrorAction SilentlyContinue + $preserveItems = @('auth_keys','server_secret.key','cache') + $preserveRoot = Join-Path $venvFolder '.__borealis_preserve' + if (Test-Path $dataDestination) { + Remove-Item $preserveRoot -Recurse -Force -ErrorAction SilentlyContinue + New-Item -Path $preserveRoot -ItemType Directory -Force | Out-Null + foreach ($item in $preserveItems) { + $sourcePath = Join-Path $dataDestination $item + if (Test-Path $sourcePath) { + $targetPath = Join-Path $preserveRoot $item + $targetParent = Split-Path $targetPath -Parent + if (-not (Test-Path $targetParent)) { + New-Item -Path $targetParent -ItemType Directory -Force | Out-Null + } + Move-Item -Path $sourcePath -Destination $targetPath -Force + } + } + Remove-Item $dataDestination -Recurse -Force -ErrorAction SilentlyContinue + } New-Item -Path $dataDestination -ItemType Directory -Force | Out-Null Copy-Item "$dataSource\Server\Python_API_Endpoints" $dataDestination -Recurse Copy-Item "$dataSource\Server\Sounds" $dataDestination -Recurse Copy-Item "$dataSource\Server\Modules" $dataDestination -Recurse Copy-Item "$dataSource\Server\server.py" $dataDestination Copy-Item "$dataSource\Server\job_scheduler.py" $dataDestination + if (Test-Path $preserveRoot) { + Get-ChildItem -Path $preserveRoot -Force | ForEach-Object { + $target = Join-Path $dataDestination $_.Name + if (Test-Path $target) { + Remove-Item $target -Recurse -Force -ErrorAction SilentlyContinue + } + Move-Item -Path $_.FullName -Destination $target -Force + } + Remove-Item $preserveRoot -Recurse -Force -ErrorAction SilentlyContinue + } } . "$venvFolder\Scripts\Activate" } diff --git a/Data/Server/WebUI/src/Navigation_Sidebar.jsx b/Data/Server/WebUI/src/Navigation_Sidebar.jsx index f0e76b3..c2a7d71 100644 --- a/Data/Server/WebUI/src/Navigation_Sidebar.jsx +++ b/Data/Server/WebUI/src/Navigation_Sidebar.jsx @@ -198,6 +198,8 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) { + } label="Device Approvals" pageKey="admin_device_approvals" /> + } label="Enrollment Codes" pageKey="admin_enrollment_codes" indent /> } label="Devices" pageKey="devices" /> } label="Agent Devices" pageKey="agent_devices" indent /> } label="SSH Devices" pageKey="ssh_devices" indent /> @@ -395,8 +397,6 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) { } label="Server Info" pageKey="server_info" /> - } label="Installer Codes" pageKey="admin_enrollment_codes" /> - } label="Device Approvals" pageKey="admin_device_approvals" /> ); diff --git a/Data/Server/server.py b/Data/Server/server.py index a3a73b6..ce36947 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -113,7 +113,7 @@ from datetime import datetime, timezone from Modules import db_migrations from Modules.auth import jwt_service as jwt_service_module from Modules.auth.dpop import DPoPValidator -from Modules.auth.device_auth import DeviceAuthManager, require_device_auth +from Modules.auth.device_auth import DeviceAuthContext, DeviceAuthError, DeviceAuthManager, require_device_auth from Modules.auth.rate_limit import SlidingWindowRateLimiter from Modules.agents import routes as agent_routes from Modules.crypto import certificates, signing @@ -752,6 +752,9 @@ def health(): # Endpoint: /api/repo/current_hash — cached GitHub head lookup for agents. @app.route("/api/repo/current_hash", methods=["GET"]) def api_repo_current_hash(): + _, error = _authenticate_agent_request() + if error is not None: + return error try: repo = (request.args.get('repo') or _DEFAULT_REPO).strip() branch = (request.args.get('branch') or _DEFAULT_BRANCH).strip() @@ -1091,13 +1094,51 @@ def _collect_agent_hash_records() -> List[Dict[str, Any]]: return sanitized -def _apply_agent_hash_update(agent_id: str, agent_hash: str, agent_guid: Optional[str] = None) -> Tuple[Dict[str, Any], int]: +def _authenticate_agent_request() -> Tuple[Optional[DeviceAuthContext], Optional["flask.wrappers.Response"]]: + """ + Lightweight helper mirroring require_device_auth for endpoints declared before DEVICE_AUTH_MANAGER is initialised. + + Returns a tuple of (context, error_response). Callers should return the response immediately when present. + """ + if DEVICE_AUTH_MANAGER is None: + response = jsonify({"error": "auth_unavailable"}) + response.status_code = 503 + return None, response + try: + ctx = DEVICE_AUTH_MANAGER.authenticate() + g.device_auth = ctx + return ctx, None + except DeviceAuthError as exc: + response = jsonify({"error": exc.message}) + response.status_code = exc.status_code + retry_after = getattr(exc, "retry_after", None) + if retry_after: + try: + response.headers["Retry-After"] = str(max(1, int(retry_after))) + except Exception: + response.headers["Retry-After"] = "1" + return None, response + + +def _apply_agent_hash_update( + agent_id: str, + agent_hash: str, + agent_guid: Optional[str] = None, + auth_ctx: Optional[DeviceAuthContext] = None, +) -> Tuple[Dict[str, Any], int]: agent_id = (agent_id or '').strip() agent_hash = (agent_hash or '').strip() normalized_guid = _normalize_guid(agent_guid) if not agent_hash or (not agent_id and not normalized_guid): return {'error': 'agent_hash and agent_guid or agent_id required'}, 400 + auth_guid = _normalize_guid(getattr(auth_ctx, "guid", None)) if auth_ctx else None + if auth_guid: + if normalized_guid and normalized_guid != auth_guid: + return {'error': 'guid_mismatch'}, 403 + if not normalized_guid: + normalized_guid = auth_guid + conn = None hostname = None resolved_agent_id = agent_id @@ -1117,6 +1158,9 @@ def _apply_agent_hash_update(agent_id: str, agent_hash: str, agent_guid: Optiona updated_via_guid = True record = _row_to_device_dict(row, _DEVICE_TABLE_COLUMNS) snapshot = _assemble_device_snapshot(record) + record_guid = _normalize_guid(record.get('guid')) + if auth_guid and record_guid and record_guid != auth_guid: + return {'error': 'guid_mismatch'}, 403 hostname = snapshot.get('hostname') description = snapshot.get('description') details = snapshot.get('details', {}) @@ -1162,6 +1206,9 @@ def _apply_agent_hash_update(agent_id: str, agent_hash: str, agent_guid: Optiona 'agent_hash': agent_hash, } else: + target_guid_norm = _normalize_guid(target.get('guid')) if target.get('guid') else None + if auth_guid and target_guid_norm and target_guid_norm != auth_guid: + return {'error': 'guid_mismatch'}, 403 hostname = target.get('hostname') details = target.get('details') or {} summary = details.setdefault('summary', {}) @@ -1257,6 +1304,12 @@ def _apply_agent_hash_update(agent_id: str, agent_hash: str, agent_guid: Optiona @app.route("/api/agent/hash", methods=["GET", "POST"]) def api_agent_hash(): + ctx, error = _authenticate_agent_request() + if error is not None: + return error + auth_guid = _normalize_guid(getattr(ctx, "guid", None)) + if not auth_guid: + return jsonify({'error': 'guid_required'}), 403 if request.method == 'GET': agent_guid = _normalize_guid(request.args.get('agent_guid')) agent_id = (request.args.get('agent_id') or request.args.get('id') or '').strip() @@ -1264,16 +1317,32 @@ def api_agent_hash(): data = request.get_json(silent=True) or {} agent_guid = _normalize_guid(data.get('agent_guid')) if data else agent_guid agent_id = (data.get('agent_id') or '').strip() if data else agent_id + if agent_guid and agent_guid != auth_guid: + return jsonify({'error': 'guid_mismatch'}), 403 + effective_guid = agent_guid or auth_guid try: record = None - if agent_guid: - record = _lookup_agent_hash_by_guid(agent_guid) + if effective_guid: + record = _lookup_agent_hash_by_guid(effective_guid) if not record and agent_id: record = _lookup_agent_hash_record(agent_id) + if record: + candidate_guid = _normalize_guid(record.get('agent_guid')) + if candidate_guid and candidate_guid != auth_guid: + return jsonify({'error': 'guid_mismatch'}), 403 + if not candidate_guid and effective_guid: + record = dict(record) + record['agent_guid'] = effective_guid except Exception as exc: _write_service_log('server', f'/api/agent/hash lookup error: {exc}') return jsonify({'error': 'internal error'}), 500 if record: + record_guid = _normalize_guid(record.get('agent_guid')) if record.get('agent_guid') else None + if record_guid and record_guid != auth_guid: + return jsonify({'error': 'guid_mismatch'}), 403 + if not record_guid: + record = dict(record) + record['agent_guid'] = auth_guid return jsonify(record) return jsonify({'error': 'agent hash not found'}), 404 @@ -1281,7 +1350,10 @@ def api_agent_hash(): agent_id = (data.get('agent_id') or '').strip() agent_hash = (data.get('agent_hash') or '').strip() agent_guid = _normalize_guid(data.get('agent_guid')) if data else None - payload, status = _apply_agent_hash_update(agent_id, agent_hash, agent_guid) + if agent_guid and agent_guid != auth_guid: + return jsonify({'error': 'guid_mismatch'}), 403 + effective_guid = agent_guid or auth_guid + payload, status = _apply_agent_hash_update(agent_id, agent_hash, effective_guid, auth_ctx=ctx) return jsonify(payload), status diff --git a/Update.ps1 b/Update.ps1 index 05144fd..39be58a 100644 --- a/Update.ps1 +++ b/Update.ps1 @@ -262,11 +262,22 @@ function Get-AgentGuid { [string]$AgentRoot ) - $candidates = @() if (-not $AgentRoot) { $AgentRoot = $scriptDir } - if ($AgentRoot) { $candidates += (Join-Path $AgentRoot 'agent_GUID') } - $defaultPath = Join-Path $scriptDir 'Agent\Borealis\agent_GUID' - if ($defaultPath -and ($candidates -notcontains $defaultPath)) { $candidates += $defaultPath } + $candidates = @() + if ($AgentRoot) { + $settingsDir = Join-Path $AgentRoot 'Settings' + if ($settingsDir) { + $settingsGuid = Join-Path $settingsDir 'Agent_GUID.txt' + if ($candidates -notcontains $settingsGuid) { $candidates += $settingsGuid } + } + $legacyPath = Join-Path $AgentRoot 'agent_GUID' + if ($candidates -notcontains $legacyPath) { $candidates += $legacyPath } + } + + $projectSettingsGuid = Join-Path $scriptDir 'Agent\Borealis\Settings\Agent_GUID.txt' + if ($candidates -notcontains $projectSettingsGuid) { $candidates += $projectSettingsGuid } + $projectLegacyGuid = Join-Path $scriptDir 'Agent\Borealis\agent_GUID' + if ($candidates -notcontains $projectLegacyGuid) { $candidates += $projectLegacyGuid } foreach ($path in ($candidates | Select-Object -Unique)) { try { @@ -280,6 +291,164 @@ function Get-AgentGuid { return '' } +function Get-AgentSettingsDirectory { + param( + [string]$AgentRoot + ) + + if (-not $AgentRoot) { $AgentRoot = $scriptDir } + $settingsDir = Join-Path $AgentRoot 'Settings' + if ($settingsDir -and (Test-Path $settingsDir -PathType Container)) { + return $settingsDir + } + return '' +} + +function Get-ProtectedTokenString { + param( + [string]$Path + ) + + if (-not $Path -or -not (Test-Path $Path -PathType Leaf)) { + return '' + } + + try { + $protected = [System.IO.File]::ReadAllBytes($Path) + if (-not $protected -or $protected.Length -eq 0) { return '' } + } catch { + return '' + } + + $scopes = @( + [System.Security.Cryptography.DataProtectionScope]::CurrentUser, + [System.Security.Cryptography.DataProtectionScope]::LocalMachine + ) + + foreach ($scope in $scopes) { + try { + $unprotected = [System.Security.Cryptography.ProtectedData]::Unprotect($protected, $null, $scope) + if ($unprotected -and $unprotected.Length -gt 0) { + return [System.Text.Encoding]::UTF8.GetString($unprotected) + } + } catch { + continue + } + } + + return '' +} + +function Invoke-AgentTokenRefresh { + param( + [Parameter(Mandatory = $true)] + [string]$ServerBaseUrl, + + [Parameter(Mandatory = $true)] + [string]$AgentGuid, + + [Parameter(Mandatory = $true)] + [string]$RefreshToken + ) + + if ([string]::IsNullOrWhiteSpace($ServerBaseUrl) -or [string]::IsNullOrWhiteSpace($AgentGuid) -or [string]::IsNullOrWhiteSpace($RefreshToken)) { + return $null + } + + $base = $ServerBaseUrl.TrimEnd('/') + $uri = "$base/api/agent/token/refresh" + $payload = @{ + guid = $AgentGuid + refresh_token = $RefreshToken + } | ConvertTo-Json + $headers = @{ + 'User-Agent' = 'borealis-agent-updater' + 'Content-Type' = 'application/json' + } + + try { + $resp = Invoke-WebRequest -Uri $uri -Method Post -Body $payload -Headers $headers -UseBasicParsing -ErrorAction Stop + $json = $resp.Content | ConvertFrom-Json + if ($json -and $json.access_token) { + $expiresIn = 900 + try { + if ($json.expires_in) { + $expiresIn = [int]$json.expires_in + } + } catch {} + $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $expiresAt = $now + [Math]::Max(0, $expiresIn - 5) + return [pscustomobject]@{ + AccessToken = ($json.access_token).Trim() + ExpiresAt = $expiresAt + } + } + } catch { + return $null + } + + return $null +} + +function Get-AgentAccessTokenContext { + param( + [string]$AgentRoot, + [string]$ServerBaseUrl, + [string]$AgentGuid + ) + + $settingsDir = Get-AgentSettingsDirectory -AgentRoot $AgentRoot + if (-not $settingsDir) { return $null } + + $accessPath = Join-Path $settingsDir 'access.jwt' + $metaPath = Join-Path $settingsDir 'access.meta.json' + $refreshPath = Join-Path $settingsDir 'refresh.token' + + $accessToken = '' + $expiresAt = 0 + + if (Test-Path $accessPath -PathType Leaf) { + try { + $accessToken = (Get-Content -Path $accessPath -Raw -ErrorAction Stop).Trim() + } catch { + $accessToken = '' + } + } + + if (Test-Path $metaPath -PathType Leaf) { + try { + $metaRaw = Get-Content -Path $metaPath -Raw -ErrorAction Stop + if ($metaRaw) { + $metaJson = $metaRaw | ConvertFrom-Json -ErrorAction Stop + if ($metaJson -and $metaJson.access_expires_at) { + $expiresAt = [int]$metaJson.access_expires_at + } + } + } catch { + $expiresAt = 0 + } + } + + $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + if ($accessToken -and $expiresAt -gt ($now + 30)) { + return [pscustomobject]@{ + AccessToken = $accessToken + ExpiresAt = $expiresAt + } + } + + $refreshToken = Get-ProtectedTokenString -Path $refreshPath + if (-not $refreshToken) { + return $null + } + + $refreshResult = Invoke-AgentTokenRefresh -ServerBaseUrl $ServerBaseUrl -AgentGuid $AgentGuid -RefreshToken $refreshToken + if ($refreshResult -and $refreshResult.AccessToken) { + return $refreshResult + } + + return $null +} function Get-RepositoryCommitHash { param( [Parameter(Mandatory = $true)] @@ -441,7 +610,8 @@ function Set-GitFetchHeadHash { function Get-ServerCurrentRepoHash { param( [Parameter(Mandatory = $true)] - [string]$ServerBaseUrl + [string]$ServerBaseUrl, + [string]$AuthToken ) if ([string]::IsNullOrWhiteSpace($ServerBaseUrl)) { return $null } @@ -449,6 +619,9 @@ function Get-ServerCurrentRepoHash { $base = $ServerBaseUrl.TrimEnd('/') $uri = "$base/api/repo/current_hash" $headers = @{ 'User-Agent' = 'borealis-agent-updater' } + if ($AuthToken -and $AuthToken.Trim()) { + $headers['Authorization'] = "Bearer $AuthToken" + } try { $resp = Invoke-WebRequest -Uri $uri -Method Get -Headers $headers -UseBasicParsing -ErrorAction Stop @@ -470,7 +643,9 @@ function Submit-AgentHash { [Parameter(Mandatory = $true)] [string]$AgentHash, - [string]$AgentGuid + [string]$AgentGuid, + + [string]$AuthToken ) if ([string]::IsNullOrWhiteSpace($ServerBaseUrl) -or [string]::IsNullOrWhiteSpace($AgentHash)) { @@ -484,6 +659,9 @@ function Submit-AgentHash { if (-not [string]::IsNullOrWhiteSpace($AgentGuid)) { $payloadBody.agent_guid = $AgentGuid } $payload = $payloadBody | ConvertTo-Json -Depth 3 $headers = @{ 'User-Agent' = 'borealis-agent-updater' } + if ($AuthToken -and $AuthToken.Trim()) { + $headers['Authorization'] = "Bearer $AuthToken" + } $resp = Invoke-WebRequest -Uri $uri -Method Post -Headers $headers -Body $payload -ContentType 'application/json' -UseBasicParsing -ErrorAction Stop try { @@ -502,6 +680,7 @@ function Sync-AgentHashRecord { [string]$ServerBaseUrl, [string]$AgentId, [string]$AgentGuid, + [string]$AuthToken = '', [string]$BranchName = 'main' ) @@ -524,16 +703,16 @@ function Sync-AgentHashRecord { } try { - $submitResult = Submit-AgentHash -ServerBaseUrl $ServerBaseUrl -AgentId $AgentId -AgentHash $AgentHash -AgentGuid $AgentGuid + $submitResult = Submit-AgentHash -ServerBaseUrl $ServerBaseUrl -AgentId $AgentId -AgentHash $AgentHash -AgentGuid $AgentGuid -AuthToken $AuthToken if ($submitResult -and ($submitResult.status -eq 'ok')) { - Write-Host "Server agent_hash database record updated successfully." + Write-Host "The server-side agent hash database record was updated successfully." } elseif ($submitResult -and ($submitResult.status -eq 'ignored')) { - Write-Host "Server ignored agent_hash update (agent not registered)." -ForegroundColor DarkYellow + Write-Host "Server ignored the agent hash update (the agent is not enrolled with the server)." -ForegroundColor DarkYellow } elseif ($submitResult) { - Write-Host "Server agent_hash update response unrecognized." -ForegroundColor DarkYellow + Write-Host "Server agent_hash update response unrecognized. We don't know what to do here. (Panic)" -ForegroundColor DarkYellow } } catch { - Write-Verbose ("Failed to submit agent hash: {0}" -f $_.Exception.Message) + Write-Verbose ("Failed to Submit Agent Hash: {0}" -f $_.Exception.Message) } } @@ -696,7 +875,15 @@ function Invoke-BorealisAgentUpdate { $serverBaseUrl = Get-BorealisServerUrl -AgentRoot $agentRoot $agentId = Get-AgentServiceId -AgentRoot $agentRoot - $serverRepoInfo = Get-ServerCurrentRepoHash -ServerBaseUrl $serverBaseUrl + $authContext = Get-AgentAccessTokenContext -AgentRoot $agentRoot -ServerBaseUrl $serverBaseUrl -AgentGuid $agentGuid + if (-not $authContext -or -not $authContext.AccessToken) { + Write-Host "Unable to obtain agent authentication token. Ensure the agent is running and enrolled, then rerun the updater." -ForegroundColor Yellow + Write-Host "⚠️ Borealis update aborted." + return + } + $authToken = $authContext.AccessToken + + $serverRepoInfo = Get-ServerCurrentRepoHash -ServerBaseUrl $serverBaseUrl -AuthToken $authToken $serverHash = '' $serverBranch = 'main' if ($serverRepoInfo) { @@ -736,7 +923,7 @@ function Invoke-BorealisAgentUpdate { return } elseif (-not $needsUpdate) { Write-Host "Local agent files already match the server repository hash." -ForegroundColor Green - Sync-AgentHashRecord -ProjectRoot $scriptDir -AgentRoot $agentRoot -AgentHash $serverHash -ServerBaseUrl $serverBaseUrl -AgentId $agentId -AgentGuid $agentGuid -BranchName $serverBranch + Sync-AgentHashRecord -ProjectRoot $scriptDir -AgentRoot $agentRoot -AgentHash $serverHash -ServerBaseUrl $serverBaseUrl -AgentId $agentId -AgentGuid $agentGuid -AuthToken $authToken -BranchName $serverBranch Write-Host "✅ Borealis - Automation Platform Already Up-to-Date" return } else { @@ -780,7 +967,11 @@ function Invoke-BorealisAgentUpdate { throw 'Borealis update failed.' } - $postUpdateInfo = Get-ServerCurrentRepoHash -ServerBaseUrl $serverBaseUrl + $refreshedContext = Get-AgentAccessTokenContext -AgentRoot $agentRoot -ServerBaseUrl $serverBaseUrl -AgentGuid $agentGuid + if ($refreshedContext -and $refreshedContext.AccessToken) { + $authToken = $refreshedContext.AccessToken + } + $postUpdateInfo = Get-ServerCurrentRepoHash -ServerBaseUrl $serverBaseUrl -AuthToken $authToken if ($postUpdateInfo) { try { $refreshedSha = (($postUpdateInfo.sha) -as [string]).Trim() @@ -805,7 +996,7 @@ function Invoke-BorealisAgentUpdate { } if ($newHash) { - Sync-AgentHashRecord -ProjectRoot $scriptDir -AgentRoot $agentRoot -AgentHash $newHash -ServerBaseUrl $serverBaseUrl -AgentId $agentId -AgentGuid $agentGuid -BranchName $serverBranch + Sync-AgentHashRecord -ProjectRoot $scriptDir -AgentRoot $agentRoot -AgentHash $newHash -ServerBaseUrl $serverBaseUrl -AgentId $agentId -AgentGuid $agentGuid -AuthToken $authToken -BranchName $serverBranch } else { Write-Host "Unable to determine repository hash for submission; server hash not updated." -ForegroundColor DarkYellow }