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>
311 lines
7.3 KiB
Go
311 lines
7.3 KiB
Go
package collector
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dushixiang/pika/internal/protocol"
|
|
)
|
|
|
|
// MonitorCollector 监控采集器
|
|
type MonitorCollector struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewMonitorCollector 创建监控采集器
|
|
func NewMonitorCollector() *MonitorCollector {
|
|
// 创建自定义的 HTTP 客户端,支持跳过 TLS 验证
|
|
httpClient := &http.Client{
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: true, // 允许自签名证书
|
|
},
|
|
DisableKeepAlives: true,
|
|
},
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
// 限制重定向次数为 10
|
|
if len(via) >= 10 {
|
|
return fmt.Errorf("stopped after 10 redirects")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
return &MonitorCollector{
|
|
httpClient: httpClient,
|
|
}
|
|
}
|
|
|
|
// Collect 采集所有监控项数据
|
|
func (c *MonitorCollector) Collect(items []protocol.MonitorItem) []protocol.MonitorData {
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
|
|
results := make([]protocol.MonitorData, 0, len(items))
|
|
|
|
for _, item := range items {
|
|
var result protocol.MonitorData
|
|
|
|
switch strings.ToLower(item.Type) {
|
|
case "http", "https":
|
|
result = c.checkHTTP(item)
|
|
case "tcp":
|
|
result = c.checkTCP(item)
|
|
case "icmp", "ping":
|
|
result = c.checkICMP(item)
|
|
default:
|
|
result = protocol.MonitorData{
|
|
MonitorId: item.ID,
|
|
Type: item.Type,
|
|
Target: item.Target,
|
|
Status: "down",
|
|
Error: fmt.Sprintf("unsupported monitor type: %s", item.Type),
|
|
CheckedAt: time.Now().UnixMilli(),
|
|
}
|
|
}
|
|
|
|
results = append(results, result)
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// checkHTTP 检查 HTTP/HTTPS 服务
|
|
func (c *MonitorCollector) checkHTTP(item protocol.MonitorItem) protocol.MonitorData {
|
|
result := protocol.MonitorData{
|
|
MonitorId: item.ID,
|
|
Type: item.Type,
|
|
Target: item.Target,
|
|
CheckedAt: time.Now().UnixMilli(),
|
|
}
|
|
|
|
// 获取配置,使用默认值
|
|
httpCfg := item.HTTPConfig
|
|
if httpCfg == nil {
|
|
httpCfg = &protocol.HTTPMonitorConfig{
|
|
Method: "GET",
|
|
ExpectedStatusCode: 200,
|
|
Timeout: 60,
|
|
}
|
|
}
|
|
|
|
// 设置默认值
|
|
method := httpCfg.Method
|
|
if method == "" {
|
|
method = "GET"
|
|
}
|
|
|
|
timeout := httpCfg.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 60
|
|
}
|
|
|
|
expectedStatus := httpCfg.ExpectedStatusCode
|
|
if expectedStatus == 0 {
|
|
expectedStatus = 200
|
|
}
|
|
|
|
// 创建请求
|
|
var bodyReader io.Reader
|
|
if httpCfg.Body != "" {
|
|
bodyReader = strings.NewReader(httpCfg.Body)
|
|
}
|
|
|
|
// 为请求创建带超时的上下文
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
|
defer cancel()
|
|
|
|
// 为请求添加上下文
|
|
req, err := http.NewRequestWithContext(ctx, method, item.Target, bodyReader)
|
|
if err != nil {
|
|
result.Status = "down"
|
|
result.Error = fmt.Sprintf("create request failed: %v", err)
|
|
return result
|
|
}
|
|
|
|
// 设置请求头
|
|
if httpCfg.Headers != nil {
|
|
for key, value := range httpCfg.Headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
}
|
|
|
|
// 发送请求并计时
|
|
startTime := time.Now()
|
|
resp, err := c.httpClient.Do(req)
|
|
responseTime := time.Since(startTime).Milliseconds()
|
|
result.ResponseTime = responseTime
|
|
|
|
if err != nil {
|
|
result.Status = "down"
|
|
result.Error = fmt.Sprintf("request failed: %v", err)
|
|
return result
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
result.StatusCode = resp.StatusCode
|
|
|
|
// 检查状态码
|
|
if resp.StatusCode != expectedStatus {
|
|
result.Status = "down"
|
|
result.Error = fmt.Sprintf("status code mismatch: expected %d, got %d", expectedStatus, resp.StatusCode)
|
|
result.Message = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
|
return result
|
|
}
|
|
|
|
// 检查响应内容(如果有配置)
|
|
if httpCfg.ExpectedContent != "" {
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
result.Status = "down"
|
|
result.Error = fmt.Sprintf("read response body failed: %v", err)
|
|
return result
|
|
}
|
|
|
|
bodyStr := string(body)
|
|
if !strings.Contains(bodyStr, httpCfg.ExpectedContent) {
|
|
result.Status = "down"
|
|
result.Error = fmt.Sprintf("content does not contain expected string: %s", httpCfg.ExpectedContent)
|
|
result.ContentMatch = false
|
|
return result
|
|
}
|
|
result.ContentMatch = true
|
|
}
|
|
|
|
// 获取 HTTPS 证书信息
|
|
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
|
|
// 获取第一个证书(服务器证书)
|
|
cert := resp.TLS.PeerCertificates[0]
|
|
|
|
// 证书过期时间
|
|
expiryTime := cert.NotAfter
|
|
result.CertExpiryTime = expiryTime.UnixMilli()
|
|
|
|
// 计算剩余天数
|
|
daysLeft := int(time.Until(expiryTime).Hours() / 24)
|
|
result.CertDaysLeft = daysLeft
|
|
}
|
|
|
|
// 检查成功
|
|
result.Status = "up"
|
|
result.Message = fmt.Sprintf("HTTP %d - %dms", resp.StatusCode, responseTime)
|
|
return result
|
|
}
|
|
|
|
// checkTCP 检查 TCP 端口
|
|
func (c *MonitorCollector) checkTCP(item protocol.MonitorItem) protocol.MonitorData {
|
|
result := protocol.MonitorData{
|
|
MonitorId: item.ID,
|
|
Type: item.Type,
|
|
Target: item.Target,
|
|
CheckedAt: time.Now().UnixMilli(),
|
|
}
|
|
|
|
// 获取配置,使用默认值
|
|
tcpCfg := item.TCPConfig
|
|
timeout := 10 // 默认 10 秒
|
|
if tcpCfg != nil && tcpCfg.Timeout > 0 {
|
|
timeout = tcpCfg.Timeout
|
|
}
|
|
|
|
// 连接并计时
|
|
startTime := time.Now()
|
|
conn, err := net.DialTimeout("tcp", item.Target, time.Duration(timeout)*time.Second)
|
|
responseTime := time.Since(startTime).Milliseconds()
|
|
result.ResponseTime = responseTime
|
|
|
|
if err != nil {
|
|
result.Status = "down"
|
|
result.Error = fmt.Sprintf("connection failed: %v", err)
|
|
return result
|
|
}
|
|
defer conn.Close()
|
|
|
|
// 连接成功
|
|
result.Status = "up"
|
|
result.Message = fmt.Sprintf("TCP connected - %dms", responseTime)
|
|
return result
|
|
}
|
|
|
|
// checkICMP 检查 ICMP (Ping)
|
|
func (c *MonitorCollector) checkICMP(item protocol.MonitorItem) protocol.MonitorData {
|
|
result := protocol.MonitorData{
|
|
MonitorId: item.ID,
|
|
Type: item.Type,
|
|
Target: item.Target,
|
|
CheckedAt: time.Now().UnixMilli(),
|
|
}
|
|
|
|
icmpCfg := item.ICMPConfig
|
|
timeout := 5
|
|
count := 4
|
|
if icmpCfg != nil {
|
|
if icmpCfg.Timeout > 0 {
|
|
timeout = icmpCfg.Timeout
|
|
}
|
|
if icmpCfg.Count > 0 {
|
|
count = icmpCfg.Count
|
|
}
|
|
}
|
|
|
|
if !isValidPingTarget(item.Target) {
|
|
result.Status = "down"
|
|
result.Error = fmt.Sprintf("invalid ping target: %s", item.Target)
|
|
return result
|
|
}
|
|
|
|
stats, err := pingHost(item.Target, count, timeout)
|
|
if err != nil {
|
|
result.Status = "down"
|
|
result.Error = fmt.Sprintf("ping failed: %v", err)
|
|
return result
|
|
}
|
|
|
|
if stats.PacketsRecv > 0 {
|
|
result.Status = "up"
|
|
result.ResponseTime = stats.AvgRttMs
|
|
loss := int(stats.PacketLoss)
|
|
result.Message = fmt.Sprintf("ICMP Echo Reply - %d/%d packets, %dms avg, %d%% loss",
|
|
stats.PacketsRecv, stats.PacketsSent, stats.AvgRttMs, loss)
|
|
} else {
|
|
result.Status = "down"
|
|
result.Error = fmt.Sprintf("all %d ping attempts failed (timeout: %ds)", count, timeout)
|
|
result.Message = "100% packet loss"
|
|
}
|
|
|
|
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
|
|
}
|