Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,18 @@ A single controller instance can configure all server instances in your cluster.

[Configuration example](examples/distributed.yaml#L21)

### Swarm

Controller-only mode that renders a full Caddyfile and rolls it out to an existing Swarm service as an immutable Swarm **config**.

This mode doesn't use (or expose) Caddy's Admin API. Instead, it updates the target service to mount the generated Caddyfile at `/etc/caddy/Caddyfile` (or `--swarm-caddyfile-target`). This triggers a Swarm service update (tasks get restarted according to the service's update/rollback settings).

Each configuration change creates a new Swarm config object named `<prefix>-<sha256>` (see `--swarm-config-prefix`). Old configs are not garbage-collected automatically.

This mode requires access to a Swarm **manager** Docker API.

[Configuration example](examples/swarm.yaml#L1)

### Standalone (default)

This mode executes a controller and a server in the same instance and doesn't require additional configuration.
Expand All @@ -496,7 +508,7 @@ Run `caddy help docker-proxy` to see the raw flag output.

| CLI flag | Env var | Description |
|---|---|---|
| `--mode` | `CADDY_DOCKER_MODE` | Which mode to run: `standalone` \| `controller` \| `server`.<br>**Default:** `standalone` |
| `--mode` | `CADDY_DOCKER_MODE` | Which mode to run: `standalone` \| `controller` \| `server` \| `swarm`.<br>**Default:** `standalone` |
| `--docker-sockets` | `CADDY_DOCKER_SOCKETS` | Comma-separated Docker sockets.<br>**Default:** `DOCKER_HOST` or the default socket |
| `--docker-certs-path` | `CADDY_DOCKER_CERTS_PATH` | Comma-separated cert paths (one per socket; leave entry empty for sockets without certs) |
| `--docker-apis-version` | `CADDY_DOCKER_APIS_VERSION` | Comma-separated API versions (one per socket) |
Expand All @@ -510,6 +522,10 @@ Run `caddy help docker-proxy` to see the raw flag output.
| `--scan-stopped-containers` | `CADDY_DOCKER_SCAN_STOPPED_CONTAINERS` | Scan stopped containers and use their labels.<br>**Default:** `false` |
| `--polling-interval` | `CADDY_DOCKER_POLLING_INTERVAL` | Interval to manually check Docker for a new Caddyfile.<br>**Default:** `30s` |
| `--event-throttle-interval` | `CADDY_DOCKER_EVENT_THROTTLE_INTERVAL` | Interval to throttle Caddyfile updates triggered by Docker events.<br>**Default:** `100ms` |
| `--swarm-service` | `CADDY_DOCKER_SWARM_SERVICE` | Existing Swarm service name/ID to update when running in `swarm` mode |
| `--swarm-caddyfile-target` | `CADDY_DOCKER_SWARM_CADDYFILE_TARGET` | Target path inside the Swarm service task to mount the generated Caddyfile.<br>**Default:** `/etc/caddy/Caddyfile` |
| `--swarm-config-prefix` | `CADDY_DOCKER_SWARM_CONFIG_PREFIX` | Prefix for generated Swarm config objects.<br>**Default:** `caddyfile` |
| `--swarm-config-hash-len` | `CADDY_DOCKER_SWARM_CONFIG_HASH_LEN` | Length of sha256 hex used in generated Swarm config names.<br>**Default:** `32` |
| _(env only)_ | `CADDY_ADMIN` | Override Caddy's admin listen address |
| _(env only)_ | `CADDY_DOCKER_NO_SCOPE` | Disable Docker event scope filter (useful for Podman).<br>**Default:** `false` |

Expand Down
56 changes: 55 additions & 1 deletion cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package caddydockerproxy

import (
"flag"
"fmt"
"net"
"os"
"regexp"
"strconv"
"strings"
"time"

Expand All @@ -28,7 +30,7 @@ func init() {
fs := flag.NewFlagSet("docker-proxy", flag.ExitOnError)

fs.String("mode", "standalone",
"Which mode this instance should run: standalone | controller | server")
"Which mode this instance should run: standalone | controller | server | swarm")

fs.String("docker-sockets", "",
"Docker sockets comma separate")
Expand Down Expand Up @@ -70,6 +72,18 @@ func init() {
fs.Duration("event-throttle-interval", 100*time.Millisecond,
"Interval to throttle caddyfile updates triggered by docker events")

fs.String("swarm-service", "",
"Existing Swarm service name/ID to update (swarm mode only)")

fs.String("swarm-caddyfile-target", "/etc/caddy/Caddyfile",
"Target path inside the Swarm service task to mount the generated Caddyfile (swarm mode only)")

fs.String("swarm-config-prefix", "caddyfile",
"Prefix for generated Swarm config objects (swarm mode only)")

fs.Int("swarm-config-hash-len", 32,
"Length of sha256 hex used in generated Swarm config name (swarm mode only)")

return fs
}(),
})
Expand All @@ -81,6 +95,12 @@ func cmdFunc(flags caddycmd.Flags) (int, error) {
options := createOptions(flags)
log := logger()

if options.SwarmMode {
if strings.TrimSpace(options.SwarmService) == "" {
return 1, fmt.Errorf("swarm mode requires --swarm-service (or CADDY_DOCKER_SWARM_SERVICE)")
}
}

if options.Mode&config.Server == config.Server {
log.Info("Running caddy proxy server")

Expand Down Expand Up @@ -172,6 +192,10 @@ func createOptions(flags caddycmd.Flags) *config.Options {
dockerCertsPathFlag := flags.String("docker-certs-path")
dockerAPIsVersionFlag := flags.String("docker-apis-version")
ingressNetworksFlag := flags.String("ingress-networks")
swarmServiceFlag := flags.String("swarm-service")
swarmCaddyfileTargetFlag := flags.String("swarm-caddyfile-target")
swarmConfigPrefixFlag := flags.String("swarm-config-prefix")
swarmConfigHashLenFlag := flags.Int("swarm-config-hash-len")

options := &config.Options{}

Expand All @@ -186,6 +210,9 @@ func createOptions(flags caddycmd.Flags) *config.Options {
options.Mode = config.Controller
case "server":
options.Mode = config.Server
case "swarm":
options.Mode = config.Controller
options.SwarmMode = true
default:
options.Mode = config.Standalone
}
Expand Down Expand Up @@ -297,5 +324,32 @@ func createOptions(flags caddycmd.Flags) *config.Options {
options.EventThrottleInterval = eventThrottleIntervalFlag
}

if swarmServiceEnv := os.Getenv("CADDY_DOCKER_SWARM_SERVICE"); swarmServiceEnv != "" {
options.SwarmService = swarmServiceEnv
} else {
options.SwarmService = swarmServiceFlag
}

if swarmCaddyfileTargetEnv := os.Getenv("CADDY_DOCKER_SWARM_CADDYFILE_TARGET"); swarmCaddyfileTargetEnv != "" {
options.SwarmCaddyfileTarget = swarmCaddyfileTargetEnv
} else {
options.SwarmCaddyfileTarget = swarmCaddyfileTargetFlag
}

if swarmConfigPrefixEnv := os.Getenv("CADDY_DOCKER_SWARM_CONFIG_PREFIX"); swarmConfigPrefixEnv != "" {
options.SwarmConfigPrefix = swarmConfigPrefixEnv
} else {
options.SwarmConfigPrefix = swarmConfigPrefixFlag
}

options.SwarmConfigHashLen = swarmConfigHashLenFlag
if swarmConfigHashLenEnv := os.Getenv("CADDY_DOCKER_SWARM_CONFIG_HASH_LEN"); swarmConfigHashLenEnv != "" {
if v, err := strconv.Atoi(swarmConfigHashLenEnv); err != nil {
log.Error("Failed to parse CADDY_DOCKER_SWARM_CONFIG_HASH_LEN", zap.String("CADDY_DOCKER_SWARM_CONFIG_HASH_LEN", swarmConfigHashLenEnv), zap.Error(err))
} else {
options.SwarmConfigHashLen = v
}
}

return options
}
9 changes: 9 additions & 0 deletions config/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ type Options struct {
Secret string
ControllerNetwork *net.IPNet
IngressNetworks []string

// SwarmMode enables atomic Caddyfile distribution via Swarm configs.
// When enabled, this instance updates an existing Swarm service by mounting
// a newly created, content-addressed Swarm config at SwarmCaddyfileTarget.
SwarmMode bool
SwarmService string
SwarmCaddyfileTarget string
SwarmConfigPrefix string
SwarmConfigHashLen int
}

// Mode represents how this instance should run
Expand Down
15 changes: 15 additions & 0 deletions docker/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ import (
type Client interface {
ContainerList(ctx context.Context, options container.ListOptions) ([]types.Container, error)
ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error)
ServiceInspectWithRaw(ctx context.Context, serviceID string, opts swarm.ServiceInspectOptions) (swarm.Service, []byte, error)
ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options swarm.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error)
TaskList(ctx context.Context, options types.TaskListOptions) ([]swarm.Task, error)
Info(ctx context.Context) (system.Info, error)
ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error)
NetworkInspect(ctx context.Context, networkID string, options network.InspectOptions) (network.Inspect, error)
NetworkList(ctx context.Context, options network.ListOptions) ([]network.Summary, error)
ConfigList(ctx context.Context, options types.ConfigListOptions) ([]swarm.Config, error)
ConfigInspectWithRaw(ctx context.Context, id string) (swarm.Config, []byte, error)
ConfigCreate(ctx context.Context, config swarm.ConfigSpec) (swarm.ConfigCreateResponse, error)
Events(ctx context.Context, options events.ListOptions) (<-chan events.Message, <-chan error)
}

Expand All @@ -45,6 +48,14 @@ func (wrapper *clientWrapper) ServiceList(ctx context.Context, options types.Ser
return wrapper.client.ServiceList(ctx, options)
}

func (wrapper *clientWrapper) ServiceInspectWithRaw(ctx context.Context, serviceID string, opts swarm.ServiceInspectOptions) (swarm.Service, []byte, error) {
return wrapper.client.ServiceInspectWithRaw(ctx, serviceID, opts)
}

func (wrapper *clientWrapper) ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options swarm.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) {
return wrapper.client.ServiceUpdate(ctx, serviceID, version, service, options)
}

func (wrapper *clientWrapper) TaskList(ctx context.Context, options types.TaskListOptions) ([]swarm.Task, error) {
return wrapper.client.TaskList(ctx, options)
}
Expand Down Expand Up @@ -73,6 +84,10 @@ func (wrapper *clientWrapper) ConfigInspectWithRaw(ctx context.Context, id strin
return wrapper.client.ConfigInspectWithRaw(ctx, id)
}

func (wrapper *clientWrapper) ConfigCreate(ctx context.Context, config swarm.ConfigSpec) (swarm.ConfigCreateResponse, error) {
return wrapper.client.ConfigCreate(ctx, config)
}

func (wrapper *clientWrapper) Events(ctx context.Context, options events.ListOptions) (<-chan events.Message, <-chan error) {
return wrapper.client.Events(ctx, options)
}
40 changes: 40 additions & 0 deletions docker/client_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package docker

import (
"context"
"fmt"

"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
Expand Down Expand Up @@ -35,6 +36,28 @@ func (mock *ClientMock) ServiceList(ctx context.Context, options types.ServiceLi
return mock.ServicesData, nil
}

// ServiceInspectWithRaw returns information about a specific service
func (mock *ClientMock) ServiceInspectWithRaw(ctx context.Context, serviceID string, opts swarm.ServiceInspectOptions) (swarm.Service, []byte, error) {
for _, service := range mock.ServicesData {
if service.ID == serviceID || service.Spec.Name == serviceID {
return service, nil, nil
}
}
return swarm.Service{}, nil, fmt.Errorf("service not found: %s", serviceID)
}

// ServiceUpdate updates a specific service
func (mock *ClientMock) ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options swarm.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) {
for i := range mock.ServicesData {
if mock.ServicesData[i].ID == serviceID || mock.ServicesData[i].Spec.Name == serviceID {
mock.ServicesData[i].Spec = service
mock.ServicesData[i].Meta.Version.Index++
return swarm.ServiceUpdateResponse{}, nil
}
}
return swarm.ServiceUpdateResponse{}, fmt.Errorf("service not found: %s", serviceID)
}

// TaskList list all tasks
func (mock *ClientMock) TaskList(ctx context.Context, options types.TaskListOptions) ([]swarm.Task, error) {
matchingTasks := []swarm.Task{}
Expand Down Expand Up @@ -85,6 +108,23 @@ func (mock *ClientMock) ConfigInspectWithRaw(ctx context.Context, id string) (sw
return swarm.Config{}, nil, nil
}

// ConfigCreate creates a new swarm config
func (mock *ClientMock) ConfigCreate(ctx context.Context, config swarm.ConfigSpec) (swarm.ConfigCreateResponse, error) {
id := fmt.Sprintf("config-%d", len(mock.ConfigsData)+1)
mock.ConfigsData = append(mock.ConfigsData, swarm.Config{
ID: id,
Spec: swarm.ConfigSpec{
Annotations: swarm.Annotations{
Name: config.Name,
Labels: config.Labels,
},
Data: config.Data,
Templating: config.Templating,
},
})
return swarm.ConfigCreateResponse{ID: id}, nil
}

// Events listen for events in docker
func (mock *ClientMock) Events(ctx context.Context, options events.ListOptions) (<-chan events.Message, <-chan error) {
return mock.EventsChannel, mock.ErrorsChannel
Expand Down
65 changes: 65 additions & 0 deletions examples/swarm.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
version: '3.7'

configs:
caddy_bootstrap:
file: ./Caddyfile

services:
# Caddy runs on workers and does NOT need the Docker socket.
caddy:
# Vanilla Caddy image; no docker-proxy plugin required on workers.
image: caddy:2-alpine
ports:
- 80:80
- 443:443
configs:
- source: caddy_bootstrap
target: /etc/caddy/Caddyfile
mode: 0444
volumes:
# Persist certificates and other Caddy state
- caddy_data:/data
deploy:
mode: global
placement:
constraints:
- node.role == worker
update_config:
parallelism: 1
delay: 5s
monitor: 10s
failure_action: rollback
order: stop-first
rollback_config:
parallelism: 1
delay: 5s
monitor: 10s
order: stop-first

# Controller runs on a manager, generates a full Caddyfile from labels,
# and rolls it out by updating the existing Swarm service above.
controller:
image: lucaslorentz/caddy-docker-proxy:ci-alpine
environment:
- CADDY_DOCKER_MODE=swarm
# NOTE: Swarm service names are <stack>_<service>.
# If you deploy this stack as `docker stack deploy -c examples/swarm.yaml caddy`,
# then the service name will be `caddy_caddy`.
- CADDY_DOCKER_SWARM_SERVICE=caddy_caddy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
deploy:
placement:
constraints:
- node.role == manager

whoami:
image: traefik/whoami
deploy:
labels:
caddy: whoami.example.com
caddy.reverse_proxy: "{{upstreams 80}}"
caddy.tls: internal

volumes:
caddy_data: {}
Loading
Loading