HTTP API reference¶
Ports and listeners¶
Before the endpoint tables, the port map. Knowing which listener serves what is half of this page:
| Port | Default | Serves | Who talks to it |
|---|---|---|---|
httpPort |
8080 | the HTTP API below, plus /metrics for backward compatibility |
Console, kubectl-kconmon, curl |
metricsPort |
9091 | /metrics, /healthz, /readyz; on the controller also GET /api/v1/prometheus/sd, the external-agent target list |
Prometheus (this is the port the chart scrapes, and the one its ScrapeConfig reads discovery from) |
grpcPort |
9090 | agent-controller gRPC; on agents, also the UDP probe server | the fleet itself |
controller.externalGateway.port |
9443 | a second gRPC listener: TLS plus a bearer token, for external agents | bare-host agents |
The gateway adds no HTTP surface. It is gRPC only, serving the same services on the same registry as the in-cluster listener; there is nothing on it to port-scan for.
None of the HTTP endpoints authenticate. The only gate is leader election,
which is availability, not authorization: a standby's registry is empty by
design, so it answers 503 and clients ask another replica. That is exactly
why /metrics got a listener of its own: a NetworkPolicy cannot say "this
port, but only these paths", so admitting a scraper to the API port admitted
the scraper's whole namespace to the fleet's control plane. Two listeners make
"let Prometheus in" and "let this caller drive the fleet" two separate
decisions. The one API route on the metrics listener follows the same logic: the external-agent target list is served there precisely
because that is the port the scrape rule opens, so discovery needs no second
hole in the policy. It is read-only and discloses only what a scraper is
about to scrape anyway.
Agent¶
| Endpoint | Method | Description |
|---|---|---|
/healthz |
GET | Liveness probe; always 200 ok |
/readyz |
GET | Readiness probe; 503 until peer watch is confirmed |
/metrics |
GET | Prometheus metrics (also on metricsPort) |
/api/v1/version |
GET | {"version":"…","commit":"…"}; no capability field |
Controller¶
| Endpoint | Method | Description |
|---|---|---|
/healthz |
GET | Liveness probe; always 200 ok |
/readyz |
GET | Readiness probe; 503 until gRPC server is bound |
/metrics |
GET | Prometheus metrics (also on metricsPort) |
/api/v1/topology |
GET | JSON snapshot of all registered agents and cluster nodes with zone info (leader only) |
/api/v1/version |
GET | Build info plus capability flags; see below |
/api/v1/diagnostics |
POST | Run a one-shot connectivity check between two nodes (leader only) |
/api/v1/external-checks |
PUT | Replace the fleet's continuous external-check assignment (leader only) |
/api/v1/prometheus/sd |
GET | Prometheus HTTP SD body listing external agents as scrape targets (leader only; also served on metricsPort) |
GET /api/v1/version¶
{
"version": "…",
"commit": "…",
"capabilities": ["events"],
"externalAllowedCidrs": ["10.0.0.0/8"]
}
capabilities advertises what this build serves, and the Console
feature-detects on it instead of version-sniffing. The set a controller can
advertise today is exactly one flag: events, present when
controller.events.enabled is on. external-checks is a different animal:
an agent capability, asserted at registration and visible per agent in
the topology snapshot, never on this endpoint. externalAllowedCidrs echoes
the agent-side allowlist (config.checkers.external.allowedCidrs), published
because the Console cannot otherwise know it: a target outside those CIDRs
can never be probed, and that is worth saying at creation time instead of as
a timeout later. Empty when the external checker is off.
GET /api/v1/topology¶
Leader only; a non-leader returns 503 not the leader, which the Console and
CLI treat as "ask another replica", since answering from a standby would
report a topology with no agents.
{
"nodes": [
{ "name": "node-1", "zone": "us-east-1a", "ready": true },
{ "name": "node-2", "zone": "us-east-1b", "ready": true }
],
"agents": [
{
"id": "node-1-kconmon-ng-agent-xxxxx",
"nodeName": "node-1",
"podIP": "10.0.0.1",
"zone": "us-east-1a",
"capabilities": ["external-checks", "plane:tcp", "plane:udp", "plane:icmp", "plane:pmtu", "plane:dns", "plane:mtr"],
"httpPort": 8080,
"udpPort": 9090,
"metricsPort": 9091
},
{
"id": "edge-host-01-edge-host-01",
"nodeName": "edge-host-01",
"podIP": "203.0.113.10",
"zone": "external",
"labels": { "kconmon-ng.io/external": "true" },
"capabilities": ["external-checks", "plane:tcp", "plane:udp", "plane:icmp", "plane:pmtu", "plane:dns", "plane:mtr"],
"httpPort": 18080,
"udpPort": 19090,
"metricsPort": 19091
}
],
"timestamp": "2025-01-01T00:00:00Z"
}
httpPort, udpPort and metricsPort are the listener ports the agent
reported at registration (2.4.0 and newer): the TCP probe target, the UDP
echo target, and the /metrics listener that is never probed but feeds
scrape discovery. They are omitted for an agent
that reported none, and a probing peer then falls back to its own configured
ports, which is why a mixed fleet keeps one port set
(External agents). labels is the agent's own
registration map, verbatim: kconmon-ng.io/external: "true" on any agent
running outside a Pod, and kconmon-ng.io/host-network: "true" on a 2.4.0
agent image running under agent.hostNetwork, whose podIP is then the
node's address.
While a sparse topology plan is in force (topology.mode: sparse, fleet at or
above topology.sparse.autoThreshold), the snapshot also carries probePlan:
source node name → the sorted node names it is planned to probe. The field is
absent on a full-mesh fleet: absence means "every pair is intended", which
keeps pre-sparse payloads unchanged. A node mapped to an empty list is planned
to probe nobody (the plan's fail-closed state until its agent re-registers).
"probePlan": {
"node-1": ["node-2", "node-3"],
"node-2": ["node-3"],
"node-3": ["node-1", "node-2"]
}
An external agent appears as an ordinary fleet member
with two tells: its labels carry kconmon-ng.io/external: "true" (stamped
automatically on any agent running outside a Pod), and podIP holds its
advertised address rather than a pod IP (the field name predates bare-host
agents, but the value is always "the address peers probe"). capabilities
lists what each agent build advertised at registration; a pre-v1.6.0 agent
sends none. Since 2.4.0 the list also carries plane:<protocol> entries
naming the probe planes the agent runs (one per enabled checker, plane:mtr
always; plane:pmtu since 2.5.0): an agent with no plane: entry at all is
older than 2.4.0 and must be read as running every plane, never as running
none. The Console applies
exactly that fail-open rule.
GET /api/v1/prometheus/sd¶
The Prometheus
HTTP service discovery
body for external agents: one
target group per agent registered from outside the cluster, so that a bare
host, which no ServiceMonitor can select, is still scraped. Mounted on
httpPort and, because that is the port the scrape NetworkPolicy opens, on
metricsPort too. GET only; the mux answers 405 to anything else.
[
{
"targets": ["203.0.113.10:19091"],
"labels": {
"node": "edge-host-01",
"zone": "external",
"external": "true",
"agent_id": "edge-host-01-edge-host-01"
}
}
]
Headers are Content-Type: application/json and Cache-Control: no-store:
every refresh must see the registry as it is now, since a cached body would
outlive an eviction. The rules:
- One group per external agent, ordered by node name (agent ID breaks
ties) and deduplicated by target address, first in that order wins: a
rolling restart on a host can briefly leave two records at one address,
which Prometheus would otherwise scrape twice under two label sets. IPv6
addresses are bracketed (
[fd00::1]:9091). In-cluster agents never appear. - The port is the agent's reported
metricsPort(agents 2.4.0 and newer). An older agent reports none, and the controller substitutes its ownconfig.metricsPortas captured at startup, loggingmetrics port assumed from controller configonce per agent ID (fieldsagent,node,address,port), so a host on a non-default port is diagnosable instead of silentlyup == 0. - The label set is fixed:
node,zone,external(always"true") andagent_id. The agent's own registration labels are never copied through: an agent owns its labels map, and copying it would let a host inject arbitrary target labels,__address__included, into Prometheus. - No external agents is the literal
[]with200, the same convention as every other list this API serves. - Leader only, through the same gate as
/api/v1/topology: a standby answers503 not the leaderastext/plain. Never200 []: Prometheus reads every200as the complete new target list, so an empty body from a standby would drop every external target, whereas on a non-200 it keeps the list it has, which is exactly right until the next refresh lands on the leader. With more than one replica behind the Service, expectprometheus_sd_http_failures_totalto climb on the standby's share of refreshes while the targets stay right. - Off (
controller.prometheusSD.enabled: false, defaulttrue) means404on both ports, for operators who would rather not disclose external hosts' addresses to everything admitted tometricsPort. The chart writes the key into the shared ConfigMap only when it isfalse, so a controller image older than 2.4.0 never sees it. The trust consequences are in SECURITY.md.
POST /api/v1/diagnostics¶
Runs a single on-demand check from a source node's agent to a destination node
and returns the resulting CheckResult verbatim. An agent that reported no
payload gets a CheckResult built from the task instead: type, source,
destination, success, error and a timestamp (the agent's, or the
controller's clock when the agent sent none), so the body is never empty and
the terminal CheckObserved or MTRCompleted event is published either way.
This is the endpoint the kubectl-kconmon plugin drives. Only the controller leader serves it (a
non-leader replica returns 503), because only the leader holds the
authoritative agent registry and their task streams.
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
source |
string | yes | Node name whose agent runs the probe |
destination |
string | * | Node name to probe (destinationKind=node), or the target's NAME for an external one |
type |
string | yes | One of tcp, udp, icmp, pmtu, dns, http, mtr |
plane |
string | no | Traffic plane; only pod, the default, is accepted |
destinationKind |
string | no | node (default) or external |
destinationAddress |
string | * | Required for destinationKind=external; the address to probe |
An optional ?timeout=<seconds> query parameter caps the dispatch wait. It
defaults to 60 and is capped at 120; invalid values fall back to 60.
The negotiated timeout also governs the response: this endpoint extends
its own connection write deadline to cover it. Every other route on the
controller's HTTP server keeps the short server-wide 10s write budget, which
is why an mtr trace that spends 30s on silent TTLs still gets its answer
delivered here and nowhere else. A client must therefore be prepared to wait
the full negotiated timeout for the first response byte, and must not impose a
shorter whole-request timeout of its own.
External destinations. With destinationKind=external the
destination is not resolved against the agent registry: destinationAddress
carries the address and destination, when present, only names it. The name,
never the address, is what published events and metrics report as the
destination, since an address must not become an identifier downstream. The
address is treated as kind=host (an address with no scheme is a host; url
targets arrive through the Console's stored target objects, not this body),
and the port is left to the check type's own default.
Two gates apply and both are the agent's, not the controller's:
- The source agent must advertise the
external-checkscapability. A pre-v1.6.0 agent silently ignores the external field, so the controller refuses up front with501instead of letting the request time out mysteriously. - The source agent's own
checkers.externalallowlist decides whether the probe happens. The controller does not consult it and cannot override it: the split exists because the socket carries the same bytes the REST routes do and must ask the same permission.
Only tcp, icmp and mtr reach an external destination, as the agent
already enforced. pmtu probes another agent's UDP echo listener, so
type=pmtu with destinationKind=external is a 400, and so are udp,
dns and http, which the agent would refuse.
The body starts external destinations support tcp, icmp and mtr checks
only; and gives the reason: for udp, udp counts replies from the kconmon
probe server, which only another agent runs, so destinationKind must be
node; for dns and http, <type> is not a one-off external check; use
destinationKind node, or a continuous external check for an external
resolver or URL.
A plane other than pod or empty is a 400 too, plane must be "pod":
agents probe the pod network only. kubectl kconmon check --plane keeps the flag for scripts, accepts only
pod and exits 1 on anything else without contacting the controller.
Every check also needs a source agent that runs that probe type. An agent
that lists its planes without plane:<type> gets a 501 instead of a task
it would refuse, for peer and external destinations alike: agent on node
<source> does not run <type> probes (checkers.<type>.enabled=false). For
pmtu the reason reads older than 2.5.0 or checkers.pmtu.enabled=false;
http is off in the default chart, so an http check on a default install
gets this 501. An agent that lists no planes at all is dispatched as
before. The 501 is counted as result="unsupported", a Console run or a
scheduled check of that type ends as an error rather than a failed probe,
and kubectl kconmon check exits 1 on it with controller returned HTTP 501:
... instead of printing FAIL and exiting 2.
Status codes:
| Code | Meaning |
|---|---|
200 |
Check dispatched and completed; body is the CheckResult JSON |
400 |
Malformed JSON, missing source/destination/type, an invalid type, a type other than tcp, icmp or mtr with destinationKind=external, or a plane other than pod |
413 |
The body is over 64 KiB (request body exceeds 65536 bytes) |
404 |
No agent registered on the source/destination node, or no active task stream |
501 |
The source agent does not run probes of this type (it lists its planes without plane:<type>), or destinationKind=external and it does not advertise external-checks |
502 |
The dispatch failed for a reason other than timeout or a missing task stream |
503 |
This replica is not the leader, or it lost leadership while the check was in flight (leadership lost); retry as for not the leader |
504 |
The check did not complete before the timeout |
A caller whose own connection to the controller closes before the answer (a
cancelled Console run, an HTTP client that gives up) gets no response, and
the attempt is counted as result="cancelled" in
kconmon_ng_controller_diagnostics_total rather than as an error. Ctrl-C in
kubectl kconmon is not one of them: the CLI reaches the controller through
a Kubernetes port-forward, which keeps the pod-side connection open after the
CLI exits, so the controller never sees it go and counts the check by its
outcome, usually result="ok". A check that ran but whose answer could not be written is
counted as result="undelivered", logged at ERROR, and followed by a
DiagnosticProgress event with state: undelivered after its
CheckObserved or MTRCompleted.
A 200 only means the check ran; inspect success to see whether it
passed. Durations are serialized as integer nanoseconds (Go time.Duration).
ICMP example ({"source":"node-1","destination":"node-2","type":"icmp"}):
{
"type": "icmp",
"success": true,
"source": "node-1",
"destination": "node-2",
"sourceZone": "us-east-1a",
"destZone": "us-east-1b",
"duration": 1520000,
"details": {
"rtt": 2100000,
"lossRatio": 0
}
}
MTR example ({"source":"node-1","destination":"node-2","type":"mtr"}):
{
"type": "mtr",
"success": true,
"source": "node-1",
"destination": "node-2",
"sourceZone": "us-east-1a",
"destZone": "us-east-1b",
"duration": 8300000,
"details": {
"target": "10.244.0.12",
"hops": [
{ "number": 1, "ip": "10.244.0.1", "rtt": 480000, "lossRatio": 0 },
{ "number": 2, "ip": "", "rtt": 0, "lossRatio": 1 },
{ "number": 3, "ip": "10.244.0.12", "rtt": 2100000, "lossRatio": 0 }
]
}
}
PUT /api/v1/external-checks¶
Replaces the fleet's continuous external-check assignment. This is the endpoint the Console's reconciler drives; you can also call it directly. Leader only, for the same registry reason as diagnostics.
The body carries the whole desired state, keyed by agent ID: an absolute
assignment, never a delta, mirroring how the assignment is fanned out to
agents (ExternalCheckAssignment is a complete replacement too):
{
"agents": {
"node-1-kconmon-ng-agent-xxxxx": [
{
"definitionId": "…",
"target": { "name": "corp-dns", "kind": "host", "address": "10.20.0.53", "port": 0 },
"checkType": "dns",
"intervalNs": 30000000000,
"timeoutNs": 5000000000,
"params": { "query": "example.internal" }
}
]
}
}
checkType must be one of tcp, icmp, dns, http. udp, pmtu and
mtr are refused with 400. The UDP and path MTU probes speak a peer-to-peer
protocol against another agent's probe server, which no external host runs,
and a continuous MTR
against an internet destination is a traffic and cardinality decision
(mtr_hop_rtt_seconds is labelled by hop_ip, unbounded for internet paths).
One-shot MTR to a target still works through diagnostics above. port: 0
means the check type's own default.
Status codes: 503 when not the leader; 400 for invalid JSON, an invalid
checkType, or unparseable params; 413 for a body over 8 MiB (about
30,000 specs); a bad spec fails the whole
body, which is why the Console's reconciler filters ineligible definitions
before calling. It also leaves out whole definitions, newest first, when the
body would pass 8 MiB
(kconmon_ng_console_external_specs_skipped_total{reason="over-budget"}). Agent IDs the registry does not know are not an error:
the Console's topology view can legitimately lag the registry. An unknown
agent that already holds an assignment gets this PUT's specs, so an agent
evicted for a moment keeps probing on the current list; one named with an
empty list is cleared, like one the PUT leaves out. An ID that holds nothing
is not recorded. Unknown IDs are logged with a warning and reported back
instead of blocking every other agent's assignment. The 200 response says what
happened:
agents is how many agents ended up with a non-empty assignment, changed
how many were actually pushed (0 on a retried identical PUT), unknown the
IDs the registry did not know, whether or not their specs were stored.
Console run planning (POST /api/v1/runs)¶
Diagnostics runs (many pairs, optionally repeated over a duration) are a Console endpoint, documented in the Console API reference with the object model in Checks, runs and schedules. Two pieces of its behavior are worth knowing from the controller side, because the controller's dispatch limits shape them.
Cadence. A duration run re-probes each pair on a cadence derived from the
duration (duration / 500, floored at 5s). That base cadence is not a field
an operator can set, so a check type too slow to keep it is re-planned, never
refused: the effective interval is the base cadence stretched to one round's
floor. Only mtr and pmtu stretch, and both are planned around their
per-pair timeout: a traceroute walks up to 30 hops in sequence (90s floor),
and a pmtu probe of a black-holed pair waits out a datagram timeout per lost
datagram, up to about 10s (15s floor). Every other type is planned as if it
answered in milliseconds, its per-pair timeout only bounding a probe that has
already failed. One round's floor also counts the fan-out: 90 mtr pairs are 12 batches of 90s, so
a large run can stretch past its own duration and settle at a single full
pass. Rounds repeat until the duration elapses, back to back but no more often
than the base cadence; a round slower than the remaining time is not cut
short, it finishes and the run ends there. MaxSamplesPerPair (500) is the
true upper bound on rounds. plannedSampleIntervalNs and
plannedSamplesPerPair, returned on POST /api/v1/runs and snapshotted onto
the run's spec (both omitted for instant runs), are a worst-case floor, not
a target: read plannedSamplesPerPair as "at least N per pair, more when
probes run fast". Nothing is refused for cadence reasons (every shape yields
at least one sample per pair), though a spec that cannot expand at all is
still rejected 422 (too many pairs, no pairs to check, run duration out
of range).
Failover. A run in flight when the controller leader changes loses the
pairs dispatched into the takeover window. The new leader starts with an empty
agent registry and agents re-register over the following seconds (roughly 15s
end to end: lease acquisition plus the agents' own reconnect backoff), so
pairs dispatched in that window come back as 503, 404 or a dispatch
timeout depending on how far they got. Those pairs are recorded as failed with
the reason the controller gave, the run reaches a terminal status normally,
and its summary says it is partial rather than quietly coming up short. There
is no repair path and none is wanted: re-run it once the topology page shows
every node again.