diff --git a/docs/docs/adding-devices.mdx b/docs/docs/adding-devices.mdx
index 81dfaae..0b070cb 100644
--- a/docs/docs/adding-devices.mdx
+++ b/docs/docs/adding-devices.mdx
@@ -79,7 +79,10 @@ For HTTP devices (i.e. devices using [hyperglass-agent](https://github.com/check
| Parameter | Type | Description |
| :-------------- | :----- | :----------------------------------------------------------- |
| `username` | String | Username |
-| `password` | String | Password Passwords will never be logged |
+| `password` | String | Password Passwords will never be logged |
+| `key` | Path | Path to SSH Private Key |
+
+To use SSH key authentication, simply specify the path to the SSH private key with `key:`. If the key is encrypted, set the private key's password to the with the `password:` field, and hyperglass will use it to decrypt the SSH key.
### `ssl`
diff --git a/hyperglass/compat/_sshtunnel.py b/hyperglass/compat/_sshtunnel.py
index 66d890c..59ff3a6 100644
--- a/hyperglass/compat/_sshtunnel.py
+++ b/hyperglass/compat/_sshtunnel.py
@@ -53,6 +53,8 @@ from hyperglass.configuration import params
if params.debug:
logging.getLogger("paramiko").setLevel(logging.DEBUG)
+log.bind(logger_name="paramiko")
+
TUNNEL_TIMEOUT = 1.0 #: Timeout (seconds) for tunnel connection
_DAEMON = False #: Use daemon threads in connections
_CONNECTION_COUNTER = 1
@@ -759,7 +761,7 @@ class SSHTunnelForwarder:
host_pkey_directories=None, # look for keys in ~/.ssh
gateway_timeout=None,
*args,
- **kwargs # for backwards compatibility
+ **kwargs, # for backwards compatibility
):
self.logger = logger or log
self.ssh_host_key = ssh_host_key
diff --git a/hyperglass/execution/drivers/ssh.py b/hyperglass/execution/drivers/ssh.py
index 02a975c..a5797a9 100644
--- a/hyperglass/execution/drivers/ssh.py
+++ b/hyperglass/execution/drivers/ssh.py
@@ -23,17 +23,29 @@ class SSHConnection(Connection):
def opener():
"""Set up an SSH tunnel according to a device's configuration."""
+ tunnel_kwargs = {
+ "ssh_username": proxy.credential.username,
+ "remote_bind_address": (self.device._target, self.device.port),
+ "local_bind_address": ("localhost", 0),
+ "skip_tunnel_checkup": False,
+ "gateway_timeout": params.request_timeout - 2,
+ }
+ if proxy.credential._method == "password":
+ # Use password auth if no key is defined.
+ tunnel_kwargs[
+ "ssh_password"
+ ] = proxy.credential.password.get_secret_value()
+ else:
+ # Otherwise, use key auth.
+ tunnel_kwargs["ssh_pkey"] = proxy.credential.key.as_posix()
+ if proxy.credential._method == "encrypted_key":
+ # If the key is encrypted, use the password field as the
+ # private key password.
+ tunnel_kwargs[
+ "ssh_private_key_password"
+ ] = proxy.credential.password.get_secret_value()
try:
- return open_tunnel(
- proxy._target,
- proxy.port,
- ssh_username=proxy.credential.username,
- ssh_password=proxy.credential.password.get_secret_value(),
- remote_bind_address=(self.device._target, self.device.port),
- local_bind_address=("localhost", 0),
- skip_tunnel_checkup=False,
- gateway_timeout=params.request_timeout - 2,
- )
+ return open_tunnel(proxy._target, proxy.port, **tunnel_kwargs)
except BaseSSHTunnelForwarderError as scrape_proxy_error:
log.error(
diff --git a/hyperglass/execution/drivers/ssh_netmiko.py b/hyperglass/execution/drivers/ssh_netmiko.py
index 71ac1f7..88a65a7 100644
--- a/hyperglass/execution/drivers/ssh_netmiko.py
+++ b/hyperglass/execution/drivers/ssh_netmiko.py
@@ -60,20 +60,35 @@ class NetmikoConnection(SSHConnection):
send_args = netmiko_nos_send_args.get(self.device.nos, {})
- netmiko_args = {
+ driver_kwargs = {
"host": host or self.device._target,
"port": port or self.device.port,
"device_type": self.device.nos,
"username": self.device.credential.username,
- "password": self.device.credential.password.get_secret_value(),
"global_delay_factor": params.netmiko_delay_factor,
"timeout": math.floor(params.request_timeout * 1.25),
"session_timeout": math.ceil(params.request_timeout - 1),
**global_args,
}
+ if self.device.credential._method == "password":
+ # Use password auth if no key is defined.
+ driver_kwargs[
+ "password"
+ ] = self.device.credential.password.get_secret_value()
+ else:
+ # Otherwise, use key auth.
+ driver_kwargs["use_keys"] = True
+ driver_kwargs["key_file"] = self.device.credential.key
+ if self.device.credential._method == "encrypted_key":
+ # If the key is encrypted, use the password field as the
+ # private key password.
+ driver_kwargs[
+ "passphrase"
+ ] = self.device.credential.password.get_secret_value()
+
try:
- nm_connect_direct = ConnectHandler(**netmiko_args)
+ nm_connect_direct = ConnectHandler(**driver_kwargs)
responses = ()
diff --git a/hyperglass/execution/drivers/ssh_scrapli.py b/hyperglass/execution/drivers/ssh_scrapli.py
index 077b719..f7493f0 100644
--- a/hyperglass/execution/drivers/ssh_scrapli.py
+++ b/hyperglass/execution/drivers/ssh_scrapli.py
@@ -77,7 +77,6 @@ class ScrapliConnection(SSHConnection):
"host": host or self.device._target,
"port": port or self.device.port,
"auth_username": self.device.credential.username,
- "auth_password": self.device.credential.password.get_secret_value(),
"timeout_transport": math.floor(params.request_timeout * 1.25),
"transport": "asyncssh",
"auth_strict_key": False,
@@ -85,6 +84,21 @@ class ScrapliConnection(SSHConnection):
"ssh_config_file": False,
}
+ if self.device.credential._method == "password":
+ # Use password auth if no key is defined.
+ driver_kwargs[
+ "auth_password"
+ ] = self.device.credential.password.get_secret_value()
+ else:
+ # Otherwise, use key auth.
+ driver_kwargs["auth_private_key"] = self.device.credential.key.as_posix()
+ if self.device.credential._method == "encrypted_key":
+ # If the key is encrypted, use the password field as the
+ # private key password.
+ driver_kwargs[
+ "auth_private_key_passphrase"
+ ] = self.device.credential.password.get_secret_value()
+
driver = driver(**driver_kwargs)
driver.logger = log.bind(logger_name=f"scrapli.driver-{driver._host}")
diff --git a/hyperglass/models/config/credential.py b/hyperglass/models/config/credential.py
index 5201e5a..364fc4e 100644
--- a/hyperglass/models/config/credential.py
+++ b/hyperglass/models/config/credential.py
@@ -1,14 +1,42 @@
"""Validate credential configuration variables."""
+# Standard Library
+from typing import Optional
+
# Third Party
-from pydantic import SecretStr, StrictStr
+from pydantic import FilePath, SecretStr, StrictStr, constr, root_validator
# Local
-from ..main import HyperglassModel
+from ..main import HyperglassModelExtra
+
+Methods = constr(regex=r"(password|unencrypted_key|encrypted_key)")
-class Credential(HyperglassModel):
+class Credential(HyperglassModelExtra):
"""Model for per-credential config in devices.yaml."""
username: StrictStr
- password: SecretStr
+ password: Optional[SecretStr]
+ key: Optional[FilePath]
+
+ @root_validator
+ def validate_credential(cls, values):
+ """Ensure either a password or an SSH key is set."""
+ if values["key"] is None and values["password"] is None:
+ raise ValueError(
+ "Either a password or an SSH key must be specified for user '{}'".format(
+ values["username"]
+ )
+ )
+ return values
+
+ def __init__(self, **kwargs):
+ """Set private attribute _method based on validated model."""
+ super().__init__(**kwargs)
+ self._method = None
+ if self.password is not None and self.key is not None:
+ self._method = "encrypted_key"
+ elif self.password is None:
+ self._method = "unencrypted_key"
+ elif self.key is None:
+ self._method = "password"