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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "3xui-api-client",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "A Node.js client library for 3x-ui panel API with built-in credential generation, session management, and web integration support",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -12,13 +12,8 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
|
-
"test": "
|
|
15
|
+
"test": "echo \"No automated tests configured. Tests require a live panel environment.\"",
|
|
16
16
|
"test:jest": "jest",
|
|
17
|
-
"test:manual": "node test/main-test.js",
|
|
18
|
-
"test:main": "node test/main-test.js",
|
|
19
|
-
"test:login": "node test/login-test.js",
|
|
20
|
-
"test:inbounds": "node test/inbounds-test.js",
|
|
21
|
-
"test:create": "node test/create-inbound-test.js",
|
|
22
17
|
"lint": "eslint index.js src/",
|
|
23
18
|
"prepublishOnly": "echo 'Skipping tests for publishing'"
|
|
24
19
|
},
|
|
@@ -93,7 +88,8 @@
|
|
|
93
88
|
"dotenv": "^16.5.0",
|
|
94
89
|
"jest": "^29.7.0",
|
|
95
90
|
"eslint": "^9.0.0",
|
|
96
|
-
"@eslint/js": "^9.0.0"
|
|
91
|
+
"@eslint/js": "^9.0.0",
|
|
92
|
+
"chai": "^4.3.4"
|
|
97
93
|
},
|
|
98
94
|
"jest": {
|
|
99
95
|
"testEnvironment": "node",
|
|
@@ -101,25 +101,30 @@ class CredentialGenerator {
|
|
|
101
101
|
return keyBytes.toString('base64');
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Generate a Curve25519 (X25519) key pair using Node's crypto module
|
|
106
|
+
* @returns {{privateKeyBytes: Buffer, publicKeyBytes: Buffer}} Raw 32-byte key pair
|
|
107
|
+
*/
|
|
108
|
+
static _generateX25519KeyPair() {
|
|
109
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
|
|
110
|
+
const privateJwk = privateKey.export({ format: 'jwk' });
|
|
111
|
+
const publicJwk = publicKey.export({ format: 'jwk' });
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
privateKeyBytes: Buffer.from(privateJwk.d, 'base64url'),
|
|
115
|
+
publicKeyBytes: Buffer.from(publicJwk.x, 'base64url')
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
/**
|
|
105
120
|
* Generate WireGuard key pair
|
|
106
121
|
* @returns {Object} Object containing private and public keys
|
|
107
122
|
*/
|
|
108
123
|
static generateWireGuardKeys() {
|
|
109
|
-
//
|
|
110
|
-
const privateKeyBytes =
|
|
111
|
-
|
|
112
|
-
// Clamp the private key (standard WireGuard key clamping)
|
|
113
|
-
privateKeyBytes[0] &= 248;
|
|
114
|
-
privateKeyBytes[31] &= 127;
|
|
115
|
-
privateKeyBytes[31] |= 64;
|
|
124
|
+
// WireGuard uses Curve25519 (X25519) key pairs encoded as standard base64
|
|
125
|
+
const { privateKeyBytes, publicKeyBytes } = this._generateX25519KeyPair();
|
|
116
126
|
|
|
117
127
|
const privateKey = privateKeyBytes.toString('base64');
|
|
118
|
-
|
|
119
|
-
// For the public key, we would normally use Curve25519 scalar multiplication
|
|
120
|
-
// This is a simplified implementation - in production, use a proper crypto library
|
|
121
|
-
// like noble-curves or libsodium for accurate public key derivation
|
|
122
|
-
const publicKeyBytes = crypto.randomBytes(32); // Placeholder
|
|
123
128
|
const publicKey = publicKeyBytes.toString('base64');
|
|
124
129
|
|
|
125
130
|
return {
|
|
@@ -140,13 +145,11 @@ class CredentialGenerator {
|
|
|
140
145
|
* @returns {Object} Object containing private and public keys
|
|
141
146
|
*/
|
|
142
147
|
static generateRealityKeys() {
|
|
143
|
-
// Reality uses X25519 key exchange
|
|
144
|
-
const privateKeyBytes =
|
|
145
|
-
const privateKey = privateKeyBytes.toString('base64').replace(/\//g, '_').replace(/\+/g, '-');
|
|
148
|
+
// Reality uses X25519 key exchange, keys encoded as base64url without padding
|
|
149
|
+
const { privateKeyBytes, publicKeyBytes } = this._generateX25519KeyPair();
|
|
146
150
|
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
const publicKey = publicKeyBytes.toString('base64').replace(/\//g, '_').replace(/\+/g, '-');
|
|
151
|
+
const privateKey = privateKeyBytes.toString('base64url');
|
|
152
|
+
const publicKey = publicKeyBytes.toString('base64url');
|
|
150
153
|
|
|
151
154
|
return {
|
|
152
155
|
privateKey,
|
|
@@ -34,6 +34,13 @@ class SessionStore {
|
|
|
34
34
|
/**
|
|
35
35
|
* Built-in memory session store (default)
|
|
36
36
|
* Recommended for development and single-instance deployments
|
|
37
|
+
*
|
|
38
|
+
* Security note: session cookies are stored as plain JavaScript objects in
|
|
39
|
+
* process memory — unencrypted. The cookie grants full admin access to the
|
|
40
|
+
* 3x-ui panel and should be treated as a high-value secret. In shared or
|
|
41
|
+
* multi-tenant Node.js processes a heap dump or memory inspection can expose
|
|
42
|
+
* a live session. For production workloads consider a Redis or Database store
|
|
43
|
+
* protected by appropriate infrastructure-level access controls.
|
|
37
44
|
*/
|
|
38
45
|
class MemorySessionStore extends SessionStore {
|
|
39
46
|
constructor() {
|
|
@@ -175,6 +182,14 @@ class RedisSessionStore extends SessionStore {
|
|
|
175
182
|
/**
|
|
176
183
|
* Database session store adapter
|
|
177
184
|
* Works with any SQL database through a provided database client
|
|
185
|
+
*
|
|
186
|
+
* Security note: session data (including the 3x-ui admin cookie) is
|
|
187
|
+
* persisted as `session_data TEXT` — unencrypted. Anyone with read access
|
|
188
|
+
* to the session table can extract a live admin-equivalent credential.
|
|
189
|
+
* Restrict table access with the least-privilege DB user possible, and
|
|
190
|
+
* consider enabling encryption at rest for the database. The session key
|
|
191
|
+
* column (sha256 hash) also allows correlation of rows to specific
|
|
192
|
+
* panels/users — see generateSessionKey() for details.
|
|
178
193
|
*/
|
|
179
194
|
class DatabaseSessionStore extends SessionStore {
|
|
180
195
|
constructor(database, options = {}) {
|
|
@@ -365,7 +380,15 @@ class SessionManager {
|
|
|
365
380
|
}
|
|
366
381
|
|
|
367
382
|
/**
|
|
368
|
-
* Generate
|
|
383
|
+
* Generate a deterministic cache key for a given panel + username pair.
|
|
384
|
+
*
|
|
385
|
+
* The key is sha256(baseURL:username) — intentionally predictable so that
|
|
386
|
+
* the same panel session can be looked up across restarts. It is NOT a
|
|
387
|
+
* security secret: anyone with read access to the session store can
|
|
388
|
+
* correlate rows back to a specific panel/user. Treat the *value* stored
|
|
389
|
+
* under this key (the session cookie) as the sensitive material that must
|
|
390
|
+
* be protected at the infrastructure level (access control, encryption at
|
|
391
|
+
* rest, etc.), not the key itself.
|
|
369
392
|
*/
|
|
370
393
|
generateSessionKey(baseURL, username) {
|
|
371
394
|
const hash = crypto
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte Conversion Utilities
|
|
3
|
+
*
|
|
4
|
+
* Converts bandwidth/storage values to bytes for consistent handling
|
|
5
|
+
* in the 3x-ui API which expects all data limits in bytes internally.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const UNITS = {
|
|
9
|
+
B: 1,
|
|
10
|
+
KB: 1024,
|
|
11
|
+
MB: 1024 ** 2,
|
|
12
|
+
GB: 1024 ** 3,
|
|
13
|
+
TB: 1024 ** 4
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Convert gigabytes to bytes
|
|
18
|
+
* @param {number} gb - Gigabytes value
|
|
19
|
+
* @returns {number} Value in bytes
|
|
20
|
+
*/
|
|
21
|
+
function gbToBytes(gb) {
|
|
22
|
+
if (typeof gb !== 'number' || gb < 0) {
|
|
23
|
+
throw new Error(`Invalid GB value: ${gb}. Must be a non-negative number.`);
|
|
24
|
+
}
|
|
25
|
+
return gb * UNITS.GB;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Convert bytes to gigabytes
|
|
30
|
+
* @param {number} bytes - Bytes value
|
|
31
|
+
* @returns {number} Value in gigabytes
|
|
32
|
+
*/
|
|
33
|
+
function bytesToGb(bytes) {
|
|
34
|
+
if (typeof bytes !== 'number' || bytes < 0) {
|
|
35
|
+
throw new Error(`Invalid byte value: ${bytes}. Must be a non-negative number.`);
|
|
36
|
+
}
|
|
37
|
+
return bytes / UNITS.GB;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Format bytes to human-readable format
|
|
42
|
+
* @param {number} bytes - Bytes value
|
|
43
|
+
* @param {number} decimals - Decimal places (default: 2)
|
|
44
|
+
* @returns {string} Formatted string like "1.5 GB"
|
|
45
|
+
*/
|
|
46
|
+
function formatBytes(bytes, decimals = 2) {
|
|
47
|
+
if (typeof bytes !== 'number' || bytes < 0) {
|
|
48
|
+
return '0 B';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
52
|
+
let size = bytes;
|
|
53
|
+
let unitIndex = 0;
|
|
54
|
+
|
|
55
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
56
|
+
size /= 1024;
|
|
57
|
+
unitIndex++;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return `${size.toFixed(decimals)} ${units[unitIndex]}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Safely convert totalGB option to bytes with warning for suspiciously small values
|
|
65
|
+
* @param {number|undefined} totalGbValue - The totalGB value from options
|
|
66
|
+
* @returns {number} Value in bytes (0 if undefined/null)
|
|
67
|
+
*/
|
|
68
|
+
function sanitizeTotalGb(totalGbValue) {
|
|
69
|
+
if (totalGbValue === undefined || totalGbValue === null) {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof totalGbValue !== 'number') {
|
|
74
|
+
throw new Error(`Invalid totalGB value: ${totalGbValue}. Must be a number.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Warn if the value looks suspiciously small (likely a user error)
|
|
78
|
+
if (totalGbValue > 0 && totalGbValue < 1) {
|
|
79
|
+
console.warn(
|
|
80
|
+
'⚠️ WARNING: totalGB is set to ' + totalGbValue + '. ' +
|
|
81
|
+
'This will be converted to ' + gbToBytes(totalGbValue) + ' bytes. ' +
|
|
82
|
+
'If you meant ' + totalGbValue + ' gigabytes, this is correct. ' +
|
|
83
|
+
'If you meant something else, please check your value.'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return gbToBytes(totalGbValue);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Process options object to convert all bandwidth-related fields to bytes
|
|
92
|
+
* Converts: totalGB → totalGB (in bytes)
|
|
93
|
+
* @param {Object} options - Options object
|
|
94
|
+
* @returns {Object} New object with converted values
|
|
95
|
+
*/
|
|
96
|
+
function convertBandwidthFields(options) {
|
|
97
|
+
if (!options || typeof options !== 'object') {
|
|
98
|
+
return options;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const converted = { ...options };
|
|
102
|
+
|
|
103
|
+
// Convert totalGB if present
|
|
104
|
+
if (converted.totalGB !== undefined && converted.totalGB !== null) {
|
|
105
|
+
converted.totalGB = sanitizeTotalGb(converted.totalGB);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return converted;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Process array of options objects for bulk operations
|
|
113
|
+
* @param {Array<Object>} optionsArray - Array of option objects
|
|
114
|
+
* @returns {Array<Object>} New array with converted values
|
|
115
|
+
*/
|
|
116
|
+
function convertBandwidthFieldsBulk(optionsArray) {
|
|
117
|
+
if (!Array.isArray(optionsArray)) {
|
|
118
|
+
return optionsArray;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return optionsArray.map(convertBandwidthFields);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
gbToBytes,
|
|
126
|
+
bytesToGb,
|
|
127
|
+
formatBytes,
|
|
128
|
+
sanitizeTotalGb,
|
|
129
|
+
convertBandwidthFields,
|
|
130
|
+
convertBandwidthFieldsBulk,
|
|
131
|
+
UNITS
|
|
132
|
+
};
|