Skip to content

authhound/probe

Repository files navigation

authhound-probe

Test any RADIUS server — FreeRADIUS, Windows NPS, or cloud — from inside your network.

CI Go Report Card License

A RADIUS server only logs the requests that reach it. A huge class of 802.1X failures happens before that — a firewall eating UDP, a NAT rewriting the source, the wrong shared secret on one switch, an expired server certificate, EAP fragments dropped in the path. The dashboard (or your radiusd -X log) says "all good"; your users say "Wi-Fi is broken"; the ticket ping-pongs for days.

authhound-probe runs a real authentication against your RADIUS server from inside your network, acting as a NAS (switch/AP) would, and tells you in plain English which hop is broken. Works with self-hosted FreeRADIUS and Windows NPS, and with hosted/cloud RADIUS services. No account, no signup, no telemetry — everything you type stays on the host you run it on.

$ export AUTHHOUND_RADIUS_SECRET='shared-secret'   # kept out of shell history and `ps`
$ authhound-probe radius test --server radius.corp.com --peap alice --server-name radius.corp.com
Enter password for alice:

Testing RADIUS server radius.corp.com:1812 (as NAS "authhound-probe")

PASS  RADIUS server answered in 23ms
PASS  Shared secret is correct (reply signature verified)
PASS  Server signs its replies with Message-Authenticator (BlastRADIUS-hardened)
PASS  PEAP-MSCHAPv2 authentication succeeded for alice
PASS  Server certificate valid for 214 more days, name matches "radius.corp.com", chain looks complete (TLS 1.2)

Verdict: 5 passed, 0 failed, 0 warnings

Like eapol_test or radtest, but the output is readable — one command, no wpa_supplicant config file (honest comparison). Add --json for scripting, --nas-port-type ethernet|wireless|virtual to match how your real NAS presents itself, and --server-name to validate the server certificate's name the way your clients will. Common questions: FAQ.

Step 0 — register the probe on your server (one time)

Because the probe acts as a NAS, your RADIUS server must know it as a client — otherwise the server silently drops its requests and every check times out. Add the probe's IP and a shared secret:

FreeRADIUS (clients.conf, then restart):

client authhound-probe {
    ipaddr = 10.20.0.50      # the probe's IP
    secret = <shared secret>
}

Windows NPS — PowerShell (elevated), or NPS console → RADIUS Clients → New:

New-NpsRadiusClient -Name "authhound-probe" -Address "10.20.0.50" -SharedSecret "<shared secret>"

Cloud/hosted RADIUS: register the probe's IP and secret in the vendor's admin UI, per their documentation.

If NAT sits between the probe and the server, register the post-NAT source IP — the address the server actually sees. Use a dedicated secret and a least-privilege test account — never a real admin credential.

Skipped this step? The probe notices: on a first-run timeout it prints this exact snippet with your detected source IP already filled in, ready to paste.

What it checks (v1)

Check What it proves
Status-Server An RFC 5997 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.
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).
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 ("BlastRADIUS"). PASS if it does; WARN, with config pointers, if it accepts the probe's (signed) request but replies unsigned. Observation only — see below.
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.
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.
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.
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.

A note on MFA / two-factor

If the server issues an MFA challenge after valid primary credentials, the probe reports that boundary — "primary auth healthy, second factor required" — but does not approve a push or submit an OTP. Completing a second factor from an unattended probe would mean storing a live MFA secret, which this tool refuses to do. For monitoring, point it at a test account exempt from MFA so the primary RADIUS path is validated cleanly.

BlastRADIUS / Message-Authenticator posture

BlastRADIUS (CVE-2024-3596), disclosed in 2024, lets an on-path attacker forge RADIUS/UDP responses by exploiting the protocol's MD5-based Response Authenticator. The mitigation is to enforce a Message-Authenticator attribute in both directions (and, longer term, to move to RadSec). Most deployments still haven't hardened.

The probe already includes a Message-Authenticator in every request it sends. This check then observes the reply: does the server sign it back?

  • PASS — the server returned a valid Message-Authenticator. That path is hardened.
  • WARN — the server accepted our signed request but replied without a Message-Authenticator, so responses to this probe's client entry are forgeable. The output includes paste-ready pointers: require_message_authenticator / limit_proxy_state for FreeRADIUS, the Microsoft NPS July-2024 hardening update, and radsec test as the durable fix.

This is observation only — a single, normal authentication exchange, never any attack technique. And it is honest about scope: it can only speak to how the server treats this probe's client entry (a server may sign for one client and not another). In --json, the result carries blastradius_posture: "signed" | "unsigned".

Machine auth (NPS)

Windows NPS shops authenticate computers as often as users — a domain PC does 802.1X with the identity host/PC-NAME.domain and its machine-account password, not a person's credentials. The probe speaks this natively: pass the host/-style identity to --peap exactly like a username. Set --nas-port-type ethernet if you're modelling a wired switch port, so NPS evaluates the same Connection Request / Network Policy your real switches hit:

# a computer account, authenticated the way a domain PC does it
$ authhound-probe radius test --server nps.corp.local \
    --nas-port-type ethernet --peap 'host/PC-01.corp.local:<machine password>'

(The identity has no colon, so host/PC-01.corp.local:<pw> splits correctly on the first : into identity + password. As always, prefer AUTHHOUND_RADIUS_PASSWORD or --password-file over an inline password.)

The honest boundary: getting a machine password. A real computer account's password is generated by the domain and rotated automatically (~every 30 days) — it is not something an admin normally knows, so you usually can't type it in. Two ways to still exercise this path end-to-end:

  • Provision a dedicated test computer object in AD with a password you set and control (and exclude it from automatic rotation), then probe with that identity. This is the cleanest way to get a green "computers can authenticate" check on a schedule.
  • Reset a throwaway machine's password to a known value for a one-off test (Reset-ComputerMachinePassword / dsmod computer), understanding the domain will rotate it again.

What the probe verifies without a machine password. Even with no computer credential, it confirms everything around the machine-auth path — the parts that actually break: the probe is reachable and registered as a RADIUS client (NPS console → RADIUS Clients), the shared secret is correct, the NPS server certificate is valid and its chain complete (what clients validate in PEAP), and — with --nas-port-type ethernet — that the Network Policy for wired authentication is reachable. Those cover the failures you'd otherwise chase blind; the inner MSCHAPv2 step is the only piece that needs the password.

Usage

The shared secret and any password are read from the environment, a file, or an interactive prompt — never required on the command line, where they would land in your shell history and be visible to every user on the box via ps. See Where credentials come from for all the options. The examples below use the environment variable and prompt forms.

Reachability, shared-secret, and server-certificate checks run automatically — no login credentials needed:

$ export AUTHHOUND_RADIUS_SECRET='shared-secret'
$ authhound-probe radius test --server radius.corp.com

Add an authentication test with the method your network actually uses. Give --pap/--peap/--ttls as just the username and you'll be prompted (no echo) for the password:

$ export AUTHHOUND_RADIUS_SECRET='shared-secret'

# PEAP-MSCHAPv2 — what most enterprise Wi-Fi / 802.1X runs
$ authhound-probe radius test --server radius.corp.com --peap alice

# PAP — VPNs, simple setups, or as a backend baseline
$ authhound-probe radius test --server radius.corp.com --pap alice

# EAP-TTLS with inner PAP — works even against hashed password backends
$ authhound-probe radius test --server radius.corp.com --ttls alice

# EAP-TLS — certificate-based login (see cert prep below), no password
$ authhound-probe radius test --server radius.corp.com \
    --client-cert client.pem --client-key client.key

# add the path-MTU / fragmentation probe (troubleshooting EAP-TLS stalls)
$ authhound-probe radius test --server radius.corp.com --mtu

# several at once — prompted once per method
$ authhound-probe radius test --server radius.corp.com \
    --pap alice --peap alice \
    --client-cert client.pem --client-key client.key --mtu

For non-interactive use (RMM, cron), supply the password without a prompt via AUTHHOUND_RADIUS_PASSWORD or --password-file:

$ AUTHHOUND_RADIUS_SECRET='shared-secret' AUTHHOUND_RADIUS_PASSWORD='pw' \
    authhound-probe radius test --server radius.corp.com --peap alice --json

RadSec (RADIUS/TLS on TCP/2083) is a separate subcommand — it checks reachability, the TLS handshake, the server certificate, and a RADIUS exchange over the tunnel. RadSec is usually mutual TLS, so supply a client cert if the endpoint requires one:

$ authhound-probe radsec test --server radius.corp.com
$ authhound-probe radsec test --server radius.corp.com \
    --client-cert client.pem --client-key client.key

radius test flags:

Flag Purpose
--server HOST[:port] RADIUS server (default port 1812). Required. Comma-separate several to compare them — see Comparing servers.
--secret SECRET Shared secret (required, but prefer AUTHHOUND_RADIUS_SECRET / --secret-file / --secret-stdin — see below).
--secret-file FILE Read the shared secret from a file (must not be world-readable on unix).
--secret-stdin Read the shared secret from standard input (one line).
--pap user[:pass] Run a PAP test. Give just user to be prompted for the password.
--peap user[:pass] Run a PEAP-MSCHAPv2 test. Give just user to be prompted.
--ttls user[:pass] Run an EAP-TTLS (inner PAP) test. Give just user to be prompted.
--password-file FILE Password for a user-only --pap/--peap/--ttls, from a file (non-interactive).
--client-cert FILE --client-key FILE Run an EAP-TLS test with this client certificate + key (PEM).
--expect-vlan ID Assert the Access-Accept assigns this VLAN (Tunnel-Private-Group-ID). Mismatch = FAIL — see Verifying policy.
--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.
--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.
--nas-id NAME NAS-Identifier to send (default authhound-probe).
--timeout DURATION Per-request timeout (default 5s).
--bind IP[:port] Source IP to send from, for pinning the outgoing interface on a multi-homed host — see Binding a source interface.
--json Machine-readable output for scripts / RMM (schema).
--strict Exit non-zero on warnings too (e.g. a soon-to-expire cert), for scheduled monitoring.
--no-color Force plain output. Colour is auto-detected otherwise — see Colour.

Verifying policy, not just connectivity

"Login works" is only half the question. On 802.1X networks a huge share of real tickets are "authentication succeeds, but the user lands in the wrong VLAN" — the guest VLAN instead of staff, or no VLAN at all. The server's answer is right there in the Access-Accept, in its authorization attributes. The probe surfaces them and lets you assert on them, so a policy regression fails loudly instead of silently mis-segmenting users.

On any successful authentication, the returned authorization is printed:

$ authhound-probe radius test --server radius.corp.com --peap alice
...
PASS  PEAP-MSCHAPv2 authentication succeeded for alice
        Authorization returned by the server:
          Tunnel-Type = VLAN (13)
          Tunnel-Medium-Type = IEEE-802 (6)
          Tunnel-Private-Group-ID = 20
          Filter-Id = staff

Now assert the VLAN you expect. A match keeps the PASS; a mismatch is a FAIL (exit 1), with a line that tells you where to look:

$ authhound-probe radius test --server radius.corp.com --peap alice --expect-vlan 20
...
FAIL  PEAP-MSCHAPv2 authentication succeeded for alice — but the returned authorization does not match
        Server assigned VLAN 30, expected 20. Check the policy/authorization
        rules that matched this request (NAS-Port-Type, user group, time-of-day).
        This probe sent NAS-Port-Type=wireless; many policies branch on it, so a
        different value (--nas-port-type wireless|ethernet|virtual) can select a
        different VLAN/policy.
          assert VLAN=20: MISMATCH (got 30)

That last hint matters: policies frequently branch on NAS-Port-Type, so a wired switch (ethernet) and a wireless AP (wireless) can land the same user in different VLANs. If the assertion fails, re-run with the --nas-port-type your real NAS sends to confirm whether that's the deciding input:

# same user, presented as a wired switch — different policy, different VLAN
$ authhound-probe radius test --server radius.corp.com --peap alice \
    --nas-port-type ethernet --expect-vlan 30

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

$ authhound-probe radius test --server radius.corp.com --peap alice \
    --expect-vlan 20 --expect-attr Filter-Id=staff --expect-attr Session-Timeout=3600

Under --json, each auth result gains an authorization object (the decoded attributes plus per-assertion pass), so RMM/monitoring can alarm on a VLAN drift the same way it alarms on a failed login. See the JSON schema. This works over PAP, PEAP-MSCHAPv2, EAP-TTLS, and EAP-TLS — for EAP methods the probe drives the exchange all the way to the Access-Accept to read what the NAS would actually receive. (Note: your server must copy the inner-tunnel reply out to the outer Access-Accept — FreeRADIUS's use_tunneled_reply = yes — which is the normal configuration for VLAN assignment; without it, no VLAN reaches the NAS either.)

Chasing intermittent failures

A single-shot PASS proves nothing about the failure that hits one user in ten. When the complaint is "Wi-Fi drops people randomly" or "auth works, except when it doesn't", run the same checks repeatedly and look at the distribution:

$ export AUTHHOUND_RADIUS_SECRET='shared-secret'
$ authhound-probe radius test --server radius.corp.com --peap alice --count 10

Running 10 iterations, 2s apart

run  1/10  reachability PASS 3ms · shared-secret PASS · peap-mschapv2 PASS
run  2/10  reachability PASS 41ms · shared-secret PASS · peap-mschapv2 LOST (no reply)
run  3/10  reachability PASS 2ms · shared-secret PASS · peap-mschapv2 PASS
...

Aggregate over 10 runs:

PASS  reachability: 10/10 succeeded — stable
        Latency over 10 answered runs: min 2ms, median 3ms, p95 41ms, max 41ms.
PASS  shared-secret: 10/10 succeeded — stable
FAIL  peap-mschapv2: 8/10 succeeded, 2/10 requests lost — consistent with an
        unstable path or an overloaded/failing server, not a config error
        Latency over 8 answered runs: min 9ms, median 12ms, p95 96ms, max 96ms.

How to read it:

  • Requests lost (timeouts) with the rest succeeding → the configuration is fine; suspect the network path or an overloaded/failing server. A p95 far above the median is the same story told by latency.
  • Failed every run → not flaky at all; it's a configuration problem (secret, credentials, policy) that a single run would also have caught.
  • Exit code stays 1 if any iteration failed, so a flaky server fails a scripted run loudly instead of depending on which iteration you got.

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. This is a diagnosis loop you babysit, not monitoring — it never schedules, repeats forever, or stores anything between runs.

With --json, each iteration's results plus per-check aggregate statistics (success counts, timeouts, latency min/median/p95/max) appear in an additive repeat block — see the schema.

Liveness & comparing servers

Status-Server runs first: an RFC 5997 liveness ping that a server answers without logging an authentication attempt. It's the polite way to ask "are you alive?" — nothing shows up in the auth log. Many servers leave it off, so silence here is a neutral INFO, never a failure; the reachability check below is the authoritative one. To turn it on in FreeRADIUS, set status_server = yes in radiusd.conf.

Comparing servers. Almost every site runs a primary and a secondary RADIUS server, and clients reach them through DNS round-robin or a shared VIP. When one of the pair is quietly broken — down, unregistered, or drifted out of config — roughly half of authentications fail depending on which server the client happened to hit. That is the single most common hidden cause of "it works sometimes" tickets, and a single-server test can't see it. Comma-separate the servers and the probe tests each, then prints a comparison:

$ export AUTHHOUND_RADIUS_SECRET='shared-secret'
$ authhound-probe radius test --server radius1.corp.com,radius2.corp.com --pap alice

=== Server 1/2: radius1.corp.com:1812 ===
... per-check results ...
Verdict: 4 passed, 0 failed, 0 warnings, 5 skipped

=== Server 2/2: radius2.corp.com:1812 ===
... per-check results ...
Verdict: 0 passed, 1 failed, 0 warnings, 8 skipped

Comparison across servers:
  radius1.corp.com:1812 is responding, but radius2.corp.com:1812 is NOT. If
  clients reach these servers via DNS round-robin or a shared VIP, roughly 50%
  of authentications would fail intermittently depending on which server they
  land on — the classic 'it works sometimes' ticket. Take the unresponsive
  server(s) out of rotation or bring them back.

The verdict also covers the subtler case where both servers answer but disagree — one accepts a login the other rejects, or assigns a different VLAN — which points at config or replication drift between them.

The exit code follows the combined result: a FAIL on any server fails the run (and under --strict, a WARN does too). Each server is still bounded by the same hard-coded rate ceiling; comparing servers never raises the load on any one of them. --count and multiple --server are mutually exclusive — chase intermittency on one server, compare across servers separately. With --json, per-server blocks and the verdict appear in additive servers / comparison fields — see the schema.

Binding a source interface (--bind)

Jump boxes and monitoring servers are usually multi-homed. --bind IP[:port] pins the source address the probe sends from, so RADIUS leaves the interface you intend (and reaches a server whose firewall only permits that address):

$ authhound-probe radius test --server radius.corp.com --bind 10.20.0.5

The source must be a local IP literal on this host (not a hostname). This is also the address the server sees, so it's the one to register as a RADIUS client — and the Step 0 registration snippet the probe prints on a timeout automatically reflects the bound IP, so you can paste it as-is. (If NAT sits between this host and the server, the server still sees the post-NAT address — register that instead.)

Certificate name validation (--server-name)

Your clients don't just check that the RADIUS server's certificate is unexpired — a correctly configured 802.1X profile also validates the certificate's name. A server presenting a healthy certificate with the wrong name still breaks every client that validates it. So the probe treats the name as part of the certificate verdict:

  • --server-name radius.corp.com and the certificate matches (SAN rules, wildcards included — the same matching real clients do): PASS, and the summary says the name matched.
  • --server-name given but the certificate doesn't match: FAIL, with the names the certificate is actually valid for, so you can tell "wrong cert selected on the server" from "my expected name is stale". A certificate with no SAN at all fails too — modern clients don't fall back to the CN.
  • --server-name omitted: the probe cannot know what name your clients expect, so name validation is skipped — and reported as a WARN, never folded into a silent "valid". Expiry and chain are still checked; the WARN tells you exactly what to add. In --json, fields.name_validation is "match", "mismatch", or "skipped" on the server-cert / radsec-cert results.

Note the division of labour: in the authentication checks (PEAP/TTLS/EAP-TLS), --server-name is only sent as TLS SNI — the probe deliberately completes those handshakes even against a broken certificate, because its job is to diagnose rather than refuse. The name verdict lives in the server-cert (and radsec-cert) check. The full TLS posture, including why verification is capture-and-report by design, is documented in SECURITY.md.

Under --strict, the skipped-name WARN exits 1 — deliberate: a scheduled monitor should be told which name to pin, not silently skip the check forever.

Where credentials come from

This tool is meant to run on shared jump boxes, so it never requires a secret or password on the command line — where it would be saved to ~/.bash_history and shown to any other user on the host by ps -ef. The shared secret and each auth password can come from any of these, checked in this order (first match wins):

Source Shared secret Password
Explicit flag file --secret-file FILE --password-file FILE
Standard input --secret-stdin (pipe the secret; give the password another way)
Inline on the command line --secret VALUE --pap user:pass (and --peap, --ttls)
Environment variable AUTHHOUND_RADIUS_SECRET AUTHHOUND_RADIUS_PASSWORD
Interactive prompt (no echo) when stdin is a terminal and no source above is set give --pap/--peap/--ttls as just user

Notes:

  • --secret, --secret-file, and --secret-stdin are mutually exclusive — giving more than one is an error, so there's no silent surprise about which won.

  • File sources must not be world-readable on Linux/macOS: the probe refuses a secret/password file that group or others can read and tells you to chmod 600 it. (Windows has no unix mode bits, so this check is skipped there — rely on NTFS permissions instead.)

  • A credential file holds the raw value and nothing else — no quotes, no secret = ... syntax, no comments, one credential per file. The entire file becomes the value, minus exactly one trailing line ending (\n or \r\n), so the final newline your editor or printf 'secret\n' adds is harmless. Anything beyond that is kept deliberately (a secret that really ends in a space survives) — which means an extra blank line, leading whitespace, or a hidden byte-order mark makes the probe use a different secret than you think, and the symptom is a shared-secret FAIL, not a file error. Create one safely:

    $ (umask 077; printf '%s\n' 'the-shared-secret' > radius.secret)

    On Windows, older PowerShell defaults write a hidden BOM; force plain ASCII:

    PS> Set-Content -Path radius.secret -Value 'the-shared-secret' -Encoding ascii

    --password-file follows the same rules and holds only the password — the username still comes from the flag (--peap alice).

  • Environment variables are convenient but not private from the same user. Another process running as you can read /proc/<pid>/environ. For untrusted multi-user boxes, prefer a chmod 600 file or the interactive prompt.

  • Standard input, for pipelines that already hold the secret in a variable:

    $ printf '%s' "$SECRET" | authhound-probe radius test --server radius.corp.com --secret-stdin --peap alice

    (Piping the secret into stdin means there's no terminal left to prompt on, so supply the password via AUTHHOUND_RADIUS_PASSWORD or --password-file in that case.)

  • The inline --secret VALUE and --pap user:pass forms still work for a quick lab test, but on a terminal they print a one-line warning reminding you they leak into history and ps. Credential values are never echoed back, never written to --json or logs, and never appear in an error message.

EAP-TLS: preparing a client certificate

EAP-TLS logs in with a certificate instead of a password, so the probe needs a client cert + key in PEM format. You don't need to make anything new — reuse what your devices already use, or issue one dedicated test cert:

Already have a .pfx / .p12? (a common Windows / MDM export) Convert it — the export password is what you set when exporting:

$ openssl pkcs12 -in device.p12 -clcerts -nokeys -out client.pem   # the certificate
$ openssl pkcs12 -in device.p12 -nocerts -nodes  -out client.key   # the private key

Issuing a dedicated test cert? Have your CA sign one for a throwaway identity (e.g. CN=authhound-probe) the same way it signs device/user certs, and export it as PEM. It must be a client-authentication cert whose chain is trusted by the RADIUS server for EAP-TLS — that's the one requirement.

The key is read from disk only to complete the TLS handshake; it is never 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.

Install

Binary (Linux, macOS, Windows — single static file, no runtime):

$ curl -sSL https://github.com/authhound/probe/releases/latest/download/authhound-probe_linux_amd64.tar.gz | tar xz
$ ./authhound-probe radius test --server ... --secret ...

Windows — PowerShell (downloads the latest release zip and unpacks it to the current directory):

Invoke-WebRequest -Uri https://github.com/authhound/probe/releases/latest/download/authhound-probe_windows_amd64.zip -OutFile authhound-probe.zip
Expand-Archive -Path authhound-probe.zip -DestinationPath authhound-probe -Force
.\authhound-probe\authhound-probe.exe version

(Use authhound-probe_windows_arm64.zip on Arm. To run authhound-probe from any directory, move the .exe somewhere on your PATH — e.g. C:\Windows\System32 for all users, or a folder you add to your user Path.)

Scoop and winget packages are in progress — the manifests are maintained in packaging/, but the Scoop bucket and the winget submission haven't been published yet. Until they land, use the PowerShell download above. (This note gets replaced with the install one-liners once they're live.)

Go:

$ go install github.com/authhound/probe/cmd/authhound-probe@latest

Docker (multi-arch: amd64 + arm64, distroless base):

$ docker run --rm ghcr.io/authhound/probe radius test --server radius.corp.com --secret '••••'

Every release also ships authhound-probe_linux_arm64.tar.gz and macOS/Windows archives — swap linux_amd64 in the curl above for your platform (linux_arm64 for a Raspberry Pi, darwin_arm64 for Apple Silicon, windows_amd64.zip for Windows).

Verify your download

Every release artifact is checksummed and keyless-signed with Sigstore cosign — no long-lived signing key exists; the signature is bound to the GitHub Actions release workflow via a short-lived OIDC certificate, and logged in the public Rekor transparency log. You can prove an artifact was built by this repo's tagged release pipeline and was not tampered with:

# 1. Download the archive, the checksums file, and its cosign signature + certificate.
$ base=https://github.com/authhound/probe/releases/latest/download
$ curl -sSLO $base/authhound-probe_linux_amd64.tar.gz
$ curl -sSLO $base/checksums.txt
$ curl -sSLO $base/checksums.txt.sig
$ curl -sSLO $base/checksums.txt.pem

# 2. Verify checksums.txt was signed by our release workflow (fails on any mismatch).
$ cosign verify-blob \
    --certificate checksums.txt.pem \
    --signature   checksums.txt.sig \
    --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
    --certificate-identity-regexp '^https://github.com/authhound/probe/\.github/workflows/release\.yml@refs/tags/v.*$' \
    checksums.txt
# -> Verified OK

# 3. checksums.txt is now trusted — verify the archive's hash against it.
$ sha256sum --ignore-missing -c checksums.txt
# -> authhound-probe_linux_amd64.tar.gz: OK

The --certificate-identity-regexp pins who signed it (the release.yml workflow in authhound/probe, running on a v* tag); the --certificate-oidc-issuer pins how they authenticated (GitHub's OIDC provider). Both must match or verification fails — a signature alone is not enough. The container image is signed the same way; verify it with:

$ cosign verify ghcr.io/authhound/probe:latest \
    --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
    --certificate-identity-regexp '^https://github.com/authhound/probe/\.github/workflows/release\.yml@refs/tags/v.*$'

No sha256sum / cosign on Windows? cosign ships as a single static .exe (releases); for the hash alone, certutil -hashfile authhound-probe_windows_amd64.zip SHA256 prints a digest you can compare by eye against checksums.txt.

Where to run it

The probe's whole value is that it tests from the same place your clients live. Common homes:

  • A container on a box you already run — a utility Linux VM, a hypervisor host, or a Synology/QNAP NAS. docker run and you're done.
  • A tiny VM (1 vCPU / 512 MB) on your existing Proxmox / ESXi / Hyper-V cluster, on the client VLAN.
  • A systemd service on the jump box that already runs your monitoring — or a Windows Scheduled Task on an NPS-adjacent server.
  • A Raspberry Pi at a branch site — a genuinely good way to probe a remote location.

Placement matters. A probe on the server VLAN may not cross the same firewall path your clients do — and that path is exactly where the invisible failures hide. Put the probe where the users are.

Exit codes

Code Meaning
0 All checks passed. Warnings are allowed unless --strict.
1 At least one check failed — or, under --strict, at least one warning.
2 Usage error (bad flags, missing --server). No JSON is emitted in this case.

This 0/1/2 contract is stable — RMM scripts and Task Scheduler can rely on it. By default a warning (e.g. a certificate expiring soon) does not change the exit code — only a hard failure does. Add --strict to make warnings exit 1 too, so a scheduled monitor pages on "still working, but about to break," not just on outright breakage — a cert about to lapse is exactly what an RMM job should alarm on.

Pair with --json for monitoring scripts and RMM integrations: the same result is machine-readable, with per-status counts (.summary.fail, .summary.warn, always present even when zero) and per-check status (.results[].status). The full document is versioned and documented in docs/json-schema.md — the top-level schema_version lets you pin the shape you coded against.

Running as a Windows Scheduled Task

The probe is a single .exe with no runtime, so it drops straight into Task Scheduler on an NPS-adjacent box. Run it non-interactively — secret and password from the environment (or a chmod-equivalent ACL'd file), --json for a machine-readable log, and --strict so a soon-to-expire cert trips the task's result too:

# One-time: create a task that probes NPS every 15 minutes and logs JSON.
# Store the secret/password as task-level env or a locked-down file — never on
# the command line (it would be visible in the task definition).
$action = New-ScheduledTaskAction `
  -Execute 'C:\Tools\authhound-probe.exe' `
  -Argument 'radius test --server nps.corp.local --server-name nps.corp.local --peap svc-radius-probe --json --strict' `
  -WorkingDirectory 'C:\Tools'
$trigger  = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 15)
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName 'AuthHound RADIUS probe' -Action $action -Trigger $trigger -Principal $principal

Or the classic one-liner with schtasks, redirecting output to a rolling log:

schtasks /Create /TN "AuthHound RADIUS probe" /SC MINUTE /MO 15 /RU SYSTEM /RL HIGHEST /TR ^
  "cmd /c C:\Tools\authhound-probe.exe radius test --server nps.corp.local --server-name nps.corp.local --peap svc-radius-probe --json --strict >> C:\Tools\probe.log 2>&1"

Supply credentials via the environment for the task's account (AUTHHOUND_RADIUS_SECRET, AUTHHOUND_RADIUS_PASSWORD) or --secret-file/--password-file pointing at a file only that account can read — see Where credentials come from. Because the probe exits 1 on failure (or on a warning under --strict), Task Scheduler's Last Run Result reflects health directly: 0x0 healthy, 0x1 something needs attention. Point your existing task-result monitoring at that, or tail probe.log.

Colour and Windows terminals

Output is colourised only when it's going to a real terminal that can render it, so logs and pipes stay clean:

  • Piped or redirected (> out.txt, | findstr, --json): colour is off automatically — no escape codes end up in your files.
  • Windows Terminal, and modern conhost / PowerShell 5.1 and 7+ (Windows 10 1511+ / Server 2016+): the probe enables virtual-terminal processing itself, so colours render with no setup.
  • Legacy conhost that can't do ANSI: the probe detects this and prints plain PASS/FAIL words instead of raw escape codes.
  • --no-color forces plain output everywhere, and the NO_COLOR environment variable is honoured if set.

Safety

Built to be safe to run against production, and to pass an enterprise security review. The one-page threat model — what the probe sends, what it stores (nothing), what it never does, and how to report a vulnerability — is SECURITY.md; the short version:

  • Read-only. Sends Access-Requests and reads the replies — it never changes anything on the server, and never captures packets.
  • Credentials stay local, and off the command line. The shared secret and any password are used only to build the RADIUS packets. They are never written to output, --json, logs, or error messages, and nothing is ever sent anywhere (there is no telemetry). So they don't leak into shell history or ps on a shared box, they're read from a file (--secret-file / --password-file, refused if world-readable), an environment variable, standard input, or a no-echo prompt — see Where credentials come from. The plain --secret/user:pass forms still work but warn on a terminal.
  • Never completes a second factor and never proxies authentication.
  • Hard-coded rate ceiling the config cannot override — it cannot be turned into a load generator.
  • Verifiable releases. Binaries and images are checksummed and keyless-signed (Sigstore cosign) by the tagged release workflow, and every release ships an SBOM per archive — verify them before you trust them on a shared jump box.
  • Minimal, scanned supply chain. One direct dependency (golang.org/x/term), govulncheck on every PR plus a daily scheduled scan, Dependabot with mandatory human review, and SHA-pinned CI actions — the full posture is documented in SECURITY.md.
  • Open source (Apache-2.0). Read it — or run ./test/freeradius-smoke.sh to watch it work against a throwaway FreeRADIUS in Docker.

From spot-check to continuous monitoring

authhound-probe radius test answers "is it working right now?" But the failures that hurt most are the intermittent ones — the 3 a.m. blip, the cert that expires next Tuesday, the drift that only shows up under load. You can't catch those by running a command when you happen to suspect trouble.

That's what the paid tier is for. The same binary, pointed at the AuthHound service:

$ authhound-probe connect <token>

...runs these checks on a schedule from every site, remembers the history, alerts you when something changes (a cert nearing expiry, latency creeping up, a new failure signature), and correlates across your whole fleet — so you hear about it before your users do. Join the waitlist at authhound.com.

Testing

$ go test ./...                   # unit tests
$ ./test/freeradius-smoke.sh      # full end-to-end vs a real FreeRADIUS in Docker

There's also a docker compose lab (classic RADIUS/UDP + RadSec/TLS) for poking at the probe by hand, and instructions for testing against your own server. See test/README.md — including how to prepare a client certificate for the EAP-TLS and RadSec tests.

Contributing

Interop reports are especially valuable: run the probe against your RADIUS — a specific NPS setup, a hosted RADIUS service, a particular FreeRADIUS version — and open an issue with what worked or didn't. It's how the tested-server list grows. Bug reports and PRs welcome.

See also

  • FAQ — timeouts, NPS, credentials, scripting.
  • authhound-probe vs eapol_test vs radtest — a factual comparison, including when the classics are the better tool.
  • SECURITY.md — the one-page threat model and vulnerability reporting.
  • Already have a log to read? Paste FreeRADIUS debug output or a Windows NPS event into the free RADIUS log analyzer for a plain-English diagnosis.
  • Registering the probe as a RADIUS client? Generate the clients.conf block or the NPS PowerShell one-liner with the free RADIUS client config generator — runs in your browser, nothing is uploaded.

License

Apache-2.0

About

Test RADIUS servers (FreeRADIUS, Windows NPS, cloud) from inside your network — PEAP-MSCHAPv2, EAP-TLS, RadSec, path-MTU, BlastRADIUS posture, VLAN assertions. Plain-English 802.1X diagnostics.

Topics

Resources

License

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors