|
14 | 14 | from pyln.client import Plugin |
15 | 15 | from pyln.client.plugin import PluginLogHandler |
16 | 16 |
|
17 | | -import ephemeral_port_reserve # type: ignore |
18 | 17 | import tempfile |
19 | 18 | import errno |
20 | 19 | import json |
@@ -178,12 +177,96 @@ def get_tx_p2wsh_outnum(bitcoind, tx, amount): |
178 | 177 | _PORT_LOCK_DIR = Path(tempfile.gettempdir()) / "pyln-testing-ports" |
179 | 178 | _PORT_LOCK_DIR.mkdir(exist_ok=True) |
180 | 179 |
|
| 180 | +# If we never hand out this many ports, something is wrong with our |
| 181 | +# ephemeral-range detection, and allocating from the OS range is a risky |
| 182 | +# fallback we'd rather fail loudly about. |
| 183 | +_MIN_RESERVED_PORTS = 2048 |
| 184 | + |
| 185 | + |
| 186 | +def ephemeral_port_range(): |
| 187 | + """Return the (lo, hi) of the OS ephemeral source-port range, or None. |
| 188 | +
|
| 189 | + The kernel only ever assigns *source* ports from this range, so a listen |
| 190 | + socket bound to a port *outside* it can never collide with an active |
| 191 | + OS-assigned connection. Linux/BSD expose it via /proc; macOS via sysctl. |
| 192 | + """ |
| 193 | + try: |
| 194 | + with open('/proc/sys/net/ipv4/ip_local_port_range') as f: |
| 195 | + lo, hi = (int(x) for x in f.read().split()) |
| 196 | + return lo, hi |
| 197 | + except OSError: |
| 198 | + pass |
| 199 | + try: |
| 200 | + import subprocess |
| 201 | + lo = int(subprocess.check_output( |
| 202 | + ['sysctl', '-n', 'net.inet.ip.portrange.first']).strip()) |
| 203 | + hi = int(subprocess.check_output( |
| 204 | + ['sysctl', '-n', 'net.inet.ip.portrange.last']).strip()) |
| 205 | + return lo, hi |
| 206 | + except Exception: |
| 207 | + # Unknown platform: assume the Linux default. |
| 208 | + return None |
| 209 | + |
| 210 | + |
| 211 | +def _reserved_port_pool(): |
| 212 | + """Return (lo, hi) of the port range we may bind. |
| 213 | +
|
| 214 | + We prefer the region strictly below the OS ephemeral floor (which is never |
| 215 | + used for source ports, so a test's listen port cannot be stolen by a |
| 216 | + *binding* foreign process either, except a deliberate one). If a machine |
| 217 | + runs with a very low ephemeral floor we fall back to above the ceiling, |
| 218 | + and finally to the ephemeral range itself as a best effort. |
| 219 | + """ |
| 220 | + rng = ephemeral_port_range() |
| 221 | + if rng is None: |
| 222 | + lo, hi = 32768, 60999 |
| 223 | + else: |
| 224 | + lo, hi = rng |
| 225 | + |
| 226 | + if lo > 1024 and lo - 1024 >= _MIN_RESERVED_PORTS: |
| 227 | + return 1024, lo - 1 |
| 228 | + if hi < 65535 and 65535 - hi >= _MIN_RESERVED_PORTS: |
| 229 | + return hi + 1, 65535 |
| 230 | + # No room anywhere else: e.g. an ephemeral range covering everything. |
| 231 | + return lo, hi |
| 232 | + |
| 233 | + |
| 234 | +def _port_is_free(port): |
| 235 | + """Return True if 127.0.0.1:port can be bound right now. |
| 236 | +
|
| 237 | + This is an actual bind() test (like the old ephemeral_port_reserve), so we |
| 238 | + never hand out a port that is genuinely claimed *right now*, as opposed to |
| 239 | + merely reserved via lockfile. We use SO_REUSEADDR to match the daemons, so |
| 240 | + a lingering TIME_WAIT socket doesn't count as "in use". |
| 241 | + """ |
| 242 | + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| 243 | + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) |
| 244 | + try: |
| 245 | + s.bind(('127.0.0.1', port)) |
| 246 | + return True |
| 247 | + except OSError as e: |
| 248 | + if e.errno != errno.EADDRINUSE: |
| 249 | + raise |
| 250 | + return False |
| 251 | + finally: |
| 252 | + s.close() |
| 253 | + |
181 | 254 |
|
182 | 255 | def reserve_unused_port(): |
183 | | - """Get an unused port: avoids handing out the same port unless it's been |
184 | | - returned""" |
| 256 | + """Get an unused port from the non-ephemeral pool. |
| 257 | +
|
| 258 | + We test-bind the candidate so a port claimed by anything at all right now |
| 259 | + is skipped, then take the filesystem lock so our *own* workers never pick |
| 260 | + the same genuinely free port concurrently. Note the lockfile only |
| 261 | + coordinates our own allocations: the real protection against the OS |
| 262 | + stealing a port via source-port assignment is that the pool lies outside |
| 263 | + the ephemeral source-port range. |
| 264 | + """ |
| 265 | + pool_lo, pool_hi = _reserved_port_pool() |
185 | 266 | while True: |
186 | | - port = ephemeral_port_reserve.reserve() |
| 267 | + port = random.randint(pool_lo, pool_hi) |
| 268 | + if not _port_is_free(port): |
| 269 | + continue |
187 | 270 |
|
188 | 271 | lock_path = _PORT_LOCK_DIR / f"{port}.lock" |
189 | 272 | try: |
@@ -227,23 +310,11 @@ def wait_for_port_released(port, timeout=TIMEOUT): |
227 | 310 | connectd fail with 'Address already in use'. |
228 | 311 | """ |
229 | 312 | start_time = time.time() |
230 | | - while True: |
231 | | - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
232 | | - # Match connectd's SO_REUSEADDR, so sockets lingering in |
233 | | - # TIME_WAIT don't count as "still in use". |
234 | | - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) |
235 | | - try: |
236 | | - s.bind(('127.0.0.1', port)) |
237 | | - break |
238 | | - except OSError as e: |
239 | | - if e.errno != errno.EADDRINUSE: |
240 | | - raise |
241 | | - if time.time() - start_time > timeout: |
242 | | - raise TimeoutError( |
243 | | - "Port {} was not released within {} seconds" |
244 | | - .format(port, timeout)) |
245 | | - finally: |
246 | | - s.close() |
| 313 | + while not _port_is_free(port): |
| 314 | + if time.time() - start_time > timeout: |
| 315 | + raise TimeoutError( |
| 316 | + "Port {} was not released within {} seconds" |
| 317 | + .format(port, timeout)) |
247 | 318 | time.sleep(0.1) |
248 | 319 |
|
249 | 320 | waited = time.time() - start_time |
@@ -564,6 +635,8 @@ def kill(self): |
564 | 635 |
|
565 | 636 | self.cleanup_files() |
566 | 637 | drop_unused_port(self.rpcport) |
| 638 | + for p in self.proxies: |
| 639 | + drop_unused_port(p.rpcport) |
567 | 640 |
|
568 | 641 | def start(self, wallet_file=None): |
569 | 642 | if not self.port_setup: |
@@ -594,7 +667,7 @@ def stop(self): |
594 | 667 | return TailableProc.stop(self) |
595 | 668 |
|
596 | 669 | def get_proxy(self): |
597 | | - proxy = BitcoinRpcProxy(self) |
| 670 | + proxy = BitcoinRpcProxy(self, rpcport=reserve_unused_port()) |
598 | 671 | self.proxies.append(proxy) |
599 | 672 | proxy.start() |
600 | 673 | return proxy |
|
0 commit comments