482 lines
16 KiB
Go
482 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"oci-portal/internal/cache"
|
|
"oci-portal/internal/crypto"
|
|
"oci-portal/internal/model"
|
|
"oci-portal/internal/oci"
|
|
)
|
|
|
|
// OciConfigService 管理 API Key 配置的导入、测活与快照同步。
|
|
type OciConfigService struct {
|
|
db *gorm.DB
|
|
cipher *crypto.Cipher
|
|
client oci.Client
|
|
cleanupTasks *TaskService
|
|
// auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟),
|
|
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
|
|
auditRaw *cache.Cache
|
|
}
|
|
|
|
// NewOciConfigService 组装依赖。
|
|
func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client) *OciConfigService {
|
|
return &OciConfigService{db: db, cipher: cipher, client: client, auditRaw: cache.New(auditRawMax)}
|
|
}
|
|
|
|
// SetTenantCleanupDeps 注入租户删除提交后的任务内存状态同步依赖。
|
|
func (s *OciConfigService) SetTenantCleanupDeps(tasks *TaskService) {
|
|
s.cleanupTasks = tasks
|
|
}
|
|
|
|
// ImportInput 是导入一份 API Key 的输入:
|
|
// ConfigINI 与显式字段二选一(ConfigINI 优先),私钥内容必填。
|
|
type ImportInput struct {
|
|
Alias string
|
|
Group string
|
|
ConfigINI string
|
|
TenancyOCID string
|
|
UserOCID string
|
|
Region string
|
|
Fingerprint string
|
|
PrivateKey string
|
|
Passphrase string
|
|
// 多区域 / 多区间支持:开启后订阅区域与 compartment 入库缓存
|
|
MultiRegion bool
|
|
MultiCompartment bool
|
|
// ProxyID 关联出站代理,nil 直连
|
|
ProxyID *uint
|
|
}
|
|
|
|
// Changes 记录一次测活前后云端信息字段的变化,值为 [旧值, 新值]。
|
|
type Changes map[string][2]string
|
|
|
|
// Import 保存一份新的 API Key 配置,随后立即测活并获取账户类别。
|
|
// 测活失败不回滚导入,结果记录在快照的状态字段里。
|
|
func (s *OciConfigService) Import(ctx context.Context, in ImportInput) (*model.OciConfig, error) {
|
|
cred, err := s.buildCredentials(in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg, err := s.storeConfig(in.Alias, cred)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg.Group = in.Group
|
|
cfg.MultiRegion = in.MultiRegion
|
|
cfg.MultiCompartment = in.MultiCompartment
|
|
cfg.ProxyID = in.ProxyID
|
|
if err := s.refresh(ctx, cfg, cred); err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// Verify 重新测活并拉取云端信息,返回更新后的快照和字段变更集。
|
|
func (s *OciConfigService) Verify(ctx context.Context, id uint) (*model.OciConfig, Changes, error) {
|
|
var cfg model.OciConfig
|
|
if err := s.db.First(&cfg, id).Error; err != nil {
|
|
return nil, nil, fmt.Errorf("find oci config %d: %w", id, err)
|
|
}
|
|
cred, err := s.credentialsOf(&cfg)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
before := cfg
|
|
if err := s.refresh(ctx, &cfg, cred); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &cfg, diffSnapshots(before, cfg), nil
|
|
}
|
|
|
|
// UpdateInput 是修改配置的输入;空字段保持不变。
|
|
// 替换私钥时必须同时提供新指纹;Passphrase 非 nil 时整体覆盖(空串表示清除口令)。
|
|
// Group 非 nil 时整体覆盖(空串表示取消分组)。
|
|
// MultiRegion / MultiCompartment 非 nil 时切换开关,开启后随本次测活同步缓存。
|
|
type UpdateInput struct {
|
|
Alias string
|
|
Group *string
|
|
Region string
|
|
Fingerprint string
|
|
PrivateKey string
|
|
Passphrase *string
|
|
MultiRegion *bool
|
|
MultiCompartment *bool
|
|
// ProxyID 非 nil 时切换关联:0 表示解除代理,>0 表示关联到该代理
|
|
ProxyID *uint
|
|
}
|
|
|
|
// Update 修改配置的别名 / 默认区域 / 凭据字段,随后立即重新测活。
|
|
func (s *OciConfigService) Update(ctx context.Context, id uint, in UpdateInput) (*model.OciConfig, Changes, error) {
|
|
if in.PrivateKey != "" && in.Fingerprint == "" {
|
|
return nil, nil, fmt.Errorf("update oci config: fingerprint is required when replacing private key")
|
|
}
|
|
var cfg model.OciConfig
|
|
if err := s.db.WithContext(ctx).First(&cfg, id).Error; err != nil {
|
|
return nil, nil, fmt.Errorf("find oci config %d: %w", id, err)
|
|
}
|
|
if err := s.applyUpdate(&cfg, in); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
cred, err := s.credentialsOf(&cfg)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
before := cfg
|
|
if err := s.refresh(ctx, &cfg, cred); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &cfg, diffSnapshots(before, cfg), nil
|
|
}
|
|
|
|
// applyUpdate 把非空输入写进快照字段,凭据字段交由 applyCredentialUpdate 加密。
|
|
func (s *OciConfigService) applyUpdate(cfg *model.OciConfig, in UpdateInput) error {
|
|
if in.Alias != "" {
|
|
cfg.Alias = in.Alias
|
|
}
|
|
if in.Group != nil {
|
|
cfg.Group = *in.Group
|
|
}
|
|
if in.Region != "" {
|
|
cfg.Region = in.Region
|
|
}
|
|
if in.Fingerprint != "" {
|
|
cfg.Fingerprint = in.Fingerprint
|
|
}
|
|
if in.MultiRegion != nil {
|
|
cfg.MultiRegion = *in.MultiRegion
|
|
}
|
|
if in.MultiCompartment != nil {
|
|
cfg.MultiCompartment = *in.MultiCompartment
|
|
}
|
|
if in.ProxyID != nil {
|
|
if *in.ProxyID == 0 {
|
|
cfg.ProxyID = nil
|
|
} else {
|
|
cfg.ProxyID = in.ProxyID
|
|
}
|
|
}
|
|
return s.applyCredentialUpdate(cfg, in)
|
|
}
|
|
|
|
// applyCredentialUpdate 重新加密私钥与口令;Passphrase 非 nil 时整体覆盖。
|
|
func (s *OciConfigService) applyCredentialUpdate(cfg *model.OciConfig, in UpdateInput) error {
|
|
if in.PrivateKey != "" {
|
|
enc, err := s.cipher.EncryptString(in.PrivateKey)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt private key: %w", err)
|
|
}
|
|
cfg.PrivateKeyEnc = enc
|
|
}
|
|
if in.Passphrase == nil {
|
|
return nil
|
|
}
|
|
cfg.PassphraseEnc = ""
|
|
if *in.Passphrase != "" {
|
|
enc, err := s.cipher.EncryptString(*in.Passphrase)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt passphrase: %w", err)
|
|
}
|
|
cfg.PassphraseEnc = enc
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ConfigSummary 是列表接口的瘦身视图:只含列表页与全局作用域选择器
|
|
// 消费的字段,订阅 / 促销 / 凭据元数据等详情字段走 Get 单查。
|
|
type ConfigSummary struct {
|
|
ID uint `json:"id"`
|
|
Alias string `json:"alias"`
|
|
Group string `json:"group"`
|
|
TenancyOCID string `json:"tenancyOcid"`
|
|
TenancyName string `json:"tenancyName"`
|
|
Region string `json:"region"`
|
|
AccountType string `json:"accountType"`
|
|
AliveStatus string `json:"aliveStatus"`
|
|
LastError string `json:"lastError"`
|
|
LastVerifiedAt *time.Time `json:"lastVerifiedAt"`
|
|
MultiRegion bool `json:"multiRegion"`
|
|
MultiCompartment bool `json:"multiCompartment"`
|
|
ProxyID *uint `json:"proxyId"`
|
|
// ProxyName 由 List 按关联填充,供表格代理列 hover 展示
|
|
ProxyName string `json:"proxyName"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
func toConfigSummary(cfg model.OciConfig) ConfigSummary {
|
|
return ConfigSummary{
|
|
ID: cfg.ID,
|
|
Alias: cfg.Alias,
|
|
Group: cfg.Group,
|
|
TenancyOCID: cfg.TenancyOCID,
|
|
TenancyName: cfg.TenancyName,
|
|
Region: cfg.Region,
|
|
AccountType: cfg.AccountType,
|
|
AliveStatus: cfg.AliveStatus,
|
|
LastError: cfg.LastError,
|
|
LastVerifiedAt: cfg.LastVerifiedAt,
|
|
MultiRegion: cfg.MultiRegion,
|
|
MultiCompartment: cfg.MultiCompartment,
|
|
ProxyID: cfg.ProxyID,
|
|
CreatedAt: cfg.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// List 返回全部配置的列表摘要。
|
|
func (s *OciConfigService) List(ctx context.Context) ([]ConfigSummary, error) {
|
|
items := make([]model.OciConfig, 0)
|
|
if err := s.db.WithContext(ctx).Order("id").Find(&items).Error; err != nil {
|
|
return nil, fmt.Errorf("list oci configs: %w", err)
|
|
}
|
|
names, err := s.proxyNames(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
summaries := make([]ConfigSummary, len(items))
|
|
for i, cfg := range items {
|
|
summaries[i] = toConfigSummary(cfg)
|
|
if cfg.ProxyID != nil {
|
|
summaries[i].ProxyName = names[*cfg.ProxyID]
|
|
}
|
|
}
|
|
return summaries, nil
|
|
}
|
|
|
|
// proxyNames 一次取回代理 id → 名称映射,避免列表逐行查询。
|
|
func (s *OciConfigService) proxyNames(ctx context.Context) (map[uint]string, error) {
|
|
rows := []model.Proxy{}
|
|
if err := s.db.WithContext(ctx).Select("id", "name").Find(&rows).Error; err != nil {
|
|
return nil, fmt.Errorf("list proxy names: %w", err)
|
|
}
|
|
out := make(map[uint]string, len(rows))
|
|
for _, r := range rows {
|
|
out[r.ID] = r.Name
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Get 返回单个配置快照。
|
|
func (s *OciConfigService) Get(ctx context.Context, id uint) (*model.OciConfig, error) {
|
|
var cfg model.OciConfig
|
|
if err := s.db.WithContext(ctx).First(&cfg, id).Error; err != nil {
|
|
return nil, fmt.Errorf("find oci config %d: %w", id, err)
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
// Delete 在单一事务中删除配置及全部租户级本地关联数据。
|
|
func (s *OciConfigService) Delete(ctx context.Context, id uint) error {
|
|
unlock := func() {}
|
|
if s.cleanupTasks != nil {
|
|
unlock = s.cleanupTasks.lockTenantCleanup()
|
|
}
|
|
defer unlock()
|
|
result, err := s.deleteTenant(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.afterTenantDelete(ctx, result)
|
|
return nil
|
|
}
|
|
|
|
// refresh 执行测活与账户信息拉取,并把最新云端状态同步进快照。
|
|
// OCI 调用失败体现在快照状态字段,只有本地存储失败才返回 error。
|
|
func (s *OciConfigService) refresh(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) error {
|
|
now := time.Now()
|
|
cfg.LastVerifiedAt = &now
|
|
info, err := s.client.ValidateKey(ctx, cred)
|
|
if err != nil {
|
|
cfg.AliveStatus = model.AliveStatusDead
|
|
cfg.LastError = oci.CompactError(err)
|
|
} else {
|
|
cfg.AliveStatus = model.AliveStatusAlive
|
|
cfg.LastError = ""
|
|
cfg.TenancyName = info.Name
|
|
cfg.HomeRegionKey = info.HomeRegionKey
|
|
s.applyProfile(ctx, cfg, cred)
|
|
s.syncScopeCaches(ctx, cfg, cred)
|
|
}
|
|
s.applySuspension(ctx, cfg, cred)
|
|
return s.saveSnapshot(ctx, cfg)
|
|
}
|
|
|
|
// saveSnapshot 只更新当前版本,避免在途测活把已删租户重新插入。
|
|
func (s *OciConfigService) saveSnapshot(ctx context.Context, cfg *model.OciConfig) error {
|
|
res := s.db.WithContext(ctx).Model(&model.OciConfig{}).
|
|
Where("id = ? AND updated_at = ?", cfg.ID, cfg.UpdatedAt).
|
|
Select("*").Omit("id", "created_at").Updates(cfg)
|
|
if res.Error != nil {
|
|
return fmt.Errorf("save oci config snapshot: %w", res.Error)
|
|
}
|
|
if res.RowsAffected != 1 {
|
|
return fmt.Errorf("save oci config snapshot: %w", gorm.ErrRecordNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// applySuspension 查询账户能力接口:云端标记暂停的租户覆盖测活结论为 suspended。
|
|
// 该接口对暂停/已终止租户仍可访问(常规 API 此时多被拒,会误判失联);查询失败不改变结论。
|
|
func (s *OciConfigService) applySuspension(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) {
|
|
caps, err := s.client.GetAccountCapabilities(ctx, cred, homeRegionOf(cfg))
|
|
if err != nil || !caps.Suspended {
|
|
return
|
|
}
|
|
cfg.AliveStatus = model.AliveStatusSuspended
|
|
cfg.LastError = strings.TrimSpace("账户已被云端暂停 " + capsStatusNote(caps))
|
|
}
|
|
|
|
// capsStatusNote 生成暂停详情备注(账户状态,如 terminated)。
|
|
func capsStatusNote(caps oci.AccountCapabilities) string {
|
|
if caps.AccountStatus == "" {
|
|
return ""
|
|
}
|
|
return "(account status: " + caps.AccountStatus + ")"
|
|
}
|
|
|
|
// applyProfile 拉取订阅信息;失败只把类别降级为 unknown,不影响测活结论。
|
|
func (s *OciConfigService) applyProfile(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) {
|
|
profile, err := s.client.FetchAccountProfile(ctx, cred)
|
|
if err != nil {
|
|
cfg.AccountType = model.AccountTypeUnknown
|
|
cfg.LastError = fmt.Sprintf("fetch account profile: %s", oci.CompactError(err))
|
|
return
|
|
}
|
|
cfg.AccountType = profile.AccountType
|
|
cfg.SubscriptionID = profile.SubscriptionID
|
|
cfg.PaymentModel = profile.PaymentModel
|
|
cfg.SubscriptionTier = profile.SubscriptionTier
|
|
cfg.PromotionStatus = profile.PromotionStatus
|
|
cfg.PromotionAmount = profile.PromotionAmount
|
|
cfg.PromotionExpires = profile.PromotionExpires
|
|
}
|
|
|
|
func (s *OciConfigService) buildCredentials(in ImportInput) (oci.Credentials, error) {
|
|
cred := oci.Credentials{
|
|
TenancyOCID: in.TenancyOCID,
|
|
UserOCID: in.UserOCID,
|
|
Region: in.Region,
|
|
Fingerprint: in.Fingerprint,
|
|
}
|
|
if in.ConfigINI != "" {
|
|
parsed, err := oci.ParseConfigINI(in.ConfigINI)
|
|
if err != nil {
|
|
return oci.Credentials{}, err
|
|
}
|
|
cred = parsed
|
|
}
|
|
cred.PrivateKey = in.PrivateKey
|
|
if cred.Passphrase == "" {
|
|
cred.Passphrase = in.Passphrase
|
|
}
|
|
if err := cred.Validate(); err != nil {
|
|
return oci.Credentials{}, err
|
|
}
|
|
return cred, nil
|
|
}
|
|
|
|
func (s *OciConfigService) storeConfig(alias string, cred oci.Credentials) (*model.OciConfig, error) {
|
|
keyEnc, err := s.cipher.EncryptString(cred.PrivateKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encrypt private key: %w", err)
|
|
}
|
|
passEnc := ""
|
|
if cred.Passphrase != "" {
|
|
if passEnc, err = s.cipher.EncryptString(cred.Passphrase); err != nil {
|
|
return nil, fmt.Errorf("encrypt passphrase: %w", err)
|
|
}
|
|
}
|
|
cfg := &model.OciConfig{
|
|
Alias: alias,
|
|
UserOCID: cred.UserOCID,
|
|
TenancyOCID: cred.TenancyOCID,
|
|
Fingerprint: cred.Fingerprint,
|
|
Region: cred.Region,
|
|
PrivateKeyEnc: keyEnc,
|
|
PassphraseEnc: passEnc,
|
|
AccountType: model.AccountTypeUnknown,
|
|
AliveStatus: model.AliveStatusUnknown,
|
|
}
|
|
if err := s.db.Create(cfg).Error; err != nil {
|
|
return nil, fmt.Errorf("create oci config: %w", err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// credentialsOf 从快照解密出完整凭据。
|
|
func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials, error) {
|
|
key, err := s.cipher.DecryptString(cfg.PrivateKeyEnc)
|
|
if err != nil {
|
|
return oci.Credentials{}, fmt.Errorf("decrypt private key of config %d: %w", cfg.ID, err)
|
|
}
|
|
pass := ""
|
|
if cfg.PassphraseEnc != "" {
|
|
if pass, err = s.cipher.DecryptString(cfg.PassphraseEnc); err != nil {
|
|
return oci.Credentials{}, fmt.Errorf("decrypt passphrase of config %d: %w", cfg.ID, err)
|
|
}
|
|
}
|
|
spec, err := s.proxySpecOf(cfg)
|
|
if err != nil {
|
|
return oci.Credentials{}, err
|
|
}
|
|
return oci.Credentials{
|
|
TenancyOCID: cfg.TenancyOCID,
|
|
UserOCID: cfg.UserOCID,
|
|
Region: cfg.Region,
|
|
Fingerprint: cfg.Fingerprint,
|
|
PrivateKey: key,
|
|
Passphrase: pass,
|
|
Proxy: spec,
|
|
}, nil
|
|
}
|
|
|
|
// proxySpecOf 加载租户关联的出站代理;未关联返回 nil(直连)。
|
|
// 代理行缺失或解密失败时报错而非静默直连,避免期望走代理的流量泄露真实出口。
|
|
func (s *OciConfigService) proxySpecOf(cfg *model.OciConfig) (*oci.ProxySpec, error) {
|
|
if cfg.ProxyID == nil {
|
|
return nil, nil
|
|
}
|
|
var row model.Proxy
|
|
if err := s.db.First(&row, *cfg.ProxyID).Error; err != nil {
|
|
return nil, fmt.Errorf("find proxy %d of config %d: %w", *cfg.ProxyID, cfg.ID, err)
|
|
}
|
|
password := ""
|
|
if row.PasswordEnc != "" {
|
|
p, err := s.cipher.DecryptString(row.PasswordEnc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypt proxy password: %w", err)
|
|
}
|
|
password = p
|
|
}
|
|
return &oci.ProxySpec{
|
|
Type: row.Type, Host: row.Host, Port: row.Port,
|
|
Username: row.Username, Password: password,
|
|
}, nil
|
|
}
|
|
|
|
// diffSnapshots 找出两次快照间云端信息字段的变化。
|
|
func diffSnapshots(before, after model.OciConfig) Changes {
|
|
fields := map[string][2]string{
|
|
"aliveStatus": {before.AliveStatus, after.AliveStatus},
|
|
"tenancyName": {before.TenancyName, after.TenancyName},
|
|
"homeRegionKey": {before.HomeRegionKey, after.HomeRegionKey},
|
|
"accountType": {before.AccountType, after.AccountType},
|
|
"subscriptionId": {before.SubscriptionID, after.SubscriptionID},
|
|
"paymentModel": {before.PaymentModel, after.PaymentModel},
|
|
"subscriptionTier": {before.SubscriptionTier, after.SubscriptionTier},
|
|
"promotionStatus": {before.PromotionStatus, after.PromotionStatus},
|
|
}
|
|
changes := make(Changes)
|
|
for name, pair := range fields {
|
|
if pair[0] != pair[1] {
|
|
changes[name] = pair
|
|
}
|
|
}
|
|
return changes
|
|
}
|