iotsploit-priv 0.0.9__py3-none-any.whl

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,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,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,6 @@
1
+ iotsploit_priv/__init__.py,sha256=DxgARFDlOaGAqaKBiOIIFLPNQteKHc_IMYi7DhMuXlg,399
2
+ iotsploit_priv/client.py,sha256=kQWrpQU2AMkinStCVllMyAT5wU5avuYoXg874kGfQ3E,5518
3
+ iotsploit_priv/native.py,sha256=RRTnMQSKCKL5wMkuyjgsWuLUsdGMNgZAGbDTpJFyX3M,7670
4
+ iotsploit_priv-0.0.9.dist-info/METADATA,sha256=t_ZNnlJXbkbcMpkMUURmGKxfDnr5xGcPM1nPKUKS1IU,672
5
+ iotsploit_priv-0.0.9.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
6
+ iotsploit_priv-0.0.9.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any