]>
Commit | Line | Data |
---|---|---|
4ddb85b1 MP |
1 | #!/usr/bin/env python3 |
2 | # | |
3 | # networkd integration test | |
4 | # This uses temporary configuration in /run and temporary veth devices, and | |
5 | # does not write anything on disk or change any system configuration; | |
6 | # but it assumes (and checks at the beginning) that networkd is not currently | |
7 | # running. | |
8 | # This can be run on a normal installation, in QEMU, nspawn, or LXC. | |
9 | # ATTENTION: This uses the *installed* networkd, not the one from the built | |
10 | # source tree. | |
11 | # | |
12 | # (C) 2015 Canonical Ltd. | |
13 | # Author: Martin Pitt <martin.pitt@ubuntu.com> | |
14 | # | |
15 | # systemd is free software; you can redistribute it and/or modify it | |
16 | # under the terms of the GNU Lesser General Public License as published by | |
17 | # the Free Software Foundation; either version 2.1 of the License, or | |
18 | # (at your option) any later version. | |
19 | ||
20 | # systemd is distributed in the hope that it will be useful, but | |
21 | # WITHOUT ANY WARRANTY; without even the implied warranty of | |
22 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |
23 | # Lesser General Public License for more details. | |
24 | # | |
25 | # You should have received a copy of the GNU Lesser General Public License | |
26 | # along with systemd; If not, see <http://www.gnu.org/licenses/>. | |
27 | ||
28 | import os | |
29 | import sys | |
30 | import time | |
31 | import unittest | |
32 | import tempfile | |
33 | import subprocess | |
34 | import shutil | |
35 | ||
36 | networkd_active = subprocess.call(['systemctl', 'is-active', '--quiet', | |
37 | 'systemd-networkd']) == 0 | |
38 | have_dnsmasq = shutil.which('dnsmasq') | |
39 | ||
40 | ||
41 | @unittest.skipIf(networkd_active, | |
42 | 'networkd is already active') | |
43 | class ClientTestBase: | |
44 | def setUp(self): | |
45 | self.iface = 'test_eth42' | |
46 | self.if_router = 'router_eth42' | |
47 | self.workdir_obj = tempfile.TemporaryDirectory() | |
48 | self.workdir = self.workdir_obj.name | |
49 | self.config = '/run/systemd/network/test_eth42.network' | |
50 | os.makedirs(os.path.dirname(self.config), exist_ok=True) | |
51 | ||
52 | # avoid "Failed to open /dev/tty" errors in containers | |
53 | os.environ['SYSTEMD_LOG_TARGET'] = 'journal' | |
54 | ||
55 | # determine path to systemd-networkd-wait-online | |
56 | for p in ['/usr/lib/systemd/systemd-networkd-wait-online', | |
57 | '/lib/systemd/systemd-networkd-wait-online']: | |
58 | if os.path.exists(p): | |
59 | self.networkd_wait_online = p | |
60 | break | |
61 | else: | |
62 | self.fail('systemd-networkd-wait-online not found') | |
63 | ||
64 | # get current journal cursor | |
65 | out = subprocess.check_output(['journalctl', '-b', '--quiet', | |
66 | '--no-pager', '-n0', '--show-cursor'], | |
67 | universal_newlines=True) | |
68 | self.assertTrue(out.startswith('-- cursor:')) | |
69 | self.journal_cursor = out.split()[-1] | |
70 | ||
71 | def tearDown(self): | |
72 | self.shutdown_iface() | |
73 | if os.path.exists(self.config): | |
74 | os.unlink(self.config) | |
75 | subprocess.call(['systemctl', 'stop', 'systemd-networkd']) | |
76 | ||
77 | def show_journal(self, unit): | |
78 | '''Show journal of given unit since start of the test''' | |
79 | ||
80 | print('---- %s ----' % unit) | |
81 | sys.stdout.flush() | |
82 | subprocess.call(['journalctl', '-b', '--no-pager', '--quiet', | |
83 | '--cursor', self.journal_cursor, '-u', unit]) | |
84 | ||
85 | def create_iface(self, ipv6=False): | |
86 | '''Create test interface with DHCP server behind it''' | |
87 | ||
88 | raise NotImplementedError('must be implemented by a subclass') | |
89 | ||
90 | def shutdown_iface(self): | |
91 | '''Remove test interface and stop DHCP server''' | |
92 | ||
93 | raise NotImplementedError('must be implemented by a subclass') | |
94 | ||
95 | def print_server_log(self): | |
96 | '''Print DHCP server log for debugging failures''' | |
97 | ||
98 | raise NotImplementedError('must be implemented by a subclass') | |
99 | ||
100 | def do_test(self, coldplug=True, ipv6=False, extra_opts='', | |
101 | online_timeout=10, dhcp_mode='yes'): | |
102 | with open(self.config, 'w') as f: | |
103 | f.write('''[Match] | |
104 | Name=%s | |
105 | [Network] | |
106 | DHCP=%s | |
107 | %s''' % (self.iface, dhcp_mode, extra_opts)) | |
108 | ||
109 | if coldplug: | |
110 | # create interface first, then start networkd | |
111 | self.create_iface(ipv6=ipv6) | |
112 | subprocess.check_call(['systemctl', 'start', 'systemd-networkd']) | |
113 | else: | |
114 | # start networkd first, then create interface | |
115 | subprocess.check_call(['systemctl', 'start', 'systemd-networkd']) | |
116 | self.create_iface(ipv6=ipv6) | |
117 | ||
118 | try: | |
119 | subprocess.check_call([self.networkd_wait_online, '--interface', | |
120 | self.iface, '--timeout=%i' % online_timeout]) | |
121 | ||
122 | if ipv6: | |
123 | # check iface state and IP 6 address; FIXME: we need to wait a bit | |
124 | # longer, as the iface is "configured" already with IPv4 *or* | |
125 | # IPv6, but we want to wait for both | |
126 | for timeout in range(10): | |
127 | out = subprocess.check_output(['ip', 'a', 'show', 'dev', self.iface]) | |
128 | if b'state UP' in out and b'inet6 2600' in out and b'inet 192.168' in out: | |
129 | break | |
130 | time.sleep(1) | |
131 | else: | |
132 | self.fail('timed out waiting for IPv6 configuration') | |
133 | ||
134 | self.assertRegex(out, b'inet6 2600::.* scope global .*dynamic') | |
135 | self.assertRegex(out, b'inet6 fe80::.* scope link') | |
136 | else: | |
137 | # should have link-local address on IPv6 only | |
138 | out = subprocess.check_output(['ip', '-6', 'a', 'show', 'dev', self.iface]) | |
139 | self.assertRegex(out, b'inet6 fe80::.* scope link') | |
140 | self.assertNotIn(b'scope global', out) | |
141 | ||
142 | # should have IPv4 address | |
143 | out = subprocess.check_output(['ip', '-4', 'a', 'show', 'dev', self.iface]) | |
144 | self.assertIn(b'state UP', out) | |
145 | self.assertRegex(out, b'inet 192.168.5.\d+/.* scope global dynamic') | |
146 | ||
147 | # check networkctl state | |
148 | out = subprocess.check_output(['networkctl']) | |
149 | self.assertRegex(out, ('%s\s+ether\s+routable\s+unmanaged' % self.if_router).encode()) | |
150 | self.assertRegex(out, ('%s\s+ether\s+routable\s+configured' % self.iface).encode()) | |
151 | ||
152 | out = subprocess.check_output(['networkctl', 'status', self.iface]) | |
153 | self.assertRegex(out, b'Type:\s+ether') | |
154 | self.assertRegex(out, b'State:\s+routable.*configured') | |
155 | self.assertRegex(out, b'Address:\s+192.168.5.\d+') | |
156 | if ipv6: | |
157 | self.assertRegex(out, b'2600::') | |
158 | else: | |
159 | self.assertNotIn(b'2600::', out) | |
160 | self.assertRegex(out, b'fe80::') | |
161 | self.assertRegex(out, b'Gateway:\s+192.168.5.1') | |
162 | self.assertRegex(out, b'DNS:\s+192.168.5.1') | |
163 | except (AssertionError, subprocess.CalledProcessError): | |
164 | # show networkd status, journal, and DHCP server log on failure | |
165 | with open(self.config) as f: | |
166 | print('\n---- %s ----\n%s' % (self.config, f.read())) | |
167 | print('---- interface status ----') | |
168 | sys.stdout.flush() | |
169 | subprocess.call(['ip', 'a', 'show', 'dev', self.iface]) | |
170 | print('---- networkctl status %s ----' % self.iface) | |
171 | sys.stdout.flush() | |
172 | subprocess.call(['networkctl', 'status', self.iface]) | |
173 | self.show_journal('systemd-networkd.service') | |
174 | self.print_server_log() | |
175 | raise | |
176 | ||
177 | # verify resolv.conf if it gets dynamically managed | |
178 | if os.path.islink('/etc/resolv.conf'): | |
179 | for timeout in range(50): | |
180 | with open('/etc/resolv.conf') as f: | |
181 | contents = f.read() | |
182 | if 'nameserver 192.168.5.1\n' in contents: | |
183 | break | |
184 | # resolv.conf can have at most three nameservers; if we already | |
185 | # have three different ones, that's also okay | |
186 | if contents.count('nameserver ') >= 3: | |
187 | break | |
188 | time.sleep(0.1) | |
189 | else: | |
190 | self.fail('nameserver 192.168.5.1 not found in /etc/resolv.conf') | |
191 | ||
192 | if not coldplug: | |
193 | # check post-down.d hook | |
194 | self.shutdown_iface() | |
195 | ||
196 | def test_coldplug_dhcp_yes_ip4(self): | |
197 | # we have a 12s timeout on RA, so we need to wait longer | |
198 | self.do_test(coldplug=True, ipv6=False, online_timeout=15) | |
199 | ||
200 | def test_coldplug_dhcp_yes_ip4_no_ra(self): | |
201 | # with disabling RA explicitly things should be fast | |
202 | self.do_test(coldplug=True, ipv6=False, | |
203 | extra_opts='IPv6AcceptRouterAdvertisements=False') | |
204 | ||
205 | def test_coldplug_dhcp_ip4_only(self): | |
206 | # we have a 12s timeout on RA, so we need to wait longer | |
207 | self.do_test(coldplug=True, ipv6=False, dhcp_mode='ipv4', | |
208 | online_timeout=15) | |
209 | ||
210 | def test_coldplug_dhcp_ip4_only_no_ra(self): | |
211 | # with disabling RA explicitly things should be fast | |
212 | self.do_test(coldplug=True, ipv6=False, dhcp_mode='ipv4', | |
213 | extra_opts='IPv6AcceptRouterAdvertisements=False') | |
214 | ||
215 | def test_coldplug_dhcp_ip6(self): | |
216 | self.do_test(coldplug=True, ipv6=True) | |
217 | ||
218 | def test_hotplug_dhcp_ip4(self): | |
219 | # With IPv4 only we have a 12s timeout on RA, so we need to wait longer | |
220 | self.do_test(coldplug=False, ipv6=False, online_timeout=15) | |
221 | ||
222 | def test_hotplug_dhcp_ip6(self): | |
223 | self.do_test(coldplug=False, ipv6=True) | |
224 | ||
225 | ||
226 | @unittest.skipUnless(have_dnsmasq, 'dnsmasq not installed') | |
227 | class DnsmasqClientTest(ClientTestBase, unittest.TestCase): | |
228 | '''Test networkd client against dnsmasq''' | |
229 | ||
230 | def setUp(self): | |
231 | super().setUp() | |
232 | self.dnsmasq = None | |
233 | ||
234 | def create_iface(self, ipv6=False): | |
235 | '''Create test interface with DHCP server behind it''' | |
236 | ||
237 | # add veth pair | |
238 | subprocess.check_call(['ip', 'link', 'add', 'name', self.iface, 'type', | |
239 | 'veth', 'peer', 'name', self.if_router]) | |
240 | ||
241 | # give our router an IP | |
242 | subprocess.check_call(['ip', 'a', 'flush', 'dev', self.if_router]) | |
243 | subprocess.check_call(['ip', 'a', 'add', '192.168.5.1/24', 'dev', self.if_router]) | |
244 | if ipv6: | |
245 | subprocess.check_call(['ip', 'a', 'add', '2600::1/64', 'dev', self.if_router]) | |
246 | subprocess.check_call(['ip', 'link', 'set', self.if_router, 'up']) | |
247 | ||
248 | # add DHCP server | |
249 | self.dnsmasq_log = os.path.join(self.workdir, 'dnsmasq.log') | |
250 | lease_file = os.path.join(self.workdir, 'dnsmasq.leases') | |
251 | if ipv6: | |
252 | extra_opts = ['--enable-ra', '--dhcp-range=2600::10,2600::20'] | |
253 | else: | |
254 | extra_opts = [] | |
255 | self.dnsmasq = subprocess.Popen( | |
256 | ['dnsmasq', '--keep-in-foreground', '--log-queries', | |
257 | '--log-facility=' + self.dnsmasq_log, '--conf-file=/dev/null', | |
258 | '--dhcp-leasefile=' + lease_file, '--bind-interfaces', | |
259 | '--interface=' + self.if_router, '--except-interface=lo', | |
260 | '--dhcp-range=192.168.5.10,192.168.5.200'] + extra_opts) | |
261 | ||
262 | def shutdown_iface(self): | |
263 | '''Remove test interface and stop DHCP server''' | |
264 | ||
265 | if self.if_router: | |
266 | subprocess.check_call(['ip', 'link', 'del', 'dev', self.if_router]) | |
267 | self.if_router = None | |
268 | if self.dnsmasq: | |
269 | self.dnsmasq.kill() | |
270 | self.dnsmasq.wait() | |
271 | self.dnsmasq = None | |
272 | ||
273 | def print_server_log(self): | |
274 | '''Print DHCP server log for debugging failures''' | |
275 | ||
276 | with open(self.dnsmasq_log) as f: | |
277 | sys.stdout.write('\n\n---- dnsmasq log ----\n%s\n------\n\n' % f.read()) | |
278 | ||
279 | ||
280 | class NetworkdClientTest(ClientTestBase, unittest.TestCase): | |
281 | '''Test networkd client against networkd server''' | |
282 | ||
283 | def setUp(self): | |
284 | super().setUp() | |
285 | self.dnsmasq = None | |
286 | ||
287 | def create_iface(self, ipv6=False): | |
288 | '''Create test interface with DHCP server behind it''' | |
289 | ||
290 | # run "router-side" networkd in own mount namespace to shield it from | |
291 | # "client-side" configuration and networkd | |
292 | (fd, script) = tempfile.mkstemp(prefix='networkd-router.sh') | |
293 | self.addCleanup(os.remove, script) | |
294 | with os.fdopen(fd, 'w+') as f: | |
295 | f.write('''#!/bin/sh -eu | |
296 | mkdir -p /run/systemd/network | |
297 | mkdir -p /run/systemd/netif | |
298 | mount -t tmpfs none /run/systemd/network | |
299 | mount -t tmpfs none /run/systemd/netif | |
300 | [ ! -e /run/dbus ] || mount -t tmpfs none /run/dbus | |
301 | # create router/client veth pair | |
302 | cat << EOF > /run/systemd/network/test.netdev | |
303 | [NetDev] | |
304 | Name=%(ifr)s | |
305 | Kind=veth | |
306 | ||
307 | [Peer] | |
308 | Name=%(ifc)s | |
309 | EOF | |
310 | ||
311 | cat << EOF > /run/systemd/network/test.network | |
312 | [Match] | |
313 | Name=%(ifr)s | |
314 | ||
315 | [Network] | |
316 | Address=192.168.5.1/24 | |
317 | %(addr6)s | |
318 | DHCPServer=yes | |
319 | ||
320 | [DHCPServer] | |
321 | PoolOffset=10 | |
322 | PoolSize=50 | |
323 | DNS=192.168.5.1 | |
324 | EOF | |
325 | ||
326 | # run networkd as in systemd-networkd.service | |
327 | exec $(systemctl cat systemd-networkd.service | sed -n '/^ExecStart=/ { s/^.*=//; p}') | |
328 | ''' % {'ifr': self.if_router, 'ifc': self.iface, 'addr6': ipv6 and 'Address=2600::1/64' or ''}) | |
329 | ||
330 | os.fchmod(fd, 0o755) | |
331 | ||
332 | subprocess.check_call(['systemd-run', '--unit=networkd-test-router.service', | |
333 | '-p', 'InaccessibleDirectories=-/etc/systemd/network', | |
334 | '-p', 'InaccessibleDirectories=-/run/systemd/network', | |
335 | '-p', 'InaccessibleDirectories=-/run/systemd/netif', | |
336 | '--service-type=notify', script]) | |
337 | ||
338 | # wait until devices got created | |
339 | for timeout in range(50): | |
340 | out = subprocess.check_output(['ip', 'a', 'show', 'dev', self.if_router]) | |
341 | if b'state UP' in out and b'scope global' in out: | |
342 | break | |
343 | time.sleep(0.1) | |
344 | ||
345 | def shutdown_iface(self): | |
346 | '''Remove test interface and stop DHCP server''' | |
347 | ||
348 | if self.if_router: | |
349 | subprocess.check_call(['systemctl', 'stop', 'networkd-test-router.service']) | |
350 | # ensure failed transient unit does not stay around | |
351 | subprocess.call(['systemctl', 'reset-failed', 'networkd-test-router.service']) | |
352 | subprocess.call(['ip', 'link', 'del', 'dev', self.if_router]) | |
353 | self.if_router = None | |
354 | ||
355 | def print_server_log(self): | |
356 | '''Print DHCP server log for debugging failures''' | |
357 | ||
358 | self.show_journal('networkd-test-router.service') | |
359 | ||
360 | @unittest.skip('networkd does not have DHCPv6 server support') | |
361 | def test_hotplug_dhcp_ip6(self): | |
362 | pass | |
363 | ||
364 | @unittest.skip('networkd does not have DHCPv6 server support') | |
365 | def test_coldplug_dhcp_ip6(self): | |
366 | pass | |
367 | ||
368 | ||
369 | if __name__ == '__main__': | |
370 | unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, | |
371 | verbosity=2)) |