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