Project homepage Mailing List  Warmcat.com  API Docs  Github Mirror 
    npro  
 Modern all-safe Rust Network Protocol library supporting h1, h2, h3, ws, wt sans-IO and with socket IO + tls
git clone https://npro.rs/repo/npro
 
root / assets / iosevka-term-ss01-regular.ttf
Author[]Andy Green <andy@warmcat.com> 2026-06-21 10:38 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-06-22 15:07 UTC
Tree4e9dcf9691cd813ab472b68be8419909daabdeb5   Raw Patch
 
hardening
hardening
diff --git a/assets/sai-terminal.js b/assets/sai-terminal.js index eb16f79..736b933 100644 --- a/assets/sai-terminal.js +++ b/assets/sai-terminal.js @@ -20,10 +20,82 @@ class SaiTerminal { this.titleBar = document.createElement('div'); this.titleBar.className = 'sai-terminal-titlebar'; + let titleLeft = document.createElement('div'); + titleLeft.style.display = 'flex'; + titleLeft.style.alignItems = 'center'; + + this.burgerBtn = document.createElement('button'); + this.burgerBtn.className = 'sai-terminal-burger'; + this.burgerBtn.innerHTML = '&#9776;'; + + this.burgerMenu = document.createElement('div'); + this.burgerMenu.className = 'sai-terminal-menu'; + + this.menuItemDark = document.createElement('div'); + this.menuItemDark.className = 'sai-terminal-menu-item'; + this.menuItemDark.textContent = 'Theme: Dark'; + this.menuItemDark.onclick = () => { this.setTheme('dark'); this.burgerMenu.classList.remove('show'); }; + + this.menuItemLight = document.createElement('div'); + this.menuItemLight.className = 'sai-terminal-menu-item'; + this.menuItemLight.textContent = 'Theme: Light'; + this.menuItemLight.onclick = () => { this.setTheme('light'); this.burgerMenu.classList.remove('show'); }; + + this.burgerMenu.appendChild(this.menuItemDark); + this.burgerMenu.appendChild(this.menuItemLight); + + this.burgerBtn.onclick = (e) => { + e.stopPropagation(); + this.burgerMenu.classList.toggle('show'); + if (this.updateFocusState) this.updateFocusState(); + }; + + const closeMenuListener = (e) => { + if (this.el && !this.burgerBtn.contains(e.target) && !this.burgerMenu.contains(e.target)) { + if (this.burgerMenu.classList.contains('show')) { + this.burgerMenu.classList.remove('show'); + if (this.updateFocusState) this.updateFocusState(); + } + } + }; + document.addEventListener('click', closeMenuListener); + this._closeMenuListener = closeMenuListener; + + titleLeft.appendChild(this.burgerBtn); + this.titleBar.appendChild(this.burgerMenu); + + if (this.options.platform) { + let plat_parts = this.options.platform.split('/'); + let plat_os = plat_parts[0] || 'generic'; + let plat_arch = plat_parts[1] || 'generic'; + let plat_tc = plat_parts[2] || 'generic'; + + this.titleIcons = document.createElement('div'); + this.titleIcons.className = 'sai-terminal-icons'; + this.titleIcons.style.display = 'flex'; + this.titleIcons.style.gap = '4px'; + this.titleIcons.style.marginRight = '8px'; + this.titleIcons.innerHTML = + `<img class="ip1 zup" src="/sai/${plat_os}.svg">` + + `<img class="ip1 tread1" src="/sai/arch-${plat_arch}.svg">` + + `<img class="ip1 tread2" src="/sai/tc-${plat_tc}.svg">`; + + const images = this.titleIcons.querySelectorAll('img'); + images.forEach(img => { + img.onerror = () => { + img.src = '/sai/generic.svg'; + img.onerror = null; + }; + }); + titleLeft.appendChild(this.titleIcons); + } + this.titleText = document.createElement('span'); this.titleText.className = 'sai-terminal-title'; this.titleText.textContent = this.options.title || "Terminal"; - this.titleBar.appendChild(this.titleText); + titleLeft.appendChild(this.titleText); + + this.titleBar.appendChild(titleLeft); this.closeBtn = document.createElement('button'); this.closeBtn.className = 'sai-terminal-close'; @@ -38,6 +110,13 @@ class SaiTerminal { this.contentEl = document.createElement('div'); this.contentEl.className = 'sai-terminal-content'; + this.loaderEl = document.createElement('div'); + this.loaderEl.className = 'sai-terminal-loader'; + const spinner = document.createElement('div'); + spinner.className = 'sai-terminal-spinner'; + this.loaderEl.appendChild(spinner); + this.contentEl.appendChild(this.loaderEl); + this.outputEl = document.createElement('div'); this.outputEl.className = 'sai-terminal-output'; this.contentEl.appendChild(this.outputEl); @@ -57,21 +136,45 @@ class SaiTerminal { this.core = new TerminalCore(80, 24); - // Setup ResizeObserver to dynamically resize this.resizeObserver = new ResizeObserver(entries => { for (let entry of entries) { - // Approximate character size (e.g., 8x16) - // This would be better if dynamically measured - const cols = Math.max(10, Math.floor(entry.contentRect.width / 8.5)); - const rows = Math.max(5, Math.floor(entry.contentRect.height / 16)); + const measureEl = document.createElement('span'); + measureEl.textContent = 'W'.repeat(100); + measureEl.style.position = 'absolute'; + measureEl.style.visibility = 'hidden'; + measureEl.style.whiteSpace = 'pre'; + this.outputEl.appendChild(measureEl); + const rect = measureEl.getBoundingClientRect(); + this._charSize = { + width: (rect.width / 100) || 8.5, + height: Math.max(16, rect.height) + }; + this.outputEl.removeChild(measureEl); + + const cols = Math.max(10, Math.floor(entry.contentRect.width / this._charSize.width)); + const rows = Math.max(5, Math.floor(entry.contentRect.height / this._charSize.height)); if (cols !== this.core.cols || rows !== this.core.rows) { this.core.resize(cols, rows); this.render(); + if (this.options.onResize) { + this.options.onResize(cols, rows); + } } } }); this.resizeObserver.observe(this.contentEl); + // Re-evaluate size when custom fonts (like Iosevka) finish loading + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(() => { + if (this.el.parentNode) { + // Force resize observer to re-trigger + this.resizeObserver.disconnect(); + this.resizeObserver.observe(this.contentEl); + } + }); + } + // Dragging logic let isDragging = false; let dragStartX, dragStartY; @@ -105,21 +208,21 @@ class SaiTerminal { }); this.inputEl.addEventListener('keydown', (e) => { - if (e.ctrlKey) { - if (e.key === 'c') { - this.options.onData('\x03'); - e.preventDefault(); - } else if (e.key === 'd') { - this.options.onData('\x04'); - e.preventDefault(); - } else if (e.key === 'z') { - this.options.onData('\x1a'); - e.preventDefault(); - } else if (e.key === 'l') { - this.options.onData('\x0c'); + if (e.ctrlKey && !e.altKey && !e.metaKey && e.key.length === 1) { + const charCode = e.key.toLowerCase().charCodeAt(0); + if (charCode >= 97 && charCode <= 122) { + if (charCode === 99 && window.getSelection().toString()) { + return; // Let browser natively copy text + } + if (charCode === 118) { // 'v' + return; // Let browser natively paste text + } + this.options.onData(String.fromCharCode(charCode - 96)); e.preventDefault(); + return; } - } else if (e.key === 'Enter') { + } + if (e.key === 'Enter') { this.options.onData('\r'); e.preventDefault(); } else if (e.key === 'Backspace') { @@ -144,32 +247,260 @@ class SaiTerminal { } }); + this.inputEl.addEventListener('paste', (e) => { + let text = (e.clipboardData || window.clipboardData).getData('text'); + if (text) { + this.options.onData(text); + } + e.preventDefault(); + }); + + this.updateFocusState = () => { + let menuOpen = this.burgerMenu && this.burgerMenu.classList.contains('show'); + let sel = window.getSelection(); + let hasSelection = sel && !sel.isCollapsed && this.el.contains(sel.anchorNode); + if (document.activeElement === this.inputEl || isSelecting || menuOpen || hasSelection) { + this.el.style.opacity = '1'; + } else { + this.el.style.opacity = '0.7'; + } + }; + const updateFocusState = this.updateFocusState; + // click to focus input this.el.addEventListener('click', () => { - const selection = window.getSelection(); - if (!selection.toString()) { - this.inputEl.focus(); + let sel = window.getSelection(); + if (!sel || sel.isCollapsed) { + this.inputEl.focus({ preventScroll: true }); + } + }); + + this.inputEl.addEventListener('focus', updateFocusState); + this.inputEl.addEventListener('blur', updateFocusState); + + // Handle scrolling + this.contentEl.addEventListener('wheel', (e) => { + let amount = Math.sign(e.deltaY) * 3; + if (amount !== 0) { + this.core.scroll(-amount); + this.render(); + } + e.preventDefault(); + e.stopPropagation(); + }, { passive: false }); + + let isSelecting = false; + let selectScrollInterval = null; + let selectMouseY = 0; + + this.contentEl.addEventListener('mousedown', (e) => { + isSelecting = true; + updateFocusState(); + }); + + document.addEventListener('mousemove', (e) => { + if (!isSelecting) return; + selectMouseY = e.clientY; + + const rect = this.contentEl.getBoundingClientRect(); + if (selectMouseY < rect.top) { + if (!selectScrollInterval) { + selectScrollInterval = setInterval(() => { + let oldOffset = this.core.scrollOffset; + this.core.scroll(1); + let diff = this.core.scrollOffset - oldOffset; + if (diff !== 0) { + let selPos = this.getSelectionPos(); + this.render(); + if (selPos) { + let shift = diff * (this.core.cols + 1); + selPos.anchor += shift; + let maxPos = this.core.rows * (this.core.cols + 1); + selPos.anchor = Math.max(0, Math.min(maxPos, selPos.anchor)); + selPos.focus = Math.max(0, Math.min(maxPos, selPos.focus)); + this.setSelectionPos(selPos); + } + } + }, 50); + } + } else if (selectMouseY > rect.bottom) { + if (!selectScrollInterval) { + selectScrollInterval = setInterval(() => { + let oldOffset = this.core.scrollOffset; + this.core.scroll(-1); + let diff = this.core.scrollOffset - oldOffset; + if (diff !== 0) { + let selPos = this.getSelectionPos(); + this.render(); + if (selPos) { + let shift = diff * (this.core.cols + 1); + selPos.anchor += shift; + let maxPos = this.core.rows * (this.core.cols + 1); + selPos.anchor = Math.max(0, Math.min(maxPos, selPos.anchor)); + selPos.focus = Math.max(0, Math.min(maxPos, selPos.focus)); + this.setSelectionPos(selPos); + } + } + }, 50); + } + } else { + if (selectScrollInterval) { + clearInterval(selectScrollInterval); + selectScrollInterval = null; + } + } + }); + + document.addEventListener('mouseup', () => { + if (isSelecting) { + isSelecting = false; + if (selectScrollInterval) { + clearInterval(selectScrollInterval); + selectScrollInterval = null; + } + updateFocusState(); } }); + + // Set initial unfocused opacity + this.el.style.opacity = '0.7'; + + // Load theme from localStorage + this.setTheme(localStorage.getItem('sai-terminal-theme') || 'dark'); + + // Initial focus + setTimeout(() => this.inputEl.focus({ preventScroll: true }), 100); + } + + setTheme(theme) { + if (theme === 'light') { + this.el.classList.add('theme-light'); + this.el.classList.remove('theme-dark'); + } else { + this.el.classList.add('theme-dark'); + this.el.classList.remove('theme-light'); + } + localStorage.setItem('sai-terminal-theme', theme); } write(channel, data) { + if (this.loaderEl) { + this.loaderEl.remove(); + this.loaderEl = null; + } if (channel === 1 || channel === 2 || channel === 0) { this.core.write(data); this.render(); } } + getSelectionPos() { + const sel = window.getSelection(); + if (!sel.rangeCount || sel.isCollapsed) return null; + if (!this.outputEl.contains(sel.anchorNode) || !this.outputEl.contains(sel.focusNode)) return null; + + const getPos = (targetNode, targetOffset) => { + let pos = 0; + const walk = (node) => { + if (node === targetNode) { + if (node.nodeType === Node.TEXT_NODE) return pos + targetOffset; + let childNodes = node.childNodes; + for (let i = 0; i < targetOffset && i < childNodes.length; i++) { + if (childNodes[i].nodeType === Node.TEXT_NODE) pos += childNodes[i].length; + else if (childNodes[i].nodeName === 'BR') pos += 1; + else pos += childNodes[i].textContent.length; + } + return pos; + } + if (node.nodeType === Node.TEXT_NODE) { + pos += node.length; + } else if (node.nodeName === 'BR') { + pos += 1; + } else { + for (let child of node.childNodes) { + let res = walk(child); + if (res !== null) return res; + } + } + return null; + }; + return walk(this.outputEl); + }; + + let a = getPos(sel.anchorNode, sel.anchorOffset); + let f = getPos(sel.focusNode, sel.focusOffset); + if (a === null || f === null) return null; + return { anchor: a, focus: f }; + } + + setSelectionPos(selPos) { + if (!selPos) return; + const sel = window.getSelection(); + + const findNodeAndOffset = (targetPos) => { + let pos = 0; + let foundNode = null; + let foundOffset = 0; + + const walk = (node) => { + if (foundNode) return; + if (node.nodeType === Node.TEXT_NODE) { + if (pos + node.length >= targetPos) { + foundNode = node; + foundOffset = targetPos - pos; + return; + } + pos += node.length; + } else if (node.nodeName === 'BR') { + if (pos === targetPos) { + foundNode = node.parentNode; + foundOffset = Array.prototype.indexOf.call(node.parentNode.childNodes, node); + return; + } + pos += 1; + } else { + for (let child of node.childNodes) { + walk(child); + } + } + }; + walk(this.outputEl); + + if (!foundNode) { + let last = this.outputEl.lastChild; + while (last && last.lastChild) last = last.lastChild; + if (last && last.nodeType === Node.TEXT_NODE) { + foundNode = last; + foundOffset = last.length; + } else { + foundNode = this.outputEl; + foundOffset = this.outputEl.childNodes.length; + } + } + return { node: foundNode, offset: foundOffset }; + }; + + let a = findNodeAndOffset(selPos.anchor); + let f = findNodeAndOffset(selPos.focus); + + if (a.node && f.node && sel.setBaseAndExtent) { + sel.setBaseAndExtent(a.node, a.offset, f.node, f.offset); + } + } + render() { this.outputEl.innerHTML = this.core.renderHtml(); } focus() { - this.inputEl.focus(); + this.inputEl.focus({ preventScroll: true }); } destroy() { this.resizeObserver.disconnect(); + if (this._closeMenuListener) { + document.removeEventListener('click', this._closeMenuListener); + } if (this.el.parentNode) { this.el.parentNode.removeChild(this.el); } diff --git a/assets/sai.css b/assets/sai.css index 2f32543..bdbf454 100644 --- a/assets/sai.css +++ b/assets/sai.css @@ -932,33 +932,33 @@ img.branch { .ansi-bold { font-weight: bold; } .ansi-underline { text-decoration: underline; } -.ansi-fg-black { color: #000; } -.ansi-fg-red { color: #A00; } -.ansi-fg-green { color: #0A0; } -.ansi-fg-yellow { color: #A50; } -.ansi-fg-blue { color: #00A; } -.ansi-fg-magenta { color: #A0A; } -.ansi-fg-cyan { color: #0AA; } -.ansi-fg-white { color: #AAA; } +.ansi-fg-black { color: var(--term-black, #000); } +.ansi-fg-red { color: var(--term-red, #A00); } +.ansi-fg-green { color: var(--term-green, #0A0); } +.ansi-fg-yellow { color: var(--term-yellow, #A50); } +.ansi-fg-blue { color: var(--term-blue, #55F); } +.ansi-fg-magenta { color: var(--term-magenta, #A0A); } +.ansi-fg-cyan { color: var(--term-cyan, #0AA); } +.ansi-fg-white { color: var(--term-white, #AAA); } /* Bright colors for bold text */ -.ansi-bold .ansi-fg-black { color: #555; } -.ansi-bold .ansi-fg-red { color: #F55; } -.ansi-bold .ansi-fg-green { color: #5F5; } -.ansi-bold .ansi-fg-yellow { color: #FF5; } -.ansi-bold .ansi-fg-blue { color: #55F; } -.ansi-bold .ansi-fg-magenta { color: #F5F; } -.ansi-bold .ansi-fg-cyan { color: #5FF; } -.ansi-bold .ansi-fg-white { color: #FFF; } - -.ansi-bg-black { background-color: #000; } -.ansi-bg-red { background-color: #A00; } -.ansi-bg-green { background-color: #0A0; } -.ansi-bg-yellow { background-color: #A50; } -.ansi-bg-blue { background-color: #00A; } -.ansi-bg-magenta { background-color: #A0A; } -.ansi-bg-cyan { background-color: #0AA; } -.ansi-bg-white { background-color: #AAA; } +.ansi-bold .ansi-fg-black { color: var(--term-bold-black, #555); } +.ansi-bold .ansi-fg-red { color: var(--term-bold-red, #F55); } +.ansi-bold .ansi-fg-green { color: var(--term-bold-green, #5F5); } +.ansi-bold .ansi-fg-yellow { color: var(--term-bold-yellow, #FF5); } +.ansi-bold .ansi-fg-blue { color: var(--term-bold-blue, #77F); } +.ansi-bold .ansi-fg-magenta { color: var(--term-bold-magenta, #F5F); } +.ansi-bold .ansi-fg-cyan { color: var(--term-bold-cyan, #5FF); } +.ansi-bold .ansi-fg-white { color: var(--term-bold-white, #FFF); } + +.ansi-bg-black { background-color: var(--term-black, #000); } +.ansi-bg-red { background-color: var(--term-red, #A00); } +.ansi-bg-green { background-color: var(--term-green, #0A0); } +.ansi-bg-yellow { background-color: var(--term-yellow, #A50); } +.ansi-bg-blue { background-color: var(--term-blue, #55F); } +.ansi-bg-magenta { background-color: var(--term-magenta, #A0A); } +.ansi-bg-cyan { background-color: var(--term-cyan, #0AA); } +.ansi-bg-white { background-color: var(--term-white, #AAA); } .step-progress { display: flex; @@ -1510,19 +1510,28 @@ div.ibuil.vm-builder { } .sai-terminal { + --term-bg: #1e1e1e; + --term-fg: #00ff00; + --term-out: #ddd; + --term-cursor-bg: rgba(0, 255, 0, 0.7); + --term-cursor-fg: #1e1e1e; + --term-title-bg: #333; + --term-title-fg: #fff; + --term-border: #555; + position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 600px; height: 400px; - background: #1e1e1e; - color: #00ff00; + background: var(--term-bg); + color: var(--term-fg); font-family: 'Iosevka', monospace; font-size: 14px; display: flex; flex-direction: column; - border: 2px solid #555; + border: 2px solid var(--term-border); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); z-index: 9999; @@ -1530,9 +1539,22 @@ div.ibuil.vm-builder { overflow: hidden; } +.sai-terminal.theme-light { + --term-bg: #f5f5f5; + --term-fg: #000; + --term-out: #333; + --term-cursor-bg: rgba(0, 0, 0, 0.5); + --term-cursor-fg: #fff; + --term-title-bg: #ddd; + --term-title-fg: #000; + --term-border: #aaa; + --term-blue: #00A; + --term-bold-blue: #55F; +} + .sai-terminal-titlebar { - background: #333; - color: #fff; + background: var(--term-title-bg); + color: var(--term-title-fg); padding: 5px 10px; display: flex; justify-content: space-between; @@ -1540,7 +1562,50 @@ div.ibuil.vm-builder { cursor: move; user-select: none; font-family: sans-serif; - border-bottom: 1px solid #555; + border-bottom: 1px solid var(--term-border); +} + +.sai-terminal-burger { + background: transparent; + border: none; + color: inherit; + font-size: 16px; + cursor: pointer; + margin-right: 8px; + padding: 2px 6px; +} +.sai-terminal-burger:hover { + background: rgba(128, 128, 128, 0.3); + border-radius: 3px; +} + +.sai-terminal-menu { + position: absolute; + top: 30px; + left: 10px; + background: var(--term-bg); + border: 1px solid var(--term-border); + border-radius: 4px; + padding: 5px 0; + box-shadow: 0 2px 10px rgba(0,0,0,0.5); + z-index: 10000; + display: none; + min-width: 120px; + font-family: sans-serif; + color: var(--term-out); +} + +.sai-terminal-menu.show { + display: block; +} + +.sai-terminal-menu-item { + padding: 5px 15px; + cursor: pointer; +} + +.sai-terminal-menu-item:hover { + background: var(--term-border); } .sai-terminal-title { @@ -1553,6 +1618,33 @@ div.ibuil.vm-builder { flex-direction: column; padding: 10px; overflow: hidden; + position: relative; +} + +.sai-terminal-loader { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #333; + display: flex; + justify-content: center; + align-items: center; + z-index: 100; +} + +.sai-terminal-spinner { + width: 40px; + height: 40px; + border: 4px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: #fff; + animation: sai-spin 1s ease-in-out infinite; +} + +@keyframes sai-spin { + to { transform: rotate(360deg); } } .sai-terminal-output { @@ -1560,7 +1652,7 @@ div.ibuil.vm-builder { overflow: hidden; margin: 0; white-space: pre; - color: #ddd; + color: var(--term-out); line-height: 16px; } @@ -1605,6 +1697,6 @@ div.ibuil.vm-builder { } .term-cursor { - background-color: rgba(0, 255, 0, 0.7); - color: #1e1e1e; + background-color: var(--term-cursor-bg); + color: var(--term-cursor-fg); } diff --git a/assets/sai.js b/assets/sai.js index ff18543..4ba1a1a 100644 --- a/assets/sai.js +++ b/assets/sai.js @@ -404,6 +404,18 @@ active_terminals = {}; var segment_stack = []; var seg_counter = 0; +window.addEventListener('beforeunload', () => { + for (const task_uuid in active_terminals) { + const closeMsg = { + schema: "com.warmcat.sai.closeshell", + task_uuid: task_uuid + }; + if (typeof sai !== 'undefined' && sai && sai.readyState === WebSocket.OPEN) { + sai.send(JSON.stringify(closeMsg)); + } + } +}); + /* Global caches for reconcilation */ var pcon_topology = {}; var pcon_energy_cache = {}; @@ -1669,7 +1681,7 @@ function createBuilderDiv(plat) { }); } - if (authd && auth_is_admin && plat.online) { + if (authd && auth_is_admin) { menuItems.push({ label: "<span class='builder-shell-btn'>Open Shell</span>", callback: () => { @@ -1685,6 +1697,7 @@ function createBuilderDiv(plat) { const term = new SaiTerminal(document.body, { title: "Terminal: " + plat.name, + platform: plat.platform, onData: (data) => { const ptyMsg = { schema: "com.warmcat.sai.ptydata", @@ -1696,6 +1709,19 @@ function createBuilderDiv(plat) { }; sai.send(JSON.stringify(ptyMsg)); }, + onResize: (cols, rows) => { + const resizeMsg = { + schema: "com.warmcat.sai.ptydata", + builder_name: plat.name, + task_uuid: task_uuid, + channel: 0, + cols: cols, + rows: rows, + data: "", + len: 0 + }; + sai.send(JSON.stringify(resizeMsg)); + }, onClose: () => { const closeMsg = { schema: "com.warmcat.sai.closeshell", @@ -2426,9 +2452,16 @@ function ws_open_sai() case "com.warmcat.sai.ptydata": if (jso.task_uuid) { - const term = active_terminals[jso.task_uuid]; + let term = active_terminals[jso.task_uuid]; if (!term) { + let platformStr = null; + if (typeof last_builder_list !== 'undefined' && jso.builder_name) { + const b = last_builder_list.find(x => x.name === jso.builder_name); + if (b) platformStr = b.platform; + } term = new SaiTerminal(document.body, { + title: "Terminal: " + (jso.builder_name || "Unknown"), + platform: platformStr, onData: function(input) { const msg = { schema: "com.warmcat.sai.ptydata", @@ -2439,6 +2472,18 @@ function ws_open_sai() }; sai.send(JSON.stringify(msg)); }, + onResize: function(cols, rows) { + const msg = { + schema: "com.warmcat.sai.ptydata", + task_uuid: jso.task_uuid, + channel: 0, + cols: cols, + rows: rows, + len: 0, + data: "" + }; + sai.send(JSON.stringify(msg)); + }, onClose: function() { const msg = { schema: "com.warmcat.sai.closeshell", diff --git a/assets/terminal-core.js b/assets/terminal-core.js index 71dc3fd..cebbaeb 100644 --- a/assets/terminal-core.js +++ b/assets/terminal-core.js @@ -7,6 +7,9 @@ class TerminalCore { this.cols = cols; this.rows = rows; this.buffer = []; // Array of { char: ' ', fg: null, bg: null, bold: false } + this.scrollback = []; // Array of pushed-out rows + this.maxScrollback = 10000; + this.scrollOffset = 0; // Number of lines scrolled back (0 = bottom) this.cx = 0; this.cy = 0; @@ -17,6 +20,7 @@ class TerminalCore { } initBuffer() { + this.wrapNext = false; this.buffer = []; for (let r = 0; r < this.rows; r++) { let row = []; @@ -28,6 +32,7 @@ class TerminalCore { } resize(cols, rows) { + this.wrapNext = false; let newBuffer = []; for (let r = 0; r < rows; r++) { let row = []; @@ -48,27 +53,38 @@ class TerminalCore { } write(data) { + if (this.scrollOffset > 0 && data.length > 0) { + this.scrollOffset = 0; + } for (let i = 0; i < data.length; i++) { let char = data[i]; if (char === '\n') { this.cy++; this.cx = 0; + this.wrapNext = false; if (this.cy >= this.rows) { this.scrollUp(); this.cy = this.rows - 1; } } else if (char === '\r') { this.cx = 0; + this.wrapNext = false; } else if (char === '\b') { if (this.cx > 0) this.cx--; + this.wrapNext = false; } else if (char === '\t') { this.cx = (this.cx + 8) - (this.cx % 8); - if (this.cx >= this.cols) this.cx = this.cols - 1; + if (this.cx >= this.cols) { + this.cx = this.cols - 1; + this.wrapNext = true; + } else { + this.wrapNext = false; + } } else if (char === '\x07') { // Bell } else if (char === '\x1b') { // ESC - if (data[i + 1] === '[') { + if (i + 1 < data.length && data[i + 1] === '[') { // CSI let j = i + 2; let paramStr = ""; @@ -81,31 +97,43 @@ class TerminalCore { this.handleCSI(cmd, paramStr); i = j; } - } else if (data[i+1] === ']') { + } else if (i + 1 < data.length && data[i+1] === ']') { // OSC let j = i + 2; while (j < data.length && data[j] !== '\x07' && data[j] !== '\x1b') j++; - if (data[j] === '\x1b' && data[j+1] === '\\') j++; + if (j < data.length && data[j] === '\x1b' && j + 1 < data.length && data[j+1] === '\\') j++; i = j; + } else if (i + 1 < data.length && (data[i+1] === '(' || data[i+1] === ')' || data[i+1] === '*' || data[i+1] === '+')) { + // Designate G0-G3 Character Set (3 bytes) + i += 2; } else { - i++; // Skip unknown ESC sequence + i++; // Skip unknown 2-byte ESC sequence } } else { - this.buffer[this.cy][this.cx] = { - char: char, - fg: this.currentStyle.fg, - bg: this.currentStyle.bg, - bold: this.currentStyle.bold - }; - this.cx++; - if (this.cx >= this.cols) { + if (this.wrapNext) { this.cx = 0; this.cy++; + this.wrapNext = false; if (this.cy >= this.rows) { this.scrollUp(); this.cy = this.rows - 1; } } + + if (this.cy >= this.rows) this.cy = this.rows - 1; + + this.buffer[this.cy][this.cx] = { + char: char, + fg: this.currentStyle.fg, + bg: this.currentStyle.bg, + bold: this.currentStyle.bold + }; + + if (this.cx < this.cols - 1) { + this.cx++; + } else { + this.wrapNext = true; + } } } } @@ -113,6 +141,11 @@ class TerminalCore { handleCSI(cmd, paramStr) { if (paramStr.startsWith('?')) paramStr = paramStr.substring(1); let params = paramStr.split(';').map(p => parseInt(p, 10)); + + if (cmd !== 'm') { + this.wrapNext = false; + } + switch (cmd) { case 'A': // Cursor Up this.cy = Math.max(0, this.cy - (params[0] || 1)); break; @@ -158,6 +191,31 @@ class TerminalCore { for (let c = 0; c < this.cols; c++) this.buffer[this.cy][c] = {...this.defaultCell}; } break; + case '@': // Insert Character + let nInsert = params[0] || 1; + for (let c = this.cols - 1; c >= this.cx + nInsert; c--) { + this.buffer[this.cy][c] = {...this.buffer[this.cy][c - nInsert]}; + } + for (let c = this.cx; c < this.cx + nInsert && c < this.cols; c++) { + this.buffer[this.cy][c] = {...this.defaultCell}; + } + break; + case 'P': // Delete Character + let nDelete = params[0] || 1; + if (nDelete > this.cols - this.cx) nDelete = this.cols - this.cx; + for (let c = this.cx; c < this.cols - nDelete; c++) { + this.buffer[this.cy][c] = {...this.buffer[this.cy][c + nDelete]}; + } + for (let c = Math.max(this.cx, this.cols - nDelete); c < this.cols; c++) { + this.buffer[this.cy][c] = {...this.defaultCell}; + } + break; + case 'X': // Erase Character + let nErase = params[0] || 1; + for (let c = this.cx; c < this.cx + nErase && c < this.cols; c++) { + this.buffer[this.cy][c] = {...this.defaultCell}; + } + break; case 'm': // SGR for (let i = 0; i < params.length; i++) { let code = params[i] || 0; @@ -171,23 +229,48 @@ class TerminalCore { } scrollUp() { - this.buffer.shift(); + let shiftedRow = this.buffer.shift(); + this.scrollback.push(shiftedRow); + if (this.scrollback.length > this.maxScrollback) { + this.scrollback.shift(); + } let row = []; for (let c = 0; c < this.cols; c++) row.push({...this.defaultCell}); this.buffer.push(row); } + scroll(amount) { + this.scrollOffset += amount; + if (this.scrollOffset < 0) this.scrollOffset = 0; + if (this.scrollOffset > this.scrollback.length) this.scrollOffset = this.scrollback.length; + } + renderHtml() { let html = ''; let lastStyle = null; const colorMap = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']; + let viewBuffer = []; + if (this.scrollOffset > 0) { + let startIdx = this.scrollback.length - this.scrollOffset; + for (let i = 0; i < this.rows; i++) { + let idx = startIdx + i; + if (idx < this.scrollback.length) { + viewBuffer.push(this.scrollback[idx]); + } else { + viewBuffer.push(this.buffer[idx - this.scrollback.length]); + } + } + } else { + viewBuffer = this.buffer; + } + for (let r = 0; r < this.rows; r++) { for (let c = 0; c < this.cols; c++) { - let cell = this.buffer[r][c]; + let cell = viewBuffer[r][c]; - let isCursor = (r === this.cy && c === this.cx); + let isCursor = (this.scrollOffset === 0 && r === this.cy && c === this.cx); let styleKey = `${cell.fg}-${cell.bg}-${cell.bold}-${isCursor}`; if (styleKey !== lastStyle) { @@ -208,7 +291,7 @@ class TerminalCore { } html += (cell.char === ' ' || cell.char === '') ? '&nbsp;' : this.escapeHtml(cell.char); } - html += '<br>'; + if (r < this.rows - 1) html += '<br>'; } if (lastStyle !== null) html += '</span>'; return html; diff --git a/src/builder/b-nspawn.c b/src/builder/b-nspawn.c index fa361e5..881eb5c 100644 --- a/src/builder/b-nspawn.c +++ b/src/builder/b-nspawn.c @@ -645,6 +645,7 @@ saib_spawn_script(struct sai_nspawn *ns) info.timeout_us = 30 * 60 * LWS_US_PER_SEC; info.reap_cb = sai_lsp_reap_cb; info.pty_mode = 1; + info.disable_ctrlc = 1; memset(&ns->res, 0, sizeof(ns->res)); info.res = &ns->res; #if defined(__linux__) @@ -697,6 +698,7 @@ sai_shell_reap_cb(void *opaque, const lws_spawn_resource_us_t *res, siginfo_t *s lws_dll2_remove(&sh->list); free(sh); } + saib_reassess_idle_situation(); } static int @@ -757,6 +759,23 @@ callback_sai_shell_stdwsi(struct lws *wsi, enum lws_callback_reasons reason, lwsl_notice("%s: PTYDATA queueing %d bytes from shell %s to server\n", __func__, (int)len, sh->task_uuid); saib_srv_queue_tx(sh->spm->ss, lj + LWS_PRE, (size_t)n, LWSSS_FLAG_SOM | LWSSS_FLAG_EOM); + + if (lws_buflist2_total_len(&sh->spm->bl_to_srv) > (LWS_BUFLIST_OOM_LIMIT - (256 * 1024))) { + /* buflist is getting full, backpressure ALL active shell stdwsi for this connection */ + lws_start_foreach_dll_safe(struct lws_dll2 *, d, d1, builder.shell_owner.head) { + struct sai_shell *s = lws_container_of(d, struct sai_shell, list); + if (s->spm == sh->spm) { + for (int i = 0; i < 3; i++) { + if (!s->stdwsi_paused[i] && s->stdwsi[i]) { + s->stdwsi_paused[i] = 1; + lws_rx_flow_control(s->stdwsi[i], 0); /* 0 disables RX */ + lwsl_notice("%s: Backpressure applied to shell %s ch %d (tot %zu)\n", + __func__, s->task_uuid, i, lws_buflist2_total_len(&sh->spm->bl_to_srv)); + } + } + } + } lws_end_foreach_dll_safe(d, d1); + } break; default: @@ -802,6 +821,7 @@ saib_shell_spawn(struct sai_plat_server *spm, const char *task_uuid) info.timeout_us = 30 * 60 * LWS_US_PER_SEC; info.reap_cb = sai_shell_reap_cb; info.pty_mode = 1; + info.disable_ctrlc = 0; info.opaque = sh; info.owner = &builder.lsp_owner; info.plsp = &sh->lsp; @@ -817,5 +837,6 @@ saib_shell_spawn(struct sai_plat_server *spm, const char *task_uuid) } lwsl_notice("%s: Spawned shell successfully for %s\n", __func__, task_uuid); + saib_reassess_idle_situation(); return 0; } diff --git a/src/builder/b-power.c b/src/builder/b-power.c index 086f87e..b8fc1b6 100644 --- a/src/builder/b-power.c +++ b/src/builder/b-power.c @@ -203,8 +203,14 @@ saib_reassess_idle_situation() } lws_end_foreach_dll_safe(mp, mp1); + if (builder.shell_owner.head) { + lwsl_notice("%s: Builder has %d active shell sessions\n", __func__, builder.shell_owner.count); + lws_sul_cancel(&builder.sul_idle); + in_use = 1; + } + if (in_use) { - lwsl_warn("%s: cancelling idle grace time as ongoing task steps\n", __func__); + lwsl_warn("%s: cancelling idle grace time as ongoing task steps or shells\n", __func__); return 0; } @@ -217,7 +223,7 @@ saib_reassess_idle_situation() */ if (lws_dll2_is_detached(&builder.sul_idle.list)) { - int grace_secs = builder.event_affinity_active ? 1 : (int)(SAI_IDLE_GRACE_US / LWS_US_PER_SEC); + int grace_secs = builder.one_shot_active ? 2 : (builder.event_affinity_active ? 15 : (int)(SAI_IDLE_GRACE_US / LWS_US_PER_SEC)); lwsl_notice("%s: %s: NO STAY and NO TASKS: starting %d sec idle grace time before auto-power-off\n", __func__, builder.host, grace_secs); lws_sul_schedule(builder.context, 0, &builder.sul_idle, @@ -310,9 +316,6 @@ saib_stay_init(void) if (!builder.url_sai_power) return 0; - if (!suspender_exists) - return LWSSSSRET_OK; - snprintf(builder.path, sizeof(builder.path) - 1, "%s/stay/%s", builder.url_sai_power, builder.host); diff --git a/src/builder/b-private.h b/src/builder/b-private.h index 0562a71..87a2591 100644 --- a/src/builder/b-private.h +++ b/src/builder/b-private.h @@ -104,6 +104,7 @@ struct sai_shell { struct sai_plat_server *spm; struct lws_spawn_piped *lsp; struct lws *stdwsi[3]; + char stdwsi_paused[3]; int user_cancel; }; diff --git a/src/builder/b-task.c b/src/builder/b-task.c index 3c938f8..4fb6c23 100644 --- a/src/builder/b-task.c +++ b/src/builder/b-task.c @@ -367,7 +367,7 @@ saib_task_destroy(struct sai_nspawn *ns) * Schedule informing all the servers we're connected to */ - if (!m) { + if (!m && !builder.shell_owner.head) { #if defined(__APPLE__) if (!saib_need_wakelock()) { lwsl_notice("%s: last task finished, scheduling wakelock release\n", __func__); diff --git a/src/builder/b-ws-server.c b/src/builder/b-ws-server.c index 9644f20..43f1623 100644 --- a/src/builder/b-ws-server.c +++ b/src/builder/b-ws-server.c @@ -29,6 +29,7 @@ #include <libwebsockets.h> #include <string.h> #include <signal.h> +#include <sys/ioctl.h> #include "b-private.h" @@ -309,35 +310,46 @@ saib_m_rx(void *userobj, const uint8_t *in, size_t len, int flags) break; } - case SAIB_RX_PTYDATA: - { - sai_ptydata_t *pd = (sai_ptydata_t *)a.dest; - struct sai_shell *sh = NULL; + case SAIB_RX_PTYDATA: + { + sai_ptydata_t *pd = (sai_ptydata_t *)a.dest; + struct sai_shell *sh = NULL; + + lws_start_foreach_dll(struct lws_dll2 *, d, builder.shell_owner.head) { + struct sai_shell *s = lws_container_of(d, struct sai_shell, list); + if (!strcmp(s->task_uuid, pd->task_uuid)) { + sh = s; + break; + } + } lws_end_foreach_dll(d); - lws_start_foreach_dll(struct lws_dll2 *, d, builder.shell_owner.head) { - struct sai_shell *s = lws_container_of(d, struct sai_shell, list); - if (!strcmp(s->task_uuid, pd->task_uuid)) { - sh = s; - break; - } - } lws_end_foreach_dll(d); + if (sh && sh->lsp && (pd->data || (pd->cols && pd->rows))) { + int fd = lws_spawn_get_fd_stdxxx(sh->lsp, 0); + if (fd >= 0) { + if (pd->cols && pd->rows) { + struct winsize ws; + memset(&ws, 0, sizeof(ws)); + ws.ws_col = (unsigned short)pd->cols; + ws.ws_row = (unsigned short)pd->rows; + if (ioctl(fd, TIOCSWINSZ, &ws) < 0) + lwsl_err("%s: failed to set pty size\n", __func__); + } - if (sh && sh->lsp && pd->data) { - int fd = lws_spawn_get_fd_stdxxx(sh->lsp, 0); - if (fd >= 0) { - char dec[1024]; - int dl = lws_b64_decode_string(pd->data, dec, sizeof(dec)); - if (dl > 0) { - lwsl_notice("%s: PTYDATA received from server (len %d), writing to shell %s\n", __func__, dl, pd->task_uuid); - if (write(fd, dec, LWS_POSIX_LENGTH_CAST(dl)) < 0) - lwsl_err("%s: failed to write to pty\n", __func__); + if (pd->data && pd->len > 0) { + char dec[1024]; + int dl = lws_b64_decode_string(pd->data, dec, sizeof(dec)); + if (dl > 0) { + // lwsl_notice("%s: PTYDATA received from server (len %d), writing to shell %s\n", __func__, dl, pd->task_uuid); + if (write(fd, dec, LWS_POSIX_LENGTH_CAST(dl)) < 0) + lwsl_err("%s: failed to write to pty\n", __func__); + } + } } + } else { + lwsl_warn("%s: PTYDATA for unknown shell %s\n", __func__, pd->task_uuid); } - } else { - lwsl_warn("%s: PTYDATA for unknown shell %s\n", __func__, pd->task_uuid); + break; } - break; - } case SAIB_RX_RESOURCE_REPLY: reso = (sai_resource_t *)a.dest; @@ -453,6 +465,21 @@ saib_m_tx(void *userobj, lws_ss_tx_ordinal_t ord, uint8_t *buf, size_t *len, } } lws_end_foreach_dll_safe(d2, d3); } lws_end_foreach_dll_safe(d, d1); + + /* unpause any backpressured shell stdwsi */ + lws_start_foreach_dll_safe(struct lws_dll2 *, d, d1, builder.shell_owner.head) { + struct sai_shell *sh = lws_container_of(d, struct sai_shell, list); + if (sh->spm == spm) { + for (int i = 0; i < 3; i++) { + if (sh->stdwsi_paused[i] && sh->stdwsi[i]) { + lwsl_notice("%s: Unpausing shell %s ch %d (tot %zu)\n", __func__, sh->task_uuid, i, + lws_buflist2_total_len(&spm->bl_to_srv)); + sh->stdwsi_paused[i] = 0; + lws_rx_flow_control(sh->stdwsi[i], 1 | LWS_RXFLOW_REASON_USER_BOOL); + } + } + } + } lws_end_foreach_dll_safe(d, d1); } } @@ -754,6 +781,29 @@ saib_m_state(void *userobj, void *sh, lws_ss_constate_t state, &builder.sai_plat_owner)) return -1; + { + sai_active_shells_t ash; + struct lwsac *ac = NULL; + + memset(&ash, 0, sizeof(ash)); + lws_start_foreach_dll(struct lws_dll2 *, d, builder.shell_owner.head) { + struct sai_shell *sh = lws_container_of(d, struct sai_shell, list); + sai_active_shell_t *s = lwsac_use_zero(&ac, sizeof(*s), 512); + if (s) { + lws_strncpy(s->task_uuid, sh->task_uuid, sizeof(s->task_uuid)); + lws_dll2_add_tail(&s->list, &ash.shells); + } + } lws_end_foreach_dll(d); + + if (ash.shells.head) { + saib_srv_queue_json_fragments_helper(spm->ss, + lsm_schema_map_active_shells, + LWS_ARRAY_SIZE(lsm_schema_map_active_shells), + &ash); + } + lwsac_free(&ac); + } + return 0; case LWSSSCS_DISCONNECTED: diff --git a/src/common/include/private.h b/src/common/include/private.h index ed827b6..5e10bb4 100644 --- a/src/common/include/private.h +++ b/src/common/include/private.h @@ -136,6 +136,15 @@ typedef struct sai_active_task_info { char git_hash[65]; } sai_active_task_info_t; +typedef struct sai_active_shell { + lws_dll2_t list; + char task_uuid[65]; +} sai_active_shell_t; + +typedef struct sai_active_shells { + lws_dll2_owner_t shells; +} sai_active_shells_t; + typedef struct sai_load_report { lws_dll2_t list; /* For queuing on sai_plat_server */ char builder_name[64]; @@ -358,6 +367,8 @@ typedef struct sai_ptydata { int channel; char *data; size_t len; + unsigned int cols; + unsigned int rows; } sai_ptydata_t; /* @@ -857,8 +868,10 @@ extern const lws_struct_map_t lsm_openshell[2], lsm_schema_openshell[1], lsm_closeshell[1], + lsm_schema_map_active_shells[1], + lsm_schema_active_shells[1], lsm_schema_closeshell[1], - lsm_ptydata[5], + lsm_ptydata[7], lsm_schema_ptydata[1], lsm_rebuild[1], lsm_schema_rebuild[1], diff --git a/src/common/struct-metadata.c b/src/common/struct-metadata.c index 43f0496..6e22df7 100644 --- a/src/common/struct-metadata.c +++ b/src/common/struct-metadata.c @@ -40,6 +40,19 @@ const lws_struct_map_t lsm_active_task_info[] = { LSM_CARRAY (sai_active_task_info_t, git_hash, "git_hash"), }; +const lws_struct_map_t lsm_active_shell[] = { + LSM_CARRAY (sai_active_shell_t, task_uuid, "task_uuid"), +}; + +const lws_struct_map_t lsm_schema_active_shells[] = { + LSM_LIST (sai_active_shells_t, shells, sai_active_shell_t, list, + NULL, lsm_active_shell, "shells"), +}; + +const lws_struct_map_t lsm_schema_map_active_shells[] = { + LSM_SCHEMA(sai_active_shells_t, NULL, lsm_schema_active_shells, "com.warmcat.sai.active_shells") +}; + const lws_struct_map_t lsm_load_report_members[] = { LSM_CARRAY (sai_load_report_t, builder_name, "builder_name"), LSM_SIGNED (sai_load_report_t, core_count, "core_count"), @@ -237,6 +250,8 @@ const lws_struct_map_t lsm_ptydata[] = { LSM_SIGNED (sai_ptydata_t, channel, "channel"), LSM_STRING_PTR (sai_ptydata_t, data, "data"), LSM_UNSIGNED (sai_ptydata_t, len, "len"), + LSM_UNSIGNED (sai_ptydata_t, cols, "cols"), + LSM_UNSIGNED (sai_ptydata_t, rows, "rows"), }; const lws_struct_map_t lsm_schema_ptydata[] = { diff --git a/src/server/s-private.h b/src/server/s-private.h index 2540887..84883b0 100644 --- a/src/server/s-private.h +++ b/src/server/s-private.h @@ -211,6 +211,13 @@ typedef struct sais_plat { int unmet_count; } sais_plat_t; +typedef struct sai_shell_session { + lws_dll2_t list; + char task_uuid[65]; + char builder_name[96]; + lws_dll2_owner_t ptydata_owner; +} sai_shell_session_t; + struct vhd { struct lws_context *context; struct lws_vhost *vhost; @@ -224,6 +231,7 @@ struct vhd { struct lws_dll2_owner sai_powers; struct lws_dll2_owner pending_plats; lws_dll2_owner_t powering_up_list; /* sai_powering_up_plat_t */ + lws_dll2_owner_t shell_sessions; /* sai_shell_session_t */ struct lwsac *ac_plats; diff --git a/src/server/s-task.c b/src/server/s-task.c index a1108a3..b0262c7 100644 --- a/src/server/s-task.c +++ b/src/server/s-task.c @@ -618,6 +618,18 @@ sais_platforms_with_tasks_pending(struct vhd *vhd) } lws_end_foreach_dll(p); + /* + * Also account for any in-memory interactive shell sessions + */ + lws_start_foreach_dll(struct lws_dll2 *, p_sh, vhd->shell_sessions.head) { + sai_shell_session_t *sh = lws_container_of(p_sh, sai_shell_session_t, list); + const char *plat = strchr(sh->builder_name, '.'); + if (plat) + sais_find_or_add_pending_plat(vhd, plat + 1, 1, 1); + else + sais_find_or_add_pending_plat(vhd, sh->builder_name, 1, 1); + } lws_end_foreach_dll(p_sh); + sais_notify_all_sai_power(vhd); /* diff --git a/src/server/s-ws-builder.c b/src/server/s-ws-builder.c index 2699112..2db6fc6 100644 --- a/src/server/s-ws-builder.c +++ b/src/server/s-ws-builder.c @@ -74,6 +74,8 @@ static const lws_struct_map_t lsm_schema_map_ba[] = { "com.warmcat.sai.build-metric"), LSM_SCHEMA (sai_ptydata_t, NULL, lsm_ptydata, "com.warmcat.sai.ptydata"), + LSM_SCHEMA (sai_active_shells_t, NULL, lsm_schema_active_shells, + "com.warmcat.sai.active_shells"), }; enum { @@ -85,6 +87,7 @@ enum { SAIM_WSSCH_BUILDER_RESOURCE_REQ, SAIM_WSSCH_BUILDER_METRIC, SAIM_WSSCH_BUILDER_PTYDATA, + SAIM_WSSCH_BUILDER_ACTIVE_SHELLS, }; static void @@ -960,16 +963,42 @@ sais_ws_json_rx_builder(struct vhd *vhd, struct pss *pss, uint8_t *buf, size_t b goto bail; } } lws_end_foreach_dll(p); - #if 0 - lws_start_foreach_dll(struct lws_dll2 *, p, vhd->server.builder_owner.head) { - sp = lws_container_of(p, sai_plat_t, sai_plat_list); - if (sp->wsi == pss->wsi) { - /* This platform belongs to the connection that sent the message */ - if (sais_allocate_task(vhd, pss, sp, sp->platform) < 0) - goto bail; - } - } lws_end_foreach_dll(p); - #endif + /* + * Also, if there are any pending in-memory shell sessions for this builder, + * send them down now! + */ + lws_start_foreach_dll(struct lws_dll2 *, p_sh, vhd->shell_sessions.head) { + sai_shell_session_t *sh = lws_container_of(p_sh, sai_shell_session_t, list); + lws_start_foreach_dll(struct lws_dll2 *, p, vhd->server.builder_owner.head) { + sai_plat_t *sp = lws_container_of(p, sai_plat_t, sai_plat_list); + const char *sh_plat = strchr(sh->builder_name, '.'); + if (sh_plat) sh_plat++; else sh_plat = sh->builder_name; + + const char *sp_plat = strchr(sp->name, '.'); + if (sp_plat) sp_plat++; else sp_plat = sp->name; + + if (sp->wsi == pss->wsi && + (!strcmp(sh->builder_name, sp->name) || !strcmp(sh_plat, sp_plat))) { + sai_openshell_t *s = malloc(sizeof(*s)); + if (s) { + memset(s, 0, sizeof(*s)); + lws_strncpy(s->task_uuid, sh->task_uuid, sizeof(s->task_uuid)); + lws_strncpy(s->builder_name, sp->name, sizeof(s->builder_name)); + lws_dll2_add_tail(&s->list, &pss->openshell_owner); + + lws_start_foreach_dll_safe(struct lws_dll2 *, pd, pd1, sh->ptydata_owner.head) { + sai_ptydata_t *pdy = lws_container_of(pd, sai_ptydata_t, list); + lws_dll2_remove(pd); + lws_strncpy(pdy->builder_name, sp->name, sizeof(pdy->builder_name)); + lws_dll2_add_tail(&pdy->list, &pss->ptydata_owner); + } lws_end_foreach_dll_safe(pd, pd1); + + lws_callback_on_writable(pss->wsi); + } + break; + } + } lws_end_foreach_dll(p); + } lws_end_foreach_dll(p_sh); /* * If we did allocate a task in pss->a.ac, responsibility of @@ -1027,6 +1056,46 @@ sais_ws_json_rx_builder(struct vhd *vhd, struct pss *pss, uint8_t *buf, size_t b lwsac_free(&pss->a.ac); break; + case SAIM_WSSCH_BUILDER_ACTIVE_SHELLS: + { + sai_active_shells_t *ash = (sai_active_shells_t *)pss->a.dest; + lws_start_foreach_dll(struct lws_dll2 *, d, ash->shells.head) { + sai_active_shell_t *s = lws_container_of(d, sai_active_shell_t, list); + int found = 0; + lws_start_foreach_dll(struct lws_dll2 *, d2, vhd->shell_sessions.head) { + sai_shell_session_t *sh = lws_container_of(d2, sai_shell_session_t, list); + if (!strcmp(sh->task_uuid, s->task_uuid)) { + found = 1; + break; + } + } lws_end_foreach_dll(d2); + + if (!found) { + sai_shell_session_t *nsh = malloc(sizeof(*nsh)); + if (nsh) { + memset(nsh, 0, sizeof(*nsh)); + lws_strncpy(nsh->task_uuid, s->task_uuid, sizeof(nsh->task_uuid)); + if (vhd->server.builder_owner.head) { + lws_start_foreach_dll(struct lws_dll2 *, p, vhd->server.builder_owner.head) { + sai_plat_t *sp = lws_container_of(p, sai_plat_t, sai_plat_list); + if (sp->wsi == pss->wsi) { + lws_strncpy(nsh->builder_name, sp->name, sizeof(nsh->builder_name)); + break; + } + } lws_end_foreach_dll(p); + } + lws_dll2_add_tail(&nsh->list, &vhd->shell_sessions); + lwsl_notice("%s: Rediscovered shell %s for builder %s\n", __func__, nsh->task_uuid, nsh->builder_name); + } + } + } lws_end_foreach_dll(d); + + sais_platforms_with_tasks_pending(vhd); + + lwsac_free(&pss->a.ac); + break; + } + case SAIM_WSSCH_BUILDER_LOADREPORT: /* @@ -1432,7 +1501,7 @@ sais_ws_json_rx_builder(struct vhd *vhd, struct pss *pss, uint8_t *buf, size_t b info.len = used; info.ss_flags = LWSSS_FLAG_SOM | LWSSS_FLAG_EOM; - lwsl_notice("%s: PTYDATA received from builder shell %s, relaying %d bytes to web\n", __func__, pd->task_uuid, (int)used); + // lwsl_notice("%s: PTYDATA received from builder shell %s, relaying %d bytes to web\n", __func__, pd->task_uuid, (int)used); if (sais_websrv_broadcast_REQUIRES_LWS_PRE(vhd->h_ss_websrv, &info) < 0) lwsl_warn("%s: unable to broadcast to web\n", __func__); diff --git a/src/server/s-ws-web.c b/src/server/s-ws-web.c index 517e79b..3ca4cf3 100644 --- a/src/server/s-ws-web.c +++ b/src/server/s-ws-web.c @@ -693,32 +693,36 @@ websrvss_ws_rx(void *userobj, const uint8_t *buf, size_t len, int flags) lwsl_notice("%s: OPENSHELL received from web for %s, passing to builder\n", __func__, os->builder_name); - sp = sais_builder_from_uuid(m->vhd, os->builder_name); - if (!sp) { - lwsl_err("%s: unknown builder %s for openshell\n", - __func__, os->builder_name); - lwsac_free(&a.ac); - break; - } - if (!os->task_uuid[0]) sai_uuid16_create(m->vhd->context, os->task_uuid); - lws_start_foreach_dll(struct lws_dll2 *, p, m->vhd->builders.head) { - struct pss *pss = lws_container_of(p, struct pss, same); + /* Add it to in-memory shell sessions list */ + sai_shell_session_t *sh = malloc(sizeof(*sh)); + if (sh) { + memset(sh, 0, sizeof(*sh)); + lws_strncpy(sh->task_uuid, os->task_uuid, sizeof(sh->task_uuid)); + lws_strncpy(sh->builder_name, os->builder_name, sizeof(sh->builder_name)); + lws_dll2_add_tail(&sh->list, &m->vhd->shell_sessions); + } - if (pss->wsi == sp->wsi) { - sai_openshell_t *s = malloc(sizeof(*s)); - if (s) { - *s = *os; - lws_dll2_add_tail(&s->list, &pss->openshell_owner); - lws_callback_on_writable(pss->wsi); - } - break; - } - } lws_end_foreach_dll(p); + sp = sais_builder_from_uuid(m->vhd, os->builder_name); + if (sp) { + lws_start_foreach_dll(struct lws_dll2 *, p, m->vhd->builders.head) { + struct pss *pss = lws_container_of(p, struct pss, same); + if (pss->wsi == sp->wsi) { + sai_openshell_t *s = malloc(sizeof(*s)); + if (s) { + *s = *os; + lws_dll2_add_tail(&s->list, &pss->openshell_owner); + lws_callback_on_writable(pss->wsi); + } + break; + } + } lws_end_foreach_dll(p); + } + sais_platforms_with_tasks_pending(m->vhd); lwsac_free(&a.ac); break; @@ -727,7 +731,26 @@ websrvss_ws_rx(void *userobj, const uint8_t *buf, size_t len, int flags) case SAIS_WS_WEBSRV_RX_CLOSESHELL: { sai_closeshell_t *cs = (sai_closeshell_t *)a.dest; + + lws_start_foreach_dll_safe(struct lws_dll2 *, d, d1, m->vhd->shell_sessions.head) { + sai_shell_session_t *sh = lws_container_of(d, sai_shell_session_t, list); + if (!strcmp(sh->task_uuid, cs->task_uuid)) { + lws_start_foreach_dll_safe(struct lws_dll2 *, pd, pd1, sh->ptydata_owner.head) { + sai_ptydata_t *pdy = lws_container_of(pd, sai_ptydata_t, list); + lws_dll2_remove(&pdy->list); + if (pdy->data) free(pdy->data); + free(pdy); + } lws_end_foreach_dll_safe(pd, pd1); + + lws_dll2_remove(&sh->list); + free(sh); + break; + } + } lws_end_foreach_dll_safe(d, d1); + sais_task_cancel(m->vhd, cs->task_uuid, 0); + sais_platforms_with_tasks_pending(m->vhd); + lwsac_free(&a.ac); break; } @@ -739,12 +762,31 @@ websrvss_ws_rx(void *userobj, const uint8_t *buf, size_t len, int flags) sp = sais_builder_from_uuid(m->vhd, pd->builder_name); if (!sp) { - lwsl_err("%s: PTYDATA from web: unknown builder %s\n", __func__, pd->builder_name); + /* Builder offline, buffer it! */ + lws_start_foreach_dll(struct lws_dll2 *, d, m->vhd->shell_sessions.head) { + sai_shell_session_t *sh = lws_container_of(d, sai_shell_session_t, list); + if (!strcmp(sh->task_uuid, pd->task_uuid)) { + sai_ptydata_t *s = malloc(sizeof(*s)); + if (s) { + size_t slen = pd->data ? strlen(pd->data) : 0; + *s = *pd; + s->data = pd->data ? malloc(slen + 1) : NULL; + if (s->data || !pd->data) { + if (pd->data) + memcpy((char *)s->data, pd->data, slen + 1); + lws_dll2_add_tail(&s->list, &sh->ptydata_owner); + } else + free(s); + } + break; + } + } lws_end_foreach_dll(d); + lwsac_free(&a.ac); break; } - lwsl_notice("%s: PTYDATA received from web (len %d), passing to builder %s\n", __func__, (int)pd->len, pd->builder_name); + // lwsl_notice("%s: PTYDATA received from web (len %d), passing to builder %s\n", __func__, (int)pd->len, pd->builder_name); lws_start_foreach_dll(struct lws_dll2 *, p, m->vhd->builders.head) { struct pss *pss = lws_container_of(p, struct pss, same); @@ -752,11 +794,12 @@ websrvss_ws_rx(void *userobj, const uint8_t *buf, size_t len, int flags) if (pss->wsi == sp->wsi) { sai_ptydata_t *s = malloc(sizeof(*s)); if (s) { - size_t slen = strlen(pd->data); + size_t slen = pd->data ? strlen(pd->data) : 0; *s = *pd; - s->data = malloc(slen + 1); - if (s->data) { - memcpy((char *)s->data, pd->data, slen + 1); + s->data = pd->data ? malloc(slen + 1) : NULL; + if (s->data || !pd->data) { + if (pd->data) + memcpy((char *)s->data, pd->data, slen + 1); lws_dll2_add_tail(&s->list, &pss->ptydata_owner); lws_callback_on_writable(pss->wsi); } else diff --git a/src/virt/v-http-api.c b/src/virt/v-http-api.c index f9cd831..91f3887 100644 --- a/src/virt/v-http-api.c +++ b/src/virt/v-http-api.c @@ -73,7 +73,7 @@ callback_virt_http(struct lws *wsi, enum lws_callback_reasons reason, if (found_vm) { /* Extend the safety timeout since the VM is alive and communicating */ lws_sul_schedule(virt.context, 0, &found_vm->sul_timeout, - saiv_vm_timeout_cb, 5 * 60 * LWS_US_PER_SEC); + saiv_vm_timeout_cb, 30 * LWS_US_PER_SEC); } /* We never return stay = true for ephemeral VMs */ diff --git a/src/web/w-comms.c b/src/web/w-comms.c index e012760..7eb11d2 100644 --- a/src/web/w-comms.c +++ b/src/web/w-comms.c @@ -163,6 +163,28 @@ w_callback_ws(struct lws *wsi, enum lws_callback_reasons reason, void *user, vhd->context = lws_get_context(wsi); vhd->vhost = lws_get_vhost(wsi); + { + const struct lws_protocol_vhost_options *pvo = (const struct lws_protocol_vhost_options *)in; + const char *jwk_path = "/etc/sai/web/auth.jwk"; + + lws_strncpy(vhd->cookie_name, "auth_session", sizeof(vhd->cookie_name)); + + while (pvo) { + if (!strcmp(pvo->name, "jwt-auth-jwk-path")) + jwk_path = pvo->value; + if (!strcmp(pvo->name, "cookie-name")) + lws_strncpy(vhd->cookie_name, pvo->value, sizeof(vhd->cookie_name)); + pvo = pvo->next; + } + lws_strncpy(vhd->jwk_path, jwk_path, sizeof(vhd->jwk_path)); + if (!lws_jwk_load(&vhd->jwk, vhd->jwk_path, NULL, NULL)) { + vhd->has_jwk = 1; + lwsl_notice("Loaded JWT jwk from %s\n", vhd->jwk_path); + } else { + lwsl_err("FAILED to load JWT jwk from %s\n", vhd->jwk_path); + } + } + if (lws_pvo_get_str(in, "database", &vhd->sqlite3_path_lhs)) { lwsl_err("%s: database pvo required\n", __func__); return -1; @@ -483,6 +505,36 @@ http_resp: } #endif + pss->authorized = 0; + if (vhd->has_jwk) { +#if defined(LWS_WITH_JOSE) + const char *reason = "unknown"; + struct lws_jwt_auth *ja = lws_jwt_auth_create(wsi, &vhd->jwk, vhd->cookie_name, NULL, NULL, &reason); + if (ja) { + if (lws_jwt_auth_query_grant(ja, "*") >= 1 || lws_jwt_auth_query_grant(ja, "com.warmcat.sai") >= 1) { + pss->authorized = 1; + lwsl_wsi_notice(wsi, "Authorized WebSocket connection (admin/grant)"); + } else { + lwsl_wsi_err(wsi, "JWT validation passed, but no grant found"); + } + lws_jwt_auth_destroy(&ja); + } else { + if (reason && !strcmp(reason, "Cookie not found")) { + char cookies[512]; + int n = lws_hdr_copy(wsi, cookies, sizeof(cookies), WSI_TOKEN_HTTP_COOKIE); + if (n > 0) + lwsl_wsi_err(wsi, "JWT auth failed (cookie: '%s'): Cookie not found (available cookies: '%s') (using JWK from %s)", vhd->cookie_name, cookies, vhd->jwk_path); + else + lwsl_wsi_err(wsi, "JWT auth failed (cookie: '%s'): Cookie not found (no cookies sent by browser) (using JWK from %s)", vhd->cookie_name, vhd->jwk_path); + } else { + lwsl_wsi_err(wsi, "JWT auth failed (cookie: '%s'): %s (using JWK from %s)", vhd->cookie_name, reason ? reason : "unknown", vhd->jwk_path); + } + } +#endif + } else { + lwsl_wsi_err(wsi, "Cannot validate JWT because no JWK was loaded (expected at %s)", vhd->jwk_path); + } + if (!memcmp((char *)start, "/sai", 4)) start += 4; diff --git a/src/web/w-private.h b/src/web/w-private.h index b2e0ae7..2e9e057 100644 --- a/src/web/w-private.h +++ b/src/web/w-private.h @@ -124,12 +124,18 @@ struct pss { unsigned int announced:1; unsigned int bulk_binary_data:1; unsigned int toggle_favour_sch:1; + unsigned int authorized:1; }; struct vhd { struct lws_context *context; struct lws_vhost *vhost; + struct lws_jwk jwk; + int has_jwk; + char cookie_name[64]; + char jwk_path[256]; + /* pss lists */ struct lws_dll2_owner browsers; diff --git a/src/web/w-ws-browser.c b/src/web/w-ws-browser.c index cfa5d40..842565e 100644 --- a/src/web/w-ws-browser.c +++ b/src/web/w-ws-browser.c @@ -641,6 +641,22 @@ saiw_ws_json_rx_browser(struct vhd *vhd, struct pss *pss, uint8_t *buf, * matched on */ + if (!pss->authorized && ( + a.top_schema_index == SAIM_WS_BROWSER_RX_TASKRESET || + a.top_schema_index == SAIM_WS_BROWSER_RX_TASKREBUILDLASTSTEP || + a.top_schema_index == SAIM_WS_BROWSER_RX_EVENTRESET || + a.top_schema_index == SAIM_WS_BROWSER_RX_EVENTDELETE || + a.top_schema_index == SAIM_WS_BROWSER_RX_TASKCANCEL || + a.top_schema_index == SAIM_WS_BROWSER_RX_REBUILD || + a.top_schema_index == SAIM_WS_BROWSER_RX_PLATRESET || + a.top_schema_index == SAIM_WS_BROWSER_RX_BUILDERDELETE || + a.top_schema_index == SAIM_WS_BROWSER_RX_OPENSHELL || + a.top_schema_index == SAIM_WS_BROWSER_RX_CLOSESHELL || + a.top_schema_index == SAIM_WS_BROWSER_RX_PTYDATA)) { + lwsl_notice("%s: Unauthorized attempt to execute administrative action (schema %d)\n", __func__, a.top_schema_index); + goto soft_error; + } + switch (a.top_schema_index) { case SAIM_WS_BROWSER_RX_TASKINFO:
Page fetched 0s ago, creation time: 16ms (vhost etag hits: 0%, cache hits: 0%)