Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 は宛先のスコープに合わせて自動選択されます)。
Expand Down
73 changes: 1 addition & 72 deletions internal/ping/icmp.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
package ping

import (
"context"
"net"
"runtime"
"sync"
"time"

probing "github.com/prometheus-community/pro-bing"
"golang.org/x/net/icmp"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
}
116 changes: 116 additions & 0 deletions internal/ping/icmp_bench_manual_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading