Files
QSfera/vendor/github.com/testcontainers/testcontainers-go/port_forwarding.go
T
dependabot[bot]andRalf Haferkamp de59bb63ad build(deps): bump github.com/testcontainers/testcontainers-go
Bumps [github.com/testcontainers/testcontainers-go](https://github.com/testcontainers/testcontainers-go) from 0.40.0 to 0.41.0.
- [Release notes](https://github.com/testcontainers/testcontainers-go/releases)
- [Commits](https://github.com/testcontainers/testcontainers-go/compare/v0.40.0...v0.41.0)

---
updated-dependencies:
- dependency-name: github.com/testcontainers/testcontainers-go
  dependency-version: 0.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-11 11:55:40 +01:00

416 lines
12 KiB
Go

package testcontainers
import (
"context"
"errors"
"fmt"
"io"
"net"
"slices"
"sync"
"time"
"github.com/docker/docker/api/types/container"
"github.com/google/uuid"
"golang.org/x/crypto/ssh"
"github.com/testcontainers/testcontainers-go/internal/core/network"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
// hubSshdImage {
sshdImage string = "testcontainers/sshd:1.3.0"
// }
// HostInternal is the internal hostname used to reach the host from the container,
// using the SSHD container as a bridge.
HostInternal string = "host.testcontainers.internal"
user string = "root"
sshPort = "22/tcp"
)
// sshPassword is a random password generated for the SSHD container.
var sshPassword = uuid.NewString()
// exposeHostPorts performs all the necessary steps to expose the host ports to the container, leveraging
// the SSHD container to create the tunnel, and the container lifecycle hooks to manage the tunnel lifecycle.
// At least one port must be provided to expose.
// The steps are:
// 1. Create a new SSHD container.
// 2. Expose the host ports to the container after the container is ready.
// 3. Close the SSH sessions before killing the container.
func exposeHostPorts(ctx context.Context, req *ContainerRequest, ports ...int) (sshdConnectHook ContainerLifecycleHooks, err error) {
if len(ports) == 0 {
return sshdConnectHook, errors.New("no ports to expose")
}
// Use the first network of the container to connect to the SSHD container.
var sshdFirstNetwork string
if len(req.Networks) > 0 {
sshdFirstNetwork = req.Networks[0]
}
if sshdFirstNetwork == "bridge" && len(req.Networks) > 1 {
sshdFirstNetwork = req.Networks[1]
}
opts := []ContainerCustomizer{}
if len(req.Networks) > 0 {
// get the first network of the container to connect the SSHD container to it.
nw, err := network.GetByName(ctx, sshdFirstNetwork)
if err != nil {
return sshdConnectHook, fmt.Errorf("get network %q: %w", sshdFirstNetwork, err)
}
dockerNw := DockerNetwork{
ID: nw.ID,
Name: nw.Name,
}
// WithNetwork reuses an already existing network, attaching the container to it.
// Finally it sets the network alias on that network to the given alias.
// TODO: Using an anonymous function to avoid cyclic dependencies with the network package.
withNetwork := func(aliases []string, nw *DockerNetwork) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
networkName := nw.Name
// attaching to the network because it was created with success or it already existed.
req.Networks = append(req.Networks, networkName)
if req.NetworkAliases == nil {
req.NetworkAliases = make(map[string][]string)
}
req.NetworkAliases[networkName] = aliases
return nil
}
}
opts = append(opts, withNetwork([]string{HostInternal}, &dockerNw))
}
// start the SSHD container with the provided options
sshdContainer, err := newSshdContainer(ctx, opts...)
// Ensure the SSHD container is stopped and removed in case of error.
defer func() {
if err != nil {
err = errors.Join(err, TerminateContainer(sshdContainer))
}
}()
if err != nil {
return sshdConnectHook, fmt.Errorf("new sshd container: %w", err)
}
// IP in the first network of the container.
inspect, err := sshdContainer.Inspect(ctx)
if err != nil {
return sshdConnectHook, fmt.Errorf("inspect sshd container: %w", err)
}
// TODO: remove once we have docker context support via #2810
//nolint:staticcheck // SA1019: IPAddress is deprecated, but we need it for compatibility until v29
sshdIP := inspect.NetworkSettings.IPAddress
if sshdIP == "" {
single := len(inspect.NetworkSettings.Networks) == 1
for name, network := range inspect.NetworkSettings.Networks {
if name == sshdFirstNetwork || single {
sshdIP = network.IPAddress
break
}
}
}
if sshdIP == "" {
return sshdConnectHook, errors.New("sshd container IP not found")
}
if req.HostConfigModifier == nil {
req.HostConfigModifier = func(_ *container.HostConfig) {}
}
// do not override the original HostConfigModifier
originalHCM := req.HostConfigModifier
req.HostConfigModifier = func(hostConfig *container.HostConfig) {
// adding the host internal alias to the container as an extra host
// to allow the container to reach the SSHD container.
hostConfig.ExtraHosts = append(hostConfig.ExtraHosts, fmt.Sprintf("%s:%s", HostInternal, sshdIP))
modes := []container.NetworkMode{container.NetworkMode(sshdFirstNetwork), "none", "host"}
// if the container is not in one of the modes, attach it to the first network of the SSHD container
found := slices.Contains(modes, hostConfig.NetworkMode)
if !found {
req.Networks = append(req.Networks, sshdFirstNetwork)
}
// invoke the original HostConfigModifier with the updated hostConfig
originalHCM(hostConfig)
}
stopHooks := []ContainerHook{
func(ctx context.Context, _ Container) error {
if ctx.Err() != nil {
// Context already canceled, need to create a new one to ensure
// the SSH session is closed.
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
}
return TerminateContainer(sshdContainer, StopContext(ctx))
},
}
// after the container is ready, create the SSH tunnel
// for each exposed port from the host.
sshdConnectHook = ContainerLifecycleHooks{
PostReadies: []ContainerHook{
func(ctx context.Context, _ Container) error {
return sshdContainer.exposeHostPort(ctx, req.HostAccessPorts...)
},
},
PostStops: stopHooks,
PreTerminates: stopHooks,
}
return sshdConnectHook, nil
}
// newSshdContainer creates a new SSHD container with the provided options.
func newSshdContainer(ctx context.Context, opts ...ContainerCustomizer) (*sshdContainer, error) {
moduleOpts := make([]ContainerCustomizer, 0, 3+len(opts))
moduleOpts = append(moduleOpts,
WithExposedPorts(sshPort),
WithEnv(map[string]string{"PASSWORD": sshPassword}),
WithWaitStrategy(wait.ForListeningPort(sshPort)),
)
moduleOpts = append(moduleOpts, opts...)
c, err := Run(ctx, sshdImage, moduleOpts...)
var sshd *sshdContainer
if c != nil {
sshd = &sshdContainer{Container: c}
}
if err != nil {
return sshd, fmt.Errorf("run sshd container: %w", err)
}
if err = sshd.clientConfig(ctx); err != nil {
// Return the container and the error to the caller to handle it.
return sshd, err
}
return sshd, nil
}
// sshdContainer represents the SSHD container type used for the port forwarding container.
// It's an internal type that extends the DockerContainer type, to add the SSH tunnelling capabilities.
type sshdContainer struct {
Container
port string
sshConfig *ssh.ClientConfig
portForwarders []*portForwarder
}
// Terminate stops the container and closes the SSH session
func (sshdC *sshdContainer) Terminate(ctx context.Context, opts ...TerminateOption) error {
return errors.Join(
sshdC.closePorts(),
sshdC.Container.Terminate(ctx, opts...),
)
}
// Stop stops the container and closes the SSH session
func (sshdC *sshdContainer) Stop(ctx context.Context, timeout *time.Duration) error {
return errors.Join(
sshdC.closePorts(),
sshdC.Container.Stop(ctx, timeout),
)
}
// closePorts closes all port forwarders.
func (sshdC *sshdContainer) closePorts() error {
var errs []error
for _, pfw := range sshdC.portForwarders {
if err := pfw.Close(); err != nil {
errs = append(errs, err)
}
}
sshdC.portForwarders = nil // Ensure the port forwarders are not used after closing.
return errors.Join(errs...)
}
// clientConfig sets up the SSHD client configuration.
func (sshdC *sshdContainer) clientConfig(ctx context.Context) error {
mappedPort, err := sshdC.MappedPort(ctx, sshPort)
if err != nil {
return fmt.Errorf("mapped port: %w", err)
}
sshdC.port = mappedPort.Port()
sshdC.sshConfig = &ssh.ClientConfig{
User: user,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Auth: []ssh.AuthMethod{ssh.Password(sshPassword)},
}
return nil
}
// exposeHostPort exposes the host ports to the container.
func (sshdC *sshdContainer) exposeHostPort(ctx context.Context, ports ...int) (err error) {
defer func() {
if err != nil {
err = errors.Join(err, sshdC.closePorts())
}
}()
for _, port := range ports {
pf, err := newPortForwarder(ctx, "localhost:"+sshdC.port, sshdC.sshConfig, port)
if err != nil {
return fmt.Errorf("new port forwarder: %w", err)
}
sshdC.portForwarders = append(sshdC.portForwarders, pf)
}
return nil
}
// portForwarder forwards a port from the container to the host.
type portForwarder struct {
client *ssh.Client
listener net.Listener
dialTimeout time.Duration
localAddr string
ctx context.Context
cancel context.CancelFunc
// closeMtx protects the close operation
closeMtx sync.Mutex
closeErr error
}
// newPortForwarder creates a new running portForwarder for the given port.
// The context is only used for the initial SSH connection.
func newPortForwarder(ctx context.Context, sshDAddr string, sshConfig *ssh.ClientConfig, port int) (pf *portForwarder, err error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", sshDAddr)
if err != nil {
return nil, fmt.Errorf("ssh dial: %w", err)
}
// Ensure the connection is closed in case of error.
defer func() {
if err != nil {
err = errors.Join(err, conn.Close())
}
}()
c, chans, reqs, err := ssh.NewClientConn(conn, sshDAddr, sshConfig)
if err != nil {
return nil, fmt.Errorf("ssh new client conn: %w", err)
}
client := ssh.NewClient(c, chans, reqs)
listener, err := client.Listen("tcp", fmt.Sprintf("localhost:%d", port))
if err != nil {
return nil, fmt.Errorf("listening on remote port %d: %w", port, err)
}
ctx, cancel := context.WithCancel(context.Background())
pf = &portForwarder{
client: client,
listener: listener,
localAddr: fmt.Sprintf("localhost:%d", port),
ctx: ctx,
cancel: cancel,
dialTimeout: time.Second * 2,
}
go pf.run()
return pf, nil
}
// Close closes the port forwarder.
func (pf *portForwarder) Close() error {
pf.closeMtx.Lock()
defer pf.closeMtx.Unlock()
select {
case <-pf.ctx.Done():
// Already closed.
return pf.closeErr
default:
}
var errs []error
if err := pf.listener.Close(); err != nil {
errs = append(errs, fmt.Errorf("close listener: %w", err))
}
if err := pf.client.Close(); err != nil {
errs = append(errs, fmt.Errorf("close client: %w", err))
}
pf.closeErr = errors.Join(errs...)
pf.cancel()
return pf.closeErr
}
// run forwards the port from the remote connection to the local connection.
func (pf *portForwarder) run() {
for {
remote, err := pf.listener.Accept()
if err != nil {
if errors.Is(err, io.EOF) {
// The listener has been closed.
return
}
// Ignore errors as they are transient and we want requests to
// continue to be accepted.
continue
}
go pf.tunnel(remote)
}
}
// tunnel runs a tunnel between two connections; as soon as the forwarder
// context is cancelled or one connection copies returns, irrespective of
// the error, both connections are closed.
func (pf *portForwarder) tunnel(remote net.Conn) {
defer remote.Close()
ctx, cancel := context.WithTimeout(pf.ctx, pf.dialTimeout)
defer cancel()
var dialer net.Dialer
local, err := dialer.DialContext(ctx, "tcp", pf.localAddr)
if err != nil {
// Nothing we can do with the error.
return
}
defer local.Close()
ctx, cancel = context.WithCancel(pf.ctx)
go func() {
defer cancel()
io.Copy(local, remote) //nolint:errcheck // Nothing useful we can do with the error.
}()
go func() {
defer cancel()
io.Copy(remote, local) //nolint:errcheck // Nothing useful we can do with the error.
}()
// Wait for the context to be done before returning which triggers
// both connections to close. This is done to prevent the copies
// blocking forever on unused connections.
<-ctx.Done()
}