Skip to content

Commit a46ae06

Browse files
fix(compose): confine dynamic addresses to 10.10.10.128/26 (#972)
The network let Docker allocate dynamic addresses from the bottom of 10.10.10.0/24 while nostream, nostream-db and nostream-cache were pinned to .2-.4. otel-collector has no dependency that orders it after nostream, so it could be given .2 first and nostream then failed to start with "Address already in use" - most reliably after a host reboot, when the daemon starts containers in arbitrary order. An ip_range keeps the subnet and gateway as they were, so 10.10.10.1 remains the trusted proxy and the overlay pins (.5, .252, .253) stay outside the pool. A unit test holds every static address outside the dynamic pool in every compose file, so a future pin cannot silently land inside it. Closes #929
1 parent 75ccb0e commit a46ae06

3 files changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"nostream": patch
3+
---
4+
5+
fix(compose): keep dynamic container addresses off the static ones
6+
7+
`docker-compose.yml` pins `nostream`, `nostream-db` and `nostream-cache` to `10.10.10.2`–`.4`, but dynamic allocation drew from the bottom of the same `/24`. The observability stack has no dependency that orders it after `nostream`, so `otel-collector` could be given `.2` and `nostream` then failed to start with `Address already in use` — most reliably after a host reboot, when the daemon starts containers in arbitrary order.
8+
9+
Dynamic allocation is now confined to `10.10.10.128/26`. The subnet and gateway are unchanged, so `10.10.10.1` stays the trusted proxy, and the overlay pins (`.5`, `.252`, `.253`) stay outside the pool.

‎docker-compose.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,17 @@ networks:
210210
ipam:
211211
driver: default
212212
config:
213+
# Dynamic addresses are confined to .128/26 so they can never land on a
214+
# static one: nostream is .2, and the observability stack takes the first
215+
# three free addresses — which were .2, .3 and .4, taken by nostream,
216+
# nostream-db and nostream-cache. Docker allocates from the bottom of the
217+
# subnet, and otel-collector has no dependency that would order it after
218+
# nostream, so whichever container came up first won the address and the
219+
# other failed with "Address already in use". The overlays pin .5
220+
# (local), .252 (i2p) and .253 (tor) for the same reason.
213221
- subnet: 10.10.10.0/24
222+
gateway: 10.10.10.1
223+
ip_range: 10.10.10.128/26
214224

215225
volumes:
216226
cache:
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import chai from 'chai'
2+
import { readFileSync, readdirSync } from 'fs'
3+
import yaml from 'js-yaml'
4+
import { join } from 'path'
5+
6+
const { expect } = chai
7+
8+
/**
9+
* Docker allocates dynamic addresses from the bottom of a subnet, while several
10+
* services are pinned by `ipv4_address`. Without an `ip_range` the two pools
11+
* overlap, and which container wins is decided by creation order — so
12+
* `otel-collector` could take `10.10.10.2` from `nostream` and the relay then
13+
* fails with "Address already in use" (#929). These checks hold the pools apart.
14+
*/
15+
16+
type Service = {
17+
networks?: Record<string, { ipv4_address?: string } | string> | string[]
18+
}
19+
20+
type Network = {
21+
ipam?: { config?: Array<{ subnet?: string; gateway?: string; ip_range?: string }> }
22+
}
23+
24+
type Compose = { services?: Record<string, Service>; networks?: Record<string, Network> }
25+
26+
const composeFiles = (): string[] =>
27+
readdirSync(process.cwd())
28+
.filter((file) => /^docker-compose(\.[\w-]+)?\.ya?ml$/.test(file))
29+
.sort()
30+
31+
const load = (file: string): Compose =>
32+
yaml.load(readFileSync(join(process.cwd(), file), 'utf-8')) as Compose
33+
34+
/** Every static address a compose file pins, as `file: service -> address`. */
35+
const staticAddresses = (): Array<{ file: string; service: string; address: string }> =>
36+
composeFiles().flatMap((file) => {
37+
const services = load(file).services ?? {}
38+
39+
return Object.entries(services).flatMap(([name, service]) => {
40+
const networks = service.networks
41+
if (!networks || Array.isArray(networks) || typeof networks === 'string') return []
42+
43+
return Object.values(networks)
44+
.filter((entry): entry is { ipv4_address?: string } => typeof entry === 'object' && entry !== null)
45+
.filter((entry) => typeof entry.ipv4_address === 'string')
46+
.map((entry) => ({ file, service: name, address: entry.ipv4_address as string }))
47+
})
48+
})
49+
50+
const ipv4ToInt = (address: string): number =>
51+
address.split('.').reduce((total, octet) => total * 256 + Number(octet), 0)
52+
53+
const parseCidr = (cidr: string): { base: number; size: number } => {
54+
const [address, bits] = cidr.split('/')
55+
56+
return { base: ipv4ToInt(address), size: 2 ** (32 - Number(bits)) }
57+
}
58+
59+
const contains = (cidr: string, address: string): boolean => {
60+
const { base, size } = parseCidr(cidr)
61+
const value = ipv4ToInt(address)
62+
63+
return value >= base && value < base + size
64+
}
65+
66+
describe('docker-compose network pools', () => {
67+
const defaultNetwork = load('docker-compose.yml').networks?.default
68+
const ipam = defaultNetwork?.ipam?.config?.[0] ?? {}
69+
70+
it('declares an ip_range beside the static addresses', () => {
71+
expect(ipam.subnet, 'docker-compose.yml must declare a subnet').to.be.a('string')
72+
expect(
73+
ipam.ip_range,
74+
'docker-compose.yml must confine dynamic allocation with ip_range, or it can hand a pinned address to another container'
75+
).to.be.a('string')
76+
})
77+
78+
it('keeps the dynamic range inside the subnet', () => {
79+
const { base, size } = parseCidr(ipam.ip_range as string)
80+
const subnet = parseCidr(ipam.subnet as string)
81+
82+
expect(base).to.be.at.least(subnet.base)
83+
expect(base + size).to.be.at.most(subnet.base + subnet.size)
84+
})
85+
86+
it('keeps the gateway outside the dynamic range', () => {
87+
expect(contains(ipam.subnet as string, ipam.gateway as string), 'gateway must be in the subnet').to.equal(true)
88+
expect(contains(ipam.ip_range as string, ipam.gateway as string), 'gateway must not be allocatable').to.equal(
89+
false
90+
)
91+
})
92+
93+
it('keeps every static address outside the dynamic range', () => {
94+
const addresses = staticAddresses()
95+
96+
expect(addresses.length, 'no static addresses found — the extraction is broken').to.be.greaterThan(0)
97+
98+
for (const { file, service, address } of addresses) {
99+
expect(
100+
contains(ipam.subnet as string, address),
101+
`${file}: ${service} pins ${address}, which is outside the declared subnet`
102+
).to.equal(true)
103+
expect(
104+
contains(ipam.ip_range as string, address),
105+
`${file}: ${service} pins ${address}, which is inside the dynamic pool ${String(ipam.ip_range)}`
106+
).to.equal(false)
107+
}
108+
})
109+
})

0 commit comments

Comments
 (0)