Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
89 changes: 87 additions & 2 deletions cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 }

Expand Down
127 changes: 127 additions & 0 deletions docs/gopher.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 11 additions & 1 deletion internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)] }
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading