displays: the client module updates itself
Install-ShopdbKiosk.ps1 lays the enforce client down once at bootstrap and never refreshes it. So a client change rode the code deploy to the server and then sat one directory away from where kiosks actually fetch, waiting for someone to re-stage the installer bundle by hand - which is how the new display-type reporting reached prod and changed nothing on any kiosk. The module now ships as a manifest entry like everything else in this scope: inline over HTTPS, Hash detection against the exact bytes shipped, written to the same path the installer uses so bootstrap and self-update cannot disagree. Ordered first, so a stale client refreshes before anything leans on it. The installer keeps its real job - a fresh kiosk still needs something that can talk to shopdb - it just stops being the update path. 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 lands 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.
This commit is contained in:
@@ -27,6 +27,9 @@ uses), then attach the inline dispatcher payload and optionally publish. Re-
|
|||||||
running replace_scope_draft is an idempotent draft rebuild.
|
running replace_scope_draft is an idempotent draft rebuild.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
|
||||||
from shopdb.api import db
|
from shopdb.api import db
|
||||||
|
|
||||||
from . import service
|
from . import service
|
||||||
@@ -58,10 +61,16 @@ DISPLAY_TYPE_TARGETS = {
|
|||||||
|
|
||||||
DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1'
|
DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1'
|
||||||
|
|
||||||
# One-shot: apply a pending Edge update and bounce the kiosk browser.
|
# The enforce client module itself, delivered as an enforced entry so a client
|
||||||
# The marker path carries a DATE. That is the whole re-arm mechanism: change the
|
# change no longer means hands on every kiosk. Path matches where
|
||||||
# date and every display runs it once more. Leave it alone and each display runs
|
# Install-ShopdbKiosk.ps1 puts it, so the installer stays the BOOTSTRAP and this
|
||||||
# it exactly once, ever.
|
# 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_FILENAME = 'Invoke-EdgeForceUpdate.ps1'
|
||||||
FORCE_EDGE_UPDATE_MARKER = (
|
FORCE_EDGE_UPDATE_MARKER = (
|
||||||
r'C:\ProgramData\ShopDB\markers\edge-force-update-2026-08-12.done')
|
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():
|
def build_forceedgeupdate_script():
|
||||||
"""Return the inline one-shot Edge force-update PowerShell as text.
|
"""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
|
exist. Kept payload-free otherwise: the kiosk engine and browser are baked
|
||||||
into the display image, not shipped over HTTPS.
|
into the display image, not shipped over HTTPS.
|
||||||
"""
|
"""
|
||||||
|
clientbytes = read_client_module()
|
||||||
applications = [
|
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(
|
_registry_drift_heal_entry(
|
||||||
'Edge kiosk RelaunchNotification (Required auto-restart)',
|
'Edge kiosk RelaunchNotification (Required auto-restart)',
|
||||||
'RelaunchNotification', 2, 'DWord',
|
'RelaunchNotification', 2, 'DWord',
|
||||||
@@ -823,6 +875,13 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
|
|||||||
watchdog, WATCHDOG_FILENAME,
|
watchdog, WATCHDOG_FILENAME,
|
||||||
'text/plain; charset=utf-8', watchdogbytes)
|
'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
|
forceupdate = next(entry for entry in scope.entries
|
||||||
if entry.name.startswith('Force pending Edge update'))
|
if entry.name.startswith('Force pending Edge update'))
|
||||||
forceupdatebytes = build_forceedgeupdate_script().encode('utf-8')
|
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,
|
'alwaysonsha256': alwaysonpayload.payloadsha256,
|
||||||
'watchdogsha256': watchdogpayload.payloadsha256,
|
'watchdogsha256': watchdogpayload.payloadsha256,
|
||||||
'forceupdatesha256': forceupdatepayload.payloadsha256,
|
'forceupdatesha256': forceupdatepayload.payloadsha256,
|
||||||
|
'clientmodulesha256': clientpayload.payloadsha256,
|
||||||
'publishedversion': publishedversion,
|
'publishedversion': publishedversion,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ def test_seed_creates_display_scope(db):
|
|||||||
# and do NOT inherit common.
|
# and do NOT inherit common.
|
||||||
assert scope.iscommon is False
|
assert scope.iscommon is False
|
||||||
|
|
||||||
# Four Registry drift-heal entries + four inline PS1 (dispatcher, watchdog,
|
# The self-updating client module (File), then four Registry drift-heal
|
||||||
# one-shot Edge force-update, always-on).
|
# entries, then four inline PS1 (dispatcher, watchdog, one-shot Edge
|
||||||
assert summary['entrycount'] == 8
|
# force-update, always-on).
|
||||||
assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry',
|
assert summary['entrycount'] == 9
|
||||||
|
assert summary['entrytypes'] == ['File', 'Registry', 'Registry', 'Registry',
|
||||||
'Registry', 'PS1', 'PS1', 'PS1', 'PS1']
|
'Registry', 'PS1', 'PS1', 'PS1', 'PS1']
|
||||||
|
|
||||||
|
|
||||||
@@ -117,16 +118,17 @@ def test_seed_draft_is_idempotent(db):
|
|||||||
assert first['alwaysonsha256'] == second['alwaysonsha256']
|
assert first['alwaysonsha256'] == second['alwaysonsha256']
|
||||||
assert first['watchdogsha256'] == second['watchdogsha256']
|
assert first['watchdogsha256'] == second['watchdogsha256']
|
||||||
assert first['forceupdatesha256'] == second['forceupdatesha256']
|
assert first['forceupdatesha256'] == second['forceupdatesha256']
|
||||||
|
assert first['clientmodulesha256'] == second['clientmodulesha256']
|
||||||
|
|
||||||
entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all()
|
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 +
|
# Exactly four inline payloads (dispatcher + watchdog + force-update +
|
||||||
# always-on) exist
|
# always-on) exist
|
||||||
# after a rebuild, not more - a payload-count invariant. (The underlying
|
# after a rebuild, not more - a payload-count invariant. (The underlying
|
||||||
# re-publish FK crash only reproduces on MySQL, which enforces the
|
# re-publish FK crash only reproduces on MySQL, which enforces the
|
||||||
# manifestpayloads FK; it was verified there directly. SQLite does not
|
# manifestpayloads FK; it was verified there directly. SQLite does not
|
||||||
# enforce it.)
|
# enforce it.)
|
||||||
assert ManifestPayload.query.count() == 4
|
assert ManifestPayload.query.count() == 5
|
||||||
|
|
||||||
|
|
||||||
def test_build_manifest_has_no_smb_exe_payloads(db):
|
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.
|
# the inline dispatcher; everything else is a Registry policy heal.
|
||||||
for entry in manifest['Applications']:
|
for entry in manifest['Applications']:
|
||||||
assert entry.get('Installer') is None
|
assert entry.get('Installer') is None
|
||||||
assert entry.get('Source') is None
|
# Every payload-bearing entry is delivered INLINE over HTTPS, never from
|
||||||
if entry['Type'] == 'PS1':
|
# a share: a display has no SFLD credentials at all.
|
||||||
|
if entry['Type'] in ('PS1', 'File'):
|
||||||
assert entry.get('PayloadSource') == 'inline'
|
assert entry.get('PayloadSource') == 'inline'
|
||||||
|
else:
|
||||||
|
assert entry.get('Source') is None
|
||||||
|
|
||||||
|
|
||||||
def test_dispatcher_script_is_ascii():
|
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():
|
def test_forceupdate_script_is_ascii():
|
||||||
build_forceedgeupdate_script().encode('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')
|
||||||
|
|||||||
Reference in New Issue
Block a user