3xui-api-client 3.0.1 → 3.1.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 +20 -0
- package/README.md +300 -286
- package/index.d.ts +366 -49
- package/index.js +343 -56
- package/package.json +4 -8
- package/src/generators/CredentialGenerator.js +21 -18
- package/src/session/SessionManager.js +24 -1
- package/src/utils/ByteConversion.js +132 -0
package/index.js
CHANGED
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
CredentialSecurity,
|
|
9
9
|
ErrorSecurity
|
|
10
10
|
} = require('./src/security/SecurityEnhancer');
|
|
11
|
+
const { convertBandwidthFields } = require('./src/utils/ByteConversion');
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* 3X-UI API Client Library
|
|
@@ -74,10 +75,19 @@ class ThreeXUI {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
this.cookie = null;
|
|
78
|
+
this.csrfToken = null;
|
|
77
79
|
this.options = options;
|
|
78
80
|
this.loginMutex = false; // Add mutex to prevent concurrent logins
|
|
79
81
|
this.loginRetryCount = 0; // Add retry counter
|
|
80
82
|
this.maxLoginRetries = 3; // Maximum login attempts
|
|
83
|
+
// Delay (ms) between forced re-login attempts on 401.
|
|
84
|
+
// Helps avoid tripping fail2ban or 3x-ui login-rate limits,
|
|
85
|
+
// especially in serverless environments where many cold-start
|
|
86
|
+
// instances may race to re-authenticate simultaneously.
|
|
87
|
+
this.loginRetryBackoff = options.loginRetryBackoff !== undefined
|
|
88
|
+
? options.loginRetryBackoff
|
|
89
|
+
: 500;
|
|
90
|
+
this.panelType = options.panelType || 'auto'; // 'modern', 'legacy', or 'auto' for detection
|
|
81
91
|
|
|
82
92
|
// Initialize security monitoring
|
|
83
93
|
this.securityMonitor = new SecurityMonitor({
|
|
@@ -168,6 +178,13 @@ class ThreeXUI {
|
|
|
168
178
|
if (existingSession && existingSession.cookie) {
|
|
169
179
|
this.cookie = existingSession.cookie;
|
|
170
180
|
this.api.defaults.headers.Cookie = this.cookie;
|
|
181
|
+
if (existingSession.csrfToken) {
|
|
182
|
+
this.csrfToken = existingSession.csrfToken;
|
|
183
|
+
}
|
|
184
|
+
// Restore panel type from session if auto-detection was used
|
|
185
|
+
if (existingSession.panelType && this.panelType === 'auto') {
|
|
186
|
+
this.panelType = existingSession.panelType;
|
|
187
|
+
}
|
|
171
188
|
// Reset retry count on successful session restore
|
|
172
189
|
this.loginRetryCount = 0;
|
|
173
190
|
return {
|
|
@@ -183,11 +200,66 @@ class ThreeXUI {
|
|
|
183
200
|
params.append('username', this.username);
|
|
184
201
|
params.append('password', this.password);
|
|
185
202
|
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
203
|
+
const loginHeaders = {
|
|
204
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Newer (React-based) 3x-ui panels require a CSRF token + session
|
|
208
|
+
// cookie obtained from /csrf-token before /login will accept
|
|
209
|
+
// credentials. Older (Vue-based) panels don't expose this
|
|
210
|
+
// endpoint (404), in which case we fall back to the classic
|
|
211
|
+
// direct login below.
|
|
212
|
+
const csrfInfo = await this._getCsrfToken();
|
|
213
|
+
if (csrfInfo) {
|
|
214
|
+
loginHeaders['X-CSRF-Token'] = csrfInfo.token;
|
|
215
|
+
loginHeaders['Cookie'] = csrfInfo.cookie;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Try modern endpoint first (/panel/api/login), then fall back to legacy endpoint (/login)
|
|
219
|
+
// This supports both newer (React-based) and older (Vue-based) 3x-ui panels
|
|
220
|
+
let response;
|
|
221
|
+
let lastError;
|
|
222
|
+
let detectedPanelType = this.panelType;
|
|
223
|
+
|
|
224
|
+
// Determine which endpoint to try based on panelType setting
|
|
225
|
+
const tryModernFirst = this.panelType !== 'legacy';
|
|
226
|
+
const tryLegacyFallback = this.panelType !== 'modern';
|
|
227
|
+
|
|
228
|
+
if (tryModernFirst) {
|
|
229
|
+
// Try modern endpoint first (/panel/api/login)
|
|
230
|
+
try {
|
|
231
|
+
response = await this.api.post('/panel/api/login', params, {
|
|
232
|
+
headers: loginHeaders
|
|
233
|
+
});
|
|
234
|
+
if (this.panelType === 'auto') {
|
|
235
|
+
detectedPanelType = 'modern';
|
|
236
|
+
}
|
|
237
|
+
} catch (modernError) {
|
|
238
|
+
lastError = modernError;
|
|
239
|
+
// If modern endpoint fails and fallback is enabled, try legacy endpoint
|
|
240
|
+
if (tryLegacyFallback) {
|
|
241
|
+
try {
|
|
242
|
+
response = await this.api.post('/login', params, {
|
|
243
|
+
headers: loginHeaders
|
|
244
|
+
});
|
|
245
|
+
if (this.panelType === 'auto') {
|
|
246
|
+
detectedPanelType = 'legacy';
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
// Both endpoints failed, throw the last error
|
|
250
|
+
throw lastError;
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
throw modernError;
|
|
254
|
+
}
|
|
189
255
|
}
|
|
190
|
-
}
|
|
256
|
+
} else {
|
|
257
|
+
// Try legacy endpoint first (/login) if explicitly set
|
|
258
|
+
response = await this.api.post('/login', params, {
|
|
259
|
+
headers: loginHeaders
|
|
260
|
+
});
|
|
261
|
+
detectedPanelType = 'legacy';
|
|
262
|
+
}
|
|
191
263
|
|
|
192
264
|
if (response.data.success) {
|
|
193
265
|
const cookies = response.headers['set-cookie'];
|
|
@@ -195,11 +267,24 @@ class ThreeXUI {
|
|
|
195
267
|
this.cookie = cookies[0].split(';')[0];
|
|
196
268
|
this.api.defaults.headers.Cookie = this.cookie;
|
|
197
269
|
|
|
270
|
+
// On newer panels, every non-safe request (POST/PUT/DELETE)
|
|
271
|
+
// also requires this CSRF token, not just /login.
|
|
272
|
+
if (csrfInfo) {
|
|
273
|
+
this.csrfToken = csrfInfo.token;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Update panel type after successful detection
|
|
277
|
+
if (this.panelType === 'auto') {
|
|
278
|
+
this.panelType = detectedPanelType;
|
|
279
|
+
}
|
|
280
|
+
|
|
198
281
|
// Store session if session manager is available
|
|
199
282
|
if (this.sessionManager) {
|
|
200
283
|
try {
|
|
201
284
|
await this.sessionManager.storeSession(this.baseURL, this.username, {
|
|
202
285
|
cookie: this.cookie,
|
|
286
|
+
csrfToken: this.csrfToken,
|
|
287
|
+
panelType: detectedPanelType, // Store detected panel type for future use
|
|
203
288
|
loginTime: new Date().toISOString()
|
|
204
289
|
});
|
|
205
290
|
} catch (sessionError) {
|
|
@@ -243,6 +328,43 @@ class ThreeXUI {
|
|
|
243
328
|
}
|
|
244
329
|
}
|
|
245
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Detect newer (React-based) 3x-ui panels and obtain the CSRF token +
|
|
333
|
+
* session cookie required by their /login endpoint.
|
|
334
|
+
*
|
|
335
|
+
* Older (Vue-based) panels don't expose /csrf-token and respond with a
|
|
336
|
+
* 404, in which case this returns null so login() falls back to the
|
|
337
|
+
* classic direct login flow.
|
|
338
|
+
*
|
|
339
|
+
* @returns {Promise<{token: string, cookie: string} | null>}
|
|
340
|
+
*/
|
|
341
|
+
async _getCsrfToken() {
|
|
342
|
+
try {
|
|
343
|
+
const response = await this.api.get('/csrf-token', {
|
|
344
|
+
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
345
|
+
validateStatus: (status) => status === 200 || status === 404
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (response.status !== 200 || !response.data || !response.data.success || !response.data.obj) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const cookies = response.headers['set-cookie'];
|
|
353
|
+
if (!cookies || cookies.length === 0) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
token: response.data.obj,
|
|
359
|
+
cookie: cookies[0].split(';')[0]
|
|
360
|
+
};
|
|
361
|
+
} catch {
|
|
362
|
+
// /csrf-token unreachable or errored - treat as an old panel
|
|
363
|
+
// and let login() proceed with the classic flow.
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
246
368
|
/**
|
|
247
369
|
* Logout and clear session
|
|
248
370
|
*/
|
|
@@ -254,6 +376,7 @@ class ThreeXUI {
|
|
|
254
376
|
}
|
|
255
377
|
|
|
256
378
|
this.cookie = null;
|
|
379
|
+
this.csrfToken = null;
|
|
257
380
|
delete this.api.defaults.headers.Cookie;
|
|
258
381
|
|
|
259
382
|
if (this.sessionManager) {
|
|
@@ -269,7 +392,7 @@ class ThreeXUI {
|
|
|
269
392
|
return this._request('post', '/getTwoFactorEnable');
|
|
270
393
|
}
|
|
271
394
|
|
|
272
|
-
async _request(method, path, data = {}) {
|
|
395
|
+
async _request(method, path, data = {}, extraHeaders = {}) {
|
|
273
396
|
// Check session validity first with mutex protection if token is not provided
|
|
274
397
|
if (!this.token) {
|
|
275
398
|
if (!this.loginMutex && this.sessionManager && !await this.sessionManager.hasValidSession(this.baseURL, this.username)) {
|
|
@@ -284,7 +407,7 @@ class ThreeXUI {
|
|
|
284
407
|
method,
|
|
285
408
|
url: path,
|
|
286
409
|
data,
|
|
287
|
-
|
|
410
|
+
headers: { ...this._buildRequestHeaders(method), ...extraHeaders }
|
|
288
411
|
});
|
|
289
412
|
// Reset retry counter on successful request
|
|
290
413
|
this.loginRetryCount = 0;
|
|
@@ -294,14 +417,20 @@ class ThreeXUI {
|
|
|
294
417
|
if (this.token) {
|
|
295
418
|
throw new Error('API Token is invalid or expired. Please check your credentials.');
|
|
296
419
|
}
|
|
297
|
-
// Cookie might have expired, try to login again with retry limit
|
|
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.
|
|
298
424
|
if (this.loginRetryCount < this.maxLoginRetries) {
|
|
425
|
+
if (this.loginRetryBackoff > 0) {
|
|
426
|
+
await new Promise(resolve => setTimeout(resolve, this.loginRetryBackoff));
|
|
427
|
+
}
|
|
299
428
|
await this._ensureAuthenticated(true); // Force refresh
|
|
300
429
|
const response = await this.api.request({
|
|
301
430
|
method,
|
|
302
431
|
url: path,
|
|
303
432
|
data,
|
|
304
|
-
|
|
433
|
+
headers: { ...this._buildRequestHeaders(method), ...extraHeaders }
|
|
305
434
|
});
|
|
306
435
|
return response.data;
|
|
307
436
|
} else {
|
|
@@ -312,6 +441,30 @@ class ThreeXUI {
|
|
|
312
441
|
}
|
|
313
442
|
}
|
|
314
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Build per-request headers, including the JSON content type for POST
|
|
446
|
+
* bodies and (on cookie-authenticated sessions against newer
|
|
447
|
+
* React-based panels) the X-CSRF-Token header required for non-safe
|
|
448
|
+
* HTTP methods.
|
|
449
|
+
*
|
|
450
|
+
* @param {string} method - HTTP method for the request
|
|
451
|
+
* @returns {Object} Headers to merge into the request
|
|
452
|
+
*/
|
|
453
|
+
_buildRequestHeaders(method) {
|
|
454
|
+
const headers = {};
|
|
455
|
+
|
|
456
|
+
if (method.toLowerCase() === 'post') {
|
|
457
|
+
headers['Content-Type'] = 'application/json';
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const isSafeMethod = ['get', 'head', 'options'].includes(method.toLowerCase());
|
|
461
|
+
if (!isSafeMethod && !this.token && this.csrfToken) {
|
|
462
|
+
headers['X-CSRF-Token'] = this.csrfToken;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return headers;
|
|
466
|
+
}
|
|
467
|
+
|
|
315
468
|
/**
|
|
316
469
|
* Ensure authentication with mutex protection
|
|
317
470
|
* @param {boolean} forceRefresh - Force a new login
|
|
@@ -443,6 +596,7 @@ class ThreeXUI {
|
|
|
443
596
|
*/
|
|
444
597
|
async addClientWithCredentials(inboundId, protocol, options = {}) {
|
|
445
598
|
const credentials = this.generateCredentials(protocol, options);
|
|
599
|
+
const convertedOptions = convertBandwidthFields(options);
|
|
446
600
|
|
|
447
601
|
const clientConfig = {
|
|
448
602
|
id: inboundId,
|
|
@@ -450,10 +604,10 @@ class ThreeXUI {
|
|
|
450
604
|
clients: [{
|
|
451
605
|
...credentials,
|
|
452
606
|
enable: true,
|
|
453
|
-
expiryTime:
|
|
454
|
-
limitIp:
|
|
455
|
-
totalGB:
|
|
456
|
-
subId:
|
|
607
|
+
expiryTime: convertedOptions.expiryTime || 0,
|
|
608
|
+
limitIp: convertedOptions.limitIp || 0,
|
|
609
|
+
totalGB: convertedOptions.totalGB || 0,
|
|
610
|
+
subId: convertedOptions.subId || this.generateUUID()
|
|
457
611
|
}]
|
|
458
612
|
})
|
|
459
613
|
};
|
|
@@ -492,17 +646,19 @@ class ThreeXUI {
|
|
|
492
646
|
throw new Error(`Client with ID ${clientId} not found in inbound ${inboundId}`);
|
|
493
647
|
}
|
|
494
648
|
|
|
649
|
+
// Convert bandwidth fields (GB to bytes)
|
|
650
|
+
const convertedOptions = convertBandwidthFields(options);
|
|
651
|
+
|
|
495
652
|
// Convert user-friendly options to API format
|
|
496
653
|
const processedOptions = {
|
|
497
|
-
email:
|
|
498
|
-
limitIp:
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
subId: options.subId || existingClients[clientIndex].subId
|
|
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
|
|
506
662
|
};
|
|
507
663
|
|
|
508
664
|
// Update the specific client while preserving others
|
|
@@ -527,8 +683,8 @@ class ThreeXUI {
|
|
|
527
683
|
...result,
|
|
528
684
|
updatedOptions: processedOptions,
|
|
529
685
|
conversions: {
|
|
530
|
-
totalGB:
|
|
531
|
-
expiryDays:
|
|
686
|
+
totalGB: convertedOptions.totalGB !== undefined ? `${options.totalGB} GB → ${convertedOptions.totalGB} bytes` : 'unchanged',
|
|
687
|
+
expiryDays: convertedOptions.expiryDays ? `${convertedOptions.expiryDays} days → ${new Date(processedOptions.expiryTime).toISOString()}` : 'unchanged'
|
|
532
688
|
}
|
|
533
689
|
};
|
|
534
690
|
} catch (error) {
|
|
@@ -669,7 +825,11 @@ class ThreeXUI {
|
|
|
669
825
|
* @returns {Promise<Object>} Addition response
|
|
670
826
|
*/
|
|
671
827
|
addModernClient(data) {
|
|
672
|
-
|
|
828
|
+
const convertedData = {
|
|
829
|
+
...data,
|
|
830
|
+
client: data.client ? convertBandwidthFields(data.client) : undefined
|
|
831
|
+
};
|
|
832
|
+
return this._request('post', '/panel/api/clients/add', convertedData);
|
|
673
833
|
}
|
|
674
834
|
|
|
675
835
|
/**
|
|
@@ -679,7 +839,8 @@ class ThreeXUI {
|
|
|
679
839
|
* @returns {Promise<Object>} Update response
|
|
680
840
|
*/
|
|
681
841
|
updateModernClient(email, data) {
|
|
682
|
-
|
|
842
|
+
const convertedData = convertBandwidthFields(data);
|
|
843
|
+
return this._request('post', `/panel/api/clients/update/${encodeURIComponent(email)}`, convertedData);
|
|
683
844
|
}
|
|
684
845
|
|
|
685
846
|
/**
|
|
@@ -836,8 +997,15 @@ class ThreeXUI {
|
|
|
836
997
|
return this._request('post', `/panel/api/nodes/del/${encodeURIComponent(id)}`);
|
|
837
998
|
}
|
|
838
999
|
|
|
839
|
-
|
|
840
|
-
|
|
1000
|
+
/**
|
|
1001
|
+
* Enable or disable a node.
|
|
1002
|
+
* @param {number|string} id - Node ID
|
|
1003
|
+
* @param {boolean} [enable] - Desired enabled state. If omitted, the panel toggles the current state.
|
|
1004
|
+
* @returns {Promise<Object>} Result of the operation
|
|
1005
|
+
*/
|
|
1006
|
+
setNodeEnable(id, enable) {
|
|
1007
|
+
const data = typeof enable === 'boolean' ? { enable } : {};
|
|
1008
|
+
return this._request('post', `/panel/api/nodes/setEnable/${encodeURIComponent(id)}`, data);
|
|
841
1009
|
}
|
|
842
1010
|
|
|
843
1011
|
testNode(data) {
|
|
@@ -916,11 +1084,21 @@ class ThreeXUI {
|
|
|
916
1084
|
}
|
|
917
1085
|
|
|
918
1086
|
/**
|
|
919
|
-
* Import inbounds
|
|
920
|
-
*
|
|
1087
|
+
* Import one or more inbounds. The panel endpoint only accepts a single
|
|
1088
|
+
* inbound per request (as a form-encoded `data` field containing the
|
|
1089
|
+
* inbound JSON), so each inbound is sent as a separate request.
|
|
1090
|
+
* @param {Object|Object[]} inbounds - Inbound configuration, or array of configurations
|
|
1091
|
+
* @returns {Promise<Object[]>} One response object per imported inbound
|
|
921
1092
|
*/
|
|
922
1093
|
importInbounds(inbounds) {
|
|
923
|
-
|
|
1094
|
+
const list = Array.isArray(inbounds) ? inbounds : [inbounds];
|
|
1095
|
+
return Promise.all(list.map((inboundConfig) => {
|
|
1096
|
+
const validatedConfig = InputValidator.validateInboundConfig(inboundConfig);
|
|
1097
|
+
const body = new URLSearchParams({ data: JSON.stringify(validatedConfig) }).toString();
|
|
1098
|
+
return this._request('post', '/panel/api/inbounds/import', body, {
|
|
1099
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
1100
|
+
});
|
|
1101
|
+
}));
|
|
924
1102
|
}
|
|
925
1103
|
|
|
926
1104
|
/**
|
|
@@ -1027,10 +1205,10 @@ class ThreeXUI {
|
|
|
1027
1205
|
|
|
1028
1206
|
/**
|
|
1029
1207
|
* Get CPU usage history
|
|
1030
|
-
* @param {
|
|
1208
|
+
* @param {number} bucket - Bucket size in seconds. Must be one of: 2, 30, 60, 120, 180, 300
|
|
1031
1209
|
*/
|
|
1032
|
-
getCPUHistory(bucket =
|
|
1033
|
-
return this._request('get', `/panel/api/server/cpuHistory/${bucket}`);
|
|
1210
|
+
getCPUHistory(bucket = 60) {
|
|
1211
|
+
return this._request('get', `/panel/api/server/cpuHistory/${encodeURIComponent(bucket)}`);
|
|
1034
1212
|
}
|
|
1035
1213
|
|
|
1036
1214
|
/**
|
|
@@ -1049,9 +1227,24 @@ class ThreeXUI {
|
|
|
1049
1227
|
|
|
1050
1228
|
/**
|
|
1051
1229
|
* Download database
|
|
1230
|
+
* @returns {Promise<string>} Raw SQLite database file content as a string (starts with "SQLite format 3 ..."), not a Buffer
|
|
1052
1231
|
*/
|
|
1053
|
-
getDb() {
|
|
1054
|
-
|
|
1232
|
+
async getDb() {
|
|
1233
|
+
const response = await this._request('get', '/panel/api/server/getDb');
|
|
1234
|
+
// Add response validation
|
|
1235
|
+
if (!response || typeof response !== 'object') {
|
|
1236
|
+
throw new Error('getDb: Invalid response format');
|
|
1237
|
+
}
|
|
1238
|
+
if (response.success === false) {
|
|
1239
|
+
throw new Error(`getDb failed: ${response.msg || 'Unknown error'}`);
|
|
1240
|
+
}
|
|
1241
|
+
if (!response.obj) {
|
|
1242
|
+
throw new Error('getDb: Response missing database content');
|
|
1243
|
+
}
|
|
1244
|
+
if (typeof response.obj !== 'string') {
|
|
1245
|
+
throw new Error(`getDb: Expected string content, got ${typeof response.obj}`);
|
|
1246
|
+
}
|
|
1247
|
+
return response;
|
|
1055
1248
|
}
|
|
1056
1249
|
|
|
1057
1250
|
/**
|
|
@@ -1070,9 +1263,20 @@ class ThreeXUI {
|
|
|
1070
1263
|
|
|
1071
1264
|
/**
|
|
1072
1265
|
* Install specific Xray version
|
|
1073
|
-
* @param {string} version - Version to install
|
|
1266
|
+
* @param {string} version - Version to install (e.g., "1.8.0")
|
|
1074
1267
|
*/
|
|
1075
1268
|
installXray(version) {
|
|
1269
|
+
// Validate version parameter
|
|
1270
|
+
if (!version || typeof version !== 'string') {
|
|
1271
|
+
throw new Error('installXray: version must be a non-empty string');
|
|
1272
|
+
}
|
|
1273
|
+
if (version.toLowerCase() === 'latest') {
|
|
1274
|
+
throw new Error(
|
|
1275
|
+
'installXray: "latest" is not a valid version. ' +
|
|
1276
|
+
'Use client.getXrayVersion() to get available versions, ' +
|
|
1277
|
+
'then pass a specific version like "1.8.0"'
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1076
1280
|
return this._request('post', `/panel/api/server/installXray/${version}`);
|
|
1077
1281
|
}
|
|
1078
1282
|
|
|
@@ -1140,8 +1344,25 @@ class ThreeXUI {
|
|
|
1140
1344
|
return this._request('get', '/panel/api/server/getNewVlessEnc');
|
|
1141
1345
|
}
|
|
1142
1346
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1347
|
+
/**
|
|
1348
|
+
* Generate a new ECH (Encrypted Client Hello) certificate.
|
|
1349
|
+
* @param {string} [sni] - Optional SNI to embed in the generated certificate.
|
|
1350
|
+
* Sent as `application/x-www-form-urlencoded` since the panel reads it via `c.PostForm`.
|
|
1351
|
+
* @returns {Promise<Object>} Generated ECH certificate data
|
|
1352
|
+
*/
|
|
1353
|
+
getNewEchCert(sni) {
|
|
1354
|
+
const body = sni ? new URLSearchParams({ sni }).toString() : '';
|
|
1355
|
+
return this._request('post', '/panel/api/server/getNewEchCert', body, {
|
|
1356
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Get the panel's configured web certificate/key file paths.
|
|
1362
|
+
* @returns {Promise<Object>} Web certificate file info
|
|
1363
|
+
*/
|
|
1364
|
+
getWebCertFiles() {
|
|
1365
|
+
return this._request('get', '/panel/api/server/getWebCertFiles');
|
|
1145
1366
|
}
|
|
1146
1367
|
|
|
1147
1368
|
// ===========================================
|
|
@@ -1152,52 +1373,106 @@ class ThreeXUI {
|
|
|
1152
1373
|
* Get all panel settings
|
|
1153
1374
|
*/
|
|
1154
1375
|
getAllSettings() {
|
|
1155
|
-
return this._request('post', '/panel/setting/all');
|
|
1376
|
+
return this._request('post', '/panel/api/setting/all');
|
|
1156
1377
|
}
|
|
1157
1378
|
|
|
1158
1379
|
/**
|
|
1159
1380
|
* Update panel settings
|
|
1160
|
-
*
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1381
|
+
* NOTE: The API requires ALL settings to be sent together, not just the changed ones.
|
|
1382
|
+
* This method automatically fetches current settings, applies your changes, and sends all.
|
|
1383
|
+
* @param {Object} updates - Settings to update (partial object, will be merged with current)
|
|
1384
|
+
* @returns {Promise} Response from /panel/api/setting/update
|
|
1385
|
+
*/
|
|
1386
|
+
async updateSetting(updates) {
|
|
1387
|
+
if (!updates || typeof updates !== 'object') {
|
|
1388
|
+
throw new Error('updates must be an object');
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Fetch current settings
|
|
1392
|
+
const currentResponse = await this.getAllSettings();
|
|
1393
|
+
if (!currentResponse.success || !currentResponse.obj) {
|
|
1394
|
+
throw new Error('Failed to fetch current settings for merge');
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// Merge updates with current settings (updates override current)
|
|
1398
|
+
const mergedSettings = {
|
|
1399
|
+
...currentResponse.obj,
|
|
1400
|
+
...updates
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1403
|
+
// Send all settings together
|
|
1404
|
+
return this._request('post', '/panel/api/setting/update', mergedSettings);
|
|
1164
1405
|
}
|
|
1165
1406
|
|
|
1166
1407
|
/**
|
|
1167
1408
|
* Update admin username and password
|
|
1409
|
+
* ⚠️ CRITICAL: This endpoint changes your login credentials.
|
|
1410
|
+
* After a successful update, the internal client credentials are refreshed
|
|
1411
|
+
* and the session is re-authenticated with new credentials.
|
|
1168
1412
|
* @param {string} oldUsername - Current username
|
|
1169
1413
|
* @param {string} oldPassword - Current password
|
|
1170
1414
|
* @param {string} newUsername - New username
|
|
1171
1415
|
* @param {string} newPassword - New password
|
|
1172
1416
|
*/
|
|
1173
|
-
updateUser(oldUsername, oldPassword, newUsername, newPassword) {
|
|
1174
|
-
|
|
1417
|
+
async updateUser(oldUsername, oldPassword, newUsername, newPassword) {
|
|
1418
|
+
const response = await this._request('post', '/panel/api/setting/updateUser', {
|
|
1175
1419
|
oldUsername,
|
|
1176
1420
|
oldPassword,
|
|
1177
1421
|
newUsername,
|
|
1178
1422
|
newPassword
|
|
1179
1423
|
});
|
|
1424
|
+
|
|
1425
|
+
if (response && response.success) {
|
|
1426
|
+
// Update internal credentials to match new credentials
|
|
1427
|
+
const oldUsername_ = this.username;
|
|
1428
|
+
this.username = newUsername;
|
|
1429
|
+
this.password = newPassword;
|
|
1430
|
+
|
|
1431
|
+
// Clear old session from session manager
|
|
1432
|
+
if (this.sessionManager) {
|
|
1433
|
+
try {
|
|
1434
|
+
await this.sessionManager.deleteSession(this.baseURL, oldUsername_);
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
console.warn('[3xui-api-client] Could not delete old session:', error.message);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// Re-authenticate with new credentials to ensure session is valid
|
|
1441
|
+
try {
|
|
1442
|
+
await this.login(true); // Force refresh with new credentials
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
// If login fails after credential change, restore old credentials and re-throw
|
|
1445
|
+
this.username = oldUsername;
|
|
1446
|
+
this.password = oldPassword;
|
|
1447
|
+
if (this.sessionManager) {
|
|
1448
|
+
await this.sessionManager.deleteSession(this.baseURL, newUsername);
|
|
1449
|
+
}
|
|
1450
|
+
throw new Error(`Credential update succeeded but re-authentication failed: ${error.message}`);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
return response;
|
|
1180
1455
|
}
|
|
1181
1456
|
|
|
1182
1457
|
/**
|
|
1183
1458
|
* Restart the panel
|
|
1184
1459
|
*/
|
|
1185
1460
|
restartPanel() {
|
|
1186
|
-
return this._request('post', '/panel/setting/restartPanel');
|
|
1461
|
+
return this._request('post', '/panel/api/setting/restartPanel');
|
|
1187
1462
|
}
|
|
1188
1463
|
|
|
1189
1464
|
/**
|
|
1190
1465
|
* Get default settings
|
|
1191
1466
|
*/
|
|
1192
1467
|
getDefaultSettings() {
|
|
1193
|
-
return this._request('post', '/panel/setting/defaultSettings');
|
|
1468
|
+
return this._request('post', '/panel/api/setting/defaultSettings');
|
|
1194
1469
|
}
|
|
1195
1470
|
|
|
1196
1471
|
/**
|
|
1197
1472
|
* Get default Xray JSON config
|
|
1198
1473
|
*/
|
|
1199
1474
|
getDefaultJsonConfig() {
|
|
1200
|
-
return this._request('get', '/panel/setting/getDefaultJsonConfig');
|
|
1475
|
+
return this._request('get', '/panel/api/setting/getDefaultJsonConfig');
|
|
1201
1476
|
}
|
|
1202
1477
|
|
|
1203
1478
|
// ===========================================
|
|
@@ -1208,45 +1483,57 @@ class ThreeXUI {
|
|
|
1208
1483
|
* Get Xray configuration
|
|
1209
1484
|
*/
|
|
1210
1485
|
getXrayConfig() {
|
|
1211
|
-
return this._request('post', '/panel/xray/');
|
|
1486
|
+
return this._request('post', '/panel/api/xray/');
|
|
1212
1487
|
}
|
|
1213
1488
|
|
|
1214
1489
|
/**
|
|
1215
1490
|
* Update Xray configuration
|
|
1216
|
-
* @param {string} config - Xray configuration content
|
|
1491
|
+
* @param {string|object} config - Xray configuration content (JSON string or object)
|
|
1217
1492
|
*/
|
|
1218
1493
|
updateXrayConfig(config) {
|
|
1219
|
-
|
|
1494
|
+
// Parse JSON string to object before sending
|
|
1495
|
+
if (typeof config === 'string') {
|
|
1496
|
+
try {
|
|
1497
|
+
config = JSON.parse(config);
|
|
1498
|
+
} catch (error) {
|
|
1499
|
+
throw new Error(`updateXrayConfig: Invalid JSON: ${error.message}`);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
// Validate it's an object
|
|
1503
|
+
if (!config || typeof config !== 'object') {
|
|
1504
|
+
throw new Error('updateXrayConfig: config must be a valid JSON object or string');
|
|
1505
|
+
}
|
|
1506
|
+
return this._request('post', '/panel/api/xray/update', { content: JSON.stringify(config) });
|
|
1220
1507
|
}
|
|
1221
1508
|
|
|
1222
1509
|
/**
|
|
1223
1510
|
* Manage WARP
|
|
1224
|
-
* @param {string} action - Action to perform (data, del, config, reg, license)
|
|
1511
|
+
* @param {string} action - Action to perform (data, del, config, reg, changeIp, license, interval)
|
|
1225
1512
|
* @param {Object} [data] - Additional data for the action
|
|
1226
1513
|
*/
|
|
1227
1514
|
manageWarp(action, data = {}) {
|
|
1228
|
-
return this._request('post', `/panel/xray/warp/${action}`, data);
|
|
1515
|
+
return this._request('post', `/panel/api/xray/warp/${action}`, data);
|
|
1229
1516
|
}
|
|
1230
1517
|
|
|
1231
1518
|
/**
|
|
1232
1519
|
* Get outbound traffic statistics
|
|
1233
1520
|
*/
|
|
1234
1521
|
getOutboundsTraffic() {
|
|
1235
|
-
return this._request('get', '/panel/xray/getOutboundsTraffic');
|
|
1522
|
+
return this._request('get', '/panel/api/xray/getOutboundsTraffic');
|
|
1236
1523
|
}
|
|
1237
1524
|
|
|
1238
1525
|
/**
|
|
1239
1526
|
* Reset outbound traffic statistics
|
|
1240
1527
|
*/
|
|
1241
1528
|
resetOutboundsTraffic() {
|
|
1242
|
-
return this._request('post', '/panel/xray/resetOutboundsTraffic');
|
|
1529
|
+
return this._request('post', '/panel/api/xray/resetOutboundsTraffic');
|
|
1243
1530
|
}
|
|
1244
1531
|
|
|
1245
1532
|
/**
|
|
1246
1533
|
* Get Xray execution result
|
|
1247
1534
|
*/
|
|
1248
1535
|
getXrayResult() {
|
|
1249
|
-
return this._request('get', '/panel/xray/getXrayResult');
|
|
1536
|
+
return this._request('get', '/panel/api/xray/getXrayResult');
|
|
1250
1537
|
}
|
|
1251
1538
|
|
|
1252
1539
|
// Security Methods
|