diff --git a/docs/evidence/2026-09-14-railiance01-clock-inventory.json b/docs/evidence/2026-09-14-railiance01-clock-inventory.json new file mode 100644 index 0000000..caa9048 --- /dev/null +++ b/docs/evidence/2026-09-14-railiance01-clock-inventory.json @@ -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" + } + } +} diff --git a/tests/test_observe_host_clock.py b/tests/test_observe_host_clock.py new file mode 100644 index 0000000..e889fb8 --- /dev/null +++ b/tests/test_observe_host_clock.py @@ -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) diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..94e84ed --- /dev/null +++ b/tools/README.md @@ -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. diff --git a/tools/observe_host_clock.py b/tools/observe_host_clock.py new file mode 100644 index 0000000..f789c53 --- /dev/null +++ b/tools/observe_host_clock.py @@ -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()