254 lines
7.9 KiB
Go
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
|
|
}
|