diff --git a/Makefile b/Makefile index 7a2de07..b83c300 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VERSION=0.3.2 +VERSION=0.3.3 default: all @@ -14,4 +14,4 @@ test: all all: duplo-aws-credential-process duplo-aws-credential-process: Makefile duplocloud/*.go cmd/duplo-aws-credential-process/*.go - go build -ldflags "-X main.version=v$(VERSION)-dev -X main.commit=$(shell git rev-parse HEAD)" ./cmd/duplo-aws-credential-process/ + go build -ldflags "-X main.version=$(VERSION)-dev -X main.commit=$(shell git rev-parse HEAD)" ./cmd/duplo-aws-credential-process/ diff --git a/cmd/duplo-aws-credential-process/interactive.go b/cmd/duplo-aws-credential-process/interactive.go new file mode 100644 index 0000000..d9af00b --- /dev/null +++ b/cmd/duplo-aws-credential-process/interactive.go @@ -0,0 +1,100 @@ +package main + +import ( + "errors" + "fmt" + "io" + "net" + "net/http" + "time" + + "github.com/skratchdot/open-golang/open" +) + +type tokenResult struct { + token string + err error +} + +func tokenViaPost(baseUrl string, timeout time.Duration) (string, error) { + + // Create the listener on a random port. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", err + } + + // Get the port being listened to. + localPort := listener.Addr().(*net.TCPAddr).Port + + // Run the HTTP server on localhost. + done := make(chan tokenResult) + go func() { + mux := http.NewServeMux() + + mux.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) { + var bytes []byte + var err error + completed := false + status := "ok" + + // Only allow the specified Duplo to give us creds. + res.Header().Add("Access-Control-Allow-Origin", baseUrl) + + // A POST means we are done, whether good or bad. + if req.Method == "POST" { + defer req.Body.Close() + + completed = true + + // Authorize the origin, and get the POST body. + origin := req.Header.Get("Origin") + if origin != baseUrl { + err = fmt.Errorf("unauthorized origin: %s", origin) + } else { + bytes, err = io.ReadAll(req.Body) + } + } + + // Send the proper response. + if completed { + if err != nil { + res.WriteHeader(500) + status = "failed" + } else { + status = "done" + if len(bytes) == 0 { + err = errors.New("canceled") + } + } + } + _, _ = fmt.Fprintf(res, "\"%s\"\n", status) + + // If we are done, send the result to the channel. + if completed { + done <- tokenResult{token: string(bytes), err: err} + } + }) + _ = http.Serve(listener, mux) + }() + + // Open the browser. + url := fmt.Sprintf("%s/app/user/verify-token?localAppName=duplo-aws-credential-process&localPort=%d", baseUrl, localPort) + err = open.Run(url) + dieIf(err, "failed to open interactive browser session") + + // Wait for the token result, and return it. + select { + case tokenResult := <-done: + return tokenResult.token, tokenResult.err + case <-time.After(timeout): + return "", errors.New("timed out") + } +} + +func mustTokenInteractive(host string) string { + token, err := tokenViaPost(host, 180*time.Second) + dieIf(err, "failed to get token from interactive browser session") + + return token +} diff --git a/cmd/duplo-aws-credential-process/main.go b/cmd/duplo-aws-credential-process/main.go index 516acb1..1884b95 100644 --- a/cmd/duplo-aws-credential-process/main.go +++ b/cmd/duplo-aws-credential-process/main.go @@ -62,6 +62,23 @@ func outputCreds(creds *AwsConfigOutput, cacheKey string) { os.Stdout.WriteString("\n") } +func mustDuploClient(host, token string, interactive bool) *duplocloud.Client { + // Possibly get a token from an interactive process. + if token == "" { + if !interactive { + log.Fatalf("%s: --token not specified and --interactive mode is disabled", os.Args[0]) + } + + token = mustTokenInteractive(host) + } + + // Create the client. + client, err := duplocloud.NewClient(host, token) + dieIf(err, "invalid arguments") + + return client +} + var commit string var version string @@ -76,6 +93,7 @@ func main() { tenantID := flag.String("tenant", "", "Get credentials for the given tenant") debug := flag.Bool("debug", false, "Turn on verbose (debugging) output") noCache = flag.Bool("no-cache", false, "Disable caching (not recommended)") + interactive := flag.Bool("interactive", false, "Allow getting Duplo credentials via an interactive browser session (experimental)") showVersion := flag.Bool("version", false, "Output version information and exit") flag.Parse() @@ -103,10 +121,6 @@ func main() { duplocloud.LogLevel = duplocloud.TRACE } - // Prepare the connection to the duplo API. - client, err := duplocloud.NewClient(*host, *token) - dieIf(err, "invalid arguments") - // Prepare the cache directory mustInitCache() @@ -123,6 +137,7 @@ func main() { // Otherwise, get the credentials from Duplo. if creds == nil { + client := mustDuploClient(*host, *token, *interactive) result, err := client.AdminGetJITAwsCredentials() dieIf(err, "failed to get credentials") creds = convertCreds(result) @@ -143,6 +158,7 @@ func main() { // Otherwise, get the credentials from Duplo. if creds == nil { + client := mustDuploClient(*host, *token, *interactive) // If it doesn't look like a UUID, get the tenant ID from the name. if len(*tenantID) < 32 { diff --git a/go.mod b/go.mod index 5cc74fd..4dac944 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/duplocloud/duplo-aws-jit go 1.16 + +require github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d5a1984 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=