feat: per-tenant network isolation
All checks were successful
CI / build (push) Successful in 52s
CI / docker (push) Successful in 33s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 08:04:11 +02:00
parent 0a43a7dcd1
commit f5ef8e6488

View File

@@ -40,16 +40,20 @@ public class DockerTenantProvisioner implements TenantProvisioner {
public ProvisionResult provision(TenantProvisionRequest req) { public ProvisionResult provision(TenantProvisionRequest req) {
String serverName = serverContainerName(req.slug()); String serverName = serverContainerName(req.slug());
String uiName = uiContainerName(req.slug()); String uiName = uiContainerName(req.slug());
String tenantNetwork = tenantNetworkName(req.slug());
String endpoint = "http://" + serverName + ":8081"; String endpoint = "http://" + serverName + ":8081";
try { try {
pullIfMissing(props.serverImage()); pullIfMissing(props.serverImage());
pullIfMissing(props.serverUiImage()); pullIfMissing(props.serverUiImage());
createServerContainer(req, serverName); // Create isolated tenant network
ensureNetwork(tenantNetwork);
createServerContainer(req, serverName, tenantNetwork);
docker.startContainerCmd(serverName).exec(); docker.startContainerCmd(serverName).exec();
createUiContainer(req.slug(), uiName, serverName); createUiContainer(req.slug(), uiName, serverName, tenantNetwork);
docker.startContainerCmd(uiName).exec(); docker.startContainerCmd(uiName).exec();
if (!waitForHealth(serverName, 60)) { if (!waitForHealth(serverName, 60)) {
@@ -90,6 +94,7 @@ public class DockerTenantProvisioner implements TenantProvisioner {
public void remove(String slug) { public void remove(String slug) {
removeContainer(uiContainerName(slug)); removeContainer(uiContainerName(slug));
removeContainer(serverContainerName(slug)); removeContainer(serverContainerName(slug));
removeNetwork(tenantNetworkName(slug));
} }
@Override @Override
@@ -112,19 +117,17 @@ public class DockerTenantProvisioner implements TenantProvisioner {
return "http://" + serverContainerName(slug) + ":8081"; 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 slug = req.slug();
String prefix = "/t/" + slug; String prefix = "/t/" + slug;
// Traefik labels — need >10 entries, use HashMap // Traefik labels — need >10 entries, use HashMap
var labels = new java.util.HashMap<String, String>(); var labels = new java.util.HashMap<String, String>();
labels.put("traefik.enable", "true"); labels.put("traefik.enable", "true");
// Router: match /t/{slug}/api/* and /t/{slug}/actuator/*
labels.put("traefik.http.routers.server-" + slug + ".rule", labels.put("traefik.http.routers.server-" + slug + ".rule",
"PathPrefix(`" + prefix + "/api`) || PathPrefix(`" + prefix + "/actuator`)"); "PathPrefix(`" + prefix + "/api`) || PathPrefix(`" + prefix + "/actuator`)");
labels.put("traefik.http.routers.server-" + slug + ".tls", "true"); labels.put("traefik.http.routers.server-" + slug + ".tls", "true");
labels.put("traefik.http.routers.server-" + slug + ".priority", "10"); 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.middlewares.server-strip-" + slug + ".stripprefix.prefixes", prefix);
labels.put("traefik.http.routers.server-" + slug + ".middlewares", "server-strip-" + slug); labels.put("traefik.http.routers.server-" + slug + ".middlewares", "server-strip-" + slug);
labels.put("traefik.http.services.server-" + slug + ".loadbalancer.server.port", "8081"); 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_DOMAIN=" + props.publicHost(),
"CAMELEER_ROUTING_MODE=path", "CAMELEER_ROUTING_MODE=path",
"CAMELEER_JAR_STORAGE_PATH=/data/jars", "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 "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() HostConfig hostConfig = HostConfig.newHostConfig()
.withRestartPolicy(RestartPolicy.unlessStoppedRestart()) .withRestartPolicy(RestartPolicy.unlessStoppedRestart())
.withNetworkMode(props.networkName()) .withNetworkMode(tenantNetwork)
.withBinds(new Bind("/var/run/docker.sock", new Volume("/var/run/docker.sock"))) .withBinds(new Bind("/var/run/docker.sock", new Volume("/var/run/docker.sock")))
.withGroupAdd(List.of("0")); .withGroupAdd(List.of("0"));
@@ -174,24 +178,31 @@ public class DockerTenantProvisioner implements TenantProvisioner {
.withStartPeriod(15_000_000_000L)) .withStartPeriod(15_000_000_000L))
.exec(); .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() docker.connectToNetworkCmd()
.withNetworkId(props.traefikNetwork()) .withNetworkId(props.traefikNetwork())
.withContainerId(resp.getId()) .withContainerId(containerId)
.withContainerNetwork(new ContainerNetwork().withAliases(List.of(name))) .withContainerNetwork(new ContainerNetwork().withAliases(List.of(name)))
.exec(); .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; String prefix = "/t/" + slug;
var labels = new java.util.HashMap<String, String>(); var labels = new java.util.HashMap<String, String>();
labels.put("traefik.enable", "true"); 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 + ".rule", "PathPrefix(`" + prefix + "`)");
labels.put("traefik.http.routers.ui-" + slug + ".tls", "true"); labels.put("traefik.http.routers.ui-" + slug + ".tls", "true");
labels.put("traefik.http.routers.ui-" + slug + ".priority", "5"); 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.middlewares.ui-strip-" + slug + ".stripprefix.prefixes", prefix);
labels.put("traefik.http.routers.ui-" + slug + ".middlewares", "ui-strip-" + slug); labels.put("traefik.http.routers.ui-" + slug + ".middlewares", "ui-strip-" + slug);
labels.put("traefik.http.services.ui-" + slug + ".loadbalancer.server.port", "80"); 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" "API_URL=http://" + serverName + ":8081"
); );
// Primary network = tenant network (can reach server via DNS)
HostConfig hostConfig = HostConfig.newHostConfig() HostConfig hostConfig = HostConfig.newHostConfig()
.withRestartPolicy(RestartPolicy.unlessStoppedRestart()) .withRestartPolicy(RestartPolicy.unlessStoppedRestart())
.withNetworkMode(props.networkName()); .withNetworkMode(tenantNetwork);
CreateContainerResponse resp = docker.createContainerCmd(props.serverUiImage()) CreateContainerResponse resp = docker.createContainerCmd(props.serverUiImage())
.withName(uiName) .withName(uiName)
@@ -214,6 +226,7 @@ public class DockerTenantProvisioner implements TenantProvisioner {
.withHostConfig(hostConfig) .withHostConfig(hostConfig)
.exec(); .exec();
// Connect to traefik for routing
docker.connectToNetworkCmd() docker.connectToNetworkCmd()
.withNetworkId(props.traefikNetwork()) .withNetworkId(props.traefikNetwork())
.withContainerId(resp.getId()) .withContainerId(resp.getId())
@@ -269,6 +282,30 @@ public class DockerTenantProvisioner implements TenantProvisioner {
} catch (NotFoundException ignored) {} } 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) { private String serverContainerName(String slug) {
return "cameleer-server-" + slug; return "cameleer-server-" + slug;
} }
@@ -276,4 +313,8 @@ public class DockerTenantProvisioner implements TenantProvisioner {
private String uiContainerName(String slug) { private String uiContainerName(String slug) {
return "cameleer-server-ui-" + slug; return "cameleer-server-ui-" + slug;
} }
private String tenantNetworkName(String slug) {
return "cameleer-tenant-" + slug;
}
} }