93 lines
3.1 KiB
Python
Executable File
93 lines
3.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Sample client-to-RCT clock alignment for client-origin transport probes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import pathlib
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
|
|
def sample_clock_alignment(host: str, ssh_opts_text: str) -> dict:
|
|
"""Return a midpoint clock-offset estimate between this client and RCT.
|
|
|
|
Inputs: SSH host plus the same SSH option string used by the manual probe.
|
|
Outputs: a JSON-serializable clock alignment record.
|
|
Why: client-origin freshness needs the final capture timestamps translated
|
|
into the client's clock without requiring NTP-level access or sudo.
|
|
"""
|
|
|
|
ssh_opts = shlex.split(ssh_opts_text)
|
|
remote_code = "import sys,time\nfor _ in sys.stdin:\n print(time.time_ns(), flush=True)\n"
|
|
proc = subprocess.Popen(
|
|
["ssh", *ssh_opts, host, "python3 -u -c " + shlex.quote(remote_code)],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
)
|
|
rows: list[tuple[int, int]] = []
|
|
try:
|
|
assert proc.stdin is not None
|
|
assert proc.stdout is not None
|
|
for _ in range(9):
|
|
start = time.time_ns()
|
|
proc.stdin.write("sample\n")
|
|
proc.stdin.flush()
|
|
remote = proc.stdout.readline().strip()
|
|
end = time.time_ns()
|
|
if not remote:
|
|
raise RuntimeError("remote clock sampler stopped before returning data")
|
|
midpoint = (start + end) // 2
|
|
rows.append((end - start, int(remote) - midpoint))
|
|
time.sleep(0.05)
|
|
finally:
|
|
if proc.stdin is not None:
|
|
proc.stdin.close()
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
rows.sort(key=lambda row: row[0])
|
|
best = rows[:5]
|
|
offset = round(sum(row[1] for row in best) / len(best))
|
|
uncertainty = max(row[0] for row in best) // 2
|
|
return {
|
|
"schema": "lesavka.client-rct-clock-alignment.v1",
|
|
"available": True,
|
|
"method": "persistent client-to-capture ssh midpoint",
|
|
"capture_host": host,
|
|
"capture_clock_offset_from_client_ns": offset,
|
|
"clock_uncertainty_ns": uncertainty,
|
|
"clock_uncertainty_ms": uncertainty / 1_000_000.0,
|
|
"samples": len(rows),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
"""CLI entrypoint for the clock alignment helper."""
|
|
|
|
if len(sys.argv) != 4:
|
|
print(
|
|
"usage: client_rct_clock_alignment.py TETHYS_HOST SSH_OPTS OUTPUT_JSON",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
host, ssh_opts_text, output_path = sys.argv[1:]
|
|
data = sample_clock_alignment(host, ssh_opts_text)
|
|
pathlib.Path(output_path).write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
|
offset = data["capture_clock_offset_from_client_ns"] / 1_000_000.0
|
|
uncertainty = data["clock_uncertainty_ms"]
|
|
print(f" ↪ tethys_from_client_offset_ms={offset:+.3f}")
|
|
print(f" ↪ clock_alignment_uncertainty_ms={uncertainty:.3f}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|