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

Arkitekturdiagram som viser nettleser med xterm.js koblet via WebSocket gjennom Cloudflare Tunnel til FastAPI-backend og pty på serveren

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, pty
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)
Frontend: xterm.js
xterm.js håndterer rendering, escape-sekvenser og input. Du trenger bare å koble til WebSocket-en og sende tastetrykkene videre som bytes.

Nettverkstopologi som viser systemd-tjeneste bundet til localhost og Cloudflare Tunnel som gir ekstern tilgang uten åpne brannmurporter

const term = new Terminal();
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)));
Systemd og Cloudflare Tunnel

Kjør backenden som en systemd-tjeneste, så starter den igjen av seg selv etter omstart.

[Unit]
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
Tjenesten binder seg til 127.0.0.1. Cloudflare Tunnel kobler utover mot Cloudflare, så ingen porter må åpnes i brannmuren.

tunnel: web-terminal
ingress:
- hostname: terminal.eksempel.no
service: http://127.0.0.1:8000
- service: http_status:404
Autentisering foran WebSocket-en
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.