diff --git a/roles/gitea/templates/nginx-vhost.conf.j2 b/roles/gitea/templates/nginx-vhost.conf.j2 index 0fef30d..2f1a24d 100644 --- a/roles/gitea/templates/nginx-vhost.conf.j2 +++ b/roles/gitea/templates/nginx-vhost.conf.j2 @@ -3,6 +3,7 @@ server { listen 80; + listen [::]:80; server_name {{ gitea_nginx_hostname }}; # Certbot webroot for ACME challenges @@ -18,6 +19,7 @@ server { server { listen 443 ssl; + listen [::]:443 ssl; server_name {{ gitea_nginx_hostname }}; # Let's Encrypt certificates (managed by Certbot) diff --git a/roles/immich/templates/nginx-vhost.conf.j2 b/roles/immich/templates/nginx-vhost.conf.j2 index f9ad6ac..4aeb559 100644 --- a/roles/immich/templates/nginx-vhost.conf.j2 +++ b/roles/immich/templates/nginx-vhost.conf.j2 @@ -3,6 +3,7 @@ server { listen 80; + listen [::]:80; server_name {{ immich_nginx_hostname }}; # Certbot webroot for ACME challenges @@ -18,6 +19,7 @@ server { server { listen 443 ssl; + listen [::]:443 ssl; server_name {{ immich_nginx_hostname }}; # Let's Encrypt certificates (managed by Certbot) diff --git a/roles/nginx/README.md b/roles/nginx/README.md index 4d542e9..96f6a36 100644 --- a/roles/nginx/README.md +++ b/roles/nginx/README.md @@ -11,6 +11,22 @@ Installs and configures Nginx as a reverse proxy for web applications with modul - SSL/TLS configuration - **Native ACME/Let's Encrypt support** (Nginx 1.25.0+) - **Transparent proxy forwarding** (HTTP/HTTPS to other hosts) +- **Catch-all `default_server`** that rejects unknown SNI/Host with `444` + +## Catch-all default_server + +A `00-default.conf` vhost is deployed and marked `default_server` on both +ports 80 and 443. It uses a self-signed cert (`/etc/nginx/ssl/default.crt`) +and returns `444` (close connection) for any request whose SNI/Host does +not match an explicit vhost. ACME HTTP-01 challenges (`/.well-known/acme-challenge/`) +are still answered on port 80 so Certbot keeps working for new hostnames. + +Without this, clients hitting the server IP directly (or doing HTTP/2 +connection coalescing across vhosts sharing the same IP) would receive the +certificate of the first vhost loaded alphabetically, leaking that +hostname and breaking TLS verification on other vhosts. + +Disable with `nginx_default_server_enabled: false`. ## Service Integration Pattern diff --git a/roles/nginx/defaults/main.yml b/roles/nginx/defaults/main.yml index c41cddd..d9c63be 100644 --- a/roles/nginx/defaults/main.yml +++ b/roles/nginx/defaults/main.yml @@ -17,6 +17,12 @@ nginx_client_max_body_size: 100M # SSL configuration (volontarily omit TLSv1.2 here) nginx_ssl_protocols: TLSv1.3 +# Catch-all default_server (rejects unknown SNI / Host with 444). +# Prevents leaking the first-loaded vhost's cert to unrelated requests. +nginx_default_server_enabled: true +nginx_default_ssl_cert: /etc/nginx/ssl/default.crt +nginx_default_ssl_key: /etc/nginx/ssl/default.key + # Logging configuration # Backend: 'file' (traditional /var/log/nginx/*.log) or 'journald' (systemd journal) nginx_log_backend: journald diff --git a/roles/nginx/tasks/certbot.yml b/roles/nginx/tasks/certbot.yml index 815c2c0..e5849d2 100644 --- a/roles/nginx/tasks/certbot.yml +++ b/roles/nginx/tasks/certbot.yml @@ -29,6 +29,16 @@ path: "/etc/letsencrypt/live/{{ certbot_hostname }}/fullchain.pem" register: certbot_cert_file +- name: Ensure letsencrypt directories are traversable by nginx + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - /etc/letsencrypt/live + - /etc/letsencrypt/archive + when: certbot_cert_file.stat.exists + - name: Provision certificate for {{ certbot_hostname }} when: not certbot_cert_file.stat.exists block: diff --git a/roles/nginx/tasks/main.yml b/roles/nginx/tasks/main.yml index 2a8a18d..0fb0253 100644 --- a/roles/nginx/tasks/main.yml +++ b/roles/nginx/tasks/main.yml @@ -78,6 +78,49 @@ group: root mode: "0755" +- name: Configure catch-all default_server + when: nginx_default_server_enabled + block: + - name: Ensure nginx ssl directory exists + ansible.builtin.file: + path: "{{ nginx_default_ssl_cert | dirname }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Generate self-signed cert for default_server + ansible.builtin.command: + cmd: >- + openssl req -x509 -nodes -newkey rsa:2048 + -keyout {{ nginx_default_ssl_key }} + -out {{ nginx_default_ssl_cert }} + -days 3650 -subj "/CN=default" + creates: "{{ nginx_default_ssl_cert }}" + + - name: Restrict permissions on default_server key + ansible.builtin.file: + path: "{{ nginx_default_ssl_key }}" + owner: root + group: root + mode: "0600" + + - name: Deploy default_server vhost + ansible.builtin.template: + src: default-server.conf.j2 + dest: "{{ nginx_conf_dir }}/00-default.conf" + owner: root + group: root + mode: "0644" + notify: Reload nginx + +- name: Remove default_server vhost when disabled + ansible.builtin.file: + path: "{{ nginx_conf_dir }}/00-default.conf" + state: absent + when: not nginx_default_server_enabled + notify: Reload nginx + - name: Ensure Certbot webroot directory exists ansible.builtin.file: path: /var/www/certbot diff --git a/roles/nginx/templates/default-server.conf.j2 b/roles/nginx/templates/default-server.conf.j2 new file mode 100644 index 0000000..c1b1e09 --- /dev/null +++ b/roles/nginx/templates/default-server.conf.j2 @@ -0,0 +1,35 @@ +# Catch-all default_server vhosts +# Managed by Ansible - DO NOT EDIT MANUALLY +# +# Purpose: reject any request whose Host/SNI does not match an explicit +# server_name. Without this, the first vhost loaded alphabetically would +# leak its certificate to unrelated SNI requests (e.g. clients doing +# HTTP/2 connection coalescing or hitting the IP directly). +# +# `return 444` closes the connection without sending an HTTP response. + +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + # Keep ACME HTTP-01 challenges working for any hostname + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 444; + } +} + +server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + server_name _; + + ssl_certificate {{ nginx_default_ssl_cert }}; + ssl_certificate_key {{ nginx_default_ssl_key }}; + + return 444; +} diff --git a/roles/nginx/templates/vhost-http-acme.conf.j2 b/roles/nginx/templates/vhost-http-acme.conf.j2 index d32079a..6c1f5d8 100644 --- a/roles/nginx/templates/vhost-http-acme.conf.j2 +++ b/roles/nginx/templates/vhost-http-acme.conf.j2 @@ -3,6 +3,7 @@ server { listen 80; + listen [::]:80; server_name {{ certbot_hostname }}; location /.well-known/acme-challenge/ { diff --git a/roles/ntfy/templates/nginx-vhost.conf.j2 b/roles/ntfy/templates/nginx-vhost.conf.j2 index e5bef40..619ff27 100644 --- a/roles/ntfy/templates/nginx-vhost.conf.j2 +++ b/roles/ntfy/templates/nginx-vhost.conf.j2 @@ -3,6 +3,7 @@ server { listen 80; + listen [::]:80; server_name {{ ntfy_nginx_hostname }}; # Certbot webroot for ACME challenges @@ -18,6 +19,7 @@ server { server { listen 443 ssl; + listen [::]:443 ssl; server_name {{ ntfy_nginx_hostname }}; # Let's Encrypt certificates (managed by Certbot) diff --git a/roles/uptime_kuma/templates/nginx-vhost.conf.j2 b/roles/uptime_kuma/templates/nginx-vhost.conf.j2 index e0ace55..af395dc 100644 --- a/roles/uptime_kuma/templates/nginx-vhost.conf.j2 +++ b/roles/uptime_kuma/templates/nginx-vhost.conf.j2 @@ -3,6 +3,7 @@ server { listen 80; + listen [::]:80; server_name {{ uptime_kuma_nginx_hostname }}; # Certbot webroot for ACME challenges @@ -18,6 +19,7 @@ server { server { listen 443 ssl; + listen [::]:443 ssl; server_name {{ uptime_kuma_nginx_hostname }}; # Let's Encrypt certificates (managed by Certbot)