3xui-api-client 2.1.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
@@ -32,16 +33,10 @@ class ThreeXUI {
32
33
  * @param {number} options.timeout - Request timeout in milliseconds (default: 30000)
33
34
  * @throws {Error} If baseURL, username, or password is missing
34
35
  */
35
- constructor(baseURL, username, password, options = {}) {
36
+ constructor(baseURL, usernameOrOptions, password, options = {}) {
36
37
  if (!baseURL) {
37
38
  throw new Error('baseURL is required');
38
39
  }
39
- if (!username) {
40
- throw new Error('username is required');
41
- }
42
- if (!password) {
43
- throw new Error('password is required');
44
- }
45
40
 
46
41
  // Apply security validations
47
42
  this.baseURL = InputValidator.validateURL(baseURL);
@@ -53,13 +48,46 @@ class ThreeXUI {
53
48
  this.baseURL = this.baseURL.replace(/\/panel\/?$/, '');
54
49
  }
55
50
 
56
- this.username = InputValidator.validateUsername(username);
57
- this.password = InputValidator.validatePassword(password);
51
+ // Object containing configs as 2nd parameter
52
+ if (typeof usernameOrOptions === 'object' && usernameOrOptions !== null) {
53
+ options = usernameOrOptions;
54
+ this.username = options.username;
55
+ this.password = options.password;
56
+ } else {
57
+ this.username = usernameOrOptions;
58
+ this.password = password;
59
+ }
60
+
61
+ this.token = options.token || options.apiToken || null;
62
+
63
+ if (this.token) {
64
+ this.username = this.username || 'token-auth'; // prevent missing args error
65
+ this.password = this.password || 'token-auth';
66
+ } else {
67
+ if (!this.username) {
68
+ throw new Error('username is required');
69
+ }
70
+ if (!this.password) {
71
+ throw new Error('password is required');
72
+ }
73
+ this.username = InputValidator.validateUsername(this.username);
74
+ this.password = InputValidator.validatePassword(this.password);
75
+ }
76
+
58
77
  this.cookie = null;
78
+ this.csrfToken = null;
59
79
  this.options = options;
60
80
  this.loginMutex = false; // Add mutex to prevent concurrent logins
61
81
  this.loginRetryCount = 0; // Add retry counter
62
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
63
91
 
64
92
  // Initialize security monitoring
65
93
  this.securityMonitor = new SecurityMonitor({
@@ -81,15 +109,21 @@ class ThreeXUI {
81
109
  }
82
110
 
83
111
  // Create axios instance with security best practices
112
+ const secureHeaders = SecureHeaders.getSecureHeaders({
113
+ userAgent: options.userAgent || '3xui-api-client/2.0.0 (Security-Enhanced)',
114
+ enableCSP: options.enableCSP || false
115
+ });
116
+
117
+ if (this.token) {
118
+ secureHeaders['Authorization'] = `Bearer ${this.token}`;
119
+ }
120
+
84
121
  this.api = axios.create({
85
122
  baseURL: this.baseURL,
86
123
  timeout: options.timeout || 30000, // 30 second timeout
87
124
  maxRedirects: 5,
88
125
  validateStatus: (status) => status >= 200 && status < 300,
89
- headers: SecureHeaders.getSecureHeaders({
90
- userAgent: options.userAgent || '3xui-api-client/2.0.0 (Security-Enhanced)',
91
- enableCSP: options.enableCSP || false
92
- })
126
+ headers: secureHeaders
93
127
  });
94
128
 
95
129
  // Add request interceptor for security headers
@@ -120,6 +154,14 @@ class ThreeXUI {
120
154
  * @returns {Object} Login response
121
155
  */
122
156
  async login(forceRefresh = false) {
157
+ // If API token is configured, skip cookie auth
158
+ if (this.token) {
159
+ return {
160
+ success: true,
161
+ message: 'Authenticated successfully using API Token'
162
+ };
163
+ }
164
+
123
165
  // Check rate limiting first
124
166
  const identifier = CredentialSecurity.hashForLogging(this.username);
125
167
  if (!this.securityMonitor.checkRateLimit(identifier, 'login')) {
@@ -136,6 +178,13 @@ class ThreeXUI {
136
178
  if (existingSession && existingSession.cookie) {
137
179
  this.cookie = existingSession.cookie;
138
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
+ }
139
188
  // Reset retry count on successful session restore
140
189
  this.loginRetryCount = 0;
141
190
  return {
@@ -151,11 +200,66 @@ class ThreeXUI {
151
200
  params.append('username', this.username);
152
201
  params.append('password', this.password);
153
202
 
154
- const response = await this.api.post('/login', params, {
155
- headers: {
156
- '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
+ }
157
255
  }
158
- });
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
+ }
159
263
 
160
264
  if (response.data.success) {
161
265
  const cookies = response.headers['set-cookie'];
@@ -163,11 +267,24 @@ class ThreeXUI {
163
267
  this.cookie = cookies[0].split(';')[0];
164
268
  this.api.defaults.headers.Cookie = this.cookie;
165
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
+
166
281
  // Store session if session manager is available
167
282
  if (this.sessionManager) {
168
283
  try {
169
284
  await this.sessionManager.storeSession(this.baseURL, this.username, {
170
285
  cookie: this.cookie,
286
+ csrfToken: this.csrfToken,
287
+ panelType: detectedPanelType, // Store detected panel type for future use
171
288
  loginTime: new Date().toISOString()
172
289
  });
173
290
  } catch (sessionError) {
@@ -211,6 +328,43 @@ class ThreeXUI {
211
328
  }
212
329
  }
213
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
+
214
368
  /**
215
369
  * Logout and clear session
216
370
  */
@@ -222,6 +376,7 @@ class ThreeXUI {
222
376
  }
223
377
 
224
378
  this.cookie = null;
379
+ this.csrfToken = null;
225
380
  delete this.api.defaults.headers.Cookie;
226
381
 
227
382
  if (this.sessionManager) {
@@ -237,12 +392,14 @@ class ThreeXUI {
237
392
  return this._request('post', '/getTwoFactorEnable');
238
393
  }
239
394
 
240
- async _request(method, path, data = {}) {
241
- // Check session validity first with mutex protection
242
- if (!this.loginMutex && this.sessionManager && !await this.sessionManager.hasValidSession(this.baseURL, this.username)) {
243
- await this._ensureAuthenticated();
244
- } else if (!this.loginMutex && !this.cookie) {
245
- await this._ensureAuthenticated();
395
+ async _request(method, path, data = {}, extraHeaders = {}) {
396
+ // Check session validity first with mutex protection if token is not provided
397
+ if (!this.token) {
398
+ if (!this.loginMutex && this.sessionManager && !await this.sessionManager.hasValidSession(this.baseURL, this.username)) {
399
+ await this._ensureAuthenticated();
400
+ } else if (!this.loginMutex && !this.cookie) {
401
+ await this._ensureAuthenticated();
402
+ }
246
403
  }
247
404
 
248
405
  try {
@@ -250,21 +407,30 @@ class ThreeXUI {
250
407
  method,
251
408
  url: path,
252
409
  data,
253
- ...(method.toLowerCase() === 'post' ? { headers: { 'Content-Type': 'application/json' } } : {})
410
+ headers: { ...this._buildRequestHeaders(method), ...extraHeaders }
254
411
  });
255
412
  // Reset retry counter on successful request
256
413
  this.loginRetryCount = 0;
257
414
  return response.data;
258
415
  } catch (error) {
259
416
  if (error.response && error.response.status === 401) {
260
- // Cookie might have expired, try to login again with retry limit
417
+ if (this.token) {
418
+ throw new Error('API Token is invalid or expired. Please check your credentials.');
419
+ }
420
+ // Cookie might have expired, try to login again with retry limit.
421
+ // A configurable backoff delay is applied before each re-login to
422
+ // avoid burning through the retry budget or tripping login-rate limits
423
+ // (e.g. fail2ban) when many instances race to re-authenticate.
261
424
  if (this.loginRetryCount < this.maxLoginRetries) {
425
+ if (this.loginRetryBackoff > 0) {
426
+ await new Promise(resolve => setTimeout(resolve, this.loginRetryBackoff));
427
+ }
262
428
  await this._ensureAuthenticated(true); // Force refresh
263
429
  const response = await this.api.request({
264
430
  method,
265
431
  url: path,
266
432
  data,
267
- ...(method.toLowerCase() === 'post' ? { headers: { 'Content-Type': 'application/json' } } : {})
433
+ headers: { ...this._buildRequestHeaders(method), ...extraHeaders }
268
434
  });
269
435
  return response.data;
270
436
  } else {
@@ -275,6 +441,30 @@ class ThreeXUI {
275
441
  }
276
442
  }
277
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
+
278
468
  /**
279
469
  * Ensure authentication with mutex protection
280
470
  * @param {boolean} forceRefresh - Force a new login
@@ -406,6 +596,7 @@ class ThreeXUI {
406
596
  */
407
597
  async addClientWithCredentials(inboundId, protocol, options = {}) {
408
598
  const credentials = this.generateCredentials(protocol, options);
599
+ const convertedOptions = convertBandwidthFields(options);
409
600
 
410
601
  const clientConfig = {
411
602
  id: inboundId,
@@ -413,10 +604,10 @@ class ThreeXUI {
413
604
  clients: [{
414
605
  ...credentials,
415
606
  enable: true,
416
- expiryTime: options.expiryTime || 0,
417
- limitIp: options.limitIp || 0,
418
- totalGB: options.totalGB || 0,
419
- 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()
420
611
  }]
421
612
  })
422
613
  };
@@ -455,17 +646,19 @@ class ThreeXUI {
455
646
  throw new Error(`Client with ID ${clientId} not found in inbound ${inboundId}`);
456
647
  }
457
648
 
649
+ // Convert bandwidth fields (GB to bytes)
650
+ const convertedOptions = convertBandwidthFields(options);
651
+
458
652
  // Convert user-friendly options to API format
459
653
  const processedOptions = {
460
- email: options.email || existingClients[clientIndex].email,
461
- limitIp: options.limitIp !== undefined ? options.limitIp : existingClients[clientIndex].limitIp,
462
- // totalGB is specified in gigabytes in 3x-ui config; do not convert to bytes
463
- totalGB: options.totalGB !== undefined ? options.totalGB : existingClients[clientIndex].totalGB,
464
- expiryTime: options.expiryDays ? Date.now() + (options.expiryDays * 24 * 60 * 60 * 1000) : existingClients[clientIndex].expiryTime,
465
- enable: options.enable !== undefined ? options.enable : existingClients[clientIndex].enable,
466
- flow: options.flow || existingClients[clientIndex].flow,
467
- encryption: options.encryption || existingClients[clientIndex].encryption || 'none',
468
- 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
469
662
  };
470
663
 
471
664
  // Update the specific client while preserving others
@@ -490,8 +683,8 @@ class ThreeXUI {
490
683
  ...result,
491
684
  updatedOptions: processedOptions,
492
685
  conversions: {
493
- totalGB: options.totalGB !== undefined ? `${options.totalGB}GB` : 'unchanged',
494
- 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'
495
688
  }
496
689
  };
497
690
  } catch (error) {
@@ -533,12 +726,334 @@ class ThreeXUI {
533
726
  * @returns {boolean} Session validity
534
727
  */
535
728
  async isSessionValid() {
729
+ if (this.token) {
730
+ return true;
731
+ }
536
732
  if (this.sessionManager) {
537
733
  return await this.sessionManager.hasValidSession(this.baseURL, this.username);
538
734
  }
539
735
  return !!this.cookie;
540
736
  }
541
737
 
738
+ // ===========================================
739
+ // MODERN API METHODS (3X-UI >= 2.x)
740
+ // ===========================================
741
+
742
+ // --- Clients ---
743
+
744
+ /**
745
+ * Get list of all clients
746
+ * @returns {Promise<Object>} Formatted list of all clients
747
+ */
748
+ getClients() {
749
+ return this._request('get', '/panel/api/clients/list');
750
+ }
751
+
752
+ /**
753
+ * Get paginated list of clients
754
+ * @param {Object} params - Pagination parameters
755
+ * @param {number} params.page - Page number (default: 1)
756
+ * @param {number} params.size - Items per page (default: 10)
757
+ * @param {string} params.sort - Sort field (e.g., 'email', 'expireTime')
758
+ * @param {string} params.order - Sort order ('asc' or 'desc')
759
+ * @param {string} params.email - Filter by email
760
+ * @returns {Promise<Object>} Paginated clients
761
+ */
762
+ getPagedClients(params = {}) {
763
+ const queryParams = new URLSearchParams();
764
+ if (params.page !== undefined) {
765
+ queryParams.append('page', params.page);
766
+ }
767
+ if (params.size !== undefined) {
768
+ queryParams.append('size', params.size);
769
+ }
770
+ if (params.sort !== undefined) {
771
+ queryParams.append('sort', params.sort);
772
+ }
773
+ if (params.order !== undefined) {
774
+ queryParams.append('order', params.order);
775
+ }
776
+ if (params.email !== undefined) {
777
+ queryParams.append('email', params.email);
778
+ }
779
+
780
+ const queryString = queryParams.toString();
781
+ const url = queryString ? `/panel/api/clients/list/paged?${queryString}` : '/panel/api/clients/list/paged';
782
+
783
+ return this._request('get', url);
784
+ }
785
+
786
+ /**
787
+ * Get client by email
788
+ * @param {string} email - Exact client email address
789
+ * @returns {Promise<Object>} Client metadata
790
+ */
791
+ getClient(email) {
792
+ return this._request('get', `/panel/api/clients/get/${encodeURIComponent(email)}`);
793
+ }
794
+
795
+ /**
796
+ * Get client traffic by email
797
+ * @param {string} email - Exact client email
798
+ * @returns {Promise<Object>} Client traffic details
799
+ */
800
+ getClientTraffic(email) {
801
+ return this._request('get', `/panel/api/clients/traffic/${encodeURIComponent(email)}`);
802
+ }
803
+
804
+ /**
805
+ * Get subscription links for a client by subscription ID
806
+ * @param {string} subId - Subscription ID (UUID)
807
+ * @returns {Promise<Object>} Subscription details and links
808
+ */
809
+ getSubLinks(subId) {
810
+ return this._request('get', `/panel/api/clients/subLinks/${encodeURIComponent(subId)}`);
811
+ }
812
+
813
+ /**
814
+ * Get generic client links by email
815
+ * @param {string} email - Exact client email
816
+ * @returns {Promise<Object>} Link strings
817
+ */
818
+ getClientLinks(email) {
819
+ return this._request('get', `/panel/api/clients/links/${encodeURIComponent(email)}`);
820
+ }
821
+
822
+ /**
823
+ * Add a new client via Modern API
824
+ * @param {Object} data - Client payload
825
+ * @returns {Promise<Object>} Addition response
826
+ */
827
+ addModernClient(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);
833
+ }
834
+
835
+ /**
836
+ * Update client by email via Modern API
837
+ * @param {string} email - Exact client email
838
+ * @param {Object} data - Update payload
839
+ * @returns {Promise<Object>} Update response
840
+ */
841
+ updateModernClient(email, data) {
842
+ const convertedData = convertBandwidthFields(data);
843
+ return this._request('post', `/panel/api/clients/update/${encodeURIComponent(email)}`, convertedData);
844
+ }
845
+
846
+ /**
847
+ * Delete client by email via Modern API
848
+ * @param {string} email - Exact client email
849
+ * @returns {Promise<Object>} Delete response
850
+ */
851
+ deleteModernClient(email) {
852
+ return this._request('post', `/panel/api/clients/del/${encodeURIComponent(email)}`);
853
+ }
854
+
855
+ attachClientToInbounds(email, data) {
856
+ return this._request('post', `/panel/api/clients/${encodeURIComponent(email)}/attach`, data);
857
+ }
858
+
859
+ detachClientFromInbounds(email, data) {
860
+ return this._request('post', `/panel/api/clients/${encodeURIComponent(email)}/detach`, data);
861
+ }
862
+
863
+ resetAllModernClientTraffics() {
864
+ return this._request('post', '/panel/api/clients/resetAllTraffics');
865
+ }
866
+
867
+ deleteDepletedModernClients() {
868
+ return this._request('post', '/panel/api/clients/delDepleted');
869
+ }
870
+
871
+ bulkAdjustModernClients(data) {
872
+ return this._request('post', '/panel/api/clients/bulkAdjust', data);
873
+ }
874
+
875
+ bulkDeleteModernClients(data) {
876
+ return this._request('post', '/panel/api/clients/bulkDel', data);
877
+ }
878
+
879
+ bulkCreateModernClients(data) {
880
+ return this._request('post', '/panel/api/clients/bulkCreate', data);
881
+ }
882
+
883
+ bulkAttachModernClients(data) {
884
+ return this._request('post', '/panel/api/clients/bulkAttach', data);
885
+ }
886
+
887
+ bulkDetachModernClients(data) {
888
+ return this._request('post', '/panel/api/clients/bulkDetach', data);
889
+ }
890
+
891
+ bulkResetTrafficModernClients(data) {
892
+ return this._request('post', '/panel/api/clients/bulkResetTraffic', data);
893
+ }
894
+
895
+ resetModernClientTrafficByEmail(email) {
896
+ return this._request('post', `/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`);
897
+ }
898
+
899
+ updateModernClientTrafficByEmail(email, data) {
900
+ return this._request('post', `/panel/api/clients/updateTraffic/${encodeURIComponent(email)}`, data);
901
+ }
902
+
903
+ getModernClientIps(email) {
904
+ return this._request('post', `/panel/api/clients/ips/${encodeURIComponent(email)}`);
905
+ }
906
+
907
+ clearModernClientIps(email) {
908
+ return this._request('post', `/panel/api/clients/clearIps/${encodeURIComponent(email)}`);
909
+ }
910
+
911
+ getOnlines() {
912
+ return this._request('post', '/panel/api/clients/onlines');
913
+ }
914
+
915
+ getModernLastOnline() {
916
+ return this._request('post', '/panel/api/clients/lastOnline');
917
+ }
918
+
919
+ // --- Client Groups ---
920
+
921
+ /**
922
+ * Get list of all client groups
923
+ * @returns {Promise<Object>} List of client groups
924
+ */
925
+ getGroups() {
926
+ return this._request('get', '/panel/api/clients/groups');
927
+ }
928
+
929
+ /**
930
+ * Get list of emails belonging to a specific group
931
+ * @param {string} groupName - The name of the group
932
+ * @returns {Promise<Object>} List of emails in the group
933
+ */
934
+ getGroupEmails(groupName) {
935
+ return this._request('get', `/panel/api/clients/groups/${encodeURIComponent(groupName)}/emails`);
936
+ }
937
+
938
+ createGroup(data) {
939
+ return this._request('post', '/panel/api/clients/groups/create', data);
940
+ }
941
+
942
+ renameGroup(data) {
943
+ return this._request('post', '/panel/api/clients/groups/rename', data);
944
+ }
945
+
946
+ deleteGroup(data) {
947
+ return this._request('post', '/panel/api/clients/groups/delete', data);
948
+ }
949
+
950
+ bulkAddGroups(data) {
951
+ return this._request('post', '/panel/api/clients/groups/bulkAdd', data);
952
+ }
953
+
954
+ bulkRemoveGroups(data) {
955
+ return this._request('post', '/panel/api/clients/groups/bulkRemove', data);
956
+ }
957
+
958
+ // --- Nodes ---
959
+
960
+ /**
961
+ * Get list of all nodes
962
+ * @returns {Promise<Object>} List of nodes
963
+ */
964
+ getNodes() {
965
+ return this._request('get', '/panel/api/nodes/list');
966
+ }
967
+
968
+ /**
969
+ * Get specific node by ID
970
+ * @param {number|string} id - Node ID
971
+ * @returns {Promise<Object>} Node details
972
+ */
973
+ getNode(id) {
974
+ return this._request('get', `/panel/api/nodes/get/${encodeURIComponent(id)}`);
975
+ }
976
+
977
+ /**
978
+ * Get history metrics for a node
979
+ * @param {number|string} id - Node ID
980
+ * @param {string} metric - Metric name (e.g., 'cpu', 'memory')
981
+ * @param {string} bucket - Time bucket size
982
+ * @returns {Promise<Object>} Node history data
983
+ */
984
+ getNodeHistory(id, metric, bucket) {
985
+ return this._request('get', `/panel/api/nodes/history/${encodeURIComponent(id)}/${encodeURIComponent(metric)}/${encodeURIComponent(bucket)}`);
986
+ }
987
+
988
+ addNode(data) {
989
+ return this._request('post', '/panel/api/nodes/add', data);
990
+ }
991
+
992
+ updateNode(id, data) {
993
+ return this._request('post', `/panel/api/nodes/update/${encodeURIComponent(id)}`, data);
994
+ }
995
+
996
+ deleteNode(id) {
997
+ return this._request('post', `/panel/api/nodes/del/${encodeURIComponent(id)}`);
998
+ }
999
+
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);
1009
+ }
1010
+
1011
+ testNode(data) {
1012
+ return this._request('post', '/panel/api/nodes/test', data);
1013
+ }
1014
+
1015
+ probeNode(id) {
1016
+ return this._request('post', `/panel/api/nodes/probe/${encodeURIComponent(id)}`);
1017
+ }
1018
+
1019
+ // --- Custom Geo ---
1020
+
1021
+ /**
1022
+ * Get list of custom geo sites/ips
1023
+ * @returns {Promise<Object>} List of custom geos
1024
+ */
1025
+ getCustomGeos() {
1026
+ return this._request('get', '/panel/api/custom-geo/list');
1027
+ }
1028
+
1029
+ /**
1030
+ * Get aliases for custom geos
1031
+ * @returns {Promise<Object>} Custom geo aliases
1032
+ */
1033
+ getGeoAliases() {
1034
+ return this._request('get', '/panel/api/custom-geo/aliases');
1035
+ }
1036
+
1037
+ addCustomGeo(data) {
1038
+ return this._request('post', '/panel/api/custom-geo/add', data);
1039
+ }
1040
+
1041
+ updateCustomGeo(id, data) {
1042
+ return this._request('post', `/panel/api/custom-geo/update/${encodeURIComponent(id)}`, data);
1043
+ }
1044
+
1045
+ deleteCustomGeo(id) {
1046
+ return this._request('post', `/panel/api/custom-geo/delete/${encodeURIComponent(id)}`);
1047
+ }
1048
+
1049
+ downloadCustomGeo(id) {
1050
+ return this._request('post', `/panel/api/custom-geo/download/${encodeURIComponent(id)}`);
1051
+ }
1052
+
1053
+ updateAllCustomGeo() {
1054
+ return this._request('post', '/panel/api/custom-geo/update-all');
1055
+ }
1056
+
542
1057
  // ===========================================
543
1058
  // ORIGINAL API METHODS (UNCHANGED)
544
1059
  // ===========================================
@@ -569,11 +1084,21 @@ class ThreeXUI {
569
1084
  }
570
1085
 
571
1086
  /**
572
- * Import inbounds
573
- * @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
574
1092
  */
575
1093
  importInbounds(inbounds) {
576
- 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
+ }));
577
1102
  }
578
1103
 
579
1104
  /**
@@ -680,10 +1205,10 @@ class ThreeXUI {
680
1205
 
681
1206
  /**
682
1207
  * Get CPU usage history
683
- * @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
684
1209
  */
685
- getCPUHistory(bucket = 'min') {
686
- return this._request('get', `/panel/api/server/cpuHistory/${bucket}`);
1210
+ getCPUHistory(bucket = 60) {
1211
+ return this._request('get', `/panel/api/server/cpuHistory/${encodeURIComponent(bucket)}`);
687
1212
  }
688
1213
 
689
1214
  /**
@@ -702,9 +1227,24 @@ class ThreeXUI {
702
1227
 
703
1228
  /**
704
1229
  * Download database
1230
+ * @returns {Promise<string>} Raw SQLite database file content as a string (starts with "SQLite format 3 ..."), not a Buffer
705
1231
  */
706
- getDb() {
707
- 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;
708
1248
  }
709
1249
 
710
1250
  /**
@@ -723,9 +1263,20 @@ class ThreeXUI {
723
1263
 
724
1264
  /**
725
1265
  * Install specific Xray version
726
- * @param {string} version - Version to install
1266
+ * @param {string} version - Version to install (e.g., "1.8.0")
727
1267
  */
728
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
+ }
729
1280
  return this._request('post', `/panel/api/server/installXray/${version}`);
730
1281
  }
731
1282
 
@@ -793,8 +1344,25 @@ class ThreeXUI {
793
1344
  return this._request('get', '/panel/api/server/getNewVlessEnc');
794
1345
  }
795
1346
 
796
- getNewEchCert() {
797
- 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');
798
1366
  }
799
1367
 
800
1368
  // ===========================================
@@ -805,52 +1373,106 @@ class ThreeXUI {
805
1373
  * Get all panel settings
806
1374
  */
807
1375
  getAllSettings() {
808
- return this._request('post', '/panel/setting/all');
1376
+ return this._request('post', '/panel/api/setting/all');
809
1377
  }
810
1378
 
811
1379
  /**
812
1380
  * Update panel settings
813
- * @param {Object} settings - Settings to update
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
814
1385
  */
815
- updateSetting(settings) {
816
- return this._request('post', '/panel/setting/update', settings);
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);
817
1405
  }
818
1406
 
819
1407
  /**
820
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.
821
1412
  * @param {string} oldUsername - Current username
822
1413
  * @param {string} oldPassword - Current password
823
1414
  * @param {string} newUsername - New username
824
1415
  * @param {string} newPassword - New password
825
1416
  */
826
- updateUser(oldUsername, oldPassword, newUsername, newPassword) {
827
- 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', {
828
1419
  oldUsername,
829
1420
  oldPassword,
830
1421
  newUsername,
831
1422
  newPassword
832
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;
833
1455
  }
834
1456
 
835
1457
  /**
836
1458
  * Restart the panel
837
1459
  */
838
1460
  restartPanel() {
839
- return this._request('post', '/panel/setting/restartPanel');
1461
+ return this._request('post', '/panel/api/setting/restartPanel');
840
1462
  }
841
1463
 
842
1464
  /**
843
1465
  * Get default settings
844
1466
  */
845
1467
  getDefaultSettings() {
846
- return this._request('post', '/panel/setting/defaultSettings');
1468
+ return this._request('post', '/panel/api/setting/defaultSettings');
847
1469
  }
848
1470
 
849
1471
  /**
850
1472
  * Get default Xray JSON config
851
1473
  */
852
1474
  getDefaultJsonConfig() {
853
- return this._request('get', '/panel/setting/getDefaultJsonConfig');
1475
+ return this._request('get', '/panel/api/setting/getDefaultJsonConfig');
854
1476
  }
855
1477
 
856
1478
  // ===========================================
@@ -861,45 +1483,57 @@ class ThreeXUI {
861
1483
  * Get Xray configuration
862
1484
  */
863
1485
  getXrayConfig() {
864
- return this._request('post', '/panel/xray/');
1486
+ return this._request('post', '/panel/api/xray/');
865
1487
  }
866
1488
 
867
1489
  /**
868
1490
  * Update Xray configuration
869
- * @param {string} config - Xray configuration content
1491
+ * @param {string|object} config - Xray configuration content (JSON string or object)
870
1492
  */
871
1493
  updateXrayConfig(config) {
872
- 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) });
873
1507
  }
874
1508
 
875
1509
  /**
876
1510
  * Manage WARP
877
- * @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)
878
1512
  * @param {Object} [data] - Additional data for the action
879
1513
  */
880
1514
  manageWarp(action, data = {}) {
881
- return this._request('post', `/panel/xray/warp/${action}`, data);
1515
+ return this._request('post', `/panel/api/xray/warp/${action}`, data);
882
1516
  }
883
1517
 
884
1518
  /**
885
1519
  * Get outbound traffic statistics
886
1520
  */
887
1521
  getOutboundsTraffic() {
888
- return this._request('get', '/panel/xray/getOutboundsTraffic');
1522
+ return this._request('get', '/panel/api/xray/getOutboundsTraffic');
889
1523
  }
890
1524
 
891
1525
  /**
892
1526
  * Reset outbound traffic statistics
893
1527
  */
894
1528
  resetOutboundsTraffic() {
895
- return this._request('post', '/panel/xray/resetOutboundsTraffic');
1529
+ return this._request('post', '/panel/api/xray/resetOutboundsTraffic');
896
1530
  }
897
1531
 
898
1532
  /**
899
1533
  * Get Xray execution result
900
1534
  */
901
1535
  getXrayResult() {
902
- return this._request('get', '/panel/xray/getXrayResult');
1536
+ return this._request('get', '/panel/api/xray/getXrayResult');
903
1537
  }
904
1538
 
905
1539
  // Security Methods