Skip to content

Fix Complement not using HSPortBindingIP (127.0.0.1) for the homeserver BaseURL #781

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
May 29, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ type Complement struct {
// disable this behaviour being added later, once this has stablised.
EnableDirtyRuns bool

// The hostname that will be used to bind the homeserver ports to, e.g. `127.0.0.1`
HSPortBindingIP string

// Name: COMPLEMENT_POST_TEST_SCRIPT
Expand Down
51 changes: 35 additions & 16 deletions internal/docker/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -539,27 +539,46 @@ func printPortBindingsOfAllComplementContainers(docker *client.Client, contextSt
log.Printf("=============== %s : END ALL COMPLEMENT DOCKER PORT BINDINGS ===============\n\n\n", contextStr)
}

func endpoints(p nat.PortMap, csPort, ssPort int) (baseURL, fedBaseURL string, err error) {
csapiPort := fmt.Sprintf("%d/tcp", csPort)
csapiPortInfo, ok := p[nat.Port(csapiPort)]
if !ok {
return "", "", fmt.Errorf("port %s not exposed - exposed ports: %v", csapiPort, p)
func endpoints(p nat.PortMap, hsPortBindingIP string, csPort, ssPort int) (baseURL, fedBaseURL string, err error) {
csapiPortBinding, err := findPortBinding(p, hsPortBindingIP, csPort)
if err != nil {
return "", "", fmt.Errorf("Problem finding CS API port: %s", err)
}
if len(csapiPortInfo) == 0 {
return "", "", fmt.Errorf("port %s exposed with not mapped port: %+v", csapiPort, p)
baseURL = fmt.Sprintf("http://"+csapiPortBinding.HostIP+":%s", csapiPortBinding.HostPort)

ssapiPortBinding, err := findPortBinding(p, hsPortBindingIP, ssPort)
if err != nil {
return "", "", fmt.Errorf("Problem finding SS API port: %s", err)
}
baseURL = fmt.Sprintf("http://"+csapiPortInfo[0].HostIP+":%s", csapiPortInfo[0].HostPort)
fedBaseURL = fmt.Sprintf("https://"+ssapiPortBinding.HostIP+":%s", ssapiPortBinding.HostPort)
return
}

ssapiPort := fmt.Sprintf("%d/tcp", ssPort)
ssapiPortInfo, ok := p[nat.Port(ssapiPort)]
// Find a matching port binding for the given host/port in the nat.PortMap.
func findPortBinding(p nat.PortMap, hsPortBindingIP string, port int) (portBinding nat.PortBinding, err error) {
portString := fmt.Sprintf("%d/tcp", port)
portBindings, ok := p[nat.Port(portString)]
if !ok {
return "", "", fmt.Errorf("port %s not exposed - exposed ports: %v", ssapiPort, p)
}
if len(ssapiPortInfo) == 0 {
return "", "", fmt.Errorf("port %s exposed with not mapped port: %+v", ssapiPort, p)
return nat.PortBinding{}, fmt.Errorf("port %s not exposed - exposed ports: %v", portString, p)
}
if len(portBindings) == 0 {
return nat.PortBinding{}, fmt.Errorf("port %s exposed with not mapped port: %+v", portString, p)
}

for _, pb := range portBindings {
if pb.HostIP == hsPortBindingIP {
return pb, nil
} else if pb.HostIP == "0.0.0.0" {
// `0.0.0.0` means "all interfaces", so we can assume that this will be listening
// for connections from `hsPortBindingIP` as well.
return nat.PortBinding{
HostIP: hsPortBindingIP,
HostPort: pb.HostPort,
}, nil
}
}
fedBaseURL = fmt.Sprintf("https://"+csapiPortInfo[0].HostIP+":%s", ssapiPortInfo[0].HostPort)
return

return portBindings[0], nil
}

type result struct {
Expand Down
111 changes: 94 additions & 17 deletions internal/docker/deployer.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,9 +313,13 @@ func (d *Deployer) StartServer(hsDep *HomeserverDeployment) error {
}

// Wait for the container to be ready.
baseURL, fedBaseURL, err := waitForPorts(ctx, d.Docker, hsDep.ContainerID)
err = waitForPorts(ctx, d.Docker, hsDep.ContainerID)
if err != nil {
return fmt.Errorf("failed to get ports for container %s: %s", hsDep.ContainerID, err)
return fmt.Errorf("failed to wait for ports on container %s: %s", hsDep.ContainerID, err)
}
baseURL, fedBaseURL, err := getHostAccessibleHomeserverUrls(ctx, d.Docker, hsDep.ContainerID, d.config.HSPortBindingIP)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clean things up, I've refactored waitForPorts(...) to only do the waiting part as described. We now assemble BaseURL and FedBaseUrl via getHostAccessibleHomeserverUrls(...)

if err != nil {
return fmt.Errorf("failed to get host accessible homeserver URL's from container %s: %s", hsDep.ContainerID, err)
}
hsDep.SetEndpoints(baseURL, fedBaseURL)

Expand Down Expand Up @@ -441,10 +445,19 @@ func deployImage(
log.Printf("%s: Started container %s", contextStr, containerID)
}

baseURL, fedBaseURL, err := waitForPorts(ctx, docker, containerID)
// Wait for the container to be ready.
err = waitForPorts(ctx, docker, containerID)
if err != nil {
return stubDeployment, fmt.Errorf("%s : image %s : %w", contextStr, imageID, err)
return stubDeployment, fmt.Errorf("%s: failed to wait for ports on container %s: %w", contextStr, containerID, err)
}
baseURL, fedBaseURL, err := getHostAccessibleHomeserverUrls(ctx, docker, containerID, cfg.HSPortBindingIP)
if err != nil {
return stubDeployment, fmt.Errorf(
"%s: failed to get host accessible homeserver URL's from container %s: %s",
contextStr, containerID, err,
)
}

inspect, err := docker.ContainerInspect(ctx, containerID)
if err != nil {
return stubDeployment, fmt.Errorf("ContainerInspect: %s", err)
Expand Down Expand Up @@ -504,26 +517,90 @@ func copyToContainer(docker *client.Client, containerID, path string, data []byt
return nil
}

// Waits until a homeserver container has NAT ports assigned and returns its clientside API URL and federation API URL.
func waitForPorts(ctx context.Context, docker *client.Client, containerID string) (baseURL string, fedBaseURL string, err error) {
func assertHostnameEqual(inputUrl string, expectedHostname string) error {
parsedUrl, err := url.Parse(inputUrl)
if err != nil {
return fmt.Errorf("failed to parse URL %s: %s", inputUrl, err)
}
if parsedUrl.Hostname() != expectedHostname {
return fmt.Errorf("expected hostname %s in URL %s, got %s", expectedHostname, inputUrl, parsedUrl.Hostname())
}

return nil
}

func getHostAccessibleHomeserverUrls(ctx context.Context, docker *client.Client, containerID string, hsPortBindingIP string) (baseURL string, fedBaseURL string, err error) {
inspectResponse, err := inspectPortsOnContainer(ctx, docker, containerID)
if err != nil {
return "", "", fmt.Errorf("failed to inspect ports: %w", err)
}

baseURL, fedBaseURL, err = endpoints(inspectResponse.NetworkSettings.Ports, hsPortBindingIP, 8008, 8448)

// Sanity check that the URL's match the expected binding hostname. It's important
// that we use the canonical publically accessible hostname for the homeserver as ...
// such as important cookies that are set during a SSO/OIDC login process (cookies are
// scoped to the domain).
err = assertHostnameEqual(baseURL, hsPortBindingIP)
if err != nil {
return "", "", fmt.Errorf("failed to assert baseURL has the correct hostname: %w", err)
}
err = assertHostnameEqual(fedBaseURL, hsPortBindingIP)
if err != nil {
return "", "", fmt.Errorf("failed to assert fedBaseURL has the correct hostname: %w", err)
}

return baseURL, fedBaseURL, nil
}

// Waits until a homeserver container has NAT ports assigned.
func waitForPorts(ctx context.Context, docker *client.Client, containerID string) (err error) {
// We need to hammer the inspect endpoint until the ports show up, they don't appear immediately.
var inspect container.InspectResponse
inspectStartTime := time.Now()
for time.Since(inspectStartTime) < time.Second {
inspect, err = docker.ContainerInspect(ctx, containerID)
if err != nil {
return "", "", err
}
if inspect.State != nil && !inspect.State.Running {
// the container exited, bail out with a container ID for logs
return "", "", fmt.Errorf("container is not running, state=%v", inspect.State.Status)
}
baseURL, fedBaseURL, err = endpoints(inspect.NetworkSettings.Ports, 8008, 8448)
_, err = inspectPortsOnContainer(ctx, docker, containerID)
if err == nil {
break
}

if inspectionErr, ok := err.(*ContainerInspectionError); ok && inspectionErr.Fatal {
// If the error is fatal, we should not retry.
return fmt.Errorf("Fatal inspection error: %s", err)
}
}
return baseURL, fedBaseURL, nil
return nil
}

type ContainerInspectionError struct {
// Error message
msg string
// Whether this error should stop retrying to inspect the container.
Fatal bool
}

func (e *ContainerInspectionError) Error() string { return e.msg }

func inspectPortsOnContainer(
ctx context.Context,
docker *client.Client,
containerID string,
) (inspectResponse container.InspectResponse, err error) {
inspectResponse, err = docker.ContainerInspect(ctx, containerID)
if err != nil {
return container.InspectResponse{}, &ContainerInspectionError{
msg: err.Error(),
Fatal: false,
}
}
if inspectResponse.State != nil && !inspectResponse.State.Running {
// the container exited, bail out with a container ID for logs
return container.InspectResponse{}, &ContainerInspectionError{
msg: fmt.Sprintf("container (%s) is not running, state=%v", containerID, inspectResponse.State.Status),
Fatal: true,
}
}

return inspectResponse, nil
}

// Waits until a homeserver deployment is ready to serve requests.
Expand Down
Loading