diff --git a/.github/workflows/_build.yaml b/.github/workflows/_build.yaml index e707980..2525d1d 100644 --- a/.github/workflows/_build.yaml +++ b/.github/workflows/_build.yaml @@ -37,21 +37,51 @@ jobs: uses: actions/setup-go@v5 with: go-version-file: go.mod + cache: false + + - name: Download dependencies + run: go mod download -x + + - name: Run vet + run: go vet ./... + + - name: Run tests + run: go test ./... - name: Setup golangci-lint uses: golangci/golangci-lint-action@v3 with: version: latest + args: --timeout 1m --verbose - - name: Run tests - run: go test ./... + publish: + needs: build + if: inputs.publish + runs-on: self-hosted - - name: Run vet - run: go vet ./... + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + show-progress: false + + - uses: axatol/actions/assume-aws-role@release + with: + aws-region: us-east-1 + role-to-assume: ${{ secrets.AWS_ECR_IMAGE_PUBLISHER_ROLE_ARN }} + + - name: Login to ECR + uses: aws-actions/amazon-ecr-login@v2 + with: + mask-password: true - name: Build - run: make image + run: make build-image IMAGE_TAG=${{ inputs.image-tag }} - name: Publish - if: inputs.publish - run: docker push axatol/external-dns-cloudflare-tunnel-webhook:${{ inputs.image-tag }} + run: make publish-image IMAGE_TAG=${{ inputs.image-tag }} + + - name: Prune ECR + uses: axatol/actions/prune-ecr-repository@release + with: + repository-name: external-dns-cloudflare-tunnel-webhook diff --git a/.github/workflows/on-push-master.yaml b/.github/workflows/on-push-master.yaml index 8cafbf3..c840620 100644 --- a/.github/workflows/on-push-master.yaml +++ b/.github/workflows/on-push-master.yaml @@ -1,9 +1,12 @@ name: On push to master on: - pull_request: + push: branches: - master + paths-ignore: + - "**/*.md" + - ".github/**" jobs: build: diff --git a/Dockerfile b/Dockerfile index 0692ca1..2398f71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ COPY . . ARG BUILD_COMMIT=unknown ARG BUILD_TIME=unknown RUN --mount=type=cache,target=/root/.cache/go-build \ - make build BUILD_COMMIT=${BUILD_COMMIT} BUILD_TIME=${BUILD_TIME} OUTPUT=/go/bin/app + make build-binary BUILD_COMMIT=${BUILD_COMMIT} BUILD_TIME=${BUILD_TIME} OUTPUT=/go/bin/app FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /go/bin/app /app diff --git a/Makefile b/Makefile index 6fcbcbd..f489c7b 100644 --- a/Makefile +++ b/Makefile @@ -2,20 +2,23 @@ OUTPUT ?= dist/app BUILD_TIME ?= $(shell date +"%Y-%m-%dT%H:%M:%S%z") BUILD_COMMIT ?= $(shell git rev-parse HEAD) IMAGE_PLATFORM ?= linux/amd64 -IMAGE_NAME ?= axatol/external-dns-cloudflare-tunnel-webhook +IMAGE_NAME ?= public.ecr.aws/axatol/external-dns-cloudflare-tunnel-webhook IMAGE_TAG ?= latest LDFLAGS = -s -w LDFLAGS += -X main.buildTime=$(BUILD_TIME) LDFLAGS += -X main.buildCommit=$(BUILD_COMMIT) -build: +build-binary: CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(OUTPUT) -image: +build-image: docker build \ --tag $(IMAGE_NAME):$(IMAGE_TAG) \ --platform $(IMAGE_PLATFORM) \ --build-arg BUILD_COMMIT=$(BUILD_COMMIT) \ --build-arg BUILD_TIME=$(BUILD_TIME) \ . + +publish-image: + docker push $(IMAGE_NAME):$(IMAGE_TAG) diff --git a/README.md b/README.md index c26ac22..b5169e0 100644 --- a/README.md +++ b/README.md @@ -1 +1,84 @@ -# external-dns-cloudflare-tunnel-webhook \ No newline at end of file +# external-dns-cloudflare-tunnel-webhook + +> [!WARNING] +> This provider is experimental + +This is a provider for use with [external-dns](https://github.com/kubernetes-sigs/external-dns) via the webhook mechanism. It provides the ability to create public hostnames and backing DNS records for Cloudflare Tunnels. + +> [!NOTE] +> Due to limitations of the external-dns webhook mechanism and my lack of brainpower, this provider only supports backing a single tunnel. To support more tunnels, deploy more instances of this provider. + +## Deploying + +You will need: + +- A Kubernetes cluster +- Helm CLI installed +- A Cloudflare account with some form of authorization with scopes + - All accounts - Cloudflare Tunnel:Edit + - All zones - DNS:Edit + +Ensure you have a secret with your Cloudflare credentials. + +```shell +kubectl create secret generic cloudflare-credentials --from-literal=CLOUDFLARE_API_TOKEN=blah +``` + +Create a values file, see below for a minimum config. + +```shell +cat < ./values.yaml +logLevel: info +logFormat: json +interval: 1h +provider: + name: webhook + webhook: + image: + repository: docker.io/axatol/external-dns-cloudflare-tunnel-webhook + tag: latest + env: + - name: CLOUDFLARE_API_TOKEN + valueFrom: + secretKeyRef: + name: cloudflare-credentials + key: CLOUDFLARE_API_TOKEN +EOF +``` + +Install the external-dns chart. + +```shell +helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/ +helm repo update +helm upgrade external-dns-cloudflare-tunnel external-dns/external-dns \ + --install \ + --atomic \ + --create-namespace \ + --namespace external-dns \ + --values ./values.yaml +``` + +## Configuration + +| Environment variable | Type | Default | Notes | +| ----------------------- | --------------- | ------------------ | ----- | +| `LOG_LEVEL` | `string` | `"info"` | | +| `LOG_FORMAT` | `string` | `"json"` | | +| `CLOUDFLARE_API_KEY` | `string` | `""` | ^1 | +| `CLOUDFLARE_API_EMAIL` | `string` | `""` | ^1 | +| `CLOUDFLARE_API_TOKEN` | `string` | `""` | ^1 | +| `CLOUDFLARE_ACCOUNT_ID` | `string` | | ^2 | +| `CLOUDFLARE_TUNNEL_ID` | `string` | | ^2 | +| `CLOUDFLARE_SYNC_DNS` | `bool` | `"false"` | | +| `PORT` | `int64` | `"8888"` | | +| `READ_TIMEOUT` | `time.Duration` | `"5s"` | | +| `WRITE_TIMEOUT` | `time.Duration` | `"10s"` | | +| `DRY_RUN` | `bool` | `"false"` | | +| `DOMAIN_FILTER` | `[]string` | `"" delimiter:","` | ^3 | + +1. Must specify: + - _both_ `CLOUDFLARE_API_KEY` and `CLOUDFLARE_API_EMAIL` + - _or_ `CLOUDFLARE_API_TOKEN` +2. Required field +3. Specify multiple by delimiting with `,` diff --git a/pkg/provider/records.go b/pkg/provider/records.go index 9040530..dd3129e 100644 --- a/pkg/provider/records.go +++ b/pkg/provider/records.go @@ -164,7 +164,7 @@ func ApplyChanges(ctx context.Context, cf cf.Cloudflare, changes []Change) error Type: endpoint.RecordTypeCNAME, TTL: 1, Proxied: cloudflare.BoolPtr(true), - Comment: fmt.Sprintf("external-dns-cloudflare-tunnel-webhook/%s", change.Service), + Comment: change.Service, } switch change.Action { diff --git a/pkg/server/server.go b/pkg/server/server.go index 6259aa3..2980f5e 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -42,15 +42,13 @@ func handleNegotiation(p provider.Provider) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { raw, err := json.Marshal(p.GetDomainFilter()) if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(http.StatusText(http.StatusInternalServerError))) + write(w, http.StatusInternalServerError, nil) return } log.Debug().Str("action", "handleNegotiation").RawJSON("domain_filter", raw).Send() w.Header().Set(contentTypeHeader, externalDNSMediaType) - w.WriteHeader(http.StatusOK) - w.Write(raw) + write(w, http.StatusOK, raw) } } @@ -61,23 +59,20 @@ func handleGetRecords(p provider.Provider) http.HandlerFunc { records, err := p.Records(r.Context()) if err != nil { log.Error().Err(err).Msg("failed to get records") - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(http.StatusText(http.StatusInternalServerError))) + write(w, http.StatusInternalServerError, nil) return } raw, err := json.Marshal(records) if err != nil { log.Error().Err(err).Msg("failed to marshal records to json") - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(http.StatusText(http.StatusInternalServerError))) + write(w, http.StatusInternalServerError, nil) return } log.Debug().RawJSON("records", raw).Send() w.Header().Set(contentTypeHeader, externalDNSMediaType) - w.WriteHeader(http.StatusOK) - w.Write(raw) + write(w, http.StatusOK, raw) } } @@ -89,20 +84,18 @@ func handleApplyChanges(p provider.Provider) http.HandlerFunc { if err := json.NewDecoder(r.Body).Decode(&changes); err != nil { log.Error().Err(err).Msg("failed to decode changes") w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(http.StatusText(http.StatusBadRequest))) + write(w, http.StatusBadRequest, nil) return } if err := p.ApplyChanges(r.Context(), &changes); err != nil { log.Error().Err(err).Any("changes", changes).Msg("failed to apply changes") - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(http.StatusText(http.StatusInternalServerError))) + write(w, http.StatusInternalServerError, nil) return } log.Debug().Any("changes", changes).Send() - w.WriteHeader(http.StatusNoContent) - w.Write([]byte(http.StatusText(http.StatusNoContent))) + write(w, http.StatusNoContent, nil) } } @@ -113,30 +106,26 @@ func handleAdjustEndpoints(p provider.Provider) http.HandlerFunc { var endpoints []*endpoint.Endpoint if err := json.NewDecoder(r.Body).Decode(&endpoints); err != nil { log.Error().Err(err).Msg("failed to decode endpoints") - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(http.StatusText(http.StatusBadRequest))) + write(w, http.StatusBadRequest, nil) return } endpoints, err := p.AdjustEndpoints(endpoints) if err != nil { log.Error().Err(err).Any("endpoints", endpoints).Msg("failed to adjust endpoints") - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(http.StatusText(http.StatusInternalServerError))) + write(w, http.StatusInternalServerError, nil) return } raw, err := json.Marshal(endpoints) if err != nil { log.Error().Err(err).Any("endpoints", endpoints).Msg("failed to marshal endpoints to json") - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(http.StatusText(http.StatusInternalServerError))) + write(w, http.StatusInternalServerError, nil) return } log.Debug().Any("endpoints", endpoints).Send() w.Header().Set(contentTypeHeader, externalDNSMediaType) - w.WriteHeader(http.StatusOK) - w.Write(raw) + write(w, http.StatusOK, raw) } } diff --git a/pkg/server/util.go b/pkg/server/util.go new file mode 100644 index 0000000..e77b1dc --- /dev/null +++ b/pkg/server/util.go @@ -0,0 +1,21 @@ +package server + +import ( + "fmt" + "net/http" + + "github.com/rs/zerolog/log" +) + +func write(w http.ResponseWriter, status int, body []byte) { + w.WriteHeader(status) + + raw := []byte(http.StatusText(status)) + if body != nil { + raw = body + } + + if _, err := w.Write(raw); err != nil { + log.Error().Err(fmt.Errorf("failed to write response: %s", err)).Send() + } +}