In production, GET /api/Cluster/nodes returns 500 every time. Seq + container logs show:
System.Net.Http.HttpRequestException: Connection failed
---> System.Net.Sockets.SocketException (99): Cannot assign requested address
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ConnectAsync(...)
at Docker.DotNet.DockerClient.<>c__DisplayClass6_0.<<-ctor>b__1>d.MoveNext()
at Docker.DotNet.SwarmOperations.Docker.DotNet.ISwarmOperations.ListNodesAsync(...)
at SpikerSoft.Business.Services.DockerClusterTopologyService.GetTopologyAsync(...)
Root cause
The spikersoft-backend API container was wired with Docker.DotNet and configured to talk to the Docker daemon directly:
The API container has no/var/run/docker.sock volume mount.
The API runs on a Swarm overlay network where 172.17.0.1 (the host's docker0 bridge gateway) and host.docker.internal (Desktop-only) are unroutable, hence EADDRNOTAVAIL.
Even if it could reach a daemon, exposing the Docker socket to the API replica would give every API RCE root-on-host privileges.
Meanwhile the spikersoft-docker-monitor service was healthy the whole time — it has the socket mounted, refreshes its cache every 30s, and publishes events to RabbitMQ. The API just wasn't using it.
Fix (architectural — option A from chat)
Make the docker-monitor the only service in the swarm with Docker socket access, and have the API consume snapshots through Redis.
SpikerSoft.EventHandlers.DockerMonitor
New DockerClusterTopologyMapper static helper (Docker.DotNet → neutral ClusterTopologyDto); moved out of the API project.
DockerCacheService gains Get/SetClusterTopologyAsync writing under SpikerSoft:docker:cluster:topology (5-min TTL, matching the existing services and daemon:info keys).
DockerMonitorService.RefreshCacheAsync builds and caches a fresh snapshot on every 30s tick.
Rewritten to read from IDistributedCache only — no IDockerClient dependency.
Stale-snapshot threshold (2 min): logs loudly but still returns the data; better than going dark on a transient monitor outage.
Throws new ClusterTopologyUnavailableException for missing / corrupt / Redis-down.
SpikerSoft.Api
Registers IDistributedCache via AddStackExchangeRedisCache with InstanceName = "SpikerSoft:" to match the monitor's key namespace.
ClusterController.GetTopology maps ClusterTopologyUnavailableException → HTTP 503 (so the dashboard can retry on cold-start instead of showing a hard error).
Tests
DockerClusterTopologyMapperTests (5 tests) — moved mapping behavior coverage to its new home.
All 10 pass; full Cluster/DockerSwarm/DockerPhase suite (127 tests) still green.
Effect
API replica no longer needs Docker socket access (no new attack surface).
One privileged consumer of /var/run/docker.sock cluster-wide.
503 for cold-starts is distinguishable from 500 for actual bugs.
Same Redis snapshot can serve a future Kubernetes monitor with no API changes.
Out of scope
The hard-coded tcp://172.17.0.1:2375 fallback in CreateDockerClient is still used by DockerSwarmService (legacy /api/DockerSwarm controller). That path also needs to be moved off of direct Docker.DotNet at some point, but it's not on the hot dashboard path so it isn't blocking.
## Symptom
In production, `GET /api/Cluster/nodes` returns 500 every time. Seq + container logs show:
```
System.Net.Http.HttpRequestException: Connection failed
---> System.Net.Sockets.SocketException (99): Cannot assign requested address
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ConnectAsync(...)
at Docker.DotNet.DockerClient.<>c__DisplayClass6_0.<<-ctor>b__1>d.MoveNext()
at Docker.DotNet.SwarmOperations.Docker.DotNet.ISwarmOperations.ListNodesAsync(...)
at SpikerSoft.Business.Services.DockerClusterTopologyService.GetTopologyAsync(...)
```
## Root cause
The `spikersoft-backend` API container was wired with `Docker.DotNet` and configured to talk to the Docker daemon directly:
```csharp
// SpikerSoft.Api/Extensions/ServiceCollectionExtensions.cs
if (string.IsNullOrEmpty(dockerUri))
{
dockerUri = "tcp://172.17.0.1:2375";
}
```
In production:
1. The API container has **no** `/var/run/docker.sock` volume mount.
2. The API runs on a Swarm overlay network where `172.17.0.1` (the host's `docker0` bridge gateway) and `host.docker.internal` (Desktop-only) are unroutable, hence `EADDRNOTAVAIL`.
3. Even if it could reach a daemon, exposing the Docker socket to the API replica would give every API RCE root-on-host privileges.
Meanwhile the `spikersoft-docker-monitor` service was healthy the whole time — it has the socket mounted, refreshes its cache every 30s, and publishes events to RabbitMQ. The API just wasn't using it.
## Fix (architectural — option A from chat)
Make the docker-monitor the *only* service in the swarm with Docker socket access, and have the API consume snapshots through Redis.
1. **`SpikerSoft.EventHandlers.DockerMonitor`**
- New `DockerClusterTopologyMapper` static helper (Docker.DotNet → neutral `ClusterTopologyDto`); moved out of the API project.
- `DockerCacheService` gains `Get/SetClusterTopologyAsync` writing under `SpikerSoft:docker:cluster:topology` (5-min TTL, matching the existing `services` and `daemon:info` keys).
- `DockerMonitorService.RefreshCacheAsync` builds and caches a fresh snapshot on every 30s tick.
2. **`SpikerSoft.Business.Services.DockerClusterTopologyService`**
- Rewritten to read from `IDistributedCache` only — no `IDockerClient` dependency.
- Stale-snapshot threshold (2 min): logs loudly but still returns the data; better than going dark on a transient monitor outage.
- Throws new `ClusterTopologyUnavailableException` for missing / corrupt / Redis-down.
3. **`SpikerSoft.Api`**
- Registers `IDistributedCache` via `AddStackExchangeRedisCache` with `InstanceName = "SpikerSoft:"` to match the monitor's key namespace.
- `ClusterController.GetTopology` maps `ClusterTopologyUnavailableException` → HTTP 503 (so the dashboard can retry on cold-start instead of showing a hard error).
4. **Tests**
- `DockerClusterTopologyMapperTests` (5 tests) — moved mapping behavior coverage to its new home.
- `DockerClusterTopologyServiceTests` rewritten (5 tests) — cache hit, missing, corrupt, Redis exception, stale-but-served.
- All 10 pass; full Cluster/DockerSwarm/DockerPhase suite (127 tests) still green.
## Effect
- API replica no longer needs Docker socket access (no new attack surface).
- One privileged consumer of `/var/run/docker.sock` cluster-wide.
- 503 for cold-starts is distinguishable from 500 for actual bugs.
- Same Redis snapshot can serve a future Kubernetes monitor with no API changes.
## Out of scope
- The hard-coded `tcp://172.17.0.1:2375` fallback in `CreateDockerClient` is still used by `DockerSwarmService` (legacy `/api/DockerSwarm` controller). That path also needs to be moved off of direct Docker.DotNet at some point, but it's not on the hot dashboard path so it isn't blocking.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Symptom
In production,
GET /api/Cluster/nodesreturns 500 every time. Seq + container logs show:Root cause
The
spikersoft-backendAPI container was wired withDocker.DotNetand configured to talk to the Docker daemon directly:In production:
/var/run/docker.sockvolume mount.172.17.0.1(the host'sdocker0bridge gateway) andhost.docker.internal(Desktop-only) are unroutable, henceEADDRNOTAVAIL.Meanwhile the
spikersoft-docker-monitorservice was healthy the whole time — it has the socket mounted, refreshes its cache every 30s, and publishes events to RabbitMQ. The API just wasn't using it.Fix (architectural — option A from chat)
Make the docker-monitor the only service in the swarm with Docker socket access, and have the API consume snapshots through Redis.
SpikerSoft.EventHandlers.DockerMonitorDockerClusterTopologyMapperstatic helper (Docker.DotNet → neutralClusterTopologyDto); moved out of the API project.DockerCacheServicegainsGet/SetClusterTopologyAsyncwriting underSpikerSoft:docker:cluster:topology(5-min TTL, matching the existingservicesanddaemon:infokeys).DockerMonitorService.RefreshCacheAsyncbuilds and caches a fresh snapshot on every 30s tick.SpikerSoft.Business.Services.DockerClusterTopologyServiceIDistributedCacheonly — noIDockerClientdependency.ClusterTopologyUnavailableExceptionfor missing / corrupt / Redis-down.SpikerSoft.ApiIDistributedCacheviaAddStackExchangeRedisCachewithInstanceName = "SpikerSoft:"to match the monitor's key namespace.ClusterController.GetTopologymapsClusterTopologyUnavailableException→ HTTP 503 (so the dashboard can retry on cold-start instead of showing a hard error).DockerClusterTopologyMapperTests(5 tests) — moved mapping behavior coverage to its new home.DockerClusterTopologyServiceTestsrewritten (5 tests) — cache hit, missing, corrupt, Redis exception, stale-but-served.Effect
/var/run/docker.sockcluster-wide.Out of scope
tcp://172.17.0.1:2375fallback inCreateDockerClientis still used byDockerSwarmService(legacy/api/DockerSwarmcontroller). That path also needs to be moved off of direct Docker.DotNet at some point, but it's not on the hot dashboard path so it isn't blocking.