From a93ea5fa38a971a06397bb592b94f4a053d5aff4 Mon Sep 17 00:00:00 2001 From: Luca Seemann Date: Mon, 18 May 2026 16:25:10 +0200 Subject: [PATCH] chrony: add support for REQ_CLIENT_ACCESSES_BY_INDEX Adds the REQ_CLIENT_ACCESSES_BY_INDEX3 codec so client.Communicate can return decoded per-client statistics (the data backing 'chronyc clients'): NTP/NKE/Cmd hit and drop counters plus last-hit timestamps. Constants and struct layouts follow chrony's candm.h. Only the v3 codec is implemented: modern chronyd (4.0+) always replies with v3 regardless of the request version, and chrony 3.x changed the wire layout of reply code 10 without changing the code itself, which makes v1 decoding ambiguous. chronyd older than 4.0 returns BADPKTVERSION, documented on the constructor. Resetting chronyd's accounting (the 'chronyc -r clients' flag) is exposed through a separate NewClientAccessesByIndexResetPacket constructor to keep the counter-clearing side effect off the default API. Motivation: unblocks per-client Prometheus metrics in chrony_exporter (SuperQ/chrony_exporter#136). --- ntp/chrony/packet.go | 179 ++++++++++++++++++++++ ntp/chrony/packet_test.go | 309 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 488 insertions(+) diff --git a/ntp/chrony/packet.go b/ntp/chrony/packet.go index ad83aa85..51da62d2 100644 --- a/ntp/chrony/packet.go +++ b/ntp/chrony/packet.go @@ -77,6 +77,8 @@ const ( reqNTPData CommandType = 57 reqNTPSourceName CommandType = 65 reqSelectData CommandType = 69 + + reqClientAccessesByIndex3 CommandType = 68 ) // reply types @@ -94,8 +96,15 @@ const ( RpyServerStats3 ReplyType = 24 RpyServerStats4 ReplyType = 25 RpyNTPData2 ReplyType = 26 + + RpyClientAccessesByIndex3 ReplyType = 21 ) +// MaxClientAccessesByIndex is the fixed number of client slots returned per +// REQ_CLIENT_ACCESSES_BY_INDEX reply (MAX_CLIENT_ACCESSES in chrony's candm.h). +// Use it to size pagination loops over chronyd's client table. +const MaxClientAccessesByIndex = 8 + // source modes const ( SourceModeClient ModeType = 0 @@ -333,6 +342,25 @@ type RequestSelectData struct { data [maxDataLen - 4]uint8 } +// RequestClientAccessesByIndex - packet to request 'clients' data: a page of +// up to MaxClientAccessesByIndex per-client entries starting at FirstIndex. +// Mirrors REQ_ClientAccessesByIndex in chrony's candm.h. +// +// The trailing padding brings the total request size to 520 bytes, which is +// the minimum chronyd accepts for this command: chrony's PKL_CommandLength +// (pktlength.c) adds anti-amplification padding so the request is at least +// as large as the reply, and the v3 'clients' reply is 520 bytes. Smaller +// requests are rejected with BADPKTLENGTH. +type RequestClientAccessesByIndex struct { + RequestHead + FirstIndex uint32 + NClients uint32 + MinHits uint32 + Reset uint32 + EOR int32 + data [480]uint8 +} + // ReplyHead is the first (common) part of the reply packet, // in a format that can be directly passed to binary.Read type ReplyHead struct { @@ -886,6 +914,95 @@ func newSelectData(r *replySelectData) *SelectData { } } +// replyClientAccessesByIndexClient is the wire-format per-client entry, +// matching RPY_ClientAccesses_Client in chrony's candm.h. 60 bytes. +type replyClientAccessesByIndexClient struct { + IPAddr IPAddr + NTPHits uint32 + NKEHits uint32 + CmdHits uint32 + NTPDrops uint32 + NKEDrops uint32 + CmdDrops uint32 + NTPInterval int8 + NKEInterval int8 + CmdInterval int8 + NTPTimeoutInterval int8 + LastNTPHitAgo uint32 + LastNKEHitAgo uint32 + LastCmdHitAgo uint32 +} + +// replyClientAccessesByIndexContent is the wire-format reply, matching +// RPY_ClientAccessesByIndex in chrony's candm.h. The clients array is fixed +// at MaxClientAccessesByIndex slots; NClients tells you how many are valid. +type replyClientAccessesByIndexContent struct { + NIndices uint32 + NextIndex uint32 + NClients uint32 + Clients [MaxClientAccessesByIndex]replyClientAccessesByIndexClient +} + +// ClientAccess contains parsed per-client statistics from chronyd's +// 'clients' command. IPAddr is the raw chrony IPAddr; use ToNetIP for a +// resolved net.IP or String for chronyc-style output (which also handles +// unresolved entries as "ID#XXXXXXXXXX"). +type ClientAccess struct { + IPAddr *IPAddr + NTPHits uint32 + NKEHits uint32 + CmdHits uint32 + NTPDrops uint32 + NKEDrops uint32 + CmdDrops uint32 + NTPInterval int8 + NKEInterval int8 + CmdInterval int8 + NTPTimeoutInterval int8 + LastNTPHitAgo uint32 + LastNKEHitAgo uint32 + LastCmdHitAgo uint32 +} + +// ClientAccessesByIndex contains parsed 'clients' reply: chronyd's known +// index count, the next index to resume pagination from, and up to +// MaxClientAccessesByIndex per-client entries. +// +// Pagination is not atomic: chronyd's internal client table can change +// between consecutive page requests, so clients may be missed or seen +// twice when traversing the full table on a busy server. +type ClientAccessesByIndex struct { + NIndices uint32 + NextIndex uint32 + NClients uint32 + Clients []ClientAccess +} + +// ReplyClientAccessesByIndex is a usable version of the 'clients' reply. +type ReplyClientAccessesByIndex struct { + ReplyHead + ClientAccessesByIndex +} + +func newClientAccess(r *replyClientAccessesByIndexClient) *ClientAccess { + return &ClientAccess{ + IPAddr: &r.IPAddr, + NTPHits: r.NTPHits, + NKEHits: r.NKEHits, + CmdHits: r.CmdHits, + NTPDrops: r.NTPDrops, + NKEDrops: r.NKEDrops, + CmdDrops: r.CmdDrops, + NTPInterval: r.NTPInterval, + NKEInterval: r.NKEInterval, + CmdInterval: r.CmdInterval, + NTPTimeoutInterval: r.NTPTimeoutInterval, + LastNTPHitAgo: r.LastNTPHitAgo, + LastNKEHitAgo: r.LastNKEHitAgo, + LastCmdHitAgo: r.LastCmdHitAgo, + } +} + // here go request constructors // NewSourcesPacket creates new packet to request number of sources (peers) @@ -1001,6 +1118,48 @@ func NewSelectDataPacket(sourceID int32) *RequestSelectData { } } +// NewClientAccessesByIndexPacket creates new packet to request 'clients' +// information: a page of up to MaxClientAccessesByIndex per-client entries +// starting at firstIndex. minHits filters out clients with fewer hits. +// +// This sends REQ_CLIENT_ACCESSES_BY_INDEX3 and expects an +// RPY_CLIENT_ACCESSES_BY_INDEX3 reply. chronyd versions older than 4.0 do +// not understand this command and reply with BADPKTVERSION. +// +// chronyd restricts this command to the Unix socket by default (see +// ChronySocketPath); access over UDP port 323 requires an explicit +// 'cmdallow' rule in chrony.conf, otherwise the reply is ACCESSDENIED. +// +// Use NewClientAccessesByIndexResetPacket if you also want chronyd to +// clear its accounting table after the reply. +func NewClientAccessesByIndexPacket(firstIndex, nClients, minHits uint32) *RequestClientAccessesByIndex { + return newClientAccessesByIndexPacket(firstIndex, nClients, minHits, 0) +} + +// NewClientAccessesByIndexResetPacket is like NewClientAccessesByIndexPacket +// but additionally instructs chronyd to clear its accounting table after +// the reply (equivalent to chronyc's `-r` flag). Most polling consumers +// want the non-resetting form, since clearing the table invalidates rate +// calculations across scrape intervals. +func NewClientAccessesByIndexResetPacket(firstIndex, nClients, minHits uint32) *RequestClientAccessesByIndex { + return newClientAccessesByIndexPacket(firstIndex, nClients, minHits, 1) +} + +func newClientAccessesByIndexPacket(firstIndex, nClients, minHits, reset uint32) *RequestClientAccessesByIndex { + return &RequestClientAccessesByIndex{ + RequestHead: RequestHead{ + Version: protoVersionNumber, + PKTType: pktTypeCmdRequest, + Command: reqClientAccessesByIndex3, + }, + FirstIndex: firstIndex, + NClients: nClients, + MinHits: minHits, + Reset: reset, + data: [480]uint8{}, + } +} + // possible clock sources const ( ClockSourceUnspec = "unspec" @@ -1173,6 +1332,26 @@ func decodePacket(response []byte) (ResponsePacket, error) { ReplyHead: *head, SelectData: *newSelectData(data), }, nil + case RpyClientAccessesByIndex3: + data := new(replyClientAccessesByIndexContent) + if err = binary.Read(r, binary.BigEndian, data); err != nil { + return nil, err + } + Logger.Printf("response data: %+v", data) + n := min(data.NClients, MaxClientAccessesByIndex) + clients := make([]ClientAccess, n) + for i := range n { + clients[i] = *newClientAccess(&data.Clients[i]) + } + return &ReplyClientAccessesByIndex{ + ReplyHead: *head, + ClientAccessesByIndex: ClientAccessesByIndex{ + NIndices: data.NIndices, + NextIndex: data.NextIndex, + NClients: data.NClients, + Clients: clients, + }, + }, nil default: return nil, fmt.Errorf("not implemented reply type %d from %+v", head.Reply, head) } diff --git a/ntp/chrony/packet_test.go b/ntp/chrony/packet_test.go index f57d4b1e..61a8c2b3 100644 --- a/ntp/chrony/packet_test.go +++ b/ntp/chrony/packet_test.go @@ -654,6 +654,305 @@ func TestDecodeSelectData(t *testing.T) { require.Equal(t, want, packet) } +func TestDecodeClientAccessesByIndex3(t *testing.T) { + // Captured via `strace -xx -e trace=recvfrom chronyc clients` against a + // chronyd serving real clients. Client IPs have been substituted with + // RFC 5737 (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) and + // RFC 3849 (2001:db8::/32) documentation ranges; all other fields + // (hit counters, intervals, last-hit-ago sentinels) are unchanged. + raw := []uint8{ + 0x06, 0x02, 0x00, 0x00, 0x00, 0x44, 0x00, 0x15, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa6, 0x18, 0xf1, 0xf7, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x10, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, + 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x01, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xc0, 0x00, 0x02, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x02, 0x91, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xc0, 0x00, 0x02, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x00, 0xc9, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xc6, 0x33, 0x64, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x01, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xc6, 0x33, 0x64, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x02, 0x5d, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xcb, 0x00, 0x71, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x08, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x00, 0x22, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xcb, 0x00, 0x71, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xfc, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x00, 0x1a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xcb, 0x00, 0x71, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x01, 0xb1, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + } + packet, err := decodePacket(raw) + require.Nil(t, err) + // chronyd uses 0x7f as an "interval unset" sentinel and 0xffffffff as + // "never hit" for the *_hit_ago fields when the protocol wasn't used. + want := &ReplyClientAccessesByIndex{ + ReplyHead: ReplyHead{ + Version: protoVersionNumber, + PKTType: pktTypeCmdReply, + Command: reqClientAccessesByIndex3, + Reply: RpyClientAccessesByIndex3, + Status: sttSuccess, + Sequence: 0xa618f1f7, + }, + ClientAccessesByIndex: ClientAccessesByIndex{ + NIndices: 4096, + NextIndex: 8, + NClients: 8, + Clients: []ClientAccess{ + { + IPAddr: &IPAddr{IP: IPToBytes(net.ParseIP("2001:db8::1")), Family: IPAddrInet6}, + NTPHits: 1, + NTPInterval: 127, + NKEInterval: 127, + CmdInterval: 127, + NTPTimeoutInterval: 127, + LastNTPHitAgo: 447, + LastNKEHitAgo: 0xffffffff, + LastCmdHitAgo: 0xffffffff, + }, + { + IPAddr: &IPAddr{IP: IPToBytes(net.ParseIP("192.0.2.1")), Family: IPAddrInet4}, + NTPHits: 1, + NTPInterval: 127, + NKEInterval: 127, + CmdInterval: 127, + NTPTimeoutInterval: 127, + LastNTPHitAgo: 657, + LastNKEHitAgo: 0xffffffff, + LastCmdHitAgo: 0xffffffff, + }, + { + IPAddr: &IPAddr{IP: IPToBytes(net.ParseIP("192.0.2.2")), Family: IPAddrInet4}, + NTPHits: 1, + NTPInterval: 127, + NKEInterval: 127, + CmdInterval: 127, + NTPTimeoutInterval: 127, + LastNTPHitAgo: 201, + LastNKEHitAgo: 0xffffffff, + LastCmdHitAgo: 0xffffffff, + }, + { + IPAddr: &IPAddr{IP: IPToBytes(net.ParseIP("198.51.100.3")), Family: IPAddrInet4}, + NTPHits: 2, + NTPInterval: 6, + NKEInterval: 127, + CmdInterval: 127, + NTPTimeoutInterval: 127, + LastNTPHitAgo: 393, + LastNKEHitAgo: 0xffffffff, + LastCmdHitAgo: 0xffffffff, + }, + { + IPAddr: &IPAddr{IP: IPToBytes(net.ParseIP("198.51.100.4")), Family: IPAddrInet4}, + NTPHits: 1, + NTPInterval: 127, + NKEInterval: 127, + CmdInterval: 127, + NTPTimeoutInterval: 127, + LastNTPHitAgo: 605, + LastNKEHitAgo: 0xffffffff, + LastCmdHitAgo: 0xffffffff, + }, + { + IPAddr: &IPAddr{IP: IPToBytes(net.ParseIP("203.0.113.5")), Family: IPAddrInet4}, + NTPHits: 7, + NTPInterval: 8, + NKEInterval: 127, + CmdInterval: 127, + NTPTimeoutInterval: 127, + LastNTPHitAgo: 34, + LastNKEHitAgo: 0xffffffff, + LastCmdHitAgo: 0xffffffff, + }, + { + IPAddr: &IPAddr{IP: IPToBytes(net.ParseIP("203.0.113.6")), Family: IPAddrInet4}, + NTPHits: 3, + NTPInterval: -4, + NKEInterval: 127, + CmdInterval: 127, + NTPTimeoutInterval: 127, + LastNTPHitAgo: 26, + LastNKEHitAgo: 0xffffffff, + LastCmdHitAgo: 0xffffffff, + }, + { + IPAddr: &IPAddr{IP: IPToBytes(net.ParseIP("203.0.113.7")), Family: IPAddrInet4}, + NTPHits: 1, + NTPInterval: 127, + NKEInterval: 127, + CmdInterval: 127, + NTPTimeoutInterval: 127, + LastNTPHitAgo: 433, + LastNKEHitAgo: 0xffffffff, + LastCmdHitAgo: 0xffffffff, + }, + }, + }, + } + require.Equal(t, want, packet) +} + +func TestDecodeClientAccessesByIndex3Empty(t *testing.T) { + raw := []uint8{ + // Reply head + 0x06, 0x02, 0x00, 0x00, 0x00, 0x44, 0x00, 0x15, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xde, 0xad, 0xbe, 0xef, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // NIndices=0, NextIndex=0, NClients=0 + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + } + raw = append(raw, make([]uint8, 8*60)...) + + packet, err := decodePacket(raw) + require.Nil(t, err) + rep, ok := packet.(*ReplyClientAccessesByIndex) + require.True(t, ok) + require.Equal(t, uint32(0), rep.NIndices) + require.Equal(t, uint32(0), rep.NextIndex) + require.Equal(t, uint32(0), rep.NClients) + require.Empty(t, rep.Clients) +} + +func TestEncodeClientAccessesByIndexPacket(t *testing.T) { + p := NewClientAccessesByIndexPacket(0, MaxClientAccessesByIndex, 0) + var buf bytes.Buffer + require.NoError(t, binary.Write(&buf, binary.BigEndian, p)) + // chronyd's PKL_CommandLength requires 520 bytes for + // REQ_CLIENT_ACCESSES_BY_INDEX3; smaller requests are rejected with + // BADPKTLENGTH. + require.Equal(t, 520, buf.Len()) + require.Equal(t, uint32(0), p.Reset) + require.Equal(t, reqClientAccessesByIndex3, p.Command) + + p2 := NewClientAccessesByIndexPacket(8, MaxClientAccessesByIndex, 5) + require.Equal(t, uint32(8), p2.FirstIndex) + require.Equal(t, uint32(MaxClientAccessesByIndex), p2.NClients) + require.Equal(t, uint32(5), p2.MinHits) + require.Equal(t, uint32(0), p2.Reset) +} + +func TestClientCommunicateClientAccessesByIndex(t *testing.T) { + // Sanitized real chronyd reply (same bytes as TestDecodeClientAccessesByIndex3, + // re-used here to exercise the full Client.Communicate read path including + // the response buffer sizing. + mockConn := &MockConnection{ + readData: []byte{ + 0x06, 0x02, 0x00, 0x00, 0x00, 0x44, 0x00, 0x15, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa6, 0x18, 0xf1, 0xf7, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x10, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, + 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x01, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xc0, 0x00, 0x02, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x02, 0x91, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xc0, 0x00, 0x02, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x00, 0xc9, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xc6, 0x33, 0x64, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x01, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xc6, 0x33, 0x64, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x02, 0x5d, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xcb, 0x00, 0x71, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x08, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x00, 0x22, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xcb, 0x00, 0x71, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xfc, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x00, 0x1a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xcb, 0x00, 0x71, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x00, + 0x01, 0xb1, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + }, + } + client := &Client{Connection: mockConn} + + rpy, err := client.Communicate(NewClientAccessesByIndexPacket(0, MaxClientAccessesByIndex, 0)) + require.NoError(t, err) + r, ok := rpy.(*ReplyClientAccessesByIndex) + require.True(t, ok) + require.Equal(t, uint32(4096), r.NIndices) + require.Equal(t, uint32(8), r.NextIndex) + require.Equal(t, uint32(8), r.NClients) + require.Len(t, r.Clients, 8) + // Spot-check first and last entries + require.Equal(t, IPAddrInet6, r.Clients[0].IPAddr.Family) + require.Equal(t, uint32(1), r.Clients[0].NTPHits) + require.Equal(t, IPAddrInet4, r.Clients[7].IPAddr.Family) + require.Equal(t, "203.0.113.7", r.Clients[7].IPAddr.String()) +} + +func TestEncodeClientAccessesByIndexResetPacket(t *testing.T) { + p := NewClientAccessesByIndexResetPacket(0, MaxClientAccessesByIndex, 0) + var buf bytes.Buffer + require.NoError(t, binary.Write(&buf, binary.BigEndian, p)) + require.Equal(t, 520, buf.Len()) + require.Equal(t, uint32(1), p.Reset) + require.Equal(t, reqClientAccessesByIndex3, p.Command) +} + func TestSourceStateTypeToString(t *testing.T) { v := SourceStateUnreach got := v.String() @@ -756,6 +1055,10 @@ func TestPacketEncodingNoPanic(t *testing.T) { require.Less(t, buf.Len(), 500, "encoded packet should not exceed reasonable protocol limits") }) } + // RequestClientAccessesByIndex has its own dedicated test + // (TestEncodeClientAccessesByIndexPacket) because it exceeds the 500-byte + // limit above: chronyd's anti-amplification padding pushes the request to + // exactly 520 bytes. } func TestClientCommunicate(t *testing.T) { @@ -786,6 +1089,9 @@ func TestClientCommunicate(t *testing.T) { {"NTPSourceName", NewNTPSourceNamePacket(&IPAddr{IP: IPToBytes(net.ParseIP("127.0.0.1")), Family: IPAddrInet4})}, {"NTPData", NewNTPDataPacket(&IPAddr{IP: IPToBytes(net.ParseIP("127.0.0.1")), Family: IPAddrInet4})}, {"SelectData", NewSelectDataPacket(1)}, + // RequestClientAccessesByIndex is covered by the dedicated + // TestClientCommunicateClientAccessesByIndex test because its 520-byte + // wire size exceeds the 500-byte sanity limit asserted below. } for _, tc := range testCases { @@ -819,6 +1125,7 @@ func TestEORFieldInitialization(t *testing.T) { {"RequestNTPSourceName", NewNTPSourceNamePacket(&IPAddr{IP: IPToBytes(net.ParseIP("127.0.0.1")), Family: IPAddrInet4})}, {"RequestNTPData", NewNTPDataPacket(&IPAddr{IP: IPToBytes(net.ParseIP("127.0.0.1")), Family: IPAddrInet4})}, {"RequestSelectData", NewSelectDataPacket(42)}, + {"RequestClientAccessesByIndex", NewClientAccessesByIndexPacket(0, MaxClientAccessesByIndex, 0)}, } for _, tt := range packetsWithEOR { @@ -835,6 +1142,8 @@ func TestEORFieldInitialization(t *testing.T) { require.Equal(t, int32(0), p.EOR, "EOR should be initialized to 0") case *RequestSelectData: require.Equal(t, int32(0), p.EOR, "EOR should be initialized to 0") + case *RequestClientAccessesByIndex: + require.Equal(t, int32(0), p.EOR, "EOR should be initialized to 0") default: t.Errorf("Unexpected packet type: %T", p) }