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