diff --git a/README.md b/README.md index bbc3443..de17101 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/__init__.py b/__init__.py index 453b60c..23f39f0 100644 --- a/__init__.py +++ b/__init__.py @@ -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 @@ -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, diff --git a/const.py b/const.py index f0e69af..cb5ddab 100644 --- a/const.py +++ b/const.py @@ -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" diff --git a/coordinator.py b/coordinator.py index 1aac8ee..1300cee 100644 --- a/coordinator.py +++ b/coordinator.py @@ -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 @@ -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. @@ -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) @@ -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] = { @@ -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( diff --git a/services.yaml b/services.yaml index 76282a8..96c9fbe 100644 --- a/services.yaml +++ b/services.yaml @@ -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). diff --git a/strings.json b/strings.json index 964cf44..1d6e635 100644 --- a/strings.json +++ b/strings.json @@ -8,11 +8,15 @@ "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", @@ -20,7 +24,7 @@ }, "password": { "name": "Password", - "description": "Password for SSH authentication (optional if using key-based authentication)." + "description": "Password for SSH authentication." }, "key_file": { "name": "Key File", @@ -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", @@ -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." }, diff --git a/translations/de.json b/translations/de.json index 5e8aae8..3041393 100644 --- a/translations/de.json +++ b/translations/de.json @@ -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", @@ -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." }, diff --git a/translations/en.json b/translations/en.json index 944ffb2..1d6e635 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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." @@ -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." },