forked from retoor/snek
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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())
|
||||
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../static/
|
||||
@@ -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 %}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user