@fenwave/agent 1.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.
@@ -0,0 +1,318 @@
1
+ import inquirer from 'inquirer';
2
+ import ora from 'ora';
3
+ import chalk from 'chalk';
4
+
5
+ /**
6
+ * Prompt for registration token
7
+ *
8
+ * @param {string} backstageUrl - Backstage URL to display
9
+ * @returns {Promise<string>} Registration token
10
+ */
11
+ export async function promptRegistrationToken(backstageUrl) {
12
+ console.log(chalk.blue('\nšŸ“ Registration Token Required\n'));
13
+ console.log(chalk.gray(`Get your token from: ${backstageUrl}/agent-installer\n`));
14
+
15
+ const answers = await inquirer.prompt([
16
+ {
17
+ type: 'password',
18
+ name: 'token',
19
+ message: 'Enter your registration token:',
20
+ mask: '*',
21
+ validate: (input) => {
22
+ if (!input || input.trim().length === 0) {
23
+ return 'Registration token is required';
24
+ }
25
+ if (input.length < 32) {
26
+ return 'Invalid token format';
27
+ }
28
+ return true;
29
+ },
30
+ },
31
+ ]);
32
+
33
+ return answers.token.trim();
34
+ }
35
+
36
+ /**
37
+ * Prompt for confirmation
38
+ *
39
+ * @param {string} message - Confirmation message
40
+ * @param {boolean} defaultValue - Default value (true/false)
41
+ * @returns {Promise<boolean>} User's answer
42
+ */
43
+ export async function promptConfirmation(message, defaultValue = true) {
44
+ const answers = await inquirer.prompt([
45
+ {
46
+ type: 'confirm',
47
+ name: 'confirmed',
48
+ message,
49
+ default: defaultValue,
50
+ },
51
+ ]);
52
+
53
+ return answers.confirmed;
54
+ }
55
+
56
+ /**
57
+ * Prompt for Docker registry configuration
58
+ *
59
+ * @returns {Promise<Object>} Registry configuration
60
+ */
61
+ export async function promptDockerRegistries() {
62
+ console.log(chalk.blue('\n🐳 Docker Registry Configuration\n'));
63
+
64
+ const answers = await inquirer.prompt([
65
+ {
66
+ type: 'confirm',
67
+ name: 'configureEcr',
68
+ message: 'Configure AWS ECR for Fenwave images?',
69
+ default: true,
70
+ },
71
+ {
72
+ type: 'input',
73
+ name: 'awsRegion',
74
+ message: 'AWS Region:',
75
+ default: 'eu-west-1',
76
+ when: (answers) => answers.configureEcr,
77
+ validate: (input) => {
78
+ if (!input || input.trim().length === 0) {
79
+ return 'AWS Region is required';
80
+ }
81
+ return true;
82
+ },
83
+ },
84
+ {
85
+ type: 'confirm',
86
+ name: 'addCustomRegistry',
87
+ message: 'Add custom Docker registry?',
88
+ default: false,
89
+ },
90
+ ]);
91
+
92
+ const registries = [];
93
+
94
+ if (answers.configureEcr) {
95
+ registries.push({
96
+ type: 'ecr',
97
+ region: answers.awsRegion,
98
+ });
99
+ }
100
+
101
+ if (answers.addCustomRegistry) {
102
+ const customRegistry = await inquirer.prompt([
103
+ {
104
+ type: 'input',
105
+ name: 'url',
106
+ message: 'Registry URL:',
107
+ validate: (input) => {
108
+ if (!input || input.trim().length === 0) {
109
+ return 'Registry URL is required';
110
+ }
111
+ return true;
112
+ },
113
+ },
114
+ {
115
+ type: 'input',
116
+ name: 'username',
117
+ message: 'Username:',
118
+ },
119
+ {
120
+ type: 'password',
121
+ name: 'password',
122
+ message: 'Password:',
123
+ mask: '*',
124
+ },
125
+ ]);
126
+
127
+ registries.push({
128
+ type: 'custom',
129
+ ...customRegistry,
130
+ });
131
+ }
132
+
133
+ return registries;
134
+ }
135
+
136
+ /**
137
+ * Prompt for choice from a list
138
+ *
139
+ * @param {string} message - Prompt message
140
+ * @param {Array} choices - Array of choices
141
+ * @param {string} defaultChoice - Default choice
142
+ * @returns {Promise<string>} Selected choice
143
+ */
144
+ export async function promptChoice(message, choices, defaultChoice) {
145
+ const answers = await inquirer.prompt([
146
+ {
147
+ type: 'list',
148
+ name: 'choice',
149
+ message,
150
+ choices,
151
+ default: defaultChoice,
152
+ },
153
+ ]);
154
+
155
+ return answers.choice;
156
+ }
157
+
158
+ /**
159
+ * Prompt for text input
160
+ *
161
+ * @param {string} message - Prompt message
162
+ * @param {string} defaultValue - Default value
163
+ * @param {Function} validate - Validation function
164
+ * @returns {Promise<string>} User input
165
+ */
166
+ export async function promptInput(message, defaultValue, validate) {
167
+ const answers = await inquirer.prompt([
168
+ {
169
+ type: 'input',
170
+ name: 'input',
171
+ message,
172
+ default: defaultValue,
173
+ validate,
174
+ },
175
+ ]);
176
+
177
+ return answers.input;
178
+ }
179
+
180
+ /**
181
+ * Display progress with spinner
182
+ *
183
+ * @param {string} message - Progress message
184
+ * @param {Promise} promise - Promise to await
185
+ * @returns {Promise} Result of the promise
186
+ */
187
+ export async function displayProgress(message, promise) {
188
+ const spinner = ora(message).start();
189
+
190
+ try {
191
+ const result = await promise;
192
+ spinner.succeed(chalk.green(message));
193
+ return result;
194
+ } catch (error) {
195
+ spinner.fail(chalk.red(`${message} - ${error.message}`));
196
+ throw error;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Display success message
202
+ *
203
+ * @param {string} message - Success message
204
+ */
205
+ export function displaySuccess(message) {
206
+ console.log(chalk.green('āœ… ' + message));
207
+ }
208
+
209
+ /**
210
+ * Display error message
211
+ *
212
+ * @param {string} message - Error message
213
+ */
214
+ export function displayError(message) {
215
+ console.log(chalk.red('āŒ ' + message));
216
+ }
217
+
218
+ /**
219
+ * Display warning message
220
+ *
221
+ * @param {string} message - Warning message
222
+ */
223
+ export function displayWarning(message) {
224
+ console.log(chalk.yellow('āš ļø ' + message));
225
+ }
226
+
227
+ /**
228
+ * Display info message
229
+ *
230
+ * @param {string} message - Info message
231
+ */
232
+ export function displayInfo(message) {
233
+ console.log(chalk.blue('ā„¹ļø ' + message));
234
+ }
235
+
236
+ /**
237
+ * Display header with decorative border
238
+ *
239
+ * @param {string} title - Header title
240
+ */
241
+ export function displayHeader(title) {
242
+ const border = '━'.repeat(70);
243
+ console.log('\n' + chalk.cyan(border));
244
+ console.log(chalk.cyan.bold(` ${title}`));
245
+ console.log(chalk.cyan(border) + '\n');
246
+ }
247
+
248
+ /**
249
+ * Display step indicator
250
+ *
251
+ * @param {number} current - Current step
252
+ * @param {number} total - Total steps
253
+ * @param {string} description - Step description
254
+ */
255
+ export function displayStep(current, total, description) {
256
+ console.log(chalk.bold(`\nStep ${current}/${total}: ${description}`));
257
+ }
258
+
259
+ /**
260
+ * Display a list of items
261
+ *
262
+ * @param {Array} items - Items to display
263
+ * @param {string} prefix - Prefix for each item (default: ' • ')
264
+ */
265
+ export function displayList(items, prefix = ' • ') {
266
+ items.forEach((item) => {
267
+ console.log(chalk.gray(prefix + item));
268
+ });
269
+ }
270
+
271
+ /**
272
+ * Display key-value pairs
273
+ *
274
+ * @param {Object} data - Data to display
275
+ * @param {string} indent - Indentation (default: ' ')
276
+ */
277
+ export function displayKeyValue(data, indent = ' ') {
278
+ Object.entries(data).forEach(([key, value]) => {
279
+ console.log(chalk.gray(`${indent}${key}: `) + chalk.white(value));
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Clear console
285
+ */
286
+ export function clearConsole() {
287
+ console.clear();
288
+ }
289
+
290
+ /**
291
+ * Prompt for backend URL
292
+ *
293
+ * @param {string} defaultUrl - Default backend URL
294
+ * @returns {Promise<string>} Backend URL
295
+ */
296
+ export async function promptBackendUrl(defaultUrl = 'http://localhost:7007') {
297
+ const answers = await inquirer.prompt([
298
+ {
299
+ type: 'input',
300
+ name: 'url',
301
+ message: 'Backstage Backend URL:',
302
+ default: defaultUrl,
303
+ validate: (input) => {
304
+ if (!input || input.trim().length === 0) {
305
+ return 'Backend URL is required';
306
+ }
307
+ try {
308
+ new URL(input);
309
+ return true;
310
+ } catch (error) {
311
+ return 'Invalid URL format';
312
+ }
313
+ },
314
+ },
315
+ ]);
316
+
317
+ return answers.url.trim();
318
+ }
@@ -0,0 +1,364 @@
1
+ import { WebSocketServer } from "ws";
2
+ import { v4 as uuidv4 } from "uuid";
3
+ import crypto from "crypto";
4
+ import fs from "fs";
5
+ import path from "path";
6
+ import chalk from "chalk";
7
+ import os from "os";
8
+ import { loadConfig } from "./store/configStore.js";
9
+
10
+ // Import handlers
11
+ import containerHandlers from "./docker-actions/containers.js";
12
+ import imageHandlers from "./docker-actions/images.js";
13
+ import volumeHandlers from "./docker-actions/volumes.js";
14
+ import appHandlers from "./docker-actions/apps.js";
15
+ import registryHandlers from "./docker-actions/registry.js";
16
+ import logHandlers from "./docker-actions/logs.js";
17
+ import metricsHandlers from "./docker-actions/metrics.js";
18
+ import terminalHandlers from "./docker-actions/terminal.js";
19
+ import generalHandlers from "./docker-actions/general.js";
20
+ import agentSessionStore from "./store/agentSessionStore.js";
21
+
22
+ // Store active streams and sessions
23
+ const activeStreams = new Map();
24
+ const terminalSessions = new Map();
25
+
26
+ // Load configuration
27
+ const config = loadConfig();
28
+ const AGENT_ROOT_DIR = path.join(os.homedir(), config.agentRootDir);
29
+ const WS_TOKEN_FILE = path.join(AGENT_ROOT_DIR, "ws-token");
30
+ const AUTH_TIMEOUT_MS = 30000; // 30 seconds to authenticate
31
+
32
+ /**
33
+ * Generate a secure random WebSocket authentication token
34
+ * @returns {string} 64-character hex token
35
+ */
36
+ function generateWsToken() {
37
+ return crypto.randomBytes(32).toString("hex");
38
+ }
39
+
40
+ /**
41
+ * Save WebSocket token to file for local-env app to read
42
+ * @param {string} token - The WebSocket authentication token
43
+ */
44
+ function saveWsToken(token) {
45
+ // Ensure directory exists
46
+ if (!fs.existsSync(AGENT_ROOT_DIR)) {
47
+ fs.mkdirSync(AGENT_ROOT_DIR, { recursive: true, mode: 0o700 });
48
+ }
49
+
50
+ // Write token to file with restricted permissions (owner only)
51
+ fs.writeFileSync(WS_TOKEN_FILE, token, { mode: 0o600 });
52
+ }
53
+
54
+ /**
55
+ * Read WebSocket token from file
56
+ * @returns {string|null} The WebSocket token or null if not found
57
+ */
58
+ function readWsToken() {
59
+ try {
60
+ if (fs.existsSync(WS_TOKEN_FILE)) {
61
+ return fs.readFileSync(WS_TOKEN_FILE, "utf-8").trim();
62
+ }
63
+ } catch (error) {
64
+ console.error("Error reading WS token:", error.message);
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function setupWebSocketServer(
70
+ server,
71
+ clients,
72
+ agentId,
73
+ sessionInfo = {},
74
+ existingToken = null
75
+ ) {
76
+ // Store agent session info for use in handlers
77
+ agentSessionStore.setAgentSessionInfo({
78
+ agentId: agentId,
79
+ userEntityRef: sessionInfo.userEntityRef || null,
80
+ sessionExpiresAt: sessionInfo.expiresAt || null,
81
+ });
82
+
83
+ // Use existing token or generate a new one
84
+ const wsToken = existingToken || generateWsToken();
85
+ if (!existingToken) {
86
+ // Only save if we generated a new token
87
+ saveWsToken(wsToken);
88
+ }
89
+
90
+ const wss = new WebSocketServer({ server });
91
+
92
+ // Set up cleanup timer for inactive terminal sessions
93
+ setInterval(() => {
94
+ terminalHandlers.cleanupInactiveTerminalSessions(terminalSessions);
95
+ }, 15 * 60 * 1000); // Run every 15 minutes
96
+
97
+ wss.on("connection", (ws) => {
98
+ // Generate a unique client ID for each WebSocket connection
99
+ const clientId = uuidv4();
100
+ const connectionTime = Date.now();
101
+ let authenticated = false;
102
+
103
+ // Set authentication timeout
104
+ const authTimeout = setTimeout(() => {
105
+ if (!authenticated) {
106
+ ws.send(
107
+ JSON.stringify({
108
+ type: "auth_error",
109
+ error: "Authentication timeout. Connection closed.",
110
+ })
111
+ );
112
+ ws.close(4001, "Authentication timeout");
113
+ }
114
+ }, AUTH_TIMEOUT_MS);
115
+
116
+ // Send authentication challenge
117
+ ws.send(
118
+ JSON.stringify({
119
+ type: "auth_required",
120
+ message:
121
+ "Authentication required. Please send auth message with token.",
122
+ clientId,
123
+ })
124
+ );
125
+
126
+ // Handle messages from client
127
+ ws.on("message", async (message) => {
128
+ let data;
129
+ try {
130
+ data = JSON.parse(message);
131
+ } catch (parseError) {
132
+ console.error("Error parsing message: ", parseError);
133
+ ws.send(
134
+ JSON.stringify({
135
+ type: "error",
136
+ error: "Failed to parse message: " + parseError.message,
137
+ })
138
+ );
139
+ return;
140
+ }
141
+
142
+ // Handle authentication
143
+ if (!authenticated) {
144
+ if (data.type === "auth" && data.token) {
145
+ if (data.token === wsToken) {
146
+ // Authentication successful
147
+ authenticated = true;
148
+ clearTimeout(authTimeout);
149
+
150
+ // Store client info
151
+ clients.set(clientId, {
152
+ ws,
153
+ connectionTime,
154
+ lastActivity: Date.now(),
155
+ isActive: true,
156
+ authenticated: true,
157
+ });
158
+
159
+ console.log(`āœ… Client authenticated: ${clientId}`);
160
+
161
+ // Send success response
162
+ ws.send(
163
+ JSON.stringify({
164
+ type: "auth_success",
165
+ clientId,
166
+ agentId: agentId,
167
+ message: "Successfully authenticated with Fenwave Agent",
168
+ })
169
+ );
170
+ } else {
171
+ // Invalid token
172
+ console.log(
173
+ chalk.red(
174
+ `āŒ Invalid authentication token from client: ${clientId}`
175
+ )
176
+ );
177
+ ws.send(
178
+ JSON.stringify({
179
+ type: "auth_error",
180
+ error: "Invalid authentication token",
181
+ })
182
+ );
183
+ ws.close(4003, "Invalid authentication token");
184
+ }
185
+ } else {
186
+ // Not an auth message
187
+ ws.send(
188
+ JSON.stringify({
189
+ type: "auth_error",
190
+ error: "Authentication required before sending messages",
191
+ })
192
+ );
193
+ }
194
+ return;
195
+ }
196
+
197
+ // Update last activity time for authenticated clients
198
+ const client = clients.get(clientId);
199
+ if (client) {
200
+ client.lastActivity = Date.now();
201
+ }
202
+
203
+ console.log(`šŸ“Ø Received message from client ${clientId}:`, data.action);
204
+
205
+ // Route messages to appropriate handlers
206
+ await routeMessage(ws, clientId, data);
207
+ });
208
+
209
+ // Handle client disconnect
210
+ ws.on("close", () => {
211
+ console.log(`šŸ”“ Client disconnected: ${clientId}`);
212
+ clients.delete(clientId);
213
+
214
+ // Clean up any active streams for this client
215
+ for (const [streamId, stream] of activeStreams.entries()) {
216
+ if (streamId.startsWith(clientId)) {
217
+ if (stream.destroy) stream.destroy();
218
+ activeStreams.delete(streamId);
219
+ }
220
+ }
221
+ });
222
+ });
223
+
224
+ return wss;
225
+ }
226
+
227
+ async function routeMessage(ws, clientId, data) {
228
+ const { action, payload = {} } = data;
229
+
230
+ // Container actions
231
+ if (
232
+ action === "fetchContainers" ||
233
+ action === "startContainer" ||
234
+ action === "stopContainer" ||
235
+ action === "restartContainer" ||
236
+ action === "deleteContainer" ||
237
+ action === "createContainer" ||
238
+ action === "inspectContainer"
239
+ ) {
240
+ return await containerHandlers.handleContainerAction(ws, action, payload);
241
+ }
242
+
243
+ // Image actions
244
+ if (
245
+ action === "fetchImages" ||
246
+ action === "pullImage" ||
247
+ action === "pushImage" ||
248
+ action === "deleteImage" ||
249
+ action === "tagImage" ||
250
+ action === "inspectImage"
251
+ ) {
252
+ return await imageHandlers.handleImageAction(ws, action, payload);
253
+ }
254
+
255
+ // Volume actions
256
+ if (
257
+ action === "fetchVolumes" ||
258
+ action === "createVolume" ||
259
+ action === "deleteVolume" ||
260
+ action === "inspectVolume" ||
261
+ action === "getContainersUsingVolume" ||
262
+ action === "fetchImages" ||
263
+ action === "pullImage" ||
264
+ action === "backupVolume" ||
265
+ action === "listVolumeBackups" ||
266
+ action === "fetchImages" ||
267
+ action === "pullImage" ||
268
+ action === "restoreVolumeFromBackup" ||
269
+ action === "deleteVolumeBackup" ||
270
+ action === "getBackupDownloadUrl"
271
+ ) {
272
+ return await volumeHandlers.handleVolumeAction(ws, action, payload);
273
+ }
274
+
275
+ // App actions
276
+ if (
277
+ action === "fetchApps" ||
278
+ action === "fetchAppVersions" ||
279
+ action === "startApp" ||
280
+ action === "stopApp" ||
281
+ action === "restartApp" ||
282
+ action === "deleteApp" ||
283
+ action === "deleteAppVersions" ||
284
+ action === "createApp" ||
285
+ action === "validateCompose" ||
286
+ action === "syncApp" ||
287
+ action === "changeVersion"
288
+ ) {
289
+ return await appHandlers.handleAppAction(ws, action, payload);
290
+ }
291
+
292
+ // Registry actions
293
+ if (
294
+ action === "fetchRegistries" ||
295
+ action === "connectRegistry" ||
296
+ action === "disconnectRegistry" ||
297
+ action === "renameRegistry" ||
298
+ action === "fetchRegistryImages" ||
299
+ action === "setActiveRegistry"
300
+ ) {
301
+ return await registryHandlers.handleRegistryAction(ws, action, payload);
302
+ }
303
+
304
+ // Log actions
305
+ if (
306
+ action === "fetchContainerLogs" ||
307
+ action === "streamContainerLogs" ||
308
+ action === "stopStreamLogs"
309
+ ) {
310
+ return await logHandlers.handleLogAction(
311
+ ws,
312
+ clientId,
313
+ action,
314
+ payload,
315
+ activeStreams
316
+ );
317
+ }
318
+
319
+ // Metrics actions
320
+ if (
321
+ action === "fetchSystemMetrics" ||
322
+ action === "fetchContainerMetrics" ||
323
+ action === "systemMetrics"
324
+ ) {
325
+ return await metricsHandlers.handleMetricsAction(ws, action, payload);
326
+ }
327
+
328
+ // Terminal actions
329
+ if (
330
+ action === "openTerminalSession" ||
331
+ action === "closeTerminalSession" ||
332
+ action === "sendTerminalInput" ||
333
+ action === "resizeTerminal"
334
+ ) {
335
+ return await terminalHandlers.handleTerminalAction(
336
+ ws,
337
+ clientId,
338
+ action,
339
+ payload,
340
+ terminalSessions
341
+ );
342
+ }
343
+
344
+ // General actions
345
+ if (["getAgentInfo", "getDockerInfo", "execInContainer"].includes(action)) {
346
+ return await generalHandlers.handleGeneralAction(
347
+ ws,
348
+ clientId,
349
+ action,
350
+ payload
351
+ );
352
+ }
353
+
354
+ // Unknown action
355
+ ws.send(
356
+ JSON.stringify({
357
+ type: "error",
358
+ requestId: payload.requestId,
359
+ error: `Unknown action: ${action}`,
360
+ })
361
+ );
362
+ }
363
+
364
+ export { setupWebSocketServer, generateWsToken, saveWsToken, readWsToken };