netdiag

package
v0.0.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 10, 2026 License: AGPL-3.0 Imports: 14 Imported by: 0

Documentation

Overview

Package netdiag is a pure-Go, CGO-free network diagnostic engine for probing self-hosted services from an external vantage point (a VPS outside the edge) or from inside the cluster. It exists to answer "where does a flow break?" for services that sit behind a home edge router (a UniFi UDM) and a Cilium LoadBalancer: a Minecraft server, an HTTP file-server, etc.

Everything here is active measurement using only the Go standard library plus golang.org/x/sys/unix (for PMTU discovery). There is no libpcap / gopacket dependency — packet capture, where genuinely needed, is done by shelling out to tcpdump on the host that already has it (see the capture coordinator). This keeps the shipped binary a single static artifact with zero runtime dependencies on the target, which is the whole point: scp and run.

The centerpiece is Download, which classifies *how* a transfer ended (complete / short / reset / stall / timeout / …). That distinction is what separates otherwise-identical "the download is flaky" symptoms into their real causes — a mid-stream RST from an edge conntrack eviction looks nothing like an idle stall from a backing-store read pause, even though a browser reports both as "failed, try again".

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ParseSize

func ParseSize(s string, def int64) int64

ParseSize parses a byte-count string with an optional k/m/g suffix (case insensitive) into bytes, returning def on empty or invalid input. Shared by the CLI (tcpflow's --size/--rate/... flags) and the reference server's HTTP query parsing (?size=/&rate=...), so both halves speak the same shorthand.

Types

type ConnectResult

type ConnectResult struct {
	Addr       string  `json:"addr"`
	OK         bool    `json:"ok"`
	Kind       string  `json:"kind"` // ok|refused|reset|timeout|no_route|dns_error|error
	RTTMs      float64 `json:"rtt_ms"`
	LocalAddr  string  `json:"local_addr,omitempty"`
	RemoteAddr string  `json:"remote_addr,omitempty"`
	Err        string  `json:"err,omitempty"`
}

ConnectResult is the outcome of a single TCP connect attempt.

func TCPConnect

func TCPConnect(ctx context.Context, addr string, timeout time.Duration) ConnectResult

TCPConnect opens a TCP connection to addr ("host:port") and immediately closes it, reporting whether the handshake succeeded and how it failed if not. This is the outside-the-edge sibling of debug-mcp's node-side `probe tcp`: run it from a VPS to answer "does a real internet client get through the UDM to this LoadBalancer, or does the SYN die at the edge?".

type DownloadOpts

type DownloadOpts struct {
	// IdleTimeout is the maximum gap between successive bytes before the transfer
	// is called a stall. This is what turns "it hung" into a classifiable
	// Outcome instead of just an overall timeout. Default 10s.
	IdleTimeout time.Duration
	// TotalTimeout caps the whole request. Default 120s.
	TotalTimeout time.Duration
	// SampleInterval controls throughput sampling cadence. Default 500ms.
	SampleInterval time.Duration
	// Host overrides the HTTP Host header — lets you hit a raw IP (e.g. the
	// Cilium gateway LoadBalancer) while presenting the real vhost, so you can
	// A/B the same backend with the edge in and out of the path.
	Host string
	// ServerName overrides TLS SNI (defaults to Host, else the URL host).
	ServerName string
	// InsecureTLS skips cert verification (needed when hitting an IP directly).
	InsecureTLS bool
	// ExpectSize, if > 0, lets short-read detection work even when the server
	// sends no Content-Length (e.g. chunked). Optional.
	ExpectSize int64
	// MaxSamples caps the retained throughput series. Default 2000.
	MaxSamples int
}

DownloadOpts tunes a single Download. Zero values get sane defaults via withDefaults, so callers can pass DownloadOpts{} and only set what they mean.

func DefaultMatrixOpts

func DefaultMatrixOpts() DownloadOpts

DefaultMatrixOpts returns download options tuned for a large-file matrix: a shorter idle timeout so a stall is caught quickly, and a total cap that still allows a slow-but-real transfer to finish.

type DownloadResult

type DownloadResult struct {
	URL           string   `json:"url"`
	Outcome       Outcome  `json:"outcome"`
	HTTPStatus    int      `json:"http_status,omitempty"`
	Bytes         int64    `json:"bytes"`
	ContentLength int64    `json:"content_length"` // -1 if the server didn't declare one
	TTFBMs        float64  `json:"ttfb_ms"`
	TotalMs       float64  `json:"total_ms"`
	MeanKBps      float64  `json:"mean_kbps"`
	MinWindowKBps float64  `json:"min_window_kbps"` // slowest sampled window — the bufferbloat / near-stall tell
	RemoteAddr    string   `json:"remote_addr,omitempty"`
	LocalAddr     string   `json:"local_addr,omitempty"`
	ConnReused    bool     `json:"conn_reused"`
	Samples       []Sample `json:"samples,omitempty"`
	Err           string   `json:"err,omitempty"`
}

DownloadResult is the full record of one transfer attempt.

func Download

func Download(ctx context.Context, url string, opts DownloadOpts) DownloadResult

Download performs one HTTP GET and reports not just success/failure but the terminal Outcome — the crux of the whole tool. It reads the body with a per-read deadline equal to IdleTimeout, so a silent wedge surfaces as OutcomeStall while a mid-stream RST surfaces as OutcomeReset and a clean early close surfaces as OutcomeShort. Those three are indistinguishable to a browser ("download failed") but point at three different culprits.

type MatrixResult

type MatrixResult struct {
	Target        MatrixTarget     `json:"target"`
	Attempts      int              `json:"attempts"`
	Succeeded     int              `json:"succeeded"`
	SuccessRate   float64          `json:"success_rate"`
	OutcomeCounts map[Outcome]int  `json:"outcome_counts"`
	MeanKBps      float64          `json:"mean_kbps"`         // over successful transfers
	MedianKBps    float64          `json:"median_kbps"`       // over successful transfers
	MinKBps       float64          `json:"min_kbps"`          // slowest successful transfer's mean
	WorstWindow   float64          `json:"worst_window_kbps"` // slowest window across all attempts
	Runs          []DownloadResult `json:"runs,omitempty"`
}

MatrixResult aggregates repeated Download attempts against one target. The point is the histogram: "18/20 complete, 2/20 reset" localizes an intermittent failure far better than a single pass/fail, and the outcome mix names the cause (reset vs stall vs short).

func RunMatrix

func RunMatrix(ctx context.Context, target MatrixTarget, repeats int, opts DownloadOpts, keepRuns bool) MatrixResult

RunMatrix downloads target `repeats` times sequentially (sequential is deliberate: parallel requests would mask a WAN-upstream bottleneck by sharing it) and aggregates the outcomes. keepRuns controls whether the per-attempt detail (including throughput samples) is retained in the result.

type MatrixTarget

type MatrixTarget struct {
	Name        string `json:"name"`
	URL         string `json:"url"`
	Host        string `json:"host,omitempty"`
	ServerName  string `json:"server_name,omitempty"`
	InsecureTLS bool   `json:"insecure_tls,omitempty"`
	ExpectSize  int64  `json:"expect_size,omitempty"`
}

MatrixTarget names one thing to hammer: a URL, optionally with a Host/SNI override so the same backend can be tested with the edge in or out of the path.

type Outcome

type Outcome string

Outcome is the terminal classification of a probe or transfer. The values are deliberately fine-grained: the diagnostic value is in telling them apart.

const (
	// OutcomeComplete: the transfer finished and delivered everything expected
	// (Content-Length satisfied, or a clean EOF with no declared length).
	OutcomeComplete Outcome = "complete"
	// OutcomeShort: a *clean* EOF arrived before Content-Length was satisfied —
	// the server closed the stream gracefully but early. Points at the origin
	// (the app/backend), not the network path.
	OutcomeShort Outcome = "short"
	// OutcomeReset: the connection was reset (RST / ECONNRESET) mid-stream.
	// Something actively killed the flow — an edge conntrack eviction, a DPI
	// engine, or the peer. This is the "connection reset by peer" fingerprint.
	OutcomeReset Outcome = "reset"
	// OutcomeStall: bytes stopped arriving and the idle gap exceeded the
	// threshold with no FIN/RST — the flow silently wedged. Classic backing-
	// store read pause (e.g. an NFS stall) or a black-hole in the path. This is
	// the "it hangs, then works if you retry" fingerprint.
	OutcomeStall Outcome = "stall"
	// OutcomeTimeout: the overall deadline elapsed.
	OutcomeTimeout Outcome = "timeout"
	// OutcomeRefused: connection refused at dial (RST to the SYN, or no listener).
	OutcomeRefused Outcome = "refused"
	// OutcomeNoRoute: host/network unreachable.
	OutcomeNoRoute Outcome = "no_route"
	// OutcomeDNSError: name resolution failed.
	OutcomeDNSError Outcome = "dns_error"
	// OutcomeTLSError: the TLS handshake failed.
	OutcomeTLSError Outcome = "tls_error"
	// OutcomeStatusError: an HTTP response arrived with status >= 400.
	OutcomeStatusError Outcome = "status_error"
	// OutcomeError: anything not covered above.
	OutcomeError Outcome = "error"
)

type Sample

type Sample struct {
	TMs   int64 `json:"t_ms"`
	Bytes int64 `json:"bytes"`
}

Sample is one throughput datapoint: cumulative bytes at t_ms since request start.

type TCPFlowOpts

type TCPFlowOpts struct {
	// Dir is "down" (server sends Size bytes, client reads — default) or "up"
	// (client sends Size bytes, server reads). "up" is the direction that
	// actually failed in the wild: the Minecraft server's own read is what saw
	// "Connection reset by peer", not a write.
	Dir string
	// Size is the number of bytes to transfer.
	Size int64
	// Rate throttles the sender to at most this many bytes/sec; 0 = unlimited.
	// Applies to whichever side is sending (server for "down", client for "up").
	Rate int64
	// StallAfter/Stall: down-direction only. The server pauses once StallAfter
	// bytes have been sent, for Stall duration — used to calibrate stall
	// detection against a known pathology.
	StallAfter int64
	Stall      time.Duration
	// Abort/AbortAfter: down-direction only. "reset" forces an RST after
	// AbortAfter bytes; "close" ends the stream early with a clean FIN.
	Abort      string
	AbortAfter int64
	// IdleTimeout is the max gap between successive reads (or, for "up", the
	// per-write deadline) before the attempt is classified as a stall.
	IdleTimeout time.Duration
	// TotalTimeout caps the whole attempt.
	TotalTimeout time.Duration
	// DialTimeout caps the initial connect.
	DialTimeout time.Duration
}

TCPFlowOpts tunes a single TCPFlow attempt.

type TCPFlowResult

type TCPFlowResult struct {
	Addr       string  `json:"addr"`
	Dir        string  `json:"dir"`
	Outcome    Outcome `json:"outcome"`
	Bytes      int64   `json:"bytes"`
	Requested  int64   `json:"requested"`
	TotalMs    float64 `json:"total_ms"`
	MeanKBps   float64 `json:"mean_kbps"`
	LocalAddr  string  `json:"local_addr,omitempty"`
	RemoteAddr string  `json:"remote_addr,omitempty"`
	Err        string  `json:"err,omitempty"`
}

TCPFlowResult is the terminal record of one TCPFlow attempt — the bare-TCP sibling of DownloadResult.

func TCPFlow

func TCPFlow(ctx context.Context, addr string, o TCPFlowOpts) TCPFlowResult

TCPFlow dials addr (the reference server's raw-TCP port, not its HTTP port), sends a one-line request describing the transfer (mirroring the HTTP /drip query-string vocabulary, so the mental model transfers), then either writes Size bytes (Dir "up") or reads them (Dir "down"), classifying the terminal Outcome the same way Download does.

type TLSResult

type TLSResult struct {
	Addr        string    `json:"addr"`
	OK          bool      `json:"ok"`
	Kind        string    `json:"kind"`
	HandshakeMs float64   `json:"handshake_ms"`
	Version     string    `json:"version,omitempty"`
	CipherSuite string    `json:"cipher_suite,omitempty"`
	PeerCN      string    `json:"peer_cn,omitempty"`
	DNSNames    []string  `json:"dns_names,omitempty"`
	NotAfter    time.Time `json:"not_after,omitempty"`
	Err         string    `json:"err,omitempty"`
}

TLSResult is the outcome of a TLS handshake attempt.

func TLSHandshake

func TLSHandshake(ctx context.Context, addr, serverName string, insecure bool, timeout time.Duration) TLSResult

TLSHandshake dials addr and completes a TLS handshake, reporting negotiated version/cipher and the leaf cert identity. serverName sets SNI (and is what the cert is verified against unless insecure is true). Use insecure to probe reachability/handshake health independently of cert validity — a handshake that fails on cert but completes the exchange still tells you the TLS path is open end-to-end.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL