Vendor Galaxy Roles and Collections

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,19 @@
debops.bind - Manage the BIND DNS server using Ansible
Copyright (C) 2022 David Härdeman <david@hardeman.nu>
Copyright (C) 2022 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/.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,721 @@
#!/usr/bin/env python3
# Copyright (C) 2022 David Härdeman <david@hardeman.nu>
# Copyright (C) 2022 DebOps <https://debops.org/>
# SPDX-License-Identifier: GPL-3.0-only
"""BIND DNSSEC key rollover parent zone update/notification script
This script is meant to be called periodically from cron and will check
for keys which need to be added to, or removed from, the parent zone.
When such keys are detected, an external utility will be executed or
an email will be sent to the administrator, requesting manual action.
"""
import os
import re
import sys
import json
import shutil
import socket
import smtplib
import logging
import argparse
import datetime
import subprocess
__license__ = 'GPL-3.0'
__author__ = 'David Härdeman <david@hardeman.nu>'
__version__ = '1.0.0'
LOG = logging.getLogger(__name__)
def die(msg):
if msg is not None:
LOG.error(msg)
sys.exit(1)
def run_process(config, args, change_locale=True):
"""Runs some external program and returns its stdout"""
result = {}
LOG.debug('Executing: "{}"'.format(' '.join(args)))
tmp_env = os.environ.copy()
if change_locale:
tmp_env["LC_ALL"] = "C"
with subprocess.Popen(args, text=True, env=tmp_env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE) as process:
stdout, stderr = process.communicate()
result.stdout = stdout.strip()
result.stderr = stderr.strip()
result.rc = process.returncode
if process.returncode != 0:
cmd = ' '.join(args)
msg = 'Command "{}" failed ({})'.format(cmd, stderr.strip())
LOG.debug('Command {} rc: {}'.format(cmd, process.returncode))
LOG.debug('Command {} stdout: {}'.format(cmd, stdout.strip()))
LOG.debug('Command {} stderr: {}'.format(cmd, stderr.strip()))
write_log_msg(config, msg + "\n")
result.error_msg = msg
return result
def run_process_get_stdout(config, args):
result = do_run_process(config, args, change_locale=True)
if result.rc != 0:
die(result.error_msg)
else:
return result.stdout
def run_process_ok(config, args):
result = do_run_process(config, args, change_locale=False)
if result.rc != 0:
LOG.error(result.error_msg)
return False
else:
return True
class Key:
"""Keeps track of a zone key.
Attributes:
alg_num: Mapping from algorithm names to IANA assigned numbers
See https://www.iana.org/assignments/dns-sec-alg-numbers/dns-sec-alg-numbers.xhtml.
"""
alg_num = {
"DSA": "3",
"RSASHA1": "5",
"DSA-NSEC3-SHA1": "6",
"RSASHA1-NSEC3-SHA1": "7",
"RSASHA256": "8",
"RSASHA512": "10",
"ECC-GOST": "12",
"ECDSAP256SHA256": "13",
"ECDSAP384SHA384": "14",
"ED25519": "15",
"ED448": "16"
}
def __init__(self, zone, line):
self.valid = False
self.zone = zone
self.goal = None
self.ds_state = None
self.key_id = None
self.key_alg = None
self.key_alg_num = None
self.key_type = None
line = re.sub('\\s+', ' ', line.strip())
parts = line.split()
if len(parts) != 4:
LOG.debug('Invalid key description ({})'.format(line))
return
self.key_id = parts[1]
self.key_alg = parts[2].strip(' ,()')
if self.key_alg.upper() in Key.alg_num:
self.key_alg_num = Key.alg_num[self.key_alg.upper()]
else:
LOG.debug('Unknown key alg {} for key {}'.format(self.key_alg, self.key_id))
return
self.key_type = parts[3].upper()
if self.key_type not in ['KSK', 'CSK', 'ZSK']:
LOG.debug('Unknown key type {}, key {}'.format(self.key_type, self.key_id))
return
self.valid = True
def print(self):
if not self.valid:
LOG.debug('Invalid key')
return
LOG.debug('ID : {}'.format(self.key_id))
LOG.debug('Alg : {}'.format(self.key_alg))
LOG.debug('AlgN: {}'.format(self.key_alg_num))
LOG.debug('Type: {}'.format(self.key_type))
LOG.debug('Goal: {}'.format(
self.goal if self.goal is not None else "<unknown>"))
LOG.debug('DS : {}'.format(
self.ds_state if self.ds_state is not None else "<unknown>"))
def is_publishable(self):
if not self.valid:
return False
elif self.key_type.upper() not in ['KSK', 'CSK']:
return False
elif self.goal is None or self.ds_state is None:
return False
else:
return True
def needs_publication(self):
if not self.is_publishable():
return False
elif self.goal.lower() != 'omnipresent':
return False
elif self.ds_state.lower() != 'rumoured':
return False
return True
def needs_withdrawal(self):
if not self.is_publishable():
return False
elif self.goal.lower() != 'hidden':
return False
elif self.ds_state.lower() != 'unretentive':
return False
return True
def parse_line(self, line):
line = re.sub('\\s+', ' ', line.strip().lower())
parts = line.split(':')
if len(parts) != 2:
return
param = parts[0].strip()
value = parts[1].strip()
if value in ['hidden', 'rumoured', 'omnipresent', 'unretentive']:
state = value
else:
state = None
if param == '- goal':
if state is None:
LOG.debug('Unknown key goal: {}'.format(value))
else:
self.goal = state
elif param == '- ds':
if state is None:
LOG.debug('Unknown DS state: {}'.format(value))
else:
self.ds_state = state
class Zone:
"""Keeps track of a zone"""
def __init__(self, name, rr_class, view):
self.name = name
self.rr_class = rr_class
self.view = view
self.keys = None
def get_keys(self, config, rndc_output=None):
"""Get the currently known keys for a zone"""
if self.keys is not None:
return self.keys
if rndc_output is None:
args = ["rndc", "dnssec", "-status",
self.name, self.rr_class, self.view]
rndc_output = run_process(config, args)
"""
Entries can have the following states:
* hidden (not published)
* rumoured (partially published)
* omnipresent (completely published)
* unretentive (stale)
This is an example of the kind of output we need to parse:
root@foobar:~# rndc dnssec -status example.com
dnssec-policy: kskzsk
current time: Sat Jul 30 20:43:25 2022
key: 38979 (ECDSAP256SHA256), KSK
published: yes - since Fri Jul 29 23:12:42 2022
key signing: yes - since Fri Jul 29 23:12:42 2022
Next rollover scheduled on Sat Jul 29 23:12:42 2023
- goal: omnipresent
- dnskey: omnipresent
- ds: rumoured
- key rrsig: omnipresent
key: 3732 (ECDSAP256SHA256), ZSK
published: yes - since Fri Jul 29 22:58:05 2022
zone signing: yes - since Fri Jul 29 22:58:05 2022
Next rollover scheduled on Tue Sep 27 22:55:05 2022
- goal: omnipresent
- dnskey: omnipresent
- zone rrsig: omnipresent
key: 52180 (ECDSAP256SHA256), KSK
published: yes - since Fri Jul 29 22:58:05 2022
key signing: yes - since Fri Jul 29 22:58:05 2022
Rollover is due since Fri Jul 29 23:18:42 2022
- goal: hidden
- dnskey: omnipresent
- ds: unretentive
- key rrsig: omnipresent
Note that there is:
* one KSK (38979) which has a goal state of omnipresent but where the DS
record is rumoured (i.e. not known to be published in the parent zone)
* one KSK (52180) which has a goal state of hidden but where the DS record
is unretentive (i.e. not known to be removed from the parent zone)
"""
self.keys = []
key = None
for line in rndc_output.splitlines():
if line.startswith('key:'):
if key is not None:
self.keys.append(key)
key = Key(self, line)
continue
elif key is None:
continue
else:
key.parse_line(line)
if key is not None:
self.keys.append(key)
def get_keys_to_publish(self):
if self.rr_class.upper() != 'IN':
return []
elif self.keys is None:
return []
keys = []
for key in self.keys:
if key.needs_publication():
keys.append(key)
return keys
def get_keys_to_withdraw(self):
if self.rr_class.upper() != 'IN':
return []
elif self.keys is None:
return []
keys = []
for key in self.keys:
if key.needs_withdrawal():
keys.append(key)
return keys
def print_keys(self):
if self.keys is None:
return
for key in self.keys:
key.print()
LOG.debug('')
def print_zone(self):
LOG.debug('View: {}, Zone: {}, Keys: {}'.format(
self.name,
self.view,
"<undefined>" if self.keys is None else len(self.keys)))
@classmethod
def get_zones(cls, config, path='/var/cache/bind/named_dump.db'):
"""Creates a list of zones, possibly by parsing BINDs dumpfile"""
zones = []
if "zones" in config and len(config["zones"]) > 0:
for zone in config["zones"]:
parts = zone.split("/")
if len(parts) > 2:
die('Invalid zone: "{}"'.format(zone))
elif len(parts) > 1:
view = parts[1].strip()
else:
view = "_default"
name = parts[0].strip()
rr_class = "IN"
if len(name) < 1 or len(view) < 1:
die('Invalid zone: "{}"'.format(zone))
zone = Zone(name, rr_class, view)
zones.append(zone)
return zones
# No zones defined, time to dig in rndc dump files to autodetect them...
args = ["rndc", "dumpdb", "-zones"]
if not run_process_ok(config, args):
die(None)
"""
This is an example of the kind of output we need to parse:
Note that the '(signed)' suffix may or may not be present for signed
zones depending on the version of BIND.
Without views:
;
; Start view _default
;
; Zone dump of 'example.com/IN'
;
...
;
; Zone dump of 'example.net/IN (signed)'
;
...
With views:
;
; Start view foo
;
; Zone dump of 'example.com/IN/foo'
;
...
;
; Start view bar
;
; Zone dump of 'example.net/IN/bar (signed)'
;
...
"""
start_view = '_default'
with open(path) as file:
for line in file:
if not line.startswith(';'):
continue
line = line[1:].strip()
if line.startswith('Start view '):
start_view = line[len('Start view '):]
continue
elif not line.startswith('Zone dump of '):
continue
line = line[len('Zone dump of '):].strip().strip("'")
if line.endswith('(signed)'):
line = line[:-len('(signed)')].strip()
parts = line.split('/')
if len(parts) < 2 or len(parts) > 3:
LOG.debug('Invalid zone line ({})'.format(line))
continue
name = parts[0].strip()
rr_class = parts[1].strip()
view = start_view
if len(parts) == 3:
view = parts[2].strip()
if view != start_view:
LOG.debug('Invalid zone view ({}, expected {})'.format(
view, start_view))
continue
if rr_class != 'IN':
continue
if view == '_bind':
continue
zone = Zone(name, rr_class, view)
zones.append(zone)
return zones
def get_args_parser():
args_parser = argparse.ArgumentParser(
prog='debops-bind-rollkey',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
description="Publish/unpublish DNSSEC 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/bind/debops-bind-rollkey.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",
)
return args_parser
def get_default_config():
return {
"method": "log/email/external",
"log_file": "/var/log/debops-bind-rollkey.log",
"email_to": "root@localhost",
"email_from": "noreply@{}".format(socket.getfqdn()),
"email_host": "localhost",
"email_port": 25,
"email_subject": "BIND DNSSEC key updates",
"external_script": "/usr/local/sbin/debops-bind-rollkey-action",
}
def print_example_config():
config = get_default_config()
config["zones"] = ["zone1/view1", "zone2/view2", "(optional)"]
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"] == "external":
if not config["external_script"]:
die("External script not configured")
if not os.access(config["external_script"], os.F_OK):
die("External script not found")
if not os.access(config["external_script"], os.X_OK):
die("External script not executable")
else:
die("Invalid method configured")
return config
def get_actionable_keys(zones):
keys = []
for zone in zones:
pub_keys = zone.get_keys_to_publish()
for key in pub_keys:
key.action = "Publish"
key.action_goal = "published"
keys.append(key)
unpub_keys = zone.get_keys_to_withdraw()
for key in unpub_keys:
key.action = "Withdraw"
key.action_goal = "withdrawn"
keys.append(key)
return keys
def do_external(config, zones):
keys = get_actionable_keys(zones)
failures = False
for key in keys:
args_ext = [config["external_script"],
key.action.lower(),
key.key_id,
key.key_alg_num,
key.zone.name,
key.zone.rr_class,
key.zone.view]
args_rndc = ["rndc",
"dnssec",
"-checkds",
"-key",
key.key_id,
"-alg",
key.key_alg_num,
key.action_goal,
key.zone.name,
key.zone.rr_class]
if key.zone.view != '_default':
args_rndc.append(key.zone.view)
if not run_process_ok(config, args_ext):
failures = True
elif not run_process_ok(config, args_rndc):
failures = True
if failures:
write_log_msg(config, "Some commands failed\n\n")
else:
write_log_msg(config, "Done\n\n")
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 DNSSEC key changes need to be made" + sep
msg += "================================================" + sep
if len(keys) == 0:
msg += "None" + sep
else:
for key in keys:
msg += "{} {} {} {} IN {}{}".format(
key.action,
key.key_id,
key.key_alg_num,
key.zone.name,
key.zone.view,
sep)
msg += "================================================" + sep
msg += sep
return msg
def do_email(config, zones):
keys = get_actionable_keys(zones)
if len(keys) == 0:
return
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 write_log_msg(config, msg):
if not config["log_file"]:
return
with open(config["log_file"], "a") as f:
f.write(msg)
def do_log(config, zones):
keys = get_actionable_keys(zones)
msg = create_message(config, keys, "\n")
write_log_msg(config, msg)
if len(keys) == 0:
LOG.debug("Nothing to do")
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()
config = read_config_file(args.config_path)
if shutil.which("rndc") is None:
die("rndc executable not found")
zones = Zone.get_zones(config)
for zone in zones:
zone.get_keys(config)
zone.print_zone()
zone.print_keys()
do_log(config, zones)
if config["method"] == "email":
do_email(config, zones)
elif config["method"] == "external":
do_external(config, zones)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,270 @@
#!/usr/bin/env bash
# This file is managed remotely, all changes will be lost
# Copyright (C) 2015-2019 Maciej Delmanowski <drybjed@gmail.com>
# Copyright (C) 2022 David Härdeman <david@hardeman.nu>
# Copyright (C) 2015-2022 DebOps <https://debops.org/>
# SPDX-License-Identifier: GPL-3.0-only
# Create .tar.gz backup snapshots of the BIND config and master zones.
# The script will automatically freeze/sync/thaw the zones as necessary,
# while keeping track of whether they were already frozen (e.g. manually
# by the administrator) and not thawing them again in that case.
set -o nounset -o pipefail -o errexit
umask 027
SCRIPT="$(basename "${0}")"
readonly SCRIPT
readonly PID="$$"
readonly PIDFILE="/run/${SCRIPT}.pid"
readonly SUBCOMMAND="${1:-}"
readonly RNDC="/usr/sbin/rndc"
BACKUP_USER="backup"
BACKUP_GROUP="backup"
BACKUP_ROOT="$(getent passwd "${BACKUP_USER}" | cut -f6 -d:)/bind"
FROZEN_FLAG="/var/lib/bind/.debops.frozen"
TAR="/bin/tar"
if [ -f "/etc/default/debops-bind-snapshot" ] ; then
# shellcheck disable=SC1091
. "/etc/default/debops-bind-snapshot"
fi
print_usage () {
cat <<EOF
Usage: ${SCRIPT} <daily|weekly|monthly|latest|now>
Create periodic backup snapshots of the BIND configuration and zones
EOF
}
log_message () {
# Display a message if script is used interactively, otherwise send it to syslog
local msg="${1:-}"
if [ -n "${msg}" ] ; then
if tty -s > /dev/null 2>&1 ; then
echo "${SCRIPT}: ${msg}" 1>&2
elif type logger > /dev/null 2>&1 ; then
logger -t "${SCRIPT}[${PID}]" "${msg}"
fi
fi
}
clean_up () {
# Clean up pidfile
local pidfile="${1}"
if [ -n "${pidfile}" ] && [ -r "${pidfile}" ] ; then
rm -f "${pidfile}"
fi
exit 0
}
wait_for_pid () {
# Wait for the specified process to exit
local pidfile="${1}"
if [ -n "${pidfile}" ] && [ -r "${pidfile}" ] ; then
local wait_pid
wait_pid="$(< "${pidfile}")"
while kill -0 "${wait_pid}" > /dev/null 2>&1 ; do
log_message "Waiting for PID ${wait_pid} to finish"
sleep $(( ( RANDOM % 30 ) + 5 ))
done
if [ -f "${pidfile}" ] ; then
rm -f "${pidfile}"
fi
fi
}
create_lock () {
local pidfile="${1}"
local pid="${PID}"
# Try and lock the script operation
echo "${pid}" > "${pidfile}"
sleep 0.5
# Exclusive lock failed
if [ "$(cat "${pidfile}")" != "${pid}" ] ; then
log_message "BIND snapshot procedure started by $(< "${pidfile}")"
exit 0
fi
# Exclusive lock succeeded
# shellcheck disable=SC2064
trap "clean_up ${pidfile}" EXIT
}
enable_read_only_mode () {
local output
if ! pidof -q named > /dev/null 2>&1; then
log_message "Not freezing zones, named isn't running"
else
if ! output="$(LC_ALL=C "${RNDC}" freeze 2>&1)"; then
if [[ "${output}" == *"already frozen"* ]]; then
log_message "Not freezing zones, already frozen"
else
log_message "Unable to freeze zones: ${output}"
exit 1
fi
else
log_message "Freezing zone updates"
touch "${FROZEN_FLAG}"
fi
log_message "Synchronizing all zones"
"${RNDC}" sync -clean
fi
}
disable_read_only_mode () {
if ! pidof -q named > /dev/null 2>&1; then
log_message "Not thawing zones, named isn't running"
elif ! [ -e "${FROZEN_FLAG}" ]; then
log_message "Not thawing zones, not frozen by this script"
else
log_message "Thawing zone updates"
"${RNDC}" thaw
rm -f "${FROZEN_FLAG}"
fi
}
create_snapshot () {
local backup_user
local backup_group
local backup_root
local backup_file
local backup_dir
backup_user="${BACKUP_USER}"
backup_group="${BACKUP_GROUP}"
backup_root="${BACKUP_ROOT}"
backup_file="${1}"
backup_dir="$(dirname "${backup_file}")"
if [ ! -d "${backup_dir}" ] ; then
mkdir -p "${backup_dir}"
chown -R "${backup_user}:${backup_group}" "${backup_root}"
fi
enable_read_only_mode
log_message "Dumping the BIND configuration and zones"
"${TAR}" -cf "${backup_file}" -C / etc/bind var/lib/bind
disable_read_only_mode
if [ -f "${backup_file}.gz.gpg" ] ; then
rm -f "${backup_file}.gz.gpg"
fi
if [ -f "${backup_file}.gz.asc" ] ; then
rm -f "${backup_file}.gz.asc"
fi
nice gzip -f "${backup_file}"
chown "${backup_user}:${backup_group}" "${backup_file}.gz"
}
snapshot_daily () {
local backup_root
local day
local weekday
local backup_file
backup_root="${BACKUP_ROOT}"
day="$(date +%w)"
weekday="$(date +%A)"
backup_file="${backup_root}/daily/bind_day${day}_${weekday}.tar"
log_message "Creating daily snapshot of the BIND zones and config"
create_snapshot "${backup_file}"
log_message "Daily snapshot of the BIND zones and config created successfully"
}
snapshot_weekly () {
local backup_root
local week
local backup_file
backup_root="${BACKUP_ROOT}"
# Find out the week number in the current month
week="$(( ( $(date +%_d) - 1 ) / 7 + 1 ))"
backup_file="${backup_root}/weekly/bind_week${week}.tar"
log_message "Creating weekly snapshot of the BIND zones and config"
create_snapshot "${backup_file}"
log_message "Weekly snapshot of the BIND zones and config created successfully"
}
snapshot_monthly () {
local backup_root
local month
local month_name
local backup_file
backup_root="${BACKUP_ROOT}"
month="$(date +%m)"
month_name="$(date +%B)"
backup_file="${backup_root}/monthly/bind_month${month}_${month_name}.tar"
log_message "Creating monthly snapshot of the BIND zones and config"
create_snapshot "${backup_file}"
log_message "Monthly snapshot of the BIND zones and config created successfully"
}
snapshot_latest () {
local backup_root
local current_date
local backup_file
backup_root="${BACKUP_ROOT}"
current_date="$(date +%F_%R)"
backup_file="${backup_root}/latest/bind_${current_date}.tar"
if [ -d "${backup_root}/latest" ] ; then
# Remove old set of database backups
find "${backup_root}/latest" -type f -name "bind_*.*" -delete
fi
log_message "Creating a snapshot of the BIND zones and config"
create_snapshot "${backup_file}"
log_message "Snapshot of the BIND zones and config created successfully"
}
main () {
local pidfile="${PIDFILE}"
local subcommand="${SUBCOMMAND}"
wait_for_pid "${pidfile}"
create_lock "${pidfile}"
case "${subcommand}" in
daily) snapshot_daily ;;
weekly) snapshot_weekly ;;
monthly) snapshot_monthly ;;
now|latest) snapshot_latest ;;
*) print_usage && exit 1 ;;
esac
}
main

View file

@ -0,0 +1,32 @@
---
# Copyright (C) 2022 David Härdeman <david@hardeman.nu>
# Copyright (C) 2022 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:
company: 'DebOps'
author: 'David Härdeman'
description: 'Configure the BIND DNS server'
license: 'GPL-3.0-only'
min_ansible_version: '2.9.0'
platforms:
- name: 'Ubuntu'
versions: [ 'all' ]
- name: 'Debian'
versions: [ 'all' ]
galaxy_tags:
- bind
- bind9
- named
- dns

View file

@ -0,0 +1,349 @@
---
# Copyright (C) 2022 David Härdeman <david@hardeman.nu>
# Copyright (C) 2022 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'
tags: [ 'role::bind:config' ]
- name: Import DebOps secret role
ansible.builtin.import_role:
name: 'secret'
tags: [ 'role::bind:keys' ]
- name: Install packages
ansible.builtin.package:
name: '{{ q("flattened", bind__base_packages + bind__packages) }}'
state: 'present'
register: bind__register_packages
until: bind__register_packages is succeeded
tags: [ 'role::bind:packages' ]
- name: Make sure that the Ansible local facts directory exists
ansible.builtin.file:
path: '/etc/ansible/facts.d'
state: 'directory'
mode: '0755'
tags: [ 'role::bind:config' ]
- name: Save local facts
ansible.builtin.template:
src: 'etc/ansible/facts.d/bind.fact.j2'
dest: '/etc/ansible/facts.d/bind.fact'
mode: '0755'
notify: [ 'Refresh host facts' ]
tags: [ 'meta::facts', 'role::bind:config' ]
- name: Update Ansible facts if they were modified
ansible.builtin.meta: 'flush_handlers'
tags: [ 'role::bind:config' ]
- name: Enable/disable resolvconf integration
ansible.builtin.service:
name: 'named-resolvconf'
enabled: '{{ ansible_local.resolvconf.installed | d(False) | bool }}'
when: ansible_service_mgr == "systemd"
tags: [ 'role::bind:config' ]
- name: Allow access to additional UNIX groups
ansible.builtin.user:
name: '{{ bind__user }}'
groups: '{{ bind__additional_groups }}'
append: True
state: 'present'
register: bind__register_unix_groups
notify: [ 'Test named configuration and restart' ]
- name: Create base directories
ansible.builtin.file:
path: '{{ item.path }}'
state: 'directory'
owner: 'root'
group: 'bind'
mode: '{{ item.mode | d("0775") }}'
loop:
- { path: '/etc/bind', mode: '02755' }
- { path: '/etc/bind/keys', mode: '02750' }
- { path: '/var/cache/bind' }
- { path: '/var/lib/bind' }
- { path: '/var/lib/bind/views' }
- { path: '/var/lib/bind/dnssec-keys', mode: '02770' }
- { path: '/var/log/named' }
notify: [ 'Test named configuration and restart' ]
tags: [ 'role::bind:config' ]
- name: Create keys
ansible.builtin.include_tasks: 'main_keys.yml'
tags: [ 'role::bind:config', 'role::bind:keys' ]
- name: Generate list of toplevel views
ansible.builtin.set_fact:
bind__tmp_top_views: '{{ bind__combined_zones
| debops.debops.parse_kv_items(name="view")
| selectattr("state", "eq", "present") }}'
tags: [ 'role::bind:config' ]
- name: Generate list of toplevel zones
ansible.builtin.set_fact:
bind__tmp_top_zones: '{{ bind__combined_zones
| debops.debops.parse_kv_items(name="name")
| selectattr("state", "eq", "present") }}'
tags: [ 'role::bind:config' ]
- name: Make sure either zones or views are defined
ansible.builtin.assert:
that: bind__tmp_top_views | length == 0 or bind__tmp_top_zones | length == 0
tags: [ 'role::bind:config' ]
- name: Assign zones to the default view
ansible.builtin.set_fact:
bind__tmp_top_views:
- view: '_default'
zones: '{{ bind__tmp_top_zones }}'
when: bind__tmp_top_views | length == 0
tags: [ 'role::bind:config' ]
- name: Create list of generic zones
ansible.builtin.set_fact:
bind__tmp_generic_zones: '{{ bind__combined_generic_zones
| debops.debops.parse_kv_items(name="name")
| selectattr("state", "eq", "present") }}'
tags: [ 'role::bind:config' ]
- name: Add generic zones to each view
ansible.builtin.set_fact:
bind__tmp_full_views: '{{ bind__tmp_full_views | d([])
+ [item | combine({"zones": item["zones"]
+ bind__tmp_generic_zones})] }}'
loop: '{{ bind__tmp_top_views }}'
loop_control:
label: '{{ item.view }}'
tags: [ 'role::bind:config' ]
- name: Register the view in each zone
ansible.builtin.set_fact:
bind__views: '{{ bind__views | d([])
+ [item | combine({"zones": (item["zones"]
| map("combine", {"view": item["view"]}))})] }}'
loop: '{{ bind__tmp_full_views }}'
loop_control:
label: '{{ item.view }}'
tags: [ 'role::bind:config' ]
- name: Create zone directories
ansible.builtin.file:
path: '{{ item.dir | d("/var/lib/bind/views/" + item.view + "/" + item.name) }}'
state: 'directory'
owner: '{{ item.owner | d("root") }}'
group: '{{ item.group | d("bind") }}'
mode: '{{ item.mode | d("0775") }}'
loop: '{{ bind__views | map(attribute="zones") | flatten }}'
when:
- item.content is defined
- item.state | d("present") == "present"
loop_control:
label: '{{ item.dir | d("/var/lib/bind/views/" + item.view + "/" + item.name) }}'
notify: [ 'Test named configuration and restart' ]
tags: [ 'role::bind:config' ]
# Note: the changed_when status for the freeze/thaw tasks is kind of a lie...
- name: Suspend dynamic updates
ansible.builtin.shell: |
if pidof -q named > /dev/null 2>&1; then
rndc freeze && exit 222
else true; fi
environment:
LC_ALL: 'C'
register: bind__register_freeze
changed_when: False
failed_when: >
(bind__register_freeze.rc not in [ 0, 222 ]) and
("already frozen" not in bind__register_freeze.stderr | lower)
tags: [ 'role::bind:config' ]
- name: Register the fact that this role suspended dynamic updates
ansible.builtin.file:
path: '/var/lib/bind/.debops.frozen'
state: 'touch'
mode: '0644'
changed_when: False
when: bind__register_freeze.rc == 222
tags: [ 'role::bind:config' ]
- name: Generate zone files
ansible.builtin.template:
src: 'var/lib/bind/views/view/zone/db.zone.j2'
dest: '{{ (item.dir | d("/var/lib/bind/views/" + item.view + "/" + item.name)) + "/db.zone" }}'
owner: '{{ item.owner | d("root") }}'
group: '{{ item.group | d("bind") }}'
mode: '{{ item.mode | d("0644") }}'
force: '{{ item.force | d(False) }}'
loop: '{{ bind__views | map(attribute="zones") | flatten }}'
when:
- item.content is defined
- item.state | d("present") == "present"
loop_control:
label: '{{ (item.dir | d("/var/lib/bind/views/" + item.view + "/" + item.name)) + "/db.zone" }}'
notify: [ 'Test named configuration and restart' ]
tags: [ 'role::bind:config' ]
- name: Divert the BIND configuration
debops.debops.dpkg_divert:
path: '/etc/bind/named.conf'
tags: [ 'role::bind:config' ]
- name: Generate BIND configuration
ansible.builtin.template:
src: 'etc/bind/named.conf.j2'
dest: '/etc/bind/named.conf'
owner: 'root'
group: 'bind'
mode: '0644'
notify: [ 'Test named configuration and restart' ]
tags: [ 'role::bind:config' ]
- name: Make sure named is restarted, if necessary
ansible.builtin.meta: 'flush_handlers'
tags: [ 'role::bind:config' ]
- name: Check if dynamic updates should be resumed
ansible.builtin.stat:
path: '/var/lib/bind/.debops.frozen'
changed_when: False
register: bind__register_should_thaw
tags: [ 'role::bind:config' ]
- name: Resume dynamic updates
ansible.builtin.command: rndc thaw
when: bind__register_should_thaw.stat.exists | d(False)
register: bind__register_thaw
until: bind__register_thaw is succeeded
changed_when: False
tags: [ 'role::bind:config' ]
- name: Remove dynamic update flag file
ansible.builtin.file:
path: '/var/lib/bind/.debops.frozen'
state: 'absent'
changed_when: False
tags: [ 'role::bind:config' ]
- name: Install backup snapshot script
ansible.builtin.copy:
src: 'usr/local/sbin/debops-bind-snapshot'
dest: '/usr/local/sbin/debops-bind-snapshot'
owner: 'root'
group: 'root'
mode: '0755'
tags: [ 'role::bind:backup' ]
- name: Configure backup snapshots as cron jobs
ansible.builtin.cron:
name: 'Create {{ item }} backup snapshots of BIND configuration and zones'
special_time: '{{ item }}'
cron_file: 'debops-bind-snapshot'
user: 'root'
state: '{{ "present"
if (bind__snapshot_enabled | d(False) | bool
and item in bind__snapshot_cron_jobs)
else "absent" }}'
job: '/usr/local/sbin/debops-bind-snapshot {{ item }}'
loop: [ 'daily', 'weekly', 'monthly' ]
loop_control:
label: '{{ {"state": ("present"
if (bind__snapshot_enabled | d(False) | bool
and item in bind__snapshot_cron_jobs)
else "absent"),
"cron_job": item} }}'
tags: [ 'role::bind:backup' ]
- name: Make sure that the PKI hook directory exists
ansible.builtin.file:
path: '{{ bind__pki_hook_path }}'
state: 'directory'
owner: 'root'
group: 'root'
mode: '0755'
when: '"dot" in bind__features or "doh_https" in bind__features'
tags: [ 'role::bind:pki' ]
- name: Install the BIND PKI hook
ansible.builtin.template:
src: 'etc/pki/hooks/bind.j2'
dest: '{{ bind__pki_hook_path + "/" + bind__pki_hook_name }}'
owner: 'root'
group: 'root'
mode: '0755'
when: '"dot" in bind__features or "doh_https" in bind__features'
tags: [ 'role::bind:pki' ]
- name: Remove the BIND PKI hook
ansible.builtin.file:
dest: '{{ bind__pki_hook_path + "/" + bind__pki_hook_name }}'
state: 'absent'
when: '"dot" not in bind__features and "doh_https" not in bind__features'
tags: [ 'role::bind:pki' ]
- name: Install DNSSEC rollover configuration
ansible.builtin.template:
src: 'etc/bind/debops-bind-rollkey.json.j2'
dest: '/etc/bind/debops-bind-rollkey.json'
owner: 'root'
group: 'root'
mode: '0644'
when: '"dnssec" in bind__features and bind__dnssec_script_enabled | d(False)'
tags: [ 'role::bind:dnssec' ]
- name: Install DNSSEC rollover script
ansible.builtin.copy:
src: 'usr/local/sbin/debops-bind-rollkey'
dest: '/usr/local/sbin/debops-bind-rollkey'
owner: 'root'
group: 'root'
mode: '0755'
when: '"dnssec" in bind__features and bind__dnssec_script_enabled | d(False)'
tags: [ 'role::bind:dnssec' ]
- name: Install DNSSEC rollover external script
ansible.builtin.copy:
src: '{{ lookup("debops.debops.file_src",
"usr/local/sbin/debops-bind-rollkey-action") }}'
dest: '/usr/local/sbin/debops-bind-rollkey-action'
owner: 'root'
group: 'root'
mode: '0755'
when:
- '"dnssec" in bind__features'
- bind__dnssec_script_enabled | d(False)
- bind__dnssec_script_method | d("") == 'external'
tags: [ 'role::bind:dnssec' ]
- name: Enable DNSSEC rollover cron job
ansible.builtin.cron:
name: 'Rollover DNSSEC keys for BIND'
special_time: 'weekly'
cron_file: 'debops-bind-rollkey'
user: 'root'
job: '/usr/local/sbin/debops-bind-rollkey'
state: '{{ "present"
if ("dnssec" in bind__features and
bind__dnssec_script_enabled | d(False))
else "absent" }}'
tags: [ 'role::bind:dnssec' ]
- name: Remove DNSSEC rollover script
ansible.builtin.file:
path: '{{ item }}'
state: 'absent'
loop:
- '/usr/local/sbin/debops-bind-rollkey'
- '/usr/local/sbin/debops-bind-rollkey-action'
- '/etc/bind/debops-bind-rollkey.json'
when: '"dnssec" not in bind__features or not bind__dnssec_script_enabled | d(False)'
tags: [ 'role::bind:dnssec' ]

View file

@ -0,0 +1,214 @@
---
# Copyright (C) 2022 David Härdeman <david@hardeman.nu>
# Copyright (C) 2022 DebOps <https://debops.org/>
# SPDX-License-Identifier: GPL-3.0-only
- name: Create sanitised list of keys
ansible.builtin.set_fact:
bind__tmp_keys: '{{ bind__tmp_keys | d([])
+ [item | combine({"state": item.state | d("present") | lower,
"type": item.type | mandatory | upper,
"dir": item.dir | d("/etc/bind/keys/"),
"owner": item.owner | d("root"),
"group": item.group | d("bind"),
"include": (item.include | d(True) | bool)
if item.type | d("") | upper == "TSIG"
else False,
"download": item.download | d(True if item.source | d("host") == "host" else False) | bool,
"source": item.source | d("host"),
"source_path": item.source_path | d(""),
"remove_private_key": item.remove_private_key | d(True) | bool,
"algorithm": (item.algorithm | mandatory)
if item.type | d("") | upper == "TSIG"
else ("%03d" | format(item.algorithm | mandatory | int))
if item.type | d("") | upper == "SIG(0)"
else omit,
"creates": (item.name + ".key")
if item.type | d("") | upper == "TSIG"
else ("K" + item.name + "+" + "%03d" | format(item.algorithm | int) + "+*.key")
if item.type | d("") | upper == "SIG(0)"
else omit,
"removes": (item.name + ".key")
if item.type | d("") | upper == "TSIG"
else ("K" + item.name + "+" + "%03d" | format(item.algorithm | int) + "+*.*")
if item.type | d("") | upper == "SIG(0)"
else omit,
"public_key": (item.name + ".key")
if item.type | d("") | upper == "TSIG"
else ("K" + item.name + "+" + "%03d" | format(item.algorithm | int) + "+*.key")
if item.type | d("") | upper == "SIG(0)"
else omit,
"private_key": (item.name + ".key")
if item.type | d("") | upper == "TSIG"
else ("K" + item.name + "+" + "%03d" | format(item.algorithm | int) + "+*.private")
if item.type | d("") | upper == "SIG(0)"
else omit})] }}'
when:
- item.name | d("") | length > 0
- item.state | d("present") | lower in [ 'present', 'absent' ]
loop: '{{ bind__combined_keys | d([]) | debops.debops.parse_kv_items(name="name") }}'
loop_control:
label: '{{ item.name | d("unknown") }}'
tags: [ 'role::bind:config', 'role::bind:keys' ]
# Note, this ensures both:
# a) that keynames are valid in the bind config; and
# b) that working with unquoted globs is safe
- name: Verify key sanity
ansible.builtin.assert:
that:
- item.name | regex_replace('([^a-zA-Z0-9-.])', '') == item.name
- item.type in [ 'TSIG', 'SIG(0)' ]
- item.algorithm != "000"
- item.source in [ "host", "controller" ]
- not (item.download and item.source == "controller")
quiet: true
loop: '{{ bind__tmp_keys | d([]) }}'
loop_control:
label: '{{ item.name }}'
tags: [ 'role::bind:config', 'role::bind:keys' ]
- name: Create TSIG keys
ansible.builtin.shell:
chdir: '{{ item.dir }}'
cmd: umask 027;
tsig-keygen -a {{ item.algorithm | quote }} {{ item.name | quote }}
> {{ item.creates | quote }} &&
chown {{ (item.owner + ":" + item.group) | quote }} {{ item.creates | quote }}
creates: '{{ item.creates }}'
when:
- item.state == 'present'
- item.type == 'TSIG'
- item.source == 'host'
loop: '{{ bind__tmp_keys | d([]) }}'
loop_control:
label: '{{ item.name }}'
notify: [ 'Test named configuration and restart' ]
tags: [ 'role::bind:keys' ]
- name: Fetch TSIG keys
ansible.builtin.fetch:
src: '{{ item.dir + "/" + item.public_key }}'
dest: '{{ secret + "/bind/" + inventory_hostname + "/" }}'
flat: True
when:
- item.state == 'present'
- item.type == 'TSIG'
- item.download
loop: '{{ bind__tmp_keys | d([]) }}'
loop_control:
label: '{{ item.name }}'
tags: [ 'role::bind:keys' ]
- name: Create SIG(0) keys
ansible.builtin.shell:
chdir: '{{ item.dir }}'
cmd: umask 027;
dnssec-keygen -C -a {{ item.algorithm | quote }} -n HOST -T KEY {{ item.name | quote }} &&
chown {{ (item.owner + ":" + item.group) | quote }} {{ item.creates }}
creates: '{{ item.creates }}'
when:
- item.state == 'present'
- item.type == 'SIG(0)'
loop: '{{ bind__tmp_keys | d([]) }}'
loop_control:
label: '{{ item.name }}'
notify: [ 'Test named configuration and restart' ]
tags: [ 'role::bind:keys' ]
- name: Find SIG(0) keys to fetch
ansible.builtin.find:
paths: '{{ item.dir }}'
use_regex: False
recurse: False
patterns:
- '{{ item.public_key }}'
- '{{ item.private_key }}'
when:
- item.state == 'present'
- item.type == 'SIG(0)'
- item.download
register: bind__tmp_find_sig0_keys
loop: '{{ bind__tmp_keys | d([]) }}'
loop_control:
label: '{{ item.name }}'
tags: [ 'role::bind:keys' ]
- name: Build combined list of SIG(0) keys to fetch
ansible.builtin.set_fact:
bind__tmp_sig0_fetch: '{{ bind__tmp_sig0_fetch | d([]) + item.files | map(attribute="path") }}' # noqa jinja[invalid]
loop: '{{ bind__tmp_find_sig0_keys.results | d([]) }}'
when: item.files is defined
loop_control:
label: '{{ item.item.name }}'
tags: [ 'role::bind:keys' ]
- name: Fetch SIG(0) keys
ansible.builtin.fetch:
src: '{{ item }}'
dest: '{{ secret + "/bind/" + inventory_hostname + "/" }}'
flat: True
loop: '{{ bind__tmp_sig0_fetch | d([]) }}'
tags: [ 'role::bind:keys' ]
- name: Find SIG(0) private keys to remove
ansible.builtin.find:
paths: '{{ item.dir }}'
use_regex: False
recurse: False
patterns:
- '{{ item.private_key }}'
when:
- item.state == 'present'
- item.type == 'SIG(0)'
- item.remove_private_key
register: bind__tmp_find_sig0_keys
loop: '{{ bind__tmp_keys | d([]) }}'
loop_control:
label: '{{ item.name }}'
tags: [ 'role::bind:keys' ]
- name: Build combined list of SIG(0) private keys to remove
ansible.builtin.set_fact:
bind__tmp_sig0_remove: '{{ bind__tmp_sig0_remove | d([]) + item.files | d([]) | map(attribute="path") }}'
loop: '{{ bind__tmp_find_sig0_keys.results | d([]) }}'
when: item.files is defined
loop_control:
label: '{{ item.item.name }}'
tags: [ 'role::bind:keys' ]
- name: Remove SIG(0) private keys
ansible.builtin.file:
path: '{{ item }}'
state: absent
loop: '{{ bind__tmp_sig0_remove | d([]) }}'
tags: [ 'role::bind:keys' ]
- name: Remove TSIG/SIG(0) keys configured as absent
ansible.builtin.shell: # noqa command-instead-of-shell no-free-form
chdir: '{{ item.dir }}'
cmd: rm -f {{ item.removes }}
removes: '{{ item.removes }}'
when:
- item.state == 'absent'
loop: '{{ bind__tmp_keys | d([]) }}'
loop_control:
label: '{{ item.name }}'
notify: [ 'Test named configuration and restart' ]
tags: [ 'role::bind:keys' ]
- name: Upload TSIG/SIG(0) keys from the controller
ansible.builtin.copy:
src: '{{ item.source_path if item.source_path.startswith("/") else secret + "/" + item.source_path }}'
dest: '{{ item.dir + "/" + item.source_path | basename }}'
owner: '{{ item.owner }}'
group: '{{ item.group }}'
mode: '0640'
when:
- item.state == 'present'
- item.source == 'controller'
loop: '{{ bind__tmp_keys | d([]) }}'
loop_control:
label: '{{ item.name }}'
notify: [ 'Test named configuration and restart' ]
tags: [ 'role::bind:keys' ]

View file

@ -0,0 +1,33 @@
#!{{ ansible_python['executable'] }}
# -*- coding: utf-8 -*-
# Copyright (C) 2022 David Härdeman <david@hardeman.nu>
# Copyright (C) 2022 DebOps <https://debops.org/>
# SPDX-License-Identifier: GPL-3.0-only
# {{ ansible_managed }}
from __future__ import print_function
from json import dumps
import subprocess
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)
)
output = {'installed': cmd_exists('named')}
try:
output['version'] = subprocess.check_output(
["named", "-v"]
).decode('utf-8').strip().split()[1]
except Exception:
pass
print(dumps(output, sort_keys=True, indent=4))

View file

@ -0,0 +1,9 @@
{# Copyright (C) 2022 David Härdeman <david@hardeman.nu>
# Copyright (C) 2022 DebOps <https://debops.org/>
# SPDX-License-Identifier: GPL-3.0-only
#}
{% set output = { "_comments": [ ansible_managed ] } %}
{% for opt in bind__dnssec_script_combined_configuration | debops.debops.parse_kv_items %}
{% set _ = output.update({ opt.name : opt.value }) %}
{% endfor %}
{{ output | to_nice_json }}

View file

@ -0,0 +1,138 @@
{# Copyright (C) 2022 David Härdeman <david@hardeman.nu>
# Copyright (C) 2022 DebOps <https://debops.org/>
# SPDX-License-Identifier: GPL-3.0-only
#}
//
// /etc/bind/named.conf - Main configuration file for bind.
//
// {{ ansible_managed }}
//
{% macro print_zone(zone, commented, loop_first, level) %}
{% if zone.state | d('present') not in [ 'absent', 'ignore', 'init' ] %}
{% set prefix = ("{:^" + (level * global.indent) | string + "}").format('') %}
{% set comment_prefix = (prefix + "// ") %}
{% if commented or zone.state | d('present') == 'comment' %}
{% set commented = True %}
{% set prefix = comment_prefix %}
{% endif %}
{% if not loop_first %}
{{ '' }}
{% endif %}
{% if zone.comment | d() %}
{{ zone.comment | regex_replace('\n$', '') | comment(prefix='', decoration=comment_prefix, postfix='') -}}
{% endif %}
{{ '{}zone "{}" {{'.format(prefix, zone.name) }}
{% for option in zone.options | d([]) %}
{{ print_option(option, commented, loop.first, level + 1) -}}
{% endfor %}
{{ '{}}};'.format(prefix) }}
{% endif %}
{% endmacro %}
{##}
{% macro print_views(commented, level) %}
{% for view in bind__views | d([]) %}
{% if view.state | d('present') not in [ 'absent', 'init', 'ignore' ] %}
{% set global.view = view.view | d("<unknown>") %}
{% set prefix = ("{:^" + (level * global.indent) | string + "}").format('') %}
{% set comment_prefix = (prefix + "// ") %}
{% if commented or view.state | d('present') == 'comment' %}
{% set commented = True %}
{% set prefix = comment_prefix %}
{% endif %}
{% if not loop.first %}
{{ '' }}
{% endif %}
{% if view.comment | d() %}
{{ view.comment | regex_replace('\n$', '') | comment(prefix='', decoration=comment_prefix, postfix='') -}}
{% endif %}
{% if view.view | d('_default') != '_default' %}
{{ '{}view "{}" {{'.format(prefix, view.view) }}
{% set level = level + 1 %}
{% endif %}
{% for option in view.options | d([]) %}
{{ print_option(option, commented, loop.first, level) -}}
{% endfor %}
{% for zone in view.zones | d([]) %}
{% set global.zone = zone.name | d("<unknown>") %}
{% set global.zone_file = '"{}/db.zone"'.format(zone.dir | d("/var/lib/bind/views/" + zone.view + "/" + zone.name)) %}
{{ print_zone(zone, commented, loop.first, level) -}}
{% endfor %}
{% if view.view | d('_default') != '_default' %}
{{ '{}}};'.format(prefix) }}
{% endif %}
{% endif %}
{% endfor %}
{% endmacro %}
{##}
{% macro print_keys(commented, level) %}
{% for key in bind__tmp_keys | d([]) %}
{% if key.state | d('present') not in [ 'absent', 'init', 'ignore' ] %}
{% set prefix = ("{:^" + (level * global.indent) | string + "}").format('') %}
{% set comment_prefix = (prefix + "// ") %}
{% if commented or key.state | d('present') == 'comment' %}
{% set commented = True %}
{% set prefix = comment_prefix %}
{% endif %}
{% if key.include | d(True) | bool and key.dir | d() and key.public_key | d() %}
{{ '{}include "{}/{}";'.format(prefix, key.dir | regex_replace('/*$', ''), key.public_key) }}
{% endif %}
{% endif %}
{% endfor %}
{% endmacro %}
{##}
{% macro print_autovalue(option, autovalue, commented, level) %}
{% set prefix = ("{:^" + (level * global.indent) | string + "}").format('') %}
{% set comment_prefix = (prefix + "// ") %}
{% if commented %}
{% set commented = True %}
{% set prefix = comment_prefix %}
{% endif %}
{% if autovalue == 'keys' %}
{{ print_keys(commented, level) -}}
{% elif autovalue == 'zones' %}
{{ print_views(commented, level) -}}
{% elif autovalue == 'zone_file_path' %}
{{ '{}{} {};'.format(prefix, option.option | d(option.name), global.zone_file) }}
{% endif %}
{% endmacro %}
{##}
{% macro print_option(option, commented, loop_first, level) %}
{% if option.state | d('present') not in [ 'absent', 'init', 'ignore' ] %}
{% set prefix = ("{:^" + (level * global.indent) | string + "}").format('') %}
{% set comment_prefix = (prefix + "// ") %}
{% if commented or option.state | d('present') == 'comment' %}
{% set commented = True %}
{% set prefix = comment_prefix %}
{% endif %}
{% if option.separator | d(False) or level == 0 or (option.comment | d() and not loop_first) %}
{{ '' }}
{% endif %}
{% if option.comment | d() %}
{{ option.comment | regex_replace('\n$', '') | comment(prefix='', decoration=comment_prefix, postfix='') -}}
{% endif %}
{% if option.raw | d() and commented %}
{{ '{}'.format(option.raw | regex_replace('\n$', '') | comment(prefix='', decoration=comment_prefix, postfix='')) -}}
{% elif option.raw | d() and not commented %}
{{ '{}{}'.format(prefix, option.raw | regex_replace('\n$', '')) }}
{% elif option.options | d() %}
{{ '{}{} {{'.format(prefix, option.option | d(option.name) | regex_replace('\n$', '')) }}
{% for option in option.options %}
{{ print_option(option, commented, loop.first, level + 1) -}}
{% endfor %}
{{ '{}}};'.format(prefix) }}
{% elif option.autovalue | d() %}
{{ print_autovalue(option, option.autovalue, commented, level) -}}
{% else %}
{{ '{}{} {};'.format(prefix, option.option | d(option.name), option.value | d("")) }}
{% endif %}
{% endif %}
{% endmacro %}
{##}
{% set global = namespace() %}
{% set global.indent = 8 %}
{% set global.view = "<undefined>" %}
{% set global.zone = "<undefined>" %}
{% set global.zone_file = "<undefined>" %}
{% for option in bind__combined_configuration | debops.debops.parse_kv_config %}
{{ print_option(option, False, loop.first, 0) -}}
{% endfor %}

View file

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Copyright (C) 2022 David Härdeman <david@hardeman.nu>
# Copyright (C) 2018-2022 DebOps <https://debops.org/>
# Based on the postfix hook which is:
# Copyright (C) 2020 Rainer Schuth <devel@reixd.net>
# Copyright (C) 2018 Norbert Summer <git@o-g.at>
# SPDX-License-Identifier: GPL-3.0-only
# {{ ansible_managed }}
# Reload or restart named on a certificate state change
set -o nounset -o pipefail -o errexit
bind_config="/etc/bind/named.conf"
bind_action="{{ bind__pki_hook_action }}"
bind_service="named.service"
# Check if current PKI realm is used by the 'named' server
certificate=$(grep -r "${PKI_SCRIPT_DEFAULT_CRT:-}" ${bind_config} || true)
# Get list of current realm states
read -r -a states <<< "$(echo "${PKI_SCRIPT_STATE:-}" | tr "," " ")"
if [ -n "${certificate}" ] && [[ ${states[*]} ]] ; then
for state in "${states[@]}" ; do
if [ "${state}" = "changed-certificate" ] || [ "${state}" = "changed-dhparam" ] ; then
# Check if current init is systemd
if pidof systemd > /dev/null 2>&1 ; then
bind_state="$(systemctl is-active ${bind_service})"
if [ "${bind_state}" = "active" ] ; then
systemctl "${bind_action}" "${bind_service}"
fi
fi
break
fi
done
fi

View file

@ -0,0 +1,73 @@
{# Copyright (C) 2022 David Härdeman <david@hardeman.nu>
# Copyright (C) 2022 DebOps <https://debops.org>
# SPDX-License-Identifier: GPL-3.0-only
#}
;
{% if item.view | d("_default") == "_default" %}
; BIND db file for zone {{ item.name }}
{% else %}
; BIND db file for zone {{ item.name }} in view {{ item.view }}
{% endif %}
;
{% if item.comment | d() %}
{{ item.comment | regex_replace('\n$', '') | comment(prefix='', decoration='; ', postfix='') -}}
;
{% endif %}
$TTL {{ item.ttl | d(bind__default_zone_ttl) }}
$ORIGIN {{ item.origin | d(item.name) | regex_replace("\.*$", ".") }}
@ IN SOA {{ item.soa_primary | d(bind__default_zone_soa_primary) }} {{ item.soa_email | d(bind__default_zone_soa_email) }} (
{{ '{:<7}'.format(item.soa_serial | d(bind__default_zone_soa_serial) | int) }} ; Serial
{{ '{:<7}'.format(item.soa_refresh | d(bind__default_zone_soa_refresh)) }} ; Refresh
{{ '{:<7}'.format(item.soa_retry | d(bind__default_zone_soa_retry)) }} ; Retry
{{ '{:<7}'.format(item.soa_expire | d(bind__default_zone_soa_expire)) }} ; Expire
{{ '{:<7}'.format(item.soa_neg_ttl | d(bind__default_zone_soa_neg_ttl)) }} ; Negative Cache TTL
)
{% if item.content is string %}
{{ item.content }}
{% elif item.content is iterable and item.content is not mapping and item.content[0] is string %}
{% for line in item.content %}
{{ line }}
{% endfor %}
{% else %}
{% set rrs = item.content | d([]) | debops.debops.parse_kv_items(name='name') %}
{% set owner_maxlen = { 'value': 21 } %}
{% for rr in rrs %}
{% if rr.state | d("present") in [ "present", "commented" ] and rr.raw is not defined %}
{% if rr.owner | d(rr.name) | d() and rr.type | d() and rr.rdata | d(rr.value) | d() %}
{% set owner_len = rr.owner | d(rr.name) | length %}
{% if rr.state | d("present") == "commented" %}
{% set owner_len = owner_len + 2 %}
{% endif %}
{% if owner_len > owner_maxlen.value %}
{% if owner_maxlen.update({ 'value': owner_len }) %}
{# do nothing #}
{% endif %}
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
{% for rr in rrs %}
{% if rr.state | d("present") in [ "present", "commented "] %}
{% if rr.comment | d() %}
{{ rr.comment | regex_replace('\n$', '') | comment(prefix='', decoration='; ', postfix='') -}}
{% endif %}
{% if rr.state | d("present") == "commented" %}
{% set prefix = "; " %}
{% else %}
{% set prefix = "" %}
{% endif %}
{% if rr.raw | d() %}
{{ rr.raw | regex_replace('\n$', '') | comment(prefix='', decoration=prefix, postfix='') -}}
{% elif rr.owner | d(rr.name) | d() and rr.type | d() and rr.rdata | d(rr.value) | d() %}
{% set owner = rr.owner | d(rr.name) %}
{% set ttl = rr.ttl | d("") %}
{% set class = rr.class | d("") %}
{% set type = rr.type %}
{% set rdata = rr.rdata | d(rr.value) %}
{{ ('{}{:<' + owner_maxlen.value | string + '} {:<7} {:<7} {:<7} {}').format(prefix, owner, ttl, class, type, rdata) }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}