Metadata-Version: 2.5
Name: rw-sdk
Version: 3.3.2
Summary: Full Python SDK for the Remnawave panel API — every endpoint, typed, sync and async
Project-URL: Homepage, https://github.com/akenai-vpn/rw-sdk
Project-URL: Repository, https://github.com/akenai-vpn/rw-sdk
Project-URL: Issues, https://github.com/akenai-vpn/rw-sdk/issues
Project-URL: Remnawave backend, https://github.com/remnawave/backend
Author: Akenai
License-Expression: MIT
License-File: LICENSE
Keywords: api,client,proxy,remnawave,sdk,vpn,xray
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: niquests>=3.14
Requires-Dist: pydantic>=2.7
Provides-Extra: codegen
Requires-Dist: black>=24.0; extra == 'codegen'
Requires-Dist: datamodel-code-generator>=0.28; extra == 'codegen'
Provides-Extra: dev
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# rw-sdk

Full Python SDK for the [Remnawave](https://github.com/remnawave/backend) panel API.
Every endpoint, typed, sync and async.

```bash
pip install rw-sdk
```

```python
from rw_sdk import Remnawave

with Remnawave("https://panel.example.com", token="...") as rw:
    user = rw.users.create_user(
        username="alice",
        expire_at=datetime(2027, 1, 1, tzinfo=timezone.utc),
        traffic_limit_bytes=100 * 1024**3,
    )
    print(user.subscription_url, user.status)

    for u in rw.users.iter_users():          # pages fetched automatically
        print(u.username, u.user_traffic.used_traffic_bytes)
```

| | |
|---|---|
| API version | **3.3.2** — 205 operations across 28 resources, all implemented |
| Models | 649 generated classes (566 pydantic v2 models + 83 enums), named after Remnawave's own zod contract schemas |
| Async | every method mirrored on `AsyncRemnawave` |
| Verified | offline contract tests + live integration run against a real 3.3.2 panel |

---

## Authentication

Use an **API token** created on the panel's *API Tokens* page. That is the supported
mode for automation and what everything here is built around.

```python
rw = Remnawave("https://panel.example.com", token="eyJhbGci...")
```

### 11 endpoints an API token cannot reach

An API token always carries role `API`. Three controllers declare `@Roles(ROLE.ADMIN)`
without `ROLE.API`, so calling them with a token returns `403 E000` no matter its
scopes (3.0.0 answered `A004` here) — verified against a live 3.3.2 panel:

| Endpoints | Why |
|---|---|
| `rw.api_tokens.*` (4) | tokens are minted from the panel UI or an admin session |
| `rw.passkeys.*` (5) | passkey management is a dashboard flow |
| `rw.settings.*` (2) | `/api/remnawave-settings` is admin-only |

They are still generated — this SDK covers all 205 operations — and each one says so in
its docstring. `rw_sdk.ENDPOINTS` carries an `admin_only` flag if you want to check
programmatically.

Everything else (users, nodes, hosts, squads, config profiles, subscriptions, stats,
HWID, connections, infra billing, snippets, templates …) works with an API token.

### Admin JWT, if you need those 11

`auth.login` returns an admin-role JWT, which `JwtDefaultGuard` rejects unless the
request also carries `X-Remnawave-Client-Type: browser` ([`def-jwt-guard.ts`][guard]).
The SDK does **not** send that header — it targets API tokens. Supply it yourself if
you need the admin path:

```python
rw = Remnawave(
    "https://panel.example.com",
    headers={"X-Remnawave-Client-Type": "browser"},
)
rw.set_token(rw.auth.login(username="admin", password="...").access_token)
token = rw.api_tokens.create_api_token(name="ci", expires_in_days=90, scopes=["users:*"])
print(token.token)  # returned once
```

Without the header every call returns `403`. Per-endpoint API-token scopes are in each
method's docstring.

## The HTTPS/proxy requirement

In production Remnawave **destroys the socket** of any request that lacks
`x-forwarded-for` or does not carry `x-forwarded-proto: https`
([`proxy-check.middleware.ts`][proxy]) — you get a connection reset, not an HTTP error.

`proxy_headers="auto"` (the default) sends those headers only when the target looks
like a direct connection — plain `http://`, or a loopback/private host. Behind a real
reverse proxy the headers already exist, and forging `x-forwarded-for` there would
corrupt the panel's client-IP accounting, so they are left alone.

```python
Remnawave("http://127.0.0.1:3000", token=t)                        # headers sent
Remnawave("https://panel.example.com", token=t)                    # not sent
Remnawave("https://panel.example.com", token=t, proxy_headers="always")   # forced
```

A reset connection is surfaced as `ProxyRestrictionError` with the fix in the message.

## Pagination

Ten endpoints paginate (7 offset, 3 cursor). Each gets an iterator that handles the
offset or cursor for you:

```python
for user in rw.users.iter_users(page_size=500):        # offset: start/size/total
    ...

for user in rw.users.iter_users_stream(page_size=1000):    # cursor: nextCursor/hasMore
    ...

async for device in arw.hwid.iter_all_users():       # same on the async client
    ...
```

The underlying single-page calls (`rw.users.get_users(start=..., size=...)`) are still
there when you want them.

## Filtering and sorting

Four list endpoints take TanStack-table style filters. The panel reads each as one
JSON-encoded string (`JSON.parse`), and this SDK serialises them for you:

```python
from rw_sdk.models import TanstackQueryFilter, TanstackQuerySorting

page = rw.users.get_users(
    filters=[TanstackQueryFilter(id="username", value="alice")],
    filter_modes={"username": "startsWith"},
    sorting=[TanstackQuerySorting(id="createdAt", desc=True)],
)

# plain dicts work too, and iterators forward the filter
for user in rw.users.iter_users(filters=[{"id": "tag", "value": "VIP"}]):
    ...
```

Modes: `equals`, `startsWith`, `endsWith`, `greaterThan`, `greaterThanOrEqualTo`,
`lessThan`, `lessThanOrEqualTo`, `between`. Anything else falls through to a `contains`
match rather than erroring. A `sorting` id is passed straight to the query builder, so
an unknown column returns 500.

## Omitted vs null

Remnawave's PATCH endpoints distinguish an absent key ("leave unchanged") from an
explicit `null` ("clear this field"). Passing `None` means `null`; leaving an argument
out sends nothing.

```python
rw.users.update_user(id=u.id, description="new")   # tag untouched
rw.users.update_user(id=u.id, tag=None)            # tag cleared
```

## Raw subscriptions

`GET /api/sub/{shortUuid}` and `/api/sub/{shortUuid}/{clientType}` are written straight
to the socket by the panel — the body may be base64, YAML, JSON or an encrypted blob
depending on the matched response rule, and the useful metadata is in the headers.
They return a `RawSubscription` instead of a model:

```python
raw = rw.sub.get_subscription(user.short_uuid)
raw.text                      # decoded body
raw.user_info                 # {'upload': 0, 'download': 0, 'total': ..., 'expire': ...}
raw.headers["profile-title"]

from rw_sdk.models import ClientType
rw.sub.get_subscription_by_client_type(user.short_uuid, ClientType.SINGBOX).json()
```

## Errors

```python
from rw_sdk import errors

try:
    rw.users.get_user_by_id(user_id)
except errors.NotFoundError as e:
    print(e.status_code, e.error_code, e.error)   # 404 A019 ErrorCode.USER_NOT_FOUND
except errors.ValidationError as e:
    for issue in e.errors:                        # zod field-level failures
        print(issue["path"], issue["message"])
except errors.PermissionDeniedError:
    ...                                           # role or token scope too narrow
except errors.APIConnectionError:
    ...                                           # network, timeout, or proxy check
```

All 234 panel error codes are available as `errors.ErrorCode`. 5xx, 429 and connection
failures are retried twice with jittered backoff by default (`max_retries=`).

## Transport

Built on [niquests](https://github.com/jawah/niquests) — sync and async from one
library, HTTP/1.1, HTTP/2 and HTTP/3.

Measured against a real 2.8.1 panel, same workload, versus the same SDK on httpx:

| | httpx | niquests |
|---|---|---|
| one user by name | 1.74 ms | 1.68 ms |
| page of 300 users | 14.56 ms | 10.73 ms |
| 100 concurrent lookups | 176.6 ms | **42.7 ms** |

The concurrency gap is the one that matters — httpx's per-request overhead multiplies
under `asyncio.gather`, and raising its connection limits makes it worse, not better.
`benchmarks/http_clients.py` reproduces the comparison across httpx, urllib3, rnet,
curl_cffi, aiohttp and aiosonic on your own hardware and latency.

Pass your own session to tune it:

```python
import niquests
Remnawave(url, token=t, http_client=niquests.Session(base_url=url, pool_maxsize=100))
```

## Escape hatch

Newer panel than this SDK? Call anything directly — auth, retries, headers and error
mapping still apply:

```python
resp = rw.request("POST", "/api/some/new/endpoint", json={"foo": 1})
resp.json()
```

## Versioning

The SDK version tracks the panel API version it was generated from.

| Branch | Targets |
|---|---|
| `main` | latest released Remnawave |
| `2.8.1` | pinned to panel 2.8.1 |

```bash
pip install "rw-sdk==2.8.1.*"     # stay on the 2.8.1 API
```

The `X.Y.Z` part is the panel API version. A `.postN` suffix means an SDK-only fix
against that same API — `2.8.1` < `2.8.1.post1` < `2.8.2`.

`extra="allow"` on every model means a panel that *adds* fields will not break your
client; a panel that *removes* or retypes them can, which is what the pinned branches
are for.

## How this is built

The Remnawave OpenAPI spec is generated by nestjs-zod and inlines every schema — 289
components with zero `$ref`s between them. Point a stock generator at it and you get
either ~20 mutually-incompatible copies of the User entity (openapi-python-client) or
deduped-but-anonymous `Response19` classes (datamodel-code-generator). Neither unwraps
the `{"response": ...}` envelope every endpoint uses.

So the pipeline is three stages:

1. **`codegen/refify.py`** rewrites the spec into one with real `$ref`s, deduping shapes
   by structural fingerprint and naming them from Remnawave's own zod contract models
   (`libs/contract/models/*.schema.ts`, including `.extend()` / `.merge()` composition).
2. **datamodel-code-generator** turns that into pydantic v2 models. Model generation is
   a solved problem; we do not reimplement it.
3. **`codegen/generate.py`** emits the layer no generator provides — resource classes,
   envelope unwrapping, auto-paging iterators, raw-subscription handling, and scope docs
   read out of the contract sources.

Regenerate for a new panel release:

```bash
git clone --depth 1 --branch 3.3.2 https://github.com/remnawave/backend /tmp/rw
# download the OpenAPI document from the panel: Scalar UI at /api/backend-tools/scalar
# (docs are always on in 3.x; the JSON endpoint sits behind the panel's auth)
mv ~/Downloads/openapi.json specs/openapi-3.3.2.json
pip install "rw-sdk[codegen]"
python3 codegen/generate.py specs/openapi-3.3.2.json --backend /tmp/rw
pytest
```

### What the spec gets wrong

All 205 operations were cross-checked against the backend sources at tag `3.3.2` and
against a running panel. The 2.8.x-era dropouts (missing TanStack query parameters,
auth operations with no documented 2xx) are fixed upstream in 3.0.0. What remains, and
how it is handled:

- **The TanStack parameters' encoding is not expressible in OpenAPI.** The document now
  declares `filters`/`filterModes`/`sorting`, but the panel reads each as *one
  JSON-encoded string* (`z.preprocess(str => JSON.parse(str), ...)`), not as bracketed
  or repeated query values. `codegen/spec_patches.py` tags every structured query
  parameter and the client serialises them with `json.dumps`.
- **Regex `pattern`s are stamped on every uuid/date/email field.** Validating the
  panel's own output against them buys nothing and a string pattern cannot even apply
  to a parsed `datetime`, so `refify` drops them — same policy as `format: uuid`.
- **`templateJson`** is `z.nullable(z.unknown())` — optional in zod, but emitted as
  *required*, while the panel omits the key entirely. Trusting the spec makes
  `subscription_template.get_all_templates()` raise on a real panel. `z.unknown()`-shaped
  properties are dropped from `required`.
- **`type: number`** (zod `z.number()` without `.int()`) covers ids and byte counters.
  Mapped to `int | float` so an 8-byte traffic total stays exact instead of becoming
  a float.
- **A short page is not the last page.** `getAllSubscriptions` skips users whose
  subscription fails to build but still reports `total` as the user count, so a page can
  come back short — or empty — with rows remaining. The iterators advance by the
  requested page size and stop on `total`, never on a short page.
- **11 endpoints are unreachable with an API token** (`@Roles(ROLE.ADMIN)`); flagged in
  their docstrings and in `ENDPOINTS[...]["admin_only"]`.
- **The client accepts any 2xx** and maps exceptions by HTTP status, never by the
  documented response list — 3.0.0 cleaned the documented codes up (deletes are `204`,
  job starts are `202`), but nothing is gained by enforcing them client-side.
- **A 5xx carrying an `errorCode` is not retried.** The exception filters stamp that
  field only onto deliberate failures — config-profile (`A112`/`A061`) and settings
  (`A199`/`A193`) validation both surface as coded 500s — so retrying only delays the
  error. Uncoded 5xx, 429 and connection failures still retry.
- **The two raw subscription endpoints** are `@Res()` handlers with no schema; they are
  special-cased rather than typed as `Any`.

## Running against a local panel

```bash
git clone --depth 1 --branch 3.3.2 https://github.com/remnawave/backend /tmp/rw
cd /tmp/rw && cp .env.sample .env    # set APP_SECRET
docker compose -f docker-compose-prod.yml up -d

export RW_SDK_TEST_URL=http://127.0.0.1:3000
export RW_SDK_TEST_TOKEN=...          # an API token from the panel
export RW_SDK_TEST_ADMIN_TOKEN=...    # optional: admin JWT, unlocks the admin-only tests
pytest tests/test_live.py
```

The panel runs in production mode, so requests need the forwarded headers — the live
tests pass `proxy_headers="always"`.

## License

MIT. Remnawave itself is AGPL-3.0; this is an independent client, not affiliated with
the Remnawave project.

[guard]: https://github.com/remnawave/backend/blob/3.3.2/src/common/guards/jwt-guards/def-jwt-guard.ts
[proxy]: https://github.com/remnawave/backend/blob/3.3.2/src/common/middlewares/proxy-check.middleware.ts
