359 lines
10 KiB
Go
359 lines
10 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"
|
|
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"`
|
|
|
|
// Mode selects traffic capture: "proxy" (default — local proxy + optional
|
|
// system proxy) or "vpn" (Windows: TUN device, ALL traffic incl. apps/games).
|
|
Mode string `json:"mode,omitempty"`
|
|
|
|
// 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"`
|
|
|
|
// Remnawave panel integration (local only — do not commit tokens).
|
|
RemnawaveBaseURL string `json:"remnawave_base_url,omitempty"`
|
|
RemnawaveToken string `json:"remnawave_token,omitempty"`
|
|
RemnawaveShortUUID string `json:"remnawave_short_uuid,omitempty"`
|
|
RemnawaveCaddyToken string `json:"remnawave_caddy_token,omitempty"`
|
|
|
|
// SubInfo caches last-known subscription usage so the UI can render it
|
|
// after restart; refreshed on subscription import.
|
|
SubInfo *SubscriptionInfo `json:"subscription_info,omitempty"`
|
|
|
|
Profiles []Profile `json:"profiles"`
|
|
}
|
|
|
|
// SubscriptionInfo is last-known subscription usage metadata.
|
|
// Traffic values are bytes; Total==0 means unlimited/unknown.
|
|
type SubscriptionInfo struct {
|
|
Upload int64 `json:"upload,omitempty"`
|
|
Download int64 `json:"download,omitempty"`
|
|
Total int64 `json:"total,omitempty"`
|
|
Expire int64 `json:"expire,omitempty"` // unix seconds; 0 = unknown
|
|
|
|
// Devices are known only when the Remnawave API token is configured.
|
|
DevicesKnown bool `json:"devices_known,omitempty"`
|
|
Devices int `json:"devices,omitempty"`
|
|
DeviceLimit int `json:"device_limit,omitempty"` // 0 = no limit / unknown
|
|
|
|
UpdatedAt int64 `json:"updated_at,omitempty"` // unix seconds of last refresh
|
|
}
|
|
|
|
// 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 {
|
|
switch c.Mode {
|
|
case "", "proxy", "vpn":
|
|
default:
|
|
c.Mode = "proxy"
|
|
}
|
|
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),
|
|
// 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.
|
|
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, 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
|
|
}
|