package config import ( "encoding/json" "fmt" "os" "path/filepath" ) // Protocol identifies a VPN/proxy backend. type Protocol string const ( ProtocolNaive Protocol = "naive" ProtocolHysteria2 Protocol = "hysteria2" ProtocolAWG Protocol = "awg" ProtocolVLESS Protocol = "vless" ProtocolVMess Protocol = "vmess" ProtocolTrojan Protocol = "trojan" ) // Config is the top-level client configuration. type Config struct { // Active is the name of the profile to use by default. Active string `json:"active"` // SystemProxy enables Windows Internet Settings / WinHTTP proxy on connect. SystemProxy bool `json:"system_proxy"` // BinDir is where protocol binaries (naive.exe / hysteria.exe) are stored. BinDir string `json:"bin_dir,omitempty"` // SubscriptionURL pulls share links (one per line or base64) and imports profiles. SubscriptionURL string `json:"subscription_url,omitempty"` Profiles []Profile `json:"profiles"` } // Profile describes one connection endpoint. type Profile struct { Name string `json:"name"` Protocol Protocol `json:"protocol"` // Naive: full proxy URI, e.g. https://user:pass@example.com or quic://... Proxy string `json:"proxy,omitempty"` // Local listen URIs passed to naive. Default: socks + http for system proxy. Listen []string `json:"listen,omitempty"` // Extra Naive options InsecureConcurrency int `json:"insecure_concurrency,omitempty"` ExtraHeaders string `json:"extra_headers,omitempty"` HostResolverRules string `json:"host_resolver_rules,omitempty"` NoPostQuantum bool `json:"no_post_quantum,omitempty"` Log string `json:"log,omitempty"` Env map[string]string `json:"env,omitempty"` // Hysteria 2 options (client-side). Masquerade on server is separate; // here SNI/insecure/pin emulate HTTPS look + salamander/gecko obfuscation. Hy2Congestion string `json:"hy2_congestion,omitempty"` // bbr | reno Hy2BBRProfile string `json:"hy2_bbr_profile,omitempty"` // standard | conservative | aggressive Hy2BandwidthUp string `json:"hy2_bandwidth_up,omitempty"` // empty = BBR; set for Brutal Hy2BandwidthDown string `json:"hy2_bandwidth_down,omitempty"` Hy2Obfs string `json:"hy2_obfs,omitempty"` // salamander | gecko Hy2ObfsPassword string `json:"hy2_obfs_password,omitempty"` Hy2SNI string `json:"hy2_sni,omitempty"` Hy2Insecure bool `json:"hy2_insecure,omitempty"` Hy2PinSHA256 string `json:"hy2_pin_sha256,omitempty"` Hy2FastOpen bool `json:"hy2_fast_open,omitempty"` Hy2Lazy bool `json:"hy2_lazy,omitempty"` Hy2HopInterval string `json:"hy2_hop_interval,omitempty"` // e.g. 30s Hy2ALPN string `json:"hy2_alpn,omitempty"` } // DefaultListen returns listen addresses suitable for Windows system proxy. func (p Profile) DefaultListen() []string { if len(p.Listen) > 0 { return p.Listen } return []string{ "socks://127.0.0.1:1080", "http://127.0.0.1:1081", } } // HTTPListenHostPort returns host:port of the first http:// listen URI, if any. func (p Profile) HTTPListenHostPort() (string, bool) { for _, u := range p.DefaultListen() { if hostPort, ok := parseListenHostPort(u, "http"); ok { return hostPort, true } } return "", false } // SOCKSListenHostPort returns host:port of the first socks:// listen URI, if any. func (p Profile) SOCKSListenHostPort() (string, bool) { for _, u := range p.DefaultListen() { if hostPort, ok := parseListenHostPort(u, "socks"); ok { return hostPort, true } } return "", false } func parseListenHostPort(uri, wantScheme string) (string, bool) { // Minimal parse: scheme://[user:pass@]host:port schemeSep := "://" i := index(uri, schemeSep) if i < 0 { return "", false } scheme := uri[:i] if scheme != wantScheme { return "", false } rest := uri[i+len(schemeSep):] if at := lastIndex(rest, "@"); at >= 0 { rest = rest[at+1:] } if rest == "" { return "", false } return rest, true } func index(s, substr string) int { for i := 0; i+len(substr) <= len(s); i++ { if s[i:i+len(substr)] == substr { return i } } return -1 } func lastIndex(s, substr string) int { for i := len(s) - len(substr); i >= 0; i-- { if s[i:i+len(substr)] == substr { return i } } return -1 } // Load reads and validates a config file. func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config: %w", err) } data = stripUTF8BOM(data) var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } if cfg.BinDir == "" { cfg.BinDir = "bin" } if err := cfg.Validate(); err != nil { return nil, err } return &cfg, nil } func stripUTF8BOM(data []byte) []byte { if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { return data[3:] } return data } // Validate checks required fields. func (c *Config) Validate() error { if len(c.Profiles) == 0 { return fmt.Errorf("config: no profiles defined") } if c.Active == "" { c.Active = c.Profiles[0].Name } if _, err := c.ActiveProfile(); err != nil { return err } for i, p := range c.Profiles { if p.Name == "" { return fmt.Errorf("config: profile[%d] missing name", i) } switch p.Protocol { case ProtocolNaive, ProtocolHysteria2, ProtocolAWG, ProtocolVLESS, ProtocolVMess, ProtocolTrojan: // Proxy may be empty until the user pastes a share link / conf in the UI. case "": c.Profiles[i].Protocol = ProtocolNaive default: return fmt.Errorf("config: profile %q: unsupported protocol %q", p.Name, p.Protocol) } } return nil } // ActiveProfile returns the selected profile. func (c *Config) ActiveProfile() (*Profile, error) { for i := range c.Profiles { if c.Profiles[i].Name == c.Active { return &c.Profiles[i], nil } } return nil, fmt.Errorf("config: active profile %q not found", c.Active) } // ResolveBinDir returns an absolute bin directory. // Relative paths are resolved next to the executable (portable install layout). func ResolveBinDir(cfgPath, binDir string) (string, error) { if filepath.IsAbs(binDir) { return binDir, nil } if exe, err := os.Executable(); err == nil { return filepath.Abs(filepath.Join(filepath.Dir(exe), binDir)) } base := filepath.Dir(cfgPath) if base == "." || base == "" { cwd, err := os.Getwd() if err != nil { return "", err } base = cwd } else if filepath.Base(base) == "configs" { base = filepath.Dir(base) } return filepath.Abs(filepath.Join(base, binDir)) } // Example returns a starter configuration. func Example() Config { return Config{ Active: "naive-main", SystemProxy: true, BinDir: "bin", Profiles: []Profile{ { Name: "naive-main", Protocol: ProtocolNaive, Proxy: "", Listen: []string{ "socks://127.0.0.1:1080", "http://127.0.0.1:1081", }, }, }, } } // WriteExample writes example config to path. func WriteExample(path string) error { return Save(path, Example()) } // Save writes config as indented JSON. func Save(path string, cfg Config) error { if err := cfg.Validate(); err != nil { return err } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } data = append(data, '\n') dir := filepath.Dir(path) if dir != "." && dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { return err } } return os.WriteFile(path, data, 0o600) } // SetActiveProxy updates the active profile proxy URI. func (c *Config) SetActiveProxy(proxy string) error { for i := range c.Profiles { if c.Profiles[i].Name == c.Active { c.Profiles[i].Proxy = proxy return nil } } return fmt.Errorf("active profile %q not found", c.Active) } // LocateConfig finds configs/config.json next to the executable or in cwd. func LocateConfig() (string, error) { candidates := []string{} if exe, err := os.Executable(); err == nil { dir := filepath.Dir(exe) candidates = append(candidates, filepath.Join(dir, "configs", "config.json"), filepath.Join(dir, "config.json"), ) } if cwd, err := os.Getwd(); err == nil { candidates = append(candidates, filepath.Join(cwd, "configs", "config.json")) } for _, p := range candidates { if st, err := os.Stat(p); err == nil && !st.IsDir() { return p, nil } } // Default path next to executable for first-run create. if exe, err := os.Executable(); err == nil { return filepath.Join(filepath.Dir(exe), "configs", "config.json"), nil } return filepath.Join("configs", "config.json"), nil }