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