domingo, 29 de diciembre de 2024

Crear y descodificar mensajes codificados con Enigma usando Python




Basado en "OctaPi: brute-force Enigma"

y el módulo py-enigma.

Licencia de "Creative Commons Attribution 4.0 International License" (CC BY 4.0).

Lo que vamos a hacer en este post es crear mensajes encriptados, mensajes secretos que solo tu y las personas en las que confíes podrán leer. Luego desarrollaremos código en Python para realizar un ataque criptográfico de fuerza bruta parcial sobre los mensajes de Enigma y recuperar la configuración de los rotores de la máquina.

¿Qué es la máquina Enigma y como funciona?

Antes de nada, para entender mejor el proceso, puedes ver como funciona la máquina enigma en este video.



Enigma es una máquina de cifrado que fue creada a principios del siglo XX para aplicaciones comerciales, diplomáticas y militares. Durante la Segunda Guerra Mundial, la máquina fue adoptada por el ejército alemán para comunicaciones secretas. El código de cifrado Enigma fue famoso por ser descifrado durante la guerra en Bletchley Park, el precursor del GCHQ, lo que permitió que los mensajes interceptados del ejército alemán pudieran ser descifrados y leídos. Este logro espectacular se cree que acortó la guerra, salvando muchas vidas en ambos lados del conflicto.

Desde un punto de vista eléctrico, la máquina Enigma es simplemente una batería, 26 bombillas y un circuito de interruptores. No tiene electrónica, por lo que es un dispositivo electro-mecánico. El cifrado se logra variando el camino de una corriente eléctrica a través del cableado de la máquina.


funcionamiento de la máquina enigma


Pej. Si tecleamos una T, con una determinada configuración de los rotores, obtendremos una G como letra cifrada.

En el diagrama anterior, puedes ver cómo una letra tecleada en el teclado pasa por muchas etapas de transposición antes de ser dirigida a una bombilla en el tablero de luces que representa la letra cifrada. El usuario escribe su mensaje en texto normal en el teclado, carácter por carácter, y lee el texto cifrado a medida que cada bombilla se ilumina en el tablero de luces en respuesta. Debido a la forma en que se realiza la transposición, una letra tecleada nunca se cifra como ella misma (por ejemplo, teclear una A nunca iluminará la bombilla para A).

El diagrama puede dar la impresión de que la transposición de letras es invariable. Sin embargo, esto no es cierto; la forma en que se transponen las letras cambia con cada letra que se teclea en la máquina Enigma. ¡Eso es lo que hizo que el código Enigma fuera tan difícil de romper! La transposición cambia porque, a medida que se teclea cada letra, el camino de la corriente cambia a medida que fluye hacia las bombillas. ¿Cómo funciona esto?

Rotores y Reflectores.

En el interior de la máquina, varios rotores con 26 contactos (uno para cada letra de la A a la Z) están apilados juntos para crear el camino de la corriente a través del corazón de la máquina. Cada rueda del rotor tiene 26 contactos eléctricos en ambos lados y un enredo de cableado en el interior, de modo que las letras tecleadas se transponen de un lado al otro. En la práctica, esto significa que un rotor específico transponde la A en E, la B en K, la C en M, y así sucesivamente.



En la foto anterior, puedes ver el lio de cables dentro de una rueda de rotor expandida de una máquina Enigma capturada en la Segunda Guerra Mundial. Al apilar varios rotores y usar un reflector al final para devolver la corriente a través de los rotores, cada letra se transponde muchas veces. Al usar la máquina Enigma, se seleccionan tres rotores de cinco disponibles (también había máquinas con cuatro rotores). La configuración de transposición del reflector es fija, asegurando que la corriente regrese a través de la máquina sin invertir la transposición.

Entonces, ¿Cómo se cambia el camino de la corriente que fluye a través de estos componentes?

Movimiento de los rotores Para utilizar una transposición diferente caracter por caracter, el primer rotor avanza paso a paso a medida que se escribe cada letra del mensaje, creando una nueva ruta para la corriente cada vez. Como resultado, el usuario puede escribir 'LL' y ambas letras serán cifradas de manera diferente, por lo que el resultado podría ser 'XV'. Después de que el primer rotor haya avanzado 26 posiciones (una revolución completa), la máquina comienza a avanzar la posición del siguiente rotor, y así sucesivamente.

Posiciones iniciales de los rotores Parte de lo que hace que el cifrado de Enigma sea difícil de romper es el hecho de que cada rotor puede usarse con una posición inicial diferente. Por ejemplo, si un rotor se establece en la posición 10 al principio y se introduce la letra A en la máquina, no entrará donde la A entra por defecto, sino donde entra la J (letra 10 en el alfabeto) por defecto. Ten en cuenta que un rotor avanzará 26 pasos sin importar cuál sea su posición inicial.

Para facilitar su ajuste, el rotor está marcado con un anillo alfabético. Por lo tanto, una posición inicial de 10 se lograría ajustando el rotor de modo que la letra J sea visible; la posición inicial de "JFM" para tres rotores significaría ajustar el primer rotor a J, el segundo a F y el tercero a M.

Anillos deslizantes Además, las asignaciones de letras pueden desplazarse al girar un anillo deslizante en el rotor. Girar el anillo deslizante rota el cableado dentro del rotor. Por ejemplo, supongamos que con el anillo deslizante en la posición predeterminada, el cableado del rotor transpondría A en E, B en K, C en M, y así sucesivamente. Mover el anillo deslizante en 1 significaría que la letra A se transpondría en K, B en M, etc.

Tablero de conexiones Como si esto no fuera suficiente, la versión alemana de la máquina Enigma también incluye un tablero de conexiones (el cuadro verde más a la izquierda en el diagrama en la parte superior), que se puede ajustar manualmente para que hasta diez pares de letras se transpongan al entrar en los rotores y nuevamente al salir.

Configuraciones de cifrado Al combinar tres rotores de un conjunto de cinco, las configuraciones de rotor con 26 posiciones y el tablero de conexiones con diez pares de letras conectadas, la máquina Enigma utilizada por el ejército durante la Segunda Guerra Mundial tenía 158962555217826360000 (casi 159 quintillones) configuraciones diferentes.

El cifrado dependía de que tanto la máquina Enigma emisora como la receptora estuvieran configuradas de la misma manera. Para lograr esto, se usaban hojas de configuración secretas idénticas en las estaciones de comunicación emisoras y receptoras. Estas hojas especificaban:

  • Qué rotores debían seleccionarse y en qué orden debían insertarse en la máquina.
  • Cuánto debía girar cada rotor.
  • Qué letras debían ser cambiadas por el tablero de conexiones.
  • Qué posiciones iniciales de los rotores debían usarse.

Se utilizaban diferentes configuraciones de máquina cada día, y las posiciones iniciales de los rotores se cambiaban incluso cada seis horas, por lo que la configuración de la máquina era altamente sensible al tiempo. Por eso las hojas de configuración distribuidas por el ejército eran tan cuidadosamente guardadas.


Hoja de configuraciones Esta es una simulación de una hoja de configuraciones de Enigma capturada al final de la Segunda Guerra Mundial. En la vista ampliada de una de las líneas mostradas a continuación, puedes ver cómo se disponen las diversas configuraciones:


Una línea de configuraciones de una hoja de Enigma capturada durante la Segunda Guerra Mundial

Las configuraciones en las que hemos hecho zoom son para el primer día del mes, de ahí el "1" en la segunda columna desde la izquierda. 

La siguiente columna muestra que los rotores IV, I y V deben seleccionarse y usarse en ese orden. 

La cuarta columna contiene las configuraciones de los anillos deslizantes: el rotor IV debe ajustarse a la posición 20, el rotor I a la posición 5 y el rotor V a la posición 10. 

Luego vienen las conexiones del tablero de enchufes: S a X, K a U, Q a F, y así sucesivamente. 

Finalmente, las posiciones iniciales de los rotores para los cuatro períodos de seis horas del día son "SRC", "EEJ", "FNZ" y "SZK". Además de eso, había dos reflectores, B y C, uno de los cuales se elegía para su uso. Para los programas de cifrado y descifrado aquí, asumiremos el uso del reflector B.

Clave única Para cada mensaje durante la Segunda Guerra Mundial, el remitente también seleccionaba tres caracteres por sí mismo como una clave única para ese mensaje, digamos "RPF". Cifraban esta clave usando las configuraciones de la hoja de configuraciones y anotaban el resultado, digamos "QMD". Luego procedían a cifrar su mensaje usando su clave única, en este caso "RPF", como las posiciones iniciales de los rotores, anotando el texto cifrado que la máquina devolvía. La versión cifrada de la clave, en este caso "QMD", más el texto cifrado, se enviaban al destinatario por radio.


Uso de la máquina enigma durante la segunda guerra mundial.

En la Segunda Guerra Mundial, los mensajes cifrados con Enigma solían enviarse en código Morse a través de radio de onda corta. Esto significaba que podían interceptarse fácilmente a cierta distancia, por lo que el ejército alemán dependía en gran medida de la fortaleza de la técnica de cifrado para mantener sus mensajes en secreto. Sin embargo, Gran Bretaña interceptó y descifró con éxito los mensajes en Bletchley Park.

Una transmisión cifrada con Enigma habría tenido un aspecto similar a este:

mensaje cifrado con enigma

Mensaje cifrado

¿Cómo cifraban los operadores en las estaciones de comunicaciones?

Paso 1: Seleccionar los rotores y elegir una clave de mensaje de tres letras


Primero, el operador buscaba la línea en la hoja de configuraciones correspondiente al día del mes actual. Esto le indicaba cómo configurar la máquina Enigma, incluyendo qué rotores seleccionar y en qué orden colocarlos, así como la posición inicial de los rotores para el período de seis horas actual.

Paso 2: Elegir y cifrar una clave de mensaje de tres letras


Luego, el operador elegía una clave de mensaje de tres letras única y aleatoria para cada mensaje. Supongamos que pensó en "SCC" como la clave. Evidentemente, esta clave no podía enviarse abiertamente. Para cifrarla antes de la transmisión, el operador escribía "SCC" en la máquina Enigma configurada según la hoja, obteniendo (por ejemplo) "PWE" como la clave cifrada. Esta clave ya era segura para enviarse por un canal de radio.

Durante al menos una parte de la Segunda Guerra Mundial, el procedimiento militar alemán consistía en enviar y cifrar la clave del mensaje dos veces. Siguiendo nuestro ejemplo, el operador habría escrito "SCCSCC" y obtenido "PWEHVF".

¿Cuál es el problema de repetir la clave del mensaje?

Anteriormente mencionamos que ninguna letra del texto plano se cifra como sí misma. Esto significa que cualquier persona que intercepte un mensaje codificado con Enigma sabe que ninguna de las letras en la clave del mensaje descifrada puede ser la correcta. En nuestro ejemplo, interceptar la clave “PWE” nos dice que “P” no es la primera letra, “W” no es la segunda y “E” no es la tercera.

Si la clave del mensaje se envía dos veces, como solía hacer el ejército alemán, también sabemos que la primera letra no puede ser “H”, la segunda no puede ser “V” y la tercera no puede ser “F”. Esto reduce la cantidad de búsqueda necesaria para encontrar las letras del texto plano de la clave del mensaje, porque ya podemos excluir dos opciones para cada letra de la clave.

Paso 3: Cifrar el mensaje utilizando la clave del mensaje sin cifrar

Una vez que la clave del mensaje había sido elegida y cifrada, el operador ajustaba los rotores a la versión sin cifrar de la clave que había elegido y escribía el mensaje en el teclado.

Los números debían escribirse completamente en palabras, ya que la máquina Enigma no tenía teclas numéricas. Además, no había barra espaciadora, por lo que a menudo se usaba una ‘X’ para indicar un espacio. Por ejemplo, si queríamos cifrar “este mensaje es secreto”, escribiríamos “ESTEXMENSAJEXESXSECRETO”.

Paso 4: Enviar el mensaje cifrado por radio

Un operador de radio enviaba la clave cifrada y el mensaje en código Morse, utilizando distintivos de llamada y texto abreviado, de forma similar a cómo usamos abreviaturas en los mensajes de texto para reducir la cantidad de escritura necesaria.

Encriptando un mensaje.

Para poder emular la máquina "enigma" en python necesitaremos instalar el paquete py-enigma.

Crea un entorno virtual con:

python3 -m venv miEntorno
source ./miEntorno/bin/activate
pip3 install py-enigma

Luego utiliza el IDLE que más te guste y crea el archivo encriptar.py

Lo primero que tenemos que hacer es importar la clase EnigmaMachine desde el módulo py-enigma de la siguiente forma:

from enigma.machine import EnigmaMachine

Tendríamos que ver en que día del mes estamos pero para esta práctica vamos a suponer que estamos a día uno. Si consultamos la hoja de configuraciones de la máquina enigma que vimos anteriormente para el día uno vemos que son las siguientes:

enigma

En tu archivo de Python, configura un objeto EnigmaMachine utilizando los ajustes de tu hoja de configuraciones. Cada ajuste debe ser una cadena y escribirse exactamente como aparece en la hoja. Por ejemplo, los rotores se configurarían como 'IV I V'.

# configuración de la máquina enigma
machine = EnigmaMachine.from_key_sheet(
    rotors='IV I IV',
    reflector='B',
    ring_settings='20 5 10',
    plugboard_settings='SX KU QP VN JG TC LA WM OB ZF'
)

Como ya comentamos usaremos el reflector B para todos los programas.

Escribe otra línea de código para establecer la posición inicial de los rotores, la configuración está al final de la hoja de configuraciones para el día 1.

# Establecemos la posición inicial de los rotores.
machine.set_display('FNZ')

Elige tres letras al azar para usar como tu clave de cifrado; usaremos "BLA", pero puedes elegir las que quieras. Cifra la clave del mensaje y anota el resultado. Esta será la clave cifrada que enviarás junto con tu mensaje.

# Encripta el texto 'BFR' y guardalo como msg_key
msg_key = machine.process_text('BLA')
print(msg_key)


Si ejecutas el código deberías obtener la siguiente salida del programa:

(miEntorno) usuario:~/enigma$ python3 encriptar.py 
LHU

Escribe una línea de código para restablecer las posiciones iniciales de los rotores a tu clave de cifrado (en nuestro ejemplo, “BLA”).

Luego, escribe un código para procesar el texto plano “LOSXRUSOSXSEXACERCAN” y mostrar el texto cifrado resultante.

# Encriptamos el texto plano a enviar
machine.set_display('BLA')    # usamos la clave de cifrado BLA
ciphertext = machine.process_text('LOSXRUSOSXSEXACERCAN')
print(ciphertext)



Si has usado la clave de cifrado "BLA", el texto cifrado esperado debería ser "JLISVXPGICEZSMQFKXFK". Si has escogido una clave diferente entonces el texto cifrado no será el mismo.

Desencriptando el mensaje

Imagina que eres un operador de la máquina enigma y acabas de recibir este mensaje:

LHU JLISVXPGICEZSMQFKXFK


Vamos a escribir código de Python que nos permita, utilizando el módulo Py-enigma simular que estamos usando una máquina enigma para desencriptar el mensaje.

Abre le IDLE que estés utilizando y crea un archivo llamado desencriptar.py

Consultando la hoja de configuración de la máquina enigma encuentras que esta es la línea que corresponde con el día y hora de envió del mensaje. En nuestro caso es el mismo día y misma hora que usamos para la codificación.

enigma

Dentro del archivo que has creado, importa la clase EnigmaMachine igual que hicimos anteriormente, y configura la maquina igual que hicimos antes. El código sería algo como esto:

# configuración de la máquina enigma
machine = EnigmaMachine.from_key_sheet(
    rotors='IV I IV',
    reflector='B',
    ring_settings='20 5 10',
    plugboard_settings='SX KU QP VN JG TC LA WM OB ZF'
)

Añadiremos también el código necesario para establecer la posición inicial de los rotores.

# Establecemos la posición inicial de los rotores.
machine.set_display('FNZ')

El operador de radio te ha enviado la clave "LHU" como llave para este mensaje. Antes de enviarla la clave estaba encriptada para impedir que algún espía la pudiera interceptar. Por tanto para leer el mensaje lo primero que tenemos que hacer es desencriptar la clave que hemos recibido para poder usar la clave real de desencriptado.

# Desencriptamos el texto 'LHU' para obtener el msg_key
msg_key = machine.process_text('LHU')
print(msg_key)
Si ejecutas el programa obtendrás la clave real utilizada anteriormente que era, en mi caso "BLA".

Ya solo nos queda desencriptar el mensaje.

# Reiniciamos la posición inicial de los rotores para decodificar el mensaje principal
# usando la clave original
machine.set_display(msg_key)

# Decodificamos el texto cifrado
texto_cifrado = 'JLISVXPGICEZSMQFKXFK'
texto_plano = machine.process_text(texto_cifrado)

print(texto_plano)

Si todo ha ido bien al desencriptar el mensaje deberías ver lo siguiente:

LOSXRUSOSXSEXACERCAN

Ejemplo de procedimiento de comunicación.

La Wehrmacht tenía diversos procedimientos elaborados para transmitir y recibir mensajes. Estos procedimientos variaban según la rama del servicio y también cambiaban a lo largo de la guerra. En general, los procedimientos de la Kriegsmarine eran más complejos e implicaban no solo hojas de claves, sino también otros documentos auxiliares. Además, cada rama del ejército tenía sus propias convenciones para codificar abreviaturas, números, caracteres de espacio, nombres de lugares, etc. Palabras o frases importantes podían necesitar ser repetidas o resaltadas de alguna manera.

Supongamos que se necesita transmitir un mensaje. El operador de la máquina transmisora consulta su hoja de claves y configura su máquina de acuerdo con los ajustes diarios indicados en ella. 

Según la wikipedia, el siguiente, es un mensaje auténtico enviado el 7 de julio de 1941 por la División SS-Totenkopf sobre la campaña contra Rusia, en la Operación Barbarroja. El mensaje (enviado en dos partes) fue descifrado por Geoff Sullivan y Frode Weierud, dos miembros del Crypto Simulation Group (CSG).

Puedes observar como los caracteres se mostraban en grupos de 5 caracteres.

1840 - 2TLE 1TL 179 - WXC KCH
RFUGZ EDPUD NRGYS ZRCXN
UYTPO MRMBO FKTBZ REZKM
LXLVE FGUEY SIOZV EQMIK
UBPMM YLKLT TDEIS MDICA
GYKUA CTCDO MOHWX MUUIA
UBSTS LRNBZ SZWNR FXWFY
SSXJZ VIJHI DISHP RKLKA
YUPAD TXQSP INQMA TLPIF
SVKDA SCTAC DPBOP VHJK

2TL 155 - CRS YPJ
FNJAU SFBWD NJUSE GQOBH
KRTAR EEZMW KPPRB XOHDR
OEQGB BGTQV PGVKB VVGBI
MHUSZ YDAJQ IROAX SSSNR
EHYGG RPISE ZBOVM QIEMM
ZCYSG QDGRE RVBIL EKXYQ
IRGIR QNRDN VRXCY YTNJR
SBDPJ BFFKY QWFUS
El mensaje fue cifrado para el modelo de tres rotores con el reflector B. Para descifrar el mensaje, primero hay que configurar la máquina con la información diaria especificada en los libros de códigos alemanes para ese mes. Para el 7 de julio de 1941 era:

Tag  Walzenlage  Ringstellung  ---- Steckerverbindungen ----
  7  II  IV  V     02 21 12    AV BS CG DL FU HZ IN KM OW RX
El operador de la máquina debía colocar esa configuración antes de enviar o recibir el primer mensaje del día. Cogería los rotores 2, 4 y 5 (en ese orden), y movería el anillo de cada rotor a las posiciones 2, 21 y 12 respectivamente (a veces la configuración se puede ver en las letras correspondientes a la posición del anillo; en este caso serían B U L), y los insertaría en la máquina. También debía colocar los cables conectores uniendo en la parte baja de la máquina las posiciones que se indican, uniendo A con V, B con S, C con G, etc. Esta configuración se mantendrá en todos los mensajes del día, y con ella ya se pueden empezar a descifrar mensajes.

El mensaje original se envió en dos partes (ya que el tamaño máximo por cada mensaje era de 250 letras). Cada mensaje tiene una cabecera, que se enviaba sin cifrar (y es la única parte del mensaje que podía tener números); en este caso:

1ª parte del mensaje:
1840 - 2TLE 1TL 179 - WXC KCH
2ª parte del mensaje:

2TL 155 - CRS YPJ
En la cabecera se indicaba la hora a la que se mandaba el mensaje (en este caso 1840 representa las 18:40), cuántas partes componían el mensaje (seguido de TLE, de Teile) y qué parte era (seguido de TL, de Teil) si había más de una, el tamaño del texto cifrado y dos grupos de tres letras (que eran diferentes y aleatorios en cada mensaje). El primer grupo era la configuración inicial y el segundo la clave cifrada del mensaje. 

El operador moverá los tres rotores a la letra indicada por el primer grupo (W X C), y tecleará el otro grupo, la clave cifrada (K C H), que le dará al operador la clave sin cifrar, en este caso B L A). 

Si traducimos esto a lo que hemos visto en código python obtendremos lo siguiente:

from enigma.machine import EnigmaMachine

# Configuración de la máquina Enigma
machine = EnigmaMachine.from_key_sheet(
    rotors='II IV V',
    reflector='B',
    ring_settings='2 21 12',
    plugboard_settings='AV BS CG DL FU HZ IN KM OW RX'
)

# Establecemos la posición inicial de los rotores
machine.set_display('WXC')

# Desencriptamos el texto 'KCH' para obtener el msg_key
msg_key = machine.process_text('KCH')
print(msg_key)

Si lo ejecutas la salida será "BLA".

A continuación pondrá los tres rotores en las posiciones B L A y tecleará el resto del mensaje cifrado, teniendo en cuenta que las cinco primeras letras corresponden al Kenngruppe, que indicará quiénes pueden leer el mensaje (ten cuidado de ignorarlas, es decir no las pongas en el mensaje a desencriptar).

# Reiniciamos la posición inicial de los rotores para decodificar el mensaje principal
# usando la clave original
machine.set_display(msg_key)

# Decodificamos el texto cifrado
texto_crudo = """EDPUD NRGYS ZRCXN
UYTPO MRMBO FKTBZ REZKM
LXLVE FGUEY SIOZV EQMIK
UBPMM YLKLT TDEIS MDICA
GYKUA CTCDO MOHWX MUUIA
UBSTS LRNBZ SZWNR FXWFY
SSXJZ VIJHI DISHP RKLKA
YUPAD TXQSP INQMA TLPIF
SVKDA SCTAC DPBOP VHJK"""
# Unir todo el texto en una única línea y quitar espacios
texto_cifrado = texto_crudo.replace("\n", "").replace(" ", "")

texto_plano = machine.process_text(texto_cifrado)

# Remplazamos las X por espacios
texto_espaciado = texto_plano.replace("X", " ")

print(texto_espaciado)

Si lo ejecutas deberías obtener:
AUFKL ABTEILUNG VON KURTINOWA KURTINOWA NORDWESTL SEBEZ SEBEZ UAFFLIEGERSTRASZERIQTUNG DUBROWKI DUBROWKI OPOTSCHKA OPOTSCHKA UM EINSAQTDREINULL UHRANGETRETEN ANGRIFF INF RGT
y modificando el código para la segunda parte del texto obtenemos:

from enigma.machine import EnigmaMachine

# Configuración de la máquina Enigma
machine = EnigmaMachine.from_key_sheet(
    rotors='II IV V',
    reflector='B',
    ring_settings='2 21 12',
    plugboard_settings='AV BS CG DL FU HZ IN KM OW RX'
)

# Establecemos la posición inicial de los rotores
machine.set_display('CRS')

# Desencriptamos el texto 'YPJ' para obtener el msg_key
msg_key = machine.process_text('YPJ')
print(msg_key)

# Reiniciamos la posición inicial de los rotores para decodificar el mensaje principal
# usando la clave original
machine.set_display(msg_key)

# Decodificamos el texto cifrado
texto_crudo = """SFBWD NJUSE GQOBH
KRTAR EEZMW KPPRB XOHDR
OEQGB BGTQV PGVKB VVGBI
MHUSZ YDAJQ IROAX SSSNR
EHYGG RPISE ZBOVM QIEMM
ZCYSG QDGRE RVBIL EKXYQ
IRGIR QNRDN VRXCY YTNJR
SBDPJ BFFKY QWFUS"""
# Unir todo el texto en una única línea y quitar espacios
texto_cifrado = texto_crudo.replace("\n", "").replace(" ", "")

texto_plano = machine.process_text(texto_cifrado)

Remplazamos las X por espacios
texto_espaciado = texto_plano.replace("X", " ")

print(texto_espaciado)

Cuya salida es:
DREIGEHTLANGSAMABERSIQERVORWAERTS EINSSIEBENNULLSEQS UHR ROEM EINS INFRGT DREI AUFFLIEGERSTRASZEMITANFANG EINSSEQS KM KM OSTW KAMENEC KAMENEC DIV KDR 
Si unimos ambas partes:

AUFKL ABTEILUNG VON KURTINOWA KURTINOWA NORDWESTL SEBEZ SEBEZ UAFFLIEGERSTRASZERIQTUNG DUBROWKI DUBROWKI OPOTSCHKA OPOTSCHKA UM EINSAQTDREINULL UHRANGETRETEN ANGRIFF INF RGT
DREIGEHTLANGSAMABERSIQERVORWAERTS EINSSIEBENNULLSEQS UHR ROEM EINS INFRGT DREI AUFFLIEGERSTRASZEMITANFANG EINSSEQS KM KM OSTW KAMENEC KAMENEC DIV KDR 
Los nombres propios se ponían dos veces seguidas (por ejemplo, KURTINOWA), y algunas combinaciones de letras se sustituían, por ejemplo, CH por Q (en SIQER, que es sicher), y los números debían escribirse con letras. 

La traducción aproximada al español sería:

"Unidad de reconocimiento de Kurtinowa,  noroeste de Sebez. Sebez en dirección de la carretera de avance Dubrowki, Opotschka. Movilizada a las 18:30 horas. Ataque del regimiento de infantería tres avanza lentamente pero de manera segura. A las 17:06 horas el regimiento de infantería tres en la carretera de avance a 16 km al este de Kamenec. Cuartel general de la división."

Algunas palabras pueden tener errores tipográficos o ser abreviaturas propias de la Wehrmacht, lo que podría afectar ligeramente la interpretación.




lunes, 15 de julio de 2024

33.- Pasando un proyecto de Django a Producción usando Docker.

Es hora de que pasemos nuestro proyecto a un entorno de producción. Empezaremos configurando Django para que funcione en múltiples entornos y finalmente estableceremos un entorno de producción.

En proyectos del mundo real, tendrás que lidiar con múltiples entornos. Normalmente, tendrás al menos un entorno local para desarrollo y un entorno de producción para servir tu aplicación. También podrías tener otros entornos, como entornos de pruebas o de preparación.

Algunas configuraciones del proyecto serán comunes para todos los entornos, pero otras serán específicas para cada uno. Usualmente, utilizarás un archivo base que define las configuraciones comunes y un archivo de configuración por entorno que sobrescribe las configuraciones necesarias y define otras adicionales.

Gestionaremos los siguientes entornos:

  • local: El entorno local para ejecutar el proyecto en tu máquina.
  • prod: El entorno para desplegar tu proyecto en un servidor de producción.
Para este post puedes usar cualquier proyecto de Django que tengas. Yo voy a usar este en concreto puedes clonarlo si lo necesitas https://github.com/chema-hg/educa.git

Teniendo git instalado yo he creado un directorio llamado "Final". He entrado en ese directorio y ejecutado el siguiente comando para clonarlo:

$ git clone https://github.com/chema-hg/educa.git
            

Empecemos.

Crea un directorio settings/ junto al archivo settings.py del proyecto. Renombra el archivo settings.py a base.py y muévelo al nuevo directorio settings/.

Crea los siguientes archivos adicionales dentro de la carpeta settings/ para que el nuevo directorio se vea así:

settings/
    __init__.py
    base.py
    local.py
    prod.py

Estos archivos son los siguientes:

  • base.py: El archivo de configuración base que contiene configuraciones comunes (anteriormente settings.py).
  • local.py: Configuraciones personalizadas para tu entorno local.
  • prod.py: Configuraciones personalizadas para el entorno de producción.

Has movido los archivos de configuración a un directorio un nivel más abajo, por lo que necesitas actualizar la configuración BASE_DIR en el archivo settings/base.py para que apunte al directorio principal del proyecto.

Al manejar múltiples entornos, crea un archivo de configuración base y un archivo de configuración para cada entorno. Los archivos de configuración de entornos deben heredar las configuraciones comunes y sobrescribir las configuraciones específicas del entorno.

Edita el archivo settings/base.py y reemplaza la siguiente línea:

BASE_DIR = Path(__file__).resolve().parent.parent

con la siguiente:

BASE_DIR = Path(__file__).resolve().parent.parent.parent

Apuntas a un directorio más arriba añadiendo .parent al camino de BASE_DIR. Configuremos las configuraciones para el entorno local.

Configuración del entorno local.


En vez de utilizar las configuraciones por defecto para DEBUG y DATABASES, las definiremos explícitamente para cada entorno. Edita el archivo settings/local.py y añade las siguientes líneas:

proyecto/settings/local.py

from .base import *

DEBUG = True

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

Este es el archivo para la configuración local. En este archivo empezamos importando todas las configuraciones definidas en el archivo base.py y luego especificamos DEBUG y DATABASES especificas de este entorno. Son las mismas que hemos estado usando para un entorno de desarrollo.

Ahora elimina las configuraciones DATABASES y DEBUG del archivo settings/base.py.

El comando de administración de Django no detectará automáticamente el archivo de configuración a usar porque el archivo de configuración del proyecto no es el archivo settings.py predeterminado. Para decirle a Django que use un archivo de configuración especifico tenemos que usar la opción --settings de la siguiente manera:

python manage.py runserver --settings=proyecto.settings.local

nota: "proyecto" es el nombre que tu tengas de tu proyecto, en mi caso es "educa"

NOTA: Si lo ejecutas ahora te dará un error porque mi programa de ejemplo depende del programa REDIS que lo ejecutaremos posteriormente a través de un contenedor de docker..

Si no quieres pasar la opción --settings cada vez que ejecutas el comando de administración, puedes definir una variable de entorno DJANGO_SETTINGS_MODULE. Django la usará para identificar la configuración a usar. Si estas ejecutando Linux o Mac puedes crear esta variable usando el siguiente comando:

export DJANGO_SETTINGS_MODULE=proyecto.settings.local

Acuérdate de sustituir "proyecto" por el nombre real que tenga tu proyecto, en mi caso "educa"

Para que esta variable este disponible para todas las sesiones del shell tendrás que añadir tu "export" al archivo de configuración de tu shell. Este archivo puede variar dependiendo del shell que uses.

Si estás usando windows puedes ejecutar el siguiente comando desde el shell:

set DJANGO_SETTINGS_MODULE=proyecto.settings.local


Configuración para el entorno de producción.

Vamos a empezar añadiendo la configuración inicial del entorno de producción. Edita el archivo proyecto/settings/prod.py y añade el siguiente código:


proyecto/settings/prod.py

from .base import *

DEBUG = False

ADMINS = [
('Perico P', 'email@midominio.com'),
]

ALLOWED_HOSTS = ['*']

DATABASES = {
'default': {
}
}

Estos son los ajustes para el entorno de producción: - DEBUG: Establecer DEBUG en False es necesario para cualquier entorno de producción. No hacerlo resultará en la exposición de información de rastreo y datos de configuración sensibles a todos. - ADMINS: Cuando DEBUG está en False y una vista genera una excepción, toda la información se enviará por correo electrónico a las personas enumeradas en la configuración de ADMINS. Asegúrate de reemplazar la tupla de nombre/correo electrónico con tu propia información. - ALLOWED_HOSTS: Por razones de seguridad, Django solo permitirá que los hosts incluidos en esta lista sirvan el proyecto. Por ahora, permites todos los hosts usando el símbolo de asterisco, *. Limitarás los hosts que pueden usarse para servir el proyecto más adelante. - DATABASES: Aunque por el momento vamos a dejar la configuración de la base de datos por defecto vacía, en las próximas secciones de este post, completarás el archivo de configuración para el entorno de producción usando una base de datos más apropiada para este fin.

Ahora construirás un entorno de producción completo configurando diferentes servicios con Docker.


Usando Docker Compose

Docker te permite construir, desplegar y ejecutar contenedores de aplicaciones. Un contenedor de Docker combina el código fuente de la aplicación con las bibliotecas y dependencias del sistema operativo necesarias para ejecutar la aplicación. Al usar contenedores de aplicaciones, puedes mejorar la portabilidad de tu aplicación. Ya en anteriores post hemos utilizado una imagen de Docker de Redis para servir Redis en tu entorno local. Esta imagen de Docker contiene todo lo necesario para ejecutar Redis y te permite ejecutarlo sin problemas en tu máquina. Para el entorno de producción, usarás Docker Compose para construir y ejecutar diferentes contenedores de Docker. Docker Compose es una herramienta para definir y ejecutar aplicaciones multicontenedor. Puedes crear un archivo de configuración para definir los diferentes servicios y usar un solo comando para iniciar todos los servicios desde tu configuración. Puedes encontrar información sobre Docker Compose en https://docs.docker.com/compose/. Para el entorno de producción, crearás una aplicación distribuida que se ejecuta en varios contenedores de Docker. Cada contenedor de Docker ejecutará un servicio diferente. Inicialmente, definirás los siguientes tres servicios y agregarás servicios adicionales en las siguientes secciones:

- Servicio web: Un servidor web para servir el proyecto Django. - Servicio de base de datos: Un servicio de base de datos para ejecutar PostgreSQL. - Servicio de caché: Un servicio para ejecutar Redis. Comencemos por instalar Docker Compose.

Instalación de Docker Compose

Puedes ejecutar Docker Compose en macOS, Linux de 64 bits y Windows. La forma más rápida de instalar Docker Compose es instalando Docker Desktop. La instalación incluye Docker Engine, la interfaz de línea de comandos y el complemento Docker Compose.

Sin embargo también si tienes instalado Docker Engine y Docker client puedes instalar Docker compose desde la línea de comandos.

Puedes encontrar todas las opciones en https://docs.docker.com/compose/install/

Si optas por instalar Docker Desktop puedes encontrar las instrucciones en https://docs.docker.com/compose/install/compose-desktop/. En cualquier caso necesitaremos crear una imagen de Docker para nuestro proyecto.


Creando un Dockerfile

Necesitas crear una imagen de Docker para ejecutar el proyecto de Django. Un Dockerfile es un archivo de texto que contiene los comandos para que Docker ensamble una imagen de Docker. Prepararemos un Dockerfile con los comandos para construir la imagen de Docker para el proyecto Django. Al lado del directorio del proyecto, educa en mi caso, crea un nuevo archivo y nómbralo Dockerfile. Para que no haya confusión tienes que ponerlo justo encima del directorio que contiene el archivo manage.py. Añade el siguiente código al nuevo archivo:


Dockerfile

FROM python:3.10.12

# Establece las variables de entorno
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Establece el directorio de trabajo
WORKDIR /code

# Añade un usuario específico
RUN adduser --disabled-password --gecos '' miusuario

# Instala las dependencias
RUN pip install --upgrade pip
COPY requirements.txt /code/
RUN pip install -r requirements.txt

# Copia el proyecto de Django y ajusta permisos
COPY . /code/
RUN chown -R miusuario:miusuario /code

# Cambia al usuario no root
USER miusuario

* Sustituye miusuario por el nombre de tu usuario. (en linux puedes verlo con whoiam)

Este código realiza las siguientes tareas:

1. Se utiliza la imagen base de Docker de Python 3.10.12. Puedes encontrar la imagen oficial de Docker de Python en https://hub.docker.com/_/python.

2. Se establecen las siguientes variables de entorno: a. PYTHONDONTWRITEBYTECODE: Evita que Python escriba archivos pyc. b. PYTHONUNBUFFERED: Asegura que los flujos stdout y stderr de Python se envíen directamente al terminal sin ser bufferizados primero.

3. El comando WORKDIR se utiliza para definir el directorio de trabajo de la imagen.

4.- Añadimos un usuario especifico para no tener luego problemas con los permisos de escritura y modificación de los archivos en los contenedores.

5. Se actualiza el paquete pip de la imagen.

6. El archivo requirements.txt se copia al directorio de código de la imagen base de Python.

7. Los paquetes de Python en requirements.txt se instalan en la imagen utilizando pip.

8. El código fuente del proyecto Django se copia desde el directorio local al directorio de código de la imagen y se ajustan los permisos de los archivos.

9.- Se cambia al usuario no root. Con este Dockerfile, has definido cómo se ensamblará la imagen de Docker para servir Django. Puedes encontrar la referencia de Dockerfile en https://docs.docker.com/engine/reference/builder/.


Añadiendo los requisitos de Python

Un archivo requirements.txt se utiliza en el Dockerfile que creaste para instalar todos los paquetes de Python necesarios para el proyecto. Junto al directorio del proyecto, educa en mi caso y al lado del Dockerfile, crea un nuevo archivo y nómbralo requirements.txt. Si has clonado el proyecto puedes encontrar este archivo dentro de "educa/requirements.txt". Copia su contenido y pégalo en este nuevo archivo requirements.txt que acabamos de crear.

En tu proyecto en desarrollo y cuando veas que todo funciona puedes crearlo si quieres con:

pip freeze>requirements.txt

Para que te hagas una idea el mío tiene el siguiente contenido. Tendrás que tener en este archivo todos los paquetes que necesite tu proyecto.

Además de los paquetes de Python que has instalado en los post anteriores, el archivo requirements.txt incluye los siguientes paquetes:

- psycopg2: Un adaptador de PostgreSQL. Usarás PostgreSQL para el entorno de producción. - uwsgi: Un servidor web WSGI. Configurarás este servidor web más adelante para servir Django en el entorno de producción. - daphne: Un servidor web ASGI. Usarás este servidor web más adelante para servir Django Channels.

Las versiones a instalar dependerán de las que hayas usado en tu proyecto.


requirements.txt

asgiref==3.7.2
async-timeout==4.0.3
attrs==23.2.0
autobahn==23.6.2
Automat==22.10.0
certifi==2024.2.2
cffi==1.16.0
channels==4.1.0
charset-normalizer==3.3.2
constantly==23.10.4
cryptography==42.0.8
Django==5.0.2
django-braces==1.15.0
django-debug-toolbar==4.3.0
django-embed-video==1.4.9
django-redisboard==8.4.0
djangorestframework==3.14.0
hyperlink==21.0.0
idna==3.6
incremental==22.10.0
pillow==10.2.0
pyasn1==0.6.0
pyasn1_modules==0.4.0
pycparser==2.22
pymemcache==4.0.0
pyOpenSSL==24.1.0
pytz==2024.1
redis==5.0.3
requests==2.31.0
service-identity==24.1.0
six==1.16.0
sqlparse==0.4.4
Twisted==24.3.0
txaio==23.1.1
typing_extensions==4.9.0
urllib3==2.2.1
zope.interface==6.4.post2
daphne==4.1.2
psycopg2>=2.9.3
uwsgi>=2.0.20

Comencemos configurando la aplicación Docker en Docker Compose. Crearemos un archivo Docker Compose con la definición para el servidor web, la base de datos y los servicios de Redis.


Creando un archivo de Docker Compose.

Para definir los servicios que se ejecutarán en diferentes contenedores de Docker, usaremos un archivo de Docker Compose. Este es un archivo de texto en formato YAML, donde definiremos los servicios, las redes y volúmenes de datos para la aplicación de Docker. Puedes ver un ejemplo de como se construyen archivos YAML a https://yaml.org/.

Al lado del directorio del proyecto, en mi caso educa, crea un nuevo archivo y llámalo docker-compose.yml. Añade el siguiente código:

docker-compose.yml

services:
  web:
    build: .
    command: python /code/educa/manage.py runserver 0.0.0.0:8000
    restart: always
    volumes:
      - .:/code
    ports:
      - "8000:8000"
    environment:
      - DJANGO_SETTINGS_MODULE=educa.settings.prod
     user: "miusuario"  # Añadir el usuario aquí

Es muy importante que copies el código tal cual, incluyendo las tabulaciones. En YAML, las tabulaciones son cruciales y deben de ser consistentes.

En este archivo hemos definido un servicio web. Vamos a explicar uno por uno su contenido

Claro, aquí tienes la traducción al castellano: - build: Define los requisitos de construcción para una imagen de contenedor de servicio. Esto puede ser una cadena única que define una ruta de contexto, o una definición de construcción detallada. Proporcionas una ruta relativa con un solo punto . para apuntar al mismo directorio donde se encuentra el archivo Compose. Docker Compose buscará un Dockerfile en esta ubicación. Puedes leer más sobre la sección build en este enlace (https://docs.docker.com/compose/compose-file/build/).

- command: Sobrescribe el comando predeterminado del contenedor. Ejecutas el servidor de desarrollo de Django usando el comando de gestión runserver. El proyecto se sirve en el host 0.0.0.0, que es la IP predeterminada de Docker, en el puerto 8000.

- restart: Define la política de reinicio para el contenedor. Usando always, el contenedor se reinicia siempre si se detiene. Esto es útil para un entorno de producción, donde deseas minimizar el tiempo de inactividad. Puedes leer más sobre la política de reinicio en este enlace (https://docs.docker.com/config/containers/start-containers-automatically/).

- volumes: Los datos en los contenedores Docker no son permanentes. Cada contenedor Docker tiene un sistema de archivos virtual que se llena con los archivos de la imagen y se destruye cuando el contenedor se detiene. Los volúmenes son el método preferido para persistir datos generados y utilizados por los contenedores Docker. En esta sección, montas el directorio local . en el directorio /code de la imagen. Puedes leer más sobre los volúmenes de Docker en este enlace (https://docs.docker.com/storage/volumes/).

- ports: Expone puertos del contenedor. El puerto 8000 del host se asigna al puerto 8000 del contenedor, en el que se está ejecutando el servidor de desarrollo de Django.

- environment: Define variables de entorno. Configuras la variable de entorno DJANGO_SETTINGS_MODULE para usar el archivo de configuración de producción de Django educa.settings.prod.

- user: al especificar el usuario en el archivo "docker-compose.yml" para el servicio web, garantizamos que los procesos dentro de ese contenedor se ejcuten con el usuario creado en el Dockerfile ("miusuario"). Ten en cuenta que en la definición del archivo Docker Compose, estás utilizando el servidor de desarrollo de Django para servir la aplicación. El servidor de desarrollo de Django no es adecuado para uso en producción, por lo que lo reemplazarás más adelante con un servidor web WSGI de Python. Puedes encontrar información sobre la especificación de Docker Compose en este enlace (https://docs.docker.com/compose/compose-file/). En este punto, asumiendo que tu directorio padre se llama "educa" es decir el nombre del proyecto, la estructura básica de archivos debería verse de la siguiente manera:

educa/
    docker-compose.yml
    Dockerfile
    educa/
        manage.py
        ....
    requirements.txt

Abre una sesión de shell en este directorio padre, donde se encuentra el archivo docker-compose.yml, y ejecuta el siguiente comando:

$ docker compose up

* Ejecuta como root si no has añadido tu usuario al grupo Docker.

Si no quieres usar el comando sudo cada vez, puedes añadir tu usuario al grupo docker. Esto le dará a tu usuario permisos para interactuar con el demonio de Docker.

Sigue estos pasos:

  1. Añade tu usuario al grupo docker:

    sudo usermod -aG docker $USER
  2. Cierra la sesión y vuelve a iniciarla, o ejecuta el siguiente comando para aplicar los cambios sin cerrar sesión:

    newgrp docker
  3. Verifica que tu usuario está en el grupo docker:

    id -nG

Después de hacer esto, intenta ejecutar nuevamente el comando docker compose up.

Otra cosa a verificar son los permisos del socket /var/run/docker.sock. Asegúrate de que los permisos sean correctos:

ls -l /var/run/docker.sock

El resultado debe mostrar algo como esto:

srw-rw---- 1 root docker 0 Jun 21 12:34 /var/run/docker.sock

Asegúrate de que el socket es accesible por el grupo docker y de que tu usuario pertenece a dicho grupo.

Al ejecutarse este comando se definirá el contenedor de docker definido en el archivo docker compose.

Volviendo a lo que estábamos haciendo, cuando finalice de ejecutarse "docker compose up" tendrás el contenedor de tu proyecto ejecutándose.

Los estilos CSS no se están cargando. Estás usando `DEBUG=False`, por lo que los patrones de URL para servir archivos estáticos no están siendo incluidos en el archivo `urls.py` principal del proyecto. Recuerda que el servidor de desarrollo de Django no es adecuado para servir archivos estáticos. Configurarás un servidor para servir archivos estáticos más adelante en este post.

Si accedes a cualquier URL de tu sitio, podrías obtener un error HTTP 500 porque aún no has configurado una base de datos para el entorno de producción. Echa un vistazo a la ejecución del siguiente comando:

$sudo docker ps

SALIDA:

CONTAINER ID   IMAGE       COMMAND                  CREATED        STATUS         PORTS                                       NAMES

8f3422ed977b   final-web   "python /code/educa/…"   23 hours ago   Up 5 minutes   0.0.0.0:8000->8000/tcp, :::8000->8000/tcp   final-web-1


Como vemos tenemos un contenedor en ejecución llamado final-web el cual está corriendo en el puerto 8000. El nombre de la aplicación de Docker se genera dinámicamente usando el nombre del directorio en el que se encuentra el archivo docker-compile, en mi caso "final".

Si quieres ver la imagen generada de la cual se crea el contenedor puedes usar el comando:

$ sudo docker images -a

SALIDA:

REPOSITORY TAG IMAGE ID CREATED SIZE final-web latest ef2cccc8385d 23 hours ago 1.26GB


Si quieres para la ejecución de los contenedores puedes usar CRTL + C. Si lo que quieres es parar los contenedores y eliminarlos (lo cual no eliminará las imágenes) puedese usar:

$ docker compose down

A continuación vamos a añadir el servicio PostgreSQL y el servicio REDIS a la aplicación.

Configuración del servicio PostgreSQL

A lo largo de este libro, has utilizado principalmente la base de datos SQLite. SQLite es simple y rápida de configurar, pero para un entorno de producción, necesitarás una base de datos más potente, como PostgreSQL, MySQL u Oracle. Vimos cómo instalar PostgreSQL en el POST "7.- BASES DE DATOS - POSTGRESQL". Para el entorno de producción, utilizaremos una imagen Docker de PostgreSQL en su lugar. Puedes encontrar información sobre la imagen oficial de Docker de PostgreSQL en este enlace (https://hub.docker.com/_/postgres). Edita el archivo docker-compose.yml y añade las siguientes líneas resaltadas en negrita:

docker-compose.yml

services:
  db:
    image: postgres:14.5
    restart: always
    volumes:
      - ./data/db:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=postgres
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres

  web:
    build: .
    command: python /code/educa/manage.py runserver 0.0.0.0:8000
    restart: always
    volumes:
      - .:/code
    ports:
      - "8000:8000"
    environment:
      - DJANGO_SETTINGS_MODULE=educa.settings.prod
      - POSTGRES_DB=postgres
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    depends_on:
      - db
    user: "miusuario"

Con estos cambios, defines un servicio llamado db con las siguientes subsecciones:

- image: El servicio utiliza la imagen base de Docker de postgres.

- restart: La política de reinicio se establece en always.

- volumes: Montas el directorio ./data/db en el directorio /var/lib/postgresql/data de la imagen para persistir la base de datos, de modo que los datos almacenados en la base de datos se mantengan después de que la aplicación Docker se detenga. Esto creará la ruta local data/db/.

- environment: Utilizas las variables POSTGRES_DB (nombre de la base de datos), POSTGRES_USER y POSTGRES_PASSWORD con valores predeterminados.

La definición del servicio web ahora incluye las variables de entorno de PostgreSQL para Django. Creas una dependencia de servicio usando depends_on para que el servicio web se inicie después del servicio db. Esto garantizará el orden de la inicialización del contenedor, pero no garantizará que PostgreSQL esté completamente iniciado antes de que el servidor web de Django se inicie. Para resolver esto, necesitas usar un script que esperará la disponibilidad del host de la base de datos y su puerto TCP. Docker recomienda usar la herramienta wait-for-it para controlar la inicialización del contenedor. Descarga el script Bash wait-for-it.sh desde este enlace (https://github.com/vishnubob/wait-for-it/blob/master/wait-for-it.sh) y guarda el archivo junto al archivo docker-compose.yml.

Asegúrate de dar permisos de ejecución al archivo con: $ chmod +x wait_for_it.sh

Luego edita el archivo docker-compose.yml y modifica la definición del servicio web como se indica a continuación. El nuevo código está resaltado en negrita:

docker-compose.yml

web:
    build: .
    command: ["./wait_for_it.sh", "db:5432", "--",
              "python", "/code/educa/manage.py", "runserver",
              "0.0.0.0:8000"]
    restart: always
    volumes:
      - .:/code
    ports:
      - "8000:8000"
    environment:
      - DJANGO_SETTINGS_MODULE=educa.settings.prod
      - POSTGRES_DB=postgres
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    depends_on:
      - db
    user: "miusuario"

En esta definición de servicio, utilizas el script Bash wait-for-it.sh para esperar a que el host db esté listo y acepte conexiones en el puerto 5432, el puerto predeterminado para PostgreSQL, antes de iniciar el servidor de desarrollo de Django. Puedes leer más sobre el orden de inicio de servicios en Compose en este enlace (https://docs.docker.com/compose/startup-order/).

Vamos a editar la configuración de Django. Edita el archivo educa/settings/prod.py y añade el siguiente código resaltado en negrita:

educa/settings/prod.py

import os

from .base import *

DEBUG = False

ADMINS = [
('Perico P', 'email@midominio.com'),
]

ALLOWED_HOSTS = ['*']

DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('POSTGRES_DB'),
'USER': os.environ.get('POSTGRES_USER'),
'PASSWORD': os.environ.get('POSTGRES_PASSWORD'),
'HOST': 'db',
'PORT': 5432,
}
}
En el archivo de configuración de producción, utilizas las siguientes configuraciones:

• ENGINE: Usas el backend de base de datos de Django para PostgreSQL.

• NAME, USER, y PASSWORD: Usas os.environ.get() para recuperar las variables de entorno POSTGRES_DB (nombre de la base de datos), POSTGRES_USER y POSTGRES_PASSWORD. Has establecido estas variables de entorno en el archivo Docker Compose.

• HOST: Usas db, que es el nombre de host del contenedor para el servicio de base de datos definido en el archivo Docker Compose. El nombre de host de un contenedor por defecto es el ID del contenedor en Docker. Por eso usas el nombre de host db.

• PORT: Usas el valor 5432, que es el puerto predeterminado para PostgreSQL.

Detén la aplicación de Docker desde la terminal presionando las teclas Ctrl + C o usando el botón de detener en la aplicación Docker Desktop. Luego, inicia Compose nuevamente con el comando:

docker compose up

La primera ejecución después de agregar el servicio db al archivo Docker Compose tomará más tiempo porque PostgreSQL necesita inicializar la base de datos.

Tanto la base de datos PostgreSQL como la aplicación Django están listas. La base de datos de producción está vacía, por lo que necesitaremos aplicar las migraciones de la base de datos.


Aplicando migraciones de base de datos y creando un superusuario

Abre una terminal diferente en el directorio padre, donde se encuentra el archivo docker-compose.yml, y ejecuta el siguiente comando:

docker compose exec web python /code/educa/manage.py migrate

El comando docker compose exec te permite ejecutar comandos en el contenedor. Usas este comando para ejecutar el comando de administración migrate en el contenedor web de Docker.

Finalmente, crea un superusuario con el siguiente comando:

docker compose exec web python /code/educa/manage.py createsuperuser

Se han aplicado las migraciones a la base de datos y has creado un superusuario. Puedes acceder a http://localhost:8000/admin/ con las credenciales del superusuario, aunque seguramente te de un error si estas usando mi imagen de ejemplo porque aún no hemos configurado el contenedor para REDIS. Los estilos CSS aún no se cargarán porque no has configurado el servicio de archivos estáticos todavía.

Has definido servicios para servir Django y PostgreSQL usando Docker Compose. A continuación, agregarás un servicio para servir Redis en el entorno de producción.


Configurando el servicio de REDIS.


Vamos a añadir REDIS al archivo de Docker Compose. Para ello utilizaremos la imagen oficial de REDIS que nos proporciona Docker. Puedes encontrar más información sobre la misma en:

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

Edita el archivo docker-compose.yml y añade el siguiente código:

docker-compose.yml

services:
  db:
    #...
  cache:
    image: redis:7.0.4
    restart: always
    volumes:
      - ./data/cache:/data 

  web:
    #....
    depends_on:
      - db
      - cache



En el código anterior, defines el servicio de caché con las siguientes subsecciones:

- image: El servicio utiliza la imagen base de Docker de Redis.
- restart: La política de reinicio está configurada para siempre.
- volumes: Montas el directorio `./data/cache` en el directorio de la imagen `/data`, donde se guardarán las escrituras de Redis. Esto creará la ruta local `data/cache/`.


En la definición del servicio web, añades el servicio de caché como una dependencia, para que el servicio web se inicie después del servicio de caché. El servidor Redis se inicializa rápidamente, por lo que en este caso no necesitas usar la herramienta wait-for-it.

Edita el archivo `educa/settings/prod.py` y añade las siguientes líneas:

REDIS_URL = 'redis://cache:6379'
CACHES['default']['LOCATION'] = REDIS_URL
CHANNEL_LAYERS['default']['CONFIG']['hosts'] = [REDIS_URL]


En estas configuraciones, utilizas el nombre de host de la caché que se genera automáticamente por Docker Compose usando el nombre del servicio de caché y el puerto 6379 utilizado por Redis. Modificas la configuración de CACHE de Django y la configuración de CHANNEL_LAYERS utilizada por Channels para usar la URL de Redis en producción.

Detén la aplicación Docker desde la terminal presionando las teclas Ctrl + C o usando el botón de detener en la aplicación Docker Desktop. Luego, inicia Compose nuevamente con el comando:

docker compose up

Si observas cuales son los contenedores en ejecución verás los siguientes:

CONTAINER ID   IMAGE           COMMAND                  CREATED        STATUS          PORTS                                       NAMES
fa58bfd42098   final-web       "./wait-for-it.sh db…"   19 hours ago   Up 15 minutes   0.0.0.0:8000->8000/tcp, :::8000->8000/tcp   final-web-1
2611603348f8   redis:7.0.4     "docker-entrypoint.s…"   19 hours ago   Up 15 minutes   6379/tcp                                    final-cache-1
7d509cdd19a1   postgres:14.5   "docker-entrypoint.s…"   45 hours ago   Up 15 minutes   5432/tcp                                    final-db-1

La aplicación de docker ejecuta un contenedor para cada uno de los servicios definidos en el archivo docker compose: db, cache y web.

Aun estamos sirviendo Django con el servidor de desarrollo de Django, el cual no es adecuado para su uso como en producción. Vamos a reemplazarlo con el servidor WSGI de python.

Servir Django a través de WSGI y NGINX


La plataforma principal de despliegue de Django es WSGI. WSGI significa Web Server Gateway Interface y es el estándar para servir aplicaciones Python en la web.

Cuando generas un nuevo proyecto usando el comando `startproject`, Django crea un archivo `wsgi.py` dentro de tu directorio de proyecto. Este archivo contiene un callable de la aplicación WSGI, que es un punto de acceso a tu aplicación.

WSGI se usa tanto para ejecutar tu proyecto con el servidor de desarrollo de Django como para desplegar tu aplicación con el servidor de tu elección en un entorno de producción. Puedes aprender más sobre WSGI en https://wsgi.readthedocs.io/en/latest/.

Usando uWSGI


uWSGI es un servidor de aplicaciones Python extremadamente rápido. Se comunica con tu aplicación Python usando la especificación WSGI. uWSGI traduce las solicitudes web en un formato que tu proyecto Django puede procesar.

Vamos a configurar uWSGI para servir el proyecto Django. Ya agregaste `uwsgi==2.0.20` al archivo `requirements.txt` del proyecto, por lo que uWSGI ya se está instalando en la imagen Docker del servicio web.

Edita el archivo `docker-compose.yml` y modifica la definición del servicio web como sigue. El nuevo código está resaltado en azul:

docker-compose.yml

  web:
    build: .
    command: ["./wait-for-it.sh", "db:5432", "--",
              "uwsgi", "--ini", "/code/config/uwsgi/uwsgi.ini"]

    restart: always
    volumes:
      - .:/code
    environment:
      - DJANGO_SETTINGS_MODULE=educa.settings.prod
      - POSTGRES_DB=postgres
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    depends_on:
      - db
      - cache
    user: "miusuario"

Asegúrate de eliminar la sección "ports". uWSGI será accesible mediante un socket, por lo que no necesitas exponer un puerto en el contenedor.

El nuevo comando para la imagen ejecuta uWSGI pasando el archivo de configuración `/code/config/uwsgi/uwsgi.ini` a él. Vamos a crear el archivo de configuración para uWSGI.

Configurando uWSGI


uWSGI te permite definir una configuración personalizada en un archivo .ini. Junto al archivo `docker-compose.yml`, crea la ruta del archivo `config/uwsgi/uwsgi.ini`. Asumiendo que mi directorio padre se llama `Final`, la estructura del archivo debería verse de la siguiente manera:

Final/
    config/
        uwsgi/
            uwsgi.ini
    docker-compose.yml
    Dockerfile
    educa/
        manage.py
        ...
    requirements.txt

Edita el archivo uwsgi.ini que acabamos de crear y añade el siguiente código:

uwsgi.ini

[uwsgi]
socket=/code/educa/uwsgi_app.sock chdir = /code/educa/ module=educa.wsgi:application master=true chmod-socket=666 uid=miusuario gid=miusuario vacuum=true

En el archivo uwsgi.ini, defines las siguientes opciones:

  • socket: El socket UNIX/TCP al que se enlaza el servidor.
  • chdir: La ruta a tu directorio de proyecto, para que uWSGI cambie a ese directorio antes de cargar la aplicación Python.
  • module: El módulo WSGI a utilizar. Configuras esto con el callable de la aplicación contenido en el módulo WSGI de tu proyecto.
  • master: Habilita el proceso maestro.
  • chmod-socket: Los permisos de archivo para aplicar al archivo de socket. En este caso, usas 666 para que NGINX pueda leer/escribir en el socket.
  • uid: El ID de usuario del proceso una vez iniciado.
  • gid: El ID de grupo del proceso una vez iniciado.
  • vacuum: Usar true indica a uWSGI que limpie cualquier archivo temporal o sockets UNIX que cree.

La opción socket está destinada a la comunicación con algún enrutador de terceros, como NGINX. Vas a ejecutar uWSGI usando un socket y vas a configurar NGINX como tu servidor web, que se comunicará con uWSGI a través del socket. Puedes encontrar la lista de opciones disponibles de uWSGI en https://uwsgi-docs.readthedocs.io/en/latest/Options.html. No podrás acceder a tu instancia de uWSGI desde tu navegador ahora, ya que se está ejecutando a través de un socket. Completemos el entorno de producción.

Usando NGINX

Cuando sirves un sitio web, tienes que servir contenido dinámico, pero también necesitas servir archivos estáticos, como hojas de estilo CSS, archivos JavaScript e imágenes. Aunque uWSGI es capaz de servir archivos estáticos, agrega una carga innecesaria a las solicitudes HTTP y, por lo tanto, se recomienda configurar un servidor web, como NGINX, frente a él. NGINX es un servidor web enfocado en alta concurrencia, rendimiento y bajo uso de memoria. NGINX también actúa como un proxy inverso, recibiendo solicitudes HTTP y WebSocket y enrutándolas a diferentes backends. Generalmente, usarás un servidor web, como NGINX, frente a uWSGI para servir archivos estáticos de manera eficiente, y reenviarás las solicitudes dinámicas a los trabajadores de uWSGI. Al usar NGINX, también puedes aplicar diferentes reglas y beneficiarte de sus capacidades de proxy inverso.

Agregaremos el servicio NGINX al archivo Docker Compose usando la imagen oficial de Docker de NGINX. Puedes encontrar información sobre la imagen oficial de Docker de NGINX en https://hub.docker.com/_/nginx. Edita el archivo docker-compose.yml y agrega las siguientes líneas resaltadas en negrita:

docker-compose.yml

services:
  db:
    #...
  cache:
    #...  

  web:
    #...
nginx: image: nginx:1.23.1 restart: always volumes: - ./config/nginx:/etc/nginx/templates - .:/code ports: - "80:80"

Has añadido la definición para el servicio nginx con las siguientes subsecciones:

  • image: El servicio utiliza la imagen base de Docker de nginx.
  • restart: La política de reinicio está configurada para siempre.
  • volumes: Montas el volumen ./config/nginx en el directorio /etc/nginx/templates de la imagen de Docker. Aquí es donde NGINX buscará una plantilla de configuración predeterminada. También montas el directorio local . en el directorio /code de la imagen, para que NGINX pueda tener acceso a los archivos estáticos.
  • ports: Expones el puerto 80, que se asigna al puerto 80 del contenedor. Este es el puerto predeterminado para HTTP.

Vamos a configurar el servidor web NGINX.


Configurando Nginx.


Dentro del directorio /config/ crea el siguiente directorio resaltado en azul. Debería tener este aspecto:

config/
    uwsgi/
        uwsgi.ini
    nginx/
        default.conf.template

Edita el archivo default.conf.template y añade el siguiente código:

default.conf.template

# upstream for uWSGI
upstream uwsgi_app {
server unix:/code/educa/uwsgi_app.sock;
}
server {
listen 80;
server_name www.educaproject.com educaproject.com;
error_log stderr warn;
access_log /dev/stdout main;
location / {
include /etc/nginx/uwsgi_params;
uwsgi_pass uwsgi_app;
}
}
Esta es la configuración básica para NGINX. En esta configuración, configuras un upstream llamado uwsgi_app, que apunta al socket creado por uWSGI. Utilizas el bloque del servidor con la siguiente configuración:

- Indicas a NGINX que escuche en el puerto 80.

- Configuras el nombre del servidor a ambos www.educaproject.com y educaproject.com. NGINX servirá las solicitudes entrantes para ambos dominios.

- Usas stderr para la directiva error_log para obtener los registros de errores escritos en el archivo de error estándar. El segundo parámetro determina el nivel de registro. Usas warn para obtener advertencias y errores de mayor severidad.

- Apuntas access_log a la salida estándar con /dev/stdout.

- Especificas que cualquier solicitud bajo la ruta / debe ser enrutada al socket uwsgi_app hacia uWSGI.

- Incluyes los parámetros de configuración predeterminados de uWSGI que vienen con NGINX. Estos están ubicados en /etc/nginx/uwsgi_params.

NGINX ahora está configurado. Puedes encontrar la documentación de NGINX en https://nginx.org/en/docs/. Detén la aplicación Docker desde la terminal presionando las teclas Ctrl + C o utilizando el botón de detener en la aplicación Docker Desktop. Luego, inicia Compose nuevamente con el comando:

```
docker compose up
```

Abre la URL http://localhost/ en tu navegador. No es necesario agregar un puerto a la URL porque estás accediendo al host a través del puerto HTTP estándar 80. Deberías ver la página de la lista de cursos sin estilos CSS:

The course list page served with NGINX and uWSGI



El siguiente diagrama muestra el ciclo de peticiones y respuestas en el entorno de producción que hemos configurado:

The production environment request/response cycle The following

Si compruebas los contenedores que estamos usando hasta ahora tenemos cuatro:
1.- El servicio db que corre PosgreSQL.
2.- El servicio cache que corre Redis.
3.- El servicio web que ejecuta uWSGI+Django
4.- El servicio nginx que ejecuta Nginx.

Vamos a continuar. Lo siguiente que haremos será entrar en vez de con localhost, configuraremos el proyecto para que utilice el nombre de host que utilicemos en este caso por ejemplo www.educaproject.com


Usando un hostname.


Usaré el nombre de host educaproject.com para mi sitio. Como estamos utilizando un nombre de dominio de muestra, no es un dominio de host real, necesitaremos redirigirlo a nuestro localhost.

Si estás utilizando Linux o macOS, edita el archivo /etc/hosts y agrega la siguiente línea:

127.0.0.1 educaproject.com www.educaproject.com

Si estás utilizando Windows, edita el archivo C:\Windows\System32\drivers\etc y agrega la misma línea.

Al hacer esto, estás redirigiendo los nombres de host educaproject.com y www.educaproject.com a tu servidor local. En un servidor de producción, no necesitarás hacer esto, ya que tendrás una dirección IP fija y apuntarás tu nombre de host a tu servidor en la configuración DNS de tu dominio.

Abre http://educaproject.com/ en tu navegador. Deberías poder ver tu sitio, aún sin ningún recurso estático cargado. Tu entorno de producción está casi listo.

Ahora puedes restringir los hosts que pueden servir tu proyecto Django. Edita el archivo de configuración de producción educa/settings/prod.py de tu proyecto y cambia la configuración de ALLOWED_HOSTS, de la siguiente manera:

ALLOWED_HOSTS = ['educaproject.com', 'www.educaproject.com']

Django solo servirá tu aplicación si está ejecutándose bajo alguno de estos nombres de host. Puedes leer más sobre la configuración de ALLOWED_HOSTS en https://docs.djangoproject.com/en/4.1/ref/settings/#allowed-hosts.

El entorno de producción está casi listo. Continuemos configurando NGINX para servir archivos estáticos.

Servir archivos estáticos y de medios


uWSGI es capaz de servir archivos estáticos perfectamente, pero no es tan rápido y efectivo como NGINX. Para obtener el mejor rendimiento, usaremos NGINX para servir archivos estáticos en el entorno de producción. Configuraremos NGINX para servir tanto los archivos estáticos de tu aplicación (hojas de estilo CSS, archivos JavaScript e imágenes) como los archivos de medios subidos por los instructores para los contenidos del curso.

Edita el archivo "settings/base.py" y agrega la siguiente línea justo debajo de la configuración de `STATIC_URL`:

"""
STATIC_ROOT = BASE_DIR / 'static'
"""

Este es el directorio raíz para todos los archivos estáticos del proyecto. A continuación, recopilaremos los archivos estáticos de las diferentes aplicaciones de Django en el directorio común.

Recopilación de archivos estáticos


Cada aplicación en tu proyecto Django puede contener archivos estáticos en un directorio "static/". Django proporciona un comando para recopilar archivos estáticos de todas las aplicaciones en una sola ubicación. Esto simplifica la configuración para servir archivos estáticos en producción. El comando `collectstatic` recopila los archivos estáticos de todas las aplicaciones del proyecto en la ruta definida con la configuración `STATIC_ROOT`.

Detén la aplicación Docker desde la terminal presionando las teclas `Ctrl + C` o usando el botón de detener en la aplicación Docker Desktop o tambien con "docker compose down". Luego, inicia Compose nuevamente con el comando:

'''
docker compose up
'''

Abre otra terminal en el directorio padre, donde se encuentra el archivo `docker-compose.yml`, y ejecuta el siguiente comando:

```
docker compose exec web python /code/educa/manage.py collectstatic
```

Ten en cuenta que alternativamente puedes ejecutar el siguiente comando en la terminal, desde el directorio del proyecto `educa`:

```
python manage.py collectstatic --settings=educa.settings.local
```

Ambos comandos tendrán el mismo efecto ya que el directorio base local está montado en la imagen Docker. Django te preguntará si deseas sobrescribir cualquier archivo existente en el directorio raíz. Escribe `yes` y presiona Enter. Verás la siguiente salida:

```
171 static files copied to '/code/educa/static'.
```

Los archivos ubicados en el directorio `static/` de cada aplicación presente en la configuración `INSTALLED_APPS` se han copiado al directorio global del proyecto `/educa/static/`.

Servir archivos estáticos con NGINX


Edita el archivo "config/nginx/default.conf.template" y agrega las siguientes líneas resaltadas en azul al bloque `server`:

default.conf.template

server {
# ...
location / {
include /etc/nginx/uwsgi_params;
uwsgi_pass uwsgi_app;
}
location /static/ {
alias /code/educa/static/;
}
location /media/ {
alias /code/educa/media/;
}
}

Estas directivas le indican a NGINX que sirva directamente los archivos estáticos ubicados en las rutas /static/ y /media/.

Estas rutas son las siguientes:

- /static/: Corresponde a la ruta de la configuración `STATIC_URL`. La ruta de destino corresponde al valor de la configuración `STATIC_ROOT`. Se utiliza para servir los archivos estáticos de tu aplicación desde el directorio montado en la imagen Docker de NGINX.

- /media/: Corresponde a la ruta de la configuración `MEDIA_URL`, y su ruta de destino corresponde al valor de la configuración `MEDIA_ROOT`. Se utiliza para servir los archivos de medios subidos para los contenidos del curso desde el directorio montado en la imagen Docker de NGINX.

El esquema del entorno de producción ahora se ve así:

The production environment request/response cycle, including static files



Los archivos bajo las rutas /static/ y /media/ ahora son servidos directamente por NGINX, en lugar de ser reenviados a uWSGI. Las solicitudes a cualquier otra ruta todavía son pasadas por NGINX a uWSGI a través del socket UNIX.

Detén la aplicación Docker desde la terminal presionando las teclas `Ctrl + C` o usando el botón de detener en la aplicación Docker Desktop. Luego, inicia Compose nuevamente con el comando:

```
docker compose up
```

Abre http://educaproject.com/ en tu navegador. Deberías ver la siguiente pantalla:

The course list page served with NGINX and uWSGI



Los recursos estáticos, como hojas de estilo CSS e imágenes, ahora se cargan correctamente. Las solicitudes HTTP para archivos estáticos ahora son servidas directamente por NGINX, en lugar de ser reenviadas a uWSGI.

Hemos configurado exitosamente NGINX para servir archivos estáticos. A continuación, verificaremos el proyecto Django para desplegarlo en un entorno de producción y vas a servir tu sitio bajo HTTPS.

Asegurar tu sitio con SSL/TLS

El protocolo de Seguridad de la Capa de Transporte (TLS) es el estándar para servir sitios web a través de una conexión segura. El predecesor de TLS es Secure Sockets Layer (SSL). Aunque SSL está ahora obsoleto, encontrarás referencias a ambos términos, TLS y SSL, en varias bibliotecas y documentación en línea.

Se recomienda encarecidamente que sirvas tus sitios web a través de HTTPS.

Comprobando tu proyecto para prepararlo para la producción.


En esta sección, vas a verificar tu proyecto Django para un despliegue en producción y preparar el proyecto para ser servido sobre HTTPS. Luego, vas a configurar un certificado SSL/TLS en NGINX para servir tu sitio de manera segura.

Verificando tu proyecto para producción

Django incluye un marco de verificación del sistema para validar tu proyecto en cualquier momento. Este marco inspecciona las aplicaciones instaladas en tu proyecto Django y detecta problemas comunes. Las verificaciones se activan implícitamente al ejecutar comandos de gestión como runserver y migrate. Sin embargo, también puedes activar las verificaciones explícitamente con el comando de gestión check.

Puedes leer más sobre el marco de verificación del sistema de Django en https://docs.djangoproject.com/es/4.1/topics/checks/.

Confirmemos que el marco de verificación no genera problemas para tu proyecto. Abre la terminal en el directorio del proyecto educa y ejecuta el siguiente comando para verificar tu proyecto:

python manage.py check --settings=educa.settings.prod

Aunque también se puede montar los contenedores y ejecutar esta orden dentro de un shell del contenedor final-web con "docker exec -it identificación_contenedor bash".

Deberías ver el siguiente resultado:

El marco de verificación del sistema no identificó problemas (0 silenciados).

El marco de verificación del sistema no encontró ningún problema. Si usas la opción --deploy, el marco de verificación del sistema realizará verificaciones adicionales relevantes para un despliegue en producción.

Ejecuta el siguiente comando desde el directorio del proyecto educa:

python manage.py check --deploy --settings=educa.settings.prod

Verás un resultado similar al siguiente:

:/code/educa$ python manage.py check --deploy --settings=educa.settings.prod
System check identified some issues:

WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. If your entire site is served only over SSL, you may want to consider setting a value and enabling HTTP Strict Transport Security. Be sure to read the documentation first; enabling HSTS carelessly can cause serious, irreversible problems.
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True. Unless your site should be available over both SSL and non-SSL connections, you may want to either set this setting True or configure a load balancer or reverse-proxy server to redirect all connections to HTTPS.
?: (security.W009) Your SECRET_KEY has less than 50 characters, less than 5 unique characters, or it's prefixed with 'django-insecure-' indicating that it was generated automatically by Django. Please generate a long and random value, otherwise many of Django's security-critical features will be vulnerable to attack.
?: (security.W012) SESSION_COOKIE_SECURE is not set to True. Using a secure-only session cookie makes it more difficult for network traffic sniffers to hijack user sessions.
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True. Using a secure-only CSRF cookie makes it more difficult for network traffic sniffers to steal the CSRF token.

System check identified 5 issues (0 silenced).

El marco de verificación ha identificado cinco problemas (0 errores, 5 advertencias). Todas las advertencias están relacionadas con configuraciones de seguridad.

Vamos a abordar el problema security.W009. Edita el archivo educa/settings/base.py y modifica la configuración de SECRET_KEY eliminando el prefijo django-insecure- y agregando caracteres aleatorios adicionales para generar una cadena de al menos 50 caracteres.

Ejecuta nuevamente el comando check y verifica que el problema security.W009 ya no se presenta. El resto de las advertencias están relacionadas con la configuración de SSL/TLS. Las abordaremos a continuación.

Configuración de tu proyecto Django para SSL/TLS


Django viene con configuraciones específicas para el soporte de SSL/TLS. Vamos a editar la configuración de producción para servir tu sitio a través de HTTPS.

Edita el archivo de configuración `educa/settings/prod.py` y agrega las siguientes configuraciones:

```
# Seguridad
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True
```

Estas configuraciones son las siguientes:

- CSRF_COOKIE_SECURE: Usa una cookie segura para la protección contra falsificación de solicitudes entre sitios (CSRF). Con `True`, los navegadores solo transferirán la cookie a través de HTTPS.
- SESSION_COOKIE_SECURE: Usa una cookie de sesión segura. Con `True`, los navegadores solo transferirán la cookie a través de HTTPS.
- SECURE_SSL_REDIRECT: Indica si las solicitudes HTTP deben ser redirigidas a HTTPS.

Django ahora redirigirá las solicitudes HTTP a HTTPS; las cookies de sesión y CSRF solo se enviarán a través de HTTPS.

Ejecuta el siguiente comando desde el directorio principal de tu proyecto:

```
python manage.py check --deploy --settings=educa.settings.prod
```

Solo queda una advertencia, `security.W004`:

```
(security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting.
```

Esta advertencia está relacionada con la política de Seguridad de Transporte Estricto de HTTP (HSTS). La política HSTS evita que los usuarios eviten las advertencias y se conecten a un sitio con un certificado SSL caducado, autofirmado o de otra manera no válido. En la siguiente sección, utilizaremos un certificado autofirmado para nuestro sitio, por lo que ignoraremos esta advertencia.

Cuando tengas un dominio real, puedes solicitar una Autoridad de Certificación (CA) confiable para que emita un certificado SSL/TLS para él, de modo que los navegadores puedan verificar su identidad. En ese caso, puedes darle un valor a `SECURE_HSTS_SECONDS` mayor que 0, que es el valor predeterminado. Puedes obtener más información sobre la política HSTS en https://docs.djangoproject.com/en/4.1/ref/middleware/#http-strict-transport-security.

Hemos corregido con éxito el resto de los problemas planteados por el marco de verificación. Puedes leer más sobre la lista de verificación de implementación de Django en https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/.


Creación de un certificado SSL/TLS


Crea un nuevo directorio dentro del directorio del proyecto 'educa' (a la misma altura que manage,py, ) y llámalo 'ssl'. Luego, genera un certificado SSL/TLS desde la línea de comandos con el siguiente comando:

"""
openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
-keyout ssl/educa.key -out ssl/educa.crt \
-subj '/CN=*.educaproject.com' \
-addext 'subjectAltName=DNS:*.educaproject.com'
"""

Esto generará una clave privada y un certificado SSL/TLS de 2048 bits válido por 10 años. Este certificado se emite para el nombre de host `*.educaproject.com`. Este es un certificado comodín; al usar el carácter comodín `*` en el nombre de dominio, el certificado se puede usar para cualquier subdominio de `educaproject.com`, como `www.educaproject.com` o `django.educaproject.com`. Después de generar el certificado, el directorio "educa/ssl/" contendrá dos archivos: `educa.key` (la clave privada) y `educa.crt` (el certificado).

Necesitarás al menos OpenSSL 1.1.1 o LibreSSL 3.1.0 para usar la opción "-addext". Puedes verificar la ubicación de OpenSSL en tu máquina con el comando "which openssl" y puedes verificar la versión con el comando "openssl version".


Configurando Nginx para que use SSL/TLS


Edita el archivo docker-compose.yml y añade la línea resaltada en azul:

docker-compose.yml

services:
    # ...
    
    nginx:
        #...
        ports:
            - "80:80"
            - "443:443"

Con esto el conenedor del host de Nginx será acesible tanto en el puerto 80 (HTTP) como en el puerto 443 (HTTPS). Asociamos el host del puerto 443 con el puerto del contenedor 443.

Ahora edita el archivo de configuración de Nginx /config/nginx/default.conf.template y edita el bloque del servidor para poder utilizar SSL/TLS de la siguiente forma:

default.conf.template

server {
listen 80;
listen 443 ssl;
ssl_certificate /code/educa/ssl/educa.crt;
ssl_certificate_key /code/educa/ssl/educa.key;
server_name www.educaproject.com educaproject.com;
# ...
Con esto conseguimos que Nginx escuche las peticiones en ambos puertos, HTTP en el puerto 80 y HTTPS en el puerto 443. También le hemos indicado el camino donde encontrar los certificados.

Si tienes activa la aplicación de Docker, detenla con CTRL+C o con docker compose down y vuelve la a ejecutar de nuevo:

$docker compose up

Luego abre la siguiente dirección en tu navegador https://educaproject.com/. Deberías ver un mensaje de advertencia similar al siguiente:

An invalid certificate warning



Esta pantalla puede variar dependiendo de tu navegador. Te alerta de que tu sitio no está utilizando un certificado confiable o válido; el navegador no puede verificar la identidad de tu sitio. Esto se debe a que firmaste tu propio certificado en lugar de obtener uno de una CA confiable. Cuando tienes un dominio real, puedes solicitar a una CA confiable que emita un certificado SSL/TLS para él, de modo que los navegadores puedan verificar su identidad. Si quieres obtener un certificado confiable para un dominio real, puedes referirte al proyecto Let's Encrypt creado por la Fundación Linux. Es una CA sin fines de lucro que simplifica la obtención y renovación de certificados SSL/TLS confiables de forma gratuita. Puedes encontrar más información en https://letsencrypt.org.

Haz clic en el enlace o botón que proporciona información adicional y elige visitar el sitio web, ignorando las advertencias. El navegador podría pedirte que añadas una excepción para este certificado o que verifiques que confías en él. Si estás usando Chrome, puede que no veas ninguna opción para ir al sitio web. Si este es el caso, escribe "thisisunsafe" y presiona Enter directamente en Chrome en la página de advertencia. Chrome entonces cargará el sitio web. Nota que haces esto con tu propio certificado emitido; no confíes en ningún certificado desconocido ni omitas las verificaciones de certificados SSL/TLS del navegador para otros dominios.

Cuando accedas al sitio, el navegador mostrará un ícono de candado junto a la URL.

The browser address bar, including a warning message

Si hace clic en el icono de candado o en el icono de advertencia, los detalles del certificado SSL/TLS se mostrarán los detalles del certificado.

En los detalles del certificado, verás que es un certificado autofirmado y podrás ver su fecha de expiración. Tu navegador podría marcar el certificado como no seguro, pero lo estás utilizando solo con fines de prueba. Ahora estás sirviendo tu sitio de manera segura a través de HTTPS.

Redirigiendo el tráfico HTTP a HTTPS


Estás redirigiendo solicitudes HTTP a HTTPS con Django usando la configuración SECURE_SSL_REDIRECT. Cualquier solicitud utilizando http:// se redirige a la misma URL usando https://. Sin embargo, esto se puede manejar de una manera más eficiente utilizando NGINX.

Edita el archivo config/nginx/default.conf.template y añade las siguientes líneas resaltadas en azul:

default.conf.template

# upstream for uWSGI
upstream uwsgi_app {
server unix:/code/educa/uwsgi_app.sock;
}

server {
listen 80;
server_name www.educaproject.com educaproject.com;
return 301 https://$host$request_uri;
}

server {
listen 443 ssl;
ssl_certificate /code/educa/ssl/educa.crt;
ssl_certificate_key /code/educa/ssl/educa.key;
server_name www.educaproject.com educaproject.com;
#...

En este código, eliminas la directiva listen 80; del bloque de servidor original, de modo que la plataforma esté disponible solo a través de HTTPS (puerto 443). Encima del bloque de servidor original, añades un bloque de servidor adicional que solo escucha en el puerto 80 y redirige todas las solicitudes HTTP a HTTPS. Para lograr esto, devuelves un código de respuesta HTTP 301 (redirección permanente) que redirige a la versión https:// de la URL solicitada utilizando las variables $host y $request_uri.

Abre una terminal en el directorio padre, donde se encuentra el archivo docker-compose.yml, y ejecuta el siguiente comando para recargar NGINX:

```
docker compose exec nginx nginx -s reload
```
Esto ejecuta el comando nginx -s reload en el contenedor de nginx. Ahora estás redirigiendo todo el tráfico HTTP a HTTPS utilizando NGINX.

Si abres el navegador y vas a http://www.educaproject.com verás como automáticamente nginx te redirige a htpps://www.educaproject.com.

Tu entorno ahora está asegurado con TLS/SSL. Para completar el entorno de producción, necesitas configurar un servidor web asíncrono para Django Channels.


Usando Daphne para Django Channels


Si has usado el programa de ejemplo que puse al principio del post, ya usamos Django Channels para construir un servidor de chat usando WebSockets. uWSGI es adecuado para ejecutar Django o cualquier otra aplicación WSGI, pero no admite comunicación asincrónica usando Asynchronous Server Gateway Interface (ASGI) o WebSockets. Para ejecutar Channels en producción, necesitas un servidor web ASGI que sea capaz de gestionar WebSockets. 

Daphne es un servidor HTTP, HTTP2 y WebSocket para ASGI desarrollado para servir Channels. Puedes ejecutar Daphne junto con uWSGI para servir aplicaciones ASGI y WSGI de manera eficiente. Puedes encontrar más información sobre Daphne en https://github.com/django/daphne.

Ya añadímos `daphne==4.1.2` al archivo `requirements.txt` del proyecto. Vamos a crear un nuevo servicio en el archivo `docker-compose.yml` para ejecutar el servidor web Daphne.

Edita el archivo `docker-compose.yml` y añade al final las siguientes líneas:

docker-compose.yml

daphne:
    build: .
    working_dir: /code/educa/
    command: ["../wait-for-it.sh", "db:5432", "--",
              "daphne", "-u", "/code/educa/daphne.sock",
              "educa.asgi:application"]
    restart: always
    volumes:
      - .:/code
    environment:
      - DJANGO_SETTINGS_MODULE=educa.settings.prod
      - POSTGRES_DB=postgres
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    depends_on:
      - db
      - cache
    user: "miusuario"

IMPORTANTE. Al añadir este contenedor al proyecto y tratar de ejecutar "docker compose up" me dio el siguiente problema de permisos:

failed to solve: error from sender: open Final/data/db: permission denied

Una solución sería cambiar los permisos y otra es detener los contenedores y las imágenes, eliminar ambos, y volver a realizar la composición con "docker compose up" además de borrar el archivo ./data con "rm -rf data". Para que todo funcione hay que volver a realizar las migraciones y crear un superusuario.

docker compose exec web python /code/educa/manage.py migrate
docker compose exec web python /code/educa/manage.py createsuperuser

Sigamos con la explicación.

La definición del servicio daphne es muy similar al servicio web. La imagen para el servicio daphne también se construye con el Dockerfile que creamos anteriormente para el servicio web. Las principales diferencias son:

- working_dir cambia el directorio de trabajo de la imagen a /code/educa/.

- command ejecuta la aplicación educa.asgi:application definida en el archivo educa/asgi.py con daphne utilizando un socket UNIX. También usa el script Bash wait-for-it para esperar a que la base de datos PostgreSQL esté lista antes de iniciar el servidor web.

Dado que estás ejecutando Django en producción, Django verifica los ALLOWED_HOSTS al recibir solicitudes HTTP. Implementaremos la misma validación para las conexiones WebSocket. Edita el archivo educa/asgi.py de tu proyecto y agrega las siguientes líneas destacadas en azul:

Final/educa/educa/asgi.py

import os

from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from channels.auth import AuthMiddlewareStack
import chat.routing

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'educa.settings')

django_asgi_app = get_asgi_application()

application = ProtocolTypeRouter({
    'http': django_asgi_app,
    'websocket': AllowedHostsOriginValidator(
        AuthMiddlewareStack(
        URLRouter(chat.routing.websocket_urlpatterns)
    )
    ),
})
La configuración de channels está lista para la produción.


Usando conexiones seguras para los websockets.


Hemos configurado Nginx para que use conexiones seguras con SSL/TLS. Tenemos que cambiar las conexiones ws (WebSocket) por conexiones seguras wss (WebSocket secure), de la misma forma que cambiamos las conexiones HTTP y ahora se sirven bajo HTTPS.

Edita la plantilla chat/rooom.html de la aplicación chat y encuentra la siguiente línea en el bloque doomready:

const url = 'ws://' + window.location.host +

Reemplázala con esta otra:

const url = 'wss://' + window.location.host +

Usando wss en lugar de ws, nos estamos conectando explícitamente a un WebSocket seguro.


Incluyendo Daphne en la configuración de Nginx.


En tu configuración de producción, ejecutarás Daphne en un socket UNIX y usarás NGINX frente a él. NGINX pasará solicitudes a Daphne según la ruta solicitada. Expondrás a Daphne a NGINX a través de
una interfaz de socket UNIX, al igual que la configuración uWSGI.

Edita el archivo config/nginx/default.conf.template y haz que tenga el siguiente aspecto:

default.conf.template

# upstream for uWSGI
upstream uwsgi_app {
server unix:/code/educa/uwsgi_app.sock;
}

# upstream for Daphne
upstream daphne {
server unix:/code/educa/daphne.sock;
}

server {
listen 80;
server_name www.educaproject.com educaproject.com;
return 301 https://$host$request_uri;
}

server {
listen 443 ssl;
ssl_certificate /code/educa/ssl/educa.crt;
ssl_certificate_key /code/educa/ssl/educa.key;
server_name www.educaproject.com educaproject.com;
error_log stderr warn;
access_log /dev/stdout main;
location / {
include /etc/nginx/uwsgi_params;
uwsgi_pass uwsgi_app;
}
location /ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
proxy_pass http://daphne;
}
location /static/ {
alias /code/educa/static/;
}
location /media/ {
alias /code/educa/media/;
}
}
En esta configuración, se establece un nuevo upstream llamado daphne, el cual apunta a un socket UNIX creado por Daphne. En el bloque del servidor, se configura la ubicación /ws/ para reenviar las solicitudes a Daphne. Se utiliza la directiva proxy_pass para pasar las solicitudes a Daphne e incluyes algunas directivas de proxy adicionales. Con esta configuración, NGINX pasará cualquier solicitud de URL que comience con el prefijo /ws/ a Daphne y el resto a uWSGI, excepto los archivos ubicados en las rutas /static/ o /media/, los cuales serán servidos directamente por NGINX.

La configuración de producción que incluye Daphne ahora se ve así:

The production environment request/response cycle, including Daphne


NGINX se ejecuta frente a uWSGI y Daphne como un servidor proxy inverso. NGINX se enfrenta a la web y pasa las solicitudes al servidor de aplicaciones (uWSGI o Daphne) en función de su prefijo de ruta. Además de esto, NGINX también sirve archivos estáticos y redirige solicitudes no seguras a solicitudes seguras. Esta configuración reduce el tiempo de inactividad, consume menos recursos del servidor y proporciona un mayor rendimiento y seguridad.

Detén la aplicación Docker desde la terminal presionando las teclas Ctrl + C o usando el botón de detener en la aplicación Docker Desktop. Luego, inicia Compose nuevamente con el comando:

```
docker compose up
```

Usa tu navegador para crear un curso de muestra con un usuario instructor, inicia sesión con un usuario que esté inscrito en el curso y abre https://educaproject.com/chat/room/1/ con tu navegador. Deberías poder enviar y recibir mensajes como en el siguiente ejemplo:

GRAFICO DE DAPNE Y NEGINX


Daphne está funcionando correctamente y NGINX está pasando las solicitudes de WebSocket a ella. Todas las conexiones están aseguradas con SSL/TLS.

Con esto hemos terminado. Has construido una pila personalizada lista para producción utilizando NGINX, uWSGI y Daphne. Podrías realizar más optimizaciones para mejorar el rendimiento y la seguridad mediante configuraciones adicionales en NGINX, uWSGI y Daphne. Sin embargo, esta configuración de producción es un excelente comienzo.

Has utilizado Docker Compose para definir y ejecutar servicios en múltiples contenedores. Ten en cuenta que puedes usar Docker Compose tanto para entornos de desarrollo local como para entornos de producción. Puedes encontrar información adicional sobre el uso de Docker Compose en producción en https://docs.docker.com/compose/production/.

Para entornos de producción más avanzados, necesitarás distribuir dinámicamente los contenedores entre un número variable de máquinas. Para eso, en lugar de Docker Compose, necesitarás un orquestador como Docker Swarm mode o Kubernetes. Puedes encontrar información sobre Docker Swarm mode en https://docs.docker.com/engine/swarm/ y sobre Kubernetes en https://kubernetes.io/docs/home/.

Crear un middleware personalizado


Ya conoces la configuración MIDDLEWARE, que contiene los middlewares para tu proyecto. Puedes pensar en ella como un sistema de plugins a bajo nivel, que te permite implementar ganchos que se ejecutan en el proceso de solicitud/respuesta. Cada middleware es responsable de una acción específica que se ejecutará para todas las solicitudes o respuestas HTTP.

Evita agregar procesamiento costoso a los middlewares, ya que se ejecutan en cada solicitud individualmente. Cuando se recibe una solicitud HTTP, los middlewares se ejecutan en el orden en que aparecen en la configuración MIDDLEWARE. Cuando Django genera una respuesta HTTP, esta pasa a través de todos los middlewares en orden inverso.

Un middleware puede escribirse como una función, de la siguiente manera:

def my_middleware(get_response):
    def middleware(request):
        # Código ejecutado para cada petición antes
        # de que se llame a la vista.
        response = get_response(request)
        # El código se ejecuta para cada petición/respuesta
        # después de que se llame a la vista.
        return response
    return middleware
Una fábrica de middleware es un callable que toma un callable get_response y devuelve un middleware. Un middleware es un callable que toma una solicitud y devuelve una respuesta, al igual que una vista. El callable get_response podría ser el siguiente middleware en la cadena o la vista actual en el caso del último middleware listado.

Si cualquier middleware devuelve una respuesta sin llamar a su callable get_response, interrumpe el proceso; no se ejecuta ningún otro middleware (tampoco la vista), y la respuesta se devuelve a través de las mismas capas por las que pasó la solicitud.

El orden de los middlewares en la configuración MIDDLEWARE es muy importante porque un middleware puede depender de los datos establecidos en la solicitud por otro middleware que se haya ejecutado previamente.

Cuando agregues un nuevo middleware a la configuración MIDDLEWARE, asegúrate de colocarlo en la posición correcta. Los middlewares se ejecutan en el orden en que aparecen en la configuración durante la fase de solicitud y en orden inverso para las respuestas.

Puedes encontrar más información sobre middlewares en https://docs.djangoproject.com/en/4.1/topics/http/middleware/
https://docs.djangoproject.com/en/4.1/topics/http/middleware/

Crear un middleware para subdominios

Vas a crear un middleware personalizado para permitir que los cursos sean accesibles a través de un subdominio personalizado. Cada URL de detalle de curso, que se ve como https://educaproject.com/course/django/, también será accesible a través del subdominio que utiliza el slug del curso, como https://django.educaproject.com/. Los usuarios podrán usar el subdominio como un atajo para acceder a los detalles del curso. Cualquier solicitud a subdominios será redirigida a la URL correspondiente del detalle del curso.

El middleware puede ubicarse en cualquier lugar dentro de tu proyecto. Sin embargo, se recomienda crear un archivo middleware.py en el directorio de tu aplicación.

Crea un nuevo archivo dentro del directorio de la aplicación courses y llámalo middleware.py. Agrega el siguiente código:

middleware.py

from django.urls import reverse
from django.shortcuts import get_object_or_404, redirect
from .models import Course


def subdomain_course_middleware(get_response):
    """
    Subdominio para los cursos.
    """
    def middleware(request):
        host_parts = request.get_host().split('.')
        if len(host_parts) > 2 and host_parts[0] != 'www':
            # get course for the given subdomain
            course = get_object_or_404(Course, slug=host_parts[0])
            course_url = reverse('course_detail',
                                 args=[course.slug])
            # redirect current request to the course_detail view
            url = '{}://{}{}'.format(request.scheme,
                                     '.'.join(host_parts[1:]), course_url)
            return redirect(url)
        response = get_response(request)
        return response
    return middleware

Cuando se recibe una petición HTML, se realizan las siguientes tareas:

1.- Obtenemos el nombre del host que se está usando en la petición y la dividimos en dos partes. Por ejemplo si el usuario accede a micurso.educaproject.com se genera la siguiente lista:

['micurso', 'educaproject', 'com']

2.- Comprobamos si el nombre del host incluye un subdominio comprobando si la lista tiene más de dos elementos. Si el nombre del host incluye un subdominio, y no es www, intentaremos acceder al curso con el slug proporcionado en el subdominio.

3.- Si no se encuentra el curso, elevaremos una excepción HTTP 404. Si se encuentra, redireccionamos el navegador a la url especifica del curso.

Edita el archivo settings/base.py del proyecto y añade 'courses.middleware.SubdomainCourseMiddleware' al final de toda la lista de Middleware, de la siguiente forma:

MIDDLEWARE = [
# ...
'courses.middleware.subdomain_course_middleware',
]
El "middkeware" se ejecutará ahora en cada petición.

Recuerda que los nombres de host permitidos para servir tu proyecto de Django están especificados en en la configuración ALLOWED_HOST. Vamos a modificarlo para permitir que cualquier posible subdominio del principal funcione.
 
Edita el archivo educa/settings/prod.py y modifica la configuración ALLOWED_HOST 

ALLOWED_HOSTS = ['.educaproject.com']

Un valor que comienza con un punto se utiliza como un comodín para los subdominios. ".educaproject.com" funcionará con educaproject.com y con cualquiera de sus subdominios, por ejemplo, course.educaproject.com y django.educaproject.com.


Sirviendo múltiples subdominios con Nginx.

Necesitamos que Nginx sirva nuestro sitio con cualquier posible subdominio. Para ello editaremos el archivo de configuración de Nginx "config/nginx/default.conf.template" y reemplazaremos esta línea:

server_name www.educaproject.com educaproject.com;

con esta otra:

server_name *.educaproject.com educaproject.com;

Al usar el asterisco, esta regla se aplicaría a todos los subdominios de educaproject.com. Para poder testear el middleware de forma local tendríamos que añadir esta linea al archivo etc/hosts/

127.0.0.1 django.educaproject.com

Si está en ejecución detén la aplicación de Docker desde el shell y luego vuelve a ejecutar docker compose de nuevo con:

docker compose up

Luego abre la dirección https://django.educaproject.com/ in tu navegador. El middleware encontrará el curso bajo el subdominio y te redirigirá a la dirección https://educaproject.com/course/django/.


Implementar comandos del usuario personalizados.

Django permite que tus aplicaciones registren comandos de gestión personalizados para la utilidad manage.py.

Un comando de gestión consiste en un módulo de Python que contiene una clase Command que hereda de django.core.management.base.BaseCommand o de una de sus subclases. Puedes crear comandos simples o hacer que acepten argumentos posicionales y opcionales como entrada.

Django busca comandos de gestión en el directorio management/commands/ de cada aplicación activa en la configuración INSTALLED_APPS. Cada módulo encontrado se registra como un comando de gestión con el mismo nombre.

Puedes aprender más sobre comandos de gestión personalizados en https://docs.djangoproject.com/
es/5.0/howto/custom-management-commands/.

Vas a crear un comando de gestión personalizado para recordar a los estudiantes que se inscriban en al menos un curso. El comando enviará un recordatorio por correo electrónico a los usuarios que han estado registrados por más tiempo que un período especificado y que aún no están inscritos en ningún curso.

Crea la siguiente estructura de archivos dentro del directorio de la aplicación students:
management/
    __init__.py
    commands/
        __init__.py
        enroll_reminder.py

Edita el archivo enroll_reminder.py y añade el siguiente código:

enroll_reminder.py

import datetime
from django.conf import settings
from django.core.management.base import BaseCommand
from django.core.mail import send_mass_mail
from django.contrib.auth.models import User
from django.db.models import Count
from django.utils import timezone


class Command(BaseCommand):
    help = 'Envia un e-mail para recordar a los usuarios registrados más de N dias \
          y que no estén aun registrados en algún curso aún'

    def add_arguments(self, parser):
        parser.add_argument('--days', dest='days', type=int)

    def handle(self, *args, **options):
        emails = []
        subject = 'Apúntate a un curso'
        date_joined = timezone.now().today() - \
            datetime.timedelta(days=options['days'] or 0)
        users = User.objects.annotate(course_count=Count('courses_joined')).filter(
            course_count=0, date_joined__date__lte=date_joined)
        for user in users:
            message = """Estimado {},
            Hemos visto que aún no te has apuntado a ningún curso.
            ¿A qué estas esperando?""".format(user.first_name)
            emails.append(
                (subject, message, settings.DEFAULT_FROM_EMAIL, [user.email]))
        send_mass_mail(emails)
        self.stdout.write('Enviar {} recordatorios'.format(len(emails)))

Este es tu comando enroll_reminder. El código anterior es el siguiente:

- La clase Command hereda de BaseCommand.

- Incluyes un atributo help. Este atributo proporciona una breve descripción del comando que se imprime si ejecutas el comando python manage.py help enroll_reminder.

- Utilizas el método add_arguments() para agregar el argumento nombrado --days. Este argumento se usa para especificar el número mínimo de días que un usuario debe estar registrado, sin haberse inscrito en ningún curso, para recibir el recordatorio.

- El comando handle() contiene el comando real. Obtienes el atributo days analizado desde la línea de comandos. Si esto no está configurado, utilizas 0, de modo que se envía un recordatorio a todos los usuarios que no se han inscrito en un curso, independientemente de cuándo se registraron. Utilizas la utilidad timezone proporcionada por Django para obtener la fecha actual con conocimiento de zona horaria con timezone.now().date(). (Puedes configurar la zona horaria para tu proyecto con la configuración TIME_ZONE). Recuperas a los usuarios que han estado registrados por más días de los especificados y que aún no están inscritos en ningún curso. Logras esto anotando el QuerySet con el número total de cursos en los que está inscrito cada usuario. Generas el correo electrónico de recordatorio para cada usuario y lo añades a la lista emails. Finalmente, envías los correos electrónicos utilizando la función send_mass_mail(), que está optimizada para abrir una sola conexión SMTP para enviar todos los correos electrónicos, en lugar de abrir una conexión por cada correo enviado.

Has creado tu primer comando de gestión. Abre la consola y ejecuta el comando:

docker compose exec web python /code/educa/manage.py \
enroll_reminder --days=20 --settings=educa.settings.prod

Si no tienes un servidor SMTP local en funcionamiento, puedes agregar la siguiente configuración al archivo settings.py para que Django muestre los correos electrónicos en la salida estándar durante el desarrollo:

```
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
```

Django también incluye una utilidad para llamar a comandos de gestión usando Python. Puedes ejecutar comandos de gestión desde tu código de la siguiente manera:

```
from django.core import management
management.call_command('enroll_reminder', days=20)
```