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 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 14:26:23 +00:00
parent 2a9d841ddb
commit 3b6b9a4a78
20 changed files with 2842 additions and 2 deletions

View file

@ -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 <your-bbs-name>` 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.

View file

@ -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 <code>ssh join@"+a.host+"</code> to get a fresh confirmation link.")))
return
}
a.provisionGit(&u)
_, _ = w.Write([]byte(verifyPage("Email confirmed ✓",
"Welcome, "+u.Name+". Your account is active — <code>ssh "+u.Name+"@"+a.host+"</code>.")))
}
// 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 "<!doctype html><meta charset=utf-8><title>" + title + "</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.<host>: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) {

43
deploy/news-refresh-certs.sh Executable file
View file

@ -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/<acme-dir>/<host>/<host>.{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

158
docs/news.md Normal file
View file

@ -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 <you@news.profullstack.com>`, 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.<domain>` 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.<domain>` 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.<host>` | 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.<name>`.
- **Gateway** — mirror `pfs.announce` to the hub MOTD or an IRC channel.
- **NEWNEWS / threading view** in the `news@` reader (group replies by `References`).

1
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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 (<name>.<host>), 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-<code> call routes

143
internal/forgejo/forgejo.go Normal file
View file

@ -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]
}

View file

@ -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")
}
}

261
internal/news/backend.go Normal file
View file

@ -0,0 +1,261 @@
// Package news implements the members-only Usenet (NNTP) server that backs
// news.<host> 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
}

View file

@ -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 <evil@elsewhere>") // 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 <alice@news.h>" {
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 <alice@news.h>" || 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)
}
}

View file

@ -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")
}

114
internal/news/reader.go Normal file
View file

@ -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()))
}

164
internal/news/server.go Normal file
View file

@ -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()
}

View file

@ -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)
}
}

435
internal/news/tui.go Normal file
View file

@ -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@<host>)\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()
}

View file

@ -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
}

View file

@ -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 <alice@h>", 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 <bob@h>", 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 <bot@h>", 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")
}
}

View file

@ -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) {

251
setup.sh
View file

@ -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.<root-of-DOMAIN>, 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.<root> -> 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 <your-bbs-name> (any password). Or from the BBS: ssh -t news@${DOMAIN}\"
}
"
fi
cat > /etc/caddy/Caddyfile <<CADDY
{
email ${ACME_EMAIL}
@ -467,7 +524,7 @@ ${DOMAIN} {
file_server
}
}
${GIT_SITE}${NEWS_SITE}
# Free per-user homepages at <name>.${DOMAIN} (needs wildcard DNS
# *.${DOMAIN} -> this host). On-demand TLS mints a cert only when agentbbs's
# ask endpoint confirms <name> 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 <<UNIT
[Unit]
Description=Refresh AgentBBS news (NNTPS) TLS cert from Caddy for news.${DOMAIN}
[Service]
Type=oneshot
Environment=DOMAIN=${DOMAIN}
Environment=NEWS_HOST=news.${DOMAIN}
Environment=NEWS_TLS_DIR=${NEWS_TLS_DIR}
Environment=SVC_USER=${SVC_USER}
ExecStart=/usr/local/bin/agentbbs-news-certs
UNIT
cat > /etc/systemd/system/agentbbs-news-certs.timer <<UNIT
[Unit]
Description=Periodic AgentBBS news TLS cert refresh from Caddy
[Timer]
OnBootSec=5min
OnUnitActiveSec=12h
Persistent=true
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now 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" <<FJ
APP_NAME = AgentGit
RUN_USER = forgejo
RUN_MODE = prod
[server]
PROTOCOL = http
HTTP_ADDR = ${FORGEJO_HTTP_ADDR%%:*}
HTTP_PORT = ${FORGEJO_HTTP_ADDR##*:}
DOMAIN = ${GIT_DOMAIN}
ROOT_URL = https://${GIT_DOMAIN}/
DISABLE_SSH = true
START_SSH_SERVER = false
[database]
DB_TYPE = sqlite3
PATH = ${FORGEJO_DATA}/data/forgejo.db
[repository]
ROOT = ${FORGEJO_DATA}/repos
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = true
DEFAULT_KEEP_EMAIL_PRIVATE = true
[security]
INSTALL_LOCK = true
SECRET_KEY = ${FJ_SECRET_KEY}
INTERNAL_TOKEN = ${FJ_INTERNAL_TOKEN}
[log]
ROOT_PATH = ${FORGEJO_DATA}/log
FJ
chown forgejo:forgejo "$FORGEJO_CONF"
chmod 0640 "$FORGEJO_CONF"
fi
log "installing forgejo.service"
cat > /etc/systemd/system/forgejo.service <<UNIT
[Unit]
Description=Forgejo (AgentGit backend — ${GIT_DOMAIN})
After=network-online.target
Wants=network-online.target
[Service]
User=forgejo
Group=forgejo
WorkingDirectory=${FORGEJO_DATA}
Environment=GITEA_WORK_DIR=${FORGEJO_DATA}
ExecStart=/usr/local/bin/forgejo web --config ${FORGEJO_CONF} --work-path ${FORGEJO_DATA}
Restart=always
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=${FORGEJO_DATA} /etc/forgejo
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable forgejo >/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 <<DONE
IRC irc.${DOMAIN}:6697 (TLS) native clients ${IRC:+(set IRC=0 to disable)}
wss://${DOMAIN}/irc web clients + agents over WebSocket
/OPER admin <pw> oper password in ${ENV_DIR}/ergo-oper.txt
News news.${DOMAIN}:563 (NNTPS) newsreaders + agents ${NEWS:+(set NEWS=0 to disable)}
ssh -t news@${DOMAIN} the in-BBS newsreader (DNS: news.${DOMAIN} A -> host)
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)