from pathlib import Path import copy import ipaddress from flask import Blueprint, request, redirect, flash from auth import require_level from config_utils import load_config, record_group, diff_fields, verify_config_hash import sanitize import mod_validation as validate _PAGE = Path(__file__).parent.name bp = Blueprint(_PAGE, __name__) def _row_index(): try: return int(request.form.get('row_index', '')) except (ValueError, TypeError): return None def _hash_ok(): if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') return False return True def _parse_ip(): raw = request.form.get('ip', '').strip() if not raw: return '' ip = validate.ip(raw) if not ip: flash(f'The configuration has not been saved because "{raw}" is not a valid IP address.', 'error') return None return ip def _check_ip_in_vlan_subnet(ip, vlan): if not ip: return None subnet = vlan.get('subnet') prefix = vlan.get('subnet_mask') if not subnet or prefix is None: return None try: network = ipaddress.IPv4Network(f'{subnet}/{prefix}', strict=False) addr = ipaddress.IPv4Address(ip) if addr == network.network_address: return f'{ip} is the network address and cannot be assigned.' if addr == network.broadcast_address: return f'{ip} is the broadcast address and cannot be assigned.' if addr not in network: return f'{ip} is not within the {vlan["name"]} subnet ({subnet}/{prefix}).' except ValueError: return f'{ip} is not a valid IP address.' return None @bp.route('/action/dhcpreservations/addreservation_add', methods=['POST']) @require_level('administrator') def addreservation_add(): vlan_name = sanitize.name(request.form.get('vlan_name', '')) description = sanitize.text(request.form.get('description', '')) hostname = validate.domainname(request.form.get('hostname', '')) mac = sanitize.mac(request.form.get('mac', '')) ip = _parse_ip() radius_client = 'radius_client' in request.form if ip is None: return redirect(f'/{_PAGE}') if not vlan_name: flash('The configuration has not been saved because a VLAN is required.', 'error') return redirect(f'/{_PAGE}') if not mac: flash('The configuration has not been saved because a MAC address is required.', 'error') return redirect(f'/{_PAGE}') if not _hash_ok(): return redirect(f'/{_PAGE}') cfg = load_config() vlans = cfg.get('vlans', []) vlan = next((v for v in vlans if v.get('name') == vlan_name), None) if vlan is None: flash(f'The configuration has not been saved because VLAN "{vlan_name}" was not found.', 'error') return redirect(f'/{_PAGE}') subnet_err = _check_ip_in_vlan_subnet(ip, vlan) if subnet_err: flash(f'The configuration has not been saved because {subnet_err}', 'error') return redirect(f'/{_PAGE}') conflict = validate.check_reservation_ip_conflicts(ip, vlan) if conflict: flash(f'The configuration has not been saved because {conflict}', 'error') return redirect(f'/{_PAGE}') entry = { 'description': description, 'mac': mac, 'ip': ip, 'radius_client': radius_client, 'enabled': True, 'vlan': vlan_name, } if hostname: entry['hostname'] = hostname cfg.setdefault('dhcp_reservations', []).append(entry) errors = validate.validate_config(cfg) if errors: for msg in errors: flash(msg, 'error') return redirect(f'/{_PAGE}') changes = diff_fields(None, entry) flash(record_group(cfg, 'dhcp_reservations', 'mac', mac, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @bp.route('/action/dhcpreservations/reservations_toggle', methods=['POST']) @require_level('administrator') def reservations_toggle(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') return redirect(f'/{_PAGE}') if not _hash_ok(): return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('dhcp_reservations', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') return redirect(f'/{_PAGE}') res = items[idx] old_enabled = res.get('enabled', True) before = copy.deepcopy(res) res['enabled'] = not old_enabled errors = validate.validate_config(cfg) if errors: for msg in errors: flash(msg, 'error') return redirect(f'/{_PAGE}') changes = diff_fields(before, res) flash(record_group(cfg, 'dhcp_reservations', 'mac', res['mac'], changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @bp.route('/action/dhcpreservations/reservations_edit', methods=['POST']) @require_level('administrator') def reservations_edit(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') return redirect(f'/{_PAGE}') description = sanitize.text(request.form.get('description', '')) hostname = validate.domainname(request.form.get('hostname', '')) mac = sanitize.mac(request.form.get('mac', '')) ip = _parse_ip() radius_client = 'radius_client' in request.form if ip is None: return redirect(f'/{_PAGE}') if not mac: flash('The configuration has not been saved because a MAC address is required.', 'error') return redirect(f'/{_PAGE}') if not _hash_ok(): return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('dhcp_reservations', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') return redirect(f'/{_PAGE}') res = items[idx] vlan_name = res.get('vlan', '') vlan = next((v for v in cfg.get('vlans', []) if v.get('name') == vlan_name), None) if vlan: subnet_err = _check_ip_in_vlan_subnet(ip, vlan) if subnet_err: flash(f'The configuration has not been saved because {subnet_err}', 'error') return redirect(f'/{_PAGE}') conflict = validate.check_reservation_ip_conflicts(ip, vlan) if conflict: flash(f'The configuration has not been saved because {conflict}', 'error') return redirect(f'/{_PAGE}') before = copy.deepcopy(res) res.update({ 'description': description, 'mac': mac, 'ip': ip, 'radius_client': radius_client, 'enabled': 'enabled' in request.form, }) if hostname: res['hostname'] = hostname else: res.pop('hostname', None) errors = validate.validate_config(cfg) if errors: for msg in errors: flash(msg, 'error') return redirect(f'/{_PAGE}') changes = diff_fields(before, res) flash(record_group(cfg, 'dhcp_reservations', 'mac', mac, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @bp.route('/action/dhcpreservations/reservations_delete', methods=['POST']) @require_level('administrator') def reservations_delete(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') return redirect(f'/{_PAGE}') if not _hash_ok(): return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('dhcp_reservations', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') return redirect(f'/{_PAGE}') removed = items.pop(idx) errors = validate.validate_config(cfg) if errors: for msg in errors: flash(msg, 'error') return redirect(f'/{_PAGE}') changes = diff_fields(removed, None) flash(record_group(cfg, 'dhcp_reservations', 'mac', removed['mac'], changes, 'core apply'), 'success') return redirect(f'/{_PAGE}')