Skip to content

Commit 302259f

Browse files
ralyodioclaude
andcommitted
feat(irc): add ssh irc@ built-in client for the members-only network
Adds an in-process IRC client (internal/irc) and an `irc@` SSH route that drops a member straight into the BBS's own Ergo network with no client to install and no SASL to configure. - internal/irc/client.go: minimal IRC client (SASL PLAIN, IRCv3 CAP, PING, PRIVMSG/JOIN/PART/NICK, event stream). Dials Ergo on the loopback 127.0.0.1:6667; presents the member's account name (the SSH key already proved membership; Ergo's auth-script ignores the passphrase by design). - internal/irc/tui.go: Bubble Tea TUI over the SSH PTY (mirrors internal/chat) with /join /part /msg /me /names /nick /help and a current-channel input. - cmd/agentbbs: handleIRC resolves the member by key (members-only, free) and runs the client; routed via auth.IsIRCName. AGENTBBS_IRC_ADDR overrides the target on dev hosts. - auth: reserve `irc` as a route name. Unlike copying tor-irc@ (a third-party client in a pod), this runs our own Go code in-process, so there is no /exec shell-escape surface, and the host process can reach Ergo's loopback listener directly. Validated live against a members-only Ergo: non-members are rejected, a member authenticates via SASL, and channel messages are received. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8adafaf commit 302259f

6 files changed

Lines changed: 631 additions & 11 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,17 @@ name is an existing AgentBBS member (registration is off — your BBS account *i
110110
your IRC identity):
111111

112112
```bash
113+
# zero-setup: built-in client over SSH (members only)
114+
ssh -t irc@bbs.profullstack.com
113115
# native client — SASL account = your BBS member name
114116
/connect irc.bbs.profullstack.com 6697
115117
# browser / agent over WebSocket
116118
wss://bbs.profullstack.com/irc
117119
```
118120

119-
Set `IRC=0` to skip it. Full details: [`docs/irc.md`](docs/irc.md).
121+
`ssh irc@` is a built-in IRC client (`internal/irc`) that authenticates you to
122+
the network automatically — no client to install. Set `IRC=0` to skip the
123+
server. Full details: [`docs/irc.md`](docs/irc.md).
120124

121125
## Architecture
122126

cmd/agentbbs/main.go

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"encoding/binary"
2929
"errors"
3030
"fmt"
31+
"io"
3132
"net"
3233
"net/http"
3334
"os"
@@ -53,6 +54,7 @@ import (
5354
"github.com/profullstack/agentbbs/internal/forwardemail"
5455
"github.com/profullstack/agentbbs/internal/games"
5556
"github.com/profullstack/agentbbs/internal/hub"
57+
"github.com/profullstack/agentbbs/internal/irc"
5658
"github.com/profullstack/agentbbs/internal/mail"
5759
"github.com/profullstack/agentbbs/internal/payments"
5860
"github.com/profullstack/agentbbs/internal/plugin"
@@ -253,6 +255,8 @@ func (a *app) router() wish.Middleware {
253255
a.handleTorIRC(s)
254256
case auth.IsTorName(user):
255257
a.handleTorCmd(s)
258+
case auth.IsIRCName(user):
259+
a.handleIRC(s)
256260
case isVideo:
257261
a.handleVideo(s, code)
258262
case user == "agent":
@@ -320,6 +324,41 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
320324
return hub.New(u, ctx, a.enabledPlugins()), []tea.ProgramOption{tea.WithAltScreen()}
321325
}
322326

327+
// readLine reads one line of interactive input from an SSH session that is
328+
// running under a client-allocated PTY. That detail is the whole reason this
329+
// helper exists: when the client requests a PTY (which `ssh join@host` does by
330+
// default) it puts its OWN terminal into raw mode, so it sends raw keystrokes —
331+
// Enter arrives as '\r', not '\n' — and does NO local echo. bufio.ReadString
332+
// ('\n') therefore blocks forever (the '\n' never comes) and the user sees a
333+
// dead prompt. So we read byte-by-byte, accept either '\r' or '\n' as the line
334+
// terminator, handle backspace, and echo printable bytes back ourselves.
335+
func readLine(s ssh.Session, in *bufio.Reader) (string, error) {
336+
var b []byte
337+
for {
338+
c, err := in.ReadByte()
339+
if err != nil {
340+
return "", err
341+
}
342+
switch c {
343+
case '\r', '\n':
344+
wish.Print(s, "\r\n")
345+
return string(b), nil
346+
case 0x03, 0x04: // Ctrl-C / Ctrl-D: treat as abort
347+
return "", io.EOF
348+
case 0x7f, '\b': // DEL / backspace: erase last char on screen too
349+
if len(b) > 0 {
350+
b = b[:len(b)-1]
351+
wish.Print(s, "\b \b")
352+
}
353+
default:
354+
if c >= 0x20 { // printable byte; ignore other control codes
355+
b = append(b, c)
356+
wish.Print(s, string(c))
357+
}
358+
}
359+
}
360+
}
361+
323362
// handleJoin runs onboarding interactively in one SSH session: register the
324363
// visitor's key, confirm their email with a code we email them, then offer the
325364
// $10 lifetime Premium membership (CoinPay). It then disconnects.
@@ -398,7 +437,7 @@ func (a *app) registerNewMember(s ssh.Session, in *bufio.Reader, fp string) (sto
398437

399438
for tries := 0; tries < 5; tries++ {
400439
wish.Print(s, "\n Username ["+def+"]: ")
401-
line, err := in.ReadString('\n')
440+
line, err := readLine(s, in)
402441
if err != nil {
403442
return store.User{}, err
404443
}
@@ -433,7 +472,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
433472
var email string
434473
for tries := 0; tries < 3; tries++ {
435474
wish.Print(s, "\n Email: ")
436-
line, err := in.ReadString('\n')
475+
line, err := readLine(s, in)
437476
if err != nil {
438477
return false
439478
}
@@ -472,7 +511,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
472511

473512
for tries := 0; tries < 3; tries++ {
474513
wish.Print(s, " Enter the code: ")
475-
line, err := in.ReadString('\n')
514+
line, err := readLine(s, in)
476515
if err != nil {
477516
return false
478517
}
@@ -891,6 +930,48 @@ func (a *app) handleTorIRC(s ssh.Session) {
891930
}
892931
}
893932

933+
// handleIRC drops a member into the BBS's own (members-only) IRC network using
934+
// an in-process client: it authenticates to Ergo over SASL as the member and
935+
// runs a Bubble Tea TUI. Free for any registered member; needs a PTY. Distinct
936+
// from tor-irc@ (a client for remote servers over Tor).
937+
func (a *app) handleIRC(s ssh.Session) {
938+
fp := auth.Fingerprint(s.PublicKey())
939+
if fp == "" {
940+
wish.Println(s, "irc@ needs your registered SSH key. New here? ssh join@"+a.host)
941+
_ = s.Exit(1)
942+
return
943+
}
944+
u, found, err := a.st.UserByFingerprint(fp)
945+
if err != nil || !found {
946+
wish.Println(s, "the IRC network is members-only — register first: ssh join@"+a.host)
947+
_ = s.Exit(1)
948+
return
949+
}
950+
if u.Banned {
951+
wish.Println(s, "this account is suspended.")
952+
_ = s.Exit(1)
953+
return
954+
}
955+
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "irc")
956+
defer func() { _ = a.st.EndSession(sessID) }()
957+
958+
addr := strings.TrimSpace(os.Getenv("AGENTBBS_IRC_ADDR"))
959+
if addr == "" {
960+
addr = irc.DefaultAddr
961+
}
962+
log.Info("irc connect", "user", u.Name, "addr", addr)
963+
c, err := irc.Dial(s.Context(), addr, u.Name)
964+
if err != nil {
965+
wish.Println(s, "irc: "+err.Error())
966+
_ = s.Exit(1)
967+
return
968+
}
969+
_ = c.Join(irc.DefaultChannel)
970+
if err := irc.Run(s, c); err != nil {
971+
wish.Println(s, "irc: "+err.Error())
972+
}
973+
}
974+
894975
// handleTorCmd runs an arbitrary command through Tor (torsocks) inside the
895976
// member's pod, never on the host. Premium; requires a PTY.
896977
func (a *app) handleTorCmd(s ssh.Session) {

docs/irc.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,26 @@ operationally independent of the wish server.
1313

1414
| Path | Address | For |
1515
|---|---|---|
16+
| In-BBS | `ssh -t irc@bbs.profullstack.com` | members — zero-setup built-in client (see below) |
1617
| Native TLS | `irc.bbs.profullstack.com:6697` (TLS) | desktop/CLI clients (HexChat, irssi, WeeChat, Halloy…) |
1718
| WebSocket | `wss://bbs.profullstack.com/irc` | browser clients (The Lounge, Gamja, Kiwi) and agents over WS |
18-
| Plaintext | `127.0.0.1:6667` | **loopback only** — on-box tooling/bridges; firewalled off |
19+
| Plaintext | `127.0.0.1:6667` | **loopback only** — on-box tooling/the `irc@` client; firewalled off |
1920

2021
The WebSocket path is fronted by Caddy (it terminates TLS and reverse-proxies to
2122
Ergo's loopback `127.0.0.1:8097`), so no extra public port is opened for the web.
2223

24+
### `ssh irc@` — the built-in client
25+
26+
`ssh -t irc@bbs.profullstack.com` drops a member straight into the network with
27+
no client to install or SASL to configure. It is an **in-process IRC client**
28+
(`internal/irc`) running inside the agentbbs process: it reaches Ergo on the
29+
loopback `127.0.0.1:6667` and authenticates as you (your SSH key already proved
30+
you're a member, so it presents your account name over SASL). Because the client
31+
is our own Go code — not a third-party client in a pod — there is no `/exec`
32+
shell-escape surface. You land in `#lobby`; type to talk, or use
33+
`/join #chan`, `/msg <nick> <text>`, `/me`, `/names`, `/nick`, `/help`, and
34+
`esc` to leave. Override the target with `AGENTBBS_IRC_ADDR` on a dev host.
35+
2336
### Membership (who can connect)
2437

2538
The network is **members-only**. There is **no self-service registration**
@@ -112,14 +125,11 @@ up immediately; the timer swaps in the real one once it exists.
112125

113126
Unrelated, complementary. `ssh tor-irc@bbs.profullstack.com <server>` is a
114127
**client** that connects *out* to a remote (e.g. `.onion`) IRC server from inside
115-
a member's pod. This is the BBS hosting **its own** IRC network for people and
116-
agents to meet on.
128+
a member's pod. `irc@` (above) and the 6697/WebSocket listeners are the BBS
129+
hosting **its own** IRC network for people and agents to meet on.
117130

118131
## Ideas / next steps
119132

120-
- **In-BBS `irc@` route** — an SSH route that drops a member straight into the
121-
local network (mirroring `tor-irc@` but pointed at `127.0.0.1:6667`), so
122-
`ssh irc@bbs.profullstack.com` is an instant client with no setup.
123133
- **Bridge to `internal/chat`** — relay the BBS hub chat ↔ an IRC channel.
124134
- **Per-pod / per-game channels** — auto-create `#pod-<name>`, `#game-<id>`.
125135
- **Persistent history** — switch `datastore.mysql` on if replay must survive

internal/auth/auth.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ var TorIRCNames = map[string]bool{"tor-irc": true}
5656
// member's pod (premium). Checked after the more specific tor-* routes.
5757
var TorNames = map[string]bool{"tor": true}
5858

59+
// IRCNames route a member straight into the BBS's own (members-only) IRC
60+
// network via an in-process client. Distinct from tor-irc@, which is a client
61+
// for connecting OUT to remote IRC servers over Tor.
62+
var IRCNames = map[string]bool{"irc": true}
63+
5964
// GameNames are usernames that route to AgentGames: the line-delimited-JSON
6065
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
6166
var GameNames = map[string]bool{"game": true, "games": true}
@@ -84,6 +89,9 @@ func IsTorIRCName(u string) bool { return TorIRCNames[strings.ToLower(u)] }
8489
// IsTorName reports whether the SSH username requests the generic tor passthrough.
8590
func IsTorName(u string) bool { return TorNames[strings.ToLower(u)] }
8691

92+
// IsIRCName reports whether the SSH username requests the in-BBS IRC client.
93+
func IsIRCName(u string) bool { return IRCNames[strings.ToLower(u)] }
94+
8795
// systemReserved are names that don't drive an SSH route but would still
8896
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
8997
// infra hostnames — so members may not claim them as account names.
@@ -100,7 +108,7 @@ var systemReserved = map[string]bool{
100108
func IsReservedName(name string) bool {
101109
n := strings.ToLower(name)
102110
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
103-
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || systemReserved[n] {
111+
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || systemReserved[n] {
104112
return true
105113
}
106114
return strings.HasPrefix(n, "video-") // video-<code> call routes

0 commit comments

Comments
 (0)