Jail the Puma

Objectives

My goal is to run multiple silly ruby web applications one one machine, with the following caveats:

  • I’ll use fancy libs, not available on my host packaging system (there can be only one of those)
  • Theses libs, my code using it, and Ruby itself are probably riddled with vulnerabilities
  • I want to maintain & update as little OS & environment as possible

Docker, which means “Cancer” in ancient greek, is a no-go because

  • It’s cancer

I’ve decided to go with a simple LXC for the “special environement” with the shiny new packages I can play with. In this LXC, I’ll run each of my snowflakes web app in their own sandbox, using Firejail.

All the following is running a LXC, running debian Sid. The LXC host is debian Stable running the Debian grsec kernel.

Step 1: the Ruby code

I usually write my silly webapps with Ruby + slim + sinatra. They are all nicely packaged in Debian Sid.

They look like this

$ tree /var/www/kurzy
.
├── config.ru
├── db
│   └── kurzy.sqlite
├── kurzy.rb
├── public
│   ├── jquery-3.2.1.min.js
│   └── kurzy.js
└── views
    └── main.slim

Running ruby kurzy.rb will spawn a Rack webserver listening on port 4567. The problem with that is it’s single threaded and heavy and weird.

Step 2: unleash the puma

Even though it has a .io domain name, I’ve decided to use puma for the webserving part. It advertises some multithreading capabilities, and is also nicely packaged in Debian Sid.

This is where it gets a bit hairy, or furry (lol). I’m going to deploy more than one silly webapp, and need some kind of centralized management of them so I can start/stop part or all of the apps.

Puma is easier dealt with when you use config files. I’ll store them in /etc/puma.conf.d

$ cat /etc/puma.conf.d/kurzy.conf
directory '/var/www/kurzy'
environment 'production'
daemonize
#pidfile '/var/www/kurzy/puma/puma.pid'
#state_path '/var/www/kurzy/puma/puma.state'
stdout_redirect '/var/www/kurzy/puma/stdout.log', '/var/www/kurzy/puma/stderr.log', 'true'
bind 'tcp://10.0.3.40:9290'
tag 'Kurzy'
quiet

the .pid & state files are not going to be really used there, they probably can be ignored. Don’t forget to create the puma folder in your app dir.

Your puma will listen on 9290, we’ll need this to drill holes in the host firewall and link it to nginx.

Step 3: Firejail, aka ‘bruteforcing config files & bash scripts until it works’

From here, this is mainly just madness from my part, as I don’t really know what I’m doing. You’ve been warned.

Each Puma will start in its own sandbox. Despite the fact that the website is hosted on Wordpress (srsly), Firejail is nicely packaged again, and definitely works out of the box, with caveats.

Grsecurity: Firejail does weird mount dances in chrooted environment, make sure you allow this.

The defaults for a Firejail sandbox are nice, but I wanted to disallow access to stuff below /var/www/ from /var/www/sillywebapp

This is a profile example:

read-write /var/www/kurzy
noblacklist /var/www/kurzy
blacklist /var/www/*
include /etc/firejail/default.profile

Store this file in your webapp dir to make it easier later.

I’m running these from a puma system user, and will start them from a init script. As seen in the Firejail profile, I allow /var/www/kurzy to be writeable. For this, /var/www/kurzy need to be owned by the puma user.

chown -R puma /var/www/kurzy

To test that everything looks nice:

su -s /bin/sh -c "firejail --profile=/var/www/kurzy/firejail.profile -- /usr/bin/puma -C /etc/puma.conf.d/kurzy.conf" puma

The server should start as user ‘puma’ and listen en port 9292. If it doesn’t, good luck. Try to append –debug everywhere and strace all the things. It’s a huge mess.

Step 4: Automate the madness

Everything is nice and tight. Repeat for all your webapps. Update the firejail profile to your needs.

Here is a simple example for a shitty /etc/init.d/puma to automate all of the start & stopping:

```bash
#! /bin/sh
### BEGIN INIT INFO
# Provides:          puma
# Required-Start:    $remote_fs $syslog
# Required-Stop:     $remote_fs $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: Puma web servers
# Description:       Start all the pumas
### END INIT INFO

# "set -e" probably breaks everything

# Classic stuff
PATH=/usr/local/bin:/usr/local/sbin/:/sbin:/usr/sbin:/bin:/usr/bin
DESC='Pumas motherboard script'
NAME=puma
SCRIPTNAME=/etc/init.d/$NAME
PID_DIR=/var/run/puma
# Specifiv coptions
FIREJAIL_BIN='/usr/bin/firejail'
FIREJAIL_OPTS='--quiet'
PUMA_BIN='/usr/bin/puma'
PUMA_OPTS='--quiet'
PUMA_USER='puma'
PUMA_CONFIG_DIR='/etc/puma.conf.d'

. /lib/init/vars.sh

. /lib/lsb/init-functions

if [ ! -d "${PUMA_CONFIG_DIR}" ]; then
    log_failure_msg "missing or empty config file $RSYNC_CONFIG_FILE"
    exit 0
fi

if [ ! -d "${PID_DIR}" ]; then
    log_warning_msg "creating ${PID_DIR}"
    mkdir "${PID_DIR}"
fi

read_puma_conf_val() {
    local readonly conf_file=$1
    local readonly key=$2
    echo $(grep "${key}" "${conf_file}" | cut -d ' ' -f 2 | sed "s/\"//g" | sed s"/'//g")
}

is_running_puma() {
    local readonly app_name=$1

    local firejail_cmd="${FIREJAIL_BIN} --quiet --list"
    su -s /bin/sh -c "${firejail_cmd}" "${PUMA_USER}" | grep -q "name=app_${app_name}"
}

do_start_one_puma() {
    local readonly app_config=$1
    local readonly app_filename=$(basename "${app_config}")
    local readonly app_name="${app_filename%.*}"
    local readonly app_workdir=$(read_puma_conf_val "${app_config}" "directory")

    if is_running_puma "${app_name}" ; then
        log_success_msg "${app_name} already running"
        return 0
    fi

    if [ ! "$(stat -c %U ${app_workdir})" = "${PUMA_USER}" ]; then
        log_failure_msg "$app_workdir should be owned by ${PUMA_USER}"
        return 1
    fi
    log_daemon_msg "Starting ${app_name}"

    local puma_cmd="${PUMA_BIN} ${PUMA_OPTS} -C ${app_config} >/dev/null &"

    if [ ! -f "${app_workdir}/firejail.profile" ]; then
        generate_firejail_profile "${app_workdir}/firejail.profile" "${app_workdir}"
    fi
    jail_opts="${FIREJAIL_OPTS} --profile=${app_workdir}/firejail.profile"

    local firejail_cmd="${FIREJAIL_BIN} ${jail_opts} --name=app_${app_name} -- ${puma_cmd}"
    su -s /bin/sh -c "${firejail_cmd}" "${PUMA_USER}"
    log_end_msg $?
}

generate_firejail_profile() {
    local readonly profile_file=$1
    local readonly app_workdir=$2
    local app_workdir_parent=$(dirname "${app_workdir}")

    cat << EOF > "${profile_file}"
# This has been autogenerated by ${SCRIPTNAME}
# Feel free to modify to your needs
read-write ${app_workdir}
noblacklist ${app_workdir}
whitelist ${app_workdir}
blacklist ${app_workdir_parent}/*
include /etc/firejail/default.profile
EOF
}

do_stop_one_puma() {
    local readonly app_config=$1
    local readonly app_filename=$(basename "${app_config}")
    local readonly app_name="${app_filename%.*}"

    if ! is_running_puma "${app_name}" ; then
        log_success_msg "${app_name} not running"
        return 0
    fi
    log_daemon_msg "Stopping ${app_name}"

    local firejail_cmd="${FIREJAIL_BIN} ${FIREJAIL_OPTS} --shutdown=app_${app_name} > /dev/null"
    su -s /bin/sh -c "${firejail_cmd}" "${PUMA_USER}"
    log_end_msg $?
}

do_start() {
    for puma_config in "${PUMA_CONFIG_DIR}"/*.conf; do
        test -f "${puma_config}" || continue
        do_start_one_puma "${puma_config}"
    done
}

do_stop() {
    for puma_config in "${PUMA_CONFIG_DIR}"/*.conf; do
        test -f "${puma_config}" || continue
        do_stop_one_puma "${puma_config}"
    done
}

do_status() {
    local firejail_cmd="${FIREJAIL_BIN} --list"
    su -s /bin/sh -c "${firejail_cmd}" "${PUMA_USER}"
}

case "$1" in
  status)
    do_status
  ;;
  start)
    echo "Starting the pumas"
    do_start
  ;;
  stop)
    echo "Stopping the pumas"
    do_stop
  ;;
  restart)
    echo "Restarting $DESC"
    do_stop && do_start
  ;;
  *)
    echo "Usage:" >&2
    echo "  $SCRIPTNAME {start|stop|restart|status}" >&2
  ;;
esac
```

Example use:

# service puma restart
Restarting Pumas motherboard script
Stopping kurzy:.
Stopping liste:.
Starting kurzy:.
Starting liste:.
# service puma status
14881:puma:/usr/bin/firejail --quiet --profile=/var/www/kurzy/firejail.profile --name=app_kurzy -- /usr/bin/puma --quiet -C /etc/puma.conf.d/kurzy.conf
14903:puma:/usr/bin/firejail --quiet --profile=/var/www/liste/firejail.profile --name=app_liste -- /usr/bin/puma --quiet -C /etc/puma.conf.d/liste.conf

Step 5: Plug this nightmare into nginx

Do the necessary firewalling holes, and add a new nginx site:

# cat /etc/nginx/sites-enabled/goto.ninja
server {
    listen 80;
    listen [::]:80;
    server_name www.goto.ninja goto.ninja;

    location /.well-known {
        alias /var/www/letsencrypt/.well-known;
    }

    location / {
        return 301 https://goto.ninja$request_uri;
    }
}

server {
    listen 443;
    listen [::]:443;
    server_name goto.ninja;

    ssl_certificate /etc/nginx/ssl/letsencrypt/goto.ninja.pem;
    ssl_certificate_key /etc/nginx/ssl/letsencrypt/goto.ninja.key;

    location / {
        proxy_pass http://10.0.3.40:9290$request_uri;
    }
}