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.
Gopher predates the web. It was created at the University of Minnesota in 1991 as a way to organize and retrieve documents across the internet. Where the web is hypertext—documents with embedded links—Gopher is a hierarchy of menus. You navigate by selecting items from a list, and each item is either another menu or a document.
By the time I added Gopher support to the emulator, I’d already built the BBS infrastructure, the modem simulation, and a dozen other backends. Adding a protocol from 1991 should have been simple. It mostly was, with a few interesting wrinkles.
The Gopher Protocol
Gopher is remarkably simple. A client connects to a server on port 70, sends a selector string followed by CRLF, and the server responds with the requested content. That’s it. No headers, no content negotiation, no cookies.
The magic is in the gophermap files. These define menus, and each line has a specific format:
TYPE<TAB>DESCRIPTION<TAB>SELECTOR<TAB>HOST<TAB>PORT<CRLF>
The type is a single character:
0— Text file1— Directory (submenu)i— Informational text (not selectable)7— Search queryh— HTML link (a later addition)
Here’s what a gophermap looks like:
iWelcome to the BBS Gopherspace fake 70
i fake 70
1About This System /about localhost 70
0User Guide /docs/guide.txt localhost 70
1Games /games localhost 70
i fake 70
iLast updated: January 2026 fake 70
The i lines are just informational text—they render but aren’t selectable. The 1 lines are directories you can navigate into. The 0 lines are text files you can read.
Why Not Just Serve Static Files?
My first thought was to bundle the Gopher content into the TypeScript frontend, like I’d done with other static content. It worked, but it had problems:
- Deployment friction: Changing content meant redeploying the app
- Bundle size: The gophermap files aren’t huge, but they add up
- Authenticity: Real Gopher servers serve from filesystems
The solution was to move the content to the Rust backend and serve it through API endpoints. The frontend would fetch menus and files on demand, just like a real Gopher client fetching from a real Gopher server.
The Content Directory
I created a server/gopher-content/ directory that mirrors the structure of a gopherspace:
gopher-content/
├── gophermap # Root menu
├── about/
│ └── gophermap # About submenu
├── docs/
│ ├── gophermap # Documentation menu
│ └── guide.txt # A text file
├── games/
│ ├── gophermap
│ └── snake.txt
└── servers/
├── gophermap
└── umn/
└── gophermap # University of Minnesota recreation
Each directory can have a gophermap file that defines its menu. Plain text files are served as-is. The structure maps directly to Gopher selectors: requesting /docs/guide.txt serves the file at gopher-content/docs/guide.txt.
The Rust Handler
The backend exposes two endpoints:
GET /api/gopher/map?selector=/path— Returns a gophermapGET /api/gopher/file?selector=/path— Returns a file
Here’s the core handler logic:
pub async fn handle_gopher_map(
Query(params): Query<GopherParams>,
State(state): State<AppState>,
) -> Result<impl IntoResponse, StatusCode> {
let selector = params.selector.unwrap_or_else(|| "/".to_string());
let validated = validate_selector(&selector)?;
let content_path = state.gopher_content_path.join(&validated[1..]);
let gophermap_path = if content_path.is_dir() {
content_path.join("gophermap")
} else {
content_path
};
let content = tokio::fs::read_to_string(&gophermap_path)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
Ok((
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
content
))
}
The validate_selector function is critical for security:
fn validate_selector(selector: &str) -> Result<String, StatusCode> {
// Must start with /
if !selector.starts_with('/') {
return Err(StatusCode::BAD_REQUEST);
}
// Cannot contain path traversal
if selector.contains("..") {
return Err(StatusCode::BAD_REQUEST);
}
// Canonicalize and verify it's within content directory
// (additional checks in the handler)
Ok(selector.to_string())
}
Path traversal protection is essential. Without it, a request for /../../../etc/passwd would escape the content directory. The handler also uses canonicalize() to resolve symlinks and verify the final path is within bounds.
The Frontend Client
The TypeScript Gopher client fetches content from these endpoints:
export class GopherBrowser {
private async fetchMenu(selector: string): Promise<GophermapEntry[]> {
const response = await fetch(
`/api/gopher/map?selector=${encodeURIComponent(selector)}`
);
if (!response.ok) {
throw new Error(`Failed to fetch menu: ${response.status}`);
}
const text = await response.text();
return this.parseGophermap(text);
}
private parseGophermap(content: string): GophermapEntry[] {
return content.split('\n')
.filter(line => line.length > 0 && line !== '.')
.map(line => {
const type = line[0];
const parts = line.substring(1).split('\t');
return {
type,
description: parts[0] || '',
selector: parts[1] || '',
host: parts[2] || 'localhost',
port: parseInt(parts[3]) || 70
};
});
}
}
The parser handles the RFC 1436 format: type character, then tab-separated fields. Lines ending with just . signal end-of-content (though most modern implementations just close the connection).
Rendering Menus
The Gopher browser renders menus as selectable lists in the terminal:
private renderMenu(entries: GophermapEntry[]): void {
this.terminal.clear();
let itemIndex = 0;
for (const entry of entries) {
if (entry.type === 'i') {
// Informational: just print it
this.terminal.writeLine(` ${entry.description}`);
} else {
// Selectable item: show with index
const prefix = this.getTypePrefix(entry.type);
this.terminal.writeLine(
`${itemIndex}. ${prefix} ${entry.description}`
);
itemIndex++;
}
}
this.terminal.writeLine('');
this.terminal.write('Select item (or B for back): ');
}
private getTypePrefix(type: string): string {
switch (type) {
case '0': return '[TXT]';
case '1': return '[DIR]';
case '7': return '[?] ';
case 'h': return '[WWW]';
default: return '[???]';
}
}
The result looks like a classic Gopher menu:
Welcome to the BBS Gopherspace
0. [DIR] About This System
1. [TXT] User Guide
2. [DIR] Games
Last updated: January 2026
Select item (or B for back): _
External Servers
One of the fun parts of Gopher is that it’s still alive. There are active Gopher servers you can connect to in 2026. I added support for linking to external servers:
1Floodgap Systems / gopher.floodgap.com 70
1SDF Public Access / sdf.org 70
1University of Minnesota (Archive) / gopher.umn.edu 70
When the user selects an external link, the client makes a WebSocket request to the backend, which then makes the actual Gopher connection:
pub async fn proxy_gopher_request(
host: &str,
port: u16,
selector: &str,
) -> Result<Vec<u8>, GopherError> {
let addr = format!("{}:{}", host, port);
let mut stream = TcpStream::connect(&addr).await?;
// Send selector + CRLF
stream.write_all(format!("{}\r\n", selector).as_bytes()).await?;
// Read response
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
Ok(response)
}
This lets users browse real gopherspace from within the emulator. They can visit Floodgap’s modern Gopher server, or explore the University of Minnesota’s archived content from where Gopher was born.
The Content
I populated the gopherspace with content that fits the BBS theme:
Root Menu:
- About the System
- User Documentation
- Games and Entertainment
- Programming Resources
- Other Gopher Servers
University of Minnesota Recreation: A lovingly recreated version of the original UMN gopherspace, including the “About Gopher” document that explains the project’s 1991 origins. It’s a bit of digital archaeology—finding out what the first gopherspace looked like and bringing it back.
NASA Goddard Recreation: Another historical recreation, this one modeled on NASA’s early gopherspace from the mid-90s. Space mission information, astronomical data, and that distinctive government-document formatting.
Docker Configuration
In production, the Gopher content directory needs to be mounted into the container:
websocket-service:
image: emulator-server
volumes:
- ./server/gopher-content:/app/gopher-content:ro
environment:
- GOPHER_CONTENT_PATH=/app/gopher-content
The GOPHER_CONTENT_PATH environment variable tells the Rust server where to find content. This separation means content can be updated by modifying files on disk—no container rebuild required.
Testing
I added both unit tests and E2E tests for the Gopher system:
Unit tests verify the gophermap parser handles edge cases:
- Empty lines
- Missing fields
- Invalid type characters
- UTF-8 content
E2E tests use Playwright to verify the full flow:
- Navigate to Gopher backend
- Verify root menu renders
- Select a submenu
- Verify navigation works
- Select a text file
- Verify content displays
There’s also a Docker Compose file for testing against a real Gopher server:
services:
gopher:
image: prodhe/gopher
ports:
- "7070:70"
volumes:
- ./gopher-content:/gopher
This runs a standard Gopher server (not my Rust implementation) so I can validate that my content files work with any compliant Gopher client.
Authenticity vs. Practicality
A purist would argue that I should implement the actual Gopher protocol—have the frontend connect to port 70 and speak raw Gopher. But browsers can’t make arbitrary TCP connections, and even if they could, most firewalls block port 70.
The API layer is a practical compromise. The user experience is authentic: menus, selectors, text files, the whole Gopher navigation model. The implementation uses HTTP because that’s what works in browsers. It’s the same philosophy as the modem simulation—we’re recreating the experience, not the exact technical implementation.
What I Learned
RFC 1436 is delightfully simple. The entire Gopher protocol specification fits in a few pages. Compare that to HTTP/2 or HTTP/3. There’s something to be said for protocols you can implement in an afternoon.
Gopherspace is still alive. I expected to find a wasteland of dead servers. Instead, there’s an active community maintaining Gopher servers, creating new content, and keeping the protocol alive. Floodgap’s Gopher server gets real traffic.
Path traversal is scary. Even with multiple layers of validation, I’m paranoid about directory escape attacks. The combination of selector validation, path canonicalization, and directory containment checks is probably overkill, but “probably overkill” is the right level for security.
Menus are underrated. The web’s hypertext model is powerful, but there’s clarity in Gopher’s strict hierarchy. You always know where you are and how you got there. The breadcrumb is implicit in the navigation path.
Adding Gopher to the emulator was a small project—maybe two days of work—but it added a whole dimension to the BBS experience. Users can browse information systems the way people did before the web existed. And occasionally, they can connect to real Gopher servers and discover that this 34-year-old protocol still has something to offer.