From 54210b55ac368b40a4fe5ec18d2bb817281812da Mon Sep 17 00:00:00 2001 From: "naotama (Naoki KOBAYASHI)" Date: Sat, 19 Aug 2023 15:22:20 +0900 Subject: [PATCH] feat: add open browser option (-web) --- README.md | 10 ++++ aws/console.go | 142 +++++++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 42 +++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 aws/console.go diff --git a/README.md b/README.md index d81a1f19..923d600a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ options: Configuration Mode -p, --profile string AWS profile name (default: "default") + -w, --web + Open the AWS Console URL in your default browser (*1) ``` Please be careful that assam overrides default profile in `.aws/credentials` by default. @@ -40,6 +42,14 @@ $ brew install cybozu/assam/assam Download a binary file from [Release](https://github.com/cybozu/assam/releases) and save it to the desired location. +## Notes + +### (*1) Command to open the default browser + +- Windows: `start` +- macOS : `open` +- Linux: `xdg-open` + ## Contribution 1. Fork ([https://github.com/cybozu/assam](https://github.com/cybozu/assam)) diff --git a/aws/console.go b/aws/console.go new file mode 100644 index 00000000..f89bcb59 --- /dev/null +++ b/aws/console.go @@ -0,0 +1,142 @@ +package aws + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" +) + +// AWSClient is an interface for AWS operations +type awsClientInterface interface { + GetConsoleURL() (string, error) +} + +// awsClient is the implementation of AWSClient interface +type awsClient struct { + session *session.Session +} + +// NewAWSClient creates a new AWSClient instance +// +// By default NewSession will only load credentials from the shared credentials file (~/.aws/credentials). +func NewAWSClient() awsClientInterface { + // Create session + sess := session.Must(session.NewSessionWithOptions(session.Options{ + SharedConfigState: session.SharedConfigEnable, + })) + + return &awsClient{ + session: sess, + } +} + +// GetConsoleURL returns the AWS Management Console URL +// ref: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html +func (c *awsClient) GetConsoleURL() (string, error) { + amazonDomain := c.getConsoleDomain(*c.session.Config.Region) + + // Create get signin token URL + creds, err := c.session.Config.Credentials.Get() + if err != nil { + return "", errors.New("failed to get aws credential: please authenticate with `assam`") + } + + token, err := c.getSigninToken(creds, amazonDomain) + if err != nil { + return "", err + } + + targetURL := fmt.Sprintf("https://console.%s/console/home", amazonDomain) + params := url.Values{ + "Action": []string{"login"}, + "Destination": []string{targetURL}, + "SigninToken": []string{token}, + } + + return fmt.Sprintf("https://signin.%s/federation?%s", amazonDomain, params.Encode()), nil +} + +// getConsoleDomain returns the console domain based on the region +func (c *awsClient) getConsoleDomain(region string) string { + var amazonDomain string + + if strings.HasPrefix(region, "us-gov-") { + amazonDomain = "amazonaws-us-gov.com" + } else if strings.HasPrefix(region, "cn-") { + amazonDomain = "amazonaws.cn" + } else { + amazonDomain = "aws.amazon.com" + } + return amazonDomain +} + +// getSinginToken retrieves the signin token +func (c *awsClient) getSigninToken(creds credentials.Value, amazonDomain string) (string, error) { + urlCreds := map[string]string{ + "sessionId": creds.AccessKeyID, + "sessionKey": creds.SecretAccessKey, + "sessionToken": creds.SessionToken, + } + + bytes, err := json.Marshal(urlCreds) + if err != nil { + return "", err + } + + params := url.Values{ + "Action": []string{"getSigninToken"}, + "DurationSeconds": []string{"900"}, // DurationSeconds minimum value + "Session": []string{string(bytes)}, + } + tokenRequest := fmt.Sprintf("https://signin.%s/federation?%s", amazonDomain, params.Encode()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Construct a request to the federation URL. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenRequest, nil) + if err != nil { + return "", err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("request failed: %s", resp.Status) + } + + // Extract a signin token from the response body. + token, err := c.getToken(resp.Body) + if err != nil { + return "", err + } + + return token, nil +} + +// getToken extracts the signin token from the response body +func (c *awsClient) getToken(reader io.Reader) (string, error) { + type response struct { + SigninToken string + } + + var resp response + if err := json.NewDecoder(reader).Decode(&resp); err != nil { + return "", err + } + + return resp.SigninToken, nil +} diff --git a/cmd/root.go b/cmd/root.go index 0558bf9f..79c689c9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,6 +4,9 @@ package cmd import ( "context" "fmt" + "runtime" + "strings" + "github.com/cybozu/assam/aws" "github.com/cybozu/assam/config" "github.com/cybozu/assam/defaults" @@ -11,7 +14,9 @@ import ( "github.com/cybozu/assam/prompt" "github.com/pkg/errors" "github.com/spf13/cobra" + "os" + "os/exec" "os/signal" "path/filepath" "strconv" @@ -37,6 +42,7 @@ func newRootCmd() *cobra.Command { var configure bool var roleName string var profile string + var web bool var showVersion bool cmd := &cobra.Command{ @@ -58,6 +64,10 @@ func newRootCmd() *cobra.Command { return nil } + if web { + return openBrowser() + } + cfg, err := config.NewConfig(profile) if err != nil { return errors.Wrap(err, "please run `assam --configure` at the first time") @@ -105,6 +115,7 @@ func newRootCmd() *cobra.Command { cmd.PersistentFlags().BoolVarP(&configure, "configure", "c", false, "configure initial settings") cmd.PersistentFlags().StringVarP(&profile, "profile", "p", "default", "AWS profile") cmd.PersistentFlags().StringVarP(&roleName, "role", "r", "", "AWS IAM role name") + cmd.PersistentFlags().BoolVarP(&web, "web", "w", false, "open AWS management console in a browser") cmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "Show version") return cmd @@ -175,6 +186,37 @@ func configureSettings(profile string) error { return config.Save(cfg, profile) } +func openBrowser() error { + url, err := aws.NewAWSClient().GetConsoleURL() + if err != nil { + return err + } + + var cmd string + var args []string + switch runtime.GOOS { + case "darwin": + cmd = "open" + args = []string{url} + case "windows": + cmd = "cmd" + args = []string{"/c", "start", strings.ReplaceAll(url, "&", "^&")} // for Windows: "&! <>^|" etc. must be escaped, but since only "&" is used, the corresponding + case "linux": + cmd = "xdg-open" + args = []string{url} + } + + if len(cmd) != 0 { + err = exec.Command(cmd, args...).Run() + if err != nil { + return err + } + } else { + return errors.New("OS does not support -web command") + } + return nil +} + func handleSignal(cancel context.CancelFunc) { signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)