This covers the setup of h2o, PHP, phpPgAdmin, and PostfixAdmin.

Configuring h2o/php-fpm

I first started using h2o because I wanted something that supported HTTP/2 (shortly after the standard was approved) and neither nginx nor apache offered anything that was not “development”. I very much like the simplicity of its configuration, light resource footprint, and responsive behavior. Documentation for h2o.

Getting this setup for serving up our mail related sites is really easy. We need to add 2 bits to the h2o.conf file (if you want to read about security for h2o, Calomel h2o has a great writeup). The config file is yaml, and sensitive to spacing. If you get errors on startup, don’t forget to check the spacing of things.

The first section is:

# php-fpm
file.custom-handler:
  extension: .php
  fastcgi.connect:
    host: 127.0.0.1
    port: 9000
    type: tcp

# Directory Index
file.index: [ 'index.php', 'index.html' ]

This takes care of not only handing all the php(1) files to php-fpm, but also defining index.php as an optional index file.

The second section is the paths we want to make available to run our applications. There are several: phpPgAdmin, PostfixAdmin, policyd, and Roundcube.

I’ve defined 2 “hosts” here. The first accepts traffic on port 80, sends back a 301 redirect to the same host, but port 443 (which is SSL encrypted). You might be wondering why not include the HSTS header with the redirect, so the browser would know to only use HTTPS. The browser won’t trust an HSTS header unless its sent over HTTP. Otherwise it could be altered in transit. The section looks like this:

# A+ on https://securityheaders.io/
header.add: "x-frame-options: deny"
header.add: "X-XSS-Protection: 1; mode=block"
header.add: "X-Content-Type-Options: nosniff"
header.add: "X-UA-Compatible: IE=Edge"
header.add: "Referrer-Policy: strict-origin"
header.add: "Cache-Control: no-transform"
header.add: "Content-Security-Policy: default-src https:"

# per-host configuration
hosts:
    "mx.cryptomonkeys.com:80":
      listen:
        port: 80
      paths:
        /:
          redirect:
            status: 301
            url: https://mx.cryptomonkeys.com/
    "mx.cryptomonkeys.com:443":
        header.add: "strict-transport-security: max-age=31556926; preload"
        listen:
          port: 443
          ssl:
            certificate-file: /usr/local/etc/ssl/server.crt
            key-file: /usr/local/etc/ssl/server.key
            dh-file: /usr/local/etc/ssl/dh2048.pem
            cipher-preference: server
            cipher-suite: ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK
            minimum-version: TLSv1.2
        paths:
            "/ppa":
                file.dir: "/usr/local/www/phpPgAdmin"
            "/pfa":
                file.dir: "/usr/local/www/postfixadmin"
            "/policyd":
                mruby.handler: |
                  require "#{$H2O_ROOT}/share/h2o/mruby/htpasswd.rb"
                  Htpasswd.new("/usr/local/www/policyd/.htpasswd", "realm-name")                  
                file.dir: "/usr/local/www/policyd"
            "/webmail":
                file.dir: "/usr/local/www/roundcube"

I’ve set the minimum version of TLS to be 1.2. As long as you have a relatively recently version of a popular browser, you should be fine with this. I’ve also restricted the ciphers to what can be found on Mozilla’s Security/Server Side TLS under the moniker ‘modern’.

Now we need to create the .htpasswd file in the policyd directory. If you have a system with apache, you can use the included utility. If you are just testing things, you can use this web based htpasswd generator.

Now we can create a php.ini file. In /usr/local/etc/, copy the production one.

cp php.ini-production php.ini

Now edit the php.ini and set the date/timezone. If you run lots of servers, I’d suggest UTC. Also, here are some security best practices:

open_basedir = /usr/local/www
expose_php = Off
memory_limit = 8M
error_log = syslog
post_max_size = 256K
sys_temp_dir = "/var/php_tmp"
upload_tmp_dir = /var/php_tmp
upload_max_filesize = 20M
allow_url_fopen = Off
date.timezone = UTC
sql.safe_mode = On
session.save_path = "/var/php_tmp"

I’ve left the upload at 20M because I want people to be able to attach things in webmail. If its larger than 20M, it doesn’t belong in email.

Don’t forget to create /var/php_tmp and set the proper permissions:

mkdir /var/php_tmp
chmod 1777 /var/php_tmp

Now we can run:

sudo sysrc h2o_enable=YES
sudo sysrc php_fpm_enable=YES

to /etc/rc.conf. Once this is done, you can run:

sudo service php-fpm start && sudo service h2o start

You should be able to see both in the ps(1) output. It should look something like this:

[louisk@mx louisk 44 ]$ ps ax | egrep 'php|h2o'
 758  -  I      0:00.03 /usr/local/bin/perl -x /usr/local/share/h2o/start_server --pid-file=/var/run/h2o.pid --log-f
 759  -  I      0:01.45 /usr/local/bin/h2o -c /usr/local/etc/h2o/h2o.conf
1037  -  Ss     0:00.62 php-fpm: master process (/usr/local/etc/php-fpm.conf) (php-fpm)
2210  -  I      0:04.44 php-fpm: pool www (php-fpm)
5623  -  I      0:01.32 php-fpm: pool www (php-fpm)
6304  -  I      0:00.26 php-fpm: pool www (php-fpm)
8356  3  S+     0:00.00 egrep php|h2o
[louisk@mx louisk 45 ]$

You can also check for open sockets with:

[louisk@mx louisk 48 ]$ sockstat -46l | egrep 'php|h2o'
www      php-fpm    6304  0  tcp4   127.0.0.1:9000        *:*
www      php-fpm    5623  0  tcp4   127.0.0.1:9000        *:*
www      php-fpm    2210  0  tcp4   127.0.0.1:9000        *:*
root     php-fpm    1037  8  tcp4   127.0.0.1:9000        *:*
www      h2o        759   5  tcp6   *:80                  *:*
www      h2o        759   6  tcp4   *:80                  *:*
www      h2o        759   7  tcp6   *:443                 *:*
www      h2o        759   8  tcp4   *:443                 *:*
www      h2o        759   15 tcp6   *:80                  *:*
www      h2o        759   16 tcp4   *:80                  *:*
www      h2o        759   17 tcp6   *:443                 *:*
www      h2o        759   18 tcp4   *:443                 *:*
[louisk@mx louisk 49 ]$

In case you’re wondering, the config below is HTTP/2 compliant and modern browsers will access the site via HTTP/2.

# vi: ft=yaml
# to find out the configuration commands, run: h2o --help
user: www
pid-file: /var/run/h2o.pid
access-log: /var/log/h2o/h2o-access.log
error-log: /var/log/h2o/h2o-error.log
# php-fpm
file.custom-handler:
  extension: .php
  fastcgi.connect:
    host: 127.0.0.1
    port: 9000
    type: tcp

# Directory Index
file.index: [ 'index.php', 'index.html' ]

file.dirlisting: off

# per-host configuration
hosts:
    "mx.cryptomonkeys.com:80":
      listen:
        port: 80
      paths:
        /:
          redirect:
            status: 301
            url: https://mx.cryptomonkeys.com/
    "mx.cryptomonkeys.com:443":
        header.add: "strict-transport-security: max-age=31556926; preload"
        listen:
          port: 443
          ssl:
            certificate-file: /usr/local/etc/ssl/server.crt
            key-file: /usr/local/etc/ssl/server.key
            dh-file: /usr/local/etc/ssl/dh2048.pem
            cipher-preference: server
            cipher-suite: ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK
            minimum-version: TLSv1.2
        paths:
            "/ppa":
                file.dir: "/usr/local/www/phpPgAdmin"
            "/pfa":
                file.dir: "/usr/local/www/postfixadmin"
            "/webmail":
                file.dir: "/usr/local/www/roundcube"
            "/policyd":
                file.dir: "/usr/local/www/policyd"

Configuring phpPgAdmin

This component is optional. If you are comfortable using psql(1) to manipulate the databases, there is no need to install this. If you choose to skip it, don’t forget to remove the appropriate lines from the h2o.conf and restart h2o.

You can find documentation for phpPgAdmin here.

By default, phpPgAdmin installs bits into /usr/local/www/phpPgAdmin (web root). The config is pretty basic. I only had to modify things in the top dozen or two lines.

$conf['servers'][0]['host'] = 'localhost';
$conf['servers'][0]['port'] = 5432;
$conf['servers'][0]['sslmode'] = 'require';

Now you should be able to point your browser at your webserver (https://my-ip/ppa/), and get something that looks similar to this:

Configuring PostfixAdmin

PostfixAdmin is the easy way for people to control virtual users and domains. Privlidges are assignable by domain so you can give somebody free reign over their own domain(s) if you wish. Documentation for PostfixAdmin.

PostfixAdmin defaults the install to /usr/local/www/postfixadmin. We need to edit the config.inc.php file first. I’ve made the following changes (to existing lines/entries):

...
$CONF['configured'] = true;
$CONF['setup_password'] = 'winkle-snicker';
$CONF['database_type'] = 'pgsql';
$CONF['database_host'] = 'localhost';
$CONF['database_user'] = 'postfix_admin';
$CONF['database_password'] = 'password';
$CONF['database_name'] = 'postfix_admin';
...
$CONF['admin_email'] = 'admins@cryptomonkeys.com';
...
$CONF['default_aliases'] = array (
    'abuse' => 'abuse@cryptomonkeys.com',
    'hostmaster' => 'hostmaster@cryptomonkeys.com',
    'postmaster' => 'postmaster@cryptomonkeys.com',
    'webmaster' => 'webmaster@cryptomonkeys.com'
);
...
$CONF['vacation'] = 'YES';
$CONF['vacation_domain'] = 'autoreply.cryptomonkeys.com';

Now, its time to add a postfix database and user to Postgres. Connect with psql and type in:

CREATE ROLE postfix_admin WITH LOGIN ENCRYPTED PASSWORD 'winkle-snicker';
CREATE DATABASE postfix_admin WITH OWNER = postfix_admin;

Once you have these bits set, you should be able to point your browser at https://my-ip/pfa/setup.php and get something that looks similar to this:

Come up with a “setup password” and admin credentials. Then you can re-point your browser at https://my-ip/pfa/, and get something that looks similar to this:

You should be able to login to PostfixAdmin and create domains, users, and aliases. They take effect immediately.

NOTE: If you wish to convert from MySQL to PostgreSQL (insert plenty of comments about the one true database), you will need to do a little dirty work. Its not terribly complicated, but it is a manual process.

Start by dumping the postfix database from mysql(1).

mysqldump --compatible=postgresql dbname > export.sql

You will need to make some edits to this (I creatively called mine postfix.sql) before you can import it into PostgreSQL.

  • At the top of the document, I inserted the following lines so I could run the script more than once as I was working through it.
TRUNCATE TABLE admin CASCADE;
TRUNCATE TABLE alias CASCADE;
TRUNCATE TABLE config CASCADE;
TRUNCATE TABLE domain CASCADE;
TRUNCATE TABLE domain_admins CASCADE;
TRUNCATE TABLE mailbox CASCADE;
  • All of the stanzas that are in the category of “table structure” get deleted
  • For each line that starts with ‘INSERT INTO’, remove all of the backquotes (`) and single quotes (')
  • All of the boolean values need to be converted from ‘0’ or ‘1’, into ‘true’ or ‘false’
  • Reordering of the tables that we insert into (because we have foreign key dependancies that must be met to succeed. The order is as follows:

Mailbox table is slightly reordered. Must be changed for every insert-entry. Order matters here for foreign keys to work properly.

1. admin
2. config
3. domain
4. domain_admins
5. mailbox
6. alias

You will have to recreate your superadmin in postfixadmin. Delete the entry in admins, and domain_admins, and then re-add through postfixadmin. W/o this process, it will tell you it already exists.


Footnotes and References