aws / aws/clock-bound

3.0.0-beta.0: daemon never synchronizes on dual-stack non-EC2 hosts (NTP sampler binds IPv4-only socket for IPv6 pool members)

Open
#34 2 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
348
Forks
25
PR merge metrics
No merged PRs in 30d

Description

## Summary

On a dual-stack host that is **not** an EC2 instance, `clockbound` 3.0.0-beta.0 never synchronizes. Every NTP sample against the built-in `time.aws.com` pool fails with `EAFNOSUPPORT` (errno 97), the SHM segment is never populated, and clients see `SegmentNotInitialized` indefinitely.

The cause is a mismatch inside the daemon: the DNS resolver validates IPv6 and selects IPv6 pool members, but the NTP sampler binds a hardcoded IPv4 socket to talk to them.

This is a regression in `3.0.0-beta.0` — the DNS pool resolver does not exist in `3.0.0-alpha.1`, which synchronizes fine on the same host.

## Environment

- `clock-bound-3.0.0~beta.0-1.fc46` (the Fedora build)
- Non-EC2 Linux host, dual-stack with working IPv6
- No configuration file changes; stock packaged `/etc/clockbound.toml`

## Symptom

```
$ cbctl waitsync -v
try: 1, error: SegmentNotInitialized: ClockBound SHM header is not initialized.
...
try: 60, error: SegmentNotInitialized: ClockBound SHM header is not initialized.
cbctl waitsync: clock not synchronized after 60 seconds, timing out
```

With `-v` on the daemon, every sample fails:

```
DEBUG ...dns::resolver: Resolved DNS. resolved=LastResolved { addrs: [
2600:1f18:4a3:6901:..., 2600:1f18:4a3:6900:..., 2600:1f18:4a3:6902:...,
2600:1f18:4a3:6900:..., 2600:1f18:4a3:6900:...,
3.87.127.143, 54.210.225.137, 54.90.191.9, 3.86.4.106, 3.94.91.31 ] }
DEBUG ...dns::resolver: Addresses to launch. new_addrs=[ ]
DEBUG ...dns::ntp_source: Failed to sample DNS NTP source.
e=SampleIo(Os { code: 97, kind: Uncategorized,
message: "Address family not supported by protocol" })
```

DNS returns five IPv6 and five IPv4 addresses. All five selected sources are IPv6; the IPv4 addresses are resolved and never used.

This is not a connectivity problem. Raw UDP/123 requests to those exact IPv6 addresses from the same host succeed and return valid 48-byte NTP responses.

## Root cause

`clock-bound/src/daemon/io/ntp.rs` binds an `AF_INET` socket unconditionally:

```rust
// ntp.rs:36
pub const UNSPECIFIED_SOCKET_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0);

// ntp.rs:192
let socket = UdpSocket::bind(UNSPECIFIED_SOCKET_ADDRESS)
.await
.map_err(NtpIoError::Bind)?;

let ntp_event = sample_packet(&socket, self.socket_address, ...)
```

`self.socket_address` is a `SocketAddr`, which for pool members may be V6. Sending to an IPv6 destination on an IPv4 socket yields `EAFNOSUPPORT`. The bind itself succeeds, so the failure surfaces as `SampleIo` rather than `Bind`.

Meanwhile `clock-bound/src/daemon/io/dns/resolver.rs` explicitly establishes that IPv6 is usable before selecting those addresses, using a correctly-typed `AF_INET6` socket:

```rust
// resolver.rs:468
async fn verify_ipv6(&self, server_addr: Ipv6Addr) -> bool {
let socket = match UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0)).await { ... };
match socket.connect((server_addr, 123)).await { Ok(()) => true, ... }
}

// resolver.rs:255
let supports_ipv6 = if let Some(ipv6_addr) = ipv6_addr {
self.verify_ipv6(ipv6_addr).await
} else { false };
let new_addrs = self.new_addrs_to_launch(addrs, needed_host_count, supports_ipv6);
```

So the resolver proves IPv6 works and hands the sampler v6 targets the sampler cannot use.

## Working IPv6 is what triggers it

If IPv6 were unavailable, `verify_ipv6` would return `false`, `new_addrs_to_launch` would drop the AAAA records, the daemon would fall back to the five IPv4 addresses, and it would synchronize normally. A host with broken IPv6 works; a host with healthy IPv6 does not.

## Impact

Any host that (a) is not an EC2 instance, so no Amazon link-local IPv4 source is installed by platform auto-detection, (b) has working IPv6, and (c) resolves `time.aws.com` to AAAA records. On such hosts the daemon runs but never produces a usable clock error bound, and clients cannot read the segment at all.

## Workaround

Pin IPv4 sources explicitly in the configuration file, so the resolver is never offered a v6 candidate:

```toml
[[sources.ntp]]
server = "3.87.127.143"
```

## Suggested fix

Bind the sampling socket to the address family of the destination — or bind a dual-stack `AF_INET6` socket and send to V4-mapped addresses — so the socket family matches what the resolver selected.

Contributor guide

Open the contributing guide

Research direction

Start in clock-bound/src/daemon/io/ntp.rs, especially the socket bind and sample_packet call, then compare its address-family handling with the IPv6 checks in clock-bound/src/daemon/io/dns/resolver.rs. Reproduce the dual-stack non-EC2 case and verify that both IPv6 and IPv4 pool selections can be sampled, the SHM segment is initialized, and clients synchronize.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
backend, networking
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.