Skip to content

Commit d8a701f

Browse files
rjanjabrienw
authored andcommitted
Add passphrase provider support (#257)
* Refactor ssh and role options into single options list
1 parent de0a42b commit d8a701f

21 files changed

+436
-156
lines changed

.credo.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
# You can give explicit globs or simply directories.
2121
# In the latter case `**/*.{ex,exs}` will be used.
2222
included: ["config/", "lib/", "test/"],
23-
excluded: [~r"/_build/", ~r"/deps/"]
23+
excluded: [~r"/_build/", ~r"/deps/", "config/deploy/"]
2424
},
2525
#
2626
# If you create your own checks, you must specify the source files for

config/deploy/dev.exs

-1
This file was deleted.

config/deploy/production.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
use Mix.Config
1+

config/deploy/test.exs

-1
This file was deleted.

docs/public_keys.md

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Public key support
2+
3+
Bootleg supports the use of SSH identity files (keys) for passwordless connections.
4+
5+
Please note the difference in terms as used in this document:
6+
7+
* `password` refers to a password-protected user account
8+
* `passphrase` refers to a passphrase-protected private SSH key
9+
10+
## Private keys
11+
12+
In many cases, private keys are not protected by a passphrase and do not need to be unlocked before use.
13+
This is ideal for tools like Bootleg which lack a user interface.
14+
15+
When defining your roles and hosts, simply add an `identity` option pointing to your private SSH key.
16+
17+
```elixir
18+
role :app, "example.com", identity: "~/.ssh/id_rsa"
19+
```
20+
21+
The SSH private key will be used in remote builds (Git push) and for execution of remote commands.
22+
23+
## Passphrase-protected private keys
24+
25+
Private keys that are protected by a passphrase need to be unlocked before use. This is natively
26+
supported by the `ssh_client_key_api` package using Bootleg's `passphrase` or `passphrase_provider` options.
27+
28+
However, the remote build scenario uses a Git push, which as an external process **does not** work seamlessly
29+
with the aforementioned Bootleg options. See "[Remote builds](#remote-builds)" below for solutions.
30+
31+
### Options for protected private keys
32+
33+
#### `passphrase`
34+
35+
When configuring your role, set the `passphrase` option to the string that unlocks your private key.
36+
37+
#### `passphrase_provider`
38+
39+
Instead of setting a `passphrase`, you may set `passphrase_provider` to something that returns the string to unlock your private key. When using a provider, the returned value is then set as the `passphrase` option at time of `SSH.init/3`.
40+
41+
##### Anonymous function
42+
43+
```elixir
44+
role(:app, "example.com", identity: "protected_id_rsa", passphrase_provider: fn -> "foobar" end)
45+
```
46+
47+
##### Module and function reference
48+
49+
```elixir
50+
defmodule Test.Foo do
51+
def bar do
52+
"foobar"
53+
end
54+
end
55+
role(:app, "example.com", identity: "protected_id_rsa", passphrase_provider: {Test.Foo, :bar})
56+
```
57+
58+
##### System command and arguments
59+
60+
```elixir
61+
role(:app, "example.com", identity: "protected_id_rsa", passphrase_provider: {"/bin/echo", ["foobar"]})
62+
```
63+
64+
### Local builds
65+
66+
When your build server is the same machine you're running Bootleg on, you may define the passphrase alongside the identity.
67+
68+
```elixir
69+
role :app, "example.com", identity: "~/.ssh/protected_id_rsa", passphrase: "secretsauce"
70+
```
71+
72+
### Remote builds
73+
74+
When your build server is another machine, the build process will attempt to do a Git push to it.
75+
This requires that you unlock your private key in one of two ways:
76+
77+
#### Using the `insecure_agent` Bootleg role option (preferred)
78+
79+
To use this option, set a passphrase options above, but also set `insecure_agent` on the role.
80+
81+
During the build process, the passphrase will be temporarily written to the filesystem in order
82+
to unlock the key using `ssh-add`. This file is then removed immediately after the Git push operation.
83+
84+
```elixir
85+
role :build, "example.com", identity: "~/.ssh/protected_id_rsa", passphrase: "secretsauce", insecure_agent: true
86+
```
87+
88+
#### Using `ssh-agent` (external to Bootleg)
89+
90+
With ssh-agent, the Git push command will succeed but a passphrase is still needed for Bootleg to use your private key during execution of remote commands.
91+
92+
```elixir
93+
role :build, "example.com", identity: "~/.ssh/protected_id_rsa", passphrase: "secretsauce"
94+
```
95+
96+
Here you would run `$ ssh-add ~/.ssh/protected_id_rsa` before invoking Bootleg to provide the passphrase that unlocks your private key. After specifying the correct passphrase your key is added to the SSH Agent and Git push operations will succeed as expected.
97+
98+
Then run Bootleg as you would.

lib/bootleg/dsl.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ defmodule Bootleg.DSL do
4242
4343
`options` is an optional `Keyword` used to provide configuration details about a specific host
4444
(or collection of hosts). Certain options are passed to SSH directly (see
45-
`Bootleg.SSH.supported_options/0`), others are used internally (`user` for example, is used
45+
`Bootleg.SSH.ssh_options/0`), others are used internally (`user` for example, is used
4646
by both SSH and Git), and unknown options are simply stored. In the future `remote/1,2` will
4747
allow for host filtering based on role options. Some Bootleg extensions may also add support
4848
for additional options.

lib/bootleg/host.ex

+4-14
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ defmodule Bootleg.Host do
33
@enforce_keys [:host, :options]
44
defstruct [:host, :options]
55

6-
def init(host, ssh_options, role_options) do
6+
def init(host, options) do
77
%__MODULE__{
8-
host: SSHKit.host(host, ssh_options),
9-
options: role_options
8+
host: SSHKit.host(host),
9+
options: options
1010
}
1111
end
1212

@@ -22,20 +22,12 @@ defmodule Bootleg.Host do
2222
put_in(host, [Access.key!(:options), option], value)
2323
end
2424

25-
def ssh_option(%__MODULE__{} = host, option) when is_atom(option) do
26-
get_in(host, [Access.key!(:host), Access.key!(:options), option])
27-
end
28-
29-
def ssh_option(%__MODULE__{} = host, option, value) when is_atom(option) do
30-
put_in(host, [Access.key!(:host), Access.key!(:options), option], value)
31-
end
32-
3325
def combine_uniq(hosts) do
3426
do_combine_uniq(hosts, %{}, &host_id/1, [])
3527
end
3628

3729
defp host_id(host) do
38-
{host_name(host), ssh_option(host, :port)}
30+
{host_name(host), option(host, :port)}
3931
end
4032

4133
defp do_combine_uniq([h | t], set, fun, acc) do
@@ -60,11 +52,9 @@ defmodule Bootleg.Host do
6052
end
6153

6254
defp combine_host_options(host1, host2) do
63-
ssh_options = Keyword.merge(host1.host.options, host2.host.options)
6455
host_options = Keyword.merge(host1.options, host2.options)
6556

6657
host1
67-
|> put_in([Access.key!(:host), Access.key!(:options)], ssh_options)
6858
|> put_in([Access.key!(:options)], host_options)
6959
end
7060
end

lib/bootleg/role.ex

+9-24
Original file line numberDiff line numberDiff line change
@@ -3,44 +3,25 @@ defmodule Bootleg.Role do
33
@enforce_keys [:name, :hosts, :user]
44
defstruct [:name, :hosts, :user, options: []]
55

6-
alias Bootleg.{Host, SSH}
6+
alias Bootleg.Host
77

88
def combine_hosts(%Bootleg.Role{} = role, hosts) do
99
%Bootleg.Role{role | hosts: Host.combine_uniq(role.hosts ++ hosts)}
1010
end
1111

1212
def define(name, hosts, options \\ []) do
13-
# user is in the role options for scm
14-
15-
user = Keyword.get(options, :user, System.get_env("USER"))
16-
17-
ssh_options =
18-
Enum.filter(options, &(Enum.member?(SSH.supported_options(), elem(&1, 0)) == true))
19-
20-
# identity needs to be present in both options lists
21-
role_options =
22-
(options -- ssh_options)
23-
|> Keyword.put(:user, user)
24-
|> Keyword.put(:identity, ssh_options[:identity])
25-
|> Keyword.get_and_update(:identity, fn val ->
26-
if val || Keyword.has_key?(ssh_options, :identity) do
27-
{val, val || ssh_options[:identity]}
28-
else
29-
:pop
30-
end
31-
end)
32-
|> elem(1)
13+
opts = Keyword.merge(default_options(), options)
3314

3415
hosts =
3516
hosts
3617
|> List.wrap()
37-
|> Enum.map(&Host.init(&1, ssh_options, role_options))
18+
|> Enum.map(&Host.init(&1, opts))
3819

3920
new_role = %Bootleg.Role{
4021
name: name,
41-
user: user,
22+
user: opts[:user],
4223
hosts: [],
43-
options: role_options
24+
options: opts
4425
}
4526

4627
role =
@@ -56,6 +37,10 @@ defmodule Bootleg.Role do
5637
)
5738
end
5839

40+
def default_options do
41+
[user: System.get_env("USER")]
42+
end
43+
5944
@doc false
6045
@spec split_roles_and_filters(atom | keyword) :: {[atom], keyword}
6146
def split_roles_and_filters(role) do

0 commit comments

Comments
 (0)