From 36bc92d6eebfe3d9db6e87d2d78c37feccf51de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B5=90=E5=8F=8B?= Date: Thu, 11 Jun 2026 14:10:29 +0900 Subject: [PATCH 1/5] =?UTF-8?q?perf:=20direct=20ICMP=20=E3=81=AE=20RTT=20?= =?UTF-8?q?=E8=A8=88=E6=B8=AC=E3=82=92=E5=90=8C=E6=9C=9F+=E3=82=AB?= =?UTF-8?q?=E3=83=BC=E3=83=8D=E3=83=AB=E3=82=BF=E3=82=A4=E3=83=A0=E3=82=B9?= =?UTF-8?q?=E3=82=BF=E3=83=B3=E3=83=97=E5=8C=96(Linux)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pro-bing は echo 応答を別 goroutine の recvICMP で受け、channel 経由で run loop へ渡してから受信時刻(time.Now)を取る。そのため goroutine 起床+ channel ホップの遅延が各 RTT サンプルに混入し、静かな LAN/loopback では AVG/JIT が真のネットワーク値を大きく上回っていた(MIN は下限のまま肥大)。 Linux のみ、direct ICMP を pro-bing から「同期送受信 + SO_TIMESTAMPNS (カーネル軟件 RX タイムスタンプ)」方式へ置換する。TX=time.Now() / RX=カーネル時刻の単一 CLOCK_REALTIME ドメイン(iputils ping と同方式)。 - raw/datagram の選択は既存 useICMPPrivileged() を再利用し挙動不変 (非特権 LXC で datagram が塞がれ raw に倒れる経路も維持)。 - 応答照合: raw は fan-out のため id+seq+token+src、datagram は kernel demux 済みで id を書き換えられるため seq+token+src。 - 受信ループは絶対デッドラインから SO_RCVTIMEO を毎回再アームし、raw fan-out 下で非マッチパケットによる無限スピンを防ぐ。 - TTL/HopLimit は IP_RECVTTL/IPV6_RECVHOPLIMIT で取得(捕捉のみ・非表示)。 macOS/FreeBSD/Windows は pro-bing 据え置き(icmp_other.go, //go:build !linux)。 設定構文・CLI・権限要件は不変のため README 変更なし。 loopback 実測(非 root/datagram): avg 0.07ms(pro-bing) → 0.03ms (native ping ~0.02 並み。userspace フォールバックの ~0.05 より明確に低く、 カーネルタイムスタンプが効いていることを確認)。 --- internal/ping/icmp.go | 73 +--- internal/ping/icmp_bench_manual_test.go | 63 ++++ internal/ping/icmp_linux.go | 459 ++++++++++++++++++++++++ internal/ping/icmp_linux_test.go | 170 +++++++++ internal/ping/icmp_other.go | 80 +++++ 5 files changed, 773 insertions(+), 72 deletions(-) create mode 100644 internal/ping/icmp_bench_manual_test.go create mode 100644 internal/ping/icmp_linux.go create mode 100644 internal/ping/icmp_linux_test.go create mode 100644 internal/ping/icmp_other.go 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..da80331 --- /dev/null +++ b/internal/ping/icmp_bench_manual_test.go @@ -0,0 +1,63 @@ +//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") }) +} diff --git a/internal/ping/icmp_linux.go b/internal/ping/icmp_linux.go new file mode 100644 index 0000000..7294b38 --- /dev/null +++ b/internal/ping/icmp_linux.go @@ -0,0 +1,459 @@ +//go:build linux + +package ping + +import ( + "bytes" + "context" + "errors" + "net" + "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 (no hop), 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. A persistent socket would not help: it +// is opened before tSend, so its setup is never inside the measured RTT; Send stays +// stateless per-probe to avoid socket-lifecycle and concurrency complexity. +// +// 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, 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 + } + + fd, err := openICMPSocket(fam, sockType, p.source) + if err != nil { + return failedResult + } + + defer func() { _ = unix.Close(fd) }() + + pr := icmpProbe{ + fam: fam, + privileged: p.privileged, + id: nextProbeID(), + seq: 1, + token: newProbeToken(), + } + + wb, err := pr.build() + if err != nil { + return failedResult + } + + dst, err := ipSockaddr(ip) + if err != nil { + return failedResult + } + + tSend := time.Now() + + err = unix.Sendto(fd, wb, 0, dst) + if err != nil { + return failedResult + } + + return pr.recv(fd, ip, tSend, deadline) +} + +// resolve turns the configured address into an IP, honoring the resolve_family pin +// (network) the same way the pro-bing path does via SetNetwork. +func (p *icmpPinger) resolve(ctx context.Context) (net.IP, error) { + if ip := net.ParseIP(p.addr); ip != nil { + if p.network == networkIPv4 && ip.To4() == nil { + return nil, errAddrFamily + } + + if p.network == networkIPv6 && ip.To4() != nil { + return nil, errAddrFamily + } + + return ip, nil + } + + 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, errAddrFamily + } + + return ips[0], nil +} + +// 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, + } +} + +// openICMPSocket opens the 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 per-read from the absolute deadline in recv, not here. +func openICMPSocket(fam icmpFamily, sockType int, source string) (int, error) { + fd, err := unix.Socket(fam.domain, sockType|unix.SOCK_CLOEXEC, 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 a userspace instant, 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) + + if source != "" { + err = bindSource(fd, source) + if err != nil { + _ = unix.Close(fd) + + return -1, err + } + } + + return fd, nil +} + +// bindSource honors source= : a source IP binds the socket address; anything else is +// treated as an interface name (SO_BINDTODEVICE), matching the portable path's split. +func bindSource(fd int, source string) error { + if srcIP := net.ParseIP(source); srcIP != nil { + sa, err := ipSockaddr(srcIP) + if err != nil { + return err + } + + return unix.Bind(fd, sa) + } + + return unix.BindToDevice(fd, source) +} + +// 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 until this probe's echo reply arrives or the deadline passes, then computes +// RTT from the kernel RX timestamp (or a userspace fallback) minus tSend. +func (pr icmpProbe) recv(fd int, peer net.IP, tSend, deadline time.Time) Result { + buf := make([]byte, recvBufSize) + oob := make([]byte, controlBufSize) + + for { + // Re-arm the recv timeout from the absolute deadline each iteration. SO_RCVTIMEO is + // per-call, and a raw socket's fan-out delivers other hosts' (and other probes') + // ICMP that we skip; without re-arming, each skipped packet would restart the full + // timeout and the probe could spin past its deadline under continuous ICMP. + remaining := time.Until(deadline) + if remaining <= 0 { + return failedResult + } + + tv := unix.NsecToTimeval(int64(remaining)) + + err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv) + if err != nil { + return failedResult + } + + n, oobn, _, from, err := unix.Recvmsg(fd, buf, oob, 0) + recvUser := time.Now() + + if err != nil { + if errors.Is(err, unix.EINTR) { + continue // the deadline is re-checked at the top of the loop. + } + + return failedResult // SO_RCVTIMEO (EAGAIN) or another error: no reply. + } + + data, ok := pr.payload(buf[:n]) + if !ok || !pr.match(data, from, peer) { + continue + } + + return pr.result(oob[:oobn], recvUser, tSend) + } +} + +// result turns a matched reply's ancillary data into a success Result: RTT from the +// kernel RX timestamp (or the userspace recvUser fallback) minus tSend, plus the TTL. +func (pr icmpProbe) result(oob []byte, recvUser, tSend time.Time) Result { + rxTime, haveRx, ttl := pr.fam.parseControl(oob) + if !haveRx { + rxTime = recvUser + } + + rtt := float64(rxTime.Sub(tSend).Microseconds()) / usPerMs + if rtt < 0 { + rtt = 0 // a clock step in the sub-ms window must not yield a negative RTT. + } + + return Result{Success: true, Code: Success, RTT: rtt, TTL: ttl} +} + +// 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) +} + +// 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 a userspace instant; 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) > 0 { + ttl = int(c.Data[0]) + } + } + + 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. +func ipSockaddr(ip net.IP) (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) + + return &unix.SockaddrInet6{Addr: a}, 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..ed89a94 --- /dev/null +++ b/internal/ping/icmp_linux_test.go @@ -0,0 +1,170 @@ +//go:build linux + +package ping + +import ( + "bytes" + "net" + "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") + } + + if _, ok := timespecToTime(tsBytes(unix.Timespec{Sec: 1, Nsec: int64(2 * time.Second)})); 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") + } +} 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} +} From de5babcdf4161503d852bdc730809cbb0bfcb0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B5=90=E5=8F=8B?= Date: Thu, 11 Jun 2026 14:59:00 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20direct=20ICMP(Linux)=E3=81=AE?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=20=E2=80=94=20netpoll/PKTINFO/zone/clock=E8=80=90?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビューで挙がった同期 ICMP プローブの回帰を修正。 - F1 送信元インタフェース指定: SO_BINDTODEVICE を廃し IP_PKTINFO/IPV6_PKTINFO (ipv4/ipv6.ControlMessage{IfIndex}.Marshal)へ。pro-bing と同じ無権限・全カーネル 方式に戻す(SO_BINDTODEVICE は <5.7 で CAP_NET_RAW を要求し source=eth0 が失敗)。 - F2 OS スレッド消費: ソケットを SOCK_NONBLOCK 化し os.NewFile+SyscallConn で ランタイムポーラ経由に。応答待ちのプローブが OS スレッドを占有しなくなり、 大規模対象/障害時のスレッド増加(10000 上限)リスクを解消。SO_TIMESTAMPNS は維持。 - F3 IPv6 zone: net.ParseIP が捨てる %zone を netip.ParseAddr で保持し、 SockaddrInet6.ZoneId に設定(数値 scope/インタフェース名の両対応)。 - F4 broadcast/multicast: 死活監視は1行=1ホスト前提のため対象外と README に明記 (現状の X グリフで graceful degrade、コード変更なし)。 - F5 時計ステップ: RTT は monotonic な recvUser.Sub(tSend) を境界とし、カーネル ts は [0, rttMono] のときのみ採用。負値クランプ(Min=0 恒久化)を撤廃。 回帰テスト追加(payload/match/zone/clock-step fallback/PKTINFO/timespec 検証)、 manual テストに source=interface/IP の egress 確認を追加。make check 通過。 --- README.md | 2 + internal/ping/icmp_bench_manual_test.go | 25 ++ internal/ping/icmp_linux.go | 306 +++++++++++++++++------- internal/ping/icmp_linux_test.go | 115 +++++++++ 4 files changed, 356 insertions(+), 92 deletions(-) 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_bench_manual_test.go b/internal/ping/icmp_bench_manual_test.go index da80331..5f3cfef 100644 --- a/internal/ping/icmp_bench_manual_test.go +++ b/internal/ping/icmp_bench_manual_test.go @@ -61,3 +61,28 @@ 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) + }) + } +} diff --git a/internal/ping/icmp_linux.go b/internal/ping/icmp_linux.go index 7294b38..0966cf5 100644 --- a/internal/ping/icmp_linux.go +++ b/internal/ping/icmp_linux.go @@ -7,6 +7,10 @@ import ( "context" "errors" "net" + "net/netip" + "os" + "strconv" + "syscall" "time" "unsafe" @@ -44,12 +48,14 @@ var failedResult = Result{Code: Failed, TTL: -1} // 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 (no hop), 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. A persistent socket would not help: it -// is opened before tSend, so its setup is never inside the measured RTT; Send stays -// stateless per-probe to avoid socket-lifecycle and concurrency complexity. +// 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 @@ -80,7 +86,7 @@ func (p *icmpPinger) Send(ctx context.Context) Result { ctx, cancel := context.WithTimeout(ctx, icmpTimeout) defer cancel() - ip, err := p.resolve(ctx) + ip, zoneID, err := p.resolve(ctx) if err != nil { return failedResult } @@ -97,12 +103,17 @@ func (p *icmpPinger) Send(ctx context.Context) Result { sockType = unix.SOCK_RAW } - fd, err := openICMPSocket(fam, sockType, p.source) + c, err := dialICMP(fam, sockType, p.source) if err != nil { return failedResult } - defer func() { _ = unix.Close(fd) }() + defer func() { _ = c.f.Close() }() + + dst, err := ipSockaddr(ip, zoneID) + if err != nil { + return failedResult + } pr := icmpProbe{ fam: fam, @@ -117,34 +128,27 @@ func (p *icmpPinger) Send(ctx context.Context) Result { return failedResult } - dst, err := ipSockaddr(ip) + tSend, err := sendProbe(c.rc, wb, c.egressOOB, dst) if err != nil { return failedResult } - tSend := time.Now() - - err = unix.Sendto(fd, wb, 0, dst) + err = c.f.SetReadDeadline(deadline) if err != nil { return failedResult } - return pr.recv(fd, ip, tSend, deadline) + return pr.recv(c.rc, ip, tSend) } -// resolve turns the configured address into an IP, honoring the resolve_family pin -// (network) the same way the pro-bing path does via SetNetwork. -func (p *icmpPinger) resolve(ctx context.Context) (net.IP, error) { - if ip := net.ParseIP(p.addr); ip != nil { - if p.network == networkIPv4 && ip.To4() == nil { - return nil, errAddrFamily - } - - if p.network == networkIPv6 && ip.To4() != nil { - return nil, errAddrFamily - } - - return ip, nil +// 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" @@ -154,10 +158,47 @@ func (p *icmpPinger) resolve(ctx context.Context) (net.IP, error) { ips, err := net.DefaultResolver.LookupIP(ctx, netw, p.addr) if err != nil || len(ips) == 0 { - return nil, errAddrFamily + 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 { + idx, err := strconv.Atoi(zone) + if err == nil { + return idx + } + + ifi, err := net.InterfaceByName(zone) + if err == nil { + return ifi.Index } - return ips[0], nil + return 0 } // icmpFamily captures the address-family-specific socket and parsing parameters, so the @@ -200,47 +241,119 @@ func familyOf(ip net.IP) icmpFamily { } } -// openICMPSocket opens the 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 per-read from the absolute deadline in recv, not here. -func openICMPSocket(fam icmpFamily, sockType int, source string) (int, error) { - fd, err := unix.Socket(fam.domain, sockType|unix.SOCK_CLOEXEC, fam.proto) +// 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 a userspace instant, degrading to synchronous accuracy. + // 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) - if source != "" { - err = bindSource(fd, source) - if err != nil { - _ = unix.Close(fd) - - return -1, err - } - } - return fd, nil } -// bindSource honors source= : a source IP binds the socket address; anything else is -// treated as an interface name (SO_BINDTODEVICE), matching the portable path's split. -func bindSource(fd int, source string) error { +// 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) + sa, err := ipSockaddr(srcIP, 0) if err != nil { - return err + return nil, err } - return unix.Bind(fd, sa) + 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 !errors.Is(serr, unix.EAGAIN) + }) + if werr != nil { + return time.Time{}, werr } - return unix.BindToDevice(fd, source) + return tSend, serr } // icmpProbe bundles a probe's family, privilege, and identity so the build/receive logic @@ -264,38 +377,30 @@ func (pr icmpProbe) build() ([]byte, error) { return msg.Marshal(nil) } -// recv reads until this probe's echo reply arrives or the deadline passes, then computes -// RTT from the kernel RX timestamp (or a userspace fallback) minus tSend. -func (pr icmpProbe) recv(fd int, peer net.IP, tSend, deadline time.Time) Result { +// 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 { - // Re-arm the recv timeout from the absolute deadline each iteration. SO_RCVTIMEO is - // per-call, and a raw socket's fan-out delivers other hosts' (and other probes') - // ICMP that we skip; without re-arming, each skipped packet would restart the full - // timeout and the probe could spin past its deadline under continuous ICMP. - remaining := time.Until(deadline) - if remaining <= 0 { - return failedResult - } + var ( + n, oobn int + from unix.Sockaddr + rerr error + ) - tv := unix.NsecToTimeval(int64(remaining)) + readErr := rc.Read(func(fd uintptr) bool { + n, oobn, _, from, rerr = unix.Recvmsg(int(fd), buf, oob, 0) - err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv) - if err != nil { - return failedResult - } + return !retryRecv(rerr) + }) - n, oobn, _, from, err := unix.Recvmsg(fd, buf, oob, 0) recvUser := time.Now() - if err != nil { - if errors.Is(err, unix.EINTR) { - continue // the deadline is re-checked at the top of the loop. - } - - return failedResult // SO_RCVTIMEO (EAGAIN) or another error: no reply. + if readErr != nil || rerr != nil { + return failedResult // deadline exceeded, poller error, or recvmsg error. } data, ok := pr.payload(buf[:n]) @@ -307,20 +412,11 @@ func (pr icmpProbe) recv(fd int, peer net.IP, tSend, deadline time.Time) Result } } -// result turns a matched reply's ancillary data into a success Result: RTT from the -// kernel RX timestamp (or the userspace recvUser fallback) minus tSend, plus the TTL. -func (pr icmpProbe) result(oob []byte, recvUser, tSend time.Time) Result { - rxTime, haveRx, ttl := pr.fam.parseControl(oob) - if !haveRx { - rxTime = recvUser - } - - rtt := float64(rxTime.Sub(tSend).Microseconds()) / usPerMs - if rtt < 0 { - rtt = 0 // a clock step in the sub-ms window must not yield a negative RTT. - } - - return Result{Success: true, Code: Success, RTT: rtt, TTL: ttl} +// retryRecv reports whether a recvmsg error means "wait for readability and try again" +// rather than fail: EAGAIN/EWOULDBLOCK (poller will re-arm) or an interrupted syscall. +func retryRecv(err error) bool { + return errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) || + errors.Is(err, unix.EINTR) } // payload returns the ICMP message bytes from a received datagram. A raw IPv4 socket @@ -369,9 +465,31 @@ func (pr icmpProbe) match(data []byte, from unix.Sockaddr, peer net.IP) bool { 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 a userspace instant; an absent TTL leaves ttl -1. +// 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 { @@ -424,8 +542,9 @@ func timespecToTime(b []byte) (time.Time, bool) { return time.Unix(0, unix.TimespecToNsec(ts)), true } -// ipSockaddr builds a unix.Sockaddr for ip, choosing the family from ip itself. -func ipSockaddr(ip net.IP) (unix.Sockaddr, error) { +// 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 @@ -443,7 +562,10 @@ func ipSockaddr(ip net.IP) (unix.Sockaddr, error) { copy(a[:], ip16) - return &unix.SockaddrInet6{Addr: a}, nil + sa := &unix.SockaddrInet6{Addr: a} + sa.ZoneId = uint32(zoneID) //nolint:gosec // ifindex is non-negative. + + return sa, nil } // sockaddrIP extracts the IP from a recvmsg source address, or nil if unknown. diff --git a/internal/ping/icmp_linux_test.go b/internal/ping/icmp_linux_test.go index ed89a94..176bffb 100644 --- a/internal/ping/icmp_linux_test.go +++ b/internal/ping/icmp_linux_test.go @@ -5,6 +5,7 @@ package ping import ( "bytes" "net" + "net/netip" "testing" "time" "unsafe" @@ -168,3 +169,117 @@ func TestTimespecToTimeValidation(t *testing.T) { 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) + } +} + +// 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") + } +} From 96b565d010e60be9d704f585d94707919d971c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B5=90=E5=8F=8B?= Date: Thu, 11 Jun 2026 16:00:50 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20direct=20ICMP(Linux)=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E7=AC=AC2=E3=83=A9=E3=82=A6?= =?UTF-8?q?=E3=83=B3=E3=83=89=20=E2=80=94=20ctx=20=E3=82=AD=E3=83=A3?= =?UTF-8?q?=E3=83=B3=E3=82=BB=E3=83=AB/32bit=20=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?/=E9=80=81=E5=8F=97=E4=BF=A1=E5=AF=BE=E7=A7=B0=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ctx キャンセルを受信待機に反映(契約整合・防御的): Send が受け取った ctx の取消で 待機中のプローブを即時中断する。絶対デッドライン(read+write 両方=SetDeadline)に 加え context.AfterFunc でデッドラインを now へ畳み、parked な send/recv を解除。 deadman の TUI は context.Background() を渡すため現状は発火しないが、ctx を引数に 取る Send 契約を尊重し pro-bing の RunWithContext と挙動を揃える。 - テストの 32-bit Linux 非互換を修正(実バグ): unix.Timespec.Nsec は linux/386・ linux/arm で int32 のため明示的 int64 リテラルはコンパイル不可。untyped 定数 nsPerSec に変更し全 linux arch で go test -c が通るようにする。 - 送受信のエラー分類を対称化(防御的): retryRecv を retryable に改名し sendProbe と recv で共有。EINTR/EAGAIN/EWOULDBLOCK を両経路で同一に扱う。送信前に write も含む デッドラインを張り、満杯送信バッファでのポーラ待機も有界化。 検証: make check 通過、4 OS + linux/arm + linux/386 クロスビルド、race クリーン、 manual(非 root datagram 経路)で ctx キャンセルが 100ms で復帰・source egress・ RTT カーネル ts・1s 有界を確認。raw/特権経路は要 root の実機 manual 検証(未実行)。 --- internal/ping/icmp_bench_manual_test.go | 28 +++++++++++++++++++++++++ internal/ping/icmp_linux.go | 23 +++++++++++++------- internal/ping/icmp_linux_test.go | 4 +++- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/internal/ping/icmp_bench_manual_test.go b/internal/ping/icmp_bench_manual_test.go index 5f3cfef..47751f7 100644 --- a/internal/ping/icmp_bench_manual_test.go +++ b/internal/ping/icmp_bench_manual_test.go @@ -86,3 +86,31 @@ func TestICMPSourceEgress(t *testing.T) { }) } } + +// 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 index 0966cf5..38cfea9 100644 --- a/internal/ping/icmp_linux.go +++ b/internal/ping/icmp_linux.go @@ -128,12 +128,20 @@ func (p *icmpPinger) Send(ctx context.Context) Result { return failedResult } - tSend, err := sendProbe(c.rc, wb, c.egressOOB, dst) + // 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 } - err = c.f.SetReadDeadline(deadline) + // 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 } @@ -347,7 +355,7 @@ func sendProbe(rc syscall.RawConn, wb, oob []byte, dst unix.Sockaddr) (time.Time tSend = time.Now() serr = unix.Sendmsg(int(fd), wb, oob, dst, 0) - return !errors.Is(serr, unix.EAGAIN) + return !retryable(serr) }) if werr != nil { return time.Time{}, werr @@ -394,7 +402,7 @@ func (pr icmpProbe) recv(rc syscall.RawConn, peer net.IP, tSend time.Time) Resul readErr := rc.Read(func(fd uintptr) bool { n, oobn, _, from, rerr = unix.Recvmsg(int(fd), buf, oob, 0) - return !retryRecv(rerr) + return !retryable(rerr) }) recvUser := time.Now() @@ -412,9 +420,10 @@ func (pr icmpProbe) recv(rc syscall.RawConn, peer net.IP, tSend time.Time) Resul } } -// retryRecv reports whether a recvmsg error means "wait for readability and try again" -// rather than fail: EAGAIN/EWOULDBLOCK (poller will re-arm) or an interrupted syscall. -func retryRecv(err error) bool { +// 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) } diff --git a/internal/ping/icmp_linux_test.go b/internal/ping/icmp_linux_test.go index 176bffb..5ff2b9d 100644 --- a/internal/ping/icmp_linux_test.go +++ b/internal/ping/icmp_linux_test.go @@ -161,7 +161,9 @@ func TestTimespecToTimeValidation(t *testing.T) { t.Fatal("zero timespec must be rejected") } - if _, ok := timespecToTime(tsBytes(unix.Timespec{Sec: 1, Nsec: int64(2 * time.Second)})); ok { + // 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") } From b6cfa52c6ea92827d31eab31f795db807df038ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B5=90=E5=8F=8B?= Date: Thu, 11 Jun 2026 17:00:57 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20direct=20ICMP(Linux)=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E7=AC=AC3=E3=83=A9=E3=82=A6?= =?UTF-8?q?=E3=83=B3=E3=83=89=20=E2=80=94=20ENOBUFS=E5=86=8D=E8=A9=A6?= =?UTF-8?q?=E8=A1=8C/TTL=E3=82=A8=E3=83=B3=E3=83=87=E3=82=A3=E3=82=A2?= =?UTF-8?q?=E3=83=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex の指摘のうち、到達しうる2点を修正。raw BPF フィルタ(C1)は見送り(下記)。 - ENOBUFS を一時障害として再試行(回帰修正): 出力キュー一時満杯時の Sendmsg は ENOBUFS を返すが、即 Failed では輻輳で偽のダウン判定になっていた。retryableSend に ENOBUFS を含め、送信前に張る write 込みの絶対デッドラインまで再試行する (pro-bing も ENOBUFS を continue で再試行)。持続的輻輳はデッドラインで X に落ちる。 - TTL/HopLimit cmsg をネイティブエンディアンで読む: IP_TTL/IPV6_HOPLIMIT cmsg は ネイティブエンディアンの C int。c.Data[0] はビッグエンディアン(s390x/ppc64)で 0 に なる。出荷対象は全て LE だが binary.NativeEndian.Uint32 で全アーキ正しく読む。 raw ソケットへの BPF id フィルタ(pro-bing InstallICMPIDFilter 相当)は見送り。 正当性はユーザー空間の照合(id+seq+token+src)で既に担保され、フィルタは root+多対象+async での fan-out 時の CPU/rcvbuf 節約=スケール最適化にすぎない。 一方、誤った BPF が attach 成功すると全 raw プローブが自分の応答を落とし「全ダウン」 誤報になる(best-effort attach でも防げない)。非 root の手元で検証不能かつ誤ると 沈黙故障のため未実装。理由は openICMPSocket にコメント化(将来 root 検証付き PR で追加可)。 検証: make check 0 issues、7 ターゲットクロスビルド(linux amd64/386/arm/arm64・ darwin・windows・freebsd)、race クリーン、manual(非 root datagram)非回帰。 --- internal/ping/icmp_linux.go | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/internal/ping/icmp_linux.go b/internal/ping/icmp_linux.go index 38cfea9..0b2cd43 100644 --- a/internal/ping/icmp_linux.go +++ b/internal/ping/icmp_linux.go @@ -5,6 +5,7 @@ package ping import ( "bytes" "context" + "encoding/binary" "errors" "net" "net/netip" @@ -314,6 +315,17 @@ func openICMPSocket(fam icmpFamily, sockType int) (int, error) { // 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 } @@ -355,7 +367,7 @@ func sendProbe(rc syscall.RawConn, wb, oob []byte, dst unix.Sockaddr) (time.Time tSend = time.Now() serr = unix.Sendmsg(int(fd), wb, oob, dst, 0) - return !retryable(serr) + return !retryableSend(serr) }) if werr != nil { return time.Time{}, werr @@ -428,6 +440,13 @@ func retryable(err error) bool { 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. @@ -524,8 +543,11 @@ func (fam icmpFamily) parseControl(oob []byte) (time.Time, bool, int) { } if int(c.Header.Level) == fam.ttlLevel && int(c.Header.Type) == fam.ttlCmsgType && - len(c.Data) > 0 { - ttl = int(c.Data[0]) + 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)) } } From 5a6efd8434395fc9bb19881d44f421bfd76d0272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B5=90=E5=8F=8B?= Date: Thu, 11 Jun 2026 17:58:36 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20zone=20=E3=81=AE=20int=E2=86=92uint3?= =?UTF-8?q?2=20=E5=A4=89=E6=8F=9B=E3=81=AB=E4=B8=8A=E9=99=90=E3=82=AC?= =?UTF-8?q?=E3=83=BC=E3=83=89(CodeQL=20go/incorrect-integer-conversion)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zoneIndex の strconv.Atoi は 64-bit int を返し、それが上限チェックなしで SockaddrInet6.ZoneId(uint32)へ変換されていた(%-1 や %4294967296 等の範囲外 zone で truncation/巨大値化)。設定由来で実害は小さいが CodeQL の指摘どおり修正: - zoneIndex: 数値 zone を [0, math.MaxInt32] に限定。範囲外は no-zone(0)へフォールバック。 - ipSockaddr: 変換直前にも同じ範囲ガードを置き int→uint32 を局所的に安全と証明可能に (//nolint:gosec を実ガードへ置換)。 - TestZoneIndex に範囲外(-1 / 2^32 / 超大)→0 のケースを追加。 make check 0 issues、linux 386/arm 含むクロスビルド維持。 --- internal/ping/icmp_linux.go | 13 +++++++++++-- internal/ping/icmp_linux_test.go | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/ping/icmp_linux.go b/internal/ping/icmp_linux.go index 0b2cd43..50fae44 100644 --- a/internal/ping/icmp_linux.go +++ b/internal/ping/icmp_linux.go @@ -7,6 +7,7 @@ import ( "context" "encoding/binary" "errors" + "math" "net" "net/netip" "os" @@ -197,8 +198,11 @@ func (p *icmpPinger) literalAddr(a netip.Addr) (net.IP, int, error) { // 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 { + if err == nil && idx >= 0 && idx <= math.MaxInt32 { return idx } @@ -594,7 +598,12 @@ func ipSockaddr(ip net.IP, zoneID int) (unix.Sockaddr, error) { copy(a[:], ip16) sa := &unix.SockaddrInet6{Addr: a} - sa.ZoneId = uint32(zoneID) //nolint:gosec // ifindex is non-negative. + // 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 } diff --git a/internal/ping/icmp_linux_test.go b/internal/ping/icmp_linux_test.go index 5ff2b9d..6956dee 100644 --- a/internal/ping/icmp_linux_test.go +++ b/internal/ping/icmp_linux_test.go @@ -243,6 +243,13 @@ func TestZoneIndex(t *testing.T) { 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;