Skip to content

Commit 730d2b7

Browse files
committed
kea_command: new module to access an ISC KEA server
This module can be used to access the JSON API of a KEA DHCP4, DHCP6, DDNS or other services in a generic way, without having to manually format the JSON, with response error code checking. It directly accesses the Unix Domain Socket API so it needs to execute on the system the server is running, with superuser privilegues, but without the hassle of wrapping it into HTTPS and password auth (or client certificates). The integration test uses a predefined setup for convenience.
1 parent 5d53927 commit 730d2b7

File tree

4 files changed

+387
-0
lines changed

4 files changed

+387
-0
lines changed

.github/BOTMETA.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,8 @@ files:
807807
maintainers: Slezhuk pertoft
808808
$modules/kdeconfig.py:
809809
maintainers: smeso
810+
$modules/kea_command.py:
811+
maintainers: mirabilos
810812
$modules/kernel_blacklist.py:
811813
maintainers: matze
812814
$modules/keycloak_:

plugins/modules/kea_command.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
#!/usr/bin/python
2+
# -*- coding: utf-8 -*-
3+
# SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
# Copyright © Thorsten Glaser <[email protected]>
6+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
7+
8+
9+
DOCUMENTATION = r"""
10+
---
11+
module: kea_command
12+
short_description: Submits generic command to ISC KEA server on target
13+
description:
14+
- Submits a command to the JSON API of an ISC KEA server running on the target and obtains the result.
15+
- This module supports sending arbitrary commands and returns the server response unchecked;
16+
while it would be possible to write individual modules for specific KEA service commands,
17+
that approach would not scale, as the FOSS hooks alone provide dozens of commands.
18+
- Between sending the command and parsing the result status, RV(ignore:changed) will register as V(true) if an error occurs,
19+
to err on the safe side.
20+
version_added: '11.3.0'
21+
author: Thorsten Glaser (@mirabilos)
22+
options:
23+
command:
24+
description:
25+
- The name of the command to send, for example V(status-get).
26+
required: true
27+
type: str
28+
arguments:
29+
description:
30+
- The arguments sent along with the command, if any.
31+
- Use V({}) to send an empty arguments dict/object instead of omitting it.
32+
type: dict
33+
rv_unchanged:
34+
description:
35+
- A list of C(result) codes to indicate success but unchanged system state.
36+
- Set this to V([0]) for most acquisition commands.
37+
- Use V([3]) for O(command=lease4-del) and similar which have a separate code for this.
38+
- Any C(result) codes not listed in either O(rv_unchanged) or O(rv_changed) are interpreted as indicating an error result.
39+
- O(rv_unchanged) has precedence over O(rv_changed) if a result code is in both lists.
40+
type: list
41+
elements: int
42+
default: []
43+
rv_changed:
44+
description:
45+
- A list of C(result) codes to indicate success and changed system state.
46+
- Omit this for most acquisition commands.
47+
- Set it to V([0]) for O(command=lease4-del) and similar which return changed system state that way.
48+
- Any C(result) codes not listed in either O(rv_unchanged) or O(rv_changed) are interpreted as indicating an error result.
49+
- O(rv_unchanged) has precedence over O(rv_changed) if a result code is in both lists.
50+
type: list
51+
elements: int
52+
default: []
53+
socket:
54+
description:
55+
- The full pathname of the Unix Domain Socket to connect to.
56+
- The default value is suitable for C(kea-dhcp4-server) on Debian trixie.
57+
- This module directly interfacees via UDS; the HTTP wrappers are not supported.
58+
type: path
59+
default: /run/kea/kea4-ctrl-socket
60+
extends_documentation_fragment:
61+
- community.general.attributes
62+
- community.general.attributes.platform
63+
attributes:
64+
check_mode:
65+
support: none
66+
diff_mode:
67+
support: none
68+
platform:
69+
support: full
70+
platforms: posix
71+
"""
72+
73+
EXAMPLES = r"""
74+
vars:
75+
ipaddr: "192.168.123.45"
76+
hwaddr: "00:00:5E:00:53:00"
77+
tasks:
78+
79+
# an example for a request acquiring information
80+
- name: Get KEA DHCP6 status
81+
kea_command:
82+
command: status-get
83+
rv_unchanged: [0]
84+
socket: /run/kea/kea6-ctrl-socket
85+
register: kea6_status
86+
- name: Display registered status result
87+
ansible.builtin.debug:
88+
msg: KEA DHCP6 running on PID {{ kea6_status.response.arguments.pid }}
89+
90+
# an example for requests modifying state
91+
- name: Remove existing leases for {{ ipaddr }}, if any
92+
kea_command:
93+
command: lease4-del
94+
arguments:
95+
ip-address: "{{ ipaddr }}"
96+
rv_changed: [0]
97+
rv_unchanged: [3]
98+
- name: Add DHCP lease for {{ ipaddr }}
99+
kea_command:
100+
command: lease4-add
101+
arguments:
102+
ip-address: "{{ ipaddr }}"
103+
hw-address: "{{ hwaddr }}"
104+
rv_changed: [0]
105+
"""
106+
107+
RETURN = r"""
108+
response:
109+
description: The server JSON response.
110+
returned: when available
111+
type: dict
112+
"""
113+
114+
import json
115+
import os
116+
import socket
117+
import traceback
118+
119+
from ansible.module_utils.basic import AnsibleModule
120+
121+
122+
# default buffer size for socket I/O
123+
BUFSIZ = 8192
124+
125+
126+
def _parse_constant(s):
127+
raise ValueError('Invalid JSON: "%s"' % s)
128+
129+
130+
def main():
131+
module = AnsibleModule(
132+
argument_spec=dict(
133+
command=dict(type='str', required=True),
134+
arguments=dict(type='dict'),
135+
rv_unchanged=dict(type='list', elements='int', default=[]),
136+
rv_changed=dict(type='list', elements='int', default=[]),
137+
socket=dict(type='path', default='/run/kea/kea4-ctrl-socket'),
138+
),
139+
)
140+
141+
cmd = {}
142+
cmd['command'] = module.params['command']
143+
if module.params['arguments'] is not None:
144+
cmd['arguments'] = module.params['arguments']
145+
cmdstr = json.dumps(cmd, ensure_ascii=True, allow_nan=False, indent=None, separators=(',', ':'), sort_keys=True)
146+
rvok = module.params['rv_unchanged']
147+
rvch = module.params['rv_changed']
148+
sockfn = module.params['socket']
149+
150+
r = {
151+
'changed': False
152+
}
153+
rsp = b''
154+
155+
if not os.path.exists(sockfn):
156+
r['msg'] = 'socket (%s) does not exist' % sockfn
157+
module.fail_json(**r)
158+
159+
phase = 'opening'
160+
try:
161+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
162+
phase = 'connecting'
163+
sock.connect(sockfn)
164+
# better safe in case anything fails…
165+
r['changed'] = True
166+
phase = 'writing'
167+
sock.sendall(cmdstr.encode('ASCII'))
168+
phase = 'reading'
169+
while True:
170+
rspnew = sock.recv(BUFSIZ)
171+
if len(rspnew) == 0:
172+
break
173+
rsp += rspnew
174+
phase = 'closing'
175+
except OSError as ex:
176+
r['msg'] = 'error %s socket (%s): %s' % (phase, sockfn, str(ex))
177+
r['exception'] = traceback.format_exc()
178+
module.fail_json(**r)
179+
180+
# 15 is the length of the minimum response {"response":0} as formatted by KEA
181+
if len(rsp) < 15:
182+
r['msg'] = 'unrealistically short response ' + repr(rsp)
183+
module.fail_json(**r)
184+
185+
try:
186+
r['response'] = json.loads(rsp, parse_constant=_parse_constant)
187+
except ValueError as ex:
188+
r['msg'] = 'error parsing JSON response: ' + str(ex)
189+
r['exception'] = traceback.format_exc()
190+
module.fail_json(**r)
191+
if not isinstance(r['response'], dict):
192+
r['msg'] = 'bogus JSON response (JSONObject expected)'
193+
module.fail_json(**r)
194+
if 'result' not in r['response']:
195+
r['msg'] = 'bogus JSON response (missing result)'
196+
module.fail_json(**r)
197+
res = r['response']['result']
198+
if not isinstance(res, int):
199+
r['msg'] = 'bogus JSON response (non-integer result)'
200+
module.fail_json(**r)
201+
202+
if res in rvok:
203+
r['changed'] = False
204+
elif res not in rvch:
205+
r['msg'] = 'failure result (code %d)' % res
206+
module.fail_json(**r)
207+
208+
module.exit_json(**r)
209+
210+
211+
if __name__ == '__main__':
212+
main()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
3+
# Copyright © Thorsten Glaser <[email protected]>
4+
# Copyright © Felix Fontein <[email protected]>
5+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
6+
7+
skip/python2
8+
9+
# sets up networks and services
10+
needs/root
11+
destructive
12+
13+
azp/posix/2
14+
azp/posix/vm
15+
skip/aix
16+
skip/alpine # TODO: make this work
17+
skip/docker
18+
skip/fedora # TODO: make this work (not running in CI right now)
19+
skip/freebsd
20+
skip/macos
21+
skip/osx
22+
skip/rhel # TODO: make this work
23+
skip/ubuntu22.04
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
---
3+
####################################################################
4+
# WARNING: These are designed specifically for Ansible tests #
5+
# and should not be used as examples of how to write Ansible roles #
6+
####################################################################
7+
8+
# Copyright © Thorsten Glaser <[email protected]>
9+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
10+
11+
- name: Ensure this is run on the right distro and version the integration test was written for
12+
ansible.builtin.meta: end_play
13+
when: not ((ansible_distribution == 'Debian' and ansible_distribution_major_version == '13') or (ansible_distribution == 'Ubuntu' and ansible_distribution_release in ['noble']))
14+
15+
- name: Install prerequisites
16+
ansible.builtin.apt:
17+
name:
18+
- iproute2
19+
state: present
20+
install_recommends: false
21+
update_cache: true
22+
23+
- name: Networking setup, interface
24+
ansible.builtin.command:
25+
cmd: "ip link add eth666 type dummy"
26+
creates: /proc/sys/net/ipv4/conf/eth666/forwarding
27+
changed_when: true
28+
29+
- name: Networking setup, IPv4
30+
ansible.builtin.command:
31+
cmd: "ip addr change 192.0.2.1/24 dev eth666"
32+
changed_when: true
33+
34+
- name: Networking setup, link
35+
ansible.builtin.command:
36+
cmd: "ip link set up dev eth666"
37+
changed_when: true
38+
39+
- name: Install KEA servers for DHCP and DHCPv6
40+
ansible.builtin.apt:
41+
name:
42+
- kea-dhcp4-server
43+
- kea-dhcp6-server
44+
state: present
45+
install_recommends: false
46+
47+
- name: Set up dhcp4 server, network
48+
ansible.builtin.lineinfile:
49+
firstmatch: true
50+
insertafter: '"interfaces-config": [{]'
51+
line: '"interfaces": [ "eth666" ]'
52+
path: /etc/kea/kea-dhcp4.conf
53+
search_string: '"interfaces": ['
54+
55+
- name: Set up dhcp4 server, hooks
56+
ansible.builtin.lineinfile:
57+
firstmatch: true
58+
insertbefore: '"subnet4": '
59+
line: '"hooks-libraries": [ { "library": "libdhcp_lease_cmds.so" } ],'
60+
path: /etc/kea/kea-dhcp4.conf
61+
regexp: '^ *"hooks-libraries":'
62+
63+
- name: Ensure the dhcp4 server is (re)started
64+
ansible.builtin.service:
65+
name: kea-dhcp4-server
66+
state: restarted
67+
68+
- name: Ensure the dhcp6 server is (re)started
69+
ansible.builtin.service:
70+
name: kea-dhcp6-server
71+
state: restarted
72+
73+
# an example for a request acquiring information
74+
- name: Get KEA DHCP6 status
75+
kea_command:
76+
command: status-get
77+
rv_unchanged: [0]
78+
socket: /run/kea/kea6-ctrl-socket
79+
register: kea6_status
80+
ignore_errors: true
81+
82+
- name: Display registered status result
83+
ansible.builtin.debug:
84+
msg: KEA DHCP6 running on PID {{ kea6_status.response.arguments.pid }}
85+
86+
# ensure socket option works
87+
- name: Get KEA DHCP4 status
88+
kea_command:
89+
command: status-get
90+
rv_unchanged: [0]
91+
socket: /run/kea/kea4-ctrl-socket
92+
register: kea4_status
93+
ignore_errors: true
94+
95+
# an example for requests modifying state
96+
- name: Remove existing leases for 192.0.2.66, if any
97+
kea_command:
98+
command: lease4-del
99+
arguments:
100+
ip-address: "192.0.2.66"
101+
rv_changed: [0]
102+
rv_unchanged: [3]
103+
register: lease_del
104+
ignore_errors: true
105+
106+
- name: Add DHCP lease for 192.0.2.66
107+
kea_command:
108+
command: lease4-add
109+
arguments:
110+
ip-address: "192.0.2.66"
111+
hw-address: "00:00:5E:00:53:00"
112+
rv_changed: [0]
113+
register: lease_add
114+
ignore_errors: true
115+
116+
# these all ignore_errors so the network teardown runs in all cases
117+
- name: An unknown command
118+
kea_command:
119+
command: get-status
120+
rv_unchanged: [0]
121+
register: uc_status
122+
ignore_errors: true
123+
124+
- name: Networking setup, teardown
125+
ansible.builtin.command:
126+
cmd: "ip link del eth666"
127+
changed_when: true
128+
129+
- name: Ensure dhcp4 and dhcp6 PIDs are different
130+
ansible.builtin.assert:
131+
that:
132+
- kea4_status.response.arguments.pid is integer
133+
- kea4_status.response.arguments.pid > 0
134+
- kea6_status.response.arguments.pid is integer
135+
- kea6_status.response.arguments.pid > 0
136+
- kea4_status.response.arguments.pid != kea6_status.response.arguments.pid
137+
fail_msg: 'PIDs are invalid or do not differ (4: {{ kea4_status.response.arguments.pid }}, 6: {{ kea6_status.response.arguments.pid }})'
138+
success_msg: 'PIDs differ (4: {{ kea4_status.response.arguments.pid }}, 6: {{ kea6_status.response.arguments.pid }})'
139+
140+
- name: Check results
141+
ansible.builtin.assert:
142+
that:
143+
- kea6_status is not changed
144+
- kea6_status is not failed
145+
- kea4_status is not changed
146+
- kea4_status is not failed
147+
- lease_del is not failed
148+
- lease_add is changed
149+
- lease_add is not failed
150+
- uc_status is failed

0 commit comments

Comments
 (0)