Improve/fix RFC3442 entries and add tests

- Generate the rfc3442 words using a filter plugin, instead of Jinja
- basic sanity test when running plugin standalone
- Improve testing by creating/binding to a bridge0 interface in
  molecule docker container, then verifying that it's running
- Add ntp option
- multiplatform support

Change-Id: I7c2c3081e8919174dd29b3ab2fdd27b4f6eb843a
diff --git a/Makefile b/Makefile
index b16ce20..9ce7e2e 100644
--- a/Makefile
+++ b/Makefile
@@ -8,10 +8,13 @@
 .DEFAULT_GOAL := help
 .PHONY: test lint yamllint ansiblelint license help
 
-test: ## run tests on the playbook with molecule
+test: filter_test ## run tests on the playbook with molecule
 	molecule --version
 	molecule test
 
+filter_test: ## test filter plugins
+	python filter_plugins/rfc3442_words.py
+
 lint: yamllint ansiblelint ## run all lint checks
 
 # all YAML files
diff --git a/README.md b/README.md
index 48aa29a..bacd957 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,8 @@
 
 - https://git.kernel.org/pub/scm/network/tftp/tftp-hpa.git
 
+Also supports OpenBSD dhcpd (fork of ISC) and tftpd (BSD).
+
 ## Reference docs
 
 DHCP:
@@ -69,6 +71,10 @@
     - dhcpd
 
 ```
+### Todo
+
+Add classless static route support for OpenBSD - see dhcp-options(5) on that
+system.
 
 ## License and Author
 
diff --git a/defaults/main.yml b/defaults/main.yml
index d0a5403..41d87ff 100644
--- a/defaults/main.yml
+++ b/defaults/main.yml
@@ -18,3 +18,7 @@
 ## tftpd config
 # files to copy to the TFTP server
 tftpd_files: []
+
+# Set this to listen on a specific non-default IP address. Only has effect on
+# OpenBSD TFTP server
+# tftpd_listen_ip: 10.0.0.1
diff --git a/filter_plugins/rfc3442_words.py b/filter_plugins/rfc3442_words.py
new file mode 100644
index 0000000..96756f5
--- /dev/null
+++ b/filter_plugins/rfc3442_words.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+
+# SPDX-FileCopyrightText: © 2021 Open Networking Foundation <support@opennetworking.org>
+# SPDX-License-Identifier: Apache-2.0
+
+# rfc3442_words.py
+# Input: a dict containing a router ip and multiple CIDR format routes
+# Output a list of octets to be appended to option 121 RFC3442 Classless routes option
+#
+# References:
+#  https://tools.ietf.org/html/rfc3442
+#  https://netaddr.readthedocs.io/en/latest/index.html
+
+from __future__ import absolute_import
+import netaddr
+
+
+class FilterModule(object):
+    def filters(self):
+        return {
+            "rfc3442_words": self.rfc3442_words,
+        }
+
+    def rfc3442_words(self, var):
+
+        words = []
+
+        router = var["ip"]
+        router_words = netaddr.IPNetwork(router).network.words
+
+        for r3442r in var["rfc3442routes"]:
+
+            # add prefix length
+            prefixlen = netaddr.IPNetwork(r3442r).prefixlen
+            words.append(prefixlen)
+
+            # add only the relevant portion of the address, depending ow words
+            (o1, o2, o3, o4) = netaddr.IPNetwork(r3442r).network.words
+            if prefixlen >= 25:
+                words += [o1, o2, o3, o4]
+            elif prefixlen >= 17:
+                words += [o1, o2, o3]
+            elif prefixlen >= 9:
+                words += [o1, o2]
+            elif prefixlen >= 1:
+                words += [o1]
+            # no additional words if prefixlen == 0
+
+            # add router address
+            words += list(router_words)
+
+        return words
+
+
+# test when running standalone outside of Ansible
+if __name__ == "__main__":
+
+    example = {
+        "ip": "192.168.1.10",
+        "rfc3442routes": [
+            "10.0.0.0/8",
+            "172.16.0.0/16",
+            "172.17.0.0/22",
+            "172.31.10.0/25",
+        ],
+    }
+
+    jfilter = FilterModule()
+
+    words = jfilter.rfc3442_words(example)
+
+    print(words)
+
+    # verify correct functionality
+    assert words == [
+        8, 10, 192, 168, 1, 10,
+        16, 172, 16, 192, 168, 1, 10,
+        22, 172, 17, 0, 192, 168, 1, 10,
+        25, 172, 31, 10, 0, 192, 168, 1, 10,
+    ]
diff --git a/handlers/main.yml b/handlers/main.yml
index 6fbce94..161f3e1 100644
--- a/handlers/main.yml
+++ b/handlers/main.yml
@@ -8,3 +8,8 @@
   service:
     name: "{{ dhcpd_service }}"
     state: restarted
+
+- name: tftpd-restart
+  service:
+    name: "{{ tftpd_service }}"
+    state: restarted
diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml
index b95c131..13c2065 100644
--- a/molecule/default/converge.yml
+++ b/molecule/default/converge.yml
@@ -6,6 +6,33 @@
 
 - name: Converge
   hosts: all
+  vars:
+    dhcpd_interfaces:
+      - bridge0
+    dhcpd_subnets:
+      - subnet: "192.168.0.1/24"
+        range: "192.168.0.128/25"
+        dns_servers:
+          - "192.168.0.1"
+          - "192.168.0.2"
+        dns_search:
+          - "example.com"
+        tftpd_server: "192.168.0.1"
+        hosts:
+          - name: "dns"
+            ip_addr: "192.168.0.2"
+            mac_addr: "a1:b2:c3:d4:e5:f6"
+          - name: "extra_router"
+            ip_addr: "192.168.0.10"
+            mac_addr: "a6:b5:c4:d3:e2:f1"
+        routers:
+          - ip: "192.168.0.1"
+          - ip: "192.168.0.10"
+            rfc3442routes:
+              - 10.0.0.0/8
+              - 172.16.0.0/16
+              - 192.168.10.0/25
+
   tasks:
     - name: "Include dhcpd"
       include_role:
diff --git a/molecule/default/prepare.yml b/molecule/default/prepare.yml
new file mode 100644
index 0000000..68ec77e
--- /dev/null
+++ b/molecule/default/prepare.yml
@@ -0,0 +1,22 @@
+---
+# dhcpd molecule/default/prepare.yml
+#
+# SPDX-FileCopyrightText: © 2020 Open Networking Foundation <support@opennetworking.org>
+# SPDX-License-Identifier: Apache-2.0
+
+- name: Prepare
+  hosts: all
+
+  tasks:
+    - name: Update apt cache
+      apt:
+        update_cache: true
+
+    - name: Create a bridge to nowhere so dhcpd can start during testing
+      when: "'bridge0' not in ansible_interfaces"
+      command:
+        cmd: "{{ item }}"
+      with_items:
+        - "ip link add bridge0 type bridge"
+        - "ip addr add 192.168.0.5/24 dev bridge0"
+        - "ip link set bridge0 up"
diff --git a/molecule/default/verify.yml b/molecule/default/verify.yml
index 1a4bfdc..aedd158 100644
--- a/molecule/default/verify.yml
+++ b/molecule/default/verify.yml
@@ -6,7 +6,12 @@
 
 - name: Verify
   hosts: all
+
   tasks:
-  - name: example assertion
+
+  - name: Populate service facts
+    service_facts:
+
+  - name: isc-dhcp-server is running
     assert:
-      that: true
+      that: ansible_facts.services["isc-dhcp-server.service"]["state"] == "running"
diff --git a/tasks/OpenBSD.yml b/tasks/OpenBSD.yml
new file mode 100644
index 0000000..7b33aa0
--- /dev/null
+++ b/tasks/OpenBSD.yml
@@ -0,0 +1,14 @@
+---
+# dhcpd tasks/OpenBSD.yml
+#
+# SPDX-FileCopyrightText: © 2020 Open Networking Foundation <support@opennetworking.org>
+# SPDX-License-Identifier: Apache-2.0
+
+# installed in base, but need config
+
+- name: Set dhcpd and tftpd arguments for use with service module
+  set_fact:
+    dhcpd_arguments: "{{ dhcpd_interfaces | join(' ') }}"
+    tftpd_arguments: >
+      -v -l {{ tftpd_listen_ip | default(ansible_default_ipv4["address"]) }}
+      {{ tftpd_boot_dir }}
diff --git a/tasks/main.yml b/tasks/main.yml
index a030609..80927ec 100644
--- a/tasks/main.yml
+++ b/tasks/main.yml
@@ -17,7 +17,7 @@
     backup: true
     mode: "0644"
     owner: root
-    group: root
+    group: "{{ dhcpd_groupname }}"
     # validate: 'dhcpd -t -cf %s' # Does not work...
   notify:
     - dhcpd-restart
@@ -36,9 +36,11 @@
     name: "{{ dhcpd_service }}"
     enabled: true
     state: started
+    arguments: "{{ dhcpd_arguments | default(omit) }}"
 
 - name: Enable and start tftpd
   service:
     name: "{{ tftpd_service }}"
     enabled: true
     state: started
+    arguments: "{{ tftpd_arguments | default(omit) }}"
diff --git a/templates/dhcpd.conf.j2 b/templates/dhcpd.conf.j2
index bda9bec..3951fe5 100644
--- a/templates/dhcpd.conf.j2
+++ b/templates/dhcpd.conf.j2
@@ -9,7 +9,9 @@
 max-lease-time {{ subnet.max_lease_time | default("480") }};
 
 # option definitions
+{% if ansible_system == "Linux" %}
 option rfc3442-classless-static-routes code 121 = array of integer 8;
+{% endif %}
 
 {% for subnet in dhcpd_subnets %}
 subnet {{ subnet.subnet | ipaddr('network') }} netmask {{ subnet.subnet | ipaddr('netmask') }} {
@@ -18,13 +20,15 @@
 {% if subnet.routers is defined %}
   # custom router IP set
   option routers {{ subnet.routers | map(attribute="ip") | join (",") }};
+{% set r3442ns = namespace(r3442list = []) %}
 {% for rtr in subnet.routers %}
 {% if "rfc3442routes" in rtr %}
-{% for r3442r in rtr.rfc3442routes %}
-  option rfc3442-classless-static-routes {{ r3442r | ipaddr('prefix') }}, {{ r3442r | ipaddr('network') | regex_replace('\.', ', ')}}, {{ rtr.ip | ipaddr('network') | regex_replace('\.', ', ')}};
-{% endfor %}
+{% set r3442ns.r3442list = r3442ns.r3442list + (rtr | rfc3442_words() ) %}
 {% endif %}
 {% endfor %}
+{% if r3442ns.r3442list %}
+  option rfc3442-classless-static-routes {{ r3442ns.r3442list | join(', ') }};
+{% endif %}
 {% else %}
   # first IP address in range used as router
   option routers {{ subnet.subnet | ipaddr('next_usable') }};
@@ -41,7 +45,13 @@
   next-server {{ subnet.tftpd_server }};
 
 {% endif %}
+{% if subnet.ntp_servers is defined %}
+  # ntp options
+  option ntp-servers {{ subnet.ntp_servers | join('", "') }};
+
+{% endif %}
 {% if subnet.range is defined %}
+  # dynamically assignable range
   range {{ subnet.range | ipaddr('next_usable') }} {{ subnet.range | ipaddr('last_usable') }};
 {% endif %}
 }
diff --git a/vars/Debian.yml b/vars/Debian.yml
index 5a1a5f8..01b6dc6 100644
--- a/vars/Debian.yml
+++ b/vars/Debian.yml
@@ -9,6 +9,7 @@
 
 dhcpd_service: "isc-dhcp-server"
 dhcpd_config_dir: "/etc/dhcp"
+dhcpd_groupname: "root"
 
 tftpd_service: "tftpd-hpa"
 tftpd_groupname: "tftp"
diff --git a/vars/OpenBSD.yml b/vars/OpenBSD.yml
new file mode 100644
index 0000000..d1ffefc
--- /dev/null
+++ b/vars/OpenBSD.yml
@@ -0,0 +1,16 @@
+---
+# dhcpd vars/OpenBSD.yml
+#
+# SPDX-FileCopyrightText: © 2020 Open Networking Foundation <support@opennetworking.org>
+# SPDX-License-Identifier: Apache-2.0
+#
+# NOTE: Only put platform/OS-specific variables in this file.
+# Put all other variables in the 'defaults/main.yml' file.
+
+dhcpd_service: "dhcpd"
+dhcpd_config_dir: "/etc"
+dhcpd_groupname: "wheel"
+
+tftpd_service: "tftpd"
+tftpd_groupname: "_tftpd"
+tftpd_boot_dir: "/var/tftpboot"