rw-sdk 2.8.1.post0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. rw_sdk-2.8.1.post0/.gitignore +15 -0
  2. rw_sdk-2.8.1.post0/LICENSE +21 -0
  3. rw_sdk-2.8.1.post0/PKG-INFO +347 -0
  4. rw_sdk-2.8.1.post0/README.md +311 -0
  5. rw_sdk-2.8.1.post0/codegen/generate.py +825 -0
  6. rw_sdk-2.8.1.post0/codegen/naming.py +146 -0
  7. rw_sdk-2.8.1.post0/codegen/refify.py +391 -0
  8. rw_sdk-2.8.1.post0/codegen/spec_patches.py +178 -0
  9. rw_sdk-2.8.1.post0/codegen/version.py +38 -0
  10. rw_sdk-2.8.1.post0/codegen/zod_names.py +193 -0
  11. rw_sdk-2.8.1.post0/pyproject.toml +73 -0
  12. rw_sdk-2.8.1.post0/specs/openapi-2.8.1.json +48947 -0
  13. rw_sdk-2.8.1.post0/src/rw_sdk/__init__.py +60 -0
  14. rw_sdk-2.8.1.post0/src/rw_sdk/_error_codes.py +479 -0
  15. rw_sdk-2.8.1.post0/src/rw_sdk/_meta.py +2427 -0
  16. rw_sdk-2.8.1.post0/src/rw_sdk/_resource.py +137 -0
  17. rw_sdk-2.8.1.post0/src/rw_sdk/_resources_mixin.py +128 -0
  18. rw_sdk-2.8.1.post0/src/rw_sdk/_types.py +91 -0
  19. rw_sdk-2.8.1.post0/src/rw_sdk/_version.py +3 -0
  20. rw_sdk-2.8.1.post0/src/rw_sdk/client.py +350 -0
  21. rw_sdk-2.8.1.post0/src/rw_sdk/errors.py +185 -0
  22. rw_sdk-2.8.1.post0/src/rw_sdk/models.py +6135 -0
  23. rw_sdk-2.8.1.post0/src/rw_sdk/py.typed +0 -0
  24. rw_sdk-2.8.1.post0/src/rw_sdk/resources/__init__.py +89 -0
  25. rw_sdk-2.8.1.post0/src/rw_sdk/resources/api_tokens.py +187 -0
  26. rw_sdk-2.8.1.post0/src/rw_sdk/resources/auth.py +236 -0
  27. rw_sdk-2.8.1.post0/src/rw_sdk/resources/bandwidth_stats.py +354 -0
  28. rw_sdk-2.8.1.post0/src/rw_sdk/resources/config_profiles.py +285 -0
  29. rw_sdk-2.8.1.post0/src/rw_sdk/resources/external_squads.py +286 -0
  30. rw_sdk-2.8.1.post0/src/rw_sdk/resources/hosts.py +501 -0
  31. rw_sdk-2.8.1.post0/src/rw_sdk/resources/hosts_bulk.py +280 -0
  32. rw_sdk-2.8.1.post0/src/rw_sdk/resources/hwid.py +410 -0
  33. rw_sdk-2.8.1.post0/src/rw_sdk/resources/infra_billing.py +500 -0
  34. rw_sdk-2.8.1.post0/src/rw_sdk/resources/internal_squads.py +290 -0
  35. rw_sdk-2.8.1.post0/src/rw_sdk/resources/ip_control.py +186 -0
  36. rw_sdk-2.8.1.post0/src/rw_sdk/resources/keygen.py +43 -0
  37. rw_sdk-2.8.1.post0/src/rw_sdk/resources/metadata.py +155 -0
  38. rw_sdk-2.8.1.post0/src/rw_sdk/resources/node_plugins.py +468 -0
  39. rw_sdk-2.8.1.post0/src/rw_sdk/resources/nodes.py +627 -0
  40. rw_sdk-2.8.1.post0/src/rw_sdk/resources/passkeys.py +212 -0
  41. rw_sdk-2.8.1.post0/src/rw_sdk/resources/settings.py +117 -0
  42. rw_sdk-2.8.1.post0/src/rw_sdk/resources/snippets.py +137 -0
  43. rw_sdk-2.8.1.post0/src/rw_sdk/resources/sub.py +102 -0
  44. rw_sdk-2.8.1.post0/src/rw_sdk/resources/subscription_page_configs.py +242 -0
  45. rw_sdk-2.8.1.post0/src/rw_sdk/resources/subscription_request_history.py +168 -0
  46. rw_sdk-2.8.1.post0/src/rw_sdk/resources/subscription_settings.py +136 -0
  47. rw_sdk-2.8.1.post0/src/rw_sdk/resources/subscription_template.py +229 -0
  48. rw_sdk-2.8.1.post0/src/rw_sdk/resources/subscriptions.py +319 -0
  49. rw_sdk-2.8.1.post0/src/rw_sdk/resources/system.py +278 -0
  50. rw_sdk-2.8.1.post0/src/rw_sdk/resources/users.py +1032 -0
  51. rw_sdk-2.8.1.post0/src/rw_sdk/resources/users_bulk.py +405 -0
  52. rw_sdk-2.8.1.post0/tests/test_contract.py +394 -0
  53. rw_sdk-2.8.1.post0/tests/test_live.py +351 -0
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ *.refs.json
14
+ .env
15
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Akenai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,347 @@
1
+ Metadata-Version: 2.4
2
+ Name: rw-sdk
3
+ Version: 2.8.1.post0
4
+ Summary: Full Python SDK for the Remnawave panel API — every endpoint, typed, sync and async
5
+ Project-URL: Homepage, https://github.com/akenai-vpn/rw-sdk
6
+ Project-URL: Repository, https://github.com/akenai-vpn/rw-sdk
7
+ Project-URL: Issues, https://github.com/akenai-vpn/rw-sdk/issues
8
+ Project-URL: Remnawave backend, https://github.com/remnawave/backend
9
+ Author: Akenai
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api,client,proxy,remnawave,sdk,vpn,xray
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: pydantic>=2.7
26
+ Provides-Extra: codegen
27
+ Requires-Dist: black>=24.0; extra == 'codegen'
28
+ Requires-Dist: datamodel-code-generator>=0.28; extra == 'codegen'
29
+ Provides-Extra: dev
30
+ Requires-Dist: mypy>=1.11; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
32
+ Requires-Dist: pytest>=8; extra == 'dev'
33
+ Requires-Dist: respx>=0.21; extra == 'dev'
34
+ Requires-Dist: ruff>=0.6; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # rw-sdk
38
+
39
+ Full Python SDK for the [Remnawave](https://github.com/remnawave/backend) panel API.
40
+ Every endpoint, typed, sync and async.
41
+
42
+ ```bash
43
+ pip install rw-sdk
44
+ ```
45
+
46
+ ```python
47
+ from rw_sdk import Remnawave
48
+
49
+ with Remnawave("https://panel.example.com", token="...") as rw:
50
+ user = rw.users.create(
51
+ username="alice",
52
+ expire_at=datetime(2027, 1, 1, tzinfo=timezone.utc),
53
+ traffic_limit_bytes=100 * 1024**3,
54
+ )
55
+ print(user.subscription_url, user.status)
56
+
57
+ for u in rw.users.iter_all(): # pages fetched automatically
58
+ print(u.username, u.user_traffic.used_traffic_bytes)
59
+ ```
60
+
61
+ | | |
62
+ |---|---|
63
+ | API version | **2.8.1** — 186 operations across 27 resources, all implemented |
64
+ | Models | 580 pydantic v2 models, named after Remnawave's own zod contract schemas |
65
+ | Async | every method mirrored on `AsyncRemnawave` |
66
+ | Verified | offline contract tests + live integration run against a real 2.8.1 panel |
67
+
68
+ ---
69
+
70
+ ## Authentication
71
+
72
+ Use an **API token** created on the panel's *API Tokens* page. That is the supported
73
+ mode for automation and what everything here is built around.
74
+
75
+ ```python
76
+ rw = Remnawave("https://panel.example.com", token="eyJhbGci...")
77
+ ```
78
+
79
+ ### 11 endpoints an API token cannot reach
80
+
81
+ An API token always carries role `API`. Three controllers declare `@Roles(ROLE.ADMIN)`
82
+ without `ROLE.API`, so calling them with a token returns `403 A004` no matter its
83
+ scopes — verified against a live 2.8.1 panel:
84
+
85
+ | Endpoints | Why |
86
+ |---|---|
87
+ | `rw.api_tokens.*` (4) | tokens are minted from the panel UI or an admin session |
88
+ | `rw.passkeys.*` (5) | passkey management is a dashboard flow |
89
+ | `rw.settings.*` (2) | `/api/remnawave-settings` is admin-only |
90
+
91
+ They are still generated — this SDK covers all 186 operations — and each one says so in
92
+ its docstring. `rw_sdk.ENDPOINTS` carries an `admin_only` flag if you want to check
93
+ programmatically.
94
+
95
+ Everything else (users, nodes, hosts, squads, config profiles, subscriptions, stats,
96
+ HWID, IP control, infra billing, snippets, templates …) works with an API token.
97
+
98
+ ### Admin JWT, if you need those 11
99
+
100
+ `auth.login` returns an admin-role JWT, which `JwtDefaultGuard` rejects unless the
101
+ request also carries `X-Remnawave-Client-Type: browser` ([`def-jwt-guard.ts`][guard]).
102
+ The SDK does **not** send that header — it targets API tokens. Supply it yourself if
103
+ you need the admin path:
104
+
105
+ ```python
106
+ rw = Remnawave(
107
+ "https://panel.example.com",
108
+ headers={"X-Remnawave-Client-Type": "browser"},
109
+ )
110
+ rw.set_token(rw.auth.login(username="admin", password="...").access_token)
111
+ token = rw.api_tokens.create(name="ci", expires_in_days=90, scopes=["users:*"])
112
+ print(token.token) # returned once
113
+ ```
114
+
115
+ Without the header every call returns `403`. Per-endpoint API-token scopes are in each
116
+ method's docstring.
117
+
118
+ ## The HTTPS/proxy requirement
119
+
120
+ In production Remnawave **destroys the socket** of any request that lacks
121
+ `x-forwarded-for` or does not carry `x-forwarded-proto: https`
122
+ ([`proxy-check.middleware.ts`][proxy]) — you get a connection reset, not an HTTP error.
123
+
124
+ `proxy_headers="auto"` (the default) sends those headers only when the target looks
125
+ like a direct connection — plain `http://`, or a loopback/private host. Behind a real
126
+ reverse proxy the headers already exist, and forging `x-forwarded-for` there would
127
+ corrupt the panel's client-IP accounting, so they are left alone.
128
+
129
+ ```python
130
+ Remnawave("http://127.0.0.1:3000", token=t) # headers sent
131
+ Remnawave("https://panel.example.com", token=t) # not sent
132
+ Remnawave("https://panel.example.com", token=t, proxy_headers="always") # forced
133
+ ```
134
+
135
+ A reset connection is surfaced as `ProxyRestrictionError` with the fix in the message.
136
+
137
+ ## Pagination
138
+
139
+ Seven endpoints paginate. Each gets an iterator that handles the offset or cursor for
140
+ you:
141
+
142
+ ```python
143
+ for user in rw.users.iter_all(page_size=500): # offset: start/size/total
144
+ ...
145
+
146
+ for user in rw.users.iter_stream(page_size=1000): # cursor: nextCursor/hasMore
147
+ ...
148
+
149
+ async for device in arw.hwid.iter_all(): # same on the async client
150
+ ...
151
+ ```
152
+
153
+ The underlying single-page calls (`rw.users.get_all(start=..., size=...)`) are still
154
+ there when you want them.
155
+
156
+ ## Filtering and sorting
157
+
158
+ Four list endpoints take TanStack-table style filters. None of them appear in the
159
+ OpenAPI document — the panel reads them with `JSON.parse`, and this SDK serialises them
160
+ for you:
161
+
162
+ ```python
163
+ from rw_sdk.models import TanstackFilter, TanstackSorting
164
+
165
+ page = rw.users.get_all(
166
+ filters=[TanstackFilter(id="username", value="alice")],
167
+ filter_modes={"username": "startsWith"},
168
+ sorting=[TanstackSorting(id="createdAt", desc=True)],
169
+ )
170
+
171
+ # plain dicts work too, and iterators forward the filter
172
+ for user in rw.users.iter_all(filters=[{"id": "tag", "value": "VIP"}]):
173
+ ...
174
+ ```
175
+
176
+ Modes: `equals`, `startsWith`, `endsWith`, `greaterThan`, `greaterThanOrEqualTo`,
177
+ `lessThan`, `lessThanOrEqualTo`, `between`. Anything else falls through to a `contains`
178
+ match rather than erroring. A `sorting` id is passed straight to the query builder, so
179
+ an unknown column returns 500.
180
+
181
+ ## Omitted vs null
182
+
183
+ Remnawave's PATCH endpoints distinguish an absent key ("leave unchanged") from an
184
+ explicit `null` ("clear this field"). Passing `None` means `null`; leaving an argument
185
+ out sends nothing.
186
+
187
+ ```python
188
+ rw.users.update(uuid=u.uuid, description="new") # tag untouched
189
+ rw.users.update(uuid=u.uuid, tag=None) # tag cleared
190
+ ```
191
+
192
+ ## Raw subscriptions
193
+
194
+ `GET /api/sub/{shortUuid}` and `/api/sub/{shortUuid}/{clientType}` are written straight
195
+ to the socket by the panel — the body may be base64, YAML, JSON or an encrypted blob
196
+ depending on the matched response rule, and the useful metadata is in the headers.
197
+ They return a `RawSubscription` instead of a model:
198
+
199
+ ```python
200
+ raw = rw.sub.get_raw(user.short_uuid)
201
+ raw.text # decoded body
202
+ raw.user_info # {'upload': 0, 'download': 0, 'total': ..., 'expire': ...}
203
+ raw.headers["profile-title"]
204
+
205
+ from rw_sdk.models import ClientType
206
+ rw.sub.get_by_client_type(user.short_uuid, ClientType.SINGBOX).json()
207
+ ```
208
+
209
+ ## Errors
210
+
211
+ ```python
212
+ from rw_sdk import errors
213
+
214
+ try:
215
+ rw.users.get_by_uuid(uuid)
216
+ except errors.NotFoundError as e:
217
+ print(e.status_code, e.error_code, e.error) # 404 A019 ErrorCode.USER_NOT_FOUND
218
+ except errors.ValidationError as e:
219
+ for issue in e.errors: # zod field-level failures
220
+ print(issue["path"], issue["message"])
221
+ except errors.PermissionDeniedError:
222
+ ... # role or token scope too narrow
223
+ except errors.APIConnectionError:
224
+ ... # network, timeout, or proxy check
225
+ ```
226
+
227
+ All 236 panel error codes are available as `errors.ErrorCode`. 5xx, 429 and connection
228
+ failures are retried twice with jittered backoff by default (`max_retries=`).
229
+
230
+ ## Escape hatch
231
+
232
+ Newer panel than this SDK? Call anything directly — auth, retries, headers and error
233
+ mapping still apply:
234
+
235
+ ```python
236
+ resp = rw.request("POST", "/api/some/new/endpoint", json={"foo": 1})
237
+ resp.json()
238
+ ```
239
+
240
+ ## Versioning
241
+
242
+ The SDK version tracks the panel API version it was generated from.
243
+
244
+ | Branch | Targets |
245
+ |---|---|
246
+ | `main` | latest released Remnawave |
247
+ | `2.8.1` | pinned to panel 2.8.1 |
248
+
249
+ ```bash
250
+ pip install "rw-sdk==2.8.1.*" # stay on the 2.8.1 API
251
+ ```
252
+
253
+ `extra="allow"` on every model means a panel that *adds* fields will not break your
254
+ client; a panel that *removes* or retypes them can, which is what the pinned branches
255
+ are for.
256
+
257
+ ## How this is built
258
+
259
+ The Remnawave OpenAPI spec is generated by nestjs-zod and inlines every schema — 268
260
+ components with zero `$ref`s between them. Point a stock generator at it and you get
261
+ either ~20 mutually-incompatible copies of the User entity (openapi-python-client) or
262
+ deduped-but-anonymous `Response19` classes (datamodel-code-generator). Neither unwraps
263
+ the `{"response": ...}` envelope every endpoint uses.
264
+
265
+ So the pipeline is three stages:
266
+
267
+ 1. **`codegen/refify.py`** rewrites the spec into one with real `$ref`s, deduping shapes
268
+ by structural fingerprint and naming them from Remnawave's own zod contract models
269
+ (`libs/contract/models/*.schema.ts`, including `.extend()` / `.merge()` composition).
270
+ 2. **datamodel-code-generator** turns that into pydantic v2 models. Model generation is
271
+ a solved problem; we do not reimplement it.
272
+ 3. **`codegen/generate.py`** emits the layer no generator provides — resource classes,
273
+ envelope unwrapping, auto-paging iterators, raw-subscription handling, and scope docs
274
+ read out of the contract sources.
275
+
276
+ Regenerate for a new panel release:
277
+
278
+ ```bash
279
+ git clone --depth 1 --branch 2.9.0 https://github.com/remnawave/backend /tmp/rw
280
+ curl -o specs/openapi-2.9.0.json https://panel.example.com/docs-json # IS_DOCS_ENABLED=true
281
+ pip install "rw-sdk[codegen]"
282
+ python3 codegen/generate.py specs/openapi-2.9.0.json --backend /tmp/rw
283
+ pytest
284
+ ```
285
+
286
+ ### What the spec gets wrong
287
+
288
+ All 186 operations were cross-checked line-by-line against the backend sources at tag
289
+ `2.8.1` and against a running panel. What that turned up, and how it is handled:
290
+
291
+ - **22 query parameters are missing from the document.** A hand-written `@ApiQuery`
292
+ decorator replaces the zod-derived parameter list wholesale, so controllers that
293
+ document `start`/`size` silently drop everything else. Six operations were affected —
294
+ four lose the whole TanStack filter/sort set, `/api/infra-billing/history` loses its
295
+ pagination, `/api/system/stats/bandwidth` loses `tz`. `codegen/spec_patches.py` puts
296
+ them back, deriving the TanStack ones from which commands import
297
+ `TanstackQueryRequestQuerySchema` rather than from a hardcoded list.
298
+ - **12 auth/passkey operations** document no 2xx response at all (no `@ApiResponse(200)`
299
+ decorator), so a spec-only generator types them `Any`. Their return types are read from
300
+ the NestJS handlers' `Promise<XxxResponseDto>` declarations instead.
301
+ - **`templateJson`** is `z.nullable(z.unknown())` — optional in zod, but emitted as
302
+ *required*, while the panel omits the key entirely. Trusting the spec makes
303
+ `subscription_template.get_all()` raise on a real panel. `z.unknown()`-shaped
304
+ properties are dropped from `required`.
305
+ - **`type: number`** (zod `z.number()` without `.int()`) covers ids and byte counters.
306
+ Mapped to `int | float` so an 8-byte traffic total stays exact instead of becoming
307
+ a float.
308
+ - **A short page is not the last page.** `getAllSubscriptions` skips users whose
309
+ subscription fails to build but still reports `total` as the user count, so a page can
310
+ come back short — or empty — with rows remaining. The iterators advance by the
311
+ requested page size and stop on `total`, never on a short page.
312
+ - **11 endpoints are unreachable with an API token** (`@Roles(ROLE.ADMIN)`); flagged in
313
+ their docstrings and in `ENDPOINTS[...]["admin_only"]`.
314
+ - **Documented status codes are unreliable** — several POSTs documented as `200` really
315
+ return `201`, and some "not found" cases arrive as `500` with an `errorCode`. The
316
+ client accepts any 2xx and maps exceptions by HTTP status, never by the documented
317
+ response list.
318
+ - **A 5xx carrying an `errorCode` is not retried.** `HttpExceptionFilter` stamps that
319
+ field only onto deliberate failures — config-profile (`A112`/`A061`) and settings
320
+ (`A199`/`A193`) validation both surface as coded 500s — so retrying only delays the
321
+ error. Uncoded 5xx, 429 and connection failures still retry.
322
+ - **The two raw subscription endpoints** are `@Res()` handlers with no schema; they are
323
+ special-cased rather than typed as `Any`.
324
+
325
+ ## Running against a local panel
326
+
327
+ ```bash
328
+ git clone --depth 1 --branch 2.8.1 https://github.com/remnawave/backend /tmp/rw
329
+ cd /tmp/rw && cp .env.sample .env # set APP_SECRET, IS_DOCS_ENABLED=true
330
+ docker compose -f docker-compose-prod.yml up -d
331
+
332
+ export RW_SDK_TEST_URL=http://127.0.0.1:3000
333
+ export RW_SDK_TEST_TOKEN=... # an API token from the panel
334
+ export RW_SDK_TEST_ADMIN_TOKEN=... # optional: admin JWT, unlocks the admin-only tests
335
+ pytest tests/test_live.py
336
+ ```
337
+
338
+ The panel runs in production mode, so requests need the forwarded headers — the live
339
+ tests pass `proxy_headers="always"`.
340
+
341
+ ## License
342
+
343
+ MIT. Remnawave itself is AGPL-3.0; this is an independent client, not affiliated with
344
+ the Remnawave project.
345
+
346
+ [guard]: https://github.com/remnawave/backend/blob/2.8.1/src/common/guards/jwt-guards/def-jwt-guard.ts
347
+ [proxy]: https://github.com/remnawave/backend/blob/2.8.1/src/common/middlewares/proxy-check.middleware.ts
@@ -0,0 +1,311 @@
1
+ # rw-sdk
2
+
3
+ Full Python SDK for the [Remnawave](https://github.com/remnawave/backend) panel API.
4
+ Every endpoint, typed, sync and async.
5
+
6
+ ```bash
7
+ pip install rw-sdk
8
+ ```
9
+
10
+ ```python
11
+ from rw_sdk import Remnawave
12
+
13
+ with Remnawave("https://panel.example.com", token="...") as rw:
14
+ user = rw.users.create(
15
+ username="alice",
16
+ expire_at=datetime(2027, 1, 1, tzinfo=timezone.utc),
17
+ traffic_limit_bytes=100 * 1024**3,
18
+ )
19
+ print(user.subscription_url, user.status)
20
+
21
+ for u in rw.users.iter_all(): # pages fetched automatically
22
+ print(u.username, u.user_traffic.used_traffic_bytes)
23
+ ```
24
+
25
+ | | |
26
+ |---|---|
27
+ | API version | **2.8.1** — 186 operations across 27 resources, all implemented |
28
+ | Models | 580 pydantic v2 models, named after Remnawave's own zod contract schemas |
29
+ | Async | every method mirrored on `AsyncRemnawave` |
30
+ | Verified | offline contract tests + live integration run against a real 2.8.1 panel |
31
+
32
+ ---
33
+
34
+ ## Authentication
35
+
36
+ Use an **API token** created on the panel's *API Tokens* page. That is the supported
37
+ mode for automation and what everything here is built around.
38
+
39
+ ```python
40
+ rw = Remnawave("https://panel.example.com", token="eyJhbGci...")
41
+ ```
42
+
43
+ ### 11 endpoints an API token cannot reach
44
+
45
+ An API token always carries role `API`. Three controllers declare `@Roles(ROLE.ADMIN)`
46
+ without `ROLE.API`, so calling them with a token returns `403 A004` no matter its
47
+ scopes — verified against a live 2.8.1 panel:
48
+
49
+ | Endpoints | Why |
50
+ |---|---|
51
+ | `rw.api_tokens.*` (4) | tokens are minted from the panel UI or an admin session |
52
+ | `rw.passkeys.*` (5) | passkey management is a dashboard flow |
53
+ | `rw.settings.*` (2) | `/api/remnawave-settings` is admin-only |
54
+
55
+ They are still generated — this SDK covers all 186 operations — and each one says so in
56
+ its docstring. `rw_sdk.ENDPOINTS` carries an `admin_only` flag if you want to check
57
+ programmatically.
58
+
59
+ Everything else (users, nodes, hosts, squads, config profiles, subscriptions, stats,
60
+ HWID, IP control, infra billing, snippets, templates …) works with an API token.
61
+
62
+ ### Admin JWT, if you need those 11
63
+
64
+ `auth.login` returns an admin-role JWT, which `JwtDefaultGuard` rejects unless the
65
+ request also carries `X-Remnawave-Client-Type: browser` ([`def-jwt-guard.ts`][guard]).
66
+ The SDK does **not** send that header — it targets API tokens. Supply it yourself if
67
+ you need the admin path:
68
+
69
+ ```python
70
+ rw = Remnawave(
71
+ "https://panel.example.com",
72
+ headers={"X-Remnawave-Client-Type": "browser"},
73
+ )
74
+ rw.set_token(rw.auth.login(username="admin", password="...").access_token)
75
+ token = rw.api_tokens.create(name="ci", expires_in_days=90, scopes=["users:*"])
76
+ print(token.token) # returned once
77
+ ```
78
+
79
+ Without the header every call returns `403`. Per-endpoint API-token scopes are in each
80
+ method's docstring.
81
+
82
+ ## The HTTPS/proxy requirement
83
+
84
+ In production Remnawave **destroys the socket** of any request that lacks
85
+ `x-forwarded-for` or does not carry `x-forwarded-proto: https`
86
+ ([`proxy-check.middleware.ts`][proxy]) — you get a connection reset, not an HTTP error.
87
+
88
+ `proxy_headers="auto"` (the default) sends those headers only when the target looks
89
+ like a direct connection — plain `http://`, or a loopback/private host. Behind a real
90
+ reverse proxy the headers already exist, and forging `x-forwarded-for` there would
91
+ corrupt the panel's client-IP accounting, so they are left alone.
92
+
93
+ ```python
94
+ Remnawave("http://127.0.0.1:3000", token=t) # headers sent
95
+ Remnawave("https://panel.example.com", token=t) # not sent
96
+ Remnawave("https://panel.example.com", token=t, proxy_headers="always") # forced
97
+ ```
98
+
99
+ A reset connection is surfaced as `ProxyRestrictionError` with the fix in the message.
100
+
101
+ ## Pagination
102
+
103
+ Seven endpoints paginate. Each gets an iterator that handles the offset or cursor for
104
+ you:
105
+
106
+ ```python
107
+ for user in rw.users.iter_all(page_size=500): # offset: start/size/total
108
+ ...
109
+
110
+ for user in rw.users.iter_stream(page_size=1000): # cursor: nextCursor/hasMore
111
+ ...
112
+
113
+ async for device in arw.hwid.iter_all(): # same on the async client
114
+ ...
115
+ ```
116
+
117
+ The underlying single-page calls (`rw.users.get_all(start=..., size=...)`) are still
118
+ there when you want them.
119
+
120
+ ## Filtering and sorting
121
+
122
+ Four list endpoints take TanStack-table style filters. None of them appear in the
123
+ OpenAPI document — the panel reads them with `JSON.parse`, and this SDK serialises them
124
+ for you:
125
+
126
+ ```python
127
+ from rw_sdk.models import TanstackFilter, TanstackSorting
128
+
129
+ page = rw.users.get_all(
130
+ filters=[TanstackFilter(id="username", value="alice")],
131
+ filter_modes={"username": "startsWith"},
132
+ sorting=[TanstackSorting(id="createdAt", desc=True)],
133
+ )
134
+
135
+ # plain dicts work too, and iterators forward the filter
136
+ for user in rw.users.iter_all(filters=[{"id": "tag", "value": "VIP"}]):
137
+ ...
138
+ ```
139
+
140
+ Modes: `equals`, `startsWith`, `endsWith`, `greaterThan`, `greaterThanOrEqualTo`,
141
+ `lessThan`, `lessThanOrEqualTo`, `between`. Anything else falls through to a `contains`
142
+ match rather than erroring. A `sorting` id is passed straight to the query builder, so
143
+ an unknown column returns 500.
144
+
145
+ ## Omitted vs null
146
+
147
+ Remnawave's PATCH endpoints distinguish an absent key ("leave unchanged") from an
148
+ explicit `null` ("clear this field"). Passing `None` means `null`; leaving an argument
149
+ out sends nothing.
150
+
151
+ ```python
152
+ rw.users.update(uuid=u.uuid, description="new") # tag untouched
153
+ rw.users.update(uuid=u.uuid, tag=None) # tag cleared
154
+ ```
155
+
156
+ ## Raw subscriptions
157
+
158
+ `GET /api/sub/{shortUuid}` and `/api/sub/{shortUuid}/{clientType}` are written straight
159
+ to the socket by the panel — the body may be base64, YAML, JSON or an encrypted blob
160
+ depending on the matched response rule, and the useful metadata is in the headers.
161
+ They return a `RawSubscription` instead of a model:
162
+
163
+ ```python
164
+ raw = rw.sub.get_raw(user.short_uuid)
165
+ raw.text # decoded body
166
+ raw.user_info # {'upload': 0, 'download': 0, 'total': ..., 'expire': ...}
167
+ raw.headers["profile-title"]
168
+
169
+ from rw_sdk.models import ClientType
170
+ rw.sub.get_by_client_type(user.short_uuid, ClientType.SINGBOX).json()
171
+ ```
172
+
173
+ ## Errors
174
+
175
+ ```python
176
+ from rw_sdk import errors
177
+
178
+ try:
179
+ rw.users.get_by_uuid(uuid)
180
+ except errors.NotFoundError as e:
181
+ print(e.status_code, e.error_code, e.error) # 404 A019 ErrorCode.USER_NOT_FOUND
182
+ except errors.ValidationError as e:
183
+ for issue in e.errors: # zod field-level failures
184
+ print(issue["path"], issue["message"])
185
+ except errors.PermissionDeniedError:
186
+ ... # role or token scope too narrow
187
+ except errors.APIConnectionError:
188
+ ... # network, timeout, or proxy check
189
+ ```
190
+
191
+ All 236 panel error codes are available as `errors.ErrorCode`. 5xx, 429 and connection
192
+ failures are retried twice with jittered backoff by default (`max_retries=`).
193
+
194
+ ## Escape hatch
195
+
196
+ Newer panel than this SDK? Call anything directly — auth, retries, headers and error
197
+ mapping still apply:
198
+
199
+ ```python
200
+ resp = rw.request("POST", "/api/some/new/endpoint", json={"foo": 1})
201
+ resp.json()
202
+ ```
203
+
204
+ ## Versioning
205
+
206
+ The SDK version tracks the panel API version it was generated from.
207
+
208
+ | Branch | Targets |
209
+ |---|---|
210
+ | `main` | latest released Remnawave |
211
+ | `2.8.1` | pinned to panel 2.8.1 |
212
+
213
+ ```bash
214
+ pip install "rw-sdk==2.8.1.*" # stay on the 2.8.1 API
215
+ ```
216
+
217
+ `extra="allow"` on every model means a panel that *adds* fields will not break your
218
+ client; a panel that *removes* or retypes them can, which is what the pinned branches
219
+ are for.
220
+
221
+ ## How this is built
222
+
223
+ The Remnawave OpenAPI spec is generated by nestjs-zod and inlines every schema — 268
224
+ components with zero `$ref`s between them. Point a stock generator at it and you get
225
+ either ~20 mutually-incompatible copies of the User entity (openapi-python-client) or
226
+ deduped-but-anonymous `Response19` classes (datamodel-code-generator). Neither unwraps
227
+ the `{"response": ...}` envelope every endpoint uses.
228
+
229
+ So the pipeline is three stages:
230
+
231
+ 1. **`codegen/refify.py`** rewrites the spec into one with real `$ref`s, deduping shapes
232
+ by structural fingerprint and naming them from Remnawave's own zod contract models
233
+ (`libs/contract/models/*.schema.ts`, including `.extend()` / `.merge()` composition).
234
+ 2. **datamodel-code-generator** turns that into pydantic v2 models. Model generation is
235
+ a solved problem; we do not reimplement it.
236
+ 3. **`codegen/generate.py`** emits the layer no generator provides — resource classes,
237
+ envelope unwrapping, auto-paging iterators, raw-subscription handling, and scope docs
238
+ read out of the contract sources.
239
+
240
+ Regenerate for a new panel release:
241
+
242
+ ```bash
243
+ git clone --depth 1 --branch 2.9.0 https://github.com/remnawave/backend /tmp/rw
244
+ curl -o specs/openapi-2.9.0.json https://panel.example.com/docs-json # IS_DOCS_ENABLED=true
245
+ pip install "rw-sdk[codegen]"
246
+ python3 codegen/generate.py specs/openapi-2.9.0.json --backend /tmp/rw
247
+ pytest
248
+ ```
249
+
250
+ ### What the spec gets wrong
251
+
252
+ All 186 operations were cross-checked line-by-line against the backend sources at tag
253
+ `2.8.1` and against a running panel. What that turned up, and how it is handled:
254
+
255
+ - **22 query parameters are missing from the document.** A hand-written `@ApiQuery`
256
+ decorator replaces the zod-derived parameter list wholesale, so controllers that
257
+ document `start`/`size` silently drop everything else. Six operations were affected —
258
+ four lose the whole TanStack filter/sort set, `/api/infra-billing/history` loses its
259
+ pagination, `/api/system/stats/bandwidth` loses `tz`. `codegen/spec_patches.py` puts
260
+ them back, deriving the TanStack ones from which commands import
261
+ `TanstackQueryRequestQuerySchema` rather than from a hardcoded list.
262
+ - **12 auth/passkey operations** document no 2xx response at all (no `@ApiResponse(200)`
263
+ decorator), so a spec-only generator types them `Any`. Their return types are read from
264
+ the NestJS handlers' `Promise<XxxResponseDto>` declarations instead.
265
+ - **`templateJson`** is `z.nullable(z.unknown())` — optional in zod, but emitted as
266
+ *required*, while the panel omits the key entirely. Trusting the spec makes
267
+ `subscription_template.get_all()` raise on a real panel. `z.unknown()`-shaped
268
+ properties are dropped from `required`.
269
+ - **`type: number`** (zod `z.number()` without `.int()`) covers ids and byte counters.
270
+ Mapped to `int | float` so an 8-byte traffic total stays exact instead of becoming
271
+ a float.
272
+ - **A short page is not the last page.** `getAllSubscriptions` skips users whose
273
+ subscription fails to build but still reports `total` as the user count, so a page can
274
+ come back short — or empty — with rows remaining. The iterators advance by the
275
+ requested page size and stop on `total`, never on a short page.
276
+ - **11 endpoints are unreachable with an API token** (`@Roles(ROLE.ADMIN)`); flagged in
277
+ their docstrings and in `ENDPOINTS[...]["admin_only"]`.
278
+ - **Documented status codes are unreliable** — several POSTs documented as `200` really
279
+ return `201`, and some "not found" cases arrive as `500` with an `errorCode`. The
280
+ client accepts any 2xx and maps exceptions by HTTP status, never by the documented
281
+ response list.
282
+ - **A 5xx carrying an `errorCode` is not retried.** `HttpExceptionFilter` stamps that
283
+ field only onto deliberate failures — config-profile (`A112`/`A061`) and settings
284
+ (`A199`/`A193`) validation both surface as coded 500s — so retrying only delays the
285
+ error. Uncoded 5xx, 429 and connection failures still retry.
286
+ - **The two raw subscription endpoints** are `@Res()` handlers with no schema; they are
287
+ special-cased rather than typed as `Any`.
288
+
289
+ ## Running against a local panel
290
+
291
+ ```bash
292
+ git clone --depth 1 --branch 2.8.1 https://github.com/remnawave/backend /tmp/rw
293
+ cd /tmp/rw && cp .env.sample .env # set APP_SECRET, IS_DOCS_ENABLED=true
294
+ docker compose -f docker-compose-prod.yml up -d
295
+
296
+ export RW_SDK_TEST_URL=http://127.0.0.1:3000
297
+ export RW_SDK_TEST_TOKEN=... # an API token from the panel
298
+ export RW_SDK_TEST_ADMIN_TOKEN=... # optional: admin JWT, unlocks the admin-only tests
299
+ pytest tests/test_live.py
300
+ ```
301
+
302
+ The panel runs in production mode, so requests need the forwarded headers — the live
303
+ tests pass `proxy_headers="always"`.
304
+
305
+ ## License
306
+
307
+ MIT. Remnawave itself is AGPL-3.0; this is an independent client, not affiliated with
308
+ the Remnawave project.
309
+
310
+ [guard]: https://github.com/remnawave/backend/blob/2.8.1/src/common/guards/jwt-guards/def-jwt-guard.ts
311
+ [proxy]: https://github.com/remnawave/backend/blob/2.8.1/src/common/middlewares/proxy-check.middleware.ts