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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version: "2"
linters:
default: standard
enable:
- revive
74 changes: 74 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <resource> --to <resource> --port <n>` (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.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <r> [--profile <p>] [--filter tag:project=X]` | Read-only scan → immutable `topology.json` snapshot (the only phase that touches AWS). |
| `reachr render <topology.json> [--format html\|dot]` | Interactive diagram from a snapshot (pure function, no AWS). |
| `reachr explain --from <id> --to <id> --port <n> <topology.json>` | Path + first blocking hop + honest verdict. |
| `reachr diff <old.json> <new.json>` | 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
```
41 changes: 41 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
12 changes: 12 additions & 0 deletions internal/cli/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package cli

import "github.com/spf13/cobra"

var diffCmd = &cobra.Command{
Use: "diff <old.json> <new.json>",
Short: "Diff two snapshots to show topology change over time",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, _ []string) error {
return errNotImplemented("diff")
},
}
30 changes: 30 additions & 0 deletions internal/cli/explain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package cli

import "github.com/spf13/cobra"

var (
explainFrom string
explainTo string
explainPort int
)

var explainCmd = &cobra.Command{
Use: "explain <topology.json>",
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")
}
26 changes: 26 additions & 0 deletions internal/cli/render.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package cli

import "github.com/spf13/cobra"

var (
renderFormat string
renderOutput string
)

var renderCmd = &cobra.Command{
Use: "render <topology.json>",
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")
}
36 changes: 36 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
@@ -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)
}
15 changes: 15 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
29 changes: 29 additions & 0 deletions internal/cli/scan.go
Original file line number Diff line number Diff line change
@@ -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")
}
8 changes: 8 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -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()
}