Release 3.9.0+1: Remnawave API configs, Remnawave-style UI with flags, Windows build.
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
// 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 <API token> 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.Item, 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 items, err := fetchAndParse(ctx, client, s, pub, false); err == nil && len(items) > 0 {
|
||||
return items, pub, nil
|
||||
} else if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// 2) With API token: resolve subscriptionUrl / raw payload.
|
||||
if s.Token != "" {
|
||||
if items, used, err := fetchWithToken(ctx, client, s); err == nil && len(items) > 0 {
|
||||
if used == "" {
|
||||
used = pub
|
||||
}
|
||||
return items, 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.Item, 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 items, err := parseRemnawaveBody(body); err == nil && len(items) > 0 {
|
||||
return items, endpoint, nil
|
||||
}
|
||||
if subURL := extractSubscriptionURL(body); subURL != "" {
|
||||
items, err := subscription.Fetch(ctx, subURL)
|
||||
if err == nil && len(items) > 0 {
|
||||
return items, 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.Item, error) {
|
||||
body, err := doGET(ctx, client, s, endpoint, auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseRemnawaveBody(body)
|
||||
}
|
||||
|
||||
func doGET(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Navis/3.9 (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 "", fmt.Errorf("Remnawave запрос: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if len(msg) > 180 {
|
||||
msg = msg[:180] + "…"
|
||||
}
|
||||
return "", fmt.Errorf("Remnawave HTTP %d: %s", resp.StatusCode, msg)
|
||||
}
|
||||
return string(raw), 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.Item, 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 items, err := subscription.ParseBody(flattenJSONLinks(body)); err == nil && len(items) > 0 {
|
||||
return items, nil
|
||||
}
|
||||
// Also try parsing the whole JSON as if links were string values only.
|
||||
if flat := extractLinkStrings(body); flat != "" {
|
||||
if items, err := subscription.ParseBody(flat); err == nil && len(items) > 0 {
|
||||
return items, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return subscription.ParseBody(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 ""
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package remnawave
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPublicSubURL(t *testing.T) {
|
||||
got := PublicSubURL("https://panel.example.com/api/", "abc12")
|
||||
want := "https://panel.example.com/api/sub/abc12"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInput(t *testing.T) {
|
||||
base, short, ok := ParseInput("https://vpn.example.com/api/sub/xyz789/clash")
|
||||
if !ok || base != "https://vpn.example.com" || short != "xyz789" {
|
||||
t.Fatalf("got base=%q short=%q ok=%v", base, short, ok)
|
||||
}
|
||||
_, _, ok = ParseInput("https://example.com/not-a-sub")
|
||||
if ok {
|
||||
t.Fatal("expected not ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSubscriptionURL(t *testing.T) {
|
||||
body := `{"response":{"subscriptionUrl":"https://vpn.example.com/api/sub/abc"}}`
|
||||
got := extractSubscriptionURL(body)
|
||||
if got != "https://vpn.example.com/api/sub/abc" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user