---
title: 'BBS Web'
url: 'https://wiki.teletypegames.org/services/bbsweb'
markdown: 'https://wiki.teletypegames.org/services/bbsweb.md'
lang: en
date: '2026-09-16'
description: 'bbsweb puts our telnet BBSes on the web without changing a line of them. The dialer screen lists the systems, and each one opens in a real terminal in the browser. Live at bbsweb.teletypegames.org Overview Our BBSes speak plain telnet on ports 2323–2327, which needs a telnet client. bbsweb adds a…'
taxonomy:
  tag:
    - howto
---

# BBS Web

> bbsweb puts our telnet BBSes on the web without changing a line of them. The dialer screen lists the systems, and each one opens in a real terminal in the browser.
> 
> **Live at [bbsweb.teletypegames.org](https://bbsweb.teletypegames.org)**

# Overview

Our BBSes speak plain telnet on ports 2323–2327, which needs a telnet client. bbsweb adds a web front end in front of them: a Node server bridges WebSocket to TCP, and the browser runs [xterm.js](https://xtermjs.org/).

It is a generic product, not a Teletype installation. A single `config.json` describes which systems appear, under what names, in which languages and with what styling — the application itself contains no site-specific text, colour or address. Adding a foreign BBS is one block of JSON.

```
browser                 nginx (TLS)              bbsweb container      BBSes
┌────────────┐  wss://  ┌──────────────┐  http   ┌────────────────┐ TCP ┌────────┐
│ xterm.js   ├─────────►│ proxy /ws    ├────────►│ WS↔telnet brdg │────►│ :2323  │
│ 256 colour │          │ proxy /      │         │ + static web   │     │ :2324  │
│ UTF-8, mouse          └──────────────┘         └────────────────┘     │ :2325  │
└────────────┘                                                          │ :2326  │
                                                                        │ :2327  │
                                                                        └────────┘
```

# What it carries

The BBSes send full-screen ANSI with 256 colours, UTF-8 box drawing and SGR mouse reporting. All of that survives the trip:

- **Clickable menus** — the mouse reports go through, so the menu bar works exactly as in a terminal.
- **Terminal size** — the browser sends `NAWS` on every resize, so the BBS knows how many columns to draw. Two modes: a fixed 80×25 that scales the font to the window, and a fitted mode that gives the BBS the whole window.
- **English / Hungarian interface**, English by default, the choice kept in the browser. Server-side status messages travel as keys, not sentences, so a running session switches language too.
- **Full screen** — the icon at the right end of the top bar shows the terminal alone. In Chromium browsers Esc still reaches the BBS (hold it to leave full screen); elsewhere Esc leaves full screen.
- **Touch key bar** — ESC / TAB / CTRL / arrows on phones and tablets.
- **File downloads (ZMODEM)** — when a BBS sends a file by ZMODEM (the WarpEngine BBS does), the page receives it with [zmodem.js](https://github.com/FGasper/zmodem.js) and the browser saves it; the status line shows the progress, Esc cancels.

The five systems on the dialer are the boards of our [BBS network](https://wiki.teletypegames.org/services/bbs-network): [Teletype BBS](https://wiki.teletypegames.org/services/teletype-bbs), [Impostor BBS](https://wiki.teletypegames.org/services/impostor-bbs), the [TTG Wiki BBS](https://wiki.teletypegames.org/services/wiki-bbs), [Alexandria BBS](https://wiki.teletypegames.org/services/alexandria-bbs) and [Feedia BBS](https://wiki.teletypegames.org/services/feedia-bbs). Every one of them is still reachable over plain telnet at the address shown on its card, and each has the C64 switch (`petscii: true`) for its PETSCII face.

# Configuration

`config/config.json` sits next to the compose file (the `config` folder is mounted into the container) and covers the whole installation:

| Group | What it controls |
|---|---|
| `site` | name, tagline, ASCII logo, footer, favicon, accent colour |
| `languages`, `defaultLanguage` | the switcher's buttons and the starting language |
| `terminal` | size, font, theme, cursor, scrollback, starting view mode |
| `server` | session limits, timeouts, ping, the `TTYPE` answer |
| `boards` | the systems, each with its own colour and size |

Every key is optional — what is missing falls back to a built-in default. Any text field may be a per-language object: `"name": { "en": "…", "hu": "…" }`.

The server watches the file's modification time, so **an edit needs no restart** — reloading the page is enough. If the JSON is broken, the last good configuration stays in effect and the error goes to the log.

The full reference is in the repo's [README](https://git.teletypegames.org/services/bbs-web).

# Why it is not an open proxy

The browser only ever sends an `id` (`/ws?id=teletype`). The actual host and port never come from the client — they come from `config.json`, and they do not appear in the public API at all. Without this the bridge would forward to any TCP address on request.

# Telnet details worth knowing

The bridge handles IAC negotiation (`ECHO`, `SGA`, `BINARY`, `TTYPE`, `NAWS`) and refuses everything else politely. Two things matter in practice:

- **A lone `CR` is not a line ending.** xterm.js sends only `CR` on `Enter`, while the BBS waits for a line ending, so in NVT ASCII mode the bridge appends `LF` the way a real telnet client does. Without it the login screen never advances — and the symptom is deceptive, because the typed name appears on screen normally.
- **nginx drops silent connections.** A BBS session stays quiet for minutes while someone reads, and the default `proxy_read_timeout` of 60s would cut it off. The vhost uses 3600s, and the server pings over WebSocket every 30 seconds so intermediate NATs keep the connection too.

# Deployment

A push to `master` deploys from Woodpecker when it changes `compose.yaml`, `bbsweb/` or `config/config.json`. The pipeline first checks the scripts (`node --check`), the JSON files and `compose.yaml`; the deploy step then only asks the server to deploy over SSH. Its key (secret `deploy_ssh_key`) is bound on the server to one fixed command, `ci-deploy bbs-web` ([host-config](https://git.teletypegames.org/infra/host-config)), which clones master from Gitea itself, checks `compose.yaml` against a policy (no privileged mode, no extra capabilities, no docker socket, bind mounts only inside the stack, no public port), copies those paths into `/srv/stacks/bbs-web` and runs `docker compose up -d --build`.

A config-only change needs no rebuild or restart: the deploy replaces `config/config.json`, and because the whole folder is mounted, the server sees the new file and reloads it by its mtime. (A single-file mount would keep showing the old file after the replacement.)

# Operating it

```bash
cd /srv/stacks/bbs-web
docker compose up -d --build     # start by hand (normally the pipeline does it)
docker compose logs -f bbsweb    # connection log (IP -> BBS)
curl -s localhost:9140/api/health
```

The container listens on `127.0.0.1:9140`; TLS and the domain are nginx's job. The vhost is `/etc/nginx/sites-available/bbsweb.teletypegames.org`, the certificate is handled by certbot.

One limitation: the BBS sees every web visitor as coming from the bridge container's IP, because telnet has no `X-Forwarded-For`. The real client IP is in the bbsweb log.

## Repository

https://git.teletypegames.org/services/bbs-web

'; var prevLevel = 2; for (var i = 0; i < headings.length; i++) { var h = headings\[i\]; var text = h.textContent.trim(); var slug = text.toLowerCase() .replace(/\[áàâä\]/g,'a').replace(/\[éèêë\]/g,'e').replace(/\[íìîï\]/g,'i') .replace(/\[óòôöő\]/g,'o').replace(/\[úùûüű\]/g,'u') .replace(/\[^a-z0-9\]+/g, '-').replace(/^-|-$/g, ''); if (!slug) slug = 'heading-' + i; if (used\[slug\]) { slug += '-' + i; } used\[slug\] = true; h.id = slug; var level = parseInt(h.tagName.charAt(1)); var cls = level > 2 ? ' class="toc-child"' : ''; html += '- [' + text + '](#'%20+%20slug%20+%20')
'; } html += '
'; toc.innerHTML = html; // Scroll-spy: highlight current heading var links = toc.querySelectorAll('a'); var ids = \[\]; for (var j = 0; j < links.length; j++) ids.push(links\[j\].getAttribute('href').slice(1)); function updateActive() { var current = ''; for (var k = 0; k < ids.length; k++) { var el = document.getElementById(ids\[k\]); if (el && el.getBoundingClientRect().top <= 80) current = ids\[k\]; } for (var m = 0; m < links.length; m++) { links\[m\].parentNode.classList.toggle('active', links\[m\].getAttribute('href') === '#' + current); } } window.addEventListener('scroll', updateActive, { passive: true }); updateActive(); })();

---

## Navigation

- Parent: [Services](https://wiki.teletypegames.org/services.md)
- Previous: [BBS Network](https://wiki.teletypegames.org/services/bbs-network.md)
- Next: [Commodex BBS](https://wiki.teletypegames.org/services/commodex-bbs.md)
