vpcrawler 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aws_vpc_net_tester/__init__.py +3 -0
- aws_vpc_net_tester/__main__.py +6 -0
- aws_vpc_net_tester/cli.py +214 -0
- aws_vpc_net_tester/config_validator.py +428 -0
- aws_vpc_net_tester/models.py +114 -0
- aws_vpc_net_tester/network_tester.py +173 -0
- vpcrawler-0.1.0.dist-info/METADATA +372 -0
- vpcrawler-0.1.0.dist-info/RECORD +11 -0
- vpcrawler-0.1.0.dist-info/WHEEL +5 -0
- vpcrawler-0.1.0.dist-info/entry_points.txt +2 -0
- vpcrawler-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""CLI entry point for AWS VPC Networking Test Tool."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from .config_validator import discover_vpc_targets, validate_vpc_connectivity
|
|
11
|
+
from .models import CheckStatus, FullValidationResult
|
|
12
|
+
from .network_tester import parse_port_target, run_dns, run_ping, run_port_check
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="vpcrawler",
|
|
16
|
+
help="Validate AWS VPC connectivity configuration and run local network tests.",
|
|
17
|
+
)
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _status_style(status: CheckStatus) -> str:
|
|
22
|
+
"""Return rich markup for status."""
|
|
23
|
+
styles = {
|
|
24
|
+
CheckStatus.PASS: "[green]PASS[/green]",
|
|
25
|
+
CheckStatus.FAIL: "[red]FAIL[/red]",
|
|
26
|
+
CheckStatus.WARN: "[yellow]WARN[/yellow]",
|
|
27
|
+
CheckStatus.SKIP: "[dim]SKIP[/dim]",
|
|
28
|
+
}
|
|
29
|
+
return styles.get(status, str(status))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run_validate(
|
|
33
|
+
config_result,
|
|
34
|
+
ping_target: Optional[str],
|
|
35
|
+
dns_target: Optional[str],
|
|
36
|
+
port_target: Optional[str],
|
|
37
|
+
output_json: bool,
|
|
38
|
+
auto_test: bool = False,
|
|
39
|
+
auto_test_max_per_vpc: int = 5,
|
|
40
|
+
auto_test_ports: list[int] | None = None,
|
|
41
|
+
profile: Optional[str] = None,
|
|
42
|
+
region: str = "us-east-1",
|
|
43
|
+
vpc_a: str = "",
|
|
44
|
+
vpc_b: str = "",
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Shared validation logic."""
|
|
47
|
+
network_results = []
|
|
48
|
+
|
|
49
|
+
# Auto-discover targets from VPCs when --auto-test is used
|
|
50
|
+
if auto_test and not config_result.error:
|
|
51
|
+
ping_targets, port_targets = discover_vpc_targets(
|
|
52
|
+
vpc_id_a=vpc_a,
|
|
53
|
+
vpc_id_b=vpc_b,
|
|
54
|
+
profile=profile,
|
|
55
|
+
region=region,
|
|
56
|
+
max_per_vpc=auto_test_max_per_vpc,
|
|
57
|
+
ports=auto_test_ports or [22, 443],
|
|
58
|
+
)
|
|
59
|
+
for ip in ping_targets:
|
|
60
|
+
network_results.append(run_ping(ip))
|
|
61
|
+
for host, port in port_targets:
|
|
62
|
+
network_results.append(run_port_check(host, port))
|
|
63
|
+
|
|
64
|
+
if ping_target:
|
|
65
|
+
network_results.append(run_ping(ping_target))
|
|
66
|
+
|
|
67
|
+
if dns_target:
|
|
68
|
+
network_results.append(run_dns(dns_target))
|
|
69
|
+
|
|
70
|
+
if port_target:
|
|
71
|
+
parsed = parse_port_target(port_target)
|
|
72
|
+
if parsed:
|
|
73
|
+
host, port = parsed
|
|
74
|
+
network_results.append(run_port_check(host, port))
|
|
75
|
+
else:
|
|
76
|
+
console.print(f"[red]Invalid --port format: {port_target}. Use host:port[/red]")
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
|
|
79
|
+
full_result = FullValidationResult(
|
|
80
|
+
config_result=config_result,
|
|
81
|
+
network_results=network_results,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if output_json:
|
|
85
|
+
console.print(json.dumps(full_result.to_dict(), indent=2))
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
if config_result.error:
|
|
89
|
+
console.print(f"[red]Error: {config_result.error}[/red]")
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
|
|
92
|
+
console.print("\n[bold]VPC Configuration[/bold]")
|
|
93
|
+
console.print(f" VPC A: {config_result.vpc_a.vpc_id} ({config_result.vpc_a.cidr_block})")
|
|
94
|
+
console.print(f" VPC B: {config_result.vpc_b.vpc_id} ({config_result.vpc_b.cidr_block})")
|
|
95
|
+
console.print(f" Region: {config_result.vpc_a.region}")
|
|
96
|
+
|
|
97
|
+
# IP ranges section
|
|
98
|
+
if config_result.vpc_a.cidr_block or config_result.vpc_b.cidr_block:
|
|
99
|
+
console.print("\n[bold]IP Ranges[/bold]")
|
|
100
|
+
console.print(f" VPC A: {config_result.vpc_a.cidr_block}")
|
|
101
|
+
for s in config_result.vpc_a.subnets:
|
|
102
|
+
console.print(f" - {s['subnet_id']}: {s['cidr']} ({s['az']})")
|
|
103
|
+
console.print(f" VPC B: {config_result.vpc_b.cidr_block}")
|
|
104
|
+
for s in config_result.vpc_b.subnets:
|
|
105
|
+
console.print(f" - {s['subnet_id']}: {s['cidr']} ({s['az']})")
|
|
106
|
+
|
|
107
|
+
table = Table(title="Validation Checks")
|
|
108
|
+
table.add_column("Check", style="cyan")
|
|
109
|
+
table.add_column("Status", style="bold")
|
|
110
|
+
table.add_column("Message", style="dim")
|
|
111
|
+
|
|
112
|
+
for check in config_result.checks:
|
|
113
|
+
table.add_row(check.name, _status_style(check.status), check.message)
|
|
114
|
+
|
|
115
|
+
console.print(table)
|
|
116
|
+
|
|
117
|
+
if config_result.connectivity_path_valid:
|
|
118
|
+
console.print("\n[green]Connectivity path: VALID[/green]")
|
|
119
|
+
else:
|
|
120
|
+
console.print("\n[red]Connectivity path: INVALID[/red]")
|
|
121
|
+
|
|
122
|
+
if network_results:
|
|
123
|
+
console.print("\n[bold]Local Network Tests[/bold]")
|
|
124
|
+
net_table = Table()
|
|
125
|
+
net_table.add_column("Test", style="cyan")
|
|
126
|
+
net_table.add_column("Target", style="dim")
|
|
127
|
+
net_table.add_column("Status", style="bold")
|
|
128
|
+
net_table.add_column("Message", style="dim")
|
|
129
|
+
|
|
130
|
+
for r in network_results:
|
|
131
|
+
net_table.add_row(r.test_type, r.target, _status_style(r.status), r.message)
|
|
132
|
+
|
|
133
|
+
console.print(net_table)
|
|
134
|
+
|
|
135
|
+
for r in network_results:
|
|
136
|
+
if r.output and r.status == CheckStatus.FAIL:
|
|
137
|
+
console.print(f"\n[dim]{r.test_type} output:[/dim]\n{r.output}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.callback(invoke_without_command=True)
|
|
141
|
+
def main(
|
|
142
|
+
ctx: typer.Context,
|
|
143
|
+
vpc_a: str = typer.Option(..., "--vpc-a", help="First VPC ID"),
|
|
144
|
+
vpc_b: str = typer.Option(..., "--vpc-b", help="Second VPC ID"),
|
|
145
|
+
profile: Optional[str] = typer.Option(None, "--profile", help="AWS profile name"),
|
|
146
|
+
region: str = typer.Option("us-east-1", "--region", help="AWS region"),
|
|
147
|
+
auto_test: bool = typer.Option(
|
|
148
|
+
False,
|
|
149
|
+
"--auto-test",
|
|
150
|
+
help="Discover EC2 instances in VPCs and run ping/port checks automatically",
|
|
151
|
+
),
|
|
152
|
+
max_targets_per_vpc: int = typer.Option(
|
|
153
|
+
5,
|
|
154
|
+
"--max-targets-per-vpc",
|
|
155
|
+
help="Max EC2 instances to test per VPC when using --auto-test",
|
|
156
|
+
),
|
|
157
|
+
auto_test_ports: str = typer.Option(
|
|
158
|
+
"22,443",
|
|
159
|
+
"--auto-test-ports",
|
|
160
|
+
help="Comma-separated ports to check when using --auto-test (e.g., 22,443,8080)",
|
|
161
|
+
),
|
|
162
|
+
ping_target: Optional[str] = typer.Option(None, "--ping", help="IP or hostname to ping"),
|
|
163
|
+
dns_target: Optional[str] = typer.Option(None, "--dns", help="Hostname to resolve via DNS"),
|
|
164
|
+
port_target: Optional[str] = typer.Option(
|
|
165
|
+
None,
|
|
166
|
+
"--port",
|
|
167
|
+
help="Host:port to test (e.g., 10.0.2.100:443)",
|
|
168
|
+
),
|
|
169
|
+
output_json: bool = typer.Option(False, "--json", help="Output results as JSON"),
|
|
170
|
+
) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Validate VPC connectivity configuration between two VPCs and optionally run
|
|
173
|
+
local network tests (ping, DNS, port check). Use --auto-test to discover EC2
|
|
174
|
+
instances in the VPCs and run connectivity tests automatically.
|
|
175
|
+
"""
|
|
176
|
+
if ctx.invoked_subcommand is not None:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
# Parse auto-test ports
|
|
180
|
+
ports_list: list[int] | None = None
|
|
181
|
+
if auto_test:
|
|
182
|
+
try:
|
|
183
|
+
ports_list = [int(p.strip()) for p in auto_test_ports.split(",") if p.strip()]
|
|
184
|
+
except ValueError:
|
|
185
|
+
console.print("[red]Invalid --auto-test-ports. Use comma-separated integers (e.g., 22,443)[/red]")
|
|
186
|
+
raise typer.Exit(1)
|
|
187
|
+
|
|
188
|
+
config_result = validate_vpc_connectivity(
|
|
189
|
+
vpc_id_a=vpc_a,
|
|
190
|
+
vpc_id_b=vpc_b,
|
|
191
|
+
profile=profile,
|
|
192
|
+
region=region,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
_run_validate(
|
|
196
|
+
config_result=config_result,
|
|
197
|
+
ping_target=ping_target,
|
|
198
|
+
dns_target=dns_target,
|
|
199
|
+
port_target=port_target,
|
|
200
|
+
output_json=output_json,
|
|
201
|
+
auto_test=auto_test,
|
|
202
|
+
auto_test_max_per_vpc=max_targets_per_vpc,
|
|
203
|
+
auto_test_ports=ports_list,
|
|
204
|
+
profile=profile,
|
|
205
|
+
region=region,
|
|
206
|
+
vpc_a=vpc_a,
|
|
207
|
+
vpc_b=vpc_b,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
app()
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""boto3-based VPC configuration validation between two VPCs."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import boto3
|
|
7
|
+
from botocore.exceptions import ClientError
|
|
8
|
+
|
|
9
|
+
from .models import CheckStatus, ConfigValidationResult, ValidationCheck, VpcInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_tags(resource: dict[str, Any]) -> dict[str, str]:
|
|
13
|
+
"""Extract tags as a dict from AWS resource."""
|
|
14
|
+
tags = resource.get("Tags") or []
|
|
15
|
+
return {t["Key"]: t["Value"] for t in tags}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cidr_overlaps(cidr1: str, cidr2: str) -> bool:
|
|
19
|
+
"""Check if two CIDR blocks overlap."""
|
|
20
|
+
try:
|
|
21
|
+
n1 = ipaddress.ip_network(cidr1)
|
|
22
|
+
n2 = ipaddress.ip_network(cidr2)
|
|
23
|
+
return n1.overlaps(n2)
|
|
24
|
+
except ValueError:
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cidr_contains(cidr: str, ip: str) -> bool:
|
|
29
|
+
"""Check if an IP is within a CIDR block."""
|
|
30
|
+
try:
|
|
31
|
+
network = ipaddress.ip_network(cidr)
|
|
32
|
+
addr = ipaddress.ip_address(ip)
|
|
33
|
+
return addr in network
|
|
34
|
+
except ValueError:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _rule_allows_cidr(
|
|
39
|
+
rule: dict[str, Any],
|
|
40
|
+
target_cidr: str,
|
|
41
|
+
port: int | None = None,
|
|
42
|
+
protocol: str = "tcp",
|
|
43
|
+
) -> bool:
|
|
44
|
+
"""Check if a security group or NACL rule allows traffic to/from target CIDR."""
|
|
45
|
+
cidr = rule.get("CidrBlock") or rule.get("Ipv6CidrBlock") or ""
|
|
46
|
+
if not cidr or cidr == "0.0.0.0/0":
|
|
47
|
+
cidr = "0.0.0.0/0"
|
|
48
|
+
if cidr == "0.0.0.0/0":
|
|
49
|
+
pass # Allow all
|
|
50
|
+
else:
|
|
51
|
+
# Check if target CIDR overlaps with rule CIDR
|
|
52
|
+
if not _cidr_overlaps(cidr, target_cidr):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
if rule.get("RuleAction") == "deny":
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
# Port check for TCP/UDP
|
|
59
|
+
if port is not None and protocol in ("tcp", "udp"):
|
|
60
|
+
from_port = rule.get("FromPort")
|
|
61
|
+
to_port = rule.get("ToPort")
|
|
62
|
+
if from_port is not None and to_port is not None:
|
|
63
|
+
if not (from_port <= port <= to_port):
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_vpc_connectivity(
|
|
70
|
+
vpc_id_a: str,
|
|
71
|
+
vpc_id_b: str,
|
|
72
|
+
profile: str | None = None,
|
|
73
|
+
region: str = "us-east-1",
|
|
74
|
+
check_port: int | None = 22,
|
|
75
|
+
check_protocol: str = "tcp",
|
|
76
|
+
) -> ConfigValidationResult:
|
|
77
|
+
"""
|
|
78
|
+
Validate VPC connectivity configuration between two VPCs.
|
|
79
|
+
|
|
80
|
+
Uses boto3 to fetch and analyze route tables, subnets, security groups,
|
|
81
|
+
NACLs, VPC peering, and Transit Gateway attachments.
|
|
82
|
+
"""
|
|
83
|
+
session = boto3.Session(profile_name=profile, region_name=region)
|
|
84
|
+
ec2 = session.client("ec2")
|
|
85
|
+
checks: list[ValidationCheck] = []
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
# Fetch both VPCs
|
|
89
|
+
vpcs_resp = ec2.describe_vpcs(VpcIds=[vpc_id_a, vpc_id_b])
|
|
90
|
+
vpcs = {v["VpcId"]: v for v in vpcs_resp["Vpcs"]}
|
|
91
|
+
|
|
92
|
+
if vpc_id_a not in vpcs or vpc_id_b not in vpcs:
|
|
93
|
+
missing = [v for v in [vpc_id_a, vpc_id_b] if v not in vpcs]
|
|
94
|
+
return ConfigValidationResult(
|
|
95
|
+
vpc_a=VpcInfo(vpc_id=vpc_id_a, cidr_block="", region=region),
|
|
96
|
+
vpc_b=VpcInfo(vpc_id=vpc_id_b, cidr_block="", region=region),
|
|
97
|
+
error=f"VPC(s) not found: {missing}",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
vpc_a_data = vpcs[vpc_id_a]
|
|
101
|
+
vpc_b_data = vpcs[vpc_id_b]
|
|
102
|
+
cidr_a = vpc_a_data.get("CidrBlock", "")
|
|
103
|
+
cidr_b = vpc_b_data.get("CidrBlock", "")
|
|
104
|
+
|
|
105
|
+
# Subnets
|
|
106
|
+
subnets_resp = ec2.describe_subnets(
|
|
107
|
+
Filters=[{"Name": "vpc-id", "Values": [vpc_id_a, vpc_id_b]}]
|
|
108
|
+
)
|
|
109
|
+
subnets_a = [
|
|
110
|
+
{"subnet_id": s["SubnetId"], "cidr": s["CidrBlock"], "az": s["AvailabilityZone"]}
|
|
111
|
+
for s in subnets_resp["Subnets"]
|
|
112
|
+
if s["VpcId"] == vpc_id_a
|
|
113
|
+
]
|
|
114
|
+
subnets_b = [
|
|
115
|
+
{"subnet_id": s["SubnetId"], "cidr": s["CidrBlock"], "az": s["AvailabilityZone"]}
|
|
116
|
+
for s in subnets_resp["Subnets"]
|
|
117
|
+
if s["VpcId"] == vpc_id_b
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
vpc_a_info = VpcInfo(
|
|
121
|
+
vpc_id=vpc_id_a,
|
|
122
|
+
cidr_block=cidr_a,
|
|
123
|
+
region=region,
|
|
124
|
+
subnets=subnets_a,
|
|
125
|
+
tags=_get_tags(vpc_a_data),
|
|
126
|
+
)
|
|
127
|
+
vpc_b_info = VpcInfo(
|
|
128
|
+
vpc_id=vpc_id_b,
|
|
129
|
+
cidr_block=cidr_b,
|
|
130
|
+
region=region,
|
|
131
|
+
subnets=subnets_b,
|
|
132
|
+
tags=_get_tags(vpc_b_data),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Check 1: CIDR overlap (peered VPCs must not overlap)
|
|
136
|
+
if _cidr_overlaps(cidr_a, cidr_b):
|
|
137
|
+
checks.append(
|
|
138
|
+
ValidationCheck(
|
|
139
|
+
name="cidr_overlap",
|
|
140
|
+
status=CheckStatus.FAIL,
|
|
141
|
+
message="VPC CIDR blocks overlap - peering/connectivity not possible",
|
|
142
|
+
details={"cidr_a": cidr_a, "cidr_b": cidr_b},
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
checks.append(
|
|
147
|
+
ValidationCheck(
|
|
148
|
+
name="cidr_overlap",
|
|
149
|
+
status=CheckStatus.PASS,
|
|
150
|
+
message="VPC CIDR blocks do not overlap",
|
|
151
|
+
details={"cidr_a": cidr_a, "cidr_b": cidr_b},
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Check 2: VPC Peering
|
|
156
|
+
peering_resp = ec2.describe_vpc_peering_connections(
|
|
157
|
+
Filters=[
|
|
158
|
+
{"Name": "status-code", "Values": ["active"]},
|
|
159
|
+
]
|
|
160
|
+
)
|
|
161
|
+
peerings = peering_resp.get("VpcPeeringConnections", [])
|
|
162
|
+
peering_exists = False
|
|
163
|
+
for p in peerings:
|
|
164
|
+
requester = p.get("RequesterVpcInfo", {}).get("VpcId")
|
|
165
|
+
accepter = p.get("AccepterVpcInfo", {}).get("VpcId")
|
|
166
|
+
if {requester, accepter} == {vpc_id_a, vpc_id_b}:
|
|
167
|
+
peering_exists = True
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
tgw_connected = False
|
|
171
|
+
if peering_exists:
|
|
172
|
+
checks.append(
|
|
173
|
+
ValidationCheck(
|
|
174
|
+
name="vpc_peering",
|
|
175
|
+
status=CheckStatus.PASS,
|
|
176
|
+
message="Active VPC peering connection exists between the two VPCs",
|
|
177
|
+
details={},
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
# Check Transit Gateway as alternative
|
|
182
|
+
tgw_attachments = ec2.describe_transit_gateway_attachments(
|
|
183
|
+
Filters=[{"Name": "resource-type", "Values": ["vpc"]}]
|
|
184
|
+
)
|
|
185
|
+
tgw_vpcs: dict[str, set[str]] = {} # tgw_id -> set of vpc_ids
|
|
186
|
+
for att in tgw_attachments.get("TransitGatewayAttachments", []):
|
|
187
|
+
if att["State"] != "available":
|
|
188
|
+
continue
|
|
189
|
+
tgw_id = att["TransitGatewayId"]
|
|
190
|
+
vpc_id = att["ResourceId"]
|
|
191
|
+
if tgw_id not in tgw_vpcs:
|
|
192
|
+
tgw_vpcs[tgw_id] = set()
|
|
193
|
+
tgw_vpcs[tgw_id].add(vpc_id)
|
|
194
|
+
|
|
195
|
+
tgw_connected = any(
|
|
196
|
+
vpc_id_a in vpcs_set and vpc_id_b in vpcs_set
|
|
197
|
+
for vpcs_set in tgw_vpcs.values()
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if tgw_connected:
|
|
201
|
+
checks.append(
|
|
202
|
+
ValidationCheck(
|
|
203
|
+
name="transit_gateway",
|
|
204
|
+
status=CheckStatus.PASS,
|
|
205
|
+
message="Both VPCs attached to same Transit Gateway",
|
|
206
|
+
details={},
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
checks.append(
|
|
211
|
+
ValidationCheck(
|
|
212
|
+
name="vpc_peering",
|
|
213
|
+
status=CheckStatus.FAIL,
|
|
214
|
+
message="No active VPC peering or Transit Gateway connection",
|
|
215
|
+
details={},
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
checks.append(
|
|
219
|
+
ValidationCheck(
|
|
220
|
+
name="transit_gateway",
|
|
221
|
+
status=CheckStatus.FAIL,
|
|
222
|
+
message="No shared Transit Gateway attachment",
|
|
223
|
+
details={},
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
has_peering_or_tgw = peering_exists or tgw_connected
|
|
228
|
+
|
|
229
|
+
# Check 3: Route tables - routes to peer CIDR
|
|
230
|
+
if has_peering_or_tgw:
|
|
231
|
+
rt_resp = ec2.describe_route_tables(
|
|
232
|
+
Filters=[{"Name": "vpc-id", "Values": [vpc_id_a, vpc_id_b]}]
|
|
233
|
+
)
|
|
234
|
+
route_tables = rt_resp.get("RouteTables", [])
|
|
235
|
+
|
|
236
|
+
routes_to_b_from_a = False
|
|
237
|
+
routes_to_a_from_b = False
|
|
238
|
+
|
|
239
|
+
for rt in route_tables:
|
|
240
|
+
vpc_id = rt["VpcId"]
|
|
241
|
+
for route in rt.get("Routes", []):
|
|
242
|
+
dest = route.get("DestinationCidrBlock", "")
|
|
243
|
+
if not dest:
|
|
244
|
+
continue
|
|
245
|
+
if vpc_id == vpc_id_a and _cidr_overlaps(dest, cidr_b):
|
|
246
|
+
routes_to_b_from_a = True
|
|
247
|
+
if vpc_id == vpc_id_b and _cidr_overlaps(dest, cidr_a):
|
|
248
|
+
routes_to_a_from_b = True
|
|
249
|
+
|
|
250
|
+
if routes_to_b_from_a and routes_to_a_from_b:
|
|
251
|
+
checks.append(
|
|
252
|
+
ValidationCheck(
|
|
253
|
+
name="route_tables",
|
|
254
|
+
status=CheckStatus.PASS,
|
|
255
|
+
message="Route tables have routes for peer VPC CIDRs",
|
|
256
|
+
details={
|
|
257
|
+
"vpc_a_routes_to_b": routes_to_b_from_a,
|
|
258
|
+
"vpc_b_routes_to_a": routes_to_a_from_b,
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
else:
|
|
263
|
+
checks.append(
|
|
264
|
+
ValidationCheck(
|
|
265
|
+
name="route_tables",
|
|
266
|
+
status=CheckStatus.FAIL,
|
|
267
|
+
message="Missing routes to peer VPC CIDR in one or both VPCs",
|
|
268
|
+
details={
|
|
269
|
+
"vpc_a_routes_to_b": routes_to_b_from_a,
|
|
270
|
+
"vpc_b_routes_to_a": routes_to_a_from_b,
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Check 4: Security groups - at least one allows traffic
|
|
276
|
+
sg_resp = ec2.describe_security_groups(
|
|
277
|
+
Filters=[{"Name": "vpc-id", "Values": [vpc_id_a, vpc_id_b]}]
|
|
278
|
+
)
|
|
279
|
+
sgs = sg_resp.get("SecurityGroups", [])
|
|
280
|
+
|
|
281
|
+
sg_a_allows_from_b = False
|
|
282
|
+
sg_b_allows_from_a = False
|
|
283
|
+
|
|
284
|
+
for sg in sgs:
|
|
285
|
+
vpc_id = sg["VpcId"]
|
|
286
|
+
for perm in sg.get("IpPermissions", []):
|
|
287
|
+
for ip_range in perm.get("IpRanges", []) + perm.get("Ipv6Ranges", []):
|
|
288
|
+
cidr = ip_range.get("CidrIp") or ip_range.get("CidrIpv6") or ""
|
|
289
|
+
if vpc_id == vpc_id_a and _cidr_overlaps(cidr, cidr_b):
|
|
290
|
+
sg_a_allows_from_b = True
|
|
291
|
+
if vpc_id == vpc_id_b and _cidr_overlaps(cidr, cidr_a):
|
|
292
|
+
sg_b_allows_from_a = True
|
|
293
|
+
|
|
294
|
+
for sg in sgs:
|
|
295
|
+
vpc_id = sg["VpcId"]
|
|
296
|
+
for perm in sg.get("IpPermissionsEgress", []):
|
|
297
|
+
for ip_range in perm.get("IpRanges", []) + perm.get("Ipv6Ranges", []):
|
|
298
|
+
cidr = ip_range.get("CidrIp") or ip_range.get("CidrIpv6") or ""
|
|
299
|
+
if vpc_id == vpc_id_a and _cidr_overlaps(cidr, cidr_b):
|
|
300
|
+
sg_a_allows_from_b = True
|
|
301
|
+
if vpc_id == vpc_id_b and _cidr_overlaps(cidr, cidr_a):
|
|
302
|
+
sg_b_allows_from_a = True
|
|
303
|
+
|
|
304
|
+
if sg_a_allows_from_b and sg_b_allows_from_a:
|
|
305
|
+
checks.append(
|
|
306
|
+
ValidationCheck(
|
|
307
|
+
name="security_groups",
|
|
308
|
+
status=CheckStatus.PASS,
|
|
309
|
+
message="Security groups allow traffic between VPC CIDRs",
|
|
310
|
+
details={},
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
else:
|
|
314
|
+
checks.append(
|
|
315
|
+
ValidationCheck(
|
|
316
|
+
name="security_groups",
|
|
317
|
+
status=CheckStatus.WARN,
|
|
318
|
+
message="Security groups may restrict traffic - verify rules allow cross-VPC access",
|
|
319
|
+
details={
|
|
320
|
+
"vpc_a_allows_from_b": sg_a_allows_from_b,
|
|
321
|
+
"vpc_b_allows_from_a": sg_b_allows_from_a,
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Check 5: Network ACLs
|
|
327
|
+
nacl_resp = ec2.describe_network_acls(
|
|
328
|
+
Filters=[{"Name": "vpc-id", "Values": [vpc_id_a, vpc_id_b]}]
|
|
329
|
+
)
|
|
330
|
+
nacls = nacl_resp.get("NetworkAcls", [])
|
|
331
|
+
|
|
332
|
+
nacl_blocks_traffic = False
|
|
333
|
+
for nacl in nacls:
|
|
334
|
+
for entry in nacl.get("Entries", []):
|
|
335
|
+
if entry.get("RuleAction") == "deny":
|
|
336
|
+
cidr = entry.get("CidrBlock", "")
|
|
337
|
+
if cidr == "0.0.0.0/0" or _cidr_overlaps(cidr, cidr_a) or _cidr_overlaps(cidr, cidr_b):
|
|
338
|
+
nacl_blocks_traffic = True
|
|
339
|
+
break
|
|
340
|
+
|
|
341
|
+
if nacl_blocks_traffic:
|
|
342
|
+
checks.append(
|
|
343
|
+
ValidationCheck(
|
|
344
|
+
name="network_acls",
|
|
345
|
+
status=CheckStatus.WARN,
|
|
346
|
+
message="NACL deny rules may block cross-VPC traffic - verify rules",
|
|
347
|
+
details={},
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
else:
|
|
351
|
+
checks.append(
|
|
352
|
+
ValidationCheck(
|
|
353
|
+
name="network_acls",
|
|
354
|
+
status=CheckStatus.PASS,
|
|
355
|
+
message="No NACL rules found that explicitly block cross-VPC traffic",
|
|
356
|
+
details={},
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Overall connectivity path
|
|
361
|
+
fail_checks = [c for c in checks if c.status == CheckStatus.FAIL]
|
|
362
|
+
connectivity_valid = len(fail_checks) == 0
|
|
363
|
+
|
|
364
|
+
return ConfigValidationResult(
|
|
365
|
+
vpc_a=vpc_a_info,
|
|
366
|
+
vpc_b=vpc_b_info,
|
|
367
|
+
checks=checks,
|
|
368
|
+
connectivity_path_valid=connectivity_valid,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
except ClientError as e:
|
|
372
|
+
error_code = e.response.get("Error", {}).get("Code", "")
|
|
373
|
+
error_msg = e.response.get("Error", {}).get("Message", str(e))
|
|
374
|
+
return ConfigValidationResult(
|
|
375
|
+
vpc_a=VpcInfo(vpc_id=vpc_id_a, cidr_block="", region=region),
|
|
376
|
+
vpc_b=VpcInfo(vpc_id=vpc_id_b, cidr_block="", region=region),
|
|
377
|
+
error=f"AWS API error ({error_code}): {error_msg}",
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def discover_vpc_targets(
|
|
382
|
+
vpc_id_a: str,
|
|
383
|
+
vpc_id_b: str,
|
|
384
|
+
profile: str | None = None,
|
|
385
|
+
region: str = "us-east-1",
|
|
386
|
+
max_per_vpc: int = 5,
|
|
387
|
+
ports: list[int] | None = None,
|
|
388
|
+
) -> tuple[list[str], list[tuple[str, int]]]:
|
|
389
|
+
"""
|
|
390
|
+
Discover EC2 instance private IPs in both VPCs for automatic connectivity testing.
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
(ping_targets, port_targets) - lists of IPs to ping and (host, port) tuples for port checks.
|
|
394
|
+
"""
|
|
395
|
+
if ports is None:
|
|
396
|
+
ports = [22, 443]
|
|
397
|
+
|
|
398
|
+
session = boto3.Session(profile_name=profile, region_name=region)
|
|
399
|
+
ec2 = session.client("ec2")
|
|
400
|
+
|
|
401
|
+
ping_targets: list[str] = []
|
|
402
|
+
port_targets: list[tuple[str, int]] = []
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
for vpc_id in [vpc_id_a, vpc_id_b]:
|
|
406
|
+
instances_resp = ec2.describe_instances(
|
|
407
|
+
Filters=[
|
|
408
|
+
{"Name": "vpc-id", "Values": [vpc_id]},
|
|
409
|
+
{"Name": "instance-state-name", "Values": ["running"]},
|
|
410
|
+
]
|
|
411
|
+
)
|
|
412
|
+
ips: list[str] = []
|
|
413
|
+
for reservation in instances_resp.get("Reservations", []):
|
|
414
|
+
for instance in reservation.get("Instances", []):
|
|
415
|
+
private_ip = instance.get("PrivateIpAddress")
|
|
416
|
+
if private_ip:
|
|
417
|
+
ips.append(private_ip)
|
|
418
|
+
|
|
419
|
+
# Limit targets per VPC
|
|
420
|
+
for ip in ips[:max_per_vpc]:
|
|
421
|
+
ping_targets.append(ip)
|
|
422
|
+
for port in ports:
|
|
423
|
+
port_targets.append((ip, port))
|
|
424
|
+
|
|
425
|
+
except ClientError:
|
|
426
|
+
pass # Return empty lists on error
|
|
427
|
+
|
|
428
|
+
return ping_targets, port_targets
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Data classes for VPC and connectivity validation results."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CheckStatus(str, Enum):
|
|
9
|
+
"""Status of a validation check."""
|
|
10
|
+
|
|
11
|
+
PASS = "pass"
|
|
12
|
+
FAIL = "fail"
|
|
13
|
+
WARN = "warn"
|
|
14
|
+
SKIP = "skip"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ValidationCheck:
|
|
19
|
+
"""Single validation check result."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
status: CheckStatus
|
|
23
|
+
message: str
|
|
24
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class VpcInfo:
|
|
29
|
+
"""Basic VPC information."""
|
|
30
|
+
|
|
31
|
+
vpc_id: str
|
|
32
|
+
cidr_block: str
|
|
33
|
+
region: str
|
|
34
|
+
subnets: list[dict[str, Any]] = field(default_factory=list)
|
|
35
|
+
tags: dict[str, str] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ConfigValidationResult:
|
|
40
|
+
"""Result of VPC configuration validation between two VPCs."""
|
|
41
|
+
|
|
42
|
+
vpc_a: VpcInfo
|
|
43
|
+
vpc_b: VpcInfo
|
|
44
|
+
checks: list[ValidationCheck] = field(default_factory=list)
|
|
45
|
+
connectivity_path_valid: bool = False
|
|
46
|
+
error: str | None = None
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, Any]:
|
|
49
|
+
"""Convert to JSON-serializable dict."""
|
|
50
|
+
return {
|
|
51
|
+
"vpc_a": {
|
|
52
|
+
"vpc_id": self.vpc_a.vpc_id,
|
|
53
|
+
"cidr_block": self.vpc_a.cidr_block,
|
|
54
|
+
"region": self.vpc_a.region,
|
|
55
|
+
"subnets": self.vpc_a.subnets,
|
|
56
|
+
"tags": self.vpc_a.tags,
|
|
57
|
+
},
|
|
58
|
+
"vpc_b": {
|
|
59
|
+
"vpc_id": self.vpc_b.vpc_id,
|
|
60
|
+
"cidr_block": self.vpc_b.cidr_block,
|
|
61
|
+
"region": self.vpc_b.region,
|
|
62
|
+
"subnets": self.vpc_b.subnets,
|
|
63
|
+
"tags": self.vpc_b.tags,
|
|
64
|
+
},
|
|
65
|
+
"checks": [
|
|
66
|
+
{
|
|
67
|
+
"name": c.name,
|
|
68
|
+
"status": c.status.value,
|
|
69
|
+
"message": c.message,
|
|
70
|
+
"details": c.details,
|
|
71
|
+
}
|
|
72
|
+
for c in self.checks
|
|
73
|
+
],
|
|
74
|
+
"connectivity_path_valid": self.connectivity_path_valid,
|
|
75
|
+
"error": self.error,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class NetworkTestResult:
|
|
81
|
+
"""Result of a local network connectivity test."""
|
|
82
|
+
|
|
83
|
+
test_type: str # ping, dns, port
|
|
84
|
+
target: str
|
|
85
|
+
status: CheckStatus
|
|
86
|
+
message: str
|
|
87
|
+
output: str = ""
|
|
88
|
+
duration_ms: float | None = None
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict[str, Any]:
|
|
91
|
+
"""Convert to JSON-serializable dict."""
|
|
92
|
+
return {
|
|
93
|
+
"test_type": self.test_type,
|
|
94
|
+
"target": self.target,
|
|
95
|
+
"status": self.status.value,
|
|
96
|
+
"message": self.message,
|
|
97
|
+
"output": self.output,
|
|
98
|
+
"duration_ms": self.duration_ms,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class FullValidationResult:
|
|
104
|
+
"""Combined result of config validation and network tests."""
|
|
105
|
+
|
|
106
|
+
config_result: ConfigValidationResult
|
|
107
|
+
network_results: list[NetworkTestResult] = field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
def to_dict(self) -> dict[str, Any]:
|
|
110
|
+
"""Convert to JSON-serializable dict."""
|
|
111
|
+
return {
|
|
112
|
+
"config": self.config_result.to_dict(),
|
|
113
|
+
"network_tests": [r.to_dict() for r in self.network_results],
|
|
114
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Local network connectivity tests (ping, DNS, port check)."""
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from .models import CheckStatus, NetworkTestResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _run_command(
|
|
13
|
+
cmd: list[str],
|
|
14
|
+
timeout: int = 10,
|
|
15
|
+
) -> tuple[bool, str, float | None]:
|
|
16
|
+
"""
|
|
17
|
+
Run a command and return (success, output, duration_ms).
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
start = time.perf_counter()
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
cmd,
|
|
23
|
+
capture_output=True,
|
|
24
|
+
text=True,
|
|
25
|
+
timeout=timeout,
|
|
26
|
+
)
|
|
27
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
28
|
+
output = (result.stdout or "") + (result.stderr or "")
|
|
29
|
+
return result.returncode == 0, output.strip(), duration_ms
|
|
30
|
+
except subprocess.TimeoutExpired:
|
|
31
|
+
return False, "Command timed out", None
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
return False, f"Command not found: {cmd[0]}", None
|
|
34
|
+
except Exception as e:
|
|
35
|
+
return False, str(e), None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_ping(target: str, count: int = 4, timeout: int = 15) -> NetworkTestResult:
|
|
39
|
+
"""
|
|
40
|
+
Run ping to target from local machine.
|
|
41
|
+
Uses platform-appropriate flags (Linux/macOS: -c, Windows: -n).
|
|
42
|
+
"""
|
|
43
|
+
system = platform.system().lower()
|
|
44
|
+
if system == "windows":
|
|
45
|
+
cmd = ["ping", "-n", str(count), target]
|
|
46
|
+
else:
|
|
47
|
+
cmd = ["ping", "-c", str(count), target]
|
|
48
|
+
|
|
49
|
+
success, output, duration_ms = _run_command(cmd, timeout=timeout)
|
|
50
|
+
|
|
51
|
+
if success:
|
|
52
|
+
return NetworkTestResult(
|
|
53
|
+
test_type="ping",
|
|
54
|
+
target=target,
|
|
55
|
+
status=CheckStatus.PASS,
|
|
56
|
+
message="Ping successful",
|
|
57
|
+
output=output,
|
|
58
|
+
duration_ms=duration_ms,
|
|
59
|
+
)
|
|
60
|
+
return NetworkTestResult(
|
|
61
|
+
test_type="ping",
|
|
62
|
+
target=target,
|
|
63
|
+
status=CheckStatus.FAIL,
|
|
64
|
+
message="Ping failed",
|
|
65
|
+
output=output,
|
|
66
|
+
duration_ms=duration_ms,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_dns(hostname: str, timeout: int = 10) -> NetworkTestResult:
|
|
71
|
+
"""
|
|
72
|
+
Resolve hostname via dig (preferred) or nslookup.
|
|
73
|
+
"""
|
|
74
|
+
# Prefer dig if available (more parseable output)
|
|
75
|
+
if shutil.which("dig"):
|
|
76
|
+
cmd = ["dig", "+short", hostname]
|
|
77
|
+
success, output, duration_ms = _run_command(cmd, timeout=timeout)
|
|
78
|
+
elif shutil.which("nslookup"):
|
|
79
|
+
cmd = ["nslookup", hostname]
|
|
80
|
+
success, output, duration_ms = _run_command(cmd, timeout=timeout)
|
|
81
|
+
else:
|
|
82
|
+
return NetworkTestResult(
|
|
83
|
+
test_type="dns",
|
|
84
|
+
target=hostname,
|
|
85
|
+
status=CheckStatus.FAIL,
|
|
86
|
+
message="Neither dig nor nslookup found - cannot resolve DNS",
|
|
87
|
+
output="",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if success and output:
|
|
91
|
+
return NetworkTestResult(
|
|
92
|
+
test_type="dns",
|
|
93
|
+
target=hostname,
|
|
94
|
+
status=CheckStatus.PASS,
|
|
95
|
+
message="DNS resolution successful",
|
|
96
|
+
output=output,
|
|
97
|
+
duration_ms=duration_ms,
|
|
98
|
+
)
|
|
99
|
+
return NetworkTestResult(
|
|
100
|
+
test_type="dns",
|
|
101
|
+
target=hostname,
|
|
102
|
+
status=CheckStatus.FAIL,
|
|
103
|
+
message="DNS resolution failed or returned empty",
|
|
104
|
+
output=output,
|
|
105
|
+
duration_ms=duration_ms,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _port_check_socket(host: str, port: int, timeout: int) -> tuple[bool, float]:
|
|
110
|
+
"""Test TCP connectivity using Python socket. Returns (success, duration_ms)."""
|
|
111
|
+
start = time.perf_counter()
|
|
112
|
+
try:
|
|
113
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
114
|
+
sock.settimeout(timeout)
|
|
115
|
+
sock.connect((host, port))
|
|
116
|
+
sock.close()
|
|
117
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
118
|
+
return True, duration_ms
|
|
119
|
+
except (socket.timeout, socket.error, OSError):
|
|
120
|
+
return False, (time.perf_counter() - start) * 1000
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def run_port_check(host: str, port: int, timeout: int = 5) -> NetworkTestResult:
|
|
124
|
+
"""
|
|
125
|
+
Test TCP connectivity to host:port using nc (netcat) or Python socket.
|
|
126
|
+
"""
|
|
127
|
+
target_str = f"{host}:{port}"
|
|
128
|
+
|
|
129
|
+
# Prefer nc (netcat) - more commonly available on macOS/Linux
|
|
130
|
+
if shutil.which("nc"):
|
|
131
|
+
cmd = ["nc", "-zv", "-w", str(timeout), host, str(port)]
|
|
132
|
+
success, output, duration_ms = _run_command(cmd, timeout=timeout + 2)
|
|
133
|
+
else:
|
|
134
|
+
# Fallback to Python socket - no external deps, works everywhere
|
|
135
|
+
success, duration_ms = _port_check_socket(host, port, timeout)
|
|
136
|
+
output = ""
|
|
137
|
+
|
|
138
|
+
if success:
|
|
139
|
+
return NetworkTestResult(
|
|
140
|
+
test_type="port",
|
|
141
|
+
target=target_str,
|
|
142
|
+
status=CheckStatus.PASS,
|
|
143
|
+
message="Port is reachable",
|
|
144
|
+
output=output,
|
|
145
|
+
duration_ms=duration_ms,
|
|
146
|
+
)
|
|
147
|
+
return NetworkTestResult(
|
|
148
|
+
test_type="port",
|
|
149
|
+
target=target_str,
|
|
150
|
+
status=CheckStatus.FAIL,
|
|
151
|
+
message="Port is not reachable",
|
|
152
|
+
output=output,
|
|
153
|
+
duration_ms=duration_ms,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parse_port_target(target: str) -> tuple[str, int] | None:
|
|
158
|
+
"""
|
|
159
|
+
Parse 'host:port' string. Returns (host, port) or None if invalid.
|
|
160
|
+
"""
|
|
161
|
+
if ":" not in target:
|
|
162
|
+
return None
|
|
163
|
+
parts = target.rsplit(":", 1)
|
|
164
|
+
if len(parts) != 2:
|
|
165
|
+
return None
|
|
166
|
+
host, port_str = parts
|
|
167
|
+
try:
|
|
168
|
+
port = int(port_str)
|
|
169
|
+
if 1 <= port <= 65535:
|
|
170
|
+
return host.strip(), port
|
|
171
|
+
except ValueError:
|
|
172
|
+
pass
|
|
173
|
+
return None
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vpcrawler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI tool to validate AWS VPC connectivity configuration between two VPCs
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: boto3>=1.28
|
|
8
|
+
Requires-Dist: typer>=0.9
|
|
9
|
+
Requires-Dist: rich>=13.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# AWS VPC Networking Test Tool
|
|
14
|
+
|
|
15
|
+
A Python CLI tool that validates AWS VPC connectivity configuration between two VPCs and runs local network connectivity tests. Use it to troubleshoot cross-VPC connectivity, verify peering and Transit Gateway setup, and test reachability from your local machine to targets inside your VPCs.
|
|
16
|
+
|
|
17
|
+
## Overview
|
|
18
|
+
|
|
19
|
+
The tool combines two capabilities:
|
|
20
|
+
|
|
21
|
+
1. **Configuration validation** — Uses the AWS API (boto3) to analyze route tables, security groups, network ACLs, VPC peering, and Transit Gateway attachments. It determines whether the *configuration* allows traffic to flow between two VPCs.
|
|
22
|
+
|
|
23
|
+
2. **Connectivity testing** — Runs ping, DNS resolution, and TCP port checks from your local machine to verify actual reachability. Targets can be specified manually or discovered automatically from EC2 instances in the VPCs.
|
|
24
|
+
|
|
25
|
+
All commands run from your local machine using your AWS profile. No agents or deployments inside AWS are required.
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **VPC configuration analysis** — CIDR overlap, route tables, security groups, NACLs, peering, Transit Gateway
|
|
30
|
+
- **IP range reporting** — VPC and subnet CIDR blocks with availability zones
|
|
31
|
+
- **Auto-discovery** — Find running EC2 instances in both VPCs and test them automatically
|
|
32
|
+
- **Local network tests** — Ping, DNS (dig/nslookup), TCP port checks
|
|
33
|
+
- **JSON output** — Machine-readable output for scripting and CI/CD
|
|
34
|
+
- **Cross-platform** — Works on macOS, Linux, and Windows
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
- Python 3.9+
|
|
39
|
+
- AWS credentials configured (profile or environment)
|
|
40
|
+
- Network access to AWS APIs and to targets you test (VPN, bastion, or direct if testing public endpoints)
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Clone or navigate to the project
|
|
46
|
+
cd vpcrawler
|
|
47
|
+
|
|
48
|
+
# Install in editable mode
|
|
49
|
+
pip install -e .
|
|
50
|
+
|
|
51
|
+
# Or install dependencies only
|
|
52
|
+
pip install -r requirements.txt
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Dependencies
|
|
56
|
+
|
|
57
|
+
- **boto3** — AWS SDK for Python
|
|
58
|
+
- **typer** — CLI framework
|
|
59
|
+
- **rich** — Formatted console output
|
|
60
|
+
|
|
61
|
+
## Quick Start
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Validate configuration between two VPCs
|
|
65
|
+
vpcrawler --vpc-a vpc-0abc123 --vpc-b vpc-0def456 --profile myprofile
|
|
66
|
+
|
|
67
|
+
# Validate and automatically test EC2 instances in both VPCs
|
|
68
|
+
vpcrawler --vpc-a vpc-0abc123 --vpc-b vpc-0def456 --profile myprofile --auto-test
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage Modes
|
|
72
|
+
|
|
73
|
+
### 1. Configuration Validation Only
|
|
74
|
+
|
|
75
|
+
Validates that the AWS configuration allows connectivity between two VPCs. No network tests are run.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
vpcrawler --vpc-a vpc-0abc123 --vpc-b vpc-0def456 --profile myprofile --region us-east-1
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Use this to:
|
|
82
|
+
- Audit peering or Transit Gateway setup
|
|
83
|
+
- Verify route tables and security groups
|
|
84
|
+
- Check for CIDR overlap before peering
|
|
85
|
+
|
|
86
|
+
### 2. Auto-Test Mode
|
|
87
|
+
|
|
88
|
+
Discovers running EC2 instances in both VPCs and automatically runs ping and port checks against their private IPs.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
vpcrawler --vpc-a vpc-0abc123 --vpc-b vpc-0def456 --profile myprofile --auto-test
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
With options:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
vpcrawler --vpc-a vpc-0abc123 --vpc-b vpc-0def456 --auto-test \
|
|
98
|
+
--max-targets-per-vpc 3 \
|
|
99
|
+
--auto-test-ports 22,443,8080
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Note:** Auto-test requires your local machine to reach the EC2 private IPs (e.g., via VPN or bastion host). If you cannot reach private IPs from your machine, use manual targets instead.
|
|
103
|
+
|
|
104
|
+
### 3. Manual Connectivity Tests
|
|
105
|
+
|
|
106
|
+
Specify exact targets for ping, DNS, and port checks.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
vpcrawler --vpc-a vpc-0abc123 --vpc-b vpc-0def456 \
|
|
110
|
+
--ping 10.0.1.50 \
|
|
111
|
+
--dns internal.example.com \
|
|
112
|
+
--port 10.0.2.100:443
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
You can use any combination of `--ping`, `--port`, and `--dns`.
|
|
116
|
+
|
|
117
|
+
### 4. Combined Usage
|
|
118
|
+
|
|
119
|
+
Auto-test and manual targets can be used together. All tests run and appear in the output.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
vpcrawler --vpc-a vpc-0abc123 --vpc-b vpc-0def456 --auto-test \
|
|
123
|
+
--ping 10.0.1.1 \
|
|
124
|
+
--dns api.internal.example.com
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Command Reference
|
|
128
|
+
|
|
129
|
+
| Option | Required | Default | Description |
|
|
130
|
+
|--------|----------|---------|-------------|
|
|
131
|
+
| `--vpc-a` | Yes | — | First VPC ID |
|
|
132
|
+
| `--vpc-b` | Yes | — | Second VPC ID |
|
|
133
|
+
| `--profile` | No | `AWS_PROFILE` env | AWS profile name |
|
|
134
|
+
| `--region` | No | `us-east-1` | AWS region |
|
|
135
|
+
| `--auto-test` | No | `false` | Discover EC2 instances and run ping/port checks |
|
|
136
|
+
| `--max-targets-per-vpc` | No | `5` | Max instances to test per VPC with `--auto-test` |
|
|
137
|
+
| `--auto-test-ports` | No | `22,443` | Ports to check with `--auto-test` (comma-separated) |
|
|
138
|
+
| `--ping` | No | — | IP or hostname to ping |
|
|
139
|
+
| `--dns` | No | — | Hostname to resolve via DNS |
|
|
140
|
+
| `--port` | No | — | `host:port` to test (e.g., `10.0.2.100:443`) |
|
|
141
|
+
| `--json` | No | `false` | Output results as JSON |
|
|
142
|
+
|
|
143
|
+
## Configuration Validation
|
|
144
|
+
|
|
145
|
+
The tool runs these checks to determine if connectivity *should* work between the two VPCs:
|
|
146
|
+
|
|
147
|
+
### CIDR Overlap
|
|
148
|
+
|
|
149
|
+
VPC peering and Transit Gateway require non-overlapping CIDR blocks. If the VPCs overlap, peering cannot be established.
|
|
150
|
+
|
|
151
|
+
- **PASS** — CIDRs do not overlap
|
|
152
|
+
- **FAIL** — CIDRs overlap; connectivity not possible
|
|
153
|
+
|
|
154
|
+
### VPC Peering
|
|
155
|
+
|
|
156
|
+
Checks for an active VPC peering connection between the two VPCs.
|
|
157
|
+
|
|
158
|
+
- **PASS** — Active peering exists
|
|
159
|
+
- **FAIL** — No peering (and no Transit Gateway fallback)
|
|
160
|
+
|
|
161
|
+
### Transit Gateway
|
|
162
|
+
|
|
163
|
+
If no peering exists, checks whether both VPCs are attached to the same Transit Gateway.
|
|
164
|
+
|
|
165
|
+
- **PASS** — Both VPCs attached to same TGW
|
|
166
|
+
- **FAIL** — No shared Transit Gateway
|
|
167
|
+
|
|
168
|
+
### Route Tables
|
|
169
|
+
|
|
170
|
+
When peering or TGW exists, verifies that route tables in both VPCs have routes for the peer VPC’s CIDR.
|
|
171
|
+
|
|
172
|
+
- **PASS** — Routes exist in both directions
|
|
173
|
+
- **FAIL** — Missing routes in one or both VPCs
|
|
174
|
+
|
|
175
|
+
### Security Groups
|
|
176
|
+
|
|
177
|
+
Checks whether any security groups allow traffic between the VPC CIDRs (ingress and egress).
|
|
178
|
+
|
|
179
|
+
- **PASS** — Rules allow cross-VPC traffic
|
|
180
|
+
- **WARN** — May restrict traffic; manual verification recommended
|
|
181
|
+
|
|
182
|
+
### Network ACLs
|
|
183
|
+
|
|
184
|
+
Looks for NACL deny rules that could block cross-VPC traffic.
|
|
185
|
+
|
|
186
|
+
- **PASS** — No blocking rules found
|
|
187
|
+
- **WARN** — Deny rules may block traffic; manual verification recommended
|
|
188
|
+
|
|
189
|
+
### Connectivity Path
|
|
190
|
+
|
|
191
|
+
Overall result: **VALID** if no checks fail, **INVALID** if any check fails.
|
|
192
|
+
|
|
193
|
+
## Local Connectivity Tests
|
|
194
|
+
|
|
195
|
+
Tests run from your local machine. Your machine must have network path to the targets (VPN, bastion, or direct access).
|
|
196
|
+
|
|
197
|
+
### Ping
|
|
198
|
+
|
|
199
|
+
- **Command:** `ping -c 4` (Linux/macOS) or `ping -n 4` (Windows)
|
|
200
|
+
- **Purpose:** ICMP reachability
|
|
201
|
+
- **Timeout:** 15 seconds
|
|
202
|
+
|
|
203
|
+
### DNS
|
|
204
|
+
|
|
205
|
+
- **Command:** `dig +short` (preferred) or `nslookup`
|
|
206
|
+
- **Purpose:** Hostname resolution
|
|
207
|
+
- **Timeout:** 10 seconds
|
|
208
|
+
- **Note:** Requires `dig` or `nslookup` on your system
|
|
209
|
+
|
|
210
|
+
### Port Check
|
|
211
|
+
|
|
212
|
+
- **Command:** `nc -zv` (netcat) or Python socket fallback
|
|
213
|
+
- **Purpose:** TCP connectivity
|
|
214
|
+
- **Timeout:** 5 seconds
|
|
215
|
+
- **Note:** Uses `nc` when available; otherwise uses a built-in socket check
|
|
216
|
+
|
|
217
|
+
## Output
|
|
218
|
+
|
|
219
|
+
### Console Output
|
|
220
|
+
|
|
221
|
+
- **VPC Configuration** — VPC IDs, CIDR blocks, region
|
|
222
|
+
- **IP Ranges** — VPC and subnet CIDRs with availability zones
|
|
223
|
+
- **Validation Checks** — Table of checks with PASS/FAIL/WARN
|
|
224
|
+
- **Connectivity Path** — Overall VALID or INVALID
|
|
225
|
+
- **Local Network Tests** — Table of ping, DNS, and port results
|
|
226
|
+
|
|
227
|
+
Failed network tests include the raw command output for debugging.
|
|
228
|
+
|
|
229
|
+
### JSON Output
|
|
230
|
+
|
|
231
|
+
Use `--json` for machine-readable output:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
vpcrawler --vpc-a vpc-0abc123 --vpc-b vpc-0def456 --json
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Structure:
|
|
238
|
+
|
|
239
|
+
```json
|
|
240
|
+
{
|
|
241
|
+
"config": {
|
|
242
|
+
"vpc_a": { "vpc_id": "...", "cidr_block": "...", "subnets": [...] },
|
|
243
|
+
"vpc_b": { "vpc_id": "...", "cidr_block": "...", "subnets": [...] },
|
|
244
|
+
"checks": [{ "name": "...", "status": "pass|fail|warn", "message": "..." }],
|
|
245
|
+
"connectivity_path_valid": true,
|
|
246
|
+
"error": null
|
|
247
|
+
},
|
|
248
|
+
"network_tests": [
|
|
249
|
+
{ "test_type": "ping", "target": "...", "status": "pass", "message": "...", "duration_ms": 123 }
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## AWS Permissions
|
|
255
|
+
|
|
256
|
+
The tool uses these EC2 API operations:
|
|
257
|
+
|
|
258
|
+
- `DescribeVpcs`
|
|
259
|
+
- `DescribeSubnets`
|
|
260
|
+
- `DescribeRouteTables`
|
|
261
|
+
- `DescribeSecurityGroups`
|
|
262
|
+
- `DescribeNetworkAcls`
|
|
263
|
+
- `DescribeVpcPeeringConnections`
|
|
264
|
+
- `DescribeTransitGatewayAttachments`
|
|
265
|
+
- `DescribeInstances` (when using `--auto-test`)
|
|
266
|
+
|
|
267
|
+
A minimal IAM policy:
|
|
268
|
+
|
|
269
|
+
```json
|
|
270
|
+
{
|
|
271
|
+
"Version": "2012-10-17",
|
|
272
|
+
"Statement": [
|
|
273
|
+
{
|
|
274
|
+
"Effect": "Allow",
|
|
275
|
+
"Action": [
|
|
276
|
+
"ec2:DescribeVpcs",
|
|
277
|
+
"ec2:DescribeSubnets",
|
|
278
|
+
"ec2:DescribeRouteTables",
|
|
279
|
+
"ec2:DescribeSecurityGroups",
|
|
280
|
+
"ec2:DescribeNetworkAcls",
|
|
281
|
+
"ec2:DescribeVpcPeeringConnections",
|
|
282
|
+
"ec2:DescribeTransitGatewayAttachments",
|
|
283
|
+
"ec2:DescribeInstances"
|
|
284
|
+
],
|
|
285
|
+
"Resource": "*"
|
|
286
|
+
}
|
|
287
|
+
]
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## Troubleshooting
|
|
292
|
+
|
|
293
|
+
### "VPC(s) not found"
|
|
294
|
+
|
|
295
|
+
- Confirm VPC IDs are correct
|
|
296
|
+
- Ensure `--region` matches the VPCs
|
|
297
|
+
- Verify AWS credentials and profile
|
|
298
|
+
|
|
299
|
+
### "No active VPC peering or Transit Gateway connection"
|
|
300
|
+
|
|
301
|
+
- Check that peering is in `active` state
|
|
302
|
+
- For Transit Gateway, ensure both VPCs are attached to the same TGW
|
|
303
|
+
|
|
304
|
+
### Ping/port checks fail from local machine
|
|
305
|
+
|
|
306
|
+
- Private IPs are only reachable from within the VPC or via VPN/bastion
|
|
307
|
+
- Ensure your machine has a path to the target (VPN, SSH tunnel, etc.)
|
|
308
|
+
- Firewall or security groups may block ICMP or the tested ports
|
|
309
|
+
|
|
310
|
+
### DNS resolution fails
|
|
311
|
+
|
|
312
|
+
- `dig` or `nslookup` must be installed
|
|
313
|
+
- Private hostnames require DNS resolution that can reach your private zones (e.g., Route 53 Resolver, VPN split-tunnel DNS)
|
|
314
|
+
|
|
315
|
+
## Limitations
|
|
316
|
+
|
|
317
|
+
- **Single region** — Both VPCs must be in the same region (cross-region peering not yet supported)
|
|
318
|
+
- **Local tests** — Connectivity tests run from your machine; they do not test traffic between instances inside the VPCs
|
|
319
|
+
- **Private IPs** — Auto-test uses private IPs; your machine must be able to reach them (VPN, etc.)
|
|
320
|
+
|
|
321
|
+
## Development
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
# Install with dev dependencies
|
|
325
|
+
pip install -e ".[dev]"
|
|
326
|
+
|
|
327
|
+
# Run tests
|
|
328
|
+
pytest tests/ -v
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Project Structure
|
|
332
|
+
|
|
333
|
+
```
|
|
334
|
+
vpcrawler/
|
|
335
|
+
├── src/aws_vpc_net_tester/
|
|
336
|
+
│ ├── cli.py # CLI entry point
|
|
337
|
+
│ ├── config_validator.py # boto3 VPC analysis + EC2 discovery
|
|
338
|
+
│ ├── models.py # Data classes
|
|
339
|
+
│ └── network_tester.py # Ping, DNS, port checks
|
|
340
|
+
├── tests/
|
|
341
|
+
├── pyproject.toml
|
|
342
|
+
└── requirements.txt
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Publishing to PyPI
|
|
346
|
+
|
|
347
|
+
Releases are published automatically via [GitHub Actions](https://github.com/marketplace/actions/pypi-publish) when you push a version tag.
|
|
348
|
+
|
|
349
|
+
### Setup (one-time)
|
|
350
|
+
|
|
351
|
+
1. **Create a PyPI project** — Register `vpcrawler` on [pypi.org](https://pypi.org) if it does not exist.
|
|
352
|
+
|
|
353
|
+
2. **Configure trusted publishing** — In your PyPI project settings, add a trusted publisher:
|
|
354
|
+
- Publisher: GitHub Actions
|
|
355
|
+
- Owner: your GitHub org/username
|
|
356
|
+
- Repository: your repo name
|
|
357
|
+
- Workflow: `publish.yml`
|
|
358
|
+
- Environment: `pypi`
|
|
359
|
+
|
|
360
|
+
3. **Create the `pypi` environment** — In your GitHub repo: Settings → Environments → New environment → name it `pypi`.
|
|
361
|
+
|
|
362
|
+
### Releasing
|
|
363
|
+
|
|
364
|
+
1. Update the version in `pyproject.toml`.
|
|
365
|
+
2. Commit, then create and push a tag:
|
|
366
|
+
|
|
367
|
+
```bash
|
|
368
|
+
git tag v0.1.0
|
|
369
|
+
git push origin v0.1.0
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
3. The workflow builds the package and publishes to PyPI. No API tokens are required when using trusted publishing.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
aws_vpc_net_tester/__init__.py,sha256=n3kSbcrlYfswhECc3O8l_k9ifQhYSDBlvfPkMqR-XF8,59
|
|
2
|
+
aws_vpc_net_tester/__main__.py,sha256=lZWyRP3_kCed7zfmMnBQCsnmioCEPvRJzLtjh-s6xuE,113
|
|
3
|
+
aws_vpc_net_tester/cli.py,sha256=KhXfHFfG35YSnf0B4hOU12xMLaCpLHDn5FD8NEmQrck,7308
|
|
4
|
+
aws_vpc_net_tester/config_validator.py,sha256=-PSJzOF-w02k9K048SXmvi8-I_swLOCYwIdZkWR20wM,15596
|
|
5
|
+
aws_vpc_net_tester/models.py,sha256=yll4iEjy8XRv6YlGfFjjR7TedyfPvXdbvVpiNkO56Gc,3109
|
|
6
|
+
aws_vpc_net_tester/network_tester.py,sha256=kiYCzDHjjZaOBXkMghm26mmwHV1oNacq8iAtzD-3hCk,5280
|
|
7
|
+
vpcrawler-0.1.0.dist-info/METADATA,sha256=Te7c5_SwHZxw6rZkBN2ZujNNafBsffav_FanK2Kt8O0,11395
|
|
8
|
+
vpcrawler-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
9
|
+
vpcrawler-0.1.0.dist-info/entry_points.txt,sha256=TI8qKVzTP1w4hpPcB_mrvkKsUPUuC55SK0ydVLQCAJU,57
|
|
10
|
+
vpcrawler-0.1.0.dist-info/top_level.txt,sha256=IIfuJwiw1qmBwfqZXmNfvMB0WbSR-OR23yxj8ObvgFc,19
|
|
11
|
+
vpcrawler-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aws_vpc_net_tester
|