yangerd: add missing files

This commit is contained in:
Mattias Walström
2026-06-27 08:41:22 +02:00
parent 127bbdfc4f
commit 8890274e90
5 changed files with 587 additions and 0 deletions
@@ -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])
}
}
}
+83
View File
@@ -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<ret>).
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<ret>. 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)
}
}
}
@@ -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<ret>
// 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")
}
}
+126
View File
@@ -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]=<ifname> / ssid[N]=<ssid> 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)
}
}
}
@@ -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 <addr>
// 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")
}
}