gitpro 0.3.1__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.
gitpro/health.py ADDED
@@ -0,0 +1,1241 @@
1
+ """Key health (`keys --health`), `gitpro auth` (ssh -T per host and alias) and `keys --upload`.
2
+
3
+ The private key is never opened by gitpro: it is only stat()ed, and `ssh-keygen -y -P ""` (stdin
4
+ /dev/null, 5 s timeout, stdout discarded) is the one passphrase probe. Public keys are read for their
5
+ type, fingerprint (`ssh-keygen -l`) and material. The network is used only by `auth` (ssh -T, its whole
6
+ purpose) and, with --online, through forge.run (gh/glab, token in the child's env only).
7
+
8
+ doctor (16) calls check_keys(cfg, online=..., announce=False) and auth(cfg, identities, timeout); both
9
+ return a dict with status/summary/problems/details next to their data, and print nothing.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import re
16
+ import shlex
17
+ import shutil
18
+ import socket
19
+ import subprocess
20
+ import sys
21
+ from concurrent.futures import ThreadPoolExecutor
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Callable, Dict, List, Optional, Sequence, Tuple
25
+
26
+ from . import config, jsonout
27
+ from .args import capability, fail
28
+ from .config import ConfigError
29
+
30
+ PROBE_TIMEOUT = 5 # ssh-keygen -y, ssh-add -l
31
+ LOCAL_TIMEOUT = 10 # ssh-keygen -l
32
+ AUTH_TIMEOUT = 30 # ssh -T per host
33
+ GITHUB, GITLAB = "github.com", "gitlab.com"
34
+ SYM = {"ok": "✓", "info": "·", "warning": "⚠", "error": "✗"}
35
+ NEEDS_CLI = "needs gh or glab on PATH: install gh (https://cli.github.com) or glab (https://gitlab.com/gitlab-org/cli)"
36
+ ANNOUNCE_WHAT = "which SSH keys are registered on your accounts"
37
+ ANNOUNCE_SENDS = "only the list request; keys are compared locally"
38
+ _KEYTYPE_RE = re.compile(r"^(ssh-[a-z0-9-]+|ecdsa-sha2-[a-z0-9]+|sk-[A-Za-z0-9.@-]+)$")
39
+ _FP_RE = re.compile(r"^(\d+)\s+(\S+)\s+(.*?)\s*\(([A-Za-z0-9-]+)\)\s*$")
40
+
41
+
42
+ # ---------------------------------------------------------------- small helpers
43
+
44
+ def _expand(p: str, home: Path) -> Path:
45
+ return home / p[2:] if p.startswith("~/") else Path(p)
46
+
47
+
48
+ def _pub(key: Path) -> Path:
49
+ return key.with_name(key.name + ".pub")
50
+
51
+
52
+ def _tilde(path: object, home: Path) -> str:
53
+ p, h = str(path), str(home)
54
+ return "~" + p[len(h):] if p == h or p.startswith(h + "/") else p
55
+
56
+
57
+ def _sh(shown: str) -> str:
58
+ """Shell-quote a displayed path, keeping a leading ~/."""
59
+ return "~/" + shlex.quote(shown[2:]) if shown.startswith("~/") else shlex.quote(shown)
60
+
61
+
62
+ def _first_line(text: str) -> str:
63
+ return next((ln.strip() for ln in (text or "").splitlines() if ln.strip()), "")
64
+
65
+
66
+ def _plural(n: int, word: str) -> str:
67
+ return f"{n} {word}{'' if n == 1 else 's'}"
68
+
69
+
70
+ def _run(argv: List[str], timeout: float) -> Optional[subprocess.CompletedProcess]:
71
+ """Captured text run with stdin /dev/null; None when the program is missing, rc 124 on timeout."""
72
+ try:
73
+ return subprocess.run(argv, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=timeout)
74
+ except OSError:
75
+ return None
76
+ except subprocess.TimeoutExpired:
77
+ return subprocess.CompletedProcess(argv, 124, "", f"timed out after {timeout:g} s")
78
+
79
+
80
+ def _check(level: str, message: str, fix: str = "", tag: str = "") -> dict:
81
+ return {"level": level, "message": message, "fix": fix, "tag": tag}
82
+
83
+
84
+ # ---------------------------------------------------------------- local probes
85
+
86
+ def material(pub: Path) -> Optional[Tuple[str, str]]:
87
+ """(type, base64) of a public key file, ignoring the comment; None when unreadable."""
88
+ words = _pub_words(pub)
89
+ return _material_in(words) if words is not None else None
90
+
91
+
92
+ def _pub_words(pub: Path) -> Optional[List[str]]:
93
+ """The words of a public key file; None unless it is a readable regular file (a FIFO would hang)."""
94
+ try:
95
+ if not pub.is_file():
96
+ return None
97
+ return pub.read_text(encoding="utf-8", errors="replace").split()
98
+ except OSError:
99
+ return None
100
+
101
+
102
+ def _material_in(words: Sequence[str]) -> Optional[Tuple[str, str]]:
103
+ for n, w in enumerate(words[:-1]):
104
+ if _KEYTYPE_RE.match(w):
105
+ return w, words[n + 1]
106
+ return None
107
+
108
+
109
+ def key_comment(pub: Path) -> str:
110
+ words = _pub_words(pub)
111
+ if words is None:
112
+ return ""
113
+ for n, w in enumerate(words[:-1]):
114
+ if _KEYTYPE_RE.match(w):
115
+ return " ".join(words[n + 2:])
116
+ return ""
117
+
118
+
119
+ def fingerprint(pub: Path) -> Tuple[Optional[int], Optional[str], Optional[str], str]:
120
+ """(bits, fingerprint, TYPE, error) from `ssh-keygen -l -f <pub>`."""
121
+ r = _run(["ssh-keygen", "-l", "-f", str(pub)], LOCAL_TIMEOUT)
122
+ if r is None:
123
+ return None, None, None, "ssh-keygen not found"
124
+ m = _FP_RE.match(_first_line(r.stdout))
125
+ if r.returncode != 0 or not m:
126
+ return None, None, None, _first_line(r.stderr) or f"ssh-keygen -l exited {r.returncode}"
127
+ return int(m[1]), m[2], m[4].upper(), ""
128
+
129
+
130
+ def classify(ktype: str, bits: int) -> Tuple[str, Optional[dict]]:
131
+ """(type name for JSON, the type/size check or None when fine)."""
132
+ t = ktype.upper()
133
+ if t.endswith("-SK"):
134
+ return t.lower(), _check("info", "hardware key", tag="hardware")
135
+ if t == "ED25519":
136
+ return "ed25519", None
137
+ if t == "RSA":
138
+ if bits < 2048:
139
+ return "rsa", _check("error", f"RSA {bits} is too weak; generate a new key",
140
+ "gitpro keys --generate after changing ssh_key", "weak")
141
+ if bits < 3072:
142
+ return "rsa", _check("warning", f"RSA {bits} is below the recommended 3072 bits; ed25519 is better",
143
+ "gitpro keys --generate after changing ssh_key", "rsa-2048")
144
+ return "rsa", None
145
+ if t == "DSA":
146
+ return "dsa", _check("error", "DSA is too weak (current OpenSSH refuses it); generate a new key",
147
+ "gitpro keys --generate after changing ssh_key", "weak")
148
+ if t == "ECDSA":
149
+ name = f"ecdsa-{bits}"
150
+ return name, _check("warning", f"{name}: ed25519 is preferred", tag="ecdsa")
151
+ return t.lower(), None
152
+
153
+
154
+ def probe_passphrase(private: Path) -> Tuple[Optional[bool], str]:
155
+ """(True: has a passphrase, False: none, None: unknown, why). The private key is read by ssh-keygen
156
+ only; stdin is /dev/null so it can never prompt, and its stdout (the public key) is discarded."""
157
+ r = _run(["ssh-keygen", "-y", "-P", "", "-f", str(private)], PROBE_TIMEOUT)
158
+ if r is None:
159
+ return None, "ssh-keygen not found"
160
+ if r.returncode == 0:
161
+ return False, ""
162
+ if "incorrect passphrase" in r.stderr.lower():
163
+ return True, ""
164
+ return None, _first_line(r.stderr) or f"ssh-keygen -y exited {r.returncode}"
165
+
166
+
167
+ def agent_fingerprints() -> Optional[set]:
168
+ """Fingerprints `ssh-add -l` lists; None when there is no agent."""
169
+ r = _run(["ssh-add", "-l"], PROBE_TIMEOUT)
170
+ if r is None or r.returncode not in (0, 1):
171
+ return None
172
+ return {ln.split()[1] for ln in r.stdout.splitlines() if len(ln.split()) >= 2 and ln.split()[0].isdigit()}
173
+
174
+
175
+ def _mode_problems(private: Path, pub: Path, home: Path) -> List[dict]:
176
+ out = []
177
+ try:
178
+ st = private.stat()
179
+ except OSError:
180
+ return out
181
+ shown = _tilde(private, home)
182
+ if st.st_mode & 0o077:
183
+ out.append(_check("error", f"accessible by others ({st.st_mode & 0o777:04o})", f"chmod 600 {_sh(shown)}",
184
+ "perms"))
185
+ if st.st_uid != os.getuid():
186
+ out.append(_check("error", "owned by another user; ssh refuses it", _chown_fix(private, home), "perms"))
187
+ why = pub_unsafe(pub)
188
+ if why:
189
+ ps = _tilde(pub, home)
190
+ fix = _chown_fix(pub, home) if "owned" in why else f"chmod 644 {_sh(ps)}"
191
+ out.append(_check("error" if "everyone" in why else "warning", f"{ps} {why}", fix, "pub-perms"))
192
+ return out
193
+
194
+
195
+ def pub_unsafe(pub: Path) -> Optional[str]:
196
+ """Why a .pub must not be uploaded as is (writable by others or owned by another user); None when fine."""
197
+ try:
198
+ st = pub.stat()
199
+ except OSError:
200
+ return None
201
+ if st.st_mode & 0o002:
202
+ return "is writable by everyone"
203
+ if st.st_mode & 0o020:
204
+ return "is writable by its group"
205
+ if st.st_uid != os.getuid():
206
+ return "is owned by another user"
207
+ return None
208
+
209
+
210
+ def _chown_fix(path: Path, home: Path) -> str:
211
+ """chown on the real file (never through a symlink); it belongs to someone else, so sudo."""
212
+ import pwd
213
+ try:
214
+ me = pwd.getpwuid(os.getuid()).pw_name
215
+ except KeyError:
216
+ me = str(os.getuid())
217
+ real = _tilde(path.resolve(), home)
218
+ return f"sudo chown {me} {_sh(real)} (check first why another user owns it)"
219
+
220
+
221
+ def ssh_dir_check(home: Path) -> Tuple[Optional[str], Optional[dict]]:
222
+ """("0700", None) or the mode and its check for ~/.ssh."""
223
+ try:
224
+ mode = (home / ".ssh").stat().st_mode & 0o777
225
+ except OSError:
226
+ return None, None
227
+ if mode & 0o022:
228
+ return f"{mode:04o}", _check("error", f"~/.ssh is writable by others ({mode:04o})", "chmod 700 ~/.ssh",
229
+ "perms")
230
+ if mode & 0o077:
231
+ return f"{mode:04o}", _check("warning", "~/.ssh is readable by others; ssh accepts it, 0700 is safer",
232
+ "chmod 700 ~/.ssh", "ssh-dir")
233
+ return f"{mode:04o}", None
234
+
235
+
236
+ # ---------------------------------------------------------------- who uses which key
237
+
238
+ @dataclass
239
+ class Use:
240
+ who: str # identity id, or "default"
241
+ host: str
242
+ accounts: list # forge.Account
243
+ auth: bool
244
+ sign: bool
245
+
246
+
247
+ def _accounts_of(host: str, gh: Optional[str], glab: Optional[str]) -> list:
248
+ from . import forge
249
+ out = []
250
+ if gh:
251
+ out.append(forge.Account(host, "gh", gh))
252
+ if glab:
253
+ out.append(forge.Account(host, "glab", glab))
254
+ return out
255
+
256
+
257
+ def uses_by_key(cfg, home: Path) -> Dict[Path, List[Use]]:
258
+ """Every private key path → the identities that use it, with the forge accounts they map to."""
259
+ d = cfg.default
260
+ out: Dict[Path, List[Use]] = {}
261
+ if d.ssh_key:
262
+ accs = _accounts_of(GITHUB, d.gh_account, None) + _accounts_of(GITLAB, None, d.glab_account)
263
+ out.setdefault(_expand(d.ssh_key, home), []).append(
264
+ Use("default", ", ".join(d.ssh_hosts) or "any host", accs, True, d.sign))
265
+ for i in cfg.identities:
266
+ key = i.ssh_key or (d.ssh_key if config.signs(i, d) else None)
267
+ if key:
268
+ out.setdefault(_expand(key, home), []).append(
269
+ Use(i.id, i.host, _accounts_of(i.host, i.gh_account, i.glab_account), bool(i.ssh_key),
270
+ config.signs(i, d)))
271
+ return out
272
+
273
+
274
+ def _forge_name(host: str, cli: str = "") -> str:
275
+ if host == GITHUB or cli == "gh":
276
+ return "GitHub"
277
+ if host == GITLAB or cli == "glab":
278
+ return "GitLab"
279
+ return host
280
+
281
+
282
+ def shared_checks(cfg, home: Path) -> Dict[Path, List[dict]]:
283
+ """⚠ for a key that two identities on one host use with different forge accounts."""
284
+ out: Dict[Path, List[dict]] = {}
285
+ groups: Dict[Tuple[Path, str], list] = {}
286
+ for i in cfg.identities:
287
+ if i.ssh_key:
288
+ groups.setdefault((_expand(i.ssh_key, home), i.host), []).append(i)
289
+ for (key, host), ids in groups.items():
290
+ for attr, cli in (("gh_account", "gh"), ("glab_account", "glab")):
291
+ logins = {getattr(i, attr) for i in ids if getattr(i, attr)}
292
+ if len(logins) > 1:
293
+ who = " and ".join(i.id for i in ids if getattr(i, attr))
294
+ out.setdefault(key, []).append(_check(
295
+ "warning", f"shared by {who} on {host}; {_forge_name(host, cli)} allows a key on one account only",
296
+ "give each identity its own ssh_key", "shared"))
297
+ return out
298
+
299
+
300
+ # ---------------------------------------------------------------- forge listings (online)
301
+
302
+ @dataclass
303
+ class Entry:
304
+ material: Tuple[str, str]
305
+ kinds: set # {"auth", "signing"}
306
+ id: Optional[str] = None
307
+
308
+
309
+ @dataclass
310
+ class Listing:
311
+ entries: List[Entry] = field(default_factory=list)
312
+ signing_known: bool = True
313
+ error: Optional[str] = None
314
+
315
+ def found(self, mat: Tuple[str, str]) -> List[Entry]:
316
+ return [e for e in self.entries if e.material == mat]
317
+
318
+
319
+ def parse_gh_keys(text: str) -> Listing:
320
+ """`gh ssh-key list` (not a terminal: title, key, added, id, type; older gh: no id/type columns)."""
321
+ out, typed = [], False
322
+ for line in text.splitlines():
323
+ cols = [c.strip() for c in (line.split("\t") if "\t" in line else line.split())]
324
+ mat = _material_in(" ".join(cols).split())
325
+ if not mat:
326
+ continue
327
+ kind = next((c.lower() for c in cols if c.lower() in ("authentication", "signing")), None)
328
+ typed |= kind is not None
329
+ kid = cols[3] if len(cols) >= 4 and cols[3].isdigit() else next((c for c in cols[1:] if c.isdigit()), None)
330
+ out.append(Entry(mat, {"signing"} if kind == "signing" else {"auth"}, kid))
331
+ return Listing(out, typed or not out)
332
+
333
+
334
+ _USAGE = {"auth": {"auth"}, "signing": {"signing"}, "auth_and_signing": {"auth", "signing"}}
335
+
336
+
337
+ def parse_glab_keys(text: str) -> Optional[Listing]:
338
+ """`glab ssh-key list --output json`; None when the text is not that JSON."""
339
+ import json
340
+ try:
341
+ data = json.loads(text)
342
+ except ValueError:
343
+ return None
344
+ if not isinstance(data, list):
345
+ return None
346
+ out, known = [], True
347
+ for k in data:
348
+ if not isinstance(k, dict):
349
+ continue
350
+ mat = _material_in(str(k.get("key") or "").split())
351
+ if not mat:
352
+ continue
353
+ usage = k.get("usage_type")
354
+ known &= usage in _USAGE
355
+ out.append(Entry(mat, set(_USAGE.get(usage, {"auth"})), str(k["id"]) if k.get("id") is not None else None))
356
+ return Listing(out, known)
357
+
358
+
359
+ def _scope_hint(acc, scopes: Sequence[str]) -> str:
360
+ flags = " ".join(f"-s {s}" for s in scopes)
361
+ return f"gh auth refresh -h {acc.host} {flags}"
362
+
363
+
364
+ def _is_scope_error(stderr: str) -> bool:
365
+ return bool(re.search(r"HTTP 40[34]", stderr)) and ("scope" in stderr.lower() or "public_key" in stderr)
366
+
367
+
368
+ class Lister:
369
+ """Key listings per forge account, cached for one run."""
370
+
371
+ def __init__(self):
372
+ self.cache: dict = {}
373
+
374
+ def get(self, acc) -> Listing:
375
+ if acc not in self.cache:
376
+ self.cache[acc] = self._list(acc)
377
+ return self.cache[acc]
378
+
379
+ def forget(self, acc) -> None:
380
+ self.cache.pop(acc, None)
381
+
382
+ @staticmethod
383
+ def _list(acc) -> Listing:
384
+ from . import forge
385
+ if acc.cli == "glab":
386
+ wrong = glab_login_error(acc)
387
+ if wrong:
388
+ return Listing(error=wrong)
389
+ try:
390
+ if acc.cli == "gh":
391
+ r = forge.run(acc, ["ssh-key", "list"])
392
+ else:
393
+ r = forge.run(acc, ["ssh-key", "list", "--output", "json"])
394
+ got = parse_glab_keys(r.stdout) if r.returncode == 0 else None
395
+ if got is not None:
396
+ return got
397
+ if r.returncode != 127:
398
+ r = forge.run(acc, ["ssh-key", "list"])
399
+ except ConfigError:
400
+ return Listing(error=f"no {acc.cli} login for {acc.login} on {acc.host}; registered check skipped")
401
+ if r.returncode == 127:
402
+ return Listing(error=f"{acc.cli} not found; registered check skipped for {acc.host} ({acc.login})")
403
+ if r.returncode != 0:
404
+ if acc.cli == "gh" and _is_scope_error(r.stderr):
405
+ return Listing(error=f"gh token for {acc.login} cannot list keys (read:public_key scope); run: "
406
+ + _scope_hint(acc, ("read:public_key", "read:ssh_signing_key")))
407
+ return Listing(error=f"{acc.cli} ssh-key list failed for {acc.login} on {acc.host}: "
408
+ f"{_first_line(r.stderr) or 'exit ' + str(r.returncode)}")
409
+ if acc.cli == "gh":
410
+ return parse_gh_keys(r.stdout)
411
+ text = parse_gh_keys(r.stdout) # glab's text table: key material, no usage type
412
+ return Listing(text.entries, False)
413
+
414
+
415
+ _GLAB_AS_RE = re.compile(r"Logged in to (\S+) as (\S+)", re.I)
416
+
417
+
418
+ def glab_login_error(acc) -> Optional[str]:
419
+ """None when glab acts as acc.login on acc.host. glab has no per-login selection: every glab call acts
420
+ as whoever `glab auth status` is logged in as, so another login must never be listed or uploaded to."""
421
+ from . import forge
422
+ r = forge.run(acc, ["auth", "status", "-h", acc.host])
423
+ if r.returncode == 127:
424
+ return None # the listing reports a missing glab itself
425
+ text = f"{r.stdout}\n{r.stderr}"
426
+ logins = [m[2].rstrip(".,") for m in _GLAB_AS_RE.finditer(text) if m[1].rstrip(".,:") == acc.host]
427
+ if any(x.lower() == acc.login.lower() for x in logins):
428
+ return None
429
+ if not logins and forge.logged_in(acc):
430
+ return None
431
+ fix = f"glab auth login --hostname {acc.host}"
432
+ if logins:
433
+ return f"glab is logged in to {acc.host} as {logins[0]}, not {acc.login} → {fix}"
434
+ return f"glab is not logged in to {acc.host} as {acc.login} → {fix}"
435
+
436
+
437
+ def _upload_hint(who: str) -> str:
438
+ return "gitpro keys --upload --online" if who == "default" else f"gitpro keys --upload --identity {who} --online"
439
+
440
+
441
+ def _registration(mat: Tuple[str, str], uses: List[Use], lister: Lister, all_accounts: list) -> Tuple[list, list]:
442
+ """(checks, registrations) of one key on every account its identities map to."""
443
+ checks, regs, seen = [], [], {}
444
+ for u in uses:
445
+ for acc in u.accounts:
446
+ w = seen.setdefault(acc, {"auth": False, "sign": False, "who": u.who})
447
+ w["auth"] |= u.auth
448
+ w["sign"] |= u.sign
449
+ if not seen:
450
+ return [_check("info", "registered: no gh_account/glab_account for this key; nothing to check online")], []
451
+ for acc, want in seen.items():
452
+ lst = lister.get(acc)
453
+ where = f"{acc.host} ({acc.login})"
454
+ if lst.error:
455
+ checks.append(_check("warning", lst.error, tag="unchecked"))
456
+ continue
457
+ found = lst.found(mat)
458
+ auth = any("auth" in e.kinds for e in found)
459
+ signing = any("signing" in e.kinds for e in found) if lst.signing_known else None
460
+ regs.append({"host": acc.host, "cli": acc.cli, "account": acc.login, "auth": auth, "signing": signing})
461
+ hint = _upload_hint(want["who"])
462
+ if not found:
463
+ other = next((o for o in all_accounts if o != acc and o.host == acc.host and o.cli == acc.cli
464
+ and lister.get(o).found(mat)), None)
465
+ if other is not None:
466
+ checks.append(_check("warning", f"registered on {acc.host} ({other.login}) but identity {want['who']} "
467
+ f"maps to {acc.login}", "give that identity its own key", "wrong-account"))
468
+ else:
469
+ checks.append(_check("error", f"not registered on {where}", hint, "unregistered"))
470
+ if lst.signing_known or not want["sign"]:
471
+ continue
472
+ kinds = [k for k, v in (("auth", auth), ("signing", signing)) if v]
473
+ if kinds:
474
+ checks.append(_check("ok", f"registered on {where} as {' + '.join(kinds)}"))
475
+ if want["auth"] and found and not auth:
476
+ checks.append(_check("error", f"not registered on {where} as an auth key", hint, "unregistered"))
477
+ if want["sign"] and signing is False and found:
478
+ checks.append(_check("warning", f"not registered on {where} as a signing key; signed commits show "
479
+ "as unverified", hint, "unregistered-signing"))
480
+ if want["sign"] and signing is None:
481
+ checks.append(_check("warning", f"{acc.cli} too old to list signing keys; signing registration on {where} "
482
+ "unknown", f"upgrade {acc.cli}", "unchecked"))
483
+ return checks, regs
484
+
485
+
486
+ # ---------------------------------------------------------------- keys --health
487
+
488
+ def _key_record(k, home: Path, uses: List[Use], agent: Optional[set], shared: List[dict], online: bool,
489
+ lister: Optional[Lister], all_accounts: list) -> dict:
490
+ private = _expand(k.path, home)
491
+ pub = _pub(private)
492
+ shown = _tilde(private, home)
493
+ rec = {"path": str(private), "pub": str(pub), "exists": private.exists() and pub.exists(), "type": None,
494
+ "bits": None, "fingerprint": None, "uses": list(k.uses), "sign": bool(k.sign), "passphrase": None,
495
+ "perms_ok": None, "in_agent": None, "state": "ok", "weak": False, "registered": None, "problems": [],
496
+ "fixes": [], "checks": []}
497
+ checks: List[dict] = rec["checks"]
498
+ if not private.exists():
499
+ checks.append(_check("error", "missing", "gitpro keys --generate", "missing"))
500
+ elif not pub.exists():
501
+ ps = _tilde(pub, home)
502
+ checks.append(_check("error", f"{ps} missing", f"ssh-keygen -y -f {_sh(shown)} > {_sh(ps)}", "missing"))
503
+ if not rec["exists"]:
504
+ rec["state"] = "missing"
505
+ return _finish(rec)
506
+ odd = [p for p in (private, pub) if not p.is_file()]
507
+ if odd: # a FIFO or device would hang ssh-keygen and the reads
508
+ checks.extend(_check("error", f"{_tilde(p, home)} is not a regular file", tag="missing") for p in odd)
509
+ rec["state"] = "missing"
510
+ return _finish(rec)
511
+ perms = _mode_problems(private, pub, home)
512
+ rec["perms_ok"] = not any(c["level"] == "error" for c in perms)
513
+ blocking = [c for c in perms if c["tag"] == "perms"]
514
+ bits, fp, ktype, err = fingerprint(pub)
515
+ if ktype is None:
516
+ checks.append(_check("warning", f"could not read {_tilde(pub, home)}: {err}", tag="unreadable"))
517
+ else:
518
+ rec["type"], typecheck = classify(ktype, bits or 0)
519
+ rec["bits"], rec["fingerprint"] = bits, fp
520
+ if typecheck:
521
+ checks.append(typecheck)
522
+ rec["weak"] = typecheck["tag"] == "weak"
523
+ checks.extend(perms)
524
+ if not blocking:
525
+ has, why = probe_passphrase(private)
526
+ rec["passphrase"] = has
527
+ if has is True:
528
+ checks.append(_check("ok", "passphrase"))
529
+ elif has is False and (rec["type"] or "").endswith("-sk"):
530
+ checks.append(_check("info", "no passphrase (the hardware key guards it)"))
531
+ elif has is False:
532
+ checks.append(_check("warning", "no passphrase", f"ssh-keygen -p -f {_sh(shown)}", "no-passphrase"))
533
+ else:
534
+ checks.append(_check("warning", f"could not probe {shown}: {why}", tag="unprobed"))
535
+ if not perms:
536
+ checks.append(_check("ok", "perms"))
537
+ if agent is not None and fp:
538
+ rec["in_agent"] = fp in agent
539
+ if rec["in_agent"]:
540
+ checks.append(_check("info", "loaded in ssh-agent"))
541
+ elif rec["passphrase"]:
542
+ rec["state"] = "locked"
543
+ checks.append(_check("warning", "has a passphrase and is not in ssh-agent: every git push will prompt, "
544
+ "GUI clients and agents may fail", f"ssh-add {_sh(shown)}", "locked"))
545
+ else:
546
+ checks.append(_check("info", "not in ssh-agent"))
547
+ checks.extend(shared)
548
+ if online and lister is not None:
549
+ mat = material(pub)
550
+ if mat is None:
551
+ checks.append(_check("warning", f"cannot read the key material of {_tilde(pub, home)}", tag="unchecked"))
552
+ else:
553
+ more, regs = _registration(mat, uses, lister, all_accounts)
554
+ checks.extend(more)
555
+ rec["registered"] = ({"auth": regs[0]["auth"], "signing": regs[0]["signing"],
556
+ "account": regs[0]["account"], "host": regs[0]["host"]} if regs else None)
557
+ rec["registrations"] = regs
558
+ if rec["weak"]:
559
+ rec["state"] = "weak"
560
+ return _finish(rec)
561
+
562
+
563
+ def _finish(rec: dict) -> dict:
564
+ for c in rec["checks"]:
565
+ if c["level"] in ("warning", "error"):
566
+ rec["problems"].append(c["message"])
567
+ if c["fix"]:
568
+ rec["fixes"].append(c["fix"])
569
+ return rec
570
+
571
+
572
+ _TAG_TEXT = {"no-passphrase": "without passphrase", "weak": "weak", "locked": "locked", "missing": "missing",
573
+ "perms": "with loose permissions", "pub-perms": "with loose permissions",
574
+ "unregistered": "not registered", "unregistered-signing": "not registered for signing",
575
+ "shared": "shared", "wrong-account": "on the wrong account"}
576
+
577
+
578
+ def check_keys(cfg, *, online: bool = False, announce: bool = True, home: Optional[Path] = None) -> dict:
579
+ """Health of every configured key. With online, also asks gh/glab which keys each mapped account has
580
+ (forge.announce first unless announce=False: doctor announces once itself). Prints nothing else."""
581
+ from . import forge, keys
582
+ home = Path(home) if home else Path.home()
583
+ inv = [k for k in keys.inventory(cfg.default, list(cfg.identities)) if k.path]
584
+ dir_mode, dir_check = ssh_dir_check(home)
585
+ out = {"online": bool(online), "keys": [], "ssh_dir_mode": dir_mode, "agent": None, "general": [],
586
+ "counts": {"fine": 0, "warnings": 0, "problems": 0}}
587
+ if not inv:
588
+ return {**out, "ok": True, "status": "ok", "summary": "no SSH keys configured", "problems": [],
589
+ "details": []}
590
+ if shutil.which("ssh-keygen") is None:
591
+ msg = "ssh-keygen not found"
592
+ return {**out, "ok": False, "status": "error", "summary": msg,
593
+ "problems": [{"message": msg, "fix": "install OpenSSH", "level": "error"}], "details": []}
594
+ general = out["general"]
595
+ if dir_check:
596
+ general.append(dir_check)
597
+ agent = agent_fingerprints()
598
+ out["agent"] = agent is not None
599
+ if agent is None:
600
+ general.append(_check("info", "no ssh-agent: a key with a passphrase prompts on every use"))
601
+ all_accounts = forge.accounts(cfg) if online else []
602
+ if online and announce and all_accounts:
603
+ forge.announce(ANNOUNCE_WHAT, all_accounts, ANNOUNCE_SENDS)
604
+ lister = Lister() if online else None
605
+ uses, shared = uses_by_key(cfg, home), shared_checks(cfg, home)
606
+ for k in inv:
607
+ p = _expand(k.path, home)
608
+ out["keys"].append(_key_record(k, home, uses.get(p, []), agent, shared.get(p, []), online, lister,
609
+ all_accounts))
610
+ return _summarise(out, home)
611
+
612
+
613
+ def _summarise(out: dict, home: Path) -> dict:
614
+ problems, tally = [], {}
615
+ for c in out["general"]:
616
+ if c["level"] in ("warning", "error"):
617
+ problems.append({"message": c["message"], "fix": c["fix"], "level": c["level"]})
618
+ for rec in out["keys"]:
619
+ bad = [c for c in rec["checks"] if c["level"] in ("warning", "error")]
620
+ out["counts"]["fine"] += not bad
621
+ for c in bad:
622
+ problems.append({"message": f"{_tilde(rec['path'], home)}: {c['message']}", "fix": c["fix"],
623
+ "level": c["level"]})
624
+ if c["tag"] in _TAG_TEXT:
625
+ tally.setdefault(c["tag"], set()).add(rec["path"])
626
+ out["counts"]["warnings"] = sum(p["level"] == "warning" for p in problems)
627
+ out["counts"]["problems"] = sum(p["level"] == "error" for p in problems)
628
+ parts = [f"{len(v)} {_TAG_TEXT[t]}" for t, v in tally.items()]
629
+ n = len(out["keys"])
630
+ summary = f"{_plural(n, 'key')} · " + (", ".join(parts) if parts else
631
+ "all fine" if not problems else problems[0]["message"])
632
+ status = "error" if out["counts"]["problems"] else "warning" if problems else "ok"
633
+ details = [f"{_tilde(r['path'], home)}: {r['type'] or '?'}"
634
+ f"{' ' + str(r['bits']) if r['type'] == 'rsa' else ''} · {r['fingerprint'] or '?'}"
635
+ for r in out["keys"]]
636
+ return {**out, "ok": not out["counts"]["problems"], "status": status, "summary": summary,
637
+ "problems": problems, "details": details}
638
+
639
+
640
+ def render_health(data: dict, home: Path) -> str:
641
+ lines = [_line(c) for c in data["general"]]
642
+ width = max([len(_tilde(r["path"], home)) for r in data["keys"]] + [12])
643
+ for r in data["keys"]:
644
+ kind = "missing" if not r["exists"] else (r["type"] or "?") + (f" {r['bits']}" if r["type"] == "rsa" else "")
645
+ role = "auth + signing" if r["sign"] else "auth"
646
+ lines.append(f"{_tilde(r['path'], home):<{width}} {kind:<8} {role} → {'; '.join(r['uses'])}")
647
+ quiet = [c for c in r["checks"] if c["level"] in ("ok", "info")]
648
+ lines += [" " + _line(c) for c in r["checks"] if c["level"] in ("warning", "error")]
649
+ if quiet:
650
+ lines.append(" " + " ".join(f"{SYM[c['level']]} {c['message']}" for c in quiet))
651
+ if not data["online"]:
652
+ lines.append("registered: run with --online to check via gh/glab")
653
+ c = data["counts"]
654
+ lines.append(f"{_plural(c['fine'], 'key')} fine, {_plural(c['warnings'], 'warning')}, "
655
+ f"{_plural(c['problems'], 'problem')}")
656
+ return "\n".join(lines) + "\n"
657
+
658
+
659
+ def _line(c: dict) -> str:
660
+ return f"{SYM[c['level']]} {c['message']}" + (f" → {c['fix']}" if c["fix"] else "")
661
+
662
+
663
+ def _health_json(data: dict) -> dict:
664
+ keys = [{k: v for k, v in r.items()} for r in data["keys"]]
665
+ for r in keys:
666
+ r["checks"] = [{k: v for k, v in c.items() if k != "tag"} for c in r["checks"]]
667
+ return {"ok": data["ok"], "online": data["online"], "keys": keys, "ssh_dir_mode": data["ssh_dir_mode"],
668
+ "agent": data["agent"], "summary": data["counts"],
669
+ "warnings": [p["message"] for p in data["problems"] if p["level"] == "warning"]}
670
+
671
+
672
+ # ---------------------------------------------------------------- gitpro auth
673
+
674
+ _GREETINGS = [
675
+ ("gitea", re.compile(r"Hi there, (\S+?)!")),
676
+ ("github", re.compile(r"Hi (\S+?)! You've successfully authenticated")),
677
+ ("gitlab", re.compile(r"Welcome to GitLab, @(\S+?)!")),
678
+ ("bitbucket", re.compile(r"logged in as (\S+?)\.?(?:\s|$)")),
679
+ ("bitbucket-server", re.compile(r"authenticated via ssh key\.")),
680
+ ("azure", re.compile(r"remote: Shell access is not supported")),
681
+ ]
682
+
683
+
684
+ def parse_greeting(text: str) -> Tuple[Optional[str], Optional[str]]:
685
+ """(forge, login) from what `ssh -T` printed; (None, None) when nothing is recognised."""
686
+ forge_name, login, _line = _greeting(text)
687
+ return forge_name, login
688
+
689
+
690
+ def _greeting(text: str) -> Tuple[Optional[str], Optional[str], str]:
691
+ """(forge, login, the line that matched)."""
692
+ for forge_name, rx in _GREETINGS:
693
+ m = rx.search(text or "")
694
+ if m:
695
+ start = text.rfind("\n", 0, m.start()) + 1
696
+ end = text.find("\n", m.end())
697
+ line = text[start:end if end >= 0 else len(text)].strip()
698
+ return forge_name, (m.group(1) if rx.groups else None), line
699
+ return None, None, ""
700
+
701
+
702
+ @dataclass
703
+ class Probe:
704
+ host: str # what ssh connects to: an alias or the host
705
+ real_host: str
706
+ user: str
707
+ key: Optional[Path] # the key ssh offers (None: ssh's own choice)
708
+ port: Optional[int]
709
+ use_i: bool # pass -i KEY -o IdentitiesOnly=yes (no alias / ssh_hosts block does it)
710
+ who: str
711
+ expected: Dict[str, Optional[str]]
712
+ config: Optional[Path] = None # -F: the ssh config of a --home that is not the login home
713
+
714
+ def argv(self, timeout: float) -> List[str]:
715
+ """BatchMode (never prompts), StrictHostKeyChecking=yes and UpdateHostKeys=no: a user config with
716
+ accept-new or no can neither make auth trust an unknown host nor write known_hosts."""
717
+ cmd = ["ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "UpdateHostKeys=no",
718
+ "-o", f"ConnectTimeout={max(1, min(10, int(timeout)))}"]
719
+ if self.config:
720
+ cmd += ["-F", str(self.config)]
721
+ if self.use_i and self.key:
722
+ cmd += ["-i", str(self.key), "-o", "IdentitiesOnly=yes"]
723
+ if self.port and self.port != 22:
724
+ cmd += ["-p", str(self.port)]
725
+ return cmd + [f"{self.user}@{self.host}"]
726
+
727
+
728
+ def probes(cfg, identities: Sequence, *, include_default: bool = False, home: Optional[Path] = None) -> List[Probe]:
729
+ """One probe per distinct (alias-or-host, key) pair that gitpro's ssh config produces."""
730
+ home = Path(home) if home else Path.home()
731
+ d = cfg.default
732
+ dkey = _expand(d.ssh_key, home) if d.ssh_key else None
733
+ out = []
734
+ if include_default:
735
+ for h in d.ssh_hosts:
736
+ exp = {"gh": d.gh_account if h == GITHUB else None, "glab": d.glab_account if h == GITLAB else None}
737
+ out.append(Probe(h, h, "git", dkey, None, False, "default", exp))
738
+ for i in identities:
739
+ exp = {"gh": i.gh_account, "glab": i.glab_account}
740
+ key = _expand(i.ssh_key, home) if i.ssh_key else None
741
+ if i.ssh_alias:
742
+ out.append(Probe(i.ssh_alias, i.host, i.ssh_user, key, None, False, i.id, exp))
743
+ elif key:
744
+ out.append(Probe(i.host, i.host, i.ssh_user, key, i.ssh_port, True, i.id, exp))
745
+ else:
746
+ out.append(Probe(i.host, i.host, i.ssh_user, dkey if i.host in d.ssh_hosts else None, i.ssh_port,
747
+ False, i.id, exp))
748
+ seen, uniq = set(), []
749
+ conf = _ssh_config_for(home)
750
+ for p in out:
751
+ k = (p.user, p.host, p.port, str(p.key), p.use_i)
752
+ if k not in seen:
753
+ seen.add(k)
754
+ p.config = conf
755
+ uniq.append(p)
756
+ return uniq
757
+
758
+
759
+ def _ssh_config_for(home: Path) -> Optional[Path]:
760
+ """None when home is the login home (ssh reads its ~/.ssh/config itself); else that home's config,
761
+ or /dev/null when it has none (never the login home's config for another home)."""
762
+ import pwd
763
+ try:
764
+ login_home = Path(pwd.getpwuid(os.getuid()).pw_dir).resolve()
765
+ except (KeyError, OSError):
766
+ login_home = None
767
+ if login_home is not None and Path(home).resolve() == login_home:
768
+ return None
769
+ conf = Path(home) / ".ssh" / "config"
770
+ return conf if conf.is_file() else Path(os.devnull)
771
+
772
+
773
+ def _expected_login(p: Probe, forge_name: Optional[str]) -> Tuple[Optional[str], str]:
774
+ if forge_name == "gitlab" or (forge_name is None and p.real_host == GITLAB):
775
+ return p.expected.get("glab"), "glab_account"
776
+ if forge_name == "github" or p.expected.get("gh"):
777
+ return p.expected.get("gh"), "gh_account"
778
+ return p.expected.get("glab"), "glab_account"
779
+
780
+
781
+ def _locked(key: Optional[Path]) -> bool:
782
+ """A passphrase key that no agent holds (BatchMode cannot ask for the passphrase)."""
783
+ if key is None or not key.exists() or shutil.which("ssh-keygen") is None:
784
+ return False
785
+ has, _why = probe_passphrase(key)
786
+ if not has:
787
+ return False
788
+ _b, fp, _t, _e = fingerprint(_pub(key))
789
+ agent = agent_fingerprints()
790
+ return agent is None or fp not in agent
791
+
792
+
793
+ def _short(text: str) -> str:
794
+ line = _first_line(text)
795
+ cut = line.find("!")
796
+ return line[:cut + 1] if 0 < cut < 60 else line[:60]
797
+
798
+
799
+ _UNKNOWN_HOST_RE = re.compile(r"Host key verification failed|No \S+ host key is known|host key for .* is not known",
800
+ re.I)
801
+ _NET_FAIL_RE = re.compile(r"Could not resolve hostname|Connection refused|Connection closed|kex_exchange|"
802
+ r"Network is unreachable|No route to host", re.I)
803
+ _TIMEOUT_RE = re.compile(r"Connection timed out|Operation timed out", re.I)
804
+
805
+
806
+ def _run_group(argv: List[str], timeout: float) -> subprocess.CompletedProcess:
807
+ """Run argv in its own process group; on timeout kill the whole group (a ProxyCommand child too)
808
+ and raise TimeoutExpired."""
809
+ import signal
810
+ proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
811
+ text=True, start_new_session=True)
812
+ try:
813
+ out, err = proc.communicate(timeout=timeout)
814
+ except subprocess.TimeoutExpired:
815
+ try:
816
+ os.killpg(proc.pid, signal.SIGKILL)
817
+ except OSError:
818
+ pass
819
+ try:
820
+ proc.communicate(timeout=2)
821
+ except subprocess.TimeoutExpired: # a child that left the group still holds the pipes
822
+ proc.kill()
823
+ raise
824
+ return subprocess.CompletedProcess(argv, proc.returncode, out, err)
825
+
826
+
827
+ def _line_with(text: str, needle: str) -> str:
828
+ return next((ln.strip() for ln in text.splitlines() if needle in ln), _first_line(text))
829
+
830
+
831
+ def probe_one(p: Probe, timeout: float, home: Path) -> dict:
832
+ res = {"host": p.host, "real_host": p.real_host, "key": str(p.key) if p.key else None, "who": p.who,
833
+ "login": None, "expected": None, "status": "error", "message": "", "fix": ""}
834
+ try:
835
+ r = _run_group(p.argv(timeout), timeout)
836
+ except subprocess.TimeoutExpired:
837
+ res.update(status="timeout", message=f"{p.real_host}: timed out after {timeout:g} s")
838
+ return res
839
+ except OSError as e:
840
+ res.update(message=str(e))
841
+ return res
842
+ text = f"{r.stderr}\n{r.stdout}"
843
+ forge_name, login, greeting = _greeting(text)
844
+ expected, field_name = _expected_login(p, forge_name)
845
+ res["expected"] = expected
846
+ shown_key = _tilde(p.key, home) if p.key else "the key"
847
+ if forge_name:
848
+ res.update(login=login, message=greeting)
849
+ if login and expected and login.lower() != expected.lower():
850
+ res.update(status="mismatch", fix=f"register {shown_key} on {expected}, not {login}",
851
+ verdict=f"✗ {p.who} maps to {field_name} {expected}")
852
+ else:
853
+ res.update(status="ok", verdict=f"✓ matches {field_name} {expected}" if login and expected else "✓")
854
+ elif _UNKNOWN_HOST_RE.search(text):
855
+ changed = "HAS CHANGED" in text.upper()
856
+ what = "has changed; compare it with the provider's published fingerprints before editing ~/.ssh/known_hosts" \
857
+ if changed else "is not in ~/.ssh/known_hosts"
858
+ res.update(status="unknown_host", message=_line_with(text, "Host key verification failed"),
859
+ verdict=f"✗ host key for {p.real_host} {what}",
860
+ fix="" if changed else f"connect once by hand: ssh -T {p.user}@{p.real_host} "
861
+ "(compare the fingerprint with the provider's published one)")
862
+ elif "Permission denied" in text:
863
+ if _locked(p.key):
864
+ res.update(status="locked", message=_line_with(text, "Permission denied"), fix=f"ssh-add {_sh(shown_key)}",
865
+ verdict=f"✗ {shown_key} has a passphrase and is not in ssh-agent")
866
+ else:
867
+ hint = _upload_hint(p.who)
868
+ res.update(status="denied", message=_line_with(text, "Permission denied"), fix=hint,
869
+ verdict=f"✗ key not registered on {p.real_host}")
870
+ elif _TIMEOUT_RE.search(text):
871
+ res.update(status="timeout", message=f"{p.real_host}: timed out ({_first_line(text)})",
872
+ verdict=f"✗ {p.real_host}: timed out")
873
+ elif r.returncode in (0, 1) and not _NET_FAIL_RE.search(text):
874
+ res.update(status="ok", message=_first_line(text) or "connected (login unknown)",
875
+ verdict="✓ connected (login unknown)")
876
+ else:
877
+ res.update(message=_first_line(text) or f"ssh exited {r.returncode}", verdict="✗ ssh failed")
878
+ return res
879
+
880
+
881
+ def auth(cfg, identities: Sequence, timeout: float = AUTH_TIMEOUT, *, all: bool = False,
882
+ home: Optional[Path] = None) -> dict:
883
+ """ssh -T for every probe (in parallel); {"ok", "results", "status", "summary", "problems"}. Prints nothing."""
884
+ home = Path(home) if home else Path.home()
885
+ if shutil.which("ssh") is None:
886
+ msg = "ssh not found"
887
+ return {"ok": False, "results": [], "status": "error", "summary": msg,
888
+ "problems": [{"message": msg, "fix": "install OpenSSH", "level": "error"}]}
889
+ todo = probes(cfg, identities, include_default=all, home=home)
890
+ with ThreadPoolExecutor(max_workers=max(1, min(8, len(todo)))) as pool:
891
+ results = list(pool.map(lambda p: probe_one(p, timeout, home), todo))
892
+ problems = [{"message": f"{r['host']}: {r.get('verdict', '✗')[2:] or r['status']}"
893
+ + (f" ({r['message']})" if r["status"] in ("denied", "error") and r["message"] else ""),
894
+ "fix": r["fix"], "level": "error"} for r in results if r["status"] != "ok"]
895
+ good = [f"{r['host']} → {r['login'] or '?'}" for r in results if r["status"] == "ok"]
896
+ summary = ", ".join(good) if good and not problems else (problems[0]["message"] if problems else "no hosts")
897
+ return {"ok": not problems, "results": results, "status": "error" if problems else "ok", "summary": summary,
898
+ "problems": problems}
899
+
900
+
901
+ def render_auth(rep: dict, home: Path) -> str:
902
+ rows = rep["results"]
903
+ hw = max([len(r["host"]) for r in rows] + [12])
904
+ kw = max([len(_tilde(r["key"], home)) if r["key"] else 1 for r in rows] + [10])
905
+ lines = []
906
+ for r in rows:
907
+ key = _tilde(r["key"], home) if r["key"] else "-"
908
+ said = _short(r["message"]) if r["status"] not in ("timeout",) else "(no answer)"
909
+ verdict = r.get("verdict") or ("✓" if r["status"] == "ok" else "✗ " + r["message"])
910
+ lines.append(f" {r['host']:<{hw}} key {key:<{kw}} → {said:<30} {verdict}"
911
+ + (f" → {r['fix']}" if r["fix"] else ""))
912
+ return "\n".join(lines) + ("\n" if lines else "")
913
+
914
+
915
+ def _select(cfg, ids: Optional[List[str]]) -> list:
916
+ if not ids:
917
+ return list(cfg.identities)
918
+ by_id = {i.id: i for i in cfg.identities}
919
+ unknown = [x for x in ids if x not in by_id]
920
+ if unknown:
921
+ have = ", ".join(by_id) or "none"
922
+ raise ConfigError(f"no identity named {', '.join(unknown)} (identities: {have})")
923
+ return [by_id[x] for x in dict.fromkeys(ids)]
924
+
925
+
926
+ def cmd_auth(args: argparse.Namespace) -> int:
927
+ from . import cli
928
+ cfg, env = cli._load_all(args)
929
+ as_json = jsonout.wants_json(args)
930
+ if shutil.which("ssh") is None:
931
+ return fail("ssh not found; install OpenSSH", args)
932
+ if args.timeout <= 0:
933
+ return fail("--timeout must be a positive number of seconds", args)
934
+ ids = _select(cfg, args.identity)
935
+ todo = probes(cfg, ids, include_default=args.all, home=env.home)
936
+ if not todo:
937
+ if as_json:
938
+ jsonout.emit("auth", {"results": []})
939
+ else:
940
+ print("· nothing to test: no identity (--all adds the ssh_hosts of [default])")
941
+ return 0
942
+ names = list(dict.fromkeys(p.host for p in todo))
943
+ hosts = ", ".join(names[:-1]) + " and " + names[-1] if len(names) > 1 else names[0]
944
+ print(f"online: connecting with ssh to {hosts} (ssh -T); sends your public key, nothing else",
945
+ file=sys.stderr if as_json else sys.stdout, flush=True)
946
+ rep = auth(cfg, ids, args.timeout, all=args.all, home=env.home)
947
+ if as_json:
948
+ results = [{k: v for k, v in r.items() if k != "verdict"} for r in rep["results"]]
949
+ jsonout.emit("auth", {"ok": rep["ok"], "results": results})
950
+ else:
951
+ sys.stdout.write(render_auth(rep, env.home))
952
+ return 0 if rep["ok"] else 1
953
+
954
+
955
+ # ---------------------------------------------------------------- keys --upload
956
+
957
+ @dataclass
958
+ class Target:
959
+ who: str
960
+ key: Path
961
+ acc: object # forge.Account
962
+ types: List[str] # "auth", "signing" in that order
963
+
964
+
965
+ def upload_targets(cfg, ids: Optional[List[str]], home: Path) -> Tuple[List[Target], List[str]]:
966
+ """(targets, warnings): every (account, key) pair the chosen identities map to, types merged."""
967
+ d = cfg.default
968
+ pairs: Dict[tuple, Target] = {}
969
+ warnings = []
970
+
971
+ def add(who: str, key: Path, accs: list, auth_: bool, sign: bool) -> None:
972
+ for acc in accs:
973
+ t = pairs.setdefault((acc, key), Target(who, key, acc, []))
974
+ for kind, want in (("auth", auth_), ("signing", sign)):
975
+ if want and kind not in t.types:
976
+ t.types.append(kind)
977
+ t.types.sort(key=("auth", "signing").index)
978
+
979
+ if not ids and d.ssh_key:
980
+ add("default", _expand(d.ssh_key, home),
981
+ _accounts_of(GITHUB, d.gh_account, None) + _accounts_of(GITLAB, None, d.glab_account), True, d.sign)
982
+ for i in _select(cfg, ids):
983
+ key = i.ssh_key or (d.ssh_key if config.signs(i, d) else None)
984
+ accs = _accounts_of(i.host, i.gh_account, i.glab_account)
985
+ if not key:
986
+ warnings.append(f"{i.id}: no ssh_key; nothing to upload")
987
+ elif not accs:
988
+ warnings.append(f"{i.id}: no gh_account or glab_account; skipped (gitpro uploads only to a mapped account)")
989
+ else:
990
+ add(i.id, _expand(key, home), accs, bool(i.ssh_key), config.signs(i, d))
991
+ return list(pairs.values()), warnings
992
+
993
+
994
+ def _add_argv(acc, pub: Path, title: str, kinds: List[str]) -> List[List[str]]:
995
+ if acc.cli == "gh":
996
+ return [["ssh-key", "add", str(pub)] + (["--type", "signing"] if k == "signing" else []) + ["--title", title]
997
+ for k in kinds]
998
+ usage = "auth_and_signing" if len(kinds) == 2 else kinds[0]
999
+ return [["ssh-key", "add", str(pub), "--title", title, "--usage-type", usage]]
1000
+
1001
+
1002
+ def _undo(acc, ids: List[str]) -> Optional[str]:
1003
+ if not ids:
1004
+ return None
1005
+ if acc.cli == "gh":
1006
+ return " && ".join(f"gh ssh-key delete {i} --yes" for i in ids)
1007
+ return " && ".join(f"glab ssh-key delete {i}" for i in ids)
1008
+
1009
+
1010
+ def upload(cfg, ids: Optional[List[str]] = None, *, title: Optional[str] = None,
1011
+ confirm: Callable[[str], bool], say: Callable[[str], None] = print,
1012
+ warn: Callable[[str], None] = lambda m: print(m, file=sys.stderr), home: Optional[Path] = None) -> dict:
1013
+ """Add each chosen identity's public key to the account it maps to, for the uses it lacks there.
1014
+ Never uploads a key already present (compares key material) or to an unmapped account."""
1015
+ home = Path(home) if home else Path.home()
1016
+ targets, warnings = upload_targets(cfg, ids, home)
1017
+ for w in warnings:
1018
+ warn(f"⚠ {w}")
1019
+ lister = Lister()
1020
+ uploaded, present, failed, declined = [], [], 0, 0
1021
+ for t in targets:
1022
+ acc, pub = t.acc, _pub(t.key)
1023
+ tpub, where = _tilde(pub, home), f"{t.acc.host} ({t.acc.login})"
1024
+ mat = material(pub)
1025
+ if mat is None:
1026
+ warn(f"✗ {tpub} is missing or not a public key → gitpro keys --generate")
1027
+ failed += 1
1028
+ continue
1029
+ unsafe = pub_unsafe(pub)
1030
+ if unsafe: # someone else could have swapped the key we would register
1031
+ fix = _chown_fix(pub, home) if "owned" in unsafe else f"chmod 644 {_sh(tpub)}"
1032
+ warn(f"✗ not uploading {tpub}: it {unsafe}; check its content, then → {fix}")
1033
+ failed += 1
1034
+ continue
1035
+ lst = lister.get(acc)
1036
+ if lst.error:
1037
+ warn(f"⚠ {lst.error}")
1038
+ failed += 1
1039
+ continue
1040
+ before = lst.found(mat)
1041
+ have = set().union(*(e.kinds for e in before)) if before else set()
1042
+ missing = [k for k in t.types if k not in have]
1043
+ if "signing" in missing and not lst.signing_known:
1044
+ warn(f"⚠ {acc.cli} too old to list signing keys; not adding {tpub} as a signing key on {where} "
1045
+ f"(upgrade {acc.cli})")
1046
+ missing.remove("signing")
1047
+ if missing and before and acc.cli == "glab":
1048
+ kinds = " + ".join(k for k in ("auth", "signing") if k in have)
1049
+ warn(f"⚠ {tpub} is already on {where} as {kinds}; GitLab keeps one entry per key: change its usage type "
1050
+ f"to auth_and_signing in GitLab (Preferences → SSH Keys) instead of adding it again")
1051
+ failed += 1
1052
+ continue
1053
+ if not missing:
1054
+ say(f"· {tpub} already on {where}: {', '.join(sorted(have, key=('auth', 'signing').index)) or 'auth'}")
1055
+ present.append({"key": str(pub), "host": acc.host, "account": acc.login, "types": sorted(have)})
1056
+ continue
1057
+ name = title or key_comment(pub) or f"gitpro {socket.gethostname()}"
1058
+ what = {("auth",): f'auth key "{name}"', ("signing",): f'signing key "{name}"',
1059
+ ("auth", "signing"): f'auth key "{name}" and signing key'}[tuple(missing)]
1060
+ if not confirm(f"upload {tpub} to {acc.host} account {acc.login} as {what}?"):
1061
+ say(f"✗ skipped {tpub} for {where}")
1062
+ declined += 1
1063
+ continue
1064
+ done = _add(acc, pub, name, missing, warn, where)
1065
+ if done is None:
1066
+ failed += 1
1067
+ continue
1068
+ lister.forget(acc)
1069
+ old = {e.id for e in before}
1070
+ new_ids = sorted({e.id for e in lister.get(acc).found(mat) if e.id and e.id not in old}, key=_num)
1071
+ undo = _undo(acc, new_ids)
1072
+ say(f"✓ uploaded {tpub} to {where}: {', '.join(done)}")
1073
+ say(f"↩ undo: {undo}" if undo else
1074
+ f"↩ undo: delete the key on {acc.host} (its id could not be read back from {acc.cli} ssh-key list)")
1075
+ uploaded.append({"key": str(pub), "host": acc.host, "account": acc.login, "who": t.who, "types": done,
1076
+ "ids": new_ids, "undo": undo})
1077
+ if len(done) < len(missing):
1078
+ failed += 1
1079
+ return {"ok": not failed and not declined, "uploaded": uploaded, "present": present, "warnings": warnings,
1080
+ "failed": failed, "declined": declined}
1081
+
1082
+
1083
+ def _num(s: str) -> Tuple[int, str]:
1084
+ return (int(s), s) if s.isdigit() else (0, s)
1085
+
1086
+
1087
+ def _add(acc, pub: Path, title: str, kinds: List[str], warn, where: str) -> Optional[List[str]]:
1088
+ """Run the add calls; the kinds added (None when the first one failed)."""
1089
+ from . import forge
1090
+ done: List[str] = []
1091
+ calls = _add_argv(acc, pub, title, kinds)
1092
+ for argv, kind in zip(calls, kinds if acc.cli == "gh" else ["+".join(kinds)]):
1093
+ try:
1094
+ r = forge.run(acc, argv)
1095
+ except ConfigError as e:
1096
+ warn(f"✗ {e}")
1097
+ break
1098
+ if r.returncode != 0:
1099
+ if acc.cli == "gh" and _is_scope_error(r.stderr):
1100
+ warn(f"⚠ gh token for {acc.login} lacks the admin:public_key (and admin:ssh_signing_key) scope; run: "
1101
+ + _scope_hint(acc, ("admin:public_key", "admin:ssh_signing_key")))
1102
+ else:
1103
+ why = _first_line(r.stderr) or f"exit {r.returncode}"
1104
+ warn(f"✗ {acc.cli} ssh-key add failed for {where}: {why}")
1105
+ break
1106
+ done += kind.split("+")
1107
+ return done or None
1108
+
1109
+
1110
+ # ---------------------------------------------------------------- CLI glue
1111
+
1112
+ def _online(args, cfg) -> bool:
1113
+ return bool(args.online or getattr(getattr(cfg, "forge", None), "online", False))
1114
+
1115
+
1116
+ def cmd_health(args: argparse.Namespace) -> int:
1117
+ from . import cli, forge, keys
1118
+ cfg, env = cli._load_all(args)
1119
+ as_json = jsonout.wants_json(args)
1120
+ if not keys.inventory(cfg.default, list(cfg.identities)):
1121
+ if as_json:
1122
+ jsonout.emit("keys", {"online": False, "keys": [], "ssh_dir_mode": ssh_dir_check(env.home)[0],
1123
+ "summary": {"fine": 0, "warnings": 0, "problems": 0}})
1124
+ else:
1125
+ print("no SSH keys configured (set ssh_key in [default] or an identity)")
1126
+ return 0
1127
+ if shutil.which("ssh-keygen") is None:
1128
+ return fail("ssh-keygen not found", args)
1129
+ online = _online(args, cfg)
1130
+ if online and not (forge.available("gh") or forge.available("glab")):
1131
+ if args.online:
1132
+ return fail(f"keys --health --online {NEEDS_CLI}", args)
1133
+ print(f"⚠ [forge] online = true, but the registered check {NEEDS_CLI}; checking offline only",
1134
+ file=sys.stderr)
1135
+ online = False
1136
+ if online:
1137
+ accs = forge.accounts(cfg)
1138
+ if accs:
1139
+ with _stdout_to(sys.stderr if as_json else sys.stdout):
1140
+ forge.announce(ANNOUNCE_WHAT, accs, ANNOUNCE_SENDS)
1141
+ data = check_keys(cfg, online=online, announce=False, home=env.home)
1142
+ if as_json:
1143
+ jsonout.emit("keys", _health_json(data))
1144
+ else:
1145
+ sys.stdout.write(render_health(data, env.home))
1146
+ return 1 if data["counts"]["problems"] else 0
1147
+
1148
+
1149
+ def _stdout_to(stream):
1150
+ import contextlib
1151
+ return contextlib.redirect_stdout(stream)
1152
+
1153
+
1154
+ def cmd_upload(args: argparse.Namespace) -> int:
1155
+ from . import cli, forge
1156
+ cfg, env = cli._load_all(args)
1157
+ as_json = jsonout.wants_json(args)
1158
+ accs = forge.accounts(cfg)
1159
+ if not _online(args, cfg):
1160
+ if as_json:
1161
+ return fail("keys --upload needs the network; re-run with --online", args)
1162
+ return forge.no_online_error("keys --upload", "which keys are registered, then adds the missing public keys",
1163
+ accs, "your public keys, after you confirm each one")
1164
+ if not (forge.available("gh") or forge.available("glab")):
1165
+ return fail(f"keys --upload {NEEDS_CLI}", args)
1166
+ if as_json and not args.yes:
1167
+ return fail("keys --upload --json needs -y (there is no question under --json)", args)
1168
+ if not args.yes and not sys.stdin.isatty():
1169
+ return fail("no terminal to confirm on; re-run with --yes", args)
1170
+ targets, _w = upload_targets(cfg, args.identity, env.home)
1171
+ shown = list(dict.fromkeys(t.acc for t in targets)) or accs
1172
+ with _stdout_to(sys.stderr if as_json else sys.stdout):
1173
+ forge.announce("which SSH keys are registered, then adds the missing ones", shown,
1174
+ "the public key of each identity you confirm")
1175
+
1176
+ def ask(question: str) -> bool:
1177
+ if args.yes:
1178
+ return True
1179
+ from rich.markup import escape
1180
+ from . import view
1181
+ return view.confirm(escape(question))
1182
+
1183
+ out = sys.stderr if as_json else sys.stdout
1184
+ rep = upload(cfg, args.identity, title=args.title, confirm=ask, home=env.home,
1185
+ say=lambda m: print(m, file=out), warn=lambda m: print(m, file=sys.stderr))
1186
+ if as_json:
1187
+ jsonout.emit("keys", {"ok": rep["ok"], "uploaded": rep["uploaded"], "present": rep["present"],
1188
+ "warnings": rep["warnings"]})
1189
+ elif not rep["uploaded"] and not rep["failed"] and not rep["declined"]:
1190
+ print("✓ nothing to upload")
1191
+ return 0 if rep["ok"] else 1
1192
+
1193
+
1194
+ def _add_key_flags(p: argparse.ArgumentParser) -> None:
1195
+ p.add_argument("--health", action="store_true",
1196
+ help="check each key: type and size, passphrase, file modes, ssh-agent (--online: registered)")
1197
+ p.add_argument("--upload", action="store_true",
1198
+ help="add missing public keys to the gh/glab account each identity maps to (needs --online)")
1199
+ p.add_argument("--online", action="store_true", help="with --health/--upload: ask gh/glab (sends no key material "
1200
+ "for --health)")
1201
+ p.add_argument("--identity", action="append", metavar="ID",
1202
+ help="with --upload: only this identity (repeatable)").gitpro_values = "identities"
1203
+ p.add_argument("--title", metavar="T", help="with --upload: key title (default: the key comment)")
1204
+ p.add_argument("-y", "--yes", action="store_true", help="with --upload: don't ask (needed without a terminal)")
1205
+ p.add_argument("--json", action="store_true", help="with --health/--upload: machine-readable output")
1206
+
1207
+
1208
+ def _keys_handler(args: argparse.Namespace) -> Optional[int]:
1209
+ if not (args.health or args.upload):
1210
+ extra = [f for f, v in (("--online", args.online), ("--identity", args.identity), ("--title", args.title),
1211
+ ("--yes", args.yes), ("--json", args.json)) if v]
1212
+ return fail(f"keys {extra[0]} needs --health or --upload", args) if extra else None
1213
+ if args.health and args.upload:
1214
+ return fail("keys: use --health or --upload, not both", args)
1215
+ if args.generate or args.no_passphrase:
1216
+ return fail("keys: --generate cannot be combined with --health/--upload", args)
1217
+ if args.health:
1218
+ bad = [f for f, v in (("--identity", args.identity), ("--title", args.title), ("--yes", args.yes)) if v]
1219
+ if bad:
1220
+ return fail(f"keys {bad[0]} goes with --upload, not --health", args)
1221
+ return cmd_health(args)
1222
+ return cmd_upload(args)
1223
+
1224
+
1225
+ def register(sub, common, confirm) -> None:
1226
+ sp = sub.add_parser("auth", parents=[common],
1227
+ help="run ssh -T for every host and alias and show which login each one gets (online)")
1228
+ sp.add_argument("--identity", action="append", metavar="ID",
1229
+ help="only this identity (repeatable)").gitpro_values = "identities"
1230
+ sp.add_argument("--all", action="store_true", help="also the ssh_hosts of [default]")
1231
+ sp.add_argument("--timeout", type=float, default=AUTH_TIMEOUT, metavar="S",
1232
+ help=f"seconds per host (default: {AUTH_TIMEOUT})")
1233
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
1234
+ sp.set_defaults(func=cmd_auth)
1235
+ capability("auth")
1236
+
1237
+
1238
+ def extend(subs) -> None:
1239
+ from .cli import extend_command
1240
+ extend_command(subs, "keys", _add_key_flags, _keys_handler)
1241
+ capability("keys.health", "keys.upload")