├── .dockerignore ├── .editorconfig ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── ci-pipeline.yaml ├── .gitignore ├── Dockerfile ├── Dockerfile-alpine ├── Dockerfile-nanoserver ├── LICENSE ├── README.md ├── build-images-linux.sh ├── build-images-windows.sh ├── build.sh ├── caddyfile ├── caddyfile.go ├── fromlabels.go ├── fromlabels_test.go ├── lexer.go ├── marshal.go ├── marshal_test.go ├── merge.go ├── merge_test.go ├── processor.go ├── processor_test.go └── testdata │ ├── labels │ ├── follow_alphabetical_order.txt │ ├── global_options.txt │ ├── global_options_comes_first.txt │ ├── grouping.txt │ ├── isolate_directives_with_suffix.txt │ ├── matchers_come_first.txt │ ├── one_line_matchers_come_first.txt │ ├── order_and_isolate_directives_with_prefix.txt │ ├── quotes.txt │ ├── snippets_come_first.txt │ ├── template_error.txt │ ├── templates_empty_values.txt │ └── wildcard_certificates.txt │ ├── marshal │ └── marshal.txt │ ├── merge │ ├── php_fastcgi_different_matcher.txt │ ├── php_fastcgi_no_matcher.txt │ ├── php_fastcgi_same_matcher.txt │ ├── reverse_proxy_different_matcher.txt │ ├── reverse_proxy_no_matcher.txt │ └── reverse_proxy_same_matcher.txt │ └── process │ ├── blank.txt │ ├── empty.txt │ ├── invalid_block.txt │ └── invalid_file.txt ├── cmd.go ├── config └── options.go ├── docker ├── client.go ├── client_mock.go ├── utils.go ├── utils_mock.go └── utils_test.go ├── examples ├── Caddyfile ├── distributed.yaml └── standalone.yaml ├── generator ├── containers.go ├── containers_test.go ├── generator.go ├── generator_test.go ├── labels.go ├── labels_test.go ├── services.go ├── services_test.go └── testdata │ └── labels │ ├── all_special_labels.txt │ ├── doesnt_override_existing_proxy.txt │ ├── h2c_reverse_proxy.txt │ ├── invalid_template.txt │ ├── minimum_special_labels.txt │ ├── multiple_addresses.txt │ ├── multiple_configs.txt │ ├── reverse_proxy_directives_are_moved_into_route.txt │ └── with_groups.txt ├── go.mod ├── go.sum ├── loader.go ├── module.go ├── run-docker-tests-linux.sh ├── run-docker-tests-windows.sh ├── tests ├── caddyfile+config │ ├── CaddyfileConfig │ ├── compose.yaml │ ├── config │ │ ├── .gitignore │ │ └── Caddyfile │ └── run.sh ├── containers │ └── run.sh ├── distributed │ ├── compose.yaml │ └── run.sh ├── empty │ ├── compose.yaml │ └── run.sh ├── envfile │ ├── Caddyfile │ ├── Envfile │ ├── compose.yaml │ └── run.sh ├── functions.sh ├── ingress-networks │ ├── compose.yaml │ └── run.sh ├── process-caddyfile-off │ ├── compose_correct.yaml │ ├── compose_wrong.yaml │ └── run.sh ├── process-caddyfile-on │ ├── compose_wrong.yaml │ └── run.sh ├── run.sh └── standalone │ ├── compose.yaml │ └── run.sh └── utils ├── stringBoolCMap.go └── stringInt64CMap.go /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !artifacts -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{txt,md}] 4 | indent_style = tab 5 | indent_size = 4 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: lucaslorentz 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 5 -------------------------------------------------------------------------------- /.github/workflows/ci-pipeline.yaml: -------------------------------------------------------------------------------- 1 | name: CI Pipeline 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | tags: 8 | - '*' 9 | pull_request: 10 | 11 | jobs: 12 | build_binaries: 13 | name: Build Binaries 14 | runs-on: ubuntu-24.04 15 | 16 | steps: 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: 1.22.8 21 | 22 | - name: Checkout Code 23 | uses: actions/checkout@v4 24 | 25 | - name: Build 26 | run: | 27 | export PATH="$GOBIN:$PATH" 28 | . build.sh 29 | env: 30 | ARTIFACTS: ${{ runner.temp }}/artifacts 31 | shell: bash 32 | 33 | - name: Upload Build Artifact 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: binaries 37 | path: ${{ runner.temp }}/artifacts/binaries 38 | 39 | linux: 40 | name: Linux 41 | runs-on: ubuntu-24.04 42 | needs: build_binaries 43 | 44 | steps: 45 | - name: Checkout Code 46 | uses: actions/checkout@v4 47 | 48 | - name: Download Build Artifacts 49 | uses: actions/download-artifact@v4 50 | with: 51 | name: binaries 52 | path: artifacts/binaries 53 | 54 | - name: Run Docker Tests 55 | run: . run-docker-tests-linux.sh 56 | shell: bash 57 | 58 | - name: Build Docker Images 59 | run: ./build-images-linux.sh 60 | env: 61 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 62 | shell: bash 63 | 64 | windows: 65 | name: Windows 66 | runs-on: windows-2022 67 | needs: build_binaries 68 | 69 | steps: 70 | - name: Checkout Code 71 | uses: actions/checkout@v4 72 | 73 | - name: Download Build Artifacts 74 | uses: actions/download-artifact@v4 75 | with: 76 | name: binaries 77 | path: artifacts/binaries 78 | 79 | # Uncomment this step if needed once windows docker tests are implemented 80 | # - name: Run Docker Tests (Windows) 81 | # run: .\run-docker-tests-windows.sh 82 | # shell: bash 83 | 84 | - name: Build Docker Images 85 | run: ./build-images-windows.sh 86 | env: 87 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 88 | shell: bash 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | artifacts 2 | vendor 3 | debug.test 4 | local 5 | .DS_Store 6 | buildenv_* -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=${BUILDPLATFORM} alpine:3.20.3 as alpine 2 | RUN apk add -U --no-cache ca-certificates 3 | 4 | # Image starts here 5 | FROM scratch 6 | ARG TARGETPLATFORM 7 | LABEL maintainer "Lucas Lorentz " 8 | 9 | EXPOSE 80 443 2019 10 | ENV XDG_CONFIG_HOME /config 11 | ENV XDG_DATA_HOME /data 12 | 13 | WORKDIR / 14 | 15 | COPY --from=alpine /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 16 | 17 | COPY artifacts/binaries/$TARGETPLATFORM/caddy /bin/ 18 | 19 | ENTRYPOINT ["/bin/caddy"] 20 | 21 | CMD ["docker-proxy"] -------------------------------------------------------------------------------- /Dockerfile-alpine: -------------------------------------------------------------------------------- 1 | FROM alpine:3.20.3 as alpine 2 | ARG TARGETPLATFORM 3 | LABEL maintainer "Lucas Lorentz " 4 | 5 | EXPOSE 80 443 2019 6 | ENV XDG_CONFIG_HOME /config 7 | ENV XDG_DATA_HOME /data 8 | 9 | RUN apk add -U --no-cache ca-certificates curl 10 | 11 | COPY artifacts/binaries/$TARGETPLATFORM/caddy /bin/ 12 | 13 | ENTRYPOINT ["/bin/caddy"] 14 | 15 | CMD ["docker-proxy"] -------------------------------------------------------------------------------- /Dockerfile-nanoserver: -------------------------------------------------------------------------------- 1 | ARG SERVERCORE_VERSION 2 | ARG NANOSERVER_VERSION 3 | 4 | FROM mcr.microsoft.com/windows/servercore:${SERVERCORE_VERSION} as core 5 | 6 | # Image starts here 7 | FROM mcr.microsoft.com/windows/nanoserver:${NANOSERVER_VERSION} 8 | ARG TARGETPLATFORM 9 | LABEL maintainer "Lucas Lorentz " 10 | 11 | EXPOSE 80 443 2019 12 | ENV XDG_CONFIG_HOME c:/config 13 | ENV XDG_DATA_HOME c:/data 14 | 15 | COPY --from=core /windows/system32/netapi32.dll /windows/system32/netapi32.dll 16 | 17 | COPY artifacts/binaries/${TARGETPLATFORM}/caddy.exe "C:\\caddy.exe" 18 | 19 | ENTRYPOINT ["C:\\caddy.exe"] 20 | 21 | CMD ["docker-proxy"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Lucas Lorentz Lara 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Caddy-Docker-Proxy 2 | [![Build Status](https://dev.azure.com/lucaslorentzlara/lucaslorentzlara/_apis/build/status/lucaslorentz.caddy-docker-proxy?branchName=master)](https://dev.azure.com/lucaslorentzlara/lucaslorentzlara/_build/latest?definitionId=1) [![Go Report Card](https://goreportcard.com/badge/github.com/lucaslorentz/caddy-docker-proxy)](https://goreportcard.com/report/github.com/lucaslorentz/caddy-docker-proxy) 3 | 4 | ## Introduction 5 | This plugin enables Caddy to be used as a reverse proxy for Docker containers via labels. 6 | 7 | ## How does it work? 8 | The plugin scans Docker metadata, looking for labels indicating that the service or container should be served by Caddy. 9 | 10 | Then, it generates an in-memory Caddyfile with site entries and proxies pointing to each Docker service by their DNS name or container IP. 11 | 12 | Every time a Docker object changes, the plugin updates the Caddyfile and triggers Caddy to gracefully reload, with zero-downtime. 13 | 14 | ## Table of contents 15 | 16 | * [Basic usage example, using docker-compose](#basic-usage-example-using-docker-compose) 17 | * [Labels to Caddyfile conversion](#labels-to-caddyfile-conversion) 18 | + [Tokens and arguments](#tokens-and-arguments) 19 | + [Ordering and isolation](#ordering-and-isolation) 20 | + [Sites, snippets and global options](#sites-snippets-and-global-options) 21 | + [Go templates](#go-templates) 22 | * [Template functions](#template-functions) 23 | + [upstreams](#upstreams) 24 | * [Examples](#examples) 25 | * [Docker configs](#docker-configs) 26 | * [Proxying services vs containers](#proxying-services-vs-containers) 27 | + [Services](#services) 28 | + [Containers](#containers) 29 | * [Execution modes](#execution-modes) 30 | + [Server](#server) 31 | + [Controller](#controller) 32 | + [Standalone (default)](#standalone-default) 33 | * [Caddy CLI](#caddy-cli) 34 | * [Docker images](#docker-images) 35 | + [Choosing the version numbers](#choosing-the-version-numbers) 36 | + [Chosing between default or alpine images](#chosing-between-default-or-alpine-images) 37 | + [CI images](#ci-images) 38 | + [ARM architecture images](#arm-architecture-images) 39 | + [Windows images](#windows-images) 40 | + [Custom images](#custom-images) 41 | * [Connecting to Docker Host](#connecting-to-docker-host) 42 | * [Volumes](#volumes) 43 | * [Trying it](#trying-it) 44 | + [With docker-compose file](#with-docker-compose-file) 45 | + [With run commands](#with-run-commands) 46 | * [Building it](#building-it) 47 | 48 | ## Basic usage example, using docker-compose 49 | ```shell 50 | $ docker network create caddy 51 | ``` 52 | 53 | `caddy/docker-compose.yml` 54 | ```yml 55 | version: "3.7" 56 | services: 57 | caddy: 58 | image: lucaslorentz/caddy-docker-proxy:ci-alpine 59 | ports: 60 | - 80:80 61 | - 443:443 62 | environment: 63 | - CADDY_INGRESS_NETWORKS=caddy 64 | networks: 65 | - caddy 66 | volumes: 67 | - /var/run/docker.sock:/var/run/docker.sock 68 | - caddy_data:/data 69 | restart: unless-stopped 70 | 71 | networks: 72 | caddy: 73 | external: true 74 | 75 | volumes: 76 | caddy_data: {} 77 | ``` 78 | ```shell 79 | $ docker-compose up -d 80 | ``` 81 | 82 | `whoami/docker-compose.yml` 83 | ```yml 84 | version: '3.7' 85 | services: 86 | whoami: 87 | image: traefik/whoami 88 | networks: 89 | - caddy 90 | labels: 91 | caddy: whoami.example.com 92 | caddy.reverse_proxy: "{{upstreams 80}}" 93 | 94 | networks: 95 | caddy: 96 | external: true 97 | ``` 98 | ```shell 99 | $ docker-compose up -d 100 | ``` 101 | Now, visit `https://whoami.example.com`. The site will be served [automatically over HTTPS](https://caddyserver.com/docs/automatic-https) with a certificate issued by Let's Encrypt or ZeroSSL. 102 | 103 | ## Labels to Caddyfile conversion 104 | Please first read the [Caddyfile Concepts](https://caddyserver.com/docs/caddyfile/concepts) documentation to understand the structure of a Caddyfile. 105 | 106 | Any label prefixed with `caddy` will be converted into a Caddyfile config, following these rules: 107 | 108 | ### Tokens and arguments 109 | 110 | Keys are the directive name, and values are whitespace separated arguments: 111 | ``` 112 | caddy.directive: arg1 arg2 113 | ↓ 114 | { 115 | directive arg1 arg2 116 | } 117 | ``` 118 | 119 | If you need whitespace or line-breaks inside one of the arguments, use double-quotes or backticks around it: 120 | ``` 121 | caddy.respond: / "Hello World" 200 122 | ↓ 123 | { 124 | respond / "Hello World" 200 125 | } 126 | ``` 127 | ``` 128 | caddy.respond: / `Hello\nWorld` 200 129 | ↓ 130 | { 131 | respond / `Hello 132 | World` 200 133 | } 134 | ``` 135 | ``` 136 | caddy.respond: | 137 | / `Hello 138 | World` 200 139 | ↓ 140 | { 141 | respond / `Hello 142 | World` 200 143 | } 144 | ``` 145 | 146 | Dots represent nesting, and grouping is done automatically: 147 | ``` 148 | caddy.directive: argA 149 | caddy.directive.subdirA: valueA 150 | caddy.directive.subdirB: valueB1 valueB2 151 | ↓ 152 | { 153 | directive argA { 154 | subdirA valueA 155 | subdirB valueB1 valueB2 156 | } 157 | } 158 | ``` 159 | 160 | Arguments for the parent directive are optional (e.g. no arguments to `directive`, setting subdirective `subdirA` directly): 161 | ``` 162 | caddy.directive.subdirA: valueA 163 | ↓ 164 | { 165 | directive { 166 | subdirA valueA 167 | } 168 | } 169 | ``` 170 | 171 | Labels with empty values generate a directive without any arguments: 172 | ``` 173 | caddy.directive: 174 | ↓ 175 | { 176 | directive 177 | } 178 | ``` 179 | 180 | ### Ordering and isolation 181 | 182 | Be aware that directives are subject to be sorted according to the default [directive order](https://caddyserver.com/docs/caddyfile/directives#directive-order) defined by Caddy, when the Caddyfile is parsed (after the Caddyfile is generated from labels). 183 | 184 | [Directives](https://caddyserver.com/docs/caddyfile/directives) from labels are ordered alphabetically by default: 185 | ``` 186 | caddy.bbb: value 187 | caddy.aaa: value 188 | ↓ 189 | { 190 | aaa value 191 | bbb value 192 | } 193 | ``` 194 | 195 | Suffix _<number> isolates directives that otherwise would be grouped: 196 | ``` 197 | caddy.route_0.a: value 198 | caddy.route_1.b: value 199 | ↓ 200 | { 201 | route { 202 | a value 203 | } 204 | route { 205 | b value 206 | } 207 | } 208 | ``` 209 | 210 | Prefix <number>_ isolates directives but also defines a custom ordering for directives (mainly relevant within [`route`](https://caddyserver.com/docs/caddyfile/directives/route) blocks), and directives without order prefix will go last: 211 | ``` 212 | caddy.1_bbb: value 213 | caddy.2_aaa: value 214 | caddy.3_aaa: value 215 | ↓ 216 | { 217 | bbb value 218 | aaa value 219 | aaa value 220 | } 221 | ``` 222 | 223 | ### Sites, snippets and global options 224 | 225 | A label `caddy` creates a [site block](https://caddyserver.com/docs/caddyfile/concepts): 226 | ``` 227 | caddy: example.com 228 | caddy.respond: "Hello World" 200 229 | ↓ 230 | example.com { 231 | respond "Hello World" 200 232 | } 233 | ``` 234 | 235 | Or a [snippet](https://caddyserver.com/docs/caddyfile/concepts#snippets): 236 | ``` 237 | caddy: (encode) 238 | caddy.encode: zstd gzip 239 | ↓ 240 | (encode) { 241 | encode zstd gzip 242 | } 243 | ``` 244 | 245 | It's also possible to isolate Caddy configurations using suffix _<number>: 246 | ``` 247 | caddy_0: (snippet) 248 | caddy_0.tls: internal 249 | caddy_1: site-a.com 250 | caddy_1.import: snippet 251 | caddy_2: site-b.com 252 | caddy_2.import: snippet 253 | ↓ 254 | (snippet) { 255 | tls internal 256 | } 257 | site_a { 258 | import snippet 259 | } 260 | site_b { 261 | import snippet 262 | } 263 | ``` 264 | 265 | [Global options](https://caddyserver.com/docs/caddyfile/options) can be defined by not setting any value for `caddy`. They can be set in any container/service, including caddy-docker-proxy itself. [Here is an example](examples/standalone.yaml#L19) 266 | ``` 267 | caddy.email: you@example.com 268 | ↓ 269 | { 270 | email you@example.com 271 | } 272 | ``` 273 | 274 | [Named matchers](https://caddyserver.com/docs/caddyfile/matchers#named-matchers) can be created using `@` inside labels: 275 | ``` 276 | caddy: localhost 277 | caddy.@match.path: /sourcepath /sourcepath/* 278 | caddy.reverse_proxy: @match localhost:6001 279 | ↓ 280 | localhost { 281 | @match { 282 | path /sourcepath /sourcepath/* 283 | } 284 | reverse_proxy @match localhost:6001 285 | } 286 | ``` 287 | 288 | ### Go templates 289 | 290 | [Golang templates](https://golang.org/pkg/text/template/) can be used inside label values to increase flexibility. From templates, you have access to current Docker resource information. But, keep in mind that the structure that describes a Docker container is different from a service. 291 | 292 | While you can access a service name like this: 293 | ``` 294 | caddy.respond: /info "{{.Spec.Name}}" 295 | ↓ 296 | respond /info "myservice" 297 | ``` 298 | 299 | The equivalent to access a container name would be: 300 | ``` 301 | caddy.respond: /info "{{index .Names 0}}" 302 | ↓ 303 | respond /info "mycontainer" 304 | ``` 305 | 306 | Sometimes it's not possile to have labels with empty values, like when using some UI to manage Docker. If that's the case, you can also use our support for go lang templates to generate empty labels. 307 | ``` 308 | caddy.directive: {{""}} 309 | ↓ 310 | directive 311 | ``` 312 | 313 | ## Template functions 314 | 315 | The following functions are available for use inside templates: 316 | 317 | ### upstreams 318 | 319 | Returns all addresses for the current Docker resource separated by whitespace. 320 | 321 | For services, that would be the service DNS name when **proxy-service-tasks** is **false**, or all running tasks IPs when **proxy-service-tasks** is **true**. 322 | 323 | For containers, that would be the container IPs. 324 | 325 | Only containers/services that are connected to Caddy ingress networks are used. 326 | 327 | :warning: caddy docker proxy does a best effort to automatically detect what are the ingress networks. But that logic fails on some scenarios: [#207](https://github.com/lucaslorentz/caddy-docker-proxy/issues/207). To have a more resilient solution, you can manually configure Caddy ingress network using CLI option `ingress-networks`, environment variable `CADDY_INGRESS_NETWORKS`. You can also specify the ingress network per container/service by adding to it a label `caddy_ingress_network` with the network name. 328 | 329 | Usage: `upstreams [http|https] [port]` 330 | 331 | Examples: 332 | ``` 333 | caddy.reverse_proxy: {{upstreams}} 334 | ↓ 335 | reverse_proxy 192.168.0.1 192.168.0.2 336 | ``` 337 | ``` 338 | caddy.reverse_proxy: {{upstreams https}} 339 | ↓ 340 | reverse_proxy https://192.168.0.1 https://192.168.0.2 341 | ``` 342 | ``` 343 | caddy.reverse_proxy: {{upstreams 8080}} 344 | ↓ 345 | reverse_proxy 192.168.0.1:8080 192.168.0.2:8080 346 | ``` 347 | ``` 348 | caddy.reverse_proxy: {{upstreams http 8080}} 349 | ↓ 350 | reverse_proxy http://192.168.0.1:8080 http://192.168.0.2:8080 351 | ``` 352 | 353 | :warning: Be carefull with quotes around upstreams. Quotes should only be added when using yaml. 354 | ``` 355 | caddy.reverse_proxy: "{{upstreams}}" 356 | ↓ 357 | reverse_proxy "192.168.0.1 192.168.0.2" 358 | ``` 359 | 360 | ## Examples 361 | Proxying all requests to a domain to the container 362 | ```yml 363 | caddy: example.com 364 | caddy.reverse_proxy: {{upstreams}} 365 | ``` 366 | 367 | Proxying all requests to a domain to a subpath in the container 368 | ```yml 369 | caddy: example.com 370 | caddy.rewrite: * /target{path} 371 | caddy.reverse_proxy: {{upstreams}} 372 | ``` 373 | 374 | Proxying requests matching a path, while stripping that path prefix 375 | ```yml 376 | caddy: example.com 377 | caddy.handle_path: /source/* 378 | caddy.handle_path.0_reverse_proxy: {{upstreams}} 379 | ``` 380 | 381 | Proxying requests matching a path, rewriting to different path prefix 382 | ```yml 383 | caddy: example.com 384 | caddy.handle_path: /source/* 385 | caddy.handle_path.0_rewrite: * /target{uri} 386 | caddy.handle_path.1_reverse_proxy: {{upstreams}} 387 | ``` 388 | 389 | Proxying all websocket requests, and all requests to `/api*`, to the container 390 | ```yml 391 | caddy: example.com 392 | caddy.@ws.0_header: Connection *Upgrade* 393 | caddy.@ws.1_header: Upgrade websocket 394 | caddy.0_reverse_proxy: @ws {{upstreams}} 395 | caddy.1_reverse_proxy: /api* {{upstreams}} 396 | ``` 397 | 398 | Proxying multiple domains, with certificates for each 399 | ```yml 400 | caddy: example.com, example.org, www.example.com, www.example.org 401 | caddy.reverse_proxy: {{upstreams}} 402 | ``` 403 | 404 | **More community-maintained examples are available in the [Wiki](https://github.com/lucaslorentz/caddy-docker-proxy/wiki).** 405 | 406 | ## Docker configs 407 | 408 | > Note: This is for Docker Swarm only. Alternatively, use `CADDY_DOCKER_CADDYFILE_PATH` or `-caddyfile-path` 409 | 410 | You can also add raw text to your Caddyfile using Docker configs. Just add Caddy label prefix to your configs and the whole config content will be inserted at the beginning of the generated Caddyfile, outside any server blocks. 411 | 412 | [Here is an example](examples/standalone.yaml#L4) 413 | 414 | ## Proxying services vs containers 415 | Caddy docker proxy is able to proxy to swarm services or raw containers. Both features are always enabled, and what will differentiate the proxy target is where you define your labels. 416 | 417 | ### Services 418 | To proxy swarm services, labels should be defined at service level. In a docker-compose file, labels should be _inside_ `deploy`, like: 419 | ```yml 420 | services: 421 | foo: 422 | deploy: # <-- labels should be _inside_ `deploy` 423 | labels: 424 | caddy: service.example.com 425 | caddy.reverse_proxy: {{upstreams}} 426 | ``` 427 | 428 | Caddy will use service DNS name as target or all service tasks IPs, depending on configuration **proxy-service-tasks**. 429 | 430 | ### Containers 431 | To proxy containers, labels should be defined at container level. In a docker-compose file, labels should be _outside_ `deploy`, like: 432 | ```yml 433 | services: 434 | foo: 435 | labels: 436 | caddy: service.example.com 437 | caddy.reverse_proxy: {{upstreams}} 438 | ``` 439 | 440 | ## Execution modes 441 | 442 | Each caddy docker proxy instance can be executed in one of the following modes. 443 | 444 | ### Server 445 | 446 | Acts as a proxy to your Docker resources. The server starts without any configuration, and will not serve anything until it is configured by a "controller". 447 | 448 | In order to make a server discoverable and configurable by controllers, you need to mark it with label `caddy_controlled_server` and define the controller network via CLI option `controller-network` or environment variable `CADDY_CONTROLLER_NETWORK`. 449 | 450 | Server instances doesn't need access to Docker host socket and you can run it in manager or worker nodes. 451 | 452 | [Configuration example](examples/distributed.yaml#L5) 453 | 454 | ### Controller 455 | 456 | Controller monitors your Docker cluster, generates Caddy configuration, and pushes it to all servers it finds in your Docker cluster. 457 | 458 | When controller instances are connected to more than one network, it is also necessary to define the controller network via CLI option `controller-network` or environment variable `CADDY_CONTROLLER_NETWORK`. 459 | 460 | Controller instances require access to Docker host socket. 461 | 462 | A single controller instance can configure all server instances in your cluster. 463 | 464 | **:warning: Controller mode requires server nodes to serve traffic.** 465 | 466 | [Configuration example](examples/distributed.yaml#L21) 467 | 468 | ### Standalone (default) 469 | 470 | This mode executes a controller and a server in the same instance and doesn't require additional configuration. 471 | 472 | [Configuration example](examples/standalone.yaml#L11) 473 | 474 | ## Caddy CLI 475 | 476 | This plugin extends caddy's CLI with the command `caddy docker-proxy`. 477 | 478 | Run `caddy help docker-proxy` to see all available flags. 479 | 480 | ``` 481 | Usage of docker-proxy: 482 | --caddyfile-path string 483 | Path to a base Caddyfile that will be extended with Docker sites 484 | --envfile 485 | Path to an environment file with environment variables in the KEY=VALUE format to load into the Caddy process 486 | --controller-network string 487 | Network allowed to configure Caddy server in CIDR notation. Ex: 10.200.200.0/24 488 | --ingress-networks string 489 | Comma separated name of ingress networks connecting Caddy servers to containers. 490 | When not defined, networks attached to controller container are considered ingress networks 491 | --docker-sockets 492 | Comma separated docker sockets 493 | When not defined, DOCKER_HOST (or default docker socket if DOCKER_HOST not defined) 494 | --docker-certs-path 495 | Comma separated cert path, you could use empty value when no cert path for the concern index docker socket like cert_path0,,cert_path2 496 | --docker-apis-version 497 | Comma separated apis version, you could use empty value when no api version for the concern index docker socket like cert_path0,,cert_path2 498 | --label-prefix string 499 | Prefix for Docker labels (default "caddy") 500 | --mode 501 | Which mode this instance should run: standalone | controller | server 502 | --polling-interval duration 503 | Interval Caddy should manually check Docker for a new Caddyfile (default 30s) 504 | --event-throttle-interval duration 505 | Interval to throttle caddyfile updates triggered by docker events (default 100ms) 506 | --process-caddyfile 507 | Process Caddyfile before loading it, removing invalid servers (default true) 508 | --proxy-service-tasks 509 | Proxy to service tasks instead of service load balancer (default true) 510 | --scan-stopped-containers 511 | Scan stopped containers and use their labels for Caddyfile generation (default false) 512 | ``` 513 | 514 | Those flags can also be set via environment variables: 515 | 516 | ``` 517 | CADDY_DOCKER_CADDYFILE_PATH= 518 | CADDY_DOCKER_ENVFILE= 519 | CADDY_CONTROLLER_NETWORK= 520 | CADDY_INGRESS_NETWORKS= 521 | CADDY_DOCKER_SOCKETS= 522 | CADDY_DOCKER_CERTS_PATH= 523 | CADDY_DOCKER_APIS_VERSION= 524 | CADDY_DOCKER_LABEL_PREFIX= 525 | CADDY_DOCKER_MODE= 526 | CADDY_DOCKER_POLLING_INTERVAL= 527 | CADDY_DOCKER_PROCESS_CADDYFILE= 528 | CADDY_DOCKER_PROXY_SERVICE_TASKS= 529 | CADDY_DOCKER_SCAN_STOPPED_CONTAINERS= 530 | CADDY_DOCKER_NO_SCOPE= 531 | ``` 532 | 533 | Check **examples** folder to see how to set them on a Docker Compose file. 534 | 535 | ## Docker images 536 | Docker images are available at Docker hub: 537 | https://hub.docker.com/r/lucaslorentz/caddy-docker-proxy/ 538 | 539 | ### Choosing the version numbers 540 | The safest approach is to use a full version numbers like 0.1.3. 541 | That way you lock to a specific build version that works well for you. 542 | 543 | But you can also use partial version numbers like 0.1. That means you will receive the most recent 0.1.x image. You will automatically receive updates without breaking changes. 544 | 545 | ### Chosing between default or alpine images 546 | Our default images are very small and safe because they only contain Caddy executable. 547 | But they're also quite hard to troubleshoot because they don't have shell or any other Linux utilities like curl or dig. 548 | 549 | The alpine images variant are based on the Linux Alpine image, a very small Linux distribution with shell and basic utilities tools. Use `-alpine` images if you want to trade security and small size for a better troubleshooting experience. 550 | 551 | ### CI images 552 | Images with the `ci` tag suffix means they were automatically generated by automated builds. 553 | CI images reflect the current state of master branch and their stability is not guaranteed. 554 | You may use CI images if you want to help testing the latest features before they're officially released. 555 | 556 | ### ARM architecture images 557 | Currently we provide linux x86_64 images by default. 558 | 559 | You can also find images for other architectures like `arm32v6` images that can be used on Raspberry Pi. 560 | 561 | ### Windows images 562 | We recently introduced experimental windows containers images with the tag suffix `nanoserver-ltsc2022`. 563 | 564 | Be aware that this needs to be tested further. 565 | 566 | This is an example of how to mount the windows Docker pipe using CLI: 567 | ```shell 568 | $ docker run --rm -it -v //./pipe/docker_engine://./pipe/docker_engine lucaslorentz/caddy-docker-proxy:ci-nanoserver-ltsc2022 569 | ``` 570 | 571 | ### Custom images 572 | If you need additional Caddy plugins, or need to use a specific version of Caddy, then you may use the `builder` variant of the [official Caddy Docker image](https://hub.docker.com/_/caddy) to make your own `Dockerfile`. 573 | 574 | The main difference from the instructions on the official image is that you must override `CMD` to have the container run using the `caddy docker-proxy` command provided by this plugin. 575 | 576 | ```Dockerfile 577 | ARG CADDY_VERSION=2.6.1 578 | FROM caddy:${CADDY_VERSION}-builder AS builder 579 | 580 | RUN xcaddy build \ 581 | --with github.com/lucaslorentz/caddy-docker-proxy/v2 \ 582 | --with 583 | 584 | FROM caddy:${CADDY_VERSION}-alpine 585 | 586 | COPY --from=builder /usr/bin/caddy /usr/bin/caddy 587 | 588 | CMD ["caddy", "docker-proxy"] 589 | ``` 590 | 591 | ## Connecting to Docker Host 592 | The default connection to Docker host varies per platform: 593 | * At Unix: `unix:///var/run/docker.sock` 594 | * At Windows: `npipe:////./pipe/docker_engine` 595 | 596 | You can modify Docker connection using the following environment variables: 597 | 598 | * **DOCKER_HOST**: to set the URL to the Docker server. 599 | * **DOCKER_API_VERSION**: to set the version of the API to reach, leave empty for latest. 600 | * **DOCKER_CERT_PATH**: to load the TLS certificates from. 601 | * **DOCKER_TLS_VERIFY**: to enable or disable TLS verification; off by default. 602 | 603 | ## Volumes 604 | On a production Docker swarm cluster, it's **very important** to store Caddy folder on persistent storage. Otherwise Caddy will re-issue certificates every time it is restarted, exceeding Let's Encrypt's quota. 605 | 606 | To do that, map a persistent Docker volume to `/data` folder. 607 | 608 | For resilient production deployments, use multiple Caddy replicas and map `/data` folder to a volume that supports multiple mounts, like Network File Sharing Docker volumes plugins. 609 | 610 | Multiple Caddy instances automatically orchestrate certificate issuing between themselves when sharing `/data` folder. 611 | 612 | ## Trying it 613 | 614 | ### With docker-compose file 615 | 616 | Clone this repository. 617 | 618 | Deploy the compose file to swarm cluster: 619 | ``` 620 | $ docker stack deploy -c examples/standalone.yaml caddy-docker-demo 621 | ``` 622 | 623 | Wait a bit for services to startup... 624 | 625 | Now you can access each service/container using different URLs 626 | ``` 627 | $ curl -k --resolve whoami0.example.com:443:127.0.0.1 https://whoami0.example.com 628 | $ curl -k --resolve whoami1.example.com:443:127.0.0.1 https://whoami1.example.com 629 | $ curl -k --resolve whoami2.example.com:443:127.0.0.1 https://whoami2.example.com 630 | $ curl -k --resolve whoami3.example.com:443:127.0.0.1 https://whoami3.example.com 631 | $ curl -k --resolve config.example.com:443:127.0.0.1 https://config.example.com 632 | $ curl -k --resolve echo0.example.com:443:127.0.0.1 https://echo0.example.com/sourcepath/something 633 | ``` 634 | 635 | After testing, delete the demo stack: 636 | ``` 637 | $ docker stack rm caddy-docker-demo 638 | ``` 639 | 640 | ### With run commands 641 | 642 | ``` 643 | $ docker run --name caddy -d -p 443:443 -v /var/run/docker.sock:/var/run/docker.sock lucaslorentz/caddy-docker-proxy:ci-alpine 644 | 645 | $ docker run --name whoami0 -d -l caddy=whoami0.example.com -l "caddy.reverse_proxy={{upstreams 80}}" -l caddy.tls=internal traefik/whoami 646 | 647 | $ docker run --name whoami1 -d -l caddy=whoami1.example.com -l "caddy.reverse_proxy={{upstreams 80}}" -l caddy.tls=internal traefik/whoami 648 | 649 | $ curl -k --resolve whoami0.example.com:443:127.0.0.1 https://whoami0.example.com 650 | $ curl -k --resolve whoami1.example.com:443:127.0.0.1 https://whoami1.example.com 651 | 652 | $ docker rm -f caddy whoami0 whoami1 653 | ``` 654 | 655 | ## Building it 656 | 657 | You can build Caddy using [xcaddy](https://github.com/caddyserver/xcaddy) or [caddy docker builder](https://hub.docker.com/_/caddy). 658 | 659 | Use module name **github.com/lucaslorentz/caddy-docker-proxy/v2** to add this plugin to your build. 660 | -------------------------------------------------------------------------------- /build-images-linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | docker buildx create --use 6 | docker run --privileged --rm tonistiigi/binfmt --install all 7 | 8 | find artifacts/binaries -type f -exec chmod +x {} \; 9 | 10 | PLATFORMS="linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64" 11 | OUTPUT="type=local,dest=local" 12 | TAGS= 13 | TAGS_ALPINE= 14 | 15 | if [[ "${GITHUB_REF}" == "refs/heads/master" ]]; then 16 | echo "Building and pushing CI images" 17 | 18 | docker login -u lucaslorentz -p "$DOCKER_PASSWORD" 19 | 20 | OUTPUT="type=registry" 21 | TAGS="-t lucaslorentz/caddy-docker-proxy:ci" 22 | TAGS_ALPINE="-t lucaslorentz/caddy-docker-proxy:ci-alpine" 23 | fi 24 | 25 | if [[ "${GITHUB_REF}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then 26 | RELEASE_VERSION=$(echo $GITHUB_REF | cut -c11-) 27 | 28 | echo "Releasing version ${RELEASE_VERSION}..." 29 | 30 | docker login -u lucaslorentz -p "$DOCKER_PASSWORD" 31 | 32 | PATCH_VERSION=$(echo $RELEASE_VERSION | cut -c2-) 33 | MINOR_VERSION=$(echo $PATCH_VERSION | cut -d. -f-2) 34 | 35 | OUTPUT="type=registry" 36 | TAGS="-t lucaslorentz/caddy-docker-proxy:latest \ 37 | -t lucaslorentz/caddy-docker-proxy:${PATCH_VERSION} \ 38 | -t lucaslorentz/caddy-docker-proxy:${MINOR_VERSION}" 39 | TAGS_ALPINE="-t lucaslorentz/caddy-docker-proxy:alpine \ 40 | -t lucaslorentz/caddy-docker-proxy:${PATCH_VERSION}-alpine \ 41 | -t lucaslorentz/caddy-docker-proxy:${MINOR_VERSION}-alpine" 42 | fi 43 | 44 | docker buildx build -f Dockerfile . \ 45 | -o $OUTPUT \ 46 | --platform $PLATFORMS \ 47 | $TAGS 48 | 49 | docker buildx build -f Dockerfile-alpine . \ 50 | -o $OUTPUT \ 51 | --platform $PLATFORMS \ 52 | $TAGS_ALPINE 53 | -------------------------------------------------------------------------------- /build-images-windows.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | docker build -f Dockerfile-nanoserver . \ 6 | --build-arg TARGETPLATFORM=windows/amd64 \ 7 | --build-arg SERVERCORE_VERSION=1809 \ 8 | --build-arg NANOSERVER_VERSION=1809 \ 9 | -t lucaslorentz/caddy-docker-proxy:ci-nanoserver-1809 10 | 11 | docker build -f Dockerfile-nanoserver . \ 12 | --build-arg TARGETPLATFORM=windows/amd64 \ 13 | --build-arg SERVERCORE_VERSION=ltsc2022 \ 14 | --build-arg NANOSERVER_VERSION=ltsc2022 \ 15 | -t lucaslorentz/caddy-docker-proxy:ci-nanoserver-ltsc2022 16 | 17 | if [[ "${GITHUB_REF}" == "refs/heads/master" ]]; then 18 | echo "Pushing CI images" 19 | 20 | docker login -u lucaslorentz -p "$DOCKER_PASSWORD" 21 | docker push lucaslorentz/caddy-docker-proxy:ci-nanoserver-1809 22 | docker push lucaslorentz/caddy-docker-proxy:ci-nanoserver-ltsc2022 23 | fi 24 | 25 | if [[ "${GITHUB_REF}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then 26 | RELEASE_VERSION=$(echo $GITHUB_REF | cut -c11-) 27 | 28 | echo "Releasing version ${RELEASE_VERSION}..." 29 | 30 | docker login -u lucaslorentz -p "$DOCKER_PASSWORD" 31 | 32 | PATCH_VERSION=$(echo $RELEASE_VERSION | cut -c2-) 33 | MINOR_VERSION=$(echo $PATCH_VERSION | cut -d. -f-2) 34 | 35 | docker login -u lucaslorentz -p "$DOCKER_PASSWORD" 36 | 37 | # nanoserver-1809 38 | docker tag lucaslorentz/caddy-docker-proxy:ci-nanoserver-1809 lucaslorentz/caddy-docker-proxy:nanoserver-1809 39 | docker tag lucaslorentz/caddy-docker-proxy:ci-nanoserver-1809 lucaslorentz/caddy-docker-proxy:${PATCH_VERSION}-nanoserver-1809 40 | docker tag lucaslorentz/caddy-docker-proxy:ci-nanoserver-1809 lucaslorentz/caddy-docker-proxy:${MINOR_VERSION}-nanoserver-1809 41 | docker push lucaslorentz/caddy-docker-proxy:nanoserver-1809 42 | docker push lucaslorentz/caddy-docker-proxy:${PATCH_VERSION}-nanoserver-1809 43 | docker push lucaslorentz/caddy-docker-proxy:${MINOR_VERSION}-nanoserver-1809 44 | 45 | # nanoserver-ltsc2022 46 | docker tag lucaslorentz/caddy-docker-proxy:ci-nanoserver-ltsc2022 lucaslorentz/caddy-docker-proxy:nanoserver-ltsc2022 47 | docker tag lucaslorentz/caddy-docker-proxy:ci-nanoserver-ltsc2022 lucaslorentz/caddy-docker-proxy:${PATCH_VERSION}-nanoserver-ltsc2022 48 | docker tag lucaslorentz/caddy-docker-proxy:ci-nanoserver-ltsc2022 lucaslorentz/caddy-docker-proxy:${MINOR_VERSION}-nanoserver-ltsc2022 49 | docker push lucaslorentz/caddy-docker-proxy:nanoserver-ltsc2022 50 | docker push lucaslorentz/caddy-docker-proxy:${PATCH_VERSION}-nanoserver-ltsc2022 51 | docker push lucaslorentz/caddy-docker-proxy:${MINOR_VERSION}-nanoserver-ltsc2022 52 | fi 53 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo ==PARAMETERS== 6 | echo ARTIFACTS: "${ARTIFACTS:=./artifacts}" 7 | 8 | go vet ./... 9 | go test -race ./... 10 | 11 | go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest 12 | 13 | # AMD64 14 | CGO_ENABLED=0 GOARCH=amd64 GOOS=linux \ 15 | xcaddy build \ 16 | --output ${ARTIFACTS}/binaries/linux/amd64/caddy \ 17 | --with github.com/lucaslorentz/caddy-docker-proxy/v2=$PWD 18 | 19 | # ARM 20 | CGO_ENABLED=0 GOARCH=arm GOARM=6 GOOS=linux \ 21 | xcaddy build \ 22 | --output ${ARTIFACTS}/binaries/linux/arm/v6/caddy \ 23 | --with github.com/lucaslorentz/caddy-docker-proxy/v2=$PWD 24 | 25 | CGO_ENABLED=0 GOARCH=arm GOARM=7 GOOS=linux \ 26 | xcaddy build \ 27 | --output ${ARTIFACTS}/binaries/linux/arm/v7/caddy \ 28 | --with github.com/lucaslorentz/caddy-docker-proxy/v2=$PWD 29 | 30 | CGO_ENABLED=0 GOARCH=arm64 GOOS=linux \ 31 | xcaddy build \ 32 | --output ${ARTIFACTS}/binaries/linux/arm64/caddy \ 33 | --with github.com/lucaslorentz/caddy-docker-proxy/v2=$PWD 34 | 35 | # AMD64 WINDOWS 36 | CGO_ENABLED=0 GOARCH=amd64 GOOS=windows \ 37 | xcaddy build \ 38 | --output ${ARTIFACTS}/binaries/windows/amd64/caddy.exe \ 39 | --with github.com/lucaslorentz/caddy-docker-proxy/v2=$PWD 40 | -------------------------------------------------------------------------------- /caddyfile/caddyfile.go: -------------------------------------------------------------------------------- 1 | package caddyfile 2 | 3 | import ( 4 | "math" 5 | "strings" 6 | ) 7 | 8 | // Block can represent any of those caddyfile elements: 9 | // - GlobalOptions 10 | // - Snippet 11 | // - Site 12 | // - MatcherDefinition 13 | // - Option 14 | // - Directive 15 | // - Subdirective 16 | // 17 | // It's structure is rendered in caddyfile as: 18 | // Keys[0] Keys[1] Keys[2] 19 | // 20 | // When children are defined, each child is also recursively rendered as: 21 | // Keys[0] Keys[1] Keys[2] { 22 | // Children[0].Keys[0] Children[0].Keys[1] 23 | // Children[1].Keys[0] Children[1].Keys[1] 24 | // } 25 | type Block struct { 26 | *Container 27 | Order int 28 | Keys []string 29 | } 30 | 31 | // Container represents a collection of blocks 32 | type Container struct { 33 | Children []*Block 34 | } 35 | 36 | // CreateBlock creates a block 37 | func CreateBlock() *Block { 38 | return &Block{ 39 | Container: CreateContainer(), 40 | Order: math.MaxInt32, 41 | Keys: []string{}, 42 | } 43 | } 44 | 45 | // CreateContainer creates a container 46 | func CreateContainer() *Container { 47 | return &Container{ 48 | Children: []*Block{}, 49 | } 50 | } 51 | 52 | // AddKeys to block 53 | func (block *Block) AddKeys(keys ...string) { 54 | block.Keys = append(block.Keys, keys...) 55 | } 56 | 57 | // AddBlock to container 58 | func (container *Container) AddBlock(block *Block) { 59 | container.Children = append(container.Children, block) 60 | } 61 | 62 | // GetFirstKey from block 63 | func (block *Block) GetFirstKey() string { 64 | if len(block.Keys) == 0 { 65 | return "" 66 | } 67 | return block.Keys[0] 68 | } 69 | 70 | // GetAllByFirstKey gets all blocks with the specified firstKey 71 | func (container *Container) GetAllByFirstKey(firstKey string) []*Block { 72 | matched := []*Block{} 73 | for _, block := range container.Children { 74 | if block.GetFirstKey() == firstKey { 75 | matched = append(matched, block) 76 | } 77 | } 78 | return matched 79 | } 80 | 81 | // Remove removes a specific block 82 | func (container *Container) Remove(blockToDelete *Block) { 83 | newItems := []*Block{} 84 | for _, block := range container.Children { 85 | if block != blockToDelete { 86 | newItems = append(newItems, block) 87 | } 88 | } 89 | container.Children = newItems 90 | } 91 | 92 | // IsGlobalBlock returns if block is a global block 93 | func (block *Block) IsGlobalBlock() bool { 94 | return len(block.Keys) == 0 95 | } 96 | 97 | // IsSnippet returns if block is a snippet 98 | func (block *Block) IsSnippet() bool { 99 | return len(block.Keys) == 1 && strings.HasPrefix(block.Keys[0], "(") && strings.HasSuffix(block.Keys[0], ")") 100 | } 101 | 102 | // IsMatcher returns if block is a matcher 103 | func (block *Block) IsMatcher() bool { 104 | return len(block.Keys) > 0 && strings.HasPrefix(block.Keys[0], "@") 105 | } 106 | -------------------------------------------------------------------------------- /caddyfile/fromlabels.go: -------------------------------------------------------------------------------- 1 | package caddyfile 2 | 3 | import ( 4 | "bytes" 5 | "math" 6 | "regexp" 7 | "strconv" 8 | "text/template" 9 | ) 10 | 11 | var whitespaceRegex = regexp.MustCompile("\\s+") 12 | var labelParserRegex = regexp.MustCompile(`^(?:(.+)\.)?(?:(\d+)_)?([^.]+?)(?:_(\d+))?$`) 13 | 14 | // FromLabels converts key value labels into a caddyfile 15 | func FromLabels(labels map[string]string, templateData interface{}, templateFuncs template.FuncMap) (*Container, error) { 16 | container := CreateContainer() 17 | 18 | blocksByPath := map[string]*Block{} 19 | for label, value := range labels { 20 | block := getOrCreateBlock(container, label, blocksByPath) 21 | argsText, err := processVariables(templateData, templateFuncs, value) 22 | if err != nil { 23 | return nil, err 24 | } 25 | args, err := parseArgs(argsText) 26 | if err != nil { 27 | return nil, err 28 | } 29 | block.AddKeys(args...) 30 | } 31 | 32 | return container, nil 33 | } 34 | 35 | func getOrCreateBlock(container *Container, path string, blocksByPath map[string]*Block) *Block { 36 | if block, blockExists := blocksByPath[path]; blockExists { 37 | return block 38 | } 39 | 40 | parentPath, order, name := parsePath(path) 41 | 42 | block := CreateBlock() 43 | block.Order = order 44 | 45 | if parentPath != "" { 46 | parentBlock := getOrCreateBlock(container, parentPath, blocksByPath) 47 | block.AddKeys(name) 48 | parentBlock.AddBlock(block) 49 | } else { 50 | container.AddBlock(block) 51 | } 52 | 53 | blocksByPath[path] = block 54 | 55 | return block 56 | } 57 | 58 | func parsePath(path string) (string, int, string) { 59 | match := labelParserRegex.FindStringSubmatch(path) 60 | parentPath := match[1] 61 | order := math.MaxInt32 62 | if match[2] != "" { 63 | order, _ = strconv.Atoi(match[2]) 64 | } 65 | name := match[3] 66 | return parentPath, order, name 67 | } 68 | 69 | func processVariables(data interface{}, funcs template.FuncMap, content string) (string, error) { 70 | t, err := template.New("").Funcs(funcs).Parse(content) 71 | if err != nil { 72 | return "", err 73 | } 74 | var writer bytes.Buffer 75 | err = t.Execute(&writer, data) 76 | if err != nil { 77 | return "", err 78 | } 79 | return writer.String(), nil 80 | } 81 | 82 | func parseArgs(text string) ([]string, error) { 83 | if len(text) == 0 { 84 | return []string{}, nil 85 | } 86 | l := new(lexer) 87 | err := l.load(bytes.NewReader([]byte(text))) 88 | if err != nil { 89 | return nil, err 90 | } 91 | var args []string 92 | for l.next() { 93 | args = append(args, l.token.Text) 94 | } 95 | return args, nil 96 | } 97 | -------------------------------------------------------------------------------- /caddyfile/fromlabels_test.go: -------------------------------------------------------------------------------- 1 | package caddyfile 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "strings" 8 | "testing" 9 | "text/template" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestLabelsToCaddyfile(t *testing.T) { 15 | // load the list of test files from the dir 16 | files, err := os.ReadDir("./testdata/labels") 17 | if err != nil { 18 | t.Errorf("failed to read labels dir: %s", err) 19 | } 20 | 21 | // prep a regexp to fix strings on windows 22 | winNewlines := regexp.MustCompile(`\r?\n`) 23 | 24 | for _, f := range files { 25 | if f.IsDir() { 26 | continue 27 | } 28 | 29 | // read the test file 30 | filename := f.Name() 31 | 32 | t.Run(filename, func(t *testing.T) { 33 | data, err := os.ReadFile("./testdata/labels/" + filename) 34 | if err != nil { 35 | t.Errorf("failed to read %s dir: %s", filename, err) 36 | } 37 | 38 | // split the labels (first) and Caddyfile (second) parts 39 | parts := strings.Split(string(data), "----------") 40 | labelsString, expectedCaddyfile := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) 41 | 42 | // parse label key-value pairs 43 | labels, err := parseLabelsFromString(labelsString) 44 | if err != nil { 45 | t.Errorf("failed to parse labels from %s", filename) 46 | } 47 | 48 | // replace windows newlines in the json with unix newlines 49 | expectedCaddyfile = winNewlines.ReplaceAllString(expectedCaddyfile, "\n") 50 | 51 | // convert the labels to a Caddyfile 52 | caddyfileContainer, err := FromLabels(labels, nil, template.FuncMap{}) 53 | 54 | // if the result is nil then we expect an empty Caddyfile 55 | if caddyfileContainer == nil { 56 | if expectedCaddyfile != "" { 57 | t.Errorf("got nil in %s but expected: %s", filename, expectedCaddyfile) 58 | } 59 | return 60 | } 61 | 62 | // if caddyfileContainer is not nil, we expect no error 63 | assert.NoError(t, err, "expected no error in %s", filename) 64 | 65 | // compare the actual and expected Caddyfiles 66 | actualCaddyfile := strings.TrimSpace(string(caddyfileContainer.Marshal())) 67 | assert.Equal(t, expectedCaddyfile, actualCaddyfile, 68 | "comparison failed in %s: \nExpected:\n%s\n\nActual:\n%s\n", 69 | filename, expectedCaddyfile, actualCaddyfile) 70 | }) 71 | } 72 | } 73 | 74 | func parseLabelsFromString(s string) (map[string]string, error) { 75 | labels := make(map[string]string) 76 | 77 | lines := strings.Split(s, "\n") 78 | lineNumber := 0 79 | 80 | for _, line := range lines { 81 | line = strings.ReplaceAll(strings.TrimSpace(line), "NEW_LINE", "\n") 82 | lineNumber++ 83 | 84 | // skip lines starting with comment 85 | if strings.HasPrefix(line, "#") { 86 | continue 87 | } 88 | 89 | // skip empty line 90 | if len(line) == 0 { 91 | continue 92 | } 93 | 94 | fields := strings.SplitN(line, "=", 2) 95 | if len(fields) != 2 { 96 | return nil, fmt.Errorf("can't parse line %d; line should be in KEY = VALUE format", lineNumber) 97 | } 98 | 99 | key := strings.TrimSpace(fields[0]) 100 | val := strings.TrimSpace(fields[1]) 101 | 102 | if key == "" { 103 | return nil, fmt.Errorf("missing or empty key on line %d", lineNumber) 104 | } 105 | labels[key] = val 106 | } 107 | 108 | return labels, nil 109 | } 110 | -------------------------------------------------------------------------------- /caddyfile/lexer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Light Code Labs, LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package caddyfile 16 | 17 | import ( 18 | "bufio" 19 | "io" 20 | "unicode" 21 | ) 22 | 23 | type ( 24 | // lexer is a utility which can get values, token by 25 | // token, from a Reader. A token is a word, and tokens 26 | // are separated by whitespace. A word can be enclosed 27 | // in quotes if it contains whitespace. 28 | lexer struct { 29 | reader *bufio.Reader 30 | token Token 31 | line int 32 | skippedLines int 33 | } 34 | 35 | // Token represents a single parsable unit. 36 | Token struct { 37 | File string 38 | Line int 39 | Text string 40 | } 41 | ) 42 | 43 | // load prepares the lexer to scan an input for tokens. 44 | // It discards any leading byte order mark. 45 | func (l *lexer) load(input io.Reader) error { 46 | l.reader = bufio.NewReader(input) 47 | l.line = 1 48 | 49 | // discard byte order mark, if present 50 | firstCh, _, err := l.reader.ReadRune() 51 | if err != nil { 52 | return err 53 | } 54 | if firstCh != 0xFEFF { 55 | err := l.reader.UnreadRune() 56 | if err != nil { 57 | return err 58 | } 59 | } 60 | 61 | return nil 62 | } 63 | 64 | // next loads the next token into the lexer. 65 | // A token is delimited by whitespace, unless 66 | // the token starts with a quotes character (") 67 | // in which case the token goes until the closing 68 | // quotes (the enclosing quotes are not included). 69 | // Inside quoted strings, quotes may be escaped 70 | // with a preceding \ character. No other chars 71 | // may be escaped. The rest of the line is skipped 72 | // if a "#" character is read in. Returns true if 73 | // a token was loaded; false otherwise. 74 | func (l *lexer) next() bool { 75 | var val []rune 76 | var comment, quoted, btQuoted, escaped bool 77 | 78 | makeToken := func() bool { 79 | l.token.Text = string(val) 80 | return true 81 | } 82 | 83 | for { 84 | ch, _, err := l.reader.ReadRune() 85 | if err != nil { 86 | if len(val) > 0 { 87 | return makeToken() 88 | } 89 | if err == io.EOF { 90 | return false 91 | } 92 | panic(err) 93 | } 94 | 95 | if !escaped && !btQuoted && ch == '\\' { 96 | escaped = true 97 | continue 98 | } 99 | 100 | if quoted || btQuoted { 101 | if quoted && escaped { 102 | // all is literal in quoted area, 103 | // so only escape quotes 104 | if ch != '"' { 105 | val = append(val, '\\') 106 | } 107 | escaped = false 108 | } else { 109 | if quoted && ch == '"' { 110 | return makeToken() 111 | } 112 | if btQuoted && ch == '`' { 113 | return makeToken() 114 | } 115 | } 116 | if ch == '\n' { 117 | l.line += 1 + l.skippedLines 118 | l.skippedLines = 0 119 | } 120 | val = append(val, ch) 121 | continue 122 | } 123 | 124 | if unicode.IsSpace(ch) { 125 | if ch == '\r' { 126 | continue 127 | } 128 | if ch == '\n' { 129 | if escaped { 130 | l.skippedLines++ 131 | escaped = false 132 | } else { 133 | l.line += 1 + l.skippedLines 134 | l.skippedLines = 0 135 | } 136 | comment = false 137 | } 138 | if len(val) > 0 { 139 | return makeToken() 140 | } 141 | continue 142 | } 143 | 144 | if ch == '#' && len(val) == 0 { 145 | comment = true 146 | } 147 | if comment { 148 | continue 149 | } 150 | 151 | if len(val) == 0 { 152 | l.token = Token{Line: l.line} 153 | if ch == '"' { 154 | quoted = true 155 | continue 156 | } 157 | if ch == '`' { 158 | btQuoted = true 159 | continue 160 | } 161 | } 162 | 163 | if escaped { 164 | val = append(val, '\\') 165 | escaped = false 166 | } 167 | 168 | val = append(val, ch) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /caddyfile/marshal.go: -------------------------------------------------------------------------------- 1 | package caddyfile 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | 9 | _ "github.com/caddyserver/caddy/v2/modules/standard" // plug standard HTTP modules 10 | ) 11 | 12 | // Marshal container into caddyfile bytes 13 | func (container *Container) Marshal() []byte { 14 | container.sort() 15 | buffer := &bytes.Buffer{} 16 | container.write(buffer, 0) 17 | return buffer.Bytes() 18 | } 19 | 20 | // Marshal block into caddyfile bytes 21 | func (block *Block) Marshal() []byte { 22 | block.Container.sort() 23 | buffer := &bytes.Buffer{} 24 | block.write(buffer, 0) 25 | return buffer.Bytes() 26 | } 27 | 28 | // write all blocks to a buffer 29 | func (container *Container) write(buffer *bytes.Buffer, level int) { 30 | for _, block := range container.Children { 31 | block.write(buffer, level) 32 | } 33 | } 34 | 35 | // write block to a buffer 36 | func (block *Block) write(buffer *bytes.Buffer, level int) { 37 | buffer.WriteString(strings.Repeat("\t", level)) 38 | needsWhitespace := false 39 | for _, key := range block.Keys { 40 | if needsWhitespace { 41 | buffer.WriteString(" ") 42 | } 43 | 44 | if strings.ContainsAny(key, "\n\"") { 45 | // If token has line break or quote, we use backtick for readability 46 | buffer.WriteString("`") 47 | buffer.WriteString(strings.ReplaceAll(key, "`", "\\`")) 48 | buffer.WriteString("`") 49 | } else if strings.ContainsAny(key, ` `) { 50 | // If token has whitespace, we use duoble quote 51 | buffer.WriteString("\"") 52 | buffer.WriteString(strings.ReplaceAll(key, "\"", "\\\"")) 53 | buffer.WriteString("\"") 54 | } else { 55 | buffer.WriteString(key) 56 | } 57 | 58 | needsWhitespace = true 59 | } 60 | if len(block.Children) > 0 { 61 | if needsWhitespace { 62 | buffer.WriteString(" ") 63 | } 64 | buffer.WriteString("{\n") 65 | block.Container.write(buffer, level+1) 66 | buffer.WriteString(strings.Repeat("\t", level) + "}") 67 | } 68 | buffer.WriteString("\n") 69 | } 70 | 71 | func (container *Container) sort() { 72 | // Sort children first 73 | for _, block := range container.Children { 74 | block.Container.sort() 75 | } 76 | // Sort container 77 | items := container.Children 78 | sort.SliceStable(items, func(i, j int) bool { 79 | return compareBlocks(items[i], items[j]) == -1 80 | }) 81 | } 82 | 83 | func compareBlocks(blockA *Block, blockB *Block) int { 84 | // Global blocks first 85 | if blockA.IsGlobalBlock() != blockB.IsGlobalBlock() { 86 | if blockA.IsGlobalBlock() { 87 | return -1 88 | } 89 | return 1 90 | } 91 | // Then snippets first 92 | if blockA.IsSnippet() != blockB.IsSnippet() { 93 | if blockA.IsSnippet() { 94 | return -1 95 | } 96 | return 1 97 | } 98 | // Then matchers first 99 | if blockA.IsMatcher() != blockB.IsMatcher() { 100 | if blockA.IsMatcher() { 101 | return -1 102 | } 103 | return 1 104 | } 105 | // Then follow order 106 | if blockA.Order != blockB.Order { 107 | if blockA.Order < blockB.Order { 108 | return -1 109 | } 110 | return 1 111 | } 112 | // Then compare common keys 113 | for keyIndex := 0; keyIndex < min(len(blockA.Keys), len(blockB.Keys)); keyIndex++ { 114 | if blockA.Keys[keyIndex] != blockB.Keys[keyIndex] { 115 | if blockA.Keys[keyIndex] < blockB.Keys[keyIndex] { 116 | return -1 117 | } 118 | return 1 119 | } 120 | } 121 | // Then the block with less keys first 122 | if len(blockA.Keys) != len(blockB.Keys) { 123 | if len(blockA.Keys) < len(blockB.Keys) { 124 | return -1 125 | } 126 | return 1 127 | } 128 | // Then based on children 129 | commonChildrenLength := min(len(blockA.Container.Children), len(blockB.Container.Children)) 130 | for c := 0; c < commonChildrenLength; c++ { 131 | childComparison := compareBlocks(blockA.Container.Children[c], blockB.Container.Children[c]) 132 | if childComparison != 0 { 133 | return childComparison 134 | } 135 | } 136 | // Then the block with less children first 137 | if len(blockA.Container.Children) != len(blockB.Container.Children) { 138 | if len(blockA.Container.Children) < len(blockB.Container.Children) { 139 | return -1 140 | } 141 | return 1 142 | } 143 | return 0 144 | } 145 | 146 | func min(a, b int) int { 147 | if a < b { 148 | return a 149 | } 150 | return b 151 | } 152 | 153 | // Unmarshal a Block fom caddyfile content 154 | func Unmarshal(caddyfileContent []byte) (*Container, error) { 155 | tokens, err := allTokens("", caddyfileContent) 156 | if err != nil { 157 | return nil, err 158 | } 159 | 160 | return parseContainer(tokens) 161 | } 162 | 163 | func allTokens(filename string, input []byte) ([]Token, error) { 164 | l := new(lexer) 165 | err := l.load(bytes.NewReader(input)) 166 | if err != nil { 167 | return nil, err 168 | } 169 | var tokens []Token 170 | for l.next() { 171 | l.token.File = filename 172 | tokens = append(tokens, l.token) 173 | } 174 | return tokens, nil 175 | } 176 | 177 | func parseContainer(tokens []Token) (*Container, error) { 178 | rootContainer := CreateContainer() 179 | stack := []*Container{rootContainer} 180 | isNewBlock := true 181 | tokenLine := -1 182 | 183 | var currentBlock *Block 184 | 185 | for _, token := range tokens { 186 | if token.Line != tokenLine { 187 | if tokenLine != -1 { 188 | isNewBlock = true 189 | } 190 | tokenLine = token.Line 191 | } 192 | if token.Text == "}" { 193 | if len(stack) == 1 { 194 | return nil, fmt.Errorf("Unexpected token '}' at line %v", token.Line) 195 | } 196 | stack = stack[:len(stack)-1] 197 | } else { 198 | if isNewBlock { 199 | parentBlock := stack[len(stack)-1] 200 | currentBlock = CreateBlock() 201 | currentBlock.Order = len(parentBlock.Children) 202 | parentBlock.AddBlock(currentBlock) 203 | isNewBlock = false 204 | } 205 | if token.Text == "{" { 206 | stack = append(stack, currentBlock.Container) 207 | } else { 208 | currentBlock.AddKeys(token.Text) 209 | tokenLine += strings.Count(token.Text, "\n") 210 | } 211 | } 212 | } 213 | 214 | return rootContainer, nil 215 | } 216 | -------------------------------------------------------------------------------- /caddyfile/marshal_test.go: -------------------------------------------------------------------------------- 1 | package caddyfile 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "strings" 7 | "testing" 8 | 9 | _ "github.com/caddyserver/caddy/v2/modules/standard" // plug standard HTTP modules 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestMarshalUnmarshal(t *testing.T) { 14 | const folder = "./testdata/marshal" 15 | 16 | // load the list of test files from the dir 17 | files, err := os.ReadDir(folder) 18 | if err != nil { 19 | t.Errorf("failed to read process dir: %s", err) 20 | } 21 | 22 | // prep a regexp to fix strings on windows 23 | winNewlines := regexp.MustCompile(`\r?\n`) 24 | 25 | for _, f := range files { 26 | if f.IsDir() { 27 | continue 28 | } 29 | 30 | // read the test file 31 | filename := f.Name() 32 | 33 | t.Run(filename, func(t *testing.T) { 34 | data, err := os.ReadFile(folder + "/" + filename) 35 | if err != nil { 36 | t.Errorf("failed to read %s dir: %s", filename, err) 37 | } 38 | 39 | // replace windows newlines in the json with unix newlines 40 | content := winNewlines.ReplaceAllString(string(data), "\n") 41 | 42 | // split two Caddyfile parts 43 | parts := strings.Split(content, "----------\n") 44 | beforeCaddyfile, expectedCaddyfile := parts[0], parts[1] 45 | 46 | container, _ := Unmarshal([]byte(beforeCaddyfile)) 47 | result := string(container.Marshal()) 48 | 49 | actualCaddyfile := string(result) 50 | 51 | // compare the actual and expected Caddyfiles 52 | assert.Equal(t, expectedCaddyfile, actualCaddyfile, 53 | "failed to process in %s", filename) 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /caddyfile/merge.go: -------------------------------------------------------------------------------- 1 | package caddyfile 2 | 3 | import "strings" 4 | 5 | // Merge a second caddyfile container into this container 6 | func (containerA *Container) Merge(containerB *Container) { 7 | OuterLoop: 8 | for _, blockB := range containerB.Children { 9 | firstKey := blockB.GetFirstKey() 10 | for _, blockA := range containerA.GetAllByFirstKey(firstKey) { 11 | if (firstKey == "reverse_proxy" || firstKey == "php_fastcgi") && getMatcher(blockA) == getMatcher(blockB) { 12 | mergeReverseProxyLike(blockA, blockB) 13 | continue OuterLoop 14 | } else if blocksAreEqual(blockA, blockB) { 15 | blockA.Container.Merge(blockB.Container) 16 | continue OuterLoop 17 | } 18 | } 19 | containerA.AddBlock(blockB) 20 | } 21 | } 22 | 23 | func mergeReverseProxyLike(blockA *Block, blockB *Block) { 24 | for index, key := range blockB.Keys[1:] { 25 | if index > 0 || !isMatcher(key) { 26 | blockA.AddKeys(key) 27 | } 28 | } 29 | blockA.Container.Merge(blockB.Container) 30 | } 31 | 32 | func getMatcher(block *Block) string { 33 | if len(block.Keys) <= 1 || !isMatcher(block.Keys[1]) { 34 | return "*" 35 | } 36 | return block.Keys[1] 37 | } 38 | 39 | func isMatcher(value string) bool { 40 | return value == "*" || strings.HasPrefix(value, "/") || strings.HasPrefix(value, "@") 41 | } 42 | 43 | func blocksAreEqual(blockA *Block, blockB *Block) bool { 44 | if len(blockA.Keys) != len(blockB.Keys) { 45 | return false 46 | } 47 | for i := 0; i < len(blockA.Keys); i++ { 48 | if blockA.Keys[i] != blockB.Keys[i] { 49 | return false 50 | } 51 | } 52 | return true 53 | } 54 | -------------------------------------------------------------------------------- /caddyfile/merge_test.go: -------------------------------------------------------------------------------- 1 | package caddyfile 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "strings" 7 | "testing" 8 | 9 | _ "github.com/caddyserver/caddy/v2/modules/standard" // plug standard HTTP modules 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestMerge(t *testing.T) { 14 | const folder = "./testdata/merge" 15 | 16 | // load the list of test files from the dir 17 | files, err := os.ReadDir(folder) 18 | if err != nil { 19 | t.Errorf("failed to read process dir: %s", err) 20 | } 21 | 22 | // prep a regexp to fix strings on windows 23 | winNewlines := regexp.MustCompile(`\r?\n`) 24 | 25 | for _, f := range files { 26 | if f.IsDir() { 27 | continue 28 | } 29 | 30 | // read the test file 31 | filename := f.Name() 32 | 33 | t.Run(filename, func(t *testing.T) { 34 | data, err := os.ReadFile(folder + "/" + filename) 35 | if err != nil { 36 | t.Errorf("failed to read %s dir: %s", filename, err) 37 | } 38 | 39 | // replace windows newlines in the json with unix newlines 40 | content := winNewlines.ReplaceAllString(string(data), "\n") 41 | 42 | // split two Caddyfile parts 43 | parts := strings.Split(content, "----------\n") 44 | caddyfile1, caddyfile2, expectedCaddyfile := parts[0], parts[1], parts[2] 45 | 46 | container1, _ := Unmarshal([]byte(caddyfile1)) 47 | container2, _ := Unmarshal([]byte(caddyfile2)) 48 | 49 | container1.Merge(container2) 50 | 51 | result := string(container1.Marshal()) 52 | 53 | actualCaddyfile := string(result) 54 | 55 | // compare the actual and expected Caddyfiles 56 | assert.Equal(t, expectedCaddyfile, actualCaddyfile, 57 | "failed to process in %s", filename) 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /caddyfile/processor.go: -------------------------------------------------------------------------------- 1 | package caddyfile 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "github.com/caddyserver/caddy/v2/caddyconfig" 8 | ) 9 | 10 | // Process caddyfile and removes wrong server blocks 11 | func Process(caddyfileContent []byte) ([]byte, []byte) { 12 | if len(caddyfileContent) == 0 { 13 | return caddyfileContent, nil 14 | } 15 | 16 | logsBuffer := bytes.Buffer{} 17 | adapter := caddyconfig.GetAdapter("caddyfile") 18 | 19 | container, err := Unmarshal(caddyfileContent) 20 | if err != nil { 21 | logsBuffer.WriteString(fmt.Sprintf("[ERROR] Invalid caddyfile: %s\n%s\n", err.Error(), caddyfileContent)) 22 | return nil, logsBuffer.Bytes() 23 | } 24 | 25 | newContainer := CreateContainer() 26 | 27 | container.sort() 28 | for _, block := range container.Children { 29 | newContainer.AddBlock(block) 30 | 31 | _, _, err := adapter.Adapt(newContainer.Marshal(), nil) 32 | 33 | if err != nil { 34 | newContainer.Remove(block) 35 | logsBuffer.WriteString(fmt.Sprintf("[ERROR] Removing invalid block: %s\n%s\n", err.Error(), block.Marshal())) 36 | } 37 | } 38 | 39 | return newContainer.Marshal(), logsBuffer.Bytes() 40 | } 41 | -------------------------------------------------------------------------------- /caddyfile/processor_test.go: -------------------------------------------------------------------------------- 1 | package caddyfile 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "strings" 7 | "testing" 8 | 9 | _ "github.com/caddyserver/caddy/v2/modules/standard" // plug standard HTTP modules 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestProcessCaddyfile(t *testing.T) { 15 | // load the list of test files from the dir 16 | files, err := os.ReadDir("./testdata/process") 17 | if err != nil { 18 | t.Errorf("failed to read process dir: %s", err) 19 | } 20 | 21 | // prep a regexp to fix strings on windows 22 | winNewlines := regexp.MustCompile(`\r?\n`) 23 | 24 | for _, f := range files { 25 | if f.IsDir() { 26 | continue 27 | } 28 | 29 | // read the test file 30 | filename := f.Name() 31 | 32 | t.Run(filename, func(t *testing.T) { 33 | data, err := os.ReadFile("./testdata/process/" + filename) 34 | if err != nil { 35 | t.Errorf("failed to read %s dir: %s", filename, err) 36 | } 37 | 38 | // replace windows newlines in the json with unix newlines 39 | content := winNewlines.ReplaceAllString(string(data), "\n") 40 | 41 | // split two Caddyfile parts 42 | parts := strings.Split(content, "----------\n") 43 | beforeCaddyfile, expectedCaddyfile, expectedLogs := parts[0], parts[1], "" 44 | 45 | if len(parts) > 2 { 46 | expectedLogs = parts[2] 47 | } 48 | 49 | // process the Caddyfile 50 | result, logs := Process([]byte(beforeCaddyfile)) 51 | 52 | actualCaddyfile := string(result) 53 | actualLogs := string(logs) 54 | 55 | // compare the actual and expected log 56 | assert.Equal(t, expectedLogs, actualLogs, 57 | "invalid process logs %s, \nExpected:\n%s\nActual:\n%s", 58 | filename, expectedLogs, actualLogs) 59 | 60 | // compare the actual and expected Caddyfiles 61 | assert.Equal(t, expectedCaddyfile, actualCaddyfile, 62 | "invalid process result %s, \nExpected:\n%s\nActual:\n%s", 63 | filename, expectedCaddyfile, actualCaddyfile) 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /caddyfile/testdata/labels/follow_alphabetical_order.txt: -------------------------------------------------------------------------------- 1 | caddy = localhost 2 | caddy.bbb = value 3 | caddy.aaa = value 4 | ---------- 5 | localhost { 6 | aaa value 7 | bbb value 8 | } -------------------------------------------------------------------------------- /caddyfile/testdata/labels/global_options.txt: -------------------------------------------------------------------------------- 1 | caddy.key = value 2 | ---------- 3 | { 4 | key value 5 | } -------------------------------------------------------------------------------- /caddyfile/testdata/labels/global_options_comes_first.txt: -------------------------------------------------------------------------------- 1 | caddy_0 = localhost 2 | caddy_0.tls = internal 3 | caddy_1.key = value 4 | ---------- 5 | { 6 | key value 7 | } 8 | localhost { 9 | tls internal 10 | } -------------------------------------------------------------------------------- /caddyfile/testdata/labels/grouping.txt: -------------------------------------------------------------------------------- 1 | caddy = localhost 2 | caddy.group.a = value-a 3 | caddy.group.b = value-b 4 | caddy.group.group.a = value-a 5 | caddy.group.group.b = value-b 6 | ---------- 7 | localhost { 8 | group { 9 | a value-a 10 | b value-b 11 | group { 12 | a value-a 13 | b value-b 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /caddyfile/testdata/labels/isolate_directives_with_suffix.txt: -------------------------------------------------------------------------------- 1 | caddy = localhost 2 | caddy.groupb_1.a = value 3 | caddy.groupb_2.b = value 4 | caddy.groupa_1.a = value 5 | caddy.groupa_2.b = value 6 | ---------- 7 | localhost { 8 | groupa { 9 | a value 10 | } 11 | groupa { 12 | b value 13 | } 14 | groupb { 15 | a value 16 | } 17 | groupb { 18 | b value 19 | } 20 | } -------------------------------------------------------------------------------- /caddyfile/testdata/labels/matchers_come_first.txt: -------------------------------------------------------------------------------- 1 | caddy = localhost 2 | caddy.@matcher.path = /path1 /path2 3 | caddy.respond = @matcher 200 4 | caddy.1_tls = internal 5 | ---------- 6 | localhost { 7 | @matcher { 8 | path /path1 /path2 9 | } 10 | tls internal 11 | respond @matcher 200 12 | } -------------------------------------------------------------------------------- /caddyfile/testdata/labels/one_line_matchers_come_first.txt: -------------------------------------------------------------------------------- 1 | caddy = localhost 2 | caddy.@matcher = path /path1 3 | caddy.respond = @matcher 200 4 | caddy.1_tls = internal 5 | ---------- 6 | localhost { 7 | @matcher path /path1 8 | tls internal 9 | respond @matcher 200 10 | } -------------------------------------------------------------------------------- /caddyfile/testdata/labels/order_and_isolate_directives_with_prefix.txt: -------------------------------------------------------------------------------- 1 | caddy = localhost 2 | caddy.1_bbb = value 3 | caddy.2_aaa = value 4 | caddy.3_merged.a = value 5 | caddy.3_merged.b = value 6 | caddy.4_isolated.a = value 7 | caddy.5_isolated.b = value 8 | ---------- 9 | localhost { 10 | bbb value 11 | aaa value 12 | merged { 13 | a value 14 | b value 15 | } 16 | isolated { 17 | a value 18 | } 19 | isolated { 20 | b value 21 | } 22 | } -------------------------------------------------------------------------------- /caddyfile/testdata/labels/quotes.txt: -------------------------------------------------------------------------------- 1 | caddy.0_with_spaces = "a b c d" 2 | caddy.1_without_spaces = "abcd" 3 | caddy.2_multiple = "a b c d" "abcd" 4 | caddy.3_back = `{"some":"json"}` 5 | caddy.4_escaped = "a\"b" 6 | caddy.5_unbalanced = "a 7 | caddy.6_unbalanced = a" 8 | caddy.7_multiline = `aNEW_LINEb` "cd" 9 | ---------- 10 | { 11 | with_spaces "a b c d" 12 | without_spaces abcd 13 | multiple "a b c d" abcd 14 | back `{"some":"json"}` 15 | escaped `a"b` 16 | unbalanced a 17 | unbalanced `a"` 18 | multiline `a 19 | b` cd 20 | } -------------------------------------------------------------------------------- /caddyfile/testdata/labels/snippets_come_first.txt: -------------------------------------------------------------------------------- 1 | caddy_0 = aaa.com 2 | caddy_0.import = my-snippet 3 | caddy_1 = (my-snippet) 4 | caddy_1.tls = internal 5 | ---------- 6 | (my-snippet) { 7 | tls internal 8 | } 9 | aaa.com { 10 | import my-snippet 11 | } -------------------------------------------------------------------------------- /caddyfile/testdata/labels/template_error.txt: -------------------------------------------------------------------------------- 1 | caddy.key = {{invalid}} 2 | ---------- -------------------------------------------------------------------------------- /caddyfile/testdata/labels/templates_empty_values.txt: -------------------------------------------------------------------------------- 1 | caddy = localhost 2 | caddy.key = {{""}} 3 | ---------- 4 | localhost { 5 | key 6 | } -------------------------------------------------------------------------------- /caddyfile/testdata/labels/wildcard_certificates.txt: -------------------------------------------------------------------------------- 1 | caddy = *.example.com 2 | caddy.1_@foo = host foo.example.com 3 | caddy.1_handle = @foo 4 | caddy.1_handle.reverse_proxy = foo:8080 5 | 6 | caddy = *.example.com 7 | caddy.2_@bar = host bar.example.com 8 | caddy.2_handle = @bar 9 | caddy.2_handle.reverse_proxy = bar:8080 10 | ---------- 11 | *.example.com { 12 | @foo host foo.example.com 13 | @bar host bar.example.com 14 | handle @foo { 15 | reverse_proxy foo:8080 16 | } 17 | handle @bar { 18 | reverse_proxy bar:8080 19 | } 20 | } -------------------------------------------------------------------------------- /caddyfile/testdata/marshal/marshal.txt: -------------------------------------------------------------------------------- 1 | { 2 | email you@example.com 3 | } 4 | (snippet) { 5 | tls internal 6 | } 7 | service2.example.com { 8 | respond 200 / 9 | route * { 10 | reverse_proxy service2:5000 { 11 | health_uri /health 12 | } 13 | } 14 | encode gzip 15 | } 16 | service3.example.com { 17 | respond 404 / 18 | respond 200 /health 19 | basicauth /secret { 20 | user " a \ b" 21 | } 22 | respond / ` 23 | Hello 24 | ` 200 25 | } 26 | ---------- 27 | { 28 | email you@example.com 29 | } 30 | (snippet) { 31 | tls internal 32 | } 33 | service2.example.com { 34 | respond 200 / 35 | route * { 36 | reverse_proxy service2:5000 { 37 | health_uri /health 38 | } 39 | } 40 | encode gzip 41 | } 42 | service3.example.com { 43 | respond 404 / 44 | respond 200 /health 45 | basicauth /secret { 46 | user " a \ b" 47 | } 48 | respond / ` 49 | Hello 50 | ` 200 51 | } 52 | -------------------------------------------------------------------------------- /caddyfile/testdata/merge/php_fastcgi_different_matcher.txt: -------------------------------------------------------------------------------- 1 | example.com { 2 | php_fastcgi /a service-a:80 3 | } 4 | ---------- 5 | example.com { 6 | php_fastcgi /b service-b:81 7 | } 8 | ---------- 9 | example.com { 10 | php_fastcgi /a service-a:80 11 | php_fastcgi /b service-b:81 12 | } 13 | -------------------------------------------------------------------------------- /caddyfile/testdata/merge/php_fastcgi_no_matcher.txt: -------------------------------------------------------------------------------- 1 | example.com { 2 | php_fastcgi service-a:80 3 | } 4 | ---------- 5 | example.com { 6 | php_fastcgi service-b:81 7 | } 8 | ---------- 9 | example.com { 10 | php_fastcgi service-a:80 service-b:81 11 | } 12 | -------------------------------------------------------------------------------- /caddyfile/testdata/merge/php_fastcgi_same_matcher.txt: -------------------------------------------------------------------------------- 1 | example.com { 2 | php_fastcgi /path service-a:80 3 | } 4 | ---------- 5 | example.com { 6 | php_fastcgi /path service-b:81 7 | } 8 | ---------- 9 | example.com { 10 | php_fastcgi /path service-a:80 service-b:81 11 | } 12 | -------------------------------------------------------------------------------- /caddyfile/testdata/merge/reverse_proxy_different_matcher.txt: -------------------------------------------------------------------------------- 1 | example.com { 2 | reverse_proxy /a service-a:80 3 | } 4 | ---------- 5 | example.com { 6 | reverse_proxy /b service-b:81 7 | } 8 | ---------- 9 | example.com { 10 | reverse_proxy /a service-a:80 11 | reverse_proxy /b service-b:81 12 | } 13 | -------------------------------------------------------------------------------- /caddyfile/testdata/merge/reverse_proxy_no_matcher.txt: -------------------------------------------------------------------------------- 1 | example.com { 2 | reverse_proxy service-a:80 3 | } 4 | ---------- 5 | example.com { 6 | reverse_proxy service-b:81 7 | } 8 | ---------- 9 | example.com { 10 | reverse_proxy service-a:80 service-b:81 11 | } 12 | -------------------------------------------------------------------------------- /caddyfile/testdata/merge/reverse_proxy_same_matcher.txt: -------------------------------------------------------------------------------- 1 | example.com { 2 | reverse_proxy /path service-a:80 3 | } 4 | ---------- 5 | example.com { 6 | reverse_proxy /path service-b:81 7 | } 8 | ---------- 9 | example.com { 10 | reverse_proxy /path service-a:80 service-b:81 11 | } 12 | -------------------------------------------------------------------------------- /caddyfile/testdata/process/blank.txt: -------------------------------------------------------------------------------- 1 | 2 | ---------- 3 | -------------------------------------------------------------------------------- /caddyfile/testdata/process/empty.txt: -------------------------------------------------------------------------------- 1 | ---------- 2 | -------------------------------------------------------------------------------- /caddyfile/testdata/process/invalid_block.txt: -------------------------------------------------------------------------------- 1 | { 2 | email you@example.com 3 | } 4 | (mysnippet) { 5 | encode gzip 6 | } 7 | service1.example.com { 8 | reverse_proxy service1:5000 { 9 | invalid 10 | } 11 | } 12 | service2.example.com { 13 | respond 200 / 14 | # Comment 15 | reverse_proxy service2:5000 { 16 | health_uri /health 17 | } 18 | import mysnippet 19 | } 20 | service3.example.com { 21 | respond 404 / 22 | basicauth /secret { 23 | user " a \ b" 24 | } 25 | respond / ` 26 | Hello 27 | ` 200 28 | } 29 | ---------- 30 | { 31 | email you@example.com 32 | } 33 | (mysnippet) { 34 | encode gzip 35 | } 36 | service2.example.com { 37 | respond 200 / 38 | reverse_proxy service2:5000 { 39 | health_uri /health 40 | } 41 | import mysnippet 42 | } 43 | service3.example.com { 44 | respond 404 / 45 | basicauth /secret { 46 | user " a \ b" 47 | } 48 | respond / ` 49 | Hello 50 | ` 200 51 | } 52 | ---------- 53 | [ERROR] Removing invalid block: parsing caddyfile tokens for 'reverse_proxy': unrecognized subdirective invalid, at Caddyfile:9 54 | service1.example.com { 55 | reverse_proxy service1:5000 { 56 | invalid 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /caddyfile/testdata/process/invalid_file.txt: -------------------------------------------------------------------------------- 1 | service1.example.com { 2 | reverse_proxy service1:5000 3 | } 4 | } 5 | ---------- 6 | ---------- 7 | [ERROR] Invalid caddyfile: Unexpected token '}' at line 4 8 | service1.example.com { 9 | reverse_proxy service1:5000 10 | } 11 | } 12 | 13 | -------------------------------------------------------------------------------- /cmd.go: -------------------------------------------------------------------------------- 1 | package caddydockerproxy 2 | 3 | import ( 4 | "flag" 5 | "net" 6 | "os" 7 | "regexp" 8 | "strings" 9 | "time" 10 | 11 | "github.com/caddyserver/caddy/v2" 12 | caddycmd "github.com/caddyserver/caddy/v2/cmd" 13 | "github.com/lucaslorentz/caddy-docker-proxy/v2/config" 14 | "github.com/lucaslorentz/caddy-docker-proxy/v2/generator" 15 | 16 | "go.uber.org/zap" 17 | ) 18 | 19 | var isTrue = regexp.MustCompile("(?i)^(true|yes|1)$") 20 | 21 | func init() { 22 | caddycmd.RegisterCommand(caddycmd.Command{ 23 | Name: "docker-proxy", 24 | Func: cmdFunc, 25 | Usage: "", 26 | Short: "Run caddy as a docker proxy", 27 | Flags: func() *flag.FlagSet { 28 | fs := flag.NewFlagSet("docker-proxy", flag.ExitOnError) 29 | 30 | fs.String("mode", "standalone", 31 | "Which mode this instance should run: standalone | controller | server") 32 | 33 | fs.String("docker-sockets", "", 34 | "Docker sockets comma separate") 35 | 36 | fs.String("docker-certs-path", "", 37 | "Docker socket certs path comma separate") 38 | 39 | fs.String("docker-apis-version", "", 40 | "Docker socket apis version comma separate") 41 | 42 | fs.String("controller-network", "", 43 | "Network allowed to configure caddy server in CIDR notation. Ex: 10.200.200.0/24") 44 | 45 | fs.String("ingress-networks", "", 46 | "Comma separated name of ingress networks connecting caddy servers to containers.\n"+ 47 | "When not defined, networks attached to controller container are considered ingress networks") 48 | 49 | fs.String("caddyfile-path", "", 50 | "Path to a base Caddyfile that will be extended with docker sites") 51 | 52 | fs.String("envfile", "", 53 | "Environment file with environment variables in the KEY=VALUE format") 54 | 55 | fs.String("label-prefix", generator.DefaultLabelPrefix, 56 | "Prefix for Docker labels") 57 | 58 | fs.Bool("proxy-service-tasks", true, 59 | "Proxy to service tasks instead of service load balancer") 60 | 61 | fs.Bool("process-caddyfile", true, 62 | "Process Caddyfile before loading it, removing invalid servers") 63 | 64 | fs.Bool("scan-stopped-containers", false, 65 | "Scan stopped containers and use its labels for caddyfile generation") 66 | 67 | fs.Duration("polling-interval", 30*time.Second, 68 | "Interval caddy should manually check docker for a new caddyfile") 69 | 70 | fs.Duration("event-throttle-interval", 100*time.Millisecond, 71 | "Interval to throttle caddyfile updates triggered by docker events") 72 | 73 | return fs 74 | }(), 75 | }) 76 | } 77 | 78 | func cmdFunc(flags caddycmd.Flags) (int, error) { 79 | caddy.TrapSignals() 80 | 81 | options := createOptions(flags) 82 | log := logger() 83 | 84 | if options.Mode&config.Server == config.Server { 85 | log.Info("Running caddy proxy server") 86 | 87 | err := caddy.Run(&caddy.Config{ 88 | Admin: &caddy.AdminConfig{ 89 | Listen: getAdminListen(options), 90 | }, 91 | }) 92 | if err != nil { 93 | return 1, err 94 | } 95 | } 96 | 97 | if options.Mode&config.Controller == config.Controller { 98 | log.Info("Running caddy proxy controller") 99 | loader := CreateDockerLoader(options) 100 | if err := loader.Start(); err != nil { 101 | if err := caddy.Stop(); err != nil { 102 | return 1, err 103 | } 104 | 105 | return 1, err 106 | } 107 | } 108 | 109 | select {} 110 | } 111 | 112 | func getAdminListen(options *config.Options) string { 113 | if options.ControllerNetwork != nil { 114 | ifaces, err := net.Interfaces() 115 | log := logger() 116 | 117 | if err != nil { 118 | log.Error("Failed to get network interfaces", zap.Error(err)) 119 | } 120 | for _, i := range ifaces { 121 | addrs, err := i.Addrs() 122 | if err != nil { 123 | log.Error("Failed to get network interface addresses", zap.Error(err)) 124 | continue 125 | } 126 | for _, a := range addrs { 127 | switch v := a.(type) { 128 | case *net.IPAddr: 129 | if options.ControllerNetwork.Contains(v.IP) { 130 | return "tcp/" + v.IP.String() + ":2019" 131 | } 132 | break 133 | case *net.IPNet: 134 | if options.ControllerNetwork.Contains(v.IP) { 135 | return "tcp/" + v.IP.String() + ":2019" 136 | } 137 | break 138 | } 139 | } 140 | } 141 | } 142 | return "tcp/localhost:2019" 143 | } 144 | 145 | func createOptions(flags caddycmd.Flags) *config.Options { 146 | caddyfilePath := flags.String("caddyfile-path") 147 | envFile := flags.String("envfile") 148 | labelPrefixFlag := flags.String("label-prefix") 149 | proxyServiceTasksFlag := flags.Bool("proxy-service-tasks") 150 | processCaddyfileFlag := flags.Bool("process-caddyfile") 151 | scanStoppedContainersFlag := flags.Bool("scan-stopped-containers") 152 | pollingIntervalFlag := flags.Duration("polling-interval") 153 | eventThrottleIntervalFlag := flags.Duration("event-throttle-interval") 154 | modeFlag := flags.String("mode") 155 | controllerSubnetFlag := flags.String("controller-network") 156 | dockerSocketsFlag := flags.String("docker-sockets") 157 | dockerCertsPathFlag := flags.String("docker-certs-path") 158 | dockerAPIsVersionFlag := flags.String("docker-apis-version") 159 | ingressNetworksFlag := flags.String("ingress-networks") 160 | 161 | options := &config.Options{} 162 | 163 | var mode string 164 | if modeEnv := os.Getenv("CADDY_DOCKER_MODE"); modeEnv != "" { 165 | mode = modeEnv 166 | } else { 167 | mode = modeFlag 168 | } 169 | switch mode { 170 | case "controller": 171 | options.Mode = config.Controller 172 | case "server": 173 | options.Mode = config.Server 174 | default: 175 | options.Mode = config.Standalone 176 | } 177 | 178 | log := logger() 179 | 180 | if dockerSocketsEnv := os.Getenv("CADDY_DOCKER_SOCKETS"); dockerSocketsEnv != "" { 181 | options.DockerSockets = strings.Split(dockerSocketsEnv, ",") 182 | } else if dockerSocketsFlag != "" { 183 | options.DockerSockets = strings.Split(dockerSocketsFlag, ",") 184 | } else { 185 | options.DockerSockets = nil 186 | } 187 | 188 | if dockerCertsPathEnv := os.Getenv("CADDY_DOCKER_CERTS_PATH"); dockerCertsPathEnv != "" { 189 | options.DockerCertsPath = strings.Split(dockerCertsPathEnv, ",") 190 | } else { 191 | options.DockerCertsPath = strings.Split(dockerCertsPathFlag, ",") 192 | } 193 | 194 | if dockerAPIsVersionEnv := os.Getenv("CADDY_DOCKER_APIS_VERSION"); dockerAPIsVersionEnv != "" { 195 | options.DockerAPIsVersion = strings.Split(dockerAPIsVersionEnv, ",") 196 | } else { 197 | options.DockerAPIsVersion = strings.Split(dockerAPIsVersionFlag, ",") 198 | } 199 | 200 | if controllerIPRangeEnv := os.Getenv("CADDY_CONTROLLER_NETWORK"); controllerIPRangeEnv != "" { 201 | _, ipNet, err := net.ParseCIDR(controllerIPRangeEnv) 202 | if err != nil { 203 | log.Error("Failed to parse CADDY_CONTROLLER_NETWORK", zap.String("CADDY_CONTROLLER_NETWORK", controllerIPRangeEnv), zap.Error(err)) 204 | } else if ipNet != nil { 205 | options.ControllerNetwork = ipNet 206 | } 207 | } else if controllerSubnetFlag != "" { 208 | _, ipNet, err := net.ParseCIDR(controllerSubnetFlag) 209 | if err != nil { 210 | log.Error("Failed to parse controller-network", zap.String("controller-network", controllerSubnetFlag), zap.Error(err)) 211 | } else if ipNet != nil { 212 | options.ControllerNetwork = ipNet 213 | } 214 | } 215 | 216 | if ingressNetworksEnv := os.Getenv("CADDY_INGRESS_NETWORKS"); ingressNetworksEnv != "" { 217 | options.IngressNetworks = strings.Split(ingressNetworksEnv, ",") 218 | } else if ingressNetworksFlag != "" { 219 | options.IngressNetworks = strings.Split(ingressNetworksFlag, ",") 220 | } 221 | 222 | if caddyfilePathEnv := os.Getenv("CADDY_DOCKER_CADDYFILE_PATH"); caddyfilePathEnv != "" { 223 | options.CaddyfilePath = caddyfilePathEnv 224 | } else { 225 | options.CaddyfilePath = caddyfilePath 226 | } 227 | 228 | if envFileEnv := os.Getenv("CADDY_DOCKER_ENVFILE"); envFileEnv != "" { 229 | options.EnvFile = envFileEnv 230 | } else { 231 | options.EnvFile = envFile 232 | } 233 | 234 | if labelPrefixEnv := os.Getenv("CADDY_DOCKER_LABEL_PREFIX"); labelPrefixEnv != "" { 235 | options.LabelPrefix = labelPrefixEnv 236 | } else { 237 | options.LabelPrefix = labelPrefixFlag 238 | } 239 | options.ControlledServersLabel = options.LabelPrefix + "_controlled_server" 240 | 241 | if proxyServiceTasksEnv := os.Getenv("CADDY_DOCKER_PROXY_SERVICE_TASKS"); proxyServiceTasksEnv != "" { 242 | options.ProxyServiceTasks = isTrue.MatchString(proxyServiceTasksEnv) 243 | } else { 244 | options.ProxyServiceTasks = proxyServiceTasksFlag 245 | } 246 | 247 | if processCaddyfileEnv := os.Getenv("CADDY_DOCKER_PROCESS_CADDYFILE"); processCaddyfileEnv != "" { 248 | options.ProcessCaddyfile = isTrue.MatchString(processCaddyfileEnv) 249 | } else { 250 | options.ProcessCaddyfile = processCaddyfileFlag 251 | } 252 | 253 | if scanStoppedContainersEnv := os.Getenv("CADDY_DOCKER_SCAN_STOPPED_CONTAINERS"); scanStoppedContainersEnv != "" { 254 | options.ScanStoppedContainers = isTrue.MatchString(scanStoppedContainersEnv) 255 | } else { 256 | options.ScanStoppedContainers = scanStoppedContainersFlag 257 | } 258 | 259 | if pollingIntervalEnv := os.Getenv("CADDY_DOCKER_POLLING_INTERVAL"); pollingIntervalEnv != "" { 260 | if p, err := time.ParseDuration(pollingIntervalEnv); err != nil { 261 | log.Error("Failed to parse CADDY_DOCKER_POLLING_INTERVAL", zap.String("CADDY_DOCKER_POLLING_INTERVAL", pollingIntervalEnv), zap.Error(err)) 262 | options.PollingInterval = pollingIntervalFlag 263 | } else { 264 | options.PollingInterval = p 265 | } 266 | } else { 267 | options.PollingInterval = pollingIntervalFlag 268 | } 269 | 270 | if eventThrottleIntervalEnv := os.Getenv("CADDY_DOCKER_EVENT_THROTTLE_INTERVAL"); eventThrottleIntervalEnv != "" { 271 | if p, err := time.ParseDuration(eventThrottleIntervalEnv); err != nil { 272 | log.Error("Failed to parse CADDY_DOCKER_EVENT_THROTTLE_INTERVAL", zap.String("CADDY_DOCKER_EVENT_THROTTLE_INTERVAL", eventThrottleIntervalEnv), zap.Error(err)) 273 | options.EventThrottleInterval = pollingIntervalFlag 274 | } else { 275 | options.EventThrottleInterval = p 276 | } 277 | } else { 278 | options.EventThrottleInterval = eventThrottleIntervalFlag 279 | } 280 | 281 | return options 282 | } 283 | -------------------------------------------------------------------------------- /config/options.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "net" 5 | "time" 6 | ) 7 | 8 | // Options are the options for generator 9 | type Options struct { 10 | CaddyfilePath string 11 | EnvFile string 12 | DockerSockets []string 13 | DockerCertsPath []string 14 | DockerAPIsVersion []string 15 | LabelPrefix string 16 | ControlledServersLabel string 17 | ProxyServiceTasks bool 18 | ProcessCaddyfile bool 19 | ScanStoppedContainers bool 20 | PollingInterval time.Duration 21 | EventThrottleInterval time.Duration 22 | Mode Mode 23 | Secret string 24 | ControllerNetwork *net.IPNet 25 | IngressNetworks []string 26 | } 27 | 28 | // Mode represents how this instance should run 29 | type Mode int 30 | 31 | const ( 32 | // Controller runs only controller 33 | Controller Mode = 1 34 | // Server runs only server 35 | Server Mode = 2 36 | // Standalone runs controller and server in a single instance 37 | Standalone Mode = Controller | Server 38 | ) 39 | -------------------------------------------------------------------------------- /docker/client.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/docker/docker/api/types" 7 | "github.com/docker/docker/api/types/container" 8 | "github.com/docker/docker/api/types/events" 9 | "github.com/docker/docker/api/types/swarm" 10 | "github.com/docker/docker/api/types/system" 11 | "github.com/docker/docker/client" 12 | ) 13 | 14 | // Client is an interface with needed functionalities from docker client 15 | type Client interface { 16 | ContainerList(ctx context.Context, options container.ListOptions) ([]types.Container, error) 17 | ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) 18 | TaskList(ctx context.Context, options types.TaskListOptions) ([]swarm.Task, error) 19 | Info(ctx context.Context) (system.Info, error) 20 | ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) 21 | NetworkInspect(ctx context.Context, networkID string, options types.NetworkInspectOptions) (types.NetworkResource, error) 22 | NetworkList(ctx context.Context, options types.NetworkListOptions) ([]types.NetworkResource, error) 23 | ConfigList(ctx context.Context, options types.ConfigListOptions) ([]swarm.Config, error) 24 | ConfigInspectWithRaw(ctx context.Context, id string) (swarm.Config, []byte, error) 25 | Events(ctx context.Context, options types.EventsOptions) (<-chan events.Message, <-chan error) 26 | } 27 | 28 | // WrapClient creates a new docker client wrapper 29 | func WrapClient(client *client.Client) Client { 30 | return &clientWrapper{ 31 | client: client, 32 | } 33 | } 34 | 35 | type clientWrapper struct { 36 | client *client.Client 37 | } 38 | 39 | func (wrapper *clientWrapper) ContainerList(ctx context.Context, options container.ListOptions) ([]types.Container, error) { 40 | return wrapper.client.ContainerList(ctx, options) 41 | } 42 | 43 | func (wrapper *clientWrapper) ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) { 44 | return wrapper.client.ServiceList(ctx, options) 45 | } 46 | 47 | func (wrapper *clientWrapper) TaskList(ctx context.Context, options types.TaskListOptions) ([]swarm.Task, error) { 48 | return wrapper.client.TaskList(ctx, options) 49 | } 50 | 51 | func (wrapper *clientWrapper) ConfigList(ctx context.Context, options types.ConfigListOptions) ([]swarm.Config, error) { 52 | return wrapper.client.ConfigList(ctx, options) 53 | } 54 | 55 | func (wrapper *clientWrapper) Info(ctx context.Context) (system.Info, error) { 56 | return wrapper.client.Info(ctx) 57 | } 58 | 59 | func (wrapper *clientWrapper) ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) { 60 | return wrapper.client.ContainerInspect(ctx, containerID) 61 | } 62 | 63 | func (wrapper *clientWrapper) NetworkInspect(ctx context.Context, networkID string, options types.NetworkInspectOptions) (types.NetworkResource, error) { 64 | return wrapper.client.NetworkInspect(ctx, networkID, options) 65 | } 66 | 67 | func (wrapper *clientWrapper) NetworkList(ctx context.Context, options types.NetworkListOptions) ([]types.NetworkResource, error) { 68 | return wrapper.client.NetworkList(ctx, options) 69 | } 70 | 71 | func (wrapper *clientWrapper) ConfigInspectWithRaw(ctx context.Context, id string) (swarm.Config, []byte, error) { 72 | return wrapper.client.ConfigInspectWithRaw(ctx, id) 73 | } 74 | 75 | func (wrapper *clientWrapper) Events(ctx context.Context, options types.EventsOptions) (<-chan events.Message, <-chan error) { 76 | return wrapper.client.Events(ctx, options) 77 | } 78 | -------------------------------------------------------------------------------- /docker/client_mock.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/docker/docker/api/types" 7 | "github.com/docker/docker/api/types/container" 8 | "github.com/docker/docker/api/types/events" 9 | "github.com/docker/docker/api/types/swarm" 10 | "github.com/docker/docker/api/types/system" 11 | ) 12 | 13 | // ClientMock allows easily mocking of docker client data 14 | type ClientMock struct { 15 | ContainersData []types.Container 16 | ServicesData []swarm.Service 17 | ConfigsData []swarm.Config 18 | TasksData []swarm.Task 19 | NetworksData []types.NetworkResource 20 | InfoData system.Info 21 | ContainerInspectData map[string]types.ContainerJSON 22 | NetworkInspectData map[string]types.NetworkResource 23 | EventsChannel chan events.Message 24 | ErrorsChannel chan error 25 | } 26 | 27 | // ContainerList list all containers 28 | func (mock *ClientMock) ContainerList(ctx context.Context, options container.ListOptions) ([]types.Container, error) { 29 | return mock.ContainersData, nil 30 | } 31 | 32 | // ServiceList list all services 33 | func (mock *ClientMock) ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) { 34 | return mock.ServicesData, nil 35 | } 36 | 37 | // TaskList list all tasks 38 | func (mock *ClientMock) TaskList(ctx context.Context, options types.TaskListOptions) ([]swarm.Task, error) { 39 | matchingTasks := []swarm.Task{} 40 | for _, task := range mock.TasksData { 41 | if !options.Filters.Match("service", task.ServiceID) { 42 | continue 43 | } 44 | if !options.Filters.Match("desired-state", string(task.DesiredState)) { 45 | continue 46 | } 47 | matchingTasks = append(matchingTasks, task) 48 | } 49 | return matchingTasks, nil 50 | } 51 | 52 | // ConfigList list all configs 53 | func (mock *ClientMock) ConfigList(ctx context.Context, options types.ConfigListOptions) ([]swarm.Config, error) { 54 | return mock.ConfigsData, nil 55 | } 56 | 57 | // NetworkList list all networks 58 | func (mock *ClientMock) NetworkList(ctx context.Context, options types.NetworkListOptions) ([]types.NetworkResource, error) { 59 | return mock.NetworksData, nil 60 | } 61 | 62 | // Info retrieves information about docker host 63 | func (mock *ClientMock) Info(ctx context.Context) (system.Info, error) { 64 | return mock.InfoData, nil 65 | } 66 | 67 | // ContainerInspect returns information about a specific container 68 | func (mock *ClientMock) ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) { 69 | return mock.ContainerInspectData[containerID], nil 70 | } 71 | 72 | // NetworkInspect returns information about a specific network 73 | func (mock *ClientMock) NetworkInspect(ctx context.Context, networkID string, options types.NetworkInspectOptions) (types.NetworkResource, error) { 74 | return mock.NetworkInspectData[networkID], nil 75 | } 76 | 77 | // ConfigInspectWithRaw return sinformation about a specific config 78 | func (mock *ClientMock) ConfigInspectWithRaw(ctx context.Context, id string) (swarm.Config, []byte, error) { 79 | for _, config := range mock.ConfigsData { 80 | if config.ID == id { 81 | return config, nil, nil 82 | } 83 | } 84 | return swarm.Config{}, nil, nil 85 | } 86 | 87 | // Events listen for events in docker 88 | func (mock *ClientMock) Events(ctx context.Context, options types.EventsOptions) (<-chan events.Message, <-chan error) { 89 | return mock.EventsChannel, mock.ErrorsChannel 90 | } 91 | -------------------------------------------------------------------------------- /docker/utils.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "runtime" 7 | ) 8 | 9 | // Utils is an interface with docker utilities 10 | type Utils interface { 11 | GetCurrentContainerID() (string, error) 12 | } 13 | 14 | type dockerUtils struct{} 15 | 16 | // CreateUtils creates a new instance of docker utils 17 | func CreateUtils() Utils { 18 | return &dockerUtils{} 19 | } 20 | 21 | // GetCurrentContainerID returns the id of the container running this application 22 | func (wrapper *dockerUtils) GetCurrentContainerID() (string, error) { 23 | var containerID string 24 | var err error 25 | if runtime.GOOS == "linux" { 26 | if containerID == "" && err == nil { 27 | containerID, err = wrapper.getCurrentContainerIDFromCGroup() 28 | } 29 | if containerID == "" && err == nil { 30 | containerID, err = wrapper.getCurrentContainerIDFromMountInfo() 31 | } 32 | } 33 | if containerID == "" && err == nil { 34 | containerID, err = os.Hostname() 35 | } 36 | return containerID, err 37 | } 38 | 39 | func (wrapper *dockerUtils) getCurrentContainerIDFromMountInfo() (string, error) { 40 | bytes, err := os.ReadFile("/proc/self/mountinfo") 41 | if err != nil { 42 | return "", err 43 | } 44 | containerID := wrapper.extractContainerIDFromMountInfo(string(bytes)) 45 | return containerID, nil 46 | } 47 | 48 | func (wrapper *dockerUtils) getCurrentContainerIDFromCGroup() (string, error) { 49 | bytes, err := os.ReadFile("/proc/self/cgroup") 50 | if err != nil { 51 | return "", err 52 | } 53 | containerID := wrapper.extractContainerIDFromCGroups(string(bytes)) 54 | return containerID, nil 55 | } 56 | 57 | func (wrapper *dockerUtils) extractContainerIDFromMountInfo(cgroups string) string { 58 | idRegex := regexp.MustCompile(`containers/([[:alnum:]]{64})/`) 59 | matches := idRegex.FindStringSubmatch(cgroups) 60 | if len(matches) == 0 { 61 | return "" 62 | } 63 | return matches[len(matches)-1] 64 | } 65 | 66 | func (wrapper *dockerUtils) extractContainerIDFromCGroups(cgroups string) string { 67 | idRegex := regexp.MustCompile(`(?im)^[^:]*:[^:]*:.*\b([[:alnum:]]{64})\b`) 68 | matches := idRegex.FindStringSubmatch(cgroups) 69 | if len(matches) == 0 { 70 | return "" 71 | } 72 | return matches[len(matches)-1] 73 | } 74 | -------------------------------------------------------------------------------- /docker/utils_mock.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | // UtilsMock allows mocking docker Utils 4 | type UtilsMock struct { 5 | MockGetCurrentContainerID func() (string, error) 6 | } 7 | 8 | // GetCurrentContainerID returns the id of the container running this application 9 | func (mock *UtilsMock) GetCurrentContainerID() (string, error) { 10 | return mock.MockGetCurrentContainerID() 11 | } 12 | -------------------------------------------------------------------------------- /docker/utils_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestExtratFromMountInfo(t *testing.T) { 10 | read := 11 | `982 811 0:188 / / rw,relatime master:211 - overlay overlay rw,lowerdir=/var/lib/docker/overlay2/l/JITAD3AQIAPDR63API26SHX5CQ:/var/lib/docker/overlay2/l/OD4G2XK3EBQC7UCYC2MKN2VU4N:/var/lib/docker/overlay2/l/XCYRLTZ7FPAFABA6UPAECHCUFM:/var/lib/docker/overlay2/l/2UTXO3KIF3I7EQFKXPOYQO6WGN,upperdir=/var/lib/docker/overlay2/11a7a30cc374c98491c15185334a99f07e2761a9759c2c5b3ba1b4122ec9fbf7/diff,workdir=/var/lib/docker/overlay2/11a7a30cc374c98491c15185334a99f07e2761a9759c2c5b3ba1b4122ec9fbf7/work 12 | 984 982 0:205 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw 13 | 986 982 0:207 / /dev rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 14 | 988 986 0:209 / /dev/pts rw,nosuid,noexec,relatime - devpts devpts rw,gid=5,mode=620,ptmxmode=666 15 | 990 982 0:211 / /sys ro,nosuid,nodev,noexec,relatime - sysfs sysfs ro 16 | 993 990 0:33 / /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw 17 | 994 986 0:202 / /dev/mqueue rw,nosuid,nodev,noexec,relatime - mqueue mqueue rw 18 | 996 986 0:213 / /dev/shm rw,nosuid,nodev,noexec,relatime - tmpfs shm rw,size=65536k 19 | 999 982 254:1 /docker/containers/d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b/resolv.conf /etc/resolv.conf rw,relatime - ext4 /dev/vda1 rw 20 | 1001 982 254:1 /docker/containers/d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b/hostname /etc/hostname rw,relatime - ext4 /dev/vda1 rw 21 | 1002 982 254:1 /docker/containers/d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b/hosts /etc/hosts rw,relatime - ext4 /dev/vda1 rw 22 | 1003 982 0:23 /host-services/docker.proxy.sock /run/docker.sock rw,nosuid,nodev,noexec,relatime - tmpfs tmpfs rw,size=608152k,mode=755 23 | 576 984 0:205 /bus /proc/bus ro,nosuid,nodev,noexec,relatime - proc proc rw 24 | 587 984 0:205 /fs /proc/fs ro,nosuid,nodev,noexec,relatime - proc proc rw 25 | 588 984 0:205 /irq /proc/irq ro,nosuid,nodev,noexec,relatime - proc proc rw 26 | 589 984 0:205 /sys /proc/sys ro,nosuid,nodev,noexec,relatime - proc proc rw 27 | 590 984 0:205 /sysrq-trigger /proc/sysrq-trigger ro,nosuid,nodev,noexec,relatime - proc proc rw 28 | 591 984 0:207 /null /proc/kcore rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 29 | 592 984 0:207 /null /proc/keys rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 30 | 593 984 0:207 /null /proc/timer_list rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 31 | 594 990 0:216 / /sys/firmware ro,relatime - tmpfs tmpfs ro` 32 | 33 | expected := "d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b" 34 | 35 | utils := dockerUtils{} 36 | 37 | actual := utils.extractContainerIDFromMountInfo(read) 38 | 39 | assert.Equal(t, expected, actual) 40 | } 41 | 42 | func TestFailExtractBasicDockerId(t *testing.T) { 43 | read := 44 | `1:cpu:/not_an_id` 45 | 46 | utils := dockerUtils{} 47 | 48 | actual := utils.extractContainerIDFromCGroups(read) 49 | 50 | assert.Empty(t, actual) 51 | } 52 | 53 | func TestExtractBasicDockerId(t *testing.T) { 54 | read := 55 | `6:blkio:/system.slice/d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b 56 | 5:cpuset:/system.slice/d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b 57 | 4:net_cls,net_prio:/system.slice/d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b 58 | 3:freezer:/system.slice/d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b 59 | 2:cpu,cpuacct:/system.slice/d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b 60 | ` 61 | expected := "d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b" 62 | 63 | utils := dockerUtils{} 64 | 65 | actual := utils.extractContainerIDFromCGroups(read) 66 | 67 | assert.Equal(t, expected, actual) 68 | } 69 | 70 | func TestExtractScopedDockerId(t *testing.T) { 71 | read := 72 | `6:blkio:/system.slice/docker-d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b.scope 73 | 5:cpuset:/system.slice/docker-d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b.scope 74 | 4:net_cls,net_prio:/system.slice/docker-d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b.scope 75 | 3:freezer:/system.slice/docker-d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b.scope 76 | 2:cpu,cpuacct:/system.slice/docker-d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b.scope 77 | ` 78 | expected := "d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b" 79 | 80 | utils := dockerUtils{} 81 | 82 | actual := utils.extractContainerIDFromCGroups(read) 83 | 84 | assert.Equal(t, expected, actual) 85 | } 86 | 87 | func TestExtractSlashPrefixedDockerId(t *testing.T) { 88 | read := 89 | `6:blkio:/system.slice/docker/d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b 90 | 5:cpuset:/system.slice/docker/d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b 91 | 4:net_cls,net_prio:/system.slice/docker/d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b 92 | 3:freezer:/system.slice/docker/d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b 93 | 2:cpu,cpuacct:/system.slice/docker/d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b 94 | ` 95 | expected := "d39fa516d8377ecddf9bf8ef33f81cbf58b4d604d85293ced7cdb0c7fc52442b" 96 | 97 | utils := dockerUtils{} 98 | 99 | actual := utils.extractContainerIDFromCGroups(read) 100 | 101 | assert.Equal(t, expected, actual) 102 | } 103 | 104 | func TestExtractNestedDockerId(t *testing.T) { 105 | read := 106 | `11:devices:/docker/c59fb9264a25958577e23808e80d82acd5a27a3758e2095d1607df134221fae3/docker/ac5c7f517c5707de3c77f33f0fa43e9c625d64d1e4d6c9c8ce7b50339ec86f61 107 | 10:perf_event:/docker/c59fb9264a25958577e23808e80d82acd5a27a3758e2095d1607df134221fae3/docker/ac5c7f517c5707de3c77f33f0fa43e9c625d64d1e4d6c9c8ce7b50339ec86f61 108 | 9:pids:/docker/c59fb9264a25958577e23808e80d82acd5a27a3758e2095d1607df134221fae3/docker/ac5c7f517c5707de3c77f33f0fa43e9c625d64d1e4d6c9c8ce7b50339ec86f61 109 | 8:cpuset:/docker/c59fb9264a25958577e23808e80d82acd5a27a3758e2095d1607df134221fae3/docker/ac5c7f517c5707de3c77f33f0fa43e9c625d64d1e4d6c9c8ce7b50339ec86f61 110 | 7:hugetlb:/docker/c59fb9264a25958577e23808e80d82acd5a27a3758e2095d1607df134221fae3/docker/ac5c7f517c5707de3c77f33f0fa43e9c625d64d1e4d6c9c8ce7b50339ec86f61 111 | 6:freezer:/docker/c59fb9264a25958577e23808e80d82acd5a27a3758e2095d1607df134221fae3/docker/ac5c7f517c5707de3c77f33f0fa43e9c625d64d1e4d6c9c8ce7b50339ec86f61 112 | 5:net_cls,net_prio:/docker/c59fb9264a25958577e23808e80d82acd5a27a3758e2095d1607df134221fae3/docker/ac5c7f517c5707de3c77f33f0fa43e9c625d64d1e4d6c9c8ce7b50339ec86f61 113 | 4:cpu,cpuacct:/docker/c59fb9264a25958577e23808e80d82acd5a27a3758e2095d1607df134221fae3/docker/ac5c7f517c5707de3c77f33f0fa43e9c625d64d1e4d6c9c8ce7b50339ec86f61 114 | 3:blkio:/docker/c59fb9264a25958577e23808e80d82acd5a27a3758e2095d1607df134221fae3/docker/ac5c7f517c5707de3c77f33f0fa43e9c625d64d1e4d6c9c8ce7b50339ec86f61 115 | 2:memory:/docker/c59fb9264a25958577e23808e80d82acd5a27a3758e2095d1607df134221fae3/docker/ac5c7f517c5707de3c77f33f0fa43e9c625d64d1e4d6c9c8ce7b50339ec86f61 116 | 1:name=systemd:/docker/c59fb9264a25958577e23808e80d82acd5a27a3758e2095d1607df134221fae3/docker/ac5c7f517c5707de3c77f33f0fa43e9c625d64d1e4d6c9c8ce7b50339ec86f61` 117 | 118 | expected := "ac5c7f517c5707de3c77f33f0fa43e9c625d64d1e4d6c9c8ce7b50339ec86f61" 119 | 120 | utils := dockerUtils{} 121 | 122 | actual := utils.extractContainerIDFromCGroups(read) 123 | 124 | assert.Equal(t, expected, actual) 125 | } 126 | 127 | func TestExtractAKSDockerId(t *testing.T) { 128 | read := 129 | `12:perf_event:/kubepods/pod54ebaa4a-f470-11ea-b463-000d3a9ecdb6/43172aa658cbf50b2e646e3aa4c90447b10774d4e76ff720b0f4faebdb759857 130 | 11:cpuset:/kubepods/pod54ebaa4a-f470-11ea-b463-000d3a9ecdb6/43172aa658cbf50b2e646e3aa4c90447b10774d4e76ff720b0f4faebdb759857 131 | 10:memory:/kubepods/pod54ebaa4a-f470-11ea-b463-000d3a9ecdb6/43172aa658cbf50b2e646e3aa4c90447b10774d4e76ff720b0f4faebdb759857 132 | 9:devices:/kubepods/pod54ebaa4a-f470-11ea-b463-000d3a9ecdb6/43172aa658cbf50b2e646e3aa4c90447b10774d4e76ff720b0f4faebdb759857 133 | 8:net_cls,net_prio:/kubepods/pod54ebaa4a-f470-11ea-b463-000d3a9ecdb6/43172aa658cbf50b2e646e3aa4c90447b10774d4e76ff720b0f4faebdb759857 134 | 7:hugetlb:/kubepods/pod54ebaa4a-f470-11ea-b463-000d3a9ecdb6/43172aa658cbf50b2e646e3aa4c90447b10774d4e76ff720b0f4faebdb759857 135 | 6:freezer:/kubepods/pod54ebaa4a-f470-11ea-b463-000d3a9ecdb6/43172aa658cbf50b2e646e3aa4c90447b10774d4e76ff720b0f4faebdb759857 136 | 5:blkio:/kubepods/pod54ebaa4a-f470-11ea-b463-000d3a9ecdb6/43172aa658cbf50b2e646e3aa4c90447b10774d4e76ff720b0f4faebdb759857 137 | 4:cpu,cpuacct:/kubepods/pod54ebaa4a-f470-11ea-b463-000d3a9ecdb6/43172aa658cbf50b2e646e3aa4c90447b10774d4e76ff720b0f4faebdb759857 138 | 3:rdma:/ 139 | 2:pids:/kubepods/pod54ebaa4a-f470-11ea-b463-000d3a9ecdb6/43172aa658cbf50b2e646e3aa4c90447b10774d4e76ff720b0f4faebdb759857 140 | 1:name=systemd:/kubepods/pod54ebaa4a-f470-11ea-b463-000d3a9ecdb6/43172aa658cbf50b2e646e3aa4c90447b10774d4e76ff720b0f4faebdb759857 141 | ` 142 | 143 | expected := "43172aa658cbf50b2e646e3aa4c90447b10774d4e76ff720b0f4faebdb759857" 144 | 145 | utils := dockerUtils{} 146 | 147 | actual := utils.extractContainerIDFromCGroups(read) 148 | 149 | assert.Equal(t, expected, actual) 150 | } 151 | 152 | func TestExtractECSDockerId(t *testing.T) { 153 | read := 154 | `9:perf_event:/ecs/8f67afbb-3222-488d-b96a-9262c37dc9d3/3137c30c56add55d7212fdef77fd796c69b08f7845aa9a3d3fdb720c2a885a1d 155 | 8:memory:/ecs/8f67afbb-3222-488d-b96a-9262c37dc9d3/3137c30c56add55d7212fdef77fd796c69b08f7845aa9a3d3fdb720c2a885a1d 156 | 7:hugetlb:/ecs/8f67afbb-3222-488d-b96a-9262c37dc9d3/3137c30c56add55d7212fdef77fd796c69b08f7845aa9a3d3fdb720c2a885a1d 157 | 6:freezer:/ecs/8f67afbb-3222-488d-b96a-9262c37dc9d3/3137c30c56add55d7212fdef77fd796c69b08f7845aa9a3d3fdb720c2a885a1d 158 | 5:devices:/ecs/8f67afbb-3222-488d-b96a-9262c37dc9d3/3137c30c56add55d7212fdef77fd796c69b08f7845aa9a3d3fdb720c2a885a1d 159 | 4:cpuset:/ecs/8f67afbb-3222-488d-b96a-9262c37dc9d3/3137c30c56add55d7212fdef77fd796c69b08f7845aa9a3d3fdb720c2a885a1d 160 | 3:cpuacct:/ecs/8f67afbb-3222-488d-b96a-9262c37dc9d3/3137c30c56add55d7212fdef77fd796c69b08f7845aa9a3d3fdb720c2a885a1d 161 | 2:cpu:/ecs/8f67afbb-3222-488d-b96a-9262c37dc9d3/3137c30c56add55d7212fdef77fd796c69b08f7845aa9a3d3fdb720c2a885a1d 162 | 1:blkio:/ecs/8f67afbb-3222-488d-b96a-9262c37dc9d3/3137c30c56add55d7212fdef77fd796c69b08f7845aa9a3d3fdb720c2a885a1d 163 | ` 164 | 165 | expected := "3137c30c56add55d7212fdef77fd796c69b08f7845aa9a3d3fdb720c2a885a1d" 166 | 167 | utils := dockerUtils{} 168 | 169 | actual := utils.extractContainerIDFromCGroups(read) 170 | 171 | assert.Equal(t, expected, actual) 172 | } 173 | 174 | func TestExtractRootlessDockerId(t *testing.T) { 175 | read := 176 | `11:rdma:/ 177 | 10:freezer:/ 178 | 9:cpuset:/ 179 | 8:net_cls,net_prio:/ 180 | 7:cpu,cpuacct:/ 181 | 6:devices:/user.slice 182 | 5:memory:/user.slice/user-1000.slice/user@1000.service 183 | 4:perf_event:/ 184 | 3:pids:/user.slice/user-1000.slice/user@1000.service 185 | 2:blkio:/ 186 | 1:name=systemd:/user.slice/user-1000.slice/user@1000.service/docker.service/f7df0c0b3a8d4350647486b24a5bd5785d494c1a0910cfaee66d3db0db784093 187 | 0::/user.slice/user-1000.slice/user@1000.service/docker.service 188 | ` 189 | 190 | expected := "f7df0c0b3a8d4350647486b24a5bd5785d494c1a0910cfaee66d3db0db784093" 191 | 192 | utils := dockerUtils{} 193 | 194 | actual := utils.extractContainerIDFromCGroups(read) 195 | 196 | assert.Equal(t, expected, actual) 197 | } 198 | -------------------------------------------------------------------------------- /examples/Caddyfile: -------------------------------------------------------------------------------- 1 | # This is an example Caddyfile stored inside docker swarm config 2 | config.example.com { 3 | respond / "Hello World" 200 4 | tls internal 5 | } -------------------------------------------------------------------------------- /examples/distributed.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | 5 | caddy_server: 6 | image: lucaslorentz/caddy-docker-proxy:ci-alpine 7 | ports: 8 | - 80:80 9 | - 443:443 10 | networks: 11 | - caddy_controller 12 | - caddy 13 | environment: 14 | - CADDY_DOCKER_MODE=server 15 | - CADDY_CONTROLLER_NETWORK=10.200.200.0/24 16 | volumes: 17 | # this volume is needed to keep the certificates 18 | # otherwise, new ones will be re-issued upon restart 19 | - caddy_data:/data 20 | deploy: 21 | replicas: 3 22 | labels: 23 | caddy_controlled_server: 24 | 25 | caddy_controller: 26 | image: lucaslorentz/caddy-docker-proxy:ci-alpine 27 | networks: 28 | - caddy_controller 29 | - caddy 30 | environment: 31 | - CADDY_DOCKER_MODE=controller 32 | - CADDY_CONTROLLER_NETWORK=10.200.200.0/24 33 | volumes: 34 | - /var/run/docker.sock:/var/run/docker.sock 35 | 36 | # Proxy to service 37 | whoami0: 38 | image: traefik/whoami 39 | networks: 40 | - caddy 41 | deploy: 42 | labels: 43 | caddy: whoami0.example.com 44 | caddy.reverse_proxy: "{{upstreams 80}}" 45 | # remove the following line when you have verified your setup 46 | # Otherwise you risk being rate limited by let's encrypt 47 | caddy.tls.ca: https://acme-staging-v02.api.letsencrypt.org/directory 48 | 49 | # Proxy to service 50 | whoami1: 51 | image: traefik/whoami 52 | networks: 53 | - caddy 54 | deploy: 55 | labels: 56 | caddy: whoami1.example.com 57 | caddy.reverse_proxy: "{{upstreams 80}}" 58 | caddy.tls: "internal" 59 | 60 | # Proxy to container 61 | whoami2: 62 | image: traefik/whoami 63 | networks: 64 | - caddy 65 | labels: 66 | caddy: whoami2.example.com 67 | caddy.reverse_proxy: "{{upstreams 80}}" 68 | caddy.tls: "internal" 69 | 70 | # Proxy to container 71 | whoami3: 72 | image: traefik/whoami 73 | networks: 74 | - caddy 75 | labels: 76 | caddy: whoami3.example.com 77 | caddy.reverse_proxy: "{{upstreams 80}}" 78 | caddy.tls: "internal" 79 | 80 | # Proxy with matches and route 81 | echo_0: 82 | image: traefik/whoami 83 | networks: 84 | - caddy 85 | deploy: 86 | labels: 87 | caddy: echo0.example.com 88 | caddy.@match.path: "/sourcepath /sourcepath/*" 89 | caddy.route: "@match" 90 | caddy.route.0_uri: "strip_prefix /sourcepath" 91 | caddy.route.1_rewrite: "* /targetpath{path}" 92 | caddy.route.2_reverse_proxy: "{{upstreams 80}}" 93 | caddy.tls: "internal" 94 | 95 | networks: 96 | caddy: 97 | driver: overlay 98 | caddy_controller: 99 | driver: overlay 100 | ipam: 101 | driver: default 102 | config: 103 | - subnet: "10.200.200.0/24" 104 | 105 | volumes: 106 | caddy_data: {} 107 | -------------------------------------------------------------------------------- /examples/standalone.yaml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | # used for specific settings you have outside of your docker config 4 | # ex: proxies to external servers, storage configuration... 5 | # remove this block entirely if not needed (Only used for Docker Swarm) 6 | configs: 7 | caddy-basic-content: 8 | file: ./Caddyfile 9 | labels: 10 | caddy: 11 | 12 | services: 13 | caddy: 14 | image: lucaslorentz/caddy-docker-proxy:ci-alpine 15 | ports: 16 | - 80:80 17 | - 443:443 18 | networks: 19 | - caddy 20 | volumes: 21 | - /var/run/docker.sock:/var/run/docker.sock 22 | # this volume is needed to keep the certificates 23 | # otherwise, new ones will be re-issued upon restart 24 | - caddy_data:/data 25 | deploy: 26 | labels: # Global options 27 | caddy.email: you@example.com 28 | placement: 29 | constraints: 30 | - node.role == manager 31 | replicas: 1 32 | restart_policy: 33 | condition: any 34 | resources: 35 | reservations: 36 | cpus: "0.1" 37 | memory: 200M 38 | 39 | # Proxy to service 40 | whoami0: 41 | image: traefik/whoami 42 | networks: 43 | - caddy 44 | deploy: 45 | labels: 46 | caddy: whoami0.example.com 47 | caddy.reverse_proxy: "{{upstreams 80}}" 48 | caddy.tls: "internal" 49 | 50 | # Proxy to service that you want to expose to the outside world 51 | whoami1: 52 | image: traefik/whoami 53 | networks: 54 | - caddy 55 | deploy: 56 | labels: 57 | caddy: whoami1.example.com 58 | caddy.reverse_proxy: "{{upstreams 80}}" 59 | # remove the following line when you have verified your setup 60 | # Otherwise you risk being rate limited by let's encrypt 61 | caddy.tls.ca: https://acme-staging-v02.api.letsencrypt.org/directory 62 | 63 | # Proxy to container 64 | whoami2: 65 | image: traefik/whoami 66 | networks: 67 | - caddy 68 | labels: 69 | caddy: whoami2.example.com 70 | caddy.reverse_proxy: "{{upstreams 80}}" 71 | caddy.tls: "internal" 72 | 73 | # Proxy to container 74 | whoami3: 75 | image: traefik/whoami 76 | networks: 77 | - caddy 78 | labels: 79 | caddy: whoami3.example.com 80 | caddy.reverse_proxy: "{{upstreams 80}}" 81 | caddy.tls: "internal" 82 | 83 | # Proxy with matches and route 84 | echo_0: 85 | image: traefik/whoami 86 | networks: 87 | - caddy 88 | deploy: 89 | labels: 90 | caddy: echo0.example.com 91 | caddy.@match.path: "/sourcepath /sourcepath/*" 92 | caddy.route: "@match" 93 | caddy.route.0_uri: "strip_prefix /sourcepath" 94 | caddy.route.1_rewrite: "* /targetpath{path}" 95 | caddy.route.2_reverse_proxy: "{{upstreams 80}}" 96 | caddy.tls: "internal" 97 | 98 | networks: 99 | caddy: 100 | driver: overlay 101 | 102 | volumes: 103 | caddy_data: {} 104 | -------------------------------------------------------------------------------- /generator/containers.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "github.com/docker/docker/api/types" 5 | "github.com/lucaslorentz/caddy-docker-proxy/v2/caddyfile" 6 | "go.uber.org/zap" 7 | ) 8 | 9 | func (g *CaddyfileGenerator) getContainerCaddyfile(container *types.Container, logger *zap.Logger) (*caddyfile.Container, error) { 10 | caddyLabels := g.filterLabels(container.Labels) 11 | 12 | return labelsToCaddyfile(caddyLabels, container, func() ([]string, error) { 13 | return g.getContainerIPAddresses(container, logger, true) 14 | }) 15 | } 16 | 17 | func (g *CaddyfileGenerator) getContainerIPAddresses(container *types.Container, logger *zap.Logger, onlyIngressIps bool) ([]string, error) { 18 | ips := []string{} 19 | 20 | ingressNetworkFromLabel, overrideNetwork := container.Labels[IngressNetworkLabel] 21 | 22 | for networkName, network := range container.NetworkSettings.Networks { 23 | include := false 24 | 25 | if !onlyIngressIps { 26 | include = true 27 | } else if overrideNetwork { 28 | include = networkName == ingressNetworkFromLabel 29 | } else { 30 | include = g.ingressNetworks[network.NetworkID] 31 | } 32 | 33 | if include { 34 | ips = append(ips, network.IPAddress) 35 | } 36 | } 37 | 38 | if len(ips) == 0 { 39 | logger.Warn("Container is not in same network as caddy", zap.String("container", container.ID), zap.String("container id", container.ID)) 40 | 41 | } 42 | 43 | return ips, nil 44 | } 45 | -------------------------------------------------------------------------------- /generator/containers_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/docker/api/types" 7 | "github.com/docker/docker/api/types/network" 8 | "github.com/lucaslorentz/caddy-docker-proxy/v2/config" 9 | ) 10 | 11 | func TestContainers_TemplateData(t *testing.T) { 12 | dockerClient := createBasicDockerClientMock() 13 | dockerClient.ContainersData = []types.Container{ 14 | { 15 | Names: []string{ 16 | "container-name", 17 | }, 18 | NetworkSettings: &types.SummaryNetworkSettings{ 19 | Networks: map[string]*network.EndpointSettings{ 20 | "caddy-network": { 21 | IPAddress: "172.17.0.2", 22 | NetworkID: caddyNetworkID, 23 | }, 24 | }, 25 | }, 26 | Labels: map[string]string{ 27 | fmtLabel("%s"): "{{index .Names 0}}.testdomain.com", 28 | fmtLabel("%s.reverse_proxy"): "{{(index .NetworkSettings.Networks \"caddy-network\").IPAddress}}:5000/api", 29 | }, 30 | }, 31 | } 32 | 33 | const expectedCaddyfile = "container-name.testdomain.com {\n" + 34 | " reverse_proxy 172.17.0.2:5000/api\n" + 35 | "}\n" 36 | 37 | const expectedLogs = commonLogs 38 | 39 | testGeneration(t, dockerClient, nil, expectedCaddyfile, expectedLogs) 40 | } 41 | 42 | func TestContainers_PicksRightNetwork(t *testing.T) { 43 | dockerClient := createBasicDockerClientMock() 44 | dockerClient.ContainersData = []types.Container{ 45 | { 46 | NetworkSettings: &types.SummaryNetworkSettings{ 47 | Networks: map[string]*network.EndpointSettings{ 48 | "other-network": { 49 | IPAddress: "10.0.0.1", 50 | NetworkID: "other-network-id", 51 | }, 52 | "caddy-network": { 53 | IPAddress: "172.17.0.2", 54 | NetworkID: caddyNetworkID, 55 | }, 56 | }, 57 | }, 58 | Labels: map[string]string{ 59 | fmtLabel("%s"): "service.testdomain.com", 60 | fmtLabel("%s.reverse_proxy"): "{{upstreams}}", 61 | }, 62 | }, 63 | } 64 | 65 | const expectedCaddyfile = "service.testdomain.com {\n" + 66 | " reverse_proxy 172.17.0.2\n" + 67 | "}\n" 68 | 69 | const expectedLogs = commonLogs 70 | 71 | testGeneration(t, dockerClient, nil, expectedCaddyfile, expectedLogs) 72 | } 73 | 74 | func TestContainers_DifferentNetwork(t *testing.T) { 75 | dockerClient := createBasicDockerClientMock() 76 | dockerClient.ContainersData = []types.Container{ 77 | { 78 | ID: "CONTAINER-ID", 79 | NetworkSettings: &types.SummaryNetworkSettings{ 80 | Networks: map[string]*network.EndpointSettings{ 81 | "other-network": { 82 | IPAddress: "10.0.0.1", 83 | NetworkID: "other-network-id", 84 | }, 85 | }, 86 | }, 87 | Labels: map[string]string{ 88 | fmtLabel("%s"): "service.testdomain.com", 89 | fmtLabel("%s.reverse_proxy"): "{{upstreams}}", 90 | }, 91 | }, 92 | } 93 | 94 | const expectedCaddyfile = "service.testdomain.com {\n" + 95 | " reverse_proxy\n" + 96 | "}\n" 97 | 98 | const expectedLogs = commonLogs + 99 | `WARN Container is not in same network as caddy {"container": "CONTAINER-ID", "container id": "CONTAINER-ID"}` + newLine 100 | 101 | testGeneration(t, dockerClient, nil, expectedCaddyfile, expectedLogs) 102 | } 103 | 104 | func TestContainers_ManualIngressNetworks(t *testing.T) { 105 | dockerClient := createBasicDockerClientMock() 106 | dockerClient.NetworksData = []types.NetworkResource{ 107 | { 108 | ID: "other-network-id", 109 | Name: "other-network-name", 110 | }, 111 | } 112 | dockerClient.ContainersData = []types.Container{ 113 | { 114 | ID: "CONTAINER-ID", 115 | NetworkSettings: &types.SummaryNetworkSettings{ 116 | Networks: map[string]*network.EndpointSettings{ 117 | "other-network": { 118 | IPAddress: "10.0.0.1", 119 | NetworkID: "other-network-id", 120 | }, 121 | }, 122 | }, 123 | Labels: map[string]string{ 124 | fmtLabel("%s"): "service.testdomain.com", 125 | fmtLabel("%s.reverse_proxy"): "{{upstreams}}", 126 | }, 127 | }, 128 | } 129 | 130 | const expectedCaddyfile = "service.testdomain.com {\n" + 131 | " reverse_proxy 10.0.0.1\n" + 132 | "}\n" 133 | 134 | const expectedLogs = otherIngressNetworksMapLog + swarmIsAvailableLog 135 | 136 | testGeneration(t, dockerClient, func(options *config.Options) { 137 | options.IngressNetworks = []string{"other-network-name"} 138 | }, expectedCaddyfile, expectedLogs) 139 | } 140 | 141 | func TestContainers_OverrideIngressNetworks(t *testing.T) { 142 | dockerClient := createBasicDockerClientMock() 143 | dockerClient.NetworksData = []types.NetworkResource{ 144 | { 145 | ID: "other-network-id", 146 | Name: "other-network-name", 147 | }, 148 | { 149 | ID: "another-network-id", 150 | Name: "another-network-name", 151 | }, 152 | } 153 | dockerClient.ContainersData = []types.Container{ 154 | { 155 | ID: "CONTAINER-ID", 156 | NetworkSettings: &types.SummaryNetworkSettings{ 157 | Networks: map[string]*network.EndpointSettings{ 158 | "other-network": { 159 | IPAddress: "10.0.0.1", 160 | NetworkID: "other-network-id", 161 | }, 162 | "another-network": { 163 | IPAddress: "10.0.0.2", 164 | NetworkID: "other-network-id", 165 | }, 166 | }, 167 | }, 168 | Labels: map[string]string{ 169 | "caddy_ingress_network": "another-network", 170 | fmtLabel("%s"): "service.testdomain.com", 171 | fmtLabel("%s.reverse_proxy"): "{{upstreams}}", 172 | }, 173 | }, 174 | } 175 | 176 | const expectedCaddyfile = "service.testdomain.com {\n" + 177 | " reverse_proxy 10.0.0.2\n" + 178 | "}\n" 179 | 180 | const expectedLogs = otherIngressNetworksMapLog + swarmIsAvailableLog 181 | 182 | testGeneration(t, dockerClient, func(options *config.Options) { 183 | options.IngressNetworks = []string{"other-network-name"} 184 | }, expectedCaddyfile, expectedLogs) 185 | } 186 | 187 | func TestContainers_Replicas(t *testing.T) { 188 | dockerClient := createBasicDockerClientMock() 189 | dockerClient.ContainersData = []types.Container{ 190 | { 191 | NetworkSettings: &types.SummaryNetworkSettings{ 192 | Networks: map[string]*network.EndpointSettings{ 193 | "caddy-network": { 194 | IPAddress: "172.17.0.2", 195 | NetworkID: caddyNetworkID, 196 | }, 197 | }, 198 | }, 199 | Labels: map[string]string{ 200 | fmtLabel("%s"): "service.testdomain.com", 201 | fmtLabel("%s.reverse_proxy"): "{{upstreams}}", 202 | }, 203 | }, 204 | { 205 | NetworkSettings: &types.SummaryNetworkSettings{ 206 | Networks: map[string]*network.EndpointSettings{ 207 | "caddy-network": { 208 | IPAddress: "172.17.0.3", 209 | NetworkID: caddyNetworkID, 210 | }, 211 | }, 212 | }, 213 | Labels: map[string]string{ 214 | fmtLabel("%s"): "service.testdomain.com", 215 | fmtLabel("%s.reverse_proxy"): "{{upstreams}}", 216 | }, 217 | }, 218 | } 219 | 220 | const expectedCaddyfile = "service.testdomain.com {\n" + 221 | " reverse_proxy 172.17.0.2 172.17.0.3\n" + 222 | "}\n" 223 | 224 | const expectedLogs = commonLogs 225 | 226 | testGeneration(t, dockerClient, nil, expectedCaddyfile, expectedLogs) 227 | } 228 | 229 | func TestContainers_DoNotMergeDifferentProxies(t *testing.T) { 230 | dockerClient := createBasicDockerClientMock() 231 | dockerClient.ContainersData = []types.Container{ 232 | { 233 | NetworkSettings: &types.SummaryNetworkSettings{ 234 | Networks: map[string]*network.EndpointSettings{ 235 | "caddy-network": { 236 | IPAddress: "172.17.0.2", 237 | NetworkID: caddyNetworkID, 238 | }, 239 | }, 240 | }, 241 | Labels: map[string]string{ 242 | fmtLabel("%s"): "service.testdomain.com", 243 | fmtLabel("%s.reverse_proxy"): "/a/* {{upstreams}}", 244 | }, 245 | }, 246 | { 247 | NetworkSettings: &types.SummaryNetworkSettings{ 248 | Networks: map[string]*network.EndpointSettings{ 249 | "caddy-network": { 250 | IPAddress: "172.17.0.3", 251 | NetworkID: caddyNetworkID, 252 | }, 253 | }, 254 | }, 255 | Labels: map[string]string{ 256 | fmtLabel("%s"): "service.testdomain.com", 257 | fmtLabel("%s.reverse_proxy"): "/b/* {{upstreams}}", 258 | }, 259 | }, 260 | } 261 | 262 | const expectedCaddyfile = "service.testdomain.com {\n" + 263 | " reverse_proxy /a/* 172.17.0.2\n" + 264 | " reverse_proxy /b/* 172.17.0.3\n" + 265 | "}\n" 266 | 267 | const expectedLogs = commonLogs 268 | 269 | testGeneration(t, dockerClient, nil, expectedCaddyfile, expectedLogs) 270 | } 271 | 272 | func TestContainers_ComplexMerge(t *testing.T) { 273 | dockerClient := createBasicDockerClientMock() 274 | dockerClient.ContainersData = []types.Container{ 275 | { 276 | NetworkSettings: &types.SummaryNetworkSettings{ 277 | Networks: map[string]*network.EndpointSettings{ 278 | "caddy-network": { 279 | IPAddress: "172.17.0.2", 280 | NetworkID: caddyNetworkID, 281 | }, 282 | }, 283 | }, 284 | Labels: map[string]string{ 285 | fmtLabel("%s"): "service.testdomain.com", 286 | fmtLabel("%s.route"): "/a/*", 287 | fmtLabel("%s.route.0_uri"): "strip_prefix /a", 288 | fmtLabel("%s.route.reverse_proxy"): "{{upstreams}}", 289 | fmtLabel("%s.route.reverse_proxy.health_uri"): "/health", 290 | fmtLabel("%s.redir"): "/a /a1", 291 | fmtLabel("%s.tls"): "internal", 292 | }, 293 | }, 294 | { 295 | NetworkSettings: &types.SummaryNetworkSettings{ 296 | Networks: map[string]*network.EndpointSettings{ 297 | "caddy-network": { 298 | IPAddress: "172.17.0.3", 299 | NetworkID: caddyNetworkID, 300 | }, 301 | }, 302 | }, 303 | Labels: map[string]string{ 304 | fmtLabel("%s"): "service.testdomain.com", 305 | fmtLabel("%s.route"): "/b/*", 306 | fmtLabel("%s.route.0_uri"): "strip_prefix /b", 307 | fmtLabel("%s.route.reverse_proxy"): "{{upstreams}}", 308 | fmtLabel("%s.route.reverse_proxy.health_uri"): "/health", 309 | fmtLabel("%s.redir"): "/b /b1", 310 | fmtLabel("%s.tls"): "internal", 311 | }, 312 | }, 313 | } 314 | 315 | const expectedCaddyfile = "service.testdomain.com {\n" + 316 | " redir /a /a1\n" + 317 | " redir /b /b1\n" + 318 | " route /a/* {\n" + 319 | " uri strip_prefix /a\n" + 320 | " reverse_proxy 172.17.0.2 {\n" + 321 | " health_uri /health\n" + 322 | " }\n" + 323 | " }\n" + 324 | " route /b/* {\n" + 325 | " uri strip_prefix /b\n" + 326 | " reverse_proxy 172.17.0.3 {\n" + 327 | " health_uri /health\n" + 328 | " }\n" + 329 | " }\n" + 330 | " tls internal\n" + 331 | "}\n" 332 | 333 | const expectedLogs = commonLogs 334 | 335 | testGeneration(t, dockerClient, nil, expectedCaddyfile, expectedLogs) 336 | } 337 | 338 | func TestContainers_WithSnippets(t *testing.T) { 339 | dockerClient := createBasicDockerClientMock() 340 | dockerClient.ContainersData = []types.Container{ 341 | { 342 | NetworkSettings: &types.SummaryNetworkSettings{ 343 | Networks: map[string]*network.EndpointSettings{ 344 | "caddy-network": { 345 | IPAddress: "172.17.0.3", 346 | NetworkID: caddyNetworkID, 347 | }, 348 | }, 349 | }, 350 | Labels: map[string]string{ 351 | fmtLabel("%s"): "service.testdomain.com", 352 | fmtLabel("%s.reverse_proxy"): "{{upstreams}}", 353 | fmtLabel("%s.import"): "mysnippet-1", 354 | }, 355 | }, 356 | { 357 | NetworkSettings: &types.SummaryNetworkSettings{ 358 | Networks: map[string]*network.EndpointSettings{ 359 | "caddy-network": { 360 | IPAddress: "172.17.0.2", 361 | NetworkID: caddyNetworkID, 362 | }, 363 | }, 364 | }, 365 | Labels: map[string]string{ 366 | fmtLabel("%s_1"): "(mysnippet-1)", 367 | fmtLabel("%s_1.tls"): "internal", 368 | fmtLabel("%s_2"): "(mysnippet-2)", 369 | fmtLabel("%s_2.tls"): "internal", 370 | }, 371 | }, 372 | } 373 | 374 | const expectedCaddyfile = "(mysnippet-1) {\n" + 375 | " tls internal\n" + 376 | "}\n" + 377 | "(mysnippet-2) {\n" + 378 | " tls internal\n" + 379 | "}\n" + 380 | "service.testdomain.com {\n" + 381 | " import mysnippet-1\n" + 382 | " reverse_proxy 172.17.0.3\n" + 383 | "}\n" 384 | 385 | const expectedLogs = commonLogs 386 | 387 | testGeneration(t, dockerClient, nil, expectedCaddyfile, expectedLogs) 388 | } 389 | -------------------------------------------------------------------------------- /generator/generator.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "net" 8 | "os" 9 | "regexp" 10 | "time" 11 | 12 | "github.com/docker/docker/api/types" 13 | "github.com/docker/docker/api/types/container" 14 | "github.com/docker/docker/api/types/swarm" 15 | "github.com/lucaslorentz/caddy-docker-proxy/v2/caddyfile" 16 | "github.com/lucaslorentz/caddy-docker-proxy/v2/config" 17 | "github.com/lucaslorentz/caddy-docker-proxy/v2/docker" 18 | 19 | "go.uber.org/zap" 20 | ) 21 | 22 | // DefaultLabelPrefix for caddy labels in docker 23 | const DefaultLabelPrefix = "caddy" 24 | 25 | const IngressNetworkLabel = "caddy_ingress_network" 26 | 27 | const swarmAvailabilityCacheInterval = 1 * time.Minute 28 | 29 | // CaddyfileGenerator generates caddyfile from docker configuration 30 | type CaddyfileGenerator struct { 31 | options *config.Options 32 | labelRegex *regexp.Regexp 33 | dockerClients []docker.Client 34 | dockerUtils docker.Utils 35 | ingressNetworks map[string]bool 36 | swarmIsAvailable []bool 37 | swarmIsAvailableTime time.Time 38 | } 39 | 40 | // CreateGenerator creates a new generator 41 | func CreateGenerator(dockerClients []docker.Client, dockerUtils docker.Utils, options *config.Options) *CaddyfileGenerator { 42 | var labelRegexString = fmt.Sprintf("^%s(_\\d+)?(\\.|$)", options.LabelPrefix) 43 | 44 | return &CaddyfileGenerator{ 45 | options: options, 46 | labelRegex: regexp.MustCompile(labelRegexString), 47 | dockerClients: dockerClients, 48 | swarmIsAvailable: make([]bool, len(dockerClients)), 49 | dockerUtils: dockerUtils, 50 | } 51 | } 52 | 53 | // GenerateCaddyfile generates a caddy file config from docker metadata 54 | func (g *CaddyfileGenerator) GenerateCaddyfile(logger *zap.Logger) ([]byte, []string) { 55 | var caddyfileBuffer bytes.Buffer 56 | 57 | if g.ingressNetworks == nil { 58 | ingressNetworks, err := g.getIngressNetworks(logger) 59 | if err == nil { 60 | g.ingressNetworks = ingressNetworks 61 | } else { 62 | logger.Error("Failed to get ingress networks", zap.Error(err)) 63 | } 64 | } 65 | 66 | if time.Since(g.swarmIsAvailableTime) > swarmAvailabilityCacheInterval { 67 | g.checkSwarmAvailability(logger, time.Time.IsZero(g.swarmIsAvailableTime)) 68 | g.swarmIsAvailableTime = time.Now() 69 | } 70 | 71 | caddyfileBlock := caddyfile.CreateContainer() 72 | controlledServers := []string{} 73 | 74 | // Add caddyfile from path 75 | if g.options.CaddyfilePath != "" { 76 | dat, err := os.ReadFile(g.options.CaddyfilePath) 77 | if err != nil { 78 | logger.Error("Failed to read Caddyfile", zap.String("path", g.options.CaddyfilePath), zap.Error(err)) 79 | } else { 80 | block, err := caddyfile.Unmarshal(dat) 81 | if err != nil { 82 | logger.Error("Failed to parse Caddyfile", zap.String("path", g.options.CaddyfilePath), zap.Error(err)) 83 | } else { 84 | caddyfileBlock.Merge(block) 85 | } 86 | } 87 | } else { 88 | logger.Debug("Skipping default Caddyfile because no path is set") 89 | } 90 | 91 | for i, dockerClient := range g.dockerClients { 92 | 93 | // Add Caddyfile from swarm configs 94 | if g.swarmIsAvailable[i] { 95 | configs, err := dockerClient.ConfigList(context.Background(), types.ConfigListOptions{}) 96 | if err == nil { 97 | for _, config := range configs { 98 | if _, hasLabel := config.Spec.Labels[g.options.LabelPrefix]; hasLabel { 99 | fullConfig, _, err := dockerClient.ConfigInspectWithRaw(context.Background(), config.ID) 100 | if err != nil { 101 | logger.Error("Failed to inspect Swarm Config", zap.String("config", config.Spec.Name), zap.Error(err)) 102 | 103 | } else { 104 | block, err := caddyfile.Unmarshal(fullConfig.Spec.Data) 105 | if err != nil { 106 | logger.Error("Failed to parse Swarm Config caddyfile format", zap.String("config", config.Spec.Name), zap.Error(err)) 107 | } else { 108 | caddyfileBlock.Merge(block) 109 | } 110 | } 111 | } 112 | } 113 | } else { 114 | logger.Error("Failed to get Swarm configs", zap.Error(err)) 115 | } 116 | } else { 117 | logger.Debug("Skipping swarm config caddyfiles because swarm is not available") 118 | } 119 | 120 | // Add containers 121 | containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: g.options.ScanStoppedContainers}) 122 | if err == nil { 123 | for _, container := range containers { 124 | if _, isControlledServer := container.Labels[g.options.ControlledServersLabel]; isControlledServer { 125 | ips, err := g.getContainerIPAddresses(&container, logger, false) 126 | if err != nil { 127 | logger.Error("Failed to get Container IPs", zap.String("container", container.ID), zap.Error(err)) 128 | } else { 129 | for _, ip := range ips { 130 | if g.options.ControllerNetwork == nil || g.options.ControllerNetwork.Contains(net.ParseIP(ip)) { 131 | controlledServers = append(controlledServers, ip) 132 | } 133 | } 134 | } 135 | } 136 | containerCaddyfile, err := g.getContainerCaddyfile(&container, logger) 137 | if err == nil { 138 | caddyfileBlock.Merge(containerCaddyfile) 139 | } else { 140 | logger.Error("Failed to get Container Caddyfile", zap.String("container", container.ID), zap.Error(err)) 141 | } 142 | } 143 | } else { 144 | logger.Error("Failed to get ContainerList", zap.Error(err)) 145 | } 146 | 147 | // Add services 148 | if g.swarmIsAvailable[i] { 149 | services, err := dockerClient.ServiceList(context.Background(), types.ServiceListOptions{}) 150 | if err == nil { 151 | for _, service := range services { 152 | logger.Debug("Swarm service", zap.String("service", service.Spec.Name)) 153 | 154 | if _, isControlledServer := service.Spec.Labels[g.options.ControlledServersLabel]; isControlledServer { 155 | ips, err := g.getServiceTasksIps(&service, logger, false) 156 | if err != nil { 157 | logger.Error("Failed to get Swarm service IPs", zap.String("service", service.Spec.Name), zap.Error(err)) 158 | } else { 159 | for _, ip := range ips { 160 | if g.options.ControllerNetwork == nil || g.options.ControllerNetwork.Contains(net.ParseIP(ip)) { 161 | controlledServers = append(controlledServers, ip) 162 | } 163 | } 164 | } 165 | } 166 | 167 | // caddy. labels based config 168 | serviceCaddyfile, err := g.getServiceCaddyfile(&service, logger) 169 | if err == nil { 170 | caddyfileBlock.Merge(serviceCaddyfile) 171 | } else { 172 | logger.Error("Failed to get Swarm service caddyfile", zap.String("service", service.Spec.Name), zap.Error(err)) 173 | } 174 | } 175 | } else { 176 | logger.Error("Failed to get Swarm services", zap.Error(err)) 177 | } 178 | } else { 179 | logger.Debug("Skipping swarm services because swarm is not available") 180 | } 181 | } 182 | 183 | // Write global blocks first 184 | globalCaddyfile := caddyfile.CreateContainer() 185 | for _, block := range caddyfileBlock.Children { 186 | if block.IsGlobalBlock() { 187 | globalCaddyfile.AddBlock(block) 188 | caddyfileBlock.Remove(block) 189 | } 190 | } 191 | caddyfileBuffer.Write(globalCaddyfile.Marshal()) 192 | 193 | // Write remaining blocks 194 | caddyfileBuffer.Write(caddyfileBlock.Marshal()) 195 | 196 | caddyfileContent := caddyfileBuffer.Bytes() 197 | 198 | if g.options.ProcessCaddyfile { 199 | processCaddyfileContent, processLogs := caddyfile.Process(caddyfileContent) 200 | caddyfileContent = processCaddyfileContent 201 | if len(processLogs) > 0 { 202 | logger.Info("Process Caddyfile", zap.ByteString("logs", processLogs)) 203 | } 204 | } 205 | 206 | if len(caddyfileContent) == 0 { 207 | caddyfileContent = []byte("# Empty caddyfile") 208 | } 209 | 210 | if g.options.Mode&config.Server == config.Server { 211 | controlledServers = append(controlledServers, "localhost") 212 | } 213 | 214 | return caddyfileContent, controlledServers 215 | } 216 | 217 | func (g *CaddyfileGenerator) checkSwarmAvailability(logger *zap.Logger, isFirstCheck bool) { 218 | 219 | for i, dockerClient := range g.dockerClients { 220 | info, err := dockerClient.Info(context.Background()) 221 | if err == nil { 222 | newSwarmIsAvailable := info.Swarm.LocalNodeState == swarm.LocalNodeStateActive 223 | if isFirstCheck || newSwarmIsAvailable != g.swarmIsAvailable[i] { 224 | logger.Info("Swarm is available", zap.Bool("new", newSwarmIsAvailable)) 225 | } 226 | g.swarmIsAvailable[i] = newSwarmIsAvailable 227 | } else { 228 | logger.Error("Swarm availability check failed", zap.Error(err)) 229 | g.swarmIsAvailable[i] = false 230 | } 231 | } 232 | } 233 | 234 | func (g *CaddyfileGenerator) getIngressNetworks(logger *zap.Logger) (map[string]bool, error) { 235 | ingressNetworks := map[string]bool{} 236 | 237 | for _, dockerClient := range g.dockerClients { 238 | if len(g.options.IngressNetworks) > 0 { 239 | networks, err := dockerClient.NetworkList(context.Background(), types.NetworkListOptions{}) 240 | if err != nil { 241 | return nil, err 242 | } 243 | for _, dockerNetwork := range networks { 244 | if dockerNetwork.Ingress { 245 | continue 246 | } 247 | for _, ingressNetwork := range g.options.IngressNetworks { 248 | if dockerNetwork.Name == ingressNetwork { 249 | ingressNetworks[dockerNetwork.ID] = true 250 | ingressNetworks[dockerNetwork.Name] = true 251 | } 252 | } 253 | } 254 | } else { 255 | containerID, err := g.dockerUtils.GetCurrentContainerID() 256 | if err != nil { 257 | return nil, err 258 | } 259 | logger.Info("Caddy ContainerID", zap.String("ID", containerID)) 260 | container, err := dockerClient.ContainerInspect(context.Background(), containerID) 261 | if err != nil { 262 | return nil, err 263 | } 264 | 265 | for _, network := range container.NetworkSettings.Networks { 266 | networkInfo, err := dockerClient.NetworkInspect(context.Background(), network.NetworkID, types.NetworkInspectOptions{}) 267 | if err != nil { 268 | return nil, err 269 | } 270 | if networkInfo.Ingress { 271 | continue 272 | } 273 | ingressNetworks[networkInfo.ID] = true 274 | ingressNetworks[networkInfo.Name] = true 275 | } 276 | } 277 | } 278 | 279 | logger.Info("IngressNetworksMap", zap.String("ingres", fmt.Sprintf("%v", ingressNetworks))) 280 | 281 | return ingressNetworks, nil 282 | } 283 | 284 | func (g *CaddyfileGenerator) filterLabels(labels map[string]string) map[string]string { 285 | filteredLabels := map[string]string{} 286 | for label, value := range labels { 287 | if g.labelRegex.MatchString(label) { 288 | filteredLabels[label] = value 289 | } 290 | } 291 | return filteredLabels 292 | } 293 | -------------------------------------------------------------------------------- /generator/generator_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "log" 9 | "testing" 10 | 11 | "github.com/docker/docker/api/types" 12 | "github.com/docker/docker/api/types/network" 13 | "github.com/docker/docker/api/types/swarm" 14 | "github.com/docker/docker/api/types/system" 15 | "github.com/lucaslorentz/caddy-docker-proxy/v2/config" 16 | "github.com/lucaslorentz/caddy-docker-proxy/v2/docker" 17 | "github.com/stretchr/testify/assert" 18 | "go.uber.org/zap" 19 | "go.uber.org/zap/zapcore" 20 | ) 21 | 22 | var caddyContainerID = "container-id" 23 | var caddyNetworkID = "network-id" 24 | var caddyNetworkName = "network-name" 25 | 26 | const newLine = "\n" 27 | const containerIdLog = `INFO Caddy ContainerID {"ID": "container-id"}` + newLine 28 | const ingressNetworksMapLog = `INFO IngressNetworksMap {"ingres": "map[network-id:true network-name:true]"}` + newLine 29 | const otherIngressNetworksMapLog = `INFO IngressNetworksMap {"ingres": "map[other-network-id:true other-network-name:true]"}` + newLine 30 | const swarmIsAvailableLog = `INFO Swarm is available {"new": true}` + newLine 31 | const swarmIsDisabledLog = `INFO Swarm is available {"new": false}` + newLine 32 | const commonLogs = containerIdLog + ingressNetworksMapLog + swarmIsAvailableLog 33 | 34 | func init() { 35 | log.SetOutput(io.Discard) 36 | } 37 | 38 | func fmtLabel(s string) string { 39 | return fmt.Sprintf(s, DefaultLabelPrefix) 40 | } 41 | 42 | func TestMergeConfigContent(t *testing.T) { 43 | dockerClient := createBasicDockerClientMock() 44 | dockerClient.ConfigsData = []swarm.Config{ 45 | { 46 | ID: "CONFIG-ID", 47 | Spec: swarm.ConfigSpec{ 48 | Annotations: swarm.Annotations{ 49 | Labels: map[string]string{ 50 | fmtLabel("%s"): "", 51 | }, 52 | }, 53 | Data: []byte( 54 | "{\n" + 55 | " email test@example.com\n" + 56 | "}\n" + 57 | "example.com {\n" + 58 | " reverse_proxy 127.0.0.1\n" + 59 | "}", 60 | ), 61 | }, 62 | }, 63 | } 64 | dockerClient.ContainersData = []types.Container{ 65 | { 66 | Names: []string{ 67 | "container-name", 68 | }, 69 | NetworkSettings: &types.SummaryNetworkSettings{ 70 | Networks: map[string]*network.EndpointSettings{ 71 | "caddy-network": { 72 | IPAddress: "172.17.0.2", 73 | NetworkID: caddyNetworkID, 74 | }, 75 | }, 76 | }, 77 | Labels: map[string]string{ 78 | fmtLabel("%s"): "example.com", 79 | fmtLabel("%s.reverse_proxy"): "{{upstreams}}", 80 | fmtLabel("%s_1.experimental_http3"): "", 81 | }, 82 | }, 83 | } 84 | 85 | const expectedCaddyfile = "{\n" + 86 | " email test@example.com\n" + 87 | " experimental_http3\n" + 88 | "}\n" + 89 | "example.com {\n" + 90 | " reverse_proxy 127.0.0.1 172.17.0.2\n" + 91 | "}\n" 92 | 93 | const expectedLogs = commonLogs 94 | 95 | testGeneration(t, dockerClient, nil, expectedCaddyfile, expectedLogs) 96 | } 97 | 98 | func TestIgnoreLabelsWithoutCaddyPrefix(t *testing.T) { 99 | dockerClient := createBasicDockerClientMock() 100 | dockerClient.ServicesData = []swarm.Service{ 101 | { 102 | Spec: swarm.ServiceSpec{ 103 | Annotations: swarm.Annotations{ 104 | Name: "service", 105 | Labels: map[string]string{ 106 | "caddy_version": "2.0.0", 107 | "caddyversion": "2.0.0", 108 | "caddy_.version": "2.0.0", 109 | "version_caddy": "2.0.0", 110 | }, 111 | }, 112 | }, 113 | Endpoint: swarm.Endpoint{ 114 | VirtualIPs: []swarm.EndpointVirtualIP{ 115 | { 116 | NetworkID: caddyNetworkID, 117 | }, 118 | }, 119 | }, 120 | }, 121 | } 122 | 123 | const expectedCaddyfile = "# Empty caddyfile" 124 | 125 | const expectedLogs = commonLogs 126 | 127 | testGeneration(t, dockerClient, nil, expectedCaddyfile, expectedLogs) 128 | } 129 | 130 | func testGeneration( 131 | t *testing.T, 132 | dockerClient docker.Client, 133 | customizeOptions func(*config.Options), 134 | expectedCaddyfile string, 135 | expectedLogs string, 136 | ) { 137 | dockerUtils := createDockerUtilsMock() 138 | 139 | options := &config.Options{ 140 | LabelPrefix: DefaultLabelPrefix, 141 | } 142 | 143 | if customizeOptions != nil { 144 | customizeOptions(options) 145 | } 146 | 147 | generator := CreateGenerator([]docker.Client{dockerClient}, dockerUtils, options) 148 | 149 | var logsBuffer bytes.Buffer 150 | encoderConfig := zap.NewDevelopmentEncoderConfig() 151 | encoderConfig.TimeKey = "" 152 | encoder := zapcore.NewConsoleEncoder(encoderConfig) 153 | writer := bufio.NewWriter(&logsBuffer) 154 | logger := zap.New(zapcore.NewCore(encoder, zapcore.AddSync(writer), zapcore.InfoLevel)) 155 | 156 | caddyfileBytes, _ := generator.GenerateCaddyfile(logger) 157 | writer.Flush() 158 | assert.Equal(t, expectedCaddyfile, string(caddyfileBytes)) 159 | assert.Equal(t, expectedLogs, logsBuffer.String()) 160 | } 161 | 162 | func createBasicDockerClientMock() *docker.ClientMock { 163 | return &docker.ClientMock{ 164 | ContainersData: []types.Container{}, 165 | ServicesData: []swarm.Service{}, 166 | ConfigsData: []swarm.Config{}, 167 | TasksData: []swarm.Task{}, 168 | NetworksData: []types.NetworkResource{}, 169 | InfoData: system.Info{ 170 | Swarm: swarm.Info{ 171 | LocalNodeState: swarm.LocalNodeStateActive, 172 | }, 173 | }, 174 | ContainerInspectData: map[string]types.ContainerJSON{ 175 | caddyContainerID: { 176 | NetworkSettings: &types.NetworkSettings{ 177 | Networks: map[string]*network.EndpointSettings{ 178 | "overlay": { 179 | NetworkID: caddyNetworkID, 180 | }, 181 | }, 182 | }, 183 | }, 184 | }, 185 | NetworkInspectData: map[string]types.NetworkResource{ 186 | caddyNetworkID: { 187 | Ingress: false, 188 | ID: caddyNetworkID, 189 | Name: caddyNetworkName, 190 | }, 191 | }, 192 | } 193 | } 194 | 195 | func createDockerUtilsMock() *docker.UtilsMock { 196 | return &docker.UtilsMock{ 197 | MockGetCurrentContainerID: func() (string, error) { 198 | return caddyContainerID, nil 199 | }, 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /generator/labels.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "text/template" 7 | 8 | "github.com/lucaslorentz/caddy-docker-proxy/v2/caddyfile" 9 | ) 10 | 11 | type targetsProvider func() ([]string, error) 12 | 13 | func labelsToCaddyfile(labels map[string]string, templateData interface{}, getTargets targetsProvider) (*caddyfile.Container, error) { 14 | funcMap := template.FuncMap{ 15 | "upstreams": func(options ...interface{}) (string, error) { 16 | targets, err := getTargets() 17 | transformed := []string{} 18 | for _, target := range targets { 19 | for _, param := range options { 20 | if protocol, isProtocol := param.(string); isProtocol { 21 | target = protocol + "://" + target 22 | } else if port, isPort := param.(int); isPort { 23 | target = target + ":" + strconv.Itoa(port) 24 | } 25 | } 26 | transformed = append(transformed, target) 27 | } 28 | return strings.Join(transformed, " "), err 29 | }, 30 | "http": func() string { 31 | return "http" 32 | }, 33 | "https": func() string { 34 | return "https" 35 | }, 36 | "h2c": func() string { 37 | return "h2c" 38 | }, 39 | } 40 | 41 | return caddyfile.FromLabels(labels, templateData, funcMap) 42 | } 43 | -------------------------------------------------------------------------------- /generator/labels_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestLabelsToCaddyfile(t *testing.T) { 14 | // load the list of test files from the dir 15 | files, err := os.ReadDir("./testdata/labels") 16 | if err != nil { 17 | t.Errorf("failed to read labels dir: %s", err) 18 | } 19 | 20 | // prep a regexp to fix strings on windows 21 | winNewlines := regexp.MustCompile(`\r?\n`) 22 | 23 | for _, f := range files { 24 | if f.IsDir() { 25 | continue 26 | } 27 | 28 | // read the test file 29 | filename := f.Name() 30 | data, err := os.ReadFile("./testdata/labels/" + filename) 31 | if err != nil { 32 | t.Errorf("failed to read %s dir: %s", filename, err) 33 | } 34 | 35 | // split the labels (first) and Caddyfile (second) parts 36 | parts := strings.Split(string(data), "----------") 37 | labelsString, expectedCaddyfile := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) 38 | 39 | // parse label key-value pairs 40 | labels, err := parseLabelsFromString(labelsString) 41 | if err != nil { 42 | t.Errorf("failed to parse labels from %s", filename) 43 | } 44 | 45 | // replace windows newlines in the json with unix newlines 46 | expectedCaddyfile = winNewlines.ReplaceAllString(expectedCaddyfile, "\n") 47 | 48 | // convert the labels to a Caddyfile 49 | caddyfileBlock, err := labelsToCaddyfile(labels, nil, func() ([]string, error) { 50 | return []string{"target"}, nil 51 | }) 52 | 53 | // if the result is nil then we expect an empty Caddyfile 54 | // or an error message prefixed with "err: " 55 | if caddyfileBlock == nil { 56 | if strings.HasPrefix(expectedCaddyfile, "err: ") { 57 | assert.Error(t, err, expectedCaddyfile[4:]) 58 | } else if expectedCaddyfile != "" { 59 | t.Errorf("got nil in %s but expected: %s", filename, expectedCaddyfile) 60 | } 61 | continue 62 | } 63 | 64 | // if caddyfileBlock is not nil, we expect no error 65 | assert.NoError(t, err, "expected no error in %s", filename) 66 | 67 | // compare the actual and expected Caddyfiles 68 | actualCaddyfile := strings.TrimSpace(string(caddyfileBlock.Marshal())) 69 | assert.Equal(t, expectedCaddyfile, actualCaddyfile, 70 | "comparison failed in %s: \nExpected:\n%s\n\nActual:\n%s\n", 71 | filename, expectedCaddyfile, actualCaddyfile) 72 | } 73 | } 74 | 75 | func parseLabelsFromString(s string) (map[string]string, error) { 76 | labels := make(map[string]string) 77 | 78 | lines := strings.Split(s, "\n") 79 | lineNumber := 0 80 | 81 | for _, line := range lines { 82 | line = strings.ReplaceAll(strings.TrimSpace(line), "NEW_LINE", "\n") 83 | lineNumber++ 84 | 85 | // skip lines starting with comment 86 | if strings.HasPrefix(line, "#") { 87 | continue 88 | } 89 | 90 | // skip empty line 91 | if len(line) == 0 { 92 | continue 93 | } 94 | 95 | fields := strings.SplitN(line, "=", 2) 96 | if len(fields) != 2 { 97 | return nil, fmt.Errorf("can't parse line %d; line should be in KEY = VALUE format", lineNumber) 98 | } 99 | 100 | key := strings.TrimSpace(fields[0]) 101 | val := strings.TrimSpace(fields[1]) 102 | 103 | if key == "" { 104 | return nil, fmt.Errorf("missing or empty key on line %d", lineNumber) 105 | } 106 | labels[key] = val 107 | } 108 | 109 | return labels, nil 110 | } 111 | -------------------------------------------------------------------------------- /generator/services.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "context" 5 | "net" 6 | 7 | "github.com/docker/docker/api/types" 8 | "github.com/docker/docker/api/types/filters" 9 | "github.com/docker/docker/api/types/swarm" 10 | "github.com/lucaslorentz/caddy-docker-proxy/v2/caddyfile" 11 | 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func (g *CaddyfileGenerator) getServiceCaddyfile(service *swarm.Service, logger *zap.Logger) (*caddyfile.Container, error) { 16 | caddyLabels := g.filterLabels(service.Spec.Labels) 17 | 18 | return labelsToCaddyfile(caddyLabels, service, func() ([]string, error) { 19 | return g.getServiceProxyTargets(service, logger, true) 20 | }) 21 | } 22 | 23 | func (g *CaddyfileGenerator) getServiceProxyTargets(service *swarm.Service, logger *zap.Logger, onlyIngressIps bool) ([]string, error) { 24 | if g.options.ProxyServiceTasks { 25 | return g.getServiceTasksIps(service, logger, onlyIngressIps) 26 | } 27 | 28 | _, err := g.getServiceVirtualIps(service, logger, onlyIngressIps) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return []string{service.Spec.Name}, nil 34 | } 35 | 36 | func (g *CaddyfileGenerator) getServiceVirtualIps(service *swarm.Service, logger *zap.Logger, onlyIngressIps bool) ([]string, error) { 37 | virtualIps := []string{} 38 | 39 | for _, virtualIP := range service.Endpoint.VirtualIPs { 40 | if !onlyIngressIps || g.ingressNetworks[virtualIP.NetworkID] { 41 | virtualIps = append(virtualIps, virtualIP.Addr) 42 | } 43 | } 44 | 45 | if len(virtualIps) == 0 { 46 | logger.Warn("Service is not in same network as caddy", zap.String("service", service.Spec.Name), zap.String("serviceId", service.ID)) 47 | } 48 | 49 | return virtualIps, nil 50 | } 51 | 52 | func (g *CaddyfileGenerator) getServiceTasksIps(service *swarm.Service, logger *zap.Logger, onlyIngressIps bool) ([]string, error) { 53 | taskListFilter := filters.NewArgs() 54 | taskListFilter.Add("service", service.ID) 55 | taskListFilter.Add("desired-state", "running") 56 | 57 | hasRunningTasks := false 58 | tasksIps := []string{} 59 | 60 | for _, dockerClient := range g.dockerClients { 61 | tasks, err := dockerClient.TaskList(context.Background(), types.TaskListOptions{Filters: taskListFilter}) 62 | if err != nil { 63 | return []string{}, err 64 | } 65 | 66 | for _, task := range tasks { 67 | if task.Status.State == swarm.TaskStateRunning { 68 | hasRunningTasks = true 69 | ingressNetworkFromLabel, overrideNetwork := service.Spec.Labels[IngressNetworkLabel] 70 | 71 | for _, networkAttachment := range task.NetworksAttachments { 72 | include := false 73 | 74 | if !onlyIngressIps { 75 | include = true 76 | } else if overrideNetwork { 77 | include = networkAttachment.Network.Spec.Name == ingressNetworkFromLabel 78 | } else { 79 | include = g.ingressNetworks[networkAttachment.Network.ID] 80 | } 81 | 82 | if include { 83 | for _, address := range networkAttachment.Addresses { 84 | ipAddress, _, _ := net.ParseCIDR(address) 85 | tasksIps = append(tasksIps, ipAddress.String()) 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | 93 | if !hasRunningTasks { 94 | logger.Warn("Service has no tasks in running state", zap.String("service", service.Spec.Name), zap.String("serviceId", service.ID)) 95 | 96 | } else if len(tasksIps) == 0 { 97 | logger.Warn("Service is not in same network as caddy", zap.String("service", service.Spec.Name), zap.String("serviceId", service.ID)) 98 | } 99 | 100 | return tasksIps, nil 101 | } 102 | -------------------------------------------------------------------------------- /generator/services_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/docker/api/types" 7 | "github.com/docker/docker/api/types/swarm" 8 | "github.com/docker/docker/api/types/system" 9 | "github.com/lucaslorentz/caddy-docker-proxy/v2/config" 10 | ) 11 | 12 | func TestServices_TemplateData(t *testing.T) { 13 | dockerClient := createBasicDockerClientMock() 14 | dockerClient.ServicesData = []swarm.Service{ 15 | { 16 | Spec: swarm.ServiceSpec{ 17 | Annotations: swarm.Annotations{ 18 | Name: "service", 19 | Labels: map[string]string{ 20 | fmtLabel("%s"): "{{.Spec.Name}}.testdomain.com", 21 | fmtLabel("%s.reverse_proxy"): "{{.Spec.Name}}:5000", 22 | fmtLabel("%s.reverse_proxy.health_uri"): "/health", 23 | fmtLabel("%s.gzip"): "", 24 | fmtLabel("%s.basicauth"): "/ user password", 25 | fmtLabel("%s.tls.dns"): "route53", 26 | fmtLabel("%s.rewrite_0"): "/path1 /path2", 27 | fmtLabel("%s.rewrite_1"): "/path3 /path4", 28 | fmtLabel("%s.limits.header"): "100kb", 29 | fmtLabel("%s.limits.body_0"): "/path1 2mb", 30 | fmtLabel("%s.limits.body_1"): "/path2 4mb", 31 | }, 32 | }, 33 | }, 34 | Endpoint: swarm.Endpoint{ 35 | VirtualIPs: []swarm.EndpointVirtualIP{ 36 | { 37 | NetworkID: caddyNetworkID, 38 | }, 39 | }, 40 | }, 41 | }, 42 | } 43 | 44 | const expectedCaddyfile = "service.testdomain.com {\n" + 45 | " basicauth / user password\n" + 46 | " gzip\n" + 47 | " limits {\n" + 48 | " body /path1 2mb\n" + 49 | " body /path2 4mb\n" + 50 | " header 100kb\n" + 51 | " }\n" + 52 | " reverse_proxy service:5000 {\n" + 53 | " health_uri /health\n" + 54 | " }\n" + 55 | " rewrite /path1 /path2\n" + 56 | " rewrite /path3 /path4\n" + 57 | " tls {\n" + 58 | " dns route53\n" + 59 | " }\n" + 60 | "}\n" 61 | 62 | const expectedLogs = commonLogs 63 | 64 | testGeneration(t, dockerClient, nil, expectedCaddyfile, expectedLogs) 65 | } 66 | 67 | func TestServices_DifferentNetwork(t *testing.T) { 68 | dockerClient := createBasicDockerClientMock() 69 | dockerClient.ServicesData = []swarm.Service{ 70 | { 71 | ID: "SERVICE-ID", 72 | Spec: swarm.ServiceSpec{ 73 | Annotations: swarm.Annotations{ 74 | Name: "service", 75 | Labels: map[string]string{ 76 | fmtLabel("%s"): "service.testdomain.com", 77 | fmtLabel("%s.reverse_proxy"): "{{upstreams}}", 78 | }, 79 | }, 80 | }, 81 | Endpoint: swarm.Endpoint{ 82 | VirtualIPs: []swarm.EndpointVirtualIP{ 83 | { 84 | NetworkID: "other-network-id", 85 | }, 86 | }, 87 | }, 88 | }, 89 | } 90 | 91 | const expectedCaddyfile = "service.testdomain.com {\n" + 92 | " reverse_proxy service\n" + 93 | "}\n" 94 | 95 | const expectedLogs = commonLogs + 96 | `WARN Service is not in same network as caddy {"service": "service", "serviceId": "SERVICE-ID"}` + newLine 97 | 98 | testGeneration(t, dockerClient, nil, expectedCaddyfile, expectedLogs) 99 | } 100 | 101 | func TestServices_ManualIngressNetwork(t *testing.T) { 102 | dockerClient := createBasicDockerClientMock() 103 | dockerClient.NetworksData = []types.NetworkResource{ 104 | { 105 | ID: "other-network-id", 106 | Name: "other-network-name", 107 | }, 108 | } 109 | dockerClient.ServicesData = []swarm.Service{ 110 | { 111 | ID: "SERVICE-ID", 112 | Spec: swarm.ServiceSpec{ 113 | Annotations: swarm.Annotations{ 114 | Name: "service", 115 | Labels: map[string]string{ 116 | fmtLabel("%s"): "service.testdomain.com", 117 | fmtLabel("%s.reverse_proxy"): "{{upstreams}}", 118 | }, 119 | }, 120 | }, 121 | Endpoint: swarm.Endpoint{ 122 | VirtualIPs: []swarm.EndpointVirtualIP{ 123 | { 124 | NetworkID: "other-network-id", 125 | }, 126 | }, 127 | }, 128 | }, 129 | } 130 | 131 | const expectedCaddyfile = "service.testdomain.com {\n" + 132 | " reverse_proxy service\n" + 133 | "}\n" 134 | 135 | const expectedLogs = otherIngressNetworksMapLog + swarmIsAvailableLog 136 | 137 | testGeneration(t, dockerClient, func(options *config.Options) { 138 | options.IngressNetworks = []string{"other-network-name"} 139 | }, expectedCaddyfile, expectedLogs) 140 | } 141 | 142 | func TestServices_SwarmDisabled(t *testing.T) { 143 | dockerClient := createBasicDockerClientMock() 144 | dockerClient.ServicesData = []swarm.Service{ 145 | { 146 | ID: "SERVICE-ID", 147 | Spec: swarm.ServiceSpec{ 148 | Annotations: swarm.Annotations{ 149 | Name: "service", 150 | Labels: map[string]string{ 151 | fmtLabel("%s"): "service.testdomain.com", 152 | fmtLabel("%s.reverse_proxy"): "{{upstreams 5000}}", 153 | }, 154 | }, 155 | }, 156 | Endpoint: swarm.Endpoint{ 157 | VirtualIPs: []swarm.EndpointVirtualIP{ 158 | { 159 | NetworkID: caddyNetworkID, 160 | }, 161 | }, 162 | }, 163 | }, 164 | } 165 | dockerClient.InfoData = system.Info{ 166 | Swarm: swarm.Info{ 167 | LocalNodeState: swarm.LocalNodeStateInactive, 168 | }, 169 | } 170 | 171 | const expectedCaddyfile = "# Empty caddyfile" 172 | 173 | const expectedLogs = containerIdLog + ingressNetworksMapLog + swarmIsDisabledLog 174 | 175 | testGeneration(t, dockerClient, nil, expectedCaddyfile, expectedLogs) 176 | } 177 | 178 | func TestServiceTasks_Empty(t *testing.T) { 179 | dockerClient := createBasicDockerClientMock() 180 | dockerClient.ServicesData = []swarm.Service{ 181 | { 182 | ID: "SERVICEID", 183 | Spec: swarm.ServiceSpec{ 184 | Annotations: swarm.Annotations{ 185 | Name: "service", 186 | Labels: map[string]string{ 187 | fmtLabel("%s"): "service.testdomain.com", 188 | fmtLabel("%s.reverse_proxy"): "{{upstreams 5000}}", 189 | }, 190 | }, 191 | }, 192 | Endpoint: swarm.Endpoint{ 193 | VirtualIPs: []swarm.EndpointVirtualIP{ 194 | { 195 | NetworkID: caddyNetworkID, 196 | }, 197 | }, 198 | }, 199 | }, 200 | } 201 | 202 | const expectedCaddyfile = "service.testdomain.com {\n" + 203 | " reverse_proxy\n" + 204 | "}\n" 205 | 206 | const expectedLogs = commonLogs + 207 | `WARN Service has no tasks in running state {"service": "service", "serviceId": "SERVICEID"}` + newLine 208 | 209 | testGeneration(t, dockerClient, func(options *config.Options) { 210 | options.ProxyServiceTasks = true 211 | }, expectedCaddyfile, expectedLogs) 212 | } 213 | 214 | func TestServiceTasks_NotRunning(t *testing.T) { 215 | dockerClient := createBasicDockerClientMock() 216 | dockerClient.ServicesData = []swarm.Service{ 217 | { 218 | ID: "SERVICEID", 219 | Spec: swarm.ServiceSpec{ 220 | Annotations: swarm.Annotations{ 221 | Name: "service", 222 | Labels: map[string]string{ 223 | fmtLabel("%s"): "service.testdomain.com", 224 | fmtLabel("%s.reverse_proxy"): "{{upstreams 5000}}", 225 | }, 226 | }, 227 | }, 228 | Endpoint: swarm.Endpoint{ 229 | VirtualIPs: []swarm.EndpointVirtualIP{ 230 | { 231 | NetworkID: caddyNetworkID, 232 | }, 233 | }, 234 | }, 235 | }, 236 | } 237 | dockerClient.TasksData = []swarm.Task{ 238 | { 239 | ServiceID: "SERVICEID", 240 | NetworksAttachments: []swarm.NetworkAttachment{ 241 | { 242 | Network: swarm.Network{ 243 | ID: caddyNetworkID, 244 | }, 245 | Addresses: []string{"10.0.0.1/24"}, 246 | }, 247 | }, 248 | DesiredState: swarm.TaskStateShutdown, 249 | Status: swarm.TaskStatus{State: swarm.TaskStateRunning}, 250 | }, 251 | { 252 | ServiceID: "SERVICEID", 253 | NetworksAttachments: []swarm.NetworkAttachment{ 254 | { 255 | Network: swarm.Network{ 256 | ID: caddyNetworkID, 257 | }, 258 | Addresses: []string{"10.0.0.2/24"}, 259 | }, 260 | }, 261 | DesiredState: swarm.TaskStateRunning, 262 | Status: swarm.TaskStatus{State: swarm.TaskStateShutdown}, 263 | }, 264 | } 265 | 266 | const expectedCaddyfile = "service.testdomain.com {\n" + 267 | " reverse_proxy\n" + 268 | "}\n" 269 | 270 | const expectedLogs = commonLogs + 271 | `WARN Service has no tasks in running state {"service": "service", "serviceId": "SERVICEID"}` + newLine 272 | 273 | testGeneration(t, dockerClient, func(options *config.Options) { 274 | options.ProxyServiceTasks = true 275 | }, expectedCaddyfile, expectedLogs) 276 | } 277 | 278 | func TestServiceTasks_DifferentNetwork(t *testing.T) { 279 | dockerClient := createBasicDockerClientMock() 280 | dockerClient.ServicesData = []swarm.Service{ 281 | { 282 | ID: "SERVICEID", 283 | Spec: swarm.ServiceSpec{ 284 | Annotations: swarm.Annotations{ 285 | Name: "service", 286 | Labels: map[string]string{ 287 | fmtLabel("%s"): "service.testdomain.com", 288 | fmtLabel("%s.reverse_proxy"): "{{upstreams 5000}}", 289 | }, 290 | }, 291 | }, 292 | Endpoint: swarm.Endpoint{ 293 | VirtualIPs: []swarm.EndpointVirtualIP{ 294 | { 295 | NetworkID: caddyNetworkID, 296 | }, 297 | }, 298 | }, 299 | }, 300 | } 301 | dockerClient.TasksData = []swarm.Task{ 302 | { 303 | ServiceID: "SERVICEID", 304 | NetworksAttachments: []swarm.NetworkAttachment{ 305 | { 306 | Network: swarm.Network{ 307 | ID: "other-network-id", 308 | }, 309 | Addresses: []string{"10.0.0.1/24"}, 310 | }, 311 | }, 312 | DesiredState: swarm.TaskStateRunning, 313 | Status: swarm.TaskStatus{State: swarm.TaskStateRunning}, 314 | }, 315 | } 316 | 317 | const expectedCaddyfile = "service.testdomain.com {\n" + 318 | " reverse_proxy\n" + 319 | "}\n" 320 | 321 | const expectedLogs = commonLogs + 322 | `WARN Service is not in same network as caddy {"service": "service", "serviceId": "SERVICEID"}` + newLine 323 | 324 | testGeneration(t, dockerClient, func(options *config.Options) { 325 | options.ProxyServiceTasks = true 326 | }, expectedCaddyfile, expectedLogs) 327 | } 328 | 329 | func TestServiceTasks_ManualIngressNetwork(t *testing.T) { 330 | dockerClient := createBasicDockerClientMock() 331 | dockerClient.ServicesData = []swarm.Service{ 332 | { 333 | ID: "SERVICEID", 334 | Spec: swarm.ServiceSpec{ 335 | Annotations: swarm.Annotations{ 336 | Name: "service", 337 | Labels: map[string]string{ 338 | fmtLabel("%s"): "service.testdomain.com", 339 | fmtLabel("%s.reverse_proxy"): "{{upstreams 5000}}", 340 | }, 341 | }, 342 | }, 343 | Endpoint: swarm.Endpoint{ 344 | VirtualIPs: []swarm.EndpointVirtualIP{ 345 | { 346 | NetworkID: caddyNetworkID, 347 | }, 348 | }, 349 | }, 350 | }, 351 | } 352 | dockerClient.NetworksData = []types.NetworkResource{ 353 | { 354 | ID: "other-network-id", 355 | Name: "other-network-name", 356 | }, 357 | } 358 | dockerClient.TasksData = []swarm.Task{ 359 | { 360 | ServiceID: "SERVICEID", 361 | NetworksAttachments: []swarm.NetworkAttachment{ 362 | { 363 | Network: swarm.Network{ 364 | ID: "other-network-id", 365 | }, 366 | Addresses: []string{"10.0.0.1/24"}, 367 | }, 368 | }, 369 | DesiredState: swarm.TaskStateRunning, 370 | Status: swarm.TaskStatus{State: swarm.TaskStateRunning}, 371 | }, 372 | } 373 | 374 | const expectedCaddyfile = "service.testdomain.com {\n" + 375 | " reverse_proxy 10.0.0.1:5000\n" + 376 | "}\n" 377 | 378 | const expectedLogs = otherIngressNetworksMapLog + swarmIsAvailableLog 379 | 380 | testGeneration(t, dockerClient, func(options *config.Options) { 381 | options.ProxyServiceTasks = true 382 | options.IngressNetworks = []string{"other-network-name"} 383 | }, expectedCaddyfile, expectedLogs) 384 | } 385 | 386 | func TestServiceTasks_OverrideIngressNetwork(t *testing.T) { 387 | dockerClient := createBasicDockerClientMock() 388 | dockerClient.ServicesData = []swarm.Service{ 389 | { 390 | ID: "SERVICEID", 391 | Spec: swarm.ServiceSpec{ 392 | Annotations: swarm.Annotations{ 393 | Name: "service", 394 | Labels: map[string]string{ 395 | "caddy_ingress_network": "another-network", 396 | fmtLabel("%s"): "service.testdomain.com", 397 | fmtLabel("%s.reverse_proxy"): "{{upstreams 5000}}", 398 | }, 399 | }, 400 | }, 401 | Endpoint: swarm.Endpoint{ 402 | VirtualIPs: []swarm.EndpointVirtualIP{ 403 | { 404 | NetworkID: caddyNetworkID, 405 | }, 406 | }, 407 | }, 408 | }, 409 | } 410 | dockerClient.NetworksData = []types.NetworkResource{ 411 | { 412 | ID: "other-network-id", 413 | Name: "other-network-name", 414 | }, 415 | { 416 | ID: "another-network-id", 417 | Name: "another-network-name", 418 | }, 419 | } 420 | dockerClient.TasksData = []swarm.Task{ 421 | { 422 | ServiceID: "SERVICEID", 423 | NetworksAttachments: []swarm.NetworkAttachment{ 424 | { 425 | Network: swarm.Network{ 426 | ID: "other-network-id", 427 | Spec: swarm.NetworkSpec{ 428 | Annotations: swarm.Annotations{ 429 | Name: "other-network", 430 | }, 431 | }, 432 | }, 433 | Addresses: []string{"10.0.0.1/24"}, 434 | }, 435 | { 436 | Network: swarm.Network{ 437 | ID: "another-network-id", 438 | Spec: swarm.NetworkSpec{ 439 | Annotations: swarm.Annotations{ 440 | Name: "another-network", 441 | }, 442 | }, 443 | }, 444 | Addresses: []string{"10.0.0.2/24"}, 445 | }, 446 | }, 447 | DesiredState: swarm.TaskStateRunning, 448 | Status: swarm.TaskStatus{State: swarm.TaskStateRunning}, 449 | }, 450 | } 451 | 452 | const expectedCaddyfile = "service.testdomain.com {\n" + 453 | " reverse_proxy 10.0.0.2:5000\n" + 454 | "}\n" 455 | 456 | const expectedLogs = otherIngressNetworksMapLog + swarmIsAvailableLog 457 | 458 | testGeneration(t, dockerClient, func(options *config.Options) { 459 | options.ProxyServiceTasks = true 460 | options.IngressNetworks = []string{"other-network-name"} 461 | }, expectedCaddyfile, expectedLogs) 462 | } 463 | 464 | func TestServiceTasks_Running(t *testing.T) { 465 | dockerClient := createBasicDockerClientMock() 466 | dockerClient.ServicesData = []swarm.Service{ 467 | { 468 | ID: "SERVICEID", 469 | Spec: swarm.ServiceSpec{ 470 | Annotations: swarm.Annotations{ 471 | Name: "service", 472 | Labels: map[string]string{ 473 | fmtLabel("%s"): "service.testdomain.com", 474 | fmtLabel("%s.reverse_proxy"): "{{upstreams 5000}}", 475 | }, 476 | }, 477 | }, 478 | Endpoint: swarm.Endpoint{ 479 | VirtualIPs: []swarm.EndpointVirtualIP{ 480 | { 481 | NetworkID: caddyNetworkID, 482 | }, 483 | }, 484 | }, 485 | }, 486 | } 487 | dockerClient.TasksData = []swarm.Task{ 488 | { 489 | ServiceID: "SERVICEID", 490 | NetworksAttachments: []swarm.NetworkAttachment{ 491 | { 492 | Network: swarm.Network{ 493 | ID: caddyNetworkID, 494 | }, 495 | Addresses: []string{"10.0.0.1/24"}, 496 | }, 497 | }, 498 | DesiredState: swarm.TaskStateRunning, 499 | Status: swarm.TaskStatus{State: swarm.TaskStateRunning}, 500 | }, 501 | { 502 | ServiceID: "SERVICEID", 503 | NetworksAttachments: []swarm.NetworkAttachment{ 504 | { 505 | Network: swarm.Network{ 506 | ID: caddyNetworkID, 507 | }, 508 | Addresses: []string{"10.0.0.2/24"}, 509 | }, 510 | }, 511 | DesiredState: swarm.TaskStateRunning, 512 | Status: swarm.TaskStatus{State: swarm.TaskStateRunning}, 513 | }, 514 | } 515 | 516 | const expectedCaddyfile = "service.testdomain.com {\n" + 517 | " reverse_proxy 10.0.0.1:5000 10.0.0.2:5000\n" + 518 | "}\n" 519 | 520 | const expectedLogs = commonLogs 521 | 522 | testGeneration(t, dockerClient, func(options *config.Options) { 523 | options.ProxyServiceTasks = true 524 | }, expectedCaddyfile, expectedLogs) 525 | } 526 | -------------------------------------------------------------------------------- /generator/testdata/labels/all_special_labels.txt: -------------------------------------------------------------------------------- 1 | caddy = service.testdomain.com 2 | caddy.route = /path/* 3 | caddy.route.0_uri = strip_prefix /path 4 | caddy.route.1_rewrite = * /api{path} 5 | caddy.route.2_reverse_proxy = {{upstreams https 5000}} 6 | ---------- 7 | service.testdomain.com { 8 | route /path/* { 9 | uri strip_prefix /path 10 | rewrite * /api{path} 11 | reverse_proxy https://target:5000 12 | } 13 | } -------------------------------------------------------------------------------- /generator/testdata/labels/doesnt_override_existing_proxy.txt: -------------------------------------------------------------------------------- 1 | caddy = testdomain.com 2 | caddy.reverse_proxy = something 3 | caddy.reverse_proxy_1 = /api/* external-api 4 | ---------- 5 | testdomain.com { 6 | reverse_proxy /api/* external-api 7 | reverse_proxy something 8 | } -------------------------------------------------------------------------------- /generator/testdata/labels/h2c_reverse_proxy.txt: -------------------------------------------------------------------------------- 1 | caddy = service.testdomain.com 2 | caddy.reverse_proxy = {{upstreams h2c 5000}} 3 | ---------- 4 | service.testdomain.com { 5 | reverse_proxy h2c://target:5000 6 | } 7 | -------------------------------------------------------------------------------- /generator/testdata/labels/invalid_template.txt: -------------------------------------------------------------------------------- 1 | caddy = service.testdomain.com 2 | caddy.reverse_proxy = {{invalid}} 3 | ---------- 4 | err: template: :1: function "invalid" not defined -------------------------------------------------------------------------------- /generator/testdata/labels/minimum_special_labels.txt: -------------------------------------------------------------------------------- 1 | caddy = service.testdomain.com 2 | caddy.reverse_proxy = {{upstreams}} 3 | ---------- 4 | service.testdomain.com { 5 | reverse_proxy target 6 | } -------------------------------------------------------------------------------- /generator/testdata/labels/multiple_addresses.txt: -------------------------------------------------------------------------------- 1 | caddy = a.testdomain.com b.testdomain.com 2 | caddy.reverse_proxy = {{upstreams}} 3 | ---------- 4 | a.testdomain.com b.testdomain.com { 5 | reverse_proxy target 6 | } -------------------------------------------------------------------------------- /generator/testdata/labels/multiple_configs.txt: -------------------------------------------------------------------------------- 1 | caddy_0 = service1.testdomain.com 2 | caddy_0.reverse_proxy = {{upstreams 5000}} 3 | caddy_0.rewrite = * /api{path} 4 | caddy_0.tls.dns = route53 5 | caddy_1 = service2.testdomain.com 6 | caddy_1.reverse_proxy = {{upstreams 5001}} 7 | caddy_1.tls.dns = route53 8 | ---------- 9 | service1.testdomain.com { 10 | reverse_proxy target:5000 11 | rewrite * /api{path} 12 | tls { 13 | dns route53 14 | } 15 | } 16 | service2.testdomain.com { 17 | reverse_proxy target:5001 18 | tls { 19 | dns route53 20 | } 21 | } -------------------------------------------------------------------------------- /generator/testdata/labels/reverse_proxy_directives_are_moved_into_route.txt: -------------------------------------------------------------------------------- 1 | caddy = service.testdomain.com 2 | caddy.route = /path/* 3 | caddy.route.0_uri = strip_prefix /path 4 | caddy.route.1_reverse_proxy = {{upstreams}} 5 | caddy.route.1_reverse_proxy.health_uri = /health 6 | ---------- 7 | service.testdomain.com { 8 | route /path/* { 9 | uri strip_prefix /path 10 | reverse_proxy target { 11 | health_uri /health 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /generator/testdata/labels/with_groups.txt: -------------------------------------------------------------------------------- 1 | caddy = service.testdomain.com 2 | caddy.reverse_proxy = {{upstreams https 5000}} 3 | caddy.rewrite = * /api{path} 4 | ---------- 5 | service.testdomain.com { 6 | reverse_proxy https://target:5000 7 | rewrite * /api{path} 8 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lucaslorentz/caddy-docker-proxy/v2 2 | 3 | go 1.21.0 4 | 5 | toolchain go1.22.8 6 | 7 | require ( 8 | github.com/caddyserver/caddy/v2 v2.8.4 9 | github.com/docker/docker v27.5.1+incompatible 10 | github.com/joho/godotenv v1.5.1 11 | github.com/stretchr/testify v1.9.0 12 | go.uber.org/zap v1.27.0 13 | ) 14 | 15 | require ( 16 | filippo.io/edwards25519 v1.1.0 // indirect 17 | github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect 18 | github.com/BurntSushi/toml v1.3.2 // indirect 19 | github.com/Masterminds/goutils v1.1.1 // indirect 20 | github.com/Masterminds/semver/v3 v3.2.0 // indirect 21 | github.com/Masterminds/sprig/v3 v3.2.3 // indirect 22 | github.com/Microsoft/go-winio v0.6.0 // indirect 23 | github.com/alecthomas/chroma/v2 v2.13.0 // indirect 24 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 25 | github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect 26 | github.com/beorn7/perks v1.0.1 // indirect 27 | github.com/caddyserver/certmagic v0.21.3 // indirect 28 | github.com/caddyserver/zerossl v0.1.3 // indirect 29 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 30 | github.com/cespare/xxhash v1.1.0 // indirect 31 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 32 | github.com/chzyer/readline v1.5.1 // indirect 33 | github.com/containerd/log v0.1.0 // indirect 34 | github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect 35 | github.com/davecgh/go-spew v1.1.1 // indirect 36 | github.com/dgraph-io/badger v1.6.2 // indirect 37 | github.com/dgraph-io/badger/v2 v2.2007.4 // indirect 38 | github.com/dgraph-io/ristretto v0.1.0 // indirect 39 | github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect 40 | github.com/distribution/reference v0.6.0 // indirect 41 | github.com/dlclark/regexp2 v1.11.0 // indirect 42 | github.com/docker/go-connections v0.5.0 // indirect 43 | github.com/docker/go-units v0.5.0 // indirect 44 | github.com/dustin/go-humanize v1.0.1 // indirect 45 | github.com/felixge/httpsnoop v1.0.4 // indirect 46 | github.com/fxamacker/cbor/v2 v2.6.0 // indirect 47 | github.com/go-chi/chi/v5 v5.0.12 // indirect 48 | github.com/go-jose/go-jose/v3 v3.0.3 // indirect 49 | github.com/go-kit/kit v0.13.0 // indirect 50 | github.com/go-kit/log v0.2.1 // indirect 51 | github.com/go-logfmt/logfmt v0.6.0 // indirect 52 | github.com/go-logr/logr v1.4.1 // indirect 53 | github.com/go-logr/stdr v1.2.2 // indirect 54 | github.com/go-sql-driver/mysql v1.7.1 // indirect 55 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 56 | github.com/gogo/protobuf v1.3.2 // indirect 57 | github.com/golang/glog v1.2.0 // indirect 58 | github.com/golang/protobuf v1.5.4 // indirect 59 | github.com/golang/snappy v0.0.4 // indirect 60 | github.com/google/cel-go v0.20.1 // indirect 61 | github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745 // indirect 62 | github.com/google/go-tpm v0.9.0 // indirect 63 | github.com/google/go-tspi v0.3.0 // indirect 64 | github.com/google/pprof v0.0.0-20231212022811-ec68065c825e // indirect 65 | github.com/google/uuid v1.6.0 // indirect 66 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect 67 | github.com/huandu/xstrings v1.3.3 // indirect 68 | github.com/imdario/mergo v0.3.12 // indirect 69 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 70 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 71 | github.com/jackc/pgconn v1.14.3 // indirect 72 | github.com/jackc/pgio v1.0.0 // indirect 73 | github.com/jackc/pgpassfile v1.0.0 // indirect 74 | github.com/jackc/pgproto3/v2 v2.3.3 // indirect 75 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 76 | github.com/jackc/pgtype v1.14.0 // indirect 77 | github.com/jackc/pgx/v4 v4.18.3 // indirect 78 | github.com/klauspost/compress v1.17.8 // indirect 79 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 80 | github.com/libdns/libdns v0.2.2 // indirect 81 | github.com/manifoldco/promptui v0.9.0 // indirect 82 | github.com/mattn/go-colorable v0.1.13 // indirect 83 | github.com/mattn/go-isatty v0.0.20 // indirect 84 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 85 | github.com/mholt/acmez/v2 v2.0.1 // indirect 86 | github.com/miekg/dns v1.1.59 // indirect 87 | github.com/mitchellh/copystructure v1.2.0 // indirect 88 | github.com/mitchellh/go-ps v1.0.0 // indirect 89 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 90 | github.com/moby/docker-image-spec v1.3.1 // indirect 91 | github.com/moby/term v0.5.0 // indirect 92 | github.com/morikuni/aec v1.0.0 // indirect 93 | github.com/onsi/ginkgo/v2 v2.13.2 // indirect 94 | github.com/opencontainers/go-digest v1.0.0 // indirect 95 | github.com/opencontainers/image-spec v1.1.0 // indirect 96 | github.com/pires/go-proxyproto v0.7.0 // indirect 97 | github.com/pkg/errors v0.9.1 // indirect 98 | github.com/pmezard/go-difflib v1.0.0 // indirect 99 | github.com/prometheus/client_golang v1.19.1 // indirect 100 | github.com/prometheus/client_model v0.5.0 // indirect 101 | github.com/prometheus/common v0.48.0 // indirect 102 | github.com/prometheus/procfs v0.12.0 // indirect 103 | github.com/quic-go/qpack v0.4.0 // indirect 104 | github.com/quic-go/quic-go v0.44.0 // indirect 105 | github.com/rs/xid v1.5.0 // indirect 106 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 107 | github.com/shopspring/decimal v1.2.0 // indirect 108 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 109 | github.com/sirupsen/logrus v1.9.3 // indirect 110 | github.com/slackhq/nebula v1.6.1 // indirect 111 | github.com/smallstep/certificates v0.26.1 // indirect 112 | github.com/smallstep/go-attestation v0.4.4-0.20240109183208-413678f90935 // indirect 113 | github.com/smallstep/nosql v0.6.1 // indirect 114 | github.com/smallstep/pkcs7 v0.0.0-20231024181729-3b98ecc1ca81 // indirect 115 | github.com/smallstep/scep v0.0.0-20231024192529-aee96d7ad34d // indirect 116 | github.com/smallstep/truststore v0.13.0 // indirect 117 | github.com/spf13/cast v1.4.1 // indirect 118 | github.com/spf13/cobra v1.8.0 // indirect 119 | github.com/spf13/pflag v1.0.5 // indirect 120 | github.com/stoewer/go-strcase v1.2.0 // indirect 121 | github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect 122 | github.com/urfave/cli v1.22.14 // indirect 123 | github.com/x448/float16 v0.8.4 // indirect 124 | github.com/yuin/goldmark v1.7.1 // indirect 125 | github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect 126 | github.com/zeebo/blake3 v0.2.3 // indirect 127 | go.etcd.io/bbolt v1.3.9 // indirect 128 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 129 | go.opentelemetry.io/contrib/propagators/autoprop v0.42.0 // indirect 130 | go.opentelemetry.io/contrib/propagators/aws v1.17.0 // indirect 131 | go.opentelemetry.io/contrib/propagators/b3 v1.17.0 // indirect 132 | go.opentelemetry.io/contrib/propagators/jaeger v1.17.0 // indirect 133 | go.opentelemetry.io/contrib/propagators/ot v1.17.0 // indirect 134 | go.opentelemetry.io/otel v1.27.0 // indirect 135 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 // indirect 136 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 // indirect 137 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect 138 | go.opentelemetry.io/otel/metric v1.27.0 // indirect 139 | go.opentelemetry.io/otel/sdk v1.27.0 // indirect 140 | go.opentelemetry.io/otel/trace v1.27.0 // indirect 141 | go.opentelemetry.io/proto/otlp v1.2.0 // indirect 142 | go.step.sm/cli-utils v0.9.0 // indirect 143 | go.step.sm/crypto v0.45.0 // indirect 144 | go.step.sm/linkedca v0.20.1 // indirect 145 | go.uber.org/automaxprocs v1.5.3 // indirect 146 | go.uber.org/mock v0.4.0 // indirect 147 | go.uber.org/multierr v1.11.0 // indirect 148 | go.uber.org/zap/exp v0.2.0 // indirect 149 | golang.org/x/crypto v0.28.0 // indirect 150 | golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect 151 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect 152 | golang.org/x/mod v0.17.0 // indirect 153 | golang.org/x/net v0.30.0 // indirect 154 | golang.org/x/sync v0.8.0 // indirect 155 | golang.org/x/sys v0.26.0 // indirect 156 | golang.org/x/term v0.25.0 // indirect 157 | golang.org/x/text v0.19.0 // indirect 158 | golang.org/x/time v0.5.0 // indirect 159 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 160 | google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5 // indirect 161 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect 162 | google.golang.org/grpc v1.64.1 // indirect 163 | google.golang.org/protobuf v1.35.1 // indirect 164 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect 165 | gopkg.in/yaml.v3 v3.0.1 // indirect 166 | gotest.tools/v3 v3.5.1 // indirect 167 | howett.net/plist v1.0.0 // indirect 168 | ) 169 | -------------------------------------------------------------------------------- /loader.go: -------------------------------------------------------------------------------- 1 | package caddydockerproxy 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "path/filepath" 11 | "sync" 12 | "time" 13 | 14 | "os" 15 | 16 | "github.com/caddyserver/caddy/v2" 17 | "github.com/caddyserver/caddy/v2/caddyconfig" 18 | "github.com/docker/docker/api/types" 19 | "github.com/docker/docker/api/types/filters" 20 | "github.com/docker/docker/client" 21 | "github.com/joho/godotenv" 22 | "github.com/lucaslorentz/caddy-docker-proxy/v2/config" 23 | "github.com/lucaslorentz/caddy-docker-proxy/v2/docker" 24 | "github.com/lucaslorentz/caddy-docker-proxy/v2/generator" 25 | "github.com/lucaslorentz/caddy-docker-proxy/v2/utils" 26 | 27 | "go.uber.org/zap" 28 | ) 29 | 30 | var CaddyfileAutosavePath = filepath.Join(caddy.AppConfigDir(), "Caddyfile.autosave") 31 | 32 | // DockerLoader generates caddy files from docker swarm information 33 | type DockerLoader struct { 34 | options *config.Options 35 | initialized bool 36 | dockerClients []docker.Client 37 | generator *generator.CaddyfileGenerator 38 | timer *time.Timer 39 | skipEvents []bool 40 | lastCaddyfile []byte 41 | lastJSONConfig []byte 42 | lastVersion int64 43 | serversVersions *utils.StringInt64CMap 44 | serversUpdating *utils.StringBoolCMap 45 | } 46 | 47 | // CreateDockerLoader creates a docker loader 48 | func CreateDockerLoader(options *config.Options) *DockerLoader { 49 | return &DockerLoader{ 50 | options: options, 51 | serversVersions: utils.NewStringInt64CMap(), 52 | serversUpdating: utils.NewStringBoolCMap(), 53 | } 54 | } 55 | 56 | func logger() *zap.Logger { 57 | return caddy.Log(). 58 | Named("docker-proxy") 59 | } 60 | 61 | // Start docker loader 62 | func (dockerLoader *DockerLoader) Start() error { 63 | if dockerLoader.initialized { 64 | return nil 65 | } 66 | 67 | dockerLoader.initialized = true 68 | log := logger() 69 | 70 | if envFile := dockerLoader.options.EnvFile; envFile != "" { 71 | if err := godotenv.Load(dockerLoader.options.EnvFile); err != nil { 72 | log.Error("Load variables from environment file failed", zap.Error(err), zap.String("envFile", dockerLoader.options.EnvFile)) 73 | return err 74 | } 75 | log.Info("environment file loaded", zap.String("envFile", dockerLoader.options.EnvFile)) 76 | } 77 | 78 | dockerClients := []docker.Client{} 79 | for i, dockerSocket := range dockerLoader.options.DockerSockets { 80 | // cf https://github.com/docker/go-docker/blob/master/client.go 81 | // setenv to use NewEnvClient 82 | // or manually 83 | 84 | os.Setenv("DOCKER_HOST", dockerSocket) 85 | 86 | if len(dockerLoader.options.DockerCertsPath) >= i+1 && dockerLoader.options.DockerCertsPath[i] != "" { 87 | os.Setenv("DOCKER_CERT_PATH", dockerLoader.options.DockerCertsPath[i]) 88 | } else { 89 | os.Unsetenv("DOCKER_CERT_PATH") 90 | } 91 | 92 | if len(dockerLoader.options.DockerAPIsVersion) >= i+1 && dockerLoader.options.DockerAPIsVersion[i] != "" { 93 | os.Setenv("DOCKER_API_VERSION", dockerLoader.options.DockerAPIsVersion[i]) 94 | } else { 95 | os.Unsetenv("DOCKER_API_VERSION") 96 | } 97 | 98 | dockerClient, err := client.NewEnvClient() 99 | if err != nil { 100 | log.Error("Docker connection failed to docker specify socket", zap.Error(err), zap.String("DockerSocket", dockerSocket)) 101 | return err 102 | } 103 | 104 | dockerPing, err := dockerClient.Ping(context.Background()) 105 | if err != nil { 106 | log.Error("Docker ping failed on specify socket", zap.Error(err), zap.String("DockerSocket", dockerSocket)) 107 | return err 108 | } 109 | 110 | dockerClient.NegotiateAPIVersionPing(dockerPing) 111 | 112 | wrappedClient := docker.WrapClient(dockerClient) 113 | 114 | dockerClients = append(dockerClients, wrappedClient) 115 | } 116 | 117 | // by default it will used the env docker 118 | if len(dockerClients) == 0 { 119 | dockerClient, err := client.NewEnvClient() 120 | dockerLoader.options.DockerSockets = append(dockerLoader.options.DockerSockets, os.Getenv("DOCKER_HOST")) 121 | if err != nil { 122 | log.Error("Docker connection failed", zap.Error(err)) 123 | return err 124 | } 125 | 126 | dockerPing, err := dockerClient.Ping(context.Background()) 127 | if err != nil { 128 | log.Error("Docker ping failed", zap.Error(err)) 129 | return err 130 | } 131 | 132 | dockerClient.NegotiateAPIVersionPing(dockerPing) 133 | 134 | wrappedClient := docker.WrapClient(dockerClient) 135 | 136 | dockerClients = append(dockerClients, wrappedClient) 137 | } 138 | 139 | dockerLoader.dockerClients = dockerClients 140 | dockerLoader.skipEvents = make([]bool, len(dockerLoader.dockerClients)) 141 | 142 | dockerLoader.generator = generator.CreateGenerator( 143 | dockerClients, 144 | docker.CreateUtils(), 145 | dockerLoader.options, 146 | ) 147 | 148 | log.Info( 149 | "Start", 150 | zap.String("CaddyfilePath", dockerLoader.options.CaddyfilePath), 151 | zap.String("EnvFile", dockerLoader.options.EnvFile), 152 | zap.String("LabelPrefix", dockerLoader.options.LabelPrefix), 153 | zap.Duration("PollingInterval", dockerLoader.options.PollingInterval), 154 | zap.Bool("ProxyServiceTasks", dockerLoader.options.ProxyServiceTasks), 155 | zap.Bool("ProcessCaddyfile", dockerLoader.options.ProcessCaddyfile), 156 | zap.Bool("ScanStoppedContainers", dockerLoader.options.ScanStoppedContainers), 157 | zap.String("IngressNetworks", fmt.Sprintf("%v", dockerLoader.options.IngressNetworks)), 158 | zap.Strings("DockerSockets", dockerLoader.options.DockerSockets), 159 | zap.Strings("DockerCertsPath", dockerLoader.options.DockerCertsPath), 160 | zap.Strings("DockerAPIsVersion", dockerLoader.options.DockerAPIsVersion), 161 | ) 162 | 163 | ready := make(chan struct{}) 164 | dockerLoader.timer = time.AfterFunc(0, func() { 165 | <-ready 166 | dockerLoader.update() 167 | }) 168 | close(ready) 169 | 170 | go dockerLoader.monitorEvents() 171 | 172 | return nil 173 | } 174 | 175 | func (dockerLoader *DockerLoader) monitorEvents() { 176 | for { 177 | dockerLoader.listenEvents() 178 | time.Sleep(30 * time.Second) 179 | } 180 | } 181 | 182 | func (dockerLoader *DockerLoader) listenEvents() { 183 | args := filters.NewArgs() 184 | if !isTrue.MatchString(os.Getenv("CADDY_DOCKER_NO_SCOPE")) { 185 | // This env var is useful for Podman where in some instances the scope can cause some issues. 186 | args.Add("scope", "swarm") 187 | args.Add("scope", "local") 188 | } 189 | args.Add("type", "service") 190 | args.Add("type", "container") 191 | args.Add("type", "config") 192 | 193 | for i, dockerClient := range dockerLoader.dockerClients { 194 | context, cancel := context.WithCancel(context.Background()) 195 | 196 | eventsChan, errorChan := dockerClient.Events(context, types.EventsOptions{ 197 | Filters: args, 198 | }) 199 | 200 | log := logger() 201 | log.Info("Connecting to docker events", zap.String("DockerSocket", dockerLoader.options.DockerSockets[i])) 202 | 203 | ListenEvents: 204 | for { 205 | select { 206 | case event := <-eventsChan: 207 | if dockerLoader.skipEvents[i] { 208 | continue 209 | } 210 | 211 | update := (event.Type == "container" && event.Action == "create") || 212 | (event.Type == "container" && event.Action == "start") || 213 | (event.Type == "container" && event.Action == "stop") || 214 | (event.Type == "container" && event.Action == "die") || 215 | (event.Type == "container" && event.Action == "destroy") || 216 | (event.Type == "service" && event.Action == "create") || 217 | (event.Type == "service" && event.Action == "update") || 218 | (event.Type == "service" && event.Action == "remove") || 219 | (event.Type == "config" && event.Action == "create") || 220 | (event.Type == "config" && event.Action == "remove") 221 | 222 | if update { 223 | dockerLoader.skipEvents[i] = true 224 | dockerLoader.timer.Reset(dockerLoader.options.EventThrottleInterval) 225 | } 226 | case err := <-errorChan: 227 | cancel() 228 | if err != nil { 229 | log.Error("Docker events error", zap.Error(err)) 230 | } 231 | break ListenEvents 232 | } 233 | } 234 | } 235 | } 236 | 237 | func (dockerLoader *DockerLoader) update() bool { 238 | dockerLoader.timer.Reset(dockerLoader.options.PollingInterval) 239 | for i := 0; i < len(dockerLoader.skipEvents); i++ { 240 | dockerLoader.skipEvents[i] = false 241 | } 242 | 243 | // Don't cache the logger more globally, it can change based on config reloads 244 | log := logger() 245 | caddyfile, controlledServers := dockerLoader.generator.GenerateCaddyfile(log) 246 | 247 | caddyfileChanged := !bytes.Equal(dockerLoader.lastCaddyfile, caddyfile) 248 | 249 | dockerLoader.lastCaddyfile = caddyfile 250 | 251 | if caddyfileChanged { 252 | log.Info("New Caddyfile", zap.ByteString("caddyfile", caddyfile)) 253 | 254 | if autosaveErr := os.WriteFile(CaddyfileAutosavePath, caddyfile, 0666); autosaveErr != nil { 255 | log.Warn("Failed to autosave caddyfile", zap.Error(autosaveErr), zap.String("path", CaddyfileAutosavePath)) 256 | } 257 | 258 | adapter := caddyconfig.GetAdapter("caddyfile") 259 | 260 | configJSON, warn, err := adapter.Adapt(caddyfile, nil) 261 | 262 | if warn != nil { 263 | log.Warn("Caddyfile to json warning", zap.String("warn", fmt.Sprintf("%v", warn))) 264 | } 265 | 266 | if err != nil { 267 | log.Error("Failed to convert caddyfile into json config", zap.Error(err)) 268 | return false 269 | } 270 | 271 | log.Info("New Config JSON", zap.ByteString("json", configJSON)) 272 | 273 | dockerLoader.lastJSONConfig = configJSON 274 | dockerLoader.lastVersion++ 275 | } 276 | 277 | var wg sync.WaitGroup 278 | for _, server := range controlledServers { 279 | wg.Add(1) 280 | go dockerLoader.updateServer(&wg, server) 281 | } 282 | wg.Wait() 283 | 284 | return true 285 | } 286 | 287 | func (dockerLoader *DockerLoader) updateServer(wg *sync.WaitGroup, server string) { 288 | defer wg.Done() 289 | 290 | // Skip servers that are being updated already 291 | if dockerLoader.serversUpdating.Get(server) { 292 | return 293 | } 294 | 295 | // Flag and unflag updating 296 | dockerLoader.serversUpdating.Set(server, true) 297 | defer dockerLoader.serversUpdating.Delete(server) 298 | 299 | version := dockerLoader.lastVersion 300 | 301 | // Skip servers that already have this version 302 | if dockerLoader.serversVersions.Get(server) >= version { 303 | return 304 | } 305 | 306 | log := logger() 307 | log.Info("Sending configuration to", zap.String("server", server)) 308 | 309 | url := "http://" + server + ":2019/load" 310 | 311 | postBody, err := addAdminListen(dockerLoader.lastJSONConfig, "tcp/"+server+":2019") 312 | if err != nil { 313 | log.Error("Failed to add admin listen to", zap.String("server", server), zap.Error(err)) 314 | return 315 | } 316 | 317 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(postBody)) 318 | if err != nil { 319 | log.Error("Failed to create request to", zap.String("server", server), zap.Error(err)) 320 | return 321 | } 322 | req.Header.Set("Content-Type", "application/json") 323 | resp, err := http.DefaultClient.Do(req) 324 | 325 | if err != nil { 326 | log.Error("Failed to send configuration to", zap.String("server", server), zap.Error(err)) 327 | return 328 | } 329 | 330 | bodyBytes, err := io.ReadAll(resp.Body) 331 | if err != nil { 332 | log.Error("Failed to read response from", zap.String("server", server), zap.Error(err)) 333 | return 334 | } 335 | 336 | if resp.StatusCode != 200 { 337 | log.Error("Error response from server", zap.String("server", server), zap.Int("status code", resp.StatusCode), zap.ByteString("body", bodyBytes)) 338 | return 339 | } 340 | 341 | dockerLoader.serversVersions.Set(server, version) 342 | 343 | log.Info("Successfully configured", zap.String("server", server)) 344 | } 345 | 346 | func addAdminListen(configJSON []byte, listen string) ([]byte, error) { 347 | config := &caddy.Config{} 348 | err := json.Unmarshal(configJSON, config) 349 | if err != nil { 350 | return nil, err 351 | } 352 | config.Admin = &caddy.AdminConfig{ 353 | Listen: listen, 354 | } 355 | return json.Marshal(config) 356 | } 357 | -------------------------------------------------------------------------------- /module.go: -------------------------------------------------------------------------------- 1 | package caddydockerproxy 2 | 3 | import "github.com/caddyserver/caddy/v2" 4 | 5 | func init() { 6 | caddy.RegisterModule(CaddyDockerProxy{}) 7 | } 8 | 9 | // Caddy docker proxy module 10 | type CaddyDockerProxy struct { 11 | } 12 | 13 | // CaddyModule returns the Caddy module information. 14 | func (CaddyDockerProxy) CaddyModule() caddy.ModuleInfo { 15 | return caddy.ModuleInfo{ 16 | ID: "docker_proxy", 17 | New: func() caddy.Module { return new(CaddyDockerProxy) }, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /run-docker-tests-linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo ==PARAMETERS== 6 | echo ARTIFACTS: "${ARTIFACTS:=./artifacts}" 7 | 8 | if [ "$ARTIFACTS" != "./artifacts" ] 9 | then 10 | mkdir -p ./artifacts/binaries/linux/amd64 11 | cp $ARTIFACTS/binaries/linux/amd64/caddy ./artifacts/binaries/linux/amd64/caddy 12 | fi 13 | 14 | find artifacts/binaries -type f -exec chmod +x {} \; 15 | 16 | docker build -q -f Dockerfile-alpine . \ 17 | --build-arg TARGETPLATFORM=linux/amd64 \ 18 | -t caddy-docker-proxy:local 19 | 20 | docker swarm init || true 21 | 22 | export DOCKER_SOCKET_PATH="/var/run/docker.sock" 23 | export DOCKER_SOCKET_TYPE="bind" 24 | 25 | (cd tests && . run.sh) 26 | -------------------------------------------------------------------------------- /run-docker-tests-windows.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo ==PARAMETERS== 6 | echo ARTIFACTS: "${ARTIFACTS:=./artifacts}" 7 | 8 | if [ "$ARTIFACTS" != "./artifacts" ] 9 | then 10 | mkdir -p ./artifacts/binaries/windows/amd64 11 | cp $ARTIFACTS/binaries/windows/amd64/caddy ./artifacts/binaries/windows/amd64/caddy 12 | fi 13 | 14 | env 15 | exit 1 16 | 17 | docker build -q -f Dockerfile-nanoserver . \ 18 | --build-arg TARGETPLATFORM=windows/amd64 \ 19 | --build-arg SERVERCORE_VERSION=ltsc2022 \ 20 | --build-arg NANOSERVER_VERSION=ltsc2022 \ 21 | -t caddy-docker-proxy:local 22 | 23 | docker swarm init --advertise-addr 127.0.0.1 || true 24 | 25 | export DOCKER_SOCKET_PATH='\\.\pipe\docker_engine' 26 | export DOCKER_SOCKET_TYPE="npipe" 27 | 28 | (cd tests && . run.sh) 29 | -------------------------------------------------------------------------------- /tests/caddyfile+config/CaddyfileConfig: -------------------------------------------------------------------------------- 1 | (configSnippet) { 2 | respond /config "config" 200 3 | } 4 | 5 | config.local { 6 | respond / "config" 200 7 | tls internal 8 | } -------------------------------------------------------------------------------- /tests/caddyfile+config/compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | configs: 4 | caddy-basic-content: 5 | file: ./CaddyfileConfig 6 | labels: 7 | caddy: 8 | 9 | services: 10 | caddy: 11 | image: caddy-docker-proxy:local 12 | ports: 13 | - 80:80 14 | - 443:443 15 | networks: 16 | - caddy 17 | environment: 18 | - CADDY_DOCKER_CADDYFILE_PATH=/config/Caddyfile 19 | volumes: 20 | - source: ./config 21 | target: /config 22 | type: bind 23 | - source: "${DOCKER_SOCKET_PATH}" 24 | target: "${DOCKER_SOCKET_PATH}" 25 | type: ${DOCKER_SOCKET_TYPE} 26 | deploy: 27 | labels: 28 | caddy.email: "test@example.com" 29 | 30 | service: 31 | image: traefik/whoami 32 | networks: 33 | - caddy 34 | deploy: 35 | labels: 36 | caddy: service.local 37 | caddy.import_0: caddyfileSnippet 38 | caddy.import_1: configSnippet 39 | caddy.tls: "internal" 40 | 41 | networks: 42 | caddy: 43 | name: caddy_test 44 | external: true 45 | -------------------------------------------------------------------------------- /tests/caddyfile+config/config/.gitignore: -------------------------------------------------------------------------------- 1 | caddy -------------------------------------------------------------------------------- /tests/caddyfile+config/config/Caddyfile: -------------------------------------------------------------------------------- 1 | (caddyfileSnippet) { 2 | respond /caddyfile "caddyfile" 200 3 | } 4 | 5 | caddyfile.local { 6 | respond / "caddyfile" 200 7 | tls internal 8 | } -------------------------------------------------------------------------------- /tests/caddyfile+config/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | . ../functions.sh 6 | 7 | docker stack deploy -c compose.yaml --prune caddy_test 8 | 9 | retry curl --show-error -s -k -f --resolve service.local:443:127.0.0.1 https://service.local/caddyfile | grep caddyfile && 10 | retry curl --show-error -s -k -f --resolve service.local:443:127.0.0.1 https://service.local/config | grep config && 11 | retry curl --show-error -s -k -f --resolve caddyfile.local:443:127.0.0.1 https://caddyfile.local | grep caddyfile && 12 | retry curl --show-error -s -k -f --resolve config.local:443:127.0.0.1 https://config.local | grep config || 13 | { 14 | echo "== Service errors ==" 15 | docker service ps --no-trunc caddy_test_caddy --format "{{.Error}}" 16 | echo "== Service logs ==" 17 | docker service logs caddy_test_caddy 18 | exit 1 19 | } 20 | -------------------------------------------------------------------------------- /tests/containers/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | . ../functions.sh 6 | 7 | trap "docker rm -f caddy whoami0 whoami1 whoami_stopped" EXIT 8 | 9 | { 10 | docker run --name caddy -d -p 4443:443 -e CADDY_DOCKER_SCAN_STOPPED_CONTAINERS=true -v /var/run/docker.sock:/var/run/docker.sock caddy-docker-proxy:local && 11 | docker run --name whoami0 -d -l caddy=whoami0.example.com -l "caddy.reverse_proxy={{upstreams 80}}" -l caddy.tls=internal traefik/whoami && 12 | docker run --name whoami1 -d -l caddy=whoami1.example.com -l "caddy.reverse_proxy={{upstreams 80}}" -l caddy.tls=internal traefik/whoami && 13 | docker create --name whoami_stopped -l caddy=whoami_stopped.example.com -l "caddy.respond=\"I'm a stopped container!\" 200" -l caddy.tls=internal traefik/whoami && 14 | 15 | retry curl -k --resolve whoami0.example.com:4443:127.0.0.1 https://whoami0.example.com:4443 && 16 | retry curl -k --resolve whoami1.example.com:4443:127.0.0.1 https://whoami1.example.com:4443 && 17 | retry curl -k --resolve whoami_stopped.example.com:4443:127.0.0.1 https://whoami_stopped.example.com:4443 18 | } || { 19 | echo "Test failed" 20 | exit 1 21 | } -------------------------------------------------------------------------------- /tests/distributed/compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | 5 | caddy_server: 6 | image: caddy-docker-proxy:local 7 | ports: 8 | - 80:80 9 | - 443:443 10 | networks: 11 | - caddy_controller 12 | - caddy 13 | environment: 14 | - CADDY_DOCKER_MODE=server 15 | - CADDY_CONTROLLER_NETWORK=10.200.200.0/24 16 | deploy: 17 | replicas: 3 18 | labels: 19 | caddy_controlled_server: 20 | 21 | caddy_controller: 22 | image: caddy-docker-proxy:local 23 | networks: 24 | - caddy_controller 25 | - caddy 26 | environment: 27 | - CADDY_DOCKER_MODE=controller 28 | - CADDY_CONTROLLER_NETWORK=10.200.200.0/24 29 | volumes: 30 | - source: "${DOCKER_SOCKET_PATH}" 31 | target: "${DOCKER_SOCKET_PATH}" 32 | type: ${DOCKER_SOCKET_TYPE} 33 | 34 | # Proxy to service 35 | whoami0: 36 | image: traefik/whoami 37 | networks: 38 | - caddy 39 | deploy: 40 | labels: 41 | caddy: whoami0.example.com 42 | caddy.reverse_proxy: "{{upstreams 80}}" 43 | caddy.tls: "internal" 44 | 45 | # Proxy to service 46 | whoami1: 47 | image: traefik/whoami 48 | networks: 49 | - caddy 50 | deploy: 51 | labels: 52 | caddy: whoami1.example.com 53 | caddy.reverse_proxy: "{{upstreams 80}}" 54 | caddy.tls: "internal" 55 | 56 | # Proxy to container 57 | whoami2: 58 | image: traefik/whoami 59 | networks: 60 | - caddy 61 | labels: 62 | caddy: whoami2.example.com 63 | caddy.reverse_proxy: "{{upstreams 80}}" 64 | caddy.tls: "internal" 65 | 66 | # Proxy to container 67 | whoami3: 68 | image: traefik/whoami 69 | networks: 70 | - caddy 71 | labels: 72 | caddy: whoami3.example.com 73 | caddy.reverse_proxy: "{{upstreams 80}}" 74 | caddy.tls: "internal" 75 | 76 | # Proxy with matches and route 77 | echo_0: 78 | image: traefik/whoami 79 | networks: 80 | - caddy 81 | deploy: 82 | labels: 83 | caddy: echo0.example.com 84 | caddy.@match.path: "/sourcepath /sourcepath/*" 85 | caddy.route: "@match" 86 | caddy.route.0_uri: "strip_prefix /sourcepath" 87 | caddy.route.1_rewrite: "* /targetpath{path}" 88 | caddy.route.2_reverse_proxy: "{{upstreams 80}}" 89 | caddy.tls: "internal" 90 | 91 | networks: 92 | caddy: 93 | name: caddy_test 94 | external: true 95 | caddy_controller: 96 | driver: overlay 97 | ipam: 98 | driver: default 99 | config: 100 | - subnet: "10.200.200.0/24" -------------------------------------------------------------------------------- /tests/distributed/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | . ../functions.sh 6 | 7 | docker stack deploy -c compose.yaml --prune caddy_test 8 | 9 | retry curl --show-error -s -k -f --resolve whoami0.example.com:443:127.0.0.1 https://whoami0.example.com && 10 | retry curl --show-error -s -k -f --resolve whoami1.example.com:443:127.0.0.1 https://whoami1.example.com && 11 | retry curl --show-error -s -k -f --resolve whoami2.example.com:443:127.0.0.1 https://whoami2.example.com && 12 | retry curl --show-error -s -k -f --resolve whoami3.example.com:443:127.0.0.1 https://whoami3.example.com && 13 | retry curl --show-error -s -k -f --resolve echo0.example.com:443:127.0.0.1 https://echo0.example.com/sourcepath/something || { 14 | docker service logs caddy_test_caddy_controller 15 | docker service logs caddy_test_caddy_server 16 | exit 1 17 | } 18 | -------------------------------------------------------------------------------- /tests/empty/compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | 5 | caddy: 6 | image: caddy-docker-proxy:local 7 | ports: 8 | - 80:80 9 | - 443:443 10 | networks: 11 | - caddy 12 | volumes: 13 | - source: "${DOCKER_SOCKET_PATH}" 14 | target: "${DOCKER_SOCKET_PATH}" 15 | type: ${DOCKER_SOCKET_TYPE} 16 | 17 | networks: 18 | caddy: 19 | name: caddy_test 20 | external: true -------------------------------------------------------------------------------- /tests/empty/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | . ../functions.sh 6 | 7 | docker stack deploy -c compose.yaml --prune caddy_test 8 | 9 | docker service logs caddy_test_caddy 10 | -------------------------------------------------------------------------------- /tests/envfile/Caddyfile: -------------------------------------------------------------------------------- 1 | service.local { 2 | handle {$ENV_HANDLE_PATH} { 3 | respond "Hello from TestEnv" 4 | } 5 | tls internal 6 | } 7 | -------------------------------------------------------------------------------- /tests/envfile/Envfile: -------------------------------------------------------------------------------- 1 | ENV_HANDLE_PATH=/testenv 2 | -------------------------------------------------------------------------------- /tests/envfile/compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | caddy: 5 | image: caddy-docker-proxy:local 6 | ports: 7 | - 80:80 8 | - 443:443 9 | networks: 10 | - caddy 11 | environment: 12 | - CADDY_DOCKER_CADDYFILE_PATH=/etc/caddy/Caddyfile 13 | command: ["docker-proxy", "--envfile", "/etc/caddy/env"] 14 | volumes: 15 | - source: "./Caddyfile" 16 | target: '/etc/caddy/Caddyfile' 17 | type: bind 18 | - source: "./Envfile" 19 | target: "/etc/caddy/env" 20 | type: bind 21 | - source: "${DOCKER_SOCKET_PATH}" 22 | target: "${DOCKER_SOCKET_PATH}" 23 | type: ${DOCKER_SOCKET_TYPE} 24 | 25 | networks: 26 | caddy: 27 | name: caddy_test 28 | external: true 29 | internal: 30 | name: internal 31 | internal: true 32 | -------------------------------------------------------------------------------- /tests/envfile/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | . ../functions.sh 6 | 7 | docker stack deploy -c compose.yaml --prune caddy_test 8 | 9 | retry curl --show-error -s -k -f --resolve service.local:443:127.0.0.1 https://service.local/testenv | grep "Hello from TestEnv" || { 10 | docker service logs caddy_test_caddy 11 | exit 1 12 | } 13 | -------------------------------------------------------------------------------- /tests/functions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function retry { 4 | local n=0 5 | local max=20 6 | local delay=5 7 | while true; do 8 | ((n=n+1)) 9 | "$@" && break || { 10 | echo "Command failed. Attempt $n/$max." 11 | if [[ $n -ge $max ]]; then 12 | return 1 13 | fi 14 | sleep $delay; 15 | } 16 | done 17 | } 18 | -------------------------------------------------------------------------------- /tests/ingress-networks/compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | 5 | caddy_server: 6 | image: caddy-docker-proxy:local 7 | ports: 8 | - 80:80 9 | - 443:443 10 | networks: 11 | - controller 12 | - ingress_0 13 | - ingress_1 14 | - ingress_2 15 | environment: 16 | - CADDY_DOCKER_MODE=server 17 | - CADDY_CONTROLLER_NETWORK=10.200.200.0/24 18 | deploy: 19 | replicas: 3 20 | labels: 21 | caddy_controlled_server: 22 | 23 | caddy_controller: 24 | image: caddy-docker-proxy:local 25 | networks: 26 | - controller 27 | environment: 28 | - CADDY_DOCKER_MODE=controller 29 | - CADDY_CONTROLLER_NETWORK=10.200.200.0/24 30 | - CADDY_INGRESS_NETWORKS=ingress_0,ingress_1 31 | volumes: 32 | - source: "${DOCKER_SOCKET_PATH}" 33 | target: "${DOCKER_SOCKET_PATH}" 34 | type: ${DOCKER_SOCKET_TYPE} 35 | 36 | # Proxy to service 37 | whoami0: 38 | image: traefik/whoami 39 | networks: 40 | - ingress_0 41 | deploy: 42 | labels: 43 | caddy: whoami0.example.com 44 | caddy.reverse_proxy: "{{upstreams 80}}" 45 | caddy.tls: "internal" 46 | 47 | # Proxy to service 48 | whoami1: 49 | image: traefik/whoami 50 | networks: 51 | - ingress_1 52 | deploy: 53 | labels: 54 | caddy: whoami1.example.com 55 | caddy.reverse_proxy: "{{upstreams 80}}" 56 | caddy.tls: "internal" 57 | 58 | # Proxy to service 59 | whoami2: 60 | image: traefik/whoami 61 | networks: 62 | - internal 63 | - ingress_2 64 | deploy: 65 | labels: 66 | caddy: whoami2.example.com 67 | caddy.reverse_proxy: "{{upstreams 80}}" 68 | caddy.tls: "internal" 69 | caddy_ingress_network: ingress_2 70 | 71 | networks: 72 | ingress_0: 73 | name: ingress_0 74 | ingress_1: 75 | name: ingress_1 76 | ingress_2: 77 | name: ingress_2 78 | internal: 79 | name: internal 80 | internal: true 81 | controller: 82 | driver: overlay 83 | ipam: 84 | driver: default 85 | config: 86 | - subnet: "10.200.200.0/24" 87 | -------------------------------------------------------------------------------- /tests/ingress-networks/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | . ../functions.sh 6 | 7 | docker stack deploy -c compose.yaml --prune caddy_test 8 | 9 | retry curl --show-error -s -k -f --resolve whoami0.example.com:443:127.0.0.1 https://whoami0.example.com && 10 | retry curl --show-error -s -k -f --resolve whoami1.example.com:443:127.0.0.1 https://whoami1.example.com && 11 | retry curl --show-error -s -k -f --resolve whoami2.example.com:443:127.0.0.1 https://whoami2.example.com || { 12 | docker service logs caddy_test_caddy_controller 13 | docker service logs caddy_test_caddy_server 14 | exit 1 15 | } 16 | -------------------------------------------------------------------------------- /tests/process-caddyfile-off/compose_correct.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | 5 | caddy: 6 | image: caddy-docker-proxy:local 7 | ports: 8 | - 80:80 9 | - 443:443 10 | networks: 11 | - caddy 12 | environment: 13 | - CADDY_DOCKER_PROCESS_CADDYFILE=false 14 | volumes: 15 | - source: "${DOCKER_SOCKET_PATH}" 16 | target: "${DOCKER_SOCKET_PATH}" 17 | type: ${DOCKER_SOCKET_TYPE} 18 | 19 | # Proxy to service 20 | whoami0: 21 | image: traefik/whoami 22 | networks: 23 | - caddy 24 | deploy: 25 | labels: 26 | caddy: whoami0.example.com 27 | caddy.reverse_proxy: "{{upstreams 80}}" 28 | caddy.tls: "internal" 29 | 30 | # Proxy to service 31 | whoami1: 32 | image: traefik/whoami 33 | networks: 34 | - caddy 35 | deploy: 36 | labels: 37 | caddy: whoami1.example.com 38 | caddy.reverse_proxy: "{{upstreams 80}}" 39 | caddy.tls: "internal" 40 | 41 | networks: 42 | caddy: 43 | name: caddy_test 44 | external: true -------------------------------------------------------------------------------- /tests/process-caddyfile-off/compose_wrong.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | 5 | caddy: 6 | image: caddy-docker-proxy:local 7 | ports: 8 | - 80:80 9 | - 443:443 10 | networks: 11 | - caddy 12 | environment: 13 | - CADDY_DOCKER_PROCESS_CADDYFILE=false 14 | volumes: 15 | - source: "${DOCKER_SOCKET_PATH}" 16 | target: "${DOCKER_SOCKET_PATH}" 17 | type: ${DOCKER_SOCKET_TYPE} 18 | 19 | # Proxy to service 20 | whoami0: 21 | image: traefik/whoami 22 | networks: 23 | - caddy 24 | deploy: 25 | labels: 26 | caddy: whoami0.example.com 27 | caddy.reverse_proxy: "{{upstreams 80}}" 28 | caddy.tls: "invalid_value" 29 | 30 | # Proxy to service 31 | whoami1: 32 | image: traefik/whoami 33 | networks: 34 | - caddy 35 | deploy: 36 | labels: 37 | caddy: whoami1.example.com 38 | caddy.reverse_proxy: "{{upstreams 80}}" 39 | caddy.tls: "internal" 40 | 41 | networks: 42 | caddy: 43 | name: caddy_test 44 | external: true -------------------------------------------------------------------------------- /tests/process-caddyfile-off/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | . ../functions.sh 6 | 7 | docker stack deploy -c compose_correct.yaml --prune caddy_test 8 | 9 | retry curl --show-error -s -k -f --resolve whoami0.example.com:443:127.0.0.1 https://whoami0.example.com && 10 | retry curl --show-error -s -k -f --resolve whoami1.example.com:443:127.0.0.1 https://whoami1.example.com || { 11 | docker service logs caddy_test_caddy 12 | exit 1 13 | } 14 | 15 | # docker stack deploy -c compose_wrong.yaml --prune caddy_test 16 | 17 | # retry curl --show-error -s -k -f --resolve whoami0.example.com:443:127.0.0.1 https://whoami0.example.com && 18 | # retry curl --show-error -s -k -f --resolve whoami1.example.com:443:127.0.0.1 https://whoami1.example.com || { 19 | # docker service logs caddy_test_caddy 20 | # exit 1 21 | # } -------------------------------------------------------------------------------- /tests/process-caddyfile-on/compose_wrong.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | 5 | caddy: 6 | image: caddy-docker-proxy:local 7 | ports: 8 | - 80:80 9 | - 443:443 10 | networks: 11 | - caddy 12 | environment: 13 | - CADDY_DOCKER_PROCESS_CADDYFILE=true 14 | volumes: 15 | - source: "${DOCKER_SOCKET_PATH}" 16 | target: "${DOCKER_SOCKET_PATH}" 17 | type: ${DOCKER_SOCKET_TYPE} 18 | 19 | # Proxy to service 20 | whoami0: 21 | image: traefik/whoami 22 | networks: 23 | - caddy 24 | deploy: 25 | labels: 26 | caddy: whoami0.example.com 27 | caddy.reverse_proxy: "{{upstreams 80}}" 28 | caddy.tls: "invalid_value" 29 | 30 | # Proxy to service 31 | whoami1: 32 | image: traefik/whoami 33 | networks: 34 | - caddy 35 | deploy: 36 | labels: 37 | caddy: whoami1.example.com 38 | caddy.reverse_proxy: "{{upstreams 80}}" 39 | caddy.tls: "internal" 40 | 41 | networks: 42 | caddy: 43 | name: caddy_test 44 | external: true -------------------------------------------------------------------------------- /tests/process-caddyfile-on/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | . ../functions.sh 6 | 7 | docker stack deploy -c compose_wrong.yaml --prune caddy_test 8 | 9 | retry curl --show-error -s -k -f --resolve whoami1.example.com:443:127.0.0.1 https://whoami1.example.com || { 10 | docker service logs caddy_test_caddy 11 | exit 1 12 | } -------------------------------------------------------------------------------- /tests/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | trap "exit 1" INT 6 | trap "docker stack rm caddy_test" EXIT 7 | 8 | docker network create --driver overlay --attachable caddy_test || true 9 | 10 | for d in */ 11 | do 12 | docker stack rm caddy_test || true 13 | 14 | echo "" 15 | echo "" 16 | echo "=== Running test $d ===" 17 | echo "" 18 | (cd $d && . run.sh) 19 | done 20 | -------------------------------------------------------------------------------- /tests/standalone/compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | 5 | caddy: 6 | image: caddy-docker-proxy:local 7 | ports: 8 | - 80:80 9 | - 443:443 10 | networks: 11 | - caddy 12 | volumes: 13 | - source: "${DOCKER_SOCKET_PATH}" 14 | target: "${DOCKER_SOCKET_PATH}" 15 | type: ${DOCKER_SOCKET_TYPE} 16 | 17 | # Proxy to service 18 | whoami0: 19 | image: traefik/whoami 20 | networks: 21 | - caddy 22 | deploy: 23 | labels: 24 | caddy: whoami0.example.com 25 | caddy.reverse_proxy: "{{upstreams 80}}" 26 | caddy.tls: "internal" 27 | 28 | # Proxy to service 29 | whoami1: 30 | image: traefik/whoami 31 | networks: 32 | - caddy 33 | deploy: 34 | labels: 35 | caddy: whoami1.example.com 36 | caddy.reverse_proxy: "{{upstreams 80}}" 37 | caddy.tls: "internal" 38 | 39 | # Proxy to container 40 | whoami2: 41 | image: traefik/whoami 42 | networks: 43 | - caddy 44 | labels: 45 | caddy: whoami2.example.com 46 | caddy.reverse_proxy: "{{upstreams 80}}" 47 | caddy.tls: "internal" 48 | 49 | # Proxy to container 50 | whoami3: 51 | image: traefik/whoami 52 | networks: 53 | - caddy 54 | labels: 55 | caddy: whoami3.example.com 56 | caddy.reverse_proxy: "{{upstreams 80}}" 57 | caddy.tls: "internal" 58 | 59 | # Proxy to container 60 | whoami4: 61 | image: traefik/whoami 62 | networks: 63 | - internal 64 | - caddy 65 | labels: 66 | caddy: whoami4.example.com 67 | caddy.reverse_proxy: "{{upstreams 80}}" 68 | caddy.tls: "internal" 69 | caddy_ingress_network: caddy_test 70 | 71 | # Proxy with matches and route 72 | echo_0: 73 | image: traefik/whoami 74 | networks: 75 | - caddy 76 | deploy: 77 | labels: 78 | caddy: echo0.example.com 79 | caddy.@match.path: "/sourcepath /sourcepath/*" 80 | caddy.route: "@match" 81 | caddy.route.0_uri: "strip_prefix /sourcepath" 82 | caddy.route.1_rewrite: "* /targetpath{path}" 83 | caddy.route.2_reverse_proxy: "{{upstreams 80}}" 84 | caddy.tls: "internal" 85 | 86 | networks: 87 | caddy: 88 | name: caddy_test 89 | external: true 90 | internal: 91 | name: internal 92 | internal: true 93 | -------------------------------------------------------------------------------- /tests/standalone/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | . ../functions.sh 6 | 7 | docker stack deploy -c compose.yaml --prune caddy_test 8 | 9 | retry curl --show-error -s -k -f --resolve whoami0.example.com:443:127.0.0.1 https://whoami0.example.com && 10 | retry curl --show-error -s -k -f --resolve whoami1.example.com:443:127.0.0.1 https://whoami1.example.com && 11 | retry curl --show-error -s -k -f --resolve whoami2.example.com:443:127.0.0.1 https://whoami2.example.com && 12 | retry curl --show-error -s -k -f --resolve whoami3.example.com:443:127.0.0.1 https://whoami3.example.com && 13 | retry curl --show-error -s -k -f --resolve whoami4.example.com:443:127.0.0.1 https://whoami4.example.com && 14 | retry curl --show-error -s -k -f --resolve echo0.example.com:443:127.0.0.1 https://echo0.example.com/sourcepath/something || { 15 | docker service logs caddy_test_caddy 16 | exit 1 17 | } 18 | -------------------------------------------------------------------------------- /utils/stringBoolCMap.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // StringBoolCMap is a concurrent map implementation of map[string]bool 8 | type StringBoolCMap struct { 9 | mutex sync.RWMutex 10 | internal map[string]bool 11 | } 12 | 13 | func NewStringBoolCMap() *StringBoolCMap { 14 | return &StringBoolCMap{ 15 | mutex: sync.RWMutex{}, 16 | internal: map[string]bool{}, 17 | } 18 | } 19 | 20 | // Set map value 21 | func (m *StringBoolCMap) Set(key string, value bool) { 22 | m.mutex.Lock() 23 | defer m.mutex.Unlock() 24 | m.internal[key] = value 25 | } 26 | 27 | // Get map value or default 28 | func (m *StringBoolCMap) Get(key string) bool { 29 | m.mutex.RLock() 30 | defer m.mutex.RUnlock() 31 | return m.internal[key] 32 | } 33 | 34 | // Delete map value 35 | func (m *StringBoolCMap) Delete(key string) { 36 | m.mutex.Lock() 37 | defer m.mutex.Unlock() 38 | delete(m.internal, key) 39 | } 40 | -------------------------------------------------------------------------------- /utils/stringInt64CMap.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // StringInt64CMap is a concurrent map implementation of map[string]int64 8 | type StringInt64CMap struct { 9 | mutex sync.RWMutex 10 | internal map[string]int64 11 | } 12 | 13 | func NewStringInt64CMap() *StringInt64CMap { 14 | return &StringInt64CMap{ 15 | mutex: sync.RWMutex{}, 16 | internal: map[string]int64{}, 17 | } 18 | } 19 | 20 | // Set map value 21 | func (m *StringInt64CMap) Set(key string, value int64) { 22 | m.mutex.Lock() 23 | defer m.mutex.Unlock() 24 | m.internal[key] = value 25 | } 26 | 27 | // Get map value or default 28 | func (m *StringInt64CMap) Get(key string) int64 { 29 | m.mutex.RLock() 30 | defer m.mutex.RUnlock() 31 | return m.internal[key] 32 | } 33 | 34 | // Delete map value 35 | func (m *StringInt64CMap) Delete(key string) { 36 | m.mutex.Lock() 37 | defer m.mutex.Unlock() 38 | delete(m.internal, key) 39 | } 40 | --------------------------------------------------------------------------------