diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30bbda8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +suppressions/ +crashers/ +c.out +coverage.html +key.pem +cert.pem \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b13e64 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# pop3 (s3-pop3-server) - pop3 server with s3 backend + +## Overview + +`s3-pop3-server` is a service that takes S3 bucket and presents it as pop3 maildrop. `pop3` is a golang library to build pop3 servers. Both are written in pure go. API documentation for `pop3` can be found in [![GoDoc](https://godoc.org/github.com/dzeromsk/pop3?status.svg)](https://godoc.org/github.com/dzeromsk/pop3). + +## Installation + +```bash +$ go get -u github.com/dzeromsk/pop3/... +``` + +This will make the `s3-pop3-server` tool available in `${GOPATH}/bin`, which by default means `~/go/bin`. + +## Usage of the binary (s3-pop3-server) + +`s3-pop3-server` starts pop3 server on port 995 with s3 bucket used as a storage. + +``` +Usage of s3-pop3-server: + -addr string + Address to listen to (default ":995") + -bucket string + AWS S3 bucket name (default "emails") + -cert string + TLS Certificate used by server (default "cert.pem") + -key string + TLS Private key used by server (default "key.pem") + -region string + AWS S3 bucket region (default "eu-west-1") +``` + +## Usage of the library (pop3) + +API documentation for `pop3` can be found in [![GoDoc](https://godoc.org/github.com/dzeromsk/pop3?status.svg)](https://godoc.org/github.com/dzeromsk/). + +```go +import "github.com/dzeromsk/pop3" +... +err := pop3.ListenAndServeTLS(*address, *cert, *key, &s3auth{ + bucket: *bucket, + region: *region, +}) +if err != nil { + log.Fatalln(err) +} +``` + +## Features + + - Simple. + - No config files. + - Minimal pop3 server feature set. + +## Downsides + + - All of the files are served from s3. + - Does not support all pop3 commands. + +## Philosophy + +Sometimes you just want S3 bucket to be accessible via pop3 protocol. For +example when receiving Email with Amazon SES and storing them in S3. There are +ways to make it work with existing pop3 servers. But you don't need all that. +You want something similar to proxy. \ No newline at end of file diff --git a/cmd/s3-pop3-server/generate_cert.go b/cmd/s3-pop3-server/generate_cert.go new file mode 100644 index 0000000..8d012be --- /dev/null +++ b/cmd/s3-pop3-server/generate_cert.go @@ -0,0 +1,169 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build ignore + +// Generate a self-signed X.509 certificate for a TLS server. Outputs to +// 'cert.pem' and 'key.pem' and will overwrite existing files. + +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "flag" + "fmt" + "log" + "math/big" + "net" + "os" + "strings" + "time" +) + +var ( + host = flag.String("host", "", "Comma-separated hostnames and IPs to generate a certificate for") + validFrom = flag.String("start-date", "", "Creation date formatted as Jan 1 15:04:05 2011") + validFor = flag.Duration("duration", 365*24*time.Hour, "Duration that certificate is valid for") + isCA = flag.Bool("ca", false, "whether this cert should be its own Certificate Authority") + rsaBits = flag.Int("rsa-bits", 2048, "Size of RSA key to generate. Ignored if --ecdsa-curve is set") + ecdsaCurve = flag.String("ecdsa-curve", "", "ECDSA curve to use to generate a key. Valid values are P224, P256 (recommended), P384, P521") +) + +func publicKey(priv interface{}) interface{} { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &k.PublicKey + case *ecdsa.PrivateKey: + return &k.PublicKey + default: + return nil + } +} + +func pemBlockForKey(priv interface{}) *pem.Block { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)} + case *ecdsa.PrivateKey: + b, err := x509.MarshalECPrivateKey(k) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to marshal ECDSA private key: %v", err) + os.Exit(2) + } + return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} + default: + return nil + } +} + +func main() { + flag.Parse() + + if len(*host) == 0 { + log.Fatalf("Missing required --host parameter") + } + + var priv interface{} + var err error + switch *ecdsaCurve { + case "": + priv, err = rsa.GenerateKey(rand.Reader, *rsaBits) + case "P224": + priv, err = ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + case "P256": + priv, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case "P384": + priv, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + case "P521": + priv, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + default: + fmt.Fprintf(os.Stderr, "Unrecognized elliptic curve: %q", *ecdsaCurve) + os.Exit(1) + } + if err != nil { + log.Fatalf("failed to generate private key: %s", err) + } + + var notBefore time.Time + if len(*validFrom) == 0 { + notBefore = time.Now() + } else { + notBefore, err = time.Parse("Jan 2 15:04:05 2006", *validFrom) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to parse creation date: %s\n", err) + os.Exit(1) + } + } + + notAfter := notBefore.Add(*validFor) + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + log.Fatalf("failed to generate serial number: %s", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Acme Co"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + hosts := strings.Split(*host, ",") + for _, h := range hosts { + if ip := net.ParseIP(h); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, h) + } + } + + if *isCA { + template.IsCA = true + template.KeyUsage |= x509.KeyUsageCertSign + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv) + if err != nil { + log.Fatalf("Failed to create certificate: %s", err) + } + + certOut, err := os.Create("cert.pem") + if err != nil { + log.Fatalf("failed to open cert.pem for writing: %s", err) + } + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + log.Fatalf("failed to write data to cert.pem: %s", err) + } + if err := certOut.Close(); err != nil { + log.Fatalf("error closing cert.pem: %s", err) + } + log.Print("wrote cert.pem\n") + + keyOut, err := os.OpenFile("key.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + log.Print("failed to open key.pem for writing:", err) + return + } + if err := pem.Encode(keyOut, pemBlockForKey(priv)); err != nil { + log.Fatalf("failed to write data to key.pem: %s", err) + } + if err := keyOut.Close(); err != nil { + log.Fatalf("error closing key.pem: %s", err) + } + log.Print("wrote key.pem\n") +} diff --git a/cmd/s3-pop3-server/main.go b/cmd/s3-pop3-server/main.go new file mode 100644 index 0000000..0a78db8 --- /dev/null +++ b/cmd/s3-pop3-server/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "flag" + "io" + "log" + + "pop3" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" +) + +var ( + address = flag.String("addr", ":995", "Address to listen to") + cert = flag.String("cert", "cert.pem", "TLS Certificate used by server") + key = flag.String("key", "key.pem", "TLS Private key used by server") + region = flag.String("region", "eu-west-1", "AWS S3 bucket region") + bucket = flag.String("bucket", "sns-example-com", "AWS S3 bucket name") +) + +func main() { + flag.Parse() + + err := pop3.ListenAndServeTLS(*address, *cert, *key, &s3auth{ + bucket: *bucket, + region: *region, + }) + if err != nil { + log.Fatalln(err) + } +} + +type s3auth struct { + bucket string + region string +} + +func (a *s3auth) Auth(user, pass string) (pop3.Maildropper, error) { + sess, err := session.NewSession(&aws.Config{ + Region: aws.String(a.region), + // Credentials: credentials.NewStaticCredentials(user, pass, ""), + }) + if err != nil { + return nil, err + } + + maildrop := &s3maildrop{ + svc: s3.New(sess), + bucket: a.bucket, + } + + return maildrop, nil +} + +type s3maildrop struct { + svc *s3.S3 + bucket string +} + +func (m *s3maildrop) List() (messages map[string]int, err error) { + resp, err := m.svc.ListObjectsV2( + &s3.ListObjectsV2Input{ + Bucket: aws.String(m.bucket), + }, + ) + if err != nil { + return nil, err + } + + messages = make(map[string]int, len(resp.Contents)) + for _, item := range resp.Contents { + messages[*item.Key] = int(*item.Size) + } + + return messages, nil +} + +func (m *s3maildrop) Get(key string, message io.Writer) (err error) { + resp, err := m.svc.GetObject( + &s3.GetObjectInput{ + Bucket: aws.String(m.bucket), + Key: aws.String(key), + }, + ) + defer resp.Body.Close() + + _, err = io.Copy(message, resp.Body) + return err +} + +func (m *s3maildrop) Delete(key string) (err error) { + _, err = m.svc.DeleteObject( + &s3.DeleteObjectInput{ + Bucket: aws.String(m.bucket), + Key: aws.String(key), + }, + ) + return err +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..e18de43 --- /dev/null +++ b/server.go @@ -0,0 +1,668 @@ +package pop3 + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "log" + "net" + "net/textproto" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + POP3User = "USER" + POP3Pass = "PASS" + POP3StartTLS = "STLS" + POP3Capability = "CAPA" + POP3Status = "STAT" + POP3List = "LIST" + POP3UIDList = "UIDL" + POP3Retrieve = "RETR" + POP3Delete = "DELE" + POP3Noop = "NOOP" + POP3Reset = "RSET" + POP3Quit = "QUIT" +) + +var ( + ErrInvalidAuthorizer = errors.New("pop3: Missing authorizer") + ErrServerClosed = errors.New("pop3: Server closed") +) + +// Authorizer responds to a POP3 AUTHORIZATION state request. +type Authorizer interface { + Auth(user, pass string) (Maildropper, error) +} + +// Maildropper responds to a POP3 TRANSACTION state requests. +type Maildropper interface { + List() (size map[string]int, err error) + Get(key string, message io.Writer) (err error) + Delete(key string) (err error) +} + +// ListenAndServe always returns a non-nil error. +func ListenAndServe(addr string, auth Authorizer) error { + server := &Server{Addr: addr, Auth: auth} + return server.ListenAndServe() +} + +// ListenAndServeTLS acts identically to ListenAndServe, except that it +// expects POP3S connections. Additionally, files containing a certificate and +// matching private key for the server must be provided. +func ListenAndServeTLS(addr, certFile, keyFile string, auth Authorizer) error { + server := &Server{Addr: addr, Auth: auth} + return server.ListenAndServeTLS(certFile, keyFile) +} + +// A Server defines parameters for running an POP3 server. +// The zero value for Server is a valid configuration. +type Server struct { + Addr string // TCP address to listen on, ":pop3" if empty + Auth Authorizer + + // TLSConfig optionally provides a TLS configuration for use + // by ServeTLS and ListenAndServeTLS. Note that this value is + // cloned by ServeTLS and ListenAndServeTLS, so it's not + // possible to modify the configuration with methods like + // tls.Config.SetSessionTicketKeys. To use + // SetSessionTicketKeys, use Server.Serve with a TLS Listener + // instead. + TLSConfig *tls.Config + + // ErrorLog specifies an optional logger for errors accepting + // connections, unexpected behavior from handlers, and + // underlying FileSystem errors. + // If nil, logging is done via the log package's standard logger. + ErrorLog *log.Logger + + inShutdown int32 // accessed atomically (non-zero means we're in Shutdown) + mu sync.Mutex + listeners map[*net.Listener]struct{} + activeConn map[*conn]struct{} + doneChan chan struct{} +} + +// ListenAndServe always returns a non-nil error. After Shutdown or Close, +// the returned error is ErrServerClosed. +func (srv *Server) ListenAndServe() error { + if srv.shuttingDown() { + return ErrServerClosed + } + if srv.Auth == nil { + return ErrInvalidAuthorizer + } + addr := srv.Addr + if addr == "" { + addr = ":pop3" + } + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + return srv.Serve(ln) +} + +// ListenAndServeTLS listens on the TCP network address srv.Addr and +// then calls ServeTLS to handle requests on incoming TLS connections. +// Accepted connections are configured to enable TCP keep-alives. +// +// Filenames containing a certificate and matching private key for the +// server must be provided if neither the Server's TLSConfig.Certificates +// nor TLSConfig.GetCertificate are populated. If the certificate is +// signed by a certificate authority, the certFile should be the +// concatenation of the server's certificate, any intermediates, and +// the CA's certificate. +func (srv *Server) ListenAndServeTLS(certFile, keyFile string) error { + if srv.shuttingDown() { + return ErrServerClosed + } + addr := srv.Addr + if addr == "" { + addr = ":pop3s" + } + + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + + defer ln.Close() + + return srv.ServeTLS(ln, certFile, keyFile) +} + +// Serve accepts incoming connections on the Listener l, creating a +// new service goroutine for each. The service goroutines read requests and +// then call srv.Handler to reply to them. +// +// Serve always returns a non-nil error and closes l. +// After Shutdown or Close, the returned error is ErrServerClosed. +func (srv *Server) Serve(l net.Listener) error { + if !srv.trackListener(&l, true) { + return ErrServerClosed + } + defer srv.trackListener(&l, false) + + var tempDelay time.Duration // how long to sleep on accept Err + for { + rw, e := l.Accept() + if e != nil { + select { + case <-srv.getDoneChan(): + return ErrServerClosed + default: + } + if ne, ok := e.(net.Error); ok && ne.Temporary() { + if tempDelay == 0 { + tempDelay = 5 * time.Millisecond + } else { + tempDelay *= 2 + } + if max := 1 * time.Second; tempDelay > max { + tempDelay = max + } + srv.logf("pop3: Accept error: %v; retrying in %v", e, tempDelay) + time.Sleep(tempDelay) + continue + } + return e + } + tempDelay = 0 + c := srv.newConn(rw) + connCtx := context.Background() + go c.serve(connCtx) + } +} + +// ServeTLS accepts incoming connections on the Listener l, creating a +// new service goroutine for each. The service goroutines perform TLS +// setup and then read requests, calling srv.Handler to reply to them. +// +// Files containing a certificate and matching private key for the +// server must be provided if neither the Server's +// TLSConfig.Certificates nor TLSConfig.GetCertificate are populated. +// If the certificate is signed by a certificate authority, the +// certFile should be the concatenation of the server's certificate, +// any intermediates, and the CA's certificate. +// +// ServeTLS always returns a non-nil error. After Shutdown or Close, the +// returned error is ErrServerClosed. +func (srv *Server) ServeTLS(l net.Listener, certFile, keyFile string) error { + config := &tls.Config{} + if srv.TLSConfig != nil { + config = srv.TLSConfig.Clone() + } + + configHasCert := len(config.Certificates) > 0 || config.GetCertificate != nil + if !configHasCert || certFile != "" || keyFile != "" { + var err error + config.Certificates = make([]tls.Certificate, 1) + config.Certificates[0], err = tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return err + } + } + + tlsListener := tls.NewListener(l, config) + return srv.Serve(tlsListener) +} + +func (srv *Server) shuttingDown() bool { + // TODO: replace inShutdown with the existing atomicBool type; + // see https://github.com/golang/go/issues/20239#issuecomment-381434582 + return atomic.LoadInt32(&srv.inShutdown) != 0 +} + +func (srv *Server) getDoneChan() <-chan struct{} { + srv.mu.Lock() + defer srv.mu.Unlock() + return srv.getDoneChanLocked() +} + +func (srv *Server) getDoneChanLocked() chan struct{} { + if srv.doneChan == nil { + srv.doneChan = make(chan struct{}) + } + return srv.doneChan +} + +func (srv *Server) closeDoneChanLocked() { + ch := srv.getDoneChanLocked() + select { + case <-ch: + // Already closed. Don't close again. + default: + // Safe to close here. We're the only closer, guarded + // by srv.mu. + close(ch) + } +} + +// Close immediately closes all active net.Listeners and any +// connections in state StateNew, StateActive, or StateIdle. For a +// graceful shutdown, use Shutdown. +// +// Close does not attempt to close (and does not even know about) +// any hijacked connections, such as WebSockets. +// +// Close returns any error returned from closing the Server's +// underlying Listener(s). +func (srv *Server) Close() error { + atomic.StoreInt32(&srv.inShutdown, 1) + srv.mu.Lock() + defer srv.mu.Unlock() + srv.closeDoneChanLocked() + err := srv.closeListenersLocked() + for c := range srv.activeConn { + c.rwc.Close() + delete(srv.activeConn, c) + } + return err +} + +func (srv *Server) logf(format string, args ...interface{}) { + if srv.ErrorLog != nil { + srv.ErrorLog.Printf(format, args...) + } else { + log.Printf(format, args...) + } +} + +func (srv *Server) closeListenersLocked() error { + var err error + for ln := range srv.listeners { + if cerr := (*ln).Close(); cerr != nil && err == nil { + err = cerr + } + delete(srv.listeners, ln) + } + return err +} + +// Create new connection from rwc. +func (srv *Server) newConn(rwc net.Conn) *conn { + c := &conn{ + server: srv, + rwc: rwc, + text: textproto.NewConn(rwc), + } + + if srv.TLSConfig != nil { + c.TLSConfig = srv.TLSConfig.Clone() + } + + return c +} + +// trackListener adds or removes a net.Listener to the set of tracked +// listeners. +// +// We store a pointer to interface in the map set, in case the +// net.Listener is not comparable. This is safe because we only call +// trackListener via Serve and can track+defer untrack the same +// pointer to local variable there. We never need to compare a +// Listener from another caller. +// +// It reports whether the server is still up (not Shutdown or Closed). +func (srv *Server) trackListener(ln *net.Listener, add bool) bool { + srv.mu.Lock() + defer srv.mu.Unlock() + if srv.listeners == nil { + srv.listeners = make(map[*net.Listener]struct{}) + } + if add { + if srv.shuttingDown() { + return false + } + srv.listeners[ln] = struct{}{} + } else { + delete(srv.listeners, ln) + } + return true +} + +func (srv *Server) trackConn(c *conn, add bool) { + srv.mu.Lock() + defer srv.mu.Unlock() + if srv.activeConn == nil { + srv.activeConn = make(map[*conn]struct{}) + } + if add { + srv.activeConn[c] = struct{}{} + } else { + delete(srv.activeConn, c) + } +} + +// A conn represents the server side of an POP3 connection. +type conn struct { + // server is the server on which the connection arrived. + // Immutable; never nil. + server *Server + + // rwc is the underlying network connection. + // This is never wrapped by other types and is the value given out + // to CloseNotifier callers. It is usually of type *net.TCPConn or + // *tls.Conn. + rwc net.Conn + + // text is the textproto.Conn used by the Client. + text *textproto.Conn + + // TLSConfig is the tls.Config used by the connection. + TLSConfig *tls.Config + + cmd string + arg string + + err error // Sticky error. +} + +// Serve a new connection. +func (c *conn) serve(ctx context.Context) { + c.server.trackConn(c, true) + defer func() { + if err := recover(); err != nil /* && err != ErrAbortHandler */ { + const size = 64 << 10 + buf := make([]byte, size) + buf = buf[:runtime.Stack(buf, false)] + c.logf("pop3: panic serving %s: %v\n%s", c.rwc.RemoteAddr(), err, buf) + } + c.close() + c.server.trackConn(c, false) + }() + + // +OK Gpop ready for requests from 89.64.10.226 v14mb24979864ljv + c.Ok("Gpop ready for requests from %s %p", c.rwc.RemoteAddr(), c) + + // start state machine + if err := c.auth(c.server.Auth); err != nil { + if err != io.EOF { + c.logf("pop3: Protocol error from %s: %v", c.rwc.RemoteAddr(), err) + } + return + } +} + +func (c *conn) logf(format string, args ...interface{}) { + c.server.logf(format, args...) +} + +// Close the connection. +func (c *conn) close() { + c.rwc.Close() +} + +// setErr records the first error encountered. +func (c *conn) setErr(err error) { + if c.err == nil || c.err == io.EOF { + c.err = err + } +} + +// debugConnections controls whether all server connections are wrapped +// with a verbose logging wrapper. +const debugConnections = false + +func (c *conn) scan() bool { + if c.err != nil { + return false + } + + l, err := c.text.ReadLine() + if err != nil { + c.setErr(err) + return false + } + + if debugConnections { + fmt.Println("C:", l) + } + + part := strings.SplitN(l, " ", 2) + c.cmd = strings.ToUpper(part[0]) + c.arg = "" + if len(part) > 1 { + c.arg = part[1] + } + + return true +} + +func (c *conn) send(format string, args ...interface{}) { + if debugConnections { + fmt.Printf("S: "+format+"\n", args...) + } + + if err := c.text.PrintfLine(format, args...); err != nil { + c.setErr(err) + } +} + +func (c *conn) Ok(format string, args ...interface{}) { + c.send("+OK "+format, args...) +} + +func (c *conn) Err(format string, args ...interface{}) { + c.send("-ERR "+format, args...) +} + +func (c *conn) auth(auth Authorizer) error { + var user string + var pass string + + for c.scan() { + switch c.cmd { + case POP3User, POP3Pass: + switch c.cmd { + case POP3User: + user = c.arg + case POP3Pass: + pass = c.arg + } + + if user != "" && pass != "" { + m, err := auth.Auth(user, pass) + if err != nil { + c.Err("invalid password") + continue + } + return c.process(m) + } + + c.Ok("send PASS") + + case POP3StartTLS: + if c.TLSConfig == nil { + c.logf("pop3: startls missing tls config %s", c.rwc.RemoteAddr()) + c.Err("malformed command") + return nil + } + + c.rwc = tls.Server(c.rwc, c.TLSConfig) + c.text = textproto.NewConn(c.rwc) + c.Ok("Begin TLS negotiation") + + case POP3Capability: + c.Ok("Capability list follows") + for _, cap := range []string{ + "USER", + "STLS", + "IMPLEMENTATION go-pop3", + } { + c.send(cap) + } + c.send(".") + + case POP3Quit: + return c.quit() + + default: + c.Err("malformed command") + } + } + + return c.err +} + +type hash string + +func (c *conn) process(maildrop Maildropper) error { + var total int // total messages size + size := make(map[hash]int) // mapping from hash to size + index := make(map[int]hash) // mapping from temporary numeric id to hash + // set of messages marked for deleteion + deleted := make(map[int]struct{}) + + sizes, err := maildrop.List() + if err != nil { + c.Err("maildrop locked") + return err + } + + var keys []string + for k := range sizes { + keys = append(keys, k) + } + sort.Strings(keys) + + // TODO(dzeromsk): simplify after adding sorted keys, remove deletes, + // use index etc. + for n, k := range keys { + index[n+1] = hash(k) + size[hash(k)] = sizes[k] + total += sizes[k] + } + + c.Ok("welcome home") + + for c.scan() { + switch c.cmd { + case POP3Noop: + c.Ok("") + + case POP3Status: + c.Ok("%d %d", len(size), total) + + case POP3List, POP3UIDList: + if c.arg == "" { + c.Ok("%d messages (%d octets)", len(size), total) + switch c.cmd { + case POP3List: + for n, v := range keys { + if _, ok := deleted[n+1]; !ok { + c.send("%d %d", n+1, size[hash(v)]) + } + } + + case POP3UIDList: + for k, v := range index { + if _, ok := deleted[k]; !ok { + c.send("%d %s", k, v) + } + } + } + c.send(".") + continue + } + + n, err := strconv.Atoi(c.arg) + if err != nil { + c.Err("invalid argument") + continue + } + switch c.cmd { + case POP3List: + c.Ok("%d %d", n, size[index[n]]) + continue + + case POP3UIDList: + c.Ok("%d %s", n, index[n]) + continue + } + + case POP3Retrieve: + n, err := strconv.Atoi(c.arg) + if err != nil { + c.Err("invalid argument") + continue + } + h, ok := index[n] + if !ok { + c.Err("unknown message") + continue + } + var buf bytes.Buffer + if err := maildrop.Get(string(h), &buf); err != nil { + c.Err("no such message") + continue + } + c.Ok("%d octets", buf.Len()) + w := c.text.DotWriter() + buf.WriteTo(w) + w.Close() + + case POP3Delete: + n, err := strconv.Atoi(c.arg) + if err != nil { + c.Err("invalid argument") + continue + } + if _, ok := index[n]; !ok { + c.Err("unknown message") + continue + } + deleted[n] = struct{}{} + c.Ok("message %d deleted", n) + + case POP3Reset: + n, err := strconv.Atoi(c.arg) + if err != nil { + c.Err("invalid argument") + continue + } + if _, ok := deleted[n]; !ok { + c.Err("RSET _what_?") + continue + } + delete(deleted, n) + c.Ok("") + + case POP3Quit: + var err error + for k := range deleted { + err2 := maildrop.Delete(string(index[k])) + if err2 != nil { + err = err2 + } + } + if err != nil { + c.Err("oops") + continue + } + + return c.quit() + + default: + c.Err("malformed command") + } + } + + return c.err +} + +func (c *conn) quit() error { + c.Ok("bye") + return nil +} diff --git a/server_fuzz.go b/server_fuzz.go new file mode 100644 index 0000000..16e8df6 --- /dev/null +++ b/server_fuzz.go @@ -0,0 +1,85 @@ +// +build gofuzz + +package pop3 + +import ( + "io" + "net" +) + +var server = &Server{ + Auth: &auth{ + m: &memoryMaildrop{ + messages: map[string]string{ + "foo": "first", + "bar": "second", + "foobar": "third", + }, + }, + }, +} + +var done = make(chan struct{}) +var ln net.Listener + +// TODO(dzeromsk): remove tcp/ip stuff +func init() { + var err error + ln, err = net.ListenTCP("tcp", nil) + if err != nil { + panic(err) + } + + go func() { + if err := server.Serve(ln); err != nil { + panic(err) + } + close(done) + }() +} + +func Fuzz(data []byte) int { + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + return 0 + } + defer conn.Close() + + _, err = conn.Write(data) + if err != nil { + return 0 + } + + return 1 +} + +type auth struct { + m Maildropper +} + +func (a *auth) Auth(user, pass string) (Maildropper, error) { + return a.m, nil +} + +type memoryMaildrop struct { + messages map[string]string +} + +func (m *memoryMaildrop) List() (sizes map[string]int, err error) { + sizes = make(map[string]int) + for k, v := range m.messages { + sizes[k] = len(v) + } + + return sizes, nil +} + +func (m *memoryMaildrop) Get(key string, message io.Writer) (err error) { + message.Write([]byte(m.messages[key])) + return nil +} + +func (m *memoryMaildrop) Delete(key string) (err error) { + delete(m.messages, key) + return nil +} diff --git a/server_test.go b/server_test.go new file mode 100644 index 0000000..0147056 --- /dev/null +++ b/server_test.go @@ -0,0 +1,460 @@ +package pop3 + +import ( + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "net" + "regexp" + "testing" + "time" + + "github.com/google/goexpect" +) + +const timeout = 1 * time.Second + +func Example() { + a := &auth{ + m: &memoryMaildrop{ + messages: map[string]string{ + "foo": "Subject: first", + "bar": "Subject: second", + "foobar": "Subject: thirdasdasd\n\ndas", + }, + }, + } + err := ListenAndServeTLS(":1995", "cert.pem", "key.pem", a) + if err != nil { + log.Fatalln(err) + } +} +func TestPOP3Server(t *testing.T) { + messages := map[string]string{ + "foo": "first", + "bar": "second", + "foobar": "third", + } + dummy := &auth{ + m: &memoryMaildrop{ + messages: messages, + }, + } + var tests = []struct { + name string + auth Authorizer + cmd []fmt.Stringer + fail bool + }{ + { + name: "simple retr", + auth: dummy, + cmd: []fmt.Stringer{ + s(`\+OK Gpop ready for requests from .*`), + c(`USER john`), + s(`\+OK send PASS`), + c(`PASS qwerty`), + s(`\+OK welcome`), + c(`LIST`), + s(`\+OK 3 messages \(.+ octets\).*`), + c(`RETR 1`), + s(`\+OK . octets`), + c(`RETR 2`), + s(`\+OK . octets`), + c(`RETR 3`), + s(`\+OK . octets`), + c(`QUIT`), + s(`\+OK bye`), + }, + fail: false, + }, + { + name: "fast quit", + auth: dummy, + cmd: []fmt.Stringer{ + s(`\+OK Gpop ready for requests from .*`), + c(`QUIT`), + s(`\+OK bye`), + }, + fail: false, + }, + { + name: "transition to transaction state", + auth: dummy, + cmd: []fmt.Stringer{ + s(`\+OK Gpop ready for requests from .*`), + c(`USER a b c`), + s(`\+OK send PASS`), + c(`PASS X Y Z`), + s(`\+OK welcome home`), + }, + fail: false, + }, + { + name: "invalid commands in authorization state", + auth: dummy, + cmd: []fmt.Stringer{ + s(`\+OK Gpop ready for requests from .*`), + c(`STAT`), + s(`-ERR malformed command`), + c(`LIST`), + s(`-ERR malformed command`), + c(`RETR 1`), + s(`-ERR malformed command`), + c(`RETR`), + s(`-ERR malformed command`), + c(`DELE`), + s(`-ERR malformed command`), + c(`DELE 1`), + s(`-ERR malformed command`), + c(`NOOP`), + s(`-ERR malformed command`), + c(`RSET`), + s(`-ERR malformed command`), + c(`TOP`), + s(`-ERR malformed command`), + c(`UIDL`), + s(`-ERR malformed command`), + c(``), + s(`-ERR malformed command`), + }, + fail: false, + }, + { + name: "capabilities", + auth: dummy, + cmd: []fmt.Stringer{ + s(`\+OK Gpop ready for requests from .*`), + c(`CAPS`), + s(`-ERR malformed command`), + c(`CAPA`), + s(`\+OK Capability list follows`), + // s(`USER`), + // s(`STLS`), + // s(`IMPLEMENTATION go-pop3`), + // s(`\.`), + c(`QUIT`), + s(`\+OK bye`), + }, + fail: false, + }, + { + name: "experimenting", + auth: dummy, + cmd: []fmt.Stringer{ + s(`\+OK Gpop ready for requests from .*`), + c(`USER john`), + s(`\+OK send PASS`), + c(`PASS qwerty`), + s(`\+OK welcome`), + c(`STAT`), + s(`\+OK 3 16`), + c(`LIST`), + s(`\+OK 3 messages \(.+ octets\)`), + // s(`1 5`), + // s(`2 6`), + // s(`3 16`), + // s(`\.`), + c(`LIST 1`), + s(`\+OK 1 6`), + c(`RETR 1`), + s(`\+OK 6 octets`), + c(`NOOP`), + s(`\+OK`), + c(`NOOP asd`), + s(`\+OK`), + + c(`UIDL`), + s(`\+OK 3 messages \(.+ octets\)`), + // s(`1 foo`), + // s(`2 bar`), + // s(`3 foobarty`), + // s(`\.`), + c(`UIDL 1`), + s(`\+OK 1 bar`), + c(`LIST 1`), + s(`\+OK 1 6`), + c(`UIDL abc`), + s(`-ERR invalid argument`), + c(`LIST abc`), + s(`-ERR invalid argument`), + c(`USER`), + s(`-ERR malformed command`), + c(`PASS`), + s(`-ERR malformed command`), + c(`QUIT`), + s(`\+OK bye`), + }, + fail: false, + }, + { + name: "test retrive", + auth: dummy, + cmd: []fmt.Stringer{ + s(`\+OK Gpop ready for requests from .*`), + c(`USER john`), + s(`\+OK send PASS`), + c(`PASS qwerty`), + s(`\+OK welcome`), + c(`RETR 1`), + s(`\+OK . octets`), + c(`RETR x`), + s(`-ERR invalid argument`), + c(`RETR 1024`), + s(`-ERR unknown message`), + c(`QUIT`), + s(`\+OK bye`), + }, + fail: false, + }, + { + name: "test deletes", + auth: dummy, + cmd: []fmt.Stringer{ + s(`\+OK Gpop ready for requests from .*`), + c(`USER john`), + s(`\+OK send PASS`), + c(`PASS qwerty`), + s(`\+OK welcome`), + c(`STAT`), + s(`\+OK 3 16`), + c(`DELE`), + s(`-ERR invalid argument`), + c(`DELE 2`), + s(`\+OK message 2 deleted`), + c(`DELE x`), + s(`-ERR invalid argument`), + c(`DELE a b`), + s(`-ERR invalid argument`), + c(`DELE 4`), + s(`-ERR unknown message`), + c(`RSET`), + s(`-ERR invalid argument`), + c(`RSET 123`), + s(`-ERR RSET _what_\?`), + c(`RSET 1`), + s(`-ERR RSET _what_\?`), + c(`DELE 3`), + s(`\+OK message 3 deleted`), + c(`RSET 3`), + s(`\+OK`), + c(`RSET 2`), + s(`\+OK`), + c(`RSET 1`), + s(`-ERR RSET _what_\?`), + c(`QUIT`), + s(`\+OK bye`), + }, + fail: false, + }, + { + name: "failing list", + auth: &auth{ + m: &listErrorMaildrop{}, + }, + cmd: []fmt.Stringer{ + s(`\+OK Gpop ready for requests from .*`), + c(`USER john`), + s(`\+OK send PASS`), + c(`PASS qwerty`), + s(`-ERR maildrop locked`), + }, + fail: false, + }, + { + name: "failing maildrop", + auth: &auth{ + m: &errorMaildrop{ + messages: messages, + }, + }, + cmd: []fmt.Stringer{ + s(`\+OK Gpop ready for requests from .*`), + c(`USER john`), + s(`\+OK send PASS`), + c(`PASS qwerty`), + s(`\+OK welcome`), + c(`RETR 1`), + s(`-ERR no such message`), + c(`RETR 2`), + s(`-ERR no such message`), + c(`RETR 3`), + s(`-ERR no such message`), + c(`DELE 3`), + s(`\+OK message 3 deleted`), + c(`QUIT`), + s(`-ERR oops`), + }, + fail: false, + }, + } + + // for n, tst := range tests { + // var data []byte + // for _, cmd := range tst.cmd { + // switch cmd.(type) { + // case c: + // fmt.Print(cmd) + // data = append(data, []byte(cmd.String())...) + // } + // } + // if err := ioutil.WriteFile(fmt.Sprintf("corpus/%d", n), data, 0644); err != nil { + // log.Fatalln(err) + // } + // } + + ln, err := net.ListenTCP("tcp", nil) + if err != nil { + t.Fatalf("net.Listen failed: %v", err) + } + + log.SetOutput(ioutil.Discard) + + server := &Server{Auth: dummy} + done := make(chan struct{}) + go func() { + if err := server.Serve(ln); err != nil { + t.Fatalf("server.Serve failed: %v", err) + } + close(done) + }() + defer server.Close() + +tests: + for _, tst := range tests { + exp, err := SpawnConnection(ln.Addr(), timeout, done) + if err != nil { + t.Errorf("%s: SpawnConnection failed: %v", tst.name, err) + continue + } + defer exp.Close() + + for _, cmd := range tst.cmd { + server.Auth = tst.auth + switch cmd.(type) { + case s: + re, err := regexp.Compile(cmd.String()) + if err != nil { + t.Errorf("%s: regexp.Compile failed: `%s` %v", tst.name, cmd, err) + continue tests + } + out, _, err := exp.Expect(re, timeout) + if got, want := err == nil, !tst.fail; got != want { + t.Errorf("%s: Expect(%q,%v) = %t want: %t , err: %v, out: %q", tst.name, re.String(), 0, got, want, err, out) + continue tests + } + + case c: + if err := exp.Send(cmd.String()); err != nil { + t.Errorf("%s, exp.Send failed: `%s` %v", tst.name, cmd, err) + continue tests + } + } + } + } +} + +type auth struct { + m Maildropper +} + +func (a *auth) Auth(user, pass string) (Maildropper, error) { + return a.m, nil +} + +type memoryMaildrop struct { + messages map[string]string +} + +func (m *memoryMaildrop) List() (sizes map[string]int, err error) { + sizes = make(map[string]int) + for k, v := range m.messages { + sizes[k] = len(v) + } + + return sizes, nil +} + +func (m *memoryMaildrop) Get(key string, message io.Writer) (err error) { + message.Write([]byte(m.messages[key])) + return nil +} + +func (m *memoryMaildrop) Delete(key string) (err error) { + delete(m.messages, key) + return nil +} + +type errorMaildrop struct { + messages map[string]string +} + +func (m *errorMaildrop) List() (sizes map[string]int, err error) { + sizes = make(map[string]int) + for k, v := range m.messages { + sizes[k] = len(v) + } + + return sizes, nil +} + +func (m *errorMaildrop) Get(key string, message io.Writer) (err error) { + return errors.New("get error") +} + +func (m *errorMaildrop) Delete(key string) (err error) { + return errors.New("delete error") +} + +type listErrorMaildrop struct{} + +func (m *listErrorMaildrop) List() (sizes map[string]int, err error) { + return nil, errors.New("list error") +} + +func (m *listErrorMaildrop) Get(key string, message io.Writer) (err error) { + return errors.New("get error") + +} + +func (m *listErrorMaildrop) Delete(key string) (err error) { + return errors.New("delete error") +} + +func SpawnConnection(addr net.Addr, timeout time.Duration, done chan struct{}) (*expect.GExpect, error) { + conn, err := net.Dial("tcp", addr.String()) + if err != nil { + return nil, err + } + exp, _, err := expect.SpawnGeneric(&expect.GenOptions{ + In: conn, + Out: conn, + Wait: func() error { + <-done + return nil + }, + Close: func() error { + return conn.Close() + }, + Check: func() bool { return true }, + }, timeout) + if err != nil { + return nil, err + } + + return exp, nil +} + +type c string + +func (c c) String() string { + return string(c) + "\r\n" +} + +type s string + +func (s s) String() string { + return string(s) +}