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) {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user