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/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 response = await this.api.post('/login', params, {
187
- headers: {
188
- 'Content-Type': 'application/x-www-form-urlencoded'
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
- ...(method.toLowerCase() === 'post' ? { headers: { 'Content-Type': 'application/json' } } : {})
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
- ...(method.toLowerCase() === 'post' ? { headers: { 'Content-Type': 'application/json' } } : {})
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: options.expiryTime || 0,
454
- limitIp: options.limitIp || 0,
455
- totalGB: options.totalGB || 0,
456
- subId: options.subId || this.generateUUID()
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: options.email || existingClients[clientIndex].email,
498
- limitIp: options.limitIp !== undefined ? options.limitIp : existingClients[clientIndex].limitIp,
499
- // totalGB is specified in gigabytes in 3x-ui config; do not convert to bytes
500
- totalGB: options.totalGB !== undefined ? options.totalGB : existingClients[clientIndex].totalGB,
501
- expiryTime: options.expiryDays ? Date.now() + (options.expiryDays * 24 * 60 * 60 * 1000) : existingClients[clientIndex].expiryTime,
502
- enable: options.enable !== undefined ? options.enable : existingClients[clientIndex].enable,
503
- flow: options.flow || existingClients[clientIndex].flow,
504
- encryption: options.encryption || existingClients[clientIndex].encryption || 'none',
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: options.totalGB !== undefined ? `${options.totalGB}GB` : 'unchanged',
531
- expiryDays: options.expiryDays ? `${options.expiryDays} days → ${new Date(processedOptions.expiryTime).toISOString()}` : 'unchanged'
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
- return this._request('post', '/panel/api/clients/add', data);
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
- return this._request('post', `/panel/api/clients/update/${encodeURIComponent(email)}`, data);
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
- setNodeEnable(id) {
840
- return this._request('post', `/panel/api/nodes/setEnable/${encodeURIComponent(id)}`);
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
- * @param {Array} inbounds - Array of inbound configurations
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
- return this._request('post', '/panel/api/inbounds/import', { inbounds });
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 {string} bucket - Time bucket (e.g., 'min', 'hour')
1208
+ * @param {number} bucket - Bucket size in seconds. Must be one of: 2, 30, 60, 120, 180, 300
1031
1209
  */
1032
- getCPUHistory(bucket = 'min') {
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
- return this._request('get', '/panel/api/server/getDb');
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
- getNewEchCert() {
1144
- return this._request('post', '/panel/api/server/getNewEchCert');
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
- * @param {Object} settings - Settings to update
1161
- */
1162
- updateSetting(settings) {
1163
- return this._request('post', '/panel/setting/update', settings);
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
- return this._request('post', '/panel/setting/updateUser', {
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
- return this._request('post', '/panel/xray/update', { content: config });
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