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>
This commit is contained in:
Anthony Ettinger 2026-07-07 05:39:35 -07:00 committed by GitHub
parent 9d0c0a0193
commit ad76e7fa16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1346 additions and 3 deletions

View file

@ -68,6 +68,7 @@ import (
"github.com/profullstack/agentbbs/internal/files"
"github.com/profullstack/agentbbs/internal/forgejo"
"github.com/profullstack/agentbbs/internal/games"
"github.com/profullstack/agentbbs/internal/gopher"
"github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/ircpass"
"github.com/profullstack/agentbbs/internal/mail"
@ -126,8 +127,9 @@ type app struct {
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
dataDir string
assets string
host string // public hostname used in user-facing messages
newsAddr string // loopback NNTP address the news@ reader dials
host string // public hostname used in user-facing messages
newsAddr string // loopback NNTP address the news@ reader dials
gopher *gopher.Server // gopher/hedgehog engine (nil when AGENTBBS_GOPHER=0)
}
// Version is the agentbbs stack release, surfaced via `agentbbs version` and
@ -314,6 +316,33 @@ func main() {
}
}
// Gopher (RFC 1436) + hedgehog: BBS content over gopher. The public listener
// on :70 serves open content (about, member homepages, public groups, public
// files); `ssh gopher@` runs the SSH-authenticated "hedgehog" browser that
// also reaches members-only selectors. See docs/gopher.md. Disable with
// AGENTBBS_GOPHER=0. (:70 is privileged — the operator grants the bind
// capability, e.g. setcap cap_net_bind_service, or maps a high port.)
if env("AGENTBBS_GOPHER", "1") == "1" {
gopherAddr := env("AGENTBBS_GOPHER_ADDR", ":70")
gopherHost := env("AGENTBBS_GOPHER_HOST", "gopher."+strings.TrimPrefix(host, "bbs."))
port := gopherAddr[strings.LastIndex(gopherAddr, ":")+1:]
pubGroups := news.ParseGroups(os.Getenv("AGENTBBS_GOPHER_NEWS_GROUPS"))
if len(pubGroups) == 0 {
pubGroups = []news.GroupSpec{{Name: "pfs.announce"}}
}
names := make([]string, len(pubGroups))
for i, g := range pubGroups {
names[i] = g.Name
}
a.gopher = gopher.New(st, dataDir, gopherHost, port, names, a.gopherAbout)
go func() {
log.Info("gopher listening", "addr", gopherAddr, "host", gopherHost)
if err := a.gopher.Serve(context.Background(), gopherAddr); err != nil {
log.Error("gopher listener", "err", err, "hint", "port 70 is privileged; set AGENTBBS_GOPHER_ADDR to a high port or grant cap_net_bind_service")
}
}()
}
addr := env("AGENTBBS_ADDR", ":2222")
opts := []ssh.Option{
wish.WithAddress(addr),
@ -389,6 +418,8 @@ func (a *app) router() wish.Middleware {
a.handleTorCmd(s)
case auth.IsNewsName(user):
a.handleNews(s)
case auth.IsGopherName(user):
a.handleGopher(s)
case auth.IsMailName(user):
a.handleMail(s)
case auth.IsFilesAdminName(user):
@ -1488,6 +1519,60 @@ func (a *app) runNews(s ssh.Session, name string) error {
return news.RunReader(s, addr, name)
}
// handleGopher drops a member into "hedgehog": the SSH-authenticated gopher
// browser (internal/gopher). The SSH key is the credential, so the member can
// browse members-only selectors the public :70 listener never serves. Free for
// any registered member, like news@; needs a PTY.
func (a *app) handleGopher(s ssh.Session) {
if a.gopher == nil {
wish.Println(s, "the gopher service is disabled on this server.")
_ = s.Exit(1)
return
}
fp := auth.Fingerprint(s.PublicKey())
if fp == "" {
wish.Println(s, "gopher@ needs your registered SSH key. New here? ssh join@"+a.host)
_ = s.Exit(1)
return
}
u, found, err := a.st.UserByFingerprint(fp)
if err != nil || !found {
wish.Println(s, "hedgehog is members-only — register first: ssh join@"+a.host+
"\n(the public gopher server is at gopher://gopher."+strings.TrimPrefix(a.host, "bbs.")+")")
_ = s.Exit(1)
return
}
if u.Banned {
wish.Println(s, "this account is suspended.")
_ = s.Exit(1)
return
}
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "gopher")
defer func() { _ = a.st.EndSession(sessID) }()
if err := gopher.RunBrowser(s, a.gopher, u.Name); err != nil {
wish.Println(s, "gopher: "+err.Error())
_ = s.Exit(1)
}
}
// gopherAbout builds the body of the gopher /about page: the brand mark, the
// current shared MOTD, and how to connect. Passed to gopher.New so the page
// stays in sync with the live MOTD.
func (a *app) gopherAbout() string {
var b strings.Builder
b.WriteString(brand.Logo() + "\n\n")
b.WriteString("AgentBBS — a terminal BBS for humans & AI agents.\n\n")
b.WriteString("Connect:\n")
b.WriteString(" ssh " + strings.TrimPrefix(a.host, "bbs.") + " register & explore\n")
b.WriteString(" ssh gopher@" + a.host + " members-only gopher (hedgehog)\n")
b.WriteString(" gopher://gopher." + strings.TrimPrefix(a.host, "bbs.") + " this public server\n")
if m := motd.Current(); m != "" {
b.WriteString("\n" + m + "\n")
}
return b.String()
}
// mailAddress is a member's email address, e.g. alice@bbs.profullstack.com.
func (a *app) mailAddress(name string) string { return name + "@" + a.mailDomain }