DEV Community

Cover image for One service at the centre: Transit Gateway, security group and API Gateway pages
Karina Pérez
Karina Pérez

Posted on Originally published at vpcatlas.com

One service at the centre: Transit Gateway, security group and API Gateway pages

How VPC Atlas 0.9.0 draws one service per page, with its tables and findings, and what those pages still leave out.

Why a page per service

In a recent network review the questions were about one service at a time. Where does this Transit Gateway route go? Which security group is open to the world, and which one is attached to nothing? Where does each route of this API end up? VPC Atlas draws everything on the region page, which answers none of them well: the answer to each is on it somewhere, spread across the whole page.

Version 0.9.0, published on 20 October 2026, adds --focus: a page per service, two tables per view next to the inventory, and three findings the tool raises on those pages and tables. One small change isn't about focus: the pages of a VPC now show the free IPs of each subnet in its subtitle, as in 10.0.1.0/24 | 251 free IPs.

The option

pipx install vpcatlas        # or: curl -fsSL https://vpcatlas.com/install.sh | sh
vpcatlas discover -r eu-west-1 --focus tgw,sg,apigw --output-dir vpcatlas-out
Enter fullscreen mode Exit fullscreen mode

--focus VIEW works on discover, inventory, diagram and serve. You can repeat it or separate the views with commas. There are three views, and aliases such as transit-gateway and api-gateway are accepted.

tgw writes one page per Transit Gateway, your own or one shared with you, with its route tables, routes and attachments (a shared one is drawn from what your credentials can read, see the limits below). sg writes one page per VPC, with its security groups, their rules and what they're attached to. apigw writes one page per region, with its APIs, endpoints, domains and integration targets. The pages come out in draw.io and Lucid as diagrams/vpcatlas-focus-<view>-<id>.<ext>, drawn by the same layout engine as the other pages (focus pages).

With --focus, --level defaults to the focus pages only; --level account, vpc or all adds those pages. Without --focus nothing changes. --detail doesn't change a focus page, and --tag narrows the graph first, so a resource the rules drop isn't on a focus page either.

On discover and inventory, --focus also writes six tables next to the inventory: tgw-attachments, tgw-routes, sg-rules, sg-attachments, apigw-apis and apigw-integrations. Each one is a file called vpcatlas-focus-<table> in every inventory format you selected (json, md, html, csv). The JSON has the schema id vpcatlas-focus/1, and rows are sorted so the same graph gives the same bytes. The columns are in the focus tables section of the docs.

Three findings appear on the pages and in the tables:

  • open_ingress:<proto>/<ports>, on a security group that admits the world (sources 0.0.0.0/0 or ::/0) on a port other than 80 or 443. The card reads ! open to the world: tcp 22.
  • unused, on a group that nothing uses and no rule names. The card reads ! not attached.
  • default_endpoint_open, on an API with a custom domain whose default execute-api endpoint is still enabled. The card reads ! default endpoint open.

A fourth mark is on the Transit Gateway page. A peering attachment that nobody accepted shows pending_acceptance in the attachments table, and its state in brackets on the line to its box.

unused isn't raised when a service that carries security groups was left out of the scan with --exclude-services, or couldn't be read. A group that looks unused only because the scan never looked at Lambda or ECS would be a false alarm, and I'd rather the tool stay quiet than raise a wrong flag.

What I measured

The figures come from the simulated account of the repository (demo/fake_aws.py, built on moto 5.2.4, the Python library that mocks AWS, behind a small shim), in eu-west-1, with the released 0.9.0. I grew the account for this release so the three views had something to show. It now has a bastion instance in a public subnet with a group bastion (SSH open to the world), a group legacy-app with RDP open to the world and nothing attached to it, an HTTP API prod-http and a REST API partner-rest, a VPN dc-vpn attached to the Transit Gateway hub, a peering hub-frankfurt that nobody has accepted, to a Transit Gateway in eu-central-1 of another account, and a second route table hub-partners.

vpcatlas discover -r eu-west-1 --focus tgw,sg,apigw --output-dir vpcatlas-out finds 113 resources and 0 issues (no call was denied or failed), and writes 5 focus pages: 1 for the Transit Gateway, 3 for security groups (the account has 3 VPCs) and 1 for the APIs of the region. The six tables have 7 rows in tgw-routes, 4 in tgw-attachments, 20 in sg-rules, 31 in sg-attachments, 2 in apigw-apis and 4 in apigw-integrations.

On the Transit Gateway page, the gateway hub (ASN 64512) has 4 attachments: the VPC prod, the VPC shared-services, the VPN dc-vpn and the peering, which is in state pendingAcceptance and carries the mark pending_acceptance. Its default route table has 6 routes: 0.0.0.0/0 → shared-services, 10.0.0.0/16 → prod, 10.1.0.0/16 → shared-services, 10.20.0.0/16 → the Frankfurt peering, 10.99.0.0/16 as a blackhole, and 172.16.0.0/12 → dc-vpn. The second table, hub-partners, has one route, 10.0.0.0/16 → prod, and the peering is associated to it.

The page of the Transit Gateway hub: two route table cards on the left with their routes, the gateway in the middle and four attachment boxes on the right, the peering line reading peering (pendingAcceptance)

The Transit Gateway hub of the simulated account: its two route tables on the left, its four attachments on the right, and the unaccepted peering with its state in brackets.

The VPC prod has 6 groups (web, app, db, bastion, legacy-app and default) and 15 rule rows. bastion carries open_ingress:tcp/22. legacy-app carries open_ingress:tcp/3389 and unused. Of the 31 rows in sg-attachments, 12 are via_eni, 9 of them on the group default: when only a network interface shows that a group is attached, the page and the table name the interface, not the resource behind it. The VPC shared-services has 2 groups and 4 rules, and the third VPC 1 group and 1 rule.

The top of the page of the security groups of VPC prod: a card per group on the left, with the bastion and legacy-app findings, and the resources they are attached to on the right

The top of the page of the security groups of VPC prod. The page is tall, with a card per group and every attached resource, so this is a crop. The full page has the rest.

Each card shows the first eight inbound and four outbound rules of its group. A rule that names another group of the VPC is a line between the two cards, labelled with the protocol and the ports: app accepts 22 from bastion, and legacy-app accepts 8080 from web.

The region has two APIs. prod-http is an HTTP API on api.vpcatlas.com whose default endpoint is still enabled, so it carries default_endpoint_open, and it has a VPC link prod-link. partner-rest is a REST API on partners.vpcatlas.com with the default endpoint disabled and a regional web ACL prod-api on its stage. apigw-integrations has 4 rows: GET /orders +1 to the Lambda prod-api (two routes that share one integration are one row, and the +1 counts the other), ANY /app/{proxy+} through the VPC link prod-link to the load balancer prod-web, GET /status to https://status.partner.example/health, and POST /orders to the Lambda prod-api.

The page of the APIs of eu-west-1: the cards of prod-http and partner-rest with their domains, stages and web ACL, and to the right the Lambda, the VPC link with the load balancer behind it and the external HTTPS host

The APIs of eu-west-1: prod-http carries the line ! default endpoint open, and to the right are the Lambda, the VPC link with its load balancer and the external host.

The routes that pointed nowhere

While I was making these figures, the routes of the hub came out with no target. Every route read → -.

The product asks AWS for the routes of each route table with SearchTransitGatewayRoutes, and it reads the attachment of every route in the answer. moto stores the attachment of a route as a single object where the real API returns a list, so its serializer dropped it, and every route came back with an empty list of attachments. Against a real account, the routes had their targets all along.

The limit is in the simulator, so the fix went there: before moto starts, the demo script (demo/fake_aws.py) wraps the call that creates a route so the attachment is stored as a list.

A mock that answers with the right shape and the wrong content is harder to catch than one that fails, and it was the tables that caught it: a whole column of empty attachment_ids in tgw-routes is harder to skim past than an arrow to a dash on a page.

Cache and dashboard

--focus on a saved graph needs no AWS call:

vpcatlas diagram --cache vpcatlas-out/vpcatlas-graph.json --focus tgw,apigw
Enter fullscreen mode Exit fullscreen mode

In the dashboard, vpcatlas serve --focus ... or the variable VPCATLAS_FOCUS, comma separated, adds a third sidebar group called "Focus views". /api/status reports the active views under filters.focus. Docker Compose passes VPCATLAS_FOCUS through, and the Helm chart takes config.focus as a list. The dashboard docs have the rest.

What it doesn't do

  • A focus page has no layer of changes in the dashboard: "View changes on diagram" skips it.
  • A view with nothing to show writes no page. With no Transit Gateway there's no tgw page, and with no API there's no apigw page.
  • A Transit Gateway shared by an account you didn't scan is drawn from what your credentials can read: the gateway with its owner and your attachments to it. And a route table card lists at most 12 routes and ends in +N more; when AWS reports more routes than it returns, the card ends in (routes truncated by the scan) and routes_truncated is yes in tgw-routes. The table has every route the scan read.
  • A group that only an interface reveals is listed through that interface (via_eni).
  • On the API page, a load balancer reached through a VPC link that is also the direct target of another integration is drawn once, and its edge crosses the column of targets. I've left that for 0.9.1.

A hit isn't always a problem: the bastion in the figures is open on 22 on purpose.

Tell me which view is next

The option is in the CLI docs, the version history in the changelog, and the code in the repository and on PyPI. Which view would you want next: load balancers, VPC endpoints, something else you keep chasing across a region page? And is the unused rule right for your accounts, or do you keep groups on purpose that nothing uses? Tell me.

Top comments (0)