package remnawave // Provisioning: the app itself can issue access via the Remnawave admin API // (docs: https://docs.rw / TypeScript SDK commands): // // GET {base}/api/users/by-username/{username} (GetUserByUsernameCommand) // POST {base}/api/users (CreateUserCommand) // // Auth: Authorization: Bearer (+ optional Caddy X-Api-Key). // Credentials live in a separate config file configs/remnawave-api.json — // never committed; see configs/remnawave-api.example.json. import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "regexp" "strings" "time" ) // ProvisionOptions are plan defaults for newly created users. type ProvisionOptions struct { TrafficGB int64 `json:"traffic_gb"` // default 50 Days int `json:"days"` // default 30 Strategy string `json:"strategy"` // NO_RESET | DAY | WEEK | MONTH (default MONTH) HwidDeviceLimit int `json:"hwid_device_limit"` // 0 = unlimited } // AdminConfig is loaded from configs/remnawave-api.json (admin credentials). type AdminConfig struct { PanelURL string `json:"panel_url"` APIToken string `json:"api_token"` CaddyAPIKey string `json:"caddy_api_key,omitempty"` Provision ProvisionOptions `json:"provision"` } // AdminConfigPath returns the expected path of remnawave-api.json — // next to the main config.json (configs dir). func AdminConfigPath(cfgPath string) string { return filepath.Join(filepath.Dir(cfgPath), "remnawave-api.json") } // ResolveAdminConfig prefers the baked-in (embedded) panel credentials; // falls back to configs/remnawave-api.json for local/developer builds. func ResolveAdminConfig(cfgPath string) (*AdminConfig, error) { if cfg := EmbeddedAdminConfig(); cfg != nil { return cfg, nil } return LoadAdminConfig(AdminConfigPath(cfgPath)) } // LoadAdminConfig reads and validates the admin API config file. func LoadAdminConfig(path string) (*AdminConfig, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("нет файла %s (скопируйте remnawave-api.example.json и вставьте ключ)", filepath.Base(path)) } return parseAdminConfig(data) } // parseAdminConfig validates raw remnawave-api.json bytes (file or embedded). func parseAdminConfig(data []byte) (*AdminConfig, error) { var cfg AdminConfig if err := json.Unmarshal(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}), &cfg); err != nil { return nil, fmt.Errorf("remnawave-api.json: некорректный JSON: %w", err) } cfg.PanelURL = NormalizeBase(cfg.PanelURL) cfg.APIToken = strings.TrimSpace(cfg.APIToken) cfg.CaddyAPIKey = strings.TrimSpace(cfg.CaddyAPIKey) if cfg.PanelURL == "" || strings.Contains(cfg.PanelURL, "panel.example.com") { return nil, fmt.Errorf("remnawave-api.json: укажите panel_url") } if cfg.APIToken == "" || cfg.APIToken == "PASTE_API_TOKEN_HERE" { return nil, fmt.Errorf("remnawave-api.json: укажите api_token (панель → API Tokens)") } // Plan defaults: 50 GB / 30 days / MONTH reset. if cfg.Provision.TrafficGB <= 0 { cfg.Provision.TrafficGB = 50 } if cfg.Provision.Days <= 0 { cfg.Provision.Days = 30 } switch strings.ToUpper(strings.TrimSpace(cfg.Provision.Strategy)) { case "NO_RESET", "DAY", "WEEK", "MONTH": cfg.Provision.Strategy = strings.ToUpper(strings.TrimSpace(cfg.Provision.Strategy)) default: cfg.Provision.Strategy = "MONTH" } if cfg.Provision.HwidDeviceLimit < 0 { cfg.Provision.HwidDeviceLimit = 0 } return &cfg, nil } // ProvisionResult describes the created or found panel user. type ProvisionResult struct { Username string `json:"username"` UUID string `json:"uuid"` ShortUUID string `json:"short_uuid"` SubscriptionURL string `json:"subscription_url"` TrafficLimitBytes int64 `json:"traffic_limit_bytes"` ExpireAt int64 `json:"expire_at"` // unix seconds; 0 = unknown Created bool `json:"created"` } var reUsername = regexp.MustCompile(`^[a-zA-Z0-9_-]{6,34}$`) // NormalizeUsername sanitizes free-form input (telegram @handle, email, etc.) // into a Remnawave-compatible username: [a-zA-Z0-9_-], 6..34 chars. func NormalizeUsername(raw string) (string, error) { s := strings.TrimSpace(raw) s = strings.TrimPrefix(s, "@") if i := strings.IndexByte(s, '@'); i > 0 { s = s[:i] // email → local part } var b strings.Builder for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': b.WriteRune(r) case r == '.', r == ' ', r == '+': b.WriteByte('_') } } s = b.String() for len(s) > 0 && len(s) < 6 { s += "_vpn" // pad short handles ("ivan" → "ivan_vpn") } if len(s) > 34 { s = s[:34] } if !reUsername.MatchString(s) { return "", fmt.Errorf("имя пользователя: 6–34 символа, латиница/цифры/_/- (получилось %q)", s) } return s, nil } // ProvisionUser finds an existing panel user by username or creates one with // the configured plan (traffic_gb / days / strategy / hwid_device_limit), // then returns its subscription URL for import. func ProvisionUser(ctx context.Context, cfg *AdminConfig, rawUsername string) (*ProvisionResult, error) { if cfg == nil { return nil, fmt.Errorf("нет конфигурации remnawave-api") } username, err := NormalizeUsername(rawUsername) if err != nil { return nil, err } client := &http.Client{Timeout: 30 * time.Second} // 1) Existing user? getURL := cfg.PanelURL + "/api/users/by-username/" + url.PathEscape(username) status, body, err := adminDo(ctx, client, cfg, http.MethodGet, getURL, nil) if err != nil { return nil, err } switch { case status == http.StatusOK: res, err := parseProvisionUser(body) if err != nil { return nil, err } res.Username = username return res, nil case status == http.StatusNotFound: // fall through to create default: return nil, adminHTTPError(status, body) } // 2) Create with plan defaults. payload := map[string]any{ "username": username, "status": "ACTIVE", "trafficLimitBytes": cfg.Provision.TrafficGB * 1024 * 1024 * 1024, "trafficLimitStrategy": cfg.Provision.Strategy, "expireAt": time.Now().UTC().AddDate(0, 0, cfg.Provision.Days).Format(time.RFC3339), } if cfg.Provision.HwidDeviceLimit > 0 { payload["hwidDeviceLimit"] = cfg.Provision.HwidDeviceLimit } raw, _ := json.Marshal(payload) status, body, err = adminDo(ctx, client, cfg, http.MethodPost, cfg.PanelURL+"/api/users", raw) if err != nil { return nil, err } if status != http.StatusOK && status != http.StatusCreated { return nil, adminHTTPError(status, body) } res, err := parseProvisionUser(body) if err != nil { return nil, err } res.Username = username res.Created = true return res, nil } // RenewUser extends the SAME panel user: resets used traffic // (POST /api/users/{uuid}/actions/reset-traffic) and pushes the plan forward // via PATCH /api/users — trafficLimitBytes = plan (50 GB default) and // expireAt = max(now, current expiry) + plan days (30 default). // It never creates users. func RenewUser(ctx context.Context, cfg *AdminConfig, userUUID string, currentExpire int64) (*ProvisionResult, error) { if cfg == nil { return nil, fmt.Errorf("нет конфигурации remnawave-api") } userUUID = strings.TrimSpace(userUUID) if userUUID == "" { return nil, fmt.Errorf("нет UUID пользователя для продления") } client := &http.Client{Timeout: 30 * time.Second} // 1) Reset used traffic. Some panels return 404 for this action route — // tolerate it, the PATCH below still extends the plan. resetURL := cfg.PanelURL + "/api/users/" + url.PathEscape(userUUID) + "/actions/reset-traffic" status, body, err := adminDo(ctx, client, cfg, http.MethodPost, resetURL, nil) if err != nil { return nil, err } if status != http.StatusOK && status != http.StatusCreated && status != http.StatusNotFound { return nil, fmt.Errorf("сброс трафика: %w", adminHTTPError(status, body)) } // 2) Extend expiry + (re)set the traffic limit. base := time.Now().UTC() if currentExpire > 0 { if cur := time.Unix(currentExpire, 0).UTC(); cur.After(base) { base = cur } } payload := map[string]any{ "uuid": userUUID, "status": "ACTIVE", "trafficLimitBytes": cfg.Provision.TrafficGB * 1024 * 1024 * 1024, "expireAt": base.AddDate(0, 0, cfg.Provision.Days).Format(time.RFC3339), } raw, _ := json.Marshal(payload) status, body, err = adminDo(ctx, client, cfg, http.MethodPatch, cfg.PanelURL+"/api/users", raw) if err != nil { return nil, err } if status != http.StatusOK && status != http.StatusCreated { return nil, fmt.Errorf("продление: %w", adminHTTPError(status, body)) } res, err := parseProvisionUser(body) if err != nil { return nil, err } return res, nil } func adminDo(ctx context.Context, client *http.Client, cfg *AdminConfig, method, endpoint string, body []byte) (int, string, error) { var rd io.Reader if body != nil { rd = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, endpoint, rd) if err != nil { return 0, "", err } req.Header.Set("User-Agent", "EvilFox/4.0 (Remnawave provision)") req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Authorization", "Bearer "+cfg.APIToken) if cfg.CaddyAPIKey != "" { req.Header.Set("X-Api-Key", cfg.CaddyAPIKey) } 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 0, "", fmt.Errorf("Remnawave API недоступен: %w", err) } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return resp.StatusCode, "", err } return resp.StatusCode, string(raw), nil } func adminHTTPError(status int, body string) error { msg := strings.TrimSpace(body) if len(msg) > 160 { msg = msg[:160] + "…" } switch status { case http.StatusUnauthorized, http.StatusForbidden: return fmt.Errorf("Неверный API ключ (HTTP %d) — проверьте api_token в remnawave-api.json", status) case http.StatusConflict: return fmt.Errorf("Пользователь уже существует, но панель вернула конфликт (HTTP 409): %s", msg) case http.StatusBadRequest: return fmt.Errorf("Панель отклонила запрос (HTTP 400): %s", msg) default: return fmt.Errorf("Remnawave HTTP %d: %s", status, msg) } } func parseProvisionUser(body string) (*ProvisionResult, error) { var payload struct { Response provisionUserDTO `json:"response"` } if err := json.Unmarshal([]byte(body), &payload); err != nil || payload.Response.UUID == "" { // Some deployments return the DTO without the envelope. var direct provisionUserDTO if err2 := json.Unmarshal([]byte(body), &direct); err2 != nil || direct.UUID == "" { return nil, fmt.Errorf("Remnawave: не удалось разобрать ответ панели") } payload.Response = direct } u := payload.Response res := &ProvisionResult{ UUID: u.UUID, ShortUUID: u.ShortUUID, SubscriptionURL: strings.TrimSpace(u.SubscriptionURL), TrafficLimitBytes: u.TrafficLimitBytes, } if u.ExpireAt != "" { if t, err := time.Parse(time.RFC3339, u.ExpireAt); err == nil { res.ExpireAt = t.Unix() } } return res, nil } type provisionUserDTO struct { UUID string `json:"uuid"` ShortUUID string `json:"shortUuid"` SubscriptionURL string `json:"subscriptionUrl"` TrafficLimitBytes int64 `json:"trafficLimitBytes"` ExpireAt string `json:"expireAt"` }