ever-works-node 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1082 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Pure decision logic for install-service.ps1.
4
+
5
+ .DESCRIPTION
6
+ Everything install-service.ps1 has to get RIGHT but cannot exercise
7
+ without changing the machine lives here: account normalisation, the
8
+ service configuration plan, the scheduled-task registration plan, the
9
+ ACL convergence plan, and the preflight verdicts.
10
+
11
+ The rules for this file:
12
+
13
+ * No I/O. Nothing here reads the registry, touches a file, queries
14
+ an ACL, or starts a process. The installer gathers those facts and
15
+ passes them in; these functions only DECIDE. That is what lets
16
+ test-install-service-logic.ps1 cover the branches that would
17
+ otherwise need a real service on a real fleet workstation.
18
+
19
+ * No side effects at load. Dot-sourcing this file defines functions
20
+ and nothing else, so the installer and the test harness can both
21
+ pull it in with
22
+ `. (Join-Path $PSScriptRoot 'install-service.logic.ps1')`.
23
+
24
+ * No secrets. The plans returned from here never carry a password. A
25
+ step that needs one is FLAGGED (RequiresPassword) and the installer
26
+ supplies the secret at the moment of the call, through unmanaged
27
+ memory. A plan is therefore always safe to print, to log, and to
28
+ assert on.
29
+
30
+ * ASCII only. Windows PowerShell 5.1 decodes a BOM-less script as the
31
+ system ANSI codepage, so a UTF-8 em dash arrives as three CP1252
32
+ characters whose last one is a curly double quote - which
33
+ PowerShell accepts as a string terminator. A single em dash inside
34
+ a double-quoted string is therefore a PARSE ERROR on the very hosts
35
+ this installer targets. The two scripts CI already runs under 5.1
36
+ (native/windows-job-launcher/*.ps1) are pure ASCII for this reason;
37
+ windows-service-contract.internal.spec.ts pins it.
38
+
39
+ Dot-sourced rather than imported as a module on purpose: the six fleet
40
+ workstations run Windows PowerShell 5.1 with no pwsh, and a .psm1 would
41
+ drag in module-path and manifest questions on machines an operator only
42
+ ever touches to run this one installer. build.js copies the whole
43
+ `packaging` directory into the published package
44
+ (files: ['cli.js', 'packaging', ...]), so this file ships beside
45
+ install-service.ps1 wherever that script is run from.
46
+ #>
47
+
48
+ # --- Command-line quoting -------------------------------------------------
49
+
50
+ # Quote ONE argument the way CommandLineToArgvW / the MSVC C runtime will
51
+ # read it back: wrap in double quotes when needed, escape embedded double
52
+ # quotes, and double the backslashes that sit in front of a quote (or at
53
+ # the very end, where they would otherwise escape the closing quote).
54
+ function ConvertTo-CommandLineArgument {
55
+ param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Value)
56
+ if ($Value.Length -gt 0 -and $Value -notmatch '[\s"]') {
57
+ return $Value
58
+ }
59
+ $escaped = [regex]::Replace($Value, '(\\*)"', '$1$1\"')
60
+ $escaped = [regex]::Replace($escaped, '(\\+)$', '$1$1')
61
+ return "`"$escaped`""
62
+ }
63
+
64
+ # Join a whole argument vector into the single command-line string that
65
+ # CreateProcess actually takes. Windows PowerShell 5.1 would otherwise
66
+ # re-quote each spaced element WITHOUT escaping the quotes inside it, so a
67
+ # pinned path such as "C:\Program Files\nodejs\claude.cmd" arrives split
68
+ # into two arguments.
69
+ function ConvertTo-CommandLine {
70
+ param([Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]] $Arguments)
71
+ return (($Arguments | ForEach-Object { ConvertTo-CommandLineArgument $_ }) -join ' ')
72
+ }
73
+
74
+ # One rendering of "program plus arguments", used by every dry-run line and
75
+ # every failure message, so the two can never describe different calls.
76
+ function Format-NativeCommandLine {
77
+ param(
78
+ [Parameter(Mandatory = $true)][string] $FilePath,
79
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]] $Arguments
80
+ )
81
+ return "$(ConvertTo-CommandLineArgument $FilePath) $(ConvertTo-CommandLine $Arguments)"
82
+ }
83
+
84
+ # --- Service account identity --------------------------------------------
85
+
86
+ # The accounts Windows lets a service log on as WITHOUT a password. The SCM's
87
+ # lpServiceStartName and New-ScheduledTaskPrincipal's -UserId disagree about
88
+ # the spelling of every one of them, which is why each entry carries both.
89
+ function Get-BuiltInServiceAccountMap {
90
+ $system = [pscustomobject]@{ Service = 'LocalSystem'; Task = 'NT AUTHORITY\SYSTEM'; Label = 'LocalSystem' }
91
+ $local = [pscustomobject]@{ Service = 'NT AUTHORITY\LocalService'; Task = 'NT AUTHORITY\LOCAL SERVICE'; Label = 'LocalService' }
92
+ $network = [pscustomobject]@{ Service = 'NT AUTHORITY\NetworkService'; Task = 'NT AUTHORITY\NETWORK SERVICE'; Label = 'NetworkService' }
93
+ return @{
94
+ 'localsystem' = $system
95
+ 'system' = $system
96
+ 'nt authority\system' = $system
97
+ '.\localsystem' = $system
98
+ 'localservice' = $local
99
+ 'local service' = $local
100
+ 'nt authority\localservice' = $local
101
+ 'nt authority\local service' = $local
102
+ 'networkservice' = $network
103
+ 'network service' = $network
104
+ 'nt authority\networkservice' = $network
105
+ 'nt authority\network service' = $network
106
+ }
107
+ }
108
+
109
+ <#
110
+ .SYNOPSIS
111
+ Normalise an operator-supplied account into the two spellings Windows
112
+ needs, plus whether it takes a password.
113
+
114
+ .DESCRIPTION
115
+ Accepted forms, all of which real operators type:
116
+
117
+ (blank) the account running the installer
118
+ DOMAIN\User a domain account
119
+ MACHINE\User a local account named by this machine
120
+ .\User a local account, the SCM's own shorthand
121
+ User a bare name, read as local (.\User)
122
+ user@domain.tld a UPN
123
+ DOMAIN\name$ a group Managed Service Account - NO password
124
+ LocalSystem and the other built-in service accounts - NO password
125
+ NT SERVICE\name a virtual service account - NO password
126
+
127
+ MachineName / DefaultDomain / DefaultUser are injected rather than read
128
+ from $env so every branch is reachable from a test on any host.
129
+ #>
130
+ function Resolve-ServiceAccountIdentity {
131
+ param(
132
+ [AllowEmptyString()][AllowNull()][string] $Account,
133
+ [Parameter(Mandatory = $true)][string] $MachineName,
134
+ [Parameter(Mandatory = $true)][string] $DefaultDomain,
135
+ [Parameter(Mandatory = $true)][string] $DefaultUser
136
+ )
137
+
138
+ $raw = ''
139
+ if ($null -ne $Account) { $raw = $Account.Trim() }
140
+ if ($raw.Length -eq 0) {
141
+ $raw = "$DefaultDomain\$DefaultUser"
142
+ }
143
+
144
+ $builtIn = (Get-BuiltInServiceAccountMap)[$raw.ToLowerInvariant()]
145
+ if ($null -ne $builtIn) {
146
+ return [pscustomobject]@{
147
+ Input = $Account
148
+ Kind = 'builtin'
149
+ Domain = 'NT AUTHORITY'
150
+ UserName = $builtIn.Label
151
+ SamAccountName = $builtIn.Task
152
+ ServiceLogonName = $builtIn.Service
153
+ TaskUserId = $builtIn.Task
154
+ RequiresPassword = $false
155
+ IsBuiltIn = $true
156
+ NeedsLogonRight = $false
157
+ Description = "built-in service account $($builtIn.Label)"
158
+ }
159
+ }
160
+
161
+ if ($raw -match '[/"]') {
162
+ throw "-ServiceAccount '$raw' is not a valid Windows account name (a forward slash or a double quote is never part of one). Use DOMAIN\User, .\User, MACHINE\User, a UPN, or a gMSA name ending in '$'."
163
+ }
164
+
165
+ # @() so the count is still 0 (not $null) when nothing matches, which
166
+ # Set-StrictMode -Version Latest in the test harness would trip on.
167
+ $separators = @($raw.ToCharArray() | Where-Object { $_ -eq '\' }).Count
168
+ if ($separators -gt 1) {
169
+ throw "-ServiceAccount '$raw' has more than one '\' separator; expected DOMAIN\User."
170
+ }
171
+ if ($separators -eq 1 -and $raw.Contains('@')) {
172
+ throw "-ServiceAccount '$raw' mixes the DOMAIN\User and user@domain forms; use one or the other."
173
+ }
174
+
175
+ if ($separators -eq 0 -and $raw.Contains('@')) {
176
+ $parts = $raw.Split('@')
177
+ if ($parts.Length -ne 2 -or $parts[0].Length -eq 0 -or $parts[1].Length -eq 0) {
178
+ throw "-ServiceAccount '$raw' is not a valid UPN; expected user@domain.tld."
179
+ }
180
+ return [pscustomobject]@{
181
+ Input = $Account
182
+ Kind = 'upn'
183
+ Domain = $parts[1]
184
+ UserName = $parts[0]
185
+ SamAccountName = $raw
186
+ ServiceLogonName = $raw
187
+ TaskUserId = $raw
188
+ RequiresPassword = $true
189
+ IsBuiltIn = $false
190
+ NeedsLogonRight = $true
191
+ Description = "domain account $raw (UPN)"
192
+ }
193
+ }
194
+
195
+ $domain = ''
196
+ $user = $raw
197
+ if ($separators -eq 1) {
198
+ $index = $raw.IndexOf('\')
199
+ $domain = $raw.Substring(0, $index)
200
+ $user = $raw.Substring($index + 1)
201
+ }
202
+ if ($user.Length -eq 0) {
203
+ throw "-ServiceAccount '$raw' names no user; expected DOMAIN\User or .\User."
204
+ }
205
+ if ($separators -eq 1 -and $domain.Length -eq 0) {
206
+ throw "-ServiceAccount '$raw' names no domain; use '.\$user' for a local account."
207
+ }
208
+
209
+ # NT SERVICE\<name> is a virtual account: the SCM derives its password,
210
+ # so passing one is an error rather than an omission.
211
+ if ($domain -ieq 'NT SERVICE') {
212
+ return [pscustomobject]@{
213
+ Input = $Account
214
+ Kind = 'virtual'
215
+ Domain = 'NT SERVICE'
216
+ UserName = $user
217
+ SamAccountName = "NT SERVICE\$user"
218
+ ServiceLogonName = "NT SERVICE\$user"
219
+ TaskUserId = "NT SERVICE\$user"
220
+ RequiresPassword = $false
221
+ IsBuiltIn = $true
222
+ NeedsLogonRight = $false
223
+ Description = "virtual service account NT SERVICE\$user"
224
+ }
225
+ }
226
+
227
+ $isLocal = ($domain.Length -eq 0) -or ($domain -eq '.') -or ($domain -ieq $MachineName)
228
+
229
+ # A gMSA is spelled with a trailing '$' and is DOMAIN-scoped by
230
+ # definition: the domain controller holds the password, which is the
231
+ # entire point of the account type. The SCM takes the name with an EMPTY
232
+ # password, and the account still needs SeServiceLogonRight like any
233
+ # other - so this is a password-less account that is NOT a built-in.
234
+ if ($user.EndsWith('$') -and -not $isLocal) {
235
+ return [pscustomobject]@{
236
+ Input = $Account
237
+ Kind = 'gmsa'
238
+ Domain = $domain
239
+ UserName = $user
240
+ SamAccountName = "$domain\$user"
241
+ ServiceLogonName = "$domain\$user"
242
+ TaskUserId = "$domain\$user"
243
+ RequiresPassword = $false
244
+ IsBuiltIn = $false
245
+ NeedsLogonRight = $true
246
+ Description = "managed service account $domain\$user"
247
+ }
248
+ }
249
+
250
+ if ($isLocal) {
251
+ return [pscustomobject]@{
252
+ Input = $Account
253
+ Kind = 'local'
254
+ Domain = $MachineName
255
+ UserName = $user
256
+ SamAccountName = "$MachineName\$user"
257
+ # The SCM documents '.\user' for a local account and resolves it
258
+ # against the machine itself; the task scheduler wants the
259
+ # machine spelled out and rejects the '.' shorthand.
260
+ ServiceLogonName = ".\$user"
261
+ TaskUserId = "$MachineName\$user"
262
+ RequiresPassword = $true
263
+ IsBuiltIn = $false
264
+ NeedsLogonRight = $true
265
+ Description = "local account $MachineName\$user"
266
+ }
267
+ }
268
+
269
+ return [pscustomobject]@{
270
+ Input = $Account
271
+ Kind = 'domain'
272
+ Domain = $domain
273
+ UserName = $user
274
+ SamAccountName = "$domain\$user"
275
+ ServiceLogonName = "$domain\$user"
276
+ TaskUserId = "$domain\$user"
277
+ RequiresPassword = $true
278
+ IsBuiltIn = $false
279
+ NeedsLogonRight = $true
280
+ Description = "domain account $domain\$user"
281
+ }
282
+ }
283
+
284
+ <#
285
+ .SYNOPSIS
286
+ True when Windows will demand a password for this account.
287
+
288
+ .DESCRIPTION
289
+ The branch that decides whether -ServicePassword is mandatory. A blank
290
+ account means "the installer's own account", which is always a real
291
+ user and therefore always needs one - so the DefaultDomain/DefaultUser
292
+ that Resolve-ServiceAccountIdentity would substitute cannot change the
293
+ answer, and this wrapper does not need them.
294
+ #>
295
+ function Test-ServiceAccountRequiresPassword {
296
+ param(
297
+ [AllowEmptyString()][AllowNull()][string] $Account,
298
+ [Parameter(Mandatory = $true)][string] $MachineName
299
+ )
300
+ $identity = Resolve-ServiceAccountIdentity `
301
+ -Account $Account `
302
+ -MachineName $MachineName `
303
+ -DefaultDomain $MachineName `
304
+ -DefaultUser 'installer'
305
+ return $identity.RequiresPassword
306
+ }
307
+
308
+ # --- Access verdicts (pure rules over facts the installer measured) -------
309
+
310
+ <#
311
+ .SYNOPSIS
312
+ The SIDs whose ACEs may legitimately stand in for this account.
313
+
314
+ .DESCRIPTION
315
+ A false 'granted' is far worse than an 'unknown': it hides exactly the
316
+ bug this installer exists to fix. So a well-known group is only
317
+ considered when the chosen account is genuinely in it.
318
+
319
+ * Everyone (S-1-1-0) and Authenticated Users (S-1-5-11) are in the
320
+ token of every account that authenticates, gMSAs and virtual
321
+ service accounts included.
322
+ * BUILTIN\Users (S-1-5-32-545) is a LOCAL group. Local users are in
323
+ it, and so are domain users (joining a domain puts Domain Users in
324
+ it). A gMSA and an NT SERVICE\<name> virtual account are NOT.
325
+
326
+ Administrators is deliberately absent: assuming it would turn "the
327
+ account happens to be an admin" into a silent pass for accounts that
328
+ are not.
329
+ #>
330
+ function Get-AccessProbeSidList {
331
+ param(
332
+ [Parameter(Mandatory = $true)][string] $Sid,
333
+ [Parameter(Mandatory = $true)][string] $IdentityKind
334
+ )
335
+ $sids = [Collections.Generic.List[string]]::new()
336
+ $sids.Add($Sid)
337
+ $sids.Add('S-1-1-0')
338
+ $sids.Add('S-1-5-11')
339
+ if (@('local', 'domain', 'upn') -contains $IdentityKind) {
340
+ $sids.Add('S-1-5-32-545')
341
+ }
342
+ return , $sids.ToArray()
343
+ }
344
+
345
+ <#
346
+ .SYNOPSIS
347
+ Three-valued access answer for one DACL and one account.
348
+
349
+ .DESCRIPTION
350
+ $Rules is the ACE list reduced to what the decision needs - SidValue,
351
+ Rights (the FileSystemRights bitmask as an int) and IsDeny - so this
352
+ stays pure and the whole table is reachable from a test.
353
+
354
+ Three rules, each of which has bitten this script:
355
+
356
+ * A Deny ACE that overlaps the wanted rights AT ALL denies the
357
+ request, even when it covers only some of them. Windows evaluates
358
+ access per bit; requiring a Deny ACE to be a superset (which an
359
+ earlier draft did) reported 'granted' for a path the account
360
+ cannot actually use.
361
+ * Allow ACEs are CUMULATIVE. Two ACEs granting Read and Write
362
+ together satisfy a Modify request even though neither does alone.
363
+ * Absence proves a denial only when inheritance has been stripped.
364
+ That is exactly the shape node-io.ts writes the config file in
365
+ (icacls /inheritance:r /grant:r <user>:(F)), which is what makes
366
+ the most important check here a precise one. Where the DACL still
367
+ inherits, the account may reach the file through a group this list
368
+ does not enumerate, so the answer is 'unknown' and the preflight
369
+ warns rather than blocking a correct install.
370
+ #>
371
+ function Get-AccessVerdictFromAcl {
372
+ param(
373
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]] $Rules,
374
+ [Parameter(Mandatory = $true)][bool] $IsProtected,
375
+ [Parameter(Mandatory = $true)][string[]] $ConsideredSids,
376
+ [Parameter(Mandatory = $true)][int] $WantedRights
377
+ )
378
+
379
+ $granted = 0
380
+ foreach ($rule in $Rules) {
381
+ if ($ConsideredSids -notcontains $rule.SidValue) { continue }
382
+ $overlap = ([int] $rule.Rights) -band $WantedRights
383
+ if ($overlap -eq 0) { continue }
384
+ if ($rule.IsDeny) { return 'denied' }
385
+ $granted = $granted -bor $overlap
386
+ }
387
+
388
+ if (($granted -band $WantedRights) -eq $WantedRights) { return 'granted' }
389
+ if ($IsProtected) { return 'denied' }
390
+ return 'unknown'
391
+ }
392
+
393
+ # Mirrors WINDOWS_LAUNCHABLE in model-cli-probe.ts so the preflight and the
394
+ # node's own probe can never disagree about what counts as launchable.
395
+ function Get-WindowsLaunchableExtension {
396
+ return , @('.cmd', '.exe', '.bat')
397
+ }
398
+
399
+ # The pin verdict, from facts the installer measured. A pin that does not
400
+ # resolve DISABLES that CLI rather than falling back to PATH, so every state
401
+ # below except 'unpinned' and 'ok' means the node would silently stop
402
+ # advertising the capability - or advertise it and fail every job.
403
+ function Get-ModelCliPinStateFromFacts {
404
+ param(
405
+ [AllowEmptyString()][AllowNull()][string] $PinnedPath,
406
+ [Parameter(Mandatory = $true)][bool] $Exists,
407
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Extension,
408
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $AccessVerdict
409
+ )
410
+ if ([string]::IsNullOrWhiteSpace($PinnedPath)) { return 'unpinned' }
411
+ if (-not $Exists) { return 'missing' }
412
+ if ((Get-WindowsLaunchableExtension) -notcontains $Extension.ToLowerInvariant()) { return 'not-launchable' }
413
+ if ($AccessVerdict -eq 'denied') { return 'unreadable' }
414
+ return 'ok'
415
+ }
416
+
417
+ function Test-PathUnder {
418
+ param(
419
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Path,
420
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Ancestor
421
+ )
422
+ if ([string]::IsNullOrWhiteSpace($Path) -or [string]::IsNullOrWhiteSpace($Ancestor)) { return $false }
423
+ $normalisedPath = $Path.TrimEnd('\') + '\'
424
+ $normalisedAncestor = $Ancestor.TrimEnd('\') + '\'
425
+ return $normalisedPath.StartsWith($normalisedAncestor, [StringComparison]::OrdinalIgnoreCase)
426
+ }
427
+
428
+ # --- Service configuration plan -------------------------------------------
429
+
430
+ <#
431
+ .SYNOPSIS
432
+ The ordered steps that register and configure the service.
433
+
434
+ .DESCRIPTION
435
+ `nssm install` calls CreateService with a NULL lpServiceStartName,
436
+ which the Win32 API defines as LocalSystem. LocalSystem has its own
437
+ profile, so the node looks for its config under
438
+ C:\Windows\system32\config\systemprofile\AppData\Roaming, reports "not
439
+ enrolled" (exit 3), and is restarted every 10 seconds forever. Hence
440
+ the 'identity' step.
441
+
442
+ Placement matters three times over:
443
+
444
+ * INSIDE the convergent block, not in the fresh-install branch - a
445
+ re-run to change -ClaudePath must re-apply the identity too, or the
446
+ service silently keeps whatever the first install used.
447
+ * BEFORE the service is started, or the first start runs under the
448
+ old identity.
449
+ * BEFORE `Start SERVICE_AUTO_START`, which is therefore the LAST
450
+ step. If anything fails in between, the half-configured service
451
+ cannot come back as LocalSystem at the next boot even if the
452
+ installer's own rollback also fails.
453
+
454
+ The identity step is a plan entry rather than an `nssm set ObjectName`
455
+ call because NSSM takes the password as a command-line ARGUMENT, and a
456
+ child process's command line is recorded by Security event 4688 where
457
+ command-line auditing is enabled and uploaded to the cloud by any EDR
458
+ agent that captures ProcessCommandLine. The installer instead calls
459
+ ChangeServiceConfigW with the password in unmanaged memory and grants
460
+ SeServiceLogonRight itself - the one thing NSSM was doing for us that
461
+ the SCM does not do on its own. Nothing in this plan ever carries the
462
+ secret; the step is FLAGGED RequiresPassword and the plan stays safe to
463
+ print and to assert on.
464
+ #>
465
+ function New-ServiceConfigurationPlan {
466
+ param(
467
+ [Parameter(Mandatory = $true)][string] $Name,
468
+ [Parameter(Mandatory = $true)][string] $NodeExe,
469
+ [Parameter(Mandatory = $true)][string] $ArgumentLine,
470
+ [Parameter(Mandatory = $true)][string] $StateDir,
471
+ [Parameter(Mandatory = $true)][string] $ServiceLogonName,
472
+ [switch] $ServiceExists,
473
+ [switch] $WithPassword
474
+ )
475
+
476
+ $steps = [Collections.Generic.List[object]]::new()
477
+ $addNssm = {
478
+ param([string[]] $Arguments, [string] $Description)
479
+ $steps.Add([pscustomobject]@{
480
+ Kind = 'nssm'
481
+ Arguments = $Arguments
482
+ Account = ''
483
+ Description = $Description
484
+ RequiresPassword = $false
485
+ })
486
+ }
487
+
488
+ if (-not $ServiceExists) {
489
+ & $addNssm @('install', $Name, $NodeExe) 'register the service'
490
+ }
491
+
492
+ & $addNssm @('set', $Name, 'Application', $NodeExe) 'point the service at node.exe'
493
+ & $addNssm @('set', $Name, 'AppParameters', $ArgumentLine) 'apply the current cli.js flags'
494
+ & $addNssm @('set', $Name, 'AppDirectory', $StateDir) 'set the working directory'
495
+
496
+ $steps.Add([pscustomobject]@{
497
+ Kind = 'identity'
498
+ Arguments = @($Name, $ServiceLogonName)
499
+ Account = $ServiceLogonName
500
+ Description = "run as $ServiceLogonName"
501
+ RequiresPassword = $WithPassword.IsPresent
502
+ })
503
+
504
+ & $addNssm @('set', $Name, 'AppStdout', (Join-Path $StateDir 'node.log')) 'redirect stdout'
505
+ & $addNssm @('set', $Name, 'AppStderr', (Join-Path $StateDir 'node.err.log')) 'redirect stderr'
506
+ & $addNssm @('set', $Name, 'AppStopMethodConsole', '900000') 'give the drain 15 minutes'
507
+ & $addNssm @('set', $Name, 'AppExit', 'Default', 'Restart') 'restart on exit'
508
+ & $addNssm @('set', $Name, 'AppRestartDelay', '10000') 'back off 10s between restarts'
509
+ & $addNssm @('set', $Name, 'Start', 'SERVICE_AUTO_START') 'start at boot'
510
+
511
+ return , $steps.ToArray()
512
+ }
513
+
514
+ # Render one plan step as the operation it will actually perform. A step
515
+ # that takes a password renders the PLACEHOLDER, never the value - the
516
+ # password is never IN the plan, and this is the only rendering path in the
517
+ # installer, so no caller (dry-run line, progress message, or exception) can
518
+ # print one by accident.
519
+ function Format-ServicePlanStep {
520
+ param(
521
+ [Parameter(Mandatory = $true)][string] $NssmPath,
522
+ [Parameter(Mandatory = $true)] $Step
523
+ )
524
+ if ($Step.Kind -eq 'identity') {
525
+ $rendered = "Set-ServiceLogonIdentity -Name $(ConvertTo-CommandLineArgument $Step.Arguments[0]) -Account $(ConvertTo-CommandLineArgument $Step.Account)"
526
+ if ($Step.RequiresPassword) {
527
+ $rendered += ' -Password <redacted -ServicePassword>'
528
+ }
529
+ return $rendered
530
+ }
531
+ return (Format-NativeCommandLine -FilePath $NssmPath -Arguments $Step.Arguments)
532
+ }
533
+
534
+ # --- State directory and log ACLs -----------------------------------------
535
+
536
+ <#
537
+ .SYNOPSIS
538
+ The icacls calls that let the chosen account write its own logs.
539
+
540
+ .DESCRIPTION
541
+ %ProgramData%\ever-works-node is the service's AppDirectory and the
542
+ parent of node.log / node.err.log on BOTH mechanisms, and nothing else
543
+ grants the service account anything there. Two measured facts make that
544
+ a real failure rather than a theoretical one:
545
+
546
+ * Files created inside a %ProgramData% subfolder by a privileged
547
+ identity inherit only BUILTIN\Users: ReadAndExecute. The
548
+ `Users: Write` ACE on C:\ProgramData is ContainerInherit ONLY, so
549
+ it reaches subfolders and never files.
550
+ * `cmd.exe /s /c "... >> <unwritable> 2>> <unwritable>"` exits 1,
551
+ prints "Access is denied." to the inherited stderr, and never runs
552
+ the command at all. Under the Task Scheduler that stderr goes
553
+ nowhere, so the result is a task with last-run 0x1, a node that
554
+ never started, and both log files empty.
555
+
556
+ The case that actually happens is the one this change creates: a
557
+ machine installed by the OLD script wrote node.log as LocalSystem, the
558
+ operator re-runs the fixed installer with -ServiceAccount, and the new
559
+ account inherits read-only access to the existing file. So the log
560
+ files are converged as well as the directory, and inheritance flags are
561
+ spelled per target - (OI)(CI) is meaningless on a file.
562
+ #>
563
+ function New-StateDirectoryAclPlan {
564
+ param(
565
+ [Parameter(Mandatory = $true)][string] $StateDir,
566
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]] $ExistingLogPaths,
567
+ [Parameter(Mandatory = $true)][string] $Account
568
+ )
569
+
570
+ $steps = [Collections.Generic.List[object]]::new()
571
+ $steps.Add([pscustomobject]@{
572
+ Arguments = @($StateDir, '/grant', "$($Account):(OI)(CI)(M)")
573
+ Description = "let $Account write the state directory and anything created in it"
574
+ })
575
+ foreach ($log in $ExistingLogPaths) {
576
+ $steps.Add([pscustomobject]@{
577
+ Arguments = @($log, '/grant', "$($Account):(M)")
578
+ Description = "let $Account append to a log file written under a previous identity"
579
+ })
580
+ }
581
+ return , $steps.ToArray()
582
+ }
583
+
584
+ # --- Scheduled-task plan --------------------------------------------------
585
+
586
+ <#
587
+ .SYNOPSIS
588
+ How to register the fallback scheduled task for this identity.
589
+
590
+ .DESCRIPTION
591
+ Register-ScheduledTask has two DISJOINT parameter sets (verified with
592
+ (Get-Command Register-ScheduledTask).ParameterSets on Windows 10 22H2,
593
+ PowerShell 5.1.19041): the [Principal] set takes -Principal and has no
594
+ -Password at all, and the [User] set takes -User / -Password /
595
+ -RunLevel and has no -Principal. So a password forces the [User] set
596
+ and the principal object is not built at all.
597
+
598
+ LogonType is the second, independent identity bug in this branch. The
599
+ original script pinned S4U, which mints a token WITHOUT the account's
600
+ password: it carries no network credentials and never unlocks the
601
+ account's DPAPI master key, so Windows Credential Manager reads fail
602
+ exactly as they do under LocalSystem. That is the git credential helper
603
+ today, and the node's own credential as soon as the optional
604
+ @napi-rs/keyring dependency is installed. Supplying -ServicePassword is
605
+ what turns it into a real password (batch) logon.
606
+
607
+ The password-less accounts do NOT want S4U either, and for different
608
+ reasons: a gMSA has a password, held by the domain controller, so its
609
+ supported shape is -LogonType Password with no -Password value; the
610
+ built-ins and NT SERVICE\<name> virtual accounts want ServiceAccount.
611
+ S4U is only ever right for a real user whose password we were not
612
+ given.
613
+ #>
614
+ function New-ScheduledTaskRegistrationPlan {
615
+ param(
616
+ [Parameter(Mandatory = $true)] $Identity,
617
+ [switch] $WithPassword
618
+ )
619
+
620
+ if ($WithPassword) {
621
+ return [pscustomobject]@{
622
+ ParameterSet = 'User'
623
+ LogonType = 'Password'
624
+ PrincipalParameters = $null
625
+ RegisterParameters = [ordered]@{ User = $Identity.TaskUserId; RunLevel = 'Highest' }
626
+ }
627
+ }
628
+
629
+ $logonType = 'S4U'
630
+ if ($Identity.IsBuiltIn) {
631
+ $logonType = 'ServiceAccount'
632
+ }
633
+ elseif ($Identity.Kind -eq 'gmsa') {
634
+ # A gMSA HAS a password; the domain controller holds it. The task
635
+ # scheduler's shape for that is a Password logon with no value.
636
+ $logonType = 'Password'
637
+ }
638
+
639
+ # The operator-facing warning about what S4U costs lives in
640
+ # Get-ServiceInstallPreflightFinding ('s4u-logon') so the preflight
641
+ # report stays the single place an operator reads for diagnosis.
642
+ return [pscustomobject]@{
643
+ ParameterSet = 'Principal'
644
+ LogonType = $logonType
645
+ PrincipalParameters = [ordered]@{ UserId = $Identity.TaskUserId; LogonType = $logonType; RunLevel = 'Highest' }
646
+ RegisterParameters = [ordered]@{}
647
+ }
648
+ }
649
+
650
+ <#
651
+ .SYNOPSIS
652
+ The task action, with the stdout/stderr redirection the service branch
653
+ already has.
654
+
655
+ .DESCRIPTION
656
+ New-ScheduledTaskAction has no redirection facility of its own, so the
657
+ only way to land the same two files NSSM's AppStdout / AppStderr
658
+ produce is to run the node under cmd.exe. The script header refuses
659
+ cmd.exe for the SERVICE because NSSM stops a service by sending a
660
+ console control event and cmd.exe swallows it ("Terminate batch job
661
+ (Y/N)?"), which kills the drain. That objection does not apply here:
662
+ Stop-ScheduledTask terminates the task's processes outright and
663
+ delivers no console event, so this branch never drained in the first
664
+ place. Trading a console signal that was never sent for logs that were
665
+ never written is the right way round - and it is one more reason this
666
+ branch stays the FALLBACK rather than the recommended shape.
667
+
668
+ `/s` is load-bearing: it makes cmd.exe strip exactly the first and last
669
+ quote and take everything between them verbatim, which is the only
670
+ deterministic behaviour when the inner command line is itself quoted.
671
+ #>
672
+ function New-ScheduledTaskActionSpec {
673
+ param(
674
+ [Parameter(Mandatory = $true)][string] $ComSpec,
675
+ [Parameter(Mandatory = $true)][string] $NodeExe,
676
+ [Parameter(Mandatory = $true)][string] $ArgumentLine,
677
+ [Parameter(Mandatory = $true)][string] $StdoutPath,
678
+ [Parameter(Mandatory = $true)][string] $StderrPath
679
+ )
680
+ $inner = "$(ConvertTo-CommandLineArgument $NodeExe) $ArgumentLine"
681
+ $redirect = ">> $(ConvertTo-CommandLineArgument $StdoutPath) 2>> $(ConvertTo-CommandLineArgument $StderrPath)"
682
+ return [pscustomobject]@{
683
+ Execute = $ComSpec
684
+ Argument = "/s /c `"$inner $redirect`""
685
+ }
686
+ }
687
+
688
+ # --- Preflight ------------------------------------------------------------
689
+
690
+ function New-PreflightFinding {
691
+ param(
692
+ [Parameter(Mandatory = $true)][ValidateSet('error', 'warning')][string] $Severity,
693
+ [Parameter(Mandatory = $true)][string] $Code,
694
+ [Parameter(Mandatory = $true)][string] $Message
695
+ )
696
+ return [pscustomobject]@{ Severity = $Severity; Code = $Code; Message = $Message }
697
+ }
698
+
699
+ # A gMSA is provisioned, never signed in to, so "sign in once" is advice it
700
+ # can never take. Say the thing that IS true for the account in hand.
701
+ function Get-AccountProvisioningRemedy {
702
+ param([Parameter(Mandatory = $true)] $Identity)
703
+ if ($Identity.Kind -eq 'gmsa') {
704
+ return "A gMSA is provisioned rather than signed in to: run Install-ADServiceAccount on this machine, then create the profile and the enrollment by running the node once as that account (schtasks /create /ru $($Identity.SamAccountName) ...). A gMSA is an unusual choice for a fleet node, whose whole job is to run the ENROLLING user's work with that user's git and model-CLI logins."
705
+ }
706
+ return "Sign in once as $($Identity.SamAccountName) (enrollment has to happen there anyway), then re-run this installer."
707
+ }
708
+
709
+ <#
710
+ .SYNOPSIS
711
+ Decide whether this account can actually run a node, from facts the
712
+ installer has already gathered.
713
+
714
+ .DESCRIPTION
715
+ The failure this exists to prevent is silent: a node installed under
716
+ the wrong identity starts cleanly, finds no config, reports "not
717
+ enrolled" (exit 3), and is restarted every 10 seconds forever. Worse,
718
+ config-store.ts's loadConfig swallows a permission error and returns
719
+ null too, so an EPERM on the config file is indistinguishable in the
720
+ log from a machine that was never enrolled. Every check below therefore
721
+ names the exact path and the exact remedy rather than a generic
722
+ failure.
723
+
724
+ $Probe is a plain hashtable of already-measured facts so this function
725
+ stays pure. Access answers are three-valued - 'granted' / 'denied' /
726
+ 'unknown' - for the reasons spelled out on Get-AccessVerdictFromAcl.
727
+
728
+ Severity is chosen by ONE rule: block when the install could not work
729
+ and this script cannot fix it; warn when it can. That is why the state
730
+ directory and the log files warn - the installer grants the account
731
+ Modify on them - while an unreadable config, which only the enrolling
732
+ user can repair, refuses.
733
+ #>
734
+ function Get-ServiceInstallPreflightFinding {
735
+ param(
736
+ [Parameter(Mandatory = $true)] $Identity,
737
+ [Parameter(Mandatory = $true)][bool] $HasPassword,
738
+ [Parameter(Mandatory = $true)][ValidateSet('service', 'task')][string] $Mechanism,
739
+ [Parameter(Mandatory = $true)][hashtable] $Probe
740
+ )
741
+
742
+ $findings = [Collections.Generic.List[object]]::new()
743
+ $account = $Identity.SamAccountName
744
+
745
+ # 1. The identity itself ----------------------------------------------
746
+
747
+ if ($Identity.IsBuiltIn) {
748
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'built-in-service-account' -Message "$($Identity.Description) cannot run a fleet node. A node executes the enrolling user's work: its config lives in that user's %APPDATA%\ever-works-node, its git credentials in that user's Credential Manager vault, and the Claude Code / Codex logins in that user's profile. A built-in account has none of them. Pass -ServiceAccount <DOMAIN\User>."))
749
+ }
750
+
751
+ if ($Identity.RequiresPassword -and -not $HasPassword) {
752
+ if ($Mechanism -eq 'service') {
753
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'password-required' -Message "$($Identity.Description) needs a password to log on as a service; without one the service registers and then fails to start with error 1069. Pass -ServicePassword (Read-Host -AsSecureString)."))
754
+ }
755
+ else {
756
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code 's4u-logon' -Message "No -ServicePassword, so the task will use an S4U logon. It runs as $account, but the token never unlocks that account's DPAPI master key: Windows Credential Manager reads - the git credential helper, and the node credential once @napi-rs/keyring is installed - report absent. Pass -ServicePassword for a real logon."))
757
+ }
758
+ }
759
+
760
+ if (-not $Identity.RequiresPassword -and $HasPassword) {
761
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'password-not-accepted' -Message "$($Identity.Description) derives its own password; supplying -ServicePassword for it is rejected by the Service Control Manager. Omit it."))
762
+ }
763
+
764
+ if (-not $Probe.AccountResolved) {
765
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'unknown-account' -Message "Windows does not recognise the account '$account' on this machine or in its domain. Check the spelling, or use .\<name> for a local account."))
766
+ # Nothing below can be measured without a SID, so stop here rather
767
+ # than emitting a cascade of derived complaints about one typo.
768
+ return , $findings.ToArray()
769
+ }
770
+
771
+ # 2. The profile every node path is derived from ----------------------
772
+
773
+ if ([string]::IsNullOrWhiteSpace($Probe.ProfilePath)) {
774
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'no-profile' -Message "$account has no user profile on this machine, so it has no %APPDATA% and cannot hold an enrolled node's config. $(Get-AccountProvisioningRemedy -Identity $Identity)"))
775
+ return , $findings.ToArray()
776
+ }
777
+
778
+ # 3. The node credential ----------------------------------------------
779
+
780
+ if (-not $Probe.ConfigExists) {
781
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'not-enrolled' -Message "No node config for $account at '$($Probe.ConfigPath)'. Enrollment is per-user and is deliberately not done by this installer. Run, as $account : ever-works-node enroll --api-url <url> --token <token>"))
782
+ }
783
+ elseif ($Probe.ConfigAccess -eq 'denied') {
784
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'config-unreadable' -Message "'$($Probe.ConfigPath)' exists but its ACL does not grant $account read access, and inheritance has been stripped from it (the node writes it with icacls /inheritance:r /grant:r <enrolling user>:(F), leaving exactly one ACE). The node would report this as 'not enrolled' rather than as a permission error. Enroll as $account, or grant it read: icacls `"$($Probe.ConfigPath)`" /grant `"$account`":(R)"))
785
+ }
786
+ elseif ($Probe.ConfigAccess -eq 'unknown') {
787
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code 'config-access-unproven' -Message "Could not prove from its ACL that $account can read '$($Probe.ConfigPath)'; the DACL still inherits, so access may come from a group this check cannot enumerate. If the node reports 'not enrolled' after install, check this first."))
788
+ }
789
+
790
+ if ($Probe.ConfigPathSource -eq 'environment-override') {
791
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code 'config-env-override' -Message "EVER_WORKS_NODE_CONFIG is set machine-wide and points the node at '$($Probe.ConfigPath)' instead of $account's %APPDATA%. That is honoured, but it means every account on this machine shares one node credential."))
792
+ }
793
+
794
+ # 4. The model CLIs ----------------------------------------------------
795
+
796
+ foreach ($cli in @(
797
+ [pscustomobject]@{ Name = 'claude-code'; Flag = '-ClaudePath'; Path = $Probe.ClaudePath; State = $Probe.ClaudePathState; LoginPath = $Probe.ClaudeLoginPath; LoginExists = $Probe.ClaudeLoginExists },
798
+ [pscustomobject]@{ Name = 'codex'; Flag = '-CodexPath'; Path = $Probe.CodexPath; State = $Probe.CodexPathState; LoginPath = $Probe.CodexLoginPath; LoginExists = $Probe.CodexLoginExists }
799
+ )) {
800
+ switch ($cli.State) {
801
+ 'missing' {
802
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code "$($cli.Name)-pin-missing" -Message "$($cli.Flag) '$($cli.Path)' does not exist. A pin that does not resolve DISABLES that CLI rather than falling back to PATH (model-cli-probe.ts), so the node would quietly stop advertising $($cli.Name)."))
803
+ }
804
+ 'not-launchable' {
805
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code "$($cli.Name)-pin-not-launchable" -Message "$($cli.Flag) '$($cli.Path)' is not a launchable Windows executable. The node accepts only .cmd, .exe and .bat here (WINDOWS_LAUNCHABLE in model-cli-probe.ts); npm's extension-less shim is a bash script and cannot be spawned."))
806
+ }
807
+ 'unreadable' {
808
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code "$($cli.Name)-pin-unreadable" -Message "$($cli.Flag) '$($cli.Path)' exists but its ACL does not grant $account access. The node would advertise $($cli.Name) and then fail to spawn it on every job."))
809
+ }
810
+ 'unpinned' {
811
+ if ($Mechanism -eq 'service') {
812
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code "$($cli.Name)-unpinned" -Message "No $($cli.Flag). The node will look for $($cli.Name) on PATH, but a service inherits the MACHINE environment and npm's global prefix (%APPDATA%\npm) is normally on the USER path only - so the capability will usually be absent. Pin it if this machine should take $($cli.Name) work."))
813
+ }
814
+ }
815
+ }
816
+ if ($cli.State -eq 'ok' -and -not $cli.LoginExists) {
817
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code "$($cli.Name)-login-missing" -Message "$($cli.Flag) resolves, but $account has no $($cli.Name) login at '$($cli.LoginPath)'. The node will ADVERTISE $($cli.Name) on the strength of the executable and then fail every job it is sent, unauthenticated - the tag being present is worse than it being absent. Sign in as $account, or make sure the platform supplies a credential with each job."))
818
+ }
819
+ }
820
+
821
+ # 5. The workspace root ------------------------------------------------
822
+
823
+ if ($Probe.WorkspaceRootUnderSystemProfile) {
824
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'workspace-root-in-system-profile' -Message "The per-Task worktrees would land in '$($Probe.WorkspaceRoot)', inside the protected system profile. That is writable but invisible to the operator, on the system volume, and adds MAX_PATH pressure to already-hashed worktree paths. Pass -WorkspaceRoot <D:\fleet-workspaces>."))
825
+ }
826
+ elseif ($Probe.WorkspaceRootAccess -eq 'denied') {
827
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'workspace-root-unwritable' -Message "$account cannot write '$($Probe.WorkspaceRoot)' (checked against '$($Probe.WorkspaceRootProbedAncestor)', the nearest existing ancestor). Every Task the node leases would fail to provision a worktree. Grant it write access or pass a different -WorkspaceRoot."))
828
+ }
829
+ elseif ($Probe.WorkspaceRootAccess -eq 'unknown') {
830
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code 'workspace-root-access-unproven' -Message "Could not prove from its ACL that $account can write '$($Probe.WorkspaceRoot)' (checked '$($Probe.WorkspaceRootProbedAncestor)'). If Tasks fail to provision worktrees, check this first."))
831
+ }
832
+
833
+ # 6. Where the logs go -------------------------------------------------
834
+ #
835
+ # Warnings, not errors, because the installer FIXES both: it grants the
836
+ # account Modify on the directory and on any log file left behind by a
837
+ # previous identity. They are still reported, because an operator
838
+ # rehearsing five machines should see that a repair will happen - and
839
+ # because if the repair itself fails, this is the line that explains the
840
+ # failure that follows.
841
+
842
+ if ($Probe.StateDirAccess -eq 'denied') {
843
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code 'state-dir-unwritable' -Message "'$($Probe.StateDir)' does not currently grant $account write access; the installer will grant it Modify. Without that the service cannot open its AppStdout, and the scheduled task dies inside cmd.exe before the node runs - leaving nothing in either log."))
844
+ }
845
+
846
+ if ($Probe.UnwritableLogPaths.Count -gt 0) {
847
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code 'log-unwritable' -Message "$($Probe.UnwritableLogPaths -join ', ') - written under a previous identity and readable but not writable by $account; the installer will grant it Modify. This is the normal state of a machine installed before the node ran as a real account."))
848
+ }
849
+
850
+ return , $findings.ToArray()
851
+ }
852
+
853
+ # Render the preflight verdict for the console. Errors first: an operator
854
+ # checking five machines reads the top of the output, not the bottom.
855
+ function Format-PreflightFindingReport {
856
+ param([Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]] $Findings)
857
+ $lines = [Collections.Generic.List[string]]::new()
858
+ foreach ($severity in @('error', 'warning')) {
859
+ foreach ($finding in @($Findings | Where-Object { $_.Severity -eq $severity })) {
860
+ $label = $severity.ToUpperInvariant()
861
+ $lines.Add(" [$label] $($finding.Code): $($finding.Message)")
862
+ }
863
+ }
864
+ return , $lines.ToArray()
865
+ }
866
+
867
+ function Test-PreflightBlocked {
868
+ param([Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]] $Findings)
869
+ return (@($Findings | Where-Object { $_.Severity -eq 'error' }).Count -gt 0)
870
+ }
871
+
872
+ # --- Service identity interop ---------------------------------------------
873
+
874
+ <#
875
+ .SYNOPSIS
876
+ The C# the installer compiles to set a service's logon account without
877
+ putting the password on any command line.
878
+
879
+ .DESCRIPTION
880
+ Returned as a string rather than compiled here, so this file keeps its
881
+ no-side-effects-at-load rule and the test harness can compile the same
882
+ text the installer does and exercise the P/Invoke signatures.
883
+
884
+ WHY NOT `nssm set <name> ObjectName <account> <password>`, which is what
885
+ this script used to do: that is an argument on a child process's command
886
+ line, and every mechanism that records process creation records it -
887
+ Security event 4688 wherever ProcessCreationIncludeCmdLine_Enabled is
888
+ set (the Microsoft and CIS baselines set it), and any EDR agent that
889
+ captures ProcessCommandLine, which Defender for Endpoint uploads to its
890
+ cloud and retains for Advanced Hunting. The exposure is persistent and
891
+ can leave the machine. ChangeServiceConfigW takes the password as a
892
+ pointer to unmanaged memory instead: no child process, no cmdlet
893
+ parameter binding, and no managed String that survives on the GC heap.
894
+
895
+ WHAT NSSM WAS ALSO DOING FOR US: granting SeServiceLogonRight. Verified
896
+ in nssm.exe 2.24-101, which imports LsaOpenPolicy/LsaAddAccountRights
897
+ and carries the SeServiceLogonRight string. ChangeServiceConfig does NOT
898
+ grant it, and without it the service configures cleanly and then fails
899
+ to start with error 1069 - so GrantServiceLogonRight below is not
900
+ optional, it is the half of the job NSSM was quietly covering.
901
+
902
+ The ProbeXxxSignature methods exist so the marshalling can be tested on
903
+ a machine that must not be modified: each passes a deliberately invalid
904
+ handle, which exercises the full signature and returns a documented
905
+ error without reaching any service or the local security policy.
906
+ #>
907
+ function Get-ServiceIdentityInteropSource {
908
+ return @'
909
+ using System;
910
+ using System.ComponentModel;
911
+ using System.Runtime.InteropServices;
912
+
913
+ public static class EverWorksServiceIdentity
914
+ {
915
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
916
+ private static extern IntPtr OpenSCManagerW(string machineName, string databaseName, uint access);
917
+
918
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
919
+ private static extern IntPtr OpenServiceW(IntPtr scManager, string serviceName, uint access);
920
+
921
+ [DllImport("advapi32.dll", SetLastError = true)]
922
+ [return: MarshalAs(UnmanagedType.Bool)]
923
+ private static extern bool CloseServiceHandle(IntPtr handle);
924
+
925
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
926
+ [return: MarshalAs(UnmanagedType.Bool)]
927
+ private static extern bool ChangeServiceConfigW(
928
+ IntPtr service,
929
+ uint serviceType,
930
+ uint startType,
931
+ uint errorControl,
932
+ string binaryPathName,
933
+ string loadOrderGroup,
934
+ IntPtr tagId,
935
+ string dependencies,
936
+ string serviceStartName,
937
+ IntPtr password,
938
+ string displayName);
939
+
940
+ [StructLayout(LayoutKind.Sequential)]
941
+ private struct LSA_UNICODE_STRING
942
+ {
943
+ public ushort Length;
944
+ public ushort MaximumLength;
945
+ public IntPtr Buffer;
946
+ }
947
+
948
+ [StructLayout(LayoutKind.Sequential)]
949
+ private struct LSA_OBJECT_ATTRIBUTES
950
+ {
951
+ public int Length;
952
+ public IntPtr RootDirectory;
953
+ public IntPtr ObjectName;
954
+ public uint Attributes;
955
+ public IntPtr SecurityDescriptor;
956
+ public IntPtr SecurityQualityOfService;
957
+ }
958
+
959
+ [DllImport("advapi32.dll", SetLastError = true)]
960
+ private static extern uint LsaOpenPolicy(
961
+ IntPtr systemName, ref LSA_OBJECT_ATTRIBUTES objectAttributes, uint desiredAccess, out IntPtr policyHandle);
962
+
963
+ [DllImport("advapi32.dll", SetLastError = true)]
964
+ private static extern uint LsaAddAccountRights(
965
+ IntPtr policyHandle, byte[] accountSid, LSA_UNICODE_STRING[] userRights, uint countOfRights);
966
+
967
+ [DllImport("advapi32.dll")]
968
+ private static extern uint LsaClose(IntPtr policyHandle);
969
+
970
+ [DllImport("advapi32.dll")]
971
+ private static extern int LsaNtStatusToWinError(uint status);
972
+
973
+ private const uint SC_MANAGER_CONNECT = 0x0001;
974
+ private const uint SERVICE_CHANGE_CONFIG = 0x0002;
975
+ private const uint SERVICE_NO_CHANGE = 0xFFFFFFFF;
976
+ private const uint POLICY_CREATE_ACCOUNT = 0x0010;
977
+ private const uint POLICY_LOOKUP_NAMES = 0x0800;
978
+
979
+ // Without SeServiceLogonRight the SCM refuses to start the service with
980
+ // error 1069 no matter how correct the account and password are.
981
+ public static void GrantServiceLogonRight(byte[] accountSid)
982
+ {
983
+ LSA_OBJECT_ATTRIBUTES attributes = new LSA_OBJECT_ATTRIBUTES();
984
+ attributes.Length = Marshal.SizeOf(typeof(LSA_OBJECT_ATTRIBUTES));
985
+ IntPtr policy;
986
+ uint status = LsaOpenPolicy(
987
+ IntPtr.Zero, ref attributes, POLICY_CREATE_ACCOUNT | POLICY_LOOKUP_NAMES, out policy);
988
+ if (status != 0)
989
+ {
990
+ throw new Win32Exception(
991
+ LsaNtStatusToWinError(status),
992
+ "Could not open the local security policy to grant the \"Log on as a service\" right");
993
+ }
994
+ try
995
+ {
996
+ LSA_UNICODE_STRING[] rights = new LSA_UNICODE_STRING[1];
997
+ rights[0] = ToLsaString("SeServiceLogonRight");
998
+ try
999
+ {
1000
+ status = LsaAddAccountRights(policy, accountSid, rights, 1);
1001
+ if (status != 0)
1002
+ {
1003
+ throw new Win32Exception(
1004
+ LsaNtStatusToWinError(status),
1005
+ "Could not grant the \"Log on as a service\" right to the service account");
1006
+ }
1007
+ }
1008
+ finally { Marshal.FreeHGlobal(rights[0].Buffer); }
1009
+ }
1010
+ finally { LsaClose(policy); }
1011
+ }
1012
+
1013
+ // password points at unmanaged UTF-16 owned and zeroed by the caller. An
1014
+ // EMPTY string is passed for gMSAs and virtual accounts; a NULL pointer
1015
+ // would mean "leave the stored password alone", which is a different
1016
+ // thing entirely.
1017
+ public static void SetServiceLogonAccount(string serviceName, string account, IntPtr password)
1018
+ {
1019
+ IntPtr manager = OpenSCManagerW(null, null, SC_MANAGER_CONNECT);
1020
+ if (manager == IntPtr.Zero)
1021
+ {
1022
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not open the Service Control Manager");
1023
+ }
1024
+ try
1025
+ {
1026
+ IntPtr service = OpenServiceW(manager, serviceName, SERVICE_CHANGE_CONFIG);
1027
+ if (service == IntPtr.Zero)
1028
+ {
1029
+ throw new Win32Exception(
1030
+ Marshal.GetLastWin32Error(), "Could not open the service '" + serviceName + "' to change its logon account");
1031
+ }
1032
+ try
1033
+ {
1034
+ bool ok = ChangeServiceConfigW(
1035
+ service, SERVICE_NO_CHANGE, SERVICE_NO_CHANGE, SERVICE_NO_CHANGE,
1036
+ null, null, IntPtr.Zero, null, account, password, null);
1037
+ if (!ok)
1038
+ {
1039
+ throw new Win32Exception(
1040
+ Marshal.GetLastWin32Error(), "Could not set the logon account of '" + serviceName + "' to '" + account + "'");
1041
+ }
1042
+ }
1043
+ finally { CloseServiceHandle(service); }
1044
+ }
1045
+ finally { CloseServiceHandle(manager); }
1046
+ }
1047
+
1048
+ // Signature probes. Both pass an invalid handle on purpose: they prove
1049
+ // the P/Invoke marshals without touching a service or the policy.
1050
+ public static int ProbeChangeServiceConfigSignature()
1051
+ {
1052
+ IntPtr password = Marshal.StringToCoTaskMemUni("");
1053
+ try
1054
+ {
1055
+ bool ok = ChangeServiceConfigW(
1056
+ IntPtr.Zero, SERVICE_NO_CHANGE, SERVICE_NO_CHANGE, SERVICE_NO_CHANGE,
1057
+ null, null, IntPtr.Zero, null, ".\\nobody", password, null);
1058
+ if (ok) { throw new InvalidOperationException("ChangeServiceConfigW accepted a null service handle"); }
1059
+ return Marshal.GetLastWin32Error();
1060
+ }
1061
+ finally { Marshal.ZeroFreeCoTaskMemUnicode(password); }
1062
+ }
1063
+
1064
+ public static uint ProbeAddAccountRightsSignature(byte[] accountSid)
1065
+ {
1066
+ LSA_UNICODE_STRING[] rights = new LSA_UNICODE_STRING[1];
1067
+ rights[0] = ToLsaString("SeServiceLogonRight");
1068
+ try { return LsaAddAccountRights(IntPtr.Zero, accountSid, rights, 1); }
1069
+ finally { Marshal.FreeHGlobal(rights[0].Buffer); }
1070
+ }
1071
+
1072
+ private static LSA_UNICODE_STRING ToLsaString(string value)
1073
+ {
1074
+ LSA_UNICODE_STRING result = new LSA_UNICODE_STRING();
1075
+ result.Buffer = Marshal.StringToHGlobalUni(value);
1076
+ result.Length = (ushort)(value.Length * 2);
1077
+ result.MaximumLength = (ushort)(value.Length * 2 + 2);
1078
+ return result;
1079
+ }
1080
+ }
1081
+ '@
1082
+ }