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
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.
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 │
└────────┘
The BBSes send full-screen ANSI with 256 colours, UTF-8 box drawing and SGR mouse reporting. All of that survives the trip:
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.The five systems on the dialer are the boards of our BBS network: Teletype BBS, Impostor BBS, the TTG Wiki BBS, Alexandria BBS and 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.
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.
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.
The bridge handles IAC negotiation (ECHO, SGA, BINARY, TTYPE, NAWS) and refuses everything else politely. Two things matter in practice:
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.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.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), 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.)
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.
https://git.teletypegames.org/services/bbs-web