| 1 | #!/usr/bin/env python3 |
| 2 | import argparse |
| 3 | import json |
| 4 | import os |
| 5 | from pathlib import Path |
| 6 | import pwd |
| 7 | import socket |
| 8 | import struct |
| 9 | import subprocess |
| 10 | import sys |
| 11 | import uuid |
| 12 | |
| 13 | |
| 14 | def receive(connection, length): |
| 15 | result = bytearray() |
| 16 | while len(result) < length: |
| 17 | chunk = connection.recv(length - len(result)) |
| 18 | if not chunk: |
| 19 | raise EOFError("host closed the connection") |
| 20 | result.extend(chunk) |
| 21 | return bytes(result) |
| 22 | |
| 23 | |
| 24 | def request(socket_path, payload): |
| 25 | with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: |
| 26 | connection.settimeout(70) |
| 27 | connection.connect(socket_path) |
| 28 | connection.sendall(payload) |
| 29 | connection.shutdown(socket.SHUT_WR) |
| 30 | length = struct.unpack("!I", receive(connection, 4))[0] |
| 31 | assert length <= 16 * 1024 * 1024, length |
| 32 | return json.loads(receive(connection, length)) |
| 33 | |
| 34 | |
| 35 | def main(): |
| 36 | parser = argparse.ArgumentParser() |
| 37 | parser.add_argument("socket") |
| 38 | parser.add_argument("--pool", default="studio-demo") |
| 39 | parser.add_argument("--user", default="nobody") |
| 40 | parser.add_argument("--client", action="store_true") |
| 41 | args = parser.parse_args() |
| 42 | if args.client: |
| 43 | account = pwd.getpwnam(args.user) |
| 44 | os.setgroups([]) |
| 45 | os.setgid(account.pw_gid) |
| 46 | os.setuid(account.pw_uid) |
| 47 | json.dump(request(args.socket, sys.stdin.buffer.read()), sys.stdout) |
| 48 | return |
| 49 | |
| 50 | def call(value, raw=False): |
| 51 | payload = value if raw else json.dumps(value).encode() + b"\n" |
| 52 | process = subprocess.run([sys.executable, __file__, args.socket, "--client", "--user", args.user], |
| 53 | input=payload, capture_output=True) |
| 54 | if process.returncode: |
| 55 | raise RuntimeError(process.stderr.decode()) |
| 56 | return json.loads(process.stdout) |
| 57 | |
| 58 | suffix = uuid.uuid4().hex |
| 59 | dataset = args.pool + "/host-boundary-test-" + suffix |
| 60 | clone = dataset + "-clone" |
| 61 | mount = Path("/srv/.host-boundary-test-" + suffix) |
| 62 | sample = call({"operation": "host.sample"})["value"] |
| 63 | expected_memory = next(int(line.split()[1]) * 1024 for line in Path("/proc/meminfo").read_text().splitlines() if line.startswith("MemTotal:")) |
| 64 | assert sample["memory"]["total"] == expected_memory and sample["memory"]["used"] > 0, sample |
| 65 | assert sample["cpu"]["total"] >= sample["cpu"]["busy"] > 0 and sample["arc"] > 0, sample |
| 66 | inventory = call({"operation": "host.usage", "refresh": True})["value"] |
| 67 | assert inventory["containers"], inventory |
| 68 | assert all(set(row) == {"id", "name", "cpu", "memory"} for row in inventory["containers"]), inventory |
| 69 | cached = call({"operation": "host.usage", "refresh": False})["value"] |
| 70 | before = {row["id"]: row for row in inventory["containers"]} |
| 71 | assert any(row["cpu"] > before[row["id"]]["cpu"] for row in cached["containers"] if row["id"] in before), cached |
| 72 | subprocess.run(["zfs", "create", "-o", "mountpoint=" + str(mount), dataset], check=True) |
| 73 | try: |
| 74 | (mount / "fixture").write_bytes(b"host boundary fixture\n" * 8192) |
| 75 | subprocess.run(["zfs", "snapshot", dataset + "@before"], check=True) |
| 76 | (mount / "fixture").unlink() |
| 77 | subprocess.run(["zfs", "snapshot", dataset + "@after"], check=True) |
| 78 | rows = call({"operation": "storage.datasets"})["value"] |
| 79 | assert any(row["name"] == dataset for row in rows), rows |
| 80 | assert all(row["name"] == args.pool or row["name"].startswith(args.pool + "/") for row in rows) |
| 81 | assert call({"operation": "storage.pool"})["value"]["name"] == args.pool |
| 82 | snapshots = call({"operation": "storage.snapshots", "dataset": dataset})["value"] |
| 83 | assert {row["name"] for row in snapshots} == {"before", "after"}, snapshots |
| 84 | assert "fixture" in call({"operation": "storage.removed", "dataset": dataset, "snapshot": "before"})["value"] |
| 85 | assert "reclaim\t" in call({"operation": "storage.reclaim", "dataset": dataset, "from": "before", "to": "after"})["value"] |
| 86 | subprocess.run(["zfs", "clone", "-o", "mountpoint=none", dataset + "@before", clone], check=True) |
| 87 | assert call({"operation": "storage.reclaim", "dataset": dataset, "from": "before", "to": "after"})["status"] == 409 |
| 88 | denied = [ |
| 89 | {"operation": "command", "argv": ["touch", str(mount / "escaped")]}, |
| 90 | {"operation": "storage.snapshots", "dataset": "other-pool/private"}, |
| 91 | {"operation": "storage.datasets", "env": {"PATH": "/tmp"}}, |
| 92 | {"operation": "storage.reclaim", "dataset": dataset, "from": "before%after", "to": "after"}, |
| 93 | {"operation": "storage.destroy", "dataset": dataset}, |
| 94 | {"operation": "host.sample", "file": "/etc/shadow"}, |
| 95 | {"operation": "host.usage", "refresh": True, "pid": 1}, |
| 96 | ] |
| 97 | for value in denied: |
| 98 | assert call(value)["status"] == 400, value |
| 99 | assert call(b"x" * 65537 + b"\n", raw=True)["status"] == 400 |
| 100 | assert call(b'{"operation":"storage.datasets"}', raw=True)["status"] == 400 |
| 101 | assert call(b'\xff\n', raw=True)["status"] == 400 |
| 102 | try: |
| 103 | request(args.socket, b'{"operation":"storage.datasets"}\n') |
| 104 | except (EOFError, ConnectionResetError, BrokenPipeError): |
| 105 | pass |
| 106 | else: |
| 107 | raise AssertionError("a different peer UID was accepted") |
| 108 | remaining = subprocess.check_output(["zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset], text=True) |
| 109 | assert set(remaining.splitlines()) == {dataset + "@before", dataset + "@after"}, remaining |
| 110 | assert not (mount / "escaped").exists() |
| 111 | rejected = [ |
| 112 | {"operation": "files.snapshot", "datasets": [dataset, "other-pool/private"]}, |
| 113 | {"operation": "files.discard", "dataset": dataset, "snapshot": "before"}, |
| 114 | {"operation": "storage.destroy", "dataset": dataset, "from": "before%after", "to": "after"}, |
| 115 | {"operation": "storage.destroy", "dataset": dataset, "from": "before", "to": "after", "recursive": True}, |
| 116 | ] |
| 117 | for value in rejected: |
| 118 | assert call(value)["status"] == 400, value |
| 119 | child = dataset + "/child" |
| 120 | subprocess.run(["zfs", "create", "-o", "mountpoint=none", child], check=True) |
| 121 | undo = call({"operation": "files.snapshot", "datasets": [dataset, child]})["value"] |
| 122 | assert undo.startswith("dash-") and undo[5:].isdigit(), undo |
| 123 | listed = call({"operation": "files.snapshots", "datasets": [dataset, child]})["value"] |
| 124 | assert set(listed) == {dataset + "@" + undo, child + "@" + undo}, listed |
| 125 | for name in [dataset, child]: |
| 126 | assert call({"operation": "files.discard", "dataset": name, "snapshot": undo})["value"] is None |
| 127 | assert call({"operation": "storage.destroy", "dataset": dataset, "from": "before", "to": "after"})["status"] == 409 |
| 128 | subprocess.run(["zfs", "destroy", clone], check=True) |
| 129 | call({"operation": "storage.destroy", "dataset": dataset, "from": "before", "to": "after"})["value"] |
| 130 | remaining = subprocess.check_output(["zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset], text=True) |
| 131 | assert not remaining.strip(), remaining |
| 132 | print(json.dumps({"unprivileged_zfs_reads": "passed", "cross_pool_isolation": "passed", |
| 133 | "dry_run_preserves_snapshots": "passed", "request_bounds": "passed", |
| 134 | "peer_uid_isolation": "passed", "command_injection_rejection": "passed", |
| 135 | "atomic_multi_dataset_undo_snapshots": "passed", "undo_namespace_isolation": "passed", |
| 136 | "snapshot_range_deletion": "passed", "clone_deletion_refusal": "passed", |
| 137 | "host_sample": "passed", "container_counter_inventory": "passed"})) |
| 138 | finally: |
| 139 | subprocess.run(["zfs", "destroy", clone], capture_output=True) |
| 140 | subprocess.run(["zfs", "destroy", "-r", dataset], check=True) |
| 141 | |
| 142 | |
| 143 | if __name__ == "__main__": |
| 144 | main() |