diff --git a/README.md b/README.md index 4928c74..5f0f06b 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ ssh 中継の例 `google-via-ssh 173.194.117.176 relay=X.X.X.X os=Linux` は、 - ホスト名が同じファミリー内に複数のアドレス(複数の A / AAAA)を持つ場合、probe するのは **RFC 6724 で並べ替えた先頭の 1 アドレスだけ**です(アドレス選択は Go / OS のリゾルバに委譲し、複数アドレスを並行して試す Happy Eyeballs 的な動作は行いません。これは `resolve_family` を指定しない場合も同じです)。 ラウンドロビン DNS では解決のたびに先頭が入れ替わりうるため、特定の 1 台を継続的に監視したい場合は IP アドレスを直接指定してください。 +- **直接 ICMP はユニキャストの単一ホスト向け**です。ブロードキャスト/マルチキャストアドレスへの直接 ICMP は対象外で、`X`(到達不能)として表示されます(死活監視は「1 行=1 ホスト」を前提とし、複数ホストが応答する宛先は扱いません)。 + - **`nexthop=GWIP`** … 直接 ICMP プローブを指定したゲートウェイ(next-hop)経由で強制送出します(特定経路の到達性を監視するのに有用)。 ゲートウェイは egress インタフェースの直結サブネット上(on-link)である必要があり、egress は `source=`(インタフェース名または IP)で明示できます。 IPv6 のリンクローカルゲートウェイ(`fe80::/10`)はどのインタフェースでも on-link になり曖昧なため、`source=IFNAME`(インタフェース名)で egress を指定する必要があります(このとき送信元 IP は宛先のスコープに合わせて自動選択されます)。 diff --git a/internal/ping/icmp.go b/internal/ping/icmp.go index b13bfaf..80c9e99 100644 --- a/internal/ping/icmp.go +++ b/internal/ping/icmp.go @@ -1,13 +1,11 @@ package ping import ( - "context" "net" "runtime" "sync" "time" - probing "github.com/prometheus-community/pro-bing" "golang.org/x/net/icmp" ) @@ -46,7 +44,7 @@ var useICMPPrivileged = sync.OnceValue(func() bool { // directICMPAvailable reports, once per process, whether any native direct-ICMP // path can open a socket on this host: the privileged raw path (root/CAP_NET_RAW) -// or the unprivileged datagram path pro-bing uses with SetPrivileged(false). The +// or the unprivileged datagram path (SOCK_DGRAM ICMP) used when not privileged. The // datagram path needs the caller's gid inside net.ipv4.ping_group_range. When both // fail — the common non-root case where that range is empty or excludes the user // (e.g. WSL2's "1 0" default) — every direct and next-hop probe fails with no packet @@ -80,72 +78,3 @@ func DirectICMPAvailable() bool { return directICMPAvailable() } // AF_PACKET, for which the unprivileged datagram path cannot substitute — so the TUI // checks it independently of DirectICMPAvailable. func RawICMPAvailable() bool { return useICMPPrivileged() } - -// icmpPinger sends a native ICMP echo using pro-bing. Native ICMP is portable -// (Windows/Linux/macOS) and avoids shelling out to `ping -c 1` and parsing -// OS-specific output. -type icmpPinger struct { - addr string - source string - network string // "ip4"/"ip6" to pin the resolve family (resolve_family=), "" for auto. - privileged bool -} - -func newICMPPinger(s Spec) (Pinger, error) { - return &icmpPinger{ - addr: s.Addr, - source: s.Source, - network: resolveNetwork(s), - privileged: useICMPPrivileged(), - }, nil -} - -func (p *icmpPinger) Send(ctx context.Context) Result { - ctx, cancel := context.WithTimeout(ctx, icmpTimeout) - defer cancel() - - // New defers resolution; SetNetwork pins the family so RunWithContext resolves - // v4/v6 as configured. A resolve failure (no record in the pinned family, or an - // IP literal of the other family) surfaces from RunWithContext below as Failed. - pinger := probing.New(p.addr) - if p.network != "" { - pinger.SetNetwork(p.network) - } - - pinger.SetPrivileged(p.privileged) - pinger.Count = 1 - pinger.Timeout = icmpTimeout - pinger.RecordTTLs = true - - if p.source != "" { - // A source may be a source IP or (on Linux) an interface name: - // pro-bing's Source binds by IP, InterfaceName by name. - if net.ParseIP(p.source) != nil { - pinger.Source = p.source - } else { - pinger.InterfaceName = p.source - } - } - - err := pinger.RunWithContext(ctx) - if err != nil { - return Result{Code: Failed, TTL: -1} - } - - st := pinger.Statistics() - if st.PacketsRecv > 0 { - ttl := -1 - if len(st.TTLs) > 0 { - ttl = int(st.TTLs[0]) - } - - return Result{ - Success: true, - Code: Success, - RTT: float64(st.AvgRtt.Microseconds()) / usPerMs, - TTL: ttl, - } - } - - return Result{Code: Failed, TTL: -1} -} diff --git a/internal/ping/icmp_bench_manual_test.go b/internal/ping/icmp_bench_manual_test.go new file mode 100644 index 0000000..47751f7 --- /dev/null +++ b/internal/ping/icmp_bench_manual_test.go @@ -0,0 +1,116 @@ +//go:build manual && linux + +// Manual benchmark for the Linux synchronous + SO_TIMESTAMPNS prober. It is excluded +// from the default suite (it sends real ICMP). Run it to confirm the loopback RTT sits +// near native ping (sub-50µs avg) rather than carrying goroutine-wakeup overhead: +// +// go test -tags manual -run TestICMPRTTLoopback -v ./internal/ping +// +// A kernel-timestamped recv lands avg ~0.02-0.04ms; a userspace fallback (no +// SO_TIMESTAMPNS) sits closer to ~0.05ms. Run as root too to exercise the raw path. +package ping + +import ( + "context" + "sort" + "testing" + "time" +) + +func benchLoopback(t *testing.T, addr string) { + t.Helper() + + p, err := newICMPPinger(Spec{Addr: addr}) + if err != nil { + t.Fatal(err) + } + + const n = 50 + + var samples []float64 + + for i := 0; i < n; i++ { + res := p.Send(context.Background()) + if !res.Success { + continue + } + + samples = append(samples, res.RTT) + time.Sleep(10 * time.Millisecond) + } + + if len(samples) == 0 { + t.Fatalf("%s: no successful probes", addr) + } + + sort.Float64s(samples) + + var tot float64 + for _, s := range samples { + tot += s + } + + pct := func(q float64) float64 { return samples[int(q*float64(len(samples)-1))] } + privileged := useICMPPrivileged() + t.Logf("%s [raw=%v] n=%d min=%.4f avg=%.4f p50=%.4f p90=%.4f p99=%.4f max=%.4f ms", + addr, privileged, len(samples), samples[0], tot/float64(len(samples)), + pct(0.5), pct(0.9), pct(0.99), samples[len(samples)-1]) +} + +func TestICMPRTTLoopback(t *testing.T) { + t.Run("v4", func(t *testing.T) { benchLoopback(t, "127.0.0.1") }) + t.Run("v6", func(t *testing.T) { benchLoopback(t, "::1") }) +} + +// TestICMPSourceEgress exercises source= on the live socket: an interface name (the +// IP_PKTINFO egress path that replaced SO_BINDTODEVICE) and a source IP (the bind path). +// Both must succeed to 127.0.0.1 over loopback on the unprivileged datagram socket. +// +// go test -tags manual -run TestICMPSourceEgress -v ./internal/ping +func TestICMPSourceEgress(t *testing.T) { + cases := map[string]string{"interface=lo": "lo", "ip=127.0.0.1": "127.0.0.1"} + + for name, source := range cases { + t.Run(name, func(t *testing.T) { + p, err := newICMPPinger(Spec{Addr: "127.0.0.1", Source: source}) + if err != nil { + t.Fatal(err) + } + + res := p.Send(context.Background()) + if !res.Success { + t.Fatalf("source=%q probe failed: %+v", source, res) + } + + t.Logf("source=%q rtt=%.4fms ttl=%d", source, res.RTT, res.TTL) + }) + } +} + +// TestICMPContextCancel verifies a ctx cancellation interrupts the recv wait: a probe to +// an unreachable TEST-NET address must return shortly after cancel, not at the ~1s timeout. +// +// go test -tags manual -run TestICMPContextCancel -v ./internal/ping +func TestICMPContextCancel(t *testing.T) { + p, err := newICMPPinger(Spec{Addr: "192.0.2.1"}) // TEST-NET-1: never replies. + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(100*time.Millisecond, cancel) + + start := time.Now() + res := p.Send(ctx) + elapsed := time.Since(start) + + t.Logf("cancelled probe returned after %v (success=%v)", elapsed, res.Success) + + if res.Success { + t.Fatal("probe to TEST-NET-1 must not succeed") + } + + if elapsed > 500*time.Millisecond { + t.Fatalf("ctx cancel was not honored: returned after %v, want ~100ms", elapsed) + } +} diff --git a/internal/ping/icmp_linux.go b/internal/ping/icmp_linux.go new file mode 100644 index 0000000..50fae44 --- /dev/null +++ b/internal/ping/icmp_linux.go @@ -0,0 +1,621 @@ +//go:build linux + +package ping + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "math" + "net" + "net/netip" + "os" + "strconv" + "syscall" + "time" + "unsafe" + + "golang.org/x/net/icmp" + netipv4 "golang.org/x/net/ipv4" // aliased: "ipv4" clashes with the IP-version constant in osname.go. + netipv6 "golang.org/x/net/ipv6" + "golang.org/x/sys/unix" +) + +const ( + // controlBufSize bounds the ancillary (cmsg) buffer: enough for the RX timestamp + // plus the TTL/hop-limit cmsg. Matches the reference STAMP implementation's 128. + controlBufSize = 128 + // ipv4IHLMask is the low nibble of a raw IPv4 packet's first byte: the header length + // in 32-bit words. A raw IPv4 socket prepends this header to each received datagram. + ipv4IHLMask = 0x0f + // ipv4IHLWordSize is the byte size of one IHL word (the IHL field counts 32-bit words). + ipv4IHLWordSize = 4 + // nsPerSec bounds a valid nanosecond field when validating a kernel timestamp. + nsPerSec = 1_000_000_000 +) + +// errAddrFamily reports that an address is not of the family the probe pinned/needs. +var errAddrFamily = errors.New("ping: address family mismatch") + +// failedResult is the canonical "no reply / could not probe" outcome. +var failedResult = Result{Code: Failed, TTL: -1} + +// icmpPinger sends a native ICMP echo via a synchronous, kernel-timestamped probe. +// +// It replaces the portable pro-bing path (icmp_other.go) on Linux for one reason: RTT +// accuracy. pro-bing receives the echo reply on a dedicated goroutine, hands the packet +// to its run loop over a channel, and only then reads time.Now() as the receive instant +// — so every RTT carries the goroutine-wakeup + channel-hop latency, which dominates on +// a quiet LAN/loopback target and inflates AVG/JIT far above the true network RTT (MIN +// stays near the floor; AVG and JIT balloon). +// +// Here the send and receive happen synchronously on the calling goroutine, and the +// receive instant comes from the kernel's SO_TIMESTAMPNS software timestamp (stamped at +// RX softirq, before any scheduling) — the same TX=userspace-CLOCK_REALTIME / +// RX=kernel-software approach iputils' ping uses. The socket is nonblocking and driven +// through the Go runtime poller (os.File + RawConn), so a parked recv holds no OS thread +// (one blocked thread per in-flight probe would otherwise pile up across an async round +// during an outage). A persistent socket would not help accuracy: it is opened before +// tSend, so its setup is never inside the measured RTT; Send stays stateless per-probe. +// +// raw vs datagram follows useICMPPrivileged() exactly as the portable path does, so the +// behavior on root, non-root, and unprivileged LXC (where the datagram path is blocked +// and the raw path is used) is unchanged. Reply matching mirrors the next-hop prober +// (nexthop_ipv4.go): a raw ICMP socket sees every host's ICMP, so id+seq+token+src is +// load-bearing there; the datagram socket is demuxed by the kernel and rewrites our id, +// so it matches on seq+token+src only. +// +// The type name and fields match the portable icmpPinger (icmp_other.go) so the +// Spec->pinger wiring test (resolve_family_test.go) holds on both platforms. +type icmpPinger struct { + addr string + source string + network string // "ip4"/"ip6" to pin the resolve family (resolve_family=), "" for auto. + privileged bool +} + +func newICMPPinger(s Spec) (Pinger, error) { + return &icmpPinger{ + addr: s.Addr, + source: s.Source, + network: resolveNetwork(s), + privileged: useICMPPrivileged(), + }, nil +} + +func (p *icmpPinger) Send(ctx context.Context) Result { + ctx, cancel := context.WithTimeout(ctx, icmpTimeout) + defer cancel() + + ip, zoneID, err := p.resolve(ctx) + if err != nil { + return failedResult + } + + deadline := probeDeadline(ctx) + if time.Until(deadline) <= 0 { + return failedResult + } + + fam := familyOf(ip) + + sockType := unix.SOCK_DGRAM + if p.privileged { + sockType = unix.SOCK_RAW + } + + c, err := dialICMP(fam, sockType, p.source) + if err != nil { + return failedResult + } + + defer func() { _ = c.f.Close() }() + + dst, err := ipSockaddr(ip, zoneID) + if err != nil { + return failedResult + } + + pr := icmpProbe{ + fam: fam, + privileged: p.privileged, + id: nextProbeID(), + seq: 1, + token: newProbeToken(), + } + + wb, err := pr.build() + if err != nil { + return failedResult + } + + // One absolute deadline bounds both the send (a poller wait on a full buffer) and the + // recv, so neither can park past the probe timeout. + err = c.f.SetDeadline(deadline) + if err != nil { + return failedResult + } + + // A ctx cancellation collapses the deadline so a parked send/recv unblocks at once, + // honoring the ctx Send is given (matching pro-bing's RunWithContext; deadman's TUI + // passes context.Background(), but a cancellable ctx is now respected). + stopCancel := context.AfterFunc(ctx, func() { _ = c.f.SetDeadline(time.Now()) }) + defer stopCancel() + + tSend, err := sendProbe(c.rc, wb, c.egressOOB, dst) + if err != nil { + return failedResult + } + + return pr.recv(c.rc, ip, tSend) +} + +// resolve turns the configured address into an IP plus an IPv6 zone (scope) index, +// honoring the resolve_family pin (network) the same way the pro-bing path did. netip is +// used for literals because, unlike net.ParseIP, it accepts the fe80::1%zone form and +// exposes the zone — net.DefaultResolver.LookupIP (the hostname path) drops it. +func (p *icmpPinger) resolve(ctx context.Context) (net.IP, int, error) { + a, perr := netip.ParseAddr(p.addr) + if perr == nil { + return p.literalAddr(a) + } + + netw := "ip" + if p.network != "" { + netw = p.network // "ip4"/"ip6". + } + + ips, err := net.DefaultResolver.LookupIP(ctx, netw, p.addr) + if err != nil || len(ips) == 0 { + return nil, 0, errAddrFamily + } + + return ips[0], 0, nil +} + +// literalAddr resolves an IP literal, enforcing the resolve_family pin and extracting an +// IPv6 zone (scope) if present. +func (p *icmpPinger) literalAddr(a netip.Addr) (net.IP, int, error) { + a = a.Unmap() + + if p.network == networkIPv4 && a.Is6() { + return nil, 0, errAddrFamily + } + + if p.network == networkIPv6 && a.Is4() { + return nil, 0, errAddrFamily + } + + zone := 0 + if a.Is6() && a.Zone() != "" { + zone = zoneIndex(a.Zone()) + } + + return net.IP(a.AsSlice()), zone, nil +} + +// zoneIndex resolves an IPv6 zone (scope) to an interface index, accepting both a numeric +// scope id (fe80::1%2) and an interface name (fe80::1%eth0); 0 means "no zone". +func zoneIndex(zone string) int { + // strconv.Atoi yields a 64-bit int; reject anything outside a valid (uint32) interface + // index so an out-of-range zone (e.g. %-1 or %4294967296) cannot truncate into a wrong + // ZoneId — it falls through to a no-zone (0) result instead. + idx, err := strconv.Atoi(zone) + if err == nil && idx >= 0 && idx <= math.MaxInt32 { + return idx + } + + ifi, err := net.InterfaceByName(zone) + if err == nil { + return ifi.Index + } + + return 0 +} + +// icmpFamily captures the address-family-specific socket and parsing parameters, so the +// prober is written once without branching on a v6 flag. proto doubles as the socket +// protocol and the icmp.ParseMessage protocol number (ICMP=1, ICMPv6=58). ttlRecvOpt is +// the option that requests the TTL cmsg; ttlCmsgType is the (different) type the cmsg +// then arrives as — IP_RECVTTL enables an IP_TTL cmsg, IPV6_RECVHOPLIMIT an IPV6_HOPLIMIT. +type icmpFamily struct { + domain int + proto int + echoType icmp.Type + replyType icmp.Type + ttlLevel int + ttlRecvOpt int + ttlCmsgType int +} + +// familyOf returns the icmpFamily for ip's address family. +func familyOf(ip net.IP) icmpFamily { + if ip.To4() != nil { + return icmpFamily{ + domain: unix.AF_INET, + proto: unix.IPPROTO_ICMP, + echoType: netipv4.ICMPTypeEcho, + replyType: netipv4.ICMPTypeEchoReply, + ttlLevel: unix.IPPROTO_IP, + ttlRecvOpt: unix.IP_RECVTTL, + ttlCmsgType: unix.IP_TTL, + } + } + + return icmpFamily{ + domain: unix.AF_INET6, + proto: unix.IPPROTO_ICMPV6, + echoType: netipv6.ICMPTypeEchoRequest, + replyType: netipv6.ICMPTypeEchoReply, + ttlLevel: unix.IPPROTO_IPV6, + ttlRecvOpt: unix.IPV6_RECVHOPLIMIT, + ttlCmsgType: unix.IPV6_HOPLIMIT, + } +} + +// egressControl returns the per-send control message that pins the outgoing interface to +// ifIndex (IP_PKTINFO / IPV6_PKTINFO). This is privilege-free on every kernel, matching +// pro-bing's ControlMessage{IfIndex} — unlike SO_BINDTODEVICE, which the kernel gated on +// CAP_NET_RAW before 5.7. The source IP is left zero so the kernel auto-picks it to match +// the destination scope (README: "送信元 IP は宛先のスコープに合わせて自動選択"). +func (fam icmpFamily) egressControl(ifIndex int) []byte { + if fam.domain == unix.AF_INET { + return (&netipv4.ControlMessage{IfIndex: ifIndex}).Marshal() + } + + return (&netipv6.ControlMessage{IfIndex: ifIndex}).Marshal() +} + +// icmpConn is the poller-registered socket a probe sends and receives on: the os.File +// (the caller closes it), its RawConn, and the per-send egress control message. +type icmpConn struct { + f *os.File + rc syscall.RawConn + egressOOB []byte // IP_PKTINFO for an interface source; nil otherwise. +} + +// dialICMP opens the poller-registered ICMP socket for fam and applies source=. +func dialICMP(fam icmpFamily, sockType int, source string) (icmpConn, error) { + fd, err := openICMPSocket(fam, sockType) + if err != nil { + return icmpConn{}, err + } + + egressOOB, err := applySource(fd, source, fam) + if err != nil { + _ = unix.Close(fd) + + return icmpConn{}, err + } + + // os.NewFile registers the nonblocking socket with the runtime poller and takes + // ownership of the fd, so from here it is closed via f.Close(), never unix.Close. + f := os.NewFile(uintptr(fd), "icmp") + + rc, err := f.SyscallConn() + if err != nil { + _ = f.Close() + + return icmpConn{}, err + } + + return icmpConn{f: f, rc: rc, egressOOB: egressOOB}, nil +} + +// openICMPSocket opens a nonblocking ICMP socket for fam, with the given socket type (raw +// or datagram), enabling the kernel RX timestamp and the reply TTL cmsg. The recv timeout +// is armed via the os.File read deadline in Send, not here. +func openICMPSocket(fam icmpFamily, sockType int) (int, error) { + fd, err := unix.Socket(fam.domain, sockType|unix.SOCK_CLOEXEC|unix.SOCK_NONBLOCK, fam.proto) + if err != nil { + return -1, err + } + + // RX kernel timestamp (software, CLOCK_REALTIME). Best-effort: if it cannot be set + // the receive falls back to the monotonic span, degrading to synchronous accuracy. + _ = unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_TIMESTAMPNS, 1) + + // Reply TTL/hop-limit as a control message (captured, not displayed). + _ = unix.SetsockoptInt(fd, fam.ttlLevel, fam.ttlRecvOpt, 1) + + // We deliberately do NOT attach a kernel BPF id-filter on the raw path the way + // pro-bing's InstallICMPIDFilter does. Correctness already lives in the userspace match + // (id+seq+token+src), so a filter would only be a scale optimization: dropping foreign + // ICMP in-kernel to spare CPU and rcvbuf when root+many-targets+async fan-out every + // host's ICMP onto each socket. That degradation is visible and load-correlated (and + // the in-repo nexthop raw path is unfiltered too), whereas a subtly wrong BPF that the + // kernel still accepts would silently drop every probe's own reply — a monitor that + // reports everything down. Not shipped because it can't be validated here (needs root) + // and is catastrophic if wrong. A future root-tested change could add SO_ATTACH_FILTER + // mirroring pro-bing's utils_linux.go if high-N raw load ever demands it. + + return fd, nil +} + +// applySource honors source= : a source IP binds the socket (privilege-free for a local +// address); an interface name yields a per-send IP_PKTINFO egress control message. The +// returned oob is nil unless an interface name was given. +func applySource(fd int, source string, fam icmpFamily) ([]byte, error) { + if source == "" { + return nil, nil + } + + if srcIP := net.ParseIP(source); srcIP != nil { + sa, err := ipSockaddr(srcIP, 0) + if err != nil { + return nil, err + } + + return nil, unix.Bind(fd, sa) + } + + ifi, err := net.InterfaceByName(source) + if err != nil { + return nil, err + } + + return fam.egressControl(ifi.Index), nil +} + +// sendProbe writes the echo through the runtime poller (no OS thread parked on a full send +// buffer) and returns the instant of the successful send. The send timestamp is taken +// inside the callback, right before the syscall, so a rare writability wait is excluded. +func sendProbe(rc syscall.RawConn, wb, oob []byte, dst unix.Sockaddr) (time.Time, error) { + var ( + tSend time.Time + serr error + ) + + werr := rc.Write(func(fd uintptr) bool { + tSend = time.Now() + serr = unix.Sendmsg(int(fd), wb, oob, dst, 0) + + return !retryableSend(serr) + }) + if werr != nil { + return time.Time{}, werr + } + + return tSend, serr +} + +// icmpProbe bundles a probe's family, privilege, and identity so the build/receive logic +// reads them as fields instead of threading boolean control parameters. +type icmpProbe struct { + fam icmpFamily + privileged bool + id, seq int + token []byte +} + +// build marshals the echo request. icmp.Message.Marshal computes the ICMPv4 checksum; +// for ICMPv6 it leaves the checksum zero and the kernel fills it in. +func (pr icmpProbe) build() ([]byte, error) { + msg := icmp.Message{ + Type: pr.fam.echoType, + Code: 0, + Body: &icmp.Echo{ID: pr.id, Seq: pr.seq, Data: pr.token}, + } + + return msg.Marshal(nil) +} + +// recv reads through the runtime poller until this probe's echo reply arrives or the +// os.File read deadline passes, skipping foreign ICMP (raw fan-out). A parked read holds +// no OS thread, and the absolute deadline bounds the loop even under continuous ICMP. +func (pr icmpProbe) recv(rc syscall.RawConn, peer net.IP, tSend time.Time) Result { + buf := make([]byte, recvBufSize) + oob := make([]byte, controlBufSize) + + for { + var ( + n, oobn int + from unix.Sockaddr + rerr error + ) + + readErr := rc.Read(func(fd uintptr) bool { + n, oobn, _, from, rerr = unix.Recvmsg(int(fd), buf, oob, 0) + + return !retryable(rerr) + }) + + recvUser := time.Now() + + if readErr != nil || rerr != nil { + return failedResult // deadline exceeded, poller error, or recvmsg error. + } + + data, ok := pr.payload(buf[:n]) + if !ok || !pr.match(data, from, peer) { + continue + } + + return pr.result(oob[:oobn], recvUser, tSend) + } +} + +// retryable reports whether a send/recv syscall error means "wait for the fd to be ready +// and try again" rather than fail: EAGAIN/EWOULDBLOCK (the poller re-arms) or an +// interrupted syscall (EINTR). Shared by sendProbe and recv so both classify identically. +func retryable(err error) bool { + return errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) || + errors.Is(err, unix.EINTR) +} + +// retryableSend extends retryable with ENOBUFS — transient output-queue congestion (a full +// qdisc/txqueue), which pro-bing retried rather than scoring as a down host. Bounded by the +// write deadline, so sustained congestion still ends as a timeout failure. +func retryableSend(err error) bool { + return retryable(err) || errors.Is(err, unix.ENOBUFS) +} + +// payload returns the ICMP message bytes from a received datagram. A raw IPv4 socket +// delivers the leading IP header (which must be skipped); raw IPv6 and both datagram +// paths deliver the ICMP message directly. +func (pr icmpProbe) payload(b []byte) ([]byte, bool) { + if pr.privileged && pr.fam.domain == unix.AF_INET { + if len(b) < 1 { + return nil, false + } + + ihl := int(b[0]&ipv4IHLMask) * ipv4IHLWordSize + if ihl <= 0 || len(b) < ihl { + return nil, false + } + + return b[ihl:], true + } + + return b, true +} + +// match reports whether data is this probe's echo reply. On the raw path the id is +// load-bearing (the socket sees all ICMP); on the datagram path the kernel rewrote our +// id, so seq+token (and the per-socket demux) carry it. The source is rejected only on a +// positive mismatch, so a missing from-address does not drop an otherwise-valid reply. +func (pr icmpProbe) match(data []byte, from unix.Sockaddr, peer net.IP) bool { + if fip := sockaddrIP(from); fip != nil && !fip.Equal(peer) { + return false + } + + msg, err := icmp.ParseMessage(pr.fam.proto, data) + if err != nil || msg.Type != pr.fam.replyType { + return false + } + + echo, ok := msg.Body.(*icmp.Echo) + if !ok { + return false + } + + if pr.privileged && echo.ID != pr.id { + return false + } + + return echo.Seq == pr.seq && bytes.Equal(echo.Data, pr.token) +} + +// result turns a matched reply into a success Result. RTT prefers the kernel RX timestamp +// (precise) but falls back to the monotonic send->recv span when the kernel value is +// implausible — i.e. a CLOCK_REALTIME step between send and receive moved it outside +// [0, rttMono]. recvUser carries a monotonic reading, so rttMono is step-immune and is a +// valid upper bound (the kernel stamps RX before the userspace Recvmsg returns). k==0 is +// accepted: a genuine sub-microsecond reply truncates to 0 ms. +func (pr icmpProbe) result(oob []byte, recvUser, tSend time.Time) Result { + rttMono := recvUser.Sub(tSend) + dur := rttMono + + rxTime, haveRx, ttl := pr.fam.parseControl(oob) + if haveRx { + if k := rxTime.Sub(tSend); k >= 0 && k <= rttMono { + dur = k + } + } + + rtt := float64(dur.Microseconds()) / usPerMs + + return Result{Success: true, Code: Success, RTT: rtt, TTL: ttl} +} + +// parseControl extracts the kernel RX timestamp (SCM_TIMESTAMPNS) and reply TTL from the +// ancillary data. An absent/invalid timestamp leaves haveRx false so the caller falls +// back to the monotonic span; an absent TTL leaves ttl -1. +func (fam icmpFamily) parseControl(oob []byte) (time.Time, bool, int) { + cmsgs, err := unix.ParseSocketControlMessage(oob) + if err != nil { + return time.Time{}, false, -1 + } + + var ( + rxTime time.Time + haveRx bool + ) + + ttl := -1 + + for i := range cmsgs { + c := &cmsgs[i] + + if c.Header.Level == unix.SOL_SOCKET && c.Header.Type == unix.SCM_TIMESTAMPNS { + if t, ok := timespecToTime(c.Data); ok { + rxTime, haveRx = t, true + } + + continue + } + + if int(c.Header.Level) == fam.ttlLevel && int(c.Header.Type) == fam.ttlCmsgType && + len(c.Data) >= 4 { + // The IP_TTL / IPV6_HOPLIMIT cmsg is a native-endian C int; reading c.Data[0] + // would yield 0 on a big-endian host (s390x/ppc64). All shipped arches are LE, + // but NativeEndian keeps it correct everywhere. + ttl = int(binary.NativeEndian.Uint32(c.Data)) + } + } + + return rxTime, haveRx, ttl +} + +// timespecToTime decodes an SCM_TIMESTAMPNS cmsg payload, rejecting an out-of-range or +// zero timestamp (validation borrowed from the reference STAMP implementation). +func timespecToTime(b []byte) (time.Time, bool) { + if len(b) < int(unsafe.Sizeof(unix.Timespec{})) { + return time.Time{}, false + } + + ts := *(*unix.Timespec)(unsafe.Pointer(&b[0])) //nolint:gosec // decode a fixed-layout kernel cmsg payload. + if ts.Sec == 0 && ts.Nsec == 0 { + return time.Time{}, false + } + + if ts.Nsec < 0 || ts.Nsec >= nsPerSec { + return time.Time{}, false + } + + return time.Unix(0, unix.TimespecToNsec(ts)), true +} + +// ipSockaddr builds a unix.Sockaddr for ip, choosing the family from ip itself and setting +// the IPv6 scope (zone) id when one is given. +func ipSockaddr(ip net.IP, zoneID int) (unix.Sockaddr, error) { + if ip4 := ip.To4(); ip4 != nil { + var a [4]byte + + copy(a[:], ip4) + + return &unix.SockaddrInet4{Addr: a}, nil + } + + ip16 := ip.To16() + if ip16 == nil { + return nil, errAddrFamily + } + + var a [16]byte + + copy(a[:], ip16) + + sa := &unix.SockaddrInet6{Addr: a} + // Bounded narrowing: a zoneID outside the uint32 interface-index range leaves ZoneId 0 + // (no zone) rather than truncating. zoneIndex already filters numeric zones, so this is + // belt-and-suspenders that also keeps the int->uint32 conversion provably safe here. + if zoneID >= 0 && zoneID <= math.MaxInt32 { + sa.ZoneId = uint32(zoneID) + } + + return sa, nil +} + +// sockaddrIP extracts the IP from a recvmsg source address, or nil if unknown. +func sockaddrIP(sa unix.Sockaddr) net.IP { + switch v := sa.(type) { + case *unix.SockaddrInet4: + return net.IP(v.Addr[:]) + case *unix.SockaddrInet6: + return net.IP(v.Addr[:]) + default: + return nil + } +} diff --git a/internal/ping/icmp_linux_test.go b/internal/ping/icmp_linux_test.go new file mode 100644 index 0000000..6956dee --- /dev/null +++ b/internal/ping/icmp_linux_test.go @@ -0,0 +1,294 @@ +//go:build linux + +package ping + +import ( + "bytes" + "net" + "net/netip" + "testing" + "time" + "unsafe" + + "golang.org/x/net/icmp" + netipv4 "golang.org/x/net/ipv4" + "golang.org/x/sys/unix" +) + +// echoReplyV4 marshals an ICMPv4 echo reply as it appears on the wire without an IP +// header — what a datagram socket delivers and what payload yields after stripping. +func echoReplyV4(t *testing.T, id, seq int, token []byte) []byte { + t.Helper() + + msg := icmp.Message{ + Type: netipv4.ICMPTypeEchoReply, + Body: &icmp.Echo{ID: id, Seq: seq, Data: token}, + } + + b, err := msg.Marshal(nil) + if err != nil { + t.Fatal(err) + } + + return b +} + +// v4Probe builds an icmpProbe for an IPv4 target (seq 1) with the given privilege and id. +func v4Probe(privileged bool, id int, token []byte) icmpProbe { + return icmpProbe{ + fam: familyOf(net.IPv4(127, 0, 0, 1)), + privileged: privileged, + id: id, + seq: 1, + token: token, + } +} + +// TestProbePayloadStripsRawV4Header pins the one path that strips a leading IP header: +// the privileged raw IPv4 socket. Datagram (v4/v6) and raw v6 deliver the ICMP message +// directly. Mis-stripping here would drop every reply to an X. +func TestProbePayloadStripsRawV4Header(t *testing.T) { + icmpBytes := echoReplyV4(t, 1, 1, []byte("tok")) + + // A 20-byte IPv4 header: version 4, IHL 5 (×4 = 20 bytes). + rawV4 := append( + []byte{0x45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + icmpBytes...) + + got, ok := v4Probe(true, 1, nil).payload(rawV4) + if !ok || !bytes.Equal(got, icmpBytes) { + t.Fatalf("raw v4 strip: ok=%v got=%v want=%v", ok, got, icmpBytes) + } + + // Datagram v4 passes the bytes through untouched. + if got, ok := v4Probe(false, 1, nil).payload(icmpBytes); !ok || !bytes.Equal(got, icmpBytes) { + t.Fatalf("datagram v4 passthrough: ok=%v", ok) + } + + // Raw v6 also passes through (only raw v4 carries an IP header). + v6 := icmpProbe{fam: familyOf(net.ParseIP("::1")), privileged: true} + if got, ok := v6.payload(icmpBytes); !ok || !bytes.Equal(got, icmpBytes) { + t.Fatalf("raw v6 passthrough: ok=%v", ok) + } + + // A header claiming more length than present must be rejected, not panic. + if _, ok := v4Probe(true, 1, nil).payload([]byte{0x4f}); ok { + t.Fatal("truncated raw v4 header should be rejected") + } +} + +// TestProbeMatchIDOnlyOnRawPath pins the raw-vs-datagram id rule: the kernel rewrites our +// id on the datagram path (so id must be ignored there), while the raw socket sees all +// ICMP and needs the id to disambiguate. +func TestProbeMatchIDOnlyOnRawPath(t *testing.T) { + const ourID, seq = 42, 1 + + token := []byte("deadman1") + peer := net.ParseIP("127.0.0.1") + from := &unix.SockaddrInet4{Addr: [4]byte{127, 0, 0, 1}} + + // Datagram reply carries the kernel-assigned id (not ours): must still match. + dgram := echoReplyV4(t, 9999, seq, token) + if !v4Probe(false, ourID, token).match(dgram, from, peer) { + t.Fatal("datagram path must match despite a rewritten id") + } + + // Raw reply with our id matches; with a foreign id it must not. + if !v4Probe(true, ourID, token).match(echoReplyV4(t, ourID, seq, token), from, peer) { + t.Fatal("raw path must match its own id") + } + + if v4Probe(true, ourID, token).match(echoReplyV4(t, 9999, seq, token), from, peer) { + t.Fatal("raw path must reject a foreign id") + } +} + +// TestProbeMatchRejectsImposters pins seq/token/source filtering — the guard against a +// concurrent prober's reply (raw fan-out) being folded into the wrong target's stats. +func TestProbeMatchRejectsImposters(t *testing.T) { + const id, seq = 7, 3 + + token := []byte("realtok0") + peer := net.ParseIP("10.0.0.1") + from := &unix.SockaddrInet4{Addr: [4]byte{10, 0, 0, 1}} + + pr := icmpProbe{fam: familyOf(peer), privileged: false, id: id, seq: seq, token: token} + + if !pr.match(echoReplyV4(t, id, seq, token), from, peer) { + t.Fatal("the genuine reply must match") + } + + if pr.match(echoReplyV4(t, id, seq, []byte("OTHERtok")), from, peer) { + t.Fatal("a foreign token must be rejected") + } + + if pr.match(echoReplyV4(t, id, seq+1, token), from, peer) { + t.Fatal("a foreign seq must be rejected") + } + + if pr.match( + echoReplyV4(t, id, seq, token), + &unix.SockaddrInet4{Addr: [4]byte{10, 0, 0, 2}}, + peer, + ) { + t.Fatal("a reply from another source must be rejected") + } + + // A missing source address must not drop an otherwise-valid reply. + if !pr.match(echoReplyV4(t, id, seq, token), nil, peer) { + t.Fatal("a nil source must not reject a matching reply") + } +} + +// TestTimespecToTimeValidation pins the SCM_TIMESTAMPNS guard: a zero or out-of-range +// stamp is rejected so the caller falls back to a userspace instant instead of computing +// an RTT against epoch. +func TestTimespecToTimeValidation(t *testing.T) { + tsBytes := func(ts unix.Timespec) []byte { + p := (*[64]byte)(unsafe.Pointer(&ts)) //nolint:gosec // read a fixed-layout struct as bytes. + b := make([]byte, unsafe.Sizeof(ts)) + copy(b, p[:unsafe.Sizeof(ts)]) + + return b + } + + got, ok := timespecToTime(tsBytes(unix.Timespec{Sec: 100, Nsec: 500})) + if !ok || !got.Equal(time.Unix(100, 500)) { + t.Fatalf("valid timespec: ok=%v got=%v", ok, got) + } + + if _, ok := timespecToTime(tsBytes(unix.Timespec{Sec: 0, Nsec: 0})); ok { + t.Fatal("zero timespec must be rejected") + } + + // nsPerSec is an untyped constant, so it assigns to Nsec whether it is int64 (amd64) or + // int32 (386/arm); a literal int64 here would not compile on the 32-bit linux arches. + if _, ok := timespecToTime(tsBytes(unix.Timespec{Sec: 1, Nsec: nsPerSec})); ok { + t.Fatal("out-of-range nsec must be rejected") + } + + if _, ok := timespecToTime([]byte{0x01, 0x02}); ok { + t.Fatal("a short buffer must be rejected") + } +} + +// scmTimestampOOB packs a SCM_TIMESTAMPNS control message carrying when, as the kernel +// would deliver it, so result()'s clock-step handling can be tested without a socket. +func scmTimestampOOB(when time.Time) []byte { + ts := unix.NsecToTimespec(when.UnixNano()) + sz := int(unsafe.Sizeof(ts)) + + b := make([]byte, unix.CmsgSpace(sz)) + h := (*unix.Cmsghdr)(unsafe.Pointer(&b[0])) //nolint:gosec // build a fixed-layout cmsg. + h.Level = unix.SOL_SOCKET + h.Type = unix.SCM_TIMESTAMPNS + h.SetLen(unix.CmsgLen(sz)) + + tsp := (*[64]byte)(unsafe.Pointer(&ts)) //nolint:gosec // view the timespec as bytes. + copy(b[unix.CmsgLen(0):], tsp[:sz]) + + return b +} + +// TestResultClockStepFallback pins F5: the kernel timestamp is used only when plausible +// (in [0, monotonic span]); a backward or forward CLOCK_REALTIME step falls back to the +// step-immune monotonic span instead of yielding a negative/huge RTT. +func TestResultClockStepFallback(t *testing.T) { + pr := v4Probe(false, 1, nil) + + tSend := time.Now() + recvUser := tSend.Add(10 * time.Millisecond) // monotonic span = 10ms. + + cases := []struct { + name string + oob []byte + wantMs float64 + }{ + {"plausible kernel ts", scmTimestampOOB(tSend.Add(3 * time.Millisecond)), 3.0}, + {"forward step (k>span)", scmTimestampOOB(tSend.Add(50 * time.Millisecond)), 10.0}, + {"backward step (k<0)", scmTimestampOOB(tSend.Add(-5 * time.Millisecond)), 10.0}, + {"no kernel ts", nil, 10.0}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + res := pr.result(c.oob, recvUser, tSend) + if !res.Success { + t.Fatal("result must be a success") + } + + if res.RTT < c.wantMs-0.5 || res.RTT > c.wantMs+0.5 { + t.Fatalf("RTT=%.3fms, want ~%.1fms", res.RTT, c.wantMs) + } + }) + } +} + +// TestZoneIndex pins F3's zone parsing: numeric scope ids and interface names both resolve, +// and an unknown zone degrades to 0 (no zone) rather than erroring. +func TestZoneIndex(t *testing.T) { + if got := zoneIndex("2"); got != 2 { + t.Errorf("numeric zone %q = %d, want 2", "2", got) + } + + lo, err := net.InterfaceByName("lo") + if err != nil { + t.Skip("no lo interface") + } + + if got := zoneIndex("lo"); got != lo.Index { + t.Errorf("named zone %q = %d, want %d", "lo", got, lo.Index) + } + + if got := zoneIndex("nonexistent-if-zzz"); got != 0 { + t.Errorf("unknown zone = %d, want 0", got) + } + + // An out-of-range numeric zone must not truncate into a bogus ZoneId; it degrades to 0. + for _, z := range []string{"-1", "4294967296", "99999999999999999999"} { + if got := zoneIndex(z); got != 0 { + t.Errorf("out-of-range zone %q = %d, want 0", z, got) + } + } +} + +// TestLiteralAddrZone pins F3: a scoped IPv6 literal keeps its zone as an interface index; +// a plain address has zone 0; the resolve_family pin still rejects the wrong family. +func TestLiteralAddrZone(t *testing.T) { + lo, err := net.InterfaceByName("lo") + if err != nil { + t.Skip("no lo interface") + } + + p := &icmpPinger{} + + ip, zone, err := p.literalAddr(netip.MustParseAddr("fe80::1%lo")) + if err != nil || ip == nil || zone != lo.Index { + t.Fatalf("fe80::1%%lo -> ip=%v zone=%d err=%v, want zone=%d", ip, zone, err, lo.Index) + } + + _, zone, err = p.literalAddr(netip.MustParseAddr("192.0.2.1")) + if err != nil || zone != 0 { + t.Fatalf("192.0.2.1 -> zone=%d err=%v, want zone=0", zone, err) + } + + pinned := &icmpPinger{network: networkIPv4} + + _, _, perr := pinned.literalAddr(netip.MustParseAddr("::1")) + if perr == nil { + t.Fatal("resolve_family=ipv4 must reject a v6 literal") + } +} + +// TestEgressControlNonEmpty pins F1: an interface source produces a non-empty IP_PKTINFO / +// IPV6_PKTINFO control message (the privilege-free egress selector that replaces +// SO_BINDTODEVICE). +func TestEgressControlNonEmpty(t *testing.T) { + if oob := familyOf(net.IPv4(127, 0, 0, 1)).egressControl(1); len(oob) == 0 { + t.Error("v4 egress control message is empty") + } + + if oob := familyOf(net.ParseIP("::1")).egressControl(1); len(oob) == 0 { + t.Error("v6 egress control message is empty") + } +} diff --git a/internal/ping/icmp_other.go b/internal/ping/icmp_other.go new file mode 100644 index 0000000..113e9dc --- /dev/null +++ b/internal/ping/icmp_other.go @@ -0,0 +1,80 @@ +//go:build !linux + +package ping + +import ( + "context" + "net" + + probing "github.com/prometheus-community/pro-bing" +) + +// icmpPinger sends a native ICMP echo using pro-bing. Native ICMP is portable +// (Windows/macOS/*BSD) and avoids shelling out to `ping -c 1` and parsing +// OS-specific output. Linux replaces this with a synchronous, kernel-timestamped +// prober (icmp_linux.go); see that file for why. +type icmpPinger struct { + addr string + source string + network string // "ip4"/"ip6" to pin the resolve family (resolve_family=), "" for auto. + privileged bool +} + +func newICMPPinger(s Spec) (Pinger, error) { + return &icmpPinger{ + addr: s.Addr, + source: s.Source, + network: resolveNetwork(s), + privileged: useICMPPrivileged(), + }, nil +} + +func (p *icmpPinger) Send(ctx context.Context) Result { + ctx, cancel := context.WithTimeout(ctx, icmpTimeout) + defer cancel() + + // New defers resolution; SetNetwork pins the family so RunWithContext resolves + // v4/v6 as configured. A resolve failure (no record in the pinned family, or an + // IP literal of the other family) surfaces from RunWithContext below as Failed. + pinger := probing.New(p.addr) + if p.network != "" { + pinger.SetNetwork(p.network) + } + + pinger.SetPrivileged(p.privileged) + pinger.Count = 1 + pinger.Timeout = icmpTimeout + pinger.RecordTTLs = true + + if p.source != "" { + // A source may be a source IP or (on Linux) an interface name: + // pro-bing's Source binds by IP, InterfaceName by name. + if net.ParseIP(p.source) != nil { + pinger.Source = p.source + } else { + pinger.InterfaceName = p.source + } + } + + err := pinger.RunWithContext(ctx) + if err != nil { + return Result{Code: Failed, TTL: -1} + } + + st := pinger.Statistics() + if st.PacketsRecv > 0 { + ttl := -1 + if len(st.TTLs) > 0 { + ttl = int(st.TTLs[0]) + } + + return Result{ + Success: true, + Code: Success, + RTT: float64(st.AvgRtt.Microseconds()) / usPerMs, + TTL: ttl, + } + } + + return Result{Code: Failed, TTL: -1} +}