Skip to content

feat: support cloning over SSH via private key auth #170

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from 16 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ On MacOS or Windows systems, we recommend either using a VM or the provided `.de
| `--git-clone-single-branch` | `GIT_CLONE_SINGLE_BRANCH` | | Clone only a single branch of the Git repository. |
| `--git-username` | `GIT_USERNAME` | | The username to use for Git authentication. This is optional. |
| `--git-password` | `GIT_PASSWORD` | | The password to use for Git authentication. This is optional. |
| `--git-ssh-private-key-path` | `GIT_SSH_PRIVATE_KEY_PATH` | | Path to an SSH private key to be used for Git authentication. |
| `--git-http-proxy-url` | `GIT_HTTP_PROXY_URL` | | The URL for the HTTP proxy. This is optional. |
| `--workspace-folder` | `WORKSPACE_FOLDER` | | The path to the workspace folder that will be built. This is optional. |
| `--ssl-cert-base64` | `SSL_CERT_BASE64` | | The content of an SSL cert file. This is useful for self-signed certificates. |
Expand Down
10 changes: 1 addition & 9 deletions envbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import (
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5/plumbing/transport"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/sirupsen/logrus"
Expand Down Expand Up @@ -194,14 +193,7 @@ func Run(ctx context.Context, options Options) error {
CABundle: caBundle,
}

if options.GitUsername != "" || options.GitPassword != "" {
// NOTE: we previously inserted the credentials into the repo URL.
// This was removed in https://github.com/coder/envbuilder/pull/141
cloneOpts.RepoAuth = &githttp.BasicAuth{
Username: options.GitUsername,
Password: options.GitPassword,
}
}
cloneOpts.RepoAuth = SetupRepoAuth(&options)
if options.GitHTTPProxyURL != "" {
cloneOpts.ProxyOptions = transport.ProxyOptions{
URL: options.GitHTTPProxyURL,
Expand Down
138 changes: 138 additions & 0 deletions git.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,26 @@ import (
"context"
"errors"
"fmt"
"io"
"net"
"net/url"
"os"
"strings"

"github.com/coder/coder/v2/codersdk"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/plumbing/protocol/packp/capability"
"github.com/go-git/go-git/v5/plumbing/protocol/packp/sideband"
"github.com/go-git/go-git/v5/plumbing/transport"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/go-git/go-git/v5/storage/filesystem"
"github.com/skeema/knownhosts"
"golang.org/x/crypto/ssh"
gossh "golang.org/x/crypto/ssh"
)

type CloneRepoOptions struct {
Expand Down Expand Up @@ -113,3 +123,131 @@ func CloneRepo(ctx context.Context, opts CloneRepoOptions) (bool, error) {
}
return true, nil
}

// ReadPrivateKey attempts to read an SSH private key from path
// and returns an ssh.Signer.
func ReadPrivateKey(path string) (gossh.Signer, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open private key file: %w", err)
}
bs, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("read private key file: %w", err)
}
k, err := gossh.ParsePrivateKey(bs)
if err != nil {
return nil, fmt.Errorf("parse private key file: %w", err)
}
return k, nil
}

// LogHostKeyCallback is a HostKeyCallback that just logs host keys
// and does nothing else.
func LogHostKeyCallback(log LoggerFunc) gossh.HostKeyCallback {
return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
var sb strings.Builder
_ = knownhosts.WriteKnownHost(&sb, hostname, remote, key)
// skeema/knownhosts uses a fake public key to determine the host key
// algorithms. Ignore this one.
if s := sb.String(); !strings.Contains(s, "fake-public-key ZmFrZSBwdWJsaWMga2V5") {
log(codersdk.LogLevelInfo, "#1: 🔑 Got host key: %s", strings.TrimSpace(s))
}
return nil
}
}

// SetupRepoAuth determines the desired AuthMethod based on options.GitURL:
//
// | Git URL format | GIT_USERNAME | GIT_PASSWORD | Auth Method |
// | ------------------------|--------------|--------------|-------------|
// | https?://host.tld/repo | Not Set | Not Set | None |
// | https?://host.tld/repo | Not Set | Set | HTTP Basic |
// | https?://host.tld/repo | Set | Not Set | HTTP Basic |
// | https?://host.tld/repo | Set | Set | HTTP Basic |
// | All other formats | - | - | SSH |
//
// For SSH authentication, the default username is "git" but will honour
// GIT_USERNAME if set.
//
// If SSH_PRIVATE_KEY_PATH is set, an SSH private key will be read from
// that path and the SSH auth method will be configured with that key.
//
// If SSH_KNOWN_HOSTS is not set, the SSH auth method will be configured
// to accept and log all host keys. Otherwise, host key checking will be
// performed as usual.
func SetupRepoAuth(options *Options) transport.AuthMethod {
if options.GitURL == "" {
options.Logger(codersdk.LogLevelInfo, "#1: ❔ No Git URL supplied!")
return nil
}
if strings.HasPrefix(options.GitURL, "http://") || strings.HasPrefix(options.GitURL, "https://") {
// Special case: no auth
if options.GitUsername == "" && options.GitPassword == "" {
options.Logger(codersdk.LogLevelInfo, "#1: 👤 Using no authentication!")
return nil
}
// Basic Auth
// NOTE: we previously inserted the credentials into the repo URL.
// This was removed in https://github.com/coder/envbuilder/pull/141
options.Logger(codersdk.LogLevelInfo, "#1: 🔒 Using HTTP basic authentication!")
return &githttp.BasicAuth{
Username: options.GitUsername,
Password: options.GitPassword,
}
}

// Generally git clones over SSH use the 'git' user, but respect
// GIT_USERNAME if set.
authUser := options.GitUsername
if authUser == "" {
authUser = "git"
}

// Assume SSH auth for all other formats.
options.Logger(codersdk.LogLevelInfo, "#1: 🔑 Using SSH authentication!")

var signer ssh.Signer
if options.GitSSHPrivateKeyPath != "" {
s, err := ReadPrivateKey(options.GitSSHPrivateKeyPath)
if err != nil {
options.Logger(codersdk.LogLevelError, "#1: ❌ Failed to read private key from %s: %s", options.GitSSHPrivateKeyPath, err.Error())
} else {
options.Logger(codersdk.LogLevelInfo, "#1: 🔑 Using %s key!", s.PublicKey().Type())
signer = s
}
}

// If no SSH key set, fall back to agent auth.
if signer == nil {
options.Logger(codersdk.LogLevelError, "#1: 🔑 No SSH key found, falling back to agent!")
auth, err := gitssh.NewSSHAgentAuth(authUser)
if err != nil {
options.Logger(codersdk.LogLevelError, "#1: ❌ Failed to connect to SSH agent: %s", err.Error())
return nil // nothing else we can do
}
if os.Getenv("SSH_KNOWN_HOSTS") == "" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: If this was moved higher up, we could avoid duplicating it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried doing this, but we need to either return *gitssh.PublicKeys or *gitssh.PublicKeysCallback. We can't just assign HostKeyCallback to transport.AuthMethods as it's an interface type :(

options.Logger(codersdk.LogLevelWarn, "#1: 🔓 SSH_KNOWN_HOSTS not set, accepting all host keys!")
auth.HostKeyCallback = LogHostKeyCallback(options.Logger)
}
return auth
}

auth := &gitssh.PublicKeys{
User: options.GitUsername,
Signer: signer,
}

// Generally git clones over SSH use the 'git' user, but respect
// GIT_USERNAME if set.
if auth.User == "" {
auth.User = "git"
}

// Duplicated code due to Go's type system.
if os.Getenv("SSH_KNOWN_HOSTS") == "" {
options.Logger(codersdk.LogLevelWarn, "#1: 🔓 SSH_KNOWN_HOSTS not set, accepting all host keys!")
auth.HostKeyCallback = LogHostKeyCallback(options.Logger)
}
return auth
}
Loading
Loading