Files
2026-05-16 00:18:06 -03:00

478 lines
11 KiB
Go

package config
import (
"bytes"
"compress/gzip"
"crypto/rand"
"encoding/gob"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
type Mode string
const (
ModeDirect Mode = "direct"
ModePayload Mode = "payload"
ModeSSL Mode = "ssl"
ModePayloadSSL Mode = "payload_ssl"
ModeDNSTT Mode = "dnstt"
ModeXray Mode = "xray"
)
const (
ProfileExtension = ".srpc"
profileMagic = "SRPC\x01"
)
type Profile struct {
ID string
Name string
Mode Mode
CreatedAt time.Time
UpdatedAt time.Time
SSH SSHConfig
Proxy ProxyConfig
Payload PayloadConfig
TLS TLSConfig
DNSTT DNSTTConfig
Xray XrayConfig
UDPGW UDPGWConfig
Reconnect ReconnectConfig
Local LocalConfig
Tun TunConfig
}
type SSHConfig struct {
Host string
Port int
Username string
Password string
KeepAliveSeconds int
HandshakeTimeoutMs int
}
type ProxyConfig struct {
Host string
Port int
}
type PayloadConfig struct {
Text string
WaitForResponse bool
AcceptAnyStatus bool
ResponseTimeoutMs int
SplitDelayMs int
}
type TLSConfig struct {
Enabled bool
Host string
Port int
ServerName string
InsecureSkipVerify bool
}
type DNSTTConfig struct {
Enabled bool
UseEmbedded bool
ResolverType string
ResolverAddress string
Domain string
PublicKey string
UTLSDistribution string
Executable string
Args []string
LocalSSHHost string
LocalSSHPort int
StartupTimeoutMs int
}
type XrayConfig struct {
Executable string
Args []string
ConfigPath string
LocalSocksHost string
LocalSocksPort int
StartupTimeoutMs int
}
type UDPGWConfig struct {
Enabled bool
Host string
Port int
Protocol string
}
type ReconnectConfig struct {
Enabled bool
DelaySeconds int
MaxRetries int
CheckIntervalSeconds int
}
type LocalConfig struct {
SocksHost string
SocksPort int
}
type TunConfig struct {
Enabled bool
Device string
InterfaceName string
MTU int
Gateway string
CIDR string
DNS []string
RouteAll bool
// IPv6 is optional because many SSH/DNSTT/UDPGW servers only have IPv4
// egress. When IPv6Enabled is false, AllowIPv6Leak controls whether the
// app should leave the normal Windows/Linux IPv6 route untouched.
IPv6Enabled bool
IPv6CIDR string
IPv6DNS []string
AllowIPv6Leak bool
}
type Store struct {
Dir string
}
func NewStore(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
return &Store{Dir: dir}, nil
}
func (s *Store) List() ([]Profile, error) {
entries, err := os.ReadDir(s.Dir)
if err != nil {
return nil, err
}
var out []Profile
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ProfileExtension) {
continue
}
p, err := s.Load(strings.TrimSuffix(e.Name(), ProfileExtension))
if err == nil {
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out, nil
}
func (s *Store) Load(id string) (Profile, error) {
var p Profile
b, err := os.ReadFile(filepath.Join(s.Dir, safeID(id)+ProfileExtension))
if err != nil {
return p, err
}
p, err = DecodeProfileFile(b)
if err != nil {
return p, err
}
ApplyDefaults(&p)
return p, nil
}
func (s *Store) Save(p Profile) (Profile, error) {
if p.ID == "" {
p.ID = randomID()
}
now := time.Now().UTC()
if p.CreatedAt.IsZero() {
p.CreatedAt = now
}
p.UpdatedAt = now
ApplyDefaults(&p)
if err := Validate(p); err != nil {
return p, err
}
b, err := EncodeProfileFile(p)
if err != nil {
return p, err
}
return p, os.WriteFile(filepath.Join(s.Dir, safeID(p.ID)+ProfileExtension), b, 0o600)
}
func (s *Store) Delete(id string) error {
return os.Remove(filepath.Join(s.Dir, safeID(id)+ProfileExtension))
}
func EncodeProfileFile(p Profile) ([]byte, error) {
ApplyDefaults(&p)
var buf bytes.Buffer
buf.WriteString(profileMagic)
gz := gzip.NewWriter(&buf)
if err := gob.NewEncoder(gz).Encode(p); err != nil {
_ = gz.Close()
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func DecodeProfileFile(b []byte) (Profile, error) {
var p Profile
if !bytes.HasPrefix(b, []byte(profileMagic)) {
return p, errors.New("invalid SocksRevive PC profile file")
}
gz, err := gzip.NewReader(bytes.NewReader(b[len(profileMagic):]))
if err != nil {
return p, err
}
defer gz.Close()
payload, err := io.ReadAll(gz)
if err != nil {
return p, err
}
if err := gob.NewDecoder(bytes.NewReader(payload)).Decode(&p); err != nil {
return p, err
}
ApplyDefaults(&p)
return p, Validate(p)
}
func Validate(p Profile) error {
if strings.TrimSpace(p.Name) == "" {
return errors.New("profile name is required")
}
switch p.Mode {
case ModeDirect, ModePayload, ModeSSL, ModePayloadSSL, ModeDNSTT, ModeXray:
default:
return fmt.Errorf("unknown mode %q", p.Mode)
}
if p.Mode != ModeXray {
if p.SSH.Username == "" || p.SSH.Password == "" {
return errors.New("ssh username/password are required")
}
if p.Mode != ModeDNSTT && (p.SSH.Host == "" || p.SSH.Port <= 0) {
return errors.New("ssh host/port are required")
}
}
if (p.Mode == ModePayload || p.Mode == ModePayloadSSL) && strings.TrimSpace(p.Payload.Text) == "" {
return errors.New("payload text is required for payload modes")
}
if p.Mode == ModeXray && strings.TrimSpace(p.Xray.Executable) == "" {
return errors.New("xray executable path is required")
}
if p.Mode == ModeDNSTT {
if p.DNSTT.UseEmbedded {
if strings.TrimSpace(p.DNSTT.ResolverType) == "" || strings.TrimSpace(p.DNSTT.ResolverAddress) == "" {
return errors.New("dnstt resolver type/address are required")
}
if strings.TrimSpace(p.DNSTT.Domain) == "" {
return errors.New("dnstt domain is required")
}
if strings.TrimSpace(p.DNSTT.PublicKey) == "" {
return errors.New("dnstt public key is required")
}
} else if strings.TrimSpace(p.DNSTT.Executable) == "" {
return errors.New("dnstt executable path is required when embedded dnstt is disabled")
}
}
if p.UDPGW.Enabled {
if strings.TrimSpace(p.UDPGW.Host) == "" {
return errors.New("udpgw host is required when udpgw is enabled")
}
if p.UDPGW.Port <= 0 || p.UDPGW.Port > 65535 {
return errors.New("udpgw port must be between 1 and 65535")
}
switch strings.ToLower(strings.TrimSpace(p.UDPGW.Protocol)) {
case "", "badvpn", "legacy":
default:
return errors.New("udpgw protocol must be badvpn or legacy")
}
}
if p.Local.SocksPort <= 0 || p.Local.SocksPort > 65535 {
return errors.New("local socks port must be between 1 and 65535")
}
if p.Tun.Enabled && p.Tun.IPv6Enabled {
ip, _, err := net.ParseCIDR(strings.TrimSpace(p.Tun.IPv6CIDR))
if err != nil || ip == nil || ip.To4() != nil {
return errors.New("IPv6 CIDR must be a valid IPv6 CIDR, for example fd00:534f:434b::1/64")
}
}
return nil
}
func ApplyDefaults(p *Profile) {
if p.ID == "" {
p.ID = randomID()
}
if p.Mode == "" {
p.Mode = ModeDirect
}
if p.SSH.Port == 0 {
p.SSH.Port = 22
}
if p.SSH.KeepAliveSeconds == 0 {
p.SSH.KeepAliveSeconds = 20
}
if p.SSH.HandshakeTimeoutMs == 0 {
p.SSH.HandshakeTimeoutMs = 15000
}
if p.Payload.Text == "" {
p.Payload.Text = "CONNECT [host]:[port] HTTP/1.1[crlf]Host: [host][crlf]User-Agent: SocksRevivePC[crlf][crlf]"
}
if p.Payload.ResponseTimeoutMs == 0 {
p.Payload.ResponseTimeoutMs = 8000
}
if p.Payload.SplitDelayMs == 0 {
p.Payload.SplitDelayMs = 120
}
if p.TLS.Port == 0 {
p.TLS.Port = 443
}
if p.Reconnect.DelaySeconds == 0 {
p.Reconnect.DelaySeconds = 3
}
if p.Reconnect.CheckIntervalSeconds == 0 {
p.Reconnect.CheckIntervalSeconds = 10
}
if p.Local.SocksHost == "" {
p.Local.SocksHost = "127.0.0.1"
}
if p.Local.SocksPort == 0 {
p.Local.SocksPort = 10809
}
if p.DNSTT.LocalSSHHost == "" {
p.DNSTT.LocalSSHHost = "127.0.0.1"
}
if p.DNSTT.LocalSSHPort == 0 {
p.DNSTT.LocalSSHPort = 2222
}
if p.DNSTT.StartupTimeoutMs == 0 {
p.DNSTT.StartupTimeoutMs = 5000
}
if p.DNSTT.ResolverType == "" {
p.DNSTT.ResolverType = "doh"
}
if p.DNSTT.UTLSDistribution == "" {
p.DNSTT.UTLSDistribution = "4*random,3*Firefox_120,1*Firefox_105,3*Chrome_120,1*Chrome_102,1*iOS_14,1*iOS_13"
}
if !p.DNSTT.UseEmbedded && p.DNSTT.Executable == "" && len(p.DNSTT.Args) == 0 {
// New profiles use the embedded DNSTT client. Old imported profiles can
// still disable UseEmbedded and point to an external executable.
p.DNSTT.UseEmbedded = true
}
if p.DNSTT.Executable == "" {
p.DNSTT.Executable = defaultToolPath("dnstt", "dnstt-client")
}
if p.Xray.LocalSocksHost == "" {
p.Xray.LocalSocksHost = "127.0.0.1"
}
if p.Xray.LocalSocksPort == 0 {
p.Xray.LocalSocksPort = 10808
}
if p.Xray.ConfigPath == "" {
p.Xray.ConfigPath = "configs/xray.json"
}
if len(p.Xray.Args) == 0 {
p.Xray.Args = []string{"run", "-config", p.Xray.ConfigPath}
}
if p.Xray.StartupTimeoutMs == 0 {
p.Xray.StartupTimeoutMs = 2500
}
if p.Xray.Executable == "" {
p.Xray.Executable = defaultToolPath("xray", "xray")
}
if p.UDPGW.Host == "" {
p.UDPGW.Host = "127.0.0.1"
}
if p.UDPGW.Port == 0 {
p.UDPGW.Port = 7400
}
if strings.TrimSpace(p.UDPGW.Protocol) == "" {
// BadVPN is the Android-compatible UDPGW framing. The old PC-only
// experimental frame is still available as "legacy" for older tests.
p.UDPGW.Protocol = "badvpn"
}
if p.Tun.Device == "" {
if runtime.GOOS == "windows" {
p.Tun.Device = "wintun"
p.Tun.InterfaceName = "wintun"
} else {
p.Tun.Device = "tun://socksrevive0"
p.Tun.InterfaceName = "socksrevive0"
}
}
if runtime.GOOS == "windows" {
// tun2socks v2 expects the Windows device model to be just "wintun".
// The old value "wintun://SocksRevive" made the engine treat
// "SocksRevive" as a network interface and crash with
// "route ip+net: no such network interface" on many PCs.
p.Tun.Device = "wintun"
p.Tun.InterfaceName = "wintun"
}
if p.Tun.InterfaceName == "" {
if strings.HasPrefix(p.Tun.Device, "tun://") {
p.Tun.InterfaceName = strings.TrimPrefix(p.Tun.Device, "tun://")
} else if strings.HasPrefix(p.Tun.Device, "wintun://") {
p.Tun.InterfaceName = strings.TrimPrefix(p.Tun.Device, "wintun://")
} else {
p.Tun.InterfaceName = p.Tun.Device
}
}
if p.Tun.MTU == 0 {
p.Tun.MTU = 1500
}
if p.Tun.Gateway == "" {
p.Tun.Gateway = "198.18.0.1"
}
if p.Tun.CIDR == "" {
p.Tun.CIDR = "198.18.0.1/15"
}
if len(p.Tun.DNS) == 0 {
p.Tun.DNS = []string{"1.1.1.1", "8.8.8.8"}
}
if p.Tun.IPv6CIDR == "" {
p.Tun.IPv6CIDR = "fd00:534f:434b::1/64"
}
if len(p.Tun.IPv6DNS) == 0 {
p.Tun.IPv6DNS = []string{"2606:4700:4700::1111", "2001:4860:4860::8888"}
}
}
func defaultToolPath(folder, base string) string {
exe := base
if runtime.GOOS == "windows" {
exe += ".exe"
}
return filepath.Join("tools", folder, exe)
}
func randomID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("p%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func safeID(id string) string {
id = strings.TrimSpace(id)
id = strings.ReplaceAll(id, "/", "_")
id = strings.ReplaceAll(id, "\\", "_")
id = strings.ReplaceAll(id, "..", "_")
return id
}