package hotspot import ( "errors" "fmt" "log" "os" "os/exec" "strconv" "strings" "github.com/nemunaire/repeater/internal/models" ) const ( AP_INTERFACE = "wlan1" HOSTAPD_CONF = "/etc/hostapd/hostapd.conf" ) // validateHotspotConfig rejects values that could break out of the // key=value lines in hostapd.conf or violate WPA-PSK constraints. func validateHotspotConfig(c models.HotspotConfig) error { if l := len(c.SSID); l < 1 || l > 32 { return fmt.Errorf("ssid must be 1-32 bytes (got %d)", l) } if strings.ContainsAny(c.SSID, "\r\n\x00") { return fmt.Errorf("ssid contains forbidden control characters") } if l := len(c.Password); l < 8 || l > 63 { return fmt.Errorf("password must be 8-63 ASCII characters (got %d)", l) } for _, r := range c.Password { if r < 0x20 || r > 0x7e { return fmt.Errorf("password must be printable ASCII") } } // 2.4 GHz channels only — hw_mode is hardcoded to "g" below. if c.Channel < 1 || c.Channel > 14 { return fmt.Errorf("channel must be in 1-14 for 2.4GHz (got %d)", c.Channel) } return nil } // Configure updates the hotspot configuration func Configure(config models.HotspotConfig) error { if err := validateHotspotConfig(config); err != nil { return err } hostapdConfig := fmt.Sprintf(`interface=%s driver=nl80211 ssid=%s hw_mode=g channel=%d wmm_enabled=0 macaddr_acl=0 auth_algs=1 ignore_broadcast_ssid=0 wpa=2 wpa_passphrase=%s wpa_key_mgmt=WPA-PSK wpa_pairwise=TKIP rsn_pairwise=CCMP `, AP_INTERFACE, config.SSID, config.Channel, config.Password) return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0600) } // Start starts the hotspot func Start() error { cmd := exec.Command("/etc/init.d/hostapd", "start") return cmd.Run() } // Stop stops the hotspot func Stop() error { cmd := exec.Command("/etc/init.d/hostapd", "stop") return cmd.Run() } // Status checks if the hotspot is running. // Returns nil if the service is running, or an error if it's stopped or crashed. func Status() error { cmd := exec.Command("/etc/init.d/hostapd", "status") return cmd.Run() } // GetDetailedStatus retrieves detailed status information from hostapd_cli. // Returns nil if hostapd is not running. Distinguishes a non-zero exit // (expected: daemon stopped) from environmental failures (binary missing, // permission denied) which are logged at most once per call. func GetDetailedStatus() *models.HotspotStatus { cmd := exec.Command("hostapd_cli", "status") output, err := cmd.Output() if err != nil { var exitErr *exec.ExitError if !errors.As(err, &exitErr) { log.Printf("hostapd_cli status: %v", err) } return nil } status := &models.HotspotStatus{} lines := strings.Split(string(output), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "Selected interface") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) switch key { case "state": status.State = value case "channel": if ch, err := strconv.Atoi(value); err == nil { status.Channel = ch } case "freq": if freq, err := strconv.Atoi(value); err == nil { status.Frequency = freq } case "ssid[0]": status.SSID = value case "bssid[0]": status.BSSID = value case "num_sta[0]": if num, err := strconv.Atoi(value); err == nil { status.NumStations = num } case "hw_mode": status.HWMode = value case "country_code": status.CountryCode = value } } return status }