agentbbs/internal/news/server.go
Anthony Ettinger 3b6b9a4a78 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>
2026-06-14 14:26:23 +00:00

164 lines
4.4 KiB
Go

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