#requires -Version 5.1 <# Install-MajicAgent.ps1 (majicagent-admin v5) OFFLINE, self-contained admin-agent install flow. Run elevated from the LOCAL staged copy (the launcher 01-Install-MajicAdminAgent-Windows.cmd stages the package to C:\Majic\MajicAgent\installer-cache\majicagent-admin and invokes this with -PackageRoot pointing at that staged dir). Required network calls only: 1. Cloudflare API (tunnel/DNS) — phase 5 2. MAJIC API registration/retry — phase 4 / 6 3. local Ollama check at 127.0.0.1 — phase 1 (best-effort) Everything else (agent setup, cloudflared, .NET, Node) is BUNDLED. This script contains NO Invoke-WebRequest / download URLs for installer dependencies. Phases: 1. Preflight + detect roles (+ local Ollama 127.0.0.1 check) 2. Install base agent from bundled psscripts\MajicAgentSetup.exe - PRESERVES C:\ProgramData\MajicAgent (installer-cache lives under C:\Majic, not here, but we still never blow away the ProgramData root) - manual GUI install cleans up first (stop svc/task, remove app dir), then installs; retries until BOTH the MajicAgent service AND the MajicAgentTray scheduled task are detected. 3. Install role modules (IIS/SQL/LLM/AD/DNS as detected) 4. Network: DHCP->static + register host with MAJIC API 5. Cloudflare: check-first cloudflared (bundled) + tunnel + DNS 6. Verify service + scheduled task + first heartbeat No vault phase in v5. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$PackageRoot, [string]$ApiBaseUrl = 'https://api.majicholdings.com', # Cloudflare [string]$CfAccountId = '', [string]$CfZoneName = 'majicholdings.com', [string]$SharedTunnelId = '', [switch]$UseSharedTunnel, # Skips [switch]$SkipStaticIp, [switch]$SkipCloudflare, [switch]$SkipServiceInstall, # Auto-update path may update in-place; manual GUI install always cleans up # first. -AutoUpdate selects the in-place behaviour for Phase 2. [switch]$AutoUpdate, # Console-only (no GUI). Used by rollout/logon scripts. [switch]$Silent, # Targeted uninstall (service + scheduled task + app dir; preserves cache). [switch]$Uninstall, # Explicit role selection (comma list, e.g. 'IIS,SQL,FileShare'). When given, # these roles are installed regardless of auto-detection (the user chose them # in the light installer). When empty, fall back to auto-detection. [string]$Roles = '' ) $ErrorActionPreference = 'Stop' # ---- package-relative paths ------------------------------------------------ $script:PkgRoot = (Resolve-Path -LiteralPath $PackageRoot).Path $script:PsScripts = Join-Path $script:PkgRoot 'psscripts' $script:OfflineDir= Join-Path $script:PsScripts 'offline' $script:ModulesDir= Join-Path $script:PsScripts 'modules' $script:SetupExe = Join-Path $script:PsScripts 'MajicAgentSetup.exe' # Resolve cloudflared from where it ACTUALLY is, not just the bundled offline copy. # In the per-bin model cloudflared may already be INSTALLED (running as a service # from its install dir, or on PATH) and the .bin download was skipped by the probe - # so requiring the staged copy is wrong (that was the 'Bundled cloudflared.exe # missing' Phase 5 failure). Search order: PATH -> running-service ImagePath -> # common install dirs -> the staged offline copy. Returns $null if truly absent. function Resolve-CloudflaredExe { # 1) on PATH $c = Get-Command cloudflared -ErrorAction SilentlyContinue if ($c -and $c.Source) { return $c.Source } # 2) the running/installed service's binary path try { $svc = Get-CimInstance Win32_Service -Filter "Name='cloudflared'" -ErrorAction SilentlyContinue if ($svc -and $svc.PathName) { $p = $svc.PathName.Trim('"') # strip any trailing args after the exe path if ($p -match '^(.*?\.exe)') { $p = $Matches[1] } if (Test-Path -LiteralPath $p) { return $p } } } catch {} # 3) common install locations foreach ($p in @( 'C:\Program Files\cloudflared\cloudflared.exe', 'C:\Program Files (x86)\cloudflared\cloudflared.exe', (Join-Path $env:ProgramData 'cloudflared\cloudflared.exe'), 'C:\Majic\cloudflared\cloudflared.exe')) { if (Test-Path -LiteralPath $p) { return $p } } # 4) the staged offline copy (only present if the bin was downloaded this run) $staged = Join-Path $script:OfflineDir 'cloudflared.exe' if (Test-Path -LiteralPath $staged) { return $staged } return $null } $script:CloudflaredExe = Join-Path $script:OfflineDir 'cloudflared.exe' # legacy default; Phase 5 re-resolves # ---- central telemetry (best-effort; pushes run-log to majic-telemetry) ----- $script:RunLog = New-Object System.Collections.Generic.List[string] try { $telemetry = Join-Path $script:PsScripts 'Send-MajicTelemetry.ps1' if (Test-Path -LiteralPath $telemetry) { . $telemetry } } catch {} function Send-RunTelemetry([int]$ExitCode) { try { if (Get-Command Send-MajicTelemetry -ErrorAction SilentlyContinue) { Send-MajicTelemetry -Launcher 'Install-MajicAgent' -PackageVersion '5.0.0' ` -ExitCode $ExitCode -Lines @($script:RunLog) } } catch {} } # Single working root: everything lives under C:\Majic\MajicAgent. $script:MajicRoot = 'C:\Majic\MajicAgent' # installer-cache root that MUST be preserved by all cleanup. $script:StageRoot = $script:MajicRoot + '\installer-cache' # Agent data/state root that MUST NOT be deleted wholesale (installer-cache # lives under it). This replaces the old C:\ProgramData\MajicAgent location. $script:ProgramDataRoot = $script:MajicRoot $script:ServiceName = 'MajicAgent' $script:TrayTaskName = 'MajicAgentTray' # Agent binary install dir (everything under C:\Majic). $script:AgentAppDir = $script:MajicRoot + '\app' # =========================================================================== # Credential helpers (Windows Credential Manager via cmdkey / CredRead). # Secrets are stored ENCRYPTED by Windows (Credential Manager). We never write # plaintext secrets to the registry, logs, or back to token.txt. # =========================================================================== function Get-WcmSecret([string]$Target) { Add-Type -Namespace MajicWcm -Name Native -MemberDefinition @' [System.Runtime.InteropServices.DllImport("advapi32.dll", SetLastError=true, CharSet=System.Runtime.InteropServices.CharSet.Unicode)] public static extern bool CredRead(string target, int type, int flags, out System.IntPtr credential); [System.Runtime.InteropServices.DllImport("advapi32.dll")] public static extern void CredFree(System.IntPtr buffer); '@ -ErrorAction SilentlyContinue $ptr = [IntPtr]::Zero if (-not [MajicWcm.Native]::CredRead($Target, 1, 0, [ref]$ptr)) { return $null } try { $size = [System.Runtime.InteropServices.Marshal]::ReadInt32($ptr, 32) $blob = [System.Runtime.InteropServices.Marshal]::ReadIntPtr($ptr, 40) if ($size -le 0 -or $size -gt 1048576 -or $blob -eq [IntPtr]::Zero) { return $null } $bytes = New-Object byte[] $size [System.Runtime.InteropServices.Marshal]::Copy($blob, $bytes, 0, $size) return [System.Text.Encoding]::Unicode.GetString($bytes) } finally { [MajicWcm.Native]::CredFree($ptr) } } function Set-WcmSecret([string]$Target, [string]$Secret) { # cmdkey stores a generic credential; Windows encrypts it at rest. & cmdkey.exe /generic:$Target /user:MAJIC /pass:$Secret | Out-Null return ($LASTEXITCODE -eq 0) } function Resolve-Ref([string]$ref) { if ([string]::IsNullOrWhiteSpace($ref)) { return $null } if ($ref -like 'env:*') { return [Environment]::GetEnvironmentVariable($ref.Substring(4)) } if ($ref -like 'wcm:*') { return Get-WcmSecret -Target $ref.Substring(4) } return $ref } # WCM target names for the five token.txt lines. $script:WcmTargets = @{ VaultPat = 'MAJIC_VAULT_GH_PAT' ApiUrl = 'MAJIC_API_URL' CfAccount = 'MAJIC_CF_ACCOUNT_ID' CfToken = 'MAJIC_CF_API_TOKEN' ApiToken = 'MAJIC_API_TOKEN' } # Effective (resolved) config slots. MUST be pre-declared: under # `Set-StrictMode -Version Latest`, READING an unset variable throws # ("cannot be retrieved because it has not been set"). Resolve-CfConfig and the # Phase 4/5 blocks test `if (-not $script:EffCfAccount)` etc. BEFORE assigning, # so without these initializers Phases 1/4/5 fail with a RuntimeException even # though enroll already minted the token. Initialize to $null so the first read # is a clean falsey check, not a strict-mode error. $script:EffCfAccount = $null $script:EffCfToken = $null $script:EffApiToken = $null $script:EffApiUrl = $null # Read token.txt (4 lines) if present next to the package, compare to stored # encrypted values, and only update WCM where missing or different. token.txt is # a transient reference input; we do NOT persist it or copy it anywhere. There is # NO ApiToken line: MAJIC_API_TOKEN is minted at enroll, never seeded from a file. function Sync-StoredConfig([scriptblock]$Report) { $tokenFile = Join-Path $script:PkgRoot 'token.txt' if (-not (Test-Path -LiteralPath $tokenFile)) { & $Report -Phase 1 -Status 'info' -Message 'token.txt not present; using already-stored credentials (Credential Manager).' return } $lines = @(Get-Content -LiteralPath $tokenFile -ErrorAction SilentlyContinue) $map = @( @{ Key='VaultPat'; Idx=0 } @{ Key='ApiUrl'; Idx=1 } @{ Key='CfAccount'; Idx=2 } @{ Key='CfToken'; Idx=3 } ) foreach ($m in $map) { $val = if ($lines.Count -gt $m.Idx) { ($lines[$m.Idx]).Trim() } else { '' } if ([string]::IsNullOrWhiteSpace($val)) { continue } if ($val -like 'REPLACE_WITH_*') { continue } # untouched template line $target = $script:WcmTargets[$m.Key] $stored = Get-WcmSecret -Target $target if ($stored -ne $val) { if (Set-WcmSecret -Target $target -Secret $val) { & $Report -Phase 1 -Status 'info' -Message "Stored/updated $($m.Key) in Credential Manager (encrypted)." } else { & $Report -Phase 1 -Status 'warning' -Message "Could not store $($m.Key) in Credential Manager." } } else { & $Report -Phase 1 -Status 'info' -Message "$($m.Key) already stored and matches; skipping." } } # token.txt is KEPT (per operator request - do NOT auto-delete). Values are # also stored encrypted in Credential Manager; the reference file is left as-is. & $Report -Phase 1 -Status 'info' -Message 'token.txt left in place (kept as reference).' } # Resolve effective Cloudflare account/zone + token from params or stored config. function Resolve-CfConfig() { if (-not $script:EffCfAccount) { $script:EffCfAccount = if ($CfAccountId) { $CfAccountId } else { Get-WcmSecret -Target $script:WcmTargets.CfAccount } } if (-not $script:EffCfToken) { $script:EffCfToken = Get-WcmSecret -Target $script:WcmTargets.CfToken } if (-not $script:EffApiToken) { $script:EffApiToken = Get-WcmSecret -Target $script:WcmTargets.ApiToken } } function Get-AdDomain() { try { $cs = Get-CimInstance Win32_ComputerSystem; if ($cs.PartOfDomain) { return $cs.Domain } } catch {} if ($env:USERDNSDOMAIN) { return $env:USERDNSDOMAIN.ToLower() } return '' } # ---- role module mapping --------------------------------------------------- $script:RolesDir = $script:ProgramDataRoot.TrimEnd('\') + '\roles' $script:RoleModuleMap = [ordered]@{ IIS = 'iis\iis.ps1'; SQL = 'sql\sql.ps1'; LLM = 'llm\llm.ps1'; AD = 'ad\ad.ps1'; DNS = 'dns\dns.ps1'; FileShare = 'fileshare\fileshare.ps1' } function Resolve-ModulePath([string]$rel) { $p = Join-Path $script:ModulesDir $rel $r = Resolve-Path -LiteralPath $p -ErrorAction SilentlyContinue if ($r) { return $r.Path } else { return $null } } function Install-RoleModule([string]$role, [scriptblock]$Report) { $path = Resolve-ModulePath $script:RoleModuleMap[$role] if (-not $path) { return $false } try { $sb = [scriptblock]::Create((Get-Content -Raw -LiteralPath $path) + "`n Install -RolesDir `$args[0]") & $sb $script:RolesDir & $Report -Phase 3 -Status 'info' -Message "role module installed: $role"; return $true } catch { & $Report -Phase 3 -Status 'warning' -Message "role $role install warning: $($_.Exception.Message)"; return $false } } # =========================================================================== # Agent presence checks (service + scheduled task) # =========================================================================== function Test-AgentService() { return [bool](Get-Service -Name $script:ServiceName -ErrorAction SilentlyContinue) } function Test-TrayTask() { # Use the FULL path to schtasks.exe. Relying on bare 'schtasks' throws a vague # 'system cannot find the file specified' (a TERMINATING error) when System32 is # not on the agent/service PATH - which is exactly what killed Phase 2/6 on hosts # running under a stripped service PATH. Wrap everything so a missing task or a # resolution failure simply returns $false instead of throwing. $schExe = Join-Path $env:SystemRoot 'System32\schtasks.exe' if (-not (Test-Path -LiteralPath $schExe)) { $schExe = 'schtasks.exe' } try { & $schExe /Query /TN $script:TrayTaskName 2>$null | Out-Null return ($LASTEXITCODE -eq 0) } catch { return $false } } # Targeted cleanup. PRESERVES installer-cache and the ProgramData root; removes # only the service, the tray scheduled task, and the agent app dir. function Invoke-AgentCleanup([scriptblock]$Report) { $log = { param($m,$st='info') & $Report -Phase 2 -Status $st -Message "CLEAN> $m" } & $log 'cleaning previous agent (service + tray task + app dir)...' # Use FULL paths to sc.exe / schtasks.exe. Relying on PATH can throw a vague # 'system cannot find the file specified' if System32 is not on the agent's PATH. $scExe = Join-Path $env:SystemRoot 'System32\sc.exe' $schExe = Join-Path $env:SystemRoot 'System32\schtasks.exe' if (-not (Test-Path -LiteralPath $scExe)) { & $log "sc.exe NOT at $scExe - falling back to bare 'sc.exe'" 'warning'; $scExe = 'sc.exe' } if (-not (Test-Path -LiteralPath $schExe)) { & $log "schtasks.exe NOT at $schExe - falling back to bare 'schtasks.exe'" 'warning'; $schExe = 'schtasks.exe' } try { & $scExe stop $script:ServiceName 2>$null | Out-Null; & $log "sc stop $($script:ServiceName) -> exit $LASTEXITCODE" } catch { & $log "sc stop threw: $($_.Exception.Message)" 'warning' } try { & $scExe delete $script:ServiceName 2>$null | Out-Null; & $log "sc delete $($script:ServiceName) -> exit $LASTEXITCODE" } catch { & $log "sc delete threw: $($_.Exception.Message)" 'warning' } # Query first: deleting a task that was never created is NORMAL on a fresh box. # Only delete (and only log) when it actually exists, so a clean first install # never records a spurious warning/error and reports outcome=failure. try { & $schExe /Query /TN $script:TrayTaskName 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { & $schExe /Delete /TN $script:TrayTaskName /F 2>$null | Out-Null & $log "schtasks delete $($script:TrayTaskName) -> exit $LASTEXITCODE" } else { & $log "no prior tray task '$($script:TrayTaskName)' to remove (clean)." } } catch { & $log "schtasks delete skipped (query/delete error: $($_.Exception.Message))." } if (Test-Path -LiteralPath $script:AgentAppDir) { try { Remove-Item -LiteralPath $script:AgentAppDir -Recurse -Force -ErrorAction Stop; & $log "removed app dir $($script:AgentAppDir)" } catch { & $log "could not fully remove app dir: $($_.Exception.Message)" 'warning' } } else { & $log "app dir not present (clean): $($script:AgentAppDir)" } # SAFETY: never touch installer-cache or the ProgramData root wholesale. & $log "installer-cache preserved ($script:StageRoot); ProgramData root preserved ($script:ProgramDataRoot)." } function Invoke-SetupExe([scriptblock]$Report) { $log = { param($m,$st='info') & $Report -Phase 2 -Status $st -Message "SETUP> $m" } & $log "step 1: checking exe exists at '$($script:SetupExe)'" if (-not (Test-Path -LiteralPath $script:SetupExe)) { throw "Bundled setup not found: $($script:SetupExe). Package incomplete; NOT going online." } # Validate it is a real, non-empty PE before launching. $fi = Get-Item -LiteralPath $script:SetupExe & $log "step 2: exe size=$($fi.Length) bytes" if ($fi.Length -lt 102400) { throw "Bundled setup looks invalid (size $($fi.Length) bytes): $($script:SetupExe). Stale/partial cache - re-run (per-run cache should prevent this)." } try { $fs = [System.IO.File]::OpenRead($script:SetupExe) $hdr = New-Object byte[] 2; [void]$fs.Read($hdr,0,2); $fs.Close() & $log ("step 3: header bytes = 0x{0:X2}{1:X2} (expect 0x4D5A 'MZ')" -f $hdr[0],$hdr[1]) if (-not ($hdr[0] -eq 0x4D -and $hdr[1] -eq 0x5A)) { throw "Bundled setup is not a valid Windows executable (no MZ header): $($script:SetupExe)." } } catch { throw "Could not read bundled setup: $($script:SetupExe) - $($_.Exception.Message)" } # Unblock the file in case it was marked from a zip (Zone.Identifier can make # Windows refuse to launch it, surfacing as a vague error). try { Unblock-File -LiteralPath $script:SetupExe -ErrorAction SilentlyContinue; & $log 'step 4: Unblock-File done' } catch { & $log "step 4: Unblock-File warn: $($_.Exception.Message)" 'warning' } # Inno writes a detailed log when SetupLogging=yes. Point it at a known file so # we can capture WHAT the setup did internally (this is the key Phase-2 detail). $innoLog = Join-Path $env:TEMP ("majic-setup-{0}.log" -f (Get-Date -Format 'yyyyMMdd-HHmmss')) $instArgs = @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-',"/LOG=$innoLog","/MAJIC_URL=$ApiBaseUrl") & $log "step 5: launching with args: $($instArgs -join ' ')" & $log "step 5: workingDir = $($script:PsScripts)" $p = $null try { $p = Start-Process -FilePath $script:SetupExe -ArgumentList $instArgs -WorkingDirectory $script:PsScripts -Wait -PassThru -ErrorAction Stop } catch { & $log "step 5 FAILED: $($_.Exception.GetType().FullName): $($_.Exception.Message)" 'failed' # Win32 'system cannot find the file specified' = 0x2 ERROR_FILE_NOT_FOUND. if ($_.Exception -is [System.ComponentModel.Win32Exception]) { & $log "step 5 Win32 native error code = $($_.Exception.NativeErrorCode) (2=file-not-found, 5=access-denied, 193=bad-exe-format, 216=arch-mismatch)" 'failed' } throw "Failed to launch bundled setup '$($script:SetupExe)': $($_.Exception.Message)" } & $log "step 6: setup process exited code=$($p.ExitCode)" # Surface the Inno setup log (last lines) so we see what it did / why it stopped. try { if (Test-Path -LiteralPath $innoLog) { $tail = Get-Content -LiteralPath $innoLog -Tail 25 -ErrorAction SilentlyContinue foreach ($line in $tail) { & $log "inno: $line" } } else { & $log 'step 6: no Inno log produced (setup may not have started).' 'warning' } } catch { & $log "step 6: could not read Inno log: $($_.Exception.Message)" 'warning' } if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { throw "setup exit $($p.ExitCode)" } & $log 'step 7: setup completed OK.' } # =========================================================================== # Phase functions # =========================================================================== $script:PhaseList = @( 'Preflight + detect roles' 'Install base agent (bundled setup)' 'Install role modules' 'Network: DHCP->static + register host' 'Cloudflare: cloudflared + tunnel + DNS' 'Verify service + tray + first heartbeat' ) function New-InstallContext { return @{ HostName = $env:COMPUTERNAME CfApi = 'https://api.cloudflare.com/client/v4' ApiBaseUrl = $ApiBaseUrl Roles = @(); StaticIp = ''; Domain = ''; ApiHeaders = @{} Registered = $false; TunnelId = ''; Fqdn = ''; DnsStatus = 'skipped' ServiceStatus = 'unknown'; TrayStatus = 'unknown'; HeartbeatOk = $false OllamaUp = $false; ApiLive = $false } } # ---- Phase 1: preflight + roles + local Ollama ----------------------------- function Invoke-PhasePreflight([hashtable]$Ctx, [scriptblock]$Report) { & $Report -Phase 1 -Status 'running' -Message 'Preflight: config + roles check...' Sync-StoredConfig $Report Resolve-CfConfig # Resolve the operator-chosen roles FIRST so role-specific probes (e.g. the # local Ollama/LLM check) only run when that role is actually selected. # Roles are chosen EXPLICITLY by the operator (light installer's console # picker) and passed via -Roles. No host auto-detection. Friendly aliases: # API -> IIS (the API runs on IIS) # ADDNS -> AD + DNS pair $alias = @{ 'API'='IIS'; 'AD/DNS'='AD'; 'ADDNS'='AD' } $chosen = @() foreach ($r in ($Roles -split ',')) { $k = $r.Trim(); if (-not $k) { continue } if ($alias.ContainsKey($k)) { $k = $alias[$k] } if ($k -eq 'AD') { $chosen += 'AD'; $chosen += 'DNS'; continue } if ($script:RoleModuleMap.Contains($k)) { $chosen += $k } } $Ctx.Roles = @($chosen | Select-Object -Unique) # local Ollama check at 127.0.0.1 - ONLY when the LLM role is selected # (a SQL/IIS/etc. host has no reason to probe for a local LLM runtime). if ($Ctx.Roles -contains 'LLM') { try { $r = Invoke-RestMethod -Uri 'http://127.0.0.1:11434/api/tags' -TimeoutSec 3 $Ctx.OllamaUp = $true & $Report -Phase 1 -Status 'info' -Message "Local Ollama reachable at 127.0.0.1 (models: $(@($r.models).Count))." } catch { & $Report -Phase 1 -Status 'warning' -Message 'LLM role selected but local Ollama not reachable at 127.0.0.1 (continuing; install the model runtime separately).' } } # API reachability probe. The API may NOT be live yet (it is deployed by # 02-Deploy-MajicApi-IIS first). When it is unreachable we DEFER registration # (orange), never fail the run — agent install + cloudflared do not need it. try { $null = Invoke-RestMethod -Uri "$ApiBaseUrl/health" -TimeoutSec 4 -ErrorAction Stop $Ctx.ApiLive = $true & $Report -Phase 1 -Status 'info' -Message "MAJIC API reachable at $ApiBaseUrl." } catch { $Ctx.ApiLive = $false & $Report -Phase 1 -Status 'warning' -Message "MAJIC API not live yet at $ApiBaseUrl; host registration will be deferred (pending). Agent install continues normally." } $Ctx.Domain = Get-AdDomain & $Report -Phase 1 -Status 'info' -Message "AD domain: $(if ($Ctx.Domain) { $Ctx.Domain } else { '(workgroup)' })" & $Report -Phase 1 -Status 'done' -Message "Roles selected: $(if ($Ctx.Roles.Count) { $Ctx.Roles -join ', ' } else { '(none - base agent only)' })" } # ---- Phase 2: install base agent (bundled setup; retry until svc+task) ------ function Invoke-PhaseBaseAgent([hashtable]$Ctx, [scriptblock]$Report) { & $Report -Phase 2 -Status 'running' -Message 'Installing base agent from bundled setup...' if ($SkipServiceInstall) { & $Report -Phase 2 -Status 'done' -Message 'Service install skipped (-SkipServiceInstall).'; return } # --- VERBOSE PHASE-2 DIAGNOSTICS (so a failure pinpoints the exact cause) --- & $Report -Phase 2 -Status 'info' -Message "DIAG PkgRoot = $($script:PkgRoot)" & $Report -Phase 2 -Status 'info' -Message "DIAG PsScripts = $($script:PsScripts)" & $Report -Phase 2 -Status 'info' -Message "DIAG SetupExe = $($script:SetupExe)" & $Report -Phase 2 -Status 'info' -Message "DIAG AgentAppDir = $($script:AgentAppDir)" try { if (Test-Path -LiteralPath $script:SetupExe) { $fi = Get-Item -LiteralPath $script:SetupExe & $Report -Phase 2 -Status 'info' -Message "DIAG SetupExe EXISTS size=$($fi.Length) bytes lastWrite=$($fi.LastWriteTime.ToString('o'))" } else { & $Report -Phase 2 -Status 'warning' -Message "DIAG SetupExe MISSING at expected path." if (Test-Path -LiteralPath $script:PsScripts) { $names = (Get-ChildItem -LiteralPath $script:PsScripts -File -ErrorAction SilentlyContinue | Select-Object -First 40 | ForEach-Object { $_.Name }) -join ', ' & $Report -Phase 2 -Status 'info' -Message "DIAG psscripts dir contents: $names" $anyExe = Get-ChildItem -LiteralPath $script:PsScripts -Recurse -Filter '*.exe' -ErrorAction SilentlyContinue | Select-Object -First 10 | ForEach-Object { "$($_.FullName) ($($_.Length))" } & $Report -Phase 2 -Status 'info' -Message "DIAG *.exe under psscripts: $([string]::Join(' | ', $anyExe))" } else { & $Report -Phase 2 -Status 'warning' -Message "DIAG psscripts dir does NOT exist: $($script:PsScripts)" } } } catch { & $Report -Phase 2 -Status 'warning' -Message "DIAG probe error: $($_.Exception.GetType().Name): $($_.Exception.Message)" } $alreadyService = Test-AgentService $alreadyTask = Test-TrayTask & $Report -Phase 2 -Status 'info' -Message "DIAG existing service=$alreadyService tray-task=$alreadyTask" if ($AutoUpdate) { # Auto-update path: update in place (do NOT clean first). & $Report -Phase 2 -Status 'info' -Message 'AutoUpdate: updating agent in place (no cleanup).' Invoke-SetupExe $Report } else { # Manual GUI install: ALWAYS clean up first (every time), even if a prior # service/task exists. Never short-circuit on existing install. if ($alreadyService -or $alreadyTask) { & $Report -Phase 2 -Status 'info' -Message 'Existing agent detected; manual install cleans up first (not skipping).' } Invoke-AgentCleanup $Report Invoke-SetupExe $Report } # Retry until BOTH the service and the tray task are present. $maxAttempts = 4 for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { Start-Sleep -Seconds 3 $svc = Test-AgentService $task = Test-TrayTask & $Report -Phase 2 -Status 'info' -Message "Verify attempt $attempt/${maxAttempts}: service=$svc tray-task=$task" if ($svc -and $task) { & $Report -Phase 2 -Status 'done' -Message 'Base agent installed (service + tray task present).' return } if ($attempt -lt $maxAttempts) { & $Report -Phase 2 -Status 'warning' -Message 'Service/tray not both detected; retrying bundled setup...' Invoke-SetupExe $Report } } throw "Phase 2: after $maxAttempts attempts, service=$([bool](Test-AgentService)) tray-task=$([bool](Test-TrayTask)). Keeping log visible; not assuming managed elsewhere." } # ---- Phase 3: role modules ------------------------------------------------- function Invoke-PhaseRoleModules([hashtable]$Ctx, [scriptblock]$Report) { & $Report -Phase 3 -Status 'running' -Message 'Installing matching role modules...' if (-not $Ctx.Roles -or $Ctx.Roles.Count -eq 0) { & $Report -Phase 3 -Status 'done' -Message 'No optional roles; base only.'; return } foreach ($role in $Ctx.Roles) { Install-RoleModule $role $Report | Out-Null } & $Report -Phase 3 -Status 'done' -Message "Role modules installed: $($Ctx.Roles -join ', ')" } # ---- Phase 4: DHCP->static + register host --------------------------------- function Invoke-PhaseNetwork([hashtable]$Ctx, [scriptblock]$Report) { & $Report -Phase 4 -Status 'running' -Message 'Configuring network + registering host...' if (-not $SkipStaticIp) { $cfg = Get-NetIPConfiguration | Where-Object { $_.IPv4DefaultGateway -and $_.NetAdapter.Status -eq 'Up' } | Select-Object -First 1 if ($cfg) { $ifIndex = $cfg.InterfaceIndex $ipObj = Get-NetIPAddress -InterfaceIndex $ifIndex -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '169.254.*' } | Select-Object -First 1 $Ctx.StaticIp = $ipObj.IPAddress; $prefix = $ipObj.PrefixLength; $gateway = $cfg.IPv4DefaultGateway.NextHop $dns = (Get-DnsClientServerAddress -InterfaceIndex $ifIndex -AddressFamily IPv4).ServerAddresses if ($ipObj.PrefixOrigin -ne 'Manual') { Set-NetIPInterface -InterfaceIndex $ifIndex -Dhcp Disabled -ErrorAction SilentlyContinue Remove-NetIPAddress -InterfaceIndex $ifIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue Remove-NetRoute -InterfaceIndex $ifIndex -DestinationPrefix '0.0.0.0/0' -Confirm:$false -ErrorAction SilentlyContinue New-NetIPAddress -InterfaceIndex $ifIndex -IPAddress $Ctx.StaticIp -PrefixLength $prefix -DefaultGateway $gateway | Out-Null if ($dns) { Set-DnsClientServerAddress -InterfaceIndex $ifIndex -ServerAddresses $dns } & $Report -Phase 4 -Status 'info' -Message "Pinned STATIC $($Ctx.StaticIp)/$prefix." } else { & $Report -Phase 4 -Status 'info' -Message 'Adapter already static.' } } else { & $Report -Phase 4 -Status 'warning' -Message 'No active adapter with gateway found.' } } else { & $Report -Phase 4 -Status 'info' -Message 'Static-IP conversion skipped.' } $apiToken = $script:EffApiToken $Ctx.ApiHeaders = @{} if ($apiToken) { $Ctx.ApiHeaders['Authorization'] = "Bearer $apiToken" } if ($Ctx.StaticIp) { $body = @{ hostname=$Ctx.HostName; staticIp=$Ctx.StaticIp; domain=$Ctx.Domain; roles=$Ctx.Roles } | ConvertTo-Json try { $r = Invoke-RestMethod -Method POST -Uri "$($Ctx.ApiBaseUrl)/api/remote-exec/register-host" -Headers $Ctx.ApiHeaders -ContentType 'application/json' -Body $body $Ctx.Registered = [bool]$r.admitted & $Report -Phase 4 -Status 'info' -Message "register-host: admitted=$($r.admitted) reason=$($r.reason)" } catch { & $Report -Phase 4 -Status 'warning' -Message "register-host failed (will retry phase 6): $($_.Exception.Message)" } } & $Report -Phase 4 -Status 'done' -Message 'Network configured.' } # ---- Phase 5: cloudflared (check-first, bundled) + tunnel + DNS ------------- function Invoke-PhaseCloudflare([hashtable]$Ctx, [scriptblock]$Report) { & $Report -Phase 5 -Status 'running' -Message 'Configuring Cloudflare (check-first cloudflared)...' if ($SkipCloudflare) { $Ctx.DnsStatus='skipped'; & $Report -Phase 5 -Status 'done' -Message 'Cloudflare skipped.'; return } # Pull CF account/token from the SAVED token.txt values (Credential Manager: # MAJIC_CF_ACCOUNT_ID / MAJIC_CF_API_TOKEN, seeded by the light installer). Resolve-CfConfig $cfToken = $script:EffCfToken $cfAccount = $script:EffCfAccount if (-not $cfToken) { throw 'Cloudflare API token not available (token.txt line 4 / Credential Manager MAJIC_CF_API_TOKEN).' } if (-not $cfAccount) { throw 'Cloudflare Account ID not available (token.txt line 3 / Credential Manager MAJIC_CF_ACCOUNT_ID).' } $cfHeaders = @{ Authorization = "Bearer $cfToken" } # Validate the stored token before using it, so a stale/rotated CF token gives a # clear message instead of opaque 403s deeper in the phase. try { $vfy = Invoke-RestMethod -Headers $cfHeaders -Uri "$($Ctx.CfApi)/user/tokens/verify" -ErrorAction Stop if ($vfy.success) { & $Report -Phase 5 -Status 'info' -Message "CF creds loaded from stored token.txt values (account $cfAccount; token status=$($vfy.result.status))." } } catch { & $Report -Phase 5 -Status 'warning' -Message "Stored Cloudflare API token failed verify ($($_.Exception.Message)) - it may be stale/rotated. Re-seed token.txt line 4. Continuing (tunnel/DNS calls may fail)." } # Resolve cloudflared from wherever it ACTUALLY is (PATH / installed service / # common dirs / staged offline copy). In the per-bin model it is often already # INSTALLED and the .bin download was skipped by the probe - so requiring the # staged copy was wrong (the 'Bundled cloudflared.exe missing' Phase 5 failure). # If it is genuinely absent we SKIP the online/tunnel setup gracefully instead of # throwing - the agent install itself does not depend on the tunnel being up. $cfExe = Resolve-CloudflaredExe if (-not $cfExe) { & $Report -Phase 5 -Status 'warning' -Message 'cloudflared not found (PATH / service / install dirs / staged copy all empty). Skipping tunnel/DNS setup; re-run after the cloudflared bin installs.' return } & $Report -Phase 5 -Status 'info' -Message "Using cloudflared at: $cfExe" # CHECK FIRST: if the cloudflared service exists and is running, leave it. $cfsvc = Get-Service -Name 'cloudflared' -ErrorAction SilentlyContinue $svcValid = $false if ($cfsvc) { # Treat a present service whose binary path points at a real cloudflared # as "configured". (Deeper config validation can be layered later.) $svcValid = $true if ($cfsvc.Status -eq 'Running') { & $Report -Phase 5 -Status 'info' -Message 'cloudflared service already running and valid; leaving it alone.' } else { & $Report -Phase 5 -Status 'info' -Message 'cloudflared service present but stopped; starting it.' try { Start-Service cloudflared } catch { $svcValid = $false; & $Report -Phase 5 -Status 'warning' -Message "could not start cloudflared: $($_.Exception.Message)" } } } # Tunnel resolve/create if ($UseSharedTunnel -and $SharedTunnelId) { $Ctx.TunnelId = $SharedTunnelId & $Report -Phase 5 -Status 'info' -Message "Attaching to shared tunnel $($Ctx.TunnelId)." } else { # CANONICAL per-host tunnel name: 'tunnel-'. Reuse the existing one for # this host (preferring a HEALTHY connector) before creating a new tunnel, so # we never spawn duplicates (the cause of tunnel sprawl + DNS pointing at dead # tunnels). All account tunnels were normalized to this scheme. $hostLc = $Ctx.HostName.ToLower() $tunnelName = "tunnel-$hostLc" $all = Invoke-RestMethod -Headers $cfHeaders -Uri "$($Ctx.CfApi)/accounts/$cfAccount/cfd_tunnel?name=$tunnelName&is_deleted=false&per_page=100" $mine = @($all.result | Where-Object { $_.name -eq $tunnelName }) $pick = ($mine | Where-Object { $_.status -eq 'healthy' } | Select-Object -First 1) if (-not $pick) { $pick = ($mine | Select-Object -First 1) } if ($pick) { $Ctx.TunnelId = $pick.id & $Report -Phase 5 -Status 'info' -Message "Reusing tunnel '$($pick.name)' status=$($pick.status) ($($Ctx.TunnelId))." } else { $secret = [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Max 256 })) $create = Invoke-RestMethod -Method POST -Headers $cfHeaders -ContentType 'application/json' -Uri "$($Ctx.CfApi)/accounts/$cfAccount/cfd_tunnel" -Body (@{ name=$tunnelName; tunnel_secret=$secret; config_src='cloudflare' } | ConvertTo-Json) $Ctx.TunnelId = $create.result.id & $Report -Phase 5 -Status 'info' -Message "Created tunnel $tunnelName ($($Ctx.TunnelId))." } } # DNS CNAME .majicholdings.com -> .cfargotunnel.com $zone = Invoke-RestMethod -Headers $cfHeaders -Uri "$($Ctx.CfApi)/zones?name=$CfZoneName" if (-not $zone.result -or $zone.result.Count -eq 0) { throw "Cloudflare zone '$CfZoneName' not found." } $zoneId = $zone.result[0].id $Ctx.Fqdn = "$($Ctx.HostName.ToLower()).$CfZoneName" $target = "$($Ctx.TunnelId).cfargotunnel.com" $rec = @{ type='CNAME'; name=$Ctx.Fqdn; content=$target; proxied=$true } | ConvertTo-Json $existing = Invoke-RestMethod -Headers $cfHeaders -Uri "$($Ctx.CfApi)/zones/$zoneId/dns_records?name=$($Ctx.Fqdn)" if ($existing.result -and $existing.result.Count -gt 0) { Invoke-RestMethod -Method PUT -Headers $cfHeaders -ContentType 'application/json' -Uri "$($Ctx.CfApi)/zones/$zoneId/dns_records/$($existing.result[0].id)" -Body $rec | Out-Null & $Report -Phase 5 -Status 'info' -Message "Updated DNS $($Ctx.Fqdn) -> $target." } else { Invoke-RestMethod -Method POST -Headers $cfHeaders -ContentType 'application/json' -Uri "$($Ctx.CfApi)/zones/$zoneId/dns_records" -Body $rec | Out-Null & $Report -Phase 5 -Status 'info' -Message "Created DNS $($Ctx.Fqdn) -> $target." } $Ctx.DnsStatus = 'configured' # If THIS host runs the API (IIS role), also publish the public API subdomain # api. -> this host's tunnel, with an ingress rule routing it to the # local IIS API on 127.0.0.1:8080. cloudflare-managed tunnels carry ingress # via the configurations API. Best-effort; never fails the run. if ($Ctx.Roles -contains 'IIS') { try { $apiFqdn = "api.$CfZoneName" # 1) ingress on this tunnel (merge: keep api rule + catch-all 404). $curCfg = Invoke-RestMethod -Headers $cfHeaders -Uri "$($Ctx.CfApi)/accounts/$cfAccount/cfd_tunnel/$($Ctx.TunnelId)/configurations" $ingress = @() if ($curCfg.result -and $curCfg.result.config -and $curCfg.result.config.ingress) { $ingress = @($curCfg.result.config.ingress | Where-Object { $_.hostname -ne $apiFqdn -and $_.hostname }) } $ingress += @{ hostname=$apiFqdn; service='http://127.0.0.1:8080' } $ingress += @{ service='http_status:404' } # catch-all must be last $cfgBody = @{ config = @{ ingress = $ingress } } | ConvertTo-Json -Depth 6 Invoke-RestMethod -Method PUT -Headers $cfHeaders -ContentType 'application/json' -Uri "$($Ctx.CfApi)/accounts/$cfAccount/cfd_tunnel/$($Ctx.TunnelId)/configurations" -Body $cfgBody | Out-Null # 2) DNS api. -> this tunnel. $apiRec = @{ type='CNAME'; name=$apiFqdn; content=$target; proxied=$true } | ConvertTo-Json $apiExisting = Invoke-RestMethod -Headers $cfHeaders -Uri "$($Ctx.CfApi)/zones/$zoneId/dns_records?name=$apiFqdn" if ($apiExisting.result -and $apiExisting.result.Count -gt 0) { Invoke-RestMethod -Method PUT -Headers $cfHeaders -ContentType 'application/json' -Uri "$($Ctx.CfApi)/zones/$zoneId/dns_records/$($apiExisting.result[0].id)" -Body $apiRec | Out-Null } else { Invoke-RestMethod -Method POST -Headers $cfHeaders -ContentType 'application/json' -Uri "$($Ctx.CfApi)/zones/$zoneId/dns_records" -Body $apiRec | Out-Null } & $Report -Phase 5 -Status 'info' -Message "Published API subdomain $apiFqdn -> tunnel (ingress -> 127.0.0.1:8080)." } catch { & $Report -Phase 5 -Status 'warning' -Message "API subdomain publish warning: $($_.Exception.Message)" } } # Only (re)install the cloudflared service if it is NOT already valid/running. if (-not $svcValid) { try { # If a broken/wrong service exists, stop it and wait before fixing. if ($cfsvc) { & $Report -Phase 5 -Status 'warning' -Message 'cloudflared service invalid; stopping and reconfiguring.' & sc.exe stop cloudflared 2>$null | Out-Null $deadline = (Get-Date).AddSeconds(20) while ((Get-Date) -lt $deadline -and (Get-Service cloudflared -ErrorAction SilentlyContinue).Status -ne 'Stopped') { Start-Sleep -Milliseconds 500 } & $cfExe service uninstall 2>$null | Out-Null } $tokResp = Invoke-RestMethod -Headers $cfHeaders -Uri "$($Ctx.CfApi)/accounts/$cfAccount/cfd_tunnel/$($Ctx.TunnelId)/token" & $cfExe service install $tokResp.result & $Report -Phase 5 -Status 'info' -Message "cloudflared service installed from $cfExe." } catch { & $Report -Phase 5 -Status 'warning' -Message "cloudflared service install warning: $($_.Exception.Message)" } } & $Report -Phase 5 -Status 'done' -Message "Cloudflare configured ($($Ctx.Fqdn))." } # ---- Phase 6: verify service + tray + first heartbeat ---------------------- function Invoke-PhaseVerify([hashtable]$Ctx, [scriptblock]$Report) { & $Report -Phase 6 -Status 'running' -Message 'Verifying service + tray task + first heartbeat...' if (-not $SkipServiceInstall) { if (Test-AgentService) { & sc.exe config $script:ServiceName obj= 'NT AUTHORITY\LocalService' | Out-Null $svc = Get-Service -Name $script:ServiceName if ($svc.Status -ne 'Running') { try { Start-Service $script:ServiceName } catch {} } $Ctx.ServiceStatus = 'running' & $Report -Phase 6 -Status 'info' -Message 'MajicAgent service present and running.' } else { $Ctx.ServiceStatus = 'not-installed'; & $Report -Phase 6 -Status 'failed' -Message 'MajicAgent service NOT found.' } if (Test-TrayTask) { $Ctx.TrayStatus = 'present'; & $Report -Phase 6 -Status 'info' -Message 'MajicAgentTray scheduled task present.' } else { $Ctx.TrayStatus = 'missing'; & $Report -Phase 6 -Status 'failed' -Message 'MajicAgentTray scheduled task NOT found.' } } else { $Ctx.ServiceStatus = 'skipped'; $Ctx.TrayStatus = 'skipped' } $regBody = @{ hostname=$Ctx.HostName; staticIp=$Ctx.StaticIp; domain=$Ctx.Domain; os='Windows'; roles=$Ctx.Roles } | ConvertTo-Json try { Invoke-RestMethod -Method POST -Uri "$($Ctx.ApiBaseUrl)/api/remote-exec/register-host" -Headers $Ctx.ApiHeaders -ContentType 'application/json' -Body $regBody | Out-Null $Ctx.HeartbeatOk = $true & $Report -Phase 6 -Status 'info' -Message 'Agent registered with MAJIC API (first heartbeat sent).' } catch { & $Report -Phase 6 -Status 'warning' -Message "Agent registration retry warning: $($_.Exception.Message)" } & $Report -Phase 6 -Status 'done' -Message 'Verification phase complete.' } # Dispatch by NAME (string), resolved at call time. Capturing via ${function:..} # into a $script: array is fragile across dot-source/runspace boundaries (the # refs can come back null, making every phase a silent no-op). Names are robust. $script:PhaseFnNames = @( 'Invoke-PhasePreflight' 'Invoke-PhaseBaseAgent' 'Invoke-PhaseRoleModules' 'Invoke-PhaseNetwork' 'Invoke-PhaseCloudflare' 'Invoke-PhaseVerify' ) function Invoke-AllPhases([hashtable]$Ctx, [scriptblock]$Report) { for ($i = 0; $i -lt $script:PhaseFnNames.Count; $i++) { $phaseNo = $i + 1 $fnName = $script:PhaseFnNames[$i] $cmd = Get-Command -Name $fnName -CommandType Function -ErrorAction SilentlyContinue if (-not $cmd) { & $Report -Phase $phaseNo -Status 'failed' -Message "Phase $phaseNo function '$fnName' not found in scope." continue } try { & $cmd $Ctx $Report } catch { $ex = $_.Exception $loc = try { "$($_.InvocationInfo.ScriptName | Split-Path -Leaf):$($_.InvocationInfo.ScriptLineNumber)" } catch { '?' } & $Report -Phase $phaseNo -Status 'failed' -Message "Phase $phaseNo failed: $($ex.Message)" & $Report -Phase $phaseNo -Status 'failed' -Message "Phase $phaseNo exType=$($ex.GetType().FullName) at $loc" if ($ex -is [System.ComponentModel.Win32Exception]) { & $Report -Phase $phaseNo -Status 'failed' -Message "Phase $phaseNo Win32 native code=$($ex.NativeErrorCode)" } if ($_.InvocationInfo -and $_.InvocationInfo.Line) { & $Report -Phase $phaseNo -Status 'failed' -Message "Phase $phaseNo failing line: $($_.InvocationInfo.Line.Trim())" } } } return $Ctx } function Get-InstallSummary([hashtable]$Ctx) { $rolesText = if ($Ctx.Roles.Count) { $Ctx.Roles -join ', ' } else { 'base only' } return (@( "Host: $($Ctx.HostName)" "AD domain: $(if ($Ctx.Domain) { $Ctx.Domain } else { '(workgroup)' })" "Detected roles: $rolesText" "Local Ollama: $(if ($Ctx.OllamaUp) { 'reachable' } else { 'not reachable' })" "Static IP: $(if ($Ctx.StaticIp) { $Ctx.StaticIp } else { '(unchanged)' })" "Host registered: $(if ($Ctx.Registered) { 'yes' } else { 'no/unknown' })" "DNS / tunnel: $($Ctx.DnsStatus)$(if ($Ctx.Fqdn) { " ($($Ctx.Fqdn))" } else { '' })" "Service: $($Ctx.ServiceStatus)" "Tray task: $($Ctx.TrayStatus)" "MAJIC API: $(if ($Ctx.ApiLive) { 'live' } else { 'not live yet (registration deferred)' })" "First heartbeat: $(if ($Ctx.HeartbeatOk) { 'sent' } elseif (-not $Ctx.ApiLive) { 'deferred (API not live)' } else { 'not confirmed' })" ) -join [Environment]::NewLine) } # =========================================================================== # Uninstall (targeted; preserves cache + ProgramData root) # =========================================================================== function Invoke-Uninstall { $report = { param([int]$Phase,[string]$Status,[string]$Message) Write-Host "[uninstall][$Status] $Message" } Invoke-AgentCleanup $report Write-Host '[uninstall] Done. installer-cache and ProgramData root preserved.' } # =========================================================================== # Silent front-end # =========================================================================== function Start-SilentInstall { $report = { param([int]$Phase,[string]$Status,[string]$Message) $tag = switch ($Status) { 'running'{'>>'} 'done'{'OK'} 'failed'{'XX'} 'warning'{'!!'} default{' '} } $line = "[install][P$Phase][$tag] $Message" $script:RunLog.Add($line) Write-Host $line } Write-Host "[install] MAJIC admin agent (silent) phases: $($script:PhaseList -join ' | ')" $ctx = New-InstallContext Invoke-AllPhases $ctx $report | Out-Null Write-Host '' Write-Host '[install] ===== Summary =====' Get-InstallSummary $ctx -split [Environment]::NewLine | ForEach-Object { $script:RunLog.Add("[summary] $_"); Write-Host "[install] $_" } $rc = if (($ctx.ServiceStatus -eq 'not-installed') -or ($ctx.TrayStatus -eq 'missing')) { 1 } else { 0 } Send-RunTelemetry $rc return $rc } # =========================================================================== # GUI front-end — resizable/maximizable, color-coded log # =========================================================================== function Start-GuiInstall { Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase # Always write a local log (no token needed) so a hang/early failure is # diagnosable even when central telemetry has no PAT. $script:GuiLog = Join-Path $env:TEMP 'majic-install-gui.log' function Write-GLog([string]$m) { try { Add-Content -LiteralPath $script:GuiLog -Value ('{0} {1}' -f (Get-Date -Format o), $m) } catch {} } Write-GLog 'GUI start' $xaml = @"