Files
chiyiandDevin 834ecacdb9 cyi-box: Memphis-style toolbox admin platform
Go(fun)+SQLite backend, Vue 3 frontend. Modules: dashboard, pay gateway
(Epay-compatible + CDK stock), license/card system (per-app appid, domain
binding), AI memory bank with built-in MCP server (/mcp, FTS5 search,
signed read links), server SSH monitor + terminal + SFTP, proxy pool with
protocol-level alive checks, DB manager (mysql/pg/sqlite + SQL
autocomplete + import/export), disk share, vault (AES-GCM + TOTP),
bookmarks, dev tools.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-01 01:58:47 +08:00

296 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package sshx SSH 连接与指标采集:Dial 负责建连(密码/私钥),
// CollectMetrics 用一个 shell 脚本两采样算出 CPU% 与网卡速率。
package sshx
import (
"bytes"
"fmt"
"net"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
// Dial 建立 SSH 连接。authType: "password" | "key";secret 为明文密码或 PEM 私钥。
// hostKey 校验用 InsecureIgnoreHostKey——自管工具箱场景,目标机由管理员自己登记。
func Dial(host string, port int64, username, authType, secret string) (*ssh.Client, error) {
cfg := &ssh.ClientConfig{
User: username,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 8 * time.Second,
}
switch authType {
case "key":
signer, err := ssh.ParsePrivateKey([]byte(secret))
if err != nil {
return nil, fmt.Errorf("sshx: 私钥无效(暂不支持加密私钥): %w", err)
}
cfg.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
default:
cfg.Auth = []ssh.AuthMethod{ssh.Password(secret), ssh.KeyboardInteractive(func(_ string, _ string, questions []string, _ []bool) ([]string, error) {
ans := make([]string, len(questions))
for i := range ans {
ans[i] = secret
}
return ans, nil
})}
}
addr := net.JoinHostPort(host, strconv.FormatInt(port, 10))
conn, err := net.DialTimeout("tcp", addr, 8*time.Second)
if err != nil {
return nil, fmt.Errorf("sshx: 连接 %s 失败: %w", addr, err)
}
c, chans, reqs, err := ssh.NewClientConn(conn, addr, cfg)
if err != nil {
conn.Close()
return nil, fmt.Errorf("sshx: SSH 握手失败: %w", err)
}
return ssh.NewClient(c, chans, reqs), nil
}
// Metrics 一次采集的全部指标(字节/百分比原始值,展示层格式化)
type Metrics struct {
Hostname string
OS string
Kernel string
UptimeSeconds int64
CpuPercent int64
Load1 string
Load5 string
Load15 string
MemTotal int64
MemAvail int64
MemUsed int64
SwapTotal int64
SwapUsed int64
DiskTotal int64
DiskUsed int64
DiskAvail int64
NetRxRate int64
NetTxRate int64
NetRxTotal int64
NetTxTotal int64
Disks []DiskMount
}
type DiskMount struct {
Mount string
Fs string
Total int64
Used int64
Avail int64
Percent int64
}
// 采集脚本:两帧 /proc/stat + /proc/net/dev 中间 sleep 0.5s 算速率,
// 其余一次性读取;==NAME== 段落标记用于切分解析
const metricsScript = `
echo ==CPU1==; grep '^cpu ' /proc/stat
echo ==NET1==; cat /proc/net/dev 2>/dev/null
sleep 0.5
echo ==CPU2==; grep '^cpu ' /proc/stat
echo ==NET2==; cat /proc/net/dev 2>/dev/null
echo ==MEM==; grep -E '^(MemTotal|MemAvailable|MemFree|SwapTotal|SwapFree)' /proc/meminfo
echo ==DISKS==; df -B1 -x tmpfs -x devtmpfs -x squashfs -x overlay -x efivarfs 2>/dev/null | tail -n +2
echo ==LOAD==; cat /proc/loadavg 2>/dev/null
echo ==UP==; cat /proc/uptime 2>/dev/null
echo ==OS==; (. /etc/os-release 2>/dev/null; echo "$PRETTY_NAME"); uname -r
echo ==HOST==; hostname
`
// CollectMetrics 跑采集脚本并解析;整体超时 20s
func CollectMetrics(c *ssh.Client) (Metrics, error) {
type result struct {
out []byte
err error
}
ch := make(chan result, 1)
go func() {
sess, err := c.NewSession()
if err != nil {
ch <- result{nil, fmt.Errorf("sshx: 建立会话失败: %w", err)}
return
}
defer sess.Close()
// 脚本走 stdin 喂给 sh -s:内容含单引号,拼进 sh -c '...' 会被顶断(exit 2)
sess.Stdin = strings.NewReader(metricsScript)
out, err := sess.Output("sh -s")
ch <- result{out, err}
}()
var out []byte
select {
case r := <-ch:
if r.err != nil {
return Metrics{}, fmt.Errorf("sshx: 采集命令失败: %w", r.err)
}
out = r.out
case <-time.After(20 * time.Second):
return Metrics{}, fmt.Errorf("sshx: 采集超时")
}
return parseMetrics(string(out)), nil
}
func parseMetrics(text string) Metrics {
sections := map[string]string{}
cur := ""
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "==") && strings.HasSuffix(trimmed, "==") {
cur = strings.Trim(trimmed, "=")
continue
}
if cur != "" {
sections[cur] += line + "\n"
}
}
m := Metrics{Disks: []DiskMount{}}
c1, c2 := cpuFields(sections["CPU1"]), cpuFields(sections["CPU2"])
if len(c1) >= 8 && len(c2) >= 8 {
m.CpuPercent = cpuPercent(c1, c2)
}
n1, n2 := netTotals(sections["NET1"]), netTotals(sections["NET2"])
// 脚本固定 sleep 0.5s → 速率 = 差值 × 2
m.NetRxRate = max64(n2[0]-n1[0], 0) * 2
m.NetTxRate = max64(n2[1]-n1[1], 0) * 2
m.NetRxTotal, m.NetTxTotal = n2[0], n2[1]
parseMem(&m, sections["MEM"])
parseDisks(&m, sections["DISKS"])
if l := strings.Fields(sections["LOAD"]); len(l) >= 3 {
m.Load1, m.Load5, m.Load15 = l[0], l[1], l[2]
}
if f := strings.Fields(sections["UP"]); len(f) > 0 {
if v, err := strconv.ParseFloat(f[0], 64); err == nil {
m.UptimeSeconds = int64(v)
}
}
if lines := strings.Split(strings.TrimSpace(sections["OS"]), "\n"); len(lines) > 0 {
m.OS = strings.TrimSpace(lines[0])
if len(lines) > 1 {
m.Kernel = strings.TrimSpace(lines[1])
}
}
m.Hostname = strings.TrimSpace(sections["HOST"])
return m
}
// cpuFields /proc/stat 的 cpu 行 → [user nice system idle iowait irq softirq steal]
func cpuFields(s string) []int64 {
for _, line := range strings.Split(s, "\n") {
if !strings.HasPrefix(strings.TrimSpace(line), "cpu ") {
continue
}
f := strings.Fields(line)[1:]
vals := make([]int64, len(f))
for i, v := range f {
vals[i], _ = strconv.ParseInt(v, 10, 64)
}
return vals
}
return nil
}
func cpuPercent(a, b []int64) int64 {
total := func(v []int64) int64 {
var s int64
for _, x := range v {
s += x
}
return s
}
idle := func(v []int64) int64 { return v[3] + v[4] } // idle + iowait
dt, di := total(b)-total(a), idle(b)-idle(a)
if dt <= 0 {
return 0
}
return (dt - di) * 100 / dt
}
// netTotals /proc/net/dev → [rx_bytes合计, tx_bytes合计](跳过 lo)
func netTotals(s string) [2]int64 {
var rx, tx int64
for _, line := range strings.Split(s, "\n") {
i := strings.Index(line, ":")
if i < 0 {
continue
}
if strings.TrimSpace(line[:i]) == "lo" {
continue
}
f := strings.Fields(line[i+1:])
if len(f) < 9 {
continue
}
r, _ := strconv.ParseInt(f[0], 10, 64)
t, _ := strconv.ParseInt(f[8], 10, 64)
rx, tx = rx+r, tx+t
}
return [2]int64{rx, tx}
}
func parseMem(m *Metrics, s string) {
kv := map[string]int64{}
for _, line := range strings.Split(s, "\n") {
f := strings.Fields(line)
if len(f) >= 2 {
v, _ := strconv.ParseInt(f[1], 10, 64)
kv[strings.TrimSuffix(f[0], ":")] = v * 1024 // kB → byte
}
}
m.MemTotal = kv["MemTotal"]
m.MemAvail = kv["MemAvailable"]
if m.MemAvail == 0 {
m.MemAvail = kv["MemFree"]
}
m.MemUsed = max64(m.MemTotal-m.MemAvail, 0)
m.SwapTotal = kv["SwapTotal"]
m.SwapUsed = m.SwapTotal - kv["SwapFree"]
}
func parseDisks(m *Metrics, s string) {
for _, line := range strings.Split(s, "\n") {
f := strings.Fields(line)
if len(f) < 6 {
continue
}
d := DiskMount{Fs: f[0], Mount: f[5]}
d.Total, _ = strconv.ParseInt(f[1], 10, 64)
d.Used, _ = strconv.ParseInt(f[2], 10, 64)
d.Avail, _ = strconv.ParseInt(f[3], 10, 64)
d.Percent, _ = strconv.ParseInt(strings.TrimSuffix(f[4], "%"), 10, 64)
m.Disks = append(m.Disks, d)
if d.Mount == "/" {
m.DiskTotal, m.DiskUsed, m.DiskAvail = d.Total, d.Used, d.Avail
}
}
// 没有独立 / 挂载(罕见)时取第一块盘作概览
if m.DiskTotal == 0 && len(m.Disks) > 0 {
m.DiskTotal, m.DiskUsed, m.DiskAvail = m.Disks[0].Total, m.Disks[0].Used, m.Disks[0].Avail
}
}
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}
// RunScript 终端之外保留:跑一次命令返回输出(SFTP 预热/调试用)
func RunScript(c *ssh.Client, script string) ([]byte, error) {
sess, err := c.NewSession()
if err != nil {
return nil, err
}
defer sess.Close()
var buf bytes.Buffer
sess.Stdout = &buf
sess.Stderr = &buf
if err := sess.Run(script); err != nil {
return buf.Bytes(), err
}
return buf.Bytes(), nil
}