Skip to content

feat(init): keep the node clock in sync with a client-only SNTP - #271

Merged
Bugs5382 merged 5 commits into
mainfrom
feat/249-sntp-client
Sep 30, 2026
Merged

Bugs5382 merged 5 commits into
mainfrom
feat/249-sntp-client

Conversation

@Bugs5382

@Bugs5382 Bugs5382 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Rebased onto the licensed main and landed by the Linux box at the owner's request (2026-09-30). Rebased with --signoff onto main 0c657fb; every commit carries the DCO sign-off. Two conflicts, both kept from both sides: internal/node/node_test.go (main's TestConfigStoreCurrentWithNoConfigIsErrNoConfig and this PR's TestStatusProviderReportsTimeSync, side by side) and internal/init/run.go (main's exported IssuedRecorder plus this PR's wireClockGate). New files keep main's current license header; the relicensing in #291 restamps them.

What and why

A node had no time sync: its clock was whatever the hypervisor RTC gave it at boot, and then it drifted (about 7 to 8 seconds of skew showed up in the alpha readiness run). For a CA the clock sets notBefore/notAfter, CRL and OCSP times and audit times. This adds a small client-only SNTPv4 in init (RFC 4330, and RFC 5905 section 14). It never serves time. Stdlib plus golang.org/x/sys/unix, no new dependency.

Configuration

  • network.ntp_servers: at most 3, IPv4 literals or hostnames (resolved through the node resolver at each poll). Empty falls back to the kernel DHCP lease, which the kernel exposes at /proc/net/ipconfig/ntp_servers (option 42; not in /proc/net/pnp). With neither, the node runs on its hardware clock.
  • pki.allow_unsynced_clock: lifts the signing gate below.
  • cryptosctl config apply warns when no server is set, and when a hostname server has no network.nameservers.

Behaviour (internal/timesync)

  • Boot: after the network and resolver, before etcd, the listeners and signing. One request per server, up to 3 retries 2 s apart, bounded at about 10 s, never fatal. Offset above 128 ms is stepped (clock_settime), otherwise slewed (adjtimex ADJ_OFFSET_SINGLESHOT).
  • After boot: polls every 64 s, backing off to 1024 s while a server is unreachable, never below the RFC 4330 15 s floor. Slews; steps forward above 128 ms; refuses a backwards step and logs it as an error.
  • Replies are checked strictly: connected UDP socket, mode 4, version 3 or 4, origin must echo a random transmit nonce, non-zero transmit, LI != 3, stratum 1 to 15, root distance at most 1 s, non-negative delay. Kiss-o'-Death DENY/RSTR stops querying that server until reboot; RATE doubles its poll interval. Timestamps are decoded into the NTP era nearest the current time, so the 2036 rollover is handled.
  • Several servers: median offset. Two or more answers spread more than 1 s apart adjust nothing and report sources disagree.
  • Clock floor: never step behind the latest of the image build time, the last good sync, and the latest notBefore issued. Kept in clock-floor on the state volume, raised on every issuance, after every good sync and at a clean shutdown (only if the clock synced this boot). Slews are not floor-checked, since a slew never moves the clock backwards.
  • After a good sync STA_UNSYNC is cleared so the kernel's 11-minute mode keeps the RTC close.

Signing gate (fail closed)

  • A configured or leased source that has not synced this boot makes SignSubordinate and every leaf path (IssueLeaf, request SANs, ACME, EST) return FailedPrecondition before the CA key is loaded. The gate opens at the first good sync and stays open for the boot.
  • No time source: never gated. CRL and OCSP: never gated. pki.allow_unsynced_clock: true lifts it, with a warning logged on every such signing.

Status

  • NodeStatus.time_sync and a Clock: line in cryptosctl status (state, source, servers, then the last good sync or the last error).

Logs go to the kernel log through log/slog: info for the source, boot step and state changes; debug for each poll; trace for raw header fields; warn for rejected replies, kisses and disagreement; error for floor violations, refused backwards steps and clock syscall failures.

Closes #249
Refs CryptOS-PKI/api#105

api pin. github.com/CryptOS-PKI/api stays at api main 81494db (already on cryptos main), which carries the time-sync contract (CryptOS-PKI/api#106) and GetIssuedCertificate (CryptOS-PKI/api#104). The unserved SCEP/TSA stubs came in with #286, so this PR no longer touches internal/grpc, go.mod or go.sum.

Merge order: #286 is merged. Merge #305 first, then this PR, then #288 and #291 (the relicensing).

Real-node validation: 8 of 8 pass on a QEMU + swtpm node built with task image from this head (cbd36c4), run against a controllable SNTP server and a DHCP server on a lab-only network:

  1. Boot step: an RTC 1 h behind was stepped before etcd (clock stepped offset=1h0m1.84s boot=true); Clock: SYNCED MACHINE_CONFIG ... stepped at boot; a signed certificate's notBefore matched the host to the second (after the 5-minute backdate).
  2. Slew after boot: a 50 ms server move gave time poll then clock slewing at the next poll; the offset went 49.9 ms, then 18.6 ms.
  3. Server 2 s ahead: clock stepped ... boot=false. Server 2 s behind: refused a backwards clock step on a running node, Clock: UNSYNCED ... refused a backwards step, and signing still worked.
  4. RTC: after 11 minutes synced, a hard reset with no reachable server came up within 0.1 s of the server's time. CONFIG_RTC_SYSTOHC is not set (the kernel has no RTC_CLASS); on x86 the kernel's 11-minute mode writes the CMOS clock through GENERIC_CMOS_UPDATE anyway. arm64 will need RTC_CLASS, RTC_SYSTOHC and an RTC driver, a kernel config follow-up outside this PR.
  5. DHCP option 42: source=TIME_SOURCE_DHCP_LEASE, Clock: SYNCED DHCP_LEASE.
  6. Signing gate: an unreachable server gave up after 10.0 s and the node booted. Signing failed with FailedPrecondition naming pki.allow_unsynced_clock, while /crl and OCSP still answered. With the override, signing worked and logged signed on an unsynced clock. With no source: Clock: NOT_CONFIGURED and no gate.
  7. Clock floor: with the server 24 h behind, clock step refused: server time ... is behind already-issued certificates; the clock did not move and signing stayed gated.
  8. Kiss-o'-Death RATE: the node logged each kiss and backed off (poll gaps 64 s, 128 s, 256 s), never faster than every 15 s.

No defects were found, so no code changed beyond the rebase and the api pin. Two notes, neither a failure: the override warning's wording doubles up ("subordinate CA signing signed on an unsynced clock"), and config apply reports requires_reboot=true for an allow_unsynced_clock-only change, although the node reads it live. allow_unverified_revocation_url behaves the same way on main.

Verification

  • Lint clean
  • Tests pass
  • Build succeeds
  • Documentation updated (if behavior or API changed)

How this was verified

  • gofmt, go vet ./..., golangci-lint run ./... (host and GOOS=linux): clean; GOOS=linux GOARCH=arm64 go build ./...: builds
  • go test ./... with CGO (TPM simulator): pass; go test -race on internal/timesync: pass
  • golic --dry: every file carries the license header
  • After the rebase: task ci (fmt, golangci-lint, vet, go test ./..., build) passes and task license exits 0 on 0b0515e (rebased onto main after feat(config): switch ACME and EST on and off through machine config #286)
  • New tests:
    • internal/timesync: packet encode/decode, every reply-rejection rule and each kiss code, offset and delay maths including the 2036 era boundary, median and disagreement, boot step versus slew, bounded boot on an unreachable server, forward step and refused backwards step after boot, DENY/RSTR stop and RATE back-off, back-off to 1024 s and never below 15 s, poll interval honoured, hostname resolution and failure, clock syscall failure, spoofed reply from another address ignored, floor file (persist, build time, corrupt file, issued notBefore, shutdown only when synced)
    • internal/node: signing gate refuses every path while unsynced without loading the key, allows once synced, allows with allow_unsynced_clock, never gates without a source; issued notBefore reported; time_sync in GetStatus
    • internal/init: source selection (config, option 42 file with validation and the 3 cap, none), CRL and OCSP still generated while signing is gated, floor raised by issuance with no source
    • internal/config: ntp_servers validation, proto round trip, both warnings
    • cmd/cryptosctl: the Clock: line, and the config apply warnings
  • Docs: docs/time-sync.md (new), README links it and lists internal/timesync

Before merging: add a closing comment summarizing what was actually done in this PR
(not just the checked boxes).

@Bugs5382

Copy link
Copy Markdown
Contributor Author

Docs: CryptOS-PKI/docs#53 (merge together).

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
@Bugs5382
Bugs5382 force-pushed the feat/249-sntp-client branch from cbd36c4 to 0b0515e Compare September 30, 2026 16:34
@Bugs5382

Copy link
Copy Markdown
Contributor Author

Merging for the v0.1.0 test run: CI green and mergeable at merge time.

@Bugs5382
Bugs5382 merged commit 08b0260 into main Sep 30, 2026
@Bugs5382
Bugs5382 deleted the feat/249-sntp-client branch September 30, 2026 16:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature (feat). Minor version bump.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(init): keep the node clock in sync (NTP client with network.ntp_servers)

1 participant