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) {
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<String, String>();
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<String, String>();
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;
}
}