mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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:
parent
2a9d841ddb
commit
3b6b9a4a78
20 changed files with 2842 additions and 2 deletions
20
README.md
20
README.md
|
|
@ -35,6 +35,7 @@ plugins around one shared account system; the full product plan is in
|
|||
| M2 — admin console (`admin@`: users, sessions, moderation, plugins) | ✅ |
|
||||
| M3 — AgentGames (`game@` + WebSocket; TTT/C4, ELO ladder, replays) | ✅ |
|
||||
| IRC (`irc.bbs.profullstack.com` — Ergo network for humans + agents) | ✅ |
|
||||
| News (`news.profullstack.com` — members-only Usenet/NNTP for humans + agents) | ✅ |
|
||||
| M4 — Files (cl1.tech SFTP workspaces) | ⬜ |
|
||||
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ |
|
||||
|
||||
|
|
@ -123,6 +124,25 @@ wss://bbs.profullstack.com/irc
|
|||
the network automatically — no client to install. Set `IRC=0` to skip the
|
||||
server. Full details: [`docs/irc.md`](docs/irc.md).
|
||||
|
||||
### News (Usenet) server
|
||||
|
||||
`setup.sh` also stands up a co-located, members-only **Usenet/NNTP server** at
|
||||
`news.profullstack.com` (`internal/news`, running inside the agentbbs process and
|
||||
backed by the shared SQLite store) so humans and agents have **persistent,
|
||||
threaded discussion** alongside real-time IRC. It is **free for every member**.
|
||||
Authenticate with `AUTHINFO USER <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.
|
||||
|
|
|
|||
|
|
@ -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
43
deploy/news-refresh-certs.sh
Executable 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
158
docs/news.md
Normal 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
1
go.mod
|
|
@ -9,6 +9,7 @@ require (
|
|||
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
|
||||
github.com/charmbracelet/wish v1.4.7
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||
github.com/livekit/protocol v1.46.0
|
||||
github.com/livekit/server-sdk-go/v2 v2.16.6
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -93,8 +93,12 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM
|
|||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-couch v0.0.0-20160816170231-8251128dab73/go.mod h1:WG/TWzFd/MRvOZ4jjna3FQ+K8AKhb2jOw4S2JMw9VKI=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1 h1:R90ND7acg9HKYj3oJBKKefk73DULdC7IlcnS7MV0X1s=
|
||||
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1/go.mod h1:elGbp3dKCIIdwu6jm3y6L93EVn+I6MSzYrcZXhpNS3Y=
|
||||
github.com/dustin/httputil v0.0.0-20170305193905-c47743f54f89/go.mod h1:ZoDWdnxro8Kesk3zrCNOHNFWtajFPSnDMjVEjGjQu/0=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
|
|
|
|||
|
|
@ -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
143
internal/forgejo/forgejo.go
Normal 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]
|
||||
}
|
||||
95
internal/forgejo/forgejo_test.go
Normal file
95
internal/forgejo/forgejo_test.go
Normal 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
261
internal/news/backend.go
Normal 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
|
||||
}
|
||||
171
internal/news/backend_test.go
Normal file
171
internal/news/backend_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
435
internal/news/nntpd/server.go
Normal file
435
internal/news/nntpd/server.go
Normal 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
114
internal/news/reader.go
Normal 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
164
internal/news/server.go
Normal 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()
|
||||
}
|
||||
111
internal/news/server_test.go
Normal file
111
internal/news/server_test.go
Normal 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
435
internal/news/tui.go
Normal 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()
|
||||
}
|
||||
167
internal/store/news_store.go
Normal file
167
internal/store/news_store.go
Normal 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
|
||||
}
|
||||
95
internal/store/news_store_test.go
Normal file
95
internal/store/news_store_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
251
setup.sh
|
|
@ -39,9 +39,16 @@ SWAP_SIZE="${SWAP_SIZE:-3G}" # swapfile size added on low-RAM hosts (set
|
|||
SELF_UPDATE="${SELF_UPDATE:-1}" # set 0 to skip the autonomous self-update systemd timer
|
||||
SELF_UPDATE_INTERVAL="${SELF_UPDATE_INTERVAL:-15min}" # how often the box polls origin for new commits
|
||||
IRC="${IRC:-1}" # set 0 to skip the co-located Ergo IRC server (irc.${DOMAIN})
|
||||
NEWS="${NEWS:-1}" # set 0 to skip the co-located Usenet/NNTP server (news.${DOMAIN})
|
||||
ERGO_VERSION="${ERGO_VERSION:-2.18.0}" # Ergo IRCd release to install
|
||||
IRC_NETWORK="${IRC_NETWORK:-ProfullstackBBS}" # IRC network name shown to clients
|
||||
ERGO_DATA="${ERGO_DATA:-/var/lib/ergo}" # Ergo state dir (ircd.db, tls/)
|
||||
FORGEJO="${FORGEJO:-1}" # set 0 to skip the AgentGit Forgejo backend (git.${DOMAIN#*.})
|
||||
GIT_DOMAIN="${GIT_DOMAIN:-git.${DOMAIN#*.}}" # AgentGit host (default: git.<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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue