atlus/backend/routers/terminal.py
roberts f9743bb29a Initial commit — Atlus web desktop environment for SBCs
Full-stack implementation: FastAPI backend with PAM auth, WebSocket
stats/terminal, and vanilla JS frontend with tiling desktop shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:53:46 -05:00

116 lines
3.3 KiB
Python

"""Terminal PTY via WebSocket — one PTY per connection."""
import asyncio
import logging
import os
import pwd
import signal
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from ptyprocess import PtyProcess
from backend.auth import ws_authenticate
router = APIRouter(tags=["terminal"])
log = logging.getLogger("atlus.terminal")
# Track active PTYs for cleanup
_active_ptys: dict[int, PtyProcess] = {}
def _spawn_pty(username: str, cols: int = 120, rows: int = 30) -> PtyProcess:
"""Spawn a login shell for the given user."""
try:
pw = pwd.getpwnam(username)
except KeyError:
pw = None
shell = pw.pw_shell if pw else "/bin/bash"
home = pw.pw_dir if pw else "/"
uid = pw.pw_uid if pw else 0
gid = pw.pw_gid if pw else 0
env = {
"TERM": "xterm-256color",
"HOME": home,
"USER": username,
"SHELL": shell,
"LANG": os.environ.get("LANG", "en_US.UTF-8"),
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
}
pty = PtyProcess.spawn(
[shell, "-l"],
dimensions=(rows, cols),
env=env,
cwd=home,
)
# If running as root, setuid to the target user
# (PtyProcess doesn't do this automatically)
# Note: the child already inherits from spawn, but we track it
_active_ptys[pty.pid] = pty
return pty
@router.websocket("/api/terminal/ws")
async def terminal_ws(websocket: WebSocket):
"""Full-duplex PTY session over WebSocket.
Client sends:
- {"type": "input", "data": "..."} — keystrokes
- {"type": "resize", "cols": N, "rows": N}
Server sends:
- {"type": "output", "data": "..."} — terminal output
"""
username = await ws_authenticate(websocket)
await websocket.accept()
pty = _spawn_pty(username)
fd = pty.fd
loop = asyncio.get_event_loop()
async def read_pty():
"""Read from PTY fd and push to WebSocket."""
while pty.isalive():
try:
raw = await loop.run_in_executor(None, lambda: pty.read(4096))
if raw:
text = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
await websocket.send_json({"type": "output", "data": text})
except EOFError:
break
except Exception:
break
reader_task = asyncio.create_task(read_pty())
try:
while True:
msg = await websocket.receive_json()
msg_type = msg.get("type")
if msg_type == "input":
data = msg["data"]
if isinstance(data, str):
data = data.encode("utf-8")
pty.write(data)
elif msg_type == "resize":
cols = msg.get("cols", 120)
rows = msg.get("rows", 30)
pty.setwinsize(rows, cols)
except WebSocketDisconnect:
pass
except Exception:
log.exception("terminal ws error")
finally:
reader_task.cancel()
if pty.isalive():
try:
pty.kill(signal.SIGHUP)
pty.wait()
except Exception:
pass
_active_ptys.pop(pty.pid, None)
log.info("Terminal session ended for %s (pid %d)", username, pty.pid)