diff --git a/README.md b/README.md index a0eca32..d1f4b75 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ -# macaronV2 +# macaron -Fast reconnaissance workflow in Go with SQLite-backed persistence and an operator-focused dashboard. +Reconnaissance workflow tool written in Go. SQLite-backed persistence, a live CLI progress view, and a web dashboard for inspecting findings. -## The Model +## Workflow -`macaronV2` is designed around one simple loop: +``` +setup → scan → status/results → serve → export +``` -1. `setup` toolchain and keys -2. `scan` targets with an explicit profile -3. `status/results` to triage findings -4. `serve` to inspect everything in one dashboard -5. `export` to share/report +1. **setup** – verify tool installation and configure API keys +2. **scan** – collect subdomains, probe live hosts, scan ports, discover URLs, run vuln checks +3. **status / results** – triage findings in the terminal +4. **serve** – open everything in the web dashboard +5. **export** – write a JSON report for sharing or archiving ## Quick Start @@ -24,73 +26,109 @@ source ~/.bashrc macaron setup macaron scan example.com --profile balanced macaron status -macaron serve --addr 127.0.0.1:8088 +macaron serve ``` -## Core Commands +## Commands -```bash -macaron setup -macaron scan -macaron status -macaron results -d -w -macaron serve -macaron export -o results.json +``` +macaron setup Show tool installation status +macaron scan Scan a target +macaron status List recent scans +macaron results --dom Show results for a domain +macaron serve Start the web dashboard +macaron export --out results.json Export all results to JSON +macaron guide Show workflow guide +``` + +## Scan Options + +``` +--profile passive|balanced|aggressive Workflow preset (default: balanced) +--stages subdomains,http,ports,urls,vulns Enable specific stages (default: all) +--mod wide|narrow|fast|deep|osint Scan mode (default: wide) +--rate N Request rate hint (default: 150) +--threads N Worker threads (default: 30) +--fil FILE Read targets from a file +--inp Read targets from stdin ``` ## Profiles -- `passive`: low-noise collection -- `balanced`: default practical workflow -- `aggressive`: high-throughput authorized testing +| Profile | Rate | Threads | Stages | +|------------|------|---------|-------------------------| +| passive | 40 | 10 | subdomains, http, urls | +| balanced | 150 | 30 | all | +| aggressive | 350 | 70 | all | ## Storage Default storage root: `./storage` -```text +``` storage/ - macaron.db - config.yaml + macaron.db SQLite database with all scan results + config.yaml API key configuration / - .json - latest.txt + .json Full scan result + latest.txt ID of the most recent scan for this target ``` -## Setup & API Keys +## API Keys ```bash -macaron setup -macaron --install-tools macaron --set-api securitytrails=YOUR_KEY macaron --show-api ``` -## Stage Control +## Stages + +| Stage | What it does | +|------------|---------------------------------------------| +| subdomains | crt.sh + subfinder/assetfinder/findomain | +| http | probe each host over HTTPS then HTTP | +| ports | TCP connect scan on common ports | +| urls | Wayback Machine URL discovery | +| vulns | nuclei template scan against live hosts | + +## Web Dashboard ```bash -macaron scan example.com --stages subdomains,http,urls +macaron serve --addr 127.0.0.1:8088 ``` -Available stages: `subdomains,http,ports,urls,vulns` +Open `http://127.0.0.1:8088`. -## Dashboard +The dashboard shows scan results, a live host table, subdomain lists, URLs, vulnerability findings, a geo heat map, and an **analytics** view with daily activity, top targets by vuln count, and severity distribution across all scans. Press `Ctrl-C` to stop. + +## Install ```bash -macaron serve --addr 127.0.0.1:8088 +git clone https://github.com/root-Manas/macaron.git +cd macaron +./install.sh # builds and installs to ~/.local/bin/macaron +source ~/.bashrc +macaron --version ``` -Open `http://127.0.0.1:8088`. +The installer requires Go 1.22 or later. To install optional external tools: + +```bash +macaron setup # show what is installed and what is missing +macaron --ins # install missing Go-based tools (Linux) +``` ## Release +Tag a version to trigger the CI build and binary release: + ```bash -git tag v3.0.1 -git push origin v3.0.1 +git tag v3.x.x +git push origin v3.x.x ``` -Tagged releases build and publish binaries for Linux, macOS, and Windows. +Binaries are published for Linux, macOS, and Windows. -## Security Note +## Security Use only on systems you own or are explicitly authorized to test. diff --git a/cmd/macaron/main.go b/cmd/macaron/main.go index 26419d2..4018252 100644 --- a/cmd/macaron/main.go +++ b/cmd/macaron/main.go @@ -100,7 +100,7 @@ func run() int { pflag.Parse() if showVersion { - fmt.Printf("macaronV2 %s (Go %s, stable)\n", version, runtime.Version()) + fmt.Printf("macaron %s (Go %s)\n", version, runtime.Version()) return 0 } if guide { @@ -170,7 +170,7 @@ func run() int { return 0 } if pipeline { - fmt.Printf("Pipeline (macaronV2 native): %s\n", filepath.Join(home, "pipeline.v2.yaml")) + fmt.Printf("Pipeline config path: %s\n", filepath.Join(home, "pipeline.v2.yaml")) return 0 } if listTools { @@ -211,8 +211,10 @@ func run() int { return 0 } if serve { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() server := ui.New(application.Store) - if err := server.Serve(serveAddr); err != nil { + if err := server.Serve(ctx, serveAddr); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) return 1 } @@ -276,42 +278,69 @@ func run() int { return 1 } if !quiet { - fmt.Println("macaronV2 scan summary") + fmt.Println("scan summary") fmt.Println(app.RenderScanSummary(res)) fmt.Printf("Completed %d target(s) in %s\n", len(res), time.Since(start).Round(time.Millisecond)) + if len(res) > 0 { + tgt := res[0].Target + fmt.Printf("\nWhat next?\n") + fmt.Printf(" macaron status\n") + fmt.Printf(" macaron results --dom %s --wht live\n", tgt) + fmt.Printf(" macaron results --dom %s --wht vulns\n", tgt) + fmt.Printf(" macaron serve\n") + } } return 0 } func printHelp() { - fmt.Println(`macaronV2 (Go stable rewrite) + fmt.Println(`macaron - reconnaissance workflow tool Usage: macaron scan example.com + macaron scan example.com --profile passive + macaron scan example.com --stages subdomains,http,urls macaron status - macaron results -dom example.com -wht live - macaron serve -adr 127.0.0.1:8088 + macaron results --dom example.com --wht live + macaron serve --addr 127.0.0.1:8088 macaron setup + macaron export --out results.json + +Core commands: + scan TARGET Scan one or more targets (positional or --scn) + status Show recent scan summaries + results Show detailed scan results + serve Start web dashboard + setup Show tool installation status + export Export scan results to JSON + guide Show workflow guide + +Scan flags: + --scn TARGET Scan one or more targets (repeatable) + --fil FILE Read targets from file + --inp Read targets from stdin + --profile NAME passive|balanced|aggressive (default: balanced) + --stages LIST subdomains,http,ports,urls,vulns (default: all) + --mod MODE wide|narrow|fast|deep|osint + --rate N Request rate hint (default: 150) + --threads N Worker threads (default: 30) -Core flags: - -scn TARGET Scan one or more targets - -fil FILE Read targets from file - -inp Read targets from stdin - -mod MODE wide|narrow|fast|deep|osint - -sts Show scan summaries - -res Show scan details - -exp Export JSON - -lst Show tool availability - -str DIR Use custom storage root (default ./storage) - -stg LIST Choose stages: subdomains,http,ports,urls,vulns - -sak k=v Save API keys to storage config.yaml - -shk Show masked API keys - -stp Show setup screen with tool status - -ins Install missing supported tools (Linux) - -prf NAME passive|balanced|aggressive - -gud Show first-principles workflow guide - -srv Start browser dashboard - -ver Show version`) +Output flags: + --dom DOMAIN Filter results by domain + --wht VIEW all|subdomains|live|ports|urls|js|vulns + --lim N Output row limit (default: 50) + --out FILE Output file path + --quiet Suppress progress output + +API keys: + --set-api k=v Save API key (e.g. securitytrails=KEY) + --show-api Show configured API keys (masked) + +Other: + --storage DIR Storage root (default: ./storage) + --addr ADDR Dashboard bind address (default: 127.0.0.1:8088) + --version Show version + --guide Show first-principles workflow guide`) } func normalizeLegacyArgs() { @@ -341,9 +370,6 @@ func normalizeCommandArgs() { } args = append(args, "--scn", tok) } - if len(args) == 1 { - args = append(args, "--scn") - } os.Args = args case "status": os.Args = append([]string{os.Args[0], "--sts"}, rest...) @@ -456,30 +482,30 @@ func applyProfile(profile string, mode *string, rate *int, threads *int, stages } func printGuide() { - fmt.Println(`macaronV2 guide (first-principles workflow) + fmt.Println(`macaron workflow guide 1) Setup once: macaron setup - macaron -ins - macaron -sak securitytrails=YOUR_KEY + macaron --ins + macaron --set-api securitytrails=YOUR_KEY -2) Run intentional scans: - macaron scan target.com -prf passive - macaron scan target.com -prf balanced - macaron scan target.com -prf aggressive -stg subdomains,http,ports,urls,vulns +2) Run scans: + macaron scan target.com --profile passive + macaron scan target.com --profile balanced + macaron scan target.com --profile aggressive --stages subdomains,http,ports,urls,vulns -3) Inspect and decide: +3) Inspect and triage: macaron status - macaron results -dom target.com -wht live + macaron results --dom target.com --wht live macaron serve -4) Export/share: - macaron export -out target.json +4) Export: + macaron export --out target.json Profiles: - passive low-noise, low-rate, mostly passive collection - balanced default practical pipeline - aggressive high concurrency for authorized deep testing only`) + passive low rate, low concurrency, passive collection only + balanced default practical pipeline + aggressive high concurrency for authorized deep testing only`) } func macaronHome(override string) (string, error) { diff --git a/docs/dashboard-analytics.png b/docs/dashboard-analytics.png new file mode 100644 index 0000000..f6a6e05 Binary files /dev/null and b/docs/dashboard-analytics.png differ diff --git a/docs/dashboard-main.png b/docs/dashboard-main.png new file mode 100644 index 0000000..de00704 Binary files /dev/null and b/docs/dashboard-main.png differ diff --git a/install.sh b/install.sh index 1ea1f9a..d412abf 100644 --- a/install.sh +++ b/install.sh @@ -10,14 +10,25 @@ if ! command -v go >/dev/null 2>&1; then fi mkdir -p "$HOME/.local/bin" -echo "[macaronV2] building binary..." +echo "[macaron] building binary..." go mod tidy go build -o "$HOME/.local/bin/macaron" ./cmd/macaron chmod +x "$HOME/.local/bin/macaron" -if ! grep -q 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2>/dev/null; then - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.bashrc" -fi +PATH_LINE='export PATH="$HOME/.local/bin:$PATH"' + +add_to_profile() { + local profile="$1" + if [ -f "$profile" ] && ! grep -qF 'HOME/.local/bin' "$profile" 2>/dev/null; then + echo "$PATH_LINE" >> "$profile" + echo "[macaron] added PATH entry to $profile" + fi +} + +add_to_profile "$HOME/.bashrc" +add_to_profile "$HOME/.zshrc" +add_to_profile "$HOME/.profile" -echo "[macaronV2] installed to $HOME/.local/bin/macaron" -echo "[macaronV2] run: macaron --version" +echo "[macaron] installed to $HOME/.local/bin/macaron" +echo "[macaron] restart your shell or run: export PATH=\"\$HOME/.local/bin:\$PATH\"" +echo "[macaron] then run: macaron --version" diff --git a/internal/app/app.go b/internal/app/app.go index 642accf..dfbbd69 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,7 +13,6 @@ import ( "sort" "strconv" "strings" - "time" "github.com/jedib0t/go-pretty/v6/table" "github.com/root-Manas/macaron/internal/engine" @@ -89,21 +88,27 @@ func (a *App) ShowStatus(limit int) (string, error) { return "", err } if len(summaries) == 0 { - return "No scans found. Run: macaron -s example.com", nil + return "No scans found.\nRun: macaron scan example.com\n", nil } b := strings.Builder{} - b.WriteString("macaronV2 status\n") + b.WriteString("macaron status\n") tw := table.NewWriter() - tw.AppendHeader(table.Row{"ID", "TARGET", "MODE", "LIVE", "URLS", "VULNS", "FINISHED"}) + tw.AppendHeader(table.Row{"ID", "TARGET", "MODE", "SUBS", "LIVE", "PORTS", "URLS", "VULNS", "FINISHED"}) for _, s := range summaries { + shortID := s.ID + if len(shortID) > 12 { + shortID = shortID[:12] + } tw.AppendRow(table.Row{ - s.ID, + shortID, s.Target, s.Mode, + strconv.Itoa(s.Stats.Subdomains), strconv.Itoa(s.Stats.LiveHosts), + strconv.Itoa(s.Stats.Ports), strconv.Itoa(s.Stats.URLs), strconv.Itoa(s.Stats.Vulns), - s.FinishedAt.Format(time.RFC3339), + s.FinishedAt.Format("2006-01-02 15:04"), }) } b.WriteString(tw.Render()) @@ -188,9 +193,9 @@ func ParseTargets(raw []string, filePath string, stdin bool) ([]string, error) { func formatResults(res model.ScanResult, what string, limit int) string { b := strings.Builder{} - b.WriteString(fmt.Sprintf("Scan: %s (%s)\n", res.Target, res.ID)) - b.WriteString(fmt.Sprintf("Mode: %s Duration: %dms\n", res.Mode, res.DurationMS)) - b.WriteString(fmt.Sprintf("Stats: subdomains=%d live=%d ports=%d urls=%d js=%d vulns=%d\n\n", + b.WriteString(fmt.Sprintf("target: %s id: %s\n", res.Target, res.ID)) + b.WriteString(fmt.Sprintf("mode: %s duration: %dms\n", res.Mode, res.DurationMS)) + b.WriteString(fmt.Sprintf("subdomains: %d live: %d ports: %d urls: %d js: %d vulns: %d\n\n", res.Stats.Subdomains, res.Stats.LiveHosts, res.Stats.Ports, res.Stats.URLs, res.Stats.JSFiles, res.Stats.Vulns, )) @@ -200,9 +205,13 @@ func formatResults(res model.ScanResult, what string, limit int) string { b.WriteString(v + "\n") } case "live": + tw := table.NewWriter() + tw.AppendHeader(table.Row{"STATUS", "URL", "TITLE"}) for _, v := range firstNLive(res.LiveHosts, limit) { - b.WriteString(fmt.Sprintf("%d %s %s\n", v.StatusCode, v.URL, v.Title)) + tw.AppendRow(table.Row{v.StatusCode, v.URL, v.Title}) } + b.WriteString(tw.Render()) + b.WriteString("\n") case "ports": for _, v := range firstNPorts(res.Ports, limit) { b.WriteString(fmt.Sprintf("%s:%d\n", v.Host, v.Port)) @@ -216,9 +225,13 @@ func formatResults(res model.ScanResult, what string, limit int) string { b.WriteString(v + "\n") } case "vulns": + tw := table.NewWriter() + tw.AppendHeader(table.Row{"SEVERITY", "TEMPLATE", "MATCHED"}) for _, v := range firstNVulns(res.Vulns, limit) { - b.WriteString(fmt.Sprintf("[%s] %s -> %s\n", v.Severity, v.Template, v.Matched)) + tw.AppendRow(table.Row{strings.ToUpper(v.Severity), v.Template, v.Matched}) } + b.WriteString(tw.Render()) + b.WriteString("\n") default: enc, _ := json.MarshalIndent(res, "", " ") b.WriteString(string(enc) + "\n") @@ -257,7 +270,8 @@ func SetupCatalog() []SetupTool { func RenderSetup(tools []SetupTool) string { tw := table.NewWriter() - tw.AppendHeader(table.Row{"TOOL", "REQUIRED", "STATUS", "INSTALL"}) + tw.AppendHeader(table.Row{"TOOL", "REQUIRED", "STATUS", "INSTALL COMMAND"}) + installedCount := 0 for _, t := range tools { required := "no" if t.Required { @@ -265,14 +279,30 @@ func RenderSetup(tools []SetupTool) string { } status := "missing" if t.Installed { - status = "installed" + status = "ok" + installedCount++ } tw.AppendRow(table.Row{t.Name, required, status, t.InstallCmd}) } b := strings.Builder{} b.WriteString("macaron setup\n") b.WriteString(tw.Render()) - b.WriteString("\n") + b.WriteString(fmt.Sprintf("\n%d / %d tools installed\n", installedCount, len(tools))) + missing := 0 + for _, t := range tools { + if t.Required && !t.Installed { + missing++ + } + } + if missing > 0 { + b.WriteString(fmt.Sprintf("\n%d required tool(s) missing. Run: macaron --ins\n", missing)) + } else { + b.WriteString("\nAll required tools are installed.\n") + b.WriteString("\nNext steps:\n") + b.WriteString(" macaron scan example.com\n") + b.WriteString(" macaron status\n") + b.WriteString(" macaron serve\n") + } return b.String() } diff --git a/internal/model/model.go b/internal/model/model.go index 1955361..4e38d6d 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -70,6 +70,39 @@ type ToolStatus struct { Installed bool `json:"installed"` } +// DayStat holds aggregated scan findings for a single calendar day. +type DayStat struct { + Day string `json:"day"` + Scans int `json:"scans"` + Subdomains int `json:"subdomains"` + LiveHosts int `json:"live_hosts"` + URLs int `json:"urls"` + Vulns int `json:"vulns"` +} + +// TargetRank holds a target ranked by vuln and live host counts. +type TargetRank struct { + Target string `json:"target"` + Vulns int `json:"vulns"` + LiveHosts int `json:"live_hosts"` +} + +// SeverityCount holds a vulnerability severity level and its total count. +type SeverityCount struct { + Severity string `json:"severity"` + Count int `json:"count"` +} + +// AnalyticsReport is the response returned by /api/analytics. +type AnalyticsReport struct { + ScanCount int `json:"scan_count"` + AvgDurationMS int64 `json:"avg_duration_ms"` + Totals ScanStats `json:"totals"` + Days []DayStat `json:"days"` + TopTargets []TargetRank `json:"top_targets"` + SeverityDist []SeverityCount `json:"severity_dist"` +} + type StageEventType string const ( diff --git a/internal/store/store.go b/internal/store/store.go index 99ed00a..1128d9f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -14,7 +14,6 @@ import ( "github.com/root-Manas/macaron/internal/model" _ "modernc.org/sqlite" ) - type Store struct { baseDir string db *sql.DB @@ -222,6 +221,156 @@ func (s *Store) Export(path string, target string) (string, error) { return path, nil } +// Analytics returns aggregated statistics across all scans for display in the +// dashboard analytics tab. +func (s *Store) Analytics() (model.AnalyticsReport, error) { + report := model.AnalyticsReport{} + + // Per-day scan counts and cumulative findings. + rows, err := s.db.Query(` + SELECT id, target, finished_at, duration_ms, stats_json + FROM scans + ORDER BY finished_at ASC + `) + if err != nil { + return report, err + } + defer rows.Close() + + dayMap := map[string]*model.DayStat{} + targetVulns := map[string]int{} + targetLive := map[string]int{} + var totalStats model.ScanStats + totalDurationMS := int64(0) + scanCount := 0 + + for rows.Next() { + var id, target, finishedAtRaw, statsRaw string + var durationMS int64 + if err := rows.Scan(&id, &target, &finishedAtRaw, &durationMS, &statsRaw); err != nil { + continue + } + _ = id + var stats model.ScanStats + if err := json.Unmarshal([]byte(statsRaw), &stats); err != nil { + continue + } + t, _ := time.Parse(time.RFC3339Nano, finishedAtRaw) + day := t.UTC().Format("2006-01-02") + + if _, ok := dayMap[day]; !ok { + dayMap[day] = &model.DayStat{Day: day} + } + d := dayMap[day] + d.Scans++ + d.Subdomains += stats.Subdomains + d.LiveHosts += stats.LiveHosts + d.URLs += stats.URLs + d.Vulns += stats.Vulns + + targetVulns[target] += stats.Vulns + targetLive[target] += stats.LiveHosts + + totalStats.Subdomains += stats.Subdomains + totalStats.LiveHosts += stats.LiveHosts + totalStats.Ports += stats.Ports + totalStats.URLs += stats.URLs + totalStats.JSFiles += stats.JSFiles + totalStats.Vulns += stats.Vulns + totalDurationMS += durationMS + scanCount++ + } + if err := rows.Err(); err != nil { + return report, err + } + + // Collect day stats sorted by date. + days := make([]model.DayStat, 0, len(dayMap)) + for _, d := range dayMap { + days = append(days, *d) + } + sort.Slice(days, func(i, j int) bool { return days[i].Day < days[j].Day }) + + // Top 10 targets by vuln count, then by live hosts. + type kv struct { + target string + vulns int + live int + } + ranked := make([]kv, 0, len(targetVulns)) + for t := range targetVulns { + ranked = append(ranked, kv{target: t, vulns: targetVulns[t], live: targetLive[t]}) + } + sort.Slice(ranked, func(i, j int) bool { + if ranked[i].vulns != ranked[j].vulns { + return ranked[i].vulns > ranked[j].vulns + } + return ranked[i].live > ranked[j].live + }) + topTargets := make([]model.TargetRank, 0, 10) + for i, r := range ranked { + if i >= 10 { + break + } + topTargets = append(topTargets, model.TargetRank{Target: r.target, Vulns: r.vulns, LiveHosts: r.live}) + } + + // Severity distribution requires reading full payloads. + sevRows, err := s.db.Query(`SELECT payload_json FROM scans`) + if err != nil { + return report, err + } + defer sevRows.Close() + sevMap := map[string]int{} + for sevRows.Next() { + var p string + if err := sevRows.Scan(&p); err != nil { + continue + } + res, err := decodeScan(p) + if err != nil { + continue + } + for _, v := range res.Vulns { + sev := strings.ToLower(strings.TrimSpace(v.Severity)) + if sev == "" { + sev = "unknown" + } + sevMap[sev]++ + } + } + if err := sevRows.Err(); err != nil { + return report, err + } + + sevDist := make([]model.SeverityCount, 0, len(sevMap)) + for sev, count := range sevMap { + sevDist = append(sevDist, model.SeverityCount{Severity: sev, Count: count}) + } + sort.Slice(sevDist, func(i, j int) bool { + order := map[string]int{"critical": 0, "high": 1, "medium": 2, "low": 3, "unknown": 4} + oi := order[sevDist[i].Severity] + oj := order[sevDist[j].Severity] + if oi != oj { + return oi < oj + } + return sevDist[i].Count > sevDist[j].Count + }) + + avgDurationMS := int64(0) + if scanCount > 0 { + avgDurationMS = totalDurationMS / int64(scanCount) + } + + report.ScanCount = scanCount + report.Totals = totalStats + report.AvgDurationMS = avgDurationMS + report.Days = days + report.TopTargets = topTargets + report.SeverityDist = sevDist + return report, nil +} + func decodeScan(payload string) (*model.ScanResult, error) { var res model.ScanResult if err := json.Unmarshal([]byte(payload), &res); err != nil { diff --git a/internal/ui/assets/index.html b/internal/ui/assets/index.html index 5ba19ed..014a290 100644 --- a/internal/ui/assets/index.html +++ b/internal/ui/assets/index.html @@ -3,7 +3,7 @@ - macaronV2 Ops Console + macaron Ops Console @@ -275,12 +275,101 @@ } .btn:hover { border-color: var(--accent); color: var(--accent); } + .ana-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0,1fr)); + gap: 12px; + } + .ana-totals { + display: grid; + grid-template-columns: repeat(3, minmax(0,1fr)); + gap: 9px; + margin-bottom: 10px; + } + .bar-chart { display: flex; flex-direction: column; gap: 6px; } + .bar-row { + display: grid; + grid-template-columns: 130px 1fr 50px; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + } + .bar-inner { + height: 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: #07121b; + overflow: hidden; + } + .bar-fill { + height: 100%; + background: linear-gradient(90deg, #4dfbc6, #59b7ff); + animation: grow .4s ease; + } + .sev-bar-fill.critical { background: linear-gradient(90deg, #ff6f8f, #ff3355); } + .sev-bar-fill.high { background: linear-gradient(90deg, #ff9060, #ff6f8f); } + .sev-bar-fill.medium { background: linear-gradient(90deg, #ffcc66, #ffaa33); } + .sev-bar-fill.low { background: linear-gradient(90deg, #59b7ff, #4dfbc6); } + .day-chart { + display: flex; + align-items: flex-end; + gap: 4px; + height: 80px; + padding-bottom: 2px; + } + .day-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + cursor: default; + } + .day-bar-wrap { + flex: 1; + width: 100%; + display: flex; + align-items: flex-end; + } + .day-bar { + width: 100%; + background: linear-gradient(0deg, #4dfbc6, #59b7ff); + border-radius: 3px 3px 0 0; + min-height: 2px; + animation: grow .4s ease; + } + .day-label { + font-size: 10px; + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + writing-mode: vertical-rl; + text-orientation: mixed; + transform: rotate(180deg); + max-height: 60px; + overflow: hidden; + } + .ana-nav { + border: 1px solid var(--line); + border-radius: 10px; + padding: 6px 12px; + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + color: var(--muted); + background: #0a1723; + cursor: pointer; + transition: all .15s ease; + } + .ana-nav:hover, .ana-nav.active { border-color: var(--accent); color: var(--accent); } @media (max-width: 1260px) { .shell { grid-template-columns: 1fr; } .left { max-height: 300px; } .summary { grid-template-columns: repeat(3, minmax(0,1fr)); } .split { grid-template-columns: 1fr; } .status-grid { grid-template-columns: 1fr; } + .ana-grid { grid-template-columns: 1fr; } + .ana-totals { grid-template-columns: repeat(2, minmax(0,1fr)); } } @@ -288,10 +377,11 @@
-

macaronV2 OPS Console

-
Intent-first recon: setup -> scan -> inspect -> export
+

macaron Ops Console

+
setup → scan → inspect → export
+
loading scans...
refreshing...
@@ -313,6 +403,10 @@

macaronV2 OPS Console

Select a scan from the left panel.
+ +
+
Loading analytics...
+
diff --git a/internal/ui/server.go b/internal/ui/server.go index ca659f4..db080bd 100644 --- a/internal/ui/server.go +++ b/internal/ui/server.go @@ -1,6 +1,7 @@ package ui import ( + "context" "embed" "encoding/json" "fmt" @@ -30,14 +31,32 @@ func New(st *store.Store) *Server { } } -func (s *Server) Serve(addr string) error { +func (s *Server) Serve(ctx context.Context, addr string) error { mux := http.NewServeMux() mux.HandleFunc("/", s.handleIndex) mux.HandleFunc("/api/scans", s.handleScans) mux.HandleFunc("/api/results", s.handleResults) mux.HandleFunc("/api/heat", s.handleHeat) - fmt.Printf("macaronV2 dashboard on http://%s\n", addr) - return http.ListenAndServe(addr, mux) + mux.HandleFunc("/api/analytics", s.handleAnalytics) + srv := &http.Server{Addr: addr, Handler: mux} + fmt.Printf("macaron dashboard → http://%s (Ctrl-C to stop)\n", addr) + + errCh := make(chan error, 1) + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + close(errCh) + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return srv.Shutdown(shutCtx) + } } func (s *Server) handleScans(w http.ResponseWriter, r *http.Request) { @@ -111,7 +130,7 @@ func (s *Server) handleHeat(w http.ResponseWriter, r *http.Request) { if ip == "" { continue } - p, ok := s.lookupGeo(ip) + p, ok := s.lookupGeo(r.Context(), ip) if !ok { continue } @@ -132,6 +151,15 @@ func (s *Server) handleHeat(w http.ResponseWriter, r *http.Request) { writeJSON(w, out) } +func (s *Server) handleAnalytics(w http.ResponseWriter, r *http.Request) { + report, err := s.Store.Analytics() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, report) +} + func hostFromURL(raw string) string { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil { @@ -161,7 +189,7 @@ func firstResolvableIP(host string) string { return "" } -func (s *Server) lookupGeo(ip string) (heatPoint, bool) { +func (s *Server) lookupGeo(ctx context.Context, ip string) (heatPoint, bool) { s.cacheMu.Lock() if p, ok := s.geoCache[ip]; ok { s.cacheMu.Unlock() @@ -170,7 +198,10 @@ func (s *Server) lookupGeo(ip string) (heatPoint, bool) { s.cacheMu.Unlock() client := &http.Client{Timeout: 4 * time.Second} - req, _ := http.NewRequest(http.MethodGet, "http://ip-api.com/json/"+ip+"?fields=status,country,city,lat,lon", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://ip-api.com/json/"+ip+"?fields=status,country,city,lat,lon", nil) + if err != nil { + return heatPoint{}, false + } resp, err := client.Do(req) if err != nil { return heatPoint{}, false