- 部署删除: k8s 模式清理 StatefulSet/Service/ConfigMap 与 PVC,先卸载关联采集器 - 采集器删除: 同步卸载成功后再删记录,失败保留避免孤儿 - 集群/存储/主机删除: 有部署或采集器引用时拒绝,避免悬空外键 - Token RBAC 补 services/proxy,保证 cluster-svc 数据源可查询
239 lines
6.4 KiB
Go
239 lines
6.4 KiB
Go
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),
|
||
})
|
||
}
|
||
}
|