bootproof 0.2.0__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.
bootproof/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """bootproof — pre-reboot boot verification.
2
+
3
+ Snapshot the machine, boot the snapshot in an isolated VM, run a
4
+ service-up checklist (ports listening, systemd units active, health
5
+ endpoints green), and print one verdict — GO or NO-GO — before you
6
+ reboot the real box.
7
+
8
+ v1 is Linux-only. The verdict and checklist engines are fully
9
+ implemented; the VM-boot layer is a runner abstraction with a real
10
+ QEMU/KVM implementation path and a fixture (simulated) implementation
11
+ for hosts without KVM. A simulated boot is never presented as a real
12
+ boot: every report labels its boot_mode.
13
+ """
14
+
15
+ __version__ = "0.2.0"
bootproof/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow `python -m bootproof`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
bootproof/capture.py ADDED
@@ -0,0 +1,120 @@
1
+ """``bootproof capture``: draft a checklist from this machine.
2
+
3
+ Reads listening TCP ports from /proc/net/tcp and /proc/net/tcp6
4
+ (state 0A) and running services from systemctl, then writes a
5
+ DRAFT checklist for a human to review. It never runs a check.
6
+
7
+ Ports bound to 0.0.0.0, ::, or a loopback address are written
8
+ with host 127.0.0.1. A port bound only to one specific non-loopback
9
+ address is written with that address. Running services that
10
+ ``systemctl is-enabled`` says will not start at boot are left out
11
+ and listed as comments.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ipaddress
17
+ import socket
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ from .errors import BootproofError, ProbeError
22
+ from .probes import BOOT_ENABLED_STATES, systemctl
23
+
24
+ PROC_NET_TCP = ("/proc/net/tcp", "/proc/net/tcp6")
25
+ LISTEN = "0A"
26
+ SYSTEMCTL_TIMEOUT = 10
27
+
28
+
29
+ def _addr(hexaddr: str) -> str:
30
+ raw = bytes.fromhex(hexaddr)
31
+ # /proc stores each 32-bit word in host (little-endian) order.
32
+ fixed = b"".join(raw[i:i + 4][::-1] for i in range(0, len(raw), 4))
33
+ ip = ipaddress.ip_address(fixed)
34
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
35
+ ip = ip.ipv4_mapped
36
+ return str(ip)
37
+
38
+
39
+ def listening_ports(paths=None) -> set[tuple[str, int]]:
40
+ """(host, port) pairs in LISTEN state. Wildcard and loopback
41
+ binds map to 127.0.0.1. Raises if no /proc/net/tcp* is readable."""
42
+ found: set[tuple[str, int]] = set()
43
+ read_any = False
44
+ for path in paths or PROC_NET_TCP:
45
+ try:
46
+ lines = Path(path).read_text().splitlines()[1:]
47
+ except OSError:
48
+ continue
49
+ read_any = True
50
+ for line in lines:
51
+ fields = line.split()
52
+ if len(fields) < 4 or fields[3] != LISTEN:
53
+ continue
54
+ hexaddr, _, hexport = fields[1].partition(":")
55
+ host = _addr(hexaddr)
56
+ ip = ipaddress.ip_address(host)
57
+ if ip.is_unspecified or ip.is_loopback:
58
+ host = "127.0.0.1"
59
+ found.add((host, int(hexport, 16)))
60
+ if not read_any:
61
+ raise BootproofError("cannot read /proc/net/tcp or /proc/net/tcp6 "
62
+ "— capture needs a Linux host")
63
+ return found
64
+
65
+
66
+ def running_services() -> tuple[list[str], list[str]]:
67
+ """(units to check, comment lines). Raises ProbeError when
68
+ systemctl is missing."""
69
+ proc = systemctl(["list-units", "--type=service", "--state=running",
70
+ "--no-legend"], SYSTEMCTL_TIMEOUT)
71
+ keep: list[str] = []
72
+ notes: list[str] = []
73
+ for line in proc.stdout.splitlines():
74
+ fields = line.replace("●", " ").split()
75
+ if not fields:
76
+ continue
77
+ unit = fields[0]
78
+ state = systemctl(["is-enabled", unit],
79
+ SYSTEMCTL_TIMEOUT).stdout.strip() or "unknown"
80
+ if state in BOOT_ENABLED_STATES:
81
+ keep.append(unit)
82
+ else:
83
+ notes.append(f"# skipped {unit}: running now but is-enabled "
84
+ f"says {state} (will not start at boot)")
85
+ return sorted(keep), notes
86
+
87
+
88
+ def render(host: str, when: str, ports, units, notes) -> str:
89
+ lines = [f"# DRAFT captured from {host} at {when}; review before use. "
90
+ "Ports bound to non-loopback addresses are listed with host "
91
+ "127.0.0.1 only if also bound to 0.0.0.0/::",
92
+ *notes, "checks:"]
93
+ for h, port in sorted(ports, key=lambda p: (p[0] != "127.0.0.1",
94
+ p[0], p[1])):
95
+ lines.append(" - type: port")
96
+ if h != "127.0.0.1":
97
+ lines.append(f" host: {h}")
98
+ lines.append(f" port: {port}")
99
+ for unit in units:
100
+ lines += [" - type: unit", f" name: {unit}"]
101
+ return "\n".join(lines) + "\n"
102
+
103
+
104
+ def capture(output: str, force: bool = False) -> str:
105
+ out = Path(output)
106
+ if out.exists() and not force:
107
+ raise BootproofError(f"{out} exists — pass --force to overwrite")
108
+ ports = listening_ports()
109
+ try:
110
+ units, notes = running_services()
111
+ except ProbeError:
112
+ units, notes = [], ["# systemctl not found — no unit checks "
113
+ "captured"]
114
+ if not ports and not units:
115
+ raise BootproofError("nothing captured: no listening TCP ports "
116
+ "and no running enabled services")
117
+ when = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
118
+ text = render(socket.gethostname(), when, ports, units, notes)
119
+ out.write_text(text, encoding="utf-8")
120
+ return text
bootproof/checklist.py ADDED
@@ -0,0 +1,263 @@
1
+ """Checklist definition: what "up" means for this machine.
2
+
3
+ Format: JSON, or a small YAML subset (no PyYAML dependency —
4
+ stdlib only). A checklist is a mapping with a ``checks`` list. Each
5
+ check is one of:
6
+
7
+ port: {"type": "port", "host": "127.0.0.1", "port": 443}
8
+ unit: {"type": "unit", "name": "nginx.service",
9
+ "require_enabled": true}
10
+ http: {"type": "http", "url": "http://127.0.0.1:8080/health",
11
+ "expect_status": 200, "body_contains": "ok"}
12
+
13
+ Defaults: port host 127.0.0.1, http expect_status 200, per-check
14
+ timeout 5 seconds. An empty checklist is an error, not a GO: a
15
+ verdict over zero checks would verify nothing.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ from .errors import ChecklistError
25
+
26
+ CHECK_TYPES = ("port", "unit", "http")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Check:
31
+ type: str
32
+ # port
33
+ host: str = "127.0.0.1"
34
+ port: int | None = None
35
+ # unit
36
+ name: str | None = None
37
+ require_enabled: bool = True # --local only: must start at boot
38
+ # http
39
+ url: str | None = None
40
+ expect_status: int = 200
41
+ body_contains: str | None = None
42
+ timeout: float = 5.0
43
+
44
+ def describe(self) -> str:
45
+ if self.type == "port":
46
+ return f"port {self.port} listening on {self.host}"
47
+ if self.type == "unit":
48
+ return f"unit {self.name} active"
49
+ return (f"endpoint {self.url} returns {self.expect_status}"
50
+ + (f" containing {self.body_contains!r}"
51
+ if self.body_contains else ""))
52
+
53
+
54
+ @dataclass
55
+ class Checklist:
56
+ checks: list[Check] = field(default_factory=list)
57
+
58
+ def __len__(self) -> int:
59
+ return len(self.checks)
60
+
61
+
62
+ def _validate(raw: object, source: str) -> Checklist:
63
+ if not isinstance(raw, dict):
64
+ raise ChecklistError(f"{source}: top level must be a mapping "
65
+ f"with a 'checks' list")
66
+ raw_checks = raw.get("checks")
67
+ if not isinstance(raw_checks, list):
68
+ raise ChecklistError(f"{source}: missing or non-list 'checks'")
69
+ if not raw_checks:
70
+ raise ChecklistError(f"{source}: checklist is empty — a verdict "
71
+ f"over zero checks would verify nothing")
72
+ checks: list[Check] = []
73
+ for i, item in enumerate(raw_checks):
74
+ where = f"{source}: check #{i + 1}"
75
+ if not isinstance(item, dict):
76
+ raise ChecklistError(f"{where}: each check must be a mapping")
77
+ ctype = item.get("type")
78
+ if ctype not in CHECK_TYPES:
79
+ raise ChecklistError(
80
+ f"{where}: unknown check type {ctype!r} "
81
+ f"(expected one of: {', '.join(CHECK_TYPES)})")
82
+ try:
83
+ timeout = float(item.get("timeout", 5.0))
84
+ except (TypeError, ValueError):
85
+ raise ChecklistError(f"{where}: timeout must be a number")
86
+ if timeout <= 0:
87
+ raise ChecklistError(f"{where}: timeout must be positive")
88
+ if ctype == "port":
89
+ port = item.get("port")
90
+ if not isinstance(port, int) or isinstance(port, bool) \
91
+ or not 1 <= port <= 65535:
92
+ raise ChecklistError(
93
+ f"{where}: port check needs an integer 'port' "
94
+ f"in 1..65535")
95
+ host = item.get("host", "127.0.0.1")
96
+ if not isinstance(host, str) or not host:
97
+ raise ChecklistError(f"{where}: 'host' must be a "
98
+ f"non-empty string")
99
+ checks.append(Check(type="port", host=host, port=port,
100
+ timeout=timeout))
101
+ elif ctype == "unit":
102
+ name = item.get("name")
103
+ if not isinstance(name, str) or not name:
104
+ raise ChecklistError(f"{where}: unit check needs a "
105
+ f"non-empty 'name'")
106
+ req = item.get("require_enabled", True)
107
+ if not isinstance(req, bool):
108
+ raise ChecklistError(f"{where}: require_enabled must be "
109
+ f"true or false")
110
+ checks.append(Check(type="unit", name=name,
111
+ require_enabled=req, timeout=timeout))
112
+ else:
113
+ url = item.get("url")
114
+ if not isinstance(url, str) or not url:
115
+ raise ChecklistError(f"{where}: http check needs a "
116
+ f"non-empty 'url'")
117
+ if not (url.startswith("http://")
118
+ or url.startswith("https://")):
119
+ raise ChecklistError(f"{where}: url must start with "
120
+ f"http:// or https://")
121
+ status = item.get("expect_status", 200)
122
+ if not isinstance(status, int) or isinstance(status, bool) \
123
+ or not 100 <= status <= 599:
124
+ raise ChecklistError(f"{where}: expect_status must be "
125
+ f"an HTTP status code")
126
+ body = item.get("body_contains")
127
+ if body is not None and not isinstance(body, str):
128
+ raise ChecklistError(f"{where}: body_contains must be a "
129
+ f"string")
130
+ checks.append(Check(type="http", url=url,
131
+ expect_status=status, body_contains=body,
132
+ timeout=timeout))
133
+ return Checklist(checks=checks)
134
+
135
+
136
+ def _strip_comment(line: str) -> str:
137
+ """Strip a YAML ``#`` comment, but only when ``#`` starts the
138
+ line or is preceded by whitespace AND is not inside quotes. A
139
+ ``#`` inside a value (URL fragments, ``body_contains: "a #b"``)
140
+ is data, not a comment — the old rule (cut at the first ``#``)
141
+ silently mangled those."""
142
+ quote: str | None = None
143
+ for i, ch in enumerate(line):
144
+ if quote is not None:
145
+ if ch == quote:
146
+ quote = None
147
+ elif ch in "\"'":
148
+ quote = ch
149
+ elif ch == "#" and (i == 0 or line[i - 1] in " \t"):
150
+ return line[:i]
151
+ return line
152
+
153
+
154
+ def _parse_yaml_subset(text: str, source: str) -> dict:
155
+ """Minimal YAML subset parser, just enough for checklists.
156
+
157
+ Supports: top-level ``key: value`` pairs, a ``checks:`` list of
158
+ ``- key: value`` mappings with scalar values (strings, ints,
159
+ quoted strings), ``#`` comments, and blank lines. Anything else
160
+ raises ChecklistError naming the line. This is deliberately not
161
+ a general YAML parser — use JSON for anything fancier.
162
+ """
163
+ data: dict = {}
164
+ checks: list[dict] = []
165
+ current: dict | None = None
166
+ in_checks = False
167
+ for lineno, raw_line in enumerate(text.splitlines(), 1):
168
+ line = _strip_comment(raw_line).rstrip()
169
+ if not line.strip():
170
+ continue
171
+ indent = len(line) - len(line.lstrip())
172
+ stripped = line.strip()
173
+ if indent == 0:
174
+ if ":" not in stripped:
175
+ raise ChecklistError(f"{source}:{lineno}: expected "
176
+ f"'key: value'")
177
+ key, _, value = stripped.partition(":")
178
+ key = key.strip()
179
+ value = value.strip()
180
+ if key == "checks":
181
+ if value:
182
+ raise ChecklistError(f"{source}:{lineno}: 'checks' "
183
+ f"must be followed by a list")
184
+ in_checks = True
185
+ data["checks"] = checks
186
+ else:
187
+ in_checks = False
188
+ data[key] = _scalar(value, source, lineno)
189
+ current = None
190
+ continue
191
+ if not in_checks:
192
+ raise ChecklistError(f"{source}:{lineno}: unexpected "
193
+ f"indented line outside 'checks'")
194
+ if stripped.startswith("- "):
195
+ current = {}
196
+ checks.append(current)
197
+ stripped = stripped[2:].strip()
198
+ if not stripped:
199
+ continue
200
+ if current is None:
201
+ raise ChecklistError(f"{source}:{lineno}: list item must "
202
+ f"start with '- '")
203
+ if ":" not in stripped:
204
+ raise ChecklistError(f"{source}:{lineno}: expected "
205
+ f"'key: value'")
206
+ key, _, value = stripped.partition(":")
207
+ current[key.strip()] = _scalar(value.strip(), source, lineno)
208
+ if "checks" not in data:
209
+ raise ChecklistError(f"{source}: missing 'checks' list")
210
+ return data
211
+
212
+
213
+ def _scalar(value: str, source: str, lineno: int) -> object:
214
+ if value == "":
215
+ return None
216
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
217
+ return value[1:-1]
218
+ try:
219
+ return int(value)
220
+ except ValueError:
221
+ pass
222
+ try:
223
+ return float(value)
224
+ except ValueError:
225
+ pass
226
+ if value in ("true", "True"):
227
+ return True
228
+ if value in ("false", "False"):
229
+ return False
230
+ if value in ("null", "Null", "~"):
231
+ return None
232
+ # ": " (colon-space) is a YAML mapping indicator; a bare colon
233
+ # is fine — URLs like http://127.0.0.1:8080 need it.
234
+ if ": " in value or value.startswith(("- ", "[", "{")):
235
+ raise ChecklistError(f"{source}:{lineno}: unsupported YAML "
236
+ f"construct {value!r} — use JSON for "
237
+ f"complex values")
238
+ return value
239
+
240
+
241
+ def load_checklist(path: str | Path) -> Checklist:
242
+ """Load a checklist from a JSON file or the YAML subset above.
243
+ JSON is tried first when the file parses as JSON; otherwise the
244
+ YAML subset parser runs. Missing files raise ChecklistError."""
245
+ p = Path(path)
246
+ if not p.exists():
247
+ raise ChecklistError(f"checklist file not found: {p}")
248
+ text = p.read_text(encoding="utf-8")
249
+ try:
250
+ raw = json.loads(text)
251
+ except json.JSONDecodeError:
252
+ raw = _parse_yaml_subset(text, str(p))
253
+ return _validate(raw, str(p))
254
+
255
+
256
+ def parse_checklist_text(text: str, source: str = "<text>") -> Checklist:
257
+ """Parse checklist text (JSON or YAML subset), for tests and
258
+ embedding."""
259
+ try:
260
+ raw = json.loads(text)
261
+ except json.JSONDecodeError:
262
+ raw = _parse_yaml_subset(text, source)
263
+ return _validate(raw, source)