Skip to content

Commit a66ee3d

Browse files
authored
feat(instance): get rdp password and decrypt it (#3680)
1 parent a012ae0 commit a66ee3d

File tree

5 files changed

+195
-0
lines changed

5 files changed

+195
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲
2+
🟥🟥🟥 STDERR️️ 🟥🟥🟥️
3+
Get your server rdp and decrypt it using your ssh key
4+
5+
USAGE:
6+
scw instance server get-rdp-password <server-id ...> [arg=value ...]
7+
8+
ARGS:
9+
server-id Server ID to connect to
10+
[key=~/.ssh/id_rsa] Path of the SSH key used to decrypt the rdp password
11+
[zone=fr-par-1] Zone to target. If none is passed will use default zone from the config
12+
13+
FLAGS:
14+
-h, --help help for get-rdp-password
15+
16+
GLOBAL FLAGS:
17+
-c, --config string The path to the config file
18+
-D, --debug Enable debug mode
19+
-o, --output string Output format: json or human, see 'scw help output' for more info (default "human")
20+
-p, --profile string The config profile to use

cmd/scw/testdata/test-all-usage-instance-server-usage.golden

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ AVAILABLE COMMANDS:
1919
detach-volume Detach a volume from its server
2020
enable-routed-ip Migrate server to IP mobility
2121
get Get an Instance
22+
get-rdp-password Get your server rdp and decrypt it using your ssh key
2223
list List all Instances
2324
list-actions List Instance actions
2425
reboot Reboot server

docs/commands/instance.md

+22
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ This API allows you to manage your Instances.
6060
- [Detach a volume from its server](#detach-a-volume-from-its-server)
6161
- [Migrate server to IP mobility](#migrate-server-to-ip-mobility)
6262
- [Get an Instance](#get-an-instance)
63+
- [Get your server rdp and decrypt it using your ssh key](#get-your-server-rdp-and-decrypt-it-using-your-ssh-key)
6364
- [List all Instances](#list-all-instances)
6465
- [List Instance actions](#list-instance-actions)
6566
- [Reboot server](#reboot-server)
@@ -1922,6 +1923,27 @@ scw instance server get 94ededdf-358d-4019-9886-d754f8a2e78d
19221923

19231924

19241925

1926+
### Get your server rdp and decrypt it using your ssh key
1927+
1928+
1929+
1930+
**Usage:**
1931+
1932+
```
1933+
scw instance server get-rdp-password <server-id ...> [arg=value ...]
1934+
```
1935+
1936+
1937+
**Args:**
1938+
1939+
| Name | | Description |
1940+
|------|---|-------------|
1941+
| server-id | Required | Server ID to connect to |
1942+
| key | Default: `~/.ssh/id_rsa` | Path of the SSH key used to decrypt the rdp password |
1943+
| zone | Default: `fr-par-1` | Zone to target. If none is passed will use default zone from the config |
1944+
1945+
1946+
19251947
### List all Instances
19261948

19271949
List all Instances in a specified Availability Zone, e.g. `fr-par-1`.

internal/namespaces/instance/v1/custom.go

+1
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ func GetCommands() *core.Commands {
193193
sshConfigInstallCommand(),
194194
sshListKeysCommand(),
195195
sshRemoveKeyCommand(),
196+
instanceServerGetRdpPassword(),
196197
))
197198

198199
return cmds
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package instance
2+
3+
import (
4+
"context"
5+
"crypto/rsa"
6+
"encoding/base64"
7+
"errors"
8+
"fmt"
9+
"os"
10+
"path/filepath"
11+
"reflect"
12+
"strings"
13+
14+
iam "github.com/scaleway/scaleway-sdk-go/api/iam/v1alpha1"
15+
"github.com/scaleway/scaleway-sdk-go/api/instance/v1"
16+
"golang.org/x/crypto/ssh"
17+
18+
"github.com/scaleway/scaleway-cli/v2/internal/core"
19+
"github.com/scaleway/scaleway-cli/v2/internal/interactive"
20+
"github.com/scaleway/scaleway-sdk-go/scw"
21+
)
22+
23+
type instanceServerGetRdpPasswordRequest struct {
24+
ServerID string
25+
Zone scw.Zone
26+
Key string
27+
}
28+
29+
func instanceServerGetRdpPassword() *core.Command {
30+
return &core.Command{
31+
Short: `Get your server rdp and decrypt it using your ssh key`,
32+
Namespace: "instance",
33+
Verb: "get-rdp-password",
34+
Resource: "server",
35+
ArgsType: reflect.TypeOf(instanceServerGetRdpPasswordRequest{}),
36+
ArgSpecs: core.ArgSpecs{
37+
{
38+
Name: "server-id",
39+
Short: "Server ID to connect to",
40+
Required: true,
41+
Positional: true,
42+
},
43+
{
44+
Name: "key",
45+
Short: "Path of the SSH key used to decrypt the rdp password",
46+
Default: func(ctx context.Context) (value string, doc string) {
47+
homeDir := core.ExtractUserHomeDir(ctx)
48+
return filepath.Join(homeDir, ".ssh/id_rsa"), "~/.ssh/id_rsa"
49+
},
50+
},
51+
core.ZoneArgSpec(),
52+
},
53+
Run: instanceServerGetRdpPasswordRun,
54+
}
55+
}
56+
57+
func instanceServerGetRdpPasswordRun(ctx context.Context, argsI interface{}) (i interface{}, e error) {
58+
args := argsI.(*instanceServerGetRdpPasswordRequest)
59+
60+
if strings.HasPrefix(args.Key, "~") {
61+
args.Key = strings.Replace(args.Key, "~", core.ExtractUserHomeDir(ctx), 1)
62+
}
63+
64+
rawKey, err := os.ReadFile(args.Key)
65+
if err != nil {
66+
return nil, fmt.Errorf("failed to read private key file: %w", err)
67+
}
68+
69+
privateKey, err := parsePrivateKey(ctx, rawKey)
70+
if err != nil {
71+
return nil, err
72+
}
73+
74+
rsaKey, isRSA := privateKey.(*rsa.PrivateKey)
75+
if !isRSA {
76+
return nil, fmt.Errorf("expected rsa private key, got %s", reflect.TypeOf(privateKey).String())
77+
}
78+
79+
client := core.ExtractClient(ctx)
80+
apiInstance := instance.NewAPI(client)
81+
resp, err := apiInstance.GetServer(&instance.GetServerRequest{
82+
Zone: args.Zone,
83+
ServerID: args.ServerID,
84+
})
85+
if err != nil {
86+
return nil, err
87+
}
88+
if resp.Server.AdminPasswordEncryptedValue == nil {
89+
return nil, fmt.Errorf("rdp password is nil")
90+
}
91+
92+
encryptedRdpPassword, err := base64.StdEncoding.DecodeString(*resp.Server.AdminPasswordEncryptedValue)
93+
if err != nil {
94+
return nil, fmt.Errorf("failed to decode base64 encoded rdp password: %w", err)
95+
}
96+
97+
password, err := rsa.DecryptPKCS1v15(nil, rsaKey, encryptedRdpPassword)
98+
if err != nil {
99+
return nil, fmt.Errorf("failed to decrypt rdp password: %w", err)
100+
}
101+
102+
sshKeyDescription := ""
103+
if resp.Server.AdminPasswordEncryptionSSHKeyID != nil {
104+
iamAPI := iam.NewAPI(client)
105+
key, err := iamAPI.GetSSHKey(&iam.GetSSHKeyRequest{
106+
SSHKeyID: *resp.Server.AdminPasswordEncryptionSSHKeyID,
107+
})
108+
if err == nil {
109+
sshKeyDescription = key.Name
110+
}
111+
}
112+
113+
return struct {
114+
Username string
115+
Password string
116+
SSHKeyID *string
117+
SSHKeyDescription string
118+
}{
119+
Username: "Administrator",
120+
Password: string(password),
121+
SSHKeyID: resp.Server.AdminPasswordEncryptionSSHKeyID,
122+
SSHKeyDescription: sshKeyDescription,
123+
}, err
124+
}
125+
126+
func parsePrivateKey(ctx context.Context, key []byte) (any, error) {
127+
privateKey, err := ssh.ParseRawPrivateKey(key)
128+
if err == nil {
129+
return privateKey, err
130+
}
131+
// Key may need a passphrase
132+
missingPassphraseError := &ssh.PassphraseMissingError{}
133+
if !errors.As(err, &missingPassphraseError) {
134+
return nil, fmt.Errorf("failed to parse private key: %w", err)
135+
}
136+
137+
passphrase, err := interactive.PromptPasswordWithConfig(&interactive.PromptPasswordConfig{
138+
Ctx: ctx,
139+
Prompt: "passphrase",
140+
})
141+
if err != nil {
142+
return nil, fmt.Errorf("failed to read input: %w", err)
143+
}
144+
145+
privateKey, err = ssh.ParseRawPrivateKeyWithPassphrase(key, []byte(passphrase))
146+
if err != nil {
147+
return nil, fmt.Errorf("failed to parse private key: %w", err)
148+
}
149+
150+
return privateKey, nil
151+
}

0 commit comments

Comments
 (0)