Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,36 @@ env:
BASE_IMAGE_NAME: ${{ github.repository }}

jobs:
go-checks:
if: github.ref != 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Check formatting
run: |
unformatted=$(gofmt -l ./cmd ./pkg)
if [ -n "$unformatted" ]; then
echo "The following files are not gofmt-formatted:"
echo "$unformatted"
exit 1
fi

- name: Vet
run: go vet ./...

- name: Run unit tests
run: go test -race ./...

test:
if: github.ref != 'refs/heads/main'
runs-on: ubuntu-latest
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added

- Unit tests for the pure parsing/config helpers in `dinghy-layer`, `dns-server`, `config`, and `utils` ([#101](https://github.com/sparkfabrik/http-proxy/issues/101))
- CI `go-checks` job running `gofmt`, `go vet`, and `go test -race` on every non-`main` branch ([#101](https://github.com/sparkfabrik/http-proxy/issues/101))
- Expose DNS server TCP port 19322 alongside UDP port for Lima virtualization compatibility ([#56](https://github.com/sparkfabrik/http-proxy/issues/56))
- Add `upgrade` command to pull latest Docker images and recreate only changed containers, preserving volumes (grafana/prometheus data) ([#96](https://github.com/sparkfabrik/http-proxy/pull/96))
- Add `self-update` command to update the script and compose files from the git repository, with guards against non-git installs and dirty working trees ([#96](https://github.com/sparkfabrik/http-proxy/pull/96))

### Fixed

- Make backend IP and port selection deterministic for `VIRTUAL_HOST` containers attached to multiple networks or exposing multiple ports; previously Go map iteration could route to a different network IP or port across restarts ([#101](https://github.com/sparkfabrik/http-proxy/issues/101))
- Lower generated DNS A-record TTL from 3600s to 60s so a changed `HTTP_PROXY_DNS_TARGET_IP` propagates quickly instead of being cached by the OS stub resolver ([#101](https://github.com/sparkfabrik/http-proxy/issues/101))
- Guard against a nil-pointer panic in `join-networks` when a container reports no network settings ([#101](https://github.com/sparkfabrik/http-proxy/issues/101))
- Make signal-driven shutdown deterministic in the event-driven services by giving a single owner control of signal handling, and abort the event-stream reconnect backoff promptly on shutdown ([#101](https://github.com/sparkfabrik/http-proxy/issues/101))
- Reconnect to the Docker event stream when the daemon closes it (for example on daemon restart) instead of busy-looping on the closed channel ([#101](https://github.com/sparkfabrik/http-proxy/issues/101))
- Reject a non-IPv4 `HTTP_PROXY_DNS_TARGET_IP` at startup; the DNS server answers only A records, so an IPv6 target would otherwise be silently truncated ([#101](https://github.com/sparkfabrik/http-proxy/issues/101))
- Fixed restart command to automatically start containers when not running instead of failing ([#40](https://github.com/sparkfabrik/http-proxy/issues/40))
- The `spark-http-proxy restart` command now intelligently detects container state
- When containers are not running: automatically starts them using existing recreate logic
Expand All @@ -23,4 +33,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Apply `disable-hsts` middleware at the HTTPS entrypoint level to ensure ALL HTTPS traffic (both dinghy-layer and native Traefik routes) benefits from this development-friendly configuration

### Added

- CHANGELOG.md file to track project changes
96 changes: 62 additions & 34 deletions cmd/dinghy-layer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"

Expand Down Expand Up @@ -82,17 +83,6 @@ func (cl *CompatibilityLayer) SetDependencies(dockerClient *client.Client, logge
cl.logger = logger
}

// TraefikLabels represents the Traefik labels that would be applied to containers
// for manual configuration. This struct is used for reference but the actual
// implementation generates dynamic configuration files instead of container labels.
type TraefikLabels struct {
Enable string
Rule string
Port string
RouterName string
ServiceName string
}

// ContainerInfo holds essential container information extracted from Docker
// container inspection. This struct contains the minimal set of data needed
// to generate Traefik configuration from nginx-proxy environment variables.
Expand Down Expand Up @@ -207,17 +197,13 @@ func (cl *CompatibilityLayer) processContainer(ctx context.Context, containerID
return nil
}

// Skip if traefik labels are already set.
// Check for traefik labels (any label starting with "traefik.")
labels := inspect.Config.Labels
for label := range labels {
if strings.HasPrefix(label, "traefik.") {
cl.logger.Debug("Skipping container with existing Traefik label",
"container_id", utils.FormatDockerID(containerID),
"container_name", containerInfo.Name,
"label", label)
return nil
}
// Skip if traefik labels are already set; native labels take precedence and
// Traefik's Docker provider handles those containers directly.
if utils.HasTraefikLabel(inspect.Config.Labels) {
cl.logger.Debug("Skipping container with existing Traefik label",
"container_id", utils.FormatDockerID(containerID),
"container_name", containerInfo.Name)
return nil
}

cl.logger.Info("Found container with VIRTUAL_HOST",
Expand Down Expand Up @@ -311,12 +297,22 @@ func (cl *CompatibilityLayer) generateTraefikConfig(inspect types.ContainerJSON,
}

func getContainerIP(inspect types.ContainerJSON) string {
// Try to get IP from the first network
if inspect.NetworkSettings != nil && inspect.NetworkSettings.Networks != nil {
for _, network := range inspect.NetworkSettings.Networks {
if network.IPAddress != "" {
return network.IPAddress
}
if inspect.NetworkSettings == nil || inspect.NetworkSettings.Networks == nil {
return ""
}

// Sort network names so the chosen IP is deterministic across restarts and
// events. Go map iteration order is randomized, which would otherwise make
// the routed backend IP vary for containers attached to multiple networks.
names := make([]string, 0, len(inspect.NetworkSettings.Networks))
for name := range inspect.NetworkSettings.Networks {
names = append(names, name)
}
sort.Strings(names)

for _, name := range names {
if ip := inspect.NetworkSettings.Networks[name].IPAddress; ip != "" {
return ip
}
}
return ""
Expand Down Expand Up @@ -496,27 +492,59 @@ func generateServiceName(containerName string) string {
}

func getDefaultPort(inspect types.ContainerJSON) string {
// Get the first exposed port or return "80" as default
// Prefer the lowest exposed TCP port, then fall back to the lowest bound TCP
// port. Sorting makes the selection deterministic; Go map iteration order is
// randomized, which would otherwise pick a different port across restarts for
// containers that expose more than one port.
var exposed []int
if inspect.Config.ExposedPorts != nil {
for port := range inspect.Config.ExposedPorts {
if strings.HasSuffix(string(port), "/tcp") {
return strings.TrimSuffix(string(port), "/tcp")
if n := tcpPortNumber(string(port)); n > 0 {
exposed = append(exposed, n)
}
}
}
if port := lowestTCPPort(exposed); port != "" {
return port
}

// Check port bindings
var bound []int
if inspect.NetworkSettings != nil && inspect.NetworkSettings.Ports != nil {
for port := range inspect.NetworkSettings.Ports {
if strings.HasSuffix(string(port), "/tcp") {
return strings.TrimSuffix(string(port), "/tcp")
if n := tcpPortNumber(string(port)); n > 0 {
bound = append(bound, n)
}
}
}
if port := lowestTCPPort(bound); port != "" {
return port
}

return "80"
}

// lowestTCPPort returns the smallest port in the slice as a string, or "" if empty.
func lowestTCPPort(ports []int) string {
if len(ports) == 0 {
return ""
}
sort.Ints(ports)
return strconv.Itoa(ports[0])
}

// tcpPortNumber returns the numeric port for a Docker "<n>/tcp" port string,
// or 0 if the string is not a TCP port or cannot be parsed.
func tcpPortNumber(port string) int {
if !strings.HasSuffix(port, "/tcp") {
return 0
}
n, err := strconv.Atoi(strings.TrimSuffix(port, "/tcp"))
if err != nil {
return 0
}
return n
}

// configFileName returns the config file name for a container
func (cl *CompatibilityLayer) configFileName(containerID string) string {
return fmt.Sprintf("%s.yaml", utils.FormatDockerID(containerID))
Expand Down
Loading
Loading