Endpoint telemetry, PC Detail rework and a checked backup/restore path

This commit is contained in:
2026-09-06 18:53:11 -04:00
parent 32b8745d10
commit 993353dfc0
97 changed files with 13340 additions and 756 deletions

View File

@@ -136,6 +136,12 @@ _BASE_APPSETTINGS = {
'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe',
'agent.exe': 'C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat',
},
# Approved scheduled task paths for scheduledtask_run. Shipped empty, which
# means the agent falls back to ScheduledTaskPolicy.DefaultApprovedTaskPaths
# (the Corporate Tools set). Listing entries here REPLACES those defaults, so
# a site can narrow or widen the set without an agent rebuild. Anything not
# matched fails closed.
'approvedtaskpaths': [],
}
_README = """ShopDB endpoint agent (site build)
@@ -217,7 +223,19 @@ param(
[string] $ServiceName = "ShopDbEndpointAgent",
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
[string] $CertFileName = {certfile},
[switch] $NoTray
[switch] $NoTray,
# Scheduled task that runs the user-context probe INSIDE the operator's own
# logon session. Mapped drives are per logon token, so this is the only
# reliable way to collect them; see the registration block below.
[string] $UserContextTaskName = "ShopDB user-context probe",
[int] $UserContextTaskMinutes = 30,
# The account the probe task runs as. Must match the account whose profile
# the agent reads the spool from (FileLoggerProvider.ShopFloorUserName), or
# the probe writes session data the service never looks at.
[string] $UserContextTaskUser = "ShopFloor",
[switch] $NoUserContextTask
)
$ErrorActionPreference = "Stop"
@@ -362,10 +380,175 @@ if (-not $NoTray) {{
}}
catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }}
# Launch the tray once now so it appears without waiting for the next
# logon. Single-instance guard makes a duplicate launch a no-op.
try {{ Start-Process -FilePath $installedTray -WorkingDirectory $InstallDir }}
catch {{ Write-Warning "could not start tray: $($_.Exception.Message)" }}
# Deliberately NOT launched here. This installer runs elevated, so
# Start-Process would hand the tray an ADMIN token, and drive letters
# are per logon token: the probe would then enumerate the installing
# admin's drive table, not the operator's. Measured on a live endpoint,
# the same two shares reported Unavailable from the elevated shell and
# OK from the operator's own session, in both Win32_NetworkConnection
# and Get-SmbMapping. Launching via explorer.exe does not fix this
# either - it de-elevates but does NOT change account.
#
# The Run key above is the correct autostart: it fires at a genuine
# logon, in the operator's own session. Collection no longer depends on
# the tray at all - the scheduled task below owns it.
Write-Host " tray will start at next logon (not launched from this elevated install)."
}}
}}
# 2b. Register the user-context probe as a scheduled task owned by the SHOPFLOOR
# USER. This is what makes mapped drives, user listeners and idle time
# collectable: it is the only mechanism that starts a process in the operator's
# own session with the operator's own token.
#
# RunLevel Limited is load-bearing, not a hardening nicety - the unelevated
# token is precisely what sees the operator's mapped drives.
#
# UserId, NOT GroupId. A group-principal task is registered against the group
# rather than against any one account, and a member of that group cannot invoke
# it on demand: Start-ScheduledTask from the ShopFloor session returns access
# denied, so the task only ever ran on its own trigger. That is exactly the bug
# where "Refresh Status" appeared to do nothing. Registering to the specific
# account makes the task startable by that account, which is what both the tray
# and any manual retry depend on. The group form is kept only as a fallback for
# a host where the account cannot be resolved - it still collects on a timer,
# it just cannot be kicked on demand.
$taskState = "skipped"
if (-not $NoUserContextTask) {{
Write-Host " registering user-context probe task ..."
$taskAgentExe = Join-Path $InstallDir "shopdbagent.exe"
$taskRegistered = $false
$taskDetail = "no principal accepted"
try {{
$taskAction = New-ScheduledTaskAction -Execute $taskAgentExe -Argument "--collect-usercontext"
# AtLogOn covers the normal case. The repeating trigger matters just as
# much: AtLogOn does NOT fire for a session that is already signed in,
# which is every install onto a machine someone is currently using.
$taskTriggers = @(New-ScheduledTaskTrigger -AtLogOn)
if ($UserContextTaskMinutes -gt 0) {{
$taskTriggers += New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes $UserContextTaskMinutes)
}}
$taskSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
# Try the machine-qualified account first, then the bare name. Interactive
# LogonType so the task joins the existing interactive session instead of
# creating a separate one with its own (empty) drive table.
$taskCandidates = @(
@{{ Kind = "user"; Id = "$env:COMPUTERNAME\\$UserContextTaskUser" }},
@{{ Kind = "user"; Id = $UserContextTaskUser }},
@{{ Kind = "group"; Id = "S-1-5-32-545" }},
@{{ Kind = "group"; Id = "BUILTIN\\Users" }}
)
foreach ($candidate in $taskCandidates) {{
try {{
if ($candidate.Kind -eq "user") {{
$taskPrincipal = New-ScheduledTaskPrincipal -UserId $candidate.Id -LogonType Interactive -RunLevel Limited -ErrorAction Stop
}}
else {{
$taskPrincipal = New-ScheduledTaskPrincipal -GroupId $candidate.Id -RunLevel Limited -ErrorAction Stop
}}
Register-ScheduledTask -TaskName $UserContextTaskName -Action $taskAction -Trigger $taskTriggers -Principal $taskPrincipal -Settings $taskSettings -Force -ErrorAction Stop | Out-Null
$taskRegistered = $true
$taskDetail = "$($candidate.Kind):$($candidate.Id)"
break
}}
catch {{ $taskDetail = $_.Exception.Message }}
}}
}}
catch {{ $taskDetail = "could not build the task: $($_.Exception.Message)" }}
if ($taskRegistered) {{
# Read the task back rather than trusting Register-ScheduledTask to have
# returned cleanly: policy or a management agent can rewrite or drop a
# task after registration, and a task that exists but runs elevated
# looks healthy in the scheduler UI while producing wrong data.
$verified = Get-ScheduledTask -TaskName $UserContextTaskName -ErrorAction SilentlyContinue
if (-not $verified) {{
$taskState = "UNVERIFIED:task not found after registration"
Write-Warning " user-context task registered but cannot be read back."
}}
elseif ([string] $verified.Principal.RunLevel -eq "Highest") {{
$taskState = "UNVERIFIED:elevated"
Write-Warning " user-context task runs ELEVATED; mapped drives would be collected from the wrong token."
}}
elseif ($verified.Principal.GroupId) {{
# Registered, will collect on its triggers, but cannot be started on
# demand by the operator - so "Refresh Status" will fall back to the
# timer. Surfaced rather than reported as ok, because the symptom
# (stale session data that never responds to a refresh) is otherwise
# indistinguishable from a broken probe.
$taskState = "DEGRADED:group principal $($verified.Principal.GroupId)"
Write-Warning " user-context task fell back to a GROUP principal; on-demand refresh will not work, only the $UserContextTaskMinutes-minute interval."
}}
else {{
$taskState = "ok:$($verified.Principal.UserId)/$($verified.Principal.RunLevel)"
Write-Host " user-context task registered and verified: $taskState"
}}
# AtLogOn will not fire for an already-signed-in session, so kick it once
# rather than leaving a fresh install blind until the repeat interval.
try {{
Start-ScheduledTask -TaskName $UserContextTaskName -ErrorAction Stop
Write-Host " user-context task started once for the current session."
}}
catch {{ Write-Warning " could not start the task now: $($_.Exception.Message); it runs at next logon or interval." }}
}}
else {{
# Not fatal: the service still checks in, only the session-scoped fields
# are missing, and the ledger records why.
$taskState = "FAILED:$taskDetail"
Write-Warning " could not register the user-context task: $taskDetail"
Write-Warning " mapped drives and idle time will not be collected."
}}
}}
# 2c. Start the tray in the operator's session.
#
# The Run key registered above only fires at LOGON, so installing onto a machine
# someone is already signed in to leaves no tray until they sign out and back in.
# The installer cannot start it directly - it is elevated, and the tray must run
# unelevated in the user's own session - so we borrow the same mechanism that
# solves this for the probe: a transient scheduled task with a Limited group
# principal, run once and then deleted.
#
# Registered without a trigger; it exists only to be invoked immediately. The
# tray is single-instance, so if one is somehow already running this is a no-op.
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
$launchTaskName = "ShopDB tray first launch"
try {{
$launchExe = Join-Path $InstallDir $TrayExeName
$launchAction = New-ScheduledTaskAction -Execute $launchExe -WorkingDirectory $InstallDir
$launchSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
$launched = $false
foreach ($groupId in @("S-1-5-32-545", "BUILTIN\\Users")) {{
try {{
$launchPrincipal = New-ScheduledTaskPrincipal -GroupId $groupId -RunLevel Limited -ErrorAction Stop
Register-ScheduledTask -TaskName $launchTaskName -Action $launchAction -Principal $launchPrincipal -Settings $launchSettings -Force -ErrorAction Stop | Out-Null
Start-ScheduledTask -TaskName $launchTaskName -ErrorAction Stop
$launched = $true
break
}}
catch {{ }}
}}
# Give the task a moment to spawn the tray before removing it: deleting a
# task that is still starting can kill the process it just launched.
if ($launched) {{
Start-Sleep -Seconds 3
Write-Host " tray started in the operator's session."
}}
else {{
Write-Warning " could not start the tray now; it will start at next logon via the Run key."
}}
# Transient by design: this is a one-shot launcher, not an autostart.
# The Run key owns autostart, and leaving this behind would mean two
# things racing to start the same single-instance tray at every logon.
try {{ Unregister-ScheduledTask -TaskName $launchTaskName -Confirm:$false -ErrorAction Stop }}
catch {{ Write-Warning " could not remove the transient launch task '$launchTaskName'; remove it manually." }}
}}
catch {{
Write-Warning " could not start the tray now: $($_.Exception.Message); it will start at next logon."
}}
}}
@@ -397,15 +580,22 @@ Write-Host " service is running." -ForegroundColor Green
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
$trayOk = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {{
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayOk = $true; break }}
Write-Host " tray not running yet (attempt $attempt/5); relaunching ..."
try {{ Start-Process -FilePath (Join-Path $InstallDir $TrayExeName) -WorkingDirectory $InstallDir }} catch {{ }}
# Report only; never relaunch from here. This block used to Start-Process the
# tray when it was not found running, which would hand it this installer's
# ADMIN token and reintroduce the wrong-drive-table bug. The launch task
# above is the only place the tray is started during install, and it starts
# it correctly - unelevated, in the operator's session.
$trayFound = $false
for ($attempt = 1; $attempt -le 3; $attempt++) {{
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayFound = $true; break }}
Start-Sleep -Seconds 2
}}
if ($trayOk) {{ Write-Host " tray is running." -ForegroundColor Green }}
else {{ Write-Warning "tray did not confirm running; it will retry at next logon via the Run key." }}
if ($trayFound) {{
Write-Host " tray is running." -ForegroundColor Green
}}
else {{
Write-Warning "tray did not confirm running; it will start at next logon via the Run key."
}}
}}
# 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under
@@ -432,6 +622,29 @@ if (Test-Path $installSelf) {{
}}
Write-Host "[6/6] Done." -ForegroundColor Green
# Append one line per install, including reinstalls and repeat installs of the
# same version. The user-context task state is recorded because it is the single
# point of failure for session-scoped collection: if mapped drives go missing
# later, this answers "was the task ever registered on this host, and as what"
# without needing the endpoint back. The ledger is a record, not a gate - a
# failure to append must never fail an otherwise-good install.
try {{
$ledgerDir = Join-Path $env:ProgramData "ShopDB\\Logs"
New-Item -ItemType Directory -Force -Path $ledgerDir | Out-Null
$ledgerPath = Join-Path $ledgerDir "shopdbagent-installs.log"
$ledgerAgentVersion = "unknown"
if (Test-Path $installedAgent) {{ $ledgerAgentVersion = (Get-Item $installedAgent).VersionInfo.FileVersion }}
$ledgerTrayVersion = "not-installed"
$ledgerTrayPath = Join-Path $InstallDir $TrayExeName
if (Test-Path $ledgerTrayPath) {{ $ledgerTrayVersion = (Get-Item $ledgerTrayPath).VersionInfo.FileVersion }}
$ledgerLine = "{{0}} host={{1}} installer={{2}} service={{3}} tray={{4}} dir={{5}} usercontexttask={{6}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState
Add-Content -Path $ledgerPath -Value $ledgerLine -Encoding UTF8
Write-Host "Install ledger: $ledgerPath"
Write-Host " $ledgerLine" -ForegroundColor DarkGray
}}
catch {{ Write-Warning "could not append to the install ledger: $($_.Exception.Message)" }}
if (Test-Path $uninstaller) {{
Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan
Write-Host " To remove the agent, run that script from an elevated PowerShell."