├── .ameba.yml ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── closed-pr.yml │ ├── docker.yml │ ├── packages.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.deb ├── Dockerfile.rpm ├── LICENSE ├── Makefile ├── README.md ├── amqproxy.spec ├── build └── release_notes ├── config └── example.ini ├── debian ├── changelog ├── compat ├── control ├── copyright ├── rules └── source │ └── format ├── extras └── amqproxy.service ├── run-specs-in-docker.sh ├── set-crystal-version.sh ├── shard.lock ├── shard.yml ├── spec ├── Dockerfile ├── amqproxy │ ├── http_server_spec.cr │ └── server_spec.cr ├── docker-compose.yml ├── entrypoint.sh └── spec_helper.cr ├── src ├── amqproxy.cr └── amqproxy │ ├── channel_pool.cr │ ├── cli.cr │ ├── client.cr │ ├── http_server.cr │ ├── prometheus_writer.cr │ ├── records.cr │ ├── server.cr │ ├── token_bucket.cr │ ├── upstream.cr │ └── version.cr ├── tar.Dockerfile └── test ├── integration-php.sh ├── integration-php ├── docker-compose.yml ├── php-amqp │ ├── Dockerfile │ └── get-test.php ├── toxiproxy-cli │ ├── Dockerfile │ └── entrypoint.sh └── toxiproxy.json ├── many_conns.cr ├── reconnect.cr └── throughput.cr /.ameba.yml: -------------------------------------------------------------------------------- 1 | Excluded: 2 | - test/ 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @cloudamqp/crystal 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | paths: 5 | - 'run-specs-in-docker.sh' 6 | - '.github/workflows/ci.yml' 7 | - 'shard.yml' 8 | - 'shard.lock' 9 | - 'src/**' 10 | - 'spec/**' 11 | - 'test/**' 12 | 13 | jobs: 14 | spec: 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 10 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Run tests 22 | run: ./run-specs-in-docker.sh 23 | 24 | lint: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | container: 84codes/crystal:1.15.1-ubuntu-22.04 28 | steps: 29 | - uses: actions/checkout@v4 30 | - run: shards install 31 | - run: bin/ameba 32 | 33 | format: 34 | runs-on: ubuntu-latest 35 | timeout-minutes: 10 36 | container: 84codes/crystal:1.15.1-ubuntu-22.04 37 | steps: 38 | - uses: actions/checkout@v4 39 | - run: crystal tool format --check 40 | 41 | integration: 42 | runs-on: ubuntu-latest 43 | timeout-minutes: 10 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v4 47 | - name: PHP client integration test using Docker Compose 48 | run: ./test/integration-php.sh 49 | -------------------------------------------------------------------------------- /.github/workflows/closed-pr.yml: -------------------------------------------------------------------------------- 1 | name: Clean up PR artifacts 2 | on: 3 | pull_request: 4 | types: [closed] 5 | 6 | jobs: 7 | delete-docker-hub-pr: 8 | name: Delete Docker hub PR image 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Get token 12 | env: 13 | CREDS: "{ \"username\": \"${{ secrets.DOCKERHUB_USERNAME }}\", \"password\": \"${{ secrets.DOCKERHUB_TOKEN }}\" }" 14 | run: | 15 | curl -s -H "Content-Type: application/json" -XPOST -d "$CREDS" \ 16 | https://hub.docker.com/v2/users/login/ | \ 17 | jq -r '"TOKEN=" + (.token)' >> $GITHUB_ENV 18 | - name: Delete PR tag 19 | env: 20 | TAG: pr-${{ github.event.number }} 21 | run: | 22 | curl -XDELETE -H "Authorization: JWT ${TOKEN}" \ 23 | "https://hub.docker.com/v2/repositories/cloudamqp/lavinmq/tags/${TAG}/" 24 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker images 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - "v*" 9 | paths: 10 | - src/** 11 | - shard.lock 12 | - Dockerfile 13 | - .github/workflows/docker.yml 14 | pull_request: 15 | paths: 16 | - src/** 17 | - shard.lock 18 | - Dockerfile 19 | - .github/workflows/docker.yml 20 | 21 | jobs: 22 | docker: 23 | runs-on: ubuntu-latest 24 | permissions: 25 | contents: read 26 | id-token: write 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Docker meta 32 | id: meta 33 | uses: docker/metadata-action@v5 34 | with: 35 | images: cloudamqp/amqproxy 36 | tags: | 37 | type=ref,event=branch 38 | type=ref,event=pr 39 | type=semver,pattern={{version}} 40 | type=semver,pattern={{major}}.{{minor}} 41 | 42 | - name: Set up Depot CLI 43 | uses: depot/setup-action@v1 44 | 45 | - name: Login to DockerHub 46 | uses: docker/login-action@v3 47 | with: 48 | username: ${{ secrets.DOCKERHUB_USERNAME }} 49 | password: ${{ secrets.DOCKERHUB_TOKEN }} 50 | 51 | - name: Build and push 52 | uses: depot/build-push-action@v1 53 | with: 54 | project: nm81cffkc0 55 | platforms: linux/amd64,linux/arm64 56 | pull: true 57 | push: true 58 | tags: ${{ steps.meta.outputs.tags }} 59 | labels: ${{ steps.meta.outputs.labels }} 60 | build-args: | 61 | MAKEFLAGS=-j1 62 | -------------------------------------------------------------------------------- /.github/workflows/packages.yml: -------------------------------------------------------------------------------- 1 | name: Build packages 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - v* 8 | pull_request: 9 | workflow_dispatch: 10 | 11 | jobs: 12 | tar: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | id-token: write 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Install Depot CLI 22 | uses: depot/setup-action@v1 23 | 24 | - name: Build tar package 25 | uses: depot/build-push-action@v1 26 | with: 27 | project: nm81cffkc0 28 | file: tar.Dockerfile 29 | platforms: linux/amd64,linux/arm64 30 | build-args: | 31 | image=84codes/crystal:1.15.1-${{ matrix.os }} 32 | outputs: builds 33 | 34 | - name: Upload github artifact 35 | uses: actions/upload-artifact@v4 36 | with: 37 | path: builds/**/*.tar.gz 38 | name: tar 39 | 40 | deb: 41 | runs-on: ubuntu-latest 42 | permissions: 43 | contents: read 44 | id-token: write 45 | strategy: 46 | matrix: 47 | os: 48 | - ubuntu-noble # Ubuntu 24.04 49 | - ubuntu-jammy # Ubuntu 22.04 50 | - ubuntu-focal # Ubuntu 20.04 51 | - debian-bookworm # Debian 12 52 | - debian-bullseye # Debian 11 53 | fail-fast: false 54 | steps: 55 | - name: Checkout 56 | uses: actions/checkout@v4 57 | with: 58 | fetch-depth: 0 59 | 60 | - name: Generate version string 61 | run: | 62 | git_describe=$(git describe --tags) 63 | echo "version=${git_describe:1}" >> $GITHUB_ENV 64 | 65 | - name: Install Depot CLI 66 | uses: depot/setup-action@v1 67 | 68 | - name: Build deb package 69 | uses: depot/build-push-action@v1 70 | with: 71 | project: nm81cffkc0 72 | file: Dockerfile.deb 73 | platforms: linux/amd64,linux/arm64 74 | build-args: | 75 | image=84codes/crystal:1.15.1-${{ matrix.os }} 76 | version=${{ env.version }} 77 | outputs: builds 78 | 79 | - name: Upload GitHub artifact 80 | uses: actions/upload-artifact@v4 81 | with: 82 | path: "builds/**/*.deb" 83 | name: debs-${{ matrix.os }} 84 | 85 | - name: Upload to PackageCloud 86 | if: startsWith(github.ref, 'refs/tags/v') 87 | run: | 88 | set -eux 89 | curl -fsSO -u "${{ secrets.PACKAGECLOUD_TOKEN }}:" https://packagecloud.io/api/v1/distributions.json 90 | for PKG_FILE in $(find builds -name "*.deb") 91 | do 92 | ID=$(echo $PKG_FILE | cut -d/ -f3) 93 | VERSION_CODENAME=$(echo $PKG_FILE | cut -d/ -f4) 94 | DIST_ID=$(jq ".deb[] | select(.index_name == \"${ID}\").versions[] | select(.index_name == \"${VERSION_CODENAME}\").id" distributions.json) 95 | curl -fsS -u "${{ secrets.PACKAGECLOUD_TOKEN }}:" -XPOST \ 96 | -F "package[distro_version_id]=${DIST_ID}" \ 97 | -F "package[package_file]=@${PKG_FILE}" \ 98 | https://packagecloud.io/api/v1/repos/${{ github.repository }}/packages.json 99 | done 100 | rpm: 101 | strategy: 102 | matrix: 103 | os: ['fedora-41'] 104 | runs-on: ubuntu-latest 105 | permissions: 106 | contents: read 107 | id-token: write 108 | steps: 109 | - name: Checkout 110 | uses: actions/checkout@v4 111 | with: 112 | fetch-depth: 0 113 | 114 | - name: Generate version string 115 | run: | 116 | last_tag=$(git describe --tags --abbrev=0) 117 | version=$(cut -d- -f1 <<< ${last_tag:1}) 118 | pre_release=$(cut -d- -f2 <<< $last_tag) 119 | if [ -n "$pre_release" ] 120 | then version=$version~${pre_release//./} 121 | fi 122 | git_describe=$(git describe --tags) 123 | post_release=${git_describe//$last_tag/} 124 | post_release=${post_release:1} 125 | post_release=${post_release//-/.} 126 | if [ -n "$post_release" ] 127 | then version=$version^${post_release} 128 | fi 129 | echo "version=$version" >> $GITHUB_ENV 130 | 131 | - name: Install Depot CLI 132 | uses: depot/setup-action@v1 133 | 134 | - name: Build rpm package 135 | uses: depot/build-push-action@v1 136 | with: 137 | project: nm81cffkc0 138 | file: Dockerfile.rpm 139 | platforms: linux/amd64,linux/arm64 140 | build-args: | 141 | build_image=84codes/crystal:1.15.1-${{ matrix.os }} 142 | version=${{ env.version }} 143 | outputs: RPMS 144 | 145 | - uses: actions/upload-artifact@v4 146 | name: Upload artifact 147 | with: 148 | name: rpm-packages-${{ matrix.os }} 149 | path: RPMS 150 | 151 | - name: Upload to Packagecloud 152 | run: | 153 | set -eux 154 | curl -fsSO -u "${{ secrets.packagecloud_token }}:" https://packagecloud.io/api/v1/distributions.json 155 | ID=$(echo "${{ matrix.os }}" | cut -d- -f1) 156 | VERSION_ID=$(echo "${{ matrix.os }}" | cut -d- -f2) 157 | DIST_ID=$(jq ".rpm[] | select(.index_name == \"${ID}\").versions[] | select(.index_name == \"${VERSION_ID}\").id" distributions.json) 158 | for PKG_FILE in $(find RPMS -name "*.rpm" | sort -u -t/ -k3) 159 | do curl -fsS -u "${{ secrets.packagecloud_token }}:" -XPOST \ 160 | -F "package[distro_version_id]=${DIST_ID}" \ 161 | -F "package[package_file]=@${PKG_FILE}" \ 162 | https://packagecloud.io/api/v1/repos/${{ github.repository }}/packages.json 163 | done 164 | if: startsWith(github.ref, 'refs/tags/v') 165 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - v* 5 | workflow_dispatch: 6 | 7 | name: Create Release 8 | 9 | jobs: 10 | build: 11 | name: Create Release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout the repository 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set up QEMU 20 | uses: docker/setup-qemu-action@v3 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Build static tar package 26 | uses: docker/build-push-action@v6 27 | with: 28 | file: tar.Dockerfile 29 | platforms: linux/amd64,linux/arm64 30 | outputs: . 31 | cache-from: type=gha,scope=release 32 | cache-to: type=gha,mode=max,scope=release 33 | 34 | - name: List files 35 | run: ls -Rl 36 | 37 | - name: Create Release 38 | run: gh release create "${{ github.ref }}" ./**/*.tar.gz --notes "$(./build/release_notes)" 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | /.vagrant/ 6 | /.idea 7 | *.log 8 | *.tar.gz 9 | *.swp 10 | /builds/ 11 | shard.override.yml 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [v3.0.1] - 2025-04-04 11 | 12 | - Improve packaging workflow: Native deb builds, faster multi-arch binaries via depot.dev, and Build deb/rpm/tar packages on PR. 13 | - Use `Time::Span` for sleep durations. 14 | 15 | ## [v3.0.0] - 2025-04-03 16 | 17 | - Improved configuration handling: ENV varibales takes priority over config file variables. [#202](https://github.com/cloudamqp/amqproxy/pull/202) 18 | - ​Resolved a deadlock occurring when running lavinmqperf throughput against LavinMQ via AMQProxy, caused by fibers holding locks while attempting to write to blocked socket. [#200](https://github.com/cloudamqp/amqproxy/pull/200) 19 | 20 | ## [v2.0.4] - 2024-11-25 21 | 22 | - Bugfix: Treat all frames as heartbeats 23 | 24 | ## [v2.0.3] - 2024-10-23 25 | 26 | - Added config option term_client_close_timeout and cli option --term-client-close-timeout to set how long to wait for clients to close their connections before sending Close when amqproxy receives a TERM signal. 27 | - Added a HTTP health check on http://listen_address:http_port/healthz 28 | - Added metrics on http://listen_address:http_port/metrics 29 | - Don't log when a client just opens and closes a TCP connection 30 | - Compile static binary 31 | 32 | 33 | ## [v2.0.2] - 2024-08-25 34 | 35 | - Compile with Crystal 1.13.2, fixes a memory leak in Hash. 36 | 37 | ## [v2.0.1] - 2024-07-11 38 | 39 | - Return unused memory faster to the OS using GC_UNMAP_THRESHOLD=1 in Dockerfile and systemd service file 40 | - Compile with Crystal 1.13.0, fixes a potential memory leak in Log 41 | 42 | ## [v2.0.0] - 2024-06-17 43 | 44 | - Main difference against v1.x is that channels are pooled, multiple client channels are shared on a single upstream connection, dramatically decreasing the number of upstream connections needed 45 | - IPv6 addresses in brackets supported in upstream URL (eg. amqp://[::1]) 46 | - Otherwise unchanged from v2.0.0-rc.8 47 | 48 | ## [v2.0.0-rc.8] - 2024-05-12 49 | 50 | - Allow large client frame sizes, but split body frames to client if smaller than upstream frame size, to support large Header frames 51 | 52 | ## [v2.0.0-rc.7] - 2024-05-12 53 | 54 | - Send all GetOk response frames in one TCP packet 55 | 56 | ## [v2.0.0-rc.6] - 2024-05-11 57 | 58 | - Bugfix: Send Connection.Close-ok to client before closing TCP socket 59 | - Bugfix: Pass Channel.Close-ok down to client 60 | 61 | ## [v2.0.0-rc.5] - 2024-05-11 62 | 63 | - Bugfix: negotiate frame_max 4096 for downstream clients 64 | 65 | ## [v2.0.0-rc.4] - 2024-05-11 66 | 67 | - Bufix: Only send channel.close once, and gracefully wait for closeok 68 | - Buffer publish frames and only send full publishes as RabbitMQ doesn't support channel.close in the middle of a publish frame sequence 69 | - Optimization: only flush socket buffer after a full publish sequence, not for each frame 70 | 71 | ## [v2.0.0-rc.3] - 2024-05-09 72 | 73 | - Never reuse channels, even publish only channels are not safe if not all publish frames for a message was sent before the client disconnected 74 | - Don't log normal client disconnect errors 75 | - Don't allow busy connection to dominate, Fiber.yield every 4k msgs 76 | - --term-timout support, wait X seconds after signal TERM and then forefully close remaining connections 77 | - Always negotate 4096 frame_max size, as that's the minimum all clients support 78 | 79 | ## [v2.0.0-rc.2] - 2024-03-09 80 | 81 | - Heartbeat support on the client side 82 | - Connection::Blocked frames are passed to clients 83 | - On channel errors correctly pass closeok to upstream server 84 | 85 | ## [v2.0.0-rc.1] - 2024-02-19 86 | 87 | - Rewrite of the proxy where Channels are pooled rather than connections. When a client opens a channel it will get a channel on a shared upstream connection, the proxy will remap the channel numbers between the two. Many client connections can therefor share a single upstream connection. Upside is that way fewer connections are needed to the upstream server, downside is that if there's a misbehaving client, for which the server closes the connection, all channels for other clients on that shared connection will also be closed. 88 | 89 | ## [v1.0.0] - 2024-02-19 90 | 91 | - Nothing changed from v0.8.14 92 | 93 | ## [v0.8.14] - 2023-10-20 94 | 95 | - Update current client in `Upstream#read_loop` [#138](https://github.com/cloudamqp/amqproxy/pull/138) 96 | 97 | ## [v0.8.13] - 2023-10-11 98 | 99 | - Disconnect clients on broken upstream connection [#128](https://github.com/cloudamqp/amqproxy/pull/128) 100 | 101 | ## [v0.8.12] - 2023-09-20 102 | 103 | No changes from 0.8.11. Tagged to build missing Debian/Ubuntu packages. 104 | 105 | ## [v0.8.11] - 2023-06-06 106 | 107 | No changes from 0.8.10. 108 | 109 | ## [v0.8.10] - 2023-05-16 110 | 111 | No changes from 0.8.9. 112 | 113 | ## [0.8.9] - 2023-05-16 114 | 115 | - Alpine Docker image now uses user/group `amqpproxy` (1000:1000) [#107](https://github.com/cloudamqp/amqproxy/pull/107) 116 | 117 | - Require updated amq-protocol version that fixes tune negotiation issue 118 | 119 | ## [0.8.8] - 2023-05-10 120 | 121 | - Same as 0.8.7 but fixes missing PCRE2 dependency in the `cloudamqp/amqproxy` Docker Hub image 122 | 123 | ## [0.8.7] - 2023-05-08 124 | 125 | - Disables the Nagle algorithm on the upstream socket as well ([#113](https://github.com/cloudamqp/amqproxy/pull/113)) 126 | - Added error handling for IO error ([#104](https://github.com/cloudamqp/amqproxy/pull/104)) 127 | 128 | ## [0.8.6] - 2023-03-01 129 | 130 | - Reenable TCP nodelay on the server connection, impacted performance 131 | 132 | ## 0.8.3, 0.8.4, 0.8.5 133 | 134 | * 0.8.3 was tagged 2023-02-15 but a proper release was never created (release workflow failed) 135 | * 0.8.4 was tagged (and released) 2023-03-01 but it was built without bumping the version, so it reports 0.8.3 136 | * 0.8.5 was tagged 2023-03-01 but a proper release was never created (release workflow did not run) 137 | 138 | ## [0.8.2] - 2022-11-26 139 | 140 | - Build RPM packages for Fedora 37 141 | - Build DEB packages for Ubuntu 22.04 142 | - Locks around socket writes 143 | - Default systemd service file uses /etc/amqproxy.ini 144 | - Set a connection name on upstream connections 145 | 146 | ## [0.8.1] - 2022-11-16 147 | 148 | - New amq-protocol.cr without a StringPool, which in many cases caused a memory leak 149 | 150 | ## [0.8.0] - 2022-11-15 151 | 152 | - Prevent race conditions by using more locks 153 | - Don't disable nagles algorithm (TCP no delay), connections are faster with the algorithm enabled 154 | - idle_connection_timeout can be specificed as an environment variable 155 | - Container image uses libssl1.1 (from libssl3 which isn't fully supported) 156 | 157 | ## [0.7.0] - 2022-08-02 158 | 159 | - Inform clients of product and version via Start frame 160 | - Check upstream connection before lending it out 161 | - Graceful shutdown, waiting for connections to close 162 | - Don't try to reuse channels closed by server for new connections 163 | - Notify upstream that consumer cancellation is supported 164 | - Reuse a single TLS context for all upstream TLS connections, saves memory 165 | - Fixed broken OpenSSL in the Docker image 166 | 167 | ## [0.6.1] - 2022-07-14 168 | 169 | - Build package for Debian 11 (bullseye) ([#73](https://github.com/cloudamqp/amqproxy/issues/73)) 170 | - [Bump dependencies](https://github.com/cloudamqp/amqproxy/commit/3cb5a4b6fdaf9ee2c58dc6cb9bdb8a09a7315669) 171 | - Fix bug with connection pool shrinking ([#70](https://github.com/cloudamqp/amqproxy/pull/70)) 172 | - Support for config files ([#64](https://github.com/cloudamqp/amqproxy/issues/64)) 173 | 174 | ## [0.6.0] - 2022-07-14 175 | 176 | This version never got built. 177 | 178 | ## [0.5.11] - 2022-03-06 179 | 180 | - Same as 0.5.10, only to test release automation 181 | 182 | ## [0.5.10] - 2022-03-06 183 | 184 | - Include error cause in upstream error log ([#67](https://github.com/cloudamqp/amqproxy/issues/67)) 185 | 186 | ## [0.5.9] - 2022-02-14 187 | 188 | ### Fixed 189 | 190 | - TLS cert verification works for container images again 191 | 192 | ## [0.5.8] - 2022-02-01 193 | 194 | ### Fixed 195 | 196 | - Don't parse timestamp value, it can be anyting 197 | 198 | ## [0.5.7] - 2021-09-27 199 | 200 | ### Added 201 | 202 | - Docker image for arm64 203 | 204 | ## [0.5.6] - 2021-06-20 205 | 206 | ### Fixed 207 | 208 | - dockerfile syntax error 209 | 210 | ## [0.5.5] - 2021-06-20 211 | 212 | ### Added 213 | 214 | - --idle-connection-timeout option, for how long an idle connection the pool will stay open 215 | 216 | ## [0.5.4] - 2021-04-07 217 | 218 | ### Changed 219 | 220 | - Wait at least 5s before closing an upstream connection 221 | 222 | ### Fixed 223 | 224 | - Close client socket on write error 225 | - Close Upstreadm socket if client disconnects while deliverying body as state is then unknown 226 | 227 | ## [0.5.3] - 2021-03-30 228 | 229 | ### Fixed 230 | 231 | - Skip body io if no client to deliver to 232 | 233 | ### Changed 234 | 235 | - Better client disconnect handling 236 | - Name all fibers for better debugging 237 | - Not stripping binaries in Dockerfile 238 | - Crystal 1.0.0 239 | 240 | ## [0.5.2] - 2021-03-10 241 | 242 | ### Added 243 | 244 | - Heartbeat support for upstreams, uses the server suggest heartbeat interval 245 | 246 | ### Fixed 247 | 248 | - Improved connection closed handling 249 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Development 4 | 5 | Run tests in Docker: 6 | 7 | ```bash 8 | docker build . -f spec/Dockerfile -t amqproxy_spec 9 | docker run --rm -it -v $(pwd):/app -w /app --entrypoint bash amqproxy_spec 10 | 11 | # ensure rabbitmq is up, run all specs 12 | ./entrypoint.sh 13 | 14 | # run single spec 15 | crystal spec --example "keeps connections open" 16 | ``` 17 | 18 | Run tests using Docker Compose: 19 | 20 | ```bash 21 | ./run-specs-in-docker.sh 22 | ``` 23 | 24 | ## Release 25 | 26 | 1. Make a commit that 27 | 1. updates `CHANGELOG.md` 28 | 1. bumps version in `shard.yml` 29 | 1. Create and push an annotated tag: `git tag -a v$(shards version)` 30 | 1. Put the changelog of the version in the tagging message 31 | 1. **NOTE**: Only the `body` will be shown in [release notes]. (The first line in the message is the `subject` followed by an empty line, then the `body` on the next line.) 32 | 33 | [release notes]: https://github.com/cloudamqp/amqproxy/releases 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM 84codes/crystal:1.15.1-alpine AS builder 2 | WORKDIR /tmp 3 | COPY shard.yml shard.lock ./ 4 | RUN shards install --production 5 | COPY src/ src/ 6 | RUN shards build --production --release --static 7 | 8 | FROM scratch 9 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem 10 | COPY --from=builder /tmp/bin/amqproxy / 11 | USER 1000:1000 12 | EXPOSE 5673 15673 13 | ENV GC_UNMAP_THRESHOLD=1 14 | ENTRYPOINT ["/amqproxy", "--listen=0.0.0.0"] 15 | -------------------------------------------------------------------------------- /Dockerfile.deb: -------------------------------------------------------------------------------- 1 | ARG build_image=84codes/crystal:latest-ubuntu-24.04 2 | 3 | FROM $build_image AS builder 4 | RUN apt-get update && apt-get install -y devscripts help2man lintian debhelper 5 | ARG version 6 | WORKDIR /usr/src/amqproxy_${version} 7 | COPY Makefile README.md LICENSE CHANGELOG.md shard.yml shard.lock ./ 8 | COPY extras/amqproxy.service config/example.ini extras/ 9 | COPY config/example.ini config/ 10 | COPY src/ src/ 11 | RUN sed -i -E "s/(VERSION =) .*/\1 \"$version\"/" src/amqproxy/version.cr 12 | RUN tar -czf ../amqproxy_${version}.orig.tar.gz -C /usr/src amqproxy_${version} 13 | COPY debian/ debian/ 14 | RUN sed -i -E "s/^(amqproxy) \(.*\)/\1 \(${version}-1\)/" debian/changelog 15 | ARG DEB_BUILD_OPTIONS="parallel=2" 16 | RUN debuild -us -uc 17 | 18 | FROM ubuntu:24.04 AS test 19 | COPY --from=builder /usr/src/*deb . 20 | RUN apt-get update && apt-get install -y ./*.deb 21 | RUN amqproxy --version 22 | 23 | # Copy the deb package to a scratch image, that then can be exported 24 | FROM scratch 25 | ARG version 26 | COPY --from=builder /usr/src/*deb . 27 | -------------------------------------------------------------------------------- /Dockerfile.rpm: -------------------------------------------------------------------------------- 1 | ARG build_image=84codes/crystal:1.15.1-fedora-39 2 | 3 | FROM $build_image AS builder 4 | RUN dnf install -y --nodocs --setopt=install_weak_deps=False --repo=fedora,updates \ 5 | rpmdevtools rpmlint systemd-rpm-macros npm make help2man 6 | RUN rpmdev-setuptree 7 | COPY amqproxy.spec /root/rpmbuild/SPECS/ 8 | ARG version 9 | RUN sed -i -E "s/^(Version:).*/\1 $version/" /root/rpmbuild/SPECS/amqproxy.spec 10 | RUN rpmlint /root/rpmbuild/SPECS/amqproxy.spec 11 | WORKDIR /usr/src/amqproxy 12 | COPY Makefile README.md LICENSE CHANGELOG.md shard.yml shard.lock ./ 13 | COPY extras/ extras/ 14 | COPY config/ config/ 15 | COPY src/ src/ 16 | RUN sed -i -E "s/(VERSION =) .*/\1 \"$version\"/" src/amqproxy/version.cr 17 | RUN tar -czf /root/rpmbuild/SOURCES/amqproxy.tar.gz -C /usr/src amqproxy 18 | ARG MAKEFLAGS 19 | RUN rpmbuild -ba /root/rpmbuild/SPECS/amqproxy.spec 20 | RUN rpmlint /root/rpmbuild/RPMS/* || true 21 | 22 | FROM fedora:39 AS test 23 | COPY --from=builder /root/rpmbuild/RPMS /tmp/RPMS 24 | RUN find /tmp/RPMS -type f -exec dnf install -y {} \; 25 | RUN amqproxy --version 26 | 27 | # Copy the deb package to a scratch image, that then can be exported 28 | FROM scratch 29 | COPY --from=builder /root/rpmbuild/RPMS /root/rpmbuild/SRPMS . 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2020 84codes AB 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCES := $(shell find src/amqproxy -name '*.cr' 2> /dev/null) 2 | LDFLAGS ?= -Wl,-O1 -Wl,--as-needed -Wl,-z,relro -Wl,-z,now -pie 3 | CRYSTAL_FLAGS ?= --release 4 | override CRYSTAL_FLAGS += --error-on-warnings --link-flags="$(LDFLAGS)" --stats 5 | 6 | .PHONY: all 7 | all: bin/amqproxy 8 | 9 | bin/%: src/%.cr $(SOURCES) lib | bin 10 | crystal build $< -o $@ $(CRYSTAL_FLAGS) 11 | 12 | lib: shard.yml shard.lock 13 | shards install --production 14 | 15 | bin man1: 16 | mkdir -p $@ 17 | 18 | man1/amqproxy.1: bin/amqproxy | man1 19 | help2man -Nn "connection pool for AMQP connections" $< -o $@ 20 | 21 | .PHONY: deps 22 | deps: lib 23 | 24 | .PHONY: lint 25 | lint: lib 26 | lib/ameba/bin/ameba src/ 27 | 28 | .PHONY: test 29 | test: lib 30 | crystal spec 31 | 32 | .PHONY: format 33 | format: 34 | crystal tool format --check 35 | 36 | DESTDIR := 37 | PREFIX := /usr 38 | BINDIR := $(PREFIX)/bin 39 | DOCDIR := $(PREFIX)/share/doc 40 | MANDIR := $(PREFIX)/share/man 41 | SYSCONFDIR := /etc 42 | UNITDIR := /lib/systemd/system 43 | 44 | .PHONY: install 45 | install: bin/amqproxy man1/amqproxy.1 config/example.ini extras/amqproxy.service README.md CHANGELOG.md 46 | install -D -m 0755 -t $(DESTDIR)$(BINDIR) bin/amqproxy 47 | install -D -m 0644 -t $(DESTDIR)$(MANDIR)/man1 man1/amqproxy.1 48 | install -D -m 0644 -t $(DESTDIR)$(UNITDIR) extras/amqproxy.service 49 | install -D -m 0644 -t $(DESTDIR)$(DOCDIR)/amqproxy README.md 50 | install -D -m 0644 config/example.ini $(DESTDIR)$(SYSCONFDIR)/amqproxy.ini 51 | install -D -m 0644 CHANGELOG.md $(DESTDIR)$(DOCDIR)/amqproxy/changelog 52 | 53 | .PHONY: uninstall 54 | uninstall: 55 | $(RM) $(DESTDIR)$(BINDIR)/amqproxy 56 | $(RM) $(DESTDIR)$(MANDIR)/man1/amqproxy.1 57 | $(RM) $(DESTDIR)$(SYSCONFDIR)/amqproxy/amqproxy.ini 58 | $(RM) $(DESTDIR)$(UNITDIR)/amqproxy.service 59 | $(RM) $(DESTDIR)$(DOCDIR)/amqproxy/{README.md,changelog} 60 | $(RM) -r $(DESTDIR)$(DOCDIR)/amqproxy 61 | 62 | .PHONY: clean 63 | clean: 64 | rm -rf bin 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AMQProxy 2 | 3 | An intelligent AMQP proxy with AMQP connection and channel pooling/reusing. Allows e.g. PHP clients to keep long lived connections to upstream servers, increasing publishing speed with a magnitude or more. 4 | 5 | In the AMQP protocol, if you open a connection the client and the server has to exchange 7 TCP packages. If you then want to publish a message you have to open a channel which requires 2 more, and then to do the publish you need at least one more, and then to gracefully close the connection you need 4 more packages. In total 15 TCP packages, or 18 if you use AMQPS (TLS). For clients that can't for whatever reason keep long-lived connections to the server this has a considerable latency impact. 6 | 7 | This proxy server, if run on the same machine as the client can save all that latency. When a connection is made to the proxy the proxy opens a connection to the upstream server, using the credentials the client provided. AMQP traffic is then forwarded between the client and the server but when the client disconnects the proxy intercepts the Channel Close command and instead keeps it open on the upstream server (if deemed safe). Next time a client connects (with the same credentials) the connection to the upstream server is reused so no TCP packages for opening and negotiating the AMQP connection or opening and waiting for the channel to be opened has to be made. 8 | 9 | Only "safe" channels are reused, that is channels where only Basic Publish or Basic Get (with no_ack) has occurred. Any channels who has subscribed to a queue will be closed when the client disconnects. However, the connection to the upstream AMQP server are always kept open and can be reused. 10 | 11 | In our benchmarks publishing one message per connection to a server (using TLS) with a round-trip latency of 50ms, takes on avarage 10ms using the proxy and 500ms without. You can read more about the proxy here [Maintaining long-lived connections with AMQProxy](https://www.cloudamqp.com/blog/2019-05-29-maintaining-long-lived-connections-with-AMQProxy.html) 12 | 13 | As of version 2.0.0 connections to the server can be shared by multiple client connections. When a client opens a channel it will get a channel on a shared upstream connection, the proxy will remap the channel numbers between the two. Many client connections can therefor share a single upstream connection. The benefit is that way fewer connections are needed to the upstream server. For instance, establihsing 10.000 connections after a server reboot might normally take several minutes, but with this proxy it can happen in seconds. 14 | 15 | A health check for amqproxy is available over http on http://listen_address:http_port/healthz and will return 200 when amqproxy is healthy. 16 | 17 | Some metrics are available over http on http://listen_address:http_port/metrics 18 | 19 | ## Installation 20 | 21 | ### Debian/Ubuntu 22 | 23 | Packages are available at [Packagecloud](https://packagecloud.io/cloudamqp/amqproxy). Install the latest version with: 24 | 25 | ```sh 26 | curl -fsSL https://packagecloud.io/cloudamqp/amqproxy/gpgkey | gpg --dearmor | sudo tee /usr/share/keyrings/amqproxy.gpg > /dev/null 27 | . /etc/os-release 28 | echo "deb [signed-by=/usr/share/keyrings/amqproxy.gpg] https://packagecloud.io/cloudamqp/amqproxy/$ID $VERSION_CODENAME main" | sudo tee /etc/apt/sources.list.d/amqproxy.list 29 | sudo apt-get update 30 | sudo apt-get install amqproxy 31 | ``` 32 | 33 | If you need to install a specific version, do so using the following command: 34 | `sudo apt install amqproxy=`. This works for both upgrades and downgrades. 35 | 36 | ### Docker/Podman 37 | 38 | Docker images are published at [Docker Hub](https://hub.docker.com/r/cloudamqp/amqproxy). Fetch and run the latest version with: 39 | 40 | ```sh 41 | docker run --rm -it -p 5673:5673 cloudamqp/amqproxy amqp://SERVER:5672 42 | ``` 43 | 44 | Note: If you are running the upstream server on localhost then you will have to add the `--network host` flag to the docker run command. 45 | 46 | Then from your AMQP client connect to localhost:5673, it will resuse connections made to the upstream. The AMQP_URL should only include protocol, hostname and port (only if non default, 5672 for AMQP and 5671 for AMQPS). Any username, password or vhost will be ignored, and it's up to the client to provide them. 47 | 48 | ## Installation (from source) 49 | 50 | [Install Crystal](https://crystal-lang.org/install/) 51 | 52 | ``` 53 | shards build --release --production 54 | cp bin/amqproxy /usr/bin 55 | cp extras/amqproxy.service /etc/systemd/system/ 56 | systemctl enable amqproxy 57 | systemctl start amqproxy 58 | ``` 59 | 60 | You probably want to modify `/etc/systemd/system/amqproxy.service` and configure another upstream host. 61 | 62 | 63 | ## Configuration 64 | 65 | ### Available settings 66 | 67 | | Setting | Description | Command line | Environment variable | Config file setting | Default value | 68 | |-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------|---------------------------|--------------------------------------------------|---------------| 69 | | Listen address | Address to listen on. This is the hostname/IP address which will be the target of clients | `--listen` / `-l` | `LISTEN_ADDRESS` | `[listen] > address` or `[listen] > bind` | `localhost` | 70 | | Listen port | Port to listen on. This is the port which will be the target of clients | `--port` / `-p` | `LISTEN_PORT` | `[listen] > port` | `5673` | 71 | | Log level | Controls log verbosity.

Available levels (see [84codes/logger.cr](https://github.com/84codes/logger.cr/blob/main/src/logger.cr#L86)):
- `DEBUG`: Low-level information for developers
- `INFO`: Generic (useful) information about system operation
- `WARN`: Warnings
- `ERROR`: Handleable error conditions
- `FATAL`: Unhandleable errors that results in a program crash | `--debug` / `-d`: Sets the level to `DEBUG` | - | `[main] > log_level` | `INFO` | 72 | | Idle connection timeout | Maximum time in seconds an unused pooled connection stays open | `--idle-connection-timeout` / `-t` | `IDLE_CONNECTION_TIMEOUT` | `[main] > idle_connection_timeout` | `5` | 73 | | Upstream | AMQP URL that points to the upstream RabbitMQ server to which the proxy should connect to. May only contain scheme, host & port (optional). Example: `amqps://rabbitmq.example.com` | Pass as argument after all options | `AMQP_URL` | `[main] > upstream` | | 74 | 75 | ### How to configure 76 | 77 | There are three ways to configure the AMQProxy. 78 | * Environment variables 79 | * Passing options & argument via the command line 80 | * Usage: `amqproxy [options] [amqp upstream url]` 81 | * Additional options, that are not mentioned in the table above: 82 | * `--config` / `-c`: Load config file at given path 83 | * `--help` / `-h`: Shows help 84 | * `--version` / `-v`: Displays AMQProxy version 85 | * Config file 86 | * You can find an example at [config/example.ini](config/example.ini) 87 | 88 | #### Precedence 89 | 1. Config file 90 | 2. Command line options & argument 91 | 3. Environment variables 92 | 93 | Settings that are avilable in the config file will override the corresponding command line options. A command line option will override the corresponding environment variable. And so on. 94 | The different configuration approaches can also be mixed. 95 | -------------------------------------------------------------------------------- /amqproxy.spec: -------------------------------------------------------------------------------- 1 | Name: amqproxy 2 | Summary: Connection and channel pool for AMQP connections 3 | Version: 1.0.0 4 | Release: 1%{?dist} 5 | 6 | License: Apache 2.0 7 | BuildRequires: systemd-rpm-macros crystal help2man 8 | URL: https://github.com/cloudamqp/amqproxy 9 | Source: amqproxy.tar.gz 10 | 11 | %description 12 | An AMQP proxy that reuses upstream connections and channels 13 | 14 | %prep 15 | %setup -qn amqproxy 16 | 17 | %check 18 | 19 | %build 20 | make 21 | 22 | %install 23 | make install DESTDIR=%{buildroot} UNITDIR=%{_unitdir} 24 | 25 | %post 26 | %systemd_post %{name}.service 27 | 28 | %preun 29 | %systemd_preun %{name}.service 30 | 31 | %postun 32 | %systemd_postun_with_restart %{name}.service 33 | 34 | %files 35 | %{_bindir}/%{name} 36 | %{_unitdir}/%{name}.service 37 | %{_mandir}/man1/* 38 | %config(noreplace) %{_sysconfdir}/%{name}.ini 39 | %doc README.md 40 | %doc %{_docdir}/%{name}/changelog 41 | %license LICENSE 42 | 43 | %changelog 44 | * Thu Nov 24 2022 CloudAMQP 45 | - Initial version of the package 46 | -------------------------------------------------------------------------------- /build/release_notes: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # GITHUB_REPOSITORY is a default environment variable in Actions 4 | # https://docs.github.com/en/actions/reference/environment-variables 5 | 6 | LATEST_TAG=$(git describe --tags --abbrev=0) 7 | PREV_TAG=$(git describe --tags --abbrev=0 "${LATEST_TAG}~") 8 | TAG_BODY=$(git tag --list --format='%(body)' $LATEST_TAG) 9 | 10 | echo "$TAG_BODY" | sed '/^-----BEGIN PGP/,/^-----END PGP/d' 11 | echo "" 12 | echo "Changes: https://github.com/${GITHUB_REPOSITORY}/compare/${PREV_TAG}...${LATEST_TAG}" 13 | -------------------------------------------------------------------------------- /config/example.ini: -------------------------------------------------------------------------------- 1 | [main] 2 | log_level = info 3 | idle_connection_timeout = 5 4 | upstream = amqp://localhost:5672 5 | 6 | [listen] 7 | address = 127.0.0.1 8 | port = 5673 9 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | amqproxy (1.0.0-1) unstable; urgency=low 2 | 3 | * New upstream release. Closes: #8484 4 | 5 | -- CloudAMQP Wed, 23 Nov 2022 00:00:01 +0000 6 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 12 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: amqproxy 2 | Standards-Version: 4.6.0 3 | Homepage: https://github.com/cloudamqp/amqproxy 4 | Section: net 5 | Priority: optional 6 | Build-Depends: debhelper (>= 12), crystal (>= 1.15.0), help2man 7 | Maintainer: CloudAMQP 8 | 9 | Package: amqproxy 10 | Architecture: any 11 | Depends: ${shlibs:Depends}, ${misc:Depends} 12 | Description: connection pool for AMQP connections 13 | Reuses connections and channels to an upstream 14 | AMQP server. 15 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: lavinmq 3 | Upstream-Contact: contact@84codes.com 4 | Source: https://github.com/cloudamqp/lavinmq 5 | Files: * 6 | Copyright: 2025, 84codes AB 7 | License: Apache-2.0 8 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | export DH_VERBOSE=1 3 | export DEB_BUILD_OPTIONS += nostrip 4 | 5 | %: 6 | dh $@ 7 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (quilt) 2 | -------------------------------------------------------------------------------- /extras/amqproxy.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=AMQProxy server for connection and channel pooling 3 | Documentation=https://github.com/cloudamqp/amqproxy 4 | Requires=network.target 5 | After=network.target 6 | 7 | [Service] 8 | ExecStart=/usr/bin/amqproxy --config /etc/amqproxy.ini 9 | Restart=on-failure 10 | DynamicUser=yes 11 | LimitNOFILE=infinity 12 | Environment="GC_UNMAP_THRESHOLD=1" 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /run-specs-in-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | set -e 5 | 6 | docker compose \ 7 | --file spec/docker-compose.yml \ 8 | up \ 9 | --remove-orphans \ 10 | --force-recreate \ 11 | --renew-anon-volumes \ 12 | --build \ 13 | --exit-code-from spec 14 | -------------------------------------------------------------------------------- /set-crystal-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | version=$1 5 | 6 | if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] 7 | then 8 | echo "Use: $0 " 9 | exit 1 10 | fi 11 | 12 | files=( 13 | Dockerfile 14 | Dockerfile.rpm 15 | deb.Dockerfile 16 | tar.Dockerfile 17 | spec/Dockerfile 18 | .github/workflows/ci.yml 19 | .github/workflows/packages.yml 20 | .github/workflows/rpm.yml 21 | ) 22 | 23 | suffix='pre-'${version} 24 | for file in ${files[@]} 25 | do 26 | sed -i'.bak' 's/84codes\/crystal:[^-]*/84codes\/crystal:'${version}'/' ${file} 27 | # sed on BSD systems require a backup extension, so we're deleting the backup afterwards 28 | rm -f ${file}.bak 29 | done 30 | sed -i'.bak' 's/crystal: .*$/crystal: '${version}'/' shard.yml 31 | rm -f shard.yml.bak 32 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | ameba: 4 | git: https://github.com/crystal-ameba/ameba.git 5 | version: 1.6.1 6 | 7 | amq-protocol: 8 | git: https://github.com/cloudamqp/amq-protocol.cr.git 9 | version: 1.1.14 10 | 11 | amqp-client: 12 | git: https://github.com/cloudamqp/amqp-client.cr.git 13 | version: 1.2.5 14 | 15 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: amqproxy 2 | version: 3.0.1 3 | 4 | authors: 5 | - CloudAMQP 6 | 7 | targets: 8 | amqproxy: 9 | main: src/amqproxy.cr 10 | 11 | dependencies: 12 | amq-protocol: 13 | github: cloudamqp/amq-protocol.cr 14 | 15 | development_dependencies: 16 | amqp-client: 17 | github: cloudamqp/amqp-client.cr 18 | ameba: 19 | github: crystal-ameba/ameba 20 | 21 | crystal: 1.15.1 22 | license: MIT 23 | -------------------------------------------------------------------------------- /spec/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM 84codes/crystal:1.15.1-ubuntu-22.04 2 | 3 | RUN apt-get update && apt-get install -y rabbitmq-server 4 | 5 | WORKDIR /tmp 6 | 7 | # We want to install shards before copying code/spec files for quicker runs 8 | COPY shard.yml shard.lock ./ 9 | RUN shards install 10 | 11 | COPY src/ src/ 12 | COPY spec/ spec/ 13 | 14 | COPY spec/entrypoint.sh /entrypoint.sh 15 | RUN chmod +x /entrypoint.sh 16 | ENTRYPOINT ["/entrypoint.sh"] 17 | -------------------------------------------------------------------------------- /spec/amqproxy/http_server_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "../../src/amqproxy/http_server" 3 | require "http/client" 4 | 5 | base_addr = "http://localhost:15673" 6 | 7 | describe AMQProxy::HTTPServer do 8 | it "GET /healthz returns 200" do 9 | with_http_server do 10 | response = HTTP::Client.get("#{base_addr}/healthz") 11 | response.status_code.should eq 200 12 | end 13 | end 14 | 15 | it "GET /metrics returns 200" do 16 | with_http_server do 17 | response = HTTP::Client.get("#{base_addr}/metrics") 18 | response.status_code.should eq 200 19 | end 20 | end 21 | 22 | it "GET /metrics returns correct metrics" do 23 | with_http_server do |_http, _amqproxy, proxy_url| 24 | AMQP::Client.start(proxy_url) do |conn| 25 | _ch = conn.channel 26 | response = HTTP::Client.get("#{base_addr}/metrics") 27 | response.body.split("\n").each do |line| 28 | if line.starts_with?("amqproxy_client_connections") 29 | line.should eq "amqproxy_client_connections 1" 30 | elsif line.starts_with?("amqproxy_upstream_connections") 31 | line.should eq "amqproxy_upstream_connections 1" 32 | end 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/amqproxy/server_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe AMQProxy::Server do 4 | it "dont reuse channels closed by upstream" do 5 | with_server do |server, proxy_url| 6 | Fiber.yield 7 | AMQP::Client.start(proxy_url) do |conn| 8 | ch = conn.channel 9 | ch.basic_publish "foobar", "non-existing" 10 | end 11 | AMQP::Client.start(proxy_url) do |conn| 12 | ch = conn.channel 13 | ch.basic_publish_confirm "foobar", "amq.fanout" 14 | end 15 | AMQP::Client.start(proxy_url) do |conn| 16 | ch = conn.channel 17 | expect_raises(AMQP::Client::Channel::ClosedException) do 18 | ch.basic_publish_confirm "foobar", "non-existing" 19 | end 20 | end 21 | sleep 0.1.seconds 22 | server.upstream_connections.should eq 1 23 | end 24 | end 25 | 26 | it "keeps connections open" do 27 | with_server do |server, proxy_url| 28 | Fiber.yield 29 | 10.times do 30 | AMQP::Client.start(proxy_url) do |conn| 31 | ch = conn.channel 32 | ch.basic_publish "foobar", "amq.fanout", "" 33 | server.client_connections.should eq 1 34 | server.upstream_connections.should eq 1 35 | end 36 | end 37 | server.client_connections.should eq 0 38 | server.upstream_connections.should eq 1 39 | end 40 | end 41 | 42 | it "publish and consume works" do 43 | with_server do |_server, proxy_url| 44 | Fiber.yield 45 | 46 | queue_name = "amqproxy-test-queue" 47 | message_payload = "Message from AMQProxy specs" 48 | num_received_messages = 0 49 | num_messages_to_publish = 5 50 | 51 | num_messages_to_publish.times do 52 | AMQP::Client.start(proxy_url) do |conn| 53 | channel = conn.channel 54 | queue = channel.queue(queue_name) 55 | queue.publish_confirm(message_payload) 56 | end 57 | end 58 | sleep 0.1.seconds 59 | 60 | AMQP::Client.start(proxy_url) do |conn| 61 | channel = conn.channel 62 | channel.basic_consume(queue_name, no_ack: false, tag: "AMQProxy specs") do |msg| 63 | body = msg.body_io.to_s 64 | if body == message_payload 65 | channel.basic_ack(msg.delivery_tag) 66 | num_received_messages += 1 67 | end 68 | end 69 | sleep 0.1.seconds 70 | end 71 | 72 | num_received_messages.should eq num_messages_to_publish 73 | end 74 | end 75 | 76 | it "a client can open all channels" do 77 | with_server do |server, proxy_url| 78 | max = 4000 79 | AMQP::Client.start("#{proxy_url}?channel_max=#{max}") do |conn| 80 | conn.channel_max.should eq max 81 | conn.channel_max.times do 82 | conn.channel 83 | end 84 | server.client_connections.should eq 1 85 | server.upstream_connections.should eq 2 86 | end 87 | sleep 0.1.seconds 88 | server.client_connections.should eq 0 89 | server.upstream_connections.should eq 2 90 | end 91 | end 92 | 93 | it "can reconnect if upstream closes" do 94 | with_server do |server, proxy_url| 95 | Fiber.yield 96 | AMQP::Client.start(proxy_url) do |conn| 97 | conn.channel 98 | system("#{MAYBE_SUDO}rabbitmqctl stop_app > /dev/null").should be_true 99 | end 100 | system("#{MAYBE_SUDO}rabbitmqctl start_app > /dev/null").should be_true 101 | AMQP::Client.start(proxy_url) do |conn| 102 | conn.channel 103 | server.client_connections.should eq(1) 104 | server.upstream_connections.should eq(1) 105 | end 106 | sleep 0.1.seconds 107 | server.client_connections.should eq(0) 108 | server.upstream_connections.should eq(1) 109 | end 110 | end 111 | 112 | it "responds to upstream heartbeats" do 113 | with_server do |server, proxy_url| 114 | system("#{MAYBE_SUDO}rabbitmqctl eval 'application:set_env(rabbit, heartbeat, 1).' > /dev/null").should be_true 115 | Fiber.yield 116 | AMQP::Client.start(proxy_url) do |conn| 117 | conn.channel 118 | end 119 | sleep 2.seconds 120 | server.client_connections.should eq(0) 121 | server.upstream_connections.should eq(1) 122 | ensure 123 | system("#{MAYBE_SUDO}rabbitmqctl eval 'application:set_env(rabbit, heartbeat, 60).' > /dev/null").should be_true 124 | end 125 | end 126 | 127 | it "supports waiting for client connections on graceful shutdown" do 128 | started = Time.utc.to_unix 129 | 130 | with_server(idle_connection_timeout: 5) do |server, proxy_url| 131 | wait_for_channel = Channel(Int32).new # channel used to wait for certain calls, to test certain behaviour 132 | Fiber.yield 133 | spawn do 134 | AMQP::Client.start(proxy_url) do |conn| 135 | conn.channel 136 | wait_for_channel.send(0) # send 0 137 | 10.times do 138 | server.client_connections.should be >= 1 139 | server.upstream_connections.should be >= 1 140 | sleep 1.seconds 141 | end 142 | end 143 | wait_for_channel.send(5) # send 5 144 | end 145 | wait_for_channel.receive.should eq 0 # wait 0 146 | server.client_connections.should eq 1 147 | server.upstream_connections.should eq 1 148 | spawn do 149 | AMQP::Client.start(proxy_url) do |conn| 150 | conn.channel 151 | wait_for_channel.send(2) # send 2 152 | sleep 2.seconds 153 | end 154 | wait_for_channel.send(3) # send 3 155 | end 156 | wait_for_channel.receive.should eq 2 # wait 2 157 | server.client_connections.should eq 2 158 | server.upstream_connections.should eq 1 159 | spawn server.stop_accepting_clients 160 | wait_for_channel.receive.should eq 3 # wait 3 161 | server.client_connections.should eq 1 162 | server.upstream_connections.should eq 1 # since connection stays open 163 | spawn do 164 | begin 165 | AMQP::Client.start(proxy_url) do |conn| 166 | conn.channel 167 | wait_for_channel.send(-1) # send 4 (this should not happen) 168 | sleep 1.seconds 169 | end 170 | rescue ex 171 | # ex.message.should be "Error reading socket: Connection reset by peer" 172 | wait_for_channel.send(4) # send 4 173 | end 174 | end 175 | wait_for_channel.receive.should eq 4 # wait 4 176 | server.client_connections.should eq 1 # since the new connection should not have worked 177 | server.upstream_connections.should eq 1 # since connections stay open 178 | wait_for_channel.receive.should eq 5 # wait 5 179 | server.client_connections.should eq 0 # since now the server should be closed 180 | server.upstream_connections.should eq 1 181 | (Time.utc.to_unix - started).should be < 30 182 | end 183 | end 184 | 185 | it "works after server closes channel" do 186 | with_server do |_server, proxy_url| 187 | Fiber.yield 188 | AMQP::Client.start(proxy_url) do |conn| 189 | qname = "test#{rand}" 190 | 3.times do 191 | expect_raises(AMQP::Client::Channel::ClosedException) do 192 | ch = conn.channel 193 | ch.basic_consume(qname) { } 194 | end 195 | end 196 | end 197 | end 198 | end 199 | 200 | it "passes connection blocked frames to clients" do 201 | with_server do |_server, proxy_url| 202 | done = Channel(Nil).new 203 | Fiber.yield 204 | AMQP::Client.start(proxy_url) do |conn| 205 | conn.on_blocked do 206 | done.send nil 207 | system("#{MAYBE_SUDO}rabbitmqctl set_vm_memory_high_watermark 0.8 > /dev/null").should be_true 208 | end 209 | conn.on_unblocked do 210 | done.send nil 211 | end 212 | ch = conn.channel 213 | system("#{MAYBE_SUDO}rabbitmqctl set_vm_memory_high_watermark 0.001 > /dev/null").should be_true 214 | ch.basic_publish "foobar", "amq.fanout" 215 | 2.times { done.receive } 216 | end 217 | end 218 | end 219 | 220 | it "supports publishing large messages" do 221 | with_server do |_server, proxy_url| 222 | Fiber.yield 223 | AMQP::Client.start(proxy_url) do |conn| 224 | ch = conn.channel 225 | q = ch.queue 226 | q.publish_confirm Bytes.new(10240) 227 | msg = q.get.not_nil!("should not be nil") 228 | msg.body_io.bytesize.should eq 10240 229 | end 230 | end 231 | end 232 | 233 | it "supports publishing large messages when frame_max is small" do 234 | with_server do |_server, proxy_url| 235 | Fiber.yield 236 | AMQP::Client.start("#{proxy_url}?frame_max=4096") do |conn| 237 | ch = conn.channel 238 | q = ch.queue 239 | q.publish_confirm Bytes.new(200_000) 240 | msg = q.get.not_nil!("should not be nil") 241 | msg.body_io.bytesize.should eq 200_000 242 | end 243 | end 244 | end 245 | 246 | it "should treat all frames as heartbeats" do 247 | with_server do |server, proxy_url| 248 | Fiber.yield 249 | AMQP::Client.start("#{proxy_url}?heartbeat=1") do |conn| 250 | client = server.@clients.first?.should_not be_nil 251 | last_heartbeat = client.@last_heartbeat 252 | conn.channel 253 | Fiber.yield 254 | client.@last_heartbeat.should be > last_heartbeat 255 | last_heartbeat = client.@last_heartbeat 256 | conn.write AMQ::Protocol::Frame::Heartbeat.new 257 | Fiber.yield 258 | client.@last_heartbeat.should be > last_heartbeat 259 | end 260 | end 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /spec/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | spec: 4 | build: 5 | context: .. 6 | dockerfile: spec/Dockerfile 7 | -------------------------------------------------------------------------------- /spec/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -u 5 | set -x 6 | 7 | export RABBITMQ_PID_FILE=/tmp/rabbitmq.pid 8 | 9 | # Start RabbitMQ 10 | rabbitmq-server -detached 11 | 12 | # Wait for RabbitMQ to start 13 | rabbitmqctl wait $RABBITMQ_PID_FILE 14 | 15 | crystal --version 16 | crystal spec --order random 17 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "log" 2 | require "spec" 3 | require "uri" 4 | require "../src/amqproxy/server" 5 | require "../src/amqproxy/version" 6 | require "amqp-client" 7 | 8 | Log.setup_from_env(default_level: :error) 9 | 10 | MAYBE_SUDO = (ENV.has_key?("NO_SUDO") || `id -u` == "0\n") ? "" : "sudo " 11 | 12 | UPSTREAM_URL = begin 13 | URI.parse ENV.fetch("UPSTREAM_URL", "amqp://127.0.0.1:5672?idle_connection_timeout=5") 14 | rescue e : URI::Error 15 | puts "Invalid UPSTREAM_URL: #{e}" 16 | exit 1 17 | end 18 | 19 | def with_server(idle_connection_timeout = 5, &) 20 | server = AMQProxy::Server.new(UPSTREAM_URL) 21 | tcp_server = TCPServer.new("127.0.0.1", 0) 22 | amqp_url = "amqp://#{tcp_server.local_address}" 23 | spawn { server.listen(tcp_server) } 24 | yield server, amqp_url 25 | ensure 26 | if s = server 27 | s.stop_accepting_clients 28 | end 29 | end 30 | 31 | def with_http_server(idle_connection_timeout = 5, &) 32 | with_server do |server, amqp_url| 33 | http_server = AMQProxy::HTTPServer.new(server, "127.0.0.1", 15673) 34 | begin 35 | yield http_server, server, amqp_url 36 | ensure 37 | http_server.close 38 | end 39 | end 40 | end 41 | 42 | def verify_running_amqp! 43 | tls = UPSTREAM_URL.scheme == "amqps" 44 | host = UPSTREAM_URL.host || "127.0.0.1" 45 | port = UPSTREAM_URL.port || 5762 46 | port = 5671 if tls && UPSTREAM_URL.port.nil? 47 | TCPSocket.new(host, port, connect_timeout: 3.seconds).close 48 | rescue Socket::ConnectError 49 | STDERR.puts "[ERROR] Specs require a running rabbitmq server on #{host}:#{port}" 50 | exit 1 51 | end 52 | 53 | Spec.before_suite do 54 | verify_running_amqp! 55 | end 56 | -------------------------------------------------------------------------------- /src/amqproxy.cr: -------------------------------------------------------------------------------- 1 | require "./amqproxy/cli" 2 | AMQProxy::CLI.new.run(ARGV) 3 | -------------------------------------------------------------------------------- /src/amqproxy/channel_pool.cr: -------------------------------------------------------------------------------- 1 | require "openssl" 2 | require "log" 3 | require "./records" 4 | require "./upstream" 5 | 6 | module AMQProxy 7 | class ChannelPool 8 | Log = ::Log.for(self) 9 | @lock = Mutex.new 10 | @upstreams = Deque(Upstream).new 11 | 12 | def initialize(@host : String, @port : Int32, @tls_ctx : OpenSSL::SSL::Context::Client?, @credentials : Credentials, @idle_connection_timeout : Int32) 13 | spawn shrink_pool_loop, name: "shrink pool loop" 14 | end 15 | 16 | def get(downstream_channel : DownstreamChannel) : UpstreamChannel 17 | at_channel_max = 0 18 | @lock.synchronize do 19 | loop do 20 | if upstream = @upstreams.shift? 21 | next if upstream.closed? 22 | begin 23 | upstream_channel = upstream.open_channel_for(downstream_channel) 24 | @upstreams.unshift(upstream) 25 | return upstream_channel 26 | rescue Upstream::ChannelMaxReached 27 | @upstreams.push(upstream) 28 | at_channel_max += 1 29 | add_upstream if at_channel_max == @upstreams.size 30 | end 31 | else 32 | add_upstream 33 | end 34 | end 35 | end 36 | end 37 | 38 | private def add_upstream 39 | upstream = Upstream.new(@host, @port, @tls_ctx, @credentials) 40 | Log.info { "Adding upstream connection" } 41 | @upstreams.unshift upstream 42 | spawn(name: "Upstream#read_loop") do 43 | begin 44 | upstream.read_loop 45 | ensure 46 | @upstreams.delete upstream 47 | end 48 | end 49 | rescue ex : IO::Error 50 | raise Upstream::Error.new ex.message, cause: ex 51 | end 52 | 53 | def connections 54 | @upstreams.size 55 | end 56 | 57 | def close 58 | Log.info { "Closing all upstream connections" } 59 | @lock.synchronize do 60 | while u = @upstreams.shift? 61 | begin 62 | u.close "AMQProxy shutdown" 63 | rescue ex 64 | Log.error { "Problem closing upstream: #{ex.inspect}" } 65 | end 66 | end 67 | end 68 | end 69 | 70 | private def shrink_pool_loop 71 | loop do 72 | sleep @idle_connection_timeout.seconds 73 | @lock.synchronize do 74 | (@upstreams.size - 1).times do # leave at least one connection 75 | u = @upstreams.pop 76 | if u.channels.zero? 77 | begin 78 | u.close "Pooled connection closed due to inactivity" 79 | rescue ex 80 | Log.error { "Problem closing upstream: #{ex.inspect}" } 81 | end 82 | elsif u.closed? 83 | Log.error { "Removing closed upstream connection from pool" } 84 | else 85 | @upstreams.unshift u 86 | end 87 | end 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /src/amqproxy/cli.cr: -------------------------------------------------------------------------------- 1 | require "./version" 2 | require "./server" 3 | require "./http_server" 4 | require "option_parser" 5 | require "uri" 6 | require "ini" 7 | require "log" 8 | 9 | class AMQProxy::CLI 10 | Log = ::Log.for(self) 11 | 12 | @listen_address = "localhost" 13 | @listen_port = 5673 14 | @http_port = 15673 15 | @log_level : ::Log::Severity = ::Log::Severity::Info 16 | @idle_connection_timeout : Int32 = 5 17 | @term_timeout = -1 18 | @term_client_close_timeout = 0 19 | @server : AMQProxy::Server? = nil 20 | 21 | def parse_config(path) # ameba:disable Metrics/CyclomaticComplexity 22 | INI.parse(File.read(path)).each do |name, section| 23 | case name 24 | when "main", "" 25 | section.each do |key, value| 26 | case key 27 | when "upstream" then @upstream = value 28 | when "log_level" then @log_level = ::Log::Severity.parse(value) 29 | when "idle_connection_timeout" then @idle_connection_timeout = value.to_i 30 | when "term_timeout" then @term_timeout = value.to_i 31 | when "term_client_close_timeout" then @term_client_close_timeout = value.to_i 32 | else raise "Unsupported config #{name}/#{key}" 33 | end 34 | end 35 | when "listen" 36 | section.each do |key, value| 37 | case key 38 | when "port" then @listen_port = value.to_i 39 | when "bind", "address" then @listen_address = value 40 | when "log_level" then @log_level = ::Log::Severity.parse(value) 41 | else raise "Unsupported config #{name}/#{key}" 42 | end 43 | end 44 | else raise "Unsupported config section #{name}" 45 | end 46 | end 47 | rescue ex 48 | abort ex.message 49 | end 50 | 51 | def apply_env_variables 52 | @listen_address = ENV["LISTEN_ADDRESS"]? || @listen_address 53 | @listen_port = ENV["LISTEN_PORT"]?.try &.to_i || @listen_port 54 | @http_port = ENV["HTTP_PORT"]?.try &.to_i || @http_port 55 | @log_level = ENV["LOG_LEVEL"]?.try { |level| ::Log::Severity.parse(level) } || @log_level 56 | @idle_connection_timeout = ENV["IDLE_CONNECTION_TIMEOUT"]?.try &.to_i || @idle_connection_timeout 57 | @term_timeout = ENV["TERM_TIMEOUT"]?.try &.to_i || @term_timeout 58 | @term_client_close_timeout = ENV["TERM_CLIENT_CLOSE_TIMEOUT"]?.try &.to_i || @term_client_close_timeout 59 | @upstream = ENV["AMQP_URL"]? || @upstream 60 | end 61 | 62 | def run(argv) 63 | raise "run cant be called multiple times" unless @server.nil? 64 | 65 | # Parse config file first 66 | OptionParser.parse(argv) do |parser| 67 | parser.on("-c FILE", "--config=FILE", "Load config file") { |v| parse_config(v) } 68 | parser.invalid_option { } # Invalid arguments are handled by the next OptionParser 69 | end 70 | 71 | apply_env_variables 72 | 73 | # Parse CLI arguments 74 | p = OptionParser.parse(argv) do |parser| 75 | parser.banner = "Usage: amqproxy [options] [amqp upstream url]" 76 | parser.on("-l ADDRESS", "--listen=ADDRESS", "Address to listen on (default is localhost)") do |v| 77 | @listen_address = v 78 | end 79 | parser.on("-p PORT", "--port=PORT", "Port to listen on (default: 5673)") { |v| @listen_port = v.to_i } 80 | parser.on("-b PORT", "--http-port=PORT", "HTTP Port to listen on (default: 15673)") { |v| @http_port = v.to_i } 81 | parser.on("-t IDLE_CONNECTION_TIMEOUT", "--idle-connection-timeout=SECONDS", "Maximum time in seconds an unused pooled connection stays open (default 5s)") do |v| 82 | @idle_connection_timeout = v.to_i 83 | end 84 | parser.on("--term-timeout=SECONDS", "At TERM the server waits SECONDS seconds for clients to gracefully close their sockets after Close has been sent (default: infinite)") do |v| 85 | @term_timeout = v.to_i 86 | end 87 | parser.on("--term-client-close-timeout=SECONDS", "At TERM the server waits SECONDS seconds for clients to send Close before sending Close to clients (default: 0s)") do |v| 88 | @term_client_close_timeout = v.to_i 89 | end 90 | parser.on("-d", "--debug", "Verbose logging") { @log_level = ::Log::Severity::Debug } 91 | parser.on("-h", "--help", "Show this help") { puts parser.to_s; exit 0 } 92 | parser.on("-v", "--version", "Display version") { puts AMQProxy::VERSION.to_s; exit 0 } 93 | parser.invalid_option { |arg| abort "Invalid argument: #{arg}" } 94 | end 95 | 96 | @upstream ||= argv.shift? 97 | upstream_url = @upstream || abort p.to_s 98 | 99 | u = URI.parse upstream_url 100 | abort "Invalid upstream URL" unless u.host 101 | default_port = 102 | case u.scheme 103 | when "amqp" then 5672 104 | when "amqps" then 5671 105 | else abort "Not a valid upstream AMQP URL, should be on the format of amqps://hostname" 106 | end 107 | port = u.port || default_port 108 | tls = u.scheme == "amqps" 109 | 110 | log_backend = if ENV.has_key?("JOURNAL_STREAM") 111 | ::Log::IOBackend.new(formatter: Journal::LogFormat, dispatcher: ::Log::DirectDispatcher) 112 | else 113 | ::Log::IOBackend.new(formatter: Stdout::LogFormat, dispatcher: ::Log::DirectDispatcher) 114 | end 115 | ::Log.setup_from_env(default_level: @log_level, backend: log_backend) 116 | 117 | Signal::INT.trap &->self.initiate_shutdown(Signal) 118 | Signal::TERM.trap &->self.initiate_shutdown(Signal) 119 | 120 | server = @server = AMQProxy::Server.new(u.hostname || "", port, tls, @idle_connection_timeout) 121 | 122 | HTTPServer.new(server, @listen_address, @http_port.to_i) 123 | server.listen(@listen_address, @listen_port.to_i) 124 | 125 | shutdown 126 | 127 | # wait until all client connections are closed 128 | until server.client_connections.zero? 129 | sleep 200.milliseconds 130 | end 131 | Log.info { "No clients left. Exiting." } 132 | end 133 | 134 | @first_shutdown = true 135 | 136 | def initiate_shutdown(_s : Signal) 137 | unless server = @server 138 | exit 0 139 | end 140 | if @first_shutdown 141 | @first_shutdown = false 142 | server.stop_accepting_clients 143 | else 144 | abort "Exiting with #{server.client_connections} client connections still open" 145 | end 146 | end 147 | 148 | def shutdown 149 | unless server = @server 150 | raise "Can't call shutdown before run" 151 | end 152 | if server.client_connections > 0 153 | if @term_client_close_timeout > 0 154 | wait_for_clients_to_close @term_client_close_timeout.seconds 155 | end 156 | server.disconnect_clients 157 | end 158 | 159 | if server.client_connections > 0 160 | if @term_timeout >= 0 161 | spawn do 162 | sleep @term_timeout.seconds 163 | abort "Exiting with #{server.client_connections} client connections still open" 164 | end 165 | end 166 | end 167 | end 168 | 169 | def wait_for_clients_to_close(close_timeout) 170 | unless server = @server 171 | raise "Can't call shutdown before run" 172 | end 173 | Log.info { "Waiting for clients to close their connections." } 174 | ch = Channel(Bool).new 175 | spawn do 176 | loop do 177 | ch.send true if server.client_connections.zero? 178 | sleep 100.milliseconds 179 | end 180 | rescue Channel::ClosedError 181 | end 182 | 183 | select 184 | when ch.receive? 185 | Log.info { "All clients has closed their connections." } 186 | when timeout close_timeout 187 | ch.close 188 | Log.info { "Timeout waiting for clients to close their connections." } 189 | end 190 | end 191 | 192 | struct Journal::LogFormat < ::Log::StaticFormatter 193 | def run 194 | source 195 | context(before: '[', after: ']') 196 | string ' ' 197 | message 198 | exception 199 | end 200 | end 201 | 202 | struct Stdout::LogFormat < ::Log::StaticFormatter 203 | def run 204 | timestamp 205 | severity 206 | source(before: ' ') 207 | context(before: '[', after: ']') 208 | string ' ' 209 | message 210 | exception 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /src/amqproxy/client.cr: -------------------------------------------------------------------------------- 1 | require "socket" 2 | require "amq-protocol" 3 | require "./version" 4 | require "./upstream" 5 | require "./records" 6 | 7 | module AMQProxy 8 | class Client 9 | Log = ::Log.for(self) 10 | getter credentials : Credentials 11 | @channel_map = Hash(UInt16, UpstreamChannel?).new 12 | @lock = Mutex.new 13 | @frame_max : UInt32 14 | @channel_max : UInt16 15 | @heartbeat : UInt16 16 | @last_heartbeat = Time.monotonic 17 | 18 | def initialize(@socket : TCPSocket) 19 | set_socket_options(@socket) 20 | tune_ok, @credentials = negotiate(@socket) 21 | @frame_max = tune_ok.frame_max 22 | @channel_max = tune_ok.channel_max 23 | @heartbeat = tune_ok.heartbeat 24 | end 25 | 26 | # Keep a buffer of publish frames 27 | # Only send to upstream when the full message is received 28 | @publish_buffers = Hash(UInt16, PublishBuffer).new 29 | 30 | private class PublishBuffer 31 | getter publish : AMQ::Protocol::Frame::Basic::Publish 32 | property! header : AMQ::Protocol::Frame::Header? 33 | getter bodies = Array(AMQ::Protocol::Frame::BytesBody).new 34 | 35 | def initialize(@publish) 36 | end 37 | 38 | def full? 39 | header.body_size == @bodies.sum &.body_size 40 | end 41 | end 42 | 43 | private def finish_publish(channel) 44 | buffer = @publish_buffers[channel] 45 | if upstream_channel = @channel_map[channel] 46 | upstream_channel.write(buffer.publish) 47 | upstream_channel.write(buffer.header) 48 | buffer.bodies.each do |body| 49 | upstream_channel.write(body) 50 | end 51 | end 52 | ensure 53 | @publish_buffers.delete channel 54 | end 55 | 56 | # frames from enduser 57 | def read_loop(channel_pool, socket = @socket) # ameba:disable Metrics/CyclomaticComplexity 58 | Log.context.set(client: socket.remote_address.to_s) 59 | Log.debug { "Connected" } 60 | i = 0u64 61 | socket.read_timeout = (@heartbeat / 2).ceil.seconds if @heartbeat > 0 62 | loop do 63 | frame = AMQ::Protocol::Frame.from_io(socket, IO::ByteFormat::NetworkEndian) 64 | @last_heartbeat = Time.monotonic 65 | case frame 66 | when AMQ::Protocol::Frame::Heartbeat # noop 67 | when AMQ::Protocol::Frame::Connection::CloseOk then return 68 | when AMQ::Protocol::Frame::Connection::Close 69 | close_all_upstream_channels(frame.reply_code, frame.reply_text) 70 | write AMQ::Protocol::Frame::Connection::CloseOk.new 71 | return 72 | when AMQ::Protocol::Frame::Channel::Open 73 | raise "Channel already opened" if @channel_map.has_key? frame.channel 74 | upstream_channel = channel_pool.get(DownstreamChannel.new(self, frame.channel)) 75 | @channel_map[frame.channel] = upstream_channel 76 | write AMQ::Protocol::Frame::Channel::OpenOk.new(frame.channel) 77 | when AMQ::Protocol::Frame::Channel::CloseOk 78 | # Server closed channel, CloseOk reply to server is already sent 79 | @channel_map.delete(frame.channel) 80 | when AMQ::Protocol::Frame::Basic::Publish 81 | @publish_buffers[frame.channel] = PublishBuffer.new(frame) 82 | when AMQ::Protocol::Frame::Header 83 | @publish_buffers[frame.channel].header = frame 84 | finish_publish(frame.channel) if frame.body_size.zero? 85 | when AMQ::Protocol::Frame::BytesBody 86 | buffer = @publish_buffers[frame.channel] 87 | buffer.bodies << frame 88 | finish_publish(frame.channel) if buffer.full? 89 | when frame.channel.zero? 90 | Log.error { "Unexpected connection frame: #{frame}" } 91 | close_connection(540_u16, "NOT_IMPLEMENTED", frame) 92 | else 93 | src_channel = frame.channel 94 | begin 95 | if upstream_channel = @channel_map[frame.channel] 96 | upstream_channel.write(frame) 97 | else 98 | # Channel::Close is sent, waiting for CloseOk 99 | end 100 | rescue ex : Upstream::WriteError 101 | close_channel(src_channel, 500_u16, "UPSTREAM_ERROR") 102 | rescue KeyError 103 | close_connection(504_u16, "CHANNEL_ERROR - Channel #{frame.channel} not open", frame) 104 | end 105 | end 106 | Fiber.yield if (i &+= 1) % 4096 == 0 107 | rescue ex : Upstream::AccessError 108 | Log.error { "Access refused, reason: #{ex.message}" } 109 | close_connection(403_u16, ex.message || "ACCESS_REFUSED") 110 | rescue ex : Upstream::Error 111 | Log.error(exception: ex) { "Upstream error" } 112 | close_connection(503_u16, "UPSTREAM_ERROR - #{ex.message}") 113 | rescue IO::TimeoutError 114 | time_since_last_heartbeat = (Time.monotonic - @last_heartbeat).total_seconds.to_i # ignore subsecond latency 115 | if time_since_last_heartbeat <= 1 + @heartbeat # add 1s grace because of rounding 116 | Log.debug { "Sending heartbeat (last heartbeat #{time_since_last_heartbeat}s ago)" } 117 | write AMQ::Protocol::Frame::Heartbeat.new 118 | else 119 | Log.warn { "No heartbeat response in #{time_since_last_heartbeat}s (max #{1 + @heartbeat}s), closing connection" } 120 | return 121 | end 122 | end 123 | rescue ex : IO::Error 124 | Log.debug { "Disconnected #{ex.inspect}" } 125 | else 126 | Log.debug { "Disconnected" } 127 | ensure 128 | socket.close rescue nil 129 | close_all_upstream_channels 130 | end 131 | 132 | # Send frame to client, channel id should already be remapped by the caller 133 | def write(frame : AMQ::Protocol::Frame) 134 | @lock.synchronize do 135 | case frame 136 | when AMQ::Protocol::Frame::BytesBody 137 | # Upstream might send large frames, split them to support lower client frame_max 138 | pos = 0u32 139 | while pos < frame.body_size 140 | len = Math.min(@frame_max - 8, frame.body_size - pos) 141 | body_part = AMQ::Protocol::Frame::BytesBody.new(frame.channel, len, frame.body[pos, len]) 142 | @socket.write_bytes body_part, IO::ByteFormat::NetworkEndian 143 | pos += len 144 | end 145 | else 146 | @socket.write_bytes frame, IO::ByteFormat::NetworkEndian 147 | end 148 | @socket.flush unless expect_more_frames?(frame) 149 | end 150 | case frame 151 | when AMQ::Protocol::Frame::Channel::Close 152 | @channel_map[frame.channel] = nil 153 | when AMQ::Protocol::Frame::Channel::CloseOk 154 | @channel_map.delete(frame.channel) 155 | when AMQ::Protocol::Frame::Connection::CloseOk 156 | @socket.close rescue nil 157 | end 158 | rescue ex : IO::Error 159 | # Client closed connection, suppress error 160 | @socket.close rescue nil 161 | end 162 | 163 | def close_connection(code, text, frame = nil) 164 | case frame 165 | when AMQ::Protocol::Frame::Method 166 | write AMQ::Protocol::Frame::Connection::Close.new(code, text, frame.class_id, frame.method_id) 167 | else 168 | write AMQ::Protocol::Frame::Connection::Close.new(code, text, 0_u16, 0_u16) 169 | end 170 | end 171 | 172 | def close_channel(id, code, reason) 173 | write AMQ::Protocol::Frame::Channel::Close.new(id, code, reason, 0_u16, 0_u16) 174 | end 175 | 176 | private def close_all_upstream_channels(code = 500_u16, reason = "CLIENT_DISCONNECTED") 177 | @channel_map.each_value do |upstream_channel| 178 | upstream_channel.try &.close(code, reason) 179 | rescue Upstream::WriteError 180 | Log.debug { "Upstream write error while closing client's channels" } 181 | next # Nothing to do 182 | end 183 | @channel_map.clear 184 | end 185 | 186 | private def expect_more_frames?(frame) : Bool 187 | case frame 188 | when AMQ::Protocol::Frame::Basic::Deliver then true 189 | when AMQ::Protocol::Frame::Basic::Return then true 190 | when AMQ::Protocol::Frame::Basic::GetOk then true 191 | when AMQ::Protocol::Frame::Header then frame.body_size != 0 192 | else false 193 | end 194 | end 195 | 196 | def close 197 | write AMQ::Protocol::Frame::Connection::Close.new(0_u16, 198 | "AMQProxy shutdown", 199 | 0_u16, 0_u16) 200 | # @socket.read_timeout = 1.seconds 201 | end 202 | 203 | def close_socket 204 | @socket.close rescue nil 205 | end 206 | 207 | private def set_socket_options(socket = @socket) 208 | socket.sync = false 209 | socket.keepalive = true 210 | socket.tcp_nodelay = true 211 | socket.tcp_keepalive_idle = 60 212 | socket.tcp_keepalive_count = 3 213 | socket.tcp_keepalive_interval = 10 214 | end 215 | 216 | private def negotiate(socket = @socket) 217 | proto = uninitialized UInt8[8] 218 | socket.read_fully(proto.to_slice) 219 | 220 | if proto != AMQ::Protocol::PROTOCOL_START_0_9_1 && proto != AMQ::Protocol::PROTOCOL_START_0_9 221 | socket.write AMQ::Protocol::PROTOCOL_START_0_9_1.to_slice 222 | socket.flush 223 | socket.close 224 | raise IO::EOFError.new("Invalid protocol start") 225 | end 226 | 227 | start = AMQ::Protocol::Frame::Connection::Start.new(server_properties: ServerProperties) 228 | start.to_io(socket, IO::ByteFormat::NetworkEndian) 229 | socket.flush 230 | 231 | user = password = "" 232 | start_ok = AMQ::Protocol::Frame.from_io(socket).as(AMQ::Protocol::Frame::Connection::StartOk) 233 | case start_ok.mechanism 234 | when "PLAIN" 235 | resp = start_ok.response 236 | if i = resp.index('\u0000', 1) 237 | user = resp[1...i] 238 | password = resp[(i + 1)..-1] 239 | else 240 | raise "Invalid authentication information encoding" 241 | end 242 | when "AMQPLAIN" 243 | io = IO::Memory.new(start_ok.response) 244 | tbl = AMQ::Protocol::Table.from_io(io, IO::ByteFormat::NetworkEndian, start_ok.response.size.to_u32) 245 | user = tbl["LOGIN"].as(String) 246 | password = tbl["PASSWORD"].as(String) 247 | else raise "Unsupported authentication mechanism: #{start_ok.mechanism}" 248 | end 249 | 250 | tune = AMQ::Protocol::Frame::Connection::Tune.new(frame_max: 131072_u32, channel_max: UInt16::MAX, heartbeat: 0_u16) 251 | tune.to_io(socket, IO::ByteFormat::NetworkEndian) 252 | socket.flush 253 | 254 | tune_ok = AMQ::Protocol::Frame.from_io(socket).as(AMQ::Protocol::Frame::Connection::TuneOk) 255 | 256 | open = AMQ::Protocol::Frame.from_io(socket).as(AMQ::Protocol::Frame::Connection::Open) 257 | vhost = open.vhost 258 | 259 | open_ok = AMQ::Protocol::Frame::Connection::OpenOk.new 260 | open_ok.to_io(socket, IO::ByteFormat::NetworkEndian) 261 | socket.flush 262 | 263 | {tune_ok, Credentials.new(user, password, vhost)} 264 | end 265 | 266 | ServerProperties = AMQ::Protocol::Table.new({ 267 | product: "AMQProxy", 268 | version: VERSION, 269 | capabilities: { 270 | consumer_priorities: true, 271 | exchange_exchange_bindings: true, 272 | "connection.blocked": true, 273 | authentication_failure_close: true, 274 | per_consumer_qos: true, 275 | "basic.nack": true, 276 | direct_reply_to: true, 277 | publisher_confirms: true, 278 | consumer_cancel_notify: true, 279 | }, 280 | }) 281 | 282 | class Error < Exception; end 283 | 284 | class ReadError < Error; end 285 | 286 | class WriteError < Error; end 287 | end 288 | end 289 | -------------------------------------------------------------------------------- /src/amqproxy/http_server.cr: -------------------------------------------------------------------------------- 1 | require "log" 2 | require "./prometheus_writer" 3 | require "http/server" 4 | 5 | module AMQProxy 6 | class HTTPServer 7 | Log = ::Log.for(self) 8 | 9 | def initialize(amqproxy : Server, address : String, port : Int32) 10 | @amqproxy = amqproxy 11 | @address = address 12 | @port = port 13 | @http = HTTP::Server.new do |context| 14 | case context.request.resource 15 | when "/metrics" 16 | metrics(context) 17 | when "/healthz" 18 | context.response.content_type = "text/plain" 19 | context.response.print "OK" 20 | else 21 | context.response.respond_with_status(::HTTP::Status::NOT_FOUND) 22 | end 23 | end 24 | bind_tcp 25 | spawn @http.listen, name: "HTTP Server" 26 | Log.info { "HTTP server listening on #{@address}:#{@port}" } 27 | end 28 | 29 | def bind_tcp 30 | addr = @http.bind_tcp @address, @port 31 | Log.info { "Bound to #{addr}" } 32 | end 33 | 34 | def metrics(context) 35 | writer = PrometheusWriter.new(context.response, "amqproxy") 36 | writer.write({name: "identity_info", 37 | type: "gauge", 38 | value: 1, 39 | help: "System information", 40 | labels: { 41 | "#{writer.prefix}_version" => AMQProxy::VERSION, 42 | "#{writer.prefix}_hostname" => System.hostname, 43 | }}) 44 | writer.write({name: "client_connections", 45 | value: @amqproxy.client_connections, 46 | type: "gauge", 47 | help: "Number of client connections"}) 48 | writer.write({name: "upstream_connections", 49 | value: @amqproxy.upstream_connections, 50 | type: "gauge", 51 | help: "Number of upstream connections"}) 52 | 53 | context.response.status = ::HTTP::Status::OK 54 | end 55 | 56 | def close 57 | @http.try &.close 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /src/amqproxy/prometheus_writer.cr: -------------------------------------------------------------------------------- 1 | class PrometheusWriter 2 | alias MetricValue = UInt16 | Int32 | UInt32 | UInt64 | Int64 | Float64 3 | alias MetricLabels = Hash(String, String) | 4 | NamedTuple(name: String) | 5 | NamedTuple(channel: String) | 6 | NamedTuple(id: String) | 7 | NamedTuple(queue: String, vhost: String) 8 | alias Metric = NamedTuple(name: String, value: MetricValue) | 9 | NamedTuple(name: String, value: MetricValue, labels: MetricLabels) | 10 | NamedTuple(name: String, value: MetricValue, help: String) | 11 | NamedTuple(name: String, value: MetricValue, type: String, help: String) | 12 | NamedTuple(name: String, value: MetricValue, help: String, labels: MetricLabels) | 13 | NamedTuple(name: String, value: MetricValue, type: String, help: String, labels: MetricLabels) 14 | 15 | getter prefix 16 | 17 | def initialize(@io : IO, @prefix : String) 18 | end 19 | 20 | private def write_labels(io, labels) 21 | first = true 22 | io << "{" 23 | labels.each do |k, v| 24 | io << ", " unless first 25 | io << k << "=\"" << v << "\"" 26 | first = false 27 | end 28 | io << "}" 29 | end 30 | 31 | def write(m : Metric) 32 | return if m[:value].nil? 33 | io = @io 34 | name = "#{@prefix}_#{m[:name]}" 35 | if t = m[:type]? 36 | io << "# TYPE " << name << " " << t << "\n" 37 | end 38 | if h = m[:help]? 39 | io << "# HELP " << name << " " << h << "\n" 40 | end 41 | io << name 42 | if l = m[:labels]? 43 | write_labels(io, l) 44 | end 45 | io << " " << m[:value] << "\n" 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /src/amqproxy/records.cr: -------------------------------------------------------------------------------- 1 | require "./upstream" 2 | require "./client" 3 | 4 | module AMQProxy 5 | record UpstreamChannel, upstream : Upstream, channel : UInt16 do 6 | def write(frame) 7 | frame.channel = @channel 8 | @upstream.write frame 9 | end 10 | 11 | def close(code, reason) 12 | @upstream.close_channel(@channel, code, reason) 13 | end 14 | end 15 | 16 | record DownstreamChannel, client : Client, channel : UInt16 do 17 | def write(frame) 18 | frame.channel = @channel 19 | @client.write(frame) 20 | end 21 | 22 | def close(code, reason) 23 | @client.close_channel(@channel, code, reason) 24 | end 25 | end 26 | 27 | record Credentials, user : String, password : String, vhost : String 28 | end 29 | 30 | # Be able to overwrite channel id 31 | module AMQ 32 | module Protocol 33 | abstract struct Frame 34 | setter channel 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /src/amqproxy/server.cr: -------------------------------------------------------------------------------- 1 | require "socket" 2 | require "log" 3 | require "amq-protocol" 4 | require "uri" 5 | require "./channel_pool" 6 | require "./client" 7 | require "./upstream" 8 | 9 | module AMQProxy 10 | class Server 11 | Log = ::Log.for(self) 12 | @clients_lock = Mutex.new 13 | @clients = Array(Client).new 14 | 15 | def self.new(url : URI) 16 | tls = url.scheme == "amqps" 17 | host = url.host || "127.0.0.1" 18 | port = url.port || 5762 19 | port = 5671 if tls && url.port.nil? 20 | idle_connection_timeout = url.query_params.fetch("idle_connection_timeout", 5).to_i 21 | new(host, port, tls, idle_connection_timeout) 22 | end 23 | 24 | def initialize(upstream_host, upstream_port, upstream_tls, idle_connection_timeout = 5) 25 | tls_ctx = OpenSSL::SSL::Context::Client.new if upstream_tls 26 | @channel_pools = Hash(Credentials, ChannelPool).new do |hash, credentials| 27 | hash[credentials] = ChannelPool.new(upstream_host, upstream_port, tls_ctx, credentials, idle_connection_timeout) 28 | end 29 | Log.info { "Proxy upstream: #{upstream_host}:#{upstream_port} #{upstream_tls ? "TLS" : ""}" } 30 | end 31 | 32 | def client_connections 33 | @clients.size 34 | end 35 | 36 | def upstream_connections 37 | @channel_pools.each_value.sum &.connections 38 | end 39 | 40 | def listen(address, port) 41 | listen(TCPServer.new(address, port)) 42 | end 43 | 44 | def listen(@server : TCPServer) 45 | Log.info { "Proxy listening on #{server.local_address}" } 46 | while socket = server.accept? 47 | begin 48 | addr = socket.remote_address 49 | spawn handle_connection(socket, addr), name: "Client#read_loop #{addr}" 50 | rescue IO::Error 51 | next 52 | end 53 | end 54 | Log.info { "Proxy stopping accepting connections" } 55 | end 56 | 57 | def stop_accepting_clients 58 | @server.try &.close 59 | end 60 | 61 | def disconnect_clients 62 | Log.info { "Disconnecting clients" } 63 | @clients_lock.synchronize do 64 | @clients.each &.close # send Connection#Close frames 65 | end 66 | end 67 | 68 | def close_sockets 69 | Log.info { "Closing client sockets" } 70 | @clients_lock.synchronize do 71 | @clients.each &.close_socket # close sockets forcefully 72 | end 73 | end 74 | 75 | private def handle_connection(socket, remote_address) 76 | c = Client.new(socket) 77 | active_client(c) do 78 | channel_pool = @channel_pools[c.credentials] 79 | c.read_loop(channel_pool) 80 | end 81 | rescue IO::EOFError 82 | # Client closed connection before/while negotiating 83 | rescue ex # only raise from constructor, when negotating 84 | Log.debug(exception: ex) { "Client negotiation failure (#{remote_address}) #{ex.inspect}" } 85 | ensure 86 | socket.close rescue nil 87 | end 88 | 89 | private def active_client(client, &) 90 | @clients_lock.synchronize do 91 | @clients << client 92 | end 93 | yield client 94 | ensure 95 | @clients_lock.synchronize do 96 | @clients.delete client 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /src/amqproxy/token_bucket.cr: -------------------------------------------------------------------------------- 1 | module AMQProxy 2 | class TokenBucket 3 | def initialize(@length : Int32, @interval : Time::Span) 4 | @bucket = Channel::Buffered(Nil).new(@length) 5 | spawn refill_periodically 6 | end 7 | 8 | def receive 9 | @bucket.receive 10 | end 11 | 12 | private def refill_periodically 13 | loop do 14 | refill 15 | sleep @interval 16 | end 17 | end 18 | 19 | private def refill 20 | @length.times do 21 | break if @bucket.full? 22 | @bucket.send nil 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /src/amqproxy/upstream.cr: -------------------------------------------------------------------------------- 1 | require "socket" 2 | require "openssl" 3 | require "log" 4 | require "./client" 5 | require "./channel_pool" 6 | 7 | module AMQProxy 8 | class Upstream 9 | Log = ::Log.for(self) 10 | @socket : IO 11 | @channels = Hash(UInt16, DownstreamChannel).new 12 | @channels_lock = Mutex.new 13 | @channel_max : UInt16 14 | @lock = Mutex.new 15 | @remote_address : String 16 | @channel_max : UInt16 17 | @frame_max : UInt32 18 | 19 | def initialize(@host : String, @port : Int32, @tls_ctx : OpenSSL::SSL::Context::Client?, credentials) 20 | tcp_socket = TCPSocket.new(@host, @port) 21 | tcp_socket.sync = false 22 | tcp_socket.keepalive = true 23 | tcp_socket.tcp_keepalive_idle = 60 24 | tcp_socket.tcp_keepalive_count = 3 25 | tcp_socket.tcp_keepalive_interval = 10 26 | tcp_socket.tcp_nodelay = true 27 | @remote_address = tcp_socket.remote_address.to_s 28 | @socket = 29 | if tls_ctx = @tls_ctx 30 | tls_socket = OpenSSL::SSL::Socket::Client.new(tcp_socket, tls_ctx, hostname: @host) 31 | tls_socket.sync_close = true 32 | tls_socket 33 | else 34 | tcp_socket 35 | end 36 | tune_ok = start(credentials) 37 | @channel_max = tune_ok.channel_max 38 | @frame_max = tune_ok.frame_max 39 | end 40 | 41 | def open_channel_for(downstream_channel : DownstreamChannel) : UpstreamChannel 42 | upstream_channel = create_upstream_channel(downstream_channel) 43 | send AMQ::Protocol::Frame::Channel::Open.new(upstream_channel.channel) 44 | upstream_channel 45 | end 46 | 47 | private def create_upstream_channel(downstream_channel : DownstreamChannel) 48 | @channels_lock.synchronize do 49 | 1_u16.upto(@channel_max) do |i| 50 | unless @channels.has_key?(i) 51 | @channels[i] = downstream_channel 52 | return UpstreamChannel.new(self, i) 53 | end 54 | end 55 | raise ChannelMaxReached.new 56 | end 57 | end 58 | 59 | def close_channel(id, code, reason) 60 | send AMQ::Protocol::Frame::Channel::Close.new(id, code, reason, 0_u16, 0_u16) 61 | end 62 | 63 | def channels 64 | @channels.size 65 | end 66 | 67 | # Frames from upstream (to client) 68 | def read_loop(socket = @socket) # ameba:disable Metrics/CyclomaticComplexity 69 | Log.context.set(upstream: @remote_address) 70 | i = 0u64 71 | loop do 72 | case frame = AMQ::Protocol::Frame.from_io(socket, IO::ByteFormat::NetworkEndian) 73 | when AMQ::Protocol::Frame::Heartbeat then send frame 74 | when AMQ::Protocol::Frame::Connection::Close 75 | Log.error { "Upstream closed connection: #{frame.reply_text} #{frame.reply_code}" } 76 | close_all_client_channels(frame.reply_code, frame.reply_text) 77 | begin 78 | send AMQ::Protocol::Frame::Connection::CloseOk.new 79 | rescue WriteError 80 | end 81 | return 82 | when AMQ::Protocol::Frame::Connection::CloseOk then return 83 | when AMQ::Protocol::Frame::Connection::Blocked, 84 | AMQ::Protocol::Frame::Connection::Unblocked 85 | send_to_all_clients(frame) 86 | when AMQ::Protocol::Frame::Channel::OpenOk # assume it always succeeds 87 | when AMQ::Protocol::Frame::Channel::Close 88 | send AMQ::Protocol::Frame::Channel::CloseOk.new(frame.channel) 89 | if downstream_channel = @channels_lock.synchronize { @channels.delete(frame.channel) } 90 | downstream_channel.write frame 91 | end 92 | when AMQ::Protocol::Frame::Channel::CloseOk # when client requested channel close 93 | if downstream_channel = @channels_lock.synchronize { @channels.delete(frame.channel) } 94 | downstream_channel.write(frame) 95 | end 96 | else 97 | if downstream_channel = @channels_lock.synchronize { @channels[frame.channel]? } 98 | downstream_channel.write(frame) 99 | else 100 | Log.debug { "Frame for unmapped channel from upstream: #{frame}" } 101 | end 102 | end 103 | Fiber.yield if (i &+= 1) % 4096 == 0 104 | end 105 | rescue ex : IO::Error | OpenSSL::SSL::Error 106 | Log.info { "Connection error #{ex.inspect}" } unless socket.closed? 107 | ensure 108 | socket.close rescue nil 109 | close_all_client_channels 110 | end 111 | 112 | def closed? 113 | @socket.closed? 114 | end 115 | 116 | private def close_all_client_channels(code = 500_u16, reason = "UPSTREAM_ERROR") 117 | @channels_lock.synchronize do 118 | return if @channels.empty? 119 | Log.debug { "Upstream connection closed, closing #{@channels.size} client channels" } 120 | @channels.each_value do |downstream_channel| 121 | downstream_channel.close(code, reason) 122 | end 123 | @channels.clear 124 | end 125 | end 126 | 127 | private def send_to_all_clients(frame : AMQ::Protocol::Frame::Connection) 128 | Log.debug { "Sending broadcast frame to all client connections" } 129 | clients = Set(Client).new 130 | @channels_lock.synchronize do 131 | @channels.each_value do |downstream_channel| 132 | clients << downstream_channel.client 133 | end 134 | end 135 | clients.each do |client| 136 | client.write frame 137 | end 138 | end 139 | 140 | # Forward frames from client to upstream 141 | def write(frame : AMQ::Protocol::Frame) : Nil 142 | case frame 143 | when AMQ::Protocol::Frame::Connection 144 | raise "Connection frames should not be sent through here: #{frame}" 145 | when AMQ::Protocol::Frame::Channel::CloseOk 146 | # when upstream server requested a channel close and client confirmed 147 | @channels_lock.synchronize do 148 | @channels.delete(frame.channel) 149 | end 150 | end 151 | send frame 152 | end 153 | 154 | private def send(frame : AMQ::Protocol::Frame) : Nil 155 | @lock.synchronize do 156 | @socket.write_bytes frame, IO::ByteFormat::NetworkEndian 157 | @socket.flush unless expect_more_publish_frames?(frame) 158 | rescue ex : IO::Error | OpenSSL::SSL::Error 159 | @socket.close rescue nil 160 | raise WriteError.new "Error writing to upstream", ex 161 | end 162 | end 163 | 164 | private def expect_more_publish_frames?(frame) : Bool 165 | case frame 166 | when AMQ::Protocol::Frame::Basic::Publish then true 167 | when AMQ::Protocol::Frame::Header then frame.body_size != 0 168 | else false 169 | end 170 | end 171 | 172 | def close(reason = "") 173 | @lock.synchronize do 174 | close = AMQ::Protocol::Frame::Connection::Close.new(200_u16, reason, 0_u16, 0_u16) 175 | close.to_io(@socket, IO::ByteFormat::NetworkEndian) 176 | @socket.flush 177 | rescue ex : IO::Error | OpenSSL::SSL::Error 178 | @socket.close 179 | raise WriteError.new "Error writing Connection#Close to upstream", ex 180 | end 181 | end 182 | 183 | def closed? 184 | @socket.closed? 185 | end 186 | 187 | private def start(credentials) : AMQ::Protocol::Frame::Connection::TuneOk 188 | @socket.write AMQ::Protocol::PROTOCOL_START_0_9_1.to_slice 189 | @socket.flush 190 | 191 | # assert correct frame type 192 | AMQ::Protocol::Frame.from_io(@socket).as(AMQ::Protocol::Frame::Connection::Start) 193 | 194 | response = "\u0000#{credentials.user}\u0000#{credentials.password}" 195 | start_ok = AMQ::Protocol::Frame::Connection::StartOk.new(response: response, client_properties: ClientProperties, mechanism: "PLAIN", locale: "en_US") 196 | @socket.write_bytes start_ok, IO::ByteFormat::NetworkEndian 197 | @socket.flush 198 | 199 | case tune = AMQ::Protocol::Frame.from_io(@socket) 200 | when AMQ::Protocol::Frame::Connection::Tune 201 | channel_max = tune.channel_max.zero? ? UInt16::MAX : tune.channel_max 202 | frame_max = tune.frame_max.zero? ? 131072_u32 : Math.min(131072_u32, tune.frame_max) 203 | tune_ok = AMQ::Protocol::Frame::Connection::TuneOk.new(channel_max, frame_max, tune.heartbeat) 204 | @socket.write_bytes tune_ok, IO::ByteFormat::NetworkEndian 205 | @socket.flush 206 | when AMQ::Protocol::Frame::Connection::Close 207 | send_close_ok 208 | raise AccessError.new tune.reply_text 209 | else 210 | raise "Unexpected frame on connection to upstream: #{tune}" 211 | end 212 | 213 | open = AMQ::Protocol::Frame::Connection::Open.new(vhost: credentials.vhost) 214 | @socket.write_bytes open, IO::ByteFormat::NetworkEndian 215 | @socket.flush 216 | 217 | case f = AMQ::Protocol::Frame.from_io(@socket, IO::ByteFormat::NetworkEndian) 218 | when AMQ::Protocol::Frame::Connection::OpenOk 219 | when AMQ::Protocol::Frame::Connection::Close 220 | send_close_ok 221 | raise AccessError.new f.reply_text 222 | else 223 | raise "Unexpected frame on connection to upstream: #{f}" 224 | end 225 | tune_ok 226 | rescue ex : AccessError 227 | raise ex 228 | rescue ex 229 | @socket.close 230 | raise Error.new ex.message, cause: ex 231 | end 232 | 233 | private def send_close_ok 234 | @socket.write_bytes AMQ::Protocol::Frame::Connection::CloseOk.new, IO::ByteFormat::NetworkEndian 235 | @socket.flush 236 | @socket.close 237 | end 238 | 239 | ClientProperties = AMQ::Protocol::Table.new({ 240 | connection_name: System.hostname, 241 | product: "AMQProxy", 242 | version: VERSION, 243 | capabilities: { 244 | consumer_priorities: true, 245 | exchange_exchange_bindings: true, 246 | "connection.blocked": true, 247 | authentication_failure_close: true, 248 | per_consumer_qos: true, 249 | "basic.nack": true, 250 | direct_reply_to: true, 251 | publisher_confirms: true, 252 | consumer_cancel_notify: true, 253 | }, 254 | }) 255 | 256 | class Error < Exception; end 257 | 258 | class AccessError < Error; end 259 | 260 | class WriteError < Error; end 261 | 262 | class ChannelMaxReached < Error; end 263 | end 264 | end 265 | -------------------------------------------------------------------------------- /src/amqproxy/version.cr: -------------------------------------------------------------------------------- 1 | module AMQProxy 2 | VERSION = {{ `shards version`.stringify.chomp }} 3 | end 4 | -------------------------------------------------------------------------------- /tar.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM 84codes/crystal:1.15.1-alpine AS builder 2 | WORKDIR /usr/src/amqproxy 3 | COPY Makefile shard.yml shard.lock ./ 4 | RUN shards install --production 5 | COPY src/ src/ 6 | ARG CRYSTAL_FLAGS="--static --release" 7 | RUN make bin/amqproxy && mkdir amqproxy && mv bin/amqproxy amqproxy/ 8 | COPY README.md LICENSE extras/amqproxy.service amqproxy/ 9 | COPY config/example.ini amqproxy/amqproxy.ini 10 | ARG TARGETARCH 11 | RUN tar zcvf amqproxy-$(shards version)_static-$TARGETARCH.tar.gz amqproxy/ 12 | 13 | FROM scratch 14 | COPY --from=builder /usr/src/amqproxy/*.tar.gz . 15 | -------------------------------------------------------------------------------- /test/integration-php.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | set -e 5 | 6 | # --force-recreate and --renew-anon-volumes needed to start fresh every time 7 | # otherwise broker datadir volume may be re-used 8 | 9 | docker compose \ 10 | --file test/integration-php/docker-compose.yml \ 11 | up \ 12 | --remove-orphans \ 13 | --force-recreate \ 14 | --renew-anon-volumes \ 15 | --build \ 16 | --exit-code-from php-amqp 17 | -------------------------------------------------------------------------------- /test/integration-php/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | configs: 3 | toxiproxy_config: 4 | file: ./toxiproxy.json 5 | services: 6 | amqproxy: 7 | build: ../../ 8 | expose: 9 | - "5673" 10 | command: ["--debug", "amqp://toxiproxy:7777"] 11 | depends_on: 12 | toxiproxy: 13 | condition: service_started 14 | 15 | toxiproxy: 16 | image: ghcr.io/shopify/toxiproxy:latest 17 | expose: 18 | - "8474" # Toxiproxy HTTP API 19 | - "7777" # Expose AMQP broker on this port 20 | command: ["-host=0.0.0.0", "-config=/toxiproxy_config"] 21 | configs: 22 | - toxiproxy_config 23 | depends_on: 24 | rabbitmq: 25 | condition: service_healthy 26 | 27 | rabbitmq: 28 | image: rabbitmq:latest 29 | expose: 30 | - "5672" 31 | healthcheck: 32 | test: ["CMD-SHELL", "rabbitmqctl status"] 33 | interval: 2s 34 | retries: 10 35 | start_period: 20s 36 | 37 | php-amqp: 38 | build: ./php-amqp 39 | environment: 40 | TEST_RABBITMQ_HOST: amqproxy 41 | TEST_RABBITMQ_PORT: 5673 42 | # Makes this container exit with error if the test script hangs (timeout) 43 | command: ["timeout", "15s", "php", "-d", "extension=amqp.so", "get-test.php"] 44 | depends_on: 45 | amqproxy: 46 | condition: service_started 47 | 48 | toxiproxy-cli: 49 | # Need to build our own image as the toxiproxy image has no shell 50 | build: ./toxiproxy-cli 51 | environment: 52 | TOXIPROXY_URL: "http://toxiproxy:8474" 53 | # The PHP client needs to start and connect before we add the toxiproxy toxic 54 | # that will break the amqproxy upstream connection, depends_on makes this happen 55 | depends_on: 56 | php-amqp: 57 | condition: service_started 58 | -------------------------------------------------------------------------------- /test/integration-php/php-amqp/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.2-cli 2 | WORKDIR /app 3 | 4 | RUN apt-get update -q \ 5 | && apt-get install -qq cmake libssl-dev git unzip \ 6 | && rm -rf /var/lib/apt/lists/* 7 | 8 | # Install librabbitmq (https://github.com/alanxz/rabbitmq-c) 9 | RUN \ 10 | curl --location --silent --output /tmp/rabbitmq-c.tar.gz https://github.com/alanxz/rabbitmq-c/archive/v0.10.0.tar.gz \ 11 | && mkdir -p /tmp/rabbitmq-c/build \ 12 | && tar --gunzip --extract --strip-components 1 --directory /tmp/rabbitmq-c --file /tmp/rabbitmq-c.tar.gz \ 13 | && cd /tmp/rabbitmq-c/build \ 14 | && cmake -DBUILD_EXAMPLES=OFF -DBUILD_TESTS=OFF -DBUILD_TOOLS=OFF -DENABLE_SSL_SUPPORT=ON .. \ 15 | && cmake --build . --target install \ 16 | && ln -s /usr/local/lib/x86_64-linux-gnu/librabbitmq.so.4 /usr/local/lib/ 17 | 18 | # Install php-amqp (https://github.com/php-amqp/php-amqp) 19 | RUN echo /usr/local | pecl install amqp 20 | 21 | COPY *.php ./ 22 | -------------------------------------------------------------------------------- /test/integration-php/php-amqp/get-test.php: -------------------------------------------------------------------------------- 1 | setHost(HOST); 11 | $connection->setPort(PORT); 12 | $connection->setLogin(USER); 13 | $connection->setPassword(PASS); 14 | $connection->connect(); 15 | 16 | $channel = new AMQPChannel($connection); 17 | 18 | $exchange_name = "test-ex"; 19 | $exchange = new AMQPExchange($channel); 20 | $exchange->setType(AMQP_EX_TYPE_FANOUT); 21 | $exchange->setName($exchange_name); 22 | $exchange->declareExchange(); 23 | 24 | $queue = new AMQPQueue($channel); 25 | $queue->setName("test-q"); 26 | $queue->declareQueue(); 27 | $queue->bind($exchange_name,$queue->getName()); 28 | 29 | $i = 1; 30 | $exit_code = 1; // exception should be raised if the test setup works correctly 31 | 32 | while ($i <= ATTEMPTS) { 33 | echo "Getting messages, attempt #", $i, PHP_EOL; 34 | try { 35 | $queue->get(AMQP_AUTOACK); 36 | sleep(1); 37 | } catch(Exception $e) { 38 | $exit_code = 0; 39 | echo "Caught exception: ", get_class($e), ": ", $e->getMessage(), PHP_EOL; 40 | break; 41 | } 42 | $i++; 43 | } 44 | 45 | if($exit_code == 1) { 46 | echo "FAIL! Exception should be raised when the test setup works correctly", PHP_EOL; 47 | } else { 48 | echo "SUCCESS! Exception was raised.", PHP_EOL; 49 | } 50 | echo "Exiting with exit code: ", $exit_code, PHP_EOL; 51 | exit($exit_code); 52 | -------------------------------------------------------------------------------- /test/integration-php/toxiproxy-cli/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | COPY --from=ghcr.io/shopify/toxiproxy:latest ./toxiproxy-cli /toxiproxy-cli 3 | COPY ./entrypoint.sh /entrypoint.sh 4 | RUN chmod +x /entrypoint.sh 5 | ENTRYPOINT ["/entrypoint.sh"] 6 | -------------------------------------------------------------------------------- /test/integration-php/toxiproxy-cli/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -x 4 | 5 | /toxiproxy-cli toxic \ 6 | add \ 7 | --type reset_peer \ 8 | --attribute timeout=2000 \ 9 | --toxicName reset_peer between-proxy-and-broker 10 | 11 | echo "Toxic added, sleeping..." 12 | 13 | # We sleep so the container keeps running as otherwise all containers would stop 14 | # as we are using --exit-code-from (implies --abort-on-container-exit) 15 | sleep 900 16 | -------------------------------------------------------------------------------- /test/integration-php/toxiproxy.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "between-proxy-and-broker", 4 | "listen": "0.0.0.0:7777", 5 | "upstream": "rabbitmq:5672", 6 | "enabled": true 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /test/many_conns.cr: -------------------------------------------------------------------------------- 1 | require "amqp-client" 2 | 3 | abort "Usage: #{PROGRAM_NAME} " if ARGV.size != 3 4 | url, conns, msgs = ARGV 5 | puts "Publishing #{msgs} msgs on #{conns} connections" 6 | done = Channel(Nil).new 7 | conns.to_i.times do |_idx| 8 | spawn do 9 | AMQP::Client.start(url) do |c| 10 | c.channel do |ch| 11 | q = ch.queue("test") 12 | msgs.to_i.times do |msg_idx| 13 | q.publish "msg #{msg_idx}" 14 | end 15 | end 16 | end 17 | ensure 18 | done.send nil 19 | end 20 | end 21 | 22 | conns.to_i.times do 23 | done.receive? 24 | end 25 | -------------------------------------------------------------------------------- /test/reconnect.cr: -------------------------------------------------------------------------------- 1 | require "amqp-client" 2 | 3 | abort "Usage: #{PROGRAM_NAME} " if ARGV.size != 2 4 | url, msgs = ARGV 5 | puts "Publishing #{msgs} msgs" 6 | msgs.to_i.times do |idx| 7 | AMQP::Client.start(url) do |c| 8 | c.channel do |ch| 9 | q = ch.queue("test") 10 | q.publish "msg #{idx}" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/throughput.cr: -------------------------------------------------------------------------------- 1 | require "amqp-client" 2 | 3 | abort "Usage: #{PROGRAM_NAME} " if ARGV.size != 2 4 | url, msgs = ARGV 5 | puts "Publishing #{msgs} msgs on one connection" 6 | AMQP::Client.start(url) do |c| 7 | c.channel do |ch| 8 | q = ch.queue("test") 9 | msgs.to_i.times do |idx| 10 | q.publish "msg #{idx}" 11 | end 12 | end 13 | end 14 | --------------------------------------------------------------------------------