display kiosk watchdog: relaunch Edge when it goes away

This commit is contained in:
2026-08-10 07:54:03 -04:00
parent 8a2a3ee216
commit e68f522132
2 changed files with 275 additions and 9 deletions

View File

@@ -287,6 +287,201 @@ Write-Host "display-type '$displayType' -> kiosk Startup shortcut for $kioskUrl"
"""
WATCHDOG_FILENAME = 'Register-KioskWatchdog.ps1'
# Where the watchdog stages the script the scheduled task actually runs. The
# task must run in the INTERACTIVE session, so its action cannot be an inline
# payload executed by the SYSTEM enforce task.
WATCHDOG_TASKNAME = 'ShopDB Kiosk Watchdog'
WATCHDOG_SCRIPTPATH = r'C:\ProgramData\ShopDB\Watch-ShopdbKiosk.ps1'
WATCHDOG_INTERVAL_MIN = 2
def build_watchdog_script():
"""Return the inline watchdog-installer PowerShell as text.
WHY THIS EXISTS: the kiosk is launched by an all-users Startup shortcut,
which runs ONCE at logon. Nothing supervises the browser afterwards, so when
Edge exits - an update, a crash, someone closing the window - the display
stays dead until the next logon or reboot.
The Edge RelaunchNotification=2 policy was intended to cover the update
case, but it does not: that policy drives Edge's own update-restart flow,
which depends on session restore to come back to where it was. Kiosk mode
(--kiosk) restores no session and shows no notification UI, so Edge honours
the close and never the relaunch.
This entry runs as SYSTEM under the enforce task and cannot launch a visible
browser itself (session 0 has no desktop - the same reason the dispatcher
writes a Startup shortcut). So it REGISTERS a scheduled task that runs in
the interactive user's session and relaunches the kiosk when it is gone.
Idempotent: the staged script and the task are rewritten only when they
differ from what is wanted, so a steady-state cycle costs one file read.
"""
# The script the task runs. Kept as a separate literal so the outer f-string
# does not have to escape its braces.
watched = r'''# Watch-ShopdbKiosk.ps1 -- relaunch the kiosk browser if it has gone.
#
# Runs in the INTERACTIVE user session (see the watchdog task), every couple of
# minutes. Reads the same Startup shortcut the dispatcher writes, so the target
# URL has exactly one source of truth: retarget a display by editing the
# dispatcher, and this follows without change.
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\ShopDB'
$logFile = Join-Path $logDir 'kiosk-watchdog.log'
$stamp = Join-Path $env:TEMP 'shopdb-kiosk-lastlaunch.txt'
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force -EA SilentlyContinue | Out-Null
}
function Log {
param([string]$Message)
# Bounded: this runs every couple of minutes forever on a PC nobody logs
# into, so an unbounded log would be the only thing that ever fills the disk.
try {
if ((Test-Path $logFile) -and ((Get-Item $logFile).Length -gt 512KB)) {
$keep = Get-Content $logFile -Tail 200 -EA SilentlyContinue
Set-Content -Path $logFile -Value $keep -EA SilentlyContinue
}
} catch { }
Add-Content -Path $logFile -EA SilentlyContinue `
-Value ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message)
}
# The kiosk shortcut is the source of truth for target + arguments.
$startup = Join-Path $env:ProgramData 'Microsoft\Windows\Start Menu\Programs\StartUp'
$lnkPath = Join-Path $startup 'ShopDB Kiosk.lnk'
if (-not (Test-Path -LiteralPath $lnkPath)) {
# Not a kiosk display, or the dispatcher has not run yet. Do NOT invent a
# browser launch here - that would put Edge on a PC that never asked for it.
return
}
try {
$shell = New-Object -ComObject WScript.Shell
$lnk = $shell.CreateShortcut($lnkPath)
$target = $lnk.TargetPath
$arguments = $lnk.Arguments
} catch {
Log "could not read $lnkPath : $_"
return
}
if (-not $target -or -not (Test-Path -LiteralPath $target)) {
Log "shortcut target missing: $target"
return
}
# Match on the COMMAND LINE, not just the image name. Edge spawns a crowd of
# msedge.exe children (renderers, GPU, crashpad) that outlive nothing useful;
# testing for "is msedge running" would leave a stray child masking a dead
# kiosk forever.
$running = $false
try {
$running = [bool](Get-CimInstance Win32_Process -Filter "Name = 'msedge.exe'" -EA SilentlyContinue |
Where-Object { $_.CommandLine -match '--kiosk' })
} catch {
Log "process query failed, assuming kiosk is up: $_"
return
}
if ($running) { return }
# Debounce. Edge takes a few seconds to present a --kiosk process, and a display
# that fails to start would otherwise get a new browser every couple of minutes.
try {
if (Test-Path $stamp) {
$last = Get-Content $stamp -First 1 -EA SilentlyContinue
if ($last) {
$age = ((Get-Date) - [datetime]$last).TotalSeconds
if ($age -lt 120) {
Log ('kiosk still absent {0:N0}s after a launch - not relaunching yet' -f $age)
return
}
}
}
} catch { }
Log "kiosk browser not running - relaunching: $target $arguments"
try {
Set-Content -Path $stamp -Value (Get-Date -Format 'o') -EA SilentlyContinue
Start-Process -FilePath $target -ArgumentList $arguments -EA Stop
Log 'relaunched.'
} catch {
Log "relaunch FAILED: $_"
}
'''
return f"""# Register-KioskWatchdog.ps1 -- gea-shopfloor-display kiosk watchdog.
#
# Installs a scheduled task that relaunches the kiosk browser when it is gone.
# See the manifest comment for why the Startup shortcut alone is not enough.
#
# Runs as SYSTEM under the enforce task, so it registers the task rather than
# launching anything: SYSTEM has no interactive desktop.
$ErrorActionPreference = 'Continue'
$taskName = '{WATCHDOG_TASKNAME}'
$scriptPath = '{WATCHDOG_SCRIPTPATH}'
$intervalMin = {WATCHDOG_INTERVAL_MIN}
$wanted = @'
{watched}
'@
# --- stage the script the task runs --------------------------------------
$dir = Split-Path -Parent $scriptPath
if (-not (Test-Path $dir)) {{ New-Item -ItemType Directory -Path $dir -Force | Out-Null }}
$current = ''
if (Test-Path -LiteralPath $scriptPath) {{
$current = Get-Content -LiteralPath $scriptPath -Raw -EA SilentlyContinue
}}
# Compare trimmed: Set-Content appends a trailing newline that the here-string
# does not carry, so a raw comparison never matches and the file is rewritten
# every enforce cycle forever.
if ($current.TrimEnd() -ne $wanted.TrimEnd()) {{
Set-Content -LiteralPath $scriptPath -Value $wanted -Encoding UTF8 -Force
Write-Host "staged $scriptPath"
}} else {{
Write-Host "watchdog script already current"
}}
# --- register the task ----------------------------------------------------
# Principal is the INTERACTIVE users group, not SYSTEM: the relaunched browser
# has to appear on the auto-login user's visible desktop. A SYSTEM task would
# start Edge in session 0 where nobody can see it.
$existing = Get-ScheduledTask -TaskName $taskName -EA SilentlyContinue
if (-not $existing) {{
try {{
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument ('-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "{{0}}"' -f $scriptPath)
$atLogon = New-ScheduledTaskTrigger -AtLogOn
# -Once + -RepetitionInterval repeats indefinitely. Do NOT pass
# -RepetitionDuration [TimeSpan]::MaxValue: it serialises to an
# out-of-range Duration that Register-ScheduledTask rejects.
$repeat = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddMinutes(1)) `
-RepetitionInterval (New-TimeSpan -Minutes $intervalMin)
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
-MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 5) `
-DontStopIfGoingOnBatteries -AllowStartIfOnBatteries
Register-ScheduledTask -TaskName $taskName -Action $action `
-Trigger @($atLogon, $repeat) -Principal $principal -Settings $settings `
-Force | Out-Null
Write-Host "registered '$taskName' (every $intervalMin min, interactive)"
}} catch {{
Write-Host "FAILED to register '$taskName': $_"
}}
}} else {{
Write-Host "'$taskName' already registered"
}}
"""
ALWAYSON_FILENAME = 'Set-DisplayAlwaysOn.ps1'
@@ -416,6 +611,26 @@ def build_display_manifest():
'PayloadRef': DISPATCHER_FILENAME,
'DetectionMethod': 'Always',
},
{
'_comment': (
'Relaunch the kiosk browser when it goes away. The Startup '
'shortcut the dispatcher writes runs ONCE at logon, so an Edge '
'update, a crash or a closed window leaves the display dead '
'until the next logon. RelaunchNotification=2 does not cover '
'it: that policy drives Edge\'s update-restart, which relies on '
'session restore, and --kiosk restores no session and shows no '
'notification UI. Registers an INTERACTIVE-session scheduled '
'task (SYSTEM cannot launch a visible browser) that relaunches '
'from the same shortcut every '
f'{WATCHDOG_INTERVAL_MIN} minutes when no --kiosk process is '
'running.'),
'Name': 'Display kiosk watchdog (relaunch Edge)',
'Type': 'PS1',
'Script': WATCHDOG_FILENAME,
'PayloadSource': 'inline',
'PayloadRef': WATCHDOG_FILENAME,
'DetectionMethod': 'Always',
},
{
'_comment': (
'Keep the display awake 24/7 so the kiosk page stays visible: '
@@ -472,6 +687,13 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
alwayson, ALWAYSON_FILENAME,
'text/plain; charset=utf-8', alwaysonbytes)
watchdog = next(entry for entry in scope.entries
if entry.name == 'Display kiosk watchdog (relaunch Edge)')
watchdogbytes = build_watchdog_script().encode('utf-8')
watchdogpayload = service.store_inline_payload(
watchdog, WATCHDOG_FILENAME,
'text/plain; charset=utf-8', watchdogbytes)
publishedversion = None
if publish:
publishedversion = service.publish_scope(
@@ -485,5 +707,6 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
'entrytypes': [entry.entrytype for entry in scope.entries],
'dispatchersha256': payload.payloadsha256,
'alwaysonsha256': alwaysonpayload.payloadsha256,
'watchdogsha256': watchdogpayload.payloadsha256,
'publishedversion': publishedversion,
}

View File

@@ -11,6 +11,7 @@ from plugins.geenforce.models import (
)
from plugins.geenforce.seed_display_scope import (
seed_display_scope, build_display_manifest, build_dispatcher_script,
build_watchdog_script,
DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME, ALWAYSON_FILENAME,
)
@@ -25,10 +26,11 @@ def test_seed_creates_display_scope(db):
# and do NOT inherit common.
assert scope.iscommon is False
# Four Registry drift-heal entries + two inline PS1 (dispatcher, always-on).
assert summary['entrycount'] == 6
# Four Registry drift-heal entries + three inline PS1 (dispatcher,
# watchdog, always-on).
assert summary['entrycount'] == 7
assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry',
'Registry', 'PS1', 'PS1']
'Registry', 'PS1', 'PS1', 'PS1']
def test_registry_entries_use_valuematches_detection(db):
@@ -112,14 +114,16 @@ def test_seed_draft_is_idempotent(db):
assert first['entrycount'] == second['entrycount']
assert first['dispatchersha256'] == second['dispatchersha256']
assert first['alwaysonsha256'] == second['alwaysonsha256']
assert first['watchdogsha256'] == second['watchdogsha256']
entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all()
assert len(entries) == 6
# Exactly two inline payloads (dispatcher + always-on) exist after a rebuild,
# not more - a payload-count invariant. (The underlying re-publish FK crash
# only reproduces on MySQL, which enforces the manifestpayloads FK; it was
# verified there directly. SQLite does not enforce it.)
assert ManifestPayload.query.count() == 2
assert len(entries) == 7
# Exactly three inline payloads (dispatcher + watchdog + always-on) exist
# after a rebuild, not more - a payload-count invariant. (The underlying
# re-publish FK crash only reproduces on MySQL, which enforces the
# manifestpayloads FK; it was verified there directly. SQLite does not
# enforce it.)
assert ManifestPayload.query.count() == 3
def test_build_manifest_has_no_smb_exe_payloads(db):
@@ -137,3 +141,42 @@ def test_dispatcher_script_is_ascii():
# Plain ASCII only (no smart quotes / em-dashes) so the naming gate stays
# green and the on-PC script parses cleanly.
build_dispatcher_script().encode('ascii')
def test_watchdog_entry_is_present_and_inline(db):
"""The Startup shortcut runs once at logon, so an Edge update/crash leaves
the display dead until the next logon. The watchdog is what recovers it."""
manifest = build_display_manifest()
entry = next(a for a in manifest['Applications']
if a['Name'] == 'Display kiosk watchdog (relaunch Edge)')
assert entry['Type'] == 'PS1'
assert entry['PayloadSource'] == 'inline'
assert entry['DetectionMethod'] == 'Always'
def test_watchdog_script_registers_an_interactive_task():
"""A SYSTEM task would start Edge in session 0 where nobody can see it, so
the principal must be the interactive Users group."""
script = build_watchdog_script()
assert "New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545'" in script
assert 'ShopDB Kiosk Watchdog' in script
# The MaxValue repetition trap the kiosk installer documents. Checked
# against CODE lines only - the script comments the trap on purpose, so a
# naive substring test would fail on its own warning.
code = [line for line in script.splitlines()
if not line.lstrip().startswith('#')]
assert not any('RepetitionDuration' in line for line in code)
def test_watchdog_matches_on_the_command_line_not_the_image_name():
"""Edge spawns many msedge.exe children; only the parent carries --kiosk.
Testing 'is msedge running' would let a stray renderer mask a dead kiosk."""
script = build_watchdog_script()
assert "CommandLine -match '--kiosk'" in script
def test_watchdog_does_nothing_without_a_kiosk_shortcut():
"""It must never invent a browser launch on a PC that is not a kiosk."""
script = build_watchdog_script()
assert 'ShopDB Kiosk.lnk' in script
assert 'shopdb-kiosk-lastlaunch' in script # debounce stamp