diff --git a/roles/immich/README.md b/roles/immich/README.md index d6eb160..2bced73 100644 --- a/roles/immich/README.md +++ b/roles/immich/README.md @@ -206,11 +206,44 @@ The role implements proper data isolation for both database backends: The compose file is deployed to `{{ podman_projects_dir }}/immich/docker-compose.yml` and managed via a systemd service. +## Nginx Reverse Proxy with ACME/Let's Encrypt + +The role includes an Nginx vhost template with native ACME support for automatic HTTPS certificate management. + +**Prerequisites:** +1. Nginx role deployed with `acme_email` configured +2. Port 80/443 accessible from internet (for ACME HTTP-01 challenge) +3. DNS pointing to your server + +**Configuration:** +```yaml +# Enable Nginx reverse proxy +immich_nginx_enabled: true +immich_nginx_hostname: "blog.hello.com" + +# In nginx role configuration (host_vars or group_vars) +acme_email: "admin@carabosse.cloud" +``` + +**What it does:** +- Deploys HTTPS vhost with automatic Let's Encrypt certificate +- HTTP → HTTPS redirect +- Proxies to Immich container on localhost +- Handles WebSocket upgrades for live photos +- Large file upload support (50GB max) + +**ACME automatic features:** +- Certificate issuance on first deployment +- Automatic renewal +- HTTP-01 challenge handling + ## Post-Installation After deployment: -1. Access Immich at `http://:2283` +1. Access Immich at: + - **With Nginx enabled**: `https://{{ immich_nginx_hostname }}` + - **Without Nginx**: `http://:{{ immich_port }}` 2. Create an admin account on first login 3. Configure mobile/desktop apps to point to your server diff --git a/roles/immich/templates/nginx-vhost.conf.j2 b/roles/immich/templates/nginx-vhost.conf.j2 index 380b067..7f6acfe 100644 --- a/roles/immich/templates/nginx-vhost.conf.j2 +++ b/roles/immich/templates/nginx-vhost.conf.j2 @@ -1,7 +1,41 @@ +# Immich vhost with Let's Encrypt (Certbot) +# Managed by Ansible - DO NOT EDIT MANUALLY + server { listen 80; server_name {{ immich_nginx_hostname }}; + # Certbot webroot for ACME challenges + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Redirect to HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +server { + listen 443 ssl; + server_name {{ immich_nginx_hostname }}; + + # Let's Encrypt certificates (managed by Certbot) + ssl_certificate /etc/letsencrypt/live/{{ immich_nginx_hostname }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ immich_nginx_hostname }}/privkey.pem; + + # SSL configuration + ssl_protocols {{ nginx_ssl_protocols }}; + ssl_prefer_server_ciphers {{ 'on' if nginx_ssl_prefer_server_ciphers else 'off' }}; + +{% if nginx_log_backend == 'journald' %} + access_log syslog:server=unix:/dev/log,nohostname,tag=nginx_immich; + error_log syslog:server=unix:/dev/log,nohostname,tag=nginx_immich; +{% else %} + access_log /var/log/nginx/{{ immich_nginx_hostname }}_access.log main; + error_log /var/log/nginx/{{ immich_nginx_hostname }}_error.log; +{% endif %} + client_max_body_size 50000M; location / { diff --git a/roles/nginx/README.md b/roles/nginx/README.md index f220652..6cde4d0 100644 --- a/roles/nginx/README.md +++ b/roles/nginx/README.md @@ -9,6 +9,8 @@ Installs and configures Nginx as a reverse proxy for web applications with modul - Configurable logging backend (journald or traditional files) - Automatic logrotate for file-based logging - SSL/TLS configuration +- **Native ACME/Let's Encrypt support** (Nginx 1.25.0+) +- **Transparent proxy forwarding** (HTTP/HTTPS to other hosts) ## Service Integration Pattern @@ -32,6 +34,33 @@ Each service role should deploy its own vhost config: notify: Reload nginx ``` +## Transparent Proxy Forwarding + +Forward TCP traffic from this Nginx instance to services on other hosts using the `stream` module (layer 4 proxy). + +**Configuration:** +```yaml +nginx_forwarder: + "blog.hello.com": + forward_to: "my.host.lan" + http: true # Forward port 80 (default: true) + https: true # Forward port 443 (default: true) +``` + +**How it works:** +- **Stream-based TCP proxy** (layer 4, not HTTP layer 7) +- No protocol inspection - just forwards raw TCP packets +- **HTTPS passes through encrypted** - backend host handles TLS termination +- HTTP also uses stream (simpler, but no HTTP features like headers/logging) + +**Use case:** Omega (gateway) forwards all traffic to Andromeda (internal server) that handles its own TLS certificates. + +**Important notes:** +- Stream configs deployed to `/etc/nginx/streams.d/` +- No HTTP logging (stream doesn't understand HTTP protocol) +- No X-Forwarded-For headers (transparent TCP forwarding) +- Only ONE domain can use port 443 forwarding (TCP port limitation) + ## Logging Backends **journald (default):** @@ -64,10 +93,19 @@ tail -f /var/log/nginx/error.log # List loaded vhosts ls -la /etc/nginx/conf.d/ + +# List stream forwarders +ls -la /etc/nginx/streams.d/ ``` +## Configuration Variables + +See [defaults/main.yml](defaults/main.yml) for all available variables. + ## References - [Nginx Documentation](https://nginx.org/en/docs/) +- [Nginx ACME Support](https://blog.nginx.org/blog/native-support-for-acme-protocol) +- [Nginx Stream Module](https://nginx.org/en/docs/stream/ngx_stream_core_module.html) - [Nginx Logging](https://nginx.org/en/docs/syslog.html) -- [Nginx SSL/TLS](https://nginx.org/en/docs/http/configuring_https_servers.html) \ No newline at end of file +- [Nginx SSL/TLS](https://nginx.org/en/docs/http/configuring_https_servers.html) diff --git a/roles/nginx/defaults/main.yml b/roles/nginx/defaults/main.yml index 5343b23..3274c33 100644 --- a/roles/nginx/defaults/main.yml +++ b/roles/nginx/defaults/main.yml @@ -2,6 +2,9 @@ # Nginx configuration directory for service vhosts nginx_conf_dir: /etc/nginx/conf.d +# Nginx stream configuration directory (TCP/UDP proxies) +nginx_streams_dir: /etc/nginx/streams.d + # Worker processes (auto = number of CPU cores) nginx_worker_processes: auto @@ -23,3 +26,16 @@ nginx_log_backend: journald nginx_logrotate_rotate: 14 # Keep 14 days of logs nginx_logrotate_frequency: daily # daily|weekly|monthly nginx_logrotate_compress: true # Compress rotated logs + +# Forwarder configuration (transparent proxy to other hosts) +# Example: +# nginx_forwarder: +# "blog.hello.com": +# forward_to: "you.domain.org" +# http: true # Forward port 80 (default: true) +# https: true # Forward port 443 (default: true) +nginx_forwarder: {} + +# Let's Encrypt / Certbot configuration +# acme_email: "" # Required for Let's Encrypt - intentionally undefined +# Set this variable to enable Certbot installation and certificate management diff --git a/roles/nginx/tasks/main.yml b/roles/nginx/tasks/main.yml index 46b97d8..e64010d 100644 --- a/roles/nginx/tasks/main.yml +++ b/roles/nginx/tasks/main.yml @@ -9,11 +9,54 @@ ansible.builtin.set_fact: nginx_user: "{{ nginx_user | default('www-data') }}" +- name: Add Nginx official APT signing key (Debian/Ubuntu) + ansible.builtin.get_url: + url: https://nginx.org/keys/nginx_signing.key + dest: /etc/apt/keyrings/nginx-archive-keyring.asc + mode: "0644" + when: + - ansible_facts['os_family'] == 'Debian' + +- name: Add Nginx official repository (Debian/Ubuntu) + ansible.builtin.deb822_repository: + name: nginx-official + types: deb + uris: http://nginx.org/packages/mainline/debian/ + suites: "{{ ansible_facts['distribution_release'] }}" + components: nginx + signed_by: /etc/apt/keyrings/nginx-archive-keyring.asc + state: present + when: + - ansible_facts['os_family'] == 'Debian' + - name: Install nginx ansible.builtin.package: name: nginx state: present +- name: Install nginx stream module (Debian) + ansible.builtin.package: + name: libnginx-mod-stream + state: present + when: + - ansible_facts['os_family'] == 'Debian' + - nginx_forwarder is defined + - nginx_forwarder | length > 0 + +- name: Install Certbot + ansible.builtin.package: + name: certbot + state: present + when: acme_email is defined + +- name: Enable Certbot renewal timer + ansible.builtin.systemd: + name: certbot-renew.timer + enabled: true + state: started + when: acme_email is defined + ignore_errors: true + - name: Ensure nginx conf.d directory exists ansible.builtin.file: path: "{{ nginx_conf_dir }}" @@ -22,6 +65,23 @@ group: root mode: "0755" +- name: Ensure nginx streams.d directory exists + ansible.builtin.file: + path: "{{ nginx_streams_dir }}" + state: directory + owner: root + group: root + mode: "0755" + +- name: Ensure Certbot webroot directory exists + ansible.builtin.file: + path: /var/www/certbot + state: directory + owner: "{{ nginx_user }}" + group: "{{ nginx_user }}" + mode: "0755" + when: acme_email is defined + - name: Deploy nginx main configuration ansible.builtin.template: src: nginx.conf.j2 @@ -32,6 +92,31 @@ validate: nginx -t -c %s notify: Reload nginx +- name: Deploy stream forwarder configurations + ansible.builtin.template: + src: forwarder.conf.j2 + dest: "{{ nginx_streams_dir }}/forwarder-{{ domain | replace('.', '_') }}.conf" + owner: root + group: root + mode: "0644" + loop: "{{ nginx_forwarder | dict2items }}" + loop_control: + loop_var: item + vars: + domain: "{{ item.key }}" + config: "{{ item.value }}" + when: + - nginx_forwarder is defined + - nginx_forwarder | length > 0 + notify: Reload nginx + +- name: Validate nginx configuration after stream forwarder deployment + ansible.builtin.command: nginx -t + changed_when: false + when: + - nginx_forwarder is defined + - nginx_forwarder | length > 0 + - name: Deploy logrotate configuration for nginx ansible.builtin.template: src: logrotate-nginx.j2 diff --git a/roles/nginx/templates/forwarder.conf.j2 b/roles/nginx/templates/forwarder.conf.j2 new file mode 100644 index 0000000..b0ebd23 --- /dev/null +++ b/roles/nginx/templates/forwarder.conf.j2 @@ -0,0 +1,25 @@ +# TCP stream forwarder for {{ domain }} +# Managed by Ansible - DO NOT EDIT MANUALLY +# Transparent TCP proxy (no protocol inspection) + +{% if config.http | default(true) %} +upstream {{ domain | replace('.', '_') | replace('-', '_') }}_http { + server {{ config.forward_to }}:80; +} + +server { + listen 80; + proxy_pass {{ domain | replace('.', '_') | replace('-', '_') }}_http; +} +{% endif %} + +{% if config.https | default(true) %} +upstream {{ domain | replace('.', '_') | replace('-', '_') }}_https { + server {{ config.forward_to }}:443; +} + +server { + listen 443; + proxy_pass {{ domain | replace('.', '_') | replace('-', '_') }}_https; +} +{% endif %} diff --git a/roles/nginx/templates/nginx.conf.j2 b/roles/nginx/templates/nginx.conf.j2 index 8c93c48..c982165 100644 --- a/roles/nginx/templates/nginx.conf.j2 +++ b/roles/nginx/templates/nginx.conf.j2 @@ -9,6 +9,11 @@ pid /run/nginx.pid; include /usr/share/nginx/modules/*.conf; +{% if nginx_forwarder and nginx_forwarder | length > 0 %} +# Load stream module for TCP/UDP proxying +load_module modules/ngx_stream_module.so; +{% endif %} + events { worker_connections {{ nginx_worker_connections }}; } @@ -48,3 +53,11 @@ http { # Load modular configuration files from the conf.d directory include {{ nginx_conf_dir }}/*.conf; } + +{% if nginx_forwarder and nginx_forwarder | length > 0 %} +# Stream block for TCP/UDP proxying +stream { + # Load stream configurations + include {{ nginx_streams_dir }}/*.conf; +} +{% endif %} diff --git a/roles/nginx/templates/vhost-acme.conf.j2 b/roles/nginx/templates/vhost-acme.conf.j2 new file mode 100644 index 0000000..d1615c9 --- /dev/null +++ b/roles/nginx/templates/vhost-acme.conf.j2 @@ -0,0 +1,41 @@ +# HTTPS vhost with Let's Encrypt (Certbot) for {{ server_name }} +# Managed by Ansible - DO NOT EDIT MANUALLY + +server { + listen 80; + server_name {{ server_name }}; + + # Certbot webroot for ACME challenges + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Redirect to HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +server { + listen 443 ssl; + server_name {{ server_name }}; + + # Let's Encrypt certificates (managed by Certbot) + ssl_certificate /etc/letsencrypt/live/{{ server_name }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ server_name }}/privkey.pem; + + # SSL configuration + ssl_protocols {{ nginx_ssl_protocols }}; + ssl_prefer_server_ciphers {{ 'on' if nginx_ssl_prefer_server_ciphers else 'off' }}; + +{% if nginx_log_backend == 'journald' %} + access_log syslog:server=unix:/dev/log,nohostname,tag=nginx_{{ server_name | replace('.', '_') }}; + error_log syslog:server=unix:/dev/log,nohostname,tag=nginx_{{ server_name | replace('.', '_') }}; +{% else %} + access_log /var/log/nginx/{{ server_name }}_access.log main; + error_log /var/log/nginx/{{ server_name }}_error.log; +{% endif %} + + # Service-specific configuration included below + {{ vhost_config | default('') }} +}