// Package remnawave fetches VPN share links from a Remnawave panel. // // Real panel endpoints (API ≈2.x, see remnawave/backend + rw-sdk): // // GET {base}/api/sub/{shortUuid} — public subscription body (base64/YAML/text) // GET {base}/api/sub/{shortUuid}/{clientType} — typed client subscription // GET {base}/api/subscriptions/by-short-uuid/{shortUuid} — JSON subscription meta (Bearer API token) // GET {base}/api/subscriptions/by-short-uuid/{shortUuid}/raw — rich raw payload (Bearer) // GET {base}/api/users/by-short-uuid/{shortUuid} — user incl. subscriptionUrl (Bearer) // // Auth: Authorization: Bearer from panel → API Tokens. // Optional: X-Api-Key for Caddy auth frontends. // Responses are often wrapped as {"response": ...}. package remnawave import ( "context" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strings" "time" "vpnclient/internal/subscription" ) // Settings are persisted locally (never committed as secrets). type Settings struct { BaseURL string `json:"base_url"` Token string `json:"token,omitempty"` ShortUUID string `json:"short_uuid"` CaddyToken string `json:"caddy_token,omitempty"` } // NormalizeBase strips trailing slash and optional trailing /api. func NormalizeBase(raw string) string { raw = strings.TrimSpace(raw) raw = strings.TrimRight(raw, "/") if strings.HasSuffix(strings.ToLower(raw), "/api") { raw = strings.TrimRight(raw[:len(raw)-4], "/") } return raw } // PublicSubURL is the standard Remnawave client subscription URL. func PublicSubURL(base, shortUUID string) string { base = NormalizeBase(base) shortUUID = strings.TrimSpace(shortUUID) if base == "" || shortUUID == "" { return "" } return base + "/api/sub/" + url.PathEscape(shortUUID) } // ParseInput extracts panel base + short UUID from a Remnawave subscription URL. // Accepts: // - https://panel.example.com/api/sub/{short} // - https://panel.example.com/api/sub/{short}/clash // - https://sub.example.com/{short} (custom sub page — returns short only if path is one segment) func ParseInput(raw string) (base, shortUUID string, ok bool) { raw = strings.TrimSpace(raw) u, err := url.Parse(raw) if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { return "", "", false } path := strings.Trim(u.Path, "/") parts := strings.Split(path, "/") // …/api/sub/{short}[/{clientType}] for i := 0; i+1 < len(parts); i++ { if strings.EqualFold(parts[i], "api") && strings.EqualFold(parts[i+1], "sub") && i+2 < len(parts) { short := parts[i+2] if short == "" || strings.EqualFold(short, "outline") { return "", "", false } base = u.Scheme + "://" + u.Host if i > 0 { base += "/" + strings.Join(parts[:i], "/") } return NormalizeBase(base), short, true } } return "", "", false } // Fetch downloads configs via Remnawave public sub URL and, if a token is set, // authenticated subscription/user endpoints. Parsed items reuse subscription.ParseBody. func Fetch(ctx context.Context, s Settings) (*subscription.Result, string, error) { s.BaseURL = NormalizeBase(s.BaseURL) s.ShortUUID = strings.TrimSpace(s.ShortUUID) s.Token = strings.TrimSpace(s.Token) s.CaddyToken = strings.TrimSpace(s.CaddyToken) if s.BaseURL == "" || s.ShortUUID == "" { return nil, "", fmt.Errorf("укажите URL панели Remnawave и short UUID") } if _, err := url.Parse(s.BaseURL); err != nil { return nil, "", fmt.Errorf("некорректный URL панели") } client := &http.Client{Timeout: 45 * time.Second} pub := PublicSubURL(s.BaseURL, s.ShortUUID) var lastErr error // 1) Public subscription (usual end-user path — no token). if res, err := fetchAndParse(ctx, client, s, pub, false); err == nil && res != nil && len(res.Items) > 0 { return res, pub, nil } else if err != nil { lastErr = err } // 2) With API token: resolve subscriptionUrl / raw payload. if s.Token != "" { if res, used, err := fetchWithToken(ctx, client, s); err == nil && res != nil && len(res.Items) > 0 { if used == "" { used = pub } return res, used, nil } else if err != nil { lastErr = err } } if lastErr != nil { return nil, "", lastErr } return nil, "", fmt.Errorf("Remnawave: нет поддерживаемых ссылок (проверьте short UUID / токен / правила ответа конфигурации)") } func fetchWithToken(ctx context.Context, client *http.Client, s Settings) (*subscription.Result, string, error) { base := s.BaseURL short := s.ShortUUID // Prefer admin raw endpoint (links in JSON); fall back to meta → subscriptionUrl. candidates := []string{ base + "/api/subscriptions/by-short-uuid/" + url.PathEscape(short) + "/raw", base + "/api/sub/" + url.PathEscape(short) + "/raw", base + "/api/subscriptions/by-short-uuid/" + url.PathEscape(short), base + "/api/users/by-short-uuid/" + url.PathEscape(short), } var lastErr error for _, endpoint := range candidates { body, _, err := doGET(ctx, client, s, endpoint, true) if err != nil { lastErr = err continue } if res, err := parseRemnawaveBody(body); err == nil && res != nil && len(res.Items) > 0 { return res, endpoint, nil } if subURL := extractSubscriptionURL(body); subURL != "" { res, err := subscription.Fetch(ctx, subURL) if err == nil && res != nil && len(res.Items) > 0 { return res, subURL, nil } if err != nil { lastErr = err } } } if lastErr != nil { return nil, "", lastErr } return nil, "", fmt.Errorf("Remnawave API: не удалось получить конфиги") } func fetchAndParse(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) (*subscription.Result, error) { body, header, err := doGET(ctx, client, s, endpoint, auth) if err != nil { return nil, err } res, err := parseRemnawaveBody(body) if res != nil && res.Info == nil { res.Info = subscription.ParseUserInfoHeader(header.Get("Subscription-Userinfo")) } return res, err } func doGET(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) (string, http.Header, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return "", nil, err } req.Header.Set("User-Agent", "EvilFox/4.0 (Remnawave)") req.Header.Set("Accept", "*/*") if auth && s.Token != "" { req.Header.Set("Authorization", "Bearer "+s.Token) } if s.CaddyToken != "" { req.Header.Set("X-Api-Key", s.CaddyToken) } // Remnawave proxy-check middleware may reset plain HTTP / direct panel access // without forwarded headers (see remnawave proxy-check.middleware). if u, err := url.Parse(endpoint); err == nil && shouldSendProxyHeaders(u) { req.Header.Set("X-Forwarded-Proto", u.Scheme) req.Header.Set("X-Forwarded-For", "127.0.0.1") } resp, err := client.Do(req) if err != nil { return "", nil, fmt.Errorf("Remnawave запрос: %w", err) } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) if err != nil { return "", nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { msg := strings.TrimSpace(string(raw)) if len(msg) > 180 { msg = msg[:180] + "…" } return "", nil, fmt.Errorf("Remnawave HTTP %d: %s", resp.StatusCode, msg) } return string(raw), resp.Header, nil } func shouldSendProxyHeaders(u *url.URL) bool { if u == nil { return false } if strings.EqualFold(u.Scheme, "http") { return true } host := u.Hostname() if host == "localhost" || host == "127.0.0.1" || host == "::1" { return true } ip := net.ParseIP(host) return ip != nil && (ip.IsLoopback() || ip.IsPrivate()) } func parseRemnawaveBody(body string) (*subscription.Result, error) { body = strings.TrimSpace(body) if body == "" { return nil, fmt.Errorf("пустой ответ Remnawave") } // Try JSON envelope / raw DTO first, then classic subscription parse. if strings.HasPrefix(body, "{") || strings.HasPrefix(body, "[") { if res, err := subscription.ParseBodyDetailed(flattenJSONLinks(body)); err == nil && len(res.Items) > 0 { return res, nil } // Also try parsing the whole JSON as if links were string values only. if flat := extractLinkStrings(body); flat != "" { if res, err := subscription.ParseBodyDetailed(flat); err == nil && len(res.Items) > 0 { return res, nil } } } return subscription.ParseBodyDetailed(body) } func flattenJSONLinks(body string) string { links := extractLinkStrings(body) if links != "" { return links } return body } func extractLinkStrings(body string) string { var root any if err := json.Unmarshal([]byte(body), &root); err != nil { return "" } var lines []string walkJSON(root, &lines) return strings.Join(lines, "\n") } func walkJSON(v any, out *[]string) { switch t := v.(type) { case string: s := strings.TrimSpace(t) lower := strings.ToLower(s) if strings.Contains(lower, "://") || strings.HasPrefix(lower, "ss://") { *out = append(*out, s) } case []any: for _, el := range t { walkJSON(el, out) } case map[string]any: for _, el := range t { walkJSON(el, out) } } } func extractSubscriptionURL(body string) string { var root any if err := json.Unmarshal([]byte(body), &root); err != nil { return "" } // Unwrap {"response": ...} if m, ok := root.(map[string]any); ok { if resp, ok := m["response"]; ok { root = resp } } return findURLField(root, []string{ "subscriptionUrl", "subscription_url", "subscriptionURL", "url", }) } func findURLField(v any, keys []string) string { switch t := v.(type) { case map[string]any: for _, k := range keys { for mk, mv := range t { if strings.EqualFold(mk, k) { if s, ok := mv.(string); ok { s = strings.TrimSpace(s) if strings.HasPrefix(strings.ToLower(s), "http") { return s } } } } } for _, mv := range t { if u := findURLField(mv, keys); u != "" { return u } } case []any: for _, el := range t { if u := findURLField(el, keys); u != "" { return u } } } return "" }