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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user