aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xarchsetup39
-rw-r--r--scripts/testing/tests/test_config_applied.py3
-rw-r--r--tests/installer-steps/test_configure_tunnel_dns_over_tls.py147
3 files changed, 188 insertions, 1 deletions
diff --git a/archsetup b/archsetup
index df9338a..971177e 100755
--- a/archsetup
+++ b/archsetup
@@ -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()