mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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>
164 lines
4.4 KiB
Go
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()
|
|
}
|