Merge pull request #15 from profullstack/feat/irc-store-auth

feat(irc): gate IRC on the BBS user store; remove ssh irc@; external clients only
This commit is contained in:
Anthony Ettinger 2026-06-14 20:23:54 -07:00 committed by GitHub
commit 336ff00fa3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 92 additions and 720 deletions

View file

@ -59,7 +59,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/mailbox"
"github.com/profullstack/agentbbs/internal/news"
@ -207,6 +206,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}
@ -314,8 +314,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 auth.IsMailName(user):
@ -451,17 +449,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 := ""
@ -1206,53 +1195,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)
// 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
}
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) }()
// 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

View file

@ -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: ["<min-uid>"] # defaults to 1000
# args: ["<auth-url>"] # 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

View file

@ -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

View file

@ -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 |
| Plaintext | `127.0.0.1:6667` | **loopback only** — on-box tooling; 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.
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.
### `ssh irc@` — the built-in client
### Membership (who can connect) — the BBS user store
`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 <nick> <text>`,
`/me`, `/names`, `/nick`, `/help`, and `esc` to leave. Override the target with
`AGENTBBS_IRC_ADDR` on a dev host.
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**.
### 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`
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=<name>`** (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 <server>` 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

View file

@ -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.<domain> 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
@ -97,9 +97,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)] }

View file

@ -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]
}

View file

@ -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@<host>)\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 <nick> <text> /me <action> /names /nick <name> /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 <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)
}

View file

@ -498,7 +498,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\"
}
}
"
@ -595,32 +595,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});
@ -665,12 +639,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
@ -1020,9 +995,8 @@ cat <<DONE
Web https://${DOMAIN}/ site root
https://${DOMAIN}/~<name> a member's homepage
https://<your-domain> 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 <pw> 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)