diff --git a/README.md b/README.md index ed0d881..59a55fe 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Deploy your application from a CI/CD pipeline via `cURL` + JWT auth. ``` -$ curl -s -H "Authorization: bearer abc..." https://example.com/your/rollout/path +$ curl -s -d '{"git-branch": "main"}' -H "Authorization: bearer abc..." https://example.com/your/rollout/path Rollout complete ``` @@ -13,7 +13,7 @@ Instead of managing SSH keys in your CI/CD for accounts that have privileged acc Requires creating a JWT from your CI provider, and sending that token to this service running in your deployment environment to trigger a deployment script. -Also requires a `rollout.sh` script that can handle all the command needing ran to rollout your software. +Also requires a `rollout.sh` script that can handle all the commands needing ran to rollout your software. ## Install @@ -27,9 +27,11 @@ $ docker run \ rollout:latest ``` +You should then proxy that port with some service that can handle TLS for you. + ## OIDC Provider examples -This service requires two envionrment variables. +This service requires two environment variables. - `JWKS_URI` - the URL of the OIDC Provider's [JSON Web Key (JWK) set document](https://www.rfc-editor.org/info/rfc7517). This is used to ensure the JWT was signed by the provider. - `JWT_AUD` - the audience set in the JWT token. @@ -40,6 +42,35 @@ This service requires two envionrment variables. - `ROLLOUT_CMD` (default: `/bin/bash`) - the command to execute a rollout - `ROLLOUT_ARGS` (default: `/rollout.sh` ) - the args to pass to `ROLLOUT_CMD` +## Dynamic environment variables for ROLLOUT_CMD + +There are a few environment variables you can make available to your rollout command. + +These environment variables can be passed to the cURL command when rolling out your changes. + +For example, if you want your rollout script to have the git repo and branch that is being deployed you can pass that in the rollout cURL call as seen below. Doing so will make an environment variable `$GIT_REPO` and `$GIT_BRANCH` available in your rollout script. + +``` +$ curl -s \ + -H "Authorization: bearer abc..." \ + -d '{"git-repo": "git@github.com:lehigh-university-libraries/rollout.git", "git-branch": "main"}' \ + https://example.com/your/rollout/path +``` + +These are the environment variables currently supported, keyed by their respective JSON key name: + +| JSON Key | Env Var Name | Example JSON to send | +|----------------|---------------| ------------------------------------- +| `docker-image` | `DOCKER_IMAGE`| `{"docker-image": "foo/bar:latest"}` | +| `docker-tag` | `DOCKER_TAG` | `{"docker-tag": "latest"}` | +| `git-repo` | `GIT_REPO` | `{"git-repo": "foo/bar:latest"}` | +| `git-branch` | `GIT_BRANCH` | `{"git-branch": "main"}` | +| `rollout-arg1` | `ROLLOUT_ARG1`| `{"rollout-arg1": "FOO"}` | +| `rollout-arg2` | `ROLLOUT_ARG2`| `{"rollout-arg2": "BAR"}` | +| `rollout-arg3` | `ROLLOUT_ARG3`| `{"rollout-arg3": "BAZ"}` | + +If there is key/env var name that is generic enough that it warrants its own placeholder, it can be added by submitting an issue or a PR. Otherwise, use the general ARG variables. + ### GitHub ``` diff --git a/main.go b/main.go index ae3a5aa..1019946 100644 --- a/main.go +++ b/main.go @@ -9,15 +9,27 @@ import ( "net/http" "os" "os/exec" + "reflect" + "regexp" "github.com/golang-jwt/jwt/v5" "github.com/google/shlex" "github.com/lestrrat-go/jwx/jwk" ) +type RolloutPayload struct { + DockerImage string `json:"docker-image" env:"DOCKER_IMAGE"` + DockerTag string `json:"docker-tag" env:"DOCKER_TAG"` + GitRepo string `json:"git-repo" env:"GIT_REPO"` + GitBranch string `json:"git-branch" env:"GIT_BRANCH"` + Arg1 string `json:"rollout-arg1" env:"ROLLOUT_ARG1"` + Arg2 string `json:"rollout-arg2" env:"ROLLOUT_ARG2"` + Arg3 string `json:"rollout-arg3" env:"ROLLOUT_ARG3"` +} + func init() { - // call getArgs early to fail on a bad config - getArgs() + // call getRolloutCmdArgs early to fail on a bad config + getRolloutCmdArgs() } func main() { @@ -88,11 +100,18 @@ func Rollout(w http.ResponseWriter, r *http.Request) { return } + err = setCustomArgs(r) + if err != nil { + slog.Error("Error setting custom args", "err", err) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + name := os.Getenv("ROLLOUT_CMD") if name == "" { name = "/bin/bash" } - cmd := exec.Command(name, getArgs()...) + cmd := exec.Command(name, getRolloutCmdArgs()...) var stdOut, stdErr bytes.Buffer cmd.Stdout = &stdOut @@ -132,7 +151,7 @@ func ParseToken(token *jwt.Token) (interface{}, error) { jwksUri := os.Getenv("JWKS_URI") jwksSet, err := jwk.Fetch(ctx, jwksUri) if err != nil { - return nil, fmt.Errorf("Unable to fetch JWK set from %s: %v", jwksUri, err) + return nil, fmt.Errorf("unable to fetch JWK set from %s: %v", jwksUri, err) } // Find the appropriate key in JWKS key, ok := jwksSet.LookupKeyID(kid) @@ -166,7 +185,7 @@ func strInSlice(e string, s []string) bool { return false } -func getArgs() []string { +func getRolloutCmdArgs() []string { args := os.Getenv("ROLLOUT_ARGS") if args == "" { args = "/rollout.sh" @@ -179,3 +198,54 @@ func getArgs() []string { return rolloutArgs } + +func setCustomArgs(r *http.Request) error { + if r.Method == "GET" { + return nil + } + + var payload RolloutPayload + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&payload) + if err != nil { + return err + } + + err = setEnvFromStruct(&payload) + if err != nil { + return err + } + + return nil +} + +func setEnvFromStruct(data interface{}) error { + regex, err := regexp.Compile(`^[a-zA-Z0-9._\-:\/@]+$`) + if err != nil { + return fmt.Errorf("failed to compile regex: %v", err) + } + + v := reflect.ValueOf(data) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + if envTag, ok := field.Tag.Lookup("env"); ok { + // For now all fields are strings + value := v.Field(i).String() + if value == "" { + continue + } + if !regex.MatchString(value) { + return fmt.Errorf("invalid input for environment variable %s:%s", envTag, value) + } + if err := os.Setenv(envTag, value); err != nil { + return fmt.Errorf("could not set environment variable %s: %v", envTag, err) + } + } + } + return nil +} diff --git a/main_test.go b/main_test.go index c1cfd5c..95d9581 100644 --- a/main_test.go +++ b/main_test.go @@ -6,11 +6,13 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "log/slog" "math/big" "net/http" "net/http/httptest" "os" + "strings" "testing" "time" @@ -23,6 +25,16 @@ var ( privateKey *rsa.PrivateKey ) +type Test struct { + name string + authHeader string + expectedStatus int + expectedBody string + cmdArgs string + method string + payload string +} + // createJWKS creates a JWKS JSON representation with a single RSA key. func mockJWKS(pub *rsa.PublicKey, kid string) (string, error) { jwks := struct { @@ -130,14 +142,14 @@ func CreateSignedJWT(kid, aud, claim string, exp int64, privateKey *rsa.PrivateK } // Utility function to create a request with an Authorization header -func createRequest(authHeader string) *http.Request { - req, _ := http.NewRequest("GET", "/", nil) +func createRequest(authHeader, method string, body io.Reader) *http.Request { + req, _ := http.NewRequest(method, "/", body) req.Header.Set("Authorization", authHeader) return req } // TestRollout tests the Rollout function with various scenarios -func TestRollout(t *testing.T) { +func TestRolloutAuth(t *testing.T) { testFile := "/tmp/rollout-test.txt" // have our test rollout cmd just touch a file @@ -196,14 +208,7 @@ func TestRollout(t *testing.T) { t.Fatalf("Unable to create a JWT with our test key: %v", err) } - tests := []struct { - name string - authHeader string - expectedStatus int - expectedBody string - claim map[string]string - cmdArgs string - }{ + tests := []Test{ { name: "No Authorization Header", authHeader: "", @@ -269,7 +274,7 @@ func TestRollout(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { recorder := httptest.NewRecorder() - request := createRequest(tt.authHeader) + request := createRequest(tt.authHeader, "GET", nil) if tt.name == "No custom claim" { os.Setenv("CUSTOM_CLAIMS", "") } else { @@ -285,7 +290,6 @@ func TestRollout(t *testing.T) { assert.Equal(t, tt.expectedBody, recorder.Body.String()) }) } - testFiles := []string{ testFile, "/tmp/rollout-shlex-test", @@ -308,6 +312,161 @@ func TestRollout(t *testing.T) { } } +func TestRolloutCmdArgs(t *testing.T) { + os.Setenv("ROLLOUT_CMD", "/bin/bash") + s := createMockJwksServer() + defer s.Close() + + // get a valid token + exp := time.Now().Add(time.Hour * 1).Unix() + jwtToken, err := CreateSignedJWT(kid, aud, claim, exp, privateKey) + if err != nil { + t.Fatalf("Unable to create a JWT with our test key: %v", err) + } + + payloads := map[string]string{ + "docker-image": "us-docker.pkg.dev-project-interal-image:latest", + "docker-tag": "rollout-docker-tag-test", + "git-branch": "rollout-git-branch-test", + "git-repo": "git@github.com:lehigh-university-libraries-rollout.git", + "rollout-arg1": "rollout-arg1-test", + "rollout-arg2": "rollout-arg2-test", + "rollout-arg3": "rollout-arg3-test", + } + for k, v := range payloads { + var e string + switch k { + case "docker-image": + e = "DOCKER_IMAGE" + case "docker-tag": + e = "DOCKER_TAG" + case "git-branch": + e = "GIT_BRANCH" + case "git-repo": + e = "GIT_REPO" + case "rollout-arg1": + e = "ROLLOUT_ARG1" + case "rollout-arg2": + e = "ROLLOUT_ARG2" + case "rollout-arg3": + e = "ROLLOUT_ARG3" + } + tt := Test{ + name: fmt.Sprintf("%s custom arg passes to rollout.sh", k), + authHeader: "Bearer " + jwtToken, + expectedStatus: http.StatusOK, + cmdArgs: fmt.Sprintf(`-c "touch /tmp/$%s"`, e), + method: "POST", + payload: fmt.Sprintf(`{"%s": "%s"}`, k, v), + expectedBody: "Rollout complete\n", + } + t.Run(tt.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + method := "POST" + body := strings.NewReader(tt.payload) + request := createRequest(tt.authHeader, method, body) + os.Setenv("ROLLOUT_ARGS", tt.cmdArgs) + + Rollout(recorder, request) + + assert.Equal(t, tt.expectedStatus, recorder.Code) + assert.Equal(t, tt.expectedBody, recorder.Body.String()) + }) + } + + for _, v := range payloads { + f := "/tmp/" + v + // make sure the rollout command actually ran the command + // which creates the file + _, err = os.Stat(f) + if err != nil && os.IsNotExist(err) { + t.Errorf("The successful test did not create the expected file %s", f) + } + + // cleanup + err = RemoveFileIfExists(f) + if err != nil { + slog.Error("Unable to cleanup test file", "file", f, "err", err) + os.Exit(1) + } + } +} + +func TestBadRolloutCmdArgs(t *testing.T) { + os.Setenv("ROLLOUT_CMD", "/bin/bash") + s := createMockJwksServer() + defer s.Close() + + // get a valid token + exp := time.Now().Add(time.Hour * 1).Unix() + jwtToken, err := CreateSignedJWT(kid, aud, claim, exp, privateKey) + if err != nil { + t.Fatalf("Unable to create a JWT with our test key: %v", err) + } + + payloads := []string{ + `{"rollout-arg1": "any;thing"}`, + `{"rollout-arg1": "any&thing"}`, + `{"rollout-arg1": "any|thing"}`, + `{"rollout-arg1": "any$thing"}`, + `{"rollout-arg1": "any\"thing"}`, + `{"rollout-arg1": "any\thing"}`, + `{"rollout-arg1": "any*thing"}`, + `{"rollout-arg1": "any?thing"}`, + `{"rollout-arg1": "any[thing"}`, + `{"rollout-arg1": "any]thing"}`, + `{"rollout-arg1": "any{thing"}`, + `{"rollout-arg1": "any}thing"}`, + `{"rollout-arg1": "any(thing"}`, + `{"rollout-arg1": "any)thing"}`, + `{"rollout-arg1": "anything"}`, + `{"rollout-arg1": "anything!"}`, + "{\"rollout-arg1\": \"any`thing\"}", + } + for _, payload := range payloads { + tt := Test{ + name: "Bad custom arg doesn't pass to rollout.sh", + authHeader: "Bearer " + jwtToken, + expectedStatus: http.StatusBadRequest, + cmdArgs: `-c "touch /tmp/$ROLLOUT_ARG1"`, + method: "POST", + payload: payload, + expectedBody: "Bad request\n", + } + t.Run(tt.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + method := "POST" + body := strings.NewReader(tt.payload) + request := createRequest(tt.authHeader, method, body) + os.Setenv("ROLLOUT_ARGS", tt.cmdArgs) + + Rollout(recorder, request) + + assert.Equal(t, tt.expectedStatus, recorder.Code) + assert.Equal(t, tt.expectedBody, recorder.Body.String()) + }) + } + + for _, v := range payloads { + f := "/tmp/" + v + // make sure the rollout command didn't run the command + // which creates the file + _, err = os.Stat(f) + if err != nil && os.IsNotExist(err) { + continue + } + t.Errorf("The test created a bad file name. Check sanitizing inputs to catch %s", f) + + // cleanup + err = RemoveFileIfExists(f) + if err != nil { + slog.Error("Unable to cleanup test file", "file", f, "err", err) + os.Exit(1) + } + } +} + func RemoveFileIfExists(filePath string) error { _, err := os.Stat(filePath) if err == nil {