CI / test (push) Successful in 10m6s
Add Appearance, Behavior, Notifications, Tunnels, Templates, Connections, Terminal, and About panels. Wire density, sidebar, confirmations, refresh, badge mode, toasts, tunnel defaults, template lists, peer reset, terminal themes, and read-only server feature flags into peardock.settings.v1.
411 lines
11 KiB
JavaScript
411 lines
11 KiB
JavaScript
/**
|
|
* 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/,
|
|
/\$\(/,
|
|
/`/,
|
|
/&&/,
|
|
/\|\|/,
|
|
/;/,
|
|
/>/,
|
|
/</,
|
|
/2>&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 }
|