Home > FLOSS > Chrooted SFTP on Ubuntu with emailed transfer logs

Chrooted SFTP on Ubuntu with emailed transfer logs

SSH is great, and OpenSSH is really great. No other tool comes close to matching its versatility for all kinds of remote access tasks. It can control other machines, transfer files, display remote GUI apps, and even functions as a quick-and-dirty VPN. All this comes with strong encryption and authentication. So when my wife asked me to set up an Internet-facing file server that would enable her to share documents with her clients securely, it took me about a second to decide that SSH (or, more specifically, SFTP) was perfect for the job.

OpenSSH

The OpenSSH Project

Her needs were simple. Each client should have a private directory for sending and receiving files. My wife needs full access to all the directories. She’d also like to be notified about transfers via email. And since the files often contain sensitive financial data, they must be encrypted in transit. OpenSSH is capable of all of this and free clients are available for every OS. I figured I could have a working setup running in a couple of hours.

Ehh, not quite. Like many UNIX-flavored power tools, OpenSSH is extremely flexible. But harnessing that flexibility took more googling and scripting than I imagined. At one point, I even started to think that OpenSSH is less than really great. I considered throwing in the towel and moving to a nicely packaged FTP-over-SSL solution, only to be confronted with an annoying deluge of firewall gotchas and scarce client support for truly secure transfers. So I came back to SFTP determined to make it work and decided to document my setup in case it helps someone else. My starting point was an Ubunutu 12.04 LTS box with the openssh-server package installed and running. I found several resources that helped with various aspects of my configuration and came up with this consolidated recipe.

Set up directory and admin user

The first step is to create the root directory that will hold all of the SFTP users’ private directories. For this, I created a new logical volume and filesystem mounted as /sftp-root. This gives me a convenient unit of storage for backups and lets me limit the total amount of disk space consumed by all users. But if you don’t want the added complexity of a separate filesystem and you trust your users not to fill up your disk, you can just create a directory on the root filesystem like so:

sudo mkdir /sftp-root

Next, I create two new user groups:

sudo addgroup sftp
sudo addgroup sftp-only

The sftp group will own all of the files created by SFTP users as well as the administrative user who has access to all of the files. The sftp-only group identifies users who may access the server only for SFTP within their private directories. They aren’t allowed shell access.

Now, I create the administrative user, called sftpadmin. This user is a member of the sftp group and I set her home directory to /sftp-root.

sudo adduser --home /sftp-root --ingroup sftp sftpadmin

Since the /sftp-root directory is owned by root, Ubuntu gives a warning and doesn’t generate login profile files for this user, but that’s OK because she will probably only need to connect via SFTP. (I’m not explicitly ruling out shell access, though.)

Configuring SSH and PAM

Next, I set up SSH and PAM so that users of the sftp-only user group can access the server only via SFTP. Furthermore, the root of the filesystem visible to them is their home directory (called “chroot jail”). To accomplish all this, I edit /etc/sshd/sshd_config as root and change the “Subsystem sftp” line to read:

Subsystem sftp internal-sftp -l INFO

This tells sshd to use an in-process SFTP server with log level set to INFO. Next, add the following section to the end of the file:

# SFTP Jailed users
Match group sftp-only
        X11Forwarding no
        AllowTcpForwarding no
        ChrootDirectory /sftp-root/%u
        ForceCommand internal-sftp -l INFO

For users of the sftp-only group, this enforces the chroot restriction and prohibits any kind of shell access. Members of this group include the low-privileged SFTP users, but not the sftpadmin user. After making these changes, I save and close the file.

Now, I want the SFTP users to be able to modify or delete any files that sftpadmin creates in their private directories. Similarly, sftpadmin should be able to modify or delete files in all the private directories. To implement this, all files created by SFTP users (including sftpadmin) must be “owned” and writable by a common user group. I use the sftp group for this, which has as its members all of the low-privilege accounts plus sftpadmin. To make their files group-writable by default requires a umask. Normally, this can be set in a login script, but since the jailed users don’t have shell access, I can’t take that approach. Instead, I use a feature of PAM that allows me to specify a default umask for users connecting through SSH. I edit /etc/pam.d/sshd as root and add the following line near the end:

# UMask for chrooted SFTP users
session optional pam_umask.so umask=002

Now. commit the SSH and PAM changes by restarting the service:

sudo service ssh restart

Done with SSH! On to logging.

Configure basic logging

I’ll set up basic SFTP logging first. Logging from the in-process SFTP server is tricky because it requires access to a shared logging socket located outside of the user’s chroot jail. First, I create a directory on the /sftp-root filesystem that will contain the special socket “file” and tell the system logger (rsyslog) to create a socket there. This command creates the directory:

sudo mkdir /sftp-root/dev

Next, create or edit /etc/rsyslog.d/sshd.conf as root and add these lines:

# Create an additional socket for some of the sshd chrooted users.
$AddUnixListenSocket /sftp-root/dev/log

# Log internal-sftp in a separate file
:programname, isequal, "internal-sftp" -/var/log/sftp.log
& @127.0.0.1:39276
:programname, isequal, "internal-sftp" ~

This sets up the additional input socket (creatively called “log”) to which the SFTP server processes write. Log output is sent to a new file called sftp.log and echoed to a local UDP port (127.0.0.1:39276). The UDP port will help generate the transfer logs to be emailed, but you can omit the line if you’re not interested in that.

Not done with rsyslog quite yet. Since the jailed users don’t have access to the file system outside of their home directories, I need to make the /sftp-root/dev/log socket visible within each jail. This requires creating a hard link in each jail that points to the socket file. As if that weren’t complicated enough, the hard links need to be regenerated each time the rsyslog service starts because it creates a new socket, invalidating the old links. To automate this, I wrote a small Python script that iterates through all the private jail directories and refreshes the hard links. The script is called /usr/local/bin/refresh-chroot-log-links and it looks like this:

#!/usr/bin/env python

# Refreshes hardlinks from SFTP users' chroot jail directories to the
# shared rsyslog socket directory. Takes no params. Must be run as root.

import os
import glob

#----- Constants for directories, group names, and other file system stuff.

SFTP_ROOT_DIR = '/sftp-root'
DEV_DIR = 'dev'
LOG_NODE = 'log'

#----- Start of script

if (os.geteuid() != 0):
        print 'Must run as root.'
        exit(1)

# Find the private dev dirs for each chrooted user
shared_log_node = SFTP_ROOT_DIR + '/' + DEV_DIR + '/' + LOG_NODE
dev_dirs = glob.glob(SFTP_ROOT_DIR + '/*/' + DEV_DIR)
for jail_dev_dir in dev_dirs:
        jail_log_node = jail_dev_dir + '/' + LOG_NODE
        # Remove old log link and recreate.
        if os.path.exists(jail_log_node):
                os.remove(jail_log_node)
        os.link(shared_log_node, jail_log_node)

After creating the file, make it executable with:

sudo chmod +x /usr/local/bin/refresh-chroot-log-links

If you run the script now (as root), it won’t do anything because no SFTP user directories have been created yet. We’ll rectify that soon. But for now, I set the script to run each time the rsyslog daemon starts by adding these lines to the end of /etc/init/rsyslog.conf:

post-start script
  refresh-chroot-log-links
end script

Save the file as root and close. Almost done. I just need to set up log rotation for the new sftp.log file so it doesn’t grow without limits. I create a new file as root called /etc/logrotate.d/sftp with this text. Customize to your liking:

/var/log/sftp.log {
        monthly
        missingok
        rotate 12
        compress
        delaycompress
        postrotate
                invoke-rc.d rsyslog reload > /dev/null
        endscript
}

Finally, it’s time to apply all of the logging changes. Restart the rsyslog daemon with:

sudo service rsyslog restart

The next step is to create the SFTP users.

Create users

As you may have guessed by now, the SFTP users require some special configuration. I created another Python script that wraps the basic Ubuntu adduser functionality and automates the process of adding a new SFTP user. As root, create /usr/local/bin/addsftpuser and paste in these contents:

#!/usr/bin/env python

# Create a chroot jailed SFTP user.
# Usage (as root): addsftpuser <username>

import os
import sys
from subprocess import check_call

#----- Constants for directories, group names, and other file system stuff

SFTP_ROOT_DIR='/sftp-root'
SFTP_JAILED_GROUP='sftp-only'
SFTP_OWNER_GROUP='sftp'
DATA_DIR='data'
DEV_DIR='dev'

#----- Start of script

if len(sys.argv) != 2:
        print 'Usage:', sys.argv[0], ''
        exit(1)
if (os.geteuid() != 0):
        print 'Must run as root.'
        exit(1)

username = sys.argv[1]
jail_root = SFTP_ROOT_DIR + '/' + username
home_dir = jail_root + '/' + DATA_DIR
jail_dev_dir = jail_root + '/' + DEV_DIR

# Create jailed and home directories so skeleton files won't be copied into it.
os.makedirs(home_dir)
# Create dev dir for logging
os.mkdir(jail_dev_dir)
# Create user.
check_call(['adduser', '--home', home_dir, '--shell', '/bin/false', '--ingroup', SFTP_OWNER_GROUP, username])
# Add user to jailed group.
check_call(['adduser', username, SFTP_JAILED_GROUP])
# Home directory should be owned by user and sftp group.
check_call(['chown', '-R', username + ':' + SFTP_OWNER_GROUP, home_dir])
# Child files should inherit parent's group even if written to by a different user (like Amy).
check_call(['chmod', '-R', 'u+rwx,g+rwxs', home_dir])
# Reset home dir relative to jail root.
check_call(['usermod', '--home', DATA_DIR, username])
# Refresh hard links for logging under chroot jails
check_call(['refresh-chroot-log-links'])

The script takes one parameter (the username) and creates the user and directories and adds the user to the proper groups. Notice that the last line calls the refresh-chroot-log-links script that I created earlier to set up the hard link to the logging socket. After saving this, make it executable by running:

sudo chmod +x /usr/local/bin/addsftpuser

You then execute it like this:

sudo addsftpuser <username>

Where <username> is the name of the user you want to create. Running the command generates the following directory structure under /sftp-root:

/sftp-root
   +--<username> (user's root directory)
      +--data (directory containing the shared files)
      +--dev
         +--log (hard link to logging socket)

You may see a warning about skeleton files not being created. You can safely ignore it because the user isn’t permitted shell access anyway. The /sftp-root/<username> directory is owned and writable only by root, which is a prerequisite for OpenSSH’s ChrootDirectory setting. That’s why the user’s home directory is set to the /sftp-root/<username>/data subdirectory, which she does own. All files created in the data subdirectory inherit the sftp group and are group-writable, which gives both the SFTP user and sftpadmin the ability to read and write files created by the other.

At this point, you are ready to test out the configuration! Create an SFTP user and use your favorite SFTP client to connect from a different machine. You should also try logging in as the sftpadmin user and verify that you can see and modify the contents of all of the SFTP users’ directories.

Emailed transfer logs

The last feature I’ll write about is transfer logs. My wife would like to be notified through email about the activity that happens during each client session including uploads, downloads, and file deletions. Sadly, OpenSSH doesn’t provide an easy-to-read transfer log, much less send it out through email, so I devised my own solution. I wrote a program that runs as a service and watches the SFTP log messages. It keeps track of open SFTP sessions, which can be identified by a process ID (PID). Then at the end of each session, it summarizes the uploads, downloads, and deletions and sends out an email.

In order for this to work, you need an SMTP server to relay the emails. You can configure a mail transfer agent (MTA) such as Postfix on the same box to handle this or use a remote SMTP server. The Ubuntu wiki contains some documentation on getting started with Postfix. Once you have the relay server worked out, create a new file as root called /usr/local/bin/sftp-xfers-mailer with the following contents:

#!/usr/bin/env python

# Listens to log traffic from OpenSSH's "internal-sftp" subsystem on a local UDP port
# and generates one transfer log per session, which it then sends as an email.
# This program is designed to run as a service.

import sys
import signal
import re
import string
import smtplib
import time
import SocketServer
from email.mime.text import MIMEText

#----- Local environment settings. Customize as needed.

LISTEN_PORT=39276
FROM = 'SFTP Daemon <sftp-daemon@example.com>'
TO = 'All <all@example.com>'
SMTP_SERVER = 'localhost'

#----- Start of script

class Session:
        """A simple session structure."""

        def __init__(self):
                # Upload messages
                self.uploads = []
                # Download messages
                self.downloads = []
                # File delete messages
                self.deletes = []

class SftpMailerRequestHandler(SocketServer.DatagramRequestHandler):
        """Implements the UDP listener."""

        # Dictionary of open self.sessions indexed by process ID (pid)
        sessions = {}

        def handle(self):
                """Consumes line input from datagrams until interrupted."""

                try:
                        while True:
                                next_line = self.rfile.readline()
                                if not next_line:
                                        break
                                self.process_line(next_line)
                except (KeyboardInterrupt, SystemExit):
                        sys.exit(0)

        def process_line(self, line):
                """Validate that a given line of log data has the expected format and parse the message.

                We parse the line to pull out common headers if we can, then pass the message on for processing.
                """

                # The following regex matches a log line of the form:
                #  internal-sftp[]:
                # There are three capture groups that capture the headers, pid, and message, respectively.
                valid_line = re.search('(.*)\s+internal-sftp\[(\d+)\]:\s+(.*)', line)
                if not valid_line is None:
                        headers = valid_line.group(1)
                        pid = valid_line.group(2)
                        message = valid_line.group(3)
                        self.process_message(valid_line.group(1), valid_line.group(2), valid_line.group(3))

        def process_message(self, headers, pid, message):
                """Given a valid log message, identify its type and dispatch to an appropriate handler
                for further processing.

                The headers are not currently used and can contain anything. The pid parameter identifies
                the session and is used as a key to group related log messages. The message is the bit
                we're trying to parse.
                """

                # The following regex matches a log line of the form:
                # close  bytes read <read_bytes> written <write_bytes>
                # There are three capture groups for the filename, bytes read, and bytes written, respectively.
                file_xfer_msg = re.search('close (.*) bytes read (\d+) written (\d+)', message)
                if not file_xfer_msg is None:
                        self.process_file_xfer_msg(pid, file_xfer_msg.group(1), file_xfer_msg.group(2), file_xfer_msg.group(3))
                # The following regex matches a log line of the form:
                # remove name
                # There is one capture group for the filename.
                file_remove_msg = re.search('remove name (.*)', message)
                if not file_remove_msg is None:
                        self.process_file_remove_msg(pid, file_remove_msg.group(1))
                # The following regex matches a log line of the form:
                # session closed [...] user  from []
                # There are two capture groups for the username and host, respectively.
                session_close_msg = re.search('session closed.*user (.*) from \[(.*)\]', message)
                if not session_close_msg is None:
                        self.process_session_close_msg(pid, session_close_msg.group(1), session_close_msg.group(2))

        def process_file_remove_msg(self, pid, filename):
                """Records a file delete action in the session object.

                The PID identifies the session and the filename is the file that the user deleted from the server.
                """

                session = self.create_or_get_session(pid)
                session.deletes += [filename]

        def process_session_close_msg(self, pid, username, host):
                """At the end of a session, generate report text from the actions recorded in the session data
                structure and send it in an email.

                The PID identifies the session and the username is the SFTP user. Host is the remote host IP
                address. Once the session closed message has been processed, the session object associated
                with the PID is removed.
                """

                # Get session (if any) from the local session dictionary, indexed by pid.
                if pid in self.sessions:
                        session = self.sessions[pid]
                        uploads_summary = ''
                        if session.uploads:
                                uploads_summary = 'UPLOADS\n  ' + string.join(session.uploads, '\n  ') + '\n'
                        downloads_summary = ''
                        if session.downloads:
                                downloads_summary = 'DOWNLOADS\n  ' + string.join(session.downloads, '\n  ') + '\n'
                        deletes_summary = ''
                        if session.deletes:
                                deletes_summary = 'REMOVED\n  ' + string.join(session.deletes, '\n  ') + '\n'

                        if session.uploads or session.downloads or session.deletes:
                                self.send_xfer_log(pid, username, host, uploads_summary, downloads_summary, deletes_summary)
                        del self.sessions[pid]

        def send_xfer_log(self, pid, username, host, uploads_summary, downloads_summary, deletes_summary):
                """Email report for a single session's activity.

                The PID identifies the session. Username and host are the remote SFTP user's details.
                The summary parameters provide text summaries of each kind of action the user took
                during the session.
                """

                # Generate the message body and envelope.
                body = 'SFTP activity of ' + username + ' connecting from ' + host + ':\n\n'
                body += uploads_summary + downloads_summary + deletes_summary
                body += '\nSession ' + pid + ' finished on ' + time.strftime('%a, %d %b %Y %I:%M %p')
                msg = MIMEText(body)
                msg['Subject'] = 'SFTP session ' + pid + ' with ' + username
                msg['From'] = FROM
                msg['To'] = TO
                try:
                        # Send it!
                        smtp = smtplib.SMTP(SMTP_SERVER)
                        smtp.sendmail(FROM, TO, msg.as_string())
                        smtp.quit()
                except:
                        sys.stderr.write('Cannot send email: %s\n' % sys.exc_info()[1])

        def process_file_xfer_msg(self, pid, filename, read_bytes, write_bytes):
                """Record a file upload or download action in the session object.

                The PID identifies the session. The filename is the name of the file (big surprise).
                Only one of bytes read or written should be greater than 0, the other should be 0.
                The non-zero value indicates whether the file was uploaded or downloaded.
                """

                # Create or update session from the local session dictionary, indexed by pid.
                session = self.create_or_get_session(pid)
                if int(read_bytes) > 0:
                        session.downloads += [filename + ' (' + read_bytes + ' bytes)']
                elif int(write_bytes) > 0:
                        session.uploads += [filename + ' (' + write_bytes + ' bytes)']

        def create_or_get_session(self, pid):
                """"Return a session object for the given PID or create a new one."""

                session = None
                if pid in self.sessions:
                        session = self.sessions[pid]
                else:
                        session = Session()
                        self.sessions[pid] = session
                return session

if __name__ == "__main__":
    host, port = "localhost", LISTEN_PORT
    server = SocketServer.UDPServer((host, port), SftpMailerRequestHandler)
    server.serve_forever()

At the top, replace the environment-specific email settings with ones appropriate for your environment. The listener port is arbitrary, but must match the one in /etc/rsyslog.d/sshd.conf that you specified earlier. The email settings speak for themselves. However, if your SMTP server requires authentication or encryption, you will need to tweak the code in the send_xfer_log method. When you’re done customizing the program for your enviuronment, make it executable with:

sudo chmod +x /usr/local/bin/sftp-xfers-mailer

You should test it now to make sure it works. Just run it from the command line:

sftp-xfers-mailer

Try logging in as one of the SFTP users, upload a file, and then log out. If your SMTP settings are correct, you should get an email. Once that’s verified, press CTRL-C to exit. The last step is to register the sftp-xfers-mailer program as a service and have it start and stop in tandem with rsyslog. To accomplish this, create a new file as root called /etc/init/sftp-xfers-mailer.conf with this text:

# sftp-xfers-mailer
#
# Listens for SFTP log activity and sends emails for each session.

description     "SFTP xfers mailer"

start on started rsyslog
stop on stopping rsyslog

respawn
respawn limit 10 5
umask 022

exec /usr/local/bin/sftp-xfers-mailer

Finally, start the service with:

sudo service sftp-xfers-mailer start

You should see a reponse indicating that the service is running.

Conclusion

This was a lot of work, but I’m happy with the result. Each user gets a private directory for sharing files and my wife (the sftpadmin user) has access to them all. Whenever each user connects and performs some activity, it is logged and a summary sent through email. Adding new SFTP users is easy with the addsftpuser script.

I learned a lot in this process, but there’s always room for improvement. Share your thoughts and suggestions in the comments!

Advertisements
Categories: FLOSS
  1. No comments yet.
  1. No trackbacks yet.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: