From 745bacf86f94cace9174af22b25511480cc8b8dc Mon Sep 17 00:00:00 2001 From: Oleksandr Kuchmystyi Date: Fri, 2 Oct 2026 13:46:53 +0300 Subject: syslog: T9336: Resolve remote FQDN targets within their configured VRF Rsyslog resolves remote targets via the process's default routing context regardless of a per-remote 'vrf' setting, so a remote given as an FQDN fails to resolve when the only reachable DNS server lives in a non-default VRF. Changes: - `system_syslog.py` pre-resolves FQDN using VRF remotes at commit time and renders the resolved address instead of the raw FQDN. - `vyos-domain-resolver` collects VRF-bound FQDN targets from syslog and handles a resolved-address change for a syslog remote. --- python/vyos/defaults.py | 1 + python/vyos/firewall.py | 11 ------ python/vyos/utils/file.py | 22 +++++++++++ python/vyos/utils/network.py | 91 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 11 deletions(-) (limited to 'python') diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 65e702927..def5fd775 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -69,6 +69,7 @@ dhclient_debug_enable = '/tmp/vyos.dhclient.debug' static_route_dhcp_interfaces_path = '/tmp/static_dhcp_interfaces' vyos_configd_socket_path = 'ipc:///run/vyos-configd.sock' podman_mount_program = '/usr/bin/fuse-overlayfs' +vyos_domain_resolver_condition_path = '/run/use-vyos-domain-resolver' cfg_group = 'vyattacfg' diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py index 08f978f39..02d3134cb 100755 --- a/python/vyos/firewall.py +++ b/python/vyos/firewall.py @@ -14,10 +14,6 @@ import re -from socket import AF_INET -from socket import AF_INET6 -from socket import getaddrinfo - from vyos.template import is_ipv4 from vyos.utils.dict import dict_search_args from vyos.utils.dict import dict_search_recursive @@ -75,13 +71,6 @@ def fqdn_config_parse(config, node): set_name = f'{nat_direction}_{nat_rule}_{suffix}' config['ip_fqdn'][set_name] = domain -def fqdn_resolve(fqdn, ipv6=False): - try: - res = getaddrinfo(fqdn, None, AF_INET6 if ipv6 else AF_INET) - return set(item[4][0] for item in res) - except OSError: - return None - def find_nftables_rule(table, chain, rule_matches=[]): # Find rule in table/chain that matches all criteria and return the handle results = cmdl(['nft', '--handle', 'list', 'chain', table, chain], sudo=True).split("\n") diff --git a/python/vyos/utils/file.py b/python/vyos/utils/file.py index 5c2eae4d5..b21f56d5f 100644 --- a/python/vyos/utils/file.py +++ b/python/vyos/utils/file.py @@ -99,6 +99,28 @@ def read_json(fname, defaultonfailure=None): raise e +def write_json(fname, data, defaultonfailure=_unset, atomic=False): + """ + Write the provided data to a file in JSON format. + If defaultonfailure is not None and writing fails, it returns the default value. + """ + + import json + + default_indent = 4 # Indent for pretty printing + + try: + if atomic: # Atomic, avoids readers seeing a partial write + write_file_atomic(fname, json.dumps(data, indent=default_indent)) + else: + with open(fname, 'w') as f: + json.dump(data, f, indent=default_indent) + except Exception as e: + if defaultonfailure is not _unset: + return defaultonfailure + raise e + + def chmod(path, bitmask): # path may also be an open file descriptor if not isinstance(path, int) and not os.path.exists(path): diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 93ed05f6b..42fec00f0 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -18,6 +18,7 @@ import hashlib from json import loads from socket import AF_INET from socket import AF_INET6 +from socket import AF_UNSPEC from vyos.utils.process import cmdl def _are_same_ip(one, two): @@ -903,3 +904,93 @@ def get_interfaces_by_ip(ip_address: str, vrf=None, include_vrf: bool = False) - ifaces.append(interface) return ifaces + + +def fqdn_resolve( + fqdn: str, ipv6: bool | None = False, vrf: str = None +) -> frozenset | None: + """Resolve an FQDN to a set of addresses, optionally inside a VRF. + + When `vrf` is None, resolution is performed with the standard + library resolver (getaddrinfo), in the process's default + routing/namespace context - this matches the original, + non-VRF-aware behavior exactly. + + When `vrf` is given, resolution is instead performed inside that + VRF's routing/namespace context via `ip vrf exec`, so lookups + against a resolver only reachable through that VRF succeed. + + Args: + fqdn: The hostname to resolve. + ipv6: If True, resolve AAAA (IPv6) records instead of A + (IPv4) records. With None will use dual-stack resolving. + vrf: Name of the VRF to resolve within, or None to resolve in + the default/global routing context. + + Returns: + A set of resolved address strings, or None if resolution + failed or returned no usable addresses. + """ + + import socket + + addresses = None + + # When `vrf` is given, it runs inside that VRF via `ip vrf exec`, + # so lookups against a resolver only reachable through that VRF. + if vrf: + + if ipv6 is None: + db = 'ahosts' + else: + # ahostsv4 / ahostsv6 restrict getent to one family directly, + # avoiding the need to filter mixed-family output ourselves + db = 'ahostsv6' if ipv6 else 'ahostsv4' + + # getent's "ahostsv6" database passes AI_V4MAPPED under the hood, so it + # will happily return a synthesized ::ffff:a.b.c.d address even when no + # real AAAA record exists - e.g.: + # + # $ ip vrf exec red getent ahostsv6 fqdn.vyos + # ::ffff:192.168.50.2 STREAM fqdn.vyos + # + # The non-VRF getaddrinfo(fqdn, None, AF_INET6) path does NOT synthesize + # mapped addresses, so without filtering these out, the VRF-aware path + # would report IPv6 support that doesn't actually exist. Strip anything + # matching the IPv4-mapped prefix before returning IPv6 results. + IPV4_MAPPED_PREFIX = '::ffff:' + + try: + out = cmdl(['ip', 'vrf', 'exec', vrf, 'getent', db, fqdn], sudo=True) + except OSError: + pass + else: + if out: + addresses = [] + for line in out.splitlines(): + if line.strip(): + addr = line.split()[0] + + # Drop synthesized IPv4-mapped addresses. + # Without this, a domain with only an A record (no AAAA) + # would incorrectly appear to have IPv6 support + # when resolved via a VRF: + if ipv6 and addr.lower().startswith(IPV4_MAPPED_PREFIX): + continue + + addresses.append(addr) + else: + if ipv6 is None: + # Use dual-stack resolving + family = AF_UNSPEC + else: + family = AF_INET6 if ipv6 else AF_INET + + try: + res = socket.getaddrinfo(fqdn, None, family=family) + except OSError: + pass + else: + addresses = [item[4][0] for item in res] + + return frozenset(addresses) if addresses else None -- cgit v1.2.3