3xui-api-client 3.1.2 → 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 +17 -1
- package/README.md +7 -6
- package/index.d.ts +30 -1
- package/index.js +181 -69
- package/package.json +8 -12
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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
|
+
|
|
8
25
|
## [3.1.2] - 2026-06-30
|
|
9
26
|
|
|
10
27
|
### Fixed
|
|
@@ -216,7 +233,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
216
233
|
## [Unreleased]
|
|
217
234
|
|
|
218
235
|
### Planned
|
|
219
|
-
- GitHub Actions CI/CD pipeline
|
|
220
236
|
- Automated semantic releases
|
|
221
237
|
- Enhanced unit test coverage
|
|
222
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,
|
|
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 (
|
|
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,
|
|
94
|
-
- ✅ **Legacy Panels** - Vue-based,
|
|
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 -
|
|
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 >=
|
|
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,10 +580,17 @@ 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
|
-
/**
|
|
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>;
|
|
@@ -671,6 +685,21 @@ declare module '3xui-api-client' {
|
|
|
671
685
|
* @param data - Additional data for the action
|
|
672
686
|
*/
|
|
673
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 }>>;
|
|
674
703
|
getOutboundsTraffic(): Promise<any>;
|
|
675
704
|
resetOutboundsTraffic(): Promise<any>;
|
|
676
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
|
-
|
|
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,7 +431,8 @@ 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
|
});
|
|
412
437
|
|
|
413
438
|
// Some panels answer a stale/expired session with HTTP 200 plus an
|
|
@@ -415,7 +440,7 @@ class ThreeXUI {
|
|
|
415
440
|
// 401/404. For cookie auth, treat that HTML body where JSON was
|
|
416
441
|
// expected as a lost session and recover transparently.
|
|
417
442
|
if (!this.token && this._looksLikeLoginPage(response.data)) {
|
|
418
|
-
return await this._retryAfterRelogin(method, path, data, extraHeaders);
|
|
443
|
+
return await this._retryAfterRelogin(method, path, data, extraHeaders, requestOptions);
|
|
419
444
|
}
|
|
420
445
|
|
|
421
446
|
// Reset retry counter on successful request
|
|
@@ -429,7 +454,7 @@ class ThreeXUI {
|
|
|
429
454
|
// resource (the token never goes "stale"), so surface it
|
|
430
455
|
// unchanged.
|
|
431
456
|
if (error.response && error.response.status === 401) {
|
|
432
|
-
throw new Error('API Token is invalid or expired. Please check your credentials.');
|
|
457
|
+
throw new Error('API Token is invalid or expired. Please check your credentials.', { cause: error });
|
|
433
458
|
}
|
|
434
459
|
throw error;
|
|
435
460
|
}
|
|
@@ -437,7 +462,7 @@ class ThreeXUI {
|
|
|
437
462
|
// are inconsistent about the signal (401, 404, or an HTML login
|
|
438
463
|
// page), so we recover the same way for each: one bounded,
|
|
439
464
|
// backed-off forced re-login + retry.
|
|
440
|
-
return await this._retryAfterRelogin(method, path, data, extraHeaders);
|
|
465
|
+
return await this._retryAfterRelogin(method, path, data, extraHeaders, requestOptions);
|
|
441
466
|
}
|
|
442
467
|
throw error;
|
|
443
468
|
}
|
|
@@ -493,7 +518,7 @@ class ThreeXUI {
|
|
|
493
518
|
* @param {Object} extraHeaders - Additional headers to merge
|
|
494
519
|
* @returns {Promise<*>} The retried response data
|
|
495
520
|
*/
|
|
496
|
-
async _retryAfterRelogin(method, path, data, extraHeaders) {
|
|
521
|
+
async _retryAfterRelogin(method, path, data, extraHeaders, requestOptions = {}) {
|
|
497
522
|
// Bound the recovery to a single retry budget to avoid login storms
|
|
498
523
|
// (e.g. tripping fail2ban) when many instances race to re-authenticate.
|
|
499
524
|
if (this.loginRetryCount >= this.maxLoginRetries) {
|
|
@@ -507,7 +532,8 @@ class ThreeXUI {
|
|
|
507
532
|
method,
|
|
508
533
|
url: path,
|
|
509
534
|
data,
|
|
510
|
-
headers: { ...this._buildRequestHeaders(method), ...extraHeaders }
|
|
535
|
+
headers: { ...this._buildRequestHeaders(method), ...extraHeaders },
|
|
536
|
+
...requestOptions
|
|
511
537
|
});
|
|
512
538
|
this.loginRetryCount = 0;
|
|
513
539
|
return response.data;
|
|
@@ -668,23 +694,22 @@ class ThreeXUI {
|
|
|
668
694
|
*/
|
|
669
695
|
async addClientWithCredentials(inboundId, protocol, options = {}) {
|
|
670
696
|
const credentials = this.generateCredentials(protocol, options);
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
})
|
|
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()
|
|
685
710
|
};
|
|
686
711
|
|
|
687
|
-
const result = await this.
|
|
712
|
+
const result = await this.addModernClient({ inboundIds: [inboundId], client });
|
|
688
713
|
return {
|
|
689
714
|
...result,
|
|
690
715
|
credentials,
|
|
@@ -701,56 +726,65 @@ class ThreeXUI {
|
|
|
701
726
|
*/
|
|
702
727
|
async updateClientWithCredentials(clientId, inboundId, options = {}) {
|
|
703
728
|
try {
|
|
704
|
-
// First, get the current inbound to
|
|
729
|
+
// First, get the current inbound to locate the client by its
|
|
730
|
+
// VLESS/VMess UUID (id) or Trojan/Shadowsocks password.
|
|
705
731
|
const inboundData = await this.getInbound(inboundId);
|
|
706
732
|
|
|
707
733
|
if (!inboundData.success || !inboundData.obj) {
|
|
708
734
|
throw new Error(`Failed to get inbound ${inboundId} for client update`);
|
|
709
735
|
}
|
|
710
736
|
|
|
711
|
-
//
|
|
712
|
-
|
|
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;
|
|
713
742
|
const existingClients = currentSettings.clients || [];
|
|
714
743
|
|
|
715
|
-
|
|
716
|
-
|
|
744
|
+
const clientIndex = existingClients.findIndex(
|
|
745
|
+
client => client.id === clientId || client.password === clientId
|
|
746
|
+
);
|
|
717
747
|
if (clientIndex === -1) {
|
|
718
748
|
throw new Error(`Client with ID ${clientId} not found in inbound ${inboundId}`);
|
|
719
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;
|
|
720
764
|
|
|
721
765
|
// Convert bandwidth fields (GB to bytes)
|
|
722
766
|
const convertedOptions = convertBandwidthFields(options);
|
|
723
767
|
|
|
724
|
-
// 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.
|
|
725
774
|
const processedOptions = {
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
subId: convertedOptions.subId ||
|
|
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
|
|
734
783
|
};
|
|
784
|
+
delete processedOptions.id;
|
|
785
|
+
delete processedOptions.allowedIPs;
|
|
735
786
|
|
|
736
|
-
|
|
737
|
-
existingClients[clientIndex] = {
|
|
738
|
-
...existingClients[clientIndex],
|
|
739
|
-
...processedOptions
|
|
740
|
-
};
|
|
741
|
-
|
|
742
|
-
// Prepare the complete settings with all clients
|
|
743
|
-
const updatedSettings = {
|
|
744
|
-
...currentSettings,
|
|
745
|
-
clients: existingClients
|
|
746
|
-
};
|
|
747
|
-
|
|
748
|
-
const clientConfig = {
|
|
749
|
-
id: inboundId,
|
|
750
|
-
settings: JSON.stringify(updatedSettings)
|
|
751
|
-
};
|
|
752
|
-
|
|
753
|
-
const result = await this.updateClient(clientId, clientConfig);
|
|
787
|
+
const result = await this._request('post', `/panel/api/clients/update/${encodeURIComponent(email)}`, processedOptions);
|
|
754
788
|
return {
|
|
755
789
|
...result,
|
|
756
790
|
updatedOptions: processedOptions,
|
|
@@ -1181,6 +1215,14 @@ class ThreeXUI {
|
|
|
1181
1215
|
}
|
|
1182
1216
|
|
|
1183
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
|
+
*/
|
|
1184
1226
|
addClient(clientConfig) {
|
|
1185
1227
|
// Validate client configuration for security
|
|
1186
1228
|
const validatedConfig = InputValidator.validateClientConfig(clientConfig);
|
|
@@ -1191,6 +1233,14 @@ class ThreeXUI {
|
|
|
1191
1233
|
return this._request('post', `/panel/api/inbounds/${inboundId}/delClient/${clientId}`);
|
|
1192
1234
|
}
|
|
1193
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
|
+
*/
|
|
1194
1244
|
updateClient(clientId, clientConfig) {
|
|
1195
1245
|
// Validate client configuration for security
|
|
1196
1246
|
const validatedConfig = InputValidator.validateClientConfig(clientConfig);
|
|
@@ -1198,9 +1248,12 @@ class ThreeXUI {
|
|
|
1198
1248
|
}
|
|
1199
1249
|
|
|
1200
1250
|
/**
|
|
1201
|
-
* Update client traffic limit and expiry by email
|
|
1251
|
+
* Update client traffic limit and expiry by email (raw passthrough — no unit conversion).
|
|
1202
1252
|
* @param {string} email - Client email
|
|
1203
|
-
* @param {Object} trafficConfig - Traffic configuration
|
|
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]
|
|
1204
1257
|
*/
|
|
1205
1258
|
updateClientTraffic(email, trafficConfig) {
|
|
1206
1259
|
return this._request('post', `/panel/api/inbounds/updateClientTraffic/${email}`, trafficConfig);
|
|
@@ -1335,11 +1388,33 @@ class ThreeXUI {
|
|
|
1335
1388
|
|
|
1336
1389
|
/**
|
|
1337
1390
|
* Download database
|
|
1338
|
-
* @returns {Promise<string
|
|
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.
|
|
1339
1394
|
*/
|
|
1340
1395
|
async getDb() {
|
|
1341
|
-
|
|
1342
|
-
//
|
|
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.
|
|
1343
1418
|
if (!response || typeof response !== 'object') {
|
|
1344
1419
|
throw new Error('getDb: Invalid response format');
|
|
1345
1420
|
}
|
|
@@ -1416,16 +1491,19 @@ class ThreeXUI {
|
|
|
1416
1491
|
}
|
|
1417
1492
|
|
|
1418
1493
|
/**
|
|
1419
|
-
* Import database
|
|
1494
|
+
* Import database (destructive - replaces the panel's entire database).
|
|
1420
1495
|
* @param {FormData} formData - FormData containing the database file
|
|
1421
1496
|
*/
|
|
1422
|
-
|
|
1423
|
-
//
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
//
|
|
1428
|
-
|
|
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);
|
|
1429
1507
|
}
|
|
1430
1508
|
|
|
1431
1509
|
// ===========================================
|
|
@@ -1555,7 +1633,7 @@ class ThreeXUI {
|
|
|
1555
1633
|
if (this.sessionManager) {
|
|
1556
1634
|
await this.sessionManager.deleteSession(this.baseURL, newUsername);
|
|
1557
1635
|
}
|
|
1558
|
-
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 });
|
|
1559
1637
|
}
|
|
1560
1638
|
}
|
|
1561
1639
|
|
|
@@ -1604,7 +1682,7 @@ class ThreeXUI {
|
|
|
1604
1682
|
try {
|
|
1605
1683
|
config = JSON.parse(config);
|
|
1606
1684
|
} catch (error) {
|
|
1607
|
-
throw new Error(`updateXrayConfig: Invalid JSON: ${error.message}
|
|
1685
|
+
throw new Error(`updateXrayConfig: Invalid JSON: ${error.message}`, { cause: error });
|
|
1608
1686
|
}
|
|
1609
1687
|
}
|
|
1610
1688
|
// Validate it's an object
|
|
@@ -1623,6 +1701,40 @@ class ThreeXUI {
|
|
|
1623
1701
|
return this._request('post', `/panel/api/xray/warp/${action}`, data);
|
|
1624
1702
|
}
|
|
1625
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
|
+
|
|
1626
1738
|
/**
|
|
1627
1739
|
* Get outbound traffic statistics
|
|
1628
1740
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "3xui-api-client",
|
|
3
|
-
"version": "3.
|
|
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": ">=
|
|
79
|
-
"npm": ">=
|
|
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.
|
|
95
|
+
"axios": "^1.20.0"
|
|
98
96
|
},
|
|
99
97
|
"devDependencies": {
|
|
100
|
-
"
|
|
101
|
-
"
|
|
102
|
-
"eslint": "^
|
|
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",
|