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.
@@ -0,0 +1,3 @@
1
+ """AWS VPC Networking Test Tool."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow running as python -m aws_vpc_net_tester."""
2
+
3
+ from .cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vpcrawler = aws_vpc_net_tester.cli:app
@@ -0,0 +1 @@
1
+ aws_vpc_net_tester