基于 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 加密前缀保留(已加密凭证不受影响)。
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"
|
||
|
||
"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)
|
||
}
|