diff --git a/access.go b/access.go index e0f2a5f..ff93a86 100644 --- a/access.go +++ b/access.go @@ -44,6 +44,25 @@ type AddSSHKeyRequest struct { SSHKeyID string `json:"ssh_key_id"` Username string `json:"username"` } +type ProvisionSSHKeyRequest struct { + Name string `json:"name,omitempty"` + PublicKey string `json:"public_key,omitempty"` + Username string `json:"username"` +} +type RotateSSHKeyRequest struct { + Name string `json:"name,omitempty"` + PublicKey string `json:"public_key,omitempty"` + Username string `json:"username"` + ReplaceKeyID string `json:"replace_key_id,omitempty"` +} +type SSHPrivateKey struct { + KeyID string `json:"key_id"` + KeyName string `json:"key_name"` + Fingerprint string `json:"fingerprint"` + Algorithm string `json:"algorithm"` + Username string `json:"username"` + PrivateKey Secret `json:"private_key"` +} func (s *AccessService) GetLoginInfo(ctx context.Context, region, instanceID string) (*LoginInfo, error) { var result LoginInfo @@ -63,9 +82,21 @@ func (s *AccessService) ListSSHKeys(ctx context.Context, region, instanceID stri err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/ssh-keys", nil, &result, "") return &result, err } + +func (s *AccessService) RevealSSHKey(ctx context.Context, region, instanceID, keyID string) (*SSHPrivateKey, error) { + var result SSHPrivateKey + err := s.client.do(ctx, http.MethodPost, instancesPath(region, instanceID)+"/ssh-keys/"+url.PathEscape(keyID)+"/reveal", map[string]any{}, &result, newID()) + return &result, err +} func (s *AccessService) AddSSHKey(ctx context.Context, region, instanceID string, input AddSSHKeyRequest, options ...RequestOption) (*Operation, error) { return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/ssh-keys", input, options...) } +func (s *AccessService) ProvisionSSHKey(ctx context.Context, region, instanceID string, input ProvisionSSHKeyRequest, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/ssh-keys/provision", input, options...) +} +func (s *AccessService) RotateSSHKey(ctx context.Context, region, instanceID string, input RotateSSHKeyRequest, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/ssh-keys/rotate", input, options...) +} func (s *AccessService) RemoveSSHKey(ctx context.Context, region, instanceID, keyID, username string, options ...RequestOption) (*Operation, error) { path := instancesPath(region, instanceID) + "/ssh-keys/" + escaped(keyID) if username != "" { diff --git a/client.go b/client.go index 8ad19de..60ced38 100644 --- a/client.go +++ b/client.go @@ -16,7 +16,7 @@ import ( "time" ) -const Version = "0.5.0" +const Version = "0.6.0" type Logger interface { Printf(format string, args ...any) diff --git a/client_test.go b/client_test.go index 6789e4f..dadec2f 100644 --- a/client_test.go +++ b/client_test.go @@ -71,6 +71,65 @@ func TestClientSendsAuthAndStableIdempotencyOnRetry(t *testing.T) { } } +func TestProvisionAndRotateSSHKeyRequests(t *testing.T) { + tests := []struct { + path string + call func(*Client) (*Operation, error) + want map[string]string + }{ + {path: "/api/sdk/v1/regions/us-east-1/instances/instance-1/ssh-keys/provision", call: func(client *Client) (*Operation, error) { + return client.Access.ProvisionSSHKey(context.Background(), "us-east-1", "instance-1", ProvisionSSHKeyRequest{Name: "uploaded", PublicKey: "ssh-ed25519 AAAA", Username: "ec2-user"}, WithIdempotencyKey("add-key")) + }, want: map[string]string{"name": "uploaded", "public_key": "ssh-ed25519 AAAA", "username": "ec2-user"}}, + {path: "/api/sdk/v1/regions/us-east-1/instances/instance-1/ssh-keys/rotate", call: func(client *Client) (*Operation, error) { + return client.Access.RotateSSHKey(context.Background(), "us-east-1", "instance-1", RotateSSHKeyRequest{Username: "ec2-user", ReplaceKeyID: "old-key"}, WithIdempotencyKey("rotate-key")) + }, want: map[string]string{"username": "ec2-user", "replace_key_id": "old-key"}}, + } + for _, test := range tests { + t.Run(test.path, func(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != test.path { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + for key, value := range test.want { + if body[key] != value { + t.Fatalf("body[%s] = %q, want %q", key, body[key], value) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":202,"message":"Accepted","data":{"command_id":"command-1","status":"queued"}}`)) + }) + operation, err := test.call(client) + if err != nil { + t.Fatal(err) + } + if operation.TaskID != "command-1" { + t.Fatalf("task ID = %q", operation.TaskID) + } + }) + } +} + +func TestRevealSSHKeyRequestAndSecret(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/sdk/v1/regions/us-east-1/instances/instance-1/ssh-keys/key-1/reveal" { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":200,"message":"Success","data":{"key_id":"key-1","key_name":"generated","username":"ec2-user","private_key":"secret-pem"}}`)) + }) + key, err := client.Access.RevealSSHKey(context.Background(), "us-east-1", "instance-1", "key-1") + if err != nil { + t.Fatal(err) + } + if key.KeyID != "key-1" || key.PrivateKey.Reveal() != "secret-pem" || key.PrivateKey.String() != "[REDACTED]" { + t.Fatalf("unexpected private key response: %#v", key) + } +} + func TestAPIErrorAndHelpers(t *testing.T) { client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json")