Bind to localhost by default and stop echoing backend errors (which can embed credentials or low-level details) back over the API and log broadcast. Validate hotspot SSID/passphrase/channel before writing hostapd.conf and tighten its mode to 0600 since it stores the WPA PSK. Restrict WebSocket upgrades to same-origin so a LAN browser can't be turned into a proxy for the API. Guard shared state: status reads/writes go through StatusMutex (the periodic updater races with the toggle and status handlers otherwise), broadcastToWebSockets no longer mutates the client map under RLock, and station-event callbacks now run under SafeGo so a panic in app code can't take down the daemon. Stop channels in hostapd, dhcp, and iwd signal monitors are now closed under sync.Once to survive concurrent Stop calls. App.Shutdown is idempotent and waits for the periodic loops before closing backends, so signal-driven and deferred shutdowns no longer race. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
147 lines
3.5 KiB
Go
147 lines
3.5 KiB
Go
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
|
|
}
|