@mriekki/lean 0.1.0 → 0.2.0
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.
- package/README.md +71 -67
- package/assets/components.json +33 -33
- package/assets/core/distribution.json +52 -51
- package/assets/deployment/distribution.json +34 -34
- package/assets/generated/ASSEMBLY.json +38 -33
- package/assets/generated/core/core/skills/core-sync/SKILL.md +78 -120
- package/assets/generated/core/core/skills/deploy/SKILL.md +74 -91
- package/assets/generated/core/core/skills/lean-update/SKILL.md +189 -0
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +91 -76
- package/assets/generated/deployment/distribution/lean-poc-deploy.py +8 -8
- package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -3
- package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -3
- package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -140
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -455
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +415 -415
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -208
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -100
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -271
- package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -437
- package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -121
- package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -25
- package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -5
- package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -171
- package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -204
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -367
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -443
- package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -290
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +334 -334
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +339 -339
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +85 -85
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -437
- package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -118
- package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -286
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -120
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -100
- package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -278
- package/bin/lean.mjs +9 -9
- package/package.json +33 -33
- package/src/cli.mjs +131 -131
- package/src/engine.mjs +243 -243
- package/src/package-data.mjs +18 -18
|
@@ -1,278 +1,278 @@
|
|
|
1
|
-
"""Console-verified target host-trust enrollment and rotation."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import ipaddress
|
|
6
|
-
import os
|
|
7
|
-
import re
|
|
8
|
-
import shutil
|
|
9
|
-
import socket
|
|
10
|
-
import subprocess
|
|
11
|
-
import tempfile
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
from typing import Any
|
|
14
|
-
|
|
15
|
-
from .errors import DeploymentKitError
|
|
16
|
-
from .security import (
|
|
17
|
-
HOST_KEY_TYPES,
|
|
18
|
-
check_identity_security,
|
|
19
|
-
check_known_hosts_security,
|
|
20
|
-
known_hosts_token,
|
|
21
|
-
prepare_ssh_directory,
|
|
22
|
-
validate_target_trust_file,
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")
|
|
27
|
-
KEYSCAN_TYPES = "ed25519,ecdsa,rsa"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def required_tool(name: str) -> str:
|
|
31
|
-
path = shutil.which(name)
|
|
32
|
-
if path is None:
|
|
33
|
-
raise DeploymentKitError(f"Required OpenSSH tool is unavailable: {name}")
|
|
34
|
-
return path
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _validate_console_address(address: str) -> str:
|
|
38
|
-
try:
|
|
39
|
-
parsed = ipaddress.ip_address(address)
|
|
40
|
-
except ValueError as error:
|
|
41
|
-
raise DeploymentKitError(
|
|
42
|
-
"Console-provided trust address must be an exact IPv4 or IPv6 address, never a hostname."
|
|
43
|
-
) from error
|
|
44
|
-
if isinstance(parsed, ipaddress.IPv6Address) and parsed.scope_id is not None:
|
|
45
|
-
raise DeploymentKitError("Console-provided trust address must not include an IPv6 scope identifier.")
|
|
46
|
-
return str(parsed)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _scan_addresses(profile: dict[str, Any], console_address: str | None) -> list[str]:
|
|
50
|
-
if console_address is not None:
|
|
51
|
-
return [_validate_console_address(console_address)]
|
|
52
|
-
if profile["addressOverride"]:
|
|
53
|
-
return [profile["addressOverride"]]
|
|
54
|
-
try:
|
|
55
|
-
answers = socket.getaddrinfo(
|
|
56
|
-
profile["hostName"],
|
|
57
|
-
profile["port"],
|
|
58
|
-
type=socket.SOCK_STREAM,
|
|
59
|
-
)
|
|
60
|
-
except OSError as error:
|
|
61
|
-
raise DeploymentKitError(
|
|
62
|
-
f"Cannot resolve target hostname {profile['hostName']} for trust candidate discovery: {error}. "
|
|
63
|
-
"Obtain the exact current IP from the trusted target console and supply --address; do not scan the network."
|
|
64
|
-
) from error
|
|
65
|
-
addresses = sorted({answer[4][0] for answer in answers})
|
|
66
|
-
if not addresses:
|
|
67
|
-
raise DeploymentKitError(
|
|
68
|
-
"Target hostname returned no addresses. Supply only an exact address independently observed at the trusted console."
|
|
69
|
-
)
|
|
70
|
-
return addresses
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _fingerprint_public_key(key_type: str, key_data: str) -> str:
|
|
74
|
-
result = subprocess.run(
|
|
75
|
-
[required_tool("ssh-keygen"), "-lf", "-"],
|
|
76
|
-
input=f"{key_type} {key_data}\n",
|
|
77
|
-
check=False,
|
|
78
|
-
capture_output=True,
|
|
79
|
-
text=True,
|
|
80
|
-
encoding="utf-8",
|
|
81
|
-
errors="replace",
|
|
82
|
-
)
|
|
83
|
-
if result.returncode != 0:
|
|
84
|
-
raise DeploymentKitError("ssh-keygen could not fingerprint a scanned host-key candidate.")
|
|
85
|
-
fingerprint = next(
|
|
86
|
-
(token for token in result.stdout.split() if FINGERPRINT_RE.fullmatch(token)),
|
|
87
|
-
None,
|
|
88
|
-
)
|
|
89
|
-
if fingerprint is None:
|
|
90
|
-
raise DeploymentKitError("ssh-keygen returned no valid SHA256 fingerprint for a scanned host key.")
|
|
91
|
-
return fingerprint
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def scan_host_keys(
|
|
95
|
-
profile: dict[str, Any],
|
|
96
|
-
*,
|
|
97
|
-
console_address: str | None = None,
|
|
98
|
-
) -> list[dict[str, str]]:
|
|
99
|
-
candidates: dict[tuple[str, str], dict[str, str]] = {}
|
|
100
|
-
failures: list[str] = []
|
|
101
|
-
for address in _scan_addresses(profile, console_address):
|
|
102
|
-
result = subprocess.run(
|
|
103
|
-
[
|
|
104
|
-
required_tool("ssh-keyscan"),
|
|
105
|
-
"-T",
|
|
106
|
-
"5",
|
|
107
|
-
"-p",
|
|
108
|
-
str(profile["port"]),
|
|
109
|
-
"-t",
|
|
110
|
-
KEYSCAN_TYPES,
|
|
111
|
-
address,
|
|
112
|
-
],
|
|
113
|
-
check=False,
|
|
114
|
-
capture_output=True,
|
|
115
|
-
text=True,
|
|
116
|
-
encoding="utf-8",
|
|
117
|
-
errors="replace",
|
|
118
|
-
)
|
|
119
|
-
found = False
|
|
120
|
-
for raw in result.stdout.splitlines():
|
|
121
|
-
line = raw.strip()
|
|
122
|
-
if not line or line.startswith("#"):
|
|
123
|
-
continue
|
|
124
|
-
fields = line.split()
|
|
125
|
-
if len(fields) < 3:
|
|
126
|
-
raise DeploymentKitError("ssh-keyscan returned a malformed host-key candidate.")
|
|
127
|
-
key_type, key_data = fields[1], fields[2]
|
|
128
|
-
if key_type not in HOST_KEY_TYPES:
|
|
129
|
-
continue
|
|
130
|
-
found = True
|
|
131
|
-
fingerprint = _fingerprint_public_key(key_type, key_data)
|
|
132
|
-
candidates[(key_type, key_data)] = {
|
|
133
|
-
"address": address,
|
|
134
|
-
"keyType": key_type,
|
|
135
|
-
"keyData": key_data,
|
|
136
|
-
"fingerprint": fingerprint,
|
|
137
|
-
}
|
|
138
|
-
if not found:
|
|
139
|
-
failures.append(f"{address}: exit {result.returncode}")
|
|
140
|
-
if not candidates:
|
|
141
|
-
raise DeploymentKitError(
|
|
142
|
-
"No supported host-key candidates were returned for the selected target address(es): "
|
|
143
|
-
+ "; ".join(failures)
|
|
144
|
-
)
|
|
145
|
-
return sorted(candidates.values(), key=lambda item: (item["fingerprint"], item["keyType"], item["address"]))
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def candidate_view(candidate: dict[str, str]) -> dict[str, str]:
|
|
149
|
-
return {
|
|
150
|
-
"address": candidate["address"],
|
|
151
|
-
"keyType": candidate["keyType"],
|
|
152
|
-
"fingerprint": candidate["fingerprint"],
|
|
153
|
-
"trustState": "candidate-only-not-trusted",
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def _write_exact_trust(profile: dict[str, Any], key_type: str, key_data: str) -> None:
|
|
158
|
-
path = Path(profile["knownHostsFile"])
|
|
159
|
-
prepare_ssh_directory(path.parent)
|
|
160
|
-
if path.exists() and path.is_symlink():
|
|
161
|
-
raise DeploymentKitError(f"Refusing to replace symbolic-link trust file: {path}")
|
|
162
|
-
|
|
163
|
-
token = known_hosts_token(profile)
|
|
164
|
-
line = f"{token} {key_type} {key_data}\n"
|
|
165
|
-
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent), text=True)
|
|
166
|
-
temp = Path(temp_name)
|
|
167
|
-
try:
|
|
168
|
-
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
169
|
-
handle.write(line)
|
|
170
|
-
if os.name != "nt":
|
|
171
|
-
temp.chmod(0o600)
|
|
172
|
-
check_known_hosts_security(temp)
|
|
173
|
-
temp.replace(path)
|
|
174
|
-
check_known_hosts_security(path)
|
|
175
|
-
validate_target_trust_file(profile)
|
|
176
|
-
except Exception:
|
|
177
|
-
try:
|
|
178
|
-
if temp.exists():
|
|
179
|
-
temp.unlink()
|
|
180
|
-
finally:
|
|
181
|
-
raise
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def plan_console_verified_trust(
|
|
185
|
-
profile: dict[str, Any],
|
|
186
|
-
*,
|
|
187
|
-
fingerprint: str,
|
|
188
|
-
rotate: bool,
|
|
189
|
-
console_address: str | None = None,
|
|
190
|
-
) -> dict[str, Any]:
|
|
191
|
-
if FINGERPRINT_RE.fullmatch(fingerprint) is None:
|
|
192
|
-
raise DeploymentKitError(
|
|
193
|
-
"Supply the SHA256 fingerprint independently observed at the trusted target console."
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
identity = Path(profile["identityFile"])
|
|
197
|
-
trust_path = Path(profile["knownHostsFile"])
|
|
198
|
-
check_identity_security(identity)
|
|
199
|
-
existing = validate_target_trust_file(profile, allow_missing_or_empty=True)
|
|
200
|
-
if rotate:
|
|
201
|
-
if not existing:
|
|
202
|
-
raise DeploymentKitError("Trust rotation requires an existing valid target-specific trust entry.")
|
|
203
|
-
check_known_hosts_security(trust_path)
|
|
204
|
-
elif existing:
|
|
205
|
-
raise DeploymentKitError("Target trust is already enrolled; use explicit trust rotation instead.")
|
|
206
|
-
|
|
207
|
-
candidates = scan_host_keys(profile, console_address=console_address)
|
|
208
|
-
matches = [candidate for candidate in candidates if candidate["fingerprint"] == fingerprint]
|
|
209
|
-
if not matches:
|
|
210
|
-
raise DeploymentKitError(
|
|
211
|
-
"The console-observed fingerprint did not match any candidate returned by the configured target/address. "
|
|
212
|
-
"No trust was written."
|
|
213
|
-
)
|
|
214
|
-
identities = {(item["keyType"], item["keyData"]) for item in matches}
|
|
215
|
-
if len(identities) != 1:
|
|
216
|
-
raise DeploymentKitError("Fingerprint matched inconsistent host-key candidates; no trust was written.")
|
|
217
|
-
|
|
218
|
-
selected = matches[0]
|
|
219
|
-
return {
|
|
220
|
-
"targetName": profile["targetName"],
|
|
221
|
-
"action": "rotate" if rotate else "enroll",
|
|
222
|
-
"fingerprint": fingerprint,
|
|
223
|
-
"keyType": selected["keyType"],
|
|
224
|
-
"keyData": selected["keyData"],
|
|
225
|
-
"address": selected["address"],
|
|
226
|
-
"knownHostsFile": str(trust_path),
|
|
227
|
-
"priorTrust": existing,
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def trust_plan_view(plan: dict[str, Any]) -> dict[str, str]:
|
|
232
|
-
return {
|
|
233
|
-
"targetName": plan["targetName"],
|
|
234
|
-
"action": plan["action"],
|
|
235
|
-
"address": plan["address"],
|
|
236
|
-
"keyType": plan["keyType"],
|
|
237
|
-
"fingerprint": plan["fingerprint"],
|
|
238
|
-
"knownHostsFile": plan["knownHostsFile"],
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def apply_trust_plan(profile: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
|
|
243
|
-
if plan["targetName"] != profile["targetName"]:
|
|
244
|
-
raise DeploymentKitError("Trust plan target does not match the selected profile.")
|
|
245
|
-
current = validate_target_trust_file(profile, allow_missing_or_empty=True)
|
|
246
|
-
if current != plan["priorTrust"]:
|
|
247
|
-
raise DeploymentKitError(
|
|
248
|
-
"Target trust state changed after the plan was reviewed; no trust was written. Re-plan and re-authorize."
|
|
249
|
-
)
|
|
250
|
-
if current:
|
|
251
|
-
check_known_hosts_security(Path(profile["knownHostsFile"]))
|
|
252
|
-
check_identity_security(Path(profile["identityFile"]))
|
|
253
|
-
_write_exact_trust(profile, plan["keyType"], plan["keyData"])
|
|
254
|
-
return {
|
|
255
|
-
"targetName": profile["targetName"],
|
|
256
|
-
"status": "rotated" if plan["action"] == "rotate" else "enrolled",
|
|
257
|
-
"fingerprint": plan["fingerprint"],
|
|
258
|
-
"keyType": plan["keyType"],
|
|
259
|
-
"address": plan["address"],
|
|
260
|
-
"knownHostsFile": plan["knownHostsFile"],
|
|
261
|
-
"mutated": True,
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def apply_console_verified_trust(
|
|
266
|
-
profile: dict[str, Any],
|
|
267
|
-
*,
|
|
268
|
-
fingerprint: str,
|
|
269
|
-
rotate: bool,
|
|
270
|
-
console_address: str | None = None,
|
|
271
|
-
) -> dict[str, Any]:
|
|
272
|
-
plan = plan_console_verified_trust(
|
|
273
|
-
profile,
|
|
274
|
-
fingerprint=fingerprint,
|
|
275
|
-
rotate=rotate,
|
|
276
|
-
console_address=console_address,
|
|
277
|
-
)
|
|
278
|
-
return apply_trust_plan(profile, plan)
|
|
1
|
+
"""Console-verified target host-trust enrollment and rotation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .errors import DeploymentKitError
|
|
16
|
+
from .security import (
|
|
17
|
+
HOST_KEY_TYPES,
|
|
18
|
+
check_identity_security,
|
|
19
|
+
check_known_hosts_security,
|
|
20
|
+
known_hosts_token,
|
|
21
|
+
prepare_ssh_directory,
|
|
22
|
+
validate_target_trust_file,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")
|
|
27
|
+
KEYSCAN_TYPES = "ed25519,ecdsa,rsa"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def required_tool(name: str) -> str:
|
|
31
|
+
path = shutil.which(name)
|
|
32
|
+
if path is None:
|
|
33
|
+
raise DeploymentKitError(f"Required OpenSSH tool is unavailable: {name}")
|
|
34
|
+
return path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _validate_console_address(address: str) -> str:
|
|
38
|
+
try:
|
|
39
|
+
parsed = ipaddress.ip_address(address)
|
|
40
|
+
except ValueError as error:
|
|
41
|
+
raise DeploymentKitError(
|
|
42
|
+
"Console-provided trust address must be an exact IPv4 or IPv6 address, never a hostname."
|
|
43
|
+
) from error
|
|
44
|
+
if isinstance(parsed, ipaddress.IPv6Address) and parsed.scope_id is not None:
|
|
45
|
+
raise DeploymentKitError("Console-provided trust address must not include an IPv6 scope identifier.")
|
|
46
|
+
return str(parsed)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _scan_addresses(profile: dict[str, Any], console_address: str | None) -> list[str]:
|
|
50
|
+
if console_address is not None:
|
|
51
|
+
return [_validate_console_address(console_address)]
|
|
52
|
+
if profile["addressOverride"]:
|
|
53
|
+
return [profile["addressOverride"]]
|
|
54
|
+
try:
|
|
55
|
+
answers = socket.getaddrinfo(
|
|
56
|
+
profile["hostName"],
|
|
57
|
+
profile["port"],
|
|
58
|
+
type=socket.SOCK_STREAM,
|
|
59
|
+
)
|
|
60
|
+
except OSError as error:
|
|
61
|
+
raise DeploymentKitError(
|
|
62
|
+
f"Cannot resolve target hostname {profile['hostName']} for trust candidate discovery: {error}. "
|
|
63
|
+
"Obtain the exact current IP from the trusted target console and supply --address; do not scan the network."
|
|
64
|
+
) from error
|
|
65
|
+
addresses = sorted({answer[4][0] for answer in answers})
|
|
66
|
+
if not addresses:
|
|
67
|
+
raise DeploymentKitError(
|
|
68
|
+
"Target hostname returned no addresses. Supply only an exact address independently observed at the trusted console."
|
|
69
|
+
)
|
|
70
|
+
return addresses
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _fingerprint_public_key(key_type: str, key_data: str) -> str:
|
|
74
|
+
result = subprocess.run(
|
|
75
|
+
[required_tool("ssh-keygen"), "-lf", "-"],
|
|
76
|
+
input=f"{key_type} {key_data}\n",
|
|
77
|
+
check=False,
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
encoding="utf-8",
|
|
81
|
+
errors="replace",
|
|
82
|
+
)
|
|
83
|
+
if result.returncode != 0:
|
|
84
|
+
raise DeploymentKitError("ssh-keygen could not fingerprint a scanned host-key candidate.")
|
|
85
|
+
fingerprint = next(
|
|
86
|
+
(token for token in result.stdout.split() if FINGERPRINT_RE.fullmatch(token)),
|
|
87
|
+
None,
|
|
88
|
+
)
|
|
89
|
+
if fingerprint is None:
|
|
90
|
+
raise DeploymentKitError("ssh-keygen returned no valid SHA256 fingerprint for a scanned host key.")
|
|
91
|
+
return fingerprint
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def scan_host_keys(
|
|
95
|
+
profile: dict[str, Any],
|
|
96
|
+
*,
|
|
97
|
+
console_address: str | None = None,
|
|
98
|
+
) -> list[dict[str, str]]:
|
|
99
|
+
candidates: dict[tuple[str, str], dict[str, str]] = {}
|
|
100
|
+
failures: list[str] = []
|
|
101
|
+
for address in _scan_addresses(profile, console_address):
|
|
102
|
+
result = subprocess.run(
|
|
103
|
+
[
|
|
104
|
+
required_tool("ssh-keyscan"),
|
|
105
|
+
"-T",
|
|
106
|
+
"5",
|
|
107
|
+
"-p",
|
|
108
|
+
str(profile["port"]),
|
|
109
|
+
"-t",
|
|
110
|
+
KEYSCAN_TYPES,
|
|
111
|
+
address,
|
|
112
|
+
],
|
|
113
|
+
check=False,
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
encoding="utf-8",
|
|
117
|
+
errors="replace",
|
|
118
|
+
)
|
|
119
|
+
found = False
|
|
120
|
+
for raw in result.stdout.splitlines():
|
|
121
|
+
line = raw.strip()
|
|
122
|
+
if not line or line.startswith("#"):
|
|
123
|
+
continue
|
|
124
|
+
fields = line.split()
|
|
125
|
+
if len(fields) < 3:
|
|
126
|
+
raise DeploymentKitError("ssh-keyscan returned a malformed host-key candidate.")
|
|
127
|
+
key_type, key_data = fields[1], fields[2]
|
|
128
|
+
if key_type not in HOST_KEY_TYPES:
|
|
129
|
+
continue
|
|
130
|
+
found = True
|
|
131
|
+
fingerprint = _fingerprint_public_key(key_type, key_data)
|
|
132
|
+
candidates[(key_type, key_data)] = {
|
|
133
|
+
"address": address,
|
|
134
|
+
"keyType": key_type,
|
|
135
|
+
"keyData": key_data,
|
|
136
|
+
"fingerprint": fingerprint,
|
|
137
|
+
}
|
|
138
|
+
if not found:
|
|
139
|
+
failures.append(f"{address}: exit {result.returncode}")
|
|
140
|
+
if not candidates:
|
|
141
|
+
raise DeploymentKitError(
|
|
142
|
+
"No supported host-key candidates were returned for the selected target address(es): "
|
|
143
|
+
+ "; ".join(failures)
|
|
144
|
+
)
|
|
145
|
+
return sorted(candidates.values(), key=lambda item: (item["fingerprint"], item["keyType"], item["address"]))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def candidate_view(candidate: dict[str, str]) -> dict[str, str]:
|
|
149
|
+
return {
|
|
150
|
+
"address": candidate["address"],
|
|
151
|
+
"keyType": candidate["keyType"],
|
|
152
|
+
"fingerprint": candidate["fingerprint"],
|
|
153
|
+
"trustState": "candidate-only-not-trusted",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _write_exact_trust(profile: dict[str, Any], key_type: str, key_data: str) -> None:
|
|
158
|
+
path = Path(profile["knownHostsFile"])
|
|
159
|
+
prepare_ssh_directory(path.parent)
|
|
160
|
+
if path.exists() and path.is_symlink():
|
|
161
|
+
raise DeploymentKitError(f"Refusing to replace symbolic-link trust file: {path}")
|
|
162
|
+
|
|
163
|
+
token = known_hosts_token(profile)
|
|
164
|
+
line = f"{token} {key_type} {key_data}\n"
|
|
165
|
+
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent), text=True)
|
|
166
|
+
temp = Path(temp_name)
|
|
167
|
+
try:
|
|
168
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
169
|
+
handle.write(line)
|
|
170
|
+
if os.name != "nt":
|
|
171
|
+
temp.chmod(0o600)
|
|
172
|
+
check_known_hosts_security(temp)
|
|
173
|
+
temp.replace(path)
|
|
174
|
+
check_known_hosts_security(path)
|
|
175
|
+
validate_target_trust_file(profile)
|
|
176
|
+
except Exception:
|
|
177
|
+
try:
|
|
178
|
+
if temp.exists():
|
|
179
|
+
temp.unlink()
|
|
180
|
+
finally:
|
|
181
|
+
raise
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def plan_console_verified_trust(
|
|
185
|
+
profile: dict[str, Any],
|
|
186
|
+
*,
|
|
187
|
+
fingerprint: str,
|
|
188
|
+
rotate: bool,
|
|
189
|
+
console_address: str | None = None,
|
|
190
|
+
) -> dict[str, Any]:
|
|
191
|
+
if FINGERPRINT_RE.fullmatch(fingerprint) is None:
|
|
192
|
+
raise DeploymentKitError(
|
|
193
|
+
"Supply the SHA256 fingerprint independently observed at the trusted target console."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
identity = Path(profile["identityFile"])
|
|
197
|
+
trust_path = Path(profile["knownHostsFile"])
|
|
198
|
+
check_identity_security(identity)
|
|
199
|
+
existing = validate_target_trust_file(profile, allow_missing_or_empty=True)
|
|
200
|
+
if rotate:
|
|
201
|
+
if not existing:
|
|
202
|
+
raise DeploymentKitError("Trust rotation requires an existing valid target-specific trust entry.")
|
|
203
|
+
check_known_hosts_security(trust_path)
|
|
204
|
+
elif existing:
|
|
205
|
+
raise DeploymentKitError("Target trust is already enrolled; use explicit trust rotation instead.")
|
|
206
|
+
|
|
207
|
+
candidates = scan_host_keys(profile, console_address=console_address)
|
|
208
|
+
matches = [candidate for candidate in candidates if candidate["fingerprint"] == fingerprint]
|
|
209
|
+
if not matches:
|
|
210
|
+
raise DeploymentKitError(
|
|
211
|
+
"The console-observed fingerprint did not match any candidate returned by the configured target/address. "
|
|
212
|
+
"No trust was written."
|
|
213
|
+
)
|
|
214
|
+
identities = {(item["keyType"], item["keyData"]) for item in matches}
|
|
215
|
+
if len(identities) != 1:
|
|
216
|
+
raise DeploymentKitError("Fingerprint matched inconsistent host-key candidates; no trust was written.")
|
|
217
|
+
|
|
218
|
+
selected = matches[0]
|
|
219
|
+
return {
|
|
220
|
+
"targetName": profile["targetName"],
|
|
221
|
+
"action": "rotate" if rotate else "enroll",
|
|
222
|
+
"fingerprint": fingerprint,
|
|
223
|
+
"keyType": selected["keyType"],
|
|
224
|
+
"keyData": selected["keyData"],
|
|
225
|
+
"address": selected["address"],
|
|
226
|
+
"knownHostsFile": str(trust_path),
|
|
227
|
+
"priorTrust": existing,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def trust_plan_view(plan: dict[str, Any]) -> dict[str, str]:
|
|
232
|
+
return {
|
|
233
|
+
"targetName": plan["targetName"],
|
|
234
|
+
"action": plan["action"],
|
|
235
|
+
"address": plan["address"],
|
|
236
|
+
"keyType": plan["keyType"],
|
|
237
|
+
"fingerprint": plan["fingerprint"],
|
|
238
|
+
"knownHostsFile": plan["knownHostsFile"],
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def apply_trust_plan(profile: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
|
|
243
|
+
if plan["targetName"] != profile["targetName"]:
|
|
244
|
+
raise DeploymentKitError("Trust plan target does not match the selected profile.")
|
|
245
|
+
current = validate_target_trust_file(profile, allow_missing_or_empty=True)
|
|
246
|
+
if current != plan["priorTrust"]:
|
|
247
|
+
raise DeploymentKitError(
|
|
248
|
+
"Target trust state changed after the plan was reviewed; no trust was written. Re-plan and re-authorize."
|
|
249
|
+
)
|
|
250
|
+
if current:
|
|
251
|
+
check_known_hosts_security(Path(profile["knownHostsFile"]))
|
|
252
|
+
check_identity_security(Path(profile["identityFile"]))
|
|
253
|
+
_write_exact_trust(profile, plan["keyType"], plan["keyData"])
|
|
254
|
+
return {
|
|
255
|
+
"targetName": profile["targetName"],
|
|
256
|
+
"status": "rotated" if plan["action"] == "rotate" else "enrolled",
|
|
257
|
+
"fingerprint": plan["fingerprint"],
|
|
258
|
+
"keyType": plan["keyType"],
|
|
259
|
+
"address": plan["address"],
|
|
260
|
+
"knownHostsFile": plan["knownHostsFile"],
|
|
261
|
+
"mutated": True,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def apply_console_verified_trust(
|
|
266
|
+
profile: dict[str, Any],
|
|
267
|
+
*,
|
|
268
|
+
fingerprint: str,
|
|
269
|
+
rotate: bool,
|
|
270
|
+
console_address: str | None = None,
|
|
271
|
+
) -> dict[str, Any]:
|
|
272
|
+
plan = plan_console_verified_trust(
|
|
273
|
+
profile,
|
|
274
|
+
fingerprint=fingerprint,
|
|
275
|
+
rotate=rotate,
|
|
276
|
+
console_address=console_address,
|
|
277
|
+
)
|
|
278
|
+
return apply_trust_plan(profile, plan)
|
package/bin/lean.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { run } from '../src/cli.mjs';
|
|
3
|
-
|
|
4
|
-
try {
|
|
5
|
-
process.exitCode = await run();
|
|
6
|
-
} catch (error) {
|
|
7
|
-
console.error(`Lean error: ${error.message}`);
|
|
8
|
-
process.exitCode = 2;
|
|
9
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from '../src/cli.mjs';
|
|
3
|
+
|
|
4
|
+
try {
|
|
5
|
+
process.exitCode = await run();
|
|
6
|
+
} catch (error) {
|
|
7
|
+
console.error(`Lean error: ${error.message}`);
|
|
8
|
+
process.exitCode = 2;
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@mriekki/lean",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Public consumer distribution and updater for Lean engineering assets.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"lean": "bin/lean.mjs"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"bin/",
|
|
11
|
-
"src/",
|
|
12
|
-
"assets/",
|
|
13
|
-
"README.md"
|
|
14
|
-
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"test": "node --test",
|
|
17
|
-
"assemble:assets": "node scripts/assemble-assets.mjs",
|
|
18
|
-
"verify:assets": "node scripts/verify-assets.mjs",
|
|
19
|
-
"release:prepare": "node scripts/prepare-release.mjs",
|
|
20
|
-
"prepack": "npm run verify:assets"
|
|
21
|
-
},
|
|
22
|
-
"engines": {
|
|
23
|
-
"node": ">=20"
|
|
24
|
-
},
|
|
25
|
-
"repository": {
|
|
26
|
-
"type": "git",
|
|
27
|
-
"url": "git+https://github.com/mriekki/lean-platform.git"
|
|
28
|
-
},
|
|
29
|
-
"publishConfig": {
|
|
30
|
-
"access": "public",
|
|
31
|
-
"provenance": true
|
|
32
|
-
}
|
|
33
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@mriekki/lean",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Public consumer distribution and updater for Lean engineering assets.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lean": "bin/lean.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"assets/",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test",
|
|
17
|
+
"assemble:assets": "node scripts/assemble-assets.mjs",
|
|
18
|
+
"verify:assets": "node scripts/verify-assets.mjs",
|
|
19
|
+
"release:prepare": "node scripts/prepare-release.mjs",
|
|
20
|
+
"prepack": "npm run verify:assets"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/mriekki/lean-platform.git"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"provenance": true
|
|
32
|
+
}
|
|
33
|
+
}
|