diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue index ade30e40..a958a62e 100644 --- a/plugins/computers/frontend/views/PCDetail.vue +++ b/plugins/computers/frontend/views/PCDetail.vue @@ -695,6 +695,15 @@ again. Previously collected logs are still readable below. + +
{{ logCollectorVersionNotice }}
+
+ +
+ Bulk Agent Update utility coming soon. Until then, agents update through + the existing installer push, and an endpoint behind the published build is + flagged on its own detail page. +
+ @@ -194,6 +204,9 @@ const toast = useToast() // Gated in the UI for convenience only; every enqueue is re-checked server-side. const cancommand = computed(() => authStore.hasPermission('endpoints.command')) const canrestart = computed(() => authStore.hasPermission('endpoints.restart')) +// Reserved for the staged rollout. Agent replacement is the control plane +// modifying itself, so it stays separate from ordinary app deployment. +const canagentupdate = computed(() => authStore.hasPermission('endpoints.agentupdate')) const allselected = computed(() => endpoints.value.length > 0 && selected.value.length === endpoints.value.length) @@ -515,6 +528,18 @@ function changePerPage(newPerPage) { .bulk-count { font-size: 0.9rem; margin-right: 0.25rem; } +/* Deliberately quieter than .bulk-bar: this announces something that does not + exist yet, so it must not compete with controls that actually do work. */ +.rollout-note { + padding: 0.6rem 0.9rem; + margin-bottom: 0.75rem; + font-size: 0.9rem; + color: var(--text-light); + background: var(--bg-card); + border: 1px dashed var(--border); + border-radius: 0.25rem; +} + .select-cell { width: 1%; } diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py index 7fd1830e..84fb8fad 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -99,6 +99,25 @@ def collector_authorized(pluginname: str): from shopdb.core.api.collector import _check_collector_auth, _plugin_api_key return _check_collector_auth(_plugin_api_key(pluginname), pluginname) + +def application_package_location(): + """Where application installers live, and the URL prefix that serves them. + + Returns (directory, urlprefix). A plugin that registers an installable + payload (the endpoint agent bundle, for example) must put the file exactly + where the applications API expects it and build installpath the same way, + or update_app cannot resolve the download. Reconstructing either by hand + lets the two drift silently, so both come from one place. + + Imported lazily for the same reason as collector_authorized above: + shopdb.core.api imports back into shopdb.api, so a module-level import + would be circular at load time. + """ + from shopdb.core.api.applications import ( + _apppackage_dir, APP_PACKAGE_URL_PREFIX) + return _apppackage_dir(), APP_PACKAGE_URL_PREFIX + + # Serving an uploaded file safely. A plugin that serves user-supplied bytes must # not have to remember the headers that keep an SVG or an HTML file from running # as script in this origin - see shopdb/utils/uploads.py. @@ -388,6 +407,7 @@ __all__ = [ 'list_service_tokens', 'revoke_service_tokens', 'collector_authorized', + 'application_package_location', 'SupportTeam', # Serving uploads 'send_upload', diff --git a/tests/test_plugins/test_endpoints_agent_registration.py b/tests/test_plugins/test_endpoints_agent_registration.py new file mode 100644 index 00000000..29898601 --- /dev/null +++ b/tests/test_plugins/test_endpoints_agent_registration.py @@ -0,0 +1,250 @@ +"""Auto-registration of staged agent builds as a pushable Application. + +The endpoint update_app push resolves an install source from the applications +table alone; it cannot see the agent version store. Before this, a build could +be staged and current while no application row existed at all, which is exactly +what was found in the fleet - the agent was unpushable. + +These tests pin the properties that make the bridge trustworthy: + - staging creates the row, and re-staging updates it in place (never a row + per build, because appname is unique and drift is the failure mode); + - the packaged archive is byte-stable for unchanged input, because update_app + pins a download to a digest and a drifting hash breaks verification; + - every path that changes 'current' re-registers, including rollback; + - registration failure never fails an upload that already landed on disk. + +Uploads use PE-magic bytes with a monkeypatched version parser, matching the +approach in test_endpoints_agentupload.py - building a real PE with an +RT_VERSION resource inline is impractical. + +Every test runs against an ISOLATED store. create_app('testing') resolves +instance_path to the real repo instance/ directory, so without redirection +these tests stage fake 130-byte binaries into the developer's actual agent +store and leave invented versions behind in its manifest. The isolate fixture +below is autouse for that reason: it is a correctness requirement, not a +convenience. +""" + +import hashlib +import io +import os +import zipfile + +import pytest + +from plugins.endpoints.api import agentstore, agentupload, agentregistry +from shopdb.core.models.application import Application + +UPLOAD_URL = '/api/endpoints/siteagent/binaries/{}' +CURRENT_URL = '/api/endpoints/siteagent/binaries/{}/current' + +_PE_BYTES = b'MZ' + b'\x00' * 128 + + +@pytest.fixture(autouse=True) +def isolate_store(tmp_path, monkeypatch): + """Point the agent store and package output at a per-test directory. + + Both must be redirected. Redirecting only the store would still write the + ~zip into the real applicationpackages tree under a live application id. + """ + siteagent = tmp_path / 'siteagent' + packages = tmp_path / 'applicationpackages' + siteagent.mkdir() + packages.mkdir() + monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(siteagent)) + monkeypatch.setattr(agentregistry, 'application_package_location', + lambda: (str(packages), '/api/applications/package/')) + return tmp_path + + +def _upload(client, headers, monkeypatch, version, binkey='agent', + payload=None, makecurrent=None): + """Stage a build through the real HTTP route.""" + monkeypatch.setattr(agentstore, 'read_pe_file_version', lambda data: version) + form = {'file': (io.BytesIO(payload or _PE_BYTES), 'shopdbagent.exe'), + 'version': version} + if makecurrent is not None: + form['makecurrent'] = makecurrent + return client.post(UPLOAD_URL.format(binkey), data=form, + content_type='multipart/form-data', headers=headers) + + +def _agent_row(): + return Application.query.filter_by( + appname=agentregistry.AGENT_APPNAME).first() + + +def test_staging_a_build_creates_a_pushable_application_row( + client, db, app, auth_headers, monkeypatch): + """Without this row, update_app has nothing to resolve and cannot push.""" + assert _agent_row() is None + + response = _upload(client, auth_headers, monkeypatch, '1.16.0.0') + assert response.status_code == 200 + + registration = response.get_json()['data']['registration'] + assert registration['ok'] is True + + row = _agent_row() + assert row is not None + # The row must be resolvable and pinned: a path update_app can fetch, and a + # digest it can verify the bytes against. + assert row.installpath == '/api/applications/package/{}/{}'.format( + row.appid, agentregistry.AGENT_PACKAGE_FILENAME) + assert row.installersha256 == registration['sha256'] + assert row.isinstallable is True + # Hidden: this is machine-managed, and must not appear in the software + # catalogue beside real licensed applications. + assert row.ishidden is True + + +def test_restaging_updates_the_same_row_rather_than_adding_one( + client, db, app, auth_headers, monkeypatch): + """appname is unique; a row per build would break the push target.""" + _upload(client, auth_headers, monkeypatch, '1.16.0.0') + first = _agent_row() + firstid = first.appid + + response = _upload(client, auth_headers, monkeypatch, '1.17.0.0') + assert response.status_code == 200 + + assert Application.query.filter_by( + appname=agentregistry.AGENT_APPNAME).count() == 1 + row = _agent_row() + assert row.appid == firstid + # The row must follow the newly current build, not keep pointing at 1.16. + assert row.installersha256 == \ + response.get_json()['data']['registration']['sha256'] + + +def test_packaging_the_same_build_twice_yields_the_same_digest( + client, db, app, auth_headers, monkeypatch): + """update_app pins bytes by digest. + + zipfile stamps members with the current mtime by default, which made the + archive hash change on every call for identical input. That would rewrite + installersha256 out from under an endpoint mid-download and fail its + SHA-256 verification. + """ + _upload(client, auth_headers, monkeypatch, '1.16.0.0') + + firstpayload, firstmanifest = agentstore.build_current_package() + secondpayload, secondmanifest = agentstore.build_current_package() + + assert firstmanifest['sha256'] == secondmanifest['sha256'] + assert firstpayload == secondpayload + + +def test_the_package_contains_the_staged_binary_under_a_fixed_name( + client, db, app, auth_headers, monkeypatch): + """The install script addresses members by name, not by discovery.""" + _upload(client, auth_headers, monkeypatch, '1.16.0.0') + + payload, manifest = agentstore.build_current_package() + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + assert archive.testzip() is None + names = archive.namelist() + assert agentstore.PACKAGE_MEMBER_NAMES['agent'] in names + assert manifest['agentversion'] == '1.16.0.0' + assert manifest['contents']['agent']['version'] == '1.16.0.0' + + +def test_the_written_package_file_matches_the_recorded_digest( + client, db, app, auth_headers, monkeypatch): + """A row whose digest disagrees with the file on disk is unpushable.""" + _upload(client, auth_headers, monkeypatch, '1.16.0.0') + row = _agent_row() + + path = agentregistry._package_path(row.appid) + assert os.path.isfile(path) + with open(path, 'rb') as handle: + ondisk = hashlib.sha256(handle.read()).hexdigest() + assert ondisk == row.installersha256 + # The atomic-write temp file must not survive a successful write. + assert not os.path.isfile(path + '.tmp') + + +def test_rolling_back_repoints_the_row_at_the_restored_build( + client, db, app, auth_headers, monkeypatch): + """A rollback changes the bytes the fleet should get. + + If only upload re-registered, the row would still pin the build the + operator just rolled away from, and the push would ship it anyway. + """ + _upload(client, auth_headers, monkeypatch, '1.16.0.0', + payload=b'MZ' + b'\x01' * 256) + oldsha = _agent_row().installersha256 + + _upload(client, auth_headers, monkeypatch, '1.17.0.0', + payload=b'MZ' + b'\x02' * 256) + newsha = _agent_row().installersha256 + assert newsha != oldsha + + response = client.put(CURRENT_URL.format('agent'), + json={'version': '1.16.0.0'}, headers=auth_headers) + assert response.status_code == 200 + assert response.get_json()['data']['registration']['ok'] is True + + assert agentstore.current_version('agent') == '1.16.0.0' + # Back to the rolled-to build's package, not the newer one. + assert _agent_row().installersha256 == oldsha + + +def test_a_registration_failure_does_not_fail_the_upload( + client, db, app, auth_headers, monkeypatch): + """The binary is already staged and audited by the time this runs. + + Turning a packaging problem into an error response would tell the operator + the upload was rejected when it actually succeeded, inviting a re-upload of + a build that is already there. + """ + def _explode(): + raise OSError('simulated packaging failure') + + monkeypatch.setattr(agentregistry.agentstore, 'build_current_package', + _explode) + + response = _upload(client, auth_headers, monkeypatch, '1.16.0.0') + + assert response.status_code == 200 + body = response.get_json()['data'] + # The staging itself still stands... + assert body['current'] == '1.16.0.0' + # ...and the failure is surfaced rather than swallowed. + assert body['registration']['ok'] is False + assert 'simulated packaging failure' in body['registration']['error'] + + +def test_staging_only_a_tray_reports_that_there_is_nothing_to_package( + client, db, app, auth_headers, monkeypatch): + """The bundle is defined by the agent; a tray alone is not pushable.""" + response = _upload(client, auth_headers, monkeypatch, '1.6.5.0', + binkey='tray') + + assert response.status_code == 200 + registration = response.get_json()['data']['registration'] + assert registration['ok'] is False + assert 'no current agent build' in registration['error'] + assert _agent_row() is None + + +def test_these_tests_do_not_touch_the_real_agent_store( + client, db, app, auth_headers, monkeypatch, isolate_store): + """Guard the isolation itself. + + An earlier draft of this file wrote fake 130-byte binaries into the repo's + real instance/siteagent/ tree and invented a version in its manifest, + because create_app('testing') shares the developer's instance_path. If that + redirection ever regresses, fail here rather than silently corrupting a + staged build again. + """ + _upload(client, auth_headers, monkeypatch, '9.9.9.9') + + # The staged build landed inside the per-test directory... + staged = isolate_store / 'siteagent' / 'agent' / '9.9.9.9' + assert staged.is_dir() + + # ...and the real store never saw it. + realstore = os.path.join(app.instance_path, 'siteagent', 'agent', '9.9.9.9') + assert not os.path.exists(realstore) diff --git a/tests/test_plugins/test_endpoints_agent_version_guard.py b/tests/test_plugins/test_endpoints_agent_version_guard.py new file mode 100644 index 00000000..110949a0 --- /dev/null +++ b/tests/test_plugins/test_endpoints_agent_version_guard.py @@ -0,0 +1,158 @@ +"""The agent version guard on command enqueue. + +An agent older than an action has no case for it and falls through to its +unknown-action default, answering "Unknown or disallowed action". That reads as +an authorization refusal, so the operator goes looking at permissions they +already hold - which is exactly what happened on a live endpoint running 1.15.0 +after collect_log shipped in 1.16.0. + +These tests pin the refusal at the SERVER, before the command is ever queued, +and pin that the message names both versions so the answer is actionable. +""" + +from werkzeug.security import generate_password_hash + + +def _endpoint(db, hostname, agentversion): + """An Asset + Computer pair whose reported state carries an agent version.""" + from shopdb.api import Asset, AssetType + from plugins.computers.models import Computer + from plugins.endpoints.models import EndpointState + + assettype = AssetType.query.filter_by(assettype='computer').first() + if assettype is None: + assettype = AssetType(assettype='computer', pluginname='computers', + tablename='computers') + db.session.add(assettype) + db.session.flush() + + asset = Asset(assetnumber=hostname, name=hostname, + assettypeid=assettype.assettypeid, isactive=True) + db.session.add(asset) + db.session.flush() + db.session.add(Computer(assetid=asset.assetid, hostname=hostname)) + db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname, + agentversion=agentversion)) + db.session.commit() + return asset.assetid + + +def _admin(db, client, username='guardadmin'): + """An admin, so permission and policy gates cannot be what refuses.""" + from shopdb.core.models import User, Role, Permission + + Permission.seed() + db.session.flush() + role = Role.query.filter_by(rolename='admin').first() + if role is None: + role = Role(rolename='admin') + db.session.add(role) + db.session.commit() + + user = User(username=username, email=username + '@test.local', + passwordhash=generate_password_hash('testpass')) + user.roles.append(role) + db.session.add(user) + db.session.commit() + + response = client.post('/api/auth/login', + json={'username': username, 'password': 'testpass'}) + token = response.get_json()['data']['access_token'] + return {'Authorization': f'Bearer {token}'} + + +def _collect(client, headers, assetid): + return client.post(f'/api/endpoints/{assetid}/commands', + json={'action': 'collect_log', + 'target': r'C:\logs\app.log'}, + headers=headers) + + +def test_an_agent_below_the_minimum_is_refused_at_enqueue(app, client, db): + """The command must not be queued at all. + + Queueing it would spend the endpoint's one in-flight command slot and leave + a failed row that reads as endpoint misbehaviour, when the truth is the + agent simply predates the feature. + """ + from plugins.endpoints.models import EndpointCommand + + assetid = _endpoint(db, 'oldagentbox', '1.15.0.0') + headers = _admin(db, client) + + response = _collect(client, headers, assetid) + + assert response.status_code == 400 + message = response.get_json()['data']['error']['message'] + # Both versions, or the operator cannot tell what to do about it. + assert '1.16.0' in message + assert '1.15.0.0' in message + assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0 + + +def test_an_agent_at_the_minimum_is_allowed(app, client, db): + """The guard must not refuse a capable endpoint. + + 1.16.0.0 against a 1.16.0 floor is the four-vs-three-part spelling of the + same build; a lexical compare would get this wrong. + """ + from plugins.endpoints.models import EndpointCommand + + assetid = _endpoint(db, 'newagentbox', '1.16.0.0') + headers = _admin(db, client) + + response = _collect(client, headers, assetid) + + assert response.status_code in (200, 201) + assert EndpointCommand.query.filter_by(assetid=assetid, + action='collect_log').count() == 1 + + +def test_a_lexically_larger_but_older_version_is_still_refused(app, client, db): + """'1.9.0.0' must not pass a '1.16.0' floor. + + String comparison says '9' > '1' and lets it through. This is the specific + bug the numeric parse exists to prevent, so it gets its own test rather + than living only in the helper's unit coverage. + """ + assetid = _endpoint(db, 'lexicaltrapbox', '1.9.0.0') + headers = _admin(db, client) + + response = _collect(client, headers, assetid) + + assert response.status_code == 400 + assert '1.16.0' in response.get_json()['data']['error']['message'] + + +def test_an_unknown_agent_version_is_refused_rather_than_guessed(app, client, db): + """A placeholder version fails closed. + + Seeded rows carry '0.0.0-sample'. There is no honest ordering between a + placeholder and a real build, and letting it through only moves the failure + to the agent where the error message is the misleading one. + """ + assetid = _endpoint(db, 'samplebox', '0.0.0-sample') + headers = _admin(db, client) + + response = _collect(client, headers, assetid) + + assert response.status_code == 400 + + +def test_an_action_without_a_floor_is_unaffected(app, client, db): + """The guard is per-action, not global. + + collect_now declares no minimum, so an old agent must still be able to run + it - otherwise adding a floor to one action quietly narrows the others. + """ + from plugins.endpoints.models import EndpointCommand + + assetid = _endpoint(db, 'refreshbox', '1.11.0.0') + headers = _admin(db, client) + + response = client.post(f'/api/endpoints/{assetid}/commands', + json={'action': 'collect_now'}, headers=headers) + + assert response.status_code in (200, 201) + assert EndpointCommand.query.filter_by(assetid=assetid, + action='collect_now').count() == 1 diff --git a/tests/test_plugins/test_endpoints_plugin.py b/tests/test_plugins/test_endpoints_plugin.py index f6b90c2d..775d6bdf 100644 --- a/tests/test_plugins/test_endpoints_plugin.py +++ b/tests/test_plugins/test_endpoints_plugin.py @@ -11,7 +11,8 @@ from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS from plugins.endpoints.commands import ( COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for, - requires_target, + requires_target, minimum_agent_version_for, agent_meets_minimum, + parse_agent_version, ) @@ -216,6 +217,61 @@ def test_collect_log_survives_a_policy_that_never_mentions_it(): assert elevated_permission_for('collect_log') == 'endpoints.updateapp' +def test_collect_log_declares_the_agent_version_that_implements_it(): + """collect_log names its minimum agent version. + + An agent older than the action has no case for it and falls through to its + unknown-action default, answering "Unknown or disallowed action" - which + reads as an authorization refusal and sends the operator to check a grant + they already hold. The floor is what lets the server refuse first with the + actionable answer instead. + """ + assert minimum_agent_version_for('collect_log') == '1.16.0' + # Actions that every shipped agent understands must NOT declare a floor, + # or the guard starts refusing endpoints that are perfectly capable. + assert minimum_agent_version_for('collect_now') is None + assert minimum_agent_version_for('system_restart') is None + + +def test_agent_version_comparison_survives_real_fleet_version_strings(): + """Version comparison is numeric per part, not lexical. + + Two traps, both real in this fleet. Agents report four parts ('1.15.0.0') + while the csproj and binary store carry three ('1.16.0'), so the same build + must compare equal across both spellings. And a lexical compare ranks + '1.9.0.0' ABOVE '1.16.0' because '9' > '1' - which would silently let an + old agent past the guard, the exact failure the guard exists to prevent. + """ + assert parse_agent_version('1.16.0') == parse_agent_version('1.16.0.0') + assert agent_meets_minimum('1.16.0', '1.16.0') + assert agent_meets_minimum('1.16.0.0', '1.16.0') + assert agent_meets_minimum('1.17.0.0', '1.16.0') + assert agent_meets_minimum('2.0.0.0', '1.16.0') + assert not agent_meets_minimum('1.15.0.0', '1.16.0') + assert not agent_meets_minimum('1.11.0.0', '1.16.0') + # The lexical trap. + assert not agent_meets_minimum('1.9.0.0', '1.16.0') + + +def test_an_unreadable_agent_version_fails_the_guard_closed(): + """An unparseable or absent version does not satisfy a minimum. + + Eight endpoints in this fleet report '0.0.0-sample', and a never-checked-in + endpoint reports nothing at all. There is no honest ordering between a + placeholder and a real build, so the guard refuses rather than guessing. + Letting it through would only move the failure to the agent, where the + error message is the misleading one. + """ + assert parse_agent_version('0.0.0-sample') is None + assert not agent_meets_minimum('0.0.0-sample', '1.16.0') + assert not agent_meets_minimum(None, '1.16.0') + assert not agent_meets_minimum('', '1.16.0') + # No declared floor means no opinion: an action every agent implements must + # stay available even to an endpoint whose version we cannot read. + assert agent_meets_minimum('0.0.0-sample', None) + assert agent_meets_minimum(None, None) + + def test_run_script_and_update_app_are_allowlisted_delivery_actions(): """The two software-delivery actions are allowlisted with a targetkind so the enqueue route accepts them and carries their subject on the wire: