Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 20 additions & 57 deletions cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 := ""
Expand Down Expand Up @@ -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
Expand Down
34 changes: 15 additions & 19 deletions deploy/ergo/auth-script.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
23 changes: 12 additions & 11 deletions deploy/ergo/ircd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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":

Expand Down Expand Up @@ -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
Expand Down
97 changes: 39 additions & 58 deletions docs/irc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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) — 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=<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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
9 changes: 3 additions & 6 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)] }

Expand Down
Loading
Loading