Files
lan-manager/server/services/ping.go
shirainbown d34ed5842a feat: SSH 成功获取系统信息时视为机器在线
在连通性检测逻辑中,当 network ping / ICMP / TCP 三层探测均失败后,
若机器配置了 SSH 凭据,则同步尝试调用 GetSSHInfo 进行 SSH 信息采集:
- 若 SSH 采集成功,则将该机器判定为在线,并保存获取到的系统信息;
- 若 SSH 采集也失败,则保持离线状态,按原逻辑记录离线日志。

此改进可避免以下场景导致的误判:
- 机器禁 ping 或 ICMP 被防火墙拦截;
- SSH 端口未被网络层探测正确识别;
- 只要 SSH 服务正常且能正确登录并获取系统信息,即认为机器处于可用状态。

其他改动:
- 提取 saveSSHResult 辅助函数,统一保存 SSH 采集结果,避免重复代码。
- 同步更新了本地与 Linux 部署二进制文件。
2026-04-15 01:39:16 +08:00

311 lines
8.5 KiB
Go

package services
import (
"context"
"encoding/json"
"fmt"
"lan-manager/server/db"
"lan-manager/server/models"
"lan-manager/server/utils"
"net"
"os"
"os/exec"
"runtime"
"strings"
"time"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
)
// PingResult holds connectivity status and failure reason
type PingResult struct {
Online bool
Reason string // empty if online, otherwise human-readable failure reason
}
func PingHost(ip string, port int) PingResult {
if ok := pingWithSystem(ip); ok {
return PingResult{Online: true}
}
if ok := pingWithICMP(ip); ok {
return PingResult{Online: true}
}
if ok := pingWithTCP(ip, port); ok {
return PingResult{Online: true}
}
return PingResult{Online: false, Reason: "unreachable (ping/icmp/tcp all failed)"}
}
// PingHostRepeated pings the host up to retries times.
// If any attempt succeeds, it returns online immediately.
// Only if all retries fail does it return offline.
func PingHostRepeated(ip string, port int, retries int) PingResult {
if retries <= 0 {
retries = 1
}
var lastReason string
for i := 0; i < retries; i++ {
if i > 0 {
time.Sleep(2 * time.Second)
}
res := PingHost(ip, port)
if res.Online {
return res
}
lastReason = res.Reason
}
return PingResult{Online: false, Reason: fmt.Sprintf("unreachable after %d retries (%s)", retries, lastReason)}
}
func pingWithSystem(ip string) bool {
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("ping", "-n", "1", "-w", "5000", ip)
} else if runtime.GOOS == "darwin" {
cmd = exec.Command("ping", "-c", "1", "-W", "5000", ip)
} else {
cmd = exec.Command("ping", "-c", "1", "-W", "5", ip)
}
out, err := cmd.CombinedOutput()
if err != nil {
if _, ok := err.(*exec.ExitError); !ok {
fmt.Printf("[Ping] system ping error for %s: %v, output: %s\n", ip, err, strings.TrimSpace(string(out)))
}
return false
}
return true
}
func pingWithICMP(ip string) bool {
c, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return false
}
defer c.Close()
id := os.Getpid() & 0xffff
m := &icmp.Message{
Type: ipv4.ICMPTypeEcho,
Code: 0,
Body: &icmp.Echo{ID: id, Seq: 1, Data: []byte("lan-manager")},
}
wb, err := m.Marshal(nil)
if err != nil {
return false
}
if _, err := c.WriteTo(wb, &net.IPAddr{IP: parseIP(ip)}); err != nil {
return false
}
rb := make([]byte, 1500)
_ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
n, _, err := c.ReadFrom(rb)
if err != nil {
return false
}
rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply.Protocol(), rb[:n])
if err != nil {
return false
}
return rm.Type == ipv4.ICMPTypeEchoReply
}
func pingWithTCP(ip string, port int) bool {
if port <= 0 {
port = 22
}
addr := fmt.Sprintf("%s:%d", ip, port)
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return false
}
_ = conn.Close()
return true
}
func parseIP(ip string) []byte {
parts := strings.Split(ip, ".")
if len(parts) != 4 {
return nil
}
res := make([]byte, 4)
for i, p := range parts {
var v byte
fmt.Sscanf(p, "%d", &v)
res[i] = v
}
return res
}
type machinePing struct {
id int64
ip string
sshPort int
sshUsername string
sshPassword string
wasOnline bool
}
func saveSSHResult(mid int64, mip string, mport int, result *models.SSHInfoResult) {
portsStr := ""
if len(result.ListenPorts) > 0 {
b, _ := json.Marshal(result.ListenPorts)
portsStr = string(b)
if len(portsStr) > 2 {
portsStr = portsStr[1 : len(portsStr)-1]
}
}
_, dbErr := db.DB.Exec(`UPDATE machines SET cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
result.RawCPUInfo, result.RawMemoryInfo, result.RawDiskInfo, result.Uptime, portsStr, mid)
if dbErr != nil {
fmt.Printf("[SSH] auto-fetch db error for %s:%d: %v\n", mip, mport, dbErr)
} else {
fmt.Printf("[SSH] auto-fetch success for %s:%d\n", mip, mport)
}
}
func handlePingResult(m *machinePing, res PingResult) {
online := res.Online
reason := res.Reason
var sshResult *models.SSHInfoResult
// 如果网络层检测失败,尝试通过 SSH 获取系统信息来判定在线
if !online && m.sshUsername != "" && m.sshPassword != "" {
plainPass, decryptErr := utils.Decrypt(m.sshPassword)
if decryptErr == nil {
result, err := GetSSHInfo(m.ip, m.sshPort, m.sshUsername, plainPass)
if err == nil {
online = true
reason = ""
sshResult = result
fmt.Printf("[Ping] %s -> network unreachable, but SSH info fetched successfully, treating as online\n", m.ip)
}
}
}
var onlineInt int
if online {
onlineInt = 1
}
// State transition: online -> offline
if m.wasOnline && !online {
_, dbErr := db.DB.Exec(`UPDATE machines SET is_online=0, last_ping_at=CURRENT_TIMESTAMP, offline_count=offline_count+1, last_offline_at=CURRENT_TIMESTAMP, last_offline_reason=? WHERE id=?`,
reason, m.id)
if dbErr != nil {
fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr)
} else {
fmt.Printf("[Ping] %s -> online=false, reason=%s\n", m.ip, reason)
}
_, _ = db.DB.Exec(`INSERT INTO offline_logs (machine_id, reason, started_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, m.id, reason)
m.wasOnline = false
return
}
// State transition: offline -> online
if !m.wasOnline && online {
_, dbErr := db.DB.Exec(`UPDATE machines SET is_online=1, last_ping_at=CURRENT_TIMESTAMP, last_offline_reason='' WHERE id=?`, m.id)
if dbErr != nil {
fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr)
} else {
fmt.Printf("[Ping] %s -> online=true\n", m.ip)
}
var logID int64
var startedAt time.Time
row := db.DB.QueryRow(`SELECT id, started_at FROM offline_logs WHERE machine_id=? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1`, m.id)
if err := row.Scan(&logID, &startedAt); err == nil {
duration := int(time.Since(startedAt).Seconds())
_, _ = db.DB.Exec(`UPDATE offline_logs SET ended_at=CURRENT_TIMESTAMP, duration_seconds=? WHERE id=?`, duration, logID)
_, _ = db.DB.Exec(`UPDATE machines SET total_offline_seconds=total_offline_seconds+? WHERE id=?`, duration, m.id)
}
m.wasOnline = true
if sshResult != nil {
saveSSHResult(m.id, m.ip, m.sshPort, sshResult)
}
return
}
// No state change
_, dbErr := db.DB.Exec(`UPDATE machines SET is_online=?, last_ping_at=CURRENT_TIMESTAMP WHERE id=?`, onlineInt, m.id)
if dbErr != nil {
fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr)
} else {
fmt.Printf("[Ping] %s -> online=%v\n", m.ip, online)
}
if online && m.sshUsername != "" && m.sshPassword != "" {
if sshResult != nil {
saveSSHResult(m.id, m.ip, m.sshPort, sshResult)
} else {
go func(mid int64, mip string, mport int, muser, mpass string) {
plainPass, decryptErr := utils.Decrypt(mpass)
if decryptErr != nil {
fmt.Printf("[SSH] decrypt failed for %s:%d: %v\n", mip, mport, decryptErr)
return
}
result, err := GetSSHInfo(mip, mport, muser, plainPass)
if err != nil {
fmt.Printf("[SSH] auto-fetch failed for %s:%d: %v\n", mip, mport, err)
return
}
saveSSHResult(mid, mip, mport, result)
}(m.id, m.ip, m.sshPort, m.sshUsername, m.sshPassword)
}
}
}
func StartPingService(interval int) {
const step = 30 * time.Second
go func() {
time.Sleep(2 * time.Second)
for {
rows, err := db.DB.Query(`SELECT id, ip, ssh_port, ssh_username, ssh_password, is_online FROM machines ORDER BY id ASC`)
if err != nil {
fmt.Printf("[Ping] query error: %v\n", err)
time.Sleep(step)
continue
}
list := []machinePing{}
for rows.Next() {
var m machinePing
var isOnline int
if err := rows.Scan(&m.id, &m.ip, &m.sshPort, &m.sshUsername, &m.sshPassword, &isOnline); err == nil {
m.wasOnline = isOnline == 1
list = append(list, m)
}
}
rows.Close()
fmt.Printf("[Ping] start round, %d machines, interval %v each\n", len(list), step)
for i := range list {
res := PingHostRepeated(list[i].ip, list[i].sshPort, 3)
handlePingResult(&list[i], res)
time.Sleep(step)
}
}
}()
}
func CleanupLogs(ctx context.Context, retentionDays int) {
if retentionDays <= 0 {
return
}
ticker := time.NewTicker(24 * time.Hour)
go func() {
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_, _ = db.DB.Exec(`DELETE FROM operation_logs WHERE created_at < datetime('now', '-` + fmt.Sprintf("%d", retentionDays) + ` days')`)
_, _ = db.DB.Exec(`DELETE FROM offline_logs WHERE started_at < datetime('now', '-` + fmt.Sprintf("%d", retentionDays) + ` days')`)
}
}
}()
}