/** * Host Docker CLI terminal — line-buffered input, xterm FitAddon, binary-safe I/O. */ import { manager, Methods } from '../client/manager.js' import { getTerminalCtor, getFitAddonCtor, defaultXtermOptions, decodePayload, createFitController, safeFit, } from './xtermUtils.js' const Terminal = getTerminalCtor() const FitAddon = getFitAddonCtor() let dockerTerminalSession = null let dockerTerminalFontSize = 14 let dockerTerminalTheme = 'dark' const dockerTerminalThemes = { dark: defaultXtermOptions().theme, light: { background: '#ffffff', foreground: '#0f172a', cursor: '#0f172a', selectionBackground: '#b3d4fc', }, 'solarized-dark': { background: '#002b36', foreground: '#839496', cursor: '#93a1a1', selectionBackground: '#073642', }, 'solarized-light': { background: '#fdf6e3', foreground: '#657b83', cursor: '#586e75', selectionBackground: '#eee8d5', }, monokai: { background: '#272822', foreground: '#f8f8f2', cursor: '#f8f8f0', selectionBackground: '#49483e', }, } const ALLOWED_DOCKER_COMMANDS = new Set([ 'ps', 'images', 'volumes', 'networks', 'info', 'version', 'logs', 'inspect', 'stats', 'top', 'diff', 'port', 'history', 'search', 'events', 'system', 'help', 'container', 'image', 'network', 'volume', 'context', 'compose', ]) const BLOCKED_COMMANDS = new Set([ 'exec', 'run', 'create', 'start', 'stop', 'restart', 'kill', 'rm', 'rmi', 'prune', 'build', 'commit', 'push', 'pull', 'tag', 'load', 'save', 'import', 'export', 'cp', 'update', 'login', 'logout', 'swarm', 'plugin', 'secret', 'config', 'service', 'node', 'stack', ]) const BLOCKED_PATTERNS = [ /rm\s+-/, /prune/, /\$\(/, /`/, /&&/, /\|\|/, /;/, />/, /&1/, /\|\s*sh/, /\|\s*bash/, ] const MAX_COMMAND_LENGTH = 500 function validateDockerCommand(command) { if (!command || command.length > MAX_COMMAND_LENGTH) return null for (const pattern of BLOCKED_PATTERNS) { if (pattern.test(command)) return null } const normalized = command.trim().toLowerCase() let rest = normalized.startsWith('docker ') ? normalized.slice(7).trim() : normalized const parts = rest.split(/\s+/).filter(Boolean) const base = parts[0] || '' if (BLOCKED_COMMANDS.has(base)) return null // docker system df / docker container ls etc. if (base === 'system' && parts[1] && ['df', 'info', 'events'].includes(parts[1])) { return command.startsWith('docker ') ? command : `docker ${command}` } if (ALLOWED_DOCKER_COMMANDS.has(base) || base === 'help' || base === '--help' || base === '-h') { return command.startsWith('docker ') ? command : `docker ${command}` } return null } /** * @param {string} connectionId * @param {import('../client/connection.js').PearDockConnection} [peer] */ function startDockerTerminal(connectionId, peer) { const conn = peer || manager.active if (!conn?.connected) { console.error('[ERROR] No active peer for Docker CLI terminal.') return } if (dockerTerminalSession) { // Refocus existing dockerTerminalSession.xterm?.focus() dockerTerminalSession.fitController?.fitNow() return } 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 || !dockerTerminalModal) { console.error('[ERROR] Missing Docker CLI terminal DOM') return } const xterm = new Terminal( defaultXtermOptions({ fontSize: dockerTerminalFontSize, theme: dockerTerminalThemes[dockerTerminalTheme] || dockerTerminalThemes.dark, convertEol: true, }) ) const fitAddon = new FitAddon() xterm.loadAddon(fitAddon) dockerTerminalContainer.innerHTML = '' dockerTerminalContainer.style.height = dockerTerminalContainer.style.height || '500px' xterm.open(dockerTerminalContainer) const fitController = createFitController(fitAddon, xterm, () => { // Docker CLI is line-mode; resize is cosmetic for local xterm only }) fitController.observe(dockerTerminalContainer) const peerDataHandler = (response) => { try { if (!response || response.type !== 'dockerOutput') return if (response.connectionId && response.connectionId !== connectionId) return let text = decodePayload(response.data, response.encoding || 'utf8') // Ensure newlines render in convertEol mode if (text && !text.endsWith('\n') && !text.endsWith('\r')) { // keep as-is for partial chunks } xterm.write(text.replace(/\r?\n/g, '\r\n')) } catch (error) { console.error(`[ERROR] Failed to handle docker output: ${error.message}`) } } const managerHandler = (msg, c) => { if (c && conn && c !== conn && c.id !== conn.id) return peerDataHandler(msg) } conn.on?.('message', peerDataHandler) manager.on('message', managerHandler) let inputBuffer = '' const onDataDisposable = xterm.onData((input) => { // Enter if (input === '\r') { const line = inputBuffer.trim() inputBuffer = '' xterm.write('\r\n') if (!line) { xterm.write('\x1b[32m$\x1b[0m ') return } const fullCommand = validateDockerCommand(line) if (!fullCommand) { xterm.write('\x1b[31m[ERROR]\x1b[0m Invalid or blocked command.\r\n') xterm.write('Allowed: ps, images, logs, inspect, stats, system df, …\r\n') xterm.write('\x1b[32m$\x1b[0m ') return } const api = conn.request ? conn : manager api .request(Methods.dockerCommand, { connectionId, data: fullCommand }) .then(() => { xterm.write('\x1b[32m$\x1b[0m ') }) .catch((err) => { xterm.write(`\x1b[31m[ERROR]\x1b[0m ${err.message}\r\n`) xterm.write('\x1b[32m$\x1b[0m ') }) return } // Backspace if (input === '\u007F' || input === '\b') { if (inputBuffer.length > 0) { inputBuffer = inputBuffer.slice(0, -1) xterm.write('\b \b') } return } // Ctrl+C if (input === '\u0003') { inputBuffer = '' xterm.write('^C\r\n\x1b[32m$\x1b[0m ') return } // Ctrl+U clear line if (input === '\u0015') { while (inputBuffer.length > 0) { inputBuffer = inputBuffer.slice(0, -1) xterm.write('\b \b') } return } // Ignore other controls except printable / tab if (input < ' ' && input !== '\t') return inputBuffer += input xterm.write(input) }) dockerTerminalSession = { xterm, fitAddon, fitController, connectionId, peer: conn, peerDataHandler, managerHandler, onDataDisposable, } if (dockerTerminalTitle) { dockerTerminalTitle.textContent = `Docker CLI Terminal: ${connectionId}` } const modalInstance = bootstrap.Modal.getOrCreateInstance(dockerTerminalModal) dockerTerminalModal.addEventListener( 'shown.bs.modal', () => { fitController.fitNow() xterm.focus() xterm.write('\x1b[1mDocker CLI\x1b[0m (read-only allow-list)\r\n\x1b[32m$\x1b[0m ') }, { once: true } ) dockerTerminalModal.addEventListener( 'hidden.bs.modal', () => { cleanUpDockerTerminal() }, { once: true } ) modalInstance.show() if (dockerKillTerminalBtn) { dockerKillTerminalBtn.onclick = () => cleanUpDockerTerminal() } requestAnimationFrame(() => safeFit(fitAddon, xterm)) } function cleanUpDockerTerminal() { const dockerTerminalContainer = document.getElementById('docker-terminal-container') const dockerTerminalModal = document.getElementById('dockerTerminalModal') if (dockerTerminalSession) { try { dockerTerminalSession.onDataDisposable?.dispose() dockerTerminalSession.fitController?.disconnect() dockerTerminalSession.peer?.removeListener?.('message', dockerTerminalSession.peerDataHandler) if (dockerTerminalSession.managerHandler) { manager.removeListener('message', dockerTerminalSession.managerHandler) } dockerTerminalSession.xterm?.dispose() } catch { // ignore } dockerTerminalSession = null } if (dockerTerminalContainer) dockerTerminalContainer.innerHTML = '' if (dockerTerminalModal) { const modalInstance = bootstrap.Modal.getInstance(dockerTerminalModal) modalInstance?.hide() } document.querySelector('.modal-backdrop')?.remove() document.body.classList.remove('modal-open') document.body.style.paddingRight = '' } function updateDockerTerminalFontSizeDisplay() { const display = document.getElementById('docker-terminal-font-size-display') if (display) display.textContent = String(dockerTerminalFontSize) } document.addEventListener('DOMContentLoaded', () => { const applySize = (n) => { dockerTerminalFontSize = n if (dockerTerminalSession?.xterm) { dockerTerminalSession.xterm.options.fontSize = n dockerTerminalSession.fitController?.fitNow() } updateDockerTerminalFontSizeDisplay() } document.getElementById('docker-terminal-font-decrease')?.addEventListener('click', () => { if (dockerTerminalFontSize > 8) applySize(dockerTerminalFontSize - 1) }) document.getElementById('docker-terminal-font-increase')?.addEventListener('click', () => { if (dockerTerminalFontSize < 28) applySize(dockerTerminalFontSize + 1) }) document.getElementById('docker-terminal-font-reset')?.addEventListener('click', () => applySize(14)) document.getElementById('docker-terminal-copy-btn')?.addEventListener('click', () => { const sel = dockerTerminalSession?.xterm?.getSelection?.() if (sel) navigator.clipboard.writeText(sel).catch(() => {}) }) document.getElementById('docker-terminal-clear-btn')?.addEventListener('click', () => { dockerTerminalSession?.xterm?.clear() }) const themeSelect = document.getElementById('docker-terminal-theme-select') try { const pref = window.__peardockSettings?.dockerTerminalTheme if (pref && dockerTerminalThemes[pref]) { dockerTerminalTheme = pref } } catch { // ignore } if (themeSelect) { themeSelect.value = dockerTerminalTheme themeSelect.addEventListener('change', (e) => { dockerTerminalTheme = e.target.value if (dockerTerminalSession?.xterm) { dockerTerminalSession.xterm.options.theme = dockerTerminalThemes[dockerTerminalTheme] || dockerTerminalThemes.dark } }) } updateDockerTerminalFontSizeDisplay() }) if (typeof window !== 'undefined') { const prev = window.__peardockApplyTerminalThemes window.__peardockApplyTerminalThemes = (termTheme, dockerTheme) => { if (typeof prev === 'function') prev(termTheme, dockerTheme) if (dockerTheme && dockerTerminalThemes[dockerTheme]) { dockerTerminalTheme = dockerTheme if (dockerTerminalSession?.xterm) { dockerTerminalSession.xterm.options.theme = dockerTerminalThemes[dockerTerminalTheme] || dockerTerminalThemes.dark } const themeSelect = document.getElementById('docker-terminal-theme-select') if (themeSelect) themeSelect.value = dockerTheme } } } export { startDockerTerminal, cleanUpDockerTerminal, dockerTerminalSession }