Files
navi/internal/remnawave/provision.go
T

331 lines
11 KiB
Go

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 <admin API token> (+ 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")
}
// 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))
}
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", "Navis/3.10 (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"`
}