Jail the Puma
01 Dec 2017Objectives
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;
}
}