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 +15 -0
- bootproof/__main__.py +8 -0
- bootproof/capture.py +120 -0
- bootproof/checklist.py +263 -0
- bootproof/cli.py +322 -0
- bootproof/errors.py +38 -0
- bootproof/preflight.py +185 -0
- bootproof/probes.py +399 -0
- bootproof/report.py +79 -0
- bootproof/runners.py +275 -0
- bootproof/snapshots.py +129 -0
- bootproof/verdict.py +108 -0
- bootproof-0.2.0.dist-info/METADATA +342 -0
- bootproof-0.2.0.dist-info/RECORD +18 -0
- bootproof-0.2.0.dist-info/WHEEL +5 -0
- bootproof-0.2.0.dist-info/entry_points.txt +2 -0
- bootproof-0.2.0.dist-info/licenses/LICENSE +21 -0
- bootproof-0.2.0.dist-info/top_level.txt +1 -0
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
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)
|