diff --git a/dockerfiles/synapse/homeserver.yaml b/dockerfiles/synapse/homeserver.yaml index a2f6f309..9fd6c97a 100644 --- a/dockerfiles/synapse/homeserver.yaml +++ b/dockerfiles/synapse/homeserver.yaml @@ -92,6 +92,13 @@ rc_joins: federation_rr_transactions_per_room_per_second: 9999 +## API Configuration ## + +# A list of application service config files to use +# +app_service_config_files: +AS_REGISTRATION_FILES + ## Experimental Features ## experimental_features: diff --git a/dockerfiles/synapse/start.sh b/dockerfiles/synapse/start.sh index 6e35b4dd..8af7c278 100755 --- a/dockerfiles/synapse/start.sh +++ b/dockerfiles/synapse/start.sh @@ -1,9 +1,22 @@ -#!/bin/sh +#!/bin/bash set -e sed -i "s/SERVER_NAME/${SERVER_NAME}/g" /conf/homeserver.yaml +# Add the application service registration files to the homeserver.yaml config +for filename in /appservices/*.yaml; do + [ -f "$filename" ] || break + + as_id=$(basename "$filename" .yaml) + + # Insert the path to the registration file and the AS_REGISTRATION_FILES marker after + # so we can add the next application service in the next iteration of this for loop + sed -i "s/AS_REGISTRATION_FILES/ - \/appservices\/${as_id}.yaml\nAS_REGISTRATION_FILES/g" /conf/homeserver.yaml +done +# Remove the AS_REGISTRATION_FILES entry +sed -i "s/AS_REGISTRATION_FILES//g" /conf/homeserver.yaml + # generate an ssl cert for the server, signed by our dummy CA openssl req -new -key /conf/server.tls.key -out /conf/server.tls.csr \ -subj "/CN=${SERVER_NAME}" diff --git a/internal/b/blueprints.go b/internal/b/blueprints.go index ef8424b4..b010daae 100644 --- a/internal/b/blueprints.go +++ b/internal/b/blueprints.go @@ -15,6 +15,8 @@ package b import ( + "crypto/rand" + "encoding/hex" "fmt" "strconv" "strings" @@ -26,6 +28,7 @@ var KnownBlueprints = map[string]*Blueprint{ BlueprintAlice.Name: &BlueprintAlice, BlueprintFederationOneToOneRoom.Name: &BlueprintFederationOneToOneRoom, BlueprintFederationTwoLocalOneRemote.Name: &BlueprintFederationTwoLocalOneRemote, + BlueprintHSWithApplicationService.Name: &BlueprintHSWithApplicationService, BlueprintOneToOneRoom.Name: &BlueprintOneToOneRoom, BlueprintPerfManyMessages.Name: &BlueprintPerfManyMessages, BlueprintPerfManyRooms.Name: &BlueprintPerfManyRooms, @@ -46,6 +49,8 @@ type Homeserver struct { Users []User // The list of rooms to create on this homeserver Rooms []Room + // The list of application services to create on the homeserver + ApplicationServices []ApplicationService } type User struct { @@ -68,6 +73,15 @@ type Room struct { Events []Event } +type ApplicationService struct { + ID string + HSToken string + ASToken string + URL string + SenderLocalpart string + RateLimited bool +} + type Event struct { Type string Sender string @@ -107,7 +121,14 @@ func Validate(bp Blueprint) (Blueprint, error) { return bp, err } } + for i, as := range hs.ApplicationServices { + hs.ApplicationServices[i], err = normalizeApplicationService(as) + if err != nil { + return bp, err + } + } } + return bp, nil } @@ -152,6 +173,25 @@ func normaliseUser(u string, hsName string) (string, error) { return u, nil } +func normalizeApplicationService(as ApplicationService) (ApplicationService, error) { + hsToken := make([]byte, 32) + _, err := rand.Read(hsToken) + if err != nil { + return as, err + } + + asToken := make([]byte, 32) + _, err = rand.Read(asToken) + if err != nil { + return as, err + } + + as.HSToken = hex.EncodeToString(hsToken) + as.ASToken = hex.EncodeToString(asToken) + + return as, err +} + // Ptr returns a pointer to `in`, because Go doesn't allow you to inline this. func Ptr(in string) *string { return &in diff --git a/internal/b/hs_with_application_service.go b/internal/b/hs_with_application_service.go new file mode 100644 index 00000000..ba9db923 --- /dev/null +++ b/internal/b/hs_with_application_service.go @@ -0,0 +1,25 @@ +package b + +// BlueprintHSWithApplicationService who has an application service to interact with +var BlueprintHSWithApplicationService = MustValidate(Blueprint{ + Name: "alice", + Homeservers: []Homeserver{ + { + Name: "hs1", + Users: []User{ + { + Localpart: "@alice", + DisplayName: "Alice", + }, + }, + ApplicationServices: []ApplicationService{ + { + ID: "my_as_id", + URL: "http://localhost:9000", + SenderLocalpart: "the-bridge-user", + RateLimited: false, + }, + }, + }, + }, +}) diff --git a/internal/docker/builder.go b/internal/docker/builder.go index a8b45ce6..4f6fb1bf 100644 --- a/internal/docker/builder.go +++ b/internal/docker/builder.go @@ -14,12 +14,15 @@ package docker import ( + "archive/tar" + "bytes" "context" "errors" "fmt" "io/ioutil" "log" "net/http" + "net/url" "os" "path" "runtime" @@ -31,6 +34,7 @@ import ( "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/volume" client "github.com/docker/docker/client" "github.com/docker/docker/pkg/stdcopy" "github.com/docker/go-connections/nat" @@ -276,6 +280,12 @@ func (d *Builder) construct(bprint b.Blueprint) (errs []error) { } labels := labelsForTokens(runner.AccessTokens(res.homeserver.Name)) + // Combine the labels for tokens and application services + asLabels := labelsForApplicationServices(res.homeserver) + for k, v := range asLabels { + labels[k] = v + } + // commit the container commit, err := d.Docker.ContainerCommit(context.Background(), res.containerID, types.ContainerCommitOptions{ Author: "Complement", @@ -300,7 +310,7 @@ func (d *Builder) construct(bprint b.Blueprint) (errs []error) { func (d *Builder) constructHomeserver(blueprintName string, runner *instruction.Runner, hs b.Homeserver, networkID string) result { contextStr := fmt.Sprintf("%s.%s", blueprintName, hs.Name) d.log("%s : constructing homeserver...\n", contextStr) - dep, err := d.deployBaseImage(blueprintName, hs.Name, contextStr, networkID) + dep, err := d.deployBaseImage(blueprintName, hs, contextStr, networkID) if err != nil { log.Printf("%s : failed to deployBaseImage: %s\n", contextStr, err) containerID := "" @@ -328,37 +338,38 @@ func (d *Builder) constructHomeserver(blueprintName string, runner *instruction. } // deployBaseImage runs the base image and returns the baseURL, containerID or an error. -func (d *Builder) deployBaseImage(blueprintName, hsName, contextStr, networkID string) (*HomeserverDeployment, error) { +func (d *Builder) deployBaseImage(blueprintName string, hs b.Homeserver, contextStr, networkID string) (*HomeserverDeployment, error) { + asIDToRegistrationMap := asIDToRegistrationFromLabels(labelsForApplicationServices(hs)) + return deployImage( - d.Docker, d.BaseImage, d.CSAPIPort, fmt.Sprintf("complement_%s", contextStr), blueprintName, hsName, contextStr, + d.Docker, d.BaseImage, d.CSAPIPort, fmt.Sprintf("complement_%s", contextStr), blueprintName, hs.Name, asIDToRegistrationMap, contextStr, networkID, d.config.VersionCheckIterations, ) } -// getCaVolume returns the correct mounts and volumes for providing a CA to homeserver containers. -func getCaVolume(docker *client.Client, ctx context.Context) (map[string]struct{}, []mount.Mount, error) { - var caVolume map[string]struct{} - var caMount []mount.Mount - +// getCaVolume returns the correct volume mount for providing a CA to homeserver containers. +// If running CI, returns an error if it's unable to find a volume that has /ca +// Otherwise, returns an error if we're unable to find the /ca directory on the local host +func getCaVolume(ctx context.Context, docker *client.Client) (caMount mount.Mount, err error) { if os.Getenv("CI") == "true" { // When in CI, Complement itself is a container with the CA volume mounted at /ca. // We need to mount this volume to all homeserver containers to synchronize the CA cert. // This is needed to establish trust among all containers. // Get volume mounted at /ca. First we get the container ID - // /proc/1/cpuset should be /docker/ + // /proc/1/cpuset should be /docker/ cpuset, err := ioutil.ReadFile("/proc/1/cpuset") if err != nil { - return nil, nil, err + return caMount, err } if !strings.Contains(string(cpuset), "docker") { - return nil, nil, errors.New("Could not identify container ID using /proc/1/cpuset") + return caMount, errors.New("Could not identify container ID using /proc/1/cpuset") } cpusetList := strings.Split(strings.TrimSpace(string(cpuset)), "/") - containerId := cpusetList[len(cpusetList)-1] - container, err := docker.ContainerInspect(ctx, containerId) + containerID := cpusetList[len(cpusetList)-1] + container, err := docker.ContainerInspect(ctx, containerID) if err != nil { - return nil, nil, err + return caMount, err } // Get the volume that matches the destination in our complement container var volumeName string @@ -371,51 +382,77 @@ func getCaVolume(docker *client.Client, ctx context.Context) (map[string]struct{ // We did not find a volume. This container might be created without a volume, // or CI=true is passed but we are not running in a container. // todo: log that we do not provide a CA volume mount? - return nil, nil, nil - } else { - caVolume = map[string]struct{}{ - "/ca": {}, - } - caMount = []mount.Mount{ - { - Type: mount.TypeVolume, - Source: volumeName, - Target: "/ca", - }, - } + return caMount, nil + } + + caMount = mount.Mount{ + Type: mount.TypeVolume, + Source: volumeName, + Target: "/ca", } } else { // When not in CI, our CA cert is placed in the current working dir. // We bind mount this directory to all homeserver containers. cwd, err := os.Getwd() if err != nil { - return nil, nil, err + return caMount, err } caCertificateDirHost := path.Join(cwd, "ca") if _, err := os.Stat(caCertificateDirHost); os.IsNotExist(err) { err = os.Mkdir(caCertificateDirHost, 0770) if err != nil { - return nil, nil, err + return caMount, err } } - caMount = []mount.Mount{ - { - Type: mount.TypeBind, - Source: path.Join(cwd, "ca"), - Target: "/ca", - }, + + caMount = mount.Mount{ + Type: mount.TypeBind, + Source: path.Join(cwd, "ca"), + Target: "/ca", } } - return caVolume, caMount, nil + return caMount, nil +} + +// getAppServiceVolume returns a volume mount for providing the `/appservice` directory to homeserver containers. +// This directory will contain application service registration config files. +// Returns an error if the volume failed to create +func getAppServiceVolume(ctx context.Context, docker *client.Client) (asMount mount.Mount, err error) { + asVolume, err := docker.VolumeCreate(context.Background(), volume.VolumesCreateBody{ + Name: "appservices", + }) + if err != nil { + return asMount, err + } + + asMount = mount.Mount{ + Type: mount.TypeVolume, + Source: asVolume.Name, + Target: "/appservices", + } + + return asMount, err +} + +func generateASRegistrationYaml(as b.ApplicationService) string { + return fmt.Sprintf("id: %s\n", as.ID) + + fmt.Sprintf("hs_token: %s\n", as.HSToken) + + fmt.Sprintf("as_token: %s\n", as.ASToken) + + fmt.Sprintf("url: '%s'\n", as.URL) + + fmt.Sprintf("sender_localpart: %s\n", as.SenderLocalpart) + + fmt.Sprintf("rate_limited: %v\n", as.RateLimited) + + "namespaces:\n" + + " users: []\n" + + " rooms: []\n" + + " aliases: []\n" } func deployImage( - docker *client.Client, imageID string, csPort int, containerName, blueprintName, hsName, contextStr, networkID string, versionCheckIterations int, + docker *client.Client, imageID string, csPort int, containerName, blueprintName, hsName string, asIDToRegistrationMap map[string]string, contextStr, networkID string, versionCheckIterations int, ) (*HomeserverDeployment, error) { ctx := context.Background() var extraHosts []string - var caVolume map[string]struct{} - var caMount []mount.Mount + var mounts []mount.Mount var err error if runtime.GOOS == "linux" { @@ -426,26 +463,39 @@ func deployImage( } if os.Getenv("COMPLEMENT_CA") == "true" { - caVolume, caMount, err = getCaVolume(docker, ctx) + var caMount mount.Mount + caMount, err = getCaVolume(ctx, docker) if err != nil { return nil, err } + + mounts = append(mounts, caMount) + } + + asMount, err := getAppServiceVolume(ctx, docker) + if err != nil { + return nil, err + } + mounts = append(mounts, asMount) + + env := []string{ + "SERVER_NAME=" + hsName, + "COMPLEMENT_CA=" + os.Getenv("COMPLEMENT_CA"), } body, err := docker.ContainerCreate(ctx, &container.Config{ Image: imageID, - Env: []string{"SERVER_NAME=" + hsName, "COMPLEMENT_CA=" + os.Getenv("COMPLEMENT_CA")}, + Env: env, //Cmd: d.ImageArgs, Labels: map[string]string{ complementLabel: contextStr, "complement_blueprint": blueprintName, "complement_hs_name": hsName, }, - Volumes: caVolume, }, &container.HostConfig{ PublishAllPorts: true, ExtraHosts: extraHosts, - Mounts: caMount, + Mounts: mounts, }, &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ hsName: { @@ -457,7 +507,35 @@ func deployImage( if err != nil { return nil, err } + containerID := body.ID + + // Create the application service files + for asID, registration := range asIDToRegistrationMap { + // Create a fake/virtual file in memory that we can copy to the container + // via https://stackoverflow.com/a/52131297/796832 + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + err = tw.WriteHeader(&tar.Header{ + Name: fmt.Sprintf("/appservices/%s.yaml", url.PathEscape(asID)), + Mode: 0777, + Size: int64(len(registration)), + }) + if err != nil { + return nil, fmt.Errorf("Failed to copy regstration to container: %v", err) + } + tw.Write([]byte(registration)) + tw.Close() + + // Put our new fake file in the container volume + err = docker.CopyToContainer(context.Background(), containerID, "/", &buf, types.CopyToContainerOptions{ + AllowOverwriteDirWithFile: false, + }) + if err != nil { + return nil, err + } + } + err = docker.ContainerStart(ctx, containerID, types.ContainerStartOptions{}) if err != nil { return nil, err @@ -488,11 +566,13 @@ func deployImage( lastErr = nil break } + d := &HomeserverDeployment{ - BaseURL: baseURL, - FedBaseURL: fedBaseURL, - ContainerID: containerID, - AccessTokens: tokensFromLabels(inspect.Config.Labels), + BaseURL: baseURL, + FedBaseURL: fedBaseURL, + ContainerID: containerID, + AccessTokens: tokensFromLabels(inspect.Config.Labels), + ApplicationServices: asIDToRegistrationFromLabels(inspect.Config.Labels), } if lastErr != nil { return d, fmt.Errorf("%s: failed to check server is up. %w", contextStr, lastErr) @@ -566,6 +646,28 @@ func labelsForTokens(userIDToToken map[string]string) map[string]string { return labels } +func asIDToRegistrationFromLabels(labels map[string]string) map[string]string { + asMap := make(map[string]string) + for k, v := range labels { + if strings.HasPrefix(k, "application_service_") { + asMap[strings.TrimPrefix(k, "application_service_")] = v + } + } + return asMap +} + +func labelsForApplicationServices(hs b.Homeserver) map[string]string { + labels := make(map[string]string) + // collect and store app service registrations as labels 'application_service_$as_id: $registration' + // collect and store app service access tokens as labels 'access_token_$sender_localpart: $as_token' + for _, as := range hs.ApplicationServices { + labels["application_service_"+as.ID] = generateASRegistrationYaml(as) + + labels["access_token_@"+as.SenderLocalpart+":"+hs.Name] = as.ASToken + } + return labels +} + 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)] diff --git a/internal/docker/deployer.go b/internal/docker/deployer.go index ca473135..b9111848 100644 --- a/internal/docker/deployer.go +++ b/internal/docker/deployer.go @@ -81,10 +81,12 @@ func (d *Deployer) Deploy(ctx context.Context, blueprintName string) (*Deploymen d.Counter++ contextStr := img.Labels["complement_context"] hsName := img.Labels["complement_hs_name"] + asIDToRegistrationMap := asIDToRegistrationFromLabels(img.Labels) + // TODO: Make CSAPI port configurable deployment, err := deployImage( d.Docker, img.ID, 8008, fmt.Sprintf("complement_%s_%s_%d", d.Namespace, contextStr, d.Counter), - blueprintName, hsName, contextStr, networkID, d.config.VersionCheckIterations) + blueprintName, hsName, asIDToRegistrationMap, contextStr, networkID, d.config.VersionCheckIterations) if err != nil { if deployment != nil && deployment.ContainerID != "" { // print logs to help debug diff --git a/internal/docker/deployment.go b/internal/docker/deployment.go index 672a4d59..489b69e2 100644 --- a/internal/docker/deployment.go +++ b/internal/docker/deployment.go @@ -20,10 +20,11 @@ type Deployment struct { // HomeserverDeployment represents a running homeserver in a container. type HomeserverDeployment struct { - BaseURL string // e.g http://localhost:38646 - FedBaseURL string // e.g https://localhost:48373 - ContainerID string // e.g 10de45efba - AccessTokens map[string]string // e.g { "@alice:hs1": "myAcc3ssT0ken" } + BaseURL string // e.g http://localhost:38646 + FedBaseURL string // e.g https://localhost:48373 + ContainerID string // e.g 10de45efba + AccessTokens map[string]string // e.g { "@alice:hs1": "myAcc3ssT0ken" } + ApplicationServices map[string]string // e.g { "my-as-id": "id: xxx\nas_token: xxx ..."} } } // Destroy the entire deployment. Destroys all running containers. If `printServerLogs` is true,