package core import ( "context" "fmt" "io" "net" "net/http" "net/url" "os" "strings" "sync" "time" "vpnclient/internal/config" "vpnclient/internal/protocols/naive" "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 { _ = 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() normalized, err := naive.NormalizeProxyURI(proxy) if err != nil { return err } if err := m.cfg.SetActiveProxy(normalized); err != nil { return err } 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) if proxy != "" { normalized, err := naive.NormalizeProxyURI(proxy) if err != nil { return err } proxy = normalized } if err := m.cfg.UpsertProfile(name, proxy); err != nil { return err } 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) if proxy != "" { normalized, err := naive.NormalizeProxyURI(proxy) if err != nil { return err } proxy = normalized } 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.SetActiveProxy(proxy); err != nil { return err } 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() } 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 default: return nil, fmt.Errorf("unsupported protocol %q", p) } }