mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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:
parent
9d0c0a0193
commit
ad76e7fa16
9 changed files with 1346 additions and 3 deletions
|
|
@ -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.<host>` | hostname advertised in gopher menu selectors |
|
||||
| `AGENTBBS_GOPHER_NEWS_GROUPS` | `pfs.announce` | newsgroups exposed on the public gopher surface (all show on hedgehog) |
|
||||
|
||||
Ops:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
127
docs/gopher.md
Normal file
127
docs/gopher.md
Normal file
|
|
@ -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
|
||||
/~<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](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:
|
||||
|
||||
```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.
|
||||
|
|
@ -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@<host>); 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
|
||||
}
|
||||
|
|
|
|||
250
internal/gopher/browser.go
Normal file
250
internal/gopher/browser.go
Normal file
|
|
@ -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@<host>)\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
|
||||
}
|
||||
116
internal/gopher/gopher.go
Normal file
116
internal/gopher/gopher.go
Normal file
|
|
@ -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 <name>@` 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 "<type><display>\t<selector>\t<host>\t<port>\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)
|
||||
}
|
||||
238
internal/gopher/gopher_test.go
Normal file
238
internal/gopher/gopher_test.go
Normal file
|
|
@ -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"), "<h1>hi</h1>")
|
||||
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, "<h1>hi</h1>") {
|
||||
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())
|
||||
}
|
||||
}
|
||||
62
internal/gopher/listener.go
Normal file
62
internal/gopher/listener.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
450
internal/gopher/server.go
Normal file
450
internal/gopher/server.go
Normal file
|
|
@ -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 // <data>; homepages live at <data>/users/<name>/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), "<group>" (article
|
||||
// list), or "<group>/<num>" (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 (<data>/users/<name>/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 <data>/users/<name>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue