Files
oci-portal/internal/service/ociconfig.go
T
2026-07-22 16:51:23 +08:00

499 lines
16 KiB
Go

package service
import (
"context"
"fmt"
"strings"
"sync"
"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
// bindWG 追踪保留 IP 绑定与桶清空等后台 goroutine;bindStop 关服时令其提前退出。
bindWG sync.WaitGroup
bindStop chan struct{}
// purgeMu/purgeInFlight 是桶清空在途注册表:同一桶(cfg/region/bucket)单飞。
purgeMu sync.Mutex
purgeInFlight map[string]bool
}
// 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), bindStop: make(chan struct{}),
purgeInFlight: map[string]bool{},
}
}
// 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
}
// 代理先于任何远端访问解析并挂进凭据:不存在/非法的代理直接拒绝导入,
// 避免首次测活绕过代理直连,泄露真实出口
cred.Proxy, err = s.proxySpecOf(in.ProxyID)
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.ProxyID)
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(proxyID *uint) (*oci.ProxySpec, error) {
if proxyID == nil {
return nil, nil
}
var row model.Proxy
if err := s.db.First(&row, *proxyID).Error; err != nil {
return nil, fmt.Errorf("find proxy %d: %w", *proxyID, 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
}