Merge pull request #7270 from kousikmitra/feature/ssh-signing-key
Add option to add ssh singing key
This commit is contained in:
commit
fe5b142233
6 changed files with 309 additions and 32 deletions
|
|
@ -7,6 +7,7 @@ import (
|
|||
"os"
|
||||
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/pkg/cmd/ssh-key/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -19,6 +20,7 @@ type AddOptions struct {
|
|||
|
||||
KeyFile string
|
||||
Title string
|
||||
Type string
|
||||
}
|
||||
|
||||
func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command {
|
||||
|
|
@ -49,6 +51,8 @@ func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command
|
|||
},
|
||||
}
|
||||
|
||||
typeEnums := []string{shared.AuthenticationKey, shared.SigningKey}
|
||||
cmdutil.StringEnumFlag(cmd, &opts.Type, "type", "", shared.AuthenticationKey, typeEnums, "Type of the ssh key")
|
||||
cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Title for the new key")
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -79,7 +83,14 @@ func runAdd(opts *AddOptions) error {
|
|||
|
||||
hostname, _ := cfg.Authentication().DefaultHost()
|
||||
|
||||
uploaded, err := SSHKeyUpload(httpClient, hostname, keyReader, opts.Title)
|
||||
var uploaded bool
|
||||
|
||||
if opts.Type == shared.SigningKey {
|
||||
uploaded, err = SSHSigningKeyUpload(httpClient, hostname, keyReader, opts.Title)
|
||||
} else {
|
||||
uploaded, err = SSHKeyUpload(httpClient, hostname, keyReader, opts.Title)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,25 @@ func Test_runAdd(t *testing.T) {
|
|||
wantErrMsg: "",
|
||||
opts: AddOptions{KeyFile: "-"},
|
||||
},
|
||||
{
|
||||
name: "valid signing key format, not already in use",
|
||||
stdin: "ssh-ed25519 asdf",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StringResponse("[]"))
|
||||
reg.Register(
|
||||
httpmock.REST("POST", "user/ssh_signing_keys"),
|
||||
httpmock.RESTPayload(200, ``, func(payload map[string]interface{}) {
|
||||
assert.Contains(t, payload, "key")
|
||||
assert.Empty(t, payload["title"])
|
||||
}))
|
||||
},
|
||||
wantStdout: "",
|
||||
wantStderr: "✓ Public key added to your account\n",
|
||||
wantErrMsg: "",
|
||||
opts: AddOptions{KeyFile: "-", Type: "signing"},
|
||||
},
|
||||
{
|
||||
name: "valid key format, already in use",
|
||||
stdin: "ssh-ed25519 asdf title",
|
||||
|
|
@ -58,6 +77,25 @@ func Test_runAdd(t *testing.T) {
|
|||
wantErrMsg: "",
|
||||
opts: AddOptions{KeyFile: "-"},
|
||||
},
|
||||
{
|
||||
name: "valid signing key format, already in use",
|
||||
stdin: "ssh-ed25519 asdf title",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StringResponse(`[
|
||||
{
|
||||
"id": 1,
|
||||
"key": "ssh-ed25519 asdf",
|
||||
"title": "anything"
|
||||
}
|
||||
]`))
|
||||
},
|
||||
wantStdout: "",
|
||||
wantStderr: "✓ Public key already exists on your account\n",
|
||||
wantErrMsg: "",
|
||||
opts: AddOptions{KeyFile: "-", Type: "signing"},
|
||||
},
|
||||
{
|
||||
name: "invalid key format",
|
||||
stdin: "ssh-ed25519",
|
||||
|
|
@ -66,6 +104,14 @@ func Test_runAdd(t *testing.T) {
|
|||
wantErrMsg: "provided key is not in a valid format",
|
||||
opts: AddOptions{KeyFile: "-"},
|
||||
},
|
||||
{
|
||||
name: "invalid signing key format",
|
||||
stdin: "ssh-ed25519",
|
||||
wantStdout: "",
|
||||
wantStderr: "",
|
||||
wantErrMsg: "provided key is not in a valid format",
|
||||
opts: AddOptions{KeyFile: "-", Type: "signing"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
|
|
@ -46,30 +46,82 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t
|
|||
"key": fullUserKey,
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
err = keyUpload(httpClient, url, payload)
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode > 299 {
|
||||
return false, api.HandleHTTPError(resp)
|
||||
}
|
||||
|
||||
_, err = io.Copy(io.Discard, resp.Body)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Uploads the provided SSH Signing key. Returns true if the key was uploaded, false if it was not.
|
||||
func SSHSigningKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) (bool, error) {
|
||||
url := ghinstance.RESTPrefix(hostname) + "user/ssh_signing_keys"
|
||||
|
||||
keyBytes, err := io.ReadAll(keyFile)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
fullUserKey := string(keyBytes)
|
||||
splitKey := strings.Fields(fullUserKey)
|
||||
if len(splitKey) < 2 {
|
||||
return false, errors.New("provided key is not in a valid format")
|
||||
}
|
||||
|
||||
keyToCompare := splitKey[0] + " " + splitKey[1]
|
||||
|
||||
keys, err := shared.UserSigningKeys(httpClient, hostname, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, k := range keys {
|
||||
if k.Key == keyToCompare {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]string{
|
||||
"title": title,
|
||||
"key": fullUserKey,
|
||||
}
|
||||
|
||||
err = keyUpload(httpClient, url, payload)
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func keyUpload(httpClient *http.Client, url string, payload map[string]string) error {
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode > 299 {
|
||||
return api.HandleHTTPError(resp)
|
||||
}
|
||||
|
||||
_, err = io.Copy(io.Discard, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/cmd/ssh-key/shared"
|
||||
|
|
@ -55,12 +59,22 @@ func listRun(opts *ListOptions) error {
|
|||
}
|
||||
|
||||
host, _ := cfg.Authentication().DefaultHost()
|
||||
|
||||
sshKeys, err := shared.UserKeys(apiClient, host, "")
|
||||
if err != nil {
|
||||
return err
|
||||
sshAuthKeys, authKeyErr := shared.UserKeys(apiClient, host, "")
|
||||
if authKeyErr != nil {
|
||||
printError(opts.IO.ErrOut, authKeyErr)
|
||||
}
|
||||
|
||||
sshSigningKeys, signKeyErr := shared.UserSigningKeys(apiClient, host, "")
|
||||
if signKeyErr != nil {
|
||||
printError(opts.IO.ErrOut, signKeyErr)
|
||||
}
|
||||
|
||||
if authKeyErr != nil && signKeyErr != nil {
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
|
||||
sshKeys := append(sshAuthKeys, sshSigningKeys...)
|
||||
|
||||
if len(sshKeys) == 0 {
|
||||
return cmdutil.NewNoResultsError("no SSH keys present in the GitHub account")
|
||||
}
|
||||
|
|
@ -74,6 +88,7 @@ func listRun(opts *ListOptions) error {
|
|||
t.AddField("TITLE", nil, nil)
|
||||
t.AddField("ID", nil, nil)
|
||||
t.AddField("KEY", nil, nil)
|
||||
t.AddField("TYPE", nil, nil)
|
||||
t.AddField("ADDED", nil, nil)
|
||||
t.EndRow()
|
||||
}
|
||||
|
|
@ -86,12 +101,14 @@ func listRun(opts *ListOptions) error {
|
|||
t.AddField(sshKey.Title, nil, nil)
|
||||
t.AddField(id, nil, nil)
|
||||
t.AddField(sshKey.Key, truncateMiddle, nil)
|
||||
t.AddField(sshKey.Type, nil, nil)
|
||||
t.AddField(text.FuzzyAgoAbbr(now, sshKey.CreatedAt), nil, cs.Gray)
|
||||
} else {
|
||||
t.AddField(sshKey.Title, nil, nil)
|
||||
t.AddField(sshKey.Key, nil, nil)
|
||||
t.AddField(createdAt, nil, nil)
|
||||
t.AddField(id, nil, nil)
|
||||
t.AddField(sshKey.Type, nil, nil)
|
||||
}
|
||||
|
||||
t.EndRow()
|
||||
|
|
@ -114,3 +131,13 @@ func truncateMiddle(maxWidth int, t string) string {
|
|||
remainder := (maxWidth - len(ellipsis)) % 2
|
||||
return t[0:halfWidth+remainder] + ellipsis + t[len(t)-halfWidth:]
|
||||
}
|
||||
|
||||
func printError(w io.Writer, err error) {
|
||||
fmt.Fprintln(w, "warning: ", err)
|
||||
var httpErr api.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
if msg := httpErr.ScopesSuggestion(); msg != "" {
|
||||
fmt.Fprintln(w, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ func TestListRun(t *testing.T) {
|
|||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "list tty",
|
||||
name: "list authentication and signing keys; in tty",
|
||||
opts: ListOptions{
|
||||
HTTPClient: func() (*http.Client, error) {
|
||||
createdAt := time.Now().Add(time.Duration(-24) * time.Hour)
|
||||
|
|
@ -44,19 +44,31 @@ func TestListRun(t *testing.T) {
|
|||
}
|
||||
]`, createdAt.Format(time.RFC3339))),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StringResponse(fmt.Sprintf(`[
|
||||
{
|
||||
"id": 321,
|
||||
"key": "ssh-rsa AAAABbBB123",
|
||||
"title": "Mac Signing",
|
||||
"created_at": "%[1]s"
|
||||
}
|
||||
]`, createdAt.Format(time.RFC3339))),
|
||||
)
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
},
|
||||
isTTY: true,
|
||||
wantStdout: heredoc.Doc(`
|
||||
TITLE ID KEY ADDED
|
||||
Mac 1234 ssh-rsa AAAABbBB123 1d
|
||||
hubot@Windows 5678 ssh-rsa EEEEEEEK247 1d
|
||||
TITLE ID KEY TYPE ADDED
|
||||
Mac 1234 ssh-rsa AAAABbBB123 authentication 1d
|
||||
hubot@Windows 5678 ssh-rsa EEEEEEEK247 authentication 1d
|
||||
Mac Signing 321 ssh-rsa AAAABbBB123 signing 1d
|
||||
`),
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "list non-tty",
|
||||
name: "list authentication and signing keys; in non-tty",
|
||||
opts: ListOptions{
|
||||
HTTPClient: func() (*http.Client, error) {
|
||||
createdAt, _ := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00")
|
||||
|
|
@ -78,16 +90,96 @@ func TestListRun(t *testing.T) {
|
|||
}
|
||||
]`, createdAt.Format(time.RFC3339))),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StringResponse(fmt.Sprintf(`[
|
||||
{
|
||||
"id": 321,
|
||||
"key": "ssh-rsa AAAABbBB123",
|
||||
"title": "Mac Signing",
|
||||
"created_at": "%[1]s"
|
||||
}
|
||||
]`, createdAt.Format(time.RFC3339))),
|
||||
)
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
},
|
||||
isTTY: false,
|
||||
wantStdout: heredoc.Doc(`
|
||||
Mac ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 1234
|
||||
hubot@Windows ssh-rsa EEEEEEEK247 2020-08-31T15:44:24+02:00 5678
|
||||
Mac ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 1234 authentication
|
||||
hubot@Windows ssh-rsa EEEEEEEK247 2020-08-31T15:44:24+02:00 5678 authentication
|
||||
Mac Signing ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 321 signing
|
||||
`),
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "only authentication ssh keys are available",
|
||||
opts: ListOptions{
|
||||
HTTPClient: func() (*http.Client, error) {
|
||||
createdAt := time.Now().Add(time.Duration(-24) * time.Hour)
|
||||
reg := &httpmock.Registry{}
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/keys"),
|
||||
httpmock.StringResponse(fmt.Sprintf(`[
|
||||
{
|
||||
"id": 1234,
|
||||
"key": "ssh-rsa AAAABbBB123",
|
||||
"title": "Mac",
|
||||
"created_at": "%[1]s"
|
||||
}
|
||||
]`, createdAt.Format(time.RFC3339))),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StatusStringResponse(404, "Not Found"),
|
||||
)
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
},
|
||||
wantStdout: heredoc.Doc(`
|
||||
TITLE ID KEY TYPE ADDED
|
||||
Mac 1234 ssh-rsa AAAABbBB123 authentication 1d
|
||||
`),
|
||||
wantStderr: heredoc.Doc(`
|
||||
warning: HTTP 404 (https://api.github.com/user/ssh_signing_keys?per_page=100)
|
||||
`),
|
||||
wantErr: false,
|
||||
isTTY: true,
|
||||
},
|
||||
{
|
||||
name: "only signing ssh keys are available",
|
||||
opts: ListOptions{
|
||||
HTTPClient: func() (*http.Client, error) {
|
||||
createdAt := time.Now().Add(time.Duration(-24) * time.Hour)
|
||||
reg := &httpmock.Registry{}
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StringResponse(fmt.Sprintf(`[
|
||||
{
|
||||
"id": 1234,
|
||||
"key": "ssh-rsa AAAABbBB123",
|
||||
"title": "Mac",
|
||||
"created_at": "%[1]s"
|
||||
}
|
||||
]`, createdAt.Format(time.RFC3339))),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/keys"),
|
||||
httpmock.StatusStringResponse(404, "Not Found"),
|
||||
)
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
},
|
||||
wantStdout: heredoc.Doc(`
|
||||
TITLE ID KEY TYPE ADDED
|
||||
Mac 1234 ssh-rsa AAAABbBB123 signing 1d
|
||||
`),
|
||||
wantStderr: heredoc.Doc(`
|
||||
warning: HTTP 404 (https://api.github.com/user/keys?per_page=100)
|
||||
`),
|
||||
wantErr: false,
|
||||
isTTY: true,
|
||||
},
|
||||
{
|
||||
name: "no keys tty",
|
||||
opts: ListOptions{
|
||||
|
|
@ -97,6 +189,10 @@ func TestListRun(t *testing.T) {
|
|||
httpmock.REST("GET", "user/keys"),
|
||||
httpmock.StringResponse(`[]`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StringResponse(`[]`),
|
||||
)
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
},
|
||||
|
|
@ -114,6 +210,10 @@ func TestListRun(t *testing.T) {
|
|||
httpmock.REST("GET", "user/keys"),
|
||||
httpmock.StringResponse(`[]`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StringResponse(`[]`),
|
||||
)
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,10 +11,16 @@ import (
|
|||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthenticationKey = "authentication"
|
||||
SigningKey = "signing"
|
||||
)
|
||||
|
||||
type sshKey struct {
|
||||
ID int
|
||||
Key string
|
||||
Title string
|
||||
Type string
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
|
|
@ -24,6 +30,41 @@ func UserKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error
|
|||
resource = fmt.Sprintf("users/%s/keys", userHandle)
|
||||
}
|
||||
url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100)
|
||||
|
||||
keys, err := getUserKeys(httpClient, url)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := 0; i < len(keys); i++ {
|
||||
keys[i].Type = AuthenticationKey
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func UserSigningKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) {
|
||||
resource := "user/ssh_signing_keys"
|
||||
if userHandle != "" {
|
||||
resource = fmt.Sprintf("users/%s/ssh_signing_keys", userHandle)
|
||||
}
|
||||
url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100)
|
||||
|
||||
keys, err := getUserKeys(httpClient, url)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := 0; i < len(keys); i++ {
|
||||
keys[i].Type = SigningKey
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func getUserKeys(httpClient *http.Client, url string) ([]sshKey, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue