Back to Articles
2026 / 02
| 6 min read

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.

emulator gopher rust networking retro

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 file
  • 1 — Directory (submenu)
  • i — Informational text (not selectable)
  • 7 — Search query
  • h — 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:

  1. Deployment friction: Changing content meant redeploying the app
  2. Bundle size: The gophermap files aren’t huge, but they add up
  3. 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 gophermap
  • GET /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:

  1. Navigate to Gopher backend
  2. Verify root menu renders
  3. Select a submenu
  4. Verify navigation works
  5. Select a text file
  6. 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.