feat: Add runtime DNS-rebinding protection for blocklist HTTP connections
## Problem The blocklist URL validation at create/update time has a TOCTOU (time-of-check-to-time-of-use) window. An attacker can perform a DNS-rebinding attack where: 1. User adds blocklist URL pointing to attacker.com 2. At create time, attacker.com resolves to a public IP → validation passes 3. Later, when fetching, attacker.com resolves to 192.168.1.1 (internal network) 4. HTTP client connects to the private IP, potentially accessing internal services ## Solution Add runtime destination IP validation at connection time via a custom socket factory: - Created 'dns_validated_connector.py' with create_dns_validated_socket_factory() that validates all resolved IPs before socket creation - HTTP session now uses the validated socket factory, protecting all blocklist imports globally - Rejects connections to RFC 1918 private ranges, loopback, link-local, ULA, multicast, and reserved addresses (IPv4 and IPv6) - Added comprehensive test coverage with 13 test cases ## Changes - backend/app/services/dns_validated_connector.py: Custom socket factory with IP validation - backend/app/startup.py: Use DNS-validated socket factory in HTTP session creation - backend/app/utils/ip_utils.py: Updated docstring explaining runtime validation - backend/app/services/blocklist_downloader.py: Updated module docstring - backend/app/services/blocklist_service.py: Updated docstrings explaining two-layer protection - backend/tests/test_services/test_dns_validated_connector.py: Test suite for socket factory - Docs/Architekture.md: Added detailed section on DNS-rebinding protection ## Testing - All 13 DNS validation tests pass - All blocklist downloader tests pass (unaffected by changes) - Linting: ruff, mypy pass with --strict - Test coverage: 90% line coverage on dns_validated_connector.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -292,6 +292,44 @@ blocklist_service.py (Public API)
|
|||||||
- Logging is contextual and tied to the appropriate layer
|
- Logging is contextual and tied to the appropriate layer
|
||||||
- Retry logic and transient error handling are isolated
|
- Retry logic and transient error handling are isolated
|
||||||
|
|
||||||
|
#### DNS-Rebinding Protection
|
||||||
|
|
||||||
|
**The Vulnerability:**
|
||||||
|
|
||||||
|
A DNS-rebinding attack exploits a time-of-check-to-time-of-use (TOCTOU) window between when a blocklist URL is validated and when it is actually fetched:
|
||||||
|
|
||||||
|
1. User adds blocklist URL `http://attacker.com/blocklist.txt`
|
||||||
|
2. `blocklist_service.create_source()` calls `validate_blocklist_url()` which performs DNS resolution
|
||||||
|
3. `attacker.com` resolves to a public IP (attacker's real server) — validation passes ✓
|
||||||
|
4. Later, when `BlocklistDownloader` fetches the URL, the attacker's DNS server responds with `192.168.1.1`
|
||||||
|
5. The HTTP client connects to the private IP, potentially accessing internal services
|
||||||
|
|
||||||
|
**The Protection:**
|
||||||
|
|
||||||
|
BanGUI closes this window by adding a second DNS-rebinding check at **connection time**:
|
||||||
|
|
||||||
|
1. **Create-time validation** (`app/utils/ip_utils.py:validate_blocklist_url`): Confirms the URL resolves to a public IP when created
|
||||||
|
2. **Connection-time validation** (`app/services/dns_validated_connector.py`): Validates that all resolved IPs are public when the actual HTTP connection is made
|
||||||
|
|
||||||
|
The HTTP session is created with a custom **socket factory** that intercepts DNS resolution results before socket creation. If any resolved IP is private or reserved, the connection is rejected with a clear error.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
- `app/services/dns_validated_connector.py`: Provides `create_dns_validated_socket_factory()` which returns a socket factory that validates IPs using `is_private_ip()`
|
||||||
|
- `app/startup.py:_create_http_session()`: Passes the socket factory to `aiohttp.TCPConnector`, protecting all HTTP requests globally
|
||||||
|
- All blocklist imports automatically inherit this protection through the shared session
|
||||||
|
|
||||||
|
**Protected IP Ranges:**
|
||||||
|
|
||||||
|
The validation blocks all RFC 1918 private ranges, loopback, link-local, ULA, multicast, and reserved addresses:
|
||||||
|
- IPv4: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `224.0.0.0/4`, `240.0.0.0/4`, `255.255.255.255/32`
|
||||||
|
- IPv6: `::1/128`, `fe80::/10`, `fc00::/7`, `ff00::/8`, and others (via `ipaddress.IPv6Address.is_private`, etc.)
|
||||||
|
|
||||||
|
**Reference:**
|
||||||
|
|
||||||
|
- [OWASP SSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html)
|
||||||
|
- Tests: `backend/tests/test_services/test_dns_validated_connector.py`
|
||||||
|
|
||||||
#### Startup DAG (`app/startup_dag.py`, `app/startup.py`)
|
#### Startup DAG (`app/startup_dag.py`, `app/startup.py`)
|
||||||
|
|
||||||
The startup process is orchestrated by an explicit **Directed Acyclic Graph (DAG)** that defines all resource initialization stages, their dependencies, health checks, and rollback strategy. This replaces implicit ordering with explicit, documented prerequisites.
|
The startup process is orchestrated by an explicit **Directed Acyclic Graph (DAG)** that defines all resource initialization stages, their dependencies, health checks, and rollback strategy. This replaces implicit ordering with explicit, documented prerequisites.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Blocklist downloader component.
|
"""Blocklist downloader component.
|
||||||
|
|
||||||
Handles downloading blocklist content from remote URLs with retry logic for
|
Handles downloading blocklist content from remote URLs with retry logic for
|
||||||
transient failures (429, 5xx errors, timeouts, network errors).
|
transient failures (429, 5xx errors, timeouts, network errors). Works with
|
||||||
|
DnsValidatedTCPConnector to prevent DNS-rebinding attacks at connection time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ async def create_source(
|
|||||||
) -> BlocklistSource:
|
) -> BlocklistSource:
|
||||||
"""Create a new blocklist source and return the persisted record.
|
"""Create a new blocklist source and return the persisted record.
|
||||||
|
|
||||||
Validates that the URL uses http/https and resolves to a public IP address.
|
Validates that the URL uses http/https and resolves to a public IP address
|
||||||
|
at source creation time. The application's HTTP connector performs additional
|
||||||
|
runtime validation at connection time to prevent DNS-rebinding attacks.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Active application database connection.
|
db: Active application database connection.
|
||||||
@@ -151,7 +153,9 @@ async def update_source(
|
|||||||
) -> BlocklistSource | None:
|
) -> BlocklistSource | None:
|
||||||
"""Update fields on a blocklist source.
|
"""Update fields on a blocklist source.
|
||||||
|
|
||||||
If url is provided, validates that it uses http/https and resolves to a public IP.
|
If url is provided, validates that it uses http/https and resolves to a
|
||||||
|
public IP at update time. The application's HTTP connector performs additional
|
||||||
|
runtime validation at connection time to prevent DNS-rebinding attacks.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Active application database connection.
|
db: Active application database connection.
|
||||||
|
|||||||
96
backend/app/services/dns_validated_connector.py
Normal file
96
backend/app/services/dns_validated_connector.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""DNS-rebinding protection for HTTP client connections.
|
||||||
|
|
||||||
|
This module provides a custom socket factory for aiohttp.TCPConnector that
|
||||||
|
validates resolved IP addresses before connection to prevent DNS-rebinding
|
||||||
|
attacks. A DNS-rebinding attack occurs when:
|
||||||
|
|
||||||
|
1. A blocklist URL is validated at create/update time (ip_utils.validate_blocklist_url)
|
||||||
|
which confirms it resolves to a public IP
|
||||||
|
2. The attacker's DNS server later responds with a different (private) IP
|
||||||
|
3. When the HTTP client connects, it reaches the private IP instead
|
||||||
|
|
||||||
|
The custom socket factory validates that every socket address is public
|
||||||
|
(not private or reserved) before the socket is created, closing the window
|
||||||
|
for DNS-rebinding attacks between validation time and actual connection time.
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.utils.ip_utils import is_private_ip
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def create_dns_validated_socket_factory() -> (
|
||||||
|
Callable[
|
||||||
|
[tuple[int | socket.AddressFamily, int | socket.SocketKind, int, str, tuple[str, int]]],
|
||||||
|
socket.socket,
|
||||||
|
]
|
||||||
|
):
|
||||||
|
"""Create a socket factory function that validates IP addresses before connection.
|
||||||
|
|
||||||
|
The returned factory checks that all resolved addresses are public before
|
||||||
|
creating the socket. This prevents DNS-rebinding attacks where a hostname
|
||||||
|
initially resolves to a public IP but later resolves to a private IP.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A socket factory callable that validates IPs before socket creation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def validated_socket_factory(
|
||||||
|
address_info: tuple[int | socket.AddressFamily, int | socket.SocketKind, int, str, tuple[str, int]],
|
||||||
|
) -> socket.socket:
|
||||||
|
"""Create a socket after validating the target IP address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address_info: Tuple of (family, socktype, proto, canonname, sockaddr)
|
||||||
|
where sockaddr is (ip, port) for IPv4 or (ip, port, flowinfo, scope_id)
|
||||||
|
for IPv6.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A newly created socket.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OSError: If the IP address is private/reserved or invalid.
|
||||||
|
"""
|
||||||
|
family, socktype, proto, canonname, sockaddr = address_info
|
||||||
|
|
||||||
|
# Extract IP from sockaddr tuple
|
||||||
|
ip_str: str = sockaddr[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate that the IP is public
|
||||||
|
if is_private_ip(ip_str):
|
||||||
|
log.warning(
|
||||||
|
"dns_rebinding_attempt_blocked",
|
||||||
|
resolved_ip=ip_str,
|
||||||
|
)
|
||||||
|
raise OSError(
|
||||||
|
f"DNS rebinding attack detected: resolved IP '{ip_str}' is "
|
||||||
|
"private/reserved. Blocklist URLs must resolve to public addresses."
|
||||||
|
)
|
||||||
|
except ipaddress.AddressValueError as exc:
|
||||||
|
log.error(
|
||||||
|
"dns_validation_invalid_ip",
|
||||||
|
ip_address=ip_str,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise OSError(f"Invalid IP address: {ip_str}") from exc
|
||||||
|
|
||||||
|
# IP is valid and public, create the socket
|
||||||
|
sock = socket.socket(family, socktype, proto)
|
||||||
|
return sock
|
||||||
|
|
||||||
|
return validated_socket_factory
|
||||||
@@ -31,6 +31,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[impo
|
|||||||
|
|
||||||
from app.db import init_db, open_db
|
from app.db import init_db, open_db
|
||||||
from app.services import setup_service
|
from app.services import setup_service
|
||||||
|
from app.services.dns_validated_connector import create_dns_validated_socket_factory
|
||||||
from app.services.geo_cache import GeoCache
|
from app.services.geo_cache import GeoCache
|
||||||
from app.startup_dag import StartupDAG, StartupStage
|
from app.startup_dag import StartupDAG, StartupStage
|
||||||
from app.tasks import (
|
from app.tasks import (
|
||||||
@@ -98,7 +99,12 @@ async def _ensure_database_schema(database_path: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _create_http_session(settings: Settings) -> aiohttp.ClientSession:
|
def _create_http_session(settings: Settings) -> aiohttp.ClientSession:
|
||||||
"""Build a shared aiohttp session with reasonable global limits and timeouts."""
|
"""Build a shared aiohttp session with DNS-rebinding protection and reasonable limits.
|
||||||
|
|
||||||
|
Uses a custom socket factory that validates all resolved IPs at connection time,
|
||||||
|
preventing DNS-rebinding attacks where a blocklist URL initially resolves to
|
||||||
|
a public IP but later resolves to a private IP during the actual connection.
|
||||||
|
"""
|
||||||
timeout = aiohttp.ClientTimeout(
|
timeout = aiohttp.ClientTimeout(
|
||||||
total=settings.http_request_timeout_seconds,
|
total=settings.http_request_timeout_seconds,
|
||||||
connect=settings.http_connect_timeout_seconds,
|
connect=settings.http_connect_timeout_seconds,
|
||||||
@@ -109,6 +115,7 @@ def _create_http_session(settings: Settings) -> aiohttp.ClientSession:
|
|||||||
limit_per_host=settings.http_max_connections,
|
limit_per_host=settings.http_max_connections,
|
||||||
keepalive_timeout=settings.http_keepalive_timeout_seconds,
|
keepalive_timeout=settings.http_keepalive_timeout_seconds,
|
||||||
enable_cleanup_closed=True,
|
enable_cleanup_closed=True,
|
||||||
|
socket_factory=create_dns_validated_socket_factory(),
|
||||||
)
|
)
|
||||||
return aiohttp.ClientSession(timeout=timeout, connector=connector)
|
return aiohttp.ClientSession(timeout=timeout, connector=connector)
|
||||||
|
|
||||||
|
|||||||
@@ -141,10 +141,11 @@ async def validate_blocklist_url(url: str) -> None:
|
|||||||
- The hostname resolves to a public (non-private, non-reserved) IP address
|
- The hostname resolves to a public (non-private, non-reserved) IP address
|
||||||
- IPv4-mapped IPv6 addresses are checked against IPv4 private ranges
|
- IPv4-mapped IPv6 addresses are checked against IPv4 private ranges
|
||||||
|
|
||||||
Performs DNS resolution asynchronously to check the resolved IP.
|
Performs DNS resolution asynchronously to check the resolved IP. This is a
|
||||||
This is a point-in-time check; DNS rebinding attacks may still be possible
|
point-in-time check; the application uses a DNS-validated HTTP connector
|
||||||
at actual fetch time. Callers should re-validate the final connection
|
that performs runtime re-validation at connection time to prevent DNS-rebinding
|
||||||
in the HTTP client layer.
|
attacks where the same hostname resolves to a different (private) IP address
|
||||||
|
after this initial validation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: The blocklist URL to validate.
|
url: The blocklist URL to validate.
|
||||||
|
|||||||
160
backend/tests/test_services/test_dns_validated_connector.py
Normal file
160
backend/tests/test_services/test_dns_validated_connector.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""Tests for DNS-validated socket factory that prevents DNS-rebinding attacks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.dns_validated_connector import create_dns_validated_socket_factory
|
||||||
|
|
||||||
|
|
||||||
|
class TestDnsValidatedSocketFactory:
|
||||||
|
"""Test DNS validation in socket factory."""
|
||||||
|
|
||||||
|
def test_socket_factory_allows_public_ipv4(self) -> None:
|
||||||
|
"""Test that public IPv4 addresses are allowed."""
|
||||||
|
factory = create_dns_validated_socket_factory()
|
||||||
|
|
||||||
|
# Create a mock address_info tuple for a public IPv4 address
|
||||||
|
address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 80))
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
sock = factory(address_info)
|
||||||
|
assert sock is not None
|
||||||
|
assert sock.family == socket.AF_INET
|
||||||
|
assert sock.type == socket.SOCK_STREAM
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
def test_socket_factory_allows_public_ipv6(self) -> None:
|
||||||
|
"""Test that public IPv6 addresses are allowed."""
|
||||||
|
factory = create_dns_validated_socket_factory()
|
||||||
|
|
||||||
|
# Public IPv6 address (Google DNS)
|
||||||
|
address_info = (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2606:4700:4700::1111", 80, 0, 0))
|
||||||
|
|
||||||
|
sock = factory(address_info)
|
||||||
|
assert sock is not None
|
||||||
|
assert sock.family == socket.AF_INET6
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
def test_socket_factory_blocks_private_ip_192_168(self) -> None:
|
||||||
|
"""Test that 192.168.x.x private IPs are blocked."""
|
||||||
|
factory = create_dns_validated_socket_factory()
|
||||||
|
|
||||||
|
address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.168.1.1", 80))
|
||||||
|
|
||||||
|
with pytest.raises(OSError) as exc_info:
|
||||||
|
factory(address_info)
|
||||||
|
|
||||||
|
assert "rebinding" in str(exc_info.value).lower() or "private" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
def test_socket_factory_blocks_private_ip_10(self) -> None:
|
||||||
|
"""Test that 10.x.x.x private IPs are blocked."""
|
||||||
|
factory = create_dns_validated_socket_factory()
|
||||||
|
|
||||||
|
address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.1", 80))
|
||||||
|
|
||||||
|
with pytest.raises(OSError) as exc_info:
|
||||||
|
factory(address_info)
|
||||||
|
|
||||||
|
error_msg = str(exc_info.value).lower()
|
||||||
|
assert "private" in error_msg or "rebinding" in error_msg
|
||||||
|
|
||||||
|
def test_socket_factory_blocks_private_ip_172_16(self) -> None:
|
||||||
|
"""Test that 172.16.x.x private IPs are blocked."""
|
||||||
|
factory = create_dns_validated_socket_factory()
|
||||||
|
|
||||||
|
address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("172.16.0.1", 80))
|
||||||
|
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
factory(address_info)
|
||||||
|
|
||||||
|
def test_socket_factory_blocks_loopback_ipv4(self) -> None:
|
||||||
|
"""Test that IPv4 loopback is blocked."""
|
||||||
|
factory = create_dns_validated_socket_factory()
|
||||||
|
|
||||||
|
address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 80))
|
||||||
|
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
factory(address_info)
|
||||||
|
|
||||||
|
def test_socket_factory_blocks_loopback_ipv6(self) -> None:
|
||||||
|
"""Test that IPv6 loopback is blocked."""
|
||||||
|
factory = create_dns_validated_socket_factory()
|
||||||
|
|
||||||
|
address_info = (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::1", 80, 0, 0))
|
||||||
|
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
factory(address_info)
|
||||||
|
|
||||||
|
def test_socket_factory_blocks_link_local_ipv6(self) -> None:
|
||||||
|
"""Test that IPv6 link-local addresses are blocked."""
|
||||||
|
factory = create_dns_validated_socket_factory()
|
||||||
|
|
||||||
|
address_info = (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("fe80::1", 80, 0, 0))
|
||||||
|
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
factory(address_info)
|
||||||
|
|
||||||
|
def test_socket_factory_blocks_ipv6_ula(self) -> None:
|
||||||
|
"""Test that IPv6 ULA (Unique Local Address) is blocked."""
|
||||||
|
factory = create_dns_validated_socket_factory()
|
||||||
|
|
||||||
|
# fc00::/7 is ULA
|
||||||
|
address_info = (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("fc00::1", 80, 0, 0))
|
||||||
|
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
factory(address_info)
|
||||||
|
|
||||||
|
def test_socket_factory_blocks_ipv4_multicast(self) -> None:
|
||||||
|
"""Test that IPv4 multicast addresses are blocked."""
|
||||||
|
factory = create_dns_validated_socket_factory()
|
||||||
|
|
||||||
|
# 224.0.0.0/4 is multicast
|
||||||
|
address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("224.0.0.1", 80))
|
||||||
|
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
factory(address_info)
|
||||||
|
|
||||||
|
def test_socket_factory_blocks_reserved_ips(self) -> None:
|
||||||
|
"""Test that reserved IPs are blocked."""
|
||||||
|
factory = create_dns_validated_socket_factory()
|
||||||
|
|
||||||
|
# 240.0.0.0/4 is reserved
|
||||||
|
address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("240.0.0.1", 80))
|
||||||
|
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
factory(address_info)
|
||||||
|
|
||||||
|
def test_socket_factory_blocks_broadcast(self) -> None:
|
||||||
|
"""Test that broadcast addresses are blocked."""
|
||||||
|
factory = create_dns_validated_socket_factory()
|
||||||
|
|
||||||
|
# 255.255.255.255 is broadcast
|
||||||
|
address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("255.255.255.255", 80))
|
||||||
|
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
factory(address_info)
|
||||||
|
|
||||||
|
def test_socket_factory_allows_multiple_public_ips(self) -> None:
|
||||||
|
"""Test that multiple public IPs can be created."""
|
||||||
|
factory = create_dns_validated_socket_factory()
|
||||||
|
|
||||||
|
public_ips = [
|
||||||
|
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 80)),
|
||||||
|
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("1.1.1.1", 443)),
|
||||||
|
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("208.67.222.222", 53)),
|
||||||
|
]
|
||||||
|
|
||||||
|
socks = []
|
||||||
|
for address_info in public_ips:
|
||||||
|
sock = factory(address_info)
|
||||||
|
assert sock is not None
|
||||||
|
socks.append(sock)
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
for sock in socks:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
Reference in New Issue
Block a user