mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-24 02:58:42 +09:00
Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.
Pattern applied across the tree:
In-package [Foo], [Foo.Bar]
Cross-package [pkg.Foo], [pkg.Foo.Bar]
Stdlib [netip.Prefix], [errors.Is], [context.Context]
Tailscale [tailcfg.MapResponse], [tailcfg.Node.CapMap],
[tailcfg.NodeAttrSuggestExitNode]
Skip rules:
- File:line refs left as plain text
- HuJSON wire keys inside backtick raw strings untouched
- ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
symbols, left as plain text
- JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
link tags, decorative dividers — all left as-is
486 lines
14 KiB
Go
486 lines
14 KiB
Go
package dockertestutil
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cenkalti/backoff/v5"
|
|
"github.com/juanfont/headscale/hscontrol/util"
|
|
"github.com/ory/dockertest/v3"
|
|
"github.com/ory/dockertest/v3/docker"
|
|
)
|
|
|
|
var (
|
|
ErrContainerNotFound = errors.New("container not found")
|
|
ErrConditionTimeout = errors.New("condition not met within timeout")
|
|
)
|
|
|
|
// retryDockerOp absorbs eventual-consistency races in libnetwork endpoint cleanup.
|
|
// Pulls its backoff bounds from retry.go so every helper that drives a
|
|
// docker control-plane call uses the same budget.
|
|
func retryDockerOp(ctx context.Context, op func() error) error {
|
|
bo := backoff.NewExponentialBackOff()
|
|
bo.InitialInterval = DockerOpInitialInterval
|
|
bo.MaxInterval = DockerOpMaxInterval
|
|
|
|
_, err := backoff.Retry(ctx, func() (struct{}, error) {
|
|
err := op()
|
|
if err != nil {
|
|
return struct{}{}, err
|
|
}
|
|
|
|
return struct{}{}, nil
|
|
}, backoff.WithBackOff(bo), backoff.WithMaxElapsedTime(DockerOpMaxElapsedTime))
|
|
|
|
return err
|
|
}
|
|
|
|
func GetFirstOrCreateNetwork(pool *dockertest.Pool, name string) (*dockertest.Network, error) {
|
|
return GetFirstOrCreateNetworkWithSubnet(pool, name, "")
|
|
}
|
|
|
|
// GetFirstOrCreateNetworkWithSubnet creates a Docker network with an optional
|
|
// custom subnet. When subnet is empty, Docker auto-assigns from its default
|
|
// pool. Use RFC 5737 TEST-NET ranges (e.g. "198.51.100.0/24") for networks
|
|
// that need to be reachable through Tailscale exit nodes, since Tailscale's
|
|
// shrinkDefaultRoute strips RFC1918 ranges from exit node forwarding filters.
|
|
func GetFirstOrCreateNetworkWithSubnet(pool *dockertest.Pool, name, subnet string) (*dockertest.Network, error) {
|
|
networks, err := pool.NetworksByName(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("looking up network names: %w", err)
|
|
}
|
|
|
|
if len(networks) == 0 {
|
|
var opts []func(*docker.CreateNetworkOptions)
|
|
if subnet != "" {
|
|
opts = append(opts, func(config *docker.CreateNetworkOptions) {
|
|
config.IPAM = &docker.IPAMOptions{
|
|
Config: []docker.IPAMConfig{
|
|
{Subnet: subnet},
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
if _, err := pool.CreateNetwork(name, opts...); err == nil { //nolint:noinlineerr // intentional inline check
|
|
// Create does not give us an updated version of the resource, so we need to
|
|
// get it again.
|
|
networks, err := pool.NetworksByName(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &networks[0], nil
|
|
} else {
|
|
return nil, fmt.Errorf("creating network: %w", err)
|
|
}
|
|
}
|
|
|
|
return &networks[0], nil
|
|
}
|
|
|
|
func AddContainerToNetwork(
|
|
pool *dockertest.Pool,
|
|
network *dockertest.Network,
|
|
testContainer string,
|
|
) error {
|
|
containers, err := pool.Client.ListContainers(docker.ListContainersOptions{
|
|
All: true,
|
|
Filters: map[string][]string{
|
|
"name": {testContainer},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// TODO(kradalby): This doesn't work reliably, but calling the exact same functions
|
|
// seem to work fine...
|
|
// if container, ok := pool.ContainerByName("/" + testContainer); ok {
|
|
// err := container.ConnectToNetwork(network)
|
|
// if err != nil {
|
|
// return err
|
|
// }
|
|
// }
|
|
|
|
return retryDockerOp(context.Background(), func() error {
|
|
return pool.Client.ConnectNetwork(network.Network.ID, docker.NetworkConnectionOptions{
|
|
Container: containers[0].ID,
|
|
})
|
|
})
|
|
}
|
|
|
|
// DisconnectContainerFromNetwork detaches the container at the docker
|
|
// daemon level (cable-pull semantics) and waits for libnetwork to drop
|
|
// the endpoint before returning — re-attaching during the
|
|
// reprogramming window otherwise fails with "network is unreachable".
|
|
func DisconnectContainerFromNetwork(
|
|
pool *dockertest.Pool,
|
|
network *dockertest.Network,
|
|
testContainer string,
|
|
) error {
|
|
containerID, err := lookupContainerID(pool, testContainer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = retryDockerOp(context.Background(), func() error {
|
|
return pool.Client.DisconnectNetwork(network.Network.ID, docker.NetworkConnectionOptions{
|
|
Container: containerID,
|
|
})
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = waitNetworkContainerAbsent(pool, network, testContainer, DockerOpMaxElapsedTime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// libnetwork drops the endpoint from its model before the kernel
|
|
// netns has flushed the matching route. Re-attach with the sticky
|
|
// IP otherwise fails with "conflicts with existing route".
|
|
return waitContainerRouteAbsent(pool, containerID, network, DockerOpMaxElapsedTime)
|
|
}
|
|
|
|
// ReconnectContainerToNetwork is the inverse of
|
|
// [DisconnectContainerFromNetwork] — re-attaches the container to the
|
|
// network so traffic can flow again.
|
|
func ReconnectContainerToNetwork(
|
|
pool *dockertest.Pool,
|
|
network *dockertest.Network,
|
|
testContainer string,
|
|
) error {
|
|
containerID, err := lookupContainerID(pool, testContainer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = retryDockerOp(context.Background(), func() error {
|
|
connectErr := pool.Client.ConnectNetwork(network.Network.ID, docker.NetworkConnectionOptions{
|
|
Container: containerID,
|
|
})
|
|
if connectErr != nil && isStaleRouteConflict(connectErr) {
|
|
// Defensive cleanup: a route survived the netns flush
|
|
// despite the wait above. Drop subnet routes that point
|
|
// at the disconnected interface so libnetwork can
|
|
// reprogram the sticky IP, then let the retry budget
|
|
// try the ConnectNetwork call again.
|
|
removeContainerSubnetRoutes(pool, containerID, network)
|
|
}
|
|
|
|
return connectErr
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return waitNetworkContainerPresent(pool, network, testContainer, DockerOpMaxElapsedTime)
|
|
}
|
|
|
|
// lookupContainerID resolves a container name to its docker ID.
|
|
func lookupContainerID(pool *dockertest.Pool, testContainer string) (string, error) {
|
|
containers, err := pool.Client.ListContainers(docker.ListContainersOptions{
|
|
All: true,
|
|
Filters: map[string][]string{"name": {testContainer}},
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if len(containers) == 0 {
|
|
return "", fmt.Errorf("%w: %s", ErrContainerNotFound, testContainer)
|
|
}
|
|
|
|
return containers[0].ID, nil
|
|
}
|
|
|
|
// DisconnectAndReconnect calls Disconnect followed by Reconnect; both
|
|
// primitives drive their own libnetwork settle waits.
|
|
func DisconnectAndReconnect(
|
|
pool *dockertest.Pool,
|
|
network *dockertest.Network,
|
|
testContainer string,
|
|
) error {
|
|
err := DisconnectContainerFromNetwork(pool, network, testContainer)
|
|
if err != nil {
|
|
return fmt.Errorf("disconnecting %s from %s: %w", testContainer, network.Network.Name, err)
|
|
}
|
|
|
|
err = ReconnectContainerToNetwork(pool, network, testContainer)
|
|
if err != nil {
|
|
return fmt.Errorf("reconnecting %s to %s: %w", testContainer, network.Network.Name, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func waitNetworkContainerAbsent(
|
|
pool *dockertest.Pool,
|
|
network *dockertest.Network,
|
|
testContainer string,
|
|
timeout time.Duration,
|
|
) error {
|
|
return pollUntil(timeout, func() (bool, error) {
|
|
net, err := pool.Client.NetworkInfo(network.Network.ID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("inspecting network %s: %w", network.Network.Name, err)
|
|
}
|
|
|
|
for _, c := range net.Containers {
|
|
if c.Name == testContainer || c.Name == "/"+testContainer {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
return true, nil
|
|
})
|
|
}
|
|
|
|
func waitNetworkContainerPresent(
|
|
pool *dockertest.Pool,
|
|
network *dockertest.Network,
|
|
testContainer string,
|
|
timeout time.Duration,
|
|
) error {
|
|
return pollUntil(timeout, func() (bool, error) {
|
|
net, err := pool.Client.NetworkInfo(network.Network.ID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("inspecting network %s: %w", network.Network.Name, err)
|
|
}
|
|
|
|
for _, c := range net.Containers {
|
|
if (c.Name == testContainer || c.Name == "/"+testContainer) && c.IPv4Address != "" {
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
})
|
|
}
|
|
|
|
// waitContainerRouteAbsent polls the container's routing table until no
|
|
// route remains for the network's IPAM subnet. libnetwork's docker-side
|
|
// endpoint teardown is asynchronous from the kernel netns flush, and a
|
|
// surviving route blocks a subsequent reconnect at sticky-IP assignment
|
|
// with "conflicts with existing route".
|
|
func waitContainerRouteAbsent(pool *dockertest.Pool, containerID string, network *dockertest.Network, timeout time.Duration) error {
|
|
subnets := networkSubnets(network)
|
|
if len(subnets) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return pollUntil(timeout, func() (bool, error) {
|
|
stdout, err := execStdout(pool, containerID, []string{"ip", "-4", "route", "show"})
|
|
if err != nil {
|
|
return false, fmt.Errorf("inspecting routes in %s: %w", containerID, err)
|
|
}
|
|
|
|
for _, subnet := range subnets {
|
|
if strings.Contains(stdout, subnet+" ") || strings.HasSuffix(strings.TrimSpace(stdout), subnet) {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
return true, nil
|
|
})
|
|
}
|
|
|
|
// removeContainerSubnetRoutes drops residue subnet routes in the
|
|
// container's netns — the leftover that libnetwork's async endpoint
|
|
// teardown can leave behind.
|
|
func removeContainerSubnetRoutes(pool *dockertest.Pool, containerID string, network *dockertest.Network) {
|
|
for _, subnet := range networkSubnets(network) {
|
|
_, err := execStdout(pool, containerID, []string{"ip", "-4", "route", "del", subnet})
|
|
if err != nil {
|
|
log.Printf("removing stale route %s in %s: %v", subnet, containerID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// isStaleRouteConflict matches the libnetwork 500 raised when a
|
|
// surviving subnet route blocks sticky-IP reprogramming on reconnect.
|
|
func isStaleRouteConflict(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
return strings.Contains(err.Error(), "conflicts with existing route")
|
|
}
|
|
|
|
// networkSubnets returns the IPAM-configured subnets for a docker
|
|
// network. Empty when IPAM is left to docker defaults.
|
|
func networkSubnets(network *dockertest.Network) []string {
|
|
out := make([]string, 0, len(network.Network.IPAM.Config))
|
|
for _, cfg := range network.Network.IPAM.Config {
|
|
if cfg.Subnet != "" {
|
|
out = append(out, cfg.Subnet)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// execStdout runs a one-shot command in containerID and returns stdout.
|
|
func execStdout(pool *dockertest.Pool, containerID string, cmd []string) (string, error) {
|
|
exec, err := pool.Client.CreateExec(docker.CreateExecOptions{
|
|
Container: containerID,
|
|
Cmd: cmd,
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create exec: %w", err)
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
err = pool.Client.StartExec(exec.ID, docker.StartExecOptions{
|
|
OutputStream: &stdout,
|
|
ErrorStream: &stderr,
|
|
})
|
|
if err != nil {
|
|
return stdout.String(), fmt.Errorf("start exec: %w", err)
|
|
}
|
|
|
|
return stdout.String(), nil
|
|
}
|
|
|
|
// pollUntil ticks every DockerOpInitialInterval until check returns
|
|
// done=true or timeout elapses. A non-nil check error aborts the loop.
|
|
func pollUntil(timeout time.Duration, check func() (done bool, err error)) error {
|
|
deadline := time.Now().Add(timeout)
|
|
|
|
ticker := time.NewTicker(DockerOpInitialInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
done, err := check()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if done {
|
|
return nil
|
|
}
|
|
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("%w: %s", ErrConditionTimeout, timeout)
|
|
}
|
|
|
|
<-ticker.C
|
|
}
|
|
}
|
|
|
|
// RandomFreeHostPort asks the kernel for a free open port that is ready to use.
|
|
// (from https://github.com/phayes/freeport)
|
|
func RandomFreeHostPort() (int, error) {
|
|
addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
listener, err := net.ListenTCP("tcp", addr)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer listener.Close()
|
|
//nolint:forcetypeassert
|
|
return listener.Addr().(*net.TCPAddr).Port, nil
|
|
}
|
|
|
|
// CleanUnreferencedNetworks removes networks that are not referenced by any containers.
|
|
func CleanUnreferencedNetworks(pool *dockertest.Pool) error {
|
|
filter := "name=hs-"
|
|
|
|
networks, err := pool.NetworksByName(filter)
|
|
if err != nil {
|
|
return fmt.Errorf("getting networks by filter %q: %w", filter, err)
|
|
}
|
|
|
|
for _, network := range networks {
|
|
if len(network.Network.Containers) == 0 {
|
|
err := pool.RemoveNetwork(&network)
|
|
if err != nil {
|
|
log.Printf("removing network %s: %s", network.Network.Name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CleanImagesInCI removes images if running in CI.
|
|
// It only removes dangling (untagged) images to avoid forcing rebuilds.
|
|
// Tagged images (golang:*, tailscale/tailscale:*, etc.) are automatically preserved.
|
|
func CleanImagesInCI(pool *dockertest.Pool) error {
|
|
if !util.IsCI() {
|
|
log.Println("Skipping image cleanup outside of CI")
|
|
return nil
|
|
}
|
|
|
|
images, err := pool.Client.ListImages(docker.ListImagesOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("getting images: %w", err)
|
|
}
|
|
|
|
removedCount := 0
|
|
|
|
for _, image := range images {
|
|
// Only remove dangling (untagged) images to avoid forcing rebuilds
|
|
// Dangling images have no RepoTags or only have "<none>:<none>"
|
|
if len(image.RepoTags) == 0 || (len(image.RepoTags) == 1 && image.RepoTags[0] == "<none>:<none>") {
|
|
log.Printf("Removing dangling image: %s", image.ID[:12])
|
|
|
|
err := pool.Client.RemoveImage(image.ID)
|
|
if err != nil {
|
|
log.Printf("Warning: failed to remove image %s: %v", image.ID[:12], err)
|
|
} else {
|
|
removedCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
if removedCount > 0 {
|
|
log.Printf("Removed %d dangling images in CI", removedCount)
|
|
} else {
|
|
log.Println("No dangling images to remove in CI")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DockerRestartPolicy sets the restart policy for containers.
|
|
func DockerRestartPolicy(config *docker.HostConfig) {
|
|
config.RestartPolicy = docker.RestartPolicy{
|
|
Name: "unless-stopped",
|
|
}
|
|
}
|
|
|
|
// DockerAllowLocalIPv6 allows IPv6 traffic within the container.
|
|
func DockerAllowLocalIPv6(config *docker.HostConfig) {
|
|
config.NetworkMode = "default"
|
|
config.Sysctls = map[string]string{
|
|
"net.ipv6.conf.all.disable_ipv6": "0",
|
|
}
|
|
}
|
|
|
|
// DockerAllowNetworkAdministration gives the container network administration capabilities.
|
|
func DockerAllowNetworkAdministration(config *docker.HostConfig) {
|
|
config.CapAdd = append(config.CapAdd, "NET_ADMIN")
|
|
config.Privileged = true
|
|
}
|
|
|
|
// DockerMemoryLimit sets memory limit and disables OOM kill for containers.
|
|
func DockerMemoryLimit(config *docker.HostConfig) {
|
|
config.Memory = 2 * 1024 * 1024 * 1024 // 2GB in bytes
|
|
config.OOMKillDisable = true
|
|
}
|