mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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:
parent
8adafaf515
commit
302259f65c
6 changed files with 631 additions and 11 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue