🖥️ Web Terminal (wterm.dev)#

Browser-based terminal giving a real bash shell on the server. Accessible at https://funday.gg/dev/terminal for dev-gated users.

Architecture#

Browser (wterm WASM emulator)
  ↕  WebSocket (JSON, wss://)
nginx  (/terminal-ws/ → 127.0.0.1:7681)
  ↕  ws://
terminal-bridge.mjs  (node-pty + ws, port 7681)
  ↕  PTY
/bin/bash -l  (login shell, Funday user)

Services#

ServicePortsystemdPurpose
funday-terminal7681 (localhost only)funday-terminal.serviceWS↔PTY bridge
nginx443 (public)nginxTLS + WS proxy at /terminal-ws/
SvelteKit frontend3000funday-frontend.service/dev/terminal page

File Map#

server/
└── terminal-bridge.mjs              ← WS↔PTY bridge (ESM module, node-pty + ws)

etc/systemd/system/
└── funday-terminal.service          ← systemd unit (enabled, restart-on-failure)

etc/nginx/sites-available/
└── funday                           ← contains location ^~ /terminal-ws/ { ... }

frontend/src/
├── lib/components/dev/
│   └── WTermTerminal.svelte         ← wterm.dev Svelte 5 wrapper component
├── routes/dev/terminal/
│   └── +page.svelte                 ← /dev/terminal route page
├── lib/config/
│   └── devTools.ts                  ← sidebar registry (terminal entry)
└── types/
    └── wterm.d.ts                   ← TypeScript declarations for @wterm/dom

WS Protocol#

JSON frames over WebSocket. Each message: {"type": "...", ...}

Browser → Bridge#

TypePurposeExample
createSpawn PTY{"type":"create","cols":100,"rows":30}
inputKeystrokes{"type":"input","data":"ls\n"}
resizeResize{"type":"resize","cols":120,"rows":40}
killKill PTY{"type":"kill"}

Bridge → Browser#

TypeFieldsWhen
createdpidPTY spawned
outputdata (ANSI string)Shell output (streaming)
exitcode, signalShell exited
errormessageServer error

Security#

  • Dev access gate/dev/* requires Nakama auth + developer role
  • Max 4 concurrent PTYs — configurable via TERMINAL_MAX_CONN
  • Non-rootUser=usr, NoNewPrivileges=true, ProtectSystem=strict
  • Private port — bridge binds 127.0.0.1:7681 only
  • TLS terminated at nginx — wss:// over the wire
  • No shell escape — wterm is render-only, keystrokes via WS bridge

Operations#

# Service
sudo systemctl status funday-terminal
sudo systemctl restart funday-terminal
sudo journalctl -u funday-terminal -f

# Health
curl -s http://127.0.0.1:7681/ | python3 -m json.tool

# Change max connections
sudo systemctl edit funday-terminal  # add Environment=TERMINAL_MAX_CONN=8
sudo systemctl daemon-reload && sudo systemctl restart funday-terminal

Nginx#

The /terminal-ws/ proxy block in sites-available/funday:

location ^~ /terminal-ws/ {
    proxy_pass http://127.0.0.1:7681/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 86400;
    proxy_send_timeout 86400;
}

⚠️ sites-enabled/funday is a file copy, not a symlink. After editing sites-available/funday, you MUST:

sudo cp /etc/nginx/sites-available/funday /etc/nginx/sites-enabled/funday
sudo nginx -t && sudo systemctl reload nginx

Verify with: sudo nginx -T 2>/dev/null | grep terminal-ws

Troubleshooting#

SymptomCauseFix
502 Bad GatewayNginx stale file or bridge downcp sites-available → sites-enabled, restart bridge
“Disconnected” in status barWS can’t reach bridgeCheck: systemctl is-active funday-terminal
Blank terminal@wterm/dom not in buildRebuild: bash scripts/build-atomic.sh
Connection rejected (1013)Max connections (default 4)Wait or increase TERMINAL_MAX_CONN
No shell outputPTY not createdSend {"type":"create","cols":80,"rows":24}

Gotchas#

  • class: directive + Tailwind / — Svelte parser treats / as division. Use inline ternary: class="bg-{status === 'ok' ? 'success' : 'error'}"
  • Dynamic @wterm/dom import — must be await import() in onMount, never static (crashes SSR)
  • wterm.destroy() — call in onDestroy or WASM leaks
  • nginx reload ≠ pick up edits if sites-enabled/funday is stale — always cp from sites-available/