Serving Gopherspace from a Rust Backend
Building a Gopher server that serves RFC 1436 content through a modern API layer—because sometimes you want to browse the internet like it's 1991.
On this page
I wanted the emulator’s BBS to feel like an information system, not just a chat board. That meant adding a way to browse structured documents—menus, text files, hierarchies of knowledge. I could have faked it with JSON and custom rendering. Instead, I reached back to 1991 and pulled in Gopher.
The constraint that made Gopher appealing was also the constraint that made it simple: Gopher is a menu tree, not a web of links. A client asks for a selector; the server returns either a menu or a text file. There’s no hypertext, no embedded images, no stylesheets. You pick an item from a numbered list and walk down the tree. That simplicity maps cleanly onto a VMS-style terminal interface, which is exactly what I wanted for the emulator’s phone-line experience.
The Shape of the System
The implementation splits into three pieces:
- Rust backend — serves gophermap files and text content from a filesystem directory
- TypeScript client — parses RFC 1436 format and renders a VMS-style menu interface
- Content tree — a real filesystem with real gophermaps at each level
The API surface is intentionally 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, just like a real Gopher client fetching from a real Gopher server. The browser can’t open TCP sockets to port 70, so HTTP is the practical bridge—but 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
}
The rules are simple: selectors must start with /, can’t be empty, and can’t contain ... But string validation alone isn’t enough—symlinks and filesystem quirks can still provide escape routes. So the handler also canonicalizes the resolved path and verifies 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(),
}
This layered approach—validate the string, then verify the resolved path—is the defense-in-depth pattern that makes path traversal attacks harder to slip through. The string check catches the obvious attacks; the canonicalization check catches the clever ones.
Gophermap Format
The content tree is a real filesystem with a real gophermap at each level. The root menu looks like this:
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 are non-selectable text—they render but you can’t navigate into them.
The frontend parses this format, filters out the informational lines to build a numbered selection list, and presents a small command surface: OPEN #, BACK, DIR, HELP, EXIT. It’s a menu system, not a hyperlink system, and the UI stays honest to that constraint.
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/. The numbered selection echoes how you’d navigate a menu system on a VAX terminal. The whole thing runs on phone line 555-0710, accessible 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
This gives me a standards-compliant Gopher server to test against—I can verify my gophermap files work with any RFC 1436-compliant client, independent of the emulator’s API layer. The content root defaults to ./server/gopher-content, with GOPHER_CONTENT_PATH available when you want to mount it elsewhere.
Where This Fits
The Gopher layer was about two days of work, but it added something I hadn’t expected: a model for how information services felt before the web. You navigate by choosing from a list. You go deeper or you go back. There’s no “related links” sidebar, no algorithmic suggestions, no infinite scroll. Just a tree of documents and the discipline to organize them well.
The emulator already had modems, terminals, and BBS backends. Adding Gopher gave it a different texture—less social, more archival. The same infrastructure that lets you chat in a message board now lets you browse a structured knowledge base. And because the content is just files on disk, updating the gopherspace is as simple as editing text.
The experience isn’t nostalgia for nostalgia’s sake. It’s a reminder that simplicity in protocol design creates room for personality in presentation. The constraint was the gift.