package main import ( "bufio" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "os" "os/exec" "runtime" "strconv" "strings" "sync" "time" "github.com/gorilla/websocket" ) // APIServer handles HTTP API requests type APIServer struct { daemon *Daemon upgrader websocket.Upgrader // 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 } // 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"` Eth0RX int64 `json:"eth0_rx,omitempty"` Eth0TX int64 `json:"eth0_tx,omitempty"` Wlan0IP string `json:"wlan0_ip,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"` LoadAvg float64 `json:"load_avg"` } // 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"` WifiSSID string `json:"wifi_ssid"` WifiPSK string `json:"wifi_psk"` Eth0Mode string `json:"eth0_mode"` Eth0IP string `json:"eth0_ip"` Eth0Gateway string `json:"eth0_gateway"` Eth0DNS string `json:"eth0_dns"` 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 { 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/ws", s.handleWebSocket) // Serve static files for dashboard mux.Handle("/", http.FileServer(http.Dir("/opt/mybeacon/www"))) log.Printf("[api] Starting HTTP server on %s", addr) return http.ListenAndServe(addr, s.corsMiddleware(mux)) } // 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"), Eth0RX: eth0rx, Eth0TX: eth0tx, Wlan0IP: getInterfaceIP("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) } // handleMetrics returns system metrics func (s *APIServer) handleMetrics(w http.ResponseWriter, r *http.Request) { metrics := MetricsResponse{ CPUPercent: getCPUPercent(), MemUsedMB: getMemUsedMB(), MemTotalMB: getMemTotalMB(), Temperature: getTemperature(), LoadAvg: getLoadAvg(), } 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) } // 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 } } // 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) s.daemon.mu.Unlock() // Apply WiFi settings if provided (this may take time) if req.Settings.WifiSSID != "" { // Also save to local config s.daemon.mu.Lock() s.daemon.cfg.WiFi.SSID = req.Settings.WifiSSID s.daemon.cfg.WiFi.PSK = req.Settings.WifiPSK s.daemon.cfg.WiFi.ClientEnabled = true SaveConfig(s.daemon.configPath, s.daemon.cfg) s.daemon.mu.Unlock() 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 settings applied: 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 { s.recentBLE = s.recentBLE[1:] } s.recentMu.Unlock() 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 { s.recentWiFi = s.recentWiFi[1:] } s.recentMu.Unlock() 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 { // Simplified - read from /proc/stat return 0 // TODO: implement proper CPU usage } func getMemUsedMB() float64 { var m runtime.MemStats runtime.ReadMemStats(&m) return float64(m.Alloc) / 1024 / 1024 } 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 { data, err := os.ReadFile("/proc/loadavg") if err != nil { return 0 } var load float64 if err := parseFloat(string(data), &load); err != nil { return 0 } return load } 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 } // 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") }