feat: add in-browser terminal with per-user docker sessions and drive mount

Add a web-based terminal accessible at /terminal, backed by per-user ephemeral Docker containers. Each user gets an isolated Ubuntu container with their drive directory mounted at /root, memory/cpu limits, and a prepared .bashrc/.profile. The terminal uses xterm.js over a WebSocket binary transport for full ANSI support. Also migrate the base Docker image from Alpine to Debian bookworm (python:3.14.0a6-bookworm), remove wkhtmltopdf dependencies, and fix the compose volume path from /media/storage/snek/molodetz.nl/drive to /media/storage/snek.molodetz.nl/drive.
This commit is contained in:
2025-03-22 18:57:39 +00:00
parent 20c2209162
commit 3ed651d23f
10 changed files with 329 additions and 51 deletions
+54
View File
@@ -0,0 +1,54 @@
const channelUid = "{{ channel.uid.value }}";
function initInputField(textBox) {
textBox.addEventListener('change', (e) => {
e.preventDefault();
this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));
});
textBox.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const message = e.target.value.trim();
if (message) {
app.rpc.sendMessage(channelUid, message);
e.target.value = '';
}
}
});
textBox.focus();
}
function updateTimes() {
document.querySelectorAll(".time").forEach((time) => {
time.innerText = app.timeDescription(time.dataset.created_at);
});
}
function isElementVisible(element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
const messagesContainer = document.querySelector(".chat-messages");
let isLoadingExtra = false;
messagesContainer.addEventListener("scroll", () => {
loadExtra();
});
setInterval(updateTimes, 1000);
app.addEventListener("channel-message", (data) => {
if (data.channel_uid !== channelUid) {
if(!isMentionForSomeoneElse(data.message)){
channelSidebar.notify(data);
}
}
});
+48
View File
@@ -0,0 +1,48 @@
import asyncio
import aiohttp
import aiohttp.web
import os
import pty
import shlex
import subprocess
import pathlib
commands = {
'alpine': 'docker run -it alpine /bin/sh',
'r': 'docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh',
}
class TerminalSession:
def __init__(self,command):
self.master, self.slave = pty.openpty()
self.sockets =[]
self.process = subprocess.Popen(
command.split(" "),
stdin=self.slave,
stdout=self.slave,
stderr=self.slave,
bufsize=0,
universal_newlines=True
)
async def read_output(self, ws):
loop = asyncio.get_event_loop()
self.sockets.append(ws)
if len(self.sockets) > 1:
return
while True:
try:
data = await loop.run_in_executor(None, os.read, self.master, 1024)
if not data:
break
try:
for ws in self.sockets: await ws.send_bytes(data) # Send raw bytes for ANSI support
except:
self.sockets.remove(ws)
except Exception:
break
async def write_input(self, data):
os.write(self.master, data.encode())
+1
View File
@@ -0,0 +1 @@
../static/
+47
View File
@@ -0,0 +1,47 @@
{% extends "app.html" %}
{% block sidebar %}
Reboot
{% endblock %}
{% block main %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css">
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
<style>
#terminal { width: 100%; height: 480px; overflow-y: none; }
</style>
<div class="container" id="terminal"></div>
<script>
const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();
window.addEventListener("resize", () => fitAddon.fit());
const schema = window.location.protocol === "https:" ? "wss" : "ws";
const hostname = window.location.host;
const url = `${schema}://${hostname}/terminal.ws`;
const socket = new WebSocket(url);
socket.binaryType = "arraybuffer"; // Support binary data
socket.onopen = () => term.write("\x1b[32mConnected to Molodetz\x1b[0m\r\n");
socket.onmessage = (event) => {
const data = new Uint8Array(event.data);
term.write(new TextDecoder().decode(data));
};
term.onData(data => socket.send(new TextEncoder().encode(data)));
socket.onclose = () => term.write("\r\n\x1b[31mConnection closed\x1b[0m\r\n");
</script>
{% endblock main %}
+56
View File
@@ -0,0 +1,56 @@
from snek.system.view import BaseView
import aiohttp
import asyncio
from snek.system.terminal import TerminalSession
import pathlib
class TerminalSocketView(BaseView):
login_required = True
user_sessions = {}
async def prepare_drive(self):
user = await self.services.user.get(uid=self.session.get("uid"))
root = pathlib.Path("drive").joinpath(user["uid"])
root.mkdir(parents=True, exist_ok=True)
terminal_folder = pathlib.Path("terminal")
for path in terminal_folder.iterdir():
destination_path = root.joinpath(path.name)
if not destination_path.exists():
if not path.is_dir():
destination_path.write_bytes(path.read_bytes())
return root
async def get(self):
ws = aiohttp.web.WebSocketResponse()
await ws.prepare(self.request)
user = await self.services.user.get(uid=self.session.get("uid"))
root = await self.prepare_drive()
command = f"docker run -v ./{root}/:/root --rm -it --memory 512M --cpus=0.5 -w /root ubuntu:latest /bin/bash"
print(command)
session = self.user_sessions.get(user["uid"])
if not session:
self.user_sessions[user["uid"]] = TerminalSession(command=command)
session = self.user_sessions[user["uid"]]
asyncio.create_task(session.read_output(ws))
async for msg in ws:
if msg.type == aiohttp.WSMsgType.BINARY:
await session.write_input(msg.data.decode())
return ws
class TerminalView(BaseView):
login_required = True
async def get(self):
request = self.request
return await self.request.app.render_template('terminal.html',self.request)