summaryrefslogtreecommitdiffstats
path: root/internal/nntp
diff options
context:
space:
mode:
Diffstat (limited to 'internal/nntp')
-rw-r--r--internal/nntp/client.go983
-rw-r--r--internal/nntp/client_test.go357
2 files changed, 1340 insertions, 0 deletions
diff --git a/internal/nntp/client.go b/internal/nntp/client.go
new file mode 100644
index 0000000..5958628
--- /dev/null
+++ b/internal/nntp/client.go
@@ -0,0 +1,983 @@
+package nntp
+
+import (
+ "bufio"
+ "compress/flate"
+ "context"
+ "crypto/tls"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+
+ "golang.org/x/net/proxy"
+)
+
+const (
+ defaultTimeout = 30 * time.Second
+ maxResponseLine = 1 << 20
+ maxMultilineBytes = 64 << 20
+ maxMultilineLines = 1_000_000
+ maxPostBytes = 10 << 20
+ maxPostLineBytes = 998
+)
+
+var tls12AESGCMSuites = []uint16{
+ tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
+ tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
+ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
+ tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
+}
+
+type DialConfig struct {
+ Host string
+ Port string
+ UseTLS bool
+ StartTLS bool
+ InsecureSkipVerify bool
+ Username string
+ Password string
+ SASLMechanism string
+ UseCompression bool
+ ProxyType string
+ ProxyAddress string
+ Timeout time.Duration
+ tlsConfig *tls.Config
+}
+
+type GroupInfo struct {
+ Name string
+ Low int64
+ High int64
+ EstimatedPost int64
+ Posting string
+}
+
+type GroupStatus struct {
+ Name string
+ Count int64
+ Low int64
+ High int64
+}
+
+type ArticleHeader struct {
+ Number int64
+ Subject string
+ From string
+ Date string
+ MessageID string
+ References string
+ Bytes int64
+ Lines int64
+}
+
+type HeaderValue struct {
+ Number int64
+ Value string
+}
+
+type GroupDescription struct {
+ Name string
+ Description string
+}
+
+type ServerDate struct {
+ Date string
+ Time string
+}
+
+type ResponseError struct {
+ Code int
+ Message string
+}
+
+func (e *ResponseError) Error() string {
+ return fmt.Sprintf("NNTP response %d: %s", e.Code, e.Message)
+}
+
+type Client struct {
+ mu sync.Mutex
+ conn net.Conn
+ reader *bufio.Reader
+ writer *bufio.Writer
+ timeout time.Duration
+ closed bool
+}
+
+func Dial(ctx context.Context, cfg DialConfig) (*Client, error) {
+ if err := validateDialConfig(cfg); err != nil {
+ return nil, err
+ }
+ if cfg.UseCompression && cfg.UseTLS {
+ return nil, errors.New("COMPRESS DEFLATE cannot be used with TLS")
+ }
+ timeout := cfg.Timeout
+ if timeout <= 0 {
+ timeout = defaultTimeout
+ }
+ target := net.JoinHostPort(cfg.Host, cfg.Port)
+ netDialer := &net.Dialer{Timeout: timeout, KeepAlive: 30 * time.Second}
+ var raw net.Conn
+ var err error
+ if strings.EqualFold(cfg.ProxyType, "SOCKS5") {
+ proxyDialer, proxyErr := proxy.SOCKS5("tcp", cfg.ProxyAddress, nil, netDialer)
+ if proxyErr != nil {
+ return nil, fmt.Errorf("configure SOCKS5 proxy: %w", proxyErr)
+ }
+ raw, err = proxyDialer.Dial("tcp", target)
+ } else {
+ raw, err = netDialer.DialContext(ctx, "tcp", target)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("connect to NNTP server: %w", err)
+ }
+ conn := raw
+ if cfg.UseTLS && !cfg.StartTLS {
+ tlsConfig := makeTLSConfig(cfg)
+ tlsConn := tls.Client(raw, tlsConfig)
+ if err := tlsConn.HandshakeContext(ctx); err != nil {
+ raw.Close()
+ return nil, fmt.Errorf("complete TLS handshake: %w", err)
+ }
+ conn = tlsConn
+ }
+ client := &Client{
+ conn: conn,
+ reader: bufio.NewReaderSize(conn, 64*1024),
+ writer: bufio.NewWriterSize(conn, 64*1024),
+ timeout: timeout,
+ }
+ if err := client.setDeadline(); err != nil {
+ client.Close()
+ return nil, err
+ }
+ code, message, err := client.readResponse()
+ if err != nil {
+ client.Close()
+ return nil, fmt.Errorf("read NNTP greeting: %w", err)
+ }
+ if code != 200 && code != 201 {
+ client.Close()
+ return nil, &ResponseError{Code: code, Message: message}
+ }
+ if cfg.StartTLS {
+ code, message, err = client.command("STARTTLS")
+ if err != nil {
+ client.Close()
+ return nil, err
+ }
+ if code != 382 {
+ client.Close()
+ return nil, &ResponseError{Code: code, Message: message}
+ }
+ tlsConn := tls.Client(client.conn, makeTLSConfig(cfg))
+ if err := tlsConn.HandshakeContext(ctx); err != nil {
+ _ = client.conn.Close()
+ return nil, fmt.Errorf("complete STARTTLS handshake: %w", err)
+ }
+ client.conn = tlsConn
+ client.reader = bufio.NewReaderSize(tlsConn, 64*1024)
+ client.writer = bufio.NewWriterSize(tlsConn, 64*1024)
+ if err := client.setDeadline(); err != nil {
+ client.Close()
+ return nil, err
+ }
+ }
+ if cfg.Username != "" {
+ if err := client.authenticateWithConfig(cfg); err != nil {
+ client.Close()
+ return nil, err
+ }
+ }
+ if cfg.UseCompression {
+ if err := client.enableCompression(); err != nil {
+ client.Close()
+ return nil, err
+ }
+ }
+ if code, message, err = client.command("MODE READER"); err != nil {
+ client.Close()
+ return nil, err
+ }
+ if code != 200 && code != 201 && code != 500 && code != 501 {
+ client.Close()
+ return nil, &ResponseError{Code: code, Message: message}
+ }
+ _ = client.conn.SetDeadline(time.Time{})
+ return client, nil
+}
+
+func (c *Client) Capabilities() ([]string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ lines, err := c.capabilitiesUnlocked()
+ if responseCode(err) == 500 || responseCode(err) == 501 {
+ return nil, nil
+ }
+ return lines, err
+}
+
+func (c *Client) capabilitiesUnlocked() ([]string, error) {
+ return c.multilineCommand("CAPABILITIES", 101)
+}
+
+func (c *Client) Help() ([]string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.multilineCommand("HELP", 100)
+}
+
+func (c *Client) Date() (ServerDate, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ code, message, err := c.commandUnlocked("DATE")
+ if err != nil {
+ return ServerDate{}, err
+ }
+ if code != 111 {
+ return ServerDate{}, &ResponseError{Code: code, Message: message}
+ }
+ fields := strings.Fields(message)
+ if len(fields) < 2 {
+ return ServerDate{}, errors.New("malformed DATE response")
+ }
+ return ServerDate{Date: fields[0], Time: fields[1]}, nil
+}
+
+func (c *Client) ListOverviewFormat() ([]string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.multilineCommand("LIST OVERVIEW.FMT", 215)
+}
+
+func (c *Client) ListNewsGroups(pattern string) ([]GroupDescription, error) {
+ if strings.ContainsAny(pattern, "\r\n") {
+ return nil, errors.New("invalid LIST NEWSGROUPS pattern")
+ }
+ command := "LIST NEWSGROUPS"
+ if strings.TrimSpace(pattern) != "" {
+ command += " " + strings.TrimSpace(pattern)
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ lines, err := c.multilineCommand(command, 215)
+ if err != nil {
+ return nil, err
+ }
+ groups := make([]GroupDescription, 0, len(lines))
+ for _, line := range lines {
+ fields := strings.SplitN(line, " ", 2)
+ if len(fields) == 0 || !validAtom(fields[0]) {
+ continue
+ }
+ description := ""
+ if len(fields) == 2 {
+ description = strings.TrimSpace(fields[1])
+ }
+ groups = append(groups, GroupDescription{Name: fields[0], Description: description})
+ }
+ return groups, nil
+}
+
+func (c *Client) NewGroups(date, clock, timezone string) ([]string, error) {
+ if !validCommandValue(date) || !validCommandValue(clock) || !validCommandValue(timezone) {
+ return nil, errors.New("invalid NEWGROUPS arguments")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.multilineCommand("NEWGROUPS "+date+" "+clock+" "+timezone, 231)
+}
+
+func (c *Client) NewNews(groups, date, clock, timezone string) ([]string, error) {
+ if !validCommandValue(groups) || !validCommandValue(date) || !validCommandValue(clock) || !validCommandValue(timezone) {
+ return nil, errors.New("invalid NEWNEWS arguments")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.multilineCommand("NEWNEWS "+groups+" "+date+" "+clock+" "+timezone, 230)
+}
+
+func (c *Client) ListGroup(group string) ([]int64, error) {
+ if !validAtom(group) {
+ return nil, errors.New("invalid newsgroup name")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ lines, err := c.multilineCommand("LISTGROUP "+group, 211)
+ if err != nil {
+ return nil, err
+ }
+ articles := make([]int64, 0, len(lines))
+ for _, line := range lines {
+ for _, token := range strings.Fields(line) {
+ parts := strings.SplitN(token, "-", 2)
+ first, parseErr := strconv.ParseInt(parts[0], 10, 64)
+ if parseErr != nil || first < 1 {
+ continue
+ }
+ last := first
+ if len(parts) == 2 {
+ last, parseErr = strconv.ParseInt(parts[1], 10, 64)
+ if parseErr != nil || last < first || last-first > 100000 {
+ continue
+ }
+ }
+ for number := first; number <= last; number++ {
+ articles = append(articles, number)
+ }
+ }
+ }
+ return articles, nil
+}
+
+func (c *Client) Head(number int64) (string, error) {
+ return c.singleArticlePart("HEAD", number, 221)
+}
+
+func (c *Client) Body(number int64) (string, error) {
+ return c.singleArticlePart("BODY", number, 222)
+}
+
+func (c *Client) Stat(number int64) (string, error) {
+ if number < 1 {
+ return "", errors.New("invalid article number")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ code, message, err := c.commandUnlocked("STAT " + strconv.FormatInt(number, 10))
+ if err != nil {
+ return "", err
+ }
+ if code != 223 {
+ return "", &ResponseError{Code: code, Message: message}
+ }
+ return message, nil
+}
+
+func (c *Client) Header(field string, first, last int64) ([]HeaderValue, error) {
+ if !validAtom(field) || first < 1 || last < first || last-first > 10000 {
+ return nil, errors.New("invalid HDR request")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ lines, err := c.multilineCommand("HDR "+field+" "+strconv.FormatInt(first, 10)+"-"+strconv.FormatInt(last, 10), 225)
+ if err != nil {
+ return nil, err
+ }
+ values := make([]HeaderValue, 0, len(lines))
+ for _, line := range lines {
+ fields := strings.SplitN(line, "\t", 2)
+ if len(fields) != 2 {
+ continue
+ }
+ number, parseErr := strconv.ParseInt(fields[0], 10, 64)
+ if parseErr != nil || number < 1 {
+ continue
+ }
+ values = append(values, HeaderValue{Number: number, Value: fields[1]})
+ }
+ return values, nil
+}
+
+func (c *Client) singleArticlePart(command string, number int64, expected int) (string, error) {
+ if number < 1 {
+ return "", errors.New("invalid article number")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ lines, err := c.multilineCommand(command+" "+strconv.FormatInt(number, 10), expected)
+ if err != nil {
+ return "", err
+ }
+ return strings.Join(lines, "\n"), nil
+}
+
+func (c *Client) ListActive() ([]GroupInfo, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ lines, err := c.multilineCommand("LIST ACTIVE", 215)
+ if err != nil {
+ return nil, err
+ }
+ groups := make([]GroupInfo, 0, len(lines))
+ for _, line := range lines {
+ fields := strings.Fields(line)
+ if len(fields) < 4 || !validAtom(fields[0]) {
+ continue
+ }
+ high, highErr := strconv.ParseInt(fields[1], 10, 64)
+ low, lowErr := strconv.ParseInt(fields[2], 10, 64)
+ if highErr != nil || lowErr != nil || high < 0 || low < 0 {
+ continue
+ }
+ groups = append(groups, GroupInfo{
+ Name: fields[0],
+ Low: low,
+ High: high,
+ EstimatedPost: estimatePopulation(low, high),
+ Posting: fields[3],
+ })
+ }
+ return groups, nil
+}
+
+func (c *Client) SelectGroup(group string) (GroupStatus, error) {
+ if !validAtom(group) {
+ return GroupStatus{}, errors.New("invalid newsgroup name")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.selectGroupUnlocked(group)
+}
+
+func (c *Client) selectGroupUnlocked(group string) (GroupStatus, error) {
+ code, message, err := c.commandUnlocked("GROUP " + group)
+ if err != nil {
+ return GroupStatus{}, err
+ }
+ if code != 211 {
+ return GroupStatus{}, &ResponseError{Code: code, Message: message}
+ }
+ fields := strings.Fields(message)
+ if len(fields) < 4 {
+ return GroupStatus{}, errors.New("malformed GROUP response")
+ }
+ count, err := strconv.ParseInt(fields[0], 10, 64)
+ if err != nil {
+ return GroupStatus{}, errors.New("malformed GROUP article count")
+ }
+ low, err := strconv.ParseInt(fields[1], 10, 64)
+ if err != nil {
+ return GroupStatus{}, errors.New("malformed GROUP low article number")
+ }
+ high, err := strconv.ParseInt(fields[2], 10, 64)
+ if err != nil {
+ return GroupStatus{}, errors.New("malformed GROUP high article number")
+ }
+ return GroupStatus{Name: fields[3], Count: count, Low: low, High: high}, nil
+}
+
+func (c *Client) Overview(first, last int64) ([]ArticleHeader, error) {
+ if first < 1 || last < first || last-first > 10_000 {
+ return nil, errors.New("invalid overview range")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.overviewUnlocked(first, last)
+}
+
+func (c *Client) overviewUnlocked(first, last int64) ([]ArticleHeader, error) {
+ rangeArg := strconv.FormatInt(first, 10) + "-" + strconv.FormatInt(last, 10)
+ lines, err := c.multilineCommand("OVER "+rangeArg, 224)
+ if code := responseCode(err); code == 500 || code == 501 {
+ lines, err = c.multilineCommand("XOVER "+rangeArg, 224)
+ }
+ if err != nil {
+ return nil, err
+ }
+ headers := make([]ArticleHeader, 0, len(lines))
+ for _, line := range lines {
+ fields := strings.Split(line, "\t")
+ if len(fields) < 5 {
+ continue
+ }
+ number, err := strconv.ParseInt(fields[0], 10, 64)
+ if err != nil {
+ continue
+ }
+ header := ArticleHeader{
+ Number: number,
+ Subject: fields[1],
+ From: fields[2],
+ Date: fields[3],
+ MessageID: fields[4],
+ }
+ if len(fields) > 5 {
+ header.References = fields[5]
+ }
+ if len(fields) > 6 {
+ header.Bytes, _ = strconv.ParseInt(fields[6], 10, 64)
+ }
+ if len(fields) > 7 {
+ header.Lines, _ = strconv.ParseInt(fields[7], 10, 64)
+ }
+ headers = append(headers, header)
+ }
+ return headers, nil
+}
+
+func (c *Client) LatestOverview(group string, limit int64) (GroupStatus, []ArticleHeader, error) {
+ if !validAtom(group) {
+ return GroupStatus{}, nil, errors.New("invalid newsgroup name")
+ }
+ if limit < 1 || limit > 10_000 {
+ return GroupStatus{}, nil, errors.New("overview limit must be between 1 and 10000")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ status, err := c.selectGroupUnlocked(group)
+ if err != nil {
+ return GroupStatus{}, nil, err
+ }
+ if status.Count == 0 || status.High < status.Low || status.High < 1 {
+ return status, nil, nil
+ }
+ first := status.High - limit + 1
+ if first < status.Low {
+ first = status.Low
+ }
+ headers, err := c.overviewUnlocked(first, status.High)
+ return status, headers, err
+}
+
+func (c *Client) Article(number int64) (string, error) {
+ if number < 1 {
+ return "", errors.New("invalid article number")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.articleUnlocked(number)
+}
+
+func (c *Client) ArticleInGroup(group string, number int64) (string, error) {
+ if !validAtom(group) {
+ return "", errors.New("invalid newsgroup name")
+ }
+ if number < 1 {
+ return "", errors.New("invalid article number")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if _, err := c.selectGroupUnlocked(group); err != nil {
+ return "", err
+ }
+ return c.articleUnlocked(number)
+}
+
+func (c *Client) articleUnlocked(number int64) (string, error) {
+ lines, err := c.multilineCommand("ARTICLE "+strconv.FormatInt(number, 10), 220)
+ if err != nil {
+ return "", err
+ }
+ return strings.Join(lines, "\n"), nil
+}
+
+func (c *Client) Post(article string) error {
+ lines, err := normalizedArticleLines(article)
+ if err != nil {
+ return err
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ code, message, err := c.commandUnlocked("POST")
+ if err != nil {
+ return err
+ }
+ if code != 340 {
+ return &ResponseError{Code: code, Message: message}
+ }
+ if err := c.setDeadline(); err != nil {
+ return err
+ }
+ for _, line := range lines {
+ if strings.HasPrefix(line, ".") {
+ line = "." + line
+ }
+ if _, err := c.writer.WriteString(line + "\r\n"); err != nil {
+ return fmt.Errorf("write article: %w", err)
+ }
+ }
+ if _, err := c.writer.WriteString(".\r\n"); err != nil {
+ return fmt.Errorf("finish article: %w", err)
+ }
+ if err := c.writer.Flush(); err != nil {
+ return fmt.Errorf("flush article: %w", err)
+ }
+ code, message, err = c.readResponse()
+ if err != nil {
+ return err
+ }
+ if code != 240 {
+ return &ResponseError{Code: code, Message: message}
+ }
+ return nil
+}
+
+// ValidateArticle checks the NNTP/MIME-safe text constraints used by Post.
+// It accepts either LF or CRLF input and does not modify the article.
+func ValidateArticle(article string) error {
+ _, err := normalizedArticleLines(article)
+ return err
+}
+
+func normalizedArticleLines(article string) ([]string, error) {
+ if len(article) == 0 || len(article) > maxPostBytes {
+ return nil, fmt.Errorf("article must contain between 1 and %d bytes", maxPostBytes)
+ }
+ if !utf8.ValidString(article) {
+ return nil, errors.New("article is not valid UTF-8")
+ }
+ if strings.ContainsRune(article, '\x00') {
+ return nil, errors.New("article contains a NUL byte")
+ }
+ normalized := strings.ReplaceAll(article, "\r\n", "\n")
+ normalized = strings.ReplaceAll(normalized, "\r", "\n")
+ lines := strings.Split(normalized, "\n")
+ for _, line := range lines {
+ wireBytes := len(line)
+ if strings.HasPrefix(line, ".") {
+ wireBytes++ // NNTP dot-stuffing adds one byte on the wire.
+ }
+ if wireBytes > maxPostLineBytes {
+ return nil, fmt.Errorf("article contains a line longer than %d octets", maxPostLineBytes)
+ }
+ }
+ return lines, nil
+}
+
+func (c *Client) Close() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.closed {
+ return nil
+ }
+ c.closed = true
+ _ = c.setDeadline()
+ _, _, _ = c.commandUnlocked("QUIT")
+ return c.conn.Close()
+}
+
+func (c *Client) authenticate(username, password string) error {
+ if !validCommandValue(username) || !validCommandValue(password) {
+ return errors.New("credentials contain invalid control characters")
+ }
+ code, message, err := c.command("AUTHINFO USER " + username)
+ if err != nil {
+ return err
+ }
+ if code == 281 {
+ return nil
+ }
+ if code != 381 {
+ return &ResponseError{Code: code, Message: message}
+ }
+ if password == "" {
+ return errors.New("server requires a password")
+ }
+ code, message, err = c.command("AUTHINFO PASS " + password)
+ if err != nil {
+ return err
+ }
+ if code != 281 {
+ return &ResponseError{Code: code, Message: message}
+ }
+ return nil
+}
+
+func (c *Client) authenticateWithConfig(cfg DialConfig) error {
+ if strings.TrimSpace(cfg.SASLMechanism) == "" {
+ return c.authenticate(cfg.Username, cfg.Password)
+ }
+ mechanism := strings.ToUpper(strings.TrimSpace(cfg.SASLMechanism))
+ if mechanism != "PLAIN" {
+ return fmt.Errorf("unsupported NNTP SASL mechanism %q", cfg.SASLMechanism)
+ }
+ capabilities, err := c.capabilitiesUnlocked()
+ if err != nil {
+ return fmt.Errorf("query NNTP capabilities for SASL: %w", err)
+ }
+ if !hasCapability(capabilities, "SASL", "PLAIN") {
+ return errors.New("NNTP server does not advertise SASL PLAIN")
+ }
+ code, message, err := c.commandUnlocked("AUTHINFO SASL PLAIN")
+ if err != nil {
+ return err
+ }
+ if code != 383 {
+ if code == 281 {
+ return nil
+ }
+ return &ResponseError{Code: code, Message: message}
+ }
+ response := base64.StdEncoding.EncodeToString([]byte("\x00" + cfg.Username + "\x00" + cfg.Password))
+ if err := c.writeLineUnlocked(response); err != nil {
+ return err
+ }
+ code, message, err = c.readResponse()
+ if err != nil {
+ return err
+ }
+ if code != 281 {
+ return &ResponseError{Code: code, Message: message}
+ }
+ return nil
+}
+
+func (c *Client) enableCompression() error {
+ capabilities, err := c.capabilitiesUnlocked()
+ if err != nil {
+ return fmt.Errorf("query NNTP capabilities for compression: %w", err)
+ }
+ if !hasCapability(capabilities, "COMPRESS", "DEFLATE") {
+ return errors.New("NNTP server does not advertise COMPRESS DEFLATE")
+ }
+ code, message, err := c.commandUnlocked("COMPRESS DEFLATE")
+ if err != nil {
+ return err
+ }
+ if code != 206 {
+ return &ResponseError{Code: code, Message: message}
+ }
+ compressed := newCompressedConn(c.conn)
+ c.conn = compressed
+ c.reader = bufio.NewReaderSize(compressed, 64*1024)
+ c.writer = bufio.NewWriterSize(compressed, 64*1024)
+ return nil
+}
+
+func hasCapability(lines []string, name string, value string) bool {
+ for _, line := range lines {
+ fields := strings.Fields(line)
+ if len(fields) == 0 || !strings.EqualFold(fields[0], name) {
+ continue
+ }
+ if value == "" {
+ return true
+ }
+ for _, field := range fields[1:] {
+ if strings.EqualFold(field, value) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func (c *Client) command(line string) (int, string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.commandUnlocked(line)
+}
+
+func (c *Client) commandUnlocked(line string) (int, string, error) {
+ if c.closed && line != "QUIT" {
+ return 0, "", errors.New("NNTP connection is closed")
+ }
+ if !validCommandValue(line) {
+ return 0, "", errors.New("NNTP command contains invalid control characters")
+ }
+ if err := c.setDeadline(); err != nil {
+ return 0, "", err
+ }
+ if err := c.writeLineUnlocked(line); err != nil {
+ return 0, "", err
+ }
+ return c.readResponse()
+}
+
+func (c *Client) writeLineUnlocked(line string) error {
+ if _, err := c.writer.WriteString(line + "\r\n"); err != nil {
+ return fmt.Errorf("write NNTP command: %w", err)
+ }
+ if err := c.writer.Flush(); err != nil {
+ return fmt.Errorf("flush NNTP command: %w", err)
+ }
+ return nil
+}
+
+type compressedConn struct {
+ net.Conn
+ reader io.ReadCloser
+ writer *flate.Writer
+}
+
+func newCompressedConn(conn net.Conn) *compressedConn {
+ writer, _ := flate.NewWriter(conn, flate.DefaultCompression)
+ return &compressedConn{Conn: conn, reader: flate.NewReader(conn), writer: writer}
+}
+
+func (c *compressedConn) Read(p []byte) (int, error) {
+ return c.reader.Read(p)
+}
+
+func (c *compressedConn) Write(p []byte) (int, error) {
+ n, err := c.writer.Write(p)
+ if flushErr := c.writer.Flush(); err == nil {
+ err = flushErr
+ }
+ return n, err
+}
+
+func (c *compressedConn) Close() error {
+ _ = c.writer.Close()
+ _ = c.reader.Close()
+ return c.Conn.Close()
+}
+
+func (c *Client) multilineCommand(command string, expectedCode int) ([]string, error) {
+ code, message, err := c.commandUnlocked(command)
+ if err != nil {
+ return nil, err
+ }
+ if code != expectedCode {
+ return nil, &ResponseError{Code: code, Message: message}
+ }
+ return c.readMultiline()
+}
+
+func (c *Client) readResponse() (int, string, error) {
+ line, err := readLimitedLine(c.reader)
+ if err != nil {
+ return 0, "", err
+ }
+ if len(line) < 3 {
+ return 0, "", errors.New("malformed NNTP response")
+ }
+ code, err := strconv.Atoi(line[:3])
+ if err != nil {
+ return 0, "", errors.New("malformed NNTP response code")
+ }
+ message := ""
+ if len(line) > 4 {
+ message = line[4:]
+ }
+ return code, message, nil
+}
+
+func (c *Client) readMultiline() ([]string, error) {
+ lines := make([]string, 0, 1024)
+ total := 0
+ for len(lines) < maxMultilineLines {
+ line, err := readLimitedLine(c.reader)
+ if err != nil {
+ return nil, err
+ }
+ if line == "." {
+ return lines, nil
+ }
+ if strings.HasPrefix(line, "..") {
+ line = line[1:]
+ }
+ total += len(line)
+ if total > maxMultilineBytes {
+ return nil, errors.New("NNTP multiline response exceeds size limit")
+ }
+ lines = append(lines, line)
+ }
+ return nil, errors.New("NNTP multiline response exceeds line limit")
+}
+
+func (c *Client) setDeadline() error {
+ if err := c.conn.SetDeadline(time.Now().Add(c.timeout)); err != nil {
+ return fmt.Errorf("set NNTP deadline: %w", err)
+ }
+ return nil
+}
+
+func readLimitedLine(reader *bufio.Reader) (string, error) {
+ buffer := make([]byte, 0, 4096)
+ for {
+ fragment, err := reader.ReadSlice('\n')
+ if len(buffer)+len(fragment) > maxResponseLine {
+ return "", errors.New("NNTP response line exceeds size limit")
+ }
+ buffer = append(buffer, fragment...)
+ if errors.Is(err, bufio.ErrBufferFull) {
+ continue
+ }
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ return "", io.ErrUnexpectedEOF
+ }
+ return "", err
+ }
+ break
+ }
+ line := string(buffer)
+ line = strings.TrimSuffix(line, "\n")
+ line = strings.TrimSuffix(line, "\r")
+ return line, nil
+}
+
+func validateDialConfig(cfg DialConfig) error {
+ if cfg.Host == "" || !validCommandValue(cfg.Host) {
+ return errors.New("invalid NNTP host")
+ }
+ port, err := strconv.Atoi(cfg.Port)
+ if err != nil || port < 1 || port > 65535 {
+ return errors.New("invalid NNTP port")
+ }
+ if cfg.ProxyType != "" && !strings.EqualFold(cfg.ProxyType, "DIRECT") && !strings.EqualFold(cfg.ProxyType, "SOCKS5") {
+ return errors.New("unsupported proxy type")
+ }
+ if strings.EqualFold(cfg.ProxyType, "SOCKS5") {
+ if _, _, err := net.SplitHostPort(cfg.ProxyAddress); err != nil {
+ return fmt.Errorf("invalid SOCKS5 address: %w", err)
+ }
+ }
+ if cfg.Username != "" && !cfg.UseTLS {
+ return errors.New("NNTP authentication requires TLS")
+ }
+ if cfg.StartTLS && !cfg.UseTLS {
+ return errors.New("STARTTLS requires TLS to be enabled")
+ }
+ if cfg.SASLMechanism != "" && cfg.Username == "" {
+ return errors.New("SASL authentication requires a username")
+ }
+ return nil
+}
+
+func makeTLSConfig(cfg DialConfig) *tls.Config {
+ tlsConfig := &tls.Config{
+ MinVersion: tls.VersionTLS12,
+ MaxVersion: tls.VersionTLS13,
+ CipherSuites: append([]uint16(nil), tls12AESGCMSuites...),
+ ServerName: cfg.Host,
+ InsecureSkipVerify: cfg.InsecureSkipVerify,
+ } //nolint:gosec // explicit user opt-in for local or pinned deployments
+ if cfg.tlsConfig != nil {
+ tlsConfig = cfg.tlsConfig.Clone()
+ if tlsConfig.MinVersion < tls.VersionTLS12 {
+ tlsConfig.MinVersion = tls.VersionTLS12
+ }
+ if tlsConfig.MaxVersion < tls.VersionTLS12 || tlsConfig.MaxVersion > tls.VersionTLS13 {
+ tlsConfig.MaxVersion = tls.VersionTLS13
+ }
+ if tlsConfig.CipherSuites == nil {
+ tlsConfig.CipherSuites = append([]uint16(nil), tls12AESGCMSuites...)
+ }
+ if tlsConfig.ServerName == "" {
+ tlsConfig.ServerName = cfg.Host
+ }
+ }
+ return tlsConfig
+}
+
+func estimatePopulation(low, high int64) int64 {
+ if low <= 0 || high < low {
+ return 0
+ }
+ return high - low + 1
+}
+
+func validAtom(value string) bool {
+ return value != "" && validCommandValue(value) && !strings.ContainsAny(value, " ")
+}
+
+func validCommandValue(value string) bool {
+ return !strings.ContainsAny(value, "\x00\r\n")
+}
+
+func responseCode(err error) int {
+ var responseErr *ResponseError
+ if errors.As(err, &responseErr) {
+ return responseErr.Code
+ }
+ return 0
+}
diff --git a/internal/nntp/client_test.go b/internal/nntp/client_test.go
new file mode 100644
index 0000000..26354ed
--- /dev/null
+++ b/internal/nntp/client_test.go
@@ -0,0 +1,357 @@
+package nntp
+
+import (
+ "bufio"
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "math/big"
+ "net"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+type fakeServer struct {
+ listener net.Listener
+ posted string
+ mu sync.Mutex
+}
+
+func startFakeServer(t *testing.T) *fakeServer {
+ t.Helper()
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return startFakeServerOn(t, listener)
+}
+
+func startFakeTLSServer(t *testing.T) (*fakeServer, *tls.Config) {
+ t.Helper()
+ privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ template := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "Aegis NNTP test"},
+ NotBefore: now.Add(-time.Minute),
+ NotAfter: now.Add(time.Hour),
+ KeyUsage: x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
+ }
+ der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ certificate := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: privateKey}
+ listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
+ Certificates: []tls.Certificate{certificate},
+ MinVersion: tls.VersionTLS12,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := x509.ParseCertificate(der)
+ if err != nil {
+ t.Fatal(err)
+ }
+ roots := x509.NewCertPool()
+ roots.AddCert(parsed)
+ return startFakeServerOn(t, listener), &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}
+}
+
+func startFakeServerOn(t *testing.T, listener net.Listener) *fakeServer {
+ t.Helper()
+ server := &fakeServer{listener: listener}
+ go server.serve(t)
+ t.Cleanup(func() { listener.Close() })
+ return server
+}
+
+func (s *fakeServer) address() (string, string) {
+ host, port, _ := net.SplitHostPort(s.listener.Addr().String())
+ return host, port
+}
+
+func (s *fakeServer) serve(t *testing.T) {
+ conn, err := s.listener.Accept()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ reader := bufio.NewReader(conn)
+ writer := bufio.NewWriter(conn)
+ write := func(value string) {
+ _, _ = writer.WriteString(value)
+ _ = writer.Flush()
+ }
+ write("200 fake server ready\r\n")
+ for {
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return
+ }
+ line = strings.TrimSpace(line)
+ switch {
+ case line == "AUTHINFO USER reader":
+ write("381 password required\r\n")
+ case line == "AUTHINFO PASS secret":
+ write("281 authentication accepted\r\n")
+ case line == "MODE READER":
+ write("200 reader mode\r\n")
+ case line == "CAPABILITIES":
+ write("101 capabilities follow\r\nVERSION 2\r\nOVER\r\nPOST\r\n.\r\n")
+ case line == "LIST ACTIVE":
+ write("215 list follows\r\ncomp.lang.go 120 101 y\r\nempty.group 0 1 n\r\n.\r\n")
+ case line == "GROUP comp.lang.go":
+ write("211 19 101 120 comp.lang.go\r\n")
+ case line == "OVER 119-120":
+ write("224 overview follows\r\n119\tFirst\tAlice <a@example.org>\tMon, 1 Jan 2024 00:00:00 +0000\t<119@example>\t\t100\t5\r\n120\tSecond\tBob <b@example.org>\tMon, 1 Jan 2024 01:00:00 +0000\t<120@example>\t<119@example>\t120\t6\r\n.\r\n")
+ case line == "ARTICLE 120":
+ write("220 120 <120@example> article follows\r\nSubject: Second\r\n\r\nHello\r\n..dot line\r\n.\r\n")
+ case line == "POST":
+ write("340 send article\r\n")
+ var article strings.Builder
+ for {
+ postedLine, readErr := reader.ReadString('\n')
+ if readErr != nil {
+ return
+ }
+ if postedLine == ".\r\n" {
+ break
+ }
+ article.WriteString(postedLine)
+ }
+ s.mu.Lock()
+ s.posted = article.String()
+ s.mu.Unlock()
+ write("240 article received\r\n")
+ case line == "QUIT":
+ write("205 closing connection\r\n")
+ return
+ default:
+ write(fmt.Sprintf("500 unsupported: %s\r\n", line))
+ }
+ }
+}
+
+func startSOCKSProxy(t *testing.T, upstreamAddress string) string {
+ t.Helper()
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { listener.Close() })
+ go func() {
+ connection, acceptErr := listener.Accept()
+ if acceptErr != nil {
+ return
+ }
+ defer connection.Close()
+ hello := make([]byte, 3)
+ if _, err := io.ReadFull(connection, hello); err != nil || hello[0] != 5 {
+ return
+ }
+ if _, err := connection.Write([]byte{5, 0}); err != nil {
+ return
+ }
+ header := make([]byte, 4)
+ if _, err := io.ReadFull(connection, header); err != nil || header[0] != 5 || header[1] != 1 {
+ return
+ }
+ switch header[3] {
+ case 1:
+ _, err = io.ReadFull(connection, make([]byte, 4))
+ case 3:
+ length := make([]byte, 1)
+ if _, err = io.ReadFull(connection, length); err == nil {
+ _, err = io.ReadFull(connection, make([]byte, int(length[0])))
+ }
+ case 4:
+ _, err = io.ReadFull(connection, make([]byte, 16))
+ default:
+ return
+ }
+ if err != nil {
+ return
+ }
+ port := make([]byte, 2)
+ if _, err := io.ReadFull(connection, port); err != nil || binary.BigEndian.Uint16(port) == 0 {
+ return
+ }
+ upstream, err := net.DialTimeout("tcp", upstreamAddress, 2*time.Second)
+ if err != nil {
+ _, _ = connection.Write([]byte{5, 1, 0, 1, 0, 0, 0, 0, 0, 0})
+ return
+ }
+ defer upstream.Close()
+ if _, err := connection.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 1}); err != nil {
+ return
+ }
+ done := make(chan struct{})
+ go func() {
+ _, _ = io.Copy(upstream, connection)
+ _ = upstream.Close()
+ close(done)
+ }()
+ _, _ = io.Copy(connection, upstream)
+ <-done
+ }()
+ return listener.Addr().String()
+}
+
+func TestClientWorkflow(t *testing.T) {
+ server, tlsConfig := startFakeTLSServer(t)
+ host, port := server.address()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ client, err := Dial(ctx, DialConfig{
+ Host: host,
+ Port: port,
+ UseTLS: true,
+ Username: "reader",
+ Password: "secret",
+ ProxyType: "DIRECT",
+ tlsConfig: tlsConfig,
+ })
+ if err != nil {
+ t.Fatalf("Dial() error = %v", err)
+ }
+ defer client.Close()
+
+ capabilities, err := client.Capabilities()
+ if err != nil || len(capabilities) != 3 {
+ t.Fatalf("Capabilities() = %v, %v", capabilities, err)
+ }
+ groups, err := client.ListActive()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(groups) != 2 || groups[0].EstimatedPost != 20 || groups[1].EstimatedPost != 0 {
+ t.Fatalf("ListActive() = %#v", groups)
+ }
+ status, headers, err := client.LatestOverview("comp.lang.go", 2)
+ if err != nil || status.Count != 19 || status.High != 120 {
+ t.Fatalf("LatestOverview() status = %#v, error = %v", status, err)
+ }
+ if err != nil || len(headers) != 2 || headers[1].Subject != "Second" {
+ t.Fatalf("LatestOverview() headers = %#v, error = %v", headers, err)
+ }
+ article, err := client.ArticleInGroup("comp.lang.go", 120)
+ if err != nil || !strings.Contains(article, "\n.dot line") {
+ t.Fatalf("Article() = %q, %v", article, err)
+ }
+ if err := client.Post("From: reader@example.org\nNewsgroups: comp.lang.go\nSubject: Test\n\n.line"); err != nil {
+ t.Fatal(err)
+ }
+ server.mu.Lock()
+ posted := server.posted
+ server.mu.Unlock()
+ if !strings.Contains(posted, "\r\n..line\r\n") {
+ t.Fatalf("posted article was not dot-stuffed: %q", posted)
+ }
+}
+
+func TestTLSCertificateVerificationCannotBeSkipped(t *testing.T) {
+ server, _ := startFakeTLSServer(t)
+ host, port := server.address()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ client, err := Dial(ctx, DialConfig{Host: host, Port: port, UseTLS: true, ProxyType: "DIRECT"})
+ if client != nil {
+ client.Close()
+ }
+ if err == nil {
+ t.Fatal("Dial() trusted an unknown TLS certificate")
+ }
+}
+
+func TestRejectsCommandInjection(t *testing.T) {
+ if _, err := Dial(context.Background(), DialConfig{Host: "example.org\r\nQUIT", Port: "119"}); err == nil {
+ t.Fatal("Dial() accepted command injection in host")
+ }
+ client := &Client{}
+ if _, err := client.SelectGroup("comp.lang.go\r\nPOST"); err == nil {
+ t.Fatal("SelectGroup() accepted command injection")
+ }
+}
+
+func TestValidateArticleTextConstraints(t *testing.T) {
+ tests := []struct {
+ name string
+ article string
+ wantErr bool
+ }{
+ {name: "valid UTF-8 and CRLF", article: "Subject: café\r\n\r\ncorps UTF-8\r\n"},
+ {name: "valid LF normalized by Post", article: "Subject: test\n\nbody"},
+ {name: "998 octets", article: "Subject: " + strings.Repeat("a", 989)},
+ {name: "oversized line", article: strings.Repeat("a", maxPostLineBytes+1), wantErr: true},
+ {name: "dot-stuffed oversized line", article: "." + strings.Repeat("a", maxPostLineBytes-1), wantErr: true},
+ {name: "NUL", article: "Subject: test\x00", wantErr: true},
+ {name: "invalid UTF-8", article: string([]byte{'a', 0xff}), wantErr: true},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ err := ValidateArticle(test.article)
+ if (err != nil) != test.wantErr {
+ t.Fatalf("ValidateArticle() error = %v, wantErr %t", err, test.wantErr)
+ }
+ })
+ }
+}
+
+func TestRejectsAuthenticationWithoutTLS(t *testing.T) {
+ if _, err := Dial(context.Background(), DialConfig{
+ Host: "127.0.0.1", Port: "119", Username: "reader", Password: "secret",
+ }); err == nil {
+ t.Fatal("Dial() accepted authentication without TLS")
+ }
+}
+
+func TestSOCKS5Connection(t *testing.T) {
+ server := startFakeServer(t)
+ proxyAddress := startSOCKSProxy(t, server.listener.Addr().String())
+ host, port := server.address()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ client, err := Dial(ctx, DialConfig{
+ Host: host, Port: port, ProxyType: "SOCKS5", ProxyAddress: proxyAddress,
+ })
+ if err != nil {
+ t.Fatalf("Dial() through SOCKS5 error = %v", err)
+ }
+ defer client.Close()
+ groups, err := client.ListActive()
+ if err != nil || len(groups) != 2 {
+ t.Fatalf("ListActive() through SOCKS5 = %#v, %v", groups, err)
+ }
+}
+
+func TestPopulationEstimate(t *testing.T) {
+ tests := []struct {
+ low, high int64
+ want int64
+ }{
+ {101, 120, 20},
+ {1, 1, 1},
+ {1, 0, 0},
+ {0, 0, 0},
+ }
+ for _, test := range tests {
+ if got := estimatePopulation(test.low, test.high); got != test.want {
+ t.Errorf("estimatePopulation(%d, %d) = %d, want %d", test.low, test.high, got, test.want)
+ }
+ }
+}