|
4 | 4 | import signal |
5 | 5 | import subprocess |
6 | 6 | import time |
| 7 | +from typing import List |
7 | 8 |
|
8 | 9 | import requests |
9 | 10 |
|
10 | | -SUBTEAM = os.environ['SUBTEAM'] |
11 | | -SUBTEAM_SECONDARY = os.environ['SUBTEAM_SECONDARY'] |
12 | | -USERNAME = os.environ['KSSH_USERNAME'] |
13 | | -BOT_USERNAME = os.environ['BOT_USERNAME'] |
| 11 | +def getDefaultExpectedHash() -> bytes: |
| 12 | + # "uniquestring" is stored in /etc/unique of the SSH server. We then run the command `sha1sum /etc/unique` via kssh |
| 13 | + # and assert that the output contains the sha1 hash of uniquestring. This checks to make sure the command given to |
| 14 | + # kssh is actually executing on the remote server. |
| 15 | + return hashlib.sha1(b"uniquestring").hexdigest().encode('utf-8') |
14 | 16 |
|
15 | | -# "uniquestring" is stored in /etc/unique of the SSH server. We then run the command `sha1sum /etc/unique` via kssh |
16 | | -# and assert that the output contains the sha1 hash of uniquestring. This checks to make sure the command given to |
17 | | -# kssh is actually executing on the remote server. |
18 | | -EXPECTED_HASH = hashlib.sha1(b"uniquestring").hexdigest().encode('utf-8') |
| 17 | +class TestConfig: |
| 18 | + # Not actually a test class so mark it to be skipped |
| 19 | + __test__ = False |
19 | 20 |
|
20 | | -class UtilitiesLib: |
21 | 21 | def __init__(self, subteam, subteam_secondary, username, bot_username, expected_hash): |
22 | 22 | self.subteam = subteam |
23 | 23 | self.subteam_secondary = subteam_secondary |
24 | 24 | self.username = username |
25 | 25 | self.bot_username = bot_username |
26 | 26 | self.expected_hash = expected_hash |
27 | 27 |
|
28 | | - def run_command(self, cmd, timeout=10): |
29 | | - # In order to properly run a command with a timeout and shell=True, we use Popen with a shell and group all child |
30 | | - # processes so we can kill all of them. See: |
31 | | - # - https://stackoverflow.com/questions/36952245/subprocess-timeout-failure |
32 | | - # - https://stackoverflow.com/questions/4789837/how-to-terminate-a-python-subprocess-launched-with-shell-true |
33 | | - with subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, preexec_fn=os.setsid) as process: |
34 | | - try: |
35 | | - stdout, stderr = process.communicate(timeout=timeout) |
36 | | - if process.returncode != 0: |
37 | | - raise subprocess.CalledProcessError(process.returncode, cmd, stdout, stderr) |
38 | | - return stdout |
39 | | - except subprocess.TimeoutExpired as e: |
40 | | - os.killpg(process.pid, signal.SIGINT) |
41 | | - print(f"Output before timeout: {process.communicate()[0]}") |
42 | | - raise e |
43 | | - |
44 | | - def read_file(self, filename): |
45 | | - """ |
46 | | - Read the contents of the given filename to a list of strings. If it is a normal file, |
47 | | - uses the standard open() function. Otherwise, uses `keybase fs read`. |
48 | | - :param filename: The name of the file to read |
49 | | - :return: A list of lines in the file |
50 | | - """ |
51 | | - if filename.startswith("/keybase/"): |
52 | | - return self.run_command(f"keybase fs read {filename}").splitlines() |
53 | | - with open(filename, 'rb') as f: |
54 | | - return f.readlines() |
55 | | - |
56 | | - def clear_keys(self): |
57 | | - # Clear all keys generated by kssh |
| 28 | + @staticmethod |
| 29 | + def getDefaultTestConfig(): |
| 30 | + return TestConfig( |
| 31 | + os.environ['SUBTEAM'], |
| 32 | + os.environ['SUBTEAM_SECONDARY'], |
| 33 | + os.environ['KSSH_USERNAME'], |
| 34 | + os.environ['BOT_USERNAME'], |
| 35 | + getDefaultExpectedHash() |
| 36 | + ) |
| 37 | + |
| 38 | +def run_command(cmd: str, timeout: int=10) -> bytes: |
| 39 | + # In order to properly run a command with a timeout and shell=True, we use Popen with a shell and group all child |
| 40 | + # processes so we can kill all of them. See: |
| 41 | + # - https://stackoverflow.com/questions/36952245/subprocess-timeout-failure |
| 42 | + # - https://stackoverflow.com/questions/4789837/how-to-terminate-a-python-subprocess-launched-with-shell-true |
| 43 | + with subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, preexec_fn=os.setsid) as process: |
58 | 44 | try: |
59 | | - self.run_command("rm -rf ~/.ssh/keybase-signed-key*") |
60 | | - except subprocess.CalledProcessError: |
61 | | - pass |
62 | | - |
63 | | - def clear_local_config(self): |
64 | | - # Clear kssh's local config file |
65 | | - try: |
66 | | - self.run_command("rm -rf ~/.ssh/kssh.config") |
67 | | - except subprocess.CalledProcessError: |
68 | | - pass |
69 | | - |
70 | | - def load_env(self, filename): |
71 | | - # Load the environment based off of the given filename which is the path to the python test script |
72 | | - env_name = os.path.basename(filename).split(".")[0] |
73 | | - return requests.get(f"http://ca-bot:8080/load_env?filename={env_name}").content == b"OK" |
74 | | - |
75 | | - def assert_contains_hash(self, output): |
76 | | - assert EXPECTED_HASH in output |
77 | | - |
78 | | - @contextmanager |
79 | | - def simulate_two_teams(self): |
80 | | - # A context manager that simulates running the given function in an environment with two teams set up |
81 | | - self.run_command(f"keybase fs read /keybase/team/{self.subteam}.ssh.staging/kssh-client.config | " |
82 | | - f"sed 's/{self.subteam}.ssh.staging/{self.subteam_secondary}/g' | " |
83 | | - f"sed 's/{self.bot_username}/otherbotname/g' | " |
84 | | - f"keybase fs write /keybase/team/{self.subteam_secondary}/kssh-client.config") |
85 | | - try: |
86 | | - yield |
87 | | - finally: |
88 | | - self.run_command(f"keybase fs rm /keybase/team/{self.subteam_secondary}/kssh-client.config") |
| 45 | + stdout, stderr = process.communicate(timeout=timeout) |
| 46 | + if process.returncode != 0: |
| 47 | + raise subprocess.CalledProcessError(process.returncode, cmd, stdout, stderr) |
| 48 | + return stdout |
| 49 | + except subprocess.TimeoutExpired as e: |
| 50 | + os.killpg(process.pid, signal.SIGINT) |
| 51 | + print(f"Output before timeout: {process.communicate()[0]}") |
| 52 | + raise e |
| 53 | + |
| 54 | +def read_file(filename: str) -> List[bytes]: |
| 55 | + """ |
| 56 | + Read the contents of the given filename to a list of strings. If it is a normal file, |
| 57 | + uses the standard open() function. Otherwise, uses `keybase fs read`. |
| 58 | + :param filename: The name of the file to read |
| 59 | + :return: A list of lines in the file |
| 60 | + """ |
| 61 | + if filename.startswith("/keybase/"): |
| 62 | + return run_command(f"keybase fs read {filename}").splitlines() |
| 63 | + with open(filename, 'rb') as f: |
| 64 | + return f.readlines() |
| 65 | + |
| 66 | +def clear_keys(): |
| 67 | + # Clear all keys generated by kssh |
| 68 | + try: |
| 69 | + run_command("rm -rf ~/.ssh/keybase-signed-key*") |
| 70 | + except subprocess.CalledProcessError: |
| 71 | + pass |
| 72 | + |
| 73 | +def clear_local_config(): |
| 74 | + # Clear kssh's local config file |
| 75 | + try: |
| 76 | + run_command("rm -rf ~/.ssh/kssh.config") |
| 77 | + except subprocess.CalledProcessError: |
| 78 | + pass |
| 79 | + |
| 80 | +def load_env(filename: str): |
| 81 | + # Load the environment based off of the given filename which is the path to the python test script |
| 82 | + env_name = os.path.basename(filename).split(".")[0] |
| 83 | + return requests.get(f"http://ca-bot:8080/load_env?filename={env_name}").content == b"OK" |
| 84 | + |
| 85 | +def assert_contains_hash(hash: bytes, output: bytes): |
| 86 | + assert hash in output |
| 87 | + |
| 88 | +@contextmanager |
| 89 | +def simulate_two_teams(tc: TestConfig): |
| 90 | + # A context manager that simulates running the given function in an environment with two teams set up |
| 91 | + run_command(f"keybase fs read /keybase/team/{tc.subteam}.ssh.staging/kssh-client.config | " |
| 92 | + f"sed 's/{tc.subteam}.ssh.staging/{tc.subteam_secondary}/g' | " |
| 93 | + f"sed 's/{tc.bot_username}/otherbotname/g' | " |
| 94 | + f"keybase fs write /keybase/team/{tc.subteam_secondary}/kssh-client.config") |
| 95 | + try: |
| 96 | + yield |
| 97 | + finally: |
| 98 | + run_command(f"keybase fs rm /keybase/team/{tc.subteam_secondary}/kssh-client.config") |
89 | 99 |
|
90 | | - @contextmanager |
91 | | - def outputs_audit_log(self, filename, expected_number): |
92 | | - # A context manager that asserts that the given function triggers expected_number of audit logs to be added to '/keybase/team/team.ssh.prod/ca.log' |
93 | | - # Note that fuse is not running in the container so this has to use `keybase fs read` |
| 100 | +@contextmanager |
| 101 | +def outputs_audit_log(tc: TestConfig, filename: str, expected_number: int): |
| 102 | + # A context manager that asserts that the given function triggers expected_number of audit logs to be added to '/keybase/team/team.ssh.prod/ca.log' |
| 103 | + # Note that fuse is not running in the container so this has to use `keybase fs read` |
94 | 104 |
|
95 | | - # Make a set of the lines in the audit log before we ran |
96 | | - before_lines = set(self.read_file(filename)) |
| 105 | + # Make a set of the lines in the audit log before we ran |
| 106 | + before_lines = set(read_file(filename)) |
97 | 107 |
|
98 | | - # Then run the code inside the context manager |
99 | | - yield |
| 108 | + # Then run the code inside the context manager |
| 109 | + yield |
100 | 110 |
|
101 | | - # And sleep to give KBFS some time |
102 | | - time.sleep(1.5) |
| 111 | + # And sleep to give KBFS some time |
| 112 | + time.sleep(1.5) |
103 | 113 |
|
104 | | - # Then see if there are new lines using set difference. This is only safe/reasonable since we include a |
105 | | - # timestamp in audit log lines. |
106 | | - after_lines = set(self.read_file(filename)) |
107 | | - new_lines = after_lines - before_lines |
| 114 | + # Then see if there are new lines using set difference. This is only safe/reasonable since we include a |
| 115 | + # timestamp in audit log lines. |
| 116 | + after_lines = set(read_file(filename)) |
| 117 | + new_lines = after_lines - before_lines |
108 | 118 |
|
109 | | - cnt = 0 |
110 | | - for line in new_lines: |
111 | | - line = line.decode('utf-8') |
112 | | - if line and f"Processing SignatureRequest from user={self.username}" in line and f"principals:{self.subteam}.ssh.staging,{self.subteam}.ssh.root_everywhere, expiration:+1h, pubkey:ssh-ed25519" in line: |
113 | | - cnt += 1 |
| 119 | + cnt = 0 |
| 120 | + for line in new_lines: |
| 121 | + line = line.decode('utf-8') |
| 122 | + if line and f"Processing SignatureRequest from user={tc.username}" in line and f"principals:{tc.subteam}.ssh.staging,{tc.subteam}.ssh.root_everywhere, expiration:+1h, pubkey:ssh-ed25519" in line: |
| 123 | + cnt += 1 |
114 | 124 |
|
115 | | - if cnt != expected_number: |
116 | | - assert False, f"Found {cnt} audit log entries, expected {expected_number}! New audit logs: {new_lines}" |
| 125 | + if cnt != expected_number: |
| 126 | + assert False, f"Found {cnt} audit log entries, expected {expected_number}! New audit logs: {new_lines}" |
0 commit comments