rusty_time-daemon 0.1.1

rtimed, the rusty_time daemon: a pure-Rust NTPv4 and NTS (RFC 8915) client and server with interleaved mode (RFC 9769), rate limiting, Kiss-o'-Death, batched send/receive and an NTP-over-HTTP gateway. The chronyd analog.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>rusty_time gateway</title>
<style>
  :root {
    --paper: #f5f7f9; --ink: #1a222c; --ink-2: #4a5866; --line: #d4dce2;
    --accent: #0c7489; --good: #2e7d4f; --warn: #9a6a1b;
  }
  @media (prefers-color-scheme: dark) {
    :root {
      --paper: #0f141b; --ink: #dce3ea; --ink-2: #9aa8b4; --line: #2a3542;
      --accent: #3fa9be; --good: #5cb884; --warn: #d2a24c;
    }
  }
  * { box-sizing: border-box; }
  body {
    margin: 0; background: var(--paper); color: var(--ink);
    font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
    padding: 2rem 1.25rem 4rem;
  }
  main { max-width: 44rem; margin: 0 auto; }
  h1 { font-size: 1.5rem; margin: 0 0 .25rem; letter-spacing: -.01em; }
  .sub { color: var(--ink-2); margin: 0 0 2rem; }
  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 1rem; }
  .tile {
    border: 1px solid var(--line); border-radius: 8px; padding: 1rem;
    background: color-mix(in srgb, var(--paper) 92%, var(--ink) 8%);
  }
  .label {
    font: 600 .68rem/1.4 ui-monospace, monospace; letter-spacing: .12em;
    text-transform: uppercase; color: var(--ink-2); margin-bottom: .4rem;
  }
  .value { font: 500 1.5rem/1.2 ui-monospace, monospace; font-variant-numeric: tabular-nums; }
  .value.small { font-size: 1.05rem; }
  .ok { color: var(--good); } .pending { color: var(--warn); }
  table { border-collapse: collapse; width: 100%; margin-top: 2rem; font-size: .86rem; }
  th, td {
    text-align: right; padding: .4rem .6rem; border-bottom: 1px solid var(--line);
    font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace;
  }
  th { color: var(--ink-2); font-weight: 600; font-size: .7rem; text-transform: uppercase; }
  th:first-child, td:first-child { text-align: left; }
  .note { color: var(--ink-2); font-size: .85rem; margin-top: 2rem; }
  code { background: color-mix(in srgb, var(--paper) 85%, var(--ink) 15%); padding: .1em .35em; border-radius: 3px; }
</style>
</head>
<body>
<main>
  <h1>rusty_time gateway</h1>
  <p class="sub">A browser has no UDP socket and no clock it may set. This page
  measures the offset of <em>this machine's</em> clock against the gateway and
  corrects for it in software, using the same NTP codec the daemon uses.</p>

  <div class="grid">
    <div class="tile">
      <div class="label">Corrected time</div>
      <div class="value small" id="corrected"></div>
    </div>
    <div class="tile">
      <div class="label">Offset</div>
      <div class="value" id="offset"></div>
    </div>
    <div class="tile">
      <div class="label">Error bound</div>
      <div class="value" id="confidence"></div>
    </div>
    <div class="tile">
      <div class="label">Exchanges</div>
      <div class="value" id="exchanges">0</div>
    </div>
  </div>

  <table>
    <thead><tr><th>#</th><th>offset (ms)</th><th>round trip (ms)</th></tr></thead>
    <tbody id="samples"></tbody>
  </table>

  <p class="note" id="status">Loading the wasm module…</p>
  <p class="note">The offset is what would be added to <code>Date.now()</code> to
  match the gateway. The error bound widens between exchanges, because an
  estimate that is not refreshed gets worse and should say so.</p>
</main>

<script type="module">
const els = {
  corrected: document.getElementById('corrected'),
  offset: document.getElementById('offset'),
  confidence: document.getElementById('confidence'),
  exchanges: document.getElementById('exchanges'),
  samples: document.getElementById('samples'),
  status: document.getElementById('status'),
};

const fmt = (ms, digits = 3) =>
  (ms >= 0 ? '+' : '') + ms.toFixed(digits) + ' ms';

let client = null;
try {
  const wasm = await import('./rusty_time_wasm.js');
  await wasm.default();
  client = new wasm.TimeClient();
  els.status.textContent = 'Measuring…';
} catch (e) {
  els.status.textContent =
    'The wasm module is not being served from this origin, so this page can ' +
    'only show the gateway is reachable. Build it with: wasm-pack build ' +
    'crates/rusty_time-wasm --target web';
}

let count = 0;

async function exchange() {
  if (!client) return;
  // The nonce is what binds the reply to this request; an off-path attacker
  // who cannot see it cannot forge an answer we would accept.
  const rnd = new Uint32Array(2);
  crypto.getRandomValues(rnd);

  const request = client.build_request(Date.now(), rnd[0], rnd[1]);
  let response;
  try {
    const res = await fetch('/time', {
      method: 'POST',
      headers: { 'content-type': 'application/octet-stream' },
      body: request,
      cache: 'no-store',
    });
    if (!res.ok) { els.status.textContent = 'Gateway said ' + res.status; return; }
    response = new Uint8Array(await res.arrayBuffer());
  } catch (e) {
    els.status.textContent = 'Gateway unreachable: ' + e;
    return;
  }

  const t4 = Date.now();
  const perf = performance.now();
  const accepted = client.process_response(response, t4, perf);
  if (!accepted) {
    els.status.textContent = 'Reply rejected (it did not match our request).';
    return;
  }

  count += 1;
  const offset = client.offset_ms(perf);
  els.offset.textContent = fmt(offset);
  els.offset.className = 'value ok';
  els.confidence.textContent = '±' + client.confidence_ms(perf).toFixed(2) + ' ms';
  els.exchanges.textContent = String(count);
  els.status.textContent = 'Synchronised with the gateway.';

  const row = els.samples.insertRow(0);
  row.insertCell().textContent = count;
  row.insertCell().textContent = offset.toFixed(3);
  row.insertCell().textContent = (performance.now() - perf + 0).toFixed(3);
  while (els.samples.rows.length > 8) els.samples.deleteRow(8);
}

function tick() {
  if (!client) return;
  const perf = performance.now();
  const corrected = client.now_ms(Date.now(), perf);
  els.corrected.textContent = new Date(corrected).toISOString().replace('T', ' ').replace('Z', '');
  els.confidence.textContent = '±' + client.confidence_ms(perf).toFixed(2) + ' ms';
}

await exchange();
setInterval(exchange, 8000);
setInterval(tick, 100);
</script>
</body>
</html>