Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
/topology.html
/topology.dot

# Exploratory raw dumps (unsanitized: contain account id / tags)
raw.json
*.raw.json

# Task runner cache
.task/

# Test & coverage artifacts
*.out
coverage.*
Expand Down
19 changes: 18 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,26 @@ module github.com/nkcoder/reachr

go 1.26

require github.com/spf13/cobra v1.10.2
require (
github.com/aws/aws-sdk-go-v2/config v1.32.29
github.com/aws/aws-sdk-go-v2/service/ec2 v1.316.0
github.com/spf13/cobra v1.10.2
)

require (
github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.28 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 // indirect
github.com/aws/smithy-go v1.27.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
30 changes: 30 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2/config v1.32.29 h1:BcMHHnpiWKogf+gGfpj3K1w+Sktz29XDo/cPSAPO3FU=
github.com/aws/aws-sdk-go-v2/config v1.32.29/go.mod h1:+Kbhn8Es4kPUph3F/0W7avykytc+Jh2Ld9/msv9ljV4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.28 h1:zTXJSsNcoO91/mTXsZoYf0AK8dvNPiA58/VtyGXR+wM=
github.com/aws/aws-sdk-go-v2/credentials v1.19.28/go.mod h1:Kd9E0JzDBW/q1xbsHFrev/GnbAf5J0Ng8xoyc7HZ91Q=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.316.0 h1:LlxNun/oe5B2XMff8Mkh/3bJeHl1K7Fod+rMYtjagw4=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.316.0/go.mod h1:eoF0SIRbTgKWnTcTPYckiURPba/7ilfEkvwL4V1iHK4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 h1:sLzmJGCMv+C8KqiJgEqDLB6vxaJGmobRh4rr//ZpA3w=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 h1:fpOlDPI55HdszaxapEGk6HsGosOUaM2YPWJpjMgp8UI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye2U8298lCEMi6ZCU=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
Expand Down
29 changes: 29 additions & 0 deletions internal/awsscan/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Package awsscan collects the AWS network backbone (read-only) for a scan.
package awsscan

import (
"context"

"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ec2"
)

// Client wraps the AWS service clients used for scanning.
type Client struct {
EC2 *ec2.Client
Region string
}

// NewClient builds a Client from the AWS default credential chain.
// region is required; profile is optional (empty = default chain).
func NewClient(ctx context.Context, region, profile string) (*Client, error) {
opts := []func(*config.LoadOptions) error{config.WithRegion(region)}
if profile != "" {
opts = append(opts, config.WithSharedConfigProfile(profile))
}
cfg, err := config.LoadDefaultConfig(ctx, opts...)
if err != nil {
return nil, err
}
return &Client{EC2: ec2.NewFromConfig(cfg), Region: cfg.Region}, nil
}
102 changes: 102 additions & 0 deletions internal/awsscan/rawdump.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package awsscan

import (
"context"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ec2"
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
)

// RawBackbone is the unshaped exploratory dump of the network backbone, straight
// from EC2 Describe* calls. It exists to inspect real field shapes before locking
// the topology.json schema (issue #6); it is NOT the final snapshot format.
type RawBackbone struct {
Region string `json:"region"`
VPCs []types.Vpc `json:"vpcs"`
Subnets []types.Subnet `json:"subnets"`
RouteTables []types.RouteTable `json:"routeTables"`
InternetGateways []types.InternetGateway `json:"internetGateways"`
NATGateways []types.NatGateway `json:"natGateways"`
NetworkInterfaces []types.NetworkInterface `json:"networkInterfaces"`
SecurityGroups []types.SecurityGroup `json:"securityGroups"`
}

// paginator is the shape shared by every EC2 Describe*Paginator.
type paginator[T any] interface {
HasMorePages() bool
NextPage(context.Context, ...func(*ec2.Options)) (T, error)
}

// drain walks a paginator to completion, flattening each page via extract.
func drain[Page, Item any](ctx context.Context, p paginator[Page], extract func(Page) []Item) ([]Item, error) {
var all []Item
for p.HasMorePages() {
page, err := p.NextPage(ctx)
if err != nil {
return nil, err
}
all = append(all, extract(page)...)
}
return all, nil
}

// DumpBackbone collects the raw network backbone with pagination. If vpcID is
// non-empty the collection is scoped to that VPC; otherwise it spans the region.
func (c *Client) DumpBackbone(ctx context.Context, vpcID string) (*RawBackbone, error) {
out := &RawBackbone{Region: c.Region}
var err error

// VPCs are selected by id; every other resource filters on vpc-id.
var vpcIDs []string
if vpcID != "" {
vpcIDs = []string{vpcID}
}
byVPC := vpcFilter(vpcID, "vpc-id")

if out.VPCs, err = drain(ctx,
ec2.NewDescribeVpcsPaginator(c.EC2, &ec2.DescribeVpcsInput{VpcIds: vpcIDs}),
func(p *ec2.DescribeVpcsOutput) []types.Vpc { return p.Vpcs }); err != nil {
return nil, err
}
if out.Subnets, err = drain(ctx,
ec2.NewDescribeSubnetsPaginator(c.EC2, &ec2.DescribeSubnetsInput{Filters: byVPC}),
func(p *ec2.DescribeSubnetsOutput) []types.Subnet { return p.Subnets }); err != nil {
return nil, err
}
if out.RouteTables, err = drain(ctx,
ec2.NewDescribeRouteTablesPaginator(c.EC2, &ec2.DescribeRouteTablesInput{Filters: byVPC}),
func(p *ec2.DescribeRouteTablesOutput) []types.RouteTable { return p.RouteTables }); err != nil {
return nil, err
}
if out.InternetGateways, err = drain(ctx,
ec2.NewDescribeInternetGatewaysPaginator(c.EC2, &ec2.DescribeInternetGatewaysInput{Filters: vpcFilter(vpcID, "attachment.vpc-id")}),
func(p *ec2.DescribeInternetGatewaysOutput) []types.InternetGateway { return p.InternetGateways }); err != nil {
return nil, err
}
if out.NATGateways, err = drain(ctx,
ec2.NewDescribeNatGatewaysPaginator(c.EC2, &ec2.DescribeNatGatewaysInput{Filter: byVPC}),
func(p *ec2.DescribeNatGatewaysOutput) []types.NatGateway { return p.NatGateways }); err != nil {
return nil, err
}
if out.NetworkInterfaces, err = drain(ctx,
ec2.NewDescribeNetworkInterfacesPaginator(c.EC2, &ec2.DescribeNetworkInterfacesInput{Filters: byVPC}),
func(p *ec2.DescribeNetworkInterfacesOutput) []types.NetworkInterface { return p.NetworkInterfaces }); err != nil {
return nil, err
}
if out.SecurityGroups, err = drain(ctx,
ec2.NewDescribeSecurityGroupsPaginator(c.EC2, &ec2.DescribeSecurityGroupsInput{Filters: byVPC}),
func(p *ec2.DescribeSecurityGroupsOutput) []types.SecurityGroup { return p.SecurityGroups }); err != nil {
return nil, err
}
return out, nil
}

// vpcFilter builds a single-value EC2 filter on the given attribute, or nil when
// vpcID is empty (region-wide collection).
func vpcFilter(vpcID, name string) []types.Filter {
if vpcID == "" {
return nil
}
return []types.Filter{{Name: aws.String(name), Values: []string{vpcID}}}
}
36 changes: 33 additions & 3 deletions internal/cli/scan.go
Original file line number Diff line number Diff line change
@@ -1,29 +1,59 @@
package cli

import "github.com/spf13/cobra"
import (
"encoding/json"
"fmt"

"github.com/nkcoder/reachr/internal/awsscan"
"github.com/spf13/cobra"
)

var (
scanRegion string
scanProfile string
scanVPC string
scanFilter string
scanOutput string
scanRaw bool
)

var scanCmd = &cobra.Command{
Use: "scan",
Short: "Scan AWS (read-only) into an immutable topology.json snapshot",
Long: "scan reads the target account/region via the AWS default credential chain and\n" +
"writes a topology.json snapshot. It is the only phase that talks to AWS.",
RunE: func(_ *cobra.Command, _ []string) error {
return errNotImplemented("scan")
RunE: func(cmd *cobra.Command, _ []string) error {
if !scanRaw {
return errNotImplemented("scan")
}
return runRawDump(cmd)
},
}

// runRawDump is the exploratory backbone dump (issue #6): it prints raw EC2
// Describe* output so we can design the topology.json schema from real shapes.
func runRawDump(cmd *cobra.Command) error {
ctx := cmd.Context()
client, err := awsscan.NewClient(ctx, scanRegion, scanProfile)
if err != nil {
return fmt.Errorf("load aws config: %w", err)
}
raw, err := client.DumpBackbone(ctx, scanVPC)
if err != nil {
return fmt.Errorf("dump backbone: %w", err)
}
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(raw)
}

func init() {
f := scanCmd.Flags()
f.StringVar(&scanRegion, "region", "", "AWS region to scan (required)")
f.StringVar(&scanProfile, "profile", "", "AWS profile (default credential chain if empty)")
f.StringVar(&scanVPC, "vpc", "", "scope scan to a single VPC id (default: whole region)")
f.StringVar(&scanFilter, "filter", "", "scope selector, e.g. tag:project=X")
f.StringVarP(&scanOutput, "output", "o", "topology.json", "snapshot output path")
f.BoolVar(&scanRaw, "raw", false, "exploratory: dump raw AWS backbone JSON to stdout")
_ = scanCmd.MarkFlagRequired("region")
}