Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ This integration does not create devices or entities. It only registers the `ssh
#### Parameters

- `host` (required) — Hostname or IP address of the remote server
- `port` (default: `22`) — SSH port of the remote server
- `username` (required) — SSH username
- `password` — SSH password (use instead of key_file)
- `key_file` — Path to an SSH private key file (use instead of password)
Expand Down
3 changes: 2 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, SERVICE_EXECUTE, CONF_KEY_FILE, CONF_INPUT, CONST_DEFAULT_TIMEOUT, \
CONF_CHECK_KNOWN_HOSTS, CONF_KNOWN_HOSTS
CONF_CHECK_KNOWN_HOSTS, CONF_KNOWN_HOSTS, CONF_PORT
from .coordinator import SshCommandCoordinator

CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) # pylint: disable=invalid-name
Expand Down Expand Up @@ -69,6 +69,7 @@ async def _validate_service_data(hass: HomeAssistant, data: dict[str, Any]) -> N
vol.All(
{
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PORT): int,
vol.Required(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
vol.Optional(CONF_KEY_FILE): str,
Expand Down
2 changes: 2 additions & 0 deletions const.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

DOMAIN = "ssh_command"

CONF_PORT = "port"
CONF_CLIENT_KEYS = "client_keys"
CONF_KEY_FILE = "key_file"
CONF_INPUT = "input"
CONF_CHECK = "check"
CONF_CHECK_KNOWN_HOSTS = "check_known_hosts"
CONF_KNOWN_HOSTS = "known_hosts"
CONF_CONNECTION_TIMEOUT = "connect_timeout"

CONF_OUTPUT = "output"
CONF_ERROR = "error"
Expand Down
36 changes: 32 additions & 4 deletions coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,31 @@

import logging
import socket
import sys
from pathlib import Path
from typing import Any

from asyncssh import HostKeyNotVerifiable, KeyImportError, PermissionDenied, connect, read_known_hosts
_LOGGER = logging.getLogger(__name__)

# asyncssh optionally imports fido2.client.windows for Windows WebAuthn support.
# fido2's Windows-specific module (win_api) uses ctypes.HRESULT, which Python 3.14
# removed on non-Windows platforms, causing an AttributeError that asyncssh does not
# catch (it only catches ImportError in that code path). Pre-attempt the import here
# so that on failure we can replace the broken sys.modules entry with None, which
# makes Python raise ImportError instead — and asyncssh handles that gracefully.
if sys.platform != "win32":
try:
import fido2.client.windows # noqa: F401
except (ImportError, OSError, AttributeError) as _fido2_err:
_LOGGER.debug(
"fido2.client.windows unavailable (%s); asyncssh Windows WebAuthn support disabled",
_fido2_err,
)
sys.modules["fido2.client.windows"] = None # type: ignore[assignment]

from asyncssh import HostKeyNotVerifiable, KeyImportError, PermissionDenied, connect, read_known_hosts, DEFAULT_PORT

from .const import CONF_CONNECTION_TIMEOUT
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_COMMAND, CONF_TIMEOUT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
Expand All @@ -33,10 +53,9 @@
CONF_ERROR,
CONF_EXIT_STATUS,
CONST_DEFAULT_TIMEOUT,
CONF_PORT,
)

_LOGGER = logging.getLogger(__name__)


class SshCommandCoordinator:
"""Single owner of all SSH I/O for the SSH Command integration.
Expand All @@ -52,6 +71,7 @@ def __init__(self, hass: HomeAssistant) -> None:
async def async_execute(self, data: dict[str, Any]) -> dict[str, Any]:
"""Execute an SSH command and return stdout, stderr and exit status."""
host = data.get(CONF_HOST)
port = data.get(CONF_PORT, DEFAULT_PORT)
username = data.get(CONF_USERNAME)
password = data.get(CONF_PASSWORD)
key_file = data.get(CONF_KEY_FILE)
Expand All @@ -67,11 +87,12 @@ async def async_execute(self, data: dict[str, Any]) -> dict[str, Any]:

conn_kwargs = {
CONF_HOST: host,
CONF_PORT: port,
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_CLIENT_KEYS: key_file,
CONF_KNOWN_HOSTS: await self._resolve_known_hosts(check_known_hosts, known_hosts),
"connect_timeout": timeout,
CONF_CONNECTION_TIMEOUT: timeout,
}

run_kwargs: dict[str, Any] = {
Expand Down Expand Up @@ -107,6 +128,13 @@ async def async_execute(self, data: dict[str, Any]) -> dict[str, Any]:
translation_domain=DOMAIN,
translation_key="login_failed",
) from exc
except ConnectionRefusedError as exc:
_LOGGER.warning("Connection refused for %s@%s: %s", username, host, exc)
raise ServiceValidationError(
"Connection refused.",
translation_domain=DOMAIN,
translation_key="connection_refused",
)
except TimeoutError as exc:
_LOGGER.warning("SSH connection to %s timed out: %s", host, exc)
raise ServiceValidationError(
Expand Down
7 changes: 7 additions & 0 deletions services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ execute:
required: true
selector:
text:
port:
name: Port
description: Port number for the SSH connection (default is 22).
required: false
default: 22
selector:
number:
username:
name: Username
description: Username for SSH authentication (optional if using key-based authentication).
Expand Down
15 changes: 11 additions & 4 deletions strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,23 @@
"services": {
"execute": {
"name": "Execute SSH Command",
"description": "Executes a specified command on a remote machine via SSH.",
"description": "Executes a specified command on a remote host via SSH.",
"fields": {
"host": {
"name": "Host",
"description": "Hostname or IP address of the remote machine."
"description": "Hostname or IP address of the remote host."
},
"port": {
"name": "Port",
"description": "Port number for SSH connection (default is 22)."
},
"username": {
"name": "Username",
"description": "Username for SSH authentication."
},
"password": {
"name": "Password",
"description": "Password for SSH authentication (optional if using key-based authentication)."
"description": "Password for SSH authentication."
},
"key_file": {
"name": "Key File",
Expand All @@ -32,7 +36,7 @@
},
"input": {
"name": "Input",
"description": "Input to send to the standard input of the remote process."
"description": "Input to send to the standard input of the host."
},
"check_known_hosts": {
"name": "Check Known Hosts",
Expand Down Expand Up @@ -77,6 +81,9 @@
"login_failed": {
"message": "SSH login failed."
},
"connection_refused": {
"message": "Connection to the host was refused."
},
"host_not_reachable": {
"message": "Host is not reachable."
},
Expand Down
13 changes: 12 additions & 1 deletion translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,17 @@
"name": "Host",
"description": "Hostname oder IP-Adresse des Zielhosts."
},
"port": {
"name": "Port",
"description": "Portnummer für die SSH-Verbindung (Standard ist 22)."
},
"username": {
"name": "Benutzername",
"description": "Der Benutzername für die SSH-Anmeldung (optional, wenn Schlüsseldatei verwendet wird)."
"description": "Der Benutzername für die SSH-Anmeldung."
},
"password": {
"name": "Password",
"description": "Das Passwort für die SSH-Anmeldung."
},
"key_file": {
"name": "Schlüsseldatei",
Expand Down Expand Up @@ -73,6 +81,9 @@
"login_failed": {
"message": "SSH-Anmeldung fehlgeschlagen."
},
"connection_refused": {
"message": "Die Verbindung zum Host wurde abgelehnt."
},
"host_not_reachable": {
"message": "Der Host ist nicht erreichbar."
},
Expand Down
11 changes: 11 additions & 0 deletions translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,18 @@
"name": "Host",
"description": "Hostname or IP address of the remote host."
},
"port": {
"name": "Port",
"description": "Port number for SSH connection (default is 22)."
},
"username": {
"name": "Username",
"description": "Username for SSH authentication."
},
"password": {
"name": "Password",
"description": "Password for SSH authentication."
},
"key_file": {
"name": "Key File",
"description": "Path to the SSH private key file for key-based authentication."
Expand Down Expand Up @@ -73,6 +81,9 @@
"login_failed": {
"message": "SSH login failed."
},
"connection_refused": {
"message": "Connection to the host was refused."
},
"host_not_reachable": {
"message": "Host is not reachable."
},
Expand Down
Loading