Files
opsx f3733f0bfc feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏
- 品牌: 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 未翻译、词典双逗号等
2026-10-27 17:11:38 +08:00

371 lines
11 KiB
Go
Raw Permalink 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"
"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)
}