agentbbs/internal/news/nntpd/server.go
Alex Nazaruk 5fba77f295 fix(nntpd): support open-ended NNTP ranges in XOVER/OVER
parseRange split the spec on '-' and required BOTH bounds to parse as
integers. For a valid open-ended range like '5-' (RFC 3977 6.2.3 / RFC 2980
XOVER: article 5 through the last) strconv.ParseInt('') fails, so parseRange
returned (0, 0) and handleOver asked the backend for articles in [0, 0] —
zero rows. '-10' (first..10) was likewise broken.

Treat an empty bound as the extreme (low 0 / high MaxInt64) while still
returning an empty range for genuinely malformed specs. Add table-driven
tests for the open-ended and malformed forms.
2026-07-02 23:01:10 +02:00

453 lines
12 KiB
Go

// 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
}
fields := strings.Fields(l)
if len(fields) == 0 {
err = handleDefault(nil, sess, c)
} else {
args := []string{}
if len(fields) > 1 {
args = fields[1:]
}
err = sess.dispatchCommand(fields[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
}
}
}
}
// parseRange parses an NNTP article range spec (RFC 3977 §6.2.3 / RFC 2980).
// An empty bound means "the extreme": "5-" is article 5 through the last
// article, "-10" is the first article through 10, "5-10" is 5..10, "5" is the
// single article 5, and "" is all articles. A genuinely malformed bound yields
// an empty range (0, 0).
func parseRange(spec string) (low, high int64) {
if spec == "" {
return 0, math.MaxInt64
}
parts := strings.SplitN(spec, "-", 2)
low, high = 0, math.MaxInt64
if parts[0] != "" {
v, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, 0 // malformed low bound — empty range
}
low = v
}
if len(parts) == 1 { // single article, e.g. "5"
return low, low
}
if parts[1] != "" {
v, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, 0 // malformed high bound — empty range
}
high = v
}
return low, high
}
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
}
if len(args) < 1 {
return ErrSyntax
}
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")
}