The SSL Transport Agent Ruby gem is a foundation for building servers that communicate over the web using Secure Sockets.
- It can listen on any number of ports simultaneously.
- It starts a separate receiver process to handle each connection.
- The server may run as root, but the processes will lose their root privileges soon after creation. This is a security feature.
- The receiver processes can switch on full encryption (STARTTLS in a mail server, for example).
- A log file is built in.
- MySQL database is built in. A simple to use MySQL API is built in.
- Runs until terminated by a
KILL -INT <pid>
or^C
. - A set of DNS queries is built in.
- A SMTP server tester (to see if a given MX has a live mail server running) is built in.
- A method to validate AUTH PLAIN (Linux CRYPT) hashes.
The ssltransportagentgemtest.rb application implements a simple receiver for an email server. This test program not only verifies whether or not the gem is functioning correctly, but also serves to demonstrate how to build an application that sits on top of ssltransportagent gem.
Too often the problem with using an otherwise useful gem is the lack of documentation. Even very important classes like SSLSocket sometimes have too little documentation to be able to implement their functionality. In fact, most of the posts on the Internet on how to use SSL Sockets are wrong! If you really want to know how it's done correctly, study the lib/ssltransportagent.rb file in this gem.
Having a working demo application helps to solve this documentation problem, which is why it was included here.
This server has been tested by sending it over 23,000 spam emails. No faults were found. It's licensed under the MIT license, so technically, you're on your own. But practically, drop me an email at mjwelchphd@gmail.com if you need help with this. I want it to be useful, stable, and reliable.
A few problems and errors have surfaced since starting pseudo-production testing, which explains why there have been 4 updates in such a short period of time. I discovered that I needed more code to handle both IPv4 and IPv6 and IP-to-port binding (so that the server can be run only on localhost, or only on the outside network, for example).
This gem requires the following:
require 'openssl'
require 'logger'
require 'mysql2'
require 'net/telnet'
require 'resolv'
require 'etc'
require 'base64'
require 'unix_crypt'
All of these packages are found in the Ruby Standard Library (stdib 2.2.2 at the time of this writing), except unix-crypt. They are required in the gem itself, so you don't have to require them. Get unix-crypt with gem, if you don't already have it:
$ sudo gem install unix-crypt
- Add parameter processing to implement the following:
- Add a switch to start as a daemon
- Add a switch to shutdown the daemon
- Add a switch to restart the deamon
- Build an /etc/init.d command file
- Improve the documentation by adding a detailed description of the configuration parameters
Use OpenSSL to create a self-signed certificate for testing as follows:
$ openssl req -x509 -newkey rsa:2048 -keyout example.key -out example.crt -days 9000 -nodes
$ chmod 400 example.key
$ chmod 444 example.crt
To install the gem, simply use the gem application:
$ sudo gem install ssltransportagentgemtest
Alternately, you can clone the project on GitHub at:
https://github.com/mjwelchphd/ssltransportagent
and build it yourself.
The basic server looks like this:
#! /usr/bin/ruby
module ServerConfig
# ServerName, PrivateKey, and Certificate are required
ServerName = "mail.example.com"
PrivateKey = "example.key" # filename or nil
Certificate = "example.crt" # filename or nil
# if Host is specified, a MySQL connection will be opened
Host = {
:host => nil, # "localhost" (usually), or nil if MySQL not used
:username => nil,
:password => nil,
:database => nil
}
# ListeningPort is a list of ip+port numbers
# an IPV4 ip+port might be "93.184.216.34:2000", or "127.0.0.1:2000", or "0.0.0.0:2000"
# an IPV6 ip+port might be "2606:2800:220:1:248:1893:25c8:1946/2000", "::1/2000", or "0:0:0:0:0:0:0:0/2000"
# an IPV4 port number might be just a port like ["2000"] -- this is equivalent to []"0.0.0.0:2000"]
ListeningPort = ['2000']
# UserName, GroupName, and WorkingDirectory must be present if ssltransportagent run as root
# otherwise, they may be nil
UserName = "username"
GroupName = "groupname"
WorkingDirectory = "mywd/"
# if not specified, the log will go to the working directory
# see http://ruby-doc.org/stdlib-2.2.3/libdoc/logger/rdoc/Logger.html for more info
LogPathAndFile = "ssltransportagentgemtest.log"
LogFileLife = "daily"
end
module ReceiverConfig
ReceiverTimeout = 30 # seconds
RemoteSMTPPort = 25
end
require 'ssltransportagent'
class TAReceiver
def receive(local_port, local_hostname, remote_port, remote_hostname, remote_ip)
(initialization)
send_text("220 mail.example.com ESMTP")
done = false
begin
text = recv_text
done = text.start_with?("QUIT")
(process received data)
send_text("250 some response")
end until done
(process received data further)
end
end
The test application included in the gem is bin/ssltransferagentgemtest.rb. It has a comple email receiver to demonstrate how to build your application.
A kill -INT <pid>
or <ctrl-C>
will terminate the server. Very likely this would be written like this:
$ ssltransportagentgemtest.rb
^C
ssltransportagentgemtest terminated by admin ^C
$
or
sudo kill -INT `cat /run/ssltransportagent/ssltransportagent.pid`
A kill -HUP <pid>
will activate a TAServer::restart method, if you have one defined in your code. For example, if you put this in your code:
class TAServer
def restart
puts "I just got a HUP request."
end
end
then at another terminal:
$ ps ax | grep ssltra
823 pts/0 Sl+ 0:00 ssltransportagentgemtest.rb
829 pts/1 S+ 0:00 grep --color=auto ssltra
$ kill -hup 823
or
kill -HUP `cat /run/ssltransportagent/ssltransportagent.pid`
it will result in:
ssltransportagent received a HUP request
I just got a HUP request.
at the terminal where ssltransportagentgemtest.rb
is running, with no other action. The first message comes from ssltransportagent
itself, and the second comes from the def restart
.
send_text(text,echo)
The send_text method sends text
to the client while adding a <cr><lf>
at the end of each line. The echo
parameter can be true (default) or false, and determines whether or not the text will be copied into the log.
The text
parameter may be a single String, or an Array of Strings.
text = recv_text(echo)
The recv_text method receives one line of text from the client, strips off the <cr><lf>
, and returns the text. It does not make any other changes to the text, such as stripping off leading and trailing spaces. The echo
parameter can be true (default) or false, and determines whether or not the text will be copied into the log.
If a timeout occurs, or the client abruptly closes the connection, recv_text makes an entry into the log of " -> <eod>"
, then returns nil. In the case of a Errno::ECONNRESET, recv_text makes an entry into the log so stating.
escaped_string = query_esc(string)
Special characters in the String string
are replaced, i.e., hex 0D character will be replaced with \r
, et.al. This method prevents users from passing parameters that execute as code.
query_act(qry)
The action query qry
is executed. No return value is expected. An error will raise QueryError.
query_all(qry)
The result query qry
is executed and the results are returned. For example, here is how the query returns data:
result = query_all("select id,created_at from domains where kind=1")
=> [ {
:id=>6,
:created_at=>2013-11-18 03:38:36 +0000
},
{
:id=>7,
:created_at=>2013-12-27 18:34:21 +0000
}
]
result = query_one(qry)
The result query qry
is executed and one row is returned. For example, here is how the query returns data:
result = query_one("select id,created_at from domains where kind=1")
=> {
:id=>6,
:created_at=>2013-11-18 03:38:36 +0000
}
This method is designed for a query that is intended to only select one row.
result = query_value(qry)
This query is designed to return a single value from the database. For example, here is how the query returns data:
result = query_value("select created_at from domains where id=12", :created_at)
=> 2014-05-29 21:22:21 +0000
result = "example.com".dig_a
This method looks up an A record in the domain's DNS. It returns the IPv4 address or nil, if the record is not found. For example,
ip = "example.com".dig_a
=> "93.184.216.34"
result = "example.com".dig_aaaa
This method looks up an AAAA record in the domain's DNS. It returns the IPv6 address or nil, if the record is not found. For example,
ip = "example.com".dig_aaaa
=> "2606:2800:220:1:248:1893:25c8:1946"
result = "github.com".dig_mx
This method looks up an MX record in the domain's DNS. It returns the list of MX records or nil, if there are none. For example,
ip = "github.com".dig_mx
=> ["ALT1.ASPMX.L.GOOGLE.COM", "ALT2.ASPMX.L.GOOGLE.COM", "ALT3.ASPMX.L.GOOGLE.COM", "ALT4.ASPMX.L.GOOGLE.COM", "ASPMX.L.GOOGLE.COM"]
result = "key._domainkey.czarmail.com".dig_dk
This method looks up a domain key public key in the domain's DNS. It returns the key or nil, if there is none. For example,
ip = "key._domainkey.czarmail.com".dig_dk
=> "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC647BjD66umGm6Mip8b2WWx/WCWGU5BM34yCWn1aUfwbVL/Ng+hyTwaOU/bI58nIV1DjpJKxc+hVwe5Bq2zYtlu5/H3K8lr5c/1P/L4ttH+B67PLzzmTZRShNxcTlp5Ge3VZ8GoG2dhfniIikGVGjSL0OSnGvKktbIxOWc+DaaGQIDAQAB"
Notice that the request has to be formed as the selector from the DKIM signature being validated ("key" in this case) + "_domainkey" + domain. See the example.
result = "github.com".dig_mx
This method looks up an MX record in the domain's DNS. It returns the list of MX records or nil, if there are none. For example,
ip = "github.com".dig_mx
=> ["ALT1.ASPMX.L.GOOGLE.COM", "ALT2.ASPMX.L.GOOGLE.COM", "ALT3.ASPMX.L.GOOGLE.COM", "ALT4.ASPMX.L.GOOGLE.COM", "ASPMX.L.GOOGLE.COM"]
result = "23.253.107.107".dig_ptr
This method looks up a PTR record (sometimes called a reverse DNS address) in the domain's DNS. It returns the address or nil, if there is none. For example,
result = "23.253.107.107".dig_ptr
=> "mail.czarmail.com"
Take into account that many websites don't have a reverse address DNS record. This is something commonly associated with SMTP servers, and is used to find the domain name of the client which is connecting with the intent to send email. Since it's common for large systems to route outgoing mail through a MSA (Mail Submissin Agent), there is no guarantee that the sender's domain will be the same as the MSA's domain.
barracuda = 'b.barracudacentral.org'.blacklisted?(ip)
spamhaus = 'zen.spamhaus.org'.blacklisted?(ip)
This method looks the given IP up on the blacklist and returns true
or false
. Each blacklist will tell on it's web site what the URL is for querying that blacklist'
utf8_string = "string with possible multi-wide (Unicode) characters".utf8
The given string is properly encoded into UTF-8 (Unicode) and if there are faulty character sequences in the string, replaces them with a '?'.
Be careful using this with email: Email has to be received and transported with NO changes, except for the addition of extra headers at the beginning (before any DKIM headers). Some mail servers will remove leading and trailing spaces, convert tabs to spaces, etc. If there is a DKIM header in the email, after those changes, the DKIM will not verify. According to the RFC, an email must not contain non-ascii characters, unless they are properly encoded, but some emailers ignore the rule.
This can be useful in translating the headers after they are parsed (leaving the original headers intact) in order to avoid unexpected results in Ruby.
ok = domain.mta_live?(port)
This method opens a socket to the IP/port to see if there is an SMTP server there. If a server responds, it returns a 250 or 421, depending on whether or not there was a mail server there. It times out in 5 seconds to prevent hanging the process. For example:
ok = "mail.czarmail.com".mta_live?(587)
=> "250 mail.czarmail.com ESMTP Czar Mail Exim 4.84 Tue, 29 Sep 2015 05:45:16 +0000"
ok = "example.com".mta_live?(25)
=> "421 Service not available (execution expired)"
or
=> "421 Service not available (getaddrinfo: Name or service not known)"
This method validates a password using the base64 plaintext in an AUTH command. A typical AUTH command might look like this:
AUTH PLAIN AGNvY29AY3phcm1haWwuY29tAG15LXBhc3N3b3Jk
The value part of the command is a base 64 encoded message. For example:
decoded = Base64::decode64("AGNvY29AY3phcm1haWwuY29tAG15LXBhc3N3b3Jk")
=> "\x00coco@czarmail.com\x00my-password"
or
=> decoded.split("\x00")[1..-1] => ["coco@example.com", "my-password"]
The validate_plain method decodes the AUTH PLAIN value, gets the username and password, and yields the password to the block. The block looks up the password hash for someplace (someplace your application stores it), then returns that. The validate_plain method validates the password from the AUTH PLAIN value against the user's password hash to see if it is valid or not. For example:
"AGNvY29AY3phcm1haWwuY29tAG15LXBhc3N3b3Jk".validate_plain { |username| "{CRYPT}IwYH/ZXeR8vUM" }
=> "coco@example.com", true
"AGNvY29AY3phcm1haWwuY29tAHh4LXBhc3N3b3Jk".validate_plain { |username| "{CRYPT}IwYH/ZXeR8vUM" }
=> "", false
In this example, of course, we ignore |username| and don't look up the hash: we just return the hash we're using for testing. The second one is an example of a wrong password, so it fails. A malformed uathorization string may be undecipherable, and so will return "", false, as will a nil value.
"This is not a legitamate base 64 encoded string".validate_plain { |username| "" }
=> "", false
The smtptransportagent gem is an email transporter which sits on top of this gem.