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>
This commit is contained in:
1 parent
fb8d10231a
commit
327e282356
93 files changed
+8433
-3089
No files matched your search
+194
-5
@@ -3,12 +3,21 @@ 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"
|
||||
)
|
||||
|
||||
@@ -38,17 +47,169 @@ type HostInfo struct {
|
||||
DiskUsed string `json:"diskUsed"`
|
||||
}
|
||||
|
||||
func NewSSHClient(host string, port int, user, password string) (*SSHClient, error) {
|
||||
if port == 0 {
|
||||
port = 22
|
||||
// SSHAuthConfig 重导出models.SSHAuthConfig以兼容现有调用
|
||||
// Deprecated: 直接使用 models.SSHAuthConfig
|
||||
type SSHAuthConfig = models.SSHAuthConfig
|
||||
|
||||
// validatePort 校验SSH端口范围
|
||||
func validatePort(port int) int {
|
||||
if port <= 0 {
|
||||
return 22
|
||||
}
|
||||
config := &ssh.ClientConfig{
|
||||
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), config)
|
||||
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", host, port), cfg)
|
||||
if err != nil {
|
||||
return nil, errors.New(FormatSSHError(err))
|
||||
}
|
||||
@@ -155,6 +316,7 @@ func (c *SSHClient) Close() error {
|
||||
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 {
|
||||
@@ -166,3 +328,30 @@ func TestSSHConnection(host string, port int, user, password string) error {
|
||||
_, _, 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)
|
||||
}
|
||||
Reference in new issue
Block a user