commit 3f2ad062778268029067033bd88826837cd3d7dd from: Dustin Sallings date: Thu Feb 23 07:53:03 2012 UTC Functional server. commit - f8aabde3b59dde0bd91fb293b5a4e4860c63226c commit + 3f2ad062778268029067033bd88826837cd3d7dd blob - 32e47df45d5a504f9960679933f6a37c1dc4aeae blob + eb4d4fab472f6ed4cb15febb9528b41a2b01b467 --- .gitignore +++ .gitignore @@ -1,3 +1,4 @@ *~ #* /examples/client/client +/examples/server/server blob - /dev/null blob + 72818dce6215b9a38c7ee655eb7d24f8beb74b7a (mode 644) --- /dev/null +++ examples/server/exampleserver.go @@ -0,0 +1,265 @@ +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 @@ -1,5 +1,11 @@ package nntp +import ( + "fmt" + "io" + "net/textproto" +) + type PostingStatus byte const ( @@ -9,10 +15,26 @@ 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 @@ -0,0 +1,506 @@ +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 @@ -0,0 +1,32 @@ +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) + } + } +}