Serving Gopherspace from a Rust Backend
Building a Gopher server that serves RFC 1436 content through a modern API layer, so the emulator's BBS has something to browse.
On this page
I wanted the emulator’s BBS to feel like an information system rather than a chat board — menus, text files, a hierarchy you could walk. I could have faked that with JSON and custom rendering. Instead I reached back to 1991 and pulled in Gopher.
What made Gopher appealing is that it’s a menu tree, not a web of links. A client asks for a selector; the server returns a menu or a text file. No hypertext, no embedded images, no stylesheets. You pick an item from a numbered list and walk down the tree, which maps cleanly onto a VMS-style terminal.
The Shape of the System
Three pieces:
- Rust backend — serves gophermap files and text content from a filesystem directory
- TypeScript client — parses RFC 1436 and renders a VMS-style menu interface
- Content tree — a real filesystem with real gophermaps at each level
The API surface is small:
GET /api/gopher/map?selector=/pathreturns a gophermap for a directoryGET /api/gopher/file?selector=/pathreturns a text file
The client fetches menus and files on demand, the same way a real Gopher client would. The browser can’t open TCP sockets to port 70, so HTTP is the bridge; the data format and navigation model stay authentic.
The Selector Constraint
Selectors are Gopher’s version of paths, and they’re where security gets interesting. The validator rejects anything that could escape the content root:
fn validate_selector(selector: &str) -> bool {
if selector.is_empty() { return false; }
if selector.contains("..") { return false; }
if !selector.starts_with('/') { return false; }
true
}
String validation on its own isn’t enough — symlinks and filesystem quirks provide escape routes that don’t contain .. — so the handler also canonicalizes the resolved path and checks it still lives under the content root:
match gophermap_path.canonicalize() {
Ok(canonical) => {
if let Ok(base_canonical) = base.canonicalize() {
if !canonical.starts_with(&base_canonical) {
warn!(selector = %query.selector, "Path traversal attempt blocked");
return (StatusCode::BAD_REQUEST, "Invalid selector").into_response();
}
}
}
Err(_) => return (StatusCode::NOT_FOUND, "Gophermap not found").into_response(),
}
The string check catches the obvious attempts; the canonicalization catches the ones that arrive by way of a symlink.
Gophermap Format
The content tree is a real filesystem with a real gophermap at each level. The root menu:
igopher.emulator.ca - VMS text client fake gopher.emulator.ca 70
0WELCOME_TO_GOPHER /welcome.txt gopher.emulator.ca 70
i fake gopher.emulator.ca 70
iLOCAL RESOURCES: fake gopher.emulator.ca 70
1VAX_VMS_REFERENCE /vms/ gopher.emulator.ca 70
1NETWORK_NODES /net/ gopher.emulator.ca 70
1LOCAL_RESOURCES /local/ gopher.emulator.ca 70
The format is RFC 1436: first character is the item type (i for informational, 0 for text file, 1 for directory), then tab-separated fields for label, selector, host, and port. Lines starting with i render but can’t be navigated into.
The frontend parses that, filters out the informational lines to build a numbered selection list, and offers a small command surface: OPEN #, BACK, DIR, HELP, EXIT.
The VMS Voice
The terminal experience leans into the VMS aesthetic:
========================================
GOPHER INFORMATION SERVICE
NODE: LOCAL TERM: VT100
========================================
Location: GOPHER::LOCAL:[ROOT]
1) WELCOME_TO_GOPHER (TXT)
2) VAX_VMS_REFERENCE (DIR)
3) NETWORK_NODES (DIR)
4) LOCAL_RESOURCES (DIR)
5) SOFTWARE_LIBRARY (DIR)
6) ARCHIVES (DIR)
7) ABOUT_GOPHER.TXT (TXT)
Commands: # OPEN # BACK HELP EXIT
GOPHER>
Paths display as GOPHER::LOCAL:[VMS.HELP] rather than Unix-style /vms/help/, and selection is numbered the way it would be on a VAX terminal. The whole thing answers on phone line 555-0710, through the same modem infrastructure that serves the BBS.
Testing Against a Real Server
For validation I keep a docker-compose.yml in the content directory that spins up a Gophernicus container on port 7070:
services:
gophernicus:
image: joshkaiju/gophernicus:latest
ports:
- "7070:70"
volumes:
- ./:/var/gopher:ro
That gives me a standards-compliant server to check against, so I can confirm the gophermap files work with any RFC 1436 client independent of the emulator’s API layer. The content root defaults to ./server/gopher-content, with GOPHER_CONTENT_PATH available if you want it elsewhere.
Where This Fits
The Gopher layer was about two days of work. The navigation model turned out to shape the experience far more than the protocol does. You choose from a list, you go deeper, or you go back. No related-links sidebar, no suggestions, no infinite scroll. A tree of documents is only as good as the effort spent organizing it, and that effort is visible in a way it usually isn’t on the web.
The emulator already had modems, terminals, and BBS backends. Gopher gave it a different texture: less social, more archival. And because the content is files on disk, updating gopherspace means editing text.