diff --git a/forwardauth/.env b/forwardauth/.env new file mode 100644 index 000000000..d4e8641e1 --- /dev/null +++ b/forwardauth/.env @@ -0,0 +1,6 @@ +ENCRYPTION_KEY=f57d73478dedc596cf41679568c62986 +CLIENT_SECRET=7awzFYIlK3io3ipSuJd07aXxShp9p9Vp +CLIENT_ID=bingus +PROVIDER_URI=https://dev.theworldavatar.io/realms/twa-test +SIGNING_SECRET=security_is_hard +STACK_NAME=bingus \ No newline at end of file diff --git a/forwardauth/bingo.log b/forwardauth/bingo.log new file mode 100644 index 000000000..c446640bb --- /dev/null +++ b/forwardauth/bingo.log @@ -0,0 +1,3 @@ + +time="2026-02-05T18:06:17Z" level=debug msg="Authenticate request" headers="map[Accept:[text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7] Accept-Encoding:[gzip, deflate, br, zstd] Accept-Language:[en-IE,en-GB;q=0.9,en;q=0.8] Cache-Control:[max-age=0] Cookie:[_forward_auth_csrf=98b60036f1373bce89d3af6249b537b5] Dnt:[1] Sec-Ch-Ua:[\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Microsoft Edge\";v=\"144\"] Sec-Ch-Ua-Mobile:[?0] Sec-Ch-Ua-Platform:[\"Windows\"] Sec-Fetch-Dest:[document] Sec-Fetch-Mode:[navigate] Sec-Fetch-Site:[cross-site] Upgrade-Insecure-Requests:[1] User-Agent:[Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0] X-Forwarded-For:[10.0.0.2] X-Forwarded-Host:[localhost:1916] X-Forwarded-Port:[1916] X-Forwarded-Proto:[http] X-Forwarded-Server:[bingus-traefik] X-Real-Ip:[10.0.0.2]]" rule=default source_ip=10.0.0.2 +time="2026-02-05T18:06:17Z" level=debug msg="sending CSRF cookie and a redirect to OIDC login" source_ip=10.0.0.2 diff --git a/forwardauth/compose.yml b/forwardauth/compose.yml new file mode 100644 index 000000000..4e64cd199 --- /dev/null +++ b/forwardauth/compose.yml @@ -0,0 +1,28 @@ +services: + forwardauth: + image: mesosphere/traefik-forward-auth + networks: + - stack + environment: + - SECRET=${SIGNING_SECRET} + - PROVIDER_URI=${PROVIDER_URI} + - CLIENT_ID=${CLIENT_ID} + - CLIENT_SECRET=${CLIENT_SECRET} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} + labels: + - "traefik.enable=true" + - "traefik.http.services.forwardauth.loadbalancer.server.port=4181" + - "traefik.http.routers.forwardauth.entrypoints=web" + # Router for OAuth callback endpoint - middleware will detect and process callback + + - "traefik.http.routers.forwardauth.rule=Path(`/_oauth`)" + # Middleware definition used by other services + - "traefik.http.middlewares.traefik-forward-auth.forwardauth.address=http://forwardauth:4181" + - "traefik.http.middlewares.traefik-forward-auth.forwardauth.authResponseHeaders=X-Forwarded-User" + - "traefik.http.middlewares.traefik-forward-auth.forwardauth.trustForwardHeader=true" + +networks: + stack: + name: ${STACK_NAME} + driver: overlay + external: true diff --git a/stack-clients/.vscode/settings.json b/stack-clients/.vscode/settings.json new file mode 100644 index 000000000..c7d20e1f8 --- /dev/null +++ b/stack-clients/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "[java]": { + "editor.formatOnSave": true + } +} diff --git a/stack-clients/docker-compose.yml b/stack-clients/docker-compose.yml index 1596f5b16..5801c070a 100644 --- a/stack-clients/docker-compose.yml +++ b/stack-clients/docker-compose.yml @@ -1,6 +1,6 @@ services: stack-client: - image: ghcr.io/theworldavatar/stack-client${IMAGE_SUFFIX}:1.56.2 + image: ghcr.io/theworldavatar/stack-client${IMAGE_SUFFIX}:1.57.0-traefik-support-SNAPSHOT secrets: - blazegraph_password - postgis_password diff --git a/stack-clients/pom.xml b/stack-clients/pom.xml index a76fd8d7c..b1d14acb7 100644 --- a/stack-clients/pom.xml +++ b/stack-clients/pom.xml @@ -7,7 +7,7 @@ com.cmclinnovations stack-clients - 1.56.2 + 1.57.0-traefik-support-SNAPSHOT Stack Clients https://theworldavatar.io @@ -15,7 +15,7 @@ uk.ac.cam.cares.jps jps-parent-pom - 2.3.2 + 2.4.0 diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/StackClient.java b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/StackClient.java index 8350f8761..2c3d3c951 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/StackClient.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/StackClient.java @@ -36,6 +36,7 @@ public final class StackClient { private static StackHost stackHost = new StackHost(); private static boolean isolated = false; + private static String reverseProxyName; static { String envVarStackName = System.getenv(StackClient.STACK_NAME_KEY); @@ -107,6 +108,14 @@ public static Path getAbsDataPath() { return getStackBaseDir().resolve("inputs").resolve("data"); } + public static void setReverseProxyName(String reverseProxyName) { + StackClient.reverseProxyName = reverseProxyName; + } + + public static String getReverseProxyName() { + return reverseProxyName; + } + /** * Get a RemoteRDBStoreClient for the named Postgres RDB running in this stack. * diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/datasets/RML.java b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/datasets/RML.java index a9ca70f65..cb6deb73f 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/datasets/RML.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/datasets/RML.java @@ -1,7 +1,6 @@ package com.cmclinnovations.stack.clients.core.datasets; import java.nio.file.Path; -import java.util.Map; import com.cmclinnovations.stack.clients.rml.RmlMapperClient; diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/DockerClient.java b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/DockerClient.java index 85391b994..a8b5cde43 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/DockerClient.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/DockerClient.java @@ -53,7 +53,9 @@ import com.github.dockerjava.core.DefaultDockerClientConfig.Builder; import com.github.dockerjava.core.DockerClientBuilder; import com.github.dockerjava.core.DockerClientConfig; -import com.github.dockerjava.core.command.ExecStartResultCallback; +import com.github.dockerjava.api.async.ResultCallback; +import com.github.dockerjava.api.model.Frame; +import com.github.dockerjava.api.model.StreamType; import com.github.dockerjava.httpclient5.ApacheDockerHttpClient; import com.github.dockerjava.transport.DockerHttpClient; @@ -115,7 +117,6 @@ public String executeSimpleCommand(String containerId, String... cmd) { .withOutputStream(outputStream) .withErrorStream(outputStream) .exec(); - String output = outputStream.toString(); return execId; } @@ -231,11 +232,21 @@ public String exec() { execStartCmd.withStdIn(inputStream); } - // ExecStartResultCallback is marked deprecated but seems to do exactly what we - // want and without knowing why it is deprecated any issues with it can't be - // overcome anyway. - try (ExecStartResultCallback result = execStartCmd - .exec(new ExecStartResultCallback(outputStream, errorStream))) { + try (ResultCallback.Adapter result = execStartCmd + .exec(new ResultCallback.Adapter() { + @Override + public void onNext(Frame frame) { + try { + if (frame.getStreamType() == StreamType.STDOUT && outputStream != null) { + outputStream.write(frame.getPayload()); + } else if (frame.getStreamType() == StreamType.STDERR && errorStream != null) { + errorStream.write(frame.getPayload()); + } + } catch (IOException ex) { + throw new RuntimeException("Failed to write frame payload", ex); + } + } + })) { if (wait) { if (!result.awaitCompletion(evaluationTimeout, TimeUnit.SECONDS)) { LOGGER.warn("Docker exec command '{}' still running after the {} second execution timeout.", @@ -553,7 +564,7 @@ public boolean isContainerUp(String containerName) { public String getContainerId(String containerName) { return getContainer(containerName).map(Container::getId) - .orElseThrow(() -> new NoSuchElementException("Cannot get container "+containerName+".")); + .orElseThrow(() -> new NoSuchElementException("Cannot get container " + containerName + ".")); } private Map> convertToConfigFilterMap(String configName, Map labelMap) { diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/PodmanClient.java b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/PodmanClient.java index 3ee826842..c0a87e018 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/PodmanClient.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/PodmanClient.java @@ -7,15 +7,16 @@ import com.cmclinnovations.stack.clients.core.StackClient; import com.cmclinnovations.stack.clients.utils.JsonHelper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.dockerjava.api.model.Config; +import com.github.dockerjava.jaxrs.ApiClientExtension; + import io.theworldavatar.swagger.podman.ApiClient; import io.theworldavatar.swagger.podman.ApiException; import io.theworldavatar.swagger.podman.api.NetworksApi; import io.theworldavatar.swagger.podman.api.SecretsApi; import io.theworldavatar.swagger.podman.model.Network; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.dockerjava.api.model.Config; -import com.github.dockerjava.jaxrs.ApiClientExtension; public class PodmanClient extends DockerClient { diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/rdf4j/Rdf4jClient.java b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/rdf4j/Rdf4jClient.java index d75c05282..6dd945853 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/rdf4j/Rdf4jClient.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/rdf4j/Rdf4jClient.java @@ -4,7 +4,6 @@ import org.eclipse.rdf4j.federated.repository.FedXRepositoryConfigBuilder; import org.eclipse.rdf4j.repository.config.RepositoryConfig; -import org.eclipse.rdf4j.repository.config.RepositoryImplConfig; import org.eclipse.rdf4j.repository.manager.RemoteRepositoryManager; import org.eclipse.rdf4j.repository.sail.config.SailRepositoryConfig; import org.eclipse.rdf4j.repository.sparql.config.SPARQLRepositoryConfig; diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/services/DockerService.java b/stack-clients/src/main/java/com/cmclinnovations/stack/services/DockerService.java index b79ceb223..669a1bc86 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/services/DockerService.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/services/DockerService.java @@ -9,6 +9,7 @@ import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -338,8 +339,14 @@ void removeService(String serviceName) { protected ServiceSpec configureServiceSpec(ContainerService service) { ServiceSpec serviceSpec = service.getServiceSpec() - .withName(service.getContainerName()) - .withLabels(StackClient.getStackNameLabelMap()); + .withName(service.getContainerName()); + // Merge existing labels with stack name labels + Map serviceLabels = new HashMap<>(); + if (serviceSpec.getLabels() != null) { + serviceLabels.putAll(serviceSpec.getLabels()); + } + serviceLabels.putAll(StackClient.getStackNameLabelMap()); + serviceSpec.withLabels(serviceLabels); TaskSpec taskTemplate = service.getTaskTemplate(); if (null == taskTemplate.getRestartPolicy()) { taskTemplate.withRestartPolicy(new ServiceRestartPolicy() @@ -350,8 +357,14 @@ protected ServiceSpec configureServiceSpec(ContainerService service) { .withTarget(network.getId()) .withAliases(List.of(service.getName())))); ContainerSpec containerSpec = service.getContainerSpec() - .withLabels(StackClient.getStackNameLabelMap()) .withHostname(service.getName()); + // Merge existing container labels with stack name labels + Map containerLabels = new HashMap<>(); + if (containerSpec.getLabels() != null) { + containerLabels.putAll(containerSpec.getLabels()); + } + containerLabels.putAll(StackClient.getStackNameLabelMap()); + containerSpec.withLabels(containerLabels); interpolateEnvironmentVariables(containerSpec); diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/services/NginxService.java b/stack-clients/src/main/java/com/cmclinnovations/stack/services/NginxService.java index f8024fc54..cbdd2e628 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/services/NginxService.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/services/NginxService.java @@ -14,8 +14,6 @@ import com.cmclinnovations.stack.exceptions.InvalidTemplateException; import com.cmclinnovations.stack.services.config.Connection; import com.cmclinnovations.stack.services.config.ServiceConfig; -import com.github.dockerjava.api.model.EndpointSpec; -import com.github.dockerjava.api.model.PortConfig; import com.github.odiszapc.nginxparser.NgxBlock; import com.github.odiszapc.nginxparser.NgxComment; import com.github.odiszapc.nginxparser.NgxConfig; @@ -27,8 +25,6 @@ public final class NginxService extends ContainerService implements ReverseProxyService { - private static final String EXTERNAL_PORT = "EXTERNAL_PORT"; - public static final String TYPE = "nginx"; private static final String TEMPLATE_TYPE = "Nginx config"; @@ -63,22 +59,7 @@ protected void doPreStartUpConfiguration() { } } - private void updateExternalPort(ServiceConfig config) { - String externalPort = System.getenv(EXTERNAL_PORT); - if (null != externalPort) { - EndpointSpec endpointSpec = config.getDockerServiceSpec().getEndpointSpec(); - if (null != endpointSpec) { - List ports = endpointSpec.getPorts(); - if (null != ports) { - ports.stream() - .filter(port -> port.getTargetPort() == 80) - .forEach(port -> port.withPublishedPort(Integer.parseInt(externalPort))); - } - } - } - } - - public void addService(ContainerService service) { + public void addStackServiceToReverseProxy(ContainerService service) { NgxConfig locationConfigOut = new NgxConfig(); @@ -170,9 +151,7 @@ private String getProxyPassValue(Connection connection, String hostname) { } private String getServerURL(Connection connection, String hostname) { - URL url = connection.getUrl(); - int port = url.getPort(); - return hostname + ":" + ((-1 == port) ? 80 : port); + return hostname + ":" + getPortOrDefault(connection.getUrl()); } private final class ConfigSender { diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/services/ReverseProxyService.java b/stack-clients/src/main/java/com/cmclinnovations/stack/services/ReverseProxyService.java index cc0175f2e..7bcc65030 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/services/ReverseProxyService.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/services/ReverseProxyService.java @@ -1,6 +1,49 @@ package com.cmclinnovations.stack.services; +import java.net.URL; +import java.util.List; + +import com.cmclinnovations.stack.services.config.ServiceConfig; +import com.github.dockerjava.api.model.EndpointSpec; +import com.github.dockerjava.api.model.PortConfig; + public interface ReverseProxyService extends Service { - public void addService(ContainerService service); + public void addStackServiceToReverseProxy(ContainerService service); + + /** + * Updates the external port mapping for the reverse proxy service. + * This allows multiple stacks to run on the same host by exposing each stack's + * reverse proxy on a different external port. + * + * @param config The service configuration containing the endpoint + * specifications + */ + default void updateExternalPort(ServiceConfig config) { + String externalPort = System.getenv("EXTERNAL_PORT"); + if (null != externalPort) { + EndpointSpec endpointSpec = config.getDockerServiceSpec().getEndpointSpec(); + if (null != endpointSpec) { + List ports = endpointSpec.getPorts(); + if (null != ports) { + ports.stream() + .filter(port -> port.getTargetPort() == 80) + .forEach(port -> port.withPublishedPort(Integer.parseInt(externalPort))); + } + } + } + } + + /** + * Gets the port from a URL, defaulting to 80 if not specified. + * This is a common pattern when working with HTTP services that don't + * explicitly specify a port. + * + * @param url The URL to extract the port from + * @return The port number, or 80 if the URL doesn't specify a port (-1) + */ + default int getPortOrDefault(URL url) { + int port = url.getPort(); + return (port == -1) ? 80 : port; + } } diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/services/ServiceManager.java b/stack-clients/src/main/java/com/cmclinnovations/stack/services/ServiceManager.java index 14a92f7e1..7615600e5 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/services/ServiceManager.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/services/ServiceManager.java @@ -167,15 +167,17 @@ public S initialiseService(String stackName, String serviceN DockerService dockerService = getOrInitialiseService(stackName, StackClient.getContainerEngineName()); dockerService.doPreStartUpConfiguration(newContainerService); + if (!StackClient.getReverseProxyName().equals(serviceName)) { + ReverseProxyService reverseProxyService = getOrInitialiseService(stackName, + StackClient.getReverseProxyName()); + reverseProxyService.addStackServiceToReverseProxy(newContainerService); + } + dockerService.writeEndpointConfigs(newContainerService); if (dockerService.startContainer(newContainerService)) { dockerService.doFirstTimePostStartUpConfiguration(newContainerService); } dockerService.doEveryTimePostStartUpConfiguration(newContainerService); - if (!NginxService.TYPE.equals(serviceName)) { - ReverseProxyService reverseProxyService = getOrInitialiseService(stackName, NginxService.TYPE); - reverseProxyService.addService(newContainerService); - } } services.put(serviceName, newService); diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/services/TraefikService.java b/stack-clients/src/main/java/com/cmclinnovations/stack/services/TraefikService.java new file mode 100644 index 000000000..c5dc2facb --- /dev/null +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/services/TraefikService.java @@ -0,0 +1,145 @@ +package com.cmclinnovations.stack.services; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.cmclinnovations.stack.clients.core.StackClient; +import com.cmclinnovations.stack.clients.docker.DockerClient; +import com.cmclinnovations.stack.clients.utils.FileUtils; +import com.cmclinnovations.stack.services.config.ServiceConfig; +import com.github.dockerjava.api.model.ContainerSpec; +import com.github.dockerjava.api.model.ContainerSpecConfig; +import com.github.dockerjava.api.model.ContainerSpecFile; +import com.github.dockerjava.api.model.ServiceSpec; + +public class TraefikService extends ContainerService implements ReverseProxyService { + + public static final String TYPE = "traefik"; + + private static final String TRAEFIK_CONFIG_NAME = "traefik_config"; + private static final String TRAEFIK_CONFIG_PATH = "/etc/traefik/traefik.yml"; + private static final String TRAEFIK_CONFIG_TEMPLATE = "traefik/configs/traefik.yml"; + + // Forward authentication middleware name (defined by the forwardauth service) + private static final String AUTH_ENABLED = "AUTH_ENABLED"; + private static final String AUTH_MIDDLEWARE_NAME = "traefik-forward-auth"; + + public TraefikService(String stackName, ServiceConfig config) { + super(stackName, config); + updateExternalPort(config); + } + + @Override + protected void doPreStartUpConfiguration() { + try (InputStream inStream = new BufferedInputStream( + TraefikService.class.getResourceAsStream(TRAEFIK_CONFIG_TEMPLATE))) { + + String configContent = new String(inStream.readAllBytes(), StandardCharsets.UTF_8); + // Replace the ${STACK_NAME} placeholder with actual stack name + String stackName = getEnvironmentVariable(StackClient.STACK_NAME_KEY); + + configContent = configContent.replace("${STACK_NAME}", stackName); + + // Create Docker Config for Traefik + DockerClient dockerClient = DockerClient.getInstance(); + if (!dockerClient.configExists(TRAEFIK_CONFIG_NAME)) { + dockerClient.addConfig(TRAEFIK_CONFIG_NAME, configContent.getBytes(StandardCharsets.UTF_8)); + } + + // Mount the config into the container + ContainerSpec containerSpec = getContainerSpec(); + List configs = containerSpec.getConfigs(); + if (null == configs) { + configs = new ArrayList<>(); + containerSpec.withConfigs(configs); + } + + ContainerSpecConfig traefikConfig = new ContainerSpecConfig() + .withConfigName(TRAEFIK_CONFIG_NAME) + .withFile(new ContainerSpecFile() + .withName(TRAEFIK_CONFIG_PATH) + .withUid("0") + .withGid("0") + .withMode(0444L)); + configs.add(traefikConfig); + + } catch (IOException ex) { + throw new RuntimeException("Failed to configure Traefik", ex); + } + } + + @Override + public void addStackServiceToReverseProxy(ContainerService service) { + // Traefik's Swarm provider reads service-level labels, not container labels + ServiceSpec serviceSpec = service.getServiceSpec(); + Map existingLabels = serviceSpec.getLabels(); + final Map labels = (existingLabels != null) ? existingLabels : new HashMap<>(); + + // Check if authentication is enabled globally + // The forwardauth service defines the middleware that handles OAuth with + // Keycloak + boolean authEnabled = isAuthEnabled(); + String authMiddleware = authEnabled ? AUTH_MIDDLEWARE_NAME : null; + + // Track if any endpoints with external paths were found + final boolean[] hasExternalEndpoints = { false }; + + service.getConfig().getEndpoints().forEach((endpointName, connection) -> { + URI externalPath = connection.getExternalPath(); + if (null != externalPath) { + hasExternalEndpoints[0] = true; + + String serviceName = service.getContainerName(); + String routerName = serviceName + "_" + endpointName; + String pathPrefix = FileUtils.fixSlashes(externalPath.getPath(), true, false); + + // Configure router with path prefix rule + labels.put("traefik.http.routers." + routerName + ".rule", + "PathPrefix(`" + pathPrefix + "`)"); + labels.put("traefik.http.routers." + routerName + ".entrypoints", "web"); + + // Add authentication middleware if enabled + if (authMiddleware != null) { + labels.put("traefik.http.routers." + routerName + ".middlewares", authMiddleware); + } + + // Configure service with the internal port + int port = getPortOrDefault(connection.getUrl()); + labels.put("traefik.http.routers." + routerName + ".service", routerName); + labels.put("traefik.http.services." + routerName + ".loadbalancer.server.port", + String.valueOf(port)); + } + }); + + // Only enable Traefik for services that have external endpoints + if (hasExternalEndpoints[0]) { + labels.put("traefik.enable", "true"); + + // Note: The traefik-forward-auth middleware is defined by the forwardauth + // service + // Services that need authentication simply reference this middleware in their + // router config + } + + // Set labels on the service spec after they've been populated + serviceSpec.withLabels(labels); + } + + /** + * Checks if authentication is enabled via environment variable. + * When enabled, services will use the traefik-forward-auth middleware + * that is defined and configured by the forwardauth service. + */ + private boolean isAuthEnabled() { + String enabled = System.getenv(AUTH_ENABLED); + return "true".equalsIgnoreCase(enabled); + } + +} diff --git a/stack-clients/src/main/resources/com/cmclinnovations/stack/services/built-ins/traefik.json b/stack-clients/src/main/resources/com/cmclinnovations/stack/services/built-ins/traefik.json new file mode 100644 index 000000000..31e9f55d9 --- /dev/null +++ b/stack-clients/src/main/resources/com/cmclinnovations/stack/services/built-ins/traefik.json @@ -0,0 +1,40 @@ +{ + "type": "traefik", + "ServiceSpec": { + "Name": "traefik", + "TaskTemplate": { + "ContainerSpec": { + "Image": "traefik:v3.6", + "Mounts": [ + { + "Type": "volume", + "Source": "traefik_config", + "Target": "/etc/traefik" + } + ] + } + }, + "EndpointSpec": { + "Ports": [ + { + "Name": "web", + "Protocol": "tcp", + "TargetPort": "80", + "PublishedPort": "3838" + }, + { + "Name": "websecure", + "Protocol": "tcp", + "TargetPort": "443", + "PublishedPort": "443" + }, + { + "Name": "dashboard", + "Protocol": "tcp", + "TargetPort": "8080", + "PublishedPort": "8080" + } + ] + } + } +} \ No newline at end of file diff --git a/stack-clients/src/main/resources/com/cmclinnovations/stack/services/traefik/configs/traefik.yml b/stack-clients/src/main/resources/com/cmclinnovations/stack/services/traefik/configs/traefik.yml new file mode 100644 index 000000000..8c0a18a74 --- /dev/null +++ b/stack-clients/src/main/resources/com/cmclinnovations/stack/services/traefik/configs/traefik.yml @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=https://json.schemastore.org/traefik-v3.json +api: + dashboard: true + insecure: true + +entryPoints: + web: + address: ":80" + websecure: + address: ":443" + traefik: + address: ":8080" + +providers: + swarm: + endpoint: "unix:///var/run/docker.sock" + exposedByDefault: false + network: "${STACK_NAME}" + +log: + level: INFO diff --git a/stack-clients/src/test/java/com/cmclinnovations/stack/StackHostTest.java b/stack-clients/src/test/java/com/cmclinnovations/stack/StackHostTest.java index 73f629980..d40d97ee2 100644 --- a/stack-clients/src/test/java/com/cmclinnovations/stack/StackHostTest.java +++ b/stack-clients/src/test/java/com/cmclinnovations/stack/StackHostTest.java @@ -45,11 +45,12 @@ void testNameJson() { () -> Assertions.assertEquals("host", stackHost.getStringBuilder().withName().build())); } - @Test - void testEmptyStrings() { + @Test + void testEmptyStrings() { StackHost stackHostDefault = new StackHost(); StackHost stackHostJson = Assertions - .assertDoesNotThrow(() -> objectMapper.readValue("{\"proto\":\"\", \"name\":\"\",\"port\":\" \",\"path\":\" \"}", StackHost.class)); + .assertDoesNotThrow(() -> objectMapper + .readValue("{\"proto\":\"\", \"name\":\"\",\"port\":\" \",\"path\":\" \"}", StackHost.class)); Assertions.assertAll( () -> Assertions.assertEquals(stackHostDefault.getProto(), stackHostJson.getProto()), () -> Assertions.assertEquals(stackHostDefault.getName(), stackHostJson.getName()), diff --git a/stack-data-uploader/.vscode/settings.json b/stack-data-uploader/.vscode/settings.json new file mode 100644 index 000000000..c7d20e1f8 --- /dev/null +++ b/stack-data-uploader/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "[java]": { + "editor.formatOnSave": true + } +} diff --git a/stack-data-uploader/docker-compose.yml b/stack-data-uploader/docker-compose.yml index 2492d196a..c110fea32 100644 --- a/stack-data-uploader/docker-compose.yml +++ b/stack-data-uploader/docker-compose.yml @@ -1,6 +1,6 @@ services: stack-data-uploader: - image: ghcr.io/theworldavatar/stack-data-uploader${IMAGE_SUFFIX}:1.56.2 + image: ghcr.io/theworldavatar/stack-data-uploader${IMAGE_SUFFIX}:1.57.0-traefik-support-SNAPSHOT secrets: - blazegraph_password - postgis_password diff --git a/stack-data-uploader/pom.xml b/stack-data-uploader/pom.xml index 9a621abf5..4f6025efe 100644 --- a/stack-data-uploader/pom.xml +++ b/stack-data-uploader/pom.xml @@ -7,7 +7,7 @@ com.cmclinnovations stack-data-uploader - 1.56.2 + 1.57.0-traefik-support-SNAPSHOT Stack Data Uploader https://theworldavatar.io @@ -38,7 +38,7 @@ com.cmclinnovations stack-clients - 1.56.2 + 1.57.0-traefik-support-SNAPSHOT diff --git a/stack-manager/.vscode/settings.json b/stack-manager/.vscode/settings.json new file mode 100644 index 000000000..c7d20e1f8 --- /dev/null +++ b/stack-manager/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "[java]": { + "editor.formatOnSave": true + } +} diff --git a/stack-manager/.vscode/tasks.json b/stack-manager/.vscode/tasks.json index af41b2f38..06455fe29 100644 --- a/stack-manager/.vscode/tasks.json +++ b/stack-manager/.vscode/tasks.json @@ -33,7 +33,10 @@ ], "options": { "shell": { - "executable": "bash" + "executable": "bash", + "args": [ + "-c" + ] } } }, diff --git a/stack-manager/docker-compose.yml b/stack-manager/docker-compose.yml index 83e67e621..52f6ad8d9 100644 --- a/stack-manager/docker-compose.yml +++ b/stack-manager/docker-compose.yml @@ -1,9 +1,12 @@ services: stack-manager: - image: ghcr.io/theworldavatar/stack-manager${IMAGE_SUFFIX}:1.56.2 + image: ghcr.io/theworldavatar/stack-manager${IMAGE_SUFFIX}:1.57.0-traefik-support-SNAPSHOT environment: EXTERNAL_PORT: "${EXTERNAL_PORT-3838}" STACK_BASE_DIR: "${STACK_BASE_DIR}" + KEYCLOAK_AUTH_ENABLED: "${KEYCLOAK_AUTH_ENABLED-false}" + KEYCLOAK_AUTH_URL: "${KEYCLOAK_AUTH_URL-}" + KEYCLOAK_REALM: "${KEYCLOAK_REALM-}" volumes: - jdbc_drivers:/jdbc - ./inputs/data:/inputs/data diff --git a/stack-manager/pom.xml b/stack-manager/pom.xml index ab2fc7ba4..d8984e8a7 100644 --- a/stack-manager/pom.xml +++ b/stack-manager/pom.xml @@ -7,7 +7,7 @@ com.cmclinnovations stack-manager - 1.56.2 + 1.57.0-traefik-support-SNAPSHOT Stack Manager https://theworldavatar.io @@ -38,7 +38,7 @@ com.cmclinnovations stack-clients - 1.56.2 + 1.57.0-traefik-support-SNAPSHOT diff --git a/stack-manager/src/main/java/com/cmclinnovations/stack/Stack.java b/stack-manager/src/main/java/com/cmclinnovations/stack/Stack.java index 692cdc6e4..14136c076 100644 --- a/stack-manager/src/main/java/com/cmclinnovations/stack/Stack.java +++ b/stack-manager/src/main/java/com/cmclinnovations/stack/Stack.java @@ -83,6 +83,7 @@ private Stack(String name, ServiceManager manager, StackConfig config) { if (null != config) { StackClient.setStackHost(config.getHost()); StackClient.setIsolated(config.isIsolated()); + StackClient.setReverseProxyName(config.getReverseProxyName()); } } diff --git a/stack-manager/src/main/java/com/cmclinnovations/stack/StackConfig.java b/stack-manager/src/main/java/com/cmclinnovations/stack/StackConfig.java index 95b2d62ad..5fbe81271 100644 --- a/stack-manager/src/main/java/com/cmclinnovations/stack/StackConfig.java +++ b/stack-manager/src/main/java/com/cmclinnovations/stack/StackConfig.java @@ -7,6 +7,7 @@ import java.util.Map; import com.cmclinnovations.stack.clients.core.StackHost; +import com.cmclinnovations.stack.services.NginxService; import com.fasterxml.jackson.annotation.JsonProperty; public class StackConfig { @@ -30,6 +31,9 @@ private enum Selector { @JsonProperty private final Boolean isolated = false; + @JsonProperty("reverseProxy") + private String reverseProxy; + @JsonProperty("hostName") private void setHostName(String hostName) { host = new StackHost(hostName); @@ -54,4 +58,8 @@ Map getVolumes() { public boolean isIsolated() { return isolated; } + + public String getReverseProxyName() { + return reverseProxy != null ? reverseProxy : NginxService.TYPE; + } }