Du står et sted med elendig nett og trenger å kjøre én kommando på en server hjemme eller i produksjon. Å pirke på en SSH-klient på telefonen er en øvelse i tålmodighet. Løsningen jeg endte på: en terminal rett i nettleseren, tilgjengelig hvor som helst fra, uten en eneste åpen port. Under følger hvordan, og hvorfor autentiseringen må komme først av alt.
Arkitektur

Det er fire deler i dette. Backenden i FastAPI eksponerer en WebSocket som kobler seg mot en pty (pseudo-terminal) på serveren. Frontenden bruker xterm.js til å tegne selve terminalen og sende tastetrykkene tilbake over den samme WebSocket-en. Hele greia kjøres som en systemd-service. Ekstern tilgang går via Cloudflare Tunnel, og dermed har maskinen ingen innkommende porter åpne i det hele tatt.
Backend: FastAPI mot pty
En pty er det som gir deg et ekte terminal-grensesnitt, ikke bare en rå strøm av bytes. Serveren spawner et skall, kobler master-enden til WebSocket-en, og pumper så bytes i begge retninger.
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 tar seg av rendering, escape-sekvenser og input, altså alt det kjedelige du ikke har lyst til å skrive selv. Det som gjenstår er å koble mot WebSocket-en og sende tastetrykkene videre som bytes.
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 tjenesten som en service, så den kommer opp igjen av seg selv etter en 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
Legg merke til bindingen til 127.0.0.1. Cloudflare Tunnel kobler utover mot Cloudflare, og det betyr at ingen porter trenger å åpnes i brannmuren.
tunnel: web-terminal
ingress:
- hostname: terminal.eksempel.no
service: http://127.0.0.1:8000
- service: http_status:404
Sikkerhet er ikke valgfritt
En åpen web-terminal er nøyaktig det det høres ut som: full tilgang til maskinen for hvem som helst som finner den. Derfor må autentiseringen stå foran WebSocket-en, ikke bak. 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 overhodet aksepteres. Kravene, kort:
- Access-policy eller token foran alt, og avvis før pty-en spawnes.
- Begrens hvem som slipper inn til en eksplisitt liste, ikke «alle som har lenken».
- Logg hver eneste tilkobling: kilde, identitet og tidspunkt.
- Kjør tjenesten som en dedikert bruker med minst mulig rettigheter.
Oppsummert
FastAPI og en pty gir deg selve terminalen, xterm.js gir grensesnittet, systemd holder den i live, og Cloudflare Tunnel gir tilgang uten åpne porter. Men glemmer du autentiseringen foran WebSocket-en, har du i praksis gitt bort maskinen. Sett opp Access-policyen først, så tar du resten etterpå.



