fix: 防止 SSH 系统信息同步覆盖自定义主机名,优化 Debian 12 兼容性
核心修复: - 后端 SyncSSH 接口彻底移除 hostname 字段更新,仅保留 os_version、cpu_info、 memory_info、disk_info、uptime、listen_ports 的系统信息同步。 - 前端 MachineDetail.vue 在 openSSH(自动获取)和 fetchSSH(手动输入密码) 两条路径均增加 delete syncData.hostname 保护,确保即使误传也不会覆盖。 - 重新编译后端二进制并重启服务,解决了旧二进制残留 hostname 更新逻辑的问题。 SSH 采集兼容性优化(server/services/ssh.go): - CPU:将复杂 shell 脚本改为 Go 层两次读取 /proc/stat 并计算利用率,避免 Debian 12 dash 对引号嵌套解析失败。 - 内存:改用 /proc/meminfo 替代 free -m,兼容最小化/容器环境。 - 磁盘:简化 df -hP 过滤,仅保留真实块设备( ~ /^/dev/)。 - 端口:增加 ss → netstat → /proc/net/tcp 三级回退,确保任何 Linux 环境 都能获取监听端口。 - 系统版本:采用 . /etc/os-release && printf '%s\n' 方式, 兼容所有 Linux 发行版。 构建与部署: - 重新构建前端并更新 server/static/。 - 交叉编译 Linux amd64 二进制并更新 deploy/lan-manager-debian12.tar.gz。 - 恢复数据库中被污染的主机名记录。
This commit is contained in:
Binary file not shown.
@@ -354,8 +354,8 @@ func (h *MachineHandler) SyncSSH(c *gin.Context) {
|
||||
portsStr = portsStr[1 : len(portsStr)-1]
|
||||
}
|
||||
}
|
||||
_, err = 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=?`,
|
||||
req.RawCPUInfo, req.RawMemoryInfo, req.RawDiskInfo, req.Uptime, portsStr, id)
|
||||
_, err = db.DB.Exec(`UPDATE machines SET os_version=?, cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
|
||||
req.OsVersion, req.RawCPUInfo, req.RawMemoryInfo, req.RawDiskInfo, req.Uptime, portsStr, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"lan-manager/server/config"
|
||||
"lan-manager/server/models"
|
||||
"strconv"
|
||||
@@ -38,24 +39,15 @@ func GetSSHInfo(ip string, port int, user, pass string) (*models.SSHInfoResult,
|
||||
result.Hostname = strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// OS Version
|
||||
if out, err := runSSHCommand(client, "cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2 | tr -d '\"'"); err == nil && out != "" {
|
||||
// OS Version: source /etc/os-release is the most compatible way across Linux distros
|
||||
if out, err := runSSHCommand(client, `. /etc/os-release 2>/dev/null && printf '%s\n' "$PRETTY_NAME"`); err == nil && out != "" {
|
||||
result.OsVersion = strings.TrimSpace(out)
|
||||
} else if out, err := runSSHCommand(client, "uname -sr"); err == nil {
|
||||
result.OsVersion = strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// CPU (two samples from /proc/stat for accurate real-time usage)
|
||||
cpuScript := `c1=$(awk '/^cpu /{print $2+$4,$2+$4+$5}' /proc/stat) && sleep 1 && c2=$(awk '/^cpu /{print $2+$4,$2+$4+$5}' /proc/stat) && awk 'BEGIN{split("'"$c1"'", a, " "); split("'"$c2"'", b, " "); if(b[2]-a[2]>0) printf "%.1f", 100*(b[1]-a[1])/(b[2]-a[2]); else print "0.0"}'`
|
||||
if out, err := runSSHCommand(client, cpuScript); err == nil && out != "" {
|
||||
cpuUsage := strings.TrimSpace(out)
|
||||
if cpuUsage == "" {
|
||||
cpuUsage = "0.0"
|
||||
}
|
||||
cores := ""
|
||||
if cout, err := runSSHCommand(client, "nproc"); err == nil {
|
||||
cores = strings.TrimSpace(cout)
|
||||
}
|
||||
// CPU: read /proc/stat twice with 1s sleep in Go to avoid complex shell quoting
|
||||
if cpuUsage, cores, err := getCPUInfo(client); err == nil {
|
||||
result.CPU = cpuUsage + "%"
|
||||
if cores != "" {
|
||||
result.CPU += " (" + cores + " cores)"
|
||||
@@ -63,15 +55,14 @@ func GetSSHInfo(ip string, port int, user, pass string) (*models.SSHInfoResult,
|
||||
result.RawCPUInfo = result.CPU
|
||||
}
|
||||
|
||||
// Memory
|
||||
if out, err := runSSHCommand(client, "free -m | awk 'NR==2{printf \"%.1f/%.1f (%.0f%%)\", $3/1024,$2/1024,$3*100/$2 }'"); err == nil && out != "" {
|
||||
// Memory: use /proc/meminfo instead of free for better compatibility
|
||||
if out, err := runSSHCommand(client, "awk '/MemTotal/{t=$2} /MemAvailable/{a=$2} END{u=t-a; if(t>0) printf \"%.1f/%.1f (%.0f%%)\", u/1024/1024, t/1024/1024, u*100/t}' /proc/meminfo"); err == nil && out != "" {
|
||||
result.Memory = strings.TrimSpace(out)
|
||||
result.RawMemoryInfo = result.Memory
|
||||
}
|
||||
|
||||
// Disk (exclude virtual filesystems, keep all real partitions)
|
||||
// Use df -hP to prevent long filesystem names from wrapping to next line
|
||||
if out, err := runSSHCommand(client, "df -hP | awk 'NR>1 && $1 !~ /^(tmpfs|devtmpfs|squashfs|overlay|efivarfs|tracefs|securityfs|pstore|bpf|configfs|fusectl|mqueue|hugetlbfs|rpc_pipefs|nfsd|binfmt_misc|autofs|devpts|proc|sysfs|cgroup|cgroup2|ramfs)$/ {printf \"%s %s/%s (%s) \", $6,$3,$2,$5}'"); err == nil && out != "" {
|
||||
// Disk: keep real block devices only, exclude all virtual filesystems
|
||||
if out, err := runSSHCommand(client, "df -hP | awk 'NR>1 && ($1 ~ /^\\/dev/ || $1 ~ /^(LABEL=|UUID=)/) {printf \"%s %s/%s (%s) \", $6,$3,$2,$5}'"); err == nil && out != "" {
|
||||
result.Disk = strings.TrimSpace(out)
|
||||
result.RawDiskInfo = result.Disk
|
||||
}
|
||||
@@ -81,21 +72,9 @@ func GetSSHInfo(ip string, port int, user, pass string) (*models.SSHInfoResult,
|
||||
result.Uptime = strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// Listen ports
|
||||
if out, err := runSSHCommand(client, "ss -tlnp | awk 'NR>1 {print $4}' | sed 's/.*://' | sort -n | uniq"); err == nil && out != "" {
|
||||
ports := []int{}
|
||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
p := strings.TrimSpace(scanner.Text())
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if pi, err := strconv.Atoi(p); err == nil {
|
||||
ports = append(ports, pi)
|
||||
}
|
||||
}
|
||||
result.ListenPorts = ports
|
||||
}
|
||||
// Listen ports: try ss first, fallback to netstat, fallback to /proc/net/tcp
|
||||
ports, _ := getListenPorts(client)
|
||||
result.ListenPorts = ports
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -111,3 +90,109 @@ func runSSHCommand(client *ssh.Client, cmd string) (string, error) {
|
||||
err = session.Run(cmd)
|
||||
return b.String(), err
|
||||
}
|
||||
|
||||
func getCPUInfo(client *ssh.Client) (usage string, cores string, err error) {
|
||||
readStat := func() (idle, total float64, e error) {
|
||||
out, e := runSSHCommand(client, "awk '/^cpu /{print $2,$3,$4,$5}' /proc/stat")
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
parts := strings.Fields(strings.TrimSpace(out))
|
||||
if len(parts) < 4 {
|
||||
return 0, 0, fmt.Errorf("unexpected /proc/stat format")
|
||||
}
|
||||
vals := make([]float64, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
v, e := strconv.ParseFloat(parts[i], 64)
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
vals[i] = v
|
||||
}
|
||||
user, nice, system, idleTick := vals[0], vals[1], vals[2], vals[3]
|
||||
idle = idleTick
|
||||
total = user + nice + system + idleTick
|
||||
return idle, total, nil
|
||||
}
|
||||
|
||||
idle1, total1, err := readStat()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
idle2, total2, err := readStat()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
dTotal := total2 - total1
|
||||
dIdle := idle2 - idle1
|
||||
if dTotal > 0 {
|
||||
pct := 100.0 * (dTotal - dIdle) / dTotal
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
}
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
usage = fmt.Sprintf("%.1f", pct)
|
||||
} else {
|
||||
usage = "0.0"
|
||||
}
|
||||
|
||||
if cout, e := runSSHCommand(client, "nproc"); e == nil {
|
||||
cores = strings.TrimSpace(cout)
|
||||
}
|
||||
return usage, cores, nil
|
||||
}
|
||||
|
||||
func getListenPorts(client *ssh.Client) ([]int, error) {
|
||||
var out string
|
||||
var err error
|
||||
|
||||
// try ss without -p (no need for process info, more compatible)
|
||||
out, err = runSSHCommand(client, "ss -tln 2>/dev/null | awk 'NR>1 {print $4}' | sed 's/.*://' | sort -n | uniq")
|
||||
if err != nil || strings.TrimSpace(out) == "" {
|
||||
// fallback to netstat
|
||||
out, err = runSSHCommand(client, "netstat -tln 2>/dev/null | awk 'NR>2 {print $4}' | sed 's/.*://' | sort -n | uniq")
|
||||
}
|
||||
if err != nil || strings.TrimSpace(out) == "" {
|
||||
// fallback to /proc/net/tcp
|
||||
out, err = runSSHCommand(client, "awk 'NR>1 {printf \"%04X\\n\", strtonum(\"0x\"$2)}' /proc/net/tcp")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ports := []int{}
|
||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
p := strings.TrimSpace(scanner.Text())
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
// handle both decimal ports and hex ports from /proc/net/tcp
|
||||
var pi int
|
||||
var e error
|
||||
if len(p) == 4 && isHex(p) {
|
||||
var h uint64
|
||||
h, e = strconv.ParseUint(p, 16, 32)
|
||||
pi = int(h)
|
||||
} else {
|
||||
pi, e = strconv.Atoi(p)
|
||||
}
|
||||
if e == nil && pi > 0 {
|
||||
ports = append(ports, pi)
|
||||
}
|
||||
}
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
func isHex(s string) bool {
|
||||
for _, c := range s {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -422,7 +422,10 @@ async function openSSH() {
|
||||
try {
|
||||
const res = await sshInfo(machineId, { username: machine.value.ssh_username, password: '' })
|
||||
sshResult.value = res.data
|
||||
await syncSSH(machineId, sshResult.value)
|
||||
// 保护用户自定义的主机名,不将 SSH 采集到的 hostname 写入数据库
|
||||
const syncData = { ...sshResult.value }
|
||||
delete syncData.hostname
|
||||
await syncSSH(machineId, syncData)
|
||||
ElMessage.success('获取并同步成功')
|
||||
await loadMachine()
|
||||
sshLoading.value = false
|
||||
@@ -461,7 +464,10 @@ async function fetchSSH() {
|
||||
const res = await sshInfo(machineId, sshForm.value)
|
||||
sshResult.value = res.data
|
||||
sshDialogVisible.value = false
|
||||
await syncSSH(machineId, sshResult.value)
|
||||
// 保护用户自定义的主机名,不将 SSH 采集到的 hostname 写入数据库
|
||||
const syncData = { ...sshResult.value }
|
||||
delete syncData.hostname
|
||||
await syncSSH(machineId, syncData)
|
||||
ElMessage.success('获取并同步成功')
|
||||
await loadMachine()
|
||||
} catch (e) {}
|
||||
|
||||
Reference in New Issue
Block a user