computers: collect remote-access protocols (RealVNC and friends)
The accessprotocols / computeraccess tables replaced the old isvnc/iswinrm booleans and the PC page already badges what a machine exposes, but nothing kept them current: the 574 rows in place all came from the legacy migration and have not moved since. The collector schema had no field for them. Adds 'accessprotocols', a list of catalog names, synced with the same discipline as the printer links. A reported protocol is activated; a catalogued one the PC did NOT report is deactivated rather than deleted, so a manual portoverride survives a service being briefly down. An unknown name warns and is skipped: the catalog is admin-managed, and a typo on one bay must not invent a protocol for the whole site. Presence of the key is what drives the sync. A payload without it leaves every existing row untouched, which is what protects the migrated rows from a collector that does not report protocols yet. Six tests cover recording, case-insensitive matching, deactivation on removal, the omitted-key no-op, an explicit empty list meaning "exposes nothing", and that an unknown name never creates a protocol.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user