diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index fee7774..d6c6dbc 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -124,6 +124,18 @@ class ComputersPlugin(BasePlugin): '(Win32_Printer): windows name / share / ' 'hostname / IP. Unresolved -> warning.'), }, + 'accessprotocols': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': ('Remote-access protocols this PC actually ' + 'exposes, by catalog name (VNC, WinRM, ' + 'RDP). Presence of the key drives the sync: ' + 'reported protocols are activated and ' + 'catalogued ones not reported are ' + 'deactivated. Omit the key entirely to ' + 'leave existing rows alone - most came from ' + 'the legacy isvnc/iswinrm migration.'), + }, }, } @@ -262,6 +274,9 @@ class ComputersPlugin(BasePlugin): computerid=comp.computerid, appid=app.appid, installedversion=version)) + # Remote-access protocol sync (only when the payload carried the key). + accessprotocols = self._sync_access_protocols(comp, payload, warnings) + # Printer relationship sync (only when the payload carried printer data). printerlinks = self._sync_printer_links(comp.asset, payload, warnings) @@ -281,11 +296,75 @@ class ComputersPlugin(BasePlugin): 'printerlinkcount': len(printerlinks), 'measuringtoollinks': measuringtoollinks, 'measuringtoollinkcount': len(measuringtoollinks), + 'accessprotocols': accessprotocols, }, } # -- printer relationship sync ----------------------------------------- + def _sync_access_protocols(self, comp, payload, warnings): + """Idempotently sync a PC's remote-access protocols from the collector. + + Payload key 'accessprotocols' is a list of protocol NAMES as they appear + in the accessprotocols catalog ('VNC', 'WinRM', 'RDP'), matched + case-insensitively. An unknown name warns and is skipped rather than + creating a protocol: the catalog is admin-managed on purpose, so a + typo on one bay must not invent a protocol for the whole site. + + Presence of the key drives the sync, exactly like the printer links: a + reported protocol is activated, and a catalogued protocol the PC did + NOT report is deactivated (not deleted, so a port override survives a + temporary outage). A payload with no 'accessprotocols' key leaves every + existing row untouched - most PCs' rows came from the legacy + isvnc/iswinrm migration and must not be wiped by a collector that + simply does not report them yet. + + Returns the list of active protocol names after the sync. + """ + from plugins.computers.models import AccessProtocol, ComputerAccess + + if 'accessprotocols' not in payload: + return [] + + reported = payload.get('accessprotocols') or [] + if not isinstance(reported, list): + warnings.append('accessprotocols must be a list of protocol names') + return [] + + wanted = set() + for name in reported: + name = str(name or '').strip() + if not name: + continue + protocol = AccessProtocol.query.filter( + AccessProtocol.name.ilike(name)).first() + if not protocol: + warnings.append('unknown access protocol: {}'.format(name)) + continue + wanted.add(protocol.protocolid) + + existing = {row.protocolid: row for row in + ComputerAccess.query.filter_by(computerid=comp.computerid).all()} + + for protocolid in wanted: + row = existing.get(protocolid) + if row: + row.isactive = True + else: + db.session.add(ComputerAccess( + computerid=comp.computerid, protocolid=protocolid, + isactive=True)) + + # Deactivate what the PC no longer exposes. Kept as rows so a manual + # portoverride is not lost the first time a service is briefly down. + for protocolid, row in existing.items(): + if protocolid not in wanted: + row.isactive = False + + names = [p.name for p in AccessProtocol.query.filter( + AccessProtocol.protocolid.in_(wanted)).all()] if wanted else [] + return sorted(names) + def _sync_printer_links(self, pcasset, payload, warnings): """Idempotently sync PC->printer relationships from collector printer data. diff --git a/tests/test_core/test_collector_contract.py b/tests/test_core/test_collector_contract.py index 175b0db..e6a13dd 100644 --- a/tests/test_core/test_collector_contract.py +++ b/tests/test_core/test_collector_contract.py @@ -432,3 +432,99 @@ def test_repurposed_pc_archives_tool_link(client, db, collector_key, label='collector:measuringtool').all() assert len(rels) == 1 assert rels[0].isactive is False + + +# ============================================================================= +# Remote-access protocol sync (RealVNC and friends) +# ============================================================================= + +@pytest.fixture +def access_protocols(db): + """Seed the admin-managed protocol catalog the collector matches against.""" + from plugins.computers.models import AccessProtocol + + db.session.add_all([ + AccessProtocol(name='VNC', scheme='vnc', defaultport=5900, + linktemplate='vnc://{host}:{port}'), + AccessProtocol(name='WinRM', scheme='https', defaultport=5986, + linktemplate='https://{host}:{port}'), + ]) + db.session.commit() + + +def _post(client, collector_key, payload): + return client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': collector_key}) + + +def _active_protocols(hostname): + from plugins.computers.models import Computer, ComputerAccess, AccessProtocol + comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first() + rows = ComputerAccess.query.filter_by(computerid=comp.computerid, + isactive=True).all() + ids = [r.protocolid for r in rows] + return sorted(p.name for p in AccessProtocol.query.filter( + AccessProtocol.protocolid.in_(ids)).all()) if ids else [] + + +def test_reported_vnc_is_recorded(client, db, collector_key, + computer_assettype, access_protocols): + response = _post(client, collector_key, + {'hostname': 'BAY001', 'accessprotocols': ['VNC']}) + assert response.status_code == 200, response.get_json() + assert _active_protocols('BAY001') == ['VNC'] + + +def test_protocol_match_is_case_insensitive(client, db, collector_key, + computer_assettype, access_protocols): + _post(client, collector_key, + {'hostname': 'BAY002', 'accessprotocols': ['vnc']}) + assert _active_protocols('BAY002') == ['VNC'] + + +def test_protocol_no_longer_reported_is_deactivated(client, db, collector_key, + computer_assettype, + access_protocols): + """RealVNC removed from a bay must stop showing as available.""" + _post(client, collector_key, + {'hostname': 'BAY003', 'accessprotocols': ['VNC', 'WinRM']}) + assert _active_protocols('BAY003') == ['VNC', 'WinRM'] + _post(client, collector_key, + {'hostname': 'BAY003', 'accessprotocols': ['WinRM']}) + assert _active_protocols('BAY003') == ['WinRM'] + + +def test_omitting_the_key_leaves_existing_rows_alone(client, db, collector_key, + computer_assettype, + access_protocols): + """Most rows came from the legacy isvnc/iswinrm migration. A collector that + does not report protocols yet must not wipe them.""" + _post(client, collector_key, + {'hostname': 'BAY004', 'accessprotocols': ['VNC']}) + _post(client, collector_key, {'hostname': 'BAY004', 'loggedinuser': 'someone'}) + assert _active_protocols('BAY004') == ['VNC'] + + +def test_empty_list_deactivates_everything(client, db, collector_key, + computer_assettype, access_protocols): + """An explicit empty list means 'this PC exposes nothing' - distinct from + omitting the key.""" + _post(client, collector_key, + {'hostname': 'BAY005', 'accessprotocols': ['VNC']}) + _post(client, collector_key, {'hostname': 'BAY005', 'accessprotocols': []}) + assert _active_protocols('BAY005') == [] + + +def test_unknown_protocol_warns_and_does_not_create_one(client, db, collector_key, + computer_assettype, + access_protocols): + """The catalog is admin-managed: a typo on one bay must not invent a + protocol for the whole site.""" + from plugins.computers.models import AccessProtocol + + response = _post(client, collector_key, + {'hostname': 'BAY006', 'accessprotocols': ['TeamViewer']}) + body = response.get_json()['data'] + assert any('TeamViewer' in w for w in body['warnings']) + assert AccessProtocol.query.filter( + AccessProtocol.name.ilike('TeamViewer')).first() is None