├── .copywrite.hcl ├── .dockerignore ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── containers │ └── ubuntu │ │ └── fips-build-Dockerfile ├── pull_request_template.md └── workflows │ ├── build.yml │ ├── reusable-get-go-version.yml │ ├── security-scan.yml │ └── test.yml ├── .gitignore ├── .go-version ├── .golangci.yml ├── .release ├── ci.hcl ├── consul-ecs-artifacts.hcl ├── release-metadata.hcl └── security-scan.hcl ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── _docs └── logo.svg ├── awsutil ├── awsutil.go └── awsutil_test.go ├── build-scripts └── version.sh ├── commands.go ├── config ├── config.go ├── config_test.go ├── resources │ ├── test_config.json │ ├── test_config_additional_properties_service.json │ ├── test_config_empty_fields.json │ ├── test_config_missing_fields.json │ ├── test_config_null_nested_fields.json │ ├── test_config_null_top_level_fields.json │ ├── test_config_uppercase_service_names.json │ └── test_extensive_config.json ├── schema.go ├── schema.json ├── types.go ├── types_test.go ├── validate.go └── validate_test.go ├── controller ├── controller.go ├── controller_test.go ├── mocks │ ├── ecs_client.go │ └── sm_client.go ├── policy.go ├── resource.go └── resource_test.go ├── entrypoint └── cmd.go ├── go.mod ├── go.sum ├── hack └── generate-config-reference │ ├── main.go │ ├── preamble.mdx │ ├── properties.tpl │ └── schema.go ├── internal ├── dataplane │ ├── dataplane_config.go │ ├── dataplane_config_test.go │ └── dataplane_json.go ├── dns │ ├── dns.go │ └── dns_test.go └── redirecttraffic │ ├── redirect_traffic.go │ └── redirect_traffic_test.go ├── logging ├── logger.go └── logger_test.go ├── main.go ├── scan.hcl ├── subcommand ├── app-entrypoint │ ├── command_common.go │ ├── command_unix.go │ ├── command_unix_test.go │ └── command_windows.go ├── controller │ ├── command.go │ ├── command_ent_test.go │ └── command_test.go ├── envoy-entrypoint │ ├── command_common.go │ ├── command_unix.go │ ├── command_unix_test.go │ ├── command_windows.go │ └── task-monitor.go ├── health-sync │ ├── checks.go │ ├── checks_test.go │ ├── command.go │ ├── command_test.go │ └── dataplane_monitor.go ├── mesh-init │ ├── checks.go │ ├── checks_test.go │ ├── command.go │ └── command_test.go ├── net-dial │ ├── command.go │ └── command_test.go └── version │ └── command.go ├── testutil ├── aws.go ├── config.go ├── consul.go ├── fake-command.go └── iamauthtest │ ├── arn.go │ ├── responses.go │ └── testing.go └── version ├── fips_build.go ├── non_fips_build.go ├── version.go └── version_test.go /.copywrite.hcl: -------------------------------------------------------------------------------- 1 | schema_version = 1 2 | 3 | project { 4 | license = "MPL-2.0" 5 | copyright_year = 2021 6 | 7 | # (OPTIONAL) A list of globs that should not have copyright/license headers. 8 | # Supports doublestar glob patterns for more flexibility in defining which 9 | # files or folders should be ignored 10 | header_ignore = [ 11 | # "vendors/**", 12 | # "**autogen**", 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | */* 3 | # Ignore build/test artifacts and editor files 4 | out/ 5 | *.log 6 | *.tmp 7 | *.swp 8 | *.swo 9 | .vscode/ 10 | .idea/ 11 | .DS_Store 12 | 13 | # Allow dist and LICENSE for default Dockerfile 14 | !dist/* 15 | !LICENSE 16 | 17 | # Allow Go source and module files for FIPS Dockerfile 18 | !go.mod 19 | !go.sum 20 | !main.go 21 | !commands.go 22 | !version/ 23 | !awsutil/ 24 | !build-scripts/ 25 | !config/ 26 | !controller/ 27 | !entrypoint/ 28 | !internal/ 29 | !logging/ 30 | !subcommand/ 31 | !testutil/ -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # default PR reviews to the team 2 | * @hashicorp/consul-selfmanage-maintainers 3 | 4 | # release configuration 5 | /.release/ @hashicorp/consul-selfmanage-maintainers @hashicorp/team-selfmanaged-releng 6 | /.github/workflows/build.yml @hashicorp/consul-selfmanage-maintainers @hashicorp/team-selfmanaged-releng 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: You're experiencing an issue with Consul on ECS 4 | labels: bug 5 | 6 | --- 7 | 8 | 9 | 10 | ### Community Note 11 | 12 | * Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request. Searching for pre-existing feature requests helps us consolidate datapoints for identical requirements into a single place, thank you! 13 | * Please do not leave "+1" or other comments that do not add relevant new information or questions, they generate extra noise for issue followers and do not help prioritize the request. 14 | * If you are interested in working on this issue or have submitted a pull request, please leave a comment. 15 | 16 | ### Describe the bug 17 | 18 | 19 | 20 | ### Platform and version information 21 | 22 | 23 | 24 | * `consul-ecs` version: 25 | * Consul version: 26 | * Envoy version: 27 | * ECS launch type (e.g. FARGATE, EC2): 28 | * Deployment tool and version (e.g. Consul ECS Terraform Module, AWS CDK, etc): 29 | 30 | ### Reproduction Steps 31 | 32 | 33 | 34 | ### Expected behavior 35 | 36 | 37 | 38 | ### Additional context 39 | 40 | 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature or enhancement. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | #### Description 13 | A clear and concise overview of the feature / enhancement. 14 | 15 | #### Use Cases 16 | Any relevant use-cases that would help us understand the use / need / value. Please share them as they can help us decide on acceptance and prioritization. 17 | 18 | #### Alternative Solutions 19 | A clear and concise description of any alternative solutions, workarounds, or features you've considered. 20 | 21 | #### Additional context 22 | Any other context or screenshots about the feature request. 23 | -------------------------------------------------------------------------------- /.github/containers/ubuntu/fips-build-Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile for FIPS builds compatible with Ubuntu (glibc) 2 | FROM ubuntu:focal 3 | 4 | ENV DEBIAN_FRONTEND=noninteractive 5 | 6 | ARG GO_VERSION 7 | ARG GOARCH 8 | 9 | # Install base build dependencies 10 | RUN apt-get update && apt-get install -y --no-install-recommends \ 11 | bash \ 12 | build-essential \ 13 | ca-certificates \ 14 | curl \ 15 | libc-bin \ 16 | binutils \ 17 | git \ 18 | xz-utils \ 19 | zip 20 | 21 | # Conditionally install cross-compiler for arm64 only 22 | RUN if [ "$GOARCH" = "arm64" ]; then \ 23 | apt-get update && \ 24 | apt-get install -y --no-install-recommends crossbuild-essential-arm64 gcc-aarch64-linux-gnu; \ 25 | fi 26 | 27 | # Install Go 28 | RUN curl -L https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz | tar -C /opt -zxv 29 | 30 | ENV PATH="/root/go/bin:/opt/go/bin:$PATH" 31 | 32 | RUN git config --global --add safe.directory /build 33 | 34 | # Accept FIPS-specific build args 35 | ARG FIPS_MODE=1 36 | ARG GO_TAGS="fips" 37 | ARG LDFLAGS="" 38 | ARG BIN_NAME="consul-ecs" 39 | 40 | WORKDIR /build 41 | 42 | # Copy source code into container 43 | COPY . /build 44 | 45 | # Build the FIPS-enabled binary for the target arch 46 | RUN cd /build && \ 47 | /opt/go/bin/go version && \ 48 | /opt/go/bin/go env && \ 49 | if [ "$GOARCH" = "arm64" ]; then \ 50 | env GOOS=linux GOARCH=arm64 CGO_ENABLED=1 GOEXPERIMENT=boringcrypto CC=aarch64-linux-gnu-gcc /opt/go/bin/go build -tags="$GO_TAGS" -ldflags="$LDFLAGS" -o /bin/$BIN_NAME .; \ 51 | else \ 52 | env GOOS=linux GOARCH=amd64 CGO_ENABLED=1 GOEXPERIMENT=boringcrypto /opt/go/bin/go build -tags="$GO_TAGS" -ldflags="$LDFLAGS" -o /bin/$BIN_NAME .; \ 53 | fi -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Changes proposed in this PR: 2 | - 3 | - 4 | 5 | ## How I've tested this PR: 6 | 7 | ## How I expect reviewers to test this PR: 8 | 9 | ## Checklist: 10 | - [ ] Tests added 11 | - [ ] CHANGELOG entry added 12 | 13 | [HashiCorp engineers only. Community PRs should not add a changelog entry.]:: 14 | [Changelog entries should use present tense, e.g. "Add support for..."]:: 15 | -------------------------------------------------------------------------------- /.github/workflows/reusable-get-go-version.yml: -------------------------------------------------------------------------------- 1 | name: get-go-version 2 | 3 | on: 4 | workflow_call: 5 | outputs: 6 | go-version: 7 | description: "The Go version detected by this workflow" 8 | value: ${{ jobs.get-go-version.outputs.go-version }} 9 | go-version-previous: 10 | description: "The Go version (MAJOR.MINOR) prior to the current one, used for backwards compatibility testing" 11 | value: ${{ jobs.get-go-version.outputs.go-version-previous }} 12 | 13 | jobs: 14 | get-go-version: 15 | name: "Determine Go toolchain version" 16 | runs-on: ubuntu-22.04 17 | outputs: 18 | go-version: ${{ steps.get-go-version.outputs.go-version }} 19 | go-version-previous: ${{ steps.get-go-version.outputs.go-version-previous }} 20 | steps: 21 | - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 22 | - name: Determine Go version 23 | id: get-go-version 24 | # We use .go-version as our source of truth for current Go 25 | # version, because "goenv" can react to it automatically. 26 | # 27 | # In the future, we can transition from .go-version and goenv to 28 | # Go 1.21 `toolchain` directives by updating this workflow rather 29 | # than individually setting `go-version-file` in each `setup-go` 30 | # job (as of 2024-01-03, `setup-go` does not support `toolchain`). 31 | run: | 32 | GO_VERSION=$(head -n 1 .go-version) 33 | echo "Building with Go ${GO_VERSION}" 34 | echo "go-version=${GO_VERSION}" >> $GITHUB_OUTPUT 35 | GO_MINOR_VERSION=${GO_VERSION%.*} 36 | GO_VERSION_PREVIOUS="${GO_MINOR_VERSION%.*}.$((${GO_MINOR_VERSION#*.}-1))" 37 | echo "Previous version ${GO_VERSION_PREVIOUS}" 38 | echo "go-version-previous=${GO_VERSION_PREVIOUS}" >> $GITHUB_OUTPUT 39 | -------------------------------------------------------------------------------- /.github/workflows/security-scan.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | name: Security Scan 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | - release/** 11 | pull_request: 12 | branches: 13 | - main 14 | - release/** 15 | 16 | # cancel existing runs of the same workflow on the same ref 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | get-go-version: 23 | uses: ./.github/workflows/reusable-get-go-version.yml 24 | 25 | scan: 26 | needs: 27 | - get-go-version 28 | runs-on: ubuntu-22.04 29 | # The first check ensures this doesn't run on community-contributed PRs, who 30 | # won't have the permissions to run this job. 31 | if: ${{ (github.repository != 'hashicorp/consul-ecs' || (github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name)) 32 | && (github.actor != 'dependabot[bot]') && (github.actor != 'hc-github-team-consul-core') }} 33 | 34 | steps: 35 | - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 36 | 37 | - name: Set up Go 38 | uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1 39 | with: 40 | go-version: ${{ needs.get-go-version.outputs.go-version }} 41 | 42 | - name: Clone Security Scanner repo 43 | uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 44 | with: 45 | repository: hashicorp/security-scanner 46 | token: ${{ secrets.PRODSEC_SCANNER_READ_ONLY }} 47 | path: security-scanner 48 | ref: main 49 | 50 | - name: Scan 51 | id: scan 52 | uses: ./security-scanner 53 | with: 54 | repository: "$PWD" 55 | # See scan.hcl at repository root for config. 56 | 57 | - name: SARIF Output 58 | shell: bash 59 | run: | 60 | cat results.sarif | jq 61 | 62 | - name: Upload SARIF file 63 | uses: github/codeql-action/upload-sarif@8fcfedf57053e09257688fce7a0beeb18b1b9ae3 # codeql-bundle-v2.17.2 64 | with: 65 | sarif_file: results.sarif 66 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | # cancel existing runs of the same workflow on the same ref 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} 13 | cancel-in-progress: true 14 | 15 | env: 16 | CONSUL_LICENSE: ${{ secrets.CONSUL_LICENSE }} 17 | 18 | jobs: 19 | get-go-version: 20 | uses: ./.github/workflows/reusable-get-go-version.yml 21 | 22 | lint: 23 | needs: 24 | - get-go-version 25 | runs-on: ubuntu-22.04 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 29 | - uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1 30 | with: 31 | go-version: ${{ needs.get-go-version.outputs.go-version }} 32 | - name: golangci-lint 33 | uses: golangci/golangci-lint-action@a4f60bb28d35aeee14e6880718e0c85ff1882e64 # v6.0.1 34 | with: 35 | version: v1.60.1 36 | args: | 37 | --verbose 38 | only-new-issues: false 39 | skip-cache: true 40 | - name: lint-consul-retry 41 | shell: bash 42 | run: | 43 | go install github.com/hashicorp/lint-consul-retry@master && lint-consul-retry 44 | 45 | test: 46 | needs: 47 | - get-go-version 48 | name: unit test (consul-version=${{ matrix.consul-version }}) 49 | strategy: 50 | matrix: 51 | consul-version: 52 | - 1.20.2 53 | - 1.20.2+ent 54 | env: 55 | TEST_RESULTS_DIR: /tmp/test-results 56 | GOTESTSUM_VERSION: 1.8.2 57 | runs-on: ubuntu-22.04 58 | steps: 59 | - name: Checkout 60 | uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 61 | - uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1 62 | with: 63 | go-version: ${{ needs.get-go-version.outputs.go-version }} 64 | - name: Install Consul 65 | shell: bash 66 | run: | 67 | CONSUL_VERSION="${{ matrix.consul-version }}" 68 | FILENAME="consul_${CONSUL_VERSION}_linux_amd64.zip" 69 | curl -sSLO "https://releases.hashicorp.com/consul/${CONSUL_VERSION}/${FILENAME}" && \ 70 | unzip "${FILENAME}" -d /usr/local/bin && \ 71 | rm "${FILENAME}" 72 | consul version 73 | - name: Build 74 | run: go build -v ./... 75 | - name: Setup gotestsum 76 | shell: bash 77 | run: | 78 | url=https://github.com/gotestyourself/gotestsum/releases/download 79 | curl -sSL "${url}/v${{ env.GOTESTSUM_VERSION }}/gotestsum_${{ env.GOTESTSUM_VERSION }}_linux_amd64.tar.gz" | \ 80 | tar -xz --overwrite -C /usr/local/bin gotestsum 81 | - name: Test 82 | run: | 83 | mkdir -p $TEST_RESULTS_DIR/${{ matrix.consul-version }}/json 84 | PACKAGE_NAMES=$(go list ./... | grep -v 'mocks\|hack\|testing' | tr '\n' ' ') 85 | echo "Testing $(echo $PACKAGE_NAMES | wc -w) packages" 86 | if [[ "${{ matrix.consul-version }}" == *ent ]]; then 87 | FLAGS=-enterprise 88 | TAGS=-tags=enterprise 89 | fi 90 | gotestsum \ 91 | --format=short-verbose \ 92 | --jsonfile $TEST_RESULTS_DIR/${{ matrix.consul-version }}/json/go-test-race.log \ 93 | --junitfile $TEST_RESULTS_DIR/${{ matrix.consul-version }}/gotestsum-report.xml \ 94 | -- $PACKAGE_NAMES $TAGS -- $FLAGS 95 | - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 96 | with: 97 | name: ${{ matrix.consul-version }}-test-results 98 | path: ${{ env.TEST_RESULTS_DIR }}/${{ matrix.consul-version }} 99 | 100 | # This is job is required for branch protection as a required GitHub check 101 | # because GitHub actions show up as checks at the job level and not the 102 | # workflow level. This is currently a feature request: 103 | # https://github.com/orgs/community/discussions/12395 104 | # 105 | # This job must: 106 | # - be placed after the fanout of a workflow so that everything fans back in 107 | # to this job. 108 | # - "need" any job that is part of the fan out / fan in 109 | # - include if: always() logic because we may have conditional jobs that this job 110 | # needs, and this would potentially get skipped if a previous job got skipped. 111 | # The if clause ensures it does not get skipped. 112 | test-success: 113 | needs: 114 | - lint 115 | - test 116 | runs-on: ubuntu-22.04 117 | if: always() 118 | steps: 119 | - name: evaluate upstream job results 120 | run: | 121 | # exit 1 if failure or cancelled result for any upstream job 122 | # this ensures that we fail the PR check regardless of cancellation, rather than skip-passing it 123 | # see https://docs.github.com/en/actions/using-jobs/using-conditions-to-control-job-execution#overview 124 | if printf '${{ toJSON(needs) }}' | grep -E -i '\"result\": \"(failure|cancelled)\"'; then 125 | printf "Tests failed or workflow cancelled:\n\n${{ toJSON(needs) }}" 126 | exit 1 127 | fi 128 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | output 2 | .terraform* 3 | *.tfstate* 4 | build-support/docker/Dev.dockerfile 5 | *.out 6 | .idea 7 | 8 | # Output directory for binaries built in CircleCI 9 | /pkg 10 | dist/ 11 | -------------------------------------------------------------------------------- /.go-version: -------------------------------------------------------------------------------- 1 | 1.23.6 2 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | linters: 5 | disable-all: true 6 | enable: 7 | - gofmt 8 | - govet 9 | - unconvert 10 | - staticcheck 11 | - ineffassign 12 | - unparam 13 | - goimports 14 | 15 | run: 16 | concurrency: 2 17 | timeout: 1m 18 | -------------------------------------------------------------------------------- /.release/ci.hcl: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | schema = "1" 5 | 6 | project "consul-ecs" { 7 | // the team key is not used by CRT currently 8 | team = "consul-ecs" 9 | slack { 10 | notification_channel = "C01J8QV0EF8" # team-consul-ecs 11 | } 12 | github { 13 | organization = "hashicorp" 14 | repository = "consul-ecs" 15 | release_branches = [ 16 | "main", 17 | "release/**", 18 | ] 19 | } 20 | } 21 | 22 | event "merge" { 23 | // "entrypoint" to use if build is not run automatically 24 | // i.e. send "merge" complete signal to orchestrator to trigger build 25 | } 26 | 27 | event "build" { 28 | depends = ["merge"] 29 | action "build" { 30 | organization = "hashicorp" 31 | repository = "consul-ecs" 32 | workflow = "build" 33 | } 34 | } 35 | 36 | event "prepare" { 37 | depends = ["build"] 38 | action "prepare" { 39 | organization = "hashicorp" 40 | repository = "crt-workflows-common" 41 | workflow = "prepare" 42 | depends = ["build"] 43 | } 44 | 45 | notification { 46 | on = "fail" 47 | } 48 | } 49 | 50 | ## These are promotion and post-publish events 51 | ## they should be added to the end of the file after the verify event stanza. 52 | 53 | event "trigger-staging" { 54 | // This event is dispatched by the bob trigger-promotion command 55 | // and is required - do not delete. 56 | } 57 | 58 | event "promote-staging" { 59 | depends = ["trigger-staging"] 60 | action "promote-staging" { 61 | organization = "hashicorp" 62 | repository = "crt-workflows-common" 63 | workflow = "promote-staging" 64 | config = "release-metadata.hcl" 65 | } 66 | 67 | notification { 68 | on = "always" 69 | } 70 | } 71 | 72 | event "promote-staging-docker" { 73 | depends = ["promote-staging"] 74 | action "promote-staging-docker" { 75 | organization = "hashicorp" 76 | repository = "crt-workflows-common" 77 | workflow = "promote-staging-docker" 78 | } 79 | 80 | notification { 81 | on = "always" 82 | } 83 | } 84 | 85 | event "trigger-production" { 86 | // This event is dispatched by the bob trigger-promotion command 87 | // and is required - do not delete. 88 | } 89 | 90 | event "promote-production" { 91 | depends = ["trigger-production"] 92 | action "promote-production" { 93 | organization = "hashicorp" 94 | repository = "crt-workflows-common" 95 | workflow = "promote-production" 96 | } 97 | 98 | notification { 99 | on = "always" 100 | } 101 | } 102 | 103 | event "promote-production-docker" { 104 | depends = ["promote-production"] 105 | action "promote-production-docker" { 106 | organization = "hashicorp" 107 | repository = "crt-workflows-common" 108 | workflow = "promote-production-docker" 109 | } 110 | 111 | notification { 112 | on = "always" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /.release/consul-ecs-artifacts.hcl: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | schema = 1 5 | artifacts { 6 | zip = [ 7 | "consul-ecs_${version}+fips1402_linux_amd64.zip", 8 | "consul-ecs_${version}+fips1402_linux_arm64.zip", 9 | "consul-ecs_${version}_linux_386.zip", 10 | "consul-ecs_${version}_linux_amd64.zip", 11 | "consul-ecs_${version}_linux_arm.zip", 12 | "consul-ecs_${version}_linux_arm64.zip", 13 | ] 14 | container = [ 15 | "consul-ecs_release-default_linux_386_${version}_${commit_sha}.docker.dev.tar", 16 | "consul-ecs_release-default_linux_386_${version}_${commit_sha}.docker.tar", 17 | "consul-ecs_release-default_linux_amd64_${version}_${commit_sha}.docker.dev.tar", 18 | "consul-ecs_release-default_linux_amd64_${version}_${commit_sha}.docker.tar", 19 | "consul-ecs_release-default_linux_arm64_${version}_${commit_sha}.docker.dev.tar", 20 | "consul-ecs_release-default_linux_arm64_${version}_${commit_sha}.docker.tar", 21 | "consul-ecs_release-default_linux_arm_${version}_${commit_sha}.docker.dev.tar", 22 | "consul-ecs_release-default_linux_arm_${version}_${commit_sha}.docker.tar", 23 | "consul-ecs_release-fips-default_linux_amd64_${version}+fips1402_${commit_sha}.docker.dev.tar", 24 | "consul-ecs_release-fips-default_linux_amd64_${version}+fips1402_${commit_sha}.docker.tar", 25 | "consul-ecs_release-fips-default_linux_arm64_${version}+fips1402_${commit_sha}.docker.dev.tar", 26 | "consul-ecs_release-fips-default_linux_arm64_${version}+fips1402_${commit_sha}.docker.tar", 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.release/release-metadata.hcl: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | url_docker_registry_dockerhub = "https://hub.docker.com/r/hashicorp/consul-ecs" 5 | url_docker_registry_ecr = "https://gallery.ecr.aws/hashicorp/consul-ecs" 6 | url_license = "https://github.com/hashicorp/consul-ecs/blob/main/LICENSE" 7 | url_project_website = "https://www.consul.io/docs/ecs" 8 | url_source_repository = "https://github.com/hashicorp/consul-ecs" 9 | url_release_notes = "https://www.consul.io/docs/release-notes" 10 | -------------------------------------------------------------------------------- /.release/security-scan.hcl: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | container { 5 | dependencies = true 6 | alpine_secdb = true 7 | secrets = true 8 | } 9 | 10 | binary { 11 | secrets = true 12 | go_modules = true 13 | osv = true 14 | oss_index = false 15 | nvd = false 16 | 17 | triage { 18 | suppress { 19 | vulnerabilities = [ 20 | "GO-2022-0635", // github.com/aws/aws-sdk-go@v1.55.5 21 | ] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Consul-ECS 2 | 3 | Thanks for your interest in Consul on ECS. We value feedback and contributions from the community whether it is a bug report, enhancements, new features or documentation. 4 | 5 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 6 | information to effectively respond to your bug report or contribution. 7 | 8 | 9 | ## Reporting Issues, Bugs or Feature Requests 10 | 11 | We welcome users to report issues or suggest features. 12 | 13 | If you're suggesting a new feature (i.e., functionality that doesn't exist yet), please use our [issue template](https://github.com/hashicorp/consul-ecs/issues). This will prompt you to answer a few questions that will help us figure out what you're looking for. The template will also tag incoming issues with "Enhancement". This gives us a way to filter the community-opened issues quickly so we can review as a team. 14 | 15 | Check for duplicates when filing an issue. Please check [existing open](https://github.com/hashicorp/consul-ecs/issues), or [recently closed](https://github.com/hashicorp/consul-ecs/issues?q=is%3Aissue+is%3Aclosed) issues to make sure somebody else hasn't already reported the issue. 16 | 17 | 18 | If you're reporting what you think is a bug (i.e., something isn't right with an existing feature), please try to include as much information as you can. Details like these are incredibly useful: 19 | 20 | * A reproducible test case or series of steps performed 21 | * The version of our code being used for our product(Consul version) 22 | * Any modifications you've made relevant to the bug 23 | * Anything unusual about your environment or deployment 24 | 25 | ## Running Tests 26 | 27 | Tests will run using the locally installed `consul` binary. 28 | 29 | Running the Enterprise tests requires a local Consul Enterprise binary, passing the `-enterprise` flag as an argument to `go test`, and a valid Consul Enterprise license (configure by [setting `CONSUL_LICENSE` or `CONSUL_LICENSE_PATH` in your environment](https://developer.hashicorp.com/consul/docs/enterprise/license/overview#applying-a-license)). 30 | ```shell 31 | export CONSUL_LICENSE_PATH=... 32 | go test -v ./... -- -enterprise 33 | ``` 34 | 35 | To run the CE tests, omit the `-enterprise` flag and ensure your local binary is Consul CE. 36 | ```shell 37 | go test -v ./... 38 | ``` 39 | 40 | Note that the CE tests cannot be run with a Consul Enterprise binary due to defaulting of tenancy fields. 41 | 42 | ## Licensing 43 | 44 | See the [LICENSE](https://github.com/hashicorp/consul-ecs/blob/main/LICENSE.md) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 45 | 46 | If you want to contribute to this project, we may ask you to sign a **[Contributor License Agreement (CLA)](https://www.hashicorp.com/cla)**. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | # This Dockerfile contains multiple targets. 5 | # Use 'docker build --target= .' to build one. 6 | # 7 | # Every target has a BIN_NAME argument that must be provided via --build-arg=BIN_NAME= 8 | # when building. 9 | 10 | # go-discover builds the discover binary 11 | FROM golang:1.23.6-alpine as go-discover 12 | RUN CGO_ENABLED=0 go install github.com/hashicorp/go-discover/cmd/discover@214571b6a5309addf3db7775f4ee8cf4d264fd5f 13 | 14 | FROM docker.mirror.hashicorp.services/alpine:latest AS release-default 15 | 16 | ARG BIN_NAME=consul-ecs 17 | ARG PRODUCT_VERSION 18 | # TARGETARCH and TARGETOS are set automatically when --platform is provided. 19 | ARG TARGETOS TARGETARCH 20 | # Export BIN_NAME for the CMD below, it can't see ARGs directly. 21 | ENV BIN_NAME=$BIN_NAME 22 | ENV VERSION=$PRODUCT_VERSION 23 | ENV PRODUCT_NAME=$BIN_NAME 24 | 25 | LABEL description="consul-ecs provides first-class integration between Consul and AWS ECS." \ 26 | maintainer="Consul Team " \ 27 | name=$BIN_NAME \ 28 | release=$PRODUCT_VERSION \ 29 | summary="consul-ecs provides first-class integration between Consul and AWS ECS." \ 30 | vendor="HashiCorp" \ 31 | version=$PRODUCT_VERSION \ 32 | org.opencontainers.image.authors="Consul Team " \ 33 | org.opencontainers.image.description="consul-ecs provides first-class integration between Consul and AWS ECS." \ 34 | org.opencontainers.image.documentation="https://www.consul.io/docs/ecs" \ 35 | org.opencontainers.image.source="https://github.com/hashicorp/consul-ecs" \ 36 | org.opencontainers.image.title=$BIN_NAME \ 37 | org.opencontainers.image.url="https://www.consul.io/" \ 38 | org.opencontainers.image.vendor="HashiCorp" \ 39 | org.opencontainers.image.licenses="MPL-2.0" \ 40 | org.opencontainers.image.version=$PRODUCT_VERSION 41 | 42 | # Create a non-root user to run the software. 43 | RUN addgroup $BIN_NAME && \ 44 | adduser -S -G $BIN_NAME $BIN_NAME && \ 45 | # Changing the owner of /consul to NAME allows mesh-init to run as NAME rather 46 | # than root. See 47 | # https://docs.aws.amazon.com/AmazonECS/latest/developerguide/bind-mounts.html 48 | # for more information 49 | mkdir /consul && \ 50 | chown $BIN_NAME:$BIN_NAME /consul 51 | 52 | # This folder will hold the consul binary that comes from the the Consul client 53 | # container at runtime 54 | ENV PATH="/bin/consul-inject:${PATH}" 55 | 56 | VOLUME [ "/consul" ] 57 | 58 | # Set up certificates, base tools, and software. 59 | RUN apk add --no-cache ca-certificates curl gnupg libcap openssl su-exec iputils iptables gcompat libc6-compat libstdc++ 60 | 61 | # for FIPS CGO glibc compatibility in alpine 62 | # see https://github.com/golang/go/issues/59305 63 | RUN ln -s /lib/libc.so.6 /usr/lib/libresolv.so.2 64 | 65 | USER $BIN_NAME 66 | ENTRYPOINT ["/bin/consul-ecs"] 67 | COPY dist/$TARGETOS/$TARGETARCH/$BIN_NAME /bin/ 68 | COPY LICENSE /usr/share/doc/$PRODUCT_NAME/LICENSE.txt 69 | COPY --from=go-discover /go/bin/discover /bin/ 70 | 71 | # Separate FIPS target to accomodate CRT label assumptions 72 | FROM release-default AS release-fips-default 73 | 74 | # Set default target 75 | FROM release-default 76 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL = /usr/bin/env bash -euo pipefail -c 2 | 3 | # ---------- CRT ---------- 4 | BIN_NAME = consul-ecs 5 | 6 | ARCH = $(shell A=$$(uname -m); [ $$A = x86_64 ] && A=amd64; [ $$A = aarch64 ] && A=arm64; echo $$A) 7 | OS = $(shell uname | tr [[:upper:]] [[:lower:]]) 8 | PLATFORM = $(OS)/$(ARCH) 9 | DIST = dist/$(PLATFORM) 10 | BIN = $(DIST)/$(BIN_NAME) 11 | 12 | BIN_NAME ?= consul-ecs 13 | VERSION ?= $(shell ./build-scripts/version.sh version/version.go) 14 | 15 | GIT_COMMIT ?= $(shell git rev-parse --short HEAD) 16 | GIT_DIRTY ?= $(shell test -n "`git status --porcelain`" && echo "+CHANGES" || true) 17 | PROJECT = $(shell go list -m) 18 | LD_FLAGS ?= -X "$(PROJECT)/version.GitCommit=$(GIT_COMMIT)$(GIT_DIRTY)" 19 | 20 | version: 21 | @echo $(VERSION) 22 | .PHONY: version 23 | 24 | dist: 25 | mkdir -p $(DIST) 26 | 27 | dev: dist 28 | GOARCH=$(ARCH) GOOS=$(OS) go build -ldflags "$(LD_FLAGS)" -o $(BIN) 29 | .PHONY: dev 30 | 31 | dev-fips: dist 32 | GOARCH=$(ARCH) GOOS=$(OS) CGO_ENABLED=1 GOEXPERIMENT=boringcrypto go build -tags=fips -ldflags "$(LD_FLAGS)" -o $(BIN) 33 | .PHONY: dev-fips 34 | 35 | # Docker Stuff. 36 | BUILD_ARGS = BIN_NAME=consul-ecs PRODUCT_VERSION=$(VERSION) GIT_COMMIT=$(GIT_COMMIT) GIT_DIRTY=$(GIT_DIRTY) 37 | TAG = $(BIN_NAME)/$(TARGET):$(VERSION) 38 | BA_FLAGS = $(addprefix --build-arg=,$(BUILD_ARGS)) 39 | FLAGS = --target $(TARGET) --platform $(PLATFORM) --tag $(TAG) $(BA_FLAGS) 40 | 41 | # Set OS to linux for all docker targets. 42 | docker: OS = linux 43 | docker: TARGET = release-default 44 | docker: dev 45 | export DOCKER_BUILDKIT=1; docker build $(FLAGS) . 46 | .PHONY: docker 47 | 48 | docker-fips: OS = linux 49 | docker-fips: TARGET = release-fips-default 50 | docker-fips: dev-fips 51 | export DOCKER_BUILDKIT=1; docker build $(FLAGS) . 52 | .PHONY: docker-fips 53 | 54 | # Generate reference config documentation. 55 | # Usage: 56 | # make reference-configuration 57 | # make reference-configuration consul= 58 | # The consul repo path is relative to the defaults to ../../../consul. 59 | consul?=../../../consul 60 | reference-configuration: 61 | cd $(CURDIR)/hack/generate-config-reference; go run . > "$(consul)/website/content/docs/ecs/configuration-reference.mdx" 62 | 63 | 64 | .PHONY: build-image ci.dev-docker dev-docker build-dev-dockerfile reference-configuration 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Consul logo 3 | Consul on ECS 4 |

5 | 6 | [![Docker Pulls](https://img.shields.io/docker/pulls/hashicorp/consul-ecs)](https://hub.docker.com/r/hashicorp/consul-ecs) 7 | [![Go Report Card](https://goreportcard.com/badge/github.com/hashicorp/consul-ecs)](https://goreportcard.com/report/github.com/hashicorp/consul-ecs) 8 | 9 | This repository holds the Go code used by the `hashicorp/consul-ecs` Docker image. 10 | This image is used to help with the installation and operation of Consul on ECS. 11 | 12 | ### Supporting Materials 13 | 14 | - HashiCorp Consul AWS ECS documentation: [https://consul.io/docs/ecs](https://consul.io/docs/ecs) 15 | - Terraform modules for Consul AWS ECS are hosted here: https://github.com/hashicorp/terraform-aws-consul-ecs 16 | 17 | ## Roadmap 18 | 19 | Knowing about our upcoming features and priorities helps our users plan. See our roadmap [here](https://github.com/hashicorp/consul-ecs/projects/1). 20 | 21 | To request a feature to be added to the roadmap open up an issue. 22 | 23 | ## Contributing 24 | 25 | We want to create a strong community around Consul on ECS. We will take all PRs very seriously and review for inclusion. Please read about [contributing](./CONTRIBUTING.md). 26 | -------------------------------------------------------------------------------- /_docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 17 | 22 | 27 | 32 | 37 | 42 | 47 | 52 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /awsutil/awsutil.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package awsutil 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "os" 12 | "runtime" 13 | "strings" 14 | 15 | "github.com/aws/aws-sdk-go/aws" 16 | "github.com/aws/aws-sdk-go/aws/arn" 17 | "github.com/aws/aws-sdk-go/aws/request" 18 | "github.com/aws/aws-sdk-go/aws/session" 19 | "github.com/aws/aws-sdk-go/service/ecs" 20 | "github.com/hashicorp/consul-ecs/version" 21 | ) 22 | 23 | const ( 24 | ECSMetadataURIEnvVar = "ECS_CONTAINER_METADATA_URI_V4" 25 | 26 | AWSRegionEnvVar = "AWS_REGION" 27 | 28 | // This is the type assigned to the containers that are 29 | // present in the task definition. 30 | containerTypeNormal = "NORMAL" 31 | ) 32 | 33 | type ECSTaskMeta struct { 34 | Cluster string `json:"Cluster"` 35 | TaskARN string `json:"TaskARN"` 36 | Family string `json:"Family"` 37 | Containers []ECSTaskMetaContainer `json:"Containers"` 38 | AvailabilityZone string `json:"AvailabilityZone"` 39 | } 40 | 41 | type ECSTaskMetaContainer struct { 42 | Name string `json:"Name"` 43 | Health ECSTaskMetaHealth `json:"Health"` 44 | DesiredStatus string `json:"DesiredStatus"` 45 | KnownStatus string `json:"KnownStatus"` 46 | Networks []ECSTaskMetaNetwork `json:"Networks"` 47 | Type string `json:"Type"` 48 | } 49 | 50 | type ECSTaskMetaHealth struct { 51 | Status string `json:"status"` 52 | StatusSince string `json:"statusSince"` 53 | ExitCode int `json:"exitCode"` 54 | } 55 | 56 | type ECSTaskMetaNetwork struct { 57 | IPv4Addresses []string `json:"IPv4Addresses"` 58 | PrivateDNSName string `json:"PrivateDNSName"` 59 | } 60 | 61 | func (e ECSTaskMeta) TaskID() string { 62 | return ParseTaskID(e.TaskARN) 63 | } 64 | 65 | func (e ECSTaskMeta) ClusterARN() (string, error) { 66 | if strings.HasPrefix(e.Cluster, "arn:") { 67 | return e.Cluster, nil 68 | } 69 | // On EC2, the "Cluster" field is the name, not the ARN. 70 | clusterArn := strings.Replace(e.TaskARN, ":task/", ":cluster/", 1) 71 | index := strings.LastIndex(clusterArn, "/") 72 | if index < 0 { 73 | return "", fmt.Errorf("unable to determine cluster ARN from task ARN %q", e.TaskARN) 74 | } 75 | return clusterArn[:index], nil 76 | } 77 | 78 | func ParseTaskID(taskArn string) string { 79 | split := strings.Split(taskArn, "/") 80 | if len(split) == 0 { 81 | return "" 82 | } 83 | return split[len(split)-1] 84 | } 85 | 86 | func (e ECSTaskMeta) AccountID() (string, error) { 87 | a, err := arn.Parse(e.TaskARN) 88 | if err != nil { 89 | return "", fmt.Errorf("unable to determine AWS account id from Task ARN: %q", e.TaskARN) 90 | } 91 | return a.AccountID, nil 92 | } 93 | 94 | func (e ECSTaskMeta) Region() (string, error) { 95 | // Task ARN: "arn:aws:ecs:us-east-1:000000000000:task/cluster/00000000000000000000000000000000" 96 | // https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html 97 | // See also: https://github.com/aws/containers-roadmap/issues/337 98 | a, err := arn.Parse(e.TaskARN) 99 | if err != nil { 100 | return "", fmt.Errorf("unable to determine AWS region from Task ARN: %q", e.TaskARN) 101 | } 102 | return a.Region, nil 103 | } 104 | 105 | // NodeIP returns the IP of the node the task is running on. 106 | func (e ECSTaskMeta) NodeIP() string { 107 | ip := "127.0.0.1" // default to localhost 108 | if len(e.Containers) > 0 && 109 | len(e.Containers[0].Networks) > 0 && 110 | len(e.Containers[0].Networks[0].IPv4Addresses) > 0 { 111 | ip = e.Containers[0].Networks[0].IPv4Addresses[0] 112 | } 113 | return ip 114 | } 115 | 116 | func (e ECSTaskMeta) HasContainerStopped(name string) bool { 117 | stopped := true 118 | for _, c := range e.Containers { 119 | if c.Name == name && !c.HasStopped() { 120 | stopped = false 121 | break 122 | } 123 | } 124 | return stopped 125 | } 126 | 127 | func (c ECSTaskMetaContainer) HasStopped() bool { 128 | return c.DesiredStatus == ecs.DesiredStatusStopped && 129 | c.KnownStatus == ecs.DesiredStatusStopped 130 | } 131 | 132 | func (c ECSTaskMetaContainer) IsNormalType() bool { 133 | return c.Type == containerTypeNormal 134 | } 135 | 136 | func ECSTaskMetadata() (ECSTaskMeta, error) { 137 | var metadataResp ECSTaskMeta 138 | 139 | metadataURI := os.Getenv(ECSMetadataURIEnvVar) 140 | if metadataURI == "" { 141 | return metadataResp, fmt.Errorf("%s env var not set", ECSMetadataURIEnvVar) 142 | } 143 | resp, err := http.Get(fmt.Sprintf("%s/task", metadataURI)) 144 | if err != nil { 145 | return metadataResp, fmt.Errorf("calling metadata uri: %s", err) 146 | } 147 | respBytes, err := io.ReadAll(resp.Body) 148 | if err != nil { 149 | return metadataResp, fmt.Errorf("reading metadata uri response body: %s", err) 150 | } 151 | if err := json.Unmarshal(respBytes, &metadataResp); err != nil { 152 | return metadataResp, fmt.Errorf("unmarshalling metadata uri response: %s", err) 153 | } 154 | return metadataResp, nil 155 | } 156 | 157 | func UserAgentHandler(caller string) request.NamedHandler { 158 | return request.NamedHandler{ 159 | Name: "UserAgentHandler", 160 | Fn: func(r *request.Request) { 161 | userAgent := r.HTTPRequest.Header.Get("User-Agent") 162 | r.HTTPRequest.Header.Set("User-Agent", 163 | fmt.Sprintf("consul-ecs-%s/%s (%s) %s", caller, version.Version, runtime.GOOS, userAgent)) 164 | }, 165 | } 166 | } 167 | 168 | // NewSession prepares a client session. 169 | // The returned session includes a User-Agent handler to enable AWS to track usage. 170 | // If the AWS SDK fails to find the region, the region is parsed from Task metadata 171 | // (on EC2 the region is not typically defined in the environment). 172 | func NewSession(meta ECSTaskMeta, userAgentCaller string) (*session.Session, error) { 173 | clientSession, err := session.NewSession() 174 | if err != nil { 175 | return nil, err 176 | } 177 | 178 | clientSession.Handlers.Build.PushBackNamed(UserAgentHandler(userAgentCaller)) 179 | 180 | cfg := clientSession.Config 181 | if cfg.Region == nil || *cfg.Region == "" { 182 | region, err := meta.Region() 183 | if err != nil { 184 | return nil, err 185 | } 186 | cfg.Region = aws.String(region) 187 | } 188 | return clientSession, nil 189 | } 190 | 191 | func GetAWSRegion() string { 192 | return os.Getenv(AWSRegionEnvVar) 193 | } 194 | -------------------------------------------------------------------------------- /awsutil/awsutil_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package awsutil 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/aws/aws-sdk-go/aws/request" 13 | "github.com/aws/aws-sdk-go/service/ecs" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func TestNewSession(t *testing.T) { 19 | taskRegion := "bogus-east-1" 20 | nonTaskRegion := "some-other-region" 21 | taskArn := fmt.Sprintf("arn:aws:ecs:%s:123456789:task/test/abcdef", taskRegion) 22 | 23 | cases := map[string]struct { 24 | env map[string]string 25 | expectRegion string 26 | taskArn string 27 | expectError string 28 | }{ 29 | "no-env": { 30 | expectRegion: taskRegion, 31 | taskArn: taskArn, 32 | }, 33 | "no-env-and-invalid-task-arn": { 34 | taskArn: "invalid-task-arn", 35 | expectError: `unable to determine AWS region from Task ARN: "invalid-task-arn"`, 36 | }, 37 | "aws-region": { 38 | env: map[string]string{"AWS_REGION": nonTaskRegion}, 39 | taskArn: taskArn, 40 | expectRegion: nonTaskRegion, 41 | }, 42 | } 43 | 44 | // Restore the environment after these test cases. 45 | environ := os.Environ() 46 | t.Cleanup(func() { restoreEnv(t, environ) }) 47 | 48 | for testName, c := range cases { 49 | t.Run(testName, func(t *testing.T) { 50 | // Ensure external environment doesn't affect us. 51 | for _, k := range []string{"AWS_REGION", "AWS_DEFAULT_REGION"} { 52 | require.NoError(t, os.Unsetenv(k)) 53 | } 54 | 55 | // Prepare environment for each test case. 56 | for k, v := range c.env { 57 | require.NoError(t, os.Setenv(k, v)) 58 | t.Cleanup(func() { 59 | require.NoError(t, os.Unsetenv(k)) 60 | }) 61 | } 62 | 63 | ecsMeta := ECSTaskMeta{ 64 | Cluster: "test", 65 | TaskARN: c.taskArn, 66 | Family: "task", 67 | } 68 | 69 | sess, err := NewSession(ecsMeta, "") 70 | 71 | // Check an expected error 72 | if c.expectError != "" { 73 | require.Nil(t, sess) 74 | require.Error(t, err) 75 | require.Equal(t, c.expectError, err.Error()) 76 | return 77 | } 78 | 79 | // Validate region is pulled correctly from environment or Task metadata. 80 | require.Equal(t, c.expectRegion, *sess.Config.Region) 81 | 82 | // Ensure the User-Agent request handler was added. 83 | // (Hacky. Only way I could figure out to detect a request handler by name) 84 | foundHandler := sess.Handlers.Build.Swap("UserAgentHandler", request.NamedHandler{}) 85 | require.True(t, foundHandler) 86 | }) 87 | } 88 | } 89 | 90 | func TestECSTaskMeta(t *testing.T) { 91 | ecsMeta := ECSTaskMeta{ 92 | Cluster: "test", 93 | TaskARN: "arn:aws:ecs:us-east-1:123456789:task/test/abcdef", 94 | Family: "task", 95 | } 96 | require.Equal(t, "abcdef", ecsMeta.TaskID()) 97 | region, err := ecsMeta.Region() 98 | require.Nil(t, err) 99 | require.Equal(t, "us-east-1", region) 100 | 101 | account, err := ecsMeta.AccountID() 102 | require.NoError(t, err) 103 | require.Equal(t, "123456789", account) 104 | 105 | clusterArn, err := ecsMeta.ClusterARN() 106 | require.NoError(t, err) 107 | require.Equal(t, clusterArn, "arn:aws:ecs:us-east-1:123456789:cluster/test") 108 | } 109 | 110 | func TestHasContainerStopped(t *testing.T) { 111 | taskMeta := ECSTaskMeta{} 112 | taskMeta.Containers = []ECSTaskMetaContainer{ 113 | { 114 | Name: "container1", 115 | DesiredStatus: ecs.DesiredStatusRunning, 116 | KnownStatus: ecs.DesiredStatusRunning, 117 | }, 118 | { 119 | Name: "container2", 120 | DesiredStatus: ecs.DesiredStatusPending, 121 | KnownStatus: ecs.DesiredStatusPending, 122 | }, 123 | } 124 | 125 | require.Equal(t, false, taskMeta.HasContainerStopped("container2")) 126 | 127 | taskMeta.Containers[1].DesiredStatus = ecs.DesiredStatusStopped 128 | taskMeta.Containers[1].KnownStatus = ecs.DesiredStatusStopped 129 | 130 | require.Equal(t, true, taskMeta.HasContainerStopped("container2")) 131 | } 132 | 133 | func TestHasStopped(t *testing.T) { 134 | container := ECSTaskMetaContainer{ 135 | Name: "container1", 136 | DesiredStatus: ecs.DesiredStatusRunning, 137 | KnownStatus: ecs.DesiredStatusRunning, 138 | } 139 | 140 | require.Equal(t, false, container.HasStopped()) 141 | 142 | container.DesiredStatus = ecs.DesiredStatusStopped 143 | container.KnownStatus = ecs.DesiredStatusStopped 144 | 145 | require.Equal(t, true, container.HasStopped()) 146 | } 147 | 148 | func TestIsNormalType(t *testing.T) { 149 | container := ECSTaskMetaContainer{ 150 | Name: "container1", 151 | DesiredStatus: ecs.DesiredStatusRunning, 152 | KnownStatus: ecs.DesiredStatusRunning, 153 | Type: containerTypeNormal, 154 | } 155 | 156 | require.True(t, container.IsNormalType()) 157 | 158 | container.Type = "SOME_AWS_MANAGED_TYPE" 159 | 160 | require.False(t, container.IsNormalType()) 161 | } 162 | 163 | // Helper to restore the environment after a test. 164 | func restoreEnv(t *testing.T, env []string) { 165 | os.Clearenv() 166 | for _, keyvalue := range env { 167 | pair := strings.SplitN(keyvalue, "=", 2) 168 | assert.NoError(t, os.Setenv(pair[0], pair[1])) 169 | } 170 | } 171 | 172 | func TestECSTaskMeta_NodeIP(t *testing.T) { 173 | cases := map[string]struct { 174 | ecsMeta ECSTaskMeta 175 | expNodeIP string 176 | }{ 177 | "no containers": { 178 | ecsMeta: ECSTaskMeta{}, 179 | expNodeIP: "127.0.0.1", 180 | }, 181 | "no networks": { 182 | ecsMeta: ECSTaskMeta{ 183 | Containers: []ECSTaskMetaContainer{{}}, 184 | }, 185 | expNodeIP: "127.0.0.1", 186 | }, 187 | "no addresses": { 188 | ecsMeta: ECSTaskMeta{ 189 | Containers: []ECSTaskMetaContainer{{ 190 | Networks: []ECSTaskMetaNetwork{{}}, 191 | }}, 192 | }, 193 | expNodeIP: "127.0.0.1", 194 | }, 195 | "node ip": { 196 | ecsMeta: ECSTaskMeta{ 197 | Containers: []ECSTaskMetaContainer{{ 198 | Networks: []ECSTaskMetaNetwork{{ 199 | IPv4Addresses: []string{"10.1.2.3"}, 200 | }}, 201 | }}, 202 | }, 203 | expNodeIP: "10.1.2.3", 204 | }, 205 | } 206 | for _, c := range cases { 207 | nodeIP := c.ecsMeta.NodeIP() 208 | require.Equal(t, c.expNodeIP, nodeIP) 209 | } 210 | } 211 | 212 | func TestGetAWSRegion(t *testing.T) { 213 | t.Setenv(AWSRegionEnvVar, "") 214 | require.Empty(t, GetAWSRegion()) 215 | 216 | t.Setenv(AWSRegionEnvVar, "us-west-2") 217 | require.Equal(t, "us-west-2", GetAWSRegion()) 218 | } 219 | -------------------------------------------------------------------------------- /build-scripts/version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | version_file=$1 7 | version=$(awk '$1 == "Version" && $2 == "=" { gsub(/"/, "", $3); print $3 }' < "${version_file}") 8 | prerelease=$(awk '$1 == "VersionPrerelease" && $2 == "=" { gsub(/"/, "", $3); print $3 }' < "${version_file}") 9 | 10 | if [ -n "$prerelease" ]; then 11 | echo "${version}-${prerelease}" 12 | else 13 | echo "${version}" 14 | fi 15 | -------------------------------------------------------------------------------- /commands.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "os" 8 | 9 | cmdAppEntrypoint "github.com/hashicorp/consul-ecs/subcommand/app-entrypoint" 10 | cmdController "github.com/hashicorp/consul-ecs/subcommand/controller" 11 | cmdEnvoyEntrypoint "github.com/hashicorp/consul-ecs/subcommand/envoy-entrypoint" 12 | cmdHealthSync "github.com/hashicorp/consul-ecs/subcommand/health-sync" 13 | cmdMeshInit "github.com/hashicorp/consul-ecs/subcommand/mesh-init" 14 | cmdNetDial "github.com/hashicorp/consul-ecs/subcommand/net-dial" 15 | cmdVersion "github.com/hashicorp/consul-ecs/subcommand/version" 16 | "github.com/hashicorp/consul-ecs/version" 17 | "github.com/mitchellh/cli" 18 | ) 19 | 20 | // Commands is the mapping of all available consul-ecs commands. 21 | var Commands map[string]cli.CommandFactory 22 | 23 | func init() { 24 | ui := &cli.BasicUi{Writer: os.Stdout, ErrorWriter: os.Stderr} 25 | 26 | Commands = map[string]cli.CommandFactory{ 27 | "version": func() (cli.Command, error) { 28 | return &cmdVersion.Command{UI: ui, Version: version.GetHumanVersion()}, nil 29 | }, 30 | "mesh-init": func() (cli.Command, error) { 31 | return &cmdMeshInit.Command{UI: ui}, nil 32 | }, 33 | "controller": func() (cli.Command, error) { 34 | return &cmdController.Command{UI: ui}, nil 35 | }, 36 | "envoy-entrypoint": func() (cli.Command, error) { 37 | return &cmdEnvoyEntrypoint.Command{UI: ui}, nil 38 | }, 39 | "app-entrypoint": func() (cli.Command, error) { 40 | return &cmdAppEntrypoint.Command{UI: ui}, nil 41 | }, 42 | "net-dial": func() (cli.Command, error) { 43 | return &cmdNetDial.Command{UI: ui}, nil 44 | }, 45 | "health-sync": func() (cli.Command, error) { 46 | return &cmdHealthSync.Command{UI: ui}, nil 47 | }, 48 | } 49 | } 50 | 51 | func helpFunc() cli.HelpFunc { 52 | // This should be updated for any commands we want to hide for any reason. 53 | // Hidden commands can still be executed if you know the command, but 54 | // aren't shown in any help output. We use this for prerelease functionality 55 | // or advanced features. 56 | hidden := map[string]struct{}{} 57 | 58 | var include []string 59 | for k := range Commands { 60 | if _, ok := hidden[k]; !ok { 61 | include = append(include, k) 62 | } 63 | } 64 | 65 | return cli.FilteredHelpFunc(include, cli.BasicHelpFunc("consul-ecs")) 66 | } 67 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package config 5 | 6 | import ( 7 | "crypto/tls" 8 | "encoding/json" 9 | "fmt" 10 | "os" 11 | "strings" 12 | 13 | "github.com/aws/aws-sdk-go/aws" 14 | "github.com/aws/aws-sdk-go/aws/credentials" 15 | "github.com/aws/aws-sdk-go/aws/session" 16 | iamauth "github.com/hashicorp/consul-awsauth" 17 | "github.com/hashicorp/consul-ecs/awsutil" 18 | "github.com/hashicorp/consul-server-connection-manager/discovery" 19 | "github.com/hashicorp/consul/api" 20 | "github.com/hashicorp/go-hclog" 21 | "github.com/hashicorp/go-rootcerts" 22 | ) 23 | 24 | const ( 25 | // Cert used for internal RPC communication to the servers 26 | ConsulGRPCCACertPemEnvVar = "CONSUL_GRPC_CACERT_PEM" 27 | 28 | ConsulDataplaneDNSBindHost = "127.0.0.1" 29 | ConsulDataplaneDNSBindPort = 8600 30 | 31 | // Login meta fields added to the token 32 | ConsulTokenTaskIDMeta = "consul.hashicorp.com/task-id" 33 | ConsulTokenClusterIDMeta = "consul.hashicorp.com/cluster" 34 | 35 | defaultGRPCPort = 8503 36 | defaultHTTPPort = 8501 37 | defaultIAMRolePath = "/consul-ecs/" 38 | 39 | // Cert used for securing HTTP traffic towards the server 40 | consulHTTPSCertPemEnvVar = "CONSUL_HTTPS_CACERT_PEM" 41 | 42 | bootstrapTokenEnvVar = "CONSUL_HTTP_TOKEN" 43 | ) 44 | 45 | type TLSSettings struct { 46 | Enabled bool 47 | CaCertFile string 48 | TLSServerName string 49 | } 50 | 51 | func (c *Config) ConsulServerConnMgrConfig(taskMeta awsutil.ECSTaskMeta) (discovery.Config, error) { 52 | cfg := discovery.Config{ 53 | Addresses: c.ConsulServers.Hosts, 54 | GRPCPort: c.ConsulServers.GRPC.Port, 55 | } 56 | 57 | grpcTLSSettings := c.ConsulServers.GetGRPCTLSSettings() 58 | if grpcTLSSettings.Enabled { 59 | tlsConfig := &tls.Config{} 60 | 61 | caCert := os.Getenv(ConsulGRPCCACertPemEnvVar) 62 | if caCert != "" { 63 | err := rootcerts.ConfigureTLS(tlsConfig, &rootcerts.Config{ 64 | CACertificate: []byte(caCert), 65 | }) 66 | if err != nil { 67 | return discovery.Config{}, err 68 | } 69 | } else if grpcTLSSettings.CaCertFile != "" { 70 | err := rootcerts.ConfigureTLS(tlsConfig, &rootcerts.Config{ 71 | CAFile: grpcTLSSettings.CaCertFile, 72 | }) 73 | if err != nil { 74 | return discovery.Config{}, err 75 | } 76 | } 77 | 78 | cfg.TLS = tlsConfig 79 | cfg.TLS.ServerName = grpcTLSSettings.TLSServerName 80 | } 81 | 82 | // We skip login if CONSUL_HTTP_TOKEN is non empty 83 | token := GetConsulToken() 84 | if token != "" { 85 | cfg.Credentials = discovery.Credentials{ 86 | Type: discovery.CredentialsTypeStatic, 87 | Static: discovery.StaticTokenCredential{ 88 | Token: token, 89 | }, 90 | } 91 | } else if c.ConsulLogin.Enabled { 92 | credentials, err := c.getLoginDiscoveryCredentials(taskMeta) 93 | if err != nil { 94 | return discovery.Config{}, err 95 | } 96 | 97 | cfg.Credentials = credentials 98 | } 99 | 100 | if c.ConsulServers.SkipServerWatch { 101 | cfg.ServerWatchDisabled = true 102 | } 103 | 104 | return cfg, nil 105 | } 106 | 107 | func (c *Config) ClientConfig() *api.Config { 108 | cfg := &api.Config{ 109 | Namespace: c.getNamespace(), 110 | Partition: c.getPartition(), 111 | Scheme: "http", 112 | } 113 | 114 | httpTLSSettings := c.ConsulServers.getHTTPTLSSettings() 115 | if c.ConsulServers.HTTP.EnableHTTPS { 116 | cfg.Scheme = "https" 117 | cfg.TLSConfig = api.TLSConfig{} 118 | 119 | caCert := os.Getenv(consulHTTPSCertPemEnvVar) 120 | if caCert != "" { 121 | cfg.TLSConfig.CAPem = []byte(caCert) 122 | } else if httpTLSSettings.CaCertFile != "" { 123 | cfg.TLSConfig.CAFile = httpTLSSettings.CaCertFile 124 | } 125 | 126 | if httpTLSSettings.TLSServerName != "" { 127 | cfg.TLSConfig.Address = httpTLSSettings.TLSServerName 128 | } else if !strings.HasPrefix(c.ConsulServers.Hosts, "exec=") { 129 | cfg.TLSConfig.Address = c.ConsulServers.Hosts 130 | } 131 | } 132 | 133 | return cfg 134 | } 135 | 136 | func (c *Config) IsGateway() bool { 137 | return c.Gateway != nil && c.Gateway.Kind != "" 138 | } 139 | 140 | func (c *ConsulServers) GetGRPCTLSSettings() *TLSSettings { 141 | enableTLS := c.Defaults.EnableTLS 142 | if c.GRPC.EnableTLS != nil { 143 | enableTLS = *c.GRPC.EnableTLS 144 | } 145 | 146 | caCertFile := c.Defaults.CaCertFile 147 | if c.GRPC.CaCertFile != "" { 148 | caCertFile = c.GRPC.CaCertFile 149 | } 150 | 151 | tlsServerName := c.Defaults.TLSServerName 152 | if c.GRPC.TLSServerName != "" { 153 | tlsServerName = c.GRPC.TLSServerName 154 | } 155 | 156 | return &TLSSettings{ 157 | Enabled: enableTLS, 158 | TLSServerName: tlsServerName, 159 | CaCertFile: caCertFile, 160 | } 161 | } 162 | 163 | func GetConsulToken() string { 164 | return os.Getenv(bootstrapTokenEnvVar) 165 | } 166 | 167 | func (c *Config) getLoginDiscoveryCredentials(taskMeta awsutil.ECSTaskMeta) (discovery.Credentials, error) { 168 | cfg := discovery.Credentials{ 169 | Type: discovery.CredentialsTypeLogin, 170 | Login: discovery.LoginCredential{ 171 | Datacenter: c.ConsulLogin.Datacenter, 172 | Partition: c.getPartition(), 173 | }, 174 | } 175 | 176 | authMethod := c.ConsulLogin.Method 177 | if authMethod == "" { 178 | authMethod = DefaultAuthMethodName 179 | } 180 | cfg.Login.AuthMethod = authMethod 181 | 182 | clusterARN, err := taskMeta.ClusterARN() 183 | if err != nil { 184 | return discovery.Credentials{}, err 185 | } 186 | 187 | cfg.Login.Meta = mergeMeta( 188 | map[string]string{ 189 | ConsulTokenTaskIDMeta: taskMeta.TaskID(), 190 | ConsulTokenClusterIDMeta: clusterARN, 191 | }, 192 | c.ConsulLogin.Meta, 193 | ) 194 | 195 | bearerToken, err := c.createAWSBearerToken(taskMeta) 196 | if err != nil { 197 | return discovery.Credentials{}, err 198 | } 199 | cfg.Login.BearerToken = bearerToken 200 | 201 | return cfg, nil 202 | } 203 | 204 | func (c *Config) getNamespace() string { 205 | if c.IsGateway() { 206 | return c.Gateway.Namespace 207 | } 208 | 209 | return c.Service.Namespace 210 | } 211 | 212 | func (c *Config) getPartition() string { 213 | if c.IsGateway() { 214 | return c.Gateway.Partition 215 | } 216 | 217 | return c.Service.Partition 218 | } 219 | 220 | func (c *Config) createAWSBearerToken(taskMeta awsutil.ECSTaskMeta) (string, error) { 221 | l := c.ConsulLogin 222 | 223 | region := l.Region 224 | if region == "" { 225 | r, err := taskMeta.Region() 226 | if err != nil { 227 | return "", err 228 | } 229 | region = r 230 | } 231 | 232 | cfg := aws.Config{ 233 | Region: aws.String(region), 234 | // More detailed error message to help debug credential discovery. 235 | CredentialsChainVerboseErrors: aws.Bool(true), 236 | } 237 | 238 | // support explicit creds for unit tests 239 | if l.AccessKeyID != "" { 240 | cfg.Credentials = credentials.NewStaticCredentials( 241 | l.AccessKeyID, l.SecretAccessKey, "", 242 | ) 243 | } 244 | 245 | // Session loads creds from standard sources (env vars, file, EC2 metadata, ...) 246 | sess, err := session.NewSessionWithOptions(session.Options{ 247 | Config: cfg, 248 | // Allow loading from config files by default: 249 | // ~/.aws/config or AWS_CONFIG_FILE 250 | // ~/.aws/credentials or AWS_SHARED_CREDENTIALS_FILE 251 | SharedConfigState: session.SharedConfigEnable, 252 | }) 253 | if err != nil { 254 | return "", err 255 | } 256 | 257 | if sess.Config.Credentials == nil { 258 | return "", fmt.Errorf("AWS credentials not found") 259 | } 260 | 261 | loginData, err := iamauth.GenerateLoginData(&iamauth.LoginInput{ 262 | Creds: sess.Config.Credentials, 263 | IncludeIAMEntity: l.IncludeEntity, 264 | STSEndpoint: l.STSEndpoint, 265 | STSRegion: region, 266 | Logger: hclog.New(nil), 267 | ServerIDHeaderValue: l.ServerIDHeaderValue, 268 | ServerIDHeaderName: IAMServerIDHeaderName, 269 | GetEntityMethodHeader: GetEntityMethodHeader, 270 | GetEntityURLHeader: GetEntityURLHeader, 271 | GetEntityHeadersHeader: GetEntityHeadersHeader, 272 | GetEntityBodyHeader: GetEntityBodyHeader, 273 | }) 274 | if err != nil { 275 | return "", err 276 | } 277 | 278 | loginDataJson, err := json.Marshal(loginData) 279 | if err != nil { 280 | return "", err 281 | } 282 | return string(loginDataJson), err 283 | } 284 | 285 | func (c *ConsulServers) getHTTPTLSSettings() *TLSSettings { 286 | enableTLS := c.Defaults.EnableTLS 287 | if c.HTTP.EnableTLS != nil { 288 | enableTLS = *c.HTTP.EnableTLS 289 | } 290 | 291 | caCertFile := c.Defaults.CaCertFile 292 | if c.HTTP.CaCertFile != "" { 293 | caCertFile = c.HTTP.CaCertFile 294 | } 295 | 296 | tlsServerName := c.Defaults.TLSServerName 297 | if c.HTTP.TLSServerName != "" { 298 | tlsServerName = c.HTTP.TLSServerName 299 | } 300 | 301 | return &TLSSettings{ 302 | Enabled: enableTLS, 303 | TLSServerName: tlsServerName, 304 | CaCertFile: caCertFile, 305 | } 306 | } 307 | 308 | func mergeMeta(m1, m2 map[string]string) map[string]string { 309 | result := make(map[string]string) 310 | 311 | for k, v := range m1 { 312 | result[k] = v 313 | } 314 | 315 | for k, v := range m2 { 316 | result[k] = v 317 | } 318 | 319 | return result 320 | } 321 | -------------------------------------------------------------------------------- /config/resources/test_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "consulServers": { 3 | "hosts": "consul.dc1" 4 | }, 5 | "service": { 6 | "name": "blah", 7 | "tags": [ 8 | "tag1" 9 | ], 10 | "meta": { 11 | "a": "1" 12 | }, 13 | "port": 1234 14 | }, 15 | "proxy": { 16 | "upstreams": [ 17 | { 18 | "destinationName": "asdf", 19 | "localBindPort": 543 20 | } 21 | ] 22 | }, 23 | "healthSyncContainers": [ 24 | "container1" 25 | ], 26 | "bootstrapDir": "/consul/" 27 | } 28 | -------------------------------------------------------------------------------- /config/resources/test_config_additional_properties_service.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrapDir": "/consul/", 3 | "healthSyncContainers": [ 4 | "frontend" 5 | ], 6 | "logLevel": "DEBUG", 7 | "consulLogin": { 8 | "enabled": true, 9 | "method": "my-auth-method", 10 | "includeEntity": false, 11 | "meta": { 12 | "tag-1": "val-1", 13 | "tag-2": "val-2" 14 | }, 15 | "datacenter": "dc1", 16 | "region": "bogus-east-2", 17 | "stsEndpoint": "https://sts.bogus-east-2.example.com", 18 | "serverIdHeaderValue": "my.consul.example.com" 19 | }, 20 | "controller": { 21 | "iamRolePath": "/consul-iam/", 22 | "partition": "default", 23 | "partitionsEnabled": true 24 | }, 25 | "consulServers": { 26 | "hosts": "consul.dc1", 27 | "skipServerWatch": true, 28 | "defaults": { 29 | "caCertFile": "/consul/ca-https-cert.pem", 30 | "tlsServerName": "consul.dc1", 31 | "tls": true 32 | }, 33 | "grpc": { 34 | "port": 8503 35 | }, 36 | "http": { 37 | "https": true, 38 | "port": 8501 39 | } 40 | }, 41 | "service": { 42 | "name": "frontend", 43 | "tags": [ 44 | "frontend" 45 | ], 46 | "port": 8080, 47 | "enableTagOverride": true, 48 | "meta": { 49 | "env": "test", 50 | "version": "x.y.z" 51 | }, 52 | "weights": { 53 | "passing": 6, 54 | "warning": 5 55 | }, 56 | "checks": [ 57 | { 58 | "name": "test-check-1" 59 | } 60 | ], 61 | "namespace": "test-ns", 62 | "partition": "test-partition" 63 | }, 64 | "gateway": { 65 | "kind": "mesh-gateway", 66 | "lanAddress": { 67 | "address": "10.0.0.1", 68 | "port": 8443 69 | }, 70 | "wanAddress": { 71 | "address": "172.16.0.0", 72 | "port": 443 73 | }, 74 | "name": "ecs-mesh-gateway", 75 | "tags": ["a", "b"], 76 | "meta": { 77 | "env": "test", 78 | "version": "x.y.z" 79 | }, 80 | "namespace": "ns1", 81 | "partition": "ptn1", 82 | "healthCheckPort": 22000, 83 | "proxy": { 84 | "config": { 85 | "data": "some-config-data" 86 | } 87 | } 88 | }, 89 | "proxy": { 90 | "publicListenerPort": 21000, 91 | "healthCheckPort": 22000, 92 | "localServiceAddress": "10.10.10.10", 93 | "config": { 94 | "data": "some-config-data" 95 | }, 96 | "upstreams": [ 97 | { 98 | "destinationType": "service", 99 | "destinationNamespace": "test-ns", 100 | "destinationPartition": "test-partition", 101 | "destinationPeer": "test-peer", 102 | "destinationName": "backend", 103 | "datacenter": "dc2", 104 | "localBindAddress": "localhost", 105 | "localBindPort": 1234, 106 | "config": { 107 | "data": "some-upstream-config-data" 108 | }, 109 | "meshGateway": { 110 | "mode": "local" 111 | } 112 | } 113 | ], 114 | "meshGateway": { 115 | "mode": "local" 116 | }, 117 | "expose": { 118 | "checks": true, 119 | "paths": [ 120 | { 121 | "listenerPort": 20001, 122 | "path": "/things", 123 | "localPathPort": 8080, 124 | "protocol": "http2" 125 | } 126 | ] 127 | } 128 | }, 129 | "transparentProxy": { 130 | "enabled": true, 131 | "excludeInboundPorts": [1234, 5678], 132 | "excludeOutboundPorts": [3456,8080], 133 | "excludeOutboundCIDRs": ["1.1.1.1/32"], 134 | "excludeUIDs": ["6678"], 135 | "consulDNS": { 136 | "enabled": false 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /config/resources/test_config_empty_fields.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrapDir": "/consul/", 3 | "healthSyncContainers": [], 4 | "consulLogin": {}, 5 | "consulServers": { 6 | "hosts": "" 7 | }, 8 | "controller": {}, 9 | "service": { 10 | "name": "", 11 | "tags": [], 12 | "port": 9000, 13 | "enableTagOverride": false, 14 | "meta": {}, 15 | "weights": null, 16 | "namespace": "", 17 | "partition": "" 18 | }, 19 | "gateway": { 20 | "kind": "mesh-gateway", 21 | "lanAddress": {}, 22 | "wanAddress": {}, 23 | "name": "", 24 | "tags": [], 25 | "meta": {}, 26 | "namespace": "", 27 | "partition": "", 28 | "healthCheckPort": 0, 29 | "proxy": {} 30 | }, 31 | "proxy": {}, 32 | "transparentProxy": {} 33 | } 34 | -------------------------------------------------------------------------------- /config/resources/test_config_missing_fields.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrapDir": "" 3 | } 4 | -------------------------------------------------------------------------------- /config/resources/test_config_null_nested_fields.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrapDir": "/consul/", 3 | "healthSyncContainers": null, 4 | "consulLogin": { 5 | "enabled": null, 6 | "method": null, 7 | "includeEntity": null, 8 | "datacenter": null, 9 | "meta": null, 10 | "region": null, 11 | "stsEndpoint": null, 12 | "serverIdHeaderValue": null 13 | }, 14 | "consulServers": { 15 | "hosts": "", 16 | "skipServerWatch": null, 17 | "defaults": { 18 | "caCertFile": null, 19 | "tlsServerName": null, 20 | "tls": null 21 | }, 22 | "grpc": { 23 | "port": null, 24 | "caCertFile": null, 25 | "tlsServerName": null, 26 | "tls": null 27 | }, 28 | "http": { 29 | "https": null, 30 | "port": null, 31 | "caCertFile": null, 32 | "tlsServerName": null, 33 | "tls": null 34 | } 35 | }, 36 | "controller": { 37 | "iamRolePath": null, 38 | "partition": null, 39 | "partitionsEnabled": null 40 | }, 41 | "service": { 42 | "name": null, 43 | "tags": null, 44 | "port": 9000, 45 | "enableTagOverride": null, 46 | "meta": null, 47 | "weights": null, 48 | "namespace": null 49 | }, 50 | "gateway": { 51 | "kind": "mesh-gateway", 52 | "lanAddress": { 53 | "address": null, 54 | "port": null 55 | }, 56 | "wanAddress": { 57 | "address": null, 58 | "port": null 59 | }, 60 | "name": null, 61 | "tags": null, 62 | "meta": null, 63 | "namespace": null, 64 | "partition": null, 65 | "healthCheckPort": 22000, 66 | "proxy": { 67 | "config": null 68 | } 69 | }, 70 | "proxy": { 71 | "config": null, 72 | "publicListenerPort": null, 73 | "healthCheckPort": null, 74 | "upstreams": [ 75 | { 76 | "destinationType": null, 77 | "destinationNamespace": null, 78 | "destinationPartition": null, 79 | "destinationName": "backend", 80 | "destinationPeer": null, 81 | "datacenter": null, 82 | "localBindAddress": null, 83 | "localBindPort": 2345, 84 | "config": null, 85 | "meshGateway": null 86 | } 87 | ], 88 | "meshGateway": null, 89 | "expose": null 90 | }, 91 | "transparentProxy": { 92 | "enabled": null, 93 | "excludeInboundPorts": null, 94 | "excludeOutboundPorts": null, 95 | "excludeOutboundCIDRs": null, 96 | "excludeUIDs": null, 97 | "consulDNS": { 98 | "enabled": null 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /config/resources/test_config_null_top_level_fields.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrapDir": "/consul/", 3 | "healthSyncContainers": null, 4 | "logLevel": null, 5 | "consulLogin": null, 6 | "controller": null, 7 | "consulServers": { 8 | "hosts": "", 9 | "skipServerWatch": null, 10 | "defaults": null, 11 | "grpc": null, 12 | "http": null 13 | }, 14 | "service": { 15 | "name": null, 16 | "tags": null, 17 | "port": 9000, 18 | "enableTagOverride": null, 19 | "meta": null, 20 | "weights": null, 21 | "namespace": null, 22 | "partition": null 23 | }, 24 | "gateway": { 25 | "kind": "mesh-gateway", 26 | "lanAddress": null, 27 | "wanAddress": null, 28 | "name": null, 29 | "tags": null, 30 | "meta": null, 31 | "namespace": null, 32 | "partition": null, 33 | "proxy": null, 34 | "healthCheckPort": null 35 | }, 36 | "proxy": null, 37 | "transparentProxy": null 38 | } 39 | -------------------------------------------------------------------------------- /config/resources/test_config_uppercase_service_names.json: -------------------------------------------------------------------------------- 1 | { 2 | "consulServers": { 3 | "hosts": "consul.dc1" 4 | }, 5 | "service": { 6 | "name": "SERVICE-NAME", 7 | "tags": [ 8 | "tag1" 9 | ], 10 | "meta": { 11 | "a": "1" 12 | }, 13 | "port": 1234 14 | }, 15 | "gateway": { 16 | "kind": "mesh-gateway", 17 | "name": "GATEWAY-NAME" 18 | }, 19 | "proxy": { 20 | "upstreams": [ 21 | { 22 | "destinationName": "asdf", 23 | "localBindPort": 543 24 | } 25 | ] 26 | }, 27 | "healthSyncContainers": [ 28 | "container1" 29 | ], 30 | "bootstrapDir": "/consul/" 31 | } 32 | -------------------------------------------------------------------------------- /config/resources/test_extensive_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrapDir": "/consul/", 3 | "healthSyncContainers": [ 4 | "frontend" 5 | ], 6 | "logLevel": "DEBUG", 7 | "controller": { 8 | "iamRolePath": "/consul-iam/", 9 | "partition": "default", 10 | "partitionsEnabled": true 11 | }, 12 | "consulLogin": { 13 | "enabled": true, 14 | "method": "my-auth-method", 15 | "includeEntity": false, 16 | "meta": { 17 | "tag-1": "val-1", 18 | "tag-2": "val-2" 19 | }, 20 | "datacenter": "dc1", 21 | "region": "bogus-east-2", 22 | "stsEndpoint": "https://sts.bogus-east-2.example.com", 23 | "serverIdHeaderValue": "my.consul.example.com" 24 | }, 25 | "consulServers": { 26 | "hosts": "consul.dc1", 27 | "skipServerWatch": true, 28 | "defaults": { 29 | "caCertFile": "/consul/ca-cert.pem", 30 | "tlsServerName": "consul.dc1", 31 | "tls": true 32 | }, 33 | "grpc": { 34 | "port": 8503, 35 | "caCertFile": "/consul/ca-cert-1.pem", 36 | "tlsServerName": "consul.dc2", 37 | "tls": true 38 | }, 39 | "http": { 40 | "https": true, 41 | "port": 8501, 42 | "caCertFile": "/consul/ca-cert-2.pem", 43 | "tlsServerName": "consul.dc3", 44 | "tls": true 45 | } 46 | }, 47 | "service": { 48 | "name": "frontend", 49 | "tags": [ 50 | "frontend" 51 | ], 52 | "port": 8080, 53 | "enableTagOverride": true, 54 | "meta": { 55 | "env": "test", 56 | "version": "x.y.z" 57 | }, 58 | "weights": { 59 | "passing": 6, 60 | "warning": 5 61 | }, 62 | "namespace": "test-ns", 63 | "partition": "test-partition" 64 | }, 65 | "gateway": { 66 | "kind": "mesh-gateway", 67 | "lanAddress": { 68 | "address": "10.0.0.1", 69 | "port": 8443 70 | }, 71 | "wanAddress": { 72 | "address": "172.16.0.0", 73 | "port": 443 74 | }, 75 | "name": "ecs-mesh-gateway", 76 | "tags": ["a", "b"], 77 | "meta": { 78 | "env": "test", 79 | "version": "x.y.z" 80 | }, 81 | "namespace": "ns1", 82 | "partition": "ptn1", 83 | "healthCheckPort": 22000, 84 | "proxy": { 85 | "config": { 86 | "data": "some-config-data" 87 | } 88 | } 89 | }, 90 | "proxy": { 91 | "publicListenerPort": 21000, 92 | "healthCheckPort": 22000, 93 | "localServiceAddress": "10.10.10.10", 94 | "config": { 95 | "data": "some-config-data" 96 | }, 97 | "upstreams": [ 98 | { 99 | "destinationType": "service", 100 | "destinationNamespace": "test-ns", 101 | "destinationPartition": "test-partition", 102 | "destinationName": "backend", 103 | "destinationPeer": "test-peer", 104 | "datacenter": "dc2", 105 | "localBindAddress": "localhost", 106 | "localBindPort": 1234, 107 | "config": { 108 | "data": "some-upstream-config-data" 109 | }, 110 | "meshGateway": { 111 | "mode": "local" 112 | } 113 | } 114 | ], 115 | "meshGateway": { 116 | "mode": "local" 117 | }, 118 | "expose": { 119 | "checks": true, 120 | "paths": [ 121 | { 122 | "listenerPort": 20001, 123 | "path": "/things", 124 | "localPathPort": 8080, 125 | "protocol": "http2" 126 | } 127 | ] 128 | } 129 | }, 130 | "transparentProxy": { 131 | "enabled": true, 132 | "excludeInboundPorts": [1234, 5678], 133 | "excludeOutboundPorts": [3456,8080], 134 | "excludeOutboundCIDRs": ["1.1.1.1/32"], 135 | "excludeUIDs": ["6678"], 136 | "consulDNS": { 137 | "enabled": true 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /config/schema.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package config 5 | 6 | import _ "embed" 7 | 8 | //go:embed schema.json 9 | var Schema string 10 | -------------------------------------------------------------------------------- /config/validate.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package config 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "os" 10 | 11 | "github.com/hashicorp/go-multierror" 12 | "github.com/xeipuuv/gojsonschema" 13 | ) 14 | 15 | const ( 16 | ConfigEnvironmentVariable = "CONSUL_ECS_CONFIG_JSON" 17 | ) 18 | 19 | func validate(config string) error { 20 | schemaLoader := gojsonschema.NewStringLoader(Schema) 21 | configLoader := gojsonschema.NewStringLoader(config) 22 | 23 | result, err := gojsonschema.Validate(schemaLoader, configLoader) 24 | if err != nil { 25 | return err 26 | } 27 | if result.Valid() { 28 | return nil 29 | } 30 | 31 | for _, e := range result.Errors() { 32 | err = multierror.Append(err, fmt.Errorf("%s", e.String())) 33 | } 34 | return err 35 | } 36 | 37 | func parse(encodedConfig string) (*Config, error) { 38 | if err := validate(encodedConfig); err != nil { 39 | return nil, err 40 | } 41 | 42 | var config Config 43 | if err := json.Unmarshal([]byte(encodedConfig), &config); err != nil { 44 | return nil, err 45 | } 46 | return &config, nil 47 | } 48 | 49 | func FromEnv() (*Config, error) { 50 | rawConfig := os.Getenv(ConfigEnvironmentVariable) 51 | if rawConfig == "" { 52 | return nil, fmt.Errorf("%q isn't populated", ConfigEnvironmentVariable) 53 | } 54 | return parse(rawConfig) 55 | } 56 | -------------------------------------------------------------------------------- /controller/controller.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package controller 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/hashicorp/go-hclog" 12 | "github.com/hashicorp/go-multierror" 13 | ) 14 | 15 | const DefaultPollingInterval = 10 * time.Second 16 | 17 | // Controller is a generic controller implementation. 18 | // It periodically polls for Resources and reconciles 19 | // them by calling Resource's Upsert or Delete function accordingly. 20 | type Controller struct { 21 | // Resources lists resources for Controller to reconcile. 22 | Resources ResourceLister 23 | // PollingInterval is an interval that Controller will use to reconcile all Resources. 24 | PollingInterval time.Duration 25 | // Log is the logger used by the Controller. 26 | Log hclog.Logger 27 | } 28 | 29 | // Run starts the Controller loop. The loop will exit when ctx is canceled. 30 | func (c *Controller) Run(ctx context.Context) { 31 | for { 32 | select { 33 | case <-time.After(c.PollingInterval): 34 | err := c.reconcile() 35 | if err != nil { 36 | c.Log.Error("error during reconcile", "err", err) 37 | } 38 | case <-ctx.Done(): 39 | return 40 | } 41 | } 42 | } 43 | 44 | // reconcile first lists all resources and then reconciles them with Controller's state. 45 | func (c *Controller) reconcile() error { 46 | c.Log.Debug("starting reconcile") 47 | resources, err := c.Resources.List() 48 | if err != nil { 49 | return fmt.Errorf("listing resources: %w", err) 50 | } 51 | 52 | var merr error 53 | if err = c.Resources.ReconcileNamespaces(resources); err != nil { 54 | merr = multierror.Append(merr, fmt.Errorf("reconciling namespaces: %w", err)) 55 | } 56 | 57 | for _, resource := range resources { 58 | err = resource.Reconcile() 59 | if err != nil { 60 | merr = multierror.Append(err, fmt.Errorf("reconciling resource: %w", err)) 61 | } 62 | } 63 | 64 | c.Log.Debug("reconcile finished") 65 | return merr 66 | } 67 | -------------------------------------------------------------------------------- /controller/controller_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package controller 5 | 6 | import ( 7 | "context" 8 | "sync" 9 | "testing" 10 | "time" 11 | 12 | "github.com/hashicorp/consul/sdk/testutil/retry" 13 | "github.com/hashicorp/go-hclog" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | // global mutex for these tests to pass the race detector 18 | var mutex sync.Mutex 19 | 20 | func TestRun(t *testing.T) { 21 | t.Parallel() 22 | resource1 := &testResource{ 23 | name: "resource1", 24 | } 25 | 26 | resource2 := &testResource{ 27 | name: "resource2", 28 | } 29 | 30 | lister := &testResourceLister{ 31 | resources: []*testResource{resource1, resource2}, 32 | } 33 | 34 | ctrl := Controller{ 35 | Resources: lister, 36 | PollingInterval: 1 * time.Second, 37 | Log: hclog.NewNullLogger(), 38 | } 39 | 40 | ctx, cancelFunc := context.WithCancel(context.Background()) 41 | t.Cleanup(cancelFunc) 42 | 43 | go ctrl.Run(ctx) 44 | 45 | retry.Run(t, func(r *retry.R) { 46 | mutex.Lock() 47 | defer mutex.Unlock() 48 | require.True(r, lister.nsReconciled) 49 | for _, resource := range lister.resources { 50 | require.True(r, resource.reconciled) 51 | } 52 | }) 53 | } 54 | 55 | type testResourceLister struct { 56 | resources []*testResource 57 | nsReconciled bool 58 | } 59 | 60 | type testResource struct { 61 | name string 62 | reconciled bool 63 | } 64 | 65 | func (t *testResourceLister) List() ([]Resource, error) { 66 | mutex.Lock() 67 | defer mutex.Unlock() 68 | 69 | var resources []Resource 70 | for _, resource := range t.resources { 71 | resources = append(resources, resource) 72 | } 73 | return resources, nil 74 | } 75 | 76 | func (t *testResourceLister) ReconcileNamespaces([]Resource) error { 77 | mutex.Lock() 78 | defer mutex.Unlock() 79 | 80 | t.nsReconciled = true 81 | return nil 82 | } 83 | 84 | func (t *testResource) Reconcile() error { 85 | mutex.Lock() 86 | defer mutex.Unlock() 87 | 88 | t.reconciled = true 89 | return nil 90 | } 91 | 92 | func (t *testResource) Namespace() string { 93 | return "" 94 | } 95 | 96 | func (t *testResource) ID() TaskID { 97 | return "" 98 | } 99 | 100 | func (t *testResource) IsPresent() bool { 101 | return true 102 | } 103 | -------------------------------------------------------------------------------- /controller/mocks/ecs_client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package mocks 5 | 6 | import ( 7 | "github.com/aws/aws-sdk-go/service/ecs" 8 | "github.com/aws/aws-sdk-go/service/ecs/ecsiface" 9 | mapset "github.com/deckarep/golang-set" 10 | ) 11 | 12 | type ECSClient struct { 13 | ecsiface.ECSAPI 14 | Tasks []*ecs.Task 15 | PaginateResults bool 16 | } 17 | 18 | func (m *ECSClient) ListTasks(input *ecs.ListTasksInput) (*ecs.ListTasksOutput, error) { 19 | var taskARNs []*string 20 | var nextToken *string 21 | if m.PaginateResults && input.NextToken == nil { 22 | for _, t := range m.Tasks[:len(m.Tasks)/2] { 23 | taskARNs = append(taskARNs, t.TaskArn) 24 | } 25 | nextToken = m.Tasks[len(m.Tasks)/2].TaskArn 26 | } else if m.PaginateResults && input.NextToken != nil { 27 | for _, t := range m.Tasks[len(m.Tasks)/2:] { 28 | taskARNs = append(taskARNs, t.TaskArn) 29 | } 30 | } else { 31 | for _, t := range m.Tasks { 32 | taskARNs = append(taskARNs, t.TaskArn) 33 | } 34 | } 35 | return &ecs.ListTasksOutput{ 36 | NextToken: nextToken, 37 | TaskArns: taskARNs, 38 | }, nil 39 | } 40 | 41 | func (m *ECSClient) DescribeTasks(input *ecs.DescribeTasksInput) (*ecs.DescribeTasksOutput, error) { 42 | var tasksResult []*ecs.Task 43 | taskARNsInput := mapset.NewSet() 44 | for _, arn := range input.Tasks { 45 | taskARNsInput.Add(*arn) 46 | } 47 | 48 | // Only return Tasks asked for in the input. 49 | for _, task := range m.Tasks { 50 | if taskARNsInput.Contains(*task.TaskArn) { 51 | tasksResult = append(tasksResult, task) 52 | } 53 | } 54 | return &ecs.DescribeTasksOutput{Tasks: tasksResult}, nil 55 | } 56 | -------------------------------------------------------------------------------- /controller/mocks/sm_client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package mocks 5 | 6 | import ( 7 | "github.com/aws/aws-sdk-go/service/secretsmanager" 8 | "github.com/aws/aws-sdk-go/service/secretsmanager/secretsmanageriface" 9 | ) 10 | 11 | type SMClient struct { 12 | secretsmanageriface.SecretsManagerAPI 13 | Secret *secretsmanager.GetSecretValueOutput 14 | } 15 | 16 | func (m *SMClient) GetSecretValue(*secretsmanager.GetSecretValueInput) (*secretsmanager.GetSecretValueOutput, error) { 17 | return m.Secret, nil 18 | } 19 | 20 | func (m *SMClient) UpdateSecret(input *secretsmanager.UpdateSecretInput) (*secretsmanager.UpdateSecretOutput, error) { 21 | m.Secret.Name = input.SecretId 22 | m.Secret.SecretString = input.SecretString 23 | return nil, nil 24 | } 25 | -------------------------------------------------------------------------------- /controller/policy.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package controller 5 | 6 | // Cross namespace policy constants 7 | const xnsPolicyName = "cross-namespace-read" 8 | const xnsPolicyDesc = "Allow service and node reads across namespaces within the partition" 9 | const xnsPolicyTpl = `partition "%s" { 10 | namespace_prefix "" { 11 | service_prefix "" { 12 | policy = "read" 13 | } 14 | node_prefix "" { 15 | policy = "read" 16 | } 17 | } 18 | }` 19 | -------------------------------------------------------------------------------- /entrypoint/cmd.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build !windows 5 | // +build !windows 6 | 7 | package entrypoint 8 | 9 | import ( 10 | "os" 11 | "os/exec" 12 | "syscall" 13 | 14 | "github.com/hashicorp/go-hclog" 15 | ) 16 | 17 | // Cmd runs a command in a subprocess (asynchronously). 18 | // Call `go cmd.Run()` to run the command asynchronously. 19 | // Use the Started() channel to wait for the command to start. 20 | // Use the Done() channel to wait for the command to complete. 21 | type Cmd struct { 22 | *exec.Cmd 23 | 24 | log hclog.Logger 25 | doneCh chan struct{} 26 | startedCh chan struct{} 27 | } 28 | 29 | func NewCmd(log hclog.Logger, args []string) *Cmd { 30 | cmd := exec.Command(args[0], args[1:]...) 31 | cmd.Stdin = os.Stdin 32 | cmd.Stdout = os.Stdout 33 | cmd.Stderr = os.Stderr 34 | // Use a process group that we can signal for cleanup. 35 | cmd.SysProcAttr = &syscall.SysProcAttr{ 36 | Setpgid: true, 37 | } 38 | 39 | return &Cmd{ 40 | Cmd: cmd, 41 | log: log, 42 | doneCh: make(chan struct{}, 1), 43 | startedCh: make(chan struct{}, 1), 44 | } 45 | } 46 | 47 | // Run the command. The Started() and Done() functions can be used 48 | // to wait for the process to start and exit, respectively. 49 | func (e *Cmd) Run() { 50 | defer close(e.doneCh) 51 | defer close(e.startedCh) 52 | 53 | if err := e.Cmd.Start(); err != nil { 54 | e.log.Error("starting process", "error", err.Error()) 55 | // Closed channels (in defers) indicate the command failed to start. 56 | return 57 | } 58 | e.startedCh <- struct{}{} 59 | 60 | if err := e.Cmd.Wait(); err != nil { 61 | if _, ok := err.(*exec.ExitError); !ok { 62 | // Do not log if it is only a non-zero exit code. 63 | e.log.Error("waiting for process to finish", "error", err.Error()) 64 | } 65 | } 66 | e.doneCh <- struct{}{} 67 | } 68 | 69 | func (e *Cmd) Started() chan struct{} { 70 | return e.startedCh 71 | } 72 | 73 | func (e *Cmd) Done() chan struct{} { 74 | return e.doneCh 75 | } 76 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hashicorp/consul-ecs 2 | 3 | go 1.23.6 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go v1.55.5 7 | github.com/cenkalti/backoff/v4 v4.1.3 8 | github.com/deckarep/golang-set v1.7.1 9 | github.com/google/go-cmp v0.5.9 10 | github.com/hashicorp/consul-awsauth v0.0.0-20250130185352-0a5f57fe920a 11 | github.com/hashicorp/consul-server-connection-manager v0.1.2 12 | github.com/hashicorp/consul/api v1.26.1-rc1 13 | github.com/hashicorp/consul/sdk v0.16.0 14 | github.com/hashicorp/go-hclog v1.6.3 15 | github.com/hashicorp/go-multierror v1.1.1 16 | github.com/hashicorp/go-rootcerts v1.0.2 17 | github.com/hashicorp/go-uuid v1.0.3 18 | github.com/hashicorp/serf v0.10.1 19 | github.com/miekg/dns v1.1.41 20 | github.com/mitchellh/cli v1.1.5 21 | github.com/mitchellh/mapstructure v1.5.0 22 | github.com/stretchr/testify v1.8.3 23 | github.com/xeipuuv/gojsonschema v1.2.0 24 | ) 25 | 26 | require ( 27 | github.com/Masterminds/goutils v1.1.1 // indirect 28 | github.com/Masterminds/semver/v3 v3.1.1 // indirect 29 | github.com/Masterminds/sprig/v3 v3.2.2 // indirect 30 | github.com/armon/go-metrics v0.4.1 // indirect 31 | github.com/armon/go-radix v1.0.0 // indirect 32 | github.com/beorn7/perks v1.0.1 // indirect 33 | github.com/bgentry/speakeasy v0.1.0 // indirect 34 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 35 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 36 | github.com/fatih/color v1.16.0 // indirect 37 | github.com/golang/protobuf v1.5.4 // indirect 38 | github.com/google/uuid v1.3.0 // indirect 39 | github.com/hashicorp/consul/proto-public v0.1.0 // indirect 40 | github.com/hashicorp/errwrap v1.1.0 // indirect 41 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 42 | github.com/hashicorp/go-immutable-radix v1.3.1 // indirect 43 | github.com/hashicorp/go-netaddrs v0.1.0 // indirect 44 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 45 | github.com/hashicorp/go-version v1.2.1 // indirect 46 | github.com/hashicorp/golang-lru v0.5.4 // indirect 47 | github.com/huandu/xstrings v1.3.3 // indirect 48 | github.com/imdario/mergo v0.3.13 // indirect 49 | github.com/jmespath/go-jmespath v0.4.0 // indirect 50 | github.com/mattn/go-colorable v0.1.13 // indirect 51 | github.com/mattn/go-isatty v0.0.20 // indirect 52 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 53 | github.com/mitchellh/copystructure v1.2.0 // indirect 54 | github.com/mitchellh/go-homedir v1.1.0 // indirect 55 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 56 | github.com/pkg/errors v0.9.1 // indirect 57 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 58 | github.com/posener/complete v1.2.3 // indirect 59 | github.com/prometheus/client_golang v1.11.1 // indirect 60 | github.com/prometheus/client_model v0.2.0 // indirect 61 | github.com/prometheus/common v0.26.0 // indirect 62 | github.com/prometheus/procfs v0.6.0 // indirect 63 | github.com/shopspring/decimal v1.3.1 // indirect 64 | github.com/spf13/cast v1.5.0 // indirect 65 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect 66 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 67 | golang.org/x/crypto v0.36.0 // indirect 68 | golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect 69 | golang.org/x/net v0.38.0 // indirect 70 | golang.org/x/sys v0.31.0 // indirect 71 | golang.org/x/text v0.23.0 // indirect 72 | google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect 73 | google.golang.org/grpc v1.56.3 // indirect 74 | google.golang.org/protobuf v1.33.0 // indirect 75 | gopkg.in/yaml.v3 v3.0.1 // indirect 76 | ) 77 | -------------------------------------------------------------------------------- /hack/generate-config-reference/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | // Generate markdown from the JSON schema for the Consul ECS config. 5 | // 6 | // You can use the Makefile in the root of the repository to run this script. 7 | // Usage: 8 | // make reference-configuration 9 | // make reference-configuration consul= 10 | // go run . > ../../../website/content/docs/ecs/configuration-reference.mdx 11 | // 12 | // This generates configuration from the schema.json, recursively: 13 | // - First, write the preamble to stdout (see ./preamble.mdx). 14 | // - For each object or array type in schema.json, render a template to stdout (see ./properties.tpl) 15 | // 16 | // Edit the /config/schema.json to modify descriptions. 17 | // After editing the schema.json, re-run this script to update the Consul ECS documentation. 18 | // The generated markdown should be included in ECS docs in the Consul repo. 19 | 20 | package main 21 | 22 | import ( 23 | _ "embed" 24 | "encoding/json" 25 | "fmt" 26 | "io" 27 | "log" 28 | "os" 29 | "sort" 30 | "strings" 31 | "text/template" 32 | 33 | "github.com/hashicorp/consul-ecs/config" 34 | ) 35 | 36 | var ( 37 | //go:embed preamble.mdx 38 | preamble string 39 | 40 | //go:embed properties.tpl 41 | propertiesTemplate string 42 | 43 | // Markdown heading prefixes 44 | depthToHeading = []string{ 45 | 0: "#", 46 | 1: "#", 47 | 2: "##", 48 | 3: "###", 49 | 4: "####", 50 | 5: "#####", 51 | 6: "######", 52 | } 53 | ) 54 | 55 | // getTemplate returns a template for a section of markdown, with the given path as the heading. 56 | func getTemplate(path string) *template.Template { 57 | // Generate heading based on path depth: "## `service.checks`" 58 | depth := strings.Count(path, ".") 59 | heading := depthToHeading[depth] 60 | if path == "" { 61 | heading += " Top-level fields" 62 | } else { 63 | heading += " `" + path + "`" 64 | } 65 | 66 | templateString := heading + "\n\n" + propertiesTemplate 67 | tpl, err := template.New(path).Parse(templateString) 68 | if err != nil { 69 | log.Fatal(err) 70 | } 71 | return tpl 72 | } 73 | 74 | // RenderTemplates walks through the schema recursively to generate markdown. 75 | // It writes directly to the provided io.Writer. 76 | func RenderTemplates(path string, schema *Schema, wr io.Writer) { 77 | if schema.Type[0] == "array" { 78 | itemSchema := schema.Items 79 | RenderTemplates(path, itemSchema, wr) 80 | return 81 | } 82 | 83 | if schema.Type[0] == "object" && schema.Properties != nil { 84 | tpl := getTemplate(path) 85 | 86 | schema.Path = path 87 | err := tpl.Execute(wr, schema) 88 | if err != nil { 89 | log.Fatal(err) 90 | } 91 | 92 | for _, field := range sortedKeys(schema.Properties) { 93 | propSchema := schema.Properties[field] 94 | propPath := strings.Trim(path+"."+field, ".") 95 | RenderTemplates(propPath, propSchema, wr) 96 | } 97 | } 98 | } 99 | 100 | func sortedKeys(props map[string]*Schema) []string { 101 | result := make([]string, 0, len(props)) 102 | for k := range props { 103 | result = append(result, k) 104 | } 105 | sort.Strings(result) 106 | return result 107 | } 108 | 109 | func main() { 110 | var schema Schema 111 | if err := json.Unmarshal([]byte(config.Schema), &schema); err != nil { 112 | log.Fatal(err) 113 | } 114 | 115 | outWriter := os.Stdout 116 | 117 | _, _ = fmt.Fprintln(outWriter, preamble) 118 | RenderTemplates("", &schema, outWriter) 119 | } 120 | -------------------------------------------------------------------------------- /hack/generate-config-reference/preamble.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | layout: docs 3 | page_title: AWS ECS 4 | description: >- 5 | Configuration Reference for Consul on AWS ECS (Elastic Container Service). 6 | Do not modify by hand! This is automatically generated documentation. 7 | --- 8 | 9 | # Configuration Reference 10 | 11 | This pages details the configuration options for the JSON config format used 12 | by the `consul-ecs` binary. This configuration is passed to the `consul-ecs` 13 | binary as a string using the `CONSUL_ECS_CONFIG_JSON` environment variable. 14 | 15 | This configuration format follows a [JSON schema](https://github.com/hashicorp/consul-ecs/blob/main/config/schema.json) 16 | that can be used for validation. 17 | 18 | ## Terraform Mesh Task Module Configuration 19 | 20 | The `mesh-task` Terraform module provides input variables for commonly used fields. 21 | The following table shows which Terraform input variables correspond to each field 22 | of the Consul ECS configuration. Refer to the 23 | [Terraform registry documentation](https://registry.terraform.io/modules/hashicorp/consul-ecs/aws/latest/submodules/mesh-task?tab=inputs) 24 | for a complete reference of supported input variables for the `mesh-task` module. 25 | 26 | | Terraform Input Variable | Consul ECS Config Field | 27 | | ------------------------ | ------------------------------------- | 28 | | `upstreams` | [`proxy.upstreams`](#proxy-upstreams) | 29 | | `checks` | [`service.checks`](#service-checks) | 30 | | `consul_service_name` | [`service.name`](#service) | 31 | | `consul_service_tags` | [`service.tags`](#service) | 32 | | `consul_service_meta` | [`service.meta`](#service) | 33 | | `consul_namespace` | [`service.namespace`](#service) | 34 | | `consul_partition` | [`service.partition`](#service) | 35 | 36 | Each of these Terraform input variables follow the Consul ECS config schema. 37 | The remaining fields of the Consul ECS configuration not listed in this table can be passed 38 | using the `consul_ecs_config` input variable. 39 | -------------------------------------------------------------------------------- /hack/generate-config-reference/properties.tpl: -------------------------------------------------------------------------------- 1 | {{ .DescriptionStr }} 2 | 3 | | Field | Type | Required | Description | 4 | | ----- | ---- | -------- | ----------- | 5 | {{- range $key, $val := .Properties }} 6 | {{- with $anchor := $.PropertyAnchor $key }} 7 | | [`{{ $key }}`](#{{ $anchor }}) | `{{ index $val.Type 0 }}` | {{ $.RequiredStr $key }} | {{ $val.DescriptionStr }} {{ $val.EnumStr }} | 8 | {{- else }} 9 | | `{{ $key }}` | `{{ index $val.Type 0 }}` | {{ $.RequiredStr $key }} | {{ $val.DescriptionStr }} {{ $val.EnumStr }} | 10 | {{- end }} 11 | {{- end }} 12 | 13 | -------------------------------------------------------------------------------- /hack/generate-config-reference/schema.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "regexp" 10 | "strings" 11 | ) 12 | 13 | // entTagRegex matches the '[Consul Enterprise]' text and any leading space. 14 | var entTagRegex *regexp.Regexp = regexp.MustCompile(`\s+\[Consul Enterprise\]`) 15 | 16 | type Schema struct { 17 | ID string `json:"$id"` 18 | Schema string `json:"$schema"` 19 | Title string `json:"title"` 20 | Description string `json:"description"` 21 | Type jsonschemaType `json:"type"` 22 | Properties map[string]*Schema `json:"properties"` 23 | Items *Schema `json:"items"` 24 | 25 | AdditionalProperties bool `json:"additionalProperties"` 26 | MinLength int `json:"minLength"` 27 | Enum []*string `json:"enum"` 28 | MinItems int `json:"minItems"` 29 | Required []string `json:"required"` 30 | UniqueItems bool `json:"uniqueItems"` 31 | 32 | // Extra fields for template args. 33 | Path string `json:"-"` 34 | } 35 | 36 | // DescriptionStr returns the description modified for consul.io docs: 37 | // - Remove "[Consul Enterprise]" and prefix a "". 38 | // - Remove leading space as well, so that " [Consul Enterprise]." becomes "." 39 | // at the end of a sentence. 40 | func (s *Schema) DescriptionStr() string { 41 | modified := entTagRegex.ReplaceAllString(s.Description, "") 42 | if modified != s.Description { 43 | modified = " " + modified 44 | } 45 | return modified 46 | } 47 | 48 | // RequiredStr returns "required" or "optional" if the given 49 | // field is in the list of required fields for this schema. 50 | func (s *Schema) RequiredStr(field string) string { 51 | for _, req := range s.Required { 52 | if req == field { 53 | return "required" 54 | } 55 | } 56 | return "optional" 57 | } 58 | 59 | // EnumStr joins the enum list for this schema into human-readable markdown, such as 60 | // "Must be one of `local`, `remote`, or `none`." If this schema has no enum field, 61 | // an empty string is returned. 62 | func (s *Schema) EnumStr() string { 63 | // Convert []*string to []string 64 | strs := make([]string, 0, len(s.Enum)) 65 | for _, val := range s.Enum { 66 | if val == nil { 67 | strs = append(strs, "`null`") 68 | } else if *val == "" { 69 | strs = append(strs, "`\"\"`") 70 | } else { 71 | strs = append(strs, "`"+*val+"`") 72 | } 73 | } 74 | 75 | var result string 76 | if len(strs) > 1 { 77 | last := len(strs) - 1 78 | result = "Must be one of " + strings.Join(strs[:last], ", ") + ", or " + strs[last] + "." 79 | } else if len(strs) == 1 { 80 | result = "Must be " + strs[0] + "." 81 | } 82 | return result 83 | } 84 | 85 | // PropertyAnchor returns the markdown/html anchor for a field in a table, if the field 86 | // is an object or array type. The anchor links to another section in the page describing 87 | // that field so that users can navigate the page more easily. 88 | // 89 | // This will return an empty string if the field has no suitable link. 90 | func (t *Schema) PropertyAnchor(field string) string { 91 | propSchema := t.Properties[field] 92 | 93 | var fieldProperties map[string]*Schema 94 | switch propSchema.Type[0] { 95 | case "object": 96 | fieldProperties = propSchema.Properties 97 | case "array": 98 | fieldProperties = propSchema.Items.Properties 99 | default: 100 | return "" 101 | } 102 | 103 | if len(fieldProperties) == 0 { 104 | // If the type has no properties documented, there's no section in the markdown describing those properties, 105 | // so there's nothing to link to. This is the case for, e.g. the `meta` field, which has arbitrary data. 106 | return "" 107 | } 108 | // Convert from e.g. `proxy.upstreams.meshGateway` -> `proxy-upstreams-meshgateway` to 109 | // match markdown/html anchors. 110 | anchor := strings.Trim(t.Path+"."+field, ".") 111 | anchor = strings.ReplaceAll(anchor, ".", "-") 112 | return strings.ToLower(anchor) 113 | } 114 | 115 | // Special parsing for the `type` field, which can be a string or []string. 116 | // Normalize the "type" to []string. 117 | type jsonschemaType []string 118 | 119 | func (t *jsonschemaType) UnmarshalJSON(data []byte) error { 120 | var array []string 121 | if err := json.Unmarshal(data, &array); err == nil { 122 | *t = append(*t, array...) 123 | return nil 124 | } 125 | 126 | var str string 127 | if err := json.Unmarshal(data, &str); err == nil { 128 | *t = append(*t, str) 129 | return nil 130 | } 131 | 132 | return fmt.Errorf("cannot unmarshal type field as string or []string: %s", string(data)) 133 | } 134 | -------------------------------------------------------------------------------- /internal/dataplane/dataplane_config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package dataplane 5 | 6 | import ( 7 | "github.com/hashicorp/consul-ecs/config" 8 | "github.com/hashicorp/consul-server-connection-manager/discovery" 9 | "github.com/hashicorp/consul/api" 10 | ) 11 | 12 | const ( 13 | localhostAddr = "127.0.0.1" 14 | ) 15 | 16 | // GetDataplaneConfigJSONInputs are the inputs needed to 17 | // generate a dataplane configuration JSON 18 | type GetDataplaneConfigJSONInput struct { 19 | // Registration details about the proxy service 20 | ProxyRegistration *api.CatalogRegistration 21 | 22 | // User provided information about the Consul servers 23 | ConsulServerConfig config.ConsulServers 24 | 25 | // Login credentials that will be passed on to the dataplane's 26 | // configuration. 27 | ConsulLoginCredentials *discovery.Credentials 28 | 29 | // Path of the CA cert file for Consul server's RPC interface 30 | CACertFile string 31 | 32 | // The HTTP health check port that indicates envoy's readiness 33 | ProxyHealthCheckPort int 34 | 35 | // The logLevel that will be used to configure dataplane's logger. 36 | LogLevel string 37 | 38 | // Whether Consul DNS is enabled in the mesh-task. If yes, dataplane 39 | // starts a local DNS server and transparently proxies it to Consul 40 | // server's DNS interface 41 | ConsulDNSEnabled bool 42 | } 43 | 44 | // GetDataplaneConfigJSON returns back a configuration JSON which 45 | // (after writing it to a shared volume) can be used to start consul-dataplane 46 | func (i *GetDataplaneConfigJSONInput) GetDataplaneConfigJSON() ([]byte, error) { 47 | cfg := &DataplaneConfig{ 48 | Consul: ConsulConfig{ 49 | Addresses: i.ConsulServerConfig.Hosts, 50 | GRPCPort: i.ConsulServerConfig.GRPC.Port, 51 | SkipServerWatch: i.ConsulServerConfig.SkipServerWatch, 52 | }, 53 | Proxy: ProxyConfig{ 54 | NodeName: i.ProxyRegistration.Node, 55 | ID: i.ProxyRegistration.Service.ID, 56 | Namespace: i.ProxyRegistration.Service.Namespace, 57 | Partition: i.ProxyRegistration.Service.Partition, 58 | }, 59 | XDSServer: XDSServerConfig{ 60 | Address: localhostAddr, 61 | }, 62 | Envoy: EnvoyConfig{ 63 | ReadyBindAddr: localhostAddr, 64 | ReadyBindPort: i.ProxyHealthCheckPort, 65 | }, 66 | Logging: LoggingConfig{ 67 | LogLevel: i.LogLevel, 68 | }, 69 | } 70 | 71 | cfg.Consul.TLS = &TLSConfig{ 72 | Disabled: true, 73 | } 74 | 75 | cfg.Consul.TLS = &TLSConfig{ 76 | Disabled: true, 77 | } 78 | 79 | grpcTLSSettings := i.ConsulServerConfig.GetGRPCTLSSettings() 80 | if grpcTLSSettings.Enabled { 81 | cfg.Consul.TLS = &TLSConfig{ 82 | Disabled: false, 83 | GRPCCACertPath: i.CACertFile, 84 | TLSServerName: grpcTLSSettings.TLSServerName, 85 | } 86 | } 87 | 88 | if i.ConsulLoginCredentials != nil { 89 | cfg.Consul.Credentials = &CredentialsConfig{ 90 | CredentialType: "login", 91 | Login: LoginCredentialsConfig{ 92 | AuthMethod: i.ConsulLoginCredentials.Login.AuthMethod, 93 | Namespace: i.ConsulLoginCredentials.Login.Namespace, 94 | Partition: i.ConsulLoginCredentials.Login.Partition, 95 | Datacenter: i.ConsulLoginCredentials.Login.Datacenter, 96 | BearerToken: i.ConsulLoginCredentials.Login.BearerToken, 97 | Meta: i.ConsulLoginCredentials.Login.Meta, 98 | }, 99 | } 100 | } 101 | 102 | if i.ConsulDNSEnabled { 103 | cfg.DNSServer = &DNSServerConfig{ 104 | BindAddress: config.ConsulDataplaneDNSBindHost, 105 | BindPort: config.ConsulDataplaneDNSBindPort, 106 | } 107 | } 108 | 109 | return cfg.generateJSON() 110 | } 111 | -------------------------------------------------------------------------------- /internal/dataplane/dataplane_json.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package dataplane 5 | 6 | import ( 7 | "encoding/json" 8 | ) 9 | 10 | type DataplaneConfig struct { 11 | Consul ConsulConfig `json:"consul"` 12 | Proxy ProxyConfig `json:"proxy"` 13 | XDSServer XDSServerConfig `json:"xdsServer"` 14 | Envoy EnvoyConfig `json:"envoy"` 15 | Logging LoggingConfig `json:"logging"` 16 | DNSServer *DNSServerConfig `json:"dnsServer,omitempty"` 17 | } 18 | 19 | type ConsulConfig struct { 20 | Addresses string `json:"addresses"` 21 | GRPCPort int `json:"grpcPort"` 22 | SkipServerWatch bool `json:"serverWatchDisabled"` 23 | TLS *TLSConfig `json:"tls,omitempty"` 24 | Credentials *CredentialsConfig `json:"credentials,omitempty"` 25 | } 26 | 27 | type TLSConfig struct { 28 | Disabled bool `json:"disabled"` 29 | GRPCCACertPath string `json:"caCertsPath,omitempty"` 30 | TLSServerName string `json:"tlsServerName,omitempty"` 31 | } 32 | 33 | type CredentialsConfig struct { 34 | CredentialType string `json:"type"` 35 | Login LoginCredentialsConfig `json:"login"` 36 | } 37 | 38 | type LoginCredentialsConfig struct { 39 | AuthMethod string `json:"authMethod"` 40 | Namespace string `json:"namespace,omitempty"` 41 | Partition string `json:"partition,omitempty"` 42 | Datacenter string `json:"datacenter"` 43 | BearerToken string `json:"bearerToken"` 44 | Meta map[string]string `json:"meta"` 45 | } 46 | 47 | type ProxyConfig struct { 48 | NodeName string `json:"nodeName"` 49 | ID string `json:"id"` 50 | Namespace string `json:"namespace"` 51 | Partition string `json:"partition"` 52 | } 53 | 54 | type XDSServerConfig struct { 55 | Address string `json:"bindAddress"` 56 | } 57 | 58 | type EnvoyConfig struct { 59 | ReadyBindAddr string `json:"readyBindAddress"` 60 | ReadyBindPort int `json:"readyBindPort"` 61 | } 62 | 63 | type LoggingConfig struct { 64 | LogLevel string `json:"logLevel"` 65 | } 66 | 67 | type DNSServerConfig struct { 68 | BindAddress string `json:"bindAddress"` 69 | BindPort int `json:"bindPort"` 70 | } 71 | 72 | func (d *DataplaneConfig) generateJSON() ([]byte, error) { 73 | dataplaneJSON, err := json.Marshal(&d) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | return dataplaneJSON, err 79 | } 80 | -------------------------------------------------------------------------------- /internal/dns/dns.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package dns 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "os" 10 | "strings" 11 | 12 | "github.com/hashicorp/consul-ecs/config" 13 | "github.com/miekg/dns" 14 | ) 15 | 16 | const ( 17 | // Defaults taken from /etc/resolv.conf man page 18 | defaultDNSOptionNdots = 1 19 | defaultDNSOptionTimeout = 5 20 | defaultDNSOptionAttempts = 2 21 | 22 | defaultEtcResolvConfFile = "/etc/resolv.conf" 23 | ) 24 | 25 | type ConfigureConsulDNSInput struct { 26 | // Used only for unit tests 27 | ETCResolvConfFile string 28 | } 29 | 30 | // ConfigureConsulDNS reconstructs the /etc/resolv.conf file by setting the 31 | // consul-dataplane's DNS server (i.e. localhost) as the first nameserver in the list. 32 | func (i *ConfigureConsulDNSInput) ConfigureConsulDNS() error { 33 | etcResolvConfFile := defaultEtcResolvConfFile 34 | if i.ETCResolvConfFile != "" { 35 | etcResolvConfFile = i.ETCResolvConfFile 36 | } 37 | 38 | cfg, err := dns.ClientConfigFromFile(etcResolvConfFile) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | if cfg == nil { 44 | return fmt.Errorf("failed to fetch DNS config") 45 | } 46 | 47 | options := constructDNSOpts(cfg) 48 | 49 | nameservers := []string{config.ConsulDataplaneDNSBindHost} 50 | nameservers = append(nameservers, cfg.Servers...) 51 | 52 | return buildResolveConf(etcResolvConfFile, cfg, nameservers, options) 53 | } 54 | 55 | func constructDNSOpts(cfg *dns.ClientConfig) []string { 56 | var opts []string 57 | if cfg.Ndots != defaultDNSOptionNdots { 58 | opts = append(opts, fmt.Sprintf("ndots:%d", cfg.Ndots)) 59 | } 60 | 61 | if cfg.Timeout != defaultDNSOptionTimeout { 62 | opts = append(opts, fmt.Sprintf("timeout:%d", cfg.Timeout)) 63 | } 64 | 65 | if cfg.Attempts != defaultDNSOptionAttempts { 66 | opts = append(opts, fmt.Sprintf("attempts:%d", cfg.Attempts)) 67 | } 68 | 69 | return opts 70 | } 71 | 72 | func buildResolveConf(etcResolvConfFile string, cfg *dns.ClientConfig, nameservers, options []string) error { 73 | content := bytes.NewBuffer(nil) 74 | if len(cfg.Search) > 0 { 75 | if searchString := strings.Join(cfg.Search, " "); strings.Trim(searchString, " ") != "." { 76 | if _, err := content.WriteString("search " + searchString + "\n"); err != nil { 77 | return err 78 | } 79 | } 80 | } 81 | 82 | for _, ns := range nameservers { 83 | if _, err := content.WriteString("nameserver " + ns + "\n"); err != nil { 84 | return err 85 | } 86 | } 87 | 88 | if len(options) > 0 { 89 | if optsString := strings.Join(options, " "); strings.Trim(optsString, " ") != "" { 90 | if _, err := content.WriteString("options " + optsString + "\n"); err != nil { 91 | return err 92 | } 93 | } 94 | } 95 | 96 | return os.WriteFile(etcResolvConfFile, content.Bytes(), 0644) 97 | } 98 | -------------------------------------------------------------------------------- /internal/dns/dns_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package dns 5 | 6 | import ( 7 | "os" 8 | "testing" 9 | 10 | "github.com/miekg/dns" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestConfigureConsulDNS(t *testing.T) { 15 | cases := map[string]struct { 16 | etcResolvConf string 17 | assertDNSConfig func(*testing.T, *dns.ClientConfig) 18 | }{ 19 | "empty /etc/resolv.conf file": { 20 | assertDNSConfig: func(t *testing.T, cfg *dns.ClientConfig) { 21 | require.Equal(t, 1, len(cfg.Servers)) 22 | require.Contains(t, cfg.Servers, "127.0.0.1") 23 | }, 24 | }, 25 | "single nameserver": { 26 | etcResolvConf: `nameserver 1.1.1.1`, 27 | assertDNSConfig: func(t *testing.T, cfg *dns.ClientConfig) { 28 | require.Equal(t, 2, len(cfg.Servers)) 29 | require.Contains(t, cfg.Servers, "127.0.0.1") 30 | }, 31 | }, 32 | "several nameservers, searches and options": { 33 | etcResolvConf: ` 34 | nameserver 1.1.1.1 35 | nameserver 2.2.2.2 36 | nameserver 3.3.3.3 37 | nameserver 4.4.4.4 38 | search foo.bar bar.baz bar.foo 39 | options ndots:5 timeout:6 attempts:3`, 40 | assertDNSConfig: func(t *testing.T, cfg *dns.ClientConfig) { 41 | require.Equal(t, 5, len(cfg.Servers)) 42 | require.Contains(t, cfg.Servers, "127.0.0.1") 43 | require.Equal(t, 5, cfg.Ndots) 44 | require.Equal(t, 6, cfg.Timeout) 45 | require.Equal(t, 3, cfg.Attempts) 46 | require.Equal(t, 3, len(cfg.Search)) 47 | }, 48 | }, 49 | } 50 | 51 | for name, c := range cases { 52 | t.Run(name, func(t *testing.T) { 53 | etcResolvFile, err := os.CreateTemp("", "") 54 | require.NoError(t, err) 55 | t.Cleanup(func() { 56 | _ = os.Remove(etcResolvFile.Name()) 57 | }) 58 | _, err = etcResolvFile.WriteString(c.etcResolvConf) 59 | require.NoError(t, err) 60 | 61 | inp := &ConfigureConsulDNSInput{ 62 | ETCResolvConfFile: etcResolvFile.Name(), 63 | } 64 | 65 | require.NoError(t, inp.ConfigureConsulDNS()) 66 | 67 | // Assert the DNS config 68 | cfg, err := dns.ClientConfigFromFile(inp.ETCResolvConfFile) 69 | require.NoError(t, err) 70 | c.assertDNSConfig(t, cfg) 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/redirecttraffic/redirect_traffic.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package redirecttraffic 5 | 6 | import ( 7 | "fmt" 8 | "net" 9 | "strconv" 10 | 11 | "github.com/hashicorp/consul-ecs/config" 12 | "github.com/hashicorp/consul/api" 13 | "github.com/hashicorp/consul/sdk/iptables" 14 | "github.com/mitchellh/mapstructure" 15 | ) 16 | 17 | const ( 18 | defaultProxyUserID = 5995 19 | 20 | // UID of the health-sync container 21 | defaultHealthSyncProcessUID = "5996" 22 | ) 23 | 24 | type trafficRedirectProxyConfig struct { 25 | BindPort int `mapstructure:"bind_port"` 26 | PrometheusBindAddr string `mapstructure:"envoy_prometheus_bind_addr"` 27 | StatsBindAddr string `mapstructure:"envoy_stats_bind_addr"` 28 | } 29 | 30 | type TrafficRedirectionCfg struct { 31 | ProxySvc *api.AgentService 32 | 33 | EnableConsulDNS bool 34 | ExcludeInboundPorts []int 35 | ExcludeOutboundPorts []int 36 | ExcludeOutboundCIDRs []string 37 | ExcludeUIDs []string 38 | 39 | iptablesCfg iptables.Config 40 | 41 | // Fields used only for unit tests 42 | iptablesProvider iptables.Provider 43 | } 44 | 45 | type TrafficRedirectionProvider interface { 46 | // Apply applies the traffic redirection with iptables 47 | Apply() error 48 | 49 | // Config returns the resultant iptables config that gets 50 | // applied by the provider 51 | Config() iptables.Config 52 | } 53 | 54 | type TrafficRedirectionOpts func(*TrafficRedirectionCfg) 55 | 56 | func WithIPTablesProvider(provider iptables.Provider) TrafficRedirectionOpts { 57 | return func(c *TrafficRedirectionCfg) { 58 | c.iptablesProvider = provider 59 | } 60 | } 61 | 62 | func New(cfg *config.Config, proxySvc *api.AgentService, additionalInboundPortsToExclude []int, opts ...TrafficRedirectionOpts) TrafficRedirectionProvider { 63 | trafficRedirectionCfg := &TrafficRedirectionCfg{ 64 | ProxySvc: proxySvc, 65 | EnableConsulDNS: cfg.ConsulDNSEnabled(), 66 | ExcludeInboundPorts: cfg.TransparentProxy.ExcludeInboundPorts, 67 | ExcludeOutboundPorts: cfg.TransparentProxy.ExcludeOutboundPorts, 68 | ExcludeOutboundCIDRs: cfg.TransparentProxy.ExcludeOutboundCIDRs, 69 | ExcludeUIDs: cfg.TransparentProxy.ExcludeUIDs, 70 | } 71 | 72 | trafficRedirectionCfg.ExcludeInboundPorts = append(trafficRedirectionCfg.ExcludeInboundPorts, additionalInboundPortsToExclude...) 73 | 74 | for _, opt := range opts { 75 | opt(trafficRedirectionCfg) 76 | } 77 | 78 | return trafficRedirectionCfg 79 | } 80 | 81 | // applyTrafficRedirectionRules creates and applies traffic redirection rules with 82 | // the help of iptables 83 | // 84 | // iptables.Config: 85 | // 86 | // ConsulDNSIP: Consul Dataplane's DNS server (i.e. localhost) 87 | // ConsulDNSPort: Consul Dataplane's DNS server's bind port 88 | // ProxyUserID: a constant set by default in the mesh-task module for the Consul dataplane's container 89 | // ProxyInboundPort: the proxy service's port or bind port 90 | // ProxyOutboundPort: default transparent proxy outbound port 91 | // ExcludeInboundPorts: prometheus, envoy stats, expose paths and `transparentProxy.excludeInboundPorts` 92 | // ExcludeOutboundPorts: `transparentProxy.excludeOutboundPorts` in CONSUL_ECS_CONFIG_JSON 93 | // ExcludeOutboundCIDRs: `transparentProxy.excludeOutboundCIDRs` in CONSUL_ECS_CONFIG_JSON 94 | // ExcludeUIDs: `transparentProxy.excludeUIDs` in CONSUL_ECS_CONFIG_JSON 95 | func (c *TrafficRedirectionCfg) Apply() error { 96 | if c.ProxySvc == nil { 97 | return fmt.Errorf("proxy service details are required to enable traffic redirection") 98 | } 99 | 100 | // Decode proxy's opaque config 101 | var trCfg trafficRedirectProxyConfig 102 | if err := mapstructure.WeakDecode(c.ProxySvc.Proxy.Config, &trCfg); err != nil { 103 | return fmt.Errorf("failed parsing proxy service's Proxy.Config: %w", err) 104 | } 105 | 106 | c.iptablesCfg = iptables.Config{ 107 | ProxyUserID: strconv.Itoa(defaultProxyUserID), 108 | ProxyInboundPort: c.ProxySvc.Port, 109 | ProxyOutboundPort: iptables.DefaultTProxyOutboundPort, 110 | } 111 | 112 | // Override proxyInboundPort with bind_port 113 | if trCfg.BindPort != 0 { 114 | c.iptablesCfg.ProxyInboundPort = trCfg.BindPort 115 | } 116 | 117 | // Override the outbound port if the outbound port present in the proxy registration 118 | if c.ProxySvc.Proxy.TransparentProxy != nil && c.ProxySvc.Proxy.TransparentProxy.OutboundListenerPort != 0 { 119 | c.iptablesCfg.ProxyOutboundPort = c.ProxySvc.Proxy.TransparentProxy.OutboundListenerPort 120 | } 121 | 122 | // Inbound ports 123 | { 124 | for _, port := range c.ExcludeInboundPorts { 125 | c.iptablesCfg.ExcludeInboundPorts = append(c.iptablesCfg.ExcludeInboundPorts, strconv.Itoa(port)) 126 | } 127 | 128 | // Exclude envoy_prometheus_bind_addr port from inbound redirection rules. 129 | if trCfg.PrometheusBindAddr != "" { 130 | _, port, err := net.SplitHostPort(trCfg.PrometheusBindAddr) 131 | if err != nil { 132 | return fmt.Errorf("failed parsing host and port from envoy_prometheus_bind_addr: %w", err) 133 | } 134 | 135 | c.iptablesCfg.ExcludeInboundPorts = append(c.iptablesCfg.ExcludeInboundPorts, port) 136 | } 137 | 138 | // Exclude envoy_stats_bind_addr port from inbound redirection rules. 139 | if trCfg.StatsBindAddr != "" { 140 | _, port, err := net.SplitHostPort(trCfg.StatsBindAddr) 141 | if err != nil { 142 | return fmt.Errorf("failed parsing host and port from envoy_stats_bind_addr: %w", err) 143 | } 144 | 145 | c.iptablesCfg.ExcludeInboundPorts = append(c.iptablesCfg.ExcludeInboundPorts, port) 146 | } 147 | 148 | // Exclude expose path ports from inbound traffic redirection 149 | for _, exposePath := range c.ProxySvc.Proxy.Expose.Paths { 150 | if exposePath.ListenerPort != 0 { 151 | c.iptablesCfg.ExcludeInboundPorts = append(c.iptablesCfg.ExcludeInboundPorts, strconv.Itoa(exposePath.ListenerPort)) 152 | } 153 | } 154 | } 155 | 156 | // Outbound ports 157 | for _, port := range c.ExcludeOutboundPorts { 158 | c.iptablesCfg.ExcludeOutboundPorts = append(c.iptablesCfg.ExcludeOutboundPorts, strconv.Itoa(port)) 159 | } 160 | 161 | // Outbound CIDRs 162 | c.iptablesCfg.ExcludeOutboundCIDRs = append(c.iptablesCfg.ExcludeOutboundCIDRs, c.ExcludeOutboundCIDRs...) 163 | 164 | // UIDs 165 | c.iptablesCfg.ExcludeUIDs = append(c.iptablesCfg.ExcludeUIDs, c.ExcludeUIDs...) 166 | c.iptablesCfg.ExcludeUIDs = append(c.iptablesCfg.ExcludeUIDs, defaultHealthSyncProcessUID) 167 | 168 | // Consul DNS 169 | if c.EnableConsulDNS { 170 | c.iptablesCfg.ConsulDNSIP = config.ConsulDataplaneDNSBindHost 171 | c.iptablesCfg.ConsulDNSPort = config.ConsulDataplaneDNSBindPort 172 | } 173 | 174 | if c.iptablesProvider != nil { 175 | c.iptablesCfg.IptablesProvider = c.iptablesProvider 176 | } 177 | 178 | addAdditionalRulesFn := func(iptablesProvider iptables.Provider) { 179 | iptablesProvider.AddRule("iptables", "-t", "nat", "--policy", "POSTROUTING", "ACCEPT") 180 | } 181 | 182 | err := iptables.SetupWithAdditionalRules(c.iptablesCfg, addAdditionalRulesFn) 183 | if err != nil { 184 | return fmt.Errorf("failed to setup traffic redirection rules %w", err) 185 | } 186 | 187 | return nil 188 | } 189 | 190 | func (c *TrafficRedirectionCfg) Config() iptables.Config { 191 | return c.iptablesCfg 192 | } 193 | -------------------------------------------------------------------------------- /internal/redirecttraffic/redirect_traffic_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package redirecttraffic 5 | 6 | import ( 7 | "strconv" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/hashicorp/consul-ecs/config" 12 | "github.com/hashicorp/consul/api" 13 | "github.com/hashicorp/consul/sdk/iptables" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func TestApply(t *testing.T) { 18 | cases := map[string]struct { 19 | wantErr bool 20 | proxySvc *api.AgentService 21 | cfg *config.Config 22 | assertIptablesConfig func(t *testing.T, actual iptables.Config) 23 | }{ 24 | "proxy service is nil": { 25 | cfg: &config.Config{}, 26 | wantErr: true, 27 | }, 28 | "default redirection behaviour": { 29 | cfg: &config.Config{ 30 | TransparentProxy: config.TransparentProxyConfig{ 31 | Enabled: true, 32 | }, 33 | }, 34 | proxySvc: &api.AgentService{ 35 | Port: 20000, 36 | Proxy: &api.AgentServiceConnectProxyConfig{}, 37 | }, 38 | assertIptablesConfig: func(t *testing.T, cfg iptables.Config) { 39 | require.Equal(t, 20000, cfg.ProxyInboundPort) 40 | require.Equal(t, iptables.DefaultTProxyOutboundPort, cfg.ProxyOutboundPort) 41 | require.Equal(t, strconv.Itoa(defaultProxyUserID), cfg.ProxyUserID) 42 | }, 43 | }, 44 | "envoy bind port is present in proxy config": { 45 | cfg: &config.Config{ 46 | TransparentProxy: config.TransparentProxyConfig{ 47 | Enabled: true, 48 | }, 49 | }, 50 | proxySvc: &api.AgentService{ 51 | Port: 20000, 52 | Proxy: &api.AgentServiceConnectProxyConfig{ 53 | Config: map[string]interface{}{ 54 | "bind_port": 12000, 55 | }, 56 | }, 57 | }, 58 | assertIptablesConfig: func(t *testing.T, cfg iptables.Config) { 59 | require.Equal(t, 12000, cfg.ProxyInboundPort) 60 | }, 61 | }, 62 | "outbound listener port present in proxy config": { 63 | cfg: &config.Config{ 64 | TransparentProxy: config.TransparentProxyConfig{ 65 | Enabled: true, 66 | }, 67 | }, 68 | proxySvc: &api.AgentService{ 69 | Port: 20000, 70 | Proxy: &api.AgentServiceConnectProxyConfig{ 71 | TransparentProxy: &api.TransparentProxyConfig{ 72 | OutboundListenerPort: 12000, 73 | }, 74 | }, 75 | }, 76 | assertIptablesConfig: func(t *testing.T, cfg iptables.Config) { 77 | require.Equal(t, 12000, cfg.ProxyOutboundPort) 78 | }, 79 | }, 80 | "envoy_stats_bind_addr port, envoy_prometheus_bind_addr port, expose path ports and user specified inbound ports should be excluded": { 81 | cfg: &config.Config{ 82 | TransparentProxy: config.TransparentProxyConfig{ 83 | Enabled: true, 84 | ExcludeInboundPorts: []int{1234, 5678, 8901}, 85 | }, 86 | }, 87 | proxySvc: &api.AgentService{ 88 | Port: 20000, 89 | Proxy: &api.AgentServiceConnectProxyConfig{ 90 | Config: map[string]interface{}{ 91 | "envoy_prometheus_bind_addr": "0.0.0.0:9090", 92 | "envoy_stats_bind_addr": "0.0.0.0:8080", 93 | }, 94 | Expose: api.ExposeConfig{ 95 | Paths: []api.ExposePath{ 96 | { 97 | ListenerPort: 14000, 98 | }, 99 | { 100 | ListenerPort: 15000, 101 | }, 102 | }, 103 | }, 104 | }, 105 | }, 106 | assertIptablesConfig: func(t *testing.T, cfg iptables.Config) { 107 | expectedPorts := []string{ 108 | "1234", 109 | "5678", 110 | "8901", 111 | "14000", 112 | "15000", 113 | "9090", // Prometheus server port 114 | "8080", // Envoy stats bind port 115 | "22000", //Proxy health check port 116 | } 117 | for _, port := range cfg.ExcludeInboundPorts { 118 | require.Contains(t, expectedPorts, port) 119 | } 120 | }, 121 | }, 122 | "user specified outbound ports should be excluded": { 123 | cfg: &config.Config{ 124 | TransparentProxy: config.TransparentProxyConfig{ 125 | Enabled: true, 126 | ExcludeOutboundPorts: []int{1234, 5678, 8901}, 127 | }, 128 | }, 129 | proxySvc: &api.AgentService{ 130 | Port: 20000, 131 | Proxy: &api.AgentServiceConnectProxyConfig{}, 132 | }, 133 | assertIptablesConfig: func(t *testing.T, cfg iptables.Config) { 134 | expectedPorts := []string{ 135 | "1234", 136 | "5678", 137 | "8901", 138 | } 139 | for _, port := range cfg.ExcludeOutboundPorts { 140 | require.Contains(t, expectedPorts, port) 141 | } 142 | }, 143 | }, 144 | "user specified UIDs should be excluded": { 145 | cfg: &config.Config{ 146 | TransparentProxy: config.TransparentProxyConfig{ 147 | Enabled: true, 148 | ExcludeUIDs: []string{"1234", "5678"}, 149 | }, 150 | }, 151 | proxySvc: &api.AgentService{ 152 | Port: 20000, 153 | Proxy: &api.AgentServiceConnectProxyConfig{}, 154 | }, 155 | assertIptablesConfig: func(t *testing.T, cfg iptables.Config) { 156 | expectedUIDs := []string{ 157 | "1234", 158 | "5678", 159 | "5996", // Health sync container UID 160 | } 161 | for _, uid := range cfg.ExcludeUIDs { 162 | require.Contains(t, expectedUIDs, uid) 163 | } 164 | }, 165 | }, 166 | "user specified CIDRs should be excluded": { 167 | cfg: &config.Config{ 168 | TransparentProxy: config.TransparentProxyConfig{ 169 | Enabled: true, 170 | ExcludeOutboundCIDRs: []string{"1.1.1.1/24", "2.2.2.2/24"}, 171 | }, 172 | }, 173 | proxySvc: &api.AgentService{ 174 | Port: 20000, 175 | Proxy: &api.AgentServiceConnectProxyConfig{}, 176 | }, 177 | assertIptablesConfig: func(t *testing.T, cfg iptables.Config) { 178 | expectedCIDRs := []string{ 179 | "1.1.1.1/24", 180 | "2.2.2.2/24", 181 | } 182 | for _, cidr := range cfg.ExcludeOutboundCIDRs { 183 | require.Contains(t, expectedCIDRs, cidr) 184 | } 185 | }, 186 | }, 187 | "Consul DNS enabled": { 188 | cfg: &config.Config{ 189 | TransparentProxy: config.TransparentProxyConfig{ 190 | Enabled: true, 191 | ConsulDNS: config.ConsulDNS{ 192 | Enabled: true, 193 | }, 194 | }, 195 | }, 196 | proxySvc: &api.AgentService{ 197 | Port: 20000, 198 | Proxy: &api.AgentServiceConnectProxyConfig{}, 199 | }, 200 | assertIptablesConfig: func(t *testing.T, cfg iptables.Config) { 201 | require.Equal(t, config.ConsulDataplaneDNSBindHost, cfg.ConsulDNSIP) 202 | require.Equal(t, config.ConsulDataplaneDNSBindPort, cfg.ConsulDNSPort) 203 | }, 204 | }, 205 | } 206 | 207 | for name, c := range cases { 208 | t.Run(name, func(t *testing.T) { 209 | iptablesProvider := &mockIptablesProvider{} 210 | provider := New(c.cfg, 211 | c.proxySvc, 212 | []int{22000}, 213 | WithIPTablesProvider(iptablesProvider), 214 | ) 215 | 216 | err := provider.Apply() 217 | if c.wantErr { 218 | require.Error(t, err) 219 | } else { 220 | require.NoError(t, err) 221 | require.Truef(t, iptablesProvider.applyCalled, "redirect traffic rules were not applied") 222 | 223 | if c.assertIptablesConfig != nil { 224 | c.assertIptablesConfig(t, provider.Config()) 225 | } 226 | } 227 | }) 228 | } 229 | } 230 | 231 | type mockIptablesProvider struct { 232 | applyCalled bool 233 | rules []string 234 | } 235 | 236 | func (f *mockIptablesProvider) AddRule(_ string, args ...string) { 237 | f.rules = append(f.rules, strings.Join(args, " ")) 238 | } 239 | 240 | func (f *mockIptablesProvider) ApplyRules() error { 241 | f.applyCalled = true 242 | return nil 243 | } 244 | 245 | func (f *mockIptablesProvider) Rules() []string { 246 | return f.rules 247 | } 248 | -------------------------------------------------------------------------------- /logging/logger.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package logging 5 | 6 | import ( 7 | "flag" 8 | 9 | "github.com/hashicorp/consul-ecs/config" 10 | "github.com/hashicorp/go-hclog" 11 | ) 12 | 13 | const defaultLogLevel = "INFO" 14 | 15 | type LogOpts struct { 16 | LogLevel string 17 | } 18 | 19 | // FromConfig pulls log settings from the consul-ecs config JSON. 20 | func FromConfig(conf *config.Config) *LogOpts { 21 | level := conf.LogLevel 22 | if level == "" { 23 | level = defaultLogLevel 24 | } 25 | return &LogOpts{LogLevel: level} 26 | } 27 | 28 | // Flags returns a FlagSet which can be used to add logging flags to a command. 29 | func (l *LogOpts) Flags() *flag.FlagSet { 30 | fs := flag.NewFlagSet("", flag.ContinueOnError) 31 | fs.StringVar(&l.LogLevel, "log-level", defaultLogLevel, "Log level for this command") 32 | return fs 33 | } 34 | 35 | // Logger returns a configured logger. 36 | func (l *LogOpts) Logger() hclog.Logger { 37 | return hclog.New( 38 | &hclog.LoggerOptions{ 39 | Level: hclog.LevelFromString(l.LogLevel), 40 | }, 41 | ) 42 | } 43 | 44 | // Merge merges flags from the src FlagSet to the dst FlagSet. 45 | // 46 | // https://github.com/hashicorp/consul/blob/64e35777e044a9c6122093067eacc871f440b7db/command/flags/merge.go 47 | func Merge(dst, src *flag.FlagSet) { 48 | if dst == nil { 49 | panic("dst cannot be nil") 50 | } 51 | if src == nil { 52 | return 53 | } 54 | src.VisitAll(func(f *flag.Flag) { 55 | dst.Var(f.Value, f.Name, f.Usage) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /logging/logger_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package logging 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/hashicorp/consul-ecs/config" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestFromConfig(t *testing.T) { 14 | t.Parallel() 15 | 16 | cases := map[string]struct { 17 | config config.Config 18 | expected LogOpts 19 | }{ 20 | "log level = empty string": { 21 | config: config.Config{}, 22 | expected: LogOpts{LogLevel: defaultLogLevel}, 23 | }, 24 | "log level = DEBUG": { 25 | config: config.Config{LogLevel: "DEBUG"}, 26 | expected: LogOpts{LogLevel: "DEBUG"}, 27 | }, 28 | // lower case okay 29 | "log level = trace": { 30 | config: config.Config{LogLevel: "trace"}, 31 | expected: LogOpts{LogLevel: "trace"}, 32 | }, 33 | } 34 | for name, c := range cases { 35 | c := c 36 | t.Run(name, func(t *testing.T) { 37 | opts := FromConfig(&c.config) 38 | require.Equal(t, &c.expected, opts) 39 | }) 40 | } 41 | } 42 | 43 | func TestLogger(t *testing.T) { 44 | cases := []struct { 45 | opts LogOpts 46 | }{ 47 | {LogOpts{LogLevel: "TRACE"}}, 48 | {LogOpts{LogLevel: "DEBUG"}}, 49 | {LogOpts{LogLevel: "INFO"}}, 50 | } 51 | for _, c := range cases { 52 | t.Run(c.opts.LogLevel, func(t *testing.T) { 53 | logger := c.opts.Logger() 54 | switch c.opts.LogLevel { 55 | case "TRACE": 56 | require.True(t, logger.IsTrace()) 57 | case "DEBUG": 58 | require.True(t, logger.IsDebug()) 59 | case "INFO": 60 | require.True(t, logger.IsInfo()) 61 | default: 62 | require.FailNow(t, "unhandled log level in assertion", "level = %s", c.opts.LogLevel) 63 | } 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "log" 8 | "os" 9 | 10 | "github.com/hashicorp/consul-ecs/version" 11 | "github.com/mitchellh/cli" 12 | ) 13 | 14 | func main() { 15 | c := cli.NewCLI("consul-ecs", version.GetHumanVersion()) 16 | c.Args = os.Args[1:] 17 | c.Commands = Commands 18 | c.HelpFunc = helpFunc() 19 | 20 | exitStatus, err := c.Run() 21 | if err != nil { 22 | log.Println(err) 23 | } 24 | os.Exit(exitStatus) 25 | } 26 | -------------------------------------------------------------------------------- /scan.hcl: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | # Configuration for security scanner. 5 | # Run on PRs and pushes to `main` and `release/**` branches. 6 | # See .github/workflows/security-scan.yml for CI config. 7 | 8 | # To run manually, install scanner and then run `scan repository .` 9 | 10 | # Scan results are triaged via the GitHub Security tab for this repo. 11 | # See `security-scanner` docs for more information on how to add `triage` config 12 | # for specific results or to exclude paths. 13 | 14 | # .release/security-scan.hcl controls scanner config for release artifacts, which 15 | # unlike the scans configured here, will block releases in CRT. 16 | 17 | repository { 18 | go_modules = true 19 | osv = true 20 | 21 | secrets { 22 | all = true 23 | } 24 | 25 | triage { 26 | suppress { 27 | paths = [ 28 | # Ignore test and local tool modules, which are not included in published 29 | # artifacts. 30 | "hack/*", 31 | "testutil/*", 32 | ] 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /subcommand/app-entrypoint/command_common.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package appentrypoint 5 | 6 | func (c *Command) Help() string { 7 | return "" 8 | } 9 | 10 | func (c *Command) Synopsis() string { 11 | return "Entrypoint for running a command in ECS" 12 | } 13 | -------------------------------------------------------------------------------- /subcommand/app-entrypoint/command_unix.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build !windows 5 | // +build !windows 6 | 7 | package appentrypoint 8 | 9 | import ( 10 | "flag" 11 | "fmt" 12 | "os" 13 | "os/signal" 14 | "sync" 15 | "syscall" 16 | "time" 17 | 18 | "github.com/hashicorp/consul-ecs/entrypoint" 19 | "github.com/hashicorp/consul-ecs/logging" 20 | "github.com/hashicorp/go-hclog" 21 | "github.com/mitchellh/cli" 22 | ) 23 | 24 | const ( 25 | flagShutdownDelay = "shutdown-delay" 26 | ) 27 | 28 | type Command struct { 29 | UI cli.Ui 30 | log hclog.Logger 31 | once sync.Once 32 | flagSet *flag.FlagSet 33 | 34 | sigs chan os.Signal 35 | appCmd *entrypoint.Cmd 36 | shutdownDelay time.Duration 37 | 38 | logging.LogOpts 39 | 40 | // for unit tests to wait for start 41 | started chan struct{} 42 | } 43 | 44 | func (c *Command) init() { 45 | c.flagSet = flag.NewFlagSet("", flag.ContinueOnError) 46 | c.flagSet.DurationVar(&c.shutdownDelay, flagShutdownDelay, 0, 47 | `Continue running for this long after receiving SIGTERM. Must be a duration (e.g. "10s").`) 48 | logging.Merge(c.flagSet, c.LogOpts.Flags()) 49 | 50 | c.started = make(chan struct{}, 1) 51 | c.sigs = make(chan os.Signal, 1) 52 | } 53 | 54 | func (c *Command) Run(args []string) int { 55 | c.once.Do(c.init) 56 | 57 | // Flag parsing stops just before the first non-flag argument ("-" is a non-flag argument) 58 | // or after the terminator "--" 59 | if err := c.flagSet.Parse(args); err != nil { 60 | c.UI.Error(err.Error()) 61 | return 1 62 | } 63 | 64 | c.log = c.LogOpts.Logger().Named("consul-ecs") 65 | 66 | // Remaining args for the application command, after parsing our flags 67 | args = c.flagSet.Args() 68 | 69 | if len(args) == 0 { 70 | c.UI.Error("command is required") 71 | return 1 72 | } 73 | 74 | c.appCmd = entrypoint.NewCmd(c.log, args) 75 | 76 | return c.realRun() 77 | } 78 | 79 | func (c *Command) realRun() int { 80 | signal.Notify(c.sigs) 81 | defer c.cleanup() 82 | 83 | go c.appCmd.Run() 84 | if _, ok := <-c.appCmd.Started(); !ok { 85 | return 1 86 | } 87 | c.started <- struct{}{} 88 | 89 | if exitCode, exited := c.waitForSigterm(); exited { 90 | return exitCode 91 | } 92 | if c.shutdownDelay > 0 { 93 | c.log.Info(fmt.Sprintf("received sigterm. waiting %s before terminating application.", c.shutdownDelay)) 94 | if exitCode, exited := c.waitForShutdownDelay(); exited { 95 | return exitCode 96 | } 97 | } 98 | // We've signaled for the process to exit, so wait until it does. 99 | c.waitForAppExit() 100 | return c.appCmd.ProcessState.ExitCode() 101 | } 102 | 103 | // waitForSigterm waits until c.appCmd has exited, or until a sigterm is received. 104 | // It returns (exitCode, exited), where if exited=true, then c.appCmd has exited. 105 | func (c *Command) waitForSigterm() (int, bool) { 106 | for { 107 | select { 108 | case <-c.appCmd.Done(): 109 | return c.appCmd.ProcessState.ExitCode(), true 110 | case sig := <-c.sigs: 111 | if sig == syscall.SIGTERM { 112 | return -1, false 113 | } 114 | c.forwardSignal(sig) 115 | } 116 | } 117 | } 118 | 119 | // waitForShutdownDelay waits for c.appCmd to exit for `delay` seconds. 120 | // After the delay has passed, it sends a sigterm to c.appCmd. 121 | // It returns (exitCode, exited), where if exited=true, then c.appCmd has exited. 122 | func (c *Command) waitForShutdownDelay() (int, bool) { 123 | timer := time.After(c.shutdownDelay) 124 | for { 125 | select { 126 | case <-c.appCmd.Done(): 127 | return c.appCmd.ProcessState.ExitCode(), true 128 | case sig := <-c.sigs: 129 | c.forwardSignal(sig) 130 | case <-timer: 131 | if err := syscall.Kill(-c.appCmd.Process.Pid, syscall.SIGTERM); err != nil { 132 | c.log.Warn("error sending sigterm to application", "error", err.Error()) 133 | } 134 | } 135 | } 136 | 137 | } 138 | 139 | func (c *Command) waitForAppExit() { 140 | for { 141 | select { 142 | case <-c.appCmd.Done(): 143 | return 144 | case sig := <-c.sigs: 145 | c.forwardSignal(sig) 146 | } 147 | } 148 | } 149 | 150 | func (c *Command) forwardSignal(sig os.Signal) { 151 | switch sig { 152 | case syscall.SIGCHLD, syscall.SIGURG: 153 | return 154 | default: 155 | if err := c.appCmd.Process.Signal(sig); err != nil { 156 | c.log.Warn("forwarding signal", "err", err.Error()) 157 | } 158 | } 159 | } 160 | 161 | func (c *Command) cleanup() { 162 | defer close(c.started) 163 | signal.Stop(c.sigs) 164 | <-c.appCmd.Done() 165 | } 166 | -------------------------------------------------------------------------------- /subcommand/app-entrypoint/command_unix_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build !windows 5 | // +build !windows 6 | 7 | package appentrypoint 8 | 9 | import ( 10 | "os" 11 | "os/signal" 12 | "syscall" 13 | "testing" 14 | "time" 15 | 16 | "github.com/hashicorp/consul-ecs/testutil" 17 | "github.com/hashicorp/consul/sdk/testutil/retry" 18 | "github.com/mitchellh/cli" 19 | "github.com/stretchr/testify/require" 20 | ) 21 | 22 | func TestFlagValidation(t *testing.T) { 23 | cases := map[string]struct { 24 | args []string 25 | code int 26 | error string 27 | shutdownDelay time.Duration 28 | }{ 29 | "no-args": { 30 | args: nil, 31 | code: 1, 32 | error: "command is required", 33 | }, 34 | "invalid-delay": { 35 | args: []string{"--shutdown-delay", "asdf"}, 36 | code: 1, 37 | error: `invalid value "asdf" for flag -shutdown-delay`, 38 | }, 39 | "delay-without-app-command": { 40 | args: []string{"--shutdown-delay", "10s"}, 41 | code: 1, 42 | error: "command is required", 43 | shutdownDelay: 10 * time.Second, 44 | }, 45 | "app-command-with-flag-collision": { 46 | // What if the app command uses a flag that collides with one of our flags? 47 | args: []string{"/bin/sh", "-c", "echo", "--shutdown-delay", "asdf"}, 48 | code: 0, 49 | }, 50 | "delay-with-app-command": { 51 | args: []string{"--shutdown-delay", "5s", "/bin/sh", "-c", "exit 0"}, 52 | code: 0, 53 | shutdownDelay: 5 * time.Second, 54 | }, 55 | "delay-with-app-command-and-double-dash": { 56 | // "--" terminates flag parsing, to separate consul-ecs from application args 57 | args: []string{"--shutdown-delay", "5s", "--", "/bin/sh", "-c", "exit 0"}, 58 | code: 0, 59 | shutdownDelay: 5 * time.Second, 60 | }, 61 | } 62 | for name, c := range cases { 63 | t.Run(name, func(t *testing.T) { 64 | ui := cli.NewMockUi() 65 | cmd := Command{UI: ui} 66 | code := cmd.Run(c.args) 67 | require.Equal(t, c.code, code) 68 | require.Contains(t, ui.ErrorWriter.String(), c.error) 69 | require.Equal(t, cmd.shutdownDelay, c.shutdownDelay) 70 | }) 71 | } 72 | } 73 | 74 | func TestRun(t *testing.T) { 75 | cases := map[string]struct { 76 | fakeApp testutil.FakeCommand 77 | sendSigterm bool 78 | sendSigint bool 79 | shutdownDelay time.Duration 80 | exitCode int 81 | }{ 82 | "app-exit-before-sigterm": { 83 | fakeApp: testutil.SimpleFakeCommand(t, 0), 84 | }, 85 | "app-exit-after-sigterm": { 86 | // T0 : app start 87 | // T1 : entrypoint receives sigterm (ignored) 88 | // T2 : app exits on its own 89 | fakeApp: testutil.FakeCommandWithTraps(t, 2), 90 | sendSigterm: true, 91 | }, 92 | "app-exit-before-shutdown-delay": { 93 | // T0 : app start 94 | // T1 : entrypoint receives sigterm (ignored) 95 | // T2 : entrypoint waits for the shutdown delay 96 | // T3 : app exits on its own 97 | fakeApp: testutil.FakeCommandWithTraps(t, 2), 98 | sendSigterm: true, 99 | shutdownDelay: 10 * time.Second, 100 | }, 101 | "app-exit-after-shutdown-delay": { 102 | // T0 : app start 103 | // T1 : entrypoint receives sigterm (ignored) 104 | // T2 : entrypoint waits for the shutdown delay 105 | // T3 : entrypoint sends sigterm to app after shutdown delay 106 | // T4 : app exits due to sigterm 107 | fakeApp: testutil.FakeCommandWithTraps(t, 10), 108 | sendSigterm: true, 109 | shutdownDelay: 1 * time.Second, 110 | // Our test script exits with 55 when receiving sigterm. 111 | exitCode: 55, 112 | }, 113 | "sigint-is-forwarded": { 114 | fakeApp: testutil.FakeCommandWithTraps(t, 10), 115 | sendSigint: true, 116 | exitCode: 42, 117 | }, 118 | "sigint-is-forwarded-after-sigterm": { 119 | fakeApp: testutil.FakeCommandWithTraps(t, 10), 120 | sendSigterm: true, 121 | sendSigint: true, 122 | exitCode: 42, 123 | }, 124 | "sigint-is-forwarded-during-shutdown-delay": { 125 | fakeApp: testutil.FakeCommandWithTraps(t, 10), 126 | sendSigterm: true, 127 | sendSigint: true, 128 | shutdownDelay: 10 * time.Second, 129 | exitCode: 42, 130 | }, 131 | } 132 | 133 | for name, c := range cases { 134 | t.Run(name, func(t *testing.T) { 135 | ui := cli.NewMockUi() 136 | cmd := Command{UI: ui} 137 | // Necessary to avoid a race with initializing the `started` channel 138 | cmd.once.Do(cmd.init) 139 | 140 | // Start the target command asynchronously. 141 | exitCodeChan := make(chan int, 1) 142 | go func() { 143 | defer close(exitCodeChan) 144 | var args []string 145 | if c.shutdownDelay > 0 { 146 | args = append(args, "-shutdown-delay", c.shutdownDelay.String()) 147 | } 148 | args = append(args, "/bin/sh", "-c", c.fakeApp.Command) 149 | exitCodeChan <- cmd.Run(args) 150 | }() 151 | 152 | t.Logf("Wait for fake app process to start") 153 | retry.RunWith(&retry.Timer{Timeout: 1 * time.Second, Wait: 100 * time.Millisecond}, t, func(r *retry.R) { 154 | require.FileExists(r, c.fakeApp.ReadyFile) 155 | }) 156 | 157 | // Necessary to avoid concurrent accesses that trigger the race detector. 158 | t.Logf("Wait for app-entrypoint to see the app has started") 159 | _, ok := <-cmd.started 160 | require.True(t, ok) 161 | 162 | appPid := cmd.appCmd.Process.Pid 163 | t.Logf("Fake app process started pid=%v", appPid) 164 | 165 | // Testing signal handling requires signaling the entrypoint process. 166 | // This is awkward since that is the CURRENT process running this test. 167 | // To avoid accidentally terminating the test run, intercept signals here as well. 168 | // This is okay. Go supports multiple registered channels for signal notification. 169 | sigs := make(chan os.Signal, 2) 170 | signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT) 171 | t.Cleanup(func() { 172 | signal.Stop(sigs) 173 | }) 174 | 175 | if c.sendSigterm { 176 | t.Logf("Sending sigterm to the entrypoint") 177 | err := syscall.Kill(os.Getpid(), syscall.SIGTERM) 178 | require.NoError(t, err) 179 | time.Sleep(500 * time.Millisecond) // Give it time to react 180 | 181 | t.Logf("Check the fake app process is still running") 182 | proc, err := os.FindProcess(appPid) 183 | require.NoError(t, err, "Failed to find fake app process") 184 | // A zero-signal lets us check the process is still valid/running. 185 | require.NoError(t, proc.Signal(syscall.Signal(0)), 186 | "Sigterm was not ignored by the entrypoint") 187 | } 188 | 189 | if c.sendSigint { 190 | t.Logf("Send sigint to entrypoint") 191 | err := syscall.Kill(os.Getpid(), syscall.SIGINT) 192 | require.NoError(t, err) 193 | 194 | t.Logf("Check the fake app process has exited") 195 | retry.RunWith(&retry.Timer{Timeout: 2 * time.Second, Wait: 100 * time.Millisecond}, t, func(r *retry.R) { 196 | proc, err := os.FindProcess(appPid) 197 | require.NoError(r, err, "Failed to find fake app process") 198 | err = proc.Signal(syscall.Signal(0)) 199 | require.Error(r, err, "Sigint was not forwarded to fake app process") 200 | require.Equal(r, os.ErrProcessDone, err) 201 | }) 202 | } 203 | 204 | // If !ok, then the channel was closed without actually sending the exit code. 205 | exitCode, ok := <-exitCodeChan 206 | require.True(t, ok) 207 | require.Equal(t, c.exitCode, exitCode) 208 | }) 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /subcommand/app-entrypoint/command_windows.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build windows 5 | // +build windows 6 | 7 | // Not implemented for Windows. 8 | // Our Unix implementation doesn't compile on Windows, and we only need to support 9 | // Linux since this is an entrypoint to a Docker container. 10 | 11 | package appentrypoint 12 | 13 | import ( 14 | "github.com/hashicorp/go-hclog" 15 | "github.com/mitchellh/cli" 16 | ) 17 | 18 | type Command struct { 19 | UI cli.Ui 20 | log hclog.Logger 21 | } 22 | 23 | func (c *Command) Run(args []string) int { 24 | c.UI.Error("not implemented on Windows") 25 | return 1 26 | } 27 | -------------------------------------------------------------------------------- /subcommand/controller/command_ent_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build enterprise 5 | 6 | package controller 7 | 8 | import ( 9 | "testing" 10 | ) 11 | 12 | func TestUpsertConsulResourcesEnt(t *testing.T) { 13 | testUpsertConsulResources(t, map[string]iamAuthTestCase{ 14 | "recreate the partition": { 15 | partitionsEnabled: true, 16 | deletePartition: true, 17 | }, 18 | "recreate all resources ent": { 19 | deletePolicy: true, 20 | deleteRole: true, 21 | deleteAuthMethods: true, 22 | deleteBindingRules: true, 23 | deletePartition: true, 24 | partitionsEnabled: true, 25 | }, 26 | }) 27 | } 28 | 29 | func TestUpsertAnonymousTokenPolicyEnt(t *testing.T) { 30 | testUpsertAnonymousTokenPolicy(t, map[string]anonTokenTest{ 31 | "primary datacenter": { 32 | agentConfig: AgentConfig{ 33 | Config: Config{Datacenter: "dc1"}, 34 | DebugConfig: Config{PrimaryDatacenter: "dc1"}, 35 | }, 36 | partitionsEnabled: true, 37 | expPolicy: expEntAnonTokenPolicy, 38 | }, 39 | }) 40 | } 41 | 42 | func TestUpsertAPIGatewayTokenPolicyAndRole(t *testing.T) { 43 | testUpsertAPIGatewayPolicyAndRole(t, map[string]gatewayTokenTest{ 44 | "test creation": { 45 | partitionsEnabled: true, 46 | }, 47 | }) 48 | } 49 | 50 | func TestUpsertMeshGatewayTokenPolicyAndRole(t *testing.T) { 51 | testUpsertMeshGatewayPolicyAndRole(t, map[string]gatewayTokenTest{ 52 | "test creation": { 53 | partitionsEnabled: true, 54 | }, 55 | "test creation with non default partition": { 56 | partitionsEnabled: true, 57 | useNonDefaultPartition: true, 58 | }, 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /subcommand/envoy-entrypoint/command_common.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | // Package envoyentrypoint 5 | // 6 | // This is intended to be used a Docker entrypoint for Envoy: 7 | // * Run Envoy in a subprocess 8 | // * Forward all signals to the subprocess, except for SIGTERM 9 | // * Monitor task metadata to terminate Envoy after application container(s) stop 10 | package envoyentrypoint 11 | 12 | func (c *Command) Help() string { 13 | return "" 14 | } 15 | 16 | func (c *Command) Synopsis() string { 17 | return "Entrypoint for running Envoy in ECS" 18 | } 19 | -------------------------------------------------------------------------------- /subcommand/envoy-entrypoint/command_unix.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build !windows 5 | // +build !windows 6 | 7 | package envoyentrypoint 8 | 9 | import ( 10 | "context" 11 | "flag" 12 | "fmt" 13 | "os" 14 | "os/signal" 15 | "sync" 16 | "syscall" 17 | 18 | "github.com/hashicorp/consul-ecs/entrypoint" 19 | "github.com/hashicorp/consul-ecs/logging" 20 | "github.com/hashicorp/go-hclog" 21 | "github.com/mitchellh/cli" 22 | ) 23 | 24 | type Command struct { 25 | UI cli.Ui 26 | log hclog.Logger 27 | once sync.Once 28 | 29 | sigs chan os.Signal 30 | ctx context.Context 31 | cancel context.CancelFunc 32 | envoyCmd *entrypoint.Cmd 33 | appMonitor *AppContainerMonitor 34 | 35 | flagSet *flag.FlagSet 36 | logging.LogOpts 37 | 38 | // for unit tests to wait for start 39 | started chan struct{} 40 | } 41 | 42 | func (c *Command) init() { 43 | c.flagSet = flag.NewFlagSet("", flag.ContinueOnError) 44 | logging.Merge(c.flagSet, c.LogOpts.Flags()) 45 | 46 | c.started = make(chan struct{}, 1) 47 | c.sigs = make(chan os.Signal, 1) 48 | c.ctx, c.cancel = context.WithCancel(context.Background()) 49 | } 50 | 51 | func (c *Command) Run(args []string) int { 52 | c.once.Do(c.init) 53 | 54 | if err := c.flagSet.Parse(args); err != nil { 55 | c.UI.Error(fmt.Sprint(err)) 56 | return 1 57 | } 58 | c.log = c.LogOpts.Logger().Named("consul-ecs") 59 | 60 | // Remaining args for the application command, after parsing our flags 61 | args = c.flagSet.Args() 62 | 63 | if len(args) == 0 { 64 | c.UI.Error("command is required") 65 | return 1 66 | } 67 | 68 | c.envoyCmd = entrypoint.NewCmd(c.log, args) 69 | c.appMonitor = NewAppContainerMonitor(c.log, c.ctx) 70 | 71 | return c.realRun() 72 | } 73 | 74 | func (c *Command) realRun() int { 75 | signal.Notify(c.sigs) 76 | defer c.cleanup() 77 | 78 | // Run Envoy in the background. 79 | go c.envoyCmd.Run() 80 | // The appMonitor wakes up on SIGTERM to poll task metadata. It is done 81 | // when the application container(s) stop, or when it is cancelled. 82 | go c.appMonitor.Run() 83 | 84 | // Wait for Envoy to start. 85 | if _, ok := <-c.envoyCmd.Started(); !ok { 86 | c.log.Error("Envoy failed to start") 87 | return 1 88 | } 89 | c.started <- struct{}{} 90 | 91 | for { 92 | select { 93 | case <-c.envoyCmd.Done(): 94 | // When the Envoy process exits, we're done. 95 | return c.envoyCmd.ProcessState.ExitCode() 96 | case sig := <-c.sigs: 97 | c.handleSignal(sig) 98 | case _, ok := <-c.appMonitor.Done(): 99 | // When the application containers stop (after SIGTERM), tell Envoy to exit. 100 | if ok { 101 | c.log.Info("terminating Envoy with sigterm") 102 | // A negative pid signals the process group to exit, to try to clean up subprocesses as well 103 | if err := syscall.Kill(-c.envoyCmd.Process.Pid, syscall.SIGTERM); err != nil { 104 | c.log.Warn("sending sigterm to Envoy", "error", err.Error()) 105 | } 106 | } 107 | } 108 | } 109 | } 110 | 111 | func (c *Command) handleSignal(sig os.Signal) { 112 | switch sig { 113 | case syscall.SIGTERM, syscall.SIGCHLD, syscall.SIGURG: 114 | return 115 | default: 116 | if err := c.envoyCmd.Process.Signal(sig); err != nil { 117 | c.log.Warn("forwarding signal", "err", err.Error()) 118 | } 119 | } 120 | } 121 | 122 | func (c *Command) cleanup() { 123 | defer close(c.started) 124 | signal.Stop(c.sigs) 125 | // Cancel background goroutines 126 | c.cancel() 127 | <-c.appMonitor.Done() 128 | <-c.envoyCmd.Done() 129 | } 130 | -------------------------------------------------------------------------------- /subcommand/envoy-entrypoint/command_unix_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build !windows 5 | // +build !windows 6 | 7 | package envoyentrypoint 8 | 9 | import ( 10 | "encoding/json" 11 | "os" 12 | "os/signal" 13 | "sync/atomic" 14 | "syscall" 15 | "testing" 16 | "time" 17 | 18 | "github.com/aws/aws-sdk-go/service/ecs" 19 | "github.com/hashicorp/consul-ecs/awsutil" 20 | "github.com/hashicorp/consul-ecs/testutil" 21 | "github.com/hashicorp/consul/sdk/testutil/retry" 22 | "github.com/mitchellh/cli" 23 | "github.com/stretchr/testify/require" 24 | ) 25 | 26 | func TestFlagValidation(t *testing.T) { 27 | ui := cli.NewMockUi() 28 | cmd := Command{ 29 | UI: ui, 30 | } 31 | code := cmd.Run(nil) 32 | require.Equal(t, code, 1) 33 | require.Contains(t, ui.ErrorWriter.String(), "command is required") 34 | } 35 | 36 | func TestRun(t *testing.T) { 37 | cases := map[string]struct { 38 | fakeEnvoy testutil.FakeCommand 39 | sendSigterm bool 40 | sendSigint bool 41 | mockTaskMetadata bool 42 | exitCode int 43 | }{ 44 | "short lived process": { 45 | fakeEnvoy: testutil.SimpleFakeCommand(t, 0), 46 | exitCode: 0, 47 | }, 48 | "sigterm is ignored": { 49 | fakeEnvoy: testutil.SimpleFakeCommand(t, 3), 50 | sendSigterm: true, 51 | exitCode: 0, 52 | }, 53 | "sigint is forwarded": { 54 | fakeEnvoy: testutil.FakeCommandWithTraps(t, 120), 55 | sendSigint: true, 56 | exitCode: 42, 57 | }, 58 | "sigterm is ignored and then sigint is forwarded": { 59 | fakeEnvoy: testutil.FakeCommandWithTraps(t, 120), 60 | sendSigterm: true, 61 | sendSigint: true, 62 | exitCode: 42, 63 | }, 64 | "sigterm is ignored and envoy terminates after the app container": { 65 | fakeEnvoy: testutil.FakeCommandWithTraps(t, 120), 66 | sendSigterm: true, 67 | mockTaskMetadata: true, 68 | exitCode: 55, 69 | }, 70 | } 71 | 72 | for name, c := range cases { 73 | c := c 74 | t.Run(name, func(t *testing.T) { 75 | cliCmd := Command{ 76 | UI: cli.NewMockUi(), 77 | } 78 | // Necessary to avoid a race with initializing the `started` channel 79 | cliCmd.once.Do(cliCmd.init) 80 | 81 | // Start the target command asynchronously. 82 | exitCodeChan := make(chan int, 1) 83 | go func() { 84 | defer close(exitCodeChan) 85 | code := cliCmd.Run([]string{"-log-level", "debug", "/bin/sh", "-c", c.fakeEnvoy.Command}) 86 | exitCodeChan <- code 87 | }() 88 | 89 | t.Logf("Wait for fake Envoy process to start") 90 | retry.RunWith(&retry.Timer{Timeout: 1 * time.Second, Wait: 100 * time.Millisecond}, t, func(r *retry.R) { 91 | require.FileExists(r, c.fakeEnvoy.ReadyFile) 92 | }) 93 | 94 | // Necessary to avoid concurrent accesses that trigger the race detector. 95 | t.Logf("Wait for envoy-entrypoint to see Envoy has started") 96 | _, ok := <-cliCmd.started 97 | require.True(t, ok) 98 | 99 | envoyPid := cliCmd.envoyCmd.Process.Pid 100 | t.Logf("Fake Envoy process started (pid=%v)", envoyPid) 101 | 102 | // Testing signal handling requires signaling the entrypoint process. 103 | // This is awkward since that is the CURRENT process running this test. 104 | // To avoid accidentally terminating the test run, intercept signals here as well. 105 | // This is okay. Go supports multiple registered channels for signal notification. 106 | sigs := make(chan os.Signal, 2) 107 | signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT) 108 | t.Cleanup(func() { 109 | signal.Stop(sigs) 110 | }) 111 | 112 | var ecsMetaRequestCount int64 // atomic, to pass race detector 113 | if c.mockTaskMetadata { 114 | // Simulate two requests with the app container running, and the rest with it stopped. 115 | testutil.TaskMetaServer(t, testutil.TaskMetaHandlerFn(t, func() string { 116 | meta := makeTaskMeta( 117 | "some-app-container", 118 | "consul-ecs-mesh-init", 119 | "consul-dataplane", 120 | "some-aws-managed-container", 121 | ) 122 | 123 | if atomic.LoadInt64(&ecsMetaRequestCount) < 2 { 124 | meta.Containers[0].KnownStatus = "RUNNING" 125 | } else { 126 | meta.Containers[0].KnownStatus = "STOPPED" 127 | } 128 | atomic.AddInt64(&ecsMetaRequestCount, 1) 129 | respData, err := json.Marshal(meta) 130 | require.NoError(t, err) 131 | return string(respData) 132 | })) 133 | } 134 | 135 | if c.sendSigterm { 136 | t.Logf("Send sigterm to the entrypoint") 137 | err := syscall.Kill(os.Getpid(), syscall.SIGTERM) 138 | require.NoError(t, err) 139 | time.Sleep(100 * time.Millisecond) // Give it time to react 140 | 141 | // NOTE: On failure to fetch Task metadata, Envoy should continue running. 142 | t.Logf("Check the fake Envoy process is still running") 143 | proc, err := os.FindProcess(envoyPid) 144 | require.NoError(t, err, "Failed to find fake Envoy process") 145 | // A zero-signal lets us check the process is still valid/running. 146 | require.NoError(t, proc.Signal(syscall.Signal(0)), 147 | "Sigterm was not ignored by the entrypoint") 148 | } 149 | 150 | // After SIGTERM, the entrypoint begins polling the task metadata server. 151 | // It exits when the application container(s) have exited. 152 | if c.mockTaskMetadata { 153 | retry.RunWith(&retry.Timer{Timeout: 4 * time.Second, Wait: 500 * time.Millisecond}, t, func(r *retry.R) { 154 | // Sanity check. We mock two requests with app container running, and the rest with the app container stopped. 155 | require.GreaterOrEqual(r, atomic.LoadInt64(&ecsMetaRequestCount), int64(3)) 156 | 157 | r.Logf("Check the fake Envoy process exits") 158 | proc, err := os.FindProcess(envoyPid) 159 | require.NoError(r, err, "Failed to find fake Envoy process") 160 | // A zero-signal checks the validity of the process id. 161 | err = proc.Signal(syscall.Signal(0)) 162 | msg := "Application exited, but entrypoint did not terminate fake Envoy" 163 | require.Error(r, err, msg) 164 | require.Equal(r, os.ErrProcessDone, err, msg) 165 | }) 166 | } 167 | 168 | // Send a SIGINT to the entrypoint. This should be forwarded along to the sub-process, 169 | // which causes the fakeEnvoyScript to exit. 170 | if c.sendSigint { 171 | t.Logf("Send sigint to entrypoint") 172 | err := syscall.Kill(os.Getpid(), syscall.SIGINT) 173 | require.NoError(t, err) 174 | time.Sleep(100 * time.Millisecond) // Give it time to react 175 | 176 | t.Logf("Check the fake Envoy process has exited") 177 | retry.RunWith(&retry.Timer{Timeout: 2 * time.Second, Wait: 250 * time.Millisecond}, t, func(r *retry.R) { 178 | proc, err := os.FindProcess(envoyPid) 179 | require.NoError(r, err, "Failed to find fake Envoy process") 180 | err = proc.Signal(syscall.Signal(0)) 181 | require.Error(r, err, "Sigint was not forwarded to fake Envoy process") 182 | require.Equal(r, os.ErrProcessDone, err) 183 | }) 184 | } 185 | 186 | // If !ok, then the channel was closed without actually sending the exit code. 187 | exitCode, ok := <-exitCodeChan 188 | require.True(t, ok) 189 | require.Equal(t, c.exitCode, exitCode) 190 | }) 191 | } 192 | } 193 | 194 | // makeTaskMeta returns task metadata with the given container names. 195 | // All containers are put in DesiredStatus=STOPPED and KnownStatus=RUNNING, 196 | // to allow us to simulate task shutdown. 197 | func makeTaskMeta(containerNames ...string) awsutil.ECSTaskMeta { 198 | var containers []awsutil.ECSTaskMetaContainer 199 | for _, name := range containerNames { 200 | container := awsutil.ECSTaskMetaContainer{ 201 | Name: name, 202 | DesiredStatus: ecs.DesiredStatusStopped, 203 | KnownStatus: ecs.DesiredStatusRunning, 204 | Type: "NORMAL", 205 | } 206 | 207 | if container.Name == "some-aws-managed-container" { 208 | container.Type = "AWS MANAGED" 209 | } 210 | containers = append(containers, container) 211 | } 212 | 213 | return awsutil.ECSTaskMeta{ 214 | Cluster: "test", 215 | TaskARN: "abc123", 216 | Family: "test-service", 217 | Containers: containers, 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /subcommand/envoy-entrypoint/command_windows.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build windows 5 | // +build windows 6 | 7 | // Not implemented for Windows. 8 | // Our Unix implementation doesn't compile on Windows, and we only need to support 9 | // Linux since this is an entrypoint to a Docker container. 10 | 11 | package envoyentrypoint 12 | 13 | import ( 14 | "github.com/hashicorp/go-hclog" 15 | "github.com/mitchellh/cli" 16 | ) 17 | 18 | type Command struct { 19 | UI cli.Ui 20 | log hclog.Logger 21 | } 22 | 23 | func (c *Command) Run(args []string) int { 24 | c.UI.Error("not implemented on Windows") 25 | return 1 26 | } 27 | -------------------------------------------------------------------------------- /subcommand/envoy-entrypoint/task-monitor.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build !windows 5 | // +build !windows 6 | 7 | package envoyentrypoint 8 | 9 | import ( 10 | "context" 11 | "os" 12 | "os/signal" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/hashicorp/consul-ecs/awsutil" 17 | "github.com/hashicorp/go-hclog" 18 | ) 19 | 20 | var ( 21 | nonAppContainers = map[string]struct{}{ 22 | "consul-ecs-mesh-init": {}, 23 | "consul-dataplane": {}, 24 | "consul-ecs-health-sync": {}, 25 | } 26 | ) 27 | 28 | type AppContainerMonitor struct { 29 | log hclog.Logger 30 | ctx context.Context 31 | 32 | doneCh chan struct{} 33 | } 34 | 35 | func NewAppContainerMonitor(log hclog.Logger, ctx context.Context) *AppContainerMonitor { 36 | return &AppContainerMonitor{ 37 | log: log, 38 | ctx: ctx, 39 | doneCh: make(chan struct{}, 1), 40 | } 41 | } 42 | 43 | func (t *AppContainerMonitor) Done() chan struct{} { 44 | return t.doneCh 45 | } 46 | 47 | // Run will wake up when SIGTERM is received. Then, it polls task metadata 48 | // until the application container(s) stop. Use the Done() channel to wait 49 | // until it has finished. It is cancellable through its context. 50 | func (t *AppContainerMonitor) Run() { 51 | defer close(t.doneCh) 52 | 53 | if !t.waitForSIGTERM() { 54 | t.doneCh <- struct{}{} 55 | return 56 | } 57 | 58 | t.log.Info("waiting for application container(s) to stop") 59 | for { 60 | select { 61 | case <-t.ctx.Done(): 62 | return 63 | case <-time.After(1 * time.Second): 64 | taskMeta, err := awsutil.ECSTaskMetadata() 65 | if err != nil { 66 | t.log.Error("fetching task metadata", "err", err.Error()) 67 | break // escape this case of the select 68 | } 69 | 70 | if allAppContainersStopped(taskMeta) { 71 | t.log.Info("application container(s) have stopped, terminating envoy") 72 | t.doneCh <- struct{}{} 73 | return 74 | } 75 | } 76 | } 77 | } 78 | 79 | func (t *AppContainerMonitor) waitForSIGTERM() bool { 80 | sigs := make(chan os.Signal, 1) 81 | signal.Notify(sigs, syscall.SIGTERM) 82 | defer signal.Stop(sigs) 83 | 84 | for { 85 | select { 86 | case <-sigs: 87 | return true 88 | case <-t.ctx.Done(): 89 | return false 90 | } 91 | } 92 | } 93 | 94 | func allAppContainersStopped(taskMeta awsutil.ECSTaskMeta) bool { 95 | allStopped := true 96 | for _, container := range taskMeta.Containers { 97 | if isApplication(container) && !container.HasStopped() { 98 | allStopped = false 99 | } 100 | } 101 | return allStopped 102 | } 103 | 104 | func isApplication(container awsutil.ECSTaskMetaContainer) bool { 105 | // ECS might auto-inject some containers that aren't part of 106 | // the task definition. We treat them as non app containers. 107 | if !container.IsNormalType() { 108 | return false 109 | } 110 | 111 | _, ok := nonAppContainers[container.Name] 112 | return !ok 113 | } 114 | -------------------------------------------------------------------------------- /subcommand/health-sync/checks_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package healthsync 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/aws/aws-sdk-go/service/ecs" 10 | "github.com/hashicorp/consul-ecs/awsutil" 11 | "github.com/hashicorp/consul/api" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestEcsHealthToConsulHealth(t *testing.T) { 16 | require.Equal(t, api.HealthPassing, ecsHealthToConsulHealth(ecs.HealthStatusHealthy)) 17 | require.Equal(t, api.HealthCritical, ecsHealthToConsulHealth(ecs.HealthStatusUnknown)) 18 | require.Equal(t, api.HealthCritical, ecsHealthToConsulHealth(ecs.HealthStatusUnhealthy)) 19 | require.Equal(t, api.HealthCritical, ecsHealthToConsulHealth("")) 20 | } 21 | 22 | func TestFindContainersToSync(t *testing.T) { 23 | taskMetaContainer1 := awsutil.ECSTaskMetaContainer{ 24 | Name: "container1", 25 | } 26 | 27 | cases := map[string]struct { 28 | containerNames []string 29 | taskMeta awsutil.ECSTaskMeta 30 | missing []string 31 | found []awsutil.ECSTaskMetaContainer 32 | }{ 33 | "A container isn't in the metadata": { 34 | containerNames: []string{"container1"}, 35 | taskMeta: awsutil.ECSTaskMeta{}, 36 | missing: []string{"container1"}, 37 | found: nil, 38 | }, 39 | "The metadata has an extra container": { 40 | containerNames: []string{}, 41 | taskMeta: awsutil.ECSTaskMeta{ 42 | Containers: []awsutil.ECSTaskMetaContainer{ 43 | taskMetaContainer1, 44 | }, 45 | }, 46 | missing: nil, 47 | found: nil, 48 | }, 49 | "some found and some not found": { 50 | containerNames: []string{"container1", "container2"}, 51 | taskMeta: awsutil.ECSTaskMeta{ 52 | Containers: []awsutil.ECSTaskMetaContainer{ 53 | taskMetaContainer1, 54 | }, 55 | }, 56 | missing: []string{"container2"}, 57 | found: []awsutil.ECSTaskMetaContainer{ 58 | taskMetaContainer1, 59 | }, 60 | }, 61 | } 62 | 63 | for name, testData := range cases { 64 | t.Run(name, func(t *testing.T) { 65 | found, missing := findContainersToSync(testData.containerNames, testData.taskMeta) 66 | require.Equal(t, testData.missing, missing) 67 | require.Equal(t, testData.found, found) 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /subcommand/health-sync/command.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package healthsync 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net" 10 | "os" 11 | "os/signal" 12 | "path/filepath" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | "syscall" 17 | "time" 18 | 19 | "github.com/hashicorp/consul-ecs/awsutil" 20 | "github.com/hashicorp/consul-ecs/config" 21 | "github.com/hashicorp/consul-ecs/logging" 22 | "github.com/hashicorp/consul-server-connection-manager/discovery" 23 | "github.com/hashicorp/consul/api" 24 | "github.com/hashicorp/go-hclog" 25 | "github.com/hashicorp/go-multierror" 26 | "github.com/mitchellh/cli" 27 | ) 28 | 29 | const ( 30 | // syncChecksInterval is how often we poll the container health endpoint. 31 | // The rate limit is about 40 per second, so 1 second polling seems reasonable. 32 | syncChecksInterval = 1 * time.Second 33 | ) 34 | 35 | type Command struct { 36 | UI cli.Ui 37 | config *config.Config 38 | log hclog.Logger 39 | 40 | ctx context.Context 41 | cancel context.CancelFunc 42 | sigs chan os.Signal 43 | once sync.Once 44 | 45 | checks map[string]*api.HealthCheck 46 | dataplaneMonitor *dataplaneMonitor 47 | watcherCh <-chan discovery.State 48 | 49 | // Following fields are only needed for unit tests 50 | 51 | // health-sync signals to this channel whenever it has completed 52 | // all the prerequisites before entering the reconciliation loop. 53 | doneChan chan struct{} 54 | 55 | // health-sync waits for someone to signal to this channel before 56 | // entering the checks reconcilation loop. 57 | proceedChan chan struct{} 58 | 59 | // Indicates that the command is run from a unit test 60 | isTestEnv bool 61 | } 62 | 63 | func (c *Command) init() { 64 | c.ctx, c.cancel = context.WithCancel(context.Background()) 65 | c.sigs = make(chan os.Signal, 1) 66 | } 67 | 68 | func (c *Command) Run(args []string) int { 69 | c.once.Do(c.init) 70 | if len(args) > 0 { 71 | c.UI.Error(fmt.Sprintf("unexpected argument: %s", args[0])) 72 | return 1 73 | } 74 | 75 | conf, err := config.FromEnv() 76 | if err != nil { 77 | c.UI.Error(fmt.Sprintf("invalid config: %s", err)) 78 | return 1 79 | } 80 | c.config = conf 81 | 82 | c.log = logging.FromConfig(c.config).Logger() 83 | c.dataplaneMonitor = newDataplaneMonitor(c.ctx, c.log) 84 | 85 | if err := c.realRun(); err != nil { 86 | c.log.Error("error running main", "err", err) 87 | return 1 88 | } 89 | 90 | return 0 91 | } 92 | 93 | func (c *Command) realRun() error { 94 | signal.Notify(c.sigs, syscall.SIGTERM) 95 | defer c.cleanup() 96 | 97 | go c.dataplaneMonitor.run() 98 | 99 | taskMeta, err := awsutil.ECSTaskMetadata() 100 | if err != nil { 101 | return err 102 | } 103 | 104 | clusterARN, err := taskMeta.ClusterARN() 105 | if err != nil { 106 | return err 107 | } 108 | 109 | serverConnMgrCfg, err := c.config.ConsulServerConnMgrConfig(taskMeta) 110 | if err != nil { 111 | return fmt.Errorf("constructing server connection manager config: %w", err) 112 | } 113 | 114 | watcher, err := discovery.NewWatcher(c.ctx, serverConnMgrCfg, c.log) 115 | if err != nil { 116 | return fmt.Errorf("unable to create consul server watcher: %w", err) 117 | } 118 | 119 | go watcher.Run() 120 | defer watcher.Stop() 121 | 122 | state, err := watcher.State() 123 | if err != nil { 124 | return fmt.Errorf("unable to fetch consul server watcher state: %w", err) 125 | } 126 | 127 | consulClient, err := c.setupConsulAPIClient(state) 128 | if err != nil { 129 | return fmt.Errorf("unable to setup Consul API client: %w", err) 130 | } 131 | 132 | if !c.isTestEnv { 133 | c.watcherCh = watcher.Subscribe() 134 | } 135 | 136 | var healthSyncContainers []string 137 | healthSyncContainers = append(healthSyncContainers, c.config.HealthSyncContainers...) 138 | healthSyncContainers = append(healthSyncContainers, config.ConsulDataplaneContainerName) 139 | currentHealthStatuses := make(map[string]string) 140 | 141 | c.checks, err = c.fetchHealthChecks(consulClient, taskMeta) 142 | if err != nil { 143 | return fmt.Errorf("unable to fetch checks before the reconciliation loop %w", err) 144 | } 145 | 146 | if c.isTestEnv { 147 | close(c.doneChan) 148 | <-c.proceedChan 149 | } 150 | 151 | // shuttingDown flag to prevent syncChecks after SIGTERM 152 | shuttingDown := false 153 | 154 | for { 155 | select { 156 | case <-time.After(syncChecksInterval): 157 | if !shuttingDown { 158 | currentHealthStatuses = c.syncChecks(consulClient, currentHealthStatuses, clusterARN, healthSyncContainers) 159 | } 160 | case watcherState := <-c.watcherCh: 161 | c.log.Info("Switching to Consul server", "address", watcherState.Address.String()) 162 | client, err := c.setupConsulAPIClient(watcherState) 163 | if err != nil { 164 | c.log.Error("error re-configuring consul client %s", err.Error()) 165 | } else { 166 | consulClient = client 167 | } 168 | case <-c.sigs: 169 | shuttingDown = true 170 | c.log.Info("Received SIGTERM. Beginning graceful shutdown by first marking all checks as critical.") 171 | err := c.setChecksCritical(consulClient, taskMeta, clusterARN, healthSyncContainers) 172 | if err != nil { 173 | c.log.Error("Error marking the status of checks as critical: %s", err.Error()) 174 | } 175 | case <-c.dataplaneMonitor.done(): 176 | var result error 177 | c.log.Info("Dataplane has successfully shutdown. Deregistering services and terminating health-sync") 178 | 179 | if c.config.IsGateway() { 180 | err = c.deregisterGatewayProxy(consulClient, taskMeta, clusterARN) 181 | if err != nil { 182 | c.log.Error("error deregistering gateway %s", err.Error()) 183 | result = multierror.Append(result, err) 184 | } 185 | } else { 186 | err = c.deregisterServiceAndProxy(consulClient, taskMeta, clusterARN) 187 | if err != nil { 188 | c.log.Error("error deregistering service and proxy %s", err.Error()) 189 | result = multierror.Append(result, err) 190 | } 191 | } 192 | 193 | if c.config.ConsulLogin.Enabled { 194 | _, err = consulClient.ACL().Logout(nil) 195 | if err != nil { 196 | c.log.Error("error logging out of consul %s", err.Error()) 197 | result = multierror.Append(result, err) 198 | } 199 | } 200 | 201 | return result 202 | } 203 | } 204 | } 205 | 206 | func (c *Command) Synopsis() string { 207 | return "Syncs ECS container's health status into Consul" 208 | } 209 | 210 | func (c *Command) Help() string { 211 | return "" 212 | } 213 | 214 | func (c *Command) cleanup() { 215 | c.cancel() 216 | } 217 | 218 | func (c *Command) setupConsulAPIClient(state discovery.State) (*api.Client, error) { 219 | if c.isTestEnv && c.config.ConsulLogin.Enabled { 220 | tokenFile := filepath.Join(c.config.BootstrapDir, config.ServiceTokenFilename) 221 | err := os.WriteFile(tokenFile, []byte(state.Token), 0644) 222 | if err != nil { 223 | return nil, err 224 | } 225 | } 226 | 227 | // Client config for the client that talks directly to the server agent 228 | cfg := c.config.ClientConfig() 229 | cfg.Address = net.JoinHostPort(state.Address.IP.String(), strconv.FormatInt(int64(c.config.ConsulServers.HTTP.Port), 10)) 230 | if state.Token != "" { 231 | cfg.Token = state.Token 232 | } 233 | 234 | return api.NewClient(cfg) 235 | } 236 | 237 | func (c *Command) deregisterServiceAndProxy(consulClient *api.Client, taskMeta awsutil.ECSTaskMeta, clusterARN string) error { 238 | var result error 239 | serviceName := c.constructServiceName(taskMeta.Family) 240 | taskID := taskMeta.TaskID() 241 | serviceID := makeServiceID(serviceName, taskID) 242 | 243 | service := c.config.Service.ToConsulType() 244 | 245 | err := deregisterConsulService(consulClient, serviceID, service.Namespace, service.Partition, clusterARN) 246 | if err != nil { 247 | result = multierror.Append(result, err) 248 | } 249 | 250 | // Proxy deregistration 251 | proxySvcID, _ := makeProxySvcIDAndName(serviceID, serviceName) 252 | err = deregisterConsulService(consulClient, proxySvcID, service.Namespace, service.Partition, clusterARN) 253 | if err != nil { 254 | result = multierror.Append(result, err) 255 | } 256 | 257 | return result 258 | } 259 | 260 | func (c *Command) deregisterGatewayProxy(consulClient *api.Client, taskMeta awsutil.ECSTaskMeta, clusterARN string) error { 261 | gatewaySvcName := c.constructServiceName(taskMeta.Family) 262 | taskID := taskMeta.TaskID() 263 | gatewaySvcID := makeServiceID(gatewaySvcName, taskID) 264 | 265 | gatewaySvc := c.config.Gateway.ToConsulType() 266 | 267 | return deregisterConsulService(consulClient, gatewaySvcID, gatewaySvc.Namespace, gatewaySvc.Partition, clusterARN) 268 | } 269 | 270 | func (c *Command) constructServiceName(family string) string { 271 | var configName string 272 | if c.config.IsGateway() { 273 | configName = c.config.Gateway.Name 274 | } else { 275 | configName = c.config.Service.Name 276 | } 277 | 278 | if configName == "" { 279 | return strings.ToLower(family) 280 | } 281 | return configName 282 | } 283 | 284 | func makeServiceID(serviceName, taskID string) string { 285 | return fmt.Sprintf("%s-%s", serviceName, taskID) 286 | } 287 | 288 | func makeProxySvcIDAndName(serviceID, serviceName string) (string, string) { 289 | fmtStr := "%s-sidecar-proxy" 290 | return fmt.Sprintf(fmtStr, serviceID), fmt.Sprintf(fmtStr, serviceName) 291 | } 292 | 293 | func deregisterConsulService(client *api.Client, svcID, namespace, partition, node string) error { 294 | deregInput := &api.CatalogDeregistration{ 295 | Node: node, 296 | ServiceID: svcID, 297 | Namespace: namespace, 298 | Partition: partition, 299 | } 300 | 301 | _, err := client.Catalog().Deregister(deregInput, nil) 302 | return err 303 | } 304 | -------------------------------------------------------------------------------- /subcommand/health-sync/dataplane_monitor.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package healthsync 5 | 6 | import ( 7 | "context" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/hashicorp/consul-ecs/awsutil" 14 | "github.com/hashicorp/consul-ecs/config" 15 | "github.com/hashicorp/go-hclog" 16 | ) 17 | 18 | type dataplaneMonitor struct { 19 | ctx context.Context 20 | log hclog.Logger 21 | 22 | doneCh chan struct{} 23 | } 24 | 25 | func newDataplaneMonitor(ctx context.Context, logger hclog.Logger) *dataplaneMonitor { 26 | return &dataplaneMonitor{ 27 | ctx: ctx, 28 | log: logger, 29 | doneCh: make(chan struct{}), 30 | } 31 | } 32 | 33 | func (d *dataplaneMonitor) done() chan struct{} { 34 | return d.doneCh 35 | } 36 | 37 | // Run will wake up when SIGTERM is received. Then, it polls task metadata 38 | // until the dataplane container stops. Use the Done() channel to wait 39 | // until it has finished. 40 | func (d *dataplaneMonitor) run() { 41 | defer close(d.doneCh) 42 | 43 | if !d.waitForSIGTERM() { 44 | return 45 | } 46 | 47 | d.log.Info("waiting for dataplane container to stop") 48 | for { 49 | select { 50 | case <-d.ctx.Done(): 51 | return 52 | case <-time.After(1 * time.Second): 53 | taskMeta, err := awsutil.ECSTaskMetadata() 54 | if err != nil { 55 | d.log.Error("fetching task metadata", "err", err.Error()) 56 | break 57 | } 58 | 59 | d.log.Info("Waiting for dataplane container to stop") 60 | if taskMeta.HasContainerStopped(config.ConsulDataplaneContainerName) { 61 | d.log.Info("dataplane container has stopped, terminating health-sync") 62 | return 63 | } 64 | } 65 | } 66 | } 67 | 68 | func (d *dataplaneMonitor) waitForSIGTERM() bool { 69 | sigs := make(chan os.Signal, 1) 70 | signal.Notify(sigs, syscall.SIGTERM) 71 | defer signal.Stop(sigs) 72 | 73 | for { 74 | select { 75 | case <-sigs: 76 | return true 77 | case <-d.ctx.Done(): 78 | return false 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /subcommand/mesh-init/checks.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package meshinit 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/hashicorp/consul-ecs/config" 10 | "github.com/hashicorp/consul/api" 11 | ) 12 | 13 | const ( 14 | consulECSCheckType = "consul-ecs-health-check" 15 | 16 | consulHealthSyncCheckName = "Consul ECS health check synced" 17 | 18 | consulDataplaneReadinessCheckName = "Consul dataplane readiness" 19 | ) 20 | 21 | func (c *Command) constructChecks(service *api.AgentService) api.HealthChecks { 22 | checks := make(api.HealthChecks, 0) 23 | if service.Kind == api.ServiceKindTypical { 24 | for _, containerName := range c.config.HealthSyncContainers { 25 | checks = append(checks, &api.HealthCheck{ 26 | CheckID: constructCheckID(service.ID, containerName), 27 | Name: consulHealthSyncCheckName, 28 | Type: consulECSCheckType, 29 | ServiceID: service.ID, 30 | Namespace: service.Namespace, 31 | Status: api.HealthCritical, 32 | Output: healthCheckOutputReason(api.HealthCritical, service.Service), 33 | Notes: fmt.Sprintf("consul-ecs created and updates this check because the %s container has an ECS health check.", containerName), 34 | }) 35 | } 36 | } 37 | 38 | // Add a custom check that indicates dataplane readiness 39 | checks = append(checks, &api.HealthCheck{ 40 | CheckID: constructCheckID(service.ID, config.ConsulDataplaneContainerName), 41 | Name: consulDataplaneReadinessCheckName, 42 | Type: consulECSCheckType, 43 | ServiceID: service.ID, 44 | Namespace: service.Namespace, 45 | Status: api.HealthCritical, 46 | Output: healthCheckOutputReason(api.HealthCritical, service.Service), 47 | Notes: "consul-ecs created and updates this check to indicate consul-dataplane container's readiness", 48 | }) 49 | return checks 50 | } 51 | 52 | func constructCheckID(serviceID, containerName string) string { 53 | return fmt.Sprintf("%s-%s", serviceID, containerName) 54 | } 55 | 56 | func healthCheckOutputReason(status, serviceName string) string { 57 | if status == api.HealthPassing { 58 | return "ECS health check passing" 59 | } 60 | 61 | return fmt.Sprintf("Service %s is not ready", serviceName) 62 | } 63 | -------------------------------------------------------------------------------- /subcommand/mesh-init/checks_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package meshinit 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/hashicorp/consul-ecs/config" 10 | "github.com/hashicorp/consul-ecs/testutil" 11 | "github.com/hashicorp/consul/api" 12 | "github.com/mitchellh/cli" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestConstructChecks(t *testing.T) { 17 | cases := map[string]struct { 18 | service *api.AgentService 19 | healthSyncContainers []string 20 | expectedChecks api.HealthChecks 21 | }{ 22 | "construct checks for the basic service": { 23 | service: &api.AgentService{ 24 | ID: "test-service-1234", 25 | Service: "test-service", 26 | Port: 8080, 27 | }, 28 | healthSyncContainers: []string{"container1", "container2"}, 29 | expectedChecks: api.HealthChecks{ 30 | &api.HealthCheck{ 31 | CheckID: constructCheckID("test-service-1234", "container1"), 32 | Name: consulHealthSyncCheckName, 33 | Type: consulECSCheckType, 34 | ServiceID: "test-service-1234", 35 | Status: api.HealthCritical, 36 | Output: "Service test-service is not ready", 37 | Notes: "consul-ecs created and updates this check because the container1 container has an ECS health check.", 38 | }, 39 | &api.HealthCheck{ 40 | CheckID: constructCheckID("test-service-1234", "container2"), 41 | Name: consulHealthSyncCheckName, 42 | Type: consulECSCheckType, 43 | ServiceID: "test-service-1234", 44 | Status: api.HealthCritical, 45 | Output: "Service test-service is not ready", 46 | Notes: "consul-ecs created and updates this check because the container2 container has an ECS health check.", 47 | }, 48 | &api.HealthCheck{ 49 | CheckID: constructCheckID("test-service-1234", config.ConsulDataplaneContainerName), 50 | Name: consulDataplaneReadinessCheckName, 51 | Type: consulECSCheckType, 52 | ServiceID: "test-service-1234", 53 | Status: api.HealthCritical, 54 | Output: "Service test-service is not ready", 55 | Notes: "consul-ecs created and updates this check to indicate consul-dataplane container's readiness", 56 | }, 57 | }, 58 | }, 59 | "construct checks for the sidecar proxy service": { 60 | service: &api.AgentService{ 61 | ID: "test-service-sidecar-proxy-1234", 62 | Service: "test-service-sidecar-proxy", 63 | Port: 19000, 64 | Kind: api.ServiceKindConnectProxy, 65 | }, 66 | expectedChecks: api.HealthChecks{ 67 | &api.HealthCheck{ 68 | CheckID: constructCheckID("test-service-sidecar-proxy-1234", config.ConsulDataplaneContainerName), 69 | Name: consulDataplaneReadinessCheckName, 70 | Type: consulECSCheckType, 71 | ServiceID: "test-service-sidecar-proxy-1234", 72 | Status: api.HealthCritical, 73 | Output: "Service test-service-sidecar-proxy is not ready", 74 | Notes: "consul-ecs created and updates this check to indicate consul-dataplane container's readiness", 75 | }, 76 | }, 77 | }, 78 | "construct checks for gateway proxy service": { 79 | service: &api.AgentService{ 80 | ID: "gateway-proxy-1234", 81 | Service: "gateway-proxy", 82 | Port: 8443, 83 | Kind: api.ServiceKindMeshGateway, 84 | }, 85 | expectedChecks: api.HealthChecks{ 86 | &api.HealthCheck{ 87 | CheckID: constructCheckID("gateway-proxy-1234", config.ConsulDataplaneContainerName), 88 | Name: consulDataplaneReadinessCheckName, 89 | Type: consulECSCheckType, 90 | ServiceID: "gateway-proxy-1234", 91 | Status: api.HealthCritical, 92 | Output: "Service gateway-proxy is not ready", 93 | Notes: "consul-ecs created and updates this check to indicate consul-dataplane container's readiness", 94 | }, 95 | }, 96 | }, 97 | } 98 | 99 | for name, c := range cases { 100 | t.Run(name, func(t *testing.T) { 101 | namespace := "" 102 | partition := "" 103 | if testutil.EnterpriseFlag() { 104 | namespace = "test-namespace" 105 | partition = "test-partition" 106 | } 107 | 108 | c.service.Namespace = namespace 109 | c.service.Partition = partition 110 | 111 | for _, expHealthCheck := range c.expectedChecks { 112 | expHealthCheck.Namespace = namespace 113 | } 114 | 115 | ui := cli.NewMockUi() 116 | cmd := Command{UI: ui} 117 | cmd.config = &config.Config{ 118 | HealthSyncContainers: c.healthSyncContainers, 119 | } 120 | 121 | require.Equal(t, c.expectedChecks, cmd.constructChecks(c.service)) 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /subcommand/net-dial/command.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package netdial 5 | 6 | import ( 7 | "net" 8 | 9 | "github.com/mitchellh/cli" 10 | ) 11 | 12 | type Command struct { 13 | UI cli.Ui 14 | } 15 | 16 | func (c *Command) Run(args []string) int { 17 | if len(args) != 1 { 18 | c.UI.Error("invalid invocation, expected one positional argument: :") 19 | return 1 20 | } 21 | 22 | conn, err := net.Dial("tcp", args[0]) 23 | if err != nil { 24 | return 2 25 | } 26 | conn.Close() 27 | 28 | return 0 29 | } 30 | 31 | func (c *Command) Synopsis() string { 32 | return "Checks for a TCP listener on a host" 33 | } 34 | 35 | func (c *Command) Help() string { 36 | return `usage: consul-ecs net-dial : 37 | 38 | Attempts to open a TCP connection to :. 39 | An exit code of 0 is returned if the connection succeeds. 40 | A non zero exit code is returned if the connection fails for any reason. 41 | ` 42 | } 43 | -------------------------------------------------------------------------------- /subcommand/net-dial/command_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package netdial 5 | 6 | import ( 7 | "net" 8 | "testing" 9 | 10 | "github.com/mitchellh/cli" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestNetDial(t *testing.T) { 15 | cases := map[string]struct { 16 | host string 17 | code int 18 | errStr string 19 | }{ 20 | "success": {host: "localhost", code: 0}, 21 | "failure no listener": {host: "localhost", code: 2}, 22 | "failure invalid args": {code: 1}, 23 | } 24 | for name, c := range cases { 25 | t.Run(name, func(t *testing.T) { 26 | ui := cli.NewMockUi() 27 | cmd := Command{UI: ui} 28 | 29 | var args []string 30 | 31 | if c.host != "" { 32 | l, err := net.Listen("tcp", c.host+":") 33 | require.NoError(t, err) 34 | args = append(args, l.Addr().String()) 35 | if c.code != 0 { 36 | l.Close() 37 | } else { 38 | t.Cleanup(func() { l.Close() }) 39 | } 40 | } 41 | 42 | require.Equal(t, c.code, cmd.Run(args)) 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /subcommand/version/command.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package version 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/mitchellh/cli" 10 | ) 11 | 12 | type Command struct { 13 | UI cli.Ui 14 | Version string 15 | } 16 | 17 | func (c *Command) Run(_ []string) int { 18 | c.UI.Output(fmt.Sprintf("consul-ecs %s", c.Version)) 19 | return 0 20 | } 21 | 22 | func (c *Command) Synopsis() string { 23 | return "Prints the version" 24 | } 25 | 26 | func (c *Command) Help() string { 27 | return "" 28 | } 29 | -------------------------------------------------------------------------------- /testutil/aws.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package testutil 5 | 6 | import ( 7 | "io" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "testing" 12 | "time" 13 | 14 | "github.com/hashicorp/consul-ecs/awsutil" 15 | "github.com/hashicorp/consul-ecs/testutil/iamauthtest" 16 | "github.com/hashicorp/consul/api" 17 | "github.com/hashicorp/consul/sdk/testutil/retry" 18 | "github.com/stretchr/testify/require" 19 | ) 20 | 21 | // TaskMetaHandler returns an http.Handler that always responds with the given string 22 | // for the 'GET /task' request of the ECS Task Metadata server. 23 | func TaskMetaHandler(t *testing.T, resp string) http.Handler { 24 | return TaskMetaHandlerFn(t, func() string { return resp }) 25 | } 26 | 27 | // TaskMetaHandler wraps the respFn in an http.Handler for the ECS Task Metadata server. 28 | // respFn should return a response to the 'GET /task' request. 29 | func TaskMetaHandlerFn(t *testing.T, respFn func() string) http.Handler { 30 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 | if r != nil && r.Method == "GET" { 32 | switch r.URL.Path { 33 | case "/task": 34 | resp := respFn() 35 | _, err := w.Write([]byte(resp)) 36 | require.NoError(t, err) 37 | case "/ok": 38 | // A "health" endpoint to make sure the server has started for tests. 39 | // We don't use /task to avoid affecting state used by respFn. 40 | _, err := w.Write([]byte("ok")) 41 | require.NoError(t, err) 42 | } 43 | } 44 | }) 45 | } 46 | 47 | // TaskMetaServer starts a local HTTP server to mimic the ECS Task Metadata server. 48 | // This sets ECS_CONTAINER_METADATA_URI_V4 and configures a test cleanup. 49 | // Because of the environment variable, this is unsafe for running tests in parallel. 50 | func TaskMetaServer(t *testing.T, handler http.Handler) { 51 | ecsMetadataServer := httptest.NewServer(handler) 52 | 53 | // Help detect invalid concurrent servers since this relies on an environment variable. 54 | require.Empty(t, os.Getenv(awsutil.ECSMetadataURIEnvVar), 55 | "%s already set. TaskMetaServer cannot be used concurrently.", awsutil.ECSMetadataURIEnvVar, 56 | ) 57 | 58 | t.Cleanup(func() { 59 | _ = os.Unsetenv(awsutil.ECSMetadataURIEnvVar) 60 | ecsMetadataServer.Close() 61 | }) 62 | err := os.Setenv(awsutil.ECSMetadataURIEnvVar, ecsMetadataServer.URL) 63 | 64 | require.NoError(t, err) 65 | 66 | // Wait for a successful response before proceeding. 67 | retry.RunWith(&retry.Timer{Timeout: 3 * time.Second, Wait: 250 * time.Millisecond}, t, func(r *retry.R) { 68 | resp, err := ecsMetadataServer.Client().Get(ecsMetadataServer.URL + "/ok") 69 | require.NoError(r, err) 70 | body, err := io.ReadAll(resp.Body) 71 | require.NoError(r, err) 72 | require.Equal(r, string(body), "ok") 73 | }) 74 | 75 | } 76 | 77 | // AuthMethodInit sets up necessary pieces for the IAM auth method: 78 | // - Start a fake AWS server. This responds with an IAM role tagged with expectedServiceName. 79 | // - Configures an auth method + binding rule that uses the tagged service name from the IAM 80 | // role for the service identity. 81 | // - Sets the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to dummy values. 82 | // 83 | // When using this, you will also need to point the login command at the fake AWS server, 84 | // for example: 85 | // 86 | // fakeAws := authMethodInit(...) 87 | // consulLogin.ExtraLoginFlags = []string{"-aws-sts-endpoint", fakeAws.URL + "/sts"} 88 | func AuthMethodInit(t *testing.T, consulClient *api.Client, expectedServiceName, authMethodName string, opts *api.WriteOptions) *httptest.Server { 89 | arn := "arn:aws:iam::1234567890:role/my-role" 90 | uniqueId := "AAAsomeuniqueid" 91 | 92 | // Start a fake AWS API server for STS and IAM. 93 | fakeAws := iamauthtest.NewTestServer(t, &iamauthtest.Server{ 94 | GetCallerIdentityResponse: iamauthtest.MakeGetCallerIdentityResponse( 95 | arn, uniqueId, "1234567890", 96 | ), 97 | GetRoleResponse: iamauthtest.MakeGetRoleResponse( 98 | arn, uniqueId, iamauthtest.Tags{ 99 | Members: []iamauthtest.TagMember{ 100 | {Key: "service-name", Value: expectedServiceName}, 101 | }, 102 | }, 103 | ), 104 | }) 105 | 106 | method, _, err := consulClient.ACL().AuthMethodCreate(&api.ACLAuthMethod{ 107 | Name: authMethodName, 108 | Type: "aws-iam", 109 | Description: "aws auth method for unit test", 110 | Config: map[string]interface{}{ 111 | // Trust the role to login. 112 | "BoundIAMPrincipalARNs": []string{arn}, 113 | // Enable fetching the IAM role 114 | "EnableIAMEntityDetails": true, 115 | // Make this tag available to the binding rule: `entity_tags.service_name` 116 | "IAMEntityTags": []string{"service-name"}, 117 | // Point the auth method at the local fake AWS server. 118 | "STSEndpoint": fakeAws.URL + "/sts", 119 | "IAMEndpoint": fakeAws.URL + "/iam", 120 | }, 121 | }, opts) 122 | require.NoError(t, err) 123 | 124 | _, _, err = consulClient.ACL().BindingRuleCreate(&api.ACLBindingRule{ 125 | AuthMethod: method.Name, 126 | BindType: api.BindingRuleBindTypeService, 127 | // Pull the service name from the IAM role `service-name` tag. 128 | BindName: "${entity_tags.service-name}", 129 | }, opts) 130 | require.NoError(t, err) 131 | 132 | t.Cleanup(func() { 133 | os.Unsetenv("AWS_ACCESS_KEY_ID") 134 | os.Unsetenv("AWS_SECRET_ACCESS_KEY") 135 | }) 136 | os.Setenv("AWS_ACCESS_KEY_ID", "fake-key-id") 137 | os.Setenv("AWS_SECRET_ACCESS_KEY", "fake-secret-key") 138 | 139 | return fakeAws 140 | } 141 | -------------------------------------------------------------------------------- /testutil/config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package testutil 5 | 6 | import ( 7 | "encoding/json" 8 | "os" 9 | "regexp" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | const ( 17 | configEnvVar = "CONSUL_ECS_CONFIG_JSON" 18 | ) 19 | 20 | // TempDir creates a temporary directory. A test cleanup removes the directory 21 | // and its contents. 22 | func TempDir(t *testing.T) string { 23 | dir, err := os.MkdirTemp("", "") 24 | require.NoError(t, err) 25 | 26 | t.Cleanup(func() { 27 | err := os.RemoveAll(dir) 28 | if err != nil { 29 | t.Logf("warning, failed to cleanup temp dir %s - %s", dir, err) 30 | } 31 | }) 32 | 33 | return dir 34 | } 35 | 36 | // SetECSConfigEnvVar the CONSUL_ECS_CONFIG_JSON environment variable 37 | // to the JSON string of the provided value, with a test cleanup. 38 | func SetECSConfigEnvVar(t *testing.T, val interface{}) { 39 | configBytes, err := json.MarshalIndent(val, "", " ") 40 | require.NoError(t, err) 41 | 42 | t.Setenv(configEnvVar, string(configBytes)) 43 | 44 | t.Logf("%s=%s", configEnvVar, os.Getenv(configEnvVar)) 45 | } 46 | 47 | // EnterpriseFlag indicates whether or not the test was invoked with the -enterprise 48 | // command line argument. 49 | func EnterpriseFlag() bool { 50 | re := regexp.MustCompile("^-+enterprise$") 51 | for _, a := range os.Args { 52 | if re.Match([]byte(strings.ToLower(a))) { 53 | return true 54 | } 55 | } 56 | return false 57 | } 58 | 59 | func BoolPtr(v bool) *bool { 60 | return &v 61 | } 62 | -------------------------------------------------------------------------------- /testutil/consul.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package testutil 5 | 6 | import ( 7 | "net" 8 | "os" 9 | "strconv" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | "github.com/hashicorp/consul/api" 15 | "github.com/hashicorp/consul/sdk/testutil" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | type ServerConfigCallback = testutil.ServerConfigCallback 20 | 21 | const AdminToken = "123e4567-e89b-12d3-a456-426614174000" 22 | 23 | // ConsulServer initializes a Consul test server and returns Consul client config 24 | // and the configured test server 25 | func ConsulServer(t *testing.T, cb ServerConfigCallback) (*testutil.TestServer, *api.Config) { 26 | server, err := testutil.NewTestServerConfigT(t, 27 | func(c *testutil.TestServerConfig) { 28 | if cb != nil { 29 | cb(c) 30 | } 31 | // A "peering" config block is passed to the Consul, which causes a config parse error in Consul 1.12. 32 | // This ensures no "peering" config block is passed, so that Consul uses its defaults. 33 | c.Peering = nil 34 | }, 35 | ) 36 | 37 | require.NoError(t, err) 38 | t.Cleanup(func() { 39 | _ = server.Stop() 40 | }) 41 | server.WaitForLeader(t) 42 | 43 | cfg := api.DefaultConfig() 44 | cfg.Address = server.HTTPAddr 45 | if server.Config.ACL.Enabled { 46 | cfg.Token = AdminToken 47 | client, err := api.NewClient(cfg) 48 | require.NoError(t, err) 49 | 50 | for { 51 | ready, err := isACLBootstrapped(client) 52 | require.NoError(t, err) 53 | if ready { 54 | break 55 | } 56 | 57 | t.Log("ACL system is not ready yet") 58 | time.Sleep(250 * time.Millisecond) 59 | } 60 | 61 | for { 62 | _, _, err = client.ACL().TokenReadSelf(nil) 63 | if err != nil { 64 | if isACLNotBootstrapped(err) { 65 | t.Log("system is rebooting", "error", err) 66 | time.Sleep(250 * time.Millisecond) 67 | continue 68 | } 69 | 70 | t.Fail() 71 | } 72 | break 73 | } 74 | } 75 | 76 | // Set CONSUL_HTTP_ADDR for mesh-init. Required to invoke the consul binary (i.e. in mesh-init). 77 | require.NoError(t, os.Setenv("CONSUL_HTTP_ADDR", server.HTTPAddr)) 78 | t.Cleanup(func() { 79 | _ = os.Unsetenv("CONSUL_HTTP_ADDR") 80 | }) 81 | 82 | return server, cfg 83 | } 84 | 85 | // ConsulACLConfigFn configures a Consul test server with ACLs. 86 | func ConsulACLConfigFn(c *testutil.TestServerConfig) { 87 | c.ACL.Enabled = true 88 | c.ACL.Tokens.InitialManagement = AdminToken 89 | c.ACL.DefaultPolicy = "deny" 90 | } 91 | 92 | func GetHostAndPortFromAddress(address string) (string, int) { 93 | host, portStr, err := net.SplitHostPort(address) 94 | if err != nil { 95 | return "", 0 96 | } 97 | 98 | port, err := strconv.ParseInt(portStr, 10, 0) 99 | if err != nil { 100 | return "", 0 101 | } 102 | 103 | return host, int(port) 104 | } 105 | 106 | func isACLBootstrapped(client *api.Client) (bool, error) { 107 | policy, _, err := client.ACL().PolicyReadByName("global-management", nil) 108 | if err != nil { 109 | if strings.Contains(err.Error(), "Unexpected response code: 403 (ACL not found)") { 110 | return false, nil 111 | } else if isACLNotBootstrapped(err) { 112 | return false, nil 113 | } 114 | return false, err 115 | } 116 | return policy != nil, nil 117 | } 118 | 119 | func isACLNotBootstrapped(err error) bool { 120 | switch { 121 | case strings.Contains(err.Error(), "ACL system must be bootstrapped before making any requests that require authorization"): 122 | return true 123 | case strings.Contains(err.Error(), "The ACL system is currently in legacy mode"): 124 | return true 125 | } 126 | return false 127 | } 128 | -------------------------------------------------------------------------------- /testutil/fake-command.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package testutil 5 | 6 | import ( 7 | "fmt" 8 | "path/filepath" 9 | "testing" 10 | ) 11 | 12 | // FakeCommand is a command/script to be run by tests for "entrypoint" commands. Each command touches 13 | // a "ready file" after the command has started and completed setup. This enables the tests to ensure 14 | // the command has started in order to avoid race conditions. For example, certain tests should not 15 | // proceed until signal handling has been set up. 16 | type FakeCommand struct { 17 | // The command to run. 18 | Command string 19 | // Check this file exists to check the command is ready. 20 | ReadyFile string 21 | } 22 | 23 | // FakeCommandWithTraps is a script used to validate our "entrypoint" commands. 24 | // This script does the following: 25 | // * When a sigint is received, it exits with code 42 26 | // * When a sigterm is received, it exits with code 55 27 | // * It sleeps for 120 seconds (long enough for tests, but not so long that it holds up CI) 28 | // 29 | // Why not just a simple 'sleep 120'? 30 | // * Bash actually ignores SIGINT by default (note: CTRL-C sends SIGINT to the process group, not just the parent) 31 | // * Tests can be run in different places, so /bin/sh could be any shell with different behavior. 32 | // Why a background process + wait? Why not just a trap + sleep? 33 | // * The sleep blocks the trap. Traps are not executed until the current command completes, except for `wait`. 34 | func FakeCommandWithTraps(t *testing.T, sleep int) FakeCommand { 35 | dir := TempDir(t) 36 | readyFile := filepath.Join(dir, "proc-ready") 37 | return FakeCommand{ 38 | ReadyFile: readyFile, 39 | Command: fmt.Sprintf(`sleep %d & 40 | export SLEEP_PID=$! 41 | trap "{ echo 'target command was interrupted'; kill $SLEEP_PID; exit 42; }" INT 42 | trap "{ echo 'target command was terminated'; kill $SLEEP_PID; exit 55; }" TERM 43 | touch %s 44 | wait $SLEEP_PID 45 | `, sleep, readyFile), 46 | } 47 | } 48 | 49 | // SimpleFakeCommand sleeps for a given number of seconds. 50 | func SimpleFakeCommand(t *testing.T, sleep int) FakeCommand { 51 | dir := TempDir(t) 52 | readyFile := filepath.Join(dir, "proc-ready") 53 | return FakeCommand{ 54 | Command: fmt.Sprintf(`touch %s 55 | sleep %d`, readyFile, sleep), 56 | ReadyFile: readyFile, 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /testutil/iamauthtest/arn.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package iamauthtest 5 | 6 | // This file is copied from Consul: 7 | // https://github.com/hashicorp/consul/blob/76c03872b709297b7649cb3f8999c3d1323361fb/internal/iamauth/responses/arn.go 8 | 9 | import ( 10 | "fmt" 11 | "strings" 12 | ) 13 | 14 | // https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1722-L1744 15 | type ParsedArn struct { 16 | Partition string 17 | AccountNumber string 18 | Type string 19 | Path string 20 | FriendlyName string 21 | SessionInfo string 22 | } 23 | 24 | // https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1482-L1530 25 | // However, instance profiles are not support in Consul. 26 | func ParseArn(iamArn string) (*ParsedArn, error) { 27 | // iamArn should look like one of the following: 28 | // 1. arn:aws:iam:::/ 29 | // 2. arn:aws:sts:::assumed-role// 30 | // if we get something like 2, then we want to transform that back to what 31 | // most people would expect, which is arn:aws:iam:::role/ 32 | var entity ParsedArn 33 | fullParts := strings.Split(iamArn, ":") 34 | if len(fullParts) != 6 { 35 | return nil, fmt.Errorf("unrecognized arn: contains %d colon-separated parts, expected 6", len(fullParts)) 36 | } 37 | if fullParts[0] != "arn" { 38 | return nil, fmt.Errorf("unrecognized arn: does not begin with \"arn:\"") 39 | } 40 | // normally aws, but could be aws-cn or aws-us-gov 41 | entity.Partition = fullParts[1] 42 | if entity.Partition == "" { 43 | return nil, fmt.Errorf("unrecognized arn: %q is missing the partition", iamArn) 44 | } 45 | if fullParts[2] != "iam" && fullParts[2] != "sts" { 46 | return nil, fmt.Errorf("unrecognized service: %v, not one of iam or sts", fullParts[2]) 47 | } 48 | // fullParts[3] is the region, which doesn't matter for AWS IAM entities 49 | entity.AccountNumber = fullParts[4] 50 | if entity.AccountNumber == "" { 51 | return nil, fmt.Errorf("unrecognized arn: %q is missing the account number", iamArn) 52 | } 53 | // fullParts[5] would now be something like user/ or assumed-role// 54 | parts := strings.Split(fullParts[5], "/") 55 | if len(parts) < 2 { 56 | return nil, fmt.Errorf("unrecognized arn: %q contains fewer than 2 slash-separated parts", fullParts[5]) 57 | } 58 | entity.Type = parts[0] 59 | entity.Path = strings.Join(parts[1:len(parts)-1], "/") 60 | entity.FriendlyName = parts[len(parts)-1] 61 | // now, entity.FriendlyName should either be or 62 | switch entity.Type { 63 | case "assumed-role": 64 | // Check for three parts for assumed role ARNs 65 | if len(parts) < 3 { 66 | return nil, fmt.Errorf("unrecognized arn: %q contains fewer than 3 slash-separated parts", fullParts[5]) 67 | } 68 | // Assumed roles don't have paths and have a slightly different format 69 | // parts[2] is 70 | entity.Path = "" 71 | entity.FriendlyName = parts[1] 72 | entity.SessionInfo = parts[2] 73 | case "user": 74 | case "role": 75 | // case "instance-profile": 76 | default: 77 | return nil, fmt.Errorf("unrecognized principal type: %q", entity.Type) 78 | } 79 | 80 | if entity.FriendlyName == "" { 81 | return nil, fmt.Errorf("unrecognized arn: %q is missing the resource name", iamArn) 82 | } 83 | 84 | return &entity, nil 85 | } 86 | 87 | // CanonicalArn returns the canonical ARN for referring to an IAM entity 88 | func (p *ParsedArn) CanonicalArn() string { 89 | entityType := p.Type 90 | // canonicalize "assumed-role" into "role" 91 | if entityType == "assumed-role" { 92 | entityType = "role" 93 | } 94 | // Annoyingly, the assumed-role entity type doesn't have the Path of the role which was assumed 95 | // So, we "canonicalize" it by just completely dropping the path. The other option would be to 96 | // make an AWS API call to look up the role by FriendlyName, which introduces more complexity to 97 | // code and test, and it also breaks backwards compatibility in an area where we would really want 98 | // it 99 | return fmt.Sprintf("arn:%s:iam::%s:%s/%s", p.Partition, p.AccountNumber, entityType, p.FriendlyName) 100 | } 101 | -------------------------------------------------------------------------------- /testutil/iamauthtest/responses.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package iamauthtest 5 | 6 | // This file is copied from these Consul files: 7 | // https://github.com/hashicorp/consul/blob/76c03872b709297b7649cb3f8999c3d1323361fb/internal/iamauth/responses/responses.go 8 | // https://github.com/hashicorp/consul/blob/76c03872b709297b7649cb3f8999c3d1323361fb/internal/iamauth/responsestest/testing.go 9 | 10 | import ( 11 | "encoding/xml" 12 | "strings" 13 | ) 14 | 15 | func MakeGetCallerIdentityResponse(arn, userId, accountId string) GetCallerIdentityResponse { 16 | // Sanity check the UserId for unit tests. 17 | parsed := parseArn(arn) 18 | switch parsed.Type { 19 | case "assumed-role": 20 | if !strings.Contains(userId, ":") { 21 | panic("UserId for assumed-role in GetCallerIdentity response must be ':'") 22 | } 23 | default: 24 | if strings.Contains(userId, ":") { 25 | panic("UserId in GetCallerIdentity must not contain ':'") 26 | } 27 | } 28 | 29 | return GetCallerIdentityResponse{ 30 | GetCallerIdentityResult: []GetCallerIdentityResult{ 31 | { 32 | Arn: arn, 33 | UserId: userId, 34 | Account: accountId, 35 | }, 36 | }, 37 | } 38 | } 39 | 40 | func MakeGetRoleResponse(arn, id string, tags Tags) GetRoleResponse { 41 | if strings.Contains(id, ":") { 42 | panic("RoleId in GetRole response must not contain ':'") 43 | } 44 | parsed := parseArn(arn) 45 | return GetRoleResponse{ 46 | GetRoleResult: []GetRoleResult{ 47 | { 48 | Role: Role{ 49 | Arn: arn, 50 | Path: parsed.Path, 51 | RoleId: id, 52 | RoleName: parsed.FriendlyName, 53 | Tags: tags, 54 | }, 55 | }, 56 | }, 57 | } 58 | } 59 | 60 | func MakeGetUserResponse(arn, id string, tags Tags) GetUserResponse { 61 | if strings.Contains(id, ":") { 62 | panic("UserId in GetUser resposne must not contain ':'") 63 | } 64 | parsed := parseArn(arn) 65 | return GetUserResponse{ 66 | GetUserResult: []GetUserResult{ 67 | { 68 | User: User{ 69 | Arn: arn, 70 | Path: parsed.Path, 71 | UserId: id, 72 | UserName: parsed.FriendlyName, 73 | Tags: tags, 74 | }, 75 | }, 76 | }, 77 | } 78 | } 79 | 80 | func parseArn(arn string) *ParsedArn { 81 | parsed, err := ParseArn(arn) 82 | if err != nil { 83 | // For testing, just fail immediately. 84 | panic(err) 85 | } 86 | return parsed 87 | } 88 | 89 | type GetCallerIdentityResponse struct { 90 | XMLName xml.Name `xml:"GetCallerIdentityResponse"` 91 | GetCallerIdentityResult []GetCallerIdentityResult `xml:"GetCallerIdentityResult"` 92 | ResponseMetadata []ResponseMetadata `xml:"ResponseMetadata"` 93 | } 94 | 95 | type GetCallerIdentityResult struct { 96 | Arn string `xml:"Arn"` 97 | UserId string `xml:"UserId"` 98 | Account string `xml:"Account"` 99 | } 100 | 101 | type ResponseMetadata struct { 102 | RequestId string `xml:"RequestId"` 103 | } 104 | 105 | // IAMEntity is an interface for getting details from an IAM Role or User. 106 | type IAMEntity interface { 107 | EntityPath() string 108 | EntityArn() string 109 | EntityName() string 110 | EntityId() string 111 | EntityTags() map[string]string 112 | } 113 | 114 | var _ IAMEntity = (*Role)(nil) 115 | var _ IAMEntity = (*User)(nil) 116 | 117 | type GetRoleResponse struct { 118 | XMLName xml.Name `xml:"GetRoleResponse"` 119 | GetRoleResult []GetRoleResult `xml:"GetRoleResult"` 120 | ResponseMetadata []ResponseMetadata `xml:"ResponseMetadata"` 121 | } 122 | 123 | type GetRoleResult struct { 124 | Role Role `xml:"Role"` 125 | } 126 | 127 | type Role struct { 128 | Arn string `xml:"Arn"` 129 | Path string `xml:"Path"` 130 | RoleId string `xml:"RoleId"` 131 | RoleName string `xml:"RoleName"` 132 | Tags Tags `xml:"Tags"` 133 | } 134 | 135 | func (r *Role) EntityPath() string { return r.Path } 136 | func (r *Role) EntityArn() string { return r.Arn } 137 | func (r *Role) EntityName() string { return r.RoleName } 138 | func (r *Role) EntityId() string { return r.RoleId } 139 | func (r *Role) EntityTags() map[string]string { return tagsToMap(r.Tags) } 140 | 141 | type GetUserResponse struct { 142 | XMLName xml.Name `xml:"GetUserResponse"` 143 | GetUserResult []GetUserResult `xml:"GetUserResult"` 144 | ResponseMetadata []ResponseMetadata `xml:"ResponseMetadata"` 145 | } 146 | 147 | type GetUserResult struct { 148 | User User `xml:"User"` 149 | } 150 | 151 | type User struct { 152 | Arn string `xml:"Arn"` 153 | Path string `xml:"Path"` 154 | UserId string `xml:"UserId"` 155 | UserName string `xml:"UserName"` 156 | Tags Tags `xml:"Tags"` 157 | } 158 | 159 | func (u *User) EntityPath() string { return u.Path } 160 | func (u *User) EntityArn() string { return u.Arn } 161 | func (u *User) EntityName() string { return u.UserName } 162 | func (u *User) EntityId() string { return u.UserId } 163 | func (u *User) EntityTags() map[string]string { return tagsToMap(u.Tags) } 164 | 165 | type Tags struct { 166 | Members []TagMember `xml:"member"` 167 | } 168 | 169 | type TagMember struct { 170 | Key string `xml:"Key"` 171 | Value string `xml:"Value"` 172 | } 173 | 174 | func tagsToMap(tags Tags) map[string]string { 175 | result := map[string]string{} 176 | for _, tag := range tags.Members { 177 | result[tag.Key] = tag.Value 178 | } 179 | return result 180 | } 181 | -------------------------------------------------------------------------------- /testutil/iamauthtest/testing.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package iamauthtest 5 | 6 | // This file is copied from Consul: 7 | // https://github.com/hashicorp/consul/blob/76c03872b709297b7649cb3f8999c3d1323361fb/internal/iamauth/iamauthtest/testing.go 8 | 9 | import ( 10 | "encoding/xml" 11 | "fmt" 12 | "io" 13 | "net/http" 14 | "net/http/httptest" 15 | "sort" 16 | "strings" 17 | "testing" 18 | ) 19 | 20 | // NewTestServer returns a fake AWS API server for local tests: 21 | // It supports the following paths: 22 | // 23 | // /sts returns STS API responses 24 | // /iam returns IAM API responses 25 | func NewTestServer(t *testing.T, s *Server) *httptest.Server { 26 | server := httptest.NewUnstartedServer(s) 27 | t.Cleanup(server.Close) 28 | server.Start() 29 | return server 30 | } 31 | 32 | // Server contains configuration for the fake AWS API server. 33 | type Server struct { 34 | GetCallerIdentityResponse GetCallerIdentityResponse 35 | GetRoleResponse GetRoleResponse 36 | GetUserResponse GetUserResponse 37 | } 38 | 39 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 40 | if r.Method != "POST" { 41 | writeError(w, http.StatusBadRequest, r) 42 | return 43 | } 44 | 45 | switch { 46 | case strings.HasPrefix(r.URL.Path, "/sts"): 47 | writeXML(w, s.GetCallerIdentityResponse) 48 | case strings.HasPrefix(r.URL.Path, "/iam"): 49 | if bodyBytes, err := io.ReadAll(r.Body); err == nil { 50 | body := string(bodyBytes) 51 | switch { 52 | case strings.Contains(body, "Action=GetRole"): 53 | writeXML(w, s.GetRoleResponse) 54 | return 55 | case strings.Contains(body, "Action=GetUser"): 56 | writeXML(w, s.GetUserResponse) 57 | return 58 | } 59 | } 60 | writeError(w, http.StatusBadRequest, r) 61 | default: 62 | writeError(w, http.StatusNotFound, r) 63 | } 64 | } 65 | 66 | func writeXML(w http.ResponseWriter, val interface{}) { 67 | str, err := xml.MarshalIndent(val, "", " ") 68 | if err != nil { 69 | w.WriteHeader(http.StatusInternalServerError) 70 | fmt.Fprint(w, err.Error()) 71 | return 72 | } 73 | w.Header().Add("Content-Type", "text/xml") 74 | w.WriteHeader(http.StatusOK) 75 | fmt.Fprint(w, string(str)) 76 | } 77 | 78 | func writeError(w http.ResponseWriter, code int, r *http.Request) { 79 | w.WriteHeader(code) 80 | msg := fmt.Sprintf("%s %s", r.Method, r.URL) 81 | fmt.Fprintf(w, ` 82 | 83 | Fake AWS Server Error: %s 84 | 85 | `, msg) 86 | } 87 | 88 | type Fixture struct { 89 | AssumedRoleARN string 90 | CanonicalRoleARN string 91 | RoleARN string 92 | RoleARNWildcard string 93 | RoleName string 94 | RolePath string 95 | RoleTags map[string]string 96 | 97 | EntityID string 98 | EntityIDWithSession string 99 | AccountID string 100 | 101 | UserARN string 102 | UserARNWildcard string 103 | UserName string 104 | UserPath string 105 | UserTags map[string]string 106 | 107 | ServerForRole *Server 108 | ServerForUser *Server 109 | } 110 | 111 | func MakeFixture() Fixture { 112 | f := Fixture{ 113 | AssumedRoleARN: "arn:aws:sts::1234567890:assumed-role/my-role/some-session", 114 | CanonicalRoleARN: "arn:aws:iam::1234567890:role/my-role", 115 | RoleARN: "arn:aws:iam::1234567890:role/some/path/my-role", 116 | RoleARNWildcard: "arn:aws:iam::1234567890:role/some/path/*", 117 | RoleName: "my-role", 118 | RolePath: "some/path", 119 | RoleTags: map[string]string{ 120 | "service-name": "my-service", 121 | "env": "my-env", 122 | }, 123 | 124 | EntityID: "AAAsomeuniqueid", 125 | EntityIDWithSession: "AAAsomeuniqueid:some-session", 126 | AccountID: "1234567890", 127 | 128 | UserARN: "arn:aws:iam::1234567890:user/my-user", 129 | UserARNWildcard: "arn:aws:iam::1234567890:user/*", 130 | UserName: "my-user", 131 | UserPath: "", 132 | UserTags: map[string]string{"user-group": "my-group"}, 133 | } 134 | 135 | f.ServerForRole = &Server{ 136 | GetCallerIdentityResponse: MakeGetCallerIdentityResponse( 137 | f.AssumedRoleARN, f.EntityIDWithSession, f.AccountID, 138 | ), 139 | GetRoleResponse: MakeGetRoleResponse( 140 | f.RoleARN, f.EntityID, toTags(f.RoleTags), 141 | ), 142 | } 143 | 144 | f.ServerForUser = &Server{ 145 | GetCallerIdentityResponse: MakeGetCallerIdentityResponse( 146 | f.UserARN, f.EntityID, f.AccountID, 147 | ), 148 | GetUserResponse: MakeGetUserResponse( 149 | f.UserARN, f.EntityID, toTags(f.UserTags), 150 | ), 151 | } 152 | 153 | return f 154 | } 155 | 156 | func (f *Fixture) RoleTagKeys() []string { return keys(f.RoleTags) } 157 | func (f *Fixture) UserTagKeys() []string { return keys(f.UserTags) } 158 | func (f *Fixture) RoleTagValues() []string { return values(f.RoleTags) } 159 | func (f *Fixture) UserTagValues() []string { return values(f.UserTags) } 160 | 161 | // toTags converts the map to a slice of Tag 162 | func toTags(tags map[string]string) Tags { 163 | members := []TagMember{} 164 | for k, v := range tags { 165 | members = append(members, TagMember{ 166 | Key: k, 167 | Value: v, 168 | }) 169 | } 170 | return Tags{Members: members} 171 | 172 | } 173 | 174 | // keys returns the keys in sorted order 175 | func keys(tags map[string]string) []string { 176 | result := []string{} 177 | for k := range tags { 178 | result = append(result, k) 179 | } 180 | sort.Strings(result) 181 | return result 182 | } 183 | 184 | // values returns values in tags, ordered by sorted keys 185 | func values(tags map[string]string) []string { 186 | result := []string{} 187 | for _, k := range keys(tags) { // ensures sorted by key 188 | result = append(result, tags[k]) 189 | } 190 | return result 191 | } 192 | -------------------------------------------------------------------------------- /version/fips_build.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build fips 5 | 6 | package version 7 | 8 | // This validates during compilation that we are being built with a FIPS enabled go toolchain 9 | import ( 10 | _ "crypto/tls/fipsonly" 11 | ) 12 | 13 | // IsFIPS returns true if consul-ecs is operating in FIPS-140-2 mode. 14 | func IsFIPS() bool { 15 | return true 16 | } 17 | -------------------------------------------------------------------------------- /version/non_fips_build.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build !fips 5 | 6 | package version 7 | 8 | // IsFIPS returns true if consul-ecs is operating in FIPS-140-2 mode. 9 | func IsFIPS() bool { 10 | return false 11 | } 12 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package version 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | ) 10 | 11 | var ( 12 | // The git commit that was compiled. These will be filled in by the compiler. 13 | GitCommit string 14 | 15 | // The main version number that is being run at the moment. 16 | // 17 | // Version must conform to the format expected by 18 | // github.com/hashicorp/go-version for tests to work. 19 | Version = "0.9.0" 20 | 21 | // A pre-release marker for the version. If this is "" (empty string) 22 | // then it means that it is a final release. Otherwise, this is a pre-release 23 | // such as "dev" (in development), "beta", "rc1", etc. 24 | VersionPrerelease = "dev" 25 | ) 26 | 27 | // GetHumanVersion composes the parts of the version in a way that's suitable 28 | // for displaying to humans. 29 | func GetHumanVersion() string { 30 | version := Version 31 | 32 | if VersionPrerelease != "" { 33 | version += fmt.Sprintf("-%s", VersionPrerelease) 34 | } 35 | 36 | if IsFIPS() { 37 | version += "+fips1402" 38 | } 39 | 40 | if GitCommit != "" { 41 | version += fmt.Sprintf(" (%s)", GitCommit) 42 | } 43 | 44 | // Strip off any single quotes added by the git information. 45 | return "v" + strings.Replace(version, "'", "", -1) 46 | } 47 | -------------------------------------------------------------------------------- /version/version_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package version 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestVersion(t *testing.T) { 13 | cases := map[string]struct { 14 | commit string 15 | prerelease string 16 | 17 | expVersion string 18 | }{ 19 | "no commit and no prerelease": { 20 | expVersion: "v" + Version, 21 | }, 22 | "commit but no prerelease": { 23 | commit: "asdf", 24 | expVersion: "v" + Version + " (asdf)", 25 | }, 26 | "prerelease but no commit": { 27 | prerelease: "beta1", 28 | expVersion: "v" + Version + "-beta1", 29 | }, 30 | "commit and prerelease": { 31 | commit: "asdf", 32 | prerelease: "beta1", 33 | expVersion: "v" + Version + "-beta1 (asdf)", 34 | }, 35 | } 36 | for name, c := range cases { 37 | t.Run(name, func(t *testing.T) { 38 | GitCommit = c.commit 39 | VersionPrerelease = c.prerelease 40 | require.Equal(t, c.expVersion, GetHumanVersion()) 41 | }) 42 | } 43 | } 44 | --------------------------------------------------------------------------------