284 lines
7.0 KiB
Go
284 lines
7.0 KiB
Go
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
|
|
}
|