├── .dockerignore ├── tests ├── caddyfile+config │ ├── config │ │ ├── .gitignore │ │ └── Caddyfile │ ├── CaddyfileConfig │ ├── run.sh │ └── compose.yaml ├── envfile │ ├── Envfile │ ├── Caddyfile │ ├── run.sh │ └── compose.yaml ├── empty │ ├── run.sh │ └── compose.yaml ├── process-caddyfile-on │ ├── run.sh │ └── compose_wrong.yaml ├── functions.sh ├── run.sh ├── ingress-networks │ ├── run.sh │ └── compose.yaml ├── process-caddyfile-off │ ├── run.sh │ ├── compose_correct.yaml │ └── compose_wrong.yaml ├── distributed │ ├── run.sh │ └── compose.yaml ├── standalone │ ├── run.sh │ └── compose.yaml └── containers │ └── run.sh ├── caddyfile ├── testdata │ ├── process │ │ ├── empty.txt │ │ ├── blank.txt │ │ ├── invalid_file.txt │ │ └── invalid_block.txt │ ├── labels │ │ ├── template_error.txt │ │ ├── global_options.txt │ │ ├── templates_empty_values.txt │ │ ├── follow_alphabetical_order.txt │ │ ├── global_options_comes_first.txt │ │ ├── snippets_come_first.txt │ │ ├── one_line_matchers_come_first.txt │ │ ├── matchers_come_first.txt │ │ ├── grouping.txt │ │ ├── isolate_directives_with_suffix.txt │ │ ├── order_and_isolate_directives_with_prefix.txt │ │ ├── wildcard_certificates.txt │ │ └── quotes.txt │ ├── merge │ │ ├── php_fastcgi_no_matcher.txt │ │ ├── reverse_proxy_no_matcher.txt │ │ ├── php_fastcgi_same_matcher.txt │ │ ├── reverse_proxy_same_matcher.txt │ │ ├── php_fastcgi_different_matcher.txt │ │ └── reverse_proxy_different_matcher.txt │ └── marshal │ │ └── marshal.txt ├── processor.go ├── merge.go ├── marshal_test.go ├── merge_test.go ├── processor_test.go ├── fromlabels.go ├── caddyfile.go ├── fromlabels_test.go ├── lexer.go └── marshal.go ├── .gitignore ├── .editorconfig ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── ci-pipeline.yaml ├── examples ├── Caddyfile ├── distributed.yaml └── standalone.yaml ├── generator ├── testdata │ └── labels │ │ ├── invalid_template.txt │ │ ├── minimum_special_labels.txt │ │ ├── h2c_reverse_proxy.txt │ │ ├── multiple_addresses.txt │ │ ├── with_groups.txt │ │ ├── doesnt_override_existing_proxy.txt │ │ ├── all_special_labels.txt │ │ ├── reverse_proxy_directives_are_moved_into_route.txt │ │ └── multiple_configs.txt ├── labels.go ├── containers.go ├── labels_test.go ├── services.go ├── generator_test.go ├── generator.go ├── containers_test.go └── services_test.go ├── docker ├── utils_mock.go ├── utils.go ├── client_mock.go ├── client.go └── utils_test.go ├── Dockerfile-alpine ├── module.go ├── Dockerfile ├── Dockerfile-nanoserver ├── run-docker-tests-linux.sh ├── run-docker-tests-windows.sh ├── utils ├── stringBoolCMap.go └── stringInt64CMap.go ├── config └── options.go ├── LICENSE ├── go.mod ├── cmd.go ├── loader.go └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !artifacts -------------------------------------------------------------------------------- /tests/caddyfile+config/config/.gitignore: -------------------------------------------------------------------------------- 1 | caddy -------------------------------------------------------------------------------- /caddyfile/testdata/process/empty.txt: -------------------------------------------------------------------------------- 1 | ---------- 2 | -------------------------------------------------------------------------------- /tests/envfile/Envfile: -------------------------------------------------------------------------------- 1 | ENV_HANDLE_PATH=/testenv 2 | -------------------------------------------------------------------------------- /caddyfile/testdata/process/blank.txt: -------------------------------------------------------------------------------- 1 | 2 | ---------- 3 | -------------------------------------------------------------------------------- /caddyfile/testdata/labels/template_error.txt: -------------------------------------------------------------------------------- 1 | caddy.key = {{invalid}} 2 | ---------- -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | artifacts 2 | vendor 3 | debug.test 4 | local 5 | .DS_Store 6 | buildenv_* -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /caddyfile/testdata/labels/global_options.txt: -------------------------------------------------------------------------------- 1 | caddy.key = value 2 | ---------- 3 | { 4 | key value 5 | } -------------------------------------------------------------------------------- /caddyfile/testdata/labels/templates_empty_values.txt: -------------------------------------------------------------------------------- 1 | caddy = localhost 2 | caddy.key = {{""}} 3 | ---------- 4 | localhost { 5 | key 6 | } -------------------------------------------------------------------------------- /tests/envfile/Caddyfile: -------------------------------------------------------------------------------- 1 | service.local { 2 | handle {$ENV_HANDLE_PATH} { 3 | respond "Hello from TestEnv" 4 | } 5 | tls internal 6 | } 7 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /tests/caddyfile+config/CaddyfileConfig: -------------------------------------------------------------------------------- 1 | (configSnippet) { 2 | respond /config "config" 200 3 | } 4 | 5 | config.local { 6 | respond / "config" 200 7 | tls internal 8 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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/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 | } -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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"] -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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"] -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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"] -------------------------------------------------------------------------------- /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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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-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-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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /generator/labels.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "text/template" 7 | "sort" 8 | 9 | "github.com/lucaslorentz/caddy-docker-proxy/v2/caddyfile" 10 | ) 11 | 12 | type targetsProvider func() ([]string, error) 13 | 14 | func labelsToCaddyfile(labels map[string]string, templateData interface{}, getTargets targetsProvider) (*caddyfile.Container, error) { 15 | funcMap := template.FuncMap{ 16 | "upstreams": func(options ...interface{}) (string, error) { 17 | targets, err := getTargets() 18 | sort.Strings(targets) 19 | transformed := []string{} 20 | for _, target := range targets { 21 | for _, param := range options { 22 | if protocol, isProtocol := param.(string); isProtocol { 23 | target = protocol + "://" + target 24 | } else if port, isPort := param.(int); isPort { 25 | target = target + ":" + strconv.Itoa(port) 26 | } 27 | } 28 | transformed = append(transformed, target) 29 | } 30 | sort.Strings(transformed) 31 | return strings.Join(transformed, " "), err 32 | }, 33 | "http": func() string { 34 | return "http" 35 | }, 36 | "https": func() string { 37 | return "https" 38 | }, 39 | "h2c": func() string { 40 | return "h2c" 41 | }, 42 | } 43 | 44 | return caddyfile.FromLabels(labels, templateData, funcMap) 45 | } 46 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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_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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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.24.4 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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" -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/tcp 10 | - 443:443/udp 11 | networks: 12 | - caddy_controller 13 | - caddy 14 | environment: 15 | - CADDY_DOCKER_MODE=server 16 | - CADDY_CONTROLLER_NETWORK=10.200.200.0/24 17 | volumes: 18 | # this volume is needed to keep the certificates 19 | # otherwise, new ones will be re-issued upon restart 20 | - caddy_data:/data 21 | deploy: 22 | replicas: 3 23 | labels: 24 | caddy_controlled_server: 25 | 26 | caddy_controller: 27 | image: lucaslorentz/caddy-docker-proxy:ci-alpine 28 | networks: 29 | - caddy_controller 30 | - caddy 31 | environment: 32 | - CADDY_DOCKER_MODE=controller 33 | - CADDY_CONTROLLER_NETWORK=10.200.200.0/24 34 | volumes: 35 | - /var/run/docker.sock:/var/run/docker.sock 36 | 37 | # Proxy to service 38 | whoami0: 39 | image: traefik/whoami 40 | networks: 41 | - caddy 42 | deploy: 43 | labels: 44 | caddy: whoami0.example.com 45 | caddy.reverse_proxy: "{{upstreams 80}}" 46 | # remove the following line when you have verified your setup 47 | # Otherwise you risk being rate limited by let's encrypt 48 | caddy.tls.ca: https://acme-staging-v02.api.letsencrypt.org/directory 49 | 50 | # Proxy to service 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 | caddy.tls: "internal" 60 | 61 | # Proxy to container 62 | whoami2: 63 | image: traefik/whoami 64 | networks: 65 | - caddy 66 | labels: 67 | caddy: whoami2.example.com 68 | caddy.reverse_proxy: "{{upstreams 80}}" 69 | caddy.tls: "internal" 70 | 71 | # Proxy to container 72 | whoami3: 73 | image: traefik/whoami 74 | networks: 75 | - caddy 76 | labels: 77 | caddy: whoami3.example.com 78 | caddy.reverse_proxy: "{{upstreams 80}}" 79 | caddy.tls: "internal" 80 | 81 | # Proxy with matches and route 82 | echo_0: 83 | image: traefik/whoami 84 | networks: 85 | - caddy 86 | deploy: 87 | labels: 88 | caddy: echo0.example.com 89 | caddy.@match.path: "/sourcepath /sourcepath/*" 90 | caddy.route: "@match" 91 | caddy.route.0_uri: "strip_prefix /sourcepath" 92 | caddy.route.1_rewrite: "* /targetpath{path}" 93 | caddy.route.2_reverse_proxy: "{{upstreams 80}}" 94 | caddy.tls: "internal" 95 | 96 | networks: 97 | caddy: 98 | driver: overlay 99 | caddy_controller: 100 | driver: overlay 101 | ipam: 102 | driver: default 103 | config: 104 | - subnet: "10.200.200.0/24" 105 | 106 | volumes: 107 | caddy_data: {} 108 | -------------------------------------------------------------------------------- /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/tcp 18 | - 443:443/udp 19 | networks: 20 | - caddy 21 | volumes: 22 | - /var/run/docker.sock:/var/run/docker.sock 23 | # this volume is needed to keep the certificates 24 | # otherwise, new ones will be re-issued upon restart 25 | - caddy_data:/data 26 | deploy: 27 | labels: # Global options 28 | caddy.email: you@example.com 29 | placement: 30 | constraints: 31 | - node.role == manager 32 | replicas: 1 33 | restart_policy: 34 | condition: any 35 | resources: 36 | reservations: 37 | cpus: "0.1" 38 | memory: 200M 39 | 40 | # Proxy to service 41 | whoami0: 42 | image: traefik/whoami 43 | networks: 44 | - caddy 45 | deploy: 46 | labels: 47 | caddy: whoami0.example.com 48 | caddy.reverse_proxy: "{{upstreams 80}}" 49 | caddy.tls: "internal" 50 | 51 | # Proxy to service that you want to expose to the outside world 52 | whoami1: 53 | image: traefik/whoami 54 | networks: 55 | - caddy 56 | deploy: 57 | labels: 58 | caddy: whoami1.example.com 59 | caddy.reverse_proxy: "{{upstreams 80}}" 60 | # remove the following line when you have verified your setup 61 | # Otherwise you risk being rate limited by let's encrypt 62 | caddy.tls.ca: https://acme-staging-v02.api.letsencrypt.org/directory 63 | 64 | # Proxy to container 65 | whoami2: 66 | image: traefik/whoami 67 | networks: 68 | - caddy 69 | labels: 70 | caddy: whoami2.example.com 71 | caddy.reverse_proxy: "{{upstreams 80}}" 72 | caddy.tls: "internal" 73 | 74 | # Proxy to container 75 | whoami3: 76 | image: traefik/whoami 77 | networks: 78 | - caddy 79 | labels: 80 | caddy: whoami3.example.com 81 | caddy.reverse_proxy: "{{upstreams 80}}" 82 | caddy.tls: "internal" 83 | 84 | # Proxy with matches and route 85 | echo_0: 86 | image: traefik/whoami 87 | networks: 88 | - caddy 89 | deploy: 90 | labels: 91 | caddy: echo0.example.com 92 | caddy.@match.path: "/sourcepath /sourcepath/*" 93 | caddy.route: "@match" 94 | caddy.route.0_uri: "strip_prefix /sourcepath" 95 | caddy.route.1_rewrite: "* /targetpath{path}" 96 | caddy.route.2_reverse_proxy: "{{upstreams 80}}" 97 | caddy.tls: "internal" 98 | 99 | networks: 100 | caddy: 101 | driver: overlay 102 | 103 | volumes: 104 | caddy_data: {} 105 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/network" 10 | "github.com/docker/docker/api/types/swarm" 11 | "github.com/docker/docker/api/types/system" 12 | ) 13 | 14 | // ClientMock allows easily mocking of docker client data 15 | type ClientMock struct { 16 | ContainersData []types.Container 17 | ServicesData []swarm.Service 18 | ConfigsData []swarm.Config 19 | TasksData []swarm.Task 20 | NetworksData []network.Summary 21 | InfoData system.Info 22 | ContainerInspectData map[string]types.ContainerJSON 23 | NetworkInspectData map[string]network.Inspect 24 | EventsChannel chan events.Message 25 | ErrorsChannel chan error 26 | } 27 | 28 | // ContainerList list all containers 29 | func (mock *ClientMock) ContainerList(ctx context.Context, options container.ListOptions) ([]types.Container, error) { 30 | return mock.ContainersData, nil 31 | } 32 | 33 | // ServiceList list all services 34 | func (mock *ClientMock) ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) { 35 | return mock.ServicesData, nil 36 | } 37 | 38 | // TaskList list all tasks 39 | func (mock *ClientMock) TaskList(ctx context.Context, options types.TaskListOptions) ([]swarm.Task, error) { 40 | matchingTasks := []swarm.Task{} 41 | for _, task := range mock.TasksData { 42 | if !options.Filters.Match("service", task.ServiceID) { 43 | continue 44 | } 45 | if !options.Filters.Match("desired-state", string(task.DesiredState)) { 46 | continue 47 | } 48 | matchingTasks = append(matchingTasks, task) 49 | } 50 | return matchingTasks, nil 51 | } 52 | 53 | // ConfigList list all configs 54 | func (mock *ClientMock) ConfigList(ctx context.Context, options types.ConfigListOptions) ([]swarm.Config, error) { 55 | return mock.ConfigsData, nil 56 | } 57 | 58 | // NetworkList list all networks 59 | func (mock *ClientMock) NetworkList(ctx context.Context, options network.ListOptions) ([]network.Summary, error) { 60 | return mock.NetworksData, nil 61 | } 62 | 63 | // Info retrieves information about docker host 64 | func (mock *ClientMock) Info(ctx context.Context) (system.Info, error) { 65 | return mock.InfoData, nil 66 | } 67 | 68 | // ContainerInspect returns information about a specific container 69 | func (mock *ClientMock) ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) { 70 | return mock.ContainerInspectData[containerID], nil 71 | } 72 | 73 | // NetworkInspect returns information about a specific network 74 | func (mock *ClientMock) NetworkInspect(ctx context.Context, networkID string, options network.InspectOptions) (network.Inspect, error) { 75 | return mock.NetworkInspectData[networkID], nil 76 | } 77 | 78 | // ConfigInspectWithRaw return sinformation about a specific config 79 | func (mock *ClientMock) ConfigInspectWithRaw(ctx context.Context, id string) (swarm.Config, []byte, error) { 80 | for _, config := range mock.ConfigsData { 81 | if config.ID == id { 82 | return config, nil, nil 83 | } 84 | } 85 | return swarm.Config{}, nil, nil 86 | } 87 | 88 | // Events listen for events in docker 89 | func (mock *ClientMock) Events(ctx context.Context, options events.ListOptions) (<-chan events.Message, <-chan error) { 90 | return mock.EventsChannel, mock.ErrorsChannel 91 | } 92 | -------------------------------------------------------------------------------- /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/network" 10 | "github.com/docker/docker/api/types/swarm" 11 | "github.com/docker/docker/api/types/system" 12 | "github.com/docker/docker/client" 13 | ) 14 | 15 | // Client is an interface with needed functionalities from docker client 16 | type Client interface { 17 | ContainerList(ctx context.Context, options container.ListOptions) ([]types.Container, error) 18 | ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) 19 | TaskList(ctx context.Context, options types.TaskListOptions) ([]swarm.Task, error) 20 | Info(ctx context.Context) (system.Info, error) 21 | ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) 22 | NetworkInspect(ctx context.Context, networkID string, options network.InspectOptions) (network.Inspect, error) 23 | NetworkList(ctx context.Context, options network.ListOptions) ([]network.Summary, error) 24 | ConfigList(ctx context.Context, options types.ConfigListOptions) ([]swarm.Config, error) 25 | ConfigInspectWithRaw(ctx context.Context, id string) (swarm.Config, []byte, error) 26 | Events(ctx context.Context, options events.ListOptions) (<-chan events.Message, <-chan error) 27 | } 28 | 29 | // WrapClient creates a new docker client wrapper 30 | func WrapClient(client *client.Client) Client { 31 | return &clientWrapper{ 32 | client: client, 33 | } 34 | } 35 | 36 | type clientWrapper struct { 37 | client *client.Client 38 | } 39 | 40 | func (wrapper *clientWrapper) ContainerList(ctx context.Context, options container.ListOptions) ([]types.Container, error) { 41 | return wrapper.client.ContainerList(ctx, options) 42 | } 43 | 44 | func (wrapper *clientWrapper) ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) { 45 | return wrapper.client.ServiceList(ctx, options) 46 | } 47 | 48 | func (wrapper *clientWrapper) TaskList(ctx context.Context, options types.TaskListOptions) ([]swarm.Task, error) { 49 | return wrapper.client.TaskList(ctx, options) 50 | } 51 | 52 | func (wrapper *clientWrapper) ConfigList(ctx context.Context, options types.ConfigListOptions) ([]swarm.Config, error) { 53 | return wrapper.client.ConfigList(ctx, options) 54 | } 55 | 56 | func (wrapper *clientWrapper) Info(ctx context.Context) (system.Info, error) { 57 | return wrapper.client.Info(ctx) 58 | } 59 | 60 | func (wrapper *clientWrapper) ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) { 61 | return wrapper.client.ContainerInspect(ctx, containerID) 62 | } 63 | 64 | func (wrapper *clientWrapper) NetworkInspect(ctx context.Context, networkID string, options network.InspectOptions) (network.Inspect, error) { 65 | return wrapper.client.NetworkInspect(ctx, networkID, options) 66 | } 67 | 68 | func (wrapper *clientWrapper) NetworkList(ctx context.Context, options network.ListOptions) ([]network.Summary, error) { 69 | return wrapper.client.NetworkList(ctx, options) 70 | } 71 | 72 | func (wrapper *clientWrapper) ConfigInspectWithRaw(ctx context.Context, id string) (swarm.Config, []byte, error) { 73 | return wrapper.client.ConfigInspectWithRaw(ctx, id) 74 | } 75 | 76 | func (wrapper *clientWrapper) Events(ctx context.Context, options events.ListOptions) (<-chan events.Message, <-chan error) { 77 | return wrapper.client.Events(ctx, options) 78 | } 79 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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: []network.Summary{}, 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]network.Summary{ 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 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lucaslorentz/caddy-docker-proxy/v2 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/caddyserver/caddy/v2 v2.10.2 7 | github.com/docker/docker v28.5.2+incompatible 8 | github.com/joho/godotenv v1.5.1 9 | github.com/stretchr/testify v1.11.1 10 | go.uber.org/zap v1.27.1 11 | ) 12 | 13 | require ( 14 | cel.dev/expr v0.24.0 // indirect 15 | cloud.google.com/go/auth v0.16.2 // indirect 16 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 17 | cloud.google.com/go/compute/metadata v0.7.0 // indirect 18 | dario.cat/mergo v1.0.1 // indirect 19 | filippo.io/edwards25519 v1.1.0 // indirect 20 | github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect 21 | github.com/BurntSushi/toml v1.5.0 // indirect 22 | github.com/KimMachineGun/automemlimit v0.7.4 // indirect 23 | github.com/Masterminds/goutils v1.1.1 // indirect 24 | github.com/Masterminds/semver/v3 v3.3.0 // indirect 25 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 26 | github.com/Microsoft/go-winio v0.6.0 // indirect 27 | github.com/alecthomas/chroma/v2 v2.20.0 // indirect 28 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 29 | github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect 30 | github.com/beorn7/perks v1.0.1 // indirect 31 | github.com/caddyserver/certmagic v0.24.0 // indirect 32 | github.com/caddyserver/zerossl v0.1.3 // indirect 33 | github.com/ccoveille/go-safecast v1.6.1 // indirect 34 | github.com/cenkalti/backoff/v5 v5.0.2 // indirect 35 | github.com/cespare/xxhash v1.1.0 // indirect 36 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 37 | github.com/chzyer/readline v1.5.1 // indirect 38 | github.com/cloudflare/circl v1.6.1 // indirect 39 | github.com/containerd/errdefs v1.0.0 // indirect 40 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 41 | github.com/containerd/log v0.1.0 // indirect 42 | github.com/coreos/go-oidc/v3 v3.14.1 // indirect 43 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 44 | github.com/davecgh/go-spew v1.1.1 // indirect 45 | github.com/dgraph-io/badger v1.6.2 // indirect 46 | github.com/dgraph-io/badger/v2 v2.2007.4 // indirect 47 | github.com/dgraph-io/ristretto v0.2.0 // indirect 48 | github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect 49 | github.com/distribution/reference v0.6.0 // indirect 50 | github.com/dlclark/regexp2 v1.11.5 // indirect 51 | github.com/docker/go-connections v0.5.0 // indirect 52 | github.com/docker/go-units v0.5.0 // indirect 53 | github.com/dustin/go-humanize v1.0.1 // indirect 54 | github.com/felixge/httpsnoop v1.0.4 // indirect 55 | github.com/francoispqt/gojay v1.2.13 // indirect 56 | github.com/fxamacker/cbor/v2 v2.8.0 // indirect 57 | github.com/go-chi/chi/v5 v5.2.2 // indirect 58 | github.com/go-jose/go-jose/v3 v3.0.4 // indirect 59 | github.com/go-jose/go-jose/v4 v4.0.5 // indirect 60 | github.com/go-logr/logr v1.4.3 // indirect 61 | github.com/go-logr/stdr v1.2.2 // indirect 62 | github.com/go-sql-driver/mysql v1.8.1 // indirect 63 | github.com/golang/protobuf v1.5.4 // indirect 64 | github.com/golang/snappy v0.0.4 // indirect 65 | github.com/google/cel-go v0.26.0 // indirect 66 | github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745 // indirect 67 | github.com/google/go-tpm v0.9.5 // indirect 68 | github.com/google/go-tspi v0.3.0 // indirect 69 | github.com/google/s2a-go v0.1.9 // indirect 70 | github.com/google/uuid v1.6.0 // indirect 71 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 72 | github.com/googleapis/gax-go/v2 v2.14.2 // indirect 73 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect 74 | github.com/huandu/xstrings v1.5.0 // indirect 75 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 76 | github.com/jackc/pgpassfile v1.0.0 // indirect 77 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 78 | github.com/jackc/pgx/v5 v5.6.0 // indirect 79 | github.com/jackc/puddle/v2 v2.2.1 // indirect 80 | github.com/klauspost/compress v1.18.0 // indirect 81 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 82 | github.com/libdns/libdns v1.1.0 // indirect 83 | github.com/manifoldco/promptui v0.9.0 // indirect 84 | github.com/mattn/go-colorable v0.1.13 // indirect 85 | github.com/mattn/go-isatty v0.0.20 // indirect 86 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 87 | github.com/mholt/acmez/v3 v3.1.2 // indirect 88 | github.com/miekg/dns v1.1.63 // indirect 89 | github.com/mitchellh/copystructure v1.2.0 // indirect 90 | github.com/mitchellh/go-ps v1.0.0 // indirect 91 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 92 | github.com/moby/docker-image-spec v1.3.1 // indirect 93 | github.com/moby/sys/atomicwriter v0.1.0 // indirect 94 | github.com/moby/term v0.5.0 // indirect 95 | github.com/morikuni/aec v1.0.0 // indirect 96 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 97 | github.com/opencontainers/go-digest v1.0.0 // indirect 98 | github.com/opencontainers/image-spec v1.1.0 // indirect 99 | github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect 100 | github.com/pires/go-proxyproto v0.8.1 // indirect 101 | github.com/pkg/errors v0.9.1 // indirect 102 | github.com/pmezard/go-difflib v1.0.0 // indirect 103 | github.com/prometheus/client_golang v1.23.0 // indirect 104 | github.com/prometheus/client_model v0.6.2 // indirect 105 | github.com/prometheus/common v0.65.0 // indirect 106 | github.com/prometheus/procfs v0.16.1 // indirect 107 | github.com/quic-go/qpack v0.5.1 // indirect 108 | github.com/quic-go/quic-go v0.54.0 // indirect 109 | github.com/rs/xid v1.6.0 // indirect 110 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 111 | github.com/shopspring/decimal v1.4.0 // indirect 112 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 113 | github.com/sirupsen/logrus v1.9.3 // indirect 114 | github.com/slackhq/nebula v1.9.5 // indirect 115 | github.com/smallstep/certificates v0.28.4 // indirect 116 | github.com/smallstep/cli-utils v0.12.1 // indirect 117 | github.com/smallstep/go-attestation v0.4.4-0.20241119153605-2306d5b464ca // indirect 118 | github.com/smallstep/linkedca v0.23.0 // indirect 119 | github.com/smallstep/nosql v0.7.0 // indirect 120 | github.com/smallstep/pkcs7 v0.2.1 // indirect 121 | github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101 // indirect 122 | github.com/smallstep/truststore v0.13.0 // indirect 123 | github.com/spf13/cast v1.7.0 // indirect 124 | github.com/spf13/cobra v1.9.1 // indirect 125 | github.com/spf13/pflag v1.0.7 // indirect 126 | github.com/stoewer/go-strcase v1.2.0 // indirect 127 | github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 // indirect 128 | github.com/urfave/cli v1.22.17 // indirect 129 | github.com/x448/float16 v0.8.4 // indirect 130 | github.com/yuin/goldmark v1.7.13 // indirect 131 | github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect 132 | github.com/zeebo/blake3 v0.2.4 // indirect 133 | go.etcd.io/bbolt v1.3.10 // indirect 134 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 135 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect 136 | go.opentelemetry.io/contrib/propagators/autoprop v0.62.0 // indirect 137 | go.opentelemetry.io/contrib/propagators/aws v1.37.0 // indirect 138 | go.opentelemetry.io/contrib/propagators/b3 v1.37.0 // indirect 139 | go.opentelemetry.io/contrib/propagators/jaeger v1.37.0 // indirect 140 | go.opentelemetry.io/contrib/propagators/ot v1.37.0 // indirect 141 | go.opentelemetry.io/otel v1.37.0 // indirect 142 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect 143 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect 144 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect 145 | go.opentelemetry.io/otel/metric v1.37.0 // indirect 146 | go.opentelemetry.io/otel/sdk v1.37.0 // indirect 147 | go.opentelemetry.io/otel/trace v1.37.0 // indirect 148 | go.opentelemetry.io/proto/otlp v1.7.0 // indirect 149 | go.step.sm/crypto v0.67.0 // indirect 150 | go.uber.org/automaxprocs v1.6.0 // indirect 151 | go.uber.org/mock v0.5.2 // indirect 152 | go.uber.org/multierr v1.11.0 // indirect 153 | go.uber.org/zap/exp v0.3.0 // indirect 154 | golang.org/x/crypto v0.40.0 // indirect 155 | golang.org/x/crypto/x509roots/fallback v0.0.0-20250305170421-49bf5b80c810 // indirect 156 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 157 | golang.org/x/mod v0.25.0 // indirect 158 | golang.org/x/net v0.42.0 // indirect 159 | golang.org/x/oauth2 v0.30.0 // indirect 160 | golang.org/x/sync v0.16.0 // indirect 161 | golang.org/x/sys v0.34.0 // indirect 162 | golang.org/x/term v0.33.0 // indirect 163 | golang.org/x/text v0.27.0 // indirect 164 | golang.org/x/time v0.12.0 // indirect 165 | golang.org/x/tools v0.34.0 // indirect 166 | google.golang.org/api v0.240.0 // indirect 167 | google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 168 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect 169 | google.golang.org/grpc v1.73.0 // indirect 170 | google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect 171 | google.golang.org/protobuf v1.36.6 // indirect 172 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect 173 | gopkg.in/yaml.v3 v3.0.1 // indirect 174 | gotest.tools/v3 v3.5.1 // indirect 175 | howett.net/plist v1.0.0 // indirect 176 | ) 177 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /generator/generator.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "net" 8 | "os" 9 | "regexp" 10 | "strings" 11 | "time" 12 | 13 | "github.com/docker/docker/api/types" 14 | "github.com/docker/docker/api/types/container" 15 | "github.com/docker/docker/api/types/network" 16 | "github.com/docker/docker/api/types/swarm" 17 | "github.com/lucaslorentz/caddy-docker-proxy/v2/caddyfile" 18 | "github.com/lucaslorentz/caddy-docker-proxy/v2/config" 19 | "github.com/lucaslorentz/caddy-docker-proxy/v2/docker" 20 | 21 | "go.uber.org/zap" 22 | ) 23 | 24 | // DefaultLabelPrefix for caddy labels in docker 25 | const DefaultLabelPrefix = "caddy" 26 | 27 | const IngressNetworkLabel = "caddy_ingress_network" 28 | 29 | const swarmAvailabilityCacheInterval = 1 * time.Minute 30 | 31 | // CaddyfileGenerator generates caddyfile from docker configuration 32 | type CaddyfileGenerator struct { 33 | options *config.Options 34 | labelRegex *regexp.Regexp 35 | dockerClients []docker.Client 36 | dockerUtils docker.Utils 37 | ingressNetworks map[string]bool 38 | swarmIsAvailable []bool 39 | swarmIsAvailableTime time.Time 40 | } 41 | 42 | // CreateGenerator creates a new generator 43 | func CreateGenerator(dockerClients []docker.Client, dockerUtils docker.Utils, options *config.Options) *CaddyfileGenerator { 44 | var labelRegexString = fmt.Sprintf("^%s(_\\d+)?(\\.|$)", regexp.QuoteMeta(options.LabelPrefix)) 45 | 46 | return &CaddyfileGenerator{ 47 | options: options, 48 | labelRegex: regexp.MustCompile(labelRegexString), 49 | dockerClients: dockerClients, 50 | swarmIsAvailable: make([]bool, len(dockerClients)), 51 | dockerUtils: dockerUtils, 52 | } 53 | } 54 | 55 | // GenerateCaddyfile generates a caddy file config from docker metadata 56 | func (g *CaddyfileGenerator) GenerateCaddyfile(logger *zap.Logger) ([]byte, []string) { 57 | var caddyfileBuffer bytes.Buffer 58 | 59 | ingressNetworks, err := g.getIngressNetworks(logger) 60 | if err == nil { 61 | g.ingressNetworks = ingressNetworks 62 | } else { 63 | logger.Error("Failed to get ingress networks", zap.Error(err)) 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(), network.ListOptions{}) 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 _, networkEndpoint := range container.NetworkSettings.Networks { 266 | networkInfo, err := dockerClient.NetworkInspect(context.Background(), networkEndpoint.NetworkID, network.InspectOptions{}) 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 | // Canonicalize label prefix to "caddy", to prevent any meta characters in the prefix from causing problem in block parsing 289 | label = strings.Replace(label, g.options.LabelPrefix, "caddy", 1) 290 | filteredLabels[label] = value 291 | } 292 | } 293 | return filteredLabels 294 | } 295 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 = []network.Summary{ 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 = []network.Summary{ 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 | -------------------------------------------------------------------------------- /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/events" 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 | args.Add("type", "network") 193 | 194 | for i, dockerClient := range dockerLoader.dockerClients { 195 | context, cancel := context.WithCancel(context.Background()) 196 | 197 | eventsChan, errorChan := dockerClient.Events(context, events.ListOptions{ 198 | Filters: args, 199 | }) 200 | 201 | log := logger() 202 | log.Info("Connecting to docker events", zap.String("DockerSocket", dockerLoader.options.DockerSockets[i])) 203 | 204 | ListenEvents: 205 | for { 206 | select { 207 | case event := <-eventsChan: 208 | if dockerLoader.skipEvents[i] { 209 | continue 210 | } 211 | 212 | update := (event.Type == "container" && event.Action == "create") || 213 | (event.Type == "container" && event.Action == "start") || 214 | (event.Type == "container" && event.Action == "stop") || 215 | (event.Type == "container" && event.Action == "die") || 216 | (event.Type == "container" && event.Action == "destroy") || 217 | (event.Type == "service" && event.Action == "create") || 218 | (event.Type == "service" && event.Action == "update") || 219 | (event.Type == "service" && event.Action == "remove") || 220 | (event.Type == "config" && event.Action == "create") || 221 | (event.Type == "config" && event.Action == "remove") || 222 | (event.Type == "network" && event.Action == "connect") || 223 | (event.Type == "network" && event.Action == "disconnect") 224 | 225 | if update { 226 | dockerLoader.skipEvents[i] = true 227 | dockerLoader.timer.Reset(dockerLoader.options.EventThrottleInterval) 228 | } 229 | case err := <-errorChan: 230 | cancel() 231 | if err != nil { 232 | log.Error("Docker events error", zap.Error(err)) 233 | } 234 | break ListenEvents 235 | } 236 | } 237 | } 238 | } 239 | 240 | func (dockerLoader *DockerLoader) update() bool { 241 | dockerLoader.timer.Reset(dockerLoader.options.PollingInterval) 242 | for i := 0; i < len(dockerLoader.skipEvents); i++ { 243 | dockerLoader.skipEvents[i] = false 244 | } 245 | 246 | // Don't cache the logger more globally, it can change based on config reloads 247 | log := logger() 248 | caddyfile, controlledServers := dockerLoader.generator.GenerateCaddyfile(log) 249 | 250 | caddyfileChanged := !bytes.Equal(dockerLoader.lastCaddyfile, caddyfile) 251 | 252 | dockerLoader.lastCaddyfile = caddyfile 253 | 254 | if caddyfileChanged { 255 | log.Info("New Caddyfile", zap.ByteString("caddyfile", caddyfile)) 256 | 257 | tmpPath := CaddyfileAutosavePath + ".tmp" 258 | if err := os.WriteFile(tmpPath, caddyfile, 0640); err != nil { 259 | log.Warn("Failed to write temporary caddyfile", zap.Error(err), zap.String("path", tmpPath)) 260 | } else if err := os.Rename(tmpPath, CaddyfileAutosavePath); err != nil { 261 | log.Warn("Failed to autosave caddyfile", zap.Error(err), zap.String("path", CaddyfileAutosavePath)) 262 | } 263 | 264 | adapter := caddyconfig.GetAdapter("caddyfile") 265 | 266 | configJSON, warn, err := adapter.Adapt(caddyfile, nil) 267 | 268 | if warn != nil { 269 | log.Warn("Caddyfile to json warning", zap.String("warn", fmt.Sprintf("%v", warn))) 270 | } 271 | 272 | if err != nil { 273 | log.Error("Failed to convert caddyfile into json config", zap.Error(err)) 274 | return false 275 | } 276 | 277 | log.Info("New Config JSON", zap.ByteString("json", configJSON)) 278 | 279 | dockerLoader.lastJSONConfig = configJSON 280 | dockerLoader.lastVersion++ 281 | } 282 | 283 | var wg sync.WaitGroup 284 | for _, server := range controlledServers { 285 | wg.Add(1) 286 | go dockerLoader.updateServer(&wg, server) 287 | } 288 | wg.Wait() 289 | 290 | return true 291 | } 292 | 293 | func (dockerLoader *DockerLoader) updateServer(wg *sync.WaitGroup, server string) { 294 | defer wg.Done() 295 | 296 | // Skip servers that are being updated already 297 | if dockerLoader.serversUpdating.Get(server) { 298 | return 299 | } 300 | 301 | // Flag and unflag updating 302 | dockerLoader.serversUpdating.Set(server, true) 303 | defer dockerLoader.serversUpdating.Delete(server) 304 | 305 | version := dockerLoader.lastVersion 306 | 307 | // Skip servers that already have this version 308 | if dockerLoader.serversVersions.Get(server) >= version { 309 | return 310 | } 311 | 312 | log := logger() 313 | log.Info("Sending configuration to", zap.String("server", server)) 314 | 315 | url := "http://" + server + ":2019/load" 316 | 317 | postBody, err := addAdminListen(dockerLoader.lastJSONConfig, "tcp/"+server+":2019") 318 | if err != nil { 319 | log.Error("Failed to add admin listen to", zap.String("server", server), zap.Error(err)) 320 | return 321 | } 322 | 323 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(postBody)) 324 | if err != nil { 325 | log.Error("Failed to create request to", zap.String("server", server), zap.Error(err)) 326 | return 327 | } 328 | req.Header.Set("Content-Type", "application/json") 329 | resp, err := http.DefaultClient.Do(req) 330 | 331 | if err != nil { 332 | log.Error("Failed to send configuration to", zap.String("server", server), zap.Error(err)) 333 | return 334 | } 335 | 336 | bodyBytes, err := io.ReadAll(resp.Body) 337 | if err != nil { 338 | log.Error("Failed to read response from", zap.String("server", server), zap.Error(err)) 339 | return 340 | } 341 | 342 | if resp.StatusCode != 200 { 343 | log.Error("Error response from server", zap.String("server", server), zap.Int("status code", resp.StatusCode), zap.ByteString("body", bodyBytes)) 344 | return 345 | } 346 | 347 | dockerLoader.serversVersions.Set(server, version) 348 | 349 | log.Info("Successfully configured", zap.String("server", server)) 350 | } 351 | 352 | func addAdminListen(configJSON []byte, listen string) ([]byte, error) { 353 | config := &caddy.Config{} 354 | err := json.Unmarshal(configJSON, config) 355 | if err != nil { 356 | return nil, err 357 | } 358 | config.Admin = &caddy.AdminConfig{ 359 | Listen: listen, 360 | } 361 | return json.Marshal(config) 362 | } 363 | -------------------------------------------------------------------------------- /generator/services_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/docker/api/types/network" 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 = []network.Summary{ 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 = []network.Summary{ 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 = []network.Summary{ 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 | -------------------------------------------------------------------------------- /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/tcp 62 | - 443:443/udp 63 | environment: 64 | - CADDY_INGRESS_NETWORKS=caddy 65 | networks: 66 | - caddy 67 | volumes: 68 | - /var/run/docker.sock:/var/run/docker.sock 69 | - caddy_data:/data 70 | restart: unless-stopped 71 | 72 | networks: 73 | caddy: 74 | external: true 75 | 76 | volumes: 77 | caddy_data: {} 78 | ``` 79 | ```shell 80 | $ docker-compose up -d 81 | ``` 82 | 83 | `whoami/docker-compose.yml` 84 | ```yml 85 | version: '3.7' 86 | services: 87 | whoami: 88 | image: traefik/whoami 89 | networks: 90 | - caddy 91 | labels: 92 | caddy: whoami.example.com 93 | caddy.reverse_proxy: "{{upstreams 80}}" 94 | 95 | networks: 96 | caddy: 97 | external: true 98 | ``` 99 | ```shell 100 | $ docker-compose up -d 101 | ``` 102 | 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. 103 | 104 | ## Labels to Caddyfile conversion 105 | Please first read the [Caddyfile Concepts](https://caddyserver.com/docs/caddyfile/concepts) documentation to understand the structure of a Caddyfile. 106 | 107 | Any label prefixed with `caddy` will be converted into a Caddyfile config, following these rules: 108 | 109 | ### Tokens and arguments 110 | 111 | Keys are the directive name, and values are whitespace separated arguments: 112 | ``` 113 | caddy.directive: arg1 arg2 114 | ↓ 115 | { 116 | directive arg1 arg2 117 | } 118 | ``` 119 | 120 | If you need whitespace or line-breaks inside one of the arguments, use double-quotes or backticks around it: 121 | ``` 122 | caddy.respond: / "Hello World" 200 123 | ↓ 124 | { 125 | respond / "Hello World" 200 126 | } 127 | ``` 128 | ``` 129 | caddy.respond: / `Hello\nWorld` 200 130 | ↓ 131 | { 132 | respond / `Hello 133 | World` 200 134 | } 135 | ``` 136 | ``` 137 | caddy.respond: | 138 | / `Hello 139 | World` 200 140 | ↓ 141 | { 142 | respond / `Hello 143 | World` 200 144 | } 145 | ``` 146 | 147 | Dots represent nesting, and grouping is done automatically: 148 | ``` 149 | caddy.directive: argA 150 | caddy.directive.subdirA: valueA 151 | caddy.directive.subdirB: valueB1 valueB2 152 | ↓ 153 | { 154 | directive argA { 155 | subdirA valueA 156 | subdirB valueB1 valueB2 157 | } 158 | } 159 | ``` 160 | 161 | Arguments for the parent directive are optional (e.g. no arguments to `directive`, setting subdirective `subdirA` directly): 162 | ``` 163 | caddy.directive.subdirA: valueA 164 | ↓ 165 | { 166 | directive { 167 | subdirA valueA 168 | } 169 | } 170 | ``` 171 | 172 | Labels with empty values generate a directive without any arguments: 173 | ``` 174 | caddy.directive: 175 | ↓ 176 | { 177 | directive 178 | } 179 | ``` 180 | 181 | ### Ordering and isolation 182 | 183 | 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). 184 | 185 | [Directives](https://caddyserver.com/docs/caddyfile/directives) from labels are ordered alphabetically by default: 186 | ``` 187 | caddy.bbb: value 188 | caddy.aaa: value 189 | ↓ 190 | { 191 | aaa value 192 | bbb value 193 | } 194 | ``` 195 | 196 | Suffix _<number> isolates directives that otherwise would be grouped: 197 | ``` 198 | caddy.route_0.a: value 199 | caddy.route_1.b: value 200 | ↓ 201 | { 202 | route { 203 | a value 204 | } 205 | route { 206 | b value 207 | } 208 | } 209 | ``` 210 | 211 | 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: 212 | ``` 213 | caddy.1_bbb: value 214 | caddy.2_aaa: value 215 | caddy.3_aaa: value 216 | ↓ 217 | { 218 | bbb value 219 | aaa value 220 | aaa value 221 | } 222 | ``` 223 | 224 | ### Sites, snippets and global options 225 | 226 | A label `caddy` creates a [site block](https://caddyserver.com/docs/caddyfile/concepts): 227 | ``` 228 | caddy: example.com 229 | caddy.respond: "Hello World" 200 230 | ↓ 231 | example.com { 232 | respond "Hello World" 200 233 | } 234 | ``` 235 | 236 | Or a [snippet](https://caddyserver.com/docs/caddyfile/concepts#snippets): 237 | ``` 238 | caddy: (encode) 239 | caddy.encode: zstd gzip 240 | ↓ 241 | (encode) { 242 | encode zstd gzip 243 | } 244 | ``` 245 | 246 | It's also possible to isolate Caddy configurations using suffix _<number>: 247 | ``` 248 | caddy_0: (snippet) 249 | caddy_0.tls: internal 250 | caddy_1: site-a.com 251 | caddy_1.import: snippet 252 | caddy_2: site-b.com 253 | caddy_2.import: snippet 254 | ↓ 255 | (snippet) { 256 | tls internal 257 | } 258 | site_a { 259 | import snippet 260 | } 261 | site_b { 262 | import snippet 263 | } 264 | ``` 265 | 266 | [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) 267 | ``` 268 | caddy.email: you@example.com 269 | ↓ 270 | { 271 | email you@example.com 272 | } 273 | ``` 274 | 275 | [Named matchers](https://caddyserver.com/docs/caddyfile/matchers#named-matchers) can be created using `@` inside labels: 276 | ``` 277 | caddy: localhost 278 | caddy.@match.path: /sourcepath /sourcepath/* 279 | caddy.reverse_proxy: @match localhost:6001 280 | ↓ 281 | localhost { 282 | @match { 283 | path /sourcepath /sourcepath/* 284 | } 285 | reverse_proxy @match localhost:6001 286 | } 287 | ``` 288 | 289 | ### Go templates 290 | 291 | [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. 292 | 293 | While you can access a service name like this: 294 | ``` 295 | caddy.respond: /info "{{.Spec.Name}}" 296 | ↓ 297 | respond /info "myservice" 298 | ``` 299 | 300 | The equivalent to access a container name would be: 301 | ``` 302 | caddy.respond: /info "{{index .Names 0}}" 303 | ↓ 304 | respond /info "mycontainer" 305 | ``` 306 | 307 | 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. 308 | ``` 309 | caddy.directive: {{""}} 310 | ↓ 311 | directive 312 | ``` 313 | 314 | ## Template functions 315 | 316 | The following functions are available for use inside templates: 317 | 318 | ### upstreams 319 | 320 | Returns all addresses for the current Docker resource separated by whitespace. 321 | 322 | 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**. 323 | 324 | For containers, that would be the container IPs. 325 | 326 | Only containers/services that are connected to Caddy ingress networks are used. 327 | 328 | :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. 329 | 330 | Usage: `upstreams [http|https] [port]` 331 | 332 | Examples: 333 | ``` 334 | caddy.reverse_proxy: {{upstreams}} 335 | ↓ 336 | reverse_proxy 192.168.0.1 192.168.0.2 337 | ``` 338 | ``` 339 | caddy.reverse_proxy: {{upstreams https}} 340 | ↓ 341 | reverse_proxy https://192.168.0.1 https://192.168.0.2 342 | ``` 343 | ``` 344 | caddy.reverse_proxy: {{upstreams 8080}} 345 | ↓ 346 | reverse_proxy 192.168.0.1:8080 192.168.0.2:8080 347 | ``` 348 | ``` 349 | caddy.reverse_proxy: {{upstreams http 8080}} 350 | ↓ 351 | reverse_proxy http://192.168.0.1:8080 http://192.168.0.2:8080 352 | ``` 353 | 354 | :warning: Be carefull with quotes around upstreams. Quotes should only be added when using yaml. 355 | ``` 356 | caddy.reverse_proxy: "{{upstreams}}" 357 | ↓ 358 | reverse_proxy "192.168.0.1 192.168.0.2" 359 | ``` 360 | 361 | ## Examples 362 | Proxying all requests to a domain to the container 363 | ```yml 364 | caddy: example.com 365 | caddy.reverse_proxy: {{upstreams}} 366 | ``` 367 | 368 | Proxying all requests to a domain to a subpath in the container 369 | ```yml 370 | caddy: example.com 371 | caddy.rewrite: * /target{path} 372 | caddy.reverse_proxy: {{upstreams}} 373 | ``` 374 | 375 | Proxying requests matching a path 376 | ```yml 377 | caddy: example.com 378 | caddy.handle: /source/* 379 | caddy.handle.0_reverse_proxy: {{upstreams}} 380 | ``` 381 | 382 | Proxying requests matching a path, while stripping that path prefix 383 | ```yml 384 | caddy: example.com 385 | caddy.handle_path: /source/* 386 | caddy.handle_path.0_reverse_proxy: {{upstreams}} 387 | ``` 388 | 389 | Proxying requests matching a path, rewriting to different path prefix 390 | ```yml 391 | caddy: example.com 392 | caddy.handle_path: /source/* 393 | caddy.handle_path.0_rewrite: * /target{uri} 394 | caddy.handle_path.1_reverse_proxy: {{upstreams}} 395 | ``` 396 | 397 | Proxying all websocket requests, and all requests to `/api*`, to the container 398 | ```yml 399 | caddy: example.com 400 | caddy.@ws.0_header: Connection *Upgrade* 401 | caddy.@ws.1_header: Upgrade websocket 402 | caddy.0_reverse_proxy: @ws {{upstreams}} 403 | caddy.1_reverse_proxy: /api* {{upstreams}} 404 | ``` 405 | 406 | Proxying multiple domains, with certificates for each 407 | ```yml 408 | caddy: example.com, example.org, www.example.com, www.example.org 409 | caddy.reverse_proxy: {{upstreams}} 410 | ``` 411 | 412 | Redirecting 413 | ```yml 414 | caddy: example.com 415 | caddy.redir_0: /favicon.ico /alternative/icon.ico 302 416 | caddy.redir_1: /photo.png /updated-photo.png 302 417 | ``` 418 | 419 | **More community-maintained examples are available in the [Wiki](https://github.com/lucaslorentz/caddy-docker-proxy/wiki).** 420 | 421 | ## Docker configs 422 | 423 | > Note: This is for Docker Swarm only. Alternatively, use `CADDY_DOCKER_CADDYFILE_PATH` or `-caddyfile-path` 424 | 425 | 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. 426 | 427 | [Here is an example](examples/standalone.yaml#L4) 428 | 429 | ## Proxying services vs containers 430 | 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. 431 | 432 | ### Services 433 | To proxy swarm services, labels should be defined at service level. In a docker-compose file, labels should be _inside_ `deploy`, like: 434 | ```yml 435 | services: 436 | foo: 437 | deploy: # <-- labels should be _inside_ `deploy` 438 | labels: 439 | caddy: service.example.com 440 | caddy.reverse_proxy: {{upstreams}} 441 | ``` 442 | 443 | Caddy will use service DNS name as target or all service tasks IPs, depending on configuration **proxy-service-tasks**. 444 | 445 | ### Containers 446 | To proxy containers, labels should be defined at container level. In a docker-compose file, labels should be _outside_ `deploy`, like: 447 | ```yml 448 | services: 449 | foo: 450 | labels: 451 | caddy: service.example.com 452 | caddy.reverse_proxy: {{upstreams}} 453 | ``` 454 | 455 | ## Execution modes 456 | 457 | Each caddy docker proxy instance can be executed in one of the following modes. 458 | 459 | ### Server 460 | 461 | 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". 462 | 463 | 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`. 464 | 465 | Server instances doesn't need access to Docker host socket and you can run it in manager or worker nodes. 466 | 467 | [Configuration example](examples/distributed.yaml#L5) 468 | 469 | ### Controller 470 | 471 | Controller monitors your Docker cluster, generates Caddy configuration, and pushes it to all servers it finds in your Docker cluster. 472 | 473 | 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`. 474 | 475 | Controller instances require access to Docker host socket. 476 | 477 | A single controller instance can configure all server instances in your cluster. 478 | 479 | **:warning: Controller mode requires server nodes to serve traffic.** 480 | 481 | [Configuration example](examples/distributed.yaml#L21) 482 | 483 | ### Standalone (default) 484 | 485 | This mode executes a controller and a server in the same instance and doesn't require additional configuration. 486 | 487 | [Configuration example](examples/standalone.yaml#L11) 488 | 489 | ## Caddy CLI 490 | 491 | This plugin extends caddy's CLI with the command `caddy docker-proxy`. 492 | 493 | Run `caddy help docker-proxy` to see all available flags. 494 | 495 | ``` 496 | Usage of docker-proxy: 497 | --caddyfile-path string 498 | Path to a base Caddyfile that will be extended with Docker sites 499 | --envfile 500 | Path to an environment file with environment variables in the KEY=VALUE format to load into the Caddy process 501 | --controller-network string 502 | Network allowed to configure Caddy server in CIDR notation. Ex: 10.200.200.0/24 503 | --ingress-networks string 504 | Comma separated name of ingress networks connecting Caddy servers to containers. 505 | When not defined, networks attached to controller container are considered ingress networks 506 | --docker-sockets 507 | Comma separated docker sockets 508 | When not defined, DOCKER_HOST (or default docker socket if DOCKER_HOST not defined) 509 | --docker-certs-path 510 | Comma separated cert path, you could use empty value when no cert path for the concern index docker socket like cert_path0,,cert_path2 511 | --docker-apis-version 512 | Comma separated apis version, you could use empty value when no api version for the concern index docker socket like cert_path0,,cert_path2 513 | --label-prefix string 514 | Prefix for Docker labels (default "caddy") 515 | --mode 516 | Which mode this instance should run: standalone | controller | server 517 | --polling-interval duration 518 | Interval Caddy should manually check Docker for a new Caddyfile (default 30s) 519 | --event-throttle-interval duration 520 | Interval to throttle caddyfile updates triggered by docker events (default 100ms) 521 | --process-caddyfile 522 | Process Caddyfile before loading it, removing invalid servers (default true) 523 | --proxy-service-tasks 524 | Proxy to service tasks instead of service load balancer (default true) 525 | --scan-stopped-containers 526 | Scan stopped containers and use their labels for Caddyfile generation (default false) 527 | ``` 528 | 529 | Those flags can also be set via environment variables: 530 | 531 | ``` 532 | CADDY_DOCKER_CADDYFILE_PATH= 533 | CADDY_DOCKER_ENVFILE= 534 | CADDY_CONTROLLER_NETWORK= 535 | CADDY_INGRESS_NETWORKS= 536 | CADDY_DOCKER_SOCKETS= 537 | CADDY_DOCKER_CERTS_PATH= 538 | CADDY_DOCKER_APIS_VERSION= 539 | CADDY_DOCKER_LABEL_PREFIX= 540 | CADDY_DOCKER_MODE= 541 | CADDY_DOCKER_POLLING_INTERVAL= 542 | CADDY_DOCKER_PROCESS_CADDYFILE= 543 | CADDY_DOCKER_PROXY_SERVICE_TASKS= 544 | CADDY_DOCKER_SCAN_STOPPED_CONTAINERS= 545 | CADDY_DOCKER_NO_SCOPE= 546 | ``` 547 | 548 | Check **examples** folder to see how to set them on a Docker Compose file. 549 | 550 | ## Docker images 551 | Docker images are available at Docker hub: 552 | https://hub.docker.com/r/lucaslorentz/caddy-docker-proxy/ 553 | 554 | ### Choosing the version numbers 555 | The safest approach is to use a full version numbers like 0.1.3. 556 | That way you lock to a specific build version that works well for you. 557 | 558 | 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. 559 | 560 | ### Chosing between default or alpine images 561 | Our default images are very small and safe because they only contain Caddy executable. 562 | But they're also quite hard to troubleshoot because they don't have shell or any other Linux utilities like curl or dig. 563 | 564 | 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. 565 | 566 | ### CI images 567 | Images with the `ci` tag suffix means they were automatically generated by automated builds. 568 | CI images reflect the current state of master branch and their stability is not guaranteed. 569 | You may use CI images if you want to help testing the latest features before they're officially released. 570 | 571 | ### ARM architecture images 572 | Currently we provide linux x86_64 images by default. 573 | 574 | You can also find images for other architectures like `arm32v6` images that can be used on Raspberry Pi. 575 | 576 | ### Windows images 577 | We recently introduced experimental windows containers images with the tag suffix `nanoserver-ltsc2022`. 578 | 579 | Be aware that this needs to be tested further. 580 | 581 | This is an example of how to mount the windows Docker pipe using CLI: 582 | ```shell 583 | $ docker run --rm -it -v //./pipe/docker_engine://./pipe/docker_engine lucaslorentz/caddy-docker-proxy:ci-nanoserver-ltsc2022 584 | ``` 585 | 586 | ### Custom images 587 | 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`. 588 | 589 | 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. 590 | 591 | ```Dockerfile 592 | ARG CADDY_VERSION=2.6.1 593 | FROM caddy:${CADDY_VERSION}-builder AS builder 594 | 595 | RUN xcaddy build \ 596 | --with github.com/lucaslorentz/caddy-docker-proxy/v2 \ 597 | --with 598 | 599 | FROM caddy:${CADDY_VERSION}-alpine 600 | 601 | COPY --from=builder /usr/bin/caddy /usr/bin/caddy 602 | 603 | CMD ["caddy", "docker-proxy"] 604 | ``` 605 | 606 | ## Connecting to Docker Host 607 | The default connection to Docker host varies per platform: 608 | * At Unix: `unix:///var/run/docker.sock` 609 | * At Windows: `npipe:////./pipe/docker_engine` 610 | 611 | You can modify Docker connection using the following environment variables: 612 | 613 | * **DOCKER_HOST**: to set the URL to the Docker server. 614 | * **DOCKER_API_VERSION**: to set the version of the API to reach, leave empty for latest. 615 | * **DOCKER_CERT_PATH**: to load the TLS certificates from. 616 | * **DOCKER_TLS_VERIFY**: to enable or disable TLS verification; off by default. 617 | 618 | ## Volumes 619 | 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. 620 | 621 | To do that, map a persistent Docker volume to `/data` folder. 622 | 623 | 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. 624 | 625 | Multiple Caddy instances automatically orchestrate certificate issuing between themselves when sharing `/data` folder. 626 | 627 | ## Trying it 628 | 629 | ### With docker-compose file 630 | 631 | Clone this repository. 632 | 633 | Deploy the compose file to swarm cluster: 634 | ``` 635 | $ docker stack deploy -c examples/standalone.yaml caddy-docker-demo 636 | ``` 637 | 638 | Wait a bit for services to startup... 639 | 640 | Now you can access each service/container using different URLs 641 | ``` 642 | $ curl -k --resolve whoami0.example.com:443:127.0.0.1 https://whoami0.example.com 643 | $ curl -k --resolve whoami1.example.com:443:127.0.0.1 https://whoami1.example.com 644 | $ curl -k --resolve whoami2.example.com:443:127.0.0.1 https://whoami2.example.com 645 | $ curl -k --resolve whoami3.example.com:443:127.0.0.1 https://whoami3.example.com 646 | $ curl -k --resolve config.example.com:443:127.0.0.1 https://config.example.com 647 | $ curl -k --resolve echo0.example.com:443:127.0.0.1 https://echo0.example.com/sourcepath/something 648 | ``` 649 | 650 | After testing, delete the demo stack: 651 | ``` 652 | $ docker stack rm caddy-docker-demo 653 | ``` 654 | 655 | ### With run commands 656 | 657 | ``` 658 | $ docker run --name caddy -d -p 443:443 -v /var/run/docker.sock:/var/run/docker.sock lucaslorentz/caddy-docker-proxy:ci-alpine 659 | 660 | $ docker run --name whoami0 -d -l caddy=whoami0.example.com -l "caddy.reverse_proxy={{upstreams 80}}" -l caddy.tls=internal traefik/whoami 661 | 662 | $ docker run --name whoami1 -d -l caddy=whoami1.example.com -l "caddy.reverse_proxy={{upstreams 80}}" -l caddy.tls=internal traefik/whoami 663 | 664 | $ curl -k --resolve whoami0.example.com:443:127.0.0.1 https://whoami0.example.com 665 | $ curl -k --resolve whoami1.example.com:443:127.0.0.1 https://whoami1.example.com 666 | 667 | $ docker rm -f caddy whoami0 whoami1 668 | ``` 669 | 670 | ## Building it 671 | 672 | You can build Caddy using [xcaddy](https://github.com/caddyserver/xcaddy) or [caddy docker builder](https://hub.docker.com/_/caddy). 673 | 674 | Use module name **github.com/lucaslorentz/caddy-docker-proxy/v2** to add this plugin to your build. 675 | --------------------------------------------------------------------------------