package config import ( "encoding/json" "fmt" "net" "os" "path/filepath" "strings" ) // 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"` // SubscriptionNames are profile names last imported from SubscriptionURL (for prune). SubscriptionNames []string `json:"subscription_names,omitempty"` // ConnectOnLaunch connects the active profile when the GUI starts. ConnectOnLaunch bool `json:"connect_on_launch,omitempty"` // AutoReconnect reconnects after an unexpected core crash. AutoReconnect bool `json:"auto_reconnect,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 } // Clone returns a deep copy of the config (JSON round-trip). func (c *Config) Clone() *Config { if c == nil { return &Config{} } data, err := json.Marshal(c) if err != nil { out := *c out.Profiles = append([]Profile(nil), c.Profiles...) return &out } var out Config if err := json.Unmarshal(data, &out); err != nil { cp := *c cp.Profiles = append([]Profile(nil), c.Profiles...) return &cp } return &out } // 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) } for _, listen := range p.Listen { if err := validateListenURI(listen); err != nil { return fmt.Errorf("config: profile %q: %w", p.Name, err) } } } return nil } func validateListenURI(uri string) error { uri = strings.TrimSpace(uri) if uri == "" { return nil } var hostPort string var ok bool for _, scheme := range []string{"http", "socks", "socks5", "https"} { if hostPort, ok = parseListenHostPort(uri, scheme); ok { break } } if !ok { lower := strings.ToLower(uri) if strings.Contains(lower, "127.0.0.1") || strings.Contains(lower, "localhost") || strings.Contains(lower, "[::1]") { return nil } return fmt.Errorf("listen %q must bind to loopback", uri) } host, _, err := net.SplitHostPort(hostPort) if err != nil { host = hostPort } host = strings.Trim(host, "[]") switch strings.ToLower(host) { case "127.0.0.1", "localhost", "::1", "": return nil default: return fmt.Errorf("listen host %q is not loopback (only 127.0.0.1 / ::1)", host) } } // 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), // or under ~/Library/Application Support/Navis on macOS (.app bundles are read-only). func ResolveBinDir(cfgPath, binDir string) (string, error) { if filepath.IsAbs(binDir) { return binDir, nil } if dir, ok := platformNavisDir(); ok { return filepath.Abs(filepath.Join(dir, binDir)) } 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 (atomic replace). 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 } } tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } if err := os.Rename(tmp, path); err != nil { _ = os.Remove(tmp) return err } return nil } // 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, in cwd, // or under ~/Library/Application Support/Navis on macOS. func LocateConfig() (string, error) { candidates := []string{} if dir, ok := platformNavisDir(); ok { candidates = append(candidates, filepath.Join(dir, "configs", "config.json"), filepath.Join(dir, "config.json"), ) } 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 } } if dir, ok := platformNavisDir(); ok { return filepath.Join(dir, "configs", "config.json"), 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 }