add docker cli access per connection

This commit is contained in:
Raven Scott 2024-12-01 23:48:26 -05:00
parent 9389d41364
commit 3e37359e61
4 changed files with 257 additions and 107 deletions

58
app.js
View File

@ -1,6 +1,7 @@
import Hyperswarm from 'hyperswarm'; import Hyperswarm from 'hyperswarm';
import b4a from 'b4a'; import b4a from 'b4a';
import { startTerminal, appendTerminalOutput } from './libs/terminal.js'; import { startTerminal, appendTerminalOutput } from './libs/terminal.js';
import { startDockerTerminal, cleanUpDockerTerminal } from './libs/dockerTerminal.js';
// DOM Elements // DOM Elements
const containerList = document.getElementById('container-list'); const containerList = document.getElementById('container-list');
@ -32,6 +33,16 @@ function stopStatsInterval() {
} }
} }
document.addEventListener('DOMContentLoaded', () => {
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
if (dockerTerminalModal) {
dockerTerminalModal.addEventListener('hidden.bs.modal', () => {
console.log('[INFO] Modal fully closed. Performing additional cleanup.');
cleanUpDockerTerminal();
});
}
});
function startStatsInterval() { function startStatsInterval() {
if (statsInterval) { if (statsInterval) {
@ -304,8 +315,6 @@ function handlePeerData(data, topicId, peer) {
switch (response.type) { switch (response.type) {
case 'stats': case 'stats':
console.log('[INFO] Updating container stats...'); console.log('[INFO] Updating container stats...');
// Ensure IP is included and passed to updateContainerStats
const stats = response.data; const stats = response.data;
stats.ip = stats.ip || 'No IP Assigned'; // Add a fallback for missing IPs stats.ip = stats.ip || 'No IP Assigned'; // Add a fallback for missing IPs
console.log(`[DEBUG] Passing stats to updateContainerStats: ${JSON.stringify(stats, null, 2)}`); console.log(`[DEBUG] Passing stats to updateContainerStats: ${JSON.stringify(stats, null, 2)}`);
@ -352,6 +361,7 @@ function handlePeerData(data, topicId, peer) {
// Add a new connection // Add a new connection
addConnectionForm.addEventListener('submit', (e) => { addConnectionForm.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
@ -394,17 +404,41 @@ function addConnection(topicHex) {
`; `;
// Add Docker Terminal button event listener // Add Docker Terminal button event listener
connectionItem.querySelector('.docker-terminal-btn').addEventListener('click', (e) => { connectionItem.querySelector('.docker-terminal-btn')?.addEventListener('click', (event) => {
e.stopPropagation(); event.stopPropagation();
const connection = connections[topicId];
if (connection && connection.peer) { console.log('[DEBUG] Docker terminal button clicked.');
import('./libs/dockerTerminal.js').then(({ startDockerTerminal }) => {
startDockerTerminal(topicId, connection.peer); if (!topicId) {
}); console.error('[ERROR] Missing topicId. Cannot proceed.');
} else { return;
console.error('[ERROR] No active peer for Docker CLI terminal.');
} }
})
const connection = connections[topicId];
console.log(`[DEBUG] Retrieved connection for topicId: ${topicId}`, connection);
if (connection && connection.peer) {
try {
console.log(`[DEBUG] Starting Docker terminal for topicId: ${topicId}`);
startDockerTerminal(topicId, connection.peer);
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
if (dockerTerminalModal) {
const modalInstance = new bootstrap.Modal(dockerTerminalModal);
modalInstance.show();
console.log('[DEBUG] Docker Terminal modal displayed.');
} else {
console.error('[ERROR] Docker Terminal modal not found in the DOM.');
}
} catch (error) {
console.error(`[ERROR] Failed to start Docker CLI terminal for topicId: ${topicId}`, error);
}
} else {
console.warn(`[WARNING] No active peer found for topicId: ${topicId}. Unable to start Docker CLI terminal.`);
}
});
connectionItem.querySelector('span').addEventListener('click', () => switchConnection(topicId)); connectionItem.querySelector('span').addEventListener('click', () => switchConnection(topicId));
connectionItem.querySelector('.disconnect-btn').addEventListener('click', (e) => { connectionItem.querySelector('.disconnect-btn').addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();

View File

@ -375,6 +375,7 @@
background-color: #999; background-color: #999;
/* Even lighter color when active */ /* Even lighter color when active */
} }
</style> </style>
</head> </head>
@ -515,17 +516,19 @@
</div> </div>
</div> </div>
<!-- Docker Terminal Modal --> <!-- Docker Terminal Modal -->
<!-- Docker CLI Terminal Modal --> <div class="modal fade" id="dockerTerminalModal" tabindex="-1" aria-labelledby="docker-terminal-title" aria-hidden="true">
<div id="docker-terminal-modal" class="position-fixed bottom-0 start-0 w-100 max-height-90 bg-dark text-white d-flex flex-column" style="display: none; z-index: 1000;"> <div class="modal-dialog modal-lg">
<div class="header d-flex justify-content-between align-items-center bg-secondary p-2"> <div class="modal-content bg-dark text-white">
<span id="docker-terminal-title" class="fw-bold">Docker CLI Terminal</span> <div class="modal-header">
<button id="docker-kill-terminal-btn" class="btn btn-sm btn-danger"> <h5 id="docker-terminal-title" class="modal-title">Docker CLI Terminal</h5>
<i class="fas fa-times-circle"></i> <button id="docker-kill-terminal-btn" type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</button> </div>
<div class="modal-body">
<div id="docker-terminal-container"></div>
</div>
</div>
</div> </div>
<div id="docker-terminal-container" class="flex-grow-1 overflow-hidden" style="background-color: black;"></div>
<div id="docker-terminal-resize-handle" class="bg-secondary" style="height: 10px; cursor: ns-resize;"></div>
</div> </div>
<!-- Alert Container --> <!-- Alert Container -->

View File

@ -10,6 +10,11 @@ const dockerKillTerminalBtn = document.getElementById('docker-kill-terminal-btn'
// Terminal variables // Terminal variables
let dockerTerminalSession = null; let dockerTerminalSession = null;
/**
* Initialize and start the Docker CLI terminal.
* @param {string} connectionId - Unique ID for the connection.
* @param {Object} peer - Active peer object for communication.
*/
function startDockerTerminal(connectionId, peer) { function startDockerTerminal(connectionId, peer) {
if (!peer) { if (!peer) {
console.error('[ERROR] No active peer for Docker CLI terminal.'); console.error('[ERROR] No active peer for Docker CLI terminal.');
@ -21,6 +26,18 @@ function startDockerTerminal(connectionId, peer) {
return; return;
} }
// Verify DOM elements
const dockerTerminalContainer = document.getElementById('docker-terminal-container');
const dockerTerminalTitle = document.getElementById('docker-terminal-title');
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
const dockerKillTerminalBtn = document.getElementById('docker-kill-terminal-btn');
if (!dockerTerminalContainer || !dockerTerminalTitle || !dockerTerminalModal || !dockerKillTerminalBtn) {
console.error('[ERROR] Missing required DOM elements for Docker CLI terminal.');
return;
}
// Initialize the xterm.js terminal
const xterm = new Terminal({ const xterm = new Terminal({
cursorBlink: true, cursorBlink: true,
theme: { background: '#000000', foreground: '#ffffff' }, theme: { background: '#000000', foreground: '#ffffff' },
@ -28,84 +45,168 @@ function startDockerTerminal(connectionId, peer) {
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
xterm.loadAddon(fitAddon); xterm.loadAddon(fitAddon);
// Prepare the terminal container
dockerTerminalContainer.innerHTML = ''; // Clear previous content dockerTerminalContainer.innerHTML = ''; // Clear previous content
xterm.open(dockerTerminalContainer); xterm.open(dockerTerminalContainer);
fitAddon.fit(); fitAddon.fit();
dockerTerminalSession = { xterm, fitAddon, connectionId, peer }; dockerTerminalSession = { xterm, fitAddon, connectionId, peer };
let inputBuffer = ''; // Buffer for user input // Buffer to accumulate user input
let inputBuffer = '';
// Handle terminal input
xterm.onData((input) => { xterm.onData((input) => {
if (input === '\r') { // User pressed Enter if (input === '\r') {
const sanitizedInput = sanitizeDockerCommand(inputBuffer.trim()); // User pressed Enter
if (sanitizedInput) { const fullCommand = prependDockerCommand(inputBuffer.trim());
console.log(`[DEBUG] Sending Docker CLI command: ${sanitizedInput}`); if (fullCommand) {
console.log(`[DEBUG] Sending Docker CLI command: ${fullCommand}`);
peer.write( peer.write(
JSON.stringify({ JSON.stringify({
type: 'dockerCommand', command: 'dockerCommand',
connectionId, connectionId,
data: sanitizedInput, data: fullCommand,
}) })
); );
xterm.write('\r\n'); // Move to the next line in the terminal xterm.write('\r\n'); // Move to the next line
} else { } else {
xterm.write('\r\n[ERROR] Invalid command. Only Docker CLI commands are allowed.\r\n'); xterm.write('\r\n[ERROR] Invalid command. Please check your input.\r\n');
} }
inputBuffer = ''; // Clear the buffer after processing inputBuffer = ''; // Clear the buffer after processing
} else if (input === '\u007F') { // Handle backspace } else if (input === '\u007F') {
// Handle backspace
if (inputBuffer.length > 0) { if (inputBuffer.length > 0) {
inputBuffer = inputBuffer.slice(0, -1); // Remove the last character from the buffer inputBuffer = inputBuffer.slice(0, -1); // Remove last character
xterm.write('\b \b'); // Erase the character from the terminal display xterm.write('\b \b'); // Erase character from display
} }
} else { } else {
inputBuffer += input; // Append input to the buffer // Append input to buffer and display it
xterm.write(input); // Display input in the terminal inputBuffer += input;
xterm.write(input);
} }
}); });
// Handle peer data
peer.on('data', (data) => { peer.on('data', (data) => {
console.log('[DEBUG] Received data event');
try { try {
const response = JSON.parse(data.toString()); const response = JSON.parse(data.toString());
if (response.type === 'dockerOutput' && response.connectionId === connectionId) { if (response.connectionId === connectionId) {
xterm.write(`${response.data}\r\n`); const decodedData = decodeResponseData(response.data, response.encoding);
if (response.type === 'dockerOutput') {
xterm.write(`${decodedData.trim()}\r\n`);
} else if (response.type === 'terminalErrorOutput') {
xterm.write(`\r\n[ERROR] ${decodedData.trim()}\r\n`);
}
} }
} catch (error) { } catch (error) {
console.error(`[ERROR] Failed to parse response from peer: ${error.message}`); console.error(`[ERROR] Failed to parse response from peer: ${error.message}`);
} }
}); });
// Update the terminal modal and title
dockerTerminalTitle.textContent = `Docker CLI Terminal: ${connectionId}`; dockerTerminalTitle.textContent = `Docker CLI Terminal: ${connectionId}`;
dockerTerminalModal.style.display = 'flex'; const modalInstance = new bootstrap.Modal(dockerTerminalModal);
modalInstance.show();
// Attach event listener for Kill Terminal button
dockerKillTerminalBtn.onclick = () => { dockerKillTerminalBtn.onclick = () => {
cleanUpDockerTerminal(); cleanUpDockerTerminal();
}; };
} }
// Sanitize input to ensure only Docker CLI commands are allowed /**
function sanitizeDockerCommand(command) { * Prepend 'docker' to the command if it's missing.
// Allow commands starting with "docker" and disallow dangerous operators * @param {string} command - Command string entered by the user.
const isValid = /^docker(\s+[\w.-]+)*$/i.test(command); * @returns {string|null} - Full Docker command or null if invalid.
return isValid ? command : null; */
function prependDockerCommand(command) {
// Disallow dangerous operators
if (/[\|;&`]/.test(command)) {
console.warn('[WARN] Invalid characters detected in command.');
return null;
} }
// Clean up Docker CLI terminal session // Prepend 'docker' if not already present
function cleanUpDockerTerminal() { if (!command.startsWith('docker ')) {
if (dockerTerminalSession) { return `docker ${command}`;
dockerTerminalSession.xterm.dispose(); }
dockerTerminalSession = null; return command;
dockerTerminalContainer.innerHTML = ''; // Clear terminal content }
dockerTerminalModal.style.display = 'none'; // Hide the modal
dockerTerminalModal.classList.add('hidden'); /**
console.log('[INFO] Docker CLI terminal session cleaned up.'); * Decode response data from Base64 or return as-is if not encoded.
* @param {string} data - Response data from the server.
* @param {string} encoding - Encoding type (e.g., 'base64').
* @returns {string} - Decoded or plain data.
*/
function decodeResponseData(data, encoding) {
if (encoding === 'base64') {
try {
return atob(data); // Decode Base64 data
} catch (error) {
console.error(`[ERROR] Failed to decode Base64 data: ${error.message}`);
return '[ERROR] Command failed.';
} }
} }
return data; // Return plain data if not encoded
}
/**
* Clean up the Docker CLI terminal session.
*/
function cleanUpDockerTerminal() {
console.log('[INFO] Cleaning up Docker Terminal...');
// Handle Kill Terminal button // Retrieve the required DOM elements
const dockerTerminalContainer = document.getElementById('docker-terminal-container');
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
if (!dockerTerminalContainer || !dockerTerminalModal) {
console.error('[ERROR] Required DOM elements not found for cleaning up the Docker Terminal.');
return;
}
// Dispose of the terminal session if it exists
if (dockerTerminalSession) {
if (dockerTerminalSession.xterm) {
dockerTerminalSession.xterm.dispose();
}
dockerTerminalSession = null; // Reset the session object
}
// Clear the terminal content
dockerTerminalContainer.innerHTML = '';
// Use Bootstrap API to hide the modal
const modalInstance = bootstrap.Modal.getInstance(dockerTerminalModal);
if (modalInstance) {
modalInstance.hide();
} else {
console.warn('[WARNING] Modal instance not found. Falling back to manual close.');
dockerTerminalModal.style.display = 'none';
}
// Ensure lingering backdrops are removed
const backdrop = document.querySelector('.modal-backdrop');
if (backdrop) {
backdrop.remove();
}
// Restore the body's scroll behavior
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
console.log('[INFO] Docker CLI terminal session cleanup completed.');
}
// Attach event listener for Kill Terminal button (redundant safety check)
dockerKillTerminalBtn.onclick = () => { dockerKillTerminalBtn.onclick = () => {
cleanUpDockerTerminal(); cleanUpDockerTerminal();
}; };
// Export functions // Export functions
export { startDockerTerminal, cleanUpDockerTerminal }; export { startDockerTerminal, cleanUpDockerTerminal, dockerTerminalSession };

View File

@ -7,6 +7,7 @@ import { PassThrough } from 'stream';
import os from "os"; import os from "os";
import fs from 'fs'; import fs from 'fs';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { spawn } from 'child_process';
// Load environment variables from .env file // Load environment variables from .env file
dotenv.config(); dotenv.config();
@ -98,6 +99,68 @@ swarm.on('connection', (peer) => {
const config = await container.inspect(); const config = await container.inspect();
response = { type: 'containerConfig', data: config }; response = { type: 'containerConfig', data: config };
break; break;
case 'dockerCommand':
console.log(`[INFO] Handling 'dockerCommand' with data: ${parsedData.data}`);
try {
const command = parsedData.data.split(' '); // Split the command into executable and args
const executable = command[0];
const args = command.slice(1);
const childProcess = spawn(executable, args);
let response = {
type: 'dockerOutput',
connectionId: parsedData.connectionId,
data: '',
};
// Stream stdout to the peer
childProcess.stdout.on('data', (data) => {
console.log(`[DEBUG] Command stdout: ${data.toString()}`);
peer.write(
JSON.stringify({
...response,
data: data.toString('base64'),
encoding: 'base64',
})
);
});
// Stream stderr to the peer
childProcess.stderr.on('data', (data) => {
console.error(`[ERROR] Command stderr: ${data.toString()}`);
peer.write(
JSON.stringify({
...response,
data: `[ERROR] ${data.toString('base64')}`,
encoding: 'base64',
})
);
});
// Handle command exit
childProcess.on('close', (code) => {
const exitMessage = `[INFO] Command exited with code ${code}`;
console.log(exitMessage);
peer.write(
JSON.stringify({
...response,
data: exitMessage,
})
);
});
} catch (error) {
console.error(`[ERROR] Command execution failed: ${error.message}`);
peer.write(
JSON.stringify({
type: 'dockerOutput',
connectionId: parsedData.connectionId,
data: `[ERROR] Failed to execute command: ${error.message}`,
})
);
}
break;
case 'duplicateContainer': case 'duplicateContainer':
console.log('[INFO] Handling \'duplicateContainer\' command'); console.log('[INFO] Handling \'duplicateContainer\' command');
@ -107,57 +170,6 @@ swarm.on('connection', (peer) => {
await duplicateContainer(name, image, hostname, netmode, cpu, memoryInMB, dupConfig, peer); await duplicateContainer(name, image, hostname, netmode, cpu, memoryInMB, dupConfig, peer);
return; // Response is handled within the duplicateContainer function return; // Response is handled within the duplicateContainer function
case 'dockerCommand':
console.log(`[INFO] Executing Docker CLI command: ${parsedData.data}`);
try {
const exec = spawn('sh', ['-c', parsedData.data]); // Use spawn to run the Docker command
// Handle stdout (command output)
exec.stdout.on('data', (output) => {
console.log(`[DEBUG] Command output: ${output.toString()}`);
peer.write(
JSON.stringify({
type: 'dockerOutput',
connectionId: parsedData.connectionId,
data: output.toString(),
})
);
});
// Handle stderr (errors)
exec.stderr.on('data', (error) => {
console.error(`[ERROR] Command error output: ${error.toString()}`);
peer.write(
JSON.stringify({
type: 'dockerOutput',
connectionId: parsedData.connectionId,
data: `[ERROR] ${error.toString()}`,
})
);
});
// Handle command completion
exec.on('close', (code) => {
console.log(`[INFO] Command exited with code: ${code}`);
peer.write(
JSON.stringify({
type: 'dockerOutput',
connectionId: parsedData.connectionId,
data: `[INFO] Command exited with code ${code}\n`,
})
);
});
} catch (error) {
console.error(`[ERROR] Failed to execute command: ${error.message}`);
peer.write(
JSON.stringify({
type: 'dockerOutput',
connectionId: parsedData.connectionId,
data: `[ERROR] Failed to execute command: ${error.message}`,
})
);
}
break;
case 'startContainer': case 'startContainer':
console.log(`[INFO] Handling 'startContainer' command for container: ${parsedData.args.id}`); console.log(`[INFO] Handling 'startContainer' command for container: ${parsedData.args.id}`);
await docker.getContainer(parsedData.args.id).start(); await docker.getContainer(parsedData.args.id).start();
@ -579,4 +591,4 @@ process.on('SIGINT', () => {
console.log('[INFO] Server shutting down'); console.log('[INFO] Server shutting down');
swarm.destroy(); swarm.destroy();
process.exit(); process.exit();
}); });