From ecaf25acadfd09e62425893bdb3e01c917ee9a91 Mon Sep 17 00:00:00 2001 From: gensyn <36128035+gensyn@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:58:51 +0000 Subject: [PATCH] Fix SSH service validation and documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 47 ++++++++++++++----- __init__.py | 16 +++++++ coordinator.py | 35 ++++++++++++-- services.yaml | 9 ++-- strings.json | 8 +++- tests/unit_tests/test_coordinator.py | 17 ++++++- .../unit_tests/test_validate_service_data.py | 17 +++++++ translations/de.json | 8 +++- translations/en.json | 8 +++- 9 files changed, 142 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e787552..c86d28b 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,12 @@ This integration does not create devices or entities. It only registers the `ssh ### Manual Installation -1. Download or clone this repository -2. Copy the `ssh_command` folder to your Home Assistant `config/custom_components` directory -3. Restart Home Assistant -4. Add the integration via `Settings > Devices & Services > Add Integration` +1. Clone this repository into `config/custom_components/ssh_command` (create `custom_components` if needed): + ```sh + git clone https://github.com/gensyn/ssh_command.git config/custom_components/ssh_command + ``` +2. Restart Home Assistant +3. Add the integration via `Settings > Devices & Services > Add Integration` --- @@ -39,7 +41,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 +- `port` (default: `22`) — SSH port of the remote server (`1`–`65535`) - `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) @@ -47,10 +49,10 @@ This integration does not create devices or entities. It only registers the `ssh - `command` — Command string to execute on the host - `input` — Input to send to the `stdin` of the host. If this is a file path, the content of the file will be sent. - `check_known_hosts` (default: `true`) — Verify host key against known hosts -- `known_hosts` — Path or string of the known hosts (only valid when `check_known_hosts` is `true`) -- `timeout` (default: `30`) — Timeout in seconds for the SSH command execution +- `known_hosts` — Path to a known_hosts file or OpenSSH-format known_hosts contents (only valid when `check_known_hosts` is `true`) +- `timeout` (default: `30`) — Positive timeout in seconds for SSH connection and command execution -All parameters are optional in the raw schema except `host` and `username` — the service enforces required combinations and file existence checks described below. +The service schema requires `host` and `username`. It also enforces the authentication, command/input, key-file, and host-verification rules below. #### Validation rules enforced by the service @@ -59,13 +61,16 @@ All parameters are optional in the raw schema except `host` and `username` — t - Either `command` or `input` or both must be provided - If `key_file` is provided, the file must exist on the Home Assistant filesystem - `known_hosts` may not be provided when `check_known_hosts` is `false` +- `port` must be between `1` and `65535`; `timeout` must be greater than zero + +`input` is sent as literal stdin text unless it names an existing file, in which case that file's contents are sent. SSH host-key verification is enabled by default; disabling it removes protection against connecting to an impersonated host and should only be used when you understand that risk. ### Return values -The service returns: +The service response contains: - `output` — standard output of the remote command - `error` — standard error of the remote command -- `exit_status` — numeric exit status of the remote command +- `exit_status` — numeric exit status of the remote command, or `null` if it ended without an exit status ### Example: run a simple command @@ -84,20 +89,38 @@ data: service: ssh_command.execute data: host: example.local + username: myuser key_file: /config/ssh/id_rsa input: /config/scripts/deploy.sh timeout: 60 ``` -To capture the response in automations that support service responses, use the platform's service response features (the result contains `output`, `error`, `exit_status`). +To capture the response in an automation, use a response variable: + +```yaml +action: + - action: ssh_command.execute + data: + host: 192.0.2.10 + username: myuser + password: !secret ssh_password + command: uptime + response_variable: ssh_result +``` + +`ssh_result` contains `output`, `error`, and `exit_status`. --- ## ⚠️ Known issues -- If you are using HassOS and enable `check_known_hosts` without explicitly providing `known_hosts`, this will probably fail with an error that the `known_hosts` file can not be found, as the file might not be accessible from within Home Assistant. Either disable host checking (not recommended), or provide `known_hosts` explicitly. See the [asyncssh Documentation](https://asyncssh.readthedocs.io/en/stable/api.html#known-hosts) for valid formats. +- If you are using HassOS and enable `check_known_hosts` without explicitly providing `known_hosts`, this may fail if the default `~/.ssh/known_hosts` file is not available to Home Assistant. Provide a known_hosts file path or OpenSSH-format contents instead. See the [AsyncSSH documentation](https://asyncssh.readthedocs.io/en/stable/api.html#known-hosts) for valid formats. - Similar errors might occur in `Docker` installations. +## Removal + +Remove **SSH Command** from `Settings > Devices & Services`. If installed manually, delete `config/custom_components/ssh_command` and restart Home Assistant. If installed through HACS, uninstall it there and restart Home Assistant. + ## 🚧 Future Development Have ideas or feature requests? I'm open to suggestions! diff --git a/__init__.py b/__init__.py index 3bfa58b..a210eae 100644 --- a/__init__.py +++ b/__init__.py @@ -30,6 +30,22 @@ async def _validate_service_data(hass: HomeAssistant, data: dict[str, Any]) -> None: + port = data.get(CONF_PORT, 22) + if not 1 <= port <= 65535: + raise ServiceValidationError( + "Port must be between 1 and 65535.", + translation_domain=DOMAIN, + translation_key="invalid_port", + ) + + timeout = data.get(CONF_TIMEOUT, CONST_DEFAULT_TIMEOUT) + if timeout <= 0: + raise ServiceValidationError( + "Timeout must be greater than zero.", + translation_domain=DOMAIN, + translation_key="invalid_timeout", + ) + has_password: bool = bool(data.get(CONF_PASSWORD)) has_key_file: bool = bool(data.get(CONF_KEY_FILE)) has_passphrase: bool = data.get(CONF_PASSPHRASE) is not None diff --git a/coordinator.py b/coordinator.py index ec2f960..a60b6a7 100644 --- a/coordinator.py +++ b/coordinator.py @@ -20,6 +20,7 @@ KeyEncryptionError, KeyImportError, PermissionDenied, + SSHKnownHosts, connect, read_known_hosts, DEFAULT_PORT, @@ -138,9 +139,9 @@ async def async_execute(self, data: dict[str, Any]) -> dict[str, Any]: translation_key="connection_refused", ) except TimeoutError as exc: - _LOGGER.warning("SSH connection to %s timed out: %s", host, exc) + _LOGGER.warning("SSH operation on %s timed out: %s", host, exc) raise ServiceValidationError( - "Connection timed out.", + "SSH operation timed out.", translation_domain=DOMAIN, translation_key="connection_timed_out", ) from exc @@ -160,12 +161,38 @@ async def async_execute(self, data: dict[str, Any]) -> dict[str, Any]: CONF_EXIT_STATUS: result.exit_status, } - async def _resolve_known_hosts(self, check_known_hosts: bool, known_hosts: str | None) -> str | None: + async def _resolve_known_hosts( + self, check_known_hosts: bool, known_hosts: str | None + ) -> SSHKnownHosts | str | None: """Resolve the known_hosts value for the SSH connection.""" if not check_known_hosts: return None if not known_hosts: known_hosts = str(Path("~", ".ssh", "known_hosts").expanduser()) - if await self.hass.async_add_executor_job(Path(known_hosts).exists): + known_hosts_lines = [ + line.strip().split() + for line in known_hosts.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + if any( + len(fields) >= 3 + and ( + fields[1].startswith(("ssh-", "ecdsa-", "sk-")) + or ( + fields[0] in ("@cert-authority", "@revoked") + and len(fields) >= 4 + and fields[2].startswith(("ssh-", "ecdsa-", "sk-")) + ) + ) + for fields in known_hosts_lines + ): + return SSHKnownHosts(known_hosts) + try: + known_hosts_file_exists = await self.hass.async_add_executor_job( + Path(known_hosts).exists + ) + except OSError: + known_hosts_file_exists = False + if known_hosts_file_exists: return await self.hass.async_add_executor_job(read_known_hosts, known_hosts) return known_hosts diff --git a/services.yaml b/services.yaml index 7e451ee..1dda629 100644 --- a/services.yaml +++ b/services.yaml @@ -16,9 +16,11 @@ execute: default: 22 selector: number: + min: 1 + max: 65535 username: name: Username - description: Username for SSH authentication (optional if using key-based authentication). + description: Username for SSH authentication. required: true selector: text: @@ -50,7 +52,7 @@ execute: text: input: name: Input - description: Input to send to the `stdin` of the host. + description: Input to send to stdin. If this is a file path, the file contents are sent. required: false selector: text: @@ -63,7 +65,7 @@ execute: boolean: known_hosts: name: Known Hosts - description: Path to the known hosts file or string of the known hosts. + description: Path to a known_hosts file or OpenSSH-format known_hosts contents. required: false selector: text: @@ -74,3 +76,4 @@ execute: default: 30 selector: number: + min: 1 diff --git a/strings.json b/strings.json index d8d2f65..12995e3 100644 --- a/strings.json +++ b/strings.json @@ -82,6 +82,12 @@ "known_hosts_with_check_disabled": { "message": "Known hosts provided while check known hosts is disabled." }, + "invalid_port": { + "message": "Port must be between 1 and 65535." + }, + "invalid_timeout": { + "message": "Timeout must be greater than zero." + }, "integration_not_set_up": { "message": "SSH Command integration is not set up." }, @@ -98,7 +104,7 @@ "message": "Host is not reachable." }, "connection_timed_out": { - "message": "Connection to the host timed out." + "message": "SSH operation timed out." } } } diff --git a/tests/unit_tests/test_coordinator.py b/tests/unit_tests/test_coordinator.py index f4f639b..9ecb262 100644 --- a/tests/unit_tests/test_coordinator.py +++ b/tests/unit_tests/test_coordinator.py @@ -10,7 +10,7 @@ absolute_plugin_path = str(Path(__file__).parent.parent.parent.parent.absolute()) sys.path.insert(0, absolute_plugin_path) -from asyncssh import HostKeyNotVerifiable, PermissionDenied +from asyncssh import HostKeyNotVerifiable, PermissionDenied, SSHKnownHosts from homeassistant.exceptions import ServiceValidationError @@ -145,6 +145,21 @@ async def test_resolve_known_hosts_file_missing(self): self.assertEqual(result, "/nonexistent/known_hosts") + async def test_resolve_known_hosts_inline_contents(self): + known_hosts = "example.local ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA" + + result = await self.coordinator._resolve_known_hosts(True, known_hosts) + + self.assertIsInstance(result, SSHKnownHosts) + + async def test_resolve_known_hosts_missing_path_with_spaces(self): + known_hosts = "/nonexistent/my known_hosts file" + + with patch("pathlib.Path.exists", return_value=False): + result = await self.coordinator._resolve_known_hosts(True, known_hosts) + + self.assertEqual(result, known_hosts) + async def test_resolve_known_hosts_default_path(self): with patch("pathlib.Path.exists", return_value=False): result = await self.coordinator._resolve_known_hosts(True, None) diff --git a/tests/unit_tests/test_validate_service_data.py b/tests/unit_tests/test_validate_service_data.py index 12831eb..6042ee3 100644 --- a/tests/unit_tests/test_validate_service_data.py +++ b/tests/unit_tests/test_validate_service_data.py @@ -24,6 +24,23 @@ async def _executor_job(func, *args): self.mock_hass.async_add_executor_job = AsyncMock(side_effect=_executor_job) + async def test_invalid_port_raises(self): + for port in (0, 65536): + with self.subTest(port=port), self.assertRaises(ServiceValidationError) as ctx: + await _validate_service_data( + self.mock_hass, + {"password": "secret", "command": "ls", "port": port}, + ) + self.assertEqual(ctx.exception.translation_key, "invalid_port") + + async def test_invalid_timeout_raises(self): + with self.assertRaises(ServiceValidationError) as ctx: + await _validate_service_data( + self.mock_hass, + {"password": "secret", "command": "ls", "timeout": 0}, + ) + self.assertEqual(ctx.exception.translation_key, "invalid_timeout") + async def test_no_password_no_key_file_raises(self): with self.assertRaises(ServiceValidationError) as ctx: await _validate_service_data(self.mock_hass, {"command": "echo hi"}) diff --git a/translations/de.json b/translations/de.json index 7534879..8efe992 100644 --- a/translations/de.json +++ b/translations/de.json @@ -82,6 +82,12 @@ "known_hosts_with_check_disabled": { "message": "Bekannte Hosts wurden angegeben, obwohl die Überprüfung deaktiviert ist." }, + "invalid_port": { + "message": "Der Port muss zwischen 1 und 65535 liegen." + }, + "invalid_timeout": { + "message": "Das Timeout muss größer als null sein." + }, "integration_not_set_up": { "message": "Die SSH-Command-Integration ist nicht eingerichtet." }, @@ -98,6 +104,6 @@ "message": "Der Host ist nicht erreichbar." }, "connection_timed_out": { - "message": "Ein Timeout ist aufgetreten."} + "message": "Der SSH-Vorgang hat das Zeitlimit überschritten."} } } diff --git a/translations/en.json b/translations/en.json index d8d2f65..12995e3 100644 --- a/translations/en.json +++ b/translations/en.json @@ -82,6 +82,12 @@ "known_hosts_with_check_disabled": { "message": "Known hosts provided while check known hosts is disabled." }, + "invalid_port": { + "message": "Port must be between 1 and 65535." + }, + "invalid_timeout": { + "message": "Timeout must be greater than zero." + }, "integration_not_set_up": { "message": "SSH Command integration is not set up." }, @@ -98,7 +104,7 @@ "message": "Host is not reachable." }, "connection_timed_out": { - "message": "Connection to the host timed out." + "message": "SSH operation timed out." } } }