Files
logan b433b5f9dd
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s
fix: 删除操作清理远端资源并拦截悬空引用
- 部署删除: k8s 模式清理 StatefulSet/Service/ConfigMap 与 PVC,先卸载关联采集器
- 采集器删除: 同步卸载成功后再删记录,失败保留避免孤儿
- 集群/存储/主机删除: 有部署或采集器引用时拒绝,避免悬空外键
- Token RBAC 补 services/proxy,保证 cluster-svc 数据源可查询
2026-10-30 01:17:26 +08:00

239 lines
6.4 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 (
"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 {
for _, d := range db.Deployments {
if d.HostID != nil && *d.HostID == id {
return fmt.Errorf("仍有部署使用该主机,请先删除部署")
}
}
for _, c := range db.Collectors {
if c.HostID != nil && *c.HostID == id {
return fmt.Errorf("仍有采集器使用该主机,请先删除采集器")
}
}
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),
})
}
}