minercon 3.0.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 +439 -0
- package/LICENSE +22 -0
- package/README.md +401 -0
- package/images/icon.png +0 -0
- package/out/ansi.js +123 -0
- package/out/argumentHint.js +43 -0
- package/out/bukkitHelpParsing.js +62 -0
- package/out/cli.js +253 -0
- package/out/cliConfig.js +141 -0
- package/out/commandLine.js +28 -0
- package/out/commandSuggestions.js +202 -0
- package/out/commandTree.js +46 -0
- package/out/commandTreeCache.js +171 -0
- package/out/commandTreeCrawler.js +583 -0
- package/out/commandTreeParsingBrigadier.js +426 -0
- package/out/commandTreeParsingBukkit.js +116 -0
- package/out/commandTreeSuggestions.js +142 -0
- package/out/completionBackend.js +94 -0
- package/out/completionEngine.js +376 -0
- package/out/completionQueries.js +86 -0
- package/out/completionsBackend.js +97 -0
- package/out/connectionManager.js +209 -0
- package/out/displayArgumentHint.js +43 -0
- package/out/displayCommandTree.js +115 -0
- package/out/displaySuggestion.js +282 -0
- package/out/extension.js +190 -0
- package/out/helpTextParsing.js +445 -0
- package/out/historySearch.js +46 -0
- package/out/historyStore.js +126 -0
- package/out/lineEditor.js +525 -0
- package/out/localCommandTree.js +541 -0
- package/out/logger.js +14 -0
- package/out/minercon +253 -0
- package/out/pager.js +168 -0
- package/out/pagination.js +142 -0
- package/out/rconClient.js +97 -0
- package/out/rconConnectionManager.js +238 -0
- package/out/rconProtocol.js +421 -0
- package/out/rconSession.js +920 -0
- package/out/rconTerminal.js +80 -0
- package/out/suggestionDisplay.js +286 -0
- package/out/terminalOutput.js +110 -0
- package/out/unpaginate.js +30 -0
- package/package.json +138 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/rconConnectionManager.ts
|
|
3
|
+
//
|
|
4
|
+
// Owns the RCON connection lifecycle: the live `RconController` (recreated on
|
|
5
|
+
// each reconnect attempt), connection/reconnection status, and the
|
|
6
|
+
// exponential-backoff retry loop. Pulled out of RconTerminal as part of the
|
|
7
|
+
// mega-module split — see lineEditor.ts / displaySuggestion.ts for the sibling
|
|
8
|
+
// extractions and their rationale.
|
|
9
|
+
//
|
|
10
|
+
// `RconSession` keeps `detectAndInitialize`/`initializeCommands` (they're
|
|
11
|
+
// about the autocomplete command-tree and `pluginMode`, a different concern
|
|
12
|
+
// that happens to run at similar times) but reads connection status and reaches
|
|
13
|
+
// the live controller through this class, and is notified via `onReconnected`
|
|
14
|
+
// when it should reload the command tree.
|
|
15
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
18
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
19
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
20
|
+
}
|
|
21
|
+
Object.defineProperty(o, k2, desc);
|
|
22
|
+
}) : (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
o[k2] = m[k];
|
|
25
|
+
}));
|
|
26
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
27
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
28
|
+
}) : function(o, v) {
|
|
29
|
+
o["default"] = v;
|
|
30
|
+
});
|
|
31
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
32
|
+
var ownKeys = function(o) {
|
|
33
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
34
|
+
var ar = [];
|
|
35
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
36
|
+
return ar;
|
|
37
|
+
};
|
|
38
|
+
return ownKeys(o);
|
|
39
|
+
};
|
|
40
|
+
return function (mod) {
|
|
41
|
+
if (mod && mod.__esModule) return mod;
|
|
42
|
+
var result = {};
|
|
43
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
44
|
+
__setModuleDefault(result, mod);
|
|
45
|
+
return result;
|
|
46
|
+
};
|
|
47
|
+
})();
|
|
48
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
|
+
exports.RconConnectionManager = void 0;
|
|
50
|
+
const rconClient_1 = require("./rconClient");
|
|
51
|
+
const logger_1 = require("./logger");
|
|
52
|
+
const ansi = __importStar(require("./ansi"));
|
|
53
|
+
const defaultControllerFactory = (host, port, password, logger) => new rconClient_1.RconController(host, port, password, logger);
|
|
54
|
+
// Auto-reconnect timing. After a dropped connection we wait a short grace
|
|
55
|
+
// period, then retry with exponential backoff (doubling each attempt) capped
|
|
56
|
+
// at the max delay.
|
|
57
|
+
const CONNECTION_LOST_GRACE_MS = 1000;
|
|
58
|
+
const INITIAL_RECONNECT_DELAY_MS = 2000;
|
|
59
|
+
const MAX_RECONNECT_DELAY_MS = 32000;
|
|
60
|
+
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
61
|
+
class RconConnectionManager {
|
|
62
|
+
serverHost;
|
|
63
|
+
serverPort;
|
|
64
|
+
password;
|
|
65
|
+
logger;
|
|
66
|
+
host;
|
|
67
|
+
controllerFactory;
|
|
68
|
+
_controller;
|
|
69
|
+
// Intent-level connection state: whether the session currently considers
|
|
70
|
+
// itself connected, driven by the lifecycle transitions here (initial
|
|
71
|
+
// connect, `disconnect`, `reportConnectionLost`, successful reconnect). This
|
|
72
|
+
// is what the UI/prompt reads, and is deliberately distinct from the live
|
|
73
|
+
// socket truth in `RconController.isConnected()` — the two can briefly differ
|
|
74
|
+
// mid-reconnect (e.g. this is `false` while a new socket is being dialed).
|
|
75
|
+
_isConnected = true;
|
|
76
|
+
_isReconnecting = false;
|
|
77
|
+
reconnectAttempts = 0;
|
|
78
|
+
reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
|
79
|
+
reconnectTimeout = null;
|
|
80
|
+
constructor(serverHost, serverPort, password, logger, controller, host, controllerFactory = defaultControllerFactory) {
|
|
81
|
+
this.serverHost = serverHost;
|
|
82
|
+
this.serverPort = serverPort;
|
|
83
|
+
this.password = password;
|
|
84
|
+
this.logger = logger;
|
|
85
|
+
this.host = host;
|
|
86
|
+
this.controllerFactory = controllerFactory;
|
|
87
|
+
this._controller = controller;
|
|
88
|
+
this.wireCloseHandler(this._controller);
|
|
89
|
+
}
|
|
90
|
+
get controller() {
|
|
91
|
+
return this._controller;
|
|
92
|
+
}
|
|
93
|
+
get isConnected() {
|
|
94
|
+
return this._isConnected;
|
|
95
|
+
}
|
|
96
|
+
get isReconnecting() {
|
|
97
|
+
return this._isReconnecting;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Subscribes to unexpected-close notifications from the given controller.
|
|
101
|
+
* The handler fires when the socket drops without being triggered by an
|
|
102
|
+
* in-flight command — i.e. a silent socket timeout or server-side close.
|
|
103
|
+
* The guard conditions prevent double-triggering when the close is the
|
|
104
|
+
* result of a deliberate disconnect() or an in-progress reconnect attempt.
|
|
105
|
+
*/
|
|
106
|
+
wireCloseHandler(controller) {
|
|
107
|
+
controller.setUnexpectedCloseHandler(() => {
|
|
108
|
+
if (this._isConnected && !this._isReconnecting) {
|
|
109
|
+
this.host.write(ansi.yellow('⚠ Connection lost. Auto-reconnecting...') + '\r\n');
|
|
110
|
+
this.reportConnectionLost();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** Resets the reconnect-attempt counter, backoff delay, and any pending reconnect timer back to their initial state. */
|
|
115
|
+
resetReconnectState() {
|
|
116
|
+
this.reconnectAttempts = 0;
|
|
117
|
+
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
|
118
|
+
if (this.reconnectTimeout) {
|
|
119
|
+
clearTimeout(this.reconnectTimeout);
|
|
120
|
+
this.reconnectTimeout = null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Records that an in-flight command discovered the connection is gone, and
|
|
125
|
+
* schedules an auto-reconnect attempt shortly after. The "Connection
|
|
126
|
+
* lost..." message itself stays with the caller (`executeCommand` accounts
|
|
127
|
+
* for it in its output-line bookkeeping).
|
|
128
|
+
*/
|
|
129
|
+
reportConnectionLost() {
|
|
130
|
+
this._isConnected = false;
|
|
131
|
+
this.resetReconnectState();
|
|
132
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
133
|
+
this.reconnectTimeout = null;
|
|
134
|
+
this.attemptReconnect();
|
|
135
|
+
}, CONNECTION_LOST_GRACE_MS);
|
|
136
|
+
}
|
|
137
|
+
disconnect() {
|
|
138
|
+
// No key-chord echo here: this runs for the typed /disconnect built-in.
|
|
139
|
+
// Ctrl+D echoes its own ^D in RconSession.handleCtrlD before closing.
|
|
140
|
+
this.host.write('Disconnecting...\r\n');
|
|
141
|
+
// Clear any pending reconnect
|
|
142
|
+
if (this.reconnectTimeout) {
|
|
143
|
+
clearTimeout(this.reconnectTimeout);
|
|
144
|
+
this.reconnectTimeout = null;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
this._controller.disconnect();
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
this.logger.error(`Error during disconnect: ${err}`);
|
|
151
|
+
}
|
|
152
|
+
this._isConnected = false;
|
|
153
|
+
this._isReconnecting = false;
|
|
154
|
+
this.host.write('Connection closed. Type ' + ansi.yellow('.reconnect') + ' to reconnect.\r\n\r\n');
|
|
155
|
+
this.host.showPrompt();
|
|
156
|
+
}
|
|
157
|
+
async manualReconnect() {
|
|
158
|
+
if (this._isReconnecting) {
|
|
159
|
+
this.host.write(ansi.yellow('Already reconnecting...') + '\r\n\r\n');
|
|
160
|
+
this.host.showPrompt();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.resetReconnectState();
|
|
164
|
+
await this.attemptReconnect();
|
|
165
|
+
}
|
|
166
|
+
async attemptReconnect() {
|
|
167
|
+
if (this._isReconnecting) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (this._isConnected) {
|
|
171
|
+
this.host.write(ansi.green('Already connected.') + '\r\n\r\n');
|
|
172
|
+
this.host.showPrompt();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
this._isReconnecting = true;
|
|
176
|
+
this.reconnectAttempts++;
|
|
177
|
+
const attemptText = this.reconnectAttempts > 1 ? ` (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})` : '';
|
|
178
|
+
this.host.write(ansi.yellow('Reconnecting to ' + this.serverHost + ':' + this.serverPort + attemptText + '...') + '\r\n');
|
|
179
|
+
try {
|
|
180
|
+
// Disconnect existing controller
|
|
181
|
+
try {
|
|
182
|
+
await this._controller.disconnect();
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
// Ignore disconnect errors during reconnect
|
|
186
|
+
}
|
|
187
|
+
// Create new controller
|
|
188
|
+
this._controller = this.controllerFactory(this.serverHost, this.serverPort, this.password, this.logger);
|
|
189
|
+
this.wireCloseHandler(this._controller);
|
|
190
|
+
await this._controller.connect();
|
|
191
|
+
this._isConnected = true;
|
|
192
|
+
this._isReconnecting = false;
|
|
193
|
+
this.resetReconnectState();
|
|
194
|
+
this.host.write(ansi.boldGreen('✓ Reconnected successfully!') + '\r\n\r\n');
|
|
195
|
+
// Reload commands after reconnection
|
|
196
|
+
this.host.onReconnected();
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
this._isReconnecting = false;
|
|
200
|
+
if (this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
|
201
|
+
this.host.write(ansi.red('✗ Connection failed: ' + (0, logger_1.errorMessage)(err)) + '\r\n');
|
|
202
|
+
this.host.write(ansi.yellow('Retrying in ' + (this.reconnectDelay / 1000) + ' seconds...') + '\r\n');
|
|
203
|
+
// Clear any existing timeout
|
|
204
|
+
if (this.reconnectTimeout) {
|
|
205
|
+
clearTimeout(this.reconnectTimeout);
|
|
206
|
+
}
|
|
207
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
208
|
+
this.reconnectTimeout = null;
|
|
209
|
+
this.attemptReconnect();
|
|
210
|
+
}, this.reconnectDelay);
|
|
211
|
+
// Exponential backoff with max delay
|
|
212
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
// Max attempts reached
|
|
216
|
+
this.host.write(ansi.boldRed('✗ Reconnection failed after ' + MAX_RECONNECT_ATTEMPTS + ' attempts.') + '\r\n');
|
|
217
|
+
this.host.write('Type ' + ansi.yellow('.reconnect') + ' to try again.\r\n\r\n');
|
|
218
|
+
this.resetReconnectState();
|
|
219
|
+
this.host.showPrompt();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Tears down any pending reconnect timer and disconnects the controller — used by `RconSession.close()`. */
|
|
224
|
+
dispose() {
|
|
225
|
+
if (this.reconnectTimeout) {
|
|
226
|
+
clearTimeout(this.reconnectTimeout);
|
|
227
|
+
this.reconnectTimeout = null;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
this._controller.disconnect();
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
this.logger.error(`Error during close: ${err}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
exports.RconConnectionManager = RconConnectionManager;
|
|
238
|
+
//# sourceMappingURL=rconConnectionManager.js.map
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.RconProtocol = void 0;
|
|
37
|
+
// src/rconProtocol.ts
|
|
38
|
+
const net = __importStar(require("net"));
|
|
39
|
+
const events_1 = require("events");
|
|
40
|
+
// RCON packet type tags. The wire protocol reuses the value 2 for two
|
|
41
|
+
// different things depending on direction (SERVERDATA_EXECCOMMAND when we send,
|
|
42
|
+
// SERVERDATA_AUTH_RESPONSE when the server replies), so they live in separate
|
|
43
|
+
// enums: nothing in this file ever compares an outgoing tag to an incoming one,
|
|
44
|
+
// which keeps that shared value from being mistaken for a single meaning.
|
|
45
|
+
var OutgoingType;
|
|
46
|
+
(function (OutgoingType) {
|
|
47
|
+
OutgoingType[OutgoingType["AUTH"] = 3] = "AUTH";
|
|
48
|
+
OutgoingType[OutgoingType["COMMAND"] = 2] = "COMMAND";
|
|
49
|
+
})(OutgoingType || (OutgoingType = {}));
|
|
50
|
+
var IncomingType;
|
|
51
|
+
(function (IncomingType) {
|
|
52
|
+
IncomingType[IncomingType["RESPONSE"] = 0] = "RESPONSE";
|
|
53
|
+
IncomingType[IncomingType["AUTH_RESPONSE"] = 2] = "AUTH_RESPONSE";
|
|
54
|
+
})(IncomingType || (IncomingType = {}));
|
|
55
|
+
class RconProtocol extends events_1.EventEmitter {
|
|
56
|
+
socket = null;
|
|
57
|
+
host;
|
|
58
|
+
port;
|
|
59
|
+
password;
|
|
60
|
+
logger;
|
|
61
|
+
createSocket;
|
|
62
|
+
authenticated = false;
|
|
63
|
+
requestId = 0;
|
|
64
|
+
responseBuffer = Buffer.alloc(0);
|
|
65
|
+
// For tracking requests and responses
|
|
66
|
+
pendingRequests = new Map();
|
|
67
|
+
// Configuration
|
|
68
|
+
RESPONSE_TIMEOUT = 10000; // 10 seconds for command responses
|
|
69
|
+
AUTH_TIMEOUT = 5000; // 5 seconds for the login handshake
|
|
70
|
+
constructor(host, port, password, logger,
|
|
71
|
+
// Defaults to a real socket; tests substitute a `SocketLike` fake here
|
|
72
|
+
// (record/replay harness — see src/test/rconProtocol.test.ts) so the
|
|
73
|
+
// wire protocol can be exercised without a live server.
|
|
74
|
+
createSocket = () => new net.Socket()) {
|
|
75
|
+
super();
|
|
76
|
+
this.host = host;
|
|
77
|
+
this.port = port;
|
|
78
|
+
this.password = password;
|
|
79
|
+
this.logger = logger;
|
|
80
|
+
this.createSocket = createSocket;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Connect to the RCON server
|
|
84
|
+
*/
|
|
85
|
+
async connect() {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
this.socket = this.createSocket();
|
|
88
|
+
// Enable TCP keepalive to prevent idle connection drops
|
|
89
|
+
// This sends periodic probes to keep the connection alive through NAT/firewalls
|
|
90
|
+
this.socket.setKeepAlive(true, 60000); // Send keepalive probes every 60 seconds
|
|
91
|
+
// Don't set a socket timeout - let the connection stay open indefinitely
|
|
92
|
+
// The keepalive will handle detecting dead connections
|
|
93
|
+
// Handle connection
|
|
94
|
+
this.socket.once('connect', async () => {
|
|
95
|
+
this.logger.info(`Connected to ${this.host}:${this.port}`);
|
|
96
|
+
try {
|
|
97
|
+
await this.authenticate();
|
|
98
|
+
resolve();
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
reject(error);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
// Handle data
|
|
105
|
+
this.socket.on('data', (data) => {
|
|
106
|
+
this.handleData(data);
|
|
107
|
+
});
|
|
108
|
+
// Handle errors
|
|
109
|
+
this.socket.on('error', (error) => {
|
|
110
|
+
this.logger.error(`Socket error: ${error.message}`);
|
|
111
|
+
this.emit('error', error);
|
|
112
|
+
reject(error);
|
|
113
|
+
});
|
|
114
|
+
// Handle timeout (shouldn't happen now that we removed setTimeout)
|
|
115
|
+
this.socket.on('timeout', () => {
|
|
116
|
+
this.logger.warn('Socket timeout — calling disconnect()');
|
|
117
|
+
const error = new Error('Connection timeout');
|
|
118
|
+
this.emit('error', error);
|
|
119
|
+
this.disconnect();
|
|
120
|
+
});
|
|
121
|
+
// Handle close
|
|
122
|
+
this.socket.on('close', () => {
|
|
123
|
+
const pending = [...this.pendingRequests.values()]
|
|
124
|
+
.map(r => r.kind === 'command' ? r.command : r.kind)
|
|
125
|
+
.join(', ');
|
|
126
|
+
this.logger.info(`Connection closed (pending: ${pending || 'none'})`);
|
|
127
|
+
this.authenticated = false;
|
|
128
|
+
this.emit('close');
|
|
129
|
+
for (const request of this.pendingRequests.values()) {
|
|
130
|
+
if (request.kind !== 'fence') {
|
|
131
|
+
clearTimeout(request.timeout);
|
|
132
|
+
}
|
|
133
|
+
// Deliver any accumulated fragments if the server closed mid-response
|
|
134
|
+
// (e.g. the final fragment was exactly one full packet and we were
|
|
135
|
+
// still waiting for more).
|
|
136
|
+
if (request.kind === 'command' && request.fragments.length > 0) {
|
|
137
|
+
request.resolve(request.fragments.join(''));
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
request.reject(new Error('Connection closed'));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
this.pendingRequests.clear();
|
|
144
|
+
});
|
|
145
|
+
// Connect
|
|
146
|
+
this.socket.connect(this.port, this.host);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Authenticate with the RCON server
|
|
151
|
+
*/
|
|
152
|
+
async authenticate() {
|
|
153
|
+
if (!this.socket) {
|
|
154
|
+
throw new Error('Not connected');
|
|
155
|
+
}
|
|
156
|
+
return new Promise((resolve, reject) => {
|
|
157
|
+
const authId = this.getNextRequestId();
|
|
158
|
+
// Set up auth response handler
|
|
159
|
+
const authTimeout = setTimeout(() => {
|
|
160
|
+
this.pendingRequests.delete(authId);
|
|
161
|
+
reject(new Error('Authentication timeout'));
|
|
162
|
+
}, this.AUTH_TIMEOUT);
|
|
163
|
+
this.pendingRequests.set(authId, {
|
|
164
|
+
kind: 'auth',
|
|
165
|
+
resolve: () => {
|
|
166
|
+
clearTimeout(authTimeout);
|
|
167
|
+
this.authenticated = true;
|
|
168
|
+
this.logger.info('Authentication successful');
|
|
169
|
+
resolve();
|
|
170
|
+
},
|
|
171
|
+
reject: (error) => {
|
|
172
|
+
clearTimeout(authTimeout);
|
|
173
|
+
reject(error);
|
|
174
|
+
},
|
|
175
|
+
timeout: authTimeout
|
|
176
|
+
});
|
|
177
|
+
// Send auth packet
|
|
178
|
+
const packet = this.createPacket(authId, OutgoingType.AUTH, this.password);
|
|
179
|
+
if (this.socket) {
|
|
180
|
+
this.socket.write(packet);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Send a command to the server
|
|
186
|
+
*/
|
|
187
|
+
async send(command) {
|
|
188
|
+
if (!this.socket || !this.authenticated) {
|
|
189
|
+
throw new Error('Not connected or authenticated');
|
|
190
|
+
}
|
|
191
|
+
// Use the double-packet technique for detecting end of fragmented
|
|
192
|
+
// responses: send the real command, then — once the first response
|
|
193
|
+
// fragment arrives — send an empty "fence" command. RCON processes
|
|
194
|
+
// commands in order, so when the fence response arrives all real
|
|
195
|
+
// fragments have already been received. Sending the fence only after
|
|
196
|
+
// the first fragment (via sendFence below) ensures the server has
|
|
197
|
+
// started replying before it sees the fence, which prevents servers
|
|
198
|
+
// from treating two back-to-back packets with no intervening response
|
|
199
|
+
// as a protocol error and closing the connection.
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const requestId = this.getNextRequestId();
|
|
202
|
+
const dummyId = this.getNextRequestId();
|
|
203
|
+
const timeout = setTimeout(() => {
|
|
204
|
+
this.pendingRequests.delete(requestId);
|
|
205
|
+
this.pendingRequests.delete(dummyId);
|
|
206
|
+
reject(new Error(`Command timeout: ${command}`));
|
|
207
|
+
}, this.RESPONSE_TIMEOUT);
|
|
208
|
+
this.pendingRequests.set(requestId, {
|
|
209
|
+
kind: 'command',
|
|
210
|
+
command: command,
|
|
211
|
+
resolve: (response) => {
|
|
212
|
+
clearTimeout(timeout);
|
|
213
|
+
this.pendingRequests.delete(requestId);
|
|
214
|
+
this.pendingRequests.delete(dummyId);
|
|
215
|
+
resolve(response);
|
|
216
|
+
},
|
|
217
|
+
reject: (error) => {
|
|
218
|
+
clearTimeout(timeout);
|
|
219
|
+
this.pendingRequests.delete(requestId);
|
|
220
|
+
this.pendingRequests.delete(dummyId);
|
|
221
|
+
reject(error);
|
|
222
|
+
},
|
|
223
|
+
timeout: timeout,
|
|
224
|
+
fragments: [],
|
|
225
|
+
// Sent on the first response fragment — ensures the fence arrives in
|
|
226
|
+
// its own TCP read() on the server, not batched with the command packet.
|
|
227
|
+
// RconClient.run() closes the connection if read() returns more bytes
|
|
228
|
+
// than the first packet's length field, so back-to-back writes that
|
|
229
|
+
// arrive in one read() cause an immediate disconnect.
|
|
230
|
+
sendFence: () => {
|
|
231
|
+
if (this.socket) {
|
|
232
|
+
const dummyPacket = this.createPacket(dummyId, OutgoingType.COMMAND, '');
|
|
233
|
+
this.socket.write(dummyPacket);
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
this.pendingRequests.set(dummyId, {
|
|
238
|
+
kind: 'fence',
|
|
239
|
+
resolve: () => {
|
|
240
|
+
const mainRequest = this.pendingRequests.get(requestId);
|
|
241
|
+
if (mainRequest && mainRequest.kind === 'command') {
|
|
242
|
+
mainRequest.resolve(mainRequest.fragments.join(''));
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
reject: () => { },
|
|
246
|
+
});
|
|
247
|
+
const commandPacket = this.createPacket(requestId, OutgoingType.COMMAND, command);
|
|
248
|
+
if (this.socket) {
|
|
249
|
+
this.socket.write(commandPacket);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Handle incoming data from the socket
|
|
255
|
+
*/
|
|
256
|
+
handleData(data) {
|
|
257
|
+
// Append to buffer
|
|
258
|
+
this.responseBuffer = Buffer.concat([this.responseBuffer, data]);
|
|
259
|
+
// Process complete packets
|
|
260
|
+
while (this.responseBuffer.length >= 4) {
|
|
261
|
+
// Read packet size (first 4 bytes, little-endian)
|
|
262
|
+
const size = this.responseBuffer.readInt32LE(0);
|
|
263
|
+
// Check if we have the complete packet
|
|
264
|
+
if (this.responseBuffer.length < size + 4) {
|
|
265
|
+
// Wait for more data
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
// Extract the packet
|
|
269
|
+
const packetBuffer = this.responseBuffer.subarray(0, size + 4);
|
|
270
|
+
this.responseBuffer = this.responseBuffer.subarray(size + 4);
|
|
271
|
+
// Parse the packet
|
|
272
|
+
try {
|
|
273
|
+
const packet = this.parsePacket(packetBuffer);
|
|
274
|
+
this.handlePacket(packet);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
this.logger.error(`Error parsing packet: ${error}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** The single in-flight auth request, if any, with the id it's keyed under. */
|
|
282
|
+
findAuthRequest() {
|
|
283
|
+
for (const [id, request] of this.pendingRequests) {
|
|
284
|
+
if (request.kind === 'auth') {
|
|
285
|
+
return { id, request };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Handle a parsed packet
|
|
292
|
+
*/
|
|
293
|
+
handlePacket(packet) {
|
|
294
|
+
// Failed auth: the server replies with id -1 rather than echoing the id we
|
|
295
|
+
// sent, so we can't look it up — find the pending auth request directly.
|
|
296
|
+
if (packet.id === -1) {
|
|
297
|
+
const auth = this.findAuthRequest();
|
|
298
|
+
if (auth) {
|
|
299
|
+
this.pendingRequests.delete(auth.id);
|
|
300
|
+
auth.request.reject(new Error('Authentication failed'));
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const request = this.pendingRequests.get(packet.id);
|
|
305
|
+
if (!request) {
|
|
306
|
+
// Some servers don't echo our auth id on the AUTH_RESPONSE packet — if an
|
|
307
|
+
// auth handshake is in flight, this unmatched response is it.
|
|
308
|
+
if (packet.type === IncomingType.AUTH_RESPONSE) {
|
|
309
|
+
const auth = this.findAuthRequest();
|
|
310
|
+
if (auth) {
|
|
311
|
+
this.pendingRequests.delete(auth.id);
|
|
312
|
+
auth.request.resolve();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
this.logger.warn(`Received packet with unknown request ID: ${packet.id}`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
switch (request.kind) {
|
|
320
|
+
case 'auth':
|
|
321
|
+
// Auth gets two packets: an empty RESPONSE_VALUE we ignore, then the
|
|
322
|
+
// AUTH_RESPONSE we resolve on. Delete on resolve so the entry doesn't
|
|
323
|
+
// linger where the close handler could re-fire it.
|
|
324
|
+
if (packet.type === IncomingType.AUTH_RESPONSE) {
|
|
325
|
+
this.pendingRequests.delete(packet.id);
|
|
326
|
+
request.resolve();
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
case 'command':
|
|
330
|
+
if (packet.type !== IncomingType.RESPONSE) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// First fragment in: send the fence so its reply can mark the end of
|
|
334
|
+
// this (possibly multi-packet) response.
|
|
335
|
+
if (request.sendFence) {
|
|
336
|
+
request.sendFence();
|
|
337
|
+
request.sendFence = undefined;
|
|
338
|
+
}
|
|
339
|
+
request.fragments.push(packet.body);
|
|
340
|
+
return;
|
|
341
|
+
case 'fence':
|
|
342
|
+
// The fence's reply means every fragment of the command it follows has
|
|
343
|
+
// arrived; its resolve settles that command (and clears both entries).
|
|
344
|
+
if (packet.type !== IncomingType.RESPONSE) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
request.resolve();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Create an RCON packet
|
|
353
|
+
*/
|
|
354
|
+
createPacket(id, type, body) {
|
|
355
|
+
// Calculate size (4 bytes ID + 4 bytes type + body + 2 null terminators)
|
|
356
|
+
const bodyLength = Buffer.byteLength(body, 'utf8');
|
|
357
|
+
const size = 4 + 4 + bodyLength + 2;
|
|
358
|
+
// Create buffer (size field + packet content)
|
|
359
|
+
const buffer = Buffer.alloc(4 + size);
|
|
360
|
+
// Write size (little-endian)
|
|
361
|
+
buffer.writeInt32LE(size, 0);
|
|
362
|
+
// Write ID (little-endian)
|
|
363
|
+
buffer.writeInt32LE(id, 4);
|
|
364
|
+
// Write type (little-endian)
|
|
365
|
+
buffer.writeInt32LE(type, 8);
|
|
366
|
+
// Write body
|
|
367
|
+
buffer.write(body, 12, bodyLength, 'utf8');
|
|
368
|
+
// Null terminators are already 0 from Buffer.alloc
|
|
369
|
+
return buffer;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Parse a packet from a buffer
|
|
373
|
+
*/
|
|
374
|
+
parsePacket(buffer) {
|
|
375
|
+
if (buffer.length < 14) {
|
|
376
|
+
throw new Error('Packet too small');
|
|
377
|
+
}
|
|
378
|
+
const size = buffer.readInt32LE(0);
|
|
379
|
+
const id = buffer.readInt32LE(4);
|
|
380
|
+
const type = buffer.readInt32LE(8);
|
|
381
|
+
// Read body (from byte 12 to size + 2, excluding null terminators)
|
|
382
|
+
const bodyEnd = Math.min(12 + size - 10, buffer.length - 2);
|
|
383
|
+
const body = buffer.toString('utf8', 12, bodyEnd);
|
|
384
|
+
return { size, id, type, body };
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Get the next request ID
|
|
388
|
+
*/
|
|
389
|
+
getNextRequestId() {
|
|
390
|
+
return ++this.requestId;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Disconnect from the server
|
|
394
|
+
*/
|
|
395
|
+
async disconnect() {
|
|
396
|
+
if (this.socket) {
|
|
397
|
+
this.authenticated = false;
|
|
398
|
+
// Clear pending requests
|
|
399
|
+
for (const [, request] of this.pendingRequests) {
|
|
400
|
+
if (request.kind !== 'fence') {
|
|
401
|
+
clearTimeout(request.timeout);
|
|
402
|
+
}
|
|
403
|
+
request.reject(new Error('Disconnected'));
|
|
404
|
+
}
|
|
405
|
+
this.pendingRequests.clear();
|
|
406
|
+
// Close socket
|
|
407
|
+
this.socket.destroy();
|
|
408
|
+
this.socket = null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The lowest-level connection truth: an open socket that has authenticated.
|
|
413
|
+
* `RconController.isConnected()` forwards to this; `RconConnectionManager`
|
|
414
|
+
* keeps a separate intent-level flag that can briefly diverge during reconnects.
|
|
415
|
+
*/
|
|
416
|
+
isConnected() {
|
|
417
|
+
return this.socket !== null && this.authenticated;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
exports.RconProtocol = RconProtocol;
|
|
421
|
+
//# sourceMappingURL=rconProtocol.js.map
|