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"