Skip to content

Repository files navigation

Terminal Chat - Web Client

Web client for Terminal Chat, served under /terminal-chat-web/. A login form (name, server IP, port) followed by a small, centered terminal environment that connects to whatever Terminal Chat server the user specifies — just like client.js, but in the browser.

Architecture

Browser (WebSocket) → nginx (/terminal-chat-web) → bridge (Node, zero-dep) → user-chosen chat server (TLS)

The browser can't connect directly to a raw TLS socket, so bridge/ws-bridge.js builds a bridge: it forwards each line of the server protocol verbatim as a WS text frame, and vice versa. The destination (IP/port) is specified in the WS connection's query string (since each user may want to connect to a different server), not a fixed env var. It has no understanding of what the messages mean, and no changes to server.js are required.

⚠️ Security note: this means the bridge is effectively an open relay (it accepts any IP:port the user provides) — exactly the same capability the terminal client itself has. If exposed publicly without restriction, it could be abused for port-scanning or SSRF. No restriction has been added yet.

The real server protocol is line-based (newline-delimited), a mix of plain text (broadcast, command replies) and raw JSON (key_announce, pm). The logic for identifying each line's type lives in src/types.ts (classifyLine).

E2E encryption for private messages

/pm <ID> <text> works exactly like client.js:

  • Encrypted client-side (not server-side) with AES-256-GCM
  • A random 32-byte session key is broadcast in the handshake (key_announce)
  • Every PM carries the sender's key along with it (for late joiners)

The encryption format is tested and compatible with client.js (which uses Node's crypto.createCipheriv): src/crypto.ts uses Web Crypto (SubtleCrypto) but manually splits/rejoins the auth tag so the output envelope ({iv, tag, data}, hex) exactly matches client.js's format.

Current status — what's actually been tested

All of the following was tested against the real server.js and client.js (not mocks):

  • ✅ Login form: validation of the three required fields (name/IP/port)
  • ✅ Handshake, public broadcast, commands (/help, /users, /admin, /ban, ...), PING/PONG
  • ✅ Multiple simultaneous users (broadcast between two web clients at once)
  • Two-way encrypted PM: from a real client.js to the web client, and back — with real AES-256-GCM, not a simulation
  • ✅ Bridge: destination selection from the query string (not just a fixed env)
  • ✅ Subpath routing: /terminal-chat-web/ is served correctly via nginx's alias (static files and script paths verified)
  • ✅ Full end-to-end on the actual built bundle (not the TS source): from filling the login form to receiving Welcome ... from the real server, run and verified in a real DOM environment

⚠️ Still outstanding:

  • Long-running live PING/PONG testing (the logic is written and unit tested, but the server pings every 30s and this hasn't been tested over more than a few minutes)
  • Security restriction on the bridge (it's still an open relay to any IP/port)

Development

npm install
npm run dev        # dev server at http://localhost:5173/terminal-chat-web/

For full testing you need an instance of server.js (+ server.key/server.crt) and the bridge:

# Terminal 1: the main chat server (from the main repo)
node server.js 1599

# Terminal 2: the bridge
cd bridge
WS_PORT=8443 node ws-bridge.js
# CHAT_BACKEND_HOST/CHAT_BACKEND_PORT are also supported as a fallback,
# but normally the user specifies the destination via the login form

# Terminal 3: the web client itself
npm run dev

Testing

npm test              # unit tests (vitest + jsdom): Terminal, ChatConnection, classifyLine, LoginForm, crypto (including a real client.js compatibility test)
npm run test:bridge   # integration test for the bridge (with a fake TLS echo server, no real server.js required)

Build

npm run build          # output in dist/, all paths under /terminal-chat-web/

Running with Docker

docker compose up --build

Two services come up:

  • web at http://localhost:8080/terminal-chat-web/ (nginx + static files)
  • bridge, which connects to whatever destination the user enters in the login form (fallback: host.docker.internal:1599)

Structure

src/
  main.ts        Wires up LoginForm → Terminal → ChatConnection, handshake, /pm, PING/PONG
  login.ts        Login form (name/IP/port) with validation of the three required fields
  terminal.ts     Renders output, manages input, command history (↑/↓)
  websocket.ts    Raw WS connection (line by line) with automatic reconnect
  crypto.ts       AES-256-GCM encryption compatible with client.js (Web Crypto)
  types.ts        classifyLine() - identifies each line's type per the real server protocol
  style.css       Terminal look (small, centered) + login form
  __tests__/      Unit tests (vitest)
bridge/
  ws-bridge.js         WS ↔ TLS bridge, destination from query string, zero-dependency
  Dockerfile
  __tests__/           Integration tests (node:test)
Dockerfile          Build + serve static files with nginx (base path /terminal-chat-web/)
nginx.conf.template  alias for /terminal-chat-web/ + proxies /terminal-chat-web/ws to the bridge
docker-compose.yml   Two services: web and bridge
vite.config.ts       base: '/terminal-chat-web/'

Permanent deployment (production, IP-only)

Since there's no domain, instead of assuming HTTP, nginx now serves over HTTPS with a self-signed TLS certificate (port 443; port 80 just redirects to 443). This is necessary because the Web Crypto API (PM encryption) is completely disabled in browsers without HTTPS or localhost.

Step 1: bring it up with automatic restart

cd terminal-chat-web
docker compose up -d --build

restart: unless-stopped is set in docker-compose.yml, meaning the containers automatically come back up after a crash or after the docker service restarts (e.g. after a server reboot) — as long as the docker service itself is enabled on the system:

sudo systemctl enable docker
sudo systemctl status docker   # should show "enabled" and "active"

On most Ubuntu/Debian installs this is already enabled; the command above is just to make sure.

Step 2: open the firewall ports (if ufw is enabled)

sudo ufw allow 8080/tcp
sudo ufw allow 8443/tcp

⚠️ If you ever change the host-mapped HTTPS port again (the left-hand side of the "8443:443" line in docker-compose.yml), also update PUBLIC_HTTPS_PORT in the same file's web service to match — otherwise the automatic HTTP→HTTPS redirect will send the browser to the wrong port.

Step 3: test it

Open your browser at:

https://<server-IP>:8443/terminal-chat-web/

The browser will show a "Connection is not private" warning — this is expected since it's a self-signed certificate (the same trust model server.js itself uses with rejectUnauthorized: false). Click Advanced → Proceed; after that it works normally.

In the login form, for "Server IP" you should enter host.docker.internal (since server.js runs on the host itself, not inside Docker) — same as before.

Checking status / logs

docker compose ps
docker compose logs -f bridge
docker compose logs -f web

Updating after code changes

git pull   # or whatever method you use to update the files
docker compose up -d --build

⚠️ Since the TLS certificate is generated at build time, every time you run --build a new certificate is generated and the browser will show the "not private" warning again (you'll need to click Proceed again). If you'd rather this not happen, we could keep the certificate in a Docker volume outside the build so it stays stable across builds — let me know if this matters to you.

Security note (reminder)

The bridge is still an open relay (it connects to whatever IP:port the user enters in the login form). With port 443 open to the public internet, this risk becomes more practical (anyone who finds the site could use it for port-scanning). If it stays public, it's recommended to add an allowlist or rate-limit to the bridge later on.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages