commit - f8aabde3b59dde0bd91fb293b5a4e4860c63226c
commit + 3f2ad062778268029067033bd88826837cd3d7dd
blob - 32e47df45d5a504f9960679933f6a37c1dc4aeae
blob + eb4d4fab472f6ed4cb15febb9528b41a2b01b467
--- .gitignore
+++ .gitignore
*~
#*
/examples/client/client
+/examples/server/server
blob - /dev/null
blob + 72818dce6215b9a38c7ee655eb7d24f8beb74b7a (mode 644)
--- /dev/null
+++ examples/server/exampleserver.go
+package main
+
+import (
+ "bytes"
+ "container/ring"
+ "io"
+ "log"
+ "net"
+ "net/textproto"
+ "strconv"
+ "strings"
+
+ "github.com/dustin/go-nntp"
+ "github.com/dustin/go-nntp/server"
+)
+
+const maxArticles = 100
+
+type articleRef struct {
+ msgid string
+ num int64
+}
+
+type groupStorage struct {
+ group *nntp.Group
+ // article refs
+ articles *ring.Ring
+}
+
+type articleStorage struct {
+ headers textproto.MIMEHeader
+ body string
+ refcount int
+}
+
+type testBackendType struct {
+ // group name -> group storage
+ groups map[string]*groupStorage
+ // message ID -> article
+ articles map[string]*articleStorage
+}
+
+var testBackend = testBackendType{
+ groups: make(map[string]*groupStorage),
+ articles: make(map[string]*articleStorage),
+}
+
+func init() {
+
+ testBackend.groups["alt.test"] = &groupStorage{
+ group: &nntp.Group{"alt.test", "A test.",
+ 0, 0, 0, nntp.PostingNotPermitted},
+ articles: ring.New(maxArticles),
+ }
+
+ testBackend.groups["misc.test"] = &groupStorage{
+ group: &nntp.Group{"misc.test", "More testing.",
+ 0, 0, 0, nntp.PostingPermitted},
+ articles: ring.New(maxArticles),
+ }
+
+}
+
+func (tb *testBackendType) ListGroups(max int) ([]*nntp.Group, error) {
+ rv := make([]*nntp.Group, 0, 100)
+ for _, g := range tb.groups {
+ rv = append(rv, g.group)
+ }
+ return rv, nil
+}
+
+func (tb *testBackendType) GetGroup(name string) (*nntp.Group, error) {
+ var group *nntp.Group
+
+ for _, g := range tb.groups {
+ if g.group.Name == name {
+ group = g.group
+ break
+ }
+ }
+
+ if group == nil {
+ return nil, nntpserver.NoSuchGroup
+ }
+
+ return group, nil
+}
+
+func mkArticle(a *articleStorage) *nntp.Article {
+ return &nntp.Article{
+ Header: a.headers,
+ Body: strings.NewReader(a.body),
+ Bytes: len(a.body),
+ Lines: strings.Count(a.body, "\n"),
+ }
+}
+
+func findInRing(in *ring.Ring, f func(r interface{}) bool) *ring.Ring {
+ if f(in.Value) {
+ return in
+ }
+ for p := in.Next(); p != in; p = p.Next() {
+ if f(p.Value) {
+ return p
+ }
+ }
+ return nil
+}
+
+func (tb *testBackendType) GetArticle(group *nntp.Group, id string) (*nntp.Article, error) {
+
+ msgId := id
+ var a *articleStorage
+
+ if intid, err := strconv.ParseInt(id, 10, 64); err == nil {
+ msgId = ""
+ // by int ID. Gotta go find it.
+ if groupStorage, ok := tb.groups[group.Name]; ok {
+ r := findInRing(groupStorage.articles, func(v interface{}) bool {
+ if v != nil {
+ log.Printf("Looking at %v", v)
+ }
+ if aref, ok := v.(articleRef); ok && aref.num == intid {
+ return true
+ }
+ return false
+ })
+ if aref, ok := r.Value.(articleRef); ok {
+ msgId = aref.msgid
+ }
+ }
+ }
+
+ a = tb.articles[msgId]
+ if a == nil {
+ return nil, nntpserver.InvalidMessageId
+ }
+
+ return mkArticle(a), nil
+}
+
+func (tb *testBackendType) GetArticles(group *nntp.Group,
+ from, to int64) ([]nntpserver.NumberedArticle, error) {
+
+ gs, ok := tb.groups[group.Name]
+ if !ok {
+ return nil, nntpserver.NoSuchGroup
+ }
+
+ log.Printf("Getting articles from %d to %d", from, to)
+
+ rv := make([]nntpserver.NumberedArticle, 0, maxArticles)
+ gs.articles.Do(func(v interface{}) {
+ if v != nil {
+ if aref, ok := v.(articleRef); ok {
+ if aref.num >= from && aref.num <= to {
+ a, ok := tb.articles[aref.msgid]
+ if ok {
+ article := mkArticle(a)
+ rv = append(rv, nntpserver.NumberedArticle{aref.num, article})
+ }
+ }
+ }
+ }
+ })
+ return rv, nil
+}
+
+func (tb *testBackendType) AllowPost() bool {
+ return true
+}
+
+func (tb *testBackendType) decr(msgid string) {
+ if a, ok := tb.articles[msgid]; ok {
+ a.refcount--
+ if a.refcount == 0 {
+ log.Printf("Getting rid of %v", msgid)
+ delete(tb.articles, msgid)
+ }
+ }
+}
+
+func (tb *testBackendType) Post(article *nntp.Article) error {
+ log.Printf("Got headers: %#v", article.Header)
+ b := []byte{}
+ buf := bytes.NewBuffer(b)
+ n, err := io.Copy(buf, article.Body)
+ if err != nil {
+ return err
+ }
+ log.Printf("Read %d bytes of body", n)
+
+ a := articleStorage{
+ headers: article.Header,
+ body: buf.String(),
+ refcount: 0,
+ }
+
+ msgId := a.headers.Get("Message-Id")
+
+ if _, ok := tb.articles[msgId]; ok {
+ return nntpserver.PostingFailed
+ }
+
+ for _, g := range article.Header["Newsgroups"] {
+ if g, ok := tb.groups[g]; ok {
+ g.articles = g.articles.Next()
+ if g.articles.Value != nil {
+ aref := g.articles.Value.(articleRef)
+ tb.decr(aref.msgid)
+ }
+ if g.articles.Value != nil || g.group.Low == 0 {
+ g.group.Low++
+ }
+ g.group.High++
+ g.articles.Value = articleRef{
+ msgId,
+ g.group.High,
+ }
+ log.Printf("Placed %v", g.articles.Value)
+ a.refcount++
+ g.group.Count = int64(g.articles.Len())
+
+ log.Printf("Stored %v in %v", msgId, g.group.Name)
+ }
+ }
+
+ if a.refcount > 0 {
+ tb.articles[msgId] = &a
+ } else {
+ return nntpserver.PostingFailed
+ }
+
+ return nil
+}
+
+func (tb *testBackendType) Authorized() bool {
+ return true
+}
+
+func (tb *testBackendType) Authenticate(user, pass string) error {
+ return nntpserver.AuthRejected
+}
+
+func maybefatal(err error, f string, a ...interface{}) {
+ if err != nil {
+ log.Fatalf(f, a...)
+ }
+}
+
+func main() {
+ a, err := net.ResolveTCPAddr("tcp", ":1119")
+ maybefatal(err, "Error resolving listener: %v", err)
+ l, err := net.ListenTCP("tcp", a)
+ maybefatal(err, "Error setting up listener: %v", err)
+ defer l.Close()
+
+ s := nntpserver.NewServer(&testBackend)
+
+ for {
+ c, err := l.AcceptTCP()
+ maybefatal(err, "Error accepting connection: %v", err)
+ go s.Process(c)
+ }
+}
blob - d8ec5e9ca77aa52080bf3c80ed7f36a5a6ecbae8
blob + 6526b96e2fb854dd3897584f9b4b96c9cc92edce
--- nntp.go
+++ nntp.go
package nntp
+import (
+ "fmt"
+ "io"
+ "net/textproto"
+)
+
type PostingStatus byte
const (
PostingModerated = PostingStatus('m')
)
+func (ps PostingStatus) String() string {
+ return fmt.Sprintf("%c", ps)
+}
+
type Group struct {
- Name string
- Count int64
- High int64
- Low int64
- Posting PostingStatus
+ Name string
+ Description string
+ Count int64
+ High int64
+ Low int64
+ Posting PostingStatus
}
+
+type Article struct {
+ Header textproto.MIMEHeader
+ Body io.Reader
+ Bytes int
+ Lines int
+}
+
+func (a *Article) MessageId() string {
+ return a.Header.Get("Message-Id")
+}
blob - /dev/null
blob + 131f148a4571f0deff0cd0630d5ce57a6998c227 (mode 644)
--- /dev/null
+++ server/server.go
+package nntpserver
+
+import (
+ "fmt"
+ "io"
+ "log"
+ "math"
+ "net"
+ "net/textproto"
+ "strconv"
+ "strings"
+
+ "github.com/dustin/go-nntp"
+)
+
+type NNTPError struct {
+ Code int
+ Msg string
+}
+
+var NoSuchGroup = &NNTPError{411, "No such newsgroup"}
+
+var NoGroupSelected = &NNTPError{412, "No newsgroup selected"}
+
+var InvalidMessageId = &NNTPError{430, "No article with that message-id"}
+var InvalidArticleNumber = &NNTPError{423, "No article with that number"}
+var NoCurrentArticle = &NNTPError{420, "Current article number is invalid"}
+
+var UnknownCommand = &NNTPError{500, "Unknown command"}
+var SyntaxError = &NNTPError{501, "not supported, or syntax error"}
+
+var PostingNotPermitted = &NNTPError{440, "Posting not permitted"}
+var PostingFailed = &NNTPError{441, "posting failed"}
+var NotWanted = &NNTPError{435, "Article not wanted"}
+
+var AuthRequired = &NNTPError{450, "authorization required"}
+var AuthRejected = &NNTPError{452, "authorization rejected"}
+
+// Low-level protocol handler
+type Handler func(args []string, s *Server, c *textproto.Conn) error
+
+type NumberedArticle struct {
+ Num int64
+ Article *nntp.Article
+}
+
+// The backend that provides the things and does the stuff.
+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(user, pass string) error
+ AllowPost() bool
+ Post(article *nntp.Article) error
+}
+
+type Server struct {
+ Handlers map[string]Handler
+ Backend Backend
+ group *nntp.Group
+}
+
+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 (e *NNTPError) Error() string {
+ return fmt.Sprintf("%d %s", e.Code, e.Msg)
+}
+
+func (s *Server) dispatchCommand(cmd string, args []string,
+ c *textproto.Conn) (err error) {
+
+ handler, found := s.Handlers[strings.ToLower(cmd)]
+ if !found {
+ handler, found = s.Handlers[""]
+ if !found {
+ panic("No default handler.")
+ }
+ }
+ return handler(args, s, c)
+}
+
+func (s *Server) Process(tc *net.TCPConn) {
+ defer tc.Close()
+ c := textproto.NewConn(tc)
+
+ c.PrintfLine("200 Hello!")
+ for {
+ l, err := c.ReadLine()
+ if err != nil {
+ log.Printf("Error reading from client, dropping conn: %v", err)
+ return
+ }
+ cmd := strings.Split(l, " ")
+ log.Printf("Got cmd: %+v", cmd)
+ args := []string{}
+ if len(cmd) > 1 {
+ args = cmd[1:]
+ }
+ err = s.dispatchCommand(cmd[0], args, c)
+ if err != nil {
+ _, isNNTPError := err.(*NNTPError)
+ switch {
+ case err == io.EOF:
+ // Drop this connection silently. They hung up
+ return
+ case isNNTPError:
+ c.PrintfLine(err.Error())
+ default:
+ log.Printf("Error dispatching command, 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 {
+ h = math.MaxInt64
+ }
+ return 0, h
+ }
+ l, _ := strconv.ParseInt(parts[0], 10, 64)
+ h, err := strconv.ParseInt(parts[1], 10, 64)
+ if err != nil {
+ h = math.MaxInt64
+ }
+ return l, h
+}
+
+/*
+ "0" or article number (see below)
+ Subject header content
+ From header content
+ Date header content
+ Message-ID header content
+ References header content
+ :bytes metadata item
+ :lines metadata item
+*/
+
+func handleOver(args []string, s *Server, c *textproto.Conn) error {
+ if s.group == nil {
+ return NoGroupSelected
+ }
+ from, to := s.group.Low, s.group.High
+ 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 handleList(args []string, s *Server, c *textproto.Conn) error {
+ c.PrintfLine("215 list of newsgroups follows")
+
+ ltype := "active"
+ if len(args) > 0 {
+ ltype = strings.ToLower(args[0])
+ }
+
+ dw := c.DotWriter()
+ defer dw.Close()
+
+ if ltype == "overview.fmt" {
+ fmt.Fprintln(dw, `Subject:
+From:
+Date:
+Message-ID:
+References:
+:bytes
+:lines`)
+ }
+
+ groups, err := s.Backend.ListGroups(-1)
+ if err != nil {
+ return err
+ }
+ 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 *Server, c *textproto.Conn) error {
+ c.PrintfLine("231 list of newsgroups follows")
+ c.PrintfLine(".")
+ return nil
+}
+
+func handleDefault(args []string, s *Server, c *textproto.Conn) error {
+ return UnknownCommand
+}
+
+func handleQuit(args []string, s *Server, c *textproto.Conn) error {
+ c.PrintfLine("205 bye")
+ return io.EOF
+}
+
+func handleGroup(args []string, s *Server, c *textproto.Conn) error {
+ if len(args) < 1 {
+ return NoSuchGroup
+ }
+
+ 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 *Server) getArticle(args []string) (*nntp.Article, error) {
+ if s.group == nil {
+ return nil, NoGroupSelected
+ }
+ return s.Backend.GetArticle(s.group, args[0])
+}
+
+/*
+ Syntax
+ HEAD message-id
+ HEAD number
+ HEAD
+
+
+ First form (message-id specified)
+ 221 0|n message-id Headers follow (multi-line)
+ 430 No article with that message-id
+
+ Second form (article number specified)
+ 221 n message-id Headers follow (multi-line)
+ 412 No newsgroup selected
+ 423 No article with that number
+
+ Third form (current article number used)
+ 221 n message-id Headers follow (multi-line)
+ 412 No newsgroup selected
+ 420 Current article number is invalid
+*/
+
+func handleHead(args []string, s *Server, 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
+}
+
+/*
+ Syntax
+ BODY message-id
+ BODY number
+ BODY
+
+ Responses
+
+ First form (message-id specified)
+ 222 0|n message-id Body follows (multi-line)
+ 430 No article with that message-id
+
+ Second form (article number specified)
+ 222 n message-id Body follows (multi-line)
+ 412 No newsgroup selected
+ 423 No article with that number
+
+ Third form (current article number used)
+ 222 n message-id Body follows (multi-line)
+ 412 No newsgroup selected
+ 420 Current article number is invalid
+
+ Parameters
+ number Requested article number
+ n Returned article number
+ message-id Article message-id
+*/
+
+func handleBody(args []string, s *Server, 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
+}
+
+/*
+ Syntax
+ ARTICLE message-id
+ ARTICLE number
+ ARTICLE
+
+ Responses
+
+ First form (message-id specified)
+ 220 0|n message-id Article follows (multi-line)
+ 430 No article with that message-id
+
+ Second form (article number specified)
+ 220 n message-id Article follows (multi-line)
+ 412 No newsgroup selected
+ 423 No article with that number
+
+ Third form (current article number used)
+ 220 n message-id Article follows (multi-line)
+ 412 No newsgroup selected
+ 420 Current article number is invalid
+
+ Parameters
+ number Requested article number
+ n Returned article number
+ message-id Article message-id
+*/
+
+func handleArticle(args []string, s *Server, 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
+}
+
+/*
+ Syntax
+ POST
+
+ Responses
+
+ Initial responses
+ 340 Send article to be posted
+ 440 Posting not permitted
+
+ Subsequent responses
+ 240 Article received OK
+ 441 Posting failed
+*/
+
+func handlePost(args []string, s *Server, c *textproto.Conn) error {
+ if !s.Backend.AllowPost() {
+ return PostingNotPermitted
+ }
+
+ c.PrintfLine("340 Go ahead")
+ var err error
+ var article nntp.Article
+ article.Header, err = c.ReadMIMEHeader()
+ if err != nil {
+ return PostingFailed
+ }
+ article.Body = c.DotReader()
+ err = s.Backend.Post(&article)
+ if err != nil {
+ return err
+ }
+ c.PrintfLine("240 article received OK")
+ return nil
+}
+
+func handleIHave(args []string, s *Server, c *textproto.Conn) error {
+ if !s.Backend.AllowPost() {
+ return NotWanted
+ }
+
+ // XXX: See if we have it.
+ article, err := s.Backend.GetArticle(nil, args[0])
+ if article != nil {
+ return NotWanted
+ }
+
+ c.PrintfLine("335 send it")
+ article = new(nntp.Article)
+ article.Header, err = c.ReadMIMEHeader()
+ if err != nil {
+ return PostingFailed
+ }
+ article.Body = c.DotReader()
+ err = s.Backend.Post(article)
+ if err != nil {
+ return err
+ }
+ c.PrintfLine("235 article received OK")
+ return nil
+}
+
+func handleCap(args []string, s *Server, 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")
+ 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 *Server, c *textproto.Conn) error {
+ if s.Backend.AllowPost() {
+ c.PrintfLine("200 Posting allowed")
+ } else {
+ c.PrintfLine("201 Posting prohibited")
+ }
+ return nil
+}
+
+func handleAuthInfo(args []string, s *Server, c *textproto.Conn) error {
+ if len(args) < 2 {
+ return SyntaxError
+ }
+ if strings.ToLower(args[0]) != "user" {
+ return SyntaxError
+ }
+
+ if s.Backend.Authorized() {
+ return c.PrintfLine("250 authenticated")
+ }
+
+ c.PrintfLine("350 Continue")
+ a, err := c.ReadLine()
+ parts := strings.SplitN(a, " ", 3)
+ if strings.ToLower(parts[0]) != "authinfo" || strings.ToLower(parts[1]) != "pass" {
+ return SyntaxError
+ }
+ err = s.Backend.Authenticate(args[1], parts[2])
+ if err == nil {
+ c.PrintfLine("250 authenticated")
+ }
+ return err
+}
blob - /dev/null
blob + 0ac3f95b4f57e79a6fe7e366c8d9e77bca803a3d (mode 644)
--- /dev/null
+++ server/server_test.go
+package nntpserver
+
+import (
+ "math"
+ "testing"
+)
+
+type rangeExpectation struct {
+ input string
+ low int64
+ high int64
+}
+
+var rangeExpectations = []rangeExpectation{
+ rangeExpectation{"", 0, math.MaxInt64},
+ rangeExpectation{"73-", 73, math.MaxInt64},
+ rangeExpectation{"73-1845", 73, 1845},
+}
+
+func TestRangeEmpty(t *testing.T) {
+ for _, e := range rangeExpectations {
+ l, h := parseRange(e.input)
+ if l != e.low {
+ t.Fatalf("Error parsing %q, got low=%v, wanted %v",
+ e.input, l, e.low)
+ }
+ if h != e.high {
+ t.Fatalf("Error parsing %q, got high=%v, wanted %v",
+ e.input, h, e.high)
+ }
+ }
+}