diff options
| -rwxr-xr-x | archsetup | 39 | ||||
| -rw-r--r-- | scripts/testing/tests/test_config_applied.py | 3 | ||||
| -rw-r--r-- | tests/installer-steps/test_configure_tunnel_dns_over_tls.py | 147 |
3 files changed, 188 insertions, 1 deletions
@@ -1852,6 +1852,8 @@ EOF dns=systemd-resolved EOF + configure_tunnel_dns_over_tls + # Note: If Docker containers have DNS issues, systemd-resolved's stub resolver # (127.0.0.53) may be the cause. Fix: configure Docker to use direct DNS, or # disable systemd-resolved and use /etc/resolv.conf directly. (2026-01-18) @@ -1861,6 +1863,43 @@ EOF run_task "linking resolv.conf to systemd-resolved" ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf } +configure_tunnel_dns_over_tls() { + # The resolved drop-in above pins DNSOverTLS=yes for every link. Proton + # VPN (proton0) and the static Proton WireGuard profiles (wgpvpn) push + # an in-tunnel resolver, 10.2.0.1, that answers plain port 53 and never + # completes TLS on 853, so with strict DoT every lookup through the + # tunnel hangs (2026-09-09). The Proton client deletes and recreates + # its NM profile on every connect, so a per-profile dns-over-tls + # setting can't stick and the net doctor's per-link repair is + # session-only there. A NetworkManager [connection-*] default matched + # on the interface names is what NM pushes to resolved on every + # activation, with no script and no race; wifi and everything else keep + # the strict setting. Verified live on ratio 2026-09-10 and velox + # 2026-09-12. + # + # $1 is the conf.d directory, defaulting to the system's so tests can + # run against a temp dir. + local confdir="${1:-/etc/NetworkManager/conf.d}" + local dropin="$confdir/tunnel-dns-over-tls.conf" + + action="turning DNS over TLS off for the Proton tunnel links" && display "task" "$action" + + mkdir -p "$confdir" 2>> "$logfile" || { error_warn "$action" "$?"; return 1; } + cat > "$dropin" << 'NMEOF' 2>> "$logfile" || { error_warn "$action" "$?"; return 1; } +# Proton VPN (proton0) and the static Proton WireGuard profiles (wgpvpn) push +# an in-tunnel resolver (10.2.0.1) that answers plain port 53 and never +# completes TLS on 853. The global resolved drop-in pins DNSOverTLS=yes, and +# the Proton client recreates its profile on every connect and sets no +# per-link mode, so every lookup through the tunnel fails. Default DNS over +# TLS off for those links only; wifi and everything else keep the strict +# setting. Diagnosed 2026-09-09/10. +[connection-tunnel-dot] +match-device=interface-name:proton0,interface-name:wgpvpn +connection.dns-over-tls=0 +NMEOF + chmod 644 "$dropin" 2>> "$logfile" || error_warn "$action" "$?" +} + configure_backlight_access() { # Screen backlight and keyboard-LED brightness, writable by the video # group. Arch ships brightnessctl with no udev rules: it relies on diff --git a/scripts/testing/tests/test_config_applied.py b/scripts/testing/tests/test_config_applied.py index 00c410e..08ffc1b 100644 --- a/scripts/testing/tests/test_config_applied.py +++ b/scripts/testing/tests/test_config_applied.py @@ -40,7 +40,8 @@ def test_makepkg_options_trimmed(host): @pytest.mark.attribution("archsetup") -@pytest.mark.parametrize("rel", ["dns.conf", "wifi-privacy.conf"]) +@pytest.mark.parametrize("rel", ["dns.conf", "wifi-privacy.conf", + "tunnel-dns-over-tls.conf"]) def test_networkmanager_dropin(host, rel): assert host.file("/etc/NetworkManager/conf.d/%s" % rel).exists diff --git a/tests/installer-steps/test_configure_tunnel_dns_over_tls.py b/tests/installer-steps/test_configure_tunnel_dns_over_tls.py new file mode 100644 index 0000000..89677d8 --- /dev/null +++ b/tests/installer-steps/test_configure_tunnel_dns_over_tls.py @@ -0,0 +1,147 @@ +"""Test configure_tunnel_dns_over_tls — per-link DoT off for VPN tunnels. + +The resolved drop-in pins DNSOverTLS=yes globally. Proton VPN (proton0) and +the static Proton WireGuard profiles (wgpvpn) push an in-tunnel resolver, +10.2.0.1, that answers plain port 53 and never completes TLS on 853, so +every lookup through the tunnel hangs. The Proton client recreates its NM +profile on each connect, so a per-profile setting can't stick; a +NetworkManager [connection-*] default matched on the interface names is +what NM pushes to resolved on every activation. Diagnosed 2026-09-09/10, +verified live on ratio and velox. + +Method: sed-extract configure_tunnel_dns_over_tls from the real +`archsetup`, point it at a temp conf.d, and assert on the file it writes. + + python3 -m unittest tests.installer-steps.test_configure_tunnel_dns_over_tls +""" + +import os +import re +import stat +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") +FILENAME = "tunnel-dns-over-tls.conf" + + +def run(confdir): + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + source <(sed -n '/^configure_tunnel_dns_over_tls() {{/,/^}}/p' "{ARCHSETUP}") + configure_tunnel_dns_over_tls "{confdir}" + echo "RC=$?" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rc_of(r): + m = re.search(r"^RC=(\d+)$", r.stdout, re.M) + assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) + return int(m.group(1)) + + +def body(confdir): + with open(os.path.join(confdir, FILENAME)) as f: + return f.read() + + +def directives(text): + """The non-comment, non-blank lines — what NM actually parses.""" + return [l.strip() for l in text.splitlines() + if l.strip() and not l.lstrip().startswith("#")] + + +class ConfigureTunnelDnsOverTls(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_writes_a_connection_default_that_turns_dot_off(self): + with tempfile.TemporaryDirectory() as d: + r = run(d) + self.assertEqual(rc_of(r), 0) + lines = directives(body(d)) + self.assertIn("[connection-tunnel-dot]", lines) + self.assertIn("connection.dns-over-tls=0", lines) + + def test_matches_both_proton_interfaces_and_nothing_else(self): + # proton0 is the Proton client; wgpvpn is the static WireGuard + # profiles. A match-device line without both leaves one tunnel + # broken; one with a wildcard would turn DoT off for wifi too. + with tempfile.TemporaryDirectory() as d: + run(d) + match = [l for l in directives(body(d)) if l.startswith("match-device=")] + self.assertEqual(len(match), 1) + devices = match[0].split("=", 1)[1].split(",") + self.assertEqual(sorted(devices), + ["interface-name:proton0", "interface-name:wgpvpn"]) + + def test_explains_why_the_global_setting_is_overridden_per_link(self): + # The file contradicts the resolved drop-in next to it, so it has + # to carry the reason: the in-tunnel resolver that never completes + # TLS, and why the Proton client can't hold a per-profile setting. + with tempfile.TemporaryDirectory() as d: + run(d) + text = body(d).lower() + self.assertIn("10.2.0.1", text) + self.assertIn("853", text) + self.assertIn("recreates", text) + + def test_drop_in_is_world_readable_not_writable(self): + with tempfile.TemporaryDirectory() as d: + run(d) + mode = stat.S_IMODE(os.stat(os.path.join(d, FILENAME)).st_mode) + self.assertEqual(mode, 0o644) + + # ---------------------------------------------------------- boundary ---- + def test_running_twice_leaves_one_identical_file(self): + with tempfile.TemporaryDirectory() as d: + run(d) + first = body(d) + r = run(d) + self.assertEqual(rc_of(r), 0) + self.assertEqual(first, body(d)) + self.assertEqual(os.listdir(d), [FILENAME]) + + def test_absent_directory_is_created(self): + with tempfile.TemporaryDirectory() as d: + nested = os.path.join(d, "etc", "NetworkManager", "conf.d") + r = run(nested) + self.assertEqual(rc_of(r), 0) + self.assertIn("connection.dns-over-tls=0", directives(body(nested))) + + def test_leaves_sibling_drop_ins_alone(self): + # dns.conf, wifi-privacy.conf and wifi-powersave-off.conf share the + # directory; this step must add a file, never rewrite the dir. + with tempfile.TemporaryDirectory() as d: + sibling = os.path.join(d, "dns.conf") + with open(sibling, "w") as f: + f.write("[main]\ndns=systemd-resolved\n") + run(d) + with open(sibling) as f: + self.assertEqual(f.read(), "[main]\ndns=systemd-resolved\n") + + # ------------------------------------------------------------- error ---- + @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits") + def test_unwritable_directory_warns_and_does_not_crash(self): + with tempfile.TemporaryDirectory() as d: + confdir = os.path.join(d, "ro") + os.mkdir(confdir) + os.chmod(confdir, 0o500) + try: + r = run(confdir) + self.assertIn("WARN:", r.stdout) + self.assertNotEqual(rc_of(r), 0) + finally: + os.chmod(confdir, 0o700) + + +if __name__ == "__main__": + unittest.main() |
