diff --git a/group_vars/all/00_general.yml b/group_vars/all/00_general.yml index 9d547efe..60f7e800 100644 --- a/group_vars/all/00_general.yml +++ b/group_vars/all/00_general.yml @@ -43,11 +43,12 @@ ACTIVATE_ALL_TIMERS: false # Activates all timers, indepe DNS_PROVIDER: cloudflare # The DNS Provider\Registrar for the domain +HOSTING_PROVIDER: hetzner # Provider which hosts the server + # Which ACME method to use: webroot, cloudflare, or hetzner CERTBOT_ACME_CHALLENGE_METHOD: "cloudflare" CERTBOT_CREDENTIALS_DIR: /etc/certbot CERTBOT_CREDENTIALS_FILE: "{{ CERTBOT_CREDENTIALS_DIR }}/{{ CERTBOT_ACME_CHALLENGE_METHOD }}.ini" -CERTBOT_DNS_API_TOKEN: "" # Define in inventory file: More information here: group_vars/all/docs/CLOUDFLARE_API_TOKEN.md CERTBOT_DNS_PROPAGATION_WAIT_SECONDS: 300 # How long should the script wait for DNS propagation before continuing CERTBOT_FLAVOR: san # Possible options: san (recommended, with a dns flavor like cloudflare, or hetzner), wildcard(doesn't function with www redirect), dedicated diff --git a/requirements.yml b/requirements.yml index 66141a5a..22692e1d 100644 --- a/requirements.yml +++ b/requirements.yml @@ -1,5 +1,6 @@ collections: - name: kewlfft.aur - name: community.general + - name: hetzner.hcloud yay: - python-simpleaudio \ No newline at end of file diff --git a/roles/srv-proxy-6-6-domain/tasks/01_cloudflare.yml b/roles/srv-proxy-6-6-domain/tasks/01_cloudflare.yml index 3b6c6b57..2ab338d1 100644 --- a/roles/srv-proxy-6-6-domain/tasks/01_cloudflare.yml +++ b/roles/srv-proxy-6-6-domain/tasks/01_cloudflare.yml @@ -16,7 +16,7 @@ url: "{{ cf_api_url }}?name={{ domain | to_primary_domain }}" method: GET headers: - Authorization: "Bearer {{ CERTBOT_DNS_API_TOKEN }}" + Authorization: "Bearer {{ CLOUDFLARE_API_TOKEN }}" Content-Type: "application/json" return_content: yes register: cf_zone_lookup_dev diff --git a/roles/srv-proxy-6-6-domain/tasks/cloudflare/01_cleanup.yml b/roles/srv-proxy-6-6-domain/tasks/cloudflare/01_cleanup.yml index 9a9ea77b..597eaf12 100644 --- a/roles/srv-proxy-6-6-domain/tasks/cloudflare/01_cleanup.yml +++ b/roles/srv-proxy-6-6-domain/tasks/cloudflare/01_cleanup.yml @@ -3,7 +3,7 @@ url: "https://api.cloudflare.com/client/v4/zones/{{ cf_zone_id }}/purge_cache" method: POST headers: - Authorization: "Bearer {{ CERTBOT_DNS_API_TOKEN }}" + Authorization: "Bearer {{ CLOUDFLARE_API_TOKEN }}" Content-Type: "application/json" body: purge_everything: true diff --git a/roles/srv-proxy-6-6-domain/tasks/cloudflare/02_enable_cf_dev_mode.yml b/roles/srv-proxy-6-6-domain/tasks/cloudflare/02_enable_cf_dev_mode.yml index dbaeb8b1..9f29c72e 100644 --- a/roles/srv-proxy-6-6-domain/tasks/cloudflare/02_enable_cf_dev_mode.yml +++ b/roles/srv-proxy-6-6-domain/tasks/cloudflare/02_enable_cf_dev_mode.yml @@ -1,7 +1,7 @@ # roles/srv-proxy-6-6-domain/tasks/02_enable_cf_dev_mode.yml --- # Enables Cloudflare Development Mode (bypasses cache for ~3 hours). -# Uses the same auth token as in 01_cleanup.yml: CERTBOT_DNS_API_TOKEN +# Uses the same auth token as in 01_cleanup.yml: CLOUDFLARE_API_TOKEN # Assumes `domain` and (optionally) `cf_zone_id` are available. # Safe to run repeatedly; only changes when the mode is not already "on". @@ -10,7 +10,7 @@ url: "https://api.cloudflare.com/client/v4/zones/{{ cf_zone_id }}/settings/development_mode" method: GET headers: - Authorization: "Bearer {{ CERTBOT_DNS_API_TOKEN }}" + Authorization: "Bearer {{ CLOUDFLARE_API_TOKEN }}" Content-Type: "application/json" return_content: yes register: cf_dev_mode_current @@ -21,7 +21,7 @@ url: "https://api.cloudflare.com/client/v4/zones/{{ cf_zone_id }}/settings/development_mode" method: PATCH headers: - Authorization: "Bearer {{ CERTBOT_DNS_API_TOKEN }}" + Authorization: "Bearer {{ CLOUDFLARE_API_TOKEN }}" Content-Type: "application/json" body: value: "on" diff --git a/roles/srv-web-7-7-dns-records/tasks/main.yml b/roles/srv-web-7-7-dns-records/tasks/main.yml index aa562a50..8754d608 100644 --- a/roles/srv-web-7-7-dns-records/tasks/main.yml +++ b/roles/srv-web-7-7-dns-records/tasks/main.yml @@ -2,7 +2,7 @@ - name: Create or update Cloudflare A-record for {{ item }} community.general.cloudflare_dns: - api_token: "{{ CERTBOT_DNS_API_TOKEN }}" + api_token: "{{ CLOUDFLARE_API_TOKEN }}" zone: "{{ item.split('.')[-2:] | join('.') }}" state: present type: A diff --git a/roles/srv-web-7-7-letsencrypt/tasks/01_set-caa-records.yml b/roles/srv-web-7-7-letsencrypt/tasks/01_set-caa-records.yml index 5397e0a5..fd5a5589 100644 --- a/roles/srv-web-7-7-letsencrypt/tasks/01_set-caa-records.yml +++ b/roles/srv-web-7-7-letsencrypt/tasks/01_set-caa-records.yml @@ -1,14 +1,14 @@ --- -- name: "Validate CERTBOT_DNS_API_TOKEN" +- name: "Validate CLOUDFLARE_API_TOKEN" fail: msg: > - The variable "CERTBOT_DNS_API_TOKEN" must be defined and cannot be empty! - when: (CERTBOT_DNS_API_TOKEN | default('') | trim) == '' + The variable "CLOUDFLARE_API_TOKEN" must be defined and cannot be empty! + when: (CLOUDFLARE_API_TOKEN | default('') | trim) == '' - name: "Ensure all CAA records are present" community.general.cloudflare_dns: - api_token: "{{ CERTBOT_DNS_API_TOKEN }}" + api_token: "{{ CLOUDFLARE_API_TOKEN }}" zone: "{{ item.0 }}" record: "@" type: CAA diff --git a/roles/sys-dns-cloudflare-records/README.md b/roles/sys-dns-cloudflare-records/README.md new file mode 100644 index 00000000..9e3aa5b6 --- /dev/null +++ b/roles/sys-dns-cloudflare-records/README.md @@ -0,0 +1,24 @@ +# 🌐 Cloudflare DNS Records + +## Description + +Generic, data-driven role to manage DNS records on Cloudflare (A/AAAA, CNAME, MX, TXT, SRV). +Designed for reuse across apps (e.g., Mailu) and environments. + +## Overview + +This role wraps `community.general.cloudflare_dns` and applies records from a single +structured variable (`cloudflare_records`). It supports async operations and +can be used to provision all required records for a service in one task. + +## Features + +- Data-driven input for multiple record types +- Supports A/AAAA, CNAME, MX, TXT, SRV +- Optional async execution +- Minimal logging of secrets + +## Further Resources + +- [Cloudflare Dashboard → API Tokens](https://dash.cloudflare.com/profile/api-tokens) +- [Ansible Collection: community.general.cloudflare_dns](https://docs.ansible.com/ansible/latest/collections/community/general/cloudflare_dns_module.html) diff --git a/roles/sys-dns-cloudflare-records/defaults/main.yml b/roles/sys-dns-cloudflare-records/defaults/main.yml new file mode 100644 index 00000000..645f074f --- /dev/null +++ b/roles/sys-dns-cloudflare-records/defaults/main.yml @@ -0,0 +1,12 @@ +# Cloudflare API Token +# More information here: group_vars/all/docs/CLOUDFLARE_API_TOKEN.md +CLOUDFLARE_API_TOKEN: "" + +cloudflare_async_enabled: "{{ ASYNC_ENABLED | bool }}" +cloudflare_async_time: "{{ ASYNC_TIME }}" +cloudflare_async_poll: "{{ ASYNC_POLL }}" +cloudflare_no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + +# Supported types: +# A/AAAA (content), CNAME/MX/TXT (value, MX hat priority), SRV (service/proto/…) +cloudflare_records: [] diff --git a/group_vars/all/docs/CLOUDFLARE_API_TOKEN.md b/roles/sys-dns-cloudflare-records/docs/CLOUDFLARE_API_TOKEN.md similarity index 89% rename from group_vars/all/docs/CLOUDFLARE_API_TOKEN.md rename to roles/sys-dns-cloudflare-records/docs/CLOUDFLARE_API_TOKEN.md index 5bfd226c..4f790e25 100644 --- a/group_vars/all/docs/CLOUDFLARE_API_TOKEN.md +++ b/roles/sys-dns-cloudflare-records/docs/CLOUDFLARE_API_TOKEN.md @@ -1,10 +1,10 @@ -# Cloudflare API Token for Ansible (`CERTBOT_DNS_API_TOKEN`) +# Cloudflare API Token for Ansible (`CLOUDFLARE_API_TOKEN`) This document explains how to generate and use a Cloudflare API Token for DNS automation and certificate operations in Ansible (e.g., with Certbot). ## Purpose -The `CERTBOT_DNS_API_TOKEN` variable must contain a valid Cloudflare API Token. +The `CLOUDFLARE_API_TOKEN` variable must contain a valid Cloudflare API Token. This token is used for all DNS operations and ACME (SSL/TLS certificate) challenges that require access to your Cloudflare-managed domains. **Never commit your API token to a public repository. Always keep it secure!** @@ -58,4 +58,4 @@ Add the following permissions: Set the token in your Ansible inventory or secrets file: ```yaml -CERTBOT_DNS_API_TOKEN: "cf_your_generated_token_here" +CLOUDFLARE_API_TOKEN: "cf_your_generated_token_here" diff --git a/roles/sys-dns-cloudflare-records/meta/main.yml b/roles/sys-dns-cloudflare-records/meta/main.yml new file mode 100644 index 00000000..6dab92b4 --- /dev/null +++ b/roles/sys-dns-cloudflare-records/meta/main.yml @@ -0,0 +1,23 @@ +--- +galaxy_info: + author: "Kevin Veen-Birkenbach" + description: "Generic role to manage Cloudflare DNS records (A/AAAA, CNAME, MX, TXT, SRV) in a data-driven way." + license: "Infinito.Nexus NonCommercial License" + license_url: "https://s.infinito.nexus/license" + company: | + Kevin Veen-Birkenbach + Consulting & Coaching Solutions + https://www.veen.world + galaxy_tags: + - cloudflare + - dns + - records + - mail + - automation + repository: "https://s.infinito.nexus/code" + issue_tracker_url: "https://s.infinito.nexus/issues" + documentation: "https://docs.infinito.nexus" + logo: + class: "fa-solid fa-cloud" + run_after: [] +dependencies: [] diff --git a/roles/sys-dns-cloudflare-records/tasks/main.yml b/roles/sys-dns-cloudflare-records/tasks/main.yml new file mode 100644 index 00000000..b7b2df35 --- /dev/null +++ b/roles/sys-dns-cloudflare-records/tasks/main.yml @@ -0,0 +1,105 @@ +--- +# run_once_sys_dns_cloudflare_records: deactivated + +- name: Assert token + ansible.builtin.assert: + that: [ "CLOUDFLARE_API_TOKEN | length > 0" ] + no_log: "{{ cloudflare_no_log | bool }}" + +- name: Apply A/AAAA + community.general.cloudflare_dns: + api_token: "{{ CLOUDFLARE_API_TOKEN }}" + zone: "{{ item.zone }}" + type: "{{ item.type }}" + name: "{{ item.name }}" + content: "{{ item.content }}" + proxied: "{{ item.proxied | default(false) }}" + ttl: "{{ item.ttl | default(1) }}" + state: "{{ item.state | default('present') }}" + loop: "{{ cloudflare_records | selectattr('type','in',['A','AAAA']) | list }}" + loop_control: { label: "{{ item.type }} {{ item.name }} -> {{ item.content }}" } + async: "{{ cloudflare_async_enabled | ternary(cloudflare_async_time, omit) }}" + poll: "{{ cloudflare_async_enabled | ternary(cloudflare_async_poll, omit) }}" + no_log: "{{ cloudflare_no_log | bool }}" + register: _cf_call + failed_when: > + _cf_call is failed and + ( + ('An identical record already exists' not in (_cf_call.msg | default('') | string)) + and + ('81058' not in (_cf_call.msg | default('') | string)) + ) + changed_when: > + (_cf_call.changed | default(false)) and + ( + ('An identical record already exists' not in (_cf_call.msg | default('') | string)) + and + ('81058' not in (_cf_call.msg | default('') | string)) + ) + +- name: Apply CNAME/MX/TXT + community.general.cloudflare_dns: + api_token: "{{ CLOUDFLARE_API_TOKEN }}" + zone: "{{ item.zone }}" + type: "{{ item.type }}" + name: "{{ item.name }}" + value: "{{ item.value }}" + ttl: "{{ item.ttl | default(1) }}" + priority: "{{ (item.type == 'MX') | ternary(item.priority | default(10), omit) }}" + state: "{{ item.state | default('present') }}" + loop: "{{ cloudflare_records | selectattr('type','in',['CNAME','MX','TXT']) | list }}" + loop_control: { label: "{{ item.type }} {{ item.name }} -> {{ item.value }}" } + async: "{{ cloudflare_async_enabled | ternary(cloudflare_async_time, omit) }}" + poll: "{{ cloudflare_async_enabled | ternary(cloudflare_async_poll, omit) }}" + no_log: "{{ cloudflare_no_log | bool }}" + register: _cf_call + failed_when: > + _cf_call is failed and + ( + ('An identical record already exists' not in (_cf_call.msg | default('') | string)) + and + ('81058' not in (_cf_call.msg | default('') | string)) + ) + changed_when: > + (_cf_call.changed | default(false)) and + ( + ('An identical record already exists' not in (_cf_call.msg | default('') | string)) + and + ('81058' not in (_cf_call.msg | default('') | string)) + ) + +- name: Apply SRV + community.general.cloudflare_dns: + api_token: "{{ CLOUDFLARE_API_TOKEN }}" + zone: "{{ item.zone }}" + type: SRV + service: "{{ item.service }}" + proto: "{{ item.proto }}" + name: "{{ item.name }}" + priority: "{{ item.priority }}" + weight: "{{ item.weight }}" + port: "{{ item.port }}" + value: "{{ item.value }}" + ttl: "{{ item.ttl | default(1) }}" + state: "{{ item.state | default('present') }}" + loop: "{{ cloudflare_records | selectattr('type','equalto','SRV') | list }}" + loop_control: { label: "SRV {{ item.service }}.{{ item.proto }} {{ item.name }} -> {{ item.value }}:{{ item.port }}" } + ignore_errors: "{{ item.ignore_errors | default(true) }}" + async: "{{ cloudflare_async_enabled | ternary(cloudflare_async_time, omit) }}" + poll: "{{ cloudflare_async_enabled | ternary(cloudflare_async_poll, omit) }}" + no_log: "{{ cloudflare_no_log | bool }}" + register: _cf_call + failed_when: > + _cf_call is failed and + ( + ('An identical record already exists' not in (_cf_call.msg | default('') | string)) + and + ('81058' not in (_cf_call.msg | default('') | string)) + ) + changed_when: > + (_cf_call.changed | default(false)) and + ( + ('An identical record already exists' not in (_cf_call.msg | default('') | string)) + and + ('81058' not in (_cf_call.msg | default('') | string)) + ) \ No newline at end of file diff --git a/roles/sys-dns-hetzner-rdns/README.md b/roles/sys-dns-hetzner-rdns/README.md new file mode 100644 index 00000000..e69de29b diff --git a/roles/sys-dns-hetzner-rdns/defaults/main.yml b/roles/sys-dns-hetzner-rdns/defaults/main.yml new file mode 100644 index 00000000..8b2d4443 --- /dev/null +++ b/roles/sys-dns-hetzner-rdns/defaults/main.yml @@ -0,0 +1,20 @@ +# Cloud (hcloud) token +# @see https://docs.hetzner.com/cloud/api/getting-started/generating-api-token/ +HETZNER_API_TOKEN: "" + +# Robot (dedicated) credentials +# You can create an user here: https://robot.hetzner.com/preferences/index +HETZNER_ROBOT_USER: "" +HETZNER_ROBOT_PASSWORD: "" + +HETZNER_ROBOT_BASE_URL: "https://robot-ws.your-server.de" + +hetzner_async_enabled: "{{ ASYNC_ENABLED | bool }}" +hetzner_async_time: "{{ ASYNC_TIME }}" +hetzner_async_poll: "{{ ASYNC_POLL }}" +hetzner_no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" +rdns_records: [] +# Example: +# rdns_records: +# - { resource: "server", ip_address: "1.2.3.4", dns_ptr: "mail.example.com" } +# - { resource: "primary_ip", identifier: "12345", ip_address: "2001:db8::1", dns_ptr: "mail.example.com" } diff --git a/roles/sys-dns-hetzner-rdns/meta/main.yml b/roles/sys-dns-hetzner-rdns/meta/main.yml new file mode 100644 index 00000000..71ef2c23 --- /dev/null +++ b/roles/sys-dns-hetzner-rdns/meta/main.yml @@ -0,0 +1,24 @@ + +--- +galaxy_info: + author: "Kevin Veen-Birkenbach" + description: "Generic role to manage reverse DNS (PTR) for Hetzner Cloud resources (server, primary_ip, floating_ip, load_balancer)." + license: "Infinito.Nexus NonCommercial License" + license_url: "https://s.infinito.nexus/license" + company: | + Kevin Veen-Birkenbach + Consulting & Coaching Solutions + https://www.veen.world + galaxy_tags: + - hetzner + - dns + - rdns + - ptr + - mail + repository: "https://s.infinito.nexus/code" + issue_tracker_url: "https://s.infinito.nexus/issues" + documentation: "https://docs.infinito.nexus" + logo: + class: "fa-solid fa-server" + run_after: [] +dependencies: [] diff --git a/roles/sys-dns-hetzner-rdns/tasks/flavors/cloud.yml b/roles/sys-dns-hetzner-rdns/tasks/flavors/cloud.yml new file mode 100644 index 00000000..09414cf3 --- /dev/null +++ b/roles/sys-dns-hetzner-rdns/tasks/flavors/cloud.yml @@ -0,0 +1,93 @@ +--- +# Cloud flavor (hcloud API) +- name: Resolve effective hcloud token + set_fact: + _hz_token: >- + {{ HETZNER_API_TOKEN + | default(lookup('env','HETZNER_API_TOKEN'), true) + | default('', true) + }} + no_log: "{{ hetzner_no_log | bool }}" + +- name: Assert hcloud token present + ansible.builtin.assert: + that: [ "_hz_token | length > 0" ] + fail_msg: "HETZNER_API_TOKEN is required for the Cloud flavor." + no_log: "{{ hetzner_no_log | bool }}" + +- name: Collect hcloud servers if needed (server records without identifier) + hetzner.hcloud.server_info: + api_token: "{{ _hz_token }}" + register: _servers_info + when: rdns_records | selectattr('resource','equalto','server') | selectattr('identifier','undefined') | list | length > 0 + no_log: "{{ hetzner_no_log | bool }}" + +- name: Init normalized records list + set_fact: + _rdns_records: [] + +- name: Normalize records (autofill server.identifier by IPv4) + vars: + _match_name: >- + {{ + (_servers_info.servers | default([])) + | selectattr('public_net.ipv4.ip','equalto', rec.ip_address | default('')) + | map(attribute='name') | list | first | default('') + }} + _needs_autofill: >- + {{ + rec.resource == 'server' + and (rec.identifier is not defined) + and (rec.ip_address | default('') | length > 0) + }} + _normalized: >- + {{ + rec if (not _needs_autofill or _match_name == '') + else (rec | combine({'identifier': _match_name})) + }} + set_fact: + _rdns_records: "{{ _rdns_records + [ _normalized ] }}" + loop: "{{ rdns_records }}" + loop_control: { loop_var: rec } + +- name: Ensure server identifiers are resolved when required + assert: + that: + - > + ( + (_rdns_records | selectattr('resource','equalto','server') | selectattr('identifier','defined') | list | length) + == + (_rdns_records | selectattr('resource','equalto','server') | list | length) + ) + fail_msg: "Could not resolve hcloud server by IPv4 for one or more records." + no_log: "{{ hetzner_no_log | bool }}" + +- name: Validate records (cloud) + ansible.builtin.assert: + that: + - (_rdns_records | default(rdns_records)) | length > 0 + - (_rdns_records | default(rdns_records)) | selectattr('dns_ptr','defined') | list | length == ((_rdns_records | default(rdns_records)) | length) + - (_rdns_records | default(rdns_records)) | selectattr('ip_address','defined') | list | length == ((_rdns_records | default(rdns_records)) | length) + - (_rdns_records | default(rdns_records)) | selectattr('resource','defined') | list | length == ((_rdns_records | default(rdns_records)) | length) + - ( + (_rdns_records | default(rdns_records)) | selectattr('resource','equalto','server') | selectattr('identifier','defined') | list | length + + ((_rdns_records | default(rdns_records)) | rejectattr('resource','equalto','server') | list | length) + ) == ((_rdns_records | default(rdns_records)) | length) + no_log: "{{ hetzner_no_log | bool }}" + +- name: Apply rDNS via hcloud + hetzner.hcloud.hcloud_rdns: + api_token: "{{ _hz_token }}" + server: "{{ (item.resource == 'server') | ternary(item.identifier, omit) }}" + primary_ip: "{{ (item.resource == 'primary_ip') | ternary(item.identifier, omit) }}" + floating_ip: "{{ (item.resource == 'floating_ip') | ternary(item.identifier, omit) }}" + load_balancer: "{{ (item.resource == 'load_balancer') | ternary(item.identifier, omit) }}" + ip_address: "{{ item.ip_address }}" + dns_ptr: "{{ item.dns_ptr }}" + state: present + loop: "{{ _rdns_records | default(rdns_records) }}" + loop_control: + label: "{{ item.resource }}[{{ item.identifier | default('auto-by-ipv4') }}] {{ item.ip_address }} -> {{ item.dns_ptr }}" + async: "{{ hetzner_async_enabled | ternary(hetzner_async_time, omit) }}" + poll: "{{ hetzner_async_enabled | ternary(hetzner_async_poll, omit) }}" + no_log: "{{ hetzner_no_log | bool }}" diff --git a/roles/sys-dns-hetzner-rdns/tasks/flavors/robot.yml b/roles/sys-dns-hetzner-rdns/tasks/flavors/robot.yml new file mode 100644 index 00000000..9115de4f --- /dev/null +++ b/roles/sys-dns-hetzner-rdns/tasks/flavors/robot.yml @@ -0,0 +1,42 @@ +--- +# Robot flavor (Robot Webservice API) +- name: Assert Robot credentials present + ansible.builtin.assert: + that: + - (HETZNER_ROBOT_USER | default('') | length) > 0 + - (HETZNER_ROBOT_PASSWORD | default('') | length) > 0 + fail_msg: "Robot credentials required: HETZNER_ROBOT_USER / HETZNER_ROBOT_PASSWORD." + no_log: "{{ hetzner_no_log | bool }}" + +- name: Validate records (robot) + ansible.builtin.assert: + that: + - rdns_records | length > 0 + - (rdns_records | selectattr('ip_address','defined') | list | length) == (rdns_records | length) + - (rdns_records | selectattr('dns_ptr','defined') | list | length) == (rdns_records | length) + fail_msg: "Each record must have ip_address and dns_ptr for Robot rDNS." + no_log: "{{ hetzner_no_log | bool }}" + +- name: Apply rDNS via Hetzner Robot API + vars: + hetzner_robot_base_url: "{{ HETZNER_ROBOT_BASE_URL }}" + ip_path: "{{ item.ip_address | urlencode }}" + ansible.builtin.uri: + url: "{{ hetzner_robot_base_url }}/rdns/{{ ip_path }}" + method: POST + user: "{{ HETZNER_ROBOT_USER }}" + password: "{{ HETZNER_ROBOT_PASSWORD }}" + force_basic_auth: true + headers: + Accept: application/json + body_format: form-urlencoded + body: + ptr: "{{ item.dns_ptr }}" + status_code: [200, 201] + loop: "{{ rdns_records }}" + loop_control: + label: "{{ item.ip_address }} -> {{ item.dns_ptr }}" + async: "{{ hetzner_async_enabled | ternary(hetzner_async_time, omit) }}" + poll: "{{ hetzner_async_enabled | ternary(hetzner_async_poll, omit) }}" + no_log: "{{ hetzner_no_log | bool }}" + diff --git a/roles/sys-dns-hetzner-rdns/tasks/main.yml b/roles/sys-dns-hetzner-rdns/tasks/main.yml new file mode 100644 index 00000000..6774ff9d --- /dev/null +++ b/roles/sys-dns-hetzner-rdns/tasks/main.yml @@ -0,0 +1,33 @@ +# run_once_sys_dns_hetzner_rdns + +# Decide flavor +- name: Decide which Hetzner flavor to use + set_fact: + _use_cloud: "{{ (HETZNER_API_TOKEN | length) > 0 }}" + _use_robot: >- + {{ + (HETZNER_ROBOT_USER | length) > 0 + and (HETZNER_ROBOT_PASSWORD | length) > 0 + }} + no_log: "{{ hetzner_no_log | bool }}" + +- name: "Note: both Cloud token and Robot creds provided; using Cloud flavor" + debug: + msg: "Both HETZNER_API_TOKEN and Robot credentials present → proceeding with Cloud (hcloud) flavor." + when: _use_cloud and _use_robot + +- name: Include Cloud flavor (hcloud) + include_tasks: flavors/cloud.yml + when: _use_cloud + +- name: Include Robot flavor (Robot Webservice) + include_tasks: flavors/robot.yml + when: (not _use_cloud) and _use_robot + +- name: Fail if no credentials provided + fail: + msg: >- + Neither Cloud nor Robot credentials provided. + Set either HETZNER_API_TOKEN for Cloud (hcloud) or + HETZNER_ROBOT_USER/HETZNER_ROBOT_PASSWORD for Robot. + when: (not _use_cloud) and (not _use_robot) diff --git a/roles/sys-svc-certbot/tasks/02_no_webroot.yml b/roles/sys-svc-certbot/tasks/02_no_webroot.yml index ba7a3d54..92db6fa2 100644 --- a/roles/sys-svc-certbot/tasks/02_no_webroot.yml +++ b/roles/sys-svc-certbot/tasks/02_no_webroot.yml @@ -15,7 +15,7 @@ copy: dest: "{{ CERTBOT_CREDENTIALS_FILE }}" content: | - dns_{{ CERTBOT_ACME_CHALLENGE_METHOD }}_api_token = {{ CERTBOT_DNS_API_TOKEN }} + dns_{{ CERTBOT_ACME_CHALLENGE_METHOD }}_api_token = {{ CLOUDFLARE_API_TOKEN }} owner: root group: root mode: '0600' \ No newline at end of file diff --git a/roles/web-app-gitea/tasks/main.yml b/roles/web-app-gitea/tasks/main.yml index 0fe3228b..46e58eed 100644 --- a/roles/web-app-gitea/tasks/main.yml +++ b/roles/web-app-gitea/tasks/main.yml @@ -60,7 +60,7 @@ include_role: name: srv-web-7-7-dns-records vars: - cloudflare_api_token: "{{ CERTBOT_DNS_API_TOKEN }}" + CLOUDFLARE_API_TOKEN: "{{ CLOUDFLARE_API_TOKEN }}" cloudflare_domains: "{{ [ domains | get_domain(application_id) ] }}" cloudflare_target_ip: "{{ networks.internet.ip4 }}" cloudflare_proxied: false diff --git a/roles/web-app-mailu/tasks/01_core.yml b/roles/web-app-mailu/tasks/01_core.yml index 9ee43e0c..8569dbb2 100644 --- a/roles/web-app-mailu/tasks/01_core.yml +++ b/roles/web-app-mailu/tasks/01_core.yml @@ -25,7 +25,7 @@ meta: flush_handlers - name: "Create Mailu accounts" - include_tasks: 02_create-mailu-user.yml + include_tasks: 02_create-user.yml vars: MAILU_DOCKER_DIR: "{{ docker_compose.directories.instance }}" mailu_api_base_url: "http://127.0.0.1:8080/api/v1" @@ -45,6 +45,8 @@ loop_var: item no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" +- name: Generate DKIM public key + include_tasks: 04_generate-and-read-dkim.yml + - name: Set Mailu DNS records - include_tasks: 04_set-mailu-dns-records.yml - when: DNS_PROVIDER == 'cloudflare' \ No newline at end of file + include_tasks: 05_dns-records.yml diff --git a/roles/web-app-mailu/tasks/02_create-mailu-user.yml b/roles/web-app-mailu/tasks/02_create-user.yml similarity index 96% rename from roles/web-app-mailu/tasks/02_create-mailu-user.yml rename to roles/web-app-mailu/tasks/02_create-user.yml index 19a16315..90dfcdac 100644 --- a/roles/web-app-mailu/tasks/02_create-mailu-user.yml +++ b/roles/web-app-mailu/tasks/02_create-user.yml @@ -25,5 +25,5 @@ no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - name: "Create Mailu API Token for {{ mailu_user_name }}" - include_tasks: 03_create-mailu-token.yml + include_tasks: 03_create-token.yml when: "{{ 'mail-bot' in item.value.roles }}" \ No newline at end of file diff --git a/roles/web-app-mailu/tasks/03_create-mailu-token.yml b/roles/web-app-mailu/tasks/03_create-token.yml similarity index 100% rename from roles/web-app-mailu/tasks/03_create-mailu-token.yml rename to roles/web-app-mailu/tasks/03_create-token.yml diff --git a/roles/web-app-mailu/tasks/05_generate-and-read-dkim.yml b/roles/web-app-mailu/tasks/04_generate-and-read-dkim.yml similarity index 100% rename from roles/web-app-mailu/tasks/05_generate-and-read-dkim.yml rename to roles/web-app-mailu/tasks/04_generate-and-read-dkim.yml diff --git a/roles/web-app-mailu/tasks/04_set-mailu-dns-records.yml b/roles/web-app-mailu/tasks/04_set-mailu-dns-records.yml deleted file mode 100644 index 96649061..00000000 --- a/roles/web-app-mailu/tasks/04_set-mailu-dns-records.yml +++ /dev/null @@ -1,125 +0,0 @@ -- name: Generate DKIM public key - include_tasks: 05_generate-and-read-dkim.yml - -# A/AAAA record for the mail host in the **Hostname Zone** -- name: "Set A record for Mailu host" - community.general.cloudflare_dns: - api_token: "{{ MAILU_CLOUDFLARE_API_TOKEN }}" - zone: "{{ MAILU_HOSTNAME_DNS_ZONE }}" - type: A - name: "{{ MAILU_HOSTNAME }}" # Fully Qualified Domain Name of the mail host - content: "{{ MAILU_IP4_PUBLIC }}" - proxied: false - ttl: 1 - state: present - async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - -- name: "Set AAAA record for Mailu host" - community.general.cloudflare_dns: - api_token: "{{ MAILU_CLOUDFLARE_API_TOKEN }}" - zone: "{{ MAILU_HOSTNAME_DNS_ZONE }}" - type: AAAA - name: "{{ MAILU_HOSTNAME }}" - content: "{{ MAILU_IP6_PUBLIC }}" - proxied: false - ttl: 1 - state: present - when: MAILU_IP6_PUBLIC is defined and MAILU_IP6_PUBLIC | length > 0 - async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - -# Autoconfig CNAME record in the **Mail Domain Zone** -- name: "Set CNAME record for autoconfig" - community.general.cloudflare_dns: - api_token: "{{ MAILU_CLOUDFLARE_API_TOKEN }}" - zone: "{{ MAILU_DOMAIN_DNS_ZONE }}" - type: CNAME - name: "autoconfig.{{ MAILU_DOMAIN_DNS_ZONE }}" - value: "{{ MAILU_HOSTNAME }}" # Points to the Mailu host FQDN - proxied: false - ttl: 1 - state: present - async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - -# MX record in the **Mail Domain Zone** -- name: "Set MX record" - community.general.cloudflare_dns: - api_token: "{{ MAILU_CLOUDFLARE_API_TOKEN }}" - zone: "{{ MAILU_DOMAIN_DNS_ZONE }}" - type: MX - name: "{{ MAILU_DOMAIN }}" # Root mail domain - value: "{{ MAILU_HOSTNAME }}" # Points to the Mailu host - priority: 10 - ttl: 1 - state: present - async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - -# SRV records in the **Mail Domain Zone** -- name: "Set SRV records" - community.general.cloudflare_dns: - api_token: "{{ MAILU_CLOUDFLARE_API_TOKEN }}" - zone: "{{ MAILU_DOMAIN_DNS_ZONE }}" - type: SRV - service: "_{{ item.key }}" - proto: "_tcp" - priority: "{{ item.value.priority }}" - weight: "{{ item.value.weight }}" - port: "{{ item.value.port }}" - value: "{{ MAILU_HOSTNAME }}" # Target = Mailu host FQDN - ttl: 1 - state: present - name: "{{ MAILU_DOMAIN }}" - loop: "{{ MAILU_DNS_SRV_RECORDS | dict2items }}" - ignore_errors: true - async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - -# SPF TXT record in the **Mail Domain Zone** -- name: "Set SPF TXT record" - community.general.cloudflare_dns: - api_token: "{{ MAILU_CLOUDFLARE_API_TOKEN }}" - zone: "{{ MAILU_DOMAIN_DNS_ZONE }}" - type: TXT - name: "{{ MAILU_DOMAIN }}" - value: "v=spf1 mx a:{{ MAILU_HOSTNAME }} ~all" - ttl: 1 - state: present - async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - -# DMARC TXT record in the **Mail Domain Zone** -- name: "Set DMARC TXT record" - community.general.cloudflare_dns: - api_token: "{{ MAILU_CLOUDFLARE_API_TOKEN }}" - zone: "{{ MAILU_DOMAIN_DNS_ZONE }}" - type: TXT - name: "_dmarc.{{ MAILU_DOMAIN_DNS_ZONE }}" - value: "v=DMARC1; p=reject; ruf=mailto:{{ MAILU_DMARC_RUF }}; adkim=s; aspf=s" - ttl: 1 - state: present - async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - -# DKIM TXT record in the **Mail Domain Zone** -- name: "Set DKIM TXT record" - community.general.cloudflare_dns: - api_token: "{{ MAILU_CLOUDFLARE_API_TOKEN }}" - zone: "{{ MAILU_DOMAIN_DNS_ZONE }}" - type: TXT - name: "dkim._domainkey.{{ MAILU_DOMAIN_DNS_ZONE }}" - value: "{{ mailu_dkim_public_key }}" - ttl: 1 - state: present - async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" diff --git a/roles/web-app-mailu/tasks/05_dns-records.yml b/roles/web-app-mailu/tasks/05_dns-records.yml new file mode 100644 index 00000000..2684af52 --- /dev/null +++ b/roles/web-app-mailu/tasks/05_dns-records.yml @@ -0,0 +1,33 @@ +- name: "DNS (Cloudflare) for Mailu" + include_role: + name: sys-dns-cloudflare-records + when: DNS_PROVIDER | lower == 'cloudflare' + vars: + cloudflare_async_enabled: "{{ ASYNC_ENABLED | default(false) | bool }}" + cloudflare_async_time: "{{ ASYNC_TIME | default(45) }}" + cloudflare_async_poll: "{{ ASYNC_POLL | default(5) }}" + cloudflare_no_log: "{{ MASK_CREDENTIALS_IN_LOGS | default(true) | bool }}" + cloudflare_records: + - { type: A, zone: "{{ MAILU_HOSTNAME_DNS_ZONE }}", name: "{{ MAILU_HOSTNAME }}", content: "{{ MAILU_IP4_PUBLIC }}", proxied: false } +# - { type: AAAA, zone: "{{ MAILU_HOSTNAME_DNS_ZONE }}", name: "{{ MAILU_HOSTNAME }}", content: "{{ MAILU_IP6_PUBLIC }}", proxied: false } + - { type: CNAME, zone: "{{ MAILU_DOMAIN_DNS_ZONE }}", name: "autoconfig.{{ MAILU_DOMAIN_DNS_ZONE }}", value: "{{ MAILU_HOSTNAME }}" } + - { type: MX, zone: "{{ MAILU_DOMAIN_DNS_ZONE }}", name: "{{ MAILU_DOMAIN }}", value: "{{ MAILU_HOSTNAME }}", priority: 10 } + - { type: TXT, zone: "{{ MAILU_DOMAIN_DNS_ZONE }}", name: "{{ MAILU_DOMAIN }}", value: "v=spf1 mx a:{{ MAILU_HOSTNAME }} ~all" } + - { type: TXT, zone: "{{ MAILU_DOMAIN_DNS_ZONE }}", name: "_dmarc.{{ MAILU_DOMAIN_DNS_ZONE }}", value: "v=DMARC1; p=reject; ruf=mailto:{{ MAILU_DMARC_RUF }}; adkim=s; aspf=s" } + - { type: TXT, zone: "{{ MAILU_DOMAIN_DNS_ZONE }}", name: "dkim._domainkey.{{ MAILU_DOMAIN_DNS_ZONE }}", value: "{{ mailu_dkim_public_key }}" } + - { type: SRV, zone: "{{ MAILU_DOMAIN_DNS_ZONE }}", service: "_submission", proto: "_tcp", name: "{{ MAILU_DOMAIN }}", priority: 20, weight: 1, port: 587, value: "{{ MAILU_HOSTNAME }}" } + - { type: SRV, zone: "{{ MAILU_DOMAIN_DNS_ZONE }}", service: "_submissions", proto: "_tcp", name: "{{ MAILU_DOMAIN }}", priority: 20, weight: 1, port: 465, value: "{{ MAILU_HOSTNAME }}" } + - { type: SRV, zone: "{{ MAILU_DOMAIN_DNS_ZONE }}", service: "_imaps", proto: "_tcp", name: "{{ MAILU_DOMAIN }}", priority: 20, weight: 1, port: 993, value: "{{ MAILU_HOSTNAME }}" } + - { type: SRV, zone: "{{ MAILU_DOMAIN_DNS_ZONE }}", service: "_imap", proto: "_tcp", name: "{{ MAILU_DOMAIN }}", priority: 20, weight: 1, port: 143, value: "{{ MAILU_HOSTNAME }}" } + - { type: SRV, zone: "{{ MAILU_DOMAIN_DNS_ZONE }}", service: "_pop3s", proto: "_tcp", name: "{{ MAILU_DOMAIN }}", priority: 20, weight: 1, port: 995, value: "{{ MAILU_HOSTNAME }}" } + - { type: SRV, zone: "{{ MAILU_DOMAIN_DNS_ZONE }}", service: "_pop3", proto: "_tcp", name: "{{ MAILU_DOMAIN }}", priority: 20, weight: 1, port: 110, value: "{{ MAILU_HOSTNAME }}" } + - { type: SRV, zone: "{{ MAILU_DOMAIN_DNS_ZONE }}", service: "_autodiscover", proto: "_tcp", name: "{{ MAILU_DOMAIN }}", priority: 20, weight: 1, port: 443, value: "{{ MAILU_HOSTNAME }}" } + +- name: "rDNS (Hetzner Cloud) for Mailu" + include_role: + name: sys-dns-hetzner-rdns + when: HOSTING_PROVIDER | lower == 'hetzner' + vars: + rdns_records: + - { resource: "server", ip_address: "{{ MAILU_IP4_PUBLIC }}", dns_ptr: "{{ MAILU_HOSTNAME }}" } +# - { resource: "server", ip_address: "{{ MAILU_IP6_PUBLIC | default('') }}", dns_ptr: "{{ MAILU_HOSTNAME }}" } \ No newline at end of file diff --git a/roles/web-app-mailu/vars/main.yml b/roles/web-app-mailu/vars/main.yml index bedfe80c..b1dd0f7e 100644 --- a/roles/web-app-mailu/vars/main.yml +++ b/roles/web-app-mailu/vars/main.yml @@ -43,7 +43,6 @@ MAILU_SUBNET: "{{ networks.local['web-app-mailu'].subnet ## Credentials MAILU_SECRET_KEY: "{{ applications | get_app_conf(application_id,'credentials.secret_key') }}" -MAILU_CLOUDFLARE_API_TOKEN: "{{ CERTBOT_DNS_API_TOKEN }}" MAILU_API_TOKEN: "{{ applications | get_app_conf(application_id, 'credentials.api_token') }}" ## OIDC @@ -55,16 +54,7 @@ MAILU_OIDC_ENABLE_USER_CREATION: "{{ applications | get_app_conf(applicatio # @see https://github.com/heviat/Mailu-OIDC/tree/2024.06 MAILU_DOCKER_FLAVOR: "{{ 'ghcr.io/heviat' if MAILU_OIDC_ENABLED | bool else 'ghcr.io/mailu' }}" -MAILU_DMARC_RUF: "{{ applications | get_app_conf(application_id, 'users.administrator.email') }}" +MAILU_DMARC_RUF: "{{ applications | get_app_conf(application_id, 'users.administrator.email') }}" -MAILU_DKIM_KEY_FILE: "{{ MAILU_DOMAIN }}.dkim.key" -MAILU_DKIM_KEY_PATH: "/dkim/{{ MAILU_DKIM_KEY_FILE }}" - -MAILU_DNS_SRV_RECORDS: - submission: { port: 587, priority: 20, weight: 1 } - submissions: { port: 465, priority: 20, weight: 1 } - imaps: { port: 993, priority: 20, weight: 1 } - imap: { port: 143, priority: 20, weight: 1 } - pop3s: { port: 995, priority: 20, weight: 1 } - pop3: { port: 110, priority: 20, weight: 1 } - autodiscover: { port: 443, priority: 20, weight: 1 } \ No newline at end of file +MAILU_DKIM_KEY_FILE: "{{ MAILU_DOMAIN }}.dkim.key" +MAILU_DKIM_KEY_PATH: "/dkim/{{ MAILU_DKIM_KEY_FILE }}" diff --git a/roles/web-opt-rdr-www/tasks/main.yml b/roles/web-opt-rdr-www/tasks/main.yml index 8aa61cd5..8d4f2c3d 100644 --- a/roles/web-opt-rdr-www/tasks/main.yml +++ b/roles/web-opt-rdr-www/tasks/main.yml @@ -20,7 +20,7 @@ include_role: name: srv-web-7-7-dns-records vars: - cloudflare_api_token: "{{ CERTBOT_DNS_API_TOKEN }}" + CLOUDFLARE_API_TOKEN: "{{ CLOUDFLARE_API_TOKEN }}" cloudflare_domains: "{{ www_domains }}" cloudflare_target_ip: "{{ networks.internet.ip4 }}" cloudflare_proxied: false