Updates
ci / test (push) Failing after 52s
ci / release (push) Has been skipped

This commit is contained in:
2026-07-10 14:59:29 -04:00
parent 92706a7271
commit 7ac05f989d
37 changed files with 1073 additions and 440 deletions
+7 -1
View File
@@ -1,4 +1,10 @@
# Optional local overrides for hp-viz
# Optional local overrides for hp-viz (copy to .env or ~/.config/hp-viz/.env)
HP_VIZ_URL=https://viz.honeypeer.com
# HP_VIZ_BUFFER=1000
# HP_VIZ_MOUSE=0
# HP_VIZ_BELL=0
# HP_VIZ_NO_UPDATE=0
# HP_VIZ_REDUCED_MOTION=0
# HP_VIZ_OPERATOR_TOKEN=
# HP_VIZ_SUBSCRIPTION_ID=
# HP_VIZ_SUBSCRIPTION_EMAIL=
+25 -3
View File
@@ -18,8 +18,29 @@ jobs:
run: bash scripts/ci-setup-go.sh
env:
GO_VERSION: ${{ env.GO_VERSION }}
- run: go test ./...
- run: go build -o bin/hp-viz ./cmd/hp-viz/
- name: Format check
run: |
unformatted=$(gofmt -l $(find . -name '*.go' -not -path './.git/*'))
if [ -n "$unformatted" ]; then
echo "gofmt needed on:"
echo "$unformatted"
exit 1
fi
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
- name: Build
run: go build -trimpath -o bin/hp-viz ./cmd/hp-viz/
- name: CLI smoke
run: |
./bin/hp-viz --help
./bin/hp-viz --version
./bin/hp-viz --fixture tests/fixtures/sse-sample.txt --no-update --no-color &
pid=$!
sleep 1
kill $pid 2>/dev/null || true
wait $pid 2>/dev/null || true
release:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
@@ -36,13 +57,14 @@ jobs:
run: |
set -euo pipefail
mkdir -p dist
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
for spec in "linux amd64" "linux arm64" "darwin amd64" "darwin arm64"; do
set -- $spec
os=$1 arch=$2
bin="hp-viz-${os}-${arch}"
pkg="hp-viz-${os}-${arch}.tar.gz"
GOOS=$os GOARCH=$arch CGO_ENABLED=0 go build \
-ldflags="-s -w -X github.com/honeypeer/cli-viz/internal/update.BuildCommit=${GITHUB_SHA}" \
-ldflags="-s -w -X github.com/honeypeer/cli-viz/internal/update.BuildCommit=${GITHUB_SHA} -X github.com/honeypeer/cli-viz/internal/update.BuildTime=${BUILD_TIME}" \
-trimpath \
-o "dist/${bin}" ./cmd/hp-viz/
tar -czf "dist/${pkg}" -C dist "${bin}"
+26
View File
@@ -1,3 +1,29 @@
# Build outputs
bin/
dist/
hp-viz
hp-viz-*
*.exe
# Local env / secrets
.env
.env.*
!.env.example
bin/.env*
# Recorded streams / scratch
feed.jsonl
*.jsonl
!tests/fixtures/**
# IDE / OS
.idea/
.vscode/
*.swp
*~
.DS_Store
# Coverage / tooling
coverage.out
coverage.html
*.test
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 20242026 HoneyPeer
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.
+49 -7
View File
@@ -1,19 +1,61 @@
.PHONY: build test run install fixture install-script
.PHONY: build test vet fmt check run install fixture install-script clean version release-build
MODULE := github.com/honeypeer/cli-viz
VERSION ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w \
-X $(MODULE)/internal/update.BuildCommit=$(COMMIT) \
-X $(MODULE)/internal/update.BuildTime=$(DATE)
build:
go build -o bin/hp-viz ./cmd/hp-viz/
go build -trimpath -ldflags "$(LDFLAGS)" -o bin/hp-viz ./cmd/hp-viz/
# Release-style multi-arch archives (mirrors CI).
release-build:
@mkdir -p dist
@for spec in "linux amd64" "linux arm64" "darwin amd64" "darwin arm64"; do \
set -- $$spec; os=$$1 arch=$$2; \
bin="hp-viz-$${os}-$${arch}"; \
GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o "dist/$${bin}" ./cmd/hp-viz/; \
tar -czf "dist/$${bin}.tar.gz" -C dist "$${bin}"; \
sha256sum "dist/$${bin}.tar.gz" | awk '{print $$1}' > "dist/$${bin}.tar.gz.sha256"; \
echo "built dist/$${bin}.tar.gz"; \
done
test:
go test ./...
vet:
go vet ./...
fmt:
gofmt -w $$(find . -name '*.go' -not -path './.git/*')
# Fail if sources are not gofmt-clean (CI).
fmt-check:
@unformatted=$$(gofmt -l $$(find . -name '*.go' -not -path './.git/*')); \
if [ -n "$$unformatted" ]; then \
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; \
fi
check: fmt-check vet test
go build -o /tmp/hp-viz-check ./cmd/hp-viz/
version: build
./bin/hp-viz --version
install: build
cp bin/hp-viz $(DESTDIR)/usr/local/bin/
install -m 755 bin/hp-viz $(DESTDIR)/usr/local/bin/hp-viz
install-script:
bash scripts/install.sh
fixture:
./bin/hp-viz --fixture tests/fixtures/sse-sample.txt
fixture: build
./bin/hp-viz --fixture tests/fixtures/sse-sample.txt --no-update
run:
./bin/hp-viz --url $(URL)
run: build
./bin/hp-viz --url $(or $(URL),https://viz.honeypeer.com) --no-update
clean:
rm -rf bin/ dist/ /tmp/hp-viz-check
+43 -22
View File
@@ -1,9 +1,12 @@
# hp-viz
**hp-viz** is a terminal app for watching the [HoneyPeer](https://honeypeer.com) public attack visualizer in real time — live feed, incidents, peers, stats, and threat-intelligence blog posts, without opening a browser.
**hp-viz** is a production-ready terminal app for watching the [HoneyPeer](https://honeypeer.com) public attack visualizer in real time — live feed, incidents, peers, stats, history, and threat-intelligence blog posts, without opening a browser.
Default data source: [viz.honeypeer.com](https://viz.honeypeer.com)
![Go](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&logoColor=white)
![License](https://img.shields.io/badge/license-MIT-green)
## Install (recommended)
One command — auto-detects Linux/macOS and amd64/arm64:
@@ -35,12 +38,13 @@ Archives: `hp-viz-linux-amd64`, `hp-viz-linux-arm64`, `hp-viz-darwin-amd64`, `hp
## Build from source
Requires Go 1.22+ and a truecolor terminal (`COLORTERM=truecolor` recommended).
Requires Go 1.24+ and a truecolor terminal (`COLORTERM=truecolor` recommended).
```bash
git clone https://git.ssh.surf/snxraven/honeypeer-viz-cli.git
cd honeypeer-viz-cli
go build -o hp-viz ./cmd/hp-viz/
make build
./bin/hp-viz --version
```
## Quick start
@@ -51,21 +55,30 @@ hp-viz
# Offline demo (bundled fixture)
hp-viz --fixture tests/fixtures/sse-sample.txt
# Help / version
hp-viz --help
hp-viz --version
```
## Configuration
Precedence: **flags → environment → config file → defaults**.
Optional file: `~/.config/hp-viz/config.yaml` (see `config.yaml.example`).
| Variable | Description |
|----------|-------------|
| `HP_VIZ_URL` | Master base URL (default: `https://viz.honeypeer.com`) |
| `HP_VIZ_BUFFER` | In-memory feed size (default `1000`) |
| `HP_VIZ_BUFFER` | In-memory feed size (default `1000`, clamped 505000) |
| `HP_VIZ_MOUSE` | Set `1` or `true` to enable mouse on startup |
| `HP_VIZ_BELL` | Terminal bell on block-tier events |
| `HP_VIZ_REDUCED_MOTION` | Static mesh and no synthesized sounds |
| `HP_VIZ_OPERATOR_TOKEN` | Saved subscription operator token |
| `HP_VIZ_SUBSCRIPTION_ID` | Subscription ID (`sub_…`) for startup sign-in |
| `HP_VIZ_SUBSCRIPTION_EMAIL` | Billing email paired with subscription ID |
Optional file: `~/.config/hp-viz/config.yaml` (see `config.yaml.example`).
Precedence: **flags → environment → config file → defaults**.
| `HP_VIZ_NO_UPDATE` | Skip automatic update checks |
| `NO_COLOR` / `HP_VIZ_NO_COLOR` | Disable colors |
| Flag | Description |
|------|-------------|
@@ -77,13 +90,18 @@ Precedence: **flags → environment → config file → defaults**.
| `--bell` | Terminal bell on high-severity events |
| `--subscription` | Subscription ID for operator sign-in at startup |
| `--email` | Billing email for operator sign-in at startup |
| `--no-update` | Skip automatic update checks |
| `--version` | Print version and exit |
| `--update` | Check and apply rolling update, then exit |
| `--help` | Show help |
## Keyboard
| Key | Action |
|-----|--------|
| `j` / `k`, `↑` / `↓` | Move selection |
| `Tab` | Feed → Incidents → Peers → Stats → **History** → Blog |
| `Tab` / `Shift+Tab` | Next / previous view |
| `1``6` | Jump to Feed · Incidents · Peers · Stats · History · Blog |
| `Enter` | Open detail (or read blog post on Blog tab) |
| `Esc` | Close detail / back from blog post / cancel filter or search |
| `m` | Toggle mouse (off by default) |
@@ -93,9 +111,11 @@ Precedence: **flags → environment → config file → defaults**.
| `f` | Toggle follow mode (auto-scroll to newest) |
| `s` | Search by attack or moderation ID (opens detail on match) |
| `o` | Operator sign-in — subscription ID + billing email unlocks real IPs |
| `/` | Filter — text or `service:ssh geo:cn peer:id since:1h` |
| `PgUp` | Page up in list / load more blog posts |
| `PgDn` | Page down in list |
| `/` | Filter — text or `service:ssh geo:cn peer:id tier:block since:1h` |
| `v` | Toggle peer mesh under the Feed tab |
| `e` | Toggle event sounds |
| `-` / `=` | Volume down / up (while sound is on) |
| `PgUp` / `PgDn` | Page list / load more blog or history |
| `]` / `[` | History sub-tab (attacks/reputation) / time range |
| `r` | Reconnect stream |
| `?` | Help overlay |
@@ -114,33 +134,34 @@ HP_VIZ_NO_UPDATE=1 hp-viz # same via environment
Source builds (`go build` without release ldflags) are not auto-updated. Install via the curl installer to get a self-updating binary.
In the detail view, `j`/`k` or the mouse wheel scroll long content.
## What you see
- **Feed** — live attacks, blocks, moderation events (masked IPs on the public feed; **operator mode** shows real IPs)
- **Incidents** — grouped activity with drill-down detail
- **Peers** — animated hub-and-spoke mesh (like the web viz), live roster, connect events
- **Stats** — aggregates and breakdowns
- **History** — paginated attack and reputation database history (like the web viz sidebar), with time-range filters
- **History** — paginated attack and reputation database history, with time-range filters
- **Blog** — published HoneyPeer threat briefings (markdown, tables, charts)
Timestamps use your **local timezone**.
## Development
```bash
make check # gofmt, vet, test, build
make test
make build
make fixture # offline TUI demo
```
## Releases (maintainers)
Every push to `main` runs tests, then rebuilds and **replaces** the rolling [`latest`](https://git.ssh.surf/snxraven/honeypeer-viz-cli/releases/tag/latest) release (binaries + `install.sh`). Requires the `RELEASE_TOKEN` Actions secret.
Every push to `main` runs format/vet/tests, then rebuilds and **replaces** the rolling [`latest`](https://git.ssh.surf/snxraven/honeypeer-viz-cli/releases/tag/latest) release (binaries + `install.sh`). Requires the `RELEASE_TOKEN` Actions secret.
Point **https://viz-cli.honeypeer.com** at:
`https://git.ssh.surf/snxraven/honeypeer-viz-cli/releases/download/latest/install.sh`
## Tests
```bash
go test ./...
```
## License
See repository license file.
MIT — see [LICENSE](LICENSE).
+23 -9
View File
@@ -18,8 +18,12 @@ import (
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
fmt.Fprintf(os.Stderr, "hp-viz: %v\n", err)
os.Exit(2)
}
if cfg.Help {
os.Exit(0)
}
if cfg.Version {
@@ -38,14 +42,18 @@ func main() {
}
if cfg.NoColor {
os.Setenv("NO_COLOR", "1")
_ = os.Setenv("NO_COLOR", "1")
lipgloss.SetColorProfile(termenv.Ascii)
}
m := ui.NewModel(cfg)
p := tea.NewProgram(m, tea.WithAltScreen())
p := tea.NewProgram(
m,
tea.WithAltScreen(),
tea.WithReportFocus(),
)
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
fmt.Fprintf(os.Stderr, "hp-viz: %v\n", err)
os.Exit(1)
}
}
@@ -64,7 +72,7 @@ func runAutoUpdate() string {
return fmt.Sprintf("hp-viz update check failed: %v", err)
}
if res.Updated {
return fmt.Sprintf("hp-viz updated to %s", res.CommitShort)
return fmt.Sprintf("hp-viz updated to %s — restart recommended", res.CommitShort)
}
return ""
}
@@ -78,7 +86,7 @@ func exitUpdate(force bool) {
res, err := update.MaybeCheck(opts)
if err != nil {
fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
fmt.Fprintf(os.Stderr, "hp-viz: update failed: %v\n", err)
os.Exit(1)
}
switch {
@@ -87,9 +95,15 @@ func exitUpdate(force bool) {
case res.Skipped == "already current":
fmt.Printf("Already up to date (%s)\n", res.CommitShort)
case res.Skipped == "source build":
fmt.Fprintln(os.Stderr, "This is a source build (no embedded commit). Install from the latest release to enable updates.")
fmt.Fprintln(os.Stderr, "hp-viz: this is a source build (no embedded commit).")
fmt.Fprintln(os.Stderr, "Install from the latest release to enable updates:")
fmt.Fprintln(os.Stderr, " curl -fsSL https://viz-cli.honeypeer.com | bash")
os.Exit(1)
default:
fmt.Println(res.Skipped)
if res.Skipped != "" {
fmt.Println(res.Skipped)
} else {
fmt.Println("No update available")
}
}
}
+7 -1
View File
@@ -1,13 +1,19 @@
# hp-viz configuration (~/.config/hp-viz/config.yaml)
# Flags and environment variables override these values.
# Precedence: flags environment → this file → defaults.
url: https://viz.honeypeer.com
buffer: 1000
bell: false
mouse: false
# no_update: false
# Operator mode (optional — use `o` in the TUI to sign in interactively)
# operator_token: "…" # saved automatically after subscription sign-in
# subscription_id: sub_…
# subscription_email: [email protected]
# secret: … # raw HP_VIZ_OPERATOR_SECRET (admin override)
# Optional environment-only knobs (set in shell or .env):
# HP_VIZ_REDUCED_MOTION=1 static mesh, disable sounds
# HP_VIZ_UPDATE_INTERVAL_HOURS=6
# HP_VIZ_RELEASE_BASE=…
+136 -20
View File
@@ -1,8 +1,12 @@
package config
import (
"errors"
"flag"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
)
@@ -10,6 +14,12 @@ import (
// DefaultPublicURL is the HoneyPeer public viz API (live attack feed).
const DefaultPublicURL = "https://viz.honeypeer.com"
const (
minBuffer = 50
maxBuffer = 5000
)
// Config holds runtime options resolved from flags, environment, and config files.
type Config struct {
URL string
Secret string
@@ -25,34 +35,89 @@ type Config struct {
NoUpdate bool
Version bool
Update bool
Help bool
}
// usageText is printed for -h / --help.
const usageText = `hp-viz — HoneyPeer terminal attack visualizer
Watch the public HoneyPeer attack feed, incidents, peers, stats, history, and
threat-intelligence blog posts in your terminal.
Usage:
hp-viz [flags]
Examples:
hp-viz
hp-viz --fixture tests/fixtures/sse-sample.txt
hp-viz --url https://viz.honeypeer.com --bell
hp-viz --subscription sub_… --email [email protected]
hp-viz --version
hp-viz --update
Configuration:
Flags override environment variables, which override ~/.config/hp-viz/config.yaml.
See config.yaml.example and README for the full list.
In-app keys:
? help · Tab views · j/k navigate · Enter detail · / filter · s search
o operator · m mouse · e sound · r reconnect · q quit
Flags:
`
// Load resolves configuration from flags (os.Args), environment, and config files.
func Load() (Config, error) {
return LoadArgs(os.Args[1:])
}
// LoadArgs is like Load but parses the given argument slice (useful for tests).
func LoadArgs(args []string) (Config, error) {
LoadDotEnv()
loadYAMLConfig()
url := flag.String("url", envOr("HP_VIZ_URL", DefaultPublicURL), "HoneyPeer public viz base URL")
secret := flag.String("secret", envOr("HP_VIZ_SECRET", envOr("HP_VIZ_OPERATOR_SECRET", "")), "Operator secret for real IPs + full detail")
subscription := flag.String("subscription", envOr("HP_VIZ_SUBSCRIPTION_ID", ""), "Subscription ID (sub_…) to claim operator access")
email := flag.String("email", envOr("HP_VIZ_SUBSCRIPTION_EMAIL", ""), "Billing email for subscription validation")
buffer := flag.Int("buffer", envIntOr("HP_VIZ_BUFFER", 1000), "Feed ring buffer size")
fixture := flag.String("fixture", "", "Replay SSE from fixture file (offline dev)")
record := flag.String("record", "", "Append SSE events to JSONL file")
noColor := flag.Bool("no-color", false, "Disable truecolor output")
bell := flag.Bool("bell", false, "Terminal bell on block-tier events")
noUpdate := flag.Bool("no-update", envBoolOr("HP_VIZ_NO_UPDATE", false), "Disable automatic update checks")
showVersion := flag.Bool("version", false, "Print version and exit")
forceUpdate := flag.Bool("update", false, "Check for updates, apply if available, and exit")
flag.Parse()
fs := flag.NewFlagSet("hp-viz", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.Usage = func() {
fmt.Fprint(os.Stderr, usageText)
fs.PrintDefaults()
fmt.Fprintln(os.Stderr)
}
urlFlag := fs.String("url", envOr("HP_VIZ_URL", DefaultPublicURL), "HoneyPeer public viz base URL")
secret := fs.String("secret", envOr("HP_VIZ_SECRET", envOr("HP_VIZ_OPERATOR_SECRET", "")), "Operator secret for real IPs + full detail")
subscription := fs.String("subscription", envOr("HP_VIZ_SUBSCRIPTION_ID", ""), "Subscription ID (sub_…) to claim operator access")
email := fs.String("email", envOr("HP_VIZ_SUBSCRIPTION_EMAIL", ""), "Billing email for subscription validation")
buffer := fs.Int("buffer", envIntOr("HP_VIZ_BUFFER", 1000), "Feed ring buffer size (505000)")
fixture := fs.String("fixture", "", "Replay SSE from fixture file (offline / demo)")
record := fs.String("record", "", "Append live SSE events to a JSONL file")
noColor := fs.Bool("no-color", envPresentOrBool("NO_COLOR") || envBoolOr("HP_VIZ_NO_COLOR", false), "Disable truecolor output")
bell := fs.Bool("bell", envBoolOr("HP_VIZ_BELL", false), "Terminal bell on block-tier events")
noUpdate := fs.Bool("no-update", envBoolOr("HP_VIZ_NO_UPDATE", false), "Disable automatic update checks")
showVersion := fs.Bool("version", false, "Print version and exit")
forceUpdate := fs.Bool("update", false, "Check for updates, apply if available, and exit")
showHelp := fs.Bool("help", false, "Show this help and exit")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return Config{Help: true}, nil
}
return Config{}, err
}
if *showHelp {
fs.Usage()
return Config{Help: true}, nil
}
cfg := Config{
URL: trimSlash(*url),
URL: trimSlash(*urlFlag),
Secret: strings.TrimSpace(*secret),
SubscriptionID: strings.TrimSpace(*subscription),
Email: strings.TrimSpace(strings.ToLower(*email)),
Buffer: *buffer,
Fixture: *fixture,
Record: *record,
Fixture: strings.TrimSpace(*fixture),
Record: strings.TrimSpace(*record),
NoColor: *noColor,
Bell: *bell,
NoUpdate: *noUpdate,
@@ -74,15 +139,61 @@ func Load() (Config, error) {
if cfg.Fixture != "" {
cfg.URL = ""
}
if cfg.Buffer < 50 {
cfg.Buffer = 50
if cfg.Buffer < minBuffer {
cfg.Buffer = minBuffer
}
if cfg.Buffer > 5000 {
cfg.Buffer = 5000
if cfg.Buffer > maxBuffer {
cfg.Buffer = maxBuffer
}
// Version / update / help don't need full validation.
if cfg.Version || cfg.Update || cfg.Help {
return cfg, nil
}
if err := cfg.validate(); err != nil {
return cfg, err
}
return cfg, nil
}
func (cfg Config) validate() error {
if cfg.Fixture != "" {
if _, err := os.Stat(cfg.Fixture); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("fixture file not found: %s", cfg.Fixture)
}
return fmt.Errorf("fixture file: %w", err)
}
return nil
}
if cfg.URL == "" {
return fmt.Errorf("url is required (or pass --fixture for offline demo)")
}
u, err := url.Parse(cfg.URL)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("invalid url %q (expected https://…)", cfg.URL)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("url scheme must be http or https, got %q", u.Scheme)
}
if cfg.Record != "" {
dir := filepath.Dir(cfg.Record)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("record path: %w", err)
}
}
}
if (cfg.SubscriptionID != "") != (cfg.Email != "") {
// Allow partial credentials only when a secret/token is already present.
if cfg.Secret == "" {
return fmt.Errorf("--subscription and --email must be provided together")
}
}
return nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return trimSlash(v)
@@ -116,6 +227,11 @@ func envBoolOr(key string, fallback bool) bool {
return fallback
}
// envPresentOrBool implements the NO_COLOR convention: any non-empty value means true.
func envPresentOrBool(key string) bool {
return strings.TrimSpace(os.Getenv(key)) != ""
}
func trimSlash(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
+73 -3
View File
@@ -1,8 +1,8 @@
package config_test
import (
"flag"
"os"
"path/filepath"
"testing"
"github.com/honeypeer/cli-viz/internal/config"
@@ -15,9 +15,9 @@ func TestDefaultPublicURL(t *testing.T) {
}
func TestLoadDefaultURL(t *testing.T) {
t.Setenv("HP_VIZ_URL", "")
os.Unsetenv("HP_VIZ_URL")
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
cfg, err := config.Load()
cfg, err := config.LoadArgs(nil)
if err != nil {
t.Fatal(err)
}
@@ -25,3 +25,73 @@ func TestLoadDefaultURL(t *testing.T) {
t.Fatalf("expected %q, got %q", config.DefaultPublicURL, cfg.URL)
}
}
func TestLoadVersionSkipsValidation(t *testing.T) {
cfg, err := config.LoadArgs([]string{"--version"})
if err != nil {
t.Fatal(err)
}
if !cfg.Version {
t.Fatal("expected Version=true")
}
}
func TestLoadInvalidURL(t *testing.T) {
_, err := config.LoadArgs([]string{"--url", "not-a-url"})
if err == nil {
t.Fatal("expected error for invalid url")
}
}
func TestLoadMissingFixture(t *testing.T) {
_, err := config.LoadArgs([]string{"--fixture", "/no/such/fixture.txt"})
if err == nil {
t.Fatal("expected error for missing fixture")
}
}
func TestLoadFixture(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sample.txt")
if err := os.WriteFile(path, []byte("event: stats\ndata: {}\n\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := config.LoadArgs([]string{"--fixture", path, "--no-update"})
if err != nil {
t.Fatal(err)
}
if cfg.Fixture != path {
t.Fatalf("fixture = %q", cfg.Fixture)
}
if cfg.URL != "" {
t.Fatalf("expected empty URL for fixture mode, got %q", cfg.URL)
}
}
func TestLoadBellFromEnv(t *testing.T) {
t.Setenv("HP_VIZ_BELL", "true")
cfg, err := config.LoadArgs(nil)
if err != nil {
t.Fatal(err)
}
if !cfg.Bell {
t.Fatal("expected Bell from HP_VIZ_BELL")
}
}
func TestLoadSubscriptionRequiresEmail(t *testing.T) {
_, err := config.LoadArgs([]string{"--subscription", "sub_abc"})
if err == nil {
t.Fatal("expected error when email missing")
}
}
func TestLoadBufferClamped(t *testing.T) {
cfg, err := config.LoadArgs([]string{"--buffer", "5"})
if err != nil {
t.Fatal(err)
}
if cfg.Buffer < 50 {
t.Fatalf("buffer should clamp to min, got %d", cfg.Buffer)
}
}
+6 -6
View File
@@ -14,13 +14,13 @@ type Point struct {
// PeerNode extends a viz peer with orbital layout fields.
type PeerNode struct {
viz.Peer
Angle float64
Orbit float64
Phase float64
Size float64
Angle float64
Orbit float64
Phase float64
Size float64
AttackCount int
Fade float64
FadeTarget float64
Fade float64
FadeTarget float64
}
func BuildPeerLayout(peers []viz.Peer) []PeerNode {
+12 -12
View File
@@ -27,19 +27,19 @@ type HitNode struct {
// State drives the animated peer mesh.
type State struct {
mu sync.Mutex
peers []PeerNode
pulses []Pulse
time float64
selectedID string
mu sync.Mutex
peers []PeerNode
pulses []Pulse
time float64
selectedID string
reducedMotion bool
coordinator Point
scale float64
hits []HitNode
pulseSeed int
renderCX float64
renderCY float64
renderScale float64
coordinator Point
scale float64
hits []HitNode
pulseSeed int
renderCX float64
renderCY float64
renderScale float64
}
func NewState() *State {
+2 -2
View File
@@ -12,8 +12,8 @@ const (
defaultVolume = 0.3
maxAttackSoundsPerSec = 4
// VolumeStep is the adjustment per -/+ key press.
VolumeStep = 0.05
volumeStep = VolumeStep
VolumeStep = 0.05
volumeStep = VolumeStep
)
// Engine plays HoneyPeer viz event sounds (ported from web useVizSound.ts).
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// Playback routes synthesized PCM through the desktop audio stack (PulseAudio /
// PipeWire / CoreAudio) instead of opening ALSA devices directly.
type player struct {
name string
name string
newCmd func(wav, raw []byte) *exec.Cmd
}
+94 -25
View File
@@ -2,12 +2,14 @@ package transport
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/honeypeer/cli-viz/internal/viz"
@@ -35,7 +37,10 @@ type SseClient struct {
state chan ConnState
errors chan error
done chan struct{}
stopOnce sync.Once
retryMs time.Duration
cancelHTTP context.CancelFunc
mu sync.Mutex
}
func NewSseClient(baseURL, secret, fixture, recordPath string) *SseClient {
@@ -45,23 +50,58 @@ func NewSseClient(baseURL, secret, fixture, recordPath string) *SseClient {
fixture: fixture,
recordPath: recordPath,
events: make(chan Event, 256),
state: make(chan ConnState, 4),
errors: make(chan error, 4),
state: make(chan ConnState, 8),
errors: make(chan error, 8),
done: make(chan struct{}),
retryMs: 2 * time.Second,
}
}
func (c *SseClient) Events() <-chan Event { return c.events }
func (c *SseClient) Events() <-chan Event { return c.events }
func (c *SseClient) State() <-chan ConnState { return c.state }
func (c *SseClient) Errors() <-chan error { return c.errors }
func (c *SseClient) Errors() <-chan error { return c.errors }
func (c *SseClient) Start() {
go c.loop()
}
// Stop is safe to call multiple times.
func (c *SseClient) Stop() {
close(c.done)
c.stopOnce.Do(func() {
close(c.done)
c.mu.Lock()
cancel := c.cancelHTTP
c.mu.Unlock()
if cancel != nil {
cancel()
}
})
}
func (c *SseClient) setHTTPCancel(cancel context.CancelFunc) {
c.mu.Lock()
c.cancelHTTP = cancel
c.mu.Unlock()
}
func (c *SseClient) emitState(st ConnState) {
select {
case c.state <- st:
case <-c.done:
default:
// Drop if full — latest reconnect/offline is advisory.
}
}
func (c *SseClient) emitError(err error) {
if err == nil {
return
}
select {
case c.errors <- err:
case <-c.done:
default:
}
}
func (c *SseClient) loop() {
@@ -77,11 +117,8 @@ func (c *SseClient) loop() {
} else {
err = c.streamLive()
}
if err != nil {
select {
case c.errors <- err:
default:
}
if err != nil && err != io.EOF && err != context.Canceled {
c.emitError(err)
}
select {
case <-c.done:
@@ -89,21 +126,50 @@ func (c *SseClient) loop() {
default:
}
if c.fixture != "" {
// Loop fixture for dev
time.Sleep(2 * time.Second)
// Loop fixture for offline demo / dev.
if !c.sleepInterruptible(2 * time.Second) {
return
}
continue
}
c.state <- ConnReconnecting
time.Sleep(c.retryMs)
c.emitState(ConnReconnecting)
if !c.sleepInterruptible(c.retryMs) {
return
}
c.retryMs = minDuration(c.retryMs*3/2, 60*time.Second)
}
}
// sleepInterruptible returns false if Stop was called during the wait.
func (c *SseClient) sleepInterruptible(d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-c.done:
return false
case <-t.C:
return true
}
}
func (c *SseClient) streamLive() error {
url := c.baseURL + "/api/public/viz/stream"
req, err := http.NewRequest(http.MethodGet, url, nil)
ctx, cancel := context.WithCancel(context.Background())
c.setHTTPCancel(cancel)
defer cancel()
// Cancel in-flight request when Stop is called.
go func() {
select {
case <-c.done:
cancel()
case <-ctx.Done():
}
}()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
c.state <- ConnOffline
c.emitState(ConnOffline)
return err
}
setSSEHeaders(req, c.secret)
@@ -111,25 +177,28 @@ func (c *SseClient) streamLive() error {
client := &http.Client{Timeout: 0}
resp, err := client.Do(req)
if err != nil {
c.state <- ConnOffline
c.emitState(ConnOffline)
if ctx.Err() != nil {
return context.Canceled
}
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusServiceUnavailable {
c.state <- ConnOffline
c.emitState(ConnOffline)
return fmt.Errorf("public visualization disabled on server (503)")
}
if resp.StatusCode == http.StatusTooManyRequests {
c.state <- ConnOffline
c.emitState(ConnOffline)
return fmt.Errorf("too many SSE connections (429)")
}
if resp.StatusCode != http.StatusOK {
c.state <- ConnOffline
c.emitState(ConnOffline)
return fmt.Errorf("SSE connect failed: HTTP %d", resp.StatusCode)
}
c.state <- ConnLive
c.emitState(ConnLive)
c.retryMs = 2 * time.Second
return c.parseStream(resp.Body)
}
@@ -137,11 +206,11 @@ func (c *SseClient) streamLive() error {
func (c *SseClient) readFixture(path string) error {
f, err := os.Open(path)
if err != nil {
c.state <- ConnOffline
c.emitState(ConnOffline)
return err
}
defer f.Close()
c.state <- ConnLive
c.emitState(ConnLive)
return c.parseStream(f)
}
@@ -202,10 +271,10 @@ func (c *SseClient) parseStream(r io.Reader) error {
return err
}
if err := scanner.Err(); err != nil {
c.state <- ConnReconnecting
c.emitState(ConnReconnecting)
return err
}
c.state <- ConnReconnecting
c.emitState(ConnReconnecting)
return io.EOF
}
+6
View File
@@ -115,3 +115,9 @@ func trim(s string) string {
}
return s
}
func TestSseClientStopIdempotent(t *testing.T) {
c := transport.NewSseClient("", "", "", "")
c.Stop()
c.Stop() // must not panic
}
+1 -1
View File
@@ -5,8 +5,8 @@ import (
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/honeypeer/cli-viz/internal/viz"
)
+76 -93
View File
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/honeypeer/cli-viz/internal/update"
"github.com/honeypeer/cli-viz/internal/viz"
)
@@ -350,127 +351,109 @@ func renderFooter(t Theme, filtering, searching, operatorSigning bool, filter st
if mouseEnabled {
hints = append(hints, "✕/outside close", "wheel scroll")
}
} else if mouseEnabled {
hints = []string{"click open", "wheel scroll", "right-click open", "Tab view", "j/k nav", "Enter detail", "o operator", "s search", "/ filter", "? help", "q quit"}
} else if filtering {
hints = []string{"type filter…", "Enter apply", "Esc clear"}
} else {
hints = []string{"Tab view", "j/k nav", "Enter detail", "o operator", "s search", "/ filter", "? help", "q quit"}
hints = []string{"1-6 views", "Tab/S-Tab", "j/k", "Enter", "o", "s", "/", "?", "q"}
if mouseEnabled {
hints = append([]string{"click", "wheel"}, hints...)
}
if tab == TabFeed {
hints = append(hints, "v mesh")
}
if tab == TabHistory {
hints = append(hints, "] sub", "[ range")
}
hints = append(hints, "e sound")
if soundEnabled {
hints = append(hints, "-/= vol")
}
hints = append(hints, "m mouse", "r reconnect")
}
if tab == TabFeed {
hints = append(hints, "v mesh")
}
if tab == TabHistory {
hints = append(hints, "] sub", "[ range", "scroll loads more")
}
hints = append(hints, "e sound")
if soundEnabled {
hints = append(hints, "-/= vol")
}
hints = append(hints, "m mouse")
if filtering {
hints = []string{"type filter…", "Enter apply", "Esc cancel"}
}
line := t.MutedStyle().Render(strings.Join(hints, " │ "))
if follow {
line += " " + lipgloss.NewStyle().Foreground(t.Teal).Render("[follow]")
line := t.MutedStyle().Render(strings.Join(hints, " │ "))
var badges []string
if follow && !filtering && !searching && !operatorSigning {
badges = append(badges, lipgloss.NewStyle().Foreground(t.Teal).Render("follow"))
}
if filter != "" {
line += " " + lipgloss.NewStyle().Foreground(t.Honey).Render("filter: "+filter)
badges = append(badges, lipgloss.NewStyle().Foreground(t.Honey).Render("filter:"+filter))
}
if mouseEnabled {
line += " " + lipgloss.NewStyle().Foreground(t.Teal).Render("[mouse]")
badges = append(badges, lipgloss.NewStyle().Foreground(t.Teal).Render("mouse"))
}
if tab == TabFeed && feedMeshVisible {
line += " " + lipgloss.NewStyle().Foreground(t.Teal).Render("[mesh]")
badges = append(badges, lipgloss.NewStyle().Foreground(t.Teal).Render("mesh"))
}
if soundEnabled {
line += " " + renderVolumeBar(t, soundVolume)
badges = append(badges, renderVolumeBar(t, soundVolume))
}
if historyNote != "" {
line += " " + lipgloss.NewStyle().Foreground(t.Muted).Render("[" + historyNote + "]")
badges = append(badges, t.MutedStyle().Render(historyNote))
}
if len(badges) > 0 {
line += " " + strings.Join(badges, " ")
}
return line
}
func renderHelp(t Theme) string {
title := lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render("◆ hp-viz help")
ver := t.MutedStyle().Render(update.VersionString())
body := `
hp-viz — HoneyPeer terminal attack feed
Navigation
j / k ↑ / ↓ Move selection
Tab / Shift+Tab Next / previous view
16 Jump to Feed · Incidents · Peers · Stats · History · Blog
Enter Open detail (or blog post)
f Toggle follow (auto-scroll to newest)
PgUp / PgDn Page list · load more history/blog
Navigation
j / k, ↑ / ↓ Move selection
Tab Cycle Feed → Incidents → Peers → Stats → History → Blog
Enter Open detail modal (or blog post on Blog tab)
f Toggle follow mode (auto-scroll to newest)
PgUp Load older attacks (Feed) / moderation (Incidents) / more posts (Blog)
Feed
v Toggle peer mesh under the feed
e Toggle event sounds
- / = Volume down / up (while sound is on)
Feed tab
v Toggle peer mesh animation below the feed
e Toggle event sounds (attack, block, peer, moderation)
Filter & search
/ Filter — text or service:ssh geo:cn peer:id tier:block since:1h
s Search by attack_… or mod_… ID
Esc Cancel filter / search / close overlays
Blog tab
Enter Read selected briefing
Esc Back to post list (while reading)
j / k Scroll post content (while reading)
PgUp Load more posts (list view)
Operator
o Sign in (subscription ID + billing email → real IPs)
Shift+O Sign out (while operator modal is open)
Peers tab
Animated hub-and-spoke mesh (attacks pulse: edge → peer → coordinator → fanout)
Click a mesh node to select a peer (when mouse is on)
HP_VIZ_REDUCED_MOTION=1 — static mesh (no animation)
History
] Attacks ↔ Reputation
[ Time range: all → 1h → 24h → 7d
Mouse (off by default — press m, or HP_VIZ_MOUSE=1)
m Toggle mouse support
Click row Open detail (Feed / Incidents / History)
Double-click Open peer roster detail (Peers tab)
Right-click row Open detail
Wheel Scroll list / detail / blog reader
Click ✕ Close Close detail modal
Click outside Close detail / search / operator / help
Mouse (off by default — m or HP_VIZ_MOUSE=1)
Click row Open detail
Wheel Scroll lists and modals
Click ✕ / outside Close detail / search / operator / help
Detail modal
Enter Open / refresh detail for selected row
Esc Close modal
✕ Close Click the close control (mouse enabled)
Click outside Close modal (mouse enabled)
Right-click Close modal (mouse enabled)
Wheel Scroll long detail content
General
m Toggle mouse
r Reconnect stream
? Toggle this help
q Ctrl+C Quit
Filter
/ Start filter input
Enter Apply filter
Esc Clear filter (cancel input)
Search
s Search by attack or moderation ID
Enter Look up and open detail
Esc Cancel search
Operator (real IPs + full detail)
o Sign in with subscription ID + billing email
Tab Move between fields (sign-in form)
Enter Validate subscription and unlock operator mode
Shift+O Sign out (while operator modal is open)
Esc Close operator modal
History (database — like web viz sidebar)
Tab to History view
] Switch Attacks ↔ Reputation sub-tabs
[ Cycle time range: all → 1h → 24h → 7d
j / k, scroll Infinite scroll — loads more near the bottom
/ filter service:ssh geo:cn peer:id tier:block (reputation)
Enter Open attack or moderation detail
General
e Toggle synthesized event sounds (matches web viz)
- / = (+) Volume down / up (while sound is on)
? Toggle this help
q, Ctrl+C Quit
Environment
HP_VIZ_URL Master base URL
HP_VIZ_MOUSE=1 Mouse on at startup
HP_VIZ_REDUCED_MOTION=1 Static mesh, no sounds
HP_VIZ_BELL=1 Terminal bell on block events
HP_VIZ_NO_UPDATE=1 Skip auto-update checks
`
return t.BorderStyle().Render(
lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render("Help") + "\n" +
t.MutedStyle().Render(body),
return t.ModalStyle().Width(72).Render(
title + " " + ver + "\n" + t.MutedStyle().Render(body),
)
}
func renderStatusErr(t Theme, msg string) string {
return lipgloss.NewStyle().Foreground(t.Attack).Bold(true).Render("Error: " + msg)
return lipgloss.NewStyle().
Foreground(t.Attack).
Bold(true).
Render("⚠ " + msg)
}
+34 -8
View File
@@ -49,14 +49,22 @@ func tabLabel(tab ViewTab) string {
func renderTabs(t Theme, active ViewTab) string {
var parts []string
for i := ViewTab(0); i <= TabBlog; i++ {
// Numbered shortcuts (16) for power users.
num := t.MutedStyle().Render(fmt.Sprintf("%d ", i+1))
label := tabLabel(i)
if i == active {
parts = append(parts, lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Underline(true).Render(label))
activeLbl := lipgloss.NewStyle().
Bold(true).
Foreground(t.Bg).
Background(t.Honey).
Padding(0, 1).
Render(label)
parts = append(parts, num+activeLbl)
} else {
parts = append(parts, t.MutedStyle().Render(label))
parts = append(parts, num+t.MutedStyle().Render(label))
}
}
return strings.Join(parts, " ")
return strings.Join(parts, t.MutedStyle().Render(" │ "))
}
func feedItemSummary(item viz.FeedItem) string {
@@ -154,7 +162,7 @@ func feedListHeader(t Theme, ipCol string) string {
func renderFeedList(t Theme, items []viz.FeedItem, cursor int, store *viz.Store, height, width int, operator bool) string {
if len(items) == 0 {
return t.MutedStyle().Render("Watching the mesh… attacks appear here in real time")
return renderEmptyState(t, "Watching the mesh", "Live attacks, blocks, and moderation events appear here.", "Press / to filter · s to search by ID · ? for help")
}
ipCol := "IP (masked)"
if operator {
@@ -240,7 +248,7 @@ func incidentListHeader(t Theme, operator bool) string {
func renderIncidentList(t Theme, incidents []viz.Incident, cursor, height, width int, operator bool) string {
if len(incidents) == 0 {
return t.MutedStyle().Render("No incidents yet")
return renderEmptyState(t, "No incidents yet", "Grouped escalations and block fanouts show up as they form.", "Switch to Feed to watch the raw stream")
}
var lines []string
lines = append(lines, incidentListHeader(t, operator))
@@ -294,9 +302,21 @@ func renderPeerEventRow(t Theme, p viz.FeedItem, width int) string {
return fixed + colGap + padCellLeft(viz.TruncatePeer(p.PeerID), peerW)
}
func renderEmptyState(t Theme, title, body, hint string) string {
var b strings.Builder
b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render("◇ "+title) + "\n\n")
if body != "" {
b.WriteString(t.MutedStyle().Render(body) + "\n")
}
if hint != "" {
b.WriteString("\n" + t.MutedStyle().Render(hint))
}
return b.String()
}
func renderStatsView(t Theme, stats *viz.Stats, network *viz.Network, width int) string {
if stats == nil {
return t.MutedStyle().Render("No stats yet")
return renderEmptyState(t, "No stats yet", "Aggregates arrive with the first snapshot or SSE stats event.", "Press r to reconnect if this stays empty")
}
if width < 40 {
width = 80
@@ -351,7 +371,10 @@ func renderStatsServiceBreakdown(t Theme, stats *viz.Stats, width int) string {
if stats == nil || len(stats.ServiceBreakdown) == 0 {
return t.MutedStyle().Render(" (none)")
}
type kv struct{ k string; v int }
type kv struct {
k string
v int
}
var pairs []kv
total := 0
for k, v := range stats.ServiceBreakdown {
@@ -393,7 +416,10 @@ func renderGeoBreakdown(t Theme, geo map[string]int, width int) string {
if len(geo) == 0 {
return t.MutedStyle().Render(" (none)")
}
type kv struct{ k string; v int }
type kv struct {
k string
v int
}
var pairs []kv
total := 0
for k, v := range geo {
+34 -22
View File
@@ -5,10 +5,11 @@ import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/honeypeer/cli-viz/internal/update"
"github.com/honeypeer/cli-viz/internal/viz"
)
func renderHeader(t Theme, conn ConnState, stats *viz.Stats, url string, operator bool) string {
func renderHeader(t Theme, conn ConnState, url string, operator bool) string {
pill := t.LivePill(false)
switch conn {
case ConnLive:
@@ -19,38 +20,49 @@ func renderHeader(t Theme, conn ConnState, stats *viz.Stats, url string, operato
pill = t.OfflinePill()
}
title := t.HeaderStyle().Render("◆ HoneyPeer Feed")
title := t.HeaderStyle().Render("◆ hp-viz")
brand := lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render(" HoneyPeer")
if operator {
title += " " + lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render("[OPERATOR]")
brand += " " + lipgloss.NewStyle().Bold(true).Foreground(t.Honey).
Background(lipgloss.Color("#2a2010")).
Padding(0, 1).
Render("OPERATOR")
}
right := pill
if stats != nil {
right += " " + t.MutedStyle().Render("│")
right += " " + lipgloss.NewStyle().Foreground(t.Attack).Bold(true).Render(viz.FormatCount(stats.AttacksLastHour)) + t.MutedStyle().Render("/hr")
right += " " + t.MutedStyle().Render("│")
right += " " + lipgloss.NewStyle().Bold(true).Render(viz.FormatCount(stats.TotalAttacks)) + t.MutedStyle().Render(" total")
right += " " + t.MutedStyle().Render("│")
right += " " + lipgloss.NewStyle().Foreground(t.Teal).Render(fmt.Sprintf("%d peers", stats.ConnectedPeers))
right += " " + t.MutedStyle().Render("│")
right += " " + lipgloss.NewStyle().Render(viz.FormatCount(stats.BlockedIPs)) + t.MutedStyle().Render(" blocked")
}
line1 := lipgloss.JoinHorizontal(lipgloss.Top, title, lipgloss.NewStyle().Width(1).Render(""), right)
host := t.MutedStyle().Render(viz.Truncate(url, 60))
line1 := lipgloss.JoinHorizontal(lipgloss.Top, title, brand, lipgloss.NewStyle().Width(2).Render(""), right)
host := t.MutedStyle().Render(viz.Truncate(url, 72))
return line1 + "\n" + host
}
func renderKPIStrip(t Theme, stats *viz.Stats) string {
if stats == nil {
return t.MutedStyle().Render("Waiting for stats")
return t.MutedStyle().Render("◈ connecting… waiting for network stats")
}
sep := t.MutedStyle().Render(" · ")
parts := []string{
lipgloss.NewStyle().Foreground(t.Attack).Bold(true).Render("◆ "+viz.FormatCount(stats.AttacksLastHour)) + t.MutedStyle().Render(" /hr"),
lipgloss.NewStyle().Bold(true).Render("◆ "+viz.FormatCount(stats.TotalAttacks)) + t.MutedStyle().Render(" total"),
lipgloss.NewStyle().Foreground(t.Teal).Render(fmt.Sprintf("◆ %d peers", stats.ConnectedPeers)),
lipgloss.NewStyle().Render("◆ "+viz.FormatCount(stats.BlockedIPs)) + t.MutedStyle().Render(" blocked"),
lipgloss.NewStyle().Foreground(t.Attack).Bold(true).Render(viz.FormatCount(stats.AttacksLastHour)) + t.MutedStyle().Render("/hr"),
lipgloss.NewStyle().Bold(true).Render(viz.FormatCount(stats.TotalAttacks)) + t.MutedStyle().Render(" total"),
lipgloss.NewStyle().Foreground(t.Teal).Bold(true).Render(fmt.Sprintf("%d", stats.ConnectedPeers)) + t.MutedStyle().Render(" peers"),
lipgloss.NewStyle().Bold(true).Render(viz.FormatCount(stats.BlockedIPs)) + t.MutedStyle().Render(" blocked"),
}
return strings.Join(parts, " ")
return "◈ " + strings.Join(parts, sep)
}
// renderSplash is shown before the first WindowSizeMsg arrives.
func renderSplash(t Theme, width, height int) string {
if width < 1 {
width = 80
}
if height < 1 {
height = 24
}
title := lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render("◆ hp-viz")
sub := t.MutedStyle().Render("HoneyPeer terminal attack visualizer")
ver := t.MutedStyle().Render(update.VersionString())
hint := t.MutedStyle().Render("connecting… press ? for help")
body := title + "\n" + sub + "\n\n" + ver + "\n\n" + hint
box := t.BorderStyle().Padding(1, 3).Render(body)
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
}
func renderBreakdown(t Theme, stats *viz.Stats, width int) string {
+2 -6
View File
@@ -241,7 +241,7 @@ func attacksToFeedItems(attacks []viz.PublicAttack) []viz.FeedItem {
Port: a.Port, Summary: a.Summary,
Interaction: a.Interaction, BlobCount: a.BlobCount,
CoConspiratorCount: a.CoConspiratorCount,
ScoreAfter: a.ScoreAfter, ScoreDelta: a.ScoreDelta, TierAfter: a.TierAfter,
ScoreAfter: a.ScoreAfter, ScoreDelta: a.ScoreDelta, TierAfter: a.TierAfter,
BlockScore: a.BlockScore, GreylistScore: a.GreylistScore,
})
}
@@ -255,11 +255,7 @@ func (m Model) visibleHistoryItems() []viz.FeedItem {
} else {
items = m.histAtkItems
}
f := viz.ParseFilterQuery(m.filterQuery)
if f.Text == "" {
return items
}
return viz.FilterFeed(items, f.Text)
return viz.FilterFeedWith(items, viz.ParseFilterQuery(m.filterQuery))
}
func (m Model) renderHistoryView(height, width int) string {
+7 -12
View File
@@ -29,7 +29,7 @@ func (m Model) chromeTop() string {
url = "fixture:" + m.cfg.Fixture
}
var b strings.Builder
b.WriteString(renderHeader(m.theme, m.conn, m.stats, url, m.cfg.Operator))
b.WriteString(renderHeader(m.theme, m.conn, url, m.cfg.Operator))
b.WriteString("\n")
b.WriteString(renderKPIStrip(m.theme, m.stats))
b.WriteString("\n\n")
@@ -149,21 +149,16 @@ type tabZone struct {
}
func tabHitZones() []tabZone {
sep := " │ "
// Must match renderTabs cell widths: "N " + label (active has padding).
sep := " │ "
var zones []tabZone
x := 0
for i := ViewTab(0); i <= TabBlog; i++ {
label := tabLabel(i)
var rendered string
if i == TabFeed {
rendered = lipgloss.NewStyle().Bold(true).Underline(true).Render(label)
} else {
rendered = label
}
w := lipgloss.Width(rendered)
if w < len(label) {
w = len(label)
}
// "N " prefix (2 cells) + active pad or plain label.
// Active label is padded (0,1) → +2 cells; inactive is plain.
// Use the wider (active) size so both hit targets are generous.
w := 2 + lipgloss.Width(label) + 2
zones = append(zones, tabZone{Tab: i, StartX: x, EndX: x + w})
x += w
if i < TabBlog {
+1 -1
View File
@@ -27,7 +27,7 @@ func TestLayoutFillsTerminalHeight(t *testing.T) {
}
lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n")
footer := lines[len(lines)-1]
if !strings.Contains(footer, "quit") {
if !strings.Contains(footer, "q") || !strings.Contains(footer, "views") {
t.Fatalf("expected footer on last line, got %q", footer)
}
}
+145 -109
View File
@@ -53,14 +53,15 @@ type blogPostLoadedMsg struct {
const blogPageSize = 20
type keyMap struct {
Up, Down, Quit, Help, Tab, Follow, Filter, Search, Operator, Enter, Reconnect, Close, Mouse key.Binding
PgUp, PgDn key.Binding
MeshView key.Binding
HistSub key.Binding
HistRange key.Binding
Sound key.Binding
VolDown key.Binding
VolUp key.Binding
Up, Down, Quit, Help, Tab, PrevTab, Follow, Filter, Search, Operator, Enter, Reconnect, Close, Mouse key.Binding
PgUp, PgDn key.Binding
MeshView key.Binding
HistSub key.Binding
HistRange key.Binding
Sound key.Binding
VolDown key.Binding
VolUp key.Binding
Tab1, Tab2, Tab3, Tab4, Tab5, Tab6 key.Binding
}
func defaultKeyMap() keyMap {
@@ -70,6 +71,7 @@ func defaultKeyMap() keyMap {
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next view")),
PrevTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("S-Tab", "prev view")),
Follow: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "follow")),
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
Search: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "search")),
@@ -78,82 +80,88 @@ func defaultKeyMap() keyMap {
Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")),
Mouse: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "mouse")),
Reconnect: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reconnect")),
PgUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "history")),
PgDn: key.NewBinding(key.WithKeys("pgdn"), key.WithHelp("pgdn", "down")),
PgUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")),
PgDn: key.NewBinding(key.WithKeys("pgdn"), key.WithHelp("pgdn", "page down")),
HistSub: key.NewBinding(key.WithKeys("]"), key.WithHelp("]", "hist sub")),
HistRange: key.NewBinding(key.WithKeys("["), key.WithHelp("[", "hist range")),
MeshView: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "mesh")),
Sound: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "sound")),
VolDown: key.NewBinding(key.WithKeys("-", "_"), key.WithHelp("-", "quieter")),
VolUp: key.NewBinding(key.WithKeys("=", "+"), key.WithHelp("=/+", "louder")),
Tab1: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "Feed")),
Tab2: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "Incidents")),
Tab3: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "Peers")),
Tab4: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "Stats")),
Tab5: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "History")),
Tab6: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "Blog")),
}
}
type Model struct {
cfg config.Config
theme Theme
keys keyMap
sse *transport.SseClient
api *transport.APIClient
cfg config.Config
theme Theme
keys keyMap
sse *transport.SseClient
api *transport.APIClient
width, height int
conn ConnState
tab ViewTab
cursor int
follow bool
showHelp bool
mouseEnabled bool
filtering bool
filterQuery string
filterInput textinput.Model
searching bool
searchInput textinput.Model
operatorSigning bool
operatorField int
operatorSubInput textinput.Model
operatorEmailInput textinput.Model
width, height int
conn ConnState
tab ViewTab
cursor int
follow bool
showHelp bool
mouseEnabled bool
filtering bool
filterQuery string
filterInput textinput.Model
searching bool
searchInput textinput.Model
operatorSigning bool
operatorField int
operatorSubInput textinput.Model
operatorEmailInput textinput.Model
store *viz.Store
stats *viz.Stats
network *viz.Network
detailItem *viz.FeedItem
detailIncident *viz.Incident
detailAttack *viz.PublicAttack
detailModeration *viz.ModerationEvent
detailModAttacks []viz.PublicAttack
store *viz.Store
stats *viz.Stats
network *viz.Network
detailItem *viz.FeedItem
detailIncident *viz.Incident
detailAttack *viz.PublicAttack
detailModeration *viz.ModerationEvent
detailModAttacks []viz.PublicAttack
detailAttacksLoading bool
detailAttacksErr string
detailViewport viewport.Model
detailLoading bool
detailErr string
detailOpen bool
statusErr string
historySubTab HistorySubTab
historyRange viz.HistoryRange
histAtkItems []viz.FeedItem
histAtkOffset int
histAtkTotal int
histAtkExhausted bool
histModItems []viz.FeedItem
histModOffset int
histModTotal int
histModExhausted bool
histLoading bool
blogPosts []viz.BlogPostSummary
blogOffset int
blogTotal int
blogExhausted bool
blogLoading bool
blogErr string
blogReading bool
blogPost *viz.BlogPost
blogViewport viewport.Model
rateLimitUntil time.Time
pendingSSE []transport.Event
coalesceScheduled bool
cachedModalBase string
cachedModalKey string
ready bool
detailViewport viewport.Model
detailLoading bool
detailErr string
detailOpen bool
statusErr string
historySubTab HistorySubTab
historyRange viz.HistoryRange
histAtkItems []viz.FeedItem
histAtkOffset int
histAtkTotal int
histAtkExhausted bool
histModItems []viz.FeedItem
histModOffset int
histModTotal int
histModExhausted bool
histLoading bool
blogPosts []viz.BlogPostSummary
blogOffset int
blogTotal int
blogExhausted bool
blogLoading bool
blogErr string
blogReading bool
blogPost *viz.BlogPost
blogViewport viewport.Model
rateLimitUntil time.Time
pendingSSE []transport.Event
coalesceScheduled bool
cachedModalBase string
cachedModalKey string
ready bool
lastClickAt time.Time
lastClickX int
@@ -166,10 +174,10 @@ type Model struct {
mousePressX int
mousePressY int
peerMesh *mesh.State
meshTickActive bool
peerMesh *mesh.State
meshTickActive bool
feedMeshVisible bool
sound *sound.Engine
sound *sound.Engine
}
func NewModel(cfg config.Config) Model {
@@ -194,17 +202,17 @@ func NewModel(cfg config.Config) Model {
opEmail.Width = 48
m := Model{
cfg: cfg,
theme: NewTheme(cfg.NoColor),
keys: defaultKeyMap(),
store: viz.NewStore(cfg.Buffer),
follow: true,
filterInput: ti,
searchInput: si,
cfg: cfg,
theme: NewTheme(cfg.NoColor),
keys: defaultKeyMap(),
store: viz.NewStore(cfg.Buffer),
follow: true,
filterInput: ti,
searchInput: si,
operatorSubInput: opSub,
operatorEmailInput: opEmail,
conn: ConnReconnecting,
mouseEnabled: envTruthy("HP_VIZ_MOUSE"),
conn: ConnReconnecting,
mouseEnabled: envTruthy("HP_VIZ_MOUSE"),
}
if cfg.URL != "" {
m.api = transport.NewAPIClient(cfg.URL, cfg.Secret)
@@ -346,17 +354,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Help):
m.showHelp = !m.showHelp
case key.Matches(msg, m.keys.Tab):
m.tab = (m.tab + 1) % (TabBlog + 1)
m.cursor = 0
m.closeDetail()
m.closeBlogReader()
if m.tab == TabBlog {
cmds = append(cmds, m.requestBlogList())
cmds = append(cmds, m.switchTab((m.tab+1)%(TabBlog+1))...)
case key.Matches(msg, m.keys.PrevTab):
prev := m.tab - 1
if prev < 0 {
prev = TabBlog
}
if m.tab == TabHistory {
cmds = append(cmds, m.ensureHistoryTabLoaded())
}
cmds = append(cmds, m.onTabChanged())
cmds = append(cmds, m.switchTab(prev)...)
case key.Matches(msg, m.keys.Tab1):
cmds = append(cmds, m.switchTab(TabFeed)...)
case key.Matches(msg, m.keys.Tab2):
cmds = append(cmds, m.switchTab(TabIncidents)...)
case key.Matches(msg, m.keys.Tab3):
cmds = append(cmds, m.switchTab(TabPeers)...)
case key.Matches(msg, m.keys.Tab4):
cmds = append(cmds, m.switchTab(TabStats)...)
case key.Matches(msg, m.keys.Tab5):
cmds = append(cmds, m.switchTab(TabHistory)...)
case key.Matches(msg, m.keys.Tab6):
cmds = append(cmds, m.switchTab(TabBlog)...)
case key.Matches(msg, m.keys.Follow):
m.follow = !m.follow
if m.follow {
@@ -405,10 +421,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, m.openDetail())
}
case key.Matches(msg, m.keys.Reconnect):
m.sse.Stop()
if m.sse != nil {
m.sse.Stop()
}
m.sse = transport.NewSseClient(m.cfg.URL, m.cfg.Secret, m.cfg.Fixture, m.cfg.Record)
m.sse.Start()
m.conn = ConnReconnecting
m.statusErr = ""
cmds = append(cmds, waitSSE(m.sse), waitConnState(m.sse), waitSSEErr(m.sse))
case key.Matches(msg, m.keys.Up):
if m.cursor > 0 {
@@ -636,8 +655,7 @@ func (m *Model) updateFilter(msg tea.KeyMsg) (Model, tea.Cmd) {
cmd := m.refreshHistoryAfterFilter()
return *m, cmd
case tea.KeyCtrlC:
m.sse.Stop()
return *m, tea.Quit
return *m, m.quit()
}
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
@@ -812,9 +830,34 @@ func (m *Model) adjustSoundVolume(delta float64) {
}
}
func (m *Model) switchTab(tab ViewTab) []tea.Cmd {
if tab == m.tab {
return nil
}
m.tab = tab
m.cursor = 0
m.closeDetail()
m.closeBlogReader()
var cmds []tea.Cmd
if m.tab == TabBlog {
cmds = append(cmds, m.requestBlogList())
}
if m.tab == TabHistory {
cmds = append(cmds, m.ensureHistoryTabLoaded())
}
if cmd := m.onTabChanged(); cmd != nil {
cmds = append(cmds, cmd)
}
return cmds
}
func (m *Model) quit() tea.Cmd {
m.sse.Stop()
m.sound.Close()
if m.sse != nil {
m.sse.Stop()
}
if m.sound != nil {
m.sound.Close()
}
return tea.Quit
}
@@ -857,23 +900,16 @@ func (m Model) selectedFeedItem() *viz.FeedItem {
func (m Model) visibleFeed() []viz.FeedItem {
raw := viz.AttackFeedItems(m.store.Items())
f := viz.ParseFilterQuery(m.filterQuery)
q := f.Text
if q == "" && f.Service == "" && f.Geo == "" && f.PeerID == "" && f.Since == "" {
q = m.filterQuery
}
return viz.FilterFeed(raw, q)
return viz.FilterFeedWith(raw, viz.ParseFilterQuery(m.filterQuery))
}
func (m Model) visibleIncidents() []viz.Incident {
return viz.FilterIncidents(viz.BuildIncidents(m.store.Items()), m.filterQuery)
return viz.FilterIncidentsWith(viz.BuildIncidents(m.store.Items()), viz.ParseFilterQuery(m.filterQuery))
}
func (m Model) visiblePeers() []viz.FeedItem {
if m.filterQuery == "" {
return viz.PeerItems(m.store.Items())
}
return viz.FilterFeed(viz.PeerItems(m.store.Items()), m.filterQuery)
peers := viz.PeerItems(m.store.Items())
return viz.FilterFeedWith(peers, viz.ParseFilterQuery(m.filterQuery))
}
func (m Model) listLen() int {
@@ -898,7 +934,7 @@ func (m Model) listLen() int {
func (m Model) View() string {
if !m.ready {
return "Initializing…"
return renderSplash(m.theme, m.width, m.height)
}
if m.showHelp {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, renderHelp(m.theme))
+1 -2
View File
@@ -57,8 +57,7 @@ func (m *Model) updateOperatorSignIn(msg tea.KeyMsg) (Model, tea.Cmd) {
m.cancelOperatorSignIn()
return *m, nil
case tea.KeyCtrlC:
m.sse.Stop()
return *m, tea.Quit
return *m, m.quit()
}
if m.cfg.Operator {
+1 -2
View File
@@ -38,8 +38,7 @@ func (m *Model) updateSearch(msg tea.KeyMsg) (Model, tea.Cmd) {
case tea.KeyEnter:
return *m, m.submitSearch()
case tea.KeyCtrlC:
m.sse.Stop()
return *m, tea.Quit
return *m, m.quit()
}
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
+7 -7
View File
@@ -17,13 +17,13 @@ type Theme struct {
func NewTheme(noColor bool) Theme {
t := Theme{
Honey: lipgloss.Color("#eab84d"),
Teal: lipgloss.Color("#2fe8da"),
Bg: lipgloss.Color("#0d0d0d"),
BgElev: lipgloss.Color("#141414"),
Muted: lipgloss.Color("#a8a29e"),
Attack: lipgloss.Color("#f87171"),
Border: lipgloss.Color("#3d3520"),
Honey: lipgloss.Color("#eab84d"),
Teal: lipgloss.Color("#2fe8da"),
Bg: lipgloss.Color("#0d0d0d"),
BgElev: lipgloss.Color("#141414"),
Muted: lipgloss.Color("#a8a29e"),
Attack: lipgloss.Color("#f87171"),
Border: lipgloss.Color("#3d3520"),
NoColor: noColor,
}
return t
+8 -1
View File
@@ -27,6 +27,9 @@ const (
// BuildCommit is set at link time for release binaries (-ldflags -X ...BuildCommit=...).
var BuildCommit string
// BuildTime is an optional RFC3339 timestamp set at link time.
var BuildTime string
type Options struct {
ReleaseBase string
APIURL string
@@ -71,7 +74,11 @@ func VersionString() string {
if commit == "" {
return "hp-viz dev (source build)"
}
return "hp-viz " + shortCommit(commit)
s := "hp-viz " + shortCommit(commit)
if t := strings.TrimSpace(BuildTime); t != "" {
s += " (" + t + ")"
}
return s
}
func CurrentCommit() string {
+3 -3
View File
@@ -48,10 +48,10 @@ func FormatBlogDate(ts int64) string {
}
var (
mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
mdBoldRe = regexp.MustCompile(`\*\*([^*]+)\*\*`)
mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
mdBoldRe = regexp.MustCompile(`\*\*([^*]+)\*\*`)
mdItalicRe = regexp.MustCompile(`\*([^*]+)\*`)
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
)
// BlogBody returns markdown/plain text for display (prefers content over HTML).
+8 -3
View File
@@ -270,14 +270,19 @@ func AttackFeedItems(feed []FeedItem) []FeedItem {
return out
}
// FilterFeed filters items by free-text query (substring match across fields).
func FilterFeed(items []FeedItem, query string) []FeedItem {
q := stringsToLower(query)
if q == "" {
return FilterFeedWith(items, ParseFilterQuery(query))
}
// FilterFeedWith filters items using structured AttackFilters (service:, geo:, etc.).
func FilterFeedWith(items []FeedItem, f AttackFilters) []FeedItem {
if f.Empty() {
return items
}
out := make([]FeedItem, 0, len(items))
for _, item := range items {
if feedMatches(item, q) {
if f.MatchFeed(item) {
out = append(out, item)
}
}
+121
View File
@@ -46,6 +46,127 @@ type AttackFilters struct {
Text string
}
// Empty reports whether the filter has no constraints.
func (f AttackFilters) Empty() bool {
return f.Service == "" && f.Geo == "" && f.PeerID == "" && f.Since == "" && f.Tier == "" && f.Text == ""
}
// MatchFeed applies structured + free-text filter rules to a feed item.
func (f AttackFilters) MatchFeed(item FeedItem) bool {
if f.Empty() {
return true
}
if f.Service != "" {
svc := f.Service
if !stringsContainsFold(item.Service, svc) && !stringsContainsFold(item.Label, svc) {
return false
}
}
if f.Geo != "" && !stringsContainsFold(item.Geo, f.Geo) {
return false
}
if f.PeerID != "" {
if !stringsContainsFold(item.PeerID, f.PeerID) && !stringsContainsFold(item.PeerPublicKeyHex, f.PeerID) {
return false
}
}
if f.Tier != "" {
if !stringsContainsFold(item.TierAfter, f.Tier) && !stringsContainsFold(item.TierBefore, f.Tier) {
return false
}
}
if f.Since != "" {
if sinceMS := ParseSinceToken(f.Since); sinceMS != "" {
if cutoff, err := parseInt64(sinceMS); err == nil && item.Timestamp > 0 && item.Timestamp < cutoff {
return false
}
}
}
if f.Text != "" && !feedMatches(item, stringsToLower(f.Text)) {
return false
}
return true
}
// MatchIncident applies structured + free-text filter rules to an incident.
func (f AttackFilters) MatchIncident(inc Incident) bool {
if f.Empty() {
return true
}
if f.Geo != "" && !stringsContainsFold(inc.Geo, f.Geo) {
return false
}
if f.Tier != "" {
kind := string(IncidentActionKindOf(inc))
if !stringsContainsFold(kind, f.Tier) && !stringsContainsFold(IncidentHeadline(inc), f.Tier) {
return false
}
}
if f.Service != "" {
ok := false
for _, a := range inc.Attacks {
if stringsContainsFold(a.Service, f.Service) {
ok = true
break
}
}
if !ok && inc.Standalone != nil {
ok = stringsContainsFold(inc.Standalone.Service, f.Service) || stringsContainsFold(inc.Standalone.Label, f.Service)
}
if !ok {
return false
}
}
if f.PeerID != "" {
ok := false
for _, a := range inc.Attacks {
if stringsContainsFold(a.PeerID, f.PeerID) {
ok = true
break
}
}
if !ok && inc.Standalone != nil {
ok = stringsContainsFold(inc.Standalone.PeerID, f.PeerID) || stringsContainsFold(inc.Standalone.PeerPublicKeyHex, f.PeerID)
}
if !ok {
return false
}
}
if f.Since != "" {
if sinceMS := ParseSinceToken(f.Since); sinceMS != "" {
ts := IncidentCardTimestamp(inc)
if cutoff, err := parseInt64(sinceMS); err == nil && ts > 0 && ts < cutoff {
return false
}
}
}
if f.Text != "" {
q := stringsToLower(f.Text)
if !incidentMatches(inc, q) {
return false
}
}
return true
}
func parseInt64(s string) (int64, error) {
var n int64
for i := 0; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
return 0, errInvalidInt
}
n = n*10 + int64(c-'0')
}
return n, nil
}
type filterError string
func (e filterError) Error() string { return string(e) }
const errInvalidInt = filterError("invalid int")
func stringsTrimSpace(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
+24
View File
@@ -41,3 +41,27 @@ func TestParseFilterQuery(t *testing.T) {
t.Fatalf("unexpected filter: %+v", f)
}
}
func TestFilterFeedWithStructured(t *testing.T) {
items := []FeedItem{
{ID: "1", Service: "SSH", Geo: "CN", TierAfter: "block", Label: "SSH", Summary: "brute force"},
{ID: "2", Service: "HTTP", Geo: "US", TierAfter: "watch", Label: "HTTP", Summary: "scan"},
{ID: "3", Service: "SSH", Geo: "US", TierAfter: "greylist", Label: "SSH", Summary: "auth fail"},
}
got := FilterFeedWith(items, ParseFilterQuery("service:ssh geo:cn"))
if len(got) != 1 || got[0].ID != "1" {
t.Fatalf("service+geo filter: got %+v", got)
}
got = FilterFeedWith(items, ParseFilterQuery("tier:block"))
if len(got) != 1 || got[0].ID != "1" {
t.Fatalf("tier filter: got %+v", got)
}
got = FilterFeedWith(items, ParseFilterQuery("brute"))
if len(got) != 1 || got[0].ID != "1" {
t.Fatalf("text filter: got %+v", got)
}
got = FilterFeed(items, "service:http")
if len(got) != 1 || got[0].ID != "2" {
t.Fatalf("FilterFeed structured: got %+v", got)
}
}
+3 -3
View File
@@ -8,9 +8,9 @@ import (
)
const (
detectionScoreMax = 200
defaultBlockScore = 45
defaultGreyScore = 20
detectionScoreMax = 200
defaultBlockScore = 45
defaultGreyScore = 20
)
func ClampScore(score float64) int {
+13 -8
View File
@@ -238,14 +238,19 @@ func BuildIncidents(feed []FeedItem) []Incident {
return incidents
}
// FilterIncidents filters incidents by free-text or structured query tokens.
func FilterIncidents(incidents []Incident, query string) []Incident {
q := stringsToLower(query)
if q == "" {
return FilterIncidentsWith(incidents, ParseFilterQuery(query))
}
// FilterIncidentsWith filters incidents using structured AttackFilters.
func FilterIncidentsWith(incidents []Incident, f AttackFilters) []Incident {
if f.Empty() {
return incidents
}
out := make([]Incident, 0, len(incidents))
for _, inc := range incidents {
if incidentMatches(inc, q) {
if f.MatchIncident(inc) {
out = append(out, inc)
}
}
@@ -300,11 +305,11 @@ func createEscalationIncident(ip, geo string, startedAt int64, pendingID string)
func createStandaloneIncident(item FeedItem) Incident {
cp := item
return Incident{
ID: "standalone_" + item.ID,
Kind: "standalone",
Resolved: true,
StartedAt: item.Timestamp,
UpdatedAt: item.Timestamp,
ID: "standalone_" + item.ID,
Kind: "standalone",
Resolved: true,
StartedAt: item.Timestamp,
UpdatedAt: item.Timestamp,
Standalone: &cp,
}
}
+7 -7
View File
@@ -9,13 +9,13 @@ import (
func TestFormatInteractionHTTP(t *testing.T) {
ix := map[string]interface{}{
"type": "http",
"method": "POST",
"url": "/wp-login.php",
"path": "/wp-login.php",
"remoteAddress": "203.0.113.1",
"userAgent": "curl/8.0",
"trapTitle": "WordPress admin",
"type": "http",
"method": "POST",
"url": "/wp-login.php",
"path": "/wp-login.php",
"remoteAddress": "203.0.113.1",
"userAgent": "curl/8.0",
"trapTitle": "WordPress admin",
"authAttempts": []interface{}{
map[string]interface{}{"username": "admin", "password": "[redacted]"},
},
+36 -36
View File
@@ -1,26 +1,26 @@
package viz
type Stats struct {
TotalAttacks int `json:"totalAttacks"`
ConnectedPeers int `json:"connectedPeers"`
BlockedIPs int `json:"blockedIPs"`
BlockedSubnets int `json:"blockedSubnets,omitempty"`
ServiceBreakdown map[string]int `json:"serviceBreakdown"`
GeoBreakdown map[string]int `json:"geoBreakdown"`
GeoBreakdownLastHour map[string]int `json:"geoBreakdownLastHour,omitempty"`
AttacksLastHour int `json:"attacksLastHour"`
RecentBlocksLastHour int `json:"recentBlocksLastHour,omitempty"`
PeersByGeo map[string]int `json:"peersByGeo,omitempty"`
TotalAttacks int `json:"totalAttacks"`
ConnectedPeers int `json:"connectedPeers"`
BlockedIPs int `json:"blockedIPs"`
BlockedSubnets int `json:"blockedSubnets,omitempty"`
ServiceBreakdown map[string]int `json:"serviceBreakdown"`
GeoBreakdown map[string]int `json:"geoBreakdown"`
GeoBreakdownLastHour map[string]int `json:"geoBreakdownLastHour,omitempty"`
AttacksLastHour int `json:"attacksLastHour"`
RecentBlocksLastHour int `json:"recentBlocksLastHour,omitempty"`
PeersByGeo map[string]int `json:"peersByGeo,omitempty"`
}
type Peer struct {
ID string `json:"id"`
PeerPublicKeyHex string `json:"peerPublicKeyHex,omitempty"`
Status string `json:"status"`
ConnectedAt int64 `json:"connectedAt"`
AttacksLastHour int `json:"attacksLastHour,omitempty"`
TopServices map[string]int `json:"topServices,omitempty"`
Geo string `json:"geo,omitempty"`
ID string `json:"id"`
PeerPublicKeyHex string `json:"peerPublicKeyHex,omitempty"`
Status string `json:"status"`
ConnectedAt int64 `json:"connectedAt"`
AttacksLastHour int `json:"attacksLastHour,omitempty"`
TopServices map[string]int `json:"topServices,omitempty"`
Geo string `json:"geo,omitempty"`
}
type Network struct {
@@ -69,27 +69,27 @@ type BlockEvent struct {
}
type ModerationEvent struct {
ID string `json:"id,omitempty"`
IP string `json:"ip,omitempty"`
IPMasked string `json:"ipMasked"`
Timestamp int64 `json:"timestamp"`
ScoreBefore float64 `json:"scoreBefore"`
ScoreAfter float64 `json:"scoreAfter"`
ScoreDelta float64 `json:"scoreDelta"`
DetectionScore float64 `json:"detectionScore,omitempty"`
BlockScore float64 `json:"blockScore,omitempty"`
GreylistScore float64 `json:"greylistScore,omitempty"`
TierBefore string `json:"tierBefore"`
TierAfter string `json:"tierAfter"`
ID string `json:"id,omitempty"`
IP string `json:"ip,omitempty"`
IPMasked string `json:"ipMasked"`
Timestamp int64 `json:"timestamp"`
ScoreBefore float64 `json:"scoreBefore"`
ScoreAfter float64 `json:"scoreAfter"`
ScoreDelta float64 `json:"scoreDelta"`
DetectionScore float64 `json:"detectionScore,omitempty"`
BlockScore float64 `json:"blockScore,omitempty"`
GreylistScore float64 `json:"greylistScore,omitempty"`
TierBefore string `json:"tierBefore"`
TierAfter string `json:"tierAfter"`
Action string `json:"action"`
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
Geo string `json:"geo,omitempty"`
AttackID string `json:"attackId,omitempty"`
AttackIds []string `json:"attackIds,omitempty"`
CoConspiratorCount int `json:"coConspiratorCount,omitempty"`
SubnetBlocked bool `json:"subnetBlocked,omitempty"`
SubnetCidrMasked string `json:"subnetCidrMasked,omitempty"`
CoConspiratorCount int `json:"coConspiratorCount,omitempty"`
SubnetBlocked bool `json:"subnetBlocked,omitempty"`
SubnetCidrMasked string `json:"subnetCidrMasked,omitempty"`
}
type RecentEvent struct {
@@ -147,10 +147,10 @@ type AttackHistoryResponse struct {
type FeedType string
const (
FeedAttack FeedType = "attack"
FeedPeer FeedType = "peer"
FeedBlock FeedType = "block"
FeedModeration FeedType = "moderation"
FeedAttack FeedType = "attack"
FeedPeer FeedType = "peer"
FeedBlock FeedType = "block"
FeedModeration FeedType = "moderation"
)
type FeedItem struct {
+10 -4
View File
@@ -1,17 +1,23 @@
class HpViz < Formula
desc "Terminal attack feed visualizer for HoneyPeer"
homepage "https://github.com/honeypeer/cli-viz"
url "https://github.com/honeypeer/cli-viz/archive/refs/tags/vVERSION.tar.gz"
homepage "https://honeypeer.com"
url "https://git.ssh.surf/snxraven/honeypeer-viz-cli/archive/refs/tags/vVERSION.tar.gz"
sha256 "SHA256_PLACEHOLDER"
license "MIT"
depends_on "go" => :build
def install
system "go", "build", "-o", bin/"hp-viz", "./cmd/hp-viz"
ldflags = %W[
-s -w
-X github.com/honeypeer/cli-viz/internal/update.BuildCommit=#{Utils.git_head}
-X github.com/honeypeer/cli-viz/internal/update.BuildTime=#{time.iso8601}
]
system "go", "build", *std_go_args(ldflags: ldflags), "./cmd/hp-viz"
end
test do
assert_match "hp-viz", shell_output("#{bin}/hp-viz --help 2>&1", 2)
assert_match "hp-viz", shell_output("#{bin}/hp-viz --version")
assert_match "HoneyPeer", shell_output("#{bin}/hp-viz --help")
end
end