mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 06:43:42 +08:00
feat: expand device networking and management
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
//go:build linux
|
||||
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"hash/fnv"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func platformSupported() error { return nil }
|
||||
|
||||
func boundDialer(networkInterface string) net.Dialer {
|
||||
return net.Dialer{Control: func(_, _ string, raw syscall.RawConn) error {
|
||||
var bindError error
|
||||
err := raw.Control(func(fd uintptr) {
|
||||
if err := syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(exportRouteMark(networkInterface))); err != nil {
|
||||
bindError = err
|
||||
return
|
||||
}
|
||||
bindError = syscall.SetsockoptString(int(fd), syscall.SOL_SOCKET, syscall.SO_BINDTODEVICE, networkInterface)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bindError
|
||||
}}
|
||||
}
|
||||
|
||||
func exportRouteMark(networkInterface string) uint32 {
|
||||
hash := fnv.New32a()
|
||||
_, _ = hash.Write([]byte(networkInterface))
|
||||
return 0x56000000 | (hash.Sum32() & 0x00ffffff)
|
||||
}
|
||||
|
||||
func boundResolver(networkInterface string) *net.Resolver {
|
||||
dialer := boundDialer(networkInterface)
|
||||
return &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
var lastError error
|
||||
for _, server := range exportRouteDNSServers(networkInterface) {
|
||||
connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(server, "53"))
|
||||
if err == nil {
|
||||
return connection, nil
|
||||
}
|
||||
lastError = err
|
||||
}
|
||||
return nil, lastError
|
||||
}}
|
||||
}
|
||||
|
||||
func exportRouteDNSServers(networkInterface string) []string {
|
||||
safeName := strings.Map(func(character rune) rune {
|
||||
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
|
||||
character >= '0' && character <= '9' || character == '-' || character == '_' || character == '.' {
|
||||
return character
|
||||
}
|
||||
return '_'
|
||||
}, networkInterface)
|
||||
file, err := os.Open(filepath.Join("/run/vocat", "cellular-"+safeName+".dns"))
|
||||
if err != nil {
|
||||
return []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
defer file.Close()
|
||||
servers := make([]string, 0, 2)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
if value := strings.TrimSpace(scanner.Text()); net.ParseIP(value) != nil {
|
||||
servers = append(servers, value)
|
||||
}
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
return []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
return servers
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !linux
|
||||
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
)
|
||||
|
||||
func platformSupported() error { return errors.New("built-in export proxy is only available on Linux") }
|
||||
func boundDialer(string) net.Dialer { return net.Dialer{} }
|
||||
func boundResolver(string) *net.Resolver { return net.DefaultResolver }
|
||||
@@ -0,0 +1,88 @@
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ipInfoURL = "https://ipinfo.io/json"
|
||||
|
||||
type PublicIPInfo struct {
|
||||
IP string `json:"ip"`
|
||||
CountryCode string `json:"country_code"`
|
||||
Region string `json:"region"`
|
||||
City string `json:"city"`
|
||||
Organization string `json:"organization,omitempty"`
|
||||
}
|
||||
|
||||
// LookupPublicIP sends the lookup through the same marked, interface-bound
|
||||
// dialer and isolated DNS resolver as Export Proxy. It therefore reports the
|
||||
// modem's roaming exit rather than the host or browser's default connection.
|
||||
func LookupPublicIP(ctx context.Context, networkInterface string) (PublicIPInfo, error) {
|
||||
networkInterface = strings.TrimSpace(networkInterface)
|
||||
if networkInterface == "" {
|
||||
return PublicIPInfo{}, errors.New("cellular network interface is required")
|
||||
}
|
||||
if err := platformSupported(); err != nil {
|
||||
return PublicIPInfo{}, err
|
||||
}
|
||||
dialer := boundDialer(networkInterface)
|
||||
resolver := boundResolver(networkInterface)
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, address string) (net.Conn, error) {
|
||||
return dialTarget(ctx, address, &dialer, resolver)
|
||||
},
|
||||
DisableKeepAlives: true,
|
||||
ResponseHeaderTimeout: 12 * time.Second,
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, ipInfoURL, nil)
|
||||
if err != nil {
|
||||
return PublicIPInfo{}, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "VoCat/1.0")
|
||||
response, err := transport.RoundTrip(request)
|
||||
if err != nil {
|
||||
return PublicIPInfo{}, fmt.Errorf("query ipinfo.io through %s: %w", networkInterface, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10))
|
||||
return PublicIPInfo{}, fmt.Errorf("ipinfo.io returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
return decodePublicIPInfo(io.LimitReader(response.Body, 64<<10))
|
||||
}
|
||||
|
||||
func decodePublicIPInfo(reader io.Reader) (PublicIPInfo, error) {
|
||||
var response struct {
|
||||
IP string `json:"ip"`
|
||||
Country string `json:"country"`
|
||||
Region string `json:"region"`
|
||||
City string `json:"city"`
|
||||
Org string `json:"org"`
|
||||
}
|
||||
if err := json.NewDecoder(reader).Decode(&response); err != nil {
|
||||
return PublicIPInfo{}, fmt.Errorf("decode ipinfo.io response: %w", err)
|
||||
}
|
||||
response.IP = strings.TrimSpace(response.IP)
|
||||
response.Country = strings.ToUpper(strings.TrimSpace(response.Country))
|
||||
if net.ParseIP(response.IP) == nil {
|
||||
return PublicIPInfo{}, errors.New("ipinfo.io response contained no valid IP address")
|
||||
}
|
||||
if len(response.Country) != 2 {
|
||||
return PublicIPInfo{}, errors.New("ipinfo.io response contained no valid country code")
|
||||
}
|
||||
return PublicIPInfo{
|
||||
IP: response.IP, CountryCode: response.Country,
|
||||
Region: strings.TrimSpace(response.Region), City: strings.TrimSpace(response.City),
|
||||
Organization: strings.TrimSpace(response.Org),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodePublicIPInfo(t *testing.T) {
|
||||
info, err := decodePublicIPInfo(strings.NewReader(`{"ip":"203.0.113.8","city":"London","region":"England","country":"gb","org":"AS64500 Test"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.IP != "203.0.113.8" || info.CountryCode != "GB" || info.Region != "England" || info.City != "London" {
|
||||
t.Fatalf("info = %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePublicIPInfoRejectsInvalidResponse(t *testing.T) {
|
||||
if _, err := decodePublicIPInfo(strings.NewReader(`{"ip":"not-an-ip","country":"GB"}`)); err == nil {
|
||||
t.Fatal("invalid IP was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestManagerPersistsAndDeletesDisabledConfig(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-1", Name: "modem-1", Interface: "wwan0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := New(ctx, database, slog.New(slog.NewTextHandler(io.Discard, nil)), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := manager.Create(ctx, Config{DeviceID: "modem-1", Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 1080})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ID == "" || created.Interface != "wwan0" {
|
||||
t.Fatalf("created = %+v", created)
|
||||
}
|
||||
configs, err := manager.Configs()
|
||||
if err != nil || len(configs) != 1 {
|
||||
t.Fatalf("configs = %+v, %v", configs, err)
|
||||
}
|
||||
if err := manager.DeleteAllAndDisable(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.Configs(); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("Configs after disable = %v", err)
|
||||
}
|
||||
if _, err := database.AppSetting(ctx, SettingKey); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("setting remains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRequiresRoamingDataForEnabledProxy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-1", Name: "modem-1", Interface: "wwan0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := New(ctx, database, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer manager.Close()
|
||||
_, err = manager.Create(ctx, Config{DeviceID: "modem-1", Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 1080, Enabled: true})
|
||||
if err == nil {
|
||||
t.Fatal("enabled proxy was accepted while roaming data was disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerEnabledConfigForDevice(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-1", Name: "modem-1", Interface: "wwan0", NetworkEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-2", Name: "modem-2", Interface: "wwan1", NetworkEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := New(ctx, database, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer manager.Close()
|
||||
if _, ok := manager.EnabledConfigForDevice("modem-1"); ok {
|
||||
t.Fatal("reported an enabled config before any was created")
|
||||
}
|
||||
// A disabled config bound to modem-1 must not count.
|
||||
if _, err := manager.Create(ctx, Config{DeviceID: "modem-1", Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 1080}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := manager.EnabledConfigForDevice("modem-1"); ok {
|
||||
t.Fatal("disabled config counted as enabled")
|
||||
}
|
||||
// An enabled config bound to modem-2 counts only for modem-2. The listener start
|
||||
// is Linux-only, so the config is created disabled and flipped on in memory to
|
||||
// exercise the query without binding a port.
|
||||
created, err := manager.Create(ctx, Config{DeviceID: "modem-2", Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 0, AuthEnabled: true, Username: "u", Password: "secret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager.mu.Lock()
|
||||
for index := range manager.configs {
|
||||
if manager.configs[index].ID == created.ID {
|
||||
manager.configs[index].Enabled = true
|
||||
}
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
if _, ok := manager.EnabledConfigForDevice("modem-1"); ok {
|
||||
t.Fatal("config bound to another device counted")
|
||||
}
|
||||
found, ok := manager.EnabledConfigForDevice("modem-2")
|
||||
if !ok {
|
||||
t.Fatal("enabled config not found for its device")
|
||||
}
|
||||
if found.Password != PasswordMask {
|
||||
t.Fatalf("password not redacted: %+v", found)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func serveHTTP(client net.Conn, config Config, dialer *net.Dialer, resolver *net.Resolver) error {
|
||||
reader := bufio.NewReader(client)
|
||||
request, err := http.ReadRequest(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.AuthEnabled && !httpAuthorized(request, config) {
|
||||
_, _ = client.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"vocat-export-proxy\"\r\n\r\n"))
|
||||
return errors.New("HTTP proxy authentication required")
|
||||
}
|
||||
if request.Method == http.MethodConnect {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), proxyTimeout)
|
||||
target, err := dialTarget(ctx, request.URL.Host, dialer, resolver)
|
||||
cancel()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprint(client, "HTTP/1.1 502 Bad Gateway\r\n\r\n")
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
if _, err := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
if buffered := reader.Buffered(); buffered > 0 {
|
||||
if value, err := reader.Peek(buffered); err == nil {
|
||||
_, _ = target.Write(value)
|
||||
_, _ = reader.Discard(buffered)
|
||||
}
|
||||
}
|
||||
pipe(client, target)
|
||||
return nil
|
||||
}
|
||||
|
||||
request.Header.Del("Proxy-Authorization")
|
||||
request.Header.Del("Proxy-Connection")
|
||||
request.RequestURI = ""
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, address string) (net.Conn, error) {
|
||||
return dialTarget(ctx, address, dialer, resolver)
|
||||
},
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
response, err := transport.RoundTrip(request)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprint(client, "HTTP/1.1 502 Bad Gateway\r\n\r\n")
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
return response.Write(client)
|
||||
}
|
||||
|
||||
func httpAuthorized(request *http.Request, config Config) bool {
|
||||
header := strings.TrimSpace(strings.TrimPrefix(request.Header.Get("Proxy-Authorization"), "Basic "))
|
||||
decoded, err := base64.StdEncoding.DecodeString(header)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
return len(parts) == 2 && parts[0] == config.Username && parts[1] == config.Password
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
const proxyTimeout = 30 * time.Second
|
||||
|
||||
func dialTarget(ctx context.Context, address string, dialer *net.Dialer, resolver *net.Resolver) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip.String(), port))
|
||||
}
|
||||
ips, err := resolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
connection, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip.IP.String(), port))
|
||||
if err == nil {
|
||||
return connection, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("%w: no addresses for %s", errors.ErrUnsupported, host)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func pipe(left, right net.Conn) {
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { _, _ = copyConnection(right, left); done <- struct{}{} }()
|
||||
go func() { _, _ = copyConnection(left, right); done <- struct{}{} }()
|
||||
<-done
|
||||
}
|
||||
|
||||
func copyConnection(destination net.Conn, source net.Conn) (int64, error) {
|
||||
written, err := io.CopyBuffer(destination, source, make([]byte, 32*1024))
|
||||
if err == nil && written > 0 {
|
||||
if connection, ok := destination.(interface{ CloseWrite() error }); ok {
|
||||
_ = connection.CloseWrite()
|
||||
}
|
||||
}
|
||||
return written, err
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func serveSOCKS(client net.Conn, config Config, dialer *net.Dialer, resolver *net.Resolver) error {
|
||||
reader := bufio.NewReader(client)
|
||||
version, err := reader.ReadByte()
|
||||
if err != nil || version != 5 {
|
||||
return errors.New("unsupported SOCKS version")
|
||||
}
|
||||
methodCount, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
methods := make([]byte, methodCount)
|
||||
if _, err := io.ReadFull(reader, methods); err != nil {
|
||||
return err
|
||||
}
|
||||
chosen := byte(0xff)
|
||||
if config.AuthEnabled && hasMethod(methods, 2) {
|
||||
chosen = 2
|
||||
} else if !config.AuthEnabled && hasMethod(methods, 0) {
|
||||
chosen = 0
|
||||
}
|
||||
if _, err := client.Write([]byte{5, chosen}); err != nil || chosen == 0xff {
|
||||
return errors.New("no acceptable SOCKS authentication method")
|
||||
}
|
||||
if chosen == 2 {
|
||||
if err := socksAuthenticate(reader, client, config); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
header := make([]byte, 4)
|
||||
if _, err := io.ReadFull(reader, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if header[0] != 5 || header[1] != 1 {
|
||||
_ = writeSocksReply(client, 7)
|
||||
return errors.New("only SOCKS5 CONNECT is supported")
|
||||
}
|
||||
host, port, err := readSocksAddress(reader, header[3])
|
||||
if err != nil {
|
||||
_ = writeSocksReply(client, 1)
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), proxyTimeout)
|
||||
target, err := dialTarget(ctx, net.JoinHostPort(host, strconv.Itoa(port)), dialer, resolver)
|
||||
cancel()
|
||||
if err != nil {
|
||||
_ = writeSocksReply(client, 5)
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
if err := writeSocksReply(client, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if buffered := reader.Buffered(); buffered > 0 {
|
||||
if value, err := reader.Peek(buffered); err == nil {
|
||||
_, _ = target.Write(value)
|
||||
_, _ = reader.Discard(buffered)
|
||||
}
|
||||
}
|
||||
pipe(client, target)
|
||||
return nil
|
||||
}
|
||||
|
||||
func socksAuthenticate(reader *bufio.Reader, writer io.Writer, config Config) error {
|
||||
header := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, header); err != nil || header[0] != 1 {
|
||||
return errors.New("invalid SOCKS authentication request")
|
||||
}
|
||||
username := make([]byte, int(header[1]))
|
||||
if _, err := io.ReadFull(reader, username); err != nil {
|
||||
return err
|
||||
}
|
||||
length, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
password := make([]byte, int(length))
|
||||
if _, err := io.ReadFull(reader, password); err != nil {
|
||||
return err
|
||||
}
|
||||
if string(username) != config.Username || string(password) != config.Password {
|
||||
_, _ = writer.Write([]byte{1, 1})
|
||||
return errors.New("SOCKS authentication failed")
|
||||
}
|
||||
_, err = writer.Write([]byte{1, 0})
|
||||
return err
|
||||
}
|
||||
|
||||
func hasMethod(methods []byte, wanted byte) bool {
|
||||
for _, method := range methods {
|
||||
if method == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readSocksAddress(reader *bufio.Reader, kind byte) (string, int, error) {
|
||||
var host string
|
||||
switch kind {
|
||||
case 1:
|
||||
value := make([]byte, 4)
|
||||
if _, err := io.ReadFull(reader, value); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
host = net.IP(value).String()
|
||||
case 3:
|
||||
length, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
value := make([]byte, int(length))
|
||||
if _, err := io.ReadFull(reader, value); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
host = string(value)
|
||||
case 4:
|
||||
value := make([]byte, 16)
|
||||
if _, err := io.ReadFull(reader, value); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
host = net.IP(value).String()
|
||||
default:
|
||||
return "", 0, fmt.Errorf("unsupported SOCKS address type %d", kind)
|
||||
}
|
||||
value := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, value); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return host, int(binary.BigEndian.Uint16(value)), nil
|
||||
}
|
||||
|
||||
func writeSocksReply(connection net.Conn, code byte) error {
|
||||
_, err := connection.Write([]byte{5, code, 0, 1, 0, 0, 0, 0, 0, 0})
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user