Windows 下 ICMP 探测改用 ping.exe,避开 raw socket 权限限制

pro-bing 在 Windows 上必须 SetPrivileged(true) 走原始套接字,普通账号
没有权限会直接失败。按平台拆分实现:unix 继续走 pro-bing,windows 调
系统 ping 并以 TTL= 作为跨语言锚点解析输出;同时校验 target 防止被
ping.exe 当作 flag 解析。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dushixiangandClaude Opus 4.7 committed 2026-05-06 23:39:40 +08:00
1 parent 304b113c3b
commit fe388df67d
3 files changed
+153 -34

No files matched your search

+37 -34
View File
@@ -10,8 +10,6 @@ import (
"strings" "strings"
"time" "time"
probing "github.com/prometheus-community/pro-bing"
"github.com/dushixiang/pika/internal/protocol" "github.com/dushixiang/pika/internal/protocol"
) )
@@ -245,10 +243,9 @@ func (c *MonitorCollector) checkICMP(item protocol.MonitorItem) protocol.Monitor
CheckedAt: time.Now().UnixMilli(), CheckedAt: time.Now().UnixMilli(),
} }
// 获取配置,使用默认值
icmpCfg := item.ICMPConfig icmpCfg := item.ICMPConfig
timeout := 5 // 默认 5 秒 timeout := 5
count := 4 // 默认 Ping 4 次 count := 4
if icmpCfg != nil { if icmpCfg != nil {
if icmpCfg.Timeout > 0 { if icmpCfg.Timeout > 0 {
timeout = icmpCfg.Timeout timeout = icmpCfg.Timeout
@@ -258,45 +255,25 @@ func (c *MonitorCollector) checkICMP(item protocol.MonitorItem) protocol.Monitor
} }
} }
// 创建 Pinger if !isValidPingTarget(item.Target) {
pinger, err := probing.NewPinger(item.Target)
if err != nil {
result.Status = "down" result.Status = "down"
result.Error = fmt.Sprintf("create pinger failed: %v", err) result.Error = fmt.Sprintf("invalid ping target: %s", item.Target)
return result return result
} }
// 配置 Pinger stats, err := pingHost(item.Target, count, timeout)
pinger.Count = count
pinger.Timeout = time.Duration(timeout) * time.Second
pinger.Interval = 100 * time.Millisecond
// 尝试以非特权模式运行(使用 UDP)
pinger.SetPrivileged(false)
// 执行 Ping
err = pinger.Run()
if err != nil { if err != nil {
// 如果非特权模式失败,尝试特权模式(需要 root 权限或 CAP_NET_RAW) result.Status = "down"
pinger.SetPrivileged(true) result.Error = fmt.Sprintf("ping failed: %v", err)
err = pinger.Run() return result
if err != nil {
result.Status = "down"
result.Error = fmt.Sprintf("ping failed: %v", err)
return result
}
} }
// 获取统计信息
stats := pinger.Statistics()
// 检查是否有成功的包
if stats.PacketsRecv > 0 { if stats.PacketsRecv > 0 {
result.Status = "up" result.Status = "up"
result.ResponseTime = stats.AvgRtt.Milliseconds() result.ResponseTime = stats.AvgRttMs
packetLoss := int(stats.PacketLoss) loss := int(stats.PacketLoss)
result.Message = fmt.Sprintf("ICMP Echo Reply - %d/%d packets, %dms avg, %d%% loss", result.Message = fmt.Sprintf("ICMP Echo Reply - %d/%d packets, %dms avg, %d%% loss",
stats.PacketsRecv, stats.PacketsSent, stats.AvgRtt.Milliseconds(), packetLoss) stats.PacketsRecv, stats.PacketsSent, stats.AvgRttMs, loss)
} else { } else {
result.Status = "down" result.Status = "down"
result.Error = fmt.Sprintf("all %d ping attempts failed (timeout: %ds)", count, timeout) result.Error = fmt.Sprintf("all %d ping attempts failed (timeout: %ds)", count, timeout)
@@ -305,3 +282,29 @@ func (c *MonitorCollector) checkICMP(item protocol.MonitorItem) protocol.Monitor
return result return result
} }
// pingStats 跨平台 ping 统计结果
type pingStats struct {
PacketsSent int
PacketsRecv int
AvgRttMs int64
PacketLoss float64
}
// isValidPingTarget 校验 ping 目标,避免在 Windows 下被 ping.exe 解析为 flag 或注入额外参数
func isValidPingTarget(target string) bool {
if target == "" || strings.HasPrefix(target, "-") {
return false
}
for _, r := range target {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '.', r == ':', r == '-', r == '_':
default:
return false
}
}
return true
}
+37
View File
@@ -0,0 +1,37 @@
//go:build !windows
package collector
import (
"time"
probing "github.com/prometheus-community/pro-bing"
)
func pingHost(target string, count, timeoutSec int) (*pingStats, error) {
pinger, err := probing.NewPinger(target)
if err != nil {
return nil, err
}
pinger.Count = count
pinger.Timeout = time.Duration(timeoutSec) * time.Second
pinger.Interval = 100 * time.Millisecond
pinger.SetPrivileged(false)
if err := pinger.Run(); err != nil {
// 非特权 UDP ICMP 不可用时回退到 raw socket(需要 root / CAP_NET_RAW)
pinger.SetPrivileged(true)
if err := pinger.Run(); err != nil {
return nil, err
}
}
s := pinger.Statistics()
return &pingStats{
PacketsSent: s.PacketsSent,
PacketsRecv: s.PacketsRecv,
AvgRttMs: s.AvgRtt.Milliseconds(),
PacketLoss: s.PacketLoss,
}, nil
}
@@ -0,0 +1,79 @@
//go:build windows
package collector
import (
"bytes"
"context"
"fmt"
"os/exec"
"regexp"
"strconv"
"syscall"
"time"
)
// "TTL=" 在 Windows ping.exe 各语言版本输出中都保留为英文字面量,
// 因此用作回包行的锚点比 "Reply from" / "来自" 之类本地化文案更稳妥
var (
winPingReplyAnchor = regexp.MustCompile(`(?i)TTL[=:]`)
winPingTimeRe = regexp.MustCompile(`(?i)[=<]\s*(\d+)\s*ms`)
)
func pingHost(target string, count, timeoutSec int) (*pingStats, error) {
if count <= 0 {
count = 4
}
if timeoutSec <= 0 {
timeoutSec = 5
}
perReplyMs := timeoutSec * 1000
// 进程级超时取每包超时 × 包数 + 余量;ping 自身在包间还会停 ~1s
overall := time.Duration(timeoutSec*count+5) * time.Second
ctx, cancel := context.WithTimeout(context.Background(), overall)
defer cancel()
cmd := exec.CommandContext(ctx, "ping",
"-n", strconv.Itoa(count),
"-w", strconv.Itoa(perReplyMs),
target,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW,避免在前台 console 下闪窗
}
out, runErr := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("ping command timed out after %v", overall)
}
if runErr != nil && len(out) == 0 {
return nil, fmt.Errorf("ping command failed: %w", runErr)
}
stats := &pingStats{PacketsSent: count}
var totalRtt int64
for _, line := range bytes.Split(out, []byte("\n")) {
if !winPingReplyAnchor.Match(line) {
continue
}
m := winPingTimeRe.FindSubmatch(line)
if m == nil {
continue
}
rtt, err := strconv.Atoi(string(m[1]))
if err != nil {
continue
}
stats.PacketsRecv++
totalRtt += int64(rtt)
}
if stats.PacketsRecv > 0 {
stats.AvgRttMs = totalRtt / int64(stats.PacketsRecv)
}
stats.PacketLoss = float64(stats.PacketsSent-stats.PacketsRecv) * 100 / float64(stats.PacketsSent)
return stats, nil
}