From da1b2cfe822caf4cae4ad8a44f8998c76d556c9e Mon Sep 17 00:00:00 2001 From: Alexander Gabriel Date: Wed, 14 Oct 2026 19:42:06 +0200 Subject: [PATCH] websites-role umgebaut --- README.md | 69 +++++++++--- defaults/main.yml | 29 ++++- tasks/absent.yml | 23 ++++ tasks/main.yml | 177 ++++++++++++------------------ tasks/ssl.yml | 94 ++++++++++++++++ templates/apache-ssl.conf.j2 | 58 +++++++--- templates/apache.conf.j2 | 16 ++- templates/includeOptional.conf.j2 | 1 - 8 files changed, 319 insertions(+), 148 deletions(-) create mode 100644 tasks/absent.yml create mode 100644 tasks/ssl.yml delete mode 100644 templates/includeOptional.conf.j2 diff --git a/README.md b/README.md index 93440da..e87f52f 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,57 @@ -Configure Apache with SSL and ACME.sh. Support snippets f.e. for a reverse Proxy +Configure Apache with SSL and acme.sh for one or more websites per host, optionally as reverse proxy. -Used to be included by other roles, require to have a webserver +Websites are defined as dict in host_vars / group_vars (or passed via `include_role` vars). +The key is the domain (ServerName and certificate name). Without `websites` the host +serves one website named after `inventory_hostname`. -# Variables +Before a certificate is requested, a token is placed in `/.well-known/acme-challenge/` +and fetched via `http:///...`. If the domain does not reach this host, certificate +and ssl vhost are skipped (warning at the end of the role) and retried on the next run. -* domainname | default(inventory_hostname) -* docroot |default('/var/www/html') -* optionalDirectives | default('') +# Example +```yaml +websites: + example.digital-infinity.de: # static site + aliases: www.example.digital-infinity.de + docroot: /var/www/example + extra_directives: | + Header always set Strict-Transport-Security "max-age=31536000" + + app.digital-infinity.de: # reverse proxy, only the target url is required + proxy_pass: http://127.0.0.1:8080/ + + immich: + proxy_pass: http://127.0.0.1:2283/ + proxy_websocket: true + proxy_timeout: 600 + + old.digital-infinity.de: + state: absent # removes vhosts and stops renewal, keeps cert files ``` -optionalDirectives: | - #reverse Proxy - ProxyPreserveHost On - SSLProxyEngine On - SSLProxyCheckPeerCN on - SSLProxyCheckPeerExpire on - RequestHeader set X-Forwarded-Proto "https" - RequestHeader set X-Forwarded-Port "443" - ProxyPass "/" "http://127.0.0.1:8000/" - ProxyPassReverse "/" "http://127.0.0.1:8000/" -ยดยดยด \ No newline at end of file + +# Options per website + +| option | default | | +|---|---|---| +| state | present | `absent` removes the vhosts | +| aliases | [] | ServerAlias, string or list (not part of the certificate) | +| docroot | /var/www/html | also used as acme webroot | +| use_local_ca | `use_local_ca` / false | true -> internal `acme_sh_server` instead of letsencrypt (for internal sites) | +| check_dns | true | reachability check before requesting a certificate | +| extra_directives | "" | raw apache directives inside the ssl vhost | +| proxy_pass | "" | target url, enables the reverse proxy | +| proxy_path | / | | +| proxy_preserve_host | true | ProxyPreserveHost | +| proxy_forwarded_headers | true | X-Forwarded-Proto/-Port | +| proxy_websocket | false | adds `upgrade=websocket` (apache >= 2.4.47) | +| proxy_timeout | "" | `timeout=` in seconds | +| proxy_ssl_verify | true | false disables certificate checks for https backends | +| proxy_options | "" | further ProxyPass options, e.g. `nocanon` | + +# Global variables + +* `website_check_host` (default `inventory_hostname`): host running the reachability check, e.g. `localhost` to check from the controller +* `website_check_timeout` (default 10) +* `website_fail_without_cert` (default false): fail instead of warn if a domain is not reachable +* `website_site_defaults`: defaults for all websites diff --git a/defaults/main.yml b/defaults/main.yml index ff32d47..fbfdf9a 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -1,3 +1,28 @@ --- -docroot: /var/www/html -domainname: "{{ inventory_hostname }}" \ No newline at end of file +# Websites served by this host. Key = domain (ServerName + certificate name). +# Without a definition the host serves one website named after inventory_hostname. +websites: "{{ {inventory_hostname: {}} }}" + +# Defaults for every entry in `websites`, overridable per website. +website_site_defaults: + state: present # present | absent + aliases: [] # ServerAlias entries (string or list) + docroot: /var/www/html + use_local_ca: "{{ use_local_ca | default(false) }}" # true -> internal acme_sh_server instead of letsencrypt + check_dns: true # verify the domain reaches this host before requesting a certificate + extra_directives: "" # raw apache directives for the ssl vhost + # reverse proxy, active as soon as proxy_pass is set + proxy_pass: "" # target url, e.g. http://127.0.0.1:8080/ + proxy_path: / + proxy_preserve_host: true + proxy_forwarded_headers: true # X-Forwarded-Proto/-Port + proxy_websocket: false # adds upgrade=websocket + proxy_timeout: "" # seconds + proxy_ssl_verify: true # false disables checks for https backends with self-signed certs + proxy_options: "" # further ProxyPass key=value options, e.g. "nocanon" + +# Host that runs the reachability check (http:///.well-known/acme-challenge/). +website_check_host: "{{ inventory_hostname }}" +website_check_timeout: 10 +# Fail the play if a website could not get a certificate because the domain does not reach this host. +website_fail_without_cert: false diff --git a/tasks/absent.yml b/tasks/absent.yml new file mode 100644 index 0000000..ebad3f0 --- /dev/null +++ b/tasks/absent.yml @@ -0,0 +1,23 @@ +--- +# included per website with state: absent, certificates and docroot are kept +- name: "{{ site.domain }}: deactivate and remove vhosts" + file: + path: "{{ item }}" + state: absent + loop: + - /etc/apache2/sites-enabled/{{ site.domain }}.conf + - /etc/apache2/sites-enabled/{{ site.domain }}-ssl.conf + - /etc/apache2/sites-available/{{ site.domain }}.conf + - /etc/apache2/sites-available/{{ site.domain }}-ssl.conf + - /etc/apache2/conf-available/{{ site.domain }}-ssl-optionalDirectives.conf + notify: + - reload apache2 + +- name: "{{ site.domain }}: stop certificate renewal" + shell: | + if /root/.acme.sh/acme.sh --list | awk 'NR>1 {print $1}' | grep -qxF '{{ site.domain }}'; then + /root/.acme.sh/acme.sh --remove -d '{{ site.domain }}' --ecc || /root/.acme.sh/acme.sh --remove -d '{{ site.domain }}' + echo removed + fi + register: _website_acme_remove + changed_when: "'removed' in _website_acme_remove.stdout" diff --git a/tasks/main.yml b/tasks/main.yml index 2714d3a..8f4b02e 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -9,75 +9,23 @@ include_role: name: acmesh -- name: create wwwroot - file: - path: "{{ docroot }}" - owner: root - group: root - mode: '0755' - state: directory - -- name: enable apache module proxy - command: a2enmod proxy +- name: enable apache modules + command: a2enmod {{ item }} args: - creates: /etc/apache2/mods-enabled/proxy.load + creates: /etc/apache2/mods-enabled/{{ item }}.load + loop: + - proxy + - proxy_html + - proxy_http + - proxy_http2 + - proxy_wstunnel + - ssl + - headers + - rewrite notify: reload apache2 -- name: enable apache module proxy_html - command: a2enmod proxy_html - args: - creates: /etc/apache2/mods-enabled/proxy_html.load - notify: - reload apache2 - -- name: enable apache module proxy_http - command: a2enmod proxy_http - args: - creates: /etc/apache2/mods-enabled/proxy_http.load - notify: - reload apache2 - -- name: enable apache module proxy_http2 - command: a2enmod proxy_http2 - args: - creates: /etc/apache2/mods-enabled/proxy_http2.load - notify: - reload apache2 - -- name: enable apache module ssl - command: a2enmod ssl - args: - creates: /etc/apache2/mods-enabled/ssl.load - notify: - reload apache2 - -- name: enable apache module headers - command: a2enmod headers - args: - creates: /etc/apache2/mods-enabled/headers.load - notify: - reload apache2 - -- name: enable apache module rewrite - command: a2enmod rewrite - args: - creates: /etc/apache2/mods-enabled/rewrite.load - notify: - reload apache2 - -- name: enable apache module proxy_wstunnel - command: a2enmod proxy_wstunnel - args: - creates: /etc/apache2/mods-enabled/proxy_wstunnel.load - notify: - reload apache2 - -- name: install acme.sh - include_role: - name: acmesh - -- name: create directory for website +- name: create directory for own root ca cert file: path: "/usr/local/share/ca-certificates/step-ca/" owner: root @@ -96,68 +44,79 @@ command: /usr/sbin/update-ca-certificates args: creates: /etc/ssl/certs/step-ca.pem + when: acme_sh_server is defined -- name: set amce server url +- name: normalize website definitions set_fact: - acmeshserver: "--server {{ acme_sh_server }} --insecure --force --days 10" - when: acme_sh_server is defined and use_local_ca != false + _websites: >- + {%- set out = [] -%} + {%- for name, cfg in (websites | default({}, true)).items() -%} + {%- set c = website_site_defaults | combine(cfg | default({}, true), {'domain': name}) -%} + {%- set a = c.aliases | default([], true) -%} + {%- set c = c | combine({'aliases': [a] if a is string else a | list}) -%} + {%- set _ = out.append(c) -%} + {%- endfor -%} + {{ out }} + _website_without_cert: [] -- name: set amce server url - set_fact: - acmeshserver: " --server letsencrypt " - when: acme_sh_server is undefined or use_local_ca == false - -- name: generate /etc/apache2/sites-available/{{ domainname }}.conf +- name: create docroot + file: + path: "{{ site.docroot }}" + owner: root + group: root + mode: '0755' + state: directory + loop: "{{ _websites | selectattr('state', 'eq', 'present') | list }}" + loop_control: + loop_var: site + label: "{{ site.domain }}" + +- name: generate http vhost template: src: apache.conf.j2 - dest: /etc/apache2/sites-available/{{ domainname }}.conf + dest: /etc/apache2/sites-available/{{ site.domain }}.conf owner: root group: root mode: "0644" + loop: "{{ _websites | selectattr('state', 'eq', 'present') | list }}" + loop_control: + loop_var: site + label: "{{ site.domain }}" notify: - reload apache2 -- name: activate /etc/apache2/sites-available/{{ domainname }}.conf +- name: activate http vhost file: state: link - src: /etc/apache2/sites-available/{{ domainname }}.conf - dest: /etc/apache2/sites-enabled/{{ domainname }}.conf + src: /etc/apache2/sites-available/{{ site.domain }}.conf + dest: /etc/apache2/sites-enabled/{{ site.domain }}.conf + loop: "{{ _websites | selectattr('state', 'eq', 'present') | list }}" + loop_control: + loop_var: site + label: "{{ site.domain }}" notify: - reload apache2 +# http vhosts must be live before acme.sh uses the webroot - name: Flush handlers meta: flush_handlers -- name: get certificates - command: /root/.acme.sh/acme.sh --issue --webroot {{ docroot }} {{ acmeshserver }} -d {{ domainname }} --email {{ acme_sh_email }} --key-file /etc/ssl/private/{{ domainname }}.key --fullchain-file /etc/ssl/certs/{{ domainname }}.pem --reloadcmd "service apache2 reload" - args: - creates: /etc/ssl/private/{{ domainname }}.key +- name: certificate and ssl vhost + include_tasks: ssl.yml + loop: "{{ _websites | selectattr('state', 'eq', 'present') | list }}" + loop_control: + loop_var: site + label: "{{ site.domain }}" -- name: generate /etc/apache2/conf-available/{{ domainname }}-ssl-optionalDirectives.conf - template: - src: includeOptional.conf.j2 - dest: /etc/apache2/conf-available/{{ domainname }}-ssl-optionalDirectives.conf - owner: root - group: root - mode: "0644" - notify: - - reload apache2 - -- name: generate /etc/apache2/sites-available/{{ domainname }}-ssl.conf - template: - src: apache-ssl.conf.j2 - dest: /etc/apache2/sites-available/{{ domainname }}-ssl.conf - owner: root - group: root - mode: "0644" - notify: - - reload apache2 - -- name: activate /etc/apache2/sites-available/{{ domainname }}-ssl.conf - file: - state: link - src: /etc/apache2/sites-available/{{ domainname }}-ssl.conf - dest: /etc/apache2/sites-enabled/{{ domainname }}-ssl.conf - notify: - - reload apache2 +- name: remove websites + include_tasks: absent.yml + loop: "{{ _websites | selectattr('state', 'eq', 'absent') | list }}" + loop_control: + loop_var: site + label: "{{ site.domain }}" +- name: websites without certificate (domain does not reach this host) + debug: + msg: "WARNING: no certificate and no ssl vhost for {{ _website_without_cert | join(', ') }} - check DNS and port 80, then rerun" + when: _website_without_cert | length > 0 + failed_when: website_fail_without_cert | bool diff --git a/tasks/ssl.yml b/tasks/ssl.yml new file mode 100644 index 0000000..363ce88 --- /dev/null +++ b/tasks/ssl.yml @@ -0,0 +1,94 @@ +--- +# included per website, expects `site` (normalized entry of _websites) +- name: "{{ site.domain }}: check for existing certificate" + stat: + path: /etc/ssl/private/{{ site.domain }}.key + register: _website_key + +- name: "{{ site.domain }}: request certificate" + when: not _website_key.stat.exists + block: + - name: "{{ site.domain }}: generate reachability token" + set_fact: + _website_token: "ansible-check-{{ lookup('password', '/dev/null chars=ascii_letters,digits length=32') }}" + + - name: "{{ site.domain }}: create acme-challenge directory" + file: + path: "{{ site.docroot }}/.well-known/acme-challenge" + owner: root + group: root + mode: '0755' + state: directory + + - name: "{{ site.domain }}: place reachability token" + copy: + dest: "{{ site.docroot }}/.well-known/acme-challenge/{{ _website_token }}" + content: "{{ _website_token }}" + mode: '0644' + when: site.check_dns | bool + + - name: "{{ site.domain }}: check that the domain points to this host" + uri: + url: "http://{{ site.domain }}/.well-known/acme-challenge/{{ _website_token }}" + return_content: true + follow_redirects: none + timeout: "{{ website_check_timeout }}" + register: _website_check + failed_when: false + delegate_to: "{{ website_check_host }}" + when: site.check_dns | bool + + - name: "{{ site.domain }}: remove reachability token" + file: + path: "{{ site.docroot }}/.well-known/acme-challenge/{{ _website_token }}" + state: absent + + - name: "{{ site.domain }}: evaluate reachability" + set_fact: + _website_reachable: >- + {{ not site.check_dns | bool + or (_website_check.status | default(-1) == 200 + and (_website_check.content | default('') | trim) == _website_token) }} + + - name: "{{ site.domain }}: domain does not reach this host, skipping certificate" + set_fact: + _website_without_cert: "{{ _website_without_cert + [site.domain] }}" + when: not _website_reachable | bool + + - name: "{{ site.domain }}: get certificate" + command: >- + /root/.acme.sh/acme.sh --issue --webroot {{ site.docroot }} + {% if site.use_local_ca | bool and acme_sh_server is defined %}--server {{ acme_sh_server }} --insecure --force --days 10{% else %}--server letsencrypt{% endif %} + -d {{ site.domain }} --email {{ acme_sh_email }} + --key-file /etc/ssl/private/{{ site.domain }}.key + --fullchain-file /etc/ssl/certs/{{ site.domain }}.pem + --reloadcmd "service apache2 reload" + args: + creates: /etc/ssl/private/{{ site.domain }}.key + when: _website_reachable | bool + +- name: "{{ site.domain }}: generate ssl vhost" + template: + src: apache-ssl.conf.j2 + dest: /etc/apache2/sites-available/{{ site.domain }}-ssl.conf + owner: root + group: root + mode: "0644" + when: site.domain not in _website_without_cert + notify: + - reload apache2 + +- name: "{{ site.domain }}: activate ssl vhost" + file: + state: link + src: /etc/apache2/sites-available/{{ site.domain }}-ssl.conf + dest: /etc/apache2/sites-enabled/{{ site.domain }}-ssl.conf + when: site.domain not in _website_without_cert + notify: + - reload apache2 + +# directives now live in the ssl vhost itself +- name: "{{ site.domain }}: remove legacy optionalDirectives include" + file: + path: /etc/apache2/conf-available/{{ site.domain }}-ssl-optionalDirectives.conf + state: absent diff --git a/templates/apache-ssl.conf.j2 b/templates/apache-ssl.conf.j2 index 83f68c5..2808f51 100644 --- a/templates/apache-ssl.conf.j2 +++ b/templates/apache-ssl.conf.j2 @@ -1,24 +1,26 @@ +# {{ ansible_managed }} - - ServerName {{ domainname }} - ServerAlias {{ serveralias | default(domainname) }} - ServerAdmin webmaster@{{ domainname }} + + ServerName {{ site.domain }} +{% if site.aliases %} + ServerAlias {{ site.aliases | join(' ') }} +{% endif %} + ServerAdmin webmaster@{{ site.domain }} - DocumentRoot "{{ docroot }}" - + DocumentRoot "{{ site.docroot }}" + Options Indexes MultiViews Includes FollowSymLinks AddOutputFilter Includes html AllowOverride All - Order allow,deny - Allow from all + Require all granted - ErrorLog ${APACHE_LOG_DIR}/{{ domainname }}.error.log - CustomLog ${APACHE_LOG_DIR}/{{ domainname }}.access.log combined + ErrorLog ${APACHE_LOG_DIR}/{{ site.domain }}.error.log + CustomLog ${APACHE_LOG_DIR}/{{ site.domain }}.access.log combined SSLEngine on - SSLCertificateFile /etc/ssl/certs/{{ domainname }}.pem - SSLCertificateKeyFile /etc/ssl/private/{{ domainname }}.key + SSLCertificateFile /etc/ssl/certs/{{ site.domain }}.pem + SSLCertificateKeyFile /etc/ssl/private/{{ site.domain }}.key SSLOptions +StdEnvVars @@ -26,6 +28,36 @@ SSLOptions +StdEnvVars - IncludeOptional /etc/apache2/conf-available/{{ domainname }}-ssl-optionalDirectives.conf +{% if site.proxy_pass %} +{% set path = site.proxy_path | string %} +{% set target = site.proxy_pass if (site.proxy_pass.endswith('/') or not path.endswith('/')) else site.proxy_pass ~ '/' %} +{% set opts = [] %} +{% if site.proxy_timeout | string | length %}{% set _ = opts.append('timeout=' ~ site.proxy_timeout) %}{% endif %} +{% if site.proxy_websocket | bool %}{% set _ = opts.append('upgrade=websocket') %}{% endif %} +{% if site.proxy_options %}{% set _ = opts.append(site.proxy_options) %}{% endif %} + + # reverse proxy + ProxyRequests Off + ProxyPreserveHost {{ 'On' if site.proxy_preserve_host | bool else 'Off' }} +{% if site.proxy_forwarded_headers | bool %} + RequestHeader set X-Forwarded-Proto "https" + RequestHeader set X-Forwarded-Port "443" +{% endif %} +{% if target is match('https://') %} + SSLProxyEngine On +{% if not site.proxy_ssl_verify | bool %} + SSLProxyVerify none + SSLProxyCheckPeerName off + SSLProxyCheckPeerCN off + SSLProxyCheckPeerExpire off +{% endif %} +{% endif %} + ProxyPass "{{ path }}" "{{ target }}"{{ (' ' ~ opts | join(' ')) if opts else '' }} + ProxyPassReverse "{{ path }}" "{{ target }}" +{% endif %} +{% if site.extra_directives %} + +{{ site.extra_directives | trim | indent(16, true) }} +{% endif %} diff --git a/templates/apache.conf.j2 b/templates/apache.conf.j2 index 08f985b..afcb2af 100644 --- a/templates/apache.conf.j2 +++ b/templates/apache.conf.j2 @@ -1,13 +1,17 @@ +# {{ ansible_managed }} - ServerName {{ domainname }} + ServerName {{ site.domain }} +{% if site.aliases %} + ServerAlias {{ site.aliases | join(' ') }} +{% endif %} - ServerAdmin webmaster@{{ domainname }} - DocumentRoot "{{ docroot }}" + ServerAdmin webmaster@{{ site.domain }} + DocumentRoot "{{ site.docroot }}" - ErrorLog ${APACHE_LOG_DIR}/{{ domainname }}.error.log - CustomLog ${APACHE_LOG_DIR}/{{ domainname }}.access.log combined + ErrorLog ${APACHE_LOG_DIR}/{{ site.domain }}.error.log + CustomLog ${APACHE_LOG_DIR}/{{ site.domain }}.access.log combined - #rewrite transparent to https, keep uri + #rewrite transparent to https, keep uri RewriteEngine On RewriteCond %{REQUEST_URI} !^/\.well\-known/acme\-challenge/ RewriteCond %{HTTPS} off diff --git a/templates/includeOptional.conf.j2 b/templates/includeOptional.conf.j2 deleted file mode 100644 index 3c85a2c..0000000 --- a/templates/includeOptional.conf.j2 +++ /dev/null @@ -1 +0,0 @@ -{{ optionalDirectives | default('')}} \ No newline at end of file