Files
LokiStack/backend/services/ssh.go
T
logan f9c6c1d826 feat: opsx-loki 全面升级 — 前端重构/用户与LDAP/部署向导/日志查询
基于 WEB-UI-Development-Guide 标准的整体升级,并更名 LokiDeploy → opsx-loki。

前端(Gin+React+shadcn/ui):
- 按 02/07 章重构:页面域化拆分(无超 400 行文件)、PageContainer/ListQuery、
  反馈分级(成功静默/失败 toast/危险确认)、401 单飞刷新+队列重放
- 暗色模式(next-themes)+ 语义 Token(--primary 品牌色/success/warning/info)
- 部署向导(状态推导四步:环境→存储→Loki→采集,内嵌表单闭环)
- 日志查询页:LogQL 标签构建器/拖拽直方图/日志上下文/Live Tail/查询历史
- 按用户+标签策略的查询授权(服务端强制合并策略)

后端(Go+Gin+JSON DB):
- 简单用户管理(仅 admin,无 RBAC)+ TokenVersion 会话吊销 + 登录 IP 限流
- LDAP 接入:配置(AES 加密存储/hasPassword 脱敏)/测试连接/批量导入/登录
- 存储配置测试连接(SigV4 零依赖)+ 桶不存在自动创建
- Loki 三模式部署修复:SSH 密钥认证、3.x 配置模板、ruler 对象存储嵌套块、
  凭证解密、孤儿 deploying 状态恢复、Grafana 部署(可选默认关)+面板导入
- DaemonSet 采集纯 YAML(去 helm 依赖)+ DNS 自愈回退 ClusterIP + 集群名标签
- 日志查询代理:cluster-svc 经 kube-apiserver proxy/url 直连 + cursor 翻页
- 统一 log 命名空间;部署就绪探活;Grafana 访问地址自动记录

修复:run.sh 增量编译检测、Sanitized 脱敏补全、LokiDeployment Update 白名单、
健康检查 CreatedAt 零值等 10+ 存量 bug。

数据兼容:DB 沿用旧 lokideploy.json(新名优先);localStorage 一次性迁移;
HKDF 加密前缀保留(已加密凭证不受影响)。
2026-10-24 13:20:12 +08:00

371 lines
11 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"
"opsxloki/config"
"opsxloki/models"
"opsxloki/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)
}