1#!/usr/bin/env python3
2import argparse
3import json
4import os
5from pathlib import Path
6import pwd
7import socket
8import struct
9import subprocess
10import sys
11import uuid
12
13
14def 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
24def 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
35def 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
143if __name__ == "__main__":
144 main()