feat(acmed): initial role

This commit is contained in:
Evelyn Alicke 2024-01-16 13:40:30 +01:00
parent 8e8f496df6
commit ae269c4332
No known key found for this signature in database
GPG key ID: 6834780BDA479436
16 changed files with 567 additions and 0 deletions

104
roles/acmed/README.md Normal file
View file

@ -0,0 +1,104 @@
# famedly.base.acmed
a role for [`acmed`](https://github.com/beard-r/acmed) using our own [container image](https://github.com/famedly/container-image-acmed)
it provides 2 extra hooks configured by default: <br>
[`acmed-hook-rfc2136`](https://github.com/famedly/acmed-hook-rfc2136/) for dynamic dns updates <br>
[`acmed-hook-ssh`](https://github.com/famedly/acmed-hook-ssh/) for certificate distribution via ssh <br>
## Configuration
### Accounts
|key|default|required|
|-|-|-|
|`acmed_account_name`|*unset*|yes|
|`acmed_account_mailto`|*unset*|yes|
|`acmed_accounts`|derived from above| yes|
|`acmed_default_account`|`{{ acmed_accounts[0]['name'] }}`| no |
if you need more than a signle account you can define them like shown below.
``` yaml
acmed_accounts:
- "{{ acmed_derived_account }}"
- name: "meow awoo"
contacts:
- "meow@example.org"
```
---
### Endpoints
`acmed_endpoints` will include Let's Encrypt by default like shown below. <br>
`acmed_default_endpoint` will default to `"le_prod"`.
when using a different default provider, it is recommended to define it like shown below.
``` yaml
your_custom_provider:
name: "your custom provider"
url: "https://acme.example.org/directory"
tos_agreed: true
renew_delay: 8w
random_early_renew: 1w
acmed_default_endpoint: "{{ your_custom_provider.name }}"
acmed_endpoints:
- "{{ acmed_endpoint_le_prod }}"
- "{{ acmed_endpoint_le_staging }}"
- "{{ your_custom_endpoint }}"
```
---
### Hooks
this role comes with `acmed_hook_ssh` and `acmed_hook_rfc2136` by default.
the ssh hook is pretty simple to configure, it requires just one key <br>
```yaml
acmed_hook_ssh_user: "meow"
```
the dynamic dns updates hook is a bit more involved,
it requires all the keys shown in `your_zone` below.
```yaml
your_zone:
name: "acme.example.org." # Don't forget the trailing dot!!!
primary_ns: "ns0.example.org"
tsig_name: "my-tsig-name"
tsig_key: "" # base64 encoded key, standard alphabet, padded
tsig_algorythm: "hmac-sha256"
acmed_hook_rfc2136_resolver: "1.1.1.1"
acmed_hook_rfc2136_zones:
- "{{ your_zone }}"
```
### Certificate
call `tasks/configure_cert.yml` with the content in `acmed_certificate`
```yaml
acmed_certificate:
# REQUIRED
identifiers:
- dns: "my_host.example.org"
challenge: "dns-01"
- dns: "some_service.example.org"
challenge: "dns-01"
# REQUIRED - with defaults
account: "meow awoo" # `acmed_default_account`
endpoint: "le_staging" # `acmed_default_endpoint`
hooks: ["dns-01-rfc-2136", "ssh-send"] # `acmed_default_hooks`
# OPTIONAL - derived from endpoint or global
random_early_renew: "1d"
renew_delay: "4w"
state: "present"
# OPTIONAL
env:
HOOK_SSH_USER: "root"
subject_attributes:
country_name: DE
organization_name: famedly
organization_unit_name: infra
```

View file

@ -0,0 +1,91 @@
---
acmed_base_path: "/opt/acmed"
acmed_user: "acmed"
acmed_version: "v0.22.2-r1"
acmed_accounts: "{{ [ acmed_derived_account ] }}"
acmed_derived_account:
name: "{{ acmed_account_name }}"
contacts:
- "{{ acmed_account_mailto }}"
acmed_default_account: "{{ acmed_acconts[0]['name'] }}"
acmed_default_endpoint: "le_prod"
acmed_default_hooks: "{{ acmed_pre_packaged_hooks }}"
acmed_pre_packaged_hooks: "{{ ['dns-01-rfc2136', 'ssh-send'] }}"
acmed_allowed_hooks: "{{ acmed_hooks | map(attribute=name) | combine(acmed_pre_packaged_hooks) }}"
acmed_default_cert:
endpoint: "{{ acmed_default_endpoint }}"
account: "{{ acmed_default_account }}"
hooks: "{{ acmed_default_hooks }}"
acmed_cert_file_group: "{{ acmed_user_registered.group }}"
acmed_cert_file_user: "{{ acmed_user_registered.uid }}"
acmed_cert_file_mode: "0644"
acmed_pk_file_group: "{{ acmed_user_registered.group }}"
acmed_pk_file_user: "{{ acmed_user_registered.uid }}"
acmed_pk_file_mode: "0640"
acmed_global_env:
HOOK_SSH_USER: "{{ acmed_hook_ssh_user }}"
HOOK_RFC2136_CONFIG_FILE: "{{ acmed_hook_rfc2136_config_file }}"
acmed_endpoints: "{{ [ acmed_endpoint_le_prod, acmed_endpoint_le_staging] }}"
acmed_endpoint_le_prod:
name: "le_prod"
url: "https://acme-v02.api.letsencrypt.org/directory"
tos_agreed: true
renew_delay: "4w"
random_early_renew: "2w"
acmed_endpoint_le_staging:
name: "le_staging"
url: "https://acme-staging-v02.api.letsencrypt.org/directory"
tos_agreed: true
renew_delay: "1w"
random_early_renew: "5d"
acmed_config_path: "{{ acmed_base_path }}/config"
acmed_state_path: "{{ acmed_base_path }}/state"
acmed_state_account_directory: "{{ acmed_state_path }}/accounts"
acmed_state_certificates_directory: "{{ acmed_state_path }}/certificates"
acmed_config_file: "{{ acmed_config_path }}/acmed.toml"
acmed_config_global_file: "{{ acmed_config_path }}/global.toml"
acmed_config_endpoints_file: "{{ acmed_config_path }}/endpoints.toml"
acmed_config_accounts_file: "{{ acmed_config_path }}/acccounts.toml"
acmed_config_rate_limits_file: "{{ acmed_config_path }}/rate_limits.toml"
acmed_config_hooks_file: "{{ acmed_config_path }}/hooks.toml"
acmed_config_certificates_directory: "{{ acmed_config_path }}/certificates"
acmed_hook_rfc2136_config_file: "{{ acmed_config_path }}/acmed_hook_rfc2136.toml"
acmed_container_name: "acmed"
acmed_container_env: {}
acmed_container_ports: []
acmed_container_labels: {}
acmed_container_volumes: []
acmed_container_restart_policy: "unless-stopped"
acmed_container_force_pull: false
acmed_container_image_reference: >-
{{
acmed_container_image_repository
+ ":"
+ acmed_container_image_tag | default(acmed_version)
}}
acmed_container_image_repository: >-
{{
(
container_registries[acmed_container_image_registry]
| default(acmed_container_image_registry)
)
+ "/"
+ acmed_container_image_namespace | default("")
+ acmed_container_image_name
}}
acmed_container_image_registry: "docker-oss.nexus.famedly.de"
acmed_container_image_name: "acmed"

View file

@ -0,0 +1,8 @@
---
- name: Restart acmed container
listen: container-acmed-restart
community.docker.docker_container:
name: "{{ acmed_container_name }}"
state: started
restart: true

View file

@ -0,0 +1,45 @@
---
- name: "Configure acmed global defaults"
ansible.builtin.template:
src: global.toml.j2
dest: "{{ acmed_config_global_file }}"
mode: "0640"
- name: "Configure acme accounts"
ansible.builtin.template:
src: accounts.toml.j2
dest: "{{ acmed_config_accounts_file }}"
mode: "0640"
- name: "Configure acme providers (endpoints)"
ansible.builtin.template:
src: endpoints.toml.j2
dest: "{{ acmed_config_endpoints_file }}"
mode: "0640"
- name: "Configure rate limits"
ansible.builtin.template:
src: rate_limits.toml.j2
dest: "{{ acmed_config_rate_limits_file }}"
mode: "0640"
when: acmed_rate_limits is defined
- name: "Configure hooks"
ansible.builtin.template:
src: hooks.toml.j2
dest: "{{ acmed_config_hooks_file }}"
mode: "0640"
when: acmed_hooks is defined
- name: "Import all other configuration files"
ansible.builtin.template:
src: glue.toml.j2
dest: "{{ acmed_config_file }}"
mode: "0640"
- name: "Configure acmed-hook-rfc2136"
ansible.builtin.template:
src: acmed-hook-rfc2136.toml.j2
dest: "{{ acmed_hook_rfc2136_config_file }}"
mode: "0640"

View file

@ -0,0 +1,38 @@
---
- name: "Ensure certificate is configured"
vars:
cert: >-2
{{
acmed_certificate |
ansible.builtin.combine(
acmed_default_cert,
list_merge=keep,
recursive=true,
)
}}
cert_path: "{{ acmed_certificate.path | default(acmed_certificate.identifiers[0].dns + '.toml') }}"
cert_state: "{{ cert.state | default("present") }}"
notify:
- acmed_reload_config
block:
- name: "Assert endpoint and account are known and configured"
ansible.builtin.assert:
that:
- "{{ cert.endpoint in acmed_endpoints | map(attribute='name') }}"
- "{{ cert.account in acmed_accounts | map(attribute='name') }}"
- "{{ cert.hooks in acmed_allowed_hooks }}"
when: cert_state == "present"
- name: "Configure acme cert"
ansible.builtin.template:
src: certificate.toml.j2
dest: "{{ cert_path }}"
mode: "0640"
when: cert_state == "present"
- name: "Remove acme cert"
ansible.builtin.file:
path: "{{ cert_path }}"
state: absent
when: cert_state == "absent"

View file

@ -0,0 +1,30 @@
- name: "Ensure acmed receive user is present"
ansible.builtin.user:
name: "{{ acmed_receive_user }}"
groups:
- "{{ acmed_receive_group }}"
state: "present"
system: true
register: "acmed_receive_user_registered"
tags: ["prepare", "prepare-acmed-receive"]
- name: "Ensure certificate directory is present"
ansible.builtin.file:
path: "{{ acmed_receive_path }}"
state: "directory"
owner: "{{ acmed_receive_user_registered.uid }}"
group: "{{ acmed_receive_group }}"
mode: "0755"
tags: ["prepare", "prepare-acmed-receive"]
- name: "Configure receive side of acmed-hook-ssh"
ansible.builtin.template:
src:
dest:
mode:
vars:
config: "{{ acmed_receive_config }}"
tags: ["prepare", "prepare-acmed-receive"]
- name: Install Package

View file

@ -0,0 +1,67 @@
---
- name: "Ensure acmed user is created"
ansible.builtin.user:
name: "{{ acmed_user }}"
state: "present"
system: true
register: "acmed_user_registered"
tags: ["prepare", "prepare-acmed"]
- name: "Ensure base paths for acmed are created"
ansible.builtin.file:
path: "{{ item }}"
state: "directory"
owner: "{{ acmed_user_registered.uid }}"
group: "{{ acmed_user_registered.group }}"
mode: "0755"
loop:
- "{{ acmed_base_path }}"
- "{{ acmed_config_path }}"
- "{{ acmed_config_certificates_directory }}"
tags: ["prepare", "prepare-acmed"]
- name: "Ensure state paths for acmed are created"
ansible.builtin.file:
path: "{{ item }}"
state: "directory"
owner: "{{ acmed_user_registered.uid }}"
group: "{{ acmed_user_registered.group }}"
mode: "0750"
loop:
- "{{ acmed_state_path }}"
- "{{ acmed_state_account_directory }}"
- "{{ acmed_state_certificates_directory }}"
tags: ["prepare", "prepare-acmed"]
- name: Configure acmed
ansible.builtin.import_tasks: "configure.yml"
tags: ["deploy", "deploy-acmed"]
- name: Ensure container image is present locally
community.docker.docker_image:
name: "{{ acmed_container_image_reference }}"
source: "pull"
state: "present"
force_source: "{{ acmed_container_force_pull }}"
register: acmed_container_image_pulled
until: acmed_container_image_pulled is success
retries: 10
delay: 5
tags: ["deploy", "deploy-acmed"]
- name: Ensure container is started
community.docker.docker_container:
image: "{{ acmed_container_image_reference }}"
name: "{{ acmed_container_name }}"
state: "started"
restart_policy: "{{ acmed_container_restart_policy | default(omit) }}"
user: "{{ acmed_user_registered.uid ~ ':' ~ acmed_user_registered.group }}"
volumes: "{{ acmed_container_volumes_merged }}"
ports: "{{ acmed_container_ports }}"
env: "{{ acmed_container_env | default(omit) }}"
labels: "{{ acmed_container_labels_merged | default(omit) }}"
command: "{{ acmed_container_command | default(omit) }}"
etc_hosts: "{{ acmed_container_etc_hosts | default(omit) }}"
networks: "{{ acmed_container_networks | default(omit) }}"
purge_networks: "{{ acmed_container_purge_networks | default(omit) }}"
tags: ["deploy", "deploy-acmed"]

View file

@ -0,0 +1,19 @@
{% for account in acmed_accounts %}
[[account]]
name = "{{ account.name }}"
contacts = [
{% for mailto in account.contacts%}
{ mailto = "{{ mailto }}" },
{% endfor -%}
]
{% if account.env is defined %}
env = "{{ account.env }}"
{% endif -%}
{% if account.signature_algorithm is defined %}
signature_algorithm = "{{ account.signature_algorithm }}"
{% endif -%}
{% if account.key_type is defined %}
key_type = "{{ account.key_type }}"
{% endif -%}
{% endfor -%}

View file

@ -0,0 +1,10 @@
resolver = "{{ acmed_hook_rfc2136_resolver }}"
{% for zone in acmed_hook_rfc2136_zones %}
[zones."{{ zone.name }}"]
primary_ns = "{{ zone.primary_ns }}"
tsig_name = "{{ zone.tsig_name }}"
tsig_key = "{{ zone.tsig_key }}"
tsig_algorithm = "{{ zone.tsig_algorithm }}"
{% endfor -%}

View file

@ -0,0 +1,54 @@
[[certificate]]
account = "{{ cert.account }}"
endpoint = "{{ cert.endpoint }}"
hooks = [
{% for hook in cert.hooks %}
"{{ hook }}",
{% endfor -%}
]
{% if cert.csr_digest is defined %}
csr_digest = "{{ cert.csr_digest }}"
{% endif -%}
{% if cert.directory is defined %}
directory = "{{ cert.directory }}"
{% endif -%}
{% if cert.env is defined %}
env = {
{% for e in certs.env | ansible.builtin.dict2items %}
"{{ e.key }}" = "{{ e.value }}",
{% endfor -%}
}
{% endif -%}
identifiers = [
{% for id in cert.identifiers %}
{
challenge = "{{ id.challenge }}"
{% if id.dns is defined %}
dns = "{{ id.dns }}"
{% endif -%}
{% if id.ip is defined %}
ip = "{{ id.ip }}"
{% endif -%}
{% if id.env is defined %}
env = "{{ id.env }}"
{% endif -%}
},
{% endfor -%}
]
{% if cert.key_type is defined %}
key_type = "{{ cert.key_type }}"
{% endif -%}
kp_reuse = "{{ cert.reuse_private_key | default(false)}}"
{% if cert.random_early_renew is defined%}
random_early_renew = "{{ cert.random_early_renew }}"
{% endif -%}
{% if cert.renew_delay is defined %}
renew_delay = "{{ cert.renew_delay }}"
{% endif -%}
{% if subject_attributes is defined%}
subject_attributes = {
{% for e in certs.subject_attributes | ansible.builtin.dict2items %}
"{{ e.key }}" = "{{ e.value }}",
{% endfor -%}
}
{% endif -%}

View file

@ -0,0 +1,21 @@
{% for endpoint in acmed_endpoints %}
[[endpoint]]
name = "{{ endpoint.name }}"
url = "{{ endpoint.url }}"
tos_agreed = "{{ endpoint.tos_agreed }}"
{% if endpoint.random_early_renew is defined %}
random_early_renew = "{{ endpoint.random_early_renew }}"
{% endif -%}
{% if endpoint.renew_delay is defined %}
renew_delay = "{{ endpoint.renew_delay }}"
{% endif -%}
{% if endpoint.rate_limits is defined %}
rate_limits = [
{% for x in endpoint.rate_limits %}
"{{ x }}",
{% endfor -%}
]
{% endif -%}
{% endfor -%}

View file

@ -0,0 +1,16 @@
[[global]]
account_directory = "{{ acmed_state_account_directory }}"
certificates_directory = "{{ acmed_state_certificates_directory }}"
cert_file_group = "{{ acmed_cert_file_group}}"
cert_file_mode = "{{ acmed_cert_file_mode }}"
cert_file_user = "{{ acmed_cert_file_user }}"
pk_file_group = "{{ acmed_pk_file_group }}"
pk_file_mode = "{{ acmed_pk_file_mode }}"
pk_file_user = "{{ acmed_pk_file_user }}"
{% if acmed_global_env is defined %}
env = {
{% for e in acmed_global_env | ansible.builtin.dict2items %}
"{{ e.key }}" = "{{ e.value }}",
{% endfor -%}
}
{% endif -%}

View file

@ -0,0 +1,13 @@
include = [
"{{ acmed_config_global_file }}",
"{{ acmed_config_endpoints_file }}",
"{{ acmed_config_accounts_file }}",
{% if acmed_rate_limits is defined %}
"{{ acmed_config_rate_limits_file }}",
{% endif -%}
{% if acmed_extra_config_file is defined %}
"{{ acmed_extra_config_file }}",
{% endif -%}
"{{ acmed_config_certificates_directory }}/*.toml",
]

View file

@ -0,0 +1,34 @@
{% for hook in acmed_hooks%}
[[hooks]]
name = "{{ hook.name }}"
{% if hook.allow_failure is defined %}
allow_failure = "{{ hook.allow_failure }}"
{% endif -%}
type = [
{% for t in hook.types %}
"{{ t }}",
{% endfor -%}
]
cmd = "{{ hook.cmd }}"
{% if hook.args %}
args = [
{% for arg in hook.args %}
"{{ arg }}",
{% endfor -%}
]
{% endif -%}
{% if hook.stderr is defined %}
stderr = "{{ hook.stderr }}"
{% endif -%}
{% if hook.stdin is defined %}
stdin = "{{ hook.stdin }}"
{% endif -%}
{% if hook.stdin_str is defined %}
stdin_str = "{{ hook.stdin_str }}"
{% endif -%}
{% if hook.stdout is defined %}
stdout = "{{ hook.stdout }}"
{% endif -%}
{% endfor -%}

View file

@ -0,0 +1,6 @@
{% for rl in acmed_rate_limits %}
[[rate-limit]]
name = "{{ rl.name }}"
number = "{{ rl.number }}"
period = "{{ rl.period }}"
{% endfor -%}

11
roles/acmed/vars/main.yml Normal file
View file

@ -0,0 +1,11 @@
---
acmed_container_volumes_base:
- "{{ acmed_base_path }}:/opt/acmed:Z"
acmed_container_volumes_merged: "{{ acmed_container_volumes_base + acmed_container_volumes }}"
acmed_container_labels_base:
version: "{{ acmed_version }}"
acmed_container_labels_merged: >-
{{ acmed_container_labels_base
| combine(acmed_container_labels | default({})) }}