Files
LokiStack/backend/services/host.go
T
opsx 572efc89a4 fix(reliability): 采集器并发去重 + 备份恢复即时重载 + context 贯穿请求链
- CollectorService 安装/卸载按采集器互斥去重(重复触发不再并发 docker rm/run 互踩);删除后卸载失败留日志
- ApplyStagedBackup 替换文件后立即 Reload 内存态,消除「重启前旧内存落盘覆盖恢复文件」竞态;暂存文件名升级 lokistack.json(兼容旧名)
- GetUsage/Validate 全链路 context 贯穿:请求取消即中止 SSH/k8s 查询(异步调用点显式 Background)
2026-10-29 13:29:25 +08:00

229 lines
6.1 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 (
"context"
"fmt"
"strings"
"time"
"lokistack/models"
"lokistack/pkg/crypto"
"lokistack/repository"
"lokistack/utils"
)
type HostService struct{}
func NewHostService() *HostService {
return &HostService{}
}
// isLoopbackHost 判断是否回环地址。平台在容器/Pod 内运行,
// 回环地址指向平台自己,不可能连到目标服务器。
func isLoopbackHost(hostIP string) bool {
h := strings.ToLower(strings.TrimSpace(hostIP))
h = strings.TrimPrefix(h, "[")
h = strings.TrimSuffix(h, "]")
return h == "localhost" || h == "127.0.0.1" || h == "::1" ||
strings.HasPrefix(h, "127.")
}
func (s *HostService) Create(name, hostIP string, port int, user, authType, password, privateKey, keyPhrase, os, arch, labels string) (*models.Host, error) {
if port == 0 {
port = 22
}
// 平台自身跑在容器里,127.0.0.1 指向的是平台自己,永远连不上目标服务器。
// 这个坑很常见(本地 ssh root@127.0.0.1 能通,平台就报"连接被拒绝"),
// 所以在录入时就拦住,并说清原因。
if isLoopbackHost(hostIP) {
return nil, fmt.Errorf("主机地址不能填 %s:平台运行在容器内,127.0.0.1 指向平台自身。"+
"请填写目标服务器的实际 IP(或平台可达的域名)", hostIP)
}
// 密钥认证必须有私钥,否则存进去也连不上,只会在测试连接时报认证失败
if authType == "key" && privateKey == "" {
return nil, fmt.Errorf("认证方式选择了密钥,但没有提供私钥内容,请粘贴私钥后再保存")
}
if authType == "password" && password == "" {
return nil, fmt.Errorf("认证方式选择了密码,但没有填写密码")
}
host := models.Host{
ID: repository.GetDB().NextID("hosts"),
Name: name,
HostIP: hostIP,
Port: port,
User: user,
AuthType: authType,
OS: os,
Arch: arch,
Labels: labels,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if password != "" {
enc, err := crypto.EncryptField(password)
if err != nil {
return nil, fmt.Errorf("密码加密失败: %w", err)
}
host.Password = enc
}
if privateKey != "" {
enc, err := crypto.EncryptField(privateKey)
if err != nil {
return nil, fmt.Errorf("私钥加密失败: %w", err)
}
host.PrivateKey = enc
}
if keyPhrase != "" {
enc, err := crypto.EncryptField(keyPhrase)
if err != nil {
return nil, fmt.Errorf("私钥口令加密失败: %w", err)
}
host.KeyPhrase = enc
}
db := repository.GetDB()
db.Lock()
db.Hosts = append(db.Hosts, host)
db.Unlock()
db.RequestSave()
go s.Validate(context.Background(), host.ID)
return &host, nil
}
func (s *HostService) Get(id uint) (*models.Host, error) {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Hosts {
if db.Hosts[i].ID == id {
h := db.Hosts[i]
return &h, nil
}
}
return nil, utils.ErrNotFound
}
func (s *HostService) List(page, pageSize int) ([]models.Host, int64, error) {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
total := int64(len(db.Hosts))
start := (page - 1) * pageSize
if start < 0 || start >= len(db.Hosts) {
return []models.Host{}, total, nil
}
end := start + pageSize
if end > len(db.Hosts) {
end = len(db.Hosts)
}
result := make([]models.Host, end-start)
copy(result, db.Hosts[start:end])
return result, total, nil
}
func (s *HostService) Update(id uint, updates map[string]interface{}) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Hosts {
if db.Hosts[i].ID == id {
if v, ok := updates["name"].(string); ok {
db.Hosts[i].Name = v
}
if v, ok := updates["status"].(string); ok {
db.Hosts[i].Status = v
}
if v, ok := updates["statusMessage"].(string); ok {
db.Hosts[i].StatusMessage = v
}
if v, ok := updates["os"].(string); ok {
db.Hosts[i].OS = v
}
if v, ok := updates["arch"].(string); ok {
db.Hosts[i].Arch = v
}
if v, ok := updates["hostIP"].(string); ok && v != "" {
db.Hosts[i].HostIP = v
}
if v, ok := updates["port"].(float64); ok && v > 0 {
db.Hosts[i].Port = int(v)
}
if v, ok := updates["user"].(string); ok && v != "" {
db.Hosts[i].User = v
}
if v, ok := updates["labels"].(string); ok {
db.Hosts[i].Labels = v
}
// 凭证编辑约定:非空才更新(留空=保留旧密文)
for _, field := range []string{"password", "privateKey", "keyPhrase"} {
if v, ok := updates[field].(string); ok && v != "" {
if enc, err := crypto.EncryptField(v); err == nil {
switch field {
case "password":
db.Hosts[i].Password = enc
case "privateKey":
db.Hosts[i].PrivateKey = enc
case "keyPhrase":
db.Hosts[i].KeyPhrase = enc
}
}
}
}
db.Hosts[i].UpdatedAt = time.Now()
db.RequestSave()
return nil
}
}
return utils.ErrNotFound
}
func (s *HostService) Delete(id uint) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i, h := range db.Hosts {
if h.ID == id {
db.Hosts = append(db.Hosts[:i], db.Hosts[i+1:]...)
db.RequestSave()
return nil
}
}
return utils.ErrNotFound
}
func (s *HostService) Validate(ctx context.Context, id uint) {
host, err := s.Get(id)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
client, err := NewSSHClientWithAuth(HostSSHAuth(host))
if err != nil {
s.Update(id, map[string]interface{}{"status": "error", "statusMessage": err.Error()})
return
}
defer client.Close()
// 注意:这里原来失败分支也写 status=active 且不写 statusMessage,
// 结果是「连不上也显示为正常,页面永远停在旧错误文案」,
// 用户会以为改完地址仍然不行。必须区分成功/失败并写清原因。
if info, err := client.GetHostInfo(ctx); err == nil {
s.Update(id, map[string]interface{}{
"status": "active",
"os": info.OS,
"arch": info.Arch,
"statusMessage": "连接成功",
})
} else {
s.Update(id, map[string]interface{}{
"status": "error",
"statusMessage": FormatSSHError(err),
})
}
}