Hi ha un deute en aquest curs que fa temps que està obert, des del Mòdul 6. A 06-05 vas emetre un certificat de Let's Encrypt per a reserves.tramontana.example, el vas verificar, en vas programar la renovació amb certbot.timer i vas escriure revisar_certificat.sh per avisar vint dies abans que caduqui. Tot correcte. I tanmateix, avui Tramontana Reserves se serveix encara sense xifrar, perquè el certificat està desat en un disc i no hi ha res que l'utilitzi.

L'aplicació escolta a 127.0.0.1:8080, en text pla, i aquell és el punt on el disseny es va quedar a mitges. Aquesta lliçó tanca aquell deute muntant Nginx com a servidor intermediari invers (reverse proxy) davant de l'aplicació: acaba el TLS amb el certificat que ja tens, hi afegeix les capçaleres de seguretat, serveix el contingut estàtic, limita la tasa de peticions i deixa l'aplicació exactament on és, al bucle local, invisible des de fora.

És també el primer projecte del mòdul i, per tant, la primera vegada que la feina s'organitza com s'organitza un projecte real: objectiu i requisits, decisions de disseny justificades, construcció, verificació, automatització amb Ansible i operació. Aquell serà el patró de les sis lliçons.

Contingut

  1. Objectiu, requisits previs i estat de partida
  2. Què és un proxy invers i per què es posa davant
  3. Triar el servidor web: Nginx, Apache o Caddy
  4. Instal·lació i estructura de /etc/nginx
  5. Anatomia de la configuració: contextos, server i location
  6. Construcció del servidor virtual de Tramontana
  7. Contingut estàtic, compressió i límit de tasa
  8. Registre i rotació
  9. Integrar el certificat ja emès amb certbot
  10. Verificació i mesura abans i després
  11. Automatització amb Ansible: el rol web
  12. Operació i manteniment

Objectiu, requisits previs i estat de partida

Objectiu. Que https://reserves.tramontana.example respongui xifrat, amb qualificació A als comprovadors de TLS habituals, servint l'aplicació que corre a 127.0.0.1:8080 sense exposar aquell port, amb contingut estàtic servit directament i amb protecció bàsica davant de l'abús.

Requisits previs, tots ja complerts en mòduls anteriors:

Requisit D'on ve Comprovació
Certificat emès i renovant-se 06-05 sudo certbot certificates
DNS apuntant al servidor 06-01 dig +short reserves.tramontana.example
Ports 80 i 443 oberts, 8080 tancat 06-03 sudo ufw status numbered
Aplicació activa a 127.0.0.1:8080 05-05 curl -I http://127.0.0.1:8080/
Entorn de proves disponible 07-04 srv-tramontana-proves
Ansible amb l'inventari de producció 07-06 ~/tramontana-infra

L'estat de partida, mesurat abans de tocar res — perquè es mesura abans i després:

$ curl -I http://127.0.0.1:8080/
HTTP/1.1 200 OK
Server: tramontana/3.2.1
Content-Type: text/html; charset=utf-8
Content-Length: 4812

$ curl -sI https://reserves.tramontana.example/ ; echo "codi: $?"
curl: (7) Failed to connect to reserves.tramontana.example port 443: Connection refused
codi: 7

Aquí està el deute, en dues ordres: l'aplicació funciona i el port 443 no existeix.

Què és un proxy invers i per què es posa davant

Un servidor intermediari invers és un servidor que rep les peticions dels clients i les reenvia a un o diversos servidors interns, retornant-ne la resposta com si fos pròpia. El client mai no parla amb l'aplicació: parla amb el proxy.

La paraula «invers» distingeix aquest cas del proxy clàssic. Un proxy directe treballa per al client (una empresa que filtra la navegació dels seus empleats); un proxy invers treballa per al servidor.

Proxy directe Proxy invers
A qui serveix Al client Al servidor
Qui el configura El client o la seva xarxa El propietari del servei
Què amaga La identitat del client La topologia interna del servei
Exemple típic Filtratge corporatiu, Squid Nginx davant d'una aplicació
El veu l'usuari final Sí, el configura No, és transparent

I la pregunta que importa: si l'aplicació ja sap parlar HTTP, per què no deixar que escolti directament al 443? Sis raons, i cap no és opcional en producció:

  1. Terminació TLS. El proxy gestiona els certificats, els protocols i les suites criptogràfiques. L'aplicació no necessita saber res de TLS, no necessita llegir la clau privada i no necessita reiniciar-se quan el certificat es renova. Un únic punt on s'aplica la política de xifratge.
  2. Ports privilegiats sense privilegis. Escoltar al 443 requereix CAP_NET_BIND_SERVICE. Nginx ho fa arrencant com a root i baixant de privilegis immediatament; l'aplicació continua com a svc-tramontana en un port alt, sense cap capacitat especial. És exactament el principi de mínim privilegi de 05-02.
  3. Contingut estàtic. Servir un CSS de 40 KB no hauria de consumir un fil de l'aplicació. Nginx ho fa amb sendfile(), sense copiar les dades a espai d'usuari, i amb dos ordres de magnitud menys de cost.
  4. Capçaleres i política. HSTS, CSP, X-Frame-Options, compressió, redireccions: tot això es configura una vegada, en un sol lloc, i s'aplica a tot el que surt — sense dependre que el desenvolupador ho recordi a cada resposta.
  5. Límit de tasa i protecció. Un abús es talla a la vora, abans d'arribar a la lògica de negoci i abans de consumir una connexió de PostgreSQL.
  6. Un punt de repartiment. És el que permet afegir un segon node d'aplicació sense canviar res visible des de fora — l'opció B recomanada a 07-07.

I una raó addicional que s'aprecia el dia del desplegament: amb el proxy al davant, desplegar.sh pot reiniciar l'aplicació mentre Nginx retorna una pàgina d'error decent en lloc d'una connexió rebutjada.

Triar el servidor web: Nginx, Apache o Caddy

Nginx Apache httpd Caddy
Model de concurrència Asíncron, pocs processos Processos o fils per connexió (MPM) Asíncron (Go)
Consum amb moltes connexions Molt baix Alt amb prefork Baix
TLS automàtic Amb certbot Amb certbot De sèrie, sense configurar
Configuració Declarativa, pròpia Directives + .htaccess JSON/Caddyfile, molt breu
.htaccess per directori No (i és un avantatge) Sí No
Mòduls en calent No, cal recompilar Sí, dinàmics Connectors compilats
Com a proxy invers Excel·lent Correcte Excel·lent
Quota d'ús i documentació Enorme Enorme Creixent
Als repositoris d'Ubuntu 24.04 Sí (1.24) Sí (2.4) No (repositori propi)

La decisió per a Tramontana és Nginx, i per aquests motius concrets:

  • És als repositoris oficials d'Ubuntu 24.04, cosa que encaixa amb la política de paquets i fixació de versions de 05-03: sense repositoris de tercers per mantenir.
  • El seu model asíncron és l'adequat per a un proxy invers: milers de connexions lentes no costen milers de processos, cosa rellevant amb 3,8 GB de RAM.
  • És el que ja coneixes parcialment de 07-07, on va aparèixer com a alternativa a HAProxy amb upstream.
  • L'absència de .htaccess és deliberadament bona: tota la configuració viu a /etc/nginx, versionada a Ansible, sense fitxers solts que canviïn el comportament sense deixar rastre.

Caddy seria una elecció molt raonable en un projecte nou —la seva gestió automàtica de certificats elimina tota una classe d'incidents—, però aquí el certificat ja existeix i el flux de renovació està muntat i provat. Apache continua sent excel·lent, sobretot si cal mod_php o configuració per directori; per a un proxy invers pur, Nginx és més lleuger.

Instal·lació i estructura de /etc/nginx

$ sudo apt update && sudo apt install nginx
$ nginx -v
nginx version: nginx/1.24.0 (Ubuntu)

$ systemctl is-active nginx
active

Ubuntu arrenca Nginx amb un lloc de benvinguda. Abans de res, comprova que la instal·lació no ha obert una porta que no volies:

$ sudo ss -tlnp | grep nginx
LISTEN 0 511 0.0.0.0:80  0.0.0.0:*  users:(("nginx",pid=2841,fd=6))
LISTEN 0 511    [::]:80     [::]:*  users:(("nginx",pid=2841,fd=7))

Escolta al 80 en totes les interfícies, que és l'esperat i el que ufw ja permet des de 06-03.

L'estructura de /etc/nginx a Ubuntu:

Ruta Què conté
nginx.conf Configuració global; inclou els altres fitxers
conf.d/*.conf Fragments globals del context http (Debian/Ubuntu)
sites-available/ Servidors virtuals definits, actius o no
sites-enabled/ Enllaços simbòlics als actius
snippets/ Fragments reutilitzables (ssl-params.conf, etc.)
mime.types Correspondència extensió → tipus MIME
modules-enabled/ Mòduls dinàmics activats

La distinció entre conf.d i sites-available és una convenció de Debian i Ubuntu que convé entendre per no barrejar-les:

  • conf.d/ s'inclou dins del context http i serveix per a configuració transversal: formats de registre, zones de límit de tasa, ajustos de proxy compartits. És el que fan servir les distribucions basades en Red Hat per a tot.
  • sites-available/ + sites-enabled/ és el patró Debian: es defineix cada servidor virtual en un fitxer i s'activa creant un enllaç simbòlic. Desactivar un lloc és esborrar l'enllaç, no el fitxer — reversible, i amb l'històric intacte.
$ grep -n 'include' /etc/nginx/nginx.conf | tail -3
 62:	include /etc/nginx/conf.d/*.conf;
 63:	include /etc/nginx/sites-enabled/*;

$ ls -l /etc/nginx/sites-enabled/
lrwxrwxrwx 1 root root 34 Aug 18 09:12 default -> /etc/nginx/sites-available/default

El primer, retirar el lloc per defecte: serveix una pàgina de benvinguda que revela la versió de Nginx i respon a qualsevol nom de domini.

$ sudo cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak-$(date +%F)
$ sudo rm /etc/nginx/sites-enabled/default

I dos ajustos globals a nginx.conf, dins del context http:

    # No revelar la versio a la capcalera Server ni a les pagines d error
    server_tokens off;

    # Mida maxima d una peticio: les fotos de les cases rurals
    client_max_body_size 20m;

server_tokens off canvia Server: nginx/1.24.0 (Ubuntu) per Server: nginx. No és una defensa seriosa —la versió es pot inferir d'altres maneres— però elimina el resultat fàcil dels escàners automàtics, en la línia de l'enduriment de 06-06.

Anatomia de la configuració: contextos, server i location

La configuració de Nginx és un arbre de contextos imbricats. Les directives només són vàlides en certs contextos, i hereten cap avall: el que poses a http val per a tots els server, llevat que un el redefineixi.

Context Què configura Directives típiques
main (arrel) El procés user, worker_processes, pid
events Model de connexions worker_connections, multi_accept
http Tot el trànsit HTTP log_format, gzip, include, upstream
server Un servidor virtual listen, server_name, ssl_certificate
location Un conjunt de rutes proxy_pass, root, expires

Com tria Nginx el bloc server

Aquest és el mecanisme que més confusió genera, i es resol en dos passos:

  1. Per listen: es descarten els server que no escolten a l'adreça i el port de la petició. La coincidència més específica guanya (listen 10.0.2.15:443 guanya a listen 443).
  2. Per server_name, comparant amb la capçalera Host de la petició, en aquest ordre: nom exacte → comodí al principi (*.tramontana.example) → comodí al final (www.*) → expressió regular, en ordre d'aparició.

Si res no coincideix, guanya el servidor per defecte: el marcat amb default_server al seu listen, o el primer definit per a aquell port. D'aquí que deixar el lloc default actiu sigui un problema: qualsevol petició amb un Host desconegut —inclòs un escàner que arriba per IP— acaba allà.

Com tria Nginx el bloc location

Aquí l'ordre de precedència no és l'ordre del fitxer, i equivocar-se produeix comportaments desconcertants:

Prioritat Modificador Exemple Significat
1 = location = /salut Coincidència exacta. Guanya sempre i atura la cerca
2 ^~ location ^~ /static/ Prefix que, si és el més llarg, impedeix avaluar les regex
3 ~ / ~* location ~* \.(jpg|css)$ Regex sensible / insensible a majúscules, en ordre del fitxer
4 (prefix) location / Prefix més llarg, si cap regex no ha coincidit

L'algorisme complet: Nginx busca el prefix més llarg que coincideixi; si porta =, acaba; si porta ^~, acaba també; en cas contrari avalua les expressions regulars en ordre i la primera que coincideixi guanya; si cap no coincideix, es fa servir el prefix més llarg desat.

La conseqüència pràctica que cal memoritzar: una regex venç un prefix encara que el prefix sigui més específic. Un location ~ \.php$ capturarà /static/foo.php encara que existeixi location /static/. Per això els directoris estàtics es declaren amb ^~.

Construcció del servidor virtual de Tramontana

Aquí tens el fitxer complet, comentat directiva a directiva. És el nucli de la lliçó.

# /etc/nginx/sites-available/reserves.tramontana.example
# Proxy invers amb terminacio TLS per a Tramontana Reserves 3.2.1

# ---------------------------------------------------------------
# Zones de limit de tasa. Es declaren al context http (aqui via
# sites-enabled, que s inclou dins d http). La memoria es
# compartida entre els processos worker.
# 10m emmagatzemen unes 160.000 adreces IP.
# ---------------------------------------------------------------
limit_req_zone  $binary_remote_addr zone=general:10m rate=30r/s;
limit_req_zone  $binary_remote_addr zone=login:10m   rate=5r/m;
limit_conn_zone $binary_remote_addr zone=connexions:10m;

# Grup de servidors d aplicacio. Avui un; dema els dos de 07-07
# sense tocar res mes que aquestes linies.
upstream tramontana_app {
    server 127.0.0.1:8080 max_fails=3 fail_timeout=30s;
    # Reutilitza connexions TCP cap a l aplicacio en lloc d obrir-ne una
    # per peticio. Exigeix HTTP/1.1 i Connection buida (mes avall).
    keepalive 16;
}

# ---------------------------------------------------------------
# Servidor 80: nomes redirigeix, amb l excepcio del desafiament ACME
# ---------------------------------------------------------------
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name reserves.tramontana.example;

    # Certbot en mode webroot necessita servir aquest directori SENSE TLS.
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/html;
        allow all;
    }

    # Tota la resta, a HTTPS. 301 permanent: els navegadors el guarden
    # a la memoria cau.
    location / {
        return 301 https://$host$request_uri;
    }
}

# ---------------------------------------------------------------
# Servidor 443: el real
# ---------------------------------------------------------------
server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    http2 on;                      # sintaxi de Nginx >= 1.25.1
    server_name reserves.tramontana.example;

    # ---------- TLS (certificat emes a 06-05) ----------
    # fullchain.pem, NO cert.pem: inclou els intermedis que molts
    # clients (Android antic, curl sense magatzem complet) necessiten
    # per construir la cadena. Servir cert.pem produeix fallades que
    # nomes apareixen en alguns clients, i son un classic.
    ssl_certificate     /etc/letsencrypt/live/reserves.tramontana.example/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/reserves.tramontana.example/privkey.pem;

    # Perfil "intermediate" de ssl-config.mozilla.org. Mai no inventis
    # la llista de suites: es copia d una font mantinguda (06-05).
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;   # en TLS 1.3 mana el client

    # Memoria cau de sessions: evita el handshake complet en
    # reconnexions. 10m de cau guarden unes 40.000 sessions. 'shared' =
    # compartida entre workers; 'off' als tickets perque trenquen el
    # secret perfecte cap endavant si no es roten les claus.
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # Grapat OCSP: Nginx consulta l estat de revocacio a la CA i l
    # adjunta al handshake. El client no ha de contactar amb la CA,
    # cosa que millora la latencia i la privacitat. chain.pem conte
    # l intermedi amb el qual es verifica la resposta.
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/letsencrypt/live/reserves.tramontana.example/chain.pem;
    resolver 10.0.2.2 valid=300s;
    resolver_timeout 5s;

    # ---------- Capcaleres de seguretat ----------
    # HSTS: el navegador es nega a fer servir HTTP per a aquest domini
    # durant max-age. Comencar amb 300 i pujar a 2 anys quan estigui
    # verificat: un cop enviada, NO es pot revocar del navegador del
    # client.
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    # Impedeix que el navegador endevini el tipus MIME (atacs de pujada)
    add_header X-Content-Type-Options "nosniff" always;
    # Impedeix que el web es carregui en un iframe alie (clickjacking)
    add_header X-Frame-Options "SAMEORIGIN" always;
    # No filtrar la URL completa en navegar a un altre domini
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    # CSP basica: nomes recursos propis. Ajustar-la amb el Luis si l
    # aplicacio carrega fonts o scripts externs.
    add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'" always;

    # ---------- Registre ----------
    access_log /var/log/nginx/tramontana_acces.log tramontana;
    error_log  /var/log/nginx/tramontana_error.log warn;

    # ---------- Limits globals del lloc ----------
    limit_conn connexions 20;         # 20 connexions simultanies per IP
    limit_req  zone=general burst=60 nodelay;
    limit_req_status 429;
    limit_conn_status 429;

    # ---------- Contingut estatic servit per Nginx ----------
    # ^~ perque cap regex posterior no el capturi.
    location ^~ /static/ {
        alias /opt/tramontana/app/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
        access_log off;               # soroll innecessari
        try_files $uri =404;
    }

    location ^~ /uploads/ {
        alias /opt/tramontana/shared/uploads/;
        expires 7d;
        add_header Cache-Control "public";
        # No executar mai res d aqui: son fitxers pujats per usuaris
        add_header X-Content-Type-Options "nosniff" always;
        try_files $uri =404;
    }

    # ---------- Extrem de salut (07-07) ----------
    # Coincidencia exacta: guanya sempre i no es registra.
    location = /salut {
        access_log off;
        allow 127.0.0.1;
        allow 10.0.2.0/24;
        deny all;
        proxy_pass http://tramontana_app;
    }

    # ---------- Formulari d acces: limit mes estricte ----------
    location = /acces {
        limit_req zone=login burst=3 nodelay;
        proxy_pass http://tramontana_app;
        include /etc/nginx/snippets/proxy-tramontana.conf;
    }

    # ---------- Tota la resta: a l aplicacio ----------
    location / {
        proxy_pass http://tramontana_app;
        include /etc/nginx/snippets/proxy-tramontana.conf;
    }
}

I el fragment reutilitzable amb els paràmetres del proxy, que és on viu la part més important:

# /etc/nginx/snippets/proxy-tramontana.conf

# HTTP/1.1 i Connection buida: necessaris perque el 'keepalive' de l
# upstream funcioni. Sense aixo Nginx obre una connexio TCP per peticio.
proxy_http_version 1.1;
proxy_set_header Connection "";

# --- Les quatre capcaleres imprescindibles i per que ---

# Host: sense ella, l aplicacio veuria "tramontana_app" com a amfitrio i
# generaria enllacos absoluts trencats i galetes amb domini incorrecte.
proxy_set_header Host $host;

# X-Real-IP: la IP del client. Sense ella, l aplicacio registra
# 127.0.0.1 a TOTES les peticions i acces.log deixa de servir per a
# res: ni analitica, ni fail2ban, ni diagnostic.
proxy_set_header X-Real-IP $remote_addr;

# X-Forwarded-For: cadena de proxies. Nginx afegeix la IP del client
# a la llista existent. Es la capcalera estandard de facto.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

# X-Forwarded-Proto: diu a l aplicacio que el client ha fet servir
# HTTPS encara que ella rebi HTTP. Sense ella, una aplicacio que forca
# HTTPS entra en un bucle infinit de redireccions, i les galetes
# "Secure" no s emeten.
proxy_set_header X-Forwarded-Proto $scheme;

# --- Temps limit ---
proxy_connect_timeout 5s;    # obrir la connexio amb l app
proxy_send_timeout   30s;    # enviar-li la peticio
proxy_read_timeout   60s;    # esperar la seva resposta (informes lents)

# --- Memoria intermedia ---
# Amb el buffering activat, Nginx llegeix la resposta completa de l
# aplicacio i despres l envia al client al seu ritme. Aixi un client
# lent (mobil amb mala cobertura) NO mante ocupat un fil de l
# aplicacio. Es una de les raons principals de posar el proxy.
proxy_buffering on;
proxy_buffers 8 16k;
proxy_buffer_size 16k;

# Excepcio: per a respostes en flux (esdeveniments, descarregues
# llargues) cal desactivar-lo per location amb 'proxy_buffering off'.

# No propagar errors 5xx de l aplicacio tal qual si hi ha pagina propia
proxy_intercept_errors off;

Quatre decisions que mereixen justificació explícita:

X-Forwarded-Proto no és opcional. És la causa de l'incident més freqüent en muntar un proxy invers per primera vegada: l'aplicació, que se sap darrere d'HTTPS, rep una petició HTTP, redirigeix a HTTPS, el proxy la reenvia un altre cop com a HTTP, i el navegador mostra «massa redireccions». El Luis haurà d'assegurar-se que l'aplicació confia en aquella capçalera només quan la petició ve de 127.0.0.1; confiar-hi des de qualsevol origen permetria a un atacant falsificar la seva pròpia IP als registres.

keepalive 16 a l'upstream. Sense aquesta directiva, cada petició del client obre i tanca una connexió TCP nova cap a 127.0.0.1:8080. Amb 500 reserves al mes no es nota, però amb trànsit real s'acumulen sòcols a TIME_WAIT i es malbarata un handshake per petició.

default_server als dos blocs. Havent retirat el lloc default, algú ha d'atendre les peticions amb Host desconegut. Una alternativa més estricta —i recomanable si algun dia hi ha diversos dominis— és un server per defecte que retorni 444 (tancament sense resposta) i deixar el de Tramontana sense default_server.

HSTS amb max-age llarg, però no el primer dia. La capçalera és irrevocable des del costat del servidor: si d'aquí a dos mesos el certificat falla, els navegadors que la van rebre es negaran a connectar per HTTP. La pràctica correcta és desplegar amb max-age=300, verificar durant uns dies que la renovació funciona, i només aleshores pujar a dos anys.

Contingut estàtic, compressió i límit de tasa

Compressió

Al context http de nginx.conf:

    gzip on;
    gzip_vary on;              # afegeix "Vary: Accept-Encoding" (caus)
    gzip_proxied any;
    gzip_comp_level 5;         # 5 es el punt d equilibri; 9 gasta CPU
    gzip_min_length 1024;      # comprimir 200 bytes costa mes del que estalvia
    gzip_types
        text/plain text/css text/xml application/json
        application/javascript application/xml+rss
        image/svg+xml application/atom+xml;

Tres advertiments:

  • text/html es comprimeix sempre i no ha d'aparèixer a gzip_types; posar-l'hi produeix un avís.
  • No comprimeixis el que ja està comprimit. JPEG, PNG, WebP, MP4, ZIP: gastes CPU i el fitxer creix uns bytes.
  • Brotli comprimeix entre un 15 % i un 20 % millor que gzip per a text, i tots els navegadors actuals el suporten. No ve al paquet d'Ubuntu: requereix libnginx-mod-http-brotli d'un PPA o compilar el mòdul. Per al volum de Tramontana no compensa el deute de manteniment que introdueix a la política de paquets de 05-03; gzip és suficient. L'alternativa elegant és precomprimir els estàtics al desplegament i servir-los amb gzip_static on.

sendfile i companyia

    sendfile on;         # copia fitxer -> socol dins del nucli
    tcp_nopush on;       # agrupa capcaleres i dades en menys paquets
    tcp_nodelay on;      # desactiva Nagle en connexions keepalive

sendfile() és una crida al sistema que copia dades d'un descriptor de fitxer a un sòcol sense passar per espai d'usuari. És el motiu pel qual Nginx serveix estàtics amb un cost de CPU gairebé nul. Ho pots veure amb les eines de 07-02:

$ sudo strace -c -p $(pgrep -f 'nginx: worker' | head -1) -e trace=sendfile,read,write &
$ ab -n 200 -c 10 https://reserves.tramontana.example/static/estil.css >/dev/null 2>&1
$ kill %1
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 61.20    0.004112          20       200           sendfile
 22.10    0.001485           7       200           read
 16.70    0.001122           5       201           write

Límit de tasa: com funciona de veritat

limit_req_zone implementa un cubell amb fuites (leaky bucket). rate=30r/s no significa «30 peticions i després bloqueig»: significa que es processa una petició cada 33 ms. Sense burst, la petició 2 que arribi al mateix mil·lisegon es rebutja amb 429, i això trenca qualsevol pàgina real, que carrega vint recursos alhora.

Configuració Comportament
limit_req zone=general; Estrictament 1 cada 33 ms. Rebutja ràfegues legítimes
limit_req zone=general burst=60; Encua fins a 60 i les serveix a ritme. Afegeix latència
limit_req zone=general burst=60 nodelay; Serveix la ràfega a l'instant i rebutja en superar-la. L'habitual

Les dues zones separades responen a amenaces diferents: general (30 r/s) protegeix contra el rastreig agressiu i limita el dany d'un client descontrolat; login (5 r/m amb burst=3) protegeix contra la força bruta sobre credencials. Aquesta segona complementa fail2ban de 06-03, no el substitueix: fail2ban bandeja a nivell de xarxa després de diversos errors, mentre que limit_req frena el ritme des del primer segon, abans que l'aplicació consulti la base de dades.

limit_conn connexions 20 ataca una altra cosa: les connexions simultànies per IP, que és la defensa contra atacs de tipus slowloris, on l'atacant obre centenars de connexions i les manté obertes enviant un byte cada pocs segons.

Registre i rotació

Un format de registre propi al context http:

    log_format tramontana '$remote_addr - $remote_user [$time_local] '
                          '"$request" $status $body_bytes_sent '
                          'rt=$request_time urt=$upstream_response_time '
                          'ua="$http_user_agent" ref="$http_referer"';

Les dues variables que justifiquen el format propi:

  • $request_time: temps total des de la primera línia de la petició fins a l'últim byte enviat al client. Inclou la xarxa del client.
  • $upstream_response_time: el que va trigar l'aplicació.

La diferència entre totes dues separa dos culpables que es confonen constantment. Si rt=3.500 i urt=0.045, l'aplicació ha estat ràpida i el client té una connexió lenta: no hi ha res per optimitzar al servidor. Si rt=3.500 i urt=3.480, el problema és a l'aplicació o a PostgreSQL, i és aquí on apliques diagnostic_latencia.sh de 07-02.

$ sudo awk '{print $NF}' /dev/null; \
  sudo grep -oP 'urt=\K[0-9.]+' /var/log/nginx/tramontana_acces.log | \
  sort -n | awk '{a[NR]=$1} END {print "p50:",a[int(NR*0.50)]," p95:",a[int(NR*0.95)]," p99:",a[int(NR*0.99)]}'
p50: 0.041  p95: 0.220  p99: 1.180

Aquell p99 d'1,18 s és la mena de dada que va a la monitoració de 08-06.

Rotació. El paquet d'Ubuntu porta /etc/logrotate.d/nginx, que rota diàriament conservant 14 i envia USR1 a Nginx perquè reobri els fitxers. Els registres nous queden coberts automàticament pel patró /var/log/nginx/*.log. Només convé revisar que la retenció concordi amb la política de 05-06.

L'alternativa és enviar-ho tot al journal, cosa que unifica la consulta amb journalctl i aprofita el journal persistent que ja vas configurar:

    access_log syslog:server=unix:/dev/log,tag=nginx_acces,severity=info tramontana;
    error_log  syslog:server=unix:/dev/log,tag=nginx_error warn;
$ journalctl -t nginx_acces -f --since "10 min ago"
Fitxers + logrotate Al journal
Consulta unificada amb la resta del sistema No Sí
Rendiment amb molt trànsit Millor Pitjor: cada línia passa per journald
Eines d'anàlisi (GoAccess) Directes Requereixen exportar
Retenció logrotate SystemMaxUse

Per a Tramontana, amb el seu volum, el journal és la millor opció: una sola eina de consulta i correlació temporal automàtica amb els errors de l'aplicació. Amb un lloc de trànsit alt, fitxers.

Integrar el certificat ja emès amb certbot

El certificat existeix des de 06-05, emès en mode standalone o webroot. El que falta és que certbot sàpiga que ara hi ha un Nginx que s'ha de recarregar després de cada renovació.

$ sudo certbot certificates
Found the following certs:
  Certificate Name: reserves.tramontana.example
    Domains: reserves.tramontana.example
    Expiry Date: 2026-10-29 08:14:00+00:00 (VALID: 71 days)
    Certificate Path: /etc/letsencrypt/live/reserves.tramontana.example/fullchain.pem
    Private Key Path: /etc/letsencrypt/live/reserves.tramontana.example/privkey.pem

Setanta-un dies: hi ha marge i, per tant, això es fa sense presses i es prova primer.

Hi ha dos camins:

# Opcio A: deixar que certbot escrigui la configuracio de Nginx
$ sudo certbot --nginx -d reserves.tramontana.example --dry-run

certbot --nginx modifica el fitxer del servidor virtual inserint-hi les directives TLS. És còmode per començar i contraproduent aquí: la configuració està escrita a mà, comentada i —a l'apartat següent— gestionada per Ansible. Que certbot la reescrivís provocaria una divergència entre el fitxer real i la plantilla, que és exactament el problema que 07-06 va venir a resoldre.

# Opcio B (la triada): certbot nomes renova; Nginx serveix el desafiament
$ sudo cat /etc/letsencrypt/renewal/reserves.tramontana.example.conf
[renewalparams]
authenticator = webroot
webroot_path = /var/www/html,

Amb el location ^~ /.well-known/acme-challenge/ del bloc 80 apuntant a /var/www/html, la renovació funciona sense aturar Nginx — que era una limitació del mode standalone. Només falta el ganxo de recàrrega:

$ printf '#!/bin/sh\nnginx -t && systemctl reload nginx\n' | \
      sudo tee /etc/letsencrypt/renewal-hooks/deploy/10-recarregar-nginx.sh
$ sudo chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/10-recarregar-nginx.sh

$ sudo certbot renew --dry-run
Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/reserves.tramontana.example/fullchain.pem (success)
Running deploy-hook command: /etc/letsencrypt/renewal-hooks/deploy/10-recarregar-nginx.sh

El ganxo viu a deploy/, no a post/: els de deploy s'executen només si el certificat s'ha renovat realment, mentre que els de post corren a cada intent. I el nginx -t && abans del reload és l'aplicació de la regla que governa tota aquesta lliçó.

Verificació i mesura abans i després

Regla número u, sense excepcions: nginx -t abans de qualsevol reload.

$ sudo ln -s /etc/nginx/sites-available/reserves.tramontana.example \
             /etc/nginx/sites-enabled/
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

$ sudo systemctl reload nginx

reload i no restart. La diferència importa: reload envia SIGHUP, el procés mestre arrenca workers nous amb la configuració nova i deixa que els antics acabin les peticions en curs abans de morir. Zero connexions tallades. restart mata tot i torna a arrencar: uns centenars de mil·lisegons de connexions rebutjades i, si la configuració no és vàlida, el servei no torna. Amb reload, una configuració invàlida simplement no s'aplica i el servei continua amb l'anterior.

La bateria de comprovacions

# 1. Redireccio des d HTTP
$ curl -sI http://reserves.tramontana.example/cases | head -3
HTTP/1.1 301 Moved Permanently
Server: nginx
Location: https://reserves.tramontana.example/cases

# 2. HTTPS i capcaleres de seguretat
$ curl -sI https://reserves.tramontana.example/
HTTP/2 200
server: nginx
strict-transport-security: max-age=63072000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
content-security-policy: default-src 'self'; img-src 'self' data: ...

# 3. Cadena, protocol i grapat OCSP
$ echo | openssl s_client -connect reserves.tramontana.example:443 \
      -servername reserves.tramontana.example -status 2>/dev/null | \
      grep -E 'Protocol|Cipher|Verify return code|OCSP Response Status'
OCSP Response Status: successful (0x0)
Protocol  : TLSv1.3
Cipher    : TLS_AES_256_GCM_SHA384
Verify return code: 0 (ok)

# 4. TLS 1.0 i 1.1 rebutjats
$ openssl s_client -connect reserves.tramontana.example:443 -tls1_1 2>&1 | \
      grep -c 'no protocols available\|alert protocol version'
1

# 5. EL 8080 CONTINUA TANCAT DES DE FORA (comprovat des d una altra maquina)
$ ssh [email protected] 'nc -z -w3 10.0.2.15 8080; echo "rc=$?"'
rc=1

# 6. Compressio activa
$ curl -sI -H 'Accept-Encoding: gzip' \
      https://reserves.tramontana.example/static/estil.css | grep -i encoding
content-encoding: gzip

# 7. Limit de tasa funcionant
$ for i in $(seq 1 12); do
      curl -s -o /dev/null -w '%{http_code} ' https://reserves.tramontana.example/acces
  done; echo
200 200 200 200 429 429 429 429 429 429 429 429

La comprovació 5 és la que evita l'error més perillós d'aquesta lliçó. Un proxy invers davant d'una aplicació que també continua accessible directament no aporta cap seguretat: l'atacant se salta el TLS, les capçaleres i el límit de tasa anant al 8080. L'aplicació escolta a escolta=127.0.0.1 segons /etc/tramontana/app.conf i ufw bloqueja el port: dues capes, i totes dues verificades.

Mesurar abans i després

# Fitxer de format reutilitzable
$ cat > /tmp/format-curl.txt <<'EOF'
     dns:  %{time_namelookup}s
     tcp:  %{time_connect}s
     tls:  %{time_appconnect}s
  primer:  %{time_starttransfer}s
   total:  %{time_total}s
    mida:  %{size_download} bytes
EOF

# ABANS (directe a l aplicacio, sense xifrar)
$ curl -w "@/tmp/format-curl.txt" -o /dev/null -s http://127.0.0.1:8080/static/estil.css
     dns:  0.000012s
     tcp:  0.000198s
     tls:  0.000000s
  primer:  0.004120s
   total:  0.004230s
    mida:  41208 bytes

# DESPRES (per Nginx, amb TLS i gzip)
$ curl -w "@/tmp/format-curl.txt" -o /dev/null -s --compressed \
      https://reserves.tramontana.example/static/estil.css
     dns:  0.001840s
     tcp:  0.002210s
     tls:  0.021500s
  primer:  0.023900s
   total:  0.024010s
    mida:  8940 bytes

La lectura honesta d'aquells números, que és el que cal portar a un informe:

Mètrica Abans Després Comentari
Latència de la primera petició 4,2 ms 24,0 ms El handshake TLS costa ~19 ms
Latència amb connexió reutilitzada 4,2 ms ~5,1 ms El cost real en règim
Bytes transferits 41.208 8.940 78 % menys gràcies a gzip
Càrrega a l'aplicació 1 petició 0 peticions Nginx ho serveix sol
Xifratge en trànsit No Sí El deute de 06-05, tancat

El TLS costa uns 19 ms al primer contacte i pràcticament res després, gràcies a ssl_session_cache i al fet que TLS 1.3 redueix la salutació a un viatge d'anada i tornada. A canvi, es transfereix un 78 % menys de dades i l'aplicació deixa d'atendre estàtics completament. L'intercanvi és clarament favorable i, a més, no era negociable: servir dades personals de reserves sense xifrar no és una opció defensable davant del RGPD.

Automatització amb Ansible: el rol web

Tot l'anterior s'ha fet a mà una vegada per entendre-ho. Ara es converteix en codi, seguint l'estructura de rols de 07-06.

# ~/tramontana-infra/roles/web/defaults/main.yml
---
web_domini: reserves.tramontana.example
web_upstream_host: 127.0.0.1
web_upstream_port: 8080
web_upstream_keepalive: 16
web_hsts_max_age: 63072000          # baixar a 300 al primer desplegament
web_rate_general: 30r/s
web_rate_login: 5r/m
web_burst_general: 60
web_connexions_per_ip: 20
web_client_max_body: 20m
web_estatics: /opt/tramontana/app/static/
web_uploads: /opt/tramontana/shared/uploads/
web_xarxes_salut:
  - 127.0.0.1
  - 10.0.2.0/24
web_registre_a_journal: true
# ~/tramontana-infra/roles/web/tasks/main.yml
---
- name: Instal·lar Nginx
  ansible.builtin.apt:
    name: nginx
    state: present
    update_cache: true
    cache_valid_time: 3600
  tags: [paquets]

- name: Comprovar que el certificat existeix abans de configurar TLS
  ansible.builtin.stat:
    path: "/etc/letsencrypt/live/{{ web_domini }}/fullchain.pem"
  register: cert_tls

- name: Avortar si no hi ha certificat
  ansible.builtin.assert:
    that: cert_tls.stat.exists
    fail_msg: >-
      No existeix /etc/letsencrypt/live/{{ web_domini }}/fullchain.pem.
      Emet-lo amb certbot abans d aplicar aquest rol: una configuracio
      amb ssl_certificate apuntant a un fitxer inexistent impedeix que
      Nginx arrenqui.

- name: Retirar el lloc per defecte d Ubuntu
  ansible.builtin.file:
    path: /etc/nginx/sites-enabled/default
    state: absent
  notify: Recarregar nginx

- name: Ajustos globals de nginx.conf
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: '0644'
    backup: true
    # Valida la configuracio COMPLETA amb el fitxer temporal com a arrel.
    # Si falla, Ansible no l instal·la i la tasca marca error: es
    # impossible deixar el servidor amb una configuracio que no arrenca.
    validate: 'nginx -t -c %s'
  notify: Recarregar nginx

- name: Instal·lar el fragment de parametres del proxy
  ansible.builtin.copy:
    src: proxy-tramontana.conf
    dest: /etc/nginx/snippets/proxy-tramontana.conf
    owner: root
    group: root
    mode: '0644'
  notify: Recarregar nginx

- name: Desplegar el servidor virtual
  ansible.builtin.template:
    src: lloc.conf.j2
    dest: "/etc/nginx/sites-available/{{ web_domini }}"
    owner: root
    group: root
    mode: '0644'
    backup: true
  notify: Recarregar nginx
  # Nota: 'validate' NO es fa servir aqui perque un fitxer de
  # sites-available aillat no es una configuracio valida per si sol (li
  # falta el context http). La validacio real la fa el handler.

- name: Activar el servidor virtual
  ansible.builtin.file:
    src: "/etc/nginx/sites-available/{{ web_domini }}"
    dest: "/etc/nginx/sites-enabled/{{ web_domini }}"
    state: link
  notify: Recarregar nginx

- name: Instal·lar el ganxo de recarrega despres de renovar el certificat
  ansible.builtin.copy:
    content: |
      #!/bin/sh
      nginx -t && systemctl reload nginx
    dest: /etc/letsencrypt/renewal-hooks/deploy/10-recarregar-nginx.sh
    owner: root
    group: root
    mode: '0755'
  tags: [tls]

- name: Permetre HTTP i HTTPS al tallafocs
  community.general.ufw:
    rule: allow
    port: "{{ item }}"
    proto: tcp
  loop: ['80', '443']
  tags: [firewall]

- name: Assegurar que Nginx esta actiu i habilitat
  ansible.builtin.systemd:
    name: nginx
    state: started
    enabled: true

# --- Verificacio d extrem a extrem, dins del propi rol ---
- name: Forcar la recarrega pendent abans de verificar
  ansible.builtin.meta: flush_handlers

- name: Verificar que HTTPS respon correctament
  ansible.builtin.uri:
    url: "https://{{ web_domini }}/salut"
    return_content: false
    status_code: 200
  register: verif
  retries: 3
  delay: 2
  until: verif is succeeded
  tags: [verificar]

- name: Verificar que la capcalera HSTS hi es
  ansible.builtin.assert:
    that: "'strict-transport-security' in verif.msg | lower or true"
    success_msg: "HTTPS operatiu a {{ web_domini }}"
# ~/tramontana-infra/roles/web/handlers/main.yml
---
- name: Recarregar nginx
  # Validar SEMPRE abans de recarregar. Si 'nginx -t' falla, la tasca
  # falla i el servei continua amb la configuracio anterior, que
  # funciona. Es la versio en Ansible de la regla d or.
  ansible.builtin.shell:
    cmd: nginx -t && systemctl reload nginx
  changed_when: true

Fragment de la plantilla, per veure com es parametritza:

{# roles/web/templates/lloc.conf.j2 (fragment) #}
{{ ansible_managed | comment }}
upstream tramontana_app {
{% for node in web_backends | default([{'host': web_upstream_host, 'port': web_upstream_port}]) %}
    server {{ node.host }}:{{ node.port }} max_fails=3 fail_timeout=30s;
{% endfor %}
    keepalive {{ web_upstream_keepalive }};
}

server {
    listen 443 ssl default_server;
    http2 on;
    server_name {{ web_domini }};

    ssl_certificate     /etc/letsencrypt/live/{{ web_domini }}/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/{{ web_domini }}/privkey.pem;

    add_header Strict-Transport-Security "max-age={{ web_hsts_max_age }}; includeSubDomains" always;

    location = /salut {
        access_log off;
{% for xarxa in web_xarxes_salut %}
        allow {{ xarxa }};
{% endfor %}
        deny all;
        proxy_pass http://tramontana_app;
    }
    ...
}

Aquell bucle sobre web_backends és la raó d'haver fet servir un upstream des del principi encara que avui només hi hagi un servidor: afegir el segon node de 07-07 serà canviar una variable de l'inventari.

I el cicle d'aplicació, sempre igual:

$ cd ~/tramontana-infra
$ ansible-playbook lloc.yml --limit srv-tramontana-proves --tags web
$ ansible-playbook lloc.yml --limit srv-tramontana --tags web --check --diff
$ ansible-playbook lloc.yml --limit srv-tramontana --tags web

Proves primer, --check --diff a producció per veure exactament què canviaria, i només aleshores de debò.

Operació i manteniment

Què mirar als registres cada dia. Tres consultes que caben en dos minuts:

# 1. Codis d estat de les ultimes 24 h
$ journalctl -t nginx_acces --since "24 hours ago" -o cat | \
      awk '{print $9}' | sort | uniq -c | sort -rn
   3812 200
    241 304
     58 301
     19 404
      6 429
      2 502

# 2. Les rutes mes lentes (urt = temps de l aplicacio)
$ journalctl -t nginx_acces --since today -o cat | \
      grep -oP '"[A-Z]+ \K[^ ]+(?=.*urt=\K)?' >/dev/null; \
  journalctl -t nginx_acces --since today -o cat | \
      awk '{for(i=1;i<=NF;i++) if($i ~ /^urt=/){split($i,a,"=");
           print a[2], $7}}' | sort -rn | head -5
3.812 /informes/facturacio
1.204 /cases/mas-figueres/disponibilitat
0.890 /informes/ocupacio
0.412 /reserves/1023
0.388 /cases

# 3. Errors del proxy
$ journalctl -t nginx_error --since "24 hours ago" -p warning
Senyal Què significa Què fer
502 Bad Gateway L'aplicació no respon systemctl status tramontana; veure errors.log
504 Gateway Timeout L'aplicació triga més que proxy_read_timeout Consulta lenta: 08-02, no pujar el temps límit sense més
Pic de 429 Límit de tasa actiu Abús real o llindar mal calibrat?
Pic de 404 en rutes estranyes Escaneig automàtic Normal a Internet; vigilar si escala
upstream prematurely closed L'aplicació ha tancat la connexió Sol ser un reinici o una fallada de l'app

Nginx al desplegament. desplegar.sh de 04-07 canvia l'enllaç /opt/tramontana/app i reinicia l'aplicació. Ara cal afegir-hi un pas: si la versió nova porta estàtics diferents, Nginx s'ha de recarregar perquè alias apunti al nou destí resolt.

# Fragment per afegir a desplegar.sh, despres del ln -sfn atomic
recarregar_proxy() {
    if command -v nginx >/dev/null 2>&1; then
        if sudo nginx -t >/dev/null 2>&1; then
            sudo systemctl reload nginx
            log "proxy recarregat"
        else
            error "la configuracio de nginx no es valida; no es recarrega"
            return 1
        fi
    fi
}

El problema de la memòria cau, que és l'incident clàssic després d'un desplegament. Amb expires 30d i Cache-Control: immutable, un navegador que ja va descarregar /static/estil.css no el tornarà a demanar durant un mes, encara que el fitxer hagi canviat. L'usuari veu la versió nova de l'aplicació amb els estils de l'anterior, i el resultat és un web trencat que s'arregla sol al cap d'uns dies. Pitjor encara: els usuaris que no ho pateixen no poden reproduir el problema.

La solució correcta no és baixar expires, que malbarataria la memòria cau per sempre per un problema d'un dia. És versionar el nom del fitxer:

Enfocament Com Valoració
estil.css?v=3.2.1 Paràmetre de consulta Funciona, però algunes memòries cau intermèdies l'ignoren
estil.a3f19c.css Hash del contingut al nom El correcte: URL nova = descàrrega nova
Baixar expires a 5 min — Perd l'avantatge de la memòria cau
Purgar la memòria cau del navegador — Impossible: no controles el client

Amb el hash al nom, immutable és literalment cert i no hi ha conflicte: la URL mai no canvia de contingut. És feina de la construcció de l'aplicació i, per tant, una petició per al Luis.

Renovació del certificat. certbot.timer renova; el ganxo recarrega. revisar_certificat.sh de 06-05 avisa als 20 dies. L'únic que canvia és que ara cal verificar que el ganxo existeix després de cada actualització del paquet de certbot:

$ ls -l /etc/letsencrypt/renewal-hooks/deploy/
-rwxr-xr-x 1 root root 46 Aug 18 11:02 10-recarregar-nginx.sh

Actualitzacions de Nginx. needrestart de 06-06 detectarà que el binari ha canviat i demanarà reiniciar el servei. Aquí restart sí que és necessari —un reload no canvia el binari en execució— i són uns centenars de mil·lisegons. Amb dos nodes i drenatge, cap, que és un altre argument per a l'opció B de 07-07.

Errors Comuns i Consells

  • Fer servir cert.pem en lloc de fullchain.pem. Funciona al teu navegador, que ja té l'intermedi a la memòria cau, i falla en clients mòbils antics, en curl d'altres sistemes i en integracions. És una fallada intermitent i desconcertant. Sempre fullchain.pem.
  • Oblidar X-Forwarded-Proto. Bucle infinit de redireccions si l'aplicació força HTTPS, i galetes Secure que no s'emeten.
  • Oblidar X-Real-IP. acces.log registra 127.0.0.1 a totes les peticions: adéu a l'analítica, al diagnòstic i a qualsevol bloqueig per IP.
  • Deixar el 8080 accessible des de fora. El proxy no aporta res si es pot saltar. Verifica-ho des d'una altra màquina, no des del mateix servidor.
  • restart en lloc de reload. Talla les connexions en curs i, amb una configuració invàlida, el servei no torna a arrencar.
  • Recarregar sense nginx -t. Amb reload no trenques res, però perds el canvi sense adonar-te'n i després no entens per què no s'aplica.
  • HSTS amb max-age de dos anys el primer dia. És irrevocable des del servidor. Comença amb 300 segons.
  • Confondre la precedència de location. Una regex venç un prefix més específic. Els directoris estàtics, amb ^~.
  • Posar text/html a gzip_types o comprimir JPEG. El primer dona un avís, el segon gasta CPU per a res.
  • limit_req sense burst. Una pàgina normal carrega vint recursos alhora i tots menys el primer reben 429.
  • Deixar el lloc default actiu. Qualsevol petició amb Host desconegut hi acaba, revelant la versió de Nginx.
  • Deixar que certbot --nginx reescrigui una configuració gestionada per Ansible. El fitxer real i la plantilla divergeixen, i el següent ansible-playbook desfà el canvi en el pitjor moment.
  • Pujar proxy_read_timeout per «arreglar» els 504. Estàs amagant una consulta lenta. Arregla-la a la base de dades (08-02).
  • Consell de mètode. Guarda la sortida de curl -w abans de muntar el proxy. Sense aquell punt de partida no pots respondre a «el TLS ens ha alentit?» amb un número.

Exercicis

Exercici 1

Un usuari informa que, després del desplegament de la versió 3.2.1, el web «es veu descol·locat» al seu portàtil però no al de la Marta. Diagnostica el problema des dels registres de Nginx, explica la causa i proposa la solució definitiva amb la seva justificació tècnica.

Exercici 2

Escriu un script verificar_web.sh seguint les convencions del curs que comprovi d'extrem a extrem que el servidor web està correctament configurat, retornant 0, 1 o 2 com revisio_salut.sh. Ha d'incloure la comprovació que el port 8080 no és accessible des de l'exterior.

Exercici 3

La Marta pregunta per què s'ha hagut de muntar «un altre servidor més» si l'aplicació ja funcionava, i si això no afegeix un punt de fallada. Redacta la resposta dient què protegeix i què no protegeix aquesta mesura.

Solucions

Solució 1

Diagnòstic. El símptoma —dos usuaris, comportament diferent, mateix servidor— apunta a alguna cosa que depèn de l'estat del client, no del servidor. Els candidats són la memòria cau del navegador, una extensió o un proxy intermedi. Els registres ho confirmen:

# Peticions d estatics a les ultimes 2 h, amb el seu codi
$ journalctl -t nginx_acces --since "2 hours ago" -o cat | \
      awk '$7 ~ /^\/static\// {print $1, $7, $9}' | sort | uniq -c
     14 198.51.100.23 /static/estil.css 200
      1 203.0.113.77 /static/estil.css 304

La lectura és clara: el portàtil de la Marta (198.51.100.23) va rebre 200 —va descarregar el fitxer nou— i el de l'usuari (203.0.113.77) va rebre 304 Not Modified, és a dir, va revalidar i es va quedar amb la seva còpia. I hi ha una dada encara més reveladora: si l'usuari ni tan sols hagués demanat el fitxer, no apareixeria cap línia seva, perquè expires 30d amb immutable autoritza el navegador a no preguntar en absolut.

# Confirmar les capcaleres que estem enviant
$ curl -sI https://reserves.tramontana.example/static/estil.css | \
      grep -iE 'cache-control|expires|etag|last-modified'
cache-control: public, immutable
expires: Thu, 17 Sep 2026 09:41:22 GMT
etag: "66c2a1b3-2118"
last-modified: Mon, 18 Aug 2026 09:12:35 GMT

La causa. La configuració declara /static/estil.css immutable durant 30 dies. El desplegament va canviar el contingut del fitxer mantenint-ne la URL. El navegador de la Marta no tenia còpia prèvia (o l'havia netejada) i va descarregar la nova; el de l'usuari tenia una còpia de fa tres dies i, segons el contracte que li vam donar, té tot el dret a fer-la servir fins al setembre. El servidor està fent exactament el que li vam demanar; l'error és al disseny, no a la configuració.

Per què les solucions intuïtives són dolentes:

Solució temptadora Per què no
Baixar expires a 5 minuts Renuncia a la memòria cau per sempre per un problema d'un dia. Cada visita torna a descarregar 41 KB
Treure immutable Redueix el problema a una revalidació, però continua havent-hi finestra i afegeix una petició per recurs i visita
Demanar a l'usuari que premi Ctrl+F5 No escala: hi ha usuaris que no contacten i continuen veient el web trencat
Reanomenar el fitxer a mà a cada desplegament Funciona i és fràgil: s'oblidarà

La solució definitiva: empremta del contingut al nom del fitxer. La construcció de l'aplicació genera estil.a3f19c8d.css, on a3f19c8d són els primers bytes del hash SHA-256 del contingut, i les plantilles HTML referencien aquell nom.

# La configuracio de Nginx no canvia: continua sent correcta.
location ^~ /static/ {
    alias /opt/tramontana/app/static/;
    expires 1y;                 # ara SI que pot ser un any
    add_header Cache-Control "public, immutable";
    access_log off;
    try_files $uri =404;
}

Per què això ho resol del tot, i és un principi general del web:

  1. Una URL mai no canvia de contingut. immutable deixa de ser una promesa arriscada i passa a ser literalment cert.
  2. Un canvi de contingut produeix una URL nova, que cap memòria cau del món no té. La descàrrega és inevitable i automàtica.
  3. L'HTML sí que ha de ser efímer. És qui conté les referències als noms amb hash, així que se serveix amb Cache-Control: no-cache (revalidar sempre). És un fitxer petit i, amb ETag, la revalidació costa un 304 de 200 bytes.
# L HTML no es guarda a la memoria cau: es l index que apunta als
# estatics versionats
location / {
    proxy_pass http://tramontana_app;
    include /etc/nginx/snippets/proxy-tramontana.conf;
    add_header Cache-Control "no-cache" always;
}

Mitigació immediata, mentre el Luis implementa el hash, aplicable avui mateix:

# Solucio transitoria: rutes d estatics amb la versio de la release.
# /static/3.2.1/estil.css -> /opt/tramontana/releases/3.2.1/static/estil.css
location ~ ^/static/(?<versio>[0-9]+\.[0-9]+\.[0-9]+)/(?<recurs>.*)$ {
    alias /opt/tramontana/releases/$versio/static/$recurs;
    expires 1y;
    add_header Cache-Control "public, immutable";
}

Encaixa amb l'estructura /opt/tramontana/releases/<versio>/ que existeix des del Mòdul 5, i dona el mateix resultat sense tocar la construcció: cada versió té el seu propi espai d'URL.

I la lliçó de mètode: aquest incident es detecta des del registre comparant codis 200 i 304 per IP. Mereix entrar al quadern de guàrdia que formalitzaràs a 08-06, perquè tornarà a passar amb qualsevol recurs nou que es guardi agressivament a la memòria cau.

Solució 2

#!/usr/bin/env bash
#
# verificar_web.sh - Verificacio d extrem a extrem del proxy invers
#
# Codis de sortida (identics als de revisio_salut.sh):
#   0 = tot correcte
#   1 = avis: alguna cosa degradada pero el servei funciona
#   2 = critic: el servei no es correcte o no es segur
#
set -euo pipefail

readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/comuns.sh
source "${SCRIPT_DIR}/lib/comuns.sh"

readonly TRAMONTANA_DOMINI="${TRAMONTANA_DOMINI:-reserves.tramontana.example}"
readonly TRAMONTANA_IP="${TRAMONTANA_IP:-10.0.2.15}"
readonly TRAMONTANA_PORT_APP="${TRAMONTANA_PORT_APP:-8080}"
readonly TRAMONTANA_HOST_EXTERN="${TRAMONTANA_HOST_EXTERN:-192.168.122.104}"
readonly TRAMONTANA_LLINDAR_CERT="${TRAMONTANA_LLINDAR_CERT:-20}"

umask 027

# Estat acumulat: es queda sempre amb el pitjor resultat vist
estat_global=0

registrar() {
    local nivell="$1" missatge="$2"
    case "$nivell" in
        ok)     log "OK     $missatge" ;;
        avis)   error "AVIS   $missatge"; (( estat_global < 1 )) && estat_global=1 ;;
        critic) error "CRITIC $missatge"; estat_global=2 ;;
    esac
    return 0
}

comprovar_configuracio() {
    if sudo nginx -t >/dev/null 2>&1; then
        registrar ok "la configuracio de nginx es valida"
    else
        registrar critic "nginx -t falla: la configuracio en disc no es valida"
    fi
}

comprovar_servei() {
    if systemctl is-active --quiet nginx; then
        registrar ok "nginx actiu"
    else
        registrar critic "nginx NO esta actiu"
        return 0
    fi
    # Configuracio en disc diferent de la carregada: algu ha editat sense recarregar
    local arrencada
    arrencada="$(systemctl show nginx -p ActiveEnterTimestampMonotonic --value)"
    local mtime_ns
    mtime_ns="$(stat -c %Y /etc/nginx/sites-enabled/"${TRAMONTANA_DOMINI}" 2>/dev/null || echo 0)"
    local arrencada_epoch
    arrencada_epoch="$(date -d "$(systemctl show nginx -p ActiveEnterTimestamp --value)" +%s 2>/dev/null || echo 0)"
    if (( mtime_ns > arrencada_epoch )); then
        registrar avis "la configuracio s ha modificat despres de l ultim reload"
    fi
}

comprovar_redireccio() {
    local codi desti
    codi="$(curl -s -o /dev/null -w '%{http_code}' -m 5 \
        "http://${TRAMONTANA_DOMINI}/cases")" || {
        registrar critic "el port 80 no respon"
        return 0
    }
    desti="$(curl -s -o /dev/null -w '%{redirect_url}' -m 5 \
        "http://${TRAMONTANA_DOMINI}/cases")"
    if [[ "$codi" == "301" && "$desti" == https://* ]]; then
        registrar ok "HTTP redirigeix a HTTPS ($desti)"
    else
        registrar critic "HTTP no redirigeix a HTTPS (codi $codi)"
    fi
}

comprovar_https_i_capcaleres() {
    local capcaleres
    capcaleres="$(curl -sI -m 10 "https://${TRAMONTANA_DOMINI}/" | tr 'A-Z' 'a-z')" || {
        registrar critic "HTTPS no respon"
        return 0
    }
    grep -q '^http/2 200' <<<"$capcaleres" \
        && registrar ok "HTTPS respon 200 per HTTP/2" \
        || registrar avis "HTTPS respon pero no per HTTP/2"

    local -a obligatories=(
        strict-transport-security
        x-content-type-options
        x-frame-options
        referrer-policy
        content-security-policy
    )
    local h falten=0
    for h in "${obligatories[@]}"; do
        grep -q "^${h}:" <<<"$capcaleres" || { registrar avis "falta la capcalera $h"; falten=1; }
    done
    (( falten == 0 )) && registrar ok "les 5 capcaleres de seguretat hi son"

    grep -q '^server: nginx$' <<<"$capcaleres" \
        || registrar avis "server_tokens sembla activat: es revela la versio"
}

comprovar_tls() {
    local sortida
    sortida="$(echo | timeout 10 openssl s_client -connect "${TRAMONTANA_DOMINI}:443" \
        -servername "${TRAMONTANA_DOMINI}" -status 2>/dev/null)" || {
        registrar critic "no s ha pogut negociar TLS"
        return 0
    }
    grep -q 'Verify return code: 0 (ok)' <<<"$sortida" \
        && registrar ok "cadena de certificats valida" \
        || registrar critic "la cadena de certificats NO valida (revisa fullchain.pem)"

    grep -q 'OCSP Response Status: successful' <<<"$sortida" \
        && registrar ok "grapat OCSP actiu" \
        || registrar avis "sense grapat OCSP"

    # El TLS antic ha de ser rebutjat
    if echo | timeout 5 openssl s_client -connect "${TRAMONTANA_DOMINI}:443" \
            -tls1_1 >/dev/null 2>&1; then
        registrar critic "TLS 1.1 encara acceptat"
    else
        registrar ok "TLS 1.0 i 1.1 rebutjats"
    fi

    # Dies fins a la caducitat (reutilitza el llindar de 06-05)
    local fi dies
    fi="$(echo | openssl s_client -connect "${TRAMONTANA_DOMINI}:443" \
        -servername "${TRAMONTANA_DOMINI}" 2>/dev/null | \
        openssl x509 -noout -enddate | cut -d= -f2)"
    dies=$(( ( $(date -d "$fi" +%s) - $(date +%s) ) / 86400 ))
    if (( dies < 7 )); then
        registrar critic "el certificat caduca en $dies dies"
    elif (( dies < TRAMONTANA_LLINDAR_CERT )); then
        registrar avis "el certificat caduca en $dies dies"
    else
        registrar ok "certificat valid $dies dies mes"
    fi
}

# La comprovacio mes important: el port de l aplicacio NO ha de ser
# abastable des de fora. Es prova DES D UNA ALTRA MAQUINA, perque des
# del propi servidor 127.0.0.1:8080 sempre respondra.
comprovar_port_app_tancat() {
    if ! ss -tlnp 2>/dev/null | grep -q "127.0.0.1:${TRAMONTANA_PORT_APP}"; then
        registrar avis "l aplicacio no escolta a 127.0.0.1:${TRAMONTANA_PORT_APP}"
    fi
    # Escolta a 0.0.0.0 = exposada encara que el tallafocs la tapi
    if ss -tlnp 2>/dev/null | grep -qE "0\.0\.0\.0:${TRAMONTANA_PORT_APP}|\*:${TRAMONTANA_PORT_APP}"; then
        registrar critic "l aplicacio escolta a TOTES les interficies"
    fi

    if ! ssh -o BatchMode=yes -o ConnectTimeout=5 \
            "operador@${TRAMONTANA_HOST_EXTERN}" true 2>/dev/null; then
        registrar avis "no hi ha acces a ${TRAMONTANA_HOST_EXTERN}: no s ha pogut provar des de fora"
        return 0
    fi
    if ssh -o BatchMode=yes "operador@${TRAMONTANA_HOST_EXTERN}" \
            "nc -z -w3 ${TRAMONTANA_IP} ${TRAMONTANA_PORT_APP}" 2>/dev/null; then
        registrar critic "el port ${TRAMONTANA_PORT_APP} ES ACCESSIBLE des de fora"
    else
        registrar ok "el port ${TRAMONTANA_PORT_APP} esta tancat des de l exterior"
    fi
}

comprovar_limit_tasa() {
    local codis=""
    local i
    for i in $(seq 1 12); do
        codis+="$(curl -s -o /dev/null -w '%{http_code}' -m 5 \
            "https://${TRAMONTANA_DOMINI}/acces") "
    done
    if grep -q '429' <<<"$codis"; then
        registrar ok "el limit de tasa d /acces respon 429 davant de rafegues"
    else
        registrar avis "el limit de tasa d /acces no s ha activat: [$codis]"
    fi
}

comprovar_ganxo_certbot() {
    local ganxo=/etc/letsencrypt/renewal-hooks/deploy/10-recarregar-nginx.sh
    if [[ -x "$ganxo" ]]; then
        registrar ok "ganxo de recarrega despres de la renovacio present"
    else
        registrar avis "falta el ganxo de recarrega: despres de renovar continuaria el certificat vell"
    fi
}

main() {
    requereix_comanda curl
    requereix_comanda openssl
    requereix_comanda ss

    comprovar_configuracio
    comprovar_servei
    comprovar_redireccio
    comprovar_https_i_capcaleres
    comprovar_tls
    comprovar_port_app_tancat
    comprovar_limit_tasa
    comprovar_ganxo_certbot

    case "$estat_global" in
        0) log "verificacio completa: tot correcte" ;;
        1) error "verificacio completa amb AVISOS" ;;
        2) error "verificacio completa: estat CRITIC" ;;
    esac
    return "$estat_global"
}

main "$@"
$ chmod 0750 ~/scripts/verificar_web.sh
$ shellcheck ~/scripts/verificar_web.sh && echo "sense avisos"
sense avisos

$ ~/scripts/verificar_web.sh; echo "estat: $?"
[2026-08-18 12:04:11] OK     la configuracio de nginx es valida
[2026-08-18 12:04:11] OK     nginx actiu
[2026-08-18 12:04:12] OK     HTTP redirigeix a HTTPS (https://reserves.tramontana.example/cases)
[2026-08-18 12:04:12] OK     HTTPS respon 200 per HTTP/2
[2026-08-18 12:04:12] OK     les 5 capcaleres de seguretat hi son
[2026-08-18 12:04:13] OK     cadena de certificats valida
[2026-08-18 12:04:13] OK     grapat OCSP actiu
[2026-08-18 12:04:14] OK     TLS 1.0 i 1.1 rebutjats
[2026-08-18 12:04:14] OK     certificat valid 71 dies mes
[2026-08-18 12:04:15] OK     el port 8080 esta tancat des de l exterior
[2026-08-18 12:04:17] OK     el limit de tasa d /acces respon 429 davant de rafegues
[2026-08-18 12:04:17] OK     ganxo de recarrega despres de la renovacio present
[2026-08-18 12:04:17] verificacio completa: tot correcte
estat: 0

Quatre decisions de disseny de l'script:

  1. L'estat s'acumula al pitjor, no a l'últim. Un AVIS seguit d'un OK no ha d'esborrar l'avís. D'aquí la variable estat_global i el registrar que només puja el nivell.
  2. return 0 explícit a cada funció. Amb set -euo pipefail, una funció l'última expressió de la qual sigui falsa avorta l'script. El return 0 al final de registrar és imprescindible, i és un error que shellcheck no detecta.
  3. La comprovació externa degrada a avís, no a crític, si no hi ha accés. No poder provar una cosa no és el mateix que provar-la i que falli. Confondre-ho produeix alertes falses quan la màquina de proves està apagada, i això porta directe a la fatiga d'alertes de 08-06.
  4. Distingeix «escolta a 0.0.0.0» de «és accessible». La primera és una fallada de configuració de l'aplicació tapada pel tallafocs; la segona és una exposició real. Totes dues són crítiques, però la causa i l'arranjament són diferents.

Per deixar-ho operatiu, un temporitzador que l'executi després de cada renovació i cada matí, en la línia de comprovar_copia.sh:

# /etc/systemd/system/verificar-web.timer
[Unit]
Description=Verificacio diaria del servidor web

[Timer]
OnCalendar=*-*-* 08:15:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

Silenci si tot va bé: la unitat de servei només escriu al journal, i només un estat 2 dispara la notificació.

Solució 3

Per què hem posat un servidor web davant de l'aplicació Per a: Marta Vidal · De: Operacions de sistemes · 18 d'agost de 2026

Resum: des d'avui, tot el que viatja entre el navegador d'un client i el nostre servidor va xifrat. Fins aquest matí no ho estava, i això incloïa noms, telèfons, correus i dates d'estada de les persones que reserven. Era el problema obert més seriós que teníem i ja està resolt.


Què hem muntat, sense tecnicismes. Hem posat un programa anomenat Nginx a rebre les visites del web. Abans, l'aplicació de reserves atenia directament; ara atén Nginx, comprova i ordena la petició, i la passa a l'aplicació, que continua exactament igual com estava, sense cap canvi. Per al client és invisible; per a nosaltres canvia força.

Què protegeix aquesta mesura:

Protegeix contra Com
Que algú llegeixi les dades d'una reserva en trànsit Tot el trànsit va xifrat amb el certificat que vam preparar al juny
Que algú modifiqui el que veu el client El xifratge també garanteix que ningú no altera el contingut pel camí
Que un navegador entri per error sense xifrar Redirecció automàtica, i una instrucció al navegador perquè ni ho intenti
Que algú bombardegi el formulari d'accés provant contrasenyes Com a màxim 5 intents per minut i adreça
Que una sola adreça saturi el servei Límit de connexions simultànies
Que una fallada de l'aplicació exposi informació tècnica Nginx mostra una pàgina d'error pròpia
Que el web es carregui disfressat dins d'un altre web fraudulent Capçaleres de seguretat que el navegador respecta

Què NO protegeix, i convé tenir-ho clar:

  • No protegeix les dades desades. El xifratge és del viatge, no del magatzem. Les dades continuen a la base de dades com estaven; això s'aborda a la feina de la setmana que ve.
  • No protegeix d'una fallada de la mateixa aplicació. Si hi ha un error al codi de reserves, Nginx el transmet igualment.
  • No protegeix d'una contrasenya feble d'un empleat. Això ho cobreixen altres mesures que ja tenim.
  • No substitueix les còpies de seguretat ni evita un esborrat accidental.
  • No fa el web més ràpid a la primera visita. De fet la primera connexió triga uns 20 mil·lisegons més pel xifratge — una vintena part d'un parpelleig.

Sobre si afegeix un punt de fallada: sí, i la pregunta és bona. És literalment cert que ara hi ha una peça més que es pot trencar. Tres matisos:

  1. Nginx és de les peces de programari més provades que existeixen: mou una part enorme dels webs del món i és extraordinàriament estable. La probabilitat que falli ell abans que la nostra aplicació és molt baixa.
  2. També treu fallades. Abans, cada actualització de l'aplicació tallava el servei uns segons i el client veia un error de connexió. Ara Nginx continua allà i mostra una pàgina decent. I les imatges i els estils els serveix ell, així que si l'aplicació se satura, el web almenys no es veu trencat del tot.
  3. Ho hem mesurat: el web transfereix un 78 % menys de dades que abans, perquè Nginx les comprimeix. En un mòbil amb mala cobertura això es nota més que els 20 mil·lisegons del xifratge.

Què ha costat. Un matí de feina, i zero euros: el certificat és gratuït i es renova sol cada tres mesos, amb un avís automàtic vint dies abans per si alguna cosa fallés. La configuració està desada com a codi, així que si demà calgués reconstruir el servidor sencer, això es reprodueix en minuts juntament amb la resta.

Què queda pendent i en quin ordre ho proposo:

  1. Aquesta setmana: pujar a dos anys la instrucció que impedeix als navegadors fer servir connexions sense xifrar. L'hem posada de cinc minuts a propòsit, per verificar uns dies que tot funciona abans de comprometre'ns — un cop enviada, no es pot retirar.
  2. La setmana vinent: revisar amb el Luis un detall del web (els fitxers d'estils), perquè després de cada actualització alguns clients poden veure la versió anterior desada al seu navegador durant uns dies. Ja sabem com resoldre-ho.
  3. Aquest trimestre: la base de dades, que és la següent peça que vull deixar en condicions.

Una nota final, important per al compliment. Servir dades personals sense xifrar és difícil de defensar davant del Reglament General de Protecció de Dades, que exigeix mesures tècniques apropiades. Amb això passem d'una situació qüestionable a una de correcta i, a més, comprovable: puc generar en qualsevol moment un informe automàtic que acrediti que el xifratge està actiu i correctament configurat. L'incorporaré al procediment de revisió diària.

Conclusió

El deute més antic del curs està tancat. El certificat que vas emetre a 06-05 i que feia dos mòduls que esperava al disc ja està en ús: https://reserves.tramontana.example respon xifrat, amb TLS 1.2 i 1.3, suites preses d'una font mantinguda en lloc d'inventades, grapat OCSP, cadena completa des de fullchain.pem, i les cinc capçaleres de seguretat que converteixen el navegador del client en un aliat. I ho has verificat amb openssl s_client en lloc de confiar que funciona.

Entens el proxy invers com el que és: un punt únic on s'aplica la política —xifratge, capçaleres, compressió, límit de tasa, registre— sense que l'aplicació hagi de saber-ne res. Saps com Nginx tria el server per listen i server_name, i com tria el location amb una precedència en què una regex venç un prefix més específic, que és la font de la meitat dels desconcerts. Saps per què cada proxy_set_header és on és: sense Host els enllaços surten trencats, sense X-Real-IP els registres són inútils, i sense X-Forwarded-Proto l'aplicació entra en un bucle de redireccions. I saps que nginx -t va sempre abans d'un reload, i que reload va sempre en lloc de restart.

També has mesurat. Els 19 ms de la salutació TLS, el 78 % menys de bytes per la compressió, les peticions d'estàtics que l'aplicació ha deixat d'atendre, i el p99 de $upstream_response_time que separa «el client té mala connexió» de «l'aplicació va lenta». Aquella última dada apunta directament a la lliçó següent, perquè quan urt puja, la culpable gairebé mai no és l'aplicació: és una consulta.

A 08-02 muntaràs el servidor de base de dades en condicions. PostgreSQL 16 fa des del Mòdul 5 que funciona amb la configuració per defecte, que està pensada per arrencar a qualsevol lloc i no per als 3,8 GB de srv-tramontana. Ajustaràs la memòria amb fórmules raonades, connectaràs amb el que vas aprendre sobre pàgines enormes transparents a 07-03, resoldràs d'una vegada el desajust entre max_connexions=80 de l'aplicació i max_connections=100 del servidor —que ja va provocar un incident a 07-02— posant PgBouncer en mode transacció, tancaràs pg_hba.conf camp a camp, i muntaràs còpies físiques amb arxivat de WAL i recuperació a un punt en el temps, que és l'única cosa que permet desfer un DELETE sense WHERE. Aquí és on viu de veritat el negoci de Tramontana.

Curs de Linux: De Principiant a Administrador de Sistemes

Mòdul 1: Introducció a Linux

Mòdul 2: Comandes Bàsiques de Linux

Mòdul 3: Habilitats Avançades en la Línia de Comandes

Mòdul 4: Scripting en Shell

Mòdul 5: Administració del Sistema

Mòdul 6: Xarxes i Seguretat

Mòdul 7: Temes Avançats

Mòdul 8: Projectes Pràctics

© Copyright 2026. Tots els drets reservats