@woopsy/mcpanel 1.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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/commands/commandRouter.js +525 -0
- package/dist/config/configManager.js +193 -0
- package/dist/index.js +776 -0
- package/dist/managers/backupManager.js +207 -0
- package/dist/managers/playitManager.js +450 -0
- package/dist/managers/serverManager.js +243 -0
- package/dist/services/downloadService.js +176 -0
- package/dist/services/processManager.js +242 -0
- package/dist/utils/colors.js +73 -0
- package/dist/utils/helpers.js +365 -0
- package/dist/utils/logger.js +85 -0
- package/package.json +67 -0
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.BackupManager = void 0;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const adm_zip_1 = __importDefault(require("adm-zip"));
|
|
43
|
+
const configManager_1 = require("../config/configManager");
|
|
44
|
+
const logger_1 = require("../utils/logger");
|
|
45
|
+
class BackupManager {
|
|
46
|
+
configManager;
|
|
47
|
+
constructor(configManager) {
|
|
48
|
+
this.configManager = configManager;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Creates a zipped backup of the managed server.
|
|
52
|
+
*/
|
|
53
|
+
createBackup() {
|
|
54
|
+
const server = this.configManager.getServer();
|
|
55
|
+
if (!server) {
|
|
56
|
+
throw new Error('No server is connected.');
|
|
57
|
+
}
|
|
58
|
+
const serverPath = path.resolve(server.path);
|
|
59
|
+
if (!fs.existsSync(serverPath)) {
|
|
60
|
+
throw new Error(`Server path "${serverPath}" does not exist.`);
|
|
61
|
+
}
|
|
62
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
63
|
+
const backupFileName = `${server.name}_backup_${timestamp}.zip`;
|
|
64
|
+
const backupDestDir = path.join(configManager_1.APP_ROOT, 'backups');
|
|
65
|
+
if (!fs.existsSync(backupDestDir)) {
|
|
66
|
+
fs.mkdirSync(backupDestDir, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
const backupPath = path.join(backupDestDir, backupFileName);
|
|
69
|
+
logger_1.logger.info(`Creating backup for server "${server.name}" to ${backupPath}...`);
|
|
70
|
+
const zip = new adm_zip_1.default();
|
|
71
|
+
zip.addLocalFolder(serverPath);
|
|
72
|
+
zip.writeZip(backupPath);
|
|
73
|
+
const stats = fs.statSync(backupPath);
|
|
74
|
+
const meta = {
|
|
75
|
+
id: `${server.name}_${timestamp}`,
|
|
76
|
+
name: backupFileName,
|
|
77
|
+
serverName: server.name,
|
|
78
|
+
sizeBytes: stats.size,
|
|
79
|
+
createdAt: new Date().toISOString(),
|
|
80
|
+
path: backupPath,
|
|
81
|
+
};
|
|
82
|
+
logger_1.logger.info(`Backup created successfully: ${backupFileName} (${(stats.size / (1024 * 1024)).toFixed(2)} MB)`);
|
|
83
|
+
return meta;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Lists all available backups in the local backups directory and synced backup locations.
|
|
87
|
+
*/
|
|
88
|
+
listBackups() {
|
|
89
|
+
const backups = [];
|
|
90
|
+
const scanDirs = [
|
|
91
|
+
path.join(configManager_1.APP_ROOT, 'backups'),
|
|
92
|
+
...this.configManager.getConfig().externalBackups
|
|
93
|
+
];
|
|
94
|
+
for (const dir of scanDirs) {
|
|
95
|
+
if (!fs.existsSync(dir))
|
|
96
|
+
continue;
|
|
97
|
+
const files = fs.readdirSync(dir);
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
if (!file.endsWith('.zip'))
|
|
100
|
+
continue;
|
|
101
|
+
const filePath = path.join(dir, file);
|
|
102
|
+
try {
|
|
103
|
+
const stats = fs.statSync(filePath);
|
|
104
|
+
// Parse server name and timestamp from filename: ServerName_backup_YYYY-MM-DD...
|
|
105
|
+
let serverName = 'Unknown';
|
|
106
|
+
if (file.includes('_backup_')) {
|
|
107
|
+
serverName = file.split('_backup_')[0];
|
|
108
|
+
}
|
|
109
|
+
// Use file stats for timestamp if we can't extract it easily
|
|
110
|
+
const createdAt = stats.mtime.toISOString();
|
|
111
|
+
const id = file.replace('.zip', '');
|
|
112
|
+
backups.push({
|
|
113
|
+
id,
|
|
114
|
+
name: file,
|
|
115
|
+
serverName,
|
|
116
|
+
sizeBytes: stats.size,
|
|
117
|
+
createdAt,
|
|
118
|
+
path: filePath,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
// Ignore issues reading single file stats
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Sort backups by creation time descending (newest first)
|
|
127
|
+
return backups.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Synchronizes an external backup directory.
|
|
131
|
+
*/
|
|
132
|
+
syncBackup(dirPath) {
|
|
133
|
+
const resolvedPath = path.resolve(dirPath);
|
|
134
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
135
|
+
throw new Error(`Backup directory "${resolvedPath}" does not exist.`);
|
|
136
|
+
}
|
|
137
|
+
this.configManager.addExternalBackup(resolvedPath);
|
|
138
|
+
// Count .zip files found
|
|
139
|
+
const files = fs.readdirSync(resolvedPath);
|
|
140
|
+
const backupZips = files.filter(f => f.endsWith('.zip'));
|
|
141
|
+
logger_1.logger.info(`Synchronized backup source: ${resolvedPath} (${backupZips.length} backups found)`);
|
|
142
|
+
return backupZips.length;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Restores a backup safely, unzipping contents and avoiding directory traversal.
|
|
146
|
+
*/
|
|
147
|
+
restoreBackup(backupId) {
|
|
148
|
+
const server = this.configManager.getServer();
|
|
149
|
+
if (!server) {
|
|
150
|
+
throw new Error('No server is connected.');
|
|
151
|
+
}
|
|
152
|
+
const backups = this.listBackups();
|
|
153
|
+
const backup = backups.find(b => b.id === backupId || b.name === backupId);
|
|
154
|
+
if (!backup) {
|
|
155
|
+
throw new Error(`Backup "${backupId}" was not found.`);
|
|
156
|
+
}
|
|
157
|
+
const serverDir = path.resolve(server.path);
|
|
158
|
+
const zipPath = path.resolve(backup.path);
|
|
159
|
+
logger_1.logger.warn(`Restoring backup "${backup.name}" to server "${server.name}" at ${serverDir}`);
|
|
160
|
+
if (!fs.existsSync(zipPath)) {
|
|
161
|
+
throw new Error(`Backup file "${zipPath}" no longer exists on disk.`);
|
|
162
|
+
}
|
|
163
|
+
// --- SECURE ZIP EXTRACTION (Zip Slip Prevention) ---
|
|
164
|
+
const zip = new adm_zip_1.default(zipPath);
|
|
165
|
+
for (const entry of zip.getEntries()) {
|
|
166
|
+
// Resolve target path of this entry
|
|
167
|
+
const entryName = entry.entryName;
|
|
168
|
+
const targetPath = path.resolve(serverDir, entryName);
|
|
169
|
+
// Verify directory boundary (prevent directory traversal)
|
|
170
|
+
if (!targetPath.startsWith(serverDir + path.sep) && targetPath !== serverDir) {
|
|
171
|
+
logger_1.logger.error(`[SECURITY] Blocked potential Zip Slip: entry "${entryName}" resolves to "${targetPath}" which is outside "${serverDir}"`);
|
|
172
|
+
throw new Error('Restoration aborted: Corrupted or malicious zip archive containing path traversal entries was detected.');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Cleanup server directory first to remove current state files
|
|
176
|
+
logger_1.logger.info(`Clearing server directory "${serverDir}" before restoration...`);
|
|
177
|
+
this.clearDirectoryContent(serverDir);
|
|
178
|
+
// Extract files safely
|
|
179
|
+
logger_1.logger.info(`Extracting backup archive contents to "${serverDir}"...`);
|
|
180
|
+
zip.extractAllTo(serverDir, true);
|
|
181
|
+
logger_1.logger.info(`Server "${server.name}" has been successfully restored from backup "${backup.name}"`);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Helper to clean directory contents without removing the directory itself.
|
|
185
|
+
*/
|
|
186
|
+
clearDirectoryContent(dir) {
|
|
187
|
+
if (!fs.existsSync(dir))
|
|
188
|
+
return;
|
|
189
|
+
const files = fs.readdirSync(dir);
|
|
190
|
+
for (const file of files) {
|
|
191
|
+
const filePath = path.join(dir, file);
|
|
192
|
+
try {
|
|
193
|
+
const stats = fs.statSync(filePath);
|
|
194
|
+
if (stats.isDirectory()) {
|
|
195
|
+
fs.rmSync(filePath, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
fs.unlinkSync(filePath);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
logger_1.logger.error(`Failed to delete "${filePath}" during directory cleanup`, err);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
exports.BackupManager = BackupManager;
|
|
@@ -0,0 +1,450 @@
|
|
|
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.PlayitManager = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const https = __importStar(require("https"));
|
|
40
|
+
const child_process_1 = require("child_process");
|
|
41
|
+
const configManager_1 = require("../config/configManager");
|
|
42
|
+
const helpers_1 = require("../utils/helpers");
|
|
43
|
+
const downloadService_1 = require("../services/downloadService");
|
|
44
|
+
const logger_1 = require("../utils/logger");
|
|
45
|
+
// playit agent version this manager targets. Bump to force a re-download.
|
|
46
|
+
const PLAYIT_VERSION = '1.0.8';
|
|
47
|
+
const PLAYIT_API = 'https://api.playit.gg';
|
|
48
|
+
/** Strips ANSI colour / cursor escape sequences that the playit binaries emit. */
|
|
49
|
+
function stripAnsi(input) {
|
|
50
|
+
return input
|
|
51
|
+
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
|
52
|
+
.replace(/\x1b[78]/g, '')
|
|
53
|
+
.replace(/\x1b[()][AB012]/g, '');
|
|
54
|
+
}
|
|
55
|
+
class PlayitManager {
|
|
56
|
+
configManager;
|
|
57
|
+
playitProcess = null;
|
|
58
|
+
claimProcess = null;
|
|
59
|
+
tunnelStatus;
|
|
60
|
+
constructor(configManager) {
|
|
61
|
+
this.configManager = configManager;
|
|
62
|
+
this.tunnelStatus = this.offlineStatus();
|
|
63
|
+
}
|
|
64
|
+
offlineStatus() {
|
|
65
|
+
return {
|
|
66
|
+
address: 'None',
|
|
67
|
+
port: 'None',
|
|
68
|
+
status: 'Offline',
|
|
69
|
+
latency: 'N/A',
|
|
70
|
+
type: null,
|
|
71
|
+
claimUrl: null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Binary management (v1.0.8: split into the agent daemon + the control cli)
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
/** Path to the agent/daemon binary (used as the traffic relay). */
|
|
78
|
+
getExecutablePath() {
|
|
79
|
+
const osType = (0, helpers_1.detectOS)();
|
|
80
|
+
const binName = osType === 'Windows' ? 'playit.exe' : 'playit';
|
|
81
|
+
return path.join(configManager_1.APP_ROOT, 'playit', binName);
|
|
82
|
+
}
|
|
83
|
+
/** Path to the control cli (used for the one-time claim flow). */
|
|
84
|
+
getCliPath() {
|
|
85
|
+
const osType = (0, helpers_1.detectOS)();
|
|
86
|
+
// v1.0.8 only ships a separate cli for Linux; on Windows the main exe is used.
|
|
87
|
+
if (osType === 'Windows')
|
|
88
|
+
return this.getExecutablePath();
|
|
89
|
+
return path.join(configManager_1.APP_ROOT, 'playit', 'playit-cli');
|
|
90
|
+
}
|
|
91
|
+
/** IPC socket / named pipe the daemon binds to (its default location may not exist). */
|
|
92
|
+
getSocketPath() {
|
|
93
|
+
const osType = (0, helpers_1.detectOS)();
|
|
94
|
+
if (osType === 'Windows')
|
|
95
|
+
return '\\\\.\\pipe\\mcpanel-playit';
|
|
96
|
+
return path.join(configManager_1.APP_ROOT, 'playit', 'agent.sock');
|
|
97
|
+
}
|
|
98
|
+
getVersionSentinel() {
|
|
99
|
+
return path.join(configManager_1.APP_ROOT, 'playit', '.version');
|
|
100
|
+
}
|
|
101
|
+
downloadUrls() {
|
|
102
|
+
const base = `https://github.com/playit-cloud/playit-agent/releases/download/v${PLAYIT_VERSION}`;
|
|
103
|
+
if ((0, helpers_1.detectOS)() === 'Windows') {
|
|
104
|
+
return { agent: `${base}/playit-windows-x86_64.exe`, cli: null };
|
|
105
|
+
}
|
|
106
|
+
return { agent: `${base}/playit-linux-amd64`, cli: `${base}/playit-cli-linux-amd64` };
|
|
107
|
+
}
|
|
108
|
+
/** True when both required binaries exist AND match the targeted version. */
|
|
109
|
+
isBinaryPresent() {
|
|
110
|
+
if (!fs.existsSync(this.getExecutablePath()))
|
|
111
|
+
return false;
|
|
112
|
+
const cli = this.getCliPath();
|
|
113
|
+
if (cli !== this.getExecutablePath() && !fs.existsSync(cli))
|
|
114
|
+
return false;
|
|
115
|
+
try {
|
|
116
|
+
return fs.readFileSync(this.getVersionSentinel(), 'utf-8').trim() === PLAYIT_VERSION;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Downloads (or upgrades) the playit agent + cli for the detected platform. */
|
|
123
|
+
async downloadBinary(onProgress) {
|
|
124
|
+
const osType = (0, helpers_1.detectOS)();
|
|
125
|
+
const binPath = this.getExecutablePath();
|
|
126
|
+
const playitDir = path.dirname(binPath);
|
|
127
|
+
const urls = this.downloadUrls();
|
|
128
|
+
if (!fs.existsSync(playitDir)) {
|
|
129
|
+
fs.mkdirSync(playitDir, { recursive: true });
|
|
130
|
+
}
|
|
131
|
+
const fetchTo = async (url, dest) => {
|
|
132
|
+
const tmp = `${dest}.tmp`;
|
|
133
|
+
await (0, downloadService_1.downloadFile)(url, tmp, (downloaded, total) => {
|
|
134
|
+
if (onProgress && total > 0)
|
|
135
|
+
onProgress(Math.round((downloaded / total) * 100));
|
|
136
|
+
});
|
|
137
|
+
fs.renameSync(tmp, dest);
|
|
138
|
+
if (osType !== 'Windows') {
|
|
139
|
+
try {
|
|
140
|
+
fs.chmodSync(dest, 0o755);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
logger_1.logger.error('chmod failed', err);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
logger_1.logger.info(`Downloading playit agent v${PLAYIT_VERSION} for ${osType}...`);
|
|
148
|
+
await fetchTo(urls.agent, binPath);
|
|
149
|
+
if (urls.cli) {
|
|
150
|
+
logger_1.logger.info('Downloading playit control cli...');
|
|
151
|
+
await fetchTo(urls.cli, this.getCliPath());
|
|
152
|
+
}
|
|
153
|
+
fs.writeFileSync(this.getVersionSentinel(), PLAYIT_VERSION, 'utf-8');
|
|
154
|
+
logger_1.logger.info(`Playit binaries ready (v${PLAYIT_VERSION}).`);
|
|
155
|
+
return binPath;
|
|
156
|
+
}
|
|
157
|
+
async ensureBinary() {
|
|
158
|
+
if (!this.isBinaryPresent()) {
|
|
159
|
+
logger_1.logger.info('Playit binary missing or outdated. Downloading...');
|
|
160
|
+
await this.downloadBinary();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
getSecret() {
|
|
164
|
+
return this.configManager.getConfig().playitSettings.secret || null;
|
|
165
|
+
}
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// playit HTTP API (authenticated with the agent secret)
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
apiPost(apiPath, body, secret) {
|
|
170
|
+
const payload = JSON.stringify(body || {});
|
|
171
|
+
const url = new URL(PLAYIT_API + apiPath);
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
const req = https.request({
|
|
174
|
+
hostname: url.hostname,
|
|
175
|
+
path: url.pathname,
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: {
|
|
178
|
+
'Authorization': `agent-key ${secret}`,
|
|
179
|
+
'Content-Type': 'application/json',
|
|
180
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
181
|
+
},
|
|
182
|
+
}, (res) => {
|
|
183
|
+
let data = '';
|
|
184
|
+
res.on('data', (c) => { data += c; });
|
|
185
|
+
res.on('end', () => {
|
|
186
|
+
let parsed;
|
|
187
|
+
try {
|
|
188
|
+
parsed = JSON.parse(data);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
reject(new Error(`Bad API response: ${data.slice(0, 200)}`));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (parsed.status === 'success') {
|
|
195
|
+
resolve(parsed.data);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const detail = typeof parsed.data === 'string' ? parsed.data : JSON.stringify(parsed.data);
|
|
199
|
+
reject(new Error(`playit API ${apiPath} failed: ${detail}`));
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
req.on('error', reject);
|
|
204
|
+
req.write(payload);
|
|
205
|
+
req.end();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** Returns agent run data: { agent_id, tunnels[], pending[] }. */
|
|
209
|
+
getRunData(secret) {
|
|
210
|
+
return this.apiPost('/agents/rundata', {}, secret);
|
|
211
|
+
}
|
|
212
|
+
/** Picks the public address from a rundata tunnel entry. */
|
|
213
|
+
tunnelAddress(tunnel) {
|
|
214
|
+
const port = tunnel?.port?.from ?? tunnel?.local_port ?? 0;
|
|
215
|
+
return { address: tunnel.assigned_domain, port: String(port) };
|
|
216
|
+
}
|
|
217
|
+
/** Finds an existing tunnel matching the requested protocol. */
|
|
218
|
+
findTunnel(rd, type) {
|
|
219
|
+
const proto = type === 'java' ? 'tcp' : 'udp';
|
|
220
|
+
const tunnels = (rd?.tunnels || []);
|
|
221
|
+
return tunnels.find((t) => t.proto === proto) || null;
|
|
222
|
+
}
|
|
223
|
+
/** Creates a new tunnel via the API (replaces the broken `tunnels prepare` CLI). */
|
|
224
|
+
async createApiTunnel(type, agentId, secret) {
|
|
225
|
+
const body = {
|
|
226
|
+
name: type === 'java' ? 'Minecraft Java' : 'Minecraft Bedrock',
|
|
227
|
+
tunnel_type: type === 'java' ? 'minecraft-java' : 'minecraft-bedrock',
|
|
228
|
+
port_type: type === 'java' ? 'tcp' : 'udp',
|
|
229
|
+
port_count: 1,
|
|
230
|
+
origin: {
|
|
231
|
+
type: 'agent',
|
|
232
|
+
data: {
|
|
233
|
+
agent_id: agentId,
|
|
234
|
+
local_ip: '127.0.0.1',
|
|
235
|
+
local_port: type === 'java' ? 25565 : 19132,
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
enabled: true,
|
|
239
|
+
alloc: null,
|
|
240
|
+
firewall_id: null,
|
|
241
|
+
proxy_protocol: null,
|
|
242
|
+
};
|
|
243
|
+
await this.apiPost('/tunnels/create', body, secret);
|
|
244
|
+
}
|
|
245
|
+
sleep(ms) {
|
|
246
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
247
|
+
}
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// One-time claim flow (uses the control cli; persists the secret)
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
runCli(args, timeoutMs = 30000) {
|
|
252
|
+
const cliPath = this.getCliPath();
|
|
253
|
+
return new Promise((resolve, reject) => {
|
|
254
|
+
(0, child_process_1.execFile)(cliPath, args, { cwd: path.dirname(cliPath), timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
255
|
+
const out = stripAnsi(stdout || '').trim();
|
|
256
|
+
const err = stripAnsi(stderr || '').trim();
|
|
257
|
+
if (error) {
|
|
258
|
+
reject(new Error(err || out || error.message));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
resolve(out);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/** Ensures a write-capable agent secret, driving the one-time claim if needed. */
|
|
266
|
+
async ensureSecret(callbacks = {}) {
|
|
267
|
+
const existing = this.getSecret();
|
|
268
|
+
if (existing)
|
|
269
|
+
return existing;
|
|
270
|
+
await this.ensureBinary();
|
|
271
|
+
callbacks.onStatus?.('Generating a new agent claim code...');
|
|
272
|
+
const code = (await this.runCli(['claim', 'generate'])).replace(/[^a-f0-9]/gi, '');
|
|
273
|
+
if (!code)
|
|
274
|
+
throw new Error('Failed to generate a claim code.');
|
|
275
|
+
const url = (await this.runCli(['claim', 'url', code, '--name', 'mcpanel', '--type', 'self-managed'])).trim();
|
|
276
|
+
this.tunnelStatus.claimUrl = url;
|
|
277
|
+
callbacks.onClaimUrl?.(url);
|
|
278
|
+
callbacks.onStatus?.('Waiting for the agent to be claimed (this only happens once)...');
|
|
279
|
+
const secret = await this.exchangeClaim(code);
|
|
280
|
+
this.configManager.setPlayitSecret(secret);
|
|
281
|
+
this.tunnelStatus.claimUrl = null;
|
|
282
|
+
callbacks.onStatus?.('Agent claimed and linked. Secret saved — future tunnels are fully automatic.');
|
|
283
|
+
return secret;
|
|
284
|
+
}
|
|
285
|
+
/** Spawns `claim exchange` and resolves once a 64-char hex secret is printed. */
|
|
286
|
+
exchangeClaim(code) {
|
|
287
|
+
return new Promise((resolve, reject) => {
|
|
288
|
+
const cliPath = this.getCliPath();
|
|
289
|
+
const child = (0, child_process_1.spawn)(cliPath, ['claim', 'exchange', code, '--wait', '0'], {
|
|
290
|
+
cwd: path.dirname(cliPath),
|
|
291
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
292
|
+
});
|
|
293
|
+
this.claimProcess = child;
|
|
294
|
+
let buffer = '';
|
|
295
|
+
const scan = (data) => {
|
|
296
|
+
buffer += stripAnsi(data.toString());
|
|
297
|
+
const match = buffer.match(/[a-f0-9]{64}/i);
|
|
298
|
+
if (match) {
|
|
299
|
+
this.claimProcess = null;
|
|
300
|
+
try {
|
|
301
|
+
child.kill();
|
|
302
|
+
}
|
|
303
|
+
catch { /* ignore */ }
|
|
304
|
+
resolve(match[0].toLowerCase());
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
child.stdout?.on('data', scan);
|
|
308
|
+
child.stderr?.on('data', scan);
|
|
309
|
+
child.on('close', (codeExit) => {
|
|
310
|
+
if (this.claimProcess === child) {
|
|
311
|
+
this.claimProcess = null;
|
|
312
|
+
const match = buffer.match(/[a-f0-9]{64}/i);
|
|
313
|
+
if (match)
|
|
314
|
+
resolve(match[0].toLowerCase());
|
|
315
|
+
else
|
|
316
|
+
reject(new Error(`Claim was not completed (exit ${codeExit}). Visit the link, then try again.`));
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
child.on('error', (err) => { this.claimProcess = null; reject(err); });
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Full automated setup
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
/**
|
|
326
|
+
* One-call entry point: ensures binary + secret, creates the tunnel via the
|
|
327
|
+
* API, starts the relay daemon, and returns the live tunnel status.
|
|
328
|
+
*/
|
|
329
|
+
async setupAndStart(type, callbacks = {}) {
|
|
330
|
+
await this.ensureBinary();
|
|
331
|
+
const secret = await this.ensureSecret(callbacks);
|
|
332
|
+
this.tunnelStatus.status = 'Connecting';
|
|
333
|
+
this.tunnelStatus.type = type;
|
|
334
|
+
callbacks.onStatus?.('Checking your playit account for an existing tunnel...');
|
|
335
|
+
let rd = await this.getRunData(secret);
|
|
336
|
+
let tunnel = this.findTunnel(rd, type);
|
|
337
|
+
if (!tunnel) {
|
|
338
|
+
callbacks.onStatus?.(`Creating ${type} tunnel...`);
|
|
339
|
+
await this.createApiTunnel(type, rd.agent_id, secret);
|
|
340
|
+
// Poll until the tunnel leaves "pending" and gets a public address.
|
|
341
|
+
for (let i = 0; i < 15 && !tunnel; i++) {
|
|
342
|
+
await this.sleep(3000);
|
|
343
|
+
rd = await this.getRunData(secret);
|
|
344
|
+
tunnel = this.findTunnel(rd, type);
|
|
345
|
+
}
|
|
346
|
+
if (!tunnel) {
|
|
347
|
+
throw new Error('Tunnel was created but no public address appeared yet. Try /tunnel status shortly.');
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const { address, port } = this.tunnelAddress(tunnel);
|
|
351
|
+
this.tunnelStatus.address = address;
|
|
352
|
+
this.tunnelStatus.port = port;
|
|
353
|
+
this.configManager.updatePlayitTunnel({ tunnelAddress: address, tunnelPort: Number(port) });
|
|
354
|
+
callbacks.onStatus?.('Starting tunnel relay...');
|
|
355
|
+
await this.startAgent(secret);
|
|
356
|
+
this.tunnelStatus.status = 'Online';
|
|
357
|
+
return this.tunnelStatus;
|
|
358
|
+
}
|
|
359
|
+
/** Spawns the long-running daemon that relays tunnel traffic. */
|
|
360
|
+
startAgent(secret) {
|
|
361
|
+
return new Promise((resolve) => {
|
|
362
|
+
if (this.playitProcess) {
|
|
363
|
+
resolve();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const binPath = this.getExecutablePath();
|
|
367
|
+
const socketPath = this.getSocketPath();
|
|
368
|
+
// Clear any stale unix socket so the daemon can bind.
|
|
369
|
+
if ((0, helpers_1.detectOS)() !== 'Windows') {
|
|
370
|
+
try {
|
|
371
|
+
if (fs.existsSync(socketPath))
|
|
372
|
+
fs.unlinkSync(socketPath);
|
|
373
|
+
}
|
|
374
|
+
catch { /* ignore */ }
|
|
375
|
+
}
|
|
376
|
+
logger_1.logger.logTunnel('Starting playit relay daemon...');
|
|
377
|
+
this.playitProcess = (0, child_process_1.spawn)(binPath, ['--secret', secret, '--socket-path', socketPath], {
|
|
378
|
+
cwd: path.dirname(binPath),
|
|
379
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
380
|
+
});
|
|
381
|
+
this.playitProcess.stdout?.on('data', (d) => {
|
|
382
|
+
const chunk = stripAnsi(d.toString());
|
|
383
|
+
logger_1.logger.logTunnel(`[stdout] ${chunk.trim()}`);
|
|
384
|
+
this.parsePlayitOutput(chunk);
|
|
385
|
+
});
|
|
386
|
+
this.playitProcess.stderr?.on('data', (d) => {
|
|
387
|
+
const chunk = stripAnsi(d.toString());
|
|
388
|
+
logger_1.logger.logTunnel(`[stderr] ${chunk.trim()}`);
|
|
389
|
+
this.parsePlayitOutput(chunk);
|
|
390
|
+
});
|
|
391
|
+
this.playitProcess.on('close', (code) => {
|
|
392
|
+
logger_1.logger.logTunnel(`Playit relay exited with code ${code}`);
|
|
393
|
+
this.playitProcess = null;
|
|
394
|
+
this.tunnelStatus = this.offlineStatus();
|
|
395
|
+
});
|
|
396
|
+
this.playitProcess.on('error', (err) => {
|
|
397
|
+
logger_1.logger.error('Playit relay process error', err);
|
|
398
|
+
this.tunnelStatus.status = 'Offline';
|
|
399
|
+
});
|
|
400
|
+
// Give the daemon a moment to register, then continue.
|
|
401
|
+
setTimeout(resolve, 2000);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
stopTunnel() {
|
|
405
|
+
let stopped = false;
|
|
406
|
+
if (this.claimProcess) {
|
|
407
|
+
try {
|
|
408
|
+
this.claimProcess.kill('SIGINT');
|
|
409
|
+
}
|
|
410
|
+
catch { /* ignore */ }
|
|
411
|
+
this.claimProcess = null;
|
|
412
|
+
stopped = true;
|
|
413
|
+
}
|
|
414
|
+
if (this.playitProcess) {
|
|
415
|
+
logger_1.logger.logTunnel('Stopping playit relay daemon...');
|
|
416
|
+
try {
|
|
417
|
+
this.playitProcess.kill('SIGINT');
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
logger_1.logger.error('Failed to stop relay', err);
|
|
421
|
+
}
|
|
422
|
+
this.playitProcess = null;
|
|
423
|
+
stopped = true;
|
|
424
|
+
}
|
|
425
|
+
this.tunnelStatus = this.offlineStatus();
|
|
426
|
+
return stopped;
|
|
427
|
+
}
|
|
428
|
+
getStatus() {
|
|
429
|
+
return this.tunnelStatus;
|
|
430
|
+
}
|
|
431
|
+
/** Clears the saved secret so the agent can be re-claimed from scratch. */
|
|
432
|
+
async resetSecret() {
|
|
433
|
+
this.stopTunnel();
|
|
434
|
+
this.configManager.updatePlayitTunnel({ secret: undefined });
|
|
435
|
+
try {
|
|
436
|
+
await this.runCli(['reset']);
|
|
437
|
+
}
|
|
438
|
+
catch { /* local secret already cleared */ }
|
|
439
|
+
}
|
|
440
|
+
/** Parses relay logs to keep latency/status fresh. */
|
|
441
|
+
parsePlayitOutput(output) {
|
|
442
|
+
if (/agent registered|udp session details|tunnel running|tunnel active/i.test(output)) {
|
|
443
|
+
this.tunnelStatus.status = 'Online';
|
|
444
|
+
}
|
|
445
|
+
const pingMatch = output.match(/ping:\s*(\d+\.?\d*ms)/i) || output.match(/latency:\s*(\d+\.?\d*ms)/i);
|
|
446
|
+
if (pingMatch)
|
|
447
|
+
this.tunnelStatus.latency = pingMatch[1];
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
exports.PlayitManager = PlayitManager;
|