package core import ( "context" "errors" "fmt" "io" "net" "net/http" "net/url" "os" "strings" "sync" "time" "vpnclient/internal/config" "vpnclient/internal/linknorm" "vpnclient/internal/protocols/awg" "vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/naive" "vpnclient/internal/protocols/xray" "vpnclient/internal/subscription" "vpnclient/internal/sysproxy" ) // Manager orchestrates protocol engines and Windows system proxy. type Manager struct { mu sync.Mutex cfgPath string cfg *config.Config engine Engine sys sysproxy.Controller profile *config.Profile stderr io.Writer binDir string } func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) { if stderr == nil { stderr = os.Stderr } binDir, err := config.ResolveBinDir(cfgPath, cfg.BinDir) if err != nil { return nil, err } return &Manager{ cfgPath: cfgPath, cfg: cfg, sys: sysproxy.New(), stderr: stderr, binDir: binDir, }, nil } func (m *Manager) Connect(ctx context.Context, profileName string) error { m.mu.Lock() defer m.mu.Unlock() if m.engine != nil && m.engine.Running() { return fmt.Errorf("already connected; disconnect first") } if profileName != "" { m.cfg.Active = profileName } profile, err := m.cfg.ActiveProfile() if err != nil { return err } engine, err := m.newEngine(profile.Protocol) if err != nil { return err } if err := engine.Start(ctx, *profile, m.binDir); err != nil { return err } if m.cfg.SystemProxy { httpProxy, ok := engine.LocalHTTPProxy() if !ok { _ = engine.Stop() return fmt.Errorf("system_proxy requires an http:// listen address in the profile") } if err := m.sys.Enable(httpProxy); err != nil { if errors.Is(err, sysproxy.ErrUnsupported) { fmt.Fprintf(m.stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n") } else { _ = engine.Stop() return fmt.Errorf("enable system proxy: %w", err) } } } m.engine = engine m.profile = profile return nil } func (m *Manager) Disconnect() error { m.mu.Lock() defer m.mu.Unlock() var first error if m.sys.Enabled() { if err := m.sys.Disable(); err != nil && first == nil { first = err } } if m.engine != nil { if err := m.engine.Stop(); err != nil && first == nil { first = err } m.engine = nil } m.profile = nil return first } func (m *Manager) Status() Status { m.mu.Lock() defer m.mu.Unlock() st := Status{SystemProxy: m.sys.Enabled()} if m.engine == nil || !m.engine.Running() { return st } st.Connected = true if m.profile != nil { st.Profile = m.profile.Name st.Protocol = m.profile.Protocol } if hp, ok := m.engine.LocalHTTPProxy(); ok { st.HTTPProxy = hp } if sp, ok := m.engine.LocalSOCKSProxy(); ok { st.SOCKSProxy = sp } return st } // Probe fetches url via the local HTTP or SOCKS proxy to verify the tunnel. func (m *Manager) Probe(ctx context.Context, testURL string) error { m.mu.Lock() engine := m.engine m.mu.Unlock() if engine == nil || !engine.Running() { return fmt.Errorf("not connected") } if testURL == "" { testURL = "https://www.google.com/generate_204" } var proxyURL *url.URL if hp, ok := engine.LocalHTTPProxy(); ok { proxyURL, _ = url.Parse("http://" + hp) } else if sp, ok := engine.LocalSOCKSProxy(); ok { proxyURL, _ = url.Parse("socks5://" + sp) } else { return fmt.Errorf("no local proxy listen address") } transport := &http.Transport{ Proxy: http.ProxyURL(proxyURL), DialContext: (&net.Dialer{ Timeout: 15 * time.Second, }).DialContext, TLSHandshakeTimeout: 15 * time.Second, ForceAttemptHTTP2: true, } client := &http.Client{ Transport: transport, Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil) if err != nil { return err } resp, err := client.Do(req) if err != nil { return fmt.Errorf("probe via %s: %w", proxyURL.String(), err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return fmt.Errorf("probe unexpected status %d", resp.StatusCode) } return nil } func (m *Manager) BinDir() string { return m.binDir } func (m *Manager) ConfigPath() string { return m.cfgPath } func (m *Manager) Config() *config.Config { m.mu.Lock() defer m.mu.Unlock() return m.cfg } func (m *Manager) SetSystemProxy(enabled bool) { m.mu.Lock() defer m.mu.Unlock() m.cfg.SystemProxy = enabled } func (m *Manager) UpdateProxyURI(proxy string) error { m.mu.Lock() defer m.mu.Unlock() active, err := m.cfg.ActiveProfile() if err != nil { return err } normalized, proto, _, err := linknorm.Normalize(active.Protocol, proxy) if err != nil { return err } if err := m.cfg.SetActiveProxy(normalized); err != nil { return err } if proto != "" { _ = m.cfg.UpsertProfileWithProtocol(m.cfg.Active, normalized, proto) } return config.Save(m.cfgPath, *m.cfg) } func (m *Manager) SetActiveProfile(name string) error { m.mu.Lock() defer m.mu.Unlock() if m.engine != nil && m.engine.Running() { return fmt.Errorf("сначала отключитесь") } if err := m.cfg.SetActive(name); err != nil { return err } return config.Save(m.cfgPath, *m.cfg) } func (m *Manager) SaveProfile(name, proxy string) error { m.mu.Lock() defer m.mu.Unlock() if m.engine != nil && m.engine.Running() { return fmt.Errorf("сначала отключитесь") } proxy = strings.TrimSpace(proxy) var proto config.Protocol if proxy != "" { normalized, detected, _, err := linknorm.Normalize("", proxy) if err != nil { return err } proxy = normalized proto = detected } if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil { return err } if p, err := m.cfg.ActiveProfile(); err == nil { hysteria2.EnrichProfile(p) } return config.Save(m.cfgPath, *m.cfg) } // SaveActiveProfile updates the current profile (rename if needed) and proxy URI. func (m *Manager) SaveActiveProfile(name, proxy string) error { m.mu.Lock() defer m.mu.Unlock() if m.engine != nil && m.engine.Running() { return fmt.Errorf("сначала отключитесь") } name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("имя профиля пустое") } proxy = strings.TrimSpace(proxy) var proto config.Protocol if proxy != "" { activeProto := config.Protocol("") if p, err := m.cfg.ActiveProfile(); err == nil { activeProto = p.Protocol } normalized, detected, _, err := linknorm.Normalize(activeProto, proxy) if err != nil { return err } proxy = normalized proto = detected } active := m.cfg.Active if name != active { exists := false for _, p := range m.cfg.Profiles { if p.Name == name { exists = true break } } if exists { if err := m.cfg.SetActive(name); err != nil { return err } } else if err := m.cfg.RenameProfile(active, name); err != nil { return err } } if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil { return err } if p, err := m.cfg.ActiveProfile(); err == nil { hysteria2.EnrichProfile(p) } return config.Save(m.cfgPath, *m.cfg) } func (m *Manager) DeleteProfile(name string) error { m.mu.Lock() defer m.mu.Unlock() if m.engine != nil && m.engine.Running() { return fmt.Errorf("сначала отключитесь") } if err := m.cfg.DeleteProfile(name); err != nil { return err } return config.Save(m.cfgPath, *m.cfg) } func (m *Manager) Profiles() []config.ProfileInfo { m.mu.Lock() defer m.mu.Unlock() return m.cfg.ListProfiles() } // Hy2Options are editable Hysteria 2 extras for the active profile. type Hy2Options struct { Congestion string `json:"congestion"` BBRProfile string `json:"bbr_profile"` BandwidthUp string `json:"bandwidth_up"` BandwidthDown string `json:"bandwidth_down"` Obfs string `json:"obfs"` ObfsPassword string `json:"obfs_password"` SNI string `json:"sni"` Insecure bool `json:"insecure"` PinSHA256 string `json:"pin_sha256"` FastOpen bool `json:"fast_open"` Lazy bool `json:"lazy"` HopInterval string `json:"hop_interval"` } func (m *Manager) ActiveHy2Options() Hy2Options { m.mu.Lock() defer m.mu.Unlock() p, err := m.cfg.ActiveProfile() if err != nil { return Hy2Options{Congestion: "bbr", BBRProfile: "standard"} } return Hy2Options{ Congestion: p.Hy2Congestion, BBRProfile: p.Hy2BBRProfile, BandwidthUp: p.Hy2BandwidthUp, BandwidthDown: p.Hy2BandwidthDown, Obfs: p.Hy2Obfs, ObfsPassword: p.Hy2ObfsPassword, SNI: p.Hy2SNI, Insecure: p.Hy2Insecure, PinSHA256: p.Hy2PinSHA256, FastOpen: p.Hy2FastOpen, Lazy: p.Hy2Lazy, HopInterval: p.Hy2HopInterval, } } func (m *Manager) SaveHy2Options(opts Hy2Options) error { m.mu.Lock() defer m.mu.Unlock() if m.engine != nil && m.engine.Running() { return fmt.Errorf("сначала отключитесь") } p, err := m.cfg.ActiveProfile() if err != nil { return err } p.Protocol = config.ProtocolHysteria2 p.Hy2Congestion = strings.TrimSpace(opts.Congestion) p.Hy2BBRProfile = strings.TrimSpace(opts.BBRProfile) p.Hy2BandwidthUp = strings.TrimSpace(opts.BandwidthUp) p.Hy2BandwidthDown = strings.TrimSpace(opts.BandwidthDown) p.Hy2Obfs = strings.TrimSpace(opts.Obfs) p.Hy2ObfsPassword = strings.TrimSpace(opts.ObfsPassword) p.Hy2SNI = strings.TrimSpace(opts.SNI) p.Hy2Insecure = opts.Insecure p.Hy2PinSHA256 = strings.TrimSpace(opts.PinSHA256) p.Hy2FastOpen = opts.FastOpen p.Hy2Lazy = opts.Lazy p.Hy2HopInterval = strings.TrimSpace(opts.HopInterval) return config.Save(m.cfgPath, *m.cfg) } func (m *Manager) SubscriptionURL() string { m.mu.Lock() defer m.mu.Unlock() return m.cfg.SubscriptionURL } func (m *Manager) ImportSubscription(rawURL string) (int, error) { ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() items, err := subscription.Fetch(ctx, rawURL) if err != nil { return 0, err } if len(items) == 0 { return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)") } m.mu.Lock() defer m.mu.Unlock() m.cfg.SubscriptionURL = strings.TrimSpace(rawURL) for _, it := range items { _ = m.cfg.UpsertProfileWithProtocol(it.Name, it.URI, it.Protocol) for i := range m.cfg.Profiles { if m.cfg.Profiles[i].Name == it.Name { hysteria2.EnrichProfile(&m.cfg.Profiles[i]) break } } } if err := config.Save(m.cfgPath, *m.cfg); err != nil { return 0, err } return len(items), nil } func (m *Manager) SaveConfig() error { m.mu.Lock() defer m.mu.Unlock() return config.Save(m.cfgPath, *m.cfg) } func (m *Manager) Reload() error { m.mu.Lock() defer m.mu.Unlock() if m.engine != nil && m.engine.Running() { return fmt.Errorf("disconnect before reloading config") } cfg, err := config.Load(m.cfgPath) if err != nil { return err } binDir, err := config.ResolveBinDir(m.cfgPath, cfg.BinDir) if err != nil { return err } m.cfg = cfg m.binDir = binDir return nil } func (m *Manager) newEngine(p config.Protocol) (Engine, error) { switch p { case config.ProtocolNaive, "": return naive.New(m.stderr), nil case config.ProtocolHysteria2: return hysteria2.New(m.stderr), nil case config.ProtocolAWG: return awg.New(m.stderr), nil case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan: return xray.New(m.stderr), nil default: return nil, fmt.Errorf("unsupported protocol %q", p) } } // EnsureCore downloads the binary required by the active (or given) protocol. func (m *Manager) EnsureCore(proto config.Protocol) (string, error) { if proto == "" { if p, err := m.cfg.ActiveProfile(); err == nil { proto = p.Protocol } } switch proto { case config.ProtocolHysteria2: return hysteria2.EnsureBinary(m.binDir) case config.ProtocolAWG: return awg.EnsureBinary(m.binDir) case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan: return xray.EnsureBinary(m.binDir) default: return naive.EnsureBinary(m.binDir) } } // EnsureAllCores installs naive + hysteria2 + xray cores (AWG is embedded). func (m *Manager) EnsureAllCores() (map[string]string, error) { out := map[string]string{} p1, err := naive.EnsureBinary(m.binDir) if err != nil { return out, fmt.Errorf("naive: %w", err) } out["naive"] = p1 p2, err := hysteria2.EnsureBinary(m.binDir) if err != nil { return out, fmt.Errorf("hysteria2: %w", err) } out["hysteria2"] = p2 p3, err := awg.EnsureBinary(m.binDir) if err != nil { return out, fmt.Errorf("awg: %w", err) } out["awg"] = p3 p4, err := xray.EnsureBinary(m.binDir) if err != nil { return out, fmt.Errorf("xray: %w", err) } out["xray"] = p4 return out, nil }