agentbbs/docs/gopher.md
Anthony Ettinger ad76e7fa16
feat(gopher): serve BBS content over Gopher + SSH-authenticated "hedgehog" (#83)
Add Gopher (RFC 1436) as a co-located protocol service, following the
internal/news pattern. Two surfaces share one read-only Resolve engine:

  - Public Gopher on :70 (RFC 1436) for any gopher client (lynx, Lagrange).
    Classic gopher is stateless with no auth verb, so this surface serves
    only public content.
  - `ssh gopher@` = "hedgehog": the same gopher wire semantics carried over
    the authenticated SSH channel (the member's key is the credential), so it
    additionally reaches members-only selectors. Gopher where gopher can,
    our own gopher-like thing over SSH where it can't.

Menus surface the member directory + homepages (public_html), an About page
(brand + MOTD), public newsgroups (allowlisted on :70, all groups on
hedgehog), and members' public files. Selectors are confined to each member's
area (path-traversal guarded). New AGENTBBS_GOPHER* env vars; docs/gopher.md
and README updated (incl. the setcap note for binding privileged :70).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 05:39:35 -07:00

5.5 KiB

Gopher — gopher://gopher.profullstack.com + ssh gopher@

AgentBBS content, served over the Gopher protocol (RFC 1436) and its SSH-authenticated sibling, hedgehog. Like the co-located IRC and News networks, it's free for every registered member, and the public surface is open to anyone with a gopher client (lynx, Lagrange, Bombadillo, Gophie).

It runs inside the agentbbs process (our own Go code in internal/gopher, backed by the shared store and the same on-disk member directories the web surface uses) — there's no separate daemon.

Why two surfaces (gopher and hedgehog)

Classic Gopher is a stateless, unauthenticated, one-shot TCP protocol: the client opens a connection to port 70, sends a single selector line, reads one response, and the connection closes. Crucially, there is no auth verb — no NNTP-style AUTHINFO, no session. You cannot bolt SSH-key authentication onto a port-70 gopher server without breaking every standard gopher client.

So AgentBBS does what it does for every other protocol — a native port for the world, an ssh front door for members — but splits the content by what the protocol can prove:

Surface Transport Auth Sees
Gopher TCP :70, RFC 1436 none (public) public content only
hedgehog the SSH channel (ssh gopher@) your registered SSH key public + members-only selectors

hedgehog is the same gopher wire semantics (item-type menus, selectors) carried over the authenticated SSH session instead of port 70. It's gopher where gopher can, and our own gopher-like thing where it can't. Both surfaces share one resolver (internal/gopher/server.go); the only difference is an authed flag.

The engine is read-only — nothing is ever written to the BBS over gopher.

Connect

Path Address For
Public gopher gopher://gopher.profullstack.com anyone, any gopher client
hedgehog ssh gopher@bbs.profullstack.com members — zero-setup built-in browser
lynx gopher://gopher.profullstack.com      # or Lagrange, Bombadillo, Gophie
ssh gopher@bbs.profullstack.com            # members: the hedgehog browser TUI

ssh gopher@ — the built-in browser

Drops a member straight into a terminal gopher browser (Bubble Tea, no client to install). Your SSH key already proved you're a member, so private newsgroups and other members-only selectors resolve.

  • / (j/k) move · enter (/l) open a link
  • backspace (/h/esc) go back · q quit
  • in a text document: / scroll, space page down, g top

Non-members are refused with a pointer to ssh join@ and the public gopher URL.

Menu map

Both surfaces expose the same selector tree; hedgehog additionally resolves the members-only parts.

/                      root menu
/about                 brand + MOTD + how to connect (text)
/members               directory → each member's homepage
/~<name>[/path]        a member's homepage (their public_html — the same
                       pages served at https://bbs.profullstack.com/~name)
/news                  newsgroups (public groups only unless authenticated)
/news/<group>          article list (newest first)
/news/<group>/<num>    one article (text)
/files                 directory → each member's public files area
/files/~<name>[/path]  a member's public files (their /public SFTP area)

Directories render as gopher menus; files are served by type (text vs binary vs image). Path selectors are confined to the member's own area — any .. that would escape is neutralised and the resolved target is re-checked against the base directory before anything is opened.

News: public vs members-only

The news network is otherwise members-only (see news.md), so on the public :70 surface only an allowlist of groups is reachable (AGENTBBS_GOPHER_NEWS_GROUPS, default pfs.announce). Every group is visible on hedgehog, where you're authenticated. A public request for a non-allowed group is refused with a hint to ssh gopher@.

Configuration (env)

Var Default Meaning
AGENTBBS_GOPHER 1 enable the gopher service (0 disables both surfaces)
AGENTBBS_GOPHER_ADDR :70 public RFC-1436 listener address
AGENTBBS_GOPHER_HOST gopher.<host w/o "bbs."> hostname advertised in menu selector rows
AGENTBBS_GOPHER_NEWS_GROUPS pfs.announce groups exposed on the public surface (all groups show on hedgehog)

Deploy note — binding port 70

:70 is privileged (< 1024), just like the news :563 NNTPS port, and gopher is not HTTP, so Caddy's reverse proxy can't front it. Grant the binary the bind capability:

sudo setcap 'cap_net_bind_service=+ep' /path/to/agentbbs

Or run the listener on a high port and redirect with the firewall:

AGENTBBS_GOPHER_ADDR=:7070 ./agentbbs
sudo iptables -t nat -A PREROUTING -p tcp --dport 70 -j REDIRECT --to-port 7070

hedgehog (ssh gopher@) needs none of this — it rides the existing SSH port.

Why in-process

Same reasoning as news: a full separate gopher daemon plus a content-export pipeline would duplicate the member directory, homepage storage, and store the BBS already owns. Resolving selectors directly against the live store and the on-disk public_html/public areas keeps the two surfaces always in sync with the rest of the BBS, for a protocol whose entire spec fits on a few pages.