summaryrefslogtreecommitdiff
path: root/python
diff options
context:
space:
mode:
authorOleksandr Kuchmystyi <o.kuchmystyi@vyos.io>2026-10-02 13:46:53 +0300
committerOleksandr Kuchmystyi <o.kuchmystyi@vyos.io>2026-10-19 15:23:38 +0300
commit745bacf86f94cace9174af22b25511480cc8b8dc (patch)
tree2123b5f9834a5d3af23739007395e70a4312cac3 /python
parent7216e748b8a37929f9d766f21cc47f099c92c27e (diff)
downloadvyos-1x-745bacf86f94cace9174af22b25511480cc8b8dc.tar.gz
vyos-1x-745bacf86f94cace9174af22b25511480cc8b8dc.zip
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.
Diffstat (limited to 'python')
-rw-r--r--python/vyos/defaults.py1
-rwxr-xr-xpython/vyos/firewall.py11
-rw-r--r--python/vyos/utils/file.py22
-rw-r--r--python/vyos/utils/network.py91
4 files changed, 114 insertions, 11 deletions
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