Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add module to manage Kea DHCP subnets #135

Open
wants to merge 4 commits into
base: latest
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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='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 @@ -154,6 +154,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 'system' 1
run_test 'acme_general' 1
Expand Down
9 changes: 9 additions & 0 deletions tests/1_cleanup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -717,3 +717,12 @@
- 'ANSIBLE_TEST_1_9'
- 'ANSIBLE_TEST_1_10'
- 'ANSIBLE_TEST_DUMMY_1_1'

- name: Cleanup DHCP Subnets
ansibleguy.opnsense.dhcp_subnet:
subnet: "{{ item }}"
state: 'absent'
loop:
- '192.168.69.0/24'
- '192.168.88.0/24'
- '192.168.89.0/24'
13 changes: 11 additions & 2 deletions tests/dhcp_reservation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@
target: 'dhcp_reservation'

tasks:
# todo: add subnet via module

- name: Listing
ansibleguy.opnsense.list:
register: opn_pre1
Expand Down Expand Up @@ -61,6 +59,11 @@
failed_when: not opn_fail4.failed
when: not ansible_check_mode

- name: Adding dummy DHCP-subnet
ansibleguy.opnsense.dhcp_subnet:
subnet: '192.168.69.0/24'
check_mode: false

- name: Adding 1
ansibleguy.opnsense.dhcp_reservation:
subnet: '192.168.69.0/24'
Expand Down Expand Up @@ -139,6 +142,12 @@
- '192.168.69.86'
when: not ansible_check_mode

- name: Cleanup dummy DHCP-subnet
ansibleguy.opnsense.dhcp_subnet:
subnet: '192.168.69.0/24'
state: 'absent'
check_mode: false

- name: Listing
ansibleguy.opnsense.list:
register: opn_clean1
Expand Down
Loading
Loading