Files
navi/internal/config/config.go
T

306 lines
8.3 KiB
Go

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"
)
// 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:
// Proxy may be empty until the user pastes a share link 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
}