mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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:
commit
336ff00fa3
8 changed files with 92 additions and 720 deletions
|
|
@ -59,7 +59,6 @@ import (
|
||||||
"github.com/profullstack/agentbbs/internal/forwardemail"
|
"github.com/profullstack/agentbbs/internal/forwardemail"
|
||||||
"github.com/profullstack/agentbbs/internal/games"
|
"github.com/profullstack/agentbbs/internal/games"
|
||||||
"github.com/profullstack/agentbbs/internal/hub"
|
"github.com/profullstack/agentbbs/internal/hub"
|
||||||
"github.com/profullstack/agentbbs/internal/irc"
|
|
||||||
"github.com/profullstack/agentbbs/internal/mail"
|
"github.com/profullstack/agentbbs/internal/mail"
|
||||||
"github.com/profullstack/agentbbs/internal/mailbox"
|
"github.com/profullstack/agentbbs/internal/mailbox"
|
||||||
"github.com/profullstack/agentbbs/internal/news"
|
"github.com/profullstack/agentbbs/internal/news"
|
||||||
|
|
@ -207,6 +206,7 @@ func main() {
|
||||||
go func() {
|
go func() {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/verify", a.handleVerify)
|
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")) })
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
|
||||||
log.Info("verify endpoint listening", "addr", verifyAddr)
|
log.Info("verify endpoint listening", "addr", verifyAddr)
|
||||||
srv := &http.Server{Addr: verifyAddr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
srv := &http.Server{Addr: verifyAddr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||||
|
|
@ -314,8 +314,6 @@ func (a *app) router() wish.Middleware {
|
||||||
a.handleTorIRC(s)
|
a.handleTorIRC(s)
|
||||||
case auth.IsTorName(user):
|
case auth.IsTorName(user):
|
||||||
a.handleTorCmd(s)
|
a.handleTorCmd(s)
|
||||||
case auth.IsIRCName(user):
|
|
||||||
a.handleIRC(s)
|
|
||||||
case auth.IsNewsName(user):
|
case auth.IsNewsName(user):
|
||||||
a.handleNews(s)
|
a.handleNews(s)
|
||||||
case auth.IsMailName(user):
|
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) }},
|
Cmd: sessionExec{run: func() error { return a.pods.Attach(s, su.Name) }},
|
||||||
})
|
})
|
||||||
|
|
||||||
// IRC — free for any registered member.
|
// IRC is members-only but accessed with an external client (or web) at
|
||||||
ircLock := ""
|
// irc.profullstack.com — not an in-BBS route — so it's not a hub menu item.
|
||||||
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) }},
|
|
||||||
})
|
|
||||||
|
|
||||||
// News — free for any registered member.
|
// News — free for any registered member.
|
||||||
newsLock := ""
|
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
|
// handleIRCAuth is the loopback endpoint Ergo's auth-script calls to gate the
|
||||||
// an in-process client: it authenticates to Ergo over SASL as the member and
|
// IRC network on BBS membership — the single user-level source of truth. It
|
||||||
// runs a Bubble Tea TUI. Free for any registered member; needs a PTY. Distinct
|
// answers {member, premium} for an account name: only members may connect, and
|
||||||
// from tor-irc@ (a client for remote servers over Tor).
|
// premium (lifetime-paid) drives paid IRC features. Loopback only (shares the
|
||||||
func (a *app) handleIRC(s ssh.Session) {
|
// /verify server), so only the on-box Ergo can reach it. There is no in-BBS
|
||||||
fp := auth.Fingerprint(s.PublicKey())
|
// irc@ route: members use an external client (or web) at irc.profullstack.com.
|
||||||
if fp == "" {
|
func (a *app) handleIRCAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
wish.Println(s, "irc@ needs your registered SSH key. New here? ssh join@"+a.host)
|
name := strings.TrimSpace(r.URL.Query().Get("account"))
|
||||||
_ = s.Exit(1)
|
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
|
return
|
||||||
}
|
}
|
||||||
u, found, err := a.st.UserByFingerprint(fp)
|
// u.Premium is the stored lifetime flag; we don't run the (network) CoinPay
|
||||||
if err != nil || !found {
|
// settlement check on a per-connect auth hook — that happens at join@/hub.
|
||||||
wish.Println(s, "the IRC network is members-only — register first: ssh join@"+a.host)
|
if u.Premium {
|
||||||
_ = s.Exit(1)
|
_, _ = io.WriteString(w, `{"member":true,"premium":true}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if u.Banned {
|
_, _ = io.WriteString(w, `{"member":true,"premium":false}`)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNews drops a member into the BBS's own (members-only) Usenet/NNTP server
|
// handleNews drops a member into the BBS's own (members-only) Usenet/NNTP server
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,22 @@
|
||||||
# membership. setup.sh installs this to /usr/local/bin/ergo-auth-member and
|
# membership. setup.sh installs this to /usr/local/bin/ergo-auth-member and
|
||||||
# wires it into /etc/ergo/ircd.yaml (accounts.auth-script).
|
# wires it into /etc/ergo/ircd.yaml (accounts.auth-script).
|
||||||
#
|
#
|
||||||
# AgentBBS members are real OS users (tilde.town model; setup.sh provisions an
|
# The single source of truth is the BBS user store (the bbs.profullstack.com
|
||||||
# OS account per member). IRC is members-only, so a login is approved iff the
|
# accounts), queried via a loopback agentbbs endpoint (/irc-auth) that answers
|
||||||
# requested account name is a real OS user with uid >= MIN_UID (which excludes
|
# {"member":bool,"premium":bool}. IRC is members-only, so a login is approved iff
|
||||||
# system accounts like root/ergo/agentbbs). The passphrase is intentionally
|
# the account is a member. The passphrase is intentionally IGNORED — BBS
|
||||||
# IGNORED — membership (being an OS user) IS the credential, by design (see
|
# membership IS the credential, by design (see docs/irc.md). Anyone who knows a
|
||||||
# docs/irc.md). Anyone who knows a member's name can connect as them; that
|
# member's name can connect as them; that tradeoff was chosen deliberately for
|
||||||
# tradeoff was chosen deliberately for this private, TLS-only network.
|
# this private, TLS-only network.
|
||||||
#
|
#
|
||||||
# Protocol (Ergo): one JSON object on stdin per attempt, one JSON line on stdout
|
# Protocol (Ergo): one JSON object on stdin per attempt, one JSON line on stdout
|
||||||
# then exit. Input keys: accountName, passphrase, certfp, ip. Output:
|
# then exit. Input keys: accountName, passphrase, certfp, ip. Output:
|
||||||
# {"success":bool,"accountName":str,"error":str}.
|
# {"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
|
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;
|
# 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.
|
# 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" ;;
|
*[!A-Za-z0-9._-]* | "." | ".." ) deny "invalid account name" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Resolve the OS account; getent passwd returns name:passwd:uid:gid:...
|
# Ask the BBS store (loopback) whether this account is a member. curl URL-encodes
|
||||||
entry="$(getent passwd "$acct" 2>/dev/null || true)"
|
# the account name; a failed/timed-out request denies (fail closed).
|
||||||
[ -n "$entry" ] || deny "not a member"
|
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)"
|
if [ "$member" = "true" ]; then
|
||||||
case "$uid" in
|
|
||||||
''|*[!0-9]*) deny "not a member" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ "$uid" -ge "$MIN_UID" ]; then
|
|
||||||
printf '{"success":true,"accountName":"%s"}\n' "$acct"
|
printf '{"success":true,"accountName":"%s"}\n' "$acct"
|
||||||
else
|
else
|
||||||
deny "system accounts cannot use IRC"
|
deny "not a member"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,10 @@
|
||||||
# for a mixed humans + agents network: it is MEMBERS-ONLY — every client must
|
# 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
|
# 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)
|
# (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).
|
# approves a login only if the BBS user store says the account is a member (it
|
||||||
# BBS members are provisioned as OS users (tilde.town model; setup.sh §4b/§9a2),
|
# queries the loopback agentbbs /irc-auth endpoint — the single user-level source
|
||||||
# so "OS user" == "BBS member". Message history (CHATHISTORY) is enabled so
|
# of truth). Message history (CHATHISTORY) is enabled so reconnecting agents and
|
||||||
# reconnecting agents and web clients can replay. See docs/irc.md.
|
# web clients can replay. See docs/irc.md.
|
||||||
#
|
#
|
||||||
# Most settings keep Ergo's recommended defaults — read the inline comments
|
# Most settings keep Ergo's recommended defaults — read the inline comments
|
||||||
# before changing one. A few worth knowing about:
|
# before changing one. A few worth knowing about:
|
||||||
|
|
@ -53,8 +53,8 @@ server:
|
||||||
key: __TLS_DIR__/privkey.pem
|
key: __TLS_DIR__/privkey.pem
|
||||||
min-tls-version: 1.2
|
min-tls-version: 1.2
|
||||||
|
|
||||||
# Loopback plaintext (6667) — never exposed (firewall blocks it). Used by
|
# Loopback plaintext (6667) — never exposed (firewall blocks it). For
|
||||||
# the in-BBS bridge and local tooling on the agentbbs box only.
|
# on-box tooling/bridges only (must still SASL as a member).
|
||||||
"127.0.0.1:6667":
|
"127.0.0.1:6667":
|
||||||
"[::1]:6667":
|
"[::1]:6667":
|
||||||
|
|
||||||
|
|
@ -597,13 +597,14 @@ accounts:
|
||||||
# pluggable authentication mechanism, via subprocess invocation
|
# pluggable authentication mechanism, via subprocess invocation
|
||||||
# see the manual for details on how to write an authentication plugin script
|
# see the manual for details on how to write an authentication plugin script
|
||||||
auth-script:
|
auth-script:
|
||||||
# MEMBERS-ONLY gate: ergo-auth-member approves a login iff the account
|
# MEMBERS-ONLY gate: ergo-auth-member asks the BBS user store (via the
|
||||||
# name is a real OS user with uid >= the arg (BBS members are OS users).
|
# loopback /irc-auth endpoint passed as the arg) whether the account is a
|
||||||
|
# member, and approves the login iff so.
|
||||||
enabled: true
|
enabled: true
|
||||||
command: "/usr/local/bin/ergo-auth-member"
|
command: "/usr/local/bin/ergo-auth-member"
|
||||||
# min-uid is passed as a constant arg (excludes system accounts like
|
# the loopback auth URL is passed as a constant arg; the per-attempt auth
|
||||||
# ergo/root); the per-attempt auth data is sent over stdin/stdout:
|
# data (accountName/passphrase/ip) is sent over stdin/stdout:
|
||||||
args: ["1000"]
|
args: ["__IRC_AUTH_URL__"]
|
||||||
# auto-create the Ergo account on first successful (member) auth, so
|
# auto-create the Ergo account on first successful (member) auth, so
|
||||||
# members never have to register:
|
# members never have to register:
|
||||||
autocreate: true
|
autocreate: true
|
||||||
|
|
|
||||||
89
docs/irc.md
89
docs/irc.md
|
|
@ -13,72 +13,53 @@ user `ergo`), independent of the wish server, under its own hostname
|
||||||
|
|
||||||
| Path | Address | For |
|
| Path | Address | For |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| In-BBS | `ssh -t irc@bbs.profullstack.com` | members — zero-setup built-in client (see below) |
|
|
||||||
| Native TLS | `irc.profullstack.com:6697` (TLS) | desktop/CLI clients (HexChat, irssi, WeeChat, Halloy…) |
|
| 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 |
|
| 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
|
There is **no in-BBS `ssh irc@` route** — members connect with their own IRC
|
||||||
Ergo's loopback `127.0.0.1:8097`), so no extra public port is opened for the web.
|
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
|
The network is **members-only**, and "member" means a **bbs.profullstack.com
|
||||||
no client to install or SASL to configure. It is an **in-process IRC client**
|
account** — a row in the BBS user store (the single user-level source of truth).
|
||||||
(`internal/irc`) running inside the agentbbs process: it reaches Ergo on the
|
There is **no self-service registration**; every client must authenticate with
|
||||||
loopback `127.0.0.1:6667` and authenticates as you (your SSH key already proved
|
SASL, using **your BBS username as the account name**.
|
||||||
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 gate is Ergo's `auth-script`
|
||||||
|
|
||||||
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`
|
|
||||||
([`deploy/ergo/auth-script.sh`](../deploy/ergo/auth-script.sh), installed as
|
([`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
|
`/usr/local/bin/ergo-auth-member`): on each login it asks the loopback agentbbs
|
||||||
real OS user with **uid ≥ 1000** (`getent passwd`), which excludes system
|
endpoint **`/irc-auth?account=<name>`** (served next to `/verify`), which answers
|
||||||
accounts like `root`/`ergo`/`agentbbs`. `accounts.require-sasl` is on,
|
`{"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
|
`accounts.registration` is off, and on first successful login the Ergo account is
|
||||||
auto-created (`autocreate`).
|
auto-created (`autocreate`).
|
||||||
|
|
||||||
Authenticate with SASL using **your BBS username as the account name**. The
|
The passphrase is **ignored** — BBS membership *is* the credential, so put
|
||||||
passphrase is **ignored** — being an OS user *is* the credential, so put anything
|
anything in the password field. (Tradeoff: anyone who knows a member's name can
|
||||||
in the password field. (Tradeoff: anyone who knows a member's name can connect as
|
connect as them; chosen deliberately for this private, TLS-only network.)
|
||||||
them; chosen deliberately for this private, TLS-only, members-only network.)
|
|
||||||
|
|
||||||
> The SASL requirement has **no IP exemption** — web/agent clients reach Ergo
|
> 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
|
> 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
|
> WebSocket client bypass the member check.
|
||||||
> SASL as a member.
|
|
||||||
|
|
||||||
### 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
|
Any member can `/join` existing channels. **Creating** a channel is intended to
|
||||||
Founding Lifetime Member (premium) perk: in the `ssh irc@` client, premium
|
be a Founding Lifetime Member (premium) perk — the `/irc-auth` endpoint already
|
||||||
members run `/create #name`, which joins the fresh channel (Ergo ops the creator)
|
returns each account's `premium` status for exactly this purpose.
|
||||||
and registers it with ChanServ so it persists with the member as **founder**.
|
|
||||||
Free members get an upgrade nudge.
|
|
||||||
|
|
||||||
> **Scope/limitation (v1):** this gate lives in the `irc@` route. Because
|
> **Status:** the premium *enforcement* for channel creation is **not yet wired**.
|
||||||
> `channels.operator-only-creation` is left off (so a member's own connection can
|
> Ergo can't gate creation per-account natively, and the previous `ssh irc@`
|
||||||
> create), a determined member using an *external* client (e.g. HexChat on 6697)
|
> `/create` command was removed with the route. The planned mechanism is
|
||||||
> could still create a channel directly. Full server-side enforcement (oper-only
|
> server-side: `channels.operator-only-creation: true` plus a small agentbbs
|
||||||
> creation + a privileged creation helper that SASLs as a service account) is a
|
> ChanServ-style bot (or oper helper) that creates+registers a channel for a
|
||||||
> follow-up. For a members-only network whose primary client is `ssh irc@`, the
|
> member only when `/irc-auth` reports `premium:true`. Until that lands,
|
||||||
> route-level gate is the intended v1.
|
> `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
|
### 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`)
|
- **Network name:** `ProfullstackBBS` (`IRC_NETWORK` in `setup.sh`)
|
||||||
- **Server name:** `irc.profullstack.com` (`IRC_DOMAIN` 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**
|
- 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
|
- Message history: **in-memory**, ~7-day window, `CHATHISTORY` enabled
|
||||||
|
|
||||||
## Operating it
|
## Operating it
|
||||||
|
|
@ -150,8 +131,8 @@ once it exists.
|
||||||
|
|
||||||
Unrelated, complementary. `ssh tor-irc@bbs.profullstack.com <server>` is a
|
Unrelated, complementary. `ssh tor-irc@bbs.profullstack.com <server>` is a
|
||||||
**client** that connects *out* to a remote (e.g. `.onion`) IRC server from inside
|
**client** that connects *out* to a remote (e.g. `.onion`) IRC server from inside
|
||||||
a member's pod. `irc@` (above) and the 6697/WebSocket listeners are the BBS
|
a member's pod. The 6697/WebSocket listeners (above) are the BBS hosting **its
|
||||||
hosting **its own** IRC network for people and agents to meet on.
|
own** IRC network for people and agents to meet on.
|
||||||
|
|
||||||
## Ideas / next steps
|
## Ideas / next steps
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,9 @@ var TorIRCNames = map[string]bool{"tor-irc": true}
|
||||||
// member's pod (premium). Checked after the more specific tor-* routes.
|
// member's pod (premium). Checked after the more specific tor-* routes.
|
||||||
var TorNames = map[string]bool{"tor": true}
|
var TorNames = map[string]bool{"tor": true}
|
||||||
|
|
||||||
// IRCNames route a member straight into the BBS's own (members-only) IRC
|
// IRCNames is kept only to reserve "irc" as an account/subdomain name: the BBS
|
||||||
// network via an in-process client. Distinct from tor-irc@, which is a client
|
// hosts its own IRC network at irc.<domain> but there is no in-BBS irc@ route —
|
||||||
// for connecting OUT to remote IRC servers over Tor.
|
// members connect with an external client. (Distinct from tor-irc@.)
|
||||||
var IRCNames = map[string]bool{"irc": true}
|
var IRCNames = map[string]bool{"irc": true}
|
||||||
|
|
||||||
// NewsNames route a member into the BBS's own (members-only) Usenet/NNTP server
|
// 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.
|
// IsTorName reports whether the SSH username requests the generic tor passthrough.
|
||||||
func IsTorName(u string) bool { return TorNames[strings.ToLower(u)] }
|
func IsTorName(u string) bool { return TorNames[strings.ToLower(u)] }
|
||||||
|
|
||||||
// IsIRCName reports whether the SSH username requests the in-BBS IRC client.
|
|
||||||
func IsIRCName(u string) bool { return IRCNames[strings.ToLower(u)] }
|
|
||||||
|
|
||||||
// IsNewsName reports whether the SSH username requests the in-BBS newsreader.
|
// IsNewsName reports whether the SSH username requests the in-BBS newsreader.
|
||||||
func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] }
|
func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
40
setup.sh
40
setup.sh
|
|
@ -498,7 +498,7 @@ ${IRC_DOMAIN} {
|
||||||
}
|
}
|
||||||
handle {
|
handle {
|
||||||
header Content-Type \"text/plain; charset=utf-8\"
|
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
|
ufw allow 443/tcp >/dev/null
|
||||||
systemctl reload caddy 2>/dev/null || systemctl restart caddy
|
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) --------
|
# ---- 9b. Ergo IRC server (co-located ${IRC_DOMAIN}; humans + agents) --------
|
||||||
# A lightweight single-binary IRC network on its own ports. Native clients hit
|
# 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});
|
# ${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|__TLS_DIR__|${ERGO_DATA}/tls|g" \
|
||||||
-e "s|__LANG_DIR__|/opt/ergo/languages|g" \
|
-e "s|__LANG_DIR__|/opt/ergo/languages|g" \
|
||||||
-e "s|__OPER_PASSWORD_HASH__|${OPER_HASH}|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
|
"${SRC_DIR}/deploy/ergo/ircd.yaml" > /etc/ergo/ircd.yaml
|
||||||
chmod 640 /etc/ergo/ircd.yaml
|
chmod 640 /etc/ergo/ircd.yaml
|
||||||
|
|
||||||
# IRC is members-only: this auth-script approves a SASL login only if the
|
# IRC is members-only: this auth-script approves a SASL login only if the BBS
|
||||||
# account name is a real OS user (uid>=1000) — i.e. a BBS member, since
|
# user store says the account is a member — it queries agentbbs's loopback
|
||||||
# members are provisioned as OS users in §4b (tilde.town model).
|
# /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
|
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
|
# 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
|
Web https://${DOMAIN}/ site root
|
||||||
https://${DOMAIN}/~<name> a member's homepage
|
https://${DOMAIN}/~<name> a member's homepage
|
||||||
https://<your-domain> a member's homepage on a custom domain (auto-HTTPS)
|
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)}
|
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
|
wss://${DOMAIN}/irc web clients + agents over WebSocket (SASL: your BBS name)
|
||||||
ssh -t irc@${DOMAIN} the in-BBS client (premium: /create #chan)
|
|
||||||
/OPER admin <pw> oper password in ${ENV_DIR}/ergo-oper.txt
|
/OPER admin <pw> oper password in ${ENV_DIR}/ergo-oper.txt
|
||||||
News news.${DOMAIN}:563 (NNTPS) newsreaders + agents ${NEWS:+(set NEWS=0 to disable)}
|
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)
|
ssh -t news@${DOMAIN} the in-BBS newsreader (DNS: news.${DOMAIN} A -> host)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue