diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 7e166a4..85ac2ef 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -58,7 +58,6 @@ 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/news" "github.com/profullstack/agentbbs/internal/payments" @@ -205,6 +204,7 @@ func main() { go func() { mux := http.NewServeMux() mux.HandleFunc("/verify", a.handleVerify) + mux.HandleFunc("/irc-auth", a.handleIRCAuth) // Ergo auth-script: members-only gate mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) log.Info("verify endpoint listening", "addr", verifyAddr) srv := &http.Server{Addr: verifyAddr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} @@ -312,8 +312,6 @@ func (a *app) router() wish.Middleware { a.handleTorIRC(s) case auth.IsTorName(user): a.handleTorCmd(s) - case auth.IsIRCName(user): - a.handleIRC(s) case auth.IsNewsName(user): a.handleNews(s) case isVideo: @@ -450,17 +448,8 @@ func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.Sessio Cmd: sessionExec{run: func() error { return a.pods.Attach(s, su.Name) }}, }) - // IRC — free for any registered member. - ircLock := "" - if guest { - ircLock = membersOnly - } - apps = append(apps, hub.SessionApp{ - Title: "IRC", - Description: "members-only chat network (humans + agents)", - Locked: ircLock, - Cmd: sessionExec{run: func() error { return a.runIRC(s, su.Name, su.Premium) }}, - }) + // IRC is members-only but accessed with an external client (or web) at + // irc.profullstack.com — not an in-BBS route — so it's not a hub menu item. // News — free for any registered member. newsLock := "" @@ -1183,53 +1172,27 @@ 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) +// handleIRCAuth is the loopback endpoint Ergo's auth-script calls to gate the +// IRC network on BBS membership — the single user-level source of truth. It +// answers {member, premium} for an account name: only members may connect, and +// premium (lifetime-paid) drives paid IRC features. Loopback only (shares the +// /verify server), so only the on-box Ergo can reach it. There is no in-BBS +// irc@ route: members use an external client (or web) at irc.profullstack.com. +func (a *app) handleIRCAuth(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.URL.Query().Get("account")) + w.Header().Set("Content-Type", "application/json") + u, found, err := a.st.UserByName(name) + if err != nil || !found || u.Banned { + _, _ = io.WriteString(w, `{"member":false,"premium":false}`) 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) + // u.Premium is the stored lifetime flag; we don't run the (network) CoinPay + // settlement check on a per-connect auth hook — that happens at join@/hub. + if u.Premium { + _, _ = io.WriteString(w, `{"member":true,"premium":true}`) return } - sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "irc") - defer func() { _ = a.st.EndSession(sessID) }() - - // Premium members may /create channels; free members can still join existing - // ones. ensurePremium also settles any pending charge. - if err := a.runIRC(s, u.Name, a.ensurePremium(&u)); err != nil { - wish.Println(s, "irc: "+err.Error()) - _ = s.Exit(1) - } -} - -// runIRC dials the members-only IRC network as name and runs the in-process -// client TUI on the session. Shared by the irc@ route and the hub's IRC entry. -func (a *app) runIRC(s ssh.Session, name string, canCreate bool) error { - addr := strings.TrimSpace(os.Getenv("AGENTBBS_IRC_ADDR")) - if addr == "" { - addr = irc.DefaultAddr - } - log.Info("irc connect", "user", name, "addr", addr) - c, err := irc.Dial(s.Context(), addr, name) - if err != nil { - return err - } - _ = c.Join(irc.DefaultChannel) - return irc.Run(s, c, canCreate) + _, _ = io.WriteString(w, `{"member":true,"premium":false}`) } // handleNews drops a member into the BBS's own (members-only) Usenet/NNTP server diff --git a/deploy/ergo/auth-script.sh b/deploy/ergo/auth-script.sh index e45d74a..042b05f 100644 --- a/deploy/ergo/auth-script.sh +++ b/deploy/ergo/auth-script.sh @@ -4,22 +4,22 @@ # membership. setup.sh installs this to /usr/local/bin/ergo-auth-member and # wires it into /etc/ergo/ircd.yaml (accounts.auth-script). # -# AgentBBS members are real OS users (tilde.town model; setup.sh provisions an -# OS account per member). IRC is members-only, so a login is approved iff the -# requested account name is a real OS user with uid >= MIN_UID (which excludes -# system accounts like root/ergo/agentbbs). The passphrase is intentionally -# IGNORED — membership (being an OS user) IS the credential, by design (see -# docs/irc.md). Anyone who knows a member's name can connect as them; that -# tradeoff was chosen deliberately for this private, TLS-only network. +# The single source of truth is the BBS user store (the bbs.profullstack.com +# accounts), queried via a loopback agentbbs endpoint (/irc-auth) that answers +# {"member":bool,"premium":bool}. IRC is members-only, so a login is approved iff +# the account is a member. The passphrase is intentionally IGNORED — BBS +# membership IS the credential, by design (see docs/irc.md). Anyone who knows a +# member's name can connect as them; that tradeoff was chosen deliberately for +# this private, TLS-only network. # # Protocol (Ergo): one JSON object on stdin per attempt, one JSON line on stdout # then exit. Input keys: accountName, passphrase, certfp, ip. Output: # {"success":bool,"accountName":str,"error":str}. # -# args: [""] # defaults to 1000 +# args: [""] # defaults to http://127.0.0.1:8088/irc-auth set -uo pipefail -MIN_UID="${1:-1000}" +AUTH_URL="${1:-http://127.0.0.1:8088/irc-auth}" # Always emit valid JSON and exit 0 — Ergo reads the JSON, not the exit code; # a non-zero exit / no output is treated as a script error, not a clean deny. @@ -41,17 +41,13 @@ case "$acct" in *[!A-Za-z0-9._-]* | "." | ".." ) deny "invalid account name" ;; esac -# Resolve the OS account; getent passwd returns name:passwd:uid:gid:... -entry="$(getent passwd "$acct" 2>/dev/null || true)" -[ -n "$entry" ] || deny "not a member" +# Ask the BBS store (loopback) whether this account is a member. curl URL-encodes +# the account name; a failed/timed-out request denies (fail closed). +resp="$(curl -fsS --max-time 5 --get --data-urlencode "account=${acct}" "$AUTH_URL" 2>/dev/null || true)" +member="$(printf '%s' "$resp" | jq -r '.member // false' 2>/dev/null || true)" -uid="$(printf '%s' "$entry" | cut -d: -f3)" -case "$uid" in - ''|*[!0-9]*) deny "not a member" ;; -esac - -if [ "$uid" -ge "$MIN_UID" ]; then +if [ "$member" = "true" ]; then printf '{"success":true,"accountName":"%s"}\n' "$acct" else - deny "system accounts cannot use IRC" + deny "not a member" fi diff --git a/deploy/ergo/ircd.yaml b/deploy/ergo/ircd.yaml index 8d500ed..3564e63 100644 --- a/deploy/ergo/ircd.yaml +++ b/deploy/ergo/ircd.yaml @@ -18,10 +18,10 @@ # for a mixed humans + agents network: it is MEMBERS-ONLY — every client must # authenticate with SASL, self-service registration is OFF, and an auth-script # (deploy/ergo/auth-script.sh, installed as /usr/local/bin/ergo-auth-member) -# approves a login only if the account name is a real OS user (uid>=1000). -# BBS members are provisioned as OS users (tilde.town model; setup.sh §4b/§9a2), -# so "OS user" == "BBS member". Message history (CHATHISTORY) is enabled so -# reconnecting agents and web clients can replay. See docs/irc.md. +# approves a login only if the BBS user store says the account is a member (it +# queries the loopback agentbbs /irc-auth endpoint — the single user-level source +# of truth). Message history (CHATHISTORY) is enabled so reconnecting agents and +# web clients can replay. See docs/irc.md. # # Most settings keep Ergo's recommended defaults — read the inline comments # before changing one. A few worth knowing about: @@ -53,8 +53,8 @@ server: key: __TLS_DIR__/privkey.pem min-tls-version: 1.2 - # Loopback plaintext (6667) — never exposed (firewall blocks it). Used by - # the in-BBS bridge and local tooling on the agentbbs box only. + # Loopback plaintext (6667) — never exposed (firewall blocks it). For + # on-box tooling/bridges only (must still SASL as a member). "127.0.0.1:6667": "[::1]:6667": @@ -597,13 +597,14 @@ accounts: # pluggable authentication mechanism, via subprocess invocation # see the manual for details on how to write an authentication plugin script auth-script: - # MEMBERS-ONLY gate: ergo-auth-member approves a login iff the account - # name is a real OS user with uid >= the arg (BBS members are OS users). + # MEMBERS-ONLY gate: ergo-auth-member asks the BBS user store (via the + # loopback /irc-auth endpoint passed as the arg) whether the account is a + # member, and approves the login iff so. enabled: true command: "/usr/local/bin/ergo-auth-member" - # min-uid is passed as a constant arg (excludes system accounts like - # ergo/root); the per-attempt auth data is sent over stdin/stdout: - args: ["1000"] + # the loopback auth URL is passed as a constant arg; the per-attempt auth + # data (accountName/passphrase/ip) is sent over stdin/stdout: + args: ["__IRC_AUTH_URL__"] # auto-create the Ergo account on first successful (member) auth, so # members never have to register: autocreate: true diff --git a/docs/irc.md b/docs/irc.md index 3a7b03b..ba0edf8 100644 --- a/docs/irc.md +++ b/docs/irc.md @@ -13,72 +13,53 @@ user `ergo`), independent of the wish server, under its own hostname | Path | Address | For | |---|---|---| -| In-BBS | `ssh -t irc@bbs.profullstack.com` | members — zero-setup built-in client (see below) | | Native TLS | `irc.profullstack.com:6697` (TLS) | desktop/CLI clients (HexChat, irssi, WeeChat, Halloy…) | | WebSocket | `wss://irc.profullstack.com/irc` (or `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/the `irc@` client; firewalled off | - -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. - -### `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`, `/part [#chan]`, `/create #chan` (premium), `/msg `, -`/me`, `/names`, `/nick`, `/help`, and `esc` to leave. Override the target with -`AGENTBBS_IRC_ADDR` on a dev host. - -### Membership (who can connect) — members are OS users - -The network is **members-only**, and "member" means a **real OS user** on the -box. AgentBBS uses the tilde.town model: registering via -`ssh join@bbs.profullstack.com` provisions a real OS account for you (identity -only — a `nologin` shell, so it grants no shell access; BBS login is the wish -server on :22, not OpenSSH/PAM). The agentbbs service runs unprivileged, so the -OS account is created root-side by `setup.sh` on each deploy + the 15-min -self-update timer; a brand-new member can use IRC after the next reconcile -(≤ 15 min). - -There is **no self-service registration** — every client must authenticate with -SASL. The gate is Ergo's `auth-script` +| Plaintext | `127.0.0.1:6667` | **loopback only** — on-box tooling; firewalled off | + +There is **no in-BBS `ssh irc@` route** — members connect with their own IRC +client (or web). 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. + +### Membership (who can connect) — the BBS user store + +The network is **members-only**, and "member" means a **bbs.profullstack.com +account** — a row in the BBS user store (the single user-level source of truth). +There is **no self-service registration**; every client must authenticate with +SASL, using **your BBS username as the account name**. + +The gate is Ergo's `auth-script` ([`deploy/ergo/auth-script.sh`](../deploy/ergo/auth-script.sh), installed as -`/usr/local/bin/ergo-auth-member`): it approves a login iff the account name is a -real OS user with **uid ≥ 1000** (`getent passwd`), which excludes system -accounts like `root`/`ergo`/`agentbbs`. `accounts.require-sasl` is on, +`/usr/local/bin/ergo-auth-member`): on each login it asks the loopback agentbbs +endpoint **`/irc-auth?account=`** (served next to `/verify`), which answers +`{"member":bool,"premium":bool}` from the store, and approves the login iff +`member` is true (and the account isn't banned). `accounts.require-sasl` is on, `accounts.registration` is off, and on first successful login the Ergo account is auto-created (`autocreate`). -Authenticate with SASL using **your BBS username as the account name**. The -passphrase is **ignored** — being an OS user *is* the credential, so put anything -in the password field. (Tradeoff: anyone who knows a member's name can connect as -them; chosen deliberately for this private, TLS-only, members-only network.) +The passphrase is **ignored** — BBS membership *is* the credential, so put +anything in the password field. (Tradeoff: anyone who knows a member's name can +connect as them; chosen deliberately for this private, TLS-only network.) > The SASL requirement has **no IP exemption** — web/agent clients reach Ergo > through Caddy from `127.0.0.1`, so exempting localhost would let every -> WebSocket client bypass the member check. On-box bridges/tooling must also -> SASL as a member. +> WebSocket client bypass the member check. -### Channels (groups) — creating is a premium perk +### Channels (groups) — premium-only creation (planned) -Any member can `/join` existing channels. **Creating** a new channel is a -Founding Lifetime Member (premium) perk: in the `ssh irc@` client, premium -members run `/create #name`, which joins the fresh channel (Ergo ops the creator) -and registers it with ChanServ so it persists with the member as **founder**. -Free members get an upgrade nudge. +Any member can `/join` existing channels. **Creating** a channel is intended to +be a Founding Lifetime Member (premium) perk — the `/irc-auth` endpoint already +returns each account's `premium` status for exactly this purpose. -> **Scope/limitation (v1):** this gate lives in the `irc@` route. Because -> `channels.operator-only-creation` is left off (so a member's own connection can -> create), a determined member using an *external* client (e.g. HexChat on 6697) -> could still create a channel directly. Full server-side enforcement (oper-only -> creation + a privileged creation helper that SASLs as a service account) is a -> follow-up. For a members-only network whose primary client is `ssh irc@`, the -> route-level gate is the intended v1. +> **Status:** the premium *enforcement* for channel creation is **not yet wired**. +> Ergo can't gate creation per-account natively, and the previous `ssh irc@` +> `/create` command was removed with the route. The planned mechanism is +> server-side: `channels.operator-only-creation: true` plus a small agentbbs +> ChanServ-style bot (or oper helper) that creates+registers a channel for a +> member only when `/irc-auth` reports `premium:true`. Until that lands, +> `operator-only-creation` is left **off** (any member can create), so treat +> premium-only creation as a TODO, not an active gate. ### Connect as an agent @@ -101,9 +82,9 @@ Any standard IRC library works — e.g. `irc-framework` (Node), `pydle` / - **Network name:** `ProfullstackBBS` (`IRC_NETWORK` in `setup.sh`) - **Server name:** `irc.profullstack.com` (`IRC_DOMAIN` in `setup.sh`) -- Access: **members-only** (SASL required; account = OS user / BBS member) +- Access: **members-only** (SASL required; account = BBS user, checked via `/irc-auth`) - Self-service account registration: **off** -- Channel creation: **premium members only** (free members may join) +- Channel creation: premium-only **(planned; not yet enforced — see Channels)** - Message history: **in-memory**, ~7-day window, `CHATHISTORY` enabled ## Operating it @@ -150,8 +131,8 @@ once it exists. Unrelated, complementary. `ssh tor-irc@bbs.profullstack.com ` is a **client** that connects *out* to a remote (e.g. `.onion`) IRC server from inside -a member's pod. `irc@` (above) and the 6697/WebSocket listeners are the BBS -hosting **its own** IRC network for people and agents to meet on. +a member's pod. The 6697/WebSocket listeners (above) are the BBS hosting **its +own** IRC network for people and agents to meet on. ## Ideas / next steps diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e8dcfe8..79ea516 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -56,9 +56,9 @@ var TorIRCNames = map[string]bool{"tor-irc": true} // member's pod (premium). Checked after the more specific tor-* routes. 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. +// IRCNames is kept only to reserve "irc" as an account/subdomain name: the BBS +// hosts its own IRC network at irc. but there is no in-BBS irc@ route — +// members connect with an external client. (Distinct from tor-irc@.) var IRCNames = map[string]bool{"irc": true} // NewsNames route a member into the BBS's own (members-only) Usenet/NNTP server @@ -93,9 +93,6 @@ func IsTorIRCName(u string) bool { return TorIRCNames[strings.ToLower(u)] } // IsTorName reports whether the SSH username requests the generic tor passthrough. 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)] } - // IsNewsName reports whether the SSH username requests the in-BBS newsreader. func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] } diff --git a/internal/irc/client.go b/internal/irc/client.go deleted file mode 100644 index 84a7868..0000000 --- a/internal/irc/client.go +++ /dev/null @@ -1,291 +0,0 @@ -// 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] -} diff --git a/internal/irc/tui.go b/internal/irc/tui.go deleted file mode 100644 index 9123adf..0000000 --- a/internal/irc/tui.go +++ /dev/null @@ -1,249 +0,0 @@ -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@). canCreate gates the -// /create command on premium membership. -func Run(s ssh.Session, c *Client, canCreate bool) error { - ptyReq, winCh, hasPty := s.Pty() - if !hasPty { - _, _ = s.Write([]byte("irc needs a terminal (ssh -t irc@)\r\n")) - return nil - } - m := &model{ - c: c, - channel: DefaultChannel, - canCreate: canCreate, - 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 - canCreate bool // premium members may /create (register) new channels - 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] /create #chan /msg /me /names /nick /quit")) - case "/create": - if !m.canCreate { - m.push(cErr.Render("creating channels is a Founding Lifetime Member perk — upgrade with: ssh join@ (the BBS). You can still /join existing channels.")) - break - } - ch := arg - if ch == "" { - m.push(cErr.Render("usage: /create #channel")) - break - } - if !strings.HasPrefix(ch, "#") { - ch = "#" + ch - } - // operator-only-creation is off, so joining a fresh channel creates it and - // ops the creator; registering it with ChanServ makes it persist with you - // as founder. (Both run in order on this connection.) - _ = m.c.Join(ch) - _ = m.c.Privmsg("ChanServ", "REGISTER "+ch) - m.channel = ch - m.push(cSys.Render("created " + ch + " — you're the founder. Share the name so members can /join it.")) - 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 ")) - 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 ")) - 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) -} diff --git a/setup.sh b/setup.sh index e8f586f..fb4f137 100755 --- a/setup.sh +++ b/setup.sh @@ -496,7 +496,7 @@ ${IRC_DOMAIN} { } handle { header Content-Type \"text/plain; charset=utf-8\" - respond \"AgentBBS IRC (members-only). Native: ${IRC_DOMAIN}:6697 (TLS), SASL as your BBS name. Web/agents: wss://${IRC_DOMAIN}/irc. Or from the BBS: ssh -t irc@${DOMAIN}\" + respond \"AgentBBS IRC (members-only). Native: ${IRC_DOMAIN}:6697 (TLS), SASL as your BBS name (any password). Web/agents: wss://${IRC_DOMAIN}/irc\" } } " @@ -579,32 +579,6 @@ ufw allow 80/tcp >/dev/null ufw allow 443/tcp >/dev/null systemctl reload caddy 2>/dev/null || systemctl restart caddy -# ---- 9a2. members are OS users (tilde.town model) -------------------------- -# Every BBS member gets a real OS account. It is IDENTITY ONLY — a nologin shell, -# so it grants no shell access (BBS login is the wish server on :22, authenticated -# against the sqlite store, not OpenSSH/PAM). This matches the tilde.town shape -# and is what lets the IRC network gate on `getent passwd`. The agentbbs service -# runs unprivileged and can't useradd, so we reconcile here (root) on every deploy -# + the 15-min self-update timer: create an account for any member home dir that -# lacks one. Existing members are migrated on the first run; new members get their -# OS account within one reconcile (<= ${SELF_UPDATE_INTERVAL}). -log "reconciling member OS users from ${DATA_DIR}/users" -getent group members >/dev/null 2>&1 || groupadd --system members -if [ -d "${DATA_DIR}/users" ]; then - for d in "${DATA_DIR}"/users/*/; do - [ -d "$d" ] || continue - name="$(basename "$d")" - # Only valid member/login names; skip anything already taken (incl. system users). - case "$name" in *[!a-z0-9._-]* | "" ) continue ;; esac - id "$name" >/dev/null 2>&1 && continue - # Non-system account (uid auto-assigned >=1000, matching the IRC auth-script - # gate), home = the member's existing data dir, no shell. - useradd --no-create-home --home-dir "$d" --shell /usr/sbin/nologin --gid members "$name" 2>/dev/null \ - && log " + OS user ${name}" \ - || warn " could not create OS user ${name}" - done -fi - # ---- 9b. Ergo IRC server (co-located ${IRC_DOMAIN}; humans + agents) -------- # A lightweight single-binary IRC network on its own ports. Native clients hit # ${IRC_DOMAIN}:6697 (TLS, using Caddy's Let's Encrypt cert for ${IRC_DOMAIN}); @@ -649,12 +623,13 @@ if [ "$IRC" = "1" ]; then -e "s|__TLS_DIR__|${ERGO_DATA}/tls|g" \ -e "s|__LANG_DIR__|/opt/ergo/languages|g" \ -e "s|__OPER_PASSWORD_HASH__|${OPER_HASH}|g" \ + -e "s|__IRC_AUTH_URL__|http://${HTTP_ADDR}/irc-auth|g" \ "${SRC_DIR}/deploy/ergo/ircd.yaml" > /etc/ergo/ircd.yaml chmod 640 /etc/ergo/ircd.yaml - # IRC is members-only: this auth-script approves a SASL login only if the - # account name is a real OS user (uid>=1000) — i.e. a BBS member, since - # members are provisioned as OS users in §4b (tilde.town model). + # IRC is members-only: this auth-script approves a SASL login only if the BBS + # user store says the account is a member — it queries agentbbs's loopback + # /irc-auth endpoint (the single user-level source of truth). Needs jq + curl. install -m 0755 "${SRC_DIR}/deploy/ergo/auth-script.sh" /usr/local/bin/ergo-auth-member # TLS for 6697: reuse Caddy's Let's Encrypt cert for ${IRC_DOMAIN}; self-signed @@ -949,9 +924,8 @@ cat < a member's homepage https:// a member's homepage on a custom domain (auto-HTTPS) - IRC ${IRC_DOMAIN}:6697 (TLS) native clients (DNS: ${IRC_DOMAIN} A -> host) ${IRC:+(set IRC=0 to disable)} - wss://${DOMAIN}/irc web clients + agents over WebSocket - ssh -t irc@${DOMAIN} the in-BBS client (premium: /create #chan) + IRC ${IRC_DOMAIN}:6697 (TLS) external clients (DNS: ${IRC_DOMAIN} A -> host) ${IRC:+(set IRC=0 to disable)} + wss://${DOMAIN}/irc web clients + agents over WebSocket (SASL: your BBS name) /OPER admin oper password in ${ENV_DIR}/ergo-oper.txt News news.${DOMAIN}:563 (NNTPS) newsreaders + agents ${NEWS:+(set NEWS=0 to disable)} ssh -t news@${DOMAIN} the in-BBS newsreader (DNS: news.${DOMAIN} A -> host)