Files
oci-portal/internal/service/proxy.go
T

254 lines
7.9 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"gorm.io/gorm"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// 代理配置错误;api 层映射 400 / 409。
var (
// ErrProxyInvalid 表示字段校验失败。
ErrProxyInvalid = errors.New("代理配置非法")
// ErrProxyInUse 表示代理仍被租户引用,拒绝删除。
ErrProxyInUse = errors.New("代理仍被租户关联,请先解除关联")
)
// ProxyService 管理出站代理配置;密码 AES-GCM 加密落库。
type ProxyService struct {
db *gorm.DB
cipher *crypto.Cipher
// wg 追踪创建 / 更新后的异步出口地理探测,Wait 供优雅关停与测试收敛
wg sync.WaitGroup
// geoClientFor 构造经代理出站的探测 client;测试注入替身避免真实外呼
geoClientFor func(*oci.ProxySpec) *http.Client
}
// NewProxyService 组装依赖。
func NewProxyService(db *gorm.DB, cipher *crypto.Cipher) *ProxyService {
return &ProxyService{db: db, cipher: cipher, geoClientFor: oci.HTTPClientFor}
}
// Wait 阻塞至在途的地理探测全部完成;进程退出前调用。
func (s *ProxyService) Wait() {
s.wg.Wait()
}
// ProxyView 是代理的脱敏视图,绝不含密码明文。
type ProxyView struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
PasswordSet bool `json:"passwordSet"`
// Country / City 为出口实测地区;GeoAt 空串表示尚未探测(前端显示「检测中」)
Country string `json:"country"`
City string `json:"city"`
GeoAt string `json:"geoAt"`
// UsedBy 为关联此代理的租户数,前端据此提示删除约束
UsedBy int64 `json:"usedBy"`
CreatedAt string `json:"createdAt"`
}
// ProxyInput 是创建 / 更新请求体;Name 缺省自动生成,
// Password 为 nil 表示沿用已存值,空串表示清除。
type ProxyInput struct {
Name string `json:"name"`
Type string `json:"type" binding:"required"`
Host string `json:"host" binding:"required"`
Port int `json:"port" binding:"required"`
Username string `json:"username"`
Password *string `json:"password"`
}
// validateProxyInput 校验类型 / 端口 / 主机;错误信息用户可读。
func validateProxyInput(in ProxyInput) error {
if in.Type != "socks5" && in.Type != "http" && in.Type != "https" {
return fmt.Errorf("类型仅支持 socks5 / http / https: %w", ErrProxyInvalid)
}
if in.Port < 1 || in.Port > 65535 {
return fmt.Errorf("端口须在 1-65535 之间: %w", ErrProxyInvalid)
}
if strings.TrimSpace(in.Host) == "" {
return fmt.Errorf("主机不能为空: %w", ErrProxyInvalid)
}
return nil
}
// List 返回全部代理的脱敏视图,附带引用计数。
func (s *ProxyService) List(ctx context.Context) ([]ProxyView, error) {
rows := []model.Proxy{}
if err := s.db.WithContext(ctx).Order("id").Find(&rows).Error; err != nil {
return nil, fmt.Errorf("list proxies: %w", err)
}
out := make([]ProxyView, 0, len(rows))
for i := range rows {
v, err := s.viewOf(ctx, &rows[i])
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
// viewOf 组装单条脱敏视图。
func (s *ProxyService) viewOf(ctx context.Context, p *model.Proxy) (ProxyView, error) {
var used int64
err := s.db.WithContext(ctx).Model(&model.OciConfig{}).Where("proxy_id = ?", p.ID).Count(&used).Error
if err != nil {
return ProxyView{}, fmt.Errorf("count proxy refs: %w", err)
}
return ProxyView{
ID: p.ID, Name: p.Name, Type: p.Type, Host: p.Host, Port: p.Port,
Username: p.Username, PasswordSet: p.PasswordEnc != "", UsedBy: used,
Country: p.Country, City: p.City, GeoAt: formatTimePtr(p.GeoAt),
CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}, nil
}
// formatTimePtr 把可空时间格式化为 RFC3339,nil 返回空串。
func formatTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("2006-01-02T15:04:05Z07:00")
}
// autoProxyName 名称缺省时生成 `{type}-{host}:{port}`,与现有代理重名时追加序号后缀。
func (s *ProxyService) autoProxyName(ctx context.Context, in ProxyInput, selfID uint) string {
base := fmt.Sprintf("%s-%s:%d", in.Type, strings.TrimSpace(in.Host), in.Port)
if len(base) > 56 {
base = base[:56]
}
name := base
for i := 2; i <= 999; i++ {
var n int64
s.db.WithContext(ctx).Model(&model.Proxy{}).
Where("name = ? AND id <> ?", name, selfID).Count(&n)
if n == 0 {
return name
}
name = fmt.Sprintf("%s-%d", base, i)
}
return name
}
// Create 新建代理;名称唯一冲突返回可读错误,成功后异步探测出口地区。
func (s *ProxyService) Create(ctx context.Context, in ProxyInput) (ProxyView, error) {
if err := validateProxyInput(in); err != nil {
return ProxyView{}, err
}
if strings.TrimSpace(in.Name) == "" {
in.Name = s.autoProxyName(ctx, in, 0)
}
row := model.Proxy{
Name: strings.TrimSpace(in.Name), Type: in.Type,
Host: strings.TrimSpace(in.Host), Port: in.Port, Username: in.Username,
}
if err := s.fillPassword(&row, in.Password); err != nil {
return ProxyView{}, err
}
if err := s.db.WithContext(ctx).Create(&row).Error; err != nil {
return ProxyView{}, fmt.Errorf("create proxy: %w", err)
}
s.probeGeoAsync(row.ID)
return s.viewOf(ctx, &row)
}
// Update 更新代理;密码缺省沿用、空串清除,名称留空重新自动生成,成功后重测地区。
func (s *ProxyService) Update(ctx context.Context, id uint, in ProxyInput) (ProxyView, error) {
if err := validateProxyInput(in); err != nil {
return ProxyView{}, err
}
if strings.TrimSpace(in.Name) == "" {
in.Name = s.autoProxyName(ctx, in, id)
}
var row model.Proxy
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
return ProxyView{}, fmt.Errorf("find proxy %d: %w", id, err)
}
row.Name, row.Type = strings.TrimSpace(in.Name), in.Type
row.Host, row.Port, row.Username = strings.TrimSpace(in.Host), in.Port, in.Username
if err := s.fillPassword(&row, in.Password); err != nil {
return ProxyView{}, err
}
if err := s.db.WithContext(ctx).Save(&row).Error; err != nil {
return ProxyView{}, fmt.Errorf("update proxy: %w", err)
}
s.probeGeoAsync(row.ID)
return s.viewOf(ctx, &row)
}
// fillPassword 按输入语义写密文:nil 沿用、空串清除、非空加密覆盖。
func (s *ProxyService) fillPassword(row *model.Proxy, password *string) error {
if password == nil {
return nil
}
if *password == "" {
row.PasswordEnc = ""
return nil
}
enc, err := s.cipher.EncryptString(*password)
if err != nil {
return fmt.Errorf("encrypt proxy password: %w", err)
}
row.PasswordEnc = enc
return nil
}
// Delete 删除代理;仍被租户引用时拒绝。
func (s *ProxyService) Delete(ctx context.Context, id uint) error {
var used int64
err := s.db.WithContext(ctx).Model(&model.OciConfig{}).Where("proxy_id = ?", id).Count(&used).Error
if err != nil {
return fmt.Errorf("count proxy refs: %w", err)
}
if used > 0 {
return ErrProxyInUse
}
res := s.db.WithContext(ctx).Delete(&model.Proxy{}, id)
if res.Error != nil {
return fmt.Errorf("delete proxy: %w", res.Error)
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// SpecOf 解密并组装 SDK 用的代理参数;id 为 nil 返回 nil(直连)。
func (s *ProxyService) SpecOf(ctx context.Context, id *uint) (*oci.ProxySpec, error) {
if id == nil {
return nil, nil
}
var row model.Proxy
if err := s.db.WithContext(ctx).First(&row, *id).Error; err != nil {
return nil, fmt.Errorf("find proxy %d: %w", *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
}