diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d7a1e..cdd3ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,28 @@ All notable changes to this project are documented in this file. -## Next +## v0.1.3 (2026-02-28) +- [`121b8a0`](https://github.com/msune/l2radar/commit/121b8a0) fix(ui): eliminate redundant double-fetch in useNeighbourData +- [`b930c7c`](https://github.com/msune/l2radar/commit/b930c7c) fix(ui): sort IPv4 numerically; avoid Date allocs in timestamp sort +- [`1e42156`](https://github.com/msune/l2radar/commit/1e42156) perf(ui): cache lookupOUI result per table row +- [`2f30124`](https://github.com/msune/l2radar/commit/2f30124) refactor(ui): extract shared renderMasked to lib +- [`e5f7681`](https://github.com/msune/l2radar/commit/e5f7681) refactor(probe/bpf): document track_mac rationale; remove extra blank line +- [`1201dad`](https://github.com/msune/l2radar/commit/1201dad) refactor(probe): rename module path from marc to msune +- [`ffc0f3a`](https://github.com/msune/l2radar/commit/ffc0f3a) refactor(probe): replace goto with labeled break +- [`a8426cb`](https://github.com/msune/l2radar/commit/a8426cb) refactor(probe): use errors.Join in Probe.Close() +- [`43d8e06`](https://github.com/msune/l2radar/commit/43d8e06) fix(l2rctl): add timeout to version check HTTP client +- [`2a5eec8`](https://github.com/msune/l2radar/commit/2a5eec8) refactor(l2rctl): extract shared containers package +- [`df53b00`](https://github.com/msune/l2radar/commit/df53b00) fix(ui): correct http-plain.conf data alias path + +## v0.1.2 (2026-02-28) - demo: automated GIF recording in CI (on version tag push); deterministic 15-host simulation across eth0 and wlan0; GIF uploaded as a GitHub release asset (fixes [#3](https://github.com/msune/l2radar/issues/3)) -- [`0441c9d`](https://github.com/msune/l2radar/commit/0441c9d) l2rctl: add --privacy-mode flag for UI container (red → green) -- [`669e588`](https://github.com/msune/l2radar/commit/669e588) ui: generate config.json in entrypoint.sh for --privacy-mode -- [`b0da078`](https://github.com/msune/l2radar/commit/b0da078) ui: wire privacy mode into App -- [`1f4738d`](https://github.com/msune/l2radar/commit/1f4738d) ui: add PrivacyToggle component and tests (red → green) -- [`249c5f4`](https://github.com/msune/l2radar/commit/249c5f4) ui: add useConfig hook and tests (red → green) -- [`2e4fe3c`](https://github.com/msune/l2radar/commit/2e4fe3c) ui: add MAC obfuscation library and tests (red → green) +- [`bf3c154`](https://github.com/msune/l2radar/commit/bf3c154) ui: gray out obfuscated MAC/IPv6 bytes in privacy mode +- [`5c4388f`](https://github.com/msune/l2radar/commit/5c4388f) l2rctl: add --privacy-mode flag for UI container (red → green) +- [`9f7ae62`](https://github.com/msune/l2radar/commit/9f7ae62) ui: generate config.json in entrypoint.sh for --privacy-mode +- [`22bc275`](https://github.com/msune/l2radar/commit/22bc275) ui: wire privacy mode into App +- [`5b1c966`](https://github.com/msune/l2radar/commit/5b1c966) ui: add PrivacyToggle component and tests (red → green) +- [`a7356c3`](https://github.com/msune/l2radar/commit/a7356c3) ui: add useConfig hook and tests (red → green) +- [`6d60694`](https://github.com/msune/l2radar/commit/6d60694) ui: add MAC obfuscation library and tests (red → green) ## v0.1.1 (2026-02-20) - [`45ff683`](https://github.com/msune/l2radar/commit/45ff683) docs: add changelog diff --git a/l2rctl/cmd/l2rctl/cli/start.go b/l2rctl/cmd/l2rctl/cli/start.go index cdb578e..6270a5e 100644 --- a/l2rctl/cmd/l2rctl/cli/start.go +++ b/l2rctl/cmd/l2rctl/cli/start.go @@ -5,6 +5,7 @@ import ( "os" "github.com/msune/l2radar/l2rctl/internal/auth" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/start" "github.com/spf13/cobra" ) @@ -77,7 +78,7 @@ func init() { func runStartOrInstall(cmd *cobra.Command, args []string, restartPolicy string) error { r := NewRunner() - target, err := start.ParseTarget(args) + target, err := containers.ParseTarget(args) if err != nil { return err } diff --git a/l2rctl/cmd/l2rctl/cli/stop.go b/l2rctl/cmd/l2rctl/cli/stop.go index 6bf3392..b770132 100644 --- a/l2rctl/cmd/l2rctl/cli/stop.go +++ b/l2rctl/cmd/l2rctl/cli/stop.go @@ -1,6 +1,7 @@ package cli import ( + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/stop" "github.com/spf13/cobra" ) @@ -16,7 +17,7 @@ var stopCmd = &cobra.Command{ Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { r := NewRunner() - target, err := stop.ParseTarget(args) + target, err := containers.ParseTarget(args) if err != nil { return err } diff --git a/l2rctl/internal/containers/containers.go b/l2rctl/internal/containers/containers.go new file mode 100644 index 0000000..7c4806e --- /dev/null +++ b/l2rctl/internal/containers/containers.go @@ -0,0 +1,70 @@ +package containers + +import ( + "encoding/json" + "fmt" + + "github.com/msune/l2radar/l2rctl/internal/docker" +) + +const ( + // Probe is the container name for the l2radar probe. + Probe = "l2radar" + // UI is the container name for the l2radar UI. + UI = "l2radar-ui" +) + +var validTargets = map[string]bool{ + "all": true, + "probe": true, + "ui": true, +} + +// ParseTarget extracts the target from args (default: "all"). +func ParseTarget(args []string) (string, error) { + if len(args) == 0 { + return "all", nil + } + t := args[0] + if !validTargets[t] { + return "", fmt.Errorf("invalid target: %s (must be all, probe, or ui)", t) + } + return t, nil +} + +// State holds the result of inspecting a container. +type State struct { + Status string + StartedAt string + Found bool +} + +// inspectResponse mirrors the relevant docker inspect JSON fields. +type inspectResponse struct { + State struct { + Status string `json:"Status"` + StartedAt string `json:"StartedAt"` + } `json:"State"` +} + +// Inspect checks a container's state via docker inspect. +func Inspect(r docker.Runner, name string) State { + stdout, _, err := r.Run("inspect", "--type", "container", name) + if err != nil { + return State{Status: "not found", Found: false} + } + var infos []inspectResponse + if err := json.Unmarshal([]byte(stdout), &infos); err != nil || len(infos) == 0 { + return State{Status: "not found", Found: false} + } + info := infos[0] + started := info.State.StartedAt + if started == "" { + started = "-" + } + return State{ + Status: info.State.Status, + StartedAt: started, + Found: true, + } +} diff --git a/l2rctl/internal/containers/containers_test.go b/l2rctl/internal/containers/containers_test.go new file mode 100644 index 0000000..930ed54 --- /dev/null +++ b/l2rctl/internal/containers/containers_test.go @@ -0,0 +1,101 @@ +package containers + +import ( + "fmt" + "testing" + + "github.com/msune/l2radar/l2rctl/internal/docker" +) + +func TestParseTarget(t *testing.T) { + tests := []struct { + name string + args []string + want string + wantErr bool + }{ + {"default is all", nil, "all", false}, + {"explicit all", []string{"all"}, "all", false}, + {"probe", []string{"probe"}, "probe", false}, + {"ui", []string{"ui"}, "ui", false}, + {"invalid", []string{"bogus"}, "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseTarget(tt.args) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestInspect_Found(t *testing.T) { + m := &docker.MockRunner{ + StdoutFn: func(args []string) string { + return `[{"State":{"Status":"running","StartedAt":"2026-02-14T14:00:00Z"}}]` + }, + } + s := Inspect(m, "l2radar") + if !s.Found { + t.Fatal("expected Found=true") + } + if s.Status != "running" { + t.Errorf("got status %q, want %q", s.Status, "running") + } + if s.StartedAt != "2026-02-14T14:00:00Z" { + t.Errorf("got StartedAt %q, want %q", s.StartedAt, "2026-02-14T14:00:00Z") + } +} + +func TestInspect_NotFound(t *testing.T) { + m := &docker.MockRunner{ + ErrFn: func(args []string) error { + return fmt.Errorf("Error: No such container: l2radar") + }, + } + s := Inspect(m, "l2radar") + if s.Found { + t.Fatal("expected Found=false") + } + if s.Status != "not found" { + t.Errorf("got status %q, want %q", s.Status, "not found") + } +} + +func TestInspect_EmptyStartedAt(t *testing.T) { + m := &docker.MockRunner{ + StdoutFn: func(args []string) string { + return `[{"State":{"Status":"created","StartedAt":""}}]` + }, + } + s := Inspect(m, "l2radar") + if !s.Found { + t.Fatal("expected Found=true") + } + if s.StartedAt != "-" { + t.Errorf("got StartedAt %q, want %q", s.StartedAt, "-") + } +} + +func TestInspect_InvalidJSON(t *testing.T) { + m := &docker.MockRunner{ + StdoutFn: func(args []string) string { + return `not json` + }, + } + s := Inspect(m, "l2radar") + if s.Found { + t.Fatal("expected Found=false for invalid JSON") + } +} diff --git a/l2rctl/internal/dump/dump.go b/l2rctl/internal/dump/dump.go index 031f9ec..9f5b64d 100644 --- a/l2rctl/internal/dump/dump.go +++ b/l2rctl/internal/dump/dump.go @@ -3,11 +3,10 @@ package dump import ( "fmt" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) -const ProbeContainer = "l2radar" - // Opts holds dump command options. type Opts struct { Iface string @@ -23,7 +22,7 @@ func Dump(r docker.Runner, opts Opts) error { return fmt.Errorf("invalid output format %q (supported: table, json)", opts.Output) } - args := []string{"exec", ProbeContainer, "/l2radar", "dump", "--iface", opts.Iface} + args := []string{"exec", containers.Probe, "/l2radar", "dump", "--iface", opts.Iface} if opts.Output != "" { args = append(args, "-o", opts.Output) } diff --git a/l2rctl/internal/start/probe.go b/l2rctl/internal/start/probe.go index 0cb2ee0..88dd788 100644 --- a/l2rctl/internal/start/probe.go +++ b/l2rctl/internal/start/probe.go @@ -3,6 +3,7 @@ package start import ( "fmt" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) @@ -20,7 +21,7 @@ type ProbeOpts struct { // StartProbe starts the l2radar probe container. func StartProbe(r docker.Runner, opts ProbeOpts) error { - if err := ensureNotRunning(r, ProbeContainer); err != nil { + if err := ensureNotRunning(r, containers.Probe); err != nil { return err } if err := pullImage(r, opts.Image); err != nil { @@ -32,7 +33,7 @@ func StartProbe(r docker.Runner, opts ProbeOpts) error { "--network=host", "-v", "/sys/fs/bpf:/sys/fs/bpf", "-v", fmt.Sprintf("%s:%s", opts.VolumeName, opts.ExportDir), - "--name", ProbeContainer, + "--name", containers.Probe, } if opts.RestartPolicy != "" { diff --git a/l2rctl/internal/start/start.go b/l2rctl/internal/start/start.go index a123ebb..0bba3a4 100644 --- a/l2rctl/internal/start/start.go +++ b/l2rctl/internal/start/start.go @@ -1,75 +1,21 @@ package start import ( - "encoding/json" "fmt" "io" "strings" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) -const ( - ProbeContainer = "l2radar" - UIContainer = "l2radar-ui" -) - -var validTargets = map[string]bool{ - "all": true, - "probe": true, - "ui": true, -} - -// ParseTarget extracts the target from args (default: "all"). -func ParseTarget(args []string) (string, error) { - if len(args) == 0 { - return "all", nil - } - t := args[0] - if !validTargets[t] { - return "", fmt.Errorf("invalid target: %s (must be all, probe, or ui)", t) - } - return t, nil -} - -// containerState holds docker inspect state. -type containerState struct { - State struct { - Status string `json:"Status"` - } `json:"State"` -} - -// checkContainer checks if a container exists and its state. -// Returns: "running", "stopped", or "notfound". -func checkContainer(r docker.Runner, name string) (string, error) { - stdout, _, err := r.Run("inspect", "--type", "container", name) - if err != nil { - return "notfound", nil - } - var states []containerState - if err := json.Unmarshal([]byte(stdout), &states); err != nil { - return "notfound", nil - } - if len(states) == 0 { - return "notfound", nil - } - status := states[0].State.Status - if status == "running" { - return "running", nil - } - return "stopped", nil -} - // ensureNotRunning checks container state and removes stopped containers. func ensureNotRunning(r docker.Runner, name string) error { - state, err := checkContainer(r, name) - if err != nil { - return err - } - switch state { - case "running": + s := containers.Inspect(r, name) + switch { + case s.Found && s.Status == "running": return fmt.Errorf("container %q is already running (stop it first)", name) - case "stopped": + case s.Found: if _, _, err := r.Run("rm", name); err != nil { return fmt.Errorf("remove stopped container %q: %w", name, err) } @@ -97,8 +43,9 @@ func pullImage(r docker.Runner, image string) error { // when the volume does not exist. func EnsureCleanVolume(r docker.Runner, volumeName string, warn io.Writer) error { // If either container is running, the volume is in active use — leave it alone. - for _, name := range []string{ProbeContainer, UIContainer} { - if state, _ := checkContainer(r, name); state == "running" { + for _, name := range []string{containers.Probe, containers.UI} { + s := containers.Inspect(r, name) + if s.Found && s.Status == "running" { return nil } } diff --git a/l2rctl/internal/start/start_test.go b/l2rctl/internal/start/start_test.go index 3b4791a..dc751c9 100644 --- a/l2rctl/internal/start/start_test.go +++ b/l2rctl/internal/start/start_test.go @@ -49,36 +49,3 @@ func TestPullImage_PullFailsAndLocalMissing(t *testing.T) { t.Fatal("expected error when pull fails and image not local") } } - -func TestParseTarget(t *testing.T) { - tests := []struct { - name string - args []string - want string - wantErr bool - }{ - {"default is all", nil, "all", false}, - {"explicit all", []string{"all"}, "all", false}, - {"probe", []string{"probe"}, "probe", false}, - {"ui", []string{"ui"}, "ui", false}, - {"invalid", []string{"bogus"}, "", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParseTarget(tt.args) - if tt.wantErr { - if err == nil { - t.Fatal("expected error") - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != tt.want { - t.Errorf("got %q, want %q", got, tt.want) - } - }) - } -} diff --git a/l2rctl/internal/start/ui.go b/l2rctl/internal/start/ui.go index 073d854..34644e4 100644 --- a/l2rctl/internal/start/ui.go +++ b/l2rctl/internal/start/ui.go @@ -3,6 +3,7 @@ package start import ( "fmt" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) @@ -24,7 +25,7 @@ type UIOpts struct { // StartUI starts the l2radar-ui container. func StartUI(r docker.Runner, opts UIOpts) error { - if err := ensureNotRunning(r, UIContainer); err != nil { + if err := ensureNotRunning(r, containers.UI); err != nil { return err } if err := pullImage(r, opts.Image); err != nil { @@ -34,7 +35,7 @@ func StartUI(r docker.Runner, opts UIOpts) error { args := []string{"run", "-d", "-v", fmt.Sprintf("%s:%s:ro", opts.VolumeName, opts.ExportDir), "-p", fmt.Sprintf("%s:%d:443", opts.Bind, opts.HTTPSPort), - "--name", UIContainer, + "--name", containers.UI, } if opts.RestartPolicy != "" { diff --git a/l2rctl/internal/start/volume_test.go b/l2rctl/internal/start/volume_test.go index 4efb4f1..243e80c 100644 --- a/l2rctl/internal/start/volume_test.go +++ b/l2rctl/internal/start/volume_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) @@ -74,7 +75,7 @@ func TestEnsureCleanVolume_ProbeRunning(t *testing.T) { // Probe is running → skip volume rm even if volume exists. m := &docker.MockRunner{ StdoutFn: func(args []string) string { - if len(args) >= 2 && args[0] == "inspect" && args[len(args)-1] == ProbeContainer { + if len(args) >= 2 && args[0] == "inspect" && args[len(args)-1] == containers.Probe { return `[{"State":{"Status":"running"}}]` } if len(args) >= 2 && args[0] == "volume" && args[1] == "inspect" { @@ -100,7 +101,7 @@ func TestEnsureCleanVolume_UIRunning(t *testing.T) { // UI is running → skip volume rm even if volume exists. m := &docker.MockRunner{ StdoutFn: func(args []string) string { - if len(args) >= 2 && args[0] == "inspect" && args[len(args)-1] == UIContainer { + if len(args) >= 2 && args[0] == "inspect" && args[len(args)-1] == containers.UI { return `[{"State":{"Status":"running"}}]` } if len(args) >= 2 && args[0] == "volume" && args[1] == "inspect" { diff --git a/l2rctl/internal/status/status.go b/l2rctl/internal/status/status.go index e53a5ea..a7bcd23 100644 --- a/l2rctl/internal/status/status.go +++ b/l2rctl/internal/status/status.go @@ -1,54 +1,34 @@ package status import ( - "encoding/json" "fmt" "strings" "text/tabwriter" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) -const ( - ProbeContainer = "l2radar" - UIContainer = "l2radar-ui" -) - -type containerInfo struct { - State struct { - Status string `json:"Status"` - StartedAt string `json:"StartedAt"` - } `json:"State"` -} - type row struct { name string status string started string } -func inspectContainer(r docker.Runner, name string) row { - stdout, _, err := r.Run("inspect", "--type", "container", name) - if err != nil { - return row{name: name, status: "not found", started: "-"} - } - var infos []containerInfo - if err := json.Unmarshal([]byte(stdout), &infos); err != nil || len(infos) == 0 { - return row{name: name, status: "not found", started: "-"} - } - info := infos[0] - started := info.State.StartedAt +func inspectRow(r docker.Runner, name string) row { + s := containers.Inspect(r, name) + started := s.StartedAt if started == "" { started = "-" } - return row{name: name, status: info.State.Status, started: started} + return row{name: name, status: s.Status, started: started} } // Status returns a formatted table of container statuses. func Status(r docker.Runner) (string, error) { rows := []row{ - inspectContainer(r, ProbeContainer), - inspectContainer(r, UIContainer), + inspectRow(r, containers.Probe), + inspectRow(r, containers.UI), } var sb strings.Builder diff --git a/l2rctl/internal/stop/stop.go b/l2rctl/internal/stop/stop.go index b5b26ba..8346d72 100644 --- a/l2rctl/internal/stop/stop.go +++ b/l2rctl/internal/stop/stop.go @@ -4,38 +4,16 @@ import ( "fmt" "strings" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) -const ( - ProbeContainer = "l2radar" - UIContainer = "l2radar-ui" -) - -var validTargets = map[string]bool{ - "all": true, - "probe": true, - "ui": true, -} - // Opts holds options for the Stop function. type Opts struct { Target string VolumeName string } -// ParseTarget extracts the target from args (default: "all"). -func ParseTarget(args []string) (string, error) { - if len(args) == 0 { - return "all", nil - } - t := args[0] - if !validTargets[t] { - return "", fmt.Errorf("invalid target: %s (must be all, probe, or ui)", t) - } - return t, nil -} - // isNotFound returns true if the error indicates a resource was not found. func isNotFound(err error) bool { if err == nil { @@ -77,14 +55,14 @@ func stopContainer(r docker.Runner, name string) error { func Stop(r docker.Runner, opts Opts) error { switch opts.Target { case "probe": - return stopContainer(r, ProbeContainer) + return stopContainer(r, containers.Probe) case "ui": - return stopContainer(r, UIContainer) + return stopContainer(r, containers.UI) case "all": - if err := stopContainer(r, ProbeContainer); err != nil { + if err := stopContainer(r, containers.Probe); err != nil { return err } - if err := stopContainer(r, UIContainer); err != nil { + if err := stopContainer(r, containers.UI); err != nil { return err } return removeVolume(r, opts.VolumeName) diff --git a/l2rctl/internal/stop/stop_test.go b/l2rctl/internal/stop/stop_test.go index d406a63..14d8b05 100644 --- a/l2rctl/internal/stop/stop_test.go +++ b/l2rctl/internal/stop/stop_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/msune/l2radar/l2rctl/internal/containers" "github.com/msune/l2radar/l2rctl/internal/docker" ) @@ -130,10 +131,10 @@ func TestStopAllVolumeRmAfterContainers(t *testing.T) { // Use slice matching to avoid "rm l2radar" being a substring of "volume rm l2radar-data". var probeRmIdx, uiRmIdx, volumeRmIdx int for i, c := range m.Calls { - if len(c) == 2 && c[0] == "rm" && c[1] == ProbeContainer { + if len(c) == 2 && c[0] == "rm" && c[1] == containers.Probe { probeRmIdx = i + 1 } - if len(c) == 2 && c[0] == "rm" && c[1] == UIContainer { + if len(c) == 2 && c[0] == "rm" && c[1] == containers.UI { uiRmIdx = i + 1 } if len(c) == 3 && c[0] == "volume" && c[1] == "rm" { @@ -179,7 +180,7 @@ func TestStopNotFound(t *testing.T) { } func TestStopDefaultTarget(t *testing.T) { - target, err := ParseTarget(nil) + target, err := containers.ParseTarget(nil) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/l2rctl/internal/version/version.go b/l2rctl/internal/version/version.go index 5286af5..1e90383 100644 --- a/l2rctl/internal/version/version.go +++ b/l2rctl/internal/version/version.go @@ -25,6 +25,10 @@ const ( cacheTTL = 24 * time.Hour ) +// httpClient is the HTTP client used for version checks, with a timeout +// to avoid blocking the CLI on slow or unresponsive networks. +var httpClient = &http.Client{Timeout: 5 * time.Second} + // proxyResponse is the JSON returned by the Go module proxy /@latest endpoint. type proxyResponse struct { Version string `json:"Version"` @@ -54,7 +58,7 @@ func FetchLatest(ctx context.Context, baseURL string) (string, error) { return "", fmt.Errorf("creating request: %w", err) } - resp, err := http.DefaultClient.Do(req) + resp, err := httpClient.Do(req) if err != nil { return "", fmt.Errorf("fetching latest version: %w", err) } diff --git a/probe/bpf/l2radar.c b/probe/bpf/l2radar.c index 08045c0..8ecfd62 100644 --- a/probe/bpf/l2radar.c +++ b/probe/bpf/l2radar.c @@ -377,11 +377,16 @@ int l2radar(struct __sk_buff *skb) l3_offset += 4; } - void *l3_start = data + l3_offset; switch (eth_proto) { case ETH_P_ARP: + /* + * Track the Ethernet source MAC here, before handle_arp, + * because handle_arp may bail on ARP header validation + * (non-Ethernet, non-IPv4). This ensures the L2 sender + * is always recorded even for malformed ARP payloads. + */ track_mac(src_mac); /* diff --git a/probe/cmd/l2radar/cli/dump.go b/probe/cmd/l2radar/cli/dump.go index cb72d96..43a86cc 100644 --- a/probe/cmd/l2radar/cli/dump.go +++ b/probe/cmd/l2radar/cli/dump.go @@ -5,9 +5,9 @@ import ( "fmt" "time" - "github.com/marc/l2radar/probe/pkg/dump" - "github.com/marc/l2radar/probe/pkg/export" - "github.com/marc/l2radar/probe/pkg/loader" + "github.com/msune/l2radar/probe/pkg/dump" + "github.com/msune/l2radar/probe/pkg/export" + "github.com/msune/l2radar/probe/pkg/loader" "github.com/spf13/cobra" ) diff --git a/probe/cmd/l2radar/cli/dump_test.go b/probe/cmd/l2radar/cli/dump_test.go index 1a47904..42b07ab 100644 --- a/probe/cmd/l2radar/cli/dump_test.go +++ b/probe/cmd/l2radar/cli/dump_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/marc/l2radar/probe/pkg/dump" - "github.com/marc/l2radar/probe/pkg/export" + "github.com/msune/l2radar/probe/pkg/dump" + "github.com/msune/l2radar/probe/pkg/export" ) func TestMarshalDumpJSONIncludesInterfaceInfoAndStats(t *testing.T) { diff --git a/probe/cmd/l2radar/cli/root.go b/probe/cmd/l2radar/cli/root.go index 8033968..e98515c 100644 --- a/probe/cmd/l2radar/cli/root.go +++ b/probe/cmd/l2radar/cli/root.go @@ -9,9 +9,9 @@ import ( "syscall" "time" - "github.com/marc/l2radar/probe/pkg/dump" - "github.com/marc/l2radar/probe/pkg/export" - "github.com/marc/l2radar/probe/pkg/loader" + "github.com/msune/l2radar/probe/pkg/dump" + "github.com/msune/l2radar/probe/pkg/export" + "github.com/msune/l2radar/probe/pkg/loader" "github.com/spf13/cobra" ) @@ -99,10 +99,11 @@ func runRoot(cmd *cobra.Command, args []string) error { // Export immediately, then on each tick. exportAll(resolved, rootPinPath, rootExportDir, rootExportInterval, logger) + exportLoop: for { select { case <-ctx.Done(): - goto shutdown + break exportLoop case <-ticker.C: exportAll(resolved, rootPinPath, rootExportDir, rootExportInterval, logger) } @@ -112,7 +113,6 @@ func runRoot(cmd *cobra.Command, args []string) error { <-ctx.Done() } -shutdown: logger.Info("shutting down...") for _, p := range probes { if err := p.Close(); err != nil { diff --git a/probe/cmd/l2radar/main.go b/probe/cmd/l2radar/main.go index 85ae88b..e682677 100644 --- a/probe/cmd/l2radar/main.go +++ b/probe/cmd/l2radar/main.go @@ -3,7 +3,7 @@ package main import ( "os" - "github.com/marc/l2radar/probe/cmd/l2radar/cli" + "github.com/msune/l2radar/probe/cmd/l2radar/cli" ) func main() { diff --git a/probe/go.mod b/probe/go.mod index 7c11151..7865c15 100644 --- a/probe/go.mod +++ b/probe/go.mod @@ -1,4 +1,4 @@ -module github.com/marc/l2radar/probe +module github.com/msune/l2radar/probe go 1.24.4 diff --git a/probe/pkg/dump/dump.go b/probe/pkg/dump/dump.go index 7af10db..ab61a0e 100644 --- a/probe/pkg/dump/dump.go +++ b/probe/pkg/dump/dump.go @@ -9,7 +9,7 @@ import ( "sort" "strings" - "github.com/marc/l2radar/probe/pkg/oui" + "github.com/msune/l2radar/probe/pkg/oui" "text/tabwriter" "time" diff --git a/probe/pkg/export/export.go b/probe/pkg/export/export.go index cc8fb4a..e018e44 100644 --- a/probe/pkg/export/export.go +++ b/probe/pkg/export/export.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/marc/l2radar/probe/pkg/dump" + "github.com/msune/l2radar/probe/pkg/dump" ) // InterfaceInfo holds the monitored interface's own addresses. diff --git a/probe/pkg/export/export_test.go b/probe/pkg/export/export_test.go index 9b55b90..c0c2da8 100644 --- a/probe/pkg/export/export_test.go +++ b/probe/pkg/export/export_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/marc/l2radar/probe/pkg/dump" + "github.com/msune/l2radar/probe/pkg/dump" ) func TestInterfaceDataJSON(t *testing.T) { diff --git a/probe/pkg/loader/loader.go b/probe/pkg/loader/loader.go index f218caa..fbaa95a 100644 --- a/probe/pkg/loader/loader.go +++ b/probe/pkg/loader/loader.go @@ -1,6 +1,7 @@ package loader import ( + "errors" "fmt" "log/slog" "net" @@ -120,10 +121,7 @@ func (p *Probe) Close() error { p.logger.Info("probe detached", "interface", p.iface) - if len(errs) > 0 { - return fmt.Errorf("close errors: %v", errs) - } - return nil + return errors.Join(errs...) } // Interface returns the name of the interface this probe is attached to. diff --git a/ui/nginx/http-plain.conf b/ui/nginx/http-plain.conf index ae633a0..f85ffd6 100644 --- a/ui/nginx/http-plain.conf +++ b/ui/nginx/http-plain.conf @@ -18,7 +18,7 @@ server { } location /data/ { - alias /tmp/l2radar/; + alias /var/lib/l2radar/; autoindex on; autoindex_format json; add_header Cache-Control "no-cache"; diff --git a/ui/src/components/InterfaceInfo.jsx b/ui/src/components/InterfaceInfo.jsx index f1d0878..591283c 100644 --- a/ui/src/components/InterfaceInfo.jsx +++ b/ui/src/components/InterfaceInfo.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { formatAgo } from '../lib/timeago' import { splitMacForDisplay, splitIPv6ForDisplay } from '../lib/macObfuscation' +import { renderMasked } from '../lib/renderMasked' function parseDuration(s) { if (!s) return 0 @@ -20,12 +21,6 @@ function formatBytes(bytes) { return `${Number.isInteger(val) ? val : val.toFixed(1)} ${units[i]}` } -function renderMasked(text, splitFn) { - const { prefix, masked } = splitFn(text) - if (!masked) return text - return <>{prefix}{masked} -} - function InterfaceInfo({ name, timestamp, info, privacyMode = false }) { const [now, setNow] = useState(Date.now()) const [statsOpen, setStatsOpen] = useState(false) diff --git a/ui/src/components/NeighbourTable.jsx b/ui/src/components/NeighbourTable.jsx index 8b9be9d..1318eaa 100644 --- a/ui/src/components/NeighbourTable.jsx +++ b/ui/src/components/NeighbourTable.jsx @@ -3,6 +3,7 @@ import { formatAgo } from '../lib/timeago' import { sortNeighbours } from '../lib/sorting' import { lookupOUI } from '../lib/ouiLookup' import { splitMacForDisplay, splitIPv6ForDisplay } from '../lib/macObfuscation' +import { renderMasked } from '../lib/renderMasked' const COLUMNS = [ { key: 'interface', label: 'Interface' }, @@ -26,12 +27,6 @@ function rowKey(n) { return `${n.interface}-${n.mac}` } -function renderMasked(text, splitFn) { - const { prefix, masked } = splitFn(text) - if (!masked) return text - return <>{prefix}{masked} -} - function NeighbourTable({ neighbours, showInterface = true, privacyMode = false }) { const columns = showInterface ? COLUMNS @@ -113,6 +108,7 @@ function NeighbourTable({ neighbours, showInterface = true, privacyMode = false const k = rowKey(n) const stale = isStale(n) const fresh = freshKeys.has(k) + const vendor = lookupOUI(n.mac) return ( {privacyMode ? renderMasked(n.mac, splitMacForDisplay) : n.mac} - {lookupOUI(n.mac) && ( + {vendor && ( - ({lookupOUI(n.mac)}) + ({vendor}) )} @@ -167,6 +163,7 @@ function NeighbourTable({ neighbours, showInterface = true, privacyMode = false const k = rowKey(n) const stale = isStale(n) const fresh = freshKeys.has(k) + const vendor = lookupOUI(n.mac) return (
{privacyMode ? renderMasked(n.mac, splitMacForDisplay) : n.mac} - {lookupOUI(n.mac) && ( + {vendor && (
- {lookupOUI(n.mac)} + {vendor}
)}
diff --git a/ui/src/hooks/useNeighbourData.js b/ui/src/hooks/useNeighbourData.js index ee7e07b..7639bd4 100644 --- a/ui/src/hooks/useNeighbourData.js +++ b/ui/src/hooks/useNeighbourData.js @@ -7,6 +7,7 @@ const DEFAULT_POLL_INTERVAL = 5000 /** * Hook that discovers and polls JSON files from the data endpoint. * Uses If-Modified-Since to avoid re-downloading unchanged files. + * Maintains a per-file parsed cache so only changed files are fetched. */ export function useNeighbourData(pollInterval = DEFAULT_POLL_INTERVAL) { const [neighbours, setNeighbours] = useState([]) @@ -15,6 +16,8 @@ export function useNeighbourData(pollInterval = DEFAULT_POLL_INTERVAL) { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const lastModifiedRef = useRef({}) + const parsedCacheRef = useRef({}) + const firstLoadRef = useRef(true) const fetchData = useCallback(async () => { try { @@ -30,16 +33,26 @@ export function useNeighbourData(pollInterval = DEFAULT_POLL_INTERVAL) { .map((entry) => entry.name) if (jsonFiles.length === 0) { + parsedCacheRef.current = {} + lastModifiedRef.current = {} setNeighbours([]) setTimestamps({}) setInterfaceInfo({}) setLoading(false) + firstLoadRef.current = false return } - // Fetch each JSON file with If-Modified-Since - const dataArray = [] - const newLastModified = { ...lastModifiedRef.current } + // Prune cache: remove entries for files no longer in the listing + const fileSet = new Set(jsonFiles) + for (const key of Object.keys(parsedCacheRef.current)) { + if (!fileSet.has(key)) { + delete parsedCacheRef.current[key] + delete lastModifiedRef.current[key] + } + } + + // Single fetch pass with If-Modified-Since let anyUpdated = false for (const file of jsonFiles) { @@ -52,7 +65,7 @@ export function useNeighbourData(pollInterval = DEFAULT_POLL_INTERVAL) { const resp = await fetch(url, { headers }) if (resp.status === 304) { - // Not modified — use cached data (skip) + // Not modified — cache already has data continue } @@ -63,39 +76,29 @@ export function useNeighbourData(pollInterval = DEFAULT_POLL_INTERVAL) { const lastMod = resp.headers.get('Last-Modified') if (lastMod) { - newLastModified[file] = lastMod + lastModifiedRef.current[file] = lastMod } - const data = await resp.json() - dataArray.push(data) + parsedCacheRef.current[file] = await resp.json() anyUpdated = true } - lastModifiedRef.current = newLastModified - - if (anyUpdated || loading) { - // Re-fetch all files to get complete picture when any file changed - const allData = [] - for (const file of jsonFiles) { - const url = `${DATA_BASE_URL}${file}` - const resp = await fetch(url) - if (resp.ok) { - allData.push(await resp.json()) - } - } + if (anyUpdated || firstLoadRef.current) { + const allData = Object.values(parsedCacheRef.current) const merged = mergeNeighbours(allData) setNeighbours(merged.neighbours) setTimestamps(merged.timestamps) setInterfaceInfo(merged.interfaceInfo) } + firstLoadRef.current = false setError(null) } catch (err) { setError(err.message) } finally { setLoading(false) } - }, [loading]) + }, []) useEffect(() => { fetchData() diff --git a/ui/src/lib/renderMasked.jsx b/ui/src/lib/renderMasked.jsx new file mode 100644 index 0000000..cad88ac --- /dev/null +++ b/ui/src/lib/renderMasked.jsx @@ -0,0 +1,5 @@ +export function renderMasked(text, splitFn) { + const { prefix, masked } = splitFn(text) + if (!masked) return text + return <>{prefix}{masked} +} diff --git a/ui/src/lib/sorting.js b/ui/src/lib/sorting.js index 5f6eda1..04c8304 100644 --- a/ui/src/lib/sorting.js +++ b/ui/src/lib/sorting.js @@ -1,3 +1,13 @@ +/** + * Convert an IPv4 address string to a numeric value for comparison. + * Returns -1 for missing/invalid addresses so they sort first. + */ +function ipv4ToNum(ip) { + if (!ip) return -1 + const parts = ip.split('.') + return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0 +} + /** * Sort neighbours by a given column. * Returns a new sorted array. @@ -14,17 +24,17 @@ export function sortNeighbours(neighbours, sortKey, sortDir) { cmp = a.mac.localeCompare(b.mac) break case 'ipv4': - cmp = (a.ipv4[0] || '').localeCompare(b.ipv4[0] || '') + cmp = ipv4ToNum(a.ipv4[0]) - ipv4ToNum(b.ipv4[0]) break case 'ipv6': cmp = (a.ipv6[0] || '').localeCompare(b.ipv6[0] || '') break case 'firstSeen': - cmp = new Date(a.firstSeen) - new Date(b.firstSeen) + cmp = (a.firstSeen || '').localeCompare(b.firstSeen || '') break case 'lastSeen': default: - cmp = new Date(a.lastSeen) - new Date(b.lastSeen) + cmp = (a.lastSeen || '').localeCompare(b.lastSeen || '') break } diff --git a/ui/src/lib/sorting.test.js b/ui/src/lib/sorting.test.js index da9c05c..f1afa19 100644 --- a/ui/src/lib/sorting.test.js +++ b/ui/src/lib/sorting.test.js @@ -76,4 +76,24 @@ describe('sortNeighbours', () => { it('handles empty array', () => { expect(sortNeighbours([], 'mac', 'asc')).toEqual([]) }) + + it('sorts IPv4 numerically (192.168.1.9 before 192.168.1.10)', () => { + const rows = [ + { interface: 'eth0', mac: 'a', ipv4: ['192.168.1.10'], ipv6: [], firstSeen: '', lastSeen: '' }, + { interface: 'eth0', mac: 'b', ipv4: ['192.168.1.9'], ipv6: [], firstSeen: '', lastSeen: '' }, + ] + const sorted = sortNeighbours(rows, 'ipv4', 'asc') + expect(sorted[0].ipv4[0]).toBe('192.168.1.9') + expect(sorted[1].ipv4[0]).toBe('192.168.1.10') + }) + + it('sorts high-octet IPv4 addresses correctly', () => { + const rows = [ + { interface: 'eth0', mac: 'a', ipv4: ['192.168.1.1'], ipv6: [], firstSeen: '', lastSeen: '' }, + { interface: 'eth0', mac: 'b', ipv4: ['10.0.0.1'], ipv6: [], firstSeen: '', lastSeen: '' }, + ] + const sorted = sortNeighbours(rows, 'ipv4', 'asc') + expect(sorted[0].ipv4[0]).toBe('10.0.0.1') + expect(sorted[1].ipv4[0]).toBe('192.168.1.1') + }) })