Files
LokiStack/backend/services/ssh.go
T
Logan.LiandClaude 327e282356 refactor: 前后端目录结构模块化 + v2 优化移植与缺陷修复
前端:
- src/srv/api → src/api,按域分文件夹(auth/cluster/host/storage/deployment/collector)
- 新增 http.ts 引擎(http.get/post/put/delete),域模块改用语义化调用
- index.html 增加 favicon(匹配应用 logo 的 emerald + Activity 脉冲)

后端分层增强(保留包名):
- 新增 repository/:从 models 拆出 JsonDB 持久化层(InitDB/GetDB/Shutdown/RequestSave)
- 新增 dto/:从 controllers 抽出各域请求契约(带 binding 校验)
- 新增 pkg/crypto、pkg/jwt:从 utils 拆出安全原语(外部 jwt 别名 jwtv5 避免冲突)
- models 只留领域实体 + sanitizer;utils 只留 gin/应用相关(response/errors)

v2 优化移植与缺陷修复:
- 移植密钥分离/SSH TOFU/并发写安全/Loki 生产级配置/Docker 加固
- 修复 deployment.go 并发丢更新、Helm values 重复键、EncryptField 哨兵、loki-gateway 端点等审计缺陷
- 实现真正的优雅关停(main.go http.Server + signal + repository.Shutdown)
- 修复登录契约:后端返回 accessToken,前端 auth.ts/Login.tsx 对齐
- 补 crypto/jwt/repository 单元测试(含并发写回归)

验证: go build/vet/test 全绿;npm run build 通过;登录→鉴权→优雅关停冒烟通过

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 10:46:26 +08:00

358 lines
10 KiB
Go
Raw 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 services
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"lokideploy/config"
"lokideploy/models"
"lokideploy/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
}
// 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)
}