Skip to content

Commit

Permalink
Merge pull request #8 from duplocloud/release/0.4.1
Browse files Browse the repository at this point in the history
Release v0.4.1
  • Loading branch information
joek-duplo committed Jul 1, 2022
2 parents a62d194 + f328456 commit ff4d907
Show file tree
Hide file tree
Showing 6 changed files with 154 additions and 56 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
*.exe
.idea/
*.log
test/
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
VERSION=0.3.3
VERSION=0.4.1

default: all

.PHONY:
clean:
rm -f duplo-aws-credential-process
rm -f duplo-aws-credential-process *.exe

install: all
sudo install -o root -m 755 duplo-aws-credential-process /usr/local/bin/duplo-aws-credential-process
Expand Down
126 changes: 80 additions & 46 deletions cmd/duplo-aws-credential-process/interactive.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -11,90 +12,123 @@ import (
"github.com/skratchdot/open-golang/open"
)

type tokenResult struct {
token string
type TokenResult struct {
Token string `json:"token"`
OTP string `json:"otp,omitempty"`
err error
}

func tokenViaPost(baseUrl string, timeout time.Duration) (string, error) {
func handlerTokenViaPost(baseUrl string, res http.ResponseWriter, req *http.Request) (completed bool, bytes []byte) {
var err error
status := "ok"

// Only allow the specified Duplo to give us creds.
res.Header().Add("Access-Control-Allow-Origin", baseUrl)
res.Header().Add("Access-Control-Allow-Headers", "X-Requested-With")
res.Header().Add("Access-Control-Allow-Headers", "Accept")
res.Header().Add("Access-Control-Allow-Headers", "Content-Type")

// 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"
}
}

// TODO: output any errors to the console

_, _ = fmt.Fprintf(res, "\"%s\"\n", status)

return
}

func tokenViaPost(baseUrl string, admin bool, timeout time.Duration) TokenResult {

// Create the listener on a random port.
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
return TokenResult{err: err}
}

// Get the port being listened to.
localPort := listener.Addr().(*net.TCPAddr).Port

// Run the HTTP server on localhost.
done := make(chan tokenResult)
done := make(chan TokenResult)
go func() {
mux := http.NewServeMux()

// legacy API, with no facility for OTP
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, bytes := handlerTokenViaPost(baseUrl, res, req)

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)
}
// If we are done, send the result to the channel.
if completed {
done <- TokenResult{Token: string(bytes), err: err}
}
})

// Send the proper response.
// API with facility for OTP
mux.HandleFunc("/v2/callbackWithOtp", func(res http.ResponseWriter, req *http.Request) {
completed, bytes := handlerTokenViaPost(baseUrl, res, req)

// If we are done, send the result to the channel.
if completed {
if err != nil {
res.WriteHeader(500)
status = "failed"
rp := TokenResult{}
if len(bytes) == 0 {
rp.err = errors.New("canceled")
} else {
status = "done"
if len(bytes) == 0 {
err = errors.New("canceled")
err = json.Unmarshal(bytes, &rp)
if err != nil {
message := fmt.Sprintf("%s: cannot unmarshal response from JSON: %s", "/v2/callbackWithOtp", err.Error())
rp.err = errors.New(message)
}
}
}
_, _ = 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}
done <- rp
}
})

_ = http.Serve(listener, mux)
}()

// Open the browser.
url := fmt.Sprintf("%s/app/user/verify-token?localAppName=duplo-aws-credential-process&localPort=%d", baseUrl, localPort)
adminFlag := ""
if admin {
adminFlag = "&isAdmin=true"
}
url := fmt.Sprintf("%s/app/user/verify-token?localAppName=duplo-aws-credential-process&localPort=%d%s", baseUrl, localPort, adminFlag)
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
return tokenResult
case <-time.After(timeout):
return "", errors.New("timed out")
return TokenResult{err: 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
func mustTokenInteractive(host string, admin bool) (tokenResult TokenResult) {
tokenResult = tokenViaPost(host, admin, 180*time.Second)
dieIf(tokenResult.err, "failed to get token from interactive browser session")
return
}
37 changes: 31 additions & 6 deletions cmd/duplo-aws-credential-process/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,22 @@ func outputCreds(creds *AwsConfigOutput, cacheKey string) {
os.Stdout.WriteString("\n")
}

func mustDuploClient(host, token string, interactive bool) *duplocloud.Client {
func mustDuploClient(host, token string, interactive, admin bool) *duplocloud.Client {
otp := ""

// 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)
tokenResult := mustTokenInteractive(host, admin)
token = tokenResult.Token
otp = tokenResult.OTP
}

// Create the client.
client, err := duplocloud.NewClient(host, token)
client, err := duplocloud.NewClientWithOtp(host, token, otp)
dieIf(err, "invalid arguments")

return client
Expand All @@ -90,6 +94,7 @@ func main() {
host := flag.String("host", "", "Duplo API base URL")
token := flag.String("token", "", "Duplo API token")
admin := flag.Bool("admin", false, "Get admin credentials")
duploOps := flag.Bool("duplo-ops", false, "Get Duplo operations credentials")
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)")
Expand All @@ -103,7 +108,7 @@ func main() {
version = "(dev build)"
}
if commit == "" {
commit = "(x)"
commit = "unknown"
}
fmt.Printf("%s version %s (git commit %s)\n", os.Args[0], version, commit)
os.Exit(0)
Expand Down Expand Up @@ -137,12 +142,28 @@ func main() {

// Otherwise, get the credentials from Duplo.
if creds == nil {
client := mustDuploClient(*host, *token, *interactive)
client := mustDuploClient(*host, *token, *interactive, true)
result, err := client.AdminGetJITAwsCredentials()
dieIf(err, "failed to get credentials")
creds = convertCreds(result)
}

} else if *duploOps {

// Build the cache key
cacheKey = strings.Join([]string{strings.TrimPrefix(*host, "https://"), "duplo-ops"}, ",")

// Try to find credentials from the cache.
creds = cacheGetAwsConfigOutput(cacheKey)

// Otherwise, get the credentials from Duplo.
if creds == nil {
client := mustDuploClient(*host, *token, *interactive, true)
result, err := client.AdminAwsGetJitAccess("duplo-ops")
dieIf(err, "failed to get credentials")
creds = convertCreds(result)
}

} else if tenantID == nil || *tenantID == "" {

// Tenant credentials require an additional argument.
Expand All @@ -158,11 +179,15 @@ func main() {

// Otherwise, get the credentials from Duplo.
if creds == nil {
client := mustDuploClient(*host, *token, *interactive)
client := mustDuploClient(*host, *token, *interactive, false)

// If it doesn't look like a UUID, get the tenant ID from the name.
if len(*tenantID) < 32 {
var err error
tenant, err := client.GetTenantByNameForUser(*tenantID)
if tenant == nil {
err = errors.New("no such tenant available to your user")
}
dieIf(err, fmt.Sprintf("%s: tenant not found", *tenantID))
tenantID = &tenant.TenantID
}
Expand Down
29 changes: 27 additions & 2 deletions duplocloud/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,33 @@ type UserTenant struct {
PlanID string `json:"PlanID"`
}

// AdminGetAwsCredentials retrieves just-in-time admin AWS credentials via the Duplo API.
// AdminAwsGetJitAccess retrieves just-in-time admin AWS credentials for the requested role via the Duplo API.
func (c *Client) AdminAwsGetJitAccess(role string) (*AwsJitCredentials, ClientError) {
creds := AwsJitCredentials{}
err := c.getAPI("AdminAwsGetJitAccess()", fmt.Sprintf("v3/admin/aws/jitAccess/%s", role), &creds)
if err != nil {
return nil, err
}
return &creds, nil
}

// AdminGetJITAwsCredentials retrieves just-in-time admin AWS credentials via the Duplo API.
func (c *Client) AdminGetJITAwsCredentials() (*AwsJitCredentials, ClientError) {
creds, err := c.AdminAwsGetJitAccess("admin")

// Fallback to legacy API
if err != nil && err.Status() == 404 {
creds, err = c.LegacyAdminGetJITAwsCredentials()
}

if err != nil {
return nil, err
}
return creds, err
}

// LegacyAdminGetJITAwsCredentials retrieves just-in-time admin AWS credentials via the Duplo API.
func (c *Client) LegacyAdminGetJITAwsCredentials() (*AwsJitCredentials, ClientError) {
creds := AwsJitCredentials{}
err := c.getAPI("AdminGetJITAwsCredentials()", "adminproxy/GetJITAwsConsoleAccessUrl", &creds)
if err != nil {
Expand All @@ -29,7 +54,7 @@ func (c *Client) AdminGetJITAwsCredentials() (*AwsJitCredentials, ClientError) {
return &creds, nil
}

// TenantGetAwsCredentials retrieves just-in-time AWS credentials for a tenant via the Duplo API.
// TenantGetJITAwsCredentials retrieves just-in-time AWS credentials for a tenant via the Duplo API.
func (c *Client) TenantGetJITAwsCredentials(tenantID string) (*AwsJitCredentials, ClientError) {
creds := AwsJitCredentials{}
err := c.getAPI(
Expand Down
13 changes: 13 additions & 0 deletions duplocloud/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type Client struct {
HTTPClient *http.Client
HostURL string
Token string
OTP string
}

// NewClient creates a new Duplo API client
Expand All @@ -29,6 +30,15 @@ func NewClient(host, token string) (*Client, error) {
return nil, fmt.Errorf("missing config for Duplo 'host' and/or 'token'")
}

// NewClientWithOtp creates a new Duplo API client with an OTP code
func NewClientWithOtp(host, token, otp string) (c *Client, err error) {
c, err = NewClient(host, token)
if err == nil && c != nil && otp != "" {
c.OTP = otp
}
return
}

type clientError struct {
message string
status int
Expand Down Expand Up @@ -163,6 +173,9 @@ func (c *Client) doAPI(verb string, apiName string, apiPath string, rp interface
logf(TRACE, "%s: cannot build request: %s", apiName, err.Error())
return nil
}
if c.OTP != "" {
req.Header.Set("otpcode", c.OTP)
}

// Call the API and get the response.
body, httpErr := c.doRequest(req)
Expand Down

0 comments on commit ff4d907

Please sign in to comment.