Fix user-context collection, split agent log destinations, widen upload cap
Mapped drives stopped reporting because the user-context path failed silently in three separate places, and none of them left a trace to read back. - Probe: launch via -EncodedCommand instead of staging a temp .ps1 and running powershell.exe -File. A missing or quarantined script file fails BEFORE any statement runs, so the script's own try/catch never sees it and the only symptom is exit -196608. - Spool: pin to the ShopFloor profile. Resolving it through WTSQueryUserToken is privileged, endpoint protection blocks it, and on failure the tray (writer) and service (reader) silently disagree about the path. - Spool: consume after a successful parse, so a stopped tray cannot keep re-reporting stale mapped drives as current. Unparseable files are left in place for the next cycle. - Logging: user-session components cannot write to Program Files or to the SYSTEM-owned ProgramData log directory, and the file sink degrades to a silent no-op when it cannot create its directory. Tray and probe now log to C:\Users\ShopFloor\ShopDB\Logs, installs transcript to ProgramData, and the sink records a SinkProblem instead of swallowing UnauthorizedAccessException. - Installer: provision the operator spool/log directories with ShopFloor:Modify before the tray first runs, and stop the transcript via trap so a throw cannot orphan exactly the failed install worth reading. - Upload cap 64 -> 128 MB: the self-contained tray publishes at ~69 MB and was rejected on SIZE, but the size check returns before the PE is parsed, so the operator only ever saw a version-detection failure. AGENT-CONTRACT.md gains a normative section defining the three security contexts, their log destinations, the fixed spool path and consume-on-read. Agent 1.8.0 -> 1.8.1, tray 1.1.0 -> 1.1.1 so the deployed build is identifiable from a log line.
This commit is contained in:
@@ -1413,6 +1413,137 @@ def panel_interfaces(asset_id: int):
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
# Columns for the two cards carved out of the old hand-written Network card on
|
||||
# the Details tab. That card listed current addresses with a Primary badge and
|
||||
# nothing else, which undersold what the rows actually hold.
|
||||
_IPHISTORY_COLUMNS = [
|
||||
{'key': 'ipaddress', 'label': 'IP address'},
|
||||
{'key': 'macaddress', 'label': 'MAC'},
|
||||
{'key': 'status', 'label': 'Status'},
|
||||
{'key': 'firstseen', 'label': 'First seen'},
|
||||
{'key': 'lastchanged', 'label': 'Last changed'},
|
||||
]
|
||||
|
||||
_IPCONFIG_COLUMNS = [
|
||||
{'key': 'ipaddress', 'label': 'IP address'},
|
||||
{'key': 'subnetmask', 'label': 'Subnet mask'},
|
||||
{'key': 'gateway', 'label': 'Gateway'},
|
||||
{'key': 'dns1', 'label': 'DNS 1'},
|
||||
{'key': 'dns2', 'label': 'DNS 2'},
|
||||
{'key': 'assignment', 'label': 'Assignment'},
|
||||
]
|
||||
|
||||
|
||||
def _isostamp(value):
|
||||
"""ISO-8601 with a Z suffix, matching every other timestamp this file emits.
|
||||
|
||||
Formatting is left to the client so it can render in the viewer's locale
|
||||
and timezone; returning a preformatted string here would pin every reader
|
||||
to the server's.
|
||||
"""
|
||||
return value.isoformat() + 'Z' if value else None
|
||||
|
||||
|
||||
def _communication_rows(asset_id: int):
|
||||
"""Every Communication row for an asset, newest change first.
|
||||
|
||||
These rows are an ACCIDENTAL history rather than a designed one.
|
||||
_record_interfaces in the computers plugin matches an incoming NIC on MAC
|
||||
then on IP and inserts when neither matches, and it never deletes - so an
|
||||
address the machine has not held for a year is still sitting here next to
|
||||
the current one. That is genuinely useful (it answers "what was this box on
|
||||
before?"), so it is surfaced as history rather than cleaned away.
|
||||
"""
|
||||
from shopdb.api import Communication
|
||||
|
||||
# MySQL has no NULLS LAST, so sort on an explicit "is null" key first.
|
||||
# Rows that never got a modifieddate sink to the bottom on every dialect.
|
||||
return (Communication.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(Communication.modifieddate.is_(None),
|
||||
Communication.modifieddate.desc(),
|
||||
Communication.communicationid.desc())
|
||||
.all())
|
||||
|
||||
|
||||
def _iphistory_status(comm):
|
||||
"""What this row IS, in the vocabulary somebody troubleshooting would use.
|
||||
|
||||
Primary is the address the collector last picked as this box's main one.
|
||||
APIPA is called out by name because a 169.254.x.x address is not an address
|
||||
the machine was given - it is what Windows assigns when DHCP failed, so its
|
||||
presence in the list is a record of a past network fault, not a past
|
||||
subnet.
|
||||
"""
|
||||
address = (comm.ipaddress or '').strip()
|
||||
if address.startswith('169.254.'):
|
||||
return 'APIPA (no DHCP)'
|
||||
if comm.isprimary:
|
||||
return 'Primary'
|
||||
if comm.ismachinenetwork:
|
||||
return 'Machine network'
|
||||
return 'Previous'
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/iphistory', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_iphistory(asset_id: int):
|
||||
"""Table panel: every address this asset has held, newest change first.
|
||||
|
||||
Replaces the hand-written Network card that used to sit on the Details tab
|
||||
and show only the current addresses. Dates come from the row's own
|
||||
lifecycle columns, so they are labelled for what they are: 'Last changed'
|
||||
is when a field on the row last moved, NOT when the address was last seen.
|
||||
An address the machine still holds and reports unchanged does not update
|
||||
modifieddate, so calling that column 'Last seen' would be a lie the data
|
||||
cannot support.
|
||||
"""
|
||||
rows = []
|
||||
for comm in _communication_rows(asset_id):
|
||||
address = (comm.ipaddress or '').strip()
|
||||
if not address:
|
||||
continue
|
||||
rows.append({
|
||||
'ipaddress': address,
|
||||
'macaddress': (comm.macaddress or '').strip() or '-',
|
||||
'status': _iphistory_status(comm),
|
||||
'firstseen': _isostamp(comm.createddate),
|
||||
'lastchanged': _isostamp(comm.modifieddate),
|
||||
})
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/ipconfig', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_ipconfig(asset_id: int):
|
||||
"""Table panel: subnet, gateway and DNS per recorded address.
|
||||
|
||||
Split from IP history rather than folded into it: history answers "what has
|
||||
this box been", configuration answers "how is this address set up", and one
|
||||
table trying to do both would be eleven columns wide. Rows with no
|
||||
configuration recorded are dropped - an address with no subnet, gateway or
|
||||
DNS contributes nothing here and already appears in the history card.
|
||||
"""
|
||||
rows = []
|
||||
for comm in _communication_rows(asset_id):
|
||||
address = (comm.ipaddress or '').strip()
|
||||
subnet = (comm.subnetmask or '').strip()
|
||||
gateway = (comm.gateway or '').strip()
|
||||
dns1 = (comm.dns1 or '').strip()
|
||||
dns2 = (comm.dns2 or '').strip()
|
||||
if not address or not (subnet or gateway or dns1 or dns2):
|
||||
continue
|
||||
rows.append({
|
||||
'ipaddress': address,
|
||||
'subnetmask': subnet or '-',
|
||||
'gateway': gateway or '-',
|
||||
'dns1': dns1 or '-',
|
||||
'dns2': dns2 or '-',
|
||||
'assignment': 'DHCP' if comm.isdhcp else 'Static',
|
||||
})
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/applications', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_applications(asset_id: int):
|
||||
|
||||
Reference in New Issue
Block a user