Skip to content

Commit e7c25f6

Browse files
Pedro Piñera Buendíaclaude
authored andcommitted
Add :key and :key_path SSH auth options with custom key callback
Support passing a private key directly as a PEM string (:key) or as a file path (:key_path), in addition to the existing :user_dir and :password options. Uses a custom ssh_client_key_api callback module to decode and provide keys to Erlang's :ssh. Also add SSH provider example to README. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2935577 commit e7c25f6

File tree

3 files changed

+94
-9
lines changed

3 files changed

+94
-9
lines changed

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,21 @@ config :terrarium,
4747
]
4848
```
4949

50-
For development, you can use the built-in local provider:
50+
Connect to an existing machine via SSH:
51+
52+
```elixir
53+
config :terrarium,
54+
default: :server,
55+
providers: [
56+
server: {Terrarium.Providers.SSH,
57+
host: "dev.example.com",
58+
user: "deploy",
59+
key: System.fetch_env!("SSH_PRIVATE_KEY")
60+
}
61+
]
62+
```
63+
64+
For development, use the built-in local provider:
5165

5266
```elixir
5367
# config/dev.exs

lib/terrarium/providers/ssh.ex

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,29 @@ defmodule Terrarium.Providers.SSH do
2525
- `:port` — SSH port (default: `22`)
2626
- `:password` — password for authentication
2727
- `:user_dir` — directory containing SSH keys (default: `~/.ssh`)
28+
- `:key` — private key as a PEM string (e.g., from an env var)
29+
- `:key_path` — path to a specific private key file
2830
- `:connect_timeout` — connection timeout in milliseconds (default: `10_000`)
2931
- `:cwd` — default working directory on the remote host (default: `"/"`)
3032
3133
## Authentication
3234
33-
Supports password and key-based authentication. For key-based auth, place
34-
your keys (`id_rsa`, `id_ed25519`, etc.) in the `:user_dir` directory.
35-
The `:ssh` module discovers them automatically.
35+
Supports three authentication methods:
36+
37+
- **Password** — `:password` option
38+
- **Key directory** — `:user_dir` option, auto-discovers keys in the directory
39+
- **Specific key** — `:key` (PEM string) or `:key_path` (file path)
40+
41+
### Examples
42+
43+
# Password auth
44+
{Terrarium.Providers.SSH, host: "example.com", user: "deploy", password: "secret"}
45+
46+
# Key from a file
47+
{Terrarium.Providers.SSH, host: "example.com", user: "deploy", key_path: "~/.ssh/id_ed25519"}
48+
49+
# Key from an environment variable
50+
{Terrarium.Providers.SSH, host: "example.com", user: "deploy", key: System.fetch_env!("SSH_PRIVATE_KEY")}
3651
"""
3752

3853
use Terrarium.Provider
@@ -56,7 +71,7 @@ defmodule Terrarium.Providers.SSH do
5671
user_interaction: false
5772
]
5873
|> maybe_add(:password, opts)
59-
|> maybe_add(:user_dir, opts)
74+
|> maybe_add_key_opts(opts)
6075

6176
case :ssh.connect(to_charlist(host), port, ssh_opts, connect_timeout) do
6277
{:ok, conn} ->
@@ -229,10 +244,19 @@ defmodule Terrarium.Providers.SSH do
229244
end
230245
end
231246

232-
defp maybe_add(ssh_opts, :user_dir, opts) do
233-
case Keyword.fetch(opts, :user_dir) do
234-
{:ok, dir} -> Keyword.put(ssh_opts, :user_dir, to_charlist(Path.expand(dir)))
235-
:error -> ssh_opts
247+
defp maybe_add_key_opts(ssh_opts, opts) do
248+
cond do
249+
Keyword.has_key?(opts, :key) ->
250+
Keyword.put(ssh_opts, :key_cb, {Terrarium.Providers.SSH.KeyCb, key: opts[:key]})
251+
252+
Keyword.has_key?(opts, :key_path) ->
253+
Keyword.put(ssh_opts, :key_cb, {Terrarium.Providers.SSH.KeyCb, key_path: opts[:key_path]})
254+
255+
Keyword.has_key?(opts, :user_dir) ->
256+
Keyword.put(ssh_opts, :user_dir, to_charlist(Path.expand(opts[:user_dir])))
257+
258+
true ->
259+
ssh_opts
236260
end
237261
end
238262
end
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
defmodule Terrarium.Providers.SSH.KeyCb do
2+
@moduledoc false
3+
4+
# Custom SSH client key callback that accepts a private key as a PEM string
5+
# or a file path, passed via key_cb_private.
6+
7+
@behaviour :ssh_client_key_api
8+
9+
@impl true
10+
def is_host_key(_key, _host, _algorithm, _options), do: true
11+
12+
@impl true
13+
def add_host_key(_host, _public_key, _options), do: :ok
14+
15+
@impl true
16+
def user_key(_algorithm, options) do
17+
key_cb_private = options[:key_cb_private] || []
18+
19+
cond do
20+
pem = key_cb_private[:key] ->
21+
decode_pem(pem)
22+
23+
path = key_cb_private[:key_path] ->
24+
case File.read(Path.expand(path)) do
25+
{:ok, pem} -> decode_pem(pem)
26+
{:error, reason} -> {:error, reason}
27+
end
28+
29+
true ->
30+
{:error, :no_key_provided}
31+
end
32+
end
33+
34+
defp decode_pem(pem) do
35+
case :public_key.pem_decode(pem) do
36+
[entry | _] ->
37+
{:ok, :public_key.pem_entry_decode(entry)}
38+
39+
[] ->
40+
# OpenSSH format (ed25519, etc.) — OTP 25+
41+
case :ssh_file.decode(pem, :openssh_key_v1) do
42+
[{private_key, _attrs} | _] -> {:ok, private_key}
43+
_ -> {:error, :pem_decode_failed}
44+
end
45+
end
46+
end
47+
end

0 commit comments

Comments
 (0)