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>
296 lines
8.0 KiB
Go
296 lines
8.0 KiB
Go
// 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
|
||
}
|