Skip to content

Commit

Permalink
Merge pull request #135 from woelfle/manage-dhcp-subnets
Browse files Browse the repository at this point in the history
add module to manage Kea DHCP subnets
  • Loading branch information
ansibleguy authored Feb 7, 2025
2 parents ea48c53 + 0894a23 commit 42c8171
Show file tree
Hide file tree
Showing 11 changed files with 477 additions and 6 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ not implemented => development => [testing](https://github.com/ansibleguy/collec
| **Nginx** | ansibleguy.opnsense.nginx_upstream_server | [Docs](https://opnsense.ansibleguy.net/modules/nginx.html#ansibleguy-opnsense-nginx-upstream-server) | unstable |
| **DHCP Relay** | ansibleguy.opnsense.dhcrelay | [Docs](https://opnsense.ansibleguy.net/modules/dhcrelay_relay.html) | unstable |
| **DHCP Relay** | ansibleguy.opnsense.dhcrelay_destination | [Docs](https://opnsense.ansibleguy.net/modules/dhcrelay_destination.html) | unstable |
| **DHCP Subnet** | ansibleguy.opnsense.dhcp_subnet | [Docs](https://opnsense.ansibleguy.net/modules/dhcp.html) | unstable |
| **DHCP Reservation** | ansibleguy.opnsense.dhcp_reservation | [Docs](https://opnsense.ansibleguy.net/modules/dhcp.html) | unstable |
| **DHCP Controlagent** | ansibleguy.opnsense.dhcp_controlagent | [Docs](https://opnsense.ansibleguy.net/modules/dhcp.html) | unstable |
| **ACME (Certificates)** | ansibleguy.opnsense.acme_account | [Docs](https://opnsense.ansibleguy.net/modules/acmeclient.html) | unstable |
Expand Down
82 changes: 80 additions & 2 deletions docs/source/modules/dhcp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ DHCP
**STATE**: unstable

**TESTS**: `Reservation <https://github.com/ansibleguy/collection_opnsense/blob/latest/tests/dhcp_reservation.yml>`_ |
`ControlAgent <https://github.com/ansibleguy/collection_opnsense/blob/latest/tests/dhcp_controlagent.yml>`_
`ControlAgent <https://github.com/ansibleguy/collection_opnsense/blob/latest/tests/dhcp_controlagent.yml>`_ |
`Subnet <https://github.com/ansibleguy/collection_opnsense/blob/latest/tests/dhcp_subnet.yml>`_

**API Docs**: `Core - KEA <https://docs.opnsense.org/development/api/core/kea.html>`_

Expand All @@ -18,7 +19,8 @@ DHCP
Contribution
************

Thanks to `@KalleDK <https://github.com/KalleDK>`_ for developing these modules!
Thanks to `@KalleDK <https://github.com/KalleDK>`_, `@superstes <https://github.com/superstes>`_ and `@woelfle <https://github.com/woelfle>`_ for developing these modules!


----

Expand Down Expand Up @@ -53,6 +55,29 @@ ansibleguy.opnsense.dhcp_controlagent
"http_port","int","false","8000","","MAC/Ether address of the client in question"
"reload","boolean","false","true","\-", .. include:: ../_include/param_reload.rst

ansibleguy.opnsense.dhcp_subnet
===============================

.. csv-table:: Definition
:header: "Parameter", "Type", "Required", "Default", "Aliases", "Comment"
:widths: 15 10 10 10 10 45

"subnet","string","true","\-","\-","Subnet to use. should be large enough to hold the specified pools and reservations"
"description","string","false","\-","desc","Optional description of the subnet"
"pools","list","false","\-","\-","List of pools, one per line in range or subnet format (e.g. 192.168.0.100 - 192.168.0.200)"
"auto_options","boolean,"false","true","option_data_autocollect","Automatically update option data for relevant attributes as routers, dns servers and ntp servers when applying settings from the gui."
"gateway","list","false","\-","gw,routers","Default gateways to offer to the clients"
"routes","string","false","\-","static_routes","Static routes that the client should install in its routing cache, defined as dest-ip1,router-ip1;dest-ip2,router-ip2"
"dns","list","false","\-","dns_servers,dns_srv","DNS servers to offer to the clients"
"domain","string","false","\-","domain_name,dom_name,dom","The domain name to offer to the client, set to this firewall's domain name when left empty"
"domain_search","list","false","\-","dom_search","Specifies a 'search list' of Domain Names to be used by the client to locate not-fully-qualified domain names."
"ntp_servers","list","false","\-","ntp_srv,ntp","Specifies a list of IP addresses indicating NTP (RFC 5905) servers available to the client."
"time_servers","list","false","\-","time_srv","Specifies a list of RFC 868 time servers available to the client."
"next_server","string","false","\-","next_srv","Next server IP address"
"tftp_server","string","false","\-","tftp,tftp_srv,tftp_server_name","TFTP server address or fqdn"
"tftp_file","string","false","\-","tftp_boot_file,boot_file_name","TFTP Boot filename to request"
"ipv","int","false","4","ip_version","IP version - one of '4', '6'"

----

Examples
Expand Down Expand Up @@ -132,3 +157,56 @@ ansibleguy.opnsense.dhcp_controlagent
ansibleguy.opnsense.dhcp_controlagent:
enabled: false
reload: true
----

ansibleguy.opnsense.dhcp_subnet
===============================

.. code-block:: yaml
- host: localhost
gather_facts: no
module_defaults:
group/ansibleguy.opnsense.all:
firewall: 'opnsense.template.ansibleguy.net'
api_credential_file: '/home/guy/.secret/opn.key'
ansibleguy.opnsense.list:
target: 'dhcp_reservation'
tasks:
- name: Example
ansibleguy.opnsense.dhcp_subnet:
subnet: '192.168.89.0/24'
# description: ''
# pools: []
# auto_options: true
# gateway: ''
# routes: ''
# dns: []
# domain: ''
# domain_search: []
# ntp_servers: []
# time_servers: []
# next_server: ''
# tftp_server: ''
# tftp_file: ''
# ipv: 4
# match_fields: ['subnet']
- name: Add subnet
ansibleguy.opnsense.dhcp_subnet:
subnet: '10.0.100.0/24'
pools:
- '10.0.100.1-10.0.100.99'
- '10.0.100.150-10.0.100.199'
auto_options: false
gateway: '192.168.89.254'
dns: ['1.1.1.2', '1.0.0.2']
domain: 'test.lan'
- name: Remove subnet
ansibleguy.opnsense.dhcp_subnet:
subnet: '10.0.100.0/24'
state: absent
1 change: 1 addition & 0 deletions meta/runtime.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ action_groups:
dhcp:
- ansibleguy.opnsense.dhcp_reservation
- ansibleguy.opnsense.dhcp_controlagent
- ansibleguy.opnsense.dhcp_subnet
acme:
- ansibleguy.opnsense.acme_general
- ansibleguy.opnsense.acme_account
Expand Down
3 changes: 3 additions & 0 deletions plugins/module_utils/helper/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,9 @@ def simplify_translate(
# correct value types to match (for diff-checks)
for t, fields in typing.items():
for f in fields:
if f in ignore:
continue

if t == 'bool':
simple[f] = is_true(simple[f])

Expand Down
105 changes: 105 additions & 0 deletions plugins/module_utils/main/dhcp_subnet_v4.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from ansible.module_utils.basic import AnsibleModule

from ansible_collections.ansibleguy.opnsense.plugins.module_utils.base.api import \
Session
from ansible_collections.ansibleguy.opnsense.plugins.module_utils.helper.main import \
get_selected_list, simplify_translate
from ansible_collections.ansibleguy.opnsense.plugins.module_utils.base.cls import BaseModule


class SubnetV4(BaseModule):
CMDS = {
'add': 'addSubnet',
'del': 'delSubnet',
'set': 'setSubnet',
'search': 'searchSubnet',
'detail': 'getSubnet',
}
API_KEY = 'subnet4'
API_KEY_PATH = 'subnet4'
API_MOD = 'kea'
API_CONT = 'dhcpv4'
API_CONT_REL = 'service'
FIELDS_CHANGE = [
'subnet', 'description', 'pools', 'auto_options',
]
FIELDS_ALL = FIELDS_CHANGE
FIELDS_TYPING = {
'list': ['gateway', 'dns', 'domain_search', 'ntp_servers', 'time_servers'], # 'pools',
'bool': ['auto_options'],
}
FIELDS_TRANSLATE = {
'auto_options': 'option_data_autocollect',
}
API_ATTR_OPTIONS = 'option_data'
API_FIELDS_OPTIONS = [
'gateway', 'routes', 'dns', 'domain', 'domain_search', 'ntp_servers', 'time_servers',
'next_server', 'tftp_server', 'tftp_file',
]
POOL_JOIN_CHAR = '\n'
FIELDS_TRANSLATE_SPECIAL = {
'dns': 'domain_name_servers',
'domain': 'domain_name',
'gateway': 'routers',
'routes': 'static_routes',
'tftp_server': 'tftp_server_name',
'tftp_file': 'boot_file_name',
}
EXIST_ATTR = 'subnet'

def __init__(self, module: AnsibleModule, result: dict, session: Session = None):
BaseModule.__init__(self=self, m=module, r=result, s=session)
self.subnet = {}
self.existing_subnets = None

def _simplify_existing(self, entry: dict) -> dict:
simple = simplify_translate(
existing=entry,
typing=self.FIELDS_TYPING,
translate=self.FIELDS_TRANSLATE,
ignore=self.API_FIELDS_OPTIONS,
)

simple['pools'] = simple['pools'].split(self.POOL_JOIN_CHAR)
opts = entry[self.API_ATTR_OPTIONS]
if isinstance(opts, dict):
simple['dns'] = get_selected_list(opts[self.FIELDS_TRANSLATE_SPECIAL['dns']])
simple['domain_search'] = get_selected_list(opts['domain_search'])
simple['gateway'] = get_selected_list(opts[self.FIELDS_TRANSLATE_SPECIAL['gateway']])
simple['routes'] = opts[self.FIELDS_TRANSLATE_SPECIAL['routes']]
simple['domain'] = opts[self.FIELDS_TRANSLATE_SPECIAL['domain']]
simple['ntp_servers'] = get_selected_list(opts['ntp_servers'])
simple['time_servers'] = get_selected_list(opts['time_servers'])
simple['tftp_server'] = opts[self.FIELDS_TRANSLATE_SPECIAL['tftp_server']]
simple['tftp_file'] = opts[self.FIELDS_TRANSLATE_SPECIAL['tftp_file']]

else:
opt_keys = list(self.FIELDS_TRANSLATE_SPECIAL.keys())
opt_keys.extend(['domain_search', 'ntp_servers', 'time_servers'])

for opt in opt_keys:
if opt in self.FIELDS_TYPING['list']:
simple[opt] = []

else:
simple[opt] = ''

return simple

def _build_request(self) -> dict:
raw_request = self.b.build_request(ignore_fields=self.API_FIELDS_OPTIONS)

raw_request[self.API_KEY]['pools'] = self.POOL_JOIN_CHAR.join(self.p['pools'])
raw_request[self.API_KEY][self.API_ATTR_OPTIONS] = {
self.FIELDS_TRANSLATE_SPECIAL['dns']: self.b.RESP_JOIN_CHAR.join(self.p['dns']),
self.FIELDS_TRANSLATE_SPECIAL['gateway']: self.b.RESP_JOIN_CHAR.join(self.p['gateway']),
self.FIELDS_TRANSLATE_SPECIAL['routes']: self.p['routes'],
self.FIELDS_TRANSLATE_SPECIAL['domain']: self.p['domain'],
self.FIELDS_TRANSLATE_SPECIAL['tftp_server']: self.p['tftp_server'],
self.FIELDS_TRANSLATE_SPECIAL['tftp_file']: self.p['tftp_file'],
'ntp_servers': self.b.RESP_JOIN_CHAR.join(self.p['ntp_servers']),
'time_servers': self.b.RESP_JOIN_CHAR.join(self.p['time_servers']),
'domain_search': self.b.RESP_JOIN_CHAR.join(self.p['domain_search']),
}

return raw_request
127 changes: 127 additions & 0 deletions plugins/modules/dhcp_subnet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright: (C) 2024, AnsibleGuy <[email protected]>
# GNU General Public License v3.0+ (see https://www.gnu.org/licenses/gpl-3.0.txt)

# see: https://docs.opnsense.org/development/api/core/kea.html

from ansible.module_utils.basic import AnsibleModule

from ansible_collections.ansibleguy.opnsense.plugins.module_utils.base.handler import \
module_dependency_error, MODULE_EXCEPTIONS

try:
from ansible_collections.ansibleguy.opnsense.plugins.module_utils.helper.wrapper import module_wrapper
from ansible_collections.ansibleguy.opnsense.plugins.module_utils.defaults.main import \
OPN_MOD_ARGS, STATE_MOD_ARG, RELOAD_MOD_ARG
from ansible_collections.ansibleguy.opnsense.plugins.module_utils.main.dhcp_subnet_v4 import SubnetV4

except MODULE_EXCEPTIONS:
module_dependency_error()


# DOCUMENTATION = 'https://opnsense.ansibleguy.net/modules/dhcp.html'
# EXAMPLES = 'https://opnsense.ansibleguy.net/modules/dhcp.html'


def run_module():
module_args = dict(
subnet=dict(
type='str', required=True,
description='Subnet to use, should be large enough to hold the specified pools and reservations',
),
description=dict(
type='str', required=False, aliases=['desc'], default='',
),
pools=dict(
type='list', elements='str', required=False, default=[],
description='List of pools, one per line in range or subnet format '
'(e.g. 192.168.0.100 - 192.168.0.200 , 192.0.2.64/26)'
),
auto_options=dict(
type='bool', required=False, default=True, aliases=['option_data_autocollect'],
description='Automatically update option data for relevant attributes as routers, '
'dns servers and ntp servers when applying settings from the gui.'
),
gateway=dict(
type='list', elements='str', required=False, aliases=['gw', 'routers'], default=[],
description='Default gateways to offer to the clients',
),
routes=dict(
type='str', required=False, aliases=['static_routes'], default='',
description='Static routes that the client should install in its routing cache, '
'defined as dest-ip1,router-ip1;dest-ip2,router-ip2',
),
dns=dict(
type='list', elements='str', required=False, aliases=['dns_servers', 'dns_srv'], default=[],
description='DNS servers to offer to the clients',
),
domain=dict(
type='str', required=False, aliases=['domain_name', 'dom_name', 'dom'], default='',
description="The domain name to offer to the client, set to this firewall's domain name when left empty",
),
domain_search=dict(
type='list', elements='str', required=False, aliases=['dom_search'], default=[],
description="Specifies a ´search list´ of Domain Names to be used by the client to locate "
'not-fully-qualified domain names.',
),
ntp_servers=dict(
type='list', elements='str', required=False, aliases=['ntp_srv', 'ntp'], default=[],
description='Specifies a list of IP addresses indicating NTP (RFC 5905) servers available to the client.',
),
time_servers=dict(
type='list', elements='str', required=False, aliases=['time_srv'], default=[],
description='Specifies a list of RFC 868 time servers available to the client.',
),
next_server=dict(
type='str', required=False, aliases=['next_srv'], default='',
description='Next server IP address',
),
tftp_server=dict(
type='str', required=False, aliases=['tftp', 'tftp_srv', 'tftp_server_name'], default='',
description='TFTP server address or fqdn',
),
tftp_file=dict(
type='str', required=False, aliases=['tftp_boot_file', 'boot_file_name'], default='',
description='TFTP Boot filename to request',
),
ipv=dict(type='int', required=False, default=4, choices=[4, 6], aliases=['ip_version']),
match_fields=dict(
type='list', required=False, elements='str',
description='Fields that are used to match configured interface with the running config - '
"if any of those fields are changed, the module will think it's a new entry",
choices=['subnet', 'description'],
default=['subnet'],
),
**RELOAD_MOD_ARG,
**STATE_MOD_ARG,
**OPN_MOD_ARGS,
)

result = dict(
changed=False,
diff={
'before': {},
'after': {},
}
)

module = AnsibleModule(
argument_spec=module_args,
supports_check_mode=True,
)

if module.params['ipv'] == 6:
module.fail_json('DHCPv6 is not yet supported!')

module_wrapper(SubnetV4(module=module, result=result))
module.exit_json(**result)


def main():
run_module()


if __name__ == '__main__':
main()
8 changes: 6 additions & 2 deletions plugins/modules/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
'ipsec_child', 'ipsec_vti', 'ipsec_auth_local', 'ipsec_auth_remote', 'frr_general', 'unbound_general',
'unbound_acl', 'ids_general', 'ids_policy', 'ids_rule', 'ids_ruleset', 'ids_user_rule', 'ids_policy_rule',
'openvpn_instance', 'openvpn_static_key', 'openvpn_client_override', 'dhcrelay_destination', 'dhcrelay_relay',
'interface_lagg', 'interface_loopback', 'unbound_dnsbl', 'dhcp_reservation', 'acme_general', 'acme_account',
'acme_validation', 'acme_action', 'acme_certificate',
'interface_lagg', 'interface_loopback', 'unbound_dnsbl', 'dhcp_reservation', 'dhcp_subnet', 'acme_general',
'acme_account', 'acme_validation', 'acme_action', 'acme_certificate',
]


Expand Down Expand Up @@ -419,6 +419,10 @@ def run_module():
from ansible_collections.ansibleguy.opnsense.plugins.module_utils.main.acme_certificate import \
Certificate as Target_Obj

elif target == 'dhcp_subnet':
from ansible_collections.ansibleguy.opnsense.plugins.module_utils.main.dhcp_subnet_v4 import \
SubnetV4 as Target_Obj

except AttributeError:
module_dependency_error()

Expand Down
1 change: 1 addition & 0 deletions scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ run_test 'nginx_upstream_server' 1
run_test 'dhcrelay_destination' 1
run_test 'dhcrelay_relay' 1
run_test 'dhcp_controlagent' 1
run_test 'dhcp_subnet' 1
run_test 'dhcp_reservation' 1
run_test 'acme_general' 1
run_test 'acme_account' 1
Expand Down
Loading

0 comments on commit 42c8171

Please sign in to comment.