Files
navi/internal/core/manager.go
T
NavisandCursor 50f7f71393 Add site contacts/FAQ and accept AWG 2.0 / naive pastes in Add config.
ImportSubscription no longer requires only http(s): paste awg://, AWG .conf/JSON, or naive links; subscription parser also reads hosted AWG and Clash wireguard/naive. Site lists support emails and FAQ; Store listing texts/tools updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 15:40:13 +03:00

887 lines
24 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
// watchGen invalidates engine watchdogs from previous connects.
watchGen uint64
// ksEngaged is true after an unexpected engine death with kill switch on:
// blackhole routes / dead system proxy are intentionally left in place.
ksEngaged bool
}
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")
}
// Reconnect releases a previously engaged kill switch (blackhole state).
if m.ksEngaged {
if m.vpn != nil {
_ = m.vpn.Close()
m.vpn = nil
}
if m.sys.Enabled() {
_ = m.sys.Disable()
}
m.ksEngaged = false
}
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
m.watchGen++
go m.watchEngine(m.watchGen, engine)
return nil
}
// watchEngine polls the engine and reacts to an UNEXPECTED death (crash of the
// core process). A normal Disconnect bumps watchGen first, so the watcher just
// exits. With kill switch enabled the blackhole state is kept, otherwise the
// network is restored (system proxy off, TUN routes removed).
func (m *Manager) watchEngine(gen uint64, engine Engine) {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
m.mu.Lock()
if m.watchGen != gen || m.engine != engine {
m.mu.Unlock()
return
}
if engine.Running() {
m.mu.Unlock()
continue
}
// Unexpected death: engine stopped without Disconnect.
m.engine = nil
m.profile = nil
ks := m.cfg.KillSwitch
vpn := m.vpn
sysOn := m.sys.Enabled()
if ks && (vpn != nil || sysOn) {
// Keep TUN blackhole routes / dead system proxy: traffic is
// blocked until the user disconnects manually or reconnects.
m.ksEngaged = true
m.mu.Unlock()
fmt.Fprintf(m.stderr, "kill switch: соединение прервано — трафик заблокирован до ручного отключения или переподключения\n")
return
}
m.vpn = nil
m.mu.Unlock()
if vpn != nil {
_ = vpn.Close()
}
if sysOn {
_ = m.sys.Disable()
}
fmt.Fprintf(m.stderr, "соединение прервано — сетевые настройки восстановлены\n")
return
}
}
// KillSwitchEnabled returns the persisted kill switch setting.
func (m *Manager) KillSwitchEnabled() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg.KillSwitch
}
// SetKillSwitch persists the kill switch setting.
func (m *Manager) SetKillSwitch(enabled bool) error {
m.mu.Lock()
defer m.mu.Unlock()
m.cfg.KillSwitch = enabled
return config.Save(m.cfgPath, *m.cfg)
}
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.watchGen++ // cancel the engine watchdog — this stop is intentional
m.ksEngaged = false
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, KillSwitchActive: m.ksEngaged}
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)
if rawURL == "" {
return ImportResult{}, fmt.Errorf("пустой URL или конфигурация")
}
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)
}
// Remote subscription / hosted config file (http(s) without user:pass@).
if linknorm.LooksLikeSubscriptionURL(rawURL) {
res, err := subscription.Fetch(ctx, rawURL)
if err != nil {
return ImportResult{}, err
}
return m.applySubscription(ctx, res, rawURL, nil)
}
// Direct paste into «Добавить конфигурацию»: awg://, AWG 2.0 .conf / JSON,
// naive share links, Clash YAML body, multi-line link lists — no HTTP fetch.
res, err := subscription.ParseBodyDetailed(rawURL)
if err != nil || res == nil || len(res.Items) == 0 {
if err != nil {
return ImportResult{}, fmt.Errorf("%w — либо вставьте http(s) URL конфигурации, либо ссылку/тело AWG, naive, hy2, vless…", err)
}
return ImportResult{}, fmt.Errorf("не удалось распознать конфигурацию — нужен http(s) URL либо ссылка/тело (AWG, naive, hy2, vless…)")
}
return m.applySubscription(ctx, res, "", 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
}