3xui-api-client 3.1.1 → 3.2.0

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.2.0] - 2026-08-26
9
+
10
+ ### Added
11
+ - 🆕 **`testOutbound(outbound, options)`** - Test connectivity through an outbound configuration via `/panel/api/xray/testOutbound`. The endpoint reads its body as `application/x-www-form-urlencoded` rather than JSON (a JSON body always fails with `"outbound parameter is required"`, regardless of field name or nesting) - the `outbound` field must hold the JSON-stringified outbound object. Accepts an object or a pre-stringified outbound, plus optional `allOutbounds` (to resolve `sockopt.dialerProxy` dependencies between outbounds) and `mode`. `freedom`/`blackhole`-type outbounds return `success: false` with a clear `error` since there's nothing to dial through - that's expected panel behavior, not a request failure. Reverse-engineered and verified against `ghcr.io/mhsanaei/3x-ui:v3.7.0`.
12
+
13
+ ### Fixed
14
+ - 🔴 **Forced re-login could fail with a masking `403`** - When `_request()` classified a response as a stale session (`401`/`404`/HTML login page) and forced a re-login via `_retryAfterRelogin`, the axios instance still carried the *previous* session's cookie as a default header. Some panels' `/csrf-token` endpoint does not reissue a `Set-Cookie` when a cookie is already attached to the request, so `_getCsrfToken()` found nothing fresh to pair with the CSRF token and returned `null`. The retried `/login` POST then went out without `X-CSRF-Token` and the panel rejected it with `403` — masking the original error (e.g. a genuine `404`) behind an unrelated "login failed" exception. `login(forceRefresh)` now clears the stale cookie/CSRF token before the handshake, so a forced re-login always starts from a clean, anonymous state. Found via local Docker testing against `ghcr.io/mhsanaei/3x-ui:v3.7.0`.
15
+ - 🔴 **`addClientWithCredentials`/`updateClientWithCredentials` were broken on v3.x panels** - Both routed through the legacy `/panel/api/inbounds/addClient` and `/panel/api/inbounds/updateClient/:id` endpoints, which 3x-ui v3.x removed (see the Legacy API note below and `CLAUDE.md`). `addClientWithCredentials` threw an unhandled `404`; `updateClientWithCredentials` additionally called `JSON.parse()` unconditionally on `inbound.settings`, which v3.7.0+ panels return as an already-parsed object (not a JSON string), throwing `"[object Object]" is not valid JSON` before even reaching the dead route. Both now route through the Modern Client API (`addModernClient`/`/panel/api/clients/update/:email`), handle `settings` as either a string or object, and — since the Modern update endpoint replaces the whole client row rather than patching it — merge over the client's full current record (fetched via `getClient`) so untouched fields like `flow` are no longer silently cleared. The merged payload also drops the read-only numeric `id` and the `allowedIPs` field (string on read, array on write, for WireGuard peers), both of which the update endpoint rejects if round-tripped verbatim. `updateClientWithCredentials` now matches clients by UUID (`id`) or Trojan/Shadowsocks `password`, not just `id`. Found via local Docker testing against `ghcr.io/mhsanaei/3x-ui:v3.7.0`.
16
+ - 🔴 **`importDB` always failed with `403`** - It bypassed `_request()` entirely and called `this.api.post()` directly (to dodge `_request()`'s forced `Content-Type: application/json`, which collides with a multipart boundary), so it never got the `X-CSRF-Token` header this panel requires on non-GET requests. It now routes through `_request()`, passing the `FormData`'s own multipart headers as `extraHeaders` so they override the default JSON content type while still getting CSRF attachment and session recovery. Verified end-to-end (backup via the fixed `getDb()` below, successful re-import) against `ghcr.io/mhsanaei/3x-ui:v3.7.0`.
17
+ - 🔴 **`getDb()` silently corrupted the downloaded database** - The panel streams the raw SQLite file directly rather than wrapping it in the standard `{success, msg, obj}` envelope, and without `responseType: 'arraybuffer'`, axios's default UTF-8 text decoding is lossy for arbitrary binary content - confirmed on a live panel: a real DB download came back 10 bytes short and byte-different from the raw response, and the *pre-existing* validation logic (which assumed the envelope shape) threw `getDb: Invalid response format` on every call, meaning this method has likely never worked. It now forces `responseType: 'arraybuffer'` and decodes with latin1 (`'binary'`), which round-trips every byte value 1:1; verified byte-identical against a raw `arraybuffer` request. `_request()`/`_retryAfterRelogin()` gained an optional 5th `requestOptions` parameter to support this (and any future binary endpoint).
18
+ - 🐛 **`package.json`'s `files` array listed two nonexistent files** (`SECURITY.md`, `USAGE_EXAMPLES.md`) - removed. Confirmed via `npm pack --dry-run` that they were never actually in the published tarball; `publint` and a strict `tsc --noEmit` type-check of `index.d.ts` are both now part of the release checklist and pass clean.
19
+
20
+ ### Changed
21
+ - ⬆️ **Minimum Node.js raised from 14/16 to 18** - `package.json engines.node` was inconsistently `>=16.0.0` while `README.md` claimed `>=14.0.0`; both now say `>=18.0.0`, matching the CI test matrix (`ci.yml`/`release.yml` now both test Node 18/20/22, dropping the long-EOL Node 16). `engines.npm` raised from `>=7.0.0` to `>=9.0.0` to match.
22
+ - ⬆️ **Dependencies updated to latest**: `axios` ^1.10.0 → ^1.20.0, `jest` ^29.7.0 → ^30.4.2, `eslint` ^9.0.0 → ^10.9.1, `@eslint/js` ^9.0.0 → ^10.9.1 (aligned with `eslint`). ESLint 10's stricter `recommended` config flagged 3 spots re-throwing an error without `{ cause }` - fixed, so the original error is now preserved for debugging instead of discarded.
23
+ - 🗑️ Removed **`dotenv`** and **`chai`** dev dependencies - both were unused (`dotenv`: no `require` anywhere in the codebase; `chai`: no assertions written against it, tests use Jest's built-in `expect`).
24
+
25
+ ## [3.1.2] - 2026-06-30
26
+
27
+ ### Fixed
28
+ - 🔴 **Session recovery for non-401 stale sessions** ([#10](https://github.com/iamhelitha/3xui-api-client/issues/10)) - Automatic re-login is no longer keyed strictly to HTTP `401`. Some 3x-ui forks reject a stale cookie session with `404` (the auth-gated route falls through to a generic not-found handler when not logged in) or by returning an HTML login page with `200`. The client now treats all three as a lost session and performs one bounded, backed-off forced re-login + retry — reusing the existing `maxLoginRetries` / `loginRetryBackoff` budget. Token auth is unchanged: a `401` still throws a clear "token invalid" error and a `404` surfaces unchanged as a genuine missing resource.
29
+ - 🐛 **`getClientIps(email)` now returns a real array** ([#12](https://github.com/iamhelitha/3xui-api-client/issues/12)) - The legacy panel returns `obj` as a JSON-encoded string (e.g. `'["1.2.3.4"]'`) rather than an actual array. The client now parses it internally so `obj` is always a `string[]` (empty array when the panel has no IP record), matching every other list-returning endpoint. The TypeScript signature is now `Promise<ModernApiResponse<string[]>>`.
30
+
31
+ ### Changed
32
+ - 📖 **`getCPUHistory(bucket)` documentation/types corrected** ([#11](https://github.com/iamhelitha/3xui-api-client/issues/11)) - The previously documented `{ history: [{ timestamp, usage }] }` shape was assumed and never verified. The endpoint actually returns the standard `{ success, msg, obj }` envelope where `obj` is an array of exactly 60 `{ cpu: number; t: number }` points (`cpu` is a plain percentage number, `t` is epoch **seconds**; total span is `60 * bucket` seconds). The TypeScript signature is now `Promise<ModernApiResponse<Array<{ cpu: number; t: number }>>>`. No runtime change — the method already returned the real envelope.
33
+
8
34
  ## [3.1.1] - 2026-06-18
9
35
 
10
36
  ### Changed
@@ -207,7 +233,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
207
233
  ## [Unreleased]
208
234
 
209
235
  ### Planned
210
- - GitHub Actions CI/CD pipeline
211
236
  - Automated semantic releases
212
237
  - Enhanced unit test coverage
213
238
  - Performance benchmarking tools
package/README.md CHANGED
@@ -31,13 +31,13 @@ This library is available through **[Context7 MCP](https://context7.com/iamhelit
31
31
 
32
32
  ## Features
33
33
 
34
- - ✅ **Dual Panel Support** - Works with both modern (React, v2.x+) and legacy (Vue, v1.x) 3x-ui panels
34
+ - ✅ **Dual Panel Support** - Works with both modern (React, v3.x+) and legacy (Vue, v2.x) 3x-ui panels
35
35
  - ✅ **Auto-Detection** - Automatically detects panel version and uses correct endpoints
36
36
  - ✅ **API Token Authentication** - Support for API token auth (3x-ui v3.0.2+) and cookie-based login
37
37
  - ✅ **Automatic Credential Generation** - Built-in UUID, password, and key pair generators
38
38
  - ✅ **Session Management** - Automatic login, session caching, and expiry handling
39
39
  - ✅ **Security** - Input validation, secure headers, rate limiting, and error sanitization
40
- - ✅ **Modern API Support** - Complete modern API (v2.x+) with advanced client management
40
+ - ✅ **Modern API Support** - Complete modern API (v3.x+) with advanced client management
41
41
  - ✅ **Legacy API Support** - Full backward compatibility with legacy API methods
42
42
  - ✅ **TypeScript Definitions** - Complete type definitions for IDE support
43
43
  - ✅ **124 API Methods** - Comprehensive coverage of all 3x-ui panel operations
@@ -90,8 +90,8 @@ const client = new ThreeXUI('https://your-3xui-server.com', 'username', 'passwor
90
90
  ```
91
91
 
92
92
  **Supported Panel Types:**
93
- - ✅ **Modern Panels** - React-based, v2.x+ with `/panel/api/*` endpoints
94
- - ✅ **Legacy Panels** - Vue-based, v1.x with `/login` endpoint
93
+ - ✅ **Modern Panels** - React-based, v3.x+ with `/panel/api/*` endpoints
94
+ - ✅ **Legacy Panels** - Vue-based, v2.x with `/login` endpoint
95
95
  - ✅ **Auto-Detection** - Tries modern first, falls back to legacy if needed
96
96
  - ✅ **Session Caching** - Detected version is cached for faster subsequent logins
97
97
 
@@ -99,7 +99,7 @@ See [PANEL-VERSION-SUPPORT.md](./PANEL-VERSION-SUPPORT.md) for detailed version
99
99
 
100
100
  ## API Methods
101
101
 
102
- ### Client Management (Modern API - v2.x+)
102
+ ### Client Management (Modern API - v3.x+)
103
103
 
104
104
  #### Read Operations
105
105
  - `getClients()` - Get all clients
@@ -218,6 +218,7 @@ See [PANEL-VERSION-SUPPORT.md](./PANEL-VERSION-SUPPORT.md) for detailed version
218
218
  - `getXrayConfig()` - Get Xray configuration
219
219
  - `updateXrayConfig(config)` - Update Xray configuration
220
220
  - `manageWarp(action, data)` - Manage WARP settings
221
+ - `testOutbound(outbound, options)` - Test connectivity through an outbound configuration
221
222
  - `getOutboundsTraffic()` - Get outbound traffic statistics
222
223
  - `resetOutboundsTraffic()` - Reset outbound traffic
223
224
  - `getXrayResult()` - Get Xray execution result
@@ -279,7 +280,7 @@ For comprehensive guides, examples, and implementation patterns, visit our **[Wi
279
280
 
280
281
  ## Requirements
281
282
 
282
- - Node.js >= 14.0.0
283
+ - Node.js >= 18.0.0
283
284
  - 3x-ui panel v2.0+ (or v3.0.2+ for API token authentication)
284
285
  - API access enabled on your 3x-ui server
285
286
 
package/index.d.ts CHANGED
@@ -59,6 +59,13 @@ declare module '3xui-api-client' {
59
59
  enable?: boolean;
60
60
  }
61
61
 
62
+ /**
63
+ * Raw config for `addClient`/`updateClient`. `settings` is sent to the panel exactly
64
+ * as given — any `totalGB` inside it must already be in BYTES (the panel's field is
65
+ * named totalGB but stores bytes). This is NOT auto-converted, unlike
66
+ * `addClientWithCredentials`/`updateClientWithCredentials`/`ModernClient.totalGB`,
67
+ * which accept gigabytes. See https://github.com/iamhelitha/3xui-api-client/issues/5.
68
+ */
62
69
  export interface ClientConfig {
63
70
  id: number;
64
71
  settings: string;
@@ -573,15 +580,29 @@ declare module '3xui-api-client' {
573
580
  updateInbound(id: number, inboundConfig: InboundConfig): Promise<any>;
574
581
  importInbounds(inbounds: InboundConfig | InboundConfig[]): Promise<any[]>;
575
582
  getLastOnline(): Promise<any>;
583
+ /** Raw passthrough — no unit conversion. See {@link ClientConfig}: any `totalGB` in `clientConfig.settings` must already be in bytes. */
576
584
  addClient(clientConfig: ClientConfig): Promise<any>;
577
585
  deleteClient(inboundId: number, clientId: string): Promise<any>;
586
+ /** Raw passthrough — no unit conversion. See {@link ClientConfig}: any `totalGB` in `clientConfig.settings` must already be in bytes. */
578
587
  updateClient(clientId: string, clientConfig: ClientConfig): Promise<any>;
579
- /** @param trafficConfig.totalGB Data limit in gigabytes (auto-converted to bytes internally) */
588
+ /**
589
+ * Raw passthrough — no unit conversion.
590
+ * @param trafficConfig.totalGB Data limit in BYTES, not gigabytes (the panel field is named
591
+ * totalGB but stores bytes). Convert yourself: `gbValue * 1024 ** 3`. For automatic
592
+ * GB->bytes conversion use `updateClientWithCredentials`/`updateModernClient` instead.
593
+ */
580
594
  updateClientTraffic(email: string, trafficConfig: { totalGB?: number; expiryTime?: number }): Promise<any>;
581
595
  deleteClientByEmail(inboundId: number, email: string): Promise<any>;
582
596
  getClientTrafficsByEmail(email: string): Promise<any>;
583
597
  getClientTrafficsById(id: string): Promise<any>;
584
- getClientIps(email: string): Promise<any>;
598
+ /**
599
+ * Get the list of IP addresses recorded for a client.
600
+ *
601
+ * The legacy panel returns `obj` as a JSON-encoded string; this client
602
+ * parses it internally so `obj` is always a real `string[]` (empty when
603
+ * the panel has no IP record for the client).
604
+ */
605
+ getClientIps(email: string): Promise<ModernApiResponse<string[]>>;
585
606
  clearClientIps(email: string): Promise<any>;
586
607
  resetClientTraffic(inboundId: number, email: string): Promise<any>;
587
608
  resetAllTraffics(): Promise<any>;
@@ -595,9 +616,15 @@ declare module '3xui-api-client' {
595
616
  getServerStatus(): Promise<any>;
596
617
  /**
597
618
  * Get CPU usage history.
619
+ *
620
+ * Returns the standard `{ success, msg, obj }` envelope. `obj` is an array
621
+ * of exactly 60 points, each `{ cpu, t }` where `cpu` is the usage
622
+ * percentage (a plain number, no trailing `%`) and `t` is the epoch time in
623
+ * seconds. The total span covered is `60 * bucket` seconds.
624
+ *
598
625
  * @param bucket - Bucket size in seconds. Must be one of: 2, 30, 60, 120, 180, 300 (default: 60)
599
626
  */
600
- getCPUHistory(bucket?: number): Promise<any>;
627
+ getCPUHistory(bucket?: number): Promise<ModernApiResponse<Array<{ cpu: number; t: number }>>>;
601
628
  getXrayVersion(): Promise<any>;
602
629
  getConfigJson(): Promise<any>;
603
630
  /**
@@ -658,6 +685,21 @@ declare module '3xui-api-client' {
658
685
  * @param data - Additional data for the action
659
686
  */
660
687
  manageWarp(action: string, data?: Record<string, any>): Promise<any>;
688
+ /**
689
+ * Test connectivity through an outbound configuration.
690
+ * Sent as `application/x-www-form-urlencoded`, not JSON.
691
+ * `freedom`/`blackhole`-type outbounds cannot be tested (nothing to
692
+ * dial through) - the panel returns `success: false` with a clear
693
+ * `error` message for those, which is expected, not a request failure.
694
+ * @param outbound - Outbound config object (or its JSON string)
695
+ * @param options.allOutbounds - Full outbounds list, to resolve `sockopt.dialerProxy`
696
+ * dependencies between outbounds
697
+ * @param options.mode - Test mode flag (panel-defined; observed value: 'http')
698
+ */
699
+ testOutbound(
700
+ outbound: object | string,
701
+ options?: { allOutbounds?: object[]; mode?: string }
702
+ ): Promise<ModernApiResponse<{ tag: string; success: boolean; delay: number; error: string; mode: string }>>;
661
703
  getOutboundsTraffic(): Promise<any>;
662
704
  resetOutboundsTraffic(): Promise<any>;
663
705
  getXrayResult(): Promise<any>;
package/index.js CHANGED
@@ -196,6 +196,22 @@ class ThreeXUI {
196
196
  }
197
197
 
198
198
  try {
199
+ // On a forced re-login (e.g. stale-session recovery in
200
+ // _retryAfterRelogin), the axios instance still carries the
201
+ // previous, now-stale session cookie as a default header. Some
202
+ // panels' /csrf-token endpoint does not reissue a Set-Cookie when
203
+ // a session cookie is already attached to the request, which
204
+ // makes _getCsrfToken() below return null (no fresh cookie to
205
+ // pair with the token) and sends /login without the
206
+ // X-CSRF-Token it requires - failing with a confusing 403 that
207
+ // masks the original error. Clear the stale cookie first so the
208
+ // CSRF handshake starts from a clean, anonymous state.
209
+ if (forceRefresh) {
210
+ this.cookie = null;
211
+ this.csrfToken = null;
212
+ delete this.api.defaults.headers.Cookie;
213
+ }
214
+
199
215
  const params = new URLSearchParams();
200
216
  params.append('username', this.username);
201
217
  params.append('password', this.password);
@@ -392,7 +408,15 @@ class ThreeXUI {
392
408
  return this._request('post', '/getTwoFactorEnable');
393
409
  }
394
410
 
395
- async _request(method, path, data = {}, extraHeaders = {}) {
411
+ /**
412
+ * @param {string} method
413
+ * @param {string} path
414
+ * @param {*} data
415
+ * @param {Object} extraHeaders
416
+ * @param {Object} [requestOptions] - Extra axios request config to merge in verbatim
417
+ * (e.g. `{ responseType: 'arraybuffer' }` for binary downloads like `getDb()`).
418
+ */
419
+ async _request(method, path, data = {}, extraHeaders = {}, requestOptions = {}) {
396
420
  // Check session validity first with mutex protection if token is not provided
397
421
  if (!this.token) {
398
422
  if (!this.loginMutex && this.sessionManager && !await this.sessionManager.hasValidSession(this.baseURL, this.username)) {
@@ -407,40 +431,114 @@ class ThreeXUI {
407
431
  method,
408
432
  url: path,
409
433
  data,
410
- headers: { ...this._buildRequestHeaders(method), ...extraHeaders }
434
+ headers: { ...this._buildRequestHeaders(method), ...extraHeaders },
435
+ ...requestOptions
411
436
  });
437
+
438
+ // Some panels answer a stale/expired session with HTTP 200 plus an
439
+ // HTML login page (a redirect-then-200) instead of an explicit
440
+ // 401/404. For cookie auth, treat that HTML body where JSON was
441
+ // expected as a lost session and recover transparently.
442
+ if (!this.token && this._looksLikeLoginPage(response.data)) {
443
+ return await this._retryAfterRelogin(method, path, data, extraHeaders, requestOptions);
444
+ }
445
+
412
446
  // Reset retry counter on successful request
413
447
  this.loginRetryCount = 0;
414
448
  return response.data;
415
449
  } catch (error) {
416
- if (error.response && error.response.status === 401) {
450
+ if (this._isStaleSessionError(error)) {
417
451
  if (this.token) {
418
- throw new Error('API Token is invalid or expired. Please check your credentials.');
419
- }
420
- // Cookie might have expired, try to login again with retry limit.
421
- // A configurable backoff delay is applied before each re-login to
422
- // avoid burning through the retry budget or tripping login-rate limits
423
- // (e.g. fail2ban) when many instances race to re-authenticate.
424
- if (this.loginRetryCount < this.maxLoginRetries) {
425
- if (this.loginRetryBackoff > 0) {
426
- await new Promise(resolve => setTimeout(resolve, this.loginRetryBackoff));
452
+ // Token auth: a 401 means the token itself is invalid, so
453
+ // retrying will not help. A 404 is a genuine missing
454
+ // resource (the token never goes "stale"), so surface it
455
+ // unchanged.
456
+ if (error.response && error.response.status === 401) {
457
+ throw new Error('API Token is invalid or expired. Please check your credentials.', { cause: error });
427
458
  }
428
- await this._ensureAuthenticated(true); // Force refresh
429
- const response = await this.api.request({
430
- method,
431
- url: path,
432
- data,
433
- headers: { ...this._buildRequestHeaders(method), ...extraHeaders }
434
- });
435
- return response.data;
436
- } else {
437
- throw new Error('Maximum login retry attempts exceeded. Check your credentials.');
459
+ throw error;
438
460
  }
461
+ // Cookie session may have expired. Different 3x-ui versions/forks
462
+ // are inconsistent about the signal (401, 404, or an HTML login
463
+ // page), so we recover the same way for each: one bounded,
464
+ // backed-off forced re-login + retry.
465
+ return await this._retryAfterRelogin(method, path, data, extraHeaders, requestOptions);
439
466
  }
440
467
  throw error;
441
468
  }
442
469
  }
443
470
 
471
+ /**
472
+ * Decide whether an error from an authenticated request indicates a lost
473
+ * cookie session that warrants a forced re-login. Different 3x-ui forks
474
+ * reject a stale session with 401, with 404 (the auth-gated route falls
475
+ * through to a generic not-found handler when not logged in), or by
476
+ * returning an HTML login page where JSON was expected.
477
+ *
478
+ * @param {*} error - Error thrown by the axios request
479
+ * @returns {boolean} True if the error looks like a session failure
480
+ */
481
+ _isStaleSessionError(error) {
482
+ if (!error || !error.response) {
483
+ return false;
484
+ }
485
+ const status = error.response.status;
486
+ if (status === 401 || status === 404) {
487
+ return true;
488
+ }
489
+ return this._looksLikeLoginPage(error.response.data);
490
+ }
491
+
492
+ /**
493
+ * Heuristic for a panel returning its HTML login page in place of the
494
+ * expected JSON envelope. Every authenticated endpoint here returns JSON
495
+ * (or, for getDb, a "SQLite format 3" string), so an HTML document body is
496
+ * itself the anomaly that signals a redirect to the login screen.
497
+ *
498
+ * @param {*} data - Response body
499
+ * @returns {boolean} True if the body looks like an HTML login page
500
+ */
501
+ _looksLikeLoginPage(data) {
502
+ if (typeof data !== 'string') {
503
+ return false;
504
+ }
505
+ const trimmed = data.trim().toLowerCase();
506
+ return trimmed.startsWith('<!doctype html') || trimmed.startsWith('<html');
507
+ }
508
+
509
+ /**
510
+ * Force a fresh login and retry the original request once, honoring the
511
+ * shared retry budget and backoff. Used for cookie-auth session recovery
512
+ * regardless of whether the panel signalled with 401, 404, or a login-page
513
+ * body, so consumers never have to reimplement session recovery themselves.
514
+ *
515
+ * @param {string} method - HTTP method
516
+ * @param {string} path - Request path
517
+ * @param {Object} data - Request body
518
+ * @param {Object} extraHeaders - Additional headers to merge
519
+ * @returns {Promise<*>} The retried response data
520
+ */
521
+ async _retryAfterRelogin(method, path, data, extraHeaders, requestOptions = {}) {
522
+ // Bound the recovery to a single retry budget to avoid login storms
523
+ // (e.g. tripping fail2ban) when many instances race to re-authenticate.
524
+ if (this.loginRetryCount >= this.maxLoginRetries) {
525
+ throw new Error('Maximum login retry attempts exceeded. Check your credentials.');
526
+ }
527
+ if (this.loginRetryBackoff > 0) {
528
+ await new Promise(resolve => setTimeout(resolve, this.loginRetryBackoff));
529
+ }
530
+ await this._ensureAuthenticated(true); // Force refresh
531
+ const response = await this.api.request({
532
+ method,
533
+ url: path,
534
+ data,
535
+ headers: { ...this._buildRequestHeaders(method), ...extraHeaders },
536
+ ...requestOptions
537
+ });
538
+ this.loginRetryCount = 0;
539
+ return response.data;
540
+ }
541
+
444
542
  /**
445
543
  * Build per-request headers, including the JSON content type for POST
446
544
  * bodies and (on cookie-authenticated sessions against newer
@@ -596,23 +694,22 @@ class ThreeXUI {
596
694
  */
597
695
  async addClientWithCredentials(inboundId, protocol, options = {}) {
598
696
  const credentials = this.generateCredentials(protocol, options);
599
- const convertedOptions = convertBandwidthFields(options);
600
-
601
- const clientConfig = {
602
- id: inboundId,
603
- settings: JSON.stringify({
604
- clients: [{
605
- ...credentials,
606
- enable: true,
607
- expiryTime: convertedOptions.expiryTime || 0,
608
- limitIp: convertedOptions.limitIp || 0,
609
- totalGB: convertedOptions.totalGB || 0,
610
- subId: convertedOptions.subId || this.generateUUID()
611
- }]
612
- })
697
+
698
+ // Routed through the Modern Client API (addModernClient), which
699
+ // does its own GB->bytes conversion - pass totalGB in GB here, not
700
+ // pre-converted, to avoid converting it twice. The legacy
701
+ // /panel/api/inbounds/addClient route this used to call is removed
702
+ // on v3.x panels (see CLAUDE.md).
703
+ const client = {
704
+ ...credentials,
705
+ enable: true,
706
+ expiryTime: options.expiryTime || 0,
707
+ limitIp: options.limitIp || 0,
708
+ totalGB: options.totalGB || 0,
709
+ subId: options.subId || this.generateUUID()
613
710
  };
614
711
 
615
- const result = await this.addClient(clientConfig);
712
+ const result = await this.addModernClient({ inboundIds: [inboundId], client });
616
713
  return {
617
714
  ...result,
618
715
  credentials,
@@ -629,56 +726,65 @@ class ThreeXUI {
629
726
  */
630
727
  async updateClientWithCredentials(clientId, inboundId, options = {}) {
631
728
  try {
632
- // First, get the current inbound to obtain all existing clients
729
+ // First, get the current inbound to locate the client by its
730
+ // VLESS/VMess UUID (id) or Trojan/Shadowsocks password.
633
731
  const inboundData = await this.getInbound(inboundId);
634
732
 
635
733
  if (!inboundData.success || !inboundData.obj) {
636
734
  throw new Error(`Failed to get inbound ${inboundId} for client update`);
637
735
  }
638
736
 
639
- // Parse existing settings to get all clients
640
- const currentSettings = JSON.parse(inboundData.obj.settings);
737
+ // `settings` comes back as a JSON string on some panel versions
738
+ // and as an already-parsed object on others (v3.7.0+) - handle both.
739
+ const currentSettings = typeof inboundData.obj.settings === 'string'
740
+ ? JSON.parse(inboundData.obj.settings)
741
+ : inboundData.obj.settings;
641
742
  const existingClients = currentSettings.clients || [];
642
743
 
643
- // Find the client to update
644
- const clientIndex = existingClients.findIndex(client => client.id === clientId);
744
+ const clientIndex = existingClients.findIndex(
745
+ client => client.id === clientId || client.password === clientId
746
+ );
645
747
  if (clientIndex === -1) {
646
748
  throw new Error(`Client with ID ${clientId} not found in inbound ${inboundId}`);
647
749
  }
750
+ const email = existingClients[clientIndex].email;
751
+ if (!email) {
752
+ throw new Error(`Client with ID ${clientId} has no email - cannot update via the Modern Client API`);
753
+ }
754
+
755
+ // The Modern Client API's update endpoint replaces the entire
756
+ // client row rather than patching it, so every field must be
757
+ // resent or it gets silently cleared (e.g. `flow`). Load the
758
+ // authoritative full record first.
759
+ const fullClient = await this.getClient(email);
760
+ if (!fullClient.success || !fullClient.obj || !fullClient.obj.client) {
761
+ throw new Error(`Failed to load full client record for ${email}`);
762
+ }
763
+ const current = fullClient.obj.client;
648
764
 
649
765
  // Convert bandwidth fields (GB to bytes)
650
766
  const convertedOptions = convertBandwidthFields(options);
651
767
 
652
- // Convert user-friendly options to API format
768
+ // Convert user-friendly options to API format, merged over the
769
+ // full existing record so untouched fields are preserved as-is.
770
+ // `id` (the read-only numeric row id from getClient()) and
771
+ // `allowedIPs` (typed as a string on read but an array on
772
+ // write, for WireGuard peers) are dropped: resending either as
773
+ // read from GET fails the update endpoint's own validation.
653
774
  const processedOptions = {
654
- email: convertedOptions.email || existingClients[clientIndex].email,
655
- limitIp: convertedOptions.limitIp !== undefined ? convertedOptions.limitIp : existingClients[clientIndex].limitIp,
656
- totalGB: convertedOptions.totalGB !== undefined ? convertedOptions.totalGB : existingClients[clientIndex].totalGB,
657
- expiryTime: convertedOptions.expiryDays ? Date.now() + (convertedOptions.expiryDays * 24 * 60 * 60 * 1000) : existingClients[clientIndex].expiryTime,
658
- enable: convertedOptions.enable !== undefined ? convertedOptions.enable : existingClients[clientIndex].enable,
659
- flow: convertedOptions.flow || existingClients[clientIndex].flow,
660
- encryption: convertedOptions.encryption || existingClients[clientIndex].encryption || 'none',
661
- subId: convertedOptions.subId || existingClients[clientIndex].subId
662
- };
663
-
664
- // Update the specific client while preserving others
665
- existingClients[clientIndex] = {
666
- ...existingClients[clientIndex],
667
- ...processedOptions
668
- };
669
-
670
- // Prepare the complete settings with all clients
671
- const updatedSettings = {
672
- ...currentSettings,
673
- clients: existingClients
674
- };
675
-
676
- const clientConfig = {
677
- id: inboundId,
678
- settings: JSON.stringify(updatedSettings)
775
+ ...current,
776
+ email: convertedOptions.email || current.email,
777
+ limitIp: convertedOptions.limitIp !== undefined ? convertedOptions.limitIp : current.limitIp,
778
+ totalGB: convertedOptions.totalGB !== undefined ? convertedOptions.totalGB : current.totalGB,
779
+ expiryTime: convertedOptions.expiryDays ? Date.now() + (convertedOptions.expiryDays * 24 * 60 * 60 * 1000) : current.expiryTime,
780
+ enable: convertedOptions.enable !== undefined ? convertedOptions.enable : current.enable,
781
+ flow: convertedOptions.flow || current.flow,
782
+ subId: convertedOptions.subId || current.subId
679
783
  };
784
+ delete processedOptions.id;
785
+ delete processedOptions.allowedIPs;
680
786
 
681
- const result = await this.updateClient(clientId, clientConfig);
787
+ const result = await this._request('post', `/panel/api/clients/update/${encodeURIComponent(email)}`, processedOptions);
682
788
  return {
683
789
  ...result,
684
790
  updatedOptions: processedOptions,
@@ -1109,6 +1215,14 @@ class ThreeXUI {
1109
1215
  }
1110
1216
 
1111
1217
  // Clients
1218
+ /**
1219
+ * Add a client (raw passthrough — no unit conversion).
1220
+ * `clientConfig.settings` is sent to the panel exactly as given, so any `totalGB`
1221
+ * inside it must already be in BYTES (the panel stores this field in bytes despite
1222
+ * its name). For a GB input with automatic conversion, use `addClientWithCredentials`
1223
+ * or `addModernClient` instead. See issue #5 for background.
1224
+ * @param {ClientConfig} clientConfig
1225
+ */
1112
1226
  addClient(clientConfig) {
1113
1227
  // Validate client configuration for security
1114
1228
  const validatedConfig = InputValidator.validateClientConfig(clientConfig);
@@ -1119,6 +1233,14 @@ class ThreeXUI {
1119
1233
  return this._request('post', `/panel/api/inbounds/${inboundId}/delClient/${clientId}`);
1120
1234
  }
1121
1235
 
1236
+ /**
1237
+ * Update a client (raw passthrough — no unit conversion).
1238
+ * Same caveat as `addClient`: any `totalGB` inside `clientConfig.settings` must
1239
+ * already be in BYTES. Use `updateClientWithCredentials`/`updateModernClient` for
1240
+ * automatic GB->bytes conversion.
1241
+ * @param {string} clientId
1242
+ * @param {ClientConfig} clientConfig
1243
+ */
1122
1244
  updateClient(clientId, clientConfig) {
1123
1245
  // Validate client configuration for security
1124
1246
  const validatedConfig = InputValidator.validateClientConfig(clientConfig);
@@ -1126,9 +1248,12 @@ class ThreeXUI {
1126
1248
  }
1127
1249
 
1128
1250
  /**
1129
- * Update client traffic limit and expiry by email
1251
+ * Update client traffic limit and expiry by email (raw passthrough — no unit conversion).
1130
1252
  * @param {string} email - Client email
1131
- * @param {Object} trafficConfig - Traffic configuration (totalGB, expiryTime)
1253
+ * @param {Object} trafficConfig - Traffic configuration
1254
+ * @param {number} [trafficConfig.totalGB] - Data limit in BYTES, not gigabytes (the panel
1255
+ * field is named totalGB but stores bytes). Convert yourself: `gbValue * 1024 ** 3`.
1256
+ * @param {number} [trafficConfig.expiryTime]
1132
1257
  */
1133
1258
  updateClientTraffic(email, trafficConfig) {
1134
1259
  return this._request('post', `/panel/api/inbounds/updateClientTraffic/${email}`, trafficConfig);
@@ -1151,8 +1276,44 @@ class ThreeXUI {
1151
1276
  return this._request('get', `/panel/api/inbounds/getClientTrafficsById/${id}`);
1152
1277
  }
1153
1278
 
1154
- getClientIps(email) {
1155
- return this._request('post', `/panel/api/inbounds/clientIps/${email}`);
1279
+ /**
1280
+ * Get the list of IP addresses recorded for a client.
1281
+ *
1282
+ * The legacy panel returns `obj` as a JSON-encoded string (e.g.
1283
+ * `'["1.2.3.4"]'`) rather than an actual array, unlike every other
1284
+ * list-returning endpoint. We parse it internally so callers get
1285
+ * `obj: string[]` directly. If the panel reports "No IP Record" (or any
1286
+ * non-array/unparseable value), `obj` is normalized to an empty array.
1287
+ *
1288
+ * @param {string} email - Client email
1289
+ * @returns {Promise<{success: boolean, msg: string, obj: string[]}>}
1290
+ */
1291
+ async getClientIps(email) {
1292
+ const response = await this._request('post', `/panel/api/inbounds/clientIps/${email}`);
1293
+ return { ...response, obj: this._parseClientIps(response && response.obj) };
1294
+ }
1295
+
1296
+ /**
1297
+ * Normalize the `obj` field returned by clientIps into a real array of IP
1298
+ * strings. The panel may return a JSON-encoded array string, an already
1299
+ * parsed array, or a placeholder message like "No IP Record".
1300
+ *
1301
+ * @param {*} obj - Raw `obj` value from the clientIps response
1302
+ * @returns {string[]} Parsed list of IP addresses (empty when none)
1303
+ */
1304
+ _parseClientIps(obj) {
1305
+ if (Array.isArray(obj)) {
1306
+ return obj;
1307
+ }
1308
+ if (typeof obj === 'string') {
1309
+ try {
1310
+ const parsed = JSON.parse(obj);
1311
+ return Array.isArray(parsed) ? parsed : [];
1312
+ } catch {
1313
+ return [];
1314
+ }
1315
+ }
1316
+ return [];
1156
1317
  }
1157
1318
 
1158
1319
  clearClientIps(email) {
@@ -1227,11 +1388,33 @@ class ThreeXUI {
1227
1388
 
1228
1389
  /**
1229
1390
  * Download database
1230
- * @returns {Promise<string>} Raw SQLite database file content as a string (starts with "SQLite format 3 ..."), not a Buffer
1391
+ * @returns {Promise<ModernApiResponse<string>>} `obj` is the raw SQLite database file
1392
+ * content as a binary-safe string (starts with "SQLite format 3 ..."), not a Buffer.
1393
+ * Pass it to `Buffer.from(obj, 'binary')` to get the actual file bytes back losslessly.
1231
1394
  */
1232
1395
  async getDb() {
1233
- const response = await this._request('get', '/panel/api/server/getDb');
1234
- // Add response validation
1396
+ // The panel streams the raw SQLite file directly rather than wrapping it in
1397
+ // the standard {success, msg, obj} envelope every other endpoint uses, and the
1398
+ // response is arbitrary binary data - axios's default text decoding (UTF-8) is
1399
+ // lossy for that (confirmed: a real DB download came back 10 bytes short and
1400
+ // byte-different from the raw response). Force responseType: 'arraybuffer' to
1401
+ // get the exact bytes, then use latin1 ('binary'), which round-trips every byte
1402
+ // value 1:1, to produce the string this method's signature promises.
1403
+ const response = await this._request('get', '/panel/api/server/getDb', {}, {}, { responseType: 'arraybuffer' });
1404
+ if (Buffer.isBuffer(response) || response instanceof ArrayBuffer || ArrayBuffer.isView(response)) {
1405
+ const buf = Buffer.from(response);
1406
+ if (buf.length === 0) {
1407
+ throw new Error('getDb: Response missing database content');
1408
+ }
1409
+ return { success: true, msg: '', obj: buf.toString('binary') };
1410
+ }
1411
+ if (typeof response === 'string') {
1412
+ if (!response) {
1413
+ throw new Error('getDb: Response missing database content');
1414
+ }
1415
+ return { success: true, msg: '', obj: response };
1416
+ }
1417
+ // Defensive fallback in case some panel fork/version does wrap it.
1235
1418
  if (!response || typeof response !== 'object') {
1236
1419
  throw new Error('getDb: Invalid response format');
1237
1420
  }
@@ -1308,16 +1491,19 @@ class ThreeXUI {
1308
1491
  }
1309
1492
 
1310
1493
  /**
1311
- * Import database
1494
+ * Import database (destructive - replaces the panel's entire database).
1312
1495
  * @param {FormData} formData - FormData containing the database file
1313
1496
  */
1314
- async importDB(formData) {
1315
- // Ensure authenticated before direct API call
1316
- if (!this.cookie) {
1317
- await this._ensureAuthenticated();
1318
- }
1319
- // Use direct api call to handle multipart/form-data correctly
1320
- return this.api.post('/panel/api/server/importDB', formData);
1497
+ importDB(formData) {
1498
+ // Route through _request() (not a direct api.post) so this gets the
1499
+ // same X-CSRF-Token attachment, session recovery, and stale-session
1500
+ // retry as every other write - without it, this always failed with
1501
+ // a 403 on panels that require CSRF on non-GET requests. The
1502
+ // FormData's own multipart boundary Content-Type is passed as
1503
+ // extraHeaders so it overrides _request()'s default
1504
+ // "Content-Type: application/json" for POST bodies.
1505
+ const extraHeaders = typeof formData.getHeaders === 'function' ? formData.getHeaders() : {};
1506
+ return this._request('post', '/panel/api/server/importDB', formData, extraHeaders);
1321
1507
  }
1322
1508
 
1323
1509
  // ===========================================
@@ -1447,7 +1633,7 @@ class ThreeXUI {
1447
1633
  if (this.sessionManager) {
1448
1634
  await this.sessionManager.deleteSession(this.baseURL, newUsername);
1449
1635
  }
1450
- throw new Error(`Credential update succeeded but re-authentication failed: ${error.message}`);
1636
+ throw new Error(`Credential update succeeded but re-authentication failed: ${error.message}`, { cause: error });
1451
1637
  }
1452
1638
  }
1453
1639
 
@@ -1496,7 +1682,7 @@ class ThreeXUI {
1496
1682
  try {
1497
1683
  config = JSON.parse(config);
1498
1684
  } catch (error) {
1499
- throw new Error(`updateXrayConfig: Invalid JSON: ${error.message}`);
1685
+ throw new Error(`updateXrayConfig: Invalid JSON: ${error.message}`, { cause: error });
1500
1686
  }
1501
1687
  }
1502
1688
  // Validate it's an object
@@ -1515,6 +1701,40 @@ class ThreeXUI {
1515
1701
  return this._request('post', `/panel/api/xray/warp/${action}`, data);
1516
1702
  }
1517
1703
 
1704
+ /**
1705
+ * Test connectivity through an outbound configuration.
1706
+ *
1707
+ * Sent as `application/x-www-form-urlencoded`, not JSON - a JSON body
1708
+ * always fails with "outbound parameter is required" regardless of
1709
+ * field name or nesting tried. The `outbound` field must be the
1710
+ * JSON-stringified outbound object.
1711
+ *
1712
+ * `freedom`/`blackhole`-type outbounds cannot be tested (nothing to
1713
+ * dial through) - the panel returns `success: false` with a clear
1714
+ * `error` message for those, which is expected, not a request failure.
1715
+ * @param {Object|string} outbound - Outbound config object (or its JSON string)
1716
+ * @param {Object} [options]
1717
+ * @param {Array<Object>} [options.allOutbounds] - Full outbounds list, to resolve
1718
+ * `sockopt.dialerProxy` dependencies between outbounds. Field name inferred from
1719
+ * the endpoint's own description ("optionally all outbounds") - not confirmed
1720
+ * against a config that actually needs it.
1721
+ * @param {string} [options.mode] - Test mode flag (panel-defined; observed value: 'http')
1722
+ * @returns {Promise<Object>} `{ tag, success, delay, error, mode }`
1723
+ */
1724
+ testOutbound(outbound, options = {}) {
1725
+ const params = new URLSearchParams();
1726
+ params.append('outbound', typeof outbound === 'string' ? outbound : JSON.stringify(outbound));
1727
+ if (options.allOutbounds) {
1728
+ params.append('outbounds', JSON.stringify(options.allOutbounds));
1729
+ }
1730
+ if (options.mode) {
1731
+ params.append('mode', options.mode);
1732
+ }
1733
+ return this._request('post', '/panel/api/xray/testOutbound', params.toString(), {
1734
+ 'Content-Type': 'application/x-www-form-urlencoded'
1735
+ });
1736
+ }
1737
+
1518
1738
  /**
1519
1739
  * Get outbound traffic statistics
1520
1740
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "3xui-api-client",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
4
4
  "description": "Node.js API client for the 3x-ui panel (VLESS, VMess, Trojan, Shadowsocks, WireGuard, Reality) with automatic session management, credential generation, and full REST API coverage",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -75,8 +75,8 @@
75
75
  "url": "https://github.com/sponsors/iamhelitha"
76
76
  },
77
77
  "engines": {
78
- "node": ">=16.0.0",
79
- "npm": ">=7.0.0"
78
+ "node": ">=18.0.0",
79
+ "npm": ">=9.0.0"
80
80
  },
81
81
  "os": [
82
82
  "linux",
@@ -89,19 +89,15 @@
89
89
  "src/",
90
90
  "README.md",
91
91
  "LICENSE",
92
- "CHANGELOG.md",
93
- "SECURITY.md",
94
- "USAGE_EXAMPLES.md"
92
+ "CHANGELOG.md"
95
93
  ],
96
94
  "dependencies": {
97
- "axios": "^1.10.0"
95
+ "axios": "^1.20.0"
98
96
  },
99
97
  "devDependencies": {
100
- "dotenv": "^16.5.0",
101
- "jest": "^29.7.0",
102
- "eslint": "^9.0.0",
103
- "@eslint/js": "^9.0.0",
104
- "chai": "^4.3.4"
98
+ "jest": "^30.4.2",
99
+ "eslint": "^10.9.1",
100
+ "@eslint/js": "^10.0.1"
105
101
  },
106
102
  "jest": {
107
103
  "testEnvironment": "node",