___ __________ ________ ______ __________ __ __ _______ ______
,, // \\ \__ __/ | ____/ / ___/ \__ __/ | | | | | ___ \ / __ \
(_,\/ \_/ \ | | | |___ \ \ | | | | | | | | \ \ | | | |
\ \_/_\_/> | | | ___| \ \ | | | | | | | | | | | | | |
/_/ /_/ | | | |_____ / / | | | |_| | | |___/ / | |__| |
|__| |_______/ /__/ |__| \_____/ |________/ \______/
Your Linux network, in one quiet terminal.
A friendly, terminal-native home for everything happening on your box - flows, DNS, latency, firewall, routes, NAT, devices - all in one Go binary.
Quick Start » · Features · Web UI · Docs · Report an issue »
Live flow analytics across every interface, replayable sessions, anomaly detection, firewall and routing control, NAT, network discovery, IPFIX flow export to opsanio, and a unified web console - all driven by a single Bubble Tea TUI and mirrored to a small embedded HTTP UI. Whatever you need to look at, there's a tab for it.
- Author: Noah Zeumer <github.com/noahzmr>
- License: MPL-2.0 with branding restrictions (see LICENSE and NOTICE)
- Status: active development, Linux-first (Ubuntu / Debian) - hello, come on in
If you've ever debugged a flaky network, you know the drill. One terminal for tcpdump, another for iftop, a third for ss, nmap in a fourth, iptables and nft in a fifth, ip route in a sixth, and somewhere off to the side a Grafana tab that doesn't quite line up with any of them. Each of those tools is great on its own. They just don't talk to each other - and the moment the problem stops, the evidence is gone.
Testudo is a gentler take on the same job. One place to look. One memory across time. Less ceremony.
- One TUI instead of six terminals. Every signal - flows, DNS, latency, firewall hits, route changes - lives in the same place.
- "What was the network doing at 03:14?" A session ID is enough. Open it in replay and scroll.
- No always-on
tcpdump. PCAPs are captured only when something interesting actually happens, then rotated. - No external metrics stack. Testudo keeps its own time-series in SQLite. One file. One binary. Done.
- One engine, two faces. The TUI and the web UI are the same thing over different transports - a feature in one shows up in the other for free.
Underneath: pure-Go netlink, AF_PACKET, and /proc. No cgo, no external broker, no external metrics backend. One binary, one config, one event bus - and you're off.
Testudo is Latin for tortoise - and the word lives in two worlds that both describe this tool.
Testudo is the scientific genus name for the palearctic (European) land tortoises. The best-known species are Testudo hermanni (Hermann's tortoise) and Testudo graeca (the Greek tortoise).
The tortoise is slow - and slow is exactly the symptom this tool was built to diagnose.
The reason most operators reach for a packet analyzer in the first place is some variation of "the network feels slow." Latency creeping up. DNS taking just a bit too long. A flow that used to be 100 ms now sitting at 400 ms. A retransmission rate that no dashboard surfaces. Testudo is built for those questions: where is it slow, when did it start being slow, and what was the network doing the moment it got slow.
In the legions, a Testudo was a tight tactical formation in which soldiers closed ranks and locked their shields together - overhead and on the flanks - to create a gap-free shell that absorbed arrow volleys and falling stones.
That formation maps almost one-to-one onto how Testudo is built:
- Shell => a hardened observation layer around the host networking stack.
- Formation => many small subsystems (collectors, analyzers, engines) interlocking through one event bus - no single subsystem is exposed alone.
- Coverage without gaps => every observable signal (flow, DNS, ICMP, ARP, LLDP, SNMP, firewall, route, NAT) is captured by some shield; nothing falls through.
- Endurance => low-overhead, replayable, made to run quietly on a box for weeks.
A turtle peeking out from under its shell - half forensic, half cozy. The terminal-native identity is intentional: this is a tool you live inside, not one you click through.
- Why It Matters
- What does "Testudo" mean?
- Features
- TUI & Web Interface
- Architecture
- System Design Philosophy
- Prerequisites
- Quick Start
- Build Instructions
- CLI Reference & Options
- Configuration System
- Network Quality Grade
- Directory Structure
- Development Guide
- Further Documentation
- Glossary - Networking Terms for Beginners
- Roadmap
- Contributing
- Support
- License
- Branding
- Acknowledgments
- ICMP latency, RTT, jitter, packet loss - per target, with rolling 20-sample statistics.
- DNS resolver health probing - per-name latency and failure-rate tracking.
- Multi-interface AF_PACKET capture - physical, wireless, VLAN, bridge, tunnel, VPN, container, and virtual interfaces (
eth0,wlan0,tun0,wg0,docker0,br0, …). - Process-to-flow correlation - via
/proc/net/*and/proc/<pid>/fd- every flow is tagged with the process that owns it. - DNS reverse correlation - flows are annotated with the name that originally resolved to the far end.
- Service catalog - well-known ports mapped to protocol names (HTTP, HTTPS, SSH, DNS, NTP, MySQL, PostgreSQL, Redis, and more).
- Interface throughput accounting - RX and TX per direction, per interface.
The flow engine aggregates packets into intelligent flow summaries rather than persisting raw packets, and enriches each flow with process ownership, DNS resolution, service classification, NAT/firewall state, and an IP scope/class label on every endpoint.
┌ Live Flows ────────────────────────────────────────────────────┐
│ IFACE PROCESS A => B SCOPE │
│ eth0 firefox 192.168.1.20 => 142.250.1.1 prv·C→pub·B │
│ wg0 ssh 10.0.0.10 => 10.0.0.1 prv·A→prv·A │
│ docker0 redis 172.18.0.2 => 172.18.0.5 prv·B→prv·B │
└─────────────────────────────────────────────────────────────────┘
Tracked per flow: source/destination, ports, protocol, throughput, retransmissions, packet loss, ingress interface, egress interface, process ownership, NAT state, firewall state, and per-endpoint IP classification.
Every IP shown in the Flows and Devices views - in both the TUI and the web UI - is tagged with its routability scope and, for IPv4, its classful network (A-E). One classifier (internal/netlabel) feeds both interfaces, so the labels are always identical.
| Scope | Meaning | Ranges | Colour |
|---|---|---|---|
| public | Globally routable | everything not below | amber |
| private | Routed inside an org, never on the public net | RFC1918, IPv6 ULA fc00::/7, CGNAT 100.64.0.0/10 |
green |
| internal | Never crosses a router | loopback, link-local, unspecified, limited broadcast | dim |
| multicast | One-to-many group address | 224.0.0.0/4, ff00::/8 |
cyan |
The network class is the historical classful bucket derived from the first octet (A = 0-127, B = 128-191, C = 192-223, D = multicast, E = reserved); it is shown only for IPv4. Labels render as a compact scope·class tag in the TUI (prv·C, pub·B, int·A) and as a coloured pill beside each address in the web UI, with the precise reason (RFC1918, CGNAT, loopback, …) available on hover.
Testudo runs layered discovery - passive listeners are always on when discovery is enabled; active probes are opt-in. The goal is "find every reachable device on every connected subnet, even the ones that drop ICMP."
┌ Devices ─────────────────────────────────────────────────────────────────────┐
│ HOSTNAME IP SCOPE TYPE SOURCE STATUS │
│ router.local 192.168.1.1 prv·C Router lldp+snmp Active │
│ sw-core-01 192.168.1.2 prv·C Switch lldp Active │
│ ap-floor3 192.168.1.5 prv·C AP lldp Active │
│ nas01 192.168.1.10 prv·C NAS snmp Active │
│ printer01 192.168.1.40 prv·C Printer arp-sweep Idle │
│ ? 192.168.1.77 prv·C Unknown arp-sweep Active │
└────────────────────────────────────────────────────────────────────────────────┘
Each device is tracked with IP, MAC, hostname, vendor (via embedded OUI table), interface, source, device type, first/last-seen timestamps, and - for managed gear - system name, description, object ID, contact, location, uptime, interface count, LLDP chassis/port IDs and capabilities.
Discovery layers (latest optimization pass):
| Layer | Mode | What it does | Cost |
|---|---|---|---|
| ARP cache | Passive | Reads /proc/net/arp every tick - zero traffic. |
None |
| Flow observe | Passive | Every new flow contributes endpoint visibility. | None |
| LLDP listen | Passive | AF_PACKET listener on ethertype 0x88cc - neighbours announce themselves; ideal for switches/APs/phones. |
None |
| ARP sweep | Active | Broadcast ARP for every host in every connected /22 - catches ping-shy devices that ICMP misses. | One-shot |
| ICMP sweep | Active | Echo to each host in the local subnets (capped by --max-subnet-bits). |
One-shot |
| SNMPv2c | Active | Hand-rolled BER GET on UDP/161 - pulls sysName, sysDescr, sysObjectID, sysContact, sysLocation, sysUpTime, ifNumber. |
Bounded |
| mDNS query | Active | One-shot _services._dns-sd._udp.local query for service-advertising hosts. |
One-shot |
| TCP/UDP probe | Active | Targeted port probes - service hints (HTTP, SSH, SMB, …). | Targeted |
| NetBIOS | Active | NetBIOS name-service query for legacy Windows hosts. | Targeted |
Why this layout matters:
- ARP sweep is the single biggest coverage win. ICMP echo is dropped by a long tail of consumer devices (Windows firewall defaults, IoT silence, "ping-shy" servers); ARP, by contrast, has to answer for the host to be reachable on the LAN. The ARP sweep is parallelized across interfaces and capped at
/20(4096 hosts) so a misconfigured/16doesn't generate 64k frames in one tick. - LLDP gives free identification. When a directly-connected switch or AP speaks LLDP, you get its chassis ID, port description, system name, system description and capabilities - without sending a single packet.
- SNMPv2c reaches managed gear that doesn't speak LLDP. The probe is hand-rolled (~250 lines, no external dependency) and runs with bounded concurrency plus a per-host timeout (default 1 s) so SNMP-dark hosts don't stall the sweep.
- Subnet expansion is capped.
MaxSubnetBits = 10(default) gives/22 = 1024 hosts. Set to8for strict/24behaviour, or12for/20. Anything wider is silently skipped to avoid burying the local NIC. - Per-interface goroutine isolation. Each interface gets its own listener goroutine; a stuck or erroring interface can't starve the others.
On-demand nmap scan. Beyond the always-on layers above, an operator can trigger a targeted nmap scan against a single IP or a CIDR block - press N in the Devices tab (TUI) or POST /api/device/nmap-scan (web). The target is validated as an IP/CIDR before it ever reaches the command line, the scan uses nmap's default host discovery (so ICMP-shy hosts are still found via ARP/TCP probes) followed by a fast top-ports TCP sweep (-F), and every host that comes back up is folded into the inventory under the nmap source. If the nmap binary isn't on PATH the feature degrades gracefully with a clear "nmap not found" message rather than an error.
Interfaces can be enabled, disabled, switched to DHCP, assigned static IPs, gateways, and DNS servers - directly from the TUI or web UI.
┌ Interfaces ─────────────────────────────┐
│ eth0 │
│ Status: UP │
│ Mode: DHCP │
│ IPv4: 192.168.1.20/24 │
│ Gateway: 192.168.1.1 │
│ RX: 122 MB/s TX: 12 MB/s │
└─────────────────────────────────────────┘
Current backend: iptables / nftables. firewalld support is on the roadmap.
Supported operations: inspect rules, inspect counters, inspect blocked traffic, hit counts, create rules, remove rules, replay firewall events.
┌ Firewall Rules ─────────────────────────────────────┐
│ Chain: INPUT │
│ DROP tcp -- 0.0.0.0/0 -> 22/tcp │
│ Hits: 1221 │
│ Blocked Traffic: 82 MB │
└─────────────────────────────────────────────────────┘
Inspect routing tables, add static routes, remove routes, and replay route changes from the historical log.
┌ Static Routes ──────────────────────────────────────┐
│ 10.0.0.0/24 via 192.168.1.1 dev eth0 │
│ 172.16.0.0/16 via 10.0.0.1 dev tun0 │
└─────────────────────────────────────────────────────┘
Create and remove port forwards, inspect NAT and masquerading rules, and observe forwarding counters.
┌ NAT Rules ──────────────────────────────────────────┐
│ DNAT tcp 0.0.0.0:443 -> 192.168.1.10:443 │
│ Hits: 8421 │
│ Forwarded: 12.4 GB │
└─────────────────────────────────────────────────────┘
A layered probe pipeline runs alongside discovery and capture so latency, loss, and service-health signal keep flowing even when packet capture is off:
- External ICMP & DNS - the baseline. Configurable targets and names; single-sample RTT and resolution latency feed the live aggregator.
- Internal DNS - queries LAN resolvers directly (not via the stub) so a slow
systemd-resolvedupstream is visible separately from external slowness. Upstreams are auto-detected from/etc/resolv.confand/run/systemd/resolve/resolv.conf; the loopback stub127.0.0.53is skipped. - Top talkers - tracks the busiest LAN hosts from the flow table and probes them on a 30 s rotation. Each tick fires both an ICMP echo and a TCP connect to the host's busiest observed service port - so "host pings but service is down" surfaces immediately.
- LAN reachability - pings every entry in the discovery inventory every 60 s. Works without flow capture, giving you per-host RTT for the whole known LAN.
- HTTP endpoints - GET with
httptraceagainst configured URLs (or auto-derivedhttps://<name>/for each entry inDNSNames). Reports TTFB plus a status-code-class severity (5xx → ERROR, 4xx → INFO). - TLS certificate expiry - dials each
host:porton a 6-hour cadence, reads the leaf cert'sNotAfter. WARN inside 14 days, CRITICAL inside 3. The single most common preventable outage. - Continuous traceroute - per-target trace on a 5-minute cadence. Raises anomalies on hop-set changes (route flap detection) and emits per-hop RTT events.
- Bufferbloat probe - off by default, gated by
--bufferbloat. Each run takes a 3 s idle ping baseline, saturates the link with a throughput download forBufferbloatDuration, and reports the loaded-vs-idle RTT delta. Severity ladder: 30 / 100 / 300 ms → INFO / WARN / ERROR.
-
Per-interface stability - polls every non-loopback interface every 5 s, emits anomalies on UP/RUNNING transitions, growing
Rx/TxErrors, growingRx/TxDropped, and collision growth. -
WiFi monitoring (nl80211 native) - talks nl80211 over netlink directly via
github.com/mdlayher/wifi, so the collector works on every modern driver without theiwuserspace package and without root on most distros. Surfaces per-radio SSID, BSSID, channel + frequency + width (incl. 6 GHz Wi-Fi 6E), band, TX/RX bitrate (Mbit/s), signal + signal average + noise floor + SNR, TX power, link quality, station-level RX/TX bytes/packets, retries, TX failures, beacon loss, and connected-since. Theiwshell-out and/proc/net/wirelessremain as automatic fallbacks for unusual drivers, with the source labelled inline ("nl80211" / "iw" / "proc") so the operator knows which backend filled each card. Anomalies fire on low signal, lost association, growing retries, growing TX failures, and growing beacon loss. Unassociated radios still appear in the iface table so they don't silently disappear. -
L2 monitor - per-interface multicast/broadcast burst detection (catches ARP storms, runaway mDNS) and ARP-table churn (IP→MAC reassignment surfaces IP conflicts or rogue devices).
-
L3 state introspection (netlink) - a slow-cadence collector dumps the kernel neighbour table via RTNETLINK
RTM_GETNEIGHfor both address families (ARP for IPv4, NDP for IPv6), carrying resolution state (REACHABLE / STALE / FAILED / INCOMPLETE / …) the legacy/proc/net/arpparse hides. Duplicate-IP detection flags any address answered by more than one MAC (conflict / rogue device →KindDuplicateIP, LAN-grade penalty), and the unreachable-neighbour ratio feeds the Stability sub-score. The same collector dumps the nf_conntrack table via NFNL (IPCTNL_MSG_CT_GET): per-flow original/reply tuples (the reply differs once NAT rewrites it), protocol, state, NAT mark, byte/packet counters, and timeout - with a write-gated, audit-logged flush to kill a stuck or translated flow. Both surface in TUI and web (Devices → neighbour table, NAT → conntrack), persist to theneighbours/conntrack_samplestables for replay, and ride the conntrack snapshot into incident bundles. -
Netlink push, not poll - a dedicated watcher subscribes to the RTNETLINK multicast groups (
RTNLGRP_LINK/IPV4_IFADDR/IPV6_IFADDR/IPV4_ROUTE/IPV6_ROUTE) and turns each kernel state change into a bus event (KindLinkStateChange/KindAddrChange/KindRouteChange) the instant it is emitted - link-flap and default-route-change detection drops from "up to one poll interval" to sub-second, and replay timelines gain precise change timestamps instead of poll-quantised ones. A burst of rapid transitions is coalesced (250 ms window) into a single "eth0 flapped 6× in 2s" anomaly rather than six rows, and a slow reconcile timer (60 s) re-reads full state and diffs it as a safety net for any dropped multicast message. The push-derived flap-rate and default-route churn feed the Stability sub-score. The watcher reads only - if the kernel refuses the subscription (restricted namespace / missing capability) it soft-fails to reconcile-only ("polled") and reports itself degraded; the Interfaces / Routes tabs in both the TUI and web carry a live / polled indicator so the freshness of the feed is always visible.
- Per-device bandwidth history - rolling 10-min × 5 s buckets per LAN host, derived from the live flow aggregator. Renders as a TX sparkline next to each row in the Devices tab.
- LAN ↔ LAN matrix - east-west traffic by host pair, sorted by bytes. New section in the Talkers tab; the diagnostic for "the LAN feels slow."
- Chatter anomaly - per-device TX baseline (median over the last 5 min) with a 3× alert threshold and 5-min cooldown. Catches exfil patterns, runaway containers, looped services. Suppressed for devices below 1 KB/s baseline to avoid noise.
A continuous analysis engine watches latency, jitter, packet loss, DNS timing, retransmissions, firewall drops, route instability, bandwidth spikes, NAT exhaustion, per-device chatter, link-state flaps, interface error counters, ARP-table churn, multicast bursts, and TLS cert expiry. Anomalies are routed to a four-level severity ladder.
| Level | Meaning |
|---|---|
| INFO | Informational |
| WARN | Degraded condition |
| ERROR | Operational issue |
| CRITICAL | Severe degradation |
┌ Alerts ─────────────────────────────────────────────┐
│ [WARN] DNS latency exceeded threshold │
│ [CRIT] Packet loss burst detected │
│ [INFO] Route changed on eth0 │
│ [WARN] Firewall rule dropping excessive traffic │
└─────────────────────────────────────────────────────┘
CRITICAL anomalies trigger the incident engine, which snapshots context (top flows, route state, firewall state, recent metrics) into a JSON bundle for forensic replay.
Replay mode reconstructs past sessions from persisted metrics, flows, alerts, and route/firewall snapshots.
testudo replay session-2026-05-23Timeline navigation, flow replay, DNS replay, firewall replay, route replay, NAT replay, topology replay, and full incident reconstruction. Example timeline:
19:42:11 Session started
19:44:02 WARN DNS latency spike
19:44:15 WARN Upload saturation
19:44:18 CRITICAL Packet loss burst
19:44:21 ERROR Firewall drops increasing
Raw packets are not persisted by default. Captures are triggered selectively by anomaly events - packet loss bursts, firewall anomalies, DNS failures, retransmission spikes, route instability, NAT exhaustion - and rotated according to retention policy. There is no always-on full-capture mode by design.
- Latency timelines
- Packet-loss heatmaps
- Throughput graphs
- Protocol-distribution graphs
- Alert timelines
- Firewall hit charts
- Flow-activity graphs
Latency Timeline
▁▁▂▂▃▄▅▆▇▆▅▄▃▂▁
The web UI mirrors every TUI view: dashboard, flows, devices, interfaces, routes, firewall, NAT, alerts, settings. Authentication uses bcrypt-hashed credentials stored locally; sessions are cookie-based with an 8-hour TTL. Default user: testudo (rotate with testudo user passwd).
- Sentry - optional, DSN-gated panic and error reporting.
- Apache Guacamole - URL deep-link helper for SSH/RDP/VNC handoff from the discovered device inventory.
- IPFIX export - optional IETF IPFIX (RFC 7011) flow export to an external collector (e.g. opsanio, ipfixcol2, nProbe, Elastic); configured from the Settings tab.
- MaxMind GeoIP - optional geolocation/ASN enrichment. When enabled, observed public IPs are annotated with country, city, ASN + organisation, and anonymity signals (proxy / Tor / hosting) from local
.mmdbfiles or a sidecarmmdb-server. The engine reconciles the config against the live enricher on a 2 s cadence (no restart needed), results are LRU- and SQLite-cached, and a license key enables periodic auto-download of the configured editions. Looked up over the web UI atGET /api/ip/{addr}.
The TUI is the canonical interface. The web UI is the same data, the same engine, exposed over HTTP for operators running Testudo on a headless box. Both surface the same tabs - in the same order as the web topbar previewed in the header above:
| Tab | Purpose |
|---|---|
| Dashboard | Network Quality grade, bandwidth per interface (live upload/download speed), ICMP/DNS sparklines, top flows, and a grouped Key Indicators KPI strip (Network · Health · Security · Infrastructure) on the web UI |
| Flows | Live multi-interface flow table with process / DNS / service enrichment; capture controls; Process → Iface → Service → Host Sankey diagram (web UI) with a bytes/packets metric toggle and click-to-filter |
| Devices | Discovered devices, vendor, open ports; Scan + Connect (Guacamole / native URI); n toggles the full ARP/NDP neighbour table (f filters by family) with state + duplicate-IP highlighting; N runs an on-demand nmap scan against an entered IP / CIDR and folds the results into the inventory |
| Interfaces | Per-interface state, MTU, hardware address, addresses, RX/TX, controls |
| Routes | Routing table, add/remove static routes |
| Firewall | iptables / nftables rule view with hit counters, Testudo-managed rules, add/remove |
| NAT | NAT and port-forwarding rules with counters, add/remove; live conntrack table (c to focus) showing NAT'd reply tuples, with F to flush a stuck flow (write-gated) |
| TCPDump | Selective PCAP capture with a BPF filter wizard (proto, host, port, raw filter) |
| Talkers | Top hosts, top processes, top services - all ranked by bytes |
| Probes | Interactive runner for ICMP / TCP / UDP / DNS / throughput / traceroute probes |
| Alerts | Live alert log with severity filter and free-text search |
| History | Read-only browse of past sessions persisted to SQLite; anomaly timeline + snapshots |
| Settings | Live-tunable thresholds, netops & integrations, IPFIX flow export (e.g. to opsanio) |
| Health | Live results of every probe collector: top talkers, internal DNS, HTTP, TLS certs, traceroute, bufferbloat, interfaces, WiFi |
Modal configuration is supported in both UIs for firewall rules, NAT rules, port forwarding, interface configuration, route configuration, and alert configuration.
┌ Add Firewall Rule ────────────────────────────────┐
│ Chain: INPUT │
│ Protocol: TCP │
│ Port: 443 │
│ Action: ACCEPT │
│ │
│ [ Save ] [ Cancel ] │
└───────────────────────────────────────────────────┘
flowchart TD
ICMP[ICMP collector] --> Bus
DNS[DNS collector] --> Bus
Cap[AF_PACKET capture<br/>multi-interface] --> Bus
Disc[Discovery scanner] --> Inventory
Bus[Event Bus] --> MetricAgg[Metrics aggregator]
Bus --> FlowAgg[Flow aggregator]
Bus --> Analyzers[Anomaly analyzers]
Bus --> Incidents[Incident engine]
Bus --> Store[(SQLite store)]
Bus --> TUI
Bus --> Web[Web UI]
Settings((Settings store)) -.->|live snapshots| Analyzers
FlowAgg --> Correlators[Process · DNS · Service]
Inventory --> Store
Inventory --> TUI
Inventory --> Web
NetOps((netlink ops)) <--> TUI
NetOps <--> Web
NetOps --> Kernel[(Linux kernel)]
Probes((User probes)) --> TUI
Probes --> CLI
flowchart LR
Collectors --> EventBus
EventBus --> FlowEngine
EventBus --> AlertEngine
EventBus --> FirewallEngine
EventBus --> RouteEngine
EventBus --> NATEngine
EventBus --> ReplayEngine
EventBus --> StorageEngine
EventBus --> TUI
EventBus --> WebUI
Every subsystem speaks to one another exclusively through the event bus. There are no direct cross-module calls in the data plane. This isolation is what makes replay possible: a session is reconstructed by feeding persisted events back into the same bus.
flowchart LR
Packets[Raw packets] --> Ring[Layer 1<br/>Live ring buffer]
Ring --> Flows[Layer 2<br/>Flow aggregation]
Flows --> Metrics[Layer 3<br/>SQLite metrics]
Anomaly{CRITICAL<br/>anomaly?}
Ring --> Anomaly
Anomaly -->|yes| PCAP[Layer 4<br/>Selective PCAP]
Anomaly -->|no| Drop[Discard]
Metrics --> Replay[Replay engine]
PCAP --> Replay
flowchart TD
TUI --> CoreEngine
WebUI --> CoreEngine
CoreEngine --> FlowEngine
CoreEngine --> FirewallEngine
CoreEngine --> RouteEngine
CoreEngine --> ReplayEngine
CoreEngine --> AlertEngine
CoreEngine --> StorageEngine
The TUI and the web UI are siblings. They consume snapshots from the same core engine, which means a feature shipped in one is automatically available in the other.
Testudo is built around ten principles that constrain every implementation decision:
- Live-first observability. The default mode is live. Historical analysis is layered on top, not the other way around.
- Historical replayability. Anything you can observe live, you can reconstruct after the fact from a session ID.
- Flow aggregation over packet storage. Raw packets are ephemeral. Flow summaries are what get persisted.
- Selective PCAP capture. Raw packets are kept only when an anomaly justifies it. There is no "always-on full capture" mode by design.
- Event-driven processing. All subsystems talk through one event bus. No direct cross-module data-plane calls.
- Low-overhead operation. Rolling buffers, bounded queues, compressed time-series, no unbounded memory growth.
- Linux-first architecture. Pure-Go netlink, AF_PACKET,
/procparsing. Nocgo, nolibpcap, no external metrics backend required. - Terminal-first interaction. The TUI is the canonical interface. The web UI mirrors it; it does not extend it.
- Reproducible diagnostics. Every probe and every operator action is captured as an event for later inspection.
- Modular subsystem isolation. A subsystem can be removed or replaced without touching another subsystem's code.
The storage pipeline has four layers, each one less volatile than the last:
| Layer | Medium | Lifetime | Purpose |
|---|---|---|---|
| 1 | In-memory ring | Seconds to minutes | Live rendering, instant anomaly replay |
| 2 | Flow aggregator | Minutes to hours | Active flow tracking, enriched summaries |
| 3 | SQLite | Days to months | Metrics, alerts, route/firewall history |
| 4 | Selective PCAP | Days (incident-bound) | Forensic packet evidence |
A typical FlowSummary looks like:
type FlowSummary struct {
Interface string
SrcIP string
DstIP string
Protocol string
BytesIn uint64
BytesOut uint64
AvgLatency float64
PacketLoss float64
DNSName string
ProcessName string
}Before building or running Testudo, ensure you have:
- Go: 1.25 or newer
- OS: Linux (Ubuntu / Debian recommended; Fedora, Arch, openSUSE on the roadmap)
- Kernel privileges:
CAP_NET_RAWandCAP_NET_ADMINon the resulting binary (or run as root - not recommended) - Disk: a few hundred MB for SQLite session history and incident-triggered PCAPs
- Optional: a Sentry DSN for crash reporting, an Apache Guacamole instance for SSH/RDP/VNC handoff
Testudo is pure Go. There is no cgo dependency, no libpcap, no libsqlite3. A clean machine needs only the Go toolchain to build.
-
Clone:
git clone https://github.com/noahzmr/testudo.git cd testudo -
Build, mark executable, and install system-wide - one line that puts
testudoon your$PATH:go build -o testudo ./cmd/testudo && chmod +x testudo && sudo mv testudo /usr/local/bin/
-
Grant capabilities (one-time, after install):
sudo setcap cap_net_raw,cap_net_admin=+ep /usr/local/bin/testudo
The capabilities are held by a thin privileged helper, not the whole process - see Privilege Separation below.
-
Run from anywhere in your terminal:
testudo # live TUI, default targets, no capture testudo live --capture # add multi-interface capture testudo web # start the HTTP UI on 127.0.0.1:8080 testudo discover --active --lldp # one-shot layered network scan testudo replay session-2026-05-23 # open a past session in replay mode
-
Log in to the web UI (if started). On the first
testudo webinvocation the server provisions a default usertestudowith a freshly-generated random password printed once to stderr. Rotate it any time with:testudo user passwd
Testudo no longer runs the web server, TUI, collectors, and analyzers in one PID
holding CAP_NET_RAW+CAP_NET_ADMIN. Instead:
- A thin privileged helper (
testudo __helper, a re-exec of the same binary) holds the capabilities and performs only the narrow set of operations that need them - netlink/nftables mutations (route/iface/addr/MTU/NAT/filter/conntrack) and raw-socket opening. It talks to the engine over an anonymousSOCK_SEQPACKETsocket, file descriptors travel back viaSCM_RIGHTS, and the helper authenticates the engine withSO_PEERCRED. - The engine - including the web server and TUI - runs unprivileged. After
spawning the helper it sets
PR_SET_NO_NEW_PRIVS, clears its capability bounding set, and drops all capabilities (capset). A web-plane compromise no longer inherits raw-socket / netlink-write power. - The helper applies a seccomp-bpf denylist (blocking
execve,ptrace, module loading,mount/namespace manipulation, raw memory injection) so its attack surface stays small even while holding caps. - The
netops.Writergained a backend seam: every call site (ListIfaces,AddRoute,AddPortForward, …) keeps its exact signature; only the backend swaps between in-process (directBackend) and helper-RPC (helperBackend).
Every privileged mutation is audit-logged (audit_log: ts, op, args, peer_uid, result) and viewable read-only in both UIs. Each collector is
supervised: a panic recovers into a bounded-backoff restart and a degraded
health state rather than crashing the engine; after the restart budget is spent
the subsystem is marked permanently failed (no crash-loop). A missing
capability soft-fails to an unprivileged state carrying the exact setcap
hint. The Health tab (TUI) and the web dashboard surface this as a
first-class subsystem-status table, and the Network Quality card shows a
"⚠ reduced coverage" badge when a core signal collector (ICMP/DNS/capture)
is degraded - so an A grade isn't mistaken for "all good" while collectors are
down.
Privilege separation is on by default; pass --privsep=false to testudo live
for the legacy single-process behaviour.
go build -o testudo ./cmd/testudogo run ./cmd/testudoDrop the binary into a directory that's already on your $PATH so you can call testudo from any shell:
go build -o testudo ./cmd/testudo \
&& chmod +x testudo \
&& sudo mv testudo /usr/local/bin/Verify:
which testudo # /usr/local/bin/testudo
testudo --versionsudo setcap cap_net_raw,cap_net_admin=+ep /usr/local/bin/testudo| Capability | Used for |
|---|---|
CAP_NET_RAW |
Raw ICMP, AF_PACKET capture, ARP sweep, LLDP listener, traceroute, ICMP discovery |
CAP_NET_ADMIN |
Netlink writes, nftables, promisc-mode interface configuration |
Verify:
getcap /usr/local/bin/testudo
# /usr/local/bin/testudo cap_net_admin,cap_net_raw=epsudo rm /usr/local/bin/testudoTestudo is pure Go and cross-compiles cleanly to any GOOS=linux target:
GOOS=linux GOARCH=amd64 go build -o testudo-linux-amd64 ./cmd/testudo
GOOS=linux GOARCH=arm64 go build -o testudo-linux-arm64 ./cmd/testudogo test ./...testudo <subcommand> [flags]| Subcommand | Description |
|---|---|
live |
Launch the live TUI (default if no subcommand is given) |
web |
Start the HTTP UI |
sessions |
List recorded replay sessions |
replay <id> |
Open a replay session |
ifaces |
List interfaces and their state |
routes |
Show the routing table |
nat list |
List NAT and port-forwarding rules |
discover |
One-shot network scan |
probe <host> |
Diagnostic probe against a host |
doctor |
Layered connectivity diagnosis - reports the first failing layer (link → address → route → gateway → DNS → WAN → captive portal) as the root cause; --json for scripts, exit code 2 when broken |
user passwd |
Rotate the local web-UI password |
| Flag | Default | Description |
|---|---|---|
--capture |
off | Enable multi-interface AF_PACKET capture |
--iface=<csv> |
auto-discover | Capture only on the named interfaces (e.g. wlp1s0,wg0) |
--exclude-iface=<csv> |
none | Skip the named interfaces during auto-discovery |
--allow-netops-write |
off | Permit route/interface/NAT writes from the TUI |
--bufferbloat |
off | Enable the bufferbloat probe (saturates link periodically to measure loaded-RTT delta) |
--bufferbloat-target |
first ICMP target | Ping target during the bufferbloat probe |
--bufferbloat-interval |
1h |
Gap between bufferbloat runs |
--listen=<addr> |
127.0.0.1:8080 |
Web UI listen address (web subcommand) |
--active |
off | Active discovery - ARP broadcast sweep + ICMP sweep + mDNS query + SNMPv2c (discover) |
--lldp |
on | Passive LLDP listener for directly-connected neighbours (discover) |
--snmp-community=<str> |
public |
SNMPv2c read community; empty string disables SNMP (discover) |
--snmp-timeout=<dur> |
1s |
Per-host SNMP UDP/161 deadline (discover) |
--max-subnet-bits=<int> |
10 |
Cap subnet expansion for active sweeps; 10 = /22 = 1024 hosts (discover) |
--wait=<dur> |
6s |
Discovery dwell time / LLDP listen window (discover) |
--config=<path> |
~/.testudo/settings.json |
Override the persistent settings path |
--log-level=<level> |
info |
debug / info / warn / error |
--no-color |
off | Disable ANSI color in the TUI |
--version |
- | Print version and exit |
--help |
- | Print help and exit |
testudo # live TUI, default targets, no capture
testudo live --capture # add multi-interface capture (auto-discover)
testudo live --iface=wlp1s0,wg0 # capture on specific interfaces
testudo live --allow-netops-write # permit route/interface/NAT writes from TUI
testudo web --listen=0.0.0.0:8443 # bind the web UI to all interfaces
testudo discover --active --lldp --wait 8s # full layered scan: ARP sweep + ICMP + mDNS + SNMP + LLDP
testudo discover --snmp-community=monitoring # SNMPv2c probe with a non-default community
testudo discover --max-subnet-bits=8 # restrict active sweeps to /24
testudo replay session-2026-05-23 # open a past session in replay modeTestudo ships with intelligent defaults. Every threshold is live-tunable from the Settings tab in the TUI, from the Settings panel in the web UI, and from the persisted config file at ~/.testudo/settings.json.
| Setting | Default | Description |
|---|---|---|
| Packet loss | 2 % | Rolling 20-sample loss percentage |
| DNS latency | 120 ms | Per-query DNS warning threshold |
| Jitter | 20 ms | Mean RTT delta over the last 20 samples |
| RTT | 150 ms | Single-sample ICMP round-trip warning |
| Retransmissions | 5 % | TCP retransmission warning threshold |
| Incident cooldown | 60 s | Minimum seconds between incident bundle triggers |
| Setting | Default | Description |
|---|---|---|
| Replay retention | 30 days | Session metrics kept in SQLite |
| PCAP retention | 7 days | Incident-triggered PCAP rotation horizon |
| Smart PCAP capture | enabled | Trigger captures from CRITICAL anomalies |
| Capture interfaces | auto-discover | Comma-separated interface override |
| Interface exclusions | none | Interfaces to skip during auto-discovery |
| Setting | Default | Description |
|---|---|---|
DiscoveryEnabled |
true | Master toggle for the discovery scanner (passive listeners + scheduler) |
DiscoveryActive |
false | Enable active probes: ARP broadcast sweep, ICMP sweep, mDNS query, SNMPv2c GET |
DiscoveryInterval |
60 s | Cadence of one discovery round |
DiscoveryMaxSubnetBits |
10 | Prefix-expansion cap; 10 = /22 (1024 hosts), 8 = /24, 12 = /20, wider is silently skipped |
LLDPEnabled |
true | Passive LLDP listener (needs CAP_NET_RAW; soft-fails per interface when missing) |
SNMPCommunity |
public |
SNMPv2c read community; empty string disables SNMP probing entirely |
SNMPTimeout |
1 s | Per-host SNMP UDP/161 deadline |
| Setting | Default | Description |
|---|---|---|
TopTalkersEnabled |
true | Periodically probe top-N LAN talkers (ICMP + TCP to the busiest observed service port) |
TopTalkersInterval |
30 s | Top-talkers probe cadence |
TopTalkersMaxHosts |
5 | Hard cap on simultaneously probed hosts |
LANReachEnabled |
true | Continuous ICMP to every device in the discovery inventory |
LANReachInterval |
60 s | Inventory ping cadence |
DNSInternalEnabled |
true | Probe LAN resolvers directly (not via the stub) |
DNSInternalServers |
auto | Explicit resolver list; empty = parse /etc/resolv.conf + /run/systemd/resolve/resolv.conf |
HTTPEndpoints |
auto | URLs to GET; empty = derived https://<name>/ per DNSNames |
HTTPInterval |
30 s | HTTP probe cadence |
HTTPTimeout |
5 s | Per-endpoint deadline |
TLSCertTargets |
auto | host:port list; empty = <name>:443 per DNSNames |
TLSCertInterval |
6 h | Cert-expiry check cadence |
TLSCertWarnDays |
14 | Days remaining that fire WARN |
TLSCertCritDays |
3 | Days remaining that fire CRITICAL |
TracerouteEnabled |
true | Continuous traceroute per target |
TracerouteInterval |
5 min | Trace cadence |
TracerouteHops |
16 | Max hops per trace |
BufferbloatEnabled |
false | Saturate link + measure loaded-RTT delta (heavy; toggle via --bufferbloat) |
BufferbloatInterval |
1 h | Gap between runs |
BufferbloatDuration |
10 s | Length of the loaded phase |
IfaceHealthEnabled |
true | Per-interface error / drop / link-state monitor |
IfaceHealthInterval |
5 s | Poll cadence |
WiFiEnabled |
true | Per-radio WiFi snapshot via nl80211 (SSID/BSSID/channel/bitrate/station counters) |
WiFiInterval |
10 s | Poll cadence |
WiFiMinSignal |
-75 dBm | "Weak signal" anomaly threshold |
L2Enabled |
true | Multicast/broadcast burst detection + ARP-table churn |
L2Interval |
10 s | Sample cadence |
L2MulticastThreshold |
1000 pkts | Per-tick burst threshold |
DeviceChatterEnabled |
true | Per-LAN-device TX-rate anomaly |
DeviceChatterFactor |
3.0 | Multiplier above the 5-min baseline that fires WARN |
| Setting | Default | Description |
|---|---|---|
| Sentry DSN | unset | Enable Sentry panic/error reporting |
| Guacamole base | unset | Base URL for the Guacamole instance |
IPFIXEnabled |
false | Export flow records over IETF IPFIX (RFC 7011) |
IPFIXEndpoint |
unset | Collector address (host:port) for IPFIX export |
IPFIXIntervalSec |
30 | Seconds between IPFIX data exports |
IPFIXDomainID |
auto | Observation Domain ID (derived from hostname when left at 0) |
MaxMindEnabled |
false | Master switch for MaxMind GeoIP enrichment of public IPs |
MaxMindDBDir |
unset | Directory holding the .mmdb files (or a sidecar mmdb-server) |
MaxMindAccountID |
unset | MaxMind account ID (required for some commercial editions) |
MaxMindLicenseKey |
unset | License key for auto-download; empty disables updating |
MaxMindEditions |
unset | Comma-separated edition IDs to auto-download |
MaxMindAutoUpdate |
false | Periodically re-download the configured editions |
MaxMindRefreshHours |
0 | Refresh cadence in hours; 0 => 7-day default |
┌ Settings ───────────────────────────────────────────┐
│ Packet Loss Threshold: 2 % │
│ DNS Warning Threshold: 120 ms │
│ Replay Retention: 30 days │
│ Smart PCAP Capture: Enabled │
└─────────────────────────────────────────────────────┘
The dashboard's most prominent element is a single letter grade (A+ through F) with a 0-100 score next to it. It's meant to be the one thing you glance at to answer "is the network OK right now?" before you dive into any specific tab.
┌ Network Quality ──────────────────────────────────────┐
│ │
│ ┌─────┐ 92 / 100 │
│ │ A │ Very good │
│ └─────┘ │
│ │
│ LOSS ████████████████ 0.1% │
│ RTT ███████████████░ 18ms │
│ JITTER ████████████████ 4ms │
│ DNS ███████████████░ 22ms │
│ LAN ████████████████ 1ms │
│ HTTP ██████████████░░ 120ms │
│ STAB ████████████████ 0.0% │
│ WIFI █████████████░░░ -65dBm │
│ │
└───────────────────────────────────────────────────────┘
Eleven live measurements, each pulled from the metrics aggregator (plus kernel, firewall, neighbour, conntrack, and per-flow TCP counters). Every measurement is scaled into its own 0-100 sub-score and the sub-scores are combined with weights:
| Sub-score | Weight | What it measures |
|---|---|---|
| Packet loss | 20 % | Average loss percentage across WAN-side targets (external ICMP + WAN top-talker probes) |
| RTT | 15 % | Average round-trip latency across WAN-side targets |
| Jitter | 5 % | Rolling RTT variation across WAN-side targets |
| DNS latency | 10 % | Average resolution time across external + internal resolvers |
| LAN | 15 % | Reachability to LAN-side hosts (blends LAN loss + LAN RTT); a duplicate-IP conflict in the neighbour table applies a hard penalty for as long as it persists |
| HTTP | 5 % | Configured / auto-derived HTTP endpoints - blends failure rate and TTFB |
| Stab | 10 % | Per-interface error / drop ratio blended with the unreachable-neighbour ratio (FAILED + INCOMPLETE ARP/NDP entries - a failed gateway neighbour is imminent connectivity loss), plus push-derived link flap-rate and default-route churn from the RTNETLINK watcher (each with a 2/min comfort line - a 300 ms flap a 5 s poll would miss now drags the grade, and an uplink that keeps re-electing its default route registers as instability) |
| WiFi | 10 % | Average signal level (dBm) across associated wireless interfaces; -60 dBm = 100, -90 dBm = 0 (linear) |
| Firewall | 5 % | DROP/REJECT velocity (drops/sec) across Testudo-managed blocking rules, diffed between snapshots; 0 = 100, 10 drops/sec = 50 |
| NAT | 5 % | Conntrack table utilisation (live entries ÷ nf_conntrack_max); 70 % = 50, near-saturation drags the grade and fires the NAT-exhaustion anomaly with real numbers |
| Congestion | 5 % | Flow-weighted per-flow retransmission rate from tcp_info (sourced pure-Go via INET_DIAG / ss -ti, or eBPF when built with -tags ebpf) - busy flows dominate the weight, so this replaces the blunt system-wide /proc/net/snmp retransmission number. Anchored to the retransmissions threshold; neutral 100 when no active TCP flows carry telemetry |
On top of the weighted sub-scores, a detected PMTU black-hole / frag-needed condition (a flow retransmitting without forward progress - the classic "some sites won't load" fault) applies a fixed -15 penalty so the letter reflects it even when the averages look fine. The worst active-flow RTT can also sharpen the RTT sub-score downward on a busy host - the path the user actually cares about, not just the probe target.
Loss / RTT / Jitter still anchor the grade because those are what users feel, but the grade is no longer blind to the rest of the stack: a slow LAN host, a 5xx HTTP endpoint, a flapping NIC, a wireless radio at the edge of coverage, a firewall rule suddenly eating traffic, a duplicate IP fighting over an address, a single connection drowning in retransmits, a PMTU black-hole silently dropping large packets, or a conntrack table about to overflow all surface immediately. Each sub-score returns a neutral 100 when its data source is empty, so a box without WiFi, without HTTP endpoints, without managed DROP rules, without a loaded conntrack table, or without active TCP flows doesn't pay a penalty for it.
Because the metrics aggregator stores every probe result under the same (target, RTT) shape, the grade has to route each target into the right sub-score so HTTP TTFB doesn't pollute the ICMP RTT number and an offline LAN host doesn't drag WAN loss:
| Pattern | Routed to |
|---|---|
http://… / https://… |
HTTP |
RFC1918 / link-local IP, or *.lan/.local/.home/.internal |
LAN |
trace:…, wifi:…, bufferbloat:… (synthetic probe targets) |
excluded - own scoring |
| Everything else | Loss / RTT / Jitter (WAN) |
Each sub-score is anchored to the matching threshold from the Settings tab (see Anomaly Thresholds). The mapping is linear in two segments:
| Measured value | Sub-score |
|---|---|
0 (perfect) |
100 |
threshold (comfort) |
50 |
2 × threshold or worse |
0 |
So with the default threshold for packet loss of 2 %:
| Real-world loss | Loss sub-score |
|---|---|
| 0 % | 100 |
| 0.5 % | ~87 |
| 1.0 % | 75 |
| 2.0 % | 50 (comfort) |
| 3.0 % | 25 (painful) |
| 4.0 % or more | 0 |
The same shape applies to RTT (threshold 150 ms), Jitter (20 ms) and DNS (120 ms). Tune the thresholds in Settings if your environment is faster or slower than the defaults - the grade re-scales automatically.
Empty inputs map to a neutral 100. "Nothing measured yet" should not paint the dashboard red on first start.
The four sub-scores are weighted, summed, rounded, and run through this ladder:
| Score range | Letter | Verdict | Colour | Roughly means… |
|---|---|---|---|---|
| 95 - 100 | A+ | Excellent | green | Effectively at the noise floor. Nothing to investigate. |
| 90 - 94 | A | Very good | green | Healthy. Small natural jitter, no loss. |
| 85 - 89 | A- | Good | green | Normal day-to-day - typical wired LAN or solid Wi-Fi. |
| 80 - 84 | B+ | OK | yellow | One sub-score brushing its comfort line; everything still usable. |
| 70 - 79 | B | Acceptable | yellow | Background noise that humans notice (slight lag, occasional stutter). |
| 60 - 69 | C | Degraded | orange | One or more sub-scores past the threshold; calls and streams will hiccup. |
| 50 - 59 | D | Poor | red | Multiple sub-scores in the painful zone; expect complaints. |
| 0 - 49 | F | Failing | red | Network is meaningfully broken; open the Alerts tab and start digging. |
Absolute thresholds answer "is this bad?"; they can't answer "is this unusual?". Testudo learns a per-target, per-hour-of-day baseline (a rolling quality_rollup table keyed (target, day-of-week, hour) holding p50/p95/p99/loss/jitter, merged with an exponential moving average so recent weeks dominate and old data decays). The dashboard compares the live RTT against the baseline for this hour and shows a badge per target:
RTT to 1.1.1.1 18ms (≈ normal)
RTT to 1.1.1.1 55ms (3.1× normal ▲)
When the live metric is far worse than the learned normal even if the absolute threshold isn't breached yet, the grade is nudged down (up to −25 points) as an early warning - the letter drops before users start complaining. Within the normal envelope the modifier is neutral. An empty baseline (first run, or a freshly-added target) applies no penalty, consistent with the "nothing measured → nothing wrong" contract. When the network legitimately changes (new ISP, moved desk), use Settings → Reset learned baselines (write-gated) to clear the stale rows.
The RTT and jitter sub-scores are fed by the live percentile set, now p50 / p95 / p99 (previously only avg/p95). p99 surfaces the tail - the occasional 400 ms spike a healthy average hides - so the grade reflects what a latency-sensitive call or game actually experiences.
The bufferbloat probe's idle-vs-loaded RTT delta is mapped to its own A–F letter shown next to the grade:
| Loaded-vs-idle delta | Letter | Meaning |
|---|---|---|
< 30 ms |
A | Imperceptible. |
30 – 100 ms |
B | Mild - noticeable under load. |
100 – 200 ms |
C | Significant. |
200 – 300 ms |
D | Heavy - calls/gaming stutter under load. |
≥ 300 ms |
F | Severe - VoIP/gaming unusable. |
When latency climbs, Testudo decomposes the path (reusing the traceroute hops plus the gateway/WAN reference RTTs) into first-hop / gateway / WAN / target segments and names the one contributing the dominant incremental delay:
Degradation isolated to: WAN (gateway healthy)
A healthy path reports "path healthy (no segment dominates)". This turns "the internet is slow" into "the ISP path is slow but your LAN and router are fine" - the difference between opening a ticket with your ISP and rebooting your access point.
Some realistic ballparks - useful for calibrating your own expectations:
| Environment | Typical grade | Why |
|---|---|---|
| Wired LAN to a local gateway | A+ / A | Sub-millisecond RTT, no loss, DNS resolved by the local resolver. |
| Good home Wi-Fi to the internet | A / A- | 10-30 ms RTT, sub-percent loss, DNS in the 20-50 ms range. |
| Office VPN over a healthy ISP | A- / B+ | 30-80 ms RTT, occasional micro-jitter, DNS sometimes >80 ms. |
| Saturated uplink (someone's uploading) | B / C | RTT and jitter both climb; loss stays low until the queue overflows. |
| Wi-Fi at the edge of coverage | C / D | Retransmissions push effective loss up; jitter doubles or triples. |
| Misconfigured DNS, healthy link | B+ / B | Loss/RTT/jitter look great but DNS latency drags the total down. |
| Real outage in progress | F | Loss spikes, RTT explodes or times out, DNS fails to resolve. |
A persistent grade below B usually means something deserves an open ticket - either reality has changed (new neighbour on the spectrum, ISP route flap, NIC negotiating down) or the thresholds are set for a quieter network than the one you actually have.
- Watch the trend, not the single number. A dashboard sitting at A- all day is healthier than one bouncing between A+ and C.
- Look at the sub-scores when the letter drops. Each sub-score line shows the raw value and an
ok/overflag - the one taggedoveris the metric to investigate first. - The grade is per-host. It reflects how this Linux box is experiencing the network. Two Testudo instances on the same LAN can legitimately show different grades.
- Replay sessions show the grade for that moment in time. Scrolling back through a session is the cleanest way to find when the grade first dropped.
testudo/
├── cmd/
│ └── testudo/ ← CLI entry point + subcommand registry
├── internal/
│ ├── tui/ ← Bubble Tea TUI
│ ├── web/ ← HTTP UI + embedded assets
│ ├── auth/ ← bcrypt web-UI credentials
│ ├── engine/ ← lifecycle orchestrator + collector supervision
│ ├── events/ ← event bus + severity ladder
│ ├── collectors/ ← ICMP, DNS, HTTP, TLS, traceroute, WiFi, bufferbloat, …
│ ├── capture/ ← multi-interface AF_PACKET + ring buffer + PCAP
│ ├── telemetry/ ← per-flow TCP telemetry (INET_DIAG, optional eBPF)
│ ├── flows/ ← flow aggregator + correlators + device matrix
│ ├── analyzers/ ← anomaly detectors
│ ├── incidents/ ← alert log + incident bundles
│ ├── netops/ ← netlink/nftables: firewall, route, NAT, iface, conntrack, neigh, dns
│ ├── privsep/ ← privileged helper, cap-drop, seccomp, FD passing
│ ├── discovery/ ← ARP / ICMP / mDNS / LLDP / SNMP scanner
│ ├── topology/ ← passive topology graph
│ ├── probes/ ← one-shot diagnostic probe runner
│ ├── doctor/ ← layered connectivity diagnosis
│ ├── health/ ← subsystem-status tracking
│ ├── quality/ ← baseline rollup + Network Quality grade
│ ├── metrics/ ← rolling per-target stats
│ ├── ipfix/ ← IETF IPFIX (RFC 7011) flow exporter
│ ├── services/ ← well-known port → service mapping
│ ├── replay/ ← session reconstruction
│ ├── storage/ ← SQLite persistence + audit log
│ ├── integrations/
│ │ ├── sentry/ ← optional panic reporting
│ │ ├── guacamole/ ← SSH/RDP/VNC deep-link helper
│ │ └── maxmind/ ← optional GeoIP / ASN enrichment
│ └── config/ ← defaults + persistent settings
├── storage/
│ ├── captures/ ← incident-triggered PCAP (gitignored)
│ ├── metrics/ ← downsampled metrics export
│ └── sessions/ ← per-session artifacts
├── docs/
│ └── images/ ← screenshots referenced in README
├── README.md
├── DEVELOPER.md
├── LICENSE
├── NOTICE
├── COPYRIGHT_HEADER.txt
├── go.mod
└── go.sum
| Package | Responsibility |
|---|---|
internal/tui |
Bubble Tea application - tabs, modals, browser, replay UI |
internal/web |
HTTP UI, embedded assets, sessions, snapshot endpoint |
internal/auth |
Local web-UI users, bcrypt password hashing and rotation |
internal/engine |
Lifecycle orchestrator - wires all subsystems together, supervises/restarts collectors |
internal/events |
Non-blocking fan-out event bus, four-level severity |
internal/collectors |
ICMP, DNS (external + internal), HTTP-endpoint, TLS-cert, top-talkers (ICMP + TCP), LAN-reachability, traceroute, bufferbloat, WiFi, iface-health, L2 (multicast burst + ARP churn), tcp_info probes |
internal/capture |
Multi-interface AF_PACKET capture, ring buffer, rotated PCAP writer |
internal/telemetry |
Per-flow TCP telemetry (RTT / RTX / cwnd) via INET_DIAG, with an optional eBPF backend (-tags ebpf) |
internal/flows |
Interface-tagged five-tuple aggregator, correlators, per-device bandwidth history, LAN host-to-host matrix |
internal/netops |
Netlink + nftables/iptables backend: firewall, route, NAT/port-forward, interface, conntrack, neighbour, and DNS operations behind a write-gated Writer |
internal/privsep |
Privileged helper process, capability dropping, seccomp-bpf denylist, SCM_RIGHTS FD passing |
internal/discovery |
ARP, ICMP, mDNS, LLDP, SNMP scanner with device inventory + vendor OUI lookup |
internal/topology |
Passive topology graph (nodes / edges) built from flow + discovery data |
internal/probes |
One-shot diagnostic probe runner (ICMP / TCP / UDP / DNS / throughput / traceroute) |
internal/doctor |
Layered bottom-up connectivity diagnosis with root-cause verdict |
internal/health |
Per-subsystem status (OK / degraded / failed) with remediation hints |
internal/quality |
Per-(target, day, hour) baseline rollup, baseline-relative scoring, bufferbloat grading |
internal/analyzers |
Anomaly detectors - packet loss, latency spike, jitter, DNS burst, firewall drops, route instability, bandwidth spike, NAT exhaustion, retransmissions, per-device chatter |
internal/incidents |
Severity escalation, alert log, incident bundles |
internal/metrics |
Rolling per-target / per-DNS counters and bandwidth windows |
internal/ipfix |
IETF IPFIX (RFC 7011) flow exporter + lifecycle manager |
internal/services |
Well-known port → service-name mapping |
internal/replay |
Session reconstruction from persisted events |
internal/storage |
SQLite persistence (sessions, samples, flows, anomalies, incidents, baselines, audit log) |
internal/integrations/sentry |
Optional panic/error reporting |
internal/integrations/guacamole |
URL deep-link helper for SSH/RDP/VNC handoff |
internal/integrations/maxmind |
Optional MaxMind GeoIP / ASN enrichment - local .mmdb or sidecar mmdb-server, auto-update, LRU + SQLite cache |
internal/config |
Defaults, thresholds, persistent settings store |
cmd/testudo |
Command-line entry point and subcommand registry |
- Avoid global state. All subsystem state is owned by a struct and passed explicitly.
context.Contexteverywhere. Every cancellable operation accepts a context as its first argument.- Channels over locks. Coordination uses channels; locks are reserved for short, contended critical sections.
- Never block the render loop. The TUI render goroutine must remain responsive; analysis runs on workers.
- Isolated analyzers. Each anomaly detector is a self-contained unit that consumes events and emits anomalies.
- Modular subsystems. A subsystem should be removable without touching another subsystem's code.
- Structured logging. Every log line is structured key/value, never freeform text.
- Never render raw packets directly to the UI.
- Minimize allocations on hot paths (capture, aggregation, event dispatch).
- Use rolling buffers with fixed capacity for live data.
- Compress historical metrics before persisting.
- Avoid unbounded memory growth - every buffer has a documented ceiling.
- Process asynchronously where possible; the event bus is the synchronization point.
Every observable signal in Testudo travels the same path:
Collector ──► EventBus ──► Subscribers ──► (UI, Storage, Analyzers, Replay)
A subscriber never calls back into a collector. If a subscriber needs to act on the network, it emits an OpsRequest event and a netops subsystem handles it. This keeps the data plane and the control plane cleanly separated.
For the full developer walkthrough - adding subsystems, release process, test conventions - see DEVELOPER.md.
This README is the top-level tour. The rest of the project is documented in a small set of focused Markdown files - each one is meant to be read on its own, no chasing forward-references required.
| Document | Audience | What's inside |
|---|---|---|
| README.md | everyone | This file - overview, install, features, configuration, glossary, roadmap. |
| DEVELOPER.md | contributors | Build & run, repository layout, architecture in 60 seconds, adding a subsystem, testing, releasing. |
| LICENSE | everyone | MPL-2.0 license text in full. |
| NOTICE | everyone | Branding restrictions, required attribution when redistributing. |
| COPYRIGHT_HEADER.txt | contributors | The standard copyright header that every new source file must carry. |
The docs/ directory hosts the longer technical writeups. Start at docs/README.md for the index, or jump straight to a topic:
| Document | Audience | Summary |
|---|---|---|
| docs/README.md | everyone | Index of the docs folder with one-line summaries. |
| docs/architecture.md | engineers | Subsystem map (Mermaid), data flow, module boundaries, lifecycle. |
| docs/storage.md | engineers, operators | The four storage layers (live ring => flow aggregator => SQLite => selective PCAP) and their lifetimes. |
| docs/replay.md | operators | Session capture, the replay engine, timeline navigation, what's persisted and what isn't. |
| docs/firewall.md | operators | nftables (default) and iptables (fallback) backends; chain semantics; common rule recipes. |
| docs/topology.md | operators | Passive topology graph - nodes, edges, sources (ARP / LLDP / SNMP / flow observation). |
| docs/alerts.md | operators | Severity levels, default thresholds, the anomaly engine, incident bundles. |
| If you want to… | Read… |
|---|---|
| Get up and running fast | Quick Start in this README |
| Understand what each tab does | TUI & Web Interface in this README |
| Read the grade on the dashboard | Network Quality Grade in this README |
| Learn networking vocabulary | Glossary in this README |
| Understand how subsystems wire together | docs/architecture.md |
| Know what Testudo keeps on disk | docs/storage.md |
| Write a firewall rule from the TUI | docs/firewall.md |
| Investigate an incident after the fact | docs/replay.md + docs/alerts.md |
| Contribute code | DEVELOPER.md + CONTRIBUTING section |
| Fork or rebrand | LICENSE + NOTICE + the Branding section |
The canonical terminal interface - every tab from the table above, rendered live.
A handful of the TUI's deeper panels and pop-over modals - the dashboard's per-target latency/DNS breakdown, the Health tab's full probe-collector results, the Devices nmap-scan prompt, the TCPDump capture wizard, and the fuller discovered-device inventory.
The same engine and the same data, exposed over HTTP for headless boxes.
New to networking? No worries - this section is a quick, plain-language cheat sheet for the terms that show up around Testudo and in network troubleshooting generally. You can read it top-to-bottom or just grep it when something looks unfamiliar.
Tip: if you only learn three things from this glossary, make them IP address, port, and packet. Almost everything else is built on those.
| Term | What it actually means |
|---|---|
| Packet | A small chunk of data sent across the network. Networks don't send files - they send thousands of packets that get reassembled. |
| IP address | The "street address" of a device on a network. IPv4 looks like 192.168.1.10; IPv6 looks like 2a01:abcd::1. |
| MAC address | The hardware address baked into a network card. Looks like aa:bb:cc:dd:ee:ff. Used on the local cable/Wi-Fi, not across the internet. |
| Port | A number from 0-65535 that says which app on a device should receive a packet. Web is 80/443, SSH is 22, DNS is 53. |
| Protocol | The "language" two devices agree to speak. Common ones: TCP, UDP, ICMP. |
| Interface | A network port on your device. eth0 is wired, wlan0 is Wi-Fi, wg0 is a WireGuard VPN, docker0 is a container bridge, etc. |
| Host | Any device on the network - a server, laptop, phone, printer, fridge, whatever. |
| Term | Plain-language version |
|---|---|
| TCP | Reliable conversation. Both sides confirm what was received. Used for the web, SSH, file transfer. |
| UDP | Fire-and-forget messages. No confirmation. Used for DNS lookups, voice, video, games. |
| ICMP | The "are you alive?" protocol. ping and traceroute both use it. |
| DNS | The phone book. Turns youtube.com into an IP address your machine can actually reach. |
| ARP | "Who has IP 192.168.1.5? Tell me your MAC." Used so devices on the same LAN can find each other. |
| mDNS | Multicast DNS - DNS but for the local network. How printers, Chromecasts and your laptop announce themselves to your home Wi-Fi. |
| LLDP | Link Layer Discovery Protocol. Switches and APs broadcast "hi, I'm switch SW-CORE-01, port 12, I'm a switch with bridge capability." Testudo listens for this for free identification. |
| SNMP | Simple Network Management Protocol. The classic way to ask routers and switches "what's your name? how long have you been up? how many interfaces do you have?" |
| Term | What it means |
|---|---|
| Flow / 5-tuple | A single conversation between two endpoints, identified by (source IP, source port, dest IP, dest port, protocol). One TCP connection = one flow. |
| Throughput | How many bytes per second are moving. Usually quoted in Mb/s or MB/s. |
| RTT | Round-Trip Time. How long a packet takes to go to the other side and come back. The fundamental "is it slow?" number. |
| Jitter | How steady RTT is. Low RTT but jittery = unstable. Bad for voice and video. |
| Packet loss | What fraction of packets never made it. Anything above ~2 % is noticeable; above ~5 % is painful. |
| Retransmission | TCP noticed a packet got lost and sent it again. High retransmissions = something on the path is dropping traffic. |
| Saturation | The link is at its bandwidth limit. Symptoms: latency spikes, packet loss, queue buildup. |
| Term | What it means |
|---|---|
| PCAP | A file format for saved raw packets. The thing tcpdump and Wireshark write. Testudo only saves PCAPs when something interesting happens. |
| tcpdump | The classic command-line packet sniffer. Testudo replaces "always-on tcpdump" with selective capture. |
| Wireshark | The classic GUI packet sniffer. |
| AF_PACKET | The Linux kernel API that lets a program read every packet that crosses an interface. Testudo uses it directly - no libpcap needed. |
| libpcap | The C library most packet tools use to capture packets. Testudo deliberately avoids it (pure Go). |
| Promiscuous mode | An interface setting that says "give me every packet you see, not just ones addressed to me." Needed for full capture on a switch port that's mirroring traffic. |
| Term | What it means |
|---|---|
| Routing table | The list of rules that tells your machine "to reach network X, send the packet to gateway Y on interface Z." |
| Gateway | The next-hop router your machine sends traffic to when the destination isn't on the same subnet. Usually 192.168.1.1 at home. |
| NAT | Network Address Translation. The trick that lets dozens of devices behind one home router share a single public IP. |
| Port forwarding (DNAT) | "Anything that arrives at my public IP on port 443, send it to the web server at 192.168.1.10:443." A specific kind of NAT. |
| Masquerading | Source NAT, the variant home routers use to rewrite outbound traffic so it looks like it came from the router. |
| Firewall | The kernel's "rules about which packets are allowed in/out." On Linux today that's iptables (older) or nftables (newer). firewalld is a friendly wrapper. |
| Chain | A list of firewall rules evaluated in order. INPUT is for traffic destined to your box, OUTPUT is for traffic leaving it, FORWARD is for traffic passing through. |
| ACCEPT / DROP | The two main verdicts a firewall rule can deliver. ACCEPT lets it through, DROP silently throws it away. |
| Conntrack | The kernel's connection-tracking table. Remembers "I've seen this TCP flow before" so reply packets get matched to the right NAT/firewall state. |
| Term | What it means |
|---|---|
| Subnet | A range of IP addresses that share the same local network. 192.168.1.0/24 means "all addresses from 192.168.1.0 to 192.168.1.255." |
CIDR / /24 |
The slash number says how many bits are fixed in the network address. /24 = 256 hosts, /22 = 1024 hosts, /16 = 65 536 hosts. Smaller number = bigger network. |
| Broadcast | A special address that means "everyone on this subnet." ARP requests are broadcast. |
| Loopback | The 127.0.0.1 interface - your machine talking to itself. Always there, never on the wire. |
| Term | What it means |
|---|---|
/proc |
The Linux "virtual filesystem" that exposes kernel state as files. /proc/net/arp, /proc/net/tcp, /proc/<pid>/fd etc. Testudo reads these directly. |
| Netlink | The kernel API for talking to networking subsystems (routes, interfaces, firewall). Used instead of running ip or iptables in a subshell. |
Capabilities (CAP_*) |
Fine-grained permissions that replace "needs root." CAP_NET_RAW lets a program open raw sockets without being root. |
setcap |
The command that grants a binary a capability. Testudo uses it so you don't have to run the tool as root. |
| VLAN | Virtual LAN. A way to run several logical networks over one physical cable. Identified by a tag number (e.g. VLAN 100). |
| Bridge | A virtual switch inside Linux. Containers and VMs usually plug into one (docker0, br0). |
| Tunnel / VPN | An encrypted "fake cable" between two machines over the public internet. WireGuard, OpenVPN, IPsec are common implementations. |
When you open a web page, here is what just happened in glossary terms:
- Your browser asks DNS to turn
example.cominto an IP address. - The kernel checks its routing table to decide which interface and gateway to send the packet through.
- On the local network, it uses ARP to find the MAC address of that gateway.
- The packet (one of many) is NAT-ed by the home router so the reply can find its way back.
- The remote server replies with TCP packets carrying the HTML, which the kernel reassembles.
- Conntrack remembers the flow so subsequent reply packets are matched to the same conversation.
- Testudo (if running) sees the whole thing as a single flow with an RTT, throughput, jitter and packet-loss number attached.
That's the entire short version of "how the internet works" - and Testudo's job is to let you watch each of those steps as they happen, or after they've already happened.
Roughly ordered by what's next. Each milestone is shaped around one theme so users and contributors can tell at a glance whether a release affects them.
Legend
| Symbol | Meaning |
|---|---|
+ |
New feature |
! |
Infrastructure change |
# |
UI / Visualization |
★ |
Headline goal for the release |
Engineering specs. The deeper diagnostics work was scoped under the same contract for every item: viewable and editable in both the TUI and the web UI, and every new measurement feeds the Network Quality grade. Most of that roadmap has now shipped - per-rule nftables counters, conntrack/NEIGH introspection, netlink push-vs-poll, the rollup/baseline quality table, per-flow TCP telemetry (with an optional eBPF backend), and privilege separation are all in the tree. IPv6 across the full data path is the main remaining item (tracked under v1.0 below).
The single biggest thing holding Testudo back today is being pinned to Ubuntu/Debian. v0.2 is about getting it to just work on the rest of the Linux ecosystem and on macOS.
★Verified install, build, and live capture on Fedora, openSUSE, Arch Linux, RHEL / Rocky / Alma, Alpine, and NixOS.+macOS port - BPF/PF_ROUTEcapture path replacing the Linux AF_PACKET socket; route, interface and firewall (pf) read-only views on the same TUI/Web UI.+firewalldbackend alongsideiptables/nftablesfor RHEL-family installs.!Per-distro packaging:.deb,.rpm, AURPKGBUILD, Alpineapk, Homebrew tap.!CI matrix that builds and smoke-tests on every supported distro per PR.#Per-platform capability hints in the welcome banner ("on macOS usesudo ./testudoor grant thecom.apple.security.network.cliententitlement").
Testudo already speaks IETF IPFIX (RFC 7011) for flow export. v0.3 hardens that exporter for production collectors and ships first-class integration with opsanio - the autonubil GmbH monitoring appliance line that Testudo is designed to feed.
★First-class opsanio integration. opsanio is autonubil's turnkey on-prem monitoring appliance. Testudo can already export flow records to it over IPFIX; v0.3 makes it a single Settings toggle, with auto-discovery of an opsanio collector on the local subnet and a pre-built information element template matched to opsanio's dashboards. See https://autonubil.de/home#appliances for the appliance line-up.+IPFIX exporter parity with other common collectors (ipfixcol2, nProbe, Elastic ingest, ntopng) - same template, same field set.+Configurable IPFIX template profiles: minimal (5-tuple + bytes/packets), standard (+ ifindex, ToS, TCP flags), forensic (+ DNS name, process name, NAT state).+IPFIX flow sampling and rate-limiting for high-throughput links.#Live "IPFIX export" panel in the dashboard: target collector, records/sec, last error.
+Per-flow PCAP slicing during incident bundles.+Topology diff between replay sessions.#Incident overlay on the dashboard sparkline and the quality grade.!Compressed metrics export format (zstd).
+Multi-host session aggregation.+Read-only federation for the web UI.!Optional remote PostgreSQL backend for long-horizon retention.#Cross-host topology view.
+IPv6 parity across discovery and firewall modules.!Long-running stability soak harness.#Operator-mode keybinding overlay in the TUI.+Stable plugin API for third-party collectors.+Stable plugin API for third-party collectors
Contributions are welcome. The Development Guide above and DEVELOPER.md describe how the project is organized and what kinds of changes fit its design.
The short version:
- Fork the repository and create a feature branch.
- Add or extend a subsystem under
internal/- keep it removable. - Wire it into the event bus, not into another subsystem.
- Include tests under the same package (
*_test.go). - Run
go test ./...andgo vet ./...before opening a PR. - Stamp every new source file with the standard header from COPYRIGHT_HEADER.txt.
- Open a PR with a focused description; small PRs land faster than sweeping ones.
Contributions are accepted under the same MPL-2.0 license as the rest of the project.
Testudo is built and maintained in the open by one person. If it saved you a late-night debugging session - or six terminals - and you'd like to say thanks, you can buy me a coffee. Every bit is appreciated and goes straight back into keeping the tortoise moving.
Not in a position to donate? That's completely fine - starring the repo, filing a good bug report, or opening a PR helps just as much.
Testudo is distributed under the Mozilla Public License Version 2.0 (LICENSE), with additional branding restrictions documented in NOTICE.
Copyright (c) 2026 Noah Zeumer.
MPL-2.0 was chosen deliberately. It strikes the balance Testudo needs:
- File-level copyleft. Modifications to MPL-licensed files must remain open under the same license, but linking Testudo against proprietary code is permitted. This keeps the core honest without making the project hostile to commercial adopters.
- Commercial use is welcome. Companies can deploy, modify, and ship Testudo internally or as part of larger commercial offerings.
- Modifications stay visible. Anyone who improves an MPL-licensed file must publish the modified source - bug fixes and improvements flow back to the community.
- Compatible with permissive ecosystems. MPL-2.0 plays well with Apache-2.0 and BSD-licensed dependencies, which makes up most of the Go ecosystem.
- Use Testudo in production, including commercial deployments.
- Modify the source code for your own needs.
- Fork the repository.
- Redistribute the source or compiled binaries.
- Build derivative works.
- Contribute changes back upstream.
- Remove the Testudo branding, ASCII banner, or attribution from forks or redistributions.
- Re-release Testudo under a different product name as if it were a new project.
- Strip the
NOTICEfile or its branding clauses. - Misrepresent authorship of the original work.
- Remove copyright headers from individual source files.
The code license and the branding restrictions are deliberately separated: MPL-2.0 governs the code, the NOTICE file governs the brand. Read both before forking.
The name Testudo, the ASCII logo, and the project identity are property of Noah Zeumer and are not granted under the MPL-2.0 source-code license. The full branding terms - including what attribution is required when redistributing, when a derivative work must be renamed, and how the ASCII banner may and may not be reused - are documented in NOTICE.
In short: fork freely, modify freely, redistribute freely. If you ship a substantively different product, give it a different name.
Testudo stands on the shoulders of giants in the Go and Linux ecosystems:
- Bubble Tea - the Elm-inspired TUI runtime that drives the terminal interface.
- Lip Gloss - styling and layout for the TUI.
- gopacket - packet decoding for the AF_PACKET capture pipeline.
- mdlayher/netlink and vishvananda/netlink - pure-Go netlink for
cgo-free kernel interaction. - google/nftables - the nftables backend for the firewall subsystem.
- AF_PACKET, netfilter, conntrack, and
/proc- the Linux primitives that make any of this possible. - IEEE 802.1AB (LLDP) and SNMPv2c - the open standards that let Testudo identify managed devices without sending a probe.
- modernc.org/sqlite - pure-Go SQLite, the embedded persistence backend.
- nmap - the on-demand targeted scanner invoked from the Devices tab.
- MaxMind GeoIP2 / GeoLite2 - optional geolocation and ASN enrichment of public IPs.
- Sentry and Apache Guacamole - optional integrations for crash reporting and console handoff.
And to every operator who has stared at six terminals during an incident and thought "there has to be a better way" - this is for you.




























