diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1e02d72 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + cache: true + - name: Build + run: go build ./... + - name: Test + run: go test ./... + - name: Lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.12.2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f20d624 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Binary +/reachr +/dist/ + +# Snapshot / render outputs (fixtures live under testdata/) +/topology.json +/topology.html +/topology.dot + +# Test & coverage artifacts +*.out +coverage.* + +# OS / editor +.DS_Store diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b6656de --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,5 @@ +version: "2" +linters: + default: standard + enable: + - revive diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dbae30c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project status + +**Pre-implementation.** The repo currently contains only `DESIGN.md` (the v1 design spec) and its +git history. There is no Go module, source code, or build tooling yet. `DESIGN.md` is the source of +truth for scope and architecture — read it before writing code, and keep it in sync when decisions +change. Do not invent build/lint/test commands until the corresponding tooling actually exists. + +## What this is + +`reachr` — a read-only Go CLI that scans AWS and produces (a) an interactive whole-VPC topology +diagram and (b) a reachability engine answering "what can actually reach what, and why is a path +broken." Positioned against Cloudmapper/Cartography/inframap/VPC Reachability Analyzer; the gap it +fills is a lightweight live-scan, whole-topology, honest-partial-fidelity CLI. + +## Architecture — the load-bearing decisions + +These are the invariants that span multiple future files. Preserve them; if you deviate, update +`DESIGN.md` and say why. + +- **Two-phase pipeline.** `scan` (needs AWS creds) writes an **immutable `topology.json` snapshot**; + `render` / `explain` / `diff` are **pure functions of that snapshot with no AWS calls**. This + split is the basis of the whole testing strategy — do not let credential-dependent logic leak into + the render/explain phase. + +- **Model at ENI level, render at logical-resource level.** The internal graph backbone is ENIs: + `ENI → (SG rules + route tables) → ENI`. Rendering aggregates ENIs up to logical resources (one + node per ECS service / RDS / ALB / NAT; an ASG of N identical tasks → one node labeled "×N"). SGs + collapse into **edge properties, not nodes**. + +- **Honest partial fidelity.** Every reachability verdict must declare what it did and did **not** + evaluate (e.g. `✅ SG allows ✅ route exists ⚠️ NACL/DNS/target-health not evaluated`). A + confidently-wrong answer is worse than none. v1 reasons about SG (rung 1) + route tables (rung 2) + + light NACL (rung 3); peering/TGW/DNS/app-layer are deferred and must be **loudly labeled**, never + silently assumed. + +- **Demote, don't delete.** Dead/orphaned/noisy resources are collapsed into a labeled "detached" + cluster plus a side list — never silently hidden. Filtering is layered: liveness filter (default + on) → tag scope selector (`--filter tag:project=X`, the main knob) → connectivity dimming → + interactive toggles (orphans/untagged off by default). + +- **Collect broadly, reason narrowly.** The snapshot captures more than v1's engine reasons about + (e.g. Route 53 private zones are collected but not resolved). Adding data to the snapshot is not the + same as claiming to model it. + +- **Scan boundary:** single region + single account per scan, explicitly labeled. Cross-account / + global targets (CloudFront, Global Accelerator, public R53, PrivateLink far side) are drawn as + labeled **boundary stubs** with edges preserved but internals not claimed. Global services + (CloudFront / global R53 / global WAF) are scanned from **us-east-1** regardless of `--region`. + +## Command surface (planned) + +- `scan --region --profile [--filter tag:project=X]` → writes `topology.json` +- `render topology.json` → self-contained interactive HTML (cytoscape.js, no frontend build / no + server); `--format dot` is the Graphviz escape hatch. Every node deep-links to the AWS console via + its resource ID. +- `explain --from --to --port ` (stretch) → path + first blocking hop + + owning repo (from tags). +- `diff old.json new.json` (later). + +## Auth + +AWS SDK for Go v2 **default credential chain** only (profiles/SSO/env/assume-role) — no custom auth +layer. A read-only role is the user's responsibility. + +## Testing strategy + +The render/explain engine is a pure function of the snapshot, so test it against **recorded/crafted +fixture snapshots** — no live AWS. The canonical test shape: craft a `topology.json` with a +deliberately misconfigured SG, assert the path-explainer names the correct blocking hop. This is how +verdicts earn trust; prioritize it when the engine lands. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..59c6588 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 nkcoder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..72da9e5 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# reachr + +AWS network reachability & topology tool — a read-only Go CLI that scans AWS and +renders an interactive whole-VPC topology, plus a reachability engine that explains +**what can actually reach what, and why a path is broken.** + +> **Status:** early development. See [DESIGN.md](DESIGN.md) for the v1 design and +> [issue #1](https://github.com/nkcoder/reachr/issues/1) for the slice roadmap. + +## Commands (planned) + +| Command | Description | +| --- | --- | +| `reachr scan --region [--profile

] [--filter tag:project=X]` | Read-only scan → immutable `topology.json` snapshot (the only phase that touches AWS). | +| `reachr render [--format html\|dot]` | Interactive diagram from a snapshot (pure function, no AWS). | +| `reachr explain --from --to --port ` | Path + first blocking hop + honest verdict. | +| `reachr diff ` | Snapshot diff over time. | + +## Development + +Uses [Task](https://taskfile.dev) (`brew install go-task`) as the task runner. + +```sh +task # list available tasks +task build # build the reachr binary +task test # run tests +task lint # golangci-lint +``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..e2c51a6 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,41 @@ +version: "3" + +silent: true + +tasks: + default: + desc: List available tasks + cmds: + - task --list + + build: + desc: Build the reachr binary + sources: + - "**/*.go" + - go.mod + - go.sum + generates: + - reachr + cmds: + - go build -o reachr . + + test: + desc: Run tests + cmds: + - go test ./... + + lint: + desc: Run golangci-lint + cmds: + - golangci-lint run + + tidy: + desc: Tidy module dependencies + cmds: + - go mod tidy + + run: + desc: Build and print CLI help + deps: [build] + cmds: + - ./reachr --help diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8918723 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/nkcoder/reachr + +go 1.26 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +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= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/diff.go b/internal/cli/diff.go new file mode 100644 index 0000000..0b4e945 --- /dev/null +++ b/internal/cli/diff.go @@ -0,0 +1,12 @@ +package cli + +import "github.com/spf13/cobra" + +var diffCmd = &cobra.Command{ + Use: "diff ", + Short: "Diff two snapshots to show topology change over time", + Args: cobra.ExactArgs(2), + RunE: func(_ *cobra.Command, _ []string) error { + return errNotImplemented("diff") + }, +} diff --git a/internal/cli/explain.go b/internal/cli/explain.go new file mode 100644 index 0000000..894e01b --- /dev/null +++ b/internal/cli/explain.go @@ -0,0 +1,30 @@ +package cli + +import "github.com/spf13/cobra" + +var ( + explainFrom string + explainTo string + explainPort int +) + +var explainCmd = &cobra.Command{ + Use: "explain ", + Short: "Explain the reachability path between two resources", + Long: "explain reasons over a snapshot (no AWS) to report the path from one resource\n" + + "to another, the first blocking hop when a path is broken, and an honest verdict\n" + + "of what was and was not evaluated.", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, _ []string) error { + return errNotImplemented("explain") + }, +} + +func init() { + f := explainCmd.Flags() + f.StringVar(&explainFrom, "from", "", "source resource id (required)") + f.StringVar(&explainTo, "to", "", "destination resource id (required)") + f.IntVar(&explainPort, "port", 0, "destination port") + _ = explainCmd.MarkFlagRequired("from") + _ = explainCmd.MarkFlagRequired("to") +} diff --git a/internal/cli/render.go b/internal/cli/render.go new file mode 100644 index 0000000..084de33 --- /dev/null +++ b/internal/cli/render.go @@ -0,0 +1,26 @@ +package cli + +import "github.com/spf13/cobra" + +var ( + renderFormat string + renderOutput string +) + +var renderCmd = &cobra.Command{ + Use: "render ", + Short: "Render a snapshot to an interactive diagram (pure function, no AWS)", + Long: "render is a pure function of a topology.json snapshot: it makes no AWS calls.\n" + + "Default output is a self-contained interactive HTML diagram; --format dot is\n" + + "the Graphviz escape hatch.", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, _ []string) error { + return errNotImplemented("render") + }, +} + +func init() { + f := renderCmd.Flags() + f.StringVar(&renderFormat, "format", "html", "output format: html|dot") + f.StringVarP(&renderOutput, "output", "o", "topology.html", "output path") +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..4b40ef4 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,36 @@ +// Package cli defines the reachr command tree (scan/render/explain/diff). +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "reachr", + Short: "AWS network reachability & topology tool", + Long: "reachr scans AWS (read-only) into an immutable topology.json snapshot, then\n" + + "renders an interactive whole-VPC topology and explains what can actually\n" + + "reach what — and why a given path is broken.", + SilenceUsage: true, + SilenceErrors: true, +} + +// Execute runs the root command and exits non-zero on error. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func init() { + rootCmd.AddCommand(scanCmd, renderCmd, explainCmd, diffCmd) +} + +// errNotImplemented is the placeholder verdict for stubbed subcommands. +func errNotImplemented(name string) error { + return fmt.Errorf("%s: not implemented yet", name) +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..6a23f3c --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,15 @@ +package cli + +import "testing" + +func TestRootHasExpectedSubcommands(t *testing.T) { + got := map[string]bool{} + for _, c := range rootCmd.Commands() { + got[c.Name()] = true + } + for _, want := range []string{"scan", "render", "explain", "diff"} { + if !got[want] { + t.Errorf("root command missing subcommand %q", want) + } + } +} diff --git a/internal/cli/scan.go b/internal/cli/scan.go new file mode 100644 index 0000000..f8be016 --- /dev/null +++ b/internal/cli/scan.go @@ -0,0 +1,29 @@ +package cli + +import "github.com/spf13/cobra" + +var ( + scanRegion string + scanProfile string + scanFilter string + scanOutput string +) + +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") + }, +} + +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(&scanFilter, "filter", "", "scope selector, e.g. tag:project=X") + f.StringVarP(&scanOutput, "output", "o", "topology.json", "snapshot output path") + _ = scanCmd.MarkFlagRequired("region") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4bf4084 --- /dev/null +++ b/main.go @@ -0,0 +1,8 @@ +// Command reachr scans AWS (read-only) and renders/explains network reachability. +package main + +import "github.com/nkcoder/reachr/internal/cli" + +func main() { + cli.Execute() +}