Files
navi/internal/core/manager.go
T

789 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package core
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"vpnclient/internal/config"
"vpnclient/internal/linknorm"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/protocols/xray"
"vpnclient/internal/remnawave"
"vpnclient/internal/subscription"
"vpnclient/internal/sysproxy"
"vpnclient/internal/vpnmode"
)
// 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
// vpn is the active VPN-mode (TUN) session, nil in proxy mode.
vpn *vpnmode.Session
}
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
}
vpnActive := false
if m.cfg.Mode == "vpn" {
if !vpnmode.Supported() {
_ = engine.Stop()
return fmt.Errorf("режим VPN пока доступен только на Windows — переключитесь на режим прокси")
}
socksAddr, ok := engine.LocalSOCKSProxy()
if !ok {
_ = engine.Stop()
return fmt.Errorf("режим VPN: у протокола нет локального SOCKS-прокси")
}
host, _, err := netcheck.HostPort(profile.Protocol, profile.Proxy)
if err != nil || strings.TrimSpace(host) == "" {
_ = engine.Stop()
return fmt.Errorf("режим VPN: не удалось определить адрес сервера для исключения из туннеля")
}
sess, err := vpnmode.Start(vpnmode.Config{
SOCKSAddr: socksAddr,
ExcludeHosts: []string{host},
Stderr: m.stderr,
})
if err != nil {
_ = engine.Stop()
return err
}
m.vpn = sess
vpnActive = true
}
// System proxy is redundant while the TUN captures everything.
if m.cfg.SystemProxy && !vpnActive {
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()
if m.vpn != nil {
_ = m.vpn.Close()
m.vpn = nil
}
return fmt.Errorf("enable system proxy: %w", err)
}
}
}
m.engine = engine
m.profile = profile
return nil
}
func (m *Manager) Disconnect() error {
m.mu.Lock()
sys := m.sys
engine := m.engine
vpn := m.vpn
m.engine = nil
m.profile = nil
m.vpn = nil
m.mu.Unlock()
var first error
if vpn != nil {
if err := vpn.Close(); err != nil && first == nil {
first = err
}
}
if sys != nil && sys.Enabled() {
if err := sys.Disable(); err != nil && first == nil {
first = err
}
}
if engine != nil {
done := make(chan error, 1)
go func() { done <- engine.Stop() }()
select {
case err := <-done:
if err != nil && first == nil {
first = err
}
case <-time.After(3 * time.Second):
// Engine Stop can block on userspace relays; don't freeze the UI.
if first == nil {
first = fmt.Errorf("отключение заняло слишком много времени")
}
}
}
return first
}
func (m *Manager) Status() Status {
m.mu.Lock()
defer m.mu.Unlock()
st := Status{SystemProxy: m.sys.Enabled(), VPNActive: m.vpn != nil}
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
}
// Mode returns the persisted traffic mode: "proxy" (default) or "vpn".
func (m *Manager) Mode() string {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg.Mode == "vpn" {
return "vpn"
}
return "proxy"
}
// SetMode persists the traffic mode; takes effect on next connect.
func (m *Manager) SetMode(mode string) error {
switch mode {
case "proxy", "vpn":
default:
return fmt.Errorf("неизвестный режим %q", mode)
}
if mode == "vpn" {
if !vpnmode.Supported() {
return fmt.Errorf("режим VPN пока доступен только на Windows")
}
if !vpnmode.IsElevated() {
return fmt.Errorf("Запустите EvilFox от имени администратора для режима VPN")
}
if !vpnmode.WintunAvailable() {
return fmt.Errorf("не найден wintun.dll — скачайте с wintun.net и положите рядом с EvilFox.exe")
}
}
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
return fmt.Errorf("сначала отключитесь, затем меняйте режим")
}
m.cfg.Mode = mode
return config.Save(m.cfgPath, *m.cfg)
}
// VPNModeSupported reports whether the VPN (TUN) switch should be shown.
func (m *Manager) VPNModeSupported() bool { return vpnmode.Supported() }
func (m *Manager) UpdateProxyURI(proxy string) error {
// A plain http(s) URL without credentials is a subscription URL, not a
// proxy link — run the subscription import instead of failing with
// "proxy URI missing username".
if linknorm.LooksLikeSubscriptionURL(proxy) {
_, err := m.ImportSubscription(proxy)
return err
}
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 {
// Subscription URL pasted into the "add server link" field → import it.
if linknorm.LooksLikeSubscriptionURL(proxy) {
_, err := m.ImportSubscription(proxy)
return err
}
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 {
// Subscription URL pasted into the profile link field → import it.
if linknorm.LooksLikeSubscriptionURL(proxy) {
_, err := m.ImportSubscription(proxy)
return err
}
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) RemnawaveSettings() remnawave.Settings {
m.mu.Lock()
defer m.mu.Unlock()
return remnawave.Settings{
BaseURL: m.cfg.RemnawaveBaseURL,
Token: m.cfg.RemnawaveToken,
ShortUUID: m.cfg.RemnawaveShortUUID,
CaddyToken: m.cfg.RemnawaveCaddyToken,
}
}
func (m *Manager) SaveRemnawaveSettings(s remnawave.Settings) error {
m.mu.Lock()
defer m.mu.Unlock()
m.cfg.RemnawaveBaseURL = remnawave.NormalizeBase(s.BaseURL)
m.cfg.RemnawaveToken = strings.TrimSpace(s.Token)
m.cfg.RemnawaveShortUUID = strings.TrimSpace(s.ShortUUID)
m.cfg.RemnawaveCaddyToken = strings.TrimSpace(s.CaddyToken)
return config.Save(m.cfgPath, *m.cfg)
}
// ImportResult reports how a subscription import went.
type ImportResult struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Warnings []string `json:"warnings,omitempty"`
}
func (m *Manager) ImportSubscription(rawURL string) (ImportResult, error) {
rawURL = strings.TrimSpace(rawURL)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
// Remnawave-style URL → persist panel coords and fetch via API helper.
if base, short, ok := remnawave.ParseInput(rawURL); ok {
s := remnawave.Settings{
BaseURL: base,
ShortUUID: short,
Token: m.RemnawaveSettings().Token,
CaddyToken: m.RemnawaveSettings().CaddyToken,
}
res, used, err := remnawave.Fetch(ctx, s)
if err != nil {
return ImportResult{}, err
}
return m.applySubscription(ctx, res, used, &s)
}
res, err := subscription.Fetch(ctx, rawURL)
if err != nil {
return ImportResult{}, err
}
return m.applySubscription(ctx, res, rawURL, nil)
}
// ImportRemnawave fetches configs using saved / provided panel settings.
func (m *Manager) ImportRemnawave(s remnawave.Settings) (ImportResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if strings.TrimSpace(s.BaseURL) == "" || strings.TrimSpace(s.ShortUUID) == "" {
cur := m.RemnawaveSettings()
if strings.TrimSpace(s.BaseURL) == "" {
s.BaseURL = cur.BaseURL
}
if strings.TrimSpace(s.ShortUUID) == "" {
s.ShortUUID = cur.ShortUUID
}
if strings.TrimSpace(s.Token) == "" {
s.Token = cur.Token
}
if strings.TrimSpace(s.CaddyToken) == "" {
s.CaddyToken = cur.CaddyToken
}
}
res, used, err := remnawave.Fetch(ctx, s)
if err != nil {
return ImportResult{}, err
}
return m.applySubscription(ctx, res, used, &s)
}
func (m *Manager) applySubscription(ctx context.Context, res *subscription.Result, usedURL string, rw *remnawave.Settings) (ImportResult, error) {
if res == nil || len(res.Items) == 0 {
if res != nil && res.Skipped > 0 {
return ImportResult{Skipped: res.Skipped, Warnings: res.Warnings},
fmt.Errorf("нет валидных серверов: %d записей пропущено (%s)", res.Skipped, strings.Join(res.Warnings, "; "))
}
return ImportResult{}, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
}
// Subscription usage info: header data first, enriched via Remnawave API
// (devices / limits) when an API token is configured. Best-effort.
subInfo := buildSubInfo(res.Info)
{
s := m.RemnawaveSettings()
if rw != nil {
s = *rw
if strings.TrimSpace(s.Token) == "" {
s.Token = m.RemnawaveSettings().Token
}
}
if strings.TrimSpace(s.Token) != "" && strings.TrimSpace(s.BaseURL) != "" && strings.TrimSpace(s.ShortUUID) != "" {
if ui, err := remnawave.FetchUserInfo(ctx, s); err == nil && ui != nil {
if subInfo == nil {
subInfo = &config.SubscriptionInfo{}
}
if subInfo.Upload == 0 && subInfo.Download == 0 && ui.UsedTraffic > 0 {
subInfo.Download = ui.UsedTraffic
}
if subInfo.Total == 0 && ui.TrafficLimit > 0 {
subInfo.Total = ui.TrafficLimit
}
if subInfo.Expire == 0 && ui.ExpireAt > 0 {
subInfo.Expire = ui.ExpireAt
}
subInfo.DevicesKnown = ui.DevicesKnown
subInfo.Devices = ui.Devices
subInfo.DeviceLimit = ui.DeviceLimit
}
}
}
if subInfo != nil {
subInfo.UpdatedAt = time.Now().Unix()
}
m.mu.Lock()
defer m.mu.Unlock()
if usedURL != "" {
m.cfg.SubscriptionURL = strings.TrimSpace(usedURL)
}
if rw != nil {
m.cfg.RemnawaveBaseURL = remnawave.NormalizeBase(rw.BaseURL)
m.cfg.RemnawaveShortUUID = strings.TrimSpace(rw.ShortUUID)
if t := strings.TrimSpace(rw.Token); t != "" {
m.cfg.RemnawaveToken = t
}
if t := strings.TrimSpace(rw.CaddyToken); t != "" {
m.cfg.RemnawaveCaddyToken = t
}
}
if subInfo != nil {
m.cfg.SubInfo = subInfo
}
for _, it := range res.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 ImportResult{}, err
}
return ImportResult{Imported: len(res.Items), Skipped: res.Skipped, Warnings: res.Warnings}, nil
}
func buildSubInfo(info *subscription.Info) *config.SubscriptionInfo {
if info == nil {
return nil
}
return &config.SubscriptionInfo{
Upload: info.Upload,
Download: info.Download,
Total: info.Total,
Expire: info.Expire,
}
}
// SubscriptionInfo returns the cached subscription usage info (may be nil).
func (m *Manager) SubscriptionInfo() *config.SubscriptionInfo {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg.SubInfo == nil {
return nil
}
cp := *m.cfg.SubInfo
return &cp
}
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
}