Sort proxy.* keys by dot depth, then name, before building the tree so broader paths apply before deeper ones. When a new value would sit on a node that is already a map, parse it as a YAML object (tabs normalized to two spaces), deep-merge, and treat an empty string as an empty object. Return clear errors when a scalar and a nested map disagree. Drop the preallocated refPrefixes table in favor of refPrefix(n). Add internal tests for parseLabelObject, mergeLabelMaps, key order, and flatten; extend export tests for mixed OIDC-style labels and conflicts. * refactor(docker): extract label parse and flatten helpers Refactor ParseLabels by moving proxy label application into applyLabel, descendLabelMap, and setLabelValue so traversal and leaf merge share one path without labelLoop continues. Add splitAliasLabel for ExpandWildcard so proxy.* prefix handling stays in one place and uses CutPrefix/Cut consistently. Deduplicate flattenMap and flattenMapAny value handling with flattenValue plus joinLabelKey and stringifyLabelKey for flattened key construction. * refactor(docker): structured errors for label type clashes Replace ad hoc fmt.Errorf messages in descendLabelMap, setLabelValue, and mergeLabelMaps with UnexpectedTypeError so wording is consistent and mapping vs scalar conflicts stay explicit. Hoist requireMap in label tests to a shared helper. Normalize tabs to two spaces in expandYamlWildcard so wildcard YAML matches the indentation used in the object-merge path. * refactor(docker): optional UnexpectedTypeError message for merge conflicts Extend UnexpectedTypeError with an optional Message field; when set, Error() returns it instead of the default expect-versus-actual formatting. mergeLabelMaps sets that message when a mapping would merge into an existing scalar, so the error states the situation instead of only "expect scalar". Update TestMergeLabelMaps to assert the new wording.
internal/docker
Docker container discovery, connection management, and label-based route configuration.
Overview
The docker package implements Docker container integration, providing shared client connections, container parsing from Docker API responses, label processing for route configuration, and container filtering capabilities.
Primary consumers
internal/route/provider- Creates Docker-based route providersinternal/idlewatcher- Container idle detection- Operators - Configure routes via Docker labels
Non-goals
- Docker image building or management
- Container lifecycle operations (start/stop)
- Volume management
- Docker Swarm orchestration
Stability
Stable internal package. Public API consists of client management and container parsing functions.
Public API
Exported types
type SharedClient struct {
*client.Client
cfg types.DockerProviderConfig
refCount atomic.Int32
closedOn atomic.Int64
key string
addr string
dial func(ctx context.Context) (net.Conn, error)
unique bool
}
type Container struct {
DockerCfg types.DockerProviderConfig
Image Image
ContainerName string
ContainerID string
Labels map[string]string
ActualLabels map[string]string
Mounts []Mount
Network string
PublicPortMapping map[int]PortSummary
PrivatePortMapping map[int]PortSummary
Aliases []string
IsExcluded bool
IsExplicit bool
IsHostNetworkMode bool
Running bool
State string
PublicHostname string
PrivateHostname string
Agent *agentpool.Agent
IdlewatcherConfig *IdlewatcherConfig
}
Exported functions
func NewClient(cfg types.DockerProviderConfig, unique ...bool) (*SharedClient, error)
Creates or returns a Docker client. Reuses existing clients for the same URL. Thread-safe.
func Clients() map[string]*SharedClient
Returns all currently connected clients. Callers must close returned clients.
func FromDocker(c *container.Summary, dockerCfg types.DockerProviderConfig) *types.Container
Converts Docker API container summary to internal container type. Parses labels for route configuration.
func UpdatePorts(ctx context.Context, c *Container) error
Refreshes port mappings from container inspect.
func DockerComposeProject(c *Container) string
Returns the Docker Compose project name.
func DockerComposeService(c *Container) string
Returns the Docker Compose service name.
func Dependencies(c *Container) []string
Returns container dependencies from labels.
func IsBlacklisted(c *Container) bool
Checks if container should be excluded from routing.
Architecture
Core components
graph TD
A[Docker API] --> B[SharedClient Pool]
B --> C{Client Request}
C -->|New Client| D[Create Connection]
C -->|Existing| E[Increment RefCount]
F[Container List] --> G[FromDocker Parser]
G --> H[Container Struct]
H --> I[Route Builder]
J[Container Labels] --> K[Label Parser]
K --> L[Route Config]
subgraph Client Pool
B --> M[clientMap]
N[Cleaner Goroutine]
end
Client lifecycle
stateDiagram-v2
[*] --> New: NewClient() called
New --> Shared: Refcount = 1, stored in pool
Shared --> Shared: Same URL, increment refcount
Shared --> Idle: Close() called, refcount = 0
Idle --> Closed: 10s timeout elapsed
Idle --> Shared: NewClient() for same URL
Closed --> [*]: Client closed
Unique --> [*]: Close() immediately
Container parsing flow
sequenceDiagram
participant Provider
participant SharedClient
participant DockerAPI
participant ContainerParser
participant RouteBuilder
Provider->>SharedClient: NewClient(cfg)
SharedClient->>SharedClient: Check Pool
alt Existing Client
SharedClient->>SharedClient: Increment RefCount
else New Client
SharedClient->>DockerAPI: Connect
DockerAPI-->>SharedClient: Client
end
Provider->>SharedClient: ListContainers()
SharedClient->>DockerAPI: GET /containers/json
DockerAPI-->>SharedClient: Container List
SharedClient-->>Provider: Container List
loop For Each Container
Provider->>ContainerParser: FromDocker()
ContainerParser->>ContainerParser: Parse Labels
ContainerParser->>ContainerParser: Resolve Hostnames
ContainerParser-->>Provider: *Container
end
Provider->>RouteBuilder: Create Routes
RouteBuilder-->>Provider: Routes
Client pool management
The docker package maintains a pool of shared clients:
var (
clientMap = make(map[string]*SharedClient, 10)
clientMapMu sync.RWMutex
)
func initClientCleaner() {
cleaner := task.RootTask("docker_clients_cleaner", true)
go func() {
ticker := time.NewTicker(cleanInterval)
for {
select {
case <-ticker.C:
closeTimedOutClients()
case <-cleaner.Context().Done():
// Cleanup all clients
}
}
}()
}
Configuration Surface
Docker provider configuration
providers:
docker:
local: ${DOCKER_HOST}
remote1:
scheme: tcp
host: docker1.local
port: 2375
remote2:
scheme: tls
host: docker2.local
port: 2375
tls:
ca_file: /path/to/ca.pem
cert_file: /path/to/cert.pem
key_file: /path/to/key.pem
Route configuration labels
Route labels use the format proxy.<alias>.<field> where <alias> is the route alias (or * for wildcard). The base labels apply to all routes.
| Label | Description | Example |
|---|---|---|
proxy.aliases |
Route aliases (comma-separated) | proxy.aliases: www,app |
proxy.exclude |
Exclude from routing | proxy.exclude: true |
proxy.network |
Docker network | proxy.network: frontend |
proxy.<alias>.host |
Override hostname | proxy.app.host: 192.168.1.100 |
proxy.<alias>.port |
Target port | proxy.app.port: 8080 |
proxy.<alias>.scheme |
HTTP scheme | proxy.app.scheme: https |
proxy.<alias>.* |
Any route-specific setting | proxy.app.no_tls_verify: true |
Wildcard alias
Use proxy.*.<field> to apply settings to all routes:
labels:
proxy.aliases: app1,app2
proxy.*.scheme: https
proxy.app1.port: 3000 # overrides wildcard
Idle watcher labels
| Label | Description | Example |
|---|---|---|
proxy.idle_timeout |
Idle timeout duration | proxy.idle_timeout: 30m |
proxy.wake_timeout |
Max time to wait for wake | proxy.wake_timeout: 10s |
proxy.stop_method |
Stop method (pause, stop, kill) | proxy.stop_method: stop |
proxy.stop_signal |
Signal to send (e.g., SIGTERM) | proxy.stop_signal: SIGTERM |
proxy.stop_timeout |
Stop timeout in seconds | proxy.stop_timeout: 30 |
proxy.depends_on |
Container dependencies | proxy.depends_on: database |
proxy.start_endpoint |
Optional path restriction | proxy.start_endpoint: /api/ready |
proxy.no_loading_page |
Skip loading page | proxy.no_loading_page: true |
Docker Compose labels
Those are created by Docker Compose.
| Label | Description |
|---|---|
com.docker.compose.project |
Compose project name |
com.docker.compose.service |
Service name |
com.docker.compose.depends_on |
Dependencies |
Dependency and Integration Map
Internal dependencies
internal/agentpool- Agent-based Docker host connectionsinternal/maxmind- Container geolocationinternal/types- Container and provider typesinternal/task/task.go- Lifetime management
External dependencies
github.com/docker/cli/cli/connhelper- Connection helpersgithub.com/moby/moby/client- Docker API clientgithub.com/docker/go-connections/nat- Port parsing
Integration points
// Route provider uses docker for container discovery
client, err := docker.NewClient(cfg)
containers, err := client.ContainerList(ctx, container.ListOptions{})
for _, c := range containers {
container := docker.FromDocker(c, cfg)
// Create routes from container
}
Observability
Logs
- Client initialization and cleanup
- Connection errors
- Container parsing errors
Metrics
No metrics are currently exposed.
Security Considerations
- Docker socket access requires proper permissions
- TLS certificates for remote connections
- Agent-based connections are authenticated via TLS
- Database containers are automatically blacklisted
Blacklist detection
Containers are automatically blacklisted if they:
- Mount database directories:
/var/lib/postgresql/data/var/lib/mysql/var/lib/mongodb/var/lib/mariadb/var/lib/memcached/var/lib/rabbitmq
- Expose database ports:
- 5432 (PostgreSQL)
- 3306 (MySQL/MariaDB)
- 6379 (Redis)
- 11211 (Memcached)
- 27017 (MongoDB)
Failure Modes and Recovery
| Failure | Behavior | Recovery |
|---|---|---|
| Docker socket inaccessible | NewClient returns error | Fix socket permissions |
| Remote connection failed | NewClient returns error | Check network/tls config |
| Container inspect failed | UpdatePorts returns error | Container may be stopped |
| Invalid labels | Container created with error | Fix label syntax |
| Agent not found | Panic during client creation | Add agent to pool |
Performance Characteristics
- Client pooling reduces connection overhead
- Reference counting prevents premature cleanup
- Background cleaner removes idle clients after 10s
- O(n) container parsing where n is container count
Usage Examples
Creating a Docker client
dockerCfg := types.DockerProviderConfig{
URL: "unix:///var/run/docker.sock",
}
client, err := docker.NewClient(dockerCfg)
if err != nil {
log.Fatal(err)
}
defer client.Close()
Using unique client
// Create a unique client that won't be shared
client, err := docker.NewClient(cfg, true)
if err != nil {
log.Fatal(err)
}
// Remember to close when done
client.Close()
Getting all clients
clients := docker.Clients()
for host, client := range clients {
log.Printf("Connected to: %s", host)
}
// Use clients...
// Close all clients when done
for _, client := range clients {
client.Close()
}
Parsing containers
containers, err := dockerClient.ContainerList(ctx, container.ListOptions{})
for _, c := range containers {
container := docker.FromDocker(c, dockerCfg)
if container.Errors != nil {
log.Printf("Container %s has errors: %v", container.ContainerName, container.Errors)
continue
}
log.Printf("Container: %s, Aliases: %v", container.ContainerName, container.Aliases)
}
Checking if container is blacklisted
container := docker.FromDocker(c, dockerCfg)
if docker.IsBlacklisted(container) {
log.Printf("Container %s is blacklisted, skipping", container.ContainerName)
continue
}