From f5ef8e6488bd0bb4501bb59302dd470836740927 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:04:11 +0200 Subject: [PATCH] feat: per-tenant network isolation Each tenant gets an isolated Docker bridge network (cameleer-tenant-{slug}). Server + UI containers use the tenant network as primary, with additional connections to the shared services network (postgres/clickhouse/logto) and Traefik network (routing). Tenant networks are internal (no internet) and isolated from each other. Apps deployed by the tenant server also join the tenant network. Network is removed on tenant delete. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../provisioning/DockerTenantProvisioner.java | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java index ee0852b..63720ab 100644 --- a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java @@ -40,16 +40,20 @@ public class DockerTenantProvisioner implements TenantProvisioner { public ProvisionResult provision(TenantProvisionRequest req) { String serverName = serverContainerName(req.slug()); String uiName = uiContainerName(req.slug()); + String tenantNetwork = tenantNetworkName(req.slug()); String endpoint = "http://" + serverName + ":8081"; try { pullIfMissing(props.serverImage()); pullIfMissing(props.serverUiImage()); - createServerContainer(req, serverName); + // Create isolated tenant network + ensureNetwork(tenantNetwork); + + createServerContainer(req, serverName, tenantNetwork); docker.startContainerCmd(serverName).exec(); - createUiContainer(req.slug(), uiName, serverName); + createUiContainer(req.slug(), uiName, serverName, tenantNetwork); docker.startContainerCmd(uiName).exec(); if (!waitForHealth(serverName, 60)) { @@ -90,6 +94,7 @@ public class DockerTenantProvisioner implements TenantProvisioner { public void remove(String slug) { removeContainer(uiContainerName(slug)); removeContainer(serverContainerName(slug)); + removeNetwork(tenantNetworkName(slug)); } @Override @@ -112,19 +117,17 @@ public class DockerTenantProvisioner implements TenantProvisioner { return "http://" + serverContainerName(slug) + ":8081"; } - private void createServerContainer(TenantProvisionRequest req, String name) { + private void createServerContainer(TenantProvisionRequest req, String name, String tenantNetwork) { String slug = req.slug(); String prefix = "/t/" + slug; // Traefik labels — need >10 entries, use HashMap var labels = new java.util.HashMap(); labels.put("traefik.enable", "true"); - // Router: match /t/{slug}/api/* and /t/{slug}/actuator/* labels.put("traefik.http.routers.server-" + slug + ".rule", "PathPrefix(`" + prefix + "/api`) || PathPrefix(`" + prefix + "/actuator`)"); labels.put("traefik.http.routers.server-" + slug + ".tls", "true"); labels.put("traefik.http.routers.server-" + slug + ".priority", "10"); - // Strip the /t/{slug} prefix so server sees /api/... and /actuator/... labels.put("traefik.http.middlewares.server-strip-" + slug + ".stripprefix.prefixes", prefix); labels.put("traefik.http.routers.server-" + slug + ".middlewares", "server-strip-" + slug); labels.put("traefik.http.services.server-" + slug + ".loadbalancer.server.port", "8081"); @@ -150,14 +153,15 @@ public class DockerTenantProvisioner implements TenantProvisioner { "CAMELEER_ROUTING_DOMAIN=" + props.publicHost(), "CAMELEER_ROUTING_MODE=path", "CAMELEER_JAR_STORAGE_PATH=/data/jars", - "CAMELEER_DOCKER_NETWORK=" + props.networkName(), + // Apps deployed by this server join the tenant network (isolated) + "CAMELEER_DOCKER_NETWORK=" + tenantNetwork, "CAMELEER_JAR_DOCKER_VOLUME=cameleer-jars-" + slug ); - // Docker socket mount for app deployment within tenant server + // Primary network = tenant-isolated network HostConfig hostConfig = HostConfig.newHostConfig() .withRestartPolicy(RestartPolicy.unlessStoppedRestart()) - .withNetworkMode(props.networkName()) + .withNetworkMode(tenantNetwork) .withBinds(new Bind("/var/run/docker.sock", new Volume("/var/run/docker.sock"))) .withGroupAdd(List.of("0")); @@ -174,24 +178,31 @@ public class DockerTenantProvisioner implements TenantProvisioner { .withStartPeriod(15_000_000_000L)) .exec(); - // Connect to traefik network with DNS alias + String containerId = resp.getId(); + + // Connect to shared services network (postgres, clickhouse, logto) + docker.connectToNetworkCmd() + .withNetworkId(props.networkName()) + .withContainerId(containerId) + .withContainerNetwork(new ContainerNetwork().withAliases(List.of(name))) + .exec(); + + // Connect to traefik network for routing docker.connectToNetworkCmd() .withNetworkId(props.traefikNetwork()) - .withContainerId(resp.getId()) + .withContainerId(containerId) .withContainerNetwork(new ContainerNetwork().withAliases(List.of(name))) .exec(); } - private void createUiContainer(String slug, String uiName, String serverName) { + private void createUiContainer(String slug, String uiName, String serverName, String tenantNetwork) { String prefix = "/t/" + slug; var labels = new java.util.HashMap(); labels.put("traefik.enable", "true"); - // Router: catch-all for /t/{slug}/* (lower priority than server API) labels.put("traefik.http.routers.ui-" + slug + ".rule", "PathPrefix(`" + prefix + "`)"); labels.put("traefik.http.routers.ui-" + slug + ".tls", "true"); labels.put("traefik.http.routers.ui-" + slug + ".priority", "5"); - // Strip /t/{slug} prefix so UI sees / labels.put("traefik.http.middlewares.ui-strip-" + slug + ".stripprefix.prefixes", prefix); labels.put("traefik.http.routers.ui-" + slug + ".middlewares", "ui-strip-" + slug); labels.put("traefik.http.services.ui-" + slug + ".loadbalancer.server.port", "80"); @@ -203,9 +214,10 @@ public class DockerTenantProvisioner implements TenantProvisioner { "API_URL=http://" + serverName + ":8081" ); + // Primary network = tenant network (can reach server via DNS) HostConfig hostConfig = HostConfig.newHostConfig() .withRestartPolicy(RestartPolicy.unlessStoppedRestart()) - .withNetworkMode(props.networkName()); + .withNetworkMode(tenantNetwork); CreateContainerResponse resp = docker.createContainerCmd(props.serverUiImage()) .withName(uiName) @@ -214,6 +226,7 @@ public class DockerTenantProvisioner implements TenantProvisioner { .withHostConfig(hostConfig) .exec(); + // Connect to traefik for routing docker.connectToNetworkCmd() .withNetworkId(props.traefikNetwork()) .withContainerId(resp.getId()) @@ -269,6 +282,30 @@ public class DockerTenantProvisioner implements TenantProvisioner { } catch (NotFoundException ignored) {} } + private void ensureNetwork(String networkName) { + try { + docker.inspectNetworkCmd().withNetworkId(networkName).exec(); + log.debug("Network '{}' already exists", networkName); + } catch (NotFoundException e) { + docker.createNetworkCmd() + .withName(networkName) + .withDriver("bridge") + .withInternal(true) // no external access — isolated + .exec(); + log.info("Created isolated tenant network: {}", networkName); + } + } + + private void removeNetwork(String networkName) { + try { + docker.removeNetworkCmd(networkName).exec(); + log.info("Removed tenant network: {}", networkName); + } catch (NotFoundException ignored) { + } catch (Exception e) { + log.warn("Failed to remove network '{}': {}", networkName, e.getMessage()); + } + } + private String serverContainerName(String slug) { return "cameleer-server-" + slug; } @@ -276,4 +313,8 @@ public class DockerTenantProvisioner implements TenantProvisioner { private String uiContainerName(String slug) { return "cameleer-server-ui-" + slug; } + + private String tenantNetworkName(String slug) { + return "cameleer-tenant-" + slug; + } }