Files
navi/internal/config/config.go
T
M4andCursor 2ad376b49e
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Release 3.8.2.5: cheaper idle UI poll and tighter hot paths.
Delta getState by rev, pause when hidden, cancellable background loops,
hostCache bounds, unlock PollUI around engine status, corebin singleflight,
PingAll worker pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:13:59 +03:00

401 lines
11 KiB
Go

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 {
InvalidateHostCache()
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
}