diff --git a/.travis.yml b/.travis.yml index 0538788..0385d30 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,6 @@ --- +sudo: True language: 'python' python: '2.7' diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 0000000..71a8bc5 --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,8 @@ +Changelog +========= + +v0.1.0 +------ + +- First release [drybjed] + diff --git a/README.md b/README.md index 39bab7a..6ee647b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,14 @@ [![Travis CI](http://img.shields.io/travis/debops/ansible-dhcpd.svg?style=flat)](http://travis-ci.org/debops/ansible-dhcpd) [![test-suite](http://img.shields.io/badge/test--suite-ansible--dhcpd-blue.svg?style=flat)](https://github.com/debops/test-suite/tree/master/ansible-dhcpd/) [![Ansible Galaxy](http://img.shields.io/badge/galaxy-debops.dhcpd-660198.svg?style=flat)](https://galaxy.ansible.com/list#/roles/1559) -Install and configure [ISC DHCP Server](https://www.isc.org/downloads/dhcp/). +`debops.dhcpd` role can be used to configure an [ISC DHCP +Server](https://www.isc.org/downloads/dhcp/) as standalone or in a 2-host +failover configuration. Alternatively, you can configure an DHCP relay on +a host connected to multiple networks which will relay DHCP/BOOTP messages +to your DHCP server. + +`dhcp-probe` script will be used to scan the network for unauthorized DHCP +servers and notify administrators if they are found. ### Installation @@ -13,7 +20,7 @@ This role requires at least Ansible `v1.7.0`. To install it, run: ### Documentation More information about `debops.dhcpd` can be found in the -[official debops.dhcpd documentation](http://docs.debops.org/en/latest/ansible/roles/debops.dhcpd.html). +[official debops.dhcpd documentation](http://docs.debops.org/en/latest/ansible/roles/ansible-dhcpd/docs/). diff --git a/defaults/main.yml b/defaults/main.yml index c9aaffd..85eda6f 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -1,285 +1,276 @@ --- +# Default variables +# ================= -# ---- Global ISC DHCP Server configuration ---- +# .. contents:: Sections +# :local: +# +# ------------------- +# General options +# ------------------- +# .. envvar:: dhcpd_mode +# +# What service type to configure on this host: +# +# - ``server``: host is an ISC DHCP server, see ``dhcpd(8)`` +# +# - ``relay``: host is an ISC DHCP relay, see dhcrelay(8) +# +# - ``probe``: configure only ``dhcp-probe`` when enabled +# +dhcpd_mode: 'server' + + +# .. envvar:: dhcpd_ipversion +# +# Internet Protocol version to configure: ``4`` or ``6`` +dhcpd_ipversion: '4' + + +# .. envvar:: dhcpd_base_packages_map +# +# What packages should be installed, depending on mode of operation +dhcpd_base_packages_map: + 'server': [ 'isc-dhcp-server' ] + 'relay': [ 'isc-dhcp-relay' ] + 'probe': [] + + +# -------------------------------- +# ISC DHCP Relay configuration +# -------------------------------- + +# .. envvar:: dhcpd_relay_servers +# +# List of DHCP servers which should receive the relayed packets +dhcpd_relay_servers: [ '{{ ansible_default_ipv4.gateway }}' ] + + +# .. envvar:: dhcpd_relay_interfaces +# +# List of network interfaces that dhcrelay should listen on +dhcpd_relay_interfaces: [] + + +# .. envvar:: dhcpd_relay_options +# +# Additional dhcrelay options +dhcpd_relay_options: '{{ "-" + dhcpd_ipversion }}' + + +# --------------------------------- +# ISC DHCP Server configuration +# --------------------------------- + +# .. envvar:: dhcpd_server_options +# +# dhcpd(8) options +dhcpd_server_options: '{{ "-" + dhcpd_ipversion }}' + + +# --------------------------- +# DHCP main configuration +# --------------------------- + +# .. envvar:: dhcpd_authoritative +# # Is this DHCP server authoritative? dhcpd_authoritative: False + +# .. envvar:: dhcpd_log_facility +# +# Log facility to use +dhcpd_log_facility: 'local7' + + +# .. envvar:: dhcpd_interfaces +# # List of network interfaces to listen on for DHCP requests # If this list is empty, Ansible will try to guess correct interfaces # automatically dhcpd_interfaces: [] -# Default domain to use -dhcpd_domain: '{{ ansible_domain }}' - -# List of default DNS servers. By default, point users to the same host that -# serves them DHCP requests, on default interface. If this host is a router, -# you might need to set DNS server to internal interface IP address. -dhcpd_dns_servers: [ '{{ ansible_default_ipv4.address }}' ] +# .. envvar:: dhcpd_lease_time +# # Max lease time in hours (default lease time is calculated below) -dhcpd_lease_time: 24 +dhcpd_lease_time: '24' -# Default global options formatted as a text block -dhcpd_global_options: | - option domain-name "{{ ansible_domain }}"; - option domain-name-servers {{ dhcpd_dns_servers | join(' ') }}; - default-lease-time {{ (((dhcpd_lease_time / 2) + 6) * 60 * 60)|round|int }}; - max-lease-time {{ (dhcpd_lease_time * 60 * 60)|round|int }}; - log-facility local7; -# Custom options formatted as a text block +# .. envvar:: dhcpd_global_default_lease_time +# +# Default lease time for all IP address leases (18 hours) +dhcpd_global_default_lease_time: '{{ (((dhcpd_lease_time|int / 2) + 6) * 60 * 60)|round|int }}' + + +# .. envvar:: dhcpd_global_max_lease_time +# +# Maximum lease time for all IP addresses (24 hours) +dhcpd_global_max_lease_time: '{{ (dhcpd_lease_time|int * 60 * 60)|round|int }}' + + +# --------------------------- +# DHCP advertised options +# --------------------------- + +# .. envvar:: dhcpd_auto_options +# +# If enabled, ISC DHCP server will be configured with a set of automatically +# detected options. See ``auto_options.j2`` template for more details. +dhcpd_auto_options: True + + +# .. envvar:: dhcpd_domain_name +# +# Default host domain to advertise +dhcpd_domain_name: '{{ ansible_domain }}' + + +# .. envvar:: dhcpd_domain_search +# +# List of additional domains which should be checked when looking for hostnames +dhcpd_domain_search: [] + + +# .. envvar:: dhcpd_nameservers +# +# List of nameservers to advertise by default +# If it's not specified, nameservers from ``/etc/resolv.conf`` will be used +# instead. +dhcpd_nameservers: [] + + +# .. envvar:: dhcpd_options +# +# Custom global options formatted as a text block dhcpd_options: False -# ---- ISC DHCP Server configuration scopes ---- +# ---------------------------------------- +# ISC DHCP Server configuration scopes +# ---------------------------------------- # These lists allow you to generate nested configuration scopes in # dhcpd.conf. Most of the information about them can be found in dhcpd.conf(5) # manual page. You can create nested configuration using Ansible variable -# expansion (examples below). - -# List of general configuration parameters (work in any configuration scope): -# - comment: '' add a comment to a scope -# - options: | custom options for that scope defined as a text block -# - include: '' path to external file to include in this scope - -# List of hosts (works in groups, subnets): -# - hosts: '' or [] list of hosts to configure in that scope; if this is -# a path to a file, dhcpd will include an external file -# in this scope - -# List of parameters specific to dhcpd_classes: -# - class: '' class name -# - subclass: this is a hash with expression as key and additional -# options as value in a text block (see example below); -# each match expression must end with a colon to indicate -# hash key; optional - -# List of parameters specific to dhcpd_groups: -# - subnets: [] list of subnet scopes to group together -# - groups: [] list of other group scopes to include. No recursion! - -# List of parameters specific to dhcpd_shared_networks: -# - name: '' name of shared network -# - subnets: [] list of subnets in a shared network (do not use -# dhcpd_subnets here, because they will be duplicated -# and DHCP server will not start) - -# List of parameters specific to dhcpd_subnets: -# - subnet: '' start of a subnet range (ie.: 192.168.1.0) -# - netmask: '' netmask for this subnet (ie.: 255.255.255.0) -# - routers: '' or [] address or list of addresses of gateway for that -# subnet (ie.: 192.168.1.1) - -# List of parameters specific to dhcpd_hosts: -# - hostname: '' hostname, without domain part -# - address: '' IP address reserved for that host, optional -# - ethernet: '' Ethernet MAC address of this host, optional +# expansion. +# .. envvar:: dhcpd_keys +# +# List of secret keys used for Dynamic DNS configuration. See +# :ref:`dhcpd_keys` for more details. dhcpd_keys: [] - #- key: "secure-key" - # algorithm: "hmac-md5" - # secret: "JFw7jM2/KVU2hIB4xkDSQmHB6JJOLUu4xkzwLNNpR88=" -# List of classes + +# .. envvar:: dhcpd_zones +# +# List of DNS zones to update with Dynamic DNS configuration. See +# :ref:`dhcpd_zones` for more details. +dhcpd_zones: [] + + +# .. envvar:: dhcpd_classes +# +# List of client classes (see dhcpd.conf(5)). More informaction can be found in +# :ref:`dhcpd_classes`. dhcpd_classes: [] - #- class 'example-class' - # subclass: - # 'match1': - # 'match2': | - # # match2 options in a text block; - - #- class 'example-empty-class' -# List of groups +# .. envvar:: dhcpd_groups +# +# List of configuration scopes groped together. See :ref:`dhcpd_groups` for +# more details. dhcpd_groups: [] - #- comment: 'First group' - # hosts: '/etc/dhcp/dhcpd-group1-hosts.conf' - # groups: '{{ dhcpd_group_second }}' - -# An example of group nesting -#dhcpd_group_second: -# - comment: 'Second group' -# hosts: '/etc/dhcp/dhcpd-group2-hosts.conf' -# List of shared networks +# .. envvar:: dhcpd_shared_networks +# +# List of shared networks grouping specified subnets together. See +# :ref:`dhcpd_shared_networks` for more details. dhcpd_shared_networks: [] - #- name: 'shared-net' - # comment: "Local shared network" - # subnets: '{{ dhcpd_subnets_local }}' - # options: | - # default-lease-time 600; - # max-lease-time 900; -# List of subnets not in a shared network -dhcpd_subnets: - - subnet: '{{ ansible_default_ipv4.network }}' - netmask: '{{ ansible_default_ipv4.netmask }}' +# .. envvar:: dhcpd_subnets +# +# List of subnets not in a shared network. See :ref:`dhcpd_subnets` for more +# details. +dhcpd_subnets: [ '{{ dhcpd_subnet_default[dhcpd_ipversion] }}' ] + +# Default subnet managed automatically +dhcpd_subnet_default: + '4': + subnet: '{{ ansible_default_ipv4.network + "/" + ansible_default_ipv4.netmask }}' + routers: '{{ ansible_default_ipv4.gateway | default("") }}' + comment: 'Generated automatically by Ansible' + '6': + subnet: '{{ ansible_default_ipv6.address + "/" + ansible_default_ipv6.prefix }}' comment: 'Generated automatically by Ansible' - #- subnet: 'dead:be:ef::/64' - # ipv6: True - # routers: '10.0.10.1' - # comment: "Example IPv6 subnet" - # options: | - # default-lease-time 300; - # max-lease-time 7200; - # - #- subnet: '10.0.20.0' - # netmask: '255.255.255.0' - # comment: 'Ignored subnet' -# An example subnets included in a shared network -#dhcpd_subnets_local: -# - subnet: '10.0.30.0' -# netmask: '255.255.255.0' -# routers: [ '10.0.30.1', '10.0.30.2' ] +# .. envvar:: dhcpd_hosts # -# - subnet: '10.0.40.0' -# netmask: '255.255.255.0' -# routers: '19.0.40.1' -# options: | -# default-lease-time 300; -# max-lease-time 7200; -# pools: -# - comment: "A pool in a subnet" -# range: '10.0.30.10 10.0.30.20' - - -# Global list of hosts in DHCP +# Global list of hosts in DHCP. See ref:`dhcpd_hosts` for more details. dhcpd_hosts: [] -# - hostname: 'examplehost' -# address: '10.0.10.1' -# ethernet: '00:00:00:00:00:00' - -# Example global list of hosts read from an external file -#dhcpd_hosts: '/etc/dhcp/dhcpd.hosts.conf' -# List of external files to include +# List of external files to include. See :ref:`dhcpd_includes` for more +# details. dhcpd_includes: [] - #- '/etc/dhcp/example.conf' -# ---- ISC DHCP failover configuration ---- -# -# Each 'failover pair' declaration consists of primary and secondary host, -# no more than two nodes failover is currently allowed by isc-dhcpd. -# -# You must specify which failover pair each pool should use by specifying a -# 'failover peer' statement under an 'options' block in each pool declaration. -# e.g: -# -# dhcpd_failovers: -# - failover: "my-failover" -# primary: '10.0.30.1' -# secondary: '10.0.30.2' -# ... -# -# dhcpd_subnets: -# - subnet: ... -# ... -# pools: -# - comment: "My pool with failover" -# range: '10.0.30.10 10.0.30.20' -# options: | -# failover peer "my-failover"; -# -# Each failover declaration has a set of an mandatory fields, which is: -# primary: "" Ansible inventory name of a primary DHCP host, if -# you need failover to work on different IP, -# see primary_fo_addr option below. -# -# secondary: "" Ansible inventory name of a secondary DHCP host, if -# you need failover to work on different IP, -# see secondary_fo_addr option below. -# -# Ansible inventory name is either IP ot hostname specified in inventory file. -# -# mclt: 3600 Max Client Lead Time. The maximum amount of time -# that one server can extend a lease for a DHCP -# client beyond the time known by the partner server. -# -# split: [0-255] Specifies the split between the primary and -# secondary for the purposes of load balancing. -# Whenever a client makes a DHCP request, the DHCP -# server runs a hash on the client identification, -# resulting in value from 0 to 255. This is used as -# an index into a 256 bit field. If the bit at that -# index is set, the primary is responsible. If -# the bit at that index is not set, the secondary -# is responsible. -# -- or -- -# hba: ([0-9a-f]{2}:){32} Specifies the split between the primary and -# secondary as a bitmap rather than a cutoff, which -# theoretically allows for finer-grained control. -# In practice, there is probably no need for such -# fine-grained control, however. -# max_response_delay: 5 Tells the DHCP server how many seconds may pass -# without receiving a message from its failover peer -# before it assumes that connection has failed. -# This is mandatory according to dhcpd.conf man page. -# max_unacked_updates: 10 Tells the remote DHCP server how many BNDUPD -# messages it can send before it receives a BNDACK -# from the local system. -# This is mandatory according to dhcpd.conf man page. -# -# You must use either 'split' or 'hba' statement. Split has a preference, so -# if it's defined, 'hba' will be omitted by configuration template. -# Optional field are mostly desribed in dhcpd.conf man page: -# port: 647 Specifies port on which primary and secondary -# nodes will listen for failover connection. -# Diffirent ports for primary and secondary is -# currently unsupported. -# -# primary_fo_addr: "" IP/Hostname of a primary DHCP host. This option -# is used if you need failover address be different -# from ansible inventory IP/hostname. -# If omitted, then 'primary' is used. -# -# secondary_fo_addr: "" IP/Hostname of a secondary DHCP host. This option -# is used if you need failover address be different -# from ansible inventory IP/hostname. -# If omitted, then 'secondary' is used. -# -# auto_partner_down: 0 Number of second to start serving partners IPs -# after the partner's failure. -# -# load_balance_max_seconds: 5 -# max_lease_misbalance: 15 -# max_lease_ownership: 10 -# min_balance: 60 -# max_balance: 3600 + +# .. envvar:: dhcpd_failovers # +# DHCP failover configuration. See :ref:`dhcpd_failovers` for more details. dhcpd_failovers: [] - ## Following is full cluster configuration - #- failover: 'failover-localsubnet' - # primary: '10.0.10.1' - # primary_fo_addr: '10.5.10.1' - # secondary: '10.0.10.2' - # secondary_fo_addr: '10.5.10.2' - # port: 1337 - # split: 128 - # hba: aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa - # max_response_delay: 5 - # max_unacked_updates: 10 - # load_balance_max_seconds: 5 - # auto_partner_down: 0 - # max_lease_misbalance: 15 - # max_lease_ownership: 10 - # min_balance: 60 - # max_balance: 3600 - # - ## Following is minimal cluster configuration - #- failover: 'failover-san' - # primary: '10.0.10.1' - # secondary: '10.0.10.2' - # mclt: 3600 - # split: 128 - # max_response_delay: 5 - # max_unacked_updates: 10 + + +# ----------------------------- +# dhcp-probe configuration +# ----------------------------- + +# .. envvar:: dhcpd_probe +# +# Enable or disable ``dhcp-probe`` script +dhcpd_probe: True + + +# .. envvar:: dhcpd_probe_mail_to +# +# List of mail recipients which will receive messages about unauthorized DHCP +# servers. Set to ``[]`` to disable. +dhcpd_probe_mail_to: [ 'root@{{ ansible_domain }}' ] + + +# .. envvar:: dhcpd_probe_page_to +# +# Alternative list of mail recipients which will receive mail messages. Meant +# to be used as a "pager service", you can use ``debops.smstools`` role to +# setup a mail-SMS gateway and send the SMS messages that way. +dhcpd_probe_page_to: [] + + +# .. envvar:: dhcpd_probe_mail_timeout +# +# Number of seconds between to wait between sending new mail messages +dhcpd_probe_mail_timeout: '{{ (20 * 60) }}' + + +# .. envvar:: dhcpd_probe_page_timeout +# +# Number of seconds between to wait between sending new pager messages +dhcpd_probe_page_timeout: '{{ (20 * 60) }}' + + +# .. envvar:: dhcpd_probe_legal_servers +# +# List of IP addresses of the host which are authorized DHCP servers. +dhcpd_probe_legal_servers: [] + + +# .. envvar:: dhcpd_probe_options +# +# Additional ``dhcp-probe`` options specified as a YAML text block. +dhcpd_probe_options: '' diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 0000000..d9e113e --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1 @@ +.. include:: ../CHANGES.rst diff --git a/docs/copyright.rst b/docs/copyright.rst new file mode 100644 index 0000000..742b240 --- /dev/null +++ b/docs/copyright.rst @@ -0,0 +1,21 @@ +Copyright +========= + +:: + + Copyright (C) 2014 Maciej Delmanowski + Copyright (C) 2014 DebOps Project http://debops.org/ + [see Credits for more details] + + his program 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. + + This program 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 this program. If not, see http://www.gnu.org/licenses/ + diff --git a/docs/credits.rst b/docs/credits.rst new file mode 100644 index 0000000..0f6b51a --- /dev/null +++ b/docs/credits.rst @@ -0,0 +1,16 @@ +Credits +======= + +Credits, in chronological order +------------------------------- + +* Maciej Delmanowski + + * creator of the DebOps Project + + * current project maintainer + +* RedRampage + + * Added support for DHCP failover and Dynamic DNS keys + diff --git a/docs/defaults-configuration.rst b/docs/defaults-configuration.rst new file mode 100644 index 0000000..3355182 --- /dev/null +++ b/docs/defaults-configuration.rst @@ -0,0 +1,468 @@ +Default variables: configuration +================================ + +some of ``debops.dhcpd`` default variables have more extensive configuration +than simple strings or lists, here you can find documentation and examples for +them. + +.. contents:: + :local: + :depth: 1 + + +.. _dhcpd_keys: + +dhcpd_keys +---------- + +This list lets you define symmetric keys used to update dynamic DNS with +information configured using DHCP. + +``key`` + Name of the key used to select it in specific scope + +``algorithm`` + Name of the algorithm to use for key encryption + +``secret`` + Encrypted symmetric key shared between DHCP and DNS servers + +``comment`` + An optional comment added in the configuration file + +Examples:: + + # Read the secret key from an external file + dhcpd_secret_secure_key: '{{ lookup("password", + secret + "/" + ansible_domain + + "/shared/ddns/keys/secure-key" }}' + + dhcpd_keys: + - key: "secure-key" + algorithm: "hmac-md5" + secret: "{{ dhcpd_secret_secure_key }}" + + +.. _dhcpd_zones: + +dhcpd_zones +----------- + +This list lets you define DNS zones used to update dynamic DNS with information +configured using DHCP. + +``zone`` + DNS domain name of a zone, needs to end with a dot (``.``) + +``primary`` + Address of the primary DNS server serving the specified zone + +``key`` + Name of the symmetric key used to authorize Dynamic DNS updates of the + specified zone + +``comment`` + An optional comment added in the configuration file + +Examples:: + + dhcpd_zones: + - zone: "example.org." + primary: "127.0.0.1" + key: "secure-key" + + +.. _dhcpd_classes: + +dhcpd_classes +------------- + +Here you can define host classes and custom options for each class. + +``class`` + Name of the host class + +``comment`` + Optional comment added in the configuration file + +``options`` + Text block with options for a particular class scope + +``include`` + Include an external file + +``subclass`` + Dict. You can specify matches for a class in two ways: + + - a dict key without a value will create a simple match for that host. You + need to specify dict key with colon (``:``) at the end to indicate that + this is a dict key, see examples below + + - a dict with a text block as a value will create an extended match scope + with options specified in the text block inside that scope + +Examples:: + + dhcpd_classes: + + - class: 'empty-class' + + - class: 'allocation-class-1' + + options: | + match pick-first-value (option dhcp-client-identifier, hardware); + + subclass: + # Simple match + '00:11:22:33:44:55': + + # Extended match + '00:11:22:33:22:11': | + option root-path "samsara:/var/diskless/alphapc"; + filename "/tftpboot/netbsd.alphapc-diskless"; + + +.. _dhcpd_groups: + +dhcpd_groups +------------ + +Group related configuration together. + +``comment`` + Optional comment added in the configuration file + +``options`` + Text block with options for a particular group + +``include`` + Include an external file + +``groups`` + Include another group definition of the group in this group. Child group + should be defined in a separate YAML dict. Recursion is not allowed. + +``hosts`` + List of hosts included in this group. Use the same format as the + ``dhcpd_hosts`` list. + +``subnets`` + List of subnets included in this group. Use the same format as the + ``dhcpd_subnets`` list. + +Examples:: + + dhcpd_groups: + - comment: 'First group' + hosts: '/etc/dhcp/dhcpd-group1-hosts.conf' + groups: '{{ dhcpd_group_second }}' + + # An example of group nesting + dhcpd_group_second: + - comment: 'Second group' + hosts: '/etc/dhcp/dhcpd-group2-hosts.conf' + + +.. _dhcpd_shared_networks: + +dhcpd_shared_networks +--------------------- + +List of shared networks which combine specified subnets together. + +``name`` + Name of a shared network + +``comment`` + A comment added to this shared network in the configuration + +``options`` + Custom options in the text block format for this shared network + +``include`` + Include an external file in this shared network scope + +``subnets`` + List of subnets included in this shared network. Use the same format as the + ``dhcpd_subnets`` list. + +Examples:: + + dhcpd_shared_networks: + - name: 'shared-net' + comment: "Local shared network" + subnets: '{{ dhcpd_subnets_local }}' + options: | + default-lease-time 600; + max-lease-time 900; + + dhcpd_subnets_local: + - subnet: '10.0.30.0' + netmask: '255.255.255.0' + routers: [ '10.0.30.1', '10.0.30.2' ] + + - subnet: '10.0.40.0' + netmask: '255.255.255.0' + routers: '19.0.40.1' + options: | + default-lease-time 300; + max-lease-time 7200; + pools: + - comment: "A pool in a subnet" + range: '10.0.30.10 10.0.30.20' + + +.. _dhcpd_subnets: + +dhcpd_subnets +------------- + +List of subnets included in a specified group. + +``subnet`` + IP address of the subnet. If it's IPv4, it should be the first IP address in + the subnet, if it's IPv6, it should be specified with the prefix. + +``netmask`` + If the subnet is IPv4, specify it's netmask in "normal" IP address form, not + the CIDR form. + +``ipv6`` + Set to ``True`` if managed subnet is IPv6. + +``routers`` + String (if just one), or list (if many) of IP addresses of the routers for + this subnet + +``comment`` + A comment added to this subnet in the configuration + +``options`` + Custom options in the text block format for this subnet + +``include`` + Include an external file in this subnet scope + +``pools`` + List of different address pools within specified subnet. Each pool should be + specified as a dict, following keys are recognized: + + - ``range``: a string which defines the range of the specific pool, with IP + addresses of the start and end delimited by space + + - ``comment``: a comment added to this host in the configuration + + - ``options``: custom options in the text block format for this host + + - ``include``: include an external file in this pool + +Examples:: + + # List of subnets + dhcpd_subnets: [ '{{ dhcpd_subnet_default }}' ] + + dhcpd_subnet_default: + subnet: '{{ ansible_default_ipv4.network }}' + netmask: '{{ ansible_default_ipv4.netmask }}' + comment: 'Generated automatically by Ansible' + + # An IPv6 subnet + example_ipv6_subnet: + subnet: 'dead:be:ef::/64' + ipv6: True + routers: 'dead:be:ef::1' + comment: "Example IPv6 subnet" + options: | + default-lease-time 300; + max-lease-time 7200; + +.. _dhcpd_hosts: + +dhcpd_hosts +----------- + +String or list. If string, include an external file with host list in this +place of the configuration. If list, specify a list of dicts describing the +hosts. Each dict can have following keys: + +``hostname`` + Name of the host + +``ethernet`` + Ethernet address of this host + +``address`` + IP address of this host + +``comment`` + A comment added to this host in the configuration + +``options`` + Custom options in the text block format for this host + +Examples:: + + # External file with list of hosts + dhcpd_hosts: '/etc/dhcp/dhcp-hosts.conf' + + # List of hosts + dhcpd_hosts: + - hostname: 'examplehost' + address: '10.0.10.1' + ethernet: '00:00:00:00:00:00' + +.. _dhcpd_includes: + +dhcpd_includes +-------------- + +List of external files to include in DHCP configuration. Use absolute paths for +the files. + +Examples:: + + dhcpd_includes: + - '/etc/dhcp/other-options.conf' + +.. _dhcpd_failovers: + +dhcpd_failovers +--------------- + +Each 'failover pair' declaration consists of primary and secondary host, +no more than two nodes failover is currently allowed by ``isc-dhcpd``. + +You must specify which failover pair each pool should use by specifying +a 'failover peer' statement under an ``options`` block in each pool +declaration. e.g:: + + dhcpd_failovers: + - failover: "my-failover" + primary: '10.0.30.1' + secondary: '10.0.30.2' + ... + + dhcpd_subnets: + - subnet: ... + ... + pools: + - comment: "My pool with failover" + range: '10.0.30.10 10.0.30.20' + options: | + failover peer "my-failover"; + +Each failover declaration has a set of an mandatory fields, which is: + +``primary`` + Ansible inventory name of a primary DHCP host, if you need failover to work + on different IP, see ``primary_fo_addr`` option below. + +``secondary`` + Ansible inventory name of a secondary DHCP host, if you need failover to work + on different IP, see secondary_fo_addr option below. + +Ansible inventory name is either IP ot hostname specified in inventory file. + +``mclt`` + Max Client Lead Time. The maximum amount of time that one server can extend + a lease for a DHCP client beyond the time known by the partner server. + + Default value: ``3600`` + +Split configuration between two failover DHCP servers: + +``split`` + Percentage value between ``0`` and ``255``. + + Specifies the split between the primary and secondary servers for the + purposes of load balancing. Whenever a client makes a DHCP request, the DHCP + server runs a hash on the client identification, resulting in value from 0 to + 255. This is used as an index into a 256 bit field. If the bit at that index + is set, the primary is responsible. If the bit at that index is not set, the + secondary is responsible. Instead of ``split``, you can use ``hba``. + +``hba`` + 32 character string in the regexp: ``([0-9a-f]{2}:){32}`` + + Specifies the split between the primary and secondary as a bitmap rather than + a cutoff, which theoretically allows for finer-grained control. In practice, + there is probably no need for such fine-grained control, however. + +You must use either 'split' or 'hba' statement. Split has a preference, so +if it's defined, 'hba' will be omitted by configuration template. + +``max_response_delay`` + Tells the DHCP server how many seconds may pass without receiving a message + from its failover peer before it assumes that connection has failed. This is + mandatory according to ``dhcpd.conf`` man page. + + Default value: ``5`` + +``max_unacked_updates`` + Tells the remote DHCP server how many ``BNDUPD`` messages it can send before + it receives a ``BNDACK`` from the local system. This is mandatory according + to ``dhcpd.conf`` man page. + + Default value: ``10`` + +Optional field are mostly desribed in ``dhcpd.conf`` man page: + +``port`` + Specifies port on which primary and secondary nodes will listen for failover + connection. Diffirent ports for primary and secondary is currently + unsupported. + + Default value: ``647`` + +``primary_fo_addr`` + IP/Hostname of a primary DHCP host. This option is used if you need + failover address be different from ansible inventory IP/hostname. If + omitted, then ``primary`` is used. + +``secondary_fo_addr`` + IP/Hostname of a secondary DHCP host. This option is used if you need + failover address be different from ansible inventory IP/hostname. If + omitted, then ``secondary`` is used. + +``auto_partner_down`` + Number of seconds to start serving partners IPs after the partner's failure. + +Other parameters:: + + load_balance_max_seconds: 5 + max_lease_misbalance: 15 + max_lease_ownership: 10 + min_balance: 60 + max_balance: 3600 + +Examples:: + + # Full cluster configuration + dhcpd_failovers: + - failover: 'failover-localsubnet' + primary: '10.0.10.1' + primary_fo_addr: '10.5.10.1' + secondary: '10.0.10.2' + secondary_fo_addr: '10.5.10.2' + port: 1337 + split: 128 + hba: aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa + max_response_delay: 5 + max_unacked_updates: 10 + load_balance_max_seconds: 5 + auto_partner_down: 0 + max_lease_misbalance: 15 + max_lease_ownership: 10 + min_balance: 60 + max_balance: 3600 + + # Minimal cluster configuration + dhcpd_failovers: + - failover: 'failover-san' + primary: '10.0.10.1' + secondary: '10.0.10.2' + mclt: 3600 + split: 128 + max_response_delay: 5 + max_unacked_updates: 10 + diff --git a/docs/getting-started.rst b/docs/getting-started.rst new file mode 100644 index 0000000..163d87c --- /dev/null +++ b/docs/getting-started.rst @@ -0,0 +1,18 @@ +Getting started +=============== + +By default ``debops.dhcpd`` installs a DHCP server with some default +configuration. Server will not be authoritative, and will have a default subnet +configuration taken from ``ansible_default_ipv4`` network configuration. + +An example playbook which uses ``debops.dhcpd`` role:: + + --- + + - name: Manage DHCP server + hosts: debops_dhcpd + + roles: + - role: debops.dhcpd + tags: dhcpd + diff --git a/docs/guides.rst b/docs/guides.rst new file mode 100644 index 0000000..742b1f7 --- /dev/null +++ b/docs/guides.rst @@ -0,0 +1,6 @@ +Guides and examples +=================== + +This section will contain guides for configuring ``debops.dhcpd`` in various +scenarios. + diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..e6a8372 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,22 @@ +debops.dhcpd +============ + +.. toctree:: + :maxdepth: 3 + + introduction + installation + getting-started + defaults + defaults-configuration + guides + troubleshooting + copyright + credits + changelog + +.. + Local Variables: + mode: rst + ispell-local-dictionary: "american" + End: diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..84578e1 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,7 @@ +Installation +============ + +This role requires at least Ansible ``v1.7.0``. To install it, run:: + + ansible-galaxy install debops.dhcpd + diff --git a/docs/introduction.rst b/docs/introduction.rst new file mode 100644 index 0000000..78e230a --- /dev/null +++ b/docs/introduction.rst @@ -0,0 +1,18 @@ +Introduction +============ + +``debops.dhcpd`` role can be used to configure an `ISC DHCP Server`_ as +standalone or in a 2-host failover configuration. Alternatively, you can +configure an DHCP relay on a host connected to multiple networks which will +relay DHCP/BOOTP messages to your DHCP server. + +``dhcp-probe`` script will be used to scan the network for unauthorized DHCP +servers and notify administrators if they are found. + +.. _ISC DHCP Server: https://www.isc.org/downloads/dhcp/ + +.. + Local Variables: + mode: rst + ispell-local-dictionary: "american" + End: diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst new file mode 100644 index 0000000..e83d254 --- /dev/null +++ b/docs/troubleshooting.rst @@ -0,0 +1,6 @@ +Troubleshooting +=============== + +This section will contain information about fixing issues with +``debops.dhcpd`` role. + diff --git a/handlers/main.yml b/handlers/main.yml index d2105ac..db77015 100644 --- a/handlers/main.yml +++ b/handlers/main.yml @@ -1,6 +1,23 @@ --- - name: Restart isc-dhcp-server - service: name=isc-dhcp-server state=restarted + service: + name: 'isc-dhcp-server' + state: 'restarted' +- name: Restart isc-dhcp-relay + service: + name: 'isc-dhcp-relay' + state: 'restarted' + +- name: Restart dhcp-probe + service: + name: 'dhcp-probe' + state: 'stopped' + notify: [ 'Start dhcp-probe' ] + +- name: Start dhcp-probe + service: + name: 'dhcp-probe' + state: 'started' diff --git a/meta/ansigenome.yml b/meta/ansigenome.yml index 4977157..e78e1f6 100644 --- a/meta/ansigenome.yml +++ b/meta/ansigenome.yml @@ -17,5 +17,11 @@ ansigenome_info: github: 'drybjed' synopsis: | - Install and configure [ISC DHCP Server](https://www.isc.org/downloads/dhcp/). - + `debops.dhcpd` role can be used to configure an [ISC DHCP + Server](https://www.isc.org/downloads/dhcp/) as standalone or in a 2-host + failover configuration. Alternatively, you can configure an DHCP relay on + a host connected to multiple networks which will relay DHCP/BOOTP messages + to your DHCP server. + + `dhcp-probe` script will be used to scan the network for unauthorized DHCP + servers and notify administrators if they are found. diff --git a/meta/main.yml b/meta/main.yml index e94cef3..96eaff6 100644 --- a/meta/main.yml +++ b/meta/main.yml @@ -1,6 +1,8 @@ --- -dependencies: [] +dependencies: + + - role: debops.secret galaxy_info: author: 'Maciej Delmanowski' diff --git a/tasks/dhcp-probe.yml b/tasks/dhcp-probe.yml new file mode 100644 index 0000000..19bb000 --- /dev/null +++ b/tasks/dhcp-probe.yml @@ -0,0 +1,28 @@ +--- + +- name: Create dhcp-probe lib directory + file: + path: '{{ ansible_local.root.lib + "/dhcp-probe" }}' + state: 'directory' + owner: 'root' + group: 'root' + mode: '0755' + +- name: Manage dhcp-probe notification scripts + template: + src: 'usr/local/lib/dhcp-probe/{{ item }}.j2' + dest: '{{ ansible_local.root.lib + "/dhcp-probe/" + item }}' + owner: 'root' + group: 'root' + mode: '0755' + with_items: [ 'dhcp_probe_notify2', 'mail-throttled' ] + +- name: Configure dhcp-probe + template: + src: 'etc/dhcp_probe.cf.j2' + dest: '/etc/dhcp_probe.cf' + owner: 'root' + group: 'root' + mode: '0644' + notify: [ 'Restart dhcp-probe' ] + diff --git a/tasks/main.yml b/tasks/main.yml index 4a919b6..2381fc3 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -1,13 +1,58 @@ --- -- name: Install DHCP server packages - apt: pkg={{ item }} state=latest install_recommends=no - with_items: [ 'isc-dhcp-server' ] +- name: Configure DHCP relay in debconf + debconf: + name: 'isc-dhcp-relay' + question: 'isc-dhcp-relay/{{ item.key }}' + vtype: 'string' + value: '{{ item.value }}' + with_dict: + servers: '{{ dhcpd_relay_servers | join(" ") }}' + interfaces: '{{ dhcpd_relay_interfaces | join(" ") }}' + options: '{{ dhcpd_relay_options }}' + register: dhcpd_register_relay_debconf + when: dhcpd_mode == 'relay' + +- name: Install DHCP packages + apt: + name: '{{ item }}' + state: 'present' + install_recommends: False + with_flattened: + - dhcpd_base_packages_map[dhcpd_mode] + - [ '{{ "dhcp-probe" if (dhcpd_probe|d() and dhcpd_probe) else [] }}' ] + +- name: Reconfigure ISC DHCP relay + command: dpkg-reconfigure --frontend=noninteractive isc-dhcp-relay + notify: [ 'Restart isc-dhcp-relay' ] + when: dhcpd_register_relay_debconf|d() and dhcpd_register_relay_debconf.changed + +- name: Get list of nameservers configured in /etc/resolv.conf + shell: grep -E '^nameserver\s' /etc/resolv.conf | awk '{print $2}' | sed -e 'N;s/\n/ /' + register: dhcpd_register_nameservers + changed_when: False + when: dhcpd_mode == 'server' + +- name: Convert list of nameservers to Ansible list + set_fact: + dhcpd_runtime_nameservers: "{{ dhcpd_register_nameservers.stdout.split(' ') }}" + when: (dhcpd_register_nameservers is defined and dhcpd_register_nameservers.stdout) - name: Configure DHCP server - template: src={{ item }}.j2 dest=/{{ item }} owner=root group=root mode=0644 + template: + src: '{{ item }}.j2' + dest: '/{{ item }}' + owner: 'root' + group: 'root' + mode: '0644' with_items: [ 'etc/default/isc-dhcp-server', 'etc/dhcp/dhcpd.conf' ] - notify: Restart isc-dhcp-server + notify: [ 'Restart isc-dhcp-server' ] + when: dhcpd_mode == 'server' +- name: Make sure that IPv6 lease file exists + command: touch /var/lib/dhcp/dhcpd6.leases creates=/var/lib/dhcp/dhcpd6.leases + when: dhcpd_ipversion == '6' +- include: dhcp-probe.yml + when: dhcpd_probe|d() and dhcpd_probe diff --git a/templates/etc/default/isc-dhcp-server.j2 b/templates/etc/default/isc-dhcp-server.j2 index 430430e..69347d7 100644 --- a/templates/etc/default/isc-dhcp-server.j2 +++ b/templates/etc/default/isc-dhcp-server.j2 @@ -16,7 +16,7 @@ # Additional options to start dhcpd with. # Don't use options -cf or -pf here; use DHCPD_CONF/ DHCPD_PID instead -#OPTIONS="" +OPTIONS="{{ dhcpd_server_options }}" # On what interfaces should the DHCP server (dhcpd) serve DHCP requests? # Separate multiple interfaces with spaces, e.g. "eth0 eth1". diff --git a/templates/etc/dhcp/auto_options.j2 b/templates/etc/dhcp/auto_options.j2 new file mode 100644 index 0000000..7c0a656 --- /dev/null +++ b/templates/etc/dhcp/auto_options.j2 @@ -0,0 +1,30 @@ +{% if dhcpd_domain_name|d() and dhcpd_domain_name %} +{% set dhcpd_tpl_domain_search = [ dhcpd_domain_name ] + dhcpd_domain_search|d([]) %} +option domain-name "{{ dhcpd_domain_name }}"; + +option domain-search "{{ dhcpd_tpl_domain_search | join('", "') }}"; +option dhcp6.domain-search "{{ dhcpd_tpl_domain_search | join('", "') }}"; + +{% endif %} +{% if dhcpd_nameservers|d() and dhcpd_nameservers %} +{% set dhcpd_tpl_nameservers = dhcpd_nameservers %} +{% elif dhcpd_runtime_nameservers|d() and dhcpd_runtime_nameservers %} +{% set dhcpd_tpl_nameservers = [] %} +{% for server in dhcpd_runtime_nameservers %} +{% if server not in [ '127.0.0.1', '::1' ] %} +{% set _ = dhcpd_tpl_nameservers.append(server) %} +{% endif %} +{% endfor %} +{% endif %} +{% if dhcpd_tpl_nameservers %} +{% if dhcpd_tpl_nameservers | ipv4 %} +option domain-name-servers {{ dhcpd_tpl_nameservers | ipv4 | join(", ") }}; +{% endif %} +{% if dhcpd_tpl_nameservers | ipv6 %} +option dhcp6.name-servers {{ dhcpd_tpl_nameservers | ipv6 | join(", ") }}; +{% endif %} + +{% endif %} +{# + vim: ft=dhcpd +#} diff --git a/templates/etc/dhcp/dhcpd.conf.j2 b/templates/etc/dhcp/dhcpd.conf.j2 index 3049878..8228928 100644 --- a/templates/etc/dhcp/dhcpd.conf.j2 +++ b/templates/etc/dhcp/dhcpd.conf.j2 @@ -1,192 +1,5 @@ -# This file is managed by Ansible, all changes will be lost -{% macro print_class(class) %} -{% if class.comment is defined and class.comment %} -# {{ class.comment }} -{% endif %} -class "{{ class.class }}" { -{% if class.options is defined and class.options %} -{{ class.options | indent(8,true) }} -{% endif %} -{% if class.include is defined and class.include %} - include "{{ class.include }}"; -{% endif %} -} -{% if class.subclass is defined and class.subclass %} - -{% for key, value in class.subclass.iteritems() %} -{% if value is defined and value %} -subclass "{{ class.class }}" "{{ key }}" { -{{ value | indent(8,true) }} -} - -{% else %} -subclass "{{ class.class }}" {{ key }}; -{% endif %} -{% endfor %} -{% endif %} -{% endmacro %} -{% macro print_group(group) %} -{% if group.comment is defined and group.comment %} -# {{ group.comment }} -{% endif %} -group { -{% if group.options is defined and group.options %} -{{ group.options | indent(8,true) }} -{% endif %} -{% if group.include is defined and group.include %} - include "{{ group.include }}"; -{% endif %} -{% if group.groups is defined and group.groups %} -{% for group in group.groups %} -{{ print_group(group) | indent(8, true) }} -{% endfor %} -{% endif %} -{% if group.subnets is defined and group.subnets %} -{% for subnet in group.subnets %} -{{ print_subnet(subnet) | indent(8, true) }} -{% endfor %} -{% endif %} -{% if group.hosts is defined and group.hosts %} -{{ print_hosts(group.hosts) | indent(8, true) }} -{% endif %} -} -{% endmacro %} -{% macro print_subnet(subnet) %} -{% if subnet.comment is defined and subnet.comment %} -# {{ subnet.comment }} -{% endif %} -{% if subnet.ipv6 is defined and subnet.ipv6 %} -subnet6 {{ subnet.subnet }} { -{% else %} -subnet {{ subnet.subnet }} netmask {{ subnet.netmask }} { -{% endif %} -{% if subnet.routers is defined and subnet.routers %} -{% if subnet.routers is string %} - option routers {{ subnet.routers }}; -{% else %} - option routers {{ subnet.routers | join(' ') }}; -{% endif %} -{% endif %} -{% if subnet.options is defined and subnet.options %} -{{ subnet.options | indent(8,true) }} -{% endif %} -{% if subnet.include is defined and subnet.include %} - include "{{ subnet.include }}"; -{% endif %} -{% if subnet.pools is defined and subnet.pools %} -{% for pool in subnet.pools %} - pool { -{% if pool.comment is defined and pool.comment %} - # {{ pool.comment }} -{% endif %} -{% if subnet.ipv6 is defined and subnet.ipv6 %} - range6 {{ pool.range }}; -{% else %} - range {{ pool.range }}; -{% endif %} -{% if pool.options is defined and pool.options %} -{{ pool.options | indent(16,true) }} -{% endif %} -{% if pool.include is defined and pool.include %} - include "{{ pool.include }}"; -{% endif %} - } -{% endfor %} -{% endif %} -{% if subnet.hosts is defined and subnet.hosts %} -{{ print_hosts(subnet.hosts) | indent(8, true) }} -{% endif %} -} -{% endmacro %} -{% macro print_hosts(hosts) %} -{% if hosts is string %} -include "{{ hosts }}"; -{% else %} -{% for host in hosts %} -{% if host.comment is defined and host.comment %} -# {{ host.comment }} -{% endif %} -host {{ host.hostname }} { -{% if host.options is defined and host.options %} -{{ host.options | indent(8,true) }} -{% endif %} -{% if host.ethernet is defined and host.ethernet %} - hardware ethernet {{ host.ethernet }}; -{% endif %} -{% if host.address is defined and host.address %} - fixed-address {{ host.address }}; -{% endif %} -} -{% endfor %} -{% endif %} -{% endmacro %} -{% macro print_failover(failover) %} -{% if failover.comment is defined and failover.comment %} -# {{ failover.comment }} -{% endif %} -failover peer "{{ failover.failover }}" { -{% if failover.primary is defined and failover.primary == inventory_hostname %} - primary; - mclt {{ failover.mclt|default(3600) }}; -{% if failover.primary_fo_addr is defined and failover.primary_fo_addr %} - address {{ failover.primary_fo_addr }}; -{% else %} - address {{ failover.primary }}; -{% endif %} -{% if failover.secondary_fo_addr is defined and failover.secondary_fo_addr %} - peer address {{ failover.secondary_fo_addr }}; -{% else %} - peer address {{ failover.secondary }}; -{% endif %} -{% if failover.split is defined and failover.split %} - split {{ failover.split }}; -{% elif failover.hba is defined and failover.hba %} - hba {{ failover.hba }}; -{% endif %} -{% else %} - secondary; -{% if failover.secondary_fo_addr is defined and failover.secondary_fo_addr %} - address {{ failover.secondary_fo_addr }}; -{% else %} - address {{ failover.secondary }}; -{% endif %} -{% if failover.primary_fo_addr is defined and failover.primary_fo_addr %} - peer address {{ failover.primary_fo_addr }}; -{% else %} - peer address {{ failover.primary }}; -{% endif %} -{% endif %} - max-response-delay {{ failover.max_response_delay|default(30) }}; - max-unacked-updates {{ failover.max_unacked_updates|default(10) }}; -{% if failover.load_balance_max_seconds is defined and failover.load_balance_max_seconds %} - load balance max seconds {{ failover.load_balance_max_seconds }}; -{% endif %} -{% if failover.max_lease_misbalance is defined and failover.max_lease_misbalance %} - max-lease-misbalance {{ failover.max_lease_misbalance }}; -{% endif %} -{% if failover.max_lease_ownership is defined and failover.max_lease_ownership %} - max-lease-ownership {{ failover.max_lease_ownership }}; -{% endif %} -{% if failover.min_balance is defined and failover.min_balance %} - min-balance {{ failover.min_balance }}; -{% endif %} -{% if failover.max_balance is defined and failover.max_balance %} - max-balance {{ failover.max_balance }}; -{% endif %} -{% if failover.auto_partner_down is defined and failover.auto_partner_down %} - auto-partner-down {{ failover.auto_partner_down }}; -{% endif %} -} -{% endmacro %} -{% macro print_key(key) %} -{% if key.comment is defined and key.comment %} -# {{ key.comment }} -{% endif %} -key "{{ key.key }}" { - algorithm {{ key.algorithm|default('hmac-md5') }}; - secret {{ key.secret }}; -} -{% endmacro %} +{% import 'macros.j2' as print with context %} +# {{ ansible_managed }} {% if dhcpd_authoritative is defined and dhcpd_authoritative %} authoritative; @@ -195,10 +8,19 @@ authoritative; not authoritative; {% endif %} -{% if dhcpd_global_options is defined and dhcpd_global_options %} -# Global configuration options -{{ dhcpd_global_options }} +{% if dhcpd_global_default_lease_time|d() and dhcpd_global_default_lease_time %} +default-lease-time {{ dhcpd_global_default_lease_time }}; +{% endif %} +{% if dhcpd_global_max_lease_time|d() and dhcpd_global_max_lease_time %} +max-lease-time {{ dhcpd_global_max_lease_time }}; +{% endif %} +{% if dhcpd_log_facility|d() and dhcpd_log_facility %} +log-facility {{ dhcpd_log_facility }}; + +{% endif %} +{% if dhcpd_auto_options|d() and dhcpd_auto_options %} +{% include 'auto_options.j2' %} {% endif %} {% if dhcpd_options is defined and dhcpd_options %} # Configuration options @@ -207,17 +29,22 @@ not authoritative; {% endif %} {% if dhcpd_keys is defined and dhcpd_keys %} {% for key in dhcpd_keys %} -{{ print_key(key) }} +{{ print.key(key) }} +{% endfor %} +{% endif %} +{% if dhcpd_zones is defined and dhcpd_zones %} +{% for zone in dhcpd_zones %} +{{ print.zone(zone) }} {% endfor %} {% endif %} {% if dhcpd_classes is defined and dhcpd_classes %} {% for class in dhcpd_classes %} -{{ print_class(class) }} +{{ print.class(class) }} {% endfor %} {% endif %} {% if dhcpd_failovers is defined and dhcpd_failovers %} {% for failover in dhcpd_failovers %} -{{ print_failover(failover) }} +{{ print.failover(failover) }} {% endfor %} {% endif %} {% if dhcpd_shared_networks is defined and dhcpd_shared_networks %} @@ -235,7 +62,7 @@ shared-network "{{ network.name }}" { {% endif %} {% for subnet in network.subnets %} -{{ print_subnet(subnet) | indent(8,true) }} +{{ print.subnet(subnet) | indent(8,true) }} {% endfor %} } @@ -244,16 +71,16 @@ shared-network "{{ network.name }}" { {% endif %} {% if dhcpd_groups is defined and dhcpd_groups %} {% for group in dhcpd_groups %} -{{ print_group(group) }} +{{ print.group(group) }} {% endfor %} {% endif %} {% if dhcpd_subnets is defined and dhcpd_subnets %} {% for subnet in dhcpd_subnets %} -{{ print_subnet(subnet) }} +{{ print.subnet(subnet) }} {% endfor %} {% endif %} {% if dhcpd_hosts is defined and dhcpd_hosts %} -{{ print_hosts(dhcpd_hosts) }} +{{ print.hosts(dhcpd_hosts) }} {% endif %} {% if dhcpd_includes is defined and dhcpd_includes %} {% for include in dhcpd_includes %} diff --git a/templates/etc/dhcp/macros.j2 b/templates/etc/dhcp/macros.j2 new file mode 100644 index 0000000..ae28e60 --- /dev/null +++ b/templates/etc/dhcp/macros.j2 @@ -0,0 +1,231 @@ +{# +# List of macros for ISC DHCP configuration, IPv4 +# =============================================== +# +# ---- Macro: print.class() ---- +#} +{% macro class(class) %} +{% if class.comment is defined and class.comment %} +# {{ class.comment }} +{% endif %} +class "{{ class.class }}" { +{% if class.options is defined and class.options %} +{{ class.options | indent(8,true) }} +{% endif %} +{% if class.include is defined and class.include %} + include "{{ class.include }}"; +{% endif %} +} +{% if class.subclass is defined and class.subclass %} + +{% for key, value in class.subclass.iteritems() %} +{% if value is defined and value %} +subclass "{{ class.class }}" "{{ key }}" { +{{ value | indent(8,true) }} +} + +{% else %} +subclass "{{ class.class }}" {{ key }}; +{% endif %} +{% endfor %} +{% endif %} +{% endmacro %} +{# +# +# ---- Macro: print.group() ---- +#} +{% macro group(group) %} +{% if group.comment is defined and group.comment %} +# {{ group.comment }} +{% endif %} +group { +{% if group.options is defined and group.options %} +{{ group.options | indent(8,true) }} +{% endif %} +{% if group.include is defined and group.include %} + include "{{ group.include }}"; +{% endif %} +{% if group.groups is defined and group.groups %} +{% for group in group.groups %} +{{ print.group(group) | indent(8, true) }} +{% endfor %} +{% endif %} +{% if group.subnets is defined and group.subnets %} +{% for subnet in group.subnets %} +{{ print.subnet(subnet) | indent(8, true) }} +{% endfor %} +{% endif %} +{% if group.hosts is defined and group.hosts %} +{{ print.hosts(group.hosts) | indent(8, true) }} +{% endif %} +} +{% endmacro %} +{# +# +# ---- Macro: print.subnet() ---- +#} +{% macro subnet(subnet) %} +{% if subnet.comment is defined and subnet.comment %} +# {{ subnet.comment }} +{% endif %} +{% if dhcpd_ipversion is defined and dhcpd_ipversion == '6' %} +subnet6 {{ subnet.subnet | ipaddr('network') + '/' + subnet.subnet | ipaddr('prefix') | string }} { +{% else %} +subnet {{ subnet.subnet | ipaddr('cidr') | ipaddr('network') }} netmask {{ subnet.netmask | default(subnet.subnet | ipaddr('netmask')) }} { +{% endif %} +{% if subnet.routers is defined and subnet.routers %} +{% if subnet.routers is string %} + option routers {{ subnet.routers }}; +{% else %} + option routers {{ subnet.routers | join(', ') }}; +{% endif %} +{% endif %} +{% if subnet.options is defined and subnet.options %} +{{ subnet.options | indent(8,true) }} +{% endif %} +{% if subnet.include is defined and subnet.include %} + include "{{ subnet.include }}"; +{% endif %} +{% if subnet.pools is defined and subnet.pools %} +{% for pool in subnet.pools %} + pool { +{% if pool.comment is defined and pool.comment %} + # {{ pool.comment }} +{% endif %} +{% if dhcpd_ipversion is defined and dhcpd_ipversion == '6' %} + range6 {{ pool.range }}; +{% else %} + range {{ pool.range }}; +{% endif %} +{% if pool.options is defined and pool.options %} +{{ pool.options | indent(16,true) }} +{% endif %} +{% if pool.include is defined and pool.include %} + include "{{ pool.include }}"; +{% endif %} + } +{% endfor %} +{% endif %} +{% if subnet.hosts is defined and subnet.hosts %} +{{ print.hosts(subnet.hosts) | indent(8, true) }} +{% endif %} +} +{% endmacro %} +{# +# +# ---- Macro: print.hosts() ---- +#} +{% macro hosts(hosts) %} +{% if hosts is string %} +include "{{ hosts }}"; +{% else %} +{% for host in hosts %} +{% if host.comment is defined and host.comment %} +# {{ host.comment }} +{% endif %} +host {{ host.hostname }} { +{% if host.options is defined and host.options %} +{{ host.options | indent(8,true) }} +{% endif %} +{% if host.ethernet is defined and host.ethernet %} + hardware ethernet {{ host.ethernet }}; +{% endif %} +{% if host.address is defined and host.address %} +{% if dhcpd_ipversion is defined and dhcpd_ipversion == '6' %} + fixed-address6 {{ host.address }}; +{% else %} + fixed-address {{ host.address }}; +{% endif %} +{% endif %} +} +{% endfor %} +{% endif %} +{% endmacro %} +{# +# +# ---- Macro: print.failover() ---- +#} +{% macro failover(failover) %} +{% if failover.comment is defined and failover.comment %} +# {{ failover.comment }} +{% endif %} +failover peer "{{ failover.failover }}" { +{% if failover.primary is defined and failover.primary == inventory_hostname %} + primary; + mclt {{ failover.mclt|default(3600) }}; +{% if failover.primary_fo_addr is defined and failover.primary_fo_addr %} + address {{ failover.primary_fo_addr }}; +{% else %} + address {{ failover.primary }}; +{% endif %} +{% if failover.secondary_fo_addr is defined and failover.secondary_fo_addr %} + peer address {{ failover.secondary_fo_addr }}; +{% else %} + peer address {{ failover.secondary }}; +{% endif %} +{% if failover.split is defined and failover.split %} + split {{ failover.split }}; +{% elif failover.hba is defined and failover.hba %} + hba {{ failover.hba }}; +{% endif %} +{% else %} + secondary; +{% if failover.secondary_fo_addr is defined and failover.secondary_fo_addr %} + address {{ failover.secondary_fo_addr }}; +{% else %} + address {{ failover.secondary }}; +{% endif %} +{% if failover.primary_fo_addr is defined and failover.primary_fo_addr %} + peer address {{ failover.primary_fo_addr }}; +{% else %} + peer address {{ failover.primary }}; +{% endif %} +{% endif %} + max-response-delay {{ failover.max_response_delay|default(30) }}; + max-unacked-updates {{ failover.max_unacked_updates|default(10) }}; +{% if failover.load_balance_max_seconds is defined and failover.load_balance_max_seconds %} + load balance max seconds {{ failover.load_balance_max_seconds }}; +{% endif %} +{% if failover.max_lease_misbalance is defined and failover.max_lease_misbalance %} + max-lease-misbalance {{ failover.max_lease_misbalance }}; +{% endif %} +{% if failover.max_lease_ownership is defined and failover.max_lease_ownership %} + max-lease-ownership {{ failover.max_lease_ownership }}; +{% endif %} +{% if failover.min_balance is defined and failover.min_balance %} + min-balance {{ failover.min_balance }}; +{% endif %} +{% if failover.max_balance is defined and failover.max_balance %} + max-balance {{ failover.max_balance }}; +{% endif %} +{% if failover.auto_partner_down is defined and failover.auto_partner_down %} + auto-partner-down {{ failover.auto_partner_down }}; +{% endif %} +} +{% endmacro %} +{# +# +# ---- Macro: print.key() ---- +#} +{% macro key(key) %} +{% if key.comment is defined and key.comment %} +# {{ key.comment }} +{% endif %} +key {{ key.key }} { + algorithm {{ key.algorithm|default('hmac-md5') }}; + secret {{ key.secret }}; +} +{% endmacro %} +{# +# +# ---- Macro: print.zone() ---- +#} +{% macro zone(zone) %} +{% if zone.comment is defined and zone.comment %} +# {{ zone.comment }} +{% endif %} +zone {{ zone.zone }} { + primary {{ zone.primary }}; + key {{ zone.key }}; +} +{% endmacro %} diff --git a/templates/etc/dhcp_probe.cf.j2 b/templates/etc/dhcp_probe.cf.j2 new file mode 100644 index 0000000..802b770 --- /dev/null +++ b/templates/etc/dhcp_probe.cf.j2 @@ -0,0 +1,17 @@ +# {{ ansible_managed }} + +# Send mail messages about unauthorized DHCP servers +alert_program_name2 {{ ansible_local.root.lib + "/dhcp-probe/dhcp_probe_notify2" }} + +{% if dhcpd_probe_legal_servers|d() and dhcpd_probe_legal_servers %} +# Legal DHCP servers +{% for address in dhcpd_probe_legal_servers %} +legal_server {{ address }} +{% endfor %} + +{% endif %} +{% if dhcpd_probe_options|d() and dhcpd_probe_options %} +# Other options +{{ dhcpd_probe_options }} + +{% endif %} diff --git a/templates/usr/local/lib/dhcp-probe/dhcp_probe_notify2.j2 b/templates/usr/local/lib/dhcp-probe/dhcp_probe_notify2.j2 new file mode 100755 index 0000000..80acece --- /dev/null +++ b/templates/usr/local/lib/dhcp-probe/dhcp_probe_notify2.j2 @@ -0,0 +1,247 @@ +#!/usr/bin/perl -w + +# {{ ansible_managed }} + +# dhcp_probe_notify2 -p calling_prog_name -I interface_name -i IPaddress -m MACaddress [-y yiaddr] +# +# An external program called by dhcp_probe upon response of a response from an unexpected BootP/DHCP server. +# +# Called via specification of 'alert_program_name2' in /etc/dhcp_probe.cf file. +# This version obeys the syntax provided by the 'alert_program_name2' statement, +# not the syntax provided by the older 'alert_program_name' statement. +# +# Required options: +# -p calling_prog_name the name of the calling program (e.g. 'dhcp_probe') +# -I interface_name the name of the interface on which the unexpected response packet arrived (e.g. 'qfe0') +# -i IPaddress the IP source address of the unexpected response packet (e.g. '192.168.0.1') +# -m MACaddress the Ethernet source address of the unexpected response packet (e.g. '0:1:2:3:4:5') +# +# Optional options: +# -y yiaddr the response packet's non-zero yiaddr value, when it falls within a "Lease Network of Interest" (e.g. '172.16.1.2') +# +# May send email subject to throttling. +# May send page subject to throttling. +# +# You will need to edit the definitions below. +# +# Irwin Tillman + + +use Sys::Hostname; +use Sys::Syslog qw(:DEFAULT setlogsock); +use Time::HiRes qw(gettimeofday); # from CPAN +use Getopt::Std; +use strict; + +############################################################################# +# +# Definitions you may need to edit + +my $SYSLOG_FACILITY="daemon"; # name of facility to use if syslogging (e.g. 'daemon') +my $SYSLOG_OPT = 'pid,cons'; # comma-separated syslog options to use if syslogging (e.g. 'pid,cons') , ignored if not syslogging + +use vars qw($VERBOSE); +$VERBOSE = 1; # set to true to produce more verbose messages + +# We may send one piece of mail this way, subject to frequency throttling. +# Use this set of definitions for a piece of email that is delivered as regular email (not a page). +use vars qw($THROTTLE_MAIL_CMD $THROTTLE_MAIL_TIMEOUT $THROTTLE_MAIL_FROM $THROTTLE_MAIL_RECIPIENT $THROTTLE_MAIL_RECIPIENT_TEST $THROTTLE_MAIL_SUBJECT); +{% if dhcpd_probe_mail_to|d() and dhcpd_probe_mail_to %} +$THROTTLE_MAIL_CMD = "{{ ansible_local.root.lib + '/dhcp-probe/mail-throttled' }}"; # set to "" to disable +{% else %} +$THROTTLE_MAIL_CMD = ""; # set to "" to disable +{% endif %} +$THROTTLE_MAIL_TIMEOUT = {{ dhcpd_probe_mail_timeout | default('600') }}; # seconds +$THROTTLE_MAIL_FROM = "root"; # e.g. "root" +$THROTTLE_MAIL_RECIPIENT = "{{ dhcpd_probe_mail_to | join(' ') | replace('@','\@') }}"; # space-separated email addresses, remember to escape '@' characters +$THROTTLE_MAIL_SUBJECT = "Unexpected BOOTP/DHCP server"; + + +# We may also send another piece of email this way, subject to frequency throttling. +# Use this set of definitions for a piece of email that is delivered to a pager (not as regular email). +use vars qw($THROTTLE_PAGE_CMD $THROTTLE_PAGE_TIMEOUT $THROTTLE_PAGE_RECIPIENT); +{% if dhcpd_probe_page_to|d() and dhcpd_probe_page_to %} +$THROTTLE_PAGE_CMD = "{{ ansible_local.root.lib + '/dhcp-probe/mail-throttled' }}"; # set to "" to disable +{% else %} +$THROTTLE_PAGE_CMD = ""; # set to "" to disable +{% endif %} +$THROTTLE_PAGE_TIMEOUT = {{ dhcpd_probe_page_timeout | default('600') }}; # seconds +$THROTTLE_PAGE_RECIPIENT = "{{ dhcpd_probe_page_to | join(' ') | replace('@','\@') }}"; # space-separated email addresses, remember to escape '@' characters + + +# End of definitions you may need to edit +# +############################################################################# + +(my $prog = $0) =~ s/.*\///; + +# init our use of syslog +# setlogsock('unix'); # talk to syslog with UNIX domain socket, not INET domain. XXX causes failure in Solaris 7 +openlog($prog, $SYSLOG_OPT, $SYSLOG_FACILITY); + +############################################################################# +# +# Parse options and arguments + +# We must use getopt() instead of getopts() to avoid throwing an error +# if we are passed an unrecognized option. +# We must silently ignore unrecognized options to be forward compatible with enhancements to dhcp_probe. +use vars qw($opt_i $opt_I $opt_m $opt_p $opt_y); +# &getopt('p:i:I:m:y:'); +&getopt('piImy'); + +# Required options +my $calling_program = $opt_p; +my $ifname = $opt_I; +my $ip_src = $opt_i; +my $ether_src = $opt_m; +# +# Optional options +my $yiaddr = $opt_y || ""; + + +# Enforce presence of required options +unless ($calling_program) { + my_message('LOG_ERR', "${prog}: missing -p calling_program option"); + exit 100; +} +unless ($ifname) { + my_message('LOG_ERR', "${prog}: missing -I interface_name option"); + exit 101; +} +unless ($ip_src) { + my_message('LOG_ERR', "${prog}: missing -i ip_src_address option"); + exit 102; +} +unless ($ether_src) { + my_message('LOG_ERR', "${prog}: missing -m ether_src_address option"); + exit 103; +} + +# Done parsing options and arguments +# +############################################################################# + +# Miscellaneous Initialization + +my $hostname = hostname(); + +my ($seconds, $microseconds) = gettimeofday; # from Time::HiRes on CPAN +my $timestamp = scalar(localtime($seconds)); +$timestamp =~ s/(\d\d:\d\d:\d\d)/$1.$microseconds/; # glue microsends to end of seconds + + + +# When we pass a key to THROTTLE_MAIL_CMD, we want the key to also include an indication +# of whether the yiaddr option was specified. (If we didn't include such an indication, +# the following could happen: The first response we detect from a rogue server doesn't +# distribute an "address of concern", so the yiaddr option wasn't specified. We alert +# based on that. The next response we detect from a rogue server does distribute an +# "address of concern", so the yiaddr is specified. Our alert gets throttled since +# the first alert we sent was "recent". But this means the administrator doesn't get +# notified that the rogue distributed an "address of interest". So we need to +# include the state of yiaddr option in the key we pass to THROTTLE_MAIL_CMD, +# to ensure the second response isn't throttled using the same key as the first response.) +# +# We can't just use the value of yiaddr itself as the string to incorporate into the key, +# as that would result in a unqiue key for each distributed IP address. Instead, we +# will use the state of yiaddr (set or unset). +# +# Note this means that when a rogue DHCP server is distributing IP addresses that fall into "Networks of Concern", +# we will very likely send more than one notification for it within each throttle period. +# That's because while some of the responses from the rogue will have a yiaddr within a "Networks of Concern", +# others (for example, DHCPNAK responses) will not. This is unfortunate, but is better than the +# alternative approach (of not taking into account the yiaddr state in the key), since that will +# sometimes cause you to not be alerted at all to the "yiaddr falls into a Network of Concern" situation. +# +# Create a string based on the state of yiaddr, for later incorporation into the key. +my $yiaddr_option_state = $yiaddr ? "yiaddr=set" : "yiaddr=unset"; + +############################################################################# + +if ($THROTTLE_MAIL_CMD) { + # This command suppresses the message if it's sent a message to 'key' within 'throttle_seconds' + # I use the calling program's name and the offender's hardware address as the key. + + my $subject_yiaddr_addendum = ""; + $subject_yiaddr_addendum = ", YIADDR=$yiaddr" if $yiaddr; + + unless (open(THROTTLE_MAIL, "| $THROTTLE_MAIL_CMD -l -k ${calling_program}_mail_${ether_src}_$yiaddr_option_state -t $THROTTLE_MAIL_TIMEOUT -f \"$THROTTLE_MAIL_FROM\" -r \"$THROTTLE_MAIL_RECIPIENT\" -s\"$THROTTLE_MAIL_SUBJECT (MAC=${ether_src}, IP=${ip_src}${subject_yiaddr_addendum})\"")) { + + my_message('LOG_ERR', "${prog}: failure trying to send throttled email: can't execute '${THROTTLE_MAIL_CMD}': open(): $!"); + exit 20; + } + + print THROTTLE_MAIL + $timestamp, "\n", + "\n", + "$calling_program detected an unexpected BOOTP/DHCP server.\n", + "Host=$hostname, interface=${ifname}, IP source=${ip_src}, Ethernet source=${ether_src}\n"; + print THROTTLE_MAIL "\nThis means that *there is* a rogue BOOTP/DHCP server operating.\n" if $VERBOSE; + if ($yiaddr) { + print THROTTLE_MAIL + "The server distributed IP address $yiaddr, which falls into a network of special concern.\n"; + } + + unless (close(THROTTLE_MAIL)) { + my_message('LOG_ERR', + "${prog}: failure trying to send throttled email: error executing '${THROTTLE_MAIL_CMD}': close(): " . + ($! ? + "syserr closing pipe: $!" + : + "wait status $? from pipe" + ) . + "\n" + ); + exit 21; + + } +} + + + + +if ($THROTTLE_PAGE_CMD) { + # This command suppresses the message if it's sent a message to 'key' within 'throttle_seconds' + # I use the calling program's name and the offender's hardware address as the key. + + unless (open(THROTTLE_PAGE, "| $THROTTLE_PAGE_CMD -l -k ${calling_program}_page_${ether_src}_$yiaddr_option_state -t $THROTTLE_PAGE_TIMEOUT -r \"$THROTTLE_PAGE_RECIPIENT\"")) { + my_message('LOG_ERR', "${prog}: failure trying to send throttled page: can't execute '${THROTTLE_PAGE_CMD}': open(): $!\n"); + exit 30; + } + + print THROTTLE_PAGE "Rogue DHCP server IP=$ip_src MAC=$ether_src seen via host $hostname interface $ifname\n"; + print THROTTLE_PAGE "This means *there is* a rogue BOOTP/DHCP server operating.\n" if $VERBOSE; + if ($yiaddr) { + print THROTTLE_PAGE + "Rogue server distributed yiaddr=$yiaddr, a special concern.\n"; + } + + unless (close(THROTTLE_PAGE)) { + my_message('LOG_ERR', + "${prog}: failure trying to send throttled page: error executing '${THROTTLE_PAGE_CMD}': close(): " . + ($! ? + "syserr closing pipe: $!" + : + "wait status $? from pipe" + ) . + "\n" + ); + exit 31; + } + +} + +exit 0; + +############################################################################# + +sub my_message { + # Call with a syslog priority constant and a message string. + # We write the message to syslog, using the specified priority. + # Your message should not contain a newline. + + my($priority, $msg) = @_; + syslog($priority, $msg); + return; +} diff --git a/templates/usr/local/lib/dhcp-probe/mail-throttled.j2 b/templates/usr/local/lib/dhcp-probe/mail-throttled.j2 new file mode 100755 index 0000000..686643e --- /dev/null +++ b/templates/usr/local/lib/dhcp-probe/mail-throttled.j2 @@ -0,0 +1,173 @@ +#!/usr/bin/perl -w + +# {{ ansible_managed }} + +# $Header: /usr/local/etc/RCS/mail-throttled,v 1.11 2008/12/06 01:50:28 root Exp $ + +# mail-throttled [-l] [-D dbm_file] -k key -t throttle_seconds [-f from] -r recipient [-d] [-T tie_attempts_max] [-S tie_retry_sleep] [-s subject] +# +# Sends mail body (read from STDIN) to 'recipient', but avoids doing so "too frequently." +# +# You provide a 'key', which is an arbitrary string used to identify this notification. +# You also provide 'throttle_seconds', an integer. If we've sent anything that +# specified this 'key' within the last 'throttle_seconds', we do not send the message. +# Otherwise, we send the message, and the remember that we've sent a message for this 'key' +# at the current time. +# +# This key/timesent tuples are stored on-disk, in a dbm. As a result, the 'key' +# you supply must satisfy the syntactic requirements for dbm keys. +# The caller needs to have permission to read and write this DBM (and create it if +# it does not already exist). If you fail to specify a dbm_file, we'll use a default +# value, which may not be what you want (since the caller might not be able to r/w that +# particular DBM). +# We never clean this dbm. You can safely erase it entirely, if you don't mind losing +# the state, and you know the caller has permission to create a new instance of the DBM. +# +# The 'recipient' should be a valid email address. Naturally, it should not +# be one that will cause any ack or bounce mail to return to us! +# If there are several addresses (delimited by spaces), be sure to quote them as a single arg. +# +# If a subject is specified, be sure to quote it if it contains any spaces or other shell +# metachars. +# +# If -l is specified, then any errors, warnings, or debugging output is written to syslog +# in addition to its usual destination (STDERR). This is helpful if you call this from an +# environment where STDERR may get lost. +# +# Irwin Tillman + +use Getopt::Std; +use GDBM_File; +use Errno qw(EAGAIN); +use Sys::Syslog qw(:DEFAULT setlogsock); + +use strict; +use warnings; + +use vars qw($DBM_FILE_DEFAULT $MAILCMD $MAILCMD_OPTS $FROM_DEFAULT); +$DBM_FILE_DEFAULT = '{{ ansible_local.root.lib + "/dhcp-probe/mail-throttled.gdbm" }}'; +$MAILCMD = "/usr/lib/sendmail"; +# $MAILCMD_OPTS = "-t -ODeliveryMode=queueonly"; +$MAILCMD_OPTS = "-t"; +$FROM_DEFAULT = "root"; + +my $SYSLOG_FACILITY="daemon"; # name of facility to use if syslogging +my $SYSLOG_OPT = 'pid,cons'; # syslog options to use if syslogging, ignored otherwise +my $SYSLOG_PRIORITY = 'LOG_ERR'; + +# The tie() call sometimes fails with EAGAIN. +# Perhaps that's due to some other process having the DBM open; +# in fact, that may be more likely if the process that calls us may calls us multiple times in quick succession. +# So when tie() fails with EAGAIN, we can sleep and retry some number of times before giving up entirely. +my $TIE_ATTEMPTS_MAX = 3; # number of times to try tie() before giving up +my $TIE_RETRY_SLEEP = 1; # seconds to sleep before retrying tie() + +(my $prog = $0) =~ s/.*\///; + +use vars qw($opt_f $opt_D $opt_d $opt_k $opt_l $opt_r $opt_s $opt_S $opt_t $opt_T); +&getopts('dD:f:k:lr:s:S:t:T:'); + +my $debug = $opt_d || ""; +my $dbm_file = $opt_D || $DBM_FILE_DEFAULT; +my $key = $opt_k || ""; +my $from = $opt_f || $FROM_DEFAULT; +my $recipient = $opt_r || ""; +my $throttle_secs = $opt_t || 1; +my $subject = $opt_s || ""; +my $also_syslog = $opt_l || ""; +my $tie_attempts_max = $opt_T || $TIE_ATTEMPTS_MAX; # we deliberately override if CLI option specifies 0, as that makes no sense +my $tie_retry_sleep = $opt_S || $TIE_RETRY_SLEEP; + +if ($also_syslog) { + # init our use of syslog + # setlogsock('unix'); # talk to syslog with UNIX domain socket, not INET domain. XXX causes failure in Solaris 7 + openlog($prog, $SYSLOG_OPT, $SYSLOG_FACILITY); +} + +my_warn("${prog}:\nkey=$key\nthrottle_secs=$throttle_secs\nfrom=$from\nrecipient=$recipient\ntie_attempts_max=$tie_attempts_max\ntie_retry_sleep=$tie_retry_sleep\nsubject=$subject") if $debug; + +# certain options and args are required +&Usage() unless ($key && $throttle_secs && $recipient); + +my %last_sent = (); +my $tie_succeeded = 0; +my $tie_attempts_left = $TIE_ATTEMPTS_MAX; + +while ($tie_attempts_left--) { + if (tie(%last_sent, 'GDBM_File', $dbm_file, &GDBM_WRCREAT, 0644)) { + $tie_succeeded = 1; + last; + } + # the tie() failed + + if ($! == EAGAIN) { + # The failure may be due to a transient problem. + # Retrying may help. + sleep $TIE_RETRY_SLEEP; + next; + + } else { + # Some other (presumably more serious) error. + last; + } +} +unless ($tie_succeeded) { + my_warn("${prog}: can't tie ${dbm_file}: $!"); + exit 10; +} + +my @mailbody = ""; +@mailbody = ; # read it even if we decide not to send it + +my $now = time; + +my_warn("now = $now") if $debug; + +$last_sent{$key} = 0 unless defined($last_sent{$key}); # so it's defined before we use it in subtraction (placate use strict) + +if ($now - $last_sent{$key} >= $throttle_secs) { + my_warn("last_sent = $last_sent{$key}, will send") if $debug; + unless (open(MAIL, "| $MAILCMD $MAILCMD_OPTS -f\"$from\"")) { + my_warn("${prog}: error executing '${MAILCMD}': open(): $!"); + exit 20; + } + print MAIL "From: $from\n", + "To: $recipient\n", + ($subject ? "Subject: $subject\n" : "") , + "\n", + @mailbody; + unless (close(MAIL)) { + my_warn("${prog}: error executing '${MAILCMD}': close(): " . + ($! ? + "syserror closing pipe: $!" + : + "wait status $? from pipe" + ) + ); + exit 21; + } + + $last_sent{$key} = $now; +} else { + my_warn("last_sent = $last_sent{$key}, suppressing") if $debug; +} + +untie %last_sent; + +exit 0; + + + +sub Usage { + my_warn("Usage: $prog [-l] [-D dbm_file] -k key -t throttle_seconds [-f from] -r recipient [-T tie_attempts_max] [-S tie_retry_sleep] [-s subject]"); + exit 1; +} + + +sub my_warn { + # Just a wrapper for warn, but with a possible copy to syslog too. + my $msg = shift; + warn $msg, "\n"; + syslog($SYSLOG_PRIORITY, $msg) if $also_syslog; + return; +}