main.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. // Beacon Daemon - collects events from scanners and uploads to server
  2. package main
  3. import (
  4. "context"
  5. "encoding/json"
  6. "flag"
  7. "fmt"
  8. "log"
  9. "net"
  10. "net/http"
  11. "os"
  12. "os/signal"
  13. "strings"
  14. "sync"
  15. "syscall"
  16. "time"
  17. "github.com/go-zeromq/zmq4"
  18. )
  19. const (
  20. defaultConfigPath = "/opt/mybeacon/etc/config.json"
  21. defaultStatePath = "/opt/mybeacon/etc/device.json"
  22. defaultBinDir = "/opt/mybeacon/bin"
  23. defaultWiFiIface = "wlan0"
  24. maxSpoolBytes = 100 * 1024 * 1024 // 100 MB
  25. )
  26. type Daemon struct {
  27. cfg *Config
  28. state *DeviceState
  29. client *APIClient
  30. spooler *Spooler
  31. tunnel *SSHTunnel
  32. scanners *ScannerManager
  33. api *APIServer
  34. netmgr *NetworkManager
  35. bleEvents []interface{}
  36. wifiEvents []interface{}
  37. mu sync.Mutex
  38. configPath string
  39. statePath string
  40. httpAddr string // HTTP API listen address
  41. stopChan chan struct{}
  42. // Upload failure counters (for reducing log spam)
  43. bleUploadFailures int
  44. wifiUploadFailures int
  45. }
  46. func main() {
  47. var (
  48. configPath = flag.String("config", defaultConfigPath, "Config file path")
  49. statePath = flag.String("state", defaultStatePath, "Device state file path")
  50. serverAddr = flag.String("server", "", "API server address (e.g., http://192.168.5.2:5000)")
  51. binDir = flag.String("bindir", defaultBinDir, "Directory with scanner binaries")
  52. wifiIface = flag.String("wifi-iface", defaultWiFiIface, "WiFi interface for monitor mode")
  53. httpAddr = flag.String("http", ":8080", "HTTP API listen address")
  54. debug = flag.Bool("debug", false, "Enable debug logging")
  55. )
  56. flag.Parse()
  57. log.SetFlags(log.Ltime)
  58. log.Println("================================================================================")
  59. log.Println("Beacon Daemon starting...")
  60. // Load configuration
  61. cfg, err := LoadConfig(*configPath)
  62. if err != nil {
  63. log.Printf("Warning: failed to load config: %v (using defaults)", err)
  64. cfg = DefaultConfig()
  65. }
  66. cfg.Debug = *debug || cfg.Debug
  67. // Override server address if provided
  68. if *serverAddr != "" {
  69. cfg.APIBase = *serverAddr + "/api/v1"
  70. log.Printf("Using server: %s", *serverAddr)
  71. }
  72. // Store WiFi interface
  73. cfg.WiFiIface = *wifiIface
  74. // Load device state
  75. state, err := LoadDeviceState(*statePath)
  76. if err != nil {
  77. log.Printf("Warning: failed to load state: %v", err)
  78. state = &DeviceState{}
  79. }
  80. // Get device ID from MAC if not set
  81. if state.DeviceID == "" {
  82. state.DeviceID = getDeviceID()
  83. SaveDeviceState(*statePath, state)
  84. }
  85. log.Printf("Device ID: %s", state.DeviceID)
  86. // Create spooler
  87. spooler, err := NewSpooler(cfg.SpoolDir, maxSpoolBytes)
  88. if err != nil {
  89. log.Fatalf("Failed to create spooler: %v", err)
  90. }
  91. // Create API client
  92. client := NewAPIClient(cfg.APIBase)
  93. // Create SSH tunnel manager (will be started after registration)
  94. tunnel := NewSSHTunnel(cfg, client, state.DeviceID)
  95. // Create scanner manager
  96. scanners := NewScannerManager(*binDir, cfg.Debug)
  97. // Create network manager (manages eth0, wlan0 client, wlan0 AP fallback)
  98. netmgr := NewNetworkManager(cfg, scanners, state.DevicePassword)
  99. // Create daemon
  100. daemon := &Daemon{
  101. cfg: cfg,
  102. state: state,
  103. client: client,
  104. spooler: spooler,
  105. tunnel: tunnel,
  106. scanners: scanners,
  107. netmgr: netmgr,
  108. configPath: *configPath,
  109. statePath: *statePath,
  110. httpAddr: *httpAddr,
  111. stopChan: make(chan struct{}),
  112. }
  113. // Create API server
  114. daemon.api = NewAPIServer(daemon)
  115. if state.DeviceToken != "" {
  116. daemon.client.SetToken(state.DeviceToken)
  117. }
  118. // Handle signals
  119. sigChan := make(chan os.Signal, 1)
  120. signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
  121. go func() {
  122. <-sigChan
  123. log.Println("Shutting down...")
  124. daemon.api.Stop()
  125. daemon.scanners.StopAll()
  126. daemon.tunnel.Stop()
  127. daemon.netmgr.Stop()
  128. close(daemon.stopChan)
  129. }()
  130. // Start HTTP API server if enabled (dashboard should be available immediately)
  131. if cfg.Dashboard.Enabled {
  132. go func() {
  133. log.Printf("Starting HTTP API server on %s", daemon.httpAddr)
  134. if err := daemon.api.Start(daemon.httpAddr); err != nil && err != http.ErrServerClosed {
  135. log.Printf("HTTP server error: %v", err)
  136. }
  137. }()
  138. // Give HTTP server time to bind
  139. time.Sleep(100 * time.Millisecond)
  140. } else {
  141. log.Println("Dashboard disabled - HTTP server not started")
  142. }
  143. // Start network manager (manages eth0, wlan0 client, wlan0 AP fallback, WiFi scanner)
  144. // Network manager will automatically handle all network priorities and scanner coordination
  145. daemon.netmgr.Start()
  146. // Start SSH tunnel if enabled
  147. if cfg.SSHTunnel.Enabled {
  148. daemon.tunnel.Start()
  149. }
  150. // Start BLE scanner if enabled (not managed by network manager)
  151. if cfg.BLE.Enabled {
  152. if !daemon.scanners.IsBLERunning() {
  153. log.Println("Starting BLE scanner...")
  154. if err := daemon.scanners.StartBLE(cfg.ZMQAddrBLE); err != nil {
  155. log.Printf("Failed to start BLE scanner: %v", err)
  156. }
  157. }
  158. }
  159. // Start registration loop (if not registered)
  160. go daemon.registrationLoop()
  161. // Start config polling loop
  162. go daemon.configLoop()
  163. // Start ZMQ subscribers
  164. go daemon.subscribeLoop("ble", cfg.ZMQAddrBLE)
  165. go daemon.subscribeLoop("wifi", cfg.ZMQAddrWiFi)
  166. // Start batch upload loops
  167. go daemon.uploadLoop("ble", "/ble", cfg.BLE.BatchIntervalMs)
  168. go daemon.uploadLoop("wifi", "/wifi", cfg.WiFi.BatchIntervalMs)
  169. // Start spool flush loop
  170. go daemon.spoolFlushLoop()
  171. // Wait for shutdown
  172. <-daemon.stopChan
  173. log.Println("Daemon stopped")
  174. }
  175. func (d *Daemon) registrationLoop() {
  176. for {
  177. select {
  178. case <-d.stopChan:
  179. return
  180. default:
  181. }
  182. if d.state.DeviceToken != "" {
  183. time.Sleep(60 * time.Second)
  184. continue
  185. }
  186. log.Println("Attempting device registration...")
  187. // Generate or load SSH key pair
  188. sshKeyPath := "/etc/beacon/ssh_tunnel_ed25519"
  189. sshPubKey, err := GenerateOrLoadSSHKey(sshKeyPath)
  190. if err != nil {
  191. log.Printf("Failed to generate/load SSH key: %v", err)
  192. time.Sleep(10 * time.Second)
  193. continue
  194. }
  195. log.Printf("SSH key ready: %s", sshKeyPath)
  196. req := &RegistrationRequest{
  197. DeviceID: d.state.DeviceID,
  198. SSHPublicKey: sshPubKey,
  199. }
  200. // Try to get IPs
  201. if ip := getInterfaceIP("eth0"); ip != "" {
  202. req.EthIP = &ip
  203. }
  204. if ip := getInterfaceIP("wlan0"); ip != "" {
  205. req.WlanIP = &ip
  206. }
  207. resp, err := d.client.Register(req)
  208. if err != nil {
  209. log.Printf("Registration failed: %v", err)
  210. time.Sleep(10 * time.Second)
  211. continue
  212. }
  213. d.state.DeviceToken = resp.DeviceToken
  214. d.state.DevicePassword = resp.DevicePassword
  215. d.client.SetToken(resp.DeviceToken)
  216. SaveDeviceState(d.statePath, d.state)
  217. log.Printf("Device registered, token received")
  218. // Immediately fetch config after registration
  219. log.Println("Fetching initial config from server...")
  220. d.fetchAndApplyConfig()
  221. }
  222. }
  223. func (d *Daemon) configLoop() {
  224. // Initial fetch immediately
  225. d.fetchAndApplyConfig()
  226. ticker := time.NewTicker(30 * time.Second)
  227. defer ticker.Stop()
  228. for {
  229. select {
  230. case <-d.stopChan:
  231. return
  232. case <-ticker.C:
  233. }
  234. d.fetchAndApplyConfig()
  235. }
  236. }
  237. func (d *Daemon) fetchAndApplyConfig() {
  238. if d.state.DeviceToken == "" {
  239. return
  240. }
  241. serverCfg, err := d.client.GetConfig(d.state.DeviceID)
  242. if err != nil {
  243. // In LAN mode, suppress errors (server might be unreachable)
  244. if d.cfg.Mode == "lan" {
  245. // Silent - use local config
  246. return
  247. }
  248. if d.cfg.Debug {
  249. log.Printf("Config fetch failed: %v", err)
  250. }
  251. return
  252. }
  253. // Determine effective mode: force_cloud overrides local mode setting
  254. effectiveMode := d.cfg.Mode
  255. if effectiveMode == "" {
  256. effectiveMode = "cloud"
  257. }
  258. if serverCfg.ForceCloud {
  259. if effectiveMode == "lan" {
  260. log.Println("Server force_cloud enabled - switching to cloud mode")
  261. }
  262. effectiveMode = "cloud"
  263. }
  264. d.mu.Lock()
  265. // Track changes for scanner restart
  266. bleChanged := false
  267. wifiMonitorChanged := false
  268. wifiClientChanged := false
  269. if effectiveMode == "cloud" {
  270. // Cloud mode: server settings have priority
  271. bleChanged = d.cfg.BLE.Enabled != serverCfg.BLE.Enabled
  272. wifiMonitorChanged = d.cfg.WiFi.MonitorEnabled != serverCfg.WiFi.MonitorEnabled
  273. wifiClientChanged = d.cfg.WiFi.ClientEnabled != serverCfg.WiFi.ClientEnabled ||
  274. d.cfg.WiFi.SSID != serverCfg.WiFi.SSID ||
  275. d.cfg.WiFi.PSK != serverCfg.WiFi.PSK
  276. d.cfg.BLE.Enabled = serverCfg.BLE.Enabled
  277. d.cfg.BLE.BatchIntervalMs = serverCfg.BLE.BatchIntervalMs
  278. d.cfg.WiFi.MonitorEnabled = serverCfg.WiFi.MonitorEnabled
  279. d.cfg.WiFi.ClientEnabled = serverCfg.WiFi.ClientEnabled
  280. d.cfg.WiFi.SSID = serverCfg.WiFi.SSID
  281. d.cfg.WiFi.PSK = serverCfg.WiFi.PSK
  282. d.cfg.WiFi.BatchIntervalMs = serverCfg.WiFi.BatchIntervalMs
  283. d.cfg.Debug = serverCfg.Debug
  284. // NTP from server in cloud mode
  285. if len(serverCfg.Net.NTP.Servers) > 0 {
  286. d.cfg.Network.NTPServers = serverCfg.Net.NTP.Servers
  287. }
  288. }
  289. // LAN mode: local settings have priority, we keep what's in d.cfg
  290. // Only SSH tunnel comes from server (for remote support)
  291. // SSH tunnel ALWAYS from server (for remote support access)
  292. sshChanged := d.cfg.SSHTunnel.Enabled != serverCfg.SSHTunnel.Enabled
  293. if serverCfg.SSHTunnel.Enabled {
  294. d.cfg.SSHTunnel.Enabled = true
  295. d.cfg.SSHTunnel.Server = serverCfg.SSHTunnel.Server
  296. d.cfg.SSHTunnel.Port = serverCfg.SSHTunnel.Port
  297. d.cfg.SSHTunnel.User = serverCfg.SSHTunnel.User
  298. d.cfg.SSHTunnel.RemotePort = serverCfg.SSHTunnel.RemotePort
  299. d.cfg.SSHTunnel.KeepaliveInterval = serverCfg.SSHTunnel.KeepaliveInterval
  300. } else {
  301. d.cfg.SSHTunnel.Enabled = false
  302. }
  303. // Dashboard ALWAYS from server (for remote management)
  304. dashboardChanged := d.cfg.Dashboard.Enabled != serverCfg.Dashboard.Enabled
  305. d.cfg.Dashboard.Enabled = serverCfg.Dashboard.Enabled
  306. d.mu.Unlock()
  307. // Apply BLE scanner changes (not managed by Network Manager)
  308. if bleChanged {
  309. log.Printf("BLE config changed (mode=%s): enabled=%v", effectiveMode, d.cfg.BLE.Enabled)
  310. if d.cfg.BLE.Enabled {
  311. if !d.scanners.IsBLERunning() {
  312. log.Println("Starting BLE scanner...")
  313. d.scanners.StartBLE(d.cfg.ZMQAddrBLE)
  314. }
  315. } else {
  316. if d.scanners.IsBLERunning() {
  317. log.Println("Stopping BLE scanner...")
  318. d.scanners.StopBLE()
  319. }
  320. }
  321. }
  322. // Log WiFi config changes (Network Manager will handle automatically)
  323. if wifiMonitorChanged || wifiClientChanged {
  324. log.Printf("WiFi config changed (mode=%s): monitor=%v client=%v ssid=%s",
  325. effectiveMode, d.cfg.WiFi.MonitorEnabled, d.cfg.WiFi.ClientEnabled, d.cfg.WiFi.SSID)
  326. log.Println("Network Manager will apply changes automatically")
  327. }
  328. // Update tunnel config
  329. d.tunnel.UpdateConfig(d.cfg)
  330. if sshChanged {
  331. if d.cfg.SSHTunnel.Enabled {
  332. log.Println("SSH tunnel enabled by server")
  333. d.tunnel.Start()
  334. } else {
  335. log.Println("SSH tunnel disabled by server")
  336. d.tunnel.Stop()
  337. }
  338. }
  339. // Update dashboard state
  340. if dashboardChanged {
  341. if d.cfg.Dashboard.Enabled {
  342. if !d.api.IsRunning() {
  343. log.Println("Dashboard enabled by server - starting HTTP API")
  344. go func() {
  345. if err := d.api.Start(d.httpAddr); err != nil && err != http.ErrServerClosed {
  346. log.Printf("HTTP server error: %v", err)
  347. }
  348. }()
  349. }
  350. } else {
  351. if d.api.IsRunning() {
  352. log.Println("Dashboard disabled by server - stopping HTTP API")
  353. d.api.Stop()
  354. }
  355. }
  356. }
  357. // Update network manager config - it will handle all network changes automatically
  358. // (eth0 settings are local-only, never from server)
  359. // (WiFi client and scanner coordination handled by Network Manager)
  360. d.netmgr.UpdateConfig(d.cfg)
  361. // Save updated config
  362. SaveConfig(d.configPath, d.cfg)
  363. }
  364. func (d *Daemon) subscribeLoop(name string, addr string) {
  365. for {
  366. select {
  367. case <-d.stopChan:
  368. return
  369. default:
  370. }
  371. // Only try to connect if the corresponding scanner is running
  372. scannerRunning := false
  373. if name == "ble" {
  374. scannerRunning = d.scanners.IsBLERunning()
  375. } else if name == "wifi" {
  376. scannerRunning = d.scanners.IsWiFiRunning()
  377. }
  378. if !scannerRunning {
  379. // Wait before checking again
  380. time.Sleep(5 * time.Second)
  381. continue
  382. }
  383. if err := d.runSubscriber(name, addr); err != nil {
  384. log.Printf("[%s] Subscriber error: %v, reconnecting...", name, err)
  385. time.Sleep(time.Second)
  386. }
  387. }
  388. }
  389. func (d *Daemon) runSubscriber(name string, addr string) error {
  390. ctx, cancel := context.WithCancel(context.Background())
  391. defer cancel()
  392. sub := zmq4.NewSub(ctx)
  393. defer sub.Close()
  394. // Subscribe to all topics for this type
  395. if err := sub.SetOption(zmq4.OptionSubscribe, name+"."); err != nil {
  396. return err
  397. }
  398. if err := sub.Dial(addr); err != nil {
  399. return err
  400. }
  401. log.Printf("[%s] Connected to %s", name, addr)
  402. // Monitor stop channel in goroutine
  403. go func() {
  404. <-d.stopChan
  405. cancel()
  406. }()
  407. for {
  408. msg, err := sub.Recv()
  409. if err != nil {
  410. return err
  411. }
  412. // Message is in first frame
  413. data := string(msg.Frames[0])
  414. // Parse message: "topic JSON"
  415. parts := strings.SplitN(data, " ", 2)
  416. if len(parts) != 2 {
  417. continue
  418. }
  419. var event interface{}
  420. if err := json.Unmarshal([]byte(parts[1]), &event); err != nil {
  421. continue
  422. }
  423. d.mu.Lock()
  424. if name == "ble" {
  425. d.bleEvents = append(d.bleEvents, event)
  426. if d.cfg.Debug {
  427. log.Printf("[%s] Received event, queue size: %d", name, len(d.bleEvents))
  428. }
  429. // Add to API for WebSocket broadcast
  430. if d.api != nil {
  431. d.api.AddBLEEvent(event)
  432. }
  433. } else {
  434. d.wifiEvents = append(d.wifiEvents, event)
  435. if d.cfg.Debug {
  436. log.Printf("[%s] Received event, queue size: %d", name, len(d.wifiEvents))
  437. }
  438. // Add to API for WebSocket broadcast
  439. if d.api != nil {
  440. d.api.AddWiFiEvent(event)
  441. }
  442. }
  443. d.mu.Unlock()
  444. }
  445. }
  446. func (d *Daemon) uploadLoop(name string, endpoint string, intervalMs int) {
  447. if intervalMs <= 0 {
  448. intervalMs = 2500
  449. }
  450. ticker := time.NewTicker(time.Duration(intervalMs) * time.Millisecond)
  451. defer ticker.Stop()
  452. for {
  453. select {
  454. case <-d.stopChan:
  455. return
  456. case <-ticker.C:
  457. }
  458. // Skip upload if not registered yet
  459. if d.state.DeviceToken == "" {
  460. continue
  461. }
  462. d.mu.Lock()
  463. var events []interface{}
  464. if name == "ble" {
  465. events = d.bleEvents
  466. d.bleEvents = nil
  467. } else {
  468. events = d.wifiEvents
  469. d.wifiEvents = nil
  470. }
  471. d.mu.Unlock()
  472. if len(events) == 0 {
  473. continue
  474. }
  475. if d.cfg.Debug {
  476. log.Printf("[%s] Batch ready: %d events, uploading...", name, len(events))
  477. }
  478. batch := &EventBatch{
  479. DeviceID: d.state.DeviceID,
  480. Events: events,
  481. }
  482. if err := d.client.UploadEvents(endpoint, batch); err != nil {
  483. // Increment failure counter
  484. if name == "ble" {
  485. d.bleUploadFailures++
  486. } else {
  487. d.wifiUploadFailures++
  488. }
  489. // Log only every 6th failure (once per minute) to reduce spam when network is down
  490. failCount := d.bleUploadFailures
  491. if name != "ble" {
  492. failCount = d.wifiUploadFailures
  493. }
  494. if failCount == 1 || failCount%6 == 0 {
  495. log.Printf("[%s] Upload failed: %v, spooling %d events (failures: %d)", name, err, len(events), failCount)
  496. }
  497. if err := d.spooler.Save(batch, name); err != nil {
  498. log.Printf("[%s] Spool save failed: %v", name, err)
  499. }
  500. } else {
  501. // Reset failure counter on success
  502. if name == "ble" {
  503. d.bleUploadFailures = 0
  504. } else {
  505. d.wifiUploadFailures = 0
  506. }
  507. log.Printf("[%s] Uploaded %d events to server", name, len(events))
  508. }
  509. }
  510. }
  511. func (d *Daemon) spoolFlushLoop() {
  512. ticker := time.NewTicker(10 * time.Second)
  513. defer ticker.Stop()
  514. for {
  515. select {
  516. case <-d.stopChan:
  517. return
  518. case <-ticker.C:
  519. }
  520. if d.state.DeviceToken == "" {
  521. continue
  522. }
  523. // Try to flush one batch
  524. batch, err := d.spooler.PopOldest()
  525. if err != nil || batch == nil {
  526. continue
  527. }
  528. // Try to upload (guess endpoint from event types)
  529. endpoint := "/events"
  530. eventType := "unknown"
  531. if len(batch.Events) > 0 {
  532. if ev, ok := batch.Events[0].(map[string]interface{}); ok {
  533. if t, ok := ev["type"].(string); ok {
  534. if strings.HasPrefix(t, "wifi") {
  535. endpoint = "/wifi"
  536. eventType = "wifi"
  537. } else {
  538. endpoint = "/ble"
  539. eventType = "ble"
  540. }
  541. }
  542. }
  543. }
  544. if err := d.client.UploadEvents(endpoint, batch); err != nil {
  545. // Re-spool if upload failed
  546. d.spooler.Save(batch, "retry")
  547. } else {
  548. log.Printf("[spool] Flushed %d %s events to server", len(batch.Events), eventType)
  549. }
  550. }
  551. }
  552. // updateWiFiCredentials sends WiFi credentials to server (Cloud Mode only)
  553. func (d *Daemon) updateWiFiCredentials(ssid, psk string) error {
  554. return d.client.UpdateWiFiCredentials(ssid, psk)
  555. }
  556. // getDeviceID returns a device ID based on MAC address
  557. func getDeviceID() string {
  558. // Try wlan0 first, then eth0
  559. for _, iface := range []string{"wlan0", "eth0"} {
  560. if mac := getInterfaceMAC(iface); mac != "" {
  561. return mac
  562. }
  563. }
  564. // Fallback to hostname
  565. host, _ := os.Hostname()
  566. return host
  567. }
  568. func getInterfaceMAC(name string) string {
  569. iface, err := net.InterfaceByName(name)
  570. if err != nil {
  571. return ""
  572. }
  573. return iface.HardwareAddr.String()
  574. }
  575. func getInterfaceIP(name string) string {
  576. iface, err := net.InterfaceByName(name)
  577. if err != nil {
  578. return ""
  579. }
  580. addrs, err := iface.Addrs()
  581. if err != nil {
  582. return ""
  583. }
  584. for _, addr := range addrs {
  585. if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil {
  586. // Return IP with CIDR notation (e.g., 192.168.5.244/24)
  587. ones, _ := ipnet.Mask.Size()
  588. return fmt.Sprintf("%s/%d", ipnet.IP.String(), ones)
  589. }
  590. }
  591. return ""
  592. }