Network testing CLI tool. Runs ICMP ping, TCP connect, HTTP request, or WebSocket handshake+ping checks on a fixed interval and logs the result of every attempt until you stop it (Ctrl+C).
Pre-built macOS arm64 binaries are published on GitHub Releases
for every tagged version — download ntest-darwin-arm64 and skip straight
to the SUID step below. Other platforms/architectures aren't built yet, so
build from source for those.
Version comes from the nearest git tag (this repo tags releases as v1.0.0,
v1.1.0, ...), so build from a checkout that has the tags available:
VERSION=$(git describe --tags --always --dirty)
go build -trimpath -ldflags "-X main.appVersion=$VERSION" -o ntest ./cmd/ntest--dirty appends a -dirty suffix if the tree has uncommitted changes;
--always falls back to the short commit hash if no tag is reachable yet.
The ping subcommand needs raw socket access, so an installed binary needs
the SUID bit:
sudo cp ./ntest /usr/local/bin/
sudo chown root:staff /usr/local/bin/ntest
sudo chmod u+s /usr/local/bin/ntestntest <subcommand> <host> [flags]
<host> is a positional argument, not a flag — no need to type --host
every time.
| Subcommand | Description |
|---|---|
ping |
ICMP ping test |
tcp |
TCP connection test |
http |
HTTP request test |
ws |
WebSocket handshake + ping test |
| Flag | Default | Description |
|---|---|---|
<host> |
required, positional | Target host (hostname or IP); for http — full URI |
--bind |
0.0.0.0 |
Local bind address |
--timeout |
3s |
Per-attempt timeout, as a Go duration (500ms, 3s, 1m, ...) |
--interval |
1s |
Delay between attempts, as a Go duration |
--dns |
— | Custom DNS server for resolving <host> (e.g. 8.8.8.8); OS resolver if unset |
Duration flags (--timeout, --interval, --warn) take a Go-style duration
string — a number followed by a unit, e.g. 500ms, 3s, 1m, 1h30m. A
bare number without a unit (e.g. 1000) is rejected.
| Flag | Default | Description |
|---|---|---|
--warn |
100ms |
RTT above this threshold logs as a warning instead of info (or colors the point on the graph, with --graph) |
--graph |
false |
Redraw a live ASCII RTT chart instead of logging a line per attempt |
--graph replaces the usual per-attempt log lines with a full-screen chart
that's cleared and redrawn on every tick (last 60 attempts, --warn-exceeding
points highlighted). A gap in the chart line marks a failed attempt, but
that alone is easy to miss at a glance — so every redraw also prints an
explicit status banner (green ✓ ok or red ✗ ping failed: <reason>) and a
fails counter in the stats line, tracking the running total of failures
since the test started (not just what's visible in the 60-attempt window).
Since it repaints the terminal, --graph is meant for interactive use —
piping/redirecting ping --graph to a file will just capture a stream of
screen-clear codes, not a normal log; use it without --graph for that.
| Flag | Default | Description |
|---|---|---|
--port |
80 |
Target TCP port |
| Flag | Default | Description |
|---|---|---|
--method |
GET |
HTTP method (must be uppercase) |
--domain |
— | Overrides the Host request header, TLS SNI and certificate hostname verification |
--body |
— | Request body |
--domain makes hitting an IP directly behave the same as hitting the
domain name: ntest http https://<google.com's IP> --domain google.com and
ntest http https://google.com send the same SNI and verify the response
certificate against the same name, so both succeed or fail together. Without
--domain, hitting an IP directly over HTTPS usually fails the TLS
handshake (no/wrong SNI — most HTTPS hosts today are name-based virtual
hosts) or fails certificate verification (the cert doesn't cover the bare
IP).
<host> is the full target URI, e.g. ws://example.com/socket or
wss://example.com/socket (scheme defaults to ws if omitted).
| Flag | Default | Description |
|---|---|---|
--domain |
— | Overrides the Host header, TLS SNI and certificate hostname verification for the handshake |
Same as http's --domain (see above): ntest ws wss://<IP> --domain example.com sends the same SNI and checks the response certificate against
the same name as ntest ws wss://example.com would, so hitting the IP
directly behaves the same as hitting the domain. Without --domain, a raw
IP literal in --host gets no SNI at all (TLS doesn't send SNI for IP
literals), which most wss:// hosts reject outright.
A successful attempt does a full Upgrade: websocket HTTP handshake, then
sends a WebSocket ping frame and waits for the pong, then closes the
connection — --timeout covers the handshake and the ping/pong round trip
together. This checks more than tcp or http: it confirms the WebSocket
protocol itself works end to end, not just that the port is open or that
plain HTTP responds — reverse proxies, load balancers and WAFs commonly let
ordinary HTTP through while breaking the Upgrade handshake (a non-101
response, e.g. a normal 200, is reported as a handshake error).
ntest ping 1.1.1.1 --warn 50ms
ntest ping 1.1.1.1 --graph
ntest tcp example.com --port 443 --interval 500ms
ntest http https://example.com --method GET --dns 8.8.8.8 --timeout 5s
ntest ws wss://example.com/socket --interval 5sEvery tick runs independently: <host> is resolved fresh before every
single attempt (via --dns if given, the OS resolver otherwise), not once
at startup. Resolution is bounded by --timeout, same as the actual ping/
dial/request — a resolver that hangs (broken VPN DNS, a stuck resolver
daemon, DNS packets silently dropped by a firewall) can't hang the whole
program indefinitely, and won't leave Ctrl+C appearing to do nothing. If
resolution fails, that tick logs failed to resolve host and moves on — it
never stops the test. This means you can start ntest
against a domain that doesn't resolve yet (e.g. DNS record not propagated,
or added to a zone you're about to publish) and just leave it running; it
will keep logging resolution failures until the host resolves, then pick
up automatically and start reporting real results, no restart needed. It
also means a later change to the DNS record (failover, new IP) takes effect
on the very next tick.
http and ws both dial the resolved IP directly for every attempt (via
their http.Transport's DialContext, with keep-alives disabled) rather
than letting net/http re-resolve the hostname itself or reuse a
connection opened against a previous tick's resolution — this is what makes
--dns actually affect the connection, not just the log output, and what
guarantees a DNS change takes effect on the very next tick instead of
"sticking" to whatever IP an old kept-alive connection was using.
cmd/ntest/root.go # package main: entry point (func main), Cobra root command, appVersion
cmd/ntest/cmd_ping.go # ping subcommand (ICMP)
cmd/ntest/cmd_tcp.go # tcp subcommand
cmd/ntest/cmd_http.go # http subcommand
cmd/ntest/cmd_ws.go # ws subcommand
internal/icmp/icmp.go # ICMP ping test implementation (used by the "ping" subcommand)
internal/tcp/tcp.go # TCP connection test implementation
internal/http/http.go # HTTP request test implementation
internal/ws/ws.go # WebSocket handshake+ping test implementation
internal/dns/dns.go # DNS resolution (OS resolver or custom nameserver)
internal/signal/signal.go # SIGINT/SIGTERM → context cancellation
.github/workflows/release.yml # builds+publishes the macOS arm64 binary on a v* tag push
- Cobra — CLI framework;
cmd/ntest/ispackage main(the build target is./cmd/ntest, not repo root), withfunc main()inroot.goand each subcommand in its owncmd_*.gofile, registered viainit(). Testinterface — a singleExecute(ctx context.Context) errormethod, implemented by theicmp(backs thepingsubcommand),tcp,httpandwspackages.- Each test runs a
time.Tickerloop and exits cleanly whenctx.Done()fires. - Each tick is independent and self-contained: DNS resolution happens fresh on every tick, not once at startup, so one tick's outcome (including a resolution failure) never affects the next.
- Graceful shutdown:
internal/signal.ContextWithSignalcancels the context on SIGINT/SIGTERM.
Uses logrus with TextFormatter — same "15:04:05" timestamp format for
all four subcommands. Each log line carries seq (1-based
attempt counter), dest, and protocol-specific fields (port for tcp,
method for http).
Log levels:
Info— successWarn— ICMP RTT exceeds--warn, HTTP 4xxError— DNS resolution failure (any subcommand), ICMP failure, TCP failure, HTTP 5xx or network error, WebSocket handshake or ping failure
| Package | Purpose |
|---|---|
github.com/spf13/cobra |
CLI framework |
github.com/digineo/go-ping |
ICMP/ping raw sockets |
github.com/miekg/dns |
Custom DNS resolution |
github.com/sirupsen/logrus |
Structured logging |
github.com/coder/websocket |
WebSocket client (handshake + ping/pong) |
github.com/guptarohit/asciigraph |
ASCII RTT chart for ping --graph |