Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AWS Marketplace Entitlement verification #1619

Merged
merged 8 commits into from
May 27, 2021
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions cmd/nginx-ingress/aws.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// +build aws

package main

import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"log"
"math/big"
"time"

"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/marketplacemetering"
"github.com/aws/aws-sdk-go-v2/service/marketplacemetering/types"

"github.com/dgrijalva/jwt-go/v4"
)

var (
productCode string
pubKeyVersion int32 = 1
pubKeyString string
nonce string
)

func init() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now this function is called every time the binary is executed. However, the binary can be executed without the intention of starting the IC - for example, a troubleshooting /nginx-ingress version command to determine the IC version. With the current init() implementation, that troubleshooting command with also register usage.

However, there is a bit bigger concern I see - the function has a lot of logic which we don't test at all.

To address those concerns I suggest the following:

(1) Let's call this function indirectly. This way we will only call it when the IC is meant to start.
(2) Let's remove all log Fatal statement. Instead, the function will return an error. If the error is not nil, we will exit in main.go

in main.go

var startupCheckFn func() error

func main() {
	flag.Parse()

	err := flag.Lookup("logtostderr").Value.Set("true")
	if err != nil {
		glog.Fatalf("Error setting logtostderr to true: %v", err)
	}

	versionInfo := fmt.Sprintf("Version=%v GitCommit=%v Date=%v", version, commit, date)
	if *versionFlag {
		fmt.Println(versionInfo)
		os.Exit(0)
	}

	if startupCheckFn != nil {
		err := startupCheckFn()
		if err != nil {
			glog.Fatalf("Failed startup check: %v", err)
		}
		glog.Info("Startup check is successful")
	}
	
	. . .

in aws.go

func init() {
	startupCheckFn = checkAWSEntitlement
}

func checkAWSEntitlement() error {
	. . .
}

this way we will be able to test checkAWSEntitlement.

Since we only make one external API call, we will be able to test it by wrapping it into an interface like this:

type awsMeter interface {
	RegisterUsage(params *marketplacemetering.RegisterUsageInput) (*marketplacemetering.RegisterUsageOutput, error)
}

In unit tests, we will be able to create a fake implementation that will return the right output.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What kind of logic would you want to test?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the ifs in init()

Copy link
Contributor

@soneillf5 soneillf5 May 24, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think init as a whole needs a test as there's so little logic, just ifs for the errors. You could wrap and test the logic around the token claims and unit test that?

The question around the binary is interesting. Why would this code need a version check?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think init as a whole needs a test as there's so little logic, just ifs for the errors. You could wrap and test the logic around the token claims and unit test that?

we have a happy path when nothing returns an error and we a number places where the error needs to be checked. why would we not want to test it? 🤔
yeah, some additional refactoring of init can also help

The question around the binary is interesting. Why would this code need a version check?

this init code is always called, even if the version check command is invoked on the binary.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

supporting calling the registration from main is easy:

main.go

var startupCheckFn func() error

. . .
       if startupCheckFn != nil {
          startupCheckFn()
       }

aws.go

func init() {
	startupCheckFn = checkAWSEntitlement
}

func checkAWSEntitlement() {
	. . .
}

However, the result is that (1) it allows using the same logger (glog) and (2) it will not affect the version check at all.

The point of using init() is to run it before main() automatically, without modifying main

the requirement is to run that code as part of the Ingress Controller startup, so that the pod is registered. However, we don't have the requirement that it must be run from the init() function. when implementing new features, in general we should not break the existing ones (in this case version check) and make sure that the new functionally fits nicely with the existing one (in this case, use the same logger for consistent format).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • The version check still works
  • The log is only one line. I just pushed a change to only print on failure, so that would be the only line printed and there won't be any inconsistency.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version check still works

yep. but it works with a side effect - it will register a pod

The log is only one line. I just pushed a change to only print on failure, so that would be the only line printed and there won't be any inconsistency.

Below is an example of inconsistent format:
fatal log in the init

2021/05/21 14:58:08 operation error Marketplace Metering: RegisterUsage, failed to resolve service endpoint, an AWS region is required, but was not found

fatal log in the main.go

I0526 15:28:24.173615   45447 main.go:259] Starting NGINX Ingress controller Version=my-version GitCommit= Date= PlusFlag=false
F0526 15:28:24.173819   45447 main.go:275] error creating client configuration: unable to load in-cluster configuration, KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT must be defined

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to offer some other considerations, for why @pleshakov's points merit consideration:

  1. init may be called before main but there's no ordering guarantee for which init is called before another between packages. Not really the case here but it could be if more pakages are using init. This is implementation-dependent and the golang authors are free the change that implementation leading to silent breaks
  2. init is magic and we should strive for simplicity rather than things happening automagically.
  3. whilst init can be used for setting up structures etc, since it's very early in the lifecycle of the process, using it to interact with state (the registration here) is beyond what would be good practice. IMO it would be better to explicitly initialize and control initialization from main
  4. using log.Fatal in init is a recipe for surprise by developers down the line. I would not use that.
  5. Testing should definitely cover the cases in the initialization however simple (or at least create tech debt to record this)
  6. I would choose consistency (in this case agreeing on the particular log library) so whichever version allows producing consistent outcome for users is preferrable. You never know how users are going to use the logs for their own purposes

my 2c

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The best solutions are always the most obvious that don't make you think and obviously this made a lot of people think! Thanks for all the great feedback, I will go with the suggested solution.

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

nonce, err := generateRandomString(255)
if err != nil {
log.Fatal(err)
}

cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
log.Fatalf("error loading AWS configuration: %v", err)
}

mpm := marketplacemetering.New(marketplacemetering.Options{Region: cfg.Region, Credentials: cfg.Credentials})

out, err := mpm.RegisterUsage(ctx, &marketplacemetering.RegisterUsageInput{ProductCode: &productCode, PublicKeyVersion: &pubKeyVersion, Nonce: &nonce})
if err != nil {
var notEnt *types.CustomerNotEntitledException
if errors.As(err, &notEnt) {
lucacome marked this conversation as resolved.
Show resolved Hide resolved
log.Fatalf("user not entitled, code: %v, message: %v, fault: %v", notEnt.ErrorCode(), notEnt.ErrorMessage(), notEnt.ErrorFault().String())
}
log.Fatal(err)

}

pk, err := base64.StdEncoding.DecodeString(pubKeyString)
if err != nil {
log.Fatalf("error decoding Public Key string: %v", err)
}
pubKey, err := jwt.ParseRSAPublicKeyFromPEM(pk)
if err != nil {
log.Fatalf("error parsing Public Key: %v", err)
}

token, err := jwt.ParseWithClaims(*out.Signature, &claims{}, jwt.KnownKeyfunc(jwt.SigningMethodPS256, pubKey))
if err != nil {
log.Fatalf("error parsing the JWT token: %v", err)
}

if claims, ok := token.Claims.(*claims); ok && token.Valid {
if claims.ProductCode != productCode || claims.PublicKeyVersion != pubKeyVersion || claims.Nonce != nonce {
log.Fatal("the claims in the JWT token don't match the request")
}
} else {
log.Fatal("something is wrong with the JWT token")
}
}

type claims struct {
ProductCode string `json:"productCode,omitempty"`
PublicKeyVersion int32 `json:"publicKeyVersion,omitempty"`
IssuedAt *jwt.Time `json:"iat,omitempty"`
Nonce string `json:"nonce,omitempty"`
}

func (c claims) Valid(h *jwt.ValidationHelper) error {
if c.Nonce == "" {
return &jwt.InvalidClaimsError{Message: "the JWT token doesn't include the Nonce"}
}
if c.ProductCode == "" {
return &jwt.InvalidClaimsError{Message: "the JWT token doesn't include the ProductCode"}
}
if c.PublicKeyVersion == 0 {
return &jwt.InvalidClaimsError{Message: "the JWT token doesn't include the PublicKeyVersion"}
}
if err := h.ValidateNotBefore(c.IssuedAt); err != nil {
return err
}

return nil
}

func generateRandomString(n int) (string, error) {
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-"
ret := make([]byte, n)
for i := 0; i < n; i++ {
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
if err != nil {
return "", err
}
ret[i] = letters[num.Int64()]
}

return string(ret), nil
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ module github.com/nginxinc/kubernetes-ingress
go 1.16

require (
github.com/aws/aws-sdk-go-v2/config v1.3.0
github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.3.1
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1
github.com/emicklei/go-restful v2.15.0+incompatible // indirect
github.com/go-openapi/spec v0.20.3 // indirect
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b
Expand Down
23 changes: 23 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,26 @@ github.com/aws/aws-sdk-go v1.25.37/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpi
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go v1.36.30/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/aws/aws-sdk-go-v2 v1.6.0 h1:r20hdhm8wZmKkClREfacXrKfX0Y7/s0aOoeraFbf/sY=
github.com/aws/aws-sdk-go-v2 v1.6.0/go.mod h1:tI4KhsR5VkzlUa2DZAdwx7wCAYGwkZZ1H31PYrBFx1w=
github.com/aws/aws-sdk-go-v2/config v1.3.0 h1:0JAnp0WcsgKilFLiZEScUTKIvTKa2LkicadZADza+u0=
github.com/aws/aws-sdk-go-v2/config v1.3.0/go.mod h1:lOxzHWDt/k7MMidA/K8DgXL4+ynnZYsDq65Qhs/l3dg=
github.com/aws/aws-sdk-go-v2/credentials v1.2.1 h1:AqQ8PzWll1wegNUOfIKcbp/JspTbJl54gNonrO6VUsY=
github.com/aws/aws-sdk-go-v2/credentials v1.2.1/go.mod h1:Rfvim1eZTC9W5s8YJyYYtl1KMk6e8fHv+wMRQGO4Ru0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.1.1 h1:w1ocBIhQkLgupEB3d0uOuBddqVYl0xpubz7HSTzWG8A=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.1.1/go.mod h1:GTXAhrxHQOj9N+J5tYVjwt+rpRyy/42qLjlgw9pz1a0=
github.com/aws/aws-sdk-go-v2/internal/ini v1.0.0 h1:k7I9E6tyVWBo7H9ffpnxDWudtjau6Qt9rnOYgV+ciEQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.0.0/go.mod h1:g3XMXuxvqSMUjnsXXp/960152w0wFS4CXVYgQaSVOHE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.1.1 h1:l7pDLsmOGrnR8LT+3gIv8NlHpUhs7220E457KEC2UM0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.1.1/go.mod h1:2+ehJPkdIdl46VCj67Emz/EH2hpebHZtaLdzqg+sWOI=
github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.3.1 h1:0rWDX2ToWh38xFUWAM+Mz2jcPfSXELfVYaaR0mYfnz4=
github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.3.1/go.mod h1:K0qzQa0qdpOqiA2tpL9XbSZXKnE95GJSHJj9YLsf7Zs=
github.com/aws/aws-sdk-go-v2/service/sso v1.2.1 h1:alpXc5UG7al7QnttHe/9hfvUfitV8r3w0onPpPkGzi0=
github.com/aws/aws-sdk-go-v2/service/sso v1.2.1/go.mod h1:VimPFPltQ/920i1X0Sb0VJBROLIHkDg2MNP10D46OGs=
github.com/aws/aws-sdk-go-v2/service/sts v1.4.1 h1:9Z00tExoaLutWVDmY6LyvIAcKjHetkbdmpRt4JN/FN0=
github.com/aws/aws-sdk-go-v2/service/sts v1.4.1/go.mod h1:G9osDWA52WQ38BDcj65VY1cNmcAQXAXTsE8IWH8j81w=
github.com/aws/smithy-go v1.4.0 h1:3rsQpgRe+OoQgJhEwGNpIkosl0fJLdmQqF4gSFRjg+4=
github.com/aws/smithy-go v1.4.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
Expand Down Expand Up @@ -153,7 +173,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denis-tingajkin/go-header v0.4.2 h1:jEeSF4sdv8/3cT/WY8AgDHUoItNSoEZ7qg9dX7pc218=
github.com/denis-tingajkin/go-header v0.4.2/go.mod h1:eLRHAVXzE5atsKAnNRDB90WHCFFnBUn4RN0nRcs1LJA=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbYd8tQGRWacE9kU=
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
Expand Down