diff options
| author | Craig Jennings <c@cjennings.net> | 2026-09-13 07:02:39 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-09-13 07:02:39 -0500 |
| commit | 2e303a07d1a6e4de85277a34669f8da61f7fdb6e (patch) | |
| tree | f428bcf0ce3a69cfb4ef589e16d7f262f1fd5d07 | |
| parent | e73b5a48196a961608a8d1d69a4bd309143fb8d4 (diff) | |
| download | archsetup-2e303a07d1a6e4de85277a34669f8da61f7fdb6e.tar.gz archsetup-2e303a07d1a6e4de85277a34669f8da61f7fdb6e.zip | |
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.
| -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() |
