Quy hoạch và kiểm tra cách bố trí mạng con (subnet) IPv4 cho các mạng trên AWS, Azure, GCP hoặc tại chỗ (on-prem). Tính toán kích thước từng mạng con dựa trên số lượng máy trạm (host) cộng với mức tăng trưởng và các địa chỉ dành riêng của nhà cung cấp, cấp phát các khối CIDR được căn chỉnh theo thứ tự từ lớn nhất trước, đồng thời cảnh báo các mạng con bị chồng lấn, các dải VPC xung đột với VPC được kết nối ngang hàng (peered VPC), mạng VPN hoặc cầu nối docker0, các dải IP công cộng, giới hạn kích thước, khoảng dự phòng thấp và cách bố trí chỉ có một vùng (single-zone). Bao gồm công cụ quy hoạch viết bằng thư viện chuẩn Python kèm tùy chọn --self-test.
---
name: vpc-subnet-cidr-planner
description: Quy hoạch và kiểm tra cách bố trí mạng con (subnet) IPv4 cho các mạng trên AWS, Azure, GCP hoặc tại chỗ (on-prem). Tính toán kích thước từng mạng con dựa trên số lượng máy trạm (host) cộng với mức tăng trưởng và các địa chỉ dành riêng của nhà cung cấp, cấp phát các khối CIDR được căn chỉnh theo thứ tự từ lớn nhất trước, đồng thời cảnh báo các mạng con bị chồng lấn, các dải VPC xung đột với VPC được kết nối ngang hàng (peered VPC), mạng VPN hoặc cầu nối docker0, các dải IP công cộng, giới hạn kích thước, khoảng dự phòng thấp và cách bố trí chỉ có một vùng (single-zone). Bao gồm công cụ quy hoạch viết bằng thư viện chuẩn Python kèm tùy chọn --self-test.
---
# Công cụ Quy hoạch CIDR Mạng con VPC
Bạn giúp các kỹ sư phân chia một mạng lớn thành các mạng con (subnet) vừa vặn với nhu cầu hiện tại, chừa đủ không gian để mở rộng và không bao giờ xung đột với bất kỳ mạng nào họ cần định tuyến tới trong tương lai. Bạn biến yêu cầu "chúng tôi cần các mạng con public, app và data ở hai vùng (zone)" thành các khối CIDR chính xác, giải thích rõ những con số tính toán và phát hiện sớm các điểm chồng lấn vốn rất vất vả để khắc phục một khi các tải công việc (workloads) đã đi vào vận hành.
## Các tệp trong kỹ năng này
- `scripts/cidr_plan.py` - tính toán kích thước và cấp phát các mạng con, liệt kê các dải địa chỉ host khả dụng cùng các khối còn trống, đồng thời báo cáo các phát hiện (chỉ dùng thư viện chuẩn Python 3, sử dụng mô-đun `ipaddress`)
- `references/cidr-math-and-provider-rules.md` - bảng kích thước tiền tố (prefix), quy tắc căn chỉnh (alignment), các địa chỉ dành riêng và giới hạn theo từng nhà cung cấp, các dải địa chỉ riêng (private ranges)
- `references/subnet-layout-playbook.md` - cách bố trí phân tầng (tiered layouts), chiến lược phân vùng (zone), tính toán kích thước mạng con cho pod Kubernetes, tránh chồng lấn với VPN và các mạng ngang hàng (peers), các phương án đánh số lại (renumbering)
- `templates/subnet-plan.md` - mẫu tài liệu quy hoạch mạng con dùng để rà soát hoặc đính kèm vào pull request
- `examples/shop-platform-vpc.json` - một mạng AWS /18 có các mạng con cũ (legacy), một VPC phân tích được kết nối ngang hàng (peered) và sáu mạng con mới trải trên hai vùng
## Quy trình làm việc
### 1. Thu thập thông tin thực tế về mạng
Hỏi về nhà cung cấp dịch vụ, dải CIDR của VPC / VNet (hoặc dải địa chỉ họ dự định dùng), các mạng con hiện có, mọi mạng mà nó phải giao tiếp cùng (các VPC kết nối ngang hàng, mạng VPN, văn phòng tại chỗ on-prem, đường kết nối đối tác, các đám mây khác), và các mạng con họ cần tạo: mục đích sử dụng, vùng (zone), số lượng host hiện tại và mức tăng trưởng dự kiến. Đối với Kubernetes, hãy hỏi xem các pod có lấy địa chỉ trực tiếp từ VPC hay không (AWS VPC CNI, Azure CNI), vì khi đó chính số lượng pod chứ không phải số lượng node mới là yếu tố quyết định kích thước mạng con.
### 2. Chạy công cụ quy hoạch
```
python3 scripts/cidr_plan.py examples/shop-platform-vpc.json
python3 scripts/cidr_plan.py plan.json --json
python3 scripts/cidr_plan.py --self-test
```
Mã thoát (exit code) 1 nghĩa là có các phát hiện mức HIGH (chồng lấn địa chỉ, hết không gian trống), 2 nghĩa là dữ liệu đầu vào bị lỗi (các bit phần host bị bật, dùng IPv6, các dải VPC bị chồng lấn nhau).
### 3. Rà soát kết quả
Khắc phục các phát hiện mức HIGH trước: sự chồng lấn với mạng ngang hàng (peer) hoặc VPN sẽ không thể định tuyến được và thường đồng nghĩa với việc phải chọn một dải VPC mới ngay bây giờ khi chi phí thay đổi còn thấp. Sử dụng `references/subnet-layout-playbook.md` để điều chỉnh cách bố trí (các mạng con có kích thước bằng nhau ở mỗi vùng, chừa một khối dự phòng cho mỗi tầng, chừa chỗ cho vùng thứ ba) và dùng `references/cidr-math-and-provider-rules.md` để giải thích về kích thước. Chạy lại công cụ sau mỗi lần thay đổi.
### 4. Bàn giao kết quả
Điền vào `templates/subnet-plan.md` bảng quy hoạch, các khối còn trống, các phát hiện và quyết định. Cung cấp thêm các đoạn mã Terraform hoặc lệnh CLI cho các khối CIDR cuối cùng nếu được yêu cầu.
## Kết quả ví dụ
Đối với `examples/shop-platform-vpc.json` (AWS 10.20.0.0/18): các mạng con tầng app gồm 2,500 pod + 60% tăng trưởng được cấp các khối /20 (mỗi khối có 4,091 địa chỉ khả dụng) tại 10.20.16.0/20 và 10.20.32.0/20; các mạng con cho bộ cân bằng tải (load balancer) và tầng dữ liệu (data) được cấp các khối /25 (123 địa chỉ khả dụng). Các phát hiện: mức HIGH do mạng con cũ rds-b 10.20.1.128/25 bị chồng lấn với rds-a 10.20.1.0/24; mức HIGH do VPC phân tích được kết nối ngang hàng 10.20.48.0/20 nằm gọn bên trong dải VPC này nên việc định tuyến tới nó sẽ bị lỗi; mức LOW do chỉ còn trống 20% không gian địa chỉ.
## Quy tắc
- Không bao giờ đề xuất một dải địa chỉ bị chồng lấn với mạng ngang hàng (peer), VPN hoặc mạng tại chỗ (on-prem) đã biết; hãy hỏi danh sách đầy đủ nếu chưa chắc chắn.
- Quy hoạch có tính đến mức tăng trưởng và một vùng (zone) trong tương lai; việc thay đổi kích thước một mạng con tại chỗ thường là bất khả thi.
- Giữ các phép tính IPv4 tuyệt đối chính xác; hiển thị số lượng địa chỉ khả dụng sau khi đã trừ đi các địa chỉ dành riêng của nhà cung cấp.
- Không tự mình thay đổi các mạng đang hoạt động; hãy đưa ra bản quy hoạch và thứ tự thực hiện thay đổi để người dùng tự áp dụng.
FILE:references/cidr-math-and-provider-rules.md
# Toán học CIDR và Quy tắc của Nhà cung cấp
## Kích thước tiền tố (IPv4)
| Tiền tố (Prefix) | Tổng số địa chỉ | Khả dụng trên AWS / Azure (-5) | Khả dụng trên GCP (-4) | Khả dụng truyền thống (-2) |
|---|---|---|---|---|
| /16 | 65,536 | 65,531 | 65,532 | 65,534 |
| /18 | 16,384 | 16,379 | 16,380 | 16,382 |
| /19 | 8,192 | 8,187 | 8,188 | 8,190 |
| /20 | 4,096 | 4,091 | 4,092 | 4,094 |
| /21 | 2,048 | 2,043 | 2,044 | 2,046 |
| /22 | 1,024 | 1,019 | 1,020 | 1,022 |
| /23 | 512 | 507 | 508 | 510 |
| /24 | 256 | 251 | 252 | 254 |
| /25 | 128 | 123 | 124 | 126 |
| /26 | 64 | 59 | 60 | 62 |
| /27 | 32 | 27 | 28 | 30 |
| /28 | 16 | 11 | 12 | 14 |
Mỗi khi tiền tố tăng hoặc giảm 1 đơn vị thì kích thước khối địa chỉ sẽ giảm đi một nửa hoặc tăng gấp đôi. Số địa chỉ = 2^(32 - prefix).
## Quy tắc căn chỉnh (Alignment)
Một khối địa chỉ phải bắt đầu tại một bội số của chính kích thước khối đó: một khối /20 bắt đầu tại x.x.0.0, x.x.16.0, x.x.32.0, ...; cách viết 10.20.8.0/20 là không hợp lệ (do các bit phần host bị bật). Việc cấp phát các khối lớn nhất trước giúp giữ cho không gian địa chỉ không bị phân mảnh; nếu đặt các mạng con nhỏ vào trước, chúng có thể chặn mất mọi vị trí căn chỉnh hợp lệ dành cho một khối lớn.
## Các địa chỉ dành riêng trên mỗi mạng con
- **AWS:** bốn địa chỉ đầu tiên (địa chỉ mạng, bộ định tuyến VPC, DNS, dự phòng) và địa chỉ cuối cùng (broadcast) -> trừ 5 địa chỉ. CIDR của VPC từ /16 đến /28, các mạng con từ /16 đến /28. Mặc định tối đa 5 khối CIDR trên mỗi VPC (có thể thêm các CIDR phụ sau để mở rộng không gian).
- **Azure:** bốn địa chỉ đầu tiên (địa chỉ mạng, cổng gateway, 2 địa chỉ DNS) và địa chỉ cuối cùng -> trừ 5 địa chỉ. Mạng con nhỏ nhất đến /29. Một số dịch vụ yêu cầu các mạng con chuyên dụng riêng (GatewaySubnet từ /27 trở lên, AzureFirewallSubnet /26, AzureBastionSubnet /26).
- **GCP:** địa chỉ mạng, cổng gateway, địa chỉ áp chót và địa chỉ broadcast -> trừ 4 địa chỉ. Các mạng con có phạm vi theo khu vực (regional chứ không phải zonal), nhỏ nhất là /29; hỗ trợ các dải địa chỉ phụ (secondary ranges) cho các pod và service của GKE.
- **Truyền thống / tại chỗ (Classic / on-prem):** địa chỉ mạng và địa chỉ broadcast -> trừ 2 địa chỉ (các liên kết điểm-nối-điểm /31 là trường hợp đặc biệt).
## Các dải địa chỉ riêng và đặc biệt
- RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16.
- Không gian dùng chung cho Carrier-grade NAT 100.64.0.0/10 (RFC 6598): có thể dùng bên trong các đám mây, nhưng cần kiểm tra để đảm bảo nhà mạng ISP / VPN không sử dụng dải này.
- Nên tránh: 172.17.0.0/16 (cầu nối docker0 mặc định), 169.254.0.0/16 (link-local, dịch vụ metadata trên đám mây), 192.168.0.0/24 và 192.168.1.0/24 (bộ định tuyến gia đình hay dùng nên người dùng VPN sẽ bị xung đột), 10.0.0.0/16 (VPC mặc định trong nhiều công cụ nên các mạng kết nối ngang hàng thường bị đụng).
- Việc sử dụng các dải địa chỉ công cộng (public ranges) bên trong một VPC sẽ khiến các địa chỉ internet thực tế tương ứng không thể truy cập được từ bên trong VPC đó.
## Công thức tính nhanh
- Tiền tố nhỏ nhất cho N máy trạm (host) khả dụng với R địa chỉ dành riêng: 32 - ceil(log2(N + R)).
- Số lượng host tính cả mức tăng trưởng: ceil(hosts x (1 + growth)).
- Không gian trống sau khi quy hoạch = tổng địa chỉ VPC - (các mạng hiện có + mạng mới + các dải mạng ngang hàng bị chặn nằm bên trong VPC).
FILE:references/subnet-layout-playbook.md
# Sổ tay Bố trí Mạng con (Subnet Layout Playbook)
## Cách bố trí phân tầng trên mỗi vùng (zone)
| Tầng (Tier) | Thành phần chứa bên trong | Kích thước điển hình | Định tuyến (Route) |
|---|---|---|---|
| Public | bộ cân bằng tải (load balancers), cổng NAT (NAT gateways), máy chủ bastion | /24 đến /26 | cổng internet (internet gateway) |
| Private app | máy ảo (VMs), containers, các node và pod của Kubernetes | lớn nhất, /20 đến /22 | cổng NAT (NAT gateway) |
| Data | cơ sở dữ liệu, bộ nhớ đệm (caches), hàng đợi (queues) | /24 đến /26 | không có đường định tuyến ra internet |
| Spare (Dự phòng) | tầng tương lai hoặc vùng (zone) thứ ba | giữ lại một khối đã căn chỉnh | - |
Sử dụng cùng một kích thước cho mỗi tầng ở tất cả các vùng để khi chuyển đổi dự phòng (failover), vùng dự phòng có thể gánh toàn bộ tải.
## Chiến lược phân vùng (Zone strategy)
- Sử dụng ít nhất hai vùng (zones) cho bất kỳ hệ thống môi trường thực tế (production) nào; dùng ba vùng nếu khu vực (region) và ngân sách cho phép.
- Giữ chỗ trước các khối địa chỉ cho vùng thứ ba ngay từ bây giờ dù chưa dùng tới; việc này giúp cấu trúc bố trí luôn đối xứng.
- Các mạng con trên GCP có phạm vi theo khu vực (regional), vì vậy hãy quy hoạch theo từng khu vực thay vì theo từng vùng (zone).
## Tính toán kích thước cho Kubernetes
- Với AWS VPC CNI hoặc Azure CNI, mỗi pod sẽ lấy một địa chỉ IP từ VPC: hãy tính kích thước mạng con tầng app theo số lượng pod ở mức đỉnh (số node x số pod tối đa trên mỗi node), cộng thêm mức tăng đột biến trong quá trình cập nhật cuốn chiếu (rolling updates, thường là +25%) và khi thay thế node.
- Cơ chế ủy quyền tiền tố (Prefix delegation trên AWS) hoặc mạng phủ (overlay networking như Calico, Cilium, kubenet, Azure CNI Overlay) sẽ chuyển các pod sang một dải địa chỉ riêng biệt; khi đó chỉ cần tính kích thước mạng con theo số lượng node và giữ cho dải CIDR của pod không bị trùng với bất kỳ mạng ngang hàng nào.
- Giữ cho dải CIDR của service (ví dụ 172.20.0.0/16) cũng tách biệt hoàn toàn khỏi VPC và các mạng ngang hàng.
## Tránh chồng lấn địa chỉ
- Thu thập mọi mạng mà bạn sẽ cần định tuyến tới: các VPC và tài khoản khác, máy khách VPN, văn phòng, đối tác, các đám mây khác và cả những dải mạng đang được sử dụng bởi các công ty được mua lại/sáp nhập.
- Duy trì một bảng quy hoạch IP tập trung (bảng tính hoặc hệ thống IPAM) và cấp cho mỗi môi trường một khối riêng biệt, ví dụ: 10.20.0.0/16 cho prod, 10.21.0.0/16 cho staging, 10.22.0.0/16 cho dev.
- Không chọn các dải 10.0.0.0/16, 172.17.0.0/16 hoặc từ 192.168.0.0/24 đến 192.168.1.0/24 cho bất kỳ hệ thống nào mà người dùng VPN sẽ kết nối vào.
## Khi đã xảy ra chồng lấn
1. Ưu tiên đánh số lại (renumbering) phía mạng nhỏ hơn hoặc mới hơn ngay lập tức (tạo VPC mới hoặc thêm một dải CIDR phụ, di chuyển hệ thống sang, rồi xóa mạng cũ).
2. Nếu không thể đánh số lại, hãy sử dụng NAT (private NAT gateway, hoặc NAT trên thiết bị VPN) và chỉ định tuyến các dải địa chỉ đã được biên dịch (translated ranges).
3. Các mạng con chồng lấn bên trong cùng một VPC không thể tạo được trên AWS / Azure / GCP; nếu thấy xuất hiện trong tài liệu thì thường là do lỗi gõ nhầm, vì vậy hãy kiểm tra lại các giá trị thực tế.
## Danh sách kiểm tra rà soát (Review checklist)
- [ ] Dải VPC là địa chỉ riêng (private) và duy nhất trên tất cả các mạng ngang hàng (peers) cùng mạng VPN
- [ ] Mọi mạng con đều được căn chỉnh đúng, nằm trong VPC và không bị chồng lấn nhau
- [ ] Kích thước đã tính gộp cả các địa chỉ dành riêng và mức tăng trưởng dự kiến
- [ ] Các tầng cùng loại có kích thước bằng nhau ở mỗi vùng; có giữ lại khối dự phòng
- [ ] Còn trống ít nhất 25% không gian địa chỉ của VPC
FILE:templates/subnet-plan.md
# Bản Quy hoạch Mạng con - <tên mạng>
**Nhà cung cấp / khu vực:** <aws eu-west-1> **VPC CIDR:** <10.x.0.0/16> **Đơn vị sở hữu:** <nhóm> **Ngày:** <ngày>
**Các mạng ngang hàng (Peers) / VPN đã kiểm tra:** <danh sách các dải địa chỉ>
## Các mạng con (Subnets)
| Mạng con | Tầng (Tier) | Vùng (Zone) | Số host hiện tại | Mức tăng trưởng | CIDR | Số IP khả dụng | Bảng định tuyến (Route table) |
|---|---|---|---|---|---|---|---|
| | | | | | | | |
## Các mạng con hiện có được giữ lại
| Tên | CIDR | Ghi chú |
|---|---|---|
| | | |
## Không gian trống
- Đã sử dụng hoặc bị chặn: <n> trên tổng số <total> (còn trống <x>%)
- Các khối dự phòng được giữ chỗ cho: <vùng thứ ba / tầng tương lai>
## Các phát hiện và quyết định
| Mức độ nghiêm trọng | Phát hiện | Quyết định |
|---|---|---|
| | | |
## Thứ tự triển khai (Rollout order)
1. <tạo CIDR phụ / tạo VPC mới>
2. <tạo các mạng con và bảng định tuyến>
3. <di chuyển các tải công việc (workloads), xóa các mạng con cũ>
FILE:examples/shop-platform-vpc.json
{
"name": "shop platform, eu-west-1 production VPC",
"provider": "aws",
"vpc_cidrs": ["10.20.0.0/18"],
"existing": [
{"name": "legacy bastion", "cidr": "10.20.0.0/26"},
{"name": "legacy rds-a", "cidr": "10.20.1.0/24"},
{"name": "legacy rds-b", "cidr": "10.20.1.128/25"}
],
"peers": [
{"name": "office VPN (Istanbul)", "cidr": "192.168.10.0/24"},
{"name": "analytics VPC (peered)", "cidr": "10.20.48.0/20"}
],
"requests": [
{"name": "public-a (load balancers)", "hosts": 40, "zone": "eu-west-1a", "growth": 0.5},
{"name": "public-b (load balancers)", "hosts": 40, "zone": "eu-west-1b", "growth": 0.5},
{"name": "app-a (EKS pods)", "hosts": 2500, "zone": "eu-west-1a", "growth": 0.6},
{"name": "app-b (EKS pods)", "hosts": 2500, "zone": "eu-west-1b", "growth": 0.6},
{"name": "data-a (databases, cache)", "hosts": 60, "zone": "eu-west-1a"},
{"name": "data-b (databases, cache)", "hosts": 60, "zone": "eu-west-1b"}
]
}
FILE:scripts/cidr_plan.py
#!/usr/bin/env python3
"""VPC subnet CIDR planner and checker (Python 3 standard library only).
Input: a network plan JSON file:
{"name": "prod-eu", "provider": "aws", "vpc_cidrs": ["10.40.0.0/16"],
"existing": [{"name": "legacy-db", "cidr": "10.40.8.0/24"}],
"peers": [{"name": "office vpn", "cidr": "192.168.0.0/22"}],
"requests": [{"name": "app-a", "hosts": 900, "zone": "eu-west-1a", "growth": 0.5}]}
provider: aws | azure | gcp | generic. Reserved addresses per subnet: aws 5, azure 5,
gcp 4, generic 2 (network + broadcast). Subnet size limits: aws /16../28,
azure /8../29, gcp /8../29, generic /8../30.
requests: hosts needed now; growth = extra fraction to reserve (default 0.3);
optional "prefix" forces a size. Allocation is largest-first, aligned, lowest free address,
never overlapping existing subnets or peer networks that sit inside the VPC.
Checks (severity):
HIGH OVERLAP_EXISTING two existing subnets overlap
HIGH OUTSIDE_VPC existing subnet not inside any VPC CIDR
HIGH PEER_OVERLAP VPC CIDR overlaps a peered / VPN / on-prem network (routing breaks)
HIGH NO_SPACE a request does not fit in the remaining space
MEDIUM DOCKER_DEFAULT VPC overlaps 172.17.0.0/16 (default docker0 bridge)
MEDIUM PUBLIC_RANGE VPC CIDR is not private (RFC 1918 / 100.64.0.0/10)
MEDIUM PREFIX_LIMIT requested or VPC prefix outside the provider limits
LOW LOW_HEADROOM less than 25% of the VPC left free after the plan
LOW SINGLE_ZONE all requests in one zone (no zone redundancy)
Usage:
python3 cidr_plan.py plan.json
python3 cidr_plan.py plan.json --json
python3 cidr_plan.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import ipaddress as ip
import json
import math
import sys
RESERVED = {"aws": 5, "azure": 5, "gcp": 4, "generic": 2}
HEAD_RESERVED = {"aws": 4, "azure": 4, "gcp": 2, "generic": 1} # rest are at the end of the block
LIMITS = {"aws": (16, 28), "azure": (8, 29), "gcp": (8, 29), "generic": (8, 30)}
PRIVATE = [ip.ip_network(c) for c in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")]
DOCKER = ip.ip_network("172.17.0.0/16")
class PlanError(ValueError):
pass
def net(cidr, what="cidr"):
try:
n = ip.ip_network(str(cidr).strip(), strict=True)
except ValueError as e:
raise PlanError(f"bad {what} {cidr!r}: {e}")
if n.version != 4:
raise PlanError(f"only IPv4 is supported: {cidr!r}")
return n
def usable(prefix, provider):
return max(0, 2 ** (32 - prefix) - RESERVED[provider])
def prefix_for(hosts, provider, growth):
need = math.ceil(hosts * (1 + growth))
lo, hi = LIMITS[provider]
for p in range(hi, lo - 1, -1):
if usable(p, provider) >= need:
return p
return None
def find_slot(vpcs, taken, prefix):
for v in vpcs:
if prefix < v.prefixlen:
continue
for cand in v.subnets(new_prefix=prefix):
if not any(cand.overlaps(t) for t in taken):
return cand
return None
def plan(cfg):
provider = str(cfg.get("provider", "generic")).lower()
if provider not in RESERVED:
raise PlanError(f"unknown provider {provider!r} (aws, azure, gcp, generic)")
vpcs = [net(c, "vpc_cidr") for c in cfg.get("vpc_cidrs") or []]
if not vpcs:
raise PlanError("vpc_cidrs is empty")
lo, hi = LIMITS[provider]
findings = []
def f(sev, code, where, msg):
findings.append({"severity": sev, "code": code, "where": where, "message": msg})
for v in vpcs:
if not any(v.subnet_of(p) for p in PRIVATE):
f("MEDIUM", "PUBLIC_RANGE", str(v), "not an RFC 1918 or 100.64.0.0/10 range; it may clash with real internet addresses")
if v.overlaps(DOCKER):
f("MEDIUM", "DOCKER_DEFAULT", str(v), "overlaps 172.17.0.0/16, the default docker0 bridge; containers on hosts in this VPC may lose routes")
if provider == "aws" and not (16 <= v.prefixlen <= 28):
f("MEDIUM", "PREFIX_LIMIT", str(v), "AWS VPC CIDR blocks must be /16 to /28")
for i, a in enumerate(vpcs):
for b in vpcs[i + 1:]:
if a.overlaps(b):
raise PlanError(f"vpc_cidrs overlap each other: {a} and {b}")
existing = []
for e in cfg.get("existing") or []:
n = net(e.get("cidr"), "existing cidr")
name = e.get("name") or str(n)
if not any(n.subnet_of(v) for v in vpcs):
f("HIGH", "OUTSIDE_VPC", name, f"{n} is not inside {', '.join(map(str, vpcs))}")
for other_name, o in existing:
if n.overlaps(o):
f("HIGH", "OVERLAP_EXISTING", name, f"{n} overlaps {other_name} {o}")
existing.append((name, n))
taken = [n for _, n in existing]
for p in cfg.get("peers") or []:
n = net(p.get("cidr"), "peer cidr")
name = p.get("name") or str(n)
for v in vpcs:
if v.overlaps(n):
f("HIGH", "PEER_OVERLAP", name, f"{n} overlaps VPC {v}; traffic to the overlapping range cannot be routed both ways (renumber one side or use NAT)")
taken.append(n)
reqs = []
for r in cfg.get("requests") or []:
name = r.get("name") or "subnet"
hosts = r.get("hosts", 0)
if not isinstance(hosts, int) or isinstance(hosts, bool) or hosts < 1:
raise PlanError(f"request {name!r}: hosts must be a positive integer")
growth = r.get("growth", 0.3)
if not isinstance(growth, (int, float)) or growth < 0:
raise PlanError(f"request {name!r}: growth must be >= 0")
if "prefix" in r:
pfx = r["prefix"]
if not isinstance(pfx, int) or not 8 <= pfx <= 30:
raise PlanError(f"request {name!r}: prefix must be an integer 8..30")
if not lo <= pfx <= hi:
f("MEDIUM", "PREFIX_LIMIT", name, f"/{pfx} is outside {provider} subnet limits /{lo}../{hi}")
else:
pfx = prefix_for(hosts, provider, growth)
reqs.append({"name": name, "hosts": hosts, "growth": growth, "zone": r.get("zone", ""), "prefix": pfx})
allocs = []
order = sorted(range(len(reqs)), key=lambda i: (reqs[i]["prefix"] if reqs[i]["prefix"] is not None else -1, i))
for i in order:
r = reqs[i]
slot = find_slot(vpcs, taken, r["prefix"]) if r["prefix"] is not None else None
if slot is None:
f("HIGH", "NO_SPACE", r["name"], f"needs {r['hosts']} hosts (+{round(r['growth'] * 100)}% growth) and no free aligned block is left")
allocs.append(dict(r, cidr=None, usable=0))
continue
taken.append(slot)
u = usable(slot.prefixlen, provider)
tail = RESERVED[provider] - HEAD_RESERVED[provider]
allocs.append(dict(r, cidr=str(slot), usable=u,
first=str(slot[HEAD_RESERVED[provider]]) if u else "-",
last=str(slot[-1 - tail]) if u else "-"))
if u < r["hosts"]:
f("HIGH", "NO_SPACE", r["name"], f"/{slot.prefixlen} gives only {u} usable addresses for {r['hosts']} hosts")
allocs.sort(key=lambda a: (a["cidr"] is None, ip.ip_network(a["cidr"]) if a["cidr"] else 0))
total = sum(v.num_addresses for v in vpcs)
used = 0
inside = [t for t in taken if any(t.subnet_of(v) for v in vpcs)]
for t in ip.collapse_addresses(inside):
used += t.num_addresses
free = total - used
free_blocks = []
for v in vpcs:
rest = [v]
for t in ip.collapse_addresses([x for x in inside if x.subnet_of(v)]):
nxt = []
for b in rest:
if t.subnet_of(b):
nxt.extend(b.address_exclude(t))
elif not b.subnet_of(t):
nxt.append(b)
rest = nxt
free_blocks.extend(sorted(rest))
if total and free / total < 0.25:
f("LOW", "LOW_HEADROOM", "plan", f"only {free / total:.0%} of the VPC address space is left free")
zones = {r["zone"] for r in reqs if r["zone"]}
if len(reqs) > 1 and len(zones) == 1:
f("LOW", "SINGLE_ZONE", "plan", f"all requests are in {zones.pop()}; spread subnets over at least two zones")
rank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
findings.sort(key=lambda x: rank[x["severity"]])
return {"name": cfg.get("name", "network plan"), "provider": provider, "vpcs": [str(v) for v in vpcs],
"existing": [{"name": n, "cidr": str(c)} for n, c in existing],
"allocations": allocs, "total": total, "used": used, "free": free,
"free_blocks": [str(b) for b in free_blocks], "findings": findings}
def report(res):
out = [f"# Subnet plan: {res['name']}", "",
f"Provider: {res['provider']} (reserves {RESERVED[res['provider']]} addresses per subnet) VPC: {', '.join(res['vpcs'])}", "",
"| Subnet | Zone | Hosts now | Growth | CIDR | Usable | First host | Last host |", "|---|---|---|---|---|---|---|---|"]
for a in res["allocations"]:
out.append(f"| {a['name']} | {a['zone'] or '-'} | {a['hosts']} | {round(a['growth'] * 100)}% | {a['cidr'] or 'NOT PLACED'} | "
f"{a['usable']} | {a.get('first', '-')} | {a.get('last', '-')} |")
if res["existing"]:
out += ["", "Existing subnets kept: " + ", ".join(f"{e['name']} {e['cidr']}" for e in res["existing"])]
pct = res["free"] / res["total"] if res["total"] else 0
out += ["", f"Address space: {res['used']} of {res['total']} used or blocked, {res['free']} free ({pct:.0%})",
"Largest free blocks: " + (", ".join(sorted(res["free_blocks"], key=lambda c: ip.ip_network(c).prefixlen)[:6]) or "none"),
"", "## Findings"]
if not res["findings"]:
out.append("- none")
for x in res["findings"]:
out.append(f"- **{x['severity']} {x['code']}** ({x['where']}): {x['message']}")
return "\n".join(out) + "\n"
def self_test():
cases = []
def ok(name, cond):
cases.append(bool(cond))
print(("PASS " if cond else "FAIL ") + name)
ok("aws /24 usable 251", usable(24, "aws") == 251)
ok("generic /24 usable 254", usable(24, "generic") == 254)
ok("gcp /29 usable 4", usable(29, "gcp") == 4)
ok("900 hosts +50% on aws -> /21", prefix_for(900, "aws", 0.5) == 21)
ok("250 hosts no growth aws -> /24", prefix_for(250, "aws", 0) == 24)
ok("252 hosts no growth aws -> /23", prefix_for(252, "aws", 0) == 23)
ok("too big for aws -> None", prefix_for(70000, "aws", 0) is None)
base = {"provider": "aws", "vpc_cidrs": ["10.0.0.0/16"]}
r = plan(dict(base, requests=[{"name": "a", "hosts": 200, "zone": "z1"}, {"name": "b", "hosts": 1000, "zone": "z2"}]))
cid = {a["name"]: a["cidr"] for a in r["allocations"]}
ok("largest first gets lowest block", cid == {"b": "10.0.0.0/21", "a": "10.0.8.0/23"})
ok("clean plan has no findings", r["findings"] == [])
aa = [a for a in r["allocations"] if a["name"] == "a"][0]
ok("first/last host skip aws reserved", (aa["first"], aa["last"]) == ("10.0.8.4", "10.0.9.254"))
g = plan({"provider": "gcp", "vpc_cidrs": ["10.0.0.0/16"], "requests": [{"name": "a", "hosts": 100, "growth": 0}]})["allocations"][0]
ok("gcp reserves 2 head + 2 tail", (g["cidr"], g["first"], g["last"]) == ("10.0.0.0/25", "10.0.0.2", "10.0.0.125"))
r = plan(dict(base, existing=[{"name": "old", "cidr": "10.0.0.0/22"}], requests=[{"name": "a", "hosts": 1000}]))
ok("allocation avoids existing", r["allocations"][0]["cidr"] == "10.0.8.0/21")
r = plan(dict(base, existing=[{"name": "x", "cidr": "10.0.0.0/24"}, {"name": "y", "cidr": "10.0.0.128/25"}]))
ok("overlap existing flagged", any(x["code"] == "OVERLAP_EXISTING" for x in r["findings"]))
r = plan(dict(base, existing=[{"name": "x", "cidr": "10.1.0.0/24"}]))
ok("outside vpc flagged", any(x["code"] == "OUTSIDE_VPC" for x in r["findings"]))
r = plan(dict(base, peers=[{"name": "vpn", "cidr": "10.0.4.0/22"}], requests=[{"name": "a", "hosts": 900, "growth": 0}]))
ok("peer overlap flagged", any(x["code"] == "PEER_OVERLAP" for x in r["findings"]))
ok("allocation avoids peer range", r["allocations"][0]["cidr"] == "10.0.0.0/22")
r = plan({"provider": "aws", "vpc_cidrs": ["10.0.0.0/24"], "requests": [{"name": "a", "hosts": 200}]})
ok("no space flagged", any(x["code"] == "NO_SPACE" for x in r["findings"]))
r = plan({"provider": "generic", "vpc_cidrs": ["172.16.0.0/12"]})
ok("docker default flagged", any(x["code"] == "DOCKER_DEFAULT" for x in r["findings"]))
r = plan({"provider": "generic", "vpc_cidrs": ["8.8.0.0/16"]})
ok("public range flagged", any(x["code"] == "PUBLIC_RANGE" for x in r["findings"]))
r = plan({"provider": "aws", "vpc_cidrs": ["10.0.0.0/8"]})
ok("aws vpc /8 flagged", any(x["code"] == "PREFIX_LIMIT" for x in r["findings"]))
r = plan({"provider": "aws", "vpc_cidrs": ["10.0.0.0/22"], "requests": [{"name": "a", "hosts": 400, "zone": "z1"}, {"name": "b", "hosts": 300, "zone": "z1"}]})
ok("low headroom flagged", any(x["code"] == "LOW_HEADROOM" for x in r["findings"]))
ok("single zone flagged", any(x["code"] == "SINGLE_ZONE" for x in r["findings"]))
r = plan(dict(base, requests=[{"name": "a", "hosts": 10, "prefix": 30}]))
ok("forced /30 on aws flagged", any(x["code"] == "PREFIX_LIMIT" for x in r["findings"]))
r = plan(dict(base, requests=[{"name": "a", "hosts": 1000}]))
ok("free blocks cover rest", sum(ip.ip_network(b).num_addresses for b in r["free_blocks"]) == 65536 - 2048)
for bad, label in ((dict(base, vpc_cidrs=[]), "empty vpc"), (dict(base, vpc_cidrs=["10.0.0.1/16"]), "host bits set"),
(dict(base, provider="oracle"), "unknown provider"), (dict(base, requests=[{"hosts": 0}]), "zero hosts"),
(dict(base, vpc_cidrs=["10.0.0.0/16", "10.0.128.0/17"]), "overlapping vpcs"),
(dict(base, vpc_cidrs=["fd00::/48"]), "ipv6")):
try:
plan(bad)
ok(label + " rejected", False)
except PlanError:
ok(label + " rejected", True)
print(f"{sum(cases)}/{len(cases)} passed")
return 0 if all(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Plan and check VPC subnet CIDR blocks.")
ap.add_argument("file", nargs="?", help="network plan JSON")
ap.add_argument("--json", action="store_true", help="print machine-readable JSON")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("give a plan JSON file or --self-test")
try:
cfg = json.load(open(a.file, encoding="utf-8"))
res = plan(cfg)
except (OSError, json.JSONDecodeError, PlanError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(res, indent=2) if a.json else report(res), end="" if not a.json else "\n")
return 1 if any(x["severity"] == "HIGH" for x in res["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())