From 78eac1f85f4e775e78f9829d30715b5b3139897f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 7 Jul 2026 12:36:51 +0000 Subject: [PATCH] feat(gopher): serve BBS content over Gopher + SSH-authenticated "hedgehog" 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 --- README.md | 5 + cmd/agentbbs/main.go | 89 ++++++- docs/gopher.md | 127 ++++++++++ internal/auth/auth.go | 12 +- internal/gopher/browser.go | 250 ++++++++++++++++++ internal/gopher/gopher.go | 116 +++++++++ internal/gopher/gopher_test.go | 238 +++++++++++++++++ internal/gopher/listener.go | 62 +++++ internal/gopher/server.go | 450 +++++++++++++++++++++++++++++++++ 9 files changed, 1346 insertions(+), 3 deletions(-) create mode 100644 docs/gopher.md create mode 100644 internal/gopher/browser.go create mode 100644 internal/gopher/gopher.go create mode 100644 internal/gopher/gopher_test.go create mode 100644 internal/gopher/listener.go create mode 100644 internal/gopher/server.go diff --git a/README.md b/README.md index 509f18b..c917cd0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ plugins around one shared account system; the full product plan is in | IRC (`irc.bbs.profullstack.com` — Ergo network for humans + agents) | ✅ | | News (`news.profullstack.com` — members-only Usenet/NNTP for humans + agents) | ✅ | | M4 — Files (SFTP: private workspaces + shared public area, mgmt TUI) | ✅ | +| Gopher (`gopher://gopher.profullstack.com` + `ssh gopher@` hedgehog) | ✅ | | M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ | ## Run it @@ -69,6 +70,10 @@ Configuration (env): | `AGENTBBS_GAME_WS_ADDR` | `127.0.0.1:8090` | AgentGames WebSocket endpoint (loopback; Caddy proxies `/play`) | | `AGENTBBS_FILES` | `1` | member SFTP storage subsystem + Files plugin (`0` disables) — see [docs/files.md](docs/files.md) | | `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) | +| `AGENTBBS_GOPHER` | `1` | Gopher (RFC 1436) public server + `ssh gopher@` hedgehog (`0` disables) — see [docs/gopher.md](docs/gopher.md) | +| `AGENTBBS_GOPHER_ADDR` | `:70` | public gopher listener (`:70` is privileged — grant `cap_net_bind_service` or map a high port) | +| `AGENTBBS_GOPHER_HOST` | `gopher.` | hostname advertised in gopher menu selectors | +| `AGENTBBS_GOPHER_NEWS_GROUPS` | `pfs.announce` | newsgroups exposed on the public gopher surface (all show on hedgehog) | Ops: diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 65afca4..81f2b16 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -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 } diff --git a/docs/gopher.md b/docs/gopher.md new file mode 100644 index 0000000..feb2f8d --- /dev/null +++ b/docs/gopher.md @@ -0,0 +1,127 @@ +# 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](irc.md) and +[News](news.md) 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 | + +```bash +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 +/~[/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/ article list (newest first) +/news// one article (text) +/files directory → each member's public files area +/files/~[/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](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.` | 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: + +```bash +sudo setcap 'cap_net_bind_service=+ep' /path/to/agentbbs +``` + +Or run the listener on a high port and redirect with the firewall: + +```bash +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](news.md): 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. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index dbc89e2..9070901 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -70,6 +70,12 @@ var NewsNames = map[string]bool{"news": true} // an interactive TUI with a PTY, or a JSON bot mode with a command/no PTY. var MailNames = map[string]bool{"mail": true} +// GopherNames route a member into "hedgehog": the SSH-authenticated gopher +// browser (see internal/gopher). Free for any registered member, like news@. +// The public, unauthenticated Gopher surface is the port-70 listener, not a +// route here (RFC 1436 has no auth verb — see internal/gopher for the rationale). +var GopherNames = map[string]bool{"gopher": true} + // FilesAdminNames route an operator into the SFTP server management TUI. Members // transfer files via the "sftp" subsystem (sftp files@); this interactive // route is the operator console and is gated by the admin allowlist. @@ -109,6 +115,10 @@ func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] } // IsMailName reports whether the SSH username requests the AgentMail client. func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] } +// IsGopherName reports whether the SSH username requests the hedgehog gopher +// browser (the SSH-authenticated gopher surface). +func IsGopherName(u string) bool { return GopherNames[strings.ToLower(u)] } + // IsFilesAdminName reports whether the SSH username requests the SFTP server // management TUI (operator-gated). func IsFilesAdminName(u string) bool { return FilesAdminNames[strings.ToLower(u)] } @@ -147,7 +157,7 @@ func IsReservedName(name string) bool { n := strings.ToLower(name) if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] || TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] || - MailNames[n] || FilesAdminNames[n] || MsgNames[n] || GameNames[n] || + MailNames[n] || GopherNames[n] || FilesAdminNames[n] || MsgNames[n] || GameNames[n] || PasswdNames[n] || systemReserved[n] { return true } diff --git a/internal/gopher/browser.go b/internal/gopher/browser.go new file mode 100644 index 0000000..ba89fc7 --- /dev/null +++ b/internal/gopher/browser.go @@ -0,0 +1,250 @@ +package gopher + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/ssh" + + "github.com/profullstack/agentbbs/internal/ui" +) + +// RunBrowser drives the hedgehog gopher browser over the SSH session: an +// in-process gopher client that resolves selectors with authed=true (so the +// member sees members-only content their SSH key entitles them to). This is the +// authenticated counterpart to the public RFC-1436 listener — same wire +// semantics, carried over SSH instead of port 70. member is the connecting +// member's name. Requires a PTY. +func RunBrowser(s ssh.Session, srv *Server, member string) error { + ptyReq, winCh, hasPty := s.Pty() + if !hasPty { + _, _ = s.Write([]byte("gopher needs a terminal (ssh -t gopher@)\r\n")) + return nil + } + m := &browser{srv: srv, member: member, width: ptyReq.Window.Width, height: ptyReq.Window.Height} + m.load("") // start at the root menu + p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen()) + go func() { + for w := range winCh { + p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height}) + } + }() + _, err := p.Run() + return err +} + +var ( + gTheme = ui.New(ui.Blue) + gSel = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#0b1020")).Background(ui.Blue) +) + +type browser struct { + srv *Server + member string + + sel string // current selector + isTxt bool // current view is a text document (vs a menu) + items []Item // menu rows (info + links) for the current menu + links []int // indices into items that are selectable + cur int // cursor position within links + lines []string + top int // first visible text line + + stack []string // selector history for back navigation + status string + width int + height int +} + +// load resolves sel on the authenticated surface and swaps the view to it. +func (b *browser) load(sel string) { + r := b.srv.Resolve(sel, true, b.member) + b.sel = sel + b.status = "" + switch r.Kind { + case KindMenu: + b.isTxt = false + b.items = r.Menu.Items + b.links = b.links[:0] + for i, it := range b.items { + if it.Type != TypeInfo { + b.links = append(b.links, i) + } + } + b.cur = 0 + case KindText: + b.isTxt = true + b.lines = strings.Split(strings.ReplaceAll(r.Text, "\r\n", "\n"), "\n") + b.top = 0 + case KindBinary: + b.status = "(binary file — not shown)" + default: // KindError + b.status = r.Text + } +} + +func (b *browser) Init() tea.Cmd { return nil } + +func (b *browser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + b.width, b.height = msg.Width, msg.Height + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return b, tea.Quit + case "up", "k": + if b.isTxt { + if b.top > 0 { + b.top-- + } + } else if b.cur > 0 { + b.cur-- + } + case "down", "j": + if b.isTxt { + if b.top < b.maxTop() { + b.top++ + } + } else if b.cur < len(b.links)-1 { + b.cur++ + } + case "pgdown", " ": + if b.isTxt { + b.top = min(b.top+b.bodyRows(), b.maxTop()) + } + case "pgup": + if b.isTxt { + b.top = max(b.top-b.bodyRows(), 0) + } + case "enter", "right", "l": + if !b.isTxt { + b.open() + } + case "backspace", "left", "h", "esc": + b.back() + case "g", "home": + if b.isTxt { + b.top = 0 + } else { + b.cur = 0 + } + } + } + return b, nil +} + +// open follows the currently selected menu link. +func (b *browser) open() { + if len(b.links) == 0 { + return + } + it := b.items[b.links[b.cur]] + if strings.HasPrefix(it.Selector, "URL:") { + b.status = "external: " + strings.TrimPrefix(it.Selector, "URL:") + return + } + b.stack = append(b.stack, b.sel) + b.load(it.Selector) +} + +// back returns to the previous selector. +func (b *browser) back() { + if len(b.stack) == 0 { + return + } + prev := b.stack[len(b.stack)-1] + b.stack = b.stack[:len(b.stack)-1] + b.load(prev) +} + +func (b *browser) bodyRows() int { + r := b.height - 4 // header + blank + keybar + margin + if r < 1 { + return 1 + } + return r +} + +func (b *browser) maxTop() int { + m := len(b.lines) - b.bodyRows() + if m < 0 { + return 0 + } + return m +} + +func (b *browser) View() string { + var sb strings.Builder + loc := b.sel + if loc == "" { + loc = "/" + } + sb.WriteString(gTheme.Title("gopher://"+trimBBS(b.srv.host)) + ui.Dim.Render(" "+loc) + "\n\n") + + rows := b.bodyRows() + if b.isTxt { + end := min(b.top+rows, len(b.lines)) + for _, ln := range b.lines[b.top:end] { + sb.WriteString(truncate(ln, b.width) + "\n") + } + sb.WriteString("\n" + keyBar("↑/↓ scroll · space page · ⌫ back · q quit")) + return ui.Frame.Render(sb.String()) + } + + // menu view + shown := 0 + for i, it := range b.items { + if shown >= rows { + break + } + shown++ + if it.Type == TypeInfo { + sb.WriteString(ui.Dim.Render(truncate(it.Display, b.width)) + "\n") + continue + } + line := gopherGlyph(it.Type) + " " + it.Display + if b.links[b.cur] == i { + sb.WriteString(gSel.Render("› "+truncate(line, b.width-2)) + "\n") + } else { + sb.WriteString(" " + truncate(line, b.width-2) + "\n") + } + } + if b.status != "" { + sb.WriteString("\n" + ui.Danger.Render(b.status) + "\n") + } + sb.WriteString("\n" + keyBar("↑/↓ move · ⏎ open · ⌫ back · q quit")) + return ui.Frame.Render(sb.String()) +} + +// gopherGlyph is a one-rune hint of a menu item's type. +func gopherGlyph(t byte) string { + switch t { + case TypeMenu: + return "▸" + case TypeText: + return "▪" + case TypeImage: + return "▨" + case TypeHTML: + return "↗" + case TypeBinary: + return "⬦" + default: + return " " + } +} + +func keyBar(s string) string { return ui.Dim.Render(s) } + +func truncate(s string, w int) string { + if w <= 0 || lipgloss.Width(s) <= w { + return s + } + r := []rune(s) + if len(r) > w { + return string(r[:w]) + } + return s +} diff --git a/internal/gopher/gopher.go b/internal/gopher/gopher.go new file mode 100644 index 0000000..cd8bec5 --- /dev/null +++ b/internal/gopher/gopher.go @@ -0,0 +1,116 @@ +// Package gopher serves AgentBBS content over the Gopher protocol (RFC 1436) +// and its SSH-authenticated sibling, "hedgehog". +// +// # Two surfaces, one engine +// +// Every AgentBBS protocol service exposes a native port for external clients +// plus a zero-install `ssh @` TUI for members (see internal/news). Gopher +// follows the same shape — but with a twist forced by the protocol itself: +// +// - Classic Gopher (RFC 1436) is a stateless, unauthenticated, one-shot TCP +// protocol on port 70: the client sends a single selector line and reads one +// response. There is no auth verb (no NNTP-style AUTHINFO), so SSH-key auth +// cannot be bolted onto a port-70 gopher server without breaking every +// gopher client. The public listener (Serve) therefore serves *public* +// content only. +// +// - "hedgehog" is the same gopher wire semantics carried over the SSH channel +// (RunBrowser), with the member's SSH key as the credential. Because the +// session is already authenticated, hedgehog additionally resolves +// members-only selectors (e.g. private newsgroups). It is the home-grown, +// gopher-like, SSH-authenticated surface — gopher where gopher can, our own +// thing where it can't. +// +// Both surfaces share one Resolve engine (server.go); the only difference is the +// authed flag passed in. The engine is read-only: nothing is ever written to the +// BBS over gopher. +package gopher + +import "strings" + +// Gopher item types (RFC 1436 §3.8). Only the handful the BBS emits are named. +const ( + TypeText = '0' // a plain text file + TypeMenu = '1' // a Gopher directory (submenu) + TypeError = '3' // an error / "does not exist" line + TypeBinary = '9' // a generic binary file + TypeHTML = 'h' // an HTML file / URL: link + TypeImage = 'I' // an image + TypeInfo = 'i' // an informational line (not selectable) +) + +// An Item is one gopher directory row. Info rows (TypeInfo) are display-only; +// every other type is a selectable link the client may follow to Selector on +// Host:Port. Keeping menus as structured Items (rather than pre-rendered text) +// lets the hedgehog browser TUI navigate them while the native listener renders +// the exact same rows to the wire. +type Item struct { + Type byte + Display string + Selector string + Host string + Port string +} + +// A Menu accumulates gopher directory rows and renders them in the wire format: +// each row is "\t\t\t\r\n" and the whole +// menu is terminated by a lone "." line (the RFC 1436 last-line dot). +type Menu struct { + host string + port string + Items []Item +} + +// NewMenu starts a menu whose links advertise host:port as the server to dial +// for each selector (mirrored back to clients so link-following works). +func NewMenu(host, port string) *Menu { + return &Menu{host: host, port: port} +} + +// Info appends one or more informational lines (type 'i'), split on newlines so +// callers can pass a multi-line block. +func (m *Menu) Info(text string) *Menu { + for _, line := range strings.Split(text, "\n") { + m.Items = append(m.Items, Item{Type: TypeInfo, Display: line, Host: "error.host", Port: "1"}) + } + return m +} + +// Link appends a selectable row of the given item type pointing at selector on +// this server. +func (m *Menu) Link(itemType byte, display, selector string) *Menu { + m.Items = append(m.Items, Item{Type: itemType, Display: display, Selector: selector, Host: m.host, Port: m.port}) + return m +} + +// URL appends an "URL:" link (type 'h') to an external http(s) address — the +// widely-supported convention for linking off-gopher. +func (m *Menu) URL(display, url string) *Menu { + m.Items = append(m.Items, Item{Type: TypeHTML, Display: display, Selector: "URL:" + url, Host: m.host, Port: m.port}) + return m +} + +// Len reports how many rows have been added (informational + selectable). +func (m *Menu) Len() int { return len(m.Items) } + +// Render returns the full menu including the terminating "." line, CRLF-framed. +func (m *Menu) Render() string { + var b strings.Builder + for _, it := range m.Items { + b.WriteString(renderItem(it)) + } + b.WriteString(".\r\n") + return b.String() +} + +// renderItem formats one gopher directory entry. Tabs and CRs in the display or +// selector would corrupt the tab-delimited framing, so they are stripped. +func renderItem(it Item) string { + return string(it.Type) + clean(it.Display) + "\t" + clean(it.Selector) + "\t" + + it.Host + "\t" + it.Port + "\r\n" +} + +// clean removes the framing-significant bytes (tab, CR, LF) from a field. +func clean(s string) string { + return strings.NewReplacer("\t", " ", "\r", "", "\n", " ").Replace(s) +} diff --git a/internal/gopher/gopher_test.go b/internal/gopher/gopher_test.go new file mode 100644 index 0000000..90a5290 --- /dev/null +++ b/internal/gopher/gopher_test.go @@ -0,0 +1,238 @@ +package gopher + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/profullstack/agentbbs/internal/store" +) + +// fakeContent is an in-memory Content for the resolver tests (no real store). +type fakeContent struct { + users []store.User + groups []store.NewsGroup + articles map[string][]store.NewsArticle +} + +func (f *fakeContent) ListUsers(int) ([]store.User, error) { return f.users, nil } +func (f *fakeContent) NewsGroups() ([]store.NewsGroup, error) { return f.groups, nil } +func (f *fakeContent) NewsArticlesRange(g string, from, to int64) ([]store.NewsArticle, error) { + var out []store.NewsArticle + for _, a := range f.articles[g] { + if a.Num >= from && a.Num <= to { + out = append(out, a) + } + } + return out, nil +} +func (f *fakeContent) NewsArticleByNum(g string, num int64) (store.NewsArticle, bool, error) { + for _, a := range f.articles[g] { + if a.Num == num { + return a, true, nil + } + } + return store.NewsArticle{}, false, nil +} + +// newTestServer wires a Server over a temp data dir with two members (one +// banned), two newsgroups (one public), and a homepage tree for alice. +func newTestServer(t *testing.T) (*Server, string) { + t.Helper() + dataDir := t.TempDir() + // alice's homepage: index.html (text) + assets/logo.png (binary) + subdir. + home := filepath.Join(dataDir, "users", "alice", "public_html") + mustMkdir(t, filepath.Join(home, "assets")) + mustWrite(t, filepath.Join(home, "index.html"), "

hi

") + mustWrite(t, filepath.Join(home, "assets", "logo.png"), "\x89PNGbinary") + // a file OUTSIDE any member area, to prove traversal cannot reach it. + mustWrite(t, filepath.Join(dataDir, "secret.txt"), "TOP SECRET") + + fc := &fakeContent{ + users: []store.User{ + {Name: "alice"}, + {Name: "bob", Banned: true}, + {Name: "carol"}, + }, + groups: []store.NewsGroup{ + {Name: "pfs.announce", Description: "Announcements", High: 1}, + {Name: "pfs.secret", Description: "Members only", High: 1}, + }, + articles: map[string][]store.NewsArticle{ + "pfs.announce": {{Group: "pfs.announce", Num: 1, Subject: "Welcome", From: "sysop", Body: "hello\n.\nworld"}}, + "pfs.secret": {{Group: "pfs.secret", Num: 1, Subject: "Hush", From: "sysop", Body: "shh"}}, + }, + } + srv := New(fc, dataDir, "gopher.test", "70", []string{"pfs.announce"}, + func() string { return "About body here" }) + return srv, dataDir +} + +func mustMkdir(t *testing.T, p string) { + t.Helper() + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } +} +func mustWrite(t *testing.T, p, s string) { + t.Helper() + if err := os.WriteFile(p, []byte(s), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestMenuWireFormat(t *testing.T) { + m := NewMenu("h.test", "70") + m.Info("hi").Link(TypeMenu, "Members", "/members") + got := m.Render() + // Info row is type 'i'; the link row is tab-delimited with host/port; the + // whole thing ends in the RFC 1436 dot line. + if !strings.HasPrefix(got, "ihi\t") { + t.Errorf("info row missing/malformed: %q", got) + } + if !strings.Contains(got, "1Members\t/members\th.test\t70\r\n") { + t.Errorf("link row malformed: %q", got) + } + if !strings.HasSuffix(got, ".\r\n") { + t.Errorf("menu must end with the dot terminator: %q", got) + } +} + +func TestResolveRootBanner(t *testing.T) { + srv, _ := newTestServer(t) + pub := srv.Resolve("", false, "") + if pub.Kind != KindMenu { + t.Fatalf("root should be a menu, got %v", pub.Kind) + } + wire := string(pub.Wire()) + for _, want := range []string{"/about", "/members", "/news", "/files", "ssh gopher@gopher.test"} { + if !strings.Contains(wire, want) { + t.Errorf("public root missing %q\n%s", want, wire) + } + } + authed := string(srv.Resolve("/", true, "alice").Wire()) + if !strings.Contains(authed, "Signed in as alice") { + t.Errorf("authed root should greet the member:\n%s", authed) + } +} + +func TestResolveAbout(t *testing.T) { + srv, _ := newTestServer(t) + r := srv.Resolve("/about", false, "") + if r.Kind != KindText || !strings.Contains(r.Text, "About body here") { + t.Fatalf("about wrong: %+v", r) + } +} + +func TestResolveMembersExcludesBanned(t *testing.T) { + srv, _ := newTestServer(t) + wire := string(srv.Resolve("/members", false, "").Wire()) + if !strings.Contains(wire, "\t/~alice\t") || !strings.Contains(wire, "\t/~carol\t") { + t.Errorf("members should list alice and carol:\n%s", wire) + } + if strings.Contains(wire, "/~bob") { + t.Errorf("banned member bob must not appear:\n%s", wire) + } +} + +func TestHomepageFileAndDir(t *testing.T) { + srv, _ := newTestServer(t) + // directory listing + dir := srv.Resolve("/~alice", false, "") + if dir.Kind != KindMenu { + t.Fatalf("homepage root should be a dir menu, got %v", dir.Kind) + } + dw := string(dir.Wire()) + if !strings.Contains(dw, "/~alice/index.html") || !strings.Contains(dw, "/~alice/assets") { + t.Errorf("dir listing missing entries:\n%s", dw) + } + // text file + txt := srv.Resolve("/~alice/index.html", false, "") + if txt.Kind != KindText || !strings.Contains(txt.Text, "

hi

") { + t.Errorf("index.html should be served as text: %+v", txt) + } + // binary file + bin := srv.Resolve("/~alice/assets/logo.png", false, "") + if bin.Kind != KindBinary || string(bin.Data) != "\x89PNGbinary" { + t.Errorf("png should be binary: %+v", bin) + } +} + +func TestPathTraversalRefused(t *testing.T) { + srv, _ := newTestServer(t) + for _, sel := range []string{ + "/~alice/../../secret.txt", + "/~alice/../../../etc/passwd", + "/files/~alice/../../secret.txt", + "/~alice/assets/../../../secret.txt", + } { + r := srv.Resolve(sel, true, "alice") + if r.Kind == KindText && strings.Contains(r.Text, "TOP SECRET") { + t.Fatalf("traversal %q leaked the secret file", sel) + } + if r.Kind == KindBinary && strings.Contains(string(r.Data), "TOP SECRET") { + t.Fatalf("traversal %q leaked the secret file (binary)", sel) + } + } + // A bad member name is refused outright. + if r := srv.Resolve("/~..", false, ""); r.Kind != KindError { + t.Errorf("bad member name should error, got %v", r.Kind) + } +} + +func TestNewsPublicVsAuthed(t *testing.T) { + srv, _ := newTestServer(t) + + // Public group list shows only the allowlisted group. + pub := string(srv.Resolve("/news", false, "").Wire()) + if !strings.Contains(pub, "/news/pfs.announce") { + t.Errorf("public news should list pfs.announce:\n%s", pub) + } + if strings.Contains(pub, "/news/pfs.secret") { + t.Errorf("public news must hide pfs.secret:\n%s", pub) + } + + // Authed group list shows every group. + authed := string(srv.Resolve("/news", true, "alice").Wire()) + if !strings.Contains(authed, "/news/pfs.secret") { + t.Errorf("authed news should list pfs.secret:\n%s", authed) + } + + // Public access to a members-only group is refused... + if r := srv.Resolve("/news/pfs.secret", false, ""); r.Kind != KindError { + t.Errorf("public access to members-only group should error, got %v", r.Kind) + } + // ...but the authenticated surface can read it. + if r := srv.Resolve("/news/pfs.secret/1", true, "alice"); r.Kind != KindText || !strings.Contains(r.Text, "shh") { + t.Errorf("authed should read the private article: %+v", r) + } +} + +func TestArticleDotStuffing(t *testing.T) { + srv, _ := newTestServer(t) + r := srv.Resolve("/news/pfs.announce/1", false, "") + if r.Kind != KindText { + t.Fatalf("article should be text, got %v", r.Kind) + } + // The body has a lone "." line; on the wire it must be escaped to ".." so it + // is not read as the terminator, and the real terminator is the final line. + wire := string(r.Wire()) + if !strings.Contains(wire, "\r\n..\r\n") { + t.Errorf("lone dot line should be dot-stuffed to '..':\n%q", wire) + } + if !strings.HasSuffix(wire, "\r\n.\r\n") { + t.Errorf("text response must end with the dot terminator:\n%q", wire) + } +} + +func TestUnknownSelector(t *testing.T) { + srv, _ := newTestServer(t) + r := srv.Resolve("/does/not/exist", false, "") + if r.Kind != KindError { + t.Fatalf("unknown selector should error, got %v", r.Kind) + } + if !strings.HasPrefix(string(r.Wire()), "3") { + t.Errorf("error response should be a type-3 line: %q", r.Wire()) + } +} diff --git a/internal/gopher/listener.go b/internal/gopher/listener.go new file mode 100644 index 0000000..cecfc3e --- /dev/null +++ b/internal/gopher/listener.go @@ -0,0 +1,62 @@ +package gopher + +import ( + "bufio" + "context" + "io" + "net" + "strings" + "time" + + "github.com/charmbracelet/log" +) + +// maxSelector caps the selector line a client may send. RFC 1436 selectors are +// short; a generous bound stops a peer from streaming forever. +const maxSelector = 4096 + +// Serve runs the public Gopher listener (RFC 1436) on addr until ctx is +// cancelled. Every connection is a single transaction: read one CRLF-terminated +// selector, resolve it with authed=false (public content only), write the +// response, close. It is read-only — nothing is ever written to the BBS here. +func (s *Server) Serve(ctx context.Context, addr string) error { + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + go func() { <-ctx.Done(); _ = ln.Close() }() + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-ctx.Done(): + return nil + default: + return err + } + } + go s.handleConn(conn) + } +} + +// handleConn serves one gopher transaction. +func (s *Server) handleConn(conn net.Conn) { + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(30 * time.Second)) + + br := bufio.NewReader(&io.LimitedReader{R: conn, N: maxSelector}) + line, err := br.ReadString('\n') + if err != nil && line == "" { + return + } + sel := strings.TrimRight(line, "\r\n") + // A gopher client may append "\t$" (Gopher+) — we speak plain RFC 1436, so + // keep only the selector up to the first tab. + if i := strings.IndexByte(sel, '\t'); i >= 0 { + sel = sel[:i] + } + resp := s.Resolve(sel, false, "") + if _, err := conn.Write(resp.Wire()); err != nil { + log.Debug("gopher write", "err", err) + } +} diff --git a/internal/gopher/server.go b/internal/gopher/server.go new file mode 100644 index 0000000..cedf8b5 --- /dev/null +++ b/internal/gopher/server.go @@ -0,0 +1,450 @@ +package gopher + +import ( + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/profullstack/agentbbs/internal/store" +) + +// Content is the read-only slice of the store the gopher engine needs. It is +// satisfied directly by store.Store, so no new store methods are required; tests +// supply a small fake. +type Content interface { + ListUsers(limit int) ([]store.User, error) + NewsGroups() ([]store.NewsGroup, error) + NewsArticlesRange(group string, from, to int64) ([]store.NewsArticle, error) + NewsArticleByNum(group string, num int64) (store.NewsArticle, bool, error) +} + +// Server resolves gopher selectors into responses over BBS content. It is shared +// by the public listener (Serve) and the hedgehog SSH browser (RunBrowser); the +// authed flag on Resolve is the only behavioural difference between them. +type Server struct { + c Content + dataDir string // ; homepages live at /users//public_html + host string // hostname advertised in menu selector rows + port string // port advertised in menu selector rows (e.g. "70") + pubGroups map[string]bool // newsgroups exposed on the public (unauthenticated) surface + aboutFn func() string // body of the /about text page (brand + MOTD + connect help) +} + +// New builds a Server. host/port are echoed into menu rows so gopher clients can +// follow links. pubGroups is the allowlist of newsgroups visible without auth +// (the authenticated hedgehog surface sees them all). aboutFn supplies the +// /about page body and may be nil. +func New(c Content, dataDir, host, port string, pubGroups []string, aboutFn func() string) *Server { + pg := make(map[string]bool, len(pubGroups)) + for _, g := range pubGroups { + if g = strings.TrimSpace(g); g != "" { + pg[g] = true + } + } + if port == "" { + port = "70" + } + return &Server{c: c, dataDir: dataDir, host: host, port: port, pubGroups: pg, aboutFn: aboutFn} +} + +// Kind classifies a resolved response. +type Kind int + +const ( + KindMenu Kind = iota // a gopher directory (Menu set) + KindText // a text document (Text set) + KindBinary // a binary file (Data set) + KindError // selector unknown/refused (Text set to the message) +) + +// Response is the result of resolving a selector. Wire() renders it to the exact +// bytes the native listener sends; the TUI reads Kind/Menu/Text directly. +type Response struct { + Kind Kind + Menu *Menu // KindMenu + Text string // KindText, KindError + Data []byte // KindBinary +} + +// Wire renders the response to the gopher wire format: menus and text end with +// the RFC 1436 "." terminator (text is dot-stuffed); binaries are sent raw. +func (r Response) Wire() []byte { + switch r.Kind { + case KindMenu: + return []byte(r.Menu.Render()) + case KindText: + return []byte(dotStuff(r.Text) + ".\r\n") + case KindBinary: + return r.Data + default: // KindError + return []byte(renderItem(Item{Type: TypeError, Display: r.Text, Host: "error.host", Port: "1"}) + ".\r\n") + } +} + +// Resolve maps a gopher selector to a response. authed is true only on the +// hedgehog (SSH-authenticated) surface, where member is the connecting member's +// name; the public listener always passes authed=false. The engine never writes. +func (s *Server) Resolve(selector string, authed bool, member string) Response { + sel := strings.Trim(selector, " \t\r\n") + sel = strings.TrimPrefix(sel, "/") + if sel == "" { + return s.root(authed, member) + } + head, rest := split1(sel) + switch head { + case "about": + return Response{Kind: KindText, Text: s.about()} + case "members": + return s.members() + case "news": + return s.news(rest, authed) + case "files": + if rest == "" { + return s.filesRoot() + } + return s.userTree("files", rest, "public") + default: + // "~name[/sub]" — a member homepage under public_html. + if strings.HasPrefix(head, "~") { + return s.userTree("", sel, "public_html") + } + return errResp("selector not found: " + selector) + } +} + +// root is the top-level menu. It is identical on both surfaces except the +// authenticated banner and the fact that /news lists private groups too. +func (s *Server) root(authed bool, member string) Response { + m := NewMenu(s.host, s.port) + m.Info("AgentBBS over Gopher") + m.Info("A terminal BBS for humans & AI agents.") + if authed { + m.Info("Signed in as " + member + " (hedgehog: private groups visible).") + } else { + m.Info("Public view. For members-only content: ssh gopher@" + s.host) + } + m.Info("") + m.Link(TypeText, "About AgentBBS", "/about") + m.Link(TypeMenu, "Members — homepages & directory", "/members") + m.Link(TypeMenu, "News — public newsgroups", "/news") + m.Link(TypeMenu, "Files — members' public files", "/files") + return Response{Kind: KindMenu, Menu: m} +} + +// about returns the /about text body (aboutFn, or a minimal fallback). +func (s *Server) about() string { + if s.aboutFn != nil { + if t := strings.TrimRight(s.aboutFn(), "\n"); t != "" { + return t + "\n" + } + } + return "AgentBBS — a terminal BBS for humans & AI agents.\n" + + "Connect: ssh " + trimBBS(s.host) + " · gopher://" + s.host + "\n" +} + +// members lists every non-banned account, each linking to its homepage tree. +func (s *Server) members() Response { + users, err := s.c.ListUsers(1000) + if err != nil { + return errResp("members unavailable") + } + sort.Slice(users, func(i, j int) bool { return users[i].Name < users[j].Name }) + m := NewMenu(s.host, s.port) + m.Info("Member homepages — the same pages served at https://" + s.host + "/~name") + m.Info("") + for _, u := range users { + if u.Banned || u.Name == "" { + continue + } + m.Link(TypeMenu, u.Name, "/~"+u.Name) + } + if m.Len() <= 2 { + m.Info("(no members yet)") + } + return Response{Kind: KindMenu, Menu: m} +} + +// news serves the newsgroup tree. rest is "" (group list), "" (article +// list), or "/" (one article). On the public surface only groups in +// pubGroups are reachable; hedgehog (authed) reaches every group. +func (s *Server) news(rest string, authed bool) Response { + if rest == "" { + groups, err := s.c.NewsGroups() + if err != nil { + return errResp("news unavailable") + } + m := NewMenu(s.host, s.port) + if authed { + m.Info("Newsgroups (all groups — you are authenticated)") + } else { + m.Info("Public newsgroups. The rest are members-only: ssh gopher@" + s.host) + } + m.Info("") + for _, g := range groups { + if !authed && !s.pubGroups[g.Name] { + continue + } + label := g.Name + if g.Description != "" { + label += " — " + g.Description + } + m.Link(TypeMenu, label, "/news/"+g.Name) + } + if m.Len() <= 2 { + m.Info("(no public groups)") + } + return Response{Kind: KindMenu, Menu: m} + } + + group, tail := split1(rest) + if !authed && !s.pubGroups[group] { + return errResp("group is members-only: ssh gopher@" + s.host) + } + if tail == "" { + return s.newsArticles(group) + } + num, err := strconv.ParseInt(tail, 10, 64) + if err != nil { + return errResp("bad article number") + } + a, ok, err := s.c.NewsArticleByNum(group, num) + if err != nil || !ok { + return errResp("article not found") + } + var b strings.Builder + b.WriteString("From: " + a.From + "\n") + b.WriteString("Subject: " + a.Subject + "\n") + b.WriteString("Date: " + a.Date + "\n") + b.WriteString("Group: " + group + " #" + tail + "\n") + b.WriteString(strings.Repeat("-", 64) + "\n\n") + b.WriteString(a.Body) + return Response{Kind: KindText, Text: b.String()} +} + +// newsArticles lists the articles in a group, newest first. +func (s *Server) newsArticles(group string) Response { + gs, err := s.c.NewsGroups() + if err != nil { + return errResp("news unavailable") + } + var high int64 + found := false + for _, g := range gs { + if g.Name == group { + high, found = g.High, true + break + } + } + if !found { + return errResp("no such group") + } + arts, err := s.c.NewsArticlesRange(group, 1, high) + if err != nil { + return errResp("group unavailable") + } + sort.Slice(arts, func(i, j int) bool { return arts[i].Num > arts[j].Num }) + m := NewMenu(s.host, s.port) + m.Info(group) + m.Info("") + for _, a := range arts { + label := "#" + strconv.FormatInt(a.Num, 10) + " " + a.Subject + if a.From != "" { + label += " (" + a.From + ")" + } + m.Link(TypeText, label, "/news/"+group+"/"+strconv.FormatInt(a.Num, 10)) + } + if m.Len() <= 2 { + m.Info("(no articles yet)") + } + return Response{Kind: KindMenu, Menu: m} +} + +// filesRoot lists every non-banned member, each linking to their public files +// area (/users//public), mirroring the anonymous web files surface. +func (s *Server) filesRoot() Response { + users, err := s.c.ListUsers(1000) + if err != nil { + return errResp("files unavailable") + } + sort.Slice(users, func(i, j int) bool { return users[i].Name < users[j].Name }) + m := NewMenu(s.host, s.port) + m.Info("Members' public files") + m.Info("") + for _, u := range users { + if u.Banned || u.Name == "" { + continue + } + m.Link(TypeMenu, u.Name, "/files/~"+u.Name) + } + if m.Len() <= 2 { + m.Info("(no members yet)") + } + return Response{Kind: KindMenu, Menu: m} +} + +// userTree serves a member's per-user area (public_html homepage, or the public +// files area) as gopher content. prefix is the selector root ("" for homepages, +// "files" for the files area); sel is the remainder after the prefix, of the form +// "~name[/subpath]". area is the on-disk subdirectory under /users/. +// +// The subpath is confined to the member's area: it is cleaned as an absolute +// path (so any ".." that would escape is neutralised) and the resolved target is +// re-checked to live under the base directory before any file is opened. +func (s *Server) userTree(prefix, sel, area string) Response { + // sel is "~name[/subpath]" for both surfaces (Resolve passes the slice after + // any "/files" head), so the first segment is always the member. + tilde, sub := split1(sel) + name := strings.TrimPrefix(tilde, "~") + if !validName(name) { + return errResp("bad member name") + } + base := filepath.Join(s.dataDir, "users", name, area) + + // Confine sub to base. Cleaning as an absolute path drops any leading ".." + // components; the HasPrefix re-check is belt-and-suspenders against symlink + // or edge cases. + clean := filepath.Clean("/" + sub) + target := filepath.Join(base, clean) + if target != base && !strings.HasPrefix(target, base+string(os.PathSeparator)) { + return errResp("forbidden") + } + + info, err := os.Stat(target) + if err != nil { + return errResp("not found") + } + linkBase := "/~" + name + if prefix == "files" { + linkBase = "/files/~" + name + } + rel := strings.TrimPrefix(strings.TrimPrefix(target, base), string(os.PathSeparator)) + + if info.IsDir() { + return s.dirMenu(target, linkBase, rel, name) + } + data, err := os.ReadFile(target) + if err != nil { + return errResp("unreadable") + } + if isText(target) { + return Response{Kind: KindText, Text: string(data)} + } + return Response{Kind: KindBinary, Data: data} +} + +// dirMenu renders a directory listing. linkBase is the selector prefix for this +// member's area ("/~name" or "/files/~name"); rel is the directory's path within +// the area ("" at the area root). +func (s *Server) dirMenu(dir, linkBase, rel, name string) Response { + entries, err := os.ReadDir(dir) + if err != nil { + return errResp("unreadable") + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].IsDir() != entries[j].IsDir() { + return entries[i].IsDir() // directories first + } + return entries[i].Name() < entries[j].Name() + }) + m := NewMenu(s.host, s.port) + title := name + if rel != "" { + title += "/" + rel + } + m.Info(title) + m.Info("") + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + continue // hidden files + } + childSel := linkBase + if rel != "" { + childSel += "/" + rel + } + childSel += "/" + e.Name() + if e.IsDir() { + m.Link(TypeMenu, e.Name()+"/", childSel) + continue + } + t := byte(TypeBinary) + if isText(e.Name()) { + t = TypeText + } else if isImage(e.Name()) { + t = TypeImage + } + m.Link(t, e.Name(), childSel) + } + if m.Len() <= 2 { + m.Info("(empty)") + } + return Response{Kind: KindMenu, Menu: m} +} + +// --- helpers --- + +// split1 splits "a/b/c" into ("a", "b/c"); a bare "a" yields ("a", ""). +func split1(p string) (head, rest string) { + p = strings.TrimPrefix(p, "/") + if i := strings.IndexByte(p, '/'); i >= 0 { + return p[:i], p[i+1:] + } + return p, "" +} + +// validName accepts the member-name charset (see auth.SanitizeUsername) and +// nothing that could traverse or escape a path. +func validName(name string) bool { + if name == "" || len(name) > 32 { + return false + } + for _, r := range name { + if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') { + return false + } + } + return true +} + +func errResp(msg string) Response { return Response{Kind: KindError, Text: msg} } + +// trimBBS drops a leading "bbs." so connect hints read "profullstack.com". +func trimBBS(host string) string { return strings.TrimPrefix(host, "bbs.") } + +// isText reports whether a filename should be served as a gopher text document. +func isText(name string) bool { + switch strings.ToLower(filepath.Ext(name)) { + case ".txt", ".md", ".markdown", ".html", ".htm", ".css", ".js", ".json", + ".xml", ".gmi", ".gph", ".log", ".csv", ".org", ".rst", "": + return true + } + return false +} + +// isImage reports whether a filename is a common image (gopher type 'I'). +func isImage(name string) bool { + switch strings.ToLower(filepath.Ext(name)) { + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".svg": + return true + } + return false +} + +// dotStuff prepares a text body for the RFC 1436 "." terminator: it forces CRLF +// line endings and escapes any line consisting solely of "." to ".." so it is +// not mistaken for the end-of-response marker. +func dotStuff(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + lines := strings.Split(text, "\n") + for i, ln := range lines { + if ln == "." { + lines[i] = ".." + } + } + out := strings.Join(lines, "\r\n") + if !strings.HasSuffix(out, "\r\n") { + out += "\r\n" + } + return out +}