From d4ada98b6910c0b671f35c43c246286f85b49a9d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 14 Jun 2026 15:22:12 +0000 Subject: [PATCH 1/2] One login: pod/IRC/news/Tor as hub menu items + single-login onboarding The join@ output and hub previously pushed members to ssh into separate servers (ssh pod@, ssh irc@, ssh news@, ssh tor@). Members now reach everything from one `ssh @bbs.profullstack.com` login: - hub: new SessionApp entries (Pod, IRC, News, Tor shell) run as terminal-takeover features via tea.Exec, then return to the menu. Gated by membership/email-verification/plan; shown locked otherwise. - main: build the session apps in teaHandler; extract runIRC/runNews so the irc@/news@ routes and the hub share one path. The old pod@/irc@/ news@/tor@ routes stay as aliases (handy for bots). - onboarding: rewrite the "You're in" and Founding-Member copy to present ONE login and stop advertising separate servers. - email: member mailboxes move to mail.profullstack.com (apex reserved for corporate mail); webmail shown at https://mail.profullstack.com. - username: harden the default handle to a safe /home/ token. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 202 ++++++++++++++++++++++++++++++++++--------- internal/hub/hub.go | 113 +++++++++++++++++++----- 2 files changed, 252 insertions(+), 63 deletions(-) diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 20d9c88..ed21424 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -150,7 +150,9 @@ func main() { host := env("AGENTBBS_HOST", "bbs.profullstack.com") fe := forwardemail.ConfigFromEnv() if fe.Domain == "" { - fe.Domain = host // personal addresses live on the BBS host by default + // Member mailboxes live on a dedicated mail subdomain (mail.profullstack.com), + // not the BBS host and not the apex (which is reserved for corporate mail). + fe.Domain = env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com") } a := &app{ st: st, @@ -332,7 +334,9 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { username := strings.ToLower(s.User()) var u auth.User - if auth.IsGuestName(username) || fp == "" { + var su store.User + guest := auth.IsGuestName(username) || fp == "" + if guest { // Keyless or explicitly anonymous → guest. Named accounts require a key. if !auth.IsGuestName(username) { wish.Println(s, "note: member access requires an SSH key; joining as guest.") @@ -341,7 +345,9 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { } else { // A key maps to exactly one account: if this key is already // registered, that identity wins regardless of the username typed. - su, found, err := a.st.UserByFingerprint(fp) + var found bool + var err error + su, found, err = a.st.UserByFingerprint(fp) if err == nil && !found { su, err = a.st.EnsureUser(username, string(auth.KindFor(username)), fp) } @@ -377,7 +383,88 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { // URL works the moment a member first signs in. seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host) } - return hub.New(u, ctx, a.enabledPlugins()), []tea.ProgramOption{tea.WithAltScreen()} + return hub.New(u, ctx, a.enabledPlugins(), a.sessionApps(s, su, guest)), []tea.ProgramOption{tea.WithAltScreen()} +} + +// sessionExec adapts a func to tea.ExecCommand so the hub can run a +// terminal-takeover feature (pod shell, IRC, news, mail, Tor) via tea.Exec and +// return to the menu afterwards. The feature reads and writes the ssh.Session +// directly, so the stream hooks are no-ops. +type sessionExec struct{ run func() error } + +func (e sessionExec) Run() error { return e.run() } +func (e sessionExec) SetStdin(io.Reader) {} +func (e sessionExec) SetStdout(io.Writer) {} +func (e sessionExec) SetStderr(io.Writer) {} + +// sessionApps builds the hub's terminal-takeover entries (pod, IRC, news, Tor) +// so a member reaches everything from one `ssh @host` login instead of +// separate `ssh pod@`/`irc@`/`news@`/`tor@` connections (which still work as +// aliases, mainly for bots). Each entry is gated by membership/verification/plan +// and shown locked with a reason when unavailable. +func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.SessionApp { + membersOnly := "members only — register first: ssh join@" + a.host + apps := make([]hub.SessionApp, 0, 4) + + // Pod — free for verified members. + podLock := "" + switch { + case guest: + podLock = membersOnly + case env("AGENTBBS_REQUIRE_VERIFIED_EMAIL", "1") != "0" && !su.EmailVerified: + podLock = "confirm your email first — re-run: ssh join@" + a.host + case a.pods == nil: + podLock = "pods are temporarily unavailable on this host" + } + apps = append(apps, hub.SessionApp{ + Title: "Pod", + Description: "your own Linux pod — a full shell", + Locked: podLock, + 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) }}, + }) + + // News — free for any registered member. + newsLock := "" + if guest { + newsLock = membersOnly + } + apps = append(apps, hub.SessionApp{ + Title: "News", + Description: "members-only Usenet/NNTP discussion", + Locked: newsLock, + Cmd: sessionExec{run: func() error { return a.runNews(s, su.Name) }}, + }) + + // Tor — a Founding Lifetime Member perk: a torsocks shell in the pod. + torLock := "" + switch { + case guest: + torLock = membersOnly + case !su.Premium: + torLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host + case a.pods == nil: + torLock = "pods are temporarily unavailable on this host" + } + apps = append(apps, hub.SessionApp{ + Title: "Tor shell", + Description: "a torsocks shell in your pod (everything over Tor)", + Locked: torLock, + Cmd: sessionExec{run: func() error { return a.pods.Exec(s, su.Name, tor.Torsocks([]string{"bash", "-l"})) }}, + }) + + return apps } // readLine reads one line of interactive input from an SSH session that is @@ -464,7 +551,7 @@ func (a *app) handleJoin(s ssh.Session) { }, "\n")) // 1) email -> emailed code -> enter code. A verified account is a free - // member: it gets a Docker pod (ssh pod@) and a /~name homepage. + // member: it gets a Docker pod, IRC/news, and a /~name homepage, all from the hub. if !u.EmailVerified { if !a.verifyEmailInteractive(s, in, &u) { _ = s.Exit(1) @@ -477,10 +564,15 @@ func (a *app) handleJoin(s ssh.Session) { seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host) wish.Println(s, "\n"+strings.Join([]string{ - " You're in — free membership includes:", - " pod ssh pod@" + a.host + " your own Linux pod", - " hub ssh " + u.Name + "@" + a.host, - " homepage https://" + a.host + "/~" + u.Name, + " You're in. One login gets you everything — no other servers to ssh into:", + "", + " ssh " + u.Name + "@" + a.host, + "", + " Inside, free membership includes:", + " • your own Linux pod (a full shell)", + " • IRC chat + Usenet/news (members-only)", + " • the arcade & games", + " • your homepage https://" + a.host + "/~" + u.Name, }, "\n")) // 2) Founding Lifetime ($99 one-time): personal @host email + custom domains. @@ -515,14 +607,35 @@ func (a *app) acceptTerms(s ssh.Session, in *bufio.Reader) bool { } } +// fpToken derives up to n lowercase alphanumeric characters from an SSH key +// fingerprint, for use in a default username/home-dir. The raw base64 +// fingerprint can contain '+' and '/', which are unsafe as a filesystem token, +// so we keep only [a-z0-9]. +func fpToken(fp string, n int) string { + cleaned := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + return r + default: + return -1 + } + }, strings.ToLower(strings.TrimPrefix(fp, "SHA256:"))) + if len(cleaned) > n { + cleaned = cleaned[:n] + } + return cleaned +} + // registerNewMember asks the visitor to choose a username, then creates their // member account under it. The name is sanitized to the hub/subdomain charset, // rejected if reserved, and must be free; pressing enter accepts a generated -// member- default. Returns the created user. +// member- default. The chosen name is also the member's pod home +// (/home/), so it must be a safe shell/filesystem token — the sanitizer +// (and the default below) keep it to lowercase [a-z0-9-]. Returns the user. func (a *app) registerNewMember(s ssh.Session, in *bufio.Reader, fp string) (store.User, error) { - def := "member-" + strings.ToLower(strings.TrimPrefix(fp, "SHA256:"))[:8] + def := "member-" + fpToken(fp, 8) wish.Println(s, "\n Pick a username — letters, numbers and dashes, 3–20 chars.") - wish.Println(s, " It's your handle for ssh @"+a.host+" and https://"+a.host+"/~.") + wish.Println(s, " It's your handle for ssh @"+a.host+" (your pod home is /home/).") for tries := 0; tries < 5; tries++ { wish.Print(s, "\n Username ["+def+"]: ") @@ -651,23 +764,19 @@ func (a *app) ensurePremium(u *store.User) bool { return true } -// showPremiumWelcome prints a premium member's perks: their personal email, -// where it forwards, the webmail URL, and custom domains. +// showPremiumWelcome prints a premium member's perks: their mailbox, the webmail +// URL, the in-hub Mail/Tor entries, and custom domains. func (a *app) showPremiumWelcome(s ssh.Session, u store.User) { lines := []string{ "", " ★ Founding Lifetime Member — thanks! Your perks:", "", - " email " + a.fe.Address(u.Name), - " forwards " + u.Email, - } - if url := a.fe.WebmailURL(); url != "" { - lines = append(lines, " webmail "+url) - } - lines = append(lines, - " domains ssh domain@"+a.host+" add ", + " mailbox " + a.fe.Address(u.Name), + " webmail https://" + a.fe.Domain, + " mail/tor pick “Mail” or “Tor shell” in the hub: ssh " + u.Name + "@" + a.host, + " domains ssh domain@" + a.host + " add ", "", - ) + } wish.Println(s, strings.Join(lines, "\n")) } @@ -691,10 +800,10 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) { "", " Everything in your free membership stays free — founding adds these", " bonus features, forever:", - " • your own email " + a.fe.Address(u.Name) + " (forwards to you, webmail included)", - " • custom domains point yourdomain.com at your homepage", - " • Tor access ssh tor@" + a.host + " — fetch URLs & join IRC over Tor", - " • locked-in price founding rate is yours for life — never renew, never pay again", + " • your own mailbox " + a.fe.Address(u.Name) + " (webmail: https://" + a.fe.Domain + ")", + " • custom domains point yourdomain.com at your homepage", + " • Tor a “Tor shell” in your pod — everything over Tor", + " • locked-in price founding rate is yours for life — never renew, never pay again", "", } if c, ok, err := payments.CreatePremiumCharge(ref); ok && err == nil { @@ -1071,21 +1180,28 @@ func (a *app) handleIRC(s ssh.Session) { 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", u.Name, "addr", addr) - c, err := irc.Dial(s.Context(), addr, u.Name) + log.Info("irc connect", "user", name, "addr", addr) + c, err := irc.Dial(s.Context(), addr, name) if err != nil { - wish.Println(s, "irc: "+err.Error()) - _ = s.Exit(1) - return + return err } _ = c.Join(irc.DefaultChannel) - if err := irc.Run(s, c); err != nil { - wish.Println(s, "irc: "+err.Error()) - } + return irc.Run(s, c, canCreate) } // handleNews drops a member into the BBS's own (members-only) Usenet/NNTP server @@ -1114,15 +1230,21 @@ func (a *app) handleNews(s ssh.Session) { sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "news") defer func() { _ = a.st.EndSession(sessID) }() + if err := a.runNews(s, u.Name); err != nil { + wish.Println(s, "news: "+err.Error()) + _ = s.Exit(1) + } +} + +// runNews runs the in-process newsreader TUI for name on the session. Shared by +// the news@ route and the hub's News entry. +func (a *app) runNews(s ssh.Session, name string) error { addr := a.newsAddr if addr == "" { addr = news.DefaultAddr } - log.Info("news connect", "user", u.Name, "addr", addr) - if err := news.RunReader(s, addr, u.Name); err != nil { - wish.Println(s, "news: "+err.Error()) - _ = s.Exit(1) - } + log.Info("news connect", "user", name, "addr", addr) + return news.RunReader(s, addr, name) } // handleTorCmd runs an arbitrary command through Tor (torsocks) inside the diff --git a/internal/hub/hub.go b/internal/hub/hub.go index b23bb20..c06c5bc 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -1,6 +1,13 @@ // Package hub is the Bubble Tea model every BBS session lands in (PRD §4.1): // it lists registered plugins and routes the session to the selection, // reclaiming it when the plugin emits ExitMsg. +// +// Besides in-hub plugins, the hub also lists "session apps" — features that take +// over the whole terminal (a pod shell, the IRC client, the newsreader, the mail +// reader, Tor) rather than rendering inside the hub. Selecting one suspends the +// hub via tea.Exec, runs it, and returns to the menu. This is what lets a member +// reach everything from a single `ssh @host` login instead of separate +// `ssh pod@` / `ssh irc@` / `ssh news@` connections. package hub import ( @@ -21,11 +28,30 @@ var ( frameStyle = lipgloss.NewStyle().Padding(1, 2) ) +// SessionApp is a hub entry that takes over the terminal — a pod shell, the IRC +// client, the newsreader, the mail reader, or Tor — instead of running as an +// in-hub model. Selecting it suspends the hub (tea.Exec), runs Cmd, then returns +// to the menu. +type SessionApp struct { + Title string + Description string + // Locked, when non-empty, marks the entry visible-but-unavailable (e.g. a + // paid feature for a free member, or members-only for a guest). The reason + // is shown when the entry is selected; the app cannot be launched. + Locked string + // Cmd is the terminal-takeover command run under tea.Exec. + Cmd tea.ExecCommand +} + +// appExitMsg is delivered when a SessionApp launched via tea.Exec returns. +type appExitMsg struct{ err error } + // Model is the hub menu. type Model struct { user auth.User ctx plugin.Context plugins []plugin.Plugin + apps []SessionApp cursor int active tea.Model @@ -34,19 +60,31 @@ type Model struct { note string } -// New builds a hub for one session. -func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin) Model { - return Model{user: user, ctx: ctx, plugins: plugins} +// New builds a hub for one session. apps are terminal-takeover features listed +// after the in-hub plugins (may be nil). +func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin, apps []SessionApp) Model { + return Model{user: user, ctx: ctx, plugins: plugins, apps: apps} } func (m Model) Init() tea.Cmd { return nil } +// entries is the total number of selectable rows (plugins then apps). +func (m Model) entries() int { return len(m.plugins) + len(m.apps) } + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Window size is shared with whichever model is active. if ws, ok := msg.(tea.WindowSizeMsg); ok { m.width, m.height = ws.Width, ws.Height } + // A returning session app (tea.Exec finished) hands control back here. + if ae, ok := msg.(appExitMsg); ok { + if ae.err != nil { + m.note = ae.err.Error() + } + return m, nil + } + // A plugin owns the session until it emits ExitMsg (PRD §4.3). if m.active != nil { if _, ok := msg.(plugin.ExitMsg); ok { @@ -69,44 +107,64 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursor-- } case "down", "j": - if m.cursor < len(m.plugins)-1 { + if m.cursor < m.entries()-1 { m.cursor++ } case "enter": - p := m.plugins[m.cursor] - if p.RequiresAuth() && m.user.Kind == auth.Guest { - m.note = "members only — ssh join@ to register" - return m, nil - } - m.active = p.New(m.user, m.ctx) - cmds := []tea.Cmd{m.active.Init()} - if m.width > 0 { - next, cmd := m.active.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - m.active = next - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) + return m.selectEntry() } } return m, nil } +// selectEntry launches whatever the cursor is on: an in-hub plugin or a session app. +func (m Model) selectEntry() (tea.Model, tea.Cmd) { + if m.cursor < len(m.plugins) { + p := m.plugins[m.cursor] + if p.RequiresAuth() && m.user.Kind == auth.Guest { + m.note = "members only — ssh join@ to register" + return m, nil + } + m.active = p.New(m.user, m.ctx) + cmds := []tea.Cmd{m.active.Init()} + if m.width > 0 { + next, cmd := m.active.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.active = next + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + } + + app := m.apps[m.cursor-len(m.plugins)] + if app.Locked != "" { + m.note = app.Locked + return m, nil + } + return m, tea.Exec(app.Cmd, func(err error) tea.Msg { return appExitMsg{err} }) +} + func (m Model) View() string { if m.active != nil { return m.active.View() } who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind) s := titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n\n" - for i, p := range m.plugins { - cur := " " - if i == m.cursor { - cur = cursorStyle.Render("> ") - } + row := 0 + for _, p := range m.plugins { label := p.Title() if p.RequiresAuth() && m.user.Kind == auth.Guest { label += lockStyle.Render(" [members]") } - s += fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(p.Description())) + s += m.renderRow(row, label, p.Description()) + row++ + } + for _, app := range m.apps { + label := app.Title + if app.Locked != "" { + label += lockStyle.Render(" [locked]") + } + s += m.renderRow(row, label, app.Description) + row++ } s += "\n" + dimStyle.Render("↑/↓ move · enter select · q quit") if m.note != "" { @@ -114,3 +172,12 @@ func (m Model) View() string { } return frameStyle.Render(s) } + +// renderRow renders one menu line with the cursor and dimmed description. +func (m Model) renderRow(i int, label, desc string) string { + cur := " " + if i == m.cursor { + cur = cursorStyle.Render("> ") + } + return fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(desc)) +} From 5eb1e96480e8b5ff84b52e8881e98d49b378318a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 14 Jun 2026 15:23:44 +0000 Subject: [PATCH 2/2] feat(irc): irc.profullstack.com host, OS-user (tilde.town) gate, premium channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hostname: serve the network as irc.profullstack.com (new IRC_DOMAIN var, default irc.). Caddy serves an irc.profullstack.com site so it gets a Let's Encrypt cert; ergo-refresh-certs copies that into Ergo for 6697. Needs an A record irc.profullstack.com -> the box (self-signed until it resolves). Members are OS users (tilde.town model): setup.sh reconciles a real OS account per member dir (root-side, on each deploy + the 15-min timer; nologin shell, so identity-only — no shell access). The IRC auth-script now gates on `getent passwd` with uid>=1000 instead of the member dir, so "OS user" == member. Premium channels: free members may /join; creating channels is a premium perk. The ssh irc@ client gains /create #name (premium-gated via ensurePremium): it joins the fresh channel and registers it with ChanServ as the member's founder. v1 gate is route-level (operator-only-creation left off); external-client creation hardening is a follow-up. See docs/irc.md. Co-Authored-By: Claude Opus 4.8 --- deploy/ergo/auth-script.sh | 35 +++++++++----- deploy/ergo/ircd.yaml | 21 ++++---- docs/irc.md | 99 ++++++++++++++++++++++++-------------- internal/irc/tui.go | 45 ++++++++++++----- setup.sh | 85 +++++++++++++++++++++++++------- 5 files changed, 196 insertions(+), 89 deletions(-) diff --git a/deploy/ergo/auth-script.sh b/deploy/ergo/auth-script.sh index 77f4332..e45d74a 100644 --- a/deploy/ergo/auth-script.sh +++ b/deploy/ergo/auth-script.sh @@ -4,21 +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). # -# "Member" == a user with a home dir under the AgentBBS users dir (created when -# someone registers via `ssh join@`). IRC is members-only, so a login is -# approved iff the requested account name maps to such a dir. The passphrase is -# intentionally IGNORED — membership (a filesystem dir) 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. +# 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. # # 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: [""] # defaults to /var/lib/agentbbs/users +# args: [""] # defaults to 1000 set -uo pipefail -USERS_DIR="${1:-/var/lib/agentbbs/users}" +MIN_UID="${1:-1000}" # 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. @@ -35,14 +36,22 @@ acct="$(printf '%s' "$line" | jq -r '.accountName // ""' 2>/dev/null || true)" # certfp-only attempts carry no account name; we don't support cert auth here. [ -n "$acct" ] || deny "membership requires an account name" -# Defense in depth against path traversal. IRC account names are a restricted -# charset anyway, but never let one escape USERS_DIR. +# Restrict to plain login names (defense in depth; IRC names are limited anyway). case "$acct" in - *[!A-Za-z0-9._-]* | "." | ".." | *..* | */* ) deny "invalid account name" ;; + *[!A-Za-z0-9._-]* | "." | ".." ) deny "invalid account name" ;; esac -if [ -d "$USERS_DIR/$acct" ]; then +# 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" + +uid="$(printf '%s' "$entry" | cut -d: -f3)" +case "$uid" in + ''|*[!0-9]*) deny "not a member" ;; +esac + +if [ "$uid" -ge "$MIN_UID" ]; then printf '{"success":true,"accountName":"%s"}\n' "$acct" else - deny "not a member" + deny "system accounts cannot use IRC" fi diff --git a/deploy/ergo/ircd.yaml b/deploy/ergo/ircd.yaml index 9d68282..8d500ed 100644 --- a/deploy/ergo/ircd.yaml +++ b/deploy/ergo/ircd.yaml @@ -6,11 +6,11 @@ # # __NETWORK__ network name shown to clients (e.g. ProfullstackBBS) # __DOMAIN__ the BBS domain (e.g. bbs.profullstack.com) +# __IRC_DOMAIN__ the IRC server name + TLS cert host (e.g. irc.profullstack.com) # __DATA__ Ergo state dir (ircd.db lives here) # __TLS_DIR__ dir holding fullchain.pem / privkey.pem for 6697 # __LANG_DIR__ Ergo's bundled languages/ dir (from the release) # __OPER_PASSWORD_HASH__ bcrypt hash for /OPER admin (ergo genpasswd) -# __USERS_DIR__ AgentBBS member home dirs (the IRC membership gate) # # It is based on Ergo's upstream default.yaml (v2.18.0) with the AgentBBS # listeners (public 6697 TLS, loopback 6667, loopback 8097 WebSocket fronted @@ -18,15 +18,16 @@ # 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 maps to an existing AgentBBS member -# home dir under __USERS_DIR__. Message history (CHATHISTORY) is enabled so +# 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. # # Most settings keep Ergo's recommended defaults — read the inline comments # before changing one. A few worth knowing about: # 1. network.name / server.name — the network identity (rendered from tokens). # 2. server.listeners — the 6697 cert/key are refreshed from Caddy's Let's -# Encrypt cert for __DOMAIN__ by setup.sh (self-signed fallback on first run). +# Encrypt cert for __IRC_DOMAIN__ by setup.sh (self-signed fallback on first run). # 3. opers — the /OPER admin password hash (rendered from a token). # 4. history — in-memory, messages expire after ~7 days. Switch to MySQL-backed # persistent history (datastore.mysql) if you need durability across restarts. @@ -39,13 +40,13 @@ network: # server configuration server: # server name - name: irc.__DOMAIN__ + name: __IRC_DOMAIN__ # addresses to listen on listeners: # Public TLS (6697) — the front door for native IRC clients (humans) and # SASL-authenticating agents. Cert/key are refreshed from Caddy's - # Let's Encrypt cert for __DOMAIN__ by setup.sh (falls back to self-signed). + # Let's Encrypt cert for __IRC_DOMAIN__ by setup.sh (falls back to self-signed). ":6697": tls: cert: __TLS_DIR__/fullchain.pem @@ -597,12 +598,12 @@ accounts: # 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 maps to an existing AgentBBS member home dir (passed as the arg). + # name is a real OS user with uid >= the arg (BBS members are OS users). enabled: true command: "/usr/local/bin/ergo-auth-member" - # the AgentBBS users dir is passed as a constant arg; the per-attempt - # auth data (accountName/passphrase/ip) is sent over stdin/stdout: - args: ["__USERS_DIR__"] + # 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"] # auto-create the Ergo account on first successful (member) auth, so # members never have to register: autocreate: true diff --git a/docs/irc.md b/docs/irc.md index 74d6afe..3a7b03b 100644 --- a/docs/irc.md +++ b/docs/irc.md @@ -1,21 +1,21 @@ -# IRC — `irc.bbs.profullstack.com` +# IRC — `irc.profullstack.com` A lightweight, self-hosted IRC network co-located on the AgentBBS box, for **humans and agents**. It runs [Ergo](https://ergo.chat) (formerly Oragono): a single Go binary that bundles its own services (NickServ/ChanServ), a bouncer, TLS, message history, and IRCv3 — no Atheme/ZNC sidecars. -It shares the box and the `bbs.profullstack.com` TLS cert with the BBS but runs -as its **own service on its own ports** (`ergo.service`, user `ergo`), so it is -operationally independent of the wish server. +It runs on the BBS box as its **own service on its own ports** (`ergo.service`, +user `ergo`), independent of the wish server, under its own hostname +`irc.profullstack.com` with its own Let's Encrypt cert (issued by Caddy). ## Connect | Path | Address | For | |---|---|---| | In-BBS | `ssh -t irc@bbs.profullstack.com` | members — zero-setup built-in client (see below) | -| Native TLS | `irc.bbs.profullstack.com:6697` (TLS) | desktop/CLI clients (HexChat, irssi, WeeChat, Halloy…) | -| WebSocket | `wss://bbs.profullstack.com/irc` | browser clients (The Lounge, Gamja, Kiwi) and agents over WS | +| Native TLS | `irc.profullstack.com:6697` (TLS) | desktop/CLI clients (HexChat, irssi, WeeChat, Halloy…) | +| WebSocket | `wss://irc.profullstack.com/irc` (or `wss://bbs.profullstack.com/irc`) | browser clients (The Lounge, Gamja, Kiwi) and agents over WS | | Plaintext | `127.0.0.1:6667` | **loopback only** — on-box tooling/the `irc@` client; firewalled off | The WebSocket path is fronted by Caddy (it terminates TLS and reverse-proxies to @@ -30,33 +30,56 @@ 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`, `/msg `, `/me`, `/names`, `/nick`, `/help`, and -`esc` to leave. Override the target with `AGENTBBS_IRC_ADDR` on a dev host. - -### Membership (who can connect) - -The network is **members-only**. There is **no self-service registration** — -every client must authenticate with SASL, and a login is approved only if the -account name is an existing AgentBBS member, i.e. someone who has registered via -`ssh join@bbs.profullstack.com` (which creates their home dir under -`/var/lib/agentbbs/users//`). Non-members are refused at connect. +`/join #chan`, `/part [#chan]`, `/create #chan` (premium), `/msg `, +`/me`, `/names`, `/nick`, `/help`, and `esc` to leave. Override the target with +`AGENTBBS_IRC_ADDR` on a dev host. + +### Membership (who can connect) — members are OS users + +The network is **members-only**, and "member" means a **real OS user** on the +box. AgentBBS uses the tilde.town model: registering via +`ssh join@bbs.profullstack.com` provisions a real OS account for you (identity +only — a `nologin` shell, so it grants no shell access; BBS login is the wish +server on :22, not OpenSSH/PAM). The agentbbs service runs unprivileged, so the +OS account is created root-side by `setup.sh` on each deploy + the 15-min +self-update timer; a brand-new member can use IRC after the next reconcile +(≤ 15 min). + +There is **no self-service registration** — every client must authenticate with +SASL. The gate is Ergo's `auth-script` +([`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, +`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** — membership (the filesystem home dir) *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 gate is Ergo's `auth-script` (`/usr/local/bin/ergo-auth-member`, from -[`deploy/ergo/auth-script.sh`](../deploy/ergo/auth-script.sh)) with -`accounts.require-sasl` on and `accounts.registration` off. On first successful -login the Ergo account is auto-created (`autocreate`), so members never register. +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 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. +### Channels (groups) — creating is a premium perk + +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. + +> **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. + ### Connect as an agent Agents authenticate with **SASL PLAIN** using their member account name (any @@ -77,9 +100,10 @@ Any standard IRC library works — e.g. `irc-framework` (Node), `pydle` / ## Network identity - **Network name:** `ProfullstackBBS` (`IRC_NETWORK` in `setup.sh`) -- **Server name:** `irc.bbs.profullstack.com` -- Access: **members-only** (SASL required; account = BBS member, see [Membership](#membership-who-can-connect)) +- **Server name:** `irc.profullstack.com` (`IRC_DOMAIN` in `setup.sh`) +- Access: **members-only** (SASL required; account = OS user / BBS member) - Self-service account registration: **off** +- Channel creation: **premium members only** (free members may join) - Message history: **in-memory**, ~7-day window, `CHATHISTORY` enabled ## Operating it @@ -100,23 +124,24 @@ the same self-update timer as the BBS. Toggle with `IRC=0`. ### TLS -Caddy is the only ACME client on the box and already holds a valid cert for -`bbs.profullstack.com`. Rather than run a second ACME client, the -`ergo-certs.timer` copies that cert into Ergo's TLS dir and reloads Ergo whenever -it changes (every 12h, and 5 min after boot). On the very first deploy — before -Caddy has issued the cert — setup.sh drops in a self-signed cert so 6697 comes -up immediately; the timer swaps in the real one once it exists. +Caddy is the only ACME client on the box. It serves an `irc.profullstack.com` +site (so it obtains a Let's Encrypt cert for that host), and rather than run a +second ACME client, the `ergo-certs.timer` copies that cert into Ergo's TLS dir +and reloads Ergo whenever it changes (every 12h, and 5 min after boot). On the +very first deploy — before Caddy has issued the cert — setup.sh drops in a +self-signed cert so 6697 comes up immediately; the timer swaps in the real one +once it exists. -> Native clients connect to **`irc.bbs.profullstack.com`**, so make sure that -> hostname resolves to the box (an A record, or a CNAME to `bbs.profullstack.com`). -> The TLS cert is for `bbs.profullstack.com`; if you want a clean match on the -> `irc.` hostname, add it as a SAN to the Caddy site or use a wildcard cert. +> **DNS prerequisite:** point an A record `irc.profullstack.com → the box` (and +> AAAA if you use IPv6). Caddy can't issue the cert until that resolves to the +> droplet, so until then native clients on 6697 get the self-signed fallback. ### Config knobs (`setup.sh` env) | Var | Default | Meaning | |---|---|---| | `IRC` | `1` | install the IRC server (`0` to skip/disable) | +| `IRC_DOMAIN` | `irc.` (e.g. `irc.profullstack.com`) | IRC server name + TLS cert host | | `ERGO_VERSION` | `2.18.0` | Ergo release to install | | `IRC_NETWORK` | `ProfullstackBBS` | network name shown to clients | | `ERGO_DATA` | `/var/lib/ergo` | Ergo state dir | diff --git a/internal/irc/tui.go b/internal/irc/tui.go index ffae9b5..9123adf 100644 --- a/internal/irc/tui.go +++ b/internal/irc/tui.go @@ -24,18 +24,20 @@ var ( ) // Run drives the IRC TUI over the SSH session until the member leaves; leaving -// ends the session (irc@ is a dedicated route, like agent@). -func Run(s ssh.Session, c *Client) error { +// 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@)\r\n")) return nil } m := &model{ - c: c, - channel: DefaultChannel, - width: ptyReq.Window.Width, - height: ptyReq.Window.Height, + 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))) @@ -62,10 +64,11 @@ func waitEvent(c *Client) tea.Cmd { } type model struct { - c *Client - channel string // current conversation target for typed lines - lines []string - input string + 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 } @@ -159,7 +162,27 @@ func (m *model) command(text string) tea.Cmd { arg := strings.TrimSpace(strings.TrimPrefix(text, fields[0])) switch cmd { case "/help": - m.push(cSys.Render("commands: /join #chan /part [#chan] /msg /me /names /nick /quit")) + m.push(cSys.Render("commands: /join #chan /part [#chan] /create #chan /msg /me /names /nick /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")) diff --git a/setup.sh b/setup.sh index 14cb169..e8f586f 100755 --- a/setup.sh +++ b/setup.sh @@ -38,7 +38,8 @@ SKIP_BUILD="${SKIP_BUILD:-0}" # set 1 to use prebuilt /usr/local/bin/{agen SWAP_SIZE="${SWAP_SIZE:-3G}" # swapfile size added on low-RAM hosts (set 0 to skip) SELF_UPDATE="${SELF_UPDATE:-1}" # set 0 to skip the autonomous self-update systemd timer SELF_UPDATE_INTERVAL="${SELF_UPDATE_INTERVAL:-15min}" # how often the box polls origin for new commits -IRC="${IRC:-1}" # set 0 to skip the co-located Ergo IRC server (irc.${DOMAIN}) +IRC="${IRC:-1}" # set 0 to skip the co-located Ergo IRC server (${IRC_DOMAIN}) +IRC_DOMAIN="${IRC_DOMAIN:-irc.${DOMAIN#*.}}" # IRC host (default: irc., e.g. irc.profullstack.com) NEWS="${NEWS:-1}" # set 0 to skip the co-located Usenet/NNTP server (news.${DOMAIN}) ERGO_VERSION="${ERGO_VERSION:-2.18.0}" # Ergo IRCd release to install IRC_NETWORK="${IRC_NETWORK:-ProfullstackBBS}" # IRC network name shown to clients @@ -481,6 +482,26 @@ news.${DOMAIN} { " fi +# IRC site (${IRC_DOMAIN}). Caddy serving this host means it obtains a Let's +# Encrypt cert for it — which ergo-refresh-certs copies into Ergo for 6697 TLS. +# It also fronts the same loopback WebSocket, so wss://${IRC_DOMAIN}/irc works +# (alongside wss://${DOMAIN}/irc). Needs an A record ${IRC_DOMAIN} -> this host. +IRC_SITE="" +if [ "$IRC" = "1" ]; then + IRC_SITE=" +${IRC_DOMAIN} { + encode zstd gzip + handle /irc { + reverse_proxy 127.0.0.1:8097 + } + 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}\" + } +} +" +fi + cat > /etc/caddy/Caddyfile <.${DOMAIN} (needs wildcard DNS # *.${DOMAIN} -> this host). On-demand TLS mints a cert only when agentbbs's # ask endpoint confirms is a registered member, so random subdomains @@ -558,13 +579,39 @@ ufw allow 80/tcp >/dev/null ufw allow 443/tcp >/dev/null systemctl reload caddy 2>/dev/null || systemctl restart caddy -# ---- 9b. Ergo IRC server (co-located irc.${DOMAIN}; humans + agents) -------- -# A lightweight single-binary IRC network on its own ports, sharing this box and -# this hostname's TLS cert. Native clients hit irc.${DOMAIN}:6697 (TLS); web -# clients and agents hit wss://${DOMAIN}/irc (Caddy fronts Ergo's loopback -# WebSocket). See docs/irc.md. Disable with IRC=0. +# ---- 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}); +# web clients and agents hit wss://${DOMAIN}/irc or wss://${IRC_DOMAIN}/irc +# (Caddy fronts Ergo's loopback WebSocket). See docs/irc.md. Disable with IRC=0. if [ "$IRC" = "1" ]; then - log "installing Ergo IRC server v${ERGO_VERSION} (irc.${DOMAIN})" + log "installing Ergo IRC server v${ERGO_VERSION} (${IRC_DOMAIN})" case "$GOARCH" in amd64) ERGO_ARCH=x86_64 ;; arm64) ERGO_ARCH=arm64 ;; @@ -597,28 +644,29 @@ if [ "$IRC" = "1" ]; then # Render the config template from the repo (the __TOKENS__ become real values). sed -e "s|__NETWORK__|${IRC_NETWORK}|g" \ -e "s|__DOMAIN__|${DOMAIN}|g" \ + -e "s|__IRC_DOMAIN__|${IRC_DOMAIN}|g" \ -e "s|__DATA__|${ERGO_DATA}|g" \ -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|__USERS_DIR__|${DATA_DIR}/users|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 maps to an AgentBBS member home dir under ${DATA_DIR}/users. + # 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). 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 ${DOMAIN}; self-signed + # TLS for 6697: reuse Caddy's Let's Encrypt cert for ${IRC_DOMAIN}; self-signed # fallback on first run before Caddy has issued it (the timer swaps it in). install -m 0755 "${SRC_DIR}/deploy/ergo/refresh-certs.sh" /usr/local/bin/ergo-refresh-certs - DOMAIN="$DOMAIN" ERGO_DATA="$ERGO_DATA" /usr/local/bin/ergo-refresh-certs || true + DOMAIN="$IRC_DOMAIN" ERGO_DATA="$ERGO_DATA" /usr/local/bin/ergo-refresh-certs || true if [ ! -s "$ERGO_DATA/tls/fullchain.pem" ]; then - warn "no Caddy cert for ${DOMAIN} yet — using a self-signed cert on 6697 until the ergo-certs timer swaps in the real one" + warn "no Caddy cert for ${IRC_DOMAIN} yet (is its A record pointed here?) — using a self-signed cert on 6697 until the ergo-certs timer swaps in the real one" ( cd /etc/ergo && /opt/ergo/ergo mkcerts --conf /etc/ergo/ircd.yaml --quiet 2>/dev/null ) \ || openssl req -newkey rsa:2048 -nodes -days 90 -x509 \ -keyout "$ERGO_DATA/tls/privkey.pem" -out "$ERGO_DATA/tls/fullchain.pem" \ - -subj "/CN=irc.${DOMAIN}" 2>/dev/null + -subj "/CN=${IRC_DOMAIN}" 2>/dev/null fi chown -R ergo:ergo "$ERGO_DATA" /etc/ergo @@ -628,7 +676,7 @@ if [ "$IRC" = "1" ]; then log "installing ergo.service" cat > /etc/systemd/system/ergo.service < /etc/systemd/system/ergo-certs.service < a member's homepage https:// a member's homepage on a custom domain (auto-HTTPS) - IRC irc.${DOMAIN}:6697 (TLS) native clients ${IRC:+(set IRC=0 to disable)} + 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) /OPER admin 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)