diff --git a/plugins/geenforce/seed_display_scope.py b/plugins/geenforce/seed_display_scope.py index 74d2ee8..a65bf5a 100644 --- a/plugins/geenforce/seed_display_scope.py +++ b/plugins/geenforce/seed_display_scope.py @@ -27,6 +27,9 @@ uses), then attach the inline dispatcher payload and optionally publish. Re- running replace_scope_draft is an idempotent draft rebuild. """ +import hashlib +import os + from shopdb.api import db from . import service @@ -58,10 +61,16 @@ DISPLAY_TYPE_TARGETS = { DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1' -# One-shot: apply a pending Edge update and bounce the kiosk browser. -# The marker path carries a DATE. That is the whole re-arm mechanism: change the -# date and every display runs it once more. Leave it alone and each display runs -# it exactly once, ever. +# The enforce client module itself, delivered as an enforced entry so a client +# change no longer means hands on every kiosk. Path matches where +# Install-ShopdbKiosk.ps1 puts it, so the installer stays the BOOTSTRAP and this +# becomes the update path. +CLIENT_MODULE_FILENAME = 'ShopdbEnforceClient.psm1' +CLIENT_MODULE_DEST = r'C:\ProgramData\GE-Enforce\ShopdbEnforceClient.psm1' + +# One-shot: apply a pending Edge update and bounce the kiosk browser. The marker +# path carries a DATE. That is the whole re-arm mechanism: change the date and +# every display runs it once more; leave it alone and each runs it exactly once. FORCE_EDGE_UPDATE_FILENAME = 'Invoke-EdgeForceUpdate.ps1' FORCE_EDGE_UPDATE_MARKER = ( r'C:\ProgramData\ShopDB\markers\edge-force-update-2026-08-12.done') @@ -584,6 +593,22 @@ Write-Host 'kiosk always-on enforced (power never-off + screensaver/lock disable ''' +def read_client_module(): + """The enforce client module's bytes, read from this repo. + + Shipping it as a manifest entry closes a real gap: the module is installed + once by Install-ShopdbKiosk.ps1 and never refreshed, so a client change + reached the server with the code deploy and then sat one directory away from + where kiosks fetch from, waiting for someone to re-stage the installer + bundle by hand. Displays are share-less by design; hands on every kiosk is + the wrong cost for a client change. + """ + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'client', CLIENT_MODULE_FILENAME) + with open(path, 'rb') as handle: + return handle.read() + + def build_forceedgeupdate_script(): """Return the inline one-shot Edge force-update PowerShell as text. @@ -677,7 +702,34 @@ def build_display_manifest(): exist. Kept payload-free otherwise: the kiosk engine and browser are baked into the display image, not shipped over HTTPS. """ + clientbytes = read_client_module() applications = [ + { + '_comment': ( + 'The GE-Enforce client module itself. Install-ShopdbKiosk.ps1 ' + 'lays this down once at bootstrap and never refreshes it, so a ' + 'client change reached the server with the code deploy and then ' + 'sat one directory away from where kiosks fetch, waiting for ' + 'someone to re-stage the installer bundle by hand. Displays are ' + 'share-less by design; hands on every kiosk is the wrong cost ' + 'for a client change. Hash detection against the shipped bytes, ' + 'so a matching module is left alone and only a changed one is ' + 'rewritten. SELF-MODIFYING BY DESIGN: this module is what stages ' + 'payloads, but PowerShell loads it into memory at start, so ' + 'rewriting the file mid-run is harmless and takes effect on the ' + 'NEXT cycle. Pilot a client change on ONE kiosk before the fleet ' + '- a broken module cannot fetch its own replacement, and on a ' + 'share-less display that means a site visit.'), + 'Name': 'GE-Enforce client module (self-update)', + 'Type': 'File', + 'Source': CLIENT_MODULE_FILENAME, + 'Destination': CLIENT_MODULE_DEST, + 'PayloadSource': 'inline', + 'PayloadRef': CLIENT_MODULE_FILENAME, + 'DetectionMethod': 'Hash', + 'DetectionPath': CLIENT_MODULE_DEST, + 'DetectionValue': hashlib.sha256(clientbytes).hexdigest(), + }, _registry_drift_heal_entry( 'Edge kiosk RelaunchNotification (Required auto-restart)', 'RelaunchNotification', 2, 'DWord', @@ -823,6 +875,13 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'): watchdog, WATCHDOG_FILENAME, 'text/plain; charset=utf-8', watchdogbytes) + clientmodule = next(entry for entry in scope.entries + if entry.name.startswith('GE-Enforce client module')) + clientbytes = read_client_module() + clientpayload = service.store_inline_payload( + clientmodule, CLIENT_MODULE_FILENAME, + 'text/plain; charset=utf-8', clientbytes) + forceupdate = next(entry for entry in scope.entries if entry.name.startswith('Force pending Edge update')) forceupdatebytes = build_forceedgeupdate_script().encode('utf-8') @@ -845,5 +904,6 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'): 'alwaysonsha256': alwaysonpayload.payloadsha256, 'watchdogsha256': watchdogpayload.payloadsha256, 'forceupdatesha256': forceupdatepayload.payloadsha256, + 'clientmodulesha256': clientpayload.payloadsha256, 'publishedversion': publishedversion, } diff --git a/tests/test_plugins/test_geenforce_display_seed.py b/tests/test_plugins/test_geenforce_display_seed.py index 80f0164..a16d3b1 100644 --- a/tests/test_plugins/test_geenforce_display_seed.py +++ b/tests/test_plugins/test_geenforce_display_seed.py @@ -27,10 +27,11 @@ def test_seed_creates_display_scope(db): # and do NOT inherit common. assert scope.iscommon is False - # Four Registry drift-heal entries + four inline PS1 (dispatcher, watchdog, - # one-shot Edge force-update, always-on). - assert summary['entrycount'] == 8 - assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry', + # The self-updating client module (File), then four Registry drift-heal + # entries, then four inline PS1 (dispatcher, watchdog, one-shot Edge + # force-update, always-on). + assert summary['entrycount'] == 9 + assert summary['entrytypes'] == ['File', 'Registry', 'Registry', 'Registry', 'Registry', 'PS1', 'PS1', 'PS1', 'PS1'] @@ -117,16 +118,17 @@ def test_seed_draft_is_idempotent(db): assert first['alwaysonsha256'] == second['alwaysonsha256'] assert first['watchdogsha256'] == second['watchdogsha256'] assert first['forceupdatesha256'] == second['forceupdatesha256'] + assert first['clientmodulesha256'] == second['clientmodulesha256'] entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all() - assert len(entries) == 8 + assert len(entries) == 9 # Exactly four inline payloads (dispatcher + watchdog + force-update + # 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() == 4 + assert ManifestPayload.query.count() == 5 def test_build_manifest_has_no_smb_exe_payloads(db): @@ -135,9 +137,12 @@ def test_build_manifest_has_no_smb_exe_payloads(db): # the inline dispatcher; everything else is a Registry policy heal. for entry in manifest['Applications']: assert entry.get('Installer') is None - assert entry.get('Source') is None - if entry['Type'] == 'PS1': + # Every payload-bearing entry is delivered INLINE over HTTPS, never from + # a share: a display has no SFLD credentials at all. + if entry['Type'] in ('PS1', 'File'): assert entry.get('PayloadSource') == 'inline' + else: + assert entry.get('Source') is None def test_dispatcher_script_is_ascii(): @@ -261,3 +266,34 @@ def test_forceupdate_script_does_not_launch_the_browser_itself(): def test_forceupdate_script_is_ascii(): build_forceedgeupdate_script().encode('ascii') + + +def _client_entry(): + return next(a for a in build_display_manifest()['Applications'] + if a['Name'].startswith('GE-Enforce client module')) + + +def test_client_module_ships_with_a_hash_of_the_bytes_it_ships(db): + """The detection hash must match the shipped payload exactly. A mismatch + means every kiosk rewrites the module on every cycle, forever.""" + import hashlib + from plugins.geenforce.seed_display_scope import read_client_module + + entry = _client_entry() + assert entry['DetectionMethod'] == 'Hash' + assert entry['DetectionValue'] == hashlib.sha256(read_client_module()).hexdigest() + # Detection must look at where the file LANDS, not where it came from. + assert entry['DetectionPath'] == entry['Destination'] + + +def test_client_module_lands_where_the_installer_puts_it(db): + """Bootstrap and self-update must write the SAME path, or a kiosk ends up + running the installer's copy while the enforced one sits beside it.""" + entry = _client_entry() + assert entry['Destination'].endswith('\\ShopdbEnforceClient.psm1') + assert 'GE-Enforce' in entry['Destination'] + + +def test_client_module_is_first_so_a_stale_client_refreshes_first(db): + names = [a['Name'] for a in build_display_manifest()['Applications']] + assert names[0].startswith('GE-Enforce client module')