/* Go-Guerrilla SMTPd An minimalist SMTP server written in Go, made for receiving large volumes of mail. Copyright (c) 2012 Flashmob, GuerrillaMail.com Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. What is Go Guerrilla SMTPd? It's a small SMTP server written in Go, optimized for receiving email. Written for GuerrillaMail.com which processes tens of thousands of emails every hour. Version: 1.0 Author: Flashmob, GuerrillaMail.com Contact: flashmob@gmail.com License: MIT Repository: https://github.com/flashmob/Go-Guerrilla-SMTPd Site: http://www.guerrillamail.com/ See README for more details */ /* Install mysql drivers $ go get github.com/ziutek/mymysql/thrsafe $ go get github.com/ziutek/mymysql/autorc $ go get github.com/ziutek/mymysql/godrv $ go get github.com/sloonz/go-iconv */ package main import ( "bufio" "bytes" "compress/zlib" "crypto/md5" "crypto/rand" "crypto/tls" // "database/sql" "encoding/base64" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "github.com/garyburd/redigo/redis" "github.com/sloonz/go-iconv" "github.com/sloonz/go-qprintable" "github.com/ziutek/mymysql/autorc" _ "github.com/ziutek/mymysql/godrv" "io" "io/ioutil" "log" "net" //"os" "regexp" "runtime" "strconv" "strings" "syscall" "time" ) // defaults. Overwrite any of these in the configure() function which loads them from a json file var gConfig = map[string]string{ "MAX_SMTP_CLIENTS": "10000", "GSMTP_MAX_SIZE": "131072", "GSMTP_HOST_NAME": "server.example.com", // This should also be set to reflect your RDNS "GSMTP_VERBOSE": "Y", "GSMTP_TIMEOUT": "100", // how many seconds before timeout. "MYSQL_HOST": "127.0.0.1:3306", "MYSQL_USER": "gmail_mail", "MYSQL_PASS": "ok", "MYSQL_DB": "gmail_mail", "GM_MAIL_TABLE": "new_mail", "GSMTP_USER": "nobody", "GSTMP_LISTEN_INTERFACE": "1.0.0.0:25", "GSMTP_LOG_FILE": "gosmtp.log", "GSMTP_GID": "", "GSMTP_UID": "", "GSMTP_PUB_KEY": "/etc/ssl/certs/ssl-cert-snakeoil.pem", "GSMTP_PRV_KEY": "/etc/ssl/private/ssl-cert-snakeoil.key", "GM_ALLOWED_HOSTS": "guerrillamail.de,guerrillamailblock.com", "GM_PRIMARY_MAIL_HOST": "guerrillamail.com", "GM_CONN_BACKLOG": "100", "GM_MAX_CLIENTS": "500", "SGID": "1008", // group id "SUID": "1008", // user id, from /etc/passwd } type Client struct { state int helo string mail_from string rcpt_to string read_buffer string response string address string data string subject string hash string time int64 tls_on bool socket net.Conn bufin *bufio.Reader bufout *bufio.Writer kill_time int64 errors int clientId int64 savedNotify chan int } type redisClient struct { count int conn redis.Conn time int } var TLSconfig *tls.Config var clientChan chan *Client // connection backlog var sem chan int // currently active clients var SaveMailChan chan *Client // workers for saving mail // hosts allowed in the 'to' address' var allowedHosts = make(map[string]bool, 15) func configure() { var configFile, verbose, iface string // parse command line arguments flag.StringVar(&configFile, "config", "goguerrilla.conf", "Path to the configuration file") flag.StringVar(&verbose, "v", "n", "Verbose, [y | n] ") flag.StringVar(&iface, "if", "", "Interface and port to listen on, eg. 127.0.0.1:2525 ") flag.Parse() // load in the config. b, err := ioutil.ReadFile(configFile) if err != nil { fmt.Println("Could not read config file") panic(err) } var myConfig map[string]string err = json.Unmarshal(b, &myConfig) if err != nil { fmt.Println("Could not parse config file") panic(err) } for k, v := range myConfig { gConfig[k] = v } gConfig["GSMTP_VERBOSE"] = strings.ToUpper(verbose) if len(iface) > 0 { gConfig["GSTMP_LISTEN_INTERFACE"] = iface } // map the allow hosts for easy lookup if arr := strings.Split(gConfig["GM_ALLOWED_HOSTS"], ","); len(arr) > 0 { for i := 0; i < len(arr); i++ { allowedHosts[arr[i]] = true } } var n int var n_err error if n, n_err = strconv.Atoi(gConfig["GM_CONN_BACKLOG"]); n_err != nil { n = 50 } // connection backlog list clientChan = make(chan *Client, n) if n, n_err = strconv.Atoi(gConfig["GM_MAX_CLIENTS"]); n_err != nil { n = 50 } // currently active client list sem = make(chan int, n) // database writing workers SaveMailChan = make(chan *Client, 4) return } func logln(level int, s string) { if level == 2 { log.Fatalf(s) } if gConfig["GSMTP_VERBOSE"] == "Y" { fmt.Println(s) } } func main() { configure() logln(1, "Loading priv:"+gConfig["GSMTP_PRV_KEY"]+" and pub:"+gConfig["GSMTP_PRV_KEY"]) cert, err := tls.LoadX509KeyPair(gConfig["GSMTP_PUB_KEY"], gConfig["GSMTP_PRV_KEY"]) if err != nil { logln(2, fmt.Sprintf("There was a problem with loading the certificate: %s", err)) } TLSconfig = &tls.Config{Certificates: []tls.Certificate{cert}, ClientAuth: tls.VerifyClientCertIfGiven, ServerName: gConfig["GSMTP_HOST_NAME"]} TLSconfig.Rand = rand.Reader listener, err := net.Listen("tcp", gConfig["GSTMP_LISTEN_INTERFACE"]) if err != nil { logln(2, fmt.Sprintf("Cannot listen on port, %s", err)) } gid, _ := strconv.ParseInt(gConfig["SGID"], 10, 32) uid, _ := strconv.ParseInt(gConfig["SUID"], 10, 32) syscall.Setgid(int(gid)) syscall.Setuid(int(uid)) logln(1, fmt.Sprintf("server listening on "+gConfig["GSTMP_LISTEN_INTERFACE"])) go Serve(clientChan) // Start our SMTP client worker pool go saveMail() // start our email saving worker pool clientId := int64(1) for { conn, err := listener.Accept() if err != nil { logln(1, fmt.Sprintf("Accept error: %s", err)) break } logln(1, fmt.Sprintf("server: accepted from %s", conn.RemoteAddr())) // place a new client on the channel clientChan <- &Client{ socket: conn, address: conn.RemoteAddr().String(), time: time.Now().Unix(), bufin: bufio.NewReader(conn), bufout: bufio.NewWriter(conn), clientId: clientId, savedNotify: make(chan int), } clientId++ } } func Serve(clientChan chan *Client) { for { // get new clients off the queue and pass them to the handler c := <-clientChan sem <- 1 // Wait for active queue to drain. go handleClient(c) // Don't wait for handle to finish. logln(1, fmt.Sprintf("There are now "+strconv.Itoa(runtime.NumGoroutine())+" goroutines")) } } func closeClient(client *Client) { client.socket.Close() <-sem // Done; enable next client to run. } func readSmtp(client *Client) (input string, err error) { var reply string // Command state terminator by default suffix := "\r\n" if client.state == 2 { // DATA state suffix = "\r\n.\r\n" } for err == nil { client.socket.SetDeadline(time.Now().Add(100 * time.Second)) reply, err = client.bufin.ReadString('\n') if reply != "" { input = input + reply if client.state == 2 { // Extract the subject while we are at it. scanSubject(client, reply) } } if err != nil { break } if strings.HasSuffix(input, suffix) { break } } return input, err } // Scan the data part for a Subject line. Can be a multi-line func scanSubject(client *Client, reply string) { if client.subject == "" && (len(reply) > 8) { test := strings.ToUpper(reply[0:9]) if i := strings.Index(test, "SUBJECT: "); i == 0 { // first line with \r\n client.subject = reply[9:] } } else if strings.HasSuffix(client.subject, "\r\n") { // chop off the \r\n client.subject = client.subject[0 : len(client.subject)-2] if (strings.HasPrefix(reply, " ")) || (strings.HasPrefix(reply, "\t")) { // subject is multi-line client.subject = client.subject + reply[1:] } } } func responseWrite(client *Client) (err error) { var size int client.socket.SetDeadline(time.Now().Add(100 * time.Second)) size, err = client.bufout.WriteString(client.response) client.bufout.Flush() client.response = client.response[size:] return err } func responseAdd(client *Client, line string) { client.response = line + "\r\n" } func responseClear(client *Client) { client.response = "" } func killClient(client *Client) { client.kill_time = time.Now().Unix() } func handleClient(client *Client) { var input_hist string defer closeClient(client) greeting := "220 " + gConfig["GSMTP_HOST_NAME"] + " SMTP Guerrilla-SMTPd #" + strconv.FormatInt(client.clientId, 10) + " (" + strconv.Itoa(len(sem)) + ") " + time.Now().Format(time.RFC1123Z) advertiseTls := "250-STARTTLS\r\n" for i := 0; i < 10; i++ { switch client.state { case 0: responseAdd(client, greeting) client.state = 1 case 1: input, err := readSmtp(client) if err != nil { if err == io.EOF { // client closed the connection already return } if neterr, ok := err.(net.Error); ok && neterr.Timeout() { // too slow, timeout return } break } input = strings.Trim(input, " \n\r") input_hist = input_hist + input + "\n" cmd := strings.ToUpper(input) switch { case strings.Index(cmd, "HELO") == 0: if len(input) > 5 { client.helo = input[5:] } responseAdd(client, "250 "+gConfig["GSMTP_HOST_NAME"]+" Hello ") case strings.Index(cmd, "EHLO") == 0: if len(input) > 5 { client.helo = input[5:] } if client.tls_on { advertiseTls = "" } responseAdd(client, "250-"+gConfig["GSMTP_HOST_NAME"]+" Hello "+client.helo+"["+client.address+"]"+"\r\n"+"250-SIZE "+gConfig["GSMTP_MAX_SIZE"]+"\r\n"+advertiseTls+"250 HELP") case strings.Index(cmd, "MAIL FROM:") == 0: if len(input) > 10 { client.mail_from = input[10:] } responseAdd(client, "250 Ok") case strings.Index(cmd, "RCPT TO:") == 0: if len(input) > 8 { client.rcpt_to = input[8:] } responseAdd(client, "250 Accepted") case strings.Index(cmd, "NOOP") == 0: responseAdd(client, "250 OK") case strings.Index(cmd, "RSET") == 0: client.mail_from = "" client.rcpt_to = "" responseAdd(client, "250 OK") case strings.Index(cmd, "DATA") == 0: responseAdd(client, "354 Enter message, ending with \".\" on a line by itself") client.state = 2 case (strings.Index(cmd, "STARTTLS") == 0) && !client.tls_on: responseAdd(client, "220 Ready to start TLS") // go to start TLS state client.state = 3 case strings.Index(cmd, "QUIT") == 0: responseAdd(client, "221 Bye") killClient(client) default: responseAdd(client, fmt.Sprintf("500 unrecognized command %v", err)) client.errors++ if client.errors > 3 { responseAdd(client, fmt.Sprintf("500 Too many unrecognized commands %v", err)) killClient(client) } } case 2: var err error client.data, err = readSmtp(client) if err == nil { // to do: timeout when adding to SaveMailChan // place on the channel so that one of the save mail workers can pick it up SaveMailChan <- client // wait for the save to complete status := <-client.savedNotify if status == 1 { responseAdd(client, "250 OK : queued as "+client.hash) } else { responseAdd(client, "554 Error: transaction failed, blame it on the weather") } } client.state = 1 case 3: // upgrade to TLS var tlsConn *tls.Conn tlsConn = tls.Server(client.socket, TLSconfig) tlsConn.Handshake() // not necessary to call here, but might as well client.socket = net.Conn(tlsConn) client.bufin = bufio.NewReader(client.socket) client.bufout = bufio.NewWriter(client.socket) client.state = 1 client.tls_on = true } // Send a response back to the client err := responseWrite(client) if err != nil { if err == io.EOF { // client closed the connection already return } if neterr, ok := err.(net.Error); ok && neterr.Timeout() { // too slow, timeout return } } if client.kill_time > 1 { return } } } func saveMail() { var to string var err error var body string var redis_err error var length int redis := &redisClient{} db := autorc.New("tcp", "", gConfig["MYSQL_HOST"], gConfig["MYSQL_USER"], gConfig["MYSQL_PASS"], gConfig["MYSQL_DB"]) db.Register("set names utf8") sql := "INSERT INTO " + gConfig["GM_MAIL_TABLE"] + " " sql += "(`date`, `to`, `from`, `subject`, `body`, `charset`, `mail`, `spam_score`, `hash`, `content_type`, `recipient`, `has_attach`, `ip_addr`)" sql += " values (NOW(), ?, ?, ?, ? , 'UTF-8' , ?, 0, ?, '', ?, 0, ?)" ins, sql_err := db.Prepare(sql) if sql_err != nil { logln(2, fmt.Sprintf("Sql statement incorrect: %s", sql_err)) } sql = "UPDATE gm2_setting SET `setting_value` = `setting_value`+1 WHERE `setting_name`='received_emails' LIMIT 1" incr, sql_err := db.Prepare(sql) if sql_err != nil { logln(2, fmt.Sprintf("Sql statement incorrect: %s", sql_err)) } //defer db.Close() // receives values from the channel repeatedly until it is closed. for { client := <-SaveMailChan if user, _, addr_err := validateEmailData(client); addr_err != nil { // user, host, addr_err logln(1, fmt.Sprintln("mail_from didnt validate: %v", addr_err)+" client.mail_from:"+client.mail_from) // notify client that a save completed, -1 = error client.savedNotify <- -1 continue } else { to = user + "@" + gConfig["GM_PRIMARY_MAIL_HOST"] } length = len(client.data) client.subject = mimeHeaderDecode(client.subject) client.hash = md5hex(to + client.mail_from + client.subject + strconv.FormatInt(time.Now().UnixNano(), 10)) // Add extra headers add_head := "" add_head += "Delivered-To: " + to + "\r\n" add_head += "Received: from " + client.helo + " (" + client.helo + " [" + client.address + "])\r\n" add_head += " by " + gConfig["GSMTP_HOST_NAME"] + " with SMTP id " + client.hash + "@" + gConfig["GSMTP_HOST_NAME"] + ";\r\n" add_head += " " + time.Now().Format(time.RFC1123Z) + "\r\n" // compress to save space client.data = compress(add_head + client.data) body = "gzencode" redis_err = redis.redisConnection() if redis_err == nil { _, do_err := redis.conn.Do("SETEX", client.hash, 3600, client.data) if do_err == nil { client.data = "" body = "redis" } //fmt.Println(do_reply, do_err) } else { fmt.Println("redis err", redis_err) } // bind data to cursor ins.Bind( to, client.mail_from, client.subject, body, client.data, client.hash, to, client.address) // save, discard result _, _, err = ins.Exec() if err != nil { logln(1, fmt.Sprintf("Database error, %v %v", err)) client.savedNotify <- -1 } else { logln(1, "Email saved "+client.hash+" len:"+strconv.Itoa(length)) _, _, err = incr.Exec() if err != nil { fmt.Println(err) } client.savedNotify <- 1 } } } func (c *redisClient) redisConnection() (err error) { if c.count > 100 { c.conn.Close() c.count = 0 } if c.count == 0 { c.conn, err = redis.Dial("tcp", ":6379") if err != nil { // handle error return err } } return nil } func mysqlTest() { //var mysqlCon *sql.DB //mysqlCon, err := sql.Open("mymysql", gConfig["MYSQL_DB"]+"/"+gConfig["MYSQL_USER"]+"/"+gConfig["MYSQL_PASS"]) //if err != nil { // log.Fatalf("Cannot open Mysql connection: %s", err) //} // defer mysqlCon.Close() } func validateEmailData(client *Client) (user string, host string, addr_err error) { if user, host, addr_err = extractEmail(client.mail_from); addr_err != nil { return user, host, addr_err } client.mail_from = user + "@" + host if user, host, addr_err = extractEmail(client.rcpt_to); addr_err != nil { return user, host, addr_err } client.rcpt_to = user + "@" + host // check if on allowed hosts if allowed := allowedHosts[host]; !allowed { return user, host, errors.New("invalid host:" + host) } return user, host, addr_err } func extractEmail(str string) (name string, host string, err error) { re, _ := regexp.Compile(`<(.+?)@(.+?)>`) // go home regex, you're drunk! if matched := re.FindStringSubmatch(str); len(matched) > 2 { host = validHost(matched[2]) name = matched[1] } else { if res := strings.Split(name, "@"); len(res) > 1 { name = matched[0] host = validHost(matched[1]) } } if host == "" || name == "" { err = errors.New("Invalid address, [" + name + "@" + host + "] address:" + str) } return name, host, err } // Decode strings in Mime header format // eg. =?ISO-2022-JP?B?GyRCIVo9dztSOWJAOCVBJWMbKEI=?= func mimeHeaderDecode(str string) string { reg, _ := regexp.Compile(`=\?(.+?)\?([QBqp])\?(.+?)\?=`) matched := reg.FindAllStringSubmatch(str, -1) var charset, encoding, payload string if matched != nil { for i := 0; i < len(matched); i++ { if len(matched[i]) > 2 { charset = matched[i][1] encoding = strings.ToUpper(matched[i][2]) payload = matched[i][3] switch encoding { case "B": str = strings.Replace(str, matched[i][0], mailTransportDecode(payload, "base64", charset), 1) case "Q": str = strings.Replace(str, matched[i][0], mailTransportDecode(payload, "quoted-printable", charset), 1) } } } } return str } func validHost(host string) string { host = strings.Trim(host, " ") re, _ := regexp.Compile(`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$`) if re.MatchString(host) { return host } return "" } // decode from 7bit to 8bit UTF-8 // encoding_type can be "base64" or "quoted-printable" func mailTransportDecode(str string, encoding_type string, charset string) string { if charset == "" { charset = "UTF-8" } else { charset = strings.ToUpper(charset) } if encoding_type == "base64" { str = fromBase64(str) } else if encoding_type == "quoted-printable" { str = fromQuotedP(str) } if charset != "UTF-8" { charset = fixCharset(charset) // eg. charset can be "ISO-2022-JP" convstr, err := iconv.Conv(str, "UTF-8", charset) if err == nil { return convstr } } return str } func fromBase64(data string) string { buf := bytes.NewBufferString(data) decoder := base64.NewDecoder(base64.StdEncoding, buf) res, _ := ioutil.ReadAll(decoder) return string(res) } func fromQuotedP(data string) string { buf := bytes.NewBufferString(data) decoder := qprintable.NewDecoder(qprintable.BinaryEncoding, buf) res, _ := ioutil.ReadAll(decoder) return string(res) } func compress(s string) string { var b bytes.Buffer w, _ := zlib.NewWriterLevel(&b, zlib.BestSpeed) // flate.BestCompression w.Write([]byte(s)) w.Close() return b.String() } func fixCharset(charset string) string { reg, _ := regexp.Compile(`[_:.\/\\]`) fixed_charset := reg.ReplaceAllString(charset, "-") // Fix charset // borrowed from http://squirrelmail.svn.sourceforge.net/viewvc/squirrelmail/trunk/squirrelmail/include/languages.php?revision=13765&view=markup // OE ks_c_5601_1987 > cp949 fixed_charset = strings.Replace(fixed_charset, "ks-c-5601-1987", "cp949", -1) // Moz x-euc-tw > euc-tw fixed_charset = strings.Replace(fixed_charset, "x-euc", "euc", -1) // Moz x-windows-949 > cp949 fixed_charset = strings.Replace(fixed_charset, "x-windows_", "cp", -1) // windows-125x and cp125x charsets fixed_charset = strings.Replace(fixed_charset, "windows-", "cp", -1) // ibm > cp fixed_charset = strings.Replace(fixed_charset, "ibm", "cp", -1) // iso-8859-8-i -> iso-8859-8 fixed_charset = strings.Replace(fixed_charset, "iso-8859-8-i", "iso-8859-8", -1) if charset != fixed_charset { return fixed_charset } return charset } func md5hex(str string) string { h := md5.New() h.Write([]byte(str)) sum := h.Sum([]byte{}) return hex.EncodeToString(sum) }