From 10fd7f79ed1669a2fae5d14ac2f623dcac92dd92 Mon Sep 17 00:00:00 2001 From: Sam Dowell Date: Thu, 28 Sep 2023 15:55:42 -0700 Subject: [PATCH] new askpass sidecar (#896) (#905) * initial implemetation of the askpass logic * wip: makefile updates * found the missing bit * local askpass img in the reconciler mgr * linting issues * cloud metadata library addition * pr feedback Co-authored-by: Mike Borozdin --- Makefile | 3 + Makefile.build | 14 +++ build/all/Dockerfile | 12 ++- cmd/gcenode-askpass-sidecar/main.go | 102 ++++++++++++++++++ go.mod | 2 +- .../reconciler-manager-configmap.yaml | 2 +- pkg/askpass/askpass.go | 93 ++++++++++++++++ pkg/askpass/askpass_test.go | 65 +++++++++++ 8 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 cmd/gcenode-askpass-sidecar/main.go create mode 100644 pkg/askpass/askpass.go create mode 100644 pkg/askpass/askpass_test.go diff --git a/Makefile b/Makefile index caa8c499ce..9047677ad4 100644 --- a/Makefile +++ b/Makefile @@ -108,6 +108,7 @@ HYDRATION_CONTROLLER_WITH_SHELL_IMAGE := $(HYDRATION_CONTROLLER_IMAGE)-with-shel OCI_SYNC_IMAGE := oci-sync HELM_SYNC_IMAGE := helm-sync NOMOS_IMAGE := nomos +ASKPASS_IMAGE := gcenode-askpass-sidecar # nomos binary for local run. NOMOS_LOCAL := $(BIN_DIR)/linux_amd64/nomos @@ -146,6 +147,7 @@ HYDRATION_CONTROLLER_WITH_SHELL_GCR := $(REGISTRY)/$(HYDRATION_CONTROLLER_WITH_S OCI_SYNC_GCR := $(REGISTRY)/$(OCI_SYNC_IMAGE) HELM_SYNC_GCR := $(REGISTRY)/$(HELM_SYNC_IMAGE) NOMOS_GCR := $(REGISTRY)/$(NOMOS_IMAGE) +ASKPASS_GCR := $(REGISTRY)/$(ASKPASS_IMAGE) # Full image tags as given on gcr.io RECONCILER_TAG := $(RECONCILER_GCR):$(IMAGE_TAG) RECONCILER_MANAGER_TAG := $(RECONCILER_MANAGER_GCR):$(IMAGE_TAG) @@ -155,6 +157,7 @@ HYDRATION_CONTROLLER_WITH_SHELL_TAG := $(HYDRATION_CONTROLLER_WITH_SHELL_GCR):$( OCI_SYNC_TAG := $(OCI_SYNC_GCR):$(IMAGE_TAG) HELM_SYNC_TAG := $(HELM_SYNC_GCR):$(IMAGE_TAG) NOMOS_TAG := $(NOMOS_GCR):$(IMAGE_TAG) +ASKPASS_TAG := $(ASKPASS_GCR):$(IMAGE_TAG) DOCKER_RUN_ARGS = \ $(DOCKER_INTERACTIVE) \ diff --git a/Makefile.build b/Makefile.build index 33515b4aff..f330680beb 100644 --- a/Makefile.build +++ b/Makefile.build @@ -106,6 +106,13 @@ build-images: install-helm install-kustomize -f build/all/Dockerfile \ --build-arg VERSION=${VERSION} \ . + @echo "+++ Building the Askpass image: $(ASKPASS_TAG)" + @docker buildx build $(DOCKER_BUILD_QUIET) \ + --target $(ASKPASS_IMAGE) \ + -t $(ASKPASS_TAG) \ + -f build/all/Dockerfile \ + --build-arg VERSION=${VERSION} \ + . @echo "+++ Building the Nomos image: $(NOMOS_TAG)" @docker buildx build $(DOCKER_BUILD_QUIET) \ --target $(NOMOS_IMAGE) \ @@ -133,6 +140,7 @@ push-images: docker push $(OCI_SYNC_TAG) docker push $(HELM_SYNC_TAG) docker push $(NOMOS_TAG) + docker push $(ASKPASS_TAG) # Deprecated alias of push-images. Remove this once unused. .PHONY: push-images-multirepo @@ -150,6 +158,7 @@ pull-images: docker pull $(OCI_SYNC_TAG) docker pull $(HELM_SYNC_TAG) docker pull $(NOMOS_TAG) + docker pull $(ASKPASS_TAG) # Deprecated alias of pull-images. Remove this once unused. .PHONY: pull-images-multirepo @@ -167,6 +176,7 @@ retag-images: docker tag $(OLD_REGISTRY)/$(OCI_SYNC_IMAGE):$(OLD_IMAGE_TAG) $(OCI_SYNC_TAG) docker tag $(OLD_REGISTRY)/$(HELM_SYNC_IMAGE):$(OLD_IMAGE_TAG) $(HELM_SYNC_TAG) docker tag $(OLD_REGISTRY)/$(NOMOS_IMAGE):$(OLD_IMAGE_TAG) $(NOMOS_TAG) + docker tag $(OLD_REGISTRY)/$(ASKPASS_IMAGE):$(OLD_IMAGE_TAG) $(ASKPASS_TAG) # Deprecated alias of retag-images. Remove this once unused. .PHONY: retag-images-multirepo @@ -192,6 +202,7 @@ build-manifests-oss: "$(GOBIN)/addlicense" "$(BIN_DIR)/kustomize" $(OUTPUT_DIR) @ echo " $(ADMISSION_WEBHOOK_IMAGE): $(ADMISSION_WEBHOOK_TAG)" @ echo " $(OCI_SYNC_IMAGE): $(OCI_SYNC_TAG)" @ echo " $(HELM_SYNC_IMAGE): $(HELM_SYNC_TAG)" + @ echo " $(ASKPASS_IMAGE): $(ASKPASS_TAG)" @ rm -f $(OSS_MANIFEST_STAGING_DIR)/* @ "$(BIN_DIR)/kustomize" build --load-restrictor=LoadRestrictionsNone manifests/oss \ | sed \ @@ -200,6 +211,7 @@ build-manifests-oss: "$(GOBIN)/addlicense" "$(BIN_DIR)/kustomize" $(OUTPUT_DIR) -e "s|HELM_SYNC_IMAGE_NAME|$(HELM_SYNC_TAG)|g" \ -e "s|HYDRATION_CONTROLLER_IMAGE_NAME|$(HYDRATION_CONTROLLER_TAG)|g" \ -e "s|RECONCILER_MANAGER_IMAGE_NAME|$(RECONCILER_MANAGER_TAG)|g" \ + -e "s|ASKPASS_IMAGE_NAME|$(ASKPASS_TAG)|g" \ > $(OSS_MANIFEST_STAGING_DIR)/config-sync-manifest.yaml @ "$(GOBIN)/addlicense" $(OSS_MANIFEST_STAGING_DIR)/config-sync-manifest.yaml @@ -224,6 +236,7 @@ build-manifests-operator: "$(GOBIN)/addlicense" "$(BIN_DIR)/kustomize" $(OUTPUT_ @ echo " $(ADMISSION_WEBHOOK_IMAGE): $(ADMISSION_WEBHOOK_TAG)" @ echo " $(OCI_SYNC_IMAGE): $(OCI_SYNC_TAG)" @ echo " $(HELM_SYNC_IMAGE): $(HELM_SYNC_TAG)" + @ echo " $(ASKPASS_IMAGE): $(ASKPASS_TAG)" @ rm -f $(NOMOS_MANIFEST_STAGING_DIR)/* @ "$(BIN_DIR)/kustomize" build --load-restrictor=LoadRestrictionsNone manifests/operator \ | sed \ @@ -233,6 +246,7 @@ build-manifests-operator: "$(GOBIN)/addlicense" "$(BIN_DIR)/kustomize" $(OUTPUT_ -e "s|HYDRATION_CONTROLLER_IMAGE_NAME|$(HYDRATION_CONTROLLER_TAG)|g" \ -e "s|RECONCILER_MANAGER_IMAGE_NAME|$(RECONCILER_MANAGER_TAG)|g" \ -e "s|WEBHOOK_IMAGE_NAME|$(ADMISSION_WEBHOOK_TAG)|g" \ + -e "s|ASKPASS_IMAGE_NAME|$(ASKPASS_TAG)|g" \ > $(NOMOS_MANIFEST_STAGING_DIR)/config-sync-manifest.yaml @ "$(GOBIN)/addlicense" $(NOMOS_MANIFEST_STAGING_DIR)/config-sync-manifest.yaml diff --git a/build/all/Dockerfile b/build/all/Dockerfile index d1d8c49da8..fa58f12394 100644 --- a/build/all/Dockerfile +++ b/build/all/Dockerfile @@ -33,7 +33,8 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on \ ./cmd/hydration-controller \ ./cmd/admission-webhook \ ./cmd/oci-sync \ - ./cmd/helm-sync + ./cmd/helm-sync \ + ./cmd/gcenode-askpass-sidecar # Concatenate vendored licenses into LICENSES.txt # Built in the container to include binary licenses (helm & kustomize) @@ -129,6 +130,15 @@ COPY --from=bins /workspace/LICENSES.txt LICENSES.txt USER nonroot:nonroot ENTRYPOINT ["/admission-webhook"] +# Askpass image +FROM gcr.io/distroless/static:nonroot as gcenode-askpass-sidecar +WORKDIR / +COPY --from=bins /go/bin/gcenode-askpass-sidecar gcenode-askpass-sidecar +COPY --from=bins /workspace/LICENSE LICENSE +COPY --from=bins /workspace/LICENSES.txt LICENSES.txt +USER nonroot:nonroot +ENTRYPOINT ["/gcenode-askpass-sidecar"] + # Nomos image # Not used by Config Sync backend components. Intended for use cases with the # nomos CLI (e.g. containerized CI/CD) diff --git a/cmd/gcenode-askpass-sidecar/main.go b/cmd/gcenode-askpass-sidecar/main.go new file mode 100644 index 0000000000..456d56d21e --- /dev/null +++ b/cmd/gcenode-askpass-sidecar/main.go @@ -0,0 +1,102 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package main + +import ( + "flag" + "fmt" + "net/http" + + "cloud.google.com/go/compute/metadata" + "k8s.io/klog/v2/klogr" + "kpt.dev/configsync/pkg/askpass" + "kpt.dev/configsync/pkg/util" + utillog "kpt.dev/configsync/pkg/util/log" +) + +// all the flags and their usage. +var flHelp = flag.Bool("help", + false, + "print help and usage information") + +var flPort = flag.Int("port", + util.EnvInt("ASKPASS_PORT", 9102), + "port to listen on") + +var flGsaEmail = flag.String("email", + util.EnvString("GSA_EMAIL", ""), + "Google Service Account for authentication") + +var flErrorFile = flag.String("error-file", + util.EnvString("ASKPASS_ERROR_FILE", ""), + "the name of a file into which errors will be written defaults to \"\", disabling error reporting") + +var flRoot = flag.String("root", + util.EnvString("ASKPASS_ROOT", util.EnvString("HOME", "")+"/askpass"), + "the root directory for askpass") + +// main function is designed only to deal with the environment. i.e. parse +// user input and make sure we can set up a server. All the logic outside +// of OS and network interractions should be in the package with the logic +// for askpass +func main() { + // if people are looking for help we are not going to launch anything + // assuming that they didn't mean to start the askpass process. + if *flHelp { + flag.Usage() + return + } + + utillog.Setup() + log := utillog.NewLogger(klogr.New(), *flRoot, *flErrorFile) + + log.Info("starting askpass with arguments", "--port", *flPort, + "--email", *flGsaEmail, "--error-file", *flErrorFile, "--root", *flRoot) + + if *flPort == 0 { + utillog.HandleError(log, true, + "ERROR: port can not be zero") + } + + if *flRoot == "" { + utillog.HandleError(log, true, "root cannot be empty") + } + + var gsaEmail string + var err error + // for getting the GSA email we have several scenarios + // the first one is that the user provides it to us. + // the second scenario is that it's not provided but we can get the + // Compute Engine default service account from the metadata server. + if *flGsaEmail != "" { + gsaEmail = *flGsaEmail + } else if metadata.OnGCE() { + gsaEmail, err = metadata.Email("") + if err != nil { + utillog.HandleError(log, false, "error in http.ListenAndServe: %v", err) + } + } else { + utillog.HandleError(log, true, + "ERROR: GSA email can not be empty") + } + + aps := &askpass.Server{ + Email: gsaEmail, + } + http.HandleFunc("/git_askpass", aps.GitAskPassHandler) + + if err := http.ListenAndServe(fmt.Sprintf(":%d", *flPort), nil); err != nil { + utillog.HandleError(log, false, "error in http.ListenAndServe: %v", err) + } +} diff --git a/go.mod b/go.mod index 60ff5faa00..5772eb99f8 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module kpt.dev/configsync go 1.20 require ( + cloud.google.com/go/compute/metadata v0.2.3 cloud.google.com/go/monitoring v1.8.0 cloud.google.com/go/trace v1.4.0 contrib.go.opencensus.io/exporter/ocagent v0.7.0 @@ -62,7 +63,6 @@ require ( require ( cloud.google.com/go/compute v1.18.0 // indirect - cloud.google.com/go/compute/metadata v0.2.3 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/BurntSushi/toml v1.0.0 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect diff --git a/manifests/templates/reconciler-manager-configmap.yaml b/manifests/templates/reconciler-manager-configmap.yaml index ceb7042758..cfe5de33df 100644 --- a/manifests/templates/reconciler-manager-configmap.yaml +++ b/manifests/templates/reconciler-manager-configmap.yaml @@ -130,7 +130,7 @@ data: cpu: "10m" memory: "200Mi" - name: gcenode-askpass-sidecar - image: gcr.io/config-management-release/gcenode-askpass-sidecar:v1.0.5 + image: ASKPASS_IMAGE_NAME args: ["--port=9102", "--logtostderr"] imagePullPolicy: IfNotPresent terminationMessagePolicy: File diff --git a/pkg/askpass/askpass.go b/pkg/askpass/askpass.go new file mode 100644 index 0000000000..0f35e6a2de --- /dev/null +++ b/pkg/askpass/askpass.go @@ -0,0 +1,93 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package askpass is designed to be used in the askpass sidecar +// to provide GSA authentication services. +package askpass + +import ( + "context" + "fmt" + "net/http" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "k8s.io/klog/v2" +) + +// Server contains server wide state and settings for the askpass sidecar +type Server struct { + Email string + token *oauth2.Token +} + +// GitAskPassHandler is the main method for clients to ask us for +// credentials +func (aps *Server) GitAskPassHandler(w http.ResponseWriter, r *http.Request) { + klog.Infof("handling new askpass request from host: %s", r.Host) + + if aps.needNewToken() { + err := aps.retrieveNewToken(r.Context()) + if err != nil { + klog.Error(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } else { + klog.Infof("reusing existing oauth2 token, type: %s, expiration: %v", + aps.token.TokenType, aps.token.Expiry) + } + + // this this point we should be equipped with all the credentials + // and it's just a matter of sending it back to the caller. + password := aps.token.AccessToken + w.WriteHeader(http.StatusOK) + if _, err := fmt.Fprintf(w, "username=%s\npassword=%s", aps.Email, password); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + klog.Error(err) + } +} + +// needNewToken will tell us if we have an Oauth2 token that is +// has not expired yet. +func (aps *Server) needNewToken() bool { + if aps.token == nil { + return true + } + + if time.Now().After(aps.token.Expiry) { + return true + } + + return false +} + +// retrieveNewToken will use the default credentials in order to +// fetch to fetch a new token. Note the side effect that the +// server token will be replaced in case of a successful retrieval. +func (aps *Server) retrieveNewToken(ctx context.Context) error { + creds, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/cloud-platform") + if err != nil { + return fmt.Errorf("error calling google.FindDefaultCredentials: %w", err) + } + aps.token, err = creds.TokenSource.Token() + if err != nil { + return fmt.Errorf("error retrieveing TokenSource.Token: %w", err) + } + + klog.Infof("retrieved new Oauth2 token, type: %s, expiration: %v", + aps.token.TokenType, aps.token.Expiry) + return nil +} diff --git a/pkg/askpass/askpass_test.go b/pkg/askpass/askpass_test.go new file mode 100644 index 0000000000..666a530a87 --- /dev/null +++ b/pkg/askpass/askpass_test.go @@ -0,0 +1,65 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package askpass + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "golang.org/x/oauth2" +) + +// GitAskPassHandler performs a basic "smoke test" +// which assumes that we have a token already and we don't +// need to call to any external services to get another one. +func TestCachedToken(t *testing.T) { + req, err := http.NewRequest("GET", "/git_askpass", nil) + if err != nil { + t.Fatal(err) + } + + token := &oauth2.Token{ + AccessToken: "0xBEEFCAFE", + TokenType: "Bearer", + RefreshToken: "0xDEADC0DE", + Expiry: time.Now().Add(time.Second * 30), + } + + aps := &Server{ + Email: "foo@bar.com", + token: token, + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(aps.GitAskPassHandler) + + handler.ServeHTTP(rr, req) + + // Check the status code is what we expect. + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } + + // Check the response body is what we expect. + expected := fmt.Sprintf("username=%s\npassword=%s", aps.Email, token.AccessToken) + if rr.Body.String() != expected { + t.Errorf("handler returned unexpected body: got %v want %v", + rr.Body.String(), expected) + } +}