Initial Navis client with NaiveProxy, profiles, ping and git updates
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Protocol identifies a VPN/proxy backend.
|
||||
type Protocol string
|
||||
|
||||
const (
|
||||
ProtocolNaive Protocol = "naive"
|
||||
)
|
||||
|
||||
// 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) are stored.
|
||||
BinDir string `json:"bin_dir,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"`
|
||||
}
|
||||
|
||||
// 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:
|
||||
// Proxy may be empty until the user pastes a share link in the UI.
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user