package main import ( "bufio" "context" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "os" "os/exec" "strconv" "strings" "sync" "time" "github.com/gorilla/websocket" ) // APIServer handles HTTP API requests type APIServer struct { daemon *Daemon upgrader websocket.Upgrader // HTTP server httpServer *http.Server serverMu sync.Mutex // Recent events (ring buffer) recentBLE []interface{} recentWiFi []interface{} recentMu sync.RWMutex // WebSocket clients wsClients map[*websocket.Conn]bool wsClientsMu sync.Mutex // Session management sessions map[string]time.Time sessionsMu sync.RWMutex // Cached CPU metrics (updated in background) cachedCPUPercent float64 cpuMu sync.RWMutex } // StatusResponse is the response for /api/status type StatusResponse struct { DeviceID string `json:"device_id"` Registered bool `json:"registered"` Mode string `json:"mode"` Uptime int64 `json:"uptime_sec"` Network NetworkStatus `json:"network"` Scanners ScannerStatus `json:"scanners"` Counters CounterStatus `json:"counters"` ServerOK bool `json:"server_ok"` } type NetworkStatus struct { Eth0IP string `json:"eth0_ip,omitempty"` Eth0MAC string `json:"eth0_mac,omitempty"` Eth0RX int64 `json:"eth0_rx,omitempty"` Eth0TX int64 `json:"eth0_tx,omitempty"` Wlan0IP string `json:"wlan0_ip,omitempty"` Wlan0MAC string `json:"wlan0_mac,omitempty"` Wlan0SSID string `json:"wlan0_ssid,omitempty"` Wlan0Signal int `json:"wlan0_signal,omitempty"` Wlan0Channel int `json:"wlan0_channel,omitempty"` Wlan0Gateway string `json:"wlan0_gateway,omitempty"` Wlan0DNS string `json:"wlan0_dns,omitempty"` Wlan0RX int64 `json:"wlan0_rx,omitempty"` Wlan0TX int64 `json:"wlan0_tx,omitempty"` Gateway string `json:"gateway,omitempty"` DNS string `json:"dns,omitempty"` NTP string `json:"ntp,omitempty"` APActive bool `json:"ap_active"` } type ScannerStatus struct { BLERunning bool `json:"ble_running"` WiFiRunning bool `json:"wifi_running"` } type CounterStatus struct { BLEEvents uint64 `json:"ble_events"` WiFiEvents uint64 `json:"wifi_events"` Uploads uint64 `json:"uploads"` Errors uint64 `json:"errors"` } // MetricsResponse is the response for /api/metrics type MetricsResponse struct { CPUPercent float64 `json:"cpu_percent"` MemUsedMB float64 `json:"mem_used_mb"` MemTotalMB float64 `json:"mem_total_mb"` Temperature float64 `json:"temperature"` Load1m float64 `json:"load_1m"` Load5m float64 `json:"load_5m"` Load15m float64 `json:"load_15m"` } // SettingsRequest is the request for /api/settings type SettingsRequest struct { Password string `json:"password"` Settings SettingsPayload `json:"settings"` } // SettingsPayload contains the actual settings type SettingsPayload struct { Mode string `json:"mode"` // BLE Scanner (LAN mode only) BLEEnabled bool `json:"ble_enabled"` BLEBatchInterval int `json:"ble_batch_interval_ms"` BLEEndpoint string `json:"ble_endpoint"` // WiFi Scanner (LAN mode only) WiFiMonitorEnabled bool `json:"wifi_monitor_enabled"` WiFiMonitorBatchInterval int `json:"wifi_monitor_batch_interval_ms"` WiFiMonitorEndpoint string `json:"wifi_monitor_endpoint"` // WiFi Client WiFiClientEnabled bool `json:"wifi_client_enabled"` WifiSSID string `json:"wifi_ssid"` WifiPSK string `json:"wifi_psk"` // eth0 Eth0Mode string `json:"eth0_mode"` Eth0IP string `json:"eth0_ip"` Eth0Gateway string `json:"eth0_gateway"` Eth0DNS string `json:"eth0_dns"` // NTP NTPServers string `json:"ntp_servers"` } // NewAPIServer creates a new API server func NewAPIServer(daemon *Daemon) *APIServer { return &APIServer{ daemon: daemon, upgrader: websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true // Allow all origins for local access }, }, recentBLE: make([]interface{}, 0, 100), recentWiFi: make([]interface{}, 0, 100), wsClients: make(map[*websocket.Conn]bool), sessions: make(map[string]time.Time), } } // Start starts the HTTP server func (s *APIServer) Start(addr string) error { s.serverMu.Lock() defer s.serverMu.Unlock() if s.httpServer != nil { return fmt.Errorf("HTTP server already running") } // Start CPU monitoring goroutine go s.updateCPUMetrics() mux := http.NewServeMux() // API endpoints mux.HandleFunc("/api/status", s.handleStatus) mux.HandleFunc("/api/metrics", s.handleMetrics) mux.HandleFunc("/api/ble/recent", s.handleBLERecent) mux.HandleFunc("/api/wifi/recent", s.handleWiFiRecent) mux.HandleFunc("/api/config", s.handleConfig) mux.HandleFunc("/api/settings", s.handleSettings) mux.HandleFunc("/api/unlock", s.handleUnlock) mux.HandleFunc("/api/logs", s.handleLogs) mux.HandleFunc("/api/kernel-logs", s.handleKernelLogs) mux.HandleFunc("/api/ws", s.handleWebSocket) // Serve static files for dashboard with SPA fallback mux.Handle("/", s.spaHandler("/opt/mybeacon/www")) s.httpServer = &http.Server{ Addr: addr, Handler: s.corsMiddleware(mux), } log.Printf("[api] Starting HTTP server on %s", addr) return s.httpServer.ListenAndServe() } // Stop stops the HTTP server func (s *APIServer) Stop() error { s.serverMu.Lock() defer s.serverMu.Unlock() if s.httpServer == nil { return nil // Already stopped } log.Println("[api] Stopping HTTP server...") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err := s.httpServer.Shutdown(ctx) s.httpServer = nil return err } // IsRunning returns true if HTTP server is running func (s *APIServer) IsRunning() bool { s.serverMu.Lock() defer s.serverMu.Unlock() return s.httpServer != nil } // spaHandler serves static files with SPA fallback func (s *APIServer) spaHandler(staticPath string) http.Handler { fileServer := http.FileServer(http.Dir(staticPath)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Check if file exists path := staticPath + r.URL.Path _, err := os.Stat(path) // If file doesn't exist and it's not the root, serve index.html (SPA routing) if os.IsNotExist(err) && r.URL.Path != "/" { // Serve index.html for SPA routing http.ServeFile(w, r, staticPath+"/index.html") return } // Serve the file normally fileServer.ServeHTTP(w, r) }) } // corsMiddleware adds CORS headers func (s *APIServer) corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } next.ServeHTTP(w, r) }) } // handleStatus returns device status func (s *APIServer) handleStatus(w http.ResponseWriter, r *http.Request) { s.daemon.mu.Lock() defer s.daemon.mu.Unlock() eth0rx, eth0tx := getInterfaceStats("eth0") wlan0rx, wlan0tx := getInterfaceStats("wlan0") wlanInfo := getWlanInfo() // Gateway and DNS are shared, but wlan0 might have its own gateway := getDefaultGateway() dns := getDNS() status := StatusResponse{ DeviceID: s.daemon.state.DeviceID, Registered: s.daemon.state.DeviceToken != "", Mode: "cloud", // TODO: implement mode switching Uptime: getUptime(), Network: NetworkStatus{ Eth0IP: getInterfaceIP("eth0"), Eth0MAC: getMacAddress("eth0"), Eth0RX: eth0rx, Eth0TX: eth0tx, Wlan0IP: getInterfaceIP("wlan0"), Wlan0MAC: getMacAddress("wlan0"), Wlan0SSID: wlanInfo.ssid, Wlan0Signal: wlanInfo.signal, Wlan0Channel: wlanInfo.channel, Wlan0Gateway: gateway, // same gateway for now Wlan0DNS: dns, // same DNS for now Wlan0RX: wlan0rx, Wlan0TX: wlan0tx, Gateway: gateway, DNS: dns, NTP: "pool.ntp.org", }, Scanners: ScannerStatus{ BLERunning: s.daemon.scanners.IsBLERunning(), WiFiRunning: s.daemon.scanners.IsWiFiRunning(), }, ServerOK: s.daemon.state.DeviceToken != "", } s.jsonResponse(w, status) } // updateCPUMetrics updates CPU metrics in background func (s *APIServer) updateCPUMetrics() { for { cpuPercent := getCPUPercent() s.cpuMu.Lock() s.cachedCPUPercent = cpuPercent s.cpuMu.Unlock() time.Sleep(2 * time.Second) } } // handleMetrics returns system metrics func (s *APIServer) handleMetrics(w http.ResponseWriter, r *http.Request) { load1m, load5m, load15m := getLoadAvg() s.cpuMu.RLock() cpuPercent := s.cachedCPUPercent s.cpuMu.RUnlock() metrics := MetricsResponse{ CPUPercent: cpuPercent, MemUsedMB: getMemUsedMB(), MemTotalMB: getMemTotalMB(), Temperature: getTemperature(), Load1m: load1m, Load5m: load5m, Load15m: load15m, } s.jsonResponse(w, metrics) } // handleBLERecent returns recent BLE events func (s *APIServer) handleBLERecent(w http.ResponseWriter, r *http.Request) { s.recentMu.RLock() events := make([]interface{}, len(s.recentBLE)) copy(events, s.recentBLE) s.recentMu.RUnlock() s.jsonResponse(w, events) } // handleWiFiRecent returns recent WiFi events func (s *APIServer) handleWiFiRecent(w http.ResponseWriter, r *http.Request) { s.recentMu.RLock() events := make([]interface{}, len(s.recentWiFi)) copy(events, s.recentWiFi) s.recentMu.RUnlock() s.jsonResponse(w, events) } // handleLogs returns recent daemon log lines func (s *APIServer) handleLogs(w http.ResponseWriter, r *http.Request) { logFile := "/var/log/mybeacon.log" data, err := os.ReadFile(logFile) if err != nil { s.jsonResponse(w, []string{}) return } lines := strings.Split(string(data), "\n") // Return last 500 lines if len(lines) > 500 { lines = lines[len(lines)-500:] } // Filter out empty lines var result []string for _, line := range lines { if strings.TrimSpace(line) != "" { result = append(result, line) } } s.jsonResponse(w, result) } // handleKernelLogs returns recent kernel log lines from dmesg func (s *APIServer) handleKernelLogs(w http.ResponseWriter, r *http.Request) { // Run dmesg command cmd := exec.Command("dmesg", "-T") output, err := cmd.CombinedOutput() if err != nil { // Try without -T flag (human-readable timestamps) if not supported cmd = exec.Command("dmesg") output, err = cmd.CombinedOutput() if err != nil { s.jsonResponse(w, []string{}) return } } lines := strings.Split(string(output), "\n") // Return last 500 lines if len(lines) > 500 { lines = lines[len(lines)-500:] } // Filter out empty lines var result []string for _, line := range lines { if strings.TrimSpace(line) != "" { result = append(result, line) } } s.jsonResponse(w, result) } // handleConfig returns current config (without secrets) func (s *APIServer) handleConfig(w http.ResponseWriter, r *http.Request) { s.daemon.mu.Lock() cfg := *s.daemon.cfg s.daemon.mu.Unlock() // Remove secrets cfg.SSHTunnel.KeyPath = "" s.jsonResponse(w, cfg) } // handleSettings handles settings updates (requires password) func (s *APIServer) handleSettings(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req SettingsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } // Verify password if !s.verifyPassword(req.Password) { http.Error(w, "Invalid password", http.StatusUnauthorized) return } s.daemon.mu.Lock() // Apply mode change if req.Settings.Mode != "" && (req.Settings.Mode == "cloud" || req.Settings.Mode == "lan") { if s.daemon.cfg.Mode != req.Settings.Mode { log.Printf("[api] Mode changed: %s -> %s", s.daemon.cfg.Mode, req.Settings.Mode) s.daemon.cfg.Mode = req.Settings.Mode } } // In LAN mode: apply scanner settings if s.daemon.cfg.Mode == "lan" { // BLE Scanner s.daemon.cfg.BLE.Enabled = req.Settings.BLEEnabled if req.Settings.BLEBatchInterval > 0 { s.daemon.cfg.BLE.BatchIntervalMs = req.Settings.BLEBatchInterval } // Store custom BLE upload endpoint for LAN mode s.daemon.cfg.BLE.UploadEndpoint = req.Settings.BLEEndpoint log.Printf("[api] BLE scanner (LAN): enabled=%v, interval=%d, endpoint=%s", req.Settings.BLEEnabled, req.Settings.BLEBatchInterval, req.Settings.BLEEndpoint) // WiFi Scanner s.daemon.cfg.WiFi.MonitorEnabled = req.Settings.WiFiMonitorEnabled if req.Settings.WiFiMonitorBatchInterval > 0 { s.daemon.cfg.WiFi.BatchIntervalMs = req.Settings.WiFiMonitorBatchInterval } // Store custom WiFi upload endpoint for LAN mode s.daemon.cfg.WiFi.UploadEndpoint = req.Settings.WiFiMonitorEndpoint log.Printf("[api] WiFi scanner (LAN): enabled=%v, interval=%d, endpoint=%s", req.Settings.WiFiMonitorEnabled, req.Settings.WiFiMonitorBatchInterval, req.Settings.WiFiMonitorEndpoint) // WiFi Client enabled flag s.daemon.cfg.WiFi.ClientEnabled = req.Settings.WiFiClientEnabled } // Apply WiFi Client settings (both modes) if req.Settings.WifiSSID != "" { s.daemon.cfg.WiFi.SSID = req.Settings.WifiSSID s.daemon.cfg.WiFi.PSK = req.Settings.WifiPSK log.Printf("[api] WiFi client: ssid=%s", req.Settings.WifiSSID) } // Apply NTP settings if req.Settings.NTPServers != "" { servers := strings.Split(req.Settings.NTPServers, ",") for i := range servers { servers[i] = strings.TrimSpace(servers[i]) } s.daemon.cfg.Network.NTPServers = servers log.Printf("[api] NTP servers updated: %v", servers) } // Apply eth0 settings (always local, never from server) if req.Settings.Eth0Mode == "static" { s.daemon.cfg.Network.Eth0.Static = true s.daemon.cfg.Network.Eth0.Address = req.Settings.Eth0IP s.daemon.cfg.Network.Eth0.Gateway = req.Settings.Eth0Gateway s.daemon.cfg.Network.Eth0.DNS = req.Settings.Eth0DNS log.Printf("[api] eth0 static IP configured: %s", req.Settings.Eth0IP) } else if req.Settings.Eth0Mode == "dhcp" { s.daemon.cfg.Network.Eth0.Static = false log.Printf("[api] eth0 set to DHCP") } // Save local config SaveConfig(s.daemon.configPath, s.daemon.cfg) // In Cloud Mode: send WiFi credentials to server for centralized management if s.daemon.cfg.Mode == "cloud" && req.Settings.WifiSSID != "" { s.daemon.mu.Unlock() if err := s.daemon.updateWiFiCredentials(req.Settings.WifiSSID, req.Settings.WifiPSK); err != nil { log.Printf("[api] Failed to update WiFi credentials on server: %v", err) // Don't fail the request - local settings are already saved } else { log.Printf("[api] WiFi credentials updated on server") } s.daemon.mu.Lock() } s.daemon.mu.Unlock() // Apply WiFi client connection if credentials provided if req.Settings.WifiSSID != "" { if err := applyWiFiSettings(req.Settings.WifiSSID, req.Settings.WifiPSK); err != nil { log.Printf("[api] Failed to apply WiFi settings: %v", err) http.Error(w, "Failed to apply WiFi settings: "+err.Error(), http.StatusInternalServerError) return } log.Printf("[api] WiFi client connection initiated: SSID=%s", req.Settings.WifiSSID) } s.jsonResponse(w, map[string]bool{"success": true}) } // handleUnlock verifies password and creates session func (s *APIServer) handleUnlock(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } if !s.verifyPassword(req.Password) { http.Error(w, "Invalid password", http.StatusUnauthorized) return } // Create session token (simple implementation) token := generateToken(32) s.sessionsMu.Lock() s.sessions[token] = time.Now().Add(30 * time.Minute) s.sessionsMu.Unlock() s.jsonResponse(w, map[string]string{"token": token}) } // handleWebSocket handles WebSocket connections for live updates func (s *APIServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { conn, err := s.upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("[api] WebSocket upgrade error: %v", err) return } defer conn.Close() s.wsClientsMu.Lock() s.wsClients[conn] = true s.wsClientsMu.Unlock() defer func() { s.wsClientsMu.Lock() delete(s.wsClients, conn) s.wsClientsMu.Unlock() }() // Keep connection alive and read messages for { _, _, err := conn.ReadMessage() if err != nil { break } } } // AddBLEEvent adds a BLE event to recent list and broadcasts to WebSocket func (s *APIServer) AddBLEEvent(event interface{}) { s.recentMu.Lock() s.recentBLE = append(s.recentBLE, event) if len(s.recentBLE) > 100 { // Copy to new slice to allow GC of old backing array newSlice := make([]interface{}, 100) copy(newSlice, s.recentBLE[len(s.recentBLE)-100:]) s.recentBLE = newSlice } s.recentMu.Unlock() // Broadcast asynchronously to avoid blocking the event loop go s.broadcast(map[string]interface{}{ "type": "ble", "event": event, }) } // AddWiFiEvent adds a WiFi event to recent list and broadcasts to WebSocket func (s *APIServer) AddWiFiEvent(event interface{}) { s.recentMu.Lock() s.recentWiFi = append(s.recentWiFi, event) if len(s.recentWiFi) > 100 { // Copy to new slice to allow GC of old backing array newSlice := make([]interface{}, 100) copy(newSlice, s.recentWiFi[len(s.recentWiFi)-100:]) s.recentWiFi = newSlice } s.recentMu.Unlock() // Broadcast asynchronously to avoid blocking the event loop go s.broadcast(map[string]interface{}{ "type": "wifi", "event": event, }) } // broadcast sends a message to all WebSocket clients func (s *APIServer) broadcast(msg interface{}) { data, err := json.Marshal(msg) if err != nil { return } s.wsClientsMu.Lock() defer s.wsClientsMu.Unlock() for conn := range s.wsClients { if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { conn.Close() delete(s.wsClients, conn) } } } // verifyPassword checks if password matches device password func (s *APIServer) verifyPassword(password string) bool { s.daemon.mu.Lock() devicePassword := s.daemon.state.DevicePassword s.daemon.mu.Unlock() // Use default password "admin" if no password set (device not registered) if devicePassword == "" { devicePassword = "admin" } return password != "" && password == devicePassword } // jsonResponse sends a JSON response func (s *APIServer) jsonResponse(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } // Helper functions for system metrics func getUptime() int64 { data, err := os.ReadFile("/proc/uptime") if err != nil { return 0 } var uptime float64 if err := parseFloat(string(data), &uptime); err != nil { return 0 } return int64(uptime) } func parseFloat(s string, f *float64) error { parts := strings.Fields(s) if len(parts) == 0 { return nil } return json.Unmarshal([]byte(parts[0]), f) } func getCPUPercent() float64 { // Read CPU stats from /proc/stat twice with 100ms interval stat1 := readCPUStat() time.Sleep(100 * time.Millisecond) stat2 := readCPUStat() if stat1 == nil || stat2 == nil { return 0 } // Calculate totals (sum all fields) var total1, total2, idle1, idle2 int64 for i := 0; i < len(stat1) && i < 10; i++ { total1 += stat1[i] if i < len(stat2) { total2 += stat2[i] } } // Idle = idle + iowait (fields 3 and 4) if len(stat1) >= 5 { idle1 = stat1[3] + stat1[4] } if len(stat2) >= 5 { idle2 = stat2[3] + stat2[4] } totalDelta := total2 - total1 idleDelta := idle2 - idle1 if totalDelta == 0 { return 0 } cpuPercent := 100.0 * (1.0 - float64(idleDelta)/float64(totalDelta)) return cpuPercent } func readCPUStat() []int64 { data, err := os.ReadFile("/proc/stat") if err != nil { return nil } // First line: cpu user nice system idle iowait irq softirq steal guest guest_nice scanner := bufio.NewScanner(strings.NewReader(string(data))) if !scanner.Scan() { return nil } line := scanner.Text() if !strings.HasPrefix(line, "cpu ") { return nil } fields := strings.Fields(line) if len(fields) < 5 { return nil } var stats []int64 // Read all available fields (up to 10) for i := 1; i < len(fields) && i < 11; i++ { var v int64 json.Unmarshal([]byte(fields[i]), &v) stats = append(stats, v) } return stats } func getMemUsedMB() float64 { data, err := os.ReadFile("/proc/meminfo") if err != nil { return 0 } var memTotal, memAvailable int64 scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "MemTotal:") { parseMemInfo(line, &memTotal) } else if strings.HasPrefix(line, "MemAvailable:") { parseMemInfo(line, &memAvailable) } } if memTotal == 0 { return 0 } memUsed := memTotal - memAvailable return float64(memUsed) / 1024 // Convert KB to MB } func getMemTotalMB() float64 { data, err := os.ReadFile("/proc/meminfo") if err != nil { return 0 } scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "MemTotal:") { var total int64 if err := parseMemInfo(line, &total); err == nil { return float64(total) / 1024 } } } return 0 } func parseMemInfo(line string, value *int64) error { parts := strings.Fields(line) if len(parts) >= 2 { return json.Unmarshal([]byte(parts[1]), value) } return nil } func getTemperature() float64 { data, err := os.ReadFile("/sys/class/thermal/thermal_zone0/temp") if err != nil { return 0 } var temp int64 if err := json.Unmarshal([]byte(strings.TrimSpace(string(data))), &temp); err != nil { return 0 } return float64(temp) / 1000 } func getLoadAvg() (float64, float64, float64) { data, err := os.ReadFile("/proc/loadavg") if err != nil { return 0, 0, 0 } // /proc/loadavg format: "0.10 0.40 0.49 1/98 2452" parts := strings.Fields(string(data)) if len(parts) < 3 { return 0, 0, 0 } var load1m, load5m, load15m float64 json.Unmarshal([]byte(parts[0]), &load1m) json.Unmarshal([]byte(parts[1]), &load5m) json.Unmarshal([]byte(parts[2]), &load15m) return load1m, load5m, load15m } func generateToken(length int) string { const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, length) for i := range b { b[i] = chars[time.Now().UnixNano()%int64(len(chars))] time.Sleep(time.Nanosecond) } return string(b) } func getInterfaceStats(name string) (rx, tx int64) { rxPath := "/sys/class/net/" + name + "/statistics/rx_bytes" txPath := "/sys/class/net/" + name + "/statistics/tx_bytes" if data, err := os.ReadFile(rxPath); err == nil { json.Unmarshal([]byte(strings.TrimSpace(string(data))), &rx) } if data, err := os.ReadFile(txPath); err == nil { json.Unmarshal([]byte(strings.TrimSpace(string(data))), &tx) } return } func getDefaultGateway() string { data, err := os.ReadFile("/proc/net/route") if err != nil { return "" } scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) >= 3 && fields[1] == "00000000" { gw := fields[2] if len(gw) == 8 { b, _ := hex.DecodeString(gw) if len(b) == 4 { // Little-endian: reverse bytes return fmt.Sprintf("%d.%d.%d.%d", b[3], b[2], b[1], b[0]) } } } } return "" } func getDNS() string { data, err := os.ReadFile("/etc/resolv.conf") if err != nil { return "" } var servers []string scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "nameserver ") { servers = append(servers, strings.TrimPrefix(line, "nameserver ")) } } return strings.Join(servers, ", ") } type wlanInfoResult struct { ssid string signal int channel int } func getWlanInfo() wlanInfoResult { var info wlanInfoResult // Get SSID, signal, channel from iw dev wlan0 link out, err := exec.Command("iw", "dev", "wlan0", "link").Output() if err == nil { scanner := bufio.NewScanner(strings.NewReader(string(out))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "SSID:") { info.ssid = strings.TrimSpace(strings.TrimPrefix(line, "SSID:")) } else if strings.HasPrefix(line, "signal:") { // signal: -30 dBm parts := strings.Fields(line) if len(parts) >= 2 { if v, err := strconv.Atoi(parts[1]); err == nil { info.signal = v } } } else if strings.HasPrefix(line, "freq:") { // freq: 2462 parts := strings.Fields(line) if len(parts) >= 2 { if f, err := strconv.Atoi(parts[1]); err == nil { info.channel = freqToChannel(f) } } } } } return info } // freqToChannel converts WiFi frequency (MHz) to channel number func freqToChannel(freq int) int { if freq >= 2412 && freq <= 2484 { if freq == 2484 { return 14 } return (freq - 2407) / 5 } if freq >= 5170 && freq <= 5825 { return (freq - 5000) / 5 } return 0 } // getMacAddress returns MAC address for given interface func getMacAddress(iface string) string { addressPath := "/sys/class/net/" + iface + "/address" data, err := os.ReadFile(addressPath) if err != nil { return "" } return strings.TrimSpace(string(data)) } // applyWiFiSettings configures wpa_supplicant and connects to WiFi func applyWiFiSettings(ssid, psk string) error { // Retry the whole connection up to 3 times var lastErr error for attempt := 1; attempt <= 3; attempt++ { log.Printf("[wifi] Connection attempt %d/3 to SSID=%s", attempt, ssid) if err := tryWiFiConnect(ssid, psk); err != nil { lastErr = err log.Printf("[wifi] Attempt %d failed: %v", attempt, err) if attempt < 3 { log.Printf("[wifi] Waiting 5s before retry...") time.Sleep(5 * time.Second) } continue } log.Println("[wifi] WiFi client connected successfully") return nil } return fmt.Errorf("all connection attempts failed: %w", lastErr) } func tryWiFiConnect(ssid, psk string) error { // Use external shell script for WiFi connection (more reliable) scriptPath := "/opt/mybeacon/bin/wifi-connect.sh" cmd := exec.Command(scriptPath, ssid, psk) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("wifi-connect.sh failed: %w (output: %s)", err, string(output)) } log.Printf("[wifi] %s", strings.TrimSpace(string(output))) // Ensure wlan0 is configured in /etc/network/interfaces (for manual use) ensureWlan0Interface() return nil } func ensureWlan0Interface() error { interfacesPath := "/etc/network/interfaces" data, err := os.ReadFile(interfacesPath) if err != nil { return err } content := string(data) // Check if wlan0 is already configured (not commented out) lines := strings.Split(content, "\n") for _, line := range lines { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "iface wlan0 inet") { return nil // Already configured } } // Add wlan0 configuration wlan0Config := ` # WiFi (managed by beacon-daemon) auto wlan0 iface wlan0 inet dhcp pre-up wpa_supplicant -B -i wlan0 -c /etc/wpa_supplicant/wpa_supplicant.conf post-down killall wpa_supplicant 2>/dev/null || true ` if err := os.WriteFile(interfacesPath, []byte(content+wlan0Config), 0644); err != nil { return err } log.Println("[wifi] Added wlan0 configuration to /etc/network/interfaces") return nil } // disconnectWiFiClient disconnects from WiFi and stops wpa_supplicant func disconnectWiFiClient() { log.Println("[wifi] Disconnecting WiFi client...") exec.Command("killall", "wpa_supplicant", "udhcpc", "dhcpcd").Run() exec.Command("ip", "addr", "flush", "dev", "wlan0").Run() os.RemoveAll("/var/run/wpa_supplicant/wlan0") log.Println("[wifi] WiFi client disconnected") }