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.
- package/LICENSE +661 -0
- package/README.md +385 -2
- package/cli.js +21859 -0
- package/package.json +47 -4
- package/packaging/README.md +276 -0
- package/packaging/systemd/ever-works-node@.service +64 -0
- package/packaging/systemd/install.sh +68 -0
- package/packaging/windows/install-service.logic.ps1 +1082 -0
- package/packaging/windows/install-service.ps1 +943 -0
- package/packaging/windows/test-install-service-logic.ps1 +722 -0
- package/packaging/windows/uninstall-service.ps1 +67 -0
|
@@ -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
|
+
}
|