The userTree function confined selectors with path cleaning and HasPrefix
checks, but called os.Stat/os.ReadFile on the unresolved path. A symlink
created inside a member's public area pointing outside would pass the
lexical checks and leak external content.
Fix: resolve symlinks with filepath.EvalSymlinks after the path check
and reject the request if the real path escapes the member's area.
Adds TestSymlinkEscapeRefused to verify both rejection of external
symlinks and acceptance of symlinks within the member area.
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>