From b2b6722c027d82c740e5df8c722a8176b22abe26 Mon Sep 17 00:00:00 2001 From: 3np <3np@example.com> Date: Sun, 6 Jul 2025 10:43:57 +0000 Subject: [PATCH 1/5] Add support for IPv6 Virtual DNS - Merge '1cho1ce/add-ipv6-dnat-to-ns' into master - https://github.com/QubesOS/qubes-core-agent-linux/pull/462 - fix: properly assign primary/secondary DNS - fix: check ipv4 dns presence by qdb /qubes-primary-dns instead of /qubes-ip - qubes-setup-dnat-to-ns: unify ipv4/ipv6 firewall rule generation Part of https://github.com/QubesOS/qubes-issues/issues/10038 --- network/qubes-setup-dnat-to-ns | 136 ++++++++++-------- network/setup-ip | 29 +++- .../10-qubes-core-agent-features.sh | 2 + 3 files changed, 99 insertions(+), 68 deletions(-) diff --git a/network/qubes-setup-dnat-to-ns b/network/qubes-setup-dnat-to-ns index aba447c7..93225921 100755 --- a/network/qubes-setup-dnat-to-ns +++ b/network/qubes-setup-dnat-to-ns @@ -27,7 +27,7 @@ import sys import dbus import qubesdb -from ipaddress import IPv4Address +from ipaddress import ip_address import os def get_dns_resolv_conf(): @@ -42,7 +42,7 @@ def get_dns_resolv_conf(): if len(tokens) < 2 or tokens[0] != "nameserver": continue try: - nameservers.append(IPv4Address(tokens[1])) + nameservers.append(ip_address(tokens[1])) except ValueError: pass return nameservers @@ -74,74 +74,88 @@ def get_dns_resolved(): raise # Use global entries first dns.sort(key=lambda x: x[0] != 0) - # Only keep IPv4 entries. systemd-resolved is trusted to return valid - # addresses. + # systemd-resolved is trusted to return valid addresses. # ToDo: We only need abridged IPv4 DNS entries for ifindex == 0. # to ensure static DNS of disconnected network interfaces are not added. - return [IPv4Address(bytes(addr)) for ifindex, family, addr in dns - if family == 2] + return [ip_address(bytes(addr)) for ifindex, family, addr in dns] def install_firewall_rules(dns): qdb = qubesdb.QubesDB() - qubesdb_dns = [] - for i in ('/qubes-netvm-primary-dns', '/qubes-netvm-secondary-dns'): - ns_maybe = qdb.read(i) - if ns_maybe is None: - continue - try: - qubesdb_dns.append(IPv4Address(ns_maybe.decode("ascii", "strict"))) - except (UnicodeDecodeError, ValueError): - pass - preamble = [ - 'add table ip qubes', - # Add the chain so that the subsequent delete will work. If the chain already - # exists this is a harmless no-op. - 'add chain ip qubes dnat-dns', - # Delete the chain so that if the chain already exists, it will be removed. - # The removal of the old chain and addition of the new one happen as a single - # atomic operation, so there is no period where neither chain is present or - # where both are present. - 'delete chain ip qubes dnat-dns', - ] - rules = [ - 'table ip qubes {', - 'chain dnat-dns {', - 'type nat hook prerouting priority dstnat; policy accept;', - ] + nft_cmd = [] dns_resolved = get_dns_resolved() - if not dns_resolved: - # User has no IPv4 DNS set in sys-net. Maybe IPv6 only environment. - # Or maybe user wants to enforce DNS-Over-HTTPS. - # Drop IPv4 DNS requests to qubesdb_dns addresses. - for vm_nameserver in qubesdb_dns: - vm_ns_ = str(vm_nameserver) - rules += [ - f"ip daddr {vm_ns_} udp dport 53 drop", - f"ip daddr {vm_ns_} tcp dport 53 drop", - ] - else: - for vm_nameserver, dest in zip(qubesdb_dns, cycle(dns_resolved)): - vm_ns_ = str(vm_nameserver) - dns_ = str(dest) - rules += [ - f"ip daddr {vm_ns_} udp dport 53 dnat to {dns_}", - f"ip daddr {vm_ns_} tcp dport 53 dnat to {dns_}", - ] - rules += ["}", "}"] + for family in [4, 6]: + ip46 = '6' if family == 6 else '' + dns_servers = [dns_ip for dns_ip in dns_resolved if dns_ip.version == family] + qubesdb_dns = [] + for i in (f"/qubes-netvm-primary-dns{ip46}", f"/qubes-netvm-secondary-dns{ip46}"): + ns_maybe = qdb.read(i) + if ns_maybe is None: + continue + try: + qubesdb_dns.append(ip_address(ns_maybe.decode("ascii", "strict"))) + except (UnicodeDecodeError, ValueError): + pass + preamble = [ + f"add table ip{ip46} qubes", + # Add the chain so that the subsequent delete will work. If the chain already + # exists this is a harmless no-op. + f"add chain ip{ip46} qubes dnat-dns", + # Delete the chain so that if the chain already exists, it will be removed. + # The removal of the old chain and addition of the new one happen as a single + # atomic operation, so there is no period where neither chain is present or + # where both are present. + f"delete chain ip{ip46} qubes dnat-dns", + ] + rules = [ + f"table ip{ip46} qubes {{", + 'chain custom-dnat-dns {}', + 'chain dnat-dns {', + 'type nat hook prerouting priority dstnat; policy accept;', + 'jump custom-dnat-dns', + ] + if not dns_servers: + # User has no DNS set in NetVM. + # Or maybe user wants to enforce DNS-Over-HTTPS. + # Drop DNS requests to qubesdb_dns addresses. + for vm_nameserver in qubesdb_dns: + vm_ns_ = str(vm_nameserver) + rules += [ + f"ip{ip46} daddr {vm_ns_} udp dport 53 drop", + f"ip{ip46} daddr {vm_ns_} tcp dport 53 drop", + ] + else: + for (vm_nameserver, dest) in zip(qubesdb_dns, cycle(dns_servers)): + vm_ns_ = str(vm_nameserver) + dns_ = str(dest) + if dest is None or (vm_nameserver == dest and len(qubesdb_dns) == 0): + rules += [ + f"ip{ip46} daddr {vm_ns_} tcp dport 53 reject with icmp{ip46} type host-unreachable", + f"ip{ip46} daddr {vm_ns_} udp dport 53 reject with icmp{ip46} type host-unreachable", + ] + else: + rules += [ + f"ip{ip46} daddr {vm_ns_} udp dport 53 dnat to {dns_}", + f"ip{ip46} daddr {vm_ns_} tcp dport 53 dnat to {dns_}", + ] + rules += ["}", "}"] - # check if new rules are the same as the old ones - if so, don't reload - # and return that info via exit code - try: - old_rules = subprocess.check_output( - ["nft", "list", "chain", "ip", "qubes", "dnat-dns"]).decode().splitlines() - except subprocess.CalledProcessError: - old_rules = [] - old_rules = [line.strip() for line in old_rules] + # check if new rules are the same as the old ones - if so, don't reload + # and return that info via exit code + try: + old_rules = subprocess.check_output( + ["nft", "list", "chain", f"ip{ip46}", "qubes", "dnat-dns"]).decode().splitlines() + except subprocess.CalledProcessError: + old_rules = [] + old_rules = [line.strip() for line in old_rules] - if old_rules == rules: - sys.exit(100) + if old_rules == rules: + continue - os.execvp("nft", ("nft", "--", "\n".join(preamble + rules))) + nft_cmd += [preamble, rules] + + if not nft_cmd: + sys.exit(100) + os.execvp("nft", ("nft", "--", "\n".join(nft_cmd))) if __name__ == '__main__': install_firewall_rules(get_dns_resolved()) diff --git a/network/setup-ip b/network/setup-ip index 7ee3c2d4..3c22a749 100755 --- a/network/setup-ip +++ b/network/setup-ip @@ -25,7 +25,9 @@ configure_network () { local gateway6="$8" local primary_dns="$9" local secondary_dns="${10}" - local custom="${11}" + local primary_dns6="${11}" + local secondary_dns6="${12}" + local custom="${13}" ip -- address replace "$ip/$netmask" dev "$INTERFACE" if [[ "$custom" = false ]]; then @@ -64,16 +66,21 @@ configure_network () { if [ -h /etc/resolv.conf ]; then rm -f /etc/resolv.conf fi - echo > /etc/resolv.conf + echo -n > /etc/resolv.conf if ! qsvc disable-dns-server ; then - echo "nameserver $primary_dns" > /etc/resolv.conf + if [ -n "$primary_dns6" ]; then + echo "nameserver $primary_dns6" >> /etc/resolv.conf + echo "nameserver $secondary_dns6" >> /etc/resolv.conf + fi + echo "nameserver $primary_dns" >> /etc/resolv.conf echo "nameserver $secondary_dns" >> /etc/resolv.conf fi fi if [ -x /usr/bin/resolvectl ] && \ systemctl is-enabled -q systemd-resolved.service && \ ! qsvc disable-dns-server ; then - resolvectl dns "$INTERFACE" "$primary_dns" "$secondary_dns" + resolvectl dns "$INTERFACE" "$primary_dns6" "$secondary_dns6" \ + "$primary_dns" "$secondary_dns" fi } @@ -88,7 +95,9 @@ configure_network_nm () { local gateway6="$8" local primary_dns="$9" local secondary_dns="${10}" - local custom="${11}" + local primary_dns6="${11}" + local secondary_dns6="${12}" + local custom="${13}" local prefix local prefix6 @@ -119,6 +128,10 @@ __EOF__ if ! qsvc disable-dns-server ; then ip4_nm_config="${ip4_nm_config} dns=${primary_dns};${secondary_dns}" + if [ -n "$primary_dns6" ]; then + ip6_nm_config="${ip6_nm_config} +dns=${primary_dns6};${secondary_dns6}" + fi fi if ! qsvc disable-default-route ; then ip4_nm_config="${ip4_nm_config} @@ -240,6 +253,8 @@ if [ "$ACTION" == "add" ]; then primary_dns=$(/usr/bin/qubesdb-read /qubes-primary-dns 2>/dev/null) || primary_dns= secondary_dns=$(/usr/bin/qubesdb-read /qubes-secondary-dns 2>/dev/null) || secondary_dns= + primary_dns6=$(/usr/bin/qubesdb-read /qubes-primary-dns6 2>/dev/null) || primary_dns6= + secondary_dns6=$(/usr/bin/qubesdb-read /qubes-secondary-dns6 2>/dev/null) || secondary_dns6= /lib/systemd/systemd-sysctl \ "--prefix=/net/ipv4/conf/all" \ "--prefix=/net/ipv4/neigh/all" \ @@ -253,9 +268,9 @@ if [ "$ACTION" == "add" ]; then if [ -n "$ip4" ]; then # If NetworkManager is enabled, let it configure the network if qsvc network-manager && [ -e /usr/bin/nmcli ]; then - configure_network_nm "$MAC" "$INTERFACE" "$ip4" "$ip6" "$netmask" "$netmask6" "$gateway" "$gateway6" "$primary_dns" "$secondary_dns" "$custom" + configure_network_nm "$MAC" "$INTERFACE" "$ip4" "$ip6" "$netmask" "$netmask6" "$gateway" "$gateway6" "$primary_dns" "$secondary_dns" "$primary_dns6" "$secondary_dns6" "$custom" else - configure_network "$MAC" "$INTERFACE" "$ip4" "$ip6" "$netmask" "$netmask6" "$gateway" "$gateway6" "$primary_dns" "$secondary_dns" "$custom" + configure_network "$MAC" "$INTERFACE" "$ip4" "$ip6" "$netmask" "$netmask6" "$gateway" "$gateway6" "$primary_dns" "$secondary_dns" "$primary_dns6" "$secondary_dns6" "$custom" fi network=$(qubesdb-read /qubes-netvm-network 2>/dev/null) || network= diff --git a/qubes-rpc/post-install.d/10-qubes-core-agent-features.sh b/qubes-rpc/post-install.d/10-qubes-core-agent-features.sh index f9d85fcb..e065b72e 100755 --- a/qubes-rpc/post-install.d/10-qubes-core-agent-features.sh +++ b/qubes-rpc/post-install.d/10-qubes-core-agent-features.sh @@ -119,6 +119,8 @@ advertise_systemd_service() { done } +qvm-features-request supported-feature.ipv6dns=1 + advertise_systemd_service network-manager NetworkManager.service \ network-manager.service advertise_systemd_service modem-manager ModemManager.service From 5f232ea49908c00e222268479f1029643557768f Mon Sep 17 00:00:00 2001 From: 3np <3np@example.com> Date: Sun, 6 Jul 2025 20:04:00 +0000 Subject: [PATCH 2/5] setup-ip: oneshot resolv.conf update --- network/setup-ip | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/network/setup-ip b/network/setup-ip index 3c22a749..a51d4fb2 100755 --- a/network/setup-ip +++ b/network/setup-ip @@ -66,14 +66,15 @@ configure_network () { if [ -h /etc/resolv.conf ]; then rm -f /etc/resolv.conf fi - echo -n > /etc/resolv.conf - if ! qsvc disable-dns-server ; then - if [ -n "$primary_dns6" ]; then - echo "nameserver $primary_dns6" >> /etc/resolv.conf - echo "nameserver $secondary_dns6" >> /etc/resolv.conf - fi - echo "nameserver $primary_dns" >> /etc/resolv.conf - echo "nameserver $secondary_dns" >> /etc/resolv.conf + if qsvc disable-dns-server ; then + echo -n > /etc/resolv.conf + else + cat < /etc/resolv.conf +nameserver ${primary_dns6} +nameserver ${secondary_dns6} +nameserver ${primary_dns} +nameserver ${secondary_dns} +EOF fi fi if [ -x /usr/bin/resolvectl ] && \ From 3fdf341c4addc4cb407697da7e10846ee9402d7d Mon Sep 17 00:00:00 2001 From: 3np <3np@example.com> Date: Mon, 7 Jul 2025 22:02:05 +0000 Subject: [PATCH 3/5] chore: remove unused argument to install_firewall_rules --- network/qubes-setup-dnat-to-ns | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/network/qubes-setup-dnat-to-ns b/network/qubes-setup-dnat-to-ns index 93225921..29bd217f 100755 --- a/network/qubes-setup-dnat-to-ns +++ b/network/qubes-setup-dnat-to-ns @@ -79,7 +79,7 @@ def get_dns_resolved(): # to ensure static DNS of disconnected network interfaces are not added. return [ip_address(bytes(addr)) for ifindex, family, addr in dns] -def install_firewall_rules(dns): +def install_firewall_rules(): qdb = qubesdb.QubesDB() nft_cmd = [] dns_resolved = get_dns_resolved() @@ -158,4 +158,4 @@ def install_firewall_rules(dns): os.execvp("nft", ("nft", "--", "\n".join(nft_cmd))) if __name__ == '__main__': - install_firewall_rules(get_dns_resolved()) + install_firewall_rules() From f0f95513457146f24b1ae52b862f8df5a4ba1625 Mon Sep 17 00:00:00 2001 From: 3np <3np@example.com> Date: Mon, 7 Jul 2025 22:02:17 +0000 Subject: [PATCH 4/5] fix nft_cmd construction, adding typing annotation --- network/qubes-setup-dnat-to-ns | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/network/qubes-setup-dnat-to-ns b/network/qubes-setup-dnat-to-ns index 29bd217f..d1c53a09 100755 --- a/network/qubes-setup-dnat-to-ns +++ b/network/qubes-setup-dnat-to-ns @@ -21,13 +21,14 @@ from __future__ import annotations +from typing import List from itertools import cycle import subprocess import sys import dbus import qubesdb -from ipaddress import ip_address +from ipaddress import ip_address, IPv4Address, IPv6Address import os def get_dns_resolv_conf(): @@ -81,12 +82,12 @@ def get_dns_resolved(): def install_firewall_rules(): qdb = qubesdb.QubesDB() - nft_cmd = [] - dns_resolved = get_dns_resolved() + nft_cmd: List[str] = [] + dns_resolved: List[IPv4Address | IPv6Address] = get_dns_resolved() for family in [4, 6]: ip46 = '6' if family == 6 else '' dns_servers = [dns_ip for dns_ip in dns_resolved if dns_ip.version == family] - qubesdb_dns = [] + qubesdb_dns: List[IPv4Address | IPv6Address] = [] for i in (f"/qubes-netvm-primary-dns{ip46}", f"/qubes-netvm-secondary-dns{ip46}"): ns_maybe = qdb.read(i) if ns_maybe is None: @@ -151,7 +152,7 @@ def install_firewall_rules(): if old_rules == rules: continue - nft_cmd += [preamble, rules] + nft_cmd += [*preamble, *rules] if not nft_cmd: sys.exit(100) From c0c7d420ec656b4035ea328da4bc101a26ae8153 Mon Sep 17 00:00:00 2001 From: 3np <3np@example.com> Date: Thu, 10 Jul 2025 19:47:12 +0000 Subject: [PATCH 5/5] revert conditional insertion of host-unreachable response --- network/qubes-setup-dnat-to-ns | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/network/qubes-setup-dnat-to-ns b/network/qubes-setup-dnat-to-ns index d1c53a09..61b3a23d 100755 --- a/network/qubes-setup-dnat-to-ns +++ b/network/qubes-setup-dnat-to-ns @@ -128,16 +128,10 @@ def install_firewall_rules(): for (vm_nameserver, dest) in zip(qubesdb_dns, cycle(dns_servers)): vm_ns_ = str(vm_nameserver) dns_ = str(dest) - if dest is None or (vm_nameserver == dest and len(qubesdb_dns) == 0): - rules += [ - f"ip{ip46} daddr {vm_ns_} tcp dport 53 reject with icmp{ip46} type host-unreachable", - f"ip{ip46} daddr {vm_ns_} udp dport 53 reject with icmp{ip46} type host-unreachable", - ] - else: - rules += [ - f"ip{ip46} daddr {vm_ns_} udp dport 53 dnat to {dns_}", - f"ip{ip46} daddr {vm_ns_} tcp dport 53 dnat to {dns_}", - ] + rules += [ + f"ip{ip46} daddr {vm_ns_} udp dport 53 dnat to {dns_}", + f"ip{ip46} daddr {vm_ns_} tcp dport 53 dnat to {dns_}", + ] rules += ["}", "}"] # check if new rules are the same as the old ones - if so, don't reload