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;
+ }
}