Skip to content
Draft
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
47 changes: 35 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

---

Expand All @@ -39,18 +41,18 @@ 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)
- `passphrase` — Passphrase for an encrypted private key file (only valid when `key_file` is used)
- `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

Expand All @@ -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

Expand All @@ -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!
Expand Down
16 changes: 16 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 31 additions & 4 deletions coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
KeyEncryptionError,
KeyImportError,
PermissionDenied,
SSHKnownHosts,
connect,
read_known_hosts,
DEFAULT_PORT,
Expand Down Expand Up @@ -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
Expand All @@ -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
9 changes: 6 additions & 3 deletions services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -74,3 +76,4 @@ execute:
default: 30
selector:
number:
min: 1
8 changes: 7 additions & 1 deletion strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand All @@ -98,7 +104,7 @@
"message": "Host is not reachable."
},
"connection_timed_out": {
"message": "Connection to the host timed out."
"message": "SSH operation timed out."
}
}
}
17 changes: 16 additions & 1 deletion tests/unit_tests/test_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions tests/unit_tests/test_validate_service_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
8 changes: 7 additions & 1 deletion translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand All @@ -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."}
}
}
8 changes: 7 additions & 1 deletion translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand All @@ -98,7 +104,7 @@
"message": "Host is not reachable."
},
"connection_timed_out": {
"message": "Connection to the host timed out."
"message": "SSH operation timed out."
}
}
}
Loading