e3dfa4374bfb10c15845d607bdec54f791ba20d8
[tinc] / test / integration / cmd_fsck.py
1 #!/usr/bin/env python3
2
3 """Test 'tinc fsck' command."""
4
5 import os
6 import sys
7 import typing as T
8
9 from testlib import check
10 from testlib.const import RUN_ACCESS_CHECKS
11 from testlib.log import log
12 from testlib.proc import Tinc, Feature
13 from testlib.util import read_text, read_lines, write_lines, append_line, write_text
14
15 RUN_LEGACY_CHECKS = Feature.LEGACY_PROTOCOL in Tinc().features
16 RUN_EXECUTABILITY_CHECKS = os.name != "nt"
17 RUN_PERMISSION_CHECKS = RUN_EXECUTABILITY_CHECKS
18
19 # Sample RSA key pair (old format). Uses e = 0xFFFF.
20 RSA_N = """
21 BB82C3A9B906E98ABF2D99FF9B320B229F5C1E58EC784762DA1F4D3509FFF78ECA7FFF19BA17073\
22 6CDE458EC8E732DDE2C02009632DF731B4A6BD6C504E50B7B875484506AC1E49FD0DF624F6612F5\
23 64C562BD20F870592A49195023D744963229C35081C8AE48BE2EBB5CC9A0D64924022DC0EB782A3\
24 A8F3EABCA04AA42B24B2A6BD2353A6893A73AE01FA54891DD24BF36CA032F19F7E78C01273334BA\
25 A2ECF36B6998754CB012BC985C975503D945E4D925F6F719ACC8FBA7B18C810FF850C3CCACD6056\
26 5D4FCFE02A98FE793E2D45D481A34D1F90584D096561FF3184C462C606535F3F9BB260541DF0D1F\
27 EB16938FFDEC2FF96ACCC6BD5BFBC19471F6AB
28 """.strip()
29
30 RSA_D = """
31 8CEC9A4316FE45E07900197D8FBB52D3AF01A51C4F8BD08A1E21A662E3CFCF7792AD7680673817B\
32 70AC1888A08B49E8C5835357016D9BF56A0EBDE8B5DF214EC422809BC8D88177F273419116EF2EC\
33 7951453F129768DE9BC31D963515CC7481559E4C0E65C549169F2B94AE68DB944171189DD654DC6\
34 970F2F5843FB7C8E9D057E2B5716752F1F5686811AC075ED3D3CBD06B5D35AE33D01260D9E0560A\
35 F545D0C9D89A31D5EAF96D5422F6567FE8A90E23906B840545805644DFD656E526A686D3B978DD2\
36 71578CA3DA0F7D23FC1252A702A5D597CAE9D4A5BBF6398A75AF72582C7538A7937FB71A2610DCB\
37 C39625B77103FA3B7D0A55177FD98C39CD4A27
38 """.strip()
39
40
41 class Context:
42     """Test context. Used to store paths to configuration files."""
43
44     def __init__(self) -> None:
45         node = Tinc()
46         node.cmd("init", node.name)
47
48         self.node = node
49         self.host = node.sub("hosts", node.name)
50         self.conf = node.sub("tinc.conf")
51         self.rsa_priv = node.sub("rsa_key.priv")
52         self.ec_priv = node.sub("ed25519_key.priv")
53         self.tinc_up = node.sub("tinc-up")
54         self.host_up = node.sub("host-up")
55
56         if os.name == "nt":
57             self.tinc_up = f"{self.tinc_up}.cmd"
58             self.host_up = f"{self.host_up}.cmd"
59
60     def expect_msg(
61         self, msg: str, force: bool = False, code: int = 1, present: bool = True
62     ) -> None:
63         """Checks that tinc output contains (or does not contain) the expected message."""
64         args = ["fsck"]
65         if force:
66             args.insert(0, "--force")
67
68         out, err = self.node.cmd(*args, code=code)
69         if present:
70             check.is_in(msg, out, err)
71         else:
72             check.not_in(msg, out, err)
73
74
75 def test(msg: str) -> Context:
76     """Create test context."""
77     context = Context()
78     log.info("TEST: %s", msg)
79     return context
80
81
82 def remove_pem(config: str) -> T.List[str]:
83     """Remove PEM from a config file, leaving everything else untouched."""
84     key, result = False, []
85     for line in read_lines(config):
86         if line.startswith("-----BEGIN"):
87             key = True
88             continue
89         if line.startswith("-----END"):
90             key = False
91             continue
92         if not key:
93             result.append(line)
94     write_lines(config, result)
95     return result
96
97
98 def extract_pem(config: str) -> T.List[str]:
99     """Extract PEM from a config file, ignoring everything else."""
100     key = False
101     result: T.List[str] = []
102     for line in read_lines(config):
103         if line.startswith("-----BEGIN"):
104             key = True
105             continue
106         if line.startswith("-----END"):
107             return result
108         if key:
109             result.append(line)
110     raise Exception("key not found")
111
112
113 def replace_line(file_path: str, prefix: str, replace: str = "") -> None:
114     """Replace lines in a file that start with the prefix."""
115     lines = read_lines(file_path)
116     lines = [replace if line.startswith(prefix) else line for line in lines]
117     write_lines(file_path, lines)
118
119
120 def test_private_key_var(var: str, file: str) -> None:
121     """Test inline private keys with variable var."""
122     context = test(f"private key variable {var} in file {file}")
123     renamed = os.path.realpath(context.node.sub("renamed_key"))
124     os.rename(src=context.node.sub(file), dst=renamed)
125     append_line(context.host, f"{var} = {renamed}")
126     context.expect_msg("key was found but no private key", present=False, code=0)
127
128
129 def test_private_keys(keyfile: str) -> None:
130     """Test private keys in file keyfile."""
131     context = test(f"fail on broken {keyfile}")
132     keyfile_path = context.node.sub(keyfile)
133     os.truncate(keyfile_path, 0)
134
135     if RUN_LEGACY_CHECKS:
136         context.expect_msg("no private key is known", code=0)
137     else:
138         context.expect_msg("No Ed25519 private key found")
139
140     if RUN_ACCESS_CHECKS:
141         context = test(f"fail on inaccessible {keyfile}")
142         keyfile_path = context.node.sub(keyfile)
143         os.chmod(keyfile_path, 0)
144         context.expect_msg("Error reading", code=0 if RUN_LEGACY_CHECKS else 1)
145
146     if RUN_PERMISSION_CHECKS:
147         context = test(f"warn about unsafe permissions on {keyfile}")
148         keyfile_path = context.node.sub(keyfile)
149         os.chmod(keyfile_path, 0o666)
150         context.expect_msg("unsafe file permissions", code=0)
151
152     if RUN_LEGACY_CHECKS:
153         context = test(f"pass on missing {keyfile} when the other key is present")
154         keyfile_path = context.node.sub(keyfile)
155         os.remove(keyfile_path)
156         context.node.cmd("fsck")
157
158
159 def test_ec_public_key_file_var(context: Context, *paths: str) -> None:
160     """Test EC public keys in config *paths."""
161     ec_pubkey = os.path.realpath(context.node.sub("ec_pubkey"))
162
163     ec_key = ""
164     for line in read_lines(context.host):
165         if line.startswith("Ed25519PublicKey"):
166             _, _, ec_key = line.split()
167             break
168     assert ec_key
169
170     pem = f"""
171 -----BEGIN ED25519 PUBLIC KEY-----
172 {ec_key}
173 -----END ED25519 PUBLIC KEY-----
174 """
175     write_text(ec_pubkey, pem)
176
177     replace_line(context.host, "Ed25519PublicKey")
178
179     config = context.node.sub(*paths)
180     append_line(config, f"Ed25519PublicKeyFile = {ec_pubkey}")
181
182     context.expect_msg("No (usable) public Ed25519", code=0, present=False)
183
184
185 ###############################################################################
186 # Common tests
187 ###############################################################################
188
189 ctx = test("pass freshly created configuration")
190 ctx.node.cmd("fsck")
191
192 ctx = test("fail on missing tinc.conf")
193 os.remove(ctx.conf)
194 ctx.expect_msg("No tinc configuration found")
195
196 for suffix in "up", "down":
197     ctx = test(f"unknown -{suffix} script warning")
198     fake_path = ctx.node.sub(f"fake-{suffix}")
199     write_text(fake_path, "")
200     ctx.expect_msg("Unknown script", code=0)
201
202 ctx = test("fix broken Ed25519 public key with --force")
203 replace_line(ctx.host, "Ed25519PublicKey", "Ed25519PublicKey = foobar")
204 ctx.expect_msg("No (usable) public Ed25519 key", force=True, code=0)
205 ctx.node.cmd("fsck")
206
207 ctx = test("fix missing Ed25519 public key with --force")
208 replace_line(ctx.host, "Ed25519PublicKey")
209 ctx.expect_msg("No (usable) public Ed25519 key", force=True, code=0)
210 ctx.node.cmd("fsck")
211
212 ctx = test("fail when all private keys are missing")
213 os.remove(ctx.ec_priv)
214 if RUN_LEGACY_CHECKS:
215     os.remove(ctx.rsa_priv)
216     ctx.expect_msg("Neither RSA or Ed25519 private")
217 else:
218     ctx.expect_msg("No Ed25519 private")
219
220 ctx = test("warn about missing EC public key and NOT fix without --force")
221 replace_line(ctx.host, "Ed25519PublicKey")
222 ctx.expect_msg("No (usable) public Ed25519", code=0)
223 host = read_text(ctx.host)
224 check.not_in("ED25519 PUBLIC KEY", host)
225
226 ctx = test("fix missing EC public key on --force")
227 replace_line(ctx.host, "Ed25519PublicKey")
228 ctx.expect_msg("Wrote Ed25519 public key", force=True, code=0)
229 host = read_text(ctx.host)
230 check.is_in("ED25519 PUBLIC KEY", host)
231
232 ctx = test("warn about obsolete variables")
233 append_line(ctx.host, "GraphDumpFile = /dev/null")
234 ctx.expect_msg("obsolete variable GraphDumpFile", code=0)
235
236 ctx = test("warn about missing values")
237 append_line(ctx.host, "Weight = ")
238 ctx.expect_msg("No value for variable `Weight")
239
240 ctx = test("warn about duplicate variables")
241 append_line(ctx.host, f"Weight = 0{os.linesep}Weight = 1")
242 ctx.expect_msg("multiple instances of variable Weight", code=0)
243
244 ctx = test("warn about server variables in host config")
245 append_line(ctx.host, "Interface = fake0")
246 ctx.expect_msg("server variable Interface found", code=0)
247
248 ctx = test("warn about host variables in server config")
249 append_line(ctx.conf, "Port = 1337")
250 ctx.expect_msg("host variable Port found", code=0)
251
252 ctx = test("warn about missing Name")
253 replace_line(ctx.conf, "Name =")
254 ctx.expect_msg("without a valid Name")
255
256 test_private_keys("ed25519_key.priv")
257 test_private_key_var("Ed25519PrivateKeyFile", "ed25519_key.priv")
258
259 ctx = test("test EC public key in tinc.conf")
260 test_ec_public_key_file_var(ctx, "tinc.conf")
261
262 ctx = test("test EC public key in hosts/")
263 test_ec_public_key_file_var(ctx, "hosts", ctx.node.name)
264
265 if RUN_ACCESS_CHECKS:
266     ctx = test("fail on inaccessible tinc.conf")
267     os.chmod(ctx.conf, 0)
268     ctx.expect_msg("not running tinc as root")
269
270     ctx = test("fail on inaccessible hosts/foo")
271     os.chmod(ctx.host, 0)
272     ctx.expect_msg("Cannot open config file")
273
274 if RUN_EXECUTABILITY_CHECKS:
275     ctx = test("non-executable tinc-up MUST be fixed by tinc --force")
276     os.chmod(ctx.tinc_up, 0o644)
277     ctx.expect_msg("cannot read and execute", force=True, code=0)
278     assert os.access(ctx.tinc_up, os.X_OK)
279
280     ctx = test("non-executable tinc-up MUST NOT be fixed by tinc without --force")
281     os.chmod(ctx.tinc_up, 0o644)
282     ctx.expect_msg("cannot read and execute", code=0)
283     assert not os.access(ctx.tinc_up, os.X_OK)
284
285     ctx = test("non-executable foo-up MUST be fixed by tinc --force")
286     write_text(ctx.host_up, "")
287     os.chmod(ctx.host_up, 0o644)
288     ctx.expect_msg("cannot read and execute", force=True, code=0)
289     assert os.access(ctx.tinc_up, os.X_OK)
290
291     ctx = test("non-executable bar-up MUST NOT be fixed by tinc")
292     path = ctx.node.sub("hosts", "bar-up")
293     write_text(path, "")
294     os.chmod(path, 0o644)
295     ctx.expect_msg("cannot read and execute", code=0)
296     assert not os.access(path, os.X_OK)
297
298 ###############################################################################
299 # Legacy protocol
300 ###############################################################################
301 if not RUN_LEGACY_CHECKS:
302     log.info("skipping legacy protocol tests")
303     sys.exit(0)
304
305
306 def test_rsa_public_key_file_var(context: Context, *paths: str) -> None:
307     """Test RSA public keys in config *paths."""
308     key = extract_pem(context.host)
309     remove_pem(context.host)
310
311     rsa_pub = os.path.realpath(context.node.sub("rsa_pubkey"))
312     write_lines(rsa_pub, key)
313
314     config = context.node.sub(*paths)
315     append_line(config, f"PublicKeyFile = {rsa_pub}")
316
317     context.expect_msg("Error reading RSA public key", code=0, present=False)
318
319
320 test_private_keys("rsa_key.priv")
321 test_private_key_var("PrivateKeyFile", "rsa_key.priv")
322
323 ctx = test("test rsa public key in tinc.conf")
324 test_rsa_public_key_file_var(ctx, "tinc.conf")
325
326 ctx = test("test rsa public key in hosts/")
327 test_rsa_public_key_file_var(ctx, "hosts", ctx.node.name)
328
329 ctx = test("warn about missing RSA private key if public key is present")
330 os.remove(ctx.rsa_priv)
331 ctx.expect_msg("public RSA key was found but no private key", code=0)
332
333 ctx = test("warn about missing RSA public key")
334 remove_pem(ctx.host)
335 ctx.expect_msg("No (usable) public RSA", code=0)
336 check.not_in("BEGIN RSA PUBLIC KEY", read_text(ctx.host))
337
338 ctx = test("fix missing RSA public key on --force")
339 remove_pem(ctx.host)
340 ctx.expect_msg("Wrote RSA public key", force=True, code=0)
341 check.is_in("BEGIN RSA PUBLIC KEY", read_text(ctx.host))
342
343 ctx = test("RSA PublicKey + PrivateKey must work")
344 os.remove(ctx.rsa_priv)
345 remove_pem(ctx.host)
346 append_line(ctx.conf, f"PrivateKey = {RSA_D}")
347 append_line(ctx.host, f"PublicKey = {RSA_N}")
348 ctx.expect_msg("no (usable) public RSA", code=0, present=False)
349
350 ctx = test("RSA PrivateKey without PublicKey must warn")
351 os.remove(ctx.rsa_priv)
352 remove_pem(ctx.host)
353 append_line(ctx.conf, f"PrivateKey = {RSA_D}")
354 ctx.expect_msg("PrivateKey used but no PublicKey found", code=0)
355
356 ctx = test("warn about missing EC private key if public key is present")
357 os.remove(ctx.ec_priv)
358 ctx.expect_msg("public Ed25519 key was found but no private key", code=0)
359
360 ctx = test("fix broken RSA public key with --force")
361 host_lines = read_lines(ctx.host)
362 del host_lines[1]
363 write_lines(ctx.host, host_lines)
364 ctx.expect_msg("old key(s) found and disabled", force=True, code=0)
365 ctx.node.cmd("fsck")
366
367 ctx = test("fix missing RSA public key with --force")
368 remove_pem(ctx.host)
369 ctx.expect_msg("No (usable) public RSA key found", force=True, code=0)
370 ctx.node.cmd("fsck")
371
372 if RUN_PERMISSION_CHECKS:
373     ctx = test("warn about unsafe permissions on tinc.conf with PrivateKey")
374     os.remove(ctx.rsa_priv)
375     append_line(ctx.conf, f"PrivateKey = {RSA_D}")
376     append_line(ctx.host, f"PublicKey = {RSA_N}")
377     os.chmod(ctx.conf, 0o666)
378     ctx.expect_msg("unsafe file permissions", code=0)