1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3#
4# Copyright: (c) 2016, Roman Belyakovsky <ihryamzik () gmail.com>
5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
6
7from __future__ import absolute_import, division, print_function
8__metaclass__ = type
9
10
11DOCUMENTATION = '''
12---
13module: interfaces_file
14short_description: Tweak settings in /etc/network/interfaces files
15extends_documentation_fragment: files
16description:
17     - Manage (add, remove, change) individual interface options in an interfaces-style file without having
18       to manage the file as a whole with, say, M(ansible.builtin.template) or M(ansible.builtin.assemble). Interface has to be presented in a file.
19     - Read information about interfaces from interfaces-styled files
20options:
21  dest:
22    type: path
23    description:
24      - Path to the interfaces file
25    default: /etc/network/interfaces
26  iface:
27    type: str
28    description:
29      - Name of the interface, required for value changes or option remove
30  address_family:
31    type: str
32    description:
33      - Address family of the interface, useful if same interface name is used for both inet and inet6
34  option:
35    type: str
36    description:
37      - Name of the option, required for value changes or option remove
38  value:
39    type: str
40    description:
41      - If I(option) is not presented for the I(interface) and I(state) is C(present) option will be added.
42        If I(option) already exists and is not C(pre-up), C(up), C(post-up) or C(down), it's value will be updated.
43        C(pre-up), C(up), C(post-up) and C(down) options can't be updated, only adding new options, removing existing
44        ones or cleaning the whole option set are supported
45  backup:
46    description:
47      - Create a backup file including the timestamp information so you can get
48        the original file back if you somehow clobbered it incorrectly.
49    type: bool
50    default: 'no'
51  state:
52    type: str
53    description:
54      - If set to C(absent) the option or section will be removed if present instead of created.
55    default: "present"
56    choices: [ "present", "absent" ]
57
58notes:
59   - If option is defined multiple times last one will be updated but all will be deleted in case of an absent state
60requirements: []
61author: "Roman Belyakovsky (@hryamzik)"
62'''
63
64RETURN = '''
65dest:
66    description: destination file/path
67    returned: success
68    type: str
69    sample: "/etc/network/interfaces"
70ifaces:
71    description: interfaces dictionary
72    returned: success
73    type: complex
74    contains:
75      ifaces:
76        description: interface dictionary
77        returned: success
78        type: dict
79        contains:
80          eth0:
81            description: Name of the interface
82            returned: success
83            type: dict
84            contains:
85              address_family:
86                description: interface address family
87                returned: success
88                type: str
89                sample: "inet"
90              method:
91                description: interface method
92                returned: success
93                type: str
94                sample: "manual"
95              mtu:
96                description: other options, all values returned as strings
97                returned: success
98                type: str
99                sample: "1500"
100              pre-up:
101                description: list of C(pre-up) scripts
102                returned: success
103                type: list
104                sample:
105                  - "route add -net 10.10.10.0/24 gw 10.10.10.1 dev eth1"
106                  - "route add -net 10.10.11.0/24 gw 10.10.11.1 dev eth2"
107              up:
108                description: list of C(up) scripts
109                returned: success
110                type: list
111                sample:
112                  - "route add -net 10.10.10.0/24 gw 10.10.10.1 dev eth1"
113                  - "route add -net 10.10.11.0/24 gw 10.10.11.1 dev eth2"
114              post-up:
115                description: list of C(post-up) scripts
116                returned: success
117                type: list
118                sample:
119                  - "route add -net 10.10.10.0/24 gw 10.10.10.1 dev eth1"
120                  - "route add -net 10.10.11.0/24 gw 10.10.11.1 dev eth2"
121              down:
122                description: list of C(down) scripts
123                returned: success
124                type: list
125                sample:
126                  - "route del -net 10.10.10.0/24 gw 10.10.10.1 dev eth1"
127                  - "route del -net 10.10.11.0/24 gw 10.10.11.1 dev eth2"
128...
129'''
130
131EXAMPLES = '''
132- name: Set eth1 mtu configuration value to 8000
133  community.general.interfaces_file:
134    dest: /etc/network/interfaces.d/eth1.cfg
135    iface: eth1
136    option: mtu
137    value: 8000
138    backup: yes
139    state: present
140  register: eth1_cfg
141'''
142
143import os
144import re
145import tempfile
146
147from ansible.module_utils.basic import AnsibleModule
148from ansible.module_utils.common.text.converters import to_bytes
149
150
151def line_dict(line):
152    return {'line': line, 'line_type': 'unknown'}
153
154
155def make_option_dict(line, iface, option, value, address_family):
156    return {'line': line, 'iface': iface, 'option': option, 'value': value, 'line_type': 'option', 'address_family': address_family}
157
158
159def get_option_value(line):
160    patt = re.compile(r'^\s+(?P<option>\S+)\s+(?P<value>\S?.*\S)\s*$')
161    match = patt.match(line)
162    if not match:
163        return None, None
164    return match.group("option"), match.group("value")
165
166
167def read_interfaces_file(module, filename):
168    with open(filename, 'r') as f:
169        return read_interfaces_lines(module, f)
170
171
172def _is_line_processing_none(first_word):
173    return first_word in ("source", "source-dir", "source-directory", "auto", "no-auto-down", "no-scripts") or first_word.startswith("auto-")
174
175
176def read_interfaces_lines(module, line_strings):
177    lines = []
178    ifaces = {}
179    iface_name = None
180    address_family = None
181    currif = {}
182    currently_processing = None
183    for i, line in enumerate(line_strings):
184        words = line.split()
185        if not words or words[0].startswith("#"):
186            lines.append(line_dict(line))
187            continue
188        if words[0] == "mapping":
189            lines.append(line_dict(line))
190            currently_processing = "MAPPING"
191        elif _is_line_processing_none(words[0]):
192            lines.append(line_dict(line))
193            currently_processing = "NONE"
194        elif words[0] == "iface":
195            currif = {
196                "pre-up": [],
197                "up": [],
198                "down": [],
199                "post-up": []
200            }
201            iface_name = words[1]
202            try:
203                currif['address_family'] = words[2]
204            except IndexError:
205                currif['address_family'] = None
206            address_family = currif['address_family']
207            try:
208                currif['method'] = words[3]
209            except IndexError:
210                currif['method'] = None
211
212            ifaces[iface_name] = currif
213            lines.append({'line': line, 'iface': iface_name, 'line_type': 'iface', 'params': currif, 'address_family': address_family})
214            currently_processing = "IFACE"
215        else:
216            if currently_processing == "IFACE":
217                option_name, value = get_option_value(line)
218                # TODO: if option_name in currif.options
219                lines.append(make_option_dict(line, iface_name, option_name, value, address_family))
220                if option_name in ["pre-up", "up", "down", "post-up"]:
221                    currif[option_name].append(value)
222                else:
223                    currif[option_name] = value
224            elif currently_processing == "MAPPING":
225                lines.append(line_dict(line))
226            elif currently_processing == "NONE":
227                lines.append(line_dict(line))
228            else:
229                module.fail_json(msg="misplaced option %s in line %d" % (line, i + 1))
230
231    return lines, ifaces
232
233
234def set_interface_option(module, lines, iface, option, raw_value, state, address_family=None):
235    value = str(raw_value)
236    changed = False
237
238    iface_lines = [item for item in lines if "iface" in item and item["iface"] == iface]
239    if address_family is not None:
240        iface_lines = [item for item in iface_lines
241                       if "address_family" in item and item["address_family"] == address_family]
242
243    if not iface_lines:
244        # interface not found
245        module.fail_json(msg="Error: interface %s not found" % iface)
246
247    iface_options = [il for il in iface_lines if il['line_type'] == 'option']
248    target_options = [io for io in iface_options if io['option'] == option]
249
250    if state == "present":
251        if not target_options:
252            # add new option
253            last_line_dict = iface_lines[-1]
254            return add_option_after_line(option, value, iface, lines, last_line_dict, iface_options, address_family)
255
256        if option in ["pre-up", "up", "down", "post-up"] and all(ito for ito in target_options if ito['value'] != value):
257            return add_option_after_line(option, value, iface, lines, target_options[-1], iface_options, address_family)
258
259        # if more than one option found edit the last one
260        if target_options[-1]['value'] != value:
261            changed = True
262            target_option = target_options[-1]
263            old_line = target_option['line']
264            old_value = target_option['value']
265            address_family = target_option['address_family']
266            prefix_start = old_line.find(option)
267            option_len = len(option)
268            old_value_position = re.search(r"\s+".join(map(re.escape, old_value.split())), old_line[prefix_start + option_len:])
269            start = old_value_position.start() + prefix_start + option_len
270            end = old_value_position.end() + prefix_start + option_len
271            line = old_line[:start] + value + old_line[end:]
272            index = len(lines) - lines[::-1].index(target_option) - 1
273            lines[index] = make_option_dict(line, iface, option, value, address_family)
274            return changed, lines
275
276    if state == "absent":
277        if target_options:
278            if option in ["pre-up", "up", "down", "post-up"] and value is not None and value != "None":
279                for target_option in [ito for ito in target_options if ito['value'] == value]:
280                    changed = True
281                    lines = [ln for ln in lines if ln != target_option]
282            else:
283                changed = True
284                for target_option in target_options:
285                    lines = [ln for ln in lines if ln != target_option]
286
287    return changed, lines
288
289
290def add_option_after_line(option, value, iface, lines, last_line_dict, iface_options, address_family):
291    # Changing method of interface is not an addition
292    if option == 'method':
293        changed = False
294        for ln in lines:
295            if ln.get('line_type', '') == 'iface' and ln.get('iface', '') == iface and value != ln.get('params', {}).get('method', ''):
296                changed = True
297                ln['line'] = re.sub(ln.get('params', {}).get('method', '') + '$', value, ln.get('line'))
298                ln['params']['method'] = value
299        return changed, lines
300
301    last_line = last_line_dict['line']
302    prefix_start = last_line.find(last_line.split()[0])
303    suffix_start = last_line.rfind(last_line.split()[-1]) + len(last_line.split()[-1])
304    prefix = last_line[:prefix_start]
305
306    if not iface_options:
307        # interface has no options, ident
308        prefix += "    "
309
310    line = prefix + "%s %s" % (option, value) + last_line[suffix_start:]
311    option_dict = make_option_dict(line, iface, option, value, address_family)
312    index = len(lines) - lines[::-1].index(last_line_dict)
313    lines.insert(index, option_dict)
314    return True, lines
315
316
317def write_changes(module, lines, dest):
318    tmpfd, tmpfile = tempfile.mkstemp()
319    with os.fdopen(tmpfd, 'wb') as f:
320        f.write(to_bytes(''.join(lines), errors='surrogate_or_strict'))
321    module.atomic_move(tmpfile, os.path.realpath(dest))
322
323
324def main():
325    module = AnsibleModule(
326        argument_spec=dict(
327            dest=dict(type='path', default='/etc/network/interfaces'),
328            iface=dict(type='str'),
329            address_family=dict(type='str'),
330            option=dict(type='str'),
331            value=dict(type='str'),
332            backup=dict(type='bool', default=False),
333            state=dict(type='str', default='present', choices=['absent', 'present']),
334        ),
335        add_file_common_args=True,
336        supports_check_mode=True,
337        required_by=dict(
338            option=('iface',),
339        ),
340    )
341
342    dest = module.params['dest']
343    iface = module.params['iface']
344    address_family = module.params['address_family']
345    option = module.params['option']
346    value = module.params['value']
347    backup = module.params['backup']
348    state = module.params['state']
349
350    if option is not None and state == "present" and value is None:
351        module.fail_json(msg="Value must be set if option is defined and state is 'present'")
352
353    lines, ifaces = read_interfaces_file(module, dest)
354
355    changed = False
356
357    if option is not None:
358        changed, lines = set_interface_option(module, lines, iface, option, value, state, address_family)
359
360    if changed:
361        dummy, ifaces = read_interfaces_lines(module, [d['line'] for d in lines if 'line' in d])
362
363    if changed and not module.check_mode:
364        if backup:
365            module.backup_local(dest)
366        write_changes(module, [d['line'] for d in lines if 'line' in d], dest)
367
368    module.exit_json(dest=dest, changed=changed, ifaces=ifaces)
369
370
371if __name__ == '__main__':
372    main()
373