Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,17 @@ Skipped this step? The probe notices: on a first-run timeout it prints this exac
| Check | What it proves |
|---|---|
| **Status-Server** | An [RFC 5997](https://www.rfc-editor.org/rfc/rfc5997) liveness ping that runs first and **consumes no authentication attempt** — nothing shows up in the server's auth log. PASS if the server answers; a neutral INFO (never a failure) if it doesn't, since many servers leave it off. See [Liveness & multi-server](#liveness--comparing-servers). |
| **Reachability** | The server answers on UDP/1812 — and how fast. A timeout means unreachable, not listening, **or the probe isn't whitelisted / the secret is wrong** (servers silently drop unverifiable requests). |
| **Reachability** | The server answers on UDP/1812 — and how fast. The request is retransmitted the way a real NAS does before "no reply" is declared, so one lost packet shows as a WARN about loss rather than "server down". A timeout means unreachable, not listening, **or the probe isn't whitelisted / the secret is wrong** (servers silently drop unverifiable requests). Latency comes from the Status-Server reply when the server answers one; otherwise from a rejected test login, which FreeRADIUS delays by `reject_delay` (1 s by default), and the output says so. |
| **Shared secret** | Cryptographically verifies the server's reply signature. A pass *proves* the secret matches — no more guessing whether "everyone's getting rejected" is a secret problem or something else. |
| **BlastRADIUS posture** | Observes whether the server signs its replies with a **Message-Authenticator** — the mitigation for the RADIUS/UDP reply-forgery flaw [CVE-2024-3596](https://blastradius.fail) ("BlastRADIUS"). PASS if it does; WARN, with config pointers, if it accepts the probe's (signed) request but replies unsigned. Observation only — see below. |

Reachability, shared secret and BlastRADIUS posture are answered by **one** request (a PAP-shaped Access-Request for the synthetic user `authhound-probe`, which the server rejects), so a run leaves one rejected-login line in the server's log rather than three. Only Status-Server consumes no authentication attempt at all. When the server does not answer that request, the auth, certificate and MTU checks are skipped immediately instead of each waiting out the timeout.
| **PAP authentication** | A real login with credentials you supply → Accept or Reject, decoded. Also detects an **MFA/second-factor challenge** and reports it (the probe does not complete push/OTP — see below). |
| **PEAP-MSCHAPv2** | The method most enterprise 802.1X networks actually run: a real inner authentication inside the PEAP TLS tunnel. Reports success — and verifies the server's own MSCHAPv2 proof (mutual auth) — or the decoded reason on rejection. The "can my users actually log in?" test. |
| **EAP-TTLS (PAP)** | A real login inside the TTLS tunnel using inner PAP. Because the password is checked in cleartext (safe inside the tunnel), TTLS-PAP works against *any* backend — including hashed stores that MSCHAPv2 can't use. If PEAP-MSCHAPv2 fails but this passes, the directory can't produce an NT hash. |
| **EAP-TLS** | Certificate-based login (no password): presents a client certificate and reports whether the server accepts it — with a plain-English reason on failure (untrusted CA, expired cert, policy reject). See [EAP-TLS: preparing a client certificate](#eap-tls-preparing-a-client-certificate). |
| **Authorization / VLAN** | On any successful login, decodes and prints the authorization the Access-Accept returned (VLAN, Filter-Id, Session-Timeout, vendor attributes) — and lets you **assert** on it with `--expect-vlan` / `--expect-attr` so "auth works, wrong VLAN" fails loudly. See [Verifying policy](#verifying-policy-not-just-connectivity). |
| **Server certificate** | Establishes the PEAP/TLS tunnel over RADIUS, captures the server's certificate, and flags **expiry**, an incomplete intermediate chain, a **name mismatch** against `--server-name` (FAIL — clients validating that name would reject the handshake), and the negotiated TLS version. Without `--server-name`, name validation is **skipped and reported as a WARN** — the probe never claims "valid" for a name it didn't check. The "Wi-Fi died overnight" outage, caught early. |
| **Server certificate** | Establishes the PEAP/TLS tunnel over RADIUS, captures the server's certificate, and flags **expiry** (of the certificate and of any CA certificate sent with it), a missing intermediate (only when the issuer is demonstrably an intermediate — a certificate issued straight from a private root your clients trust is normal and is not warned about), a **name mismatch** against `--server-name` (FAIL — clients validating that name would reject the handshake; a match by Common Name only, with no SAN, is a WARN because Windows accepts it while Android/iOS do not), and the negotiated TLS version. Without `--server-name`, name validation is **skipped and reported as a WARN** — the probe never claims "valid" for a name it didn't check. The "Wi-Fi died overnight" outage, caught early. |
| **Path MTU / fragmentation** (`--mtu`) | Finds the largest RADIUS packet that survives the round trip. Pinpoints the invisible failure where a firewall or VPN drops large / IP-fragmented UDP, so the multi-kilobyte EAP-TLS certificate flight never arrives and 802.1X silently stalls — while every server-side log looks clean. |
| **RadSec** (`radsec test`) | Checks a RADIUS/TLS endpoint on TCP/2083: reachability, TLS handshake, server certificate, and a RADIUS exchange over the tunnel. For modern deployments and UDP→RadSec migration readiness. |

Expand Down Expand Up @@ -206,7 +208,9 @@ $ authhound-probe radsec test --server radius.corp.com \
| `--expect-vlan ID` | Assert the Access-Accept assigns this VLAN (`Tunnel-Private-Group-ID`). Mismatch = **FAIL** — see [Verifying policy](#verifying-policy-not-just-connectivity). |
| `--expect-attr Name=Value` | Assert a returned authorization attribute (repeatable), e.g. `--expect-attr Filter-Id=staff`. Mismatch = **FAIL**. |
| `--mtu` | Run the path-MTU / fragmentation probe (sends a few padded packets). |
| `--count N` | Run the checks `N` times (2–50) and report aggregate statistics — see [Chasing intermittent failures](#chasing-intermittent-failures). |
| `--count N` | Run the checks `N` times (2–50) and report aggregate statistics — see [Chasing intermittent failures](#chasing-intermittent-failures). Stops early if the server rejects the test credentials (repeating a rejected password locks accounts). |
| `--called-station-id VALUE` | Send a Called-Station-Id, e.g. `AA-BB-CC-DD-EE-FF:CorpWiFi`. Wireless policies (NPS conditions, FreeRADIUS unlang) often match the SSID here; without it the probe can land on a different policy than real clients. |
| `--calling-station-id VALUE` | Send a Calling-Station-Id, e.g. the client MAC `11-22-33-44-55-66`, for MAC-based policies. |
| `--interval DURATION` | Pause between `--count` iterations (default `2s`; a hard-coded safety floor applies). |
| `--nas-port-type wireless\|ethernet\|virtual` | How the probe presents itself, so server policies match (default `wireless`). |
| `--server-name NAME` | Name the server certificate must be valid for (also sent as TLS SNI). Mismatch = **FAIL**; omitted = name validation skipped, reported as a **WARN** — see [Certificate name validation](#certificate-name-validation---server-name). |
Expand Down Expand Up @@ -265,6 +269,20 @@ $ authhound-probe radius test --server radius.corp.com --peap alice \
--nas-port-type ethernet --expect-vlan 30
```

`NAS-Port-Type` is not the only input a policy branches on. Wireless policies
routinely match the **SSID** in `Called-Station-Id`, and MAC-based rules match
the client MAC in `Calling-Station-Id`; if the probe doesn't send them, it can
match a different policy than your real clients do (a different VLAN, or a
"did not match any network policy" reject). Present the probe the way the real
NAS does:

```console
# as an AP on the CorpWiFi SSID, from a specific client MAC
$ authhound-probe radius test --server radius.corp.com --peap alice \
--called-station-id 'AA-BB-CC-DD-EE-FF:CorpWiFi' \
--calling-station-id '11-22-33-44-55-66' --expect-vlan 20
```

`--expect-attr` generalises this to any returned attribute (repeatable), so you can
pin a whole policy in a scheduled check:

Expand Down Expand Up @@ -323,6 +341,10 @@ How to read it:
Iterations run sequentially, `--interval` apart (default `2s`). The probe's
hard-coded rate ceiling still bounds everything: intervals below the safety
floor are stretched (and the stretch announced), and `--count` is capped at 50.
If the server **rejects the test credentials**, the loop stops after that
iteration and says so: re-sending a rejected password 50 times is how a test
account trips the domain's lockout threshold, and the aggregate would then
report a lockout as "consistent failure". Fix the credentials and re-run.
This is a diagnosis loop you babysit, not monitoring — it never schedules,
repeats forever, or stores anything between runs.

Expand Down Expand Up @@ -513,6 +535,18 @@ transmitted or logged. If the cert is untrusted, expired, or rejected by policy,
the probe says exactly which — so you know whether to fix the cert, the CA trust,
or the server's authorization rules.

## What's new in v0.5.0

- **Retransmits.** A lost UDP datagram is re-sent the way a real NAS does before "no reply" is declared, so one dropped packet is a warning about packet loss rather than a false "server is down". Scheduled `--strict` runs stop paging on single drops.
- **One request for three checks.** Reachability, shared secret and BlastRADIUS posture share a single Access-Request, so a run leaves one rejected test login in the server's log instead of three. Checks that depend on reachability skip immediately when the server is silent, instead of each waiting out the timeout.
- **Honest latency.** The round-trip figure comes from the Status-Server reply when the server answers one; otherwise the output says it includes FreeRADIUS's `reject_delay`.
- **Better certificate verdicts.** A certificate issued straight from a private root is no longer reported as an incomplete chain; CA certificates sent with it are checked for expiry; a Common-Name-only match (no SAN) is a warning rather than a failure, because Windows accepts it and modern clients don't.
- **Clearer rejects.** A wrong PEAP password is reported as a credential reject instead of "the server may not offer PEAP", and an EAP-TLS handshake that failed before the server asked for a certificate is no longer blamed on the certificate.
- **Lockout guard.** `--count` stops as soon as the server rejects the test credentials, instead of re-sending a rejected password up to 50 times.
- **New flags** `--called-station-id` and `--calling-station-id`, plus IPv6 literals in `--server`.

Full detail in the [release notes](https://github.com/authhound/probe/releases/tag/v0.5.0).

## Install

**Binary** (Linux, macOS, Windows — single static file, no runtime):
Expand Down
52 changes: 35 additions & 17 deletions cmd/authhound-probe/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ func cmdRadiusTest(args []string) int {
clientKey := fs.String("client-key", "", "client private key (PEM) for the EAP-TLS test")
nasID := fs.String("nas-id", "authhound-probe", "NAS-Identifier to send")
nasPortType := fs.String("nas-port-type", "wireless", "NAS-Port-Type: wireless, ethernet, or virtual")
calledStation := fs.String("called-station-id", "", "Called-Station-Id to send, e.g. 'AA-BB-CC-DD-EE-FF:CorpWiFi' (policies often match the SSID here)")
callingStation := fs.String("calling-station-id", "", "Calling-Station-Id to send, e.g. the client MAC '11-22-33-44-55-66' (MAC-based policies match here)")
serverName := fs.String("server-name", "", "name the server certificate must be valid for (also sent as TLS SNI); omitted = name validation skipped and reported as a warning")
expectVLAN := fs.String("expect-vlan", "", "assert the Access-Accept assigns this VLAN (Tunnel-Private-Group-ID); a mismatch is a FAIL")
var expectAttr stringSliceFlag
Expand Down Expand Up @@ -274,11 +276,16 @@ func cmdRadiusTest(args []string) int {
// Address is set per server below; everything else is shared across a
// multi-server comparison.
target := check.Target{
Secret: secretValue,
Timeout: *timeout,
NASIdentifier: *nasID,
NASPortType: portType,
LocalAddr: localAddr,
Secret: secretValue,
Timeout: *timeout,
NASIdentifier: *nasID,
NASPortType: portType,
CalledStation: *calledStation,
CallingStation: *callingStation,
LocalAddr: localAddr,
}
if provided["timeout"] && *timeout < 2*time.Second {
fmt.Fprintf(os.Stderr, "note: --timeout %s is short: FreeRADIUS delays every Access-Reject by 1s (reject_delay), so a rejected reply can be misread as a lost packet; 2s or more is safer\n", *timeout)
}

papUser, papPass, err := resolveCreds(prompter, *pap, "--pap", *passwordFile)
Expand Down Expand Up @@ -317,17 +324,21 @@ func cmdRadiusTest(args []string) int {
// Status-Server runs first: an RFC 5997 liveness ping that doesn't consume an
// auth attempt. The rest follow in dependency order (reachability/secret before
// the auth methods that rely on them).
// One shared exchange answers reachability, shared-secret and posture (one
// rejected-login line in the server log per run instead of three), and its
// outcome lets the dependent checks skip at once when the server is silent.
base := &check.BaseExchange{}
checks := []check.Check{
check.StatusServer{},
check.Reachability{},
check.SharedSecret{},
check.BlastRADIUS{},
check.PAP{User: papUser, Pass: papPass},
check.PEAPMSCHAPv2{User: peapUser, Pass: peapPass, ServerName: *serverName},
check.EAPTTLS{User: ttlsUser, Pass: ttlsPass, ServerName: *serverName},
check.EAPTLS{CertFile: *clientCert, KeyFile: *clientKey, ServerName: *serverName},
check.ServerCert{ServerName: *serverName},
check.MTUProbe{Enabled: *mtu},
check.StatusServer{Base: base},
check.Reachability{Base: base},
check.SharedSecret{Base: base},
check.BlastRADIUS{Base: base},
check.PAP{User: papUser, Pass: papPass, Base: base},
check.PEAPMSCHAPv2{User: peapUser, Pass: peapPass, ServerName: *serverName, Base: base},
check.EAPTTLS{User: ttlsUser, Pass: ttlsPass, ServerName: *serverName, Base: base},
check.EAPTLS{CertFile: *clientCert, KeyFile: *clientKey, ServerName: *serverName, Base: base},
check.ServerCert{ServerName: *serverName, Base: base},
check.MTUProbe{Enabled: *mtu, Base: base},
}

if len(servers) > 1 {
Expand Down Expand Up @@ -368,7 +379,12 @@ func parseServers(raw string) ([]string, error) {
if s == "" {
continue
}
if !strings.Contains(s, ":") {
switch {
case net.ParseIP(s) != nil && strings.Contains(s, ":"):
s = "[" + s + "]:1812" // bare IPv6 literal
case strings.HasPrefix(s, "[") && !strings.Contains(s, "]:"):
s += ":1812" // bracketed IPv6 literal without a port
case !strings.HasPrefix(s, "[") && !strings.Contains(s, ":"):
s += ":1812"
}
if seen[s] {
Expand Down Expand Up @@ -498,7 +514,9 @@ func runRepeat(plan check.Plan, sink resultSink, count int, interval time.Durati
fmt.Fprintln(os.Stderr, "error:", err)
return 2
}
if completed := len(run.Iterations); completed < count {
if run.StoppedReason != "" {
fmt.Fprintln(os.Stderr, "note:", run.StoppedReason)
} else if completed := len(run.Iterations); completed < count {
fmt.Fprintf(os.Stderr, "interrupted — aggregating the %d completed iteration(s)\n", completed)
if completed == 0 {
return 1
Expand Down
3 changes: 2 additions & 1 deletion docs/json-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Each entry in `results`:
| `summary` | string | always | One plain-English line describing the outcome. |
| `detail` | string | when present | Extra context. Omitted when empty. |
| `hint` | string | when present | Multi-line, paste-ready remediation. Newline formatting is significant. Omitted when empty. Never contains secrets. |
| `fields` | object (string→string) | when present | Structured extras such as `rtt_ms`, `tls_version`, `not_after`, `subject`, `san`, `chain_len`, `source_ip`. On the `status-server` check, `supported` is `"true"` (the server answered the RFC 5997 liveness query) or `"false"` (it didn't — which is fine; that check never fails). `blastradius_posture` (on the `blastradius-posture` check) is `"signed"` or `"unsigned"` — whether the server signed its reply with a Message-Authenticator (see [BlastRADIUS posture](../README.md#blastradius--message-authenticator-posture)). `timeout: "true"` marks a request that got no reply at all (a *lost* request, as opposed to a processed rejection). On `server-cert` and `radsec-cert`, `name_validation` is `"match"`, `"mismatch"` (a `fail`), or `"skipped"` (no `--server-name` given — a `warn` when everything else is healthy, so a run that never validated the name is never a silent pass). Aggregate verdicts under `--count` add `success_rate`, `attempts`, `successes`, `timeouts`, and `latency_{min,median,p95,max}_ms`. Keys vary by check; values are always strings. Omitted when there are none. |
| `fields` | object (string→string) | when present | Structured extras such as `rtt_ms`, `tls_version`, `not_after`, `subject`, `san`, `chain_len`, `source_ip`. On the `status-server` check, `supported` is `"true"` (the server answered the RFC 5997 liveness query) or `"false"` (it didn't — which is fine; that check never fails). `blastradius_posture` (on the `blastradius-posture` check) is `"signed"` or `"unsigned"` — whether the server signed its reply with a Message-Authenticator (see [BlastRADIUS posture](../README.md#blastradius--message-authenticator-posture)). `timeout: "true"` marks a request that got no reply at all (a *lost* request, as opposed to a processed rejection). On `server-cert` and `radsec-cert`, `name_validation` is `"match"`, `"mismatch"` (a `fail`), or `"skipped"` (no `--server-name` given — a `warn` when everything else is healthy, so a run that never validated the name is never a silent pass). Aggregate verdicts under `--count` add `success_rate`, `attempts`, `successes`, `timeouts`, and `latency_{min,median,p95,max}_ms`. On `reachability`, `rtt_source` is `"status-server"` (undelayed liveness reply; `auth_rtt_ms` then carries the auth request's time) or `"access-reject"` (measured on a rejected test login, which FreeRADIUS delays by `reject_delay`); `retransmits` (also on the EAP checks) counts datagrams that had to be re-sent before a reply, i.e. observed packet loss. On the auth checks, `credential_rejected: "true"` marks a reject the server issued after processing the credentials (repeat mode stops on it). On `server-cert`/`radsec-cert`, `name_validation` also has the value `"cn-only"` (a `warn`: matches by Common Name only, no SAN) and `chain` is `"complete"`, `"self-signed"`, or `"leaf-only"`. Keys vary by check; values are always strings. Omitted when there are none. |
| `duration_ns` | integer | when present | How long the check took, in nanoseconds. Omitted when zero. |
| `authorization` | object | when present | On an auth check that reached an Access-Accept, the authorization attributes the server returned (VLAN/Filter-Id/…) and the outcome of any `--expect-vlan`/`--expect-attr` assertions. See below. Omitted otherwise. |

Expand Down Expand Up @@ -129,6 +129,7 @@ the raw material:
| `interval_ms` | integer | Pause between iterations actually used. |
| `requested_interval_ms` | integer | The `--interval` that was asked for. |
| `interval_stretched` | boolean | `true` when the requested interval was below the hard-coded safety floor and got stretched. |
| `stopped_early` | string | when present | Additive: the loop ended before `count` on purpose because the server rejected the test credentials (repeating would lock the account). Absent otherwise. |
| `iterations` | array | One entry per completed iteration, each with its `results` (same result-object shape as the top level). |
| `aggregate` | array | Per-check tallies. `successes` = the server answered and processed the request (pass/warn/info); `timeouts` = the subset of `failures` where no reply arrived at all. `latency_ms` (nearest-rank percentiles over answered runs) is omitted when nothing was answered. |

Expand Down
Loading
Loading