Vendor Galaxy Roles and Collections
Some checks failed
/ Ansible Lint (push) Failing after 5m45s
/ Ansible Lint (pull_request) Failing after 4m59s

This commit is contained in:
Stefan Bethke 2026-02-06 22:07:16 +01:00
commit 2aed20393f
3553 changed files with 387444 additions and 2 deletions

View 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/.

View 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'
# ]]]
# ]]]
# ]]]

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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