1#!/usr/bin/python
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
11ANSIBLE_METADATA = {'metadata_version': '1.1',
12                    'status': ['stableinterface'],
13                    'supported_by': 'community'}
14
15DOCUMENTATION = '''
16---
17module: interfaces_file
18short_description: Tweak settings in /etc/network/interfaces files
19extends_documentation_fragment: files
20description:
21     - Manage (add, remove, change) individual interface options in an interfaces-style file without having
22       to manage the file as a whole with, say, M(template) or M(assemble). Interface has to be presented in a file.
23     - Read information about interfaces from interfaces-styled files
24version_added: "2.4"
25options:
26  dest:
27    description:
28      - Path to the interfaces file
29    default: /etc/network/interfaces
30  iface:
31    description:
32      - Name of the interface, required for value changes or option remove
33  address_family:
34    description:
35      - Address family of the interface, useful if same interface name is used for both inet and inet6
36    version_added: "2.8"
37  option:
38    description:
39      - Name of the option, required for value changes or option remove
40  value:
41    description:
42      - If I(option) is not presented for the I(interface) and I(state) is C(present) option will be added.
43        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.
44        C(pre-up), C(up), C(post-up) and C(down) options can't be updated, only adding new options, removing existing
45        ones or cleaning the whole option set are supported
46  backup:
47    description:
48      - Create a backup file including the timestamp information so you can get
49        the original file back if you somehow clobbered it incorrectly.
50    type: bool
51    default: 'no'
52  state:
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# Set eth1 mtu configuration value to 8000
133- 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._text import to_bytes
149
150
151def lineDict(line):
152    return {'line': line, 'line_type': 'unknown'}
153
154
155def optionDict(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 getValueFromLine(s):
160    spaceRe = re.compile(r'\s+')
161    for m in spaceRe.finditer(s):
162        pass
163    valueEnd = m.start()
164    option = s.split()[0]
165    optionStart = s.find(option)
166    optionLen = len(option)
167    valueStart = re.search(r'\s', s[optionLen + optionStart:]).end() + optionLen + optionStart
168    return s[valueStart:valueEnd]
169
170
171def read_interfaces_file(module, filename):
172    f = open(filename, 'r')
173    return read_interfaces_lines(module, f)
174
175
176def read_interfaces_lines(module, line_strings):
177    lines = []
178    ifaces = {}
179    currently_processing = None
180    i = 0
181    for line in line_strings:
182        i += 1
183        words = line.split()
184        if len(words) < 1:
185            lines.append(lineDict(line))
186            continue
187        if words[0][0] == "#":
188            lines.append(lineDict(line))
189            continue
190        if words[0] == "mapping":
191            # currmap = calloc(1, sizeof *currmap);
192            lines.append(lineDict(line))
193            currently_processing = "MAPPING"
194        elif words[0] == "source":
195            lines.append(lineDict(line))
196            currently_processing = "NONE"
197        elif words[0] == "source-dir":
198            lines.append(lineDict(line))
199            currently_processing = "NONE"
200        elif words[0] == "source-directory":
201            lines.append(lineDict(line))
202            currently_processing = "NONE"
203        elif words[0] == "iface":
204            currif = {
205                "pre-up": [],
206                "up": [],
207                "down": [],
208                "post-up": []
209            }
210            iface_name = words[1]
211            try:
212                currif['address_family'] = words[2]
213            except IndexError:
214                currif['address_family'] = None
215            address_family = currif['address_family']
216            try:
217                currif['method'] = words[3]
218            except IndexError:
219                currif['method'] = None
220
221            ifaces[iface_name] = currif
222            lines.append({'line': line, 'iface': iface_name, 'line_type': 'iface', 'params': currif, 'address_family': address_family})
223            currently_processing = "IFACE"
224        elif words[0] == "auto":
225            lines.append(lineDict(line))
226            currently_processing = "NONE"
227        elif words[0].startswith("allow-"):
228            lines.append(lineDict(line))
229            currently_processing = "NONE"
230        elif words[0] == "no-auto-down":
231            lines.append(lineDict(line))
232            currently_processing = "NONE"
233        elif words[0] == "no-scripts":
234            lines.append(lineDict(line))
235            currently_processing = "NONE"
236        else:
237            if currently_processing == "IFACE":
238                option_name = words[0]
239                # TODO: if option_name in currif.options
240                value = getValueFromLine(line)
241                lines.append(optionDict(line, iface_name, option_name, value, address_family))
242                if option_name in ["pre-up", "up", "down", "post-up"]:
243                    currif[option_name].append(value)
244                else:
245                    currif[option_name] = value
246            elif currently_processing == "MAPPING":
247                lines.append(lineDict(line))
248            elif currently_processing == "NONE":
249                lines.append(lineDict(line))
250            else:
251                module.fail_json(msg="misplaced option %s in line %d" % (line, i))
252                return None, None
253    return lines, ifaces
254
255
256def setInterfaceOption(module, lines, iface, option, raw_value, state, address_family=None):
257    value = str(raw_value)
258    changed = False
259
260    iface_lines = [item for item in lines if "iface" in item and item["iface"] == iface]
261    if address_family is not None:
262        iface_lines = [item for item in iface_lines
263                       if "address_family" in item and item["address_family"] == address_family]
264
265    if len(iface_lines) < 1:
266        # interface not found
267        module.fail_json(msg="Error: interface %s not found" % iface)
268        return changed, None
269
270    iface_options = list(filter(lambda i: i['line_type'] == 'option', iface_lines))
271    target_options = list(filter(lambda i: i['option'] == option, iface_options))
272
273    if state == "present":
274        if len(target_options) < 1:
275            changed = True
276            # add new option
277            last_line_dict = iface_lines[-1]
278            changed, lines = addOptionAfterLine(option, value, iface, lines, last_line_dict, iface_options, address_family)
279        else:
280            if option in ["pre-up", "up", "down", "post-up"]:
281                if len(list(filter(lambda i: i['value'] == value, target_options))) < 1:
282                    changed, lines = addOptionAfterLine(option, value, iface, lines, target_options[-1], iface_options, address_family)
283            else:
284                # if more than one option found edit the last one
285                if target_options[-1]['value'] != value:
286                    changed = True
287                    target_option = target_options[-1]
288                    old_line = target_option['line']
289                    old_value = target_option['value']
290                    address_family = target_option['address_family']
291                    prefix_start = old_line.find(option)
292                    optionLen = len(option)
293                    old_value_position = re.search(r"\s+".join(map(re.escape, old_value.split())), old_line[prefix_start + optionLen:])
294                    start = old_value_position.start() + prefix_start + optionLen
295                    end = old_value_position.end() + prefix_start + optionLen
296                    line = old_line[:start] + value + old_line[end:]
297                    index = len(lines) - lines[::-1].index(target_option) - 1
298                    lines[index] = optionDict(line, iface, option, value, address_family)
299    elif state == "absent":
300        if len(target_options) >= 1:
301            if option in ["pre-up", "up", "down", "post-up"] and value is not None and value != "None":
302                for target_option in filter(lambda i: i['value'] == value, target_options):
303                    changed = True
304                    lines = list(filter(lambda ln: ln != target_option, lines))
305            else:
306                changed = True
307                for target_option in target_options:
308                    lines = list(filter(lambda ln: ln != target_option, lines))
309    else:
310        module.fail_json(msg="Error: unsupported state %s, has to be either present or absent" % state)
311
312    return changed, lines
313
314
315def addOptionAfterLine(option, value, iface, lines, last_line_dict, iface_options, address_family):
316    # Changing method of interface is not an addition
317    if option == 'method':
318        changed = False
319        for ln in lines:
320            if ln.get('line_type', '') == 'iface' and ln.get('iface', '') == iface and value != ln.get('params', {}).get('method', ''):
321                changed = True
322                ln['line'] = re.sub(ln.get('params', {}).get('method', '') + '$', value, ln.get('line'))
323                ln['params']['method'] = value
324        return changed, lines
325
326    last_line = last_line_dict['line']
327    prefix_start = last_line.find(last_line.split()[0])
328    suffix_start = last_line.rfind(last_line.split()[-1]) + len(last_line.split()[-1])
329    prefix = last_line[:prefix_start]
330
331    if len(iface_options) < 1:
332        # interface has no options, ident
333        prefix += "    "
334
335    line = prefix + "%s %s" % (option, value) + last_line[suffix_start:]
336    option_dict = optionDict(line, iface, option, value, address_family)
337    index = len(lines) - lines[::-1].index(last_line_dict)
338    lines.insert(index, option_dict)
339    return True, lines
340
341
342def write_changes(module, lines, dest):
343
344    tmpfd, tmpfile = tempfile.mkstemp()
345    f = os.fdopen(tmpfd, 'wb')
346    f.write(to_bytes(''.join(lines), errors='surrogate_or_strict'))
347    f.close()
348    module.atomic_move(tmpfile, os.path.realpath(dest))
349
350
351def main():
352    module = AnsibleModule(
353        argument_spec=dict(
354            dest=dict(type='path', default='/etc/network/interfaces'),
355            iface=dict(type='str'),
356            address_family=dict(type='str'),
357            option=dict(type='str'),
358            value=dict(type='str'),
359            backup=dict(type='bool', default=False),
360            state=dict(type='str', default='present', choices=['absent', 'present']),
361        ),
362        add_file_common_args=True,
363        supports_check_mode=True,
364        required_by=dict(
365            option=('iface',),
366        ),
367    )
368
369    dest = module.params['dest']
370    iface = module.params['iface']
371    address_family = module.params['address_family']
372    option = module.params['option']
373    value = module.params['value']
374    backup = module.params['backup']
375    state = module.params['state']
376
377    if option is not None and state == "present" and value is None:
378        module.fail_json(msg="Value must be set if option is defined and state is 'present'")
379
380    lines, ifaces = read_interfaces_file(module, dest)
381
382    changed = False
383
384    if option is not None:
385        changed, lines = setInterfaceOption(module, lines, iface, option, value, state, address_family)
386
387    if changed:
388        _, ifaces = read_interfaces_lines(module, [d['line'] for d in lines if 'line' in d])
389
390    if changed and not module.check_mode:
391        if backup:
392            module.backup_local(dest)
393        write_changes(module, [d['line'] for d in lines if 'line' in d], dest)
394
395    module.exit_json(dest=dest, changed=changed, ifaces=ifaces)
396
397
398if __name__ == '__main__':
399    main()
400