@mriekki/project-forge 4.0.0 → 4.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/assets/components.json +3 -3
- package/assets/deployment/distribution.json +2 -0
- package/assets/generated/ASSEMBLY.json +25 -15
- package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +3 -1
- package/assets/generated/core/core/skills/project-forge-update/SKILL.md +3 -3
- package/assets/generated/core/core/skills/setup/SKILL.md +61 -0
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +137 -18
- package/assets/generated/deployment/docs/DEVELOPER_ENVIRONMENTS.md +52 -12
- package/assets/generated/deployment/docs/ENVIRONMENT_PROOF.md +82 -6
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime_workstation.py +103 -42
- package/assets/generated/deployment/src/project_forge_deployment/environment_cli.py +41 -4
- package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +159 -79
- package/assets/generated/deployment/src/project_forge_deployment/environment_setup.py +323 -0
- package/assets/generated/deployment/src/project_forge_deployment/home_lab.py +22 -1
- package/assets/generated/deployment/src/project_forge_deployment/image_transfer.py +51 -11
- package/assets/generated/deployment/src/project_forge_deployment/proof_receipt.py +183 -0
- package/assets/generated/deployment/src/project_forge_deployment/ssh.py +277 -23
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
5
6
|
import shlex
|
|
6
7
|
import shutil
|
|
7
8
|
import subprocess
|
|
@@ -13,6 +14,9 @@ from .registry_access import normalize_registry_image
|
|
|
13
14
|
from .ssh import ssh_args, verify_access
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
_IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
18
|
+
|
|
19
|
+
|
|
16
20
|
def _docker() -> str:
|
|
17
21
|
path = shutil.which("docker")
|
|
18
22
|
if path is None:
|
|
@@ -20,13 +24,25 @@ def _docker() -> str:
|
|
|
20
24
|
return path
|
|
21
25
|
|
|
22
26
|
|
|
27
|
+
def _image_id(value: str) -> str:
|
|
28
|
+
if not isinstance(value, str) or _IMAGE_ID_RE.fullmatch(value) is None:
|
|
29
|
+
raise DeploymentKitError("Docker image ID must be sha256:<64 lowercase hex>.")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
23
33
|
def verify_local_image(image: str) -> dict[str, str]:
|
|
34
|
+
"""Verify the declared immutable registry digest and resolve its local config/image ID.
|
|
35
|
+
|
|
36
|
+
Registry RepoDigests are release identity. Docker's image ID is the config digest and is
|
|
37
|
+
the content identity preserved by ``docker save``/``docker load`` even when RepoTags are
|
|
38
|
+
absent from the archive.
|
|
39
|
+
"""
|
|
40
|
+
|
|
24
41
|
exact = normalize_registry_image(image)
|
|
25
42
|
result = subprocess.run(
|
|
26
|
-
[_docker(), "image", "inspect", exact],
|
|
43
|
+
[_docker(), "image", "inspect", "--format", "{{.Id}}", exact],
|
|
27
44
|
check=False,
|
|
28
|
-
|
|
29
|
-
stderr=subprocess.PIPE,
|
|
45
|
+
capture_output=True,
|
|
30
46
|
text=True,
|
|
31
47
|
encoding="utf-8",
|
|
32
48
|
errors="replace",
|
|
@@ -36,11 +52,17 @@ def verify_local_image(image: str) -> dict[str, str]:
|
|
|
36
52
|
f"Exact image is not present in the workstation Docker engine: {exact}. "
|
|
37
53
|
"Pull/build it locally before transfer."
|
|
38
54
|
)
|
|
39
|
-
|
|
55
|
+
image_id = _image_id(result.stdout.strip())
|
|
56
|
+
return {
|
|
57
|
+
"image": exact,
|
|
58
|
+
"registryDigest": exact,
|
|
59
|
+
"localImageId": image_id,
|
|
60
|
+
}
|
|
40
61
|
|
|
41
62
|
|
|
42
|
-
def _remote_has_image(profile: dict[str, Any], address: str,
|
|
43
|
-
|
|
63
|
+
def _remote_has_image(profile: dict[str, Any], address: str, image_id: str) -> bool:
|
|
64
|
+
exact_id = _image_id(image_id)
|
|
65
|
+
command = "sudo -n docker image inspect " + shlex.quote(exact_id) + " >/dev/null 2>&1"
|
|
44
66
|
result = subprocess.run(
|
|
45
67
|
ssh_args(profile, address, command=command),
|
|
46
68
|
check=False,
|
|
@@ -50,17 +72,31 @@ def _remote_has_image(profile: dict[str, Any], address: str, image: str) -> bool
|
|
|
50
72
|
return result.returncode == 0
|
|
51
73
|
|
|
52
74
|
|
|
75
|
+
def remote_image_present(name: str, image_id: str) -> bool:
|
|
76
|
+
"""Read-only check for one exact Docker config/image ID on a verified target."""
|
|
77
|
+
|
|
78
|
+
exact_id = _image_id(image_id)
|
|
79
|
+
profile = load_profile(name)
|
|
80
|
+
access = verify_access(profile)
|
|
81
|
+
return _remote_has_image(profile, access["address"], exact_id)
|
|
82
|
+
|
|
83
|
+
|
|
53
84
|
def transfer_image(name: str, image: str) -> dict[str, Any]:
|
|
54
|
-
|
|
55
|
-
|
|
85
|
+
local = verify_local_image(image)
|
|
86
|
+
exact = local["registryDigest"]
|
|
87
|
+
image_id = local["localImageId"]
|
|
56
88
|
profile = load_profile(name)
|
|
57
89
|
access = verify_access(profile)
|
|
58
90
|
address = access["address"]
|
|
59
91
|
|
|
60
|
-
if _remote_has_image(profile, address,
|
|
92
|
+
if _remote_has_image(profile, address, image_id):
|
|
61
93
|
return {
|
|
62
94
|
"targetName": name,
|
|
63
95
|
"image": exact,
|
|
96
|
+
"registryDigest": exact,
|
|
97
|
+
"localImageId": image_id,
|
|
98
|
+
"remoteImageId": image_id,
|
|
99
|
+
"runtimeImage": image_id,
|
|
64
100
|
"status": "already-present",
|
|
65
101
|
"mutated": False,
|
|
66
102
|
}
|
|
@@ -93,13 +129,17 @@ def transfer_image(name: str, image: str) -> dict[str, Any]:
|
|
|
93
129
|
)
|
|
94
130
|
raise DeploymentKitError(f"Remote Docker image load failed: {detail}")
|
|
95
131
|
|
|
96
|
-
if not _remote_has_image(profile, address,
|
|
132
|
+
if not _remote_has_image(profile, address, image_id):
|
|
97
133
|
raise DeploymentKitError(
|
|
98
|
-
"Remote docker load completed but the exact
|
|
134
|
+
"Remote docker load completed but the exact Docker config/image ID is not inspectable on the target."
|
|
99
135
|
)
|
|
100
136
|
return {
|
|
101
137
|
"targetName": name,
|
|
102
138
|
"image": exact,
|
|
139
|
+
"registryDigest": exact,
|
|
140
|
+
"localImageId": image_id,
|
|
141
|
+
"remoteImageId": image_id,
|
|
142
|
+
"runtimeImage": image_id,
|
|
103
143
|
"status": "transferred",
|
|
104
144
|
"mutated": True,
|
|
105
145
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Durable workstation-local receipts for disposable environment proofs.
|
|
2
|
+
|
|
3
|
+
Receipts are diagnostic/readiness evidence, not deployment authorization or a workflow
|
|
4
|
+
database. They contain no credentials or mutable application data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .errors import DeploymentKitError
|
|
19
|
+
from .keys import key_fingerprint
|
|
20
|
+
from .profiles import load_profile
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = 1
|
|
24
|
+
PROOF_CONTRACT_VERSION = 1
|
|
25
|
+
_ENV_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
26
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _root() -> Path:
|
|
30
|
+
configured = os.environ.get("PROJECT_FORGE_HOME")
|
|
31
|
+
return Path(configured).expanduser() if configured else Path.home() / ".project-forge"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _receipt_path(owner_repo: str, environment: str) -> Path:
|
|
35
|
+
if _REPO_RE.fullmatch(owner_repo) is None:
|
|
36
|
+
raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
|
|
37
|
+
if _ENV_RE.fullmatch(environment) is None:
|
|
38
|
+
raise DeploymentKitError("environment must use lowercase letters, digits, and internal hyphens.")
|
|
39
|
+
owner, repo = owner_repo.split("/", 1)
|
|
40
|
+
return _root() / "evidence" / "environment-proofs" / owner / repo / f"{environment}.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _sha256_file(path: Path) -> str:
|
|
44
|
+
try:
|
|
45
|
+
data = path.read_bytes()
|
|
46
|
+
except OSError as error:
|
|
47
|
+
raise DeploymentKitError(f"Cannot read target trust evidence {path}: {error}") from error
|
|
48
|
+
return hashlib.sha256(data).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def current_target_signature(target_name: str) -> dict[str, Any]:
|
|
52
|
+
"""Return non-secret workstation/target identity used to invalidate stale proof."""
|
|
53
|
+
|
|
54
|
+
profile = load_profile(target_name)
|
|
55
|
+
identity = Path(profile["identityFile"])
|
|
56
|
+
known_hosts = Path(profile["knownHostsFile"])
|
|
57
|
+
signature = {
|
|
58
|
+
"targetName": profile["targetName"],
|
|
59
|
+
"targetOs": profile["targetOs"],
|
|
60
|
+
"hostName": profile["hostName"],
|
|
61
|
+
"sshAlias": profile["sshAlias"],
|
|
62
|
+
"user": profile["user"],
|
|
63
|
+
"port": profile["port"],
|
|
64
|
+
"addressOverride": profile["addressOverride"],
|
|
65
|
+
"identityFingerprint": key_fingerprint(identity),
|
|
66
|
+
"trustSha256": _sha256_file(known_hosts),
|
|
67
|
+
}
|
|
68
|
+
canonical = json.dumps(signature, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
69
|
+
return {**signature, "signatureSha256": hashlib.sha256(canonical).hexdigest()}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def write_environment_proof_receipt(result: dict[str, Any]) -> dict[str, Any]:
|
|
73
|
+
required = {
|
|
74
|
+
"ownerRepo",
|
|
75
|
+
"environment",
|
|
76
|
+
"targetName",
|
|
77
|
+
"production",
|
|
78
|
+
"proof",
|
|
79
|
+
"cleanupState",
|
|
80
|
+
"finalMutationState",
|
|
81
|
+
}
|
|
82
|
+
missing = sorted(required.difference(result))
|
|
83
|
+
if missing:
|
|
84
|
+
raise DeploymentKitError("Environment proof result is missing receipt fields: " + ", ".join(missing))
|
|
85
|
+
|
|
86
|
+
path = _receipt_path(result["ownerRepo"], result["environment"])
|
|
87
|
+
target = current_target_signature(result["targetName"])
|
|
88
|
+
receipt = {
|
|
89
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
90
|
+
"proofContractVersion": PROOF_CONTRACT_VERSION,
|
|
91
|
+
"deploymentKitVersion": __version__,
|
|
92
|
+
"recordedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
93
|
+
"ownerRepo": result["ownerRepo"],
|
|
94
|
+
"environment": result["environment"],
|
|
95
|
+
"targetName": result["targetName"],
|
|
96
|
+
"production": bool(result["production"]),
|
|
97
|
+
"proof": result["proof"],
|
|
98
|
+
"cleanupState": result["cleanupState"],
|
|
99
|
+
"finalMutationState": result["finalMutationState"],
|
|
100
|
+
"registryDigest": result.get("registryDigest"),
|
|
101
|
+
"localImageId": result.get("localImageId"),
|
|
102
|
+
"remoteImageId": result.get("remoteImageId"),
|
|
103
|
+
"targetSignature": target,
|
|
104
|
+
}
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
temp = path.with_suffix(".tmp")
|
|
107
|
+
temp.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
108
|
+
temp.replace(path)
|
|
109
|
+
return {"path": str(path), "recordedAt": receipt["recordedAt"], "state": "recorded"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_environment_proof_receipt(
|
|
113
|
+
target_name: str,
|
|
114
|
+
*,
|
|
115
|
+
owner_repo: str,
|
|
116
|
+
environment: str,
|
|
117
|
+
production: bool,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""Return whether a prior proof receipt is still applicable on this workstation."""
|
|
120
|
+
|
|
121
|
+
path = _receipt_path(owner_repo, environment)
|
|
122
|
+
if not path.is_file():
|
|
123
|
+
return {"state": "missing", "path": str(path), "mutated": False}
|
|
124
|
+
try:
|
|
125
|
+
receipt = json.loads(path.read_text(encoding="utf-8"))
|
|
126
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
127
|
+
return {
|
|
128
|
+
"state": "invalid",
|
|
129
|
+
"path": str(path),
|
|
130
|
+
"reason": f"Cannot read proof receipt: {error}",
|
|
131
|
+
"mutated": False,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
expected = {
|
|
135
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
136
|
+
"proofContractVersion": PROOF_CONTRACT_VERSION,
|
|
137
|
+
"deploymentKitVersion": __version__,
|
|
138
|
+
"ownerRepo": owner_repo,
|
|
139
|
+
"environment": environment,
|
|
140
|
+
"targetName": target_name,
|
|
141
|
+
"production": bool(production),
|
|
142
|
+
}
|
|
143
|
+
mismatches = [key for key, value in expected.items() if receipt.get(key) != value]
|
|
144
|
+
if mismatches:
|
|
145
|
+
return {
|
|
146
|
+
"state": "stale",
|
|
147
|
+
"path": str(path),
|
|
148
|
+
"reason": "Receipt context changed: " + ", ".join(mismatches),
|
|
149
|
+
"mutated": False,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if receipt.get("proof") != "passed" or receipt.get("cleanupState") not in {"removed", "absent"}:
|
|
153
|
+
return {
|
|
154
|
+
"state": "not-proven",
|
|
155
|
+
"path": str(path),
|
|
156
|
+
"reason": "Latest receipt is not a clean passing proof.",
|
|
157
|
+
"mutated": False,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
current = current_target_signature(target_name)
|
|
162
|
+
except DeploymentKitError as error:
|
|
163
|
+
return {"state": "stale", "path": str(path), "reason": str(error), "mutated": False}
|
|
164
|
+
previous = receipt.get("targetSignature") or {}
|
|
165
|
+
if previous.get("signatureSha256") != current.get("signatureSha256"):
|
|
166
|
+
return {
|
|
167
|
+
"state": "stale",
|
|
168
|
+
"path": str(path),
|
|
169
|
+
"reason": "Target/workstation identity or trust changed after the recorded proof.",
|
|
170
|
+
"mutated": False,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
"state": "proven",
|
|
175
|
+
"path": str(path),
|
|
176
|
+
"recordedAt": receipt.get("recordedAt"),
|
|
177
|
+
"targetName": target_name,
|
|
178
|
+
"ownerRepo": owner_repo,
|
|
179
|
+
"environment": environment,
|
|
180
|
+
"production": bool(production),
|
|
181
|
+
"finalMutationState": receipt.get("finalMutationState"),
|
|
182
|
+
"mutated": False,
|
|
183
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
"""Strict SSH verification
|
|
1
|
+
"""Strict SSH verification and bounded diagnostics for logical targets."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
5
6
|
import shutil
|
|
6
7
|
import socket
|
|
7
8
|
import subprocess
|
|
@@ -9,9 +10,14 @@ from pathlib import Path
|
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
11
12
|
from .errors import DeploymentKitError
|
|
13
|
+
from .keys import key_fingerprint
|
|
12
14
|
from .security import check_ssh_material_security, validate_target_trust_file
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
_AUTH_METHODS_RE = re.compile(r"Authentications that can continue:\s*(?P<methods>[^\r\n]+)")
|
|
18
|
+
_PERMISSION_DENIED_RE = re.compile(r"Permission denied \((?P<methods>[^)]+)\)")
|
|
19
|
+
|
|
20
|
+
|
|
15
21
|
def required_tool(name: str) -> str:
|
|
16
22
|
path = shutil.which(name)
|
|
17
23
|
if path is None:
|
|
@@ -40,7 +46,7 @@ def resolve_addresses(profile: dict[str, Any]) -> list[str]:
|
|
|
40
46
|
return addresses
|
|
41
47
|
|
|
42
48
|
|
|
43
|
-
def
|
|
49
|
+
def _strict_transport_args(profile: dict[str, Any], address: str) -> list[str]:
|
|
44
50
|
return [
|
|
45
51
|
required_tool("ssh"),
|
|
46
52
|
"-F",
|
|
@@ -64,16 +70,6 @@ def ssh_args(profile: dict[str, Any], address: str, *, command: str = "true") ->
|
|
|
64
70
|
"-o",
|
|
65
71
|
f"HostKeyAlias={profile['sshAlias']}",
|
|
66
72
|
"-o",
|
|
67
|
-
f"IdentityFile={profile['identityFile']}",
|
|
68
|
-
"-o",
|
|
69
|
-
"IdentitiesOnly=yes",
|
|
70
|
-
"-o",
|
|
71
|
-
"PasswordAuthentication=no",
|
|
72
|
-
"-o",
|
|
73
|
-
"KbdInteractiveAuthentication=no",
|
|
74
|
-
"-o",
|
|
75
|
-
"PreferredAuthentications=publickey",
|
|
76
|
-
"-o",
|
|
77
73
|
"ForwardAgent=no",
|
|
78
74
|
"-o",
|
|
79
75
|
"ForwardX11=no",
|
|
@@ -81,11 +77,277 @@ def ssh_args(profile: dict[str, Any], address: str, *, command: str = "true") ->
|
|
|
81
77
|
"ClearAllForwardings=yes",
|
|
82
78
|
"-p",
|
|
83
79
|
str(profile["port"]),
|
|
84
|
-
f"{profile['user']}@{address}",
|
|
85
|
-
command,
|
|
86
80
|
]
|
|
87
81
|
|
|
88
82
|
|
|
83
|
+
def ssh_args(
|
|
84
|
+
profile: dict[str, Any],
|
|
85
|
+
address: str,
|
|
86
|
+
*,
|
|
87
|
+
command: str = "true",
|
|
88
|
+
verbose: bool = False,
|
|
89
|
+
) -> list[str]:
|
|
90
|
+
args = _strict_transport_args(profile, address)
|
|
91
|
+
if verbose:
|
|
92
|
+
args.insert(1, "-vv")
|
|
93
|
+
args.extend(
|
|
94
|
+
[
|
|
95
|
+
"-o",
|
|
96
|
+
f"IdentityFile={profile['identityFile']}",
|
|
97
|
+
"-o",
|
|
98
|
+
"IdentitiesOnly=yes",
|
|
99
|
+
"-o",
|
|
100
|
+
"PasswordAuthentication=no",
|
|
101
|
+
"-o",
|
|
102
|
+
"KbdInteractiveAuthentication=no",
|
|
103
|
+
"-o",
|
|
104
|
+
"PreferredAuthentications=publickey",
|
|
105
|
+
f"{profile['user']}@{address}",
|
|
106
|
+
command,
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
return args
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def authentication_probe_args(profile: dict[str, Any], address: str) -> list[str]:
|
|
113
|
+
"""Build a read-only SSH handshake that discovers advertised auth methods.
|
|
114
|
+
|
|
115
|
+
Host identity is still verified through the configured target-specific trust file.
|
|
116
|
+
All actual client authentication mechanisms are disabled so the server's initial
|
|
117
|
+
authentication-method advertisement can be observed without attempting credentials.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
args = _strict_transport_args(profile, address)
|
|
121
|
+
args.insert(1, "-vv")
|
|
122
|
+
args.extend(
|
|
123
|
+
[
|
|
124
|
+
"-o",
|
|
125
|
+
"PubkeyAuthentication=no",
|
|
126
|
+
"-o",
|
|
127
|
+
"PasswordAuthentication=no",
|
|
128
|
+
"-o",
|
|
129
|
+
"KbdInteractiveAuthentication=no",
|
|
130
|
+
"-o",
|
|
131
|
+
"GSSAPIAuthentication=no",
|
|
132
|
+
"-o",
|
|
133
|
+
"HostbasedAuthentication=no",
|
|
134
|
+
f"{profile['user']}@{address}",
|
|
135
|
+
"true",
|
|
136
|
+
]
|
|
137
|
+
)
|
|
138
|
+
return args
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _completed(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
142
|
+
return subprocess.run(
|
|
143
|
+
args,
|
|
144
|
+
check=False,
|
|
145
|
+
capture_output=True,
|
|
146
|
+
text=True,
|
|
147
|
+
encoding="utf-8",
|
|
148
|
+
errors="replace",
|
|
149
|
+
stdin=subprocess.DEVNULL,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _auth_methods(text: str) -> list[str]:
|
|
154
|
+
methods: list[str] = []
|
|
155
|
+
for match in _AUTH_METHODS_RE.finditer(text):
|
|
156
|
+
for method in match.group("methods").split(","):
|
|
157
|
+
value = method.strip()
|
|
158
|
+
if value and value not in methods:
|
|
159
|
+
methods.append(value)
|
|
160
|
+
if not methods:
|
|
161
|
+
denied = _PERMISSION_DENIED_RE.search(text)
|
|
162
|
+
if denied:
|
|
163
|
+
for method in denied.group("methods").split(","):
|
|
164
|
+
value = method.strip()
|
|
165
|
+
if value and value not in methods:
|
|
166
|
+
methods.append(value)
|
|
167
|
+
return methods
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def probe_authentication_methods(profile: dict[str, Any], address: str) -> dict[str, Any]:
|
|
171
|
+
result = _completed(authentication_probe_args(profile, address))
|
|
172
|
+
text = "\n".join(part for part in (result.stderr, result.stdout) if part)
|
|
173
|
+
methods = _auth_methods(text)
|
|
174
|
+
return {
|
|
175
|
+
"address": address,
|
|
176
|
+
"methods": methods,
|
|
177
|
+
"passwordSupported": "password" in methods or "keyboard-interactive" in methods,
|
|
178
|
+
"publicKeySupported": "publickey" in methods,
|
|
179
|
+
"observed": bool(methods),
|
|
180
|
+
"returnCode": result.returncode,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _tcp_probe(profile: dict[str, Any], address: str) -> dict[str, Any]:
|
|
185
|
+
try:
|
|
186
|
+
with socket.create_connection((address, int(profile["port"])), timeout=8):
|
|
187
|
+
pass
|
|
188
|
+
return {"state": "reachable", "address": address, "port": int(profile["port"])}
|
|
189
|
+
except OSError as error:
|
|
190
|
+
return {
|
|
191
|
+
"state": "unreachable",
|
|
192
|
+
"address": address,
|
|
193
|
+
"port": int(profile["port"]),
|
|
194
|
+
"reason": str(error),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _classify_failure(text: str, *, auth: dict[str, Any], offered: bool, accepted: bool) -> str:
|
|
199
|
+
lower = text.lower()
|
|
200
|
+
if "remote host identification has changed" in lower or "host key verification failed" in lower:
|
|
201
|
+
return "host-identity-rejected"
|
|
202
|
+
if any(
|
|
203
|
+
marker in lower
|
|
204
|
+
for marker in (
|
|
205
|
+
"sign_and_send_pubkey: signing failed",
|
|
206
|
+
"agent refused operation",
|
|
207
|
+
"incorrect passphrase",
|
|
208
|
+
"load key",
|
|
209
|
+
)
|
|
210
|
+
):
|
|
211
|
+
return "local-signing-failed"
|
|
212
|
+
if "connection timed out" in lower or "connection refused" in lower or "no route to host" in lower:
|
|
213
|
+
return "transport-failed"
|
|
214
|
+
if "permission denied" in lower:
|
|
215
|
+
if accepted:
|
|
216
|
+
return "remote-account-policy-rejected"
|
|
217
|
+
if offered or auth.get("publicKeySupported"):
|
|
218
|
+
return "remote-authentication-rejected"
|
|
219
|
+
return "authentication-rejected"
|
|
220
|
+
return "ssh-verification-failed"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def diagnose_access(profile: dict[str, Any]) -> dict[str, Any]:
|
|
224
|
+
"""Return safe structured SSH failure evidence without changing workstation or target state."""
|
|
225
|
+
|
|
226
|
+
identity = Path(profile["identityFile"])
|
|
227
|
+
known_hosts = Path(profile["knownHostsFile"])
|
|
228
|
+
if not identity.is_file():
|
|
229
|
+
return {
|
|
230
|
+
"targetName": profile["targetName"],
|
|
231
|
+
"state": "failed",
|
|
232
|
+
"failureCategory": "missing-identity",
|
|
233
|
+
"reason": f"Dedicated private key is missing: {identity}",
|
|
234
|
+
"mutated": False,
|
|
235
|
+
}
|
|
236
|
+
if not known_hosts.is_file() or known_hosts.stat().st_size == 0:
|
|
237
|
+
return {
|
|
238
|
+
"targetName": profile["targetName"],
|
|
239
|
+
"state": "failed",
|
|
240
|
+
"failureCategory": "missing-trust",
|
|
241
|
+
"reason": f"Target-specific trust file is missing or empty: {known_hosts}",
|
|
242
|
+
"mutated": False,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
check_ssh_material_security(identity, known_hosts)
|
|
247
|
+
validate_target_trust_file(profile)
|
|
248
|
+
except DeploymentKitError as error:
|
|
249
|
+
return {
|
|
250
|
+
"targetName": profile["targetName"],
|
|
251
|
+
"state": "failed",
|
|
252
|
+
"failureCategory": "local-security-or-trust-invalid",
|
|
253
|
+
"reason": str(error),
|
|
254
|
+
"mutated": False,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
addresses = resolve_addresses(profile)
|
|
259
|
+
except DeploymentKitError as error:
|
|
260
|
+
return {
|
|
261
|
+
"targetName": profile["targetName"],
|
|
262
|
+
"state": "failed",
|
|
263
|
+
"failureCategory": "name-resolution-failed",
|
|
264
|
+
"reason": str(error),
|
|
265
|
+
"mutated": False,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fingerprint = key_fingerprint(identity)
|
|
269
|
+
attempts: list[dict[str, Any]] = []
|
|
270
|
+
for address in addresses:
|
|
271
|
+
tcp = _tcp_probe(profile, address)
|
|
272
|
+
if tcp["state"] != "reachable":
|
|
273
|
+
attempts.append({"address": address, "tcp": tcp, "state": "transport-failed"})
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
auth = probe_authentication_methods(profile, address)
|
|
277
|
+
result = _completed(ssh_args(profile, address, verbose=True))
|
|
278
|
+
text = "\n".join(part for part in (result.stderr, result.stdout) if part)
|
|
279
|
+
offered = "offering public key" in text.lower()
|
|
280
|
+
accepted = "server accepts key" in text.lower()
|
|
281
|
+
if result.returncode == 0:
|
|
282
|
+
return {
|
|
283
|
+
"targetName": profile["targetName"],
|
|
284
|
+
"state": "verified",
|
|
285
|
+
"address": address,
|
|
286
|
+
"identityFingerprint": fingerprint,
|
|
287
|
+
"authentication": auth,
|
|
288
|
+
"offeredConfiguredIdentity": offered,
|
|
289
|
+
"serverAcceptedConfiguredIdentity": accepted,
|
|
290
|
+
"mutated": False,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
attempts.append(
|
|
294
|
+
{
|
|
295
|
+
"address": address,
|
|
296
|
+
"tcp": tcp,
|
|
297
|
+
"authentication": auth,
|
|
298
|
+
"identityFingerprint": fingerprint,
|
|
299
|
+
"offeredConfiguredIdentity": offered,
|
|
300
|
+
"serverAcceptedConfiguredIdentity": accepted,
|
|
301
|
+
"returnCode": result.returncode,
|
|
302
|
+
"failureCategory": _classify_failure(
|
|
303
|
+
text,
|
|
304
|
+
auth=auth,
|
|
305
|
+
offered=offered,
|
|
306
|
+
accepted=accepted,
|
|
307
|
+
),
|
|
308
|
+
}
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
category = "transport-failed"
|
|
312
|
+
for candidate in (
|
|
313
|
+
"host-identity-rejected",
|
|
314
|
+
"local-signing-failed",
|
|
315
|
+
"remote-account-policy-rejected",
|
|
316
|
+
"remote-authentication-rejected",
|
|
317
|
+
"authentication-rejected",
|
|
318
|
+
"ssh-verification-failed",
|
|
319
|
+
):
|
|
320
|
+
if any(item.get("failureCategory") == candidate for item in attempts):
|
|
321
|
+
category = candidate
|
|
322
|
+
break
|
|
323
|
+
|
|
324
|
+
guidance = {
|
|
325
|
+
"transport-failed": "Verify the configured address/port and network path; do not scan or silently switch targets.",
|
|
326
|
+
"host-identity-rejected": "Re-verify host identity independently before any trust rotation.",
|
|
327
|
+
"local-signing-failed": "Repair local key/passphrase/agent use; do not weaken remote authentication policy.",
|
|
328
|
+
"remote-authentication-rejected": (
|
|
329
|
+
"The configured public-key path reached the server but authentication was rejected. "
|
|
330
|
+
"Verify authorized_keys and remote account/source access policy from a trusted console or already-authorized session."
|
|
331
|
+
),
|
|
332
|
+
"remote-account-policy-rejected": (
|
|
333
|
+
"The server accepted the configured key but the session was still rejected. "
|
|
334
|
+
"Inspect remote account/source policy from a trusted console or already-authorized session."
|
|
335
|
+
),
|
|
336
|
+
"authentication-rejected": "Inspect the server-advertised authentication policy and the configured account.",
|
|
337
|
+
"ssh-verification-failed": "Inspect the structured attempt evidence before changing target or SSH policy.",
|
|
338
|
+
}[category]
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
"targetName": profile["targetName"],
|
|
342
|
+
"state": "failed",
|
|
343
|
+
"failureCategory": category,
|
|
344
|
+
"identityFingerprint": fingerprint,
|
|
345
|
+
"attempts": attempts,
|
|
346
|
+
"guidance": guidance,
|
|
347
|
+
"mutated": False,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
89
351
|
def verify_access(profile: dict[str, Any]) -> dict[str, str]:
|
|
90
352
|
identity = Path(profile["identityFile"])
|
|
91
353
|
known_hosts = Path(profile["knownHostsFile"])
|
|
@@ -101,15 +363,7 @@ def verify_access(profile: dict[str, Any]) -> dict[str, str]:
|
|
|
101
363
|
|
|
102
364
|
failures: list[str] = []
|
|
103
365
|
for address in resolve_addresses(profile):
|
|
104
|
-
result =
|
|
105
|
-
ssh_args(profile, address),
|
|
106
|
-
check=False,
|
|
107
|
-
capture_output=True,
|
|
108
|
-
text=True,
|
|
109
|
-
encoding="utf-8",
|
|
110
|
-
errors="replace",
|
|
111
|
-
stdin=subprocess.DEVNULL,
|
|
112
|
-
)
|
|
366
|
+
result = _completed(ssh_args(profile, address))
|
|
113
367
|
if result.returncode == 0:
|
|
114
368
|
return {"targetName": profile["targetName"], "address": address, "status": "verified"}
|
|
115
369
|
failures.append(f"{address}: exit {result.returncode}")
|