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!

Cómo conseguir que los contenedores Docker vean la IP de origen

Siento soltar el rollazo de las aventuras, desventuras y lo mal que lo pasé intentando solucionarlo, pero tengo que hacerlo. Si no te apetece leerlo, ve al capítulo “TL;DR” que allí explico cómo hacerlo. Y te ahorras 3 horas o más de sufirimiento.

Lo importante a tener en muy en cuenta es que por defecto los contenedores no ven la IP externa que se está conectando, por lo que todos los logs de IP, baneos, filtros y firewalls no sirven de nada. A no ser que te dé absolutamente igual quien acceda desde internet al contenedor, esto es algo que hay que corregir.

…la ira lleva al odio, el odio lleva al sufrimiento, el sufrimiento al lado oscuro

Estaba preparando un gran artículo sobre Docker para poder filtrar las IP de internet que pueden acceder al contenedor, y he terminado cabreadísimo con el NAT, las redes, iptables y conmigo mismo.

Había acabado de agregar a la cookie de sesión información variada para verificar la autenticidad del usuario, como la fecha de login, el user agent del navegador y la IP con la que se logueó. Y al comprobar, veo que Flask no me devuelve la IP de mi equipo, sino la IP del gateway del router en el que está encerrado.

Me voy a ver la documentación, StackOverflow y Google, y me dan una solución: mismo resultado. Empiezo a ver si es que Nginx no está pasando correctamente el dato, pruebo otras tantas cosas. Tampoco. Y entonces veo el log de Nginx y me doy cuenta de que también registra la misma IP del gateway!

Mierda, esto no va a acabar bien…

Empiezo a buscar sobre Nginx no respeta la ip entrante, y me encuentro un montón de gente con Docker con el mismo problema. Docker tiende a hacer esto con los contenedores. Así que empiezo a tirar de “lsof” y “nc” (netcat) para comprobarlo. Efectivamente, lsof reporta la IP del gateway como origen dentro del contenedor, y en el host reporta la IP de mi equipo como destino.

Busco más y básicamente dan dos opciones, o pasar el contenedor de Nginx a la red de tipo “host” en lugar de “bridge” o desactivar algo llamado “userland-proxy”. Lo de cambiar la red no parece buena idea, así que indago más en la segunda; en cuanto me dí cuenta que había que desactivarlo para globalmente en el servidor de Docker y no por contenedor, vuelvo atrás y miro lo de la red en “host”. Esto efectivamente tiene que funcionar pero comparte/reusa la misma red que el anfitrión, cosa que no es agradable.

Pero al arrancar los contenedores de nuevo me doy cuenta de que Docker Compose se niega a que un contenedor esté a la red “host” y en la red “bridge” a la vez; ésta última es la responsable de las conexiones entre contenedores. Pero claro, el anfitrión está en todas las redes a la vez, así que la red “host” tiene que tener acceso a todos los contenedores igualmente, ¿verdad? Pues sí pero no, porque Docker en este modo no crea sus nombres en /etc/hosts por lo que cuando Nginx intenta hacer “proxy_pass http://apache&#8221; falla porque “apache” no puede resolverlo en las DNS.

Así que otra vez a Google a ver cómo hacer que el anfitrión tenga acceso a los nombres, o que ese contenedor los tenga. Todas las soluciones son rebuscadas y/o con software que hay que instalar en el anfitrión. Y en ese momento me dí cuenta…. aunque funcione, ¿los servidores FTP y SFTP van a tener el mismo problema? Va por iptables, así que… pues sí, lo tienen igualmente. Así que los servidores FTP también en modo “host”, ¿no? Ni de coña. Por ahí sí que no paso. Si por dar algo más de seguridad tengo que abrir las puertas a todo, algo está tremendamente mal. Y aquí empieza mi cabreo.

Me voy a iptables, a seguir depurando con lsof/ncat y a intentar entender qué cojones está haciendo Docker. Todo lo que tiene que hacer es un puñetero NAT, desde la IP del equipo a la IP del contenedor. Lo mismo hace un router de toda la vida y los servidores web detrás del router ven a qué IP están atacando. ¿Qué está haciendo Docker con las iptables? Tuve que revisar lo que era un “masquerade” en redes e inspeccionar las iptables a mano a ver si había algo raro. Nada.

Y entonces me doy cuenta que en lsof aparece un proceso “docker-proxy” que parece estar intermediando entre el anfitrión y el contenedor, haciendo el enrutado a mano. Es decir, como si fuese un programa “socat” en ambos lados, uno leyendo los paquetes y el otro emitiéndolos. Normal que la IP parezca venir desde dentro ¡porque viene desde dentro! Argh! ¿Cómo le digo a Docker que deje de hacer esa “basura” de NAT y haga uno como dios manda?

Después de buscar sobre el susodicho “docker-proxy” y salirme 50 enlaces sobre imágenes de Docker para proxies, consigo encontrar algún artículo que explica que este proceso es el encargado de hacer el “userland-proxy”. Esto es lo mismo que al principio, pero la verdad es que si se puede eliminar globalmente, ahora que entiendo la “m” que está haciendo Docker con el NAT, mejor quitarlo por completo. Fuera, pero ya

Quitarlo es sencillo, se agrega {“userland-proxy”: false} a “/etc/docker/daemon.json” y se reinicia Docker. ¿Funciona? Pues no, yo veo lo mismo otra vez, consulto desde el ordenador con la IP pública del equipo (eth0) y sigo viendo como origen la IP del gateway. A buscar otra vez, hay muchos que les da problemas y otros tantos que no les funciona. Al final encuentro en GitHub issues un proyecto que dicen que a veces las reglas NAT arrancan tarde, la conexión se hizo antes y se queda guardado en las tablas de enrutado. Que haga “conntrack -D” y solucionado… pues tampoco. Así que reinicio el ordenador entero, a ver si….

Creo que ya veis el patrón, ¿verdad? Otra vez lo mismo. Y lo peor, que ahora hay otro proceso de docker, en este caso el servicio maestro, que está escuchando en la IP. Después de revisar otra vez las iptables, me doy por vencido, que le den: voy a agregar una regla a mano en iptables y a enrutarlo por mi cuenta.

Después de pelearme un buen rato con IpTables y pensar que Docker estaba borrando mis reglas me dí cuenta que “iptables” es un poco especialito para listar las reglas. Hay diferentes tablas que contienen cadenas; y por defecto filtra por la tabla “filter”. Si agrego algo en la tabla “nat” es normal que no lo vea. Al agregar la regla y verificar que estaba allí, sigo sin que un simple “nc” haga ni la intención de conectar. Empecé a pensar que la regla de Docker estaba tomando precedencia; así que la subo. Tampoco. Saco los contadores de paquetes: ni uno. Cambio la regla para filtrar por IP en lugar de por interfaz, tampoco, ni un paquete.

Vale que la regla NAT, una sola, no es suficiente para hacer un NAT en condiciones; no espero que funcione, pero al menos el primer paquete de conexión debería activar la regla y los contadores ni se movían.

Aquí ya me empecé a dar cuenta de que había algo fundamental que estaba pasando por alto desde el principio. Me vino a la cabeza muchos routers que cuando hacen NAT son incapaces de enrutar desde dentro, hacia adentro; y que las reglas de iptables para solucionarlo son bastante rebuscadas…. ¿podría ser eso? ¿todas estas horas perdidas aquí sólo porque estoy probando desde mi equipo al contenedor de dentro, en lugar de usar un segundo equipo para comprobar?

Menudo escalofrío me vino. Por un lado, deseando que sea eso y por otro deseando no haber perdido tres horas por soberana estupidez. Y me voy al portátil, abro una consola, escribo “nc 192.168.1.10 1026” y… conecta. Comorrr? si desde mi equipo no va!

Compruebo los conteos de mi regla y efectivamente ha capturado y redirigido correctamente, no solo eso, sino que la conexión es perfecta. Estupefacto me quedo. Pues si es eso, probemos el puerto 80 con Nginx y a ver qué IP saca, tal cual sin agregar reglas.

Y funciona. Los logs reportan la IP del portátil; pero si hago la prueba desde local veo la IP del gateway. Esto lo explica perfectamente, parece que las reglas de la cadena PREROUTING no se ejecutan cuando la conexión sale del mismo equipo.

Y qué más da si las conexiones locales se ven como 127.0.0.1, 192.168.1.10 o 172.16.144.1; una IP u otra, es local y para el caso es lo mismo. Para las IP externas funciona. Victoria, aunque agridulce. Qué mal trago.

NOTA: Si te ha parecido un rollazo (que lo es) tengo que agregar que he pasado por alto la mitad de las pruebas y cosas que busqué. Tres horas de pánico dan para mucho.

TL;DR

Os explico cómo solucionarlo: Básicamente hay que desactivar el “userland-proxy”. Basta con crear el fichero “/etc/docker/daemon.json” con el siguiente contenido:

{
"userland-proxy": false
}

Luego hay que reiniciar Docker:

$ sudo service docker restart

Y lo más importante, para probarlo TIENE QUE SER DESDE OTRO EQUIPO

Hay algunos usuarios que han reportado problemas con IPv6 después de hacer esto, otros con problemas al arrancar Docker. Pero todos los casos tienen algo en común: ejecutaban CentOS/RedHat o una versión antigua de Ubuntu (12.04).

Si la distribución es reciente no debería dar problemas. A mí desde luego no me ha dado ninguno. CentOS y RedHat publican una release cada cuatro años y tienen soporte de diez; no es raro ver equipos RedHat con más de 10 años de antiguedad, eso explica que también les de problemas.

Espero haberos ahorrado unas cuantas horas!

Servidores web más seguros con Docker (I)

Si alguna vez has administrado servidores web multidominio con decenas (o cientos) de webs, seguramente te habrás encontrado en algún momento con el problema de que un hacker consigue hacerse con una de las webs y desde ahí, empieza a contaminar todo el servidor. Llega el punto a veces que puede hacerse con el control del servidor completo, pero habitualmente roban credenciales de otras webs y empiezan a hackearlas también. Es muy complicado deshacer los cambios y devolver el servidor a un estado mínimamente seguro de nuevo.

Docker es un sistema de contenedores de aplicaciones y una de sus principales aptitudes es aislar una aplicación de su anfitrión de forma que los recursos a los que puede acceder están todos en el contenedor, y a priori, no puede acceder a nada externo. Hay muchas sutilezas y detalles que explicar de Docker, pero para resumir, Docker agrega mucha seguridad y sin prácticamente agregar carga al sistema anfitrión.

La idea en la que se basa esta serie de artículos es que si tienes 100 webs distintas de distintos clientes y una es hackeada, al menos el hacker quedará contenido dentro de Docker, por lo que sólo esa web quedará afectada, pudiendo restringir sus recursos, apagarla, etc. Pero tanto el anfitrión como los demás clientes en el servidor deberían estar a salvo.

Medidas a tener en cuenta antes de plantearse Docker

Docker no va a ser la solución a todo. Aunque las distintas webs estén totalmente aisladas o tuviésemos un VPS contratado por web: si éstas son fácilmente hackeables tenemos un gran problema, robo de datos, credenciales, puede que ejecuten código malicioso, que envíen Spam o algo peor, usando nuestros servidores. Los ISP se pueden cabrear bastante si no hacemos algo y Docker no soluciona esto.

Veamos algunos puntos típicos en materia de seguridad de servidores web:

  • Permisos de ficheros. Los permisos de acceso a los distintos ficheros de la web y del sistema operativo son la última barrera de seguridad que impide que un hacker se haga con la web. Hacer “chmod 0777 . -R” es por norma general una irresponsabilidad. Además, hay que usar correctamente los distintos usuarios y grupos para evitar que un proceso escriba donde no debería.
  • Revisar y eliminar configuraciones inseguras. PHP, Apache, MySQL, todos los programas tienen ficheros de configuración que a veces vienen configurados de forma irresponsable. A veces es así por defecto, o porque la instalación de la época así lo hacía. Para cada uno de ellos hay que seguir las guías disponibles en internet para asegurarse que todo está en orden. A veces para empezar configuramos los servicios de forma insegura porque es un hastío tener que proveer contraseñas o para ciertos aspectos. Pero lo que para empezar era aceptable, cuando es producción todo esto se tiene que revisar y corregir. Y no uséis la misma contraseña varias veces!
  • Actualizar el software y estar pendiente de nuevas vulnerabilidades. Casi todos los servidores FTP han tenido vulnerabilidades graves en algún momento, SSH ha tenido diversas vulnerabilidades gravísimas en el pasado, Linux ha tenido otras tantas. Mejorar a la última versión no es necesario, pero al menos que la versión que uses no tenga ninguna vulnerabilidad conocida que pueda impactar en el negocio.
  • Contraseñas. Evitar que ningún usuario en ningún servicio tenga una contraseña débil, conocida o que se pueda deducir. En algunos casos como SSH es posible hasta eliminarlas por completo y acceder por certificado. Evitar compartir la contraseña a toda costa con cualquier persona, página web, etc.

En mi experiencia, la mayor parte de los hackeos que he visto han venido por dos flancos: Contraseñas descubiertas o Software web con vulnerabilidades.

Desgraciadamente en algunos casos, es inevitable. Una vez se le robaron credenciales FTP a un cliente vía virus; supongo que el programa detectó la conexión, la espió, y obtuvo IP, usuario, contraseña y la comunicó. En otros casos el cliente quería administrarse un WordPress y instaló unos plugins con unas vulnerabilidades terribles.

En el caso de FTP, se puede usar SFTP, pero configurarlo para que SFTP esté limitado correctamente a una carpeta es complicado. En el caso de “yo me lo administro” lo veo realmente difícil a no ser que seamos expertos en la plataforma y podamos limitar efectivamente lo que puede instalar o lo que puede configurar.

En fin, lo que vengo a decir es que usar Docker no te exime de seguir todas las prácticas y consejos de seguridad posibles para servidores web sin Docker.

Arquitectura de un Servidor Web con Docker

Hay muchas formas de organizar Docker. Normalmente en cada Docker se suele introducir una única aplicación, por lo que es mejor tener el servidor web, PHP/Python/… y bases de datos separadas entre sí.

Diagrama de ejemplo de la arquitectura propuesta

Base de datos

Las bases de datos, por simplicidad, lo mejor es dejarlas en el anfitrión, sin Docker alguno; con un único servicio (MySQL) compartido para todos. Normalmente las vulnerabilidades de las bases de datos no van a permitir que se acceda al sistema anfitrión, y si tenemos los permisos correctos teniendo un usuario separado para cada base de datos correctamente limitado, no debería ser problema. En el caso de PostgreSQL es planteable tener uno por cliente, ya que se rinde bastante bien cuando hay distintos servidores PostgreSQL en la misma máquina. Para MySQL no es demasiado recomendable, ya que consume mucha memoria y suele ser más interesante unificar; no obstante sí es factible, y también se puede plantear en “tiers”, es decir, teniendo 2-5 servidores MySQL donde en cada uno agrupamos un cierto número de clientes.

El problema de Docker con las bases de datos es que Docker asume que los datos en disco van a ser bastante estáticos. Una base de datos dentro de Docker va a requerir que configuremos volúmenes de datos, o bien que compartamos una carpeta del anfitrión; y muchas veces las bases de datos no están en un lugar que facilite separarlo en otra carpeta, por lo que requiere configuración a medida.

Si tienes interés en realizar contenedores para MySQL, dale un vistazo a la imagen oficial de Docker y lee al completo la documentación en la página:
https://hub.docker.com/_/mysql/

Para PostgreSQL existe también una imagen oficial y las instrucciones son casi idénticas:
https://hub.docker.com/_/postgres

Hay que tener en cuenta en el caso de querer lanzar varias instancias a la vez, que todas las instancias del mismo servicio abrirán el mismo puerto. Esto no es un problema si al conectar agregamos la IP a la que queremos conectar; pero si queremos exponer el puerto en el anfitrión (localhost para conexiones internas, o a la IP pública para poder conectarse desde fuera), entonces habremos de numerar los puertos. No hace falta cambiar el puerto interno, pero el externo al compartir IP, tiene que ser distinto. Por ejemplo, se puede enrutar PostgreSQL del 5432 al 54321 para el contenedor 1, al 54322 para el contenedor 2, etc.

Servidor Web (Apache/Nginx)

Como ya he sugerido antes, necesitamos un servidor web común a todas las webs. La razón de esto es que normalmente queremos que todas las webs sean accesibles en el puerto 80 en la misma IP.

Si tuviésemos suficientes IPs disponibles para alojar cada web en una IP dedicada, el servidor web maestro no sería necesario o se podría lanzar un contenedor por web. Esto sería factible con IPv6, ya que se pueden pedir rangos de 256 IPs de forma relativamente sencilla. Con IPv4 esto no es el caso, y hasta que IPv4 no quede completamente en desuso, el servidor web va a tener que seguir sirviendo en una sola IP.

En el caso de que tengas varias IP disponibles, es posible segmentar las webs entre distintos servidores web; teniendo por ejemplo 3 servidores web para 50 webs. No es que vaya a haber ninguna diferencia, pero puede permitirte diferentes configuraciones a la vez.

También puede ser interesante lanzar otros servidores web en distintos puertos bajo la misma IP, pero dudo que tenga alguna utilidad inmediata.

Para NGINX la imagen la tenéis aquí:
https://hub.docker.com/_/nginx/

Y para Apache, aquí:
https://hub.docker.com/_/httpd

En ambos casos, la configuración está ya dentro de la imagen, por lo que si queréis configurarla al detalle, tendréis que personalizar la imagen vosotros mismos. Otra opción podría ser montar la carpeta de la configuración desde una carpeta en el anfitrión.

Y también, hay que montar la carpeta con las webs (p.ej. /var/www) dentro de la imagen, para que se pueda servir el contenido estático directamente. A no ser, claro, que queráis que cada contenedor de aplicación tenga su servidor web propio, en cuyo caso el servidor web maestro serviría de proxy. Puede ser útil para permitir configuraciones esotéricas por cada web, pero tener un proxy de por medio complica las cosas a la hora de detectar correctamente la IP de la que proceden las peticiones.

Yo recomiendo que las aplicaciones corran por un protocolo tipo CGI, como es FastCGI o SCGI y que el contenido estático sea servido por el servidor web maestro. Consume menos recursos y las webs serán más rápidas.

A la hora de los puntos de montaje, lo mejor es que el servidor web tenga todos los puntos de montaje en solo lectura, ya que se supone que el servidor web no escribe sus configuraciones ni tampoco escribe en las webs. Si tenéis logs por web, podéis agregar un punto de montaje específico por cada web que sí tenga acceso de escritura. Esto evita que en el remoto caso de que el servidor web sea comprometido, el atacante pueda escribir en el disco, y esto prácticamente detendrá todos los ataques. Además garantiza que al reiniciar el contenedor todo volverá a su sitio.

Aplicaciones (PHP/Python/Ruby/…)

Aquí es donde las cosas se ponen interesantes. Las aplicaciones son el mayor riesgo de seguridad, y la mayoría de ataques empiezan aquí. Es por eso que voy a hacer más énfasis en este apartado en las diferentes técnicas que se pueden usar para limitar el daño.

El contenedor contendrá una sola web y contendrá el menor software posible en él. Además los permisos de escritura estarán limitados lo máximo posible.

Antes de entrar en detalle en cómo hacer la seguridad, vamos a ver cómo lo crearíamos.

La conexión con el servidor web maestro pueden ser básicamente dos, dependiendo cual queramos la imagen de Docker es una u otra. Una opción es tener un servidor web dentro de éste contenedor de aplicación y servir las peticiones vía proxy; otra opción es tener un servicio FastCGI sin servidor web, en el caso de PHP. En el caso de Python/Ruby, normalmente es SCGI, pero ambos protocolos funcionan casi igual.

Para PHP, tenemos las imágenes aquí:
https://hub.docker.com/_/php/

De todas las opciones que hay para PHP, nos interesan las versiones x.x.x-fpm para FastCGI y x.x.x-apache para Apache.

La documentación es demasiado larga para la página de Docker y ha salido cortada; para leerla completa hay que ir a este enlace:
https://github.com/docker-library/docs/blob/master/php/README.md

Hay que montar los datos de la web en /var/www/html y debería funcionar.

Para Python, las imágenes están aquí:
https://hub.docker.com/_/python

En el caso de Python, Ruby y otros, no se incluye servidor web o servidor SCGI. Lo más normal es que haya que crear nuestra propia imagen a partir de éstas para agregar los servicios necesarios para servir páginas web.

Si montáis un Apache por contenedor, recordad que hay un plugin llamado RemoteIP que permite leer la cabecera X-Forwarded-For y usarla como IP entrante a todos los efectos, para que la web no pierda la información de la IP del usuario: https://httpd.apache.org/docs/2.4/mod/mod_remoteip.html

Seguridad adicional en contenedores de aplicación

Lo primero, hay que revisar cómo funciona la web en cuestión que queremos levantar. ¿Tiene carpetas de ficheros temporales, uploads, etc donde la web tiene que escribir? Si no es el caso, lo mejor es montar la carpeta como sólo lectura. Si tiene ciertas carpetas donde se supone que va a escribir, se pueden separar en dos directorios distintos en el anfitrión, el directorio principal lo montamos como sólo lectura y el de uploads lo montamos dentro, en su ubicación real, como de escritura.

Aseguráos que el servidor web no ejecute en ningún caso los directorios marcados como de escritura; y que no sirva los ficheros php/python en ningún caso sin procesarlos primero.

Por defecto, las imágenes de Docker se montan en el contenedor como lectura-escritura. Esto está muy bien para la mayoría de los casos y por simplicidad, pero para los contenedores de aplicación esto es un riesgo de seguridad. Si se compromete la web, hay posibilidades de que el atacante intente escribir en el sistema operativo. Aunque ésto no va a sobrevivir un reinicio del contenedor, mientras esté en marcha sigue siendo un problema de seguridad grave.

Las imágenes de Docker al crear el contenedor se pueden marcar como de sólo lectura. Hay algún handicap en esto y es que la mayoría de aplicaciones esperan que se pueda escribir en /tmp y otros directorios. Pero una vez solucionado, el contenedor queda muy bien protegido ya que aparte de servir la web, no puede hacer prácticamente nada. Dadle un vistazo a éste enlace:
https://www.projectatomic.io/blog/2015/12/making-docker-images-write-only-in-production/

Otra medida de seguridad que podemos agregar es crear nuestra imagen propia basada en la imagen que queramos y eliminar todos los ejecutables del sistema operativo que no se vayan a usar. La mayoría están en /bin /sbin /usr/bin y /usr/sbin. Esto va a impedir que un atacante use los comandos disponibles para hacerse con el sistema.

Y se me olvidaba lo más básico, el usuario bajo el que se ejecuta PHP o Python, tiene que ser un usuario propio que sirva únicamente para leer los ficheros necesarios y escribir donde deba hacerlo. Nada más.

Como última medida, muy a tener en cuenta, es agregar reglas de firewall al contenedor. La aplicación no debería ser capaz de acceder a internet y realizar peticiones. Esto impedirá que el posible hacker envíe spam o se descargue herramientas maliciosas desde sus servidores. Hay que tener en cuenta que sí que queremos normalmente que envíe email, pero que no pueda conectarse a alguna weeb para comprobar actualizaciones o instalar plugins, porque eso significa que la web puede sobreescribirse a sí misma. Para el email, lo mejor es habilitar sólo las IP y puertos de los servidores de email a los que se va a conectar, siempre vía SMTP con contraseña. De este modo el hacker, en caso de enviar spam tendrá que usar los servidores que nosotros controlamos. Aquí hay una guía:
https://dev.to/andre/docker-restricting-in–and-outbound-network-traffic-67p

Con todas estas medidas, un posible hacker que consiga encontrar una vulnerabilidad está limitado a las siguientes tareas maliciosas:

  • Ejecutar un minero, normalmente de Monero, programado en PHP o Python. Es extremadamente ineficiente, y al final los resultados no los va a poder obtener fácilmente.
  • Enviar spam usando nuestros servidores de correo. Desgraciadamente las credenciales tienen que ser accesibles a la aplicación web y no es posible distinguir un acceso legítimo de los que no lo son. Por lo menos lo va a tener más complicado porque de normal intentan emitir el correo directamente o usar el demonio de correo local, que no requieren credenciales. Si quiere enviar va a tener que localizar las credenciales entre el código para reusarlas; o usar el mismo framework que use la web.
  • Cargar ficheros en las carpetas de upload o temporales e intentar ejecutarlos. Si los permisos están correctos, ésto no debería ser posible.
  • Robar credenciales. Va a poder acceder a los ficheros, por lo que cualquier contraseña en ellos seguramente será comprometida. Los usuarios y contraseñas de base de datos o de correo deberían funcionar sólo desde el contenedor o desde el servidor anfitrión, para evitar que el hacker las use desde sus servidores. Y por supuesto, no reusar esas contraseñas con ningún otro servicio o usuario.
  • Modificar la base de datos. Con las credenciales o haciendo uso del mismo framework, se pueden cambiar los datos de la web. Si el framework/CMS que usamos aloja parte de la lógica de la aplicación o código PHP en la base de datos (que es muy mala práctica), podrá ejecutar más código y éste código sobrevivirá un reinicio del contenedor. Se puede contener haciendo que el usuario de la web no tenga acceso de escritura a las tablas peligrosas.

Transferencia de ficheros (FTP)

No hay que dejar de lado las FTP, ya que son otro agujero de seguridad importante; no es la primera vez que un atacante modifica una web a través del servidor FTP.

Como servidor FTP yo recomiendo vsftpd, que funciona rápido y es probablemente el más seguro. Pero no hay imágenes oficiales de Docker para vsftpd ni para ningún servidor FTP, así que tendremos que construirnos la nuestra.

Otra cosa importante es que FTP es inseguro y lo recomendado es SFTP. SFTP no es más que un acceso vía SSH a la máquina y se debe configurar correctamente. Pero con Docker, ésto es mucho más fácil.

Mi recomendación aquí sería tener FTP sólo si es estrictamente necesario y configurarlo con SFTP si es posible. Un contenedor por web, y cada uno en un puerto distinto. No debería ser un problema si el cliente tiene que acceder al puerto 2101/2201 para una web y al 2102/2202 para otra. Al final, esto se queda guardado en configuraciones de los programas como FileZilla y sólo es hacer doble clic.

El contenedor debería estar creado como sólo lectura, no debería necesitar de temporales, tal vez requiera de escribir logs para lo que podemos montar una carpeta en el anfitrión y que no se borren cuando se reinicia el contenedor. Agregaríamos el punto de montaje /var/www a la carpeta del anfitrión donde estén los datos que tenga que poder modificar, y ya está.

Otros consejos para contenedores de aplicación

Con lo que he explicado más arriba, las vulnerabilidades restantes son básicamente el envío de SPAM, el robo de credenciales y la necesidad de un administrador para instalar plugins. Veamos qué se puede hacer al respecto.

SPAM

A la hora de tratar el envío de SPAM, lo más apropiado sería tener un servidor de correo dedicado para las webs, o un contenedor de correo por web. De este modo, si algo va mal, se puede apagar el contenedor de correo problemático sin afectar al resto. Un servidor de correo puede delegar la entrega a otro servidor, de forma que si queremos que todo email esté gestionado por la misma plataforma no es problema, sólo le tenemos que configurar que reenvíe los correos al servidor de email que elijamos.

Otra forma de contenerlo es agregar a estos contenedores una limitación de ratio de emails por hora. Si se excede puede generar una alarma, limpiar la cola y detener el servicio durante un par de horas para evitar males mayores. Esto bloquearía parte del SPAM incluso a través de formularios de contacto. La mayor ventaja de este sistema es que los ISP se suelen enfadar más por el volumen de SPAM que por el SPAM en sí mismo, por lo que puede evitar muchos problemas con un ISP.

Como último método, se pueden instalar herramientas antiSpam en el servidor que analizen el correo saliente, bloqueando lo que sea sospechoso. SpamAssasin es una buena forma de empezar, pero para afinar mejor Bogofilter funciona muy bien, pero hay que entrenarlo y vigilarlo.

Robo de credenciales

En este apartado hay poco que hacer, ya que las credenciales se tienen que poder leer desde los programas del contenedor, así que si un hacker consigue ejecutar código malicioso, ten por seguro que va a poder leer las credenciales.

Las únicas cosas que puedo recomendar aquí son, nunca reusar las contraseñas y en caso de duda cambiarlas de vez en cuando. Y por supuesto, los servicios a los que se accede con esas credenciales que no se puedan acceder desde internet.

La única alternativa, que me parece bastante surreal, es conseguir abrir conexión con los servicios requeridos al iniciar el programa, realizar un fork y cerrarlo dentro de un chroot para que no pueda acceder a las credenciales. Pero esto puede ser más un riesgo de seguridad que una ventaja, ya que para hacer chroot tienes que tener permisos de root inicialmente. El montaje sería realmente complejo y fácilmente se nos puede escapar algo que sea realmente más peligroso que el robo de credenciales.

El programa que ejecutamos, si es Python o PHP, es posible que tenga opciones para limitar a qué carpetas puede acceder, pero esto sería permanente y no podríamos pasar las credenciales fácilmente.

La única opción que se me ocurre es pasar las credenciales vía una variable de entorno, y al arrancar el programa y conectar con el servidor, borrarla. Pero esto en la mayoría de páginas web requiere modificar su código fuente muy a medida.

Modo de administrador para instalar plugins

Algo habitual que comenté antes es que el administrador a menudo quiere poder actualizar el software desde la misma página web o instalar plugins. Esto requiere una configuración muy insegura, que de estar activa, un hacker puede usarla para modificar la web permanentemente y puede ser realmente complicado de deshacerlo más tarde.

Una solución a esto es tener un segundo contenedor con los permisos relajados y servir esta versión bajo otro puerto o subdominio. El administrador accedería a esta versión únicamente cuando tenga que actualizar o instalar plugins. Pero este contenedor no puede estar siempre abierto a todo el mundo, pues sería lo mismo al final ya que un hacker podría encontrar fácilmente la segunda web y atacarla allí.

Una primera solución es bloquear el acceso a la web a nivel del servidor web usando un plugin de HTTP Auth, que pida usuario y contraseña a la vieja usanza para acceder a cualquiera de las páginas. Este protocolo no es demasiado seguro, por lo que si alguien espía la conexión a través de HTTP obtendrá las credenciales. La solución es servir esta web alternativa sólo bajo HTTPS. Se puede configurar usando LetsEncrypt, ya hice un artículo sobre esto en el blog:
https://deavid.wordpress.com/2018/06/12/sedice-https-y-autorenovacion-de-certificados/
https://deavid.wordpress.com/2018/05/20/como-http-2-acelera-tu-sitio-web/

Adicionalmente, se puede bloquear el acceso a todas las IP excepto una lista de IPs determinadas. Esto sería lo más seguro pero el problema es que muchos clientes tienen IP dinámica, por lo que en caso de hacerlo habría que habilitar rangos, por ejemplo habilitar sólo las IP de los proveedores de internet de España.

Otra opción es que el contenedor para administrar esté normalmente detenido y lanzarlo durante un tiempo máximo (1 hora) cuando el administrador quiera acceder. Para hacer esto, hay que tener algún tipo de sitio web o aplicación que permita al administrador encender la administración cada vez que la necesite.

Eligiendo imágenes del Docker Hub

En el Docker Hub hay montones de imágenes disponibles para cualquier cosa y a veces puede parecer difícil que elegir. Para complicarlo más, dentro de cada imagen hay montones de tags que apuntan a distintas variantes de imágenes. ¿Cual es mejor?

Primero que nada, ante la duda, elegid siempre imágenes oficiales. Si no, que sean certificadas, o de un autor verificado. Si tenéis que elegir otra que no cumple nada de eso, al menos que esté generada directamente desde GitHub, esto se puede comprobar porque la imagen tiene una pestaña llamada “Builds” y un bloque a la derecha llamado “Source Repository”:

Las imágenes en Docker Hub pueden contener código malicioso y arruinar la seguridad que estamos intentando agregar. Por lo tanto, sólo debemos usar las que sean de confianza. Las que no tienen ninguna garantía, pero son compiladas directamente desde GitHub al menos sabemos que no han sido manipuladas y cualquier código malicioso estará en GitHub también. No es garantía, pero hay menor probabilidad de que tenga un backdoor o algo por el estilo.

Sobre los tags, la regla de oro es jamás uséis tags que se llamen “-latest”, ya que se actualizan a la última versión cada vez y os puede romper los contenedores algún día.

Las imágenes pueden compartir diversas capas entre sí. Si dos imágenes heredan de la misma imagen Debian Stretch, ahorraréis espacio en disco ya que la imagen padre se comparte para ambas. No tiene demasiado sentido tener un contenedor Red Hat, otro Debian y otro Suse, ya que descargaríamos todos los sistemas operativos. Pero incluso si es el mismo sistema operativo y versión, si no es la misma imagen, provocará que se descargue también varias veces ya que sólo funciona si es la misma imagen padre.

Yo recomiendo usar las basadas en Debian Stretch, en su versión completa (porque también hay una versión lite). Esto hace que todos los paquetes que preinstala sean compartidos por todos los contenedores.

También hay tags que van directamente a la última versión mayor y otros especifican hasta la versión menor. Los que especifican la versión menor no se actualizan, y los que especifican la versión mayor se actualizan dentro de esa versión mayor. Lo mejor es quedarse en un término medio, donde podamos recibir actualizaciones menores, que normalmente son de seguridad, pero no traen nada nuevo y no rompen nada.

Por ejemplo:

  • Apache tag: 2.4
  • Nginx tag: 1.14
  • MySql tag: 8.0
  • PHP tag: 7.3-apache-stretch / 7.3-fpm-stretch
  • Python tag: 3.7-stretch
  • VSFTPD: Crear imagen nueva basada en debian:stretch
  • Exim4/Postfix: Crear imagen nueva basada en debian:stretch

Conclusiones

Agregar Docker para aislar los diferentes aspectos de un servidor LAMP no es sencillo, pero abre la puerta a muchas técnicas de mejoran seguridad que antes no eran factibles.

El montaje del servidor es más sencillo con Docker Compose, una herramienta que permite definir contenedores, su estado y interrelación de forma muy sencilla. Esto intentaré cubrirlo en un próximo artículo explicando la instalación paso a paso, aunque no podré cubrir la mayoría de las medidas aquí descritas ya que se hace demasiado extenso.

Qué es la programación Ágil, SCRUM y cómo empezar

Ya hace tiempo que quería dedicar un artículo a la programación Ágil. Llevo año y medio trabajando en Dublín y aquí todo es “Agile Programming”. Hoy voy a explicar a nivel muy básico qué es, qué beneficios trae y cómo ser un poco más ágil a la práctica, para los que no queráis cambiar la mecánica de trabajo por completo.

La programación Ágil en esencia son una serie de técnicas o de ideas para mejorar el desarrollo de software; cómo implementarlas depende de cada uno, aunque también hay algunas muy conocidas somo SCRUM o KanBan. El objetivo siempre es que se desarrolle más rápido, con menos errores, ser más adaptable a los cambios y sobretodo, mejorar la comunicación.

El método más conocido y usado es SCRUM, que aunque funciona tremendamente bien, igual es demasiado cambio para un equipo que no está familiarizado. Bajo mi corta experiencia, creo que hay ciertos aspectos de SCRUM que son mucho más importantes que otros, y al final si hay que empezar que sea por aquello que más efecto vaya a tener, ¿verdad?

Release Early, Release Often

Creo que este es el primer concepto que hay que poner sobre la mesa. La mayoría de equipos de desarrollo hoy en día, aunque no estén haciendo programación ágil ya siguen RERO.

La idea es la siguiente: Cuanto más tarda el código para estar en producción, más peligros hay de errores o cambios. Y es que la teoría es una cosa y la práctica es otra. Reduciendo al máximo posible el tiempo que una aplicación o una nueva feature tarda en desplegarse en producción se reducen los riesgos enormemente.

Otra forma de verlo es que si pasan muchos meses desde que se subieron cambios al cliente o se publicó la última versión, la siguiente vez va a costarnos mucho. Hacerlo a menudo implica que van a haber menos cambios entre versiones y que por tanto va a ser un proceso más ameno y menos doloroso.

A la práctica, las actualizaciones deberían enviarse entre cada dos semanas y dos meses, intentando preferir los periodos más cortos posibles.

Mejorar la Comunicación

Uno de los problemas más comunes es la ausencia de comunicación entre los miembros del equipo. Y como equipo aquí no nos referimos a desarrolladores, nos referimos a todo el equipo que hace el producto posible. Esto implica que administradores de sistemas, desarrolladores, analistas, consultores, comerciales e incluso gerencia deben comunicar entre sí lo mejor posible.

La forma en que SCRUM soluciona ésto es con un stand-up diario (o “plantadillo” si lo preferís en español) donde tanto desarrolladores como consultores se juntan para ponerse al día del estado de las tareas y para comunicar cualquier problema. Debe ser rápido, de unos diez minutos y de las primeras tareas de la mañana. Sin entrar en detalles técnicos dentro de lo posible.

Si hacer una reunión diaria de diez minutos os parece mucho, porque supone parar a mucha gente, una forma de evitarlo sería que todo el mundo genere un reporte vía email al jefe del equipo al final del día anterior que puede constar en una sola línea, describiendo cómo van las tareas, si los tiempos se van a cumplir y si cualquier cosa que crean que se debería discutir. El jefe del equipo junta todo en un solo correo a la mañana siguiente, que se reenvía a todo el mundo y en función del contenido determina si debe hacerse la reunión y la asistencia puede ser a los interesados.

La clave aquí es que todo el mundo debe estar al día de todos los aspectos. Los que tratan con el cliente deben tener una idea general de cómo les va a los programadores, y éstos últimos deben entender cuales son los problemas de los consultores y los comerciales. Hay que eliminar barreras. Dejar a la gente aislada es bastante malo.

Eficiencia

Todos estaremos de acuerdo en que el tiempo es dinero cuando hablamos de desarrollo de software o cualquier otro ámbito de servicios. Por lo que si se pierde tiempo se está perdiendo dinero.

En programación, la experiencia es que cambiar de tarea puede conllevar más de media hora perdida cambiando el foco a la nueva tarea. Minimizar la cantidad de reuniones que tiene el equipo de desarrollo es crucial, pero también encadenar las reuniones una detrás de otra. Hacer una reunión, luego una hora para programar y luego otra reunión es desperdiciar la hora completa de toda la gente.

Otros aspectos no tan evidentes son la multitarea y los cambios de prioridad de última hora. Si el que está programando tiene que estar a dos o tres cosas a la vez, no trabajará ni la mitad de rápido y será mucho más probable que cometa errores en alguna de las tareas que está realizando. Si alguien está haciendo una tarea concreta y se le pide que pare para cambiar a otra cosa y luego volver a lo que hacía, se va a perder más de una hora de trabajo por el camino además de que al volver a la tarea anterior puede que cometa errores al no recordar exactamente el análisis.

Una de las claves aquí está en tener una cola de tareas asignadas a cada uno, que debe ser siempre pequeña. La idea es que el programador sepa qué tarea puede empezar al terminar lo que tenía y al no tener muchas donde elegir, el trabajo va a salir de una forma mucho más predecible.

Para poder tener las urgencias y otras tareas menores funcionando sin tener que esperar demasiado lo que hay que hacer es dividir las tareas en subtareas mucho más pequeñas y finitas. En vez de “crear la funcionalidad X”, la tarea puede ser “crear los campos para la funcionalidad X”. Esto hace que el ámbito que tiene que cubrir el programador sea mucho más pequeño y pueda empezar antes. Al ser más pequeñas hay mayor frecuencia, por lo que entre una y otra puede saltar a urgencias o cualquier otra cosa. Las tareas, aunque pequeñas, deben tener sentido y poder ser subidas a producción sin que haya ningún problema; aunque no entreguen ningún valor de negocio.

Esto se puede mejorar con un tablero KanBan, a veces incluso si es físico donde la gente tiene que ir y cambiar los post-it de sitio puede que de un empujón de moral a la gente. Lo bueno de un tablero KanBan es que da una vista general de cómo va el trabajo del equipo.

El Backlog

Un concepto bastante común a todos los sistemas es la existencia del “backlog”, que no es otra cosa del listado de tareas pendientes de asignar.

Como ya comentaba antes, cada persona tiene asignada una lista de tareas muy pequeña para ir haciendo. ¿Qué pasa con el resto de tareas? Todo se tiene que registrar, por lo que en cuanto se sepa que hay algo que hacer se tiene que dar de alta el ítem en el sistema que uséis (Jira/Redmine/Asana/…).

Estas tareas, los consultores que son los que saben las prioridades para entregar cada cosa las ordenarán en el backlog por prioridad, para que luego sean movidas dentro del “tablero” según se van terminando las tareas anteriores. Normalmente quien se encarga de mover las tareas es el jefe del equipo o alguien particular designado para ello. Las tareas en el backlog pueden tener a alguien pre-asignado para saber quién es el mejor candidato para realizarlas pero lo normal es que no tengan a nadie, para que el primero que tenga capacidad la tome.

Los Sprints

Principalmente los Sprints vienen de SCRUM, la idea es dar un intervalo de tiempo en el cual una serie de tareas han de ser completadas. Las razones de hacer esto pueden ser muy diversas. Por ejemplo, se puede hacer un Sprint por release, de modo que sabemos de antemano qué es lo que vamos a publicar en la siguiente iteración. Otra posible razón es tener un punto de control cada poco tiempo para que si algo va mal no se nos vaya de las manos. Los sprints facilitan mucho ver cuando una tarea sea alarga más de lo normal porque agrega una fecha de entrega ficticia.

Para usar los Sprints es necesario saber cuantas tareas se pueden realizar en un período de tiempo. No es preciso saber con exactitud el tiempo de cada una, pues la incertidumbre es inherente al desarrollo de software.

Hay que medir de algún modo el tiempo estimado por tarea. El método más habitual para hacerlo es por “Story points” o puntos de tarea. Se trata simplemente de asignar un número que se corresponde con la complejidad de la tarea. Normalmente se usa la serie de fibonnacci, por lo que se puede asignar a una tarea 1, 2, 3, 5, 8 o 13 puntos; siendo 1 una tarea sencilla y 13 una tarea muy compleja que no sabemos cuando podrá estar lista.

A lo largo del tiempo la gente empieza a comparar las tareas de anteriores Sprints con las del nuevo, por lo que una tarea tendrá 3 puntos si parece igual de dificil que otra de 3 puntos que hicieron en el pasado.

En la práctica, hay tareas que tienen 1 punto y se tardan 5 minutos y otras con 1 punto que se tardan dos días. No es problema, al final como tienes 30 tareas dentro del Sprint estas discrepancias se compensan y la media se cumple. Por lo que si en el anterior Sprint se realizaron 30 puntos lo lógico es que el siguiente también sean más o menos 30. Con esto podemos determinar cuantas tareas hay que poner en un Sprint.

Otras reuniones útiles

Antes de finalizar, también quería comentar brevemente otras reuniones que se suelen hacer en SCRUM:

  • Sprint Planning: Antes de empezar el siguiente Sprint se reune el equipo para ver qué tareas se podrán cubrir en el intervalo de tiempo, se asignan a cada persona y si no están estimadas, se estiman en ese mismo momento. Es también un buen momento para revisar si la tarea está clara; y si no lo está se deja para el siguiente sprint hasta que todo esté bien atado.
  • Backlog Grooming: Normalmente a mitad del Sprint se reunen todos para revisar las tareas que hay en la parte de arriba del backlog que deben haberse priorizado previamente por los consultores. Es el momento para estimarlas y hacer preguntas. Las que no estén claras se marcan para que sean revisadas en los siguientes días.
  • Retrospectiva: Una reunión que sirve para preguntar regularmente a cada miembro del equipo qué ha ido bien, qué ha ido mal y en qué podemos mejorar. Es un buen momento para proponer ideas. Es importante para que la gente del equipo se sienta integrada y parte de él. Como resultado, se deben tomar una o dos ideas que nos parezcan tener más retorno y empezar a aplicarlas a ver qué sucede.
  • Post-mortems / Análisis de causas: Cuando algo imprevisto sucede, que no debería haber pasado, se suele hacer una reunión o al menos un esfuerzo por hallar cual ha sido la raíz del problema. Cuando se identifica hay que ver qué medidas se pueden tomar para reducir la probabilidad de que suceda de nuevo.

Con esto voy a terminar el artículo. Es muy básico y mi intención sólo es rascar la superficie de lo que es la programación ágil. Espero que le sirva a la gente que es completamente ajena al tema.

Si tenéis cualquier duda o sugerencia, no dudéis en ponerlo en los comentarios!