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/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
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")
|