Skip to content

feat(init): sync the clock from /dev/ptp0 and drop chrony - #7

Open
patrislav wants to merge 7 commits into
feat/rust-eiffel-initfrom
feat/rust-init-time-sync
Open

patrislav wants to merge 7 commits into
feat/rust-eiffel-initfrom
feat/rust-init-time-sync

Conversation

@patrislav

@patrislav patrislav commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

The enclave still carries a full NTP daemon for one job it is not doing: there is no network in here, so chronyd is not speaking NTP to anyone. It exists only to read the host's clock from /dev/ptp0 and discipline the local one against it. That is a build stage, a config file, a password file and a separate binary in the measured ramdisk, in exchange for a few hundred lines of work the init can do itself. This folds it in and deletes the rest.

Last of three stacked pull requests; merge the other two first. After this the bootstrap ramdisk holds exactly the init binary and a CA bundle, plus directory entries, and everything measured into PCR1 is built from source in this repository.

Why this is enough

The only time source in the enclave is the host's clock, exposed through a hypercall. The guest clocksource derives from the same host TSC, so frequency drift is near zero and the real problem is the offset at boot. A step before any app starts, plus a slow correction loop, covers that. What chrony offered beyond this, statistical filtering and long-term frequency learning, does not matter for a clock whose reference sits on the same physical host.

Stepping before spawn also means no process ever observes time going backwards. chrony stepped on its first measurement, which could land after an app had already started.

What lands

  • A time_sync module with no new dependencies. The ioctls are defined locally against the kernel's own header.
  • Sampling prefers the precise cross-timestamp call and falls back to the multi-sample call when the hypervisor does not support it, picking the tightest bracket. The fallback is required rather than optional: nested virtualisation can clear the flag the precise path depends on.
  • At boot, one sample. Beyond a millisecond of offset the clock is stepped and the step is logged; otherwise the offset is logged and nothing moves. If the device never appears the enclave still boots, with a line saying so.
  • Afterwards a dedicated thread samples every four seconds, matching chrony's old poll interval, and slews through the kernel PLL. A large offset steps and warns, which should never happen and would mean a host problem. There is a frequency estimate behind a single constant, clamped, and reset by any step or error.
  • Errors log immediately and then back off.

A note on the fallback ioctl

The two PTP calls have different directions in the header, which is easy to get wrong. The precise one is read-write. The fallback is declared write-only even though the kernel writes timestamps back, so deriving its request number as read-write produces a number the kernel rejects. It is built as a write-pointer ioctl instead, with the reason in a comment.

Only the unsupported-operation error triggers the fallback, deliberately. A bad-ioctl error would mean the request number is wrong, and falling back there would hide exactly the mismatch the tests exist to catch.

Verification

  • Formatting, clippy with warnings denied, and the full workspace suite pass: nine builder tests and nineteen init tests.
  • The ioctl request numbers and struct sizes are asserted against the real header from the pinned kernel version, confirmed twice: once from the upstream tree and once by compiling a snippet inside the Dockerfile's own kernel stage against the unpacked source. A header mismatch now fails the build rather than the enclave.
  • The offset math is tested on both sampling paths, including the seconds-field boundary, and a kernel reporting no samples is an error rather than a panic. An init that panics would take the enclave down with it.
  • The parts-per-million conversion, the clamp and the warm-up gate are tested.
  • The binary still builds static for musl with no interpreter segment, and two cache-free builds of the init stage produce an identical hash.
  • The resulting bootstrap ramdisk was listed to confirm its contents.

Not verified

No ioctl was ever issued against a real PTP device, so neither sampling path has run end to end; only the numbers and the pure math are tested. Nothing was booted, on Nitro or under QEMU. The CI assertion that the guest clock gets stepped rests on an assumption about how QEMU's clock base reaches the guest that has not been demonstrated; if it does not hold, that step needs rethinking.

The frequency estimator ships enabled without field data behind that decision. It is the one piece approximating what chrony did, it is bounded, and the comment in the source names the ceiling: the kernel is slewing the clock while the estimator measures it, so part of what it reads as drift is its own correction. Turning it off is a one-line change.

Rollback

Reverting this restores chrony with no other change. The two earlier pull requests do not depend on it.

Impact

PCR0, PCR1 and PCR2 move once more. Re-pin from the PCR output.

🤖 Generated with Claude Code

patrislav and others added 4 commits November 2, 2026 17:03
eiffel-init now does what chronyd did, in-process. /dev/ptp0 is the host's
CLOCK_REALTIME as ptp_kvm exposes it: reading it is the KVM clock-pairing
hypercall, answered by the hypervisor rather than by the parent instance, so
the parent cannot skew an enclave's clock.

One sample is taken between mounting the filesystems and spawning anything,
and the clock is stepped if it is more than a millisecond out, so no process
ever observes time jumping backwards. After that a thread of its own samples
every 4s — chrony's poll 2 on the same refclock — and feeds the offset to the
kernel PLL, which slews. The supervisor stays single-threaded.

PTP_SYS_OFFSET_PRECISE2 is a cross-timestamp and needs no correction for the
measurement's own delay, but it answers EOPNOTSUPP wherever the pvclock
TSC-stable bit is clear (nested virtualisation, mostly), so the bracketed
PTP_SYS_OFFSET2 fallback is load-bearing rather than belt-and-braces.

The ioctl request numbers and struct layouts are derived with nix's ioc macros
from include/uapi/linux/ptp_clock.h in the kernel the Dockerfile pins, never
from memory. A unit test checks them against .github/ptp-ioctl-numbers.txt,
which CI regenerates from that header with a C snippet, so a uapi change fails
the build rather than leaving an enclave unable to read the host clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eiffel-init keeps the clock itself now, so the Dockerfile's chrony stage,
/sbin/chronyd, /etc/chrony.conf, /etc/passwd (chronyd was the only thing that
read it), the /var/lib/chrony mkdirs and /rootfs/var all go. The MUSL, GCC and
BINUTILS pins stay: the kernel stage still compiles with them.

The bootstrap ramdisk is now exactly /init, the CA bundle at
/rootfs/etc/ssl/certs/ca-certificates.crt, and the directory entries. PCR0,
PCR1 and PCR2 all move with this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… clock

Two things CI did not cover before.

The ioctl check compiles .github/ptp-ioctl-numbers.c inside the Dockerfile's
kernel stage, against that tree's own include/uapi/linux/ptp_clock.h, and
diffs the request numbers and struct sizes it prints against the file
eiffel-init's unit test reads. A uapi change now fails the build instead of
leaving an enclave unable to read the host clock.

The boot test gains -rtc base=2000-01-01, which starts the guest's wall clock
26 years behind the host's while ptp_kvm still reports the host's real time.
With KVM it asserts that init logged a step and that the date /app/hello
printed is the host's; the console says whether the precise ioctl or the
PTP_SYS_OFFSET2 fallback produced it, so both paths pass and a failure is
diagnosable. Without /dev/kvm there is no hypercall and so no /dev/ptp0: the
time assertions are skipped and the run instead proves a missing PTP clock
does not stop the enclave booting.

With chronyd gone nothing races /app/hello to the exit any more, so its
clean-exit assertion no longer needs KVM either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@patrislav
patrislav force-pushed the feat/rust-init-time-sync branch from ff0f2c9 to cdd38d8 Compare November 2, 2026 16:15
patrislav and others added 3 commits November 2, 2026 17:23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings up the workflow hardening. No conflicts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The PTP ioctl check this branch adds unpacks a kernel source tree, which
does not fit in the 20 minutes that covered lint, test and the musl build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@patrislav
patrislav added this pull request to stack #8 November 2, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant