iotsploit-priv 0.0.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: iotsploit-priv
3
+ Version: 0.0.9
4
+ Summary: Bounded privileged-operation client for IoTSploit
5
+ License: GPL-3.0-or-later
6
+ Author: IoTSploit Team
7
+ Author-email: support@iotsploit.org
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: Python :: 3.15
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/python3
2
+ """Install the bounded IoTSploit helper without importing application code."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import grp
8
+ import os
9
+ import pwd
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+
16
+ SOURCE_ROOT = Path(__file__).resolve().parent.parent
17
+ DAEMON_SOURCE = SOURCE_ROOT / "privd/iotsploit-privd"
18
+ SYSTEMD_SOURCE = SOURCE_ROOT / "systemd"
19
+ DAEMON_DESTINATION = Path("/usr/local/libexec/iotsploit-privd")
20
+ SYSTEMD_DESTINATION = Path("/etc/systemd/system")
21
+ RUNTIME_DIRECTORY = Path("/run/iotsploit")
22
+ GROUP = "iotsploit"
23
+ SYSTEMCTL = "/usr/bin/systemctl"
24
+ GROUPADD = "/usr/sbin/groupadd"
25
+ USERMOD = "/usr/sbin/usermod"
26
+
27
+
28
+ def _run(argv: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
29
+ return subprocess.run(argv, check=check, text=True, env={}, close_fds=True)
30
+
31
+
32
+ def _copy_root_file(source: Path, destination: Path, mode: int) -> None:
33
+ if not source.is_file() or source.is_symlink():
34
+ raise RuntimeError(f"invalid packaged source: {source}")
35
+ destination.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
36
+ temporary = destination.with_name(f".{destination.name}.new")
37
+ shutil.copyfile(source, temporary)
38
+ os.chown(temporary, 0, 0)
39
+ os.chmod(temporary, mode)
40
+ temporary.replace(destination)
41
+
42
+
43
+ def _ensure_group() -> None:
44
+ try:
45
+ grp.getgrnam(GROUP)
46
+ except KeyError:
47
+ _run([GROUPADD, "--system", GROUP])
48
+
49
+
50
+ def install(service_user: str, worker_units: list[str]) -> None:
51
+ pwd.getpwnam(service_user)
52
+ _ensure_group()
53
+ _run([USERMOD, "--append", "--groups", GROUP, service_user])
54
+ _copy_root_file(DAEMON_SOURCE, DAEMON_DESTINATION, 0o755)
55
+ for unit_name in ("iotsploit-privd.socket", "iotsploit-privd.service"):
56
+ _copy_root_file(SYSTEMD_SOURCE / unit_name, SYSTEMD_DESTINATION / unit_name, 0o644)
57
+ for unit in worker_units:
58
+ if not unit or "/" in unit or unit in {"iotsploit-privd.service", "iotsploit-privd.socket"}:
59
+ raise ValueError(f"invalid worker unit: {unit!r}")
60
+ drop_in = SYSTEMD_DESTINATION / f"{unit}.d/50-iotsploit-capabilities.conf"
61
+ _copy_root_file(SYSTEMD_SOURCE / "iotsploit-worker-capabilities.conf", drop_in, 0o644)
62
+ _run([SYSTEMCTL, "daemon-reload"])
63
+ _run([SYSTEMCTL, "enable", "--now", "iotsploit-privd.socket"])
64
+ print(f"Installed helper access for {service_user}.")
65
+ print("Group membership starts at login: log out and back in (or run: newgrp iotsploit)")
66
+ print("before using privileged verbs, and restart that user's Django/Celery services.")
67
+
68
+
69
+ def uninstall(worker_units: list[str]) -> None:
70
+ _run([SYSTEMCTL, "disable", "--now", "iotsploit-privd.socket"], check=False)
71
+ _run([SYSTEMCTL, "stop", "iotsploit-privd.service"], check=False)
72
+ for destination in (
73
+ SYSTEMD_DESTINATION / "iotsploit-privd.socket",
74
+ SYSTEMD_DESTINATION / "iotsploit-privd.service",
75
+ DAEMON_DESTINATION,
76
+ ):
77
+ try:
78
+ destination.unlink()
79
+ except FileNotFoundError:
80
+ pass
81
+ for unit in worker_units:
82
+ drop_in = SYSTEMD_DESTINATION / f"{unit}.d/50-iotsploit-capabilities.conf"
83
+ try:
84
+ drop_in.unlink()
85
+ drop_in.parent.rmdir()
86
+ except FileNotFoundError:
87
+ pass
88
+ except OSError:
89
+ pass
90
+ try:
91
+ RUNTIME_DIRECTORY.rmdir()
92
+ except (FileNotFoundError, OSError):
93
+ pass
94
+ _run([SYSTEMCTL, "daemon-reload"])
95
+ print("Removed the IoTSploit privileged helper. The iotsploit group was retained.")
96
+
97
+
98
+ def main() -> int:
99
+ parser = argparse.ArgumentParser()
100
+ subparsers = parser.add_subparsers(dest="action", required=True)
101
+ install_parser = subparsers.add_parser("install")
102
+ install_parser.add_argument("--service-user", default=os.environ.get("SUDO_USER") or "root")
103
+ install_parser.add_argument("--worker-unit", action="append", default=[])
104
+ uninstall_parser = subparsers.add_parser("uninstall")
105
+ uninstall_parser.add_argument("--worker-unit", action="append", default=[])
106
+ options = parser.parse_args()
107
+ if os.geteuid() != 0:
108
+ parser.error("installer must run as root")
109
+ if options.action == "install":
110
+ install(options.service_user, options.worker_unit)
111
+ else:
112
+ uninstall(options.worker_unit)
113
+ return 0
114
+
115
+
116
+ if __name__ == "__main__":
117
+ try:
118
+ raise SystemExit(main())
119
+ except (KeyError, OSError, RuntimeError, subprocess.CalledProcessError, ValueError) as exc:
120
+ print(f"iotsploit privileged helper installation failed: {exc}", file=sys.stderr)
121
+ raise SystemExit(1)
@@ -0,0 +1,634 @@
1
+ #!/usr/bin/python3
2
+ """Root-owned IoTSploit host-state daemon. Standard library only."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import datetime
8
+ import grp
9
+ import hashlib
10
+ import ipaddress
11
+ import json
12
+ import os
13
+ import re
14
+ import selectors
15
+ import signal
16
+ import socket
17
+ import stat
18
+ import struct
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ MAX_REQUEST_BYTES = 4_096
27
+ MAX_OUTPUT_BYTES = 8_192
28
+ MAX_RESPONSE_BYTES = 24_576
29
+ COMMAND_TIMEOUT_SECONDS = 10
30
+ SOCKET_BACKLOG = 16
31
+ DEFAULT_SOCKET_PATH = Path("/run/iotsploit/priv.sock")
32
+ IP_EXECUTABLE = "/usr/sbin/ip"
33
+ NMCLI_EXECUTABLE = "/usr/bin/nmcli"
34
+
35
+ CAN_INTERFACE = re.compile(r"^(v?can)[0-9]{1,3}$")
36
+ NETWORK_INTERFACE = re.compile(r"^[a-z0-9._-]{1,15}$")
37
+ VERB_SCHEMAS = {
38
+ "can-fd-up": {
39
+ "iface": "can",
40
+ "bitrate": "integer",
41
+ "sample_point": "ratio",
42
+ "dbitrate": "integer",
43
+ "dsample_point": "ratio",
44
+ },
45
+ "can-link-state": {"iface": "can", "state": ["up", "down"]},
46
+ "can-up": {"iface": "can", "bitrate": "integer-or-null"},
47
+ "doip-config": {"iface": "network"},
48
+ "route-via": {"action": ["add", "delete"], "cidr": "ipv4-/16", "gateway": "ipv4"},
49
+ "vlan-add": {
50
+ "parent": "network",
51
+ "vlan_id": "1-4094",
52
+ "address": "ipv4-interface",
53
+ "local_mac": "mac-or-null",
54
+ "peer_ip": "ipv4-or-null",
55
+ "peer_mac": "mac-or-null",
56
+ },
57
+ "vlan-edit": {
58
+ "parent": "network",
59
+ "vlan_id": "1-4094",
60
+ "address": "ipv4-interface",
61
+ "local_mac": "mac-or-null",
62
+ "peer_ip": "ipv4-or-null",
63
+ "peer_mac": "mac-or-null",
64
+ },
65
+ "vlan-delete": {"parent": "network", "vlan_id": "1-4094"},
66
+ }
67
+ VERB_KEYS = {verb: set(schema) for verb, schema in VERB_SCHEMAS.items()}
68
+ VERB_TABLE_HASH = hashlib.sha256(
69
+ json.dumps(VERB_SCHEMAS, sort_keys=True, separators=(",", ":")).encode()
70
+ ).hexdigest()
71
+
72
+
73
+ class RequestError(ValueError):
74
+ pass
75
+
76
+
77
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
78
+ result: dict[str, Any] = {}
79
+ for key, value in pairs:
80
+ if key in result:
81
+ raise RequestError(f"duplicate key: {key}")
82
+ result[key] = value
83
+ return result
84
+
85
+
86
+ def _read_request(connection: socket.socket) -> dict[str, Any]:
87
+ data = bytearray()
88
+ while b"\n" not in data:
89
+ chunk = connection.recv(1_024)
90
+ if not chunk:
91
+ raise RequestError("request ended before newline")
92
+ data.extend(chunk)
93
+ if len(data) > MAX_REQUEST_BYTES:
94
+ raise RequestError("request exceeds 4 KiB")
95
+ line, trailing = bytes(data).split(b"\n", 1)
96
+ while not trailing:
97
+ chunk = connection.recv(1_024)
98
+ if not chunk:
99
+ break
100
+ trailing += chunk
101
+ if len(data) + len(trailing) > MAX_REQUEST_BYTES:
102
+ raise RequestError("request exceeds 4 KiB")
103
+ if trailing:
104
+ raise RequestError("trailing request bytes are not allowed")
105
+ try:
106
+ payload = json.loads(line, object_pairs_hook=_unique_object)
107
+ except UnicodeDecodeError as exc:
108
+ raise RequestError("request is not UTF-8") from exc
109
+ except json.JSONDecodeError as exc:
110
+ raise RequestError("request is not valid JSON") from exc
111
+ if not isinstance(payload, dict) or set(payload) != {"verb", "args"}:
112
+ raise RequestError("request must contain exactly verb and args")
113
+ if not isinstance(payload["verb"], str) or not isinstance(payload["args"], dict):
114
+ raise RequestError("verb must be a string and args must be an object")
115
+ return payload
116
+
117
+
118
+ def _text(value: Any, name: str) -> str:
119
+ if not isinstance(value, str) or not value or "\x00" in value:
120
+ raise RequestError(f"{name} must be a non-empty string without NUL")
121
+ return value
122
+
123
+
124
+ def _can_interface(value: Any) -> str:
125
+ interface = _text(value, "iface")
126
+ if not CAN_INTERFACE.fullmatch(interface):
127
+ raise RequestError("iface must match ^(v?can)[0-9]{1,3}$")
128
+ return interface
129
+
130
+
131
+ def _network_interface(value: Any) -> str:
132
+ interface = _text(value, "iface")
133
+ if not NETWORK_INTERFACE.fullmatch(interface):
134
+ raise RequestError("iface must match ^[a-z0-9._-]{1,15}$")
135
+ return interface
136
+
137
+
138
+ def _bitrate(value: Any, name: str) -> int:
139
+ if type(value) is not int or not 10_000 <= value <= 10_000_000:
140
+ raise RequestError(f"{name} must be an integer from 10000 to 10000000")
141
+ return value
142
+
143
+
144
+ def _sample_point(value: Any, name: str) -> str:
145
+ """A CAN sample point as ip(8) wants it: a ratio, three decimals.
146
+
147
+ Bounded well inside 0..1 because the ends are not sample points at all --
148
+ a controller cannot sample a bit before it starts or after it ends.
149
+ """
150
+ if type(value) not in (int, float) or not 0.5 <= value <= 0.95:
151
+ raise RequestError(f"{name} must be a number from 0.5 to 0.95")
152
+ return f"{float(value):.3f}"
153
+
154
+
155
+ def _ipv4(value: Any, name: str) -> str:
156
+ text = _text(value, name)
157
+ try:
158
+ address = ipaddress.ip_address(text)
159
+ except ValueError as exc:
160
+ raise RequestError(f"{name} must be an IPv4 address") from exc
161
+ if address.version != 4:
162
+ raise RequestError(f"{name} must be an IPv4 address")
163
+ return str(address)
164
+
165
+
166
+ def _ipv4_interface(value: Any) -> str:
167
+ text = _text(value, "address")
168
+ if "/" not in text:
169
+ raise RequestError("address must be an IPv4 interface in CIDR notation")
170
+ try:
171
+ interface = ipaddress.ip_interface(text)
172
+ except ValueError as exc:
173
+ raise RequestError("address must be an IPv4 interface in CIDR notation") from exc
174
+ if interface.version != 4 or interface.ip.is_unspecified or interface.ip.is_multicast:
175
+ raise RequestError("address must be a usable IPv4 interface in CIDR notation")
176
+ return str(interface)
177
+
178
+
179
+ def _optional_ipv4(value: Any, name: str) -> str | None:
180
+ if value is None:
181
+ return None
182
+ address = _ipv4(value, name)
183
+ parsed = ipaddress.ip_address(address)
184
+ if parsed.is_unspecified or parsed.is_multicast:
185
+ raise RequestError(f"{name} must be a usable IPv4 address")
186
+ return address
187
+
188
+
189
+ def _mac(value: Any, name: str) -> str:
190
+ address = _text(value, name).lower()
191
+ if not re.fullmatch(r"(?:[0-9a-f]{2}:){5}[0-9a-f]{2}", address):
192
+ raise RequestError(f"{name} must be a colon-separated MAC address")
193
+ octets = bytes.fromhex(address.replace(":", ""))
194
+ if octets == b"\x00" * 6 or octets == b"\xff" * 6 or octets[0] & 1:
195
+ raise RequestError(f"{name} must be a unicast MAC address")
196
+ return address
197
+
198
+
199
+ def _optional_mac(value: Any, name: str) -> str | None:
200
+ if value is None:
201
+ return None
202
+ return _mac(value, name)
203
+
204
+
205
+ def _vlan_id(value: Any) -> int:
206
+ if type(value) is not int or not 1 <= value <= 4_094:
207
+ raise RequestError("vlan_id must be an integer from 1 to 4094")
208
+ return value
209
+
210
+
211
+ def _vlan_identity(args: dict[str, Any]) -> tuple[str, int, str, str]:
212
+ parent = _network_interface(args["parent"])
213
+ vlan_id = _vlan_id(args["vlan_id"])
214
+ interface = f"{parent}.{vlan_id}"
215
+ if len(interface) > 15:
216
+ raise RequestError("derived VLAN interface name exceeds 15 characters")
217
+ return parent, vlan_id, interface, f"iotsploit-vlan-{parent}-{vlan_id}"
218
+
219
+
220
+ def _vlan_config(args: dict[str, Any]) -> dict[str, Any]:
221
+ parent, vlan_id, interface, profile = _vlan_identity(args)
222
+ peer_ip = _optional_ipv4(args["peer_ip"], "peer_ip")
223
+ peer_mac = _optional_mac(args["peer_mac"], "peer_mac")
224
+ if (peer_ip is None) != (peer_mac is None):
225
+ raise RequestError("peer_ip and peer_mac must both be set or both be null")
226
+ return {
227
+ "parent": parent,
228
+ "vlan_id": vlan_id,
229
+ "interface": interface,
230
+ "profile": profile,
231
+ "address": _ipv4_interface(args["address"]),
232
+ "local_mac": _optional_mac(args["local_mac"], "local_mac"),
233
+ "peer_ip": peer_ip,
234
+ "peer_mac": peer_mac,
235
+ }
236
+
237
+
238
+ def _neighbor_command(config: dict[str, Any]) -> list[str] | None:
239
+ if config["peer_ip"] is None:
240
+ return None
241
+ return [
242
+ IP_EXECUTABLE,
243
+ "neigh",
244
+ "replace",
245
+ config["peer_ip"],
246
+ "lladdr",
247
+ config["peer_mac"],
248
+ "dev",
249
+ config["interface"],
250
+ "nud",
251
+ "permanent",
252
+ ]
253
+
254
+
255
+ def _cidr(value: Any) -> str:
256
+ text = _text(value, "cidr")
257
+ try:
258
+ network = ipaddress.ip_network(text, strict=False)
259
+ except ValueError as exc:
260
+ raise RequestError("cidr must be an IPv4 network") from exc
261
+ if network.version != 4 or network.num_addresses > 65_536:
262
+ raise RequestError("cidr must be IPv4 and no larger than /16")
263
+ return str(network)
264
+
265
+
266
+ def _validate_request(payload: dict[str, Any]) -> tuple[str, dict[str, Any], list[list[str]]]:
267
+ verb = payload["verb"]
268
+ args = payload["args"]
269
+ if verb not in VERB_KEYS:
270
+ raise RequestError(f"unknown verb: {verb}")
271
+ if set(args) != VERB_KEYS[verb]:
272
+ raise RequestError(f"{verb} requires exactly: {', '.join(sorted(VERB_KEYS[verb]))}")
273
+
274
+ if verb == "can-up":
275
+ interface = _can_interface(args["iface"])
276
+ bitrate = args["bitrate"]
277
+ if interface.startswith("vcan"):
278
+ if bitrate is not None:
279
+ raise RequestError("vcan bitrate must be null")
280
+ validated = {"iface": interface, "bitrate": None}
281
+ commands = [[IP_EXECUTABLE, "link", "set", "dev", interface, "up"]]
282
+ else:
283
+ validated = {"iface": interface, "bitrate": _bitrate(bitrate, "physical CAN bitrate")}
284
+ commands = [
285
+ [IP_EXECUTABLE, "link", "set", "dev", interface, "type", "can", "bitrate", str(bitrate)],
286
+ [IP_EXECUTABLE, "link", "set", "dev", interface, "up"],
287
+ ]
288
+ elif verb == "can-fd-up":
289
+ interface = _can_interface(args["iface"])
290
+ if interface.startswith("vcan"):
291
+ raise RequestError("a virtual CAN interface has no bit timing")
292
+ bitrate = _bitrate(args["bitrate"], "bitrate")
293
+ dbitrate = _bitrate(args["dbitrate"], "dbitrate")
294
+ sample_point = _sample_point(args["sample_point"], "sample_point")
295
+ dsample_point = _sample_point(args["dsample_point"], "dsample_point")
296
+ validated = {
297
+ "iface": interface,
298
+ "bitrate": bitrate,
299
+ "sample_point": sample_point,
300
+ "dbitrate": dbitrate,
301
+ "dsample_point": dsample_point,
302
+ }
303
+ # Bit timing cannot be set on a running link, so the link is lowered
304
+ # first. Both halves are one verb because a link left down between two
305
+ # calls is a bus nobody is listening to and nobody was told about.
306
+ commands = [
307
+ [IP_EXECUTABLE, "link", "set", "dev", interface, "down"],
308
+ [
309
+ IP_EXECUTABLE, "link", "set", "dev", interface, "type", "can",
310
+ "bitrate", str(bitrate), "sample-point", sample_point,
311
+ "dbitrate", str(dbitrate), "dsample-point", dsample_point,
312
+ "fd", "on",
313
+ ],
314
+ [IP_EXECUTABLE, "link", "set", "dev", interface, "up"],
315
+ ]
316
+ elif verb == "can-link-state":
317
+ interface = _can_interface(args["iface"])
318
+ state_value = _text(args["state"], "state")
319
+ if state_value not in {"up", "down"}:
320
+ raise RequestError("state must be up or down")
321
+ validated = {"iface": interface, "state": state_value}
322
+ commands = [[IP_EXECUTABLE, "link", "set", "dev", interface, state_value]]
323
+ elif verb == "doip-config":
324
+ interface = _network_interface(args["iface"])
325
+ validated = {"iface": interface}
326
+ commands = [
327
+ [IP_EXECUTABLE, "address", "replace", "169.254.58.58/16", "dev", interface],
328
+ [IP_EXECUTABLE, "route", "replace", "169.254.0.0/16", "dev", interface],
329
+ ]
330
+ elif verb == "route-via":
331
+ action = _text(args["action"], "action")
332
+ if action not in {"add", "delete"}:
333
+ raise RequestError("action must be add or delete")
334
+ network = _cidr(args["cidr"])
335
+ gateway = _ipv4(args["gateway"], "gateway")
336
+ validated = {"action": action, "cidr": network, "gateway": gateway}
337
+ commands = [[IP_EXECUTABLE, "route", action, network, "via", gateway]]
338
+ elif verb in {"vlan-add", "vlan-edit"}:
339
+ config = _vlan_config(args)
340
+ validated = {key: config[key] for key in args}
341
+ if verb == "vlan-add":
342
+ commands = [[
343
+ NMCLI_EXECUTABLE,
344
+ "--wait",
345
+ "10",
346
+ "connection",
347
+ "add",
348
+ "type",
349
+ "vlan",
350
+ "con-name",
351
+ config["profile"],
352
+ "ifname",
353
+ config["interface"],
354
+ "dev",
355
+ config["parent"],
356
+ "id",
357
+ str(config["vlan_id"]),
358
+ "ipv4.method",
359
+ "manual",
360
+ "ipv4.addresses",
361
+ config["address"],
362
+ "ipv4.never-default",
363
+ "yes",
364
+ "ipv6.method",
365
+ "disabled",
366
+ "connection.autoconnect",
367
+ "yes",
368
+ ]]
369
+ if config["local_mac"] is not None:
370
+ commands[0].extend(["802-3-ethernet.cloned-mac-address", config["local_mac"]])
371
+ else:
372
+ commands = [[
373
+ NMCLI_EXECUTABLE,
374
+ "--wait",
375
+ "10",
376
+ "connection",
377
+ "modify",
378
+ config["profile"],
379
+ "ipv4.method",
380
+ "manual",
381
+ "ipv4.addresses",
382
+ config["address"],
383
+ "ipv4.gateway",
384
+ "",
385
+ "ipv4.never-default",
386
+ "yes",
387
+ "ipv6.method",
388
+ "disabled",
389
+ "connection.autoconnect",
390
+ "yes",
391
+ "802-3-ethernet.cloned-mac-address",
392
+ config["local_mac"] or "",
393
+ ]]
394
+ commands.append([
395
+ NMCLI_EXECUTABLE,
396
+ "--wait",
397
+ "10",
398
+ "connection",
399
+ "up",
400
+ config["profile"],
401
+ ])
402
+ if verb == "vlan-edit":
403
+ commands.append([
404
+ IP_EXECUTABLE,
405
+ "neigh",
406
+ "flush",
407
+ "dev",
408
+ config["interface"],
409
+ "nud",
410
+ "permanent",
411
+ ])
412
+ neighbor = _neighbor_command(config)
413
+ if neighbor is not None:
414
+ commands.append(neighbor)
415
+ else:
416
+ parent, vlan_id, _, profile = _vlan_identity(args)
417
+ validated = {"parent": parent, "vlan_id": vlan_id}
418
+ commands = [[
419
+ NMCLI_EXECUTABLE,
420
+ "--wait",
421
+ "10",
422
+ "connection",
423
+ "delete",
424
+ profile,
425
+ ]]
426
+ return verb, validated, commands
427
+
428
+
429
+ def _append_capped(buffer: bytearray, chunk: bytes) -> bool:
430
+ available = MAX_OUTPUT_BYTES - len(buffer)
431
+ if available > 0:
432
+ buffer.extend(chunk[:available])
433
+ return len(chunk) > max(available, 0)
434
+
435
+
436
+ def _terminate_group(process: subprocess.Popen[bytes]) -> None:
437
+ try:
438
+ os.killpg(process.pid, signal.SIGTERM)
439
+ process.wait(timeout=1)
440
+ except subprocess.TimeoutExpired:
441
+ os.killpg(process.pid, signal.SIGKILL)
442
+ process.wait(timeout=1)
443
+ except ProcessLookupError:
444
+ pass
445
+
446
+
447
+ def _run_command(argv: list[str]) -> tuple[int, str, str, bool]:
448
+ process = subprocess.Popen(
449
+ argv,
450
+ stdin=subprocess.DEVNULL,
451
+ stdout=subprocess.PIPE,
452
+ stderr=subprocess.PIPE,
453
+ env={},
454
+ close_fds=True,
455
+ start_new_session=True,
456
+ )
457
+ assert process.stdout is not None and process.stderr is not None
458
+ selector = selectors.DefaultSelector()
459
+ selector.register(process.stdout, selectors.EVENT_READ, "stdout")
460
+ selector.register(process.stderr, selectors.EVENT_READ, "stderr")
461
+ buffers = {"stdout": bytearray(), "stderr": bytearray()}
462
+ truncated = False
463
+ deadline = time.monotonic() + COMMAND_TIMEOUT_SECONDS
464
+ timed_out = False
465
+
466
+ while selector.get_map():
467
+ remaining = deadline - time.monotonic()
468
+ if remaining <= 0 and not timed_out:
469
+ timed_out = True
470
+ _terminate_group(process)
471
+ for key, _ in selector.select(timeout=max(0.0, min(0.1, remaining)) if not timed_out else 0.1):
472
+ chunk = os.read(key.fileobj.fileno(), 4_096)
473
+ if chunk:
474
+ truncated = _append_capped(buffers[key.data], chunk) or truncated
475
+ else:
476
+ selector.unregister(key.fileobj)
477
+ selector.close()
478
+ return_code = process.wait()
479
+ if timed_out:
480
+ return_code = 124
481
+ truncated = _append_capped(buffers["stderr"], b"command timed out\n") or truncated
482
+ return (
483
+ return_code,
484
+ buffers["stdout"].decode("utf-8", errors="replace"),
485
+ buffers["stderr"].decode("utf-8", errors="replace"),
486
+ truncated,
487
+ )
488
+
489
+
490
+ def _execute(commands: list[list[str]]) -> tuple[int, str, str, bool]:
491
+ stdout_parts = []
492
+ stderr_parts = []
493
+ truncated = False
494
+ exit_code = 0
495
+ for command in commands:
496
+ exit_code, stdout, stderr, command_truncated = _run_command(command)
497
+ stdout_parts.append(stdout)
498
+ stderr_parts.append(stderr)
499
+ truncated = truncated or command_truncated
500
+ if exit_code != 0:
501
+ break
502
+ stdout_bytes = "".join(stdout_parts).encode()
503
+ stderr_bytes = "".join(stderr_parts).encode()
504
+ if len(stdout_bytes) > MAX_OUTPUT_BYTES or len(stderr_bytes) > MAX_OUTPUT_BYTES:
505
+ truncated = True
506
+ stdout = stdout_bytes[:MAX_OUTPUT_BYTES].decode("utf-8", errors="replace")
507
+ stderr = stderr_bytes[:MAX_OUTPUT_BYTES].decode("utf-8", errors="replace")
508
+ return exit_code, stdout, stderr, truncated
509
+
510
+
511
+ def _encoded_response(response: dict[str, Any]) -> bytes:
512
+ encoded = json.dumps(response, separators=(",", ":"), ensure_ascii=False).encode() + b"\n"
513
+ while len(encoded) > MAX_RESPONSE_BYTES and (response["stdout"] or response["stderr"]):
514
+ field = "stdout" if len(response["stdout"]) >= len(response["stderr"]) else "stderr"
515
+ response[field] = response[field][:-512]
516
+ response["output_truncated"] = True
517
+ encoded = json.dumps(response, separators=(",", ":"), ensure_ascii=False).encode() + b"\n"
518
+ return encoded
519
+
520
+
521
+ def _peer(connection: socket.socket) -> tuple[int, int]:
522
+ pid, uid, _ = struct.unpack("3i", connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
523
+ return pid, uid
524
+
525
+
526
+ def _audit(event: str, *, pid: int, uid: int, verb: str, args: dict[str, Any], **fields: Any) -> None:
527
+ record = {
528
+ "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
529
+ "event": event,
530
+ "peer_pid": pid,
531
+ "peer_uid": uid,
532
+ "verb": verb,
533
+ "args": args,
534
+ **fields,
535
+ }
536
+ print(json.dumps(record, sort_keys=True, separators=(",", ":")), file=sys.stderr, flush=True)
537
+
538
+
539
+ def _handle_connection(connection: socket.socket) -> None:
540
+ started = time.monotonic()
541
+ connection.settimeout(2)
542
+ try:
543
+ pid, uid = _peer(connection)
544
+ except OSError:
545
+ pid, uid = -1, -1
546
+ verb = "invalid"
547
+ args: dict[str, Any] = {}
548
+ try:
549
+ payload = _read_request(connection)
550
+ verb, args, commands = _validate_request(payload)
551
+ _audit("start", pid=pid, uid=uid, verb=verb, args=args)
552
+ exit_code, stdout, stderr, truncated = _execute(commands)
553
+ response: dict[str, Any] = {
554
+ "ok": exit_code == 0,
555
+ "exit": exit_code,
556
+ "stdout": stdout,
557
+ "stderr": stderr,
558
+ }
559
+ if truncated:
560
+ response["output_truncated"] = True
561
+ except (RequestError, OSError, ValueError) as exc:
562
+ response = {"ok": False, "exit": 2, "stdout": "", "stderr": str(exc)}
563
+ delivered = True
564
+ try:
565
+ connection.sendall(_encoded_response(response))
566
+ except OSError:
567
+ # The caller vanished before reading the reply. The command already ran,
568
+ # so the outcome belongs in the audit rather than in a dead daemon.
569
+ delivered = False
570
+ duration_ms = round((time.monotonic() - started) * 1_000, 3)
571
+ _audit(
572
+ "finish",
573
+ pid=pid,
574
+ uid=uid,
575
+ verb=verb,
576
+ args=args,
577
+ exit=response["exit"],
578
+ duration_ms=duration_ms,
579
+ delivered=delivered,
580
+ )
581
+
582
+
583
+ def _systemd_socket() -> socket.socket | None:
584
+ try:
585
+ listen_pid = int(os.getenv("LISTEN_PID", "0"))
586
+ listen_fds = int(os.getenv("LISTEN_FDS", "0"))
587
+ except ValueError:
588
+ return None
589
+ if listen_pid != os.getpid() or listen_fds != 1:
590
+ return None
591
+ listener = socket.socket(fileno=3)
592
+ if listener.family != socket.AF_UNIX:
593
+ raise RuntimeError("systemd fd 3 is not an AF_UNIX socket")
594
+ return listener
595
+
596
+
597
+ def _container_socket(path: Path, group_name: str) -> socket.socket:
598
+ path.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
599
+ if path.exists() or path.is_symlink():
600
+ metadata = path.lstat()
601
+ if metadata.st_uid != 0 or not stat.S_ISSOCK(metadata.st_mode):
602
+ raise RuntimeError(f"refusing to replace non-root or non-socket path: {path}")
603
+ path.unlink()
604
+ group_id = grp.getgrnam(group_name).gr_gid
605
+ listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
606
+ listener.bind(str(path))
607
+ os.chown(path, 0, group_id)
608
+ os.chmod(path, 0o660)
609
+ listener.listen(SOCKET_BACKLOG)
610
+ return listener
611
+
612
+
613
+ def _serve_once(listener: socket.socket) -> None:
614
+ connection, _ = listener.accept()
615
+ with connection:
616
+ try:
617
+ _handle_connection(connection)
618
+ except Exception as exc:
619
+ # One connection must never take the privileged daemon down with it.
620
+ _audit("error", pid=-1, uid=-1, verb="unknown", args={}, error=repr(exc))
621
+
622
+
623
+ def main() -> int:
624
+ parser = argparse.ArgumentParser()
625
+ parser.add_argument("--socket", type=Path, default=DEFAULT_SOCKET_PATH)
626
+ parser.add_argument("--group", default="iotsploit")
627
+ options = parser.parse_args()
628
+ listener = _systemd_socket() or _container_socket(options.socket, options.group)
629
+ while True:
630
+ _serve_once(listener)
631
+
632
+
633
+ if __name__ == "__main__":
634
+ raise SystemExit(main())
@@ -0,0 +1,15 @@
1
+ [tool.poetry]
2
+ name = "iotsploit-priv"
3
+ version = "0.0.9"
4
+ description = "Bounded privileged-operation client for IoTSploit"
5
+ authors = ["IoTSploit Team <support@iotsploit.org>"]
6
+ license = "GPL-3.0-or-later"
7
+ packages = [{ include = "iotsploit_priv", from = "src" }]
8
+ include = ["privd/iotsploit-privd", "install/iotsploit-priv-install", "systemd/*"]
9
+
10
+ [tool.poetry.dependencies]
11
+ python = ">=3.10,<4.0"
12
+
13
+ [build-system]
14
+ requires = ["poetry-core>=1.8.0"]
15
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,19 @@
1
+ from iotsploit_priv.client import (
2
+ INSTALL_HINT,
3
+ VERB_TABLE_HASH,
4
+ PrivilegedHelperError,
5
+ PrivilegedHelperProtocolError,
6
+ PrivilegedHelperUnavailable,
7
+ PrivilegedResult,
8
+ call,
9
+ )
10
+
11
+ __all__ = [
12
+ "INSTALL_HINT",
13
+ "VERB_TABLE_HASH",
14
+ "PrivilegedHelperError",
15
+ "PrivilegedHelperProtocolError",
16
+ "PrivilegedHelperUnavailable",
17
+ "PrivilegedResult",
18
+ "call",
19
+ ]
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import socket
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ DEFAULT_SOCKET_PATH = Path("/run/iotsploit/priv.sock")
13
+ MAX_REQUEST_BYTES = 4_096
14
+ MAX_RESPONSE_BYTES = 24_576
15
+ # A VLAN edit can run four commands, each capped at 10 seconds, so a shorter
16
+ # patience here reports a working helper as missing.
17
+ DEFAULT_TIMEOUT_SECONDS = 45.0
18
+ INSTALL_HINT = "Install the IoTSploit privileged helper with `priv install`."
19
+
20
+ VERB_SCHEMAS = {
21
+ "can-fd-up": {
22
+ "iface": "can",
23
+ "bitrate": "integer",
24
+ "sample_point": "ratio",
25
+ "dbitrate": "integer",
26
+ "dsample_point": "ratio",
27
+ },
28
+ "can-link-state": {"iface": "can", "state": ["up", "down"]},
29
+ "can-up": {"iface": "can", "bitrate": "integer-or-null"},
30
+ "doip-config": {"iface": "network"},
31
+ "route-via": {"action": ["add", "delete"], "cidr": "ipv4-/16", "gateway": "ipv4"},
32
+ "vlan-add": {
33
+ "parent": "network",
34
+ "vlan_id": "1-4094",
35
+ "address": "ipv4-interface",
36
+ "local_mac": "mac-or-null",
37
+ "peer_ip": "ipv4-or-null",
38
+ "peer_mac": "mac-or-null",
39
+ },
40
+ "vlan-edit": {
41
+ "parent": "network",
42
+ "vlan_id": "1-4094",
43
+ "address": "ipv4-interface",
44
+ "local_mac": "mac-or-null",
45
+ "peer_ip": "ipv4-or-null",
46
+ "peer_mac": "mac-or-null",
47
+ },
48
+ "vlan-delete": {"parent": "network", "vlan_id": "1-4094"},
49
+ }
50
+ VERB_TABLE_HASH = hashlib.sha256(
51
+ json.dumps(VERB_SCHEMAS, sort_keys=True, separators=(",", ":")).encode()
52
+ ).hexdigest()
53
+
54
+
55
+ class PrivilegedHelperError(RuntimeError):
56
+ pass
57
+
58
+
59
+ class PrivilegedHelperUnavailable(PrivilegedHelperError):
60
+ pass
61
+
62
+
63
+ class PrivilegedHelperProtocolError(PrivilegedHelperError):
64
+ pass
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class PrivilegedResult:
69
+ ok: bool
70
+ exit: int
71
+ stdout: str
72
+ stderr: str
73
+ output_truncated: bool = False
74
+
75
+
76
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
77
+ result: dict[str, Any] = {}
78
+ for key, value in pairs:
79
+ if key in result:
80
+ raise PrivilegedHelperProtocolError(f"duplicate response key: {key}")
81
+ result[key] = value
82
+ return result
83
+
84
+
85
+ def _decode_response(data: bytes) -> PrivilegedResult:
86
+ if len(data) > MAX_RESPONSE_BYTES:
87
+ raise PrivilegedHelperProtocolError("privileged helper response exceeds 24 KiB")
88
+ if not data.endswith(b"\n") or data.count(b"\n") != 1:
89
+ raise PrivilegedHelperProtocolError("privileged helper returned an incomplete or trailing response")
90
+ try:
91
+ payload = json.loads(data, object_pairs_hook=_unique_object)
92
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
93
+ raise PrivilegedHelperProtocolError("privileged helper returned invalid JSON") from exc
94
+ if not isinstance(payload, dict):
95
+ raise PrivilegedHelperProtocolError("privileged helper response must be an object")
96
+
97
+ required = {"ok", "exit", "stdout", "stderr"}
98
+ allowed = required | {"output_truncated"}
99
+ if set(payload) - allowed or not required.issubset(payload):
100
+ raise PrivilegedHelperProtocolError("privileged helper returned an unexpected response schema")
101
+ if type(payload["ok"]) is not bool or type(payload["exit"]) is not int:
102
+ raise PrivilegedHelperProtocolError("privileged helper returned invalid status fields")
103
+ if not isinstance(payload["stdout"], str) or not isinstance(payload["stderr"], str):
104
+ raise PrivilegedHelperProtocolError("privileged helper returned non-string output")
105
+ truncated = payload.get("output_truncated", False)
106
+ if type(truncated) is not bool:
107
+ raise PrivilegedHelperProtocolError("privileged helper returned an invalid truncation flag")
108
+ return PrivilegedResult(
109
+ ok=payload["ok"],
110
+ exit=payload["exit"],
111
+ stdout=payload["stdout"],
112
+ stderr=payload["stderr"],
113
+ output_truncated=truncated,
114
+ )
115
+
116
+
117
+ def call(
118
+ verb: str,
119
+ args: dict[str, Any],
120
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
121
+ *,
122
+ socket_path: str | os.PathLike[str] = DEFAULT_SOCKET_PATH,
123
+ ) -> PrivilegedResult:
124
+ if not isinstance(verb, str) or not verb:
125
+ raise ValueError("verb must be a non-empty string")
126
+ if not isinstance(args, dict):
127
+ raise ValueError("args must be an object")
128
+ request = json.dumps({"verb": verb, "args": args}, separators=(",", ":"), ensure_ascii=False).encode() + b"\n"
129
+ if len(request) > MAX_REQUEST_BYTES:
130
+ raise ValueError("privileged helper request exceeds 4 KiB")
131
+ if not hasattr(socket, "AF_UNIX"):
132
+ raise PrivilegedHelperUnavailable("Privileged helper is Linux-only.")
133
+
134
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
135
+ client.settimeout(float(timeout))
136
+ try:
137
+ client.connect(os.fspath(socket_path))
138
+ client.sendall(request)
139
+ client.shutdown(socket.SHUT_WR)
140
+ response = bytearray()
141
+ while True:
142
+ chunk = client.recv(4_096)
143
+ if not chunk:
144
+ break
145
+ response.extend(chunk)
146
+ if len(response) > MAX_RESPONSE_BYTES:
147
+ raise PrivilegedHelperProtocolError("privileged helper response exceeds 24 KiB")
148
+ except PrivilegedHelperProtocolError:
149
+ raise
150
+ except (OSError, TimeoutError) as exc:
151
+ raise PrivilegedHelperUnavailable(f"Privileged helper unavailable. {INSTALL_HINT}") from exc
152
+ finally:
153
+ client.close()
154
+ return _decode_response(bytes(response))
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import socket
8
+ import stat
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from .client import DEFAULT_SOCKET_PATH, VERB_SCHEMAS, VERB_TABLE_HASH
13
+
14
+
15
+ SOURCE_ROOT = Path(__file__).resolve().parents[2]
16
+ INSTALLER = SOURCE_ROOT / "install/iotsploit-priv-install"
17
+ DAEMON_SOURCE = SOURCE_ROOT / "privd/iotsploit-privd"
18
+ SYSTEMD_SOURCE = SOURCE_ROOT / "systemd"
19
+ DAEMON_DESTINATION = Path("/usr/local/libexec/iotsploit-privd")
20
+ UNIT_DESTINATIONS = (
21
+ Path("/etc/systemd/system/iotsploit-privd.socket"),
22
+ Path("/etc/systemd/system/iotsploit-privd.service"),
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class NativeStatus:
28
+ code: int
29
+ lines: tuple[str, ...]
30
+
31
+
32
+ def sha256_file(path: Path) -> str:
33
+ digest = hashlib.sha256()
34
+ with path.open("rb") as handle:
35
+ for chunk in iter(lambda: handle.read(65_536), b""):
36
+ digest.update(chunk)
37
+ return digest.hexdigest()
38
+
39
+
40
+ def install_manifest() -> tuple[tuple[Path, Path, int], ...]:
41
+ return (
42
+ (DAEMON_SOURCE, DAEMON_DESTINATION, 0o755),
43
+ (SYSTEMD_SOURCE / "iotsploit-privd.socket", UNIT_DESTINATIONS[0], 0o644),
44
+ (SYSTEMD_SOURCE / "iotsploit-privd.service", UNIT_DESTINATIONS[1], 0o644),
45
+ )
46
+
47
+
48
+ def current_user() -> str:
49
+ """The account this process runs as -- the one that must reach the socket."""
50
+ return os.environ.get("SUDO_USER") or getpass.getuser()
51
+
52
+
53
+ def _service_identity(service_user: str) -> tuple[int, set[int]]:
54
+ import pwd
55
+
56
+ account = pwd.getpwnam(service_user)
57
+ return account.pw_uid, set(os.getgrouplist(service_user, account.pw_gid))
58
+
59
+
60
+ def _writable_by(path: Path, uid: int, gids: set[int]) -> bool:
61
+ metadata = path.stat()
62
+ mode = stat.S_IMODE(metadata.st_mode)
63
+ if metadata.st_uid == uid:
64
+ return bool(mode & stat.S_IWUSR)
65
+ if metadata.st_gid in gids:
66
+ return bool(mode & stat.S_IWGRP)
67
+ return bool(mode & stat.S_IWOTH)
68
+
69
+
70
+ def _permission_diagnosis(caller: str, helper_group: "grp.struct_group") -> str:
71
+ """Say which of the two permission failures this is.
72
+
73
+ Group membership is fixed when a session starts, so the install can have
74
+ succeeded while the shell that ran it still cannot reach the socket. That
75
+ reads as a failed install unless it is named.
76
+ """
77
+ if caller in helper_group.gr_mem and helper_group.gr_gid not in os.getgroups():
78
+ return (
79
+ f"{caller} is in the iotsploit group, but this session started before that "
80
+ f"and still carries the old group set -- log out and back in, or run: newgrp iotsploit"
81
+ )
82
+ if caller not in helper_group.gr_mem:
83
+ return (
84
+ f"{caller} is not in the iotsploit group and cannot reach the socket "
85
+ f"-- run: priv install --service-user {caller}"
86
+ )
87
+ return f"{caller} cannot open the helper socket: permission denied"
88
+
89
+
90
+ def _health_probe(socket_path: Path) -> tuple[str, str] | None:
91
+ """Return None when the daemon answers correctly, else (kind, detail).
92
+
93
+ The probe runs as the invoking account, so a permission error here is a
94
+ statement about the caller's group membership, not about the daemon.
95
+ """
96
+ caller = current_user()
97
+ request = b'{"verb":"status","args":{}}\n'
98
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
99
+ client.settimeout(2)
100
+ try:
101
+ client.connect(os.fspath(socket_path))
102
+ client.sendall(request)
103
+ client.shutdown(socket.SHUT_WR)
104
+ response = client.recv(4_096)
105
+ except PermissionError:
106
+ return ("permission", f"{caller} cannot open {socket_path}: permission denied")
107
+ except OSError as exc:
108
+ return ("unreachable", f"{caller} cannot reach {socket_path}: {exc}")
109
+ finally:
110
+ client.close()
111
+ try:
112
+ payload = json.loads(response)
113
+ except (UnicodeDecodeError, json.JSONDecodeError):
114
+ return ("bad-response", "daemon did not return the bounded unknown-verb response")
115
+ answered = (
116
+ isinstance(payload, dict)
117
+ and payload.get("ok") is False
118
+ and payload.get("exit") == 2
119
+ and "unknown verb" in payload.get("stderr", "")
120
+ )
121
+ if answered:
122
+ return None
123
+ return ("bad-response", "daemon did not return the bounded unknown-verb response")
124
+
125
+
126
+ def native_status(
127
+ service_user: str | None = None,
128
+ *,
129
+ socket_path: Path = DEFAULT_SOCKET_PATH,
130
+ ) -> NativeStatus:
131
+ if not hasattr(socket, "AF_UNIX"):
132
+ return NativeStatus(1, ("privileged helper is Linux-only",))
133
+
134
+ import grp
135
+
136
+ service_user = service_user or current_user()
137
+ destinations = (DAEMON_DESTINATION, *UNIT_DESTINATIONS)
138
+ if not any(path.exists() or path.is_symlink() for path in (*destinations, socket_path)):
139
+ return NativeStatus(1, ("privileged helper is not installed",))
140
+
141
+ problems: list[str] = []
142
+ try:
143
+ service_uid, service_gids = _service_identity(service_user)
144
+ helper_group = grp.getgrnam("iotsploit")
145
+ except KeyError as exc:
146
+ return NativeStatus(2, (f"missing account or group: {exc}",))
147
+
148
+ for source, destination, expected_mode in install_manifest():
149
+ try:
150
+ metadata = destination.lstat()
151
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
152
+ problems.append(f"{destination} is not a regular file")
153
+ continue
154
+ if (metadata.st_uid, metadata.st_gid) != (0, 0):
155
+ problems.append(f"{destination} is not root:root")
156
+ if stat.S_IMODE(metadata.st_mode) != expected_mode:
157
+ problems.append(f"{destination} mode is not {expected_mode:04o}")
158
+ if sha256_file(source) != sha256_file(destination):
159
+ problems.append(f"{destination} checksum differs from packaged source")
160
+ parent = destination.parent
161
+ while True:
162
+ if _writable_by(parent, service_uid, service_gids):
163
+ problems.append(f"{parent} is writable by {service_user}")
164
+ if parent == parent.parent:
165
+ break
166
+ parent = parent.parent
167
+ except (FileNotFoundError, OSError) as exc:
168
+ problems.append(f"{destination}: {exc}")
169
+
170
+ if helper_group.gr_gid not in service_gids:
171
+ problems.append(f"{service_user} is not a member of iotsploit")
172
+ try:
173
+ metadata = socket_path.lstat()
174
+ if not stat.S_ISSOCK(metadata.st_mode):
175
+ problems.append(f"{socket_path} is not a socket")
176
+ if metadata.st_uid != 0 or metadata.st_gid != helper_group.gr_gid:
177
+ problems.append(f"{socket_path} is not root:iotsploit")
178
+ if stat.S_IMODE(metadata.st_mode) != 0o660:
179
+ problems.append(f"{socket_path} mode is not 0660")
180
+ except OSError as exc:
181
+ problems.append(f"{socket_path}: {exc}")
182
+ if not problems:
183
+ probe_failure = _health_probe(socket_path)
184
+ if probe_failure:
185
+ kind, detail = probe_failure
186
+ problems.append(
187
+ _permission_diagnosis(current_user(), helper_group) if kind == "permission" else detail
188
+ )
189
+
190
+ if problems:
191
+ return NativeStatus(2, tuple(problems))
192
+ members = ", ".join(sorted(set(helper_group.gr_mem))) or "none"
193
+ return NativeStatus(
194
+ 0,
195
+ (
196
+ "privileged helper is healthy",
197
+ f"iotsploit group members: {members}",
198
+ f"verb table sha256: {VERB_TABLE_HASH}",
199
+ ),
200
+ )
201
+
202
+
203
+ def verb_lines() -> tuple[str, ...]:
204
+ return tuple(
205
+ f"{verb}: {json.dumps(schema, sort_keys=True, separators=(',', ':'))}"
206
+ for verb, schema in sorted(VERB_SCHEMAS.items())
207
+ )
@@ -0,0 +1,20 @@
1
+ [Unit]
2
+ Description=IoTSploit bounded privileged-operation daemon
3
+ Requires=iotsploit-privd.socket
4
+ After=iotsploit-privd.socket
5
+
6
+ [Service]
7
+ Type=simple
8
+ ExecStart=/usr/local/libexec/iotsploit-privd
9
+ User=root
10
+ Group=root
11
+ NoNewPrivileges=yes
12
+ CapabilityBoundingSet=CAP_NET_ADMIN
13
+ AmbientCapabilities=CAP_NET_ADMIN
14
+ ProtectSystem=strict
15
+ ProtectHome=yes
16
+ PrivateTmp=yes
17
+ RestrictAddressFamilies=AF_UNIX AF_NETLINK
18
+
19
+ [Install]
20
+ WantedBy=multi-user.target
@@ -0,0 +1,13 @@
1
+ [Unit]
2
+ Description=IoTSploit privileged-operation socket
3
+
4
+ [Socket]
5
+ ListenStream=/run/iotsploit/priv.sock
6
+ SocketUser=root
7
+ SocketGroup=iotsploit
8
+ SocketMode=0660
9
+ DirectoryMode=0755
10
+ RemoveOnStop=true
11
+
12
+ [Install]
13
+ WantedBy=sockets.target
@@ -0,0 +1,4 @@
1
+ [Service]
2
+ NoNewPrivileges=yes
3
+ CapabilityBoundingSet=CAP_NET_RAW
4
+ AmbientCapabilities=CAP_NET_RAW