the Somewhat Immature SMTP Server
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

539 lines
25 KiB

;;;; the Somewhat Immature SMTP Server
(in-package :siss)
(defparameter *max-recipients* 50)
(defparameter *max-line-length* 512)
(defparameter *max-data-line-length* 1001)
(defparameter *bad-hosts*
:test #'equal
:synchronized t)
"Hash table for hosts that hit the evil counter.")
(defparameter +sayings+
#("250-Wonderful day for it, isn't it?"
"250-Please remain calm"
"250-Deliver the mail into the mail delivery tube"
"250-Delivery of your mail is not guaranteed."
"250-I'm a server!")
"Stupid sayings to echo back on EHLO.")
(defun get-saying ()
(elt +sayings+ (random (length +sayings+))))
(defun stream-read-sequence-until (stream end-element seq
&key (start 0) (end (array-total-size seq)) (read-func #'read-byte))
(declare (type stream stream) (type vector seq))
(assert (typep end-element (stream-element-type stream)))
(let ((count 0)
(orig-fill-pointer (fill-pointer seq)))
(setf (fill-pointer seq) (array-total-size seq))
(when (= start end)
(return count))
(let ((element (funcall read-func stream)))
(setf (aref seq start) element)
(incf start)
(incf count)
(when (eq element end-element)
(setf (fill-pointer seq) (+ orig-fill-pointer count))
(return count))))))
(defclass smtp-conn ()
:initarg :sock
:accessor conn-sock)
:initarg :remote-host
:accessor conn-remote-host)
:initform nil
:accessor conn-tlsp)))
(defun write-crlf-bytes (stream)
"Write CRLF to STREAM, as bytes."
(write-byte #.(char-code #\return) stream)
(write-byte #.(char-code #\linefeed) stream))
(defun write-line-bytes (stream line)
"Write LINE, a string, as UTF-8 encoded octets to STREAM."
(write-sequence (babel:string-to-octets line
:encoding :utf-8)
(write-crlf-bytes stream))
(defun conn-write (conn line)
"Writes LINE to CONN's socket."
(with-accessors ((sock conn-sock) (ra conn-remote-host)) conn
(format *debug-io* "~&~A --> ~A~%" ra line)
(write-line-bytes sock line)
(force-output sock)))
(defclass smtp-conn-initial (smtp-conn) ())
(defclass smtp-conn-ehlo (smtp-conn)
:initarg :domain
:accessor conn-domain)))
(defclass smtp-conn-from (smtp-conn-ehlo)
:initarg :from-addr
:accessor conn-from-addr)
:initform nil
:accessor conn-to-addrs)))
(defclass smtp-conn-data (smtp-conn-from)
:initform (make-array (* 1024 8)
:element-type 'flexi-streams:octet
:initial-element 0
:fill-pointer 0
:adjustable t)
:accessor conn-mail-data)))
(defclass smtp-conn-delivered (smtp-conn-data) ())
(defgeneric process-smtp-command (conn cmd)
(:documentation "Processes the SMTP command object CMD on connection CONN."))
(defmethod process-smtp-command ((conn smtp-conn) cmd)
(declare (ignore cmd))
(conn-write conn "500 I'm not sure I follow. (command unrecognized, malformed, or in the wrong place)"))
(defmethod process-smtp-command ((conn smtp-conn) (cmd cmd-rset))
(declare (ignore cmd))
(conn-write conn "250 I know no-thing. Noooo-thing!")
(change-class conn 'smtp-conn-initial))
(defmethod process-smtp-command ((conn smtp-conn) (cmd cmd-quit))
(declare (ignore cmd))
(conn-write conn "221 kthxbai")
(throw 'end-conn nil))
(defmethod process-smtp-command ((conn smtp-conn) (cmd cmd-noop))
(declare (ignore cmd))
(conn-write conn "250 Well, that was a waste of some CPU cycles, wasn't it?"))
(defmethod process-smtp-command ((conn smtp-conn-initial) (cmd helo-or-ehlo))
(if (typep cmd 'ehlo)
(conn-write conn (get-saying))
(when (and *ssl-key-path* *ssl-cert-path*)
(conn-write conn "250-STARTTLS"))
(conn-write conn "250-SMTPUTF8")
(conn-write conn "250-8BITMIME")
(conn-write conn (format nil "250 SIZE ~A" *max-size-bytes*)))
(conn-write conn (format nil "250 Hey ~A, I'm ~A o/" (supplied-domain cmd) *our-fqdn*)))
(change-class conn 'smtp-conn-ehlo
:domain (supplied-domain cmd)))
(defmethod process-smtp-command ((conn smtp-conn-ehlo) (cmd mail-from))
(when (and (mail-size cmd) (> (mail-size cmd) *max-size-bytes*))
(conn-write conn (format nil "552 Your message (~A bytes) is too big; the maximum message size is ~A bytes" (mail-size cmd) *max-size-bytes*))
(return-from process-smtp-command))
(conn-write conn "250 Sofa, so good.")
(change-class conn 'smtp-conn-from
:from-addr (mail-addr cmd)))
(defmethod process-smtp-command ((conn smtp-conn-from) (cmd rcpt-to))
(when (> (length (conn-to-addrs conn)) 100)
(conn-write conn "452 Too many recipients")
(return-from process-smtp-command))
(unless (string= (nth-value 1 (split-address (mail-addr cmd))) *our-mail-domain*)
(conn-write conn "550 I can't handle mail for that domain (relaying isn't implemented)")
(return-from process-smtp-command))
(push (mail-addr cmd) (conn-to-addrs conn))
(conn-write conn "250 OK"))
(defmethod process-smtp-command ((conn smtp-conn-from) (cmd cmd-data))
(when (eql (length (conn-to-addrs conn)) 0)
(conn-write conn "554 Wait, who am I sending this to again? (please send RCPT TO first)")
(return-from process-smtp-command))
(conn-write conn "354 Ready to ingest juicy email data. End with <CRLF>.<CRLF>")
(change-class conn 'smtp-conn-data))
(defun format-remote-host (host)
(format nil "~A.~A.~A.~A" (elt host 0) (elt host 1) (elt host 2) (elt host 3)))
(format nil "<~A>" host)))
(defun add-mail-headers (conn)
"Adds some Received, Return-Path etc. headers to the mail data object of CONN."
(with-accessors ((mail-data conn-mail-data)) conn
(let ((out (make-string-output-stream)))
(labels ((crlf ()
(write-char #\Return out)
(write-char #\Linefeed out)))
(format out "Return-Path: <~A>" (conn-from-addr conn))
(format out "Delivered-To: ~A" (first (conn-to-addrs conn)))
(format out "Received: from ~A ([~A]) by ~A (siss) with ~A id ~A for <~A>; ~A"
(conn-domain conn)
(conn-remote-host conn)
(if (conn-tlsp conn) "ESMTPS" "SMTP")
(random 1000000000)
(first (conn-to-addrs conn))
(local-time:format-rfc1123-timestring nil (local-time:now)))
(let ((header-octets (babel:string-to-octets (get-output-stream-string out))))
for octet across header-octets
do (vector-push-extend octet mail-data)))))))
(defun data-loop (conn)
"Loops, receiving mail data from CONN's socket and adding it to CONN's internal buffer, until all the mail data is received."
(with-accessors ((stream conn-sock) (mail-data conn-mail-data)) conn
(let ((headers-length (length mail-data)))
(block loop-body
(when (>= (length mail-data) (+ *max-size-bytes* headers-length))
(conn-write conn (format nil "552 Data transfer interrupted: your message data (~A bytes) is too big; the maximum message size is ~A bytes" (- (length mail-data) headers-length) *max-size-bytes*))
(error "Maximum message size exceeded"))
(let* ((start-idx (length mail-data))
(new-length (+ start-idx *max-data-line-length*)))
(setf mail-data (adjust-array mail-data new-length
:fill-pointer start-idx))
(let* ((bytes-read (stream-read-sequence-until stream #.(char-code #\linefeed) mail-data
:start start-idx))
(string-read (subseq mail-data start-idx (+ start-idx bytes-read))))
(when (>= bytes-read *max-data-line-length*)
(conn-write conn "500 Data line too long")
(return-from loop-body))
(when (eql bytes-read 0)
(error "Unexpected EOF"))
(when (and (<= bytes-read 3)
(eql (elt string-read 0) #.(char-code #\.)))
;; Get rid of the last <CRLF>.<CRLF>
(setf (fill-pointer mail-data) (max (- (length mail-data) bytes-read 2) 0))
(defun send-initial-header (conn)
(conn-write conn (format nil "220 ~A ESMTP nom nom, feed me your mail" *our-fqdn*)))
(define-condition too-much-evil-error (error) ())
(defun mail-loop (conn)
"Loops, receiving SMTP messages from CONN's socket and applying them to CONN, until the user disconnects or an error is thrown."
(send-initial-header conn)
(with-accessors ((stream conn-sock) (tlsp conn-tlsp) (ra conn-remote-host)) conn
(let ((line-buf (make-array *max-line-length*
:element-type '(unsigned-byte 8)
:fill-pointer 0))
(evil-count 0)
(lines-processed 0))
(block loop-body
(labels ((on-evil-action ()
(when (> (incf evil-count) 7)
(error 'too-much-evil-error))))
(setf (fill-pointer line-buf) 0)
(let ((bytes-read (stream-read-sequence-until stream (char-code #\linefeed) line-buf)))
(when (>= bytes-read *max-line-length*)
(conn-write conn "500 Line too long")
(return-from loop-body))
(when (eql bytes-read 0)
(error "Unexpected EOF"))
(incf lines-processed)
(when (> lines-processed 50)
(error 'too-much-evil-error))
(let* ((line (babel:octets-to-string line-buf
:encoding :utf-8
:errorp nil))
(line (string-right-trim '(#\Space #\Tab #\Return #\Linefeed) line))
(smtp-command (parse-smtp-command line)))
(format *debug-io* "~&~A <-- ~A~%" ra line)
(unless smtp-command
(when (and *enable-proxy-protocol* (uiop:string-prefix-p "PROXY " line))
(let ((proxy-src (extract-proxy-source-address line)))
(format *debug-io* "~&PROXY protocol: got source address ~A~%" proxy-src)
(when proxy-src
(setf (conn-remote-host conn) proxy-src)
(return-from loop-body))))
(conn-write conn "500 Couldn't parse SMTP command. Did you read the RFC?")
(return-from loop-body))
(when (and (typep smtp-command 'starttls) (not tlsp)
*ssl-key-path* *ssl-cert-path*)
(conn-write conn "220 [encryption noises]")
(setf stream (cl+ssl:make-ssl-server-stream stream
:certificate *ssl-cert-path*
:key *ssl-key-path*))
(setf tlsp t)
(change-class conn 'smtp-conn-initial)
(return-from loop-body))
(process-smtp-command conn smtp-command)
(when (typep conn 'smtp-conn-data)
(add-mail-headers conn)
(data-loop conn)
(let ((tid (deliver-mail conn)))
(conn-write conn (format nil "250 Mail delivered as #~A \o/" tid)))
(error (e)
(format *error-output* "~&failed to deliver!~%~A~%" e)
(conn-write conn "451 Couldn't deliver mail; retry?")))
(change-class conn 'smtp-conn-delivered))))))))))
(defun deliver-mail (conn)
"Delivers the mail data stored in CONN. Returns the RT ticket ID, if delivery was successful."
(let* ((mail (conn-mail-data conn))
(first-to-addr (first (conn-to-addrs conn)))
(to-localpart (split-address first-to-addr))
(queue-to-use (or
(cdr (assoc to-localpart *recipient-queue-mappings*))
(format *debug-io* "~&got a mail:~%~A~%"
(babel:octets-to-string mail
:errorp nil))
(format *debug-io* "~&intended for ~A@ => queue ~A~%" to-localpart queue-to-use)
(multiple-value-bind (filtered-mail spamp spam-header)
(if (and *sa-host* *sa-port*)
(bounded-spamc-process mail)
(when spam-header
(format *debug-io* "~&mail spam result: ~A~%" spam-header))
(when spamp
(setf queue-to-use *spam-rt-queue*))
(multiple-value-bind (ticket-id body)
(upload-email-to-rt filtered-mail queue-to-use)
(if ticket-id
(format *debug-io* "~&delivered as ticket ~A!~%" ticket-id)
(error "RT returned error: ~A" body))
(defun make-smtp-conn (sock)
"Make a SMTP-CONN object out of SOCK."
(let ((remote-addr (usocket:get-peer-address sock)))
(make-instance 'smtp-conn-initial
:remote-host (format-remote-host remote-addr)
:sock (usocket:socket-stream sock))))
(defun smtp-listen (host port)
(let ((master-socket (usocket:socket-listen host port
:reuse-address t
:element-type '(unsigned-byte 8))))
(let* ((sock (usocket:socket-accept master-socket))
(conn (make-smtp-conn sock)))
((evilness (gethash (conn-remote-host conn) *bad-hosts*)))
(if (and evilness (> evilness 5))
(usocket:socket-close sock)
(format *debug-io* "~&hark, a client from ~A~%" (conn-remote-host conn))
(lambda ()
(catch 'end-conn
(mail-loop conn))
(end-of-file () (format *debug-io* "~&an eof! how rude.~%"))
(babel-encodings:character-decoding-error (e) (format *debug-io* "~&a dodgy character!~%~A~%" e))
(cl+ssl::ssl-error-ssl (e) (format *debug-io* "~&ssl error: ~A~%" e))
(simple-error (e) (format *debug-io* "~&the simplest of errors: ~A~%" e))
(too-much-evil-error ()
(unless evilness
(setf evilness 0))
(incf evilness)
(format *error-output* "~&host ~A is evil. current evilness score: ~A~%" (conn-remote-host conn) evilness))
(stream-error (e) (format *debug-io* "~&stream error: ~A~%" e))
(error (e)
(format *error-output* "~&error on connection!~%~A~%" e)
(conn-write conn "421 Internal server error"))))
(usocket:socket-close sock))))
:name (format nil "SMTP thread for ~A" (conn-remote-host conn))))))))
(ignore-errors (usocket:socket-close master-socket)))))
(defun read-spamd-line (line-buf stream)
"Read a header line from the spamd stream STREAM, using LINE-BUF to store the data."
(setf (fill-pointer line-buf) 0)
with byte
do (progn
(setf byte (read-byte stream))
(vector-push-extend byte line-buf))
while (not (eql byte #.(char-code #\linefeed))))
(string-right-trim '(#\Space #\Tab #\Return #\Linefeed)
(babel:octets-to-string line-buf)))
(defun bounded-spamc-process (mail-data &optional (timeout *sa-timeout*))
"Like SPAMC-PROCESS, but uses a thread and waits at most TIMEOUT seconds before giving up and just returning the mail data back."
(let ((thread (sb-thread:make-thread
(lambda ()
(spamc-process mail-data)
(error (e)
(format *error-output* "~&failed to spamc process: ~A~%" e)
:name "spamc thread")))
(sb-thread:join-thread thread
:timeout timeout)
(sb-thread:join-thread-error () mail-data))))
(defun spamc-process (mail-data)
"Send MAIL-DATA, a MIME-encoded email blob, over to a SpamAssassin spamd server, returning (VALUES DATA SPAMP SPAM-HEADER), where DATA is the mail blob after processing, SPAMP is a boolean indicating whether or not the mail is spam, and SPAM-HEADER (if SPAMP is T) is a textual description of the spam score."
(let* ((sock (usocket:socket-connect *sa-host* *sa-port*
:element-type '(unsigned-byte 8)))
(stream (usocket:socket-stream sock)))
;; Send the mail for processing
(write-line-bytes stream "PROCESS SPAMC/1.5")
(write-line-bytes stream (format nil "Content-length: ~A" (length mail-data)))
(write-crlf-bytes stream)
(write-sequence mail-data stream)
(force-output stream)
(usocket:socket-shutdown sock :output)
(let* ((line-buf (make-array *max-data-line-length* ; good first approximation
:element-type '(unsigned-byte 8)
:fill-pointer 0))
(first-line (read-spamd-line line-buf stream))
(ret-code (parse-integer (elt (split-sequence:split-sequence #\Space first-line) 1))))
(when (not (eql ret-code 0))
(error "spamd returned an error: ~A" first-line))
(let* ((headers (loop
with line
do (setf line (read-spamd-line line-buf stream))
while (> (length line) 0)
collect (multiple-value-bind (header-name header-value)
(split-at line (position #\: line))
(cons header-name (subseq header-value 1)))))
(content-length (parse-integer (cdr (assoc "Content-length" headers
:test #'string=))))
(spam-header (cdr (assoc "Spam" headers
:test #'string=)))
(ret-buf (make-array (min content-length *max-spamd-content-length*)
:element-type '(unsigned-byte 8)
:fill-pointer 0)))
do (vector-push (read-byte stream) ret-buf))
(end-of-file () nil))
(values ret-buf
(uiop:string-prefix-p "True" spam-header)
(defun make-rt-email-body (mail-data queue action)
"Hack: RT breaks if we send it a filename in the multipart/form-data request body. To work around this, we just write our own request body instead of using Drakma's (which will unconditionally append a filename). This function returns a lambda we can pass to DRAKMA:HTTP-REQUEST that writes out the request body, as well as returning a Content-Type."
(let ((boundary (format nil "----------~A" (drakma::make-random-string))))
(lambda (stream)
(labels ((crlf ()
(write-char #\Return stream)
(write-char #\Linefeed stream))
(write-header (name)
(format stream "--~A" boundary)
(format stream "Content-Disposition: form-data; name=\"~A\"" name)
(write-header "queue")
(crlf) (format stream "~A" queue) (crlf)
(write-header "action")
(crlf) (format stream "~A" action) (crlf)
(write-header "message")
(format stream "Content-Type: application/octet-stream")
(crlf) (crlf) (write-sequence mail-data stream) (crlf)
(format stream "--~A--" boundary) (crlf)))
(format nil "multipart/form-data; boundary=~A" boundary))))
(defun upload-email-to-rt (mail-data queue &key (action "correspond"))
"Upload the MIME-encoded email stored in MAIL-DATA to the RT server, aiming to enqueue it in the QUEUE queue.
Returns the RT ticket ID of the new ticket if successful, and the complete body as 2nd value."
(multiple-value-bind (request-body content-type)
(make-rt-email-body mail-data queue action)
(let ((body
(drakma:http-request *rt-gateway-url*
:method :post
:content-type content-type
:content request-body)))
(let ((ticket-id (extract-rt-ticket-id body)))
(values ticket-id body)))))
(defmacro load-env-vars (&rest env-var-pairs)
for evp in env-var-pairs
collect (destructuring-bind
(name variable &key func (required t)) evp
(let ((var-sym (gensym))
(func (or func 'identity)))
`(let ((,var-sym (uiop:getenv ,name)))
(when ,var-sym
(setf ,variable (funcall (function ,func) ,var-sym)))
(unless (or ,variable (not ,required))
(error "The ~A environment variable must be set." ,name))))))))
(defun real-main ()
("SISS_MAX_SIZE_BYTES" *max-size-bytes*
:func parse-integer)
("SISS_MAX_SPAMD_SIZE_BYTES" *max-spamd-content-length*
:func parse-integer
:required nil)
("SISS_OUR_FQDN" *our-fqdn*)
("SISS_OUR_MAIL_DOMAIN" *our-mail-domain*
:required nil)
("SISS_RT_GATEWAY_URL" *rt-gateway-url*)
("SISS_SSL_CERT_PATH" *ssl-cert-path*
:required nil)
("SISS_SSL_KEY_PATH" *ssl-key-path*
:required nil)
("SISS_DEFAULT_RT_QUEUE" *default-rt-queue*)
("SISS_SPAM_RT_QUEUE" *spam-rt-queue*)
("SISS_RECIPIENT_QUEUE_MAPPINGS" *recipient-queue-mappings*
:func read-from-string
:required nil)
("SISS_SA_HOST" *sa-host*
:required nil)
("SISS_SA_PORT" *sa-port*
:func parse-integer
:required nil)
("SISS_SA_TIMEOUT" *sa-timeout*
:func parse-integer)
("SISS_LISTEN_HOST" *listen-host*
:required nil)
("SISS_LISTEN_PORT" *listen-port*
:func parse-integer
:required nil)
("SISS_ENABLE_PROXY_PROTOCOL" *enable-proxy-protocol*
:required nil))
(when (not *max-spamd-content-length*)
(setf *max-spamd-content-length* (* *max-size-bytes* 2)))
(when (not *our-mail-domain*)
(setf *our-mail-domain* *our-fqdn*))
(format t "*** Somewhat Immature SMTP Server, version 0.0.1~%")
(format t "*** (an eta insane idea:")
(format t "[+] I am host '~A', doing mail for '~A'.~%" *our-fqdn* *our-mail-domain*)
(format t "[+] Mails get posted to the RT gateway URL '~A'.~%" *rt-gateway-url*)
(if (and *ssl-cert-path* *ssl-key-path*)
(format t "[+] SSL is enabled. Using cert '~A' with key '~A'.~%" *ssl-cert-path* *ssl-key-path*)
(format t "[-] SSL is disabled.~%"))
(if (and *sa-host* *sa-port*)
(format t "[+] Mails will be checked using the SpamAssassin server hosted at ~A:~A (timeout ~A secs).~%" *sa-host* *sa-port* *sa-timeout*)
(format t "[-] SpamAssassin checking is disabled.~%"))
(format t "[+] The default RT queue is '~A' (spam goes to '~A').~%" *default-rt-queue* *spam-rt-queue*)
(when *recipient-queue-mappings*
(format t "[+] The following custom mappings are defined: ~A~%" *recipient-queue-mappings*))
(when *enable-proxy-protocol*
(format t "[+] The haproxy PROXY protocol is enabled. (WARNING: Do not allow hosts to connect directly to this server!)~%"))
(format t "[+] Listening for incoming SMTP connections on '~A', port ~A...~%" *listen-host* *listen-port*)
(smtp-listen *listen-host* *listen-port*))
(defun report-error-and-die (err)
(trivial-backtrace:print-backtrace err
:output *error-output*)
(sb-ext:exit :code 1 :abort t))
(defun main ()
"Hacky main() function for running this in 'the real world' (outside emacs)"
(setf *debugger-hook* (lambda (condition hook)
(declare (ignore hook))
(report-error-and-die condition)))