diff --git a/src/yangerd/internal/ethmonitor/ethmonitor_test.go b/src/yangerd/internal/ethmonitor/ethmonitor_test.go new file mode 100644 index 00000000..e3ae481e --- /dev/null +++ b/src/yangerd/internal/ethmonitor/ethmonitor_test.go @@ -0,0 +1,138 @@ +package ethmonitor + +import ( + "testing" +) + +func TestBuildEthernetContainerCopper1G(t *testing.T) { + data := ethtoolJSON{ + Speed: 1000, + Duplex: "Full", + Port: "Twisted Pair", + AutoNegotiation: true, + SupportedLinkModes: []string{ + "10baseT/Half", "10baseT/Full", + "100baseT/Half", "100baseT/Full", + "1000baseT/Full", + }, + AdvertisedLinkModes: []string{ + "10baseT/Half", "10baseT/Full", + "100baseT/Half", "100baseT/Full", + "1000baseT/Full", + }, + } + + eth, speedBPS := buildEthernetContainer(data) + + if speedBPS != 1_000_000_000 { + t.Fatalf("speed = %d, want 1000000000", speedBPS) + } + if eth["phy-type"] != "ieee802-ethernet-phy-type:phy-type-1000BASE-T" { + t.Fatalf("phy-type = %v", eth["phy-type"]) + } + if eth["pmd-type"] != "ieee802-ethernet-phy-type:pmd-type-1000BASE-T" { + t.Fatalf("pmd-type = %v", eth["pmd-type"]) + } + if eth["duplex"] != "full" { + t.Fatalf("duplex = %v", eth["duplex"]) + } + autoneg := eth["auto-negotiation"].(map[string]any) + if autoneg["enable"] != true { + t.Fatal("autoneg should be true") + } + // advertised == supported → no advertised-pmd-types key + if _, ok := autoneg["infix-ethernet-interface:advertised-pmd-types"]; ok { + t.Fatal("advertised-pmd-types should be suppressed when equal to supported") + } +} + +func TestBuildEthernetContainerFibre10G(t *testing.T) { + data := ethtoolJSON{ + Speed: 10000, + Duplex: "Full", + Port: "FIBRE", + AutoNegotiation: false, + SupportedLinkModes: []string{"10000baseSR/Full"}, + AdvertisedLinkModes: []string{"10000baseSR/Full"}, + } + + eth, speedBPS := buildEthernetContainer(data) + + if speedBPS != 10_000_000_000 { + t.Fatalf("speed = %d, want 10000000000", speedBPS) + } + // Fibre 10G → phy-type 10GBASE-R, no pmd-type from lookup table + // But exactly one supported mode → pmd-type refined from supported list + if eth["pmd-type"] != "ieee802-ethernet-phy-type:pmd-type-10GBASE-SR" { + t.Fatalf("pmd-type = %v, want refined from single supported mode", eth["pmd-type"]) + } + if eth["phy-type"] != "ieee802-ethernet-phy-type:phy-type-10GBASE-R" { + t.Fatalf("phy-type = %v", eth["phy-type"]) + } +} + +func TestBuildEthernetContainerSpeedUnknown(t *testing.T) { + data := ethtoolJSON{ + Speed: ethtoolSpeedUnknown, + Duplex: "Unknown! (255)", + Port: "Twisted Pair", + AutoNegotiation: true, + } + + eth, speedBPS := buildEthernetContainer(data) + + if speedBPS != 0 { + t.Fatalf("speed = %d, want 0 for unknown", speedBPS) + } + if _, ok := eth["speed"]; ok { + t.Fatal("speed should not be set when unknown") + } + if _, ok := eth["phy-type"]; ok { + t.Fatal("phy-type should not be set when speed unknown") + } +} + +func TestBuildEthernetContainerAdvertisedDiffers(t *testing.T) { + data := ethtoolJSON{ + Speed: 1000, + Duplex: "Full", + Port: "Twisted Pair", + SupportedLinkModes: []string{ + "10baseT/Full", "100baseT/Full", "1000baseT/Full", + }, + AdvertisedLinkModes: []string{"1000baseT/Full"}, + } + + eth, _ := buildEthernetContainer(data) + + autoneg := eth["auto-negotiation"].(map[string]any) + adv, ok := autoneg["infix-ethernet-interface:advertised-pmd-types"] + if !ok { + t.Fatal("advertised-pmd-types should be present when != supported") + } + advList := adv.([]string) + if len(advList) != 1 || advList[0] != "ieee802-ethernet-phy-type:pmd-type-1000BASE-T" { + t.Fatalf("advertised = %v", advList) + } +} + +func TestEthtoolModesToPMD(t *testing.T) { + modes := []string{ + "10baseT/Half", "10baseT/Full", + "1000baseT/Full", + "Autoneg", "TP", + } + got := ethtoolModesToPMD(modes) + want := []string{ + "ieee802-ethernet-phy-type:pmd-type-10BASE-T", + "ieee802-ethernet-phy-type:pmd-type-1000BASE-T", + } + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/src/yangerd/internal/frrvty/frrvty.go b/src/yangerd/internal/frrvty/frrvty.go new file mode 100644 index 00000000..4e228b6f --- /dev/null +++ b/src/yangerd/internal/frrvty/frrvty.go @@ -0,0 +1,83 @@ +// Package frrvty is a minimal in-process client for an FRR daemon's vty +// Unix socket. +// +// It speaks the same protocol vtysh uses, so yangerd can run "show ..." +// commands (e.g. "show ip route json") against zebra without forking +// vtysh. The command is written NUL-terminated; the daemon streams the +// command output followed by a four-byte trailer of three NUL bytes and a +// one-byte CLI return code (\0\0\0). +package frrvty + +import ( + "bytes" + "context" + "fmt" + "io" + "net" +) + +// ZebraVtySocket is the default path to zebra's vty socket. It lives in +// the same runstatedir as the zserv API socket. +const ZebraVtySocket = "/var/run/frr/zebra.vty" + +// Client runs commands against a single FRR daemon vty socket. A fresh +// connection is opened per query, matching vtysh's behaviour. +type Client struct { + socket string +} + +// New returns a Client for the given vty socket path. An empty path +// selects the zebra socket. +func New(socket string) *Client { + if socket == "" { + socket = ZebraVtySocket + } + return &Client{socket: socket} +} + +// Query connects to the vty socket, runs one command, and returns its raw +// output with the protocol trailer stripped. A non-zero CLI return code +// is reported as an error (the partial output is still returned). +func (c *Client) Query(ctx context.Context, command string) ([]byte, error) { + var d net.Dialer + conn, err := d.DialContext(ctx, "unix", c.socket) + if err != nil { + return nil, fmt.Errorf("dial %s: %w", c.socket, err) + } + defer conn.Close() + + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + // vtysh writes the command including its trailing NUL terminator. + if _, err := conn.Write(append([]byte(command), 0)); err != nil { + return nil, fmt.Errorf("write %q: %w", command, err) + } + + var buf bytes.Buffer + tmp := make([]byte, 4096) + for { + n, rerr := conn.Read(tmp) + if n > 0 { + buf.Write(tmp[:n]) + // The response ends with \0\0\0. The payload is + // text/JSON and never contains NUL, so testing the last + // four accumulated bytes is unambiguous. + if b := buf.Bytes(); len(b) >= 4 && + b[len(b)-4] == 0 && b[len(b)-3] == 0 && b[len(b)-2] == 0 { + payload := b[:len(b)-4] + if ret := b[len(b)-1]; ret != 0 { + return payload, fmt.Errorf("vty command %q: status %d", command, ret) + } + return payload, nil + } + } + if rerr != nil { + if rerr == io.EOF { + return nil, fmt.Errorf("vty command %q: closed before trailer", command) + } + return nil, fmt.Errorf("vty command %q: read: %w", command, rerr) + } + } +} diff --git a/src/yangerd/internal/frrvty/frrvty_test.go b/src/yangerd/internal/frrvty/frrvty_test.go new file mode 100644 index 00000000..2194e19b --- /dev/null +++ b/src/yangerd/internal/frrvty/frrvty_test.go @@ -0,0 +1,98 @@ +package frrvty + +import ( + "context" + "net" + "path/filepath" + "sync" + "testing" + "time" +) + +// fakeZebra serves a single vty connection: it reads the NUL-terminated +// command, then writes the configured reply followed by the \0\0\0 +// trailer. +func fakeZebra(t *testing.T, reply string, ret byte) string { + t.Helper() + + sock := filepath.Join(t.TempDir(), "zebra.vty") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatalf("listen: %v", err) + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + + // Read the NUL-terminated command. + buf := make([]byte, 256) + for { + n, err := conn.Read(buf) + if n > 0 && buf[n-1] == 0 { + break + } + if err != nil { + return + } + } + + out := append([]byte(reply), 0, 0, 0, ret) + _, _ = conn.Write(out) + }() + + t.Cleanup(func() { + ln.Close() + wg.Wait() + }) + return sock +} + +func TestQueryStripsTrailer(t *testing.T) { + sock := fakeZebra(t, `{"a":1}`, 0) + c := New(sock) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + out, err := c.Query(ctx, "show ip route json") + if err != nil { + t.Fatalf("Query: %v", err) + } + if string(out) != `{"a":1}` { + t.Errorf("output = %q, want %q", out, `{"a":1}`) + } +} + +func TestQueryNonZeroStatus(t *testing.T) { + sock := fakeZebra(t, "Unknown command", 1) + c := New(sock) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + out, err := c.Query(ctx, "bogus") + if err == nil { + t.Fatal("expected error for non-zero status") + } + if string(out) != "Unknown command" { + t.Errorf("partial output = %q, want %q", out, "Unknown command") + } +} + +func TestQueryDialError(t *testing.T) { + c := New(filepath.Join(t.TempDir(), "does-not-exist.vty")) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if _, err := c.Query(ctx, "show ip route json"); err == nil { + t.Fatal("expected dial error") + } +} diff --git a/src/yangerd/internal/iwmonitor/ap_test.go b/src/yangerd/internal/iwmonitor/ap_test.go new file mode 100644 index 00000000..ccdd3238 --- /dev/null +++ b/src/yangerd/internal/iwmonitor/ap_test.go @@ -0,0 +1,126 @@ +package iwmonitor + +import ( + "encoding/json" + "testing" + + "github.com/kernelkit/infix/src/yangerd/internal/wpactrl" +) + +func TestFormatStations(t *testing.T) { + m := &IWMonitor{} + stas := []map[string]string{ + { + "addr": "02:00:00:00:00:01", + "signal": "-57", + "connected_time": "120", + "rx_packets": "1500", + "tx_packets": "2500", + "rx_bytes": "4825331939", + "tx_bytes": "216392802676", + "rx_rate_info": "1560 vhtmcs 8 vhtnss 2", + "tx_rate_info": "1733 vhtmcs 9 vhtnss 2", + }, + } + + raw, err := json.Marshal(m.formatStations(stas)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var parsed struct { + Station []struct { + MAC string `json:"mac-address"` + Signal int16 `json:"signal-strength"` + ConnectedTime uint32 `json:"connected-time"` + RxPackets string `json:"rx-packets"` + TxPackets string `json:"tx-packets"` + RxBytes string `json:"rx-bytes"` + TxBytes string `json:"tx-bytes"` + RxSpeed uint32 `json:"rx-speed"` + TxSpeed uint32 `json:"tx-speed"` + } `json:"station"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(parsed.Station) != 1 { + t.Fatalf("got %d stations, want 1", len(parsed.Station)) + } + + s := parsed.Station[0] + if s.MAC != "02:00:00:00:00:01" { + t.Errorf("mac-address = %q", s.MAC) + } + if s.Signal != -57 { + t.Errorf("signal-strength = %d, want -57", s.Signal) + } + if s.ConnectedTime != 120 { + t.Errorf("connected-time = %d, want 120", s.ConnectedTime) + } + if s.RxBytes != "4825331939" || s.TxBytes != "216392802676" { + t.Errorf("bytes = %q/%q", s.RxBytes, s.TxBytes) + } + if s.RxPackets != "1500" || s.TxPackets != "2500" { + t.Errorf("packets = %q/%q", s.RxPackets, s.TxPackets) + } + // hostapd rate info is already in 100kbps units + if s.RxSpeed != 1560 || s.TxSpeed != 1733 { + t.Errorf("speed = %d/%d, want 1560/1733", s.RxSpeed, s.TxSpeed) + } +} + +func TestFilterAuthorized(t *testing.T) { + stas := []map[string]string{ + {"addr": "02:00:00:00:00:01", "flags": "[AUTH][ASSOC][AUTHORIZED]"}, + {"addr": "02:00:00:00:00:02", "flags": "[AUTH][ASSOC]"}, // mid-handshake + {"addr": "02:00:00:00:00:03", "flags": "[AUTH][ASSOC][AUTHORIZED][SHORT_PREAMBLE]"}, + } + + out := filterAuthorized(stas) + if len(out) != 2 { + t.Fatalf("got %d stations, want 2", len(out)) + } + if out[0]["addr"] != "02:00:00:00:00:01" || out[1]["addr"] != "02:00:00:00:00:03" { + t.Errorf("addrs = %q, %q", out[0]["addr"], out[1]["addr"]) + } +} + +func TestResolveSSIDHostapd(t *testing.T) { + // hostapd STATUS reports bss[N]= / ssid[N]= pairs; + // multi-BSS setups must resolve by interface name. + status := map[string]string{ + "state": "ENABLED", + "bss[0]": "wlan0", + "ssid[0]": "Lobby", + "bss[1]": "wlan0_1", + "ssid[1]": "Office", + } + + si := wpactrl.SocketInfo{Iface: "wlan0_1", Daemon: "hostapd"} + if got := resolveSSID("wlan0_1", si, status); got != "Office" { + t.Errorf("resolveSSID(wlan0_1) = %q, want Office", got) + } + si = wpactrl.SocketInfo{Iface: "wlan0", Daemon: "hostapd"} + if got := resolveSSID("wlan0", si, status); got != "Lobby" { + t.Errorf("resolveSSID(wlan0) = %q, want Lobby", got) + } + if got := resolveSSID("wlan9", si, status); got != "" { + t.Errorf("resolveSSID(wlan9) = %q, want empty", got) + } +} + +func TestParseBitrate(t *testing.T) { + cases := map[string]uint32{ + "1560 vhtmcs 8 vhtnss 2": 1560, // hostapd: 100kbps units + "866.7 MBit/s VHT-MCS 9": 8667, // iw: MBit/s -> 100kbps + "54.0 MBit/s": 540, + "": 0, + "garbage rate": 0, + } + for in, want := range cases { + if got := parseBitrate(in); got != want { + t.Errorf("parseBitrate(%q) = %d, want %d", in, got, want) + } + } +} diff --git a/src/yangerd/internal/wpactrl/allstations_test.go b/src/yangerd/internal/wpactrl/allstations_test.go new file mode 100644 index 00000000..459d762b --- /dev/null +++ b/src/yangerd/internal/wpactrl/allstations_test.go @@ -0,0 +1,142 @@ +package wpactrl + +import ( + "net" + "strings" + "testing" + "time" +) + +// fakeHostapd serves the hostapd control protocol for station +// enumeration: STA-FIRST returns the first station block, STA-NEXT +// the one after it, and an empty datagram past the last station. +func fakeHostapd(t *testing.T, stations []string) string { + t.Helper() + + dir := t.TempDir() + serverPath := dir + "/wlan0" + + serverAddr := &net.UnixAddr{Name: serverPath, Net: "unixgram"} + server, err := net.ListenUnixgram("unixgram", serverAddr) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { server.Close() }) + + addrOf := func(block string) string { + return strings.SplitN(block, "\n", 2)[0] + } + + go func() { + buf := make([]byte, 4096) + for { + n, raddr, err := server.ReadFromUnix(buf) + if err != nil { + return + } + cmd := string(buf[:n]) + + var resp string + switch { + case cmd == "STA-FIRST": + if len(stations) > 0 { + resp = stations[0] + } + case strings.HasPrefix(cmd, "STA-NEXT "): + prev := strings.TrimPrefix(cmd, "STA-NEXT ") + for i, st := range stations { + if addrOf(st) == prev && i+1 < len(stations) { + resp = stations[i+1] + break + } + } + default: + resp = "UNKNOWN COMMAND\n" + } + server.WriteToUnix([]byte(resp), raddr) + } + }() + + return serverPath +} + +func TestAllStations(t *testing.T) { + sta1 := "02:00:00:00:00:01\nflags=[AUTH][ASSOC][AUTHORIZED]\n" + + "signal=-57\nconnected_time=120\nrx_bytes=1000\ntx_bytes=2000\n" + sta2 := "02:00:00:00:00:02\nflags=[AUTH][ASSOC][AUTHORIZED]\n" + + "signal=-78\nconnected_time=60\nrx_bytes=300\ntx_bytes=400\n" + + path := fakeHostapd(t, []string{sta1, sta2}) + + conn, err := DialTimeout(path, 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + stas, err := conn.AllStations() + if err != nil { + t.Fatalf("AllStations: %v", err) + } + if len(stas) != 2 { + t.Fatalf("got %d stations, want 2", len(stas)) + } + if stas[0]["addr"] != "02:00:00:00:00:01" || stas[1]["addr"] != "02:00:00:00:00:02" { + t.Errorf("addrs = %q, %q", stas[0]["addr"], stas[1]["addr"]) + } + if stas[0]["signal"] != "-57" { + t.Errorf("sta[0] signal = %q", stas[0]["signal"]) + } + if stas[1]["connected_time"] != "60" { + t.Errorf("sta[1] connected_time = %q", stas[1]["connected_time"]) + } +} + +func TestAllStationsNone(t *testing.T) { + path := fakeHostapd(t, nil) + + conn, err := DialTimeout(path, 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + stas, err := conn.AllStations() + if err != nil { + t.Fatalf("AllStations: %v", err) + } + if len(stas) != 0 { + t.Fatalf("got %d stations, want 0", len(stas)) + } +} + +func TestAllStationsUnsupported(t *testing.T) { + dir := t.TempDir() + serverPath := dir + "/wlan0" + + serverAddr := &net.UnixAddr{Name: serverPath, Net: "unixgram"} + server, err := net.ListenUnixgram("unixgram", serverAddr) + if err != nil { + t.Fatalf("listen: %v", err) + } + defer server.Close() + + go func() { + buf := make([]byte, 4096) + n, raddr, err := server.ReadFromUnix(buf) + if err != nil || n == 0 { + return + } + server.WriteToUnix([]byte("UNKNOWN COMMAND\n"), raddr) + }() + + conn, err := DialTimeout(serverPath, 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + if _, err := conn.AllStations(); err == nil { + t.Fatal("expected error for UNKNOWN COMMAND") + } +}