mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
495 lines
14 KiB
Go
495 lines
14 KiB
Go
package exportproxy
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"vocat/internal/store"
|
|
)
|
|
|
|
const (
|
|
SettingKey = "developer.export_proxy.configs"
|
|
PasswordMask = "••••••••"
|
|
ReservedID = "export-proxy"
|
|
)
|
|
|
|
var (
|
|
ErrNotFound = errors.New("export proxy configuration not found")
|
|
ErrDisabled = errors.New("export proxy is disabled")
|
|
)
|
|
|
|
type Config struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
DeviceID string `json:"device_id"`
|
|
Interface string `json:"interface"`
|
|
Mode string `json:"mode"`
|
|
ListenHost string `json:"listen_host"`
|
|
ListenPort int `json:"listen_port"`
|
|
Enabled bool `json:"enabled"`
|
|
AuthEnabled bool `json:"auth_enabled"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type Status struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Mode string `json:"mode"`
|
|
Enabled bool `json:"enabled"`
|
|
Running bool `json:"running"`
|
|
Listen string `json:"listen"`
|
|
Error string `json:"error,omitempty"`
|
|
StartedAt time.Time `json:"started_at,omitempty"`
|
|
}
|
|
|
|
type Manager struct {
|
|
mu sync.Mutex
|
|
store *store.Store
|
|
logger *slog.Logger
|
|
configs []Config
|
|
listeners map[string]net.Listener
|
|
started map[string]time.Time
|
|
lastError map[string]string
|
|
disabled bool
|
|
}
|
|
|
|
func New(ctx context.Context, database *store.Store, logger *slog.Logger, legacyConfigPath string) (*Manager, error) {
|
|
if database == nil {
|
|
return nil, errors.New("export proxy store is required")
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
manager := &Manager{
|
|
store: database, logger: logger,
|
|
listeners: make(map[string]net.Listener),
|
|
started: make(map[string]time.Time),
|
|
lastError: make(map[string]string),
|
|
}
|
|
migrated, err := manager.load(ctx, legacyConfigPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if migrated {
|
|
if err := manager.saveLocked(ctx); err != nil {
|
|
return nil, fmt.Errorf("migrate legacy export proxy configurations: %w", err)
|
|
}
|
|
_ = RemoveLegacyConfig(legacyConfigPath)
|
|
}
|
|
|
|
for _, config := range manager.configs {
|
|
if config.Enabled {
|
|
if err := manager.start(ctx, config.ID); err != nil {
|
|
manager.logger.Warn("start built-in export proxy", "id", config.ID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
return manager, nil
|
|
}
|
|
|
|
func RemoveLegacyConfig(path string) error {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
err := os.Remove(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (manager *Manager) load(ctx context.Context, legacyConfigPath string) (bool, error) {
|
|
setting, err := manager.store.AppSetting(ctx, SettingKey)
|
|
if err == nil {
|
|
if err := json.Unmarshal(setting.Value, &manager.configs); err != nil {
|
|
return false, fmt.Errorf("decode export proxy configurations: %w", err)
|
|
}
|
|
return false, nil
|
|
}
|
|
if !errors.Is(err, store.ErrNotFound) {
|
|
return false, err
|
|
}
|
|
legacy, err := os.ReadFile(strings.TrimSpace(legacyConfigPath))
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) || strings.TrimSpace(legacyConfigPath) == "" {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
if err := json.Unmarshal(legacy, &manager.configs); err != nil {
|
|
return false, fmt.Errorf("decode legacy export proxy configurations: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (manager *Manager) saveLocked(ctx context.Context) error {
|
|
raw, err := json.Marshal(manager.configs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return manager.store.UpsertAppSetting(ctx, store.AppSetting{Key: SettingKey, Value: raw, Sensitive: true})
|
|
}
|
|
|
|
func (manager *Manager) Configs() ([]Config, error) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if manager.disabled {
|
|
return nil, ErrDisabled
|
|
}
|
|
result := make([]Config, len(manager.configs))
|
|
for index, config := range manager.configs {
|
|
result[index] = redact(config)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// EnabledConfigForDevice returns the first enabled configuration bound to the
|
|
// given device, reporting whether one exists. It is used to block turning off a
|
|
// device's roaming data while one of its export proxies is still running.
|
|
func (manager *Manager) EnabledConfigForDevice(deviceID string) (Config, bool) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if manager.disabled {
|
|
return Config{}, false
|
|
}
|
|
for _, config := range manager.configs {
|
|
if config.DeviceID == deviceID && config.Enabled {
|
|
return redact(config), true
|
|
}
|
|
}
|
|
return Config{}, false
|
|
}
|
|
|
|
func (manager *Manager) Status() ([]Status, error) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if manager.disabled {
|
|
return nil, ErrDisabled
|
|
}
|
|
result := make([]Status, 0, len(manager.configs))
|
|
for _, config := range manager.configs {
|
|
status := Status{ID: config.ID, Name: config.Name, Mode: config.Mode, Enabled: config.Enabled, Error: manager.lastError[config.ID]}
|
|
if listener := manager.listeners[config.ID]; listener != nil {
|
|
status.Running = true
|
|
status.Listen = listener.Addr().String()
|
|
status.StartedAt = manager.started[config.ID]
|
|
}
|
|
result = append(result, status)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (manager *Manager) Create(ctx context.Context, config Config) (Config, error) {
|
|
config.ID = generateID()
|
|
if err := manager.prepareConfig(ctx, &config); err != nil {
|
|
return Config{}, err
|
|
}
|
|
manager.mu.Lock()
|
|
if manager.disabled {
|
|
manager.mu.Unlock()
|
|
return Config{}, ErrDisabled
|
|
}
|
|
if err := manager.checkPortLocked(config, ""); err != nil {
|
|
manager.mu.Unlock()
|
|
return Config{}, err
|
|
}
|
|
manager.configs = append(manager.configs, config)
|
|
if err := manager.saveLocked(ctx); err != nil {
|
|
manager.configs = manager.configs[:len(manager.configs)-1]
|
|
manager.mu.Unlock()
|
|
return Config{}, err
|
|
}
|
|
manager.mu.Unlock()
|
|
if config.Enabled {
|
|
if err := manager.start(ctx, config.ID); err != nil {
|
|
_ = manager.Delete(context.Background(), config.ID)
|
|
return Config{}, err
|
|
}
|
|
}
|
|
return redact(config), nil
|
|
}
|
|
|
|
func (manager *Manager) Update(ctx context.Context, id string, incoming Config) (Config, error) {
|
|
incoming.ID = strings.TrimSpace(id)
|
|
manager.mu.Lock()
|
|
if manager.disabled {
|
|
manager.mu.Unlock()
|
|
return Config{}, ErrDisabled
|
|
}
|
|
existing, index := manager.configByIDLocked(incoming.ID)
|
|
manager.mu.Unlock()
|
|
if index < 0 {
|
|
return Config{}, ErrNotFound
|
|
}
|
|
if incoming.Password == "" || incoming.Password == PasswordMask {
|
|
incoming.Password = existing.Password
|
|
}
|
|
if err := manager.prepareConfig(ctx, &incoming); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
manager.mu.Lock()
|
|
if manager.disabled {
|
|
manager.mu.Unlock()
|
|
return Config{}, ErrDisabled
|
|
}
|
|
existing, index = manager.configByIDLocked(incoming.ID)
|
|
if index < 0 {
|
|
manager.mu.Unlock()
|
|
return Config{}, ErrNotFound
|
|
}
|
|
if err := manager.checkPortLocked(incoming, incoming.ID); err != nil {
|
|
manager.mu.Unlock()
|
|
return Config{}, err
|
|
}
|
|
wasRunning := manager.listeners[incoming.ID] != nil
|
|
runtimeChanged := existing.Mode != incoming.Mode || existing.Interface != incoming.Interface ||
|
|
existing.ListenHost != incoming.ListenHost || existing.ListenPort != incoming.ListenPort ||
|
|
existing.AuthEnabled != incoming.AuthEnabled || existing.Username != incoming.Username || existing.Password != incoming.Password
|
|
manager.configs[index] = incoming
|
|
if err := manager.saveLocked(ctx); err != nil {
|
|
manager.configs[index] = existing
|
|
manager.mu.Unlock()
|
|
return Config{}, err
|
|
}
|
|
manager.mu.Unlock()
|
|
|
|
switch {
|
|
case !incoming.Enabled:
|
|
manager.stop(incoming.ID)
|
|
case !wasRunning || runtimeChanged || !existing.Enabled:
|
|
if err := manager.start(ctx, incoming.ID); err != nil {
|
|
return redact(incoming), err
|
|
}
|
|
}
|
|
return redact(incoming), nil
|
|
}
|
|
|
|
func (manager *Manager) Delete(ctx context.Context, id string) error {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if manager.disabled {
|
|
return ErrDisabled
|
|
}
|
|
_, index := manager.configByIDLocked(strings.TrimSpace(id))
|
|
if index < 0 {
|
|
return ErrNotFound
|
|
}
|
|
manager.stopLocked(id)
|
|
manager.configs = append(manager.configs[:index], manager.configs[index+1:]...)
|
|
return manager.saveLocked(ctx)
|
|
}
|
|
|
|
// DeleteAllAndDisable is irreversible for the active developer-mode session:
|
|
// it closes every listener, removes every saved proxy, and rejects new work.
|
|
func (manager *Manager) DeleteAllAndDisable(ctx context.Context) error {
|
|
manager.mu.Lock()
|
|
for id := range manager.listeners {
|
|
manager.stopLocked(id)
|
|
}
|
|
manager.configs = nil
|
|
manager.disabled = true
|
|
manager.mu.Unlock()
|
|
err := manager.store.DeleteAppSetting(ctx, SettingKey)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (manager *Manager) Close() error {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
manager.disabled = true
|
|
for id := range manager.listeners {
|
|
manager.stopLocked(id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (manager *Manager) prepareConfig(ctx context.Context, config *Config) error {
|
|
config.Name = strings.TrimSpace(config.Name)
|
|
config.DeviceID = strings.TrimSpace(config.DeviceID)
|
|
config.Interface = strings.TrimSpace(config.Interface)
|
|
config.Mode = strings.ToLower(strings.TrimSpace(config.Mode))
|
|
config.ListenHost = strings.TrimSpace(config.ListenHost)
|
|
config.Username = strings.TrimSpace(config.Username)
|
|
if config.Name == "" {
|
|
config.Name = "proxy-" + config.ID[:4]
|
|
}
|
|
if config.DeviceID == "" {
|
|
return errors.New("device is required")
|
|
}
|
|
device, err := manager.store.Device(ctx, config.DeviceID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return errors.New("configured device was not found")
|
|
}
|
|
return err
|
|
}
|
|
if strings.TrimSpace(device.Interface) == "" {
|
|
return errors.New("the selected device has no cellular interface")
|
|
}
|
|
if config.Interface != "" && config.Interface != device.Interface {
|
|
return errors.New("proxy interface does not match the selected device")
|
|
}
|
|
config.Interface = device.Interface
|
|
if config.Enabled && !device.NetworkEnabled {
|
|
return errors.New("enable roaming data on the selected device before starting its export proxy")
|
|
}
|
|
if config.Mode != "http" && config.Mode != "socks5" {
|
|
return errors.New("mode must be http or socks5")
|
|
}
|
|
if config.ListenHost == "" {
|
|
config.ListenHost = "0.0.0.0"
|
|
}
|
|
if net.ParseIP(config.ListenHost) == nil && config.ListenHost != "localhost" {
|
|
return errors.New("listen host must be an IP address")
|
|
}
|
|
if config.ListenPort < 0 || config.ListenPort > 65535 {
|
|
return errors.New("listen port must be between 0 and 65535")
|
|
}
|
|
if config.AuthEnabled {
|
|
if config.Username == "" {
|
|
return errors.New("username is required when authentication is enabled")
|
|
}
|
|
if len(config.Username) > 128 || len(config.Password) > 128 {
|
|
return errors.New("proxy credentials are too long")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (manager *Manager) checkPortLocked(config Config, excludeID string) error {
|
|
if config.ListenPort == 0 {
|
|
return nil
|
|
}
|
|
for _, current := range manager.configs {
|
|
if current.ID != excludeID && current.ListenPort == config.ListenPort && current.ListenHost == config.ListenHost {
|
|
return fmt.Errorf("port %d is already used by another export proxy", config.ListenPort)
|
|
}
|
|
}
|
|
if existing, _ := manager.configByIDLocked(excludeID); excludeID != "" &&
|
|
existing.ListenHost == config.ListenHost && existing.ListenPort == config.ListenPort {
|
|
return nil
|
|
}
|
|
listener, err := net.Listen("tcp", net.JoinHostPort(config.ListenHost, strconv.Itoa(config.ListenPort)))
|
|
if err != nil {
|
|
return fmt.Errorf("port %d is already in use", config.ListenPort)
|
|
}
|
|
_ = listener.Close()
|
|
return nil
|
|
}
|
|
|
|
func (manager *Manager) start(ctx context.Context, id string) error {
|
|
manager.mu.Lock()
|
|
if manager.disabled {
|
|
manager.mu.Unlock()
|
|
return ErrDisabled
|
|
}
|
|
config, index := manager.configByIDLocked(id)
|
|
if index < 0 || !config.Enabled {
|
|
manager.mu.Unlock()
|
|
return ErrNotFound
|
|
}
|
|
if err := platformSupported(); err != nil {
|
|
manager.lastError[id] = err.Error()
|
|
manager.mu.Unlock()
|
|
return err
|
|
}
|
|
manager.stopLocked(id)
|
|
listener, err := net.Listen("tcp", net.JoinHostPort(config.ListenHost, strconv.Itoa(config.ListenPort)))
|
|
if err != nil {
|
|
manager.lastError[id] = err.Error()
|
|
manager.mu.Unlock()
|
|
return err
|
|
}
|
|
if config.ListenPort == 0 {
|
|
config.ListenPort = listener.Addr().(*net.TCPAddr).Port
|
|
manager.configs[index] = config
|
|
if err := manager.saveLocked(ctx); err != nil {
|
|
_ = listener.Close()
|
|
manager.mu.Unlock()
|
|
return err
|
|
}
|
|
}
|
|
delete(manager.lastError, id)
|
|
manager.listeners[id] = listener
|
|
manager.started[id] = time.Now().UTC()
|
|
manager.mu.Unlock()
|
|
go manager.serve(listener, config)
|
|
return nil
|
|
}
|
|
|
|
func (manager *Manager) stop(id string) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
manager.stopLocked(id)
|
|
}
|
|
|
|
func (manager *Manager) stopLocked(id string) {
|
|
if listener := manager.listeners[id]; listener != nil {
|
|
_ = listener.Close()
|
|
delete(manager.listeners, id)
|
|
}
|
|
delete(manager.started, id)
|
|
}
|
|
|
|
func (manager *Manager) serve(listener net.Listener, config Config) {
|
|
dialer := boundDialer(config.Interface)
|
|
resolver := boundResolver(config.Interface)
|
|
for {
|
|
connection, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func(client net.Conn) {
|
|
defer client.Close()
|
|
var err error
|
|
if config.Mode == "http" {
|
|
err = serveHTTP(client, config, &dialer, resolver)
|
|
} else {
|
|
err = serveSOCKS(client, config, &dialer, resolver)
|
|
}
|
|
if err != nil {
|
|
manager.logger.Debug("export proxy connection closed", "id", config.ID, "error", err)
|
|
}
|
|
}(connection)
|
|
}
|
|
}
|
|
|
|
func (manager *Manager) configByIDLocked(id string) (Config, int) {
|
|
for index, config := range manager.configs {
|
|
if config.ID == id {
|
|
return config, index
|
|
}
|
|
}
|
|
return Config{}, -1
|
|
}
|
|
|
|
func redact(config Config) Config {
|
|
if config.Password != "" {
|
|
config.Password = PasswordMask
|
|
}
|
|
return config
|
|
}
|
|
|
|
func generateID() string {
|
|
value := make([]byte, 4)
|
|
_, _ = rand.Read(value)
|
|
return hex.EncodeToString(value)
|
|
}
|