From 2e303a07d1a6e4de85277a34669f8da61f7fdb6e Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 13 Sep 2026 07:02:39 -0500 Subject: feat(install): default DNS over TLS off on the Proton tunnel links The resolved drop-in 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 every lookup through the tunnel hung. The Proton client recreates its NetworkManager profile on each connect, so a per-profile setting can't stick. NM pushes a [connection-tunnel-dot] default matched on those two interface names to resolved on every activation. Wifi and everything else keep the strict setting. Ratio and velox already carry the drop-in by hand. This makes a rebuild carry it too. --- .../test_configure_tunnel_dns_over_tls.py | 147 +++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/installer-steps/test_configure_tunnel_dns_over_tls.py (limited to 'tests') 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() -- cgit v1.2.3