Servidores web más seguros con Docker (V)

Habiendo visto ya cómo instalar todo el servidor Lamp con FTP incluido, en este artículo que cierra la serie vamos a enfocarnos en seguridad y hacer las cosas bien para evitar posibles desastres.

Restringiendo permisos de los contenedores

Los contenedores que hemos creado suponen una mejora de seguridad considerable, pero aún pueden conectar todos con todos; y también pueden acceder a internet.

Vamos a cubrir principalmente:

  • Puntos de montaje de sólo lectura
  • Mezclar puntos de montaje de sólo lectura y de escritura para definir qué parte de la web se puede escribir
  • Hacer el contenedor (la imagen) de sólo lectura
  • Definir una topología de red restringida para que sólo puedan comunicarse entre ellos unos contenedor determinados y no tengan acceso a internet.

Volúmenes de solo lectura

Los puntos de montaje (volumes) en sólo lectura son bastante sencillos de implementar, sólo hay que agregar “:ro” al final de la cadena del punto de montaje para indicar que es sólo lectura.

¿Qué puntos de montaje pueden ser de sólo lectura? Prácticamente todos. Repasemos todos los que tenemos:

  • Nginx:
    • conf.d: no necesita escribir su propia configuración
    • /var/www: no necesita escribir en las webs
    • /cache: escritura. Necesita escribir las nuevas cachés y borrar las antiguas.
  • PHP (Apache y FPM):
    • /var/www/html: una web no debería “sobreescribirse” a sí misma
  • MySQL:
    • /var/lib/mysql: escritura. Necesita modificar las bases de datos.
  • PostgreSQL:
    • pgdata: escritura. Necesita modificar las bases de datos.
  • PhpMyAdmin:
    • /sessions: escritura. Necesita poder guardar
  • Python uWSGI:
    • foobar.py: no debe poder reescribirse a sí mismo.
  • SFTP y FTP:
    • /www: escritura. A no ser que la FTP sólo sirva para leer el contenido, claro.

Ahora, todas las que no son de escritura agregamos “:ro” al final, por ejemplo:

master-webserver:
image: nginx:1.14
volumes:
- ./master-webserver/sites-enabled:/etc/nginx/conf.d:ro
- ./www:/var/www:ro
- ./master-webserver/cache:/cache

El resto ya las cambiáis vosotros. Luego reiniciáis los servicios con “docker compose up -d” y probáis a ver si todo sigue funcionando.

Ahora bien, en el caso de las webs es muy habitual que se puedan subir ficheros como imágenes y adjuntos. Si está todo como solo lectura, el contenedor no va a poder escribir, y por tanto los usuarios no van a poder adjuntar nada.

¿Cómo solucionar ésto? Montando la carpeta que sea (por ejemplo uploads/) encima de la de solo lectura, pero en modo de escritura.

Por ejemplo, pongamos que el programa PHP que tenemos queremos que escriba un fichero dentro de una carpeta “uploads/”. Temporalmente quitamos el “:ro” de php-apache-1 y cambiamos el código a index.php:

<html>
 <head>
  <title>Prueba de PHP</title>
 </head>
 <body>
 <?php
$filename = './uploads/test.txt';
echo '<p>Hola Mundo</p>';
file_put_contents($filename, 'Time: ' . time() . "\n", FILE_APPEND);
echo '<pre>';
echo file_get_contents($filename);
echo '</pre>';

 ?>
 </body>
</html>

Hemos de crear la carpeta uploads/ y ésta debe tener como propietario el usuario www-data cuyo UID ha de ser 33. (Lo importante es el UID)

Como veis funciona y ya hay una restricción importante de permisos por usuario. Esto es algo que hay que tener en cuenta: bien hecho este “coñazo” de UID’s y de permisos evitará que hagan daño si todo lo demás falla.

Pero no es suficiente; no nos fiamos y queremos que esté montado como solo lectura. Simplemente agregamos otra línea con la carpeta específica, sin el flag “:ro”:

php-apache-1:
image: php:7.3-apache-stretch
volumes:
- ./www/php-apache-1:/var/www/html/:ro
- ./www/php-apache-1/uploads:/var/www/html/uploads/

Y ya funciona otra vez. Ahora queda meridianamente claro que no se puede escribir fuera de la carpeta de uploads. Ni aunque desde el contenedor obtenga permisos de root, o el empleado o cliente X por desconocimiento haga un “chmod 0777 . -R”, no se puede escribir en las carpetas no habilitadas. Esto es lo que yo llamo seguridad.

¿Pero qué pasa si la web es un Drupal o WordPress y necesitamos actualizarlo o instalar plugins? ¿Tenemos que hacerlo ahora a mano? ¿Y si el cliente se quiere instalar algo? No os preocupéis, os explico una posible solución en este mismo artículo, más abajo.

Contenedor de sólo lectura

Un atacante que consiga entrar en un contenedor, seguramente no conseguirá acceso de root y su acceso de usuario le limitará mucho en lo que puede y no puede hacer. Pero aún así, podría modificar el contenedor.

Si creemos que el contenedor tiene cambios, recrear el contenedor debería ser suficiente. Igualmente, los volumes pueden ser borrados y recreados a no ser que contengan información valiosa, como código de páginas web o bases de datos.

Mi recomendación es que tiréis de GIT para almacenar el código de las páginas web, excluyendo uploads y temporales. Los uploads y las bases de datos, haced backups frecuentes incrementales y completos; y guardad un histórico de copias de al menos 6 meses. De ese modo, si hubiera una modificación no autorizada en alguna de estas partes, tendríamos datos para intentar deshacer el entuerto.

Para el caso de los contenedores, ponerlos en solo lectura es una medida extrema que aportará apenas un 1% de seguridad comparado con otras prácticas más sencillas. Pero si la superficie de ataque que nos queda es un 2%, reducirla otro 1% supone doblar la seguridad.

Hacer que un contenedor sea solo lectura es muy sencillo. Sólo hay que agregar “read_only: true” a la descripción del servicio. Por ejemplo:

php-apache-1:
image: php:7.3-apache-stretch
read_only: true
volumes:
- ./www/php-apache-1:/var/www/html/:ro
- ./www/php-apache-1/uploads:/var/www/html/uploads/

El problema es que a la práctica esto no es tan sencillo. Esto es lo que ocurre al iniciar el contenedor:

Apache intenta escribir su PID al iniciarse y falla. Como resultado, el contenedor se detiene. Los programas suelen asumir que están en un sistema operativo con acceso de escritura, así que cuando no pueden escribir terminan “explotando” de formas variadas.

Cada imagen de Docker tiene sus handicaps para montarla en read_only. Lo mejor es leer la documentación de la imagen particular que queremos para ver si cubre cómo ejecutarla en read_only. En el caso de la de PHP, no tiene información.

Podemos tirar a base e prueba y error; vemos qué fichero falla al escribir en los logs y lo arreglamos. La forma de arreglarlo puede ser un volumen tmpfs. Estos volumenes son en memoria y se pierden en cada reinicio. Ideal para que aunque alguien consiga instalar algo allí, dure poco.

Agregaremos un tmpfs para /var/run/apache2; pero hay que tener en cuenta que /var/run es un enlace simbólico a /run, por lo que quedaría de la siguiente forma:

php-apache-1:
image: php:7.3-apache-stretch
read_only: true
volumes:
- ./www/php-apache-1:/var/www/html/:ro
- ./www/php-apache-1/uploads:/var/www/html/uploads/
tmpfs:
- /run/apache2/

Con esto probamos y vemos que funciona de nuevo. Parece que no era tan complicado como aparentaba.

Restringiendo el acceso a la red

Por defecto, todos los contenedores tienen acceso de red tanto a internet como al anfitrión, como a cualquier otro contenedor. Esto está muy bien para entornos de desarrollo, pero en producción que el contenedor de PHP pueda acceder al puerto 22 de otro contenedor SFTP dista mucho de lo ideal.

Que, como siempre, si las contraseñas son seguras no están reutilizadas y tal, un atacante que gana acceso a un contenedor no debería poder ganar acceso a otro; simplemente porque desconoce los usuarios y contraseñas.

Pero es que, además de que impedir las conexiones es una capa de seguridad muy buena, los contenedores tienen acceso a internet y al ganar acceso a un contenedor podría enviar SPAM, intentar atacar otros equipos en LAN o infectar equipos por internet. Aparte del impacto para el negocio que esto pueda suponer, un ISP u otra entidad podría apagar los servidores en caso de que vea actividad maliciosa. Así que no nos faltan razones para limitar al máximo las conexiones de red de los contenedores.

Hay dos modos de restringir las redes, uno es con Dockerfile. Personalizando la imagen, se puede hacer que al iniciar el contenedor ejecute una serie de comandos “iptables” para limitar las conexiones de red. La ventaja es que se puede hilar muy fino. La desventaja es que cada imagen tendríamos que personalizarla. Si os interesa, éste artículo lo explica de forma sencilla:

https://dev.to/andre/docker-restricting-in–and-outbound-network-traffic-67p

La segunda forma es usando las redes (networks) que docker-compose puede crear por nosotros. Esto es lo que haremos aquí. La ventaja es la sencillez de configuración. La desventaja es que sólo podemos definir subredes entre contenedores.

En nuestro caso, como no hay diferentes dominios diferenciados, sino que al final todos terminan conectando con todos (nginx a php, php a mysql, …), lo mejor será definir una subred por contenedor. Suena un poco a una barbaridad, pero nos permite limitar qué equipo conecta a cual usando “docker-compose.yml”.

Entonces, empezamos agregando una clave “networks:” al final del fichero. Tiene que estar a primer nivel (raíz) fuera de los servicios. En esta, agregaremos una entrada por contenedor, más una llamada “default” tal que así:

networks:
default: {internal: true}
master-webserver: {}
php-apache-1: {internal: true}
php-fpm-1: {internal: true}
mysql-1: {internal: true}
postgresql-1: {internal: true}
phpmyadmin: {internal: true}
pgadmin4: {internal: true}
python-uwsgi-1: {internal: true}
sftp-php-apache-1: {}
sftp-python-uwsgi-1: {}
ftpd-php-apache-1: {}

La red “default” es la que usan los contenedores que no especificamos red alguna. “internal: true” quiere decir que no tienen acceso a internet. De este modo, deshabilitamos internet a todos los contenedores que no la necesitan. Como regla general, si no tienen puerto enrutado al anfitrión, no necesitan internet.

Luego, a cada servicio agregamos una clave “networks:” y listamos las redes a las que puede acceder, que será a sí mismo y en algún caso a otro contenedor. El caso más importante es el de master-webserver:

master-webserver:
image: nginx:1.14
networks:
- master-webserver
- php-apache-1
- php-fpm-1
- pgadmin4
- phpmyadmin
- python-uwsgi-1
(...)

Pero por ejemplo php-apache-1 sólo necesita conectividad consigo mismo:

php-apache-1:
image: php:7.3-apache-stretch
networks:
- php-apache-1

Con esto conseguimos que Nginx pueda conectarse a php-apache-1 vía proxy http, pero que php-apache-1 no pueda conectarse a Nginx (en realidad sí puede si escanea IP’s, pero no puede conectarse a MySQL o a php-fcgi-1). Además, Nginx tiene acceso a internet, pero php-apache-1 no lo tiene.

Como referencia, este sería el de phpmyadmin:

phpmyadmin:
image: phpmyadmin/phpmyadmin:4.7
networks:
- phpmyadmin
- mysql-1

Ahora el hecho de separar todo en contenedores cobra aún más sentido. Gracias a esta separación, cuando se ejecuta PHP está totalmente aislado de la red. Poniendo al frente un servicio de confianza como es Nginx y escondiendo detrás el más vulnerable que es PHP, reduce enormemente la probabilidad de un hackeo exitoso.

Sobre docker y redes

Cuando cambias las redes y ya has levantado contenedores antes con redes personalizadas a menudo salen errores de que no se puede reconfigurar la red o que no la encuentra.

Si no puede reconfigurar la red hay que parar los contenedores con “docker-compose stop contenedor1”, luego examinar las redes con “docker network ls” y borrar la que hayamos cambiado con “docker network rm nombredelared”.

Después de esto, al lanzar “docker-compose up” hay que pasar el flag “–force-recreate” para que regenere los contenedores con la nueva red. Por ejemplo:

$ docker-compose stop master-webserver
$ docker network ls
NETWORK ID          NAME                              DRIVER              SCOPE
2ff8f2a1ce0f        docker-lamp_master-webserver      bridge              local
$ docker network rm docker-lamp_master-webserver
$ docker-compose up -d --force-recreate master-webserver

Como no podía ser de otra forma, el código fuente de este tutorial está en GitHub:

https://github.com/deavid/docker-lamp/tree/v0.12

Mejorando la gestión de passwords

Para el caso de FTP y SFTP, el hecho de que los passwords estén en texto claro, dentro del repositorio es un problema serio. Por ejemplo, si alguien usa el repositorio “tal cual”, estará exponiendo su máquina a ejecución de código de forma trivial: simplemente el atacante se loguea por SFTP con la contraseña por defecto, instala su software y lo ejecuta vía HTTP. Que sí, que está limitado a solo el contenedor; pero esto es muy peligroso.

O podemos imaginar también el escenario donde una empresa tiene su versión privada de este ejemplo también en Git, pero interno. Y un día un empleado por error hace push al remoto que no tocaba y termina en GitHub por unas semanas. Todas las contraseñas de todos los clientes publicadas en un santiamén.

Tenemos que ver qué podemos hacer para mitigar estos escenarios.

SFTP

En el caso de atmoz/sftp, permite especificar los passwords de forma cifrada. Para ello usaremos el programa “makepasswd” y al resultado agregaremos “:e” al final para especificar que está cifrado.

Por ejemplo:

deavid@debian:~$ makepasswd --crypt-md5 --clearfrom=-  
mipassword
mipassword   $1$wRMZ98PL$2z8jFQSQGvTEmfJYigDvW0

Ahora modificamos la configuración e incorporamos el password cifrado:

sftp-php-apache-1:
image: atmoz/sftp:debian-stretch
networks:
- sftp-php-apache-1
volumes:
- ./www/php-apache-1:/home/admin/php-apache-1
ports:
- "2201:22"
command: admin:$$1$$wRMZ98PL$$2z8jFQSQGvTEmfJYigDvW0:e:1000

Si os fijáis veréis que he doblado los símbolos de dólar. Esto es porque docker-compose tiene una sintaxis para reemplazar variables y si ve un dólar sin variable va a dar un error. El símbolo $$ es la forma de escapar $.

Con esto ya lo tenemos. No es que este password cifrado se pueda compartir, pero en caso de que pase, será bastante más difícil que alguien deduzca qué password es, entre otras cosas porque el mismo password genera distintas cadenas cada vez.

El programa “makepasswd” se puede instalar en Debian/Ubuntu con un “apt-get install makepasswd”. Podéis incluso instalarlo en las máquinas de desarrollo y no es necesario tenerlo en el servidor. Las contraseñas sirven igual.

FTP

Para la imagen de FTP no hay forma de cifrar la contraseña, que era bastante cómodo. Esta imagen deja los usuarios guardados permanentemente al estilo de MySql y PostgreSQL. Lo mejor será adaptarla para que funcione de forma similar:

ftpd-php-apache-1:
image: stilliard/pure-ftpd
networks:
- ftpd-php-apache-1
ports:
- "2101:21"
- "21010-21019:21010-21019"
expose:
- "21010-21019"
volumes:
- ./www/php-apache-1:/var/www/html/
environment:
- PUBLICHOST=localhost # Replace this with your server public IP
- FTP_PASSIVE_PORTS=21010:21019
- FTP_USER_HOME=/var/www/html/
- FTP_USER_NAME=admin
- FTP_USER_PASS

De este modo, el usuario no es creado hasta que en el arranque especificamos la variable de entorno “FTP_USER_PASS”. Hay que tener cuidado porque si arrancamos todos los contenedores a la vez y hay varios de FTP, si pasamos esta variable de entorno todos recibirán el mismo password.

Para evitar esto, podemos mapear un nombre personalizado que puede variar por contenedor:

environment:
- PUBLICHOST=localhost # Replace this with your server public IP
- FTP_PASSIVE_PORTS=21010:21019
- FTP_USER_HOME=/var/www/html/
- FTP_USER_NAME=admin
- FTP_USER_PASS=${FTP_PHP_APACHE_1_PASS-}

La variable en este caso es FTP_PHP_APACHE_1_PASS, el guión al final es para el valor por defecto, que es vacío. Principalmente para evitar un warning de Docker Compose si no existe. El contenedor de FTP es lo suficientemente listo como para no crear usuarios sin contraseña. Si no la pasamos, no crea nada.

Os podéis crear un fichero aparte con una serie de comandos “export” que definan todas las contraseñas y que podáis hacer “source ../passwords.profile”

El código de ejemplo lo tenéis aquí:

https://github.com/deavid/docker-lamp/tree/v0.13

Aplicación de administración con HttpAuth

Veamos cómo hacer un contenedor secundario para la misma web con más permisos, que requiera contraseña para poder acceder a cualquier página.

La idea es muy sencilla, duplicamos el contenedor, le damos acceso a internet y acceso de escritura a disco, y lo conectamos a Nginx. En Nginx agregamos Http Auth y opcionalmente limitamos por IP. Esta es la guía:

https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/

Como ejemplo, vamos a copiar el contenedor php-fpm-1 y le daremos como nombre php-fpm-1-admin. Acordaos de cambiar el nombre de la red y de quitar el sólo lectura al volumen:

php-fpm-1-admin:
networks:
- php-fpm-1-admin
- mysql-1
image: php:7.3-fpm-stretch
volumes:
- ./www/php-fpm-1:/var/www/html/
restart: always

Al apartado “networks” del final del fichero, agregamos nuestra red; pero sin especificar “internal: true” porque queremos que pueda acceder a internet:

networks:
default: {internal: true}
master-webserver: {}
php-apache-1: {internal: true}
php-fpm-1: {internal: true}
mysql-1: {internal: true}
postgresql-1: {internal: true}
phpmyadmin: {internal: true}
pgadmin4: {internal: true}
python-uwsgi-1: {internal: true}
sftp-php-apache-1: {}
sftp-python-uwsgi-1: {}
ftpd-php-apache-1: {}
php-fpm-1-admin: {}

Con esto el contenedor ya se puede crear correctamente. Tiene acceso a internet pero no a los otros contenedores. Puede escribir la carpeta de la web pero no de otras webs. Ahora deberíamos agregar la configuración de Nginx para poder conectar; no expongáis el puerto de este contenedor.

Copiamos php-fpm-1.conf a php-fpm-1-admin.conf, cambiamos el nombre del servidor por ejemplo a “admin.php-fpm-1” y el fastcgi_pass a “php-fpm-1-admin:9000”.

También hay que actualizar en docker-compose.yml las redes de master-webserver, que ahora tiene que poder acceder a este nuevo equipo.

Agregamos al bloque “server” las siguientes dos líneas:

   auth_basic           "Admin";
auth_basic_user_file /etc/nginx/conf.d/php-fpm-1.htpasswd;

Esto indica a Nginx que las contraseñas estarán guardadas en la misma carpeta de configuración. Ahora toca crear el fichero. Para eso usaremos el programa “htpassword” que se instala con “apt-get install apache2-utils”:

$ touch master-webserver/sites-enabled/php-fpm-1.htpasswd
$ htpasswd master-webserver/sites-enabled/php-fpm-1.htpassw
d admin
New password:  
Re-type new password:  
Adding password for user admin

El primer comando crea el fichero si no existe. El segundo agrega un usuario llamado “admin” y nos pregunta la contraseña.

El resultado es que en el fichero ahora aparece lo siguiente:

admin:$apr1$V8vOzr1B$1dK0BkFGSJuXTLa89anLs/

Como podéis ver, la contraseña está cifrada. Solo falta actualizar /etc/hosts para incluir el nuevo dominio admin.php-fpm-1 y probar a acceder.

Si accedemos, veremos que nos pide el usuario y la contraseña:

Ya lo tenemos finalizado. Al pasar usuario “admin” y la contraseña que hayamos elegido, podremos acceder a la web. Para limitar también por IP podéis verlo en el artículo que enlacé al inicio del capítulo.

La idea aquí es que podamos actualizar la web o instalar plugins desde la misma, pero sin comprometer en seguridad. Al tener una web secundaria para esto que está bloqueada para el público pero no para el webmaster, éste puede usar esta web para cambiar el propio programa, pero un hacker no puede acceder a esta versión.

Esta autenticación debería ser sólo por HTTPS, ya que las contraseñas se transmiten en texto plano y en cada petición. Lo veremos en el próximo capítulo. El código de este capítulo lo podéis descargar desde aquí:

https://github.com/deavid/docker-lamp/tree/v0.14

HTTPS y autorenovación con LetsEncrypt

Hoy en día HTTPS es la norma. Veamos cómo configurar HTTPS con LetsEncrypt; ésto sólo funciona si tenéis dominios reales en un servidor web funcionando. Agregaremos también un crontab para la autorenovación, y todo esto en un contenedor dedicado y separado del resto.

Certificados autofirmados

Como Letsencrypt require de un dominio real servido desde la misma máquina, voy a empezar con algo más sencillo: Certificados autofirmados (self-signed). Éstos no son buena idea para webs reales, pues van a mostrar errores en el navegador de que el certificado no es seguro, pero es lo más cercano que podemos tener en un entorno de desarrollo.

La idea básica va a ser crear un certificado SSL con el comando “openssl”, montarlo como volumen en Nginx y configurarlo adecuadamente.

Como al final no sirven para mucho, no voy a complicarme con crearlo bien; nos basta con crear “algo” que funcione. Vamos a crear una carpeta ./master-webserver/certs/ y dentro vamos a crear un certificado SSL para php-fpm-1:

openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -key
out php-fpm-1.key -out php-fpm-1.crt
Generating a RSA private key
.................................................................+++++
.......+++++
writing new private key to 'php-fpm-1.key'
-----
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:php-fpm-1
Email Address []:

En el comando, lo único importante es el Common Name, que debería ser el nombre del dominio. De todas formas, como el certificado no va a ser 100% válido de todos modos, no importa demasiado.

Esto nos creará dos ficheros en la carpeta certs/, un crt y un key. El fichero key es privado y deberíais tratarlo con tanto cuidado como una contraseña. No está cifrado.

Lo siguiente es abrir el puerto 443 y configurar el volumen (solo lectura) en docker-compose.yml:

services:
master-webserver:
image: nginx:1.14
networks:
- master-webserver
- php-apache-1
- php-fpm-1
- php-fpm-1-admin
- pgadmin4
- phpmyadmin
- python-uwsgi-1
volumes:
- ./master-webserver/sites-enabled:/etc/nginx/conf.d:ro
- ./master-webserver/certs:/etc/nginx/certs:ro
- ./www:/var/www:ro
- ./master-webserver/cache:/cache
ports:
- "85:85"
- "443:443"
restart: always

Después vamos a la configuración del dominio en sites-enabled/php-fpm-1.conf y agregamos al inicio:

   listen              443 ssl http2;
ssl_certificate /etc/nginx/certs/php-fpm-1.crt;
ssl_certificate_key /etc/nginx/certs/php-fpm-1.key;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers HIGH:!aNULL:!MD5;

Con esto ya está, cuando vayamos a https://php-fpm-1 nos saldrá el aviso de que el certificado no es válido (no lo es), agregamos la excepción temporalmente y deberíamos poder ver la web.

Si tenéis el puerto 443 ocupado y queréis usar otro, simplemente cambiadlo en los tres sitios.

Letsencrypt

Para empezar, comentar que LetsEncrypt no va a funcionar en una máquina de desarrollo. Necesitamos de un dominio real en una máquina que esté sirviendo el dominio en el puerto 80 a través de nuestro master-webserver (o que haya otro reverse proxy convirtiendo el puerto 85 al 80). Por lo tanto, esta sección sólo funciona si habéis preparado con el tutorial un servidor web real.

Lo que vamos a hacer es crear un nuevo servicio llamado “letsencrypt” basado en una imagen Docker que nos haremos nosotros que llamaremos “lamp/letsencrypt” y ésta a su vez la basaremos en Debian Stretch. Sobre ésta base instalaremos certbot y montaremos tanto las webs como la carpeta de certificados (en modo lectura+escritura). Tienen que permitir escritura ambas porque certbot va a escribir un fichero de prueba en las webs para comprobar que controlamos el dominio y la de certificados porque tiene que poder escribirlos al crearlos o actualizarlos.

Agregamos el servicio a docker-compose.yml y su red:

services:
(...)
letsencrypt:
build: ./letsencrypt
image: lamp/letsencrypt
  volumes:
- ./master-webserver/certs:/certs
  - ./www:/var/www
  - ./letsencrypt-etc:/etc/letsencrypt
networks:
- letsencrypt
networks:
default: {internal: true}
(...)
letsencrypt: {}

NOTA: La carpeta /letsencrypt-etc está en la raíz en lugar de ser /letsencrypt/etc porque al arrancar la imagen, escribirá en ella como root varios ficheros que no tienen permisos de lectura y las siguientes compilaciones de la imagen fallarían, ya que Docker intenta recursivamente acceder a las carpetas de donde está el Dockerfile.

La red para letsencrypt va a permitir conexiones externas, ya que tiene que contactar con su servidor para pedir los certificados.

Creamos la carpeta y fichero “letsencrypt/Dockerfile” con el siguiente contenido:

FROM debian:stretch
RUN echo "deb http://ftp.debian.org/debian stretch-backports main" \
> /etc/apt/sources.list.d/backports.list
RUN set -ex \
&& apt-get update \
&& apt-get install -y -t stretch-backports \
certbot \
&& rm -rf /var/lib/apt/lists/*
CMD ["sleep", "99999"]

Este Dockerfile inicial descarga Debian Stretch, agrega el repositorio de backports e instala certbot. A continuación simplemente ejecutará un sleep para mantenerlo encendido sin hacer nada.

Para probar los Dockerfile, lo mejor es usar:

$ docker-compose up -d --build letsencrypt

Cuando lo tengáis funcionando, ejecutamos una consola bash dentro:

$ docker-compose exec letsencrypt bash

Esto nos dará acceso de root al contenedor. En él, comprobamos la versión de certbot:

root@3ff79f0bc160:/# certbot --version 
certbot 0.28.0

Vuestra versión puede que sea más nueva. Si es más antigua, es que no ha obtenido el paquete desde backports.

Una vez aquí, podemos empezar con la generación de los certificados de este modo:

certbot certonly --staging --webroot \
--cert-path /certs --key-path /certs
-w /var/www/php-fpm-1/ \
-d php-fpm-1.example.com -d www.php-fpm-1.com \
-w /var/www/php-apache-1/ -d php-apache-1.example.com

Varias cosas a comentar aquí:

  • Sólo funcionará si el dominio y subdominio existen y estamos sirviendolos desde nuestro Nginx en ese momento.
  • El flag “–staging” sirve para hacer pruebas. Los certificados que devuelve no son válidos. Usadla hasta que aprendáis cómo funciona y cuando lo tengáis claro la quitáis. Letsencrypt tiene unos límites y no podéis abusar. Con este flag los límites son mucho más altos.
  • Especificamos una carpeta a probar con “-w /var/www/…” y luego pasamos la lista de dominios con los que esa carpeta se tiene que comprobar.
  • Se pueden pasar más de una carpeta y más de un dominio a la vez. La recomendación es que paséis cuantos más a la vez mejor, ya que Letsencrypt intentará generar certificados que sirvan para más dominios.

Este sería un ejemplo de la salida (pero fallida, no tengo dominio donde probar ahora):

# certbot certonly --staging --webroot -w /var/www/php-fpm-1/ -d sedice.com 
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Plugins selected: Authenticator webroot, Installer None
Enter email address (used for urgent renewal and security notices) (Enter 'c' to
cancel): deavidsedice@gmail.com

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Please read the Terms of Service at
https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf. You must
agree in order to register with the ACME server at
https://acme-staging-v02.api.letsencrypt.org/directory
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
(A)gree/(C)ancel: A

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Would you be willing to share your email address with the Electronic Frontier
Foundation, a founding partner of the Let's Encrypt project and the non-profit
organization that develops Certbot? We'd like to send you email about our work
encrypting the web, EFF news, campaigns, and ways to support digital freedom.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
(Y)es/(N)o: N
Obtaining a new certificate
Performing the following challenges:
http-01 challenge for sedice.com
Using the webroot path /var/www/php-fpm-1 for all unmatched domains.
Waiting for verification...
Cleaning up challenges
Failed authorization procedure. sedice.com (http-01): urn:ietf:params:acme:error:unauthorized ::
The client lacks sufficient authorization :: Invalid response from http://sedice.com/.well-known/
acme-challenge/PqF0LO40O_HjQitgv8OcAak83b3soN46yJGeNeh2ZlA: "<?xml version=\"1.0\" encoding=\"iso
-8859-1\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\"\n         \"http://
www."

IMPORTANT NOTES:
- The following errors were reported by the server:

  Domain: sedice.com
  Type:   unauthorized
  Detail: Invalid response from
  http://sedice.com/.well-known/acme-challenge/PqF0LO40O_HjQitgv8OcAak83b3soN46yJGeNeh2ZlA:
  "<?xml version=\"1.0\" encoding=\"iso-8859-1\"?>\n<!DOCTYPE html
  PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\"\n
  \"http://www."

  To fix these errors, please make sure that your domain name was
  entered correctly and the DNS A/AAAA record(s) for that domain
  contain(s) the right IP address.
- Your account credentials have been saved in your Certbot
  configuration directory at /etc/letsencrypt. You should make a
  secure backup of this folder now. This configuration directory will
  also contain certificates and private keys obtained by Certbot so
  making regular backups of this folder is ideal.

Esto nos va a crear ficheros xxx.pem en master-webserver/certs. Si no fuese el caso, revisad dónde los crea y cambiad el punto de montaje. Vais a tener uno para el certificado y otro para la clave privada. La configuración en Nginx es exactamente igual que en los certificados autofirmados.

Cada vez que necesitéis un certificado nuevo haréis lo mismo, ejecutaréis “bash” en el contenedor y usando la línea de comandos solicitáis el nuevo certificado.

Con esto, ya deberíais tener un SSL funcionando perfectamente sin errores de validación de certificados. Pero hay un pequeño problema que no debe pasarnos por alto: Los certificados de Letsencrypt deben ser renovados cada tres meses sin falta, o las webs empezarán a fallar.

Para renovarlos es tan sencillo como ejecutar en el contenedor:

$ certbot renew

Y el programa se encargará de detectar los que están a punto de caducar, renovarlos y sobreescribir los certificados con la nueva versión. Esto es posible gracias a los datos que guarda en /etc/letsencrypt, y por ese motivo en los pasos anteriores hemos montado la carpeta en el disco duro, para que si la imagen hay que recompilarla o pasa cualquier cosa, estos datos no se pierdan.

Pero es un poco engorro tener que acordarse cada tres meses de entrar y renovar los certificados. Si habéis configurado una dirección de correo válida recibiréis alertas; pero lo suyo sería que se autorenueven.

Lo bueno es que “certbot renew” se encarga de todo, incluso de cuando hay que intentar la renovación o cuando no hace falta. Por lo que ejecutándolo en un cron todos los días sería suficiente. Pero en Docker no tenemos cron, así que, ¿qué hacemos?

Una solución muy sencilla es crear un pequeño programa de bash que ejecute un bucle infinito con “certbot renew” y un “sleep” de un día. Rudimentario, pero más que suficiente.

Creemos un fichero ./letsencrypt/renew.sh que contenga lo siguiente:

#!/bin/bash
while true
do
certbot renew
sleep 48h
done

Y en el Dockerfile de la misma carpeta cambiamos el comando del CMD y agregamos antes un comando COPY:

COPY renew.sh /

CMD ["bash", "/renew.sh"]

Básicamente le estamos diciendo que al arrancar el contenedor ejecute renew.sh; éste se ejecutará indefinidamente. Al iniciar, intentará una renovación y luego se dormirá 48 horas, para volver a intentarlo de nuevo. He puesto 48 horas pero puede ser cualquier valor. Sencillamente no creo que sea necesario intentarlo cada día. En Sedice.com lo tengo en un crontab cada semana y funciona bien.

Al iniciar de nuevo y ver los logs se aprecia cómo se ejecuta correctamente:

Con esto terminamos. El código está en GitHub:

https://github.com/deavid/docker-lamp/tree/v0.15

Activar TLS a la FTP

Ahora que ya tenemos las webs en HTTPS, la autenticación por HttpAuth que hicimos antes pasa a ser mucho más segura (eliminad el acceso por HTTP/1.1 para más seguridad). Pero la FTP sigue transmitiendo las contraseñas en texto plano y esto es ahora mismo el mayor riesgo de seguridad que tenemos.

Como ya he indicado muchas veces, lo mejor es eliminar las FTP por completo y pasar a usar SFTP (a través de SSH). De hecho es lo más sencillo.

Para el caso de las FTP, hay una variante llamada FTPS que agrega TLS al protocolo. Esto impide que las contraseñas pasen por texto plano sino cifradas.

Pero, ya aviso: Normalmente la mayoría de clientes FTP que la gente usa soporta SFTP (SSH), como por ejemplo FileZilla. Si realmente necesitamos FTP, probablemente es porque el software que necesita conectar no tiene soporte de SFTP. Y si es el caso, lo más normal es que tampoco tenga soporte de FTPS, por lo que describo aquí seguramente sea una pérdida de tiempo.

Básicamente para activar el soporte TLS, hay una opción “–tls” que puede tener tres valores:

  • –tls=1: Activar soporte TLS sólo opcionalmente
  • –tls=2: Activar TLS obligatoriamente sólo para el login
  • –tls=3: Activar TLS también para las conexiones de datos

En este ejemplo voy a usar –tls=3 que es el más seguro.

Vamos a necesitar un certificado para TLS. Para empezar, lo más sencillo es que la propia imagen se genere el certificado al arrancar. Para ello lo configuramos así:

ftpd-php-apache-1:
image: stilliard/pure-ftpd
networks:
- ftpd-php-apache-1
ports:
- "2101:21"
- "21010-21019:21010-21019"
volumes:
- ./www/php-apache-1:/var/www/html/
environment:
- PUBLICHOST=localhost # Replace this with your server public IP
- FTP_PASSIVE_PORTS=21010:21019
- FTP_USER_HOME=/var/www/html/
- FTP_USER_NAME=admin
- FTP_USER_PASS=${FTP_PHP_APACHE_1_PASS-}
- ADDED_FLAGS=--tls=3
- TLS_USE_DSAPRAM=true
- TLS_CN=php-apache-1
- TLS_ORG=lamp
- TLS_C=ES

Esto generará un certificado en cada arranque, no muy seguro (por el uso de USE_DSAPRAM), pero que cifrará la conexión. Es mejor que nada.

Si queremos usar nuestros certificados, necesitamos juntar el certificado y la clave pública en un sólo fichero:

cat php-fpm-1.crt php-fpm-1.key > php-fpm-1.both.pem

Y ahora hay que montar este fichero en el path /etc/ssl/private/pure-ftpd.pem; una vez hecho esto, las cuatro últimas variables de entorno (TLS_*) ya no son necesarias. Por lo que queda así:

ftpd-php-apache-1:
image: stilliard/pure-ftpd
networks:
- ftpd-php-apache-1
ports:
- "2101:21"
- "21010-21019:21010-21019"
volumes:
- ./www/php-apache-1:/var/www/html/
- ./master-webserver/certs/php-fpm-1.both.pem:/etc/ssl/private/pure-ftpd.pem
environment:
- PUBLICHOST=localhost # Replace this with your server public IP
- FTP_PASSIVE_PORTS=21010:21019
- FTP_USER_HOME=/var/www/html/
- FTP_USER_NAME=admin
- FTP_USER_PASS=${FTP_PHP_APACHE_1_PASS-}
- ADDED_FLAGS=--tls=3

Y ya está. No tiene más misterio. Los certificados de letsencrypt se pueden juntar en uno solo de la misma forma independientemente de si son *.crt o *.pem; simplemente hay que concatenarlos.

El código para FTPS lo tenéis aquí:

https://github.com/deavid/docker-lamp/tree/v0.16

Servidor de emails (relay)

Para enviar emails y no exponer nuestros contenedores de aplicación a internet, es más seguro crear un servidor de emails tipo relay, que simplemente reenvía todos los correos entrantes.

Normalmente no llevan contraseña, y pensándolo bien, si el contenedor tiene la contraseña es casi lo mismo que sin contraseña; siempre y cuando el relay sólo sea accesible desde un contenedor. Esto lo hemos visto antes en este mismo artículo.

La ventaja de montar así el correo, es que el usuario, password y host SMTP están fuera del contenedor de aplicación, por lo que no puede robar las credenciales. Y si quiere enviar correo, tiene que pasar por nuestro relay.

En este relay, más adelante se podrán agregar técnicas antispam para detectar usos maliciosos y detener los servicios o descartar correos. Como crearemos un contenedor de emails por cada web, si hay problemas con uno y lo detenemos, no vamos a afectar al resto de webs, ni mucho menos las cuentas de correo de usuarios con Thunderbird/Outlook/etc.

He intentado realizarlo con Postfix, pero este servidor de emails espera un sistema operativo funcionando, por lo que aunque es posible, es tremendamente laborioso. Al final me he decantado por una imagen en Docker que ya existe y funciona, está basada en Debian Jessie y Exim4:

https://hub.docker.com/r/namshi/smtp/

Después de inspeccionar su script de entrypoint me he quedado sorprendido lo fácil que configura y arranca Exim4, aún sin tener ni idea de cómo funciona éste servidor de correo. Postfix tiene una configuración más sencilla tal vez, pero el arranque dentro del contenedor es una locura.

Simplemente agregamos el bloque para el nuevo servicio en docker-compose.yml:

email-relay:
image: namshi/smtp
networks:
- email-relay
environment:
- SMARTHOST_ADDRESS=mail.mysmtp.com
- SMARTHOST_PORT=587
- SMARTHOST_USER=myuser
- SMARTHOST_PASSWORD=secret
- SMARTHOST_ALIASES=*.mysmtp.com
networks:
default: {internal: true}
(...)
email-relay: {}

Luego esta nueva red la agregáis al contenedor que queráis, y ya tiene sistema de correo. Para enviar correos desde él, tiene que ser vía SMTP al puerto 25, sin usuario ni contraseña.

Cada vez que este contenedor reciba un correo, lo reenviará al servidor de correo que habéis indicado en las variables de entorno. Ese servidor será el encargado de enviar definitivamente el correo.

Hay que tener en cuanta que los servidores relay son inseguros, porque cualquiera que pueda conectar a ellos podrá enviar correo. Es muy importante que no expongáis el puerto y que limitéis el acceso sólo al contenedor que tiene que enviar los emails.

Las contraseñas en este caso aparecen en docker-compose.yml lo cual no es bueno. Podéis pasarlas desde la shell y quitarlas de ahí; o en un fichero. Docker Compose permite muchas formas de cargar variables.

La imagen está basada en Debian Jessie en lugar de Stretch. Esto causa que hayan unos 60Mb adicionales en disco porque no está basada de la misma que usamos nosotros. La imagen parece sencilla de personalizar y se podría fácilmente hacer una nuestra sólo con soporte de Smarthost y más tarde agregar SpamAssasin u otros sistemas antispam. Yo de momento lo voy a dejar aquí.

El código para este capítulo lo tenéis aquí:

https://github.com/deavid/docker-lamp/tree/v0.17

Conlusión

Con esta entrega terminamos la serie de artículos de cómo montar un servidor LAMP super seguro con Docker. El resultado es que tenemos todos los servicios encapsulados en diferentes contenedores, con los permisos restringidos y la red limitada al máximo.

Si hemos configurado todo correctamente; no sólo copiando los ejemplos, sino entendiendolos y adaptándolos apropiadamente a nuestra situación, deberíamos tener un servicio que aunque sea vulnerable, el alcance de un posible hacker estará tremendamente limitado.

Uno de los factores que más debería preocupar es la existencia de las FTP y SFTP. Una contraseña robada podría permitir al atacante escribir cualquier cosa y ejecutar código en los contenedores. Aunque no pueda llegar muy lejos, va a poder usar esto para robar otras credenciales de otros usuarios y en caso de e-commerce podría robar cosas más sensibles como son tarjetas de crédito si la web trabaja con ellas.

Para evitar esto, los contenedores de FTP y SFTP deberían estar normalmente apagados (o con el puerto sin enrutar). Pero esto no es posible. Lo ideal sería poder tener una doble autenticación o tokens de un solo uso; pero esto se escapa del alcance de este tutorial. Si algún día tengo tiempo para desarrollarlo, tened por seguro que haré un artículo al respecto.

Por lo demás, espero que os haya gustado y os sea útil. Nos seguimos leyendo!

Security issues when setting up a Web server as a Reverse Proxy

I never thought about this, it may seem obvious when you read this article, but nevertheless this is going to be a good reminder. When we setup a Web Server as Nginx as a Reverse Proxy, one of my common approaches (up to last year, 2018) to efficiency was to serve the files directly from Nginx if possible, if not, jump to the proxied web server. So it was looking like something like this:

location / {     
try_files $uri $uri/ =404;
}
location ~ \.php$ {
  proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://php-apache-1;
}

While this is best for speed, it has a serious security issue: We’re bypassing the secondary webserver policies for static content.

Of course, an attacker cannot use your PHP files in any new way because of this, but now the static content does not follow the rules of the internal Apache. So, for example, let’s say the PHP site contained “.htaccess” files to forbid access to “*.inc” files because they contain code. Now we’re exposing them bypassing Apache. Or backup files, or lots of other files that could leak private information to the attacker.

In my particular case, I wondered why a “.git” folder was accessible through https but not over http. The reason was, I was serving http directly and that was following the rules of my lighttpd, but https was being served by nginx and bypassing lighttpd rules.

So, if we do this, we need to ensure that all policies in the secondary webserver must be copied to the primary (the reverse proxy).

A lot of wrong things can happen on this kind of setup, for example the attacker could get access to the secret_key that is ciphering our cookies, and with that he may be able to create cookies at will, spoofing any user, even admins, and get access to any part of the website.

So, what we can do about it?

Copying rules

One of the common approaches would be to copy the rules of your Apache to Nginx or whatever server it is.

The main problem here is usually we are mixing two different webservers together. If they were both Apache, probably the reverse proxy would be smart enough to follow the same “.htaccess” files properly. But that’s not the usual case.

We have to rewrite the rules from scratch on the upper server. So for example, to avoid serving hidden files and folders we could setup:

location ~ /\.well-known {
allow all;
}
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}

This would take care of all hidden files except for the “.well-known” folder used by let’s encrypt project.

Don’t bypass and cache

Another simpler approach would be to entirely avoid bypassing the secondary server. The problem is speed; usually we do this for increasing speed because Nginx is several times faster than Apache. Calling Apache for each request through Nginx is going to be slower than not having Nginx at all.

Caching can be a good intermediate solution. With caching we can serve content in a quick manner but still ask Apache for the resources from time to time, to be sure that Apache is allowing access to it.

It could look like the following code:

proxy_cache_path /cache/php-apache-1 levels=1:2
keys_zone=phpapache1_cache:10m max_size=10g
inactive=600m use_temp_path=off;

server {
 location / {
proxy_cache phpapache1_cache;
add_header X-Cache-Status $upstream_cache_status;
proxy_set_header X-Forwarded-For $remote_addr;
  proxy_pass http://php-apache-1;
}
}

This will solve the security issues while still being fast. As an improvement, it also caches dynamic pages by looking at the headers, so your applications can tell Nginx to cache just by sending some headers; by default they will not be cached as they should be sending the appropriate headers to forbid it.

The downside in security here is, if your application misbehaves sending caching headers; before this the user could get the same response twice as the browser doesn’t want to refresh. After this change, the same resource will be shared across users; while this is a huge performance benefit, it can also share private data between them in an unexpected way. Beware of this and test your site to be sure it contains the sensible headers for each case, or include a “Cache-Control: private” header in each request to behave as before, or “Cache-Control: no-cache” to forbid caching at all.

There is also a downside in performance as well. Your static data is going to be stored twice; once in their main location, and then again on the cache. This is both a waste of hard disk space and memory. On disk space I think it’s obvious why, and most of us would not care much as long as we have more than enough to fit the data.

But on memory, why it will also consume more memory? Aside of the memory used by the caching system itself, which is not concerning, the operating system does cache the files recently read on memory. If you have enough memory and a small set of accessed files they tend to mostly tend to be cached and thus, read from memory instead of disk, which is thousands times faster. But, if you have your own caching layer on top which is also reading and writting to its own files, then those files are being cached twice on memory; and Memory is scarcer than disk. So probably your server will struggle twice as much to get the data cached. This means that some sites will be working slower because now they aren’t fitting on your OS cache anymore.

Hopefully your operating system will learn quicky than the cache layer is being read very often, and the actual source of the static files is being read rarely, so it should push out the latter in favour of the most frequent accesses. So hopes are this will not impact as much. Just if you try to access directly the underlying web server (for example, like via a exposed port 8080) you will see that static content is now served way slower on the first try.

Mixing both approaches

So you want more and you wonder if there’s a way to get the best of two worlds. So do I.

Yes, it is possible. Most websites hold their static data or public data in certain folders. So it is easy to setup these folders to be served directly from Nginx and bypass both the cache and Apache:

proxy_cache_path /cache/php-apache-1 levels=1:2
keys_zone=phpapache1_cache:10m max_size=1g
inactive=600m use_temp_path=off;

server {
 location /static {
try_files $uri $uri/ =404;
 }
 location / {
proxy_cache phpapache1_cache;
add_header X-Cache-Status $upstream_cache_status;
proxy_set_header X-Forwarded-For $remote_addr;
  proxy_pass http://php-apache-1;
}
}

With just that, we can avoid populating the cache with so much data, so 90% of that size can served without having to store it on the cache. The remaining, static or dynamic, is up to the secondary server to decide if it should be served or not.

There is a catch with this, the seondary could have rules for forbidding access to particular file extensions as “.php” or “.htaccess”. If you know the extensions that you’re supposed to serve, then you can change that location block to:

 location ~* /static/.+\.(jpe?g|png|css|js|gif|pdf) {     
try_files $uri $uri/ =404;
 }

If the user tries one of these file extensions on /static/ and it’s not found, a 404 will be returned. But if it tries a different file extension, then it will be still forwarded to Apache, so it will follow Apache rules.

I would discourage to do this site-wide, unless you know for sure that those extensions are always meant to be served anyone regardless on which location are stored.

But anyway, this last solution seems to be both fast and secure, given your secondary webserver is taking proper care of the access restrictions.

Hope you find this useful!

Servidores web más seguros con Docker (IV)

Este artículo es parte de una serie donde empecé explicando porqué Docker puede mejorar mucho la seguridad en servidores web multi-tenant y de ahí inicié el tutorial de cómo hacerlo.

En esta entrega voy a abarcar cómo servir SFTP y FTP desde Docker. En el caso de SFTP (recomendado), Docker simplifica mucho la instalación y es bastante seguro. En el caso de FTP (inseguro per-sé) termina siendo un poco coñazo y la utilidad real de Docker en este caso se ve relegada a poder apagar los contenedores FTP cuando no están en uso.

Adicionalmente, veremos cómo usar la caché de Nginx para acelerar PHP+Apache. Es una opción muy recomendable, porque solo con agregar las cabeceras HTTP adecuadas desde PHP podemos hacer una caché ultrarrápida.

Y para acabar, remataremos con una explicación de cómo usar docker compose en producción; cuales son los comandos típicos, cómo levantar los servicios, etcétera.

Servidor SFTP

A la hora de que los clientes se suban los ficheros lo mejor es que sea vía SFTP. Encontré una imagen en el Docker Hub que parece que es ideal para esto:

https://hub.docker.com/r/atmoz/sftp/

Viene compilada por Docker desde Github, el Dockerfile que tiene es realmente sencillo y la verdad la veo bastante segura. Así que la usaremos directamente.

Tiene tres formas de pasar los usuarios y las contraseñas. Por línea de comandos (argumentos), variables de entorno o fichero. Lo haremos por comando, ya que normalmente sólo habilitaremos un usuario por dominio.

Vamos al lío, a editar docker-compose.yml:

sftp-php-apache-1:
image: atmoz/sftp:debian-stretch
volumes:
- ./www/php-apache-1:/home/admin/php-apache-1
ports:
- "2201:22"
command: admin:passwordadmin:1000

Aquí lo único a resaltar es el UID. Tiene que coincidir con el ID de usuario que tiene los ficheros. No lo asignéis al UID 1, que es root! eso sería un desastre en seguridad. En el ejemplo os he puesto UID 1000 que es el primer usuario interactivo en la mayoría de distribuciones Linux, por lo que os debería funcionar tal cual. De todos modos revisad vuestro fichero /etc/passwd para comprobar cual es vuestro UID y aseguraos que es el mismo número. De lo contrario no podrá escribir.

Para probar, nos conectamos desde filezilla y funciona a la maravilla. Aquí agrego una captura para que veáis que he subido algunos ficheros:

Y borrar también funciona. Excelente. Desde luego no le puedo pedir más. Eso sí, las contraseñas se guardan en claro en docker-compose.yml, esto os puede suponer un problema. Normalmente quien tiene acceso al servidor como administrador tiene acceso a las contraseñas; pero si usáis un repositorio GIT igual no es buena idea ir haciendo commit de las contraseñas.

De momento ya lo tenemos arreglado. El código lo tenéis aquí:

https://github.com/deavid/docker-lamp/tree/v0.8

Como regalo, os he agregado un ejemplo de un segundo contenedor SFTP. Creo que no necesita mayor explicación, ¿verdad?

Servidor FTP

Si nuestros clientes requieren de un servidor FTP, podemos crear uno por web también, o sólo en aquellas que sea necesario. La imagen que voy a usar es la siguiente:

https://hub.docker.com/r/stilliard/pure-ftpd/

Si os tomáis el tiempo de leer el README de la página veréis que todo viene muy bien descrito y debería ser sencillo iniciar un contenedor:

https://github.com/stilliard/docker-pure-ftpd/blob/master/docker-compose.yml

ftpd-php-apache-1:
image: stilliard/pure-ftpd
ports:
- "2101:21"
- "21010-21019:21010-21019"
expose:
- "21010-21019"
volumes:
- ./www/php-apache-1:/var/www/html/
environment:
PUBLICHOST: "localhost" # Replace this with your server public IP
FTP_USER_NAME: admin
FTP_USER_PASS: adminpass
FTP_USER_HOME: /var/www/html/
FTP_PASSIVE_PORTS: "21010:21019"

Con esto ya funciona. El resultado es casi idéntico al anterior, pero sin la carpeta extra.

Hay varios problemas con las FTP. Por ejemplo, la mayoría de clientes FTP se conectan en modo pasivo (PASV) y esto hace que el servidor tenga que decidir un puerto nuevo para seguir la conexión. Esto provoca que el servidor no tenga que abrir un solo puerto, sino un segundo rango adicional para las conexiones pasivas (se usan para subir y descargar ficheros). Esto hace que la configuración sea algo más pesada y tengamos que especificar el rango de puertos, que además tiene que coincidir tanto en el contenedor como en el anfitrión.

Para complicarlo más, por culpa de cómo funciona el modo pasivo, tenemos que especificar qué nombre de host usará el cliente FTP al conectarse. Entonces si nuestro servidor tiene que accederse como “example.com” tendréis que reemplazar PUBLIC_HOST con este valor, y ya no podréis subir ficheros al conectaros como “localhost”.

El último problema que tiene esta FTP es que no loguea a la consola sino al syslog interno. Esto hace que desde docker compose no veamos ningún log de error de ningún tipo. Para corregir ésto habría que modificar la imagen o usar otra distinta. O directamente otro servidor FTP distinto; pero no he encontrado mejores imágenes.

Hay que tener en cuenta que ésta imagen crea volumenes de docker para almacenar certificados y las cuentas de usuario. Una vez iniciado las configura y luego ya no se sobreescriben (como en la instalación de PostgreSQL o MySQL). Así que si queréis cambiar la contraseña más tarde tendréis que o tirar de comando o regenerar el contenedor y los volúmenes como expliqué en el artículo anterior.

También tengo que decir que no es mucho más seguro que una FTP convencional, el protocolo está muy anticuado y lo mejor sería limitar el acceso en la medida de lo posible.

A tener en cuenta: Esta configuración no habilita TLS/SSL. Esta sería la configuración más sencilla posible, pero también la más insegura. En el próximo artículo explicaré cómo habilitar TLS, lo que se llamaría “FTPS”. No recomiendo en absoluto correr esta configuración en producción.

Caché desde Nginx

Nginx tiene un módulo de caché muy potente que es útil para acelerar sobretodo el contenido estático. O en el caso de contenido dinámico, siempre y cuando no esté personalizado por usuario, también se puede cachear con unas mejoras de velocidad muy grandes.

Nginx es mucho más rápido que Apache a la hora de servir contenido estático. Y no es que lo diga yo, es que todos los benchmarks apuntan a que Nginx es tremendamente más rápido. Por eso, en el artículo anterior habilité para que Nginx sirviera el contenido estático directamente cuando era posible.

Como ya vengo diciendo, tiene un módulo de caché; éste es tan rápido como cuando sirve contenido estático. No es más rápido que servir contenido estático con él, así que no tiene sentido habilitarlo para las URL que puede servir directamente.

Para probar esto con los ejemplos que tenemos hechos hasta ahora, el mejor candidato parece ser PgAdmin4. La imagen de PhpMyAdmin ya está funcionando internamente sobre Nginx y FastCGI, por lo que mejorar su velocidad va a requerir de configuración a medida. La de PgAdmin4 en cambio viene con un servidor Python puro incluso para los estáticos y es ideal para demostrar la técnica de caching en Nginx.

Modificaremos pgadmin4.conf (la configuración de nginx para este virtualhost) del siguiente modo:

proxy_cache_path /cache/pgadmin4 
levels=1:2 keys_zone=pgadmin4_cache:10m
max_size=1g inactive=60m use_temp_path=off;
server {
listen 85;
listen [::]:85;
server_name pgadmin4;
root /var/www/pgadmin4;
index index.html index.htm;
location / {
proxy_cache pgadmin4_cache;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://pgadmin4:5050;
}
}

Como veis, sólo hay dos pequeños bloques que he agregado. Al principio del fichero donde definimos dónde va a guardar la caché, los tamaños en disco y memoria y la caducidad. Dentro del bloque location he agregado la sentencia “proxy_cache” donde le digo qué nombre de caché tiene que usar (definido el la segunda línea del fichero)

Pero necesitamos crear ésta carpeta de caché. Mi solución es agregar un punto de montaje, para que podamos acceder a los ficheros de caché desde el servidor. No para poder leerlos, ya que van en un formato que no se pueden leer fácilmente; sino para poder borrarlos. Si jugamos con las cachés y nos equivocamos, a veces lo más sencillo es borrarla completamente.

Entonces, agregamos a services/master-webserver/volumes de nuestro docker-compose.yml la siguiente entrada:

 - ./master-webserver/cache:/cache

Esto creará la carpeta tanto en el anfitrión como en el contenedor. En lugar de especificar un punto de montaje por caché, especificamos uno genérico para todos. Nginx puede crear la última carpeta automáticamente, de este modo no necesitamos ir agregando puntos de montaje por web.

Reiniciamos los servicios y éste es el resultado:

PgAdmin4 sin caché:


PgAdmin4 con caché:

Como podéis apreciar, hay una serie de peticiones que ahora funcionan más rápido. En consecuencia, la web carga unos 400ms más rápido.

No es mucho, considerando que el total son 2.6 segundos de carga. Por lo que se aprecia, hay algunos ficheros javascript que no parecen usar la caché correctamente. El problema es, ¿está realmente llamando al contenedor de PgAdmin4 o usando la caché? Los logs deberían indicarlo pero no imprime las peticiones para el contenedor de PgAdmin4. La solución es cambiar la imagen para habilitar la depuración.

Para ello, agregamos una línea extra a pgadmin4/docker-entrypoint.sh que agregue una línea adicional a config_local.py antes de ejecutar el setup:

     file_env 'DEFAULT_USER' 'pgadmin4@pgadmin.org'
file_env 'DEFAULT_PASSWORD' 'admin'
export PGADMIN_SETUP_EMAIL=${DEFAULT_USER}
export PGADMIN_SETUP_PASSWORD=${DEFAULT_PASSWORD}
# <<< Agregar la siguiente linea
echo "CONSOLE_LOG_LEVEL = logging.INFO" >> config_local.py
# >>>
python setup.py

Ahora recompilamos la imagen:

$ docker-compose build pgadmin4

Al iniciar ahora los contenedores veremos un montón de logs de pgadmin4, cuando termine de cargar podremos comprobar que, efectivamente, imprime las peticiones recibidas:


Se puede observar que ya hay unas cuantas peticiones donde pgadmin4 no es consultado para devolver la petición. Pero los ficheros javascript están siendo consultados cada vez.

Otra medida adicional para inspeccionar qué está pasando es decirle a Nginx que agregue una cabecera HTTP con el estado de la caché. Simplemente en el apartado “location / {” agregamos:

add_header X-Cache-Status $upstream_cache_status;

Ahora se puede ver claramente lo que está pasando. Algunos de los recursos carecen de cabeceras “Expires” y no se almacenan. Nginx sigue las directivas especificadas en las cabeceras HTTP devueltas por Pgadmin4. Si no le dice que el fichero expira en un tiempo, Nginx asume (correctamente) que no debe almacenarlo.

Le podemos especificar a Nginx que los ficheros de recursos como css, javascript e imágenes los puede cachear al menos 15 minutos. Para ello, agregamos el siguiente bloque location:

 location ~* \.(?:ico|css|js|gif|jpe?g|png) {
proxy_cache pgadmin4_cache;
add_header X-Cache-Status $upstream_cache_status;
proxy_cache_valid 15m;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://pgadmin4:5050;
}

Como se puede ver, es exactamente igual al anterior, solo agregando la clave “proxy_cache_valid”. Ahora al repetir la prueba vemos que el gráfico waterfall es mucho mejor:

Para estas peticiones (estoy filtrando sólo ciertos tipos de contenido) ahora es muchísimo más rápido. Le hemos sacado 100ms al tiempo de carga. Pero como hace otras peticiones de otros tipos, sigue tardando 2.5 segundos.

Filtrando por otras dos categorías (Doc & XHR) vemos dónde está el tiempo ahora:

Si inspeccionamos lo que devuelve la url “browser/” vemos que es un fichero html+javascript genérico. Tendría sentido cachearlo. El problema aquí es que reconoce si estamos logueados y si no es el caso nos redirige a la página de login. Cachear este recurso puede confundir usuarios a los que les haya caducado la sesión.

La url “get_all” es completamente dinámica, pero lo que devuelve son las preferencias. Si las preferencias no cambian habitualmente, se puede intentar cachear. Al igual que la anterior, va a romper un poco el funcionamiento de PgAdmin4; en este caso, si alguien cambia las preferencias va a quedar confundido porque no ve los cambios aplicados hasta pasado un tiempo.

Supongamos que nos da igual todo y que queremos sacar el máximo partido. Pues agregamos otro bloque location para estos dos recursos y los cacheamos otros 15 minutos. Agregamos el siguiente bloque location:

location ~* (browser/|preferences/get_all)$ {
proxy_cache pgadmin4_cache;
add_header X-Cache-Status $upstream_cache_status;
proxy_cache_valid 15m;
proxy_ignore_headers Cache-Control Expires Set-Cookie;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://pgadmin4:5050;
}

En este caso nos toca agregar “proxy_ignore_headers”, porque PgAdmin4 insiste en el caso de “get_all” que no debería ser cacheado, pero nosotros queremos hacer caso omiso. En el caso de “browser/”, envía una cookie y por defecto Nginx rechaza cachearlas porque enviaría la misma cookie a todos, provocando que todos los usuarios se logueen como el mismo. En este caso, al inspeccionar la cookie vemos que en realidad es una cookie bastante genérica, para que el usuario se configure qué idioma tiene, o en que Path del árbol tiene que abrir. Igual no nos importa que esto se confunda entre usuarios; tampoco es que sea crítico. Unos usuarios intentarán cambiar de idioma y lo perderán al refrescar, otras veces, el cambio pasará a la caché y afectará a todo el mundo.

Pero para esta prueba de velocidad nos da igual. Vamos a ver cómo queda:

Hemos bajado de 2.5 segundos a 0.4! Impresionante, ¿verdad? Además los tiempos de carga ahora vienen dados por lo que tarda el navegador en interpretar el Javascript; así que el servidor está recibiendo una carga de CPU ínfima. Podríamos servir la web a miles de usuarios a la vez y nuestro servidor ni se enteraría.

Por supuesto, estos dos últimos ejemplos se alejan bastante de la realidad. En producción no queremos que los sitios web funcionen mal o inconsistentemente. Hay que ir con cuidado cuando se habilitan estas prácticas porque por un lado el rendimiento es fenomenal, pero por otro podríamos estar compartiendo datos privados de un usuario con otros; o incluso hacerlos públicos. Hay que tener en cuenta que para acceder a la caché no hay que loguearse, Nginx la sirve a cualquiera que la pida. Esto supone un riesgo de seguridad muy grande cuando le decimos a Nginx que ignore las cabeceras http y que cachee igualmente. Hay que saber bien lo que hacemos antes de llegar tan lejos y probar los distintos escenarios concienzudamente.

De cualquier modo con la caché básica activada podemos acelerar muchas partes de una web; y si estamos programando nosotros la web podemos sacar partido de ésto, le podemos ordenar a Nginx que cachee ciertas URL dinámicas que sabemos que se pueden cachear, o a lo mejor hemos programado la aplicación de forma que está preparada para recibir contenido antiguo.

Hay muchas más técnicas, las cubrí con detalle en la serie Diseñando una web Ultra-rápida. Dadle un vistazo si estáis interesados en técnicas avanzadas de caché.

El código fuente para éste capítulo lo tenéis aquí:

https://github.com/deavid/docker-lamp/tree/v0.10

Docker Compose como servicio

Realizar “docker-compose up” está muy bien para desarrollo local, pero dejar una consola abierta cada vez no es adecuado. Además, cada vez que hay un cambio y cerramos, se cierran todos los contenedores a la vez causando una caída general del servicio.

Para empezar usaríamos la opción “-d” (detach) para que los servicios corran en segundo plano (docker-compose up -d), con esto los contenedores se iniciarán y aunque cerremos la consola seguirán activos. Lo que, lógicamente, no vemos la salida de éstos. Para eso usaremos “docker-compose logs -f”, que al igual que tail, soporta la opción “-f” para que continúe emitiendo líneas según se producen.

Con los contenedores funcionando, si realizamos cualquier cambio y ejecutamos de nuevo “docker-compose up -d” se encargará de descubrir cuales han cambiado y reemplazarlos.

Si queremos ver los logs sólo de un par de contenedores a la vez, podemos agregar sus nombres al final del comando: docker-compose logs -f master-webserver php-apache-1

Si hemos cambiado algún Dockerfile, seguramente “docker-compose up” no sea suficiente. Es mejor ejecutar antes “docker-compose build”.

Para que se inicien con el arranque del sistema es muy sencillo, sólo hay que agregar “restart: always” a cada servicio en el fichero docker-compose.yml (tenéis el ejemplo en GitHub)

Si queréis depurar un servicio en particular, podéis hacer “docker-compose up python-uwsgi-1”. Si está en marcha, se conectará a él. Si no lo está, lo pondrá en marcha. Y al hacer Ctrl-C dentendrá sólo ese servicio.

Si queréis parar un contenedor: “docker-compose stop python-uwsgi-1”

Para pararlos todos, omitid el nombre del contenedor.

Si queréis borrar las imágenes y los volúmenes: “docker-compose down” (–help para más detalles)

Si queréis iniciar los servicios de nuevo sin comprobar actualizaciones: “docker-compose start”

Podéis aprender más en el siguiente enlace:

https://docs.docker.com/compose/production/

Los cambios para el arranque automático los podéis ver aquí:

https://github.com/deavid/docker-lamp/tree/v0.11

Conclusión

Aunque los servidores SFTP y FTP son muy convienientes, son un riesgo de seguridad alto. La solución presentada aquí no es lo suficientemente segura; por simplicidad se han omitido muchas cosas. En el próximo artículo atajaré estos problemas para que, dentro de lo posible, sean seguros.

En cuanto a la caché de Nginx, tiene infinitas posibilidades y recomiendo visitar mis otros artículos sobre el tema. Como siempre, hay que aprender mucho y aplicarlo con cuidado ya que podemos revelar información privada accidentalmente.

La administración de docker compose es muy agradecida; ciertamente es la primera vez que veo que el arrancar un servicio del sistema al inicio de la máquina es tan sencillo.

Si llegaste hasta aquí y le ves mucha utilidad, recomiendo encarecidamente que leas el siguiente artículo de ésta serie, donde reforzaremos la seguridad todo lo posible. Va a ser más complicado, pero no tiene mucho sentido agregar Docker si al final todo queda casi igual de vulnerable que sin él.

Servidores web más seguros con Docker (III)

Seguimos con la serie de Docker para web. El primer artículo trató del razonamiento de la seguridad que Docker agrega a un servidor LAMP. En el segundo (el anterior) vimos paso a paso cómo usar Docker Compose para levantar un servidor web maestro nginx y conectarlo a un contenedor PHP con Apache.

En este artículo voy a seguir con el tutorial, tratando de avanzar un poco más rápido. Empezamos desde donde nos quedamos, aquí puedes descargar el código:

https://github.com/deavid/docker-lamp/tree/v0.2

Agregando MySQL y PhpMyAdmin

Ningún servidor LAMP se podría llamar así si no tuviese un servidor de base de datos MySQL. Y por supuesto, el administrador favorito de todos, PhpMyAdmin.

Para agregar MySQL basta con agregar otro servicio a nuestro docker-compose.yml. Lo llamaré mysql-1 y apuntará a la imagen “mysql:8.0”. También crearemos una carpeta mysql-1 para dejar los datos relacionados con éste contenedor.

Éste sería el bloque de docker-compose.yml que hay que agregar:

  mysql-1:
image: mysql:8.0
command: --default-authentication-plugin=mysql_native_password
volumes:
- ./mysql-1/datadir:/var/lib/mysql
ports:
- "33061:3306"
environment:
- MYSQL_ROOT_PASSWORD

He seleccionado mysql:8.0 porque es la última versión y seleccioné hasta la versión menor, para que no se actualice y no dé problemas más tarde en versiones mejores; pero la revisión exacta no está seleccionada para que se pueda actualizar en caso de corrección de bugs.

La línea “command:” agrega parámetros a la línea de comando. En este caso seleccionamos por como plugin de autenticación por defecto “mysql_native_password”, que es cómo los clientes negocian la contraseña con el servidor MySQL. En este caso estoy seleccionando el método clásico, que es inseguro. La razón es que, esto fue cambiado hace muy poco y la mayoría de sistemas no reconocen el sistema nuevo. Dentro de un año o dos, seguramente esto se pueda eliminar y usar el nuevo sistema en todos los clientes. De momento es necesario para conectar desde PHP.

También tengo que decir que han tardado muchísimo en realizar este cambio. MySQL es antiguo y no entiendo porqué hasta ahora no usaban “salts” en las contraseñas; es algo bastante básico. Aún así, la nueva autenticación deja que desear, porque hubieran podido usar HMAC y/o Bcrypt para mejorar notablemente la seguridad. Además, se preocupan demasiado con la velocidad de login, dando menos seguridad en favor de velocidad.

En volumes, igual en el artículo anterior, enlazamos la carpeta de datos de mysql a una carpeta local. Esto principalmente sirve para que los datos de la base de datos estén fuera del contenedor, y si éste se borra, los datos los seguimos teniendo nosotros.

En puertos, veréis que he agregado un enrutado de ejemplo. Esto os puede servir para inspeccionar la base de datos desde el anfitrión. En producción, este enrutado de puertos habría que borrarlo.

He agregado una clave environment. Esto permite que si la variable de entorno MYSQL_ROOT_PASSWORD está definida, sea copiada al contenedor de MySQL. En este caso particular lo hago para poder crear el servidor por primera vez. Cuando MySQL se instala, tiene que crear un usuario root y darle una contraseña. Para ejecutar MySQL las siguientes veces, como la carpeta “datadir” ya estará creada y con datos, no es necesario especificar ningún password. De hecho esta variable sólo tiene efecto en el primer arranque.

Cuando iniciéis docker compose la primera vez, lo hacéis así:

$ MYSQL_ROOT_PASSWORD=mipassword docker-compose up

Esto creará los datos de MySQL y le dará al usuario root la contraseña “mipassword”. Obviamente cambiad la contraseña por una de vuestra elección.

Una vez con el servidor en marcha podéis probar a conectaros a MySQL usando el programa que prefiráis, simplemente usad la IP pública del equipo y el puerto 33061.

Ahora agregaremos phpmyadmin. Para ello usaremos la imagen de Docker Hub phpmyadmin/phpmyadmin:4.7 que podéis ver en este enlace:

https://hub.docker.com/r/phpmyadmin/phpmyadmin

No es una imagen oficial de Docker ni verificada. En este caso la mejor opción está en las imagenes que provee PhpMyAdmin. Como podéis ver está compilada directamente desde GitHub. No es que sea de máxima confianza, pero sigue estando dentro de las más fiables. Si quisiéramos hacerlo bien, habría que generar nuestra propia imagen de phpmyadmin, pero no es algo que vaya a cubrir aquí.

Agregaríamos lo siguiente al fichero de docker compose:

  phpmyadmin:
image: phpmyadmin/phpmyadmin:4.7
environment:
- PMA_HOST=mysql-1
ports:
- 8081:80
volumes:
- /sessions

Al igual que antes, la versión seleccionada es estable y no se actualiza. En environment pasamos el nombre del servidor de MySQL que queremos que se conecte, y enrutamos el puerto 80 al que queramos de nuestro anfitrión, en este caso, 8081.

En volumes, en este caso hay algo especial. Sólo hay una ruta. Esto es un volumen de docker sin punto de montaje. Creará un contenedor vacío para datos de sesión de usuario. Es un término medio entre tener un punto de montaje y no tener nada. Como crea un contenedor separado, aunque el contenedor de PhpMyAdmin se regenere o se actualice, las sesiones no se pierden. Pero no es fácil acceder a los datos de sesión sin usar comandos de docker. De todos modos, no necesitamos acceder a esos datos. Y en caso de que se pierdan, el único problema sería que las sesiones de usuario tendrían que hacer login de nuevo. Nada que nos preocupe.

Reiniciamos docker compose y abrimos la url de phpmyadmin:

Aquí ya podemos especificar el usuario y contraseña de root:

Como veis, funciona sin problemas.

Ahora agregamos proxy_pass a nuestro Nginx para enrutarlo a phpmyadmin:

 location = /phpmyadmin {
return 301 $scheme://$http_host/phpmyadmin/;
}
location /phpmyadmin/ {
rewrite /phpmyadmin/?(.*) /$1 break;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://phpmyadmin/;
}

En el artículo anterior ya expliqué cómo funcionan éstos enrutados.

Ahora deberíamos poder quitar todos los puertos enrutados innecesarios, ya que el servidor principal se encarga. Podéis comentar las líneas si queréis usando almohadilla (#)

Y con esto ya está! Ya tenemos nuestro primer servidor LAMP funcionando.

El código del resultado lo tenéis aquí:

https://github.com/deavid/docker-lamp/tree/v0.3

Configurando dominios en Nginx

Tener las distintas webs accesibles desde localhost está muy bien, pero en la vida real necesitan ser dominios o subdominios.

Crearemos una carpeta sites-enabled en master-webserver y dentro pondremos todas las configuraciones que tienen que ir en conf.d/; así en lugar de un punto de montaje por web, tenemos un sólo punto de montaje para todo. El fichero de configuración básico lo renombramos ya al nombre final, default.conf.

Duplicamos default.conf como php-apache-1.conf y lo arreglamos de la siguiente manera:

server {
server_name php-apache-1; # Nombre del dominio
root /var/www/php-apache-1;
index index.html index.htm index.php;
location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
location ~ .php$ {
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://php-apache-1;
}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
location ~ /.ht {
deny all;
}
}

En la carpeta www, vamos a hacer algo parecido. Creamos una carpeta html y movemos allí el index.html genérico que teníamos.

Arreglamos los puntos de montaje, y se nos quedan así:

  master-webserver:
image: nginx:1.14
volumes:
- ./master-webserver/sites-enabled:/etc/nginx/conf.d
- ./www:/var/www
ports:
- "85:80"

Con estos dos puntos de montaje ya podemos mover todas las webs que queramos. Si iniciamos “docker-compose up” ya debería funcionar correctamente.

Pero si accedemos a php-apache-1:85 desde el navegador no resuelve la DNS. Es lógico, porque no es un dominio que exista. En entornos reales cambiaremos el nombre del dominio por uno real; también podemos usar subdominios. Para que funcione localmente, hay que editar el fichero /etc/hosts (como root, por ejemplo “sudo nano /etc/hosts”) y agregar al final:

127.0.0.1       php-apache-1 

Ahora ya debería funcionar. Igual tienes que reiniciar el navegador, porque a veces cachean las DNS.

Si revisas la configuración verás que le hemos indicado que los ficheros estáticos los sirva Nginx, y que use el Apache interno sólo para PHP. Para probar esto agregaremos un “test.html” en la misma carpeta que el php y pondremos contenido de prueba:

<!doctype html>
<html>
<head>
<title>Test page</title>
</head>
<body>
<p>Probably served from Nginx</p>
</body>
</html>

Ahora revisamos desde la consola de docker-compose que al acceder a la raíz, se ejecuta index.php en el contenedor de apache pasando por el servidor maestro, pero cuando accedemos a test.html sólo se activa el servidor maestro y el contenedor de PHP no se activa:

Si es algo que os moleste, o por ejemplo en el caso de phpmyadmin no tenemos los datos accesibles, siempre podemos hacer que todo pase por el contenedor, al igual que antes. Entonces, para phpmyadmin podemos agregar esta configuración:

server {
server_name phpmyadmin;
root /var/www/phpmyadmin;
index index.html index.htm index.php;
location / {
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://phpmyadmin;
}
location ~ /.ht {
deny all;
}
}

Recordad que /etc/hosts hay que actualizarlo. En lugar de agregar otra línea también podéis usar una sola línea para varios dominios:

127.0.0.1       php-apache-1 phpmyadmin

Navegamos y vemos que funciona correctamente:

Personalmente prefiero que Nginx sirva los estáticos porque es mucho más rápido que apache; en el caso de ésta imagen de phpmyadmin no es sencillo de arreglar. Lo más fácil sería usar una imagen de php-apache como la anterior, y descomprimir phpmyadmin en la carpeta. Además no estamos expuestos a cualquier posible peligro con la imagen de un tercero.

Y como viene siendo habitual, aquí va el código:

https://github.com/deavid/docker-lamp/tree/v0.4

PostgreSQL y PgAdmin4

Hemos visto MySQL, pero mi base de datos favorita es PostgreSQL. Además, veamos cómo instalar PgAdmin4 que suele pedir algunas dependencias muy particulares y en Docker es hasta más sencillo que instalarlo directamente para algunos servidores.

Empezemos por postgres. Voy a coger la imagen oficial de Docker “postgres:11”

https://hub.docker.com/_/postgres/

El motivo por el que he seleccionado el tag 11 y no 11.1 es porque en PostgreSQL desde la version 10, la versión menor es la revisión, así que de 11.1 a cualquier 11.x posterior no puede romper nada.

Vamos a agregar el siguiente bloque para docker compose:

   postgresql-1:
image: postgres:11
volumes:
- ./postgresql-1/pgdata:/var/lib/postgresql/data/pgdata
environment:
- POSTGRES_PASSWORD
- PGDATA=/var/lib/postgresql/data/pgdata

La idea es igual que en el de mysql. Tenemos unas variables de entorno. POSTGRES_PASSWORD establece en el arranque inicial la contraseña del usuario “postgres” (el administrador). Con PGDATA le indicamos en qué carpeta queremos guardar los datos y luego montamos la carpeta para poder retener los datos.

Y al igual que en mysql, en el primer arranque especificamos la contraseña:

POSTGRES_PASSWORD=mipassword docker-compose up

Las siguientes veces ya no será necesario y un simple “docker-compose up” debería ser suficiente.

Ahora pasemos a ver cómo haríamos una imagen de PgAdmin4, al igual que con phpmyadmin vamos a usar otro contenedor y conectarlo a postgresql.

Para PgAdmin4 usaremos la imagen fenglc/pgadmin4:2-python3.6-stretch, tenéis el enlace aquí:

https://hub.docker.com/r/fenglc/pgadmin4

No hay imágenes mejores por lo que he podido ver. Ésta no está compilada por Docker, pero al menos tiene un repositorio de GitHub. He revisado el Dockerfile y tiene muy buen aspecto. Yo recomendaría “copiar” el Dockerfile en vez de usar la imagen porque no hay garantías reales de que la imagen sea “tal cual” la compilación; alguien podría subir una variación con malware sin actualizar GitHub.

Descargaremos el Dockerfile y lo copiaremos a pgadmin4/Dockerfile:

https://github.com/fenglc/dockercloud-pgadmin4/blob/5b825889a95a2707456aa4c18a5f94a689d7a8eb/python3.6/stretch/Dockerfile

Vamos a copiar “docker-entrypoint.sh” a la misma carpeta:

https://raw.githubusercontent.com/fenglc/dockercloud-pgadmin4/5b825889a95a2707456aa4c18a5f94a689d7a8eb/python3.6/stretch/docker-entrypoint.sh

MUY IMPORTANTE: Hay que agregar permisos de ejecución a docker-entrypoint.sh o fallará la compilación de la imagen.

Ahora toca actualizar nuestro fichero compose para que compile la imagen.

   pgadmin4:
build: ./pgadmin4
ports:
- "5050:5050"
environment:
- DEFAULT_USER=postgres
- DEFAULT_PASSWORD

Como veis, es casi igual que antes, en lugar de especificar una imagen que ya existe, le decimos que compile desde nuestra carpeta pgadmin4. Docker espera que haya un fichero “Dockerfile” allí para seguir las instrucciones, que es el que hemos copiado antes. El resto es el enrutado de puertos, que en este caso es el 5050 y las variables de entorno para la instalación. Como en los anteriores, especificaremos el password que queramos en el primer arranque.

DEFAULT_PASSWORD=mipassword docker-compose up

Cuando lo ejecutéis por primera vez, va a tardar un poco, ya que tiene que levantar una serie de contenedores y empezar a instalarlo todo por pasos. Esto terminará con la generación de la imagen y a partir de ahí esa imagen se puede reusar.

Los datos del contenedor parecen estar en un contenedor/volumen separado montado en /var/lib/pgadmin para que si lo regeneramos no perdamos la configuración que hayamos hecho hasta el momento.

Si queréis borrar el contenedor, por ejemplo porque habéis olvidado pasar la contraseña, lo mejor es usar “docker-compose rm pgadmin4” seguido de “docker volume purge”:

Cuando volváis a iniciar docker compose no re-generará la imagen, será rápido. Pero el contenedor y el volumen será nuevo.

Accedemos a localhost:5050 y deberíamos poder hacer login. Agregamos la conexión al servidor (usando postgresql-1 como nombre de host). No recomiendo guardar la contraseña, quedaría guardada en claro en el volumen. Al final veremos algo así:


Vemos que nos advierte de que hay una versión más nueva disponible. Si seguimos el enlace y vamos a buscar Python Wheel, nos lleva al siguiente enlace:

https://www.postgresql.org/ftp/pgadmin/pgadmin4/v3.6/pip/

Podemos usar la URL al wheel para reemplazarla en el Dockerfile:

ENV PGADMIN4_VERSION 3.6
ENV PGADMIN4_DOWNLOAD_URL https://ftp.postgresql.org/pub/pgadmin/pgadmin4/v3.6/pip/pgadmin4-3.6-py2.py3-none-any.whl

Y ahora le podemos decir a docker compose que nos recompile la imagen:

$ docker-compose build pgadmin4

Otra vez, tardará un tanto ya que tiene que rehacer casi todos los pasos. Lo bueno es que la imagen inicial ya está descargada y preparada, por lo que es un paso menos.

Cuando termine, lanzamos otra vez “docker-compose up” y deberíamos poder ver ahora la nueva versión de PgAdmin4:

Se ha actualizado correctamente, me ha respetado los usuarios y la configuración.

Ahora ya, como ejercicio, podéis intentar enrutar pgadmin4 a través de Nginx a un dominio de vuestra elección. Lamentablemente, no se puede enrutar a una carpeta correctamente porque la imagen ejecuta el servidor web de python y no soporta una url base para decirle que está dentro de una carpeta. Esto se corregiría añadiendo wsgi, pero ni esta imagen, ni la oficial de PgAdmin4 llevan un servidor wsgi instalado.

NOTA: Al final lo mejor es que Nginx sirva en el mismo puerto que está expuesto en el servidor. Si es 85 externamente, que sea 85 internamente. Esto arregla el problema de las redirecciones y simplifica las configuraciones. Adicionalmente, hay que agregar “listen 85;” a los demás ficheros, porque de lo contrario Nginx cree que está sirviendo unas webs por el puerto 85 y otras por el 80.

El código de este capítulo lo tenéis aquí:

https://github.com/deavid/docker-lamp/tree/v0.5

FastCGI con PHP

Veamos un ejemplo de web con fastcgi (php). Nginx se encargará del contenido estático. Como ya comenté en un artículo anterior esta variante es más rápida que tener un Apache en el contenedor. Os permite desplegar tantas webs como queráis sin recursos extra. Lanzar un Apache por contenedor supone una cantidad de memoria que a lo mejor no tenéis.

No me enrollo más y vamos al tajo. La imagen que vamos a usar primero es la de PHP con FPM: php:7.3-fpm-stretch; como siempre elijo la que tiene la versión menor y tiene Debian Stretch como base.

Montamos la imagen como siempre, en el fichero de Docker Compose:

php-fpm-1:
image: php:7.3-fpm-stretch
volumes:
- ./www/php-fpm-1:/var/www/html/

Y copiamos la carpeta de www/php-apache-1 a www-php-fpm-1, ya que los ejemplos nos van a valer igual.

Ahora sí, exponer el puerto no sirve de nada. Lo que ésta imagen expone es un puerto para protocolo FastCGI, no se puede acceder desde el navegador. Además, este protocolo es inseguro por internet; ni se os ocurra exponer el puerto a cualquier IP, pues podrían ejecutar código que no debería. Este protocolo está pensado para conectar servidores web con PHP u otros lenguajes; nunca para que un usuario final o cliente se conecte a ellos.

Tenemos que modificar la configuración de Nginx para agregar la conexión, pero en lugar de proxy_pass esta vez es fastcgi_pass. El puerto es el 9000. Duplicamos el fichero de configuración de Nginx para php-apache-1 y lo guardamos como php-fpm-1.

Nos ayudaremos de la guía de oficial Nginx:

https://www.nginx.com/resources/wiki/start/topics/examples/phpfcgi/

Despues de adaptarlo queda como sigue:

 server {
listen 85;
listen [::]:85;
server_name php-fpm-1;
root /var/www/php-fpm-1;
index index.html index.htm index.php;
location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
if (!-f $document_root$fastcgi_script_name) {
return 404;
}
# Mitigate https://httpoxy.org/ vulnerabilities
fastcgi_param HTTP_PROXY "";

fastcgi_pass php-fpm-1:9000;
fastcgi_index index.php;

# include the fastcgi_param setting
include fastcgi_params;

fastcgi_param SCRIPT_FILENAME /var/www/html/$fastcgi_script_name;
}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
location ~ /\.ht {
deny all;
}
}

Mucho OJO con la variable SCRIPT_FILENAME. Esta tiene que ir a la ruta absoluta de disco del contenedor de destino. Sino se configura bien aparece “file not found” desde PHP.

Acordaos de agregar php-fpm-1 a el fichero hosts, y ya podéis acceder:

Si accedéis a test.html veréis que lo sirve Nginx sin pasar por PHP. Aquí es más importante que nunca que no paséis ficheros que no son PHP a PHP, porque los intentará interpretar.

La configuración es más avanzada que con Apache, pero el rendimiento vale mucho la pena.

Una última cosa antes de terminar este apartado: Tenemos un servicio (nginx) que depende de otros tantos para iniciarse correctamente. En docker compose, podemos usar la clave “depends_on” para determinar el orden de arranque:

master-webserver:
image: nginx:1.14
volumes:
- ./master-webserver/sites-enabled:/etc/nginx/conf.d
- ./www:/var/www
ports:
- "85:85"
depends_on:
- php-apache-1
- php-fpm-1
- pgadmin4
- phpmyadmin

Esto no cambia el resultado, pero eliminará algunos problemas de arranque más adelante.

Con esto, os dejo con el código fuente:

https://github.com/deavid/docker-lamp/tree/v0.6

uWSGI con Python

Con Python, el protocolo preferido es uWSGI. Es mejor, más sencillo y más rápido que FastCGI. En el caso de Python, sea con FastCGI o con uWSGI, hay que hacer una imagen a medida, ya que la imagen oficial de Docker no viene configurada para servir vía web.

Como referencia, vamos a seguir la guía oficial de uWSGI para python:

https://uwsgi-docs.readthedocs.io/en/latest/WSGIquickstart.html

Por lo tanto, lo primero que tendremos que hacer es compilarnos una imagen propia con nuestro Dockerfile a partir de la imagen oficial de Docker.

El Dockerfile más sencillo posible solo tiene una línea que sería “FROM” e indicaría de qué imagen vamos a heredar. Podríamos basarnos directamente en Debian Stretch, pero al ser estable lleva una versión de Python antigua (3.5). Si queremos la última release vamos a necesitar una instalación manual, y esto es lo que la imagen oficial de Docker hace por nosotros.

Agregaremos también la carpeta de trabajo por defecto como /usr/src/app y instalaremos uwsgi desde pip.

Creamos el nuevo Dockerfile en la carpeta /python-uwsgi:

FROM python:3.7-stretch

WORKDIR /usr/src/app

RUN pip install --no-cache-dir uwsgi

Agregamos la compilación de la imagen a docker-compose.yml:

python-uwsgi:
build: ./python-uwsgi
  image: lamp/python-uwsgi

Lanzamos “docker-compose build python-uwsgi” (o docker-compose up también nos sirve) y empezará a compilar la imagen.

Ahora ya tenemos nuestra imagen de python con uwsgi instalado. Ya deberíamos poder ejecutar scripts básicos con él.

Vamos a crear otra carpeta para el contenedor, la llamamos python-uwsgi-1 y incluiremos foobar.py, tal y como aparece en la guía que mencioné antes:

def application(env, start_response):
start_response('200 OK', [('Content-Type','text/html')])
return [b"Hello World"]

No es que sea muy dinámico, pero tampoco quiero complicarme con Django o Flask; cada uno tiene sus propias guías para uWSGI.

Ahora agregaremos lo necesario para crear un contenedor python-uwsgi-1; la idea es que si hay varios, la imagen es la misma, pero cada contenedor puede tener una configuración distinta. He usado una mezcla entre lo que aparece en la guía y mi experiencia usando Flask con uWSGI:

python-uwsgi-1:
image: lamp/python-uwsgi
depends_on:
- python-uwsgi
volumes:
- ./python-uwsgi-1/foobar.py:/usr/src/app/foobar.py
command:
- uwsgi
  - --uid
  - www-data # user to log in
- -p3 # number of python processes
- --lazy-apps # fork early (avoid sharing same DB connection)
- --uwsgi-socket
- 0.0.0.0:3031 # Listen on 3031
- -i # don't use multiple interpreters
- --py-autoreload
- "1" # watch the python file and reload
- --wsgi-file
- foobar.py # name of the python file to load

Si queréis limitar los logs, podéis lanzar “docker-compose up python-uwsgi-1” y os lanzará sólo de ese contenedor. Podéis observar como parece haber cargado correctamente:

Ahora vamos a enlazarlo a Nginx, a una carpeta. Así que vamos a editar default.conf y agregar:

location /python-uwsgi-1/ {
include uwsgi_params;
uwsgi_pass python-uwsgi-1:3031;
}

Iniciamos docker compose y navegamos a la ruta para comprobar:

Funciona correctamente. A partir de aquí lo podéis personalizar para correr la web que queráis. También se puede hacer con Ruby por si os interesa. Podéis descargar el código de este capítulo aquí:

https://github.com/deavid/docker-lamp/tree/v0.7

Conclusión

A estas alturas ya tenemos un servidor LAMP muy completo. Tiene además de los cuatro básicos, PostgreSQL, PgAdmin, PhpMyAdmin, PHP con FastCGI y Python con uWSGI.

En el próximo artículo veremos FTP, SFTP y cachés con Nginx.

Espero que os esté gustando. Cualquier duda, los comentarios están abiertos!

Sedice – me contactaron para decirme que encontraron una vulnerabilidad grave

No es la primera vez que me llega un aviso de una persona avisando de una vulnerabilidad en Sedice; pero cuando el 9 de Enero me llega este correo avisando sobre una vulnerabilidad a medianoche, ya no pude dormir.

Hello,
I am an independent security researcher and I have identified a security vulnerability on your website.
Could you please send me a direct contact email for the person that's responsible for your website's security / your IT department?
To demonstrate authenticity of this email, please see below for sample data that can be extracted through exploitation:
(...)
Although the data above may look foriegn to you, please forward this email to the relevant parties and they will be able to understand
I would like to follow the responsible disclosure process, so please get someone to email me back as soon as possible for further details.

Básicamente incluía datos de las credenciales de la web, usuario y contraseña de la base de datos y clave privada de cifrado de PHPNuke. Claramente había podido acceder al sistema de ficheros.

No me sorprendió, dado el software que corre la web, sé de sobra que es un colador. De hecho invertí muchísimas horas en el pasado en cortar de una forma u otra los ataques recibidos. Aún así, por supuesto, queda mucho abierto y es casi imposible de cerrar.

Como ya comenté en el foro, la única forma que hay de solucionarlo es rehacer la web por completo desde cero. En eso consiste el proyecto que empecé de www.sedice.xyz, pero tampoco le estoy viendo demasiado interés por los usuarios.

No podía dormir, así que me levanté y empecé a revisar todos los logs en busca del posible ataque. Empecé a ver mil cosas (que posiblemente siempre han estado) en los logs que no pintan nada bien; intentos de acceso a ficheros extraños, accessos a URL que no deberían funcionar pero funcionan, etc.

Detecté uno en particular que parecía alterar las cookies, y eso explicaría que pueda acceder. Me puse a revisar todos los avisos de seguridad que tiene PHPNuke y encontré el que parecía ser. Después de parchear por completo, le envío el siguiente correo:

Hi, I believe what you found is CVE-2007-1450
https://www.cvedetails.com/cve/CVE-2007-1450/

I patched it in my own way. Also I changed the sensible data.

Thanks again for the report. If you think the site is still vulnerable please let me know.

Pero no era eso. Y lo peor es que la contestación me sacaría los colores al día siguiente:

Hi there

It's possible to view all of your internal source code with the vulnerability, steal all your database credentials and potentially conduct a complete website takeover within a few minutes (including subdomains).

Vulnerability

(...)

In essence, it comes down to a permission misconfiguration.

When you clone a git repository, it ceates a folder for git's metadata - .git - in the folder where you checkout. This is usually what lets you do a simple 'git pull' to get new versions of your files.

By using the exposed metadata files, we can actually start obtaining objects:

(...)

Additionally, this then allow us to download internal files.
There are a few ways to prevent this:

(...)

There's a lot of data (not going to go through it - don't want to be intrusive).

I think it's fair to say that had a malicious attacker identified this vulnerability - it would have ended badly.

Although I've disclosed the vulnerability to you - would you be interested in issuing me with a bug bounty / monetary reward? I'm only able to continue doing this type of work if I receive monetary support / payments (see here: bugize.com for project details).

Básicamente me viene a decir que había un repositorio git que se podía acceder vía web, descargar todo el contenido, y luego al clonarlo en local se podía ver todo el código. Luego describe cómo solucionarlo; básicamente eliminarlo, moverlo más arriba del public_html o restringir el acceso cambiando las reglas del servidor web.

Como digo, avergonzante. No era nada que ver con PHPNuke, era un error mío de configuración años atrás. No tuve en cuenta cuando pensé en registrar los cambios en git que el repositorio se podría acceder.

Ha sido un error total de principiante. Registrar los cambios en GIT tiene muchas ventajas, como por ejemplo poder restaurar copias antiguas de ficheros o compararlos para ver si se han modificado inadvertidamente. Pero, dejar el repositorio dentro de public_html es simplemente una irresponsabilidad.

Filtrar el acceso vía configurar el servidor web no ha sido suficiente, días más tarde ví que seguía pudiéndose acceder vía https, ya que lo sirve otro servidor web distinto. Después de configurarlo todo, además me aseguré que ni el servidor web tenga acceso restringiendo los permisos de lectura para que ningún usuario de web pueda acceder a leer los ficheros; de modo que ni aunque el servidor web quiera leerlos, no va a poder.

Esos días fueron como una montaña rusa emocional; no sé ni cómo describirlo.

La lección que esto nos da, más allá de que hay que ir con cuidado con los repositorios de código o qué dejas dentro del public_html, para mí es que las vulnerabilidades a veces creemos que vienen del sofware que usamos, pero realmente las vulnerabilidades las creamos nosotros, las personas que tenemos acceso a los servidores. Si no vamos con máximo cuidado y pensamos bien qué implica lo que estamos haciendo, el desastre está asegurado.

Y no aparece ni hoy ni a los tres meses; normalmente las vulnerabilidades están allí, sin que nadie se dé cuenta durante años. Cuando nadie se acuerda ya de que “eso” estaba allí, entonces es cuando alguien lo encuentra y no hay forma alguna de ver ninguna relación causa-efecto (puse esto en el servidor y me hackearon).

A la gente que nos gusta el tema de la seguridad nos suelen mirar como paranóicos. Pero a la práctica, hay que serlo. Hay que tener bien controlado qué hay en el servidor y cual es su estado actual; que todo esté en perfectísimo orden. Y sedice en este caso desde el principio ha sido un completo descontrol, con mucha gente sin experiencia en seguridad accediendo al servidor; y el primer culpable yo mismo por no ser más precavido con lo que hago.

A todo esto, no puedo hacer más que agradecer a la gente de bugize.com que me reportó el problema, por lo que les donaré 200€ para que sigan así y sumaré lo que pueda recaudar desde Sedice en una semana o dos; que no creo que sea mucho, pero por dar una oportunidad a la gente de la web si quiere contribuir al trabajo que hacen.

En fin, no sé si habré aprendido la lección o no; solo el tiempo lo dirá. Igual dentro de 3 o 5 años me toca escribir otro post sobre algo similar con otra pifia mía. Espero que no!

Servidores web más seguros con Docker (II)

En la anterior entrada Servidores web más seguros con Docker, expliqué cómo debería ser una arquitectura de un servidor LAMP con Docker para hacerlo lo más seguro posible, aislando cada apartado de cada web lo más posible para limitar los daños que de un posible atacante.

En esta entrada quiero cubrir la instalación de la arquitectura propuesta, pero sólo la parte más básica, ya que en el artículo anterior cubrí todas las medidas posibles a tomar y la instalación sería realmente compleja.

Partiré de una instalación limpia de Debian 9.6 Stretch. Si ya tienes otras cosas instaladas en tu servidor no debería ser problema, igual hay algunos paquetes que ya tienes instalados. Si tu distribución no es Debian ni basada en ella (como CentOS o SuSE) la instalación de paquetes tendrás que adaptarla a tu distro, pero la mayoría de los pasos son los mismos.

Requisitos:

  • Servidor Linux (Windows, OSX y BSD también funcionan pero no los tengo en cuenta en este artículo)
  • Kernel para 64 bits 3.10 o superior
  • 2GB de RAM disponibles
  • 5GB de disco disponible
  • Distribución Linux suficientemente nueva como para correr Docker.
  • El servidor no puede estar dentro de un contenedor pero sí puede ser una máquina virtual; es decir, si es un VPS XEN o KVM funciona, pero si es un VPS con OpenVZ seguramente no funcione.

Como se puede ver los requisitos son muy básicos. Sobre si tu distribución es lo suficientemente reciente, si tiene menos de 5 años seguro que funciona. Si es más antigua, probablemente hayan guías para hacerla funcionar. En Debian desde la versión 7 (Wheezy), en Ubuntu desde la 14.04 (Trusty).

Preinstalación

En el caso de que sea una máquina nueva, aquí están los pasos que yo he seguido, muy básicos, para prepararla para la instalación real.

Si aún no tienes “sudo” habilitado en el servidor, haz login como root y ejecuta:

# adduser usuario sudo

Luego sal de la sesión y vuelve a entrar. Sudo debería funcionar ahora.

Lo primero que yo suelo hacer en un servidor es instalar aptitude. No es necesario, pero lo prefiero personalmente a apt:

$ sudo apt-get install aptitude

En el caso de que estés usando VirtualBox, es conveniente instalar las Guest additions. Sin ellas funciona bien, pero instalarlas agrega una mayor integración con el sistema anfitrión.

Monta el CD usando el menú:

Y copia el contenido a una carpeta dentro de tu $HOME:

$ mkdir vbox
$ cp /media/cdrom vbox/ -R

Necesitaremos los paquetes necesarios para poder compilar módulos para el kernel que está corriendo:

$ sudo aptitude install linux-headers-amd64 gcc make perl

Ahora ya debería funcionar el programa de instalación de las Guest Additions para Linux:

$ cd vbox
$ sudo ./VBoxLinuxAdditions.run

También habilita en el administrador de VirtualBox el compartir el portapapeles:

Y reinicia la máquina virtual. Ahora el portapapeles debería funcionar, así como el modo fluido.

Instalación

Vamos a seguir primero los pasos de Docker CE (Community Edition) para Debian:
https://docs.docker.com/install/linux/docker-ce/debian/

Agregamos los paquetes para APT sobre HTTPS:

$ sudo apt-get install apt-transport-https ca-certificates curl gnupg2 software-properties-common

Agregamos la clave GPG de Docker:

curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add -

Y agregamos el nuevo repositorio:

sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable"

Actualizamos el repositorio e instalamos docker-ce:

sudo apt-get update && sudo apt-get install docker-ce

Comprobamos si está correctamente instalado ejecutando el hello world de Docker:

sudo docker run hello-world

Este debería ser el resultado (o similar):

Instalaremos también docker-compose:

sudo apt-get install docker-compose

Para comprobar que esté instalado, simplemente ejecutamos “docker-compose” y deberíamos ver esto: (la salida es mucho más larga, sólo pongo las primeras líneas en la captura)

Y con esto, tenemos el servidor listo y funcionando.

Configuración inicial

Vamos a empezar con un fichero de docker compose que nos inicie un Nginx y más adelante agregaremos un contenedor de php-apache y los conectaremos vía túnel proxy.

Como el contenedor de Nginx vamos a exponerlo al puerto 80, hay que vigilar que no tengamos otro servicio activo en el mismo puerto. Podemos usar el siguiente comando para inspeccionar si hay algo escuchando en el puerto 80:

sudo lsof -i -nP | grep LISTEN | grep :80

Si devuelve algo, es que tenéis un servidor web u otro programa escuchando en el puerto y no podréis exponer el puerto de NGINX al puerto 80. La solución sería desinstalar el programa que esté activo en el puerto o reconfigurarlo para que use otro puerto.

Pero para facilitar el testeo, en la guía en vez del puerto 80 voy a usar el 85. Así, si tenéis algo sobre el puerto 80 que sea importante no hace falta que lo quitéis. Cuando queráis usar los contenedores como servidor web principal sólo tenéis que cambiar donde veáis 85 a 80 y reiniciar docker compose.

Creando nuestro primer fichero para docker compose

Creo que estamos listos para empezar de verdad. Vamos a empezar con una carpeta vacía y agregar un fichero llamado “docker-compose.yml”. Este fichero es el que “docker-compose up” leerá (cuando estemos desde la misma carpeta en la consola) y seguirá sus instrucciones.

Nuestra primera versión contendrá sólo las siguientes líneas:

version: '3'
services:
master-webserver:
image: nginx:1.14
ports:
- "85:80"

Guardamos y ejecutamos “docker-compose up”, como nuestro usuario habitual, no necesitamos sudo. El usuario principal debería tener permisos para ejecutar docker sin ser root. La salida se parecerá a algo como esto:

Accedemos a http://localhost:85 y deberíamos ver lo siguiente:

Esto es indicativo de que Nginx está funcionando correctamente en el contenedor. Pasemos a analizar cómo funciona el fichero de docker compose, para que podáis configurarlo vosotros mismos.

docker-compose.yml como su extensión ya indica, es un fichero YAML. Si no habéis trabajado con YAML antes, es como JSON pero más fácil de leer. De hecho acepta JSON sin problemas. Al final es un set de diccionarios y listas que contienen textos o números. No tiene mucho secreto.

El fichero, por líneas:

  • version: ‘3’ – Es el número de versión del formato del fichero. Por temas de compatibilidad, cuando docker compose ejecuta el fichero revisa este valor, de forma que cuando introducen nuevos cambios que son incompatibles en versiones nuevas no afectan a ficheros que antes funcionaban. La versión actual es 3.
  • services: – este bloque indica que vamos a definir los contenedores (servicios) que se pondrán en marcha
  • master-webserver: – este es el nombre de un servicio a nuestra elección. Puede ser cualquier cosa, pero debería seguir las normas de un nombre de dominio porque luego se tiene que resolver por DNS. Cuando otro contenedor quiera acceder a este, tendrá que ir a http://master-webserver/; de ese modo, si contiene caracteres extraños podría dar problemas.
  • image: nginx:1.14 – aquí le decimos la imagen que tiene que cargar. Puede que sea del Docker Hub o que la hayamos creado localmente. Si el nombre lo puede localizar docker, funciona. El formato es igual que en docker: “nombre-imagen:nombre-tag”. Hay que poner siempre el tag o descargará la última versión, que un día puede que contenga una versión incompatible y nos rompa el sistema que hemos montado.
  • ports: Aquí le indicamos un listado de puertos en el formato “puerto-anfitrion:puerto-contenedor”. Esto hará que enrute los puertos automáticamente al iniciar. Como ya veis aquí he indicado el 80 del contenedor al 85 del anfitrión. Si queréis que este contenedor sea el principal para servir webs en la máquina, simplemente cambiadlo por “80:80”.

Esto está muy bien para empezar, pero quiero agregar una cosa más que será útil luego. Ahora mismo ni podemos cambiar la web que sirve, ni tampoco podemos configurar el servidor al gusto. Si queremos agregar más adelante un proxy_pass para phpmyadmin necesitamos poder cambiar la configuración.

Una forma sería crear nuestra propia imagen a partir de la de Nginx y copiar los ficheros que queramos. Pero no es necesario. Podemos jugar con los puntos de montaje, que en docker se llaman volumes (volúmenes). Basta con agregar una clave “volumes:” y un listado de “path-anfitrion:path-contenedor” con los montajes que queramos. Pueden ser carpetas o simples ficheros.

La ventaja de usar volúmenes en lugar de copiar dentro de la imagen es que el efecto es inmediato y no necesitamos regenerar la imagen. Para empezar vamos a crear una carpeta “master-webserver” y un fichero dentro “index.html”. Dentro de éste ponemos un HTML básico de ejemplo para probar. En mi caso, agregué:

<!doctype html>                       
<html>
<head>
<title>Welcome to Nginx - The Master Webserver</title>
</head>
<body>
<h1>Welcome to Nginx - The Master Webserver</h1>
<p>This is just a sample page</p>
</body>
</html>

Ahora queremos que Nginx sirva este fichero en lugar del que viene por defecto. Pero, ¿dónde está el fichero de Nginx por defecto? No importa, agregamos nosotros un fichero de configuración y sobreescribimos la configuración por defecto.

Creamos un fichero, en la misma carpeta que el anterior y lo llamamos nginx.default-site.conf; El contenido sería el siguiente:

server {
listen 80 default_server;
listen [::]:80 default_server;

root /var/www/html;
  index index.html index.htm;

  server_name _ localhost;

  location / {
  # First attempt to serve request as file, then
  # as directory, then fall back to displaying a 404.
  try_files $uri $uri/ =404;
  }

  # deny access to .htaccess files
  location ~ /\.ht {
  deny all;
  }
}

Como veis es un fichero básico que sería para localhost o para dominios que no están registrados (acceso vía IP sin dominio, por ejemplo). La parte interesante aquí es que estamos especificando la raíz en /var/www/html. Esta es la carpeta donde pondremos el index.html.

Ahora solo falta agregar los puntos de montaje para estos dos ficheros:

volumes:  
 - ./master-webserver/nginx.default-site.conf:/etc/nginx/conf.d/default.conf
- ./master-webserver/index.html:/var/www/html/index.html

El orden no importa; puede estar antes de “ports:” o después. O al inicio. Donde le veáis más sentido. Lo importante es que tiene que estar correctamente indentado dentro de “master-webserver”.

En la primera línea lo que hacemos es sobreescribir la configuración por defecto de Nginx. En esta imagen de Docker hub está en conf.d/default.conf, pero en otras instalaciones podría estar en otro lado.

Para inspeccionar la imagen yo lo que hago es ejecutar bash en un contenedor nuevo:

$ docker run -it --rm nginx:1.14 bash

Esto lo que hace es crear un contenedor temporal que se borrará al salir (–rm), que es interactivo (-it) y en lugar de ejecutar el comando por defecto, ejecuta bash. A partir de ahí es como una terminal SSH a otro ordenador. Podéis revisar los ficheros, e incluso borrar lo que queráis. Cualquier cosa que hagáis se deshará al salir de la sesión.

La segunda línea en volumes es bastante obvia, simplemente enlazamos el fichero a la carpeta que habíamos dicho en la configuración: /var/www/html

Ahora, al hacer “docker-compose up” y acceder a localhost:85 de nuevo, vemos esto:

Y con esto terminamos la primera parte de la configuración. Si os habéis perdido en algún momento o simplemente queréis descargar el código sin tener que seguir los pasos, podéis ir a mi GitHub para descargar o examinar el resultado final:

https://github.com/deavid/docker-lamp/tree/v0.1

Agregando PHP

Vamos a seguir un poco más y agregar un segundo contenedor para PHP. Este contenedor queremos que tenga su propio Apache, por lo que Nginx tendrá que conectar con él vía proxy HTTP.

Empiezo con esta configuración y no con FastCGI porque es más sencilla de entender y porque es muy habitual que las webs de los clientes dependan de Apache porque contienen ficheros .htaccess que deben ser obedecidos.

Para esto vamos a agregar un nuevo servicio llamado “php-apache-1” y lo vamos a apuntar a la imagen php:7.3-apache-stretch. No necesitará puertos, ya que Nginx debe conectar internamente y no a través del anfitrión. Pero de momento, para probar, agregaremos un entrutado al puerto 86. Sería agregar el siguiente bloque de texto:

  php-apache-1:
image: php:7.3-apache-stretch
ports:
- "86:80"

Al ejecutar “docker-compose up” por primera vez veremos como descarga la imagen. Si os fijáis, hay una primera capa (layer) que ya existe y aparece como “Already exists”, esto quiere decir que el sistema operativo se está reusando entre la imagen de nginx y php, que no se almacena dos veces.

Al abrir http://localhost:86/ vemos lo siguiente:

El resultado no es que sea muy prometedor, pero ya tenemos Apache funcionando sobre nuestro puerto 86, y supuestamente tiene PHP instalado.

Vamos a copiar el mismo fichero html al mismo sitio a ver si nos funciona. Agregamos el mismo volumen que antes:

volumes:  
- ./master-webserver/index.html:/var/www/html/index.html

Esto no sería muy correcto porque el fichero que está en la carpeta para un servicio lo estamos usando para otro, pero de momento nos sirve.

Abrimos de nuevo la web y:

Voilà! Funciona! ¿Pero porqué dice Nginx? Obviamente, porque el fichero index.html contiene “Nginx” literalmente. Pero el puerto es el 86, así que todo en orden. Tenemos Apache funcionando.

Ahora vamos a probar a poner un PHP en lugar de un HTML y ver si le gusta. Creemos unas carpetas que emulen un servidor web multidominio y pongamos dentro un fichero php. Por ejemplo www/php-apache-1/index.php. El punto de montaje sería:

volumes:
- ./www/php-apache-1:/var/www/html/

El fichero php de ejemplo puede ser como éste:

<html>
<head>
<title>Prueba de PHP</title>
</head>
<body>
<?php echo '<p>Hola Mundo</p>'; ?>
<?php echo time(); ?>
</body>
</html>

Reiniciamos docker compose y recargamos localhost:86; nos aparecerá esto:

Funciona! De hecho cada vez que recargamos la página, el número cambia (porque es la hora unix). Esto es lo que nos demuestra que estamos delante de una página dinámica.

Ahora vamos a conectar Nginx al contenedor de PHP. Para ello se usa proxy_pass. Hay que modificar el fichero de configuración. Añadiremos otra sección location como la que sigue:

location /php-apache-1/ {
rewrite /php-apache-1/?(.*) /$1 break;
proxy_set_header X-Forwarded-For $remote_addr;
  proxy_pass http://php-apache-1/;
}

Con esto le estamos diciendo que cuando el usuario acceda a una URL que empieza con /php-apache-1/ tiene que enviarla a http://php-apache-1/ (que es el nombre que le hemos dado a nuestro servicio), agregar una cabecera X-Forwarded-For con la IP de origen (que más adelante usaremos para convertirla en la IP real desde Apache) y transformar la URL para eliminar la parte inicial de /php-apache-1/ cuando se envía a Apache; ya que Apache no tiene esta carpeta.

Con esto, accedemos ahora a http://localhost:85/php-apache-1/ y vemos que funciona:

Bingo! Ahora tenemos un contenedor conectado a otro, sirviendo una web desde otro servidor web. Es fácil imaginar cómo agregar más contenedores como éste y conectarlos todos a la vez.

Esto tiene un par de pegas. Porque Nginx en el contenedor está en el puerto 80 pero enrutado al 85, cuando abres una URL sin la barra final que no es un fichero, Nginx devuelve un HTTP 301 Moved Permanently a la url correcta con la barra al final, pero cambia el puerto al 80. Esto no sucedería si los dos puertos son iguales. Es decir, si te molesta, pasa el contenedor al puerto 85; o en cambio, si lo enrutas al puerto 80 del anfitrión, también se soluciona.

El otro problema es que es una subcarpeta y no otro dominio. Esto provoca que tengamos que usar el rewrite para solucionarlo. En la realidad usaríamos diferentes dominios, pero para probar era más fácil en carpetas porque los dominios si no resuelven al servidor, tienes que editar el fichero hosts en tu ordenador para simularlo. Pero funciona de modo muy similar, sólo hay que seguir una guía cualquiera de Nginx para configurar virtual hosts.

Si queréis descargaros el ejemplo, aquí está la dirección de github:

https://github.com/deavid/docker-lamp/tree/v0.2

Veréis que en la configuración de Nginx está solucionado el problema de la redireción 301 que comentaba antes. sólo son 3 líneas.

Y hasta aquí llego por hoy. Nos leemos en el siguiente artículo!

Networking features that Docker that I would like to see

I have been researching a lot lately on Docker & Docker Compose and I’ve been impressed how well Docker Compose behaves and how easy makes things. With a few Kb of configs you can setup a really complex collection of containers properly interconnected. Also it simplifies A LOT the setup phase of those, as usually it is just “docker-compose up -d” and everything else is being taken care for you.

One of the most challenging things to do properly is networks; docker-compose leverages a lot on this matter, so it is way easier now. But on my research I found that still falls very short on slightly advanced setups.

Let me discuss which features I would like to see implemented in the future. Beware that any feature described here is purely fictional.

Current limitations

Let’s begin by explaining what are the current pitfalls as of today:

  • userland-proxy enabled by default. As it seems that for old Linux distributions disabling it still has bugs, seems sensible to have it “ON” by default. But this has the drawback that servers that expect to log incoming request or block by IP do not work. One user setting up fail2ban in a container could end easily being blocked because other attacker has tried too many attempts, and for the container everything is the same IP address.
  • No firewall rules per container in docker-compose. This means that any Docker image or docker-compose repository willing to setup anything special needs the user to configure the host with complex iptables rules.
  • IP Tables / Firewall rules are not configurable from the container. This makes almost impossible or really hard for a container to dynamically change the rules. Some secure setups require this.
  • The host sees all networks. While this is nice, it clutters “ip” commands and alike; and also it means that any userland program in the host is able to communicate by default with all container ports. This is insecure.
  • Networks and Builds are not smartly compared. So if you change the docker-compose or a Dockerfile you have to remember either to use the –build switch or to remove the network so it gets recreated.

My desired features

Here are a collection of ideas that could led Docker to be even easier to use and we could see really complex setups in docker-compose that would be just plug&play.

Userland-proxy per container or exposed port

As for my understanding of the userland-proxy is kind of a “socat” program listening on the host IP and bridging to the actual container network, effectively making a NAT route purely in userspace.

When it is disabled, extra iptables rules are added to cover full NAT support.

If that’s the case, nothing stops docker for enabling it on by exposed port basis. It could be enabled by default, and on a container for docker-compose we could have this syntax:

ports:
- target: 80
published: 8080
protocol: tcp
userland-proxy: prefer-disabled

The values for “userland-proxy” would be “prefer-enabled/prefer-disabled/require-enabled/require-disabled”. Docker can in its server config use the “userland-proxy” as a default and add another key like “userland-proxy-container-allow” to enable containers switch the behaviour. “prefer” or “require” would be used for making the container fail if the requirement is not met, or just ignore the fact that the setting was not met.

This can enable “nginx” images to log proper IP’s on default settings and avoid annoying users with bugs that are not using the image. Users with serious issues can still disable the feature.

Also, imagine that one docker image is making use of fail2ban; with this they could “require” the userland-proxy to be disabled in order to run the image, so this avoid potential disasters.

It would be extra-nice for a container to get some information on those settings on boot, so for example an optional fail2ban in an image could be disabled on boot if the userland-proxy is still enabled.

Extra networking restrictions

In a default docker-compose setup, every container has access to every other. While this is nice for starting is dangerous, as one hacker gaining access to one container could start attacking other containers on non-exposed ports.

The way I solve this currently is adding one network per container, and attaching containers just to the networks they require. The problem with this is that we easily end with 10 networks, which are a pain on “ifconfig” outputs. Also it seems overkill.

Docker already manages a bunch of iptables rules, so I guess it can handle a few more to further filter down connectivity. For example, in docker-compose we could have something like:

services:
php-fpm:
image: php-fpm
networks:
- www
firewall-internal: # Between containers only
input-allow-only:
- service: nginx
dst-port: 3301
output-allow-only:
- service: mysql
dst-port: 3306
firewall-external: # To and from internet
input-allow-only:
- ip-address: 133.133.133.133/24
dst-port: 22
output-allow-only:
- dns: www.wordpress.com # for automatic updates...
dst-port: 80,443

How nice would be that? Docker could take care of all rules and we just forget. Easy to read and very restrictive. Still there could be challenges as allowing a certain DNS as a destination; it would need a “ipset”, do the query on boot and add every IP to the set. Also a refresh of that DNS every hour would be required in order to keep track of new IP addresses of the servers.

Currently the only way to do this is by manually adding iptables at the host level and taking care of loading them on boot.

IPTables updatable inside containers

Without extra permissions and compatible with docker-compose. That was obvious, right?

May seem crazy, but with the use of “ipset” we could achieve a high degree of personalization without compromising security. Following the prior example, consider this one:

services:
php-fpm:
image: php-fpm
firewall-external:
input-allow-only:
- match-set: trusted_hosts
dst-port: 22
admin:
build: ./admin
  volumes:
  - ./firewall-sets/trusted_hosts.txt:/trusted_hosts.txt
firewall-sets:
trusted_hosts:
type: "hash:ip"
options:
timeout: 3600
file: ./firewall-sets/trusted_hosts.txt

The idea here is leveraging the work to “ipset”. We can have a file that docker can monitor for changes and update the ipset accordingly

Having the list of addresses or address:port in its own file can be already a great advantage. But being able to mount it in other container to change it is awesome, it allows us to build admins to do port-knocking mechanisms. All of this inside docker-compose and without extra privileges.

Include directive in Compose

I found a pair of GitHub Issues (#1599) and (#318). I’m really in favour of the first one, where it declares that several files should be parsed first:

includes:
- db-common.yml
- redis-common.yml
web:
extends:
file: web-common.yml
service: web
command: bundle exec rails s -p 80 -b 0.0.0.0 thin

It can help big projects to manage nicely huge amounts of configuration splitted onto files. If we have to decide which path we should take into account, I would say go with the parent (caller) path. As this is how it is done for the extends.

Conclusion

I had to stop at some point suggesting features. Docker Compose is a great tool and I would like to see it growing in the next years. Containers are still a young technology far from displaying all its capabilities. Stay tuned as things are very likely to improve shortly as it gains even more traction.

Hope someone from Docker finds this and thinks these would be as cool to have as I think they are!