Web-terminal i nettleseren med FastAPI og Cloudflare Tunnel

Jeg kjører en terminal i nettleseren for å nå servere hjemme og i produksjon fra hvor som helst, uten åpne porter. En SSH-klient på telefonen er tungvint på dårlig nett. Oppsettet er FastAPI, en pty, xterm.js, systemd og Cloudflare Tunnel. Autentiseringen må settes opp før alt annet, se siste avsnitt.
Arkitektur

Oppsettet har fire deler. Backenden i FastAPI har en WebSocket som kobler seg mot en pty (pseudo-terminal) på serveren. Frontenden bruker xterm.js til å tegne terminalen og sender tastetrykkene tilbake over samme WebSocket. Tjenesten kjører under systemd. Ekstern tilgang går via Cloudflare Tunnel, så maskinen har ingen innkommende porter åpne.
Backend: FastAPI mot pty
En pty gir et ekte terminalgrensesnitt i stedet for en rå strøm av bytes. Serveren starter et skall, kobler master-enden til WebSocket-en og sender bytes begge veier.
import asyncio, os, ptyFrontend: xterm.js
from fastapi import FastAPI, WebSocket
app = FastAPI()
@app.websocket("/ws")
async def terminal(ws: WebSocket):
await ws.accept()
pid, fd = pty.fork()
if pid == 0:
os.execvp("bash", ["bash"])
loop = asyncio.get_event_loop()
async def to_browser():
while True:
data = await loop.run_in_executor(None, os.read, fd, 1024)
await ws.send_bytes(data)
task = asyncio.create_task(to_browser())
try:
while True:
msg = await ws.receive_bytes()
os.write(fd, msg)
finally:
task.cancel()
os.close(fd)
xterm.js håndterer rendering, escape-sekvenser og input. Du trenger bare å koble til WebSocket-en og sende tastetrykkene videre som bytes.

const term = new Terminal();Systemd og Cloudflare Tunnel
term.open(document.getElementById("terminal"));
const ws = new WebSocket("wss://terminal.eksempel.no/ws");
ws.binaryType = "arraybuffer";
ws.onmessage = (e) => term.write(new Uint8Array(e.data));
term.onData((d) => ws.send(new TextEncoder().encode(d)));
Kjør backenden som en systemd-tjeneste, så starter den igjen av seg selv etter omstart.
[Unit]Tjenesten binder seg til 127.0.0.1. Cloudflare Tunnel kobler utover mot Cloudflare, så ingen porter må åpnes i brannmuren.
Description=Web terminal
After=network.target
[Service]
ExecStart=/opt/webterm/venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000
Restart=always
User=webterm
[Install]
WantedBy=multi-user.target
tunnel: web-terminalAutentisering foran WebSocket-en
ingress:
- hostname: terminal.eksempel.no
service: http://127.0.0.1:8000
- service: http_status:404
En web-terminal uten autentisering gir full tilgang til maskinen for alle som finner adressen. Autentiseringen må derfor stå foran WebSocket-en. Legg en Cloudflare Access-policy foran hostnavnet, med tilgang begrenset til navngitte e-postadresser eller en identitetsleverandør. Alternativet er et token som valideres før WebSocket-en aksepteres. Kravene:
- Access-policy eller token foran alt, og avvis tilkoblingen før pty-en startes.
- Begrens tilgangen til en eksplisitt liste, aldri til alle som har lenken.
- Logg hver tilkobling med kilde, identitet og tidspunkt.
- Kjør tjenesten som en egen bruker med minst mulig rettigheter.
Uten autentisering foran WebSocket-en har du i praksis gitt bort maskinen. Sett opp Access-policyen først.


