Skip to content

Commit 595807e

Browse files
committed
fix: Harden the CheckActiveDirectory checks against review findings
Code review of the new module surfaced a set of misclassification and timeout bugs; all checks keep their documented contracts but stop lying about failures: check_ad_replication - The benign not-a-DC contract now only applies without server=: an explicitly named DC that cannot be bound is a dead or unreachable domain controller and is reported as a plain UNKNOWN failure instead of the "safe to ignore fleet-wide" message. - DsBindW has no timeout of its own and blocks ~21s+ against a black-holed host; remote targets are now pre-flighted with a bounded TCP connect to the RPC endpoint mapper (port 135, 5s) so an unreachable DC fails fast instead of blowing the transport timeout. check_secure_channel - The domain-join defaulting queried the local machine even when server=<remote> was given; NetGetJoinInformation now targets the named computer, and the not-joined/failure messages name it. - Netlogon call failures (service stopped, access denied, RPC failure) were folded into the same healthy=0 row as a genuinely broken channel, paging a trust-relationship-broken alert for a healthy one. They now return UNKNOWN with the call error, matching the sibling checks' data-source-failure contract; CRITICAL is reserved for netlog2_tc_connection_status. check_kdc - KDCs were probed sequentially, each with its own timeout, so a site outage with several dead KDCs exceeded the transport command timeout and produced no result at all. All probes now share one io_context and one deadline: worst case is one timeout, not one per KDC. - Round-trip time was measured from before DNS resolution, billing a slow resolver to the KDC and tripping the `time > 1000` warning; it is now measured from connect, and stamped at completion so a slow KDC cannot inflate a fast one's reading. - timeout= was in seconds while every sibling network probe (check_tcp, check_dns, check_http, check_ntp_offset) is in milliseconds, and timeout*1000 could overflow int. It is now milliseconds (default 5000) with no multiplication. - The DER writer silently truncated element lengths above 0xFFFF, corrupting the request for absurdly long realms; it now emits however many long-form length octets the size needs (unit-tested through the independent reader). Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Michael Medin <michael@medin.name>
1 parent d11881b commit 595807e

9 files changed

Lines changed: 237 additions & 84 deletions

docs/samples/CheckActiveDirectory_check_ad_replication_desc.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ synced trips the 24-hour rule by design.
3232
Options: `server=<dc>` checks another domain controller (default: the local
3333
machine — replication state is per-DC, so run the check on every DC).
3434

35-
**Not-a-DC contract:** on a host that does not run the directory service the
36-
check returns **UNKNOWN** with a "Not a domain controller" message rather than
37-
a hard error, so it is safe to deploy fleet-wide. A single-DC domain (no
38-
replication partners) returns **OK** with an explanatory empty-state message.
35+
**Not-a-DC contract:** run without `server=` on a host that does not run the
36+
directory service, the check returns **UNKNOWN** with a "Not a domain
37+
controller" message rather than a hard error, so it is safe to deploy
38+
fleet-wide. The contract only applies locally: a `server=` target that cannot
39+
be bound is reported as a plain failure — an explicitly named DC being
40+
unreachable (powered off, firewalled, NTDS stopped) is an outage, not an
41+
ignorable non-DC. Remote targets are first probed on TCP port 135 (the RPC
42+
endpoint mapper) with a 5 second deadline so a black-holed host fails fast
43+
instead of hanging the check. A single-DC domain (no replication partners)
44+
returns **OK** with an explanatory empty-state message.

docs/samples/CheckActiveDirectory_check_kdc_desc.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@ Defaults: **WARNING** when `time > 1000`, **CRITICAL** when `responding = 0`.
2929
Options: `server=<host>` (repeatable) picks the KDC(s) to probe and
3030
`realm=<REALM>` the realm; both default to what the domain join discovers
3131
(`DsGetDcName`). On a machine that is not domain-joined, `server=` and
32-
`realm=` are required and the check says so with **UNKNOWN**. `timeout=<s>`
33-
(default 5) bounds each probe.
32+
`realm=` are required and the check says so with **UNKNOWN**. `timeout=<ms>`
33+
(default 5000) bounds the probes; all KDCs are probed concurrently, so it also
34+
bounds the whole check even when several KDCs are unreachable.

docs/samples/CheckActiveDirectory_check_secure_channel_desc.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,18 @@ Keywords (a single row):
2424
Defaults: **CRITICAL** when `healthy = 0`; no warning threshold.
2525

2626
Options: `domain=<name>` checks the channel to a specific trusted domain
27-
(default: the domain this machine is joined to); `server=<host>` queries
28-
another computer's netlogon service.
27+
(default: the domain the checked machine is joined to); `server=<host>`
28+
queries another computer's netlogon service (its join state is then also read
29+
from that computer when `domain=` is not given).
2930

3031
**Not-joined contract:** on a workgroup or standalone machine the check
3132
returns **UNKNOWN** ("not joined to a domain") rather than a hard error, so it
3233
is safe to deploy fleet-wide.
3334

34-
Note: verifying the channel requires administrator rights on the target, which
35-
the NSClient++ service (LocalSystem) has; running the check as an unprivileged
36-
user may yield access-denied instead.
35+
**CRITICAL means a broken channel, nothing else:** when the netlogon query
36+
itself fails — the service is stopped or restarting, the caller lacks
37+
administrator rights, or the RPC connection to `server=` fails — the check
38+
returns **UNKNOWN** with the failure message instead of scoring the channel as
39+
broken. Verifying the channel requires administrator rights on the target,
40+
which the NSClient++ service (LocalSystem) has; running the check as an
41+
unprivileged user yields that UNKNOWN.

modules/CheckActiveDirectory/ad_replication_source_win.cpp

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,22 @@
33

44
#include "ad_replication_source.hpp"
55

6+
// boost/asio must precede Windows.h so winsock2.h is included first.
7+
#include <boost/asio.hpp>
8+
69
// Windows.h must precede ntdsapi.h; the capital W keeps clang-format's
7-
// case-sensitive include sort from breaking that order.
10+
// case-sensitive include sort from breaking that order. asio defines
11+
// WIN32_LEAN_AND_MEAN, which drops rpc.h from Windows.h, so pull it in
12+
// explicitly: ntdsapi.h needs RPC_AUTH_IDENTITY_HANDLE.
813
#include <Windows.h>
14+
#include <rpc.h>
15+
916
#include <ntdsapi.h>
1017

18+
#include <chrono>
1119
#include <error/error.hpp>
1220
#include <str/utf8.hpp>
21+
#include <str/xtos.hpp>
1322
#include <vector>
1423

1524
namespace ad_replication_source {
@@ -25,6 +34,40 @@ std::string local_dns_hostname() {
2534
return utf8::cvt<std::string>(std::wstring(buf.data()));
2635
}
2736

37+
// DsBindW itself has no timeout: against a black-holed host it blocks for the
38+
// full TCP retransmit window (~21s+, possibly per RPC endpoint), blowing the
39+
// transport's command timeout. Before binding to an explicitly named remote
40+
// server, require its RPC endpoint mapper (port 135) to answer a TCP connect
41+
// within a bounded deadline so an unreachable DC fails fast instead.
42+
bool can_reach_rpc(const std::string &host, int timeout_ms, std::string &error) {
43+
namespace asio = boost::asio;
44+
using boost::asio::ip::tcp;
45+
46+
asio::io_context io;
47+
tcp::resolver resolver(io);
48+
tcp::socket socket(io);
49+
bool connected = false;
50+
51+
resolver.async_resolve(host, "135", [&](const boost::system::error_code &ec, tcp::resolver::results_type results) {
52+
if (ec) {
53+
error = "resolve failed: " + ec.message();
54+
return;
55+
}
56+
asio::async_connect(socket, results, [&](const boost::system::error_code &ec, const tcp::endpoint &) {
57+
if (ec)
58+
error = "connect failed: " + ec.message();
59+
else
60+
connected = true;
61+
});
62+
});
63+
64+
io.run_for(std::chrono::milliseconds(timeout_ms));
65+
if (!connected && error.empty()) error = "no answer within " + str::xtos(timeout_ms) + "ms";
66+
boost::system::error_code ignored;
67+
socket.close(ignored);
68+
return connected;
69+
}
70+
2871
long long filetime_to_epoch(const FILETIME &ft) {
2972
const unsigned long long ticks = (static_cast<unsigned long long>(ft.dwHighDateTime) << 32) | ft.dwLowDateTime;
3073
if (ticks == 0) return 0;
@@ -47,13 +90,22 @@ bool fetch(const std::string &server, std::vector<ad_replication_filter::filter_
4790
}
4891
const std::wstring target_w = utf8::cvt<std::wstring>(target);
4992

93+
if (!server.empty()) {
94+
std::string reach_error;
95+
if (!can_reach_rpc(server, 5000, reach_error)) {
96+
error = "Cannot reach the RPC endpoint mapper on " + server + " (port 135): " + reach_error;
97+
return false;
98+
}
99+
}
100+
50101
HANDLE hds = nullptr;
51102
DWORD rc = DsBindW(target_w.c_str(), nullptr, &hds);
52103
if (rc != ERROR_SUCCESS) {
53-
// The directory service not being reachable on the target is what a bind
54-
// failure means in practice: a non-DC (or a stopped NTDS). Report it as
55-
// the not-a-DC contract rather than a replication failure.
56-
not_a_dc = true;
104+
// Only the local host gets the benign not-a-DC contract: deployed
105+
// fleet-wide, a local bind failure means a non-DC (or a stopped NTDS).
106+
// An explicitly named server failing to bind is a dead or unreachable
107+
// domain controller — the very outage the check exists to surface.
108+
not_a_dc = server.empty();
57109
error = "Failed to bind to the directory service on " + target + ": " + error::lookup::last_error(rc);
58110
return false;
59111
}

modules/CheckActiveDirectory/check_activedirectory_test.cpp

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,25 @@ TEST_F(AsReqTest, EncodesTillNonceAndEtypes) {
184184
EXPECT_EQ(23, read_int(etype_list[2])); // rc4-hmac
185185
}
186186

187+
TEST(AsReq, EncodesElementsLargerThan64k) {
188+
// A realm this large pushes the req-body (where the realm appears twice)
189+
// past 0xFFFF, forcing 3-byte long-form DER lengths; an encoder that
190+
// truncates to 2 length octets would emit a self-inconsistent blob this
191+
// independent reader could not walk.
192+
const std::string realm(70000, 'R');
193+
const bytes req = kdc_probe::build_as_req(realm, "c", 1UL);
194+
const std::vector<tlv> outer = parse_tlvs(req);
195+
ASSERT_EQ(1u, outer.size());
196+
ASSERT_EQ(0x6a, outer[0].tag);
197+
const std::vector<tlv> fields = parse_tlvs(parse_tlvs(outer[0].content)[0].content);
198+
const tlv *body = find_tag(fields, 0xa4);
199+
ASSERT_NE(nullptr, body);
200+
const std::vector<tlv> body_fields = parse_tlvs(parse_tlvs(body->content)[0].content);
201+
const tlv *realm_field = find_tag(body_fields, 0xa2);
202+
ASSERT_NE(nullptr, realm_field);
203+
EXPECT_EQ(realm, read_string(parse_tlvs(realm_field->content)[0]));
204+
}
205+
187206
TEST(AsReq, NonceWithHighBitGetsLeadingZeroByte) {
188207
// 0x80000000 would read as negative without a leading 0x00 pad octet.
189208
const bytes req = kdc_probe::build_as_req("R", "c", 0x80000000UL);

modules/CheckActiveDirectory/check_kdc.cpp

Lines changed: 84 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include <algorithm>
1616
#include <cctype>
1717
#include <chrono>
18+
#include <deque>
1819
#include <error/error.hpp>
1920
#include <memory>
2021
#include <nscapi/nscapi_program_options.hpp>
@@ -91,14 +92,45 @@ struct probe_outcome {
9192
probe_outcome() : exchanged(false), time_ms(-1) {}
9293
};
9394

95+
// The in-flight state of one KDC probe on the shared io_context.
96+
struct probe_state {
97+
explicit probe_state(boost::asio::io_context &io) : resolver(io), socket(io), done(false), timed(false) { header[0] = header[1] = header[2] = header[3] = 0; }
98+
99+
boost::asio::ip::tcp::resolver resolver;
100+
boost::asio::ip::tcp::socket socket;
101+
unsigned char header[4];
102+
bool done;
103+
// Round-trip time is measured from when the resolver answered (timed set),
104+
// so a slow DNS server is not billed to the KDC.
105+
bool timed;
106+
std::chrono::steady_clock::time_point exchange_start;
107+
probe_outcome out;
108+
109+
void start_exchange() {
110+
exchange_start = std::chrono::steady_clock::now();
111+
timed = true;
112+
}
113+
// Terminal handlers stamp the time here; measuring after the event loop
114+
// exits would bill a fast KDC for the full deadline whenever a slow one
115+
// keeps the loop running.
116+
void finish(const std::string &error) {
117+
out.error = error;
118+
if (timed) out.time_ms = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - exchange_start).count();
119+
done = true;
120+
}
121+
};
122+
94123
// One framed request/response exchange (RFC 4120 7.2.2: 4-byte big-endian
95-
// length prefix) against host:port with an overall deadline.
96-
probe_outcome exchange_with_kdc(const std::string &host, int port, int timeout_ms, const kdc_probe::bytes &request) {
124+
// length prefix) per host against host:port. All hosts are probed concurrently
125+
// on one io_context with a single deadline, so the worst case costs one
126+
// timeout rather than one per unreachable KDC. A DNS lookup the OS never
127+
// answers can still hold the io_context destructor past the deadline (asio
128+
// runs getaddrinfo on a worker thread it joins on shutdown), but at most once
129+
// for the whole batch.
130+
std::vector<probe_outcome> exchange_with_kdcs(const std::vector<std::string> &hosts, int port, int timeout_ms, const kdc_probe::bytes &request) {
97131
namespace asio = boost::asio;
98132
using boost::asio::ip::tcp;
99133

100-
probe_outcome out;
101-
102134
kdc_probe::bytes framed;
103135
framed.reserve(request.size() + 4);
104136
framed.push_back(static_cast<unsigned char>((request.size() >> 24) & 0xff));
@@ -108,68 +140,67 @@ probe_outcome exchange_with_kdc(const std::string &host, int port, int timeout_m
108140
framed.insert(framed.end(), request.begin(), request.end());
109141

110142
asio::io_context io;
111-
tcp::resolver resolver(io);
112-
tcp::socket socket(io);
113-
unsigned char header[4] = {0, 0, 0, 0};
114-
bool done = false;
115-
std::string error;
143+
// deque: the completion handlers hold references into the container.
144+
std::deque<probe_state> states;
116145

117-
const auto start = std::chrono::steady_clock::now();
118-
resolver.async_resolve(host, str::xtos(port), [&](const boost::system::error_code &ec, tcp::resolver::results_type results) {
119-
if (ec) {
120-
error = "resolve failed: " + ec.message();
121-
done = true;
122-
return;
123-
}
124-
asio::async_connect(socket, results, [&](const boost::system::error_code &ec, const tcp::endpoint &) {
146+
for (const std::string &host : hosts) {
147+
states.emplace_back(io);
148+
probe_state &st = states.back();
149+
st.resolver.async_resolve(host, str::xtos(port), [&st, &framed](const boost::system::error_code &ec, tcp::resolver::results_type results) {
125150
if (ec) {
126-
error = "connect failed: " + ec.message();
127-
done = true;
151+
st.finish("resolve failed: " + ec.message());
128152
return;
129153
}
130-
asio::async_write(socket, asio::buffer(framed), [&](const boost::system::error_code &ec, std::size_t) {
154+
st.start_exchange();
155+
asio::async_connect(st.socket, results, [&st, &framed](const boost::system::error_code &ec, const tcp::endpoint &) {
131156
if (ec) {
132-
error = "send failed: " + ec.message();
133-
done = true;
157+
st.finish("connect failed: " + ec.message());
134158
return;
135159
}
136-
asio::async_read(socket, asio::buffer(header), [&](const boost::system::error_code &ec, std::size_t) {
160+
asio::async_write(st.socket, asio::buffer(framed), [&st](const boost::system::error_code &ec, std::size_t) {
137161
if (ec) {
138-
error = "read failed: " + ec.message();
139-
done = true;
140-
return;
141-
}
142-
const std::size_t len = (static_cast<std::size_t>(header[0]) << 24) | (static_cast<std::size_t>(header[1]) << 16) |
143-
(static_cast<std::size_t>(header[2]) << 8) | static_cast<std::size_t>(header[3]);
144-
if (len == 0 || len > 512 * 1024) {
145-
error = "invalid response length";
146-
done = true;
162+
st.finish("send failed: " + ec.message());
147163
return;
148164
}
149-
out.response.resize(len);
150-
asio::async_read(socket, asio::buffer(out.response), [&](const boost::system::error_code &ec, std::size_t) {
165+
asio::async_read(st.socket, asio::buffer(st.header), [&st](const boost::system::error_code &ec, std::size_t) {
151166
if (ec) {
152-
error = "read failed: " + ec.message();
153-
out.response.clear();
154-
done = true;
167+
st.finish("read failed: " + ec.message());
168+
return;
169+
}
170+
const std::size_t len = (static_cast<std::size_t>(st.header[0]) << 24) | (static_cast<std::size_t>(st.header[1]) << 16) |
171+
(static_cast<std::size_t>(st.header[2]) << 8) | static_cast<std::size_t>(st.header[3]);
172+
if (len == 0 || len > 512 * 1024) {
173+
st.finish("invalid response length");
155174
return;
156175
}
157-
out.exchanged = true;
158-
done = true;
176+
st.out.response.resize(len);
177+
asio::async_read(st.socket, asio::buffer(st.out.response), [&st](const boost::system::error_code &ec, std::size_t) {
178+
if (ec) {
179+
st.out.response.clear();
180+
st.finish("read failed: " + ec.message());
181+
return;
182+
}
183+
st.out.exchanged = true;
184+
st.finish("");
185+
});
159186
});
160187
});
161188
});
162189
});
163-
});
190+
}
164191

165192
io.run_for(std::chrono::milliseconds(timeout_ms));
166-
out.time_ms = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start).count();
167-
if (!done) {
168-
error = "timeout after " + str::xtos(timeout_ms) + "ms";
169-
boost::system::error_code ignored;
170-
socket.close(ignored);
193+
194+
std::vector<probe_outcome> out;
195+
for (probe_state &st : states) {
196+
if (!st.done) {
197+
boost::system::error_code ignored;
198+
st.socket.close(ignored);
199+
st.resolver.cancel();
200+
st.finish("timeout after " + str::xtos(timeout_ms) + "ms");
201+
}
202+
out.push_back(st.out);
171203
}
172-
out.error = error;
173204
return out;
174205
}
175206

@@ -187,7 +218,7 @@ void check(const PB::Commands::QueryRequestMessage::Request &request, PB::Comman
187218
std::vector<std::string> servers;
188219
std::string realm;
189220
int port = 88;
190-
int timeout = 5;
221+
int timeout_ms = 5000;
191222

192223
kdc_filter::filter filter;
193224
filter_helper.add_options("time > 1000", "responding = 0", "", filter.get_filter_syntax(), "ignored");
@@ -200,7 +231,7 @@ void check(const PB::Commands::QueryRequestMessage::Request &request, PB::Comman
200231
("server", po::value<std::vector<std::string>>(&servers), "KDC host to probe; can be given multiple times (default: the KDC located via the domain join).")
201232
("realm", po::value<std::string>(&realm), "Kerberos realm to request a ticket for (default: the joined domain; required when not domain-joined).")
202233
("port", po::value<int>(&port)->default_value(88), "TCP port to probe.")
203-
("timeout", po::value<int>(&timeout)->default_value(5), "Seconds to wait for each KDC before considering it down.")
234+
("timeout", po::value<int>(&timeout_ms)->default_value(5000), "Timeout in milliseconds. All KDCs are probed concurrently, so this also bounds the whole check.")
204235
;
205236
// clang-format on
206237

@@ -228,13 +259,15 @@ void check(const PB::Commands::QueryRequestMessage::Request &request, PB::Comman
228259

229260
const kdc_probe::bytes as_req = kdc_probe::build_as_req(realm, "nscp-probe", 12345678UL);
230261

262+
const std::vector<probe_outcome> outcomes = exchange_with_kdcs(servers, port, timeout_ms, as_req);
263+
231264
parsers::where::constants::reset();
232-
for (const std::string &server : servers) {
265+
for (std::size_t i = 0; i < servers.size(); ++i) {
233266
kdc_filter::filter_obj_ptr obj(new kdc_filter::filter_obj());
234-
obj->kdc = server;
267+
obj->kdc = servers[i];
235268
obj->realm = realm;
236269
obj->port = port;
237-
const probe_outcome outcome = exchange_with_kdc(server, port, timeout * 1000, as_req);
270+
const probe_outcome &outcome = outcomes[i];
238271
obj->time = outcome.time_ms;
239272
if (outcome.exchanged) {
240273
const kdc_probe::classification c = kdc_probe::classify_response(outcome.response);

0 commit comments

Comments
 (0)