Skip to content

Commit c08151b

Browse files
authored
feat: support cloning over SSH via private key auth (#170)
1 parent d3f71e5 commit c08151b

File tree

8 files changed

+555
-11
lines changed

8 files changed

+555
-11
lines changed

README.md

+43-1
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,12 @@ DOCKER_CONFIG_BASE64=ewoJImF1dGhzIjogewoJCSJodHRwczovL2luZGV4LmRvY2tlci5pby92MS8
136136

137137
## Git Authentication
138138

139-
`GIT_USERNAME` and `GIT_PASSWORD` are environment variables to provide Git authentication for private repositories.
139+
Two methods of authentication are supported:
140+
141+
### HTTP Authentication
142+
143+
If the `GIT_URL` supplied starts with `http://` or `https://`, envbuilder will
144+
supply HTTP basic authentication using `GIT_USERNAME` and `GIT_PASSWORD`, if set.
140145

141146
For access token-based authentication, follow the following schema (if empty, there's no need to provide the field):
142147

@@ -161,6 +166,42 @@ resource "docker_container" "dev" {
161166
}
162167
```
163168

169+
### SSH Authentication
170+
171+
If the `GIT_URL` supplied does not start with `http://` or `https://`,
172+
envbuilder will assume SSH authentication. You have the following options:
173+
174+
1. Public/Private key authentication: set `GIT_SSH_KEY_PATH` to the path of an
175+
SSH private key mounted inside the container. Envbuilder will use this SSH
176+
key to authenticate. Example:
177+
178+
```bash
179+
docker run -it --rm \
180+
-v /tmp/envbuilder:/workspaces \
181+
-e [email protected]:path/to/private/repo.git \
182+
-e GIT_SSH_KEY_PATH=/.ssh/id_rsa \
183+
-v /home/user/id_rsa:/.ssh/id_rsa \
184+
-e INIT_SCRIPT=bash \
185+
ghcr.io/coder/envbuilder
186+
```
187+
188+
1. Agent-based authentication: set `SSH_AUTH_SOCK` and mount in your agent socket, for example:
189+
190+
```bash
191+
docker run -it --rm \
192+
-v /tmp/envbuilder:/workspaces \
193+
-e [email protected]:path/to/private/repo.git \
194+
-e INIT_SCRIPT=bash \
195+
-e SSH_AUTH_SOCK=/tmp/ssh-auth-sock \
196+
-v $SSH_AUTH_SOCK:/tmp/ssh-auth-sock \
197+
ghcr.io/coder/envbuilder
198+
```
199+
200+
> Note: by default, envbuilder will accept and log all host keys. If you need
201+
> strict host key checking, set `SSH_KNOWN_HOSTS` and mount in a `known_hosts`
202+
> file.
203+
204+
164205
## Layer Caching
165206

166207
Cache layers in a container registry to speed up builds. To enable caching, [authenticate with your registry](#container-registry-authentication) and set the `CACHE_REPO` environment variable.
@@ -288,6 +329,7 @@ On MacOS or Windows systems, we recommend either using a VM or the provided `.de
288329
| `--git-clone-single-branch` | `GIT_CLONE_SINGLE_BRANCH` | | Clone only a single branch of the Git repository. |
289330
| `--git-username` | `GIT_USERNAME` | | The username to use for Git authentication. This is optional. |
290331
| `--git-password` | `GIT_PASSWORD` | | The password to use for Git authentication. This is optional. |
332+
| `--git-ssh-private-key-path` | `GIT_SSH_PRIVATE_KEY_PATH` | | Path to an SSH private key to be used for Git authentication. |
291333
| `--git-http-proxy-url` | `GIT_HTTP_PROXY_URL` | | The URL for the HTTP proxy. This is optional. |
292334
| `--workspace-folder` | `WORKSPACE_FOLDER` | | The path to the workspace folder that will be built. This is optional. |
293335
| `--ssl-cert-base64` | `SSL_CERT_BASE64` | | The content of an SSL cert file. This is useful for self-signed certificates. |

envbuilder.go

+1-9
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ import (
4545
"github.com/go-git/go-billy/v5"
4646
"github.com/go-git/go-billy/v5/osfs"
4747
"github.com/go-git/go-git/v5/plumbing/transport"
48-
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
4948
v1 "github.com/google/go-containerregistry/pkg/v1"
5049
"github.com/google/go-containerregistry/pkg/v1/remote"
5150
"github.com/sirupsen/logrus"
@@ -195,14 +194,7 @@ func Run(ctx context.Context, options Options) error {
195194
CABundle: caBundle,
196195
}
197196

198-
if options.GitUsername != "" || options.GitPassword != "" {
199-
// NOTE: we previously inserted the credentials into the repo URL.
200-
// This was removed in https://github.com/coder/envbuilder/pull/141
201-
cloneOpts.RepoAuth = &githttp.BasicAuth{
202-
Username: options.GitUsername,
203-
Password: options.GitPassword,
204-
}
205-
}
197+
cloneOpts.RepoAuth = SetupRepoAuth(&options)
206198
if options.GitHTTPProxyURL != "" {
207199
cloneOpts.ProxyOptions = transport.ProxyOptions{
208200
URL: options.GitHTTPProxyURL,

git.go

+138
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,26 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"io"
8+
"net"
79
"net/url"
10+
"os"
11+
"strings"
812

13+
"github.com/coder/coder/v2/codersdk"
914
"github.com/go-git/go-billy/v5"
1015
"github.com/go-git/go-git/v5"
1116
"github.com/go-git/go-git/v5/plumbing"
1217
"github.com/go-git/go-git/v5/plumbing/cache"
1318
"github.com/go-git/go-git/v5/plumbing/protocol/packp/capability"
1419
"github.com/go-git/go-git/v5/plumbing/protocol/packp/sideband"
1520
"github.com/go-git/go-git/v5/plumbing/transport"
21+
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
22+
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
1623
"github.com/go-git/go-git/v5/storage/filesystem"
24+
"github.com/skeema/knownhosts"
25+
"golang.org/x/crypto/ssh"
26+
gossh "golang.org/x/crypto/ssh"
1727
)
1828

1929
type CloneRepoOptions struct {
@@ -113,3 +123,131 @@ func CloneRepo(ctx context.Context, opts CloneRepoOptions) (bool, error) {
113123
}
114124
return true, nil
115125
}
126+
127+
// ReadPrivateKey attempts to read an SSH private key from path
128+
// and returns an ssh.Signer.
129+
func ReadPrivateKey(path string) (gossh.Signer, error) {
130+
f, err := os.Open(path)
131+
if err != nil {
132+
return nil, fmt.Errorf("open private key file: %w", err)
133+
}
134+
defer f.Close()
135+
bs, err := io.ReadAll(f)
136+
if err != nil {
137+
return nil, fmt.Errorf("read private key file: %w", err)
138+
}
139+
k, err := gossh.ParsePrivateKey(bs)
140+
if err != nil {
141+
return nil, fmt.Errorf("parse private key file: %w", err)
142+
}
143+
return k, nil
144+
}
145+
146+
// LogHostKeyCallback is a HostKeyCallback that just logs host keys
147+
// and does nothing else.
148+
func LogHostKeyCallback(log LoggerFunc) gossh.HostKeyCallback {
149+
return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
150+
var sb strings.Builder
151+
_ = knownhosts.WriteKnownHost(&sb, hostname, remote, key)
152+
// skeema/knownhosts uses a fake public key to determine the host key
153+
// algorithms. Ignore this one.
154+
if s := sb.String(); !strings.Contains(s, "fake-public-key ZmFrZSBwdWJsaWMga2V5") {
155+
log(codersdk.LogLevelInfo, "#1: 🔑 Got host key: %s", strings.TrimSpace(s))
156+
}
157+
return nil
158+
}
159+
}
160+
161+
// SetupRepoAuth determines the desired AuthMethod based on options.GitURL:
162+
//
163+
// | Git URL format | GIT_USERNAME | GIT_PASSWORD | Auth Method |
164+
// | ------------------------|--------------|--------------|-------------|
165+
// | https?://host.tld/repo | Not Set | Not Set | None |
166+
// | https?://host.tld/repo | Not Set | Set | HTTP Basic |
167+
// | https?://host.tld/repo | Set | Not Set | HTTP Basic |
168+
// | https?://host.tld/repo | Set | Set | HTTP Basic |
169+
// | All other formats | - | - | SSH |
170+
//
171+
// For SSH authentication, the default username is "git" but will honour
172+
// GIT_USERNAME if set.
173+
//
174+
// If SSH_PRIVATE_KEY_PATH is set, an SSH private key will be read from
175+
// that path and the SSH auth method will be configured with that key.
176+
//
177+
// If SSH_KNOWN_HOSTS is not set, the SSH auth method will be configured
178+
// to accept and log all host keys. Otherwise, host key checking will be
179+
// performed as usual.
180+
func SetupRepoAuth(options *Options) transport.AuthMethod {
181+
if options.GitURL == "" {
182+
options.Logger(codersdk.LogLevelInfo, "#1: ❔ No Git URL supplied!")
183+
return nil
184+
}
185+
if strings.HasPrefix(options.GitURL, "http://") || strings.HasPrefix(options.GitURL, "https://") {
186+
// Special case: no auth
187+
if options.GitUsername == "" && options.GitPassword == "" {
188+
options.Logger(codersdk.LogLevelInfo, "#1: 👤 Using no authentication!")
189+
return nil
190+
}
191+
// Basic Auth
192+
// NOTE: we previously inserted the credentials into the repo URL.
193+
// This was removed in https://github.com/coder/envbuilder/pull/141
194+
options.Logger(codersdk.LogLevelInfo, "#1: 🔒 Using HTTP basic authentication!")
195+
return &githttp.BasicAuth{
196+
Username: options.GitUsername,
197+
Password: options.GitPassword,
198+
}
199+
}
200+
201+
// Generally git clones over SSH use the 'git' user, but respect
202+
// GIT_USERNAME if set.
203+
if options.GitUsername == "" {
204+
options.GitUsername = "git"
205+
}
206+
207+
// Assume SSH auth for all other formats.
208+
options.Logger(codersdk.LogLevelInfo, "#1: 🔑 Using SSH authentication!")
209+
210+
var signer ssh.Signer
211+
if options.GitSSHPrivateKeyPath != "" {
212+
s, err := ReadPrivateKey(options.GitSSHPrivateKeyPath)
213+
if err != nil {
214+
options.Logger(codersdk.LogLevelError, "#1: ❌ Failed to read private key from %s: %s", options.GitSSHPrivateKeyPath, err.Error())
215+
} else {
216+
options.Logger(codersdk.LogLevelInfo, "#1: 🔑 Using %s key!", s.PublicKey().Type())
217+
signer = s
218+
}
219+
}
220+
221+
// If no SSH key set, fall back to agent auth.
222+
if signer == nil {
223+
options.Logger(codersdk.LogLevelError, "#1: 🔑 No SSH key found, falling back to agent!")
224+
auth, err := gitssh.NewSSHAgentAuth(options.GitUsername)
225+
if err != nil {
226+
options.Logger(codersdk.LogLevelError, "#1: ❌ Failed to connect to SSH agent: %s", err.Error())
227+
return nil // nothing else we can do
228+
}
229+
if os.Getenv("SSH_KNOWN_HOSTS") == "" {
230+
options.Logger(codersdk.LogLevelWarn, "#1: 🔓 SSH_KNOWN_HOSTS not set, accepting all host keys!")
231+
auth.HostKeyCallback = LogHostKeyCallback(options.Logger)
232+
}
233+
return auth
234+
}
235+
236+
auth := &gitssh.PublicKeys{
237+
User: options.GitUsername,
238+
Signer: signer,
239+
}
240+
241+
// Generally git clones over SSH use the 'git' user, but respect
242+
// GIT_USERNAME if set.
243+
if auth.User == "" {
244+
auth.User = "git"
245+
}
246+
247+
// Duplicated code due to Go's type system.
248+
if os.Getenv("SSH_KNOWN_HOSTS") == "" {
249+
options.Logger(codersdk.LogLevelWarn, "#1: 🔓 SSH_KNOWN_HOSTS not set, accepting all host keys!")
250+
auth.HostKeyCallback = LogHostKeyCallback(options.Logger)
251+
}
252+
return auth
253+
}

0 commit comments

Comments
 (0)