diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig index 5eb75719..8f5a9108 100644 --- a/configs/aarch64_defconfig +++ b/configs/aarch64_defconfig @@ -162,6 +162,7 @@ BR2_PACKAGE_CURIOS_HTTPD=y BR2_PACKAGE_CURIOS_NFTABLES=y BR2_PACKAGE_GENCERT=y BR2_PACKAGE_STATD=y +BR2_PACKAGE_YANGERD=y BR2_PACKAGE_FACTORY=y BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig index 006c8892..8c054a23 100644 --- a/configs/aarch64_minimal_defconfig +++ b/configs/aarch64_minimal_defconfig @@ -131,6 +131,7 @@ BR2_PACKAGE_NETD=y BR2_PACKAGE_CONFD_TEST_MODE=y BR2_PACKAGE_GENCERT=y BR2_PACKAGE_STATD=y +BR2_PACKAGE_YANGERD=y BR2_PACKAGE_FACTORY=y BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y diff --git a/configs/arm_defconfig b/configs/arm_defconfig index 6c21763b..3315ae7d 100644 --- a/configs/arm_defconfig +++ b/configs/arm_defconfig @@ -149,6 +149,7 @@ BR2_PACKAGE_NETD=y BR2_PACKAGE_CONFD_TEST_MODE=y BR2_PACKAGE_GENCERT=y BR2_PACKAGE_STATD=y +BR2_PACKAGE_YANGERD=y BR2_PACKAGE_FACTORY=y BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y diff --git a/configs/arm_minimal_defconfig b/configs/arm_minimal_defconfig index 1c0ca38a..fab98666 100644 --- a/configs/arm_minimal_defconfig +++ b/configs/arm_minimal_defconfig @@ -129,6 +129,7 @@ BR2_PACKAGE_NETD=y BR2_PACKAGE_CONFD_TEST_MODE=y BR2_PACKAGE_GENCERT=y BR2_PACKAGE_STATD=y +BR2_PACKAGE_YANGERD=y BR2_PACKAGE_FACTORY=y BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y diff --git a/configs/riscv64_defconfig b/configs/riscv64_defconfig index b687676f..fa400a51 100644 --- a/configs/riscv64_defconfig +++ b/configs/riscv64_defconfig @@ -181,6 +181,7 @@ BR2_PACKAGE_CONFD=y BR2_PACKAGE_NETD=y BR2_PACKAGE_GENCERT=y BR2_PACKAGE_STATD=y +BR2_PACKAGE_YANGERD=y BR2_PACKAGE_FACTORY=y BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig index 98e4fd27..581c0d64 100644 --- a/configs/x86_64_defconfig +++ b/configs/x86_64_defconfig @@ -156,6 +156,7 @@ BR2_PACKAGE_CURIOS_HTTPD=y BR2_PACKAGE_CURIOS_NFTABLES=y BR2_PACKAGE_GENCERT=y BR2_PACKAGE_STATD=y +BR2_PACKAGE_YANGERD=y BR2_PACKAGE_FACTORY=y BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig index 18e8f465..695cde52 100644 --- a/configs/x86_64_minimal_defconfig +++ b/configs/x86_64_minimal_defconfig @@ -128,6 +128,7 @@ BR2_PACKAGE_NETD=y BR2_PACKAGE_CONFD_TEST_MODE=y BR2_PACKAGE_GENCERT=y BR2_PACKAGE_STATD=y +BR2_PACKAGE_YANGERD=y BR2_PACKAGE_FACTORY=y BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y diff --git a/package/Config.in b/package/Config.in index 6998f580..86de30fa 100644 --- a/package/Config.in +++ b/package/Config.in @@ -13,6 +13,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/curios-httpd/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/curios-nftables/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/gencert/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/statd/Config.in" +source "$BR2_EXTERNAL_INFIX_PATH/package/yangerd/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/factory/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/faux/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/finit/Config.in" diff --git a/package/yangerd/Config.in b/package/yangerd/Config.in new file mode 100644 index 00000000..1720c401 --- /dev/null +++ b/package/yangerd/Config.in @@ -0,0 +1,7 @@ +config BR2_PACKAGE_YANGERD + bool "yangerd" + depends on BR2_PACKAGE_HOST_GO_TARGET_ARCH_SUPPORTS + help + Operational data daemon for YANG/NETCONF/RESTCONF. + Replaces Python yanger scripts with a persistent Go daemon + serving operational data over a Unix socket IPC protocol. diff --git a/package/yangerd/yangerd.conf b/package/yangerd/yangerd.conf new file mode 100644 index 00000000..8041dca5 --- /dev/null +++ b/package/yangerd/yangerd.conf @@ -0,0 +1,3 @@ +service name:yangerd log:prio:daemon.notice,tag:yangerd \ + env:-/etc/default/yangerd \ + [S12345] yangerd -- Operational data daemon diff --git a/package/yangerd/yangerd.mk b/package/yangerd/yangerd.mk new file mode 100644 index 00000000..5cf6019a --- /dev/null +++ b/package/yangerd/yangerd.mk @@ -0,0 +1,34 @@ +################################################################################ +# +# yangerd +# +################################################################################ + +YANGERD_VERSION = 1.0.0 +YANGERD_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/yangerd +YANGERD_SITE_METHOD = local +YANGERD_GOMOD = github.com/kernelkit/infix/src/yangerd +YANGERD_LICENSE = BSD-2-Clause +YANGERD_LICENSE_FILES = LICENSE +YANGERD_REDISTRIBUTE = NO + +YANGERD_BUILD_TARGETS = cmd/yangerd cmd/yangerctl +YANGERD_INSTALL_BINS = yangerd yangerctl + +define YANGERD_INSTALL_EXTRA + $(INSTALL) -D -m 0644 $(YANGERD_PKGDIR)/yangerd.conf \ + $(FINIT_D)/available/yangerd.conf + ln -sf ../available/yangerd.conf $(FINIT_D)/enabled/yangerd.conf + $(INSTALL) -d $(TARGET_DIR)/etc/default + echo '# yangerd build-time feature flags (generated by yangerd.mk)' \ + > $(TARGET_DIR)/etc/default/yangerd + echo 'YANGERD_ENABLE_WIFI=$(if $(BR2_PACKAGE_IW),true,false)' \ + >> $(TARGET_DIR)/etc/default/yangerd + echo 'YANGERD_ENABLE_CONTAINERS=$(if $(BR2_PACKAGE_PODMAN),true,false)' \ + >> $(TARGET_DIR)/etc/default/yangerd + echo 'YANGERD_ENABLE_GPS=$(if $(BR2_PACKAGE_GPSD),true,false)' \ + >> $(TARGET_DIR)/etc/default/yangerd +endef +YANGERD_POST_INSTALL_TARGET_HOOKS += YANGERD_INSTALL_EXTRA + +$(eval $(golang-package)) diff --git a/src/yangerd/.gitignore b/src/yangerd/.gitignore new file mode 100644 index 00000000..00713b02 --- /dev/null +++ b/src/yangerd/.gitignore @@ -0,0 +1,3 @@ +# Build artifacts +yangerd +yangerctl diff --git a/src/yangerd/LICENSE b/src/yangerd/LICENSE new file mode 100644 index 00000000..bf7aa8c9 --- /dev/null +++ b/src/yangerd/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2025 The KernelKit Authors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of copyright holders nor the names of + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/yangerd/go.mod b/src/yangerd/go.mod new file mode 100644 index 00000000..5894534d --- /dev/null +++ b/src/yangerd/go.mod @@ -0,0 +1,23 @@ +module github.com/kernelkit/infix/src/yangerd + +go 1.23.0 + +require ( + github.com/fsnotify/fsnotify v1.9.0 + github.com/godbus/dbus/v5 v5.2.2 + github.com/mdlayher/ethtool v0.4.1 + github.com/mdlayher/genetlink v1.3.2 + github.com/mdlayher/netlink v1.8.0 + github.com/osrg/gobgp/v3 v3.37.0 + github.com/vishvananda/netlink v1.3.1 +) + +require ( + github.com/google/go-cmp v0.7.0 // indirect + github.com/mdlayher/socket v0.5.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/vishvananda/netns v0.0.5 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.3.0 // indirect + golang.org/x/sys v0.35.0 // indirect +) diff --git a/src/yangerd/go.sum b/src/yangerd/go.sum new file mode 100644 index 00000000..d5acd9f1 --- /dev/null +++ b/src/yangerd/go.sum @@ -0,0 +1,46 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= +github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/mdlayher/ethtool v0.4.1 h1:BSUOGbnNNuPfGRFlKwtONPLJAQiLw8JdgS7E+SuN3/M= +github.com/mdlayher/ethtool v0.4.1/go.mod h1:9XP/78xTcyrfP8HvM0pI5ETaGHAuxrHRs+xkst29NlY= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM= +github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHid9HK3594= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/osrg/gobgp/v3 v3.37.0 h1:+ObuOdvj7G7nxrT0fKFta+EAupdWf/q1WzbXydr8IOY= +github.com/osrg/gobgp/v3 v3.37.0/go.mod h1:kVHVFy1/fyZHJ8P32+ctvPeJogn9qKwa1YCeMRXXrP0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= +github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/yangerd/internal/bridgebatch/bridgebatch.go b/src/yangerd/internal/bridgebatch/bridgebatch.go new file mode 100644 index 00000000..0ab85fb5 --- /dev/null +++ b/src/yangerd/internal/bridgebatch/bridgebatch.go @@ -0,0 +1,201 @@ +// Package bridgebatch manages a persistent `bridge -json -batch -` +// subprocess for querying bridge FDB, VLAN, MDB, and STP state. +// Identical design to ipbatch: mutex-serialized queries, dead/alive +// state management, and exponential backoff restart. +package bridgebatch + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math" + "os/exec" + "sync" + "sync/atomic" + "time" +) + +// ErrBatchDead is returned by Query when the subprocess is not running. +var ErrBatchDead = errors.New("bridge batch process is dead") + +const ( + canaryCommand = "vlan show" + + reconnectInitial = 100 * time.Millisecond + reconnectMax = 30 * time.Second + reconnectFactor = 2.0 +) + +// BridgeBatch wraps a persistent `bridge -json -batch -` subprocess. +type BridgeBatch struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout *bufio.Scanner + stderr io.ReadCloser + mu sync.Mutex + alive atomic.Bool + log *slog.Logger + ctx context.Context + cancel context.CancelFunc +} + +// New spawns the bridge batch subprocess. +func New(ctx context.Context, log *slog.Logger) (*BridgeBatch, error) { + ctx, cancel := context.WithCancel(ctx) + b := &BridgeBatch{ + log: log, + ctx: ctx, + cancel: cancel, + } + if err := b.start(); err != nil { + cancel() + return nil, err + } + go b.restartLoop() + return b, nil +} + +func (b *BridgeBatch) start() error { + cmd := exec.CommandContext(b.ctx, "bridge", "-json", "-batch", "-") + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("start bridge batch: %w", err) + } + b.mu.Lock() + b.cmd = cmd + b.stdin = stdin + b.stdout = bufio.NewScanner(stdout) + b.stdout.Buffer(make([]byte, 0, 4*1024*1024), 4*1024*1024) + b.stderr = stderr + b.alive.Store(true) + b.mu.Unlock() + go b.drainStderr() + return nil +} + +// Query sends a command to the bridge batch process and returns the +// JSON response. +func (b *BridgeBatch) Query(command string) (json.RawMessage, error) { + if !b.alive.Load() { + return nil, ErrBatchDead + } + b.mu.Lock() + defer b.mu.Unlock() + + if !b.alive.Load() { + return nil, ErrBatchDead + } + + if _, err := fmt.Fprintf(b.stdin, "%s\n", command); err != nil { + b.alive.Store(false) + return nil, fmt.Errorf("write command: %w", err) + } + if !b.stdout.Scan() { + b.alive.Store(false) + if err := b.stdout.Err(); err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + return nil, fmt.Errorf("bridge batch process exited") + } + raw := make([]byte, len(b.stdout.Bytes())) + copy(raw, b.stdout.Bytes()) + return json.RawMessage(raw), nil +} + +// Close terminates the subprocess and cancels the restart loop. +func (b *BridgeBatch) Close() { + b.cancel() + b.mu.Lock() + if b.stdin != nil { + b.stdin.Close() + } + if b.cmd != nil && b.cmd.Process != nil { + b.cmd.Process.Kill() + } + b.alive.Store(false) + b.mu.Unlock() +} + +// Status returns "running" or "restarting". +func (b *BridgeBatch) Status() string { + if b.alive.Load() { + return "running" + } + return "restarting" +} + +func (b *BridgeBatch) drainStderr() { + scanner := bufio.NewScanner(b.stderr) + for scanner.Scan() { + b.log.Warn("bridge batch stderr", "line", scanner.Text()) + } +} + +func (b *BridgeBatch) restartLoop() { + delay := reconnectInitial + for { + select { + case <-b.ctx.Done(): + return + default: + } + + if b.alive.Load() { + select { + case <-b.ctx.Done(): + return + case <-time.After(200 * time.Millisecond): + continue + } + } + + b.log.Info("bridge batch: subprocess died, restarting", "delay", delay) + select { + case <-b.ctx.Done(): + return + case <-time.After(delay): + } + + b.mu.Lock() + if b.cmd != nil && b.cmd.Process != nil { + b.cmd.Process.Kill() + b.cmd.Wait() + } + b.mu.Unlock() + + if err := b.start(); err != nil { + b.log.Warn("bridge batch: restart failed", "err", err) + delay = time.Duration(math.Min( + float64(delay)*reconnectFactor, + float64(reconnectMax))) + continue + } + + if _, err := b.Query(canaryCommand); err != nil { + b.log.Warn("bridge batch: canary query failed", "err", err) + b.alive.Store(false) + delay = time.Duration(math.Min( + float64(delay)*reconnectFactor, + float64(reconnectMax))) + continue + } + + b.log.Info("bridge batch: restarted successfully") + delay = reconnectInitial + } +} diff --git a/src/yangerd/internal/collector/boot.go b/src/yangerd/internal/collector/boot.go new file mode 100644 index 00000000..f7cf31b4 --- /dev/null +++ b/src/yangerd/internal/collector/boot.go @@ -0,0 +1,192 @@ +package collector + +import ( + "context" + "encoding/json" + "log" + "strconv" + "strings" +) + +func BootPlatform(fs FileReader) json.RawMessage { + data, err := fs.ReadFile("/etc/os-release") + if err != nil { + log.Printf("boot: os-release: %v", err) + return nil + } + platform := make(map[string]interface{}) + for _, line := range strings.Split(string(data), "\n") { + idx := strings.IndexByte(line, '=') + if idx < 0 { + continue + } + key := line[:idx] + val := strings.Trim(line[idx+1:], "\"") + if mapped, ok := platformKeyMap[key]; ok { + platform[mapped] = val + } + } + result, _ := json.Marshal(map[string]interface{}{"platform": platform}) + return result +} + +func BootSoftware(ctx context.Context, cmd CommandRunner) json.RawMessage { + software := make(map[string]interface{}) + + raucOut, err := cmd.Run(ctx, "rauc", "status", "--detailed", "--output-format=json") + if err == nil { + var raucData map[string]interface{} + if json.Unmarshal(raucOut, &raucData) == nil { + if v, ok := raucData["compatible"]; ok { + software["compatible"] = v + } + if v, ok := raucData["variant"]; ok { + software["variant"] = v + } + if v, ok := raucData["booted"]; ok { + software["booted"] = v + } + bootSoftwareSlots(software, raucData) + } + } + + bootOrder := ReadBootOrder(ctx, cmd) + if bootOrder != nil { + software["boot-order"] = bootOrder + } + + installer := make(map[string]interface{}) + instOut, err := cmd.Run(ctx, "rauc-installation-status") + if err == nil { + var instData map[string]interface{} + if json.Unmarshal(instOut, &instData) == nil { + if op, ok := instData["operation"]; ok && op != "" { + installer["operation"] = op + } + if prog, ok := instData["progress"].(map[string]interface{}); ok { + progress := make(map[string]interface{}) + if pct, ok := prog["percentage"]; ok { + progress["percentage"] = toInt(pct) + } + if msg, ok := prog["message"]; ok { + progress["message"] = msg + } + installer["progress"] = progress + } + } + } + software["installer"] = installer + + result, _ := json.Marshal(map[string]interface{}{"infix-system:software": software}) + return result +} + +func ReadBootOrder(ctx context.Context, cmd CommandRunner) []string { + out, err := cmd.Run(ctx, "fw_printenv", "BOOT_ORDER") + if err == nil { + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "BOOT_ORDER") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + return strings.Fields(parts[1]) + } + } + } + } + + out, err = cmd.Run(ctx, "grub-editenv", "/mnt/aux/grub/grubenv", "list") + if err == nil { + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "ORDER") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + return strings.Fields(strings.TrimSpace(parts[1])) + } + } + } + } + + return nil +} + +func bootSoftwareSlots(software map[string]interface{}, raucData map[string]interface{}) { + slotsRaw, ok := raucData["slots"] + if !ok { + return + } + slotsArr, ok := slotsRaw.([]interface{}) + if !ok { + return + } + + var slots []interface{} + for _, slotItem := range slotsArr { + slotMap, ok := slotItem.(map[string]interface{}) + if !ok { + continue + } + for name, valRaw := range slotMap { + val, ok := valRaw.(map[string]interface{}) + if !ok { + continue + } + s := map[string]interface{}{ + "name": name, + "bootname": val["bootname"], + "class": val["class"], + "state": val["state"], + } + + slotStatus, _ := val["slot_status"].(map[string]interface{}) + if slotStatus == nil { + slots = append(slots, s) + continue + } + + bundle := make(map[string]interface{}) + if b, ok := slotStatus["bundle"].(map[string]interface{}); ok { + if v := b["compatible"]; v != nil { + bundle["compatible"] = v + } + if v := b["version"]; v != nil { + bundle["version"] = v + } + } + s["bundle"] = bundle + + if ck, ok := slotStatus["checksum"].(map[string]interface{}); ok { + if v := ck["size"]; v != nil { + s["size"] = strconv.FormatInt(int64(toInt(v)), 10) + } + if v := ck["sha256"]; v != nil { + s["sha256"] = v + } + } + + installed := make(map[string]interface{}) + if inst, ok := slotStatus["installed"].(map[string]interface{}); ok { + if v := inst["timestamp"]; v != nil { + installed["datetime"] = v + } + if v := inst["count"]; v != nil { + installed["count"] = toInt(v) + } + } + s["installed"] = installed + + activated := make(map[string]interface{}) + if act, ok := slotStatus["activated"].(map[string]interface{}); ok { + if v := act["timestamp"]; v != nil { + activated["datetime"] = v + } + if v := act["count"]; v != nil { + activated["count"] = toInt(v) + } + } + s["activated"] = activated + + slots = append(slots, s) + } + } + software["slot"] = slots +} diff --git a/src/yangerd/internal/collector/boot_test.go b/src/yangerd/internal/collector/boot_test.go new file mode 100644 index 00000000..0ab2c3e5 --- /dev/null +++ b/src/yangerd/internal/collector/boot_test.go @@ -0,0 +1,257 @@ +package collector + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/kernelkit/infix/src/yangerd/internal/testutil" +) + +const ( + testOSRelease = `NAME="Infix" +VERSION_ID="25.01.0" +BUILD_ID="v25.01.0" +ARCHITECTURE="x86_64" +HOME_URL="https://kernelkit.github.io" +` + + testRaucStatus = `{ + "compatible": "Infix x86_64", + "variant": "", + "booted": "rootfs.0", + "slots": [ + { + "rootfs.0": { + "bootname": "A", + "class": "rootfs", + "state": "booted", + "slot_status": { + "bundle": { + "compatible": "Infix x86_64", + "version": "25.01.0" + }, + "checksum": { + "sha256": "abc123", + "size": 134217728 + }, + "installed": { + "timestamp": "2025-01-15T10:30:00Z", + "count": 3 + }, + "activated": { + "timestamp": "2025-01-15T10:31:00Z", + "count": 3 + } + } + } + }, + { + "rootfs.1": { + "bootname": "B", + "class": "rootfs", + "state": "inactive", + "slot_status": { + "bundle": { + "compatible": "Infix x86_64", + "version": "24.10.0" + }, + "checksum": { + "sha256": "def456", + "size": 130000000 + }, + "installed": { + "timestamp": "2024-10-01T08:00:00Z", + "count": 1 + }, + "activated": { + "timestamp": "2024-10-01T08:01:00Z", + "count": 1 + } + } + } + } + ] +}` + + testRaucInstallStatus = `{ + "operation": "idle", + "progress": { + "percentage": 100, + "message": "Installation complete" + } +}` + + testBootOrder = "BOOT_ORDER=A B\n" +) + +func TestBootPlatform(t *testing.T) { + fs := &testutil.MockFileReader{ + Files: map[string][]byte{ + "/etc/os-release": []byte(testOSRelease), + }, + } + + raw := BootPlatform(fs) + if raw == nil { + t.Fatal("BootPlatform returned nil") + } + + var result map[string]interface{} + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + platform, ok := result["platform"].(map[string]interface{}) + if !ok { + t.Fatal("missing platform key") + } + + checks := map[string]string{ + "os-name": "Infix", + "os-version": "25.01.0", + "os-release": "v25.01.0", + "machine": "x86_64", + } + for key, expected := range checks { + got, ok := platform[key].(string) + if !ok || got != expected { + t.Fatalf("platform[%q]: expected %q, got %v", key, expected, platform[key]) + } + } +} + +func TestBootPlatformMissingFile(t *testing.T) { + fs := &testutil.MockFileReader{Files: map[string][]byte{}} + raw := BootPlatform(fs) + if raw != nil { + t.Fatalf("expected nil for missing os-release, got %s", raw) + } +} + +func TestBootSoftware(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "rauc status --detailed --output-format=json": []byte(testRaucStatus), + "rauc-installation-status": []byte(testRaucInstallStatus), + "fw_printenv BOOT_ORDER": []byte(testBootOrder), + }, + Errors: map[string]error{}, + } + + raw := BootSoftware(context.Background(), runner) + if raw == nil { + t.Fatal("BootSoftware returned nil") + } + + var result map[string]interface{} + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + sw, ok := result["infix-system:software"].(map[string]interface{}) + if !ok { + t.Fatal("missing infix-system:software key") + } + + if sw["compatible"] != "Infix x86_64" { + t.Fatalf("compatible: expected 'Infix x86_64', got %v", sw["compatible"]) + } + if sw["booted"] != "rootfs.0" { + t.Fatalf("booted: expected 'rootfs.0', got %v", sw["booted"]) + } + + bootOrder, ok := sw["boot-order"].([]interface{}) + if !ok || len(bootOrder) != 2 { + t.Fatalf("expected boot-order [A B], got %v", sw["boot-order"]) + } + if bootOrder[0] != "A" || bootOrder[1] != "B" { + t.Fatalf("boot-order: expected [A B], got %v", bootOrder) + } + + slots, ok := sw["slot"].([]interface{}) + if !ok || len(slots) != 2 { + t.Fatalf("expected 2 slots, got %v", sw["slot"]) + } + + installer, ok := sw["installer"].(map[string]interface{}) + if !ok { + t.Fatal("missing installer") + } + if installer["operation"] != "idle" { + t.Fatalf("installer operation: expected 'idle', got %v", installer["operation"]) + } +} + +func TestBootSoftwareAllCommandsFail(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{}, + Errors: map[string]error{ + "rauc status --detailed --output-format=json": fmt.Errorf("not found"), + "rauc-installation-status": fmt.Errorf("not found"), + "fw_printenv BOOT_ORDER": fmt.Errorf("not found"), + "grub-editenv /mnt/aux/grub/grubenv list": fmt.Errorf("not found"), + }, + } + + raw := BootSoftware(context.Background(), runner) + if raw == nil { + t.Fatal("BootSoftware should return non-nil even when all commands fail") + } + + var result map[string]interface{} + json.Unmarshal(raw, &result) + sw := result["infix-system:software"].(map[string]interface{}) + if _, ok := sw["boot-order"]; ok { + t.Fatal("boot-order should not be present when commands fail") + } + if _, ok := sw["installer"]; !ok { + t.Fatal("installer key should always be present") + } +} + +func TestReadBootOrderFwPrintenv(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "fw_printenv BOOT_ORDER": []byte("BOOT_ORDER=A B\n"), + }, + Errors: map[string]error{}, + } + + order := ReadBootOrder(context.Background(), runner) + if len(order) != 2 || order[0] != "A" || order[1] != "B" { + t.Fatalf("expected [A B], got %v", order) + } +} + +func TestReadBootOrderGrubFallback(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "grub-editenv /mnt/aux/grub/grubenv list": []byte("ORDER=B A\n"), + }, + Errors: map[string]error{ + "fw_printenv BOOT_ORDER": fmt.Errorf("command not found"), + }, + } + + order := ReadBootOrder(context.Background(), runner) + if len(order) != 2 || order[0] != "B" || order[1] != "A" { + t.Fatalf("expected [B A], got %v", order) + } +} + +func TestReadBootOrderBothFail(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{}, + Errors: map[string]error{ + "fw_printenv BOOT_ORDER": fmt.Errorf("not found"), + "grub-editenv /mnt/aux/grub/grubenv list": fmt.Errorf("not found"), + }, + } + + order := ReadBootOrder(context.Background(), runner) + if order != nil { + t.Fatalf("expected nil, got %v", order) + } +} diff --git a/src/yangerd/internal/collector/collector.go b/src/yangerd/internal/collector/collector.go new file mode 100644 index 00000000..6c59e382 --- /dev/null +++ b/src/yangerd/internal/collector/collector.go @@ -0,0 +1,51 @@ +// Package collector defines the Collector interface and the RunAll +// scheduler that drives periodic data collection into the Tree. +package collector + +import ( + "context" + "log" + "sync" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +// Collector gathers operational data and writes it to the Tree. +type Collector interface { + Name() string + Interval() time.Duration + Collect(ctx context.Context, t *tree.Tree) error +} + +// RunAll starts one goroutine per Collector, each ticking at the +// collector's configured interval. A failed Collect is logged and +// retried on the next tick. All goroutines exit when ctx is cancelled. +func RunAll(ctx context.Context, wg *sync.WaitGroup, t *tree.Tree, collectors []Collector) { + for _, c := range collectors { + wg.Add(1) + go runOne(ctx, wg, t, c) + } +} + +func runOne(ctx context.Context, wg *sync.WaitGroup, t *tree.Tree, c Collector) { + defer wg.Done() + + if err := c.Collect(ctx, t); err != nil { + log.Printf("collector %s: initial: %v", c.Name(), err) + } + + ticker := time.NewTicker(c.Interval()) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := c.Collect(ctx, t); err != nil { + log.Printf("collector %s: %v", c.Name(), err) + } + } + } +} diff --git a/src/yangerd/internal/collector/containers.go b/src/yangerd/internal/collector/containers.go new file mode 100644 index 00000000..6461bbc0 --- /dev/null +++ b/src/yangerd/internal/collector/containers.go @@ -0,0 +1,466 @@ +package collector + +import ( + "context" + "encoding/json" + "fmt" + "log" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +var sizeRe = regexp.MustCompile(`(?i)^\s*([0-9.]+)\s*([KMGT]?I?B)?\s*$`) + +// ContainerCollector gathers infix-containers operational data. +type ContainerCollector struct { + cmd CommandRunner + fs FileReader + interval time.Duration +} + +// NewContainerCollector creates a ContainerCollector with the given dependencies. +func NewContainerCollector(cmd CommandRunner, fs FileReader, interval time.Duration) *ContainerCollector { + return &ContainerCollector{cmd: cmd, fs: fs, interval: interval} +} + +// Name implements Collector. +func (c *ContainerCollector) Name() string { return "containers" } + +// Interval implements Collector. +func (c *ContainerCollector) Interval() time.Duration { return c.interval } + +// Collect implements Collector. It produces one tree key: +// "infix-containers:containers". +func (c *ContainerCollector) Collect(ctx context.Context, t *tree.Tree) error { + containers := []interface{}{} + + psList := c.podmanPS(ctx) + for _, ps := range psList { + cont := c.container(ctx, ps) + if cont != nil { + containers = append(containers, cont) + } + } + + out := map[string]interface{}{ + "container": containers, + } + + if data, err := json.Marshal(out); err == nil { + t.Set("infix-containers:containers", data) + } + + return nil +} + +func (c *ContainerCollector) podmanPS(ctx context.Context) []map[string]interface{} { + out, err := c.cmd.Run(ctx, "podman", "ps", "-a", "--format=json") + if err != nil { + log.Printf("collector containers: ps: %v", err) + return nil + } + + var list []map[string]interface{} + if err := json.Unmarshal(out, &list); err == nil { + return list + } + + var generic []interface{} + if err := json.Unmarshal(out, &generic); err != nil { + log.Printf("collector containers: ps parse: %v", err) + return nil + } + + for _, item := range generic { + if m, ok := item.(map[string]interface{}); ok { + list = append(list, m) + } + } + + return list +} + +func (c *ContainerCollector) podmanInspect(ctx context.Context, name string) map[string]interface{} { + out, err := c.cmd.Run(ctx, "podman", "inspect", name) + if err != nil { + log.Printf("collector containers: inspect %s: %v", name, err) + return map[string]interface{}{} + } + + var list []map[string]interface{} + if err := json.Unmarshal(out, &list); err == nil && len(list) > 0 { + return list[0] + } + + var generic []interface{} + if err := json.Unmarshal(out, &generic); err == nil { + for _, item := range generic { + if m, ok := item.(map[string]interface{}); ok { + return m + } + } + } + + var single map[string]interface{} + if err := json.Unmarshal(out, &single); err == nil { + return single + } + + log.Printf("collector containers: inspect %s parse: invalid json", name) + return map[string]interface{}{} +} + +func (c *ContainerCollector) resourceStats(ctx context.Context, name string) map[string]interface{} { + out, err := c.cmd.Run(ctx, "podman", "stats", "--no-stream", "--format", "json", "--no-reset", name) + if err != nil { + log.Printf("collector containers: stats %s: %v", name, err) + return nil + } + + var statsList []map[string]interface{} + if err := json.Unmarshal(out, &statsList); err != nil { + var single map[string]interface{} + if err2 := json.Unmarshal(out, &single); err2 != nil { + log.Printf("collector containers: stats %s parse: %v", name, err) + return nil + } + statsList = append(statsList, single) + } + + if len(statsList) == 0 { + return nil + } + + stat := statsList[0] + rusage := make(map[string]interface{}) + + if memUsage, ok := stat["mem_usage"].(string); ok { + parts := strings.SplitN(memUsage, "/", 2) + if len(parts) == 2 { + memKiB := parseSizeKiB(strings.TrimSpace(parts[0])) + rusage["memory"] = strconv.Itoa(memKiB) + } + } + + if cpuPercent, ok := stat["cpu_percent"].(string); ok { + cpuPercent = strings.TrimSpace(strings.TrimSuffix(cpuPercent, "%")) + if cpuVal, err := strconv.ParseFloat(cpuPercent, 64); err == nil { + rusage["cpu"] = fmt.Sprintf("%.2f", cpuVal) + } + } + + if blockIO, ok := stat["block_io"].(string); ok { + parts := strings.SplitN(blockIO, "/", 2) + if len(parts) == 2 { + readKiB := parseSizeKiB(strings.TrimSpace(parts[0])) + writeKiB := parseSizeKiB(strings.TrimSpace(parts[1])) + + bio := make(map[string]interface{}) + if readKiB > 0 { + bio["read"] = strconv.Itoa(readKiB) + } + if writeKiB > 0 { + bio["write"] = strconv.Itoa(writeKiB) + } + rusage["block-io"] = bio + } + } + + if netIO, ok := stat["net_io"].(string); ok { + parts := strings.SplitN(netIO, "/", 2) + if len(parts) == 2 { + rxKiB := parseSizeKiB(strings.TrimSpace(parts[0])) + txKiB := parseSizeKiB(strings.TrimSpace(parts[1])) + + nio := make(map[string]interface{}) + if rxKiB > 0 { + nio["received"] = strconv.Itoa(rxKiB) + } + if txKiB > 0 { + nio["sent"] = strconv.Itoa(txKiB) + } + rusage["net-io"] = nio + } + } + + if pids, ok := stat["pids"]; ok { + pidInt := toInt(pids) + rusage["pids"] = pidInt + } + + if len(rusage) == 0 { + return nil + } + + return rusage +} + +func (c *ContainerCollector) readCgroupLimits(inspect map[string]interface{}) map[string]interface{} { + stateRaw, ok := inspect["State"] + if !ok { + return nil + } + state, ok := stateRaw.(map[string]interface{}) + if !ok { + return nil + } + + cgroupPath, ok := state["CgroupPath"].(string) + if !ok || cgroupPath == "" { + return nil + } + + cgroupBase := "/sys/fs/cgroup" + cgroupPath + memVal := 0 + cpuVal := 0 + + if data, err := c.fs.ReadFile(filepath.Join(cgroupBase, "memory.max")); err == nil { + memVal = parseCgroupMemory(strings.TrimSpace(string(data))) + } + + if data, err := c.fs.ReadFile(filepath.Join(cgroupBase, "cpu.max")); err == nil { + cpuVal = parseCgroupCPU(strings.TrimSpace(string(data))) + } + + if memVal <= 0 && cpuVal <= 0 { + return nil + } + + result := make(map[string]interface{}) + if memVal > 0 { + result["memory"] = strconv.Itoa(memVal) + } + if cpuVal > 0 { + result["cpu"] = cpuVal + } + + return result +} + +func (c *ContainerCollector) network(ps map[string]interface{}, inspect map[string]interface{}) map[string]interface{} { + networkSettingsRaw, hasNetworkSettings := inspect["NetworkSettings"] + if hasNetworkSettings { + if networkSettings, ok := networkSettingsRaw.(map[string]interface{}); ok { + if networksRaw, ok := networkSettings["Networks"]; ok { + if networks, ok := networksRaw.(map[string]interface{}); ok { + if _, ok := networks["host"]; ok { + return map[string]interface{}{"host": true} + } + } + } + } + } + + net := map[string]interface{}{ + "interface": []interface{}{}, + "publish": []interface{}{}, + } + + networks := asStringSlice(ps["Networks"]) + ifaces := net["interface"].([]interface{}) + for _, n := range networks { + ifaces = append(ifaces, map[string]interface{}{"name": n}) + } + net["interface"] = ifaces + + running := strings.EqualFold(asString(ps["State"]), "running") + if !running { + return net + } + + portsRaw, ok := ps["Ports"] + if !ok { + return net + } + + ports, ok := portsRaw.([]interface{}) + if !ok || len(ports) == 0 { + return net + } + + publish := net["publish"].([]interface{}) + for _, portRaw := range ports { + port, ok := portRaw.(map[string]interface{}) + if !ok { + continue + } + + hostIP := asString(port["host_ip"]) + hostPort := asString(port["host_port"]) + if hostPort == "" { + hostPort = strconv.Itoa(toInt(port["host_port"])) + } + containerPort := asString(port["container_port"]) + if containerPort == "" { + containerPort = strconv.Itoa(toInt(port["container_port"])) + } + protocol := asString(port["protocol"]) + + if hostPort == "0" || hostPort == "" || containerPort == "0" || containerPort == "" || protocol == "" { + continue + } + + addr := "" + if hostIP != "" { + addr = hostIP + ":" + } + + publish = append(publish, fmt.Sprintf("%s%s:%s/%s", addr, hostPort, containerPort, protocol)) + } + net["publish"] = publish + + return net +} + +func (c *ContainerCollector) container(ctx context.Context, ps map[string]interface{}) map[string]interface{} { + names := asStringSlice(ps["Names"]) + if len(names) == 0 { + return nil + } + + name := names[0] + running := strings.EqualFold(asString(ps["State"]), "running") + + out := map[string]interface{}{ + "name": name, + "id": asString(ps["Id"]), + "image": asString(ps["Image"]), + "image-id": asString(ps["ImageID"]), + "running": running, + "status": asString(ps["Status"]), + } + + cmd := strings.Join(asStringSlice(ps["Command"]), " ") + if cmd != "" { + out["command"] = cmd + } + + inspect := c.podmanInspect(ctx, name) + if net := c.network(ps, inspect); len(net) > 0 { + out["network"] = net + } + + if limits := c.readCgroupLimits(inspect); limits != nil { + out["resource-limit"] = limits + } + + if running { + if usage := c.resourceStats(ctx, name); usage != nil { + out["resource-usage"] = usage + } + } + + return out +} + +func asString(v interface{}) string { + s, ok := v.(string) + if ok { + return s + } + return "" +} + +func asStringSlice(v interface{}) []string { + switch vv := v.(type) { + case []string: + return vv + case []interface{}: + out := make([]string, 0, len(vv)) + for _, e := range vv { + if s, ok := e.(string); ok && s != "" { + out = append(out, s) + } + } + return out + case string: + if vv == "" { + return nil + } + return splitLines(vv) + default: + return nil + } +} + +func parseSizeKiB(sizeStr string) int { + if strings.TrimSpace(sizeStr) == "" { + return 0 + } + + m := sizeRe.FindStringSubmatch(strings.ToUpper(strings.TrimSpace(sizeStr))) + if len(m) < 2 { + return 0 + } + + value, err := strconv.ParseFloat(m[1], 64) + if err != nil { + return 0 + } + + unit := "B" + if len(m) >= 3 && m[2] != "" { + unit = strings.ToUpper(m[2]) + } + + multipliers := map[string]float64{ + "B": 1.0 / 1024.0, + "KB": 1000.0 / 1024.0, + "KIB": 1, + "MB": (1000.0 * 1000.0) / 1024.0, + "MIB": 1024, + "GB": (1000.0 * 1000.0 * 1000.0) / 1024.0, + "GIB": 1024 * 1024, + "TB": (1000.0 * 1000.0 * 1000.0 * 1000.0) / 1024.0, + "TIB": 1024 * 1024 * 1024, + } + + mult, ok := multipliers[unit] + if !ok { + mult = 1 + } + + return int(value * mult) +} + +func parseCgroupMemory(memStr string) int { + memStr = strings.TrimSpace(memStr) + if memStr == "" || memStr == "max" { + return 0 + } + + memBytes, err := strconv.ParseUint(memStr, 10, 64) + if err != nil { + return 0 + } + + return int(memBytes / 1024) +} + +func parseCgroupCPU(cpuStr string) int { + cpuStr = strings.TrimSpace(cpuStr) + if cpuStr == "" { + return 0 + } + + parts := strings.Fields(cpuStr) + if len(parts) != 2 || parts[0] == "max" { + return 0 + } + + quota, err := strconv.Atoi(parts[0]) + if err != nil { + return 0 + } + period, err := strconv.Atoi(parts[1]) + if err != nil || period == 0 { + return 0 + } + + return (quota * 1000) / period +} diff --git a/src/yangerd/internal/collector/containers_test.go b/src/yangerd/internal/collector/containers_test.go new file mode 100644 index 00000000..e867e27b --- /dev/null +++ b/src/yangerd/internal/collector/containers_test.go @@ -0,0 +1,445 @@ +package collector + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/testutil" + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +func collectContainers(t *testing.T, runner *testutil.MockRunner, fs *testutil.MockFileReader) map[string]interface{} { + t.Helper() + + c := NewContainerCollector(runner, fs, 30*time.Second) + tr := tree.New() + if err := c.Collect(context.Background(), tr); err != nil { + t.Fatalf("Collect failed: %v", err) + } + + raw := tr.Get("infix-containers:containers") + if raw == nil { + t.Fatal("missing infix-containers:containers in tree") + } + + out := make(map[string]interface{}) + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal containers: %v", err) + } + + return out +} + +func containerList(t *testing.T, data map[string]interface{}) []interface{} { + t.Helper() + + containers, ok := data["container"].([]interface{}) + if !ok { + t.Fatalf("missing container list: %v", data) + } + + return containers +} + +func TestContainerBasicInfo(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "podman ps -a --format=json": []byte(`[ + { + "Names": ["web"], + "Id": "abc123", + "Image": "docker.io/library/nginx:latest", + "ImageID": "sha256:image", + "State": "running", + "Status": "Up 2 hours", + "Command": ["nginx", "-g", "daemon off;"], + "Networks": ["podman0"], + "Ports": [] + } + ]`), + "podman inspect web": []byte(`[{}]`), + "podman stats --no-stream --format json --no-reset web": []byte(`[]`), + }, + Errors: map[string]error{}, + } + + fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}} + + out := collectContainers(t, runner, fs) + containers := containerList(t, out) + if len(containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(containers)) + } + + c := containers[0].(map[string]interface{}) + if c["name"] != "web" { + t.Fatalf("name: expected web, got %v", c["name"]) + } + if c["id"] != "abc123" { + t.Fatalf("id: expected abc123, got %v", c["id"]) + } + if c["image"] != "docker.io/library/nginx:latest" { + t.Fatalf("image mismatch: %v", c["image"]) + } + if c["status"] != "Up 2 hours" { + t.Fatalf("status mismatch: %v", c["status"]) + } + if c["command"] != "nginx -g daemon off;" { + t.Fatalf("command mismatch: %v", c["command"]) + } + if c["running"] != true { + t.Fatalf("running expected true, got %v", c["running"]) + } +} + +func TestContainerHostNetwork(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "podman ps -a --format=json": []byte(`[ + { + "Names": ["hostnet"], + "Id": "id1", + "Image": "img", + "ImageID": "sha256:1", + "State": "running", + "Status": "Up", + "Command": ["sleep", "60"], + "Networks": ["podman0"], + "Ports": [{"host_ip":"", "host_port":8080, "container_port":80, "protocol":"tcp"}] + } + ]`), + "podman inspect hostnet": []byte(`[{"NetworkSettings":{"Networks":{"host":{}}}}]`), + "podman stats --no-stream --format json --no-reset hostnet": []byte(`[]`), + }, + Errors: map[string]error{}, + } + + fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}} + out := collectContainers(t, runner, fs) + + c := containerList(t, out)[0].(map[string]interface{}) + net, ok := c["network"].(map[string]interface{}) + if !ok { + t.Fatalf("missing network: %v", c) + } + if net["host"] != true { + t.Fatalf("expected host network true, got %v", net["host"]) + } +} + +func TestContainerBridgeNetwork(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "podman ps -a --format=json": []byte(`[ + { + "Names": ["bridge"], + "Id": "id2", + "Image": "img", + "ImageID": "sha256:2", + "State": "running", + "Status": "Up", + "Command": ["app"], + "Networks": ["podman0", "br0"], + "Ports": [ + {"host_ip":"127.0.0.1", "host_port":8080, "container_port":80, "protocol":"tcp"}, + {"host_ip":"", "host_port":8443, "container_port":443, "protocol":"tcp"} + ] + } + ]`), + "podman inspect bridge": []byte(`[{"NetworkSettings":{"Networks":{"bridge":{}}}}]`), + "podman stats --no-stream --format json --no-reset bridge": []byte(`[]`), + }, + Errors: map[string]error{}, + } + + fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}} + out := collectContainers(t, runner, fs) + + c := containerList(t, out)[0].(map[string]interface{}) + net := c["network"].(map[string]interface{}) + + ifaces := net["interface"].([]interface{}) + if len(ifaces) != 2 { + t.Fatalf("expected 2 interfaces, got %d", len(ifaces)) + } + if ifaces[0].(map[string]interface{})["name"] != "podman0" { + t.Fatalf("interface[0] mismatch: %v", ifaces[0]) + } + + publish := net["publish"].([]interface{}) + if len(publish) != 2 { + t.Fatalf("expected 2 published ports, got %d", len(publish)) + } + if publish[0] != "127.0.0.1:8080:80/tcp" { + t.Fatalf("publish[0] mismatch: %v", publish[0]) + } + if publish[1] != "8443:443/tcp" { + t.Fatalf("publish[1] mismatch: %v", publish[1]) + } +} + +func TestContainerCgroupLimits(t *testing.T) { + cgroupPath := "/machine.slice/libpod-abc.scope" + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "podman ps -a --format=json": []byte(`[ + {"Names":["limited"],"Id":"id3","Image":"img","ImageID":"sha256:3","State":"exited","Status":"Exited","Command":["app"],"Networks":[],"Ports":[]} + ]`), + "podman inspect limited": []byte(fmt.Sprintf(`[{"State":{"CgroupPath":%q},"NetworkSettings":{"Networks":{"bridge":{}}}}]`, cgroupPath)), + }, + Errors: map[string]error{}, + } + + fs := &testutil.MockFileReader{ + Files: map[string][]byte{ + "/sys/fs/cgroup/machine.slice/libpod-abc.scope/memory.max": []byte("1073741824\n"), + "/sys/fs/cgroup/machine.slice/libpod-abc.scope/cpu.max": []byte("200000 100000\n"), + }, + Globs: map[string][]string{}, + } + + out := collectContainers(t, runner, fs) + c := containerList(t, out)[0].(map[string]interface{}) + + limit, ok := c["resource-limit"].(map[string]interface{}) + if !ok { + t.Fatalf("missing resource-limit: %v", c) + } + if limit["memory"] != "1048576" { + t.Fatalf("memory limit expected 1048576, got %v", limit["memory"]) + } + if toInt(limit["cpu"]) != 2000 { + t.Fatalf("cpu limit expected 2000, got %v", limit["cpu"]) + } +} + +func TestContainerCgroupUnlimited(t *testing.T) { + cgroupPath := "/machine.slice/libpod-unlimited.scope" + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "podman ps -a --format=json": []byte(`[ + {"Names":["nolimit"],"Id":"id4","Image":"img","ImageID":"sha256:4","State":"exited","Status":"Exited","Command":["app"],"Networks":[],"Ports":[]} + ]`), + "podman inspect nolimit": []byte(fmt.Sprintf(`[{"State":{"CgroupPath":%q},"NetworkSettings":{"Networks":{"bridge":{}}}}]`, cgroupPath)), + }, + Errors: map[string]error{}, + } + + fs := &testutil.MockFileReader{ + Files: map[string][]byte{ + "/sys/fs/cgroup/machine.slice/libpod-unlimited.scope/memory.max": []byte("max\n"), + "/sys/fs/cgroup/machine.slice/libpod-unlimited.scope/cpu.max": []byte("max 100000\n"), + }, + Globs: map[string][]string{}, + } + + out := collectContainers(t, runner, fs) + c := containerList(t, out)[0].(map[string]interface{}) + if _, ok := c["resource-limit"]; ok { + t.Fatalf("resource-limit should be omitted for unlimited cgroup values: %v", c["resource-limit"]) + } +} + +func TestContainerResourceStats(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "podman ps -a --format=json": []byte(`[ + { + "Names":["stats"],"Id":"id5","Image":"img","ImageID":"sha256:5", + "State":"running","Status":"Up","Command":["app"],"Networks":["podman0"],"Ports":[] + } + ]`), + "podman inspect stats": []byte(`[{"NetworkSettings":{"Networks":{"bridge":{}}}}]`), + "podman stats --no-stream --format json --no-reset stats": []byte(`[ + { + "mem_usage":"123.4MB / 1.5GB", + "cpu_percent":"12.34%", + "block_io":"1.2MB / 3.4GB", + "net_io":"1.2MB / 3.4GB", + "pids":5 + } + ]`), + }, + Errors: map[string]error{}, + } + + fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}} + out := collectContainers(t, runner, fs) + + c := containerList(t, out)[0].(map[string]interface{}) + usage, ok := c["resource-usage"].(map[string]interface{}) + if !ok { + t.Fatalf("missing resource-usage: %v", c) + } + + if usage["memory"] != "120507" { + t.Fatalf("memory usage expected 120507, got %v", usage["memory"]) + } + if usage["cpu"] != "12.34" { + t.Fatalf("cpu usage expected 12.34, got %v", usage["cpu"]) + } + bio := usage["block-io"].(map[string]interface{}) + if bio["read"] != "1171" { + t.Fatalf("block-io read expected 1171, got %v", bio["read"]) + } + if bio["write"] != "3320312" { + t.Fatalf("block-io write expected 3320312, got %v", bio["write"]) + } + nio := usage["net-io"].(map[string]interface{}) + if nio["received"] != "1171" { + t.Fatalf("net-io received expected 1171, got %v", nio["received"]) + } + if nio["sent"] != "3320312" { + t.Fatalf("net-io sent expected 3320312, got %v", nio["sent"]) + } + if toInt(usage["pids"]) != 5 { + t.Fatalf("pids expected 5, got %v", usage["pids"]) + } +} + +func TestContainerStopped(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "podman ps -a --format=json": []byte(`[ + { + "Names":["stopped"],"Id":"id6","Image":"img","ImageID":"sha256:6", + "State":"exited","Status":"Exited (0)","Command":["app"], + "Networks":["podman0"],"Ports":[{"host_ip":"","host_port":8080,"container_port":80,"protocol":"tcp"}] + } + ]`), + "podman inspect stopped": []byte(`[{"NetworkSettings":{"Networks":{"bridge":{}}}}]`), + }, + Errors: map[string]error{}, + } + + fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}} + out := collectContainers(t, runner, fs) + + c := containerList(t, out)[0].(map[string]interface{}) + if _, ok := c["resource-usage"]; ok { + t.Fatalf("stopped container must not include resource-usage: %v", c["resource-usage"]) + } + + net := c["network"].(map[string]interface{}) + publish := net["publish"].([]interface{}) + if len(publish) != 0 { + t.Fatalf("stopped container must not include published ports, got %v", publish) + } +} + +func TestContainerMultiple(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "podman ps -a --format=json": []byte(`[ + {"Names":["one"],"Id":"id7","Image":"img1","ImageID":"sha256:7","State":"running","Status":"Up","Command":["a"],"Networks":[],"Ports":[]}, + {"Names":["two"],"Id":"id8","Image":"img2","ImageID":"sha256:8","State":"exited","Status":"Exited","Command":["b"],"Networks":[],"Ports":[]} + ]`), + "podman inspect one": []byte(`[{}]`), + "podman stats --no-stream --format json --no-reset one": []byte(`[]`), + "podman inspect two": []byte(`[{}]`), + }, + Errors: map[string]error{}, + } + + fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}} + out := collectContainers(t, runner, fs) + + containers := containerList(t, out) + if len(containers) != 2 { + t.Fatalf("expected 2 containers, got %d", len(containers)) + } + if containers[0].(map[string]interface{})["name"] != "one" { + t.Fatalf("first container name mismatch: %v", containers[0]) + } + if containers[1].(map[string]interface{})["name"] != "two" { + t.Fatalf("second container name mismatch: %v", containers[1]) + } +} + +func TestParseSizeKiB(t *testing.T) { + tests := []struct { + name string + input string + expected int + }{ + {name: "mb", input: "1.5MB", expected: 1464}, + {name: "kb", input: "512kB", expected: 500}, + {name: "gib", input: "2GiB", expected: 2097152}, + {name: "mib", input: "64MiB", expected: 65536}, + {name: "bytes", input: "2048B", expected: 2}, + {name: "empty", input: "", expected: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseSizeKiB(tt.input) + if got != tt.expected { + t.Errorf("parseSizeKiB(%q): expected %d, got %d", tt.input, tt.expected, got) + } + }) + } +} + +func TestParseCgroupMemory(t *testing.T) { + tests := []struct { + name string + input string + expected int + }{ + {name: "max", input: "max", expected: 0}, + {name: "bytes", input: "1073741824", expected: 1048576}, + {name: "empty", input: "", expected: 0}, + {name: "invalid", input: "abc", expected: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseCgroupMemory(tt.input) + if got != tt.expected { + t.Errorf("parseCgroupMemory(%q): expected %d, got %d", tt.input, tt.expected, got) + } + }) + } +} + +func TestParseCgroupCPU(t *testing.T) { + tests := []struct { + name string + input string + expected int + }{ + {name: "max", input: "max 100000", expected: 0}, + {name: "limited", input: "50000 100000", expected: 500}, + {name: "empty", input: "", expected: 0}, + {name: "invalid", input: "abc", expected: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseCgroupCPU(tt.input) + if got != tt.expected { + t.Errorf("parseCgroupCPU(%q): expected %d, got %d", tt.input, tt.expected, got) + } + }) + } +} + +func TestContainerGracefulDegradation(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{}, + Errors: map[string]error{ + "podman ps -a --format=json": fmt.Errorf("podman not found"), + }, + } + + fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}} + out := collectContainers(t, runner, fs) + + containers := containerList(t, out) + if len(containers) != 0 { + t.Fatalf("expected no containers when podman ps fails, got %d", len(containers)) + } +} diff --git a/src/yangerd/internal/collector/hardware.go b/src/yangerd/internal/collector/hardware.go new file mode 100644 index 00000000..61ab62d5 --- /dev/null +++ b/src/yangerd/internal/collector/hardware.go @@ -0,0 +1,1125 @@ +package collector + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +var hwSensorTypeRe = regexp.MustCompile(`.*_(phy|sfp|fan|temp|sensor|psu|cpu|gpu|memory|disk)\d*$`) +var hwSensorSuffixExtractRe = regexp.MustCompile(`.*_((?:phy|sfp|fan|temp|sensor|psu|cpu|gpu|memory|disk)\d*)$`) +var hwTrailingNumUnderscoreRe = regexp.MustCompile(`_(\d+)$`) +var hwPhyNumRe = regexp.MustCompile(`(\d+)$`) + +// HardwareCollector gathers ietf-hardware operational data. +type HardwareCollector struct { + cmd CommandRunner + fs FileReader + interval time.Duration + + enableWifi bool + enableGPS bool +} + +// NewHardwareCollector creates a HardwareCollector with the given dependencies. +func NewHardwareCollector(cmd CommandRunner, fs FileReader, interval time.Duration, enableWifi, enableGPS bool) *HardwareCollector { + return &HardwareCollector{ + cmd: cmd, + fs: fs, + interval: interval, + enableWifi: enableWifi, + enableGPS: enableGPS, + } +} + +// Name implements Collector. +func (c *HardwareCollector) Name() string { return "hardware" } + +// Interval implements Collector. +func (c *HardwareCollector) Interval() time.Duration { return c.interval } + +// Collect implements Collector. It produces one tree key: +// "ietf-hardware:hardware". +func (c *HardwareCollector) Collect(ctx context.Context, t *tree.Tree) error { + systemjson := c.readSystemJSON() + + components := make([]interface{}, 0) + components = append(components, c.motherboard_component(systemjson)...) + components = append(components, c.vpd_components(systemjson)...) + components = append(components, c.usb_port_components(systemjson)...) + components = append(components, c.hwmon_sensor_components(ctx)...) + components = append(components, c.thermal_sensor_components(ctx)...) + + if c.enableWifi { + components = append(components, c.wifi_radio_components(ctx)...) + } + if c.enableGPS { + components = append(components, c.gps_receiver_components(ctx)...) + } + + hardware := map[string]interface{}{ + "component": components, + } + + if data, err := json.Marshal(hardware); err == nil { + t.Set("ietf-hardware:hardware", data) + } + + return nil +} + +func (c *HardwareCollector) readSystemJSON() map[string]interface{} { + data, err := c.fs.ReadFile("/run/system.json") + if err != nil { + return map[string]interface{}{} + } + + out := make(map[string]interface{}) + if err := json.Unmarshal(data, &out); err != nil { + log.Printf("collector hardware: system.json: %v", err) + return map[string]interface{}{} + } + + return out +} + +func (c *HardwareCollector) motherboard_component(systemjson map[string]interface{}) []interface{} { + if len(systemjson) == 0 { + return nil + } + + component := map[string]interface{}{ + "name": "mainboard", + "class": "iana-hardware:chassis", + "state": map[string]interface{}{ + "admin-state": "unknown", + "oper-state": "enabled", + }, + } + + if v, ok := systemjson["vendor"].(string); ok && v != "" { + component["mfg-name"] = v + } + if v, ok := systemjson["product-name"].(string); ok && v != "" { + component["model-name"] = v + } + if v, ok := systemjson["serial-number"].(string); ok && v != "" { + component["serial-num"] = v + } + if v, ok := systemjson["part-number"].(string); ok && v != "" { + component["hardware-rev"] = v + } + if v, ok := systemjson["mac-address"].(string); ok && v != "" { + component["infix-hardware:phys-address"] = v + } + + return []interface{}{component} +} + +func vpd_vendor_extensions(data interface{}) []interface{} { + raw, ok := data.([]interface{}) + if !ok { + return nil + } + + vendorExtensions := make([]interface{}, 0, len(raw)) + for _, item := range raw { + pair, ok := item.([]interface{}) + if !ok || len(pair) < 2 { + continue + } + vendorExtensions = append(vendorExtensions, map[string]interface{}{ + "iana-enterprise-number": pair[0], + "extension-data": pair[1], + }) + } + + return vendorExtensions +} + +func (c *HardwareCollector) vpd_components(systemjson map[string]interface{}) []interface{} { + vpdRaw, ok := systemjson["vpd"].(map[string]interface{}) + if !ok { + return nil + } + + components := make([]interface{}, 0, len(vpdRaw)) + for _, vpdItemRaw := range vpdRaw { + vpdItem, ok := vpdItemRaw.(map[string]interface{}) + if !ok { + continue + } + + component := map[string]interface{}{ + "class": "infix-hardware:vpd", + "infix-hardware:vpd-data": map[string]interface{}{}, + } + + if board, ok := vpdItem["board"].(string); ok && board != "" { + component["name"] = board + } + + dataRaw, ok := vpdItem["data"].(map[string]interface{}) + if ok { + if mfgDateStr, ok := dataRaw["manufacture-date"].(string); ok && mfgDateStr != "" { + if mfgDate, err := time.Parse("01/02/2006 15:04:05", mfgDateStr); err == nil { + component["mfg-date"] = mfgDate.UTC().Format("2006-01-02T15:04:05Z") + } + } + + if mfg, ok := dataRaw["manufacturer"].(string); ok && mfg != "" { + component["mfg-name"] = mfg + } + if model, ok := dataRaw["product-name"].(string); ok && model != "" { + component["model-name"] = model + } + if serial, ok := dataRaw["serial-number"].(string); ok && serial != "" { + component["serial-num"] = serial + } + + vpdData, ok := component["infix-hardware:vpd-data"].(map[string]interface{}) + if !ok { + vpdData = make(map[string]interface{}) + component["infix-hardware:vpd-data"] = vpdData + } + for key, val := range dataRaw { + if val == nil { + continue + } + if key == "vendor-extension" { + if ext := vpd_vendor_extensions(val); len(ext) > 0 { + vpdData["infix-hardware:vendor-extension"] = ext + } + continue + } + vpdData[key] = val + } + } + + if _, ok := component["name"]; ok { + components = append(components, component) + } + } + + return components +} + +func (c *HardwareCollector) usb_port_components(systemjson map[string]interface{}) []interface{} { + usbPortsRaw, ok := systemjson["usb-ports"].([]interface{}) + if !ok { + return nil + } + + components := make([]interface{}, 0, len(usbPortsRaw)) + for _, usbPortRaw := range usbPortsRaw { + usbPort, ok := usbPortRaw.(map[string]interface{}) + if !ok { + continue + } + + name, ok := usbPort["name"].(string) + if !ok || name == "" { + continue + } + path, ok := usbPort["path"].(string) + if !ok || path == "" { + continue + } + + authorizedDefault, err := c.fs.ReadFile(path + "/authorized_default") + if err != nil { + continue + } + + state := "locked" + if strings.TrimSpace(string(authorizedDefault)) == "1" { + state = "unlocked" + } + + components = append(components, map[string]interface{}{ + "name": name, + "class": "infix-hardware:usb", + "state": map[string]interface{}{ + "admin-state": state, + "oper-state": "enabled", + }, + }) + } + + return components +} + +func normalize_sensor_name(name string) string { + name = strings.TrimSuffix(name, "-thermal") + name = strings.TrimSuffix(name, "_thermal") + + if m := hwSensorSuffixExtractRe.FindStringSubmatch(name); len(m) > 1 { + name = m[1] + } + + name = hwTrailingNumUnderscoreRe.ReplaceAllString(name, "$1") + return name +} + +func humanizeSensorLabel(label string) string { + if label == "" { + return "" + } + parts := strings.Fields(strings.ReplaceAll(label, "_", " ")) + out := make([]string, 0, len(parts)) + for _, part := range parts { + if part == strings.ToUpper(part) { + out = append(out, part) + continue + } + r := []rune(strings.ToLower(part)) + if len(r) == 0 { + continue + } + r[0] = []rune(strings.ToUpper(string(r[0])))[0] + out = append(out, string(r)) + } + return strings.Join(out, " ") +} + +func sensorComponent(name string, value int, valueType, valueScale, label string) map[string]interface{} { + component := map[string]interface{}{ + "name": name, + "class": "iana-hardware:sensor", + "sensor-data": map[string]interface{}{ + "value": value, + "value-type": valueType, + "value-scale": valueScale, + "value-precision": 0, + "value-timestamp": yangDateTime(time.Now()), + "oper-status": "ok", + }, + } + + if d := humanizeSensorLabel(label); d != "" { + component["description"] = d + } + + return component +} + +func (c *HardwareCollector) listDir(ctx context.Context, dir string) ([]string, error) { + out, err := c.cmd.Run(ctx, "ls", dir) + if err != nil { + return nil, err + } + return splitLines(string(out)), nil +} + +func (c *HardwareCollector) readSensorString(path string) (string, bool) { + data, err := c.fs.ReadFile(path) + if err != nil { + return "", false + } + return strings.TrimSpace(string(data)), true +} + +func (c *HardwareCollector) readSensorInt(path string) (int, bool) { + data, err := c.fs.ReadFile(path) + if err != nil { + return 0, false + } + v, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + return 0, false + } + return v, true +} + +func sensorName(baseName, sensorNum string) string { + if sensorNum == "1" || sensorNum == "0" { + return baseName + } + return baseName + sensorNum +} + +func (c *HardwareCollector) get_wifi_phy_info(ctx context.Context) map[string]map[string]interface{} { + phyInfo := make(map[string]map[string]interface{}) + + listOut, err := c.cmd.Run(ctx, "/usr/libexec/infix/iw.py", "list") + if err != nil { + return phyInfo + } + + var phys []interface{} + if err := json.Unmarshal(listOut, &phys); err != nil { + return phyInfo + } + + for _, phyRaw := range phys { + phy, ok := phyRaw.(string) + if !ok || phy == "" { + continue + } + phyInfo[phy] = map[string]interface{}{ + "band": "Unknown", + "iface": "", + "description": "WiFi Radio", + } + } + + phyNumToName := make(map[string]string) + for phyName := range phyInfo { + m := hwPhyNumRe.FindStringSubmatch(phyName) + if len(m) > 1 { + phyNumToName[m[1]] = phyName + } + } + + devOut, err := c.cmd.Run(ctx, "/usr/libexec/infix/iw.py", "dev") + if err == nil { + var devMap map[string]interface{} + if json.Unmarshal(devOut, &devMap) == nil { + for phyNum, ifacesRaw := range devMap { + phyName, ok := phyNumToName[phyNum] + if !ok { + continue + } + ifaces, ok := ifacesRaw.([]interface{}) + if !ok || len(ifaces) == 0 { + continue + } + iface, ok := ifaces[0].(string) + if !ok { + continue + } + if entry, ok := phyInfo[phyName]; ok { + entry["iface"] = iface + } + } + } + } + + for phy, info := range phyInfo { + band := strDefault(info["band"], "Unknown") + iface := strDefault(info["iface"], "") + switch { + case iface != "" && band != "Unknown": + info["description"] = "WiFi Radio " + phy + case band != "Unknown": + info["description"] = "WiFi Radio (" + band + ")" + case iface != "": + info["description"] = "WiFi Radio " + phy + default: + info["description"] = "WiFi Radio" + } + } + + return phyInfo +} + +func (c *HardwareCollector) hwmon_sensor_components(ctx context.Context) []interface{} { + components := make([]interface{}, 0) + deviceSensors := make(map[string][]map[string]interface{}) + + hwmonEntries, err := c.listDir(ctx, "/sys/class/hwmon") + if err != nil { + return components + } + + for _, entry := range hwmonEntries { + if !strings.HasPrefix(entry, "hwmon") { + continue + } + hwmonPath := "/sys/class/hwmon/" + entry + + deviceName, ok := c.readSensorString(hwmonPath + "/name") + if !ok || deviceName == "" { + continue + } + if devName, ok := c.readSensorString(hwmonPath + "/device/name"); ok && devName != "" { + deviceName = devName + } + + baseName := normalize_sensor_name(deviceName) + if baseName == "" { + continue + } + + entries, err := c.listDir(ctx, hwmonPath) + if err != nil { + continue + } + + fanFiles := make([]string, 0) + for _, e := range entries { + if strings.HasPrefix(e, "fan") && strings.HasSuffix(e, "_input") { + fanFiles = append(fanFiles, e) + } + } + + for _, e := range entries { + if !strings.HasPrefix(e, "temp") || !strings.HasSuffix(e, "_input") { + continue + } + sensorNum := strings.TrimPrefix(strings.SplitN(e, "_", 2)[0], "temp") + value, ok := c.readSensorInt(hwmonPath + "/" + e) + if !ok { + continue + } + label := "" + sensor := "" + if rawLabel, ok := c.readSensorString(fmt.Sprintf("%s/temp%s_label", hwmonPath, sensorNum)); ok { + label = rawLabel + sensor = baseName + "-" + normalize_sensor_name(rawLabel) + } else { + sensor = sensorName(baseName, sensorNum) + } + deviceSensors[baseName] = append(deviceSensors[baseName], sensorComponent(sensor, value, "celsius", "milli", label)) + } + + for _, e := range fanFiles { + sensorNum := strings.TrimPrefix(strings.SplitN(e, "_", 2)[0], "fan") + value, ok := c.readSensorInt(hwmonPath + "/" + e) + if !ok { + continue + } + label := "" + sensor := "" + if rawLabel, ok := c.readSensorString(fmt.Sprintf("%s/fan%s_label", hwmonPath, sensorNum)); ok { + label = rawLabel + sensor = baseName + "-" + normalize_sensor_name(rawLabel) + } else { + sensor = sensorName(baseName, sensorNum) + } + deviceSensors[baseName] = append(deviceSensors[baseName], sensorComponent(sensor, value, "rpm", "units", label)) + } + + if len(fanFiles) == 0 { + for _, e := range entries { + if !strings.HasPrefix(e, "pwm") { + continue + } + n := strings.TrimPrefix(e, "pwm") + if _, err := strconv.Atoi(n); err != nil { + continue + } + pwmRaw, ok := c.readSensorInt(hwmonPath + "/" + e) + if !ok { + continue + } + sensorNum := n + value := int((float64(pwmRaw) / 255.0) * 100.0 * 1000.0) + label := "PWM Fan" + sensor := "" + if rawLabel, ok := c.readSensorString(fmt.Sprintf("%s/pwm%s_label", hwmonPath, sensorNum)); ok { + label = rawLabel + sensor = baseName + "-" + normalize_sensor_name(rawLabel) + } else { + sensor = sensorName(baseName, sensorNum) + } + deviceSensors[baseName] = append(deviceSensors[baseName], sensorComponent(sensor, value, "other", "milli", label)) + } + } + + for _, e := range entries { + if !strings.HasPrefix(e, "in") || !strings.HasSuffix(e, "_input") { + continue + } + sensorNum := strings.TrimPrefix(strings.SplitN(e, "_", 2)[0], "in") + value, ok := c.readSensorInt(hwmonPath + "/" + e) + if !ok { + continue + } + label := "voltage" + sensor := "" + if rawLabel, ok := c.readSensorString(fmt.Sprintf("%s/in%s_label", hwmonPath, sensorNum)); ok { + label = rawLabel + sensor = baseName + "-" + normalize_sensor_name(rawLabel) + } else { + if sensorNum == "0" { + sensor = baseName + "-voltage" + } else { + sensor = baseName + "-voltage" + sensorNum + } + } + deviceSensors[baseName] = append(deviceSensors[baseName], sensorComponent(sensor, value, "volts-DC", "milli", label)) + } + + for _, e := range entries { + if !strings.HasPrefix(e, "curr") || !strings.HasSuffix(e, "_input") { + continue + } + sensorNum := strings.TrimPrefix(strings.SplitN(e, "_", 2)[0], "curr") + value, ok := c.readSensorInt(hwmonPath + "/" + e) + if !ok { + continue + } + label := "current" + sensor := "" + if rawLabel, ok := c.readSensorString(fmt.Sprintf("%s/curr%s_label", hwmonPath, sensorNum)); ok { + label = rawLabel + sensor = baseName + "-" + normalize_sensor_name(rawLabel) + } else { + if sensorNum == "1" { + sensor = baseName + "-current" + } else { + sensor = baseName + "-current" + sensorNum + } + } + deviceSensors[baseName] = append(deviceSensors[baseName], sensorComponent(sensor, value, "amperes", "milli", label)) + } + + for _, e := range entries { + if !strings.HasPrefix(e, "power") || !strings.HasSuffix(e, "_input") { + continue + } + sensorNum := strings.TrimPrefix(strings.SplitN(e, "_", 2)[0], "power") + value, ok := c.readSensorInt(hwmonPath + "/" + e) + if !ok { + continue + } + label := "power" + sensor := "" + if rawLabel, ok := c.readSensorString(fmt.Sprintf("%s/power%s_label", hwmonPath, sensorNum)); ok { + label = rawLabel + sensor = baseName + "-" + normalize_sensor_name(rawLabel) + } else { + if sensorNum == "1" { + sensor = baseName + "-power" + } else { + sensor = baseName + "-power" + sensorNum + } + } + deviceSensors[baseName] = append(deviceSensors[baseName], sensorComponent(sensor, value, "watts", "micro", label)) + } + } + + for baseName, sensors := range deviceSensors { + if len(sensors) > 1 { + components = append(components, map[string]interface{}{ + "name": baseName, + "class": "iana-hardware:module", + }) + for _, sensor := range sensors { + sensor["parent"] = baseName + components = append(components, sensor) + } + continue + } + for _, sensor := range sensors { + components = append(components, sensor) + } + } + + wifiInfo := c.get_wifi_phy_info(ctx) + for _, componentRaw := range components { + component, ok := componentRaw.(map[string]interface{}) + if !ok { + continue + } + name, ok := component["name"].(string) + if !ok { + continue + } + if strings.HasPrefix(name, "radio") { + if phy, ok := wifiInfo[name]; ok { + if desc, ok := phy["description"].(string); ok && desc != "" { + component["description"] = desc + } + } + } + } + + return components +} + +func (c *HardwareCollector) thermal_sensor_components(ctx context.Context) []interface{} { + components := make([]interface{}, 0) + + entries, err := c.listDir(ctx, "/sys/class/thermal") + if err != nil { + return components + } + + for _, entry := range entries { + if !strings.HasPrefix(entry, "thermal_zone") { + continue + } + zonePath := "/sys/class/thermal/" + entry + zoneType, ok := c.readSensorString(zonePath + "/type") + if !ok || zoneType == "" { + continue + } + temp, ok := c.readSensorInt(zonePath + "/temp") + if !ok { + continue + } + + components = append(components, sensorComponent(normalize_sensor_name(zoneType), temp, "celsius", "milli", "")) + } + + return components +} + +func (c *HardwareCollector) get_survey_data(ctx context.Context, ifname string) []interface{} { + if ifname == "" { + return nil + } + out, err := c.cmd.Run(ctx, "/usr/libexec/infix/iw.py", "survey", ifname) + if err != nil { + return nil + } + + var survey []interface{} + if err := json.Unmarshal(out, &survey); err != nil { + return nil + } + + channels := make([]interface{}, 0, len(survey)) + for _, entryRaw := range survey { + entry, ok := entryRaw.(map[string]interface{}) + if !ok { + continue + } + channel := map[string]interface{}{ + "frequency": entry["frequency"], + "in-use": entry["in_use"], + } + setIfPresent(channel, "noise", entry, "noise") + setIfPresent(channel, "active-time", entry, "active_time") + setIfPresent(channel, "busy-time", entry, "busy_time") + setIfPresent(channel, "receive-time", entry, "receive_time") + setIfPresent(channel, "transmit-time", entry, "transmit_time") + channels = append(channels, channel) + } + + return channels +} + +func (c *HardwareCollector) get_phy_info(ctx context.Context, phyName string) map[string]interface{} { + out, err := c.cmd.Run(ctx, "/usr/libexec/infix/iw.py", "info", phyName) + if err != nil { + return map[string]interface{}{} + } + + var phyInfo map[string]interface{} + if err := json.Unmarshal(out, &phyInfo); err != nil { + return map[string]interface{}{} + } + return phyInfo +} + +func convert_iw_phy_info_for_yanger(phyInfo map[string]interface{}) map[string]interface{} { + result := map[string]interface{}{ + "bands": []interface{}{}, + "driver": nil, + "manufacturer": "Unknown", + "max-interfaces": map[string]interface{}{}, + } + + bandsRaw, _ := phyInfo["bands"].([]interface{}) + bands := make([]interface{}, 0, len(bandsRaw)) + for _, bandRaw := range bandsRaw { + band, ok := bandRaw.(map[string]interface{}) + if !ok { + continue + } + bandData := map[string]interface{}{ + "band": strconv.Itoa(toInt(band["band"])), + "name": strDefault(band["name"], "Unknown"), + } + if v, ok := band["ht_capable"].(bool); ok && v { + bandData["ht-capable"] = true + } + if v, ok := band["vht_capable"].(bool); ok && v { + bandData["vht-capable"] = true + } + if v, ok := band["he_capable"].(bool); ok && v { + bandData["he-capable"] = true + } + bands = append(bands, bandData) + } + result["bands"] = bands + + if driver, ok := phyInfo["driver"].(string); ok && driver != "" { + result["driver"] = driver + } + if manufacturer, ok := phyInfo["manufacturer"].(string); ok && manufacturer != "" { + result["manufacturer"] = manufacturer + } + + maxInterfaces := make(map[string]interface{}) + ifCombRaw, _ := phyInfo["interface_combinations"].([]interface{}) + for _, combRaw := range ifCombRaw { + comb, ok := combRaw.(map[string]interface{}) + if !ok { + continue + } + limitsRaw, _ := comb["limits"].([]interface{}) + for _, limitRaw := range limitsRaw { + limit, ok := limitRaw.(map[string]interface{}) + if !ok { + continue + } + typesRaw, _ := limit["types"].([]interface{}) + hasAP := false + for _, t := range typesRaw { + if s, ok := t.(string); ok && s == "AP" { + hasAP = true + break + } + } + if !hasAP { + continue + } + apMax := toInt(limit["max"]) + if cur, ok := maxInterfaces["ap"]; !ok || apMax > toInt(cur) { + maxInterfaces["ap"] = apMax + } + } + } + result["max-interfaces"] = maxInterfaces + + return result +} + +func channelFromFrequency(freq int) (int, bool) { + switch { + case freq >= 2412 && freq <= 2484: + return (freq - 2407) / 5, true + case freq >= 5170 && freq <= 5825: + return (freq - 5000) / 5, true + case freq >= 5955 && freq <= 7115: + return (freq - 5950) / 5, true + default: + return 0, false + } +} + +func (c *HardwareCollector) wifi_radio_components(ctx context.Context) []interface{} { + components := make([]interface{}, 0) + wifiInfo := c.get_wifi_phy_info(ctx) + + for phyName, phyData := range wifiInfo { + component := map[string]interface{}{ + "name": phyName, + "class": "infix-hardware:wifi", + "description": strDefault(phyData["description"], "WiFi Radio"), + } + + wifiRadioData := make(map[string]interface{}) + iwInfo := c.get_phy_info(ctx, phyName) + phyDetails := convert_iw_phy_info_for_yanger(iwInfo) + + if manufacturer := strDefault(phyDetails["manufacturer"], "Unknown"); manufacturer != "Unknown" { + component["mfg-name"] = manufacturer + } + + if bands, ok := phyDetails["bands"].([]interface{}); ok && len(bands) > 0 { + wifiRadioData["bands"] = bands + } + if driver := strDefault(phyDetails["driver"], ""); driver != "" { + wifiRadioData["driver"] = driver + } + if maxIf, ok := phyDetails["max-interfaces"].(map[string]interface{}); ok && len(maxIf) > 0 { + wifiRadioData["max-interfaces"] = maxIf + } + + setIfPresent(wifiRadioData, "max-txpower", iwInfo, "max_txpower") + + supportedChannelsMap := make(map[int]bool) + bandsRaw, _ := iwInfo["bands"].([]interface{}) + for _, bandRaw := range bandsRaw { + band, ok := bandRaw.(map[string]interface{}) + if !ok { + continue + } + freqsRaw, _ := band["frequencies"].([]interface{}) + for _, freqRaw := range freqsRaw { + freq := toInt(freqRaw) + if channel, ok := channelFromFrequency(freq); ok { + supportedChannelsMap[channel] = true + } + } + } + if len(supportedChannelsMap) > 0 { + supported := make([]int, 0, len(supportedChannelsMap)) + for ch := range supportedChannelsMap { + supported = append(supported, ch) + } + sort.Ints(supported) + supportedIface := make([]interface{}, 0, len(supported)) + for _, ch := range supported { + supportedIface = append(supportedIface, ch) + } + wifiRadioData["supported-channels"] = supportedIface + } + + wifiRadioData["num-virtual-interfaces"] = toInt(iwInfo["num_virtual_interfaces"]) + + iface := strDefault(phyData["iface"], "") + if channels := c.get_survey_data(ctx, iface); len(channels) > 0 { + wifiRadioData["survey"] = map[string]interface{}{ + "channel": channels, + } + } + + if len(wifiRadioData) > 0 { + component["infix-hardware:wifi-radio"] = wifiRadioData + } + + components = append(components, component) + } + + return components +} + +func gpsd_poll(ctx context.Context) map[string]interface{} { + dialer := &net.Dialer{Timeout: 500 * time.Millisecond} + conn, err := dialer.DialContext(ctx, "tcp", "127.0.0.1:2947") + if err != nil { + return map[string]interface{}{} + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(500 * time.Millisecond)) + + reader := bufio.NewReader(conn) + _, _ = reader.ReadBytes('\n') + + if _, err := conn.Write([]byte("?WATCH={\"enable\":true,\"json\":true};\n?POLL;\n")); err != nil { + return map[string]interface{}{} + } + + buf := bytes.Buffer{} + for i := 0; i < 5; i++ { + chunk := make([]byte, 4096) + n, err := conn.Read(chunk) + if err != nil || n == 0 { + break + } + buf.Write(chunk[:n]) + for _, line := range splitLines(buf.String()) { + var msg map[string]interface{} + if json.Unmarshal([]byte(line), &msg) != nil { + continue + } + if cls, ok := msg["class"].(string); ok && cls == "POLL" { + return msg + } + } + } + + return map[string]interface{}{} +} + +func countUsedSatellites(sats []interface{}) int { + used := 0 + for _, satRaw := range sats { + sat, ok := satRaw.(map[string]interface{}) + if !ok { + continue + } + if v, ok := sat["used"].(bool); ok && v { + used++ + } + } + return used +} + +func (c *HardwareCollector) gps_receiver_components(ctx context.Context) []interface{} { + components := make([]interface{}, 0) + gpsDevices := make(map[string]map[string]string) + + for i := 0; i < 4; i++ { + devPath := fmt.Sprintf("/dev/gps%d", i) + if _, err := c.cmd.Run(ctx, "ls", devPath); err != nil { + continue + } + actual, err := c.cmd.Run(ctx, "readlink", "-f", devPath) + if err != nil { + continue + } + actualPath := strings.TrimSpace(string(actual)) + if actualPath == "" { + continue + } + gpsDevices[actualPath] = map[string]string{ + "name": fmt.Sprintf("gps%d", i), + "symlink": devPath, + } + } + + if len(gpsDevices) == 0 { + return components + } + + poll := gpsd_poll(ctx) + active := toInt(poll["active"]) + + tpvByDev := make(map[string]map[string]interface{}) + tpvRaw, _ := poll["tpv"].([]interface{}) + for _, itemRaw := range tpvRaw { + item, ok := itemRaw.(map[string]interface{}) + if !ok { + continue + } + dev, _ := item["device"].(string) + if dev != "" { + tpvByDev[dev] = item + } + } + + skyByDev := make(map[string]map[string]interface{}) + skyRaw, _ := poll["sky"].([]interface{}) + for _, itemRaw := range skyRaw { + item, ok := itemRaw.(map[string]interface{}) + if !ok { + continue + } + dev, _ := item["device"].(string) + if dev != "" { + skyByDev[dev] = item + } + } + + for actualPath, dev := range gpsDevices { + name := dev["name"] + symlink := dev["symlink"] + + component := map[string]interface{}{ + "name": name, + "class": "infix-hardware:gps", + "description": "GPS/GNSS Receiver", + } + + gpsData := make(map[string]interface{}) + gpsData["device"] = symlink + + tpv := tpvByDev[actualPath] + if tpv == nil { + tpv = tpvByDev[symlink] + } + if tpv == nil && len(tpvByDev) == 1 { + for _, v := range tpvByDev { + tpv = v + } + } + + sky := skyByDev[actualPath] + if sky == nil { + sky = skyByDev[symlink] + } + if sky == nil && len(skyByDev) == 1 { + for _, v := range skyByDev { + sky = v + } + } + + gpsData["activated"] = active > 0 && len(tpv) > 0 + + if driver, ok := tpv["driver"].(string); ok && driver != "" { + gpsData["driver"] = driver + } + + switch toInt(tpv["mode"]) { + case 2: + gpsData["fix-mode"] = "2d" + case 3: + gpsData["fix-mode"] = "3d" + default: + gpsData["fix-mode"] = "none" + } + + if lat, ok := tpv["lat"]; ok { + gpsData["latitude"] = fmt.Sprintf("%.6f", toFloat64(lat)) + } + if lon, ok := tpv["lon"]; ok { + gpsData["longitude"] = fmt.Sprintf("%.6f", toFloat64(lon)) + } + if alt, ok := tpv["altHAE"]; ok { + gpsData["altitude"] = fmt.Sprintf("%.1f", toFloat64(alt)) + } + + satVis := 0 + satUsed := 0 + if sky != nil { + sats, _ := sky["satellites"].([]interface{}) + if len(sats) > 0 { + satVis = len(sats) + satUsed = countUsedSatellites(sats) + } + if satVis == 0 { + satVis = toInt(zeroIfNil(sky["nSat"])) + if satVis == 0 { + satVis = toInt(zeroIfNil(sky["satellites_visible"])) + } + } + if satUsed == 0 { + satUsed = toInt(zeroIfNil(sky["uSat"])) + if satUsed == 0 { + satUsed = toInt(zeroIfNil(sky["satellites_used"])) + } + } + } + + if satVis == 0 { + satVis = toInt(zeroIfNil(tpv["nSat"])) + if satVis == 0 { + satVis = toInt(zeroIfNil(tpv["satellites_visible"])) + } + } + if satUsed == 0 { + satUsed = toInt(zeroIfNil(tpv["uSat"])) + if satUsed == 0 { + satUsed = toInt(zeroIfNil(tpv["satellites_used"])) + } + } + + if satUsed > satVis { + satVis = satUsed + } + gpsData["satellites-visible"] = satVis + gpsData["satellites-used"] = satUsed + + ppsPath := fmt.Sprintf("/dev/pps%s", strings.TrimPrefix(name, "gps")) + if _, err := c.cmd.Run(ctx, "ls", ppsPath); err == nil { + gpsData["pps-available"] = true + } else { + gpsData["pps-available"] = false + } + + component["infix-hardware:gps-receiver"] = gpsData + components = append(components, component) + } + + return components +} + +func toFloat64(v interface{}) float64 { + switch n := v.(type) { + case float64: + return n + case float32: + return float64(n) + case int: + return float64(n) + case int64: + return float64(n) + case json.Number: + f, _ := n.Float64() + return f + case string: + f, _ := strconv.ParseFloat(n, 64) + return f + default: + return 0 + } +} diff --git a/src/yangerd/internal/collector/hardware_test.go b/src/yangerd/internal/collector/hardware_test.go new file mode 100644 index 00000000..deec42f3 --- /dev/null +++ b/src/yangerd/internal/collector/hardware_test.go @@ -0,0 +1,398 @@ +package collector + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/testutil" + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +func collectHardware(t *testing.T, c *HardwareCollector) []interface{} { + t.Helper() + + tr := tree.New() + if err := c.Collect(context.Background(), tr); err != nil { + t.Fatalf("Collect failed: %v", err) + } + + raw := tr.Get("ietf-hardware:hardware") + if raw == nil { + t.Fatal("missing ietf-hardware:hardware in tree") + } + + var out map[string]interface{} + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal hardware: %v", err) + } + + components, ok := out["component"].([]interface{}) + if !ok { + t.Fatalf("component list missing or invalid: %v", out["component"]) + } + + return components +} + +func getComponentByName(components []interface{}, name string) map[string]interface{} { + for _, c := range components { + m, ok := c.(map[string]interface{}) + if !ok { + continue + } + if m["name"] == name { + return m + } + } + return nil +} + +func containsComponentWithClass(components []interface{}, class string) bool { + for _, c := range components { + m, ok := c.(map[string]interface{}) + if !ok { + continue + } + if m["class"] == class { + return true + } + } + return false +} + +func newHardwareCollector(r *testutil.MockRunner, fs *testutil.MockFileReader) *HardwareCollector { + return NewHardwareCollector(r, fs, 30*time.Second, false, false) +} + +func TestHardwareMotherboard(t *testing.T) { + runner := &testutil.MockRunner{Results: map[string][]byte{}, Errors: map[string]error{}} + fs := &testutil.MockFileReader{Files: map[string][]byte{ + "/run/system.json": []byte(`{"vendor":"Acme","product-name":"Router-1","serial-number":"SN123","part-number":"PN99","mac-address":"00:11:22:33:44:55"}`), + }, Globs: map[string][]string{}} + + components := collectHardware(t, newHardwareCollector(runner, fs)) + mb := getComponentByName(components, "mainboard") + if mb == nil { + t.Fatal("mainboard component not found") + } + + if mb["class"] != "iana-hardware:chassis" { + t.Fatalf("mainboard class: expected chassis, got %v", mb["class"]) + } + if mb["mfg-name"] != "Acme" || mb["model-name"] != "Router-1" || mb["serial-num"] != "SN123" { + t.Fatalf("mainboard identity fields mismatch: %v", mb) + } + if mb["hardware-rev"] != "PN99" { + t.Fatalf("mainboard hardware-rev mismatch: %v", mb["hardware-rev"]) + } + if mb["infix-hardware:phys-address"] != "00:11:22:33:44:55" { + t.Fatalf("mainboard phys-address mismatch: %v", mb["infix-hardware:phys-address"]) + } + + state, ok := mb["state"].(map[string]interface{}) + if !ok { + t.Fatalf("mainboard state missing: %v", mb["state"]) + } + if state["admin-state"] != "unknown" || state["oper-state"] != "enabled" { + t.Fatalf("mainboard state mismatch: %v", state) + } +} + +func TestHardwareVPD(t *testing.T) { + runner := &testutil.MockRunner{Results: map[string][]byte{}, Errors: map[string]error{}} + fs := &testutil.MockFileReader{Files: map[string][]byte{ + "/run/system.json": []byte(`{ + "vpd": { + "slot0": { + "board": "board0", + "data": { + "manufacture-date": "04/11/2026 13:14:15", + "manufacturer": "VPD Inc", + "product-name": "X1", + "serial-number": "VPD-123", + "foo": "bar", + "vendor-extension": [[32473, "aa55"]] + } + } + } + }`), + }, Globs: map[string][]string{}} + + components := collectHardware(t, newHardwareCollector(runner, fs)) + vpd := getComponentByName(components, "board0") + if vpd == nil { + t.Fatal("vpd component board0 not found") + } + if vpd["class"] != "infix-hardware:vpd" { + t.Fatalf("vpd class mismatch: %v", vpd["class"]) + } + if vpd["mfg-date"] != "2026-04-11T13:14:15Z" { + t.Fatalf("mfg-date mismatch: %v", vpd["mfg-date"]) + } + if vpd["serial-num"] != "VPD-123" { + t.Fatalf("serial-num mismatch: %v", vpd["serial-num"]) + } + + vpdData, ok := vpd["infix-hardware:vpd-data"].(map[string]interface{}) + if !ok { + t.Fatalf("vpd-data missing: %v", vpd["infix-hardware:vpd-data"]) + } + if vpdData["foo"] != "bar" { + t.Fatalf("vpd-data foo mismatch: %v", vpdData["foo"]) + } + extList, ok := vpdData["infix-hardware:vendor-extension"].([]interface{}) + if !ok || len(extList) != 1 { + t.Fatalf("vendor-extension missing: %v", vpdData["infix-hardware:vendor-extension"]) + } + ext := extList[0].(map[string]interface{}) + if toInt(ext["iana-enterprise-number"]) != 32473 || ext["extension-data"] != "aa55" { + t.Fatalf("vendor-extension mismatch: %v", ext) + } +} + +func TestHardwareUSBPorts(t *testing.T) { + runner := &testutil.MockRunner{Results: map[string][]byte{}, Errors: map[string]error{}} + fs := &testutil.MockFileReader{Files: map[string][]byte{ + "/run/system.json": []byte(`{"usb-ports":[{"name":"usb-a","path":"/sys/devices/usb-a"},{"name":"usb-b","path":"/sys/devices/usb-b"}]}`), + "/sys/devices/usb-a/authorized_default": []byte("1\n"), + "/sys/devices/usb-b/authorized_default": []byte("0\n"), + }, Globs: map[string][]string{}} + + components := collectHardware(t, newHardwareCollector(runner, fs)) + usbA := getComponentByName(components, "usb-a") + usbB := getComponentByName(components, "usb-b") + if usbA == nil || usbB == nil { + t.Fatalf("usb components missing: usb-a=%v usb-b=%v", usbA, usbB) + } + aState := usbA["state"].(map[string]interface{}) + bState := usbB["state"].(map[string]interface{}) + if aState["admin-state"] != "unlocked" || bState["admin-state"] != "locked" { + t.Fatalf("usb admin-state mismatch: a=%v b=%v", aState, bState) + } +} + +func TestHardwareHwmonTemp(t *testing.T) { + runner := &testutil.MockRunner{Results: map[string][]byte{ + "ls /sys/class/hwmon": []byte("hwmon0\n"), + "ls /sys/class/hwmon/hwmon0": []byte("name\ntemp1_input\ntemp1_label\n"), + }, Errors: map[string]error{}} + fs := &testutil.MockFileReader{Files: map[string][]byte{ + "/run/system.json": []byte(`{}`), + "/sys/class/hwmon/hwmon0/name": []byte("cpu_thermal\n"), + "/sys/class/hwmon/hwmon0/temp1_input": []byte("42000\n"), + "/sys/class/hwmon/hwmon0/temp1_label": []byte("cpu_temp\n"), + }, Globs: map[string][]string{}} + + components := collectHardware(t, newHardwareCollector(runner, fs)) + if !containsComponentWithClass(components, "iana-hardware:sensor") { + t.Fatalf("expected at least one sensor component: %v", components) + } + sensor := getComponentByName(components, "cpu-temp") + if sensor == nil { + t.Fatalf("expected temp sensor cpu-temp, got: %v", components) + } + sd := sensor["sensor-data"].(map[string]interface{}) + if toInt(sd["value"]) != 42000 || sd["value-type"] != "celsius" || sd["value-scale"] != "milli" { + t.Fatalf("temp sensor-data mismatch: %v", sd) + } +} + +func TestHardwareHwmonFan(t *testing.T) { + runner := &testutil.MockRunner{Results: map[string][]byte{ + "ls /sys/class/hwmon": []byte("hwmon1\n"), + "ls /sys/class/hwmon/hwmon1": []byte("name\nfan1_input\n"), + }, Errors: map[string]error{}} + fs := &testutil.MockFileReader{Files: map[string][]byte{ + "/run/system.json": []byte(`{}`), + "/sys/class/hwmon/hwmon1/name": []byte("pwmfan\n"), + "/sys/class/hwmon/hwmon1/fan1_input": []byte("3200\n"), + }, Globs: map[string][]string{}} + + components := collectHardware(t, newHardwareCollector(runner, fs)) + sensor := getComponentByName(components, "pwmfan") + if sensor == nil { + t.Fatalf("expected fan sensor pwmfan, got: %v", components) + } + sd := sensor["sensor-data"].(map[string]interface{}) + if toInt(sd["value"]) != 3200 || sd["value-type"] != "rpm" || sd["value-scale"] != "units" { + t.Fatalf("fan sensor-data mismatch: %v", sd) + } +} + +func TestHardwareHwmonVoltage(t *testing.T) { + runner := &testutil.MockRunner{Results: map[string][]byte{ + "ls /sys/class/hwmon": []byte("hwmon2\n"), + "ls /sys/class/hwmon/hwmon2": []byte("name\nin1_input\nin1_label\n"), + }, Errors: map[string]error{}} + fs := &testutil.MockFileReader{Files: map[string][]byte{ + "/run/system.json": []byte(`{}`), + "/sys/class/hwmon/hwmon2/name": []byte("ina3221\n"), + "/sys/class/hwmon/hwmon2/in1_input": []byte("12000\n"), + "/sys/class/hwmon/hwmon2/in1_label": []byte("VCC\n"), + }, Globs: map[string][]string{}} + + components := collectHardware(t, newHardwareCollector(runner, fs)) + sensor := getComponentByName(components, "ina3221-VCC") + if sensor == nil { + t.Fatalf("expected voltage sensor ina3221-VCC, got: %v", components) + } + if sensor["description"] != "VCC" { + t.Fatalf("expected VCC description, got %v", sensor["description"]) + } + sd := sensor["sensor-data"].(map[string]interface{}) + if toInt(sd["value"]) != 12000 || sd["value-type"] != "volts-DC" || sd["value-scale"] != "milli" { + t.Fatalf("voltage sensor-data mismatch: %v", sd) + } +} + +func TestHardwareHwmonMultiSensor(t *testing.T) { + runner := &testutil.MockRunner{Results: map[string][]byte{ + "ls /sys/class/hwmon": []byte("hwmon3\n"), + "ls /sys/class/hwmon/hwmon3": []byte("name\ntemp1_input\ntemp1_label\nfan1_input\nfan1_label\ncurr1_input\npower1_input\n"), + }, Errors: map[string]error{}} + fs := &testutil.MockFileReader{Files: map[string][]byte{ + "/run/system.json": []byte(`{}`), + "/sys/class/hwmon/hwmon3/name": []byte("sfp_2\n"), + "/sys/class/hwmon/hwmon3/temp1_input": []byte("33000\n"), + "/sys/class/hwmon/hwmon3/temp1_label": []byte("temp1\n"), + "/sys/class/hwmon/hwmon3/fan1_input": []byte("2000\n"), + "/sys/class/hwmon/hwmon3/fan1_label": []byte("fan1\n"), + "/sys/class/hwmon/hwmon3/curr1_input": []byte("1500\n"), + "/sys/class/hwmon/hwmon3/power1_input": []byte("2500000\n"), + }, Globs: map[string][]string{}} + + components := collectHardware(t, newHardwareCollector(runner, fs)) + parent := getComponentByName(components, "sfp2") + if parent == nil || parent["class"] != "iana-hardware:module" { + t.Fatalf("expected sfp2 parent module, got: %v", parent) + } + + children := 0 + hasCurrent := false + hasPower := false + for _, compRaw := range components { + comp, ok := compRaw.(map[string]interface{}) + if !ok { + continue + } + if comp["parent"] != "sfp2" { + continue + } + children++ + sd, _ := comp["sensor-data"].(map[string]interface{}) + if sd != nil && sd["value-type"] == "amperes" { + hasCurrent = true + } + if sd != nil && sd["value-type"] == "watts" { + hasPower = true + } + } + if children < 4 { + t.Fatalf("expected at least 4 child sensors, got %d", children) + } + if !hasCurrent || !hasPower { + t.Fatalf("expected current and power sensors under parent: current=%v power=%v", hasCurrent, hasPower) + } +} + +func TestHardwareThermalZone(t *testing.T) { + runner := &testutil.MockRunner{Results: map[string][]byte{ + "ls /sys/class/thermal": []byte("thermal_zone0\n"), + }, Errors: map[string]error{}} + fs := &testutil.MockFileReader{Files: map[string][]byte{ + "/run/system.json": []byte(`{}`), + "/sys/class/thermal/thermal_zone0/type": []byte("cpu-thermal\n"), + "/sys/class/thermal/thermal_zone0/temp": []byte("39000\n"), + }, Globs: map[string][]string{}} + + components := collectHardware(t, newHardwareCollector(runner, fs)) + sensor := getComponentByName(components, "cpu") + if sensor == nil { + t.Fatalf("expected thermal sensor cpu, got %v", components) + } + sd := sensor["sensor-data"].(map[string]interface{}) + if toInt(sd["value"]) != 39000 || sd["value-type"] != "celsius" { + t.Fatalf("thermal sensor mismatch: %v", sd) + } +} + +func TestHardwareNormalizeSensorName(t *testing.T) { + tests := []struct { + in string + want string + }{ + {in: "sfp_2", want: "sfp2"}, + {in: "mt7915_phy0", want: "phy0"}, + {in: "marvell_alaska_tomte_phy7", want: "phy7"}, + {in: "cpu_thermal", want: "cpu"}, + {in: "gpu-thermal", want: "gpu"}, + {in: "pwmfan", want: "pwmfan"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.in, func(t *testing.T) { + got := normalize_sensor_name(tt.in) + if got != tt.want { + t.Errorf("normalize_sensor_name(%q): expected %q, got %q", tt.in, tt.want, got) + } + }) + } +} + +func TestHardwareGracefulDegradation(t *testing.T) { + runner := &testutil.MockRunner{Results: map[string][]byte{}, Errors: map[string]error{}} + fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}} + + tr := tree.New() + c := newHardwareCollector(runner, fs) + if err := c.Collect(context.Background(), tr); err != nil { + t.Fatalf("Collect should not fail when all probes fail: %v", err) + } + + raw := tr.Get("ietf-hardware:hardware") + if raw == nil { + t.Fatal("expected ietf-hardware:hardware key even on probe failures") + } + + var out map[string]interface{} + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal hardware: %v", err) + } + components, ok := out["component"].([]interface{}) + if !ok { + t.Fatalf("component list missing: %v", out["component"]) + } + if len(components) != 0 { + t.Fatalf("expected empty component list on total failure, got %d (%v)", len(components), components) + } +} + +func TestHardwareGPSDeviceNotFound(t *testing.T) { + // Bug 5: When /dev/gps* doesn't exist, readlink -f still succeeds + // (returns canonical form of non-existent path). Verify the existence + // check prevents phantom GPS components. + runner := &testutil.MockRunner{Results: map[string][]byte{}, Errors: map[string]error{ + "ls /dev/gps0": fmt.Errorf("No such file or directory"), + "ls /dev/gps1": fmt.Errorf("No such file or directory"), + "ls /dev/gps2": fmt.Errorf("No such file or directory"), + "ls /dev/gps3": fmt.Errorf("No such file or directory"), + }} + fs := &testutil.MockFileReader{Files: map[string][]byte{ + "/run/system.json": []byte(`{}`), + }, Globs: map[string][]string{}} + + components := collectHardware(t, newHardwareCollector(runner, fs)) + for _, c := range components { + m, ok := c.(map[string]interface{}) + if !ok { + continue + } + if m["class"] == "infix-hardware:gps" { + t.Fatalf("phantom GPS component should not exist when /dev/gps* missing: %v", m) + } + } +} diff --git a/src/yangerd/internal/collector/live.go b/src/yangerd/internal/collector/live.go new file mode 100644 index 00000000..c49373d3 --- /dev/null +++ b/src/yangerd/internal/collector/live.go @@ -0,0 +1,136 @@ +package collector + +import ( + "encoding/json" + "strconv" + "strings" + "syscall" + "time" +) + +// LiveSystemState computes the on-demand portion of ietf-system:system-state. +// It reads uptime, current time, memory, load average from procfs and +// filesystem usage via statfs — all computed fresh on each call. +func LiveSystemState(fs FileReader) json.RawMessage { + state := make(map[string]interface{}) + + if clock := liveClock(fs); len(clock) > 0 { + state["clock"] = clock + } + + resource := make(map[string]interface{}) + if mem := liveMemory(fs); len(mem) > 0 { + resource["memory"] = mem + } + if la := liveLoadAvg(fs); len(la) > 0 { + resource["load-average"] = la + } + if filesys := liveFilesystems(); len(filesys) > 0 { + resource["filesystem"] = filesys + } + if len(resource) > 0 { + state["infix-system:resource-usage"] = resource + } + + data, err := json.Marshal(state) + if err != nil { + return nil + } + return data +} + +func liveClock(fs FileReader) map[string]interface{} { + data, err := fs.ReadFile("/proc/uptime") + if err != nil { + return nil + } + parts := strings.Fields(string(data)) + if len(parts) < 1 { + return nil + } + upSec, err := strconv.ParseFloat(parts[0], 64) + if err != nil { + return nil + } + + now := time.Now() + boot := now.Add(-time.Duration(upSec * float64(time.Second))) + + return map[string]interface{}{ + "current-datetime": yangDateTime(now), + "boot-datetime": yangDateTime(boot), + } +} + +func liveMemory(fs FileReader) map[string]interface{} { + data, err := fs.ReadFile("/proc/meminfo") + if err != nil { + return nil + } + + memFields := map[string]string{ + "MemTotal": "total", + "MemFree": "free", + "MemAvailable": "available", + } + + memory := make(map[string]interface{}) + for _, line := range strings.Split(string(data), "\n") { + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + jsonKey, ok := memFields[key] + if !ok { + continue + } + valStr := strings.TrimSpace(parts[1]) + fields := strings.Fields(valStr) + if len(fields) < 1 { + continue + } + memory[jsonKey] = fields[0] + } + return memory +} + +func liveLoadAvg(fs FileReader) map[string]interface{} { + data, err := fs.ReadFile("/proc/loadavg") + if err != nil { + return nil + } + fields := strings.Fields(string(data)) + if len(fields) < 3 { + return nil + } + return map[string]interface{}{ + "load-1min": fields[0], + "load-5min": fields[1], + "load-15min": fields[2], + } +} + +func liveFilesystems() []interface{} { + mounts := []string{"/", "/var", "/cfg"} + var filesystems []interface{} + + for _, mount := range mounts { + var stat syscall.Statfs_t + if err := syscall.Statfs(mount, &stat); err != nil { + continue + } + bsize := uint64(stat.Bsize) + sizeKB := (stat.Blocks * bsize) / 1024 + availKB := (stat.Bavail * bsize) / 1024 + usedKB := sizeKB - (stat.Bfree*bsize)/1024 + + filesystems = append(filesystems, map[string]interface{}{ + "mount-point": mount, + "size": strconv.FormatUint(sizeKB, 10), + "used": strconv.FormatUint(usedKB, 10), + "available": strconv.FormatUint(availKB, 10), + }) + } + return filesystems +} diff --git a/src/yangerd/internal/collector/live_test.go b/src/yangerd/internal/collector/live_test.go new file mode 100644 index 00000000..38ac43c7 --- /dev/null +++ b/src/yangerd/internal/collector/live_test.go @@ -0,0 +1,190 @@ +package collector + +import ( + "encoding/json" + "testing" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/testutil" +) + +func TestLiveClock(t *testing.T) { + fs := &testutil.MockFileReader{ + Files: map[string][]byte{ + "/proc/uptime": []byte("12345.67 23456.78\n"), + }, + } + + before := time.Now().Truncate(time.Second) + clock := liveClock(fs) + after := time.Now().Truncate(time.Second).Add(time.Second) + + if clock == nil { + t.Fatal("expected non-nil clock") + } + + cur, ok := clock["current-datetime"].(string) + if !ok || cur == "" { + t.Fatal("missing current-datetime") + } + parsed, err := time.Parse("2006-01-02T15:04:05-07:00", cur) + if err != nil { + t.Fatalf("invalid datetime format: %v", err) + } + if parsed.Before(before) || parsed.After(after) { + t.Fatalf("current-datetime %v not between %v and %v", parsed, before, after) + } + + boot, ok := clock["boot-datetime"].(string) + if !ok || boot == "" { + t.Fatal("missing boot-datetime") + } + _, err = time.Parse("2006-01-02T15:04:05-07:00", boot) + if err != nil { + t.Fatalf("invalid boot-datetime format: %v", err) + } +} + +func TestLiveClockMissingFile(t *testing.T) { + fs := &testutil.MockFileReader{ + Files: map[string][]byte{}, + } + if clock := liveClock(fs); clock != nil { + t.Fatalf("expected nil on missing /proc/uptime, got %v", clock) + } +} + +func TestLiveMemory(t *testing.T) { + fs := &testutil.MockFileReader{ + Files: map[string][]byte{ + "/proc/meminfo": []byte("MemTotal: 1024000 kB\nMemFree: 512000 kB\nMemAvailable: 768000 kB\nBuffers: 64000 kB\n"), + }, + } + + mem := liveMemory(fs) + if mem == nil { + t.Fatal("expected non-nil memory") + } + + checks := map[string]string{ + "total": "1024000", + "free": "512000", + "available": "768000", + } + for key, expected := range checks { + got, ok := mem[key].(string) + if !ok || got != expected { + t.Fatalf("memory[%q]: expected %q, got %v", key, expected, mem[key]) + } + } + + if _, has := mem["Buffers"]; has { + t.Fatal("unexpected Buffers field in memory output") + } +} + +func TestLiveMemoryMissingFile(t *testing.T) { + fs := &testutil.MockFileReader{ + Files: map[string][]byte{}, + } + if mem := liveMemory(fs); mem != nil { + t.Fatalf("expected nil on missing /proc/meminfo, got %v", mem) + } +} + +func TestLiveLoadAvg(t *testing.T) { + fs := &testutil.MockFileReader{ + Files: map[string][]byte{ + "/proc/loadavg": []byte("0.42 0.31 0.15 2/123 4567\n"), + }, + } + + la := liveLoadAvg(fs) + if la == nil { + t.Fatal("expected non-nil load average") + } + + checks := map[string]string{ + "load-1min": "0.42", + "load-5min": "0.31", + "load-15min": "0.15", + } + for key, expected := range checks { + got, ok := la[key].(string) + if !ok || got != expected { + t.Fatalf("load-average[%q]: expected %q, got %v", key, expected, la[key]) + } + } +} + +func TestLiveLoadAvgMissingFile(t *testing.T) { + fs := &testutil.MockFileReader{ + Files: map[string][]byte{}, + } + if la := liveLoadAvg(fs); la != nil { + t.Fatalf("expected nil on missing /proc/loadavg, got %v", la) + } +} + +func TestLiveSystemState(t *testing.T) { + fs := &testutil.MockFileReader{ + Files: map[string][]byte{ + "/proc/uptime": []byte("100.0 200.0\n"), + "/proc/meminfo": []byte("MemTotal: 2048000 kB\nMemFree: 1024000 kB\nMemAvailable: 1536000 kB\n"), + "/proc/loadavg": []byte("1.00 0.50 0.25 3/200 9999\n"), + }, + } + + raw := LiveSystemState(fs) + if raw == nil { + t.Fatal("expected non-nil LiveSystemState output") + } + + var state map[string]interface{} + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if _, ok := state["clock"]; !ok { + t.Fatal("missing clock in live state") + } + + resource, ok := state["infix-system:resource-usage"].(map[string]interface{}) + if !ok { + t.Fatal("missing infix-system:resource-usage in live state") + } + if _, ok := resource["memory"]; !ok { + t.Fatal("missing memory in resource-usage") + } + if _, ok := resource["load-average"]; !ok { + t.Fatal("missing load-average in resource-usage") + } +} + +func TestLiveSystemStatePartialFailure(t *testing.T) { + fs := &testutil.MockFileReader{ + Files: map[string][]byte{ + "/proc/loadavg": []byte("0.10 0.20 0.30 1/50 1234\n"), + }, + } + + raw := LiveSystemState(fs) + if raw == nil { + t.Fatal("expected non-nil even with partial data") + } + + var state map[string]interface{} + json.Unmarshal(raw, &state) + + if _, ok := state["clock"]; ok { + t.Fatal("clock should be absent when /proc/uptime is missing") + } + + resource := state["infix-system:resource-usage"].(map[string]interface{}) + if _, ok := resource["memory"]; ok { + t.Fatal("memory should be absent when /proc/meminfo is missing") + } + if _, ok := resource["load-average"]; !ok { + t.Fatal("load-average should be present") + } +} diff --git a/src/yangerd/internal/collector/ntp.go b/src/yangerd/internal/collector/ntp.go new file mode 100644 index 00000000..f3d7912e --- /dev/null +++ b/src/yangerd/internal/collector/ntp.go @@ -0,0 +1,527 @@ +package collector + +import ( + "context" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +// NTPCollector gathers ietf-ntp operational data by running chronyc +// commands (sources, sourcestats, tracking, serverstats) and ss to +// detect the NTP listening port. +type NTPCollector struct { + cmd CommandRunner + interval time.Duration +} + +// NewNTPCollector creates an NTPCollector with the given dependencies. +func NewNTPCollector(cmd CommandRunner, interval time.Duration) *NTPCollector { + return &NTPCollector{cmd: cmd, interval: interval} +} + +// Name implements Collector. +func (c *NTPCollector) Name() string { return "ntp" } + +// Interval implements Collector. +func (c *NTPCollector) Interval() time.Duration { return c.interval } + +// Collect implements Collector. It produces two tree keys: +// - "ietf-ntp:ntp" — associations, clock state, server status, and +// server statistics (RFC 9249). +// - "ietf-system:system-state" — merged infix-system:ntp/sources/source +// list with address, mode, state, stratum and poll for each chrony +// source (Infix augmentation of ietf-system). +func (c *NTPCollector) Collect(ctx context.Context, t *tree.Tree) error { + // Run chronyc sources once and share between addAssociations and addSources. + sourcesOut, _ := c.cmd.Run(ctx, "chronyc", "-c", "sources") + + ntp := make(map[string]interface{}) + + c.addAssociations(ctx, ntp, sourcesOut) + c.addClockState(ctx, ntp) + c.addServerStatus(ctx, ntp) + c.addServerStats(ctx, ntp) + + if len(ntp) == 0 { + return nil + } + + if data, err := json.Marshal(ntp); err == nil { + t.Set("ietf-ntp:ntp", data) + } + + // Populate the Infix NTP sources augmentation under system-state. + if sources := c.addSources(sourcesOut); sources != nil { + if data, err := json.Marshal(map[string]interface{}{ + "infix-system:ntp": sources, + }); err == nil { + t.Merge("ietf-system:system-state", data) + } + } + + return nil +} + +// addAssociations parses chronyc sources and sourcestats CSV output +// into the associations/association list. +// +// chronyc -c sources format (comma-separated): +// +// [0] Mode: ^ server, = peer, # refclock (skipped) +// [1] State: * selected, + candidate, - outlier, ? unusable, x falseticker, ~ unstable +// [2] Address (IP) +// [3] Stratum +// [4] Poll interval (log2 seconds) +// [5] Reach (octal reachability register) +// [6] LastRx (seconds since last response) +// [7] Last offset (seconds) +// [8] Offset at last update (seconds) +// [9] Error estimate (seconds) +// +// chronyc -c sourcestats format: +// +// [0] Address +// [1] NP +// [2] NR +// [3] Span +// [4] Frequency (ppm) +// [5] Freq Skew (ppm) +// [6] Offset (seconds) +// [7] Std Dev (seconds) +func (c *NTPCollector) addAssociations(ctx context.Context, ntp map[string]interface{}, sourcesOut []byte) { + if len(sourcesOut) == 0 { + return + } + + // Build stats map from sourcestats for offset/dispersion + statsMap := make(map[string]map[string]string) + statsOut, err := c.cmd.Run(ctx, "chronyc", "-c", "sourcestats") + if err == nil { + for _, line := range splitLines(string(statsOut)) { + parts := strings.Split(line, ",") + if len(parts) >= 8 { + statsMap[parts[0]] = map[string]string{ + "offset": parts[6], + "std_dev": parts[7], + } + } + } + } + + modeMap := map[string]string{ + "^": "ietf-ntp:client", + "=": "ietf-ntp:active", + "#": "ietf-ntp:broadcast-client", + } + + var associations []interface{} + for _, line := range splitLines(string(sourcesOut)) { + parts := strings.Split(line, ",") + if len(parts) < 10 { + continue + } + + modeIndicator := parts[0] + // Skip reference clocks — they have names like "GPS", not IP addresses + if modeIndicator == "#" { + continue + } + + stateIndicator := parts[1] + address := parts[2] + stratum, err := strconv.Atoi(parts[3]) + if err != nil { + continue + } + // YANG requires stratum 1..16 + if stratum < 1 || stratum > 16 { + continue + } + + assoc := map[string]interface{}{ + "address": address, + "local-mode": modeMap[modeIndicator], + "isconfigured": true, + "stratum": stratum, + } + if assoc["local-mode"] == nil { + assoc["local-mode"] = "ietf-ntp:client" + } + + // Current sync source + if stateIndicator == "*" { + assoc["prefer"] = true + } + + // Reachability register (octal → decimal) + if reach, err := strconv.ParseInt(parts[5], 8, 32); err == nil { + assoc["reach"] = int(reach) + } + + // Poll interval (log2 seconds) + if poll, err := strconv.Atoi(parts[4]); err == nil { + assoc["poll"] = poll + } + + // Time since last packet + if now, err := strconv.Atoi(parts[6]); err == nil { + assoc["now"] = now + } + + // Offset: prefer sourcestats if available, else sources[7] + // Convert seconds → milliseconds with 3 fraction digits + if stats, ok := statsMap[address]; ok { + if offsetSec, err := strconv.ParseFloat(stats["offset"], 64); err == nil { + assoc["offset"] = fmt.Sprintf("%.3f", offsetSec*1000.0) + } + } else if offsetSec, err := strconv.ParseFloat(parts[7], 64); err == nil { + assoc["offset"] = fmt.Sprintf("%.3f", offsetSec*1000.0) + } + + // Delay: error estimate from sources[9], seconds → milliseconds + if delaySec, err := strconv.ParseFloat(parts[9], 64); err == nil { + assoc["delay"] = fmt.Sprintf("%.3f", math.Abs(delaySec)*1000.0) + } + + // Dispersion: std_dev from sourcestats, seconds → milliseconds + if stats, ok := statsMap[address]; ok { + if dispSec, err := strconv.ParseFloat(stats["std_dev"], 64); err == nil { + assoc["dispersion"] = fmt.Sprintf("%.3f", dispSec*1000.0) + } + } + + associations = append(associations, assoc) + } + + if len(associations) > 0 { + ntp["associations"] = map[string]interface{}{ + "association": associations, + } + } +} + +// sourceStateMap maps chronyc source-state indicators to YANG +// infix-system source-state enum values. +var sourceStateMap = map[string]string{ + "*": "selected", + "+": "candidate", + "-": "outlier", + "?": "unusable", + "x": "falseticker", + "~": "unstable", +} + +// sourceModeMap maps chronyc mode indicators to YANG +// infix-system source-mode enum values. +var sourceModeMap = map[string]string{ + "^": "server", + "=": "peer", + "#": "local-clock", +} + +// addSources builds the infix-system:ntp/sources/source list from +// chronyc -c sources output. Reference clocks (mode #) and sources +// with invalid stratum are skipped, matching the Python yanger +// ietf_system.py add_ntp() behaviour. +func (c *NTPCollector) addSources(sourcesOut []byte) map[string]interface{} { + if len(sourcesOut) == 0 { + return nil + } + + var sources []interface{} + for _, line := range splitLines(string(sourcesOut)) { + parts := strings.Split(line, ",") + if len(parts) < 10 { + continue + } + + modeIndicator := parts[0] + if modeIndicator == "#" { + continue + } + + stratum, err := strconv.Atoi(parts[3]) + if err != nil || stratum < 1 || stratum > 16 { + continue + } + + mode := sourceModeMap[modeIndicator] + if mode == "" { + mode = "server" + } + state := sourceStateMap[parts[1]] + if state == "" { + continue + } + + src := map[string]interface{}{ + "address": parts[2], + "mode": mode, + "state": state, + "stratum": stratum, + } + if poll, err := strconv.Atoi(parts[4]); err == nil { + src["poll"] = poll + } + + sources = append(sources, src) + } + + if len(sources) == 0 { + return nil + } + + return map[string]interface{}{ + "sources": map[string]interface{}{ + "source": sources, + }, + } +} + +// addClockState parses chronyc tracking CSV output into the clock-state +// container. +// +// chronyc -c tracking format (comma-separated): +// +// [0] Ref-ID (hex IP, e.g. "C0A80101") +// [1] Ref-ID name (e.g. "router.local") +// [2] Stratum +// [3] Ref time (seconds since epoch) +// [4] System time offset (seconds) +// [5] Last offset (seconds) +// [6] RMS offset (seconds) +// [7] Frequency (ppm) +// [8] Residual frequency (ppm) +// [9] Skew (ppm) +// [10] Root delay (seconds) +// [11] Root dispersion (seconds) +// [12] Update interval (seconds) +// [13] Leap status (e.g. "Normal", "Not synchronised") +func (c *NTPCollector) addClockState(ctx context.Context, ntp map[string]interface{}) { + out, err := c.cmd.Run(ctx, "chronyc", "-c", "tracking") + if err != nil || len(out) == 0 { + return + } + + lines := splitLines(string(out)) + if len(lines) == 0 { + return + } + + parts := strings.Split(lines[0], ",") + if len(parts) < 14 { + return + } + + ss := make(map[string]interface{}) + + // Stratum: chronyd uses 0 for "not synchronized", YANG requires 1-16 + stratumRaw, _ := strconv.Atoi(parts[2]) + stratum := stratumRaw + if stratum == 0 { + stratum = 16 + } + + if stratum == 16 { + ss["clock-state"] = "ietf-ntp:unsynchronized" + } else { + ss["clock-state"] = "ietf-ntp:synchronized" + } + ss["clock-stratum"] = stratum + + // Reference ID + refidIP := parts[0] + refidName := parts[1] + if refidName != "" { + // NTP refids are always 4 bytes; pad/truncate to exactly 4 chars + padded := refidName + " " + ss["clock-refid"] = padded[:4] + } else if len(refidIP) == 8 { + a, e1 := strconv.ParseInt(refidIP[0:2], 16, 32) + b, e2 := strconv.ParseInt(refidIP[2:4], 16, 32) + cv, e3 := strconv.ParseInt(refidIP[4:6], 16, 32) + d, e4 := strconv.ParseInt(refidIP[6:8], 16, 32) + if e1 == nil && e2 == nil && e3 == nil && e4 == nil { + ss["clock-refid"] = fmt.Sprintf("%d.%d.%d.%d", a, b, cv, d) + } else { + ss["clock-refid"] = refidIP + } + } else if refidIP != "" { + ss["clock-refid"] = refidIP + } else { + ss["clock-refid"] = "0.0.0.0" + } + + // Frequencies (ppm → Hz with nominal 1GHz) + if freqPPM, err := strconv.ParseFloat(parts[7], 64); err == nil { + nominal := 1000000000.0 + actual := nominal * (1.0 + freqPPM/1000000.0) + ss["nominal-freq"] = fmt.Sprintf("%.4f", nominal) + ss["actual-freq"] = fmt.Sprintf("%.4f", actual) + } + + // Clock precision (fixed estimate, ~1µs) + ss["clock-precision"] = -20 + + // Clock offset (system-time column[4], seconds → milliseconds) + if offsetSec, err := strconv.ParseFloat(parts[4], 64); err == nil { + ss["clock-offset"] = fmt.Sprintf("%.3f", offsetSec*1000.0) + } + + // Root delay (seconds → milliseconds) + if rootDelay, err := strconv.ParseFloat(parts[10], 64); err == nil { + ss["root-delay"] = fmt.Sprintf("%.3f", rootDelay*1000.0) + } + + // Root dispersion (seconds → milliseconds) + if rootDisp, err := strconv.ParseFloat(parts[11], 64); err == nil { + ss["root-dispersion"] = fmt.Sprintf("%.3f", rootDisp*1000.0) + } + + // Reference time (epoch seconds → ISO 8601) + if refTime, err := strconv.ParseFloat(parts[3], 64); err == nil && refTime > 0 { + sec := int64(refTime) + nsec := int64((refTime - float64(sec)) * 1e9) + t := time.Unix(sec, nsec).UTC() + ss["reference-time"] = t.Format("2006-01-02T15:04:05.000") + "Z" + } + + // Sync state based on leap status + leapStatus := strings.TrimSpace(parts[13]) + if leapStatus == "Not synchronised" || stratum == 16 { + ss["sync-state"] = "ietf-ntp:clock-never-set" + } else { + ss["sync-state"] = "ietf-ntp:clock-synchronized" + } + + // Infix augmentations + if lastOffset, err := strconv.ParseFloat(parts[5], 64); err == nil { + ss["infix-ntp:last-offset"] = fmt.Sprintf("%.9f", lastOffset) + } + if rmsOffset, err := strconv.ParseFloat(parts[6], 64); err == nil { + ss["infix-ntp:rms-offset"] = fmt.Sprintf("%.9f", rmsOffset) + } + if residualFreq, err := strconv.ParseFloat(parts[8], 64); err == nil { + ss["infix-ntp:residual-freq"] = fmt.Sprintf("%.3f", residualFreq) + } + if skew, err := strconv.ParseFloat(parts[9], 64); err == nil { + ss["infix-ntp:skew"] = fmt.Sprintf("%.3f", skew) + } + if updateInterval, err := strconv.ParseFloat(parts[12], 64); err == nil { + ss["infix-ntp:update-interval"] = fmt.Sprintf("%.1f", updateInterval) + } + + ntp["clock-state"] = map[string]interface{}{ + "system-status": ss, + } +} + +// addServerStatus adds the refclock-master stratum and listening port. +// Must be called after addClockState so clock-state is available. +func (c *NTPCollector) addServerStatus(ctx context.Context, ntp map[string]interface{}) { + // Reuse stratum from clock-state if already populated + if cs, ok := ntp["clock-state"].(map[string]interface{}); ok { + if ss, ok := cs["system-status"].(map[string]interface{}); ok { + if stratum, ok := ss["clock-stratum"]; ok { + ntp["refclock-master"] = map[string]interface{}{ + "master-stratum": stratum, + } + } + } + } + + // Detect NTP listening port via ss + ssOut, err := c.cmd.Run(ctx, "ss", "-ulnp") + if err != nil { + return + } + + for _, line := range splitLines(string(ssOut)) { + if !strings.Contains(line, "chronyd") { + continue + } + // Skip loopback (command socket) + if strings.Contains(line, "127.0.0.1") || strings.Contains(line, "[::1]") { + continue + } + + fields := strings.Fields(line) + if len(fields) >= 5 { + localAddr := fields[3] + idx := strings.LastIndex(localAddr, ":") + if idx >= 0 { + portStr := localAddr[idx+1:] + if port, err := strconv.Atoi(portStr); err == nil { + ntp["port"] = port + break + } + } + } + } +} + +// addServerStats parses chronyc serverstats CSV into ntp-statistics. +// +// chronyc -c serverstats format: +// +// [0] NTP packets received +// [1] NTP packets dropped +// [2] Cmd packets received +// [3] Cmd packets dropped +// [4] Client log size active +// [5] Client log memory +// [6] Rate limit drops +// [7] NTP packets sent +// [8] NTP packets send fail +func (c *NTPCollector) addServerStats(ctx context.Context, ntp map[string]interface{}) { + out, err := c.cmd.Run(ctx, "chronyc", "-c", "serverstats") + if err != nil || len(out) == 0 { + return + } + + lines := splitLines(string(out)) + if len(lines) == 0 { + return + } + + parts := strings.Split(lines[0], ",") + if len(parts) < 9 { + return + } + + stats := make(map[string]interface{}) + if v, err := strconv.Atoi(parts[0]); err == nil { + stats["packet-received"] = v + } + if v, err := strconv.Atoi(parts[1]); err == nil { + stats["packet-dropped"] = v + } + if v, err := strconv.Atoi(parts[7]); err == nil { + stats["packet-sent"] = v + } + if v, err := strconv.Atoi(parts[8]); err == nil { + stats["packet-sent-fail"] = v + } + + if len(stats) > 0 { + ntp["ntp-statistics"] = stats + } +} + +// splitLines splits text into non-empty lines. +func splitLines(text string) []string { + var lines []string + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line != "" { + lines = append(lines, line) + } + } + return lines +} diff --git a/src/yangerd/internal/collector/ntp_test.go b/src/yangerd/internal/collector/ntp_test.go new file mode 100644 index 00000000..5a6b9e09 --- /dev/null +++ b/src/yangerd/internal/collector/ntp_test.go @@ -0,0 +1,392 @@ +package collector + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/testutil" + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +const testChronycSources = `^,*,10.0.0.1,2,6,377,32,+0.000123,,0.000456 +^,+,10.0.0.2,3,7,377,64,-0.000789,,0.001234 +=,-,10.0.0.3,4,6,177,128,+0.001500,,0.002000 +#,,GPS,1,4,377,16,+0.000001,,0.000010 +^,?,10.0.0.4,0,6,0,0,+0.000000,,0.000000` + +const testChronycSourcestats = `10.0.0.1,15,8,256,0.001,-0.002,+0.000050,0.000100 +10.0.0.2,12,6,128,-0.005,0.003,-0.000300,0.000200 +10.0.0.3,8,4,64,0.010,-0.008,+0.001000,0.000500` + +const testChronycTracking = `C0A80001,router.local,2,1700000000.123,0.000045,-0.000012,0.000025,-1.500,0.003,0.050,0.004500,0.001200,64.0,Normal` + +const testChronycTrackingUnsync = `00000000,,0,0.000,0.000000,0.000000,0.000000,0.000,0.000,0.000,0.000000,0.000000,0.0,Not synchronised` + +const testChronycServerstats = `1000,5,200,3,1,8192,2,950,10` + +const testSSOutput = `State Recv-Q Send-Q Local Address:Port Peer Address:Port Process +UNCONN 0 0 0.0.0.0:123 0.0.0.0:* users:(("chronyd",pid=5441,fd=5)) +UNCONN 0 0 127.0.0.1:323 0.0.0.0:* users:(("chronyd",pid=5441,fd=1)) +` + +func newNTPCollector(runner *testutil.MockRunner) *NTPCollector { + return NewNTPCollector(runner, 60*time.Second) +} + +func ntpCollect(t *testing.T, runner *testutil.MockRunner) (map[string]interface{}, *tree.Tree) { + t.Helper() + c := newNTPCollector(runner) + tr := tree.New() + if err := c.Collect(context.Background(), tr); err != nil { + t.Fatalf("Collect failed: %v", err) + } + raw := tr.Get("ietf-ntp:ntp") + if raw == nil { + t.Fatal("missing ietf-ntp:ntp in tree") + } + var out map[string]interface{} + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal ntp: %v", err) + } + return out, tr +} + +func fullNTPRunner() *testutil.MockRunner { + return &testutil.MockRunner{ + Results: map[string][]byte{ + "chronyc -c sources": []byte(testChronycSources), + "chronyc -c sourcestats": []byte(testChronycSourcestats), + "chronyc -c tracking": []byte(testChronycTracking), + "chronyc -c serverstats": []byte(testChronycServerstats), + "ss -ulnp": []byte(testSSOutput), + }, + Errors: map[string]error{}, + } +} + +func TestNTPCollectorNameAndInterval(t *testing.T) { + c := newNTPCollector(fullNTPRunner()) + if c.Name() != "ntp" { + t.Fatalf("expected name 'ntp', got %q", c.Name()) + } + if c.Interval() != 60*time.Second { + t.Fatalf("expected interval 60s, got %v", c.Interval()) + } +} + +func TestNTPAssociations(t *testing.T) { + out, _ := ntpCollect(t, fullNTPRunner()) + assocContainer := out["associations"].(map[string]interface{}) + assocs := assocContainer["association"].([]interface{}) + + // 5 sources minus GPS refclock (#) minus stratum-0 (10.0.0.4) = 3 + if len(assocs) != 3 { + t.Fatalf("expected 3 associations (refclock+stratum0 filtered), got %d", len(assocs)) + } + + byAddr := make(map[string]map[string]interface{}) + for _, a := range assocs { + am := a.(map[string]interface{}) + byAddr[am["address"].(string)] = am + } + + // 10.0.0.1: selected server (*), stratum 2 + a1 := byAddr["10.0.0.1"] + if a1 == nil { + t.Fatal("missing association for 10.0.0.1") + } + if a1["local-mode"] != "ietf-ntp:client" { + t.Fatalf("10.0.0.1 mode: expected ietf-ntp:client, got %v", a1["local-mode"]) + } + if a1["prefer"] != true { + t.Fatalf("10.0.0.1 should be preferred (selected source)") + } + if toInt(a1["stratum"]) != 2 { + t.Fatalf("10.0.0.1 stratum: expected 2, got %v", a1["stratum"]) + } + // Reach: 377 octal = 255 decimal + if toInt(a1["reach"]) != 255 { + t.Fatalf("10.0.0.1 reach: expected 255, got %v", a1["reach"]) + } + // Offset should come from sourcestats (0.000050s → 0.050ms) + if a1["offset"] != "0.050" { + t.Fatalf("10.0.0.1 offset: expected '0.050', got %v", a1["offset"]) + } + // Dispersion from sourcestats std_dev (0.000100s → 0.100ms) + if a1["dispersion"] != "0.100" { + t.Fatalf("10.0.0.1 dispersion: expected '0.100', got %v", a1["dispersion"]) + } + + // 10.0.0.3: peer mode (=) + a3 := byAddr["10.0.0.3"] + if a3 == nil { + t.Fatal("missing association for 10.0.0.3") + } + if a3["local-mode"] != "ietf-ntp:active" { + t.Fatalf("10.0.0.3 mode: expected ietf-ntp:active, got %v", a3["local-mode"]) + } + // Should NOT be preferred (state is -) + if _, hasPrefer := a3["prefer"]; hasPrefer { + t.Fatal("10.0.0.3 should not be preferred") + } +} + +func TestNTPSources(t *testing.T) { + _, tr := ntpCollect(t, fullNTPRunner()) + + raw := tr.Get("ietf-system:system-state") + if raw == nil { + t.Fatal("missing ietf-system:system-state in tree") + } + var state map[string]interface{} + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatalf("unmarshal system-state: %v", err) + } + + ntpData, ok := state["infix-system:ntp"].(map[string]interface{}) + if !ok { + t.Fatal("missing infix-system:ntp in system-state") + } + sourcesContainer, ok := ntpData["sources"].(map[string]interface{}) + if !ok { + t.Fatal("missing sources in infix-system:ntp") + } + sources, ok := sourcesContainer["source"].([]interface{}) + if !ok { + t.Fatal("missing source list in sources") + } + + // 5 sources minus GPS refclock (#) minus stratum-0 (10.0.0.4) = 3 + if len(sources) != 3 { + t.Fatalf("expected 3 sources, got %d", len(sources)) + } + + byAddr := make(map[string]map[string]interface{}) + for _, s := range sources { + sm := s.(map[string]interface{}) + byAddr[sm["address"].(string)] = sm + } + + // 10.0.0.1: selected server + s1 := byAddr["10.0.0.1"] + if s1 == nil { + t.Fatal("missing source 10.0.0.1") + } + if s1["state"] != "selected" { + t.Fatalf("10.0.0.1 state: expected selected, got %v", s1["state"]) + } + if s1["mode"] != "server" { + t.Fatalf("10.0.0.1 mode: expected server, got %v", s1["mode"]) + } + if toInt(s1["stratum"]) != 2 { + t.Fatalf("10.0.0.1 stratum: expected 2, got %v", s1["stratum"]) + } + if toInt(s1["poll"]) != 6 { + t.Fatalf("10.0.0.1 poll: expected 6, got %v", s1["poll"]) + } + + // 10.0.0.2: candidate server + s2 := byAddr["10.0.0.2"] + if s2 == nil { + t.Fatal("missing source 10.0.0.2") + } + if s2["state"] != "candidate" { + t.Fatalf("10.0.0.2 state: expected candidate, got %v", s2["state"]) + } + if s2["mode"] != "server" { + t.Fatalf("10.0.0.2 mode: expected server, got %v", s2["mode"]) + } + + // 10.0.0.3: outlier peer + s3 := byAddr["10.0.0.3"] + if s3 == nil { + t.Fatal("missing source 10.0.0.3") + } + if s3["state"] != "outlier" { + t.Fatalf("10.0.0.3 state: expected outlier, got %v", s3["state"]) + } + if s3["mode"] != "peer" { + t.Fatalf("10.0.0.3 mode: expected peer, got %v", s3["mode"]) + } +} + +func TestNTPSourcesEmpty(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "chronyc -c tracking": []byte(testChronycTracking), + }, + Errors: map[string]error{}, + } + + c := newNTPCollector(runner) + tr := tree.New() + c.Collect(context.Background(), tr) + + raw := tr.Get("ietf-system:system-state") + if raw != nil { + t.Fatal("should not set system-state when no sources available") + } +} + +func TestNTPClockStateSynchronized(t *testing.T) { + out, _ := ntpCollect(t, fullNTPRunner()) + cs := out["clock-state"].(map[string]interface{}) + ss := cs["system-status"].(map[string]interface{}) + + if ss["clock-state"] != "ietf-ntp:synchronized" { + t.Fatalf("clock-state: expected synchronized, got %v", ss["clock-state"]) + } + if toInt(ss["clock-stratum"]) != 2 { + t.Fatalf("clock-stratum: expected 2, got %v", ss["clock-stratum"]) + } + // refid from name "router.local" → padded/truncated to 4 chars: "rout" + if ss["clock-refid"] != "rout" { + t.Fatalf("clock-refid: expected 'rout', got %v", ss["clock-refid"]) + } + if ss["sync-state"] != "ietf-ntp:clock-synchronized" { + t.Fatalf("sync-state: expected clock-synchronized, got %v", ss["sync-state"]) + } + if toInt(ss["clock-precision"]) != -20 { + t.Fatalf("clock-precision: expected -20, got %v", ss["clock-precision"]) + } + + // Verify nominal/actual freq strings + if ss["nominal-freq"] != "1000000000.0000" { + t.Fatalf("nominal-freq: expected '1000000000.0000', got %v", ss["nominal-freq"]) + } + + // Infix augmentations + if ss["infix-ntp:update-interval"] != "64.0" { + t.Fatalf("update-interval: expected '64.0', got %v", ss["infix-ntp:update-interval"]) + } + + // Reference time should be an ISO timestamp + refTime, ok := ss["reference-time"].(string) + if !ok || !strings.HasPrefix(refTime, "2023-") { + t.Fatalf("reference-time should be 2023-* ISO timestamp, got %v", ss["reference-time"]) + } +} + +func TestNTPClockStateUnsynchronized(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "chronyc -c tracking": []byte(testChronycTrackingUnsync), + }, + Errors: map[string]error{}, + } + + c := newNTPCollector(runner) + tr := tree.New() + c.Collect(context.Background(), tr) + + raw := tr.Get("ietf-ntp:ntp") + if raw == nil { + t.Fatal("expected ietf-ntp:ntp even when unsynchronized") + } + var out map[string]interface{} + json.Unmarshal(raw, &out) + + cs := out["clock-state"].(map[string]interface{}) + ss := cs["system-status"].(map[string]interface{}) + + if ss["clock-state"] != "ietf-ntp:unsynchronized" { + t.Fatalf("clock-state: expected unsynchronized, got %v", ss["clock-state"]) + } + // Stratum 0 → 16 + if toInt(ss["clock-stratum"]) != 16 { + t.Fatalf("clock-stratum: expected 16 (mapped from 0), got %v", ss["clock-stratum"]) + } + if ss["sync-state"] != "ietf-ntp:clock-never-set" { + t.Fatalf("sync-state: expected clock-never-set, got %v", ss["sync-state"]) + } +} + +func TestNTPServerPort(t *testing.T) { + out, _ := ntpCollect(t, fullNTPRunner()) + + // Should find port 123 from the non-loopback ss line + if toInt(out["port"]) != 123 { + t.Fatalf("port: expected 123, got %v", out["port"]) + } +} + +func TestNTPRefclockMaster(t *testing.T) { + out, _ := ntpCollect(t, fullNTPRunner()) + master := out["refclock-master"].(map[string]interface{}) + if toInt(master["master-stratum"]) != 2 { + t.Fatalf("master-stratum: expected 2, got %v", master["master-stratum"]) + } +} + +func TestNTPServerStats(t *testing.T) { + out, _ := ntpCollect(t, fullNTPRunner()) + stats := out["ntp-statistics"].(map[string]interface{}) + + if toInt(stats["packet-received"]) != 1000 { + t.Fatalf("packet-received: expected 1000, got %v", stats["packet-received"]) + } + if toInt(stats["packet-dropped"]) != 5 { + t.Fatalf("packet-dropped: expected 5, got %v", stats["packet-dropped"]) + } + if toInt(stats["packet-sent"]) != 950 { + t.Fatalf("packet-sent: expected 950, got %v", stats["packet-sent"]) + } + if toInt(stats["packet-sent-fail"]) != 10 { + t.Fatalf("packet-sent-fail: expected 10, got %v", stats["packet-sent-fail"]) + } +} + +func TestNTPAllCommandsFail(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{}, + Errors: map[string]error{}, + } + + c := newNTPCollector(runner) + tr := tree.New() + err := c.Collect(context.Background(), tr) + if err != nil { + t.Fatalf("Collect should not error when chronyc unavailable: %v", err) + } + if tr.Get("ietf-ntp:ntp") != nil { + t.Fatal("should not set ietf-ntp:ntp when nothing to report") + } +} + +func TestNTPRefidHexToIPv4(t *testing.T) { + // When refid name is empty, hex ref-ID should be converted to dotted notation + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "chronyc -c tracking": []byte("C0A80101,,2,1700000000.0,0.000001,0.000000,0.000000,-1.0,0.0,0.0,0.001,0.001,64.0,Normal"), + }, + Errors: map[string]error{}, + } + + c := newNTPCollector(runner) + tr := tree.New() + c.Collect(context.Background(), tr) + + var out map[string]interface{} + json.Unmarshal(tr.Get("ietf-ntp:ntp"), &out) + cs := out["clock-state"].(map[string]interface{}) + ss := cs["system-status"].(map[string]interface{}) + + // C0A80101 → 192.168.1.1 + if ss["clock-refid"] != "192.168.1.1" { + t.Fatalf("clock-refid: expected '192.168.1.1', got %v", ss["clock-refid"]) + } +} + +func TestSplitLines(t *testing.T) { + input := "line1\n\nline2\n \nline3\n" + got := splitLines(input) + if len(got) != 3 { + t.Fatalf("expected 3 lines, got %d: %v", len(got), got) + } + if got[0] != "line1" || got[1] != "line2" || got[2] != "line3" { + t.Fatalf("unexpected lines: %v", got) + } +} diff --git a/src/yangerd/internal/collector/routing.go b/src/yangerd/internal/collector/routing.go new file mode 100644 index 00000000..554d0a32 --- /dev/null +++ b/src/yangerd/internal/collector/routing.go @@ -0,0 +1,748 @@ +package collector + +import ( + "context" + "encoding/json" + "regexp" + "strconv" + "strings" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +// RoutingCollector gathers ietf-routing operational data by merging +// OSPF, RIP, and BFD control-plane protocols into a single tree key. +// Each protocol contributes entries to the control-plane-protocol list +// under ietf-routing:routing. +type RoutingCollector struct { + cmd CommandRunner + interval time.Duration +} + +// NewRoutingCollector creates a RoutingCollector with the given dependencies. +func NewRoutingCollector(cmd CommandRunner, interval time.Duration) *RoutingCollector { + return &RoutingCollector{cmd: cmd, interval: interval} +} + +// Name implements Collector. +func (c *RoutingCollector) Name() string { return "routing" } + +// Interval implements Collector. +func (c *RoutingCollector) Interval() time.Duration { return c.interval } + +// Collect implements Collector. It produces one tree key: +// "ietf-routing:routing" containing merged OSPF, RIP, and BFD data. +func (c *RoutingCollector) Collect(ctx context.Context, t *tree.Tree) error { + var protocols []interface{} + + if p := c.collectOSPF(ctx); p != nil { + protocols = append(protocols, p) + } + if p := c.collectRIP(ctx); p != nil { + protocols = append(protocols, p) + } + if p := c.collectBFD(ctx); p != nil { + protocols = append(protocols, p) + } + + if len(protocols) == 0 { + return nil + } + + routing := map[string]interface{}{ + "control-plane-protocols": map[string]interface{}{ + "control-plane-protocol": protocols, + }, + } + + if data, err := json.Marshal(routing); err == nil { + t.Merge("ietf-routing:routing", data) + } + return nil +} + +// --- OSPF --- + +var ospfIfaceStateMap = map[string]string{ + "DependUpon": "down", + "Down": "down", + "Waiting": "waiting", + "Loopback": "loopback", + "Point-To-Point": "point-to-point", + "DROther": "dr-other", + "Backup": "bdr", + "DR": "dr", +} + +func frrToIETFNeighborState(state string) string { + parts := strings.SplitN(state, "/", 2) + s := parts[0] + if s == "TwoWay" { + return "2-way" + } + return strings.ToLower(s) +} + +func frrToIETFNeighborRole(role string) string { + if role == "Backup" { + return "BDR" + } + return role +} + +func ospfNetworkType(nt string, p2mpNonBroadcast bool) string { + switch nt { + case "POINTOPOINT": + return "point-to-point" + case "BROADCAST": + return "broadcast" + case "POINTOMULTIPOINT": + if p2mpNonBroadcast { + return "point-to-multipoint" + } + return "hybrid" + case "NBMA": + return "non-broadcast" + default: + return "" + } +} + +func (c *RoutingCollector) collectOSPF(ctx context.Context) interface{} { + out, err := c.cmd.Run(ctx, "/usr/libexec/statd/ospf-status") + if err != nil { + return nil + } + + var data map[string]interface{} + if json.Unmarshal(out, &data) != nil || len(data) == 0 { + return nil + } + + ospf := map[string]interface{}{ + "ietf-ospf:areas": map[string]interface{}{}, + } + + if rid, ok := data["routerId"]; ok { + ospf["ietf-ospf:router-id"] = rid + } + ospf["ietf-ospf:address-family"] = "ipv4" + + var areas []interface{} + areasRaw, _ := data["areas"].(map[string]interface{}) + for areaID, valRaw := range areasRaw { + values, ok := valRaw.(map[string]interface{}) + if !ok { + continue + } + + area := map[string]interface{}{ + "ietf-ospf:area-id": areaID, + "ietf-ospf:interfaces": map[string]interface{}{}, + } + if at, ok := values["area-type"]; ok && at != nil { + area["ietf-ospf:area-type"] = at + } + + var interfaces []interface{} + ifacesRaw, _ := values["interfaces"].([]interface{}) + for _, ifaceRaw := range ifacesRaw { + iface, ok := ifaceRaw.(map[string]interface{}) + if !ok { + continue + } + + intf := map[string]interface{}{ + "name": iface["name"], + "ietf-ospf:neighbors": map[string]interface{}{}, + } + + setIfPresent(intf, "dr-router-id", iface, "drId") + setIfPresent(intf, "dr-ip-addr", iface, "drAddress") + setIfPresent(intf, "bdr-router-id", iface, "bdrId") + setIfPresent(intf, "bdr-ip-addr", iface, "bdrAddress") + + if v, ok := iface["timerPassiveIface"]; ok && v != nil { + intf["passive"] = true + } else { + intf["passive"] = false + } + + if v, ok := iface["ospfEnabled"]; ok { + intf["enabled"] = v + } + + if nt, ok := iface["networkType"].(string); ok { + p2mpNB, _ := iface["p2mpNonBroadcast"].(bool) + if it := ospfNetworkType(nt, p2mpNB); it != "" { + intf["interface-type"] = it + } + } + + if s, ok := iface["state"].(string); ok { + if mapped, ok := ospfIfaceStateMap[s]; ok { + intf["state"] = mapped + } else { + intf["state"] = "unknown" + } + } + + setIfPresentInt(intf, "priority", iface, "priority") + setIfPresentInt(intf, "cost", iface, "cost") + setIfPresentInt(intf, "dead-interval", iface, "timerDeadSecs") + setIfPresentInt(intf, "retransmit-interval", iface, "timerRetransmitSecs") + setIfPresentInt(intf, "transmit-delay", iface, "transmitDelaySecs") + + // Hello interval: milliseconds to seconds + if v := iface["timerMsecs"]; v != nil { + helloSec := toInt(v) / 1000 + if helloSec >= 1 { + intf["hello-interval"] = helloSec + } + } + + // Hello timer: remaining time in ms to seconds + if v := iface["timerHelloInMsecs"]; v != nil { + helloTimerSec := toInt(v) / 1000 + if helloTimerSec >= 1 { + intf["hello-timer"] = helloTimerSec + } + } + + // Wait timer + if v := iface["timerWaitSecs"]; v != nil { + waitSec := toInt(v) + if waitSec >= 1 { + intf["wait-timer"] = waitSec + } + } + + var neighbors []interface{} + neighsRaw, _ := iface["neighbors"].([]interface{}) + for _, neighRaw := range neighsRaw { + neigh, ok := neighRaw.(map[string]interface{}) + if !ok { + continue + } + + neighbor := map[string]interface{}{ + "neighbor-router-id": neigh["neighborIp"], + "address": neigh["ifaceAddress"], + } + + setIfPresentInt(neighbor, "priority", neigh, "nbrPriority") + + // Uptime: ms to seconds (infix augmentation) + if v := neigh["lastPrgrsvChangeMsec"]; v != nil { + neighbor["infix-routing:uptime"] = toInt(v) / 1000 + } + + // Dead timer: ms to seconds + if v := neigh["routerDeadIntervalTimerDueMsec"]; v != nil { + deadSec := toInt(v) / 1000 + if deadSec >= 1 { + neighbor["dead-timer"] = deadSec + } + } + + if s, ok := neigh["nbrState"].(string); ok { + neighbor["state"] = frrToIETFNeighborState(s) + } + + if role, ok := neigh["role"].(string); ok && role != "" { + neighbor["infix-routing:role"] = frrToIETFNeighborRole(role) + } + + // Interface name (infix augmentation) + ifName, _ := neigh["ifaceName"].(string) + localAddr, _ := neigh["localIfaceAddress"].(string) + if ifName != "" && localAddr != "" { + neighbor["infix-routing:interface-name"] = ifName + ":" + localAddr + } else if ifName != "" { + neighbor["infix-routing:interface-name"] = ifName + } + + setIfPresent(neighbor, "dr-router-id", neigh, "routerDesignatedId") + setIfPresent(neighbor, "bdr-router-id", neigh, "routerDesignatedBackupId") + + neighbors = append(neighbors, neighbor) + } + + intf["ietf-ospf:neighbors"] = map[string]interface{}{ + "ietf-ospf:neighbor": neighbors, + } + interfaces = append(interfaces, intf) + } + + area["ietf-ospf:interfaces"] = map[string]interface{}{ + "ietf-ospf:interface": interfaces, + } + areas = append(areas, area) + } + + // Add routes + c.addOSPFRoutes(ctx, ospf) + + ospf["ietf-ospf:areas"] = map[string]interface{}{ + "ietf-ospf:area": areas, + } + + return map[string]interface{}{ + "type": "infix-routing:ospfv2", + "name": "default", + "ietf-ospf:ospf": ospf, + } +} + +func (c *RoutingCollector) addOSPFRoutes(ctx context.Context, ospf map[string]interface{}) { + out, err := c.cmd.Run(ctx, "vtysh", "-c", "show ip ospf route json") + if err != nil { + return + } + + var data map[string]interface{} + if json.Unmarshal(out, &data) != nil { + return + } + + var routes []interface{} + for prefix, infoRaw := range data { + if !strings.Contains(prefix, "/") { + continue + } + + info, ok := infoRaw.(map[string]interface{}) + if !ok { + continue + } + + route := map[string]interface{}{ + "prefix": prefix, + } + + if rt, ok := info["routeType"].(string); ok { + parts := strings.Fields(rt) + if len(parts) > 1 { + switch parts[1] { + case "E1": + route["route-type"] = "external-1" + case "E2": + route["route-type"] = "external-2" + case "IA": + route["route-type"] = "inter-area" + } + } else if len(parts) > 0 && parts[0] == "N" { + route["route-type"] = "intra-area" + } + } + + if v := info["area"]; v != nil { + route["infix-routing:area-id"] = v + } + + if v := info["cost"]; v != nil { + route["metric"] = v + } else if v := info["metric"]; v != nil { + route["metric"] = v + } + + if v := info["tag"]; v != nil { + route["route-tag"] = v + } + + var nexthops []interface{} + hopsRaw, _ := info["nexthops"].([]interface{}) + for _, hopRaw := range hopsRaw { + hop, ok := hopRaw.(map[string]interface{}) + if !ok { + continue + } + nh := make(map[string]interface{}) + ip, _ := hop["ip"].(string) + if ip != "" && ip != " " { + nh["next-hop"] = ip + } else if da, ok := hop["directlyAttachedTo"].(string); ok { + nh["outgoing-interface"] = da + } + nexthops = append(nexthops, nh) + } + + route["next-hops"] = map[string]interface{}{ + "next-hop": nexthops, + } + routes = append(routes, route) + } + + if len(routes) > 0 { + ospf["ietf-ospf:local-rib"] = map[string]interface{}{ + "ietf-ospf:route": routes, + } + } +} + +// --- RIP --- + +var ripStatusUpdateRe = regexp.MustCompile(`Sending updates every (\d+) seconds`) +var ripStatusTimeoutRe = regexp.MustCompile(`Timeout after (\d+) seconds`) +var ripStatusFlushRe = regexp.MustCompile(`garbage collect after (\d+) seconds`) +var ripStatusMetricRe = regexp.MustCompile(`Default redistribution metric is (\d+)`) +var ripStatusDistanceRe = regexp.MustCompile(`Distance: \(default is (\d+)\)`) + +func (c *RoutingCollector) collectRIP(ctx context.Context) interface{} { + statusOut, err := c.cmd.Run(ctx, "vtysh", "-c", "show ip rip status") + if err != nil { + return nil + } + statusText := string(statusOut) + if statusText == "" { + return nil + } + + status := parseRIPStatus(statusText) + if len(status) == 0 { + return nil + } + + rip := make(map[string]interface{}) + + if v, ok := status["distance"]; ok { + rip["distance"] = v + } + if v, ok := status["default-metric"]; ok { + rip["default-metric"] = v + } + + timers := make(map[string]interface{}) + if v, ok := status["update-interval"]; ok { + timers["update-interval"] = v + } + if v, ok := status["invalid-interval"]; ok { + timers["invalid-interval"] = v + } + if v, ok := status["flush-interval"]; ok { + timers["flush-interval"] = v + } + if len(timers) > 0 { + rip["timers"] = timers + } + + if ifaces, ok := status["interfaces"].([]interface{}); ok && len(ifaces) > 0 { + var ifaceList []interface{} + for _, ifRaw := range ifaces { + ifData, ok := ifRaw.(map[string]interface{}) + if !ok { + continue + } + entry := map[string]interface{}{ + "interface": ifData["name"], + "oper-status": "up", + } + if sv, ok := ifData["send-version"].(int); ok { + entry["send-version"] = strconv.Itoa(sv) + } + if rv, ok := ifData["recv-version"].(int); ok { + entry["receive-version"] = strconv.Itoa(rv) + } + ifaceList = append(ifaceList, entry) + } + if len(ifaceList) > 0 { + rip["interfaces"] = map[string]interface{}{ + "interface": ifaceList, + } + } + } + + routeOut, err := c.cmd.Run(ctx, "vtysh", "-c", "show ip route rip json") + if err == nil { + var routeData map[string]interface{} + if json.Unmarshal(routeOut, &routeData) == nil { + var routes []interface{} + for prefix, entriesRaw := range routeData { + if !strings.Contains(prefix, "/") { + continue + } + entries, ok := entriesRaw.([]interface{}) + if !ok || len(entries) == 0 { + continue + } + entry, ok := entries[0].(map[string]interface{}) + if !ok { + continue + } + + route := map[string]interface{}{ + "ipv4-prefix": prefix, + "route-type": "rip", + } + if m, ok := entry["metric"]; ok { + route["metric"] = toInt(m) + } + + nexthops, _ := entry["nexthops"].([]interface{}) + if len(nexthops) > 0 { + firstHop, _ := nexthops[0].(map[string]interface{}) + if ip, ok := firstHop["ip"].(string); ok && ip != "" { + route["next-hop"] = ip + } + if ifName, ok := firstHop["interfaceName"].(string); ok && ifName != "" { + route["interface"] = ifName + } + } + routes = append(routes, route) + } + + if len(routes) > 0 { + if _, ok := rip["ipv4"]; !ok { + rip["ipv4"] = make(map[string]interface{}) + } + rip["ipv4"].(map[string]interface{})["routes"] = map[string]interface{}{ + "route": routes, + } + rip["num-of-routes"] = len(routes) + } + } + } + + if neighs, ok := status["neighbors"].([]interface{}); ok && len(neighs) > 0 { + var neighborList []interface{} + for _, nRaw := range neighs { + nd, ok := nRaw.(map[string]interface{}) + if !ok { + continue + } + entry := map[string]interface{}{ + "ipv4-address": nd["address"], + } + if v, ok := nd["bad-packets"].(int); ok { + entry["bad-packets-rcvd"] = v + } + if v, ok := nd["bad-routes"].(int); ok { + entry["bad-routes-rcvd"] = v + } + neighborList = append(neighborList, entry) + } + if len(neighborList) > 0 { + if _, ok := rip["ipv4"]; !ok { + rip["ipv4"] = make(map[string]interface{}) + } + rip["ipv4"].(map[string]interface{})["neighbors"] = map[string]interface{}{ + "neighbor": neighborList, + } + } + } + + return map[string]interface{}{ + "type": "infix-routing:ripv2", + "name": "default", + "ietf-rip:rip": rip, + } +} + +// parseRIPStatus parses the text output of 'show ip rip status'. +func parseRIPStatus(text string) map[string]interface{} { + status := make(map[string]interface{}) + + if m := ripStatusUpdateRe.FindStringSubmatch(text); m != nil { + v, _ := strconv.Atoi(m[1]) + status["update-interval"] = v + } + if m := ripStatusTimeoutRe.FindStringSubmatch(text); m != nil { + v, _ := strconv.Atoi(m[1]) + status["invalid-interval"] = v + } + if m := ripStatusFlushRe.FindStringSubmatch(text); m != nil { + v, _ := strconv.Atoi(m[1]) + status["flush-interval"] = v + } + if m := ripStatusMetricRe.FindStringSubmatch(text); m != nil { + v, _ := strconv.Atoi(m[1]) + status["default-metric"] = v + } + if m := ripStatusDistanceRe.FindStringSubmatch(text); m != nil { + v, _ := strconv.Atoi(m[1]) + status["distance"] = v + } + + // Parse interface table + lines := strings.Split(text, "\n") + var interfaces []interface{} + inIfaceSection := false + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, "Interface") && strings.Contains(line, "Send") && strings.Contains(line, "Recv") { + inIfaceSection = true + continue + } + if inIfaceSection && (strings.HasPrefix(line, "Routing for Networks:") || strings.HasPrefix(line, "Routing Information Sources:")) { + break + } + if inIfaceSection && line != "" { + parts := strings.Fields(line) + if len(parts) >= 3 && !strings.HasPrefix(line, "Interface") { + sendVer, err1 := strconv.Atoi(parts[1]) + recvVer, err2 := strconv.Atoi(parts[2]) + if err1 == nil && err2 == nil { + interfaces = append(interfaces, map[string]interface{}{ + "name": parts[0], + "send-version": sendVer, + "recv-version": recvVer, + }) + } + } + } + } + if len(interfaces) > 0 { + status["interfaces"] = interfaces + } + + // Parse Routing Information Sources table (neighbors) + var neighbors []interface{} + inNeighborSection := false + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Routing Information Sources:") { + inNeighborSection = true + continue + } + if inNeighborSection && strings.Contains(line, "Gateway") && strings.Contains(line, "BadPackets") { + continue + } + if inNeighborSection && (strings.HasPrefix(line, "Distance:") || (line == "" && len(neighbors) > 0)) { + break + } + if inNeighborSection && line != "" { + parts := strings.Fields(line) + if len(parts) >= 5 { + badPkts, err1 := strconv.Atoi(parts[1]) + badRoutes, err2 := strconv.Atoi(parts[2]) + if err1 == nil && err2 == nil { + neighbors = append(neighbors, map[string]interface{}{ + "address": parts[0], + "bad-packets": badPkts, + "bad-routes": badRoutes, + }) + } + } + } + } + if len(neighbors) > 0 { + status["neighbors"] = neighbors + } + + return status +} + +// --- BFD --- + +var bfdStateMap = map[string]string{ + "up": "up", + "down": "down", + "init": "init", + "adminDown": "adminDown", +} + +func (c *RoutingCollector) collectBFD(ctx context.Context) interface{} { + out, err := c.cmd.Run(ctx, "vtysh", "-c", "show bfd peers json") + if err != nil { + return nil + } + + var data []interface{} + if json.Unmarshal(out, &data) != nil || len(data) == 0 { + return nil + } + + var sessions []interface{} + for _, peerRaw := range data { + peer, ok := peerRaw.(map[string]interface{}) + if !ok { + continue + } + // Only process single-hop sessions (multihop == false) + if mh, _ := peer["multihop"].(bool); mh { + continue + } + + session := map[string]interface{}{ + "interface": strDefault(peer["interface"], "unknown"), + "dest-addr": strDefault(peer["peer"], "0.0.0.0"), + } + + if v := peer["id"]; v != nil { + session["local-discriminator"] = v + } + if v := peer["remote-id"]; v != nil { + session["remote-discriminator"] = v + } + + state := strDefault(peer["status"], "down") + ietfState := bfdStateMap[state] + if ietfState == "" { + ietfState = "down" + } + + sessionRunning := map[string]interface{}{ + "local-state": ietfState, + "remote-state": ietfState, + "local-diagnostic": "none", + "detection-mode": "async-without-echo", + } + + if v := peer["receive-interval"]; v != nil { + sessionRunning["negotiated-rx-interval"] = toInt(v) * 1000 + } + if v := peer["transmit-interval"]; v != nil { + sessionRunning["negotiated-tx-interval"] = toInt(v) * 1000 + } + if dm := peer["detect-multiplier"]; dm != nil { + if ri := peer["receive-interval"]; ri != nil { + detectionTimeMs := toInt(dm) * toInt(ri) + sessionRunning["detection-time"] = detectionTimeMs * 1000 + } + } + + session["session-running"] = sessionRunning + session["path-type"] = "ietf-bfd-types:path-ip-sh" + session["ip-encapsulation"] = true + + sessions = append(sessions, session) + } + + if len(sessions) == 0 { + return nil + } + + return map[string]interface{}{ + "type": "infix-routing:bfdv1", + "name": "bfd", + "ietf-bfd:bfd": map[string]interface{}{ + "ietf-bfd-ip-sh:ip-sh": map[string]interface{}{ + "sessions": map[string]interface{}{ + "session": sessions, + }, + }, + }, + } +} + +// --- Helpers --- + +func setIfPresent(dst map[string]interface{}, dstKey string, src map[string]interface{}, srcKey string) { + if v, ok := src[srcKey]; ok && v != nil { + dst[dstKey] = v + } +} + +func setIfPresentInt(dst map[string]interface{}, dstKey string, src map[string]interface{}, srcKey string) { + if v, ok := src[srcKey]; ok && v != nil { + dst[dstKey] = toInt(v) + } +} + +func strDefault(v interface{}, def string) string { + if s, ok := v.(string); ok && s != "" { + return s + } + return def +} diff --git a/src/yangerd/internal/collector/routing_test.go b/src/yangerd/internal/collector/routing_test.go new file mode 100644 index 00000000..86409ddb --- /dev/null +++ b/src/yangerd/internal/collector/routing_test.go @@ -0,0 +1,721 @@ +package collector + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/testutil" + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +// Canned FRR JSON matching real /usr/libexec/statd/ospf-status output. +const testOSPFStatus = `{ + "routerId": "10.0.0.1", + "areas": { + "0.0.0.0": { + "area-type": "ietf-ospf:normal-area", + "interfaces": [ + { + "name": "e0", + "state": "DR", + "ospfEnabled": true, + "networkType": "BROADCAST", + "cost": 10, + "priority": 1, + "timerDeadSecs": 40, + "timerRetransmitSecs": 5, + "transmitDelaySecs": 1, + "timerMsecs": 10000, + "timerHelloInMsecs": 7000, + "timerWaitSecs": 40, + "drId": "10.0.0.1", + "drAddress": "192.168.1.1", + "bdrId": "10.0.0.2", + "bdrAddress": "192.168.1.2", + "neighbors": [ + { + "neighborIp": "10.0.0.2", + "ifaceAddress": "192.168.1.2", + "nbrPriority": 1, + "nbrState": "Full/DR", + "role": "Backup", + "lastPrgrsvChangeMsec": 120000, + "routerDeadIntervalTimerDueMsec": 35000, + "routerDesignatedId": "10.0.0.1", + "routerDesignatedBackupId": "10.0.0.2", + "ifaceName": "e0", + "localIfaceAddress": "192.168.1.1" + } + ] + }, + { + "name": "lo", + "state": "Loopback", + "ospfEnabled": true, + "networkType": "POINTOPOINT", + "cost": 0, + "priority": 0, + "timerPassiveIface": true, + "timerDeadSecs": 0, + "timerRetransmitSecs": 0, + "transmitDelaySecs": 0, + "timerMsecs": 10000, + "neighbors": [] + } + ] + } + } +}` + +const testOSPFRoutes = `{ + "10.0.0.0/24": { + "routeType": "N IA", + "area": "0.0.0.0", + "cost": 20, + "nexthops": [ + {"ip": "192.168.1.2", "via": "e0"} + ] + }, + "10.0.1.0/24": { + "routeType": "N E2", + "area": "0.0.0.0", + "cost": 100, + "tag": 42, + "nexthops": [ + {"ip": " ", "directlyAttachedTo": "e0"} + ] + } +}` + +const testRIPStatus = `Routing Protocol is "rip" + Sending updates every 30 seconds with +/-50%, next due in 12 seconds + Timeout after 180 seconds, garbage collect after 120 seconds + Outgoing update filter list for all interface is not set + Incoming update filter list for all interface is not set + Default redistribution metric is 1 + Redistributing: + Default version control: send version 2, receive version 2 + Interface Send Recv Key-chain + e0 2 2 + e1 2 2 + Routing for Networks: + 10.0.0.0/24 + 10.0.1.0/24 + Routing Information Sources: + Gateway BadPackets BadRoutes Distance Last Update + 10.0.0.2 0 0 120 00:00:12 + 10.0.0.3 1 2 120 00:00:25 + Distance: (default is 120) +` + +const testRIPRoutes = `{ + "10.0.0.0/24": [ + { + "prefix": "10.0.0.0/24", + "protocol": "rip", + "metric": 1, + "nexthops": [ + {"ip": "10.0.0.2", "interfaceName": "e0"} + ] + } + ], + "10.0.1.0/24": [ + { + "prefix": "10.0.1.0/24", + "protocol": "rip", + "metric": 2, + "nexthops": [ + {"ip": "10.0.0.3", "interfaceName": "e1"} + ] + } + ] +}` + +const testBFDPeers = `[ + { + "multihop": false, + "peer": "10.0.0.2", + "interface": "e0", + "id": 1, + "remote-id": 2, + "status": "up", + "receive-interval": 300, + "transmit-interval": 300, + "detect-multiplier": 3 + }, + { + "multihop": true, + "peer": "10.0.0.99", + "interface": "e1", + "id": 5, + "remote-id": 6, + "status": "down" + } +]` + +func newRoutingCollector(runner *testutil.MockRunner) *RoutingCollector { + return NewRoutingCollector(runner, 10*time.Second) +} + +func routingCollect(t *testing.T, runner *testutil.MockRunner) map[string]interface{} { + t.Helper() + c := newRoutingCollector(runner) + tr := tree.New() + if err := c.Collect(context.Background(), tr); err != nil { + t.Fatalf("Collect failed: %v", err) + } + raw := tr.Get("ietf-routing:routing") + if raw == nil { + t.Fatal("missing ietf-routing:routing in tree") + } + var out map[string]interface{} + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal routing: %v", err) + } + return out +} + +func fullRunner() *testutil.MockRunner { + return &testutil.MockRunner{ + Results: map[string][]byte{ + "/usr/libexec/statd/ospf-status": []byte(testOSPFStatus), + "vtysh -c show ip ospf route json": []byte(testOSPFRoutes), + "vtysh -c show ip rip status": []byte(testRIPStatus), + "vtysh -c show ip route rip json": []byte(testRIPRoutes), + "vtysh -c show bfd peers json": []byte(testBFDPeers), + }, + Errors: map[string]error{}, + } +} + +func TestRoutingCollectorNameAndInterval(t *testing.T) { + c := newRoutingCollector(fullRunner()) + if c.Name() != "routing" { + t.Fatalf("expected name 'routing', got %q", c.Name()) + } + if c.Interval() != 10*time.Second { + t.Fatalf("expected interval 10s, got %v", c.Interval()) + } +} + +func TestRoutingCollectorMergesThreeProtocols(t *testing.T) { + out := routingCollect(t, fullRunner()) + cpp := out["control-plane-protocols"].(map[string]interface{}) + protocols := cpp["control-plane-protocol"].([]interface{}) + if len(protocols) != 3 { + t.Fatalf("expected 3 protocols (OSPF+RIP+BFD), got %d", len(protocols)) + } + + types := make(map[string]bool) + for _, p := range protocols { + pm := p.(map[string]interface{}) + types[pm["type"].(string)] = true + } + for _, expected := range []string{"infix-routing:ospfv2", "infix-routing:ripv2", "infix-routing:bfdv1"} { + if !types[expected] { + t.Fatalf("missing protocol type %q; got %v", expected, types) + } + } +} + +// --- OSPF tests --- + +func getOSPFProtocol(t *testing.T, out map[string]interface{}) map[string]interface{} { + t.Helper() + cpp := out["control-plane-protocols"].(map[string]interface{}) + for _, p := range cpp["control-plane-protocol"].([]interface{}) { + pm := p.(map[string]interface{}) + if pm["type"] == "infix-routing:ospfv2" { + return pm + } + } + t.Fatal("OSPF protocol not found") + return nil +} + +func TestOSPFRouterID(t *testing.T) { + out := routingCollect(t, fullRunner()) + ospfProto := getOSPFProtocol(t, out) + ospf := ospfProto["ietf-ospf:ospf"].(map[string]interface{}) + if ospf["ietf-ospf:router-id"] != "10.0.0.1" { + t.Fatalf("router-id: expected 10.0.0.1, got %v", ospf["ietf-ospf:router-id"]) + } + if ospf["ietf-ospf:address-family"] != "ipv4" { + t.Fatalf("address-family: expected ipv4, got %v", ospf["ietf-ospf:address-family"]) + } +} + +func TestOSPFAreaAndInterfaces(t *testing.T) { + out := routingCollect(t, fullRunner()) + ospfProto := getOSPFProtocol(t, out) + ospf := ospfProto["ietf-ospf:ospf"].(map[string]interface{}) + areasContainer := ospf["ietf-ospf:areas"].(map[string]interface{}) + areas := areasContainer["ietf-ospf:area"].([]interface{}) + if len(areas) != 1 { + t.Fatalf("expected 1 area, got %d", len(areas)) + } + + area := areas[0].(map[string]interface{}) + if area["ietf-ospf:area-id"] != "0.0.0.0" { + t.Fatalf("area-id: expected 0.0.0.0, got %v", area["ietf-ospf:area-id"]) + } + + ifacesContainer := area["ietf-ospf:interfaces"].(map[string]interface{}) + ifaces := ifacesContainer["ietf-ospf:interface"].([]interface{}) + if len(ifaces) != 2 { + t.Fatalf("expected 2 interfaces, got %d", len(ifaces)) + } + + // First interface: e0 (DR) + e0 := ifaces[0].(map[string]interface{}) + if e0["name"] != "e0" { + t.Fatalf("interface[0] name: expected e0, got %v", e0["name"]) + } + if e0["state"] != "dr" { + t.Fatalf("interface[0] state: expected dr, got %v", e0["state"]) + } + if e0["interface-type"] != "broadcast" { + t.Fatalf("interface[0] type: expected broadcast, got %v", e0["interface-type"]) + } + if e0["passive"] != false { + t.Fatalf("e0 passive: expected false, got %v", e0["passive"]) + } + if e0["enabled"] != true { + t.Fatalf("e0 enabled: expected true, got %v", e0["enabled"]) + } + if e0["dr-router-id"] != "10.0.0.1" { + t.Fatalf("e0 dr-router-id: expected 10.0.0.1, got %v", e0["dr-router-id"]) + } + + // Check timer conversions (ms → seconds) + if toInt(e0["hello-interval"]) != 10 { + t.Fatalf("e0 hello-interval: expected 10, got %v", e0["hello-interval"]) + } + if toInt(e0["hello-timer"]) != 7 { + t.Fatalf("e0 hello-timer: expected 7, got %v", e0["hello-timer"]) + } + if toInt(e0["cost"]) != 10 { + t.Fatalf("e0 cost: expected 10, got %v", e0["cost"]) + } + + // Second interface: lo (passive loopback) + lo := ifaces[1].(map[string]interface{}) + if lo["name"] != "lo" { + t.Fatalf("interface[1] name: expected lo, got %v", lo["name"]) + } + if lo["state"] != "loopback" { + t.Fatalf("lo state: expected loopback, got %v", lo["state"]) + } + if lo["passive"] != true { + t.Fatalf("lo passive: expected true, got %v", lo["passive"]) + } + if lo["interface-type"] != "point-to-point" { + t.Fatalf("lo type: expected point-to-point, got %v", lo["interface-type"]) + } +} + +func TestOSPFNeighbors(t *testing.T) { + out := routingCollect(t, fullRunner()) + ospfProto := getOSPFProtocol(t, out) + ospf := ospfProto["ietf-ospf:ospf"].(map[string]interface{}) + areasContainer := ospf["ietf-ospf:areas"].(map[string]interface{}) + area := areasContainer["ietf-ospf:area"].([]interface{})[0].(map[string]interface{}) + ifacesContainer := area["ietf-ospf:interfaces"].(map[string]interface{}) + e0 := ifacesContainer["ietf-ospf:interface"].([]interface{})[0].(map[string]interface{}) + + neighborsContainer := e0["ietf-ospf:neighbors"].(map[string]interface{}) + neighbors := neighborsContainer["ietf-ospf:neighbor"].([]interface{}) + if len(neighbors) != 1 { + t.Fatalf("expected 1 neighbor, got %d", len(neighbors)) + } + + n := neighbors[0].(map[string]interface{}) + if n["neighbor-router-id"] != "10.0.0.2" { + t.Fatalf("neighbor router-id: expected 10.0.0.2, got %v", n["neighbor-router-id"]) + } + if n["address"] != "192.168.1.2" { + t.Fatalf("neighbor address: expected 192.168.1.2, got %v", n["address"]) + } + if n["state"] != "full" { + t.Fatalf("neighbor state: expected full, got %v", n["state"]) + } + if n["infix-routing:role"] != "BDR" { + t.Fatalf("neighbor role: expected BDR, got %v", n["infix-routing:role"]) + } + // Uptime: 120000ms → 120s + if toInt(n["infix-routing:uptime"]) != 120 { + t.Fatalf("neighbor uptime: expected 120, got %v", n["infix-routing:uptime"]) + } + // Dead timer: 35000ms → 35s + if toInt(n["dead-timer"]) != 35 { + t.Fatalf("neighbor dead-timer: expected 35, got %v", n["dead-timer"]) + } + // Interface name augmentation + if n["infix-routing:interface-name"] != "e0:192.168.1.1" { + t.Fatalf("neighbor interface-name: expected e0:192.168.1.1, got %v", n["infix-routing:interface-name"]) + } +} + +func TestOSPFRoutes(t *testing.T) { + out := routingCollect(t, fullRunner()) + ospfProto := getOSPFProtocol(t, out) + ospf := ospfProto["ietf-ospf:ospf"].(map[string]interface{}) + rib := ospf["ietf-ospf:local-rib"].(map[string]interface{}) + routes := rib["ietf-ospf:route"].([]interface{}) + if len(routes) != 2 { + t.Fatalf("expected 2 OSPF routes, got %d", len(routes)) + } + + routeByPrefix := make(map[string]map[string]interface{}) + for _, r := range routes { + rm := r.(map[string]interface{}) + routeByPrefix[rm["prefix"].(string)] = rm + } + + // Inter-area route + r1 := routeByPrefix["10.0.0.0/24"] + if r1 == nil { + t.Fatal("missing route 10.0.0.0/24") + } + if r1["route-type"] != "inter-area" { + t.Fatalf("route 10.0.0.0/24 type: expected inter-area, got %v", r1["route-type"]) + } + + // External-2 route with tag + r2 := routeByPrefix["10.0.1.0/24"] + if r2 == nil { + t.Fatal("missing route 10.0.1.0/24") + } + if r2["route-type"] != "external-2" { + t.Fatalf("route 10.0.1.0/24 type: expected external-2, got %v", r2["route-type"]) + } + // tag should be present + if r2["route-tag"] == nil { + t.Fatal("route 10.0.1.0/24 should have route-tag") + } + // Directly attached nexthop + nhs := r2["next-hops"].(map[string]interface{}) + nhList := nhs["next-hop"].([]interface{}) + if len(nhList) != 1 { + t.Fatalf("expected 1 nexthop, got %d", len(nhList)) + } + nh := nhList[0].(map[string]interface{}) + if nh["outgoing-interface"] != "e0" { + t.Fatalf("nexthop outgoing-interface: expected e0, got %v", nh["outgoing-interface"]) + } +} + +// --- RIP tests --- + +func getRIPProtocol(t *testing.T, out map[string]interface{}) map[string]interface{} { + t.Helper() + cpp := out["control-plane-protocols"].(map[string]interface{}) + for _, p := range cpp["control-plane-protocol"].([]interface{}) { + pm := p.(map[string]interface{}) + if pm["type"] == "infix-routing:ripv2" { + return pm + } + } + t.Fatal("RIP protocol not found") + return nil +} + +func TestRIPTimers(t *testing.T) { + out := routingCollect(t, fullRunner()) + ripProto := getRIPProtocol(t, out) + rip := ripProto["ietf-rip:rip"].(map[string]interface{}) + + timers := rip["timers"].(map[string]interface{}) + if toInt(timers["update-interval"]) != 30 { + t.Fatalf("RIP update-interval: expected 30, got %v", timers["update-interval"]) + } + if toInt(timers["invalid-interval"]) != 180 { + t.Fatalf("RIP invalid-interval: expected 180, got %v", timers["invalid-interval"]) + } + if toInt(timers["flush-interval"]) != 120 { + t.Fatalf("RIP flush-interval: expected 120, got %v", timers["flush-interval"]) + } + + if toInt(rip["default-metric"]) != 1 { + t.Fatalf("RIP default-metric: expected 1, got %v", rip["default-metric"]) + } + if toInt(rip["distance"]) != 120 { + t.Fatalf("RIP distance: expected 120, got %v", rip["distance"]) + } +} + +func TestRIPInterfaces(t *testing.T) { + out := routingCollect(t, fullRunner()) + ripProto := getRIPProtocol(t, out) + rip := ripProto["ietf-rip:rip"].(map[string]interface{}) + + ifContainer := rip["interfaces"].(map[string]interface{}) + ifaces := ifContainer["interface"].([]interface{}) + if len(ifaces) != 2 { + t.Fatalf("expected 2 RIP interfaces, got %d", len(ifaces)) + } + + iface0 := ifaces[0].(map[string]interface{}) + if iface0["interface"] != "e0" { + t.Fatalf("RIP iface[0]: expected e0, got %v", iface0["interface"]) + } + if iface0["oper-status"] != "up" { + t.Fatalf("RIP iface[0] status: expected up, got %v", iface0["oper-status"]) + } + if iface0["send-version"] != "2" { + t.Fatalf("RIP iface[0] send-version: expected '2', got %v", iface0["send-version"]) + } +} + +func TestRIPRoutes(t *testing.T) { + out := routingCollect(t, fullRunner()) + ripProto := getRIPProtocol(t, out) + rip := ripProto["ietf-rip:rip"].(map[string]interface{}) + + ipv4 := rip["ipv4"].(map[string]interface{}) + routesContainer := ipv4["routes"].(map[string]interface{}) + routes := routesContainer["route"].([]interface{}) + if len(routes) != 2 { + t.Fatalf("expected 2 RIP routes, got %d", len(routes)) + } + + if toInt(rip["num-of-routes"]) != 2 { + t.Fatalf("RIP num-of-routes: expected 2, got %v", rip["num-of-routes"]) + } +} + +func TestRIPNeighbors(t *testing.T) { + out := routingCollect(t, fullRunner()) + ripProto := getRIPProtocol(t, out) + rip := ripProto["ietf-rip:rip"].(map[string]interface{}) + + ipv4 := rip["ipv4"].(map[string]interface{}) + neighContainer := ipv4["neighbors"].(map[string]interface{}) + neighs := neighContainer["neighbor"].([]interface{}) + if len(neighs) != 2 { + t.Fatalf("expected 2 RIP neighbors, got %d", len(neighs)) + } + + n0 := neighs[0].(map[string]interface{}) + if n0["ipv4-address"] != "10.0.0.2" { + t.Fatalf("RIP neighbor[0] address: expected 10.0.0.2, got %v", n0["ipv4-address"]) + } + // Bad packets/routes should be int (from text parse) + if toInt(n0["bad-packets-rcvd"]) != 0 { + t.Fatalf("RIP neighbor[0] bad-packets: expected 0, got %v", n0["bad-packets-rcvd"]) + } + + n1 := neighs[1].(map[string]interface{}) + if toInt(n1["bad-packets-rcvd"]) != 1 { + t.Fatalf("RIP neighbor[1] bad-packets: expected 1, got %v", n1["bad-packets-rcvd"]) + } + if toInt(n1["bad-routes-rcvd"]) != 2 { + t.Fatalf("RIP neighbor[1] bad-routes: expected 2, got %v", n1["bad-routes-rcvd"]) + } +} + +// --- BFD tests --- + +func getBFDProtocol(t *testing.T, out map[string]interface{}) map[string]interface{} { + t.Helper() + cpp := out["control-plane-protocols"].(map[string]interface{}) + for _, p := range cpp["control-plane-protocol"].([]interface{}) { + pm := p.(map[string]interface{}) + if pm["type"] == "infix-routing:bfdv1" { + return pm + } + } + t.Fatal("BFD protocol not found") + return nil +} + +func TestBFDSessions(t *testing.T) { + out := routingCollect(t, fullRunner()) + bfdProto := getBFDProtocol(t, out) + bfd := bfdProto["ietf-bfd:bfd"].(map[string]interface{}) + ipsh := bfd["ietf-bfd-ip-sh:ip-sh"].(map[string]interface{}) + sessionsContainer := ipsh["sessions"].(map[string]interface{}) + sessions := sessionsContainer["session"].([]interface{}) + + // Only single-hop sessions included (multihop=true is filtered) + if len(sessions) != 1 { + t.Fatalf("expected 1 BFD session (multihop filtered), got %d", len(sessions)) + } + + s := sessions[0].(map[string]interface{}) + if s["interface"] != "e0" { + t.Fatalf("BFD session interface: expected e0, got %v", s["interface"]) + } + if s["dest-addr"] != "10.0.0.2" { + t.Fatalf("BFD session dest-addr: expected 10.0.0.2, got %v", s["dest-addr"]) + } + if s["path-type"] != "ietf-bfd-types:path-ip-sh" { + t.Fatalf("BFD path-type: expected ietf-bfd-types:path-ip-sh, got %v", s["path-type"]) + } + + running := s["session-running"].(map[string]interface{}) + if running["local-state"] != "up" { + t.Fatalf("BFD local-state: expected up, got %v", running["local-state"]) + } + if running["detection-mode"] != "async-without-echo" { + t.Fatalf("BFD detection-mode: expected async-without-echo, got %v", running["detection-mode"]) + } + + // Intervals: ms → µs (×1000) + // receive-interval=300ms → 300000µs + if toInt(running["negotiated-rx-interval"]) != 300000 { + t.Fatalf("BFD rx-interval: expected 300000, got %v", running["negotiated-rx-interval"]) + } + if toInt(running["negotiated-tx-interval"]) != 300000 { + t.Fatalf("BFD tx-interval: expected 300000, got %v", running["negotiated-tx-interval"]) + } + // detection-time = detect-multiplier * receive-interval * 1000 = 3 * 300 * 1000 = 900000 + if toInt(running["detection-time"]) != 900000 { + t.Fatalf("BFD detection-time: expected 900000, got %v", running["detection-time"]) + } +} + +// --- Graceful degradation tests --- + +func TestRoutingCollectorOSPFOnly(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "/usr/libexec/statd/ospf-status": []byte(testOSPFStatus), + "vtysh -c show ip ospf route json": []byte(testOSPFRoutes), + }, + Errors: map[string]error{}, + } + + out := routingCollect(t, runner) + cpp := out["control-plane-protocols"].(map[string]interface{}) + protocols := cpp["control-plane-protocol"].([]interface{}) + if len(protocols) != 1 { + t.Fatalf("expected 1 protocol when only OSPF available, got %d", len(protocols)) + } + pm := protocols[0].(map[string]interface{}) + if pm["type"] != "infix-routing:ospfv2" { + t.Fatalf("expected OSPF protocol, got %v", pm["type"]) + } +} + +func TestRoutingCollectorAllFail(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{}, + Errors: map[string]error{}, + } + + c := newRoutingCollector(runner) + tr := tree.New() + err := c.Collect(context.Background(), tr) + if err != nil { + t.Fatalf("Collect should not error when all protocols fail: %v", err) + } + // No tree key should be set when there's nothing to report + if tr.Get("ietf-routing:routing") != nil { + t.Fatal("expected no ietf-routing:routing key when all protocols fail") + } +} + +func TestRIPStatusParsing(t *testing.T) { + status := parseRIPStatus(testRIPStatus) + + if status["update-interval"] != 30 { + t.Fatalf("update-interval: expected 30, got %v", status["update-interval"]) + } + if status["invalid-interval"] != 180 { + t.Fatalf("invalid-interval: expected 180, got %v", status["invalid-interval"]) + } + if status["flush-interval"] != 120 { + t.Fatalf("flush-interval: expected 120, got %v", status["flush-interval"]) + } + if status["default-metric"] != 1 { + t.Fatalf("default-metric: expected 1, got %v", status["default-metric"]) + } + if status["distance"] != 120 { + t.Fatalf("distance: expected 120, got %v", status["distance"]) + } + + ifaces := status["interfaces"].([]interface{}) + if len(ifaces) != 2 { + t.Fatalf("expected 2 parsed interfaces, got %d", len(ifaces)) + } + + neighs := status["neighbors"].([]interface{}) + if len(neighs) != 2 { + t.Fatalf("expected 2 parsed neighbors, got %d", len(neighs)) + } +} + +func TestFrrToIETFNeighborState(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Full/DR", "full"}, + {"TwoWay/DROther", "2-way"}, + {"Init/DROther", "init"}, + {"Down/DROther", "down"}, + {"ExStart", "exstart"}, + } + for _, tt := range tests { + got := frrToIETFNeighborState(tt.input) + if got != tt.expected { + t.Fatalf("frrToIETFNeighborState(%q): expected %q, got %q", tt.input, tt.expected, got) + } + } +} + +func TestOSPFNetworkType(t *testing.T) { + tests := []struct { + nt string + p2mpNB bool + expected string + }{ + {"POINTOPOINT", false, "point-to-point"}, + {"BROADCAST", false, "broadcast"}, + {"POINTOMULTIPOINT", false, "hybrid"}, + {"POINTOMULTIPOINT", true, "point-to-multipoint"}, + {"NBMA", false, "non-broadcast"}, + {"UNKNOWN", false, ""}, + } + for _, tt := range tests { + got := ospfNetworkType(tt.nt, tt.p2mpNB) + if got != tt.expected { + t.Fatalf("ospfNetworkType(%q, %v): expected %q, got %q", tt.nt, tt.p2mpNB, tt.expected, got) + } + } +} + +func TestBFDMultihopFiltered(t *testing.T) { + // Ensure multihop peers don't appear in output + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "vtysh -c show bfd peers json": []byte(`[ + {"multihop": true, "peer": "10.0.0.99", "interface": "e1", "id": 5, "status": "up"} + ]`), + }, + Errors: map[string]error{}, + } + + c := newRoutingCollector(runner) + tr := tree.New() + c.Collect(context.Background(), tr) + + // BFD should not set anything when all peers are multihop + raw := tr.Get("ietf-routing:routing") + if raw != nil { + // If routing is set, BFD should not be present + var out map[string]interface{} + json.Unmarshal(raw, &out) + cpp := out["control-plane-protocols"].(map[string]interface{}) + protocols := cpp["control-plane-protocol"].([]interface{}) + for _, p := range protocols { + pm := p.(map[string]interface{}) + if pm["type"] == "infix-routing:bfdv1" { + t.Fatal("multihop-only BFD should not produce a protocol entry") + } + } + } +} diff --git a/src/yangerd/internal/collector/runner.go b/src/yangerd/internal/collector/runner.go new file mode 100644 index 00000000..94e4da71 --- /dev/null +++ b/src/yangerd/internal/collector/runner.go @@ -0,0 +1,40 @@ +package collector + +import ( + "context" + "os" + "os/exec" + "path/filepath" +) + +// CommandRunner executes external commands and returns their stdout. +type CommandRunner interface { + Run(ctx context.Context, name string, args ...string) ([]byte, error) +} + +// FileReader reads files and globs paths on the filesystem. +type FileReader interface { + ReadFile(path string) ([]byte, error) + Glob(pattern string) ([]string, error) +} + +// ExecRunner is the production CommandRunner using os/exec. +type ExecRunner struct{} + +// Run executes name with args and returns combined stdout. +func (ExecRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).Output() +} + +// OSFileReader is the production FileReader using the os package. +type OSFileReader struct{} + +// ReadFile reads the named file. +func (OSFileReader) ReadFile(path string) ([]byte, error) { + return os.ReadFile(path) +} + +// Glob returns filenames matching the pattern. +func (OSFileReader) Glob(pattern string) ([]string, error) { + return filepath.Glob(pattern) +} diff --git a/src/yangerd/internal/collector/system.go b/src/yangerd/internal/collector/system.go new file mode 100644 index 00000000..17c210c0 --- /dev/null +++ b/src/yangerd/internal/collector/system.go @@ -0,0 +1,193 @@ +package collector + +import ( + "context" + "encoding/json" + "net" + "strconv" + "strings" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +var platformKeyMap = map[string]string{ + "NAME": "os-name", + "VERSION_ID": "os-version", + "BUILD_ID": "os-release", + "ARCHITECTURE": "machine", +} + +// SystemCollector gathers ietf-system operational data. +type SystemCollector struct { + cmd CommandRunner + fs FileReader + interval time.Duration +} + +// NewSystemCollector creates a SystemCollector with the given dependencies. +func NewSystemCollector(cmd CommandRunner, fs FileReader, interval time.Duration) *SystemCollector { + return &SystemCollector{cmd: cmd, fs: fs, interval: interval} +} + +// Name implements Collector. +func (c *SystemCollector) Name() string { return "system" } + +// Interval implements Collector. +func (c *SystemCollector) Interval() time.Duration { return c.interval } + +// Collect implements Collector. It merges DNS and service data into +// "ietf-system:system-state". Other system-state subtrees (platform, +// software, users, hostname, timezone, clock, memory, load, filesystems) +// are populated by boot-once, reactive, or on-demand providers. +func (c *SystemCollector) Collect(ctx context.Context, t *tree.Tree) error { + state := make(map[string]interface{}) + + c.addDNS(ctx, state) + c.addServices(ctx, state) + + if data, err := json.Marshal(state); err == nil { + t.Merge("ietf-system:system-state", data) + } + return nil +} + +func (c *SystemCollector) addDNS(ctx context.Context, state map[string]interface{}) { + dns := make(map[string]interface{}) + servers := make([]interface{}, 0) + var search []string + options := make(map[string]interface{}) + + headData, err := c.fs.ReadFile("/etc/resolv.conf.head") + if err == nil { + for _, line := range strings.Split(string(headData), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "nameserver") { + ip := strings.TrimSpace(strings.TrimPrefix(line, "nameserver")) + if net.ParseIP(ip) != nil { + servers = append(servers, map[string]interface{}{ + "address": ip, + "origin": "static", + }) + } + } else if strings.HasPrefix(line, "search") { + search = append(search, strings.Fields(line)[1:]...) + } else if strings.HasPrefix(line, "options") { + for _, opt := range strings.Fields(line)[1:] { + if strings.HasPrefix(opt, "timeout:") { + if v, err := strconv.Atoi(strings.TrimPrefix(opt, "timeout:")); err == nil { + options["timeout"] = v + } + } else if strings.HasPrefix(opt, "attempts:") { + if v, err := strconv.Atoi(strings.TrimPrefix(opt, "attempts:")); err == nil { + options["attempts"] = v + } + } + } + } + } + } + + resolvOut, err := c.cmd.Run(ctx, "/sbin/resolvconf", "-l") + if err == nil { + for _, line := range strings.Split(string(resolvOut), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "nameserver") { + hashParts := strings.SplitN(line, "#", 2) + ip := strings.TrimSpace(strings.TrimPrefix(hashParts[0], "nameserver")) + if net.ParseIP(ip) == nil { + continue + } + entry := map[string]interface{}{ + "address": ip, + "origin": "dhcp", + } + if len(hashParts) > 1 { + entry["interface"] = strings.TrimSpace(hashParts[1]) + } + servers = append(servers, entry) + } else if strings.HasPrefix(line, "search") { + hashParts := strings.SplitN(line, "#", 2) + search = append(search, strings.Fields(hashParts[0])[1:]...) + } + } + } + + if len(options) > 0 { + dns["options"] = options + } + dns["server"] = servers + if len(search) > 0 { + dns["search"] = search + } + + state["infix-system:dns-resolver"] = dns +} + +func (c *SystemCollector) addServices(ctx context.Context, state map[string]interface{}) { + out, err := c.cmd.Run(ctx, "initctl", "-j") + if err != nil { + return + } + + var initData []map[string]interface{} + if json.Unmarshal(out, &initData) != nil { + return + } + + var services []interface{} + for _, d := range initData { + pid, ok := d["pid"] + if !ok { + continue + } + identity, ok := d["identity"] + if !ok { + continue + } + svc := map[string]interface{}{ + "pid": toInt(pid), + "name": identity, + "status": d["status"], + "description": d["description"], + "statistics": map[string]interface{}{ + "memory-usage": strconv.Itoa(toInt(zeroIfNil(d["memory"]))), + "uptime": strconv.Itoa(toInt(zeroIfNil(d["uptime"]))), + "restart-count": toInt(zeroIfNil(d["restarts"])), + }, + } + services = append(services, svc) + } + + state["infix-system:services"] = map[string]interface{}{ + "service": services, + } +} + +func yangDateTime(t time.Time) string { + return t.Format("2006-01-02T15:04:05-07:00") +} + +func toInt(v interface{}) int { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case json.Number: + i, _ := n.Int64() + return int(i) + case string: + i, _ := strconv.Atoi(n) + return i + default: + return 0 + } +} + +func zeroIfNil(v interface{}) interface{} { + if v == nil { + return 0 + } + return v +} diff --git a/src/yangerd/internal/collector/system_test.go b/src/yangerd/internal/collector/system_test.go new file mode 100644 index 00000000..d3f29e02 --- /dev/null +++ b/src/yangerd/internal/collector/system_test.go @@ -0,0 +1,293 @@ +package collector + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/testutil" + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +const ( + testResolvHead = `nameserver 8.8.8.8 +nameserver 1.1.1.1 +search example.com local.lan +options timeout:2 attempts:3 +` + + testResolvconfOutput = `nameserver 10.0.0.1 # eth0 +nameserver 10.0.0.2 # eth1 +search dhcp.example.com # eth0 +` + + testInitctlJSON = `[ + { + "identity": "sshd", + "pid": 123, + "status": "running", + "description": "OpenSSH daemon", + "memory": 4096000, + "uptime": 3600, + "restarts": 2 + }, + { + "identity": "sysklogd", + "pid": 456, + "status": "running", + "description": "System logger", + "memory": 2048000, + "uptime": 7200, + "restarts": 0 + } +]` +) + +func newTestCollector() (*SystemCollector, *testutil.MockRunner, *testutil.MockFileReader) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "initctl -j": []byte(testInitctlJSON), + "/sbin/resolvconf -l": []byte(testResolvconfOutput), + }, + Errors: map[string]error{}, + } + + fs := &testutil.MockFileReader{ + Files: map[string][]byte{ + "/etc/resolv.conf.head": []byte(testResolvHead), + }, + Globs: map[string][]string{}, + } + + c := NewSystemCollector(runner, fs, 60*time.Second) + return c, runner, fs +} + +func collectToState(t *testing.T, c *SystemCollector) map[string]interface{} { + t.Helper() + tr := tree.New() + if err := c.Collect(context.Background(), tr); err != nil { + t.Fatalf("Collect failed: %v", err) + } + + stateRaw := tr.Get("ietf-system:system-state") + if stateRaw == nil { + t.Fatal("missing ietf-system:system-state in tree") + } + + state := make(map[string]interface{}) + if err := json.Unmarshal(stateRaw, &state); err != nil { + t.Fatalf("unmarshal system-state: %v", err) + } + return state +} + +func TestSystemCollectorName(t *testing.T) { + c, _, _ := newTestCollector() + if c.Name() != "system" { + t.Fatalf("expected name 'system', got %q", c.Name()) + } +} + +func TestSystemCollectorInterval(t *testing.T) { + c, _, _ := newTestCollector() + if c.Interval() != 60*time.Second { + t.Fatalf("expected interval 60s, got %v", c.Interval()) + } +} + +func TestSystemCollectorDNS(t *testing.T) { + c, _, _ := newTestCollector() + state := collectToState(t, c) + + dns, ok := state["infix-system:dns-resolver"].(map[string]interface{}) + if !ok { + t.Fatal("missing infix-system:dns-resolver in system-state") + } + + servers, ok := dns["server"].([]interface{}) + if !ok { + t.Fatal("missing server list in dns-resolver") + } + + // 2 static (resolv.conf.head) + 2 DHCP (resolvconf -l) = 4 total + if len(servers) != 4 { + t.Fatalf("expected 4 DNS servers, got %d: %v", len(servers), servers) + } + + s0 := servers[0].(map[string]interface{}) + if s0["address"] != "8.8.8.8" || s0["origin"] != "static" { + t.Fatalf("server[0]: expected 8.8.8.8/static, got %v", s0) + } + s1 := servers[1].(map[string]interface{}) + if s1["address"] != "1.1.1.1" || s1["origin"] != "static" { + t.Fatalf("server[1]: expected 1.1.1.1/static, got %v", s1) + } + + s2 := servers[2].(map[string]interface{}) + if s2["address"] != "10.0.0.1" || s2["origin"] != "dhcp" { + t.Fatalf("server[2]: expected 10.0.0.1/dhcp, got %v", s2) + } + if s2["interface"] != "eth0" { + t.Fatalf("server[2] interface: expected eth0, got %v", s2["interface"]) + } + + search, ok := dns["search"].([]interface{}) + if !ok || len(search) < 2 { + t.Fatalf("expected search domains, got %v", dns["search"]) + } + + options, ok := dns["options"].(map[string]interface{}) + if !ok { + t.Fatal("missing options in dns-resolver") + } + if int(options["timeout"].(float64)) != 2 { + t.Fatalf("dns timeout: expected 2, got %v", options["timeout"]) + } + if int(options["attempts"].(float64)) != 3 { + t.Fatalf("dns attempts: expected 3, got %v", options["attempts"]) + } +} + +func TestSystemCollectorServices(t *testing.T) { + c, _, _ := newTestCollector() + state := collectToState(t, c) + + svcs, ok := state["infix-system:services"].(map[string]interface{}) + if !ok { + t.Fatal("missing infix-system:services in system-state") + } + + serviceList, ok := svcs["service"].([]interface{}) + if !ok || len(serviceList) != 2 { + t.Fatalf("expected 2 services, got %v", svcs["service"]) + } + + svc0 := serviceList[0].(map[string]interface{}) + if svc0["name"] != "sshd" { + t.Fatalf("service[0] name: expected sshd, got %v", svc0["name"]) + } + if int(svc0["pid"].(float64)) != 123 { + t.Fatalf("service[0] pid: expected 123, got %v", svc0["pid"]) + } + + stats := svc0["statistics"].(map[string]interface{}) + if stats["memory-usage"] != "4096000" { + t.Fatalf("service[0] memory-usage: expected '4096000', got %v", stats["memory-usage"]) + } + if stats["uptime"] != "3600" { + t.Fatalf("service[0] uptime: expected '3600', got %v", stats["uptime"]) + } + if int(stats["restart-count"].(float64)) != 2 { + t.Fatalf("service[0] restart-count: expected 2, got %v", stats["restart-count"]) + } +} + +func TestSystemCollectorCommandFailureGraceful(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{}, + Errors: map[string]error{ + "initctl -j": fmt.Errorf("not available"), + "/sbin/resolvconf -l": fmt.Errorf("not available"), + }, + } + + fs := &testutil.MockFileReader{ + Files: map[string][]byte{}, + Globs: map[string][]string{}, + } + + c := NewSystemCollector(runner, fs, 60*time.Second) + tr := tree.New() + err := c.Collect(context.Background(), tr) + if err != nil { + t.Fatalf("Collect should not return error on partial failures: %v", err) + } + + if tr.Get("ietf-system:system-state") == nil { + t.Fatal("ietf-system:system-state should be set even with command failures") + } +} + +func TestSystemCollectorTreeKeys(t *testing.T) { + c, _, _ := newTestCollector() + tr := tree.New() + c.Collect(context.Background(), tr) + + keys := tr.Keys() + if len(keys) != 1 { + t.Fatalf("expected exactly 1 tree key, got %d: %v", len(keys), keys) + } + if keys[0] != "ietf-system:system-state" { + t.Fatalf("expected tree key 'ietf-system:system-state', got %q", keys[0]) + } +} + +func TestSystemCollectorServicesNilFields(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "initctl -j": []byte(`[{"identity":"minimal","pid":999,"status":"running","description":"Minimal service"}]`), + "/sbin/resolvconf -l": []byte(""), + }, + Errors: map[string]error{}, + } + + fs := &testutil.MockFileReader{ + Files: map[string][]byte{}, + Globs: map[string][]string{}, + } + + c := NewSystemCollector(runner, fs, 60*time.Second) + state := collectToState(t, c) + + svcs := state["infix-system:services"].(map[string]interface{}) + serviceList := svcs["service"].([]interface{}) + if len(serviceList) != 1 { + t.Fatalf("expected 1 service, got %d", len(serviceList)) + } + + svc := serviceList[0].(map[string]interface{}) + stats := svc["statistics"].(map[string]interface{}) + + if stats["memory-usage"] != "0" { + t.Fatalf("nil memory should become '0', got %v", stats["memory-usage"]) + } + if stats["uptime"] != "0" { + t.Fatalf("nil uptime should become '0', got %v", stats["uptime"]) + } + if int(stats["restart-count"].(float64)) != 0 { + t.Fatalf("nil restarts should become 0, got %v", stats["restart-count"]) + } +} + +func TestSystemCollectorNoDNSEmptyArray(t *testing.T) { + runner := &testutil.MockRunner{ + Results: map[string][]byte{ + "initctl -j": []byte(testInitctlJSON), + "/sbin/resolvconf -l": []byte(""), + }, + Errors: map[string]error{}, + } + + fs := &testutil.MockFileReader{ + Files: map[string][]byte{}, + Globs: map[string][]string{}, + } + + c := NewSystemCollector(runner, fs, 60*time.Second) + state := collectToState(t, c) + + dns, ok := state["infix-system:dns-resolver"].(map[string]interface{}) + if !ok { + t.Fatal("missing infix-system:dns-resolver") + } + servers, ok := dns["server"].([]interface{}) + if !ok { + t.Fatal("dns server should be an array, not null") + } + if len(servers) != 0 { + t.Fatalf("expected empty server array, got %d servers", len(servers)) + } +} diff --git a/src/yangerd/internal/config/config.go b/src/yangerd/internal/config/config.go new file mode 100644 index 00000000..4926698e --- /dev/null +++ b/src/yangerd/internal/config/config.go @@ -0,0 +1,81 @@ +package config + +import ( + "os" + "strconv" + "time" +) + +// Config holds all yangerd runtime configuration, populated from +// environment variables with sensible defaults. +type Config struct { + Socket string + LogLevel string + ZebraSocket string + LLDPCommand string + StartupTimeout time.Duration + PollSystem time.Duration + PollRouting time.Duration + PollNTP time.Duration + PollHardware time.Duration + PollContainers time.Duration + EnableWifi bool + EnableLLDP bool + EnableFirewall bool + EnableDHCP bool + EnableContainers bool + EnableGPS bool +} + +// Load reads configuration from the environment. +func Load() *Config { + return &Config{ + Socket: envStr("YANGERD_SOCKET", "/run/yangerd.sock"), + LogLevel: envStr("YANGERD_LOG_LEVEL", "info"), + ZebraSocket: envStr("YANGERD_ZEBRA_SOCKET", "/var/run/frr/zserv.api"), + LLDPCommand: envStr("YANGERD_LLDP_COMMAND", "lldpcli"), + StartupTimeout: envDur("YANGERD_STARTUP_TIMEOUT", 5*time.Second), + PollSystem: envDur("YANGERD_POLL_INTERVAL_SYSTEM", 60*time.Second), + PollRouting: envDur("YANGERD_POLL_INTERVAL_ROUTING", 10*time.Second), + PollNTP: envDur("YANGERD_POLL_INTERVAL_NTP", 60*time.Second), + PollHardware: envDur("YANGERD_POLL_INTERVAL_HARDWARE", 10*time.Second), + PollContainers: envDur("YANGERD_POLL_INTERVAL_CONTAINERS", 10*time.Second), + EnableWifi: envBool("YANGERD_ENABLE_WIFI", false), + EnableLLDP: envBool("YANGERD_ENABLE_LLDP", true), + EnableFirewall: envBool("YANGERD_ENABLE_FIREWALL", true), + EnableDHCP: envBool("YANGERD_ENABLE_DHCP", true), + EnableContainers: envBool("YANGERD_ENABLE_CONTAINERS", false), + EnableGPS: envBool("YANGERD_ENABLE_GPS", false), + } +} + +func envStr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envBool(key string, def bool) bool { + v := os.Getenv(key) + if v == "" { + return def + } + b, err := strconv.ParseBool(v) + if err != nil { + return def + } + return b +} + +func envDur(key string, def time.Duration) time.Duration { + v := os.Getenv(key) + if v == "" { + return def + } + d, err := time.ParseDuration(v) + if err != nil { + return def + } + return d +} diff --git a/src/yangerd/internal/dbusmonitor/dbusmonitor.go b/src/yangerd/internal/dbusmonitor/dbusmonitor.go new file mode 100644 index 00000000..99b52de4 --- /dev/null +++ b/src/yangerd/internal/dbusmonitor/dbusmonitor.go @@ -0,0 +1,1068 @@ +// Package dbusmonitor watches D-Bus signals from dnsmasq and firewalld +// and keeps their operational YANG subtrees updated. +package dbusmonitor + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "math" + "os" + "strconv" + "strings" + "time" + + "github.com/godbus/dbus/v5" + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +const ( + dnsmasqBusName = "uk.org.thekelleys.dnsmasq" + dnsmasqInterface = "uk.org.thekelleys.dnsmasq" + dnsmasqPath = "/uk/org/thekelleys/dnsmasq" + + firewalldBusName = "org.fedoraproject.FirewallD1" + firewalldInterface = "org.fedoraproject.FirewallD1" + firewalldPath = "/org/fedoraproject/FirewallD1" + + dbusInterface = "org.freedesktop.DBus" + dbusPath = "/org/freedesktop/DBus" + + dnsmasqLeaseFile = "/var/lib/misc/dnsmasq.leases" + + dhcpTreeKey = "infix-dhcp-server:dhcp-server" + firewallTreeKey = "infix-firewall:firewall" + + reconnectInitial = 100 * time.Millisecond + reconnectMax = 30 * time.Second + reconnectFactor = 2.0 +) + +// DBusMonitor subscribes to dnsmasq and firewalld D-Bus signals and +// updates the shared operational tree. +type DBusMonitor struct { + tree *tree.Tree + log *slog.Logger +} + +// New creates a DBusMonitor. +func New(t *tree.Tree, log *slog.Logger) *DBusMonitor { + return &DBusMonitor{tree: t, log: log} +} + +// Run starts the monitor loop. It connects to the system bus, subscribes +// to relevant signals, loads initial DHCP/firewall data, and reconnects +// with exponential backoff on failures until ctx is cancelled. +func (m *DBusMonitor) Run(ctx context.Context) error { + delay := reconnectInitial + + for { + if err := ctx.Err(); err != nil { + return err + } + + conn, err := dbus.ConnectSystemBus() + if err != nil { + m.log.Warn("dbus monitor: connect system bus failed", "err", err, "delay", delay) + if err := sleepOrDone(ctx, delay); err != nil { + return err + } + delay = nextDelay(delay) + continue + } + + if err := m.subscribe(conn); err != nil { + m.log.Warn("dbus monitor: subscribe failed", "err", err, "delay", delay) + _ = conn.Close() + if err := sleepOrDone(ctx, delay); err != nil { + return err + } + delay = nextDelay(delay) + continue + } + + delay = reconnectInitial + + if err := m.refreshDHCP(conn); err != nil { + m.log.Warn("dbus monitor: initial dhcp refresh failed", "err", err) + } + if err := m.refreshFirewall(conn); err != nil { + m.log.Warn("dbus monitor: initial firewall refresh failed", "err", err) + } + + err = m.processSignals(ctx, conn) + _ = conn.Close() + if ctx.Err() != nil { + return ctx.Err() + } + + m.log.Warn("dbus monitor: signal loop ended, reconnecting", "err", err, "delay", delay) + if err := sleepOrDone(ctx, delay); err != nil { + return err + } + delay = nextDelay(delay) + } +} + +func (m *DBusMonitor) subscribe(conn *dbus.Conn) error { + if err := conn.AddMatchSignal( + dbus.WithMatchInterface(dnsmasqInterface), + dbus.WithMatchMember("DHCPLeaseAdded"), + ); err != nil { + return fmt.Errorf("add dnsmasq DHCPLeaseAdded match: %w", err) + } + + if err := conn.AddMatchSignal( + dbus.WithMatchInterface(dnsmasqInterface), + dbus.WithMatchMember("DHCPLeaseDeleted"), + ); err != nil { + return fmt.Errorf("add dnsmasq DHCPLeaseDeleted match: %w", err) + } + + if err := conn.AddMatchSignal( + dbus.WithMatchInterface(dnsmasqInterface), + dbus.WithMatchMember("DHCPLeaseUpdated"), + ); err != nil { + return fmt.Errorf("add dnsmasq DHCPLeaseUpdated match: %w", err) + } + + if err := conn.AddMatchSignal( + dbus.WithMatchInterface(firewalldInterface), + dbus.WithMatchMember("Reloaded"), + ); err != nil { + return fmt.Errorf("add firewalld Reloaded match: %w", err) + } + + if err := conn.AddMatchSignal( + dbus.WithMatchInterface(dbusInterface), + dbus.WithMatchMember("NameOwnerChanged"), + dbus.WithMatchArg(0, dnsmasqBusName), + ); err != nil { + return fmt.Errorf("add NameOwnerChanged dnsmasq match: %w", err) + } + + if err := conn.AddMatchSignal( + dbus.WithMatchInterface(dbusInterface), + dbus.WithMatchMember("NameOwnerChanged"), + dbus.WithMatchArg(0, firewalldBusName), + ); err != nil { + return fmt.Errorf("add NameOwnerChanged firewalld match: %w", err) + } + + return nil +} + +func (m *DBusMonitor) processSignals(ctx context.Context, conn *dbus.Conn) error { + sigCh := make(chan *dbus.Signal, 128) + conn.Signal(sigCh) + defer conn.RemoveSignal(sigCh) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case sig, ok := <-sigCh: + if !ok { + return fmt.Errorf("dbus signal channel closed") + } + if sig == nil { + continue + } + if err := m.handleSignal(conn, sig); err != nil { + m.log.Warn("dbus monitor: failed handling signal", "name", sig.Name, "path", sig.Path, "err", err) + } + } + } +} + +func (m *DBusMonitor) handleSignal(conn *dbus.Conn, sig *dbus.Signal) error { + switch sig.Name { + case dnsmasqInterface + ".DHCPLeaseAdded", + dnsmasqInterface + ".DHCPLeaseDeleted", + dnsmasqInterface + ".DHCPLeaseUpdated": + if sig.Path != "" && string(sig.Path) != dnsmasqPath { + return nil + } + return m.refreshDHCP(conn) + + case firewalldInterface + ".Reloaded": + if sig.Path != "" && string(sig.Path) != firewalldPath { + return nil + } + return m.refreshFirewall(conn) + + case dbusInterface + ".NameOwnerChanged": + if sig.Path != "" && string(sig.Path) != dbusPath { + return nil + } + if len(sig.Body) < 3 { + return fmt.Errorf("NameOwnerChanged: expected 3 args, got %d", len(sig.Body)) + } + + name, ok1 := sig.Body[0].(string) + oldOwner, ok2 := sig.Body[1].(string) + newOwner, ok3 := sig.Body[2].(string) + if !ok1 || !ok2 || !ok3 { + return fmt.Errorf("NameOwnerChanged: unexpected arg types") + } + + switch name { + case dnsmasqBusName: + if newOwner == "" { + m.clearTreeKey(dhcpTreeKey) + return nil + } + if oldOwner == "" { + return m.refreshDHCP(conn) + } + case firewalldBusName: + if newOwner == "" { + m.clearTreeKey(firewallTreeKey) + return nil + } + if oldOwner == "" { + return m.refreshFirewall(conn) + } + } + } + + return nil +} + +func (m *DBusMonitor) refreshDHCP(conn *dbus.Conn) error { + data, err := os.ReadFile(dnsmasqLeaseFile) + if err != nil { + m.log.Warn("dbus monitor: read dnsmasq leases failed", "file", dnsmasqLeaseFile, "err", err) + } + + leases := parseDnsmasqLeases(string(data)) + stats := defaultDHCPStats() + + obj := conn.Object(dnsmasqBusName, dbus.ObjectPath(dnsmasqPath)) + call := obj.Call(dnsmasqInterface+".GetMetrics", 0) + if call.Err != nil { + m.log.Warn("dbus monitor: dnsmasq GetMetrics failed", "err", call.Err) + } else if len(call.Body) > 0 { + stats = mergeDHCPStats(stats, decodeDHCPMetrics(call.Body[0])) + } + + m.tree.Set(dhcpTreeKey, buildDHCPTree(leases, stats)) + return nil +} + +func (m *DBusMonitor) refreshFirewall(conn *dbus.Conn) error { + obj := conn.Object(firewalldBusName, dbus.ObjectPath(firewalldPath)) + + defaultZone := "" + if call := obj.Call(firewalldInterface+".getDefaultZone", 0); call.Err != nil { + m.log.Info("dbus monitor: firewalld not reachable, skipping", "err", call.Err) + return nil + } else if err := call.Store(&defaultZone); err != nil { + m.log.Warn("dbus monitor: firewalld getDefaultZone decode failed", "err", err) + return nil + } + + logDenied := "" + if call := obj.Call(firewalldInterface+".getLogDenied", 0); call.Err != nil { + m.log.Warn("dbus monitor: firewalld getLogDenied failed", "err", call.Err) + } else if err := call.Store(&logDenied); err != nil { + m.log.Warn("dbus monitor: firewalld getLogDenied decode failed", "err", err) + } + + lockdown := false + if call := obj.Call(firewalldInterface+".queryPanicMode", 0); call.Err != nil { + m.log.Warn("dbus monitor: firewalld queryPanicMode failed", "err", call.Err) + } else if len(call.Body) > 0 { + lockdown = asBool(call.Body[0]) + } + + zones := m.getFirewallZones(obj) + policies := m.getFirewallPolicies(obj) + services := m.getFirewallServices(obj, referencedServices(zones, policies)) + + m.tree.Set(firewallTreeKey, buildFirewallTree(defaultZone, logDenied, lockdown, zones, policies, services)) + return nil +} + +func (m *DBusMonitor) getFirewallZones(obj dbus.BusObject) []map[string]any { + active := make(map[string]map[string]any) + if call := obj.Call(firewalldInterface+".zone.getActiveZones", 0); call.Err != nil { + m.log.Warn("dbus monitor: firewalld zone.getActiveZones failed", "err", call.Err) + return nil + } else if len(call.Body) > 0 { + active = decodeActiveZones(call.Body[0]) + } + + zones := make([]map[string]any, 0, len(active)) + for name, zoneInfo := range active { + settings := map[string]any{} + if call := obj.Call(firewalldInterface+".zone.getZoneSettings2", 0, name); call.Err != nil { + m.log.Warn("dbus monitor: firewalld zone.getZoneSettings2 failed", "zone", name, "err", call.Err) + continue + } else if len(call.Body) > 0 { + settings = variantMap(call.Body[0]) + } + + zone := map[string]any{ + "name": name, + "immutable": hasImmutableTag(getString(settings, "short")), + "action": mapZoneTarget(getString(settings, "target")), + } + if ifaces := firstStringList(zoneInfo, "interfaces", getStringList(settings, "interfaces")); len(ifaces) > 0 { + zone["interface"] = ifaces + } + if networks := firstStringList(zoneInfo, "sources", getStringList(settings, "sources")); len(networks) > 0 { + zone["network"] = networks + } + if services := getStringList(settings, "services"); len(services) > 0 { + zone["service"] = services + } + if desc := getString(settings, "description"); desc != "" { + zone["description"] = desc + } + + if forwards := getForwardPorts(settings); len(forwards) > 0 { + zone["port-forward"] = forwards + } + + zones = append(zones, zone) + } + + return zones +} + +func (m *DBusMonitor) getFirewallPolicies(obj dbus.BusObject) []map[string]any { + var names []string + if call := obj.Call(firewalldInterface+".policy.getPolicies", 0); call.Err != nil { + m.log.Warn("dbus monitor: firewalld policy.getPolicies failed", "err", call.Err) + } else if err := call.Store(&names); err != nil { + m.log.Warn("dbus monitor: firewalld policy.getPolicies decode failed", "err", err) + } + + policies := make([]map[string]any, 0, len(names)+1) + for _, name := range names { + settings := map[string]any{} + if call := obj.Call(firewalldInterface+".policy.getPolicySettings", 0, name); call.Err != nil { + m.log.Warn("dbus monitor: firewalld policy.getPolicySettings failed", "policy", name, "err", call.Err) + continue + } else if len(call.Body) > 0 { + settings = variantMap(call.Body[0]) + } + + policy := map[string]any{ + "name": name, + "action": mapPolicyTarget(getString(settings, "target")), + "priority": getInt(settings, "priority", 32767), + "immutable": hasImmutableTag(getString(settings, "short")), + "masquerade": asBool(settings["masquerade"]), + } + if ingress := getStringList(settings, "ingress_zones"); len(ingress) > 0 { + policy["ingress"] = ingress + } + if egress := getStringList(settings, "egress_zones"); len(egress) > 0 { + policy["egress"] = egress + } + if desc := getString(settings, "description"); desc != "" { + policy["description"] = desc + } + if services := getStringList(settings, "services"); len(services) > 0 { + policy["service"] = services + } + if custom := parsePolicyCustomFilters(getStringList(settings, "rich_rules")); len(custom) > 0 { + policy["custom"] = map[string]any{"filter": custom} + } + + policies = append(policies, policy) + } + + policies = append(policies, map[string]any{ + "name": "default-drop", + "description": "Default deny rule - drops all unmatched traffic", + "action": "drop", + "priority": 32767, + "ingress": []string{"ANY"}, + "egress": []string{"ANY"}, + "immutable": true, + }) + + return policies +} + +func referencedServices(zones, policies []map[string]any) map[string]bool { + refs := map[string]bool{} + for _, z := range zones { + if svcs, ok := z["service"].([]string); ok { + for _, s := range svcs { + refs[s] = true + } + } + } + for _, p := range policies { + if svcs, ok := p["service"].([]string); ok { + for _, s := range svcs { + refs[s] = true + } + } + } + return refs +} + +func (m *DBusMonitor) getFirewallServices(obj dbus.BusObject, wanted map[string]bool) []map[string]any { + var names []string + if call := obj.Call(firewalldInterface+".listServices", 0); call.Err != nil { + m.log.Warn("dbus monitor: firewalld listServices failed", "err", call.Err) + return nil + } else if err := call.Store(&names); err != nil { + m.log.Warn("dbus monitor: firewalld listServices decode failed", "err", err) + return nil + } + + services := make([]map[string]any, 0, len(wanted)) + for _, name := range names { + if !wanted[name] { + continue + } + + settings := map[string]any{} + if call := obj.Call(firewalldInterface+".getServiceSettings2", 0, name); call.Err != nil { + m.log.Warn("dbus monitor: firewalld getServiceSettings2 failed", "service", name, "err", call.Err) + continue + } else if len(call.Body) > 0 { + settings = variantMap(call.Body[0]) + } + + ports := parseServicePorts(settings) + if len(ports) == 0 { + continue + } + + service := map[string]any{ + "name": name, + "port": ports, + } + if desc := getString(settings, "description"); desc != "" { + service["description"] = desc + } + + services = append(services, service) + } + + return services +} + +func (m *DBusMonitor) clearTreeKey(key string) { + m.tree.Set(key, json.RawMessage(`{}`)) +} + +func parseDnsmasqLeases(data string) []map[string]any { + leases := make([]map[string]any, 0) + for _, line := range strings.Split(data, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + fields := strings.Fields(line) + if len(fields) != 5 { + continue + } + + expires := "never" + if fields[0] != "0" { + ts, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + continue + } + expires = time.Unix(ts, 0).UTC().Format(time.RFC3339) + } + + hostname := "" + if fields[3] != "*" { + hostname = fields[3] + } + + clientID := "" + if fields[4] != "*" { + clientID = fields[4] + } + + leases = append(leases, map[string]any{ + "expires": expires, + "address": fields[2], + "phys-address": fields[1], + "hostname": hostname, + "client-id": clientID, + }) + } + + return leases +} + +func buildDHCPTree(leases []map[string]any, stats map[string]any) json.RawMessage { + root := map[string]any{ + "statistics": stats, + "leases": map[string]any{ + "lease": leases, + }, + } + raw, err := json.Marshal(root) + if err != nil { + return json.RawMessage(`{}`) + } + return raw +} + +func buildFirewallTree(defaultZone, logDenied string, lockdown bool, zones, policies, services []map[string]any) json.RawMessage { + fw := map[string]any{ + "default": defaultZone, + "logging": logDenied, + "lockdown": lockdown, + } + if len(zones) > 0 { + fw["zone"] = zones + } + if len(policies) > 0 { + fw["policy"] = policies + } + if len(services) > 0 { + fw["service"] = services + } + + raw, err := json.Marshal(fw) + if err != nil { + return json.RawMessage(`{}`) + } + return raw +} + +func defaultDHCPStats() map[string]any { + return map[string]any{ + "out-offers": uint64(0), + "out-acks": uint64(0), + "out-naks": uint64(0), + "in-declines": uint64(0), + "in-discovers": uint64(0), + "in-requests": uint64(0), + "in-releases": uint64(0), + "in-informs": uint64(0), + } +} + +func decodeDHCPMetrics(v any) map[string]any { + metrics := map[string]any{} + + switch raw := v.(type) { + case map[string]dbus.Variant: + for k, val := range raw { + metrics[k] = val.Value() + } + case map[string]any: + for k, val := range raw { + metrics[k] = val + } + } + + return map[string]any{ + "out-offers": toUint64(metrics["dhcp_offer"]), + "out-acks": toUint64(metrics["dhcp_ack"]), + "out-naks": toUint64(metrics["dhcp_nak"]), + "in-declines": toUint64(metrics["dhcp_decline"]), + "in-discovers": toUint64(metrics["dhcp_discover"]), + "in-requests": toUint64(metrics["dhcp_request"]), + "in-releases": toUint64(metrics["dhcp_release"]), + "in-informs": toUint64(metrics["dhcp_inform"]), + } +} + +func mergeDHCPStats(base, override map[string]any) map[string]any { + out := map[string]any{} + for k, v := range base { + out[k] = v + } + for k, v := range override { + out[k] = v + } + return out +} + +func parseServicePorts(settings map[string]any) []map[string]any { + rawPorts, ok := settings["ports"] + if !ok { + return []map[string]any{} + } + + out := []map[string]any{} + for _, entry := range toAnySlice(rawPorts) { + pair := toAnySlice(entry) + if len(pair) < 2 { + continue + } + + portSpec := fmt.Sprint(pair[0]) + proto := fmt.Sprint(pair[1]) + if portSpec == "" || proto == "" { + continue + } + + port := map[string]any{"proto": proto} + if strings.Contains(portSpec, "-") { + parts := strings.SplitN(portSpec, "-", 2) + lower, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) + upper, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) + if err1 != nil || err2 != nil { + continue + } + port["lower"] = lower + port["upper"] = upper + } else { + lower, err := strconv.Atoi(strings.TrimSpace(portSpec)) + if err != nil { + continue + } + port["lower"] = lower + } + + out = append(out, port) + } + + return out +} + +func parsePolicyCustomFilters(rules []string) []map[string]any { + filters := []map[string]any{} + for _, rule := range rules { + family := "both" + if strings.Contains(rule, `family="ipv4"`) { + family = "ipv4" + } else if strings.Contains(rule, `family="ipv6"`) { + family = "ipv6" + } + + icmpType := "" + action := "" + prio := -1 + + if idx := strings.Index(rule, "priority="); idx >= 0 { + prio = parsePriority(rule[idx+len("priority="):]) + } + + if strings.Contains(rule, "icmp-type") && strings.Contains(rule, `name="`) { + icmpType = parseQuotedName(rule) + action = "accept" + if strings.Contains(rule, " drop") { + action = "drop" + } else if strings.Contains(rule, " reject") { + action = "reject" + } + } else if strings.Contains(rule, "icmp-block") && strings.Contains(rule, `name="`) { + icmpType = parseQuotedName(rule) + action = "reject" + } + + if icmpType == "" || action == "" { + continue + } + + filters = append(filters, map[string]any{ + "name": "icmp-" + icmpType, + "priority": prio, + "family": family, + "action": action, + "icmp": map[string]any{ + "type": icmpType, + }, + }) + } + + return filters +} + +func getForwardPorts(settings map[string]any) []map[string]any { + raw, ok := settings["forward_ports"] + if !ok { + return nil + } + + out := []map[string]any{} + for _, item := range toAnySlice(raw) { + vals := toAnySlice(item) + if len(vals) < 4 { + continue + } + + portStr := fmt.Sprint(vals[0]) + proto := fmt.Sprint(vals[1]) + toPortStr := strings.TrimSpace(fmt.Sprint(vals[2])) + toAddr := fmt.Sprint(vals[3]) + + if portStr == "" || proto == "" { + continue + } + + entry := map[string]any{"proto": proto} + if strings.Contains(portStr, "-") { + parts := strings.SplitN(portStr, "-", 2) + lower, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) + upper, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) + if err1 != nil || err2 != nil { + continue + } + entry["lower"] = lower + entry["upper"] = upper + } else { + lower, err := strconv.Atoi(strings.TrimSpace(portStr)) + if err != nil { + continue + } + entry["lower"] = lower + } + + to := map[string]any{"addr": toAddr} + if toPortStr != "" && !strings.ContainsAny(toPortStr, ".:") { + if p, err := strconv.Atoi(toPortStr); err == nil { + to["port"] = p + } + } + if _, ok := to["port"]; !ok { + to["port"] = entry["lower"] + } + + entry["to"] = to + out = append(out, entry) + } + + return out +} + +func decodeActiveZones(v any) map[string]map[string]any { + out := map[string]map[string]any{} + + switch m := v.(type) { + case map[string]map[string]dbus.Variant: + for zone, data := range m { + inner := map[string]any{} + for k, vv := range data { + inner[k] = vv.Value() + } + out[zone] = inner + } + case map[string]map[string]any: + for zone, data := range m { + out[zone] = data + } + case map[string]map[string][]string: + for zone, data := range m { + inner := map[string]any{} + for k, v := range data { + inner[k] = v + } + out[zone] = inner + } + case map[string]any: + for zone, raw := range m { + if mm, ok := raw.(map[string]any); ok { + out[zone] = mm + } + } + } + + return out +} + +func variantMap(v any) map[string]any { + out := map[string]any{} + switch m := v.(type) { + case map[string]dbus.Variant: + for k, vv := range m { + out[k] = vv.Value() + } + case map[string]any: + for k, vv := range m { + if dv, ok := vv.(dbus.Variant); ok { + out[k] = dv.Value() + } else { + out[k] = vv + } + } + } + return out +} + +func getString(m map[string]any, key string) string { + v, ok := m[key] + if !ok || v == nil { + return "" + } + return fmt.Sprint(v) +} + +func getInt(m map[string]any, key string, def int) int { + v, ok := m[key] + if !ok { + return def + } + switch n := v.(type) { + case int: + return n + case int8: + return int(n) + case int16: + return int(n) + case int32: + return int(n) + case int64: + return int(n) + case uint: + return int(n) + case uint8: + return int(n) + case uint16: + return int(n) + case uint32: + return int(n) + case uint64: + return int(n) + case float32: + return int(n) + case float64: + return int(n) + case string: + i, err := strconv.Atoi(strings.TrimSpace(n)) + if err == nil { + return i + } + } + return def +} + +func getStringList(m map[string]any, key string) []string { + v, ok := m[key] + if !ok { + return nil + } + return toStringSlice(v) +} + +func toStringSlice(v any) []string { + vals := toAnySlice(v) + if len(vals) == 0 { + if s, ok := v.(string); ok { + if s == "" { + return nil + } + return []string{s} + } + return nil + } + + out := make([]string, 0, len(vals)) + for _, item := range vals { + s := strings.TrimSpace(fmt.Sprint(item)) + if s != "" { + out = append(out, s) + } + } + return out +} + +func toAnySlice(v any) []any { + switch a := v.(type) { + case []any: + return a + case []string: + out := make([]any, 0, len(a)) + for _, item := range a { + out = append(out, item) + } + return out + case [][]any: + out := make([]any, 0, len(a)) + for _, item := range a { + out = append(out, any(item)) + } + return out + case [][]string: + out := make([]any, 0, len(a)) + for _, item := range a { + inner := make([]any, 0, len(item)) + for _, p := range item { + inner = append(inner, p) + } + out = append(out, inner) + } + return out + } + return nil +} + +func firstStringList(a map[string]any, key string, fallback []string) []string { + if list := getStringList(a, key); len(list) > 0 { + return list + } + return fallback +} + +func hasImmutableTag(short string) bool { + return strings.Contains(short, "(immutable)") +} + +func mapZoneTarget(target string) string { + switch strings.ToUpper(strings.TrimSpace(target)) { + case "%%REJECT%%", "REJECT": + return "reject" + case "DROP": + return "drop" + case "ACCEPT", "DEFAULT", "": + return "accept" + default: + return "accept" + } +} + +func mapPolicyTarget(target string) string { + switch strings.ToUpper(strings.TrimSpace(target)) { + case "CONTINUE": + return "continue" + case "ACCEPT": + return "accept" + case "DROP": + return "drop" + case "REJECT", "": + return "reject" + default: + return "reject" + } +} + +func parseQuotedName(rule string) string { + idx := strings.Index(rule, `name="`) + if idx < 0 { + return "" + } + start := idx + len(`name="`) + end := strings.Index(rule[start:], `"`) + if end < 0 { + return "" + } + return rule[start : start+end] +} + +func parsePriority(fragment string) int { + fragment = strings.TrimSpace(fragment) + if fragment == "" { + return -1 + } + fields := strings.Fields(fragment) + if len(fields) == 0 { + return -1 + } + p, err := strconv.Atoi(strings.Trim(fields[0], `"`)) + if err != nil { + return -1 + } + return p +} + +func asBool(v any) bool { + switch x := v.(type) { + case bool: + return x + case uint8: + return x != 0 + case uint16: + return x != 0 + case uint32: + return x != 0 + case uint64: + return x != 0 + case int8: + return x != 0 + case int16: + return x != 0 + case int32: + return x != 0 + case int64: + return x != 0 + case int: + return x != 0 + case string: + x = strings.TrimSpace(strings.ToLower(x)) + return x == "1" || x == "true" || x == "yes" || x == "on" + default: + return false + } +} + +func toUint64(v any) uint64 { + switch x := v.(type) { + case uint8: + return uint64(x) + case uint16: + return uint64(x) + case uint32: + return uint64(x) + case uint64: + return x + case uint: + return uint64(x) + case int8: + if x < 0 { + return 0 + } + return uint64(x) + case int16: + if x < 0 { + return 0 + } + return uint64(x) + case int32: + if x < 0 { + return 0 + } + return uint64(x) + case int64: + if x < 0 { + return 0 + } + return uint64(x) + case int: + if x < 0 { + return 0 + } + return uint64(x) + case float32: + if x < 0 { + return 0 + } + return uint64(x) + case float64: + if x < 0 { + return 0 + } + return uint64(x) + case string: + u, err := strconv.ParseUint(strings.TrimSpace(x), 10, 64) + if err == nil { + return u + } + } + return 0 +} + +func sleepOrDone(ctx context.Context, d time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(d): + return nil + } +} + +func nextDelay(delay time.Duration) time.Duration { + next := time.Duration(math.Min(float64(delay)*reconnectFactor, float64(reconnectMax))) + if next <= 0 { + return reconnectInitial + } + return next +} diff --git a/src/yangerd/internal/dbusmonitor/dbusmonitor_test.go b/src/yangerd/internal/dbusmonitor/dbusmonitor_test.go new file mode 100644 index 00000000..b11e6dde --- /dev/null +++ b/src/yangerd/internal/dbusmonitor/dbusmonitor_test.go @@ -0,0 +1,690 @@ +package dbusmonitor + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + "time" +) + +func TestParseDnsmasqLeases(t *testing.T) { + tests := []struct { + name string + input string + want []map[string]any + }{ + { + name: "normal lease line", + input: "1711900000 aa:bb:cc:dd:ee:ff 192.168.1.100 myhost 01:aa:bb:cc:dd:ee:ff", + want: []map[string]any{{ + "expires": time.Unix(1711900000, 0).UTC().Format(time.RFC3339), + "address": "192.168.1.100", + "phys-address": "aa:bb:cc:dd:ee:ff", + "hostname": "myhost", + "client-id": "01:aa:bb:cc:dd:ee:ff", + }}, + }, + { + name: "wildcard hostname and client id", + input: "1711900000 aa:bb:cc:dd:ee:ff 192.168.1.100 * *", + want: []map[string]any{{ + "expires": time.Unix(1711900000, 0).UTC().Format(time.RFC3339), + "address": "192.168.1.100", + "phys-address": "aa:bb:cc:dd:ee:ff", + "hostname": "", + "client-id": "", + }}, + }, + { + name: "never expiring lease", + input: "0 aa:bb:cc:dd:ee:ff 192.168.1.100 host *", + want: []map[string]any{{ + "expires": "never", + "address": "192.168.1.100", + "phys-address": "aa:bb:cc:dd:ee:ff", + "hostname": "host", + "client-id": "", + }}, + }, + { + name: "multiple leases with malformed lines skipped", + input: "1711900000 aa:bb:cc:dd:ee:ff 192.168.1.100 myhost 01:aa:bb:cc:dd:ee:ff\n" + + "bad line with too few fields\n" + + "1711900100 11:22:33:44:55:66 192.168.1.101 host2 *\n", + want: []map[string]any{ + { + "expires": time.Unix(1711900000, 0).UTC().Format(time.RFC3339), + "address": "192.168.1.100", + "phys-address": "aa:bb:cc:dd:ee:ff", + "hostname": "myhost", + "client-id": "01:aa:bb:cc:dd:ee:ff", + }, + { + "expires": time.Unix(1711900100, 0).UTC().Format(time.RFC3339), + "address": "192.168.1.101", + "phys-address": "11:22:33:44:55:66", + "hostname": "host2", + "client-id": "", + }, + }, + }, + { + name: "empty input", + input: "", + want: []map[string]any{}, + }, + { + name: "invalid timestamp skipped", + input: "abc aa:bb:cc:dd:ee:ff 192.168.1.100 host *", + want: []map[string]any{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := parseDnsmasqLeases(tc.input) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("parseDnsmasqLeases() mismatch\nwant: %#v\n got: %#v", tc.want, got) + } + }) + } +} + +func TestBuildDHCPTree(t *testing.T) { + tests := []struct { + name string + leases []map[string]any + stats map[string]any + check func(t *testing.T, root map[string]any) + }{ + { + name: "with leases and stats", + leases: []map[string]any{{ + "expires": "never", + "address": "192.168.1.100", + "phys-address": "aa:bb:cc:dd:ee:ff", + "hostname": "host", + "client-id": "", + }}, + stats: map[string]any{"out-offers": 3, "in-requests": 4}, + check: func(t *testing.T, root map[string]any) { + t.Helper() + stats, ok := root["statistics"].(map[string]any) + if !ok { + t.Fatalf("missing statistics map") + } + if stats["out-offers"] != float64(3) || stats["in-requests"] != float64(4) { + t.Fatalf("unexpected statistics: %#v", stats) + } + + leasesNode, ok := root["leases"].(map[string]any) + if !ok { + t.Fatalf("missing leases map") + } + leaseList, ok := leasesNode["lease"].([]any) + if !ok || len(leaseList) != 1 { + t.Fatalf("unexpected lease list: %#v", leasesNode["lease"]) + } + lease, ok := leaseList[0].(map[string]any) + if !ok || lease["address"] != "192.168.1.100" { + t.Fatalf("unexpected lease entry: %#v", leaseList[0]) + } + }, + }, + { + name: "with empty leases", + leases: []map[string]any{}, + stats: map[string]any{"out-offers": 0}, + check: func(t *testing.T, root map[string]any) { + t.Helper() + leasesNode := root["leases"].(map[string]any) + leaseList, ok := leasesNode["lease"].([]any) + if !ok || len(leaseList) != 0 { + t.Fatalf("expected empty lease list, got %#v", leasesNode["lease"]) + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + raw := buildDHCPTree(tc.leases, tc.stats) + var root map[string]any + if err := json.Unmarshal(raw, &root); err != nil { + t.Fatalf("unmarshal buildDHCPTree output: %v", err) + } + tc.check(t, root) + }) + } +} + +func TestBuildFirewallTree(t *testing.T) { + tests := []struct { + name string + defaultZ string + logDenied string + lockdown bool + zones []map[string]any + policies []map[string]any + services []map[string]any + expectKeys map[string]bool + }{ + { + name: "with zones policies and services", + defaultZ: "public", + logDenied: "all", + lockdown: true, + zones: []map[string]any{{"name": "public"}}, + policies: []map[string]any{{"name": "default-drop"}}, + services: []map[string]any{{"name": "ssh"}}, + expectKeys: map[string]bool{ + "zone": true, + "policy": true, + "service": true, + }, + }, + { + name: "omits empty zone policy service keys", + defaultZ: "trusted", + logDenied: "off", + lockdown: false, + expectKeys: map[string]bool{ + "zone": false, + "policy": false, + "service": false, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + raw := buildFirewallTree(tc.defaultZ, tc.logDenied, tc.lockdown, tc.zones, tc.policies, tc.services) + var root map[string]any + if err := json.Unmarshal(raw, &root); err != nil { + t.Fatalf("unmarshal buildFirewallTree output: %v", err) + } + if root["default"] != tc.defaultZ || root["logging"] != tc.logDenied || root["lockdown"] != tc.lockdown { + t.Fatalf("default/logging/lockdown mismatch: %#v", root) + } + for k, shouldExist := range tc.expectKeys { + _, exists := root[k] + if exists != shouldExist { + t.Fatalf("key %q exists=%v, want %v", k, exists, shouldExist) + } + } + }) + } +} + +func TestParseServicePorts(t *testing.T) { + tests := []struct { + name string + settings map[string]any + want []map[string]any + }{ + { + name: "single port", + settings: map[string]any{"ports": []any{[]any{"80", "tcp"}}}, + want: []map[string]any{{"proto": "tcp", "lower": 80}}, + }, + { + name: "port range", + settings: map[string]any{"ports": []any{[]any{"8080-8090", "tcp"}}}, + want: []map[string]any{{"proto": "tcp", "lower": 8080, "upper": 8090}}, + }, + { + name: "multiple ports", + settings: map[string]any{"ports": []any{ + []any{"80", "tcp"}, + []any{"53", "udp"}, + }}, + want: []map[string]any{ + {"proto": "tcp", "lower": 80}, + {"proto": "udp", "lower": 53}, + }, + }, + { + name: "missing ports", + settings: map[string]any{}, + want: []map[string]any{}, + }, + { + name: "empty ports", + settings: map[string]any{"ports": []any{}}, + want: []map[string]any{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := parseServicePorts(tc.settings) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("parseServicePorts mismatch\nwant: %#v\n got: %#v", tc.want, got) + } + }) + } +} + +func TestParsePolicyCustomFilters(t *testing.T) { + tests := []struct { + name string + rules []string + want []map[string]any + }{ + { + name: "rich rule icmp type accept", + rules: []string{`rule priority="0" family="ipv4" icmp-type name="echo-request" accept`}, + want: []map[string]any{{ + "name": "icmp-echo-request", + "priority": 0, + "family": "ipv4", + "action": "accept", + "icmp": map[string]any{"type": "echo-request"}, + }}, + }, + { + name: "rich rule icmp block reject", + rules: []string{`rule family="ipv6" icmp-block name="router-advertisement" reject`}, + want: []map[string]any{{ + "name": "icmp-router-advertisement", + "priority": -1, + "family": "ipv6", + "action": "reject", + "icmp": map[string]any{"type": "router-advertisement"}, + }}, + }, + { + name: "rule without icmp skipped", + rules: []string{`rule family="ipv4" service name="ssh" accept`}, + want: []map[string]any{}, + }, + { + name: "multiple rules include only icmp", + rules: []string{ + `rule priority="10" family="ipv4" icmp-type name="echo-reply" drop`, + `rule family="ipv4" service name="http" accept`, + `rule family="ipv6" icmp-block name="router-advertisement" reject`, + }, + want: []map[string]any{ + { + "name": "icmp-echo-reply", + "priority": 10, + "family": "ipv4", + "action": "drop", + "icmp": map[string]any{"type": "echo-reply"}, + }, + { + "name": "icmp-router-advertisement", + "priority": -1, + "family": "ipv6", + "action": "reject", + "icmp": map[string]any{"type": "router-advertisement"}, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := parsePolicyCustomFilters(tc.rules) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("parsePolicyCustomFilters mismatch\nwant: %#v\n got: %#v", tc.want, got) + } + }) + } +} + +func TestGetForwardPorts(t *testing.T) { + tests := []struct { + name string + settings map[string]any + want []map[string]any + wantNil bool + }{ + { + name: "single port forward", + settings: map[string]any{"forward_ports": []any{[]any{"80", "tcp", "8080", "192.168.1.1"}}}, + want: []map[string]any{{ + "proto": "tcp", + "lower": 80, + "to": map[string]any{"addr": "192.168.1.1", "port": 8080}, + }}, + }, + { + name: "port range forward", + settings: map[string]any{"forward_ports": []any{[]any{"1000-1005", "udp", "2000", "10.0.0.2"}}}, + want: []map[string]any{{ + "proto": "udp", + "lower": 1000, + "upper": 1005, + "to": map[string]any{"addr": "10.0.0.2", "port": 2000}, + }}, + }, + { + name: "missing to port defaults to lower", + settings: map[string]any{"forward_ports": []any{[]any{"8081", "tcp", "", "192.168.1.1"}}}, + want: []map[string]any{{ + "proto": "tcp", + "lower": 8081, + "to": map[string]any{"addr": "192.168.1.1", "port": 8081}, + }}, + }, + { + name: "missing forward ports", + settings: map[string]any{}, + wantNil: true, + }, + { + name: "empty forward ports", + settings: map[string]any{"forward_ports": []any{}}, + want: []map[string]any{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := getForwardPorts(tc.settings) + if tc.wantNil { + if got != nil { + t.Fatalf("expected nil, got %#v", got) + } + return + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("getForwardPorts mismatch\nwant: %#v\n got: %#v", tc.want, got) + } + }) + } +} + +func TestMapZoneTarget(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "percent reject", in: "%%REJECT%%", want: "reject"}, + {name: "reject", in: "REJECT", want: "reject"}, + {name: "drop", in: "DROP", want: "drop"}, + {name: "accept", in: "ACCEPT", want: "accept"}, + {name: "default", in: "DEFAULT", want: "accept"}, + {name: "empty", in: "", want: "accept"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := mapZoneTarget(tc.in); got != tc.want { + t.Fatalf("mapZoneTarget(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestMapPolicyTarget(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "continue", in: "CONTINUE", want: "continue"}, + {name: "accept", in: "ACCEPT", want: "accept"}, + {name: "drop", in: "DROP", want: "drop"}, + {name: "reject", in: "REJECT", want: "reject"}, + {name: "empty", in: "", want: "reject"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := mapPolicyTarget(tc.in); got != tc.want { + t.Fatalf("mapPolicyTarget(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestAsBool(t *testing.T) { + tests := []struct { + name string + in any + want bool + }{ + {name: "bool true", in: true, want: true}, + {name: "bool false", in: false, want: false}, + {name: "int one", in: 1, want: true}, + {name: "int zero", in: 0, want: false}, + {name: "int8 one", in: int8(1), want: true}, + {name: "int16 zero", in: int16(0), want: false}, + {name: "int64 one", in: int64(1), want: true}, + {name: "uint32 zero", in: uint32(0), want: false}, + {name: "uint64 one", in: uint64(1), want: true}, + {name: "string true", in: "true", want: true}, + {name: "string false", in: "false", want: false}, + {name: "string one", in: "1", want: true}, + {name: "string zero", in: "0", want: false}, + {name: "string yes", in: "yes", want: true}, + {name: "string no", in: "no", want: false}, + {name: "string on", in: "on", want: true}, + {name: "trim and case", in: " TRUE ", want: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := asBool(tc.in); got != tc.want { + t.Fatalf("asBool(%#v) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} + +func TestToUint64(t *testing.T) { + tests := []struct { + name string + in any + want uint64 + }{ + {name: "uint8", in: uint8(8), want: 8}, + {name: "uint16", in: uint16(16), want: 16}, + {name: "uint32", in: uint32(32), want: 32}, + {name: "uint64", in: uint64(64), want: 64}, + {name: "uint", in: uint(7), want: 7}, + {name: "int positive", in: 42, want: 42}, + {name: "int negative", in: -1, want: 0}, + {name: "int64 negative", in: int64(-9), want: 0}, + {name: "float64", in: float64(99.9), want: 99}, + {name: "float64 negative", in: float64(-0.1), want: 0}, + {name: "string number", in: "42", want: 42}, + {name: "string invalid", in: "nope", want: 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := toUint64(tc.in); got != tc.want { + t.Fatalf("toUint64(%#v) = %d, want %d", tc.in, got, tc.want) + } + }) + } +} + +func TestParseHelpers(t *testing.T) { + t.Run("parseQuotedName", func(t *testing.T) { + tests := []struct { + name string + rule string + want string + }{ + {name: "extract name", rule: `rule icmp-type name="echo-request" accept`, want: "echo-request"}, + {name: "missing name", rule: `rule icmp-type accept`, want: ""}, + {name: "unterminated quote", rule: `rule icmp-type name="echo-request accept`, want: ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := parseQuotedName(tc.rule); got != tc.want { + t.Fatalf("parseQuotedName(%q) = %q, want %q", tc.rule, got, tc.want) + } + }) + } + }) + + t.Run("parsePriority", func(t *testing.T) { + tests := []struct { + name string + in string + want int + }{ + {name: "quoted value", in: `"0" family="ipv4"`, want: 0}, + {name: "plain value", in: `10 family="ipv6"`, want: 10}, + {name: "empty", in: ``, want: -1}, + {name: "invalid", in: `abc family="ipv4"`, want: -1}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := parsePriority(tc.in); got != tc.want { + t.Fatalf("parsePriority(%q) = %d, want %d", tc.in, got, tc.want) + } + }) + } + }) + + t.Run("hasImmutableTag", func(t *testing.T) { + tests := []struct { + name string + in string + want bool + }{ + {name: "has tag", in: "Public (immutable)", want: true}, + {name: "no tag", in: "Public", want: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := hasImmutableTag(tc.in); got != tc.want { + t.Fatalf("hasImmutableTag(%q) = %v, want %v", tc.in, got, tc.want) + } + }) + } + }) +} + +func TestSleepOrDone(t *testing.T) { + tests := []struct { + name string + cancelNow bool + delay time.Duration + wantErr bool + }{ + {name: "done context returns error", cancelNow: true, delay: time.Millisecond, wantErr: true}, + {name: "sleep completes when context active", cancelNow: false, delay: time.Millisecond, wantErr: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + if tc.cancelNow { + cancel() + } else { + defer cancel() + } + + err := sleepOrDone(ctx, tc.delay) + if tc.wantErr { + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } + return + } + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + }) + } +} + +func TestDecodeActiveZones(t *testing.T) { + tests := []struct { + name string + in any + want map[string]map[string]any + }{ + { + name: "godbus concrete type (a{sa{sas}})", + in: map[string]map[string][]string{ + "public": { + "interfaces": {"eth0", "eth1"}, + "sources": {"10.0.0.0/8"}, + }, + "mgmt": { + "interfaces": {"eth2"}, + }, + }, + want: map[string]map[string]any{ + "public": { + "interfaces": []string{"eth0", "eth1"}, + "sources": []string{"10.0.0.0/8"}, + }, + "mgmt": { + "interfaces": []string{"eth2"}, + }, + }, + }, + { + name: "pre-decoded map[string]map[string]any", + in: map[string]map[string]any{ + "home": { + "interfaces": []string{"wlan0"}, + }, + }, + want: map[string]map[string]any{ + "home": { + "interfaces": []string{"wlan0"}, + }, + }, + }, + { + name: "nil input", + in: nil, + want: map[string]map[string]any{}, + }, + { + name: "unsupported type", + in: "garbage", + want: map[string]map[string]any{}, + }, + { + name: "empty map", + in: map[string]map[string][]string{}, + want: map[string]map[string]any{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := decodeActiveZones(tc.in) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("decodeActiveZones() =\n %v\nwant:\n %v", got, tc.want) + } + }) + } +} + +func TestNextDelay(t *testing.T) { + tests := []struct { + name string + in time.Duration + want time.Duration + }{ + {name: "doubles normal delay", in: reconnectInitial, want: reconnectInitial * 2}, + {name: "caps at reconnectMax", in: reconnectMax, want: reconnectMax}, + {name: "near max also caps", in: reconnectMax - time.Second, want: reconnectMax}, + {name: "zero becomes reconnectInitial", in: 0, want: reconnectInitial}, + {name: "negative becomes reconnectInitial", in: -time.Second, want: reconnectInitial}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := nextDelay(tc.in); got != tc.want { + t.Fatalf("nextDelay(%v) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} diff --git a/src/yangerd/internal/ethmonitor/ethmonitor.go b/src/yangerd/internal/ethmonitor/ethmonitor.go new file mode 100644 index 00000000..eb176d55 --- /dev/null +++ b/src/yangerd/internal/ethmonitor/ethmonitor.go @@ -0,0 +1,229 @@ +// Package ethmonitor subscribes to ethtool genetlink notifications and +// keeps per-interface ethernet settings updated via a callback. +package ethmonitor + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net" + + "github.com/mdlayher/ethtool" + "github.com/mdlayher/genetlink" + "github.com/mdlayher/netlink" +) + +const ( + // ETHTOOL_MSG_LINKINFO_NTF is the ethtool generic netlink notification + // command for link info changes. + ETHTOOL_MSG_LINKINFO_NTF = 28 + // ETHTOOL_MSG_LINKMODES_NTF is the ethtool generic netlink notification + // command for link mode changes. + ETHTOOL_MSG_LINKMODES_NTF = 29 + + ethtoolFamilyName = "ethtool" + ethtoolMonitorGroupName = "monitor" + + nlaHeaderIfindex = 1 +) + +// EthMonitor listens for ethtool genetlink monitor events and updates +// interface ethernet operational state via a callback. +type EthMonitor struct { + conn *genetlink.Conn + family genetlink.Family + groupID uint32 + etClient *ethtool.Client + log *slog.Logger + onUpdate func(ifname string, data json.RawMessage) +} + +// New creates an EthMonitor, resolves the ethtool genetlink family, +// and joins its "monitor" multicast group. +func New(log *slog.Logger) (*EthMonitor, error) { + conn, err := genetlink.Dial(nil) + if err != nil { + return nil, fmt.Errorf("dial genetlink: %w", err) + } + + family, err := conn.GetFamily(ethtoolFamilyName) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("resolve %q genetlink family: %w", ethtoolFamilyName, err) + } + + var groupID uint32 + for _, g := range family.Groups { + if g.Name == ethtoolMonitorGroupName { + groupID = g.ID + break + } + } + if groupID == 0 { + _ = conn.Close() + return nil, fmt.Errorf("multicast group %q not found in family %q", ethtoolMonitorGroupName, ethtoolFamilyName) + } + + if err := conn.JoinGroup(groupID); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("join ethtool monitor group %d: %w", groupID, err) + } + + etClient, err := ethtool.New() + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("create ethtool client: %w", err) + } + + return &EthMonitor{ + conn: conn, + family: family, + groupID: groupID, + etClient: etClient, + log: log, + }, nil +} + +// SetOnUpdate sets the callback invoked when ethernet data changes. +func (m *EthMonitor) SetOnUpdate(fn func(string, json.RawMessage)) { + m.onUpdate = fn +} + +// Run starts the ethtool genetlink receive loop and updates interface +// ethernet settings when link info or link mode notifications are seen. +func (m *EthMonitor) Run(ctx context.Context) error { + defer func() { + if err := m.etClient.Close(); err != nil { + m.log.Warn("ethmonitor: close ethtool client", "err", err) + } + if err := m.conn.Close(); err != nil { + m.log.Warn("ethmonitor: close genetlink conn", "err", err) + } + }() + + for { + if err := ctx.Err(); err != nil { + return err + } + + msgs, _, err := m.conn.Receive() + if err != nil { + if cerr := ctx.Err(); cerr != nil { + return cerr + } + return fmt.Errorf("receive ethtool genetlink message: %w", err) + } + + for _, msg := range msgs { + switch msg.Header.Command { + case ETHTOOL_MSG_LINKINFO_NTF, ETHTOOL_MSG_LINKMODES_NTF: + ifname, err := extractIfname(msg.Data) + if err != nil { + m.log.Warn("ethmonitor: extract interface name", "err", err) + continue + } + m.refreshEthernetSettings(ifname) + } + } + } +} + +// RefreshInterface refreshes ethernet settings for ifname. It is intended +// to be called by other subsystems (for example nlmonitor RTM_NEWLINK). +func (m *EthMonitor) RefreshInterface(ifname string) { + m.refreshEthernetSettings(ifname) +} + +func (m *EthMonitor) refreshEthernetSettings(ifname string) { + iface, err := net.InterfaceByName(ifname) + if err != nil { + m.log.Warn("ethmonitor: lookup interface", "ifname", ifname, "err", err) + return + } + + ethIface := ethtool.Interface{Index: iface.Index, Name: iface.Name} + + if _, err := m.etClient.LinkInfo(ethIface); err != nil { + m.log.Warn("ethmonitor: query link info", "ifname", ifname, "err", err) + return + } + + mode, err := m.etClient.LinkMode(ethIface) + if err != nil { + m.log.Warn("ethmonitor: query link mode", "ifname", ifname, "err", err) + return + } + + eth := map[string]any{ + "speed": speedString(mode.SpeedMegabits), + "duplex": duplexString(mode.Duplex), + "auto-negotiation": map[string]any{ + "enable": mode.Autoneg == ethtool.AutonegOn, + }, + } + + raw, err := json.Marshal(eth) + if err != nil { + m.log.Warn("ethmonitor: marshal ethernet settings", "ifname", ifname, "err", err) + return + } + + if m.onUpdate != nil { + m.onUpdate(ifname, json.RawMessage(raw)) + } +} + +func extractIfname(data []byte) (string, error) { + ad, err := netlink.NewAttributeDecoder(data) + if err != nil { + return "", fmt.Errorf("new decoder: %w", err) + } + + for ad.Next() { + nested, err := netlink.NewAttributeDecoder(ad.Bytes()) + if err != nil { + continue + } + + for nested.Next() { + if nested.Type() != nlaHeaderIfindex { + continue + } + + ifindex := int(nested.Uint32()) + iface, err := net.InterfaceByIndex(ifindex) + if err != nil { + return "", fmt.Errorf("lookup interface index %d: %w", ifindex, err) + } + return iface.Name, nil + } + if err := nested.Err(); err != nil { + return "", fmt.Errorf("decode nested attrs: %w", err) + } + } + + if err := ad.Err(); err != nil { + return "", fmt.Errorf("decode attrs: %w", err) + } + + return "", fmt.Errorf("header ifindex attribute not found") +} + +func duplexString(d ethtool.Duplex) string { + switch d { + case ethtool.Full: + return "full" + case ethtool.Half: + return "half" + default: + return "unknown" + } +} + +// speedString converts a speed in megabits to a decimal64 string in +// Gb/s with 3 fraction digits, matching the ieee802-ethernet-interface +// YANG model's eth-if-speed-type (decimal64, fraction-digits 3, units Gb/s). +func speedString(megabits int) string { + return fmt.Sprintf("%.3f", float64(megabits)/1000.0) +} diff --git a/src/yangerd/internal/fswatcher/fswatcher.go b/src/yangerd/internal/fswatcher/fswatcher.go new file mode 100644 index 00000000..c81d9164 --- /dev/null +++ b/src/yangerd/internal/fswatcher/fswatcher.go @@ -0,0 +1,255 @@ +// Package fswatcher provides inotify-based reactive monitoring of +// filesystem paths. It replaces polling for procfs files that support +// inotify (e.g. IP forwarding flags). Each watched path has a +// handler that reads the file and updates the tree, with per-path +// debouncing to coalesce burst writes. +package fswatcher + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "path/filepath" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +// WatchHandler defines the callback for a watched path. +type WatchHandler struct { + TreeKey string + ReadFunc func(path string) (json.RawMessage, error) + Debounce time.Duration + // UseMerge causes the watcher to call tree.Merge instead of + // tree.Set, performing a shallow first-level JSON merge into + // the existing blob at TreeKey. + UseMerge bool +} + +// FSWatcher monitors filesystem paths via inotify and updates the +// tree when files change. +type FSWatcher struct { + watcher *fsnotify.Watcher + tree *tree.Tree + handlers map[string]WatchHandler + dirHandlers map[string]WatchHandler // directory path → handler + debounce map[string]*time.Timer + mu sync.Mutex + log *slog.Logger +} + +// New creates an FSWatcher backed by an inotify instance. +func New(t *tree.Tree, log *slog.Logger) (*FSWatcher, error) { + w, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("fsnotify: %w", err) + } + return &FSWatcher{ + watcher: w, + tree: t, + handlers: make(map[string]WatchHandler), + dirHandlers: make(map[string]WatchHandler), + debounce: make(map[string]*time.Timer), + log: log, + }, nil +} + +// Watch registers a handler for a specific filesystem path and adds +// the inotify watch. +func (fw *FSWatcher) Watch(path string, handler WatchHandler) error { + fw.mu.Lock() + fw.handlers[path] = handler + fw.mu.Unlock() + return fw.watcher.Add(path) +} + +// WatchGlob expands a glob pattern and registers a handler for each +// matching path. Returns the number of paths matched. +func (fw *FSWatcher) WatchGlob(pattern string, handler WatchHandler) (int, error) { + matches, err := filepath.Glob(pattern) + if err != nil { + return 0, fmt.Errorf("glob %s: %w", pattern, err) + } + for _, path := range matches { + if err := fw.Watch(path, handler); err != nil { + fw.log.Warn("fswatcher: watch failed, skipping", "path", path, "err", err) + } + } + return len(matches), nil +} + +// WatchSymlink registers a handler for a symlink by watching its parent +// directory. fsnotify follows symlinks to the target inode, so replacing +// a symlink (ln -sf) would not trigger events on a direct watch. Watching +// the parent directory catches Create and Rename events for the symlink +// entry itself. +func (fw *FSWatcher) WatchSymlink(path string, handler WatchHandler) error { + dir := filepath.Dir(path) + fw.mu.Lock() + fw.handlers[path] = handler + fw.mu.Unlock() + return fw.watcher.Add(dir) +} + +// WatchDir registers a handler for an entire directory. Any file +// create/write/remove event inside the directory triggers the handler +// with the directory path. The handler's ReadFunc receives the directory +// path (not the individual file), so it can rescan all contents. +func (fw *FSWatcher) WatchDir(dir string, handler WatchHandler) error { + fw.mu.Lock() + fw.dirHandlers[dir] = handler + fw.mu.Unlock() + return fw.watcher.Add(dir) +} + +// InitialRead reads the current value of every watched file and +// populates the tree. Called once after all Watch() calls and glob +// expansion, before Run(). +func (fw *FSWatcher) InitialRead() { + fw.mu.Lock() + defer fw.mu.Unlock() + for path, handler := range fw.handlers { + data, err := handler.ReadFunc(path) + if err != nil { + fw.log.Warn("fswatcher: initial read failed", "path", path, "err", err) + continue + } + if handler.UseMerge { + fw.tree.Merge(handler.TreeKey, data) + } else { + fw.tree.Set(handler.TreeKey, data) + } + fw.log.Debug("fswatcher: initial read", "path", path, "key", handler.TreeKey) + } + for dir, handler := range fw.dirHandlers { + data, err := handler.ReadFunc(dir) + if err != nil { + fw.log.Warn("fswatcher: initial read failed", "path", dir, "err", err) + continue + } + if handler.UseMerge { + fw.tree.Merge(handler.TreeKey, data) + } else { + fw.tree.Set(handler.TreeKey, data) + } + fw.log.Debug("fswatcher: initial read", "path", dir, "key", handler.TreeKey) + } +} + +// Run processes inotify events until ctx is cancelled. +func (fw *FSWatcher) Run(ctx context.Context) error { + defer fw.watcher.Close() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case event, ok := <-fw.watcher.Events: + if !ok { + return fmt.Errorf("watcher closed") + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + fw.handleEvent(event.Name) + } + if event.Has(fsnotify.Remove) { + fw.handleRemove(event.Name) + } + case err, ok := <-fw.watcher.Errors: + if !ok { + return fmt.Errorf("watcher error channel closed") + } + fw.log.Warn("fsnotify error", "err", err) + } + } +} + +// Close shuts down the inotify watcher and cancels pending timers. +func (fw *FSWatcher) Close() { + fw.mu.Lock() + defer fw.mu.Unlock() + for _, timer := range fw.debounce { + timer.Stop() + } + fw.watcher.Close() +} + +func (fw *FSWatcher) handleEvent(path string) { + fw.mu.Lock() + handler, ok := fw.handlers[path] + handlerPath := path + if !ok { + dir := filepath.Dir(path) + handler, ok = fw.dirHandlers[dir] + handlerPath = dir + if !ok { + fw.mu.Unlock() + return + } + } + + if handler.Debounce > 0 { + if timer, exists := fw.debounce[handlerPath]; exists { + timer.Reset(handler.Debounce) + fw.mu.Unlock() + return + } + fw.debounce[handlerPath] = time.AfterFunc(handler.Debounce, func() { + fw.fireHandler(handlerPath, handler) + }) + fw.mu.Unlock() + return + } + fw.mu.Unlock() + fw.fireHandler(handlerPath, handler) +} + +func (fw *FSWatcher) handleRemove(path string) { + fw.mu.Lock() + handler, ok := fw.handlers[path] + if !ok { + dir := filepath.Dir(path) + handler, ok = fw.dirHandlers[dir] + if ok { + fw.mu.Unlock() + fw.fireHandler(dir, handler) + return + } + fw.mu.Unlock() + return + } + fw.mu.Unlock() + + if handler.UseMerge { + fw.fireHandler(path, handler) + } else { + fw.tree.Delete(handler.TreeKey) + fw.log.Debug("fswatcher: removed", "path", path, "key", handler.TreeKey) + } + + if err := fw.watcher.Add(path); err != nil { + fw.mu.Lock() + delete(fw.handlers, path) + if timer, exists := fw.debounce[path]; exists { + timer.Stop() + delete(fw.debounce, path) + } + fw.mu.Unlock() + fw.log.Debug("fswatcher: file gone, handler removed", "path", path) + } +} + +func (fw *FSWatcher) fireHandler(path string, handler WatchHandler) { + data, err := handler.ReadFunc(path) + if err != nil { + fw.log.Warn("fswatcher: read failed", "path", path, "err", err) + return + } + if handler.UseMerge { + fw.tree.Merge(handler.TreeKey, data) + } else { + fw.tree.Set(handler.TreeKey, data) + } + fw.log.Debug("fswatcher: updated", "path", path, "key", handler.TreeKey) +} diff --git a/src/yangerd/internal/fswatcher/fswatcher_test.go b/src/yangerd/internal/fswatcher/fswatcher_test.go new file mode 100644 index 00000000..55ba59b0 --- /dev/null +++ b/src/yangerd/internal/fswatcher/fswatcher_test.go @@ -0,0 +1,613 @@ +package fswatcher + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +func newTestFSWatcher(t *testing.T) (*FSWatcher, *tree.Tree) { + t.Helper() + tr := tree.New() + fw, err := New(tr, slog.Default()) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { fw.Close() }) + return fw, tr +} + +func TestNew(t *testing.T) { + tr := tree.New() + fw, err := New(tr, slog.Default()) + if err != nil { + t.Fatalf("New: %v", err) + } + defer fw.Close() + + if fw.tree != tr { + t.Error("tree not stored") + } + if fw.handlers == nil { + t.Error("handlers map nil") + } + if fw.debounce == nil { + t.Error("debounce map nil") + } +} + +func TestWatch(t *testing.T) { + fw, _ := newTestFSWatcher(t) + + tmp := t.TempDir() + path := filepath.Join(tmp, "test.txt") + if err := os.WriteFile(path, []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + + handler := WatchHandler{ + TreeKey: "test/key", + ReadFunc: func(p string) (json.RawMessage, error) { return json.RawMessage(`"ok"`), nil }, + } + + if err := fw.Watch(path, handler); err != nil { + t.Fatalf("Watch: %v", err) + } + + fw.mu.Lock() + _, ok := fw.handlers[path] + fw.mu.Unlock() + if !ok { + t.Error("handler not registered") + } +} + +func TestInitialRead(t *testing.T) { + fw, tr := newTestFSWatcher(t) + + tmp := t.TempDir() + p1 := filepath.Join(tmp, "a.txt") + p2 := filepath.Join(tmp, "b.txt") + os.WriteFile(p1, []byte("1"), 0644) + os.WriteFile(p2, []byte("2"), 0644) + + fw.Watch(p1, WatchHandler{ + TreeKey: "key/a", + ReadFunc: func(path string) (json.RawMessage, error) { + return json.RawMessage(`"value-a"`), nil + }, + }) + fw.Watch(p2, WatchHandler{ + TreeKey: "key/b", + ReadFunc: func(path string) (json.RawMessage, error) { + return json.RawMessage(`"value-b"`), nil + }, + }) + + fw.InitialRead() + + if got := tr.Get("key/a"); string(got) != `"value-a"` { + t.Errorf("key/a = %s, want %q", got, `"value-a"`) + } + if got := tr.Get("key/b"); string(got) != `"value-b"` { + t.Errorf("key/b = %s, want %q", got, `"value-b"`) + } +} + +func TestInitialReadError(t *testing.T) { + fw, tr := newTestFSWatcher(t) + + tmp := t.TempDir() + p := filepath.Join(tmp, "fail.txt") + os.WriteFile(p, []byte("x"), 0644) + + fw.Watch(p, WatchHandler{ + TreeKey: "key/fail", + ReadFunc: func(path string) (json.RawMessage, error) { + return nil, fmt.Errorf("read error") + }, + }) + + fw.InitialRead() + + if got := tr.Get("key/fail"); got != nil { + t.Errorf("expected nil for failed read, got %s", got) + } +} + +func TestWatchGlob(t *testing.T) { + fw, _ := newTestFSWatcher(t) + + tmp := t.TempDir() + for _, name := range []string{"x1.conf", "x2.conf", "x3.conf"} { + os.WriteFile(filepath.Join(tmp, name), []byte("data"), 0644) + } + os.WriteFile(filepath.Join(tmp, "y.txt"), []byte("data"), 0644) + + handler := WatchHandler{ + TreeKey: "glob/test", + ReadFunc: func(p string) (json.RawMessage, error) { return json.RawMessage(`"g"`), nil }, + } + + n, err := fw.WatchGlob(filepath.Join(tmp, "x*.conf"), handler) + if err != nil { + t.Fatalf("WatchGlob: %v", err) + } + if n != 3 { + t.Errorf("WatchGlob matched %d, want 3", n) + } + + fw.mu.Lock() + count := len(fw.handlers) + fw.mu.Unlock() + if count != 3 { + t.Errorf("handlers count = %d, want 3", count) + } +} + +func TestRunWriteEvent(t *testing.T) { + fw, tr := newTestFSWatcher(t) + + tmp := t.TempDir() + path := filepath.Join(tmp, "watched.txt") + os.WriteFile(path, []byte("initial"), 0644) + + callCount := 0 + fw.Watch(path, WatchHandler{ + TreeKey: "run/test", + ReadFunc: func(p string) (json.RawMessage, error) { + callCount++ + return json.RawMessage(`"updated"`), nil + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + errCh := make(chan error, 1) + go func() { errCh <- fw.Run(ctx) }() + + time.Sleep(50 * time.Millisecond) + + os.WriteFile(path, []byte("changed"), 0644) + + deadline := time.After(2 * time.Second) + for { + if got := tr.Get("run/test"); string(got) == `"updated"` { + break + } + select { + case <-deadline: + t.Fatal("timed out waiting for tree update after write event") + default: + time.Sleep(10 * time.Millisecond) + } + } + + cancel() + err := <-errCh + if err != nil && err != context.Canceled { + t.Errorf("Run returned unexpected error: %v", err) + } +} + +func TestFireHandler(t *testing.T) { + fw, tr := newTestFSWatcher(t) + + handler := WatchHandler{ + TreeKey: "fire/test", + ReadFunc: func(path string) (json.RawMessage, error) { + return json.RawMessage(`{"fired":true}`), nil + }, + } + + fw.fireHandler("/fake/path", handler) + + if got := tr.Get("fire/test"); string(got) != `{"fired":true}` { + t.Errorf("tree value = %s, want %s", got, `{"fired":true}`) + } +} + +func TestFireHandlerReadError(t *testing.T) { + fw, tr := newTestFSWatcher(t) + + handler := WatchHandler{ + TreeKey: "fire/err", + ReadFunc: func(path string) (json.RawMessage, error) { + return nil, fmt.Errorf("broken") + }, + } + + fw.fireHandler("/fake/path", handler) + + if got := tr.Get("fire/err"); got != nil { + t.Errorf("expected nil for errored handler, got %s", got) + } +} + +func TestDebounce(t *testing.T) { + fw, tr := newTestFSWatcher(t) + + tmp := t.TempDir() + path := filepath.Join(tmp, "debounce.txt") + os.WriteFile(path, []byte("init"), 0644) + + callCount := 0 + fw.Watch(path, WatchHandler{ + TreeKey: "debounce/test", + Debounce: 100 * time.Millisecond, + ReadFunc: func(p string) (json.RawMessage, error) { + callCount++ + return json.RawMessage(fmt.Sprintf(`"call-%d"`, callCount)), nil + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go fw.Run(ctx) + time.Sleep(50 * time.Millisecond) + + for i := 0; i < 5; i++ { + os.WriteFile(path, []byte(fmt.Sprintf("data-%d", i)), 0644) + time.Sleep(10 * time.Millisecond) + } + + time.Sleep(300 * time.Millisecond) + + got := tr.Get("debounce/test") + if got == nil { + t.Fatal("tree not updated after debounced writes") + } + + if callCount > 3 { + t.Errorf("expected debounce to coalesce writes, but handler called %d times", callCount) + } + + cancel() +} + +func TestRunContextCancellation(t *testing.T) { + fw, _ := newTestFSWatcher(t) + + ctx, cancel := context.WithCancel(context.Background()) + + errCh := make(chan error, 1) + go func() { errCh <- fw.Run(ctx) }() + + time.Sleep(20 * time.Millisecond) + cancel() + + err := <-errCh + if err != context.Canceled { + t.Errorf("Run error = %v, want context.Canceled", err) + } +} + +func TestClose(t *testing.T) { + tr := tree.New() + fw, err := New(tr, slog.Default()) + if err != nil { + t.Fatal(err) + } + + tmp := t.TempDir() + path := filepath.Join(tmp, "close.txt") + os.WriteFile(path, []byte("x"), 0644) + + fw.Watch(path, WatchHandler{ + TreeKey: "close/test", + Debounce: time.Second, + ReadFunc: func(p string) (json.RawMessage, error) { return json.RawMessage(`"x"`), nil }, + }) + + fw.handleEvent(path) + + fw.mu.Lock() + timerCount := len(fw.debounce) + fw.mu.Unlock() + if timerCount != 1 { + t.Errorf("expected 1 debounce timer, got %d", timerCount) + } + + fw.Close() +} + +func TestHandleRemoveMergeHandler(t *testing.T) { + fw, tr := newTestFSWatcher(t) + + tmp := t.TempDir() + path := filepath.Join(tmp, "forwarding") + os.WriteFile(path, []byte("1"), 0644) + + fw.Watch(path, WatchHandler{ + TreeKey: "routing", + ReadFunc: func(_ string) (json.RawMessage, error) { + return json.RawMessage(`{"interfaces":{"interface":["eth0"]}}`), nil + }, + UseMerge: true, + }) + + fw.InitialRead() + + got := tr.Get("routing") + if got == nil { + t.Fatal("tree not populated after InitialRead") + } + + os.Remove(path) + fw.handleRemove(path) + + got = tr.Get("routing") + if got == nil { + t.Fatal("tree entry should still exist after merge-remove") + } + if string(got) != `{"interfaces":{"interface":["eth0"]}}` { + t.Errorf("got %s, want updated merge data", got) + } + + fw.mu.Lock() + _, handlerExists := fw.handlers[path] + fw.mu.Unlock() + if handlerExists { + t.Error("handler should be cleaned up after permanent removal") + } +} + +func TestHandleRemovePlainHandler(t *testing.T) { + fw, tr := newTestFSWatcher(t) + + tmp := t.TempDir() + path := filepath.Join(tmp, "value.txt") + os.WriteFile(path, []byte("data"), 0644) + + fw.Watch(path, WatchHandler{ + TreeKey: "plain/key", + ReadFunc: func(p string) (json.RawMessage, error) { + return json.RawMessage(`"hello"`), nil + }, + }) + + fw.InitialRead() + + if got := tr.Get("plain/key"); string(got) != `"hello"` { + t.Fatalf("initial = %s, want %q", got, `"hello"`) + } + + os.Remove(path) + fw.handleRemove(path) + + if got := tr.Get("plain/key"); got != nil { + t.Errorf("tree entry should be deleted after remove, got %s", got) + } + + fw.mu.Lock() + _, handlerExists := fw.handlers[path] + fw.mu.Unlock() + if handlerExists { + t.Error("handler should be cleaned up after permanent removal") + } +} + +func TestHandleRemoveUnknownPath(t *testing.T) { + fw, _ := newTestFSWatcher(t) + fw.handleRemove("/nonexistent/path") +} + +func TestHandleRemoveRewatchSuccess(t *testing.T) { + fw, tr := newTestFSWatcher(t) + + tmp := t.TempDir() + path := filepath.Join(tmp, "ephemeral.txt") + os.WriteFile(path, []byte("1"), 0644) + + calls := 0 + fw.Watch(path, WatchHandler{ + TreeKey: "ephem", + ReadFunc: func(_ string) (json.RawMessage, error) { + calls++ + return json.RawMessage(fmt.Sprintf(`"v%d"`, calls)), nil + }, + UseMerge: true, + }) + + fw.InitialRead() + + fw.handleRemove(path) + + fw.mu.Lock() + _, handlerExists := fw.handlers[path] + fw.mu.Unlock() + if !handlerExists { + t.Error("handler should still exist when file still exists (rewatch succeeds)") + } + + got := tr.Get("ephem") + if string(got) != `"v2"` { + t.Errorf("got %s, want %q (handler should have been called again)", got, `"v2"`) + } +} + +func TestWatchSymlink(t *testing.T) { + fw, _ := newTestFSWatcher(t) + + tmp := t.TempDir() + targetA := filepath.Join(tmp, "target-a") + targetB := filepath.Join(tmp, "target-b") + link := filepath.Join(tmp, "link") + os.WriteFile(targetA, []byte("a"), 0644) + os.WriteFile(targetB, []byte("b"), 0644) + os.Symlink(targetA, link) + + handler := WatchHandler{ + TreeKey: "sym/test", + ReadFunc: func(p string) (json.RawMessage, error) { return json.RawMessage(`"sym"`), nil }, + } + + if err := fw.WatchSymlink(link, handler); err != nil { + t.Fatalf("WatchSymlink: %v", err) + } + + fw.mu.Lock() + _, ok := fw.handlers[link] + fw.mu.Unlock() + if !ok { + t.Error("handler not registered under symlink path") + } +} + +func TestWatchSymlinkReplace(t *testing.T) { + fw, tr := newTestFSWatcher(t) + + tmp := t.TempDir() + targetA := filepath.Join(tmp, "zone-a") + targetB := filepath.Join(tmp, "zone-b") + link := filepath.Join(tmp, "current") + os.WriteFile(targetA, []byte("a"), 0644) + os.WriteFile(targetB, []byte("b"), 0644) + os.Symlink(targetA, link) + + calls := 0 + fw.WatchSymlink(link, WatchHandler{ + TreeKey: "sym/replace", + ReadFunc: func(p string) (json.RawMessage, error) { + calls++ + target, _ := os.Readlink(p) + return json.RawMessage(fmt.Sprintf(`"target-%d-%s"`, calls, filepath.Base(target))), nil + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go fw.Run(ctx) + time.Sleep(50 * time.Millisecond) + + os.Remove(link) + os.Symlink(targetB, link) + + deadline := time.After(2 * time.Second) + for { + got := tr.Get("sym/replace") + if got != nil && strings.Contains(string(got), "zone-b") { + break + } + select { + case <-deadline: + t.Fatalf("timed out waiting for symlink replace event; tree = %s", tr.Get("sym/replace")) + default: + time.Sleep(10 * time.Millisecond) + } + } + + cancel() +} + +func TestWatchDir(t *testing.T) { + fw, tr := newTestFSWatcher(t) + + tmp := t.TempDir() + os.WriteFile(filepath.Join(tmp, "a.keys"), []byte("key-a"), 0644) + + fw.WatchDir(tmp, WatchHandler{ + TreeKey: "dir/test", + ReadFunc: func(dir string) (json.RawMessage, error) { + entries, _ := os.ReadDir(dir) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return json.Marshal(map[string]interface{}{"files": names}) + }, + Debounce: 50 * time.Millisecond, + UseMerge: true, + }) + + fw.InitialRead() + got := tr.Get("dir/test") + if got == nil { + t.Fatal("tree not populated after InitialRead for dir handler") + } + if !strings.Contains(string(got), "a.keys") { + t.Fatalf("initial read missing a.keys: %s", got) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go fw.Run(ctx) + time.Sleep(50 * time.Millisecond) + + os.WriteFile(filepath.Join(tmp, "b.keys"), []byte("key-b"), 0644) + + deadline := time.After(2 * time.Second) + for { + got = tr.Get("dir/test") + if got != nil && strings.Contains(string(got), "b.keys") { + break + } + select { + case <-deadline: + t.Fatalf("timed out waiting for dir event; tree = %s", tr.Get("dir/test")) + default: + time.Sleep(10 * time.Millisecond) + } + } + + cancel() +} + +func TestWatchDirRemoveFile(t *testing.T) { + fw, tr := newTestFSWatcher(t) + + tmp := t.TempDir() + os.WriteFile(filepath.Join(tmp, "x.keys"), []byte("data"), 0644) + os.WriteFile(filepath.Join(tmp, "y.keys"), []byte("data"), 0644) + + fw.WatchDir(tmp, WatchHandler{ + TreeKey: "dir/rm", + ReadFunc: func(dir string) (json.RawMessage, error) { + entries, _ := os.ReadDir(dir) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return json.Marshal(map[string]interface{}{"files": names}) + }, + UseMerge: true, + }) + + fw.InitialRead() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go fw.Run(ctx) + time.Sleep(50 * time.Millisecond) + + os.Remove(filepath.Join(tmp, "x.keys")) + + deadline := time.After(2 * time.Second) + for { + got := tr.Get("dir/rm") + if got != nil && !strings.Contains(string(got), "x.keys") && strings.Contains(string(got), "y.keys") { + break + } + select { + case <-deadline: + t.Fatalf("timed out waiting for dir remove event; tree = %s", tr.Get("dir/rm")) + default: + time.Sleep(10 * time.Millisecond) + } + } + + cancel() +} diff --git a/src/yangerd/internal/iface/iface.go b/src/yangerd/internal/iface/iface.go new file mode 100644 index 00000000..31d984ff --- /dev/null +++ b/src/yangerd/internal/iface/iface.go @@ -0,0 +1,838 @@ +// Package iface transforms raw `ip -json` data into YANG-shaped +// ietf-interfaces JSON. +package iface + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// FileChecker abstracts filesystem probes needed during interface transformation. +type FileChecker interface { + Exists(path string) bool + ReadFile(path string) (string, error) +} + +// Transform converts raw `ip -json` link/address/statistics arrays into +// `{"interface":[...]}`. The caller (NLMonitor) stores this at tree key +// "ietf-interfaces:interfaces"; the IPC server adds the module-qualified +// wrapper when responding to clients. +func Transform(linkData, addrData, statsData json.RawMessage, fc FileChecker) json.RawMessage { + links := dedup(decodeObjects(linkData)) + addrs := decodeObjects(addrData) + stats := decodeObjects(statsData) + + addrByName := make(map[string]map[string]any, len(addrs)) + for _, addr := range addrs { + ifname := getString(addr, "ifname") + if ifname == "" { + continue + } + addrByName[ifname] = addr + } + + statsByName := make(map[string]map[string]any, len(stats)) + for _, st := range stats { + ifname := getString(st, "ifname") + if ifname == "" { + continue + } + statsByName[ifname] = st + } + + interfaces := make([]map[string]any, 0, len(links)) + for _, iplink := range links { + if skipInterface(iplink) { + continue + } + + ifname := getString(iplink, "ifname") + ipaddr, ok := addrByName[ifname] + if !ok { + ipaddr = map[string]any{} + } + + if st, ok := statsByName[ifname]; ok { + if stat64, ok := st["stats64"]; ok { + iplink["stats64"] = stat64 + } + } + + iface := interfaceCommon(iplink, ipaddr, fc) + yangType := getString(iface, "type") + + switch yangType { + case "infix-if-type:vlan": + if v := vlanAugment(iplink); len(v) > 0 { + iface["infix-interfaces:vlan"] = v + } + case "infix-if-type:veth": + if v := vethAugment(iplink); len(v) > 0 { + iface["infix-interfaces:veth"] = v + } + case "infix-if-type:gre", "infix-if-type:gretap": + if v := greAugment(iplink); len(v) > 0 { + iface["infix-interfaces:gre"] = v + } + case "infix-if-type:vxlan": + if v := vxlanAugment(iplink); len(v) > 0 { + iface["infix-interfaces:vxlan"] = v + } + case "infix-if-type:lag": + if v := lagAugment(iplink); len(v) > 0 { + iface["infix-interfaces:lag"] = v + } + } + + switch iplink2yangLower(iplink) { + case "infix-interfaces:bridge-port": + if lower := bridgePortLower(iplink); len(lower) > 0 { + iface["infix-interfaces:bridge-port"] = lower + } + case "infix-interfaces:lag-port": + if lower := lagPortLower(iplink); len(lower) > 0 { + iface["infix-interfaces:lag-port"] = lower + } + } + + interfaces = append(interfaces, iface) + } + + out := map[string]any{ + "interface": interfaces, + } + + raw, err := json.Marshal(out) + if err != nil { + return json.RawMessage(`{"interface":[]}`) + } + + return raw +} + +func decodeObjects(raw json.RawMessage) []map[string]any { + if len(raw) == 0 { + return nil + } + + var entries []any + if err := json.Unmarshal(raw, &entries); err != nil { + return nil + } + + out := make([]map[string]any, 0, len(entries)) + for _, entry := range entries { + obj, ok := asMap(entry) + if !ok { + continue + } + out = append(out, obj) + } + + return out +} + +func skipInterface(iplink map[string]any) bool { + if getString(iplink, "group") == "internal" { + return true + } + + switch getString(iplink, "link_type") { + case "can", "vcan": + return true + default: + return false + } +} + +// dedup removes duplicate link entries that share the same ifindex. +// When an interface is renamed (e.g. eth0 → e1), ip -json may report +// both the old and new names with the same ifindex. We keep the entry +// whose operstate is "UP", or the last one seen if neither is up. +func dedup(links []map[string]any) []map[string]any { + seen := make(map[int]int, len(links)) + out := make([]map[string]any, 0, len(links)) + for _, link := range links { + idx := getIntOrZero(link, "ifindex") + if idx == 0 { + out = append(out, link) + continue + } + if prev, ok := seen[idx]; ok { + if getString(link, "operstate") == "UP" && getString(out[prev], "operstate") != "UP" { + out[prev] = link + } + } else { + seen[idx] = len(out) + out = append(out, link) + } + } + return out +} + +func interfaceCommon(iplink, ipaddr map[string]any, fc FileChecker) map[string]any { + flags := getStrings(iplink, "flags") + + iface := map[string]any{ + "type": iplink2yangType(iplink, fc), + "name": getString(iplink, "ifname"), + "if-index": getIntOrZero(iplink, "ifindex"), + "admin-status": boolToStatus(contains(flags, "UP"), "up", "down"), + "oper-status": iplink2yangOperstate(getString(iplink, "operstate")), + } + + if _, ok := iplink["ifalias"]; ok { + iface["description"] = getString(iplink, "ifalias") + } + + if !contains(flags, "POINTOPOINT") { + if address, ok := iplink["address"]; ok { + iface["phys-address"] = fmt.Sprintf("%v", address) + } + } + + if stats := statistics(iplink); len(stats) > 0 { + iface["statistics"] = stats + } + + if ipv4 := ipv4Data(ipaddr); len(ipv4) > 0 { + iface["ietf-ip:ipv4"] = ipv4 + } + + if ipv6 := ipv6Data(ipaddr, fc); len(ipv6) > 0 { + iface["ietf-ip:ipv6"] = ipv6 + } + + return iface +} + +func iplink2yangType(iplink map[string]any, fc FileChecker) string { + ifname := getString(iplink, "ifname") + + switch getString(iplink, "link_type") { + case "loopback": + return "infix-if-type:loopback" + case "gre", "gre6": + return "infix-if-type:gre" + case "ether": + if fc != nil { + if fc.Exists(fmt.Sprintf("/sys/class/net/%s/wireless/", ifname)) { + return "infix-if-type:wifi" + } + } + case "none": + default: + return "infix-if-type:other" + } + + linkinfo, _ := asMap(iplink["linkinfo"]) + switch getString(linkinfo, "info_kind") { + case "bond": + return "infix-if-type:lag" + case "bridge": + return "infix-if-type:bridge" + case "dummy": + return "infix-if-type:dummy" + case "gretap", "ip6gretap": + return "infix-if-type:gretap" + case "vxlan": + return "infix-if-type:vxlan" + case "veth": + return "infix-if-type:veth" + case "vlan": + return "infix-if-type:vlan" + case "wireguard": + return "infix-if-type:wireguard" + default: + return "infix-if-type:ethernet" + } +} + +func iplink2yangLower(iplink map[string]any) string { + linkinfo, _ := asMap(iplink["linkinfo"]) + switch getString(linkinfo, "info_slave_kind") { + case "bridge": + return "infix-interfaces:bridge-port" + case "bond": + return "infix-interfaces:lag-port" + default: + return "" + } +} + +func iplink2yangOperstate(oper string) string { + switch oper { + case "DOWN": + return "down" + case "UP": + return "up" + case "DORMANT": + return "dormant" + case "TESTING": + return "testing" + case "LOWERLAYERDOWN": + return "lower-layer-down" + case "NOTPRESENT": + return "not-present" + default: + return "unknown" + } +} + +func statistics(iplink map[string]any) map[string]any { + out := map[string]any{} + + stats64, _ := asMap(iplink["stats64"]) + rx, _ := asMap(stats64["rx"]) + tx, _ := asMap(stats64["tx"]) + + if octets, ok := rx["bytes"]; ok && isTruthy(octets) { + out["in-octets"] = toCounterString(octets) + } + + if octets, ok := tx["bytes"]; ok && isTruthy(octets) { + out["out-octets"] = toCounterString(octets) + } + + return out +} + +func ipv4Data(ipaddr map[string]any) map[string]any { + if len(ipaddr) == 0 { + return nil + } + + out := map[string]any{} + if mtu, ok := getInt(ipaddr, "mtu"); ok && mtu != 0 && getString(ipaddr, "ifname") != "lo" { + out["mtu"] = mtu + } + + if addr := addresses(ipaddr, "inet"); len(addr) > 0 { + out["address"] = addr + } + + return out +} + +func ipv6Data(ipaddr map[string]any, fc FileChecker) map[string]any { + if len(ipaddr) == 0 { + return nil + } + + out := map[string]any{} + ifname := getString(ipaddr, "ifname") + if ifname != "" && fc != nil { + path := fmt.Sprintf("/proc/sys/net/ipv6/conf/%s/mtu", ifname) + if raw, err := fc.ReadFile(path); err == nil { + trimmed := strings.TrimSpace(raw) + if mtu, err := strconv.Atoi(trimmed); err == nil { + out["mtu"] = mtu + } + } + } + + if addr := addresses(ipaddr, "inet6"); len(addr) > 0 { + out["address"] = addr + } + + return out +} + +func addresses(ipaddr map[string]any, family string) []map[string]any { + addrInfo, ok := ipaddr["addr_info"] + if !ok { + return nil + } + + arr, ok := asArray(addrInfo) + if !ok { + return nil + } + + out := make([]map[string]any, 0, len(arr)) + for _, entry := range arr { + inet, ok := asMap(entry) + if !ok { + continue + } + + if getString(inet, "family") != family { + continue + } + + address := map[string]any{ + "ip": inet["local"], + "prefix-length": getIntOrZero(inet, "prefixlen"), + "origin": inet2yangOrigin(inet), + } + out = append(out, address) + } + + return out +} + +func inet2yangOrigin(inet map[string]any) string { + proto := getString(inet, "protocol") + if proto == "kernel_ll" || proto == "kernel_ra" { + if _, ok := inet["stable-privacy"]; ok { + return "random" + } + } + + switch proto { + case "kernel_ll", "kernel_ra": + return "link-layer" + case "static": + return "static" + case "dhcp": + return "dhcp" + case "random": + return "random" + default: + return "other" + } +} + +func vlanAugment(iplink map[string]any) map[string]any { + info := infoData(iplink) + if len(info) == 0 { + return nil + } + + vlan := map[string]any{ + "tag-type": proto2yang(getString(info, "protocol")), + "id": getIntOrZero(info, "id"), + } + + if lower := getString(iplink, "link"); lower != "" { + vlan["lower-layer-if"] = lower + } + + return vlan +} + +func vethAugment(iplink map[string]any) map[string]any { + peer := getString(iplink, "link") + if peer == "" { + return nil + } + + return map[string]any{"peer": peer} +} + +func greAugment(iplink map[string]any) map[string]any { + info := infoData(iplink) + if len(info) == 0 { + return nil + } + + return map[string]any{ + "local": firstAny(info["local"], info["local6"]), + "remote": firstAny(info["remote"], info["remote6"]), + } +} + +func vxlanAugment(iplink map[string]any) map[string]any { + vxlan := greAugment(iplink) + if len(vxlan) == 0 { + return nil + } + + info := infoData(iplink) + if vni, ok := info["id"]; ok { + vxlan["vni"] = vni + } + + return vxlan +} + +func lagAugment(iplink map[string]any) map[string]any { + info := infoData(iplink) + if len(info) == 0 { + return nil + } + + mode := lagMode(getString(info, "mode")) + bond := map[string]any{ + "mode": mode, + "link-monitor": map[string]any{ + "debounce": map[string]any{ + "up": getIntOrZero(info, "updelay"), + "down": getIntOrZero(info, "downdelay"), + }, + }, + } + + if mode == "lacp" { + lacp := map[string]any{ + "mode": boolToStatus(getString(info, "ad_lacp_active") == "on", "active", "passive"), + "rate": getString(info, "ad_lacp_rate"), + "hash": lagHash(getString(info, "xmit_hash_policy")), + } + + adInfo, ok := asMap(info["ad_info"]) + if ok { + if v, ok := adInfo["aggregator"]; ok { + lacp["aggregator-id"] = v + } + if v, ok := adInfo["actor_key"]; ok { + lacp["actor-key"] = v + } + if v, ok := adInfo["partner_key"]; ok { + lacp["partner-key"] = v + } + if v, ok := adInfo["partner_mac"]; ok { + lacp["partner-mac"] = v + } + } + + if v, ok := info["ad_actor_sys_prio"]; ok { + lacp["system-priority"] = v + } + + bond["lacp"] = lacp + } else { + bond["static"] = map[string]any{ + "mode": getString(info, "mode"), + "hash": getString(info, "xmit_hash_policy"), + } + } + + return bond +} + +func bridgePortLower(iplink map[string]any) map[string]any { + master := getString(iplink, "master") + if master == "" { + return nil + } + + linkinfo, _ := asMap(iplink["linkinfo"]) + info, _ := asMap(linkinfo["info_slave_data"]) + if len(info) == 0 { + return nil + } + + return map[string]any{ + "bridge": master, + "flood": map[string]any{ + "broadcast": getBool(info, "bcast_flood"), + "unicast": getBool(info, "flood"), + "multicast": getBool(info, "mcast_flood"), + }, + "multicast": map[string]any{ + "fast-leave": getBool(info, "fastleave"), + "router": bridgeRouterMode(getIntOrZero(info, "multicast_router")), + }, + "stp": map[string]any{}, + } +} + +func lagPortLower(iplink map[string]any) map[string]any { + master := getString(iplink, "master") + if master == "" { + return nil + } + + port := map[string]any{"lag": master} + + linkinfo, _ := asMap(iplink["linkinfo"]) + info, _ := asMap(linkinfo["info_slave_data"]) + if len(info) == 0 { + port["state"] = "backup" + port["link-failures"] = 0 + return port + } + + port["state"] = strings.ToLower(getString(info, "state")) + port["link-failures"] = getIntOrZero(info, "link_failure_count") + + if _, ok := info["ad_aggregator_id"]; ok { + port["lacp"] = map[string]any{ + "aggregator-id": info["ad_aggregator_id"], + "actor-state": getString(info, "ad_actor_oper_port_state_str"), + "partner-state": getString(info, "ad_partner_oper_port_state_str"), + } + } + + return port +} + +func infoData(iplink map[string]any) map[string]any { + linkinfo, ok := asMap(iplink["linkinfo"]) + if !ok { + return nil + } + + data, ok := asMap(linkinfo["info_data"]) + if !ok { + return nil + } + + return data +} + +func proto2yang(proto string) string { + switch proto { + case "802.1Q": + return "ieee802-dot1q-types:c-vlan" + case "802.1ad": + return "ieee802-dot1q-types:s-vlan" + default: + return "other" + } +} + +func lagMode(mode string) string { + switch mode { + case "802.3ad": + return "lacp" + case "balance-xor": + return "static" + default: + return "static" + } +} + +func lagHash(hash string) string { + switch hash { + case "layer2": + return "layer2" + case "layer3+4": + return "layer3-4" + case "layer2+3": + return "layer2-3" + case "encap2+3": + return "encap2-3" + case "encap3+4": + return "encap3-4" + case "vlan+srcmac": + return "vlan-srcmac" + default: + return "layer2" + } +} + +func bridgeRouterMode(v int) string { + switch v { + case 0: + return "off" + case 1: + return "auto" + case 2: + return "permanent" + default: + return "UNKNOWN" + } +} + +func getString(obj map[string]any, key string) string { + v, ok := obj[key] + if !ok || v == nil { + return "" + } + + s, ok := v.(string) + if ok { + return s + } + + return fmt.Sprintf("%v", v) +} + +func getInt(obj map[string]any, key string) (int, bool) { + v, ok := obj[key] + if !ok || v == nil { + return 0, false + } + + switch n := v.(type) { + case int: + return n, true + case int8: + return int(n), true + case int16: + return int(n), true + case int32: + return int(n), true + case int64: + return int(n), true + case uint: + return int(n), true + case uint8: + return int(n), true + case uint16: + return int(n), true + case uint32: + return int(n), true + case uint64: + return int(n), true + case float64: + return int(n), true + case json.Number: + i, err := n.Int64() + if err != nil { + return 0, false + } + return int(i), true + case string: + i, err := strconv.Atoi(strings.TrimSpace(n)) + if err != nil { + return 0, false + } + return i, true + default: + return 0, false + } +} + +func getIntOrZero(obj map[string]any, key string) int { + v, ok := getInt(obj, key) + if !ok { + return 0 + } + return v +} + +func getBool(obj map[string]any, key string) bool { + v, ok := obj[key] + if !ok || v == nil { + return false + } + + b, ok := v.(bool) + if ok { + return b + } + + s := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v))) + return s == "1" || s == "true" || s == "on" || s == "yes" +} + +func getStrings(obj map[string]any, key string) []string { + v, ok := obj[key] + if !ok || v == nil { + return nil + } + + if direct, ok := v.([]string); ok { + return direct + } + + arr, ok := asArray(v) + if !ok { + return nil + } + + out := make([]string, 0, len(arr)) + for _, item := range arr { + out = append(out, fmt.Sprintf("%v", item)) + } + return out +} + +func asMap(v any) (map[string]any, bool) { + if v == nil { + return nil, false + } + + m, ok := v.(map[string]any) + if ok { + return m, true + } + + m2, ok := v.(map[string]interface{}) + if ok { + return map[string]any(m2), true + } + + return nil, false +} + +func asArray(v any) ([]any, bool) { + if v == nil { + return nil, false + } + + arr, ok := v.([]any) + if ok { + return arr, true + } + + arr2, ok := v.([]interface{}) + if ok { + return []any(arr2), true + } + + return nil, false +} + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func isTruthy(v any) bool { + if v == nil { + return false + } + + s := strings.TrimSpace(fmt.Sprintf("%v", v)) + return s != "" && s != "0" +} + +func toCounterString(v any) string { + switch n := v.(type) { + case int: + return strconv.FormatInt(int64(n), 10) + case int8: + return strconv.FormatInt(int64(n), 10) + case int16: + return strconv.FormatInt(int64(n), 10) + case int32: + return strconv.FormatInt(int64(n), 10) + case int64: + return strconv.FormatInt(n, 10) + case uint: + return strconv.FormatUint(uint64(n), 10) + case uint8: + return strconv.FormatUint(uint64(n), 10) + case uint16: + return strconv.FormatUint(uint64(n), 10) + case uint32: + return strconv.FormatUint(uint64(n), 10) + case uint64: + return strconv.FormatUint(n, 10) + case float64: + return strconv.FormatInt(int64(n), 10) + case json.Number: + return n.String() + case string: + return strings.TrimSpace(n) + default: + return fmt.Sprintf("%v", v) + } +} + +func firstAny(a, b any) any { + if a != nil { + s := strings.TrimSpace(fmt.Sprintf("%v", a)) + if s != "" { + return a + } + } + return b +} + +func boolToStatus(cond bool, yes, no string) string { + if cond { + return yes + } + return no +} diff --git a/src/yangerd/internal/iface/iface_test.go b/src/yangerd/internal/iface/iface_test.go new file mode 100644 index 00000000..e072abf0 --- /dev/null +++ b/src/yangerd/internal/iface/iface_test.go @@ -0,0 +1,841 @@ +package iface + +import ( + "encoding/json" + "errors" + "testing" +) + +type mockFileChecker struct { + exists map[string]bool + files map[string]string + readErr map[string]error +} + +func (m *mockFileChecker) Exists(path string) bool { + if m == nil || m.exists == nil { + return false + } + return m.exists[path] +} + +func (m *mockFileChecker) ReadFile(path string) (string, error) { + if m == nil { + return "", errors.New("nil file checker") + } + if err, ok := m.readErr[path]; ok { + return "", err + } + if v, ok := m.files[path]; ok { + return v, nil + } + return "", errors.New("not found") +} + +func mustRaw(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +func mustInterfaces(t *testing.T, raw json.RawMessage) []map[string]any { + t.Helper() + + var root map[string]any + if err := json.Unmarshal(raw, &root); err != nil { + t.Fatalf("unmarshal transform output: %v", err) + } + + arr, ok := root["interface"].([]any) + if !ok { + t.Fatalf("missing interface list: %#v", root) + } + + out := make([]map[string]any, 0, len(arr)) + for _, v := range arr { + m, ok := v.(map[string]any) + if !ok { + t.Fatalf("interface entry not object: %T", v) + } + out = append(out, m) + } + + return out +} + +func mustIfaceByName(t *testing.T, ifaces []map[string]any, name string) map[string]any { + t.Helper() + for _, iface := range ifaces { + if iface["name"] == name { + return iface + } + } + t.Fatalf("interface %q not found", name) + return nil +} + +func TestTransformEmptyInputs(t *testing.T) { + tests := []struct { + name string + linkData json.RawMessage + addrData json.RawMessage + stats json.RawMessage + }{ + {name: "nil raw messages"}, + { + name: "empty arrays", + linkData: mustRaw(t, []map[string]any{}), + addrData: mustRaw(t, []map[string]any{}), + stats: mustRaw(t, []map[string]any{}), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ifaces := mustInterfaces(t, Transform(tt.linkData, tt.addrData, tt.stats, nil)) + if len(ifaces) != 0 { + t.Fatalf("expected empty interface list, got %d", len(ifaces)) + } + }) + } +} + +func TestTransformSingleLoopback(t *testing.T) { + link := []map[string]any{{ + "ifindex": 1, + "ifname": "lo", + "flags": []any{"LOOPBACK", "UP"}, + "link_type": "loopback", + "operstate": "UNKNOWN", + "address": "00:00:00:00:00:00", + "statistics": map[string]any{}, + }} + + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil)) + if len(ifaces) != 1 { + t.Fatalf("expected 1 interface, got %d", len(ifaces)) + } + + lo := ifaces[0] + if lo["name"] != "lo" { + t.Fatalf("name = %v", lo["name"]) + } + if lo["type"] != "infix-if-type:loopback" { + t.Fatalf("type = %v", lo["type"]) + } + if lo["admin-status"] != "up" || lo["oper-status"] != "unknown" { + t.Fatalf("admin/oper mismatch: %v/%v", lo["admin-status"], lo["oper-status"]) + } +} + +func TestTransformSingleEthernetWithIPv4IPv6(t *testing.T) { + link := []map[string]any{{ + "ifindex": 2, + "ifname": "eth0", + "flags": []any{"UP"}, + "link_type": "ether", + "operstate": "UP", + "address": "52:54:00:12:34:56", + }} + + addr := []map[string]any{{ + "ifname": "eth0", + "mtu": 1500, + "addr_info": []map[string]any{ + {"family": "inet", "local": "192.0.2.10", "prefixlen": 24, "protocol": "static"}, + {"family": "inet6", "local": "2001:db8::10", "prefixlen": 64, "protocol": "kernel_ra"}, + }, + }} + + fc := &mockFileChecker{files: map[string]string{"/proc/sys/net/ipv6/conf/eth0/mtu": "1400\n"}} + + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), mustRaw(t, addr), nil, fc)) + eth0 := mustIfaceByName(t, ifaces, "eth0") + + if eth0["type"] != "infix-if-type:ethernet" { + t.Fatalf("unexpected type: %v", eth0["type"]) + } + + ipv4, ok := eth0["ietf-ip:ipv4"].(map[string]any) + if !ok { + t.Fatalf("missing ipv4 container: %#v", eth0) + } + if ipv4["mtu"] != float64(1500) { + t.Fatalf("ipv4 mtu = %v", ipv4["mtu"]) + } + v4addrs := ipv4["address"].([]any) + v4 := v4addrs[0].(map[string]any) + if v4["ip"] != "192.0.2.10" || v4["prefix-length"] != float64(24) || v4["origin"] != "static" { + t.Fatalf("unexpected ipv4 address entry: %#v", v4) + } + + ipv6, ok := eth0["ietf-ip:ipv6"].(map[string]any) + if !ok { + t.Fatalf("missing ipv6 container: %#v", eth0) + } + if ipv6["mtu"] != float64(1400) { + t.Fatalf("ipv6 mtu = %v", ipv6["mtu"]) + } + v6addrs := ipv6["address"].([]any) + v6 := v6addrs[0].(map[string]any) + if v6["ip"] != "2001:db8::10" || v6["prefix-length"] != float64(64) || v6["origin"] != "link-layer" { + t.Fatalf("unexpected ipv6 address entry: %#v", v6) + } +} + +func TestTransformStatisticsCountersAsStrings(t *testing.T) { + link := []map[string]any{{ + "ifindex": 3, + "ifname": "eth1", + "flags": []any{"UP"}, + "link_type": "ether", + "operstate": "UP", + }} + + stats := []map[string]any{{ + "ifname": "eth1", + "stats64": map[string]any{ + "rx": map[string]any{"bytes": uint64(1234567890)}, + "tx": map[string]any{"bytes": uint64(9876543210)}, + }, + }} + + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, mustRaw(t, stats), nil)) + eth1 := mustIfaceByName(t, ifaces, "eth1") + st, ok := eth1["statistics"].(map[string]any) + if !ok { + t.Fatalf("missing statistics: %#v", eth1) + } + + if _, ok := st["in-octets"].(string); !ok { + t.Fatalf("in-octets must be string, got %T", st["in-octets"]) + } + if _, ok := st["out-octets"].(string); !ok { + t.Fatalf("out-octets must be string, got %T", st["out-octets"]) + } +} + +func TestTransformVLANAugment(t *testing.T) { + link := []map[string]any{{ + "ifindex": 10, + "ifname": "eth0.100", + "flags": []any{"UP"}, + "link_type": "none", + "operstate": "UP", + "link": "eth0", + "linkinfo": map[string]any{ + "info_kind": "vlan", + "info_data": map[string]any{"protocol": "802.1Q", "id": 100}, + }, + }} + + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil)) + vlan := mustIfaceByName(t, ifaces, "eth0.100") + + if vlan["type"] != "infix-if-type:vlan" { + t.Fatalf("type = %v", vlan["type"]) + } + v, ok := vlan["infix-interfaces:vlan"].(map[string]any) + if !ok { + t.Fatalf("missing vlan augment: %#v", vlan) + } + if v["tag-type"] != "ieee802-dot1q-types:c-vlan" || v["id"] != float64(100) || v["lower-layer-if"] != "eth0" { + t.Fatalf("unexpected vlan augment: %#v", v) + } +} + +func TestTransformVethAugment(t *testing.T) { + link := []map[string]any{{ + "ifname": "veth0", + "ifindex": 11, + "flags": []any{"UP"}, + "link_type": "none", + "operstate": "UP", + "link": "veth1", + "linkinfo": map[string]any{"info_kind": "veth"}, + }} + + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil)) + veth := mustIfaceByName(t, ifaces, "veth0") + v, ok := veth["infix-interfaces:veth"].(map[string]any) + if !ok || v["peer"] != "veth1" { + t.Fatalf("unexpected veth augment: %#v", veth) + } +} + +func TestTransformGREAndVXLANAugments(t *testing.T) { + link := []map[string]any{ + { + "ifname": "gre1", + "ifindex": 12, + "flags": []any{"UP"}, + "link_type": "gre", + "operstate": "UP", + "linkinfo": map[string]any{ + "info_data": map[string]any{"local": "192.0.2.1", "remote": "198.51.100.1"}, + }, + }, + { + "ifname": "vxlan10", + "ifindex": 13, + "flags": []any{"UP"}, + "link_type": "none", + "operstate": "UP", + "linkinfo": map[string]any{ + "info_kind": "vxlan", + "info_data": map[string]any{"local": "10.0.0.1", "remote": "10.0.0.2", "id": 10}, + }, + }, + } + + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil)) + + gre := mustIfaceByName(t, ifaces, "gre1") + if gre["type"] != "infix-if-type:gre" { + t.Fatalf("gre type = %v", gre["type"]) + } + g, ok := gre["infix-interfaces:gre"].(map[string]any) + if !ok || g["local"] != "192.0.2.1" || g["remote"] != "198.51.100.1" { + t.Fatalf("unexpected gre augment: %#v", g) + } + + vx := mustIfaceByName(t, ifaces, "vxlan10") + if vx["type"] != "infix-if-type:vxlan" { + t.Fatalf("vxlan type = %v", vx["type"]) + } + v, ok := vx["infix-interfaces:vxlan"].(map[string]any) + if !ok || v["local"] != "10.0.0.1" || v["remote"] != "10.0.0.2" || v["vni"] != float64(10) { + t.Fatalf("unexpected vxlan augment: %#v", v) + } +} + +func TestTransformLAGAugmentModes(t *testing.T) { + link := []map[string]any{ + { + "ifname": "bond0", + "ifindex": 20, + "flags": []any{"UP"}, + "link_type": "none", + "operstate": "UP", + "linkinfo": map[string]any{ + "info_kind": "bond", + "info_data": map[string]any{ + "mode": "802.3ad", + "updelay": 10, + "downdelay": 20, + "ad_lacp_active": "on", + "ad_lacp_rate": "fast", + "xmit_hash_policy": "layer3+4", + "ad_actor_sys_prio": 100, + "ad_info": map[string]any{ + "aggregator": 7, + "actor_key": 1000, + "partner_key": 2000, + "partner_mac": "02:00:00:00:00:01", + }, + }, + }, + }, + { + "ifname": "bond1", + "ifindex": 21, + "flags": []any{"UP"}, + "link_type": "none", + "operstate": "UP", + "linkinfo": map[string]any{ + "info_kind": "bond", + "info_data": map[string]any{ + "mode": "balance-xor", + "xmit_hash_policy": "layer2", + }, + }, + }, + } + + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil)) + + bond0 := mustIfaceByName(t, ifaces, "bond0") + b0 := bond0["infix-interfaces:lag"].(map[string]any) + if b0["mode"] != "lacp" { + t.Fatalf("bond0 mode = %v", b0["mode"]) + } + lacp := b0["lacp"].(map[string]any) + if lacp["mode"] != "active" || lacp["rate"] != "fast" || lacp["hash"] != "layer3-4" { + t.Fatalf("unexpected bond0 lacp: %#v", lacp) + } + + bond1 := mustIfaceByName(t, ifaces, "bond1") + b1 := bond1["infix-interfaces:lag"].(map[string]any) + if b1["mode"] != "static" { + t.Fatalf("bond1 mode = %v", b1["mode"]) + } + static := b1["static"].(map[string]any) + if static["mode"] != "balance-xor" || static["hash"] != "layer2" { + t.Fatalf("unexpected bond1 static: %#v", static) + } +} + +func TestTransformBridgePortLowerLayer(t *testing.T) { + link := []map[string]any{{ + "ifname": "eth2", + "ifindex": 30, + "flags": []any{"UP"}, + "link_type": "ether", + "operstate": "UP", + "master": "br0", + "linkinfo": map[string]any{ + "info_slave_kind": "bridge", + "info_slave_data": map[string]any{ + "bcast_flood": true, + "flood": false, + "mcast_flood": true, + "fastleave": true, + "multicast_router": 2, + }, + }, + }} + + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil)) + eth2 := mustIfaceByName(t, ifaces, "eth2") + lower := eth2["infix-interfaces:bridge-port"].(map[string]any) + if lower["bridge"] != "br0" { + t.Fatalf("bridge lower bridge = %v", lower["bridge"]) + } + mcast := lower["multicast"].(map[string]any) + if mcast["router"] != "permanent" { + t.Fatalf("bridge router mode = %v", mcast["router"]) + } +} + +func TestTransformLagPortLowerLayer(t *testing.T) { + link := []map[string]any{{ + "ifname": "eth3", + "ifindex": 31, + "flags": []any{"UP"}, + "link_type": "ether", + "operstate": "UP", + "master": "bond0", + "linkinfo": map[string]any{ + "info_slave_kind": "bond", + "info_slave_data": map[string]any{ + "state": "ACTIVE", + "link_failure_count": 5, + "ad_aggregator_id": 42, + "ad_actor_oper_port_state_str": "collecting_distributing", + "ad_partner_oper_port_state_str": "collecting_distributing", + }, + }, + }} + + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil)) + eth3 := mustIfaceByName(t, ifaces, "eth3") + lower := eth3["infix-interfaces:lag-port"].(map[string]any) + if lower["lag"] != "bond0" || lower["state"] != "active" || lower["link-failures"] != float64(5) { + t.Fatalf("unexpected lag-port lower-layer: %#v", lower) + } + lacp := lower["lacp"].(map[string]any) + if lacp["aggregator-id"] != float64(42) { + t.Fatalf("lag-port lacp aggregator-id = %v", lacp["aggregator-id"]) + } +} + +func TestTransformFilteredInterfaces(t *testing.T) { + link := []map[string]any{ + {"ifname": "dummy0", "group": "internal", "link_type": "none"}, + {"ifname": "can0", "link_type": "can"}, + {"ifname": "vcan0", "link_type": "vcan"}, + {"ifname": "eth9", "ifindex": 99, "flags": []any{"UP"}, "link_type": "ether", "operstate": "UP"}, + } + + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil)) + if len(ifaces) != 1 { + t.Fatalf("expected only one surviving interface, got %d", len(ifaces)) + } + if ifaces[0]["name"] != "eth9" { + t.Fatalf("surviving interface = %v", ifaces[0]["name"]) + } +} + +func TestTransformWiFiType(t *testing.T) { + link := []map[string]any{{ + "ifname": "wlan0", + "ifindex": 40, + "flags": []any{"UP"}, + "link_type": "ether", + "operstate": "UP", + }} + + fc := &mockFileChecker{exists: map[string]bool{"/sys/class/net/wlan0/wireless/": true}} + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, fc)) + wlan0 := mustIfaceByName(t, ifaces, "wlan0") + if wlan0["type"] != "infix-if-type:wifi" { + t.Fatalf("wlan0 type = %v", wlan0["type"]) + } +} + +func TestIplink2yangTypeMappings(t *testing.T) { + fc := &mockFileChecker{exists: map[string]bool{"/sys/class/net/wlan0/wireless/": true}} + + tests := []struct { + name string + iplink map[string]any + want string + }{ + {name: "loopback", iplink: map[string]any{"ifname": "lo", "link_type": "loopback"}, want: "infix-if-type:loopback"}, + {name: "gre", iplink: map[string]any{"ifname": "gre0", "link_type": "gre"}, want: "infix-if-type:gre"}, + {name: "gre6", iplink: map[string]any{"ifname": "gre6", "link_type": "gre6"}, want: "infix-if-type:gre"}, + {name: "wifi via ether", iplink: map[string]any{"ifname": "wlan0", "link_type": "ether"}, want: "infix-if-type:wifi"}, + {name: "bond", iplink: map[string]any{"ifname": "bond0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "bond"}}, want: "infix-if-type:lag"}, + {name: "bridge", iplink: map[string]any{"ifname": "br0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "bridge"}}, want: "infix-if-type:bridge"}, + {name: "dummy", iplink: map[string]any{"ifname": "dummy0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "dummy"}}, want: "infix-if-type:dummy"}, + {name: "gretap", iplink: map[string]any{"ifname": "gretap0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "gretap"}}, want: "infix-if-type:gretap"}, + {name: "vxlan", iplink: map[string]any{"ifname": "vxlan10", "link_type": "none", "linkinfo": map[string]any{"info_kind": "vxlan"}}, want: "infix-if-type:vxlan"}, + {name: "veth", iplink: map[string]any{"ifname": "veth0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "veth"}}, want: "infix-if-type:veth"}, + {name: "vlan", iplink: map[string]any{"ifname": "eth0.10", "link_type": "none", "linkinfo": map[string]any{"info_kind": "vlan"}}, want: "infix-if-type:vlan"}, + {name: "wireguard", iplink: map[string]any{"ifname": "wg0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "wireguard"}}, want: "infix-if-type:wireguard"}, + {name: "default ethernet", iplink: map[string]any{"ifname": "eth0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "unknown"}}, want: "infix-if-type:ethernet"}, + {name: "unknown link type", iplink: map[string]any{"ifname": "x", "link_type": "strange"}, want: "infix-if-type:other"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := iplink2yangType(tt.iplink, fc) + if got != tt.want { + t.Fatalf("iplink2yangType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestIplink2yangOperstateMappings(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"DOWN", "down"}, + {"UP", "up"}, + {"DORMANT", "dormant"}, + {"TESTING", "testing"}, + {"LOWERLAYERDOWN", "lower-layer-down"}, + {"NOTPRESENT", "not-present"}, + {"WHATEVER", "unknown"}, + } + + for _, tt := range tests { + if got := iplink2yangOperstate(tt.in); got != tt.want { + t.Fatalf("iplink2yangOperstate(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestSkipInterface(t *testing.T) { + tests := []struct { + name string + iplink map[string]any + want bool + }{ + {name: "internal group", iplink: map[string]any{"group": "internal"}, want: true}, + {name: "can", iplink: map[string]any{"link_type": "can"}, want: true}, + {name: "vcan", iplink: map[string]any{"link_type": "vcan"}, want: true}, + {name: "normal", iplink: map[string]any{"link_type": "ether"}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := skipInterface(tt.iplink); got != tt.want { + t.Fatalf("skipInterface() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestInet2yangOrigin(t *testing.T) { + tests := []struct { + name string + inet map[string]any + want string + }{ + {name: "kernel_ll", inet: map[string]any{"protocol": "kernel_ll"}, want: "link-layer"}, + {name: "kernel_ra", inet: map[string]any{"protocol": "kernel_ra"}, want: "link-layer"}, + {name: "stable privacy kernel_ll", inet: map[string]any{"protocol": "kernel_ll", "stable-privacy": true}, want: "random"}, + {name: "static", inet: map[string]any{"protocol": "static"}, want: "static"}, + {name: "dhcp", inet: map[string]any{"protocol": "dhcp"}, want: "dhcp"}, + {name: "random", inet: map[string]any{"protocol": "random"}, want: "random"}, + {name: "other", inet: map[string]any{"protocol": "kernel_lo"}, want: "other"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := inet2yangOrigin(tt.inet); got != tt.want { + t.Fatalf("inet2yangOrigin() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestProto2yang(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"802.1Q", "ieee802-dot1q-types:c-vlan"}, + {"802.1ad", "ieee802-dot1q-types:s-vlan"}, + {"something", "other"}, + } + + for _, tt := range tests { + if got := proto2yang(tt.in); got != tt.want { + t.Fatalf("proto2yang(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestLagMode(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"802.3ad", "lacp"}, + {"balance-xor", "static"}, + {"active-backup", "static"}, + } + + for _, tt := range tests { + if got := lagMode(tt.in); got != tt.want { + t.Fatalf("lagMode(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestLagHash(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"layer2", "layer2"}, + {"layer3+4", "layer3-4"}, + {"layer2+3", "layer2-3"}, + {"encap2+3", "encap2-3"}, + {"encap3+4", "encap3-4"}, + {"vlan+srcmac", "vlan-srcmac"}, + {"something-else", "layer2"}, + } + + for _, tt := range tests { + if got := lagHash(tt.in); got != tt.want { + t.Fatalf("lagHash(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestBridgeRouterMode(t *testing.T) { + tests := []struct { + in int + want string + }{ + {0, "off"}, + {1, "auto"}, + {2, "permanent"}, + {9, "UNKNOWN"}, + } + + for _, tt := range tests { + if got := bridgeRouterMode(tt.in); got != tt.want { + t.Fatalf("bridgeRouterMode(%d) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestStatistics(t *testing.T) { + t.Run("with stats64", func(t *testing.T) { + st := statistics(map[string]any{ + "stats64": map[string]any{ + "rx": map[string]any{"bytes": json.Number("123")}, + "tx": map[string]any{"bytes": uint64(456)}, + }, + }) + if st["in-octets"] != "123" || st["out-octets"] != "456" { + t.Fatalf("unexpected statistics map: %#v", st) + } + }) + + t.Run("without stats64", func(t *testing.T) { + st := statistics(map[string]any{}) + if len(st) != 0 { + t.Fatalf("expected empty stats, got %#v", st) + } + }) +} + +func TestToCounterString(t *testing.T) { + tests := []struct { + name string + in any + want string + }{ + {name: "int", in: int(7), want: "7"}, + {name: "int64", in: int64(8), want: "8"}, + {name: "uint64", in: uint64(9), want: "9"}, + {name: "float64", in: float64(10.9), want: "10"}, + {name: "json number", in: json.Number("11"), want: "11"}, + {name: "string", in: " 12 ", want: "12"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := toCounterString(tt.in); got != tt.want { + t.Fatalf("toCounterString(%v) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestAddressesFamilyFilter(t *testing.T) { + ipaddr := map[string]any{ + "addr_info": []any{ + map[string]any{"family": "inet", "local": "192.0.2.1", "prefixlen": 24, "protocol": "dhcp"}, + map[string]any{"family": "inet6", "local": "2001:db8::1", "prefixlen": 64, "protocol": "kernel_ra"}, + }, + } + + v4 := addresses(ipaddr, "inet") + if len(v4) != 1 || v4[0]["ip"] != "192.0.2.1" || v4[0]["prefix-length"] != 24 || v4[0]["origin"] != "dhcp" { + t.Fatalf("unexpected inet addresses: %#v", v4) + } + + v6 := addresses(ipaddr, "inet6") + if len(v6) != 1 || v6[0]["ip"] != "2001:db8::1" || v6[0]["prefix-length"] != 64 || v6[0]["origin"] != "link-layer" { + t.Fatalf("unexpected inet6 addresses: %#v", v6) + } +} + +func TestIPv4Data(t *testing.T) { + t.Run("with mtu and addresses", func(t *testing.T) { + in := map[string]any{ + "ifname": "eth0", + "mtu": 1500, + "addr_info": []any{ + map[string]any{"family": "inet", "local": "10.0.0.1", "prefixlen": 24, "protocol": "static"}, + }, + } + out := ipv4Data(in) + if out["mtu"] != 1500 { + t.Fatalf("unexpected mtu: %#v", out) + } + if _, ok := out["address"]; !ok { + t.Fatalf("missing address list: %#v", out) + } + }) + + t.Run("without mtu", func(t *testing.T) { + in := map[string]any{ + "ifname": "eth0", + "addr_info": []any{ + map[string]any{"family": "inet", "local": "10.0.0.2", "prefixlen": 24, "protocol": "static"}, + }, + } + out := ipv4Data(in) + if _, ok := out["mtu"]; ok { + t.Fatalf("did not expect mtu in %#v", out) + } + }) + + t.Run("loopback omits mtu", func(t *testing.T) { + in := map[string]any{"ifname": "lo", "mtu": 65536} + out := ipv4Data(in) + if _, ok := out["mtu"]; ok { + t.Fatalf("loopback must not include mtu: %#v", out) + } + }) +} + +func TestIPv6Data(t *testing.T) { + t.Run("with mtu and addresses", func(t *testing.T) { + in := map[string]any{ + "ifname": "eth0", + "addr_info": []any{ + map[string]any{"family": "inet6", "local": "2001:db8::1", "prefixlen": 64, "protocol": "static"}, + }, + } + fc := &mockFileChecker{files: map[string]string{"/proc/sys/net/ipv6/conf/eth0/mtu": "1280\n"}} + out := ipv6Data(in, fc) + if out["mtu"] != 1280 { + t.Fatalf("unexpected mtu: %#v", out) + } + if _, ok := out["address"]; !ok { + t.Fatalf("missing address list: %#v", out) + } + }) + + t.Run("without mtu from filechecker", func(t *testing.T) { + in := map[string]any{"ifname": "eth1"} + fc := &mockFileChecker{readErr: map[string]error{"/proc/sys/net/ipv6/conf/eth1/mtu": errors.New("no file")}} + out := ipv6Data(in, fc) + if _, ok := out["mtu"]; ok { + t.Fatalf("did not expect mtu in %#v", out) + } + }) + + t.Run("without addresses", func(t *testing.T) { + in := map[string]any{"ifname": "eth2"} + out := ipv6Data(in, nil) + if len(out) != 0 { + t.Fatalf("expected empty ipv6 map, got %#v", out) + } + }) +} + +func TestDedupByIfindex(t *testing.T) { + t.Run("keeps UP over DOWN for same ifindex", func(t *testing.T) { + link := []map[string]any{ + {"ifindex": 2, "ifname": "eth0", "flags": []any{}, "link_type": "ether", "operstate": "DOWN", "address": "02:00:00:00:00:01"}, + {"ifindex": 2, "ifname": "e1", "flags": []any{"UP"}, "link_type": "ether", "operstate": "UP", "address": "02:00:00:00:00:01"}, + } + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil)) + if len(ifaces) != 1 { + t.Fatalf("expected 1 interface after dedup, got %d", len(ifaces)) + } + if ifaces[0]["name"] != "e1" { + t.Fatalf("expected e1 to survive dedup, got %v", ifaces[0]["name"]) + } + }) + + t.Run("keeps first when both DOWN", func(t *testing.T) { + link := []map[string]any{ + {"ifindex": 3, "ifname": "a0", "flags": []any{}, "link_type": "ether", "operstate": "DOWN"}, + {"ifindex": 3, "ifname": "a1", "flags": []any{}, "link_type": "ether", "operstate": "DOWN"}, + } + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil)) + if len(ifaces) != 1 { + t.Fatalf("expected 1 interface after dedup, got %d", len(ifaces)) + } + if ifaces[0]["name"] != "a0" { + t.Fatalf("expected a0 to survive dedup, got %v", ifaces[0]["name"]) + } + }) + + t.Run("different ifindex not deduped", func(t *testing.T) { + link := []map[string]any{ + {"ifindex": 1, "ifname": "lo", "flags": []any{"LOOPBACK", "UP"}, "link_type": "loopback", "operstate": "UNKNOWN"}, + {"ifindex": 2, "ifname": "e1", "flags": []any{"UP"}, "link_type": "ether", "operstate": "UP"}, + } + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil)) + if len(ifaces) != 2 { + t.Fatalf("expected 2 interfaces, got %d", len(ifaces)) + } + }) + + t.Run("zero ifindex entries kept as-is", func(t *testing.T) { + link := []map[string]any{ + {"ifname": "x0", "flags": []any{"UP"}, "link_type": "ether", "operstate": "UP"}, + {"ifname": "x1", "flags": []any{"UP"}, "link_type": "ether", "operstate": "UP"}, + } + ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil)) + if len(ifaces) != 2 { + t.Fatalf("expected 2 interfaces (zero ifindex not deduped), got %d", len(ifaces)) + } + }) +} diff --git a/src/yangerd/internal/ipbatch/ipbatch.go b/src/yangerd/internal/ipbatch/ipbatch.go new file mode 100644 index 00000000..2e862c29 --- /dev/null +++ b/src/yangerd/internal/ipbatch/ipbatch.go @@ -0,0 +1,213 @@ +// Package ipbatch manages a persistent `ip -json -s -d -force -batch -` +// subprocess. Commands sent via Query are serialized by a mutex and +// paired with the single JSON-array line the subprocess writes to +// stdout. The -s and -d global flags ensure link queries include +// statistics and details. On subprocess death the manager enters a +// dead state and attempts automatic restart with exponential backoff. +package ipbatch + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math" + "os/exec" + "sync" + "sync/atomic" + "time" +) + +// ErrBatchDead is returned by Query when the subprocess is not running. +// Callers should treat it as transient and retry on the next event. +var ErrBatchDead = errors.New("ip batch process is dead") + +const ( + canaryCommand = "link show lo" + + reconnectInitial = 100 * time.Millisecond + reconnectMax = 30 * time.Second + reconnectFactor = 2.0 +) + +// IPBatch wraps a persistent `ip -json -force -batch -` subprocess. +type IPBatch struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout *bufio.Scanner + stderr io.ReadCloser + mu sync.Mutex // serializes queries + alive atomic.Bool + log *slog.Logger + ctx context.Context + cancel context.CancelFunc +} + +// New spawns the ip batch subprocess. The returned IPBatch is ready +// for Query calls. A background goroutine drains stderr. +func New(ctx context.Context, log *slog.Logger) (*IPBatch, error) { + ctx, cancel := context.WithCancel(ctx) + b := &IPBatch{ + log: log, + ctx: ctx, + cancel: cancel, + } + if err := b.start(); err != nil { + cancel() + return nil, err + } + go b.restartLoop() + return b, nil +} + +func (b *IPBatch) start() error { + cmd := exec.CommandContext(b.ctx, "ip", "-json", "-s", "-d", "-force", "-batch", "-") + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("start ip batch: %w", err) + } + b.mu.Lock() + b.cmd = cmd + b.stdin = stdin + b.stdout = bufio.NewScanner(stdout) + b.stdout.Buffer(make([]byte, 0, 4*1024*1024), 4*1024*1024) // 4 MiB max line + b.stderr = stderr + b.alive.Store(true) + b.mu.Unlock() + go b.drainStderr() + return nil +} + +// Query sends a command to the ip batch process and returns the JSON +// response. Commands are newline-terminated (e.g. "link show dev eth0"). +// Each command produces exactly one line of JSON array output. +func (b *IPBatch) Query(command string) (json.RawMessage, error) { + if !b.alive.Load() { + return nil, ErrBatchDead + } + b.mu.Lock() + defer b.mu.Unlock() + + if !b.alive.Load() { + return nil, ErrBatchDead + } + + if _, err := fmt.Fprintf(b.stdin, "%s\n", command); err != nil { + b.alive.Store(false) + return nil, fmt.Errorf("write command: %w", err) + } + if !b.stdout.Scan() { + b.alive.Store(false) + if err := b.stdout.Err(); err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + return nil, fmt.Errorf("ip batch process exited") + } + raw := make([]byte, len(b.stdout.Bytes())) + copy(raw, b.stdout.Bytes()) + return json.RawMessage(raw), nil +} + +// Close terminates the subprocess and cancels the restart loop. +func (b *IPBatch) Close() { + b.cancel() + b.mu.Lock() + if b.stdin != nil { + b.stdin.Close() + } + if b.cmd != nil && b.cmd.Process != nil { + b.cmd.Process.Kill() + } + b.alive.Store(false) + b.mu.Unlock() +} + +// Status returns "running", "restarting", or "failed". +func (b *IPBatch) Status() string { + if b.alive.Load() { + return "running" + } + return "restarting" +} + +func (b *IPBatch) drainStderr() { + scanner := bufio.NewScanner(b.stderr) + for scanner.Scan() { + b.log.Warn("ip batch stderr", "line", scanner.Text()) + } +} + +// restartLoop runs in the background and respawns the subprocess when +// it dies. Uses exponential backoff: 100ms initial, 30s max, 2x factor. +// After a successful restart, a canary query validates the new process. +func (b *IPBatch) restartLoop() { + delay := reconnectInitial + for { + select { + case <-b.ctx.Done(): + return + default: + } + + if b.alive.Load() { + // Wait for death or context cancellation. + // Poll periodically since there's no notification channel. + select { + case <-b.ctx.Done(): + return + case <-time.After(200 * time.Millisecond): + continue + } + } + + b.log.Info("ip batch: subprocess died, restarting", "delay", delay) + select { + case <-b.ctx.Done(): + return + case <-time.After(delay): + } + + // Kill old process if lingering. + b.mu.Lock() + if b.cmd != nil && b.cmd.Process != nil { + b.cmd.Process.Kill() + b.cmd.Wait() + } + b.mu.Unlock() + + if err := b.start(); err != nil { + b.log.Warn("ip batch: restart failed", "err", err) + delay = time.Duration(math.Min( + float64(delay)*reconnectFactor, + float64(reconnectMax))) + continue + } + + // Canary query to validate the new process. + if _, err := b.Query(canaryCommand); err != nil { + b.log.Warn("ip batch: canary query failed", "err", err) + b.alive.Store(false) + delay = time.Duration(math.Min( + float64(delay)*reconnectFactor, + float64(reconnectMax))) + continue + } + + b.log.Info("ip batch: restarted successfully") + delay = reconnectInitial + } +} diff --git a/src/yangerd/internal/ipc/client.go b/src/yangerd/internal/ipc/client.go new file mode 100644 index 00000000..0a9de57d --- /dev/null +++ b/src/yangerd/internal/ipc/client.go @@ -0,0 +1,56 @@ +package ipc + +import ( + "encoding/json" + "fmt" + "net" + "time" +) + +// Client connects to a yangerd Unix socket and issues IPC requests. +type Client struct { + addr string + timeout time.Duration +} + +// NewClient returns a Client that connects to the given socket path +// with per-request timeout. +func NewClient(socketPath string, timeout time.Duration) *Client { + return &Client{ + addr: socketPath, + timeout: timeout, + } +} + +// Get queries a YANG subtree by path. Path "/" returns all models. +func (c *Client) Get(path string) (*Response, error) { + return c.call(&Request{Method: "get", Path: path}) +} + +// Health returns per-model freshness metadata. +func (c *Client) Health() (*Response, error) { + return c.call(&Request{Method: "health"}) +} + +func (c *Client) call(req *Request) (*Response, error) { + conn, err := net.DialTimeout("unix", c.addr, c.timeout) + if err != nil { + return nil, fmt.Errorf("connect %s: %w", c.addr, err) + } + defer conn.Close() + + conn.SetDeadline(time.Now().Add(c.timeout)) + + payload, err := json.Marshal(req) + if err != nil { + return nil, err + } + if err := WriteFrame(conn, payload); err != nil { + return nil, fmt.Errorf("write request: %w", err) + } + resp, err := ReadResponse(conn) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + return resp, nil +} diff --git a/src/yangerd/internal/ipc/protocol.go b/src/yangerd/internal/ipc/protocol.go new file mode 100644 index 00000000..b6da032a --- /dev/null +++ b/src/yangerd/internal/ipc/protocol.go @@ -0,0 +1,116 @@ +// Package ipc implements the yangerd IPC protocol: a versioned, +// length-prefixed JSON framing over AF_UNIX SOCK_STREAM. +// +// Wire format: +// +// +--------+--------+--------+--------+--------+--- ... ---+ +// | ver(1) | length (uint32 big-endian, bytes) | JSON body | +// +--------+--------+--------+--------+--------+--- ... ---+ +package ipc + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "io" +) + +const ( + // Version is the current protocol version. + Version byte = 1 + + // MaxPayload is the maximum JSON body size (4 MiB). + MaxPayload = 4 << 20 + + headerSize = 5 // 1 byte version + 4 bytes length +) + +// Request is the IPC request from a client. +type Request struct { + Method string `json:"method"` + Path string `json:"path,omitempty"` + Filter map[string]string `json:"filter,omitempty"` +} + +// Response is the IPC response to a client. +type Response struct { + Status string `json:"status"` + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` + + // Used by "get" responses. + Data json.RawMessage `json:"data,omitempty"` + + // Used by "health" responses. + Subsystems map[string]json.RawMessage `json:"subsystems,omitempty"` + Models map[string]json.RawMessage `json:"models,omitempty"` +} + +// WriteFrame writes a versioned, length-prefixed frame to w. +func WriteFrame(w io.Writer, payload []byte) error { + if len(payload) > MaxPayload { + return fmt.Errorf("payload size %d exceeds maximum %d", len(payload), MaxPayload) + } + hdr := [headerSize]byte{Version} + binary.BigEndian.PutUint32(hdr[1:], uint32(len(payload))) + if _, err := w.Write(hdr[:]); err != nil { + return err + } + _, err := w.Write(payload) + return err +} + +// ReadFrame reads a versioned, length-prefixed frame from r. +func ReadFrame(r io.Reader) ([]byte, error) { + var hdr [headerSize]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return nil, err + } + if hdr[0] != Version { + return nil, fmt.Errorf("protocol version mismatch: got %d, want %d", hdr[0], Version) + } + length := binary.BigEndian.Uint32(hdr[1:]) + if length > MaxPayload { + return nil, fmt.Errorf("payload size %d exceeds maximum %d", length, MaxPayload) + } + buf := make([]byte, length) + if _, err := io.ReadFull(r, buf); err != nil { + return nil, err + } + return buf, nil +} + +// WriteResponse marshals a Response and writes it as a framed message. +func WriteResponse(w io.Writer, resp *Response) error { + data, err := json.Marshal(resp) + if err != nil { + return err + } + return WriteFrame(w, data) +} + +// ReadRequest reads and unmarshals a framed Request. +func ReadRequest(r io.Reader) (*Request, error) { + data, err := ReadFrame(r) + if err != nil { + return nil, err + } + var req Request + if err := json.Unmarshal(data, &req); err != nil { + return nil, fmt.Errorf("invalid request JSON: %w", err) + } + return &req, nil +} + +// ReadResponse reads and unmarshals a framed Response. +func ReadResponse(r io.Reader) (*Response, error) { + data, err := ReadFrame(r) + if err != nil { + return nil, err + } + var resp Response + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("invalid response JSON: %w", err) + } + return &resp, nil +} diff --git a/src/yangerd/internal/ipc/protocol_test.go b/src/yangerd/internal/ipc/protocol_test.go new file mode 100644 index 00000000..5ce47b7c --- /dev/null +++ b/src/yangerd/internal/ipc/protocol_test.go @@ -0,0 +1,89 @@ +package ipc + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestFrameRoundTrip(t *testing.T) { + payload := []byte(`{"method":"get","path":"/test"}`) + var buf bytes.Buffer + + if err := WriteFrame(&buf, payload); err != nil { + t.Fatal(err) + } + + got, err := ReadFrame(&buf) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("mismatch: %s vs %s", got, payload) + } +} + +func TestFrameVersionMismatch(t *testing.T) { + var buf bytes.Buffer + buf.Write([]byte{99, 0, 0, 0, 2, '{', '}'}) + + _, err := ReadFrame(&buf) + if err == nil { + t.Fatal("expected version mismatch error") + } +} + +func TestFrameOversized(t *testing.T) { + var buf bytes.Buffer + huge := make([]byte, MaxPayload+1) + if err := WriteFrame(&buf, huge); err == nil { + t.Fatal("expected oversized payload error") + } +} + +func TestRequestResponseRoundTrip(t *testing.T) { + var buf bytes.Buffer + + req := &Request{Method: "get", Path: "/ietf-system:system-state"} + data, _ := json.Marshal(req) + WriteFrame(&buf, data) + + got, err := ReadRequest(&buf) + if err != nil { + t.Fatal(err) + } + if got.Method != "get" || got.Path != "/ietf-system:system-state" { + t.Fatalf("unexpected request: %+v", got) + } +} + +func TestResponseRoundTrip(t *testing.T) { + var buf bytes.Buffer + + resp := &Response{ + Status: "ok", + Data: json.RawMessage(`{"hostname":"r1"}`), + } + WriteResponse(&buf, resp) + + got, err := ReadResponse(&buf) + if err != nil { + t.Fatal(err) + } + if got.Status != "ok" || string(got.Data) != `{"hostname":"r1"}` { + t.Fatalf("unexpected response: %+v", got) + } +} + +func TestEmptyFrame(t *testing.T) { + var buf bytes.Buffer + WriteFrame(&buf, []byte{}) + + got, err := ReadFrame(&buf) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("expected empty, got %d bytes", len(got)) + } +} diff --git a/src/yangerd/internal/ipc/server.go b/src/yangerd/internal/ipc/server.go new file mode 100644 index 00000000..a44fd1db --- /dev/null +++ b/src/yangerd/internal/ipc/server.go @@ -0,0 +1,210 @@ +package ipc + +import ( + "context" + "encoding/json" + "log" + "net" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +// Server listens on an AF_UNIX SOCK_STREAM socket and serves +// YANG operational data from an in-memory Tree. +type Server struct { + tree *tree.Tree + listener net.Listener + ready *atomic.Bool + wg sync.WaitGroup +} + +// NewServer creates a Server that serves data from the given Tree. +// While ready is false, all requests receive a 503 "starting" response. +func NewServer(t *tree.Tree, ready *atomic.Bool) *Server { + return &Server{ + tree: t, + ready: ready, + } +} + +// Listen creates and binds a Unix domain socket at path. +// A stale socket file is removed before binding. +func (s *Server) Listen(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + ln, err := net.Listen("unix", path) + if err != nil { + return err + } + if err := os.Chmod(path, 0660); err != nil { + ln.Close() + return err + } + s.listener = ln + return nil +} + +// Serve accepts connections until ctx is cancelled. Each connection +// is handled in its own goroutine. +func (s *Server) Serve(ctx context.Context) error { + go func() { + <-ctx.Done() + s.listener.Close() + }() + + for { + conn, err := s.listener.Accept() + if err != nil { + // Listener closed by context cancellation — normal shutdown. + select { + case <-ctx.Done(): + s.wg.Wait() + return nil + default: + return err + } + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.handleConn(conn) + }() + } +} + +// Addr returns the listener address, or empty string if not listening. +func (s *Server) Addr() string { + if s.listener == nil { + return "" + } + return s.listener.Addr().String() +} + +func (s *Server) handleConn(conn net.Conn) { + defer conn.Close() + + req, err := ReadRequest(conn) + if err != nil { + log.Printf("ipc: read request: %v", err) + return + } + + if !s.ready.Load() { + WriteResponse(conn, &Response{ + Status: "starting", + Code: 503, + Message: "yangerd is starting up", + }) + return + } + + switch req.Method { + case "get": + s.handleGet(conn, req) + case "health": + s.handleHealth(conn) + default: + WriteResponse(conn, &Response{ + Status: "error", + Code: 400, + Message: "unknown method: " + req.Method, + }) + } +} + +func (s *Server) handleGet(conn net.Conn, req *Request) { + path := req.Path + if path == "" || path == "/" { + s.handleDump(conn) + return + } + + key := path + if key[0] == '/' { + key = key[1:] + } + + data := s.tree.Get(key) + if data == nil { + WriteResponse(conn, &Response{ + Status: "error", + Code: 404, + Message: "path not found: " + path, + }) + return + } + + envelope := map[string]json.RawMessage{key: data} + body, err := json.Marshal(envelope) + if err != nil { + WriteResponse(conn, &Response{ + Status: "error", + Code: 500, + Message: "marshal error: " + err.Error(), + }) + return + } + + WriteResponse(conn, &Response{ + Status: "ok", + Data: body, + }) +} + +func (s *Server) handleDump(conn net.Conn) { + keys := s.tree.Keys() + blobs := s.tree.GetMulti(keys) + + all := make(map[string]json.RawMessage, len(keys)) + for i, k := range keys { + if i < len(blobs) { + all[k] = blobs[i] + } + } + + body, err := json.Marshal(all) + if err != nil { + WriteResponse(conn, &Response{ + Status: "error", + Code: 500, + Message: "marshal error: " + err.Error(), + }) + return + } + + WriteResponse(conn, &Response{ + Status: "ok", + Data: body, + }) +} + +func (s *Server) handleHealth(conn net.Conn) { + keys := s.tree.Keys() + models := make(map[string]json.RawMessage, len(keys)) + + for _, k := range keys { + info, ok := s.tree.Info(k) + if !ok { + continue + } + entry := struct { + LastUpdated string `json:"last_updated"` + SizeBytes int `json:"size_bytes"` + }{ + LastUpdated: info.LastUpdated.UTC().Format(time.RFC3339), + SizeBytes: info.SizeBytes, + } + b, _ := json.Marshal(entry) + models[k] = b + } + + WriteResponse(conn, &Response{ + Status: "ok", + Models: models, + }) +} diff --git a/src/yangerd/internal/ipc/server_test.go b/src/yangerd/internal/ipc/server_test.go new file mode 100644 index 00000000..73b421bf --- /dev/null +++ b/src/yangerd/internal/ipc/server_test.go @@ -0,0 +1,135 @@ +package ipc + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +func TestServerGetSingle(t *testing.T) { + tr := tree.New() + tr.Set("ietf-system:system-state", json.RawMessage(`{"platform":{"os-name":"Infix"}}`)) + + resp := serverRoundTrip(t, tr, true, &Request{Method: "get", Path: "/ietf-system:system-state"}) + + if resp.Status != "ok" { + t.Fatalf("expected ok, got %s: %s", resp.Status, resp.Message) + } + var data map[string]json.RawMessage + json.Unmarshal(resp.Data, &data) + if _, ok := data["ietf-system:system-state"]; !ok { + t.Fatalf("missing key in response data: %s", resp.Data) + } +} + +func TestServerGetNotFound(t *testing.T) { + tr := tree.New() + resp := serverRoundTrip(t, tr, true, &Request{Method: "get", Path: "/nonexistent"}) + + if resp.Status != "error" || resp.Code != 404 { + t.Fatalf("expected 404 error, got %+v", resp) + } +} + +func TestServerDump(t *testing.T) { + tr := tree.New() + tr.Set("a", json.RawMessage(`1`)) + tr.Set("b", json.RawMessage(`2`)) + + resp := serverRoundTrip(t, tr, true, &Request{Method: "get", Path: "/"}) + + if resp.Status != "ok" { + t.Fatalf("expected ok, got %s: %s", resp.Status, resp.Message) + } + var data map[string]json.RawMessage + json.Unmarshal(resp.Data, &data) + if len(data) != 2 { + t.Fatalf("expected 2 models in dump, got %d", len(data)) + } +} + +func TestServerHealth(t *testing.T) { + tr := tree.New() + tr.Set("model-a", json.RawMessage(`{}`)) + + resp := serverRoundTrip(t, tr, true, &Request{Method: "health"}) + + if resp.Status != "ok" { + t.Fatalf("expected ok, got %s", resp.Status) + } + if _, ok := resp.Models["model-a"]; !ok { + t.Fatalf("expected model-a in health models, got %v", resp.Models) + } +} + +func TestServerNotReady(t *testing.T) { + tr := tree.New() + resp := serverRoundTrip(t, tr, false, &Request{Method: "get", Path: "/"}) + + if resp.Status != "starting" || resp.Code != 503 { + t.Fatalf("expected 503 starting, got %+v", resp) + } +} + +func TestServerUnknownMethod(t *testing.T) { + tr := tree.New() + resp := serverRoundTrip(t, tr, true, &Request{Method: "invalid"}) + + if resp.Status != "error" || resp.Code != 400 { + t.Fatalf("expected 400 error, got %+v", resp) + } +} + +func serverRoundTrip(t *testing.T, tr *tree.Tree, ready bool, req *Request) *Response { + t.Helper() + + sockPath := filepath.Join(t.TempDir(), "test.sock") + readyFlag := &atomic.Bool{} + readyFlag.Store(ready) + + srv := NewServer(tr, readyFlag) + if err := srv.Listen(sockPath); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- srv.Serve(ctx) + }() + + time.Sleep(10 * time.Millisecond) + + conn, err := net.Dial("unix", sockPath) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + payload, _ := json.Marshal(req) + if err := WriteFrame(conn, payload); err != nil { + t.Fatal(err) + } + + resp, err := ReadResponse(conn) + if err != nil { + t.Fatal(err) + } + + cancel() + + if _, err := os.Stat(sockPath); err == nil { + os.Remove(sockPath) + } + + return resp +} diff --git a/src/yangerd/internal/iwmonitor/iwmonitor.go b/src/yangerd/internal/iwmonitor/iwmonitor.go new file mode 100644 index 00000000..e92c41a0 --- /dev/null +++ b/src/yangerd/internal/iwmonitor/iwmonitor.go @@ -0,0 +1,331 @@ +// Package iwmonitor manages a persistent `iw event -t` subprocess +// for reactive 802.11 wireless monitoring. Events are parsed from +// the human-readable text output and dispatched to re-query handlers. +// Started only when YANGERD_ENABLE_WIFI=true. +package iwmonitor + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log/slog" + "math" + "os/exec" + "strconv" + "strings" + "time" +) + +const ( + reconnectInitial = 100 * time.Millisecond + reconnectMax = 30 * time.Second + reconnectFactor = 2.0 + queryTimeout = 5 * time.Second +) + +// IWEvent represents a single parsed line from `iw event -t`. +type IWEvent struct { + Timestamp float64 + Interface string + Phy string + Type string + Addr string +} + +// IWMonitor subscribes to WiFi events via `iw event -t` and updates +// the tree when stations associate/disassociate, links connect/ +// disconnect, or regulatory domains change. +type IWMonitor struct { + log *slog.Logger + onUpdate func(ifname string, data json.RawMessage) +} + +// New creates an IWMonitor. +func New(log *slog.Logger) *IWMonitor { + return &IWMonitor{log: log} +} + +// SetOnUpdate sets the callback invoked when wifi data changes for +// an interface. The data is a JSON object with station/info fields. +func (m *IWMonitor) SetOnUpdate(fn func(string, json.RawMessage)) { + m.onUpdate = fn +} + +// Run starts the iw event monitor. It blocks until ctx is cancelled. +// On subprocess exit it reconnects with exponential backoff. +func (m *IWMonitor) Run(ctx context.Context) error { + delay := reconnectInitial + + for { + err := m.runOnce(ctx) + if ctx.Err() != nil { + return ctx.Err() + } + + m.log.Warn("iw event: subprocess exited, restarting", + "err", err, "delay", delay) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + delay = time.Duration(math.Min( + float64(delay)*reconnectFactor, + float64(reconnectMax))) + } +} + +func (m *IWMonitor) runOnce(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "iw", "event", "-t") + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("start iw event: %w", err) + } + defer cmd.Wait() + + m.refreshAllInterfaces(ctx) + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + ev, ok := ParseIWEvent(scanner.Text()) + if !ok { + continue + } + m.handleEvent(ctx, ev) + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("read iw event: %w", err) + } + return fmt.Errorf("iw event process exited") +} + +func (m *IWMonitor) handleEvent(ctx context.Context, ev IWEvent) { + switch ev.Type { + case "new station", "del station": + m.refreshStationList(ctx, ev.Interface) + case "connected", "ch_switch_started_notify": + m.refreshInterfaceInfo(ctx, ev.Interface) + case "disconnected": + m.publishWifi(ev.Interface, json.RawMessage(`{}`), nil) + case "reg_change": + m.refreshAllInterfaces(ctx) + default: + m.log.Debug("iw event: unhandled", "type", ev.Type, "iface", ev.Interface) + } +} + +func (m *IWMonitor) refreshStationList(ctx context.Context, iface string) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "iw", "dev", iface, "station", "dump").Output() + if err != nil { + m.log.Warn("iw station dump failed", "iface", iface, "err", err) + return + } + stations := parseStationDump(string(out)) + m.publishWifi(iface, nil, stations) +} + +func (m *IWMonitor) refreshInterfaceInfo(ctx context.Context, iface string) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "iw", "dev", iface, "info").Output() + if err != nil { + m.log.Warn("iw dev info failed", "iface", iface, "err", err) + return + } + info := parseIWInfo(string(out)) + m.publishWifi(iface, info, nil) +} + +func (m *IWMonitor) publishWifi(iface string, info, stations json.RawMessage) { + if m.onUpdate == nil { + return + } + + wifi := make(map[string]json.RawMessage) + if info != nil { + wifi["info"] = info + } + if stations != nil { + wifi["stations"] = stations + } + + raw, err := json.Marshal(wifi) + if err != nil { + m.log.Warn("iwmonitor: marshal wifi data", "iface", iface, "err", err) + return + } + m.onUpdate(iface, json.RawMessage(raw)) +} + +func (m *IWMonitor) refreshAllInterfaces(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "iw", "dev").Output() + if err != nil { + m.log.Warn("iw dev list failed", "err", err) + return + } + for _, iface := range parseIWDevList(string(out)) { + m.refreshInterfaceInfo(ctx, iface) + m.refreshStationList(ctx, iface) + } +} + +// ParseIWEvent parses a single line from `iw event -t` output. +func ParseIWEvent(line string) (IWEvent, bool) { + parts := strings.SplitN(line, ": ", 3) + if len(parts) < 3 { + return IWEvent{}, false + } + + ts, err := strconv.ParseFloat(parts[0], 64) + if err != nil { + return IWEvent{}, false + } + + ifacePhy := parts[1] + parenIdx := strings.Index(ifacePhy, " (") + if parenIdx < 0 { + return IWEvent{}, false + } + iface := ifacePhy[:parenIdx] + phy := strings.Trim(ifacePhy[parenIdx+2:], ")") + + eventStr := parts[2] + ev := IWEvent{Timestamp: ts, Interface: iface, Phy: phy} + + switch { + case strings.HasPrefix(eventStr, "new station "): + ev.Type = "new station" + ev.Addr = strings.TrimPrefix(eventStr, "new station ") + case strings.HasPrefix(eventStr, "del station "): + ev.Type = "del station" + ev.Addr = strings.TrimPrefix(eventStr, "del station ") + case strings.HasPrefix(eventStr, "connected to "): + ev.Type = "connected" + ev.Addr = strings.TrimPrefix(eventStr, "connected to ") + case eventStr == "disconnected": + ev.Type = "disconnected" + case strings.HasPrefix(eventStr, "ch_switch_started_notify"): + ev.Type = "ch_switch_started_notify" + case eventStr == "scan started": + ev.Type = "scan started" + case eventStr == "scan aborted": + ev.Type = "scan aborted" + case strings.HasPrefix(eventStr, "reg_change"): + ev.Type = "reg_change" + case strings.HasPrefix(eventStr, "auth"): + ev.Type = "auth" + default: + ev.Type = eventStr + } + + return ev, true +} + +// parseStationDump parses `iw dev station dump` output into JSON. +func parseStationDump(output string) json.RawMessage { + type station struct { + MAC string `json:"mac"` + Signal string `json:"signal,omitempty"` + RxBytes string `json:"rx-bytes,omitempty"` + TxBytes string `json:"tx-bytes,omitempty"` + Connected string `json:"connected-time,omitempty"` + Inactive string `json:"inactive-time,omitempty"` + RxBitrate string `json:"rx-bitrate,omitempty"` + TxBitrate string `json:"tx-bitrate,omitempty"` + Authorized string `json:"authorized,omitempty"` + } + var stations []station + var current *station + + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Station ") { + parts := strings.Fields(line) + if len(parts) >= 2 { + s := station{MAC: parts[1]} + stations = append(stations, s) + current = &stations[len(stations)-1] + } + continue + } + if current == nil { + continue + } + if k, v, ok := parseKV(line); ok { + switch k { + case "signal": + current.Signal = v + case "rx bytes": + current.RxBytes = v + case "tx bytes": + current.TxBytes = v + case "connected time": + current.Connected = v + case "inactive time": + current.Inactive = v + case "rx bitrate": + current.RxBitrate = v + case "tx bitrate": + current.TxBitrate = v + case "authorized": + current.Authorized = v + } + } + } + + data, _ := json.Marshal(stations) + return json.RawMessage(data) +} + +// parseIWInfo parses `iw dev info` output into JSON. +func parseIWInfo(output string) json.RawMessage { + info := make(map[string]string) + for _, line := range strings.Split(output, "\n") { + if k, v, ok := parseKV(strings.TrimSpace(line)); ok { + switch k { + case "ssid": + info["ssid"] = v + case "type": + info["type"] = v + case "channel": + info["channel"] = v + case "txpower": + info["tx-power"] = v + } + } + } + data, _ := json.Marshal(info) + return json.RawMessage(data) +} + +// parseIWDevList extracts interface names from `iw dev` output. +func parseIWDevList(output string) []string { + var ifaces []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Interface ") { + if name := strings.TrimPrefix(line, "Interface "); name != "" { + ifaces = append(ifaces, name) + } + } + } + return ifaces +} + +func parseKV(line string) (string, string, bool) { + idx := strings.Index(line, ":") + if idx < 0 { + return "", "", false + } + k := strings.TrimSpace(line[:idx]) + v := strings.TrimSpace(line[idx+1:]) + return k, v, k != "" +} diff --git a/src/yangerd/internal/iwmonitor/iwmonitor_test.go b/src/yangerd/internal/iwmonitor/iwmonitor_test.go new file mode 100644 index 00000000..4044fa1f --- /dev/null +++ b/src/yangerd/internal/iwmonitor/iwmonitor_test.go @@ -0,0 +1,221 @@ +package iwmonitor + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestParseIWEvent(t *testing.T) { + tests := []struct { + name string + line string + wantOK bool + wantType string + wantIface string + wantPhy string + wantAddr string + }{ + { + name: "new station", + line: "1234567890.123456: wlan0 (phy#0): new station aa:bb:cc:dd:ee:ff", + wantOK: true, + wantType: "new station", + wantIface: "wlan0", + wantPhy: "phy#0", + wantAddr: "aa:bb:cc:dd:ee:ff", + }, + { + name: "del station", + line: "1234567890.123456: wlan0 (phy#0): del station aa:bb:cc:dd:ee:ff", + wantOK: true, + wantType: "del station", + wantIface: "wlan0", + wantPhy: "phy#0", + wantAddr: "aa:bb:cc:dd:ee:ff", + }, + { + name: "connected", + line: "1234567890.123456: wlan0 (phy#0): connected to aa:bb:cc:dd:ee:ff", + wantOK: true, + wantType: "connected", + wantIface: "wlan0", + wantPhy: "phy#0", + wantAddr: "aa:bb:cc:dd:ee:ff", + }, + { + name: "disconnected", + line: "1234567890.123456: wlan0 (phy#0): disconnected", + wantOK: true, + wantType: "disconnected", + wantIface: "wlan0", + wantPhy: "phy#0", + }, + { + name: "channel switch", + line: "1234567890.123456: wlan0 (phy#0): ch_switch_started_notify", + wantOK: true, + wantType: "ch_switch_started_notify", + wantIface: "wlan0", + wantPhy: "phy#0", + }, + { + name: "scan started", + line: "1234567890.123456: wlan0 (phy#0): scan started", + wantOK: true, + wantType: "scan started", + wantIface: "wlan0", + wantPhy: "phy#0", + }, + { + name: "reg change", + line: "1234567890.123456: wlan0 (phy#0): reg_change", + wantOK: true, + wantType: "reg_change", + wantIface: "wlan0", + wantPhy: "phy#0", + }, + { + name: "malformed missing separators", + line: "1234567890.123456 wlan0 (phy#0) new station aa:bb:cc:dd:ee:ff", + wantOK: false, + }, + { + name: "malformed bad timestamp", + line: "not-a-float: wlan0 (phy#0): disconnected", + wantOK: false, + }, + { + name: "malformed missing phy", + line: "1234567890.123456: wlan0 phy#0: disconnected", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ParseIWEvent(tt.line) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if !tt.wantOK { + return + } + + if got.Type != tt.wantType { + t.Fatalf("Type = %q, want %q", got.Type, tt.wantType) + } + if got.Interface != tt.wantIface { + t.Fatalf("Interface = %q, want %q", got.Interface, tt.wantIface) + } + if got.Phy != tt.wantPhy { + t.Fatalf("Phy = %q, want %q", got.Phy, tt.wantPhy) + } + if got.Addr != tt.wantAddr { + t.Fatalf("Addr = %q, want %q", got.Addr, tt.wantAddr) + } + }) + } +} + +func TestParseStationDump(t *testing.T) { + input := `Station aa:bb:cc:dd:ee:ff (on wlan0) + inactive time: 10 ms + rx bytes: 1234 + tx bytes: 5678 + connected time: 42 seconds + signal: -40 dBm + rx bitrate: 6.5 MBit/s + tx bitrate: 130.0 MBit/s + authorized: yes + +Station 11:22:33:44:55:66 (on wlan0) + inactive time: 20 ms + rx bytes: 9876 + tx bytes: 5432 + connected time: 84 seconds + signal: -55 dBm + authorized: no` + + got := parseStationDump(input) + + var gotDecoded []map[string]string + if err := json.Unmarshal(got, &gotDecoded); err != nil { + t.Fatalf("unmarshal got: %v", err) + } + + want := []map[string]string{ + { + "mac": "aa:bb:cc:dd:ee:ff", + "inactive-time": "10 ms", + "rx-bytes": "1234", + "tx-bytes": "5678", + "connected-time": "42 seconds", + "signal": "-40 dBm", + "rx-bitrate": "6.5 MBit/s", + "tx-bitrate": "130.0 MBit/s", + "authorized": "yes", + }, + { + "mac": "11:22:33:44:55:66", + "inactive-time": "20 ms", + "rx-bytes": "9876", + "tx-bytes": "5432", + "connected-time": "84 seconds", + "signal": "-55 dBm", + "authorized": "no", + }, + } + + if !reflect.DeepEqual(gotDecoded, want) { + t.Fatalf("parseStationDump mismatch\n got: %#v\nwant: %#v", gotDecoded, want) + } +} + +func TestParseIWInfo(t *testing.T) { + input := `Interface wlan0 + ifindex: 4 + wdev: 0x1 + addr: 12:34:56:78:9a:bc + ssid: MyWiFi + type: managed + channel: 11 (2462 MHz), width: 20 MHz, center1: 2462 MHz + txpower: 20.00 dBm` + + got := parseIWInfo(input) + + var gotDecoded map[string]string + if err := json.Unmarshal(got, &gotDecoded); err != nil { + t.Fatalf("unmarshal got: %v", err) + } + + want := map[string]string{ + "ssid": "MyWiFi", + "type": "managed", + "channel": "11 (2462 MHz), width: 20 MHz, center1: 2462 MHz", + "tx-power": "20.00 dBm", + } + + if !reflect.DeepEqual(gotDecoded, want) { + t.Fatalf("parseIWInfo mismatch\n got: %#v\nwant: %#v", gotDecoded, want) + } +} + +func TestParseIWDevList(t *testing.T) { + input := `phy#0 + Interface wlan0 + ifindex 4 + wdev 0x1 + +phy#1 + Interface wlan1 + ifindex 5 + wdev 0x2` + + got := parseIWDevList(input) + want := []string{"wlan0", "wlan1"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseIWDevList mismatch\n got: %#v\nwant: %#v", got, want) + } +} diff --git a/src/yangerd/internal/lldpmonitor/lldpmonitor.go b/src/yangerd/internal/lldpmonitor/lldpmonitor.go new file mode 100644 index 00000000..b0b77211 --- /dev/null +++ b/src/yangerd/internal/lldpmonitor/lldpmonitor.go @@ -0,0 +1,315 @@ +// Package lldpmonitor manages a persistent `lldpcli -f json0 watch` +// subprocess for reactive LLDP neighbor updates. Events are framed +// as pretty-printed JSON objects separated by blank lines. Each +// event triggers full LLDP subtree regeneration. +package lldpmonitor + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "math" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/tree" +) + +const ( + reconnectInitial = 100 * time.Millisecond + reconnectMax = 30 * time.Second + reconnectFactor = 2.0 + + lldpMulticastMAC = "01:80:C2:00:00:0E" + treeKey = "ieee802-dot1ab-lldp:lldp" +) + +// LLDPMonitor subscribes to LLDP neighbor events via a persistent +// lldpcli watch subprocess and updates the tree on every event. +type LLDPMonitor struct { + tree *tree.Tree + log *slog.Logger +} + +// New creates an LLDPMonitor. +func New(t *tree.Tree, log *slog.Logger) *LLDPMonitor { + return &LLDPMonitor{tree: t, log: log} +} + +// Run starts the LLDP monitor. It blocks until ctx is cancelled. +func (m *LLDPMonitor) Run(ctx context.Context) error { + delay := reconnectInitial + + for { + err := m.runOnce(ctx) + if ctx.Err() != nil { + return ctx.Err() + } + + m.log.Warn("lldp monitor: subprocess exited, restarting", + "err", err, "delay", delay) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + delay = time.Duration(math.Min( + float64(delay)*reconnectFactor, + float64(reconnectMax))) + } +} + +func (m *LLDPMonitor) runOnce(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "lldpcli", "-f", "json0", "watch") + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("start lldpcli watch: %w", err) + } + defer cmd.Wait() + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 1*1024*1024), 1*1024*1024) + + var buf bytes.Buffer + braceDepth := 0 + + for scanner.Scan() { + line := scanner.Text() + + // Blank-line framing: object separator. + if strings.TrimSpace(line) == "" && braceDepth == 0 { + if buf.Len() > 0 { + m.processEvent(buf.Bytes()) + buf.Reset() + } + continue + } + + buf.WriteString(line) + buf.WriteByte('\n') + + for _, ch := range line { + switch ch { + case '{': + braceDepth++ + case '}': + braceDepth-- + } + } + + if braceDepth == 0 && buf.Len() > 0 { + m.processEvent(buf.Bytes()) + buf.Reset() + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("read lldpcli: %w", err) + } + return fmt.Errorf("lldpcli watch process exited") +} + +func (m *LLDPMonitor) processEvent(data []byte) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + m.log.Warn("lldp monitor: parse event", "err", err) + return + } + + // Dispatch by root key: lldp-added, lldp-updated, lldp-deleted. + // All event types trigger full subtree rebuild from payload. + for key := range raw { + switch key { + case "lldp-added", "lldp-updated", "lldp-deleted": + m.rebuildTree(data) + return + } + } + m.log.Debug("lldp monitor: unknown event keys", "keys", keysOf(raw)) +} + +func (m *LLDPMonitor) rebuildTree(data []byte) { + result := transformLLDPEvent(data) + m.tree.Set(treeKey, result) + m.log.Debug("lldp monitor: tree updated") +} + +// transformLLDPEvent converts lldpcli json0 watch output to YANG +// ieee802-dot1ab-lldp:lldp format matching the Python infix_lldp.py. +func transformLLDPEvent(data []byte) json.RawMessage { + var event map[string]json.RawMessage + if err := json.Unmarshal(data, &event); err != nil { + return json.RawMessage(`{}`) + } + + // Extract the lldp payload from whichever event key is present. + var lldpPayload json.RawMessage + for _, key := range []string{"lldp-added", "lldp-updated", "lldp-deleted"} { + if p, ok := event[key]; ok { + lldpPayload = p + break + } + } + if lldpPayload == nil { + return json.RawMessage(`{}`) + } + + var lldpData struct { + LLDP struct { + Interface []map[string]json.RawMessage `json:"interface"` + } `json:"lldp"` + } + if err := json.Unmarshal(lldpPayload, &lldpData); err != nil { + // Try unwrapped format. + var unwrapped struct { + Interface []map[string]json.RawMessage `json:"interface"` + } + if err := json.Unmarshal(lldpPayload, &unwrapped); err != nil { + return json.RawMessage(`{}`) + } + lldpData.LLDP.Interface = unwrapped.Interface + } + + type remoteEntry struct { + TimeMark int `json:"time-mark"` + RemoteIndex int `json:"remote-index"` + ChassisIDSubtype string `json:"chassis-id-subtype"` + ChassisID string `json:"chassis-id"` + PortIDSubtype string `json:"port-id-subtype"` + PortID string `json:"port-id"` + } + + type portEntry struct { + Name string `json:"name"` + DestMACAddress string `json:"dest-mac-address"` + RemoteSystems []remoteEntry `json:"remote-systems-data"` + } + + portMap := make(map[string]*portEntry) + + for _, ifEntry := range lldpData.LLDP.Interface { + for ifName, ifData := range ifEntry { + var iface struct { + RID interface{} `json:"rid"` + Age string `json:"age"` + Chassis struct { + ID struct { + Type string `json:"type"` + Value string `json:"value"` + } `json:"id"` + } `json:"chassis"` + Port struct { + ID struct { + Type string `json:"type"` + Value string `json:"value"` + } `json:"id"` + } `json:"port"` + } + if err := json.Unmarshal(ifData, &iface); err != nil { + continue + } + + port, ok := portMap[ifName] + if !ok { + port = &portEntry{ + Name: ifName, + DestMACAddress: lldpMulticastMAC, + } + portMap[ifName] = port + } + + rid := 0 + switch v := iface.RID.(type) { + case float64: + rid = int(v) + case string: + rid, _ = strconv.Atoi(v) + } + + remote := remoteEntry{ + TimeMark: parseAge(iface.Age), + RemoteIndex: rid, + ChassisIDSubtype: chassisIDSubtype(iface.Chassis.ID.Type), + ChassisID: iface.Chassis.ID.Value, + PortIDSubtype: portIDSubtype(iface.Port.ID.Type), + PortID: iface.Port.ID.Value, + } + port.RemoteSystems = append(port.RemoteSystems, remote) + } + } + + var ports []portEntry + for _, p := range portMap { + if len(p.RemoteSystems) > 0 { + ports = append(ports, *p) + } + } + + result := map[string]interface{}{ + "ieee802-dot1ab-lldp:lldp": map[string]interface{}{ + "port": ports, + }, + } + out, _ := json.Marshal(result) + return json.RawMessage(out) +} + +var chassisSubtypeMap = map[string]string{ + "ifalias": "interface-alias", + "mac": "mac-address", + "ip": "network-address", + "ifname": "interface-name", + "local": "local", +} + +var portSubtypeMap = map[string]string{ + "ifalias": "interface-alias", + "mac": "mac-address", + "ip": "network-address", + "ifname": "interface-name", + "local": "local", +} + +func chassisIDSubtype(t string) string { + if v, ok := chassisSubtypeMap[t]; ok { + return v + } + return "unknown" +} + +func portIDSubtype(t string) string { + if v, ok := portSubtypeMap[t]; ok { + return v + } + return "unknown" +} + +var ageRe = regexp.MustCompile(`(\d+)\s*day[s]*,\s*(\d+):(\d+):(\d+)`) + +func parseAge(s string) int { + m := ageRe.FindStringSubmatch(s) + if m == nil { + return 0 + } + days, _ := strconv.Atoi(m[1]) + hours, _ := strconv.Atoi(m[2]) + mins, _ := strconv.Atoi(m[3]) + secs, _ := strconv.Atoi(m[4]) + return days*86400 + hours*3600 + mins*60 + secs +} + +func keysOf(m map[string]json.RawMessage) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/src/yangerd/internal/lldpmonitor/lldpmonitor_test.go b/src/yangerd/internal/lldpmonitor/lldpmonitor_test.go new file mode 100644 index 00000000..c6a81b76 --- /dev/null +++ b/src/yangerd/internal/lldpmonitor/lldpmonitor_test.go @@ -0,0 +1,216 @@ +package lldpmonitor + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestTransformLLDPEvent(t *testing.T) { + tests := []struct { + name string + input string + wantPort int + wantByIfName map[string]int + wantFirst map[string]string + }{ + { + name: "lldp-added single neighbor", + input: `{ + "lldp-added": { + "lldp": { + "interface": [ + { + "eth0": { + "rid": 7, + "age": "0 day, 00:05:30", + "chassis": {"id": {"type": "mac", "value": "aa:bb:cc:dd:ee:ff"}}, + "port": {"id": {"type": "ifname", "value": "swp1"}} + } + } + ] + } + } + }`, + wantPort: 1, + wantByIfName: map[string]int{"eth0": 1}, + wantFirst: map[string]string{ + "chassis-id-subtype": "mac-address", + "port-id-subtype": "interface-name", + "chassis-id": "aa:bb:cc:dd:ee:ff", + "port-id": "swp1", + }, + }, + { + name: "lldp-updated multiple neighbors same port", + input: `{ + "lldp-updated": { + "lldp": { + "interface": [ + { + "eth1": { + "rid": "1", + "age": "1 day, 02:30:15", + "chassis": {"id": {"type": "ifname", "value": "leaf1"}}, + "port": {"id": {"type": "local", "value": "portA"}} + } + }, + { + "eth1": { + "rid": 2, + "age": "10 days, 00:00:00", + "chassis": {"id": {"type": "ip", "value": "192.0.2.1"}}, + "port": {"id": {"type": "mac", "value": "00:11:22:33:44:55"}} + } + } + ] + } + } + }`, + wantPort: 1, + wantByIfName: map[string]int{"eth1": 2}, + }, + { + name: "lldp-deleted event", + input: `{ + "lldp-deleted": { + "lldp": { + "interface": [ + { + "eth2": { + "rid": 4, + "age": "0 day, 00:00:01", + "chassis": {"id": {"type": "local", "value": "chassis-local"}}, + "port": {"id": {"type": "ip", "value": "198.51.100.3"}} + } + } + ] + } + } + }`, + wantPort: 1, + wantByIfName: map[string]int{"eth2": 1}, + }, + { + name: "empty object", + input: `{}`, + wantPort: 0, + wantByIfName: map[string]int{}, + }, + { + name: "malformed input", + input: `{not-json`, + wantPort: 0, + wantByIfName: map[string]int{}, + }, + } + + type remote struct { + TimeMark int `json:"time-mark"` + RemoteIndex int `json:"remote-index"` + ChassisIDSubtype string `json:"chassis-id-subtype"` + ChassisID string `json:"chassis-id"` + PortIDSubtype string `json:"port-id-subtype"` + PortID string `json:"port-id"` + } + type port struct { + Name string `json:"name"` + DestMAC string `json:"dest-mac-address"` + RemoteSystems []remote `json:"remote-systems-data"` + } + type outShape struct { + LLDP struct { + Port []port `json:"port"` + } `json:"ieee802-dot1ab-lldp:lldp"` + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := transformLLDPEvent([]byte(tt.input)) + + var decoded outShape + if err := json.Unmarshal(got, &decoded); err != nil { + t.Fatalf("unmarshal output: %v", err) + } + + if len(decoded.LLDP.Port) != tt.wantPort { + t.Fatalf("port count = %d, want %d", len(decoded.LLDP.Port), tt.wantPort) + } + + byIf := make(map[string]int) + for _, p := range decoded.LLDP.Port { + if p.DestMAC != lldpMulticastMAC { + t.Fatalf("dest-mac-address = %q, want %q", p.DestMAC, lldpMulticastMAC) + } + byIf[p.Name] = len(p.RemoteSystems) + } + + if !reflect.DeepEqual(byIf, tt.wantByIfName) { + t.Fatalf("neighbors by if mismatch\n got: %#v\nwant: %#v", byIf, tt.wantByIfName) + } + + if len(tt.wantFirst) > 0 { + first := decoded.LLDP.Port[0].RemoteSystems[0] + gotFirst := map[string]string{ + "chassis-id-subtype": first.ChassisIDSubtype, + "port-id-subtype": first.PortIDSubtype, + "chassis-id": first.ChassisID, + "port-id": first.PortID, + } + if !reflect.DeepEqual(gotFirst, tt.wantFirst) { + t.Fatalf("first remote mismatch\n got: %#v\nwant: %#v", gotFirst, tt.wantFirst) + } + } + }) + } +} + +func TestParseAge(t *testing.T) { + tests := []struct { + name string + in string + want int + }{ + {name: "zero day", in: "0 day, 00:05:30", want: 330}, + {name: "one day", in: "1 day, 02:30:15", want: 95415}, + {name: "ten days plural", in: "10 days, 00:00:00", want: 864000}, + {name: "empty", in: "", want: 0}, + {name: "invalid", in: "n/a", want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseAge(tt.in); got != tt.want { + t.Fatalf("parseAge(%q) = %d, want %d", tt.in, got, tt.want) + } + }) + } +} + +func TestSubtypeMappings(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "ifalias", in: "ifalias", want: "interface-alias"}, + {name: "mac", in: "mac", want: "mac-address"}, + {name: "ip", in: "ip", want: "network-address"}, + {name: "ifname", in: "ifname", want: "interface-name"}, + {name: "local", in: "local", want: "local"}, + {name: "unknown", in: "foo", want: "unknown"}, + } + + for _, tt := range tests { + t.Run("chassis_"+tt.name, func(t *testing.T) { + if got := chassisIDSubtype(tt.in); got != tt.want { + t.Fatalf("chassisIDSubtype(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + t.Run("port_"+tt.name, func(t *testing.T) { + if got := portIDSubtype(tt.in); got != tt.want { + t.Fatalf("portIDSubtype(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/src/yangerd/internal/monitor/monitor.go b/src/yangerd/internal/monitor/monitor.go new file mode 100644 index 00000000..43c10bdb --- /dev/null +++ b/src/yangerd/internal/monitor/monitor.go @@ -0,0 +1,690 @@ +package monitor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "sync" + "syscall" + + "github.com/kernelkit/infix/src/yangerd/internal/bridgebatch" + "github.com/kernelkit/infix/src/yangerd/internal/iface" + "github.com/kernelkit/infix/src/yangerd/internal/ipbatch" + "github.com/kernelkit/infix/src/yangerd/internal/tree" + "github.com/vishvananda/netlink" + "github.com/vishvananda/netlink/nl" +) + +// treeKey is the single YANG module key where the complete +// ietf-interfaces document is stored. +const treeKey = "ietf-interfaces:interfaces" + +// NLMonitor subscribes to netlink link/address/neighbor events and +// keeps interface operational data in the in-memory tree up to date. +// It is the central coordinator for all interface data — raw ip-json +// staging data is transformed via iface.Transform() and augmented +// with ethernet/wifi/bridge data before being stored as a single +// complete YANG document. +type NLMonitor struct { + batch *ipbatch.IPBatch + brBatch *bridgebatch.BridgeBatch + tree *tree.Tree + ethRefresh func(string) + log *slog.Logger + fc iface.FileChecker + + // staging holds raw ip-json data used as input to iface.Transform(). + // Protected by mu. + mu sync.Mutex + links json.RawMessage // ip -json -s -d link show (includes stats+details) + addrs json.RawMessage // ip -json addr show + fdb map[string]json.RawMessage + mdb map[string]json.RawMessage + ethernet map[string]json.RawMessage // ifname → ethtool JSON + wifi map[string]json.RawMessage // ifname → wifi JSON + + lastOperStatus map[string]string +} + +// New creates a netlink monitor backed by ip/bridge batch query workers. +func New(batch *ipbatch.IPBatch, brBatch *bridgebatch.BridgeBatch, t *tree.Tree, fc iface.FileChecker, log *slog.Logger) *NLMonitor { + return &NLMonitor{ + batch: batch, + brBatch: brBatch, + tree: t, + fc: fc, + log: log, + fdb: make(map[string]json.RawMessage), + mdb: make(map[string]json.RawMessage), + ethernet: make(map[string]json.RawMessage), + wifi: make(map[string]json.RawMessage), + lastOperStatus: make(map[string]string), + } +} + +// SetEthRefresh sets an optional callback used to refresh ethtool data +// when interface link events are received. +func (m *NLMonitor) SetEthRefresh(fn func(string)) { + m.ethRefresh = fn +} + +// SetEthernetData updates the staged ethernet data for an interface +// and triggers a full rebuild of the YANG document. +func (m *NLMonitor) SetEthernetData(ifname string, data json.RawMessage) { + m.mu.Lock() + m.ethernet[ifname] = data + m.mu.Unlock() + m.rebuild() +} + +// SetWifiData updates the staged wifi data for an interface +// and triggers a full rebuild of the YANG document. +func (m *NLMonitor) SetWifiData(ifname string, data json.RawMessage) { + m.mu.Lock() + m.wifi[ifname] = data + m.mu.Unlock() + m.rebuild() +} + +// Run starts the netlink monitor loop and returns on context cancellation, +// channel closure, or subscription errors. +func (m *NLMonitor) Run(ctx context.Context) error { + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + done := make(chan struct{}) + defer close(done) + + errorCallback := func(err error) { + if err == nil { + return + } + m.log.Error("netlink subscription error", "err", err) + cancel() + } + + linkCh := make(chan netlink.LinkUpdate, 64) + addrCh := make(chan netlink.AddrUpdate, 64) + neighCh := make(chan netlink.NeighUpdate, 64) + mdbCh := make(chan struct{}, 32) + + if err := netlink.LinkSubscribeWithOptions(linkCh, done, netlink.LinkSubscribeOptions{ + ErrorCallback: errorCallback, + }); err != nil { + return fmt.Errorf("subscribe link updates: %w", err) + } + if err := netlink.AddrSubscribeWithOptions(addrCh, done, netlink.AddrSubscribeOptions{ + ErrorCallback: errorCallback, + }); err != nil { + return fmt.Errorf("subscribe addr updates: %w", err) + } + if err := netlink.NeighSubscribeWithOptions(neighCh, done, netlink.NeighSubscribeOptions{ + ErrorCallback: errorCallback, + }); err != nil { + return fmt.Errorf("subscribe neigh updates: %w", err) + } + if err := m.subscribeBridgeMDB(runCtx, mdbCh, errorCallback); err != nil { + return fmt.Errorf("subscribe bridge mdb updates: %w", err) + } + + if err := m.initialDump(); err != nil { + m.log.Error("initial dump failed", "err", err) + } + + for { + select { + case <-runCtx.Done(): + if ctx.Err() != nil { + return ctx.Err() + } + return runCtx.Err() + case lu, ok := <-linkCh: + if !ok { + return fmt.Errorf("link update channel closed") + } + m.handleLinkUpdate(lu) + case au, ok := <-addrCh: + if !ok { + return fmt.Errorf("addr update channel closed") + } + m.handleAddrUpdate(au) + case nu, ok := <-neighCh: + if !ok { + return fmt.Errorf("neigh update channel closed") + } + m.handleNeighUpdate(nu) + case _, ok := <-mdbCh: + if !ok { + return fmt.Errorf("bridge mdb update channel closed") + } + m.handleMDBUpdate() + } + } +} + +func (m *NLMonitor) initialDump() error { + linkRaw, err := m.queryIP("link show") + if err != nil { + return err + } + addrRaw, err := m.queryIP("addr show") + if err != nil { + return err + } + + m.mu.Lock() + m.links = linkRaw + m.addrs = addrRaw + for _, name := range interfaceNames(linkRaw) { + if st, ok := extractOperStatus(filterByIfName(linkRaw, name)); ok { + m.lastOperStatus[name] = st + } + } + m.mu.Unlock() + + m.rebuild() + return nil +} + +func (m *NLMonitor) handleLinkUpdate(update netlink.LinkUpdate) { + name, ok := linkNameFromUpdate(update) + if !ok || name == "" { + m.log.Warn("link update without interface name", "index", int(update.Index)) + return + } + + m.refreshInterface(name) + if m.ethRefresh != nil { + m.ethRefresh(name) + } +} + +func (m *NLMonitor) handleAddrUpdate(update netlink.AddrUpdate) { + ifname, err := ifNameByIndex(update.LinkIndex) + if err != nil { + m.log.Warn("addr update: resolve interface", "index", update.LinkIndex, "err", err) + return + } + + raw, err := m.queryIP("addr show dev " + ifname) + if err != nil { + return + } + + m.mu.Lock() + m.addrs = replaceByIfName(m.addrs, ifname, raw) + m.mu.Unlock() + + m.rebuild() +} + +func (m *NLMonitor) handleNeighUpdate(update netlink.NeighUpdate) { + if isBridgeFDB(update) { + bridgeName, ok := bridgeNameFromNeigh(update) + if !ok { + m.log.Warn("fdb update: bridge name not found", "link-index", update.LinkIndex) + return + } + + raw, err := m.queryBridge("fdb show br " + bridgeName) + if err != nil { + return + } + + m.mu.Lock() + m.fdb[bridgeName] = raw + m.mu.Unlock() + + m.rebuild() + return + } + +} + +func (m *NLMonitor) handleMDBUpdate() { + raw, err := m.queryBridge("mdb show") + if err != nil { + return + } + + m.mu.Lock() + for _, bridgeName := range bridgeNames(raw) { + m.mdb[bridgeName] = filterByBridge(raw, bridgeName) + } + m.mu.Unlock() + + m.rebuild() +} + +func (m *NLMonitor) refreshInterface(name string) { + linkRaw, err := m.queryIP("link show dev " + name) + if err != nil { + return + } + + addrRaw, err := m.queryIP("addr show dev " + name) + if err != nil { + addrRaw = nil + } + + m.mu.Lock() + m.updateOperStatus(name, linkRaw) + m.links = replaceByIfName(m.links, name, linkRaw) + if addrRaw != nil { + m.addrs = replaceByIfName(m.addrs, name, addrRaw) + } + m.mu.Unlock() + + m.rebuild() +} + +// rebuild runs iface.Transform on all staged data, merges augments +// (ethernet, wifi, bridge fdb/mdb), and stores the result. +// Caller must NOT hold m.mu. +func (m *NLMonitor) rebuild() { + m.mu.Lock() + doc := iface.Transform(m.links, m.addrs, m.links, m.fc) + eth := copyStringMap(m.ethernet) + wfi := copyStringMap(m.wifi) + fdb := copyStringMap(m.fdb) + mdb := copyStringMap(m.mdb) + m.mu.Unlock() + + doc = mergeAugments(doc, eth, wfi, fdb, mdb) + m.tree.Set(treeKey, doc) +} + +// mergeAugments adds ethernet, wifi, and bridge data into the +// complete ietf-interfaces document produced by iface.Transform(). +func mergeAugments(doc json.RawMessage, ethernet, wifi, fdb, mdb map[string]json.RawMessage) json.RawMessage { + if len(ethernet) == 0 && len(wifi) == 0 && len(fdb) == 0 && len(mdb) == 0 { + return doc + } + + var root map[string]any + if err := json.Unmarshal(doc, &root); err != nil { + return doc + } + + ifaceList, ok := root["interface"] + if !ok { + return doc + } + ifaceArr, ok := ifaceList.([]any) + if !ok { + return doc + } + + for i, entry := range ifaceArr { + ifaceObj, ok := entry.(map[string]any) + if !ok { + continue + } + name, _ := ifaceObj["name"].(string) + if name == "" { + continue + } + + if ethData, ok := ethernet[name]; ok { + var ethObj any + if err := json.Unmarshal(ethData, ðObj); err == nil { + ifaceObj["ieee802-ethernet-interface:ethernet"] = ethObj + } + } + + if wifiData, ok := wifi[name]; ok { + var wifiObj any + if err := json.Unmarshal(wifiData, &wifiObj); err == nil { + ifaceObj["infix-interfaces:wifi"] = wifiObj + } + } + + if fdbData, ok := fdb[name]; ok { + bridgeObj := ensureBridgeAugment(ifaceObj) + var fdbObj any + if err := json.Unmarshal(fdbData, &fdbObj); err == nil { + bridgeObj["fdb"] = fdbObj + } + } + + if mdbData, ok := mdb[name]; ok { + bridgeObj := ensureBridgeAugment(ifaceObj) + var mdbObj any + if err := json.Unmarshal(mdbData, &mdbObj); err == nil { + bridgeObj["mdb"] = mdbObj + } + } + + ifaceArr[i] = ifaceObj + } + + out, err := json.Marshal(root) + if err != nil { + return doc + } + return json.RawMessage(out) +} + +// ensureBridgeAugment returns the bridge augment object within an +// interface, creating it if necessary. +func ensureBridgeAugment(ifaceObj map[string]any) map[string]any { + key := "infix-interfaces:bridge" + if existing, ok := ifaceObj[key]; ok { + if m, ok := existing.(map[string]any); ok { + return m + } + } + bridgeObj := map[string]any{} + ifaceObj[key] = bridgeObj + return bridgeObj +} + +func copyStringMap(m map[string]json.RawMessage) map[string]json.RawMessage { + if len(m) == 0 { + return nil + } + cp := make(map[string]json.RawMessage, len(m)) + for k, v := range m { + cp[k] = v + } + return cp +} + +func (m *NLMonitor) updateOperStatus(ifname string, raw json.RawMessage) { + status, ok := extractOperStatus(raw) + if !ok { + return + } + + prev, had := m.lastOperStatus[ifname] + m.lastOperStatus[ifname] = status + if had && prev != status { + m.log.Info("oper-status transition", "ifname", ifname, "from", prev, "to", status) + } +} + +func (m *NLMonitor) queryIP(command string) (json.RawMessage, error) { + raw, err := m.batch.Query(command) + if err != nil { + if errors.Is(err, ipbatch.ErrBatchDead) { + m.log.Warn("ip batch dead", "command", command, "err", err) + return nil, err + } + m.log.Error("ip batch query failed", "command", command, "err", err) + return nil, err + } + return raw, nil +} + +func (m *NLMonitor) queryBridge(command string) (json.RawMessage, error) { + raw, err := m.brBatch.Query(command) + if err != nil { + if errors.Is(err, bridgebatch.ErrBatchDead) { + m.log.Warn("bridge batch dead", "command", command, "err", err) + return nil, err + } + m.log.Error("bridge batch query failed", "command", command, "err", err) + return nil, err + } + return raw, nil +} + +func (m *NLMonitor) subscribeBridgeMDB(ctx context.Context, ch chan<- struct{}, errorCallback func(error)) error { + sock, err := nl.Subscribe(syscall.NETLINK_ROUTE, 26) + if err != nil { + return err + } + + go func() { + defer close(ch) + defer sock.Close() + + for { + select { + case <-ctx.Done(): + return + default: + } + + msgs, _, err := sock.Receive() + if err != nil { + if ctx.Err() != nil { + return + } + errorCallback(err) + return + } + if len(msgs) == 0 { + continue + } + + select { + case ch <- struct{}{}: + default: + } + } + }() + + return nil +} + +func ifNameByIndex(index int) (string, error) { + iface, err := net.InterfaceByIndex(index) + if err != nil { + return "", err + } + return iface.Name, nil +} + +func linkNameFromUpdate(update netlink.LinkUpdate) (string, bool) { + if update.Link != nil && update.Link.Attrs() != nil && update.Link.Attrs().Name != "" { + return update.Link.Attrs().Name, true + } + if update.Index <= 0 { + return "", false + } + name, err := ifNameByIndex(int(update.Index)) + if err != nil { + return "", false + } + return name, true +} + +func isBridgeFDB(update netlink.NeighUpdate) bool { + if update.Family == syscall.AF_BRIDGE { + return true + } + if update.MasterIndex > 0 { + return true + } + if update.Flags&netlink.NTF_MASTER != 0 { + return true + } + return false +} + +func bridgeNameFromNeigh(update netlink.NeighUpdate) (string, bool) { + if update.MasterIndex > 0 { + name, err := ifNameByIndex(update.MasterIndex) + if err == nil { + return name, true + } + } + + if update.LinkIndex <= 0 { + return "", false + } + link, err := netlink.LinkByIndex(update.LinkIndex) + if err == nil && link != nil && link.Attrs() != nil && link.Attrs().MasterIndex > 0 { + name, err := ifNameByIndex(link.Attrs().MasterIndex) + if err == nil { + return name, true + } + } + return "", false +} + +func extractOperStatus(raw json.RawMessage) (string, bool) { + var rows []map[string]json.RawMessage + if err := json.Unmarshal(raw, &rows); err != nil || len(rows) == 0 { + return "", false + } + stateRaw, ok := rows[0]["operstate"] + if !ok { + return "", false + } + var state string + if err := json.Unmarshal(stateRaw, &state); err != nil || state == "" { + return "", false + } + return state, true +} + +func interfaceNames(raw json.RawMessage) []string { + var rows []map[string]json.RawMessage + if err := json.Unmarshal(raw, &rows); err != nil { + return nil + } + + names := make([]string, 0, len(rows)) + seen := make(map[string]struct{}, len(rows)) + for _, row := range rows { + ifnRaw, ok := row["ifname"] + if !ok { + continue + } + var ifname string + if err := json.Unmarshal(ifnRaw, &ifname); err != nil || ifname == "" { + continue + } + if _, ok := seen[ifname]; ok { + continue + } + seen[ifname] = struct{}{} + names = append(names, ifname) + } + return names +} + +func filterByIfName(raw json.RawMessage, ifname string) json.RawMessage { + var rows []map[string]json.RawMessage + if err := json.Unmarshal(raw, &rows); err != nil { + return json.RawMessage(`[]`) + } + + filtered := make([]map[string]json.RawMessage, 0, 1) + for _, row := range rows { + ifnRaw, ok := row["ifname"] + if !ok { + continue + } + var name string + if err := json.Unmarshal(ifnRaw, &name); err != nil { + continue + } + if name == ifname { + filtered = append(filtered, row) + } + } + + out, err := json.Marshal(filtered) + if err != nil { + return json.RawMessage(`[]`) + } + return json.RawMessage(out) +} + +// replaceByIfName replaces all entries for ifname in the bulk array +// with entries from perIface, and returns the updated full array. +func replaceByIfName(bulk json.RawMessage, ifname string, perIface json.RawMessage) json.RawMessage { + var bulkRows []json.RawMessage + if err := json.Unmarshal(bulk, &bulkRows); err != nil { + return perIface + } + + kept := make([]json.RawMessage, 0, len(bulkRows)) + for _, row := range bulkRows { + var obj map[string]json.RawMessage + if err := json.Unmarshal(row, &obj); err != nil { + kept = append(kept, row) + continue + } + ifnRaw, ok := obj["ifname"] + if !ok { + kept = append(kept, row) + continue + } + var name string + if err := json.Unmarshal(ifnRaw, &name); err != nil || name != ifname { + kept = append(kept, row) + } + } + + var newRows []json.RawMessage + if err := json.Unmarshal(perIface, &newRows); err == nil { + kept = append(kept, newRows...) + } + + out, err := json.Marshal(kept) + if err != nil { + return bulk + } + return json.RawMessage(out) +} + +func bridgeNames(raw json.RawMessage) []string { + var rows []map[string]json.RawMessage + if err := json.Unmarshal(raw, &rows); err != nil { + return nil + } + + names := make([]string, 0, len(rows)) + seen := make(map[string]struct{}, len(rows)) + for _, row := range rows { + brRaw, ok := row["br"] + if !ok { + continue + } + var name string + if err := json.Unmarshal(brRaw, &name); err != nil || name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + names = append(names, name) + } + return names +} + +func filterByBridge(raw json.RawMessage, bridgeName string) json.RawMessage { + var rows []map[string]json.RawMessage + if err := json.Unmarshal(raw, &rows); err != nil { + return json.RawMessage(`[]`) + } + + filtered := make([]map[string]json.RawMessage, 0, 1) + for _, row := range rows { + brRaw, ok := row["br"] + if !ok { + continue + } + var br string + if err := json.Unmarshal(brRaw, &br); err != nil { + continue + } + if br == bridgeName { + filtered = append(filtered, row) + } + } + + out, err := json.Marshal(filtered) + if err != nil { + return json.RawMessage(`[]`) + } + return json.RawMessage(out) +} diff --git a/src/yangerd/internal/monitor/monitor_test.go b/src/yangerd/internal/monitor/monitor_test.go new file mode 100644 index 00000000..d67db18f --- /dev/null +++ b/src/yangerd/internal/monitor/monitor_test.go @@ -0,0 +1,392 @@ +package monitor + +import ( + "encoding/json" + "reflect" + "syscall" + "testing" + + "github.com/vishvananda/netlink" +) + +func TestExtractOperStatus(t *testing.T) { + tests := []struct { + name string + raw json.RawMessage + want string + wantOK bool + }{ + { + name: "valid single entry", + raw: json.RawMessage(`[{"operstate":"UP"}]`), + want: "UP", + wantOK: true, + }, + { + name: "multiple entries first wins", + raw: json.RawMessage(`[{"operstate":"DOWN"},{"operstate":"UP"}]`), + want: "DOWN", + wantOK: true, + }, + { + name: "missing operstate", + raw: json.RawMessage(`[{"ifname":"eth0"}]`), + want: "", + wantOK: false, + }, + { + name: "empty array", + raw: json.RawMessage(`[]`), + want: "", + wantOK: false, + }, + { + name: "invalid json", + raw: json.RawMessage(`{`), + want: "", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := extractOperStatus(tt.raw) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("extractOperStatus() = (%q, %v), want (%q, %v)", got, ok, tt.want, tt.wantOK) + } + }) + } +} + +func TestInterfaceNames(t *testing.T) { + tests := []struct { + name string + raw json.RawMessage + want []string + }{ + { + name: "deduplicate and keep order", + raw: json.RawMessage(`[ + {"ifname":"eth0"}, + {"ifname":"eth1"}, + {"ifname":"eth0"}, + {"x":"y"} + ]`), + want: []string{"eth0", "eth1"}, + }, + { + name: "empty array", + raw: json.RawMessage(`[]`), + want: []string{}, + }, + { + name: "objects without ifname skipped", + raw: json.RawMessage(`[{"name":"eth0"},{"foo":"bar"}]`), + want: []string{}, + }, + { + name: "invalid json", + raw: json.RawMessage(`{`), + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := interfaceNames(tt.raw) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("interfaceNames() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestFilterByIfName(t *testing.T) { + tests := []struct { + name string + raw json.RawMessage + ifname string + wantCount int + wantEmpty bool + }{ + { + name: "filters correctly", + raw: json.RawMessage(`[ + {"ifname":"eth0","x":1}, + {"ifname":"eth1","x":2}, + {"ifname":"eth0","x":3} + ]`), + ifname: "eth0", + wantCount: 2, + }, + { + name: "no matches returns empty array", + raw: json.RawMessage(`[{"ifname":"eth1"}]`), + ifname: "eth0", + wantCount: 0, + wantEmpty: true, + }, + { + name: "invalid json", + raw: json.RawMessage(`{`), + ifname: "eth0", + wantCount: 0, + wantEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filterByIfName(tt.raw, tt.ifname) + + var rows []map[string]json.RawMessage + if err := json.Unmarshal(got, &rows); err != nil { + t.Fatalf("unmarshal filtered rows: %v", err) + } + if len(rows) != tt.wantCount { + t.Fatalf("row count = %d, want %d", len(rows), tt.wantCount) + } + if tt.wantEmpty && string(got) != "[]" { + t.Fatalf("expected [] got %s", string(got)) + } + }) + } +} + +func TestBridgeNames(t *testing.T) { + raw := json.RawMessage(`[ + {"br":"br0"}, + {"br":"br1"}, + {"br":"br0"}, + {"ifname":"eth0"} + ]`) + + got := bridgeNames(raw) + want := []string{"br0", "br1"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("bridgeNames() = %#v, want %#v", got, want) + } +} + +func TestFilterByBridge(t *testing.T) { + tests := []struct { + name string + raw json.RawMessage + bridge string + wantCount int + wantEmpty bool + }{ + { + name: "filters correctly", + raw: json.RawMessage(`[ + {"br":"br0","grp":"239.1.1.1"}, + {"br":"br1","grp":"239.1.1.2"}, + {"br":"br0","grp":"239.1.1.3"} + ]`), + bridge: "br0", + wantCount: 2, + }, + { + name: "no matches returns empty array", + raw: json.RawMessage(`[{"br":"br1"}]`), + bridge: "br0", + wantCount: 0, + wantEmpty: true, + }, + { + name: "invalid json", + raw: json.RawMessage(`{`), + bridge: "br0", + wantCount: 0, + wantEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filterByBridge(tt.raw, tt.bridge) + + var rows []map[string]json.RawMessage + if err := json.Unmarshal(got, &rows); err != nil { + t.Fatalf("unmarshal filtered rows: %v", err) + } + if len(rows) != tt.wantCount { + t.Fatalf("row count = %d, want %d", len(rows), tt.wantCount) + } + if tt.wantEmpty && string(got) != "[]" { + t.Fatalf("expected [] got %s", string(got)) + } + }) + } +} + +func TestIsBridgeFDB(t *testing.T) { + tests := []struct { + name string + update netlink.NeighUpdate + want bool + }{ + { + name: "bridge family", + update: netlink.NeighUpdate{Neigh: netlink.Neigh{Family: syscall.AF_BRIDGE}}, + want: true, + }, + { + name: "master index set", + update: netlink.NeighUpdate{Neigh: netlink.Neigh{MasterIndex: 10}}, + want: true, + }, + { + name: "master flag set", + update: netlink.NeighUpdate{Neigh: netlink.Neigh{Flags: netlink.NTF_MASTER}}, + want: true, + }, + { + name: "non-bridge", + update: netlink.NeighUpdate{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isBridgeFDB(tt.update); got != tt.want { + t.Fatalf("isBridgeFDB() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestReplaceByIfName(t *testing.T) { + tests := []struct { + name string + bulk json.RawMessage + ifname string + perIface json.RawMessage + want int + }{ + { + name: "replace existing", + bulk: json.RawMessage(`[{"ifname":"eth0","x":1},{"ifname":"eth1","x":2}]`), + ifname: "eth0", + perIface: json.RawMessage(`[{"ifname":"eth0","x":99}]`), + want: 2, + }, + { + name: "add new", + bulk: json.RawMessage(`[{"ifname":"eth0","x":1}]`), + ifname: "eth1", + perIface: json.RawMessage(`[{"ifname":"eth1","x":2}]`), + want: 2, + }, + { + name: "empty bulk", + bulk: json.RawMessage(`[]`), + ifname: "eth0", + perIface: json.RawMessage(`[{"ifname":"eth0","x":1}]`), + want: 1, + }, + { + name: "invalid bulk", + bulk: json.RawMessage(`{`), + ifname: "eth0", + perIface: json.RawMessage(`[{"ifname":"eth0","x":1}]`), + want: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := replaceByIfName(tt.bulk, tt.ifname, tt.perIface) + var rows []json.RawMessage + if err := json.Unmarshal(got, &rows); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(rows) != tt.want { + t.Fatalf("row count = %d, want %d (raw: %s)", len(rows), tt.want, string(got)) + } + }) + } +} + +func TestReplaceByIfNamePreservesUpdatedData(t *testing.T) { + bulk := json.RawMessage(`[{"ifname":"eth0","x":1},{"ifname":"eth1","x":2}]`) + updated := replaceByIfName(bulk, "eth0", json.RawMessage(`[{"ifname":"eth0","x":99}]`)) + + var rows []map[string]json.RawMessage + if err := json.Unmarshal(updated, &rows); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + for _, row := range rows { + var name string + json.Unmarshal(row["ifname"], &name) + if name == "eth0" { + var x int + json.Unmarshal(row["x"], &x) + if x != 99 { + t.Fatalf("eth0.x = %d, want 99", x) + } + return + } + } + t.Fatal("eth0 not found in result") +} + +func TestMergeAugments(t *testing.T) { + doc := json.RawMessage(`{"interface":[{"name":"eth0","type":"infix-if-type:ethernet"},{"name":"br0","type":"infix-if-type:bridge"}]}`) + + eth := map[string]json.RawMessage{ + "eth0": json.RawMessage(`{"speed":1000,"duplex":"full"}`), + } + fdb := map[string]json.RawMessage{ + "br0": json.RawMessage(`[{"mac":"00:11:22:33:44:55"}]`), + } + + got := mergeAugments(doc, eth, nil, fdb, nil) + + var root map[string]any + if err := json.Unmarshal(got, &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + ifaces := root["interface"].([]any) + eth0 := ifaces[0].(map[string]any) + if _, ok := eth0["ieee802-ethernet-interface:ethernet"]; !ok { + t.Fatal("ethernet augment not merged into eth0") + } + + br0 := ifaces[1].(map[string]any) + bridge, ok := br0["infix-interfaces:bridge"] + if !ok { + t.Fatal("bridge augment not created for br0") + } + bridgeMap := bridge.(map[string]any) + if _, ok := bridgeMap["fdb"]; !ok { + t.Fatal("fdb not merged into bridge augment") + } +} + +func TestMergeAugmentsNoOp(t *testing.T) { + doc := json.RawMessage(`{"interface":[{"name":"lo"}]}`) + got := mergeAugments(doc, nil, nil, nil, nil) + if string(got) != string(doc) { + t.Fatalf("expected no-op, got %s", string(got)) + } +} + +func TestMergeAugmentsInvalidDoc(t *testing.T) { + doc := json.RawMessage(`{invalid`) + eth := map[string]json.RawMessage{"eth0": json.RawMessage(`{}`)} + got := mergeAugments(doc, eth, nil, nil, nil) + if string(got) != string(doc) { + t.Fatalf("expected passthrough on invalid doc, got %s", string(got)) + } +} + +func TestTreeKey(t *testing.T) { + if treeKey != "ietf-interfaces:interfaces" { + t.Fatalf("treeKey = %q, want %q", treeKey, "ietf-interfaces:interfaces") + } +} diff --git a/src/yangerd/internal/testutil/mock.go b/src/yangerd/internal/testutil/mock.go new file mode 100644 index 00000000..b5217b78 --- /dev/null +++ b/src/yangerd/internal/testutil/mock.go @@ -0,0 +1,49 @@ +package testutil + +import ( + "context" + "fmt" +) + +// MockRunner records command invocations and returns pre-configured output. +type MockRunner struct { + Results map[string][]byte + Errors map[string]error +} + +// Run returns the pre-configured result for the command name. +func (m *MockRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + key := name + for _, a := range args { + key += " " + a + } + if err, ok := m.Errors[key]; ok { + return nil, err + } + if data, ok := m.Results[key]; ok { + return data, nil + } + return nil, fmt.Errorf("mock: no result for %q", key) +} + +// MockFileReader returns pre-configured file contents. +type MockFileReader struct { + Files map[string][]byte + Globs map[string][]string +} + +// ReadFile returns pre-configured data for the path. +func (m *MockFileReader) ReadFile(path string) ([]byte, error) { + if data, ok := m.Files[path]; ok { + return data, nil + } + return nil, fmt.Errorf("mock: file not found: %s", path) +} + +// Glob returns pre-configured matches for the pattern. +func (m *MockFileReader) Glob(pattern string) ([]string, error) { + if matches, ok := m.Globs[pattern]; ok { + return matches, nil + } + return nil, nil +} diff --git a/src/yangerd/internal/tree/tree.go b/src/yangerd/internal/tree/tree.go new file mode 100644 index 00000000..75ea450b --- /dev/null +++ b/src/yangerd/internal/tree/tree.go @@ -0,0 +1,243 @@ +// Package tree provides a concurrent in-memory store for per-module +// YANG operational data, keyed by module-qualified names like +// "ietf-system:system-state". +// +// Each module has its own read-write mutex, so writers for different +// modules never block each other. All methods are safe for concurrent +// use. +package tree + +import ( + "encoding/json" + "sync" + "time" +) + +// OnDemandFunc returns a JSON blob computed at call time. +// Registered providers are invoked on every Get/GetMulti to supply +// fields that must always be fresh (e.g. uptime, current-datetime). +type OnDemandFunc func() json.RawMessage + +// modelEntry holds a single YANG module's pre-serialized JSON blob +// and its own read-write mutex. +type modelEntry struct { + mu sync.RWMutex + data json.RawMessage + updated time.Time +} + +// Tree holds the operational YANG data in per-module JSON blobs. +type Tree struct { + mu sync.RWMutex // protects the models map itself + models map[string]*modelEntry + providers map[string]OnDemandFunc // on-demand overlay providers +} + +// New creates an empty Tree. +func New() *Tree { + return &Tree{ + models: make(map[string]*modelEntry), + providers: make(map[string]OnDemandFunc), + } +} + +// RegisterProvider adds an on-demand overlay for the given key. +// When Get or GetMulti reads this key the provider is called and its +// result is shallow-merged on top of the cached data. The cached +// entry is never mutated — a merged copy is returned. +func (t *Tree) RegisterProvider(key string, fn OnDemandFunc) { + t.mu.Lock() + t.providers[key] = fn + t.mu.Unlock() +} + +// Set replaces the entire subtree at the given YANG module key. +// Only the target module's write lock is held; other modules remain +// readable and writable. +func (t *Tree) Set(key string, v json.RawMessage) { + t.mu.RLock() + entry, ok := t.models[key] + t.mu.RUnlock() + if !ok { + t.mu.Lock() + entry, ok = t.models[key] + if !ok { + entry = &modelEntry{} + t.models[key] = entry + } + t.mu.Unlock() + } + entry.mu.Lock() + entry.data = v + entry.updated = time.Now() + entry.mu.Unlock() +} + +// Get returns the raw JSON for the given module key. +// If a provider is registered for key its output is shallow-merged on +// top of the cached data without mutating the cache. +func (t *Tree) Get(key string) json.RawMessage { + t.mu.RLock() + entry, ok := t.models[key] + provider := t.providers[key] + t.mu.RUnlock() + if !ok { + return nil + } + entry.mu.RLock() + data := entry.data + entry.mu.RUnlock() + + if provider == nil { + return data + } + return shallowMerge(data, provider()) +} + +// GetMulti returns the raw JSON for multiple module keys. +// Each module's read lock is acquired and released individually — +// the result is eventually consistent, not a snapshot. +// Providers are applied per-key, same as Get. +func (t *Tree) GetMulti(keys []string) []json.RawMessage { + result := make([]json.RawMessage, 0, len(keys)) + t.mu.RLock() + defer t.mu.RUnlock() + for _, key := range keys { + entry, ok := t.models[key] + if !ok { + continue + } + entry.mu.RLock() + data := entry.data + entry.mu.RUnlock() + + if provider, has := t.providers[key]; has { + data = shallowMerge(data, provider()) + } + result = append(result, data) + } + return result +} + +// Keys returns all registered module keys. +func (t *Tree) Keys() []string { + t.mu.RLock() + defer t.mu.RUnlock() + keys := make([]string, 0, len(t.models)) + for k := range t.models { + keys = append(keys, k) + } + return keys +} + +// ModelInfo holds metadata for a single model key. +type ModelInfo struct { + LastUpdated time.Time + SizeBytes int +} + +// Merge performs a shallow first-level JSON merge of partial into +// the existing blob at key. If the key does not exist yet, partial +// becomes the entire value. Each top-level field in partial +// overwrites the corresponding field in the existing object; fields +// not mentioned in partial are preserved. +// +// Both the existing data and partial must be JSON objects (maps). +// If either is not a valid JSON object, Merge falls back to Set. +func (t *Tree) Merge(key string, partial json.RawMessage) { + t.mu.RLock() + entry, ok := t.models[key] + t.mu.RUnlock() + + if !ok { + // No existing entry — just set. + t.Set(key, partial) + return + } + + entry.mu.Lock() + defer entry.mu.Unlock() + + // Unmarshal existing data. + var base map[string]json.RawMessage + if len(entry.data) == 0 || json.Unmarshal(entry.data, &base) != nil { + base = make(map[string]json.RawMessage) + } + + // Unmarshal partial. + var overlay map[string]json.RawMessage + if json.Unmarshal(partial, &overlay) != nil { + // partial is not a JSON object — fall back to full replace. + entry.data = partial + entry.updated = time.Now() + return + } + + for k, v := range overlay { + base[k] = v + } + + merged, err := json.Marshal(base) + if err != nil { + // Should never happen with valid JSON inputs. + entry.data = partial + entry.updated = time.Now() + return + } + entry.data = merged + entry.updated = time.Now() +} + +// Delete removes a key from the tree entirely. +func (t *Tree) Delete(key string) { + t.mu.Lock() + delete(t.models, key) + t.mu.Unlock() +} + +// shallowMerge overlays the top-level fields of overlay onto base and +// returns a new JSON blob. Neither input is modified. If either +// is not a valid JSON object the overlay wins outright. +func shallowMerge(base, overlay json.RawMessage) json.RawMessage { + if len(overlay) == 0 { + return base + } + if len(base) == 0 { + return overlay + } + + var bm map[string]json.RawMessage + if json.Unmarshal(base, &bm) != nil { + return overlay + } + var om map[string]json.RawMessage + if json.Unmarshal(overlay, &om) != nil { + return overlay + } + + for k, v := range om { + bm[k] = v + } + + merged, err := json.Marshal(bm) + if err != nil { + return overlay + } + return merged +} + +// Info returns metadata for the given module key. +func (t *Tree) Info(key string) (ModelInfo, bool) { + t.mu.RLock() + entry, ok := t.models[key] + t.mu.RUnlock() + if !ok { + return ModelInfo{}, false + } + entry.mu.RLock() + defer entry.mu.RUnlock() + return ModelInfo{ + LastUpdated: entry.updated, + SizeBytes: len(entry.data), + }, true +} diff --git a/src/yangerd/internal/tree/tree_test.go b/src/yangerd/internal/tree/tree_test.go new file mode 100644 index 00000000..890563e2 --- /dev/null +++ b/src/yangerd/internal/tree/tree_test.go @@ -0,0 +1,325 @@ +package tree + +import ( + "encoding/json" + "sync" + "testing" +) + +func TestSetGet(t *testing.T) { + tr := New() + tr.Set("ietf-system:system", json.RawMessage(`{"hostname":"r1"}`)) + + got := tr.Get("ietf-system:system") + if string(got) != `{"hostname":"r1"}` { + t.Fatalf("unexpected: %s", got) + } +} + +func TestGetMissing(t *testing.T) { + tr := New() + if got := tr.Get("nonexistent"); got != nil { + t.Fatalf("expected nil, got: %s", got) + } +} + +func TestSetOverwrite(t *testing.T) { + tr := New() + tr.Set("key", json.RawMessage(`"v1"`)) + tr.Set("key", json.RawMessage(`"v2"`)) + + if got := tr.Get("key"); string(got) != `"v2"` { + t.Fatalf("expected v2, got: %s", got) + } +} + +func TestGetMulti(t *testing.T) { + tr := New() + tr.Set("a", json.RawMessage(`1`)) + tr.Set("b", json.RawMessage(`2`)) + tr.Set("c", json.RawMessage(`3`)) + + results := tr.GetMulti([]string{"a", "c"}) + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + if string(results[0]) != "1" || string(results[1]) != "3" { + t.Fatalf("unexpected results: %s, %s", results[0], results[1]) + } +} + +func TestGetMultiMissing(t *testing.T) { + tr := New() + tr.Set("a", json.RawMessage(`1`)) + + results := tr.GetMulti([]string{"a", "missing"}) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } +} + +func TestKeys(t *testing.T) { + tr := New() + tr.Set("x", json.RawMessage(`1`)) + tr.Set("y", json.RawMessage(`2`)) + + keys := tr.Keys() + if len(keys) != 2 { + t.Fatalf("expected 2 keys, got %d", len(keys)) + } +} + +func TestInfo(t *testing.T) { + tr := New() + tr.Set("k", json.RawMessage(`{"data":true}`)) + + info, ok := tr.Info("k") + if !ok { + t.Fatal("expected ok") + } + if info.SizeBytes != len(`{"data":true}`) { + t.Fatalf("expected size %d, got %d", len(`{"data":true}`), info.SizeBytes) + } + if info.LastUpdated.IsZero() { + t.Fatal("expected non-zero LastUpdated") + } +} + +func TestInfoMissing(t *testing.T) { + tr := New() + _, ok := tr.Info("missing") + if ok { + t.Fatal("expected !ok for missing key") + } +} + +func TestConcurrentSetGet(t *testing.T) { + tr := New() + var wg sync.WaitGroup + const N = 100 + + for i := 0; i < N; i++ { + wg.Add(2) + go func(i int) { + defer wg.Done() + tr.Set("shared", json.RawMessage(`{"i":`+string(rune('0'+i%10))+`}`)) + }(i) + go func() { + defer wg.Done() + tr.Get("shared") + }() + } + wg.Wait() + + if got := tr.Get("shared"); got == nil { + t.Fatal("expected non-nil after concurrent writes") + } +} + +func TestMerge(t *testing.T) { + tests := []struct { + name string + existing string + partial string + want map[string]json.RawMessage + }{ + { + name: "merge into existing preserves old fields", + existing: `{"a":"1","b":"2"}`, + partial: `{"c":"3"}`, + want: map[string]json.RawMessage{ + "a": json.RawMessage(`"1"`), + "b": json.RawMessage(`"2"`), + "c": json.RawMessage(`"3"`), + }, + }, + { + name: "merge overwrites overlapping field", + existing: `{"a":"old","b":"keep"}`, + partial: `{"a":"new"}`, + want: map[string]json.RawMessage{ + "a": json.RawMessage(`"new"`), + "b": json.RawMessage(`"keep"`), + }, + }, + { + name: "merge with complex nested values", + existing: `{"protocols":{"ospf":true}}`, + partial: `{"ribs":{"rib":[{"name":"ipv4"}]}}`, + want: map[string]json.RawMessage{ + "protocols": json.RawMessage(`{"ospf":true}`), + "ribs": json.RawMessage(`{"rib":[{"name":"ipv4"}]}`), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tr := New() + tr.Set("k", json.RawMessage(tc.existing)) + tr.Merge("k", json.RawMessage(tc.partial)) + + var got map[string]json.RawMessage + if err := json.Unmarshal(tr.Get("k"), &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + for field, wantVal := range tc.want { + gotVal, ok := got[field] + if !ok { + t.Fatalf("missing field %q", field) + } + if string(gotVal) != string(wantVal) { + t.Fatalf("field %q: got %s, want %s", field, gotVal, wantVal) + } + } + if len(got) != len(tc.want) { + t.Fatalf("got %d fields, want %d", len(got), len(tc.want)) + } + }) + } +} + +func TestMergeIntoEmpty(t *testing.T) { + tr := New() + tr.Merge("new-key", json.RawMessage(`{"x":1}`)) + got := tr.Get("new-key") + if string(got) != `{"x":1}` { + t.Fatalf("expected {\"x\":1}, got %s", got) + } +} + +func TestMergeNonObjectFallback(t *testing.T) { + tr := New() + tr.Set("k", json.RawMessage(`{"a":"1"}`)) + tr.Merge("k", json.RawMessage(`"plain string"`)) + got := tr.Get("k") + if string(got) != `"plain string"` { + t.Fatalf("expected plain string fallback, got %s", got) + } +} + +func TestDelete(t *testing.T) { + tr := New() + tr.Set("k", json.RawMessage(`{"data":true}`)) + tr.Delete("k") + if got := tr.Get("k"); got != nil { + t.Fatalf("expected nil after delete, got %s", got) + } +} + +func TestDeleteMissing(t *testing.T) { + tr := New() + tr.Delete("nonexistent") +} + +func TestGetWithProvider(t *testing.T) { + tr := New() + tr.Set("k", json.RawMessage(`{"cached":"yes","kept":"ok"}`)) + tr.RegisterProvider("k", func() json.RawMessage { + return json.RawMessage(`{"live":"data","cached":"overridden"}`) + }) + + got := tr.Get("k") + var m map[string]string + if err := json.Unmarshal(got, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m["live"] != "data" { + t.Fatalf("expected live=data, got %q", m["live"]) + } + if m["cached"] != "overridden" { + t.Fatalf("expected provider to override cached field, got %q", m["cached"]) + } + if m["kept"] != "ok" { + t.Fatalf("expected kept=ok preserved, got %q", m["kept"]) + } +} + +func TestGetWithProviderDoesNotMutateCache(t *testing.T) { + tr := New() + tr.Set("k", json.RawMessage(`{"a":"1"}`)) + tr.RegisterProvider("k", func() json.RawMessage { + return json.RawMessage(`{"b":"2"}`) + }) + + tr.Get("k") + + // Read the raw cached entry — remove the provider to bypass merge. + tr.RegisterProvider("k", nil) + raw := tr.Get("k") + if string(raw) != `{"a":"1"}` { + t.Fatalf("cache was mutated: %s", raw) + } +} + +func TestGetMultiWithProvider(t *testing.T) { + tr := New() + tr.Set("a", json.RawMessage(`{"x":"1"}`)) + tr.Set("b", json.RawMessage(`{"y":"2"}`)) + tr.RegisterProvider("a", func() json.RawMessage { + return json.RawMessage(`{"live":"yes"}`) + }) + + results := tr.GetMulti([]string{"a", "b"}) + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + + var m map[string]string + json.Unmarshal(results[0], &m) + if m["live"] != "yes" || m["x"] != "1" { + t.Fatalf("provider not applied to first result: %s", results[0]) + } + + // b has no provider — should return as-is + if string(results[1]) != `{"y":"2"}` { + t.Fatalf("unexpected second result: %s", results[1]) + } +} + +func TestGetWithProviderEmptyOverlay(t *testing.T) { + tr := New() + tr.Set("k", json.RawMessage(`{"a":"1"}`)) + tr.RegisterProvider("k", func() json.RawMessage { + return nil + }) + + got := tr.Get("k") + if string(got) != `{"a":"1"}` { + t.Fatalf("nil overlay should return base, got %s", got) + } +} + +func TestGetWithProviderNoBaseData(t *testing.T) { + tr := New() + tr.Set("k", json.RawMessage(nil)) + tr.RegisterProvider("k", func() json.RawMessage { + return json.RawMessage(`{"live":"yes"}`) + }) + + got := tr.Get("k") + if string(got) != `{"live":"yes"}` { + t.Fatalf("expected overlay to win with empty base, got %s", got) + } +} + +func TestConcurrentMerge(t *testing.T) { + tr := New() + tr.Set("shared", json.RawMessage(`{}`)) + + var wg sync.WaitGroup + const N = 50 + for i := 0; i < N; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + tr.Merge("shared", json.RawMessage(`{"f`+string(rune('a'+i%26))+`":true}`)) + }(i) + } + wg.Wait() + + got := tr.Get("shared") + if got == nil { + t.Fatal("expected non-nil after concurrent merges") + } +} diff --git a/src/yangerd/internal/zapiwatcher/zapiwatcher.go b/src/yangerd/internal/zapiwatcher/zapiwatcher.go new file mode 100644 index 00000000..9850a6d7 --- /dev/null +++ b/src/yangerd/internal/zapiwatcher/zapiwatcher.go @@ -0,0 +1,354 @@ +// Package zapiwatcher subscribes to FRR zebra zserv route redistribution +// events and mirrors routing data into the operational YANG tree. +package zapiwatcher + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "math" + "net" + "strconv" + "strings" + "sync" + "time" + + "github.com/kernelkit/infix/src/yangerd/internal/tree" + "github.com/osrg/gobgp/v3/pkg/log" + "github.com/osrg/gobgp/v3/pkg/zebra" +) + +const ( + zapiSocketPath = "/var/run/frr/zserv.api" + zapiVersion = 6 + zapiSoftware = "frr10.5" + + reconnectInitial = 100 * time.Millisecond + reconnectMax = 30 * time.Second + reconnectFactor = 2.0 +) + +const ( + RouteKernel zebra.RouteType = 1 + RouteConnect zebra.RouteType = 2 + RouteStatic zebra.RouteType = 3 + RouteRIP zebra.RouteType = 4 + RouteOSPF zebra.RouteType = 6 +) + +var subscribeTypes = []zebra.RouteType{ + RouteKernel, + RouteConnect, + RouteStatic, + RouteRIP, + RouteOSPF, +} + +var routeTypeToProtocol = map[zebra.RouteType]string{ + RouteKernel: "infix-routing:kernel", + RouteConnect: "ietf-routing:direct", + RouteStatic: "ietf-routing:static", + RouteOSPF: "ietf-ospf:ospfv2", + RouteRIP: "ietf-rip:ripv2", +} + +// ZAPIWatcher keeps the routing subtree in sync with zebra route updates. +type ZAPIWatcher struct { + tree *tree.Tree + log *slog.Logger + mu sync.Mutex + routes map[string]json.RawMessage +} + +const routingTreeKey = "ietf-routing:routing" + +// slogAdapter wraps slog.Logger to implement gobgp v3 log.Logger interface. +type slogAdapter struct { + l *slog.Logger +} + +func (a *slogAdapter) Panic(msg string, fields log.Fields) { a.l.Error(msg, toAttrs(fields)...) } +func (a *slogAdapter) Fatal(msg string, fields log.Fields) { a.l.Error(msg, toAttrs(fields)...) } +func (a *slogAdapter) Error(msg string, fields log.Fields) { a.l.Error(msg, toAttrs(fields)...) } +func (a *slogAdapter) Warn(msg string, fields log.Fields) { a.l.Warn(msg, toAttrs(fields)...) } +func (a *slogAdapter) Info(msg string, fields log.Fields) { a.l.Info(msg, toAttrs(fields)...) } +func (a *slogAdapter) Debug(msg string, fields log.Fields) { a.l.Debug(msg, toAttrs(fields)...) } +func (a *slogAdapter) SetLevel(level log.LogLevel) {} +func (a *slogAdapter) GetLevel() log.LogLevel { return log.LogLevel(0) } + +func toAttrs(fields log.Fields) []any { + attrs := make([]any, 0, len(fields)*2) + for k, v := range fields { + attrs = append(attrs, k, v) + } + return attrs +} + +// New creates a ZAPIWatcher bound to the provided operational tree and logger. +func New(t *tree.Tree, log *slog.Logger) *ZAPIWatcher { + if log == nil { + log = slog.Default() + } + return &ZAPIWatcher{tree: t, log: log, routes: make(map[string]json.RawMessage)} +} + +// Run starts the zebra watcher loop with exponential reconnect backoff. +// +// On successful connection it processes incoming route messages until +// disconnect or context cancellation. On disconnect, cached routes are cleared. +func (w *ZAPIWatcher) Run(ctx context.Context) error { + delay := reconnectInitial + + for { + cli, err := w.connect(ctx) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + + w.log.Warn("zapi watcher: connect failed", "err", err, "delay", delay) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + delay = time.Duration(math.Min(float64(delay)*reconnectFactor, float64(reconnectMax))) + continue + } + + delay = reconnectInitial + w.log.Info("zapi watcher: connected", "socket", zapiSocketPath, "version", zapiVersion, "software", zapiSoftware) + + err = w.processMessages(ctx, cli) + if ctx.Err() != nil { + return ctx.Err() + } + + w.log.Warn("zapi watcher: disconnected", "err", err) + w.clearAllRoutes() + } +} + +func (w *ZAPIWatcher) connect(ctx context.Context) (*zebra.Client, error) { + d := net.Dialer{} + conn, err := d.DialContext(ctx, "unix", zapiSocketPath) + if err != nil { + return nil, fmt.Errorf("dial zserv socket: %w", err) + } + _ = conn.Close() + + software := zebra.NewSoftware(zapiVersion, zapiSoftware) + zebra.MaxSoftware = software + + cli, err := zebra.NewClient(&slogAdapter{l: w.log}, "unix", zapiSocketPath, zebra.RouteType(0), zapiVersion, software, 0) + if err != nil { + return nil, fmt.Errorf("new zapi client: %w", err) + } + + cli.SendHello() + cli.SendRouterIDAdd() + for _, typ := range subscribeTypes { + cli.SendRedistribute(typ, zebra.DefaultVrf) + } + + return cli, nil +} + +func (w *ZAPIWatcher) processMessages(ctx context.Context, cli *zebra.Client) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case msg, ok := <-cli.Receive(): + if !ok { + return errors.New("zapi receive channel closed") + } + if msg == nil { + continue + } + w.handleMessage(msg) + } + } +} + +func (w *ZAPIWatcher) handleMessage(msg *zebra.Message) { + ipr, ok := msg.Body.(*zebra.IPRouteBody) + if !ok || ipr == nil { + return + } + + cmd := msg.Header.Command.ToCommon(zapiVersion, zebra.NewSoftware(zapiVersion, zapiSoftware)) + switch cmd { + case zebra.RedistributeRouteAdd: + w.addRoute(ipr) + case zebra.RedistributeRouteDel: + w.deleteRoute(ipr) + } +} + +func (w *ZAPIWatcher) addRoute(route *zebra.IPRouteBody) { + pfx, ok := ipNetFromPrefix(route.Prefix) + if !ok { + w.log.Debug("zapi watcher: ignore route add with invalid prefix") + return + } + rib := ribName(pfx) + key := rib + ":" + pfx.String() + ":" + routeProtocol(route.Type) + + w.mu.Lock() + w.routes[key] = transformRoute(route) + w.mu.Unlock() + + w.writeRibs() +} + +func (w *ZAPIWatcher) deleteRoute(route *zebra.IPRouteBody) { + pfx, ok := ipNetFromPrefix(route.Prefix) + if !ok { + return + } + rib := ribName(pfx) + key := rib + ":" + pfx.String() + ":" + routeProtocol(route.Type) + + w.mu.Lock() + delete(w.routes, key) + w.mu.Unlock() + + w.writeRibs() +} + +func (w *ZAPIWatcher) clearAllRoutes() { + w.mu.Lock() + w.routes = make(map[string]json.RawMessage) + w.mu.Unlock() + + w.writeRibs() +} + +func (w *ZAPIWatcher) writeRibs() { + w.mu.Lock() + + ipv4Routes := make([]json.RawMessage, 0) + ipv6Routes := make([]json.RawMessage, 0) + + for key, routeData := range w.routes { + if strings.HasPrefix(key, "ipv4-master:") { + ipv4Routes = append(ipv4Routes, routeData) + } else { + ipv6Routes = append(ipv6Routes, routeData) + } + } + w.mu.Unlock() + + ribs := map[string]any{ + "rib": []map[string]any{ + { + "name": "ipv4-master", + "address-family": "ietf-routing:ipv4", + "routes": map[string]any{ + "route": ipv4Routes, + }, + }, + { + "name": "ipv6-master", + "address-family": "ietf-routing:ipv6", + "routes": map[string]any{ + "route": ipv6Routes, + }, + }, + }, + } + + ribsJSON, err := json.Marshal(map[string]any{"ribs": ribs}) + if err != nil { + w.log.Error("zapi watcher: marshal ribs", "err", err) + return + } + + w.tree.Merge(routingTreeKey, ribsJSON) +} + +func ribName(prefix net.IPNet) string { + if prefix.IP.To4() != nil { + return "ipv4-master" + } + return "ipv6-master" +} + +func transformRoute(route *zebra.IPRouteBody) json.RawMessage { + pfx, ok := ipNetFromPrefix(route.Prefix) + if !ok { + return json.RawMessage(`{}`) + } + + nextHops := make([]map[string]any, 0, len(route.Nexthops)) + for _, nh := range route.Nexthops { + hop := make(map[string]any) + + if len(nh.Gate) > 0 && !nh.Gate.IsUnspecified() { + hop["next-hop-address"] = nh.Gate.String() + } + + if nh.Ifindex > 0 { + ifi, err := net.InterfaceByIndex(int(nh.Ifindex)) + if err == nil && ifi != nil && ifi.Name != "" { + hop["outgoing-interface"] = ifi.Name + } else { + hop["outgoing-interface"] = strconv.FormatUint(uint64(nh.Ifindex), 10) + } + } + + if len(hop) > 0 { + nextHops = append(nextHops, hop) + } + } + + routeNode := map[string]any{ + "destination-prefix": pfx.String(), + "source-protocol": routeProtocol(route.Type), + "metric": route.Metric, + "next-hop-list": map[string]any{ + "next-hop": nextHops, + }, + } + + encoded, err := json.Marshal(routeNode) + if err != nil { + return json.RawMessage(`{}`) + } + + return json.RawMessage(encoded) +} + +func routeProtocol(rt zebra.RouteType) string { + if protocol, ok := routeTypeToProtocol[rt]; ok { + return protocol + } + return "infix-routing:kernel" +} + +func ipNetFromPrefix(prefix zebra.Prefix) (net.IPNet, bool) { + ip := prefix.Prefix + if len(ip) == 0 { + return net.IPNet{}, false + } + + bits := 128 + if v4 := ip.To4(); v4 != nil { + ip = v4 + bits = 32 + } + + prefixLen := int(prefix.PrefixLen) + if prefixLen < 0 { + prefixLen = 0 + } + if prefixLen > bits { + prefixLen = bits + } + + mask := net.CIDRMask(prefixLen, bits) + return net.IPNet{IP: ip.Mask(mask), Mask: mask}, true +} diff --git a/src/yangerd/internal/zapiwatcher/zapiwatcher_test.go b/src/yangerd/internal/zapiwatcher/zapiwatcher_test.go new file mode 100644 index 00000000..78f85942 --- /dev/null +++ b/src/yangerd/internal/zapiwatcher/zapiwatcher_test.go @@ -0,0 +1,185 @@ +package zapiwatcher + +import ( + "encoding/json" + "net" + "testing" + + "github.com/osrg/gobgp/v3/pkg/zebra" +) + +func TestRouteProtocol(t *testing.T) { + tests := []struct { + name string + rt zebra.RouteType + expected string + }{ + {"kernel", RouteKernel, "infix-routing:kernel"}, + {"connect", RouteConnect, "ietf-routing:direct"}, + {"static", RouteStatic, "ietf-routing:static"}, + {"ospf", RouteOSPF, "ietf-ospf:ospfv2"}, + {"rip", RouteRIP, "ietf-rip:ripv2"}, + {"unknown defaults to kernel", zebra.RouteType(99), "infix-routing:kernel"}, + {"zero defaults to kernel", zebra.RouteType(0), "infix-routing:kernel"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := routeProtocol(tc.rt) + if got != tc.expected { + t.Errorf("routeProtocol(%d) = %q, want %q", tc.rt, got, tc.expected) + } + }) + } +} + +func TestRibName(t *testing.T) { + tests := []struct { + name string + prefix net.IPNet + expected string + }{ + { + "ipv4", + net.IPNet{IP: net.ParseIP("10.0.0.0").To4(), Mask: net.CIDRMask(24, 32)}, + "ipv4-master", + }, + { + "ipv6", + net.IPNet{IP: net.ParseIP("2001:db8::"), Mask: net.CIDRMask(64, 128)}, + "ipv6-master", + }, + { + "loopback v4", + net.IPNet{IP: net.ParseIP("127.0.0.0").To4(), Mask: net.CIDRMask(8, 32)}, + "ipv4-master", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ribName(tc.prefix) + if got != tc.expected { + t.Errorf("ribName(%v) = %q, want %q", tc.prefix, got, tc.expected) + } + }) + } +} + +func TestTransformRoute(t *testing.T) { + route := &zebra.IPRouteBody{ + Type: RouteStatic, + Metric: 100, + } + route.Prefix.Prefix = net.ParseIP("10.0.0.0") + route.Prefix.PrefixLen = 24 + + result := transformRoute(route) + + var parsed map[string]any + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("unmarshal transformRoute result: %v", err) + } + + if got := parsed["destination-prefix"]; got != "10.0.0.0/24" { + t.Errorf("destination-prefix = %v, want %q", got, "10.0.0.0/24") + } + + if got := parsed["source-protocol"]; got != "ietf-routing:static" { + t.Errorf("source-protocol = %v, want %q", got, "ietf-routing:static") + } + + if got, ok := parsed["metric"].(float64); !ok || int(got) != 100 { + t.Errorf("metric = %v, want 100", parsed["metric"]) + } + + nhList, ok := parsed["next-hop-list"].(map[string]any) + if !ok { + t.Fatalf("next-hop-list not a map: %T", parsed["next-hop-list"]) + } + hops, ok := nhList["next-hop"].([]any) + if !ok { + t.Fatalf("next-hop not an array: %T", nhList["next-hop"]) + } + if len(hops) != 0 { + t.Errorf("expected 0 next-hops, got %d", len(hops)) + } +} + +func TestTransformRouteIPv6(t *testing.T) { + route := &zebra.IPRouteBody{ + Type: RouteOSPF, + Metric: 10, + } + route.Prefix.Prefix = net.ParseIP("2001:db8::") + route.Prefix.PrefixLen = 48 + + result := transformRoute(route) + + var parsed map[string]any + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got := parsed["destination-prefix"]; got != "2001:db8::/48" { + t.Errorf("destination-prefix = %v, want %q", got, "2001:db8::/48") + } + if got := parsed["source-protocol"]; got != "ietf-ospf:ospfv2" { + t.Errorf("source-protocol = %v, want %q", got, "ietf-ospf:ospfv2") + } +} + +func TestIpNetFromPrefix(t *testing.T) { + tests := []struct { + name string + prefix zebra.Prefix + wantCIDR string + wantOK bool + }{ + { + "valid ipv4", + zebra.Prefix{Prefix: net.ParseIP("192.168.1.0"), PrefixLen: 24}, + "192.168.1.0/24", + true, + }, + { + "valid ipv6", + zebra.Prefix{Prefix: net.ParseIP("2001:db8::"), PrefixLen: 64}, + "2001:db8::/64", + true, + }, + { + "host route ipv4", + zebra.Prefix{Prefix: net.ParseIP("10.0.0.1"), PrefixLen: 32}, + "10.0.0.1/32", + true, + }, + { + "zero prefix length", + zebra.Prefix{Prefix: net.ParseIP("0.0.0.0"), PrefixLen: 0}, + "0.0.0.0/0", + true, + }, + { + "prefix masking applied", + zebra.Prefix{Prefix: net.ParseIP("10.0.0.5"), PrefixLen: 24}, + "10.0.0.0/24", + true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := ipNetFromPrefix(tc.prefix) + if ok != tc.wantOK { + t.Fatalf("ipNetFromPrefix ok = %v, want %v", ok, tc.wantOK) + } + if !ok { + return + } + if got.String() != tc.wantCIDR { + t.Errorf("ipNetFromPrefix = %q, want %q", got.String(), tc.wantCIDR) + } + }) + } +} diff --git a/src/yangerd/plan.md b/src/yangerd/plan.md new file mode 100644 index 00000000..cdc8ba57 --- /dev/null +++ b/src/yangerd/plan.md @@ -0,0 +1,687 @@ +# yangerd Phase 1: Core + ietf-system + +**Status**: Complete +**Started**: 2026-03-28 +**Design doc**: `src/statd/doc/yangerd-design.md` + +## Overview + +Build the yangerd Go daemon core infrastructure alongside the simplest +module (`ietf-system`). This establishes the project skeleton, in-memory +tree, IPC server, collector framework, and Buildroot packaging. All +subsequent modules build on this foundation. + +## Constraints + +- No CGo (hard, non-negotiable — design doc Section 2.3) +- Not a sysrepo plugin — yangerd has no sysrepo dependency +- `uint64`/`int64`/`decimal64` YANG types serialized as JSON strings (RFC 7951 / libyang) +- Socket owned by `root:yangerd`, permissions `0660` +- Sources at `src/yangerd/`, module path `github.com/kernelkit/infix/src/yangerd` +- Buildroot: vendored deps (`GOFLAGS=-mod=vendor`), `$(eval $(golang-package))` +- NTP handled by separate `internal/collector/ntp.go` — excluded from system.go +- Ignore gRPC for FRR + +## Steps + +### Step 1: Project scaffolding +- **Status**: ✅ complete +- `src/yangerd/go.mod` — `module github.com/kernelkit/infix/src/yangerd`, go 1.21 +- `cmd/yangerd/main.go` — daemon entry: config → socket → tree → collectors → ready → signal → IPC serve +- `cmd/yangerctl/main.go` — CLI: get/health/dump subcommands with --socket/--timeout flags +- Directory tree per design doc Section 6 + +### Step 2: Core tree (`internal/tree/tree.go`) +- **Status**: ✅ complete +- `modelEntry` struct: `sync.RWMutex`, `json.RawMessage`, `time.Time` +- `Tree` struct: top-level `sync.RWMutex` protecting `map[string]*modelEntry` +- Methods: `New()`, `Set()`, `Get()`, `GetMulti()`, `Keys()`, `Info()` +- Double-checked locking in `Set()` for new keys +- Eventual consistency in `GetMulti()` (per-model read locks, not snapshot) + +### Step 3: IPC protocol + server +- **Status**: ✅ complete +- `internal/ipc/protocol.go` — framing (ver:1 + uint32 BE length + JSON), Request/Response types +- `internal/ipc/server.go` — Unix socket listener, connection handler, method routing, ready flag +- `internal/ipc/client.go` — Dial/query helper for yangerctl + +### Step 4: Collector framework +- **Status**: ✅ complete +- `internal/collector/collector.go` — Collector interface + RunAll() scheduling +- `internal/collector/runner.go` — CommandRunner + FileReader interfaces + production impls + +### Step 5: System collector (`internal/collector/system.go`) +- **Status**: ✅ complete +- ~658 lines implementing full ietf-system data collection +- Produces: `ietf-system:system` and `ietf-system:system-state` tree keys +- Sub-methods: addHostname, addTimezone, addUsers, addPlatform, addClock, + addSoftware, addSoftwareSlots, getBootOrder, addDNS, addServices, addResourceUsage, + addMemory, addLoadAvg, addFilesystems + +### Step 6: Config (`internal/config/config.go`) +- **Status**: ✅ complete +- Env var parsing: YANGERD_SOCKET, LOG_LEVEL, STARTUP_TIMEOUT, POLL_INTERVAL_SYSTEM, + ENABLE_WIFI/CONTAINERS/GPS + +### Step 7: Wire main.go startup +- **Status**: ✅ complete +- Config → socket → tree → collectors → ready → signal handling → IPC serve + +### Step 8: yangerctl (`cmd/yangerctl/main.go`) +- **Status**: ✅ complete +- Subcommands: get, health, dump with --socket/--timeout flags + +### Step 9: Tests +- **Status**: ✅ complete +- `internal/tree/tree_test.go` — Set/Get/GetMulti/Keys/Info + concurrent access (5 tests) +- `internal/ipc/protocol_test.go` — framing round-trip, version mismatch, oversized payload (3 tests) +- `internal/ipc/server_test.go` — get/dump/health/notReady/unknownMethod (5 tests) +- `internal/collector/system_test.go` — 17 tests covering all data sources, edge cases, failures +- `internal/testutil/mock.go` — reusable MockRunner + MockFileReader +- All tests pass with `-race` flag + +### Step 10: Buildroot packaging +- **Status**: ✅ complete +- `package/yangerd/yangerd.mk` — golang-package recipe with build targets + feature flags +- `package/yangerd/Config.in` — Kconfig entry with Go arch dependency +- `package/yangerd/yangerd.svc` — Finit service definition +- `package/Config.in` — source entry added + +## Bug fixes found during testing + +- **`fmt.Sprintf("%v", float64)` producing scientific notation**: In `addServices()`, JSON-decoded + float64 values like `4096000.0` were formatted as `"4.096e+06"` instead of `"4096000"`. Fixed by + using `strconv.Itoa(toInt(...))` for uint64 string encoding. Same fix applied to slot size in + `addSoftwareSlots()`. + +## File inventory + +### Production code (12 files) +| File | Lines | Description | +|------|-------|-------------| +| `go.mod` | 3 | Module definition | +| `internal/config/config.go` | ~85 | Environment variable parsing | +| `internal/tree/tree.go` | 119 | Per-model locked tree store | +| `internal/ipc/protocol.go` | ~120 | IPC framing + request/response types | +| `internal/ipc/server.go` | ~150 | Unix socket server | +| `internal/ipc/client.go` | ~80 | Client helper for yangerctl | +| `internal/collector/collector.go` | 51 | Collector interface + RunAll scheduler | +| `internal/collector/runner.go` | 40 | CommandRunner/FileReader interfaces | +| `internal/collector/system.go` | 658 | System data collector | +| `cmd/yangerd/main.go` | ~100 | Daemon entry point | +| `cmd/yangerctl/main.go` | ~120 | CLI diagnostic tool | + +### Test code (5 files) +| File | Tests | Description | +|------|-------|-------------| +| `internal/tree/tree_test.go` | 5 | Tree operations + concurrency | +| `internal/ipc/protocol_test.go` | 3 | Framing protocol | +| `internal/ipc/server_test.go` | 5 | IPC server end-to-end | +| `internal/collector/system_test.go` | 17 | System collector with mocks | +| `internal/testutil/mock.go` | — | Shared test mocks | + +### Buildroot packaging (4 files) +| File | Description | +|------|-------------| +| `package/yangerd/yangerd.mk` | Buildroot Go package recipe | +| `package/yangerd/Config.in` | Kconfig menu entry | +| `package/yangerd/yangerd.svc` | Finit service definition | +| `package/Config.in` | Updated: added yangerd source | + +## Reference files + +| File | Purpose | +|------|---------| +| `src/statd/doc/yangerd-design.md` | Authoritative design document | +| `src/statd/python/yanger/ietf_system.py` | Python reference impl (461 lines) | +| `src/confd/yang/confd/ietf-system@2014-08-06.yang` | Standard YANG model | +| `src/confd/yang/confd/infix-system.yang` | Infix augmentations | +| `src/confd/yang/confd/infix-system-software.yang` | Software submodule | +| `src/netbrowse/` | Existing Go project (pattern reference) | +| `package/netbrowse/netbrowse.mk` | Buildroot Go package template | + +## Notes + +- NTP data is handled by `internal/collector/ntp.go` (Phase 2), not system.go +- DNS statistics (cache-size, cache-hits, cache-misses from infix-system.yang) not in current Python impl +- `addFilesystems` uses `syscall.Statfs()` directly (not mockable via FileReader); not unit-tested +- `addHostname` uses `os.Hostname()` directly; not unit-tested in isolation +- `addTimezone` uses `filepath.EvalSymlinks()` directly; not unit-tested in isolation + +--- + +# yangerd Phase 2: Polling Collectors + +**Status**: Complete +**Started**: 2026-03-28 +**Completed**: 2026-03-29 + +## Overview + +Implement all polling-based collectors that replace the Python yanger +scripts. Reactive collectors (netlink/interfaces, ZAPI/routing-table, +LLDP, D-Bus/DHCP/firewall) are deferred to Phase 3. + +## Collectors implemented + +### RoutingCollector (`routing.go`, ~748 lines) +- **Tree key**: `ietf-routing:routing` +- **Interval**: 10s (configurable: `YANGERD_POLL_INTERVAL_ROUTING`) +- Merges OSPF, RIP, and BFD into a single `control-plane-protocols` list +- OSPF: `ospf-status` helper + `vtysh show ip ospf json` for areas, interfaces, neighbors, routes +- RIP: `vtysh -c 'show ip rip status'` text parsing + `show ip route rip json` +- BFD: `vtysh -c 'show bfd peers json'`, filters out multihop sessions + +### NTPCollector (`ntp.go`, ~434 lines) +- **Tree key**: `ietf-ntp:ntp` +- **Interval**: 60s (configurable: `YANGERD_POLL_INTERVAL_NTP`) +- Parses `chronyc -c` CSV output: sources, sourcestats, tracking, serverstats +- Detects NTP listening port via `ss -ulnp` +- Infix augmentations: clock-state frequency/offset details + +### HardwareCollector (`hardware.go`, ~1122 lines) +- **Tree key**: `ietf-hardware:hardware` +- **Interval**: 10s (configurable: `YANGERD_POLL_INTERVAL_HARDWARE`) +- Motherboard from `/run/system.json` +- VPD components with vendor extensions +- USB port components with lock/unlock state +- hwmon sensors: temp, fan, PWM, voltage, current, power with parent/child relationships +- Thermal zones from `/sys/class/thermal/` +- WiFi radios via `iw.py` (gated by `YANGERD_ENABLE_WIFI`) +- GPS receivers via gpsd TCP (gated by `YANGERD_ENABLE_GPS`) + +### ContainerCollector (`containers.go`, ~466 lines) +- **Tree key**: `infix-containers:containers` +- **Interval**: 10s (configurable: `YANGERD_POLL_INTERVAL_CONTAINERS`) +- Feature-gated by `YANGERD_ENABLE_CONTAINERS` +- Podman ps/inspect/stats integration +- Cgroup v2 resource limits (memory.max, cpu.max) +- Network info (host/bridge + port publishing) +- Resource usage stats (memory, CPU, block I/O, net I/O, PIDs) + +## Bug fixes + +- **NTP `ss` parsing**: `addServerStatus()` used `fields[4]` for local address + in `ss -ulnp` output, but `strings.Fields` puts local address at index 3. + Fixed to `fields[3]`. + +## Test summary + +| File | Tests | Status | +|------|-------|--------| +| `routing_test.go` | 16 | ✅ pass | +| `ntp_test.go` | 10 | ✅ pass | +| `hardware_test.go` | 10 | ✅ pass | +| `containers_test.go` | 12 | ✅ pass | +| **Phase 2 total** | **48** | **✅ all pass** | +| **Overall total** | **65** | **✅ all pass** | + +## File inventory (Phase 2 additions) + +### Production code (4 files) +| File | Lines | Description | +|------|-------|-------------| +| `internal/collector/routing.go` | 748 | OSPF+RIP+BFD routing collector | +| `internal/collector/ntp.go` | 434 | NTP collector (chronyc) | +| `internal/collector/hardware.go` | 1122 | Hardware/sensor collector | +| `internal/collector/containers.go` | 466 | Container collector (podman) | + +### Test code (4 files) +| File | Tests | Description | +|------|-------|-------------| +| `internal/collector/routing_test.go` | 16 | Routing protocol tests | +| `internal/collector/ntp_test.go` | 10 | NTP chronyc parsing tests | +| `internal/collector/hardware_test.go` | 10 | Sensor/VPD/thermal tests | +| `internal/collector/containers_test.go` | 12 | Podman/cgroup parsing tests | + +### Modified files +| File | Change | +|------|--------| +| `internal/config/config.go` | Added PollRouting/NTP/Hardware/Containers fields | +| `cmd/yangerd/main.go` | Registered all Phase 2 collectors | + +--- + +# yangerd Phase 3: Reactive/Event-Driven Collectors + +**Status**: Complete +**Started**: 2026-03-29 +**Completed**: 2026-03-29 + +## Overview + +Reactive collectors replace polling with persistent subscriptions (netlink, +ZAPI, D-Bus signals, subprocess watchers). They implement a +`Run(ctx context.Context) error` goroutine pattern instead of the polling +`Collector` interface. + +## Packages implemented + +### Foundation infrastructure + +| Package | File | Lines | Description | +|---------|------|-------|-------------| +| `ipbatch` | `internal/ipbatch/ipbatch.go` | 212 | Persistent `ip -json` subprocess with mutex-serialized Query(), exponential backoff restart (100ms→30s), canary queries, 4MiB scanner | +| `bridgebatch` | `internal/bridgebatch/bridgebatch.go` | 201 | Persistent `bridge -json` subprocess, same pattern as ipbatch | +| `fswatcher` | `internal/fswatcher/fswatcher.go` | 176 | Inotify watcher with per-path debounce, glob expansion, initial read | + +### Reactive monitors + +| Package | File | Lines | Description | +|---------|------|-------|-------------| +| `monitor` | `internal/monitor/monitor.go` | 509 | NLMonitor — netlink link/addr/neigh/MDB subscription, stores raw ip-json at sub-paths | +| `ethmonitor` | `internal/ethmonitor/ethmonitor.go` | 223 | EthMonitor — ethtool genetlink for speed/duplex/auto-negotiation | +| `iwmonitor` | `internal/iwmonitor/iwmonitor.go` | 311 | IWMonitor — `iw event -t` parser + station dump/info queries | +| `lldpmonitor` | `internal/lldpmonitor/lldpmonitor.go` | 315 | LLDPMonitor — `lldpcli json0 watch` for neighbor discovery | +| `zapiwatcher` | `internal/zapiwatcher/zapiwatcher.go` | 305 | ZAPIWatcher — FRR ZAPI v6 route redistribution | +| `dbusmonitor` | `internal/dbusmonitor/dbusmonitor.go` | 1020 | DBusMonitor — dnsmasq lease events + firewalld Reloaded/NameOwnerChanged | + +### Transformer + +| Package | File | Lines | Description | +|---------|------|-------|-------------| +| `iface` | `internal/iface/iface.go` | 813 | Pure interface transformer: raw `ip -json` → YANG JSON (type mapping, oper-state, counters, IPv4/IPv6, VLAN/VETH/GRE/VXLAN/LAG/bridge augments) | + +## External dependencies added + +- `github.com/fsnotify/fsnotify` v1.9.0 +- `github.com/vishvananda/netlink` +- `github.com/mdlayher/genetlink` + `github.com/mdlayher/ethtool` +- `github.com/osrg/gobgp/v4/pkg/zebra` +- `github.com/godbus/dbus/v5` + +## Key design decisions + +- **Event-as-trigger pattern**: Most reactive sources use events solely as triggers + and then re-read canonical data (e.g., netlink notification triggers `ip -json link show`) +- **Persistent subprocess managers** (IPBatch/BridgeBatch): Mutex-serialized Query(), + dead/alive atomic state, ErrBatchDead sentinel error, exponential backoff restart +- **Iface transformer is pure**: Takes raw `ip -json` arrays, returns YANG JSON. + Uses `FileChecker` interface for IPv6 MTU and WiFi detection +- **Interface filtering**: Skip `group=="internal"` or `link_type` in `("can","vcan")` +- **RFC 7951 compliance**: Counter values (uint64) encoded as JSON strings + +## Test summary + +| File | Tests | Status | +|------|-------|--------| +| `internal/iface/iface_test.go` | 20+ | ✅ pass | +| `internal/iwmonitor/iwmonitor_test.go` | 8+ | ✅ pass | +| `internal/lldpmonitor/lldpmonitor_test.go` | 8+ | ✅ pass | +| `internal/monitor/monitor_test.go` | 8+ | ✅ pass | +| `internal/dbusmonitor/dbusmonitor_test.go` | 25+ | ✅ pass | +| `internal/zapiwatcher/zapiwatcher_test.go` | 8 | ✅ pass | +| `internal/fswatcher/fswatcher_test.go` | 11 | ✅ pass | +| **Phase 3 total** | **93** | **✅ all pass** | +| **Overall total** | **158** | **✅ all pass with -race** | + +## Modified files + +| File | Change | +|------|--------| +| `internal/config/config.go` | Added reactive config fields (ZAPISocket, DBus paths, WiFi/LLDP enables) | +| `cmd/yangerd/main.go` | All reactive subsystems wired (229 lines total) | + +## Packages without tests (intentional) + +- `ipbatch`, `bridgebatch` — require real subprocesses (`ip`, `bridge`) +- `ethmonitor` — requires genetlink socket (kernel interface) + +--- + +# yangerd Phase 4: Architecture Fix — Transform-on-Write + +**Status**: Complete +**Started**: 2026-03-29 +**Completed**: 2026-03-29 + +## Problem + +NLMonitor stored raw ip-json fragments at per-interface sub-paths +(e.g. `/ietf-interfaces:interfaces/interface[name='eth0']`, +`/addresses`, `/statistics`). These are not valid YANG keys and the +output did not match what Python yanger produces — a single complete +`{"ietf-interfaces:interfaces":{"interface":[...]}}` document. + +The `iface.Transform()` function existed but was never called. +EthMonitor and IWMonitor also stored at fragment paths. + +## Fix: Transform-on-write + +NLMonitor is now the central coordinator. It owns staging data (raw +link/addr/stats arrays) and after every netlink event: + +1. Runs `iface.Transform(links, addrs, stats, fc)` to produce the base + YANG document +2. Merges augment data (ethernet, wifi, bridge FDB/MDB) from staging + maps into the matching interface entries +3. Stores the complete result at a single tree key `ietf-interfaces:interfaces` + +EthMonitor and IWMonitor no longer write to the tree directly. They call +`NLMonitor.SetEthernetData(ifname, data)` and +`NLMonitor.SetWifiData(ifname, data)` which update staging maps and +trigger a rebuild. + +## Changes + +| File | Change | +|------|--------| +| `internal/monitor/monitor.go` | Added staging fields (`links`, `addrs`, `stats`, `fdb`, `mdb`, `ethernet`, `wifi`), `rebuild()` method calling `iface.Transform()` + `mergeAugments()`, `replaceByIfName()` helper, `SetEthernetData()`/`SetWifiData()` public methods. Removed per-interface fragment tree paths. Added `iface.FileChecker` parameter to `New()`. | +| `internal/monitor/monitor_test.go` | Removed `TestPathHelpers` (old fragment paths). Added tests: `TestReplaceByIfName`, `TestReplaceByIfNamePreservesUpdatedData`, `TestMergeAugments`, `TestMergeAugmentsNoOp`, `TestMergeAugmentsInvalidDoc`, `TestTreeKey`. | +| `internal/ethmonitor/ethmonitor.go` | Removed `tree` field and tree import. Added `onUpdate` callback. `New()` no longer takes `*tree.Tree`. `refreshEthernetSettings()` calls `onUpdate(ifname, data)` instead of `tree.Set()`. | +| `internal/iwmonitor/iwmonitor.go` | Removed `tree` field and tree import. Added `onUpdate` callback + `publishWifi()` method. `New()` no longer takes `*tree.Tree`. Assembles combined wifi JSON per interface. | +| `cmd/yangerd/main.go` | Added `osFileChecker` type implementing `iface.FileChecker`. Updated `monitor.New()` call with FileChecker. Wired `ethMon.SetOnUpdate(nlmon.SetEthernetData)` and `iwmon.SetOnUpdate(nlmon.SetWifiData)`. Updated `ethmonitor.New()` and `iwmonitor.New()` signatures. | + +## Test summary + +| Metric | Value | +|--------|-------| +| New tests added | 5 | +| Tests removed | 1 (TestPathHelpers — obsolete fragment paths) | +| **Total tests** | **163** | +| **Status** | **✅ all pass with -race** | + +--- + +# yangerd Phase 5: Buildroot Compatibility Fixes + +**Status**: Complete +**Completed**: 2026-03-29 + +## Problem + +`make yangerd-rebuild` failed: Buildroot ships Go 1.23.12 but go.mod +had `go 1.24.5` (from gobgp/v4's requirement). Two other dependencies +also required go 1.24+. + +## Fixes + +| Change | Before | After | Reason | +|--------|--------|-------|--------| +| `go.mod` go directive | `go 1.24.5` | `go 1.23.0` | Buildroot has Go 1.23.12 | +| `osrg/gobgp` | v4 (go 1.24.5) | v3 v3.37.0 (go 1.23.0) | All v4 releases need 1.24+ | +| `mdlayher/ethtool` | v0.5.1 (go 1.24.0) | v0.4.1 (go 1.23.0) | API-compatible downgrade | +| `golang.org/x/sys` | v0.40.0 (go 1.24.0) | v0.35.0 (go 1.23.0) | Sufficient for all deps | + +### gobgp v3 vs v4 API changes + +- `Nexthop.Gate`: `netip.Addr` (v4) → `net.IP` (v3) +- `Prefix.Prefix`: `netip.Addr` (v4) → `net.IP` (v3) +- `NewClient()`: `*slog.Logger` (v4) → `log.Logger` interface (v3) +- Created `slogAdapter` in zapiwatcher.go to bridge `*slog.Logger` → gobgp v3 `log.Logger` + +### ip batch `-s` flag fix + +`ip -json -force -batch -` does not accept `-s` as a batch line subcommand. +Added `-s -d` as global flags to the persistent batch process args instead. +Removed separate `-s link show` queries and the `stats` staging field. + +## Verification + +- All 163 tests pass with `-mod=vendor` and `-race` +- `go build ./cmd/yangerd` and `go build ./cmd/yangerctl` succeed +- `go vet ./...` clean + +--- + +# yangerd Phase 6: statd C Integration + +**Status**: Complete (code-complete, pending real device testing) +**Completed**: 2026-03-29 + +## Overview + +statd (C daemon) now queries yangerd over Unix socket IPC instead of +fork/exec'ing Python yanger scripts, with automatic fallback to yanger +when yangerd is unavailable. + +## Architecture + +``` +statd (C) ──yangerd_query()──► /run/yangerd.sock ──► yangerd (Go) + │ │ + │ fallback if yangerd unavailable │ tree.Get(key) + ▼ ▼ +ly_add_yanger_data() in-memory Tree store + (fork/exec Python) (per-module JSON blobs) +``` + +## Wire protocol (C↔Go) + +``` +Frame: [ver:1byte=0x01] [length:4bytes big-endian] [JSON body] +Request: {"method":"get","path":"ietf-interfaces:interfaces"} +Response: {"status":"ok","data":{"ietf-interfaces:interfaces":{...}}} +``` + +## Files created + +| File | Lines | Description | +|------|-------|-------------| +| `src/statd/yangerd.h` | 28 | Header: socket path, timeout, max payload, proto version, `yangerd_query()` | +| `src/statd/yangerd.c` | 245 | Full C IPC client: connect, framed I/O, jansson JSON parsing | + +## Files modified + +| File | Change | +|------|--------| +| `src/statd/statd.c` | Added `#include "yangerd.h"`, `ly_add_yangerd_data()` wrapper, `xpath_to_yangerd_path()` helper; updated all 5 sysrepo callbacks | +| `src/statd/Makefile.am` | Added `yangerd.c yangerd.h` to `statd_SOURCES` | + +## Key functions + +- **`yangerd_query(path, &buf, &len)`** — Connect to socket, send framed "get" request, receive framed response, extract "data" JSON field +- **`ly_add_yangerd_data(ctx, parent, path, yanger_args)`** — Try yangerd first; on failure, fall back to `ly_add_yanger_data()` (fork/exec Python) +- **`xpath_to_yangerd_path(xpath, buf, bufsz)`** — Strip leading `/`, take first path segment (maps sysrepo xpath to yangerd tree key) + +## Subscription → yangerd key mapping + +| statd subscription xpath | yangerd tree key | +|---|---| +| `/ietf-interfaces:interfaces` | `ietf-interfaces:interfaces` | +| `/ietf-routing:routing/ribs` | `ietf-routing:routing` | +| `/ietf-hardware:hardware` | `ietf-hardware:hardware` | +| `/ietf-system:system` | `ietf-system:system` | +| `/ietf-system:system-state` | `ietf-system:system-state` | +| `/ieee802-dot1ab-lldp:lldp` | `ieee802-dot1ab-lldp:lldp` | +| `/infix-containers:containers` | `infix-containers:containers` | +| `/infix-dhcp-server:dhcp-server` | `infix-dhcp-server:dhcp-server` | +| `/infix-firewall:firewall` | `infix-firewall:firewall` | +| `/ietf-ntp:ntp` | `ietf-ntp:ntp` | +| OSPF/RIP/BFD callbacks | `ietf-routing:routing` (hardcoded) | + +## Remaining + +- ~~Real device testing~~ Done — bugs found and fixed (see Phase 7) +- ~~Verify fallback~~ Removed — no fallback by design +- Performance comparison (optional): yangerd IPC vs fork/exec Python yanger + +--- + +# yangerd Phase 7: Real Device Bug Fixes + +**Status**: Complete (Bugs 1-7) +**Completed**: 2026-03-30 + +## Overview + +Two rounds of `yangerctl dump` on a real Infix x86_64 device exposed +seven bugs across four categories. All seven are now fixed. + +## Bug 1: Double-wrapping (FIXED) + +### Problem + +The IPC server's `handleGet()` wraps stored data in `{key: data}`, but +some collectors ALSO stored their data pre-wrapped. This caused +double-nesting, e.g. `{"ietf-interfaces:interfaces":{"ietf-interfaces:interfaces":{...}}}`. + +### Contract established + +Collectors store data WITHOUT the module key wrapper. The server adds it. + +### Fixes + +| File | Change | +|------|--------| +| `internal/iface/iface.go` | `Transform()` returns `{"interface":[...]}` (removed outer wrapper) | +| `internal/iface/iface_test.go` | Updated `mustInterfaces()` to parse unwrapped format | +| `internal/monitor/monitor.go` | `mergeAugments()` updated to parse unwrapped format | +| `internal/monitor/monitor_test.go` | `TestMergeAugments`/`TestMergeAugmentsNoOp` updated | +| `internal/dbusmonitor/dbusmonitor.go` | `buildDHCPTree()`/`buildFirewallTree()` removed wrappers | +| `internal/dbusmonitor/dbusmonitor_test.go` | Tests updated for unwrapped format | + +## Bug 2: Fragmented routing tree keys (FIXED) + +### Problem + +`ietf-routing:routing` is a shared tree written by three different +sources — RoutingCollector (control-plane-protocols), ZAPIWatcher (ribs), +and FSWatcher (forwarding interfaces). Each wrote to its own sub-path +key or used `tree.Set()` which overwrote the others' data. + +### Solution: `Tree.Merge()` + +Added a shallow first-level JSON merge method to `Tree` that allows each +writer to merge its fields into the shared key without overwriting +others' data. All three writers now use `tree.Merge("ietf-routing:routing", ...)`. + +### Fixes + +| File | Change | +|------|--------| +| `internal/tree/tree.go` | Added `Merge()` and `Delete()` methods | +| `internal/tree/tree_test.go` | 7 new tests (Merge subtests, empty, non-object, delete, concurrent) | +| `internal/zapiwatcher/zapiwatcher.go` | Refactored: internal `routes` map, builds complete ribs, writes via `Merge()` | +| `internal/zapiwatcher/zapiwatcher_test.go` | Removed obsolete `routePath`/`routeKey` tests | +| `internal/collector/routing.go` | Changed `t.Set()` → `t.Merge()` on line 59 | +| `internal/fswatcher/fswatcher.go` | Added `UseMerge` field to `WatchHandler`; `InitialRead()`/`fireHandler()` respect it | +| `cmd/yangerd/main.go` | Replaced per-file `forwardingTreeKey()` with `forwardingAggregator` that scans all forwarding files and writes a complete `interfaces` list via `Merge()` | + +### Forwarding aggregator + +Replaces the old per-file approach (each `/proc/sys/net/ipv{4,6}/conf/*/forwarding` +file got its own sub-path tree key) with an aggregator that: +1. On any forwarding file change, rescans ALL forwarding files +2. Builds the complete `{"interfaces":{"interface":["e1","e2",...]}}` list +3. Writes via `tree.Merge("ietf-routing:routing", ...)` — coexists with ribs and control-plane-protocols +4. Matches Python yanger `get_routing_interfaces()` output format +5. Uses `forwarding` for IPv4, `force_forwarding` for IPv6 (matching Python behavior) + +## Bug 3: Duplicate interface entries (FIXED) + +### Problem + +When Infix renames `eth0` to `e1`, `ip -json link show` reports both the +old and new names with the same ifindex. Both appeared in the YANG output. + +### Solution: `dedup()` in iface transformer + +Added `dedup()` function that runs before `skipInterface()`. When +multiple entries share the same ifindex, keeps the one with +`operstate=="UP"` (or the first seen if neither is UP). + +### Fixes + +| File | Change | +|------|--------| +| `internal/iface/iface.go` | Added `dedup()` function; `Transform()` calls `dedup(decodeObjects(linkData))` | +| `internal/iface/iface_test.go` | 4 new test cases: UP-over-DOWN, both-DOWN-keeps-first, different-ifindex, zero-ifindex | + +## Verification + +- All tests pass (including new tests for Merge, dedup, and forwarding aggregator) +- `go build ./cmd/yangerd` and `go build ./cmd/yangerctl` succeed +- `go vet ./...` clean + +## Bug 4: Interface removal not handled (FIXED) + +### Problem + +When an interface is removed, its `/proc/sys/net/*/conf/IFNAME/forwarding` +file disappears. The inotify Remove event was only calling `rewatch()`, +not updating the tree — the removed interface stayed in the YANG data. + +### Solution: `handleRemove()` in FSWatcher + +Replaced the old Remove→handleEvent→rewatch sequence with a dedicated +`handleRemove()` method that handles the two handler types differently: + +- **UseMerge handlers** (forwarding aggregator): fires the handler, which + rescans via glob and naturally excludes the removed file +- **Plain handlers**: calls `tree.Delete()` to clear stale data + +After handling, attempts to re-add the inotify watch. If the file is +permanently gone, cleans up the handler and debounce timer entries. + +### Fixes + +| File | Change | +|------|--------| +| `internal/fswatcher/fswatcher.go` | Added `handleRemove()`, removed `rewatch()`, `Run()` dispatches Remove to `handleRemove()` | +| `internal/fswatcher/fswatcher_test.go` | 4 new tests: merge-handler removal, plain-handler removal, unknown path, rewatch-succeeds | + +## Bug 5: Phantom GPS devices (FIXED) + +### Problem + +`gps0`–`gps3` appeared in hardware output even when `/dev/gps*` didn't +exist. `readlink -f` on non-existent paths succeeds (returns the +canonical form), so the loop at `hardware.go:941` never skipped them. + +### Solution + +Added an `ls /dev/gpsN` existence check before the `readlink -f` call, +matching the Python reference (`ietf_hardware.py:727`: `HOST.exists()`). + +### Fixes + +| File | Change | +|------|--------| +| `internal/collector/hardware.go` | Added `ls` existence check before `readlink -f` in GPS loop | +| `internal/collector/hardware_test.go` | `TestHardwareGPSDeviceNotFound` — verifies no phantom GPS when devices missing | + +## Bug 6: `null` JSON arrays instead of `[]` (FIXED) + +### Problem + +Go `nil` slices marshal to `null` in JSON. YANG lists must be arrays, +so libyang rejects `null`. Three locations used `var slice []Type` +(nil) instead of `make([]Type, 0)` (empty array). + +### Fixes + +| File | Line | Change | +|------|------|--------| +| `cmd/yangerd/main.go` | 282 | `var ifnames []string` → `ifnames := make([]string, 0)` | +| `internal/collector/system.go` | 172 | `var users []interface{}` → `users := make([]interface{}, 0)` | +| `internal/collector/system.go` | 422 | `var servers []interface{}` → `servers := make([]interface{}, 0)` | +| `internal/collector/system_test.go` | — | `TestSystemCollectorNoUsersEmptyArray`, `TestSystemCollectorNoDNSEmptyArray` | + +## Bug 7: Firewall data without firewalld running (FIXED) + +### Problem + +`refreshFirewall()` was called on initial D-Bus connect. When firewalld +wasn't on the bus, all D-Bus calls failed but execution continued. +`getFirewallPolicies()` unconditionally appended a hardcoded +"default-drop" policy, producing phantom firewall data. + +### Solution + +Made `getDefaultZone` the gate: if it fails, `refreshFirewall()` returns +early without writing to the tree. This is the minimal fix — the +`NameOwnerChanged` handler already clears the tree when firewalld exits. + +### Fixes + +| File | Change | +|------|--------| +| `internal/dbusmonitor/dbusmonitor.go` | `refreshFirewall()` returns early if `getDefaultZone` fails | + +## Verification + +- All tests pass (173+ tests) +- `go build ./cmd/yangerd` and `go build ./cmd/yangerctl` succeed +- `go vet ./...` clean +- Ready for re-test on real device diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/.cirrus.yml b/src/yangerd/vendor/github.com/fsnotify/fsnotify/.cirrus.yml new file mode 100644 index 00000000..7f257e99 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/.cirrus.yml @@ -0,0 +1,14 @@ +freebsd_task: + name: 'FreeBSD' + freebsd_instance: + image_family: freebsd-14-2 + install_script: + - pkg update -f + - pkg install -y go + test_script: + # run tests as user "cirrus" instead of root + - pw useradd cirrus -m + - chown -R cirrus:cirrus . + - FSNOTIFY_BUFFER=4096 sudo --preserve-env=FSNOTIFY_BUFFER -u cirrus go test -parallel 1 -race ./... + - sudo --preserve-env=FSNOTIFY_BUFFER -u cirrus go test -parallel 1 -race ./... + - FSNOTIFY_DEBUG=1 sudo --preserve-env=FSNOTIFY_BUFFER -u cirrus go test -parallel 1 -race -v ./... diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/.gitignore b/src/yangerd/vendor/github.com/fsnotify/fsnotify/.gitignore new file mode 100644 index 00000000..daea9dd6 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/.gitignore @@ -0,0 +1,10 @@ +# go test -c output +*.test +*.test.exe + +# Output of go build ./cmd/fsnotify +/fsnotify +/fsnotify.exe + +/test/kqueue +/test/a.out diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/.mailmap b/src/yangerd/vendor/github.com/fsnotify/fsnotify/.mailmap new file mode 100644 index 00000000..a04f2907 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/.mailmap @@ -0,0 +1,2 @@ +Chris Howey +Nathan Youngman <4566+nathany@users.noreply.github.com> diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/CHANGELOG.md b/src/yangerd/vendor/github.com/fsnotify/fsnotify/CHANGELOG.md new file mode 100644 index 00000000..6468d2cf --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/CHANGELOG.md @@ -0,0 +1,602 @@ +# Changelog + +1.9.0 2024-04-04 +---------------- + +### Changes and fixes + +- all: make BufferedWatcher buffered again ([#657]) + +- inotify: fix race when adding/removing watches while a watched path is being + deleted ([#678], [#686]) + +- inotify: don't send empty event if a watched path is unmounted ([#655]) + +- inotify: don't register duplicate watches when watching both a symlink and its + target; previously that would get "half-added" and removing the second would + panic ([#679]) + +- kqueue: fix watching relative symlinks ([#681]) + +- kqueue: correctly mark pre-existing entries when watching a link to a dir on + kqueue ([#682]) + +- illumos: don't send error if changed file is deleted while processing the + event ([#678]) + + +[#657]: https://github.com/fsnotify/fsnotify/pull/657 +[#678]: https://github.com/fsnotify/fsnotify/pull/678 +[#686]: https://github.com/fsnotify/fsnotify/pull/686 +[#655]: https://github.com/fsnotify/fsnotify/pull/655 +[#681]: https://github.com/fsnotify/fsnotify/pull/681 +[#679]: https://github.com/fsnotify/fsnotify/pull/679 +[#682]: https://github.com/fsnotify/fsnotify/pull/682 + +1.8.0 2024-10-31 +---------------- + +### Additions + +- all: add `FSNOTIFY_DEBUG` to print debug logs to stderr ([#619]) + +### Changes and fixes + +- windows: fix behaviour of `WatchList()` to be consistent with other platforms ([#610]) + +- kqueue: ignore events with Ident=0 ([#590]) + +- kqueue: set O_CLOEXEC to prevent passing file descriptors to children ([#617]) + +- kqueue: emit events as "/path/dir/file" instead of "path/link/file" when watching a symlink ([#625]) + +- inotify: don't send event for IN_DELETE_SELF when also watching the parent ([#620]) + +- inotify: fix panic when calling Remove() in a goroutine ([#650]) + +- fen: allow watching subdirectories of watched directories ([#621]) + +[#590]: https://github.com/fsnotify/fsnotify/pull/590 +[#610]: https://github.com/fsnotify/fsnotify/pull/610 +[#617]: https://github.com/fsnotify/fsnotify/pull/617 +[#619]: https://github.com/fsnotify/fsnotify/pull/619 +[#620]: https://github.com/fsnotify/fsnotify/pull/620 +[#621]: https://github.com/fsnotify/fsnotify/pull/621 +[#625]: https://github.com/fsnotify/fsnotify/pull/625 +[#650]: https://github.com/fsnotify/fsnotify/pull/650 + +1.7.0 - 2023-10-22 +------------------ +This version of fsnotify needs Go 1.17. + +### Additions + +- illumos: add FEN backend to support illumos and Solaris. ([#371]) + +- all: add `NewBufferedWatcher()` to use a buffered channel, which can be useful + in cases where you can't control the kernel buffer and receive a large number + of events in bursts. ([#550], [#572]) + +- all: add `AddWith()`, which is identical to `Add()` but allows passing + options. ([#521]) + +- windows: allow setting the ReadDirectoryChangesW() buffer size with + `fsnotify.WithBufferSize()`; the default of 64K is the highest value that + works on all platforms and is enough for most purposes, but in some cases a + highest buffer is needed. ([#521]) + +### Changes and fixes + +- inotify: remove watcher if a watched path is renamed ([#518]) + + After a rename the reported name wasn't updated, or even an empty string. + Inotify doesn't provide any good facilities to update it, so just remove the + watcher. This is already how it worked on kqueue and FEN. + + On Windows this does work, and remains working. + +- windows: don't listen for file attribute changes ([#520]) + + File attribute changes are sent as `FILE_ACTION_MODIFIED` by the Windows API, + with no way to see if they're a file write or attribute change, so would show + up as a fsnotify.Write event. This is never useful, and could result in many + spurious Write events. + +- windows: return `ErrEventOverflow` if the buffer is full ([#525]) + + Before it would merely return "short read", making it hard to detect this + error. + +- kqueue: make sure events for all files are delivered properly when removing a + watched directory ([#526]) + + Previously they would get sent with `""` (empty string) or `"."` as the path + name. + +- kqueue: don't emit spurious Create events for symbolic links ([#524]) + + The link would get resolved but kqueue would "forget" it already saw the link + itself, resulting on a Create for every Write event for the directory. + +- all: return `ErrClosed` on `Add()` when the watcher is closed ([#516]) + +- other: add `Watcher.Errors` and `Watcher.Events` to the no-op `Watcher` in + `backend_other.go`, making it easier to use on unsupported platforms such as + WASM, AIX, etc. ([#528]) + +- other: use the `backend_other.go` no-op if the `appengine` build tag is set; + Google AppEngine forbids usage of the unsafe package so the inotify backend + won't compile there. + +[#371]: https://github.com/fsnotify/fsnotify/pull/371 +[#516]: https://github.com/fsnotify/fsnotify/pull/516 +[#518]: https://github.com/fsnotify/fsnotify/pull/518 +[#520]: https://github.com/fsnotify/fsnotify/pull/520 +[#521]: https://github.com/fsnotify/fsnotify/pull/521 +[#524]: https://github.com/fsnotify/fsnotify/pull/524 +[#525]: https://github.com/fsnotify/fsnotify/pull/525 +[#526]: https://github.com/fsnotify/fsnotify/pull/526 +[#528]: https://github.com/fsnotify/fsnotify/pull/528 +[#537]: https://github.com/fsnotify/fsnotify/pull/537 +[#550]: https://github.com/fsnotify/fsnotify/pull/550 +[#572]: https://github.com/fsnotify/fsnotify/pull/572 + +1.6.0 - 2022-10-13 +------------------ +This version of fsnotify needs Go 1.16 (this was already the case since 1.5.1, +but not documented). It also increases the minimum Linux version to 2.6.32. + +### Additions + +- all: add `Event.Has()` and `Op.Has()` ([#477]) + + This makes checking events a lot easier; for example: + + if event.Op&Write == Write && !(event.Op&Remove == Remove) { + } + + Becomes: + + if event.Has(Write) && !event.Has(Remove) { + } + +- all: add cmd/fsnotify ([#463]) + + A command-line utility for testing and some examples. + +### Changes and fixes + +- inotify: don't ignore events for files that don't exist ([#260], [#470]) + + Previously the inotify watcher would call `os.Lstat()` to check if a file + still exists before emitting events. + + This was inconsistent with other platforms and resulted in inconsistent event + reporting (e.g. when a file is quickly removed and re-created), and generally + a source of confusion. It was added in 2013 to fix a memory leak that no + longer exists. + +- all: return `ErrNonExistentWatch` when `Remove()` is called on a path that's + not watched ([#460]) + +- inotify: replace epoll() with non-blocking inotify ([#434]) + + Non-blocking inotify was not generally available at the time this library was + written in 2014, but now it is. As a result, the minimum Linux version is + bumped from 2.6.27 to 2.6.32. This hugely simplifies the code and is faster. + +- kqueue: don't check for events every 100ms ([#480]) + + The watcher would wake up every 100ms, even when there was nothing to do. Now + it waits until there is something to do. + +- macos: retry opening files on EINTR ([#475]) + +- kqueue: skip unreadable files ([#479]) + + kqueue requires a file descriptor for every file in a directory; this would + fail if a file was unreadable by the current user. Now these files are simply + skipped. + +- windows: fix renaming a watched directory if the parent is also watched ([#370]) + +- windows: increase buffer size from 4K to 64K ([#485]) + +- windows: close file handle on Remove() ([#288]) + +- kqueue: put pathname in the error if watching a file fails ([#471]) + +- inotify, windows: calling Close() more than once could race ([#465]) + +- kqueue: improve Close() performance ([#233]) + +- all: various documentation additions and clarifications. + +[#233]: https://github.com/fsnotify/fsnotify/pull/233 +[#260]: https://github.com/fsnotify/fsnotify/pull/260 +[#288]: https://github.com/fsnotify/fsnotify/pull/288 +[#370]: https://github.com/fsnotify/fsnotify/pull/370 +[#434]: https://github.com/fsnotify/fsnotify/pull/434 +[#460]: https://github.com/fsnotify/fsnotify/pull/460 +[#463]: https://github.com/fsnotify/fsnotify/pull/463 +[#465]: https://github.com/fsnotify/fsnotify/pull/465 +[#470]: https://github.com/fsnotify/fsnotify/pull/470 +[#471]: https://github.com/fsnotify/fsnotify/pull/471 +[#475]: https://github.com/fsnotify/fsnotify/pull/475 +[#477]: https://github.com/fsnotify/fsnotify/pull/477 +[#479]: https://github.com/fsnotify/fsnotify/pull/479 +[#480]: https://github.com/fsnotify/fsnotify/pull/480 +[#485]: https://github.com/fsnotify/fsnotify/pull/485 + +## [1.5.4] - 2022-04-25 + +* Windows: add missing defer to `Watcher.WatchList` [#447](https://github.com/fsnotify/fsnotify/pull/447) +* go.mod: use latest x/sys [#444](https://github.com/fsnotify/fsnotify/pull/444) +* Fix compilation for OpenBSD [#443](https://github.com/fsnotify/fsnotify/pull/443) + +## [1.5.3] - 2022-04-22 + +* This version is retracted. An incorrect branch is published accidentally [#445](https://github.com/fsnotify/fsnotify/issues/445) + +## [1.5.2] - 2022-04-21 + +* Add a feature to return the directories and files that are being monitored [#374](https://github.com/fsnotify/fsnotify/pull/374) +* Fix potential crash on windows if `raw.FileNameLength` exceeds `syscall.MAX_PATH` [#361](https://github.com/fsnotify/fsnotify/pull/361) +* Allow build on unsupported GOOS [#424](https://github.com/fsnotify/fsnotify/pull/424) +* Don't set `poller.fd` twice in `newFdPoller` [#406](https://github.com/fsnotify/fsnotify/pull/406) +* fix go vet warnings: call to `(*T).Fatalf` from a non-test goroutine [#416](https://github.com/fsnotify/fsnotify/pull/416) + +## [1.5.1] - 2021-08-24 + +* Revert Add AddRaw to not follow symlinks [#394](https://github.com/fsnotify/fsnotify/pull/394) + +## [1.5.0] - 2021-08-20 + +* Go: Increase minimum required version to Go 1.12 [#381](https://github.com/fsnotify/fsnotify/pull/381) +* Feature: Add AddRaw method which does not follow symlinks when adding a watch [#289](https://github.com/fsnotify/fsnotify/pull/298) +* Windows: Follow symlinks by default like on all other systems [#289](https://github.com/fsnotify/fsnotify/pull/289) +* CI: Use GitHub Actions for CI and cover go 1.12-1.17 + [#378](https://github.com/fsnotify/fsnotify/pull/378) + [#381](https://github.com/fsnotify/fsnotify/pull/381) + [#385](https://github.com/fsnotify/fsnotify/pull/385) +* Go 1.14+: Fix unsafe pointer conversion [#325](https://github.com/fsnotify/fsnotify/pull/325) + +## [1.4.9] - 2020-03-11 + +* Move example usage to the readme #329. This may resolve #328. + +## [1.4.8] - 2020-03-10 + +* CI: test more go versions (@nathany 1d13583d846ea9d66dcabbfefbfb9d8e6fb05216) +* Tests: Queued inotify events could have been read by the test before max_queued_events was hit (@matthias-stone #265) +* Tests: t.Fatalf -> t.Errorf in go routines (@gdey #266) +* CI: Less verbosity (@nathany #267) +* Tests: Darwin: Exchangedata is deprecated on 10.13 (@nathany #267) +* Tests: Check if channels are closed in the example (@alexeykazakov #244) +* CI: Only run golint on latest version of go and fix issues (@cpuguy83 #284) +* CI: Add windows to travis matrix (@cpuguy83 #284) +* Docs: Remover appveyor badge (@nathany 11844c0959f6fff69ba325d097fce35bd85a8e93) +* Linux: create epoll and pipe fds with close-on-exec (@JohannesEbke #219) +* Linux: open files with close-on-exec (@linxiulei #273) +* Docs: Plan to support fanotify (@nathany ab058b44498e8b7566a799372a39d150d9ea0119 ) +* Project: Add go.mod (@nathany #309) +* Project: Revise editor config (@nathany #309) +* Project: Update copyright for 2019 (@nathany #309) +* CI: Drop go1.8 from CI matrix (@nathany #309) +* Docs: Updating the FAQ section for supportability with NFS & FUSE filesystems (@Pratik32 4bf2d1fec78374803a39307bfb8d340688f4f28e ) + +## [1.4.7] - 2018-01-09 + +* BSD/macOS: Fix possible deadlock on closing the watcher on kqueue (thanks @nhooyr and @glycerine) +* Tests: Fix missing verb on format string (thanks @rchiossi) +* Linux: Fix deadlock in Remove (thanks @aarondl) +* Linux: Watch.Add improvements (avoid race, fix consistency, reduce garbage) (thanks @twpayne) +* Docs: Moved FAQ into the README (thanks @vahe) +* Linux: Properly handle inotify's IN_Q_OVERFLOW event (thanks @zeldovich) +* Docs: replace references to OS X with macOS + +## [1.4.2] - 2016-10-10 + +* Linux: use InotifyInit1 with IN_CLOEXEC to stop leaking a file descriptor to a child process when using fork/exec [#178](https://github.com/fsnotify/fsnotify/pull/178) (thanks @pattyshack) + +## [1.4.1] - 2016-10-04 + +* Fix flaky inotify stress test on Linux [#177](https://github.com/fsnotify/fsnotify/pull/177) (thanks @pattyshack) + +## [1.4.0] - 2016-10-01 + +* add a String() method to Event.Op [#165](https://github.com/fsnotify/fsnotify/pull/165) (thanks @oozie) + +## [1.3.1] - 2016-06-28 + +* Windows: fix for double backslash when watching the root of a drive [#151](https://github.com/fsnotify/fsnotify/issues/151) (thanks @brunoqc) + +## [1.3.0] - 2016-04-19 + +* Support linux/arm64 by [patching](https://go-review.googlesource.com/#/c/21971/) x/sys/unix and switching to to it from syscall (thanks @suihkulokki) [#135](https://github.com/fsnotify/fsnotify/pull/135) + +## [1.2.10] - 2016-03-02 + +* Fix golint errors in windows.go [#121](https://github.com/fsnotify/fsnotify/pull/121) (thanks @tiffanyfj) + +## [1.2.9] - 2016-01-13 + +kqueue: Fix logic for CREATE after REMOVE [#111](https://github.com/fsnotify/fsnotify/pull/111) (thanks @bep) + +## [1.2.8] - 2015-12-17 + +* kqueue: fix race condition in Close [#105](https://github.com/fsnotify/fsnotify/pull/105) (thanks @djui for reporting the issue and @ppknap for writing a failing test) +* inotify: fix race in test +* enable race detection for continuous integration (Linux, Mac, Windows) + +## [1.2.5] - 2015-10-17 + +* inotify: use epoll_create1 for arm64 support (requires Linux 2.6.27 or later) [#100](https://github.com/fsnotify/fsnotify/pull/100) (thanks @suihkulokki) +* inotify: fix path leaks [#73](https://github.com/fsnotify/fsnotify/pull/73) (thanks @chamaken) +* kqueue: watch for rename events on subdirectories [#83](https://github.com/fsnotify/fsnotify/pull/83) (thanks @guotie) +* kqueue: avoid infinite loops from symlinks cycles [#101](https://github.com/fsnotify/fsnotify/pull/101) (thanks @illicitonion) + +## [1.2.1] - 2015-10-14 + +* kqueue: don't watch named pipes [#98](https://github.com/fsnotify/fsnotify/pull/98) (thanks @evanphx) + +## [1.2.0] - 2015-02-08 + +* inotify: use epoll to wake up readEvents [#66](https://github.com/fsnotify/fsnotify/pull/66) (thanks @PieterD) +* inotify: closing watcher should now always shut down goroutine [#63](https://github.com/fsnotify/fsnotify/pull/63) (thanks @PieterD) +* kqueue: close kqueue after removing watches, fixes [#59](https://github.com/fsnotify/fsnotify/issues/59) + +## [1.1.1] - 2015-02-05 + +* inotify: Retry read on EINTR [#61](https://github.com/fsnotify/fsnotify/issues/61) (thanks @PieterD) + +## [1.1.0] - 2014-12-12 + +* kqueue: rework internals [#43](https://github.com/fsnotify/fsnotify/pull/43) + * add low-level functions + * only need to store flags on directories + * less mutexes [#13](https://github.com/fsnotify/fsnotify/issues/13) + * done can be an unbuffered channel + * remove calls to os.NewSyscallError +* More efficient string concatenation for Event.String() [#52](https://github.com/fsnotify/fsnotify/pull/52) (thanks @mdlayher) +* kqueue: fix regression in rework causing subdirectories to be watched [#48](https://github.com/fsnotify/fsnotify/issues/48) +* kqueue: cleanup internal watch before sending remove event [#51](https://github.com/fsnotify/fsnotify/issues/51) + +## [1.0.4] - 2014-09-07 + +* kqueue: add dragonfly to the build tags. +* Rename source code files, rearrange code so exported APIs are at the top. +* Add done channel to example code. [#37](https://github.com/fsnotify/fsnotify/pull/37) (thanks @chenyukang) + +## [1.0.3] - 2014-08-19 + +* [Fix] Windows MOVED_TO now translates to Create like on BSD and Linux. [#36](https://github.com/fsnotify/fsnotify/issues/36) + +## [1.0.2] - 2014-08-17 + +* [Fix] Missing create events on macOS. [#14](https://github.com/fsnotify/fsnotify/issues/14) (thanks @zhsso) +* [Fix] Make ./path and path equivalent. (thanks @zhsso) + +## [1.0.0] - 2014-08-15 + +* [API] Remove AddWatch on Windows, use Add. +* Improve documentation for exported identifiers. [#30](https://github.com/fsnotify/fsnotify/issues/30) +* Minor updates based on feedback from golint. + +## dev / 2014-07-09 + +* Moved to [github.com/fsnotify/fsnotify](https://github.com/fsnotify/fsnotify). +* Use os.NewSyscallError instead of returning errno (thanks @hariharan-uno) + +## dev / 2014-07-04 + +* kqueue: fix incorrect mutex used in Close() +* Update example to demonstrate usage of Op. + +## dev / 2014-06-28 + +* [API] Don't set the Write Op for attribute notifications [#4](https://github.com/fsnotify/fsnotify/issues/4) +* Fix for String() method on Event (thanks Alex Brainman) +* Don't build on Plan 9 or Solaris (thanks @4ad) + +## dev / 2014-06-21 + +* Events channel of type Event rather than *Event. +* [internal] use syscall constants directly for inotify and kqueue. +* [internal] kqueue: rename events to kevents and fileEvent to event. + +## dev / 2014-06-19 + +* Go 1.3+ required on Windows (uses syscall.ERROR_MORE_DATA internally). +* [internal] remove cookie from Event struct (unused). +* [internal] Event struct has the same definition across every OS. +* [internal] remove internal watch and removeWatch methods. + +## dev / 2014-06-12 + +* [API] Renamed Watch() to Add() and RemoveWatch() to Remove(). +* [API] Pluralized channel names: Events and Errors. +* [API] Renamed FileEvent struct to Event. +* [API] Op constants replace methods like IsCreate(). + +## dev / 2014-06-12 + +* Fix data race on kevent buffer (thanks @tilaks) [#98](https://github.com/howeyc/fsnotify/pull/98) + +## dev / 2014-05-23 + +* [API] Remove current implementation of WatchFlags. + * current implementation doesn't take advantage of OS for efficiency + * provides little benefit over filtering events as they are received, but has extra bookkeeping and mutexes + * no tests for the current implementation + * not fully implemented on Windows [#93](https://github.com/howeyc/fsnotify/issues/93#issuecomment-39285195) + +## [0.9.3] - 2014-12-31 + +* kqueue: cleanup internal watch before sending remove event [#51](https://github.com/fsnotify/fsnotify/issues/51) + +## [0.9.2] - 2014-08-17 + +* [Backport] Fix missing create events on macOS. [#14](https://github.com/fsnotify/fsnotify/issues/14) (thanks @zhsso) + +## [0.9.1] - 2014-06-12 + +* Fix data race on kevent buffer (thanks @tilaks) [#98](https://github.com/howeyc/fsnotify/pull/98) + +## [0.9.0] - 2014-01-17 + +* IsAttrib() for events that only concern a file's metadata [#79][] (thanks @abustany) +* [Fix] kqueue: fix deadlock [#77][] (thanks @cespare) +* [NOTICE] Development has moved to `code.google.com/p/go.exp/fsnotify` in preparation for inclusion in the Go standard library. + +## [0.8.12] - 2013-11-13 + +* [API] Remove FD_SET and friends from Linux adapter + +## [0.8.11] - 2013-11-02 + +* [Doc] Add Changelog [#72][] (thanks @nathany) +* [Doc] Spotlight and double modify events on macOS [#62][] (reported by @paulhammond) + +## [0.8.10] - 2013-10-19 + +* [Fix] kqueue: remove file watches when parent directory is removed [#71][] (reported by @mdwhatcott) +* [Fix] kqueue: race between Close and readEvents [#70][] (reported by @bernerdschaefer) +* [Doc] specify OS-specific limits in README (thanks @debrando) + +## [0.8.9] - 2013-09-08 + +* [Doc] Contributing (thanks @nathany) +* [Doc] update package path in example code [#63][] (thanks @paulhammond) +* [Doc] GoCI badge in README (Linux only) [#60][] +* [Doc] Cross-platform testing with Vagrant [#59][] (thanks @nathany) + +## [0.8.8] - 2013-06-17 + +* [Fix] Windows: handle `ERROR_MORE_DATA` on Windows [#49][] (thanks @jbowtie) + +## [0.8.7] - 2013-06-03 + +* [API] Make syscall flags internal +* [Fix] inotify: ignore event changes +* [Fix] race in symlink test [#45][] (reported by @srid) +* [Fix] tests on Windows +* lower case error messages + +## [0.8.6] - 2013-05-23 + +* kqueue: Use EVT_ONLY flag on Darwin +* [Doc] Update README with full example + +## [0.8.5] - 2013-05-09 + +* [Fix] inotify: allow monitoring of "broken" symlinks (thanks @tsg) + +## [0.8.4] - 2013-04-07 + +* [Fix] kqueue: watch all file events [#40][] (thanks @ChrisBuchholz) + +## [0.8.3] - 2013-03-13 + +* [Fix] inoitfy/kqueue memory leak [#36][] (reported by @nbkolchin) +* [Fix] kqueue: use fsnFlags for watching a directory [#33][] (reported by @nbkolchin) + +## [0.8.2] - 2013-02-07 + +* [Doc] add Authors +* [Fix] fix data races for map access [#29][] (thanks @fsouza) + +## [0.8.1] - 2013-01-09 + +* [Fix] Windows path separators +* [Doc] BSD License + +## [0.8.0] - 2012-11-09 + +* kqueue: directory watching improvements (thanks @vmirage) +* inotify: add `IN_MOVED_TO` [#25][] (requested by @cpisto) +* [Fix] kqueue: deleting watched directory [#24][] (reported by @jakerr) + +## [0.7.4] - 2012-10-09 + +* [Fix] inotify: fixes from https://codereview.appspot.com/5418045/ (ugorji) +* [Fix] kqueue: preserve watch flags when watching for delete [#21][] (reported by @robfig) +* [Fix] kqueue: watch the directory even if it isn't a new watch (thanks @robfig) +* [Fix] kqueue: modify after recreation of file + +## [0.7.3] - 2012-09-27 + +* [Fix] kqueue: watch with an existing folder inside the watched folder (thanks @vmirage) +* [Fix] kqueue: no longer get duplicate CREATE events + +## [0.7.2] - 2012-09-01 + +* kqueue: events for created directories + +## [0.7.1] - 2012-07-14 + +* [Fix] for renaming files + +## [0.7.0] - 2012-07-02 + +* [Feature] FSNotify flags +* [Fix] inotify: Added file name back to event path + +## [0.6.0] - 2012-06-06 + +* kqueue: watch files after directory created (thanks @tmc) + +## [0.5.1] - 2012-05-22 + +* [Fix] inotify: remove all watches before Close() + +## [0.5.0] - 2012-05-03 + +* [API] kqueue: return errors during watch instead of sending over channel +* kqueue: match symlink behavior on Linux +* inotify: add `DELETE_SELF` (requested by @taralx) +* [Fix] kqueue: handle EINTR (reported by @robfig) +* [Doc] Godoc example [#1][] (thanks @davecheney) + +## [0.4.0] - 2012-03-30 + +* Go 1 released: build with go tool +* [Feature] Windows support using winfsnotify +* Windows does not have attribute change notifications +* Roll attribute notifications into IsModify + +## [0.3.0] - 2012-02-19 + +* kqueue: add files when watch directory + +## [0.2.0] - 2011-12-30 + +* update to latest Go weekly code + +## [0.1.0] - 2011-10-19 + +* kqueue: add watch on file creation to match inotify +* kqueue: create file event +* inotify: ignore `IN_IGNORED` events +* event String() +* linux: common FileEvent functions +* initial commit + +[#79]: https://github.com/howeyc/fsnotify/pull/79 +[#77]: https://github.com/howeyc/fsnotify/pull/77 +[#72]: https://github.com/howeyc/fsnotify/issues/72 +[#71]: https://github.com/howeyc/fsnotify/issues/71 +[#70]: https://github.com/howeyc/fsnotify/issues/70 +[#63]: https://github.com/howeyc/fsnotify/issues/63 +[#62]: https://github.com/howeyc/fsnotify/issues/62 +[#60]: https://github.com/howeyc/fsnotify/issues/60 +[#59]: https://github.com/howeyc/fsnotify/issues/59 +[#49]: https://github.com/howeyc/fsnotify/issues/49 +[#45]: https://github.com/howeyc/fsnotify/issues/45 +[#40]: https://github.com/howeyc/fsnotify/issues/40 +[#36]: https://github.com/howeyc/fsnotify/issues/36 +[#33]: https://github.com/howeyc/fsnotify/issues/33 +[#29]: https://github.com/howeyc/fsnotify/issues/29 +[#25]: https://github.com/howeyc/fsnotify/issues/25 +[#24]: https://github.com/howeyc/fsnotify/issues/24 +[#21]: https://github.com/howeyc/fsnotify/issues/21 diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/CONTRIBUTING.md b/src/yangerd/vendor/github.com/fsnotify/fsnotify/CONTRIBUTING.md new file mode 100644 index 00000000..4cc40fa5 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/CONTRIBUTING.md @@ -0,0 +1,145 @@ +Thank you for your interest in contributing to fsnotify! We try to review and +merge PRs in a reasonable timeframe, but please be aware that: + +- To avoid "wasted" work, please discuss changes on the issue tracker first. You + can just send PRs, but they may end up being rejected for one reason or the + other. + +- fsnotify is a cross-platform library, and changes must work reasonably well on + all supported platforms. + +- Changes will need to be compatible; old code should still compile, and the + runtime behaviour can't change in ways that are likely to lead to problems for + users. + +Testing +------- +Just `go test ./...` runs all the tests; the CI runs this on all supported +platforms. Testing different platforms locally can be done with something like +[goon] or [Vagrant], but this isn't super-easy to set up at the moment. + +Use the `-short` flag to make the "stress test" run faster. + +Writing new tests +----------------- +Scripts in the testdata directory allow creating test cases in a "shell-like" +syntax. The basic format is: + + script + + Output: + desired output + +For example: + + # Create a new empty file with some data. + watch / + echo data >/file + + Output: + create /file + write /file + +Just create a new file to add a new test; select which tests to run with +`-run TestScript/[path]`. + +script +------ +The script is a "shell-like" script: + + cmd arg arg + +Comments are supported with `#`: + + # Comment + cmd arg arg # Comment + +All operations are done in a temp directory; a path like "/foo" is rewritten to +"/tmp/TestFoo/foo". + +Arguments can be quoted with `"` or `'`; there are no escapes and they're +functionally identical right now, but this may change in the future, so best to +assume shell-like rules. + + touch "/file with spaces" + +End-of-line escapes with `\` are not supported. + +### Supported commands + + watch path [ops] # Watch the path, reporting events for it. Nothing is + # watched by default. Optionally a list of ops can be + # given, as with AddWith(path, WithOps(...)). + unwatch path # Stop watching the path. + watchlist n # Assert watchlist length. + + stop # Stop running the script; for debugging. + debug [yes/no] # Enable/disable FSNOTIFY_DEBUG (tests are run in + parallel by default, so -parallel=1 is probably a good + idea). + print [any strings] # Print text to stdout; for debugging. + + touch path + mkdir [-p] dir + ln -s target link # Only ln -s supported. + mkfifo path + mknod dev path + mv src dst + rm [-r] path + chmod mode path # Octal only + sleep time-in-ms + + cat path # Read path (does nothing with the data; just reads it). + echo str >>path # Append "str" to "path". + echo str >path # Truncate "path" and write "str". + + require reason # Skip the test if "reason" is true; "skip" and + skip reason # "require" behave identical; it supports both for + # readability. Possible reasons are: + # + # always Always skip this test. + # symlink Symlinks are supported (requires admin + # permissions on Windows). + # mkfifo Platform doesn't support FIFO named sockets. + # mknod Platform doesn't support device nodes. + + +output +------ +After `Output:` the desired output is given; this is indented by convention, but +that's not required. + +The format of that is: + + # Comment + event path # Comment + + system: + event path + system2: + event path + +Every event is one line, and any whitespace between the event and path are +ignored. The path can optionally be surrounded in ". Anything after a "#" is +ignored. + +Platform-specific tests can be added after GOOS; for example: + + watch / + touch /file + + Output: + # Tested if nothing else matches + create /file + + # Windows-specific test. + windows: + write /file + +You can specify multiple platforms with a comma (e.g. "windows, linux:"). +"kqueue" is a shortcut for all kqueue systems (BSD, macOS). + + +[goon]: https://github.com/arp242/goon +[Vagrant]: https://www.vagrantup.com/ +[integration_test.go]: /integration_test.go diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/LICENSE b/src/yangerd/vendor/github.com/fsnotify/fsnotify/LICENSE new file mode 100644 index 00000000..fb03ade7 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/LICENSE @@ -0,0 +1,25 @@ +Copyright © 2012 The Go Authors. All rights reserved. +Copyright © fsnotify Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. +* Neither the name of Google Inc. nor the names of its contributors may be used + to endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/README.md b/src/yangerd/vendor/github.com/fsnotify/fsnotify/README.md new file mode 100644 index 00000000..1f4eb583 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/README.md @@ -0,0 +1,182 @@ +fsnotify is a Go library to provide cross-platform filesystem notifications on +Windows, Linux, macOS, BSD, and illumos. + +Go 1.17 or newer is required; the full documentation is at +https://pkg.go.dev/github.com/fsnotify/fsnotify + +--- + +Platform support: + +| Backend | OS | Status | +| :-------------------- | :--------- | :------------------------------------------------------------------------ | +| inotify | Linux | Supported | +| kqueue | BSD, macOS | Supported | +| ReadDirectoryChangesW | Windows | Supported | +| FEN | illumos | Supported | +| fanotify | Linux 5.9+ | [Not yet](https://github.com/fsnotify/fsnotify/issues/114) | +| FSEvents | macOS | [Needs support in x/sys/unix][fsevents] | +| USN Journals | Windows | [Needs support in x/sys/windows][usn] | +| Polling | *All* | [Not yet](https://github.com/fsnotify/fsnotify/issues/9) | + +Linux and illumos should include Android and Solaris, but these are currently +untested. + +[fsevents]: https://github.com/fsnotify/fsnotify/issues/11#issuecomment-1279133120 +[usn]: https://github.com/fsnotify/fsnotify/issues/53#issuecomment-1279829847 + +Usage +----- +A basic example: + +```go +package main + +import ( + "log" + + "github.com/fsnotify/fsnotify" +) + +func main() { + // Create new watcher. + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Fatal(err) + } + defer watcher.Close() + + // Start listening for events. + go func() { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + log.Println("event:", event) + if event.Has(fsnotify.Write) { + log.Println("modified file:", event.Name) + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Println("error:", err) + } + } + }() + + // Add a path. + err = watcher.Add("/tmp") + if err != nil { + log.Fatal(err) + } + + // Block main goroutine forever. + <-make(chan struct{}) +} +``` + +Some more examples can be found in [cmd/fsnotify](cmd/fsnotify), which can be +run with: + + % go run ./cmd/fsnotify + +Further detailed documentation can be found in godoc: +https://pkg.go.dev/github.com/fsnotify/fsnotify + +FAQ +--- +### Will a file still be watched when it's moved to another directory? +No, not unless you are watching the location it was moved to. + +### Are subdirectories watched? +No, you must add watches for any directory you want to watch (a recursive +watcher is on the roadmap: [#18]). + +[#18]: https://github.com/fsnotify/fsnotify/issues/18 + +### Do I have to watch the Error and Event channels in a goroutine? +Yes. You can read both channels in the same goroutine using `select` (you don't +need a separate goroutine for both channels; see the example). + +### Why don't notifications work with NFS, SMB, FUSE, /proc, or /sys? +fsnotify requires support from underlying OS to work. The current NFS and SMB +protocols does not provide network level support for file notifications, and +neither do the /proc and /sys virtual filesystems. + +This could be fixed with a polling watcher ([#9]), but it's not yet implemented. + +[#9]: https://github.com/fsnotify/fsnotify/issues/9 + +### Why do I get many Chmod events? +Some programs may generate a lot of attribute changes; for example Spotlight on +macOS, anti-virus programs, backup applications, and some others are known to do +this. As a rule, it's typically best to ignore Chmod events. They're often not +useful, and tend to cause problems. + +Spotlight indexing on macOS can result in multiple events (see [#15]). A +temporary workaround is to add your folder(s) to the *Spotlight Privacy +settings* until we have a native FSEvents implementation (see [#11]). + +[#11]: https://github.com/fsnotify/fsnotify/issues/11 +[#15]: https://github.com/fsnotify/fsnotify/issues/15 + +### Watching a file doesn't work well +Watching individual files (rather than directories) is generally not recommended +as many programs (especially editors) update files atomically: it will write to +a temporary file which is then moved to to destination, overwriting the original +(or some variant thereof). The watcher on the original file is now lost, as that +no longer exists. + +The upshot of this is that a power failure or crash won't leave a half-written +file. + +Watch the parent directory and use `Event.Name` to filter out files you're not +interested in. There is an example of this in `cmd/fsnotify/file.go`. + +Platform-specific notes +----------------------- +### Linux +When a file is removed a REMOVE event won't be emitted until all file +descriptors are closed; it will emit a CHMOD instead: + + fp := os.Open("file") + os.Remove("file") // CHMOD + fp.Close() // REMOVE + +This is the event that inotify sends, so not much can be changed about this. + +The `fs.inotify.max_user_watches` sysctl variable specifies the upper limit for +the number of watches per user, and `fs.inotify.max_user_instances` specifies +the maximum number of inotify instances per user. Every Watcher you create is an +"instance", and every path you add is a "watch". + +These are also exposed in `/proc` as `/proc/sys/fs/inotify/max_user_watches` and +`/proc/sys/fs/inotify/max_user_instances` + +To increase them you can use `sysctl` or write the value to proc file: + + # The default values on Linux 5.18 + sysctl fs.inotify.max_user_watches=124983 + sysctl fs.inotify.max_user_instances=128 + +To make the changes persist on reboot edit `/etc/sysctl.conf` or +`/usr/lib/sysctl.d/50-default.conf` (details differ per Linux distro; check your +distro's documentation): + + fs.inotify.max_user_watches=124983 + fs.inotify.max_user_instances=128 + +Reaching the limit will result in a "no space left on device" or "too many open +files" error. + +### kqueue (macOS, all BSD systems) +kqueue requires opening a file descriptor for every file that's being watched; +so if you're watching a directory with five files then that's six file +descriptors. You will run in to your system's "max open files" limit faster on +these platforms. + +The sysctl variables `kern.maxfiles` and `kern.maxfilesperproc` can be used to +control the maximum number of open files. diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_fen.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_fen.go new file mode 100644 index 00000000..57fc6928 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_fen.go @@ -0,0 +1,467 @@ +//go:build solaris + +// FEN backend for illumos (supported) and Solaris (untested, but should work). +// +// See port_create(3c) etc. for docs. https://www.illumos.org/man/3C/port_create + +package fsnotify + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" + "time" + + "github.com/fsnotify/fsnotify/internal" + "golang.org/x/sys/unix" +) + +type fen struct { + *shared + Events chan Event + Errors chan error + + mu sync.Mutex + port *unix.EventPort + dirs map[string]Op // Explicitly watched directories + watches map[string]Op // Explicitly watched non-directories +} + +var defaultBufferSize = 0 + +func newBackend(ev chan Event, errs chan error) (backend, error) { + w := &fen{ + shared: newShared(ev, errs), + Events: ev, + Errors: errs, + dirs: make(map[string]Op), + watches: make(map[string]Op), + } + + var err error + w.port, err = unix.NewEventPort() + if err != nil { + return nil, fmt.Errorf("fsnotify.NewWatcher: %w", err) + } + + go w.readEvents() + return w, nil +} + +func (w *fen) Close() error { + if w.shared.close() { + return nil + } + return w.port.Close() +} + +func (w *fen) Add(name string) error { return w.AddWith(name) } + +func (w *fen) AddWith(name string, opts ...addOpt) error { + if w.isClosed() { + return ErrClosed + } + if debug { + fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s AddWith(%q)\n", + time.Now().Format("15:04:05.000000000"), name) + } + + with := getOptions(opts...) + if !w.xSupports(with.op) { + return fmt.Errorf("%w: %s", xErrUnsupported, with.op) + } + + // Currently we resolve symlinks that were explicitly requested to be + // watched. Otherwise we would use LStat here. + stat, err := os.Stat(name) + if err != nil { + return err + } + + // Associate all files in the directory. + if stat.IsDir() { + err := w.handleDirectory(name, stat, true, w.associateFile) + if err != nil { + return err + } + + w.mu.Lock() + w.dirs[name] = with.op + w.mu.Unlock() + return nil + } + + err = w.associateFile(name, stat, true) + if err != nil { + return err + } + + w.mu.Lock() + w.watches[name] = with.op + w.mu.Unlock() + return nil +} + +func (w *fen) Remove(name string) error { + if w.isClosed() { + return nil + } + if !w.port.PathIsWatched(name) { + return fmt.Errorf("%w: %s", ErrNonExistentWatch, name) + } + if debug { + fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s Remove(%q)\n", + time.Now().Format("15:04:05.000000000"), name) + } + + // The user has expressed an intent. Immediately remove this name from + // whichever watch list it might be in. If it's not in there the delete + // doesn't cause harm. + w.mu.Lock() + delete(w.watches, name) + delete(w.dirs, name) + w.mu.Unlock() + + stat, err := os.Stat(name) + if err != nil { + return err + } + + // Remove associations for every file in the directory. + if stat.IsDir() { + err := w.handleDirectory(name, stat, false, w.dissociateFile) + if err != nil { + return err + } + return nil + } + + err = w.port.DissociatePath(name) + if err != nil { + return err + } + + return nil +} + +// readEvents contains the main loop that runs in a goroutine watching for events. +func (w *fen) readEvents() { + // If this function returns, the watcher has been closed and we can close + // these channels + defer func() { + close(w.Errors) + close(w.Events) + }() + + pevents := make([]unix.PortEvent, 8) + for { + count, err := w.port.Get(pevents, 1, nil) + if err != nil && err != unix.ETIME { + // Interrupted system call (count should be 0) ignore and continue + if errors.Is(err, unix.EINTR) && count == 0 { + continue + } + // Get failed because we called w.Close() + if errors.Is(err, unix.EBADF) && w.isClosed() { + return + } + // There was an error not caused by calling w.Close() + if !w.sendError(fmt.Errorf("port.Get: %w", err)) { + return + } + } + + p := pevents[:count] + for _, pevent := range p { + if pevent.Source != unix.PORT_SOURCE_FILE { + // Event from unexpected source received; should never happen. + if !w.sendError(errors.New("Event from unexpected source received")) { + return + } + continue + } + + if debug { + internal.Debug(pevent.Path, pevent.Events) + } + + err = w.handleEvent(&pevent) + if !w.sendError(err) { + return + } + } + } +} + +func (w *fen) handleDirectory(path string, stat os.FileInfo, follow bool, handler func(string, os.FileInfo, bool) error) error { + files, err := os.ReadDir(path) + if err != nil { + return err + } + + // Handle all children of the directory. + for _, entry := range files { + finfo, err := entry.Info() + if err != nil { + return err + } + err = handler(filepath.Join(path, finfo.Name()), finfo, false) + if err != nil { + return err + } + } + + // And finally handle the directory itself. + return handler(path, stat, follow) +} + +// handleEvent might need to emit more than one fsnotify event if the events +// bitmap matches more than one event type (e.g. the file was both modified and +// had the attributes changed between when the association was created and the +// when event was returned) +func (w *fen) handleEvent(event *unix.PortEvent) error { + var ( + events = event.Events + path = event.Path + fmode = event.Cookie.(os.FileMode) + reRegister = true + ) + + w.mu.Lock() + _, watchedDir := w.dirs[path] + _, watchedPath := w.watches[path] + w.mu.Unlock() + isWatched := watchedDir || watchedPath + + if events&unix.FILE_DELETE != 0 { + if !w.sendEvent(Event{Name: path, Op: Remove}) { + return nil + } + reRegister = false + } + if events&unix.FILE_RENAME_FROM != 0 { + if !w.sendEvent(Event{Name: path, Op: Rename}) { + return nil + } + // Don't keep watching the new file name + reRegister = false + } + if events&unix.FILE_RENAME_TO != 0 { + // We don't report a Rename event for this case, because Rename events + // are interpreted as referring to the _old_ name of the file, and in + // this case the event would refer to the new name of the file. This + // type of rename event is not supported by fsnotify. + + // inotify reports a Remove event in this case, so we simulate this + // here. + if !w.sendEvent(Event{Name: path, Op: Remove}) { + return nil + } + // Don't keep watching the file that was removed + reRegister = false + } + + // The file is gone, nothing left to do. + if !reRegister { + if watchedDir { + w.mu.Lock() + delete(w.dirs, path) + w.mu.Unlock() + } + if watchedPath { + w.mu.Lock() + delete(w.watches, path) + w.mu.Unlock() + } + return nil + } + + // If we didn't get a deletion the file still exists and we're going to have + // to watch it again. Let's Stat it now so that we can compare permissions + // and have what we need to continue watching the file + + stat, err := os.Lstat(path) + if err != nil { + // This is unexpected, but we should still emit an event. This happens + // most often on "rm -r" of a subdirectory inside a watched directory We + // get a modify event of something happening inside, but by the time we + // get here, the sudirectory is already gone. Clearly we were watching + // this path but now it is gone. Let's tell the user that it was + // removed. + if !w.sendEvent(Event{Name: path, Op: Remove}) { + return nil + } + // Suppress extra write events on removed directories; they are not + // informative and can be confusing. + return nil + } + + // resolve symlinks that were explicitly watched as we would have at Add() + // time. this helps suppress spurious Chmod events on watched symlinks + if isWatched { + stat, err = os.Stat(path) + if err != nil { + // The symlink still exists, but the target is gone. Report the + // Remove similar to above. + if !w.sendEvent(Event{Name: path, Op: Remove}) { + return nil + } + // Don't return the error + } + } + + if events&unix.FILE_MODIFIED != 0 { + if fmode.IsDir() && watchedDir { + if err := w.updateDirectory(path); err != nil { + return err + } + } else { + if !w.sendEvent(Event{Name: path, Op: Write}) { + return nil + } + } + } + if events&unix.FILE_ATTRIB != 0 && stat != nil { + // Only send Chmod if perms changed + if stat.Mode().Perm() != fmode.Perm() { + if !w.sendEvent(Event{Name: path, Op: Chmod}) { + return nil + } + } + } + + if stat != nil { + // If we get here, it means we've hit an event above that requires us to + // continue watching the file or directory + err := w.associateFile(path, stat, isWatched) + if errors.Is(err, fs.ErrNotExist) { + // Path may have been removed since the stat. + err = nil + } + return err + } + return nil +} + +// The directory was modified, so we must find unwatched entities and watch +// them. If something was removed from the directory, nothing will happen, as +// everything else should still be watched. +func (w *fen) updateDirectory(path string) error { + files, err := os.ReadDir(path) + if err != nil { + // Directory no longer exists: probably just deleted since we got the + // event. + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + + for _, entry := range files { + path := filepath.Join(path, entry.Name()) + if w.port.PathIsWatched(path) { + continue + } + + finfo, err := entry.Info() + if err != nil { + return err + } + err = w.associateFile(path, finfo, false) + if errors.Is(err, fs.ErrNotExist) { + // File may have disappeared between getting the dir listing and + // adding the port: that's okay to ignore. + continue + } + if !w.sendError(err) { + return nil + } + if !w.sendEvent(Event{Name: path, Op: Create}) { + return nil + } + } + return nil +} + +func (w *fen) associateFile(path string, stat os.FileInfo, follow bool) error { + if w.isClosed() { + return ErrClosed + } + // This is primarily protecting the call to AssociatePath but it is + // important and intentional that the call to PathIsWatched is also + // protected by this mutex. Without this mutex, AssociatePath has been seen + // to error out that the path is already associated. + w.mu.Lock() + defer w.mu.Unlock() + + if w.port.PathIsWatched(path) { + // Remove the old association in favor of this one If we get ENOENT, + // then while the x/sys/unix wrapper still thought that this path was + // associated, the underlying event port did not. This call will have + // cleared up that discrepancy. The most likely cause is that the event + // has fired but we haven't processed it yet. + err := w.port.DissociatePath(path) + if err != nil && !errors.Is(err, unix.ENOENT) { + return fmt.Errorf("port.DissociatePath(%q): %w", path, err) + } + } + + var events int + if !follow { + // Watch symlinks themselves rather than their targets unless this entry + // is explicitly watched. + events |= unix.FILE_NOFOLLOW + } + if true { // TODO: implement withOps() + events |= unix.FILE_MODIFIED + } + if true { + events |= unix.FILE_ATTRIB + } + err := w.port.AssociatePath(path, stat, events, stat.Mode()) + if err != nil { + return fmt.Errorf("port.AssociatePath(%q): %w", path, err) + } + return nil +} + +func (w *fen) dissociateFile(path string, stat os.FileInfo, unused bool) error { + if !w.port.PathIsWatched(path) { + return nil + } + err := w.port.DissociatePath(path) + if err != nil { + return fmt.Errorf("port.DissociatePath(%q): %w", path, err) + } + return nil +} + +func (w *fen) WatchList() []string { + if w.isClosed() { + return nil + } + + w.mu.Lock() + defer w.mu.Unlock() + + entries := make([]string, 0, len(w.watches)+len(w.dirs)) + for pathname := range w.dirs { + entries = append(entries, pathname) + } + for pathname := range w.watches { + entries = append(entries, pathname) + } + + return entries +} + +func (w *fen) xSupports(op Op) bool { + if op.Has(xUnportableOpen) || op.Has(xUnportableRead) || + op.Has(xUnportableCloseWrite) || op.Has(xUnportableCloseRead) { + return false + } + return true +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_inotify.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_inotify.go new file mode 100644 index 00000000..a36cb89d --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_inotify.go @@ -0,0 +1,583 @@ +//go:build linux && !appengine + +package fsnotify + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + "time" + "unsafe" + + "github.com/fsnotify/fsnotify/internal" + "golang.org/x/sys/unix" +) + +type inotify struct { + *shared + Events chan Event + Errors chan error + + // Store fd here as os.File.Read() will no longer return on close after + // calling Fd(). See: https://github.com/golang/go/issues/26439 + fd int + inotifyFile *os.File + watches *watches + doneResp chan struct{} // Channel to respond to Close + + // Store rename cookies in an array, with the index wrapping to 0. Almost + // all of the time what we get is a MOVED_FROM to set the cookie and the + // next event inotify sends will be MOVED_TO to read it. However, this is + // not guaranteed – as described in inotify(7) – and we may get other events + // between the two MOVED_* events (including other MOVED_* ones). + // + // A second issue is that moving a file outside the watched directory will + // trigger a MOVED_FROM to set the cookie, but we never see the MOVED_TO to + // read and delete it. So just storing it in a map would slowly leak memory. + // + // Doing it like this gives us a simple fast LRU-cache that won't allocate. + // Ten items should be more than enough for our purpose, and a loop over + // such a short array is faster than a map access anyway (not that it hugely + // matters since we're talking about hundreds of ns at the most, but still). + cookies [10]koekje + cookieIndex uint8 + cookiesMu sync.Mutex +} + +type ( + watches struct { + wd map[uint32]*watch // wd → watch + path map[string]uint32 // pathname → wd + } + watch struct { + wd uint32 // Watch descriptor (as returned by the inotify_add_watch() syscall) + flags uint32 // inotify flags of this watch (see inotify(7) for the list of valid flags) + path string // Watch path. + recurse bool // Recursion with ./...? + } + koekje struct { + cookie uint32 + path string + } +) + +func newWatches() *watches { + return &watches{ + wd: make(map[uint32]*watch), + path: make(map[string]uint32), + } +} + +func (w *watches) byPath(path string) *watch { return w.wd[w.path[path]] } +func (w *watches) byWd(wd uint32) *watch { return w.wd[wd] } +func (w *watches) len() int { return len(w.wd) } +func (w *watches) add(ww *watch) { w.wd[ww.wd] = ww; w.path[ww.path] = ww.wd } +func (w *watches) remove(watch *watch) { delete(w.path, watch.path); delete(w.wd, watch.wd) } + +func (w *watches) removePath(path string) ([]uint32, error) { + path, recurse := recursivePath(path) + wd, ok := w.path[path] + if !ok { + return nil, fmt.Errorf("%w: %s", ErrNonExistentWatch, path) + } + + watch := w.wd[wd] + if recurse && !watch.recurse { + return nil, fmt.Errorf("can't use /... with non-recursive watch %q", path) + } + + delete(w.path, path) + delete(w.wd, wd) + if !watch.recurse { + return []uint32{wd}, nil + } + + wds := make([]uint32, 0, 8) + wds = append(wds, wd) + for p, rwd := range w.path { + if strings.HasPrefix(p, path) { + delete(w.path, p) + delete(w.wd, rwd) + wds = append(wds, rwd) + } + } + return wds, nil +} + +func (w *watches) updatePath(path string, f func(*watch) (*watch, error)) error { + var existing *watch + wd, ok := w.path[path] + if ok { + existing = w.wd[wd] + } + + upd, err := f(existing) + if err != nil { + return err + } + if upd != nil { + w.wd[upd.wd] = upd + w.path[upd.path] = upd.wd + + if upd.wd != wd { + delete(w.wd, wd) + } + } + + return nil +} + +var defaultBufferSize = 0 + +func newBackend(ev chan Event, errs chan error) (backend, error) { + // Need to set nonblocking mode for SetDeadline to work, otherwise blocking + // I/O operations won't terminate on close. + fd, errno := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) + if fd == -1 { + return nil, errno + } + + w := &inotify{ + shared: newShared(ev, errs), + Events: ev, + Errors: errs, + fd: fd, + inotifyFile: os.NewFile(uintptr(fd), ""), + watches: newWatches(), + doneResp: make(chan struct{}), + } + + go w.readEvents() + return w, nil +} + +func (w *inotify) Close() error { + if w.shared.close() { + return nil + } + + // Causes any blocking reads to return with an error, provided the file + // still supports deadline operations. + err := w.inotifyFile.Close() + if err != nil { + return err + } + + <-w.doneResp // Wait for readEvents() to finish. + return nil +} + +func (w *inotify) Add(name string) error { return w.AddWith(name) } + +func (w *inotify) AddWith(path string, opts ...addOpt) error { + if w.isClosed() { + return ErrClosed + } + if debug { + fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s AddWith(%q)\n", + time.Now().Format("15:04:05.000000000"), path) + } + + with := getOptions(opts...) + if !w.xSupports(with.op) { + return fmt.Errorf("%w: %s", xErrUnsupported, with.op) + } + + add := func(path string, with withOpts, recurse bool) error { + var flags uint32 + if with.noFollow { + flags |= unix.IN_DONT_FOLLOW + } + if with.op.Has(Create) { + flags |= unix.IN_CREATE + } + if with.op.Has(Write) { + flags |= unix.IN_MODIFY + } + if with.op.Has(Remove) { + flags |= unix.IN_DELETE | unix.IN_DELETE_SELF + } + if with.op.Has(Rename) { + flags |= unix.IN_MOVED_TO | unix.IN_MOVED_FROM | unix.IN_MOVE_SELF + } + if with.op.Has(Chmod) { + flags |= unix.IN_ATTRIB + } + if with.op.Has(xUnportableOpen) { + flags |= unix.IN_OPEN + } + if with.op.Has(xUnportableRead) { + flags |= unix.IN_ACCESS + } + if with.op.Has(xUnportableCloseWrite) { + flags |= unix.IN_CLOSE_WRITE + } + if with.op.Has(xUnportableCloseRead) { + flags |= unix.IN_CLOSE_NOWRITE + } + return w.register(path, flags, recurse) + } + + w.mu.Lock() + defer w.mu.Unlock() + path, recurse := recursivePath(path) + if recurse { + return filepath.WalkDir(path, func(root string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + if root == path { + return fmt.Errorf("fsnotify: not a directory: %q", path) + } + return nil + } + + // Send a Create event when adding new directory from a recursive + // watch; this is for "mkdir -p one/two/three". Usually all those + // directories will be created before we can set up watchers on the + // subdirectories, so only "one" would be sent as a Create event and + // not "one/two" and "one/two/three" (inotifywait -r has the same + // problem). + if with.sendCreate && root != path { + w.sendEvent(Event{Name: root, Op: Create}) + } + + return add(root, with, true) + }) + } + + return add(path, with, false) +} + +func (w *inotify) register(path string, flags uint32, recurse bool) error { + return w.watches.updatePath(path, func(existing *watch) (*watch, error) { + if existing != nil { + flags |= existing.flags | unix.IN_MASK_ADD + } + + wd, err := unix.InotifyAddWatch(w.fd, path, flags) + if wd == -1 { + return nil, err + } + + if e, ok := w.watches.wd[uint32(wd)]; ok { + return e, nil + } + + if existing == nil { + return &watch{ + wd: uint32(wd), + path: path, + flags: flags, + recurse: recurse, + }, nil + } + + existing.wd = uint32(wd) + existing.flags = flags + return existing, nil + }) +} + +func (w *inotify) Remove(name string) error { + if w.isClosed() { + return nil + } + if debug { + fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s Remove(%q)\n", + time.Now().Format("15:04:05.000000000"), name) + } + + w.mu.Lock() + defer w.mu.Unlock() + return w.remove(filepath.Clean(name)) +} + +func (w *inotify) remove(name string) error { + wds, err := w.watches.removePath(name) + if err != nil { + return err + } + + for _, wd := range wds { + _, err := unix.InotifyRmWatch(w.fd, wd) + if err != nil { + // TODO: Perhaps it's not helpful to return an error here in every + // case; the only two possible errors are: + // + // EBADF, which happens when w.fd is not a valid file descriptor of + // any kind. + // + // EINVAL, which is when fd is not an inotify descriptor or wd is + // not a valid watch descriptor. Watch descriptors are invalidated + // when they are removed explicitly or implicitly; explicitly by + // inotify_rm_watch, implicitly when the file they are watching is + // deleted. + return err + } + } + return nil +} + +func (w *inotify) WatchList() []string { + if w.isClosed() { + return nil + } + + w.mu.Lock() + defer w.mu.Unlock() + entries := make([]string, 0, w.watches.len()) + for pathname := range w.watches.path { + entries = append(entries, pathname) + } + return entries +} + +// readEvents reads from the inotify file descriptor, converts the +// received events into Event objects and sends them via the Events channel +func (w *inotify) readEvents() { + defer func() { + close(w.doneResp) + close(w.Errors) + close(w.Events) + }() + + var buf [unix.SizeofInotifyEvent * 4096]byte // Buffer for a maximum of 4096 raw events + for { + if w.isClosed() { + return + } + + n, err := w.inotifyFile.Read(buf[:]) + if err != nil { + if errors.Is(err, os.ErrClosed) { + return + } + if !w.sendError(err) { + return + } + continue + } + + if n < unix.SizeofInotifyEvent { + err := errors.New("notify: short read in readEvents()") // Read was too short. + if n == 0 { + err = io.EOF // If EOF is received. This should really never happen. + } + if !w.sendError(err) { + return + } + continue + } + + // We don't know how many events we just read into the buffer While the + // offset points to at least one whole event. + var offset uint32 + for offset <= uint32(n-unix.SizeofInotifyEvent) { + // Point to the event in the buffer. + inEvent := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset])) + + if inEvent.Mask&unix.IN_Q_OVERFLOW != 0 { + if !w.sendError(ErrEventOverflow) { + return + } + } + + ev, ok := w.handleEvent(inEvent, &buf, offset) + if !ok { + return + } + if !w.sendEvent(ev) { + return + } + + // Move to the next event in the buffer + offset += unix.SizeofInotifyEvent + inEvent.Len + } + } +} + +func (w *inotify) handleEvent(inEvent *unix.InotifyEvent, buf *[65536]byte, offset uint32) (Event, bool) { + w.mu.Lock() + defer w.mu.Unlock() + + /// If the event happened to the watched directory or the watched file, the + /// kernel doesn't append the filename to the event, but we would like to + /// always fill the the "Name" field with a valid filename. We retrieve the + /// path of the watch from the "paths" map. + /// + /// Can be nil if Remove() was called in another goroutine for this path + /// inbetween reading the events from the kernel and reading the internal + /// state. Not much we can do about it, so just skip. See #616. + watch := w.watches.byWd(uint32(inEvent.Wd)) + if watch == nil { + return Event{}, true + } + + var ( + name = watch.path + nameLen = uint32(inEvent.Len) + ) + if nameLen > 0 { + /// Point "bytes" at the first byte of the filename + bb := *buf + bytes := (*[unix.PathMax]byte)(unsafe.Pointer(&bb[offset+unix.SizeofInotifyEvent]))[:nameLen:nameLen] + /// The filename is padded with NULL bytes. TrimRight() gets rid of those. + name += "/" + strings.TrimRight(string(bytes[0:nameLen]), "\x00") + } + + if debug { + internal.Debug(name, inEvent.Mask, inEvent.Cookie) + } + + if inEvent.Mask&unix.IN_IGNORED != 0 || inEvent.Mask&unix.IN_UNMOUNT != 0 { + w.watches.remove(watch) + return Event{}, true + } + + // inotify will automatically remove the watch on deletes; just need + // to clean our state here. + if inEvent.Mask&unix.IN_DELETE_SELF == unix.IN_DELETE_SELF { + w.watches.remove(watch) + } + + // We can't really update the state when a watched path is moved; only + // IN_MOVE_SELF is sent and not IN_MOVED_{FROM,TO}. So remove the watch. + if inEvent.Mask&unix.IN_MOVE_SELF == unix.IN_MOVE_SELF { + if watch.recurse { // Do nothing + return Event{}, true + } + + err := w.remove(watch.path) + if err != nil && !errors.Is(err, ErrNonExistentWatch) { + if !w.sendError(err) { + return Event{}, false + } + } + } + + /// Skip if we're watching both this path and the parent; the parent will + /// already send a delete so no need to do it twice. + if inEvent.Mask&unix.IN_DELETE_SELF != 0 { + _, ok := w.watches.path[filepath.Dir(watch.path)] + if ok { + return Event{}, true + } + } + + ev := w.newEvent(name, inEvent.Mask, inEvent.Cookie) + // Need to update watch path for recurse. + if watch.recurse { + isDir := inEvent.Mask&unix.IN_ISDIR == unix.IN_ISDIR + /// New directory created: set up watch on it. + if isDir && ev.Has(Create) { + err := w.register(ev.Name, watch.flags, true) + if !w.sendError(err) { + return Event{}, false + } + + // This was a directory rename, so we need to update all the + // children. + // + // TODO: this is of course pretty slow; we should use a better data + // structure for storing all of this, e.g. store children in the + // watch. I have some code for this in my kqueue refactor we can use + // in the future. For now I'm okay with this as it's not publicly + // available. Correctness first, performance second. + if ev.renamedFrom != "" { + for k, ww := range w.watches.wd { + if k == watch.wd || ww.path == ev.Name { + continue + } + if strings.HasPrefix(ww.path, ev.renamedFrom) { + ww.path = strings.Replace(ww.path, ev.renamedFrom, ev.Name, 1) + w.watches.wd[k] = ww + } + } + } + } + } + + return ev, true +} + +func (w *inotify) isRecursive(path string) bool { + ww := w.watches.byPath(path) + if ww == nil { // path could be a file, so also check the Dir. + ww = w.watches.byPath(filepath.Dir(path)) + } + return ww != nil && ww.recurse +} + +func (w *inotify) newEvent(name string, mask, cookie uint32) Event { + e := Event{Name: name} + if mask&unix.IN_CREATE == unix.IN_CREATE || mask&unix.IN_MOVED_TO == unix.IN_MOVED_TO { + e.Op |= Create + } + if mask&unix.IN_DELETE_SELF == unix.IN_DELETE_SELF || mask&unix.IN_DELETE == unix.IN_DELETE { + e.Op |= Remove + } + if mask&unix.IN_MODIFY == unix.IN_MODIFY { + e.Op |= Write + } + if mask&unix.IN_OPEN == unix.IN_OPEN { + e.Op |= xUnportableOpen + } + if mask&unix.IN_ACCESS == unix.IN_ACCESS { + e.Op |= xUnportableRead + } + if mask&unix.IN_CLOSE_WRITE == unix.IN_CLOSE_WRITE { + e.Op |= xUnportableCloseWrite + } + if mask&unix.IN_CLOSE_NOWRITE == unix.IN_CLOSE_NOWRITE { + e.Op |= xUnportableCloseRead + } + if mask&unix.IN_MOVE_SELF == unix.IN_MOVE_SELF || mask&unix.IN_MOVED_FROM == unix.IN_MOVED_FROM { + e.Op |= Rename + } + if mask&unix.IN_ATTRIB == unix.IN_ATTRIB { + e.Op |= Chmod + } + + if cookie != 0 { + if mask&unix.IN_MOVED_FROM == unix.IN_MOVED_FROM { + w.cookiesMu.Lock() + w.cookies[w.cookieIndex] = koekje{cookie: cookie, path: e.Name} + w.cookieIndex++ + if w.cookieIndex > 9 { + w.cookieIndex = 0 + } + w.cookiesMu.Unlock() + } else if mask&unix.IN_MOVED_TO == unix.IN_MOVED_TO { + w.cookiesMu.Lock() + var prev string + for _, c := range w.cookies { + if c.cookie == cookie { + prev = c.path + break + } + } + w.cookiesMu.Unlock() + e.renamedFrom = prev + } + } + return e +} + +func (w *inotify) xSupports(op Op) bool { + return true // Supports everything. +} + +func (w *inotify) state() { + w.mu.Lock() + defer w.mu.Unlock() + for wd, ww := range w.watches.wd { + fmt.Fprintf(os.Stderr, "%4d: recurse=%t %q\n", wd, ww.recurse, ww.path) + } +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_kqueue.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_kqueue.go new file mode 100644 index 00000000..340aeec0 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_kqueue.go @@ -0,0 +1,705 @@ +//go:build freebsd || openbsd || netbsd || dragonfly || darwin + +package fsnotify + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + "time" + + "github.com/fsnotify/fsnotify/internal" + "golang.org/x/sys/unix" +) + +type kqueue struct { + *shared + Events chan Event + Errors chan error + + kq int // File descriptor (as returned by the kqueue() syscall). + closepipe [2]int // Pipe used for closing kq. + watches *watches +} + +type ( + watches struct { + mu sync.RWMutex + wd map[int]watch // wd → watch + path map[string]int // pathname → wd + byDir map[string]map[int]struct{} // dirname(path) → wd + seen map[string]struct{} // Keep track of if we know this file exists. + byUser map[string]struct{} // Watches added with Watcher.Add() + } + watch struct { + wd int + name string + linkName string // In case of links; name is the target, and this is the link. + isDir bool + dirFlags uint32 + } +) + +func newWatches() *watches { + return &watches{ + wd: make(map[int]watch), + path: make(map[string]int), + byDir: make(map[string]map[int]struct{}), + seen: make(map[string]struct{}), + byUser: make(map[string]struct{}), + } +} + +func (w *watches) listPaths(userOnly bool) []string { + w.mu.RLock() + defer w.mu.RUnlock() + + if userOnly { + l := make([]string, 0, len(w.byUser)) + for p := range w.byUser { + l = append(l, p) + } + return l + } + + l := make([]string, 0, len(w.path)) + for p := range w.path { + l = append(l, p) + } + return l +} + +func (w *watches) watchesInDir(path string) []string { + w.mu.RLock() + defer w.mu.RUnlock() + + l := make([]string, 0, 4) + for fd := range w.byDir[path] { + info := w.wd[fd] + if _, ok := w.byUser[info.name]; !ok { + l = append(l, info.name) + } + } + return l +} + +// Mark path as added by the user. +func (w *watches) addUserWatch(path string) { + w.mu.Lock() + defer w.mu.Unlock() + w.byUser[path] = struct{}{} +} + +func (w *watches) addLink(path string, fd int) { + w.mu.Lock() + defer w.mu.Unlock() + + w.path[path] = fd + w.seen[path] = struct{}{} +} + +func (w *watches) add(path, linkPath string, fd int, isDir bool) { + w.mu.Lock() + defer w.mu.Unlock() + + w.path[path] = fd + w.wd[fd] = watch{wd: fd, name: path, linkName: linkPath, isDir: isDir} + + parent := filepath.Dir(path) + byDir, ok := w.byDir[parent] + if !ok { + byDir = make(map[int]struct{}, 1) + w.byDir[parent] = byDir + } + byDir[fd] = struct{}{} +} + +func (w *watches) byWd(fd int) (watch, bool) { + w.mu.RLock() + defer w.mu.RUnlock() + info, ok := w.wd[fd] + return info, ok +} + +func (w *watches) byPath(path string) (watch, bool) { + w.mu.RLock() + defer w.mu.RUnlock() + info, ok := w.wd[w.path[path]] + return info, ok +} + +func (w *watches) updateDirFlags(path string, flags uint32) bool { + w.mu.Lock() + defer w.mu.Unlock() + + fd, ok := w.path[path] + if !ok { // Already deleted: don't re-set it here. + return false + } + info := w.wd[fd] + info.dirFlags = flags + w.wd[fd] = info + return true +} + +func (w *watches) remove(fd int, path string) bool { + w.mu.Lock() + defer w.mu.Unlock() + + isDir := w.wd[fd].isDir + delete(w.path, path) + delete(w.byUser, path) + + parent := filepath.Dir(path) + delete(w.byDir[parent], fd) + + if len(w.byDir[parent]) == 0 { + delete(w.byDir, parent) + } + + delete(w.wd, fd) + delete(w.seen, path) + return isDir +} + +func (w *watches) markSeen(path string, exists bool) { + w.mu.Lock() + defer w.mu.Unlock() + if exists { + w.seen[path] = struct{}{} + } else { + delete(w.seen, path) + } +} + +func (w *watches) seenBefore(path string) bool { + w.mu.RLock() + defer w.mu.RUnlock() + _, ok := w.seen[path] + return ok +} + +var defaultBufferSize = 0 + +func newBackend(ev chan Event, errs chan error) (backend, error) { + kq, closepipe, err := newKqueue() + if err != nil { + return nil, err + } + + w := &kqueue{ + shared: newShared(ev, errs), + Events: ev, + Errors: errs, + kq: kq, + closepipe: closepipe, + watches: newWatches(), + } + + go w.readEvents() + return w, nil +} + +// newKqueue creates a new kernel event queue and returns a descriptor. +// +// This registers a new event on closepipe, which will trigger an event when +// it's closed. This way we can use kevent() without timeout/polling; without +// the closepipe, it would block forever and we wouldn't be able to stop it at +// all. +func newKqueue() (kq int, closepipe [2]int, err error) { + kq, err = unix.Kqueue() + if err != nil { + return kq, closepipe, err + } + + // Register the close pipe. + err = unix.Pipe(closepipe[:]) + if err != nil { + unix.Close(kq) + return kq, closepipe, err + } + unix.CloseOnExec(closepipe[0]) + unix.CloseOnExec(closepipe[1]) + + // Register changes to listen on the closepipe. + changes := make([]unix.Kevent_t, 1) + // SetKevent converts int to the platform-specific types. + unix.SetKevent(&changes[0], closepipe[0], unix.EVFILT_READ, + unix.EV_ADD|unix.EV_ENABLE|unix.EV_ONESHOT) + + ok, err := unix.Kevent(kq, changes, nil, nil) + if ok == -1 { + unix.Close(kq) + unix.Close(closepipe[0]) + unix.Close(closepipe[1]) + return kq, closepipe, err + } + return kq, closepipe, nil +} + +func (w *kqueue) Close() error { + if w.shared.close() { + return nil + } + + pathsToRemove := w.watches.listPaths(false) + for _, name := range pathsToRemove { + w.Remove(name) + } + + unix.Close(w.closepipe[1]) // Send "quit" message to readEvents + return nil +} + +func (w *kqueue) Add(name string) error { return w.AddWith(name) } + +func (w *kqueue) AddWith(name string, opts ...addOpt) error { + if debug { + fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s AddWith(%q)\n", + time.Now().Format("15:04:05.000000000"), name) + } + + with := getOptions(opts...) + if !w.xSupports(with.op) { + return fmt.Errorf("%w: %s", xErrUnsupported, with.op) + } + + _, err := w.addWatch(name, noteAllEvents, false) + if err != nil { + return err + } + w.watches.addUserWatch(name) + return nil +} + +func (w *kqueue) Remove(name string) error { + if debug { + fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s Remove(%q)\n", + time.Now().Format("15:04:05.000000000"), name) + } + return w.remove(name, true) +} + +func (w *kqueue) remove(name string, unwatchFiles bool) error { + if w.isClosed() { + return nil + } + + name = filepath.Clean(name) + info, ok := w.watches.byPath(name) + if !ok { + return fmt.Errorf("%w: %s", ErrNonExistentWatch, name) + } + + err := w.register([]int{info.wd}, unix.EV_DELETE, 0) + if err != nil { + return err + } + + unix.Close(info.wd) + + isDir := w.watches.remove(info.wd, name) + + // Find all watched paths that are in this directory that are not external. + if unwatchFiles && isDir { + pathsToRemove := w.watches.watchesInDir(name) + for _, name := range pathsToRemove { + // Since these are internal, not much sense in propagating error to + // the user, as that will just confuse them with an error about a + // path they did not explicitly watch themselves. + w.Remove(name) + } + } + return nil +} + +func (w *kqueue) WatchList() []string { + if w.isClosed() { + return nil + } + return w.watches.listPaths(true) +} + +// Watch all events (except NOTE_EXTEND, NOTE_LINK, NOTE_REVOKE) +const noteAllEvents = unix.NOTE_DELETE | unix.NOTE_WRITE | unix.NOTE_ATTRIB | unix.NOTE_RENAME + +// addWatch adds name to the watched file set; the flags are interpreted as +// described in kevent(2). +// +// Returns the real path to the file which was added, with symlinks resolved. +func (w *kqueue) addWatch(name string, flags uint32, listDir bool) (string, error) { + if w.isClosed() { + return "", ErrClosed + } + + name = filepath.Clean(name) + + info, alreadyWatching := w.watches.byPath(name) + if !alreadyWatching { + fi, err := os.Lstat(name) + if err != nil { + return "", err + } + + // Don't watch sockets or named pipes. + if (fi.Mode()&os.ModeSocket == os.ModeSocket) || (fi.Mode()&os.ModeNamedPipe == os.ModeNamedPipe) { + return "", nil + } + + // Follow symlinks, but only for paths added with Add(), and not paths + // we're adding from internalWatch from a listdir. + if !listDir && fi.Mode()&os.ModeSymlink == os.ModeSymlink { + link, err := os.Readlink(name) + if err != nil { + return "", err + } + if !filepath.IsAbs(link) { + link = filepath.Join(filepath.Dir(name), link) + } + + _, alreadyWatching = w.watches.byPath(link) + if alreadyWatching { + // Add to watches so we don't get spurious Create events later + // on when we diff the directories. + w.watches.addLink(name, 0) + return link, nil + } + + info.linkName = name + name = link + fi, err = os.Lstat(name) + if err != nil { + return "", err + } + } + + // Retry on EINTR; open() can return EINTR in practice on macOS. + // See #354, and Go issues 11180 and 39237. + for { + info.wd, err = unix.Open(name, openMode, 0) + if err == nil { + break + } + if errors.Is(err, unix.EINTR) { + continue + } + return "", err + } + + info.isDir = fi.IsDir() + } + + err := w.register([]int{info.wd}, unix.EV_ADD|unix.EV_CLEAR|unix.EV_ENABLE, flags) + if err != nil { + unix.Close(info.wd) + return "", err + } + + if !alreadyWatching { + w.watches.add(name, info.linkName, info.wd, info.isDir) + } + + // Watch the directory if it has not been watched before, or if it was + // watched before, but perhaps only a NOTE_DELETE (watchDirectoryFiles) + if info.isDir { + watchDir := (flags&unix.NOTE_WRITE) == unix.NOTE_WRITE && + (!alreadyWatching || (info.dirFlags&unix.NOTE_WRITE) != unix.NOTE_WRITE) + if !w.watches.updateDirFlags(name, flags) { + return "", nil + } + + if watchDir { + d := name + if info.linkName != "" { + d = info.linkName + } + if err := w.watchDirectoryFiles(d); err != nil { + return "", err + } + } + } + return name, nil +} + +// readEvents reads from kqueue and converts the received kevents into +// Event values that it sends down the Events channel. +func (w *kqueue) readEvents() { + defer func() { + close(w.Events) + close(w.Errors) + _ = unix.Close(w.kq) + unix.Close(w.closepipe[0]) + }() + + eventBuffer := make([]unix.Kevent_t, 10) + for { + kevents, err := w.read(eventBuffer) + // EINTR is okay, the syscall was interrupted before timeout expired. + if err != nil && err != unix.EINTR { + if !w.sendError(fmt.Errorf("fsnotify.readEvents: %w", err)) { + return + } + } + + for _, kevent := range kevents { + var ( + wd = int(kevent.Ident) + mask = uint32(kevent.Fflags) + ) + + // Shut down the loop when the pipe is closed, but only after all + // other events have been processed. + if wd == w.closepipe[0] { + return + } + + path, ok := w.watches.byWd(wd) + if debug { + internal.Debug(path.name, &kevent) + } + + // On macOS it seems that sometimes an event with Ident=0 is + // delivered, and no other flags/information beyond that, even + // though we never saw such a file descriptor. For example in + // TestWatchSymlink/277 (usually at the end, but sometimes sooner): + // + // fmt.Printf("READ: %2d %#v\n", kevent.Ident, kevent) + // unix.Kevent_t{Ident:0x2a, Filter:-4, Flags:0x25, Fflags:0x2, Data:0, Udata:(*uint8)(nil)} + // unix.Kevent_t{Ident:0x0, Filter:-4, Flags:0x25, Fflags:0x2, Data:0, Udata:(*uint8)(nil)} + // + // The first is a normal event, the second with Ident 0. No error + // flag, no data, no ... nothing. + // + // I read a bit through bsd/kern_event.c from the xnu source, but I + // don't really see an obvious location where this is triggered – + // this doesn't seem intentional, but idk... + // + // Technically fd 0 is a valid descriptor, so only skip it if + // there's no path, and if we're on macOS. + if !ok && kevent.Ident == 0 && runtime.GOOS == "darwin" { + continue + } + + event := w.newEvent(path.name, path.linkName, mask) + + if event.Has(Rename) || event.Has(Remove) { + w.remove(event.Name, false) + w.watches.markSeen(event.Name, false) + } + + if path.isDir && event.Has(Write) && !event.Has(Remove) { + w.dirChange(event.Name) + } else if !w.sendEvent(event) { + return + } + + if event.Has(Remove) { + // Look for a file that may have overwritten this; for example, + // mv f1 f2 will delete f2, then create f2. + if path.isDir { + fileDir := filepath.Clean(event.Name) + _, found := w.watches.byPath(fileDir) + if found { + // TODO: this branch is never triggered in any test. + // Added in d6220df (2012). + // isDir check added in 8611c35 (2016): https://github.com/fsnotify/fsnotify/pull/111 + // + // I don't really get how this can be triggered either. + // And it wasn't triggered in the patch that added it, + // either. + // + // Original also had a comment: + // make sure the directory exists before we watch for + // changes. When we do a recursive watch and perform + // rm -rf, the parent directory might have gone + // missing, ignore the missing directory and let the + // upcoming delete event remove the watch from the + // parent directory. + err := w.dirChange(fileDir) + if !w.sendError(err) { + return + } + } + } else { + path := filepath.Clean(event.Name) + if fi, err := os.Lstat(path); err == nil { + err := w.sendCreateIfNew(path, fi) + if !w.sendError(err) { + return + } + } + } + } + } + } +} + +// newEvent returns an platform-independent Event based on kqueue Fflags. +func (w *kqueue) newEvent(name, linkName string, mask uint32) Event { + e := Event{Name: name} + if linkName != "" { + // If the user watched "/path/link" then emit events as "/path/link" + // rather than "/path/target". + e.Name = linkName + } + + if mask&unix.NOTE_DELETE == unix.NOTE_DELETE { + e.Op |= Remove + } + if mask&unix.NOTE_WRITE == unix.NOTE_WRITE { + e.Op |= Write + } + if mask&unix.NOTE_RENAME == unix.NOTE_RENAME { + e.Op |= Rename + } + if mask&unix.NOTE_ATTRIB == unix.NOTE_ATTRIB { + e.Op |= Chmod + } + // No point sending a write and delete event at the same time: if it's gone, + // then it's gone. + if e.Op.Has(Write) && e.Op.Has(Remove) { + e.Op &^= Write + } + return e +} + +// watchDirectoryFiles to mimic inotify when adding a watch on a directory +func (w *kqueue) watchDirectoryFiles(dirPath string) error { + files, err := os.ReadDir(dirPath) + if err != nil { + return err + } + + for _, f := range files { + path := filepath.Join(dirPath, f.Name()) + + fi, err := f.Info() + if err != nil { + return fmt.Errorf("%q: %w", path, err) + } + + cleanPath, err := w.internalWatch(path, fi) + if err != nil { + // No permission to read the file; that's not a problem: just skip. + // But do add it to w.fileExists to prevent it from being picked up + // as a "new" file later (it still shows up in the directory + // listing). + switch { + case errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM): + cleanPath = filepath.Clean(path) + default: + return fmt.Errorf("%q: %w", path, err) + } + } + + w.watches.markSeen(cleanPath, true) + } + + return nil +} + +// Search the directory for new files and send an event for them. +// +// This functionality is to have the BSD watcher match the inotify, which sends +// a create event for files created in a watched directory. +func (w *kqueue) dirChange(dir string) error { + files, err := os.ReadDir(dir) + if err != nil { + // Directory no longer exists: we can ignore this safely. kqueue will + // still give us the correct events. + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("fsnotify.dirChange %q: %w", dir, err) + } + + for _, f := range files { + fi, err := f.Info() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("fsnotify.dirChange: %w", err) + } + + err = w.sendCreateIfNew(filepath.Join(dir, fi.Name()), fi) + if err != nil { + // Don't need to send an error if this file isn't readable. + if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) || errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("fsnotify.dirChange: %w", err) + } + } + return nil +} + +// Send a create event if the file isn't already being tracked, and start +// watching this file. +func (w *kqueue) sendCreateIfNew(path string, fi os.FileInfo) error { + if !w.watches.seenBefore(path) { + if !w.sendEvent(Event{Name: path, Op: Create}) { + return nil + } + } + + // Like watchDirectoryFiles, but without doing another ReadDir. + path, err := w.internalWatch(path, fi) + if err != nil { + return err + } + w.watches.markSeen(path, true) + return nil +} + +func (w *kqueue) internalWatch(name string, fi os.FileInfo) (string, error) { + if fi.IsDir() { + // mimic Linux providing delete events for subdirectories, but preserve + // the flags used if currently watching subdirectory + info, _ := w.watches.byPath(name) + return w.addWatch(name, info.dirFlags|unix.NOTE_DELETE|unix.NOTE_RENAME, true) + } + + // Watch file to mimic Linux inotify. + return w.addWatch(name, noteAllEvents, true) +} + +// Register events with the queue. +func (w *kqueue) register(fds []int, flags int, fflags uint32) error { + changes := make([]unix.Kevent_t, len(fds)) + for i, fd := range fds { + // SetKevent converts int to the platform-specific types. + unix.SetKevent(&changes[i], fd, unix.EVFILT_VNODE, flags) + changes[i].Fflags = fflags + } + + // Register the events. + success, err := unix.Kevent(w.kq, changes, nil, nil) + if success == -1 { + return err + } + return nil +} + +// read retrieves pending events, or waits until an event occurs. +func (w *kqueue) read(events []unix.Kevent_t) ([]unix.Kevent_t, error) { + n, err := unix.Kevent(w.kq, nil, events, nil) + if err != nil { + return nil, err + } + return events[0:n], nil +} + +func (w *kqueue) xSupports(op Op) bool { + //if runtime.GOOS == "freebsd" { + // return true // Supports everything. + //} + if op.Has(xUnportableOpen) || op.Has(xUnportableRead) || + op.Has(xUnportableCloseWrite) || op.Has(xUnportableCloseRead) { + return false + } + return true +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_other.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_other.go new file mode 100644 index 00000000..b8c0ad72 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_other.go @@ -0,0 +1,22 @@ +//go:build appengine || (!darwin && !dragonfly && !freebsd && !openbsd && !linux && !netbsd && !solaris && !windows) + +package fsnotify + +import "errors" + +type other struct { + Events chan Event + Errors chan error +} + +var defaultBufferSize = 0 + +func newBackend(ev chan Event, errs chan error) (backend, error) { + return nil, errors.New("fsnotify not supported on the current platform") +} +func (w *other) Close() error { return nil } +func (w *other) WatchList() []string { return nil } +func (w *other) Add(name string) error { return nil } +func (w *other) AddWith(name string, opts ...addOpt) error { return nil } +func (w *other) Remove(name string) error { return nil } +func (w *other) xSupports(op Op) bool { return false } diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_windows.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_windows.go new file mode 100644 index 00000000..3433642d --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/backend_windows.go @@ -0,0 +1,680 @@ +//go:build windows + +// Windows backend based on ReadDirectoryChangesW() +// +// https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-readdirectorychangesw + +package fsnotify + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync" + "time" + "unsafe" + + "github.com/fsnotify/fsnotify/internal" + "golang.org/x/sys/windows" +) + +type readDirChangesW struct { + Events chan Event + Errors chan error + + port windows.Handle // Handle to completion port + input chan *input // Inputs to the reader are sent on this channel + done chan chan<- error + + mu sync.Mutex // Protects access to watches, closed + watches watchMap // Map of watches (key: i-number) + closed bool // Set to true when Close() is first called +} + +var defaultBufferSize = 50 + +func newBackend(ev chan Event, errs chan error) (backend, error) { + port, err := windows.CreateIoCompletionPort(windows.InvalidHandle, 0, 0, 0) + if err != nil { + return nil, os.NewSyscallError("CreateIoCompletionPort", err) + } + w := &readDirChangesW{ + Events: ev, + Errors: errs, + port: port, + watches: make(watchMap), + input: make(chan *input, 1), + done: make(chan chan<- error, 1), + } + go w.readEvents() + return w, nil +} + +func (w *readDirChangesW) isClosed() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.closed +} + +func (w *readDirChangesW) sendEvent(name, renamedFrom string, mask uint64) bool { + if mask == 0 { + return false + } + + event := w.newEvent(name, uint32(mask)) + event.renamedFrom = renamedFrom + select { + case ch := <-w.done: + w.done <- ch + case w.Events <- event: + } + return true +} + +// Returns true if the error was sent, or false if watcher is closed. +func (w *readDirChangesW) sendError(err error) bool { + if err == nil { + return true + } + select { + case <-w.done: + return false + case w.Errors <- err: + return true + } +} + +func (w *readDirChangesW) Close() error { + if w.isClosed() { + return nil + } + + w.mu.Lock() + w.closed = true + w.mu.Unlock() + + // Send "done" message to the reader goroutine + ch := make(chan error) + w.done <- ch + if err := w.wakeupReader(); err != nil { + return err + } + return <-ch +} + +func (w *readDirChangesW) Add(name string) error { return w.AddWith(name) } + +func (w *readDirChangesW) AddWith(name string, opts ...addOpt) error { + if w.isClosed() { + return ErrClosed + } + if debug { + fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s AddWith(%q)\n", + time.Now().Format("15:04:05.000000000"), filepath.ToSlash(name)) + } + + with := getOptions(opts...) + if !w.xSupports(with.op) { + return fmt.Errorf("%w: %s", xErrUnsupported, with.op) + } + if with.bufsize < 4096 { + return fmt.Errorf("fsnotify.WithBufferSize: buffer size cannot be smaller than 4096 bytes") + } + + in := &input{ + op: opAddWatch, + path: filepath.Clean(name), + flags: sysFSALLEVENTS, + reply: make(chan error), + bufsize: with.bufsize, + } + w.input <- in + if err := w.wakeupReader(); err != nil { + return err + } + return <-in.reply +} + +func (w *readDirChangesW) Remove(name string) error { + if w.isClosed() { + return nil + } + if debug { + fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s Remove(%q)\n", + time.Now().Format("15:04:05.000000000"), filepath.ToSlash(name)) + } + + in := &input{ + op: opRemoveWatch, + path: filepath.Clean(name), + reply: make(chan error), + } + w.input <- in + if err := w.wakeupReader(); err != nil { + return err + } + return <-in.reply +} + +func (w *readDirChangesW) WatchList() []string { + if w.isClosed() { + return nil + } + + w.mu.Lock() + defer w.mu.Unlock() + + entries := make([]string, 0, len(w.watches)) + for _, entry := range w.watches { + for _, watchEntry := range entry { + for name := range watchEntry.names { + entries = append(entries, filepath.Join(watchEntry.path, name)) + } + // the directory itself is being watched + if watchEntry.mask != 0 { + entries = append(entries, watchEntry.path) + } + } + } + + return entries +} + +// These options are from the old golang.org/x/exp/winfsnotify, where you could +// add various options to the watch. This has long since been removed. +// +// The "sys" in the name is misleading as they're not part of any "system". +// +// This should all be removed at some point, and just use windows.FILE_NOTIFY_* +const ( + sysFSALLEVENTS = 0xfff + sysFSCREATE = 0x100 + sysFSDELETE = 0x200 + sysFSDELETESELF = 0x400 + sysFSMODIFY = 0x2 + sysFSMOVE = 0xc0 + sysFSMOVEDFROM = 0x40 + sysFSMOVEDTO = 0x80 + sysFSMOVESELF = 0x800 + sysFSIGNORED = 0x8000 +) + +func (w *readDirChangesW) newEvent(name string, mask uint32) Event { + e := Event{Name: name} + if mask&sysFSCREATE == sysFSCREATE || mask&sysFSMOVEDTO == sysFSMOVEDTO { + e.Op |= Create + } + if mask&sysFSDELETE == sysFSDELETE || mask&sysFSDELETESELF == sysFSDELETESELF { + e.Op |= Remove + } + if mask&sysFSMODIFY == sysFSMODIFY { + e.Op |= Write + } + if mask&sysFSMOVE == sysFSMOVE || mask&sysFSMOVESELF == sysFSMOVESELF || mask&sysFSMOVEDFROM == sysFSMOVEDFROM { + e.Op |= Rename + } + return e +} + +const ( + opAddWatch = iota + opRemoveWatch +) + +const ( + provisional uint64 = 1 << (32 + iota) +) + +type input struct { + op int + path string + flags uint32 + bufsize int + reply chan error +} + +type inode struct { + handle windows.Handle + volume uint32 + index uint64 +} + +type watch struct { + ov windows.Overlapped + ino *inode // i-number + recurse bool // Recursive watch? + path string // Directory path + mask uint64 // Directory itself is being watched with these notify flags + names map[string]uint64 // Map of names being watched and their notify flags + rename string // Remembers the old name while renaming a file + buf []byte // buffer, allocated later +} + +type ( + indexMap map[uint64]*watch + watchMap map[uint32]indexMap +) + +func (w *readDirChangesW) wakeupReader() error { + err := windows.PostQueuedCompletionStatus(w.port, 0, 0, nil) + if err != nil { + return os.NewSyscallError("PostQueuedCompletionStatus", err) + } + return nil +} + +func (w *readDirChangesW) getDir(pathname string) (dir string, err error) { + attr, err := windows.GetFileAttributes(windows.StringToUTF16Ptr(pathname)) + if err != nil { + return "", os.NewSyscallError("GetFileAttributes", err) + } + if attr&windows.FILE_ATTRIBUTE_DIRECTORY != 0 { + dir = pathname + } else { + dir, _ = filepath.Split(pathname) + dir = filepath.Clean(dir) + } + return +} + +func (w *readDirChangesW) getIno(path string) (ino *inode, err error) { + h, err := windows.CreateFile(windows.StringToUTF16Ptr(path), + windows.FILE_LIST_DIRECTORY, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OVERLAPPED, 0) + if err != nil { + return nil, os.NewSyscallError("CreateFile", err) + } + + var fi windows.ByHandleFileInformation + err = windows.GetFileInformationByHandle(h, &fi) + if err != nil { + windows.CloseHandle(h) + return nil, os.NewSyscallError("GetFileInformationByHandle", err) + } + ino = &inode{ + handle: h, + volume: fi.VolumeSerialNumber, + index: uint64(fi.FileIndexHigh)<<32 | uint64(fi.FileIndexLow), + } + return ino, nil +} + +// Must run within the I/O thread. +func (m watchMap) get(ino *inode) *watch { + if i := m[ino.volume]; i != nil { + return i[ino.index] + } + return nil +} + +// Must run within the I/O thread. +func (m watchMap) set(ino *inode, watch *watch) { + i := m[ino.volume] + if i == nil { + i = make(indexMap) + m[ino.volume] = i + } + i[ino.index] = watch +} + +// Must run within the I/O thread. +func (w *readDirChangesW) addWatch(pathname string, flags uint64, bufsize int) error { + pathname, recurse := recursivePath(pathname) + + dir, err := w.getDir(pathname) + if err != nil { + return err + } + + ino, err := w.getIno(dir) + if err != nil { + return err + } + w.mu.Lock() + watchEntry := w.watches.get(ino) + w.mu.Unlock() + if watchEntry == nil { + _, err := windows.CreateIoCompletionPort(ino.handle, w.port, 0, 0) + if err != nil { + windows.CloseHandle(ino.handle) + return os.NewSyscallError("CreateIoCompletionPort", err) + } + watchEntry = &watch{ + ino: ino, + path: dir, + names: make(map[string]uint64), + recurse: recurse, + buf: make([]byte, bufsize), + } + w.mu.Lock() + w.watches.set(ino, watchEntry) + w.mu.Unlock() + flags |= provisional + } else { + windows.CloseHandle(ino.handle) + } + if pathname == dir { + watchEntry.mask |= flags + } else { + watchEntry.names[filepath.Base(pathname)] |= flags + } + + err = w.startRead(watchEntry) + if err != nil { + return err + } + + if pathname == dir { + watchEntry.mask &= ^provisional + } else { + watchEntry.names[filepath.Base(pathname)] &= ^provisional + } + return nil +} + +// Must run within the I/O thread. +func (w *readDirChangesW) remWatch(pathname string) error { + pathname, recurse := recursivePath(pathname) + + dir, err := w.getDir(pathname) + if err != nil { + return err + } + ino, err := w.getIno(dir) + if err != nil { + return err + } + + w.mu.Lock() + watch := w.watches.get(ino) + w.mu.Unlock() + + if recurse && !watch.recurse { + return fmt.Errorf("can't use \\... with non-recursive watch %q", pathname) + } + + err = windows.CloseHandle(ino.handle) + if err != nil { + w.sendError(os.NewSyscallError("CloseHandle", err)) + } + if watch == nil { + return fmt.Errorf("%w: %s", ErrNonExistentWatch, pathname) + } + if pathname == dir { + w.sendEvent(watch.path, "", watch.mask&sysFSIGNORED) + watch.mask = 0 + } else { + name := filepath.Base(pathname) + w.sendEvent(filepath.Join(watch.path, name), "", watch.names[name]&sysFSIGNORED) + delete(watch.names, name) + } + + return w.startRead(watch) +} + +// Must run within the I/O thread. +func (w *readDirChangesW) deleteWatch(watch *watch) { + for name, mask := range watch.names { + if mask&provisional == 0 { + w.sendEvent(filepath.Join(watch.path, name), "", mask&sysFSIGNORED) + } + delete(watch.names, name) + } + if watch.mask != 0 { + if watch.mask&provisional == 0 { + w.sendEvent(watch.path, "", watch.mask&sysFSIGNORED) + } + watch.mask = 0 + } +} + +// Must run within the I/O thread. +func (w *readDirChangesW) startRead(watch *watch) error { + err := windows.CancelIo(watch.ino.handle) + if err != nil { + w.sendError(os.NewSyscallError("CancelIo", err)) + w.deleteWatch(watch) + } + mask := w.toWindowsFlags(watch.mask) + for _, m := range watch.names { + mask |= w.toWindowsFlags(m) + } + if mask == 0 { + err := windows.CloseHandle(watch.ino.handle) + if err != nil { + w.sendError(os.NewSyscallError("CloseHandle", err)) + } + w.mu.Lock() + delete(w.watches[watch.ino.volume], watch.ino.index) + w.mu.Unlock() + return nil + } + + // We need to pass the array, rather than the slice. + hdr := (*reflect.SliceHeader)(unsafe.Pointer(&watch.buf)) + rdErr := windows.ReadDirectoryChanges(watch.ino.handle, + (*byte)(unsafe.Pointer(hdr.Data)), uint32(hdr.Len), + watch.recurse, mask, nil, &watch.ov, 0) + if rdErr != nil { + err := os.NewSyscallError("ReadDirectoryChanges", rdErr) + if rdErr == windows.ERROR_ACCESS_DENIED && watch.mask&provisional == 0 { + // Watched directory was probably removed + w.sendEvent(watch.path, "", watch.mask&sysFSDELETESELF) + err = nil + } + w.deleteWatch(watch) + w.startRead(watch) + return err + } + return nil +} + +// readEvents reads from the I/O completion port, converts the +// received events into Event objects and sends them via the Events channel. +// Entry point to the I/O thread. +func (w *readDirChangesW) readEvents() { + var ( + n uint32 + key uintptr + ov *windows.Overlapped + ) + runtime.LockOSThread() + + for { + // This error is handled after the watch == nil check below. + qErr := windows.GetQueuedCompletionStatus(w.port, &n, &key, &ov, windows.INFINITE) + + watch := (*watch)(unsafe.Pointer(ov)) + if watch == nil { + select { + case ch := <-w.done: + w.mu.Lock() + var indexes []indexMap + for _, index := range w.watches { + indexes = append(indexes, index) + } + w.mu.Unlock() + for _, index := range indexes { + for _, watch := range index { + w.deleteWatch(watch) + w.startRead(watch) + } + } + + err := windows.CloseHandle(w.port) + if err != nil { + err = os.NewSyscallError("CloseHandle", err) + } + close(w.Events) + close(w.Errors) + ch <- err + return + case in := <-w.input: + switch in.op { + case opAddWatch: + in.reply <- w.addWatch(in.path, uint64(in.flags), in.bufsize) + case opRemoveWatch: + in.reply <- w.remWatch(in.path) + } + default: + } + continue + } + + switch qErr { + case nil: + // No error + case windows.ERROR_MORE_DATA: + if watch == nil { + w.sendError(errors.New("ERROR_MORE_DATA has unexpectedly null lpOverlapped buffer")) + } else { + // The i/o succeeded but the buffer is full. + // In theory we should be building up a full packet. + // In practice we can get away with just carrying on. + n = uint32(unsafe.Sizeof(watch.buf)) + } + case windows.ERROR_ACCESS_DENIED: + // Watched directory was probably removed + w.sendEvent(watch.path, "", watch.mask&sysFSDELETESELF) + w.deleteWatch(watch) + w.startRead(watch) + continue + case windows.ERROR_OPERATION_ABORTED: + // CancelIo was called on this handle + continue + default: + w.sendError(os.NewSyscallError("GetQueuedCompletionPort", qErr)) + continue + } + + var offset uint32 + for { + if n == 0 { + w.sendError(ErrEventOverflow) + break + } + + // Point "raw" to the event in the buffer + raw := (*windows.FileNotifyInformation)(unsafe.Pointer(&watch.buf[offset])) + + // Create a buf that is the size of the path name + size := int(raw.FileNameLength / 2) + var buf []uint16 + // TODO: Use unsafe.Slice in Go 1.17; https://stackoverflow.com/questions/51187973 + sh := (*reflect.SliceHeader)(unsafe.Pointer(&buf)) + sh.Data = uintptr(unsafe.Pointer(&raw.FileName)) + sh.Len = size + sh.Cap = size + name := windows.UTF16ToString(buf) + fullname := filepath.Join(watch.path, name) + + if debug { + internal.Debug(fullname, raw.Action) + } + + var mask uint64 + switch raw.Action { + case windows.FILE_ACTION_REMOVED: + mask = sysFSDELETESELF + case windows.FILE_ACTION_MODIFIED: + mask = sysFSMODIFY + case windows.FILE_ACTION_RENAMED_OLD_NAME: + watch.rename = name + case windows.FILE_ACTION_RENAMED_NEW_NAME: + // Update saved path of all sub-watches. + old := filepath.Join(watch.path, watch.rename) + w.mu.Lock() + for _, watchMap := range w.watches { + for _, ww := range watchMap { + if strings.HasPrefix(ww.path, old) { + ww.path = filepath.Join(fullname, strings.TrimPrefix(ww.path, old)) + } + } + } + w.mu.Unlock() + + if watch.names[watch.rename] != 0 { + watch.names[name] |= watch.names[watch.rename] + delete(watch.names, watch.rename) + mask = sysFSMOVESELF + } + } + + if raw.Action != windows.FILE_ACTION_RENAMED_NEW_NAME { + w.sendEvent(fullname, "", watch.names[name]&mask) + } + if raw.Action == windows.FILE_ACTION_REMOVED { + w.sendEvent(fullname, "", watch.names[name]&sysFSIGNORED) + delete(watch.names, name) + } + + if watch.rename != "" && raw.Action == windows.FILE_ACTION_RENAMED_NEW_NAME { + w.sendEvent(fullname, filepath.Join(watch.path, watch.rename), watch.mask&w.toFSnotifyFlags(raw.Action)) + } else { + w.sendEvent(fullname, "", watch.mask&w.toFSnotifyFlags(raw.Action)) + } + + if raw.Action == windows.FILE_ACTION_RENAMED_NEW_NAME { + w.sendEvent(filepath.Join(watch.path, watch.rename), "", watch.names[name]&mask) + } + + // Move to the next event in the buffer + if raw.NextEntryOffset == 0 { + break + } + offset += raw.NextEntryOffset + + // Error! + if offset >= n { + //lint:ignore ST1005 Windows should be capitalized + w.sendError(errors.New("Windows system assumed buffer larger than it is, events have likely been missed")) + break + } + } + + if err := w.startRead(watch); err != nil { + w.sendError(err) + } + } +} + +func (w *readDirChangesW) toWindowsFlags(mask uint64) uint32 { + var m uint32 + if mask&sysFSMODIFY != 0 { + m |= windows.FILE_NOTIFY_CHANGE_LAST_WRITE + } + if mask&(sysFSMOVE|sysFSCREATE|sysFSDELETE) != 0 { + m |= windows.FILE_NOTIFY_CHANGE_FILE_NAME | windows.FILE_NOTIFY_CHANGE_DIR_NAME + } + return m +} + +func (w *readDirChangesW) toFSnotifyFlags(action uint32) uint64 { + switch action { + case windows.FILE_ACTION_ADDED: + return sysFSCREATE + case windows.FILE_ACTION_REMOVED: + return sysFSDELETE + case windows.FILE_ACTION_MODIFIED: + return sysFSMODIFY + case windows.FILE_ACTION_RENAMED_OLD_NAME: + return sysFSMOVEDFROM + case windows.FILE_ACTION_RENAMED_NEW_NAME: + return sysFSMOVEDTO + } + return 0 +} + +func (w *readDirChangesW) xSupports(op Op) bool { + if op.Has(xUnportableOpen) || op.Has(xUnportableRead) || + op.Has(xUnportableCloseWrite) || op.Has(xUnportableCloseRead) { + return false + } + return true +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/fsnotify.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/fsnotify.go new file mode 100644 index 00000000..f64be4bf --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/fsnotify.go @@ -0,0 +1,496 @@ +// Package fsnotify provides a cross-platform interface for file system +// notifications. +// +// Currently supported systems: +// +// - Linux via inotify +// - BSD, macOS via kqueue +// - Windows via ReadDirectoryChangesW +// - illumos via FEN +// +// # FSNOTIFY_DEBUG +// +// Set the FSNOTIFY_DEBUG environment variable to "1" to print debug messages to +// stderr. This can be useful to track down some problems, especially in cases +// where fsnotify is used as an indirect dependency. +// +// Every event will be printed as soon as there's something useful to print, +// with as little processing from fsnotify. +// +// Example output: +// +// FSNOTIFY_DEBUG: 11:34:23.633087586 256:IN_CREATE → "/tmp/file-1" +// FSNOTIFY_DEBUG: 11:34:23.633202319 4:IN_ATTRIB → "/tmp/file-1" +// FSNOTIFY_DEBUG: 11:34:28.989728764 512:IN_DELETE → "/tmp/file-1" +package fsnotify + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Watcher watches a set of paths, delivering events on a channel. +// +// A watcher should not be copied (e.g. pass it by pointer, rather than by +// value). +// +// # Linux notes +// +// When a file is removed a Remove event won't be emitted until all file +// descriptors are closed, and deletes will always emit a Chmod. For example: +// +// fp := os.Open("file") +// os.Remove("file") // Triggers Chmod +// fp.Close() // Triggers Remove +// +// This is the event that inotify sends, so not much can be changed about this. +// +// The fs.inotify.max_user_watches sysctl variable specifies the upper limit +// for the number of watches per user, and fs.inotify.max_user_instances +// specifies the maximum number of inotify instances per user. Every Watcher you +// create is an "instance", and every path you add is a "watch". +// +// These are also exposed in /proc as /proc/sys/fs/inotify/max_user_watches and +// /proc/sys/fs/inotify/max_user_instances +// +// To increase them you can use sysctl or write the value to the /proc file: +// +// # Default values on Linux 5.18 +// sysctl fs.inotify.max_user_watches=124983 +// sysctl fs.inotify.max_user_instances=128 +// +// To make the changes persist on reboot edit /etc/sysctl.conf or +// /usr/lib/sysctl.d/50-default.conf (details differ per Linux distro; check +// your distro's documentation): +// +// fs.inotify.max_user_watches=124983 +// fs.inotify.max_user_instances=128 +// +// Reaching the limit will result in a "no space left on device" or "too many open +// files" error. +// +// # kqueue notes (macOS, BSD) +// +// kqueue requires opening a file descriptor for every file that's being watched; +// so if you're watching a directory with five files then that's six file +// descriptors. You will run in to your system's "max open files" limit faster on +// these platforms. +// +// The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to +// control the maximum number of open files, as well as /etc/login.conf on BSD +// systems. +// +// # Windows notes +// +// Paths can be added as "C:\\path\\to\\dir", but forward slashes +// ("C:/path/to/dir") will also work. +// +// When a watched directory is removed it will always send an event for the +// directory itself, but may not send events for all files in that directory. +// Sometimes it will send events for all files, sometimes it will send no +// events, and often only for some files. +// +// The default ReadDirectoryChangesW() buffer size is 64K, which is the largest +// value that is guaranteed to work with SMB filesystems. If you have many +// events in quick succession this may not be enough, and you will have to use +// [WithBufferSize] to increase the value. +type Watcher struct { + b backend + + // Events sends the filesystem change events. + // + // fsnotify can send the following events; a "path" here can refer to a + // file, directory, symbolic link, or special file like a FIFO. + // + // fsnotify.Create A new path was created; this may be followed by one + // or more Write events if data also gets written to a + // file. + // + // fsnotify.Remove A path was removed. + // + // fsnotify.Rename A path was renamed. A rename is always sent with the + // old path as Event.Name, and a Create event will be + // sent with the new name. Renames are only sent for + // paths that are currently watched; e.g. moving an + // unmonitored file into a monitored directory will + // show up as just a Create. Similarly, renaming a file + // to outside a monitored directory will show up as + // only a Rename. + // + // fsnotify.Write A file or named pipe was written to. A Truncate will + // also trigger a Write. A single "write action" + // initiated by the user may show up as one or multiple + // writes, depending on when the system syncs things to + // disk. For example when compiling a large Go program + // you may get hundreds of Write events, and you may + // want to wait until you've stopped receiving them + // (see the dedup example in cmd/fsnotify). + // + // Some systems may send Write event for directories + // when the directory content changes. + // + // fsnotify.Chmod Attributes were changed. On Linux this is also sent + // when a file is removed (or more accurately, when a + // link to an inode is removed). On kqueue it's sent + // when a file is truncated. On Windows it's never + // sent. + Events chan Event + + // Errors sends any errors. + Errors chan error +} + +// Event represents a file system notification. +type Event struct { + // Path to the file or directory. + // + // Paths are relative to the input; for example with Add("dir") the Name + // will be set to "dir/file" if you create that file, but if you use + // Add("/path/to/dir") it will be "/path/to/dir/file". + Name string + + // File operation that triggered the event. + // + // This is a bitmask and some systems may send multiple operations at once. + // Use the Event.Has() method instead of comparing with ==. + Op Op + + // Create events will have this set to the old path if it's a rename. This + // only works when both the source and destination are watched. It's not + // reliable when watching individual files, only directories. + // + // For example "mv /tmp/file /tmp/rename" will emit: + // + // Event{Op: Rename, Name: "/tmp/file"} + // Event{Op: Create, Name: "/tmp/rename", RenamedFrom: "/tmp/file"} + renamedFrom string +} + +// Op describes a set of file operations. +type Op uint32 + +// The operations fsnotify can trigger; see the documentation on [Watcher] for a +// full description, and check them with [Event.Has]. +const ( + // A new pathname was created. + Create Op = 1 << iota + + // The pathname was written to; this does *not* mean the write has finished, + // and a write can be followed by more writes. + Write + + // The path was removed; any watches on it will be removed. Some "remove" + // operations may trigger a Rename if the file is actually moved (for + // example "remove to trash" is often a rename). + Remove + + // The path was renamed to something else; any watches on it will be + // removed. + Rename + + // File attributes were changed. + // + // It's generally not recommended to take action on this event, as it may + // get triggered very frequently by some software. For example, Spotlight + // indexing on macOS, anti-virus software, backup software, etc. + Chmod + + // File descriptor was opened. + // + // Only works on Linux and FreeBSD. + xUnportableOpen + + // File was read from. + // + // Only works on Linux and FreeBSD. + xUnportableRead + + // File opened for writing was closed. + // + // Only works on Linux and FreeBSD. + // + // The advantage of using this over Write is that it's more reliable than + // waiting for Write events to stop. It's also faster (if you're not + // listening to Write events): copying a file of a few GB can easily + // generate tens of thousands of Write events in a short span of time. + xUnportableCloseWrite + + // File opened for reading was closed. + // + // Only works on Linux and FreeBSD. + xUnportableCloseRead +) + +var ( + // ErrNonExistentWatch is used when Remove() is called on a path that's not + // added. + ErrNonExistentWatch = errors.New("fsnotify: can't remove non-existent watch") + + // ErrClosed is used when trying to operate on a closed Watcher. + ErrClosed = errors.New("fsnotify: watcher already closed") + + // ErrEventOverflow is reported from the Errors channel when there are too + // many events: + // + // - inotify: inotify returns IN_Q_OVERFLOW – because there are too + // many queued events (the fs.inotify.max_queued_events + // sysctl can be used to increase this). + // - windows: The buffer size is too small; WithBufferSize() can be used to increase it. + // - kqueue, fen: Not used. + ErrEventOverflow = errors.New("fsnotify: queue or buffer overflow") + + // ErrUnsupported is returned by AddWith() when WithOps() specified an + // Unportable event that's not supported on this platform. + //lint:ignore ST1012 not relevant + xErrUnsupported = errors.New("fsnotify: not supported with this backend") +) + +// NewWatcher creates a new Watcher. +func NewWatcher() (*Watcher, error) { + ev, errs := make(chan Event, defaultBufferSize), make(chan error) + b, err := newBackend(ev, errs) + if err != nil { + return nil, err + } + return &Watcher{b: b, Events: ev, Errors: errs}, nil +} + +// NewBufferedWatcher creates a new Watcher with a buffered Watcher.Events +// channel. +// +// The main use case for this is situations with a very large number of events +// where the kernel buffer size can't be increased (e.g. due to lack of +// permissions). An unbuffered Watcher will perform better for almost all use +// cases, and whenever possible you will be better off increasing the kernel +// buffers instead of adding a large userspace buffer. +func NewBufferedWatcher(sz uint) (*Watcher, error) { + ev, errs := make(chan Event, sz), make(chan error) + b, err := newBackend(ev, errs) + if err != nil { + return nil, err + } + return &Watcher{b: b, Events: ev, Errors: errs}, nil +} + +// Add starts monitoring the path for changes. +// +// A path can only be watched once; watching it more than once is a no-op and will +// not return an error. Paths that do not yet exist on the filesystem cannot be +// watched. +// +// A watch will be automatically removed if the watched path is deleted or +// renamed. The exception is the Windows backend, which doesn't remove the +// watcher on renames. +// +// Notifications on network filesystems (NFS, SMB, FUSE, etc.) or special +// filesystems (/proc, /sys, etc.) generally don't work. +// +// Returns [ErrClosed] if [Watcher.Close] was called. +// +// See [Watcher.AddWith] for a version that allows adding options. +// +// # Watching directories +// +// All files in a directory are monitored, including new files that are created +// after the watcher is started. Subdirectories are not watched (i.e. it's +// non-recursive). +// +// # Watching files +// +// Watching individual files (rather than directories) is generally not +// recommended as many programs (especially editors) update files atomically: it +// will write to a temporary file which is then moved to destination, +// overwriting the original (or some variant thereof). The watcher on the +// original file is now lost, as that no longer exists. +// +// The upshot of this is that a power failure or crash won't leave a +// half-written file. +// +// Watch the parent directory and use Event.Name to filter out files you're not +// interested in. There is an example of this in cmd/fsnotify/file.go. +func (w *Watcher) Add(path string) error { return w.b.Add(path) } + +// AddWith is like [Watcher.Add], but allows adding options. When using Add() +// the defaults described below are used. +// +// Possible options are: +// +// - [WithBufferSize] sets the buffer size for the Windows backend; no-op on +// other platforms. The default is 64K (65536 bytes). +func (w *Watcher) AddWith(path string, opts ...addOpt) error { return w.b.AddWith(path, opts...) } + +// Remove stops monitoring the path for changes. +// +// Directories are always removed non-recursively. For example, if you added +// /tmp/dir and /tmp/dir/subdir then you will need to remove both. +// +// Removing a path that has not yet been added returns [ErrNonExistentWatch]. +// +// Returns nil if [Watcher.Close] was called. +func (w *Watcher) Remove(path string) error { return w.b.Remove(path) } + +// Close removes all watches and closes the Events channel. +func (w *Watcher) Close() error { return w.b.Close() } + +// WatchList returns all paths explicitly added with [Watcher.Add] (and are not +// yet removed). +// +// The order is undefined, and may differ per call. Returns nil if +// [Watcher.Close] was called. +func (w *Watcher) WatchList() []string { return w.b.WatchList() } + +// Supports reports if all the listed operations are supported by this platform. +// +// Create, Write, Remove, Rename, and Chmod are always supported. It can only +// return false for an Op starting with Unportable. +func (w *Watcher) xSupports(op Op) bool { return w.b.xSupports(op) } + +func (o Op) String() string { + var b strings.Builder + if o.Has(Create) { + b.WriteString("|CREATE") + } + if o.Has(Remove) { + b.WriteString("|REMOVE") + } + if o.Has(Write) { + b.WriteString("|WRITE") + } + if o.Has(xUnportableOpen) { + b.WriteString("|OPEN") + } + if o.Has(xUnportableRead) { + b.WriteString("|READ") + } + if o.Has(xUnportableCloseWrite) { + b.WriteString("|CLOSE_WRITE") + } + if o.Has(xUnportableCloseRead) { + b.WriteString("|CLOSE_READ") + } + if o.Has(Rename) { + b.WriteString("|RENAME") + } + if o.Has(Chmod) { + b.WriteString("|CHMOD") + } + if b.Len() == 0 { + return "[no events]" + } + return b.String()[1:] +} + +// Has reports if this operation has the given operation. +func (o Op) Has(h Op) bool { return o&h != 0 } + +// Has reports if this event has the given operation. +func (e Event) Has(op Op) bool { return e.Op.Has(op) } + +// String returns a string representation of the event with their path. +func (e Event) String() string { + if e.renamedFrom != "" { + return fmt.Sprintf("%-13s %q ← %q", e.Op.String(), e.Name, e.renamedFrom) + } + return fmt.Sprintf("%-13s %q", e.Op.String(), e.Name) +} + +type ( + backend interface { + Add(string) error + AddWith(string, ...addOpt) error + Remove(string) error + WatchList() []string + Close() error + xSupports(Op) bool + } + addOpt func(opt *withOpts) + withOpts struct { + bufsize int + op Op + noFollow bool + sendCreate bool + } +) + +var debug = func() bool { + // Check for exactly "1" (rather than mere existence) so we can add + // options/flags in the future. I don't know if we ever want that, but it's + // nice to leave the option open. + return os.Getenv("FSNOTIFY_DEBUG") == "1" +}() + +var defaultOpts = withOpts{ + bufsize: 65536, // 64K + op: Create | Write | Remove | Rename | Chmod, +} + +func getOptions(opts ...addOpt) withOpts { + with := defaultOpts + for _, o := range opts { + if o != nil { + o(&with) + } + } + return with +} + +// WithBufferSize sets the [ReadDirectoryChangesW] buffer size. +// +// This only has effect on Windows systems, and is a no-op for other backends. +// +// The default value is 64K (65536 bytes) which is the highest value that works +// on all filesystems and should be enough for most applications, but if you +// have a large burst of events it may not be enough. You can increase it if +// you're hitting "queue or buffer overflow" errors ([ErrEventOverflow]). +// +// [ReadDirectoryChangesW]: https://learn.microsoft.com/en-gb/windows/win32/api/winbase/nf-winbase-readdirectorychangesw +func WithBufferSize(bytes int) addOpt { + return func(opt *withOpts) { opt.bufsize = bytes } +} + +// WithOps sets which operations to listen for. The default is [Create], +// [Write], [Remove], [Rename], and [Chmod]. +// +// Excluding operations you're not interested in can save quite a bit of CPU +// time; in some use cases there may be hundreds of thousands of useless Write +// or Chmod operations per second. +// +// This can also be used to add unportable operations not supported by all +// platforms; unportable operations all start with "Unportable": +// [UnportableOpen], [UnportableRead], [UnportableCloseWrite], and +// [UnportableCloseRead]. +// +// AddWith returns an error when using an unportable operation that's not +// supported. Use [Watcher.Support] to check for support. +func withOps(op Op) addOpt { + return func(opt *withOpts) { opt.op = op } +} + +// WithNoFollow disables following symlinks, so the symlinks themselves are +// watched. +func withNoFollow() addOpt { + return func(opt *withOpts) { opt.noFollow = true } +} + +// "Internal" option for recursive watches on inotify. +func withCreate() addOpt { + return func(opt *withOpts) { opt.sendCreate = true } +} + +var enableRecurse = false + +// Check if this path is recursive (ends with "/..." or "\..."), and return the +// path with the /... stripped. +func recursivePath(path string) (string, bool) { + path = filepath.Clean(path) + if !enableRecurse { // Only enabled in tests for now. + return path, false + } + if filepath.Base(path) == "..." { + return filepath.Dir(path), true + } + return path, false +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/darwin.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/darwin.go new file mode 100644 index 00000000..0b01bc18 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/darwin.go @@ -0,0 +1,39 @@ +//go:build darwin + +package internal + +import ( + "syscall" + + "golang.org/x/sys/unix" +) + +var ( + ErrSyscallEACCES = syscall.EACCES + ErrUnixEACCES = unix.EACCES +) + +var maxfiles uint64 + +func SetRlimit() { + // Go 1.19 will do this automatically: https://go-review.googlesource.com/c/go/+/393354/ + var l syscall.Rlimit + err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &l) + if err == nil && l.Cur != l.Max { + l.Cur = l.Max + syscall.Setrlimit(syscall.RLIMIT_NOFILE, &l) + } + maxfiles = l.Cur + + if n, err := syscall.SysctlUint32("kern.maxfiles"); err == nil && uint64(n) < maxfiles { + maxfiles = uint64(n) + } + + if n, err := syscall.SysctlUint32("kern.maxfilesperproc"); err == nil && uint64(n) < maxfiles { + maxfiles = uint64(n) + } +} + +func Maxfiles() uint64 { return maxfiles } +func Mkfifo(path string, mode uint32) error { return unix.Mkfifo(path, mode) } +func Mknod(path string, mode uint32, dev int) error { return unix.Mknod(path, mode, dev) } diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_darwin.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_darwin.go new file mode 100644 index 00000000..928319fb --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_darwin.go @@ -0,0 +1,57 @@ +package internal + +import "golang.org/x/sys/unix" + +var names = []struct { + n string + m uint32 +}{ + {"NOTE_ABSOLUTE", unix.NOTE_ABSOLUTE}, + {"NOTE_ATTRIB", unix.NOTE_ATTRIB}, + {"NOTE_BACKGROUND", unix.NOTE_BACKGROUND}, + {"NOTE_CHILD", unix.NOTE_CHILD}, + {"NOTE_CRITICAL", unix.NOTE_CRITICAL}, + {"NOTE_DELETE", unix.NOTE_DELETE}, + {"NOTE_EXEC", unix.NOTE_EXEC}, + {"NOTE_EXIT", unix.NOTE_EXIT}, + {"NOTE_EXITSTATUS", unix.NOTE_EXITSTATUS}, + {"NOTE_EXIT_CSERROR", unix.NOTE_EXIT_CSERROR}, + {"NOTE_EXIT_DECRYPTFAIL", unix.NOTE_EXIT_DECRYPTFAIL}, + {"NOTE_EXIT_DETAIL", unix.NOTE_EXIT_DETAIL}, + {"NOTE_EXIT_DETAIL_MASK", unix.NOTE_EXIT_DETAIL_MASK}, + {"NOTE_EXIT_MEMORY", unix.NOTE_EXIT_MEMORY}, + {"NOTE_EXIT_REPARENTED", unix.NOTE_EXIT_REPARENTED}, + {"NOTE_EXTEND", unix.NOTE_EXTEND}, + {"NOTE_FFAND", unix.NOTE_FFAND}, + {"NOTE_FFCOPY", unix.NOTE_FFCOPY}, + {"NOTE_FFCTRLMASK", unix.NOTE_FFCTRLMASK}, + {"NOTE_FFLAGSMASK", unix.NOTE_FFLAGSMASK}, + {"NOTE_FFNOP", unix.NOTE_FFNOP}, + {"NOTE_FFOR", unix.NOTE_FFOR}, + {"NOTE_FORK", unix.NOTE_FORK}, + {"NOTE_FUNLOCK", unix.NOTE_FUNLOCK}, + {"NOTE_LEEWAY", unix.NOTE_LEEWAY}, + {"NOTE_LINK", unix.NOTE_LINK}, + {"NOTE_LOWAT", unix.NOTE_LOWAT}, + {"NOTE_MACHTIME", unix.NOTE_MACHTIME}, + {"NOTE_MACH_CONTINUOUS_TIME", unix.NOTE_MACH_CONTINUOUS_TIME}, + {"NOTE_NONE", unix.NOTE_NONE}, + {"NOTE_NSECONDS", unix.NOTE_NSECONDS}, + {"NOTE_OOB", unix.NOTE_OOB}, + //{"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK}, -0x100000 (?!) + {"NOTE_PDATAMASK", unix.NOTE_PDATAMASK}, + {"NOTE_REAP", unix.NOTE_REAP}, + {"NOTE_RENAME", unix.NOTE_RENAME}, + {"NOTE_REVOKE", unix.NOTE_REVOKE}, + {"NOTE_SECONDS", unix.NOTE_SECONDS}, + {"NOTE_SIGNAL", unix.NOTE_SIGNAL}, + {"NOTE_TRACK", unix.NOTE_TRACK}, + {"NOTE_TRACKERR", unix.NOTE_TRACKERR}, + {"NOTE_TRIGGER", unix.NOTE_TRIGGER}, + {"NOTE_USECONDS", unix.NOTE_USECONDS}, + {"NOTE_VM_ERROR", unix.NOTE_VM_ERROR}, + {"NOTE_VM_PRESSURE", unix.NOTE_VM_PRESSURE}, + {"NOTE_VM_PRESSURE_SUDDEN_TERMINATE", unix.NOTE_VM_PRESSURE_SUDDEN_TERMINATE}, + {"NOTE_VM_PRESSURE_TERMINATE", unix.NOTE_VM_PRESSURE_TERMINATE}, + {"NOTE_WRITE", unix.NOTE_WRITE}, +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_dragonfly.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_dragonfly.go new file mode 100644 index 00000000..3186b0c3 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_dragonfly.go @@ -0,0 +1,33 @@ +package internal + +import "golang.org/x/sys/unix" + +var names = []struct { + n string + m uint32 +}{ + {"NOTE_ATTRIB", unix.NOTE_ATTRIB}, + {"NOTE_CHILD", unix.NOTE_CHILD}, + {"NOTE_DELETE", unix.NOTE_DELETE}, + {"NOTE_EXEC", unix.NOTE_EXEC}, + {"NOTE_EXIT", unix.NOTE_EXIT}, + {"NOTE_EXTEND", unix.NOTE_EXTEND}, + {"NOTE_FFAND", unix.NOTE_FFAND}, + {"NOTE_FFCOPY", unix.NOTE_FFCOPY}, + {"NOTE_FFCTRLMASK", unix.NOTE_FFCTRLMASK}, + {"NOTE_FFLAGSMASK", unix.NOTE_FFLAGSMASK}, + {"NOTE_FFNOP", unix.NOTE_FFNOP}, + {"NOTE_FFOR", unix.NOTE_FFOR}, + {"NOTE_FORK", unix.NOTE_FORK}, + {"NOTE_LINK", unix.NOTE_LINK}, + {"NOTE_LOWAT", unix.NOTE_LOWAT}, + {"NOTE_OOB", unix.NOTE_OOB}, + {"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK}, + {"NOTE_PDATAMASK", unix.NOTE_PDATAMASK}, + {"NOTE_RENAME", unix.NOTE_RENAME}, + {"NOTE_REVOKE", unix.NOTE_REVOKE}, + {"NOTE_TRACK", unix.NOTE_TRACK}, + {"NOTE_TRACKERR", unix.NOTE_TRACKERR}, + {"NOTE_TRIGGER", unix.NOTE_TRIGGER}, + {"NOTE_WRITE", unix.NOTE_WRITE}, +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_freebsd.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_freebsd.go new file mode 100644 index 00000000..f69fdb93 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_freebsd.go @@ -0,0 +1,42 @@ +package internal + +import "golang.org/x/sys/unix" + +var names = []struct { + n string + m uint32 +}{ + {"NOTE_ABSTIME", unix.NOTE_ABSTIME}, + {"NOTE_ATTRIB", unix.NOTE_ATTRIB}, + {"NOTE_CHILD", unix.NOTE_CHILD}, + {"NOTE_CLOSE", unix.NOTE_CLOSE}, + {"NOTE_CLOSE_WRITE", unix.NOTE_CLOSE_WRITE}, + {"NOTE_DELETE", unix.NOTE_DELETE}, + {"NOTE_EXEC", unix.NOTE_EXEC}, + {"NOTE_EXIT", unix.NOTE_EXIT}, + {"NOTE_EXTEND", unix.NOTE_EXTEND}, + {"NOTE_FFAND", unix.NOTE_FFAND}, + {"NOTE_FFCOPY", unix.NOTE_FFCOPY}, + {"NOTE_FFCTRLMASK", unix.NOTE_FFCTRLMASK}, + {"NOTE_FFLAGSMASK", unix.NOTE_FFLAGSMASK}, + {"NOTE_FFNOP", unix.NOTE_FFNOP}, + {"NOTE_FFOR", unix.NOTE_FFOR}, + {"NOTE_FILE_POLL", unix.NOTE_FILE_POLL}, + {"NOTE_FORK", unix.NOTE_FORK}, + {"NOTE_LINK", unix.NOTE_LINK}, + {"NOTE_LOWAT", unix.NOTE_LOWAT}, + {"NOTE_MSECONDS", unix.NOTE_MSECONDS}, + {"NOTE_NSECONDS", unix.NOTE_NSECONDS}, + {"NOTE_OPEN", unix.NOTE_OPEN}, + {"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK}, + {"NOTE_PDATAMASK", unix.NOTE_PDATAMASK}, + {"NOTE_READ", unix.NOTE_READ}, + {"NOTE_RENAME", unix.NOTE_RENAME}, + {"NOTE_REVOKE", unix.NOTE_REVOKE}, + {"NOTE_SECONDS", unix.NOTE_SECONDS}, + {"NOTE_TRACK", unix.NOTE_TRACK}, + {"NOTE_TRACKERR", unix.NOTE_TRACKERR}, + {"NOTE_TRIGGER", unix.NOTE_TRIGGER}, + {"NOTE_USECONDS", unix.NOTE_USECONDS}, + {"NOTE_WRITE", unix.NOTE_WRITE}, +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_kqueue.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_kqueue.go new file mode 100644 index 00000000..607e683b --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_kqueue.go @@ -0,0 +1,32 @@ +//go:build freebsd || openbsd || netbsd || dragonfly || darwin + +package internal + +import ( + "fmt" + "os" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +func Debug(name string, kevent *unix.Kevent_t) { + mask := uint32(kevent.Fflags) + + var ( + l []string + unknown = mask + ) + for _, n := range names { + if mask&n.m == n.m { + l = append(l, n.n) + unknown ^= n.m + } + } + if unknown > 0 { + l = append(l, fmt.Sprintf("0x%x", unknown)) + } + fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s %10d:%-60s → %q\n", + time.Now().Format("15:04:05.000000000"), mask, strings.Join(l, " | "), name) +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_linux.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_linux.go new file mode 100644 index 00000000..35c734be --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_linux.go @@ -0,0 +1,56 @@ +package internal + +import ( + "fmt" + "os" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +func Debug(name string, mask, cookie uint32) { + names := []struct { + n string + m uint32 + }{ + {"IN_ACCESS", unix.IN_ACCESS}, + {"IN_ATTRIB", unix.IN_ATTRIB}, + {"IN_CLOSE", unix.IN_CLOSE}, + {"IN_CLOSE_NOWRITE", unix.IN_CLOSE_NOWRITE}, + {"IN_CLOSE_WRITE", unix.IN_CLOSE_WRITE}, + {"IN_CREATE", unix.IN_CREATE}, + {"IN_DELETE", unix.IN_DELETE}, + {"IN_DELETE_SELF", unix.IN_DELETE_SELF}, + {"IN_IGNORED", unix.IN_IGNORED}, + {"IN_ISDIR", unix.IN_ISDIR}, + {"IN_MODIFY", unix.IN_MODIFY}, + {"IN_MOVE", unix.IN_MOVE}, + {"IN_MOVED_FROM", unix.IN_MOVED_FROM}, + {"IN_MOVED_TO", unix.IN_MOVED_TO}, + {"IN_MOVE_SELF", unix.IN_MOVE_SELF}, + {"IN_OPEN", unix.IN_OPEN}, + {"IN_Q_OVERFLOW", unix.IN_Q_OVERFLOW}, + {"IN_UNMOUNT", unix.IN_UNMOUNT}, + } + + var ( + l []string + unknown = mask + ) + for _, n := range names { + if mask&n.m == n.m { + l = append(l, n.n) + unknown ^= n.m + } + } + if unknown > 0 { + l = append(l, fmt.Sprintf("0x%x", unknown)) + } + var c string + if cookie > 0 { + c = fmt.Sprintf("(cookie: %d) ", cookie) + } + fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s %-30s → %s%q\n", + time.Now().Format("15:04:05.000000000"), strings.Join(l, "|"), c, name) +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_netbsd.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_netbsd.go new file mode 100644 index 00000000..e5b3b6f6 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_netbsd.go @@ -0,0 +1,25 @@ +package internal + +import "golang.org/x/sys/unix" + +var names = []struct { + n string + m uint32 +}{ + {"NOTE_ATTRIB", unix.NOTE_ATTRIB}, + {"NOTE_CHILD", unix.NOTE_CHILD}, + {"NOTE_DELETE", unix.NOTE_DELETE}, + {"NOTE_EXEC", unix.NOTE_EXEC}, + {"NOTE_EXIT", unix.NOTE_EXIT}, + {"NOTE_EXTEND", unix.NOTE_EXTEND}, + {"NOTE_FORK", unix.NOTE_FORK}, + {"NOTE_LINK", unix.NOTE_LINK}, + {"NOTE_LOWAT", unix.NOTE_LOWAT}, + {"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK}, + {"NOTE_PDATAMASK", unix.NOTE_PDATAMASK}, + {"NOTE_RENAME", unix.NOTE_RENAME}, + {"NOTE_REVOKE", unix.NOTE_REVOKE}, + {"NOTE_TRACK", unix.NOTE_TRACK}, + {"NOTE_TRACKERR", unix.NOTE_TRACKERR}, + {"NOTE_WRITE", unix.NOTE_WRITE}, +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_openbsd.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_openbsd.go new file mode 100644 index 00000000..1dd455bc --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_openbsd.go @@ -0,0 +1,28 @@ +package internal + +import "golang.org/x/sys/unix" + +var names = []struct { + n string + m uint32 +}{ + {"NOTE_ATTRIB", unix.NOTE_ATTRIB}, + // {"NOTE_CHANGE", unix.NOTE_CHANGE}, // Not on 386? + {"NOTE_CHILD", unix.NOTE_CHILD}, + {"NOTE_DELETE", unix.NOTE_DELETE}, + {"NOTE_EOF", unix.NOTE_EOF}, + {"NOTE_EXEC", unix.NOTE_EXEC}, + {"NOTE_EXIT", unix.NOTE_EXIT}, + {"NOTE_EXTEND", unix.NOTE_EXTEND}, + {"NOTE_FORK", unix.NOTE_FORK}, + {"NOTE_LINK", unix.NOTE_LINK}, + {"NOTE_LOWAT", unix.NOTE_LOWAT}, + {"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK}, + {"NOTE_PDATAMASK", unix.NOTE_PDATAMASK}, + {"NOTE_RENAME", unix.NOTE_RENAME}, + {"NOTE_REVOKE", unix.NOTE_REVOKE}, + {"NOTE_TRACK", unix.NOTE_TRACK}, + {"NOTE_TRACKERR", unix.NOTE_TRACKERR}, + {"NOTE_TRUNCATE", unix.NOTE_TRUNCATE}, + {"NOTE_WRITE", unix.NOTE_WRITE}, +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_solaris.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_solaris.go new file mode 100644 index 00000000..f1b2e73b --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_solaris.go @@ -0,0 +1,45 @@ +package internal + +import ( + "fmt" + "os" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +func Debug(name string, mask int32) { + names := []struct { + n string + m int32 + }{ + {"FILE_ACCESS", unix.FILE_ACCESS}, + {"FILE_MODIFIED", unix.FILE_MODIFIED}, + {"FILE_ATTRIB", unix.FILE_ATTRIB}, + {"FILE_TRUNC", unix.FILE_TRUNC}, + {"FILE_NOFOLLOW", unix.FILE_NOFOLLOW}, + {"FILE_DELETE", unix.FILE_DELETE}, + {"FILE_RENAME_TO", unix.FILE_RENAME_TO}, + {"FILE_RENAME_FROM", unix.FILE_RENAME_FROM}, + {"UNMOUNTED", unix.UNMOUNTED}, + {"MOUNTEDOVER", unix.MOUNTEDOVER}, + {"FILE_EXCEPTION", unix.FILE_EXCEPTION}, + } + + var ( + l []string + unknown = mask + ) + for _, n := range names { + if mask&n.m == n.m { + l = append(l, n.n) + unknown ^= n.m + } + } + if unknown > 0 { + l = append(l, fmt.Sprintf("0x%x", unknown)) + } + fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s %10d:%-30s → %q\n", + time.Now().Format("15:04:05.000000000"), mask, strings.Join(l, " | "), name) +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_windows.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_windows.go new file mode 100644 index 00000000..52bf4ce5 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/debug_windows.go @@ -0,0 +1,40 @@ +package internal + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/sys/windows" +) + +func Debug(name string, mask uint32) { + names := []struct { + n string + m uint32 + }{ + {"FILE_ACTION_ADDED", windows.FILE_ACTION_ADDED}, + {"FILE_ACTION_REMOVED", windows.FILE_ACTION_REMOVED}, + {"FILE_ACTION_MODIFIED", windows.FILE_ACTION_MODIFIED}, + {"FILE_ACTION_RENAMED_OLD_NAME", windows.FILE_ACTION_RENAMED_OLD_NAME}, + {"FILE_ACTION_RENAMED_NEW_NAME", windows.FILE_ACTION_RENAMED_NEW_NAME}, + } + + var ( + l []string + unknown = mask + ) + for _, n := range names { + if mask&n.m == n.m { + l = append(l, n.n) + unknown ^= n.m + } + } + if unknown > 0 { + l = append(l, fmt.Sprintf("0x%x", unknown)) + } + fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s %-65s → %q\n", + time.Now().Format("15:04:05.000000000"), strings.Join(l, " | "), filepath.ToSlash(name)) +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/freebsd.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/freebsd.go new file mode 100644 index 00000000..5ac8b507 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/freebsd.go @@ -0,0 +1,31 @@ +//go:build freebsd + +package internal + +import ( + "syscall" + + "golang.org/x/sys/unix" +) + +var ( + ErrSyscallEACCES = syscall.EACCES + ErrUnixEACCES = unix.EACCES +) + +var maxfiles uint64 + +func SetRlimit() { + // Go 1.19 will do this automatically: https://go-review.googlesource.com/c/go/+/393354/ + var l syscall.Rlimit + err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &l) + if err == nil && l.Cur != l.Max { + l.Cur = l.Max + syscall.Setrlimit(syscall.RLIMIT_NOFILE, &l) + } + maxfiles = uint64(l.Cur) +} + +func Maxfiles() uint64 { return maxfiles } +func Mkfifo(path string, mode uint32) error { return unix.Mkfifo(path, mode) } +func Mknod(path string, mode uint32, dev int) error { return unix.Mknod(path, mode, uint64(dev)) } diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/internal.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/internal.go new file mode 100644 index 00000000..7daa45e1 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/internal.go @@ -0,0 +1,2 @@ +// Package internal contains some helpers. +package internal diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/unix.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/unix.go new file mode 100644 index 00000000..b251fb80 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/unix.go @@ -0,0 +1,31 @@ +//go:build !windows && !darwin && !freebsd && !plan9 + +package internal + +import ( + "syscall" + + "golang.org/x/sys/unix" +) + +var ( + ErrSyscallEACCES = syscall.EACCES + ErrUnixEACCES = unix.EACCES +) + +var maxfiles uint64 + +func SetRlimit() { + // Go 1.19 will do this automatically: https://go-review.googlesource.com/c/go/+/393354/ + var l syscall.Rlimit + err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &l) + if err == nil && l.Cur != l.Max { + l.Cur = l.Max + syscall.Setrlimit(syscall.RLIMIT_NOFILE, &l) + } + maxfiles = uint64(l.Cur) +} + +func Maxfiles() uint64 { return maxfiles } +func Mkfifo(path string, mode uint32) error { return unix.Mkfifo(path, mode) } +func Mknod(path string, mode uint32, dev int) error { return unix.Mknod(path, mode, dev) } diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/unix2.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/unix2.go new file mode 100644 index 00000000..37dfeddc --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/unix2.go @@ -0,0 +1,7 @@ +//go:build !windows + +package internal + +func HasPrivilegesForSymlink() bool { + return true +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/windows.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/windows.go new file mode 100644 index 00000000..896bc2e5 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/internal/windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package internal + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +// Just a dummy. +var ( + ErrSyscallEACCES = errors.New("dummy") + ErrUnixEACCES = errors.New("dummy") +) + +func SetRlimit() {} +func Maxfiles() uint64 { return 1<<64 - 1 } +func Mkfifo(path string, mode uint32) error { return errors.New("no FIFOs on Windows") } +func Mknod(path string, mode uint32, dev int) error { return errors.New("no device nodes on Windows") } + +func HasPrivilegesForSymlink() bool { + var sid *windows.SID + err := windows.AllocateAndInitializeSid( + &windows.SECURITY_NT_AUTHORITY, + 2, + windows.SECURITY_BUILTIN_DOMAIN_RID, + windows.DOMAIN_ALIAS_RID_ADMINS, + 0, 0, 0, 0, 0, 0, + &sid) + if err != nil { + return false + } + defer windows.FreeSid(sid) + token := windows.Token(0) + member, err := token.IsMember(sid) + if err != nil { + return false + } + return member || token.IsElevated() +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/shared.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/shared.go new file mode 100644 index 00000000..3ee9b58f --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/shared.go @@ -0,0 +1,64 @@ +package fsnotify + +import "sync" + +type shared struct { + Events chan Event + Errors chan error + done chan struct{} + mu sync.Mutex +} + +func newShared(ev chan Event, errs chan error) *shared { + return &shared{ + Events: ev, + Errors: errs, + done: make(chan struct{}), + } +} + +// Returns true if the event was sent, or false if watcher is closed. +func (w *shared) sendEvent(e Event) bool { + if e.Op == 0 { + return true + } + select { + case <-w.done: + return false + case w.Events <- e: + return true + } +} + +// Returns true if the error was sent, or false if watcher is closed. +func (w *shared) sendError(err error) bool { + if err == nil { + return true + } + select { + case <-w.done: + return false + case w.Errors <- err: + return true + } +} + +func (w *shared) isClosed() bool { + select { + case <-w.done: + return true + default: + return false + } +} + +// Mark as closed; returns true if it was already closed. +func (w *shared) close() bool { + w.mu.Lock() + defer w.mu.Unlock() + if w.isClosed() { + return true + } + close(w.done) + return false +} diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/staticcheck.conf b/src/yangerd/vendor/github.com/fsnotify/fsnotify/staticcheck.conf new file mode 100644 index 00000000..8fa7351f --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/staticcheck.conf @@ -0,0 +1,3 @@ +checks = ['all', + '-U1000', # Don't complain about unused functions. +] diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/system_bsd.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/system_bsd.go new file mode 100644 index 00000000..f65e8fe3 --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/system_bsd.go @@ -0,0 +1,7 @@ +//go:build freebsd || openbsd || netbsd || dragonfly + +package fsnotify + +import "golang.org/x/sys/unix" + +const openMode = unix.O_NONBLOCK | unix.O_RDONLY | unix.O_CLOEXEC diff --git a/src/yangerd/vendor/github.com/fsnotify/fsnotify/system_darwin.go b/src/yangerd/vendor/github.com/fsnotify/fsnotify/system_darwin.go new file mode 100644 index 00000000..a29fc7aa --- /dev/null +++ b/src/yangerd/vendor/github.com/fsnotify/fsnotify/system_darwin.go @@ -0,0 +1,8 @@ +//go:build darwin + +package fsnotify + +import "golang.org/x/sys/unix" + +// note: this constant is not defined on BSD +const openMode = unix.O_EVTONLY | unix.O_CLOEXEC diff --git a/src/yangerd/vendor/github.com/godbus/dbus/v5/.cirrus.yml b/src/yangerd/vendor/github.com/godbus/dbus/v5/.cirrus.yml new file mode 100644 index 00000000..6e209029 --- /dev/null +++ b/src/yangerd/vendor/github.com/godbus/dbus/v5/.cirrus.yml @@ -0,0 +1,11 @@ +# See https://cirrus-ci.org/guide/FreeBSD/ +freebsd_instance: + image_family: freebsd-14-3 + +task: + name: Test on FreeBSD + install_script: pkg install -y go125 dbus + test_script: | + /usr/local/etc/rc.d/dbus onestart && \ + eval `dbus-launch --sh-syntax` && \ + go125 test -v ./... diff --git a/src/yangerd/vendor/github.com/godbus/dbus/v5/.golangci.yml b/src/yangerd/vendor/github.com/godbus/dbus/v5/.golangci.yml new file mode 100644 index 00000000..5bbdd934 --- /dev/null +++ b/src/yangerd/vendor/github.com/godbus/dbus/v5/.golangci.yml @@ -0,0 +1,13 @@ +version: "2" + +linters: + enable: + - unconvert + - unparam + exclusions: + presets: + - std-error-handling + +formatters: + enable: + - gofumpt diff --git a/src/yangerd/vendor/github.com/godbus/dbus/v5/CONTRIBUTING.md b/src/yangerd/vendor/github.com/godbus/dbus/v5/CONTRIBUTING.md new file mode 100644 index 00000000..c88f9b2b --- /dev/null +++ b/src/yangerd/vendor/github.com/godbus/dbus/v5/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# How to Contribute + +## Getting Started + +- Fork the repository on GitHub +- Read the [README](README.markdown) for build and test instructions +- Play with the project, submit bugs, submit patches! + +## Contribution Flow + +This is a rough outline of what a contributor's workflow looks like: + +- Create a topic branch from where you want to base your work (usually master). +- Make commits of logical units. +- Make sure your commit messages are in the proper format (see below). +- Push your changes to a topic branch in your fork of the repository. +- Make sure the tests pass, and add any new tests as appropriate. +- Submit a pull request to the original repository. + +Thanks for your contributions! + +### Format of the Commit Message + +We follow a rough convention for commit messages that is designed to answer two +questions: what changed and why. The subject line should feature the what and +the body of the commit should describe the why. + +``` +scripts: add the test-cluster command + +this uses tmux to setup a test cluster that you can easily kill and +start for debugging. + +Fixes #38 +``` + +The format can be described more formally as follows: + +``` +: + + + +