websites-role umgebaut

This commit is contained in:
Alexander Gabriel 2026-10-14 19:42:06 +02:00
parent 522cfca114
commit da1b2cfe82
8 changed files with 319 additions and 148 deletions

View File

@ -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 `<docroot>/.well-known/acme-challenge/`
and fetched via `http://<domain>/...`. 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) # Example
* docroot |default('/var/www/html')
* optionalDirectives | default('')
```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 # Options per website
ProxyPreserveHost On
SSLProxyEngine On | option | default | |
SSLProxyCheckPeerCN on |---|---|---|
SSLProxyCheckPeerExpire on | state | present | `absent` removes the vhosts |
RequestHeader set X-Forwarded-Proto "https" | aliases | [] | ServerAlias, string or list (not part of the certificate) |
RequestHeader set X-Forwarded-Port "443" | docroot | /var/www/html | also used as acme webroot |
ProxyPass "/" "http://127.0.0.1:8000/" | use_local_ca | `use_local_ca` / false | true -> internal `acme_sh_server` instead of letsencrypt (for internal sites) |
ProxyPassReverse "/" "http://127.0.0.1:8000/" | 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

View File

@ -1,3 +1,28 @@
--- ---
docroot: /var/www/html # Websites served by this host. Key = domain (ServerName + certificate name).
domainname: "{{ inventory_hostname }}" # 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://<domain>/.well-known/acme-challenge/<token>).
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

23
tasks/absent.yml Normal file
View File

@ -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"

View File

@ -9,75 +9,23 @@
include_role: include_role:
name: acmesh name: acmesh
- name: create wwwroot - name: enable apache modules
file: command: a2enmod {{ item }}
path: "{{ docroot }}"
owner: root
group: root
mode: '0755'
state: directory
- name: enable apache module proxy
command: a2enmod proxy
args: 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: notify:
reload apache2 reload apache2
- name: enable apache module proxy_html - name: create directory for own root ca cert
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
file: file:
path: "/usr/local/share/ca-certificates/step-ca/" path: "/usr/local/share/ca-certificates/step-ca/"
owner: root owner: root
@ -96,68 +44,79 @@
command: /usr/sbin/update-ca-certificates command: /usr/sbin/update-ca-certificates
args: args:
creates: /etc/ssl/certs/step-ca.pem creates: /etc/ssl/certs/step-ca.pem
when: acme_sh_server is defined
- name: set amce server url - name: normalize website definitions
set_fact: set_fact:
acmeshserver: "--server {{ acme_sh_server }} --insecure --force --days 10" _websites: >-
when: acme_sh_server is defined and use_local_ca != false {%- 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 - name: create docroot
set_fact: file:
acmeshserver: " --server letsencrypt " path: "{{ site.docroot }}"
when: acme_sh_server is undefined or use_local_ca == false owner: root
group: root
- name: generate /etc/apache2/sites-available/{{ domainname }}.conf mode: '0755'
state: directory
loop: "{{ _websites | selectattr('state', 'eq', 'present') | list }}"
loop_control:
loop_var: site
label: "{{ site.domain }}"
- name: generate http vhost
template: template:
src: apache.conf.j2 src: apache.conf.j2
dest: /etc/apache2/sites-available/{{ domainname }}.conf dest: /etc/apache2/sites-available/{{ site.domain }}.conf
owner: root owner: root
group: root group: root
mode: "0644" mode: "0644"
loop: "{{ _websites | selectattr('state', 'eq', 'present') | list }}"
loop_control:
loop_var: site
label: "{{ site.domain }}"
notify: notify:
- reload apache2 - reload apache2
- name: activate /etc/apache2/sites-available/{{ domainname }}.conf - name: activate http vhost
file: file:
state: link state: link
src: /etc/apache2/sites-available/{{ domainname }}.conf src: /etc/apache2/sites-available/{{ site.domain }}.conf
dest: /etc/apache2/sites-enabled/{{ domainname }}.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: notify:
- reload apache2 - reload apache2
# http vhosts must be live before acme.sh uses the webroot
- name: Flush handlers - name: Flush handlers
meta: flush_handlers meta: flush_handlers
- name: get certificates - name: certificate and ssl vhost
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" include_tasks: ssl.yml
args: loop: "{{ _websites | selectattr('state', 'eq', 'present') | list }}"
creates: /etc/ssl/private/{{ domainname }}.key loop_control:
loop_var: site
label: "{{ site.domain }}"
- name: generate /etc/apache2/conf-available/{{ domainname }}-ssl-optionalDirectives.conf - name: remove websites
template: include_tasks: absent.yml
src: includeOptional.conf.j2 loop: "{{ _websites | selectattr('state', 'eq', 'absent') | list }}"
dest: /etc/apache2/conf-available/{{ domainname }}-ssl-optionalDirectives.conf loop_control:
owner: root loop_var: site
group: root label: "{{ site.domain }}"
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: 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

94
tasks/ssl.yml Normal file
View File

@ -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

View File

@ -1,24 +1,26 @@
# {{ ansible_managed }}
<IfModule mod_ssl.c> <IfModule mod_ssl.c>
<VirtualHost :443> <VirtualHost *:443>
ServerName {{ domainname }} ServerName {{ site.domain }}
ServerAlias {{ serveralias | default(domainname) }} {% if site.aliases %}
ServerAdmin webmaster@{{ domainname }} ServerAlias {{ site.aliases | join(' ') }}
{% endif %}
ServerAdmin webmaster@{{ site.domain }}
DocumentRoot "{{ docroot }}" DocumentRoot "{{ site.docroot }}"
<Directory "{{ docroot }}"> <Directory "{{ site.docroot }}">
Options Indexes MultiViews Includes FollowSymLinks Options Indexes MultiViews Includes FollowSymLinks
AddOutputFilter Includes html AddOutputFilter Includes html
AllowOverride All AllowOverride All
Order allow,deny Require all granted
Allow from all
</Directory> </Directory>
ErrorLog ${APACHE_LOG_DIR}/{{ domainname }}.error.log ErrorLog ${APACHE_LOG_DIR}/{{ site.domain }}.error.log
CustomLog ${APACHE_LOG_DIR}/{{ domainname }}.access.log combined CustomLog ${APACHE_LOG_DIR}/{{ site.domain }}.access.log combined
SSLEngine on SSLEngine on
SSLCertificateFile /etc/ssl/certs/{{ domainname }}.pem SSLCertificateFile /etc/ssl/certs/{{ site.domain }}.pem
SSLCertificateKeyFile /etc/ssl/private/{{ domainname }}.key SSLCertificateKeyFile /etc/ssl/private/{{ site.domain }}.key
<FilesMatch "\.(cgi|shtml|phtml|php)$"> <FilesMatch "\.(cgi|shtml|phtml|php)$">
SSLOptions +StdEnvVars SSLOptions +StdEnvVars
@ -26,6 +28,36 @@
<Directory /usr/lib/cgi-bin> <Directory /usr/lib/cgi-bin>
SSLOptions +StdEnvVars SSLOptions +StdEnvVars
</Directory> </Directory>
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 %}
</VirtualHost> </VirtualHost>
</IfModule> </IfModule>

View File

@ -1,13 +1,17 @@
# {{ ansible_managed }}
<VirtualHost *:80> <VirtualHost *:80>
ServerName {{ domainname }} ServerName {{ site.domain }}
{% if site.aliases %}
ServerAlias {{ site.aliases | join(' ') }}
{% endif %}
ServerAdmin webmaster@{{ domainname }} ServerAdmin webmaster@{{ site.domain }}
DocumentRoot "{{ docroot }}" DocumentRoot "{{ site.docroot }}"
ErrorLog ${APACHE_LOG_DIR}/{{ domainname }}.error.log ErrorLog ${APACHE_LOG_DIR}/{{ site.domain }}.error.log
CustomLog ${APACHE_LOG_DIR}/{{ domainname }}.access.log combined CustomLog ${APACHE_LOG_DIR}/{{ site.domain }}.access.log combined
#rewrite transparent to https, keep uri #rewrite transparent to https, keep uri
RewriteEngine On RewriteEngine On
RewriteCond %{REQUEST_URI} !^/\.well\-known/acme\-challenge/ RewriteCond %{REQUEST_URI} !^/\.well\-known/acme\-challenge/
RewriteCond %{HTTPS} off RewriteCond %{HTTPS} off

View File

@ -1 +0,0 @@
{{ optionalDirectives | default('')}}