Source code for univention.management.console.modules.ipchange

#!/usr/bin/python3
#
# Univention Management Console
#  module: ipchange
#
# SPDX-FileCopyrightText: 2012-2026 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only

import ipaddress
from urllib.parse import urlparse

from ldap.filter import filter_format

import univention.admin.modules
import univention.admin.uldap
from univention.management.console.base import Base
from univention.management.console.config import ucr
from univention.management.console.error import BadRequest
from univention.management.console.log import MODULE
from univention.management.console.modules.decorators import simple_response


univention.admin.modules.update()


[docs] class Instance(Base):
[docs] @simple_response def change(self, role: str, ip: str, netmask: str, oldip: str | None = None) -> None: try: network = ipaddress.ip_network(f'{ip}/{netmask}', False) old_ip = ipaddress.ip_address(oldip) if oldip else None old_address = old_ip.exploded if old_ip else None new_ip = ipaddress.ip_address(ip) new_address = new_ip.exploded except ValueError as exc: raise BadRequest(f'The IP address is invalid: {exc}') if old_ip is not None and old_ip.version != new_ip.version: raise BadRequest('The old and new IP addresses use different address families.') # ignore link local or loopback addresses (no DHCP address received) if new_ip.is_link_local or new_ip.is_loopback: MODULE.error('Ignore link local or loopback address change.') return # set attribute aRecord(<split>.|<trim>0) or aAAARecord(<split>:|<trim>0000) for IP version 4 or 6 if network.version == 4: attribute = 'aRecord' ip_delimiter = '.' ip_trim = '0' elif network.version == 6: attribute = 'aAAARecord' ip_delimiter = ':' ip_trim = '0000' lo, position = univention.admin.uldap.getAdminConnection() host_mod = univention.admin.modules.get('dns/host_record') comp_mod = univention.admin.modules.get(f'computers/{role}') fwd_mod = univention.admin.modules.get('dns/forward_zone') rev_mod = univention.admin.modules.get('dns/reverse_zone') server = comp_mod.object(None, lo, position, self.user_dn) server.open() current_ips = set(server['ip']) if new_address in current_ips and (not oldip or old_address == new_address): return # get the current server status filtered by IP version source_addresses = { entry for entry in server['ip'] if ipaddress.ip_address(entry).version == network.version } if oldip: source_addresses |= {old_address, oldip} ucr.load() server_domain = server.get('domain', '').lower() fqdn = server.get('fqdn', '').lower() fqdns = {fqdn} sso_fqdn = urlparse(ucr.get('ucs/server/sso/uri', '').lower()).hostname if ucr.is_true('keycloak/server/sso/autoregistraton', True) and sso_fqdn: fqdns.add(sso_fqdn) if ucr.get('dns/backend') == 'samba4': # FIXED? Works, but it isn't stable! # (Sometimes the S4 connector resyncs old adresses. Do we have to wait for a replication?) fqdns |= {f'{host}.{server_domain}' for host in ['gc._msdcs', 'DomainDnsZones', 'ForestDnsZones']} fqdns.discard('') fqdns.discard(None) # check if already used by host record # FIXME: what about further A records for this server, which are just manually added by customers? # TODO: replace the check by a search for computers/computer ip={new_address} ? host_recs = univention.admin.modules.lookup(host_mod, None, lo, scope='sub', filter=filter_format('(|(%s=%s)(%s=%s))', (attribute, new_address, attribute, ip))) used_by = { f"{host_rec['name']}.{host_rec.superordinate['zone']}".lower() for host_rec in host_recs if 'name' in host_rec } - {managed_fqdn.lower() for managed_fqdn in fqdns} if used_by: raise BadRequest(f'The IP address is already in use by host record(s) for: {", ".join(used_by)}') # do we have a forward zone for the IP addresses? for address in source_addresses: for fwd_zone in univention.admin.modules.lookup(fwd_mod, None, lo, scope='sub', filter=filter_format('(%s=%s)', (attribute, address))): fwd_zone.open() if address in fwd_zone['a']: fwd_zone['a'].remove(address) if new_address not in fwd_zone['a']: fwd_zone['a'].append(new_address) fwd_zone.modify() # add the new A records for all known DNS names # this must be done before modifying the server, as that would cleanup the old IP addresses and we wouldn't find any records anymore # FIXME: this should be done for UCS-in-AD domains as well! for fwd_zone in univention.admin.modules.lookup(fwd_mod, None, lo, scope='sub'): zone = fwd_zone.get('zone') for host_fqdns in fqdns: # check case insensitive. Mostly necessary for Keycloak if not host_fqdns.lower().endswith('.' + zone.lower()): continue name = host_fqdns[:-(len(zone) + 1)] for host_rec in univention.admin.modules.lookup(host_mod, None, lo, scope='sub', superordinate=fwd_zone, filter=filter_format('(&(relativeDomainName=%s))', (name,))): host_rec.open() host_rec['a'] = [ address for address in host_rec['a'] if address not in source_addresses ] if new_address not in host_rec['a']: host_rec['a'].append(new_address) host_rec.modify() server = comp_mod.object(None, lo, position, self.user_dn) server.open() # change/append server address and cleanup any old ip addresses server['ip'] = list(current_ips - source_addresses | {new_address}) # TODO: from security perspective we should only do: # server['ip'] = list(set(server['ip']) | {new_address}) # remove old DNS reverse entries with current IP address(es) for zone_ip in server['dnsEntryZoneReverse']: if zone_ip[1] in source_addresses: server['dnsEntryZoneReverse'].remove(zone_ip) # do we have a new reverse zone for this IP address? parts = network.network_address.exploded.split(ip_delimiter) while parts[-1] == ip_trim: parts.pop() while parts: subnet = ip_delimiter.join(parts) rev_recs = univention.admin.modules.lookup(rev_mod, None, lo, scope='sub', filter=filter_format('(subnet=%s)', (subnet,))) if rev_recs: entry = [rev_recs[0].dn, new_address] if entry not in server['dnsEntryZoneReverse']: server['dnsEntryZoneReverse'].append(entry) break parts.pop() # add IP address to new A record in forward zone parts = fqdn.split('.') while len(parts) > 1: zone_name = '.'.join(parts) zones = univention.admin.modules.lookup(fwd_mod, None, lo, scope='sub', filter=filter_format('(zone=%s)', (zone_name,))) if zones: entry = [zones[0].dn, new_address] if entry not in server['dnsEntryZoneForward']: server['dnsEntryZoneForward'].append(entry) break parts.pop(0) MODULE.process('Change IP address %s for %s', ip, fqdn) server.modify()