TestPulse Pro · User Guide
Evidence-first AAA diagnostics for your own lab.
Point TestPulse at your RADIUS or TACACS+ server, give it the logs and captures you already have, and get a named cause with the evidence behind it — never "RADIUS is flaky".
| EVERY DIAGNOSIS ANSWERS | WHICH MEANS |
|---|---|
| Observed outcome | what actually happened across the evidence |
| Cause family | a named, machine-readable class — not free text |
| Why | the specific counters and log lines that support it |
| Fix direction | what to change next |
These four are never collapsed into one verdict. Everything in this guide was run against the shipped package — where a command's output is shown, that is its real output.
SECTION 1Install
Python 3.10 or newer. Nothing else is required — no cluster, no container, no second service.
python3 -m venv .venv && source .venv/bin/activate pip install testpulse
What it runs on
The wheel is py3-none-any — pure Python, no compiled extension. The API, the viewer, the diagnosis engine and the CLI run anywhere Python 3.10+ runs. What differs between operating systems is not TestPulse; it is the external tools TestPulse shells out to, and every one of them is optional.
| LINUX | macOS | WINDOWS | |
|---|---|---|---|
API + viewer + testpulse-diagnose | yes | yes | yes |
| Device log collection over SSH | yes | yes | yes (paramiko — no ssh.exe needed) |
| Packet capture | dumpcap + setcap | dumpcap + ChmodBPF | Wireshark + Npcap |
testpulse-capture-setup | grants | reports, cannot grant | Linux/macOS only |
| The shipped RADIUS suite | freeradius-utils | brew freeradius-server | no native radtest; use WSL |
Bring your own .pcap | yes | yes | yes |
Two rows are worth reading twice
testpulse-capture-setup is the only entry point that is not portable — it grants CAP_NET_RAW, a Linux capability, and imports pwd/grp to check your groups, so on Windows it fails at import rather than doing something useless. Nothing else in the package does. And the shipped test suite needs radtest/radclient, which have no native Windows build — everything that reads evidence you already have works there regardless.
The recommendation, plainly: Ubuntu 22.04 LTS. That is what this build is developed and regression-tested on, it is the one platform where every optional tool installs with a single apt line, and it is the only one where testpulse-capture-setup --grant can finish the job for you. macOS is a close second. On Windows, prefer WSL2 with Ubuntu over native Python — not because the package fails natively, but because the AAA tooling around it assumes a POSIX box and you will spend your time on that rather than on your network.
Disk footprint by install
measured, whole virtualenv, clean installTen times the size for embeddings with no key and no cloud. Worth it if that is what you want; not worth it if OpenAI or Bedrock is doing the embedding — which is why they are two extras and not one, and why [all] deliberately leaves rag-local out.
The API holds about 96 MB resident, and 2 GB of RAM with any modern CPU is comfortable.
Optional extras
None are needed to start, and each one is a feature that degrades cleanly when absent rather than a hidden requirement.
| EXTRA | ADDS |
|---|---|
testpulse[ai] | AI narration using your own Anthropic/OpenAI key |
testpulse[mcp] | MCP server, to drive TestPulse from an AI client |
testpulse[pcap] | Deeper packet analysis (scapy/dpkt) |
testpulse[windows] | Windows endpoint collection (WinRM, Event Log) |
testpulse[rag] | Retrieval over your own run history |
testpulse[postgres] | Postgres instead of the bundled SQLite |
testpulse[all] | All of the above |
SECTION 2First run
TestPulse refuses to start without a credential. That is deliberate: an unauthenticated API bound to 0.0.0.0 is a mistake you get to make exactly once.
Why there is a token, and who issues it
You generate it. Nobody issues it to you. There is no licence server, no account, and no activation call — TestPulse Pro runs entirely on your machine, and a vendor-issued key would mean phoning home, which this build never does.
The token exists for one specific reason: testpulse-api serves the viewer and the API from a single process on a single origin. There is no reverse proxy in front to add a credential, and baking one into a public download would be handing the key to everyone who downloads it.
export TESTPULSE_API_TOKEN="$(python3 -c 'import secrets;print(secrets.token_urlsafe(32))')" testpulse-api
Any value works — the command above simply generates a strong one. Keep it. You need the same string again in the next step, and again on any other browser you open the viewer from. Then open http://127.0.0.1:8000; the viewer is served by the same process, so there is no second container to run and no separate front-end port.
TESTPULSE_API_TOKEN — the value the server was started with — not an AI provider key.- The token is checked against
/v1/healthbefore it is stored, so a typo is rejected on the spot. Storing first and discovering the mistake later means every panel fails separately with no explanation. - Once accepted it is saved in that browser's
localStoragefor that origin, and you are not asked again there. It is never written into the downloaded package and never leaves your machine. - A different browser, a private window, or a different
host:portis a different origin — you will be asked again. Enter the same token; it is not a new one.
If you paste an AI provider key here, it will say so
A value starting sk-ant-, sk-proj-, sk-, AIza or gsk_ is recognised on sight and named, because at this point in setup that is the credential most people have in hand. That key is a different thing entirely: it goes in Environment / Settings once you are inside the viewer — see §7.
| VARIABLE | DEFAULT | MEANING |
|---|---|---|
TESTPULSE_API_TOKEN | none | Bearer token for every /v1/* route |
TESTPULSE_PORT | 8000 | Listen port |
TESTPULSE_CONFIG | none | Path to your testbed YAML (§4) |
TESTPULSE_ARTIFACTS | ./artifacts | Where run evidence is written |
TestPulse writes testpulse.db (SQLite) into the working directory on first start. Run it from a directory you intend to keep, or set TESTPULSE_DB_URL to a Postgres URL.
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/healthz # 200
curl -s -H "Authorization: Bearer $TESTPULSE_API_TOKEN" \
http://127.0.0.1:8000/v1/runs # your runs
/healthz is unauthenticated on purpose so a load balancer can probe it. Everything under /v1/ requires the bearer token.
The Explorer starts collapsed
After you connect, the editor area is empty and there is no tree until you open one: press Ctrl+B, or click the folder icon at the top of the Activity Bar. Nothing auto-opens — that is deliberate, since a panel that opens itself covers the Explorer — but it does mean the first screen is blank until you ask for something.
A brand-new install is not empty
Five sample runs are seeded into the artifacts root on first start, so every evidence panel has something real to draw before you have run anything yourself. Delete them from the artifacts directory when you no longer want them.
SECTION 3Diagnose a log you already have
First — how a classification is reached
Before you read your first verdict, it is worth seeing the model that produces it, because the output is deliberately not a single answer. TestPulse ships that model as a panel rather than leaving it to the documentation: Explorer → REFERENCE → Forensic Analysis Primer.
Read the table left to right and the classification stops being a verdict handed down and becomes an address. A directory_path_degradation is hop 3 — RADIUS Server → LDAP/AD over LDAP, budget 500 ms. A dhcp_path_degradation is hop 5, Endpoint → DHCP Server on UDP/67-68, budget 4 s. transport_degradation on TCP/49 is hop 9, the TACACS+ leg. The cause family names the hop, and the hop names what to go and look at.
Each hop is measured against its own budget
not a global timeout · log scaleThree things follow from that table, and they are why a diagnosis here refuses to collapse into one word:
- A pass is all nine hops succeeding at once. Any one of them failing presents as "authentication failed", including the six that are not authentication. That is the whole reason "RADIUS is flaky" is never an acceptable answer — RADIUS is one hop of nine, and it is frequently the healthy one.
- Each hop is measured against its own budget, not a global timeout.
- The vocabulary is closed. The thirteen names below are the only classifications a diagnosis can return. It names one of them or it returns
null— it never invents a label, so the same failure carries the same name across every run, every panel, and every export.
transport_degradationdirectory_path_degradationresolver_path_degradation
dhcp_path_degradationpolicy_path_delayradius_server_delay
nas_or_relay_instabilitydynamic_authorization_path_degradation
authorization_deniedauth_semantic_failureaccounting_path_failure
timing_budget_violationendpoint_network_readiness_delay
One rough edge in this build
The Primer panel opens at 902×622 and clips its right-hand column at that size — drag the window corner out, and the evidence planes and the vocabulary become readable.
Diagnosing the log
The fastest path to value needs no configuration and no server at all. If you have a tac_plus.log or a radiusd -X capture, diagnose it directly:
testpulse-diagnose diagnose --protocol tacacs --file /var/log/tac_plus/tac_plus.log
{
"decision": "reject",
"confidence": 0.85,
"events_parsed": 1,
"evidence": { "TACACS_AUTHEN_REPLY_FAIL": 1 },
"cause_family": "auth_semantic_failure"
}
--protocol accepts tacacs, radius, dot1x, eap_tls, peap, mab and coa. The 802.1X variants all ride the same RADIUS Access-Request/Accept/Reject exchange, so they share a parser; tacacs and coa have their own decision logic.
Give it the raw log, not a summary
This is the single most common reason a first run returns nothing. FreeRADIUS's one-line Auth: Login incorrect … summary records the verdict but none of the evidence — which is precisely what TestPulse exists to read. Restart with radiusd -X (or freeradius -X) and capture that.
{
"error": "no_events_parsed",
"message": "No recognizable AAA log lines found for this protocol.
Paste the raw tac_plus.log or radiusd -X output, not a summary or excerpt."
}
| PROTOCOL | WHAT THE PARSER READS |
|---|---|
radius and the 802.1X variants | radiusd -X debug output — the lines containing Received Access-Request Id … / Sent Access-Accept|Reject Id …, with the User-Name, Calling-Station-Id and NAS-IP-Address attribute lines that follow |
tacacs | the tac_plus server log — login query …, login failure …, authorization … lines |
coa | radiusd -X output covering the CoA/Disconnect exchange on port 3799 |
SECTION 4Point it at your testbed
TestPulse ships with a sample testbed and loads it when you have not configured anything, so the Topology panel draws a network on first run instead of an empty box.
Every address in it is from the RFC 5737 documentation range, so every device reads as unreachable — that is the truthful result, not a failure, and it shows you the shape of the map before you own one.
sample_testbed.yaml (named in the chip, top right). Every address is documentation-range, so the count reads 0/6 reachable — the truthful result on a machine that owns none of them. Point TESTPULSE_CONFIG at your own file and the same panel draws your network with real ping and latency.export TESTPULSE_CONFIG=/path/to/my-testbed.yaml # explicit, wins over everything # ── or just save it as ./testbed.yaml where you run testpulse-api
Resolution order, first hit wins: TESTPULSE_CONFIG → ./testbed.yaml → ./env1.yaml → the packaged sample. The file drives everything: which devices exist, where their logs are, and where to capture. It is the source of truth — TestPulse has no hidden device list.
Credentials: named, never guessed
Prefer *_env and keep the secret out of the file:
radius:
ip: 198.51.100.10
secret_env: TESTPULSE_RADIUS_SECRET # export TESTPULSE_RADIUS_SECRET=…
TestPulse ships no default shared secrets
If one is missing it tells you which variable to set and stops. That is the honest behaviour: sending a guessed shared secret to your RADIUS server produces an authentication failure on your box, which then comes back as a TestPulse finding. A confidently wrong answer is worse than no answer.
A literal value in the file works too. Then the file holds a secret: chmod 600 it and never commit it.
Declare only what you have
Sections you leave out are skipped, not assumed. If you do not declare a DHCP pool, TestPulse does not check lease placement and does not guess a range your network never agreed to — the result is "unknown", never "wrong". The same is true of ldap:, pcap: and switch:.
SECTION 5Reading a diagnosis
The three evidence planes
A verdict is only trustworthy when all three agree, so all three are always evaluated.
Three planes, three questions
all evaluated on every rundns degraded, dhcp failed, directory degraded, transport healthy, tacacs skipped. "Skipped" and "failed" are different answers, and the panel keeps them apart.The effective AAA path
Identity and policy lookups make DHCP, DNS and the directory part of the authentication path, not adjacent to it:
endpoint → DHCP/DNS → NAS/switch → RADIUS/TACACS+ → policy → LDAP/AD → enforcement
Read combinations, not single counters
The pattern across counter groups is the diagnosis. A few that are routinely misread:
| PATTERN | CAUSE FAMILY | NOT THIS |
|---|---|---|
| auth healthy, accounting broken | accounting_path_failure | an auth failure |
| auth healthy, enforcement never applied | policy_path_delay or nas_or_relay_instability | an auth failure |
EAP-Success, then endpoint at 169.254.x.x | endpoint_network_readiness_delay | an auth failure — the port never moved to the target VLAN |
| Access-Request sent, no reply of any kind | radius_server_delay | a reject |
| CoA sent, NAK or no reply | dynamic_authorization_path_degradation | a policy error |
On dhcp_path_degradation
It means "this MAC has no confirmed lease" — established from the lease file, the switch binding table, ARP, or a RADIUS Accounting Framed-IP-Address. Never from a ping: an endpoint can hold a valid lease and still not answer ICMP.
SECTION 6Packet capture
Capture points are declared in your testbed YAML, so TestPulse captures what you tell it to and nothing else.
pcap:
capture_points:
- name: radius-wire
bpf: "udp port 1812 or udp port 1813"
- name: tacacs-wire
bpf: "tcp port 49"
testpulse-pcap-start <run-id> --duration 30 # run-id is positional
testpulse-pcap-status <run-id>
testpulse-pcap-stop <run-id>
The run id is a directory name under the artifacts root (TESTPULSE_ARTIFACTS, ./artifacts by default) — the same root the API writes runs to, so a run created in the viewer is addressable from the CLI by its own name.
These three commands drive the running API — they do not capture locally
The artifacts root that matters is therefore the one the server was started with. If testpulse-pcap-start reports a path you did not expect, check the environment of the testpulse-api process, not your shell.
Bringing your own capture
Where TestPulse cannot capture for you — no SSH to the device, a span port you mirror by hand, a capture a colleague sent you — drop the file into the run directory under the device's name (radius.pcap, switch.pcap, dhcp.pcap, ad_ldap.pcap, wireless.pcap) and it is picked up as evidence.
A missing capture is recorded as missing
The evidence bundle is marked partial and no forensic claim is made about transport-path health, rather than quietly inferring one from the logs. Deeper frame analysis needs the [pcap] extra; without it, frame and byte counts are still produced.
SECTION 7AI narration is optional, and it is your key
TestPulse ships with no AI key and no AI subscription. Parsing, correlation, the deterministic diagnosis, the cause family and the evidence bundle all run locally with no network call. That is the product; AI is narration on top of it.
Install the provider SDK first — it is not in the base package, and which one you need depends on whose model you are using:
pip install 'testpulse[ai]' # Anthropic or OpenAI, with your own key pip install 'testpulse[aws]' # Bedrock / SageMaker in your own AWS account
A key without the SDK is the one combination that looks configured and is not
The credential saves, the panel shows it stored, and reasoning still refuses — with nothing saying the SDK behind it is absent. TestPulse tells you when you save the key, and again if you ask a question anyway, but installing the extra first avoids the round trip. This is the single most common way a correctly-configured install still refuses to reason.
testpulse-diagnose configure --provider anthropic --api-key sk-ant-… testpulse-diagnose status
status shows the active plan, entitlements, whether a BYOK key is configured, and — worth checking once — where that key actually landed. The CLI prefers your OS keychain and falls back to a 0600 file when no keychain backend is reachable, which on a headless box is the common case.
set or unset and never the value, and each row states where the key goes — to that provider, never to BMI2. The viewer writes them to <artifacts>/_settings/platform_settings.json, mode 0600. If your threat model requires a key never to sit in a file, use the CLI on a host with a working keychain, or pass ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment and save nothing.How to tell a real similarity score from a fake one
With the [rag] extra, every run you keep is embedded and stored locally — that is what powers Analyzer (RAG), Similar Runs and cross-run comparison. A number between 0 and 1 always appears. What makes it worth anything is separation: the same failure described differently should score high, an unrelated failure low.
Separation is the whole test
measured on a clean install, local modelThose two sentences share almost no words, so a keyword search would rank them apart; the model ranks them together because it reads meaning. That gap is what makes "have I seen this before?" answerable.
The embedding provider and the vector store are two separate requirements
Install [rag] whichever provider you pick — it carries chromadb, which does the actual cosine search. Without it TestPulse falls back to matching on cause family and recency and returns a fixed similarity of 0.50 for every result: a ranked-looking list that ranks nothing. And note that Anthropic publishes no embedding model at all — an Anthropic key gives excellent per-run diagnosis and cannot build the corpus.
SECTION 8Driving TestPulse from an AI client (MCP)
With the [mcp] extra, TestPulse exposes its tools over MCP so an AI client can run tests, pull evidence, and read diagnoses directly.
pip install 'testpulse[mcp]' testpulse-mcp # stdio transport testpulse-mcp-sse # SSE transport
Point your MCP client at the command. testpulse-menu lists the available tools grouped by category.
SECTION 9The CLI
The package installs 31 commands. The ones worth knowing first:
| COMMAND | DOES |
|---|---|
testpulse-api | the API + viewer, one process |
testpulse-diagnose | BYOK config, plan status, one-shot log diagnosis, feedback |
testpulse | run diagnostics against the configured testbed |
testpulse-menu | list every available tool by category |
testpulse-config | list / show / validate / select / edit your testbed YAMLs |
testpulse-tacacs-audit | TACACS+ command-authorization audit |
testpulse-seam-correlate | correlate evidence across component seams |
testpulse-save-baseline / -list-baselines / -delete-baseline | baseline management |
testpulse-index-run / -delete-run | run index maintenance |
testpulse-pcap-start / -stop / -status | capture control |
testpulse-migrate | database schema migration |
Every command supports --help.
SECTION 10What this build does not include
Deliberately, and stated rather than hidden.
- The Trainer — the curriculum, grading and human sign-off loop is a separate product.
- Virtual-lab surfaces — live attack generation and the cluster panels need a lab this download does not ship. They are absent, not disabled: a greyed-out panel advertising something it can never run is worse than no panel.
- Vendor plugins — vendor-specific appliance packs ship separately. The core is RADIUS and TACACS+, which is what this build does well.
SECTION 11Troubleshooting
| SYMPTOM | CAUSE | FIX |
|---|---|---|
| Refusing to start in unauthenticated open mode | no credential configured | set TESTPULSE_API_TOKEN (§2). TESTPULSE_ENV=dev allows open mode in development only |
401 from a /v1/* route | missing or wrong bearer token | send Authorization: Bearer $TESTPULSE_API_TOKEN. /healthz is the unauthenticated probe |
| The viewer opens on Connect to TestPulse | expected on first use in each browser — the build ships with no credential in it | paste the value of TESTPULSE_API_TOKEN. The same string the server started with, not a new one |
| "That looks like an AI provider key" | pasted an Anthropic / OpenAI / Google / Groq key | that key goes in the AI panel (§7). This screen wants TESTPULSE_API_TOKEN |
| Asked for the token again on another browser or port | the token is stored per browser origin, not on the server | enter the same token there. Nothing is wrong |
| "This browser is blocking local storage" | localStorage blocked — private window, embedded webview, or site data disabled | allow site data for that address, or open the viewer in a normal window |
{"error":"no_events_parsed"} | a summary log, not raw output | use radiusd -X output or the tac_plus server log (§3) |
| "Which variable do I set?" on start | a shared secret is declared *_env but unset | export the named variable. TestPulse names it rather than guessing a secret |
Address already in use | something already holds :8000 | set TESTPULSE_PORT |
| Frame counts appear but no protocol breakdown | [pcap] extra not installed | pip install 'testpulse[pcap]' |
| Open in Wireshark does nothing | the API has no desktop to draw on — started without DISPLAY | start the API from your graphical session, or name it: DISPLAY=:10 testpulse-api. who shows which display is yours |
| Wireshark opened but you cannot see it | it went to a different desktop — console :0 while you are on xrdp :10 | check display in the response, then restart the API with that DISPLAY |
| Copilot answers "no reasoning provider available" | a key is set but the AI SDK is missing | pip install 'testpulse[ai]' — or 'testpulse[aws]' for Bedrock. A key alone is not enough |
| AWS keys saved, Bedrock still not used | TESTPULSE_AI_PROVIDER still defaults to openai | set it to bedrock. Credentials do not select a provider |
| AWS settings saved but nothing changed | marked restart to apply — boto3 clients are built at start-up | restart testpulse-api; the log prints applied N stored setting(s) |
| A panel you expected is missing | it is out of scope for this build | see §10 — absent is intentional |
| Topology shows devices you do not own | no config yet, so the shipped sample is loaded | point TESTPULSE_CONFIG at your own YAML, or save one as ./testbed.yaml |
Two warnings that are not errors
These appear inside a Copilot answer, appended after it, and they are TestPulse disagreeing with its own AI. Evidence check — this claim is not supported by the run means the answer asserted a cause family the run's own evidence does not carry. Citation check — a cited panel does not show this means the answer cited a panel for a fault that panel's data does not contain.
Neither rewrites the answer. You are shown the model's words and the contradiction, and you decide. Silently correcting a model would teach you to trust it, which is the opposite of what an evidence product should do. If you see one of these, the answer above it is suspect and the run's own panels are the truth.
SECTION 12Feedback
Beta feedback goes through the product.
testpulse-diagnose feedback
Or the Feedback tab in the viewer. Feedback never includes your raw logs or captures — it carries your message and the build identifier, nothing else.
If you are blocked and need a person rather than a form, mail support@bmi2.com. That address is a forwarder to the maintainer, so it is a best-effort beta channel and not a contracted SLA. Attach the output of:
testpulse-diagnose status
It prints the plan, the entitlements in force, and whether a BYOK key is configured — which is most of what a first reply would otherwise have to ask you for. Say what you ran alongside it.