fix(install): worker isolation survives a folder swap: recorded settings, -Reapply, -Verify, swap script (#901)
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
An upgrade that swaps the install folder dropped the explicit ScriptWorker isolation ACEs on script-worker\, the install folder and a copied-back pki\ (vd03 2026-11-13). Re-applying needed the operator to repeat the arguments. - Install-ScriptWorkerIsolation.ps1 records its effective settings (InstallRoot, HostServiceAccount, PeCacheDir, HostServiceName, ConfigureHostService) beside DenyPaths under ...\Services\OtOpcUaScriptWorker\Parameters (new plan step SettingsRecord) and locks that key to SYSTEM + Administrators (protected DACL; no secrets recorded). - -Reapply: re-apply from the record, no arguments; prints a summary line (changed/unchanged). - -Verify: check only, [ok]/[MISSING] per step, exit 1 when anything is missing. -Uninstall also works from the record. A node isolated by a pre-#901 installer (DenyPaths only) must run the full-argument install once; both modes say so. - Invoke-OtOpcUaInstallSwap.ps1: preflight -> stop -> move live/candidate -> carry appsettings/pki/logs -> -Reapply -> -Verify -> start; a failed -Verify leaves the service stopped. Also the rollback (-NoCarry). - Tests: dry-run harness 101/101 (record + argument rules; record-key SDDL mutation turns 2 red). The Windows test adds swap -> Verify fails (exactly the 3 lost ACEs) -> Reapply (3 changed) -> Verify ok -> Reapply 12/12 unchanged, PE-cache and firewall negative controls, the swap script, and the pre-#901 refusal; 52/52 on windev under 5.1 and 7, with a scratch local host account and with SYSTEM. Evidence: scripts/validation/opcua-windows-service/evidence/2026-11-13-windev-901-reapply-verify. - Docs: ServiceHosting isolation section; vd03 pack (swap step, rollback, post-deploy gate, one-time full-argument run), the 2026-11-13 vd03 record, and the QA pack (QA swap preceded the isolation install; ACLs checked intact over SMB on vq03/vq04 2026-11-13; next-deploy procedure). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
+30
-2
@@ -122,13 +122,41 @@ The plan is pure data asserted by `scripts/install/tests/Test-ScriptWorkerIsolat
|
||||
`Test-ScriptWorkerIsolation.Windows.ps1` applies it for real in a scratch folder (elevated), and
|
||||
`InstallerLayoutIsolationTests` (ScriptWorker.Tests, `OTOPCUA_ISOLATION_INSTALLER_E2E=1`, elevated) proves under the real
|
||||
restricted token that a worker cannot read `pki\`/`data\`/`logs\`/`backup\` of a scratch wonder layout and still runs.
|
||||
Re-run the installer after a deploy that replaces the install folder: a rename/swap drops the folder ACEs, but files
|
||||
copied into the existing folder inherit them. Rollback: `-Uninstall -ConfigureHostService`, then restart the host.
|
||||
Rollback: `-Uninstall -ConfigureHostService`, then restart the host (`-Uninstall` alone also works from the record).
|
||||
|
||||
#### After an upgrade that swaps the install folder (lmxopcua#901)
|
||||
|
||||
A rename/swap drops the explicit ACEs on `script-worker\`, on the install folder and on any denied folder that is
|
||||
recreated or copied in (vd03's `pki\`): a moved-in folder carries its own DACL. Files copied into an existing folder
|
||||
inherit the ACEs. The installer therefore records its effective settings (`InstallRoot`, `HostServiceAccount`,
|
||||
`PeCacheDir`, `HostServiceName`, `ConfigureHostService`, beside `DenyPaths`) under
|
||||
`HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaScriptWorker\Parameters`, and sets that key's DACL protected, SYSTEM +
|
||||
Administrators only (no secrets are recorded; Users, a restricting SID of the worker token, can no longer read the list).
|
||||
Two argument-free modes use the record:
|
||||
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `Install-ScriptWorkerIsolation.ps1 -Reapply` | Re-applies with the recorded settings; one `[changed]`/`[unchanged]` line per step and a `summary:` line. After a swap: the three folder ACEs `changed`, the rest `unchanged`; a second run all `unchanged`. |
|
||||
| `Install-ScriptWorkerIsolation.ps1 -Verify` | Check only. `[ok]`/`[MISSING]` per step (anchor service, record + key ACL, PE cache ACL, `pe\`, `script-worker\`, install-folder deny, every `-DenyPaths` deny, firewall rule and the worker exe it names, the host env entries when recorded). **Exit 1** with `VERIFY FAILED: …` when anything is missing, exit 0 with `VERIFY OK`. Protected-DACL coverage gaps are WARNed, not failed. |
|
||||
|
||||
Both refuse to run without a full record. A node isolated by an installer older than #901 recorded only `DenyPaths`:
|
||||
run the install once with its full arguments (the per-box deploy record lists them) to write the record.
|
||||
|
||||
`scripts/install/Invoke-OtOpcUaInstallSwap.ps1` is the swap with the gate built in: preflight (candidate carries
|
||||
`script-worker\`, `PreviousInstall` is fresh and on the same volume, isolation record present when isolation is
|
||||
installed, record `InstallRoot` equals `-InstallRoot`) → stop → move live → previous, candidate → live → copy `-Carry`
|
||||
items back (default `appsettings*.json`, `pki`, `logs`; QA: `'appsettings*.json','logs'`; rollback: `-NoCarry`) →
|
||||
`-Reapply` → `-Verify` → start (`-NoStart` for a pair window). A failed `-Verify` leaves the service **stopped**. Backups,
|
||||
`data\` restore, migrations and health checks stay in the deploy's own steps. Every deploy pack that swaps the folder runs
|
||||
it (or `-Reapply` + `-Verify` right after its own swap), and `-Verify` is a post-deploy gate.
|
||||
`Test-ScriptWorkerIsolation.Windows.ps1` exercises the whole sequence (swap → `-Verify` fails → `-Reapply` → `-Verify`
|
||||
passes → `-Reapply` unchanged → the swap script) on a scratch tree.
|
||||
|
||||
## Install / Uninstall
|
||||
|
||||
- `scripts/install/Install-Services.ps1 -Roles admin,driver` — installs `OtOpcUaHost`.
|
||||
- `scripts/install/Install-ScriptWorkerIsolation.ps1` — the ScriptWorker identity, ACLs and firewall rule (above); only for `Scripting:Runner` ≠ `InProcess`.
|
||||
- `scripts/install/Invoke-OtOpcUaInstallSwap.ps1` — upgrade/rollback folder swap with the isolation `-Reapply` + `-Verify` gate built in (lmxopcua#901, above).
|
||||
- `scripts/install/Uninstall-Services.ps1` — stops + removes the host service. (The historian backend is the external HistorianGateway — not installed/removed by these scripts.)
|
||||
|
||||
## Logging
|
||||
|
||||
@@ -56,6 +56,10 @@ Staged in `E:\ApiInstall\_stage-otopcua-6c4d2990\` (`prepare.ps1`, `swap.ps1`, `
|
||||
(`E:\ApiInstall\OtOpcUa`) and `SensitiveDirDeny` (`…\OtOpcUa\pki`) applied as **changed**; the immediate re-apply
|
||||
reported **every step unchanged**. The runner stays `InProcess`; no restart needed. The same 6 pre-existing
|
||||
"protected DACL" warnings under `E:\psrem-stage` as before.
|
||||
**Follow-up: lmxopcua#901.** The re-apply was a manual step with recorded arguments. From #901 the installer records its
|
||||
settings and has `-Reapply` / `-Verify`, and `scripts/install/Invoke-OtOpcUaInstallSwap.ps1` runs both after the swap
|
||||
(service left stopped if `-Verify` fails). Next vd03 deploy: run the full-argument install once to write the record, then
|
||||
swap with the script and gate on `-Verify` ([`vd03-upgrade-b8b4c18f-to-master.md`](vd03-upgrade-b8b4c18f-to-master.md) §e/§f).
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -81,7 +85,8 @@ Details: [evidence, second run](../plans/evidence/2026-11-13-historian-700-vd03/
|
||||
|
||||
`E:\ApiInstall\_stage-otopcua-6c4d2990\rollback.ps1`: stop; move the live folder to `E:\ApiInstall\OtOpcUa.failed-6c4d2990`;
|
||||
restore `E:\ApiInstall\OtOpcUa.bak.20261113-pre-6c4d2990` (`fea7de0a`); restore `OtOpcUa-data` from the pre-deploy backup;
|
||||
start; re-apply the worker isolation. No schema undo (no migration in range). The ConfigDb `.bak` is there if the
|
||||
start; re-apply the worker isolation. (From #901 the re-apply comes before the start, as `-Reapply` then `-Verify`:
|
||||
`Invoke-OtOpcUaInstallSwap.ps1 -Candidate <bak> -PreviousInstall <failed> -NoCarry` does it.) No schema undo (no migration in range). The ConfigDb `.bak` is there if the
|
||||
configuration itself must go back.
|
||||
|
||||
## Observed (not deploy blockers)
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
> ScriptWorker isolation installed. Tracking: #801 (SDK 2 live gate), #793 (ConfigDb preflight), #794 (worker
|
||||
> isolation install), #780 (P3 runner flip). Template: [`vd03-upgrade-b8b4c18f-to-master.md`](vd03-upgrade-b8b4c18f-to-master.md).
|
||||
>
|
||||
> **lmxopcua#901 (2026-11-13):** a folder swap drops the worker-isolation folder ACEs. The installer now records its
|
||||
> settings and has `-Reapply` / `-Verify`; `scripts/install/Invoke-OtOpcUaInstallSwap.ps1` runs both after the swap. QA's
|
||||
> ACLs were checked intact over SMB; see "Worker isolation across the next swap" at the end for the next QA deploy.
|
||||
>
|
||||
> **DEV vd03 moved on 2026-11-13:** `fea7de0a` → `6c4d2990` (#899 read-only historian bindings; #700 checks). Record:
|
||||
> [`2026-11-13-vd03-fea7de0a-to-6c4d2990.md`](2026-11-13-vd03-fea7de0a-to-6c4d2990.md). QA is unchanged.
|
||||
|
||||
@@ -207,6 +211,8 @@ Rollback stays `… -ConfigureHostService -Uninstall`. It now also removes the d
|
||||
3. imports `backup\OtOpcUaHost-pre-fea7de0a.reg`, which restores the pre-deploy environment, without the Scripting entries.
|
||||
|
||||
The isolation ACEs, firewall rule and anchor service are harmless to `b8b4c18f`. No schema undo is needed.
|
||||
**The rollback swaps `app\` back, which drops the folder ACEs (lmxopcua#901):** re-apply with `-Reapply` and gate with
|
||||
`-Verify` before starting (see "Worker isolation across the next swap" below).
|
||||
`OtOpcUaConfig_pre-fea7de0a_20261112.bak` is the DB fallback.
|
||||
- **DEV:** `E:\ApiInstall\_stage-otopcua-fea7de0a\rollback.ps1` (→ `5fb4b4db`).
|
||||
- SDK 1.5 tooling caveat after a rollback: see the rollout runbook §Rollback item 4 (#880).
|
||||
@@ -351,4 +357,52 @@ None of these grants anything to `Everyone`, `BUILTIN\Users` or `RESTRICTED`. A
|
||||
|
||||
**Deploy procedure from now on.** Write registry exports and appsettings backups into a backup folder that you create with a protected ACL (SYSTEM + Administrators only), and run the installer with the box-wide `-DenyPaths` list above. Any new app folder under `E:\ApiInstall` must be added to the list when it is installed.
|
||||
|
||||
## Worker isolation across the next swap (lmxopcua#901)
|
||||
|
||||
**Was the 2026-11-12 QA swap before or after the isolation?** Before. The window swapped `app\` at 16:48:13–16:48:20 and
|
||||
installed the #794 isolation on the new `app\` afterwards (QA window step 3, services stopped); the #890 and #891 re-applies
|
||||
ran later the same evening, with no further swap. So nothing on QA was lost to a swap. **Checked 2026-11-13 from the Mac,
|
||||
read-only over SMB** (a DACL query per folder, no remote exec), both vq03 and vq04 identical:
|
||||
|
||||
| Folder | Worker identity ACEs |
|
||||
|---|---|
|
||||
| `E:\ApiInstall\OtOpcUa` | explicit Deny `0xD0157` (OI)(CI) |
|
||||
| `…\OtOpcUa\app` | explicit Deny `0xD0157` + the inherited one |
|
||||
| `…\app\script-worker` | explicit Allow `0x1200A9` (RX) (OI)(CI) ahead of the inherited deny |
|
||||
| `…\OtOpcUa\pki`, `…\OtOpcUa\data` | explicit Deny `0xD0157` + inherited |
|
||||
| `C:\ProgramData\ZB\kek` (protected) | explicit Deny `0xD0157` |
|
||||
| `C:\ProgramData\OtOpcUa\script-pe-cache\pe` | explicit Allow `0x1301BF` (Modify) |
|
||||
| `E:\ApiInstall\ScadaBridge`, `E:\psrem-stage` (#891 box-wide) | explicit Deny `0xD0157` |
|
||||
|
||||
Controls: `C:\Windows` and the PE-cache root show no worker ACE (as designed). The anchor service, the firewall rule and
|
||||
the host env entries are not visible over SMB; `-Verify` checks them at the next deploy.
|
||||
|
||||
**Next QA deploy (both nodes, stop-both / start-both):**
|
||||
|
||||
1. **One time, before the first swap with the #901 scripts:** QA was isolated by the older installer, which recorded only
|
||||
`DenyPaths`. Run the full-argument install once from the staged `scripts\install` to write the settings record (and
|
||||
lock its key to SYSTEM + Administrators). The `-DenyPaths` list is the #891 box-wide one:
|
||||
```powershell
|
||||
$deny = 'E:\ApiInstall\OtOpcUa','E:\ApiInstall\OtOpcUa\pki','E:\ApiInstall\OtOpcUa\data','C:\ProgramData\ZB\kek',
|
||||
'E:\ApiInstall\ScadaBridge','E:\ApiInstall\HistorianGateway','E:\ApiInstall\MxAccessGateway','E:\ApiInstall\Overview',
|
||||
'E:\ApiInstall\btdbmanager','E:\psrem-stage','E:\psrem'
|
||||
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -HostServiceAccount LocalSystem -ConfigureHostService -DenyPaths $deny
|
||||
.\Install-ScriptWorkerIsolation.ps1 -Verify # VERIFY OK
|
||||
```
|
||||
Check the list against `reg query HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaScriptWorker\Parameters /v DenyPaths`
|
||||
first and use exactly what is recorded. Expect `SettingsRecord` changed, every other step unchanged.
|
||||
2. **Swap each node with the gate built in**, services stopped, then start both:
|
||||
```powershell
|
||||
.\Invoke-OtOpcUaInstallSwap.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -Candidate E:\ApiInstall\OtOpcUa\app.new-<sha> `
|
||||
-PreviousInstall E:\ApiInstall\OtOpcUa\app.bak.<yyyymmdd>-pre-<sha> -Carry 'appsettings*.json','logs' -NoStart
|
||||
```
|
||||
It stops the service, swaps, carries the box files, runs `-Reapply` and `-Verify`, and throws (service still stopped) if
|
||||
`-Verify` fails. On QA `pki\` and `data\` sit beside `app\`, so only `app\` and its `script-worker\` lose their explicit
|
||||
ACEs; expect the re-apply to report `WorkerFolderAcl` and `InstallRootDeny` changed. If the swap is done by hand instead,
|
||||
run `-Reapply` then `-Verify` right after it, **before** starting the service.
|
||||
3. **Post-deploy gate:** `Install-ScriptWorkerIsolation.ps1 -Verify` on each node → `VERIFY OK`, exit 0. Record the output.
|
||||
4. **Rollback** swaps the old `app\` back, so it loses the ACEs the same way: `Invoke-OtOpcUaInstallSwap.ps1 -Candidate
|
||||
…\app.bak.<…> -PreviousInstall …\app.failed-<sha> -NoCarry -NoStart`, then restore `data\`/the registry as before, then
|
||||
start both. Without the script: `-Reapply` + `-Verify` after the folder moves.
|
||||
|
||||
**Rollback:** re-run the installer with the previous four-path list to remove the new denies, or use `-Uninstall`. To restore the `.reg` ACLs, run `icacls <file> /reset`.
|
||||
|
||||
@@ -207,7 +207,21 @@ The source is `docs/plans/2026-11-10-opcua-sdk-2-rollout.md` (§Sequence, §Roll
|
||||
- `Move-Item` the live install to a fresh sibling `$PreviousInstall`, then `Move-Item` the candidate into
|
||||
`E:\ApiInstall\OtOpcUa`.
|
||||
- Copy back the box's `appsettings*.json`, `pki\` and `data\`.
|
||||
- **Re-apply and verify the worker isolation before the start (lmxopcua#901).** The swap drops the explicit
|
||||
isolation ACEs on `script-worker\`, `E:\ApiInstall\OtOpcUa` and the copied-back `pki\`. Run
|
||||
`Install-ScriptWorkerIsolation.ps1 -Reapply`, then `-Verify` (exit 1 = do not start; fix and re-run).
|
||||
- `Start-Service` and wait for Running.
|
||||
|
||||
**From #901 on, use `scripts/install/Invoke-OtOpcUaInstallSwap.ps1`** for the stop / move / carry / re-apply / verify /
|
||||
start sequence (it leaves the service stopped when `-Verify` fails):
|
||||
`.\Invoke-OtOpcUaInstallSwap.ps1 -InstallRoot E:\ApiInstall\OtOpcUa -Candidate E:\ApiInstall\OtOpcUa.new-<sha> -PreviousInstall E:\ApiInstall\OtOpcUa.bak.<yyyymmdd>-pre-<sha>`
|
||||
(default `-Carry` = `appsettings*.json`, `pki`, `logs`; vd03's `data\` lives outside the install in `E:\ApiInstall\OtOpcUa-data`).
|
||||
**One-time step at the first deploy with the #901 installer:** vd03 was isolated by the older installer, which recorded only
|
||||
`DenyPaths`, so the swap script refuses until the record is written. Before the swap, run the full-argument install once
|
||||
(§"Worker isolation and box-wide denies" below; it writes the settings record and locks the key):
|
||||
`.\Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa -HostServiceAccount LocalSystem -DenyPaths E:\ApiInstall,E:\ApiInstall\OtOpcUa\pki,E:\ApiInstall\OtOpcUa-data,C:\ProgramData\ZB\kek,E:\psrem-stage`
|
||||
(add `-ConfigureHostService` only if the vd03 runner flip is part of that deploy). Expect `DenyPathRecord` unchanged and
|
||||
`SettingsRecord` changed; then `-Verify` must print `VERIFY OK`.
|
||||
5. **Never run `Install-Services.ps1`, `Uninstall-Services.ps1` or `Refresh-Services.ps1` here.**
|
||||
- `sc delete` wipes the 23 environment variables.
|
||||
- `Install-Services.ps1` rewrites the `Environment` value with only 2–3 entries.
|
||||
@@ -216,7 +230,9 @@ The source is `docs/plans/2026-11-10-opcua-sdk-2-rollout.md` (§Sequence, §Roll
|
||||
6. **Rollback.**
|
||||
- Stop the service and move the candidate to a quarantine path.
|
||||
- Restore `$PreviousInstall` (or the robocopy backup), including its `appsettings*.json`, `pki\` and `data\`.
|
||||
- Check the certificate thumbprint, then start the service.
|
||||
- Check the certificate thumbprint, re-apply and verify the worker isolation (`-Reapply`, `-Verify`; or run the
|
||||
rollback as `Invoke-OtOpcUaInstallSwap.ps1 -Candidate <previous install> -PreviousInstall <quarantine path> -NoCarry`),
|
||||
then start the service.
|
||||
- Clients reconnect. Do not replay writes or acks.
|
||||
|
||||
No schema undo is needed (§a). Remove the pepper only if it got in the way; `b8b4c18f` ignores it. The
|
||||
@@ -228,6 +244,9 @@ The source is `docs/plans/2026-11-10-opcua-sdk-2-rollout.md` (§Sequence, §Roll
|
||||
|
||||
## (f) Post-deploy checks
|
||||
|
||||
- **Worker isolation gate (lmxopcua#901), first.** `Install-ScriptWorkerIsolation.ps1 -Verify` from the staged
|
||||
`scripts\install` (elevated) must print `VERIFY OK` and exit 0. Exit 1 lists each `[MISSING]` step: run `-Reapply`,
|
||||
then `-Verify` again. Do this even when the runner is `InProcess` (vd03 today): the denies also cover the box-wide folders.
|
||||
- **Boot log** `logs\otopcua-YYYYMMDD.log`. Read its contents; the listed size shows 0 while the service holds
|
||||
it open.
|
||||
- Expect `OtOpcUa.Host starting with roles=[admin,driver]`, with no roles-disagreement warning.
|
||||
@@ -407,3 +426,8 @@ None of them grants anything to Everyone, Users or RESTRICTED.
|
||||
**After:** `/health/ready` returns 200 for OtOpcUa `:9000`, Overview `:5320`, ScadaBridge `:8085`, mxgw `:5130` and HG `:5220`.
|
||||
|
||||
**Rollback:** `-Uninstall`, which removes the recorded denies, the anchor service, the firewall rule and the PE cache ACLs. To undo the lockdown on a backup file, run `icacls <file> /reset`.
|
||||
|
||||
**Every later swap (lmxopcua#901).** The 2026-11-13 upgrade (`6c4d2990`) showed that the folder swap drops `WorkerFolderAcl`,
|
||||
`InstallRootDeny` and the `pki\` `SensitiveDirDeny`; re-running the line above restored them. From the #901 installer on, the
|
||||
settings are recorded, so a swap is followed by `-Reapply` + `-Verify` with no arguments, or the whole swap runs through
|
||||
`Invoke-OtOpcUaInstallSwap.ps1` (§e step 4). The first #901 run on vd03 must be the full-argument line above (one time).
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
(HKLM\...\Services\<WorkerServiceName>\Parameters, value DenyPaths). A re-run removes the
|
||||
deny from folders dropped from the list; -Uninstall removes it from every recorded folder
|
||||
without the operator repeating -DenyPaths.
|
||||
SettingsRecord (lmxopcua#901) the other effective arguments (InstallRoot, HostServiceAccount, PeCacheDir,
|
||||
HostServiceName, ConfigureHostService) under the same key, whose DACL is set protected,
|
||||
SYSTEM + Administrators only. No secrets. -Reapply and -Verify read them back.
|
||||
6. FirewallBlockOutbound a Windows Firewall rule blocking all outbound traffic from
|
||||
<InstallRoot>\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe (every profile). The
|
||||
self-contained Windows publish starts the worker through that apphost; a framework-dependent
|
||||
@@ -40,8 +43,15 @@
|
||||
in the OtOpcUaHost service environment. Scripting:Runner itself is NOT set here: the
|
||||
runner flip is a separate, per-tier decision (design §12, P2/P3).
|
||||
|
||||
Re-run it after any deploy that REPLACES the install folder (a rename/swap drops the folder ACEs; files copied into
|
||||
an existing folder inherit them). The host must be restarted for -ConfigureHostService to take effect.
|
||||
A deploy that REPLACES the install folder (a rename/swap) drops the explicit ACEs on script-worker\, on the install
|
||||
folder and on any denied folder that is recreated or copied in (pki\ on vd03); files copied into an existing folder
|
||||
inherit them (lmxopcua#901). After every swap run, with no other arguments:
|
||||
|
||||
.\Install-ScriptWorkerIsolation.ps1 -Reapply # the recorded settings; prints changed/unchanged per step
|
||||
.\Install-ScriptWorkerIsolation.ps1 -Verify # check only; exit 1 and a [MISSING] line per gap
|
||||
|
||||
Invoke-OtOpcUaInstallSwap.ps1 does both after its swap and does not start the service when -Verify fails. The host
|
||||
must be restarted for -ConfigureHostService to take effect.
|
||||
|
||||
After an apply it walks every denied folder and WARNs about any file or folder whose DACL is protected (does not
|
||||
inherit) and has no deny of its own; the inherited deny does not reach those. Without -DenyPaths it WARNs when the
|
||||
@@ -74,6 +84,16 @@
|
||||
Roll everything back: the firewall rule, every explicit ACE for the worker identity, the anchor service, and (with
|
||||
-ConfigureHostService) the env entries. The PE cache is kept unless -RemovePeCache.
|
||||
|
||||
.PARAMETER Reapply
|
||||
lmxopcua#901. Re-apply with the settings the last install recorded (no other argument except -WorkerServiceName,
|
||||
-WhatIf). Fails when nothing is recorded, or when the box was installed by an installer older than #901 (that one
|
||||
recorded only DenyPaths): run the full-argument install once, which writes the record.
|
||||
|
||||
.PARAMETER Verify
|
||||
lmxopcua#901. Check only: compares every install step with the machine, prints [ok] or [MISSING] per step and exits 1
|
||||
when anything is missing (0 when all is in place). Uses the recorded settings, or -InstallRoot/-HostServiceAccount/...
|
||||
when given. Protected-DACL coverage gaps are WARNed, as after an apply, and do not fail the check.
|
||||
|
||||
.PARAMETER PlanOnly
|
||||
Print the plan and return it as objects without touching (or even reading) the machine. Runs on any OS; used by the
|
||||
dry-run harness scripts/install/tests/Test-ScriptWorkerIsolation.ps1. -HostServiceAccount must then be a SID.
|
||||
@@ -87,6 +107,11 @@
|
||||
.EXAMPLE
|
||||
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -ConfigureHostService -Uninstall
|
||||
|
||||
.EXAMPLE
|
||||
# After an upgrade swapped the install folder (lmxopcua#901): re-apply from the record, then gate on the check.
|
||||
.\Install-ScriptWorkerIsolation.ps1 -Reapply
|
||||
.\Install-ScriptWorkerIsolation.ps1 -Verify; if ($LASTEXITCODE) { throw 'worker isolation incomplete' }
|
||||
|
||||
.EXAMPLE
|
||||
# The wonder layout: binaries in app\, keys in pki\, secrets.db + LocalDb in data\ (lmxopcua#890).
|
||||
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -HostServiceAccount LocalSystem -ConfigureHostService `
|
||||
@@ -94,8 +119,9 @@
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$InstallRoot,
|
||||
[Parameter(Mandatory)] [string]$HostServiceAccount,
|
||||
# Required unless -Reapply / -Verify / -Uninstall take them from the record (lmxopcua#901).
|
||||
[string]$InstallRoot,
|
||||
[string]$HostServiceAccount,
|
||||
[string]$PeCacheDir,
|
||||
[ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$WorkerServiceName = 'OtOpcUaScriptWorker',
|
||||
[string]$HostServiceName = 'OtOpcUaHost',
|
||||
@@ -103,6 +129,8 @@ param(
|
||||
[switch]$ConfigureHostService,
|
||||
[switch]$Uninstall,
|
||||
[switch]$RemovePeCache,
|
||||
[switch]$Reapply,
|
||||
[switch]$Verify,
|
||||
[switch]$PlanOnly
|
||||
)
|
||||
|
||||
@@ -110,6 +138,34 @@ Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Import-Module (Join-Path $PSScriptRoot 'ScriptWorkerIsolation.psm1') -Force
|
||||
|
||||
# lmxopcua#901: -Reapply / -Verify / -Uninstall without arguments run from what the last install recorded.
|
||||
if (@($Reapply, $Verify, $Uninstall, $PlanOnly | Where-Object { $_ }).Count -gt 1) { throw '-Reapply, -Verify, -Uninstall and -PlanOnly are exclusive' }
|
||||
if ($Reapply) {
|
||||
$given = @('InstallRoot', 'HostServiceAccount', 'PeCacheDir', 'HostServiceName', 'DenyPaths', 'ConfigureHostService', 'RemovePeCache' |
|
||||
Where-Object { $PSBoundParameters.ContainsKey($_) })
|
||||
if ($given.Count) { throw "-Reapply takes its settings from the record; do not pass -$($given -join ', -') (run a normal install to change them)" }
|
||||
}
|
||||
$fromRecord = $false
|
||||
if (($Reapply -or $Verify -or $Uninstall) -and -not $InstallRoot -and -not $HostServiceAccount) {
|
||||
# The record key is SYSTEM + Administrators only, so a non-elevated read would look like "nothing recorded".
|
||||
$me = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if (-not $me.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'run elevated (the recorded settings are readable by SYSTEM and Administrators only)' }
|
||||
$rec = Get-OtOpcUaIsolationSettings -WorkerServiceName $WorkerServiceName
|
||||
if (-not $rec) {
|
||||
throw ("no recorded ScriptWorker isolation settings under HKLM\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters. " +
|
||||
'Either isolation is not installed, or it was installed by an installer older than lmxopcua#901 (which recorded only DenyPaths): ' +
|
||||
'run the install once with its full arguments (-InstallRoot, -HostServiceAccount, -DenyPaths, [-ConfigureHostService]); that writes the record.')
|
||||
}
|
||||
$InstallRoot = $rec.InstallRoot
|
||||
$HostServiceAccount = $rec.HostServiceAccount
|
||||
if (-not $PSBoundParameters.ContainsKey('PeCacheDir') -and $rec.PeCacheDir) { $PeCacheDir = $rec.PeCacheDir }
|
||||
if (-not $PSBoundParameters.ContainsKey('HostServiceName')) { $HostServiceName = $rec.HostServiceName }
|
||||
if (-not $PSBoundParameters.ContainsKey('DenyPaths')) { $DenyPaths = @($rec.DenyPaths) }
|
||||
if (-not $PSBoundParameters.ContainsKey('ConfigureHostService')) { $ConfigureHostService = [switch]$rec.ConfigureHostService }
|
||||
$fromRecord = $true
|
||||
}
|
||||
if (-not $InstallRoot -or -not $HostServiceAccount) { throw '-InstallRoot and -HostServiceAccount are required (or -Reapply / -Verify / -Uninstall with a recorded install)' }
|
||||
|
||||
if (-not $PeCacheDir) {
|
||||
$programData = if ($env:ProgramData) { $env:ProgramData } else { 'C:\ProgramData' }
|
||||
$PeCacheDir = "$($programData.TrimEnd('\'))\OtOpcUa\script-pe-cache"
|
||||
@@ -128,14 +184,19 @@ $hostSid = Resolve-HostSid $HostServiceAccount
|
||||
# lmxopcua#890: what an earlier run denied, so a re-run drops stale denies and -Uninstall removes all of them. -PlanOnly
|
||||
# reads nothing from the machine, so there it is empty.
|
||||
$recorded = if ($PlanOnly) { @() } else { @(Get-OtOpcUaRecordedDenyPaths -WorkerServiceName $WorkerServiceName) }
|
||||
$plan = Get-ScriptWorkerIsolationPlan -InstallRoot $InstallRoot -HostSid $hostSid -PeCacheDir $PeCacheDir `
|
||||
$plan = Get-ScriptWorkerIsolationPlan -InstallRoot $InstallRoot -HostSid $hostSid -PeCacheDir $PeCacheDir -HostServiceAccount $HostServiceAccount `
|
||||
-WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -DenyPaths $DenyPaths -RecordedDenyPaths $recorded `
|
||||
-ConfigureHostService:$ConfigureHostService -Uninstall:$Uninstall -RemovePeCache:$RemovePeCache
|
||||
$denied = @($plan | Where-Object { $_.Id -eq 'DenyPathRecord' -and $_.Data.ContainsKey('Paths') } | ForEach-Object { $_.Data.Paths } | Where-Object { $_ })
|
||||
|
||||
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
|
||||
Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4})" -f `
|
||||
$(if ($Uninstall) { 'ROLLBACK' } else { 'install' }), $WorkerServiceName, $workerSid, $HostServiceAccount, $hostSid)
|
||||
Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4}){5}" -f `
|
||||
$(if ($Uninstall) { 'ROLLBACK' } elseif ($Verify) { 'VERIFY' } elseif ($Reapply) { 'REAPPLY' } else { 'install' }), $WorkerServiceName,
|
||||
$workerSid, $HostServiceAccount, $hostSid, $(if ($fromRecord) { ', settings from the record' } else { '' }))
|
||||
if ($fromRecord) {
|
||||
Write-Host (" InstallRoot {0}; PeCacheDir {1}; DenyPaths {2}; ConfigureHostService {3}" -f $InstallRoot, $PeCacheDir,
|
||||
$(if (@($DenyPaths).Count) { $DenyPaths -join ', ' } else { 'none' }), [bool]$ConfigureHostService)
|
||||
}
|
||||
|
||||
if ($PlanOnly) {
|
||||
foreach ($step in $plan) { Write-Host (" [plan] {0,-22} {1}" -f $step.Id, $step.Change) }
|
||||
@@ -143,6 +204,27 @@ if ($PlanOnly) {
|
||||
}
|
||||
|
||||
if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { throw 'Install-ScriptWorkerIsolation.ps1 applies changes on Windows only (use -PlanOnly elsewhere)' }
|
||||
|
||||
if ($Verify) {
|
||||
# lmxopcua#901: check only, nothing changes. Exit 1 when any step is not in place.
|
||||
$checks = foreach ($step in $plan) {
|
||||
$r = try { Test-ScriptWorkerIsolationStep -Step $step } catch { [pscustomobject]@{ Ok = $false; Detail = "check failed: $($_.Exception.Message)" } }
|
||||
Write-Host (" [{0,-7}] {1,-22} {2} - {3}" -f $(if ($r.Ok) { 'ok' } else { 'MISSING' }), $step.Id, $step.Target, $r.Detail)
|
||||
[pscustomobject]@{ Id = $step.Id; Target = $step.Target; Ok = [bool]$r.Ok; Detail = $r.Detail }
|
||||
}
|
||||
$workerFolder = Join-Path $InstallRoot.TrimEnd('\') 'script-worker'
|
||||
$gaps = @(foreach ($p in @($InstallRoot.TrimEnd('\')) + $denied) { Find-OtOpcUaDenyCoverageGap -Path $p -WorkerSid $workerSid -Skip @($workerFolder, $PeCacheDir.TrimEnd('\')) })
|
||||
foreach ($g in @($gaps | Sort-Object -Unique)) { Write-Warning "$g does not inherit the worker deny (protected DACL, no deny of its own); check it grants nothing to Everyone, BUILTIN\Users or RESTRICTED." }
|
||||
$missing = @($checks | Where-Object { -not $_.Ok })
|
||||
Write-Host ''
|
||||
if ($missing.Count) {
|
||||
Write-Host ("VERIFY FAILED: {0} of {1} step(s) not in place: {2}. Run -Reapply (as administrator) and verify again." -f `
|
||||
$missing.Count, @($checks).Count, (($missing | ForEach-Object { "$($_.Id) $($_.Target)" }) -join '; '))
|
||||
exit 1
|
||||
}
|
||||
Write-Host ("VERIFY OK: {0}/{0} step(s) in place." -f @($checks).Count)
|
||||
exit 0
|
||||
}
|
||||
if (-not $Uninstall -and -not (Test-Path -LiteralPath (Join-Path $InstallRoot 'script-worker'))) {
|
||||
throw "$InstallRoot\script-worker not found - publish the Host (it carries script-worker\) before isolating it"
|
||||
}
|
||||
@@ -166,12 +248,16 @@ if (-not $WhatIfPreference -and -not $principal.IsInRole([Security.Principal.Win
|
||||
throw 'run elevated (services, firewall rules and folder ACLs need an administrator)'
|
||||
}
|
||||
|
||||
$results = foreach ($step in $plan) {
|
||||
$results = @(foreach ($step in $plan) {
|
||||
if ($PSCmdlet.ShouldProcess($step.Target, "$($step.Id): $($step.Change)")) {
|
||||
$outcome = Invoke-ScriptWorkerIsolationStep -Step $step
|
||||
Write-Host (" [{0,-9}] {1,-22} {2}" -f $outcome, $step.Id, $step.Target)
|
||||
[pscustomobject]@{ Id = $step.Id; Target = $step.Target; Outcome = $outcome }
|
||||
}
|
||||
})
|
||||
if (-not $WhatIfPreference) {
|
||||
$nChanged = @($results | Where-Object Outcome -eq 'changed').Count
|
||||
Write-Host (" summary: {0} step(s), {1} changed, {2} unchanged" -f $results.Count, $nChanged, ($results.Count - $nChanged))
|
||||
}
|
||||
|
||||
if (-not $WhatIfPreference -and -not $Uninstall) {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Swaps an OtOpcUa install folder for a candidate build and restores the ScriptWorker isolation before the host starts
|
||||
again (lmxopcua#901). Also the rollback: swap the previous install back in the same way.
|
||||
|
||||
.DESCRIPTION
|
||||
A folder swap drops the explicit worker-isolation ACEs on script-worker\, on the install folder and on any denied
|
||||
folder that is recreated or copied in (pki\ on vd03), because a moved-in folder carries its own DACL. This script makes
|
||||
the re-apply part of the swap, in this order:
|
||||
|
||||
0. Preflight (nothing changed yet): the candidate exists and carries script-worker\; PreviousInstall does not exist
|
||||
and is on the same volume; when isolation is installed, its settings are recorded (an install by an installer
|
||||
older than #901 recorded only DenyPaths; then run the full-argument install once first, or pass -SkipIsolation).
|
||||
1. Stop the service (unless -NoServiceControl).
|
||||
2. Move InstallRoot -> PreviousInstall, then Candidate -> InstallRoot.
|
||||
3. Copy each -Carry item (relative to InstallRoot; wildcards allowed) from PreviousInstall into the new InstallRoot.
|
||||
Folders are copied with robocopy /E /COPY:DAT (data, attributes, timestamps; not ACLs: step 4 sets those).
|
||||
4. Isolation, when recorded: Install-ScriptWorkerIsolation.ps1 -Reapply, then -Verify. If -Verify fails, the script
|
||||
stops here with the service STOPPED (fail closed): fix it, re-run -Reapply/-Verify, then start the service.
|
||||
5. Start the service (unless -NoStart, e.g. a Primary/Secondary pair window where both nodes start together).
|
||||
|
||||
Not covered (layout-specific; keep them in the deploy's own scripts): backups of data\ / the registry / the ConfigDb,
|
||||
restoring data\ on a rollback, migrations, health checks.
|
||||
|
||||
.PARAMETER InstallRoot
|
||||
The live install folder (OtOpcUa.Host.exe + script-worker\). vd03: E:\ApiInstall\OtOpcUa. QA: E:\ApiInstall\OtOpcUa\app.
|
||||
|
||||
.PARAMETER Candidate
|
||||
The expanded new build (for a rollback: the previous install).
|
||||
|
||||
.PARAMETER PreviousInstall
|
||||
Where the live folder is moved to. Must not exist. E.g. E:\ApiInstall\OtOpcUa.bak.20261113-pre-<sha>; for a rollback
|
||||
E:\ApiInstall\OtOpcUa.failed-<sha>.
|
||||
|
||||
.PARAMETER Carry
|
||||
Items copied from the previous install into the new one, relative to InstallRoot. Default: appsettings*.json, pki, logs
|
||||
(the vd03 layout, where pki\ and logs\ live inside the install folder). QA keeps pki\ and data\ beside app\: pass
|
||||
-Carry 'appsettings*.json','logs'. A rollback swaps back a complete folder: pass -NoCarry.
|
||||
|
||||
.PARAMETER NoCarry
|
||||
Copy nothing from the previous install (a rollback).
|
||||
|
||||
.PARAMETER ServiceName
|
||||
The host service (default OtOpcUaHost).
|
||||
|
||||
.PARAMETER NoServiceControl
|
||||
Neither stop nor start the service (the caller owns it; used by the scratch test).
|
||||
|
||||
.PARAMETER NoStart
|
||||
Leave the service stopped after a successful swap and verify.
|
||||
|
||||
.PARAMETER SkipIsolation
|
||||
Do not re-apply/verify the isolation even when it is installed. WARNs. Only for a box where it is being removed.
|
||||
|
||||
.PARAMETER WorkerServiceName
|
||||
Passed to the isolation installer (default OtOpcUaScriptWorker).
|
||||
|
||||
.EXAMPLE
|
||||
# vd03 (pki\ and logs\ inside the install folder)
|
||||
.\Invoke-OtOpcUaInstallSwap.ps1 -InstallRoot E:\ApiInstall\OtOpcUa -Candidate E:\ApiInstall\OtOpcUa.new-6c4d2990 `
|
||||
-PreviousInstall E:\ApiInstall\OtOpcUa.bak.20261113-pre-6c4d2990
|
||||
|
||||
.EXAMPLE
|
||||
# QA, one node of the stop-both/start-both window (start both afterwards)
|
||||
.\Invoke-OtOpcUaInstallSwap.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -Candidate E:\ApiInstall\OtOpcUa\app.new-<sha> `
|
||||
-PreviousInstall E:\ApiInstall\OtOpcUa\app.bak.<date>-pre-<sha> -Carry 'appsettings*.json','logs' -NoStart
|
||||
|
||||
.EXAMPLE
|
||||
# Rollback on vd03: the failed build out, the previous one back (restore data\ separately)
|
||||
.\Invoke-OtOpcUaInstallSwap.ps1 -InstallRoot E:\ApiInstall\OtOpcUa -Candidate E:\ApiInstall\OtOpcUa.bak.20261113-pre-6c4d2990 `
|
||||
-PreviousInstall E:\ApiInstall\OtOpcUa.failed-6c4d2990 -NoCarry
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$InstallRoot,
|
||||
[Parameter(Mandatory)] [string]$Candidate,
|
||||
[Parameter(Mandatory)] [string]$PreviousInstall,
|
||||
[string[]]$Carry = @('appsettings*.json', 'pki', 'logs'),
|
||||
[switch]$NoCarry,
|
||||
[string]$ServiceName = 'OtOpcUaHost',
|
||||
[switch]$NoServiceControl,
|
||||
[switch]$NoStart,
|
||||
[switch]$SkipIsolation,
|
||||
[ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$WorkerServiceName = 'OtOpcUaScriptWorker',
|
||||
[int]$StopTimeoutSeconds = 120
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Import-Module (Join-Path $PSScriptRoot 'ScriptWorkerIsolation.psm1') -Force
|
||||
$installer = Join-Path $PSScriptRoot 'Install-ScriptWorkerIsolation.ps1'
|
||||
|
||||
function Step([string]$Message) { Write-Host "==> $Message" }
|
||||
function Invoke-Isolation([string]$Mode) {
|
||||
# Same PowerShell host as this script; the installer's exit code is the gate.
|
||||
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
|
||||
& $exe -NoProfile -ExecutionPolicy Bypass -File $installer "-$Mode" -WorkerServiceName $WorkerServiceName | ForEach-Object { Write-Host " $_" }
|
||||
$LASTEXITCODE
|
||||
}
|
||||
|
||||
$InstallRoot = $InstallRoot.TrimEnd('\')
|
||||
$Candidate = $Candidate.TrimEnd('\')
|
||||
$PreviousInstall = $PreviousInstall.TrimEnd('\')
|
||||
if ($NoCarry) { $Carry = @() }
|
||||
$Carry = @($Carry | ForEach-Object { $_ -split '[,;]' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
|
||||
# ---- 0. Preflight: nothing changes until all of it passes -----------------------------------------------------------
|
||||
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'run elevated' }
|
||||
if (-not (Test-Path -LiteralPath $InstallRoot -PathType Container)) { throw "InstallRoot $InstallRoot not found" }
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $Candidate 'script-worker') -PathType Container)) { throw "candidate $Candidate has no script-worker\ (not a Host publish?)" }
|
||||
if (Test-Path -LiteralPath $PreviousInstall) { throw "PreviousInstall $PreviousInstall already exists; pick a fresh name" }
|
||||
if ([IO.Path]::GetPathRoot($InstallRoot) -ne [IO.Path]::GetPathRoot($PreviousInstall) -or [IO.Path]::GetPathRoot($InstallRoot) -ne [IO.Path]::GetPathRoot($Candidate)) {
|
||||
throw 'InstallRoot, Candidate and PreviousInstall must be on the same volume (Move-Item renames)'
|
||||
}
|
||||
$isolation = $null
|
||||
if (-not $SkipIsolation) {
|
||||
$isolation = Get-OtOpcUaIsolationSettings -WorkerServiceName $WorkerServiceName
|
||||
$legacy = @(Get-OtOpcUaRecordedDenyPaths -WorkerServiceName $WorkerServiceName)
|
||||
$anchor = Get-Service -Name $WorkerServiceName -ErrorAction SilentlyContinue
|
||||
if (-not $isolation -and ($anchor -or $legacy.Count)) {
|
||||
throw ("ScriptWorker isolation is installed but its settings are not recorded (installed by an installer older than lmxopcua#901). " +
|
||||
'Before swapping, run Install-ScriptWorkerIsolation.ps1 once with its full arguments (the deploy pack lists them); that writes the record. ' +
|
||||
'Then re-run this swap.')
|
||||
}
|
||||
if ($isolation -and $isolation.InstallRoot.TrimEnd('\') -ine $InstallRoot) {
|
||||
throw "the isolation record is for InstallRoot '$($isolation.InstallRoot)', not '$InstallRoot'; fix -InstallRoot or re-run the isolation install"
|
||||
}
|
||||
if ($isolation) { Write-Host "Isolation installed (record: InstallRoot $($isolation.InstallRoot)); it is re-applied and verified after the swap." }
|
||||
else { Write-Host 'Isolation not installed on this node; nothing to re-apply.' }
|
||||
}
|
||||
else { Write-Warning '-SkipIsolation: the worker-isolation ACLs are NOT re-applied; a swapped folder has lost them (lmxopcua#901).' }
|
||||
|
||||
if (-not $PSCmdlet.ShouldProcess($InstallRoot, "swap in $Candidate (previous install -> $PreviousInstall)")) { return }
|
||||
|
||||
# ---- 1. Stop ---------------------------------------------------------------------------------------------------------
|
||||
if (-not $NoServiceControl) {
|
||||
Step "stop $ServiceName"
|
||||
$svc = Get-Service -Name $ServiceName
|
||||
if ($svc.Status -ne 'Stopped') {
|
||||
Stop-Service -Name $ServiceName -Force
|
||||
$svc.WaitForStatus('Stopped', [TimeSpan]::FromSeconds($StopTimeoutSeconds))
|
||||
}
|
||||
}
|
||||
|
||||
# ---- 2. Swap ---------------------------------------------------------------------------------------------------------
|
||||
Step "move $InstallRoot -> $PreviousInstall"
|
||||
Move-Item -LiteralPath $InstallRoot -Destination $PreviousInstall
|
||||
Step "move $Candidate -> $InstallRoot"
|
||||
try { Move-Item -LiteralPath $Candidate -Destination $InstallRoot }
|
||||
catch {
|
||||
Write-Warning "candidate move failed; putting the previous install back: $($_.Exception.Message)"
|
||||
Move-Item -LiteralPath $PreviousInstall -Destination $InstallRoot
|
||||
throw
|
||||
}
|
||||
|
||||
# ---- 3. Carry --------------------------------------------------------------------------------------------------------
|
||||
foreach ($item in $Carry) {
|
||||
# Get-Item, not Get-ChildItem: a folder name must give the folder itself, not its children (wildcards still expand).
|
||||
$sources = @(Get-Item -Path (Join-Path $PreviousInstall $item) -Force -ErrorAction SilentlyContinue)
|
||||
if ($sources.Count -eq 0) { Write-Host " (carry: nothing matches $item)"; continue }
|
||||
foreach ($src in $sources) {
|
||||
$dst = Join-Path $InstallRoot $src.Name
|
||||
if ($src.PSIsContainer) {
|
||||
& robocopy $src.FullName $dst /E /COPY:DAT /DCOPY:DAT /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
if ($LASTEXITCODE -ge 8) { throw "robocopy $($src.FullName) -> $dst failed ($LASTEXITCODE)" }
|
||||
}
|
||||
else { Copy-Item -LiteralPath $src.FullName -Destination $dst -Force }
|
||||
Write-Host " carried $($src.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
# ---- 4. Isolation re-apply + verify (fail closed) --------------------------------------------------------------------
|
||||
if ($isolation) {
|
||||
Step 'worker isolation: -Reapply'
|
||||
$code = Invoke-Isolation 'Reapply'
|
||||
if ($code -ne 0) { throw "Install-ScriptWorkerIsolation.ps1 -Reapply failed ($code); $ServiceName left stopped" }
|
||||
Step 'worker isolation: -Verify (post-swap gate)'
|
||||
$code = Invoke-Isolation 'Verify'
|
||||
if ($code -ne 0) { throw "Install-ScriptWorkerIsolation.ps1 -Verify failed ($code); $ServiceName left stopped. Fix the [MISSING] steps, re-run -Reapply/-Verify, then start it." }
|
||||
}
|
||||
|
||||
# ---- 5. Start --------------------------------------------------------------------------------------------------------
|
||||
if (-not $NoServiceControl -and -not $NoStart) {
|
||||
Step "start $ServiceName"
|
||||
Start-Service -Name $ServiceName
|
||||
(Get-Service -Name $ServiceName).WaitForStatus('Running', [TimeSpan]::FromSeconds(60))
|
||||
}
|
||||
Write-Host "Swap complete: $InstallRoot is the candidate; previous install at $PreviousInstall."
|
||||
@@ -33,6 +33,13 @@ $script:DenyMask = 0x0D0157
|
||||
# anchor service's key and goes with it.
|
||||
$script:DenyRecordValue = 'DenyPaths'
|
||||
|
||||
# lmxopcua#901: the effective install arguments, recorded beside DenyPaths so `-Reapply` (after an upgrade swapped the
|
||||
# install folder) and `-Verify` (post-deploy gate) need no arguments. Paths, an account name and two flags; no secrets.
|
||||
# The key's DACL is set protected, SYSTEM + Administrators only (the services hive is otherwise readable by Users, one of
|
||||
# the worker token's restricting SIDs, and the record lists exactly which folders are denied).
|
||||
$script:SettingsValueNames = @('InstallRoot', 'HostServiceAccount', 'PeCacheDir', 'HostServiceName', 'ConfigureHostService')
|
||||
$script:RecordKeySddl = 'D:PAI(A;CI;KA;;;SY)(A;CI;KA;;;BA)'
|
||||
|
||||
$script:FirewallRuleName = 'OtOpcUa-ScriptWorker-BlockOutbound'
|
||||
$script:WorkerExeName = 'ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
|
||||
$script:EnvIdentity = 'Scripting__Worker__Identity'
|
||||
@@ -162,6 +169,8 @@ function Get-ScriptWorkerIsolationPlan {
|
||||
# The -DenyPaths a previous run recorded (read by the entry script from the anchor service's key; empty under
|
||||
# -PlanOnly). Install removes the deny from recorded paths no longer listed; -Uninstall removes it from all.
|
||||
[string[]]$RecordedDenyPaths = @(),
|
||||
# lmxopcua#901: the -HostServiceAccount as the operator gave it (recorded for -Reapply). Defaults to the SID.
|
||||
[string]$HostServiceAccount,
|
||||
[switch]$ConfigureHostService,
|
||||
[switch]$Uninstall,
|
||||
[switch]$RemovePeCache
|
||||
@@ -200,6 +209,13 @@ function Get-ScriptWorkerIsolationPlan {
|
||||
$steps.Add((New-OtOpcUaPlanStep 'DenyPathRecord' $recordKey `
|
||||
"record the denied folders ($(if ($deny.Count) { $deny -join '; ' } else { 'none beyond the install folder' })) so a re-run or -Uninstall removes exactly these" `
|
||||
@{ Key = $recordKey; Name = $script:DenyRecordValue; Paths = $deny }))
|
||||
$settings = [ordered]@{
|
||||
InstallRoot = $root; HostServiceAccount = $(if ($HostServiceAccount) { $HostServiceAccount } else { $HostSid })
|
||||
PeCacheDir = $cache; HostServiceName = $HostServiceName; ConfigureHostService = [int][bool]$ConfigureHostService
|
||||
}
|
||||
$steps.Add((New-OtOpcUaPlanStep 'SettingsRecord' $recordKey `
|
||||
"record the effective settings (InstallRoot, HostServiceAccount, PeCacheDir, HostServiceName, ConfigureHostService) and lock the key to SYSTEM + Administrators, so -Reapply and -Verify need no arguments (lmxopcua#901)" `
|
||||
@{ Key = $recordKey; Settings = $settings; Sddl = $script:RecordKeySddl }))
|
||||
$steps.Add((New-OtOpcUaPlanStep 'PeCacheDir' $cache `
|
||||
"create the PE cache directory and set its ACL (protected; SYSTEM, Administrators, host account full control; no Users; no worker access to the key or markers)" `
|
||||
@{ Path = $cache; Sddl = (Get-OtOpcUaPeCacheSddl -HostSid $HostSid) }))
|
||||
@@ -265,13 +281,9 @@ function Get-ScriptWorkerIsolationPlan {
|
||||
# returns 'changed' or 'unchanged'. Callers gate every call with ShouldProcess, so -WhatIf runs none of them.
|
||||
# ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
function Set-OtOpcUaExplicitAce {
|
||||
# Ensures exactly one explicit ACE for $Sid on $Path with the given type/mask/inheritance (or none when $Remove).
|
||||
function Get-OtOpcUaExplicitAceState {
|
||||
# The explicit ACEs for $Sid on $Path and whether they are exactly the wanted one (or none when $Remove).
|
||||
param([string]$Path, [string]$Sid, [int]$Mask = 0, [string]$Type = 'Allow', [switch]$Remove)
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
if ($Remove) { return 'unchanged' }
|
||||
throw "$Path does not exist"
|
||||
}
|
||||
$acl = Get-Acl -LiteralPath $Path # #800: DirectoryInfo.GetAccessControl does not exist on PowerShell 7 (.NET)
|
||||
$identity = [System.Security.Principal.SecurityIdentifier]::new($Sid)
|
||||
$explicit = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
|
||||
@@ -287,9 +299,22 @@ function Set-OtOpcUaExplicitAce {
|
||||
$wanted.Count -eq 0 -or (
|
||||
[int]$explicit[0].FileSystemRights -eq $Mask -and $explicit[0].AccessControlType -eq $wanted[0].AccessControlType -and
|
||||
$explicit[0].InheritanceFlags -eq $inherit -and $explicit[0].PropagationFlags -eq 'None'))
|
||||
if ($same) { return 'unchanged' }
|
||||
$acl.PurgeAccessRules($identity)
|
||||
foreach ($rule in $wanted) { $acl.AddAccessRule($rule) }
|
||||
$found = @($explicit | ForEach-Object { '{0} 0x{1:x} {2}' -f $_.AccessControlType, [int]$_.FileSystemRights, $_.InheritanceFlags }) -join '; '
|
||||
[pscustomobject]@{ Acl = $acl; Identity = $identity; Wanted = $wanted; Same = $same; Found = $(if ($found) { $found } else { 'none' }) }
|
||||
}
|
||||
|
||||
function Set-OtOpcUaExplicitAce {
|
||||
# Ensures exactly one explicit ACE for $Sid on $Path with the given type/mask/inheritance (or none when $Remove).
|
||||
param([string]$Path, [string]$Sid, [int]$Mask = 0, [string]$Type = 'Allow', [switch]$Remove)
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
if ($Remove) { return 'unchanged' }
|
||||
throw "$Path does not exist"
|
||||
}
|
||||
$state = Get-OtOpcUaExplicitAceState -Path $Path -Sid $Sid -Mask $Mask -Type $Type -Remove:$Remove
|
||||
if ($state.Same) { return 'unchanged' }
|
||||
$acl = $state.Acl
|
||||
$acl.PurgeAccessRules($state.Identity)
|
||||
foreach ($rule in $state.Wanted) { $acl.AddAccessRule($rule) }
|
||||
Set-Acl -LiteralPath $Path -AclObject $acl
|
||||
'changed'
|
||||
}
|
||||
@@ -388,6 +413,27 @@ function Invoke-ScriptWorkerIsolationStep {
|
||||
else { Set-ItemProperty -LiteralPath $d.Key -Name $d.Name -Type MultiString -Value ([string[]]$d.Paths) }
|
||||
return 'changed'
|
||||
}
|
||||
'SettingsRecord' {
|
||||
# Install only: -Uninstall deletes the whole key in its DenyPathRecord step.
|
||||
$changed = $false
|
||||
if (-not (Test-Path -LiteralPath $d.Key)) { New-Item -Path $d.Key -Force | Out-Null; $changed = $true }
|
||||
$current = Get-ItemProperty -LiteralPath $d.Key -ErrorAction SilentlyContinue
|
||||
foreach ($name in @($d.Settings.Keys)) {
|
||||
$want = $d.Settings[$name]
|
||||
$have = if ($current -and $current.PSObject.Properties[$name]) { $current.$name } else { $null }
|
||||
if ($null -ne $have -and "$have" -ceq "$want") { continue }
|
||||
$kind = if ($want -is [int]) { 'DWord' } else { 'String' }
|
||||
Set-ItemProperty -LiteralPath $d.Key -Name $name -Type $kind -Value $want
|
||||
$changed = $true
|
||||
}
|
||||
if (-not (Test-OtOpcUaRecordKeyAcl -Key $d.Key -Sddl $d.Sddl)) {
|
||||
$acl = Get-Acl -Path $d.Key
|
||||
$acl.SetSecurityDescriptorSddlForm($d.Sddl, 'Access')
|
||||
Set-Acl -Path $d.Key -AclObject $acl
|
||||
$changed = $true
|
||||
}
|
||||
return $(if ($changed) { 'changed' } else { 'unchanged' })
|
||||
}
|
||||
'FirewallBlockOutbound' {
|
||||
$rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue
|
||||
if ($d.ContainsKey('Remove')) {
|
||||
@@ -433,6 +479,109 @@ function Get-OtOpcUaRecordedDenyPaths {
|
||||
if ($v) { @($v.$Name | Where-Object { $_ }) }
|
||||
}
|
||||
|
||||
function Test-OtOpcUaRecordKeyAcl {
|
||||
# True when the record key's DACL is exactly $Sddl (protected; SYSTEM + Administrators), compared in canonical form.
|
||||
param([string]$Key, [string]$Sddl = $script:RecordKeySddl)
|
||||
$want = [System.Security.AccessControl.RegistrySecurity]::new()
|
||||
$want.SetSecurityDescriptorSddlForm($Sddl, 'Access')
|
||||
# -Path, not -LiteralPath: Windows PowerShell 5.1's Get-Acl -LiteralPath does not find registry keys (windev, #901).
|
||||
(Get-Acl -Path $Key).GetSecurityDescriptorSddlForm('Access') -ceq $want.GetSecurityDescriptorSddlForm('Access')
|
||||
}
|
||||
|
||||
function Get-OtOpcUaIsolationSettings {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
The settings an earlier install recorded (lmxopcua#901) as a hashtable: InstallRoot, HostServiceAccount, PeCacheDir,
|
||||
HostServiceName, ConfigureHostService ([bool]) and DenyPaths. $null when the key or InstallRoot/HostServiceAccount
|
||||
is missing (nothing installed, or installed by an installer older than #901, which recorded only DenyPaths).
|
||||
#>
|
||||
param([string]$WorkerServiceName = 'OtOpcUaScriptWorker')
|
||||
$key = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters"
|
||||
if (-not (Test-Path -LiteralPath $key)) { return $null }
|
||||
$v = Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue
|
||||
if (-not $v) { return $null }
|
||||
$get = { param($n) if ($v.PSObject.Properties[$n]) { $v.$n } else { $null } }
|
||||
$root = & $get 'InstallRoot'; $account = & $get 'HostServiceAccount'
|
||||
if (-not $root -or -not $account) { return $null }
|
||||
@{
|
||||
InstallRoot = [string]$root
|
||||
HostServiceAccount = [string]$account
|
||||
PeCacheDir = [string](& $get 'PeCacheDir')
|
||||
HostServiceName = $(if (& $get 'HostServiceName') { [string](& $get 'HostServiceName') } else { 'OtOpcUaHost' })
|
||||
ConfigureHostService = [bool][int](& $get 'ConfigureHostService')
|
||||
DenyPaths = @(& $get $script:DenyRecordValue | Where-Object { $_ })
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ScriptWorkerIsolationStep {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Checks one INSTALL plan step against the machine without changing anything (lmxopcua#901, -Verify). Returns
|
||||
[pscustomobject]@{ Ok; Detail }. Windows only.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)] $Step)
|
||||
$d = $Step.Data
|
||||
$result = { param([bool]$Ok, [string]$Detail) [pscustomobject]@{ Ok = $Ok; Detail = $Detail } }
|
||||
switch ($Step.Id) {
|
||||
'AnchorService' {
|
||||
$cfg = Get-CimInstance Win32_Service -Filter "Name='$($d.Name)'" -ErrorAction SilentlyContinue
|
||||
if (-not $cfg) { return (& $result $false "service '$($d.Name)' not found") }
|
||||
$ok = $cfg.StartMode -eq 'Disabled' -and $cfg.StartName -ieq $d.Identity -and $cfg.PathName -eq "`"$($d.BinPath)`""
|
||||
return (& $result $ok "StartMode=$($cfg.StartMode) StartName=$($cfg.StartName) PathName=$($cfg.PathName)")
|
||||
}
|
||||
'DenyPathRecord' {
|
||||
$current = @(Get-OtOpcUaRecordedDenyPaths -Key $d.Key -Name $d.Name)
|
||||
$ok = (@($current) -join "`n") -ceq (@($d.Paths) -join "`n")
|
||||
return (& $result $ok "recorded: $(if ($current.Count) { $current -join '; ' } else { 'none' })")
|
||||
}
|
||||
'SettingsRecord' {
|
||||
if (-not (Test-Path -LiteralPath $d.Key)) { return (& $result $false 'record key missing') }
|
||||
$v = Get-ItemProperty -LiteralPath $d.Key
|
||||
$bad = @(foreach ($n in @($d.Settings.Keys)) {
|
||||
if (-not $v.PSObject.Properties[$n] -or "$($v.$n)" -cne "$($d.Settings[$n])") { $n }
|
||||
})
|
||||
$aclOk = Test-OtOpcUaRecordKeyAcl -Key $d.Key -Sddl $d.Sddl
|
||||
if (-not $aclOk) { $bad += 'key ACL (want SYSTEM + Administrators only, protected)' }
|
||||
return (& $result ($bad.Count -eq 0) $(if ($bad.Count) { "differs: $($bad -join ', ')" } else { 'settings recorded, key locked' }))
|
||||
}
|
||||
'PeCacheDir' {
|
||||
if (-not (Test-Path -LiteralPath $d.Path)) { return (& $result $false 'missing') }
|
||||
$desired = [System.Security.AccessControl.DirectorySecurity]::new()
|
||||
$desired.SetSecurityDescriptorSddlForm($d.Sddl, 'Access, Owner')
|
||||
$current = (Get-Acl -LiteralPath $d.Path).GetSecurityDescriptorSddlForm('Access, Owner')
|
||||
return (& $result ($current -eq $desired.GetSecurityDescriptorSddlForm('Access, Owner')) "SDDL $current")
|
||||
}
|
||||
{ $_ -in 'PeEntriesDir', 'WorkerFolderAcl', 'InstallRootDeny', 'SensitiveDirDeny' } {
|
||||
if (-not (Test-Path -LiteralPath $d.Path)) {
|
||||
if ($d.ContainsKey('Remove')) { return (& $result $true 'absent') }
|
||||
return (& $result $false 'folder missing')
|
||||
}
|
||||
$state = if ($d.ContainsKey('Remove')) { Get-OtOpcUaExplicitAceState -Path $d.Path -Sid $d.Sid -Remove }
|
||||
else { Get-OtOpcUaExplicitAceState -Path $d.Path -Sid $d.Sid -Mask $d.Mask -Type $d.Type }
|
||||
$want = if ($d.ContainsKey('Remove')) { 'none' } else { '{0} 0x{1:x} ContainerInherit, ObjectInherit' -f $d.Type, $d.Mask }
|
||||
return (& $result $state.Same "explicit worker ACE: $($state.Found) (want $want)")
|
||||
}
|
||||
'FirewallBlockOutbound' {
|
||||
$rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue
|
||||
if (-not $rule) { return (& $result $false "rule '$($d.Name)' missing") }
|
||||
$app = ($rule | Get-NetFirewallApplicationFilter).Program
|
||||
$ok = "$($rule.Direction)" -eq 'Outbound' -and "$($rule.Action)" -eq 'Block' -and "$($rule.Enabled)" -eq 'True' -and
|
||||
"$($rule.Profile)" -eq 'Any' -and $app -ieq $d.Program
|
||||
if ($ok -and -not (Test-Path -LiteralPath $d.Program)) { return (& $result $false "rule ok but $($d.Program) is missing") }
|
||||
return (& $result $ok "$($rule.Direction)/$($rule.Action)/$($rule.Profile)/enabled=$($rule.Enabled) program=$app")
|
||||
}
|
||||
'HostServiceEnv' {
|
||||
$key = "HKLM:\SYSTEM\CurrentControlSet\Services\$($d.Service)"
|
||||
if (-not (Test-Path $key)) { return (& $result $false "service '$($d.Service)' not installed") }
|
||||
$current = @((Get-ItemProperty -Path $key -Name Environment -ErrorAction SilentlyContinue).Environment | Where-Object { $_ })
|
||||
$missing = @(foreach ($k in $d.Set.Keys) { if ($current -notcontains "$k=$($d.Set[$k])") { $k } })
|
||||
return (& $result ($missing.Count -eq 0) $(if ($missing.Count) { "missing or different: $($missing -join ', ')" } else { 'both entries present' }))
|
||||
}
|
||||
default { throw "unknown plan step '$($Step.Id)'" }
|
||||
}
|
||||
}
|
||||
|
||||
function Find-OtOpcUaDenyCoverageGap {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
@@ -456,4 +605,5 @@ function Find-OtOpcUaDenyCoverageGap {
|
||||
|
||||
Export-ModuleMember -Function Get-OtOpcUaServiceSid, Get-OtOpcUaPeCacheSddl, Get-OtOpcUaPeEntriesAce,
|
||||
Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Resolve-OtOpcUaDenyPaths, Get-ScriptWorkerIsolationPlan,
|
||||
Invoke-ScriptWorkerIsolationStep, Get-OtOpcUaRecordedDenyPaths, Find-OtOpcUaDenyCoverageGap
|
||||
Invoke-ScriptWorkerIsolationStep, Get-OtOpcUaRecordedDenyPaths, Find-OtOpcUaDenyCoverageGap, Get-OtOpcUaIsolationSettings,
|
||||
Test-ScriptWorkerIsolationStep, Test-OtOpcUaRecordKeyAcl
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883, #890): apply -> re-apply -> verify -> shorter
|
||||
-DenyPaths -> uninstall (from the record, without -DenyPaths) -> uninstall. ACL level only; the restricted-token proof that
|
||||
Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883, #890, #901): apply -> re-apply -> verify -> shorter
|
||||
-DenyPaths -> -Verify -> folder swap -> -Verify fails -> -Reapply -> -Verify passes -> -Reapply unchanged ->
|
||||
Invoke-OtOpcUaInstallSwap.ps1 (re-apply + verify built in; refuses a pre-#901 record) -> uninstall (from the record,
|
||||
without -DenyPaths) -> uninstall. ACL level only; the restricted-token proof that
|
||||
the worker cannot read the denied folders is InstallerLayoutIsolationTests (ScriptWorker.Tests, OTOPCUA_ISOLATION_INSTALLER_E2E=1).
|
||||
The dry-run harness (Test-ScriptWorkerIsolation.ps1) only asserts plan data; it cannot see host-specific failures such as
|
||||
DirectoryInfo.GetAccessControl missing on PowerShell 7, or StrictMode .Count on a scalar on Windows PowerShell 5.1.
|
||||
@@ -12,13 +14,15 @@
|
||||
|
||||
.DESCRIPTION
|
||||
Needs an elevated Windows session. It works in a scratch folder under $env:TEMP with a scratch worker service name and a
|
||||
scratch stand-in host service (a disabled service that is never started), and uses -HostServiceAccount 'NT AUTHORITY\SYSTEM'.
|
||||
scratch stand-in host service (a disabled service that is never started), and uses -HostServiceAccount 'NT AUTHORITY\SYSTEM',
|
||||
or with -LocalHostAccount a scratch local user (random password, never shown) that is deleted at the end.
|
||||
It does NOT touch OtOpcUaHost. The firewall rule name is fixed by the installer, so the test refuses to run (exit 2) when
|
||||
that rule already exists, i.e. on a node that has the real isolation installed. Everything it creates is removed in
|
||||
`finally`, including on failure.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param([string]$WorkerServiceName = 'OtOpcUaIsoTestWorker', [string]$HostServiceName = 'OtOpcUaIsoTestHost')
|
||||
param([string]$WorkerServiceName = 'OtOpcUaIsoTestWorker', [string]$HostServiceName = 'OtOpcUaIsoTestHost', [switch]$LocalHostAccount,
|
||||
[string]$LocalHostAccountName = 'otopcuaIsoTestAcct')
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -34,6 +38,8 @@ if (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue) { Write-H
|
||||
foreach ($n in $WorkerServiceName, $HostServiceName) {
|
||||
if (Get-Service -Name $n -ErrorAction SilentlyContinue) { Write-Host "refusing: service $n already exists"; exit 2 }
|
||||
}
|
||||
if ($LocalHostAccount -and (Get-LocalUser -Name $LocalHostAccountName -ErrorAction SilentlyContinue)) { Write-Host "refusing: local user $LocalHostAccountName already exists"; exit 2 }
|
||||
$script:hostAccount = 'NT AUTHORITY\SYSTEM'
|
||||
|
||||
$script:failures = 0; $script:passes = 0
|
||||
function Assert([bool]$Condition, [string]$Message) {
|
||||
@@ -43,11 +49,27 @@ function Invoke-Installer([string[]]$Extra) {
|
||||
# $Extra: e.g. @('-DenyPaths', 'a,b') or @('-Uninstall'); -File passes an array parameter as one string, which the installer splits.
|
||||
# Same PowerShell host as this test; the installer's output lines are ' [outcome ] StepId target'.
|
||||
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
|
||||
$out = & $exe -NoProfile -ExecutionPolicy Bypass -File $installer -InstallRoot $root -HostServiceAccount 'NT AUTHORITY\SYSTEM' `
|
||||
$out = & $exe -NoProfile -ExecutionPolicy Bypass -File $installer -InstallRoot $root -HostServiceAccount $script:hostAccount `
|
||||
-PeCacheDir $cache -WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -ConfigureHostService @Extra 2>&1 | Out-String
|
||||
$steps = @([regex]::Matches($out, '\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value } })
|
||||
[pscustomobject]@{ Exit = $LASTEXITCODE; Output = $out; Steps = $steps }
|
||||
}
|
||||
function Invoke-Mode([string]$Mode) {
|
||||
# lmxopcua#901: -Reapply / -Verify with NO settings argument, only the scratch worker name (the record selector).
|
||||
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
|
||||
# Continue: an expected failure writes to stderr, which Stop would turn into a terminating error here. Whitespace is
|
||||
# collapsed (and pwsh's '|' margins dropped) because both hosts wrap the error text at the console width.
|
||||
$out = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $installer "-$Mode" -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
||||
$steps = @([regex]::Matches($out, '\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s+(?<t>\S+)') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value; Target = $_.Groups['t'].Value } })
|
||||
$code = $LASTEXITCODE
|
||||
$missing = @([regex]::Matches($out, '\[MISSING\]\s+(?<id>[A-Za-z]+)\s+(?<t>\S+)') | ForEach-Object { "$($_.Groups['id'].Value) $($_.Groups['t'].Value)" })
|
||||
[pscustomobject]@{ Exit = $code; Output = $out; Flat = ($out -replace '[\s|]+', ' '); Steps = $steps; Missing = $missing }
|
||||
}
|
||||
function Show([string]$Label, $R) {
|
||||
# The transcript the issue asks for: one line per run.
|
||||
$what = if ($R.Missing.Count) { "MISSING: $($R.Missing -join ' | ')" } else { ($R.Steps | Group-Object Outcome | ForEach-Object { "$($_.Count) $($_.Name)" }) -join ', ' }
|
||||
Write-Host (" {0,-34} exit {1}; {2}" -f $Label, $R.Exit, $what)
|
||||
}
|
||||
function Get-Explicit([string]$Path, [string]$Sid) {
|
||||
$id = [System.Security.Principal.SecurityIdentifier]::new($Sid)
|
||||
@((Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object { $_.IdentityReference -eq $id })
|
||||
@@ -56,7 +78,8 @@ function Get-Explicit([string]$Path, [string]$Sid) {
|
||||
$scratch = Join-Path ([IO.Path]::GetTempPath()) ("otopcua-iso-test-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
$root = Join-Path $scratch 'install'
|
||||
$cache = Join-Path $scratch 'pe-cache'
|
||||
$deny = @($scratch, (Join-Path $scratch 'pki'), (Join-Path $scratch 'data')) # lmxopcua#890: the deployment folder + keys + secrets
|
||||
# lmxopcua#890: the deployment folder + keys + secrets. pki\ inside the install root, as on vd03 (lmxopcua#901: the swap re-creates it).
|
||||
$deny = @($scratch, (Join-Path $root 'pki'), (Join-Path $scratch 'data'))
|
||||
$recordKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters"
|
||||
$workerExe = Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
|
||||
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
|
||||
@@ -65,10 +88,17 @@ try {
|
||||
New-Item -ItemType Directory -Path (Split-Path $workerExe) -Force | Out-Null
|
||||
Set-Content -LiteralPath $workerExe -Value 'stub'
|
||||
foreach ($d in $deny) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
||||
if ($LocalHostAccount) {
|
||||
$pw = [Security.SecureString]::new()
|
||||
foreach ($ch in ([Convert]::ToBase64String((1..24 | ForEach-Object { [byte](Get-Random -Maximum 256) })) + 'aA1!').ToCharArray()) { $pw.AppendChar($ch) }
|
||||
New-LocalUser -Name $LocalHostAccountName -Password $pw -AccountNeverExpires -Description 'lmxopcua#901 isolation test (scratch)' | Out-Null
|
||||
$script:hostAccount = ".\$LocalHostAccountName"
|
||||
}
|
||||
Set-Content -LiteralPath (Join-Path $root 'appsettings.json') -Value '{}'
|
||||
& sc.exe create $HostServiceName binPath= "$env:SystemRoot\System32\cmd.exe" start= disabled | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "sc.exe create $HostServiceName failed" }
|
||||
Set-ItemProperty -Path $hostKey -Name Environment -Type MultiString -Value ([string[]]@('Keep__Me=1'))
|
||||
Write-Host "Host: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion); scratch $scratch"
|
||||
Write-Host "Host: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion); scratch $scratch; host account $script:hostAccount"
|
||||
|
||||
Write-Host 'Dry run'
|
||||
$dry = Invoke-Installer @('-WhatIf', '-DenyPaths', ($deny -join ','))
|
||||
@@ -79,7 +109,7 @@ try {
|
||||
Write-Host 'Apply'
|
||||
$a = Invoke-Installer @('-DenyPaths', ($deny -join ','))
|
||||
Assert ($a.Exit -eq 0) "apply exits 0 ($($a.Output))"
|
||||
Assert ($a.Steps.Count -eq 11 -and @($a.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "apply: 11 steps, all changed ($(($a.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
||||
Assert ($a.Steps.Count -eq 12 -and @($a.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "apply: 12 steps, all changed ($(($a.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
||||
Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").StartMode -eq 'Disabled') 'anchor service is Disabled'
|
||||
Assert ((& sc.exe showsid $WorkerServiceName | Out-String) -match [regex]::Escape($workerSid)) 'anchor SID matches Get-OtOpcUaServiceSid'
|
||||
Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").PathName -eq "`"$workerExe`"") 'anchor binPath is stored quoted (Windows PowerShell 5.1 used to drop the quotes)'
|
||||
@@ -107,7 +137,7 @@ try {
|
||||
Write-Host 'Re-apply (idempotent)'
|
||||
$b = Invoke-Installer @('-DenyPaths', ($deny -join ','))
|
||||
Assert ($b.Exit -eq 0) "re-apply exits 0 ($($b.Output))"
|
||||
Assert ($b.Steps.Count -eq 11 -and @($b.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "re-apply: 11 steps, all unchanged ($(($b.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
||||
Assert ($b.Steps.Count -eq 12 -and @($b.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "re-apply: 12 steps, all unchanged ($(($b.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
||||
|
||||
Write-Host 'Re-apply with data\ dropped from -DenyPaths: its deny goes, the others stay'
|
||||
$c = Invoke-Installer @('-DenyPaths', (($deny | Select-Object -First 2) -join ','))
|
||||
@@ -118,6 +148,81 @@ try {
|
||||
$c2 = Invoke-Installer @('-DenyPaths', ($deny -join ','))
|
||||
Assert ($c2.Exit -eq 0 -and @($c2.Steps | Where-Object Outcome -eq 'changed').Count -eq 2) 'putting data\ back changes its deny and the record only'
|
||||
|
||||
Write-Host 'Settings record (lmxopcua#901)'
|
||||
$recVals = Get-ItemProperty -Path $recordKey
|
||||
Assert ($recVals.InstallRoot -eq $root -and $recVals.HostServiceAccount -eq $script:hostAccount -and $recVals.PeCacheDir -eq $cache -and
|
||||
$recVals.HostServiceName -eq $HostServiceName -and $recVals.ConfigureHostService -eq 1) 'install root, host account, PE cache, host service and the flag are recorded'
|
||||
Assert (Test-OtOpcUaRecordKeyAcl -Key $recordKey) "record key DACL is protected SYSTEM + Administrators ($((Get-Acl -Path $recordKey).GetSecurityDescriptorSddlForm('Access')))"
|
||||
$settings = Get-OtOpcUaIsolationSettings -WorkerServiceName $WorkerServiceName
|
||||
Assert ($null -ne $settings -and (@($settings.DenyPaths) -join '|') -eq ($deny -join '|') -and $settings.ConfigureHostService) 'Get-OtOpcUaIsolationSettings reads it back'
|
||||
|
||||
Write-Host 'Swap -> Verify fails -> Reapply -> Verify passes -> Reapply unchanged (lmxopcua#901)'
|
||||
$v0 = Invoke-Mode 'Verify'; Show 'Verify (installed)' $v0
|
||||
Assert ($v0.Exit -eq 0 -and $v0.Output -match 'VERIFY OK') "verify passes on a fresh install ($($v0.Output))"
|
||||
# The swap as the deploy packs did it: build the candidate beside the live folder, move live aside, move candidate in,
|
||||
# copy pki\ and appsettings back (robocopy without ACLs, as a new folder).
|
||||
$cand = Join-Path $scratch 'install.new'
|
||||
New-Item -ItemType Directory -Path (Join-Path $cand 'script-worker') -Force | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $cand 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v2'
|
||||
$prev1 = Join-Path $scratch 'install.bak.1'
|
||||
Move-Item -LiteralPath $root -Destination $prev1
|
||||
Move-Item -LiteralPath $cand -Destination $root
|
||||
& robocopy (Join-Path $prev1 'pki') (Join-Path $root 'pki') /E /COPY:DAT /R:0 /W:0 /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $prev1 'appsettings.json') -Destination $root
|
||||
$v1 = Invoke-Mode 'Verify'; Show 'Verify (after the swap)' $v1
|
||||
Assert ($v1.Exit -eq 1 -and $v1.Output -match 'VERIFY FAILED') "verify exits 1 after the swap ($($v1.Exit))"
|
||||
$expectMissing = @("WorkerFolderAcl $root\script-worker", "InstallRootDeny $root", "SensitiveDirDeny $root\pki")
|
||||
Assert ((@($v1.Missing | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) "exactly the three folder ACEs the swap drops are MISSING ($($v1.Missing -join ' | '))"
|
||||
$r1 = Invoke-Mode 'Reapply'; Show 'Reapply (no arguments)' $r1
|
||||
Assert ($r1.Exit -eq 0 -and $r1.Output -match 'settings from the record') "reapply exits 0 and says it used the record ($($r1.Output))"
|
||||
$r1Changed = @($r1.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object { "$($_.Id) $($_.Target)" })
|
||||
Assert ($r1.Steps.Count -eq 12 -and (@($r1Changed | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) "reapply changes exactly the missing three ($($r1Changed -join ' | '))"
|
||||
$v2 = Invoke-Mode 'Verify'; Show 'Verify (after Reapply)' $v2
|
||||
Assert ($v2.Exit -eq 0 -and $v2.Output -match 'VERIFY OK') "verify passes after the reapply ($($v2.Exit))"
|
||||
$r2 = Invoke-Mode 'Reapply'; Show 'Reapply again' $r2
|
||||
Assert ($r2.Exit -eq 0 -and $r2.Steps.Count -eq 12 -and @($r2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second reapply: 12 steps, all unchanged ($(($r2.Steps | ForEach-Object Outcome) -join ','))"
|
||||
Assert ($r2.Output -match 'summary: 12 step\(s\), 0 changed, 12 unchanged') 'the summary line says 0 changed'
|
||||
|
||||
Write-Host 'Negative control: Verify catches each kind of loss'
|
||||
& icacls $cache /grant '*S-1-5-32-545:(OI)(CI)(RX)' /Q | Out-Null # Users on the PE cache root
|
||||
Remove-NetFirewallRule -Name $ruleName
|
||||
$v3 = Invoke-Mode 'Verify'; Show 'Verify (cache ACL + rule broken)' $v3
|
||||
Assert ($v3.Exit -eq 1 -and ($v3.Missing -join '|') -match 'PeCacheDir' -and ($v3.Missing -join '|') -match 'FirewallBlockOutbound') "verify flags the PE cache ACL and the missing rule ($($v3.Missing -join ' | '))"
|
||||
$r3 = Invoke-Mode 'Reapply'; $v4 = Invoke-Mode 'Verify'; Show 'Verify (after Reapply)' $v4
|
||||
Assert ($r3.Exit -eq 0 -and $v4.Exit -eq 0) 'reapply repairs them'
|
||||
|
||||
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1: swap + Reapply + Verify in one step (no service control in the scratch test)'
|
||||
$swapScript = Join-Path (Split-Path -Parent $PSScriptRoot) 'Invoke-OtOpcUaInstallSwap.ps1'
|
||||
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
|
||||
$cand2 = Join-Path $scratch 'install.new2'
|
||||
New-Item -ItemType Directory -Path (Join-Path $cand2 'script-worker') -Force | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $cand2 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v3'
|
||||
$prev2 = Join-Path $scratch 'install.bak.2'
|
||||
$sw = & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand2 -PreviousInstall $prev2 `
|
||||
-Carry 'appsettings*.json,pki' -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String
|
||||
$swExit = $LASTEXITCODE
|
||||
Write-Host (" {0,-34} exit {1}; {2}" -f 'Invoke-OtOpcUaInstallSwap', $swExit, $(if ($sw -match 'VERIFY OK') { 'VERIFY OK after its Reapply' } else { 'no VERIFY OK' }))
|
||||
Assert ($swExit -eq 0 -and $sw -match 'VERIFY OK' -and $sw -match 'Swap complete') "swap script exits 0 with the gate passed ($sw)"
|
||||
Assert ((Get-Content -LiteralPath (Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe')) -eq 'stub v3' -and (Test-Path (Join-Path $root 'pki')) -and
|
||||
(Test-Path (Join-Path $root 'appsettings.json'))) 'the candidate is live and pki\ + appsettings were carried'
|
||||
Assert (@(Get-Explicit $root $workerSid).Count -eq 1 -and @(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 1 -and
|
||||
@(Get-Explicit (Join-Path $root 'pki') $workerSid).Count -eq 1) 'the three folder ACEs are back on the swapped-in folder'
|
||||
|
||||
Write-Host 'Swap refuses a pre-#901 record (DenyPaths only) before changing anything'
|
||||
$saved = (Get-ItemProperty -Path $recordKey).InstallRoot
|
||||
Remove-ItemProperty -Path $recordKey -Name InstallRoot
|
||||
$cand3 = Join-Path $scratch 'install.new3'
|
||||
New-Item -ItemType Directory -Path (Join-Path $cand3 'script-worker') -Force | Out-Null
|
||||
$sw2 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand3 `
|
||||
-PreviousInstall (Join-Path $scratch 'install.bak.3') -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
||||
$sw2Exit = $LASTEXITCODE
|
||||
Write-Host (" {0,-34} exit {1}; {2}" -f 'Invoke-OtOpcUaInstallSwap (pre-#901)', $sw2Exit, $(if (($sw2 -replace '[\s|]+', ' ') -match 'older than lmxopcua#901') { 'refused: settings not recorded' } else { 'NOT refused' }))
|
||||
Assert ($sw2Exit -ne 0 -and ($sw2 -replace '[\s|]+', ' ') -match 'older than lmxopcua#901' -and (Test-Path $cand3) -and -not (Test-Path (Join-Path $scratch 'install.bak.3'))) "refused, nothing moved ($sw2)"
|
||||
$r4 = Invoke-Mode 'Reapply'
|
||||
Show 'Reapply (pre-#901 record)' $r4
|
||||
Assert ($r4.Exit -ne 0 -and $r4.Flat -match 'full arguments') "-Reapply without a full record refuses and says how to fix it ($($r4.Exit))"
|
||||
Set-ItemProperty -Path $recordKey -Name InstallRoot -Value $saved
|
||||
|
||||
Write-Host 'Uninstall WITHOUT -DenyPaths (the record drives it), twice'
|
||||
$u1 = Invoke-Installer @('-Uninstall')
|
||||
Assert ($u1.Exit -eq 0) "uninstall exits 0 ($($u1.Output))"
|
||||
@@ -142,6 +247,7 @@ finally {
|
||||
foreach ($n in $WorkerServiceName, $HostServiceName) {
|
||||
if (Get-Service -Name $n -ErrorAction SilentlyContinue) { & sc.exe delete $n | Out-Null }
|
||||
}
|
||||
if ($LocalHostAccount -and (Get-LocalUser -Name $LocalHostAccountName -ErrorAction SilentlyContinue)) { Remove-LocalUser -Name $LocalHostAccountName }
|
||||
if (Test-Path -LiteralPath $scratch) {
|
||||
& icacls $scratch /reset /T /C /Q 2>&1 | Out-Null
|
||||
Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
@@ -39,7 +39,7 @@ Assert-Throws { Get-OtOpcUaServiceSid -Name 'bad name' } '*' 'a name with a spac
|
||||
Write-Host 'Install plan'
|
||||
$plan = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService)
|
||||
$ids = $plan.Id -join ','
|
||||
Assert ($ids -eq 'AnchorService,DenyPathRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,FirewallBlockOutbound,HostServiceEnv') "install step order ($ids)"
|
||||
Assert ($ids -eq 'AnchorService,DenyPathRecord,SettingsRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,FirewallBlockOutbound,HostServiceEnv') "install step order ($ids)"
|
||||
$byId = @{}; foreach ($s in $plan) { $byId[$s.Id] = $s }
|
||||
|
||||
$anchor = $byId['AnchorService'].Data
|
||||
@@ -79,6 +79,19 @@ $rec = $byId['DenyPathRecord'].Data
|
||||
Assert ($rec.Key -eq 'HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaScriptWorker\Parameters' -and $rec.Name -eq 'DenyPaths' -and @($rec.Paths).Count -eq 0) `
|
||||
"without -DenyPaths the record is empty, under the anchor service's key ($($rec.Key))"
|
||||
|
||||
# lmxopcua#901: the effective arguments are recorded beside DenyPaths, in a key locked to SYSTEM + Administrators.
|
||||
$set = $byId['SettingsRecord'].Data
|
||||
Assert ($set.Key -eq $rec.Key) "settings live under the same key as DenyPaths ($($set.Key))"
|
||||
Assert ($set.Settings.InstallRoot -eq $root -and $set.Settings.PeCacheDir -eq $cache -and $set.Settings.HostServiceName -eq 'OtOpcUaHost' -and
|
||||
$set.Settings.ConfigureHostService -eq 1 -and $set.Settings.HostServiceAccount -eq $hostSid) "recorded settings ($(($set.Settings.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '; '))"
|
||||
Assert ((@($set.Settings.Keys) -join ',') -eq 'InstallRoot,HostServiceAccount,PeCacheDir,HostServiceName,ConfigureHostService') 'exactly these five values (no secrets)'
|
||||
Assert ($set.Sddl -eq 'D:PAI(A;CI;KA;;;SY)(A;CI;KA;;;BA)') "record key DACL is protected, SYSTEM + Administrators only ($($set.Sddl))"
|
||||
foreach ($forbidden in 'BU', 'WD', 'AU', 'RC', $workerSid) { Assert (-not $set.Sddl.Contains(";;;$forbidden)")) "record key grants nothing to $forbidden" }
|
||||
$named = @(Get-ScriptWorkerIsolationPlan -InstallRoot "$root\" -HostSid 'S-1-5-18' -HostServiceAccount 'LocalSystem' -PeCacheDir $cache)
|
||||
$namedSet = ($named | Where-Object Id -eq 'SettingsRecord').Data.Settings
|
||||
Assert ($namedSet.HostServiceAccount -eq 'LocalSystem' -and $namedSet.ConfigureHostService -eq 0 -and $namedSet.InstallRoot -eq $root) `
|
||||
'the account is recorded as given, the flag as 0, the root without a trailing backslash'
|
||||
|
||||
$fw = $byId['FirewallBlockOutbound'].Data
|
||||
Assert ($fw.Program -eq 'C:\Program Files\OtOpcUa\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') "firewall rule program ($($fw.Program))"
|
||||
Assert ($byId['FirewallBlockOutbound'].Change -like '*blocking ALL outbound*') 'firewall rule blocks outbound'
|
||||
@@ -96,7 +109,7 @@ Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostS
|
||||
Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $root } '*inside InstallRoot*' 'the install root itself is refused'
|
||||
Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'relative\cache' } '*absolute*' 'a relative cache is refused'
|
||||
$sibling = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'C:\Program Files\OtOpcUa-cache')
|
||||
Assert ($sibling.Count -eq 7) 'a sibling folder sharing the prefix is NOT inside the root (positive control)'
|
||||
Assert ($sibling.Count -eq 8) 'a sibling folder sharing the prefix is NOT inside the root (positive control)'
|
||||
|
||||
Write-Host 'Sensitive siblings: -DenyPaths (lmxopcua#890, the wonder layout)'
|
||||
$qaRoot = 'E:\ApiInstall\OtOpcUa'
|
||||
@@ -104,7 +117,7 @@ $qaApp = "$qaRoot\app"
|
||||
$qaDeny = @($qaRoot, "$qaRoot\pki", "$qaRoot\data")
|
||||
$qa = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths $qaDeny -ConfigureHostService)
|
||||
$qaIds = $qa.Id -join ','
|
||||
Assert ($qaIds -eq 'AnchorService,DenyPathRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,SensitiveDirDeny,SensitiveDirDeny,SensitiveDirDeny,FirewallBlockOutbound,HostServiceEnv') `
|
||||
Assert ($qaIds -eq 'AnchorService,DenyPathRecord,SettingsRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,SensitiveDirDeny,SensitiveDirDeny,SensitiveDirDeny,FirewallBlockOutbound,HostServiceEnv') `
|
||||
"QA plan order ($qaIds)"
|
||||
$sens = @($qa | Where-Object Id -eq 'SensitiveDirDeny')
|
||||
Assert ((Join-Targets $sens) -eq ($qaDeny -join '|')) "one deny per folder, in the given order ($(Join-Targets $sens))"
|
||||
@@ -151,8 +164,8 @@ $un = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCa
|
||||
$unIds = $un.Id -join ','
|
||||
Assert ($unIds -eq 'HostServiceEnv,FirewallBlockOutbound,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,DenyPathRecord,AnchorService') "rollback order ($unIds)"
|
||||
Assert (@($un | Where-Object { -not ($_.Data.ContainsKey('Remove')) }).Count -eq 0) 'every rollback step removes'
|
||||
Assert ((@($un.Id | Sort-Object) -join ',') -eq (@($plan.Id | Where-Object { $_ -ne 'PeCacheDir' } | Sort-Object) -join ',')) `
|
||||
'rollback reverses every install step but the cache (kept by default)'
|
||||
Assert ((@($un.Id | Sort-Object) -join ',') -eq (@($plan.Id | Where-Object { $_ -notin 'PeCacheDir', 'SettingsRecord' } | Sort-Object) -join ',')) `
|
||||
'rollback reverses every install step but the cache (kept by default); the settings go with the record key (DenyPathRecord)'
|
||||
Assert (($un | Where-Object Id -eq 'HostServiceEnv').Data.Remove -contains 'Scripting__Worker__Identity') 'rollback removes the identity env entry'
|
||||
foreach ($s in $un | Where-Object { $_.Id -in 'InstallRootDeny', 'WorkerFolderAcl', 'PeEntriesDir' }) {
|
||||
Assert ($s.Data.Sid -eq $workerSid) "rollback $($s.Id) targets only the worker identity's ACEs"
|
||||
@@ -181,6 +194,13 @@ Assert ((($defaultCache | Where-Object Id -eq 'PeCacheDir').Data.Path) -like '*O
|
||||
$qaEntry = @(& $entry -InstallRoot $qaApp -HostServiceAccount 'S-1-5-18' -PeCacheDir $cache -DenyPaths ($qaDeny -join ',') -ConfigureHostService -PlanOnly 6>$null)
|
||||
Assert ((@($qaEntry.Id) -join ',') -eq $qaIds) "-PlanOnly -DenyPaths 'a,b,c' (one string, as powershell -File passes it) plans the same steps"
|
||||
|
||||
Write-Host 'Entry script -Reapply / -Verify argument rules (lmxopcua#901; refused before any machine access)'
|
||||
Assert-Throws { & $entry -Reapply -InstallRoot $root 6>$null } '*-Reapply takes its settings from the record*' '-Reapply refuses explicit settings'
|
||||
Assert-Throws { & $entry -Reapply -DenyPaths 'C:\x' 6>$null } '*-Reapply takes its settings from the record*' '-Reapply refuses -DenyPaths'
|
||||
Assert-Throws { & $entry -Reapply -Verify 6>$null } '*exclusive*' '-Reapply and -Verify are exclusive'
|
||||
Assert-Throws { & $entry -Verify -Uninstall 6>$null } '*exclusive*' '-Verify and -Uninstall are exclusive'
|
||||
Assert-Throws { & $entry -InstallRoot $root 6>$null } '*required*' 'a plain install still needs -HostServiceAccount'
|
||||
|
||||
if ($IsWindows) {
|
||||
Write-Host 'SDDL parses on Windows'
|
||||
$ds = [System.Security.AccessControl.DirectorySecurity]::new()
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# lmxopcua#901: re-apply and verify the ScriptWorker isolation after a folder swap (windev, 2026-11-13)
|
||||
|
||||
**Result: PASS on both PowerShell hosts.** `scripts/install/tests/Test-ScriptWorkerIsolation.Windows.ps1` (elevated, scratch
|
||||
folder tree under `%TEMP%`, scratch worker/host service names, never touches `OtOpcUaHost`; windev has no OtOpcUa service and
|
||||
no real isolation installed):
|
||||
|
||||
| Host | Host account | Assertions |
|
||||
|---|---|---|
|
||||
| Windows PowerShell 5.1.19041.6456 | scratch local user `.\otopcuaIsoTestAcct` (`-LocalHostAccount`, random password, deleted) | 52 passed, 0 failed |
|
||||
| PowerShell 7 | scratch local user (`-LocalHostAccount`) | 52 passed, 0 failed |
|
||||
| Windows PowerShell 5.1 | `NT AUTHORITY\SYSTEM` | 52 passed, 0 failed |
|
||||
|
||||
The scratch layout mirrors vd03: `pki\` inside the install folder and in `-DenyPaths`, the deployment folder around it denied
|
||||
too. The "swap" is the deploy packs' sequence: build the candidate beside the live folder, move live aside, move the
|
||||
candidate in, robocopy `pki\` (no ACLs) and copy `appsettings.json` back.
|
||||
|
||||
Transcript of the #901 part (Windows PowerShell 5.1, SYSTEM; the scratch path is shown as `<scratch>`):
|
||||
|
||||
```
|
||||
Swap -> Verify fails -> Reapply -> Verify passes -> Reapply unchanged (lmxopcua#901)
|
||||
Verify (installed) exit 0; 12 ok
|
||||
Verify (after the swap) exit 1; MISSING: WorkerFolderAcl <scratch>\install\script-worker | InstallRootDeny <scratch>\install | SensitiveDirDeny <scratch>\install\pki
|
||||
Reapply (no arguments) exit 0; 9 unchanged, 3 changed
|
||||
Verify (after Reapply) exit 0; 12 ok
|
||||
Reapply again exit 0; 12 unchanged
|
||||
Negative control: Verify catches each kind of loss
|
||||
Verify (cache ACL + rule broken) exit 1; MISSING: PeCacheDir <scratch>\pe-cache | FirewallBlockOutbound OtOpcUa-ScriptWorker-BlockOutbound
|
||||
Verify (after Reapply) exit 0; 12 ok
|
||||
Invoke-OtOpcUaInstallSwap.ps1: swap + Reapply + Verify in one step (no service control in the scratch test)
|
||||
Invoke-OtOpcUaInstallSwap exit 0; VERIFY OK after its Reapply
|
||||
Swap refuses a pre-#901 record (DenyPaths only) before changing anything
|
||||
Invoke-OtOpcUaInstallSwap (pre-#901) exit 1; refused: settings not recorded
|
||||
Reapply (pre-#901 record) exit 1;
|
||||
Uninstall WITHOUT -DenyPaths (the record drives it), twice
|
||||
```
|
||||
|
||||
- After the swap `-Verify` exits **1** and lists exactly the three ACEs the 2026-11-13 vd03 swap lost: `WorkerFolderAcl`
|
||||
(`script-worker\`), `InstallRootDeny` (install folder), `SensitiveDirDeny` (`pki\`).
|
||||
- `-Reapply` with no settings argument changes exactly those three (9 unchanged); `-Verify` then exits 0; a second `-Reapply`
|
||||
reports 12/12 unchanged (`summary: 12 step(s), 0 changed, 12 unchanged`).
|
||||
- Negative controls: a Users grant on the PE-cache root and a deleted firewall rule each show as `[MISSING]` (exit 1).
|
||||
- `Invoke-OtOpcUaInstallSwap.ps1` (`-NoServiceControl`) swaps, carries `appsettings*.json` + `pki`, re-applies and verifies in
|
||||
one run (exit 0, `VERIFY OK`); with a pre-#901 record (no `InstallRoot` value) it refuses before moving anything (exit 1,
|
||||
candidate still in place, no previous-install folder), and `-Reapply` refuses with the "run the full-argument install once" message.
|
||||
- The record key's DACL is `D:PAI(A;CI;KA;;;SY)(A;CI;KA;;;BA)`; the five settings read back.
|
||||
- Cleanup verified afterwards: no firewall rule, no scratch services, no scratch local user, no scratch folder, no scratch
|
||||
registry key; the staged scripts were removed from windev.
|
||||
|
||||
The dry-run harness (`pwsh -File scripts/install/tests/Test-ScriptWorkerIsolation.ps1`, macOS) passes 101/101; a mutation of the
|
||||
record-key SDDL (unprotected + a Users read ACE) turns 2 assertions red.
|
||||
Reference in New Issue
Block a user