From 939cdd0882e230af92b4b9759f6029d545134d08 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Mon, 10 Aug 2026 07:49:43 -0400 Subject: [PATCH] geenforce: relaunch the display kiosk when Edge goes away Kiosks were updating, closing Edge, and never coming back - the display stayed dead until the next logon or reboot. The kiosk is launched by an all-users Startup shortcut, which runs ONCE at logon, and nothing supervised the browser afterwards. RelaunchNotification=2 was meant to cover the update case and does not: that policy drives Edge's own update-restart, which depends on session restore to return to where it was. Kiosk mode restores no session and has no UI to show the notification in, so Edge honours the close and never the relaunch. The same gap swallowed crashes and anyone closing the window. Adds a scope entry that registers a scheduled task in the INTERACTIVE session - SYSTEM cannot launch a visible browser, which is why the dispatcher writes a shortcut rather than calling Start-Process. The task relaunches from that same shortcut, so the target URL keeps one source of truth: retarget a subtype in DISPLAY_TYPE_TARGETS and the watchdog follows unchanged. Two details that matter. It matches on the COMMAND LINE, not the image name: Edge runs a crowd of msedge.exe children and only the parent carries --kiosk, so testing "is msedge running" would let a stray renderer mask a dead kiosk forever - verified against a real kiosk PC showing 7 processes and 1 match. And it avoids -RepetitionDuration [TimeSpan]::MaxValue, which serialises out of range and is rejected, exactly as the kiosk installer documents. A launch debounce stops a display that fails to start from spawning a browser every cycle, the log is size-bounded because this runs forever on a PC nobody watches, and it does nothing at all when no kiosk shortcut is present so it cannot put Edge on a PC that never asked for one. Verified on Windows: registers with the right principal and triggers, relaunches when the kiosk is gone, debounces an immediate re-run, and is idempotent across cycles (the staged script compare is trimmed - Set-Content adds a trailing newline the here-string lacks, so an untrimmed compare rewrote it every cycle). --- plugins/geenforce/seed_display_scope.py | 223 ++++++++++++++++++ .../test_geenforce_display_seed.py | 61 ++++- 2 files changed, 275 insertions(+), 9 deletions(-) diff --git a/plugins/geenforce/seed_display_scope.py b/plugins/geenforce/seed_display_scope.py index 1a1495d..4bdf779 100644 --- a/plugins/geenforce/seed_display_scope.py +++ b/plugins/geenforce/seed_display_scope.py @@ -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, } diff --git a/tests/test_plugins/test_geenforce_display_seed.py b/tests/test_plugins/test_geenforce_display_seed.py index 3149795..5786adb 100644 --- a/tests/test_plugins/test_geenforce_display_seed.py +++ b/tests/test_plugins/test_geenforce_display_seed.py @@ -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