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:
parent
56c418f09c
commit
fb09e138b8
4 changed files with 200 additions and 0 deletions
47
docs/evidence/2026-09-14-railiance01-clock-inventory.json
Normal file
47
docs/evidence/2026-09-14-railiance01-clock-inventory.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
44
tests/test_observe_host_clock.py
Normal file
44
tests/test_observe_host_clock.py
Normal 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
24
tools/README.md
Normal 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.
|
||||
85
tools/observe_host_clock.py
Normal file
85
tools/observe_host_clock.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue