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
|
|
@ -110,13 +110,17 @@ name is an existing AgentBBS member (registration is off — your BBS account *i
|
||||||
your IRC identity):
|
your IRC identity):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# zero-setup: built-in client over SSH (members only)
|
||||||
|
ssh -t irc@bbs.profullstack.com
|
||||||
# native client — SASL account = your BBS member name
|
# native client — SASL account = your BBS member name
|
||||||
/connect irc.bbs.profullstack.com 6697
|
/connect irc.bbs.profullstack.com 6697
|
||||||
# browser / agent over WebSocket
|
# browser / agent over WebSocket
|
||||||
wss://bbs.profullstack.com/irc
|
wss://bbs.profullstack.com/irc
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `IRC=0` to skip it. Full details: [`docs/irc.md`](docs/irc.md).
|
`ssh irc@` is a built-in IRC client (`internal/irc`) that authenticates you to
|
||||||
|
the network automatically — no client to install. Set `IRC=0` to skip the
|
||||||
|
server. Full details: [`docs/irc.md`](docs/irc.md).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import (
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -53,6 +54,7 @@ import (
|
||||||
"github.com/profullstack/agentbbs/internal/forwardemail"
|
"github.com/profullstack/agentbbs/internal/forwardemail"
|
||||||
"github.com/profullstack/agentbbs/internal/games"
|
"github.com/profullstack/agentbbs/internal/games"
|
||||||
"github.com/profullstack/agentbbs/internal/hub"
|
"github.com/profullstack/agentbbs/internal/hub"
|
||||||
|
"github.com/profullstack/agentbbs/internal/irc"
|
||||||
"github.com/profullstack/agentbbs/internal/mail"
|
"github.com/profullstack/agentbbs/internal/mail"
|
||||||
"github.com/profullstack/agentbbs/internal/payments"
|
"github.com/profullstack/agentbbs/internal/payments"
|
||||||
"github.com/profullstack/agentbbs/internal/plugin"
|
"github.com/profullstack/agentbbs/internal/plugin"
|
||||||
|
|
@ -253,6 +255,8 @@ func (a *app) router() wish.Middleware {
|
||||||
a.handleTorIRC(s)
|
a.handleTorIRC(s)
|
||||||
case auth.IsTorName(user):
|
case auth.IsTorName(user):
|
||||||
a.handleTorCmd(s)
|
a.handleTorCmd(s)
|
||||||
|
case auth.IsIRCName(user):
|
||||||
|
a.handleIRC(s)
|
||||||
case isVideo:
|
case isVideo:
|
||||||
a.handleVideo(s, code)
|
a.handleVideo(s, code)
|
||||||
case user == "agent":
|
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()}
|
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
|
// 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
|
// visitor's key, confirm their email with a code we email them, then offer the
|
||||||
// $10 lifetime Premium membership (CoinPay). It then disconnects.
|
// $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++ {
|
for tries := 0; tries < 5; tries++ {
|
||||||
wish.Print(s, "\n Username ["+def+"]: ")
|
wish.Print(s, "\n Username ["+def+"]: ")
|
||||||
line, err := in.ReadString('\n')
|
line, err := readLine(s, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return store.User{}, err
|
return store.User{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -433,7 +472,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
|
||||||
var email string
|
var email string
|
||||||
for tries := 0; tries < 3; tries++ {
|
for tries := 0; tries < 3; tries++ {
|
||||||
wish.Print(s, "\n Email: ")
|
wish.Print(s, "\n Email: ")
|
||||||
line, err := in.ReadString('\n')
|
line, err := readLine(s, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
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++ {
|
for tries := 0; tries < 3; tries++ {
|
||||||
wish.Print(s, " Enter the code: ")
|
wish.Print(s, " Enter the code: ")
|
||||||
line, err := in.ReadString('\n')
|
line, err := readLine(s, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
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
|
// handleTorCmd runs an arbitrary command through Tor (torsocks) inside the
|
||||||
// member's pod, never on the host. Premium; requires a PTY.
|
// member's pod, never on the host. Premium; requires a PTY.
|
||||||
func (a *app) handleTorCmd(s ssh.Session) {
|
func (a *app) handleTorCmd(s ssh.Session) {
|
||||||
|
|
|
||||||
22
docs/irc.md
22
docs/irc.md
|
|
@ -13,13 +13,26 @@ operationally independent of the wish server.
|
||||||
|
|
||||||
| Path | Address | For |
|
| Path | Address | For |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| In-BBS | `ssh -t irc@bbs.profullstack.com` | members — zero-setup built-in client (see below) |
|
||||||
| Native TLS | `irc.bbs.profullstack.com:6697` (TLS) | desktop/CLI clients (HexChat, irssi, WeeChat, Halloy…) |
|
| Native TLS | `irc.bbs.profullstack.com:6697` (TLS) | desktop/CLI clients (HexChat, irssi, WeeChat, Halloy…) |
|
||||||
| WebSocket | `wss://bbs.profullstack.com/irc` | browser clients (The Lounge, Gamja, Kiwi) and agents over WS |
|
| WebSocket | `wss://bbs.profullstack.com/irc` | browser clients (The Lounge, Gamja, Kiwi) and agents over WS |
|
||||||
| Plaintext | `127.0.0.1:6667` | **loopback only** — on-box tooling/bridges; firewalled off |
|
| Plaintext | `127.0.0.1:6667` | **loopback only** — on-box tooling/the `irc@` client; firewalled off |
|
||||||
|
|
||||||
The WebSocket path is fronted by Caddy (it terminates TLS and reverse-proxies to
|
The WebSocket path is fronted by Caddy (it terminates TLS and reverse-proxies to
|
||||||
Ergo's loopback `127.0.0.1:8097`), so no extra public port is opened for the web.
|
Ergo's loopback `127.0.0.1:8097`), so no extra public port is opened for the web.
|
||||||
|
|
||||||
|
### `ssh irc@` — the built-in client
|
||||||
|
|
||||||
|
`ssh -t irc@bbs.profullstack.com` drops a member straight into the network with
|
||||||
|
no client to install or SASL to configure. It is an **in-process IRC client**
|
||||||
|
(`internal/irc`) running inside the agentbbs process: it reaches Ergo on the
|
||||||
|
loopback `127.0.0.1:6667` and authenticates as you (your SSH key already proved
|
||||||
|
you're a member, so it presents your account name over SASL). Because the client
|
||||||
|
is our own Go code — not a third-party client in a pod — there is no `/exec`
|
||||||
|
shell-escape surface. You land in `#lobby`; type to talk, or use
|
||||||
|
`/join #chan`, `/msg <nick> <text>`, `/me`, `/names`, `/nick`, `/help`, and
|
||||||
|
`esc` to leave. Override the target with `AGENTBBS_IRC_ADDR` on a dev host.
|
||||||
|
|
||||||
### Membership (who can connect)
|
### Membership (who can connect)
|
||||||
|
|
||||||
The network is **members-only**. There is **no self-service registration** —
|
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.
|
||||||
|
|
||||||
Unrelated, complementary. `ssh tor-irc@bbs.profullstack.com <server>` is a
|
Unrelated, complementary. `ssh tor-irc@bbs.profullstack.com <server>` is a
|
||||||
**client** that connects *out* to a remote (e.g. `.onion`) IRC server from inside
|
**client** that connects *out* to a remote (e.g. `.onion`) IRC server from inside
|
||||||
a member's pod. This is the BBS hosting **its own** IRC network for people and
|
a member's pod. `irc@` (above) and the 6697/WebSocket listeners are the BBS
|
||||||
agents to meet on.
|
hosting **its own** IRC network for people and agents to meet on.
|
||||||
|
|
||||||
## Ideas / next steps
|
## Ideas / next steps
|
||||||
|
|
||||||
- **In-BBS `irc@` route** — an SSH route that drops a member straight into the
|
|
||||||
local network (mirroring `tor-irc@` but pointed at `127.0.0.1:6667`), so
|
|
||||||
`ssh irc@bbs.profullstack.com` is an instant client with no setup.
|
|
||||||
- **Bridge to `internal/chat`** — relay the BBS hub chat ↔ an IRC channel.
|
- **Bridge to `internal/chat`** — relay the BBS hub chat ↔ an IRC channel.
|
||||||
- **Per-pod / per-game channels** — auto-create `#pod-<name>`, `#game-<id>`.
|
- **Per-pod / per-game channels** — auto-create `#pod-<name>`, `#game-<id>`.
|
||||||
- **Persistent history** — switch `datastore.mysql` on if replay must survive
|
- **Persistent history** — switch `datastore.mysql` on if replay must survive
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,11 @@ var TorIRCNames = map[string]bool{"tor-irc": true}
|
||||||
// member's pod (premium). Checked after the more specific tor-* routes.
|
// member's pod (premium). Checked after the more specific tor-* routes.
|
||||||
var TorNames = map[string]bool{"tor": true}
|
var TorNames = map[string]bool{"tor": true}
|
||||||
|
|
||||||
|
// IRCNames route a member straight into the BBS's own (members-only) IRC
|
||||||
|
// network via an in-process client. Distinct from tor-irc@, which is a client
|
||||||
|
// for connecting OUT to remote IRC servers over Tor.
|
||||||
|
var IRCNames = map[string]bool{"irc": true}
|
||||||
|
|
||||||
// GameNames are usernames that route to AgentGames: the line-delimited-JSON
|
// GameNames are usernames that route to AgentGames: the line-delimited-JSON
|
||||||
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
|
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
|
||||||
var GameNames = map[string]bool{"game": true, "games": true}
|
var GameNames = map[string]bool{"game": true, "games": true}
|
||||||
|
|
@ -84,6 +89,9 @@ func IsTorIRCName(u string) bool { return TorIRCNames[strings.ToLower(u)] }
|
||||||
// IsTorName reports whether the SSH username requests the generic tor passthrough.
|
// IsTorName reports whether the SSH username requests the generic tor passthrough.
|
||||||
func IsTorName(u string) bool { return TorNames[strings.ToLower(u)] }
|
func IsTorName(u string) bool { return TorNames[strings.ToLower(u)] }
|
||||||
|
|
||||||
|
// IsIRCName reports whether the SSH username requests the in-BBS IRC client.
|
||||||
|
func IsIRCName(u string) bool { return IRCNames[strings.ToLower(u)] }
|
||||||
|
|
||||||
// systemReserved are names that don't drive an SSH route but would still
|
// systemReserved are names that don't drive an SSH route but would still
|
||||||
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
|
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
|
||||||
// infra hostnames — so members may not claim them as account names.
|
// infra hostnames — so members may not claim them as account names.
|
||||||
|
|
@ -100,7 +108,7 @@ var systemReserved = map[string]bool{
|
||||||
func IsReservedName(name string) bool {
|
func IsReservedName(name string) bool {
|
||||||
n := strings.ToLower(name)
|
n := strings.ToLower(name)
|
||||||
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
|
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
|
||||||
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || systemReserved[n] {
|
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || systemReserved[n] {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return strings.HasPrefix(n, "video-") // video-<code> call routes
|
return strings.HasPrefix(n, "video-") // video-<code> call routes
|
||||||
|
|
|
||||||
291
internal/irc/client.go
Normal file
291
internal/irc/client.go
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
// Package irc is a minimal, in-process IRC client for the `irc@` route: it
|
||||||
|
// connects a member to the BBS's own (members-only) Ergo network and drives a
|
||||||
|
// Bubble Tea TUI (see tui.go). It runs inside the agentbbs process on the host,
|
||||||
|
// so it reaches Ergo's loopback listener directly and — unlike running a
|
||||||
|
// third-party client like irssi in a pod — offers no /exec shell escape.
|
||||||
|
//
|
||||||
|
// The member is already authenticated to the BBS by their SSH key; we present
|
||||||
|
// their account name to Ergo over SASL PLAIN. Ergo's auth-script approves the
|
||||||
|
// login on membership alone (the passphrase is ignored, by design), so we send
|
||||||
|
// a placeholder.
|
||||||
|
package irc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultAddr is the host-local plaintext Ergo listener (loopback only; the
|
||||||
|
// public front door is 6697/TLS). Overridable for dev hosts.
|
||||||
|
const DefaultAddr = "127.0.0.1:6667"
|
||||||
|
|
||||||
|
// Event is one thing that happened on the connection, already formatted for
|
||||||
|
// display. Kind groups them so the TUI can colorize.
|
||||||
|
type Event struct {
|
||||||
|
Kind EventKind
|
||||||
|
// Channel is the conversation the event belongs to ("" = server/status).
|
||||||
|
Channel string
|
||||||
|
Nick string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
EvMessage EventKind = iota // a PRIVMSG to a channel or to us
|
||||||
|
EvNotice // a NOTICE (often from services)
|
||||||
|
EvJoin
|
||||||
|
EvPart
|
||||||
|
EvQuit
|
||||||
|
EvNick
|
||||||
|
EvSystem // numerics, topics, names, errors, our own status lines
|
||||||
|
EvClosed // the connection ended; Text carries the reason
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is a single member's connection to the network.
|
||||||
|
type Client struct {
|
||||||
|
conn net.Conn
|
||||||
|
w *bufio.Writer
|
||||||
|
r *bufio.Reader
|
||||||
|
nick string
|
||||||
|
events chan Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial connects to addr, performs the SASL PLAIN handshake as nick, and blocks
|
||||||
|
// until the server welcomes us (001) or the attempt fails. On success the read
|
||||||
|
// loop is running and Events() is live.
|
||||||
|
func Dial(ctx context.Context, addr, nick string) (*Client, error) {
|
||||||
|
if addr == "" {
|
||||||
|
addr = DefaultAddr
|
||||||
|
}
|
||||||
|
d := net.Dialer{Timeout: 10 * time.Second}
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not reach the IRC server (%s): %w", addr, err)
|
||||||
|
}
|
||||||
|
c := &Client{
|
||||||
|
conn: conn,
|
||||||
|
w: bufio.NewWriter(conn),
|
||||||
|
r: bufio.NewReader(conn),
|
||||||
|
nick: nick,
|
||||||
|
events: make(chan Event, 256),
|
||||||
|
}
|
||||||
|
if err := c.handshake(nick); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
go c.readLoop()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events is the stream the TUI consumes. It is closed when the connection ends.
|
||||||
|
func (c *Client) Events() <-chan Event { return c.events }
|
||||||
|
|
||||||
|
// Nick reports the connection's current nickname.
|
||||||
|
func (c *Client) Nick() string { return c.nick }
|
||||||
|
|
||||||
|
func (c *Client) send(format string, a ...any) error {
|
||||||
|
if _, err := fmt.Fprintf(c.w, format+"\r\n", a...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handshake authenticates with SASL PLAIN and waits for welcome (001) or an
|
||||||
|
// auth failure (904/906) / error. The membership gate lives server-side; the
|
||||||
|
// passphrase is a placeholder the auth-script ignores.
|
||||||
|
func (c *Client) handshake(nick string) error {
|
||||||
|
_ = c.conn.SetDeadline(time.Now().Add(20 * time.Second))
|
||||||
|
if err := c.send("CAP LS 302"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.send("NICK %s", nick); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.send("USER %s 0 * :%s", nick, nick); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.send("CAP REQ :sasl"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
line, err := c.r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connection closed during login: %w", err)
|
||||||
|
}
|
||||||
|
msg := parse(line)
|
||||||
|
switch msg.command {
|
||||||
|
case "PING":
|
||||||
|
_ = c.send("PONG :%s", msg.trailing())
|
||||||
|
case "CAP":
|
||||||
|
// params: <*> ACK :sasl → start PLAIN exchange
|
||||||
|
if len(msg.params) >= 2 && msg.params[1] == "ACK" {
|
||||||
|
_ = c.send("AUTHENTICATE PLAIN")
|
||||||
|
}
|
||||||
|
case "AUTHENTICATE":
|
||||||
|
if msg.first() == "+" {
|
||||||
|
tok := base64.StdEncoding.EncodeToString([]byte("\x00" + nick + "\x00x"))
|
||||||
|
_ = c.send("AUTHENTICATE %s", tok)
|
||||||
|
}
|
||||||
|
case "903": // SASL success → close capability negotiation so we get 001
|
||||||
|
_ = c.send("CAP END")
|
||||||
|
case "900": // RPL_LOGGEDIN (informational; 903 drives CAP END)
|
||||||
|
case "904", "905", "906": // SASL failed/aborted
|
||||||
|
return fmt.Errorf("the network is members-only and rejected %q — register first: ssh join@", nick)
|
||||||
|
case "433": // nick in use
|
||||||
|
return fmt.Errorf("nickname %q is already in use on the network", nick)
|
||||||
|
case "ERROR":
|
||||||
|
return fmt.Errorf("server refused the connection: %s", msg.trailing())
|
||||||
|
case "001": // welcome — we're in
|
||||||
|
_ = c.conn.SetDeadline(time.Time{}) // clear; read loop manages liveness
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLoop turns inbound IRC into Events until the connection drops.
|
||||||
|
func (c *Client) readLoop() {
|
||||||
|
defer close(c.events)
|
||||||
|
for {
|
||||||
|
line, err := c.r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
c.emit(Event{Kind: EvClosed, Text: "disconnected"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := parse(line)
|
||||||
|
switch msg.command {
|
||||||
|
case "PING":
|
||||||
|
_ = c.send("PONG :%s", msg.trailing())
|
||||||
|
case "PRIVMSG":
|
||||||
|
c.emit(Event{Kind: EvMessage, Channel: msg.first(), Nick: msg.nick(), Text: msg.trailing()})
|
||||||
|
case "NOTICE":
|
||||||
|
c.emit(Event{Kind: EvNotice, Channel: msg.first(), Nick: msg.nick(), Text: msg.trailing()})
|
||||||
|
case "JOIN":
|
||||||
|
c.emit(Event{Kind: EvJoin, Channel: msg.first(), Nick: msg.nick()})
|
||||||
|
case "PART":
|
||||||
|
c.emit(Event{Kind: EvPart, Channel: msg.first(), Nick: msg.nick(), Text: msg.trailing()})
|
||||||
|
case "QUIT":
|
||||||
|
c.emit(Event{Kind: EvQuit, Nick: msg.nick(), Text: msg.trailing()})
|
||||||
|
case "NICK":
|
||||||
|
n := msg.nick()
|
||||||
|
if n == c.nick {
|
||||||
|
c.nick = msg.trailing()
|
||||||
|
}
|
||||||
|
c.emit(Event{Kind: EvNick, Nick: n, Text: msg.trailing()})
|
||||||
|
case "332": // RPL_TOPIC
|
||||||
|
c.emit(Event{Kind: EvSystem, Channel: msg.nth(1), Text: "topic: " + msg.trailing()})
|
||||||
|
case "353": // RPL_NAMREPLY
|
||||||
|
c.emit(Event{Kind: EvSystem, Channel: msg.nth(2), Text: "names: " + msg.trailing()})
|
||||||
|
case "ERROR":
|
||||||
|
c.emit(Event{Kind: EvClosed, Text: msg.trailing()})
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// Surface numeric replies (server info, MOTD, errors) as status.
|
||||||
|
if len(msg.command) == 3 && msg.command[0] >= '0' && msg.command[0] <= '9' {
|
||||||
|
c.emit(Event{Kind: EvSystem, Text: msg.trailing()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) emit(e Event) {
|
||||||
|
select {
|
||||||
|
case c.events <- e:
|
||||||
|
default: // drop if the TUI is far behind rather than block the read loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Privmsg sends a message to a channel or nick.
|
||||||
|
func (c *Client) Privmsg(target, text string) error { return c.send("PRIVMSG %s :%s", target, text) }
|
||||||
|
|
||||||
|
// Join joins a channel.
|
||||||
|
func (c *Client) Join(ch string) error { return c.send("JOIN %s", ch) }
|
||||||
|
|
||||||
|
// Part leaves a channel.
|
||||||
|
func (c *Client) Part(ch string) error { return c.send("PART %s", ch) }
|
||||||
|
|
||||||
|
// Raw sends a pre-formed IRC command (for /-commands the TUI doesn't model).
|
||||||
|
func (c *Client) Raw(line string) error { return c.send("%s", line) }
|
||||||
|
|
||||||
|
// Close quits cleanly and tears down the socket.
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
_ = c.send("QUIT :leaving")
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- minimal message parsing ------------------------------------------------
|
||||||
|
|
||||||
|
type message struct {
|
||||||
|
prefix string
|
||||||
|
command string
|
||||||
|
params []string // includes the trailing param as the last element
|
||||||
|
hasTrail bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func parse(line string) message {
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
var m message
|
||||||
|
if strings.HasPrefix(line, "@") { // strip IRCv3 tags; we don't use them here
|
||||||
|
if i := strings.IndexByte(line, ' '); i >= 0 {
|
||||||
|
line = line[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, ":") {
|
||||||
|
i := strings.IndexByte(line, ' ')
|
||||||
|
if i < 0 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
m.prefix = line[1:i]
|
||||||
|
line = line[i+1:]
|
||||||
|
}
|
||||||
|
// trailing param
|
||||||
|
if i := strings.Index(line, " :"); i >= 0 {
|
||||||
|
trail := line[i+2:]
|
||||||
|
line = line[:i]
|
||||||
|
m.command, m.params = splitCmd(line)
|
||||||
|
m.params = append(m.params, trail)
|
||||||
|
m.hasTrail = true
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
m.command, m.params = splitCmd(line)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCmd(s string) (string, []string) {
|
||||||
|
f := strings.Fields(s)
|
||||||
|
if len(f) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return strings.ToUpper(f[0]), f[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// nick returns the nick from the prefix (nick!user@host).
|
||||||
|
func (m message) nick() string {
|
||||||
|
if i := strings.IndexByte(m.prefix, '!'); i >= 0 {
|
||||||
|
return m.prefix[:i]
|
||||||
|
}
|
||||||
|
return m.prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// first returns the first param (often the target/channel), else "".
|
||||||
|
func (m message) first() string { return m.nth(0) }
|
||||||
|
|
||||||
|
func (m message) nth(i int) string {
|
||||||
|
if i >= 0 && i < len(m.params) {
|
||||||
|
return m.params[i]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// trailing returns the trailing (message) param, else the last param.
|
||||||
|
func (m message) trailing() string {
|
||||||
|
if len(m.params) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.params[len(m.params)-1]
|
||||||
|
}
|
||||||
226
internal/irc/tui.go
Normal file
226
internal/irc/tui.go
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
package irc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
const historyLines = 500
|
||||||
|
|
||||||
|
// DefaultChannel is joined automatically when a member enters via irc@.
|
||||||
|
const DefaultChannel = "#lobby"
|
||||||
|
|
||||||
|
var (
|
||||||
|
cTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c084fc"))
|
||||||
|
cNick = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
|
||||||
|
cSelf = lipgloss.NewStyle().Foreground(lipgloss.Color("#38bdf8"))
|
||||||
|
cSys = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||||
|
cNote = lipgloss.NewStyle().Foreground(lipgloss.Color("#fbbf24"))
|
||||||
|
cErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run drives the IRC TUI over the SSH session until the member leaves; leaving
|
||||||
|
// ends the session (irc@ is a dedicated route, like agent@).
|
||||||
|
func Run(s ssh.Session, c *Client) error {
|
||||||
|
ptyReq, winCh, hasPty := s.Pty()
|
||||||
|
if !hasPty {
|
||||||
|
_, _ = s.Write([]byte("irc needs a terminal (ssh -t irc@<host>)\r\n"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := &model{
|
||||||
|
c: c,
|
||||||
|
channel: DefaultChannel,
|
||||||
|
width: ptyReq.Window.Width,
|
||||||
|
height: ptyReq.Window.Height,
|
||||||
|
}
|
||||||
|
m.lines = append(m.lines,
|
||||||
|
cSys.Render(fmt.Sprintf("connected as %s — joined %s. /help for commands, esc to leave.", c.Nick(), DefaultChannel)))
|
||||||
|
p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen())
|
||||||
|
go func() {
|
||||||
|
for w := range winCh {
|
||||||
|
p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, err := p.Run()
|
||||||
|
_ = c.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitEvent blocks on the next connection event and delivers it to the model.
|
||||||
|
func waitEvent(c *Client) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
e, ok := <-c.Events()
|
||||||
|
if !ok {
|
||||||
|
return Event{Kind: EvClosed, Text: "disconnected"}
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
c *Client
|
||||||
|
channel string // current conversation target for typed lines
|
||||||
|
lines []string
|
||||||
|
input string
|
||||||
|
|
||||||
|
width, height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) Init() tea.Cmd { return waitEvent(m.c) }
|
||||||
|
|
||||||
|
func (m *model) push(line string) {
|
||||||
|
m.lines = append(m.lines, line)
|
||||||
|
if len(m.lines) > historyLines {
|
||||||
|
m.lines = m.lines[len(m.lines)-historyLines:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width, m.height = msg.Width, msg.Height
|
||||||
|
case Event:
|
||||||
|
return m.handleEvent(msg)
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c", "esc":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "enter":
|
||||||
|
return m.handleInput()
|
||||||
|
case "backspace":
|
||||||
|
if len(m.input) > 0 {
|
||||||
|
m.input = m.input[:len(m.input)-1]
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if msg.Type == tea.KeyRunes || msg.String() == " " {
|
||||||
|
m.input += string(msg.Runes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) handleEvent(e Event) (tea.Model, tea.Cmd) {
|
||||||
|
switch e.Kind {
|
||||||
|
case EvMessage:
|
||||||
|
where := ""
|
||||||
|
if !strings.HasPrefix(e.Channel, "#") { // a direct message to us
|
||||||
|
where = cNote.Render("[dm] ")
|
||||||
|
}
|
||||||
|
m.push(where + cNick.Render(e.Nick) + " " + e.Text)
|
||||||
|
case EvNotice:
|
||||||
|
m.push(cNote.Render("-"+e.Nick+"- ") + e.Text)
|
||||||
|
case EvJoin:
|
||||||
|
m.push(cSys.Render(fmt.Sprintf("→ %s joined %s", e.Nick, e.Channel)))
|
||||||
|
case EvPart:
|
||||||
|
m.push(cSys.Render(fmt.Sprintf("← %s left %s %s", e.Nick, e.Channel, e.Text)))
|
||||||
|
case EvQuit:
|
||||||
|
m.push(cSys.Render(fmt.Sprintf("← %s quit (%s)", e.Nick, e.Text)))
|
||||||
|
case EvNick:
|
||||||
|
m.push(cSys.Render(fmt.Sprintf("* %s is now %s", e.Nick, e.Text)))
|
||||||
|
case EvSystem:
|
||||||
|
if e.Text != "" {
|
||||||
|
m.push(cSys.Render(e.Text))
|
||||||
|
}
|
||||||
|
case EvClosed:
|
||||||
|
m.push(cErr.Render("* connection closed: " + e.Text))
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
return m, waitEvent(m.c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) handleInput() (tea.Model, tea.Cmd) {
|
||||||
|
text := strings.TrimSpace(m.input)
|
||||||
|
m.input = ""
|
||||||
|
if text == "" {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(text, "/") {
|
||||||
|
return m, m.command(text)
|
||||||
|
}
|
||||||
|
// plain line → message to the current channel
|
||||||
|
if err := m.c.Privmsg(m.channel, text); err != nil {
|
||||||
|
m.push(cErr.Render("send failed: " + err.Error()))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.push(cSelf.Render(m.c.Nick()) + " " + text)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// command handles the small set of /-commands the TUI models; anything else is
|
||||||
|
// passed through raw so power users can drive the server directly.
|
||||||
|
func (m *model) command(text string) tea.Cmd {
|
||||||
|
fields := strings.Fields(text)
|
||||||
|
cmd := strings.ToLower(fields[0])
|
||||||
|
arg := strings.TrimSpace(strings.TrimPrefix(text, fields[0]))
|
||||||
|
switch cmd {
|
||||||
|
case "/help":
|
||||||
|
m.push(cSys.Render("commands: /join #chan /part [#chan] /msg <nick> <text> /me <action> /names /nick <name> /quit"))
|
||||||
|
case "/join":
|
||||||
|
if arg == "" {
|
||||||
|
m.push(cErr.Render("usage: /join #channel"))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
ch := arg
|
||||||
|
if !strings.HasPrefix(ch, "#") {
|
||||||
|
ch = "#" + ch
|
||||||
|
}
|
||||||
|
_ = m.c.Join(ch)
|
||||||
|
m.channel = ch
|
||||||
|
m.push(cSys.Render("joining " + ch))
|
||||||
|
case "/part":
|
||||||
|
ch := m.channel
|
||||||
|
if arg != "" {
|
||||||
|
ch = arg
|
||||||
|
}
|
||||||
|
_ = m.c.Part(ch)
|
||||||
|
m.push(cSys.Render("leaving " + ch))
|
||||||
|
case "/msg":
|
||||||
|
f := strings.SplitN(arg, " ", 2)
|
||||||
|
if len(f) < 2 {
|
||||||
|
m.push(cErr.Render("usage: /msg <nick> <text>"))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
_ = m.c.Privmsg(f[0], f[1])
|
||||||
|
m.push(cSelf.Render(m.c.Nick()) + " " + cNote.Render("→"+f[0]+" ") + f[1])
|
||||||
|
case "/me":
|
||||||
|
if arg == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
_ = m.c.Privmsg(m.channel, "\x01ACTION "+arg+"\x01")
|
||||||
|
m.push(cSelf.Render("* "+m.c.Nick()) + " " + arg)
|
||||||
|
case "/names":
|
||||||
|
_ = m.c.Raw("NAMES " + m.channel)
|
||||||
|
case "/nick":
|
||||||
|
if arg == "" {
|
||||||
|
m.push(cErr.Render("usage: /nick <name>"))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
_ = m.c.Raw("NICK " + arg)
|
||||||
|
case "/quit":
|
||||||
|
return tea.Quit
|
||||||
|
default:
|
||||||
|
_ = m.c.Raw(strings.TrimPrefix(text, "/"))
|
||||||
|
m.push(cSys.Render("» " + strings.TrimPrefix(text, "/")))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) View() string {
|
||||||
|
rows := m.height - 4
|
||||||
|
if rows < 3 {
|
||||||
|
rows = 3
|
||||||
|
}
|
||||||
|
start := 0
|
||||||
|
if len(m.lines) > rows {
|
||||||
|
start = len(m.lines) - rows
|
||||||
|
}
|
||||||
|
body := strings.Join(m.lines[start:], "\n")
|
||||||
|
header := cTitle.Render("irc@ — "+m.channel) + cSys.Render(" ("+m.c.Nick()+" · esc to leave)")
|
||||||
|
prompt := cSys.Render(m.channel+" ") + "› " + m.input + "█"
|
||||||
|
return lipgloss.NewStyle().Padding(0, 1).Render(header + "\n" + body + "\n\n" + prompt)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue