// Package collector 提供服务器各项系统指标的采集能力。 // 本文件实现监听 TCP 端口的自动发现: // - 扫描 /proc/net/tcp 与 /proc/net/tcp6 中状态为 LISTEN(0A) 的套接字; // - 通过遍历 /proc/*/fd 反查套接字 inode 归属的进程。 // - 内置系统进程排除列表(sshd、rpcbind、master 等永远不上报)。 // - 支持远程端口策略:whitelist(全量上报,默认)/ blacklist(只上报白名单端口)。 // // 署名 Logan.Li package collector import ( "os" "path/filepath" "strconv" "strings" "ezops/agent/model" ) // 系统级进程 — 永远不采集其监听端口 var systemProcesses = map[string]bool{ "sshd": true, "rpcbind": true, "master": true, // Postfix "systemd": true, "systemd-resolve": true, "systemd-journal": true, "dbus-daemon": true, "rsyslogd": true, "irqbalance": true, "polkitd": true, "accounts-daemon": true, "NetworkManager": true, "udisksd": true, "upowerd": true, "rtkit-daemon": true, "switcheroo-contr": true, "power-profiles-": true, // power-profiles-daemon "wpa_supplicant": true, "avahi-daemon": true, "cron": true, "atd": true, "agetty": true, } // PortCollector 监听端口自动发现采集器 type PortCollector struct { Policy *model.PortPolicy // 可选:后端下发的端口过滤策略 } // NewPortCollector 创建端口采集器实例 func NewPortCollector() *PortCollector { return &PortCollector{} } // SetPolicy 设置端口过滤策略(daemon 模式下从后端轮询获得) func (p *PortCollector) SetPolicy(policy *model.PortPolicy) { p.Policy = policy } // listenSocket 描述一个处于 LISTEN 状态的套接字 type listenSocket struct { port int protocol string inode uint64 } // procInfo 描述占用某套接字 inode 的进程信息 type procInfo struct { pid int name string } // Collect 采集所有处于监听状态的 TCP 端口及其归属进程 func (p *PortCollector) Collect() ([]model.PortStat, error) { // 1. 扫描 /proc/net/tcp(/tcp6),收集 LISTEN 套接字 sockets := p.scanListenSockets() if len(sockets) == 0 { return []model.PortStat{}, nil } // 2. 构建 inode -> 进程信息 映射(仅 Linux 上可用) inodeMap := p.buildInodeMap() // 3. 组装结果并按 (端口, 协议, PID) 去重,应用过滤策略 seen := make(map[string]bool) ports := make([]model.PortStat, 0, len(sockets)) for _, s := range sockets { pid, name := 0, "" if info, ok := inodeMap[s.inode]; ok { pid, name = info.pid, info.name } // ── 过滤逻辑 ── // 1) 永远排除系统级进程端口 if isSystemProcess(name) { continue } // 2) 黑名单模式:只上报在白名单中的端口 if p.Policy != nil && p.Policy.IsBlacklistMode() { if !p.Policy.InWhitelist(s.port) { continue } } // 白名单模式(默认):全量上报已排除系统进程的端口 // ── 过滤逻辑结束 ── key := strconv.Itoa(s.port) + "|" + s.protocol + "|" + strconv.Itoa(pid) if seen[key] { continue } seen[key] = true ports = append(ports, model.PortStat{ Port: s.port, Protocol: s.protocol, Process: name, PID: pid, }) } return ports, nil } // scanListenSockets 解析 /proc/net/tcp 与 /proc/net/tcp6,返回 LISTEN 套接字。 // 单个文件读取失败(如 macOS 开发环境下不存在 /proc)会被忽略。 func (p *PortCollector) scanListenSockets() []listenSocket { files := []struct { path string protocol string }{ {"/proc/net/tcp", "tcp"}, {"/proc/net/tcp6", "tcp6"}, } var sockets []listenSocket for _, sf := range files { result, err := parseNetTCP(sf.path, sf.protocol) if err != nil { continue } sockets = append(sockets, result...) } return sockets } // parseNetTCP 解析单个 /proc/net/tcp(*) 文件 func parseNetTCP(path, protocol string) ([]listenSocket, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var sockets []listenSocket lines := strings.Split(string(data), "\n") for i, line := range lines { if i == 0 { continue // 跳过表头 } fields := strings.Fields(line) if len(fields) < 10 { continue } // fields[3] 为套接字状态,"0A" 表示 TCP_LISTEN if fields[3] != "0A" { continue } // fields[1] 为本地地址,格式 IP:Port(均为十六进制) localAddr := fields[1] colon := strings.LastIndex(localAddr, ":") if colon < 0 { continue } port, err := strconv.ParseUint(localAddr[colon+1:], 16, 32) if err != nil { continue } // fields[9] 为套接字 inode inode, err := strconv.ParseUint(fields[9], 10, 64) if err != nil { continue } sockets = append(sockets, listenSocket{ port: int(port), protocol: protocol, inode: inode, }) } return sockets, nil } // buildInodeMap 扫描 /proc/*/fd,构建 套接字 inode -> 进程信息 的映射。 // 对无权限读取的进程目录(如其它用户进程)会自动跳过。 func (p *PortCollector) buildInodeMap() map[uint64]procInfo { inodeMap := make(map[uint64]procInfo) entries, err := os.ReadDir("/proc") if err != nil { return inodeMap } for _, entry := range entries { if !entry.IsDir() { continue } pid, err := strconv.Atoi(entry.Name()) if err != nil { continue // 非 PID 目录 } fdDir := filepath.Join("/proc", entry.Name(), "fd") fds, err := os.ReadDir(fdDir) if err != nil { continue // 无权限读取,跳过 } name := readProcessName(entry.Name()) for _, fd := range fds { link, err := os.Readlink(filepath.Join(fdDir, fd.Name())) if err != nil { continue } // 套接字链接形如: socket:[12345] if !strings.HasPrefix(link, "socket:[") { continue } inodeStr := link[len("socket:[") : len(link)-1] inode, err := strconv.ParseUint(inodeStr, 10, 64) if err != nil { continue } // 同一 inode 仅记录首个发现的进程即可 if _, exists := inodeMap[inode]; !exists { inodeMap[inode] = procInfo{pid: pid, name: name} } } } return inodeMap } // readProcessName 读取进程名称:优先 /proc//comm,失败则尝试取 cmdline 的可执行文件名 func readProcessName(pid string) string { if data, err := os.ReadFile("/proc/" + pid + "/comm"); err == nil { if s := strings.TrimSpace(string(data)); s != "" { return s } } if data, err := os.ReadFile("/proc/" + pid + "/cmdline"); err == nil { // cmdline 以 \0 分隔参数,取首个(即可执行文件路径)的基名 parts := strings.SplitN(string(data), "\x00", 2) if len(parts) > 0 && parts[0] != "" { return filepath.Base(parts[0]) } } return "" } // isSystemProcess 判断进程名是否为系统进程(永远不上报的端口) func isSystemProcess(name string) bool { if name == "" { return false } // 精确匹配 if systemProcesses[name] { return true } // 前缀匹配(如 power-profiles-daemon) for prefix := range systemProcesses { if strings.HasPrefix(name, prefix) { return true } } return false }