aboutsummaryrefslogtreecommitdiff
path: root/tests/installer-steps/test_configure_tunnel_dns_over_tls.py
blob: 89677d8c4896a84fb56b98a4e588c2909f577c03 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
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()