continuous-integration/drone/push Build is passing
This adds an IsConnected method to verify if a container is already connected to a network with the expected IP, preventing redundant operations. In reconcileIPs, it skips reconnections if the state is correct. In applyNATRule, MASQUERADE is now applied in the same namespace as DNAT (container or host) for consistent and accurate rule application.
351 lines
13 KiB
Go
351 lines
13 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"
|
|
"firewall_containers/network-go/logger"
|
|
)
|
|
|
|
// 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)
|
|
IsConnected(ctx context.Context, containerName, networkName, expectedIP string) bool
|
|
}
|
|
|
|
// 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) {
|
|
logger.Info("DOCKER: creating Docker client (using DOCKER_HOST env)")
|
|
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create Docker client: %w", err)
|
|
}
|
|
ping, err := cli.Ping(context.Background())
|
|
if err != nil {
|
|
logger.Warn("DOCKER: Docker daemon ping failed: %v", err)
|
|
} else {
|
|
logger.Info("DOCKER: connected to Docker daemon (API version=%s, OS=%s)", ping.APIVersion, ping.OSType)
|
|
}
|
|
return &Client{cli: cli}, nil
|
|
}
|
|
|
|
// Close closes the Docker client
|
|
func (c *Client) Close() error {
|
|
logger.Debug("DOCKER: closing Docker client")
|
|
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 {
|
|
logger.Debug("DOCKER: checking if network %q exists", netCfg.NetworkName)
|
|
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 {
|
|
logger.Debug("DOCKER: network %q already exists", 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,
|
|
}
|
|
|
|
logger.Info("DOCKER: creating network %q (subnet=%s, gateway=%s)", netCfg.NetworkName, netCfg.Subnet, netCfg.Gateway)
|
|
resp, err := c.cli.NetworkCreate(ctx, netCfg.NetworkName, createOpts)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create network %s: %w", netCfg.NetworkName, err)
|
|
}
|
|
|
|
logger.Info("DOCKER: network %q created (ID=%s)", netCfg.NetworkName, resp.ID)
|
|
return nil
|
|
}
|
|
|
|
// RemoveNetwork removes a Docker network
|
|
func (c *Client) RemoveNetwork(ctx context.Context, networkName string) error {
|
|
logger.Info("DOCKER: removing network %q", networkName)
|
|
err := c.cli.NetworkRemove(ctx, networkName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove network %s: %w", networkName, err)
|
|
}
|
|
logger.Info("DOCKER: network %q removed", networkName)
|
|
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 {
|
|
logger.Info("DOCKER: connecting container %q to network %q with IP %s", containerName, networkName, ip)
|
|
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") {
|
|
logger.Info("DOCKER: container %q already connected to network %q", containerName, networkName)
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to connect container %s to network %s: %w", containerName, networkName, err)
|
|
}
|
|
logger.Info("DOCKER: container %q connected to network %q with IP %s", containerName, networkName, ip)
|
|
return nil
|
|
}
|
|
|
|
// DisconnectContainer disconnects a container from a network
|
|
func (c *Client) DisconnectContainer(ctx context.Context, containerName, networkName string) error {
|
|
logger.Info("DOCKER: disconnecting container %q from network %q", containerName, networkName)
|
|
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)
|
|
}
|
|
logger.Info("DOCKER: container %q disconnected from network %q", containerName, networkName)
|
|
return nil
|
|
}
|
|
|
|
// InspectContainer returns the container's details
|
|
func (c *Client) InspectContainer(ctx context.Context, containerName string) (*types.ContainerJSON, error) {
|
|
logger.Debug("DOCKER: inspecting container %q", containerName)
|
|
container, err := c.cli.ContainerInspect(ctx, containerName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to inspect container %s: %w", containerName, err)
|
|
}
|
|
logger.Debug("DOCKER: container %q inspected (status=%s, PID=%d)",
|
|
containerName, container.State.Status, container.State.Pid)
|
|
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 {
|
|
logger.Info("DOCKER: waiting for container %q to be running (timeout=%s)", containerName, timeout)
|
|
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 {
|
|
logger.Debug("DOCKER: container %q inspect failed (not ready yet): %v", containerName, err)
|
|
continue
|
|
}
|
|
if container.State != nil && container.State.Running {
|
|
logger.Info("DOCKER: container %q is now running", containerName)
|
|
return nil
|
|
}
|
|
logger.Debug("DOCKER: container %q status=%s, not running yet", containerName, container.State.Status)
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetContainerPID returns the PID of a container for nsenter operations
|
|
func (c *Client) GetContainerPID(ctx context.Context, containerName string) (int, error) {
|
|
logger.Info("DOCKER: getting PID for container %q", containerName)
|
|
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)
|
|
}
|
|
logger.Info("DOCKER: container %q PID=%d", containerName, cont.State.Pid)
|
|
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)
|
|
}
|
|
|
|
logger.Info("DOCKER: adding route in container %q (PID=%d): %s via %s", containerName, pid, network, gateway)
|
|
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))
|
|
}
|
|
logger.Info("DOCKER: route added in container %q: %s via %s", containerName, network, gateway)
|
|
logger.Debug("DOCKER: nsenter output: %s", strings.TrimSpace(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) {
|
|
logger.Info("DOCKER: finding container: name=%q selector=%q", name, selector)
|
|
|
|
// 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], "/")
|
|
logger.Info("DOCKER: found container by exact name match: %q", cName)
|
|
return cName, nil
|
|
}
|
|
|
|
// Try exact selector match
|
|
if selector != "" && selector != name {
|
|
logger.Debug("DOCKER: trying selector match: %q", selector)
|
|
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], "/")
|
|
logger.Info("DOCKER: found container by selector match: %q", cName)
|
|
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, "-")]
|
|
}
|
|
logger.Debug("DOCKER: trying prefix match: candidate=%q prefix=%q", candidate, prefix)
|
|
|
|
containers, err = c.cli.ContainerList(ctx, container.ListOptions{
|
|
Filters: filters.NewArgs(
|
|
filters.Arg("name", prefix+"-"),
|
|
filters.Arg("status", "running"),
|
|
),
|
|
})
|
|
if err != nil {
|
|
logger.Debug("DOCKER: prefix list failed: %v", err)
|
|
continue
|
|
}
|
|
|
|
for _, c := range containers {
|
|
for _, cName := range c.Names {
|
|
cName = strings.TrimPrefix(cName, "/")
|
|
logger.Info("DOCKER: found container by prefix match %q-: %q", prefix, cName)
|
|
return cName, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("no running container found matching name=%q selector=%q", name, selector)
|
|
}
|
|
|
|
// IsConnected checks if a container is already connected to a network with the expected IP.
|
|
// Returns true if the connection already exists with the correct IP (stateful check).
|
|
func (c *Client) IsConnected(ctx context.Context, containerName, networkName, expectedIP string) bool {
|
|
logger.Debug("DOCKER: checking if container %q is connected to %q with IP %s", containerName, networkName, expectedIP)
|
|
|
|
containers, err := c.cli.ContainerList(ctx, container.ListOptions{
|
|
Filters: filters.NewArgs(
|
|
filters.Arg("name", "^/?"+regexp.QuoteMeta(containerName)+"$"),
|
|
filters.Arg("status", "running"),
|
|
),
|
|
})
|
|
if err != nil || len(containers) == 0 {
|
|
logger.Debug("DOCKER: container %q not found or error: %v", containerName, err)
|
|
return false
|
|
}
|
|
|
|
// Check the network settings for the expected IP
|
|
inspect, err := c.cli.ContainerInspect(ctx, containerName)
|
|
if err != nil {
|
|
logger.Debug("DOCKER: inspect failed for %q: %v", containerName, err)
|
|
return false
|
|
}
|
|
|
|
if inspect.NetworkSettings != nil && inspect.NetworkSettings.Networks != nil {
|
|
if netSettings, ok := inspect.NetworkSettings.Networks[networkName]; ok {
|
|
currentIP := netSettings.IPAddress
|
|
logger.Debug("DOCKER: container %q on network %q has IP=%s (expected=%s)", containerName, networkName, currentIP, expectedIP)
|
|
if currentIP == expectedIP {
|
|
logger.Debug("DOCKER: container %q already correctly connected to %q with IP %s", containerName, networkName, expectedIP)
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.Debug("DOCKER: container %q not yet correctly connected to %q", containerName, networkName)
|
|
return false
|
|
}
|