Vendor Galaxy Roles and Collections
This commit is contained in:
parent
c1e1897cda
commit
2aed20393f
3553 changed files with 387444 additions and 2 deletions
20
ansible_collections/debops/debops/roles/rspamd/COPYRIGHT
Normal file
20
ansible_collections/debops/debops/roles/rspamd/COPYRIGHT
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
debops.rspamd - Manage rspamd with Ansible
|
||||
|
||||
Copyright (C) 2021 David Härdeman <david@hardeman.nu>
|
||||
Copyright (C) 2020 CipherMail B.V. <https://www.ciphermail.com/>
|
||||
Copyright (C) 2021 DebOps <https://debops.org/>
|
||||
SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
This Ansible role is part of DebOps.
|
||||
|
||||
DebOps is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License version 3, as
|
||||
published by the Free Software Foundation.
|
||||
|
||||
DebOps is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with DebOps. If not, see https://www.gnu.org/licenses/.
|
||||
669
ansible_collections/debops/debops/roles/rspamd/defaults/main.yml
Normal file
669
ansible_collections/debops/debops/roles/rspamd/defaults/main.yml
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
---
|
||||
# .. vim: foldmarker=[[[,]]]:foldmethod=marker
|
||||
|
||||
# .. Copyright (C) 2021 David Härdeman <david@hardeman.nu>
|
||||
# .. Copyright (C) 2020 CipherMail B.V. <https://www.ciphermail.com/>
|
||||
# .. Copyright (C) 2021 DebOps <https://debops.org/>
|
||||
# .. SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
# .. _rspamd__ref_defaults:
|
||||
|
||||
# debops.rspamd default variables [[[
|
||||
# ===================================
|
||||
|
||||
# .. contents:: Sections
|
||||
# :local:
|
||||
#
|
||||
# .. include:: ../../../../includes/global.rst
|
||||
|
||||
|
||||
# Packages and installation [[[
|
||||
# -----------------------------
|
||||
|
||||
# .. envvar:: rspamd__packages [[[
|
||||
#
|
||||
# List of additional APT packages that should be installed with rspamd.
|
||||
rspamd__packages: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__base_packages [[[
|
||||
#
|
||||
# APT packages required for the rspamd installation.
|
||||
rspamd__base_packages:
|
||||
- 'rspamd'
|
||||
- '{{ "bind9-dnsutils" if rspamd__dkim_update_method is search("nsupdate") else [] }}'
|
||||
- '{{ "krb5-user" if rspamd__dkim_update_method == "nsupdate_gsstsig" else [] }}'
|
||||
|
||||
# ]]]
|
||||
# ]]]
|
||||
# DKIM configuration [[[
|
||||
# -----------------------
|
||||
|
||||
# .. envvar:: rspamd__dkim_enabled [[[
|
||||
#
|
||||
# Whether to enable DKIM signing of messages. Note that this defaults to
|
||||
# false because DKIM requires some manual configuration, see
|
||||
# :ref:`rspamd__ref_dkim` for more details.
|
||||
rspamd__dkim_enabled: False
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__dkim_domains [[[
|
||||
#
|
||||
# DKIM domains to generate, manage and use signing keys for.
|
||||
rspamd__dkim_domains: [ '{{ ansible_domain }}' ]
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__dkim_log_dir [[[
|
||||
#
|
||||
# Absolute path to the directory which contains DKIM logs.
|
||||
rspamd__dkim_log_dir: '/var/log/rspamd'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__dkim_keygen_default_configuration [[[
|
||||
#
|
||||
# The default configuration for the ``rspamd-dkim-keygen`` script.
|
||||
rspamd__dkim_keygen_default_configuration:
|
||||
- name: 'key_directory'
|
||||
value: '/var/lib/rspamd/dkim/'
|
||||
|
||||
- name: 'key_archive'
|
||||
value: '/var/lib/rspamd/dkim-archive/'
|
||||
|
||||
- name: 'update_script'
|
||||
value: '/usr/local/sbin/rspamd-dkim-update'
|
||||
|
||||
- name: 'future_config'
|
||||
value: 'dkim-future.conf'
|
||||
|
||||
- name: 'active_config'
|
||||
value: 'dkim-active.conf'
|
||||
|
||||
- name: 'expired_config'
|
||||
value: 'dkim-expired.conf'
|
||||
|
||||
- name: 'future_period'
|
||||
value: 1
|
||||
|
||||
- name: 'active_period'
|
||||
value: 3
|
||||
|
||||
- name: 'expired_period'
|
||||
value: 1
|
||||
|
||||
- name: 'domains'
|
||||
value: '{{ rspamd__dkim_domains }}'
|
||||
|
||||
- name: 'key_types'
|
||||
value:
|
||||
- { type: 'ed25519' }
|
||||
- { type: 'rsa', extra_args: [ '--bits', '2048' ] }
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__dkim_keygen_configuration [[[
|
||||
#
|
||||
# The :command:`rspamd` configuration options defined for all hosts in the
|
||||
# Ansible inventory.
|
||||
rspamd__dkim_keygen_configuration: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__dkim_keygen_group_configuration [[[
|
||||
#
|
||||
# The :command:`rspamd` configuration options defined for all hosts in a
|
||||
# specific Ansible inventory group.
|
||||
rspamd__dkim_keygen_group_configuration: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__dkim_keygen_host_configuration [[[
|
||||
#
|
||||
# The :command:`rspamd` configuration options defined for a specific host
|
||||
# in the Ansible inventory.
|
||||
rspamd__dkim_keygen_host_configuration: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__dkim_keygen_combined_configuration [[[
|
||||
#
|
||||
# This variable combines other :command:`rspamd` configuration options and
|
||||
# is used by the role template to generate the configuration snippets in
|
||||
# :file:`/etc/rspamd/local.d`.
|
||||
rspamd__dkim_keygen_combined_configuration: '{{
|
||||
rspamd__dkim_keygen_default_configuration
|
||||
+ rspamd__dkim_keygen_configuration
|
||||
+ rspamd__dkim_keygen_group_configuration
|
||||
+ rspamd__dkim_keygen_host_configuration }}'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__dkim_update_method [[[
|
||||
#
|
||||
# The method to use to publish/remove DKIM keys as DNS RRs.
|
||||
#
|
||||
# Supported values are:
|
||||
#
|
||||
# log
|
||||
# Key updates will simply be logged to a file.
|
||||
# email
|
||||
# Emails will be sent to the admin email address for manual handling.
|
||||
# nsupdate
|
||||
# The :command:`nsupdate` tool will be used to update DNS RRs as necessary.
|
||||
# nsupdate_tsig
|
||||
# Same as ``nsupdate``, but a ``TSIG`` or ``SIG(0)`` key will be used to
|
||||
# authenticate the update requests.
|
||||
# nsupdate_gsstsig
|
||||
# Same as ``nsupdate``, but ``GSS-TSIG`` (Kerberos credentials) will be
|
||||
# used to authenticate the update requests.
|
||||
#
|
||||
# Note that the various ``nsupdate`` methods most likely require some manual
|
||||
# configuration of the DNS server to trust this host to make the required
|
||||
# updates and/or to create suitable key material/keytabs on the Ansible
|
||||
# controller. See :ref:`rspamd__ref_configuration` for more details.
|
||||
rspamd__dkim_update_method: 'email'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__dkim_update_default_configuration [[[
|
||||
#
|
||||
# The default configuration for the ``rspamd-dkim-update`` script.
|
||||
rspamd__dkim_update_default_configuration:
|
||||
- name: 'method'
|
||||
value: '{{ rspamd__dkim_update_method }}'
|
||||
|
||||
- name: 'log_file'
|
||||
value: '{{ rspamd__dkim_log_dir + "/rspamd-dkim-update.log" }}'
|
||||
|
||||
- name: 'email_to'
|
||||
value: '{{ ansible_local.core.admin_public_email[0]
|
||||
| d("root@" + ansible_domain) }}'
|
||||
|
||||
- name: 'email_from'
|
||||
value: '{{ "noreply@" + ansible_domain }}'
|
||||
|
||||
- name: 'email_host'
|
||||
value: 'localhost'
|
||||
|
||||
- name: 'email_port'
|
||||
value: 25
|
||||
|
||||
- name: 'email_subject'
|
||||
value: 'Rspamd DKIM DNS updates'
|
||||
|
||||
- name: 'nsupdate_keyfile'
|
||||
value: '{{ "" if rspamd__dkim_update_method in ["email", "nsupdate"]
|
||||
else "/etc/rspamd/dkim_dns_key" }}'
|
||||
|
||||
- name: 'nsupdate_gsstsig_princ'
|
||||
value: '{{ "" if rspamd__dkim_update_method != "nsupdate_gsstsig"
|
||||
else "rspamd@" + ansible_domain | upper }}'
|
||||
|
||||
- name: 'nsupdate_ttl'
|
||||
value: 3600
|
||||
|
||||
- name: 'nsupdate_server'
|
||||
value: '{{ ansible_dns.nameservers[0] | d("") }}'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__dkim_update_configuration [[[
|
||||
#
|
||||
# The :command:`rspamd` configuration options defined for all hosts in the
|
||||
# Ansible inventory.
|
||||
rspamd__dkim_update_configuration: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__dkim_update_group_configuration [[[
|
||||
#
|
||||
# The :command:`rspamd` configuration options defined for all hosts in a
|
||||
# specific Ansible inventory group.
|
||||
rspamd__dkim_update_group_configuration: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__dkim_update_host_configuration [[[
|
||||
#
|
||||
# The :command:`rspamd` configuration options defined for a specific host
|
||||
# in the Ansible inventory.
|
||||
rspamd__dkim_update_host_configuration: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__dkim_update_combined_configuration [[[
|
||||
#
|
||||
# This variable combines other :command:`rspamd` configuration options and
|
||||
# is used by the role template to generate the configuration snippets in
|
||||
# :file:`/etc/rspamd/local.d`.
|
||||
rspamd__dkim_update_combined_configuration: '{{
|
||||
rspamd__dkim_update_default_configuration
|
||||
+ rspamd__dkim_update_configuration
|
||||
+ rspamd__dkim_update_group_configuration
|
||||
+ rspamd__dkim_update_host_configuration }}'
|
||||
|
||||
# ]]]
|
||||
# ]]]
|
||||
# Redis configuration [[[
|
||||
# -----------------------
|
||||
|
||||
# .. envvar:: rspamd__redis_host [[[
|
||||
#
|
||||
# The hostname of the Redis server.
|
||||
rspamd__redis_host: '{{ ansible_local.redis_server.host | d("127.0.0.1") }}'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__redis_port [[[
|
||||
#
|
||||
# The port which the Redis server is listening to.
|
||||
rspamd__redis_port: '{{ ansible_local.redis_server.port | d("6379") }}'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__redis_password [[[
|
||||
#
|
||||
# The Redis authentication password.
|
||||
rspamd__redis_password: '{{ ansible_local.redis_server.password | d("") }}'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__redis_db [[[
|
||||
#
|
||||
# The Redis db to use.
|
||||
rspamd__redis_db: '0'
|
||||
|
||||
# ]]]
|
||||
# ]]]
|
||||
# Service configuration [[[
|
||||
# -------------------------
|
||||
|
||||
# These variables define the contents of the :file:`/etc/rspamd/local.d` and
|
||||
# :file:`/etc/rspamd/override.d` configuration directories. See
|
||||
# :ref:`rspamd__ref_configuration` and the `Rspamd configuration documentation
|
||||
# <https://rspamd.com/doc/configuration/>`_
|
||||
# for further details.
|
||||
|
||||
# .. envvar:: rspamd__default_local_configuration [[[
|
||||
#
|
||||
# The default :command:`rspamd` :file:`local.d` configuration.
|
||||
rspamd__default_local_configuration:
|
||||
|
||||
- file: 'worker-proxy.inc'
|
||||
comment: |
|
||||
Proxy worker configuration
|
||||
https://rspamd.com/doc/workers/rspamd_proxy.html
|
||||
options:
|
||||
|
||||
- name: 'bind_socket'
|
||||
value: 'localhost:11332'
|
||||
|
||||
- name: 'milter'
|
||||
value: True
|
||||
|
||||
- name: 'timeout'
|
||||
value: 120
|
||||
|
||||
- name: 'upstream "local"'
|
||||
options:
|
||||
|
||||
- name: 'default'
|
||||
value: True
|
||||
|
||||
- name: 'self_scan'
|
||||
value: True
|
||||
|
||||
- file: 'worker-controller.inc'
|
||||
comment: |
|
||||
Controller worker configuration
|
||||
https://rspamd.com/doc/workers/controller.html
|
||||
options:
|
||||
|
||||
- name: 'password'
|
||||
value: '{{ rspamd__controller_password_hash }}'
|
||||
|
||||
- file: 'redis.conf'
|
||||
comment: |
|
||||
Redis configuration
|
||||
https://rspamd.com/doc/configuration/redis.html
|
||||
options:
|
||||
|
||||
- name: 'servers'
|
||||
value: '{{ rspamd__redis_host }}:{{ rspamd__redis_port }}'
|
||||
|
||||
- name: 'db'
|
||||
value: '{{ rspamd__redis_db }}'
|
||||
|
||||
- name: 'password'
|
||||
value: '{{ rspamd__redis_password }}'
|
||||
|
||||
- file: 'milter_headers.conf'
|
||||
comment: |
|
||||
Milter headers configuration
|
||||
https://rspamd.com/doc/modules/milter_headers.html
|
||||
options:
|
||||
|
||||
- name: 'use'
|
||||
value: [ 'x-spamd-bar', 'x-spam-level', 'authentication-results' ]
|
||||
|
||||
- name: 'authenticated_headers'
|
||||
value: [ 'authentication-results' ]
|
||||
|
||||
- file: 'dkim_signing.conf'
|
||||
comment: |
|
||||
DKIM signing configuration
|
||||
https://rspamd.com/doc/modules/dkim_signing.html
|
||||
state: '{{ "present" if rspamd__dkim_enabled | d(False) else "absent" }}'
|
||||
options:
|
||||
|
||||
- name: 'allow_username_mismatch'
|
||||
value: True
|
||||
|
||||
- name: 'include_dkim_keys'
|
||||
raw: '.include(try=true,priority=1,duplicate=merge) "/var/lib/rspamd/dkim/dkim-active.conf"'
|
||||
|
||||
- file: 'arc.conf'
|
||||
comment: |
|
||||
ARC signature check configuration
|
||||
https://rspamd.com/doc/modules/arc.html
|
||||
state: '{{ "present" if rspamd__dkim_enabled | d(False) else "absent" }}'
|
||||
options:
|
||||
|
||||
- name: 'allow_username_mismatch'
|
||||
value: True
|
||||
|
||||
- name: 'include_dkim_keys'
|
||||
raw: '.include(try=true,priority=1,duplicate=merge) "/var/lib/rspamd/dkim/dkim-active.conf"'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__local_configuration [[[
|
||||
#
|
||||
# The default :command:`rspamd` :file:`local.d` configuration defined for all
|
||||
# hosts in the Ansible inventory.
|
||||
rspamd__local_configuration: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__group_local_configuration [[[
|
||||
#
|
||||
# The default :command:`rspamd` :file:`local.d` configuration defined for all
|
||||
# hosts in a specific Ansible inventory group.
|
||||
rspamd__group_local_configuration: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__host_local_configuration [[[
|
||||
#
|
||||
# The default :command:`rspamd` :file:`local.d` configuration defined for a
|
||||
# specific host in the Ansible inventory.
|
||||
rspamd__host_local_configuration: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__combined_local_configuration [[[
|
||||
#
|
||||
# This variable combines other :command:`rspamd` configuration options and
|
||||
# is used by the role template to generate the configuration snippets in
|
||||
# :file:`/etc/rspamd/local.d`.
|
||||
rspamd__combined_local_configuration: '{{ rspamd__default_local_configuration
|
||||
+ rspamd__local_configuration
|
||||
+ rspamd__group_local_configuration
|
||||
+ rspamd__host_local_configuration }}'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__default_override_configuration [[[
|
||||
#
|
||||
# The default :command:`rspamd` :file:`override.d` configuration.
|
||||
rspamd__default_override_configuration: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__override_configuration [[[
|
||||
#
|
||||
# The default :command:`rspamd` :file:`override.d` configuration defined for
|
||||
# all hosts in the Ansible inventory.
|
||||
rspamd__override_configuration: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__group_override_configuration [[[
|
||||
#
|
||||
# The default :command:`rspamd` :file:`override.d` configuration defined for
|
||||
# all hosts in a specific Ansible inventory group.
|
||||
rspamd__group_override_configuration: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__host_override_configuration [[[
|
||||
#
|
||||
# The default :command:`rspamd` :file:`override.d` configuration defined for a
|
||||
# specific host in the Ansible inventory.
|
||||
rspamd__host_override_configuration: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__combined_override_configuration [[[
|
||||
#
|
||||
# This variable combines other :command:`rspamd` configuration options and
|
||||
# is used by the role template to generate the configuration snippets in
|
||||
# :file:`/etc/rspamd/override.d`.
|
||||
rspamd__combined_override_configuration: '{{ rspamd__default_override_configuration
|
||||
+ rspamd__override_configuration
|
||||
+ rspamd__group_override_configuration
|
||||
+ rspamd__host_override_configuration }}'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__controller_password [[[
|
||||
#
|
||||
# This variable controls the password necessary to access e.g. the web-based
|
||||
# user interface of :command:`rspamd`. Note that the WebUI is only accessible
|
||||
# via ``http://localhost:11334/``, so for remote access, a :command:`ssh`
|
||||
# tunnel or `local proxy server`__ is necessary. The latter can be configured
|
||||
# automatically by this role (see :envvar:`rspamd__nginx_enabled` below).
|
||||
#
|
||||
# .. __: https://rspamd.com/doc/faq.html#how-to-use-the-webui-behind-a-proxy-server
|
||||
rspamd__controller_password: '{{ lookup("password",
|
||||
secret
|
||||
+ "/credentials/"
|
||||
+ inventory_hostname
|
||||
+ "/rspamd/controller_password"
|
||||
+ " chars=ascii_letters,digits"
|
||||
+ " length=32") }}'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__controller_password_salt [[[
|
||||
#
|
||||
# The salt used to hash the :envvar:`rspamd__controller_password`.
|
||||
# The default might look complicated, but it's a workaround for
|
||||
# `Ansible issue #36129`__.
|
||||
#
|
||||
# .. __: https://github.com/ansible/ansible/issues/36129
|
||||
rspamd__controller_password_salt: '{{ (lookup("password",
|
||||
secret
|
||||
+ "/credentials/"
|
||||
+ inventory_hostname
|
||||
+ "/rspamd/salt"
|
||||
+ " chars=ascii_letters,digits"
|
||||
+ " length=21"))[:21]
|
||||
+ ("Oeu"
|
||||
| shuffle(seed=inventory_hostname)
|
||||
| join)[1] }}'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__controller_password_hash [[[
|
||||
#
|
||||
# The hashed version of :envvar:`rspamd__controller_password`. If you want to
|
||||
# set a password manually, you probably want to change that variable instead.
|
||||
rspamd__controller_password_hash: '{{ rspamd__controller_password
|
||||
| password_hash("bcrypt",
|
||||
salt=rspamd__controller_password_salt) }}'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__nginx_enabled [[[
|
||||
#
|
||||
# If enabled, :command:`nginx` will be setup to provide remote access to
|
||||
# the WebUI of :command:`rspamd`.
|
||||
rspamd__nginx_enabled: False
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__nginx_fqdns [[[
|
||||
#
|
||||
# List of FQDNs which :command:`nginx` should use to make the WebUI available.
|
||||
rspamd__nginx_fqdns:
|
||||
- 'rspamd.{{ ansible_domain }}'
|
||||
- '{{ ansible_hostname }}-rspamd.{{ ansible_domain }}'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__nginx_access_policy [[[
|
||||
#
|
||||
# The nginx access policy, by default none.
|
||||
rspamd__nginx_access_policy: ''
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__proxy_allow [[[
|
||||
#
|
||||
# List of IP addresses or CIDR subnets which are allowed to connect to
|
||||
# :command:`rspamd`'s ``proxy`` worker remotely. If the list is empty, remote
|
||||
# connections are denied. Note that this only influences the firewall settings,
|
||||
# further configuration is still necessary to make :command:`rspamd` actually
|
||||
# listen for remote connections (see the ``bind_socket`` and ``secure_ip``
|
||||
# options in the `workers documentation`__).
|
||||
#
|
||||
# .. __: https://rspamd.com/doc/workers/
|
||||
rspamd__proxy_allow: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__normal_allow [[[
|
||||
#
|
||||
# Same as :envvar:`rspamd__proxy_allow`, but for the ``normal`` worker.
|
||||
rspamd__normal_allow: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__controller_allow [[[
|
||||
#
|
||||
# Same as :envvar:`rspamd__proxy_allow`, but for the ``controller`` worker.
|
||||
rspamd__controller_allow: []
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__fuzzy_allow [[[
|
||||
#
|
||||
# Same as :envvar:`rspamd__proxy_allow`, but for the ``fuzzy`` worker.
|
||||
rspamd__fuzzy_allow: []
|
||||
|
||||
# ]]]
|
||||
# ]]]
|
||||
# Configuration for other Ansible roles [[[
|
||||
# -----------------------------------------
|
||||
|
||||
# .. envvar:: rspamd__logrotate__dependent_config [[[
|
||||
#
|
||||
# Configuration for the :ref:`debops.logrotate` role.
|
||||
rspamd__logrotate__dependent_config:
|
||||
|
||||
- filename: 'rspamd-dkim'
|
||||
logs: '{{ rspamd__dkim_log_dir + "/rspamd-dkim-update.log" }}'
|
||||
options: |
|
||||
notifempty
|
||||
missingok
|
||||
yearly
|
||||
maxsize 16M
|
||||
rotate 10
|
||||
compress
|
||||
comment: 'Rspamd DKIM key rotation logs'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__etc_services__dependent_list [[[
|
||||
#
|
||||
# Configuration for the :ref:`debops.etc_services` role.
|
||||
rspamd__etc_services__dependent_list:
|
||||
|
||||
- name: 'rspamd-proxy'
|
||||
port: '11332'
|
||||
protocols: [ 'tcp' ]
|
||||
comment: 'Added by debops.rspamd Ansible role.'
|
||||
|
||||
- name: 'rspamd-normal'
|
||||
port: '11333'
|
||||
protocols: [ 'tcp' ]
|
||||
comment: 'Added by debops.rspamd Ansible role.'
|
||||
|
||||
- name: 'rspamd-controller'
|
||||
port: '11334'
|
||||
protocols: [ 'tcp' ]
|
||||
comment: 'Added by debops.rspamd Ansible role.'
|
||||
|
||||
- name: 'rspamd-fuzzy'
|
||||
port: '11335'
|
||||
protocols: [ 'udp' ]
|
||||
comment: 'Added by debops.rspamd Ansible role.'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__nginx__dependent_servers [[[
|
||||
#
|
||||
# Configuration for the :ref:`debops.nginx` role.
|
||||
rspamd__nginx__dependent_servers:
|
||||
|
||||
- name: '{{ rspamd__nginx_fqdns }}'
|
||||
filename: 'debops.rspamd'
|
||||
by_role: 'debops.rspamd'
|
||||
access_policy: '{{ rspamd__nginx_access_policy }}'
|
||||
webroot_create: False
|
||||
type: 'proxy'
|
||||
proxy_pass: 'http://localhost:11334'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__ferm__dependent_rules [[[
|
||||
#
|
||||
# Configuration for the :ref:`debops.ferm` role.
|
||||
rspamd__ferm__dependent_rules:
|
||||
|
||||
- type: 'accept'
|
||||
dport: [ 'rspamd-proxy' ]
|
||||
protocol: [ 'tcp' ]
|
||||
saddr: '{{ rspamd__proxy_allow }}'
|
||||
accept_any: False
|
||||
weight: '50'
|
||||
role: 'rspamd'
|
||||
|
||||
- type: 'accept'
|
||||
dport: [ 'rspamd-normal' ]
|
||||
protocol: [ 'tcp' ]
|
||||
saddr: '{{ rspamd__normal_allow }}'
|
||||
accept_any: False
|
||||
weight: '50'
|
||||
role: 'rspamd'
|
||||
|
||||
- type: 'accept'
|
||||
dport: [ 'rspamd-controller' ]
|
||||
protocol: [ 'tcp' ]
|
||||
saddr: '{{ rspamd__controller_allow }}'
|
||||
accept_any: False
|
||||
weight: '50'
|
||||
role: 'rspamd'
|
||||
|
||||
- type: 'accept'
|
||||
dport: [ 'rspamd-fuzzy' ]
|
||||
protocol: [ 'udp' ]
|
||||
saddr: '{{ rspamd__fuzzy_allow }}'
|
||||
accept_any: False
|
||||
weight: '50'
|
||||
role: 'rspamd'
|
||||
|
||||
# ]]]
|
||||
# .. envvar:: rspamd__postfix__dependent_maincf [[[
|
||||
#
|
||||
# The :file:`main.cf` configuration for the :ref:`debops.postfix` role.
|
||||
rspamd__postfix__dependent_maincf:
|
||||
|
||||
- name: 'smtpd_milters'
|
||||
comment: 'Added by the rspamd role'
|
||||
value:
|
||||
- name: 'inet:localhost:11332'
|
||||
weight: -400
|
||||
state: 'present'
|
||||
|
||||
- name: 'non_smtpd_milters'
|
||||
comment: 'Added by the rspamd role'
|
||||
value:
|
||||
- name: 'inet:localhost:11332'
|
||||
weight: -400
|
||||
state: 'present'
|
||||
|
||||
- name: 'milter_mail_macros'
|
||||
comment: 'Added by the rspamd role'
|
||||
value: '{{ "i {auth_type} {auth_authen} {auth_author} "
|
||||
+ "{client_addr} {client_name} {mail_addr} "
|
||||
+ "{mail_host} {mail_mailer}" }}'
|
||||
state: 'present'
|
||||
|
||||
- name: 'milter_default_action'
|
||||
comment: 'Added by the rspamd role'
|
||||
value: 'accept'
|
||||
state: 'comment'
|
||||
|
||||
- name: 'milter_protocol'
|
||||
comment: 'Added by the rspamd role'
|
||||
value: 6
|
||||
state: 'comment'
|
||||
# ]]]
|
||||
# ]]]
|
||||
# ]]]
|
||||
|
|
@ -0,0 +1,740 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (C) 2021 David Härdeman <david@hardeman.nu>
|
||||
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR Apache-2.0
|
||||
|
||||
"""Rspamd DKIM key generation/rollover script
|
||||
|
||||
This script is meant to be executed on a monthly basis (e.g. via cron
|
||||
or systemd) and will automatically manage DKIM key rollover.
|
||||
|
||||
Keys go through four states:
|
||||
* future - a key which is created in advance of being used and which
|
||||
should be published in the DNS zone ASAP
|
||||
* current - key which is actively being used to sign emails
|
||||
* expired - key which is no longer used to sign emails but the DNS
|
||||
entry is still kept in order to make sure that queued
|
||||
and in-flight emails will still have a valid signature
|
||||
* dead - key which can (hopefully) be safely removed from the file
|
||||
system and from the DNS zone after enough time has passed
|
||||
that no signed emails are in flight anymore
|
||||
|
||||
These states correspond to the published/active/inactive/removed states
|
||||
known from DNSSEC key rollover.
|
||||
|
||||
The default settings can be overridden by creating a JSON snippet in
|
||||
/etc/rspamd/dkim-keygen.json (the path can be overridden with the
|
||||
-c/--config parameter, use -C/--create-config to print a config
|
||||
template).
|
||||
|
||||
At least "domains" and "key_types" need to be set using the config file.
|
||||
If "update_script" is set, it will be called for each key which is created
|
||||
or deleted, with the following arguments:
|
||||
<add/del> <domain> <selector> <dns_entry_file_path>
|
||||
"""
|
||||
|
||||
from __future__ import (print_function, division)
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import tempfile
|
||||
import argparse
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
__license__ = 'GPL-2.0 OR Apache-2.0'
|
||||
__author__ = 'David Härdeman <david@hardeman.nu>'
|
||||
__version__ = '1.0.1'
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Key:
|
||||
"""Keeps track of a real or virtual key
|
||||
|
||||
Attributes:
|
||||
dryrun whether to change any files
|
||||
current_month as the number of months since year 0
|
||||
key_archive directory to move expired keys to
|
||||
update_script script to execute upon key creation/removal
|
||||
update_script_args list of arguments to pass to the update_script
|
||||
future_period time between key creation and activation (months)
|
||||
active_period key active time (months)
|
||||
expired_period time between key inactivation and deletion (months)
|
||||
"""
|
||||
dryrun = False
|
||||
current_month = 0
|
||||
key_archive = None
|
||||
update_script = None
|
||||
update_script_args = []
|
||||
future_period = 1
|
||||
active_period = 3
|
||||
expired_period = 1
|
||||
|
||||
def __init__(self):
|
||||
self.state = "init"
|
||||
self.key_path = ""
|
||||
self.txt_path = ""
|
||||
self.valid_from = 0
|
||||
self.age = 0
|
||||
self.type = ""
|
||||
self.domain = ""
|
||||
self.selector = ""
|
||||
self.error = ""
|
||||
|
||||
def validate_filename(self, path, selector):
|
||||
"""Validate the filename of a key
|
||||
|
||||
The expected path is e.g. /some/path/YYYYMM_keytype_domain.key
|
||||
with the DNS record in /some/path/YYYYMM_keytype_domain.txt
|
||||
"""
|
||||
|
||||
abspath = os.path.abspath(path)
|
||||
if not abspath.endswith(".key"):
|
||||
raise ValueError("key name suffix invalid")
|
||||
abspath_wo_suffix = abspath[:-len(".key")]
|
||||
basename = os.path.basename(abspath_wo_suffix)
|
||||
|
||||
parts = basename.split("_")
|
||||
if len(parts) != 3:
|
||||
raise ValueError("key name missing components")
|
||||
|
||||
self.key_path = abspath
|
||||
self.txt_path = abspath_wo_suffix + ".txt"
|
||||
self.type = parts[1]
|
||||
self.domain = parts[2]
|
||||
self.selector = selector or ("{}-{}".format(parts[0].strip(), self.type))
|
||||
|
||||
date = parts[0].strip()
|
||||
if len(date) != 6:
|
||||
raise ValueError("key validity incorrect size")
|
||||
try:
|
||||
year = int(date[0:4])
|
||||
month = int(date[4:6])
|
||||
except ValueError:
|
||||
raise ValueError("key validity not numeric") from None
|
||||
self.valid_from = year * 12 + month
|
||||
self.age = self.current_month - self.valid_from
|
||||
|
||||
if self.type not in KeyType.key_types:
|
||||
raise ValueError("key type unknown")
|
||||
|
||||
def read(self, path, selector=None, skip_exist_check=False):
|
||||
"""Read a DKIM key"""
|
||||
|
||||
self.error = ""
|
||||
try:
|
||||
self.validate_filename(path, selector)
|
||||
except Exception as err:
|
||||
self.state = "invalid"
|
||||
self.error = str(err)
|
||||
LOG.debug("Key {}, invalid filename: {}".format(path, self.error))
|
||||
return False
|
||||
|
||||
if not skip_exist_check:
|
||||
try:
|
||||
with open(self.key_path, "r"):
|
||||
pass
|
||||
except Exception as err:
|
||||
self.state = "invalid"
|
||||
self.error = "can't open key file"
|
||||
LOG.debug("Key {}, {}: {}".format(self.key_path, self.error, err))
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(self.txt_path, "r"):
|
||||
pass
|
||||
except Exception as err:
|
||||
self.state = "invalid"
|
||||
self.error = "can't open txt file"
|
||||
LOG.debug("Key {}, {}: {}".format(self.txt_path, self.error, err))
|
||||
return False
|
||||
|
||||
if self.age >= self.active_period + self.expired_period:
|
||||
self.state = "dead"
|
||||
elif self.age >= self.active_period:
|
||||
self.state = "expired"
|
||||
elif self.age >= 0:
|
||||
self.state = "active"
|
||||
else:
|
||||
self.state = "future"
|
||||
LOG.debug("Key {} loaded, state {}".format(path, self.state))
|
||||
LOG.debug(vars(self))
|
||||
return True
|
||||
|
||||
def create(self, domain, key_type, offset):
|
||||
"""Create a DKIM key"""
|
||||
|
||||
valid_from = self.current_month + offset
|
||||
datestr = "{:04d}{:02d}".format(valid_from // 12, valid_from % 12)
|
||||
selector = "{}-{}".format(datestr, key_type.type)
|
||||
self.key_path = os.path.abspath("{}_{}_{}.key".format(
|
||||
datestr, key_type.type, domain))
|
||||
self.txt_path = os.path.abspath("{}_{}_{}.txt".format(
|
||||
datestr, key_type.type, domain))
|
||||
|
||||
LOG.debug("Asked to create key {}".format(self.key_path))
|
||||
if self.read(self.key_path, selector):
|
||||
return
|
||||
|
||||
if not self.dryrun:
|
||||
try:
|
||||
os.unlink(self.key_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.unlink(self.txt_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.dryrun:
|
||||
LOG.info("Would have created key {}".format(self.key_path))
|
||||
else:
|
||||
key_type.create(domain, selector, self.key_path, self.txt_path)
|
||||
LOG.info("Created key {}".format(self.key_path))
|
||||
|
||||
self.read(self.key_path, selector, skip_exist_check=self.dryrun)
|
||||
if self.state == "invalid":
|
||||
die("Created key is invalid!?")
|
||||
|
||||
self.__class__.update_script_args += \
|
||||
["add", self.domain, self.selector, self.txt_path]
|
||||
|
||||
def delete(self):
|
||||
"""Delete/archive a key from the file system"""
|
||||
|
||||
self.__class__.update_script_args += \
|
||||
["del", self.domain, self.selector, self.txt_path]
|
||||
|
||||
for path in self.key_path, self.txt_path:
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
|
||||
if self.dryrun:
|
||||
if self.key_archive:
|
||||
LOG.info("Would have archived {}".format(path))
|
||||
else:
|
||||
LOG.info("Would have unlinked {}".format(path))
|
||||
continue
|
||||
|
||||
try:
|
||||
if self.key_archive:
|
||||
LOG.info("Archiving {}".format(path))
|
||||
os.rename(path, os.path.join(self.key_archive,
|
||||
os.path.basename(path)))
|
||||
else:
|
||||
LOG.info("Unlinking {}".format(path))
|
||||
os.unlink(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def pending_changes(cls):
|
||||
return len(cls.update_script_args) > 0
|
||||
|
||||
@classmethod
|
||||
def run_update_script(cls):
|
||||
"""Run the update_script if configured and if there are any changes"""
|
||||
if cls.update_script and cls.update_script_args:
|
||||
args = [cls.update_script] + cls.update_script_args
|
||||
|
||||
if cls.dryrun:
|
||||
LOG.debug("Would have called key script {}".format(args))
|
||||
else:
|
||||
try:
|
||||
LOG.debug("Calling key script {}".format(args))
|
||||
subprocess.call(args)
|
||||
except Exception as err:
|
||||
LOG.error("Calling key script failed: {}".format(err))
|
||||
|
||||
cls.update_script_args = []
|
||||
|
||||
|
||||
class KeyType:
|
||||
"""Keeps track of configured key types and associated attributes
|
||||
|
||||
Attributes:
|
||||
instances list of created instances
|
||||
key_types list of known key types
|
||||
"""
|
||||
instances = []
|
||||
key_types = []
|
||||
|
||||
def __init__(self, key_type_dict):
|
||||
"""Create a new key type from a dict object"""
|
||||
|
||||
key_type_name = key_type_dict.get("type")
|
||||
if key_type_name is None:
|
||||
raise ValueError("Key type definition missing key type")
|
||||
elif key_type_name in self.__class__.key_types:
|
||||
raise ValueError("Key type {} defined twice".format(type))
|
||||
|
||||
self.type = key_type_name
|
||||
extra_args = key_type_dict.get("extra_args")
|
||||
if extra_args is not None:
|
||||
self.extra_args = extra_args
|
||||
|
||||
self.__class__.key_types.append(key_type_name)
|
||||
self.__class__.instances.append(self)
|
||||
|
||||
def create(self, domain, selector, key_path, txt_path):
|
||||
"""Create a key of the given type"""
|
||||
|
||||
args = ["rspamadm", "dkim_keygen",
|
||||
"--type", self.type,
|
||||
"--domain", domain,
|
||||
"--selector", selector,
|
||||
"--privkey", key_path]
|
||||
|
||||
if hasattr(self, "extra_args"):
|
||||
args.extend(self.extra_args)
|
||||
|
||||
LOG.debug("Key creation command: {}".format(args))
|
||||
|
||||
with open(txt_path, "w") as tout:
|
||||
with subprocess.Popen(args, text=True, stdout=tout,
|
||||
stderr=subprocess.PIPE) as process:
|
||||
stdout, stderr = process.communicate()
|
||||
if process.returncode != 0:
|
||||
LOG.debug("Key creation rc: {}".format(process.returncode))
|
||||
LOG.debug("Key creation stdout: {}".format(stdout))
|
||||
LOG.debug("Key creation stderr: {}".format(stderr))
|
||||
die("Key creation failed ({})".format(stderr))
|
||||
|
||||
|
||||
class KeyConfig:
|
||||
"""Keeps track of a set of configured keys
|
||||
|
||||
Attributes:
|
||||
dryrun whether to change any files
|
||||
"""
|
||||
dryrun = False
|
||||
|
||||
def __init__(self, path, state):
|
||||
self.keys = {}
|
||||
self.raw_json = None
|
||||
self.path = os.path.abspath(path)
|
||||
self.state = state
|
||||
|
||||
def remove_invalid(self):
|
||||
"""Remove and return all keys unsuited for this config"""
|
||||
|
||||
invalid = []
|
||||
for domain, domain_keys in self.keys.items():
|
||||
valid = []
|
||||
for key in domain_keys:
|
||||
if key.state == self.state:
|
||||
valid.append(key)
|
||||
else:
|
||||
invalid.append(key)
|
||||
self.keys[domain] = valid
|
||||
|
||||
return invalid
|
||||
|
||||
def get(self, domain, key_type, state, remove=False):
|
||||
"""Get and optionally remove a key from this config"""
|
||||
|
||||
if domain in self.keys:
|
||||
for i, key in enumerate(self.keys[domain]):
|
||||
if key.type != key_type.type:
|
||||
continue
|
||||
if key.error:
|
||||
continue
|
||||
if key.state != state:
|
||||
continue
|
||||
if remove:
|
||||
LOG.debug("Removing key {} from {} config".format(
|
||||
key.key_path, self.state))
|
||||
del self.keys[domain][i]
|
||||
return key
|
||||
|
||||
return None
|
||||
|
||||
def add(self, key):
|
||||
"""Adds a key to this config"""
|
||||
|
||||
if key.domain not in self.keys:
|
||||
self.keys[key.domain] = []
|
||||
LOG.debug("Adding key {} to {} config".format(key.key_path, self.state))
|
||||
self.keys[key.domain].append(key)
|
||||
|
||||
def read(self):
|
||||
""" Read configuration file"""
|
||||
try:
|
||||
with open(self.path, "r") as f:
|
||||
self.raw_json = f.read()
|
||||
cfg = json.loads("{" + self.raw_json + "}").get("domain", {})
|
||||
LOG.debug("Processing file {}".format(self.path))
|
||||
except FileNotFoundError:
|
||||
LOG.debug("File {} not found".format(self.path))
|
||||
return
|
||||
except json.decoder.JSONDecodeError as err:
|
||||
LOG.error("Failed to parse {}: {}".format(self.path, str(e)))
|
||||
return
|
||||
except Exception as err:
|
||||
die("Error reading {}: {}".format(self.path, err))
|
||||
|
||||
for domain in cfg:
|
||||
LOG.debug("Configured domain {}".format(domain))
|
||||
|
||||
if "selectors" not in cfg[domain]:
|
||||
LOG.debug("File {} contains no selectors".format(self.path))
|
||||
continue
|
||||
|
||||
for item in cfg[domain]["selectors"]:
|
||||
if "path" not in item:
|
||||
LOG.error("Key entry in {} missing a selector".format(self.path))
|
||||
continue
|
||||
|
||||
LOG.debug("Configured key {}, selector {}".format(
|
||||
item["path"], item.get("selector", "<unknown>")))
|
||||
key = Key()
|
||||
key.read(item["path"], item.get("selector"))
|
||||
self.add(key)
|
||||
|
||||
LOG.debug("Done processing file {}".format(self.path))
|
||||
|
||||
def to_json(self, include_invalid):
|
||||
"""Converts the current configuration to a JSON fragment
|
||||
|
||||
Note that the fragment is suitable for inclusion in other
|
||||
JSON files (i.e. in the Rspamd configuration files),
|
||||
which is why the opening/closing curly braces are removed.
|
||||
"""
|
||||
json_dict = {"domain": {}}
|
||||
for domain, domain_keys in self.keys.items():
|
||||
key_list = []
|
||||
|
||||
for key in domain_keys:
|
||||
if include_invalid or key.state == self.state:
|
||||
key_list.append({"path": key.key_path,
|
||||
"selector": key.selector})
|
||||
|
||||
if key_list:
|
||||
json_dict["domain"][domain] = {"selectors": key_list}
|
||||
|
||||
json_lines = []
|
||||
raw_json = json.dumps(json_dict, sort_keys=True, indent=4)
|
||||
for line in raw_json.split("\n")[1:-1]:
|
||||
# Python 2 and 3 differ in the trailing whitespace
|
||||
json_lines.append(line[4:].rstrip())
|
||||
return "\n".join(json_lines)
|
||||
|
||||
def dump(self, reason):
|
||||
"""Prints the current configuration as a JSON fragment"""
|
||||
json = self.to_json(True)
|
||||
LOG.debug("Dumping {} {} config".format(reason, self.state))
|
||||
LOG.debug(json)
|
||||
|
||||
def write(self):
|
||||
"""Writes out the current configuration as a JSON fragment"""
|
||||
json = self.to_json(False)
|
||||
|
||||
if json == self.raw_json:
|
||||
LOG.info("Not updating {}, no changes".format(self.path))
|
||||
return
|
||||
|
||||
self.raw_json = json
|
||||
if self.dryrun:
|
||||
LOG.info("Would have written {}".format(self.path))
|
||||
return
|
||||
|
||||
LOG.info("Writing {}".format(self.path))
|
||||
wd = os.path.dirname(self.path)
|
||||
with tempfile.NamedTemporaryFile(mode="w", dir=wd, delete=False) as f:
|
||||
f.write(self.raw_json)
|
||||
f.close()
|
||||
os.rename(f.name, self.path)
|
||||
os.chmod(self.path, 0o640)
|
||||
|
||||
|
||||
def die(msg):
|
||||
LOG.error(msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_args_parser():
|
||||
args_parser = argparse.ArgumentParser(
|
||||
prog='rspamd-dkim-keygen',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
description="Generate and rollover DKIM keys.",
|
||||
)
|
||||
args_parser.add_argument(
|
||||
'-V', '--version',
|
||||
action='version',
|
||||
version=__version__,
|
||||
)
|
||||
args_parser.add_argument(
|
||||
'-d', '--debug',
|
||||
help="Enable debug output",
|
||||
action='store_const',
|
||||
dest='loglevel',
|
||||
const=logging.DEBUG,
|
||||
)
|
||||
args_parser.add_argument(
|
||||
'-v', '--verbose',
|
||||
help="Enable verbose output",
|
||||
action="store_const",
|
||||
dest="loglevel",
|
||||
const=logging.INFO,
|
||||
)
|
||||
args_parser.add_argument(
|
||||
'-c', '--config',
|
||||
help="Configuration file path",
|
||||
default="/etc/rspamd/dkim-keygen.json",
|
||||
dest="config_path",
|
||||
)
|
||||
args_parser.add_argument(
|
||||
'-C', '--print-config',
|
||||
help="Print a configuration file example",
|
||||
action="store_true",
|
||||
default=False,
|
||||
dest="print_config",
|
||||
)
|
||||
args_parser.add_argument(
|
||||
'-m', '--month',
|
||||
help="Set current month (for testing)",
|
||||
type=int,
|
||||
default=0,
|
||||
dest="month",
|
||||
)
|
||||
args_parser.add_argument(
|
||||
"--dry-run",
|
||||
help="don't change any files",
|
||||
action="store_true",
|
||||
default=False,
|
||||
dest="dryrun",
|
||||
)
|
||||
|
||||
return args_parser
|
||||
|
||||
|
||||
def get_default_config():
|
||||
return {
|
||||
"key_directory": "/var/lib/rspamd/dkim",
|
||||
"key_archive": Key.key_archive,
|
||||
"update_script": Key.update_script,
|
||||
"active_config": "dkim-active.conf",
|
||||
"future_config": "dkim-future.conf",
|
||||
"expired_config": "dkim-expired.conf",
|
||||
"future_period": Key.future_period,
|
||||
"active_period": Key.active_period,
|
||||
"expired_period": Key.expired_period,
|
||||
"domains": [],
|
||||
"key_types": [vars(x) for x in KeyType.instances],
|
||||
}
|
||||
|
||||
|
||||
def print_example_config():
|
||||
KeyType({"type": "ed25519"})
|
||||
KeyType({"type": "rsa", "extra_args": ["--bits", "2048"]})
|
||||
|
||||
config = get_default_config()
|
||||
|
||||
config["key_archive"] = "/var/lib/rspamd/dkim_archive"
|
||||
config["update_script"] = "/usr/local/sbin/rspamd-dkim-update"
|
||||
config["domains"] = ["example.com"]
|
||||
|
||||
print(json.dumps(config, sort_keys=False, indent=4))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def read_config_file(path):
|
||||
config = get_default_config()
|
||||
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = f.read()
|
||||
except Exception as err:
|
||||
LOG.info("Can't open cfg file {}: {}".format(path, str(err)))
|
||||
data = None
|
||||
|
||||
if data is not None:
|
||||
try:
|
||||
j = json.loads(data)
|
||||
config.update(j)
|
||||
except Exception as err:
|
||||
die("Unable to parse cfg file {}: {}".format(path, str(err)))
|
||||
|
||||
if not config["key_directory"]:
|
||||
die("Key directory not configured")
|
||||
|
||||
if not os.path.isdir(config["key_directory"]):
|
||||
die("Key directory \"{}\" not found".format(config["key_directory"]))
|
||||
|
||||
try:
|
||||
os.chdir(config["key_directory"])
|
||||
except Exception as err:
|
||||
die("Failed to chdir to key directory: {}".format(err))
|
||||
|
||||
if config["key_archive"] and not os.path.isdir(config["key_archive"]):
|
||||
die("Key archive \"{}\" not found".format(config["key_archive"]))
|
||||
else:
|
||||
Key.key_archive = config["key_archive"]
|
||||
|
||||
if config["update_script"] and not os.access(config["update_script"], os.X_OK):
|
||||
die("Update script \"{}\" not found".format(config["update_script"]))
|
||||
else:
|
||||
Key.update_script = config["update_script"]
|
||||
|
||||
if not isinstance(config["domains"], list) or len(config["domains"]) < 1:
|
||||
die("No domains configured")
|
||||
|
||||
if isinstance(config["key_types"], list) and len(config["key_types"]) > 0:
|
||||
for key_type in config["key_types"]:
|
||||
try:
|
||||
KeyType(key_type)
|
||||
except Exception as err:
|
||||
die("Invalid key type definition: {}".format(err))
|
||||
else:
|
||||
die("No key types configured")
|
||||
|
||||
if isinstance(config["future_period"], int) and config["future_period"] > 0:
|
||||
Key.future_period = config["future_period"]
|
||||
else:
|
||||
die("Invalid future_period")
|
||||
|
||||
if isinstance(config["active_period"], int) and config["active_period"] > 0:
|
||||
Key.active_period = config["active_period"]
|
||||
else:
|
||||
die("Invalid active_period")
|
||||
|
||||
if isinstance(config["expired_period"], int) and config["expired_period"] > 0:
|
||||
Key.expired_period = config["expired_period"]
|
||||
else:
|
||||
die("Invalid expired_period")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def main():
|
||||
os.umask(0o027)
|
||||
args_parser = get_args_parser()
|
||||
args_parser.set_defaults(loglevel=logging.WARN)
|
||||
args = args_parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
format='%(levelname)s{}, %(asctime)s: %(message)s'.format(
|
||||
' (%(filename)s:%(lineno)s)'
|
||||
if args.loglevel <= logging.DEBUG else '',
|
||||
),
|
||||
level=args.loglevel,
|
||||
)
|
||||
|
||||
if args.print_config:
|
||||
print_example_config()
|
||||
|
||||
if shutil.which("rspamadm") is None:
|
||||
die("rspamadm executable not found")
|
||||
|
||||
if args.month == 0:
|
||||
current_time = time.localtime()
|
||||
args.month = current_time.tm_year * 12 + current_time.tm_mon
|
||||
Key.current_month = args.month
|
||||
|
||||
Key.dryrun = args.dryrun
|
||||
KeyConfig.dryrun = args.dryrun
|
||||
|
||||
config = read_config_file(args.config_path)
|
||||
|
||||
LOG.debug("Current month: {}".format(Key.current_month))
|
||||
|
||||
active_keys = KeyConfig(config["active_config"], "active")
|
||||
active_keys.read()
|
||||
active_keys.dump("old")
|
||||
|
||||
future_keys = KeyConfig(config["future_config"], "future")
|
||||
future_keys.read()
|
||||
future_keys.dump("old")
|
||||
|
||||
expired_keys = KeyConfig(config["expired_config"], "expired")
|
||||
expired_keys.read()
|
||||
expired_keys.dump("old")
|
||||
|
||||
LOG.debug("Handling active and future keys")
|
||||
for domain in config["domains"]:
|
||||
for key_type in KeyType.instances:
|
||||
|
||||
# First, check for an already valid & configured key
|
||||
active_key = active_keys.get(domain, key_type, "active")
|
||||
if active_key:
|
||||
LOG.info("Using existing key {}".format(active_key.key_path))
|
||||
|
||||
# Second, see if there's a future key which has become active
|
||||
if active_key is None:
|
||||
active_key = future_keys.get(domain, key_type, "active", remove=True)
|
||||
if active_key:
|
||||
LOG.info("Using future key {}".format(active_key.key_path))
|
||||
active_keys.add(active_key)
|
||||
|
||||
# Third, since expired keys should still have valid DNS records,
|
||||
# keep using an expired key while waiting for a future key to become
|
||||
# active.
|
||||
if active_key is None:
|
||||
active_key = active_keys.get(domain, key_type, "expired")
|
||||
if active_key:
|
||||
LOG.info("Using expired key {}".format(active_key.key_path))
|
||||
active_key.state = "active"
|
||||
|
||||
# As a fallback, create a brand new key
|
||||
if active_key is None:
|
||||
try:
|
||||
active_key = Key()
|
||||
active_key.create(domain, key_type, 0)
|
||||
except Exception as err:
|
||||
die("Can't create active key for domain {}: {}".format(
|
||||
domain, err))
|
||||
|
||||
LOG.info("Using created key {}".format(active_key.key_path))
|
||||
active_keys.add(active_key)
|
||||
|
||||
# Finally, make sure we have a future key if expiry is approaching
|
||||
if Key.active_period - active_key.age <= Key.future_period:
|
||||
future_key = future_keys.get(domain, key_type, "future")
|
||||
if future_key is None:
|
||||
try:
|
||||
future_key = Key()
|
||||
future_key.create(domain, key_type, Key.future_period)
|
||||
except Exception as err:
|
||||
die("Can't create future key for domain {}: {}".format(
|
||||
domain, err))
|
||||
|
||||
LOG.info("Created future key {}".format(future_key.key_path))
|
||||
future_keys.add(future_key)
|
||||
|
||||
LOG.debug("Handling expired/invalid keys")
|
||||
invalid_keys = []
|
||||
for key in active_keys.remove_invalid() + future_keys.remove_invalid():
|
||||
if key.state == "expired":
|
||||
LOG.info("Key {} expired".format(key.key_path))
|
||||
expired_keys.add(key)
|
||||
else:
|
||||
invalid_keys.append(key)
|
||||
|
||||
LOG.debug("Handling dead/invalid keys")
|
||||
dead_keys = invalid_keys + expired_keys.remove_invalid()
|
||||
for key in dead_keys:
|
||||
LOG.info("Key {} invalid/dead".format(key.key_path))
|
||||
key.delete()
|
||||
|
||||
LOG.debug("Writing updated configuration files")
|
||||
active_keys.write()
|
||||
future_keys.write()
|
||||
expired_keys.write()
|
||||
|
||||
active_keys.dump("new")
|
||||
future_keys.dump("new")
|
||||
expired_keys.dump("new")
|
||||
|
||||
if Key.pending_changes():
|
||||
LOG.debug("Changes detected, potentially calling update_script")
|
||||
Key.run_update_script()
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -0,0 +1,370 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (C) 2021 David Härdeman <david@hardeman.nu>
|
||||
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR Apache-2.0
|
||||
|
||||
"""Rspamd DKIM key update/notification script
|
||||
|
||||
This script is meant to be called by rspamd-dkim-keygen and will perform
|
||||
various actions (log, email the administrator, or attempt an automated
|
||||
update using nsupdate) when DNS entries for DKIM keys should be
|
||||
added/removed.
|
||||
"""
|
||||
|
||||
from __future__ import (print_function)
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import socket
|
||||
import logging
|
||||
import smtplib
|
||||
import argparse
|
||||
import datetime
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
__license__ = 'GPL-2.0 OR Apache-2.0'
|
||||
__author__ = 'David Härdeman <david@hardeman.nu>'
|
||||
__version__ = '1.0.1'
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def die(msg):
|
||||
LOG.error(msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_args_parser():
|
||||
args_parser = argparse.ArgumentParser(
|
||||
prog='rspamd-dkim-update',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
description="Update DKIM configuration in DNS.",
|
||||
)
|
||||
args_parser.add_argument(
|
||||
'-V', '--version',
|
||||
action='version',
|
||||
version=__version__,
|
||||
)
|
||||
args_parser.add_argument(
|
||||
'-d', '--debug',
|
||||
help="Enable debug output",
|
||||
action='store_const',
|
||||
dest='loglevel',
|
||||
const=logging.DEBUG,
|
||||
)
|
||||
args_parser.add_argument(
|
||||
'-v', '--verbose',
|
||||
help="Enable verbose output",
|
||||
action="store_const",
|
||||
dest="loglevel",
|
||||
const=logging.INFO,
|
||||
)
|
||||
args_parser.add_argument(
|
||||
'-c', '--config',
|
||||
help="Configuration file path",
|
||||
default="/etc/rspamd/dkim-update.json",
|
||||
dest="config_path",
|
||||
)
|
||||
args_parser.add_argument(
|
||||
'-C', '--print-config',
|
||||
help="Print a configuration file example",
|
||||
action="store_true",
|
||||
default=False,
|
||||
dest="print_config",
|
||||
)
|
||||
args_parser.add_argument(
|
||||
metavar='<"add"/"del"> <domain> <selector> <dns_entry_file_path>',
|
||||
help="4-tuple(s) defining the key which has been added/removed",
|
||||
nargs="*",
|
||||
dest="keys",
|
||||
)
|
||||
|
||||
return args_parser
|
||||
|
||||
|
||||
def get_default_config():
|
||||
return {
|
||||
"method": "log/email/nsupdate/nsupdate_tsig/nsupdate_gsstsig",
|
||||
"log_file": "/var/log/rspamd/rspamd-dkim-update.log",
|
||||
"email_to": "root@localhost",
|
||||
"email_from": "noreply@{}".format(socket.getfqdn()),
|
||||
"email_host": "localhost",
|
||||
"email_port": 25,
|
||||
"email_subject": "Rspamd DKIM DNS updates",
|
||||
"nsupdate_keyfile": "/etc/rspamd/dkim_dns_key",
|
||||
"nsupdate_gsstsig_princ": "dnsupdate@EXAMPLE.COM",
|
||||
"nsupdate_ttl": 3600,
|
||||
"nsupdate_server": "dns.example.com",
|
||||
}
|
||||
|
||||
|
||||
def print_example_config():
|
||||
config = get_default_config()
|
||||
print(json.dumps(config, sort_keys=False, indent=4))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def read_config_file(path):
|
||||
config = get_default_config()
|
||||
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = f.read()
|
||||
except Exception as err:
|
||||
LOG.info("Can't open cfg file {}: {}".format(path, str(err)))
|
||||
data = None
|
||||
|
||||
if data is not None:
|
||||
try:
|
||||
j = json.loads(data)
|
||||
config.update(j)
|
||||
except Exception as err:
|
||||
die("Can't parse cfg file {}: {}".format(path, str(err)))
|
||||
|
||||
if not config["method"]:
|
||||
die("Method not configured")
|
||||
|
||||
elif config["method"] == "log":
|
||||
if not config["log_file"]:
|
||||
die("Log file path not configured")
|
||||
|
||||
elif config["method"] == "email":
|
||||
if not config["email_to"]:
|
||||
die("Recipient email address not configured")
|
||||
|
||||
if not config["email_host"]:
|
||||
die("SMTP host not configured")
|
||||
|
||||
if not config["email_port"]:
|
||||
die("SMTP port not configured")
|
||||
|
||||
if not config["email_subject"]:
|
||||
die("Email subject not configured")
|
||||
|
||||
elif config["method"] in ["nsupdate", "nsupdate_tsig", "nsupdate_gsstsig"]:
|
||||
if (config["method"] in ["nsupdate_tsig", "nsupdate_gsstsig"] and
|
||||
not config["nsupdate_keyfile"]):
|
||||
die("nsupdate keyfile not configured")
|
||||
|
||||
if (config["method"] == "nsupdate_gsstsig" and
|
||||
not config["nsupdate_gsstsig_princ"]):
|
||||
die("nsupdate gsstsig principal not configured")
|
||||
|
||||
# Note: invalid methods are caught in main()
|
||||
return config
|
||||
|
||||
|
||||
def get_keys(key_list):
|
||||
for i in range(0, len(key_list), 4):
|
||||
action = key_list[i]
|
||||
domain = key_list[i + 1]
|
||||
selector = key_list[i + 2]
|
||||
dns_file = key_list[i + 3]
|
||||
name = "{}._domainkey".format(selector)
|
||||
domain_name = "{}.{}.".format(name, domain)
|
||||
rr = "{} IN TXT".format(domain_name)
|
||||
|
||||
rr_data = ""
|
||||
# The public key files generated by rspamadm have the following format:
|
||||
# <selector>._domainkey IN TXT ( "v=DKIM1; k=<keytype>; "
|
||||
# \t"chunk1"
|
||||
# \t"chunk2"
|
||||
# \t...
|
||||
# \t"chunkN"
|
||||
# ) ;
|
||||
#
|
||||
# The last line can also be:
|
||||
# \t"chunkN" ) ;
|
||||
#
|
||||
if action == "add":
|
||||
try:
|
||||
with open(dns_file, "r") as f:
|
||||
rr_data = f.read().strip()
|
||||
except Exception as err:
|
||||
die("Can't open file {}: {}".format(dns_file, str(err)))
|
||||
|
||||
parts = rr_data.split(maxsplit=3)
|
||||
if len(parts) != 4:
|
||||
die("Invalid file {}: missing parts".format(dns_file))
|
||||
elif parts[0] != name:
|
||||
die("Invalid file {}: name mismatch".format(dns_file))
|
||||
elif parts[1].upper() != "IN":
|
||||
die("Invalid file {}: IN missing".format(dns_file))
|
||||
elif parts[2].upper() != "TXT":
|
||||
die("Invalid file {}: TXT missing".format(dns_file))
|
||||
|
||||
rr_lines = parts[3].strip("\r\n\t ();").split("\n")
|
||||
# nsupdate wants the data in < 256 byte chunks
|
||||
rr_data = " ".join([line.strip("\r\n\t ") for line in rr_lines])
|
||||
|
||||
elif action == "del":
|
||||
action = "delete"
|
||||
|
||||
else:
|
||||
die("Invalid action specified")
|
||||
|
||||
yield {
|
||||
"action": action,
|
||||
"domain": domain,
|
||||
"selector": selector,
|
||||
"dns_file": dns_file,
|
||||
"name": name,
|
||||
"domain_name": domain_name,
|
||||
"rr": rr,
|
||||
"rr_data": rr_data,
|
||||
}
|
||||
|
||||
|
||||
def do_nsupdate(config, keys):
|
||||
# First, prepare the commands and make sure all executables are present
|
||||
if shutil.which("nsupdate") is None:
|
||||
die("nsupdate executable not found")
|
||||
|
||||
pre_cmd = []
|
||||
main_cmd = ["nsupdate"]
|
||||
post_cmd = []
|
||||
|
||||
if config["method"] in ["nsupdate_tsig", "nsupdate_gsstsig"]:
|
||||
if not os.path.isfile(config["nsupdate_keyfile"]):
|
||||
die("keyfile {} missing".format(config["nsupdate_keyfile"]))
|
||||
|
||||
if config["method"] == "nsupdate_tsig":
|
||||
main_cmd += ["-k", config["nsupdate_keyfile"]]
|
||||
|
||||
elif config["method"] == "nsupdate_gsstsig":
|
||||
main_cmd += ["-g"]
|
||||
|
||||
if shutil.which("kinit") is None:
|
||||
die("kinit executable not found")
|
||||
pre_cmd = [
|
||||
"kinit", "-k",
|
||||
"-t", config["nsupdate_keyfile"],
|
||||
"-p", config["nsupdate_gsstsig_princ"]
|
||||
]
|
||||
|
||||
if shutil.which("kdestroy") is None:
|
||||
die("kdestroy executable not found")
|
||||
post_cmd = ["kdestroy"]
|
||||
|
||||
# Second, prepare the list of nsupdate commands
|
||||
cmds = ""
|
||||
|
||||
if config["nsupdate_server"]:
|
||||
cmds += "server {}\n".format(config["nsupdate_server"])
|
||||
|
||||
if config["nsupdate_ttl"]:
|
||||
cmds += "ttl {}\n".format(config["nsupdate_ttl"])
|
||||
|
||||
for key in keys:
|
||||
cmds += "update {} {} {}\n".format(
|
||||
key["action"].lower(),
|
||||
key["rr"],
|
||||
key["rr_data"])
|
||||
|
||||
cmds += "send\n"
|
||||
|
||||
# Third, launch our commands
|
||||
if pre_cmd:
|
||||
LOG.debug("Calling nsupdate pre cmd: {}".format(pre_cmd))
|
||||
if subprocess.call(pre_cmd) != 0:
|
||||
die("Failed to call {}".format(pre_cmd))
|
||||
|
||||
LOG.debug("Calling {}".format(main_cmd))
|
||||
main_cmd_rc = subprocess.call(main_cmd)
|
||||
|
||||
# Note: we always want to call post_cmd, even if main_cmd failed
|
||||
if post_cmd:
|
||||
LOG.debug("Calling nsupdate post cmd: {}".format(post_cmd))
|
||||
if subprocess.call(post_cmd) != 0:
|
||||
die("Failed to call {}".format(post_cmd))
|
||||
|
||||
if main_cmd_rc != 0:
|
||||
die("Failed to call {}".format(main_cmd))
|
||||
|
||||
|
||||
def create_message(config, keys, sep):
|
||||
msg = "{}{}Executed at {}{}".format(
|
||||
sys.argv[0],
|
||||
sep,
|
||||
datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S"),
|
||||
sep)
|
||||
|
||||
msg += "The following DKIM key changes need to be made in the DNS" + sep
|
||||
msg += "========================================================="
|
||||
|
||||
for key in keys:
|
||||
msg += "{}{} {} {}{}".format(
|
||||
sep,
|
||||
key["action"].upper(),
|
||||
key["rr"],
|
||||
key["rr_data"],
|
||||
sep)
|
||||
|
||||
msg += "=========================================================" + sep
|
||||
msg += sep
|
||||
msg += sep
|
||||
return msg
|
||||
|
||||
|
||||
def do_email(config, keys):
|
||||
msg = "From: {}\r\nTo: {}\r\nSubject: {}\r\n\r\n".format(
|
||||
config["email_from"],
|
||||
config["email_to"],
|
||||
config["email_subject"])
|
||||
|
||||
msg += create_message(config, keys, "\r\n")
|
||||
|
||||
with smtplib.SMTP(host=config["email_host"],
|
||||
port=config["email_port"]) as smtp:
|
||||
# smtp.set_debuglevel(1)
|
||||
smtp.sendmail(config["email_from"], config["email_to"], msg)
|
||||
|
||||
|
||||
def do_log(config, keys):
|
||||
if not config["log_file"]:
|
||||
return
|
||||
|
||||
msg = create_message(config, keys, "\n")
|
||||
with open(config["log_file"], "a") as f:
|
||||
f.write(msg)
|
||||
|
||||
|
||||
def main():
|
||||
args_parser = get_args_parser()
|
||||
args_parser.set_defaults(loglevel=logging.WARN)
|
||||
args = args_parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
format='%(levelname)s{}, %(asctime)s: %(message)s'.format(
|
||||
' (%(filename)s:%(lineno)s)'
|
||||
if args.loglevel <= logging.DEBUG else '',
|
||||
),
|
||||
level=args.loglevel,
|
||||
)
|
||||
|
||||
if args.print_config:
|
||||
print_example_config()
|
||||
|
||||
if len(args.keys) < 4:
|
||||
die("At least 4 positional arguments are necessary")
|
||||
|
||||
if len(args.keys) % 4 != 0:
|
||||
die("A multiple of 4 positional arguments are necessary")
|
||||
|
||||
config = read_config_file(args.config_path)
|
||||
keys = list(get_keys(args.keys))
|
||||
|
||||
if config["method"] == "email":
|
||||
do_email(config, keys)
|
||||
elif config["method"] in ["nsupdate", "nsupdate_tsig", "nsupdate_gsstsig"]:
|
||||
do_nsupdate(config, keys)
|
||||
elif config["method"] != "log":
|
||||
die("Invalid method configured")
|
||||
|
||||
do_log(config, keys)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
33
ansible_collections/debops/debops/roles/rspamd/meta/main.yml
Normal file
33
ansible_collections/debops/debops/roles/rspamd/meta/main.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
# Copyright (C) 2021 David Härdeman <david@hardeman.nu>
|
||||
# Copyright (C) 2020 CipherMail B.V. <https://www.ciphermail.com/>
|
||||
# Copyright (C) 2021 DebOps <https://debops.org/>
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
# Ensure that custom Ansible plugins and modules included in the main DebOps
|
||||
# collection are available to roles in other collections.
|
||||
collections: [ 'debops.debops' ]
|
||||
|
||||
dependencies: []
|
||||
|
||||
galaxy_info:
|
||||
|
||||
author: 'David Härdeman'
|
||||
description: 'Install and manage Rspamd service'
|
||||
company: 'DebOps'
|
||||
license: 'GPL-3.0-only'
|
||||
min_ansible_version: '2.3.0'
|
||||
|
||||
platforms:
|
||||
|
||||
- name: 'Ubuntu'
|
||||
versions: [ 'all' ]
|
||||
|
||||
- name: 'Debian'
|
||||
versions: [ 'all' ]
|
||||
|
||||
galaxy_tags:
|
||||
- mail
|
||||
- spam
|
||||
- smtp
|
||||
- antispam
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
# Copyright (C) 2021 David Härdeman <david@hardeman.nu>
|
||||
# Copyright (C) 2020 CipherMail B.V. <https://www.ciphermail.com/>
|
||||
# Copyright (C) 2021 DebOps <https://debops.org/>
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
- name: Import custom Ansible plugins
|
||||
ansible.builtin.import_role:
|
||||
name: 'ansible_plugins'
|
||||
|
||||
- name: Import DebOps global handlers
|
||||
ansible.builtin.import_role:
|
||||
name: 'global_handlers'
|
||||
|
||||
- name: Import DebOps secret role
|
||||
ansible.builtin.import_role:
|
||||
name: 'secret'
|
||||
|
||||
- name: Install rspamd packages
|
||||
ansible.builtin.package:
|
||||
name: '{{ q("flattened", (rspamd__base_packages
|
||||
+ rspamd__packages)) }}'
|
||||
state: 'present'
|
||||
register: rspamd__register_packages
|
||||
until: rspamd__register_packages is succeeded
|
||||
tags: [ 'role::rspamd:pkg' ]
|
||||
|
||||
- name: Make sure that the Ansible local facts directory exists
|
||||
ansible.builtin.file:
|
||||
path: '/etc/ansible/facts.d'
|
||||
state: 'directory'
|
||||
mode: '0755'
|
||||
|
||||
- name: Save rspamd local facts
|
||||
ansible.builtin.template:
|
||||
src: 'etc/ansible/facts.d/rspamd.fact.j2'
|
||||
dest: '/etc/ansible/facts.d/rspamd.fact'
|
||||
mode: '0755'
|
||||
notify: [ 'Refresh host facts' ]
|
||||
tags: [ 'meta::facts' ]
|
||||
|
||||
- name: Create configuration directories and files
|
||||
ansible.builtin.include_tasks: main_cfgdir.yml
|
||||
loop:
|
||||
- { 'path': '/etc/rspamd/local.d',
|
||||
'config': '{{ rspamd__combined_local_configuration
|
||||
| debops.debops.parse_kv_items(name="file") }}' }
|
||||
- { 'path': '/etc/rspamd/override.d',
|
||||
'config': '{{ rspamd__combined_override_configuration
|
||||
| debops.debops.parse_kv_items(name="file") }}' }
|
||||
loop_control:
|
||||
loop_var: cfgdir
|
||||
label: '{{ cfgdir.path }}'
|
||||
|
||||
- name: Handle DKIM configuration
|
||||
ansible.builtin.include_tasks: main_dkim.yml
|
||||
|
||||
- name: Update Ansible facts and restart Rspamd if necessary
|
||||
ansible.builtin.meta: 'flush_handlers'
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
# Copyright (C) 2021 David Härdeman <david@hardeman.nu>
|
||||
# Copyright (C) 2020 CipherMail B.V. <https://www.ciphermail.com/>
|
||||
# Copyright (C) 2021 DebOps <https://debops.org/>
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
- name: Create directory {{ cfgdir.path }}
|
||||
ansible.builtin.file:
|
||||
path: '{{ cfgdir.path }}'
|
||||
state: 'directory'
|
||||
owner: 'root'
|
||||
group: 'root'
|
||||
mode: '0755'
|
||||
|
||||
- name: Create configuration snippets in {{ cfgdir.path }}
|
||||
ansible.builtin.template:
|
||||
src: 'etc/rspamd/snippet.j2'
|
||||
dest: '{{ cfgdir.path + "/" + item.file }}'
|
||||
owner: '{{ item.owner | d("root") }}'
|
||||
group: '{{ item.group | d("_rspamd") }}'
|
||||
mode: '{{ item.mode | d("0640") }}'
|
||||
loop: '{{ cfgdir.config
|
||||
| rejectattr("state", "in", ["absent", "init", "ignore"]) }}'
|
||||
loop_control:
|
||||
label: '{{ item.file }}'
|
||||
notify: [ 'Restart rspamd' ]
|
||||
|
||||
- name: Generate a list of created configuration snippets in {{ cfgdir.path }}
|
||||
ansible.builtin.set_fact:
|
||||
rspamd__created_snippets: '{{
|
||||
cfgdir.config
|
||||
| rejectattr("state", "in", ["absent", "init"])
|
||||
| map(attribute="file")
|
||||
| map("regex_replace", "^", cfgdir.path + "/") }}'
|
||||
|
||||
- name: Find all configuration snippets in {{ cfgdir.path }}
|
||||
ansible.builtin.find:
|
||||
paths: '{{ cfgdir.path }}'
|
||||
recurse: no
|
||||
file_type: 'file'
|
||||
register: rspamd__found_snippets
|
||||
|
||||
- name: Remove superfluous configuration snippets from {{ cfgdir.path }}
|
||||
ansible.builtin.file:
|
||||
path: '{{ item }}'
|
||||
state: 'absent'
|
||||
loop: '{{ rspamd__found_snippets.files
|
||||
| map(attribute="path")
|
||||
| list
|
||||
| difference(rspamd__created_snippets) }}'
|
||||
notify: [ 'Restart rspamd' ]
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
---
|
||||
# Copyright (C) 2021 David Härdeman <david@hardeman.nu>
|
||||
# Copyright (C) 2021 DebOps <https://debops.org/>
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
- name: Make sure the DKIM directories exist
|
||||
ansible.builtin.file:
|
||||
path: '{{ item }}'
|
||||
state: 'directory'
|
||||
mode: '0750'
|
||||
owner: '_rspamd'
|
||||
group: '_rspamd'
|
||||
loop:
|
||||
- '/var/lib/rspamd'
|
||||
- '/var/lib/rspamd/dkim'
|
||||
- '/var/lib/rspamd/dkim-archive'
|
||||
- '{{ rspamd__dkim_log_dir }}'
|
||||
when: rspamd__dkim_enabled | d(False)
|
||||
|
||||
- name: Create DKIM key generation/update configuration
|
||||
ansible.builtin.template:
|
||||
src: 'etc/rspamd/dkim-json.j2'
|
||||
dest: '{{ item.dest }}'
|
||||
mode: '0644'
|
||||
loop:
|
||||
- { config: '{{ rspamd__dkim_keygen_combined_configuration }}',
|
||||
dest: "/etc/rspamd/dkim-keygen.json" }
|
||||
- { config: '{{ rspamd__dkim_update_combined_configuration }}',
|
||||
dest: "/etc/rspamd/dkim-update.json" }
|
||||
loop_control:
|
||||
label: '{{ item.dest }}'
|
||||
when: rspamd__dkim_enabled | d(False)
|
||||
|
||||
- name: Install DKIM scripts
|
||||
ansible.builtin.copy:
|
||||
src: "{{ lookup('debops.debops.file_src', item.src) }}"
|
||||
dest: "{{ item.dest }}"
|
||||
owner: '{{ item.owner | d("root") }}'
|
||||
group: '{{ item.group | d("root") }}'
|
||||
mode: '{{ item.mode | d("0755") }}'
|
||||
with_items:
|
||||
- { src: 'usr/local/sbin/rspamd-dkim-keygen',
|
||||
dest: '/usr/local/sbin/rspamd-dkim-keygen' }
|
||||
- { src: 'usr/local/sbin/rspamd-dkim-update',
|
||||
dest: '/usr/local/sbin/rspamd-dkim-update' }
|
||||
when: rspamd__dkim_enabled | d(False)
|
||||
|
||||
- name: See if a nsupdate keyfile/keytab exists on the Ansible controller
|
||||
ansible.builtin.set_fact:
|
||||
rspamd__dkim_local_keyfile: '{{ lookup("debops.debops.file_src", "etc/rspamd/dkim_dns_key", errors="ignore") }}'
|
||||
when: >
|
||||
rspamd__dkim_enabled | d(False) and
|
||||
rspamd__dkim_update_method in ["nsupdate_tsig", "nsupdate_gsstsig"]
|
||||
|
||||
- name: Copy nsupdate keyfile/keytab from controller to host
|
||||
ansible.builtin.copy:
|
||||
src: '{{ rspamd__dkim_local_keyfile }}'
|
||||
dest: '/etc/rspamd/dkim_dns_key'
|
||||
owner: 'root'
|
||||
group: '_rspamd'
|
||||
mode: '0640'
|
||||
when: >
|
||||
rspamd__dkim_enabled | d(False) and
|
||||
rspamd__dkim_update_method in ["nsupdate_tsig", "nsupdate_gsstsig"] and
|
||||
rspamd__dkim_local_keyfile is defined and
|
||||
rspamd__dkim_local_keyfile | length > 0
|
||||
|
||||
- name: Create DKIM keys
|
||||
ansible.builtin.command: /usr/local/sbin/rspamd-dkim-keygen
|
||||
become: True
|
||||
become_user: '_rspamd'
|
||||
register: rspamd__dkim_tmp_output
|
||||
changed_when: rspamd__dkim_tmp_output.rc == 2
|
||||
failed_when: rspamd__dkim_tmp_output.rc not in [0, 2]
|
||||
when: rspamd__dkim_enabled | d(False)
|
||||
notify: [ 'Restart rspamd' ]
|
||||
|
||||
- name: Configure DKIM key generation/rollover cron job
|
||||
ansible.builtin.cron:
|
||||
name: 'Generate/rollover DKIM keys for rspamd'
|
||||
special_time: 'monthly'
|
||||
cron_file: 'rspamd-dkim-keygen'
|
||||
user: '_rspamd'
|
||||
state: '{{ "present" if rspamd__dkim_enabled | d(False) else "absent" }}'
|
||||
job: '/usr/local/sbin/rspamd-dkim-keygen'
|
||||
|
||||
- name: Purge DKIM configuration/scripts
|
||||
ansible.builtin.file:
|
||||
path: '{{ item }}'
|
||||
state: 'absent'
|
||||
loop:
|
||||
- '/etc/rspamd/dkim-keygen.json'
|
||||
- '/etc/rspamd/dkim-update.json'
|
||||
- '/usr/local/sbin/rspamd-dkim-keygen'
|
||||
- '/usr/local/sbin/rspamd-dkim-update'
|
||||
when: not rspamd__dkim_enabled | d(False)
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
#!{{ ansible_python['executable'] }}
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (C) 2021 David Härdeman <david@hardeman.nu>
|
||||
# Copyright (C) 2021 DebOps <https://debops.org/>
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
# {{ ansible_managed }}
|
||||
|
||||
from __future__ import print_function
|
||||
from json import loads, dumps
|
||||
from sys import exit
|
||||
import subprocess
|
||||
import signal
|
||||
import os
|
||||
|
||||
|
||||
def cmd_exists(cmd):
|
||||
return any(
|
||||
os.access(os.path.join(path, cmd), os.X_OK)
|
||||
for path in os.environ["PATH"].split(os.pathsep)
|
||||
)
|
||||
|
||||
|
||||
def get_value(input_line, key, output, output_key=None):
|
||||
if input_line.startswith(key):
|
||||
output[output_key if output_key is not None else key] = \
|
||||
input_line.split("=", 1)[1].strip(' "\'\t\r\n;')
|
||||
|
||||
|
||||
output = {}
|
||||
|
||||
output['installed'] = cmd_exists('rspamd')
|
||||
|
||||
if output['installed']:
|
||||
try:
|
||||
rspamd_version_stdout = subprocess.check_output(
|
||||
["dpkg-query", "-W", "-f=${Version}",
|
||||
"rspamd"]).decode('utf-8').split('-')[0]
|
||||
output['version'] = rspamd_version_stdout
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if os.path.isfile('/etc/rspamd/local.d/worker-proxy.inc'):
|
||||
try:
|
||||
with open('/etc/rspamd/local.d/worker-proxy.inc') as fh:
|
||||
for line in fh:
|
||||
get_value(line, 'milter', output, 'milter_enabled')
|
||||
get_value(line, 'bind_socket', output, 'milter_socket')
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if output.get('milter_enabled', 'false').lower() in ['true', 'yes', 'on']:
|
||||
output['milter_enabled'] = True
|
||||
milter_host_port = output.get('milter_socket', 'localhost:11332').split(":")
|
||||
output['milter_host'] = milter_host_port[0].strip()
|
||||
if len(milter_host_port) > 1:
|
||||
output['milter_port'] = milter_host_port[1].strip()
|
||||
else:
|
||||
output['milter_port'] = 11332
|
||||
else:
|
||||
output['milter_enabled'] = False
|
||||
|
||||
output['redis_enabled'] = False
|
||||
if os.path.isfile('/etc/rspamd/local.d/redis.conf'):
|
||||
try:
|
||||
with open('/etc/rspamd/local.d/redis.conf') as fh:
|
||||
for line in fh:
|
||||
get_value(line, 'servers', output, 'redis_servers')
|
||||
get_value(line, 'db', output, 'redis_db')
|
||||
get_value(line, 'password', output, 'redis_password')
|
||||
if 'redis_servers' in output:
|
||||
output['redis_servers'] = output['redis_servers'].split(',')
|
||||
output['redis_enabled'] = True
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
output['dkim_enabled'] = False
|
||||
if os.path.isfile('/etc/rspamd/local.d/dkim_signing.conf'):
|
||||
try:
|
||||
with open('/etc/rspamd/local.d/dkim_signing.conf') as fh:
|
||||
for line in fh:
|
||||
get_value(line, 'path', output, 'dkim_path')
|
||||
get_value(line, 'selector', output, 'dkim_selector')
|
||||
if ('dkim_path' in output and 'dkim_selector' in output and
|
||||
os.path.isfile(output['dkim_path'])):
|
||||
output['dkim_enabled'] = True
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
print(dumps(output, sort_keys=True, indent=4))
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{# Copyright (C) 2021 David Härdeman <david@hardeman.nu>
|
||||
# Copyright (C) 2021 DebOps <https://debops.org/>
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
#}
|
||||
{% set output = { "_comments": [ item.dest, ansible_managed ] } %}
|
||||
{% for opt in item.config | debops.debops.parse_kv_items %}
|
||||
{% set _ = output.update({ opt.name : opt.value }) %}
|
||||
{% endfor %}
|
||||
{{ output | to_nice_json }}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
{# Copyright (C) 2021 David Härdeman <david@hardeman.nu>
|
||||
# Copyright (C) 2021 DebOps <https://debops.org>
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
#}
|
||||
# {{ ansible_managed }}
|
||||
#
|
||||
# /etc/rspamd/local.d/{{ item.file }}
|
||||
{% if item.comment | d() %}
|
||||
{{ item.comment | regex_replace('\n$', '') | comment(prefix='#', postfix='#', decoration='# ') }}
|
||||
{% endif %}
|
||||
|
||||
{% macro print_element(element, commented, loop_first, indent) %}
|
||||
{% set prefix = ("{:^" + indent | string + "}").format('') %}
|
||||
{% set comment_prefix = (prefix + "# ") %}
|
||||
{% if commented or element.state | d('present') == 'comment' %}
|
||||
{% set commented = True %}
|
||||
{% set prefix = comment_prefix %}
|
||||
{% else %}
|
||||
{% set commented = False %}
|
||||
{% endif %}
|
||||
{% if element.options | d() and not loop_first %}
|
||||
{{ '' }}
|
||||
{% endif %}
|
||||
{% if element.comment | d() %}
|
||||
{{ element.comment | regex_replace('\n$', '') | comment(prefix='', decoration=comment_prefix, postfix='') -}}
|
||||
{% endif %}
|
||||
{% if element.raw | d() %}
|
||||
{% if commented %}
|
||||
{{ '{}'.format(element.raw | regex_replace('\n$', '') | comment(prefix='', decoration=comment_prefix, postfix='')) -}}
|
||||
{% else %}
|
||||
{{ '{}'.format(element.raw | regex_replace('\n$', '')) }}
|
||||
{% endif %}
|
||||
{% elif element.options | d() %}
|
||||
{{ '{}{} {{'.format(prefix, element.option | d(element.name) | regex_replace('\n$', '')) }}
|
||||
{% for element in element.options %}
|
||||
{% if element.state | d('present') not in [ 'absent', 'ignore', 'init' ] %}
|
||||
{{ print_element(element, commented, loop.first, indent + 8) -}}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ '{}}}'.format(prefix) }}
|
||||
{% else %}
|
||||
{% if element.value is not string and element.value is not mapping and element.value is iterable %}
|
||||
{% set element_value = [] %}
|
||||
{% for option in element.value %}
|
||||
{% if option.state | d('present') != 'absent' and option.name | d("") | length > 0 %}
|
||||
{% set _ = element_value.append(option.name) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% set element_value = element.value | d("") | to_json %}
|
||||
{% endif %}
|
||||
{{ '{}{} = {};'.format(prefix, element.option | d(element.name), element_value) }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
{##}
|
||||
{% if item.options %}
|
||||
{% for option in item.options %}
|
||||
{% if option.state | d('present') not in [ 'absent', 'init', 'ignore' ] %}
|
||||
{{ print_element(option, option.state | d('present') == 'comment', loop.first, 0) -}}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
Loading…
Reference in a new issue