Skip to content

Commit 6d76aca

Browse files
committed
feat: fetch SSH key from Coder if required
1 parent 6bc56e8 commit 6d76aca

File tree

2 files changed

+103
-3
lines changed

2 files changed

+103
-3
lines changed

git.go

+43-3
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@ package envbuilder
22

33
import (
44
"context"
5+
"crypto/md5"
56
"errors"
67
"fmt"
78
"io"
89
"net"
910
"net/url"
1011
"os"
1112
"strings"
13+
"time"
1214

1315
"github.com/coder/coder/v2/codersdk"
16+
"github.com/coder/coder/v2/codersdk/agentsdk"
1417
"github.com/go-git/go-billy/v5"
1518
"github.com/go-git/go-git/v5"
1619
"github.com/go-git/go-git/v5/plumbing"
@@ -22,7 +25,6 @@ import (
2225
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
2326
"github.com/go-git/go-git/v5/storage/filesystem"
2427
"github.com/skeema/knownhosts"
25-
"golang.org/x/crypto/ssh"
2628
gossh "golang.org/x/crypto/ssh"
2729
)
2830

@@ -207,14 +209,29 @@ func SetupRepoAuth(options *Options) transport.AuthMethod {
207209
// Assume SSH auth for all other formats.
208210
options.Logger(codersdk.LogLevelInfo, "#1: 🔑 Using SSH authentication!")
209211

210-
var signer ssh.Signer
212+
var signer gossh.Signer
211213
if options.GitSSHPrivateKeyPath != "" {
212214
s, err := ReadPrivateKey(options.GitSSHPrivateKeyPath)
213215
if err != nil {
214216
options.Logger(codersdk.LogLevelError, "#1: ❌ Failed to read private key from %s: %s", options.GitSSHPrivateKeyPath, err.Error())
215217
} else {
216-
options.Logger(codersdk.LogLevelInfo, "#1: 🔑 Using %s key!", s.PublicKey().Type())
217218
signer = s
219+
options.Logger(codersdk.LogLevelInfo, "#1: 🔑 Using %s key %s!", s.PublicKey().Type(), keyFingerprint(signer)[:8])
220+
}
221+
}
222+
223+
// If we have no signer but we have a Coder URL and agent token, try to fetch
224+
// an SSH key from Coder!
225+
if signer == nil && options.CoderAgentURL != nil && options.CoderAgentToken != "" {
226+
options.Logger(codersdk.LogLevelInfo, "#1: 🔑 Fetching key from %s!", options.CoderAgentURL.String())
227+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
228+
defer cancel()
229+
s, err := FetchCoderSSHKey(ctx, options.CoderAgentURL, options.CoderAgentToken)
230+
if err == nil {
231+
signer = s
232+
options.Logger(codersdk.LogLevelInfo, "#1: 🔑 Fetched %s key %s !", signer.PublicKey().Type(), keyFingerprint(signer)[:8])
233+
} else {
234+
options.Logger(codersdk.LogLevelInfo, "#1: ❌ Failed to fetch SSH key: %w", options.CoderAgentURL.String(), err)
218235
}
219236
}
220237

@@ -251,3 +268,26 @@ func SetupRepoAuth(options *Options) transport.AuthMethod {
251268
}
252269
return auth
253270
}
271+
272+
// FetchCoderSSHKey fetches the user's Git SSH key from Coder using the supplied
273+
// Coder URL and agent token.
274+
func FetchCoderSSHKey(ctx context.Context, coderURL url.URL, agentToken string) (gossh.Signer, error) {
275+
client := agentsdk.New(&coderURL)
276+
client.SetSessionToken(agentToken)
277+
key, err := client.GitSSHKey(ctx)
278+
if err != nil {
279+
return nil, fmt.Errorf("get coder ssh key: %w", err)
280+
}
281+
signer, err := gossh.ParsePrivateKey([]byte(key.PrivateKey))
282+
if err != nil {
283+
return nil, fmt.Errorf("parse coder ssh key: %w", err)
284+
}
285+
return signer, nil
286+
}
287+
288+
// keyFingerprint returns the md5 checksum of the public key of signer.
289+
func keyFingerprint(s gossh.Signer) string {
290+
h := md5.New()
291+
h.Write(s.PublicKey().Marshal())
292+
return fmt.Sprintf("%x", h.Sum(nil))
293+
}

git_test.go

+60
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package envbuilder_test
33
import (
44
"context"
55
"crypto/ed25519"
6+
"encoding/json"
67
"fmt"
78
"io"
9+
"net/http"
810
"net/http/httptest"
911
"net/url"
1012
"os"
@@ -13,13 +15,16 @@ import (
1315
"testing"
1416

1517
"github.com/coder/coder/v2/codersdk"
18+
"github.com/coder/coder/v2/codersdk/agentsdk"
1619
"github.com/coder/envbuilder"
1720
"github.com/coder/envbuilder/testutil/gittest"
1821
"github.com/go-git/go-billy/v5"
1922
"github.com/go-git/go-billy/v5/memfs"
2023
"github.com/go-git/go-billy/v5/osfs"
2124
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
2225
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
26+
"github.com/google/uuid"
27+
"github.com/stretchr/testify/assert"
2328
"github.com/stretchr/testify/require"
2429
gossh "golang.org/x/crypto/ssh"
2530
)
@@ -382,6 +387,60 @@ func TestSetupRepoAuth(t *testing.T) {
382387
auth := envbuilder.SetupRepoAuth(opts)
383388
require.Nil(t, auth) // TODO: actually test SSH_AUTH_SOCK
384389
})
390+
391+
t.Run("SSH/Coder", func(t *testing.T) {
392+
token := uuid.NewString()
393+
actualSigner, err := gossh.ParsePrivateKey([]byte(testKey))
394+
require.NoError(t, err)
395+
handler := func(w http.ResponseWriter, r *http.Request) {
396+
hdr := r.Header.Get("Coder-Session-Token")
397+
if !assert.Equal(t, hdr, token) {
398+
w.WriteHeader(http.StatusForbidden)
399+
return
400+
}
401+
switch r.URL.Path {
402+
case "/api/v2/workspaceagents/me/gitsshkey":
403+
_ = json.NewEncoder(w).Encode(&agentsdk.GitSSHKey{
404+
PublicKey: string(actualSigner.PublicKey().Marshal()),
405+
PrivateKey: string(testKey),
406+
})
407+
default:
408+
assert.Fail(t, "unknown path: %q", r.URL.Path)
409+
}
410+
}
411+
srv := httptest.NewServer(http.HandlerFunc(handler))
412+
u, err := url.Parse(srv.URL)
413+
require.NoError(t, err)
414+
opts := &envbuilder.Options{
415+
CoderAgentURL: u,
416+
CoderAgentToken: token,
417+
GitURL: "ssh://[email protected]:repo/path",
418+
Logger: testLog(t),
419+
}
420+
auth := envbuilder.SetupRepoAuth(opts)
421+
pk, ok := auth.(*gitssh.PublicKeys)
422+
require.True(t, ok)
423+
require.NotNil(t, pk.Signer)
424+
require.Equal(t, actualSigner, pk.Signer)
425+
})
426+
427+
t.Run("SSH/CoderForbidden", func(t *testing.T) {
428+
token := uuid.NewString()
429+
handler := func(w http.ResponseWriter, r *http.Request) {
430+
w.WriteHeader(http.StatusForbidden)
431+
}
432+
srv := httptest.NewServer(http.HandlerFunc(handler))
433+
u, err := url.Parse(srv.URL)
434+
require.NoError(t, err)
435+
opts := &envbuilder.Options{
436+
CoderAgentURL: u,
437+
CoderAgentToken: token,
438+
GitURL: "ssh://[email protected]:repo/path",
439+
Logger: testLog(t),
440+
}
441+
auth := envbuilder.SetupRepoAuth(opts)
442+
require.Nil(t, auth)
443+
})
385444
}
386445

387446
func mustRead(t *testing.T, fs billy.Filesystem, path string) string {
@@ -405,6 +464,7 @@ func randKeygen(t *testing.T) gossh.Signer {
405464

406465
func testLog(t *testing.T) envbuilder.LoggerFunc {
407466
return func(_ codersdk.LogLevel, format string, args ...interface{}) {
467+
t.Helper()
408468
t.Logf(format, args...)
409469
}
410470
}

0 commit comments

Comments
 (0)