478 lines
11 KiB
Go
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
|
|
}
|