keywarden-ssh 0.3.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.
keywarden/ui.py ADDED
@@ -0,0 +1,348 @@
1
+ """keywarden ui: the same checks in a browser tab, served from this machine only.
2
+
3
+ The server listens on 127.0.0.1, answers only requests that carry the session
4
+ token and a localhost Host header, and keeps nothing between requests. Public
5
+ key text is fingerprinted on arrival and dropped; the page holds fingerprints.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import secrets
12
+ import sys
13
+ import threading
14
+ import time
15
+ import webbrowser
16
+ from datetime import date
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+ from pathlib import Path
19
+ from types import SimpleNamespace
20
+ from urllib.parse import parse_qs, urlsplit
21
+
22
+ from keywarden import __version__
23
+ from keywarden.access import key_rows
24
+ from keywarden.check import FINGERPRINT, HostObservation, Policy, apply_drift, evaluate
25
+ from keywarden.cli import (
26
+ _envelope,
27
+ _host_report,
28
+ _matches,
29
+ _observe_target,
30
+ _targets,
31
+ _unreachable,
32
+ csv_text,
33
+ prior_from_document,
34
+ )
35
+ from keywarden.collect import REMOTE_SCRIPT, ProtocolError, collect_local, collect_remote, parse_protocol
36
+ from keywarden.parse import KeyRef, PrivateKeyError, kind_of, parse_public_keys
37
+ from keywarden.ui_lab import LAB_PUSH, lab_hosts
38
+ from keywarden.ui_page import render_page
39
+ from keywarden.waivers import apply_waivers, load_waivers
40
+
41
+ MAX_BODY = 2_000_000
42
+ TARGETS = {"lab", "local", "host", "inventory", "paste"}
43
+
44
+
45
+ class BadRequest(ValueError):
46
+ pass
47
+
48
+
49
+ def _push_from(body: dict) -> list[KeyRef]:
50
+ """New key text replaces the set. Without it, the fingerprints the page already holds are used."""
51
+ text = body.get("allowText") or ""
52
+ if not isinstance(text, str):
53
+ raise BadRequest("allowText must be text")
54
+ if text.strip():
55
+ try:
56
+ keys, _issues = parse_public_keys(text)
57
+ finally:
58
+ del text
59
+ if not keys:
60
+ raise BadRequest("No public keys were found in the push box.")
61
+ return keys
62
+ keys = []
63
+ for item in body.get("allowFingerprints") or []:
64
+ if not isinstance(item, dict) or not FINGERPRINT.fullmatch(str(item.get("fingerprint", ""))):
65
+ raise BadRequest("allowFingerprints holds something that is not a fingerprint")
66
+ key_type = str(item.get("type") or "unknown")
67
+ bits = item.get("bits") if isinstance(item.get("bits"), int) else None
68
+ keys.append(KeyRef(item["fingerprint"], key_type, kind_of(key_type), bits, str(item.get("comment") or "")))
69
+ return keys
70
+
71
+
72
+ def _policy(body: dict) -> Policy:
73
+ stale = body.get("staleDays", 90)
74
+ if not isinstance(stale, int) or stale < 1 or stale > 3650:
75
+ raise BadRequest("Stale after must be between 1 and 3650 days.")
76
+ return Policy(
77
+ flag_outside_push=bool(body.get("flagOutside", True)),
78
+ flag_weak=bool(body.get("flagWeak", True)),
79
+ stale_days=stale,
80
+ )
81
+
82
+
83
+ def _args(body: dict) -> SimpleNamespace:
84
+ accounts = body.get("accounts") or []
85
+ if not isinstance(accounts, list) or not all(isinstance(item, str) for item in accounts):
86
+ raise BadRequest("accounts must be a list of names")
87
+ return SimpleNamespace(account=accounts, accept_new=bool(body.get("acceptNew", False)))
88
+
89
+
90
+ def _observations(body: dict):
91
+ """Yield (name, observation or error dict, role, prior) for the chosen target."""
92
+ target = body.get("target")
93
+ if target not in TARGETS:
94
+ raise BadRequest("Pick what to check.")
95
+ args = _args(body)
96
+ if target == "lab":
97
+ for observation, address, role, prior in lab_hosts(int(time.time())):
98
+ observation.address = address
99
+ yield observation.name, observation, role, prior
100
+ return
101
+ if target == "local":
102
+ observation = collect_local(args.account)
103
+ yield observation.name, observation, "This machine", None
104
+ return
105
+ if target == "host":
106
+ destination = str(body.get("host") or "").strip()
107
+ if not destination:
108
+ raise BadRequest("Enter a host as user@hostname.")
109
+ try:
110
+ observation = collect_remote(destination, args.account, accept_new=args.accept_new)
111
+ except ProtocolError as exc:
112
+ yield destination, _unreachable(destination, destination, str(exc), code="collector-output"), "", None
113
+ return
114
+ except (OSError, RuntimeError, ValueError) as exc:
115
+ yield destination, _unreachable(destination, destination, str(exc)), "", None
116
+ return
117
+ yield observation.name, observation, "", None
118
+ return
119
+ if target == "paste":
120
+ text = str(body.get("observation") or "")
121
+ if not text.strip():
122
+ raise BadRequest("Paste the collector output first.")
123
+ if len(text) > 1_000_000:
124
+ raise BadRequest("The pasted output is larger than 1 MB.")
125
+ try:
126
+ observation = parse_protocol(text, address="pasted")
127
+ finally:
128
+ del text
129
+ yield observation.name, observation, "Pasted", None
130
+ return
131
+ path = Path(str(body.get("inventoryPath") or "").strip()).expanduser()
132
+ if not str(path) or not path.is_file():
133
+ raise BadRequest("Enter the path to an INI inventory on this machine.")
134
+ for target_host in _targets(path):
135
+ try:
136
+ observation = _observe_target(target_host, args)
137
+ except ProtocolError as exc:
138
+ yield target_host.name, _unreachable(target_host.name, target_host.destination, str(exc), code="collector-output"), "", None
139
+ continue
140
+ except (OSError, RuntimeError, ValueError) as exc:
141
+ yield target_host.name, _unreachable(target_host.name, target_host.destination, str(exc)), "", None
142
+ continue
143
+ yield target_host.name, observation, "", None
144
+
145
+
146
+ def run_check(body: dict) -> dict:
147
+ push = _push_from(body)
148
+ policy = _policy(body)
149
+ waivers = []
150
+ waiver_path = str(body.get("waiversPath") or "").strip()
151
+ if waiver_path:
152
+ waivers = load_waivers(Path(waiver_path).expanduser())
153
+ uploaded_prior = body.get("prior")
154
+ used: set[int] = set()
155
+ hosts: list[dict] = []
156
+ rows: list[dict] = []
157
+ for name, observed, role, lab_prior in _observations(body):
158
+ if not isinstance(observed, HostObservation):
159
+ observed["roleLabel"] = role
160
+ hosts.append(observed)
161
+ continue
162
+ prior = None
163
+ if lab_prior is not None:
164
+ # The lab's previous run: today's results, except where it says a check was different.
165
+ prior = {item.code: (item.severity, set(item.fingerprints)) for item in evaluate(observed, push, policy)}
166
+ prior.update(lab_prior)
167
+ if uploaded_prior is not None:
168
+ prior = prior_from_document(uploaded_prior, observed.name) or prior
169
+ findings = apply_drift(evaluate(observed, push, policy), prior)
170
+ findings = apply_waivers(findings, observed.name, waivers, date.today(), used)
171
+ host_rows = key_rows(observed, push, policy)
172
+ rows.extend(host_rows)
173
+ report = _host_report(observed, findings, host_rows)
174
+ report["roleLabel"] = role
175
+ hosts.append(report)
176
+ document = _envelope(push)
177
+ document["hosts"] = hosts
178
+ document["posture"] = "fail" if any(host["posture"] == "fail" for host in hosts) else "pass"
179
+ notes = [
180
+ f"Waiver {waiver.label} ({waiver.code}) matched nothing; remove it if the risk is gone."
181
+ for waiver in waivers
182
+ if waiver.index not in used
183
+ ]
184
+ from keywarden.evidence import render_evidence
185
+
186
+ return {
187
+ "report": document,
188
+ "csv": csv_text(rows),
189
+ "evidence": render_evidence(document),
190
+ "notes": notes,
191
+ "lab": body.get("target") == "lab",
192
+ }
193
+
194
+
195
+ def run_where(body: dict) -> dict:
196
+ wanted: list[str] = []
197
+ for value in body.get("fingerprints") or []:
198
+ value = str(value).strip()
199
+ if value:
200
+ if not FINGERPRINT.fullmatch(value):
201
+ raise BadRequest(f"{value[:60]} is not a fingerprint. Use SHA256:..., as ssh-keygen -lf prints.")
202
+ wanted.append(value)
203
+ text = str(body.get("keyText") or "")
204
+ if text.strip():
205
+ try:
206
+ keys, _issues = parse_public_keys(text)
207
+ finally:
208
+ del text
209
+ wanted.extend(key.fingerprint for key in keys)
210
+ if not wanted:
211
+ raise BadRequest("Enter a fingerprint or paste a public key.")
212
+ found: list[dict] = []
213
+ blind: list[str] = []
214
+ for name, observed, _role, _prior in _observations(body):
215
+ if not isinstance(observed, HostObservation):
216
+ blind.append(f"{name}: not checked ({observed['findings'][0]['detail'][:120]})")
217
+ continue
218
+ found.extend(_matches(observed, set(wanted)))
219
+ blind.extend(f"{observed.name}: {account.user} could not be read" for account in observed.accounts if account.read_state == "unreadable")
220
+ return {"fingerprints": list(dict.fromkeys(wanted)), "found": found, "notChecked": blind}
221
+
222
+
223
+ class Handler(BaseHTTPRequestHandler):
224
+ server_version = "keywarden-ui"
225
+ sys_version = ""
226
+
227
+ def log_message(self, format, *args): # noqa: A002 - quiet: requests can carry host names
228
+ return
229
+
230
+ # Every response: no caching, no framing, no referrer, scripts only with this page's nonce.
231
+ def _headers(self, status: int, content_type: str, length: int, nonce: str | None = None) -> None:
232
+ self.send_response(status)
233
+ self.send_header("Content-Type", content_type)
234
+ self.send_header("Content-Length", str(length))
235
+ self.send_header("Cache-Control", "no-store")
236
+ self.send_header("X-Content-Type-Options", "nosniff")
237
+ self.send_header("Referrer-Policy", "no-referrer")
238
+ self.send_header("X-Frame-Options", "DENY")
239
+ self.send_header("Cross-Origin-Opener-Policy", "same-origin")
240
+ self.send_header("Cross-Origin-Resource-Policy", "same-origin")
241
+ if nonce:
242
+ self.send_header(
243
+ "Content-Security-Policy",
244
+ f"default-src 'none'; script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'; "
245
+ "img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
246
+ )
247
+ self.end_headers()
248
+
249
+ def _send_json(self, status: int, payload: dict) -> None:
250
+ data = json.dumps(payload).encode("utf-8")
251
+ self._headers(status, "application/json; charset=utf-8", len(data))
252
+ self.wfile.write(data)
253
+
254
+ def _host_ok(self) -> bool:
255
+ port = self.server.server_address[1]
256
+ return self.headers.get("Host", "") in {f"127.0.0.1:{port}", f"localhost:{port}"}
257
+
258
+ def _origin_ok(self) -> bool:
259
+ origin = self.headers.get("Origin")
260
+ if origin is None:
261
+ return True
262
+ port = self.server.server_address[1]
263
+ return origin in {f"http://127.0.0.1:{port}", f"http://localhost:{port}"}
264
+
265
+ def _token_ok(self, supplied: str | None) -> bool:
266
+ return supplied is not None and secrets.compare_digest(supplied, self.server.token)
267
+
268
+ def do_GET(self): # noqa: N802
269
+ if not self._host_ok():
270
+ return self._send_json(421, {"error": "wrong host"})
271
+ url = urlsplit(self.path)
272
+ if url.path == "/":
273
+ token = parse_qs(url.query).get("t", [None])[0]
274
+ if not self._token_ok(token):
275
+ body = b"Open the link keywarden ui printed. It carries this session's token."
276
+ self._headers(403, "text/plain; charset=utf-8", len(body))
277
+ return self.wfile.write(body)
278
+ nonce = secrets.token_urlsafe(16)
279
+ page = render_page(nonce, self.server.token, __version__).encode("utf-8")
280
+ self._headers(200, "text/html; charset=utf-8", len(page), nonce)
281
+ return self.wfile.write(page)
282
+ return self._send_json(404, {"error": "not found"})
283
+
284
+ def do_POST(self): # noqa: N802
285
+ if not self._host_ok() or not self._origin_ok():
286
+ return self._send_json(403, {"error": "This server only answers its own page."})
287
+ if not self._token_ok(self.headers.get("X-Keywarden-Token")):
288
+ return self._send_json(403, {"error": "Missing or wrong session token. Reopen the link keywarden ui printed."})
289
+ if self.headers.get("Content-Type", "").split(";")[0].strip() != "application/json":
290
+ return self._send_json(415, {"error": "Send JSON."})
291
+ try:
292
+ length = int(self.headers.get("Content-Length", "0"))
293
+ except ValueError:
294
+ length = -1
295
+ if length < 0 or length > MAX_BODY:
296
+ return self._send_json(413, {"error": "The request is larger than 2 MB."})
297
+ raw = self.rfile.read(length)
298
+ try:
299
+ body = json.loads(raw.decode("utf-8"))
300
+ except (UnicodeDecodeError, json.JSONDecodeError):
301
+ return self._send_json(400, {"error": "The request was not valid JSON."})
302
+ finally:
303
+ del raw
304
+ if not isinstance(body, dict):
305
+ return self._send_json(400, {"error": "The request was not a JSON object."})
306
+ try:
307
+ if self.path == "/api/check":
308
+ return self._send_json(200, run_check(body))
309
+ if self.path == "/api/where":
310
+ return self._send_json(200, run_where(body))
311
+ if self.path == "/api/collector":
312
+ return self._send_json(200, {"script": REMOTE_SCRIPT})
313
+ if self.path == "/api/lab-keys":
314
+ return self._send_json(200, {"keys": LAB_PUSH})
315
+ return self._send_json(404, {"error": "not found"})
316
+ except PrivateKeyError:
317
+ return self._send_json(400, {"error": "A private key was in the input. Keywarden takes public keys only and kept nothing."})
318
+ except (BadRequest, ValueError, OSError, RuntimeError, json.JSONDecodeError) as exc:
319
+ return self._send_json(400, {"error": str(exc)[:400]})
320
+ finally:
321
+ body.clear()
322
+
323
+
324
+ class KeywardenServer(ThreadingHTTPServer):
325
+ daemon_threads = True
326
+
327
+ def __init__(self, port: int):
328
+ super().__init__(("127.0.0.1", port), Handler)
329
+ self.token = secrets.token_urlsafe(24)
330
+
331
+
332
+ def serve(port: int = 0, open_browser: bool = True) -> int:
333
+ server = KeywardenServer(port)
334
+ url = f"http://127.0.0.1:{server.server_address[1]}/?t={server.token}"
335
+ print(f"Keywarden is running at {url}")
336
+ print("Only this machine can reach it. Press Ctrl+C to stop; nothing is kept when it stops.")
337
+ if open_browser:
338
+ threading.Timer(0.3, lambda: webbrowser.open(url)).start()
339
+ try:
340
+ server.serve_forever()
341
+ except KeyboardInterrupt:
342
+ print("\nStopped.", file=sys.stderr)
343
+ finally:
344
+ server.server_close()
345
+ return 0
346
+
347
+
348
+ __all__ = ["serve", "run_check", "run_where"]
keywarden/ui_lab.py ADDED
@@ -0,0 +1,173 @@
1
+ """The lab: six made-up hosts for trying the interface. None of these keys exist anywhere real."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from keywarden.check import HostObservation
6
+ from keywarden.collect import parse_protocol
7
+ from keywarden.parse import parse_public_keys
8
+
9
+ DAY = 86400
10
+
11
+ LAB_KEYS = {
12
+ "ops": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMw3CRyIVJdlcMu0yNey4yKK9cbarwPgFyt3RYj11nOm ops@latch",
13
+ "deploy": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFNC5kulzZA+Y15VIvsXBCD4xVqmzgNc3Yrt0uMZrLMe deploy@latch",
14
+ "ci": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKyqTebqUn7JMI3f6e7CRqxw/LVNetdp5tfDuNCSA9hi ci-bot",
15
+ "root": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMjKhLYgU3zJepCV0ElII061TiD8YX9qsBP9hfJjhB6v root@app-web-03",
16
+ "contractor": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAFuDUY/c8R+5oUuOUyByXj9ZGOVT/nVX5lnf+RXtZwI contractor@temp",
17
+ "alice": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILamNR3cDT6qyuPVk9beyF8PHD/KXGgtPGuYeqOp4Tmz alice@old-laptop",
18
+ "legacy": (
19
+ "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDM6KTe5dS730B8qyUwp1YK6JM8ccsTIrDRedOsdrz0eF+058iL/B21uIPEE+5/ffdY+DXN0so0IjckwZvVm3SkdFu3"
20
+ "KnVShlUXIlyvjtHWKohCpK6ZiNf33OvpPBePbciFUrcFT9XeclgqziKoMUzeCgsSJ89sOkXvUTBfBNkq3JkK7Lges129q/D0XpFduFjAagdG10jOGBbR5OyduljMYJVjL3bn"
21
+ "88GaaNsPTkYoZsEZ9ttawoIi4sJTNLQYJBcXjmrviQNjIjVLDzsnhXArXQd/oJnXl2ZxVXIoQxKucHlChbLQDIJ8MT61hB2X4ee+ltwAwK3lRHRSynzLyfyh legacy-jump"
22
+ ),
23
+ }
24
+ LAB_PUSH = [LAB_KEYS["ops"], LAB_KEYS["deploy"], LAB_KEYS["ci"]]
25
+
26
+ _SAFE_SSHD = {
27
+ "permitrootlogin": "no",
28
+ "passwordauthentication": "no",
29
+ "kbdinteractiveauthentication": "no",
30
+ "pubkeyauthentication": "yes",
31
+ "permitemptypasswords": "no",
32
+ "authenticationmethods": "publickey",
33
+ "hostbasedauthentication": "no",
34
+ "pubkeyacceptedalgorithms": "ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256",
35
+ "casignaturealgorithms": "ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256",
36
+ "trustedusercakeys": "none",
37
+ "revokedkeys": "none",
38
+ "authorizedkeyscommand": "none",
39
+ "authorizedkeysfile": ".ssh/authorized_keys .ssh/authorized_keys2",
40
+ "loglevel": "INFO",
41
+ "allowtcpforwarding": "yes",
42
+ "allowagentforwarding": "yes",
43
+ "x11forwarding": "no",
44
+ "permituserenvironment": "no",
45
+ }
46
+ _ADMIN_SUDO = ["User {user} may run the following commands on {host}:", " (ALL : ALL) NOPASSWD: ALL"]
47
+ _NO_SUDO = ["User {user} is not allowed to run sudo on {host}."]
48
+ _PASSWORD_SUDO = ["User {user} may run the following commands on {host}:", " (ALL : ALL) ALL"]
49
+
50
+
51
+ def _fingerprint(line: str) -> str:
52
+ return parse_public_keys(line)[0][0].fingerprint
53
+
54
+
55
+ def _account(user, uid, groups, sudo, keys, host, mode="600"):
56
+ lines = [f"ACCOUNT {user}", f"UID {uid}", "LOCKED yes", f"GROUPS {' '.join(groups)}", "SUDOSTATE ok", "SUDO"]
57
+ lines += ["S " + line.format(user=user, host=host) for line in sudo]
58
+ lines += ["ENDSUDO"]
59
+ if not keys:
60
+ return lines + ["DIR 700", "FILE absent", "STATE absent", "KEYS", "ENDKEYS"]
61
+ lines += ["DIR 700", f"FILE {mode}", "STATE ok", "KEYS"]
62
+ lines += ["K " + key for key in keys]
63
+ return lines + ["ENDKEYS"]
64
+
65
+
66
+ def _host(name, now, sshd_changes, accounts, logins):
67
+ sshd = dict(_SAFE_SSHD)
68
+ sshd.update(sshd_changes)
69
+ lines = [
70
+ "KEYWARDEN 3",
71
+ f"HOST {name}",
72
+ f"NOW {now} +0000",
73
+ "PRIV sudo",
74
+ "SSHDRUN ok",
75
+ "SSHD",
76
+ *(f"{key} {value}" for key, value in sshd.items()),
77
+ "ENDSSHD",
78
+ "MATCH no",
79
+ "SSHDROOT",
80
+ "ENDSSHDROOT",
81
+ "AUTHLOG journal",
82
+ "LOGINS",
83
+ f"AUTHFIRST {now - 210 * DAY}.000000",
84
+ ]
85
+ for user, key, days_ago, source, count in logins:
86
+ lines.append(
87
+ f"L {count} {now - days_ago * DAY}.000000 {name} sshd[1]: Accepted publickey for {user} "
88
+ f"from {source} port 50000 ssh2: ED25519 {_fingerprint(LAB_KEYS[key])}"
89
+ )
90
+ lines.append("ENDLOGINS")
91
+ for account in accounts:
92
+ lines += account
93
+ lines += ["PASSWD ok", "END"]
94
+ return "\n".join(lines) + "\n"
95
+
96
+
97
+ def lab_hosts(now: int) -> list[tuple[HostObservation, str, str, dict]]:
98
+ """(observation, address, role, prior changes) for each lab host.
99
+
100
+ The prior changes are how the previous run differed from this one; every other check is unchanged.
101
+ """
102
+ k = LAB_KEYS
103
+ root_none = lambda host: _account("root", 0, ["root"], [], [], host) # noqa: E731
104
+ specs = [
105
+ (
106
+ "app-web-03", "10.0.4.23", "Application",
107
+ {"permitrootlogin": "prohibit-password"},
108
+ lambda h: [
109
+ _account("root", 0, ["root"], [], [k["root"]], h),
110
+ _account("ops", 1000, ["ops", "sudo"], _ADMIN_SUDO, ['from="10.0.0.0/16" ' + k["ops"]], h),
111
+ _account("deploy", 1001, ["deploy"], _NO_SUDO, [k["deploy"]], h),
112
+ ],
113
+ [("ops", "ops", 1, "10.0.0.9", 41), ("deploy", "deploy", 0, "10.0.6.15", 212)],
114
+ {"permit-root": ("pass", set()), "root-authorized-key": ("pass", set())},
115
+ ),
116
+ (
117
+ "db-primary", "10.0.8.4", "Database",
118
+ {"passwordauthentication": "yes"},
119
+ lambda h: [
120
+ root_none(h),
121
+ _account("ops", 1000, ["ops", "sudo"], _ADMIN_SUDO, ['from="10.0.0.0/16" ' + k["ops"]], h),
122
+ _account("postgres", 113, ["postgres"], _NO_SUDO, [k["contractor"]], h),
123
+ ],
124
+ [("ops", "ops", 3, "10.0.0.9", 12)],
125
+ {"password-auth": ("fail", set()), "outside-push": ("fail", {_fingerprint(k["contractor"])})},
126
+ ),
127
+ (
128
+ "ci-runner", "10.0.6.15", "Automation",
129
+ {},
130
+ lambda h: [
131
+ root_none(h),
132
+ _account("deploy", 1001, ["deploy", "docker"], _NO_SUDO, [k["ci"], k["alice"]], h),
133
+ ],
134
+ [("deploy", "ci", 0, "140.82.112.4", 388), ("deploy", "alice", 151, "10.20.0.14", 3)],
135
+ {"outside-push": ("pass", set()), "stale-key": ("pass", set())},
136
+ ),
137
+ (
138
+ "jump-legacy", "10.0.1.4", "Legacy jump",
139
+ {"pubkeyacceptedalgorithms": "ssh-ed25519,rsa-sha2-256,ssh-rsa"},
140
+ lambda h: [
141
+ root_none(h),
142
+ _account("ops", 1000, ["ops"], _NO_SUDO, ['from="10.0.0.0/16" ' + k["ops"], k["legacy"]], h),
143
+ ],
144
+ [("ops", "ops", 2, "10.0.0.9", 30)],
145
+ {"weak-algorithm": ("fail", {_fingerprint(k["legacy"])}), "accepted-algorithms": ("fail", set())},
146
+ ),
147
+ (
148
+ "bastion", "10.0.0.9", "Bastion",
149
+ {"allowtcpforwarding": "no", "allowagentforwarding": "no"},
150
+ lambda h: [
151
+ root_none(h),
152
+ _account("ops", 1000, ["ops"], _NO_SUDO, [k["ops"]], h),
153
+ ],
154
+ [("ops", "ops", 0, "203.0.113.20", 96)],
155
+ {},
156
+ ),
157
+ (
158
+ "mon-01", "10.0.9.7", "Monitoring",
159
+ {},
160
+ lambda h: [
161
+ root_none(h),
162
+ _account("ops", 1000, ["ops", "sudo"], _PASSWORD_SUDO, ['from="10.0.0.0/16" ' + k["ops"]], h),
163
+ _account("deploy", 1001, ["deploy"], _NO_SUDO, ['restrict,command="/usr/local/bin/collect" ' + k["deploy"]], h),
164
+ ],
165
+ [("ops", "ops", 6, "10.0.0.9", 8), ("deploy", "deploy", 0, "10.0.6.15", 1440)],
166
+ {},
167
+ ),
168
+ ]
169
+ result = []
170
+ for name, address, role, sshd, accounts, logins, prior in specs:
171
+ observation = parse_protocol(_host(name, now, sshd, accounts(name), logins), address=address)
172
+ result.append((observation, address, role, prior))
173
+ return result