geenforce: relaunch the display kiosk when Edge goes away
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s

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).
This commit is contained in:
cproudlock
2026-08-10 07:49:43 -04:00
parent 178dacd55e
commit 939cdd0882
2 changed files with 275 additions and 9 deletions

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