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>
This commit is contained in:
Anthony Ettinger 2026-06-14 11:35:09 +00:00
parent 8adafaf515
commit 302259f65c
6 changed files with 631 additions and 11 deletions

View file

@ -28,6 +28,7 @@ import (
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
@ -53,6 +54,7 @@ import (
"github.com/profullstack/agentbbs/internal/forwardemail"
"github.com/profullstack/agentbbs/internal/games"
"github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/irc"
"github.com/profullstack/agentbbs/internal/mail"
"github.com/profullstack/agentbbs/internal/payments"
"github.com/profullstack/agentbbs/internal/plugin"
@ -253,6 +255,8 @@ func (a *app) router() wish.Middleware {
a.handleTorIRC(s)
case auth.IsTorName(user):
a.handleTorCmd(s)
case auth.IsIRCName(user):
a.handleIRC(s)
case isVideo:
a.handleVideo(s, code)
case user == "agent":
@ -320,6 +324,41 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return hub.New(u, ctx, a.enabledPlugins()), []tea.ProgramOption{tea.WithAltScreen()}
}
// readLine reads one line of interactive input from an SSH session that is
// running under a client-allocated PTY. That detail is the whole reason this
// helper exists: when the client requests a PTY (which `ssh join@host` does by
// default) it puts its OWN terminal into raw mode, so it sends raw keystrokes —
// Enter arrives as '\r', not '\n' — and does NO local echo. bufio.ReadString
// ('\n') therefore blocks forever (the '\n' never comes) and the user sees a
// dead prompt. So we read byte-by-byte, accept either '\r' or '\n' as the line
// terminator, handle backspace, and echo printable bytes back ourselves.
func readLine(s ssh.Session, in *bufio.Reader) (string, error) {
var b []byte
for {
c, err := in.ReadByte()
if err != nil {
return "", err
}
switch c {
case '\r', '\n':
wish.Print(s, "\r\n")
return string(b), nil
case 0x03, 0x04: // Ctrl-C / Ctrl-D: treat as abort
return "", io.EOF
case 0x7f, '\b': // DEL / backspace: erase last char on screen too
if len(b) > 0 {
b = b[:len(b)-1]
wish.Print(s, "\b \b")
}
default:
if c >= 0x20 { // printable byte; ignore other control codes
b = append(b, c)
wish.Print(s, string(c))
}
}
}
}
// handleJoin runs onboarding interactively in one SSH session: register the
// visitor's key, confirm their email with a code we email them, then offer the
// $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
for tries := 0; tries < 5; tries++ {
wish.Print(s, "\n Username ["+def+"]: ")
line, err := in.ReadString('\n')
line, err := readLine(s, in)
if err != nil {
return store.User{}, err
}
@ -433,7 +472,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
var email string
for tries := 0; tries < 3; tries++ {
wish.Print(s, "\n Email: ")
line, err := in.ReadString('\n')
line, err := readLine(s, in)
if err != nil {
return false
}
@ -472,7 +511,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
for tries := 0; tries < 3; tries++ {
wish.Print(s, " Enter the code: ")
line, err := in.ReadString('\n')
line, err := readLine(s, in)
if err != nil {
return false
}
@ -891,6 +930,48 @@ func (a *app) handleTorIRC(s ssh.Session) {
}
}
// handleIRC drops a member into the BBS's own (members-only) IRC network using
// an in-process client: it authenticates to Ergo over SASL as the member and
// runs a Bubble Tea TUI. Free for any registered member; needs a PTY. Distinct
// from tor-irc@ (a client for remote servers over Tor).
func (a *app) handleIRC(s ssh.Session) {
fp := auth.Fingerprint(s.PublicKey())
if fp == "" {
wish.Println(s, "irc@ 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, "the IRC network is members-only — register first: ssh join@"+a.host)
_ = 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), "irc")
defer func() { _ = a.st.EndSession(sessID) }()
addr := strings.TrimSpace(os.Getenv("AGENTBBS_IRC_ADDR"))
if addr == "" {
addr = irc.DefaultAddr
}
log.Info("irc connect", "user", u.Name, "addr", addr)
c, err := irc.Dial(s.Context(), addr, u.Name)
if err != nil {
wish.Println(s, "irc: "+err.Error())
_ = s.Exit(1)
return
}
_ = c.Join(irc.DefaultChannel)
if err := irc.Run(s, c); err != nil {
wish.Println(s, "irc: "+err.Error())
}
}
// handleTorCmd runs an arbitrary command through Tor (torsocks) inside the
// member's pod, never on the host. Premium; requires a PTY.
func (a *app) handleTorCmd(s ssh.Session) {