Add repeatable read-only host clock inventory and live receipt

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a09cbc-910e-77a0-b336-3130194350b3
This commit is contained in:
tegwick 2026-09-14 13:19:22 +02:00
parent 56c418f09c
commit fb09e138b8
4 changed files with 200 additions and 0 deletions

View file

@ -0,0 +1,47 @@
{
"schema_version": 1,
"host_alias": "railiance01",
"collector_reported_at": "2026-09-14T10:27:19.220821+00:00",
"read_only": true,
"authority_usable": null,
"synchronized_reported": true,
"limitations": [
"OS synchronization flag is not an independent UTC error bound",
"host and collector timestamps are observations, not authenticated time samples",
"SSH uses existing caller identity/trust; no sudo, installation or time mutation"
],
"collector_sha256": "3aadb6fb888d87cefb63e94ac87c9c640ef63ef6f404b09a01e8e8642b26784e",
"remote_script_sha256": "597e08b95855c4bd1fee40ad8e7c1a8359312c1583ac8363e5bd922b493f074a",
"inventory": {
"remote_reported_at": "2026-09-14T10:27:21.992499+00:00",
"commands": {
"clock": {
"returncode": 0,
"stdout": "Timezone=Etc/UTC\nLocalRTC=no\nNTP=yes\nNTPSynchronized=yes\n"
},
"upstream": {
"returncode": 0,
"stdout": "LinkNTPServers=\nSystemNTPServers=\nRuntimeNTPServers=\nFallbackNTPServers=ntp.ubuntu.com\nServerName=ntp.ubuntu.com\nServerAddress=2620:2d:4000:1::3f\nRootDistanceMaxUSec=5s\nPollIntervalMinUSec=32s\nPollIntervalMaxUSec=34min 8s\nPollIntervalUSec=34min 8s\nNTPMessage={ Leap=0, Version=4, Mode=4, Stratum=2, Precision=-25, RootDelay=6.469ms, RootDispersion=167us, Reference=1D586304, OriginateTimestamp=Mon 2026-09-14 10:04:40 UTC, ReceiveTimestamp=Mon 2026-09-14 10:04:40 UTC, TransmitTimestamp=Mon 2026-09-14 10:04:40 UTC, DestinationTimestamp=Mon 2026-09-14 10:04:40 UTC, Ignored=no, PacketCount=397, Jitter=1.952ms }\nFrequency=1182270\n"
},
"timesyncd": {
"returncode": 0,
"stdout": "User=systemd-timesync\nActiveState=active\nFragmentPath=/usr/lib/systemd/system/systemd-timesyncd.service\nDropInPaths=\nUnitFileState=enabled\n"
},
"other_daemons": {
"returncode": 4,
"stdout": "inactive\ninactive\ninactive\n"
},
"virtualization": {
"returncode": 0,
"stdout": "kvm\n"
},
"ntp_listeners": {
"returncode": 0,
"stdout": ""
}
},
"config_sha256": {
"/etc/systemd/timesyncd.conf": "e6734751f8aaf19fddfff891ad246387f5f59bd9ff1a5f0cac2c34bc81941c62"
}
}
}

View file

@ -0,0 +1,44 @@
import importlib.util
import json
from pathlib import Path
from types import SimpleNamespace
import unittest
spec = importlib.util.spec_from_file_location('observe', Path(__file__).resolve().parents[1]/'tools/observe_host_clock.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class ObservationTests(unittest.TestCase):
def response(self, code=0, text='NTPSynchronized=yes\n'):
return SimpleNamespace(returncode=0, stdout=json.dumps({'commands': {'clock': {'returncode':code,'stdout':text}}}))
def test_reported_sync_is_not_accuracy_or_authority(self):
result=module.observe('railiance01', runner=lambda *a, **k:self.response())
self.assertTrue(result['synchronized_reported'])
self.assertIsNone(result['authority_usable'])
def test_failed_clock_command_remains_unknown(self):
result=module.observe('railiance01',runner=lambda *a, **k:self.response(1))
self.assertIsNone(result['synchronized_reported'])
def test_missing_sync_property_remains_unknown(self):
result=module.observe('railiance01',runner=lambda *a, **k:self.response(0, ''))
self.assertIsNone(result['synchronized_reported'])
def test_ssh_failure_refuses_success_without_echoing_stderr(self):
with self.assertRaisesRegex(ValueError,'SSH inventory failed'):
module.observe('railiance01',runner=lambda *a, **k:SimpleNamespace(returncode=255,stderr='not-for-evidence'))
def test_host_cannot_inject_options_or_shell(self):
for host in ['-oProxyCommand=bad','host;bad','host bad','$(bad)']:
with self.subTest(host=host), self.assertRaises(ValueError):
module.observe(host,runner=lambda *a, **k:self.fail('network called'))
def test_transport_uses_fixed_read_only_script(self):
def run(argv, **kwargs):
self.assertEqual(argv[-2:], ['railiance01','python3 -'])
self.assertEqual(kwargs['input'],module.REMOTE)
self.assertNotIn('shell',kwargs)
return self.response()
module.observe('railiance01',runner=run)

24
tools/README.md Normal file
View file

@ -0,0 +1,24 @@
# Host clock inventory
From the repository root, using an existing SSH alias and trust configuration:
```sh
python3 tools/observe_host_clock.py railiance01 --output docs/evidence/UNIQUE-host-clock-inventory.json
python3 -m unittest discover -s tests -v
```
Choose a unique output name for every observation. The collector refuses to
replace an existing file. The remote host needs Python 3 and the systemd/ss tools;
individual unavailable commands are recorded as failed observations. SSH failure
aborts collection. No sudo, installation or host configuration change is performed.
The JSON records daemon/upstream metadata, configuration hashes and command exit
codes. `synchronized_reported` is an OS report; `authority_usable` remains null.
Neither host nor workstation observation timestamps are authenticated Railiance
time, and this tool cannot establish an independent UTC error bound. It supports
RCLK-WP-0005-T01 inventory; service acceptance remains in the specification and
later workplan tasks.
The 2026-09-14 live JSON receipt accompanies the human-readable host inventory.
Six unit tests cover transport failure, host argument validation, fixed command
transport and unknown-versus-synchronized semantics.

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Read-only SSH inventory; reported synchronization is not a UTC accuracy proof."""
import argparse
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import re
import subprocess
REMOTE = r'''
import hashlib,json,subprocess
from datetime import datetime,timezone
from pathlib import Path
commands = {
'clock': ['timedatectl','show','--property=Timezone','--property=NTPSynchronized','--property=NTP','--property=LocalRTC'],
'upstream': ['timedatectl','show-timesync','--all'],
'timesyncd': ['systemctl','show','systemd-timesyncd','--property=ActiveState','--property=UnitFileState','--property=FragmentPath','--property=DropInPaths','--property=User'],
'other_daemons': ['systemctl','is-active','chrony','chronyd','ntp'],
'virtualization': ['systemd-detect-virt'],
'ntp_listeners': ['ss','-H','-lun','sport','=',':123'],
}
rows={}
for name,argv in commands.items():
try:
result=subprocess.run(argv,capture_output=True,text=True,timeout=10)
rows[name]={'returncode':result.returncode,'stdout':result.stdout[:32768]}
except (OSError,subprocess.TimeoutExpired):
rows[name]={'returncode':None,'error':'command_unavailable_or_timeout'}
files=[Path('/etc/systemd/timesyncd.conf')]
for directory in ['/etc/systemd/timesyncd.conf.d','/run/systemd/timesyncd.conf.d','/usr/lib/systemd/timesyncd.conf.d']:
files.extend(sorted(Path(directory).glob('*.conf')))
hashes={}
for p in files:
try:hashes[str(p)]=hashlib.sha256(p.read_bytes()).hexdigest()
except OSError:hashes[str(p)]=None
print(json.dumps({'remote_reported_at':datetime.now(timezone.utc).isoformat(),'commands':rows,'config_sha256':hashes}))
'''
def observe(host, runner=subprocess.run):
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.@-]*', host):
raise ValueError('host must be an SSH alias or hostname, not options or shell text')
result = runner(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10',
host, 'python3 -'], input=REMOTE, text=True,
capture_output=True, timeout=90)
if result.returncode != 0:
raise ValueError('SSH inventory failed; no usable inventory was produced')
if len(result.stdout.encode()) > 256 * 1024:
raise ValueError('inventory exceeded size limit')
data = json.loads(result.stdout)
if not isinstance(data, dict) or not isinstance(data.get('commands'), dict):
raise ValueError('invalid inventory shape')
clock = data['commands'].get('clock', {})
properties = dict(line.split('=', 1) for line in clock.get('stdout', '').splitlines() if '=' in line)
return {'schema_version': 1, 'host_alias': host,
'collector_reported_at': datetime.now(timezone.utc).isoformat(),
'read_only': True, 'authority_usable': None,
'synchronized_reported': {'yes': True, 'no': False}.get(properties.get('NTPSynchronized')) if clock.get('returncode') == 0 else None,
'limitations': ['OS synchronization flag is not an independent UTC error bound',
'host and collector timestamps are observations, not authenticated time samples',
'SSH uses existing caller identity/trust; no sudo, installation or time mutation'],
'collector_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
'remote_script_sha256': hashlib.sha256(REMOTE.encode()).hexdigest(),
'inventory': data}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('host')
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
try:
result = observe(args.host)
# Refuse overwriting historical observations.
with args.output.open('x') as stream:
json.dump(result, stream, indent=2)
stream.write('\n')
except (OSError, ValueError, subprocess.TimeoutExpired):
parser.exit(1, 'Inventory failed or output already exists; no success claimed.\n')
print(f'Inventory saved to {args.output}; UTC accuracy remains unverified.')
if __name__ == '__main__':
main()