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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
func newTestManager(t *testing.T) *Manager {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "configs", "config.json")
|
||||
cfg := config.Example()
|
||||
if err := config.Save(cfgPath, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := NewManager(cfgPath, loaded, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
const testSubBody = "vless://7e1c0fbf-1758-4781-97f6-ae8715ed3f60@de.example.com:443?encryption=none&type=tcp&security=tls&sni=de.example.com#DE-1\n" +
|
||||
"hysteria2://pass@nl.example.com:9000/?sni=nl.example.com#NL-1\n"
|
||||
|
||||
// A credential-less https URL pasted into the "add server link" field must be
|
||||
// treated as a subscription URL and imported — never fail with
|
||||
// "proxy URI missing username" (regression for the naive proxy-URI parser).
|
||||
func TestSaveProfileRoutesSubscriptionURL(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, testSubBody)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
m := newTestManager(t)
|
||||
if err := m.SaveProfile("whatever", ts.URL+"/ZKszcVcC3xbWb8qj"); err != nil {
|
||||
t.Fatalf("SaveProfile(subscription URL) failed: %v", err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, p := range m.Profiles() {
|
||||
names[p.Name] = true
|
||||
}
|
||||
if !names["DE-1"] || !names["NL-1"] {
|
||||
t.Fatalf("subscription profiles not imported, got %v", names)
|
||||
}
|
||||
if m.Config().SubscriptionURL == "" {
|
||||
t.Fatal("subscription URL not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveActiveProfileRoutesSubscriptionURL(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, testSubBody)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
m := newTestManager(t)
|
||||
if err := m.SaveActiveProfile("naive-main", ts.URL+"/short"); err != nil {
|
||||
t.Fatalf("SaveActiveProfile(subscription URL) failed: %v", err)
|
||||
}
|
||||
if len(m.Profiles()) < 3 { // naive-main + 2 imported
|
||||
t.Fatalf("expected imported profiles, got %d", len(m.Profiles()))
|
||||
}
|
||||
}
|
||||
|
||||
// A real naive share link (with credentials) must still be saved as a proxy.
|
||||
func TestSaveProfileKeepsNaiveLink(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
if err := m.SaveProfile("naive-1", "https://user:pass@proxy.example.com:443"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := m.Config().ActiveProfile()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Protocol != config.ProtocolNaive || !strings.Contains(p.Proxy, "user:pass@") {
|
||||
t.Fatalf("naive profile broken: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionConfigured(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
if m.ProvisionConfigured() {
|
||||
t.Fatal("should be false without remnawave-api.json")
|
||||
}
|
||||
path := filepath.Join(filepath.Dir(m.ConfigPath()), "remnawave-api.json")
|
||||
if err := os.WriteFile(path, []byte(`{}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.ProvisionConfigured() {
|
||||
t.Fatal("should be true with remnawave-api.json present")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user