Release 3.9.0+3: subscription-URL routing, emoji flags, Remnawave provisioning.

Route credential-less http(s) URLs pasted into the add-link field to
subscription import (fixes remaining 'proxy URI missing username').
Extend geoflag with RU country names and city hints; live Remnawave
names already carrying emoji flags are kept as-is. Add admin
provisioning via configs/remnawave-api.json (GET by-username / POST
users, 50 GB MONTH plan) and the «Выдать доступ» UI panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-08-01 17:36:54 +03:00
co-authored by Cursor
parent e34312ef9c
commit 77bd7da861
24 changed files with 974 additions and 110 deletions
+72
View File
@@ -221,6 +221,13 @@ func (m *Manager) SetSystemProxy(enabled bool) {
}
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()
@@ -253,6 +260,11 @@ func (m *Manager) SetActiveProfile(name string) error {
}
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() {
@@ -279,6 +291,11 @@ func (m *Manager) SaveProfile(name, proxy string) error {
// 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() {
@@ -576,6 +593,61 @@ func (m *Manager) applySubscription(ctx context.Context, res *subscription.Resul
return ImportResult{Imported: len(res.Items), Skipped: res.Skipped, Warnings: res.Warnings}, nil
}
// ProvisionOutcome is what the UI shows after issuing access via the panel.
type ProvisionOutcome struct {
Username string `json:"username"`
Created bool `json:"created"`
SubscriptionURL string `json:"subscription_url"`
TrafficLimitBytes int64 `json:"traffic_limit_bytes,omitempty"`
ExpireAt int64 `json:"expire_at,omitempty"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
}
// ProvisionConfigured reports whether configs/remnawave-api.json exists —
// the admin "Выдать доступ" panel is shown only in that case.
func (m *Manager) ProvisionConfigured() bool {
st, err := os.Stat(remnawave.AdminConfigPath(m.cfgPath))
return err == nil && !st.IsDir()
}
// ProvisionAccess creates (or finds) a panel user via the Remnawave admin API
// and imports its subscription configs into the app.
func (m *Manager) ProvisionAccess(username string) (ProvisionOutcome, error) {
cfg, err := remnawave.LoadAdminConfig(remnawave.AdminConfigPath(m.cfgPath))
if err != nil {
return ProvisionOutcome{}, err
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
res, err := remnawave.ProvisionUser(ctx, cfg, username)
if err != nil {
return ProvisionOutcome{}, err
}
out := ProvisionOutcome{
Username: res.Username,
Created: res.Created,
SubscriptionURL: res.SubscriptionURL,
TrafficLimitBytes: res.TrafficLimitBytes,
ExpireAt: res.ExpireAt,
}
subURL := res.SubscriptionURL
if subURL == "" && res.ShortUUID != "" {
subURL = remnawave.PublicSubURL(cfg.PanelURL, res.ShortUUID)
out.SubscriptionURL = subURL
}
if subURL == "" {
return out, fmt.Errorf("панель не вернула subscription URL для %s", res.Username)
}
imp, err := m.ImportSubscription(subURL)
if err != nil {
return out, fmt.Errorf("доступ выдан (%s), но импорт конфигов не удался: %w", res.Username, err)
}
out.Imported = imp.Imported
out.Skipped = imp.Skipped
return out, nil
}
func buildSubInfo(info *subscription.Info) *config.SubscriptionInfo {
if info == nil {
return nil