mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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>
450 lines
14 KiB
Go
450 lines
14 KiB
Go
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
|
|
}
|