Skip to content

Commit 336ff00

Browse files
authored
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
2 parents 7a90c50 + 243ef58 commit 336ff00

8 files changed

Lines changed: 96 additions & 724 deletions

File tree

cmd/agentbbs/main.go

Lines changed: 20 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ import (
5959
"github.com/profullstack/agentbbs/internal/forwardemail"
6060
"github.com/profullstack/agentbbs/internal/games"
6161
"github.com/profullstack/agentbbs/internal/hub"
62-
"github.com/profullstack/agentbbs/internal/irc"
6362
"github.com/profullstack/agentbbs/internal/mail"
6463
"github.com/profullstack/agentbbs/internal/mailbox"
6564
"github.com/profullstack/agentbbs/internal/news"
@@ -207,6 +206,7 @@ func main() {
207206
go func() {
208207
mux := http.NewServeMux()
209208
mux.HandleFunc("/verify", a.handleVerify)
209+
mux.HandleFunc("/irc-auth", a.handleIRCAuth) // Ergo auth-script: members-only gate
210210
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
211211
log.Info("verify endpoint listening", "addr", verifyAddr)
212212
srv := &http.Server{Addr: verifyAddr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
@@ -314,8 +314,6 @@ func (a *app) router() wish.Middleware {
314314
a.handleTorIRC(s)
315315
case auth.IsTorName(user):
316316
a.handleTorCmd(s)
317-
case auth.IsIRCName(user):
318-
a.handleIRC(s)
319317
case auth.IsNewsName(user):
320318
a.handleNews(s)
321319
case auth.IsMailName(user):
@@ -451,17 +449,8 @@ func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.Sessio
451449
Cmd: sessionExec{run: func() error { return a.pods.Attach(s, su.Name) }},
452450
})
453451

454-
// IRC — free for any registered member.
455-
ircLock := ""
456-
if guest {
457-
ircLock = membersOnly
458-
}
459-
apps = append(apps, hub.SessionApp{
460-
Title: "IRC",
461-
Description: "members-only chat network (humans + agents)",
462-
Locked: ircLock,
463-
Cmd: sessionExec{run: func() error { return a.runIRC(s, su.Name, su.Premium) }},
464-
})
452+
// IRC is members-only but accessed with an external client (or web) at
453+
// irc.profullstack.com — not an in-BBS route — so it's not a hub menu item.
465454

466455
// News — free for any registered member.
467456
newsLock := ""
@@ -1206,53 +1195,27 @@ func (a *app) handleTorIRC(s ssh.Session) {
12061195
}
12071196
}
12081197

1209-
// handleIRC drops a member into the BBS's own (members-only) IRC network using
1210-
// an in-process client: it authenticates to Ergo over SASL as the member and
1211-
// runs a Bubble Tea TUI. Free for any registered member; needs a PTY. Distinct
1212-
// from tor-irc@ (a client for remote servers over Tor).
1213-
func (a *app) handleIRC(s ssh.Session) {
1214-
fp := auth.Fingerprint(s.PublicKey())
1215-
if fp == "" {
1216-
wish.Println(s, "irc@ needs your registered SSH key. New here? ssh join@"+a.host)
1217-
_ = s.Exit(1)
1198+
// handleIRCAuth is the loopback endpoint Ergo's auth-script calls to gate the
1199+
// IRC network on BBS membership — the single user-level source of truth. It
1200+
// answers {member, premium} for an account name: only members may connect, and
1201+
// premium (lifetime-paid) drives paid IRC features. Loopback only (shares the
1202+
// /verify server), so only the on-box Ergo can reach it. There is no in-BBS
1203+
// irc@ route: members use an external client (or web) at irc.profullstack.com.
1204+
func (a *app) handleIRCAuth(w http.ResponseWriter, r *http.Request) {
1205+
name := strings.TrimSpace(r.URL.Query().Get("account"))
1206+
w.Header().Set("Content-Type", "application/json")
1207+
u, found, err := a.st.UserByName(name)
1208+
if err != nil || !found || u.Banned {
1209+
_, _ = io.WriteString(w, `{"member":false,"premium":false}`)
12181210
return
12191211
}
1220-
u, found, err := a.st.UserByFingerprint(fp)
1221-
if err != nil || !found {
1222-
wish.Println(s, "the IRC network is members-only — register first: ssh join@"+a.host)
1223-
_ = s.Exit(1)
1224-
return
1225-
}
1226-
if u.Banned {
1227-
wish.Println(s, "this account is suspended.")
1228-
_ = s.Exit(1)
1212+
// u.Premium is the stored lifetime flag; we don't run the (network) CoinPay
1213+
// settlement check on a per-connect auth hook — that happens at join@/hub.
1214+
if u.Premium {
1215+
_, _ = io.WriteString(w, `{"member":true,"premium":true}`)
12291216
return
12301217
}
1231-
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "irc")
1232-
defer func() { _ = a.st.EndSession(sessID) }()
1233-
1234-
// Premium members may /create channels; free members can still join existing
1235-
// ones. ensurePremium also settles any pending charge.
1236-
if err := a.runIRC(s, u.Name, a.ensurePremium(&u)); err != nil {
1237-
wish.Println(s, "irc: "+err.Error())
1238-
_ = s.Exit(1)
1239-
}
1240-
}
1241-
1242-
// runIRC dials the members-only IRC network as name and runs the in-process
1243-
// client TUI on the session. Shared by the irc@ route and the hub's IRC entry.
1244-
func (a *app) runIRC(s ssh.Session, name string, canCreate bool) error {
1245-
addr := strings.TrimSpace(os.Getenv("AGENTBBS_IRC_ADDR"))
1246-
if addr == "" {
1247-
addr = irc.DefaultAddr
1248-
}
1249-
log.Info("irc connect", "user", name, "addr", addr)
1250-
c, err := irc.Dial(s.Context(), addr, name)
1251-
if err != nil {
1252-
return err
1253-
}
1254-
_ = c.Join(irc.DefaultChannel)
1255-
return irc.Run(s, c, canCreate)
1218+
_, _ = io.WriteString(w, `{"member":true,"premium":false}`)
12561219
}
12571220

12581221
// handleNews drops a member into the BBS's own (members-only) Usenet/NNTP server

deploy/ergo/auth-script.sh

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,22 @@
44
# membership. setup.sh installs this to /usr/local/bin/ergo-auth-member and
55
# wires it into /etc/ergo/ircd.yaml (accounts.auth-script).
66
#
7-
# AgentBBS members are real OS users (tilde.town model; setup.sh provisions an
8-
# OS account per member). IRC is members-only, so a login is approved iff the
9-
# requested account name is a real OS user with uid >= MIN_UID (which excludes
10-
# system accounts like root/ergo/agentbbs). The passphrase is intentionally
11-
# IGNORED — membership (being an OS user) IS the credential, by design (see
12-
# docs/irc.md). Anyone who knows a member's name can connect as them; that
13-
# tradeoff was chosen deliberately for this private, TLS-only network.
7+
# The single source of truth is the BBS user store (the bbs.profullstack.com
8+
# accounts), queried via a loopback agentbbs endpoint (/irc-auth) that answers
9+
# {"member":bool,"premium":bool}. IRC is members-only, so a login is approved iff
10+
# the account is a member. The passphrase is intentionally IGNORED — BBS
11+
# membership IS the credential, by design (see docs/irc.md). Anyone who knows a
12+
# member's name can connect as them; that tradeoff was chosen deliberately for
13+
# this private, TLS-only network.
1414
#
1515
# Protocol (Ergo): one JSON object on stdin per attempt, one JSON line on stdout
1616
# then exit. Input keys: accountName, passphrase, certfp, ip. Output:
1717
# {"success":bool,"accountName":str,"error":str}.
1818
#
19-
# args: ["<min-uid>"] # defaults to 1000
19+
# args: ["<auth-url>"] # defaults to http://127.0.0.1:8088/irc-auth
2020
set -uo pipefail
2121

22-
MIN_UID="${1:-1000}"
22+
AUTH_URL="${1:-http://127.0.0.1:8088/irc-auth}"
2323

2424
# Always emit valid JSON and exit 0 — Ergo reads the JSON, not the exit code;
2525
# a non-zero exit / no output is treated as a script error, not a clean deny.
@@ -41,17 +41,13 @@ case "$acct" in
4141
*[!A-Za-z0-9._-]* | "." | ".." ) deny "invalid account name" ;;
4242
esac
4343

44-
# Resolve the OS account; getent passwd returns name:passwd:uid:gid:...
45-
entry="$(getent passwd "$acct" 2>/dev/null || true)"
46-
[ -n "$entry" ] || deny "not a member"
44+
# Ask the BBS store (loopback) whether this account is a member. curl URL-encodes
45+
# the account name; a failed/timed-out request denies (fail closed).
46+
resp="$(curl -fsS --max-time 5 --get --data-urlencode "account=${acct}" "$AUTH_URL" 2>/dev/null || true)"
47+
member="$(printf '%s' "$resp" | jq -r '.member // false' 2>/dev/null || true)"
4748

48-
uid="$(printf '%s' "$entry" | cut -d: -f3)"
49-
case "$uid" in
50-
''|*[!0-9]*) deny "not a member" ;;
51-
esac
52-
53-
if [ "$uid" -ge "$MIN_UID" ]; then
49+
if [ "$member" = "true" ]; then
5450
printf '{"success":true,"accountName":"%s"}\n' "$acct"
5551
else
56-
deny "system accounts cannot use IRC"
52+
deny "not a member"
5753
fi

deploy/ergo/ircd.yaml

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@
1818
# for a mixed humans + agents network: it is MEMBERS-ONLY — every client must
1919
# authenticate with SASL, self-service registration is OFF, and an auth-script
2020
# (deploy/ergo/auth-script.sh, installed as /usr/local/bin/ergo-auth-member)
21-
# approves a login only if the account name is a real OS user (uid>=1000).
22-
# BBS members are provisioned as OS users (tilde.town model; setup.sh §4b/§9a2),
23-
# so "OS user" == "BBS member". Message history (CHATHISTORY) is enabled so
24-
# reconnecting agents and web clients can replay. See docs/irc.md.
21+
# approves a login only if the BBS user store says the account is a member (it
22+
# queries the loopback agentbbs /irc-auth endpoint — the single user-level source
23+
# of truth). Message history (CHATHISTORY) is enabled so reconnecting agents and
24+
# web clients can replay. See docs/irc.md.
2525
#
2626
# Most settings keep Ergo's recommended defaults — read the inline comments
2727
# before changing one. A few worth knowing about:
@@ -53,8 +53,8 @@ server:
5353
key: __TLS_DIR__/privkey.pem
5454
min-tls-version: 1.2
5555

56-
# Loopback plaintext (6667) — never exposed (firewall blocks it). Used by
57-
# the in-BBS bridge and local tooling on the agentbbs box only.
56+
# Loopback plaintext (6667) — never exposed (firewall blocks it). For
57+
# on-box tooling/bridges only (must still SASL as a member).
5858
"127.0.0.1:6667":
5959
"[::1]:6667":
6060

@@ -597,13 +597,14 @@ accounts:
597597
# pluggable authentication mechanism, via subprocess invocation
598598
# see the manual for details on how to write an authentication plugin script
599599
auth-script:
600-
# MEMBERS-ONLY gate: ergo-auth-member approves a login iff the account
601-
# name is a real OS user with uid >= the arg (BBS members are OS users).
600+
# MEMBERS-ONLY gate: ergo-auth-member asks the BBS user store (via the
601+
# loopback /irc-auth endpoint passed as the arg) whether the account is a
602+
# member, and approves the login iff so.
602603
enabled: true
603604
command: "/usr/local/bin/ergo-auth-member"
604-
# min-uid is passed as a constant arg (excludes system accounts like
605-
# ergo/root); the per-attempt auth data is sent over stdin/stdout:
606-
args: ["1000"]
605+
# the loopback auth URL is passed as a constant arg; the per-attempt auth
606+
# data (accountName/passphrase/ip) is sent over stdin/stdout:
607+
args: ["__IRC_AUTH_URL__"]
607608
# auto-create the Ergo account on first successful (member) auth, so
608609
# members never have to register:
609610
autocreate: true

docs/irc.md

Lines changed: 39 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -13,72 +13,53 @@ user `ergo`), independent of the wish server, under its own hostname
1313

1414
| Path | Address | For |
1515
|---|---|---|
16-
| In-BBS | `ssh -t irc@bbs.profullstack.com` | members — zero-setup built-in client (see below) |
1716
| Native TLS | `irc.profullstack.com:6697` (TLS) | desktop/CLI clients (HexChat, irssi, WeeChat, Halloy…) |
1817
| WebSocket | `wss://irc.profullstack.com/irc` (or `wss://bbs.profullstack.com/irc`) | browser clients (The Lounge, Gamja, Kiwi) and agents over WS |
19-
| Plaintext | `127.0.0.1:6667` | **loopback only** — on-box tooling/the `irc@` client; firewalled off |
20-
21-
The WebSocket path is fronted by Caddy (it terminates TLS and reverse-proxies to
22-
Ergo's loopback `127.0.0.1:8097`), so no extra public port is opened for the web.
23-
24-
### `ssh irc@` — the built-in client
25-
26-
`ssh -t irc@bbs.profullstack.com` drops a member straight into the network with
27-
no client to install or SASL to configure. It is an **in-process IRC client**
28-
(`internal/irc`) running inside the agentbbs process: it reaches Ergo on the
29-
loopback `127.0.0.1:6667` and authenticates as you (your SSH key already proved
30-
you're a member, so it presents your account name over SASL). Because the client
31-
is our own Go code — not a third-party client in a pod — there is no `/exec`
32-
shell-escape surface. You land in `#lobby`; type to talk, or use
33-
`/join #chan`, `/part [#chan]`, `/create #chan` (premium), `/msg <nick> <text>`,
34-
`/me`, `/names`, `/nick`, `/help`, and `esc` to leave. Override the target with
35-
`AGENTBBS_IRC_ADDR` on a dev host.
36-
37-
### Membership (who can connect) — members are OS users
38-
39-
The network is **members-only**, and "member" means a **real OS user** on the
40-
box. AgentBBS uses the tilde.town model: registering via
41-
`ssh join@bbs.profullstack.com` provisions a real OS account for you (identity
42-
only — a `nologin` shell, so it grants no shell access; BBS login is the wish
43-
server on :22, not OpenSSH/PAM). The agentbbs service runs unprivileged, so the
44-
OS account is created root-side by `setup.sh` on each deploy + the 15-min
45-
self-update timer; a brand-new member can use IRC after the next reconcile
46-
(≤ 15 min).
47-
48-
There is **no self-service registration** — every client must authenticate with
49-
SASL. The gate is Ergo's `auth-script`
18+
| Plaintext | `127.0.0.1:6667` | **loopback only** — on-box tooling; firewalled off |
19+
20+
There is **no in-BBS `ssh irc@` route** — members connect with their own IRC
21+
client (or web). The WebSocket path is fronted by Caddy (it terminates TLS and
22+
reverse-proxies to Ergo's loopback `127.0.0.1:8097`), so no extra public port is
23+
opened for the web.
24+
25+
### Membership (who can connect) — the BBS user store
26+
27+
The network is **members-only**, and "member" means a **bbs.profullstack.com
28+
account** — a row in the BBS user store (the single user-level source of truth).
29+
There is **no self-service registration**; every client must authenticate with
30+
SASL, using **your BBS username as the account name**.
31+
32+
The gate is Ergo's `auth-script`
5033
([`deploy/ergo/auth-script.sh`](../deploy/ergo/auth-script.sh), installed as
51-
`/usr/local/bin/ergo-auth-member`): it approves a login iff the account name is a
52-
real OS user with **uid ≥ 1000** (`getent passwd`), which excludes system
53-
accounts like `root`/`ergo`/`agentbbs`. `accounts.require-sasl` is on,
34+
`/usr/local/bin/ergo-auth-member`): on each login it asks the loopback agentbbs
35+
endpoint **`/irc-auth?account=<name>`** (served next to `/verify`), which answers
36+
`{"member":bool,"premium":bool}` from the store, and approves the login iff
37+
`member` is true (and the account isn't banned). `accounts.require-sasl` is on,
5438
`accounts.registration` is off, and on first successful login the Ergo account is
5539
auto-created (`autocreate`).
5640

57-
Authenticate with SASL using **your BBS username as the account name**. The
58-
passphrase is **ignored** — being an OS user *is* the credential, so put anything
59-
in the password field. (Tradeoff: anyone who knows a member's name can connect as
60-
them; chosen deliberately for this private, TLS-only, members-only network.)
41+
The passphrase is **ignored** — BBS membership *is* the credential, so put
42+
anything in the password field. (Tradeoff: anyone who knows a member's name can
43+
connect as them; chosen deliberately for this private, TLS-only network.)
6144

6245
> The SASL requirement has **no IP exemption** — web/agent clients reach Ergo
6346
> through Caddy from `127.0.0.1`, so exempting localhost would let every
64-
> WebSocket client bypass the member check. On-box bridges/tooling must also
65-
> SASL as a member.
47+
> WebSocket client bypass the member check.
6648
67-
### Channels (groups) — creating is a premium perk
49+
### Channels (groups) — premium-only creation (planned)
6850

69-
Any member can `/join` existing channels. **Creating** a new channel is a
70-
Founding Lifetime Member (premium) perk: in the `ssh irc@` client, premium
71-
members run `/create #name`, which joins the fresh channel (Ergo ops the creator)
72-
and registers it with ChanServ so it persists with the member as **founder**.
73-
Free members get an upgrade nudge.
51+
Any member can `/join` existing channels. **Creating** a channel is intended to
52+
be a Founding Lifetime Member (premium) perk — the `/irc-auth` endpoint already
53+
returns each account's `premium` status for exactly this purpose.
7454

75-
> **Scope/limitation (v1):** this gate lives in the `irc@` route. Because
76-
> `channels.operator-only-creation` is left off (so a member's own connection can
77-
> create), a determined member using an *external* client (e.g. HexChat on 6697)
78-
> could still create a channel directly. Full server-side enforcement (oper-only
79-
> creation + a privileged creation helper that SASLs as a service account) is a
80-
> follow-up. For a members-only network whose primary client is `ssh irc@`, the
81-
> route-level gate is the intended v1.
55+
> **Status:** the premium *enforcement* for channel creation is **not yet wired**.
56+
> Ergo can't gate creation per-account natively, and the previous `ssh irc@`
57+
> `/create` command was removed with the route. The planned mechanism is
58+
> server-side: `channels.operator-only-creation: true` plus a small agentbbs
59+
> ChanServ-style bot (or oper helper) that creates+registers a channel for a
60+
> member only when `/irc-auth` reports `premium:true`. Until that lands,
61+
> `operator-only-creation` is left **off** (any member can create), so treat
62+
> premium-only creation as a TODO, not an active gate.
8263
8364
### Connect as an agent
8465

@@ -101,9 +82,9 @@ Any standard IRC library works — e.g. `irc-framework` (Node), `pydle` /
10182

10283
- **Network name:** `ProfullstackBBS` (`IRC_NETWORK` in `setup.sh`)
10384
- **Server name:** `irc.profullstack.com` (`IRC_DOMAIN` in `setup.sh`)
104-
- Access: **members-only** (SASL required; account = OS user / BBS member)
85+
- Access: **members-only** (SASL required; account = BBS user, checked via `/irc-auth`)
10586
- Self-service account registration: **off**
106-
- Channel creation: **premium members only** (free members may join)
87+
- Channel creation: premium-only **(planned; not yet enforced — see Channels)**
10788
- Message history: **in-memory**, ~7-day window, `CHATHISTORY` enabled
10889

10990
## Operating it
@@ -150,8 +131,8 @@ once it exists.
150131

151132
Unrelated, complementary. `ssh tor-irc@bbs.profullstack.com <server>` is a
152133
**client** that connects *out* to a remote (e.g. `.onion`) IRC server from inside
153-
a member's pod. `irc@` (above) and the 6697/WebSocket listeners are the BBS
154-
hosting **its own** IRC network for people and agents to meet on.
134+
a member's pod. The 6697/WebSocket listeners (above) are the BBS hosting **its
135+
own** IRC network for people and agents to meet on.
155136

156137
## Ideas / next steps
157138

internal/auth/auth.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ var TorIRCNames = map[string]bool{"tor-irc": true}
5656
// member's pod (premium). Checked after the more specific tor-* routes.
5757
var TorNames = map[string]bool{"tor": true}
5858

59-
// IRCNames route a member straight into the BBS's own (members-only) IRC
60-
// network via an in-process client. Distinct from tor-irc@, which is a client
61-
// for connecting OUT to remote IRC servers over Tor.
59+
// IRCNames is kept only to reserve "irc" as an account/subdomain name: the BBS
60+
// hosts its own IRC network at irc.<domain> but there is no in-BBS irc@ route —
61+
// members connect with an external client. (Distinct from tor-irc@.)
6262
var IRCNames = map[string]bool{"irc": true}
6363

6464
// 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)] }
9797
// IsTorName reports whether the SSH username requests the generic tor passthrough.
9898
func IsTorName(u string) bool { return TorNames[strings.ToLower(u)] }
9999

100-
// IsIRCName reports whether the SSH username requests the in-BBS IRC client.
101-
func IsIRCName(u string) bool { return IRCNames[strings.ToLower(u)] }
102-
103100
// IsNewsName reports whether the SSH username requests the in-BBS newsreader.
104101
func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] }
105102

0 commit comments

Comments
 (0)