Last active
June 20, 2025 00:32
-
-
Save odysseus0/bd9c3e0cf7f0b2e1222b609577fc4eb1 to your computer and use it in GitHub Desktop.
DNS Environment Scanner - Diagnose 'works on my machine' networking issues by comparing DNS and connectivity
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # /// script | |
| # requires-python = ">=3.7" | |
| # /// | |
| """ | |
| DNS Environment Scanner - Diagnose "Works on My Machine" Issues | |
| https://gist.github.com/odysseus0/bd9c3e0cf7f0b2e1222b609577fc4eb1 | |
| This tool revealed why httpx AsyncClient worked on my colleague's machine | |
| but not mine: her DNS didn't return IPv6 addresses at all, while mine did. | |
| TikAPI advertises IPv6 but doesn't support it, causing the failure. | |
| Usage: uv run dns_environment_scanner.py [hostname] | |
| """ | |
| import socket | |
| import ssl | |
| import sys | |
| import platform | |
| import subprocess | |
| import json | |
| import time | |
| from typing import Dict, List, Tuple, Optional | |
| def get_system_info() -> Dict[str, str]: | |
| """Gather system information.""" | |
| info = { | |
| 'platform': platform.system(), | |
| 'platform_version': platform.version(), | |
| 'platform_release': platform.release(), | |
| 'machine': platform.machine(), | |
| 'python_version': sys.version.split()[0], | |
| 'python_implementation': platform.python_implementation(), | |
| } | |
| # Get DNS servers on macOS/Linux | |
| if platform.system() in ['Darwin', 'Linux']: | |
| try: | |
| if platform.system() == 'Darwin': | |
| # macOS | |
| result = subprocess.run(['scutil', '--dns'], | |
| capture_output=True, text=True) | |
| nameservers = [] | |
| for line in result.stdout.split('\n'): | |
| if 'nameserver' in line and ':' in line: | |
| ns = line.split(':')[1].strip() | |
| if ns and ns not in nameservers: | |
| nameservers.append(ns) | |
| info['dns_servers'] = nameservers[:3] # First 3 | |
| else: | |
| # Linux | |
| with open('/etc/resolv.conf', 'r') as f: | |
| nameservers = [] | |
| for line in f: | |
| if line.startswith('nameserver'): | |
| ns = line.split()[1] | |
| if ns not in nameservers: | |
| nameservers.append(ns) | |
| info['dns_servers'] = nameservers | |
| except Exception as e: | |
| info['dns_servers'] = [f"Error: {e}"] | |
| return info | |
| def resolve_hostname(hostname: str) -> Dict[str, List[str]]: | |
| """Resolve a hostname to all its IP addresses.""" | |
| results = { | |
| 'ipv4': [], | |
| 'ipv6': [], | |
| 'errors': [] | |
| } | |
| try: | |
| # Get all address info | |
| addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, | |
| socket.SOCK_STREAM) | |
| for family, _, _, _, sockaddr in addr_info: | |
| ip = sockaddr[0] | |
| if family == socket.AF_INET: | |
| if ip not in results['ipv4']: | |
| results['ipv4'].append(ip) | |
| elif family == socket.AF_INET6: | |
| if ip not in results['ipv6']: | |
| results['ipv6'].append(ip) | |
| except socket.gaierror as e: | |
| results['errors'].append(f"DNS resolution failed: {e}") | |
| except Exception as e: | |
| results['errors'].append(f"Unexpected error: {e}") | |
| return results | |
| def test_connectivity(ip: str, port: int, timeout: int = 5, hostname: str = None) -> Dict[str, any]: | |
| """Test TCP connectivity and TLS handshake to an IP address.""" | |
| result = { | |
| 'ip': ip, | |
| 'port': port, | |
| 'success': False, | |
| 'error': None, | |
| 'duration': None, | |
| 'tls_success': False | |
| } | |
| # Determine socket family | |
| try: | |
| socket.inet_pton(socket.AF_INET, ip) | |
| family = socket.AF_INET | |
| result['family'] = 'IPv4' | |
| except socket.error: | |
| try: | |
| socket.inet_pton(socket.AF_INET6, ip) | |
| family = socket.AF_INET6 | |
| result['family'] = 'IPv6' | |
| except socket.error: | |
| result['error'] = "Invalid IP address" | |
| return result | |
| # Test connection | |
| sock = socket.socket(family, socket.SOCK_STREAM) | |
| sock.settimeout(timeout) | |
| start_time = time.time() | |
| try: | |
| # For IPv6, use 4-tuple format | |
| if family == socket.AF_INET6: | |
| sock.connect((ip, port, 0, 0)) | |
| else: | |
| sock.connect((ip, port)) | |
| result['success'] = True | |
| # Try TLS handshake if hostname provided and HTTPS port | |
| if hostname and port == 443: | |
| try: | |
| import ssl | |
| context = ssl.create_default_context() | |
| with context.wrap_socket(sock, server_hostname=hostname) as ssock: | |
| result['tls_success'] = True | |
| result['tls_version'] = ssock.version() | |
| except Exception as tls_e: | |
| result['tls_error'] = str(tls_e) | |
| result['error'] = f"TCP OK but TLS failed: {tls_e}" | |
| result['success'] = False # Overall failure if TLS fails | |
| result['duration'] = time.time() - start_time | |
| except socket.timeout: | |
| result['error'] = "Connection timeout" | |
| result['duration'] = timeout | |
| except ConnectionRefusedError: | |
| result['error'] = "Connection refused" | |
| result['duration'] = time.time() - start_time | |
| except socket.error as e: | |
| result['error'] = str(e) | |
| result['duration'] = time.time() - start_time | |
| finally: | |
| try: | |
| sock.close() | |
| except: | |
| pass | |
| return result | |
| def diagnose_environment(hostname: str, port: int = 443): | |
| """Perform complete environment diagnosis.""" | |
| print("π DNS ENVIRONMENT SCANNER") | |
| print("=" * 60) | |
| # System info | |
| print("\nπ± SYSTEM INFORMATION:") | |
| sys_info = get_system_info() | |
| for key, value in sys_info.items(): | |
| print(f" {key}: {value}") | |
| # DNS resolution | |
| print(f"\nπ DNS RESOLUTION for {hostname}:") | |
| dns_results = resolve_hostname(hostname) | |
| print(f" IPv4 addresses: {dns_results['ipv4'] or ['None']}") | |
| print(f" IPv6 addresses: {dns_results['ipv6'] or ['None']}") | |
| if dns_results['errors']: | |
| print(f" Errors: {dns_results['errors']}") | |
| # Key insight | |
| if dns_results['ipv6'] and not dns_results['ipv4']: | |
| print("\nβ οΈ WARNING: Only IPv6 addresses returned!") | |
| elif dns_results['ipv4'] and not dns_results['ipv6']: | |
| print("\nβ IPv4-only resolution (this might explain why it works!)") | |
| elif dns_results['ipv4'] and dns_results['ipv6']: | |
| print("\nπ Dual-stack: Both IPv4 and IPv6 available") | |
| # Connectivity tests | |
| print(f"\nπ§ͺ CONNECTIVITY TESTS (port {port}):") | |
| # Test first IPv4 | |
| if dns_results['ipv4']: | |
| print("\nIPv4 Connectivity:") | |
| for ip in dns_results['ipv4'][:2]: # Test first 2 | |
| result = test_connectivity(ip, port, hostname=hostname) | |
| status = "β " if result['success'] else "β" | |
| print(f" {status} {ip}: ", end="") | |
| if result['success']: | |
| tls_info = f" (TLS {result.get('tls_version', 'N/A')})" if result.get('tls_success') else "" | |
| print(f"Connected in {result['duration']:.3f}s{tls_info}") | |
| else: | |
| print(f"{result['error']}") | |
| # Test first IPv6 | |
| if dns_results['ipv6']: | |
| print("\nIPv6 Connectivity:") | |
| for ip in dns_results['ipv6'][:2]: # Test first 2 | |
| result = test_connectivity(ip, port, hostname=hostname) | |
| status = "β " if result['success'] else "β" | |
| print(f" {status} {ip}: ", end="") | |
| if result['success']: | |
| tls_info = f" (TLS {result.get('tls_version', 'N/A')})" if result.get('tls_success') else "" | |
| print(f"Connected in {result['duration']:.3f}s{tls_info}") | |
| else: | |
| print(f"{result['error']}") | |
| # Analysis | |
| print("\nπ ANALYSIS:") | |
| # Check for IPv6-only failure pattern | |
| ipv4_works = any(test_connectivity(ip, port)['success'] | |
| for ip in dns_results['ipv4'][:1]) | |
| ipv6_works = any(test_connectivity(ip, port)['success'] | |
| for ip in dns_results['ipv6'][:1]) | |
| if dns_results['ipv6'] and not ipv6_works and ipv4_works: | |
| print(" π¨ IPv6 advertised but not working!") | |
| print(" π‘ This is likely why httpx AsyncClient fails") | |
| print(" (it prefers IPv6 when available)") | |
| if not dns_results['ipv6']: | |
| print(" βΉοΈ No IPv6 addresses returned by DNS") | |
| print(" π‘ This might explain why httpx AsyncClient works here") | |
| print(" (no IPv6 to try, falls back to IPv4)") | |
| # Environment comparison helper | |
| print("\nπ€ SHARE THIS WITH YOUR TEAM:") | |
| share_data = { | |
| 'hostname': hostname, | |
| 'platform': sys_info['platform'], | |
| 'dns_servers': sys_info.get('dns_servers', []), | |
| 'ipv4_addresses': dns_results['ipv4'], | |
| 'ipv6_addresses': dns_results['ipv6'], | |
| 'ipv4_works': ipv4_works, | |
| 'ipv6_works': ipv6_works | |
| } | |
| # Add note only if relevant | |
| if dns_results['ipv6'] and not ipv6_works: | |
| share_data['ipv6_note'] = 'TCP works but TLS fails' | |
| print(json.dumps(share_data, indent=2)) | |
| print("\nπ‘ To compare environments, have your colleague run:") | |
| print(f" python {sys.argv[0]} {hostname}") | |
| def main(): | |
| """Main entry point.""" | |
| if len(sys.argv) < 2: | |
| # Default to testing common problematic services | |
| hostnames = ['api.tikapi.io', 'httpbin.org', 'google.com'] | |
| print("Usage: python dns_environment_scanner.py [hostname]") | |
| print(f"\nRunning default test with: {', '.join(hostnames)}") | |
| print() | |
| for hostname in hostnames: | |
| diagnose_environment(hostname) | |
| print("\n" + "=" * 60 + "\n") | |
| else: | |
| hostname = sys.argv[1] | |
| port = int(sys.argv[2]) if len(sys.argv) > 2 else 443 | |
| diagnose_environment(hostname, port) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment