Esta pagina se ve mejor con JavaScript habilitado

Django Proyecto en producción

 ·  🎃 kr0m

En este artículo vamos a explicar como desplegar un proyecto Django de modo que quede listo para entrar en producción, utilizaremos HaProxy como balanceador de carga, Nginx como servidor web para el contenido estático y finalmente ASGI/Daphne como servidor Python. Además seguiremos una serie de pasos como deshabilitar el debug, definit el paths, habilitar y forzar el ssl, cachear conexiones a la base de datos y configurar el informe sobre errores web. Como paso final configuraremos Git como sistema de control de versiones y programaremos un script de despliege para subir los cambios.

Antes de comenzar es recomendable leer los artículos anteriores sobre Django ya que son los pasos previos a este artículo:


Este artículo se compone de varias partes:


Servidor PostgreSQL

Antes de nada vamos a instalar el servidor PostgreSQL, consultamos las versiones disponibles:

pkg search postgresql|grep '\-server'

postgresql10-server-10.15   PostgreSQL is the most advanced open-source database available anywhere  
postgresql11-server-11.10   PostgreSQL is the most advanced open-source database available anywhere  
postgresql12-server-12.5    PostgreSQL is the most advanced open-source database available anywhere  
postgresql13-server-13.1_1  PostgreSQL is the most advanced open-source database available anywhere  
postgresql95-server-9.5.24  PostgreSQL is the most advanced open-source database available anywhere  
postgresql96-server-9.6.20  PostgreSQL is the most advanced open-source database available anywhere

Instalamos la versión deseada:

pkg install postgresql13-server

Configuramos PostgreSQL para que arranque en el boot del sistema:

sysrc postgresql_enable="yes"

Inicializamos la base de datos:

/usr/local/etc/rc.d/postgresql initdb

Arrancamos el servicio:

service postgresql start

Por defecto PostgreSQL utiliza el esquema de autenticación “peer authentication” para las conexiones locales, básicamente significa que si el nombre de un usuario del SO coincide con el nombre de un usuario de PostgreSQL este usuario puede acceder a la cli de PostgreSQL sin autenticación.

su - postgres
psql

Creamos el usuario de acceso de la aplicación web:

CREATE USER rxwod_user WITH PASSWORD 'PASSWORD';  
ALTER ROLE rxwod_user SET client_encoding TO 'utf8';  
ALTER ROLE rxwod_user SET default_transaction_isolation TO 'read committed';  
ALTER ROLE rxwod_user SET timezone TO 'UTC+1';

Creamos la base de datos indicando el propietario:

CREATE DATABASE rxwod WITH OWNER rxwod_user;

Listamos las bases de datos:

\l
                              List of databases  
   Name    |   Owner    | Encoding | Collate | Ctype |   Access privileges     
-----------+------------+----------+---------+-------+-----------------------  
 postgres  | postgres   | UTF8     | C       | C     |   
 rxwod     | rxwod_user | UTF8     | C       | C     |   
 template0 | postgres   | UTF8     | C       | C     | =c/postgres          +  
           |            |          |         |       | postgres=CTc/postgres  
 template1 | postgres   | UTF8     | C       | C     | =c/postgres          +  
           |            |          |         |       | postgres=CTc/postgres  
(4 rows)

Asignamos los permisos necesarios y salimos:

GRANT ALL PRIVILEGES ON DATABASE rxwod TO rxwod_user;  
\q  
exit

Comprobamos el acceso:

psql -U rxwod_user rxwod

rxwod=> \conninfo
You are connected to database "rxwod" as user "rxwod_user" via socket in "/tmp" at port "5432".

Dumpeamos la base de datos de nuestro pc:

su - postgres
pg_dump rxwod > /tmp/rxwod.psql

Subimos el dump al servidor:

scp /tmp/rxwod.psql RxWod:/tmp/rxwod.psql

Cargamos el dump en el servidor:

su - postgres
psql rxwod < /tmp/rxwod.psql

Comprobamos que haya creado las tablas necesarias:

psql

postgres=# \c rxwod  
rxwod=# \dt  
                    List of relations  
 Schema |            Name            | Type  |   Owner      
--------+----------------------------+-------+------------  
 public | auth_group                 | table | rxwod_user  
 public | auth_group_permissions     | table | rxwod_user  
 public | auth_permission            | table | rxwod_user  
 public | auth_user                  | table | rxwod_user  
 public | auth_user_groups           | table | rxwod_user  
 public | auth_user_user_permissions | table | rxwod_user  
 public | django_admin_log           | table | rxwod_user  
 public | django_content_type        | table | rxwod_user  
 public | django_migrations          | table | rxwod_user  
 public | django_session             | table | rxwod_user  
 public | rxWod_exercise             | table | rxwod_user  
 public | rxWod_routine              | table | rxwod_user  
(12 rows)  
  
exit

Otra forma de acceder a la shell de PostgreSQL y comprobar que todo esté en orden es mediante el comando dbshell, este utilizará las credenciales indicadas en la configuración del proyecto:

python manage.py dbshell

rxwod=> select * from "rxWod_routine";  
 id |            date            |  exercise_ids   | rounds |     percentages     | user_id   
----+----------------------------+-----------------+--------+---------------------+---------  
  1 | 2020-12-20 22:00:24.524-01 | 1,2,3,4,5,6,7,8 |      4 | 50,50,0,0,0,0,0,0,0 |       1  
  2 | 2020-12-20 22:00:37.701-01 | 1,2,3,4,5,6,7,8 |      2 | 50,50,0,0,0,0,0,0,0 |       1  
  3 | 2020-12-20 22:00:46.758-01 | 1,2,3,4,5,6,7,8 |      1 | 50,50,0,0,0,0,0,0,0 |       1  
  4 | 2020-12-23 23:36:33.569-01 | 1,2,3,4,5,6,7,8 |      4 | 50,50,0,0,0,0,0,0,0 |      24  
(4 rows)

Borramos el dump tanto en el servidor como en nuestro pc:

rm /tmp/rxwod.psql


Repositorio Git

El siguiente paso es configurar un repositorio de Git, de este modo podremos versionar el código y colaborar con otros programadores de forma mas sencilla, al utilizar Git debemos configurar los ficheros a excluir para evitar leaks de información sensible.

Creamos el repositorio en Gitolite , este se llamará RxWodProject.

Instalamos el cliente de Git en nuestro pc:

pkg install git

Realizamos la configuración inicial:

git config --global user.name “Kr0m”
git config --global user.email kr0m@alfaexploit.com

Externalizamos el fichero que contendrá la SECRET_KEY del proyecto de este modo podremos gitear el fichero settings.py pero ignorando el fichero de la secret_key:

cd /home/kr0m/rxWod/rxWodProject
vi rxWodProject/settings.py

secret_key_path = str(Path(__file__).resolve().parent) + '/secret_key.txt'
with open(secret_key_path) as f:
    SECRET_KEY = f.read().strip()
vi rxWodProject/secret_key.txt
SECRET_KEY = 'XXXXXXXXXXXXXXXXXXXXXXXX'

Inicializamos el repositorio:

cd /home/kr0m/rxWod/rxWodProject
git init

Nos aparecerá el siguiente mensaje:

hint: Using 'master' as the name for the initial branch. This default branch name  
hint: is subject to change. To configure the initial branch name to use in all  
hint: of your new repositories, which will suppress this warning, call:  
hint:   
hint:  git config --global init.defaultBranch <name>  
hint:   
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and  
hint: 'development'. The just-created branch can be renamed via this command:  
hint:   
hint:  git branch -m <name>  
Initialized empty Git repository in /usr/home/kr0m/rxWod/rxWodProject/.git/

Configuramos Git:

git config pull.rebase false

Configuramos los ficheros a ignorar:

vi .gitignore

rxWodProject/secret_key.txt
rxWodProject/database_auth.txt
rxWodProject/email_auth.txt
rxWod/migrations
usersAuth/migrations
db.sqlite3
node_modules
yarn-error.log
assets/bundles/*
assets/videos/*
webpack-stats.json
django.mo
djangojs.mo
__pycache__/
*.core
production_enabled

Añadimos el origin del repositorio:

git remote add origin gitolite@192.168.69.5 :RxWodProject
git add .
git commit -m “Initial commit”
git push origin master

Configuramos Git para que utilice el branch master:

vi .git/config

[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
[remote "origin"]
        url = gitolite@192.168.69.5:RxWodProject
        fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
        remote = origin
        merge = refs/heads/master

Habilitamos el forwardagent cuando accedemos al servidor, recordad que si alguien mas tiene acceso a este host forwardear el agent puede ser peligroso , en tal caso deberíamos permitir la key del propio servidor web en el repositorio de Git.

vi .ssh/config
Host RxWod
  HostName 192.168.69.12
  User kr0m
  Port 22
  ForwardAgent yes

Instalamos el cliente de Git en el servidor:

pkg install git

Instalamos Yarn en el servidor:

pkg install npm
npm install -g yarn

Realizamos la configuración inicial de Git en el servidor(como usuario regular):

git config --global user.name “Kr0m RxWod-Server”
git config --global user.email kr0m@alfaexploit.com

Creamos el proyecto de Django y clonamos el repositorio:

python3.7 -m venv rxWod
cd rxWod
git clone gitolite@192.168.69.5 :RxWodProject

Instalamos sus dependencias, hay algunas que mediante pip son problemáticas, las instalamos de forma global:

pkg install py37-tkinter py37-sqlite3

El resto mediante pip:

source bin/activate
/usr/home/kr0m/rxWod/bin/python3.7 -m pip install --upgrade pip
pip install wheel
pip install -r RxWodProject/requirements.txt

Si nos fijamos el repositorio se llama RxWodProject, pero en artículos anteriores se llamaba rxWodProject, el path era: /home/kr0m/rxWod/rxWodProject ahora es: /home/kr0m/rxWod/RxWodProject.

Esta discrepancia en los nombres es debida a que para redactar el artículo he necesitado crear un repositorio nuevo pero no quiero perder los cambios del repositorio original ya que es donde tengo la aplicación rxwod prácticamente terminada, para evitar confusiones y que los paths no cambien renombramos el directorio, internamente se seguirá utilizando el repositorio RxWodProject pero todos los paths se verán como rxWodProject.

mv RxWodProject rxWodProject
cd rxWodProject

Instalamos las dependencias JS:

yarn install

Subimos los ficheros de credenciales al servidor:

scp /home/kr0m/rxWod/rxWodProject/rxWodProject/secret_key.txt RxWod:/home/kr0m/rxWod/rxWodProject/rxWodProject/secret_key.txt
scp /home/kr0m/rxWod/rxWodProject/rxWodProject/database_auth.txt RxWod:/home/kr0m/rxWod/rxWodProject/rxWodProject/database_auth.txt
scp /home/kr0m/rxWod/rxWodProject/rxWodProject/email_auth.txt RxWod:/home/kr0m/rxWod/rxWodProject/rxWodProject/email_auth.txt

Creamos el directorio de los bundles:

mkdir assets/bundles/

Ejecutamos las posibles migraciones pendientes en la base de datos:

python manage.py makemigrations
python manage.py migrate

Copiamos el fichero de configuración de desarrollo de WebPack a producción:

cp webpack.dev.config.js webpack.prod.config.js

Cambiamos el parámetro mode a production:

vi webpack.prod.config.js

module.exports = {
    mode: "production",

Compilamos los bundles de WebPack:

yarn prod-build

Arrancamos el servidor:

python manage.py runserver

Accedemos al servidor a través de un túnel Ssh para comprobar que funciona correctamente:

ssh -L 8000:localhost:8000 -A -g RxWod

Apuntamos nuestro navegador a:
http://localhost:8000

En la consola donde tenemos el servidor arrancado veremos:

[15/Mar/2021 22:03:39] "GET / HTTP/1.1" 302 0  
[15/Mar/2021 22:03:39] "GET /accounts/login/?next=/ HTTP/1.1" 200 591  
[15/Mar/2021 22:03:40] "GET /accounts/login/?next=/ HTTP/1.1" 200 591

Si realizamos login veremos la rutina:

Como podemos ver la aplicación funciona correctamente en el nuevo servidor, paramos el servidor de desarrollo(Ctrl+c).


Servidor Python/Daphne

Un proyecto Django precisa de varias partes para pasar a producción, la primera es un balanceador de carga que repartirá las peticiones entre nuestros servidores pudiendo escalar de modo horizontal, la segunda es un servidor web que serviará el contenido estático y reenviará las peticiones del proyecto al servidor Python, en nuestro caso Daphne.

El esquema sería el siguiente:

Django viene con un servidor web integrado pero debemos tener en cuenta que este servidor es solo para uso en fase de pruebas, en la web de Django insisten mucho en que no se utilice como servidor en producción:

Don’t use this server in anything resembling a production environment. It’s intended only for use while developing. (We’re in the business of making Web frameworks, not Web servers.)

En la web de Django nos advierten de que tener nuestro código en el document root del servidor web es una pésima idea ya que incrementa las posibilidades de que el código fuente de la app acabe siendo visualizado por un atacante.

It’s not a good idea to put any of this Python code within your Web server’s document root, because it risks the possibility that people may be able to view your code over the Web. That’s not good for security.
Put your code in some directory outside of the document root, such as /home/mycode.

Podemos elegir entre varias opciones en cuanto a servidores web pero las dos mas populares son Apache y Nginx, estos se encargarán de servir el contenido estático y de enviar las peticiones que debe atender nuestro código a un servidor WSGI/ASGI.

Django actualmente soporta dos interfaces: WSGI y ASGI.

  • WSGI es el estandar de Python para comunicar servidores web con aplicaciones pero solo soporta código síncrono.

    • Gunicorn : Servidor escrito completamente en Python, fácilmente instalable mediante pip.
    • uWSGI : Servidor rápido y sysadmin friendly, escrito en C.
    • mod_wsgi : Módulo de Apache que nos permite ejecutar código Python.
  • ASGI es el nuevo estandar asíncrono que permite a Django utilizar las funcionalidades asíncronas de Python/Django.

    • Daphne : Servidor escrito en Python y mantenido por el equipo de Django, actúa como referencia de servidor ASGI.
    • Hypercorn : Servidor con soporte para HTTP/1, HTTP/2 y HTTP/3.
    • Uvicorn : Servidor enfocado en la velocidad.

WSGI presenta limitaciones en cuanto a concurrencia, por ejemplo si nuestro proyecto consulta la base de datos y esta tarda X en responder, el servidor web no atiende nuevas peticiones hasta que se haya terminado de procesar la primera.

Mediante ASGI las peticiones a la espera de respuesta por parte de la base de datos se quedan en standby y se atienden peticiones nuevas, cuando se recibe respuesta de la base de datos se termina de procesar la petición pendiente.

ASGI aporta una serie de ventajas adicionales para aplicaciones que precisan de una conexión persistente como pueden ser chats, mantiene unos canales abiertos(websockets) de comunicación bidireccional, pero para utilizar estas funcionalidades no basta con utilizar un servidor ASGI también hay que programar nuestro código con las librerias oportunas.

Los servidores ASGI son capaces de gestionar tanto las peticiones WSGI tradicionales como las ASGI así que nosotros montaremos un servidor ASGI y si mas adelante decidimos utilizar funcionalidades asíncronas podremos hacerlo sin problemas.

El esquema de procesamiento de peticiones WSGI es el siguiente:

Con ASGI podemos ver que además del procesamieto WSGI tenemos la parte de WebSockets y canales:

En este artículo vamos a utilizar Daphne ya que es el servidor estandar.

Instalamos las dependencias:

pkg install rust

Instalamos Daphne:

cd rxWod
source bin/activate
cd rxWodProject/
pip install daphne

Definimos el dominio al que debe responder nuestra aplicación:

vi rxWodProject/settings.py

ALLOWED_HOSTS = rxwod.alfaexploit.com

Configuramos los DNS para que rxwod.alfaexploit.com resuelva a la dirección ip del servidor.

Arrancamos Daphne manualmente para comprobar que funcione correctamente:

cd /home/kr0m/rxWod/rxWodProject
daphne rxWodProject.asgi:application

2021-03-16 22:18:21,101 INFO     Starting server at tcp:port=8000:interface=127.0.0.1  
2021-03-16 22:18:21,102 INFO     HTTP/2 support not enabled (install the http2 and tls Twisted extras)  
2021-03-16 22:18:21,102 INFO     Configuring endpoint tcp:port=8000:interface=127.0.0.1  
2021-03-16 22:18:21,103 INFO     Listening on TCP address 192.168.69.12:8000

En caso de ser necesario aplicamos las siguientes reglas de filtrado:

$cmd 00806 allow tcp from PC_IP to SERVER_IP 8000 in via $wanif  
$cmd 00806 allow tcp from SERVER_IP 8000 to PC_IP out via $wanif

Podemos probar a acceder para comprobar que funciona:
http://rxwod.alfaexploit.com:8000/

Como podemos ver funciona pero no carga el contenido estático ya que este debe servirlo Nginx:

Para poder demonizar Daphne vamos a utilizar el sistema RC de FreeBSD:

vi /usr/local/etc/rc.d/daphne

#! /bin/sh
#
# $FreeBSD$
#

# PROVIDE: daphne
# REQUIRE: DAEMON
# KEYWORD: shutdown 

. /etc/rc.subr

name="daphne"
rcvar="${name}_enable"
extra_commands="status"

start_cmd="${name}_start"
stop_cmd="${name}_stop"
status_cmd="${name}_status"

daphne_start(){
    echo "Starting service: ${name}"
    chdir /home/kr0m/rxWod/rxWodProject
    /usr/sbin/daemon -S -p /var/run/${name}.pid -T daphne -u kr0m /home/kr0m/rxWod/bin/daphne rxWodProject.asgi:application --proxy-headers
}

daphne_stop(){
    if [ -f /var/run/${name}.pid ]; then
        echo "Stopping service: ${name}"
        kill -s INT $(cat /var/run/${name}.pid)
        sleep 3
    else
        echo "It appears ${name} is not running."
    fi
}

daphne_status(){
    if [ -f /var/run/${name}.pid ]; then
        echo "${name} running with PID: $(cat /var/run/${name}.pid)"
    else
        echo "It appears ${name} is not running."
    fi
}


load_rc_config ${name}
run_rc_command "$1"

NOTA: En el script RC hemos añadido el parámetro --proxy-headers para que Daphne lea las cabeceras que le enviará el Nginx que vamos a montar un poco mas adelante en este mismo artículo.

Asignamos permisos y propietario:

chmod 555 /usr/local/etc/rc.d/daphne
chown root:wheel /usr/local/etc/rc.d/daphne

Habilitamos el servicio y lo arrancamos:

sysrc daphne_enable="YES"
service daphne start

Comprobamos que haya arrancado:

sockstat -sv|grep 8000

kr0m     python3.7  726   12 tcp4   192.168.69.12:8000    *:*                                LISTEN

Si surge algún problema podemos debugear Daphne arrancándolo manualmente con verbose: 2

(rxWod) rxWod $ ~/rxWod/rxWodProject> /home/kr0m/rxWod/bin/daphne -v 2 rxWodProject.asgi:application --proxy-headers

Y consultando los logs generados:

tail -f /var/log/messages


Nginx

El siguiente paso es configurar Nginx para que sirva nuestro contenido estático y reenvíe las peticiones del proyecto a Daphne.
El contenido estático se generará mediante Webpack y con el comando de Django collectstatic copiaremos dicho contenido al DocRoot de Nginx.

En Django debemos definir algunos parámetros:

  • STATIC_URL: Url desde la que colgará todo nuestro contenido estático, por ejemplo si queremos acceder a una imagen accederemos a http://DOMAIN_NAME/STATIC_URL/file.png
  • STATICFILES_DIRS: Directorio donde se encuentra el contenido estático generado por WebPack, cuando se ejecute la orden collectstatic se leerán los ficheros de este directorio y se copiarán al path configurado en STATIC_ROOT.
  • STATIC_ROOT: Directorio donde se copiará todo el contenido estático del proyecto cuando se ejecute la orden collectstatic, este contenido estará disponible bajo la Url: http://DOMAIN_NAME/STATIC_URL/, este paso solo es necesario cuando pasemos nuestro proyecto a producción y utilicemos algún servidor web como Apache/Nginx. Si estamos utilizando el servidor de desarrollo en nuestro pc no hará falta ejecutar el comando collectstatic ya que este contenido se servirá directamente desde el directorio STATICFILES_DIRS.
vi rxWodProject/settings.py
STATIC_URL = '/static/'
STATICFILES_DIRS = (
    os.path.join(BASE_DIR, 'assets/bundles/'),
)
STATIC_ROOT = '/usr/local/www/rxWod/static/'

Instalamos Nginx y lo arrancamos:

pkg install nginx

Habilitamos y arrancamos el servicio:

sysrc nginx_enable="yes"
service nginx start

Creamos el directorio donde residirá el contenido estático:

mkdir -p /usr/local/www/rxWod/static/
chown -R kr0m:kr0m /usr/local/www/rxWod/static/

Copiamos todos los ficheros que necesita nuestro proyecto:

python manage.py collectstatic

148 static files copied to '/usr/local/www/rxWod/static'

Comprobamos que se hayan copiado correctamente:

ls -la /usr/local/www/rxWod/static

total 189  
drwxr-xr-x  4 kr0m  kr0m       10 Mar 16 22:37 .  
drwxr-xr-x  3 root  wheel       3 Mar 16 22:36 ..  
drwxr-xr-x  6 kr0m  kr0m        6 Mar 16 22:37 admin  
-rw-r--r--  1 kr0m  kr0m       57 Mar 16 22:37 base-c4b6e89240744702871f.js  
drwxr-xr-x  5 kr0m  kr0m        5 Mar 16 22:37 debug_toolbar  
-rw-r--r--  1 kr0m  kr0m    16735 Mar 16 22:37 django-logo-positive.png  
-rw-r--r--  1 kr0m  kr0m      348 Mar 16 22:37 index-c4b6e89240744702871f.js  
-rw-r--r--  1 kr0m  kr0m   161364 Mar 16 22:37 minimal-c4b6e89240744702871f.css  
-rw-r--r--  1 kr0m  kr0m   173306 Mar 16 22:37 minimal-c4b6e89240744702871f.js  
-rw-r--r--  1 kr0m  kr0m     1976 Mar 16 22:37 minimal-c4b6e89240744702871f.js.LICENSE.txt

Mas adelante montaremos un HaProxy para balancear el tráfico, esto supone un problema para determinar el origen de la petición ya que todos los paquetes a nivel Ip provienen del balanceador, para poder leer la información correctamente debemos: 

  • Añadir la cabecera X-Forwarded-For si se trata de tráfico HTTP
  • Utilizar el protocolo Proxy Protocol si se trata de tráfico HTTPS

NOTA: Podríamos utilizar Proxy Protocol también para HTTP pero ProxyProtocol es un protocolo binario por lo tanto resultaría mas complicado de debugear que HTTP plano.

El fichero de configuración principal de Nginx lo dejamos prácticamente como viene por defecto pero asignamos el valor de la cabecera X-Forwarded-For a la variable $proxy_protocol_addr y definimos un nuevo formato de log llamado proxyprotocollogformat que utilizará dicha variable.

    proxy_set_header X-Forwarded-For $proxy_protocol_addr;  
    log_format proxyprotocollogformat  
            '$proxy_protocol_addr - $remote_user [$time_local] '  
            '"$request" $status $body_bytes_sent '  
            '"$http_referer" "$http_user_agent"';

Luego incluimos el fichero de configuración del vhost en si mismo, en este caso rxwod.conf

include rxwod.conf;

Y finalmente indicamos que solo debemos fiarnos de 192.168.69.11(HaProxy ip) cuando obtengamos los datos de la ip origen de la petición y esta información la obtendremos leyendo la cabecera X-Forwarded-For.

   set_real_ip_from    192.168.69.11;  
   real_ip_header      X-Forwarded-For;

El fichero quedaría del siguiente modo:

vi /usr/local/etc/nginx/nginx.conf

worker_processes  1;
events {
    worker_connections  1024;
}
http {
    include       mime.types;
    default_type  application/octet-stream;
    proxy_set_header X-Forwarded-For $proxy_protocol_addr;
    log_format proxyprotocollogformat
            '$proxy_protocol_addr - $remote_user [$time_local] '
            '"$request" $status $body_bytes_sent '
            '"$http_referer" "$http_user_agent"';
    include rxwod.conf;
    sendfile        on;
    keepalive_timeout  65;
    server {
        listen       80;
        server_name  localhost;
	
        set_real_ip_from    192.168.69.11;
        real_ip_header      X-Forwarded-For;
        location / {
            root   /usr/local/www/nginx;
            index  index.html index.htm;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   /usr/local/www/nginx-dist;
        }
    }
}

Como vemos la configuración HTTP es muy básica ya que solo la vamos a utilizar para renovar el certificado SSL y nuestra aplicación solo estará accesible por HTTPS por tratar con datos de carácter personal como el login.

La configuración SSL es un poco mas compleja, primero indicamos que solo debemos fiarnos de 192.168.69.11(HaProxy ip) cuando obtengamos los datos de la ip origen de la petición pero en esta ocasión obtendremos dicha información mediante ProxyProtocol.

    set_real_ip_from 192.168.69.11;
    real_ip_header proxy_protocol;

Cuando se pida contenido bajo la ruta /static/ serviremos los ficheros del directorio /usr/local/www/rxWod/

    location /static/ {
        root /usr/local/www/rxWod/;
    }

El resto de peticiones realizaremos un proxy_pass a Daphne pero antes asignaremos ciertas cabeceras:

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Host $server_name;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://rxwod.alfaexploit.com:8000;

El fichero de configuración final quedaría del siguiente modo:

vi /usr/local/etc/nginx/rxwod.conf

server {
    listen 443 ssl default_server proxy_protocol;
    server_name rxwod.alfaexploit.com;
    set_real_ip_from 192.168.69.11;
    real_ip_header proxy_protocol;
    ssl_certificate "/usr/local/etc/ssl/rxwod.alfaexploit.com/fullchain.cer";
    ssl_certificate_key "/usr/local/etc/ssl/rxwod.alfaexploit.com/rxwod.alfaexploit.com.key";
    location = /favicon.ico { access_log off; log_not_found off; }
    location /static/ {
        root /usr/local/www/rxWod/;
    }
    location /{
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Host $server_name;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://rxwod.alfaexploit.com:8000;
    }
}

Para que el proxy_pass pueda funcionar por nombre de dominio como vemos en la configuración, tendremos que hacer que rxwod.alfaexploit.com resuelva a la propia ip del servidor:

vi /etc/hosts

192.168.69.12  rxwod.alfaexploit.com

Para poder reiniciar Nginx antes debemos obtener el certificado SSL:

​pkg install curl socat
curl https://get.acme.sh | sh -s email=kr0m@alfaexploit.com

Configuramos ACME para el dominio rxwod:

/root/.acme.sh/acme.sh --issue -d rxwod.alfaexploit.com -w /usr/local/www/nginx --renew-hook ‘rm -rf /usr/local/etc/ssl/rxwod.alfaexploit.com/ && cp -r /root/.acme.sh/rxwod.alfaexploit.com /usr/local/etc/ssl/ && service nginx restart’
cp -r /root/.acme.sh/rxwod.alfaexploit.com /usr/local/etc/ssl/

Reiniciamos el servicio:

service nginx restart


Haproxy

La configuración del HaProxy es muy similar a la descrita en este otro artículo así que no la repetiré a favor de una mayor brevedad del artículo, básicamente consiste en filtrar por nombre de dominio y enviar el tráfico al backend pertinente, si se accede por HTTP se realizará un redirect a HTTPS a no ser que se trate de LetsEncrypt intentando renovar el certificado.

Cuando enviamos el tráfico del HaProxy a los servidores web estos solo verán como ip origen la del HaProxy, no la del cliente real, para evitar esto haremos que el HaProxy añada una cabecera llamada X-Forwarded-For para HTTP y utilice ProxyProtocol para HTTPS.


Retoques finales

Ya tenemos nuestro proyecto funcionando pero todavía hay algunas medidas que debemos tomar antes de expornerlo al público general. Todos los parámetros se configurarán en el fichero rxWodProject/settings.py pero para poder gitear el setting.py controlaremos los valores a cargar mediante un fichero externo(production_enabled) que estará ignorado.

vi rxWodProject/settings.py
"""
Django settings for rxWodProject project.

Generated by 'django-admin startproject' using Django 3.1.7.

For more information on this file, see
https://docs.djangoproject.com/en/3.1/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""

from pathlib import Path
import os

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
secret_key_path = str(Path(__file__).resolve().parent) + '/secret_key.txt'
with open(secret_key_path) as f:
    SECRET_KEY = f.read().strip()

production_enabled = str(Path(__file__).resolve().parent) + '/production_enabled'
f = open(production_enabled, "r")
lines = f.readlines()
f.close()
if lines[0].strip().split(' ')[0] == '1':
    PRODUCTION_ENABLED = True
else:
    PRODUCTION_ENABLED = False

if PRODUCTION_ENABLED:
    ALLOWED_HOSTS = ['rxwod.alfaexploit.com']
    DEBUG = False
    SECURE_SSL_REDIRECT = True
    SECURE_HSTS_SECONDS = 3600
    SECURE_HSTS_INCLUDE_SUBDOMAINS = True
    SECURE_HSTS_PRELOAD = True
    CSRF_COOKIE_SECURE = True
    SESSION_COOKIE_SECURE = True
    CONN_MAX_AGE = 1
    ADMINS = [('kr0m', 'kr0m@alfaexploit.com')]
else:
    # SECURITY WARNING: don't run with debug turned on in production!
    DEBUG = True
    ALLOWED_HOSTS = []


INTERNAL_IPS = [
    '127.0.0.1',
]

# Application definition

INSTALLED_APPS = [
    'rxWod.apps.RxwodConfig',
    'usersAuth.apps.UsersauthConfig',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'debug_toolbar',
    'webpack_loader',
]

if PRODUCTION_ENABLED:
    MIDDLEWARE = [
        'django.middleware.common.BrokenLinkEmailsMiddleware',
        'django.middleware.security.SecurityMiddleware',
        'django.contrib.sessions.middleware.SessionMiddleware',
        'django.middleware.common.CommonMiddleware',
        'django.middleware.csrf.CsrfViewMiddleware',
        'django.contrib.auth.middleware.AuthenticationMiddleware',
        'debug_toolbar.middleware.DebugToolbarMiddleware',
        'django.contrib.messages.middleware.MessageMiddleware',
        'django.middleware.clickjacking.XFrameOptionsMiddleware',
    ]
    MANAGERS = [('kr0m', 'kr0m@alfaexploit.com')]

    import re
    IGNORABLE_404_URLS = [
        re.compile(r'\.(php|cgi)$'),
        re.compile(r'^/phpmyadmin/'),
    ]
else:
    MIDDLEWARE = [
        'django.middleware.security.SecurityMiddleware',
        'django.contrib.sessions.middleware.SessionMiddleware',
        'django.middleware.common.CommonMiddleware',
        'django.middleware.csrf.CsrfViewMiddleware',
        'django.contrib.auth.middleware.AuthenticationMiddleware',
        'debug_toolbar.middleware.DebugToolbarMiddleware',
        'django.contrib.messages.middleware.MessageMiddleware',
        'django.middleware.clickjacking.XFrameOptionsMiddleware',
    ]

ROOT_URLCONF = 'rxWodProject.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'rxWodProject.wsgi.application'


# Database
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases

#DATABASES = {
#    'default': {
#        'ENGINE': 'django.db.backends.sqlite3',
#        'NAME': BASE_DIR / 'db.sqlite3',
#    }
#}

database_auth_path = str(Path(__file__).resolve().parent) + '/database_auth.txt'
f = open(database_auth_path, "r")
lines = f.readlines()
f.close()
DB_USERNAME = lines[0].strip().split(' ')[0]
DB_PASSWORD = lines[1].strip().split(' ')[0]

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': 'rxwod',
        'USER': DB_USERNAME,
        'PASSWORD': DB_PASSWORD,
    }
}


# Password validation
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/

LANGUAGE_CODE = 'es-es'

TIME_ZONE = 'Europe/Madrid'

USE_I18N = True

USE_L10N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/

STATIC_URL = '/static/'

STATICFILES_DIRS = (
    os.path.join(BASE_DIR, 'assets/bundles/'),
)

STATIC_ROOT = '/usr/local/www/rxWod/static/'

WEBPACK_LOADER = {
    'DEFAULT': {
        'BUNDLE_DIR_NAME': '',
        'LOADER_CLASS': 'fixes.webpack.CustomWebpackLoader',
        'STATS_FILE': os.path.join(BASE_DIR, 'webpack-stats.json'),
    }
}

# Email configuration
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'

email_auth_path = str(Path(__file__).resolve().parent) + '/email_auth.txt'
f = open(email_auth_path, "r")
lines = f.readlines()
f.close()

EMAIL_HOST = lines[0].strip().split(' ')[0]
EMAIL_USE_TLS = lines[1].strip().split(' ')[0]
EMAIL_PORT = lines[2].strip().split(' ')[0]
EMAIL_HOST_USER = lines[3].strip().split(' ')[0]
EMAIL_HOST_PASSWORD = lines[4].strip().split(' ')[0]

LOGIN_URL='/accounts/login'
LOGIN_REDIRECT_URL='/'

Ignoramos el fichero de control:

vi .gitignore

rxWodProject/secret_key.txt
rxWodProject/database_auth.txt
rxWodProject/email_auth.txt
rxWod/migrations
usersAuth/migrations
db.sqlite3
node_modules
yarn-error.log
assets/bundles/*
assets/videos/*
webpack-stats.json
django.mo
djangojs.mo
__pycache__/
*.core
rxWodProject/production_enabled

Según el entorno en el que nos encontremos asignaremos un valor u otro al fichero:

cd /home/kr0m/rxWod/rxWodProject/rxWodProject

Producción:

echo 1 > production_enabled

Desarrollo:

echo 0 > production_enabled

A continuación explico cada uno de los parámetros en detalle.

Deshabilitamos el modo Debug, este resulta muy útil cuando estamos desarrollando pero puede suponer un leak de información si se mantiene en producción además de obtener un menor rendimiento, así que lo deshabilitamos:

DEBUG = False

Los parámetros MEDIA_URL/MEDIA_ROOT hacen referencia a ficheros subidos por el usuario, en mi caso no será necesario configurarlos ya que no se permite en ningún caso subir ficheros, pero los dejo documentados como referencia:

MEDIA_URL: URL desde la que colgarán los ficheros media.
MEDIA_ROOT: Path donde se encuentra el contenido media.

NOTA: Estos ficheros no son de confianza ya que los ha subido el usuario por lo tanto debemos tomar medidas a nivel de servidor web para no interpretarlos ni ejecutarlos.

En cuanto al acceso SSL solo vamos a permitir acceso a la web por HTTPS ya que los datos son personales y hay un login en esta, no queremos que nadie consiga las credenciales de uno de nuestros usuarios o que altere el contenido de sus rutinas mediante ataques de tipo MITM por ejemplo, mediante configuración del HaProxy solo permitimos el acceso por HTTPS a no ser que se trate de LetsEncrypt intentando renovar el certificado.

Otro aspecto a tener en cuenta es que nuestro Nginx enviará las peticiones a Daphne en claro, para que Daphne sea consciente de si llegaron por HTTP o HTTPS Nginx debe insertar una cabecera(X-Forwarded-Proto), de este modo podremos forzar el SSL( SECURE_SSL_REDIRECT ) en el proyecto de Django.

Si no insertamos la cabecera y forzamos el SSL(SECURE_SSL_REDIRECT) el proyecto de Django indetificará las peticiones como HTTP y entraremos en un bucle de redirects ya que siempre detectará el acceso como inseguro:

ERR_TOO_MANY_REDIRECTS

Forzamos el acceso por SSL:

SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 3600
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

Ahora que ya tenemos SSL funcionando debemos configurar el parámetro CSRF_COOKIE_SECURE a True para que marque las cookies CSRF como seguras, de este modo el navegador solo permitirá el uso de cookies por HTTPS.

CSRF_COOKIE_SECURE = True

Este parámetro es idéntico al anterior pero para las cookies de sesión.

SESSION_COOKIE_SECURE = True

Cada vez que se consulta un dato en la base de datos se realiza una conexión y se cierra, esto es muy ineficiente sobre todo en aplicaciones que hagan un uso intensivo de esta. Para evitar este comportamiento podemos habilitar las conexiones persistentes de este modo las conexiones permanecerán abiertas Xs, aumentando de este modo el rendimiento.

Los valores permitidos son:

  • 0: Por defecto, se abre y se cierra la coneción en cada petición
  • X: Valor expresado en segundos indicando cuanto tiempo debe mantener la conexión abierta
  • None: Nunca cierra la conexión
CONN_MAX_AGE = 1

Cuando configuramos un proyecto para que pase a producción debemos deshabilitar el modo DEBUG para que funcione mucho mas rápido y prevenir que usuarios maliciosos obtengan detalles de la aplicación.

Por otro lado al deshabilitar el DEBUG ya no veremos errores cuando estos ocurran lo que supone un problema ya que la aplicación podría estar fallanado sin ser conscientes de ello, para estos casos utilizaremos el sistema de informe de errores de Django .

El sistema de informe de errores se comportará de un modo u otro dependiendo del tipo de error:

  • 500 internal server error: Enviará un email a todos los usuarios definidos en el parámetro ADMINS, estos obtendrán una descripción del error, una traza completa del error de Python y detalles de la petición HTTP. Si se ha seguido la guía completa todos los parámetros necesarios para poder enviar emails deberían estar configurados.
ADMINS = [('kr0m', 'kr0m@alfaexploit.com')]
  • 404 page not found error: Para habilitar el envío de estos informes además de deshabilitar el DEBUG debemos añadir django.middleware.common.BrokenLinkEmailsMiddleware en la sección MIDDLEWARE del fichero settings.py, los errores solo se reportarán si estos fueron producidos por una petición con un referer y este referer no es la URL de nuestra web. Los usuarios a notificar se indican con el parámetro MANAGERS.
MIDDLEWARE = [
    'django.middleware.common.BrokenLinkEmailsMiddleware',
    ...
]
MANAGERS = [('kr0m', 'kr0m@alfaexploit.com')]

Podemos configurar expresiones regulares para que no nos informe sobre ciertas peticiones, por ejemplo que no nos informe si intentan acceder a ficheros .php, .cgi o alguna URL que cuelgue de /phpmyadmin/.

import re
IGNORABLE_404_URLS = [
    re.compile(r'\.(php|cgi)$'),
    re.compile(r'^/phpmyadmin/'),
]

Debemos tener en cuenta que los informes contienen información sensible, solo debemos reportarlos a personal de confianza mediante canales seguros.
Incluso cuando el personal es de confianza hay ciertos datos que puede que no queramos que sean enviados como números de tarjeta de credito por ejemplo, Django nos permite ofuscar dichos datos:

Si lo que necesitamos es generar informes personalizados también podremos hacerlo:
https://docs.djangoproject.com/en/3.1/howto/error-reporting/#custom-error-reports

Podemos comprobar que la mayoría de los parámetros estén configurados correctamente mediante el comando:

cd /home/kr0m/rxWod/rxWodProject
python manage.py check --deploy

System check identified no issues (0 silenced).

Algunas consideraciones adicionales antes de pasar a producción son:

  • Utilizar cacheo para las sessiones
  • Cachear los templates

Finalmente configuramos nuestros DNS para que el dominio apunte a la dirección ip de nuestro HaProxy y accedemos a nuestra web para comprobar su correcto funcionamiento:


Segundo programador

Si ampliamos nuestro equipo de programadores, la nueva incorporación tan solo deberá seguir los siguientes pasos:

Instalamos el cliente de Git:

pkg install git

Instalamos Yarn:

pkg install npm
npm install -g yarn

Realizamos la configuración inicial de Git(como usuario regular):

git config --global user.name “coder2”
git config --global user.email coder2@alfaexploit.com

Creamos el proyecto de Django y clonamos el repositorio:

python3.7 -m venv rxWod
cd rxWod
git clone gitolite@192.168.69.5 :RxWodProject

Instalamos sus dependencias, hay algunas que mediante pip son problemáticas, las instalamos de forma global:

pkg install py37-tkinter py37-sqlite3

El resto mediante pip:

source bin/activate
/usr/home/kr0m/rxWod/bin/python3.7 -m pip install --upgrade pip
pip install wheel
pip install -r RxWodProject/requirements.txt
mv RxWodProject rxWodProject
cd rxWodProject

Instalamos las dependencias JS:

yarn install

Rellenamos los ficheros de credenciales e información sensible:

vi rxWodProject/secret_key.txt
vi rxWodProject/database_auth.txt
vi rxWodProject/email_auth.txt

Creamos el directorio de los bundles:

mkdir assets/bundles/

Ejecutamos las posibles migraciones pendientes en la base de datos:

python manage.py makemigrations
python manage.py migrate

Compilamos los bundles de WebPack y arrancamos el servidor de pruebas:

yarn dev-build


Script deploy:

Cada vez que se subamos algún cambio al repositorio debemos desplegarlo en el servidor en producción, a continuación se muestra un script para desplegar los cambios de forma mas ágil.

Uno de los pasos precisa reiniciar el servidor Daphne, para ello configuraremos sudo.

pkg install sudo
vi /usr/local/etc/sudoers.d/kr0m

kr0m ALL=(ALL) NOPASSWD: /usr/sbin/service daphne restart

Como user regular:

mkdir .scripts
vi .scripts/deploy.sh

#!/usr/local/bin/bash
cd /home/kr0m/rxWod/rxWodProject
git pull
cd ..
source bin/activate
pip install -r rxWodProject/requirements.txt
cd rxWodProject
python manage.py makemigrations
python manage.py migrate
yarn install
yarn prod-build
python manage.py collectstatic --noinput
sudo /usr/sbin/service daphne restart
chmod 700 .scripts/deploy.sh

Este artículo ha resultado ser mucho mas largo de lo esperado pero pienso que se ha explicado todo el proceso de la forma mas sencilla posible sin dejar ningún cabo suelto, de todos modos si hay alguna duda siempre podéis poneros en contacto conmigo vía Telegram y realizar las preguntas que consideréis necesarias.

Si te ha gustado el artículo puedes invitarme a un RedBull aquí