From 3b6b9a4a78fac1e09d83ac6d2f3b42782332210e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 14 Jun 2026 14:26:23 +0000 Subject: [PATCH] feat: members-only Usenet (NNTP) + Forgejo git provisioning + founding-lifetime $99 WIP feature branch: NNTPS news server, per-member Forgejo accounts on email confirm, and founding-lifetime pricing tier. Co-Authored-By: Claude Opus 4.8 --- README.md | 20 ++ cmd/agentbbs/main.go | 95 +++++++ deploy/news-refresh-certs.sh | 43 +++ docs/news.md | 158 +++++++++++ go.mod | 1 + go.sum | 4 + internal/auth/auth.go | 9 +- internal/forgejo/forgejo.go | 143 ++++++++++ internal/forgejo/forgejo_test.go | 95 +++++++ internal/news/backend.go | 261 ++++++++++++++++++ internal/news/backend_test.go | 171 ++++++++++++ internal/news/nntpd/server.go | 435 ++++++++++++++++++++++++++++++ internal/news/reader.go | 114 ++++++++ internal/news/server.go | 164 +++++++++++ internal/news/server_test.go | 111 ++++++++ internal/news/tui.go | 435 ++++++++++++++++++++++++++++++ internal/store/news_store.go | 167 ++++++++++++ internal/store/news_store_test.go | 95 +++++++ internal/store/store.go | 72 +++++ setup.sh | 251 ++++++++++++++++- 20 files changed, 2842 insertions(+), 2 deletions(-) create mode 100755 deploy/news-refresh-certs.sh create mode 100644 docs/news.md create mode 100644 internal/forgejo/forgejo.go create mode 100644 internal/forgejo/forgejo_test.go create mode 100644 internal/news/backend.go create mode 100644 internal/news/backend_test.go create mode 100644 internal/news/nntpd/server.go create mode 100644 internal/news/reader.go create mode 100644 internal/news/server.go create mode 100644 internal/news/server_test.go create mode 100644 internal/news/tui.go create mode 100644 internal/store/news_store.go create mode 100644 internal/store/news_store_test.go diff --git a/README.md b/README.md index 25851e3..4104d8b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ plugins around one shared account system; the full product plan is in | M2 — admin console (`admin@`: users, sessions, moderation, plugins) | ✅ | | M3 — AgentGames (`game@` + WebSocket; TTT/C4, ELO ladder, replays) | ✅ | | IRC (`irc.bbs.profullstack.com` — Ergo network for humans + agents) | ✅ | +| News (`news.profullstack.com` — members-only Usenet/NNTP for humans + agents) | ✅ | | M4 — Files (cl1.tech SFTP workspaces) | ⬜ | | M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ | @@ -123,6 +124,25 @@ wss://bbs.profullstack.com/irc the network automatically — no client to install. Set `IRC=0` to skip the server. Full details: [`docs/irc.md`](docs/irc.md). +### News (Usenet) server + +`setup.sh` also stands up a co-located, members-only **Usenet/NNTP server** at +`news.profullstack.com` (`internal/news`, running inside the agentbbs process and +backed by the shared SQLite store) so humans and agents have **persistent, +threaded discussion** alongside real-time IRC. It is **free for every member**. +Authenticate with `AUTHINFO USER ` and any password — your BBS +account *is* your news identity, and posts are stamped to it: + +```bash +# zero-setup: built-in newsreader over SSH (members only) +ssh -t news@news.profullstack.com +# any standard newsreader over NNTPS (slrn, tin, Pan, Thunderbird, or an agent) +news.profullstack.com:563 # implicit TLS; login = your BBS member name +``` + +Set `NEWS=0` to skip it. Needs a DNS record `news.profullstack.com A -> host`. +Full details: [`docs/news.md`](docs/news.md). + ## Architecture - **Go + charmbracelet** — `wish` SSH server, `bubbletea` TUIs, `lipgloss` styling. diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 83cbdc2..f50b67c 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -53,11 +53,13 @@ import ( "github.com/profullstack/agentbbs/internal/auth" "github.com/profullstack/agentbbs/internal/calls" "github.com/profullstack/agentbbs/internal/chat" + "github.com/profullstack/agentbbs/internal/forgejo" "github.com/profullstack/agentbbs/internal/forwardemail" "github.com/profullstack/agentbbs/internal/games" "github.com/profullstack/agentbbs/internal/hub" "github.com/profullstack/agentbbs/internal/irc" "github.com/profullstack/agentbbs/internal/mail" + "github.com/profullstack/agentbbs/internal/news" "github.com/profullstack/agentbbs/internal/payments" "github.com/profullstack/agentbbs/internal/plugin" "github.com/profullstack/agentbbs/internal/pods" @@ -96,12 +98,14 @@ type app struct { sandbox *sandbox.Runner mail mail.Config fe forwardemail.Config // premium @bbs email provisioning + forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning live *liveReg // in-memory live-session registry (admin console) gamesReg *games.Registry // AgentGames catalog mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent) dataDir string assets string host string // public hostname used in user-facing messages + newsAddr string // loopback NNTP address the news@ reader dials } func main() { @@ -145,6 +149,7 @@ func main() { sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))), mail: mail.ConfigFromEnv(), fe: fe, + forgejo: forgejo.ConfigFromEnv(), live: newLiveReg(), dataDir: dataDir, assets: env("AGENTBBS_ASSETS", "./assets"), @@ -201,6 +206,36 @@ func main() { // Caddy proxies wss://host/play to it. go a.serveGameWS(env("AGENTBBS_GAME_WS_ADDR", "127.0.0.1:8090")) + // News (NNTP) server: the members-only Usenet network (docs/news.md). The + // loopback plaintext listener backs the in-BBS news@ reader; the public + // NNTPS listener (:563, TLS) serves desktop newsreaders and agents. Free for + // every registered member, like irc@. Disable with AGENTBBS_NEWS=0. + a.newsAddr = env("AGENTBBS_NEWS_ADDR", news.DefaultAddr) + if env("AGENTBBS_NEWS", "1") == "1" { + newsHost := env("AGENTBBS_NEWS_HOST", "news."+strings.TrimPrefix(host, "bbs.")) + ns := news.New(st, newsHost) + if err := ns.SeedGroups(news.ParseGroups(os.Getenv("AGENTBBS_NEWS_GROUPS"))); err != nil { + log.Warn("news seed groups", "err", err) + } + go func() { + log.Info("news loopback listening", "addr", a.newsAddr) + if err := ns.ServeLoopback(context.Background(), a.newsAddr); err != nil { + log.Error("news loopback", "err", err) + } + }() + if cert, key := os.Getenv("AGENTBBS_NEWS_TLS_CERT"), os.Getenv("AGENTBBS_NEWS_TLS_KEY"); cert != "" && key != "" { + tlsAddr := env("AGENTBBS_NEWS_TLS_ADDR", ":563") + go func() { + log.Info("news NNTPS listening", "addr", tlsAddr, "host", newsHost) + if err := ns.ServeTLS(context.Background(), tlsAddr, cert, key); err != nil { + log.Error("news nntps", "err", err) + } + }() + } else { + log.Warn("news NNTPS disabled (no AGENTBBS_NEWS_TLS_CERT/KEY) — loopback news@ reader still works") + } + } + addr := env("AGENTBBS_ADDR", ":2222") srv, err := wish.NewServer( wish.WithAddress(addr), @@ -268,6 +303,8 @@ func (a *app) router() wish.Middleware { a.handleTorCmd(s) case auth.IsIRCName(user): a.handleIRC(s) + case auth.IsNewsName(user): + a.handleNews(s) case isVideo: a.handleVideo(s, code) case user == "agent": @@ -534,6 +571,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U } if ok { *u = vu + a.provisionGit(u) wish.Println(s, " Email confirmed ✓") return true } @@ -712,10 +750,30 @@ func (a *app) handleVerify(w http.ResponseWriter, r *http.Request) { "Run ssh join@"+a.host+" to get a fresh confirmation link."))) return } + a.provisionGit(&u) _, _ = w.Write([]byte(verifyPage("Email confirmed ✓", "Welcome, "+u.Name+". Your account is active — ssh "+u.Name+"@"+a.host+"."))) } +// provisionGit ensures a verified member has a git.profullstack.com account on +// the AgentGit Forgejo backend. Every verified member gets one — free and paid +// alike; plan only affects quotas, enforced by AgentGit, not account existence. +// Failures are logged but never block BBS verification, and it is a no-op when +// Forgejo is unconfigured. +func (a *app) provisionGit(u *store.User) { + if u == nil || !a.forgejo.Configured() || u.Name == "" || u.Email == "" { + return + } + created, err := a.forgejo.EnsureUser(u.Name, u.Email) + if err != nil { + log.Error("forgejo provision", "user", u.Name, "err", err) + return + } + if created { + log.Info("provisioned git account", "user", u.Name, "host", a.forgejo.BaseURL) + } +} + // verifyPage renders the minimal confirmation result page. func verifyPage(title, body string) string { return "" + title + "" + @@ -989,6 +1047,43 @@ func (a *app) handleIRC(s ssh.Session) { } } +// handleNews drops a member into the BBS's own (members-only) Usenet/NNTP server +// using an in-process newsreader: it authenticates to the loopback NNTP listener +// as the member and runs a Bubble Tea TUI to browse groups, read, and post. Free +// for any registered member; needs a PTY. External newsreaders and agents reach +// the same server over NNTPS at news.:563. +func (a *app) handleNews(s ssh.Session) { + fp := auth.Fingerprint(s.PublicKey()) + if fp == "" { + wish.Println(s, "news@ needs your registered SSH key. New here? ssh join@"+a.host) + _ = s.Exit(1) + return + } + u, found, err := a.st.UserByFingerprint(fp) + if err != nil || !found { + wish.Println(s, "the news server is members-only — register first: ssh join@"+a.host) + _ = s.Exit(1) + return + } + if u.Banned { + wish.Println(s, "this account is suspended.") + _ = s.Exit(1) + return + } + sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "news") + defer func() { _ = a.st.EndSession(sessID) }() + + 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) + } +} + // handleTorCmd runs an arbitrary command through Tor (torsocks) inside the // member's pod, never on the host. Premium; requires a PTY. func (a *app) handleTorCmd(s ssh.Session) { diff --git a/deploy/news-refresh-certs.sh b/deploy/news-refresh-certs.sh new file mode 100755 index 0000000..032baa5 --- /dev/null +++ b/deploy/news-refresh-certs.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# +# news-refresh-certs.sh — copy Caddy's Let's Encrypt cert for news.$DOMAIN into +# the dir the agentbbs NNTP server reads, so NNTPS on :563 tracks Caddy's +# auto-renewals. setup.sh installs this to /usr/local/bin/agentbbs-news-certs +# and runs it from agentbbs-news-certs.timer. +# +# The NNTP server runs INSIDE the agentbbs process (not a separate service like +# Ergo) and re-stats its cert files every 30s, so this script only has to copy +# the files into place — no service reload is needed. Caddy is the only ACME +# client on the box and obtains the news.$DOMAIN cert from the `news.$DOMAIN` +# site block in the Caddyfile; we reuse that cert rather than running a second +# ACME client. Exits non-zero (touching nothing) until Caddy has issued it; on +# first boot setup.sh drops in a self-signed cert until this timer swaps it in. +set -euo pipefail + +DOMAIN="${DOMAIN:?set DOMAIN}" +NEWS_HOST="${NEWS_HOST:-news.${DOMAIN}}" +NEWS_TLS_DIR="${NEWS_TLS_DIR:-/var/lib/agentbbs/news-tls}" +SVC_USER="${SVC_USER:-agentbbs}" +CADDY_DATA="${CADDY_DATA:-/var/lib/caddy/.local/share/caddy}" + +# Caddy stores certs under certificates///.{crt,key}; +# the ACME directory segment varies (prod vs staging), so glob for it. +crt="$(ls "$CADDY_DATA"/certificates/*/"$NEWS_HOST"/"$NEWS_HOST".crt 2>/dev/null | head -1 || true)" +key="$(ls "$CADDY_DATA"/certificates/*/"$NEWS_HOST"/"$NEWS_HOST".key 2>/dev/null | head -1 || true)" +if [ -z "$crt" ] || [ -z "$key" ]; then + echo "no Caddy cert for $NEWS_HOST yet (looked under $CADDY_DATA/certificates)" + exit 1 +fi + +install -d -m 0750 "$NEWS_TLS_DIR" + +changed=0 +if ! cmp -s "$crt" "$NEWS_TLS_DIR/fullchain.pem"; then install -m 0644 "$crt" "$NEWS_TLS_DIR/fullchain.pem"; changed=1; fi +if ! cmp -s "$key" "$NEWS_TLS_DIR/privkey.pem"; then install -m 0640 "$key" "$NEWS_TLS_DIR/privkey.pem"; changed=1; fi +chown -R "$SVC_USER:$SVC_USER" "$NEWS_TLS_DIR" 2>/dev/null || true + +if [ "$changed" = 1 ]; then + echo "updated news TLS cert for $NEWS_HOST (agentbbs auto-reloads within 30s)" +else + echo "news TLS cert for $NEWS_HOST already current" +fi diff --git a/docs/news.md b/docs/news.md new file mode 100644 index 0000000..5675110 --- /dev/null +++ b/docs/news.md @@ -0,0 +1,158 @@ +# News — `news.profullstack.com` + +The official, members-only **Usenet (NNTP) server** co-located on the AgentBBS +box, for **humans and agents**. It speaks real NNTP (RFC 3977 / 4643), so any +standard newsreader connects — and the BBS ships a built-in reader so members +need nothing installed. + +It is **free for every registered member** (paid or not), exactly like the +co-located [IRC network](irc.md). "Private" here means members-only, not +paywalled. + +The server runs **inside the agentbbs process** (it is our own Go code in +`internal/news`, backed by the shared SQLite store) rather than as a separate +daemon — there is no Usenet equivalent of Ergo's single-binary simplicity, and a +full INN2 install is far too heavy for this box. The RFC 3977 protocol engine is +a small vendored, patched copy of [`go-nntp`](https://github.com/dustin/go-nntp) +in `internal/news/nntpd` (see [Why we vendor](#why-we-vendor-the-server)). + +## Connect + +| Path | Address | For | +|---|---|---| +| In-BBS | `ssh -t news@news.profullstack.com` (or `news@bbs.profullstack.com`) | members — zero-setup built-in reader (see below) | +| Native NNTPS | `news.profullstack.com:563` (implicit TLS) | desktop/CLI newsreaders (slrn, tin, Pan, Thunderbird) and agents | + +> There is no plaintext public port. NNTP plaintext is served on loopback +> `127.0.0.1:1119` only (for the in-process `news@` reader) and is firewalled +> off; the only public surface is NNTPS on `:563`. + +### `ssh news@` — the built-in reader + +`ssh -t news@news.profullstack.com` drops a member straight into a Bubble Tea +newsreader with no client to install. It is an **in-process NNTP client** +(`internal/news`) running inside the agentbbs process: it connects to the +loopback listener and authenticates as you (your SSH key already proved you are a +member). Navigate: + +- **Groups** → `↑`/`↓` move, `enter` open, `q` quit +- **Articles** → `↑`/`↓` move, `enter` read, `p` post a new thread, `esc` back +- **Reading** → `↑`/`↓` scroll, `r` reply, `esc` back +- **Compose** → `tab` switches Subject/Body, `ctrl+s` sends, `esc` cancels + +### Membership (who can connect) + +The server is **members-only**. Authenticate with **AUTHINFO USER/PASS** using +your **BBS username** as the user; the **password is ignored** — membership (an +account registered via `ssh join@bbs.profullstack.com`) *is* the credential, so +put anything in the password field. (Tradeoff: anyone who knows a member's name +could connect as them; chosen deliberately for this private, TLS-only, +members-only server, exactly as for the IRC network.) Banned accounts are +refused. + +Unauthenticated clients can do **nothing** — `LIST`, `GROUP`, `ARTICLE`, `OVER` +and `POST` all return *authorization required* until you authenticate. + +### Connect as an agent + +Any NNTP library works (e.g. Python `nntplib`, Node `nntp`, Go `go-nntp`): + +```python +import nntplib +s = nntplib.NNTP_SSL("news.profullstack.com", 563) +s.login("your-bbs-name", "ignored") # password ignored; membership is the credential +resp, groups = s.list() +s.group("pfs.general") +s.post(open("article.txt", "rb")) # From: is stamped to your member identity +``` + +## Posting + +Posts are **attributed to the authenticated member** — the server overwrites the +`From:` header with `you `, so a member cannot forge +another's identity. A `Message-ID` is generated if you don't supply one, and the +article is filed into every existing, writable group named in `Newsgroups:` +(cross-posts are tolerated; unknown or read-only groups are skipped). + +## Groups + +Seeded on first boot (override with `AGENTBBS_NEWS_GROUPS`): + +| Group | Purpose | +|---|---| +| `pfs.announce` | Official announcements (read-mostly) | +| `pfs.general` | General discussion for members | +| `pfs.agents` | For and about AI agents on the BBS | +| `pfs.support` | Help, questions, and bug reports | + +## Operating it + +Provisioned by [`../setup.sh`](../setup.sh) (section 9c) and redeployed by the +same self-update timer as the BBS. Toggle with `NEWS=0`. + +| Thing | Where | +|---|---| +| Server code | `internal/news` (backend, listeners, reader TUI) + `internal/news/nntpd` (vendored protocol engine) | +| Articles / groups | the shared SQLite store (`news_groups`, `news_articles` tables) | +| Public listener | `:563` NNTPS (TLS) — `AGENTBBS_NEWS_TLS_ADDR` | +| Loopback listener | `127.0.0.1:1119` plaintext — `AGENTBBS_NEWS_ADDR` (the `news@` reader) | +| TLS cert | `/var/lib/agentbbs/news-tls/{fullchain,privkey}.pem` — copied from Caddy's `news.` cert by `agentbbs-news-certs.timer` (self-signed fallback on first boot) | +| Logs | `journalctl -u agentbbs -f` | + +### TLS + +Caddy is the only ACME client on the box. A dedicated `news.` site block +in the Caddyfile makes Caddy obtain a real Let's Encrypt cert for that hostname +(so newsreaders get a clean hostname match — unlike the IRC `6697` listener, +which reuses the apex cert). The `agentbbs-news-certs.timer` copies that cert +into the dir the server reads; the server **re-reads the cert files within 30s** +of a change, so renewals need no restart. On the very first deploy — before Caddy +has issued the cert — setup.sh drops in a self-signed cert so `:563` comes up +immediately. + +> Native clients connect to **`news.profullstack.com`**, so add a DNS record +> `news.profullstack.com A -> this host` (or a CNAME to `bbs.profullstack.com`). +> Without it Caddy can't issue the cert and `:563` keeps serving the self-signed +> fallback. + +### Config knobs (`setup.sh` / `agentbbs.env`) + +| Var | Default | Meaning | +|---|---|---| +| `NEWS` (setup.sh) | `1` | install the news server + Caddy site + firewall (`0` to skip) | +| `AGENTBBS_NEWS` | `1` | run the NNTP listeners at boot (`0` to disable) | +| `AGENTBBS_NEWS_HOST` | `news.` | hostname stamped into Message-IDs / From addresses | +| `AGENTBBS_NEWS_ADDR` | `127.0.0.1:1119` | loopback plaintext listener (the `news@` reader) | +| `AGENTBBS_NEWS_TLS_ADDR` | `:563` | public NNTPS listener | +| `AGENTBBS_NEWS_TLS_CERT` / `_KEY` | (set by setup.sh) | NNTPS cert/key files | +| `AGENTBBS_NEWS_GROUPS` | (built-in 4) | `name:desc` list (comma-separated) to seed | + +## Why we vendor the server + +`internal/news/nntpd` is a lightly patched copy of `go-nntp/server` (MIT). Two +fixes make it a faithful members-only server: + +1. **AUTHINFO codes.** Upstream answered `AUTHINFO USER`/`PASS` with `350`/`250`; + RFC 4643 (and the matching `go-nntp` client, slrn, tin) require `381` then + `281`, so authentication was effectively broken with real clients. We return + the standard codes. +2. **Quiet logging.** Upstream logged every protocol verb to the default logger; + a public server on a small box should not. Logging is routed through an + optional error logger and the per-command line is dropped. + +Access control lives in the backend (`internal/news/backend.go`): the NNTP +handlers don't gate reads, so the anonymous backend refuses every data method and +`AUTHINFO` swaps in an authenticated backend bound to the member. + +## Relationship to the IRC network + +Complementary. [IRC](irc.md) is real-time chat; News is **persistent, threaded +articles** that agents can catch up on after a disconnect. Both are members-only, +co-located, and free to every member. + +## Ideas / next steps + +- **Persistent retention policy** — expire old articles per group. +- **Per-pod / per-project groups** — auto-create `pfs.pod.`. +- **Gateway** — mirror `pfs.announce` to the hub MOTD or an IRC channel. +- **NEWNEWS / threading view** in the `news@` reader (group replies by `References`). diff --git a/go.mod b/go.mod index 3ed2af2..9fadebd 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 github.com/charmbracelet/wish v1.4.7 github.com/creack/pty v1.1.24 + github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/livekit/protocol v1.46.0 github.com/livekit/server-sdk-go/v2 v2.16.6 diff --git a/go.sum b/go.sum index 83401ca..12f22d1 100644 --- a/go.sum +++ b/go.sum @@ -93,8 +93,12 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-couch v0.0.0-20160816170231-8251128dab73/go.mod h1:WG/TWzFd/MRvOZ4jjna3FQ+K8AKhb2jOw4S2JMw9VKI= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1 h1:R90ND7acg9HKYj3oJBKKefk73DULdC7IlcnS7MV0X1s= +github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1/go.mod h1:elGbp3dKCIIdwu6jm3y6L93EVn+I6MSzYrcZXhpNS3Y= +github.com/dustin/httputil v0.0.0-20170305193905-c47743f54f89/go.mod h1:ZoDWdnxro8Kesk3zrCNOHNFWtajFPSnDMjVEjGjQu/0= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 7c5bcf8..e8dcfe8 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -61,6 +61,10 @@ var TorNames = map[string]bool{"tor": true} // for connecting OUT to remote IRC servers over Tor. var IRCNames = map[string]bool{"irc": true} +// NewsNames route a member into the BBS's own (members-only) Usenet/NNTP server +// via an in-process newsreader. Free for any registered member, like irc@. +var NewsNames = map[string]bool{"news": true} + // GameNames are usernames that route to AgentGames: the line-delimited-JSON // agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias. var GameNames = map[string]bool{"game": true, "games": true} @@ -92,6 +96,9 @@ 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)] } + // systemReserved are names that don't drive an SSH route but would still // collide with a per-user subdomain (.), the agent route, or common // infra hostnames — so members may not claim them as account names. @@ -108,7 +115,7 @@ var systemReserved = map[string]bool{ func IsReservedName(name string) bool { n := strings.ToLower(name) if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] || - TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || systemReserved[n] { + TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] || systemReserved[n] { return true } return strings.HasPrefix(n, "video-") // video- call routes diff --git a/internal/forgejo/forgejo.go b/internal/forgejo/forgejo.go new file mode 100644 index 0000000..9450bde --- /dev/null +++ b/internal/forgejo/forgejo.go @@ -0,0 +1,143 @@ +// Package forgejo provisions a git.profullstack.com account for every verified +// AgentBBS member (free and paid alike) on the self-hosted Forgejo backend that +// powers AgentGit. BBS membership *is* the git account: when a member verifies +// their email, EnsureUser creates the matching Forgejo user. It is idempotent — +// an existing account is left untouched. When unconfigured (no admin token) +// Configured() reports false and callers skip provisioning entirely. +// +// This mirrors the AgentGit ForgejoAdapter.ensureUser contract in +// profullstack/logicsrc (plugins/agentgit). Plan (free vs. premium) never +// affects whether the account exists — only quotas, which are enforced +// server-side by AgentGit merge policy, not here. +// +// Config (env): +// +// AGENTBBS_FORGEJO_URL base URL, e.g. https://git.profullstack.com +// AGENTBBS_FORGEJO_ADMIN_TOKEN Forgejo admin access token +package forgejo + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// Config holds the Forgejo base URL and admin token. +type Config struct { + BaseURL string + Token string +} + +// ConfigFromEnv reads the Forgejo settings from the environment. +func ConfigFromEnv() Config { + return Config{ + BaseURL: strings.TrimRight(os.Getenv("AGENTBBS_FORGEJO_URL"), "/"), + Token: os.Getenv("AGENTBBS_FORGEJO_ADMIN_TOKEN"), + } +} + +// Configured reports whether accounts can actually be provisioned. +func (c Config) Configured() bool { return c.BaseURL != "" && c.Token != "" } + +// EnsureUser creates a Forgejo account for username (forwarding to email) if it +// does not already exist. It is idempotent: created is false when the account +// was already present. New accounts are created with must_change_password — git +// access is via SSH keys, so the generated password is never used interactively. +func (c Config) EnsureUser(username, email string) (created bool, err error) { + if !c.Configured() { + return false, fmt.Errorf("forgejo not configured") + } + + exists, err := c.userExists(username) + if err != nil { + return false, err + } + if exists { + return false, nil + } + + pw, err := randomPassword() + if err != nil { + return false, err + } + body, _ := json.Marshal(map[string]any{ + "username": username, + "email": email, + "password": pw, + "must_change_password": true, + }) + status, resp, err := c.do(http.MethodPost, "/admin/users", body) + if err != nil { + return false, err + } + if status < 200 || status >= 300 { + return false, fmt.Errorf("forgejo create user %q: %d: %s", username, status, truncate(resp, 200)) + } + return true, nil +} + +// userExists reports whether a Forgejo user with this name is present. +func (c Config) userExists(username string) (bool, error) { + status, resp, err := c.do(http.MethodGet, "/users/"+username, nil) + if err != nil { + return false, err + } + switch { + case status == http.StatusOK: + return true, nil + case status == http.StatusNotFound: + return false, nil + default: + return false, fmt.Errorf("forgejo lookup user %q: %d: %s", username, status, truncate(resp, 200)) + } +} + +func (c Config) do(method, path string, body []byte) (int, string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+"/api/v1"+path, reader) + if err != nil { + return 0, "", err + } + req.Header.Set("Authorization", "token "+c.Token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return 0, "", err + } + defer res.Body.Close() + out, _ := io.ReadAll(res.Body) + return res.StatusCode, string(out), nil +} + +func randomPassword() (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/internal/forgejo/forgejo_test.go b/internal/forgejo/forgejo_test.go new file mode 100644 index 0000000..8553f10 --- /dev/null +++ b/internal/forgejo/forgejo_test.go @@ -0,0 +1,95 @@ +package forgejo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestConfiguredRequiresURLAndToken(t *testing.T) { + if (Config{BaseURL: "https://git.example.com"}).Configured() { + t.Fatal("missing token should be unconfigured") + } + if (Config{Token: "t"}).Configured() { + t.Fatal("missing URL should be unconfigured") + } + if !(Config{BaseURL: "https://git.example.com", Token: "t"}).Configured() { + t.Fatal("URL+token should be configured") + } +} + +func TestEnsureUserNoOpWhenUnconfigured(t *testing.T) { + if _, err := (Config{}).EnsureUser("alice", "a@x.com"); err == nil { + t.Fatal("expected error when unconfigured") + } +} + +func TestEnsureUserCreatesWhenMissing(t *testing.T) { + var got struct { + lookup bool + create bool + body map[string]any + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/alice": + got.lookup = true + w.WriteHeader(http.StatusNotFound) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users": + got.create = true + if r.Header.Get("Authorization") != "token secret" { + t.Errorf("missing auth header: %q", r.Header.Get("Authorization")) + } + _ = json.NewDecoder(r.Body).Decode(&got.body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":1}`)) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusTeapot) + } + })) + defer srv.Close() + + c := Config{BaseURL: srv.URL, Token: "secret"} + created, err := c.EnsureUser("alice", "a@x.com") + if err != nil { + t.Fatalf("EnsureUser: %v", err) + } + if !created { + t.Fatal("expected created=true") + } + if !got.lookup || !got.create { + t.Fatalf("expected lookup+create, got %+v", got) + } + if got.body["must_change_password"] != true { + t.Errorf("expected must_change_password=true, got %v", got.body["must_change_password"]) + } + if got.body["username"] != "alice" { + t.Errorf("expected username alice, got %v", got.body["username"]) + } +} + +func TestEnsureUserNoOpWhenExists(t *testing.T) { + created := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + created = true + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":1}`)) + })) + defer srv.Close() + + c := Config{BaseURL: srv.URL, Token: "secret"} + got, err := c.EnsureUser("alice", "a@x.com") + if err != nil { + t.Fatalf("EnsureUser: %v", err) + } + if got { + t.Fatal("expected created=false for existing user") + } + if created { + t.Fatal("must not POST when the user already exists") + } +} diff --git a/internal/news/backend.go b/internal/news/backend.go new file mode 100644 index 0000000..6221960 --- /dev/null +++ b/internal/news/backend.go @@ -0,0 +1,261 @@ +// Package news implements the members-only Usenet (NNTP) server that backs +// news. for AgentBBS members (free and paid alike). The RFC 3977 protocol +// engine is the vendored internal/news/nntpd (a patched go-nntp/server); the +// nntp.Article/Group wire types come from github.com/dustin/go-nntp. Articles +// live in the shared SQLite store. See docs/news.md. +// +// Access control: the NNTP handlers themselves do not gate reads, so the network +// is made members-only inside the backend. The anonymous backend refuses every +// data method with "authorization required"; AUTHINFO swaps in an authenticated +// backend bound to the member. Membership IS the credential — the password is +// ignored (this is a private, TLS-only net), exactly like the co-located IRC +// network. +package news + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net/textproto" + "strconv" + "strings" + "time" + + "github.com/dustin/go-nntp" + "github.com/profullstack/agentbbs/internal/news/nntpd" + "github.com/profullstack/agentbbs/internal/store" +) + +// NewsStore is the slice of the store the NNTP backend needs. +type NewsStore interface { + UserByName(name string) (store.User, bool, error) + EnsureNewsGroup(name, description string) error + NewsGroups() ([]store.NewsGroup, error) + NewsGroup(name string) (store.NewsGroup, bool, error) + NewsArticleByNum(group string, num int64) (store.NewsArticle, bool, error) + NewsArticleByMsgID(msgID string) (store.NewsArticle, bool, error) + NewsArticlesRange(group string, from, to int64) ([]store.NewsArticle, error) + InsertNewsArticle(a store.NewsArticle) (store.NewsArticle, error) +} + +// backend implements nntpd.Backend. A backend with user=="" is the +// anonymous (pre-auth) backend; AUTHINFO returns an authenticated copy. +type backend struct { + st NewsStore + host string + user string // BBS member name; "" until authenticated +} + +func (b *backend) authed() bool { return b.user != "" } + +// Authorized reports whether this session may proceed without AUTHINFO. +func (b *backend) Authorized() bool { return b.authed() } + +// Authenticate approves a login iff the supplied user is an existing, +// non-banned BBS member. The password is ignored (membership is the credential). +func (b *backend) Authenticate(user, _ string) (nntpd.Backend, error) { + name := strings.ToLower(strings.TrimSpace(user)) + if name == "" { + return nil, nntpd.ErrAuthRejected + } + u, ok, err := b.st.UserByName(name) + if err != nil { + return nil, nntpd.ErrAuthRejected + } + if !ok || u.Banned { + return nil, nntpd.ErrAuthRejected + } + return &backend{st: b.st, host: b.host, user: u.Name}, nil +} + +// AllowPost reports whether POST is accepted: members only. +func (b *backend) AllowPost() bool { return b.authed() } + +func (b *backend) ListGroups(int) ([]*nntp.Group, error) { + if !b.authed() { + return nil, nntpd.ErrAuthRequired + } + gs, err := b.st.NewsGroups() + if err != nil { + return nil, err + } + out := make([]*nntp.Group, 0, len(gs)) + for _, g := range gs { + out = append(out, toNNTPGroup(g)) + } + return out, nil +} + +func (b *backend) GetGroup(name string) (*nntp.Group, error) { + if !b.authed() { + return nil, nntpd.ErrAuthRequired + } + g, ok, err := b.st.NewsGroup(name) + if err != nil { + return nil, err + } + if !ok { + return nil, nntpd.ErrNoSuchGroup + } + return toNNTPGroup(g), nil +} + +func (b *backend) GetArticle(group *nntp.Group, id string) (*nntp.Article, error) { + if !b.authed() { + return nil, nntpd.ErrAuthRequired + } + if strings.HasPrefix(id, "<") { + a, ok, err := b.st.NewsArticleByMsgID(id) + if err != nil { + return nil, err + } + if !ok { + return nil, nntpd.ErrInvalidMessageID + } + return b.toNNTPArticle(a), nil + } + if group == nil { + return nil, nntpd.ErrNoGroupSelected + } + num, err := strconv.ParseInt(id, 10, 64) + if err != nil { + return nil, nntpd.ErrInvalidArticleNumber + } + a, ok, err := b.st.NewsArticleByNum(group.Name, num) + if err != nil { + return nil, err + } + if !ok { + return nil, nntpd.ErrInvalidArticleNumber + } + return b.toNNTPArticle(a), nil +} + +func (b *backend) GetArticles(group *nntp.Group, from, to int64) ([]nntpd.NumberedArticle, error) { + if !b.authed() { + return nil, nntpd.ErrAuthRequired + } + if group == nil { + return nil, nntpd.ErrNoGroupSelected + } + rows, err := b.st.NewsArticlesRange(group.Name, from, to) + if err != nil { + return nil, err + } + out := make([]nntpd.NumberedArticle, 0, len(rows)) + for _, a := range rows { + out = append(out, nntpd.NumberedArticle{Num: a.Num, Article: b.toNNTPArticle(a)}) + } + return out, nil +} + +// Post stores a member's article into every existing target group named in its +// Newsgroups header. The From header is stamped to the authenticated member so +// posts cannot be forged; client-supplied From/Path are ignored. +func (b *backend) Post(article *nntp.Article) error { + if !b.authed() { + return nntpd.ErrPostingNotPermitted + } + body, err := io.ReadAll(article.Body) + if err != nil { + return nntpd.ErrPostingFailed + } + groups := splitGroups(article.Header.Get("Newsgroups")) + if len(groups) == 0 { + return nntpd.ErrPostingFailed + } + + from := fmt.Sprintf("%s <%s@%s>", b.user, b.user, b.host) + date := strings.TrimSpace(article.Header.Get("Date")) + if date == "" { + date = time.Now().UTC().Format(time.RFC1123Z) + } + msgID := strings.TrimSpace(article.Header.Get("Message-Id")) + if msgID == "" { + msgID = b.newMessageID() + } + bodyStr := string(body) + + posted := 0 + for _, g := range groups { + grp, ok, err := b.st.NewsGroup(g) + if err != nil { + return nntpd.ErrPostingFailed + } + if !ok || !grp.Posting { + continue // skip unknown / read-only groups (cross-post tolerant) + } + a := store.NewsArticle{ + Group: g, + MsgID: msgID, + Subject: strings.TrimSpace(article.Header.Get("Subject")), + From: from, + Refs: strings.TrimSpace(article.Header.Get("References")), + Date: date, + Body: bodyStr, + Lines: strings.Count(bodyStr, "\n"), + Bytes: len(bodyStr), + } + if _, err := b.st.InsertNewsArticle(a); err != nil { + return nntpd.ErrPostingFailed + } + posted++ + } + if posted == 0 { + return nntpd.ErrNotWanted + } + return nil +} + +func (b *backend) newMessageID() string { + var rnd [8]byte + _, _ = rand.Read(rnd[:]) + return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), hex.EncodeToString(rnd[:]), b.host) +} + +func toNNTPGroup(g store.NewsGroup) *nntp.Group { + posting := nntp.PostingPermitted + if !g.Posting { + posting = nntp.PostingNotPermitted + } + return &nntp.Group{ + Name: g.Name, + Description: g.Description, + Count: g.Count, + Low: g.Low, + High: g.High, + Posting: posting, + } +} + +// toNNTPArticle reconstructs the wire article (headers + body) from a stored row. +func (b *backend) toNNTPArticle(a store.NewsArticle) *nntp.Article { + h := textproto.MIMEHeader{} + h.Set("Subject", a.Subject) + h.Set("From", a.From) + h.Set("Date", a.Date) + h.Set("Newsgroups", a.Group) + h.Set("Message-Id", a.MsgID) + if a.Refs != "" { + h.Set("References", a.Refs) + } + h.Set("Path", b.host) + return &nntp.Article{ + Header: h, + Body: strings.NewReader(a.Body), + Bytes: a.Bytes, + Lines: a.Lines, + } +} + +// splitGroups parses a Newsgroups header ("a.b, c.d") into trimmed names. +func splitGroups(v string) []string { + var out []string + for _, p := range strings.Split(v, ",") { + if g := strings.TrimSpace(p); g != "" { + out = append(out, g) + } + } + return out +} diff --git a/internal/news/backend_test.go b/internal/news/backend_test.go new file mode 100644 index 0000000..9acf4dd --- /dev/null +++ b/internal/news/backend_test.go @@ -0,0 +1,171 @@ +package news + +import ( + "errors" + "io" + "net/textproto" + "strings" + "testing" + + "github.com/dustin/go-nntp" + "github.com/profullstack/agentbbs/internal/news/nntpd" + "github.com/profullstack/agentbbs/internal/store" +) + +// fakeStore is an in-memory NewsStore for backend tests. +type fakeStore struct { + users map[string]store.User + groups map[string]store.NewsGroup + articles []store.NewsArticle +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + users: map[string]store.User{ + "alice": {ID: 1, Name: "alice"}, + "bob": {ID: 2, Name: "bob", Banned: true}, + }, + groups: map[string]store.NewsGroup{ + "pfs.general": {Name: "pfs.general", Posting: true}, + "pfs.locked": {Name: "pfs.locked", Posting: false}, + }, + } +} + +func (f *fakeStore) UserByName(name string) (store.User, bool, error) { + u, ok := f.users[name] + return u, ok, nil +} +func (f *fakeStore) EnsureNewsGroup(name, desc string) error { + if _, ok := f.groups[name]; !ok { + f.groups[name] = store.NewsGroup{Name: name, Description: desc, Posting: true} + } + return nil +} +func (f *fakeStore) NewsGroups() ([]store.NewsGroup, error) { + var out []store.NewsGroup + for _, g := range f.groups { + out = append(out, g) + } + return out, nil +} +func (f *fakeStore) NewsGroup(name string) (store.NewsGroup, bool, error) { + g, ok := f.groups[name] + return g, ok, nil +} +func (f *fakeStore) NewsArticleByNum(group string, num int64) (store.NewsArticle, bool, error) { + for _, a := range f.articles { + if a.Group == group && a.Num == num { + return a, true, nil + } + } + return store.NewsArticle{}, false, nil +} +func (f *fakeStore) NewsArticleByMsgID(id string) (store.NewsArticle, bool, error) { + for _, a := range f.articles { + if a.MsgID == id { + return a, true, nil + } + } + return store.NewsArticle{}, false, nil +} +func (f *fakeStore) NewsArticlesRange(group string, from, to int64) ([]store.NewsArticle, error) { + var out []store.NewsArticle + for _, a := range f.articles { + if a.Group == group && a.Num >= from && a.Num <= to { + out = append(out, a) + } + } + return out, nil +} +func (f *fakeStore) InsertNewsArticle(a store.NewsArticle) (store.NewsArticle, error) { + var max int64 + for _, x := range f.articles { + if x.Group == a.Group && x.Num > max { + max = x.Num + } + } + a.Num = max + 1 + f.articles = append(f.articles, a) + return a, nil +} + +func TestAnonymousBackendRefusesData(t *testing.T) { + b := &backend{st: newFakeStore(), host: "news.h"} + if b.Authorized() || b.AllowPost() { + t.Fatal("anonymous backend must not be authorized or allowed to post") + } + if _, err := b.ListGroups(-1); !errors.Is(err, nntpd.ErrAuthRequired) { + t.Fatalf("ListGroups should require auth, got %v", err) + } + if _, err := b.GetGroup("pfs.general"); !errors.Is(err, nntpd.ErrAuthRequired) { + t.Fatalf("GetGroup should require auth, got %v", err) + } + if _, err := b.GetArticle(&nntp.Group{Name: "pfs.general"}, "1"); !errors.Is(err, nntpd.ErrAuthRequired) { + t.Fatalf("GetArticle should require auth, got %v", err) + } +} + +func TestAuthenticate(t *testing.T) { + b := &backend{st: newFakeStore(), host: "news.h"} + + if _, err := b.Authenticate("nobody", "x"); !errors.Is(err, nntpd.ErrAuthRejected) { + t.Fatalf("unknown user must be rejected, got %v", err) + } + if _, err := b.Authenticate("bob", "x"); !errors.Is(err, nntpd.ErrAuthRejected) { + t.Fatalf("banned user must be rejected, got %v", err) + } + nb, err := b.Authenticate("Alice", "anything") // case-insensitive, password ignored + if err != nil { + t.Fatalf("valid member rejected: %v", err) + } + ab, ok := nb.(*backend) + if !ok || !ab.Authorized() || !ab.AllowPost() || ab.user != "alice" { + t.Fatalf("authed backend wrong: %+v ok=%v", ab, ok) + } +} + +func TestPostStampsFromAndNumbers(t *testing.T) { + fs := newFakeStore() + ab := &backend{st: fs, host: "news.h", user: "alice"} + + mkArticle := func(groups, subject, body string) *nntp.Article { + h := textproto.MIMEHeader{} + h.Set("Newsgroups", groups) + h.Set("Subject", subject) + h.Set("From", "forged ") // must be ignored + return &nntp.Article{Header: h, Body: strings.NewReader(body)} + } + + if err := ab.Post(mkArticle("pfs.general", "Hi", "hello\nworld\n")); err != nil { + t.Fatalf("post: %v", err) + } + if len(fs.articles) != 1 { + t.Fatalf("expected 1 stored article, got %d", len(fs.articles)) + } + got := fs.articles[0] + if got.From != "alice " { + t.Fatalf("From not stamped to member: %q", got.From) + } + if got.Num != 1 || got.Subject != "Hi" || got.MsgID == "" { + t.Fatalf("stored article wrong: %+v", got) + } + + // Posting only to a locked/unknown group yields ErrNotWanted. + if err := ab.Post(mkArticle("pfs.locked, no.such.group", "x", "y")); !errors.Is(err, nntpd.ErrNotWanted) { + t.Fatalf("post to locked/unknown should be not-wanted, got %v", err) + } + + // A retrieved article reconstructs headers and body. + art, err := ab.GetArticle(&nntp.Group{Name: "pfs.general"}, "1") + if err != nil { + t.Fatalf("get article: %v", err) + } + if art.Header.Get("From") != "alice " || art.Header.Get("Subject") != "Hi" { + t.Fatalf("reconstructed headers wrong: %+v", art.Header) + } + body, _ := io.ReadAll(art.Body) + if string(body) != "hello\nworld\n" { + t.Fatalf("reconstructed body wrong: %q", body) + } +} diff --git a/internal/news/nntpd/server.go b/internal/news/nntpd/server.go new file mode 100644 index 0000000..2c58d6a --- /dev/null +++ b/internal/news/nntpd/server.go @@ -0,0 +1,435 @@ +// Package nntpd is a vendored, lightly patched copy of +// github.com/dustin/go-nntp/server (MIT, Dustin Sallings). Two changes make it +// a faithful, members-only Usenet server: +// +// 1. AUTHINFO USER/PASS now return RFC 4643 response codes (381 "password +// required" then 281 "authentication accepted"). Upstream returned 350/250, +// which neither the matching go-nntp client nor standard newsreaders +// (slrn, tin) accept — so authentication was effectively broken. +// 2. The per-command and drop-connection logging is routed through an optional +// Server.ErrLog and the noisy "Got cmd" line is dropped (a public server on +// a small box should not log every protocol verb). +// +// Everything else is upstream. The Backend interface is unchanged, so backends +// written against go-nntp/server work here verbatim. +package nntpd + +import ( + "fmt" + "io" + "log" + "math" + "net" + "net/textproto" + "strconv" + "strings" + + "github.com/dustin/go-nntp" +) + +// An NNTPError is a coded NNTP error message. +type NNTPError struct { + Code int + Msg string +} + +// Sentinel errors, returned by backends and rendered to the client. +var ( + ErrNoSuchGroup = &NNTPError{411, "No such newsgroup"} + ErrNoGroupSelected = &NNTPError{412, "No newsgroup selected"} + ErrInvalidMessageID = &NNTPError{430, "No article with that message-id"} + ErrInvalidArticleNumber = &NNTPError{423, "No article with that number"} + ErrNoCurrentArticle = &NNTPError{420, "Current article number is invalid"} + ErrUnknownCommand = &NNTPError{500, "Unknown command"} + ErrSyntax = &NNTPError{501, "not supported, or syntax error"} + ErrPostingNotPermitted = &NNTPError{440, "Posting not permitted"} + ErrPostingFailed = &NNTPError{441, "posting failed"} + ErrNotWanted = &NNTPError{435, "Article not wanted"} + ErrAuthRequired = &NNTPError{450, "authorization required"} + ErrAuthRejected = &NNTPError{452, "authorization rejected"} + ErrNotAuthenticated = &NNTPError{480, "authentication required"} +) + +func (e *NNTPError) Error() string { return fmt.Sprintf("%d %s", e.Code, e.Msg) } + +// Handler is a low-level protocol handler. +type Handler func(args []string, s *session, c *textproto.Conn) error + +// NumberedArticle ties an article to its per-group sequence number. +type NumberedArticle struct { + Num int64 + Article *nntp.Article +} + +// Backend provides the data and does the work. +type Backend interface { + ListGroups(max int) ([]*nntp.Group, error) + GetGroup(name string) (*nntp.Group, error) + GetArticle(group *nntp.Group, id string) (*nntp.Article, error) + GetArticles(group *nntp.Group, from, to int64) ([]NumberedArticle, error) + Authorized() bool + // Authenticate validates the credentials and optionally returns a + // replacement backend for this session (nil keeps the current one). + Authenticate(user, pass string) (Backend, error) + AllowPost() bool + Post(article *nntp.Article) error +} + +type session struct { + server *Server + backend Backend + group *nntp.Group +} + +// Server is an NNTP server handle. +type Server struct { + Handlers map[string]Handler + Backend Backend + // ErrLog, when non-nil, receives connection-level errors. Protocol verbs + // are never logged (a public server should not log every command). + ErrLog *log.Logger + group *nntp.Group +} + +// NewServer builds a server bound to a backend. +func NewServer(backend Backend) *Server { + rv := Server{ + Handlers: make(map[string]Handler), + Backend: backend, + } + rv.Handlers[""] = handleDefault + rv.Handlers["quit"] = handleQuit + rv.Handlers["group"] = handleGroup + rv.Handlers["list"] = handleList + rv.Handlers["head"] = handleHead + rv.Handlers["body"] = handleBody + rv.Handlers["article"] = handleArticle + rv.Handlers["post"] = handlePost + rv.Handlers["ihave"] = handleIHave + rv.Handlers["capabilities"] = handleCap + rv.Handlers["mode"] = handleMode + rv.Handlers["authinfo"] = handleAuthInfo + rv.Handlers["newgroups"] = handleNewGroups + rv.Handlers["over"] = handleOver + rv.Handlers["xover"] = handleOver + return &rv +} + +func (s *Server) logf(format string, args ...any) { + if s.ErrLog != nil { + s.ErrLog.Printf(format, args...) + } +} + +func (s *session) dispatchCommand(cmd string, args []string, c *textproto.Conn) error { + handler, found := s.server.Handlers[strings.ToLower(cmd)] + if !found { + handler = s.server.Handlers[""] + } + return handler(args, s, c) +} + +// Process handles a single NNTP connection. +func (s *Server) Process(nc net.Conn) { + defer nc.Close() + c := textproto.NewConn(nc) + + sess := &session{server: s, backend: s.Backend, group: nil} + + _ = c.PrintfLine("200 Hello!") + for { + l, err := c.ReadLine() + if err != nil { + return + } + cmd := strings.Split(l, " ") + args := []string{} + if len(cmd) > 1 { + args = cmd[1:] + } + err = sess.dispatchCommand(cmd[0], args, c) + if err != nil { + if _, isNNTPError := err.(*NNTPError); err == io.EOF { + return + } else if isNNTPError { + _ = c.PrintfLine("%s", err.Error()) + } else { + s.logf("dropping conn: %v", err) + return + } + } + } +} + +func parseRange(spec string) (low, high int64) { + if spec == "" { + return 0, math.MaxInt64 + } + parts := strings.Split(spec, "-") + if len(parts) == 1 { + h, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + h = math.MaxInt64 + } + return 0, h + } + l, _ := strconv.ParseInt(parts[0], 10, 64) + h, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + h = math.MaxInt64 + } + return l, h +} + +func handleOver(args []string, s *session, c *textproto.Conn) error { + if s.group == nil { + return ErrNoGroupSelected + } + spec := "" + if len(args) > 0 { + spec = args[0] + } + from, to := parseRange(spec) + articles, err := s.backend.GetArticles(s.group, from, to) + if err != nil { + return err + } + _ = c.PrintfLine("224 here it comes") + dw := c.DotWriter() + defer dw.Close() + for _, a := range articles { + fmt.Fprintf(dw, "%d\t%s\t%s\t%s\t%s\t%s\t%d\t%d\n", a.Num, + a.Article.Header.Get("Subject"), + a.Article.Header.Get("From"), + a.Article.Header.Get("Date"), + a.Article.Header.Get("Message-Id"), + a.Article.Header.Get("References"), + a.Article.Bytes, a.Article.Lines) + } + return nil +} + +func handleListOverviewFmt(c *textproto.Conn) error { + if err := c.PrintfLine("215 Order of fields in overview database."); err != nil { + return err + } + dw := c.DotWriter() + defer dw.Close() + _, err := fmt.Fprintln(dw, `Subject: +From: +Date: +Message-ID: +References: +:bytes +:lines`) + return err +} + +func handleList(args []string, s *session, c *textproto.Conn) error { + ltype := "active" + if len(args) > 0 { + ltype = strings.ToLower(args[0]) + } + if ltype == "overview.fmt" { + return handleListOverviewFmt(c) + } + groups, err := s.backend.ListGroups(-1) + if err != nil { + return err + } + _ = c.PrintfLine("215 list of newsgroups follows") + dw := c.DotWriter() + defer dw.Close() + for _, g := range groups { + switch ltype { + case "active": + fmt.Fprintf(dw, "%s %d %d %v\r\n", g.Name, g.High, g.Low, g.Posting) + case "newsgroups": + fmt.Fprintf(dw, "%s %s\r\n", g.Name, g.Description) + } + } + return nil +} + +func handleNewGroups(args []string, s *session, c *textproto.Conn) error { + _ = c.PrintfLine("231 list of newsgroups follows") + _ = c.PrintfLine(".") + return nil +} + +func handleDefault(args []string, s *session, c *textproto.Conn) error { + return ErrUnknownCommand +} + +func handleQuit(args []string, s *session, c *textproto.Conn) error { + _ = c.PrintfLine("205 bye") + return io.EOF +} + +func handleGroup(args []string, s *session, c *textproto.Conn) error { + if len(args) < 1 { + return ErrNoSuchGroup + } + group, err := s.backend.GetGroup(args[0]) + if err != nil { + return err + } + s.group = group + _ = c.PrintfLine("211 %d %d %d %s", group.Count, group.Low, group.High, group.Name) + return nil +} + +func (s *session) getArticle(args []string) (*nntp.Article, error) { + if len(args) == 0 { + return nil, ErrNoCurrentArticle + } + if strings.HasPrefix(args[0], "<") { + return s.backend.GetArticle(s.group, args[0]) + } + if s.group == nil { + return nil, ErrNoGroupSelected + } + return s.backend.GetArticle(s.group, args[0]) +} + +func handleHead(args []string, s *session, c *textproto.Conn) error { + article, err := s.getArticle(args) + if err != nil { + return err + } + _ = c.PrintfLine("221 1 %s", article.MessageID()) + dw := c.DotWriter() + defer dw.Close() + for k, v := range article.Header { + fmt.Fprintf(dw, "%s: %s\r\n", k, v[0]) + } + return nil +} + +func handleBody(args []string, s *session, c *textproto.Conn) error { + article, err := s.getArticle(args) + if err != nil { + return err + } + _ = c.PrintfLine("222 1 %s", article.MessageID()) + dw := c.DotWriter() + defer dw.Close() + _, err = io.Copy(dw, article.Body) + return err +} + +func handleArticle(args []string, s *session, c *textproto.Conn) error { + article, err := s.getArticle(args) + if err != nil { + return err + } + _ = c.PrintfLine("220 1 %s", article.MessageID()) + dw := c.DotWriter() + defer dw.Close() + for k, v := range article.Header { + fmt.Fprintf(dw, "%s: %s\r\n", k, v[0]) + } + fmt.Fprintln(dw, "") + _, err = io.Copy(dw, article.Body) + return err +} + +func handlePost(args []string, s *session, c *textproto.Conn) error { + if !s.backend.AllowPost() { + return ErrPostingNotPermitted + } + _ = c.PrintfLine("340 Go ahead") + var err error + var article nntp.Article + article.Header, err = c.ReadMIMEHeader() + if err != nil { + return ErrPostingFailed + } + article.Body = c.DotReader() + if err = s.backend.Post(&article); err != nil { + return err + } + _ = c.PrintfLine("240 article received OK") + return nil +} + +func handleIHave(args []string, s *session, c *textproto.Conn) error { + if !s.backend.AllowPost() { + return ErrNotWanted + } + article, err := s.backend.GetArticle(nil, args[0]) + if article != nil { + return ErrNotWanted + } + _ = c.PrintfLine("335 send it") + article = &nntp.Article{} + article.Header, err = c.ReadMIMEHeader() + if err != nil { + return ErrPostingFailed + } + article.Body = c.DotReader() + if err = s.backend.Post(article); err != nil { + return err + } + _ = c.PrintfLine("235 article received OK") + return nil +} + +func handleCap(args []string, s *session, c *textproto.Conn) error { + _ = c.PrintfLine("101 Capability list:") + dw := c.DotWriter() + defer dw.Close() + fmt.Fprintf(dw, "VERSION 2\n") + fmt.Fprintf(dw, "READER\n") + fmt.Fprintf(dw, "AUTHINFO USER\n") + if s.backend.AllowPost() { + fmt.Fprintf(dw, "POST\n") + fmt.Fprintf(dw, "IHAVE\n") + } + fmt.Fprintf(dw, "OVER\n") + fmt.Fprintf(dw, "XOVER\n") + fmt.Fprintf(dw, "LIST ACTIVE NEWSGROUPS OVERVIEW.FMT\n") + return nil +} + +func handleMode(args []string, s *session, c *textproto.Conn) error { + if s.backend.AllowPost() { + _ = c.PrintfLine("200 Posting allowed") + } else { + _ = c.PrintfLine("201 Posting prohibited") + } + return nil +} + +// handleAuthInfo implements RFC 4643 AUTHINFO USER/PASS (381 then 281). +func handleAuthInfo(args []string, s *session, c *textproto.Conn) error { + if len(args) < 2 { + return ErrSyntax + } + if strings.ToLower(args[0]) != "user" { + return ErrSyntax + } + if s.backend.Authorized() { + return c.PrintfLine("281 already authenticated") + } + + if err := c.PrintfLine("381 Password required"); err != nil { + return err + } + a, err := c.ReadLine() + if err != nil { + return err + } + parts := strings.SplitN(a, " ", 3) + if len(parts) < 3 || strings.ToLower(parts[0]) != "authinfo" || strings.ToLower(parts[1]) != "pass" { + return ErrSyntax + } + b, err := s.backend.Authenticate(args[1], parts[2]) + if err != nil { + return err + } + if b != nil { + s.backend = b + } + return c.PrintfLine("281 authentication accepted") +} diff --git a/internal/news/reader.go b/internal/news/reader.go new file mode 100644 index 0000000..ae68b32 --- /dev/null +++ b/internal/news/reader.go @@ -0,0 +1,114 @@ +package news + +import ( + "fmt" + "io" + "sort" + "strconv" + "strings" + + "github.com/dustin/go-nntp" + nntpclient "github.com/dustin/go-nntp/client" +) + +// Reader is a thin NNTP client used by the in-BBS `news@` TUI. It dials the +// loopback listener and authenticates as the member, so it exercises the same +// server path (auth, From-stamping, numbering) as external newsreaders. +type Reader struct { + c *nntpclient.Client + user string +} + +// Dial connects to the loopback NNTP server at addr and authenticates as user. +// The password is ignored by the server (membership is the credential). +func Dial(addr, user string) (*Reader, error) { + c, err := nntpclient.New("tcp", addr) + if err != nil { + return nil, err + } + if _, err := c.Authenticate(user, "-"); err != nil { + _ = c.Close() + return nil, fmt.Errorf("auth: %w", err) + } + return &Reader{c: c, user: user}, nil +} + +// Close ends the NNTP session. +func (r *Reader) Close() error { return r.c.Close() } + +// User is the authenticated member name. +func (r *Reader) User() string { return r.user } + +// Groups lists the available newsgroups, name-sorted. +func (r *Reader) Groups() ([]nntp.Group, error) { + gs, err := r.c.List("active") + if err != nil { + return nil, err + } + sort.Slice(gs, func(i, j int) bool { return gs[i].Name < gs[j].Name }) + return gs, nil +} + +// Select makes group current and returns its bounds. +func (r *Reader) Select(name string) (nntp.Group, error) { return r.c.Group(name) } + +// Overview is one row of a group's article overview. +type Overview struct { + Num int64 + Subject string + From string + Date string + MsgID string + Refs string +} + +// Overview fetches the overview rows for [low,high] in the current group. +func (r *Reader) Overview(low, high int64) ([]Overview, error) { + if high < low || high == 0 { + return nil, nil + } + lines, err := r.c.Over(fmt.Sprintf("%d-%d", low, high)) + if err != nil { + return nil, err + } + out := make([]Overview, 0, len(lines)) + for _, l := range lines { + f := strings.Split(l, "\t") + if len(f) < 6 { + continue + } + num, _ := strconv.ParseInt(f[0], 10, 64) + out = append(out, Overview{ + Num: num, Subject: f[1], From: f[2], Date: f[3], MsgID: f[4], Refs: f[5], + }) + } + return out, nil +} + +// Article returns the full article (headers and body) for a number in the +// current group. +func (r *Reader) Article(num int64) (string, error) { + _, _, rd, err := r.c.Article(strconv.FormatInt(num, 10)) + if err != nil { + return "", err + } + b, err := io.ReadAll(rd) + if err != nil { + return "", err + } + return string(b), nil +} + +// Post submits an article to group with the given subject/body. References ties +// a reply to its parent (its Message-ID). The server stamps the From header. +func (r *Reader) Post(group, subject, references, body string) error { + var b strings.Builder + fmt.Fprintf(&b, "Newsgroups: %s\r\n", group) + fmt.Fprintf(&b, "Subject: %s\r\n", subject) + if references != "" { + fmt.Fprintf(&b, "References: %s\r\n", references) + } + b.WriteString("\r\n") + b.WriteString(strings.ReplaceAll(body, "\n", "\r\n")) + return r.c.Post(strings.NewReader(b.String())) +} diff --git a/internal/news/server.go b/internal/news/server.go new file mode 100644 index 0000000..8ebbed0 --- /dev/null +++ b/internal/news/server.go @@ -0,0 +1,164 @@ +package news + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "os" + "strings" + "sync" + "time" + + "github.com/profullstack/agentbbs/internal/news/nntpd" +) + +// DefaultAddr is the loopback plaintext listener the in-BBS `news@` reader dials. +// The public surface is the TLS (NNTPS) listener; see ServeTLS. +const DefaultAddr = "127.0.0.1:1119" + +// DefaultGroups are seeded on first boot if AGENTBBS_NEWS_GROUPS is unset. +var DefaultGroups = []GroupSpec{ + {"pfs.announce", "Official announcements (read-mostly)"}, + {"pfs.general", "General discussion for members"}, + {"pfs.agents", "For and about AI agents on the BBS"}, + {"pfs.support", "Help, questions, and bug reports"}, +} + +// GroupSpec is a newsgroup to seed. +type GroupSpec struct{ Name, Description string } + +// Server hosts the members-only NNTP network over the shared store. +type Server struct { + st NewsStore + srv *nntpd.Server + host string +} + +// New builds a news Server. host is the public hostname used in Message-IDs and +// stamped From addresses (e.g. "news.profullstack.com"). +func New(st NewsStore, host string) *Server { + return &Server{ + st: st, + srv: nntpd.NewServer(&backend{st: st, host: host}), + host: host, + } +} + +// SeedGroups ensures the given groups exist. A nil/empty slice seeds +// DefaultGroups. Idempotent. +func (s *Server) SeedGroups(groups []GroupSpec) error { + if len(groups) == 0 { + groups = DefaultGroups + } + for _, g := range groups { + if err := s.st.EnsureNewsGroup(g.Name, g.Description); err != nil { + return err + } + } + return nil +} + +// ParseGroups turns the AGENTBBS_NEWS_GROUPS env value into GroupSpecs. Each +// entry is "name" or "name:description", comma- or whitespace-separated. +func ParseGroups(s string) []GroupSpec { + var out []GroupSpec + for _, field := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == '\n' }) { + field = strings.TrimSpace(field) + if field == "" { + continue + } + name, desc, _ := strings.Cut(field, ":") + out = append(out, GroupSpec{Name: strings.TrimSpace(name), Description: strings.TrimSpace(desc)}) + } + return out +} + +// Serve accepts connections on ln until ctx is cancelled. The anonymous backend +// is immutable and AUTHINFO swaps only the per-session backend, so one Server is +// safe to share across all connections. +func (s *Server) Serve(ctx context.Context, ln net.Listener) error { + go func() { <-ctx.Done(); _ = ln.Close() }() + for { + conn, err := ln.Accept() + if err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + go s.srv.Process(conn) + } +} + +// ServeLoopback listens for plaintext NNTP on addr (loopback only — it carries +// no TLS, so it must never be a public interface). +func (s *Server) ServeLoopback(ctx context.Context, addr string) error { + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + return s.Serve(ctx, ln) +} + +// ServeTLS listens for NNTPS on addr using the certificate at certFile/keyFile, +// reloading it from disk when it changes (so Caddy cert renewals are picked up +// without a restart). +func (s *Server) ServeTLS(ctx context.Context, addr, certFile, keyFile string) error { + k := &certKeeper{certFile: certFile, keyFile: keyFile} + if _, err := k.get(); err != nil { + return fmt.Errorf("load news TLS cert: %w", err) + } + ln, err := tls.Listen("tcp", addr, &tls.Config{ + MinVersion: tls.VersionTLS12, + GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return k.get() }, + }) + if err != nil { + return err + } + return s.Serve(ctx, ln) +} + +// certKeeper loads a TLS keypair from disk and reloads it when either file's +// modification time changes. +type certKeeper struct { + certFile, keyFile string + + mu sync.Mutex + cert *tls.Certificate + modSum int64 + checked time.Time +} + +func (k *certKeeper) get() (*tls.Certificate, error) { + k.mu.Lock() + defer k.mu.Unlock() + + // Re-stat at most every 30s to keep the handshake hot path cheap. + if k.cert != nil && time.Since(k.checked) < 30*time.Second { + return k.cert, nil + } + k.checked = time.Now() + + sum := statSum(k.certFile) ^ statSum(k.keyFile) + if k.cert != nil && sum == k.modSum { + return k.cert, nil + } + cert, err := tls.LoadX509KeyPair(k.certFile, k.keyFile) + if err != nil { + if k.cert != nil { + return k.cert, nil // keep serving the last good cert on a transient read error + } + return nil, err + } + k.cert, k.modSum = &cert, sum + return k.cert, nil +} + +func statSum(path string) int64 { + fi, err := os.Stat(path) + if err != nil { + return 0 + } + return fi.ModTime().UnixNano() ^ fi.Size() +} diff --git a/internal/news/server_test.go b/internal/news/server_test.go new file mode 100644 index 0000000..9509089 --- /dev/null +++ b/internal/news/server_test.go @@ -0,0 +1,111 @@ +package news + +import ( + "context" + "net" + "path/filepath" + "strings" + "testing" + + nntpclient "github.com/dustin/go-nntp/client" + "github.com/profullstack/agentbbs/internal/store" +) + +// startServer brings up a news Server over an ephemeral loopback listener and +// returns its address. The server stops when the test ends. +func startServer(t *testing.T) (string, store.Store) { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + ns := New(st, "news.test") + if err := ns.SeedGroups(nil); err != nil { + t.Fatalf("seed: %v", err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go func() { _ = ns.Serve(ctx, ln) }() + return ln.Addr().String(), st +} + +func TestServerEndToEnd(t *testing.T) { + addr, st := startServer(t) + if _, err := st.EnsureUser("alice", "member", "SHA256:a"); err != nil { + t.Fatalf("ensure user: %v", err) + } + + // A non-member cannot authenticate. + if _, err := Dial(addr, "intruder"); err == nil { + t.Fatal("non-member should not be able to connect") + } + + // An unauthenticated raw client must not be able to LIST (members-only). + raw, err := nntpclient.New("tcp", addr) + if err != nil { + t.Fatalf("raw dial: %v", err) + } + if _, err := raw.List("active"); err == nil { + t.Fatal("unauthenticated LIST must be refused") + } + _ = raw.Close() + + // A member connects, posts, and reads it back. + r, err := Dial(addr, "alice") + if err != nil { + t.Fatalf("member dial: %v", err) + } + defer r.Close() + + if err := r.Post("pfs.general", "First post", "", "Hello, Usenet.\n"); err != nil { + t.Fatalf("post: %v", err) + } + + groups, err := r.Groups() + if err != nil { + t.Fatalf("groups: %v", err) + } + var general bool + for _, g := range groups { + if g.Name == "pfs.general" { + general = true + } + } + if !general { + t.Fatalf("pfs.general not listed: %+v", groups) + } + + g, err := r.Select("pfs.general") + if err != nil { + t.Fatalf("select: %v", err) + } + if g.High < 1 { + t.Fatalf("expected at least one article, high=%d", g.High) + } + + ov, err := r.Overview(g.Low, g.High) + if err != nil { + t.Fatalf("overview: %v", err) + } + if len(ov) != 1 || ov[0].Subject != "First post" { + t.Fatalf("overview wrong: %+v", ov) + } + // From is stamped to the member, not anything client-supplied. + if !strings.Contains(ov[0].From, "alice") { + t.Fatalf("From not stamped: %q", ov[0].From) + } + + art, err := r.Article(ov[0].Num) + if err != nil { + t.Fatalf("article: %v", err) + } + if !strings.Contains(art, "Hello, Usenet.") || !strings.Contains(art, "Subject: First post") { + t.Fatalf("article body/headers wrong: %q", art) + } +} diff --git a/internal/news/tui.go b/internal/news/tui.go new file mode 100644 index 0000000..62f3d7d --- /dev/null +++ b/internal/news/tui.go @@ -0,0 +1,435 @@ +package news + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/ssh" + "github.com/dustin/go-nntp" +) + +var ( + nTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c084fc")) + nSel = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1020")).Background(lipgloss.Color("#38bdf8")) + nMeta = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + nFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) + nErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + nHint = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) +) + +// RunReader connects the member to the loopback NNTP server and drives the +// newsreader TUI over the SSH session until they leave. +func RunReader(s ssh.Session, addr, user string) error { + ptyReq, winCh, hasPty := s.Pty() + if !hasPty { + _, _ = s.Write([]byte("news needs a terminal (ssh -t news@)\r\n")) + return nil + } + r, err := Dial(addr, user) + if err != nil { + return err + } + m := &model{r: r, width: ptyReq.Window.Width, height: ptyReq.Window.Height, mode: modeGroups} + 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}) + } + }() + _, runErr := p.Run() + _ = r.Close() + return runErr +} + +type mode int + +const ( + modeGroups mode = iota + modeThreads + modeArticle + modeCompose +) + +type model struct { + r *Reader + mode mode + + groups []nntp.Group + gSel int + + group nntp.Group + threads []Overview + tSel int + + article string + aScroll int + + // compose + cField int // 0=subject, 1=body + cSubject string + cBody string + cRefs string + + status string + width, height int +} + +// async message types +type groupsMsg struct { + groups []nntp.Group + err error +} +type threadsMsg struct { + group nntp.Group + threads []Overview + err error +} +type articleMsg struct { + text string + err error +} +type postedMsg struct{ err error } + +func (m *model) Init() tea.Cmd { return m.loadGroups } + +func (m *model) loadGroups() tea.Msg { + gs, err := m.r.Groups() + return groupsMsg{groups: gs, err: err} +} + +func (m *model) loadThreads(name string) tea.Cmd { + return func() tea.Msg { + g, err := m.r.Select(name) + if err != nil { + return threadsMsg{err: err} + } + ov, err := m.r.Overview(g.Low, g.High) + return threadsMsg{group: g, threads: ov, err: err} + } +} + +func (m *model) loadArticle(num int64) tea.Cmd { + return func() tea.Msg { + txt, err := m.r.Article(num) + return articleMsg{text: txt, err: err} + } +} + +func (m *model) submitPost() tea.Cmd { + group, subject, refs, body := m.group.Name, strings.TrimSpace(m.cSubject), m.cRefs, m.cBody + return func() tea.Msg { + if subject == "" { + subject = "(no subject)" + } + return postedMsg{err: m.r.Post(group, subject, refs, body)} + } +} + +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 groupsMsg: + if msg.err != nil { + m.status = "groups: " + msg.err.Error() + } else { + m.groups = msg.groups + } + case threadsMsg: + if msg.err != nil { + m.status = "open group: " + msg.err.Error() + m.mode = modeGroups + } else { + m.group, m.threads, m.tSel, m.mode = msg.group, msg.threads, 0, modeThreads + } + case articleMsg: + if msg.err != nil { + m.status = "open article: " + msg.err.Error() + } else { + m.article, m.aScroll, m.mode = msg.text, 0, modeArticle + } + case postedMsg: + if msg.err != nil { + m.status = "post failed: " + msg.err.Error() + return m, nil + } + m.status = "posted to " + m.group.Name + m.mode = modeThreads + return m, m.loadThreads(m.group.Name) + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.mode == modeCompose { + return m.composeKey(msg) + } + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + } + switch m.mode { + case modeGroups: + switch msg.String() { + case "q", "esc": + return m, tea.Quit + case "up", "k": + if m.gSel > 0 { + m.gSel-- + } + case "down", "j": + if m.gSel < len(m.groups)-1 { + m.gSel++ + } + case "enter", "right", "l": + if len(m.groups) > 0 { + return m, m.loadThreads(m.groups[m.gSel].Name) + } + } + case modeThreads: + switch msg.String() { + case "q", "esc", "left", "h": + m.mode = modeGroups + case "up", "k": + if m.tSel > 0 { + m.tSel-- + } + case "down", "j": + if m.tSel < len(m.threads)-1 { + m.tSel++ + } + case "enter", "right", "l": + if len(m.threads) > 0 { + return m, m.loadArticle(m.threads[m.tSel].Num) + } + case "p": + m.startCompose("", "") + } + case modeArticle: + switch msg.String() { + case "q", "esc", "left", "h": + m.mode = modeThreads + case "up", "k": + if m.aScroll > 0 { + m.aScroll-- + } + case "down", "j": + m.aScroll++ + case "r": + cur := m.threads[m.tSel] + subj := cur.Subject + if !strings.HasPrefix(strings.ToLower(subj), "re:") { + subj = "Re: " + subj + } + refs := strings.TrimSpace(cur.Refs + " " + cur.MsgID) + m.startCompose(subj, refs) + } + } + return m, nil +} + +func (m *model) startCompose(subject, refs string) { + m.mode = modeCompose + m.cField = 0 + m.cSubject = subject + m.cBody = "" + m.cRefs = refs +} + +func (m *model) composeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.mode = modeThreads + m.status = "compose cancelled" + return m, nil + case "ctrl+s": + return m, m.submitPost() + case "tab": + m.cField = (m.cField + 1) % 2 + return m, nil + } + if m.cField == 0 { // subject + switch msg.String() { + case "enter": + m.cField = 1 + case "backspace": + if len(m.cSubject) > 0 { + m.cSubject = m.cSubject[:len(m.cSubject)-1] + } + default: + if msg.Type == tea.KeyRunes || msg.String() == " " { + m.cSubject += string(msg.Runes) + } + } + return m, nil + } + // body + switch msg.String() { + case "enter": + m.cBody += "\n" + case "backspace": + if len(m.cBody) > 0 { + m.cBody = m.cBody[:len(m.cBody)-1] + } + default: + if msg.Type == tea.KeyRunes || msg.String() == " " { + m.cBody += string(msg.Runes) + } + } + return m, nil +} + +func (m *model) View() string { + switch m.mode { + case modeGroups: + return m.viewGroups() + case modeThreads: + return m.viewThreads() + case modeArticle: + return m.viewArticle() + case modeCompose: + return m.viewCompose() + } + return "" +} + +func (m *model) rows() int { + r := m.height - 4 + if r < 3 { + r = 3 + } + return r +} + +func (m *model) frame(header, body, hint string) string { + status := "" + if m.status != "" { + status = "\n" + nMeta.Render(m.status) + } + return lipgloss.NewStyle().Padding(0, 1).Render( + nTitle.Render(header) + "\n\n" + body + status + "\n\n" + nHint.Render(hint)) +} + +func (m *model) viewGroups() string { + var b strings.Builder + for i, g := range m.groups { + line := fmt.Sprintf("%-24s %5d articles", g.Name, g.High) + if i == m.gSel { + line = nSel.Render("› " + line) + } else { + line = " " + line + } + b.WriteString(line + "\n") + } + if len(m.groups) == 0 { + b.WriteString(nMeta.Render("no groups yet")) + } + return m.frame("news@ "+m.r.User()+" — newsgroups", b.String(), + "↑/↓ move · enter open · q quit") +} + +func (m *model) viewThreads() string { + var b strings.Builder + start, rows := scrollStart(m.tSel, len(m.threads), m.rows()) + for i := start; i < len(m.threads) && i < start+rows; i++ { + t := m.threads[i] + line := fmt.Sprintf("%-50s %s", truncate(t.Subject, 50), nFrom.Render(shortFrom(t.From))) + if i == m.tSel { + line = nSel.Render("› ") + line + } else { + line = " " + line + } + b.WriteString(line + "\n") + } + if len(m.threads) == 0 { + b.WriteString(nMeta.Render("no articles — press p to post the first one")) + } + return m.frame("news@ — "+m.group.Name, b.String(), + "↑/↓ move · enter read · p post · esc groups") +} + +func (m *model) viewArticle() string { + lines := strings.Split(strings.ReplaceAll(m.article, "\r\n", "\n"), "\n") + rows := m.rows() + if m.aScroll > len(lines)-1 { + m.aScroll = max(0, len(lines)-1) + } + end := m.aScroll + rows + if end > len(lines) { + end = len(lines) + } + body := strings.Join(lines[m.aScroll:end], "\n") + return m.frame("news@ — "+m.group.Name, body, + "↑/↓ scroll · r reply · esc back") +} + +func (m *model) viewCompose() string { + subjLabel, bodyLabel := " Subject: ", " Body:" + if m.cField == 0 { + subjLabel = nSel.Render("› Subject: ") + } else { + bodyLabel = nSel.Render("› Body:") + } + cursor := "" + if m.cField == 0 { + cursor = "█" + } + body := subjLabel + m.cSubject + cursor + "\n" + bodyLabel + "\n" + bodyText := m.cBody + if m.cField == 1 { + bodyText += "█" + } + body += indent(bodyText) + header := "compose → " + m.group.Name + if m.cRefs != "" { + header = "reply → " + m.group.Name + } + return m.frame(header, body, "tab switch field · ctrl+s send · esc cancel") +} + +func scrollStart(sel, n, rows int) (int, int) { + if n <= rows { + return 0, rows + } + start := sel - rows/2 + if start < 0 { + start = 0 + } + if start > n-rows { + start = n - rows + } + return start, rows +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + if n <= 1 { + return s[:n] + } + return s[:n-1] + "…" +} + +// shortFrom renders just the display name (or local part) of a From header. +func shortFrom(from string) string { + if i := strings.IndexByte(from, '<'); i > 0 { + return strings.TrimSpace(from[:i]) + } + if i := strings.IndexByte(from, '@'); i > 0 { + return from[:i] + } + return from +} + +func indent(s string) string { + var b strings.Builder + for _, l := range strings.Split(s, "\n") { + b.WriteString(" " + l + "\n") + } + return b.String() +} diff --git a/internal/store/news_store.go b/internal/store/news_store.go new file mode 100644 index 0000000..1df9501 --- /dev/null +++ b/internal/store/news_store.go @@ -0,0 +1,167 @@ +package store + +import ( + "database/sql" + "errors" + "time" +) + +// EnsureNewsGroup creates a newsgroup if it does not already exist. The +// description is only applied on first creation (re-running is a no-op). +func (s *sqliteStore) EnsureNewsGroup(name, description string) error { + _, err := s.db.Exec( + `INSERT INTO news_groups (name, description) VALUES (?, ?) + ON CONFLICT(name) DO NOTHING`, name, description) + return err +} + +// newsGroupCounts computes Low/High/Count for a group from its articles. +// An empty group reports Low=1, High=0, Count=0 per RFC 3977 convention. +func (s *sqliteStore) newsGroupCounts(name string) (count, low, high int64, err error) { + row := s.db.QueryRow( + `SELECT COUNT(*), COALESCE(MIN(num),0), COALESCE(MAX(num),0) + FROM news_articles WHERE grp = ?`, name) + if err = row.Scan(&count, &low, &high); err != nil { + return 0, 1, 0, err + } + if count == 0 { + low, high = 1, 0 + } + return count, low, high, nil +} + +func (s *sqliteStore) NewsGroup(name string) (NewsGroup, bool, error) { + var g NewsGroup + var posting int + var created string + err := s.db.QueryRow( + `SELECT name, description, posting, created_at FROM news_groups WHERE name = ?`, name). + Scan(&g.Name, &g.Description, &posting, &created) + if errors.Is(err, sql.ErrNoRows) { + return NewsGroup{}, false, nil + } + if err != nil { + return NewsGroup{}, false, err + } + g.Posting = posting != 0 + g.CreatedAt, _ = time.Parse(time.RFC3339, created) + if g.Count, g.Low, g.High, err = s.newsGroupCounts(name); err != nil { + return NewsGroup{}, false, err + } + return g, true, nil +} + +func (s *sqliteStore) NewsGroups() ([]NewsGroup, error) { + rows, err := s.db.Query( + `SELECT name, description, posting, created_at FROM news_groups ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []NewsGroup + for rows.Next() { + var g NewsGroup + var posting int + var created string + if err := rows.Scan(&g.Name, &g.Description, &posting, &created); err != nil { + return nil, err + } + g.Posting = posting != 0 + g.CreatedAt, _ = time.Parse(time.RFC3339, created) + out = append(out, g) + } + if err := rows.Err(); err != nil { + return nil, err + } + // Fill counts (a second pass keeps the listing query simple). + for i := range out { + if out[i].Count, out[i].Low, out[i].High, err = s.newsGroupCounts(out[i].Name); err != nil { + return nil, err + } + } + return out, nil +} + +const newsArticleCols = `grp, num, msg_id, subject, author, refs, date, body, lines, bytes, created_at` + +func scanNewsArticle(sc interface{ Scan(...any) error }) (NewsArticle, error) { + var a NewsArticle + var created string + err := sc.Scan(&a.Group, &a.Num, &a.MsgID, &a.Subject, &a.From, &a.Refs, &a.Date, &a.Body, &a.Lines, &a.Bytes, &created) + if err != nil { + return NewsArticle{}, err + } + a.CreatedAt, _ = time.Parse(time.RFC3339, created) + return a, nil +} + +func (s *sqliteStore) NewsArticleByNum(group string, num int64) (NewsArticle, bool, error) { + a, err := scanNewsArticle(s.db.QueryRow( + `SELECT `+newsArticleCols+` FROM news_articles WHERE grp = ? AND num = ?`, group, num)) + if errors.Is(err, sql.ErrNoRows) { + return NewsArticle{}, false, nil + } + if err != nil { + return NewsArticle{}, false, err + } + return a, true, nil +} + +func (s *sqliteStore) NewsArticleByMsgID(msgID string) (NewsArticle, bool, error) { + a, err := scanNewsArticle(s.db.QueryRow( + `SELECT `+newsArticleCols+` FROM news_articles WHERE msg_id = ? ORDER BY id LIMIT 1`, msgID)) + if errors.Is(err, sql.ErrNoRows) { + return NewsArticle{}, false, nil + } + if err != nil { + return NewsArticle{}, false, err + } + return a, true, nil +} + +func (s *sqliteStore) NewsArticlesRange(group string, from, to int64) ([]NewsArticle, error) { + rows, err := s.db.Query( + `SELECT `+newsArticleCols+` FROM news_articles + WHERE grp = ? AND num >= ? AND num <= ? ORDER BY num`, group, from, to) + if err != nil { + return nil, err + } + defer rows.Close() + var out []NewsArticle + for rows.Next() { + a, err := scanNewsArticle(rows) + if err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// InsertNewsArticle assigns the next per-group sequence number atomically and +// stores the article, returning the stored row (with Num populated). +func (s *sqliteStore) InsertNewsArticle(a NewsArticle) (NewsArticle, error) { + tx, err := s.db.Begin() + if err != nil { + return NewsArticle{}, err + } + defer func() { _ = tx.Rollback() }() + + var next int64 + if err := tx.QueryRow( + `SELECT COALESCE(MAX(num),0)+1 FROM news_articles WHERE grp = ?`, a.Group). + Scan(&next); err != nil { + return NewsArticle{}, err + } + a.Num = next + if _, err := tx.Exec( + `INSERT INTO news_articles (grp, num, msg_id, subject, author, refs, date, body, lines, bytes) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + a.Group, a.Num, a.MsgID, a.Subject, a.From, a.Refs, a.Date, a.Body, a.Lines, a.Bytes); err != nil { + return NewsArticle{}, err + } + if err := tx.Commit(); err != nil { + return NewsArticle{}, err + } + return a, nil +} diff --git a/internal/store/news_store_test.go b/internal/store/news_store_test.go new file mode 100644 index 0000000..c507670 --- /dev/null +++ b/internal/store/news_store_test.go @@ -0,0 +1,95 @@ +package store + +import ( + "path/filepath" + "testing" +) + +func openTestStore(t *testing.T) Store { + t.Helper() + st, err := Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +func TestNewsGroupsAndArticles(t *testing.T) { + st := openTestStore(t) + + // EnsureNewsGroup is idempotent; the description sticks from first creation. + if err := st.EnsureNewsGroup("pfs.general", "General discussion"); err != nil { + t.Fatalf("ensure: %v", err) + } + if err := st.EnsureNewsGroup("pfs.general", "ignored on re-create"); err != nil { + t.Fatalf("ensure 2: %v", err) + } + if err := st.EnsureNewsGroup("pfs.agents", "Agents"); err != nil { + t.Fatalf("ensure agents: %v", err) + } + + gs, err := st.NewsGroups() + if err != nil { + t.Fatalf("groups: %v", err) + } + if len(gs) != 2 || gs[0].Name != "pfs.agents" || gs[1].Name != "pfs.general" { + t.Fatalf("groups sorted/listed wrong: %+v", gs) + } + // An empty group reports Low=1, High=0, Count=0 (RFC 3977 convention). + if gs[1].Description != "General discussion" || gs[1].Count != 0 || gs[1].Low != 1 || gs[1].High != 0 { + t.Fatalf("empty group bounds wrong: %+v", gs[1]) + } + + // Inserting assigns sequential per-group numbers starting at 1. + a1, err := st.InsertNewsArticle(NewsArticle{Group: "pfs.general", MsgID: "<1@h>", Subject: "Hello", From: "alice ", Body: "hi\n", Lines: 1, Bytes: 3}) + if err != nil { + t.Fatalf("insert 1: %v", err) + } + a2, err := st.InsertNewsArticle(NewsArticle{Group: "pfs.general", MsgID: "<2@h>", Subject: "Re: Hello", From: "bob ", Refs: "<1@h>", Body: "yo\n", Lines: 1, Bytes: 3}) + if err != nil { + t.Fatalf("insert 2: %v", err) + } + if a1.Num != 1 || a2.Num != 2 { + t.Fatalf("numbering wrong: a1=%d a2=%d", a1.Num, a2.Num) + } + + // Group counts now reflect the two articles. + g, ok, err := st.NewsGroup("pfs.general") + if err != nil || !ok { + t.Fatalf("group: ok=%v err=%v", ok, err) + } + if g.Count != 2 || g.Low != 1 || g.High != 2 { + t.Fatalf("counts wrong: %+v", g) + } + + // Fetch by number and by message-id. + got, ok, err := st.NewsArticleByNum("pfs.general", 2) + if err != nil || !ok || got.Subject != "Re: Hello" || got.Refs != "<1@h>" { + t.Fatalf("by num: ok=%v err=%v got=%+v", ok, err, got) + } + got, ok, err = st.NewsArticleByMsgID("<1@h>") + if err != nil || !ok || got.Num != 1 { + t.Fatalf("by msgid: ok=%v err=%v got=%+v", ok, err, got) + } + + // Range query for OVER/XOVER. + rng, err := st.NewsArticlesRange("pfs.general", 1, 100) + if err != nil || len(rng) != 2 || rng[0].Num != 1 || rng[1].Num != 2 { + t.Fatalf("range: err=%v got=%+v", err, rng) + } + + // Numbering is independent per group. + b1, err := st.InsertNewsArticle(NewsArticle{Group: "pfs.agents", MsgID: "<3@h>", Subject: "bot", From: "bot ", Body: "beep\n"}) + if err != nil || b1.Num != 1 { + t.Fatalf("agents numbering: num=%d err=%v", b1.Num, err) + } + + // Misses are clean. + if _, ok, _ := st.NewsArticleByNum("pfs.general", 99); ok { + t.Fatal("missing num should not be found") + } + if _, ok, _ := st.NewsGroup("nope"); ok { + t.Fatal("missing group should not be found") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index d4ddcdc..5cb797b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -161,9 +161,58 @@ type Store interface { // ErrQuotaExceeded if the member is already at or above quota. RecordQryptInvite(username, jti string, quota int) error + // News (NNTP) — the members-only Usenet server (docs/news.md). + + // EnsureNewsGroup creates a newsgroup if absent (idempotent), setting the + // description only on first creation. + EnsureNewsGroup(name, description string) error + // NewsGroups lists every group with its article counts, name-sorted. + NewsGroups() ([]NewsGroup, error) + // NewsGroup returns one group (with counts), or ok=false if unknown. + NewsGroup(name string) (NewsGroup, bool, error) + // NewsArticleByNum fetches an article by its per-group sequence number. + NewsArticleByNum(group string, num int64) (NewsArticle, bool, error) + // NewsArticleByMsgID fetches the first article with this Message-ID (any + // group it was posted to). + NewsArticleByMsgID(msgID string) (NewsArticle, bool, error) + // NewsArticlesRange returns articles in [from,to] (inclusive) for a group, + // ordered by number, for OVER/XOVER. + NewsArticlesRange(group string, from, to int64) ([]NewsArticle, error) + // InsertNewsArticle stores an article in a group, assigning the next + // per-group number, and returns the stored row (with its number). + InsertNewsArticle(a NewsArticle) (NewsArticle, error) + Close() error } +// NewsGroup is a newsgroup plus the cached article-number bounds NNTP clients +// expect (Low/High/Count). Empty groups report Low=1, High=0, Count=0. +type NewsGroup struct { + Name string + Description string + Posting bool + Count int64 + Low int64 + High int64 + CreatedAt time.Time +} + +// NewsArticle is one stored article within a group. Headers beyond these are +// reconstructed at serve time (Message-ID, Newsgroups, Path) from these fields. +type NewsArticle struct { + Group string + Num int64 + MsgID string + Subject string + From string // the From: header (stamped to the posting member) + Refs string // the References: header + Date string // the Date: header as posted (RFC1123Z) + Body string + Lines int + Bytes int + CreatedAt time.Time +} + // RatingRow is one ladder entry. type RatingRow struct { User string @@ -399,6 +448,29 @@ CREATE TABLE IF NOT EXISTS qrypt_invites ( created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_qrypt_invites_user ON qrypt_invites(username); +CREATE TABLE IF NOT EXISTS news_groups ( + name TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + posting INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE TABLE IF NOT EXISTS news_articles ( + id INTEGER PRIMARY KEY, + grp TEXT NOT NULL, + num INTEGER NOT NULL, + msg_id TEXT NOT NULL, + subject TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL DEFAULT '', + refs TEXT NOT NULL DEFAULT '', + date TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + lines INTEGER NOT NULL DEFAULT 0, + bytes INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + UNIQUE(grp, num) +); +CREATE INDEX IF NOT EXISTS idx_news_articles_grp ON news_articles(grp, num); +CREATE INDEX IF NOT EXISTS idx_news_articles_msgid ON news_articles(msg_id); ` func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) { diff --git a/setup.sh b/setup.sh index fbf0f63..14cb169 100755 --- a/setup.sh +++ b/setup.sh @@ -39,9 +39,16 @@ SWAP_SIZE="${SWAP_SIZE:-3G}" # swapfile size added on low-RAM hosts (set 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}) +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 ERGO_DATA="${ERGO_DATA:-/var/lib/ergo}" # Ergo state dir (ircd.db, tls/) +FORGEJO="${FORGEJO:-1}" # set 0 to skip the AgentGit Forgejo backend (git.${DOMAIN#*.}) +GIT_DOMAIN="${GIT_DOMAIN:-git.${DOMAIN#*.}}" # AgentGit host (default: git., e.g. git.profullstack.com) +FORGEJO_VERSION="${FORGEJO_VERSION:-11.0.1}" # Forgejo release to install +FORGEJO_HTTP_ADDR="${FORGEJO_HTTP_ADDR:-127.0.0.1:3000}" # Forgejo loopback HTTP (Caddy fronts it) +FORGEJO_DATA="${FORGEJO_DATA:-/var/lib/forgejo}" # Forgejo state dir (repos, db) +FORGEJO_ADMIN_USER="${FORGEJO_ADMIN_USER:-agentgit-admin}" # Forgejo admin used to provision members log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; } @@ -257,6 +264,13 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR} # AGENTBBS_FORWARDEMAIL_DOMAIN=${DOMAIN} # AGENTBBS_WEBMAIL_URL=https://webmail.${DOMAIN} +# AgentGit (git.profullstack.com): every verified member — free and paid alike — +# is provisioned a Forgejo account when they confirm their email. The admin token +# is generated and filled in by setup.sh's Forgejo section (§9d). Without it, +# provisioning is a silent no-op. See docs (logicsrc plugins/agentgit). +AGENTBBS_FORGEJO_URL=https://${GIT_DOMAIN} +AGENTBBS_FORGEJO_ADMIN_TOKEN= + # PairUX video calls rendered as ASCII (video@ / tv@ PairUX sources): # AGENTBBS_LIVEKIT_URL= # AGENTBBS_LIVEKIT_KEY= @@ -274,6 +288,21 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR} # AGENTBBS_QRYPT_INVITE_TTL=168h # AGENTBBS_QRYPT_REDEEM_URL=https://qrypt.chat/anon?invite= # AGENTBBS_QRYPT_INVITE_QUOTA=5 + +# News (Usenet/NNTP) server (docs/news.md). Members-only — free and paid alike, +# like the IRC network. The in-BBS reader is \`ssh news@${DOMAIN}\`; external +# newsreaders and agents connect over NNTPS at news.${DOMAIN}:563. The TLS cert +# paths below are populated by setup.sh from Caddy's news.${DOMAIN} cert; the +# server re-reads them within 30s of renewal (no restart needed). Set AGENTBBS_NEWS=0 +# to disable. AGENTBBS_NEWS_GROUPS seeds groups ("name:desc" comma-separated); +# empty seeds pfs.announce/general/agents/support. +AGENTBBS_NEWS_HOST=news.${DOMAIN} +AGENTBBS_NEWS_TLS_CERT=${DATA_DIR}/news-tls/fullchain.pem +AGENTBBS_NEWS_TLS_KEY=${DATA_DIR}/news-tls/privkey.pem +# AGENTBBS_NEWS=1 +# AGENTBBS_NEWS_ADDR=127.0.0.1:1119 +# AGENTBBS_NEWS_TLS_ADDR=:563 +# AGENTBBS_NEWS_GROUPS=pfs.announce:Announcements,pfs.general:General,pfs.agents:Agents ENV chmod 0640 "$ENV_DIR/agentbbs.env" fi @@ -424,6 +453,34 @@ log "writing Caddyfile" # Caddy indexes host labels from the right (com=0, profullstack=1, bbs=2, …); # the user subdomain is the next label left, i.e. index = DOMAIN's label count. USER_LABEL_IDX=$(printf '%s' "$DOMAIN" | awk -F. '{print NF}') +# A dedicated site for news.${DOMAIN} so Caddy obtains a real LE cert for that +# hostname (the agentbbs NNTP server reuses it for NNTPS on :563 — see §9c). +# Needs a DNS A record news.${DOMAIN} -> this host. The site itself just shows a +# connect hint; the Usenet protocol is on :563, not HTTP. Omitted when NEWS=0. +# AgentGit: front the loopback Forgejo backend at https://${GIT_DOMAIN}. Needs a +# DNS A record git. -> this host. Omitted when FORGEJO=0. Forgejo enforces +# its own members-only access; agentbbs provisions the accounts (§9d). +GIT_SITE="" +if [ "$FORGEJO" = "1" ]; then + GIT_SITE=" +${GIT_DOMAIN} { + encode zstd gzip + reverse_proxy http://${FORGEJO_HTTP_ADDR} +} +" +fi + +NEWS_SITE="" +if [ "$NEWS" = "1" ]; then + NEWS_SITE=" +news.${DOMAIN} { + encode zstd gzip + header Content-Type \"text/plain; charset=utf-8\" + respond \"AgentBBS Usenet (members-only). Point a newsreader at news.${DOMAIN}:563 over NNTPS and AUTHINFO USER (any password). Or from the BBS: ssh -t news@${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 @@ -630,6 +687,194 @@ else systemctl disable --now ergo ergo-certs.timer >/dev/null 2>&1 || true fi +# ---- 9c. News (Usenet/NNTP) server (co-located news.${DOMAIN}) -------------- +# The NNTP server runs INSIDE the agentbbs process (members-only, free + paid): +# a loopback plaintext listener backs `ssh news@`, and a public NNTPS listener +# on :563 serves desktop newsreaders and agents. It reuses Caddy's LE cert for +# news.${DOMAIN} (issued by the Caddy site block above); a timer copies the cert +# into a dir agentbbs reads, and agentbbs re-reads it within 30s (no restart). +# See docs/news.md. Disable with NEWS=0. +NEWS_TLS_DIR="${DATA_DIR}/news-tls" +if [ "$NEWS" = "1" ]; then + log "configuring news (NNTP) server (news.${DOMAIN})" + install -d -m 0750 -o "$SVC_USER" -g "$SVC_USER" "$NEWS_TLS_DIR" + + # Make sure an existing agentbbs.env (only written when absent) learns the + # news knobs on redeploy too. + upsert_env AGENTBBS_NEWS_HOST "news.${DOMAIN}" + upsert_env AGENTBBS_NEWS_TLS_CERT "${NEWS_TLS_DIR}/fullchain.pem" + upsert_env AGENTBBS_NEWS_TLS_KEY "${NEWS_TLS_DIR}/privkey.pem" + + install -m 0755 "${SRC_DIR}/deploy/news-refresh-certs.sh" /usr/local/bin/agentbbs-news-certs + DOMAIN="$DOMAIN" NEWS_HOST="news.${DOMAIN}" NEWS_TLS_DIR="$NEWS_TLS_DIR" SVC_USER="$SVC_USER" \ + /usr/local/bin/agentbbs-news-certs || true + if [ ! -s "$NEWS_TLS_DIR/fullchain.pem" ]; then + warn "no Caddy cert for news.${DOMAIN} yet — using a self-signed cert on :563 until the news-certs timer swaps in the real one (add DNS: news.${DOMAIN} A -> this host)" + openssl req -newkey rsa:2048 -nodes -days 90 -x509 \ + -keyout "$NEWS_TLS_DIR/privkey.pem" -out "$NEWS_TLS_DIR/fullchain.pem" \ + -subj "/CN=news.${DOMAIN}" 2>/dev/null || true + chown -R "$SVC_USER:$SVC_USER" "$NEWS_TLS_DIR" 2>/dev/null || true + fi + + cat > /etc/systemd/system/agentbbs-news-certs.service < /etc/systemd/system/agentbbs-news-certs.timer </dev/null 2>&1 || true + ufw allow 563/tcp >/dev/null +else + upsert_env AGENTBBS_NEWS "0" + systemctl disable --now agentbbs-news-certs.timer >/dev/null 2>&1 || true +fi + +# ---- 9d. AgentGit: Forgejo backend (https://${GIT_DOMAIN}) ------------------ +# Self-hosted Forgejo that powers AgentGit. It listens on a loopback HTTP port +# that Caddy fronts at https://${GIT_DOMAIN} (site block in §9). Members-only: +# open registration is disabled and sign-in is required to view, so the only way +# in is the account agentbbs provisions for every verified member (free + paid) +# using the admin token captured below. See logicsrc plugins/agentgit. FORGEJO=0 +# disables it. +FORGEJO_CONF=/etc/forgejo/app.ini +if [ "$FORGEJO" = "1" ]; then + log "installing Forgejo (AgentGit backend, ${GIT_DOMAIN})" + id -u forgejo >/dev/null 2>&1 \ + || useradd --system --shell /usr/sbin/nologin --home-dir "$FORGEJO_DATA" --create-home forgejo + install -d -m 0750 -o forgejo -g forgejo \ + "$FORGEJO_DATA" "$FORGEJO_DATA/data" "$FORGEJO_DATA/log" "$FORGEJO_DATA/repos" /etc/forgejo + + if [ ! -x /usr/local/bin/forgejo ] || ! /usr/local/bin/forgejo --version 2>/dev/null | grep -q "$FORGEJO_VERSION"; then + case "$(uname -m)" in + x86_64|amd64) FJ_ARCH=amd64 ;; + aarch64|arm64) FJ_ARCH=arm64 ;; + *) FJ_ARCH="" ; warn "unknown arch $(uname -m) for Forgejo; skipping download" ;; + esac + if [ -n "$FJ_ARCH" ]; then + log "downloading forgejo ${FORGEJO_VERSION} (${FJ_ARCH})" + curl -fsSL "https://codeberg.org/forgejo/forgejo/releases/download/v${FORGEJO_VERSION}/forgejo-${FORGEJO_VERSION}-linux-${FJ_ARCH}" \ + -o /usr/local/bin/forgejo && chmod 0755 /usr/local/bin/forgejo \ + || warn "forgejo download failed — backend will be unavailable" + fi + fi + + # app.ini is written once so Forgejo-managed secrets survive redeploys. + if [ ! -f "$FORGEJO_CONF" ] && [ -x /usr/local/bin/forgejo ]; then + FJ_SECRET_KEY=$(sudo -u forgejo /usr/local/bin/forgejo generate secret SECRET_KEY) + FJ_INTERNAL_TOKEN=$(sudo -u forgejo /usr/local/bin/forgejo generate secret INTERNAL_TOKEN) + cat > "$FORGEJO_CONF" < /etc/systemd/system/forgejo.service </dev/null 2>&1 || true + systemctl restart forgejo + sleep 2 + systemctl is-active --quiet forgejo \ + || warn "forgejo failed to start — check: journalctl -u forgejo -n50" + + # First-run: create the admin agentbbs uses to mint member accounts, and store + # an admin-scoped token in agentbbs.env. Guarded on the token being empty so + # reruns never create duplicate tokens. + if ! grep -qE '^AGENTBBS_FORGEJO_ADMIN_TOKEN=.+' "$ENV_DIR/agentbbs.env" 2>/dev/null; then + FJ_ADMIN_PW=$(head -c32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c24) + sudo -u forgejo GITEA_WORK_DIR="$FORGEJO_DATA" /usr/local/bin/forgejo admin user create \ + --admin --username "$FORGEJO_ADMIN_USER" --email "agentgit@${GIT_DOMAIN}" \ + --password "$FJ_ADMIN_PW" --must-change-password=false --config "$FORGEJO_CONF" >/dev/null 2>&1 \ + || true + FJ_TOKEN=$(sudo -u forgejo GITEA_WORK_DIR="$FORGEJO_DATA" /usr/local/bin/forgejo admin user generate-access-token \ + --username "$FORGEJO_ADMIN_USER" --token-name "agentbbs-$(date +%s)" --scopes write:admin \ + --config "$FORGEJO_CONF" 2>/dev/null | grep -oE '[0-9a-f]{40}' | head -1) + if [ -n "$FJ_TOKEN" ]; then + upsert_env AGENTBBS_FORGEJO_URL "https://${GIT_DOMAIN}" + upsert_env AGENTBBS_FORGEJO_ADMIN_TOKEN "$FJ_TOKEN" + log "Forgejo admin token provisioned into agentbbs.env" + else + warn "could not mint Forgejo admin token — set AGENTBBS_FORGEJO_ADMIN_TOKEN by hand (journalctl -u forgejo)" + fi + fi +else + systemctl disable --now forgejo >/dev/null 2>&1 || true +fi + # ---- 10. firewall + start agentbbs on :22 ---------------------------------- log "configuring firewall + starting agentbbs" ufw allow 22/tcp >/dev/null @@ -659,6 +904,10 @@ cat < 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) + AgentGit https://${GIT_DOMAIN} git for members (auto-account on email verify) ${FORGEJO:+(set FORGEJO=0 to disable)} + DNS: ${GIT_DOMAIN} A -> this host Config ${ENV_DIR}/agentbbs.env (set CoinPay + LiveKit, then: systemctl restart agentbbs) Logs journalctl -u agentbbs -f (IRC: journalctl -u ergo -f)