Skip to content

Latest commit

 

History

46 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ntest

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).

Build & install

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/ntest

Usage

ntest <subcommand> <host> [flags]

<host> is a positional argument, not a flag — no need to type --host every time.

Subcommands

Subcommand Description
ping ICMP ping test
tcp TCP connection test
http HTTP request test
ws WebSocket handshake + ping test

Common flags

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.

ping-specific

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.

tcp-specific

Flag Default Description
--port 80 Target TCP port

http-specific

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).

ws-specific

<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).

Examples

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 5s

Every 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.

Project structure

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

Architecture

  • Cobra — CLI framework; cmd/ntest/ is package main (the build target is ./cmd/ntest, not repo root), with func main() in root.go and each subcommand in its own cmd_*.go file, registered via init().
  • Test interface — a single Execute(ctx context.Context) error method, implemented by the icmp (backs the ping subcommand), tcp, http and ws packages.
  • Each test runs a time.Ticker loop and exits cleanly when ctx.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.ContextWithSignal cancels the context on SIGINT/SIGTERM.

Logging

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 — success
  • Warn — ICMP RTT exceeds --warn, HTTP 4xx
  • Error — DNS resolution failure (any subcommand), ICMP failure, TCP failure, HTTP 5xx or network error, WebSocket handshake or ping failure

Dependencies

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages