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) } // 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) }