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 \tMon, 1 Jan 2024 00:00:00 +0000\t<119@example>\t\t100\t5\r\n120\tSecond\tBob \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) } } }