- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂 - i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏 - 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条 - 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器 - UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正 - 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简 - 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
371 lines
11 KiB
Go
371 lines
11 KiB
Go
package services
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"lokistack/config"
|
||
"lokistack/models"
|
||
"lokistack/pkg/crypto"
|
||
|
||
"golang.org/x/crypto/ssh"
|
||
)
|
||
|
||
type SSHClient struct {
|
||
client *ssh.Client
|
||
}
|
||
|
||
type HostInfo struct {
|
||
Hostname string `json:"hostname"`
|
||
OS string `json:"os"`
|
||
Version string `json:"version"`
|
||
Arch string `json:"arch"`
|
||
Kernel string `json:"kernel"`
|
||
CPU string `json:"cpu"`
|
||
Memory string `json:"memory"`
|
||
Disk string `json:"disk"`
|
||
Uptime string `json:"uptime"`
|
||
|
||
// 资源使用率(百分比)
|
||
CPUUsage float64 `json:"cpuUsage"`
|
||
MemUsage float64 `json:"memUsage"`
|
||
DiskUsage float64 `json:"diskUsage"`
|
||
// 资源已用/总量
|
||
MemTotal string `json:"memTotal"`
|
||
MemUsed string `json:"memUsed"`
|
||
DiskTotal string `json:"diskTotal"`
|
||
DiskUsed string `json:"diskUsed"`
|
||
}
|
||
|
||
// SSHAuthConfig 重导出models.SSHAuthConfig以兼容现有调用
|
||
// Deprecated: 直接使用 models.SSHAuthConfig
|
||
type SSHAuthConfig = models.SSHAuthConfig
|
||
|
||
// validatePort 校验SSH端口范围
|
||
func validatePort(port int) int {
|
||
if port <= 0 {
|
||
return 22
|
||
}
|
||
if port > 65535 {
|
||
return 22
|
||
}
|
||
return port
|
||
}
|
||
|
||
// hostKeyPath 返回主机密钥存储路径
|
||
func hostKeyPath(host string) string {
|
||
cfg := config.GetConfig()
|
||
// 对主机名进行安全编码作为文件名
|
||
safeHost := base64.URLEncoding.EncodeToString([]byte(host))
|
||
return filepath.Join(cfg.HostKeyDir, safeHost+".pub")
|
||
}
|
||
|
||
// fingerprint 计算SSH主机密钥的SHA256指纹
|
||
func fingerprint(key ssh.PublicKey) string {
|
||
hash := sha256.Sum256(key.Marshal())
|
||
return base64.StdEncoding.EncodeToString(hash[:])
|
||
}
|
||
|
||
// TrustOnFirstUse 实现TOFU(Trust On First Use)主机密钥验证
|
||
// 首次连接时记录主机密钥,后续连接时校验
|
||
func TrustOnFirstUse(host string) ssh.HostKeyCallback {
|
||
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||
fp := fingerprint(key)
|
||
hostKeyFile := hostKeyPath(host)
|
||
|
||
storedKey, err := os.ReadFile(hostKeyFile)
|
||
if err != nil {
|
||
// 首次连接:保存主机密钥
|
||
if os.IsNotExist(err) {
|
||
if writeErr := os.WriteFile(hostKeyFile, ssh.MarshalAuthorizedKey(key), 0600); writeErr != nil {
|
||
return fmt.Errorf("failed to save host key: %w", writeErr)
|
||
}
|
||
// 记录首次信任的主机密钥指纹(安全审计日志)
|
||
fmt.Fprintf(os.Stderr, "[SSH-TOFU] First connection to %s, host key fingerprint saved: SHA256:%s\n", host, fp)
|
||
return nil
|
||
}
|
||
return fmt.Errorf("failed to read host key file: %w", err)
|
||
}
|
||
|
||
// 解析存储的密钥并与当前密钥比较
|
||
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey(storedKey)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to parse stored host key: %w", err)
|
||
}
|
||
|
||
if !bytes.Equal(parsedKey.Marshal(), key.Marshal()) {
|
||
return fmt.Errorf("HOST KEY MISMATCH for %s! Expected SHA256:%s, got SHA256:%s. Possible MITM attack!",
|
||
host, fingerprint(parsedKey), fp)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// InsecureIgnoreHostKey 保留但不导出,用于向后兼容的内部测试场景
|
||
// 生产环境必须使用TrustOnFirstUse
|
||
func InsecureIgnoreHostKey() ssh.HostKeyCallback {
|
||
return ssh.InsecureIgnoreHostKey()
|
||
}
|
||
|
||
// buildAuthMethods 构建SSH认证方法列表(优先密钥认证)
|
||
func buildAuthMethods(auth *models.SSHAuthConfig) []ssh.AuthMethod {
|
||
methods := []ssh.AuthMethod{}
|
||
|
||
// 1. 优先尝试私钥认证
|
||
if auth.PrivateKey != "" {
|
||
privateKey := auth.PrivateKey
|
||
// 如果是加密存储的密钥,先解密
|
||
if strings.HasPrefix(privateKey, "ENC:") {
|
||
privateKey = crypto.DecryptField(privateKey)
|
||
}
|
||
|
||
var signer ssh.Signer
|
||
var err error
|
||
keyPhrase := auth.KeyPhrase
|
||
if strings.HasPrefix(keyPhrase, "ENC:") {
|
||
keyPhrase = crypto.DecryptField(keyPhrase)
|
||
}
|
||
|
||
if keyPhrase != "" {
|
||
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(keyPhrase))
|
||
} else {
|
||
signer, err = ssh.ParsePrivateKey([]byte(privateKey))
|
||
}
|
||
|
||
if err == nil {
|
||
methods = append(methods, ssh.PublicKeys(signer))
|
||
}
|
||
}
|
||
|
||
// 2. 密码认证作为后备
|
||
if auth.Password != "" {
|
||
password := auth.Password
|
||
if strings.HasPrefix(password, "ENC:") {
|
||
password = crypto.DecryptField(password)
|
||
}
|
||
methods = append(methods, ssh.Password(password))
|
||
}
|
||
|
||
return methods
|
||
}
|
||
|
||
// HostSSHAuth 从 Host 模型构建完整 SSH 认证配置(密钥优先、密码后备)。
|
||
// 凭证保持 ENC: 原样传入,由 buildAuthMethods 统一解密。
|
||
func HostSSHAuth(host *models.Host) *models.SSHAuthConfig {
|
||
return &models.SSHAuthConfig{
|
||
Host: host.HostIP,
|
||
Port: host.Port,
|
||
User: host.User,
|
||
Password: host.Password,
|
||
PrivateKey: host.PrivateKey,
|
||
KeyPhrase: host.KeyPhrase,
|
||
}
|
||
}
|
||
|
||
// NewSSHClient 创建SSH客户端(支持密钥+密码认证,TOFU主机密钥验证)
|
||
func NewSSHClient(host string, port int, user, password string) (*SSHClient, error) {
|
||
auth := &SSHAuthConfig{
|
||
Host: host,
|
||
Port: port,
|
||
User: user,
|
||
Password: password,
|
||
}
|
||
return NewSSHClientWithAuth(auth)
|
||
}
|
||
|
||
// NewSSHClientWithAuth 使用完整认证配置创建SSH客户端
|
||
func NewSSHClientWithAuth(auth *models.SSHAuthConfig) (*SSHClient, error) {
|
||
port := validatePort(auth.Port)
|
||
if auth.User == "" {
|
||
return nil, errors.New("SSH user is required")
|
||
}
|
||
|
||
authMethods := buildAuthMethods(auth)
|
||
if len(authMethods) == 0 {
|
||
return nil, errors.New("no authentication method available")
|
||
}
|
||
|
||
cfg := &ssh.ClientConfig{
|
||
User: auth.User,
|
||
HostKeyCallback: TrustOnFirstUse(auth.Host),
|
||
Timeout: 10 * time.Second,
|
||
Auth: authMethods,
|
||
}
|
||
|
||
addr := fmt.Sprintf("%s:%d", auth.Host, port)
|
||
client, err := ssh.Dial("tcp", addr, cfg)
|
||
if err != nil {
|
||
return nil, errors.New(FormatSSHError(err))
|
||
}
|
||
|
||
return &SSHClient{client: client}, nil
|
||
}
|
||
|
||
// NewSSHClientUnsafe 创建不验证主机密钥的SSH客户端(仅用于内部测试)
|
||
// Deprecated: 生产环境请使用NewSSHClient或NewSSHClientWithAuth
|
||
func NewSSHClientUnsafe(host string, port int, user, password string) (*SSHClient, error) {
|
||
port = validatePort(port)
|
||
cfg := &ssh.ClientConfig{
|
||
User: user,
|
||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||
Timeout: 10 * time.Second,
|
||
Auth: []ssh.AuthMethod{ssh.Password(password)},
|
||
}
|
||
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", host, port), cfg)
|
||
if err != nil {
|
||
return nil, errors.New(FormatSSHError(err))
|
||
}
|
||
return &SSHClient{client: client}, nil
|
||
}
|
||
|
||
func (c *SSHClient) Execute(ctx context.Context, command string) (string, string, error) {
|
||
session, err := c.client.NewSession()
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
defer session.Close()
|
||
|
||
var stdout, stderr bytes.Buffer
|
||
session.Stdout = &stdout
|
||
session.Stderr = &stderr
|
||
|
||
done := make(chan error, 1)
|
||
go func() { done <- session.Run(command) }()
|
||
|
||
select {
|
||
case err := <-done:
|
||
return stdout.String(), stderr.String(), err
|
||
case <-ctx.Done():
|
||
session.Signal(ssh.SIGKILL)
|
||
return "", "", ctx.Err()
|
||
}
|
||
}
|
||
|
||
// GetHostInfo 采集主机静态信息与资源使用率
|
||
func (c *SSHClient) GetHostInfo(ctx context.Context) (*HostInfo, error) {
|
||
info := &HostInfo{}
|
||
|
||
// 静态信息(逐条,best-effort)
|
||
if out, _, err := c.Execute(ctx, "hostname"); err == nil {
|
||
info.Hostname = strings.TrimSpace(out)
|
||
}
|
||
if out, _, err := c.Execute(ctx, `cat /etc/os-release | grep '^ID=' | cut -d= -f2 | tr -d '"'`); err == nil {
|
||
info.OS = strings.TrimSpace(out)
|
||
}
|
||
if out, _, err := c.Execute(ctx, `cat /etc/os-release | grep 'VERSION_ID=' | cut -d= -f2 | tr -d '"'`); err == nil {
|
||
info.Version = strings.TrimSpace(out)
|
||
}
|
||
if out, _, err := c.Execute(ctx, "uname -m"); err == nil {
|
||
info.Arch = strings.TrimSpace(out)
|
||
}
|
||
if out, _, err := c.Execute(ctx, "uname -r"); err == nil {
|
||
info.Kernel = strings.TrimSpace(out)
|
||
}
|
||
if out, _, err := c.Execute(ctx, "nproc"); err == nil {
|
||
info.CPU = strings.TrimSpace(out) + " 核"
|
||
}
|
||
if out, _, err := c.Execute(ctx, "free -h | awk '/^Mem:/ {print $2}'"); err == nil {
|
||
info.Memory = strings.TrimSpace(out)
|
||
}
|
||
if out, _, err := c.Execute(ctx, "df -h / | awk 'NR==2 {print $2}'"); err == nil {
|
||
info.Disk = strings.TrimSpace(out)
|
||
}
|
||
if out, _, err := c.Execute(ctx, "uptime -p 2>/dev/null || uptime"); err == nil {
|
||
info.Uptime = strings.TrimSpace(out)
|
||
}
|
||
|
||
// 资源使用率:一次合并命令(减少 SSH 往返)
|
||
// 输出形如:CPUUSAGE:12.3 / MEM:16384:8192 / DISK:51200:25600:50%
|
||
metricScript := `top -bn1 2>/dev/null | awk '/Cpu\(s\)/{print "CPUUSAGE:"100-$8; exit}'
|
||
free -m | awk '/^Mem:/{print "MEM:"$2":"$3}'
|
||
df -m / | awk 'NR==2{print "DISK:"$2":"$3":"$5}'`
|
||
if out, _, err := c.Execute(ctx, metricScript); err == nil {
|
||
for _, line := range strings.Split(out, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
switch {
|
||
case strings.HasPrefix(line, "CPUUSAGE:"):
|
||
info.CPUUsage, _ = strconv.ParseFloat(strings.TrimPrefix(line, "CPUUSAGE:"), 64)
|
||
case strings.HasPrefix(line, "MEM:"):
|
||
parts := strings.Split(strings.TrimPrefix(line, "MEM:"), ":")
|
||
if len(parts) == 2 {
|
||
total, _ := strconv.Atoi(parts[0])
|
||
used, _ := strconv.Atoi(parts[1])
|
||
info.MemTotal = fmt.Sprintf("%d MB", total)
|
||
info.MemUsed = fmt.Sprintf("%d MB", used)
|
||
if total > 0 {
|
||
info.MemUsage = float64(used) / float64(total) * 100
|
||
}
|
||
}
|
||
case strings.HasPrefix(line, "DISK:"):
|
||
parts := strings.Split(strings.TrimPrefix(line, "DISK:"), ":")
|
||
if len(parts) >= 3 {
|
||
info.DiskTotal = parts[0] + " MB"
|
||
info.DiskUsed = parts[1] + " MB"
|
||
pct := strings.TrimSuffix(strings.TrimSpace(parts[2]), "%")
|
||
info.DiskUsage, _ = strconv.ParseFloat(pct, 64)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return info, nil
|
||
}
|
||
|
||
func (c *SSHClient) Close() error {
|
||
if c.client != nil {
|
||
return c.client.Close()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// TestSSHConnection 测试SSH连接(使用TOFU模式)
|
||
func TestSSHConnection(host string, port int, user, password string) error {
|
||
client, err := NewSSHClient(host, port, user, password)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer client.Close()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
_, _, err = client.Execute(ctx, "echo 'connected'")
|
||
return err
|
||
}
|
||
|
||
// TestSSHConnectionWithAuth 使用完整认证配置测试SSH连接
|
||
func TestSSHConnectionWithAuth(auth *models.SSHAuthConfig) error {
|
||
client, err := NewSSHClientWithAuth(auth)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer client.Close()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
_, _, err = client.Execute(ctx, "echo 'connected'")
|
||
return err
|
||
}
|
||
|
||
// GetHostKeyFingerprint 获取已保存的主机密钥指纹
|
||
func GetHostKeyFingerprint(host string) string {
|
||
hostKeyFile := hostKeyPath(host)
|
||
storedKey, err := os.ReadFile(hostKeyFile)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey(storedKey)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return "SHA256:" + fingerprint(parsedKey)
|
||
}
|