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.
- iotsploit_priv-0.0.9/PKG-INFO +16 -0
- iotsploit_priv-0.0.9/install/iotsploit-priv-install +121 -0
- iotsploit_priv-0.0.9/privd/iotsploit-privd +634 -0
- iotsploit_priv-0.0.9/pyproject.toml +15 -0
- iotsploit_priv-0.0.9/src/iotsploit_priv/__init__.py +19 -0
- iotsploit_priv-0.0.9/src/iotsploit_priv/client.py +154 -0
- iotsploit_priv-0.0.9/src/iotsploit_priv/native.py +207 -0
- iotsploit_priv-0.0.9/systemd/iotsploit-privd.service +20 -0
- iotsploit_priv-0.0.9/systemd/iotsploit-privd.socket +13 -0
- iotsploit_priv-0.0.9/systemd/iotsploit-worker-capabilities.conf +4 -0
|
@@ -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
|