mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
parseRange previously returned (0, MaxInt64) for unparseable input, causing OVER/XOVER to deliver the full article overview instead of returning an empty result. Now returns (0, 0) for any parse error. Fixes #45 Co-authored-by: root <root@vultr.guest>
445 lines
12 KiB
Go
445 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return 0, 0 // malformed — empty range instead of all articles
|
|
}
|
|
return h, h
|
|
}
|
|
l, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
return 0, 0 // malformed — empty range
|
|
}
|
|
h, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil {
|
|
return 0, 0 // malformed — empty range
|
|
}
|
|
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
|
|
}
|
|
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")
|
|
}
|