Files
firewall_containers/network-go/docker/docker.go
gyurix 2d6e22b9e6
continuous-integration/drone/push Build is passing
fix(network-go): handle reconnection gracefully and fix DNAT rule issues
- Ignore "endpoint already exists" error in ConnectContainer on re-reconciliation
- Improve iptables comment generation to avoid trailing dashes
- Enhance DNAT rule logic: try multiple selectors and fall back to host rules
- Add missing "-t nat" flag in InsertPreroutingRuleOnInterface
2026-06-15 16:12:08 +02:00

273 lines
8.3 KiB
Go

package docker
import (
"context"
"fmt"
"net"
"os/exec"
"regexp"
"strings"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"firewall_containers/network-go/config"
)
// DockerAPI defines the interface for Docker operations, enabling mock implementations for testing
type DockerAPI interface {
Close() error
EnsureNetwork(ctx context.Context, netCfg config.NetworkConfig) error
RemoveNetwork(ctx context.Context, networkName string) error
ConnectContainer(ctx context.Context, containerName, networkName, ip string) error
DisconnectContainer(ctx context.Context, containerName, networkName string) error
InspectContainer(ctx context.Context, containerName string) (*types.ContainerJSON, error)
WaitForContainerRunning(ctx context.Context, containerName string, timeout time.Duration) error
GetContainerPID(ctx context.Context, containerName string) (int, error)
AddRouteInContainer(ctx context.Context, containerName, network, gateway string) error
FindContainerName(ctx context.Context, name, selector string) (string, error)
}
// Client wraps the Docker SDK client
type Client struct {
cli *client.Client
}
// Ensure Client implements DockerAPI
var _ DockerAPI = (*Client)(nil)
// NewClient creates a new Docker client
func NewClient() (*Client, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("failed to create Docker client: %w", err)
}
return &Client{cli: cli}, nil
}
// Close closes the Docker client
func (c *Client) Close() error {
return c.cli.Close()
}
// EnsureNetwork creates a Docker network if it does not already exist
func (c *Client) EnsureNetwork(ctx context.Context, netCfg config.NetworkConfig) error {
existingNetworks, err := c.cli.NetworkList(ctx, network.ListOptions{
Filters: filters.NewArgs(filters.Arg("name", netCfg.NetworkName)),
})
if err != nil {
return fmt.Errorf("failed to list networks: %w", err)
}
for _, n := range existingNetworks {
if n.Name == netCfg.NetworkName {
return nil
}
}
_, ipNet, err := net.ParseCIDR(netCfg.Subnet)
if err != nil {
return fmt.Errorf("failed to parse subnet %s: %w", netCfg.Subnet, err)
}
gatewayIP := net.ParseIP(netCfg.Gateway)
if gatewayIP == nil {
return fmt.Errorf("failed to parse gateway IP %s", netCfg.Gateway)
}
createOpts := network.CreateOptions{
Driver: "bridge",
IPAM: &network.IPAM{
Config: []network.IPAMConfig{
{
Subnet: ipNet.String(),
Gateway: gatewayIP.String(),
},
},
},
Labels: map[string]string{
"managed-by": "firewall-network-go",
},
Attachable: true,
}
resp, err := c.cli.NetworkCreate(ctx, netCfg.NetworkName, createOpts)
if err != nil {
return fmt.Errorf("failed to create network %s: %w", netCfg.NetworkName, err)
}
_ = resp
return nil
}
// RemoveNetwork removes a Docker network
func (c *Client) RemoveNetwork(ctx context.Context, networkName string) error {
err := c.cli.NetworkRemove(ctx, networkName)
if err != nil {
return fmt.Errorf("failed to remove network %s: %w", networkName, err)
}
return nil
}
// ConnectContainer connects a container to a network with a specific IP
func (c *Client) ConnectContainer(ctx context.Context, containerName, networkName, ip string) error {
endpointSettings := &network.EndpointSettings{
IPAMConfig: &network.EndpointIPAMConfig{
IPv4Address: ip,
},
}
err := c.cli.NetworkConnect(ctx, networkName, containerName, endpointSettings)
if err != nil {
// "endpoint with name ... already exists" is expected on re-reconciliation
if strings.Contains(err.Error(), "already exists") {
return nil
}
return fmt.Errorf("failed to connect container %s to network %s: %w", containerName, networkName, err)
}
return nil
}
// DisconnectContainer disconnects a container from a network
func (c *Client) DisconnectContainer(ctx context.Context, containerName, networkName string) error {
err := c.cli.NetworkDisconnect(ctx, networkName, containerName, true)
if err != nil {
return fmt.Errorf("failed to disconnect container %s from network %s: %w", containerName, networkName, err)
}
return nil
}
// InspectContainer returns the container's details
func (c *Client) InspectContainer(ctx context.Context, containerName string) (*types.ContainerJSON, error) {
container, err := c.cli.ContainerInspect(ctx, containerName)
if err != nil {
return nil, fmt.Errorf("failed to inspect container %s: %w", containerName, err)
}
return &container, nil
}
// WaitForContainerRunning waits until a container is in running state, with a timeout
func (c *Client) WaitForContainerRunning(ctx context.Context, containerName string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return fmt.Errorf("timeout waiting for container %s to be running", containerName)
case <-ticker.C:
container, err := c.cli.ContainerInspect(ctx, containerName)
if err != nil {
continue
}
if container.State != nil && container.State.Running {
return nil
}
}
}
}
// GetContainerPID returns the PID of a container for nsenter operations
func (c *Client) GetContainerPID(ctx context.Context, containerName string) (int, error) {
cont, err := c.cli.ContainerInspect(ctx, containerName)
if err != nil {
return 0, fmt.Errorf("failed to inspect container %s: %w", containerName, err)
}
if cont.State == nil || !cont.State.Running {
return 0, fmt.Errorf("container %s is not running", containerName)
}
return cont.State.Pid, nil
}
// AddRouteInContainer adds a route inside a container's network namespace using nsenter
func (c *Client) AddRouteInContainer(ctx context.Context, containerName, network, gateway string) error {
pid, err := c.GetContainerPID(ctx, containerName)
if err != nil {
return fmt.Errorf("failed to get PID for container %s: %w", containerName, err)
}
args := []string{
"-t", fmt.Sprintf("%d", pid),
"-n", "--",
"ip", "route", "add", network, "via", gateway,
}
cmd := exec.Command("nsenter", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to add route in container %s: %w\noutput: %s", containerName, err, string(output))
}
return nil
}
// FindContainerName attempts to find a running container by name or selector.
// First tries exact name match, then exact selector, then prefix matching
// (matching the old shell script's grep $D"-" behavior).
func (c *Client) FindContainerName(ctx context.Context, name, selector string) (string, error) {
// Try exact name match using ContainerList with a name filter
// Docker API name filter does an exact match by default
containers, err := c.cli.ContainerList(ctx, container.ListOptions{
Filters: filters.NewArgs(
filters.Arg("name", "^/?"+regexp.QuoteMeta(name)+"$"),
filters.Arg("status", "running"),
),
})
if err == nil && len(containers) > 0 {
cName := strings.TrimPrefix(containers[0].Names[0], "/")
return cName, nil
}
// Try exact selector match
if selector != "" && selector != name {
containers, err = c.cli.ContainerList(ctx, container.ListOptions{
Filters: filters.NewArgs(
filters.Arg("name", "^/?"+regexp.QuoteMeta(selector)+"$"),
filters.Arg("status", "running"),
),
})
if err == nil && len(containers) > 0 {
cName := strings.TrimPrefix(containers[0].Names[0], "/")
return cName, nil
}
}
// Try prefix matching on both name and selector (shell script behavior: grep $D"-")
candidates := []string{name, selector}
for _, candidate := range candidates {
if candidate == "" {
continue
}
// Extract prefix before first dash if present
prefix := candidate
if strings.Contains(candidate, "-") {
prefix = candidate[:strings.Index(candidate, "-")]
}
containers, err = c.cli.ContainerList(ctx, container.ListOptions{
Filters: filters.NewArgs(
filters.Arg("name", prefix+"-"),
filters.Arg("status", "running"),
),
})
if err != nil {
continue
}
for _, c := range containers {
for _, cName := range c.Names {
cName = strings.TrimPrefix(cName, "/")
return cName, nil
}
}
}
return "", fmt.Errorf("no running container found matching name=%q selector=%q", name, selector)
}