Release 3.9.0+2: tolerant subscription import, full Remnawave server list, subscription info card (expiry / traffic / devices).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-08-01 17:01:37 +03:00
co-authored by Cursor
parent 015ded9bd5
commit e34312ef9c
18 changed files with 818 additions and 174 deletions
+97 -25
View File
@@ -26,14 +26,14 @@ import (
// 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
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) {
@@ -439,7 +439,14 @@ func (m *Manager) SaveRemnawaveSettings(s remnawave.Settings) error {
return config.Save(m.cfgPath, *m.cfg)
}
func (m *Manager) ImportSubscription(rawURL string) (int, error) {
// 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()
@@ -452,22 +459,22 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) {
Token: m.RemnawaveSettings().Token,
CaddyToken: m.RemnawaveSettings().CaddyToken,
}
items, used, err := remnawave.Fetch(ctx, s)
res, used, err := remnawave.Fetch(ctx, s)
if err != nil {
return 0, err
return ImportResult{}, err
}
return m.applySubscriptionItems(items, used, &s)
return m.applySubscription(ctx, res, used, &s)
}
items, err := subscription.Fetch(ctx, rawURL)
res, err := subscription.Fetch(ctx, rawURL)
if err != nil {
return 0, err
return ImportResult{}, err
}
return m.applySubscriptionItems(items, rawURL, nil)
return m.applySubscription(ctx, res, rawURL, nil)
}
// ImportRemnawave fetches configs using saved / provided panel settings.
func (m *Manager) ImportRemnawave(s remnawave.Settings) (int, error) {
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) == "" {
@@ -485,16 +492,55 @@ func (m *Manager) ImportRemnawave(s remnawave.Settings) (int, error) {
s.CaddyToken = cur.CaddyToken
}
}
items, used, err := remnawave.Fetch(ctx, s)
res, used, err := remnawave.Fetch(ctx, s)
if err != nil {
return 0, err
return ImportResult{}, err
}
return m.applySubscriptionItems(items, used, &s)
return m.applySubscription(ctx, res, used, &s)
}
func (m *Manager) applySubscriptionItems(items []subscription.Item, usedURL string, rw *remnawave.Settings) (int, error) {
if len(items) == 0 {
return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
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()
@@ -512,7 +558,10 @@ func (m *Manager) applySubscriptionItems(items []subscription.Item, usedURL stri
m.cfg.RemnawaveCaddyToken = t
}
}
for _, it := range items {
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 {
@@ -522,9 +571,32 @@ func (m *Manager) applySubscriptionItems(items []subscription.Item, usedURL stri
}
}
if err := config.Save(m.cfgPath, *m.cfg); err != nil {
return 0, err
return ImportResult{}, err
}
return len(items), nil
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 {