Files
MeshCore/test/test_ethernet_preamble_ssh_runner.py
T

218 lines
13 KiB
Python

"""Physical SSH-runner protocol/ownership tests: no SSH, WSL, RF or key reads."""
import io
import json
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/hil"))
import ethernet_preamble_ssh_runner as runner
def request(**changes):
return dict(index=1, case_id="case-001-p128", preamble=128, frequency_mhz=909.5,
bandwidth_khz=500, sf=5, cr=5, **changes)
def config(directory):
return dict(ssh_host="bench@example.test", ssh_key=str(Path(directory) / "ssh_key"),
pi_bench="/home/mikec/bench", server_endpoint="tcp://192.168.1.84:5003",
client_endpoint="tcp://192.168.1.87:5003", wsl_workspace="/home/mcarper/app",
flutter="/home/mcarper/flutter/bin/flutter", account="mcarper", out_root=directory)
def ready_text(config, pid=123):
return (f"READY: gateway={config['server_endpoint']} local=10.90.5.1 remote=10.90.5.2 SSH=2222 user=mikec\n"
"Ephemeral client private key file (never print contents): /home/mikec/bench/ethernet-phone.A1234567/client_key\n"
f"256 SHA256:{'a' * 43} root@host (ED25519)\n"
"Artifacts: /home/mikec/bench/ethernet-phone.A1234567\n"
f"Server helper PID: {pid}\n")
class EthernetPreambleSshRunnerTests(unittest.TestCase):
def test_config_uses_explicit_hosts_private_port5003_and_absolute_paths(self):
with tempfile.TemporaryDirectory() as directory:
value = config(directory); Path(value["ssh_key"]).touch()
self.assertEqual(runner.validate_config(value), value)
for field, bad in (("ssh_host", "-oProxyCommand=evil"), ("account", "root"),
("server_endpoint", "tcp://192.168.1.84:5002"),
("server_endpoint", "tcp://127.0.0.1:5003"),
("client_endpoint", value["server_endpoint"]),
("pi_bench", "/"), ("wsl_workspace", "/home/../etc"),
("flutter", "relative"), ("out_root", "relative")):
changed = dict(value); changed[field] = bad
with self.subTest(field=field, value=bad), self.assertRaises((ValueError, TypeError)):
runner.validate_config(changed)
def test_requests_accept_sf5_through_sf9_without_changing_case_identifier(self):
for bandwidth in (125, 500):
for sf in range(5, 10):
value = request(); value.update(sf=sf, bandwidth_khz=bandwidth)
with self.subTest(bandwidth=bandwidth, sf=sf):
self.assertIs(runner.validate_request(value), value)
self.assertEqual(value["case_id"], "case-001-p128")
def test_requests_only_allow_fixed_lab_frequency_bandwidth_cr_and_safe_case_id(self):
for field, bad in (("frequency_mhz", 910.525), ("bandwidth_khz", 250),
("cr", 7), ("preamble", 31), ("index", True),
("case_id", "../escape")):
changed = request(); changed[field] = bad
with self.subTest(field=field), self.assertRaises(ValueError):
runner.validate_request(changed)
def test_request_radio_fields_reject_wrong_types_and_invalid_sf(self):
invalid = {
"frequency_mhz": (True, False, "909.5", None),
"bandwidth_khz": (True, False, 125.0, 500.0, "125", "500", None),
"sf": (True, False, 4, 10, 5.0, 9.0, "5", None),
"cr": (True, False, 5.0, "5", None),
}
for field, values in invalid.items():
for bad in values:
changed = request(); changed[field] = bad
with self.subTest(field=field, value=bad), self.assertRaises(ValueError):
runner.validate_request(changed)
def test_windows_drive_mapping_never_expands_shell_metacharacters(self):
self.assertEqual(runner.wsl_path(r"C:\git\out with spaces\file.key"), "/mnt/c/git/out with spaces/file.key")
self.assertEqual(runner.wsl_path("/mnt/c/git/key"), "/mnt/c/git/key")
for path in (r"\\host\share\key", "relative", "/", "/tmp/../etc/key"):
with self.assertRaises(ValueError): runner.wsl_path(path)
def test_ready_requires_exact_endpoint_one_fingerprint_pid_and_scoped_artifacts(self):
value = config("/tmp/fixture")
ready = runner.parse_server_ready(ready_text(value), value)
self.assertEqual(ready["pid"], 123)
self.assertEqual(ready["fingerprint"], "SHA256:" + "a" * 43)
for text in (ready_text(value).replace("192.168.1.84", "192.168.1.87"),
ready_text(value).replace("Server helper PID: 123\n", ""),
ready_text(value) + "Server helper PID: 234\n",
ready_text(value) + "256 SHA256:" + "b" * 43 + " (ED25519)\n",
ready_text(value).replace("Artifacts: /home/mikec/bench/", "Artifacts: /etc/")):
with self.assertRaises(ValueError): runner.parse_server_ready(text, value)
def test_read_ready_waits_past_artifact_line_for_pid(self):
value = config("/tmp/fixture")
process = SimpleNamespace(stdout=io.StringIO(ready_text(value)))
self.assertEqual(runner.read_ready(process, value, .5)["pid"], 123)
with self.assertRaises(RuntimeError):
runner.read_ready(SimpleNamespace(stdout=io.StringIO(ready_text(value).split("Server helper PID")[0])), value, .5)
def test_flutter_artifact_path_must_be_exact_child_of_selected_workspace(self):
self.assertEqual(runner.artifact_dir("Flutter RF artifacts: /home/test/app/ethernet-flutter.Ab123456\n",
"/home/test/app", "Flutter RF artifacts"), "/home/test/app/ethernet-flutter.Ab123456")
for text in ("Flutter RF artifacts: /tmp/ethernet-flutter.Ab123456\n",
"Flutter RF artifacts: /home/test/app/sub/ethernet-flutter.Ab123456\n",
"Flutter RF artifacts: /home/test/app/ethernet-flutter.short\n"):
with self.assertRaises(ValueError): runner.artifact_dir(text, "/home/test/app", "Flutter RF artifacts")
def test_speed_normalization_uses_actual_time_and_both_sha256_checks(self):
speed = dict(success=True, host_verified=True, handshake_ms=7000, total_elapsed_ms=11000,
download=dict(bytes=8192, elapsed_ms=2000, kbps=9999, sha256_valid=True,
data_elapsed_ms=1000, data_kbps=65.536),
upload=dict(bytes=8192, elapsed_ms=4000, sha256_valid=True))
result = runner.normalized_speed(speed, 0)
self.assertTrue(result["verified"])
self.assertEqual(result["downlink"]["kbps"], 32.768)
self.assertEqual(result["uplink"]["elapsed_seconds"], 4)
self.assertEqual(result["downlink"]["data_kbps"], 65.536)
for field in ("success", "host_verified"):
changed = dict(speed); changed[field] = False
self.assertFalse(runner.normalized_speed(changed, 0)["verified"])
self.assertFalse(runner.normalized_speed(speed, 1)["verified"])
for bad in (None, dict(bytes=8192, elapsed_ms=0, sha256_valid=True),
dict(bytes=8192, elapsed_ms=float("nan"), sha256_valid=True),
dict(bytes=8192, elapsed_ms=2000, sha256_valid=False)):
changed = dict(speed); changed["download"] = bad
self.assertFalse(runner.normalized_speed(changed, 0)["verified"])
def test_remote_stop_checks_whole_pid_argv_script_and_unique_prefix_before_signal(self):
value = config("/tmp/fixture")
command = runner.stop_server_args(value, 123, "ethphone-speed-abcdef01")
self.assertIn("BatchMode=yes", command); self.assertIn("StrictHostKeyChecking=yes", command)
remote = shlex.split(command[-1])
self.assertEqual(remote[:4], ["sudo", "-n", "python3", "-c"])
self.assertEqual(remote[-3:], ["123", "/home/mikec/bench/ethernet_ssh_phone_bench.sh", "ethphone-speed-abcdef01"])
code = remote[4]
self.assertIn("script not in args", code)
self.assertIn("args[args.index('--prefix')+1] != prefix", code)
self.assertIn("os.kill(pid,signal.SIGTERM)", code)
self.assertNotIn("pkill", code)
for pid, prefix in ((1, "ethphone-speed-abcdef01"), (123, "ethphone-speed-abcdef01;killall")):
with self.assertRaises(ValueError): runner.stop_server_args(value, pid, prefix)
def test_failed_startup_still_removes_only_owned_key_and_owned_popen_child(self):
with tempfile.TemporaryDirectory() as directory:
value = config(directory); Path(value["ssh_key"]).touch()
process = Mock(); process.stdout = io.StringIO("")
process.wait.side_effect = [subprocess.TimeoutExpired("ssh", 3), None]
with patch.object(runner.subprocess, "Popen", return_value=process), \
patch.object(runner.subprocess, "run") as run:
result = runner.execute(value, request())
self.assertFalse(result["verified"])
process.kill.assert_called_once()
run.assert_not_called() # No WSL key was created; don't remove one.
self.assertFalse(any(path.name == "client_key.ephemeral" for path in Path(directory).rglob("*")))
def test_full_fake_case_normalizes_copies_evidence_and_cleans_only_its_resources(self):
with tempfile.TemporaryDirectory() as directory:
value = config(directory); Path(value["ssh_key"]).touch()
process = Mock(); process.stdout = io.StringIO(ready_text(value))
tcp_count = 0
commands = []
def run(argv, **kwargs):
nonlocal tcp_count
commands.append(argv)
self.assertFalse(kwargs["shell"])
self.assertNotIn("PRIVATE_KEY_CONTENT", " ".join(argv))
if argv[0] == "scp":
Path(argv[-1]).write_text("PRIVATE_KEY_CONTENT")
if argv[0] == "ssh" and "/proc/net/snmp" in argv[-1]:
tcp_count += 1
return SimpleNamespace(stdout=("Tcp: InSegs OutSegs RetransSegs\n"
f"Tcp: {tcp_count * 10} {tcp_count * 20} {tcp_count - 1}\n"), returncode=0)
if any(str(argument).endswith("/ethernet_ssh_flutter_bench.sh") for argument in argv):
return SimpleNamespace(stdout="Flutter RF artifacts: /home/mcarper/app/ethernet-flutter.Ab123456\n", returncode=0)
if argv[0] == "wsl" and "-c" in argv and "src=$1" in argv[argv.index("-c") + 1]:
output = next(Path(directory).glob("case-001-p128-*"))
(output / "speed.json").write_text(json.dumps(dict(success=True, host_verified=True,
handshake_ms=7000, total_elapsed_ms=11000,
download=dict(bytes=8192, elapsed_ms=2000, sha256_valid=True),
upload=dict(bytes=8192, elapsed_ms=2000, sha256_valid=True))))
(output / "tcp-stats.json").write_text(json.dumps(dict(available=True, delta=dict(OutSegs=40, RetransSegs=2))))
return SimpleNamespace(stdout="", returncode=0)
with patch.object(runner.subprocess, "Popen", return_value=process), \
patch.object(runner.subprocess, "run", side_effect=run):
result = runner.execute(value, request())
self.assertTrue(result["verified"], result.get("error"))
self.assertEqual(result["downlink"]["kbps"], 32.768)
self.assertEqual(result["tcp_server"]["delta"]["RetransSegs"], 1)
self.assertEqual(result["tcp_client"]["delta"]["RetransSegs"], 2)
self.assertTrue(any(argv[:7] == ["wsl", "--user", "root", "--exec", "rm", "-f", "--"] for argv in commands))
self.assertTrue(any(argv[0] == "ssh" and "os.kill(pid,signal.SIGTERM)" in argv[-1] for argv in commands))
self.assertFalse((Path(result["artifacts"]["directory"]) / "client_key.ephemeral").exists())
self.assertNotIn("PRIVATE_KEY_CONTENT", json.dumps(result))
process.kill.assert_not_called()
def test_source_has_no_network_mutation_or_key_content_copy_in_evidence_allowlist(self):
source = Path(runner.__file__).read_text()
self.assertNotIn("shell=True", source)
self.assertNotIn("pkill", source)
self.assertNotIn("killall", source)
self.assertNotIn("client_key", runner.ARTIFACTS)
self.assertNotIn("host_key", runner.ARTIFACTS)
self.assertNotIn("iptables", source)
self.assertIn('"--duration", "300"', source)
self.assertIn('deadline = time.monotonic() + 225', source)
if __name__ == "__main__":
unittest.main()