diff --git a/.gitignore b/.gitignore index f20d624..1aef449 100644 --- a/.gitignore +++ b/.gitignore @@ -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.* diff --git a/go.mod b/go.mod index 8918723..48ad0d6 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index a6ee3e0..2205c9d 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/awsscan/client.go b/internal/awsscan/client.go new file mode 100644 index 0000000..4063f4f --- /dev/null +++ b/internal/awsscan/client.go @@ -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 +} diff --git a/internal/awsscan/rawdump.go b/internal/awsscan/rawdump.go new file mode 100644 index 0000000..961745a --- /dev/null +++ b/internal/awsscan/rawdump.go @@ -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}}} +} diff --git a/internal/cli/scan.go b/internal/cli/scan.go index f8be016..537a076 100644 --- a/internal/cli/scan.go +++ b/internal/cli/scan.go @@ -1,12 +1,20 @@ 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{ @@ -14,16 +22,38 @@ var scanCmd = &cobra.Command{ 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") }