-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathDeploy-HybridAgent.ps1
More file actions
598 lines (510 loc) · 30.6 KB
/
Copy pathDeploy-HybridAgent.ps1
File metadata and controls
598 lines (510 loc) · 30.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
<#
.SYNOPSIS
Deploys BitLockerKeyMonitor.HybridAgent to a remote Windows Server from the build machine.
.DESCRIPTION
This script:
1. Publishes the HybridAgent project locally (unless -SkipPublish)
2. Opens a PSSession to the target server using the supplied credential
3. Verifies the .NET 10 runtime is installed on the target; downloads and
installs the official Microsoft hosting runtime via dotnet-install.ps1
if missing
4. Stops the existing service on the target (if any)
5. Copies the published artifacts via the PS session
6. Merges connection parameters (Service Bus FQDN, Key Vault key URI,
Azure AD app credentials) into the remote appsettings.json
7. Registers/updates the Windows Service and configures recovery
8. Starts the service and verifies it is running
Prerequisites on the BUILD machine:
- .NET 10 SDK
- Source code in the same directory as this script
Prerequisites on the TARGET machine:
- Windows Server 2019+ (or Windows 11 Pro for a lab box)
- WinRM enabled (default on domain-joined servers; run `winrm quickconfig`
elsewhere)
- Outbound HTTPS to Azure Key Vault, Service Bus, and (on first run)
https://dot.net for the .NET runtime installer
- When -AzureClientCertificateThumbprint is supplied: a private-key cert
with that SHA-1 thumbprint in LocalMachine\My, granted access by the
target service account (gMSA or domain user)
.PARAMETER TargetServer
Hostname or FQDN of the target server. Required.
.PARAMETER Credential
PSCredential object for the target server. If omitted, prompts interactively.
Alias: -cred
.PARAMETER InstallRoot
Installation folder on the target. Default:
C:\Program Files\BitLockerKeyMonitor\HybridAgent
.PARAMETER ServiceAccount
Optional account to run the Windows Service under (gMSA must end with $,
e.g. mslabs\gmsa-blkmon$). If omitted, LocalSystem is used — fine for a
first deploy when the agent runs unattended and only needs outbound HTTPS.
.PARAMETER ServiceBusFullyQualifiedNamespace
FQDN of the Azure Service Bus namespace hosting the sealed-batch queue,
e.g. sb-blkmon-nsewij464oblc.servicebus.windows.net. Required for the
Phase 3 consumer to start subscribing; leave empty to deploy the agent
in idle mode (heartbeat only).
.PARAMETER QueueName
Service Bus queue name. Default: dek-sealed-batches.
.PARAMETER AzureTenantId
Entra tenant ID. Required when -AzureClientId is supplied — used to scope
the workload's app-registration sign-in.
.PARAMETER AzureClientId
Application (client) ID of the Entra app registration the agent uses to
authenticate to Azure (Key Vault decrypt + Service Bus receive). When
supplied without -AzureClientCertificateThumbprint, the script assumes the
runtime will pick up other DefaultAzureCredential sources (managed identity,
workload identity).
.PARAMETER AzureClientCertificateThumbprint
SHA-1 thumbprint (40 hex chars) of the cert in LocalMachine\My used by the
agent to authenticate as the Entra app. When supplied, env vars
AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_CERTIFICATE_THUMBPRINT are
set on the Windows Service so DefaultAzureCredential's
EnvironmentCredential -> ClientCertificateCredential lights up.
.PARAMETER SkipPublish
Skip the local dotnet publish step (use pre-built artifacts).
.PARAMETER SkipServiceRestart
Deploy binaries but do not (re)start the service.
.PARAMETER DotNetChannel
Major.minor .NET channel to install when the runtime is missing.
Default: 10.0 (matches the project target framework).
.EXAMPLE
.\Deploy-HybridAgent.ps1 -TargetServer SRVBLKHYB01 -Credential $cred `
-ServiceBusFullyQualifiedNamespace sb-blkmon-nsewij464oblc.servicebus.windows.net `
-AzureTenantId 46b06a5e-8f7a-467b-bc9a-e776011fbb57 `
-AzureClientId 11111111-2222-3333-4444-555555555555 `
-AzureClientCertificateThumbprint 855CDF6182DE9CBD6C7D3C95340B5CAA7222D2BA
.EXAMPLE
# Idle deploy (heartbeat only — no Service Bus subscription)
.\Deploy-HybridAgent.ps1 -TargetServer SRVBLKHYB01 -Credential $cred
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TargetServer,
[Alias('cred')]
[PSCredential]$Credential,
[string]$InstallRoot = "C:\Program Files\BitLockerKeyMonitor\HybridAgent",
[string]$ServiceAccount,
[string]$ServiceBusFullyQualifiedNamespace,
[string]$QueueName = "dek-sealed-batches",
# ─── KV sealing target (producer) ────────────────────────────────────────
[string]$KeyVaultUri,
[string]$SealKeyName = "blkmon-agent-seal",
# ─── AD scan parameters (mirrors on-prem Worker schema) ──────────────────
# When -ImportFromWorkerAppSettings is supplied, ScanSettings:* values are
# pulled from that file and merged before the explicit parameters below —
# explicit values WIN over the imported file so an admin can override one
# field without re-editing the file.
[string]$ImportFromWorkerAppSettings,
[string]$LdapServer,
[string]$LdapSearchBase,
# Optional newline- or comma-separated list of OU DNs to scan. Maps to ScanSettings:TargetOUs.
# When empty, the producer scans LdapSearchBase as a single root.
[string]$TargetOUs,
[int]$LdapPort,
[Nullable[bool]]$LdapUseSsl,
[string]$CronSchedule,
[string]$AzureTenantId,
[string]$AzureClientId,
[ValidateScript({
if ($_ -match '^[A-Fa-f0-9]{40}$') { return $true }
throw "AzureClientCertificateThumbprint must be a 40-character hex SHA-1 thumbprint (got '$_')."
})]
[string]$AzureClientCertificateThumbprint,
[switch]$SkipPublish,
[switch]$SkipServiceRestart,
[string]$DotNetChannel = "10.0"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if ($AzureClientCertificateThumbprint) {
$AzureClientCertificateThumbprint = $AzureClientCertificateThumbprint.Trim().ToUpperInvariant()
}
# Cross-check Azure auth parameters — silent partial config has been the source of
# multiple "service starts but auth fails on first KV call" surprises in this repo.
if ($AzureClientId -and -not $AzureTenantId) {
throw "AzureClientId supplied without AzureTenantId — both are required for ClientCertificateCredential."
}
if ($AzureClientCertificateThumbprint -and (-not $AzureClientId -or -not $AzureTenantId)) {
throw "AzureClientCertificateThumbprint requires both AzureTenantId and AzureClientId."
}
# ── Helpers ──────────────────────────────────────────────────────────────────
function Write-Step { param([string]$msg) Write-Host "`n▶ $msg" -ForegroundColor Cyan }
function Write-Ok { param([string]$msg) Write-Host " ✓ $msg" -ForegroundColor Green }
function Write-Warn { param([string]$msg) Write-Host " ⚠ $msg" -ForegroundColor Yellow }
function Write-Err { param([string]$msg) Write-Host " ✗ $msg" -ForegroundColor Red }
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$SrcDir = Join-Path $ScriptDir "src"
$AgentProj = Join-Path $SrcDir "BitLockerKeyMonitor.HybridAgent\BitLockerKeyMonitor.HybridAgent.csproj"
$AgentPubDir = Join-Path $ScriptDir "artifacts\HybridAgent"
$ServiceName = "BitLockerKeyMonitor.HybridAgent"
$ServiceDisplay = "BitLocker Key Monitor - Hybrid Agent"
# ── Pre-flight ───────────────────────────────────────────────────────────────
Write-Step "Pre-flight checks"
if (-not $SkipPublish) {
if (-not (Test-Path $AgentProj)) {
Write-Err "HybridAgent project not found: $AgentProj"
exit 1
}
Write-Ok "Source project found"
}
if (-not (Test-Connection -ComputerName $TargetServer -Count 2 -Quiet)) {
Write-Err "Cannot reach $TargetServer. Verify hostname and network."
exit 1
}
Write-Ok "$TargetServer is reachable"
# ── Publish locally ──────────────────────────────────────────────────────────
# NOTE: -r win-x64 --no-self-contained is REQUIRED. Without the RID, the
# netstandard stub of System.ServiceProcess.ServiceController.dll ends up at
# the publish root and gets loaded instead of the Windows impl, causing the
# service to crash at startup with PlatformNotSupportedException from
# WindowsServiceLifetime. This has bitten the Worker before — same trap here.
if (-not $SkipPublish) {
Write-Step "Publishing HybridAgent"
& dotnet publish $AgentProj -c Release -r win-x64 --no-self-contained -o $AgentPubDir --nologo -v q
if ($LASTEXITCODE -ne 0) { Write-Err "HybridAgent publish failed."; exit 1 }
Write-Ok "HybridAgent published to $AgentPubDir"
} else {
if (-not (Test-Path $AgentPubDir)) {
Write-Err "Pre-built artifacts not found in $AgentPubDir. Run without -SkipPublish first."
exit 1
}
Write-Ok "Using pre-built artifacts at $AgentPubDir"
}
# ── Connect to target ────────────────────────────────────────────────────────
Write-Step "Connecting to $TargetServer"
$sessionParams = @{ ComputerName = $TargetServer }
if ($Credential) {
$sessionParams.Credential = $Credential
} else {
Write-Host " No -Credential supplied. Prompting..." -ForegroundColor Yellow
$sessionParams.Credential = Get-Credential -Message "Enter credentials for $TargetServer"
}
try {
$session = New-PSSession @sessionParams
Write-Ok "Connected to $TargetServer"
} catch {
Write-Err "Failed to connect to $TargetServer : $_"
Write-Err "Ensure WinRM is enabled on the target: winrm quickconfig"
exit 1
}
try {
# ── Ensure .NET runtime is present on target ─────────────────────────────
Write-Step "Verifying .NET $DotNetChannel runtime on $TargetServer"
$dotnetReport = Invoke-Command -Session $session -ScriptBlock {
param($Channel)
# Pull the major version (e.g. "10") for prefix match on dotnet --list-runtimes.
$major = ($Channel -split '\.')[0]
# Resolve dotnet — could be on PATH already, or freshly installed by us.
$dotnetCmd = Get-Command dotnet -ErrorAction SilentlyContinue
if ($dotnetCmd) {
$runtimes = & $dotnetCmd.Source --list-runtimes 2>$null
$hasRuntime = $runtimes | Where-Object { $_ -match "^Microsoft\.NETCore\.App\s+$major\." }
if ($hasRuntime) {
return [pscustomobject]@{ Installed = $true; Version = ($hasRuntime | Select-Object -First 1); Path = $dotnetCmd.Source }
}
}
return [pscustomobject]@{ Installed = $false; Version = $null; Path = $dotnetCmd.Source }
} -ArgumentList $DotNetChannel
if ($dotnetReport.Installed) {
Write-Ok ".NET runtime present: $($dotnetReport.Version)"
} else {
Write-Warn ".NET $DotNetChannel runtime missing on target — installing via dotnet-install.ps1"
$installReport = Invoke-Command -Session $session -ScriptBlock {
param($Channel)
$installDir = "C:\Program Files\dotnet"
$installerUrl = "https://dot.net/v1/dotnet-install.ps1"
$installerPath = Join-Path $env:TEMP "dotnet-install.ps1"
# The official installer requires TLS 1.2 on older WinHttp stacks.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri $installerUrl -OutFile $installerPath -UseBasicParsing
& $installerPath -Channel $Channel -Runtime dotnet -InstallDir $installDir -NoPath -Verbose | Out-Null
# Persist dotnet on the MACHINE PATH so the service host finds it. We don't rely on
# -NoPath/-AddPath because the dotnet-install.ps1 PATH handling only affects the
# current process; we want every future process (including the SCM-launched service).
$machinePath = [Environment]::GetEnvironmentVariable("Path", [EnvironmentVariableTarget]::Machine)
if ($machinePath -notlike "*$installDir*") {
[Environment]::SetEnvironmentVariable("Path", "$machinePath;$installDir", [EnvironmentVariableTarget]::Machine)
}
# Verify
$exe = Join-Path $installDir "dotnet.exe"
if (-not (Test-Path $exe)) { throw "dotnet.exe not found at $exe after install" }
$version = & $exe --list-runtimes | Where-Object { $_ -match "^Microsoft\.NETCore\.App\s+$([Regex]::Escape(($Channel -split '\.')[0]))\." } | Select-Object -First 1
if (-not $version) { throw "Channel $Channel runtime not visible to $exe after install" }
return $version
} -ArgumentList $DotNetChannel
Write-Ok "Installed: $installReport"
}
# ── Stop service on target ───────────────────────────────────────────────
Write-Step "Stopping service on $TargetServer"
Invoke-Command -Session $session -ScriptBlock {
param($SvcName)
$svc = Get-Service -Name $SvcName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -ne 'Stopped') {
Stop-Service -Name $SvcName -Force -ErrorAction Stop
Start-Sleep -Seconds 2
Write-Output "Stopped $SvcName"
} elseif ($svc) {
Write-Output "$SvcName already stopped"
} else {
Write-Output "$SvcName not installed yet"
}
} -ArgumentList $ServiceName | ForEach-Object { Write-Ok $_ }
# ── Create directories ───────────────────────────────────────────────────
Write-Step "Creating directories on $TargetServer"
Invoke-Command -Session $session -ScriptBlock {
param($Root)
$dirs = @($Root, (Join-Path $Root "logs"))
foreach ($d in $dirs) {
if (-not (Test-Path $d)) {
New-Item -ItemType Directory -Path $d -Force | Out-Null
Write-Output "Created $d"
}
}
} -ArgumentList $InstallRoot | ForEach-Object { Write-Ok $_ }
# ── Copy artifacts ───────────────────────────────────────────────────────
Write-Step "Copying HybridAgent artifacts to $TargetServer"
Copy-Item -Path "$AgentPubDir\*" -Destination $InstallRoot -ToSession $session -Recurse -Force
Write-Ok "HybridAgent → $InstallRoot"
# ── Merge configuration into appsettings.json ────────────────────────────
# Build the section dictionary on the BUILD machine (where -ImportFromWorkerAppSettings
# lives) and ship the resolved values to the target. Each parameter explicitly supplied
# by the caller overrides the imported file so an admin can override one knob without
# re-editing the source.
$scanSettings = [ordered]@{}
$authSettings = [ordered]@{}
$kvSettings = [ordered]@{}
$sbSettings = [ordered]@{ QueueName = $QueueName }
if ($ImportFromWorkerAppSettings) {
if (-not (Test-Path -LiteralPath $ImportFromWorkerAppSettings)) {
throw "ImportFromWorkerAppSettings file not found: $ImportFromWorkerAppSettings"
}
Write-Step "Importing ScanSettings/Authentication from $ImportFromWorkerAppSettings"
$imported = Get-Content -LiteralPath $ImportFromWorkerAppSettings -Raw | ConvertFrom-Json
# ScanSettings: pull the AD-relevant fields only — Graph/Entra fields are intentionally
# skipped because Entra scanning lives on the Azure Functions side of the deployment.
$adFields = @(
'CronSchedule','LdapServer','LdapSearchBase','TargetOUs','LdapPort','LdapUseSsl',
'IncludeRecoveryKeyValue','MaxRetryAttempts','BaseRetryDelaySeconds',
'CsvOutputPath','KeyValueExportPath','RetirementGraceDays'
)
if ($imported.PSObject.Properties.Name -contains 'ScanSettings') {
foreach ($f in $adFields) {
if ($imported.ScanSettings.PSObject.Properties.Name -contains $f) {
$scanSettings[$f] = $imported.ScanSettings.$f
}
}
Write-Ok "Imported $($scanSettings.Count) ScanSettings field(s) from on-prem Worker"
}
if ($imported.PSObject.Properties.Name -contains 'Authentication') {
$auth = $imported.Authentication
$importedMode = if ($auth.PSObject.Properties.Name -contains 'AdAuthMode') { $auth.AdAuthMode } else { $null }
if ($importedMode) { $authSettings['AdAuthMode'] = $importedMode }
# Credential-mode safety: importing only AdAuthMode=Credential without the
# accompanying LdapUsername leaves the agent with a known-bad config (LDAP bind
# fails on every scan). Import the username when present and emit a loud warning
# if no password is available — passwords intentionally NOT plumbed through this
# script to keep them out of PowerShell history; ops must set the password via a
# post-deploy secure channel (Key Vault reference or manual edit).
if ($importedMode -eq 'Credential') {
if ($auth.PSObject.Properties.Name -contains 'LdapUsername' -and $auth.LdapUsername) {
$authSettings['LdapUsername'] = $auth.LdapUsername
Write-Ok "Imported Credential AdAuthMode with LdapUsername='$($auth.LdapUsername)'"
Write-Warn "LdapPassword is NOT imported by this script. Set Authentication:LdapPassword manually on $TargetServer (Key Vault reference recommended) before starting the service, or scans will fail."
} else {
Write-Warn "Imported AdAuthMode=Credential but the source file has no LdapUsername — agent will fail to bind. Either supply -LdapServer + credentials via a post-deploy step or switch the source file to WindowsIntegrated."
}
}
}
}
# Explicit parameter overrides
if ($PSBoundParameters.ContainsKey('CronSchedule')) { $scanSettings['CronSchedule'] = $CronSchedule }
if ($PSBoundParameters.ContainsKey('LdapServer')) { $scanSettings['LdapServer'] = $LdapServer }
if ($PSBoundParameters.ContainsKey('LdapSearchBase')) { $scanSettings['LdapSearchBase'] = $LdapSearchBase }
if ($PSBoundParameters.ContainsKey('TargetOUs')) { $scanSettings['TargetOUs'] = $TargetOUs }
if ($PSBoundParameters.ContainsKey('LdapPort')) { $scanSettings['LdapPort'] = $LdapPort }
if ($PSBoundParameters.ContainsKey('LdapUseSsl') -and $null -ne $LdapUseSsl) {
$scanSettings['LdapUseSsl'] = [bool]$LdapUseSsl
}
if ($PSBoundParameters.ContainsKey('KeyVaultUri')) { $kvSettings['Uri'] = $KeyVaultUri }
if ($PSBoundParameters.ContainsKey('SealKeyName')) { $kvSettings['SealKeyName'] = $SealKeyName }
if ($ServiceBusFullyQualifiedNamespace) {
$sbSettings['ServiceBusFullyQualifiedNamespace'] = $ServiceBusFullyQualifiedNamespace
}
# ────────────────────────────────────────────────────────────────────────
Write-Step "Merging configuration into appsettings.json"
Invoke-Command -Session $session -ScriptBlock {
param($InstallRoot, $Sections)
# PSCustomObject merge helper — PS 5.1 compatible. Supports nested PSCustomObject sections
# (Encryption.KeyVault) via dotted keys: pass an inner hashtable as the section value and
# the function recurses one level.
function Update-AppSettings {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][hashtable]$Sections
)
if (-not (Test-Path -LiteralPath $Path)) { throw "appsettings.json not found at $Path" }
$backup = "$Path.bak.$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Copy-Item -LiteralPath $Path -Destination $backup -Force
$obj = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
$changes = @()
function Set-Field {
param($Parent, [string]$Name, $Value)
if ($Parent.PSObject.Properties.Name -contains $Name) {
$Parent.$Name = $Value
} else {
$Parent | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force
}
}
foreach ($sectionName in $Sections.Keys) {
$sectionData = $Sections[$sectionName]
if ($null -eq $sectionData -or $sectionData.Count -eq 0) { continue }
if ($null -eq $obj.$sectionName) {
Set-Field -Parent $obj -Name $sectionName -Value ([pscustomobject]@{})
}
foreach ($key in $sectionData.Keys) {
$newVal = $sectionData[$key]
# Nested object support (Encryption.KeyVault)
if ($newVal -is [System.Collections.IDictionary]) {
if ($null -eq $obj.$sectionName.$key) {
Set-Field -Parent ($obj.$sectionName) -Name $key -Value ([pscustomobject]@{})
}
foreach ($innerKey in $newVal.Keys) {
$innerVal = $newVal[$innerKey]
$oldInner = if ($obj.$sectionName.$key.PSObject.Properties.Name -contains $innerKey) { $obj.$sectionName.$key.$innerKey } else { $null }
if ($oldInner -ne $innerVal) {
Set-Field -Parent ($obj.$sectionName.$key) -Name $innerKey -Value $innerVal
$changes += "${sectionName}:${key}:${innerKey} = $innerVal"
}
}
continue
}
$oldVal = if ($obj.$sectionName.PSObject.Properties.Name -contains $key) { $obj.$sectionName.$key } else { $null }
if ($oldVal -ne $newVal) {
Set-Field -Parent ($obj.$sectionName) -Name $key -Value $newVal
$changes += "${sectionName}:${key} = $newVal"
}
}
}
$json = $obj | ConvertTo-Json -Depth 32
[System.IO.File]::WriteAllText($Path, $json, [System.Text.UTF8Encoding]::new($false))
return [pscustomobject]@{ Path = $Path; Backup = $backup; Changes = $changes }
}
$appSettings = Join-Path $InstallRoot "appsettings.json"
$r = Update-AppSettings -Path $appSettings -Sections $Sections
foreach ($c in $r.Changes) { Write-Output "appsettings: $c" }
Write-Output "backup: $($r.Backup)"
} -ArgumentList $InstallRoot, @{
SealedBatch = $sbSettings
ScanSettings = $scanSettings
Authentication = $authSettings
Encryption = @{ KeyVault = $kvSettings }
} | ForEach-Object { Write-Ok $_ }
# ── Pre-flight: verify cert is present when thumbprint supplied ──────────
if ($AzureClientCertificateThumbprint) {
Write-Step "Verifying Azure AD client cert on $TargetServer"
Invoke-Command -Session $session -ScriptBlock {
param($Tp)
$cert = Get-ChildItem -Path "Cert:\LocalMachine\My" -ErrorAction SilentlyContinue |
Where-Object { $_.Thumbprint -eq $Tp }
if ($cert) {
"Cert $($Tp.Substring(0,8))... present: $($cert.Subject) (notAfter=$($cert.NotAfter.ToString('yyyy-MM-dd')))"
} else {
"WARNING: cert $($Tp.Substring(0,8))... NOT FOUND in LocalMachine\My — service will fail to authenticate until it is imported"
}
} -ArgumentList $AzureClientCertificateThumbprint | ForEach-Object {
if ($_ -like "WARNING:*") { Write-Warn $_ } else { Write-Ok $_ }
}
}
# ── Register service + configure recovery + (optional) account ───────────
Write-Step "Registering service on $TargetServer"
Invoke-Command -Session $session -ScriptBlock {
param($InstallRoot, $SvcName, $SvcDisplay, $ServiceAccount, $TenantId, $ClientId, $CertTp)
$exe = Join-Path $InstallRoot "BitLockerKeyMonitor.HybridAgent.exe"
if (-not (Test-Path $exe)) { throw "Service binary not found at $exe — publish artifacts probably missing the win-x64 RID" }
$existing = Get-Service -Name $SvcName -ErrorAction SilentlyContinue
if ($existing) {
& sc.exe config $SvcName binPath= "`"$exe`"" start= delayed-auto | Out-Null
Write-Output "Updated service $SvcName"
} else {
& sc.exe create $SvcName binPath= "`"$exe`"" DisplayName= $SvcDisplay start= delayed-auto | Out-Null
Write-Output "Created service $SvcName"
}
# Recovery policy: restart after 60s on first / second failure
& sc.exe failure $SvcName reset= 86400 actions= restart/60000/restart/60000// | Out-Null
# Apply service account if supplied. gMSAs end with $ and have no password
# (sc.exe accepts an empty password string for them). For a normal domain
# user, the password must be supplied via a separate secure channel — we
# do NOT accept it as a parameter to avoid leaving it in PowerShell history.
if ($ServiceAccount) {
$obj = $ServiceAccount
if ($obj -match '\$$' -or $obj -match 'gMSA') {
& sc.exe config $SvcName obj= $obj password= "" | Out-Null
Write-Output "Service account set: $obj (gMSA, no password)"
} else {
Write-Output "WARNING: ServiceAccount '$obj' does not look like a gMSA. Set the password manually via services.msc"
}
}
# Set per-service environment variables so DefaultAzureCredential's
# EnvironmentCredential picks up the client certificate from LocalMachine\My
# without us having to bake credentials into appsettings.json.
if ($TenantId -and $ClientId -and $CertTp) {
$envBlock = @(
"AZURE_TENANT_ID=$TenantId",
"AZURE_CLIENT_ID=$ClientId",
"AZURE_CLIENT_CERTIFICATE_THUMBPRINT=$CertTp",
"AZURE_CLIENT_SEND_CERTIFICATE_CHAIN=true"
)
$regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$SvcName"
Set-ItemProperty -Path $regPath -Name "Environment" -Value $envBlock -Type MultiString
Write-Output "Environment variables set on service (AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_CERTIFICATE_THUMBPRINT)"
} elseif ($TenantId -or $ClientId -or $CertTp) {
Write-Output "WARNING: partial Azure AD env vars supplied — not configured. Need all three of TenantId/ClientId/CertTp."
}
} -ArgumentList $InstallRoot, $ServiceName, $ServiceDisplay, $ServiceAccount, $AzureTenantId, $AzureClientId, $AzureClientCertificateThumbprint | ForEach-Object {
if ($_ -like "WARNING:*") { Write-Warn $_ } else { Write-Ok $_ }
}
# ── Start service ────────────────────────────────────────────────────────
if (-not $SkipServiceRestart) {
Write-Step "Starting $ServiceName on $TargetServer"
Invoke-Command -Session $session -ScriptBlock {
param($SvcName)
Start-Service -Name $SvcName
Start-Sleep -Seconds 3
$svc = Get-Service -Name $SvcName
if ($svc.Status -eq 'Running') {
Write-Output "$SvcName is running ✓"
} else {
Write-Output "WARNING: $SvcName status is $($svc.Status). Inspect Event Viewer -> Application for startup errors."
}
} -ArgumentList $ServiceName | ForEach-Object {
if ($_ -like "WARNING:*") { Write-Warn $_ } else { Write-Ok $_ }
}
} else {
Write-Warn "Skipping service restart (-SkipServiceRestart)"
}
# ── Summary ──────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "═══════════════════════════════════════════════════════════════" -ForegroundColor Green
Write-Host " HybridAgent deployed to $TargetServer!" -ForegroundColor Green
Write-Host "═══════════════════════════════════════════════════════════════" -ForegroundColor Green
Write-Host ""
Write-Host " Target server : $TargetServer"
Write-Host " Install root : $InstallRoot"
Write-Host " Service : $ServiceName"
if ($ServiceAccount) { Write-Host " Service account : $ServiceAccount" }
if ($ServiceBusFullyQualifiedNamespace) { Write-Host " Service Bus : $ServiceBusFullyQualifiedNamespace ($QueueName)" }
else { Write-Host " Service Bus : (not configured — agent runs in idle/heartbeat mode)" }
if ($AzureClientId) { Write-Host " Entra app : $AzureClientId (tenant $AzureTenantId)" }
if ($AzureClientCertificateThumbprint) { Write-Host " Auth cert : $($AzureClientCertificateThumbprint.Substring(0,8))..." }
if ($KeyVaultUri) { Write-Host " KV agent-seal : $KeyVaultUri ($SealKeyName)" }
if ($scanSettings.Count -gt 0) {
Write-Host " AD scan : LdapServer=$($scanSettings['LdapServer']) base=$($scanSettings['LdapSearchBase']) cron='$($scanSettings['CronSchedule'])'"
}
Write-Host ""
} finally {
if ($session) {
Remove-PSSession -Session $session -ErrorAction SilentlyContinue
Write-Ok "PS session closed"
}
}