Skip to content

Commit 02bcb27

Browse files
committed
feat: add SSH agent authentication
1 parent 75bdc6e commit 02bcb27

9 files changed

Lines changed: 379 additions & 22 deletions

File tree

‎README.md‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ The deterministic precedence is `explicit CLI > CH_DIAG_* environment > TOML
147147
lists and can then be repeated. Supported environment names follow CLI names,
148148
for example `CH_DIAG_HOST`, `CH_DIAG_PORT`, `CH_DIAG_COLLECTION_MODE`,
149149
`CH_DIAG_TARGET_SCOPE`, `CH_DIAG_OUTPUT_FORMAT`, `CH_DIAG_SSH_HOST` and
150-
`CH_DIAG_DURATION`. Run `ch-diag --help` and the command help for the full CLI.
150+
`CH_DIAG_SSH_AGENT`. Run `ch-diag --help` and the command help for the full CLI.
151151

152152
Literal passwords are deliberately rejected in TOML. Put only
153153
`password_env = "CH_DIAG_PASSWORD"` in the file and supply the secret through
@@ -324,8 +324,10 @@ Node is the default and does not require a configured cluster.
324324

325325
## SSH mode and known hosts
326326

327-
SSH is key-only. `--ssh-known-hosts` is mandatory and host-key verification is
328-
never silently disabled. Prepare a dedicated file before the first run:
327+
SSH uses explicit public-key authentication through either a key file or an
328+
existing local agent. `--ssh-known-hosts` is mandatory and host-key
329+
verification is never silently disabled. Prepare a dedicated file before the
330+
first run:
329331

330332
```bash
331333
mkdir -p ~/.ssh
@@ -350,6 +352,31 @@ ch-diag snapshots \
350352
--out-dir reports/remote
351353
```
352354

355+
Alternatively, load the identity into a local agent and replace `--ssh-key`
356+
with `--ssh-agent`:
357+
358+
```bash
359+
eval "$(ssh-agent -s)"
360+
ssh-add ~/.ssh/ch_diag_ed25519
361+
ssh-add -l
362+
363+
ch-diag snapshots \
364+
--host 127.0.0.1 --port 9000 \
365+
--collection-mode remote --target-scope node \
366+
--ssh-host db-host.example.net --ssh-port 22 \
367+
--ssh-user chdiag --ssh-agent \
368+
--ssh-known-hosts ~/.ssh/ch_diag_known_hosts \
369+
--duration 10 --interval 5 \
370+
--out-dir reports/remote
371+
```
372+
373+
`--ssh-key` and `--ssh-agent` are mutually exclusive. Agent mode requires a
374+
live Unix socket in the inherited `SSH_AUTH_SOCK` for the complete collection.
375+
`ch-diag` does not start an agent, run `ssh-add`, or forward the agent to the
376+
target. Key mode explicitly disables fallback to identities from an agent.
377+
TOML may select agent mode with `agent = true` in `[ssh]`; the corresponding
378+
environment switch is `CH_DIAG_SSH_AGENT=true`.
379+
353380
`--host` and `--port` in this mode are resolved from the SSH target. The
354381
ClickHouse native connection travels through the tunnel; shell scripts execute
355382
on the SSH target.

‎ch_diag/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""ch_diag package."""
22

3-
__version__ = "0.8.0"
3+
__version__ = "0.8.1"

‎ch_diag/cli.py‎

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,39 @@ def __call__(
5555
setattr(namespace, self.dest, current)
5656

5757

58+
class _SshKeyAction(argparse.Action):
59+
"""Select key-file auth and override an agent default from config or env."""
60+
61+
def __call__(
62+
self,
63+
parser: argparse.ArgumentParser,
64+
namespace: argparse.Namespace,
65+
values: str,
66+
option_string: str | None = None,
67+
) -> None:
68+
del parser, option_string
69+
namespace.ssh_key = values
70+
namespace.ssh_agent = False
71+
72+
73+
class _SshAgentAction(argparse.Action):
74+
"""Select agent auth and override a key default from config or env."""
75+
76+
def __init__(self, option_strings: Sequence[str], dest: str, **kwargs: Any) -> None:
77+
super().__init__(option_strings, dest, nargs=0, **kwargs)
78+
79+
def __call__(
80+
self,
81+
parser: argparse.ArgumentParser,
82+
namespace: argparse.Namespace,
83+
values: Any,
84+
option_string: str | None = None,
85+
) -> None:
86+
del parser, values, option_string
87+
namespace.ssh_agent = True
88+
namespace.ssh_key = None
89+
90+
5891
def _parser(defaults: dict[str, Any] | None = None) -> argparse.ArgumentParser:
5992
parser = argparse.ArgumentParser(prog="ch-diag", description="ClickHouse diagnostics")
6093
parser.add_argument("--config", help="TOML configuration file (must precede command)")
@@ -152,7 +185,14 @@ def _add_collection_arguments(parser: argparse.ArgumentParser) -> None:
152185
parser.add_argument("--ssh-host")
153186
parser.add_argument("--ssh-port", type=int, default=22)
154187
parser.add_argument("--ssh-user")
155-
parser.add_argument("--ssh-key")
188+
ssh_auth = parser.add_mutually_exclusive_group()
189+
ssh_auth.add_argument("--ssh-key", action=_SshKeyAction)
190+
ssh_auth.add_argument(
191+
"--ssh-agent",
192+
action=_SshAgentAction,
193+
default=False,
194+
help="authenticate through the existing SSH_AUTH_SOCK agent",
195+
)
156196
parser.add_argument("--ssh-known-hosts")
157197

158198

@@ -200,18 +240,35 @@ def _connection(args: argparse.Namespace) -> ConnectionConfig:
200240

201241

202242
def _ssh_config(args: argparse.Namespace) -> SshConfig | None:
203-
values = (args.ssh_host, args.ssh_user, args.ssh_key, args.ssh_known_hosts)
243+
values = (
244+
args.ssh_host,
245+
args.ssh_user,
246+
args.ssh_key,
247+
args.ssh_agent,
248+
args.ssh_known_hosts,
249+
)
204250
if not any(values):
205251
return None
206-
if not all(values):
252+
if not args.ssh_host or not args.ssh_user or not args.ssh_known_hosts:
207253
raise ValueError(
208-
"SSH requires --ssh-host, --ssh-user, --ssh-key and --ssh-known-hosts"
254+
"SSH requires --ssh-host, --ssh-user, --ssh-known-hosts and "
255+
"exactly one of --ssh-key or --ssh-agent"
209256
)
257+
if bool(args.ssh_key) == bool(args.ssh_agent):
258+
raise ValueError("SSH requires exactly one of --ssh-key or --ssh-agent")
259+
agent_path = None
260+
if args.ssh_agent:
261+
agent_path = os.environ.get("SSH_AUTH_SOCK")
262+
if not agent_path:
263+
raise ValueError(
264+
"--ssh-agent requires SSH_AUTH_SOCK to reference a running agent"
265+
)
210266
return SshConfig(
211267
host=args.ssh_host,
212268
port=args.ssh_port,
213269
username=args.ssh_user,
214270
client_key=args.ssh_key,
271+
agent_path=agent_path,
215272
known_hosts=args.ssh_known_hosts,
216273
)
217274

‎ch_diag/config.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"port": "ssh_port",
4343
"user": "ssh_user",
4444
"key": "ssh_key",
45+
"agent": "ssh_agent",
4546
"known_hosts": "ssh_known_hosts",
4647
},
4748
"snapshots": {"duration": "duration", "interval": "interval"},
@@ -75,12 +76,13 @@
7576
"CH_DIAG_SSH_PORT": "ssh_port",
7677
"CH_DIAG_SSH_USER": "ssh_user",
7778
"CH_DIAG_SSH_KEY": "ssh_key",
79+
"CH_DIAG_SSH_AGENT": "ssh_agent",
7880
"CH_DIAG_SSH_KNOWN_HOSTS": "ssh_known_hosts",
7981
"CH_DIAG_DURATION": "duration",
8082
"CH_DIAG_INTERVAL": "interval",
8183
}
8284

83-
BOOLEAN_FIELDS = {"secure", "no_verify", "strip_meta"}
85+
BOOLEAN_FIELDS = {"secure", "no_verify", "strip_meta", "ssh_agent"}
8486
INTEGER_FIELDS = {"port", "ssh_port"}
8587
FLOAT_FIELDS = {"duration", "interval"}
8688
LIST_FIELDS = {"item_ids", "tags", "output_format"}
@@ -142,6 +144,12 @@ def resolve_cli_defaults(
142144
raise ValueError(
143145
"unknown configuration section(s): " + ", ".join(unknown_sections)
144146
)
147+
ssh_values = document.get("ssh") or {}
148+
if isinstance(ssh_values, dict) and ssh_values.get("key") and _boolean(
149+
ssh_values.get("agent", False),
150+
"configuration [ssh].agent",
151+
):
152+
raise ValueError("configuration [ssh] must select either key or agent")
145153
for section, fields in CONFIG_FIELDS.items():
146154
values = document.get(section) or {}
147155
if not isinstance(values, dict):
@@ -160,6 +168,21 @@ def resolve_cli_defaults(
160168
)
161169

162170
env = environment if environment is not None else os.environ
171+
env_key_present = "CH_DIAG_SSH_KEY" in env
172+
env_agent_present = "CH_DIAG_SSH_AGENT" in env
173+
env_agent_enabled = (
174+
_boolean(env["CH_DIAG_SSH_AGENT"], "environment variable CH_DIAG_SSH_AGENT")
175+
if env_agent_present
176+
else False
177+
)
178+
if env_key_present and env.get("CH_DIAG_SSH_KEY") and env_agent_enabled:
179+
raise ValueError(
180+
"environment must select either CH_DIAG_SSH_KEY or CH_DIAG_SSH_AGENT"
181+
)
182+
if env_key_present:
183+
defaults["ssh_agent"] = False
184+
if env_agent_enabled:
185+
defaults["ssh_key"] = None
163186
for name, destination in ENV_FIELDS.items():
164187
if name in env:
165188
defaults[destination] = _coerce(

‎ch_diag/ssh_transport.py‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
"""Key-only SSH transport with mandatory known_hosts verification."""
1+
"""Explicit key-or-agent SSH transport with mandatory known_hosts verification."""
22

33
from __future__ import annotations
44

55
from dataclasses import dataclass
66
from pathlib import Path
7+
import stat
78
from typing import Any
89

910
from .errors import ChDiagError
@@ -24,18 +25,32 @@ def _asyncssh():
2425
class SshConfig:
2526
host: str
2627
username: str
27-
client_key: str
2828
known_hosts: str
29+
client_key: str | None = None
30+
agent_path: str | None = None
2931
port: int = 22
3032
connect_timeout: float = 10.0
3133

3234
def validate(self) -> None:
3335
if not self.host or not self.username:
3436
raise ValueError("SSH host and username are required")
35-
for label, value in (("SSH private key", self.client_key), ("known_hosts", self.known_hosts)):
36-
path = Path(value).expanduser()
37-
if not path.is_file():
38-
raise ValueError(f"{label} file does not exist: {path}")
37+
if bool(self.client_key) == bool(self.agent_path):
38+
raise ValueError("SSH requires exactly one private key or agent socket")
39+
if self.client_key:
40+
key_path = Path(self.client_key).expanduser()
41+
if not key_path.is_file():
42+
raise ValueError(f"SSH private key file does not exist: {key_path}")
43+
if self.agent_path:
44+
agent_path = Path(self.agent_path).expanduser()
45+
try:
46+
mode = agent_path.stat().st_mode
47+
except OSError as exc:
48+
raise ValueError(f"SSH agent socket is not available: {agent_path}") from exc
49+
if not stat.S_ISSOCK(mode):
50+
raise ValueError(f"SSH_AUTH_SOCK is not a socket: {agent_path}")
51+
known_hosts = Path(self.known_hosts).expanduser()
52+
if not known_hosts.is_file():
53+
raise ValueError(f"known_hosts file does not exist: {known_hosts}")
3954

4055

4156
class SshSession:
@@ -48,16 +63,25 @@ def __init__(self, config: SshConfig, connection: Any) -> None:
4863
async def connect(cls, config: SshConfig) -> "SshSession":
4964
config.validate()
5065
asyncssh = _asyncssh()
66+
client_keys = (
67+
[str(Path(config.client_key).expanduser())] if config.client_key else []
68+
)
69+
agent_path = (
70+
str(Path(config.agent_path).expanduser()) if config.agent_path else None
71+
)
5172
try:
5273
connection = await asyncssh.connect(
5374
config.host,
5475
port=config.port,
5576
username=config.username,
56-
client_keys=[str(Path(config.client_key).expanduser())],
77+
client_keys=client_keys,
5778
known_hosts=str(Path(config.known_hosts).expanduser()),
79+
config=None,
80+
preferred_auth=["publickey"],
5881
password_auth=False,
5982
kbdint_auth=False,
60-
agent_path=None,
83+
agent_path=agent_path,
84+
agent_forwarding=False,
6185
connect_timeout=config.connect_timeout,
6286
)
6387
except Exception as exc:

‎docs/security.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ it redacts, and what remains sensitive in a report.
1111
statement and runs with ClickHouse `readonly=2`, timeout, row and byte guards.
1212
- Cluster names are selected from `system.clusters` and quoted by the adapter.
1313
- Host scripts are package content, not user-supplied commands. Remote mode
14-
requires key authentication and a verified `known_hosts` file.
14+
requires explicit key-file or local-agent authentication and a verified
15+
`known_hosts` file. Agent forwarding is always disabled.
1516
- The checksum detects corruption or replacement relative to its installed
1617
baseline. It cannot authenticate an attacker who can replace both Python
1718
code and the checksum manifest; package provenance and filesystem ownership
@@ -22,8 +23,9 @@ it redacts, and what remains sensitive in a report.
2223
Passwords should be supplied by a prompt or an environment variable named by
2324
`password_env`. Literal TOML passwords are rejected. Passwords, private-key
2425
contents and authentication options are not written to stdout, logs or
25-
artifacts. CLI password arguments remain visible to local process inspection
26-
and are therefore discouraged.
26+
artifacts. The local agent socket pathname can appear in validation errors; it
27+
should not contain sensitive naming. CLI password arguments remain visible to
28+
local process inspection and are therefore discouraged.
2729

2830
`--strip-meta` removes embedded vendor SQL/shell sources, instructions,
2931
manifests, provenance and the HTML metadata controls. It does not redact values

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "ch-diag"
7-
version = "0.8.0"
7+
version = "0.8.1"
88
description = "ClickHouse diagnostics with host metrics and portable JSON/HTML reports"
99
readme = "README.md"
1010
requires-python = ">=3.10"

0 commit comments

Comments
 (0)