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 +26 -1
- package/README.md +7 -6
- package/index.d.ts +45 -3
- package/index.js +306 -86
- package/package.json +8 -12
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,
|
|
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,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
|
-
/**
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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 (
|
|
450
|
+
if (this._isStaleSessionError(error)) {
|
|
417
451
|
if (this.token) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
640
|
-
|
|
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
|
-
|
|
644
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
subId: convertedOptions.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.
|
|
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
|
|
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
|
-
|
|
1155
|
-
|
|
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
|
|
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
|
-
|
|
1234
|
-
//
|
|
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
|
-
|
|
1315
|
-
//
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
//
|
|
1320
|
-
|
|
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.
|
|
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",
|