From c3c946869eba9688a306ad84d39ad529badcea6b Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 11 Jul 2026 18:20:34 +1000 Subject: [PATCH 1/4] Slice 0: project skeleton, CLI, CI - Go module + Cobra command tree (scan/render/explain/diff stubs) - GitHub Actions CI: build, test, golangci-lint - Repo hygiene: .gitignore, .golangci.yml, Makefile, README, MIT LICENSE, CLAUDE.md Closes #2, #3, #4 --- .github/workflows/ci.yml | 24 +++++++++++++ .gitignore | 15 ++++++++ .golangci.yml | 5 +++ CLAUDE.md | 74 +++++++++++++++++++++++++++++++++++++++ LICENSE | 21 +++++++++++ Makefile | 16 +++++++++ README.md | 25 +++++++++++++ go.mod | 10 ++++++ go.sum | 10 ++++++ internal/cli/diff.go | 12 +++++++ internal/cli/explain.go | 30 ++++++++++++++++ internal/cli/render.go | 26 ++++++++++++++ internal/cli/root.go | 36 +++++++++++++++++++ internal/cli/root_test.go | 15 ++++++++ internal/cli/scan.go | 29 +++++++++++++++ main.go | 8 +++++ 16 files changed, 356 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cli/diff.go create mode 100644 internal/cli/explain.go create mode 100644 internal/cli/render.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/root_test.go create mode 100644 internal/cli/scan.go create mode 100644 main.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..89b4ccd --- /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@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + cache: true + - name: Build + run: go build ./... + - name: Test + run: go test ./... + - name: Lint + uses: golangci/golangci-lint-action@v7 + 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/Makefile b/Makefile new file mode 100644 index 0000000..15e45aa --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: build test lint tidy run + +build: + go build -o reachr . + +test: + go test ./... + +lint: + golangci-lint run + +tidy: + go mod tidy + +run: build + ./reachr --help diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea3dae4 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# 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 + +```sh +make build # build the reachr binary +make test # run tests +make lint # golangci-lint +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b11a4c9 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/nkcoder/reachr + +go 1.26.5 + +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() +} From dc3866ad9d24ebc4a0e8edbd107875d606402598 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 11 Jul 2026 19:51:11 +1000 Subject: [PATCH 2/4] Use Task (Taskfile.yml) instead of Make --- Makefile | 16 ---------------- README.md | 9 ++++++--- Taskfile.yml | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 19 deletions(-) delete mode 100644 Makefile create mode 100644 Taskfile.yml diff --git a/Makefile b/Makefile deleted file mode 100644 index 15e45aa..0000000 --- a/Makefile +++ /dev/null @@ -1,16 +0,0 @@ -.PHONY: build test lint tidy run - -build: - go build -o reachr . - -test: - go test ./... - -lint: - golangci-lint run - -tidy: - go mod tidy - -run: build - ./reachr --help diff --git a/README.md b/README.md index ea3dae4..72da9e5 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,11 @@ renders an interactive whole-VPC topology, plus a reachability engine that expla ## Development +Uses [Task](https://taskfile.dev) (`brew install go-task`) as the task runner. + ```sh -make build # build the reachr binary -make test # run tests -make lint # golangci-lint +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 From d9d0b6eb9b0aa251bcd25c31dfdbb5b1d2c76556 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 11 Jul 2026 19:54:51 +1000 Subject: [PATCH 3/4] CI: bump actions to latest (checkout v7, setup-go v6, golangci-lint-action v9) --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89b4ccd..1e02d72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6 with: go-version: "1.26" cache: true @@ -19,6 +19,6 @@ jobs: - name: Test run: go test ./... - name: Lint - uses: golangci/golangci-lint-action@v7 + uses: golangci/golangci-lint-action@v9 with: version: v2.12.2 From 62dc2d174011d2a535a142d0069a3bb432205471 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 11 Jul 2026 19:55:54 +1000 Subject: [PATCH 4/4] Set go directive to language version 1.26 (not patch), fixes CI under GOTOOLCHAIN=local --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index b11a4c9..8918723 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/nkcoder/reachr -go 1.26.5 +go 1.26 require github.com/spf13/cobra v1.10.2