From b293355eac9a44b1238be8039dcbf33ffc331c82 Mon Sep 17 00:00:00 2001 From: billz Date: Fri, 18 Jul 2025 14:43:25 -0700 Subject: [PATCH 01/44] Replace procedural code w/ HostapdManager, DnsmasqManager method calls --- includes/hostapd.php | 213 ++++++++++--------------------------------- 1 file changed, 47 insertions(+), 166 deletions(-) diff --git a/includes/hostapd.php b/includes/hostapd.php index bf44a020..99774591 100755 --- a/includes/hostapd.php +++ b/includes/hostapd.php @@ -3,6 +3,9 @@ require_once 'includes/wifi_functions.php'; require_once 'includes/config.php'; +use RaspAP\Networking\Hotspot\DnsmasqManager; +use RaspAP\Networking\Hotspot\HostapdManager; + getWifiInterface(); /** @@ -85,39 +88,24 @@ function DisplayHostAPDConfig() } } } - exec('cat '. RASPI_HOSTAPD_CONFIG, $hostapdconfig); if (isset($_SESSION['wifi_client_interface'])) { exec('iwgetid '.escapeshellarg($_SESSION['wifi_client_interface']). ' -r', $wifiNetworkID); if (!empty($wifiNetworkID[0])) { $managedModeEnabled = true; } } + + // Parse hostapd configuration + $hostapd = new HostapdManager(); + try { + $arrConfig = $hostapd->getConfig(); + } catch (\RuntimeException $e) { + error_log('Error: ' . $e->getMessage()); + } + $hostapdstatus = $system->hostapdStatus(); $serviceStatus = $hostapdstatus[0] == 0 ? "down" : "up"; - - foreach ($hostapdconfig as $hostapdconfigline) { - if (strlen($hostapdconfigline) === 0) { - continue; - } - if ($hostapdconfigline[0] != "#") { - $arrLine = explode("=", $hostapdconfigline); - $arrConfig[$arrLine[0]]=$arrLine[1]; - } - }; - // assign beacon_int boolean if value is set - if (isset($arrConfig['beacon_int'])) { - $arrConfig['beacon_interval_bool'] = 1; - } - // assign disassoc_low_ack boolean if value is set - if (isset($arrConfig['disassoc_low_ack'])) { - $arrConfig['disassoc_low_ack_bool'] = 1; - } else { - $arrConfig['disassoc_low_ack_bool'] = 0; - } - // assign country_code from iw reg if not set in config - if (empty($arrConfig['country_code']) && isset($country_code[0])) { - $arrConfig['country_code'] = $country_code[0]; - } + // set txpower with iw if value is non-default ('auto') if (isset($_POST['txpower'])) { if ($_POST['txpower'] != 'auto') { @@ -132,33 +120,6 @@ function DisplayHostAPDConfig() $txpower = $_POST['txpower']; } } - // map wpa_key_mgmt to security types - if ($arrConfig['wpa_key_mgmt'] == 'WPA-PSK WPA-PSK-SHA256 SAE') { - $arrConfig['wpa'] = 4; - } elseif ($arrConfig['wpa_key_mgmt'] == 'SAE') { - $arrConfig['wpa'] = 5; - } - - $selectedHwMode = $arrConfig['hw_mode']; - if (isset($arrConfig['ieee80211n'])) { - if (strval($arrConfig['ieee80211n']) === '1') { - $selectedHwMode = 'n'; - } - } - if (isset($arrConfig['ieee80211ac'])) { - if (strval($arrConfig['ieee80211ac']) === '1') { - $selectedHwMode = 'ac'; - } - } - if (isset($arrConfig['ieee80211w'])) { - if (strval($arrConfig['ieee80211w']) === '2') { - $selectedHwMode = 'w'; - } - } - - $arrConfig['ignore_broadcast_ssid'] ??= 0; - $arrConfig['max_num_sta'] ??= 0; - $arrConfig['wep_default_key'] ??= 0; exec('sudo /bin/chmod o+r '.RASPI_HOSTAPD_LOG); $logdata = getLogLimited(RASPI_HOSTAPD_LOG); @@ -224,6 +185,8 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom } $arrHostapdConf = parse_ini_file('/etc/raspap/hostapd.ini'); + $dualAPEnable = false; + // Check for Bridged AP mode checkbox $bridgedEnable = 0; if ($arrHostapdConf['BridgedEnable'] == 0) { @@ -351,8 +314,10 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom $_POST['max_num_sta'] = $_POST['max_num_sta'] > 2007 ? 2007 : $_POST['max_num_sta']; $_POST['max_num_sta'] = $_POST['max_num_sta'] < 1 ? null : $_POST['max_num_sta']; + $hostapd = new HostapdManager(); + if ($good_input) { - $config = buildHostapdConfig([ + $config = $hostapd->buildConfig([ 'interface' => $_POST['interface'], 'ssid' => $_POST['ssid'], 'channel' => $_POST['channel'], @@ -369,19 +334,23 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom 'bridge' => $bridgedEnable ? 'br0' : null ]); - file_put_contents('/tmp/hostapddata', $config); - if ($dualAPEnable) { - system("sudo cp /tmp/hostapddata " . '/etc/hostapd/hostapd-'.$_POST['interface'].'.conf', $result); - } else { - system("sudo cp /tmp/hostapddata " . RASPI_HOSTAPD_CONFIG, $result); + try { + $arrConfig = $hostapd->saveConfig($config, $dualAPEnable, $ap_iface); + } catch (\RuntimeException $e) { + error_log('Error: ' . $e->getMessage()); } if (trim($country_code) != trim($reg_domain)) { $return = iwRegSet($country_code, $status); } - // Fetch dhcp-range, lease time from system config - $syscfg = parse_ini_file(RASPI_DNSMASQ_PREFIX.$ap_iface.'.conf', false, INI_SCANNER_RAW); + // Parse dnsmasq config for selected interface + $dnsmasq = new DnsmasqManager(); + try { + $syscfg = $dnsmasq->getConfig($ap_iface ?? RASPI_WIFI_AP_INTERFACE); + } catch (\RuntimeException $e) { + error_log('Error: ' . $e->getMessage()); + } if ($wifiAPEnable == 1) { // Enable uap0 configuration for ap-sta mode @@ -408,13 +377,27 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom $config[] = 'interface='.$_POST['interface']; $config[] = 'domain-needed'; $config[] = 'dhcp-range='.$dhcp_range; + // handle multiple dhcp-host + option entries + if (!empty($syscfg['dhcp-host'])) { + if (is_array($syscfg['dhcp-host'])) { + foreach ($syscfg['dhcp-host'] as $host) { + $config[] = 'dhcp-host=' . $host; + } + } else { + $config[] = 'dhcp-host=' . $syscfg['dhcp-host']; + } + } if (!empty($syscfg['dhcp-option'])) { - $config[] = 'dhcp-option='.$syscfg['dhcp-option']; + if (is_array($syscfg['dhcp-option'])) { + foreach ($syscfg['dhcp-option'] as $opt) { + $config[] = 'dhcp-option=' . $opt; + } + } else { + $config[] = 'dhcp-option=' . $syscfg['dhcp-option']; + } } $config[] = PHP_EOL; - $config = join(PHP_EOL, $config); - file_put_contents("/tmp/dnsmasqdata", $config); - system('sudo cp /tmp/dnsmasqdata '.RASPI_DNSMASQ_PREFIX.$ap_iface.'.conf', $return); + $dnsmasq->saveConfig($config, $ap_iface); } // Set dhcp values from system config, fallback to default if undefined @@ -561,108 +544,6 @@ function getIfaceMetric($iface) } } -/** - * Builds a hostapd configuration string for a given interface - * - * @param array $params Associative array of config values - * @return string - */ -function buildHostapdConfig(array $params): string -{ - $config = []; - $config[] = 'driver=nl80211'; - $config[] = 'ctrl_interface=' . RASPI_HOSTAPD_CTRL_INTERFACE; - $config[] = 'ctrl_interface_group=0'; - $config[] = 'auth_algs=1'; - - $wpa = $params['wpa']; - $wpa_key_mgmt = 'WPA-PSK'; - - if ($wpa == 4) { - $config[] = 'ieee80211w=1'; - $wpa_key_mgmt = 'WPA-PSK WPA-PSK-SHA256 SAE'; - $wpa = 2; - } elseif ($wpa == 5) { - $config[] = 'ieee80211w=2'; - $wpa_key_mgmt = 'SAE'; - $wpa = 2; - } - - if ($params['80211w'] == 1) { - $config[] = 'ieee80211w=1'; - $wpa_key_mgmt = 'WPA-PSK'; - } elseif ($params['80211w'] == 2) { - $config[] = 'ieee80211w=2'; - $wpa_key_mgmt = 'WPA-PSK-SHA256'; - } - - $config[] = 'wpa_key_mgmt=' . $wpa_key_mgmt; - - if (!empty($params['beacon_interval'])) { - $config[] = 'beacon_int=' . $params['beacon_interval']; - } - - if (!empty($params['disassoc_low_ack'])) { - $config[] = 'disassoc_low_ack=0'; - } - - $config[] = 'ssid=' . $params['ssid']; - $config[] = 'channel=' . $params['channel']; - - // Choose VHT segment index (fallback only if required) - $vht_freq_idx = ($params['channel'] < RASPI_5GHZ_CHANNEL_MIN) ? 42 : 155; - - switch ($params['hw_mode']) { - case 'n': - $config[] = 'hw_mode=g'; - $config[] = 'ieee80211n=1'; - $config[] = 'wmm_enabled=1'; - break; - case 'ac': - $config[] = 'hw_mode=a'; - $config[] = '# N'; - $config[] = 'ieee80211n=1'; - $config[] = 'require_ht=1'; - $config[] = 'ht_capab=[MAX-AMSDU-3839][HT40+][SHORT-GI-20][SHORT-GI-40][DSSS_CCK-40]'; - $config[] = '# AC'; - $config[] = 'ieee80211ac=1'; - $config[] = 'require_vht=1'; - $config[] = 'ieee80211d=0'; - $config[] = 'ieee80211h=0'; - $config[] = 'vht_capab=[MAX-AMSDU-3839][SHORT-GI-80]'; - $config[] = 'vht_oper_chwidth=1'; - $config[] = 'vht_oper_centr_freq_seg0_idx=' . $vht_freq_idx; - break; - default: - $config[] = 'hw_mode=' . $params['hw_mode']; - $config[] = 'ieee80211n=0'; - } - - if ($params['wpa'] !== 'none') { - $config[] = 'wpa_passphrase=' . $params['wpa_passphrase']; - } - - if (!empty($params['bridge'])) { - $config[] = 'interface=' . $params['interface']; - $config[] = 'bridge=' . $params['bridge']; - } else { - $config[] = 'interface=' . $params['interface']; - } - - $config[] = 'wpa=' . $wpa; - $config[] = 'wpa_pairwise=' . $params['wpa_pairwise']; - $config[] = 'country_code=' . $params['country_code']; - $config[] = 'ignore_broadcast_ssid=' . $params['hiddenSSID']; - if (!empty($params['max_num_sta'])) { - $config[] = 'max_num_sta=' . (int)$params['max_num_sta']; - } - - // Optional additional user config - $config[] = parseUserHostapdCfg(); - - return implode(PHP_EOL, $config) . PHP_EOL; -} - /** * Updates the dhcpcd configuration for a given interface, preserving existing settings * From 2b2a76c5126138496c7f2e5ddd9f22cab1bf2e67 Mon Sep 17 00:00:00 2001 From: billz Date: Fri, 18 Jul 2025 14:43:47 -0700 Subject: [PATCH 02/44] Update w/ reload dnsmasq.service --- installers/raspap.sudoers | 1 + 1 file changed, 1 insertion(+) diff --git a/installers/raspap.sudoers b/installers/raspap.sudoers index 3b7fd055..2d051035 100644 --- a/installers/raspap.sudoers +++ b/installers/raspap.sudoers @@ -22,6 +22,7 @@ www-data ALL=(ALL) NOPASSWD:/bin/systemctl stop hostapd.service www-data ALL=(ALL) NOPASSWD:/bin/systemctl start dnsmasq.service www-data ALL=(ALL) NOPASSWD:/bin/systemctl stop dnsmasq.service www-data ALL=(ALL) NOPASSWD:/bin/systemctl restart dnsmasq.service +www-data ALL=(ALL) NOPASSWD:/bin/systemctl reload dnsmasq.service www-data ALL=(ALL) NOPASSWD:/bin/systemctl start openvpn-client@client www-data ALL=(ALL) NOPASSWD:/bin/systemctl enable openvpn-client@client www-data ALL=(ALL) NOPASSWD:/bin/systemctl stop openvpn-client@client From 619bfdc04dcf55eaaea2b59a7fa1a01ca8f2982a Mon Sep 17 00:00:00 2001 From: billz Date: Fri, 18 Jul 2025 14:44:52 -0700 Subject: [PATCH 03/44] Create getConfig(), saveConfig() class methods --- .../Networking/Hotspot/DnsmasqManager.php | 89 +++++ .../Networking/Hotspot/HostapdManager.php | 315 ++++++++++++++++++ 2 files changed, 404 insertions(+) create mode 100644 src/RaspAP/Networking/Hotspot/DnsmasqManager.php create mode 100644 src/RaspAP/Networking/Hotspot/HostapdManager.php diff --git a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php new file mode 100644 index 00000000..46f121a1 --- /dev/null +++ b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php @@ -0,0 +1,89 @@ + after save + * @return bool + * @throws \RuntimeException + */ + public function saveConfig(string $config, bool $dualMode, string $iface, bool $restart = false): bool + { + $configFile = $this->resolveConfigPath($iface, $dualMode); + //$configFile = self::CONF_DEFAULT; + $tempFile = self::CONF_TMP; + + + if (file_put_contents($tempFile, $config) === false) { + throw new \RuntimeException("Failed to write temp hostapd config"); + } + + exec(sprintf('sudo cp %s %s', escapeshellarg($tempFile), escapeshellarg($configFile)), $o, $status); + if ($status !== 0) { + throw new \RuntimeException("Failed to apply new hostapd config"); + } + + if ($restart) { + $this->restartService($iface); + } + + return true; + } + + /** + * Sets transmit power for an interface + * + * @param string $iface + * @param int|string $dbm + * @return bool + */ + public function setTxPower(string $iface, $dbm): bool + { + return false; + } + + /** + * Sets regulatory domain + * + * @param string $countryCode + * @return bool + */ + public function setRegDomain(string $countryCode): bool + { + return false; + } + + /** + * Parses optional /etc/hostapd/hostapd.conf.users file + * + * @return string $tmp + */ + function parseUserHostapdCfg() + { + if (file_exists(CONF_DEFAULT . '.users')) { + exec('cat '. CONF_DEFAULT . '.users', $hostapdconfigusers); + foreach ($hostapdconfigusers as $hostapdconfigusersline) { + if (strlen($hostapdconfigusersline) === 0) { + continue; + } + if ($hostapdconfigusersline[0] != "#") { + $arrLine = explode("=", $hostapdconfigusersline); + $tmp.= $arrLine[0]."=".$arrLine[1].PHP_EOL;; + } + } + return $tmp; + } + } + + /** + * Determines the hostapd config file for a given interface + * + * @param string $iface + * @param bool $dualMode + * @return string + */ + private function resolveConfigPath(string $iface, bool $dualMode): string + { + if ($dualMode) { + return SELF::CONF_PATH_PREFIX . $iface . '.conf'; + } + // primary interface uses the canonical config path + return self::CONF_DEFAULT; + } + + /** + * Restarts hostapd systemd instance + * + * @param string $iface + * @throws \RuntimeException + */ + private function restartService(string $iface): void + { + // sanitize + if (!preg_match('/^[A-Za-z0-9_-]+$/', $iface)) { + throw new \RuntimeException("Invalid interface name: $iface"); + } + + // use instance unit (preferred) if available + $cmds = [ + sprintf('sudo systemctl restart hostapd@%s', $iface), + // fallback to singleton service + 'sudo systemctl restart hostapd.service' + ]; + + foreach ($cmds as $cmd) { + exec($cmd, $out, $rc); + if ($rc === 0) { + return; + } + } + + throw new \RuntimeException("Failed to restart hostapd (tried instance + fallback)."); + } + +} + + From 4e55f5a97fcfa016e2151071e25e8abc5aadd578 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 00:18:18 -0700 Subject: [PATCH 04/44] Initial commit --- src/RaspAP/Networking/Hotspot/WiFiManager.php | 392 ++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 src/RaspAP/Networking/Hotspot/WiFiManager.php diff --git a/src/RaspAP/Networking/Hotspot/WiFiManager.php b/src/RaspAP/Networking/Hotspot/WiFiManager.php new file mode 100644 index 00000000..e7002a7a --- /dev/null +++ b/src/RaspAP/Networking/Hotspot/WiFiManager.php @@ -0,0 +1,392 @@ + + * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE + */ + +class WiFiManager +{ + + private const MIN_RSSI = -100; + private const MAX_RSSI = -55; + + public function knownWifiStations(&$networks) + { + // find currently configured networks + exec(' sudo cat ' . RASPI_WPA_SUPPLICANT_CONFIG, $known_return); + $index = 0; + foreach ($known_return as $line) { + if (preg_match('/network\s*=/', $line)) { + $network = array('visible' => false, 'configured' => true, 'connected' => false, 'index' => null); + ++$index; + } elseif (isset($network) && $network !== null) { + if (preg_match('/^\s*}\s*$/', $line)) { + $networks[$ssid] = $network; + $network = null; + $ssid = null; + } elseif ($lineArr = preg_split('/\s*=\s*/', trim($line), 2)) { + switch (strtolower($lineArr[0])) { + case 'ssid': + $ssid = trim($lineArr[1], '"'); + $ssid = str_replace('P"','',$ssid); + $network['ssid'] = $ssid; + $index = $this->getNetworkIdBySSID($ssid); + $network['index'] = $index; + break; + case 'psk': + $network['passkey'] = trim($lineArr[1]); + $network['protocol'] = 'WPA'; + break; + case '#psk': + $network['protocol'] = 'WPA'; + case 'wep_key0': // Untested + $network['passphrase'] = trim($lineArr[1], '"'); + break; + case 'key_mgmt': + if (! array_key_exists('passphrase', $network) && $lineArr[1] === 'NONE') { + $network['protocol'] = 'Open'; + } + break; + case 'priority': + $network['priority'] = trim($lineArr[1], '"'); + break; + } + } + } + } + } + + /** + * Scans for nearby WiFi networks using `iw` and updates the reference array + * + * @param array $networks Reference to the array of known and discovered networks + * @param bool $cached If false, bypasses the cache and performs a fresh scan + */ + public function nearbyWifiStations(&$networks, $cached = true) + { + $cacheTime = filemtime(RASPI_WPA_SUPPLICANT_CONFIG); + $cacheKey = "nearby_wifi_stations_$cacheTime"; + + if ($cached == false) { + deleteCache($cacheKey); + } + + $iface = escapeshellarg($_SESSION['wifi_client_interface']); + + $scan_results = cache( + $cacheKey, + function () use ($iface) { + $stdout = shell_exec("sudo iw dev $iface scan"); + return preg_split("/\n/", $stdout); + } + ); + + // exclude the AP from nearby networks + exec('sed -rn "s/ssid=(.*)\s*$/\1/p" ' . escapeshellarg(RASPI_HOSTAPD_CONFIG), $ap_ssid); + $ap_ssid = $ap_ssid[0] ?? ''; + + $index = 0; + if (!empty($networks)) { + $lastnet = end($networks); + if (isset($lastnet['index'])) { + $index = $lastnet['index'] + 1; + } + } + + $current = []; + $commitCurrent = function () use (&$current, &$networks, &$index, $ap_ssid) { + if (empty($current['ssid'])) { + return; + } + + $ssid = $current['ssid']; + + // unprintable 7bit ASCII control codes, delete or quotes -> ignore network + if ($ssid === $ap_ssid || preg_match('/[\x00-\x1f\x7f\'`\´"]/', $ssid)) { + return; + } + + $channel = ConvertToChannel($current['freq'] ?? 0); + $rssi = $current['signal'] ?? -100; + + // if network is saved + if (array_key_exists($ssid, $networks)) { + $networks[$ssid]['visible'] = true; + $networks[$ssid]['channel'] = $channel; + if (!isset($networks[$ssid]['RSSI']) || $networks[$ssid]['RSSI'] < $rssi) { + $networks[$ssid]['RSSI'] = $rssi; + } + } else { + $networks[$ssid] = [ + 'ssid' => $ssid, + 'configured' => false, + 'protocol' => $current['security'] ?? 'OPEN', + 'channel' => $channel, + 'passphrase' => '', + 'visible' => true, + 'connected' => false, + 'RSSI' => $rssi, + 'index' => $index + ]; + ++$index; + } + }; + + foreach ($scan_results as $line) { + $line = trim($line); + + if (preg_match('/^BSS\s+([0-9a-f:]{17})/', $line, $match)) { + $commitCurrent(); // commit previous + $current = [ + 'bssid' => $match[1], + 'ssid' => '', + 'signal' => null, + 'freq' => null, + 'security' => 'OPEN' + ]; + continue; + } + if (preg_match('/^SSID:\s*(.*)$/', $line, $match)) { + $current['ssid'] = $match[1]; + continue; + } + if (preg_match('/^signal:\s*(-?\d+\.\d+)/', $line, $match)) { + $current['signal'] = (float)$match[1]; + continue; + } + if (preg_match('/^freq:\s*(\d+)/', $line, $match)) { + $current['freq'] = (int)$match[1]; + continue; + } + if (preg_match('/^RSN:/', $line) || preg_match('/^WPA:/', $line)) { + $current['security'] = 'WPA/WPA2'; + continue; + } + } + $commitCurrent(); + } + + /** + * + */ + public function connectedWifiStations(&$networks) + { + exec('iwconfig ' .$_SESSION['wifi_client_interface'], $iwconfig_return); + foreach ($iwconfig_return as $line) { + if (preg_match('/ESSID:\"([^"]+)\"/i', $line, $iwconfig_ssid)) { + $ssid=hexSequence2lower($iwconfig_ssid[1]); + $networks[$ssid]['connected'] = true; + $check=detectCaptivePortal($_SESSION['wifi_client_interface']); + $networks[$ssid]["portal-url"]=$check["URL"]; + } + } + } + + /** + * + * + */ + public function sortNetworksByRSSI(&$networks) + { + $valRSSI = array(); + foreach ($networks as $SSID => $net) { + if (!array_key_exists('RSSI', $net)) { + $net['RSSI'] = -1000; + } + $valRSSI[$SSID] = $net['RSSI']; + } + $nets = $networks; + arsort($valRSSI); + $networks = array(); + foreach ($valRSSI as $SSID => $RSSI) { + $networks[$SSID] = $nets[$SSID]; + $networks[$SSID]['RSSI'] = $RSSI; + } + } + + /* + * Determines the configured wireless AP interface + * + * If not saved in /etc/raspap/hostapd.ini, check for a second + * wireless interface with iw dev. Fallback to the constant + * value defined in config.php + */ + public function getWifiInterface() + { + $hostapdIni = RASPI_CONFIG . '/hostapd.ini'; + $arrHostapdConf = file_exists($hostapdIni) ? parse_ini_file($hostapdIni) : []; + + $iface = $_SESSION['ap_interface'] = $arrHostapdConf['WifiInterface'] ?? RASPI_WIFI_AP_INTERFACE; + + if (!validateInterface($iface)) { + $iface = RASPI_WIFI_AP_INTERFACE; + } + + // check for 2nd wifi interface -> wifi client on different interface + exec("iw dev | awk '$1==\"Interface\" && $2!=\"$iface\" {print $2}'", $iface2); + $client_iface = $_SESSION['wifi_client_interface'] = empty($iface2) ? $iface : trim($iface2[0]); + + // handle special case for RPi Zero W in AP-STA mode + if ($client_iface === "uap0" && ($arrHostapdConf['WifiAPEnable'] ?? 0)) { + $_SESSION['wifi_client_interface'] = $iface; + $_SESSION['ap_interface'] = $client_iface; + } + } + + /* + * Reinitializes wpa_supplicant for the wireless client interface + * The 'force' parameter deletes the socket in /var/run/wpa_supplicant/ + * + * @param boolean $force + */ + public function reinitializeWPA($force) + { + $iface = escapeshellarg($_SESSION['wifi_client_interface']); + if ($force == true) { + $cmd = "sudo wpa_supplicant -B -Dnl80211 -c/etc/wpa_supplicant/wpa_supplicant.conf -i$iface"; + $result = shell_exec($cmd); + } + $cmd = "sudo wpa_cli -i $iface reconfigure"; + $result = shell_exec($cmd); + sleep(1); + return $result; + } + + /* + * Replace escaped bytes (hex) by binary - assume UTF8 encoding + * + * @param string $ssid + */ + public function ssid2utf8($ssid) + { + return evalHexSequence($ssid); + } + + /* + * Returns a signal strength indicator based on RSSI value + * + * @param string $rssi + */ + public function getSignalBars($rssi) + { + // assign css class based on RSSI value + $class = ''; + if ($rssi >= SELF::MAX_RSSI) { + $class = 'strong'; + } elseif ($rssi >= -56) { + $class = 'medium'; + } elseif ($rssi >= -67) { + $class = 'weak'; + } elseif ($rssi >= -89) { + $class = ''; + } + + // calculate percent strength + if ($rssi >= -50) { + $pct = 100; + } elseif ($rssi <= SELF::MIN_RSSI) { + $pct = 0; + } else { + $pct = 2*($rssi + 100); + } + $elem = '
'.PHP_EOL; + for ($n = 0; $n < 3; $n++ ) { + $elem .= '
'.PHP_EOL; + } + $elem .= '
'.PHP_EOL; + return $elem; + } + + /* + * Parses output of wpa_cli list_networks, compares with known networks + * from wpa_supplicant, and adds with wpa_cli if not found + * + * @param array $networks + */ + public function setKnownStationsWPA($networks) + { + $iface = escapeshellarg($_SESSION['wifi_client_interface']); + $output = shell_exec("sudo wpa_cli -i $iface list_networks"); + $lines = explode("\n", $output); + array_shift($lines); + $wpaCliNetworks = []; + + foreach ($lines as $line) { + $data = explode("\t", trim($line)); + if (!empty($data) && count($data) >= 2) { + $id = $data[0]; + $ssid = $data[1]; + $item = [ + 'id' => $id, + 'ssid' => $ssid + ]; + $wpaCliNetworks[] = $item; + } + } + foreach ($networks as $network) { + $ssid = $network['ssid']; + if (!$this->networkExists($ssid, $wpaCliNetworks)) { + $ssid = escapeshellarg('"'.$network['ssid'].'"'); + $psk = escapeshellarg('"'.$network['passphrase'].'"'); + $protocol = $network['protocol']; + $netid = trim(shell_exec("sudo wpa_cli -i $iface add_network")); + if (isset($netid) && !isset($known[$netid])) { + $commands = [ + "sudo wpa_cli -i $iface set_network $netid ssid $ssid", + "sudo wpa_cli -i $iface set_network $netid psk $psk", + "sudo wpa_cli -i $iface enable_network $netid" + ]; + if ($protocol === 'Open') { + $commands[1] = "sudo wpa_cli -i $iface set_network $netid key_mgmt NONE"; + } + foreach ($commands as $cmd) { + exec($cmd); + usleep(1000); + } + } + } + } + } + + /* + * Parses wpa_cli list_networks output and returns the id + * of a corresponding network SSID + * + * @param string $ssid + * @return integer id + */ + public function getNetworkIdBySSID($ssid) { + $iface = escapeshellarg($_SESSION['wifi_client_interface']); + $cmd = "sudo wpa_cli -i $iface list_networks"; + $output = []; + exec($cmd, $output); + array_shift($output); + foreach ($output as $line) { + $columns = preg_split('/\t/', $line); + if (count($columns) >= 4 && trim($columns[1]) === trim($ssid)) { + return $columns[0]; // return network ID + } + } + return null; + } + + /** + * + */ + public function networkExists($ssid, $collection) + { + foreach ($collection as $network) { + if ($network['ssid'] === $ssid) { + return true; + } + } + return false; + } + +} + From dea3e7c4850aa990b922d99fc573d7b2089fa1a5 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 00:19:24 -0700 Subject: [PATCH 05/44] Modify select to use selected_hw_mode from config --- templates/hostapd/basic.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/hostapd/basic.php b/templates/hostapd/basic.php index 1845c829..e30a2309 100644 --- a/templates/hostapd/basic.php +++ b/templates/hostapd/basic.php @@ -19,7 +19,7 @@
"> - +
From 094ebdb85f7ebf1f2c27c42b823c993fea95d5bc Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 00:23:14 -0700 Subject: [PATCH 06/44] Replace procedural code w/ dnsmasq->buildConfig, hostapd->persistHostapdIni --- includes/hostapd.php | 81 ++++++++------------------------------------ 1 file changed, 15 insertions(+), 66 deletions(-) diff --git a/includes/hostapd.php b/includes/hostapd.php index 99774591..3b2e3e21 100755 --- a/includes/hostapd.php +++ b/includes/hostapd.php @@ -1,12 +1,12 @@ getWifiInterface(); /** * Initialize hostapd values, display interface @@ -132,7 +132,6 @@ function DisplayHostAPDConfig() "interfaces", "arrConfig", "arr80211Standard", - "selectedHwMode", "arrSecurity", "arrEncType", "arr80211w", @@ -160,8 +159,10 @@ function DisplayHostAPDConfig() */ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_domain, $status) { - // It should not be possible to send bad data for these fields. - // If wpa fields are absent, return false and log securely. + $hostapd = new HostapdManager(); + $dnsmasq = new DnsmasqManager(); + + // If wpa fields are absent, return false and log securely if (!(array_key_exists($_POST['wpa'], $wpa_array) && array_key_exists($_POST['wpa_pairwise'], $enc_types) && array_key_exists($_POST['hw_mode'], $modes)) @@ -255,16 +256,8 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom $cli_iface = $session_iface = 'br0'; } - // persist user options to /etc/raspap - $cfg = []; - $cfg['WifiInterface'] = $ap_iface; - $cfg['LogEnable'] = $logEnable; - // Save previous Client mode status when Bridged - $cfg['WifiAPEnable'] = ($bridgedEnable == 1 ? $arrHostapdConf['WifiAPEnable'] : $wifiAPEnable); - $cfg['BridgedEnable'] = $bridgedEnable; - $cfg['RepeaterEnable'] = $repeaterEnable; - $cfg['WifiManaged'] = $cli_iface; - write_php_ini($cfg, RASPI_CONFIG.'/hostapd.ini'); + $hostapd->persistHostapdIni($ap_iface, $logEnable, $bridgedEnable, $arrHostapdConf['WifiAPEnable'], $wifiAPEnable, $repeaterEnable, $cli_iface); + $_SESSION['ap_interface'] = $session_iface; // Verify input @@ -314,8 +307,6 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom $_POST['max_num_sta'] = $_POST['max_num_sta'] > 2007 ? 2007 : $_POST['max_num_sta']; $_POST['max_num_sta'] = $_POST['max_num_sta'] < 1 ? null : $_POST['max_num_sta']; - $hostapd = new HostapdManager(); - if ($good_input) { $config = $hostapd->buildConfig([ 'interface' => $_POST['interface'], @@ -345,59 +336,17 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom } // Parse dnsmasq config for selected interface - $dnsmasq = new DnsmasqManager(); try { $syscfg = $dnsmasq->getConfig($ap_iface ?? RASPI_WIFI_AP_INTERFACE); } catch (\RuntimeException $e) { error_log('Error: ' . $e->getMessage()); } - - if ($wifiAPEnable == 1) { - // Enable uap0 configuration for ap-sta mode - // Set dhcp-range from system config, fallback to default if undefined - $dhcp_range = ($syscfg['dhcp-range'] == '') ? getDefaultNetValue('dnsmasq','uap0','dhcp-range') : $syscfg['dhcp-range']; - $config = [ '# RaspAP uap0 configuration' ]; - $config[] = 'interface=lo,uap0 # Enable uap0 interface for wireless client AP mode'; - $config[] = 'bind-dynamic # Hybrid between --bind-interfaces and default'; - $config[] = 'server=8.8.8.8 # Forward DNS requests to Google DNS'; - $config[] = 'domain-needed # Don\'t forward short names'; - $config[] = 'bogus-priv # Never forward addresses in the non-routed address spaces'; - $config[] = 'dhcp-range='.$dhcp_range; - if (!empty($syscfg['dhcp-option'])) { - $config[] = 'dhcp-option='.$syscfg['dhcp-option']; - } - $config[] = PHP_EOL; - scanConfigDir('/etc/dnsmasq.d/','uap0',$status); - $config = join(PHP_EOL, $config); - file_put_contents("/tmp/dnsmasqdata", $config); - system('sudo cp /tmp/dnsmasqdata '.RASPI_DNSMASQ_PREFIX.$ap_iface.'.conf', $return); - } elseif ($bridgedEnable !==1) { - $dhcp_range = ($syscfg['dhcp-range'] =='') ? getDefaultNetValue('dnsmasq',$ap_iface,'dhcp-range') : $syscfg['dhcp-range']; - $config = [ '# RaspAP '.$_POST['interface'].' configuration' ]; - $config[] = 'interface='.$_POST['interface']; - $config[] = 'domain-needed'; - $config[] = 'dhcp-range='.$dhcp_range; - // handle multiple dhcp-host + option entries - if (!empty($syscfg['dhcp-host'])) { - if (is_array($syscfg['dhcp-host'])) { - foreach ($syscfg['dhcp-host'] as $host) { - $config[] = 'dhcp-host=' . $host; - } - } else { - $config[] = 'dhcp-host=' . $syscfg['dhcp-host']; - } - } - if (!empty($syscfg['dhcp-option'])) { - if (is_array($syscfg['dhcp-option'])) { - foreach ($syscfg['dhcp-option'] as $opt) { - $config[] = 'dhcp-option=' . $opt; - } - } else { - $config[] = 'dhcp-option=' . $syscfg['dhcp-option']; - } - } - $config[] = PHP_EOL; + // Build and save dsnmasq config + try { + $config = $dnsmasq->buildConfig($syscfg, $ap_iface, $wifiAPEnable, $bridgedEnable); $dnsmasq->saveConfig($config, $ap_iface); + } catch (\RuntimeException $e) { + error_log('Error: ' . $e->getMessage()); } // Set dhcp values from system config, fallback to default if undefined From 3ad5a987983e91da80c71127720130bfd292182a Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 00:24:01 -0700 Subject: [PATCH 07/44] Replace global wifi_functions with WiFiManager class --- includes/configure_client.php | 13 +- includes/dashboard.php | 18 +- includes/dhcp.php | 9 +- includes/openvpn.php | 5 +- includes/wifi_functions.php | 366 ---------------------------------- includes/wireguard.php | 6 +- 6 files changed, 33 insertions(+), 384 deletions(-) delete mode 100755 includes/wifi_functions.php diff --git a/includes/configure_client.php b/includes/configure_client.php index d390b8c5..097b9ac6 100755 --- a/includes/configure_client.php +++ b/includes/configure_client.php @@ -1,6 +1,6 @@ getWifiInterface(); + $wifi->knownWifiStations($networks); + $wifi->setKnownStationsWPA($networks); $iface = escapeshellarg($_SESSION['wifi_client_interface']); @@ -30,7 +31,7 @@ function DisplayWPAConfig() } elseif (isset($_POST['wpa_reinit'])) { $status->addMessage('Attempting to reinitialize wpa_supplicant', 'warning'); $force_remove = true; - $result = reinitializeWPA($force_remove); + $result = $wifi->reinitializeWPA($force_remove); } elseif (isset($_POST['client_settings'])) { $tmp_networks = $networks; if ($wpa_file = fopen('/tmp/wifidata', 'w')) { @@ -90,7 +91,7 @@ function DisplayWPAConfig() if (strlen($network['passphrase']) >=8 && strlen($network['passphrase']) <= 63) { unset($wpa_passphrase); unset($line); - exec('wpa_passphrase '. ssid2utf8( escapeshellarg($ssid) ) . ' ' . escapeshellarg($network['passphrase']), $wpa_passphrase); + exec('wpa_passphrase '. $wifi->ssid2utf8( escapeshellarg($ssid) ) . ' ' . escapeshellarg($network['passphrase']), $wpa_passphrase); foreach ($wpa_passphrase as $line) { if (preg_match('/^\s*}\s*$/', $line)) { if (array_key_exists('priority', $network)) { diff --git a/includes/dashboard.php b/includes/dashboard.php index f01917ca..303060f1 100755 --- a/includes/dashboard.php +++ b/includes/dashboard.php @@ -1,22 +1,28 @@ getWifiInterface(); $interface = $_SESSION['ap_interface'] ?? 'wlan0'; $clientInterface = $_SESSION['wifi_client_interface']; diff --git a/includes/dhcp.php b/includes/dhcp.php index fd6a47ac..4c35fac1 100755 --- a/includes/dhcp.php +++ b/includes/dhcp.php @@ -2,12 +2,18 @@ require_once 'config.php'; +use RaspAP\Networking\Hotspot\WiFiManager; +use RaspAP\Messages\StatusMessage; + /** * Manage DHCP configuration */ function DisplayDHCPConfig() { - $status = new \RaspAP\Messages\StatusMessage; + $status = new StatusMessage(); + $wifi = new WiFiManager(); + $wifi->getWifiInterface(); + if (!RASPI_MONITOR_ENABLED) { if (isset($_POST['savedhcpdsettings'])) { saveDHCPConfig($status); @@ -43,7 +49,6 @@ function DisplayDHCPConfig() } } } - getWifiInterface(); $ap_iface = $_SESSION['ap_interface']; $serviceStatus = $dnsmasq_state ? "up" : "down"; exec('cat '. RASPI_DNSMASQ_PREFIX.'raspap.conf', $return); diff --git a/includes/openvpn.php b/includes/openvpn.php index 2b59666b..d207f069 100755 --- a/includes/openvpn.php +++ b/includes/openvpn.php @@ -1,9 +1,10 @@ getWifiInterface(); /** * Manage OpenVPN configuration diff --git a/includes/wifi_functions.php b/includes/wifi_functions.php deleted file mode 100755 index c26d8b57..00000000 --- a/includes/wifi_functions.php +++ /dev/null @@ -1,366 +0,0 @@ - false, 'configured' => true, 'connected' => false, 'index' => null); - ++$index; - } elseif (isset($network) && $network !== null) { - if (preg_match('/^\s*}\s*$/', $line)) { - $networks[$ssid] = $network; - $network = null; - $ssid = null; - } elseif ($lineArr = preg_split('/\s*=\s*/', trim($line), 2)) { - switch (strtolower($lineArr[0])) { - case 'ssid': - $ssid = trim($lineArr[1], '"'); - $ssid = str_replace('P"','',$ssid); - $network['ssid'] = $ssid; - $index = getNetworkIdBySSID($ssid); - $network['index'] = $index; - break; - case 'psk': - $network['passkey'] = trim($lineArr[1]); - $network['protocol'] = 'WPA'; - break; - case '#psk': - $network['protocol'] = 'WPA'; - case 'wep_key0': // Untested - $network['passphrase'] = trim($lineArr[1], '"'); - break; - case 'key_mgmt': - if (! array_key_exists('passphrase', $network) && $lineArr[1] === 'NONE') { - $network['protocol'] = 'Open'; - } - break; - case 'priority': - $network['priority'] = trim($lineArr[1], '"'); - break; - } - } - } - } -} - -/** - * Scans for nearby WiFi networks using `iw` and updates the reference array - * - * @param array $networks Reference to the array of known and discovered networks. - * @param bool $cached If false, bypasses the cache and performs a fresh scan. - */ -function nearbyWifiStations(&$networks, $cached = true) -{ - $cacheTime = filemtime(RASPI_WPA_SUPPLICANT_CONFIG); - $cacheKey = "nearby_wifi_stations_$cacheTime"; - - if ($cached == false) { - deleteCache($cacheKey); - } - - $iface = escapeshellarg($_SESSION['wifi_client_interface']); - - $scan_results = cache( - $cacheKey, - function () use ($iface) { - $stdout = shell_exec("sudo iw dev $iface scan"); - return preg_split("/\n/", $stdout); - } - ); - - // exclude the AP from nearby networks - exec('sed -rn "s/ssid=(.*)\s*$/\1/p" ' . escapeshellarg(RASPI_HOSTAPD_CONFIG), $ap_ssid); - $ap_ssid = $ap_ssid[0] ?? ''; - - $index = 0; - if (!empty($networks)) { - $lastnet = end($networks); - if (isset($lastnet['index'])) { - $index = $lastnet['index'] + 1; - } - } - - $current = []; - $commitCurrent = function () use (&$current, &$networks, &$index, $ap_ssid) { - if (empty($current['ssid'])) { - return; - } - - $ssid = $current['ssid']; - - // unprintable 7bit ASCII control codes, delete or quotes -> ignore network - if ($ssid === $ap_ssid || preg_match('/[\x00-\x1f\x7f\'`\´"]/', $ssid)) { - return; - } - - $channel = ConvertToChannel($current['freq'] ?? 0); - $rssi = $current['signal'] ?? -100; - - // if network is saved - if (array_key_exists($ssid, $networks)) { - $networks[$ssid]['visible'] = true; - $networks[$ssid]['channel'] = $channel; - if (!isset($networks[$ssid]['RSSI']) || $networks[$ssid]['RSSI'] < $rssi) { - $networks[$ssid]['RSSI'] = $rssi; - } - } else { - $networks[$ssid] = [ - 'ssid' => $ssid, - 'configured' => false, - 'protocol' => $current['security'] ?? 'OPEN', - 'channel' => $channel, - 'passphrase' => '', - 'visible' => true, - 'connected' => false, - 'RSSI' => $rssi, - 'index' => $index - ]; - ++$index; - } - }; - - foreach ($scan_results as $line) { - $line = trim($line); - - if (preg_match('/^BSS\s+([0-9a-f:]{17})/', $line, $match)) { - $commitCurrent(); // commit previous - $current = [ - 'bssid' => $match[1], - 'ssid' => '', - 'signal' => null, - 'freq' => null, - 'security' => 'OPEN' - ]; - continue; - } - if (preg_match('/^SSID:\s*(.*)$/', $line, $match)) { - $current['ssid'] = $match[1]; - continue; - } - if (preg_match('/^signal:\s*(-?\d+\.\d+)/', $line, $match)) { - $current['signal'] = (float)$match[1]; - continue; - } - if (preg_match('/^freq:\s*(\d+)/', $line, $match)) { - $current['freq'] = (int)$match[1]; - continue; - } - if (preg_match('/^RSN:/', $line) || preg_match('/^WPA:/', $line)) { - $current['security'] = 'WPA/WPA2'; - continue; - } - } - $commitCurrent(); -} - -function connectedWifiStations(&$networks) -{ - exec('iwconfig ' .$_SESSION['wifi_client_interface'], $iwconfig_return); - foreach ($iwconfig_return as $line) { - if (preg_match('/ESSID:\"([^"]+)\"/i', $line, $iwconfig_ssid)) { - $networks[hexSequence2lower($iwconfig_ssid[1])]['connected'] = true; - } - } -} - -function sortNetworksByRSSI(&$networks) -{ - $valRSSI = array(); - foreach ($networks as $SSID => $net) { - if (!array_key_exists('RSSI', $net)) { - $net['RSSI'] = -1000; - } - $valRSSI[$SSID] = $net['RSSI']; - } - $nets = $networks; - arsort($valRSSI); - $networks = array(); - foreach ($valRSSI as $SSID => $RSSI) { - $networks[$SSID] = $nets[$SSID]; - $networks[$SSID]['RSSI'] = $RSSI; - } -} - -/* - * Determines the configured wireless AP interface - * - * If not saved in /etc/raspap/hostapd.ini, check for a second - * wireless interface with iw dev. Fallback to the constant - * value defined in config.php - */ -function getWifiInterface() -{ - $hostapdIni = RASPI_CONFIG . '/hostapd.ini'; - $arrHostapdConf = file_exists($hostapdIni) ? parse_ini_file($hostapdIni) : []; - - $iface = $_SESSION['ap_interface'] = $arrHostapdConf['WifiInterface'] ?? RASPI_WIFI_AP_INTERFACE; - - if (!validateInterface($iface)) { - $iface = RASPI_WIFI_AP_INTERFACE; - } - - // check for 2nd wifi interface -> wifi client on different interface - exec("iw dev | awk '$1==\"Interface\" && $2!=\"$iface\" {print $2}'", $iface2); - $client_iface = $_SESSION['wifi_client_interface'] = empty($iface2) ? $iface : trim($iface2[0]); - - // handle special case for RPi Zero W in AP-STA mode - if ($client_iface === "uap0" && ($arrHostapdConf['WifiAPEnable'] ?? 0)) { - $_SESSION['wifi_client_interface'] = $iface; - $_SESSION['ap_interface'] = $client_iface; - } -} - -/* - * Reinitializes wpa_supplicant for the wireless client interface - * The 'force' parameter deletes the socket in /var/run/wpa_supplicant/ - * - * @param boolean $force - */ -function reinitializeWPA($force) -{ - $iface = escapeshellarg($_SESSION['wifi_client_interface']); - if ($force == true) { - $cmd = "sudo /bin/rm /var/run/wpa_supplicant/$iface"; - $result = shell_exec($cmd); - } - $cmd = "sudo wpa_supplicant -B -Dnl80211 -c/etc/wpa_supplicant/wpa_supplicant.conf -i$iface"; - $result = shell_exec($cmd); - sleep(1); - return $result; -} - -/* - * Replace escaped bytes (hex) by binary - assume UTF8 encoding - * - * @param string $ssid - */ -function ssid2utf8($ssid) -{ - return evalHexSequence($ssid); -} - -/* - * Returns a signal strength indicator based on RSSI value - * - * @param string $rssi - */ -function getSignalBars($rssi) -{ - // assign css class based on RSSI value - if ($rssi >= MAX_RSSI) { - $class = 'strong'; - } elseif ($rssi >= -56) { - $class = 'medium'; - } elseif ($rssi >= -67) { - $class = 'weak'; - } elseif ($rssi >= -89) { - $class = ''; - } - - // calculate percent strength - if ($rssi >= -50) { - $pct = 100; - } elseif ($rssi <= MIN_RSSI) { - $pct = 0; - } else { - $pct = 2*($rssi + 100); - } - $elem = '
'.PHP_EOL; - for ($n = 0; $n < 3; $n++ ) { - $elem .= '
'.PHP_EOL; - } - $elem .= '
'.PHP_EOL; - return $elem; -} - -/* - * Parses output of wpa_cli list_networks, compares with known networks - * from wpa_supplicant, and adds with wpa_cli if not found - * - * @param array $networks - */ -function setKnownStationsWPA($networks) -{ - $iface = escapeshellarg($_SESSION['wifi_client_interface']); - $output = shell_exec("sudo wpa_cli -i $iface list_networks"); - $lines = explode("\n", $output); - array_shift($lines); - $wpaCliNetworks = []; - - foreach ($lines as $line) { - $data = explode("\t", trim($line)); - if (!empty($data) && count($data) >= 2) { - $id = $data[0]; - $ssid = $data[1]; - $item = [ - 'id' => $id, - 'ssid' => $ssid - ]; - $wpaCliNetworks[] = $item; - } - } - foreach ($networks as $network) { - $ssid = $network['ssid']; - if (!networkExists($ssid, $wpaCliNetworks)) { - $ssid = escapeshellarg('"'.$network['ssid'].'"'); - $psk = escapeshellarg('"'.$network['passphrase'].'"'); - $protocol = $network['protocol']; - $netid = trim(shell_exec("sudo wpa_cli -i $iface add_network")); - if (isset($netid) && !isset($known[$netid])) { - $commands = [ - "sudo wpa_cli -i $iface set_network $netid ssid $ssid", - "sudo wpa_cli -i $iface set_network $netid psk $psk", - "sudo wpa_cli -i $iface enable_network $netid" - ]; - if ($protocol === 'Open') { - $commands[1] = "sudo wpa_cli -i $iface set_network $netid key_mgmt NONE"; - } - foreach ($commands as $cmd) { - exec($cmd); - usleep(1000); - } - } - } - } -} - -/* - * Parses wpa_cli list_networks output and returns the id - * of a corresponding network SSID - * - * @param string $ssid - * @return integer id - */ -function getNetworkIdBySSID($ssid) { - $iface = escapeshellarg($_SESSION['wifi_client_interface']); - $cmd = "sudo wpa_cli -i $iface list_networks"; - $output = []; - exec($cmd, $output); - array_shift($output); - foreach ($output as $line) { - $columns = preg_split('/\t/', $line); - if (count($columns) >= 4 && trim($columns[1]) === trim($ssid)) { - return $columns[0]; // return network ID - } - } - return null; -} - -function networkExists($ssid, $collection) -{ - foreach ($collection as $network) { - if ($network['ssid'] === $ssid) { - return true; - } - } - return false; -} - diff --git a/includes/wireguard.php b/includes/wireguard.php index ea2b1fe9..af6dbe28 100755 --- a/includes/wireguard.php +++ b/includes/wireguard.php @@ -1,9 +1,11 @@ getWifiInterface(); /** * Displays wireguard server & peer configuration From f1ced918115e1125ab0a491e146358218cfb4615 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 00:24:41 -0700 Subject: [PATCH 08/44] Update w/ WiFiManager class method calls --- ajax/networking/wifi_stations.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/ajax/networking/wifi_stations.php b/ajax/networking/wifi_stations.php index b6298046..d61974ab 100644 --- a/ajax/networking/wifi_stations.php +++ b/ajax/networking/wifi_stations.php @@ -6,17 +6,21 @@ require_once '../../includes/config.php'; require_once '../../includes/authenticate.php'; require_once '../../includes/defaults.php'; require_once '../../includes/functions.php'; -require_once '../../includes/wifi_functions.php'; + +use RaspAP\Networking\Hotspot\WiFiManager; + +$wifi = new WiFiManager(); $networks = []; $network = null; $ssid = null; -knownWifiStations($networks); -nearbyWifiStations($networks, !isset($_REQUEST["refresh"])); -connectedWifiStations($networks); -sortNetworksByRSSI($networks); -foreach ($networks as $ssid => $network) $networks[$ssid]["ssidutf8"] = ssid2utf8( $ssid ); +$wifi->knownWifiStations($networks); +$wifi->nearbyWifiStations($networks, !isset($_REQUEST["refresh"])); +$wifi->connectedWifiStations($networks); +$wifi->sortNetworksByRSSI($networks); + +foreach ($networks as $ssid => $network) $networks[$ssid]["ssidutf8"] = $wifi->ssid2utf8( $ssid ); $connected = array_filter($networks, function($n) { return $n['connected']; } ); $known = array_filter($networks, function($n) { return !$n['connected'] && $n['configured']; } ); From a91e44107331cd54975f6d8b4e0dcbb6cac3bb63 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 00:25:18 -0700 Subject: [PATCH 09/44] Implement persistHostapdIni() --- .../Networking/Hotspot/HostapdManager.php | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/HostapdManager.php b/src/RaspAP/Networking/Hotspot/HostapdManager.php index 1819d5df..0e114108 100644 --- a/src/RaspAP/Networking/Hotspot/HostapdManager.php +++ b/src/RaspAP/Networking/Hotspot/HostapdManager.php @@ -76,6 +76,7 @@ class HostapdManager $selectedHwMode = 'w'; } } + $config['selected_hw_mode'] = $selectedHwMode; $config['ignore_broadcast_ssid'] ??= 0; $config['max_num_sta'] ??= 0; $config['wep_default_key'] ??= 0; @@ -199,8 +200,7 @@ class HostapdManager public function saveConfig(string $config, bool $dualMode, string $iface, bool $restart = false): bool { $configFile = $this->resolveConfigPath($iface, $dualMode); - //$configFile = self::CONF_DEFAULT; - $tempFile = self::CONF_TMP; + $tempFile = SELF::CONF_TMP; if (file_put_contents($tempFile, $config) === false) { @@ -310,6 +310,34 @@ class HostapdManager throw new \RuntimeException("Failed to restart hostapd (tried instance + fallback)."); } + /** + * Persists options to /etc/raspap/ + * + * @param string $apIface + * @param bool $logEnable + * @param bool $bridgedEnable + * @param bool $cfgWifiAPEnable + * @param bool $wifiAPEnable + * @param bool $repeaterEnable + * @param string $cliIface + * @return bool + */ + public function persistHostapdIni($apIface, $logEnable, $bridgedEnable, $cfgWifiAPEnable, $wifiAPEnable, $repeaterEnable, $cliIface): bool + { + $cfg = []; + $cfg['WifiInterface'] = $apIface; + $cfg['LogEnable'] = $logEnable; + $cfg['WifiAPEnable'] = ($bridgedEnable == 1 ? $cfgWifiAPEnable : $wifiAPEnable); + $cfg['BridgedEnable'] = $bridgedEnable; + $cfg['RepeaterEnable'] = $repeaterEnable; + $cfg['WifiManaged'] = $cliIface; + $success = write_php_ini($cfg, RASPI_CONFIG.'/hostapd.ini'); + if (!$success) { + throw new \RuntimeException("Unable to write to hostapd.ini"); + } + return true; + } + } From da6f469982ac58f271d21b670ee3ee551a88b897 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 00:25:46 -0700 Subject: [PATCH 10/44] Implement buildConfig() --- .../Networking/Hotspot/DnsmasqManager.php | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php index 46f121a1..16a6ce41 100644 --- a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php +++ b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php @@ -41,6 +41,63 @@ class DnsmasqManager return $config; } + /** + * Builds a dnsmasq configuration + * @param array $syscfg + * @param string $iface + * @param bool $wifiAPEnable + * @param bool $bridgedEnable + * @return array $config + * @throws \RuntimeException + */ + public function buildConfig(array $syscfg, string $iface, bool $wifiAPEnable, bool $bridgedEnable): array + { + if ($wifiAPEnable == 1) { + // Enable uap0 configuration for ap-sta mode + // Set dhcp-range from system config, fallback to default if undefined + $dhcp_range = ($syscfg['dhcp-range'] == '') ? getDefaultNetValue('dnsmasq','uap0','dhcp-range') : $syscfg['dhcp-range']; + $config = [ '# RaspAP uap0 configuration' ]; + $config[] = 'interface=lo,uap0 # Enable uap0 interface for wireless client AP mode'; + $config[] = 'bind-dynamic # Hybrid between --bind-interfaces and default'; + $config[] = 'server=8.8.8.8 # Forward DNS requests to Google DNS'; + $config[] = 'domain-needed # Don\'t forward short names'; + $config[] = 'bogus-priv # Never forward addresses in the non-routed address spaces'; + $config[] = 'dhcp-range='.$dhcp_range; + if (!empty($syscfg['dhcp-option'])) { + $config[] = 'dhcp-option='.$syscfg['dhcp-option']; + } + $config[] = PHP_EOL; + scanConfigDir('/etc/dnsmasq.d/','uap0',$status); + } elseif ($bridgedEnable !==1) { + $dhcp_range = ($syscfg['dhcp-range'] =='') ? getDefaultNetValue('dnsmasq',$iface,'dhcp-range') : $syscfg['dhcp-range']; + $config = [ '# RaspAP '.$_POST['interface'].' configuration' ]; + $config[] = 'interface='.$_POST['interface']; + $config[] = 'domain-needed'; + $config[] = 'dhcp-range='.$dhcp_range; + // handle multiple dhcp-host + option entries + if (!empty($syscfg['dhcp-host'])) { + if (is_array($syscfg['dhcp-host'])) { + foreach ($syscfg['dhcp-host'] as $host) { + $config[] = 'dhcp-host=' . $host; + } + } else { + $config[] = 'dhcp-host=' . $syscfg['dhcp-host']; + } + } + if (!empty($syscfg['dhcp-option'])) { + if (is_array($syscfg['dhcp-option'])) { + foreach ($syscfg['dhcp-option'] as $opt) { + $config[] = 'dhcp-option=' . $opt; + } + } else { + $config[] = 'dhcp-option=' . $syscfg['dhcp-option']; + } + } + $config[] = PHP_EOL; + } + return $config; + } + /** * Saves dnsmasq configuration for an interface * From dd3b300931d28329216dfd19a4b3ed11feeb9a12 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 00:26:53 -0700 Subject: [PATCH 11/44] Instantiate WiFiManager, update w/ $wifi->getSignalBars() --- templates/wifi_stations/network.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/templates/wifi_stations/network.php b/templates/wifi_stations/network.php index 98df7c8c..26685647 100644 --- a/templates/wifi_stations/network.php +++ b/templates/wifi_stations/network.php @@ -1,3 +1,9 @@ +
@@ -31,7 +37,7 @@
= -200) { echo '
'; - echo getSignalBars($network['RSSI']); + echo $wifi->getSignalBars($network['RSSI']); echo '
' .htmlspecialchars($network['RSSI'], ENT_QUOTES) . "dB" . "
"; echo '
'; } else { From fa38ac615344608a725a3b9fae0460e4bac8dbd7 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 03:16:47 -0700 Subject: [PATCH 12/44] Initial commit --- .../Hotspot/Validators/HostapdValidator.php | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/RaspAP/Networking/Hotspot/Validators/HostapdValidator.php diff --git a/src/RaspAP/Networking/Hotspot/Validators/HostapdValidator.php b/src/RaspAP/Networking/Hotspot/Validators/HostapdValidator.php new file mode 100644 index 00000000..e391fc49 --- /dev/null +++ b/src/RaspAP/Networking/Hotspot/Validators/HostapdValidator.php @@ -0,0 +1,141 @@ + + * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE + */ +class HostapdValidator +{ + + /** + * Validates full hostapd parameter set + * + * @param array $post raw $_POST object + * @param array $wpaArray allowed WPA values + * @param array $encTypes allowed encryption types + * @param array $modes allowed hardware modes + * @param array $interfaces valid interface list + * @param string $regDomain regulatory domain + * @param StatusMessage $status Status message collector + * @return array|false validated configuration array or false on failure + */ + public function validate( + array $post, + array $wpaArray, + array $encTypes, + array $modes, + array $interfaces, + string $regDomain, + ?StatusMessage $status = null + ) { + $goodInput = true; + + // check WPA and encryption + if ( + !array_key_exists($post['wpa'], $wpaArray) || + !array_key_exists($post['wpa_pairwise'], $encTypes) || + !array_key_exists($post['hw_mode'], $modes) + ) { + $err = "Invalid WPA or encryption settings: " + . "wpa='{$post['wpa']}', " + . "wpa_pairwise='{$post['wpa_pairwise']}', " + . "hw_mode='{$post['hw_mode']}'"; + error_log($err); + return false; + } + + // validate channel + if (!filter_var($post['channel'], FILTER_VALIDATE_INT)) { + $status->addMessage('Attempting to set channel to invalid number.', 'danger'); + $goodInput = false; + } + if ((int)$post['channel'] < 1 || (int)$post['channel'] > RASPI_5GHZ_CHANNEL_MAX) { + $status->addMessage('Attempting to set channel outside of permitted range', 'danger'); + $goodInput = false; + } + + // validate SSID + if (empty($post['ssid']) || strlen($post['ssid']) > 32) { + $status->addMessage('SSID must be between 1 and 32 characters', 'danger'); + $goodInput = false; + } + + // validate WPA passphrase + if ($post['wpa'] !== 'none') { + if (strlen($post['wpa_passphrase']) < 8 || strlen($post['wpa_passphrase']) > 63) { + $status->addMessage('WPA passphrase must be between 8 and 63 characters', 'danger'); + $goodInput = false; + } elseif (!ctype_print($post['wpa_passphrase'])) { + $status->addMessage('WPA passphrase must be comprised of printable ASCII characters', 'danger'); + $goodInput = false; + } + } + + // hidden SSID + $ignoreBroadcastSSID = $post['hiddenSSID'] ?? '0'; + if (!ctype_digit($ignoreBroadcastSSID) || (int)$ignoreBroadcastSSID < 0 || (int)$ignoreBroadcastSSID >= 3) { + $status->addMessage('Invalid hiddenSSID parameter.', 'danger'); + $goodInput = false; + } + + // validate interface + if (!in_array($post['interface'], $interfaces, true)) { + $status->addMessage('Unknown interface '.htmlspecialchars($post['interface'], ENT_QUOTES), 'danger'); + $goodInput = false; + } + + // country code + $countryCode = $post['country_code']; + if (strlen($countryCode) !== 0 && strlen($countryCode) !== 2) { + $status->addMessage('Country code must be blank or two characters', 'danger'); + $goodInput = false; + } + + // beacon Interval + if (!empty($post['beaconintervalEnable'])) { + if (!is_numeric($post['beacon_interval'])) { + $status->addMessage('Beacon interval must be numeric', 'danger'); + $goodInput = false; + } elseif ($post['beacon_interval'] < 15 || $post['beacon_interval'] > 65535) { + $status->addMessage('Beacon interval must be between 15 and 65535', 'danger'); + $goodInput = false; + } + } + + // max number of clients + $post['max_num_sta'] = (int) ($post['max_num_sta'] ?? 0); + $post['max_num_sta'] = $post['max_num_sta'] > 2007 ? 2007 : $post['max_num_sta']; + $post['max_num_sta'] = $post['max_num_sta'] < 1 ? null : $post['max_num_sta']; + + if (!$goodInput) { + return false; + } + + // return normalized config array + return [ + 'interface' => $post['interface'], + 'ssid' => $post['ssid'], + 'channel' => (int)$post['channel'], + 'wpa' => $post['wpa'], + '80211w' => $post['80211w'] ?? 0, + 'wpa_passphrase' => $post['wpa_passphrase'], + 'wpa_pairwise' => $post['wpa_pairwise'], + 'hw_mode' => $post['hw_mode'], + 'country_code' => $countryCode, + 'hiddenSSID' => (int)$ignoreBroadcastSSID, + 'max_num_sta' => $post['max_num_sta'], + 'beacon_interval' => $post['beacon_interval'] ?? null, + 'disassoc_low_ack' => $post['disassoc_low_ackEnable'] ?? null, + 'bridge' => $post['bridgedEnable'] ? 'br0' : null, + ]; + } + +} + From 9d03517896e9f247827dfa89f58dd111eeb59745 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 03:17:17 -0700 Subject: [PATCH 13/44] Add getIfaceMetric public method --- .../Networking/Hotspot/DhcpcdManager.php | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/RaspAP/Networking/Hotspot/DhcpcdManager.php diff --git a/src/RaspAP/Networking/Hotspot/DhcpcdManager.php b/src/RaspAP/Networking/Hotspot/DhcpcdManager.php new file mode 100644 index 00000000..6cff77d2 --- /dev/null +++ b/src/RaspAP/Networking/Hotspot/DhcpcdManager.php @@ -0,0 +1,66 @@ + Date: Sat, 19 Jul 2025 03:17:49 -0700 Subject: [PATCH 14/44] Minor: comment block Minor: comment block --- src/RaspAP/Networking/Hotspot/DnsmasqManager.php | 7 ++++++- src/RaspAP/Networking/Hotspot/WiFiManager.php | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php index 16a6ce41..94a45803 100644 --- a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php +++ b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php @@ -3,8 +3,13 @@ namespace RaspAP\Networking\Hotspot; /** - * Manages dnsmasq configuration for DHCP/DNS services + * A dnsmasq configuration manager for RaspAP + * + * @description Class methods to get, build and save dnsmasq configs + * @author Bill Zimmerman + * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE */ + class DnsmasqManager { private const CONF_SUFFIX = '.conf'; diff --git a/src/RaspAP/Networking/Hotspot/WiFiManager.php b/src/RaspAP/Networking/Hotspot/WiFiManager.php index e7002a7a..4e2e4a9f 100644 --- a/src/RaspAP/Networking/Hotspot/WiFiManager.php +++ b/src/RaspAP/Networking/Hotspot/WiFiManager.php @@ -4,7 +4,7 @@ namespace RaspAP\Networking\Hotspot; /** * Wireless utility class - * @description A collection of wireless utlity methods for RaspAP + * @description A collection of wireless utility methods for RaspAP * @author Bill Zimmerman * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE */ From b9642371e022f204f13a8000f0a13f499ffc7d2c Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 03:20:58 -0700 Subject: [PATCH 15/44] Use HostapdValidator, add deriveInterfaces, deriveModeStates, iwRegSet methods --- .../Networking/Hotspot/HostapdManager.php | 194 +++++++++++++++--- .../Networking/Hotspot/HotspotService.php | 86 ++++++++ 2 files changed, 255 insertions(+), 25 deletions(-) create mode 100644 src/RaspAP/Networking/Hotspot/HotspotService.php diff --git a/src/RaspAP/Networking/Hotspot/HostapdManager.php b/src/RaspAP/Networking/Hotspot/HostapdManager.php index 0e114108..d1e03058 100644 --- a/src/RaspAP/Networking/Hotspot/HostapdManager.php +++ b/src/RaspAP/Networking/Hotspot/HostapdManager.php @@ -2,8 +2,15 @@ namespace RaspAP\Networking\Hotspot; +use RaspAP\Networking\Hotspot\Validators\HostapdValidator; +use RaspAP\Messages\StatusMessage; + /** - * Manages hostapd configurations and runtime settings + * Hostapd manager class for RaspAP + * + * @description Manages hostapd configurations and runtime settings + * @author Bill Zimmerman + * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE */ class HostapdManager { @@ -11,10 +18,19 @@ class HostapdManager private const CONF_PATH_PREFIX = '/etc/hostapd/hostapd-'; private const CONF_TMP = '/tmp/hostapddata'; + /** @var HostapdValidator */ + private $validator; + + public function __construct(?HostapdValidator $validator = null) + { + $this->validator = $validator ?: new HostapdValidator(); + } + /** * Retrieves current hostapd config * * @return array + * @throws \RuntimeException */ public function getConfig(): array { @@ -85,6 +101,30 @@ class HostapdManager } + /** + * Validates a hostapd configuration + * + * @param array $post raw $_POST object + * @param array $wpaArray allowed WPA values + * @param array $encTypes allowed encryption types + * @param array $modes allowed hardware modes + * @param array $interfaces valid interface list + * @param string $regDomain regulatory domain + * @param StatusMessage $status Status message collector + * @return array|false validated configuration array or false on failure + */ + public function validate( + array $post, + array $wpaArray, + array $encTypes, + array $modes, + array $interfaces, + string $regDomain, + StatusMessage $status + ) { + return $this->validator->validate($post, $wpaArray, $encTypes, $modes, $interfaces, $regDomain, $status); + } + /** * Builds hostapd configuration text from array * @@ -182,7 +222,7 @@ class HostapdManager } // Optional additional user config - $config[] = parseUserHostapdCfg(); + $config[] = $this->parseUserHostapdCfg(); return implode(PHP_EOL, $config) . PHP_EOL; } @@ -219,6 +259,81 @@ class HostapdManager return true; } + /** + * Derives mode checkbox states from POST + existing ini + * + * @param array $post raw $_POST + * @param array $currentIni parsed hostapd.ini + * @return array normalized states + */ + public function deriveModeStates(array $post, array $currentIni): array + { + $prevWifiAPEnable = (int)($currentIni['WifiAPEnable'] ?? 0); + $bridgedEnable = isset($post['bridgedEnable']) ? 1 : 0; + $repeaterEnable = 0; + $wifiAPEnable = 0; + + if ($bridgedEnable === 0) { + // Only meaningful when not bridged + $repeaterEnable = isset($post['repeaterEnable']) ? 1 : 0; + $wifiAPEnable = isset($post['wifiAPEnable']) ? 1 : 0; + } + + $logEnable = isset($post['logEnable']) ? 1 : 0; + + $effectiveWifiAPEnable = $bridgedEnable === 1 ? $prevWifiAPEnable : $wifiAPEnable; + + return [ + 'BridgedEnable' => $bridgedEnable, + 'RepeaterEnable' => $repeaterEnable, + 'WifiAPEnable' => $effectiveWifiAPEnable, + 'LogEnable' => $logEnable, + '_rawPostedWifiAPEnable' => $wifiAPEnable + ]; + } + + /** + * Determine AP interface, client (managed) interface and session/monitor interface + * Uses these semantics: + * - Base interface = user selection (validated) or RASPI_WIFI_AP_INTERFACE + * - AP-STA mode (WifiAPEnable=1): AP is 'uap0', client is base iface + * - Bridged mode: client/session use 'br0', AP remains base iface + * + * @param string $baseIface Selected interface from form + * @param array $states Output from deriveModeStates() + * @return array [ap_iface, cli_iface, session_iface] + */ + public function deriveInterfaces(string $baseIface, array $states): array + { + $apIface = $baseIface; + $cliIface = $baseIface; + $sessionIface = $baseIface; + + if ($states['WifiAPEnable'] === 1 && $states['BridgedEnable'] === 0) { + // client AP (AP-STA) – uap0 is AP, base iface remains client + $apIface = 'uap0'; + $sessionIface = 'uap0'; + $cliIface = $baseIface; + } elseif ($states['BridgedEnable'] === 1) { + // bridged mode – monitor br0, AP stays as base wireless iface + $cliIface = 'br0'; + $sessionIface = 'br0'; + } + + return [$apIface, $cliIface, $sessionIface]; + } + + /** + * Enables or disables hostapd logging + * + * @param int $logEnable + */ + private function handleLogState(int $logEnable): void + { + $script = $logEnable === 1 ? 'enablelog.sh' : 'disablelog.sh'; + exec('sudo ' . RASPI_CONFIG . '/hostapd/' . $script); + } + /** * Sets transmit power for an interface * @@ -247,10 +362,10 @@ class HostapdManager * * @return string $tmp */ - function parseUserHostapdCfg() + private function parseUserHostapdCfg() { - if (file_exists(CONF_DEFAULT . '.users')) { - exec('cat '. CONF_DEFAULT . '.users', $hostapdconfigusers); + if (file_exists(SELF::CONF_DEFAULT . '.users')) { + exec('cat '. SELF::CONF_DEFAULT . '.users', $hostapdconfigusers); foreach ($hostapdconfigusers as $hostapdconfigusersline) { if (strlen($hostapdconfigusersline) === 0) { continue; @@ -311,33 +426,62 @@ class HostapdManager } /** - * Persists options to /etc/raspap/ + * Persist hostapd.ini with mode / interface user settings * - * @param string $apIface - * @param bool $logEnable - * @param bool $bridgedEnable - * @param bool $cfgWifiAPEnable - * @param bool $wifiAPEnable - * @param bool $repeaterEnable - * @param string $cliIface + * @param array $states states from deriveModeStates() + * @param string $apIface the AP interface + * @param string $cliIface the managed interface + * @param array $previousIni existing ini * @return bool */ - public function persistHostapdIni($apIface, $logEnable, $bridgedEnable, $cfgWifiAPEnable, $wifiAPEnable, $repeaterEnable, $cliIface): bool + public function persistHostapdIni(array $states, string $apIface, string $cliIface, array $previousIni = []): bool { - $cfg = []; - $cfg['WifiInterface'] = $apIface; - $cfg['LogEnable'] = $logEnable; - $cfg['WifiAPEnable'] = ($bridgedEnable == 1 ? $cfgWifiAPEnable : $wifiAPEnable); - $cfg['BridgedEnable'] = $bridgedEnable; - $cfg['RepeaterEnable'] = $repeaterEnable; - $cfg['WifiManaged'] = $cliIface; - $success = write_php_ini($cfg, RASPI_CONFIG.'/hostapd.ini'); - if (!$success) { - throw new \RuntimeException("Unable to write to hostapd.ini"); + $this->applyLogState($states['LogEnable']); + + // compose new ini payload + $cfg = [ + 'WifiInterface' => $apIface, + 'LogEnable' => $states['LogEnable'], + 'WifiAPEnable' => $states['WifiAPEnable'], + 'BridgedEnable' => $states['BridgedEnable'], + 'RepeaterEnable' => $states['RepeaterEnable'], + 'WifiManaged' => $cliIface + ]; + foreach ($previousIni as $k => $v) { + if (!array_key_exists($k, $cfg)) { + $cfg[$k] = $v; + } } - return true; + return write_php_ini($cfg, RASPI_CONFIG . '/hostapd.ini'); } + /** + * Enables or disables hostapd logging + * + * @param int $logEnable 1 = enable, 0 = disable + */ + private function applyLogState(int $logEnable): void + { + $script = $logEnable === 1 ? 'enablelog.sh' : 'disablelog.sh'; + exec('sudo ' . RASPI_CONFIG . '/hostapd/' . $script); + } + + /** + * Executes iw to set the specified ISO 2-letter country code + * + * @param string $country_code + * @param object $status + * @return boolean $result + */ + public function iwRegSet(string $country_code, $status): bool + { + $country_code = escapeshellarg($country_code); + $result = shell_exec("sudo iw reg set $country_code"); + $status->addMessage(sprintf(_('Setting wireless regulatory domain to %s'), $country_code, 'success')); + return $result; + } + + } diff --git a/src/RaspAP/Networking/Hotspot/HotspotService.php b/src/RaspAP/Networking/Hotspot/HotspotService.php new file mode 100644 index 00000000..cc331f2e --- /dev/null +++ b/src/RaspAP/Networking/Hotspot/HotspotService.php @@ -0,0 +1,86 @@ +hostapdManager = new HostapdManager(); + $this->dnsmasqManager = new DnsmasqManager(); + $this->dhcpcdManager = new DhcpcdManager(); + } + + /** + * Apply configuration changes for hotspot. + * + * @param array $params + * @return bool + */ + public function configureHotspot(array $params): bool + { + // TODO: validate params, orchestrate managers + return false; + } + + /** + * Start hotspot services for given interface. + * + * @param string $iface + * @return bool + */ + public function start(string $iface): bool + { + // TODO: implement systemctl or service logic + return false; + } + + /** + * Stop hotspot services. + * + * @return bool + */ + public function stop(): bool + { + // TODO: implement + return false; + } + + /** + * Restart hotspot services for given interface. + * + * @param string $iface + * @return bool + */ + public function restart(string $iface): bool + { + // TODO: implement + return false; + } + + /** + * Get current hotspot status. + * + * @return array + */ + public function getStatus(): array + { + // TODO: query service state + configs + return []; + } +} + From 3b352b12d8b2f9046aa97462ba3011fb7b55327d Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 03:21:50 -0700 Subject: [PATCH 16/44] WIP: migrate functions to class methods, refactor saveHostAPDConfig() --- includes/hostapd.php | 259 +++++++------------------------------------ 1 file changed, 40 insertions(+), 219 deletions(-) diff --git a/includes/hostapd.php b/includes/hostapd.php index 3b2e3e21..0d03f0ba 100755 --- a/includes/hostapd.php +++ b/includes/hostapd.php @@ -4,6 +4,8 @@ use RaspAP\Networking\Hotspot\DnsmasqManager; use RaspAP\Networking\Hotspot\HostapdManager; use RaspAP\Networking\Hotspot\DhcpcdManager; use RaspAP\Networking\Hotspot\WiFiManager; +use RaspAP\Messages\StatusMessage; +use RaspAP\System\Sysinfo; $wifi = new WiFiManager(); $wifi->getWifiInterface(); @@ -14,9 +16,10 @@ $wifi->getWifiInterface(); */ function DisplayHostAPDConfig() { - $status = new \RaspAP\Messages\StatusMessage; - $system = new \RaspAP\System\Sysinfo; + $status = new StatusMessage(); + $system = new Sysinfo(); $operatingSystem = $system->operatingSystem(); + $arrConfig = array(); $arr80211Standard = [ 'a' => '802.11a - 5 GHz', @@ -50,7 +53,7 @@ function DisplayHostAPDConfig() if (!RASPI_MONITOR_ENABLED) { if (isset($_POST['SaveHostAPDSettings'])) { - SaveHostAPDConfig($arrSecurity, $arrEncType, $arr80211Standard, $interfaces, $reg_domain, $status); + saveHostAPDConfig($arrSecurity, $arrEncType, $arr80211Standard, $interfaces, $reg_domain, $status); } } @@ -157,182 +160,56 @@ function DisplayHostAPDConfig() * @param object $status * @return boolean */ -function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_domain, $status) +function saveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_domain, $status) { $hostapd = new HostapdManager(); $dnsmasq = new DnsmasqManager(); - - // If wpa fields are absent, return false and log securely - if (!(array_key_exists($_POST['wpa'], $wpa_array) - && array_key_exists($_POST['wpa_pairwise'], $enc_types) - && array_key_exists($_POST['hw_mode'], $modes)) - ) { - $err = "Attempting to set hostapd config with wpa='".escapeshellarg($_POST['wpa']); - $err .= "', wpa_pairwise='".$escapeshellarg(_POST['wpa_pairwise']); - $err .= "and hw_mode='".$escapeshellarg(_POST['hw_mode'])."'"; - error_log($err); - return false; - } - // Validate input - $good_input = true; - - if (!filter_var($_POST['channel'], FILTER_VALIDATE_INT)) { - $status->addMessage('Attempting to set channel to invalid number.', 'danger'); - $good_input = false; - } - if (intval($_POST['channel']) < 1 || intval($_POST['channel']) > RASPI_5GHZ_CHANNEL_MAX) { - $status->addMessage('Attempting to set channel outside of permitted range', 'danger'); - $good_input = false; - } - $arrHostapdConf = parse_ini_file('/etc/raspap/hostapd.ini'); - + $dhcpcd = new DhcpcdManager(); $dualAPEnable = false; - // Check for Bridged AP mode checkbox - $bridgedEnable = 0; - if ($arrHostapdConf['BridgedEnable'] == 0) { - if (isset($_POST['bridgedEnable'])) { - $bridgedEnable = 1; - } - } else { - if (isset($_POST['bridgedEnable'])) { - $bridgedEnable = 1; - } - } - // Check for WiFi repeater mode checkbox - $repeaterEnable = 0; - if ($bridgedEnable == 0) { // enable client mode actions when not bridged - if ($arrHostapdConf['RepeaterEnable'] == 0) { - if (isset($_POST['repeaterEnable'])) { - $repeaterEnable = 1; - } - } else { - if (isset($_POST['repeaterEnable'])) { - $repeaterEnable = 1; - } - } - } - // Check for WiFi client AP mode checkbox - $wifiAPEnable = 0; - if ($bridgedEnable == 0) { // enable client mode actions when not bridged - if ($arrHostapdConf['WifiAPEnable'] == 0) { - if (isset($_POST['wifiAPEnable'])) { - $wifiAPEnable = 1; - } - } else { - if (isset($_POST['wifiAPEnable'])) { - $wifiAPEnable = 1; - } - } - } - // Check for Logfile output checkbox - $logEnable = 0; - if ($arrHostapdConf['LogEnable'] == 0) { - if (isset($_POST['logEnable'])) { - $logEnable = 1; - exec('sudo '.RASPI_CONFIG.'/hostapd/enablelog.sh'); - } else { - exec('sudo '.RASPI_CONFIG.'/hostapd/disablelog.sh'); - } - } else { - if (isset($_POST['logEnable'])) { - $logEnable = 1; - exec('sudo '.RASPI_CONFIG.'/hostapd/enablelog.sh'); - } else { - exec('sudo '.RASPI_CONFIG.'/hostapd/disablelog.sh'); - } - } + $hostapdIniPath = RASPI_CONFIG . '/hostapd.ini'; + $arrHostapdConf = file_exists($hostapdIniPath) ? parse_ini_file($hostapdIniPath) : []; - // set AP interface default, override for ap-sta & bridged options - $iface = validateInterface($_POST['interface']) ? $_POST['interface'] : RASPI_WIFI_AP_INTERFACE; + // derive mode states + $states = $hostapd->deriveModeStates($_POST, $arrHostapdConf); - $ap_iface = $iface; // the hostap AP interface - $cli_iface = $iface; // the wifi client interface - $session_iface = $iface; // the interface that the UI needs to monitor for data usage etc. - if ($wifiAPEnable) { // for AP-STA we monitor the uap0 interface, which is always the ap interface. - $ap_iface = $session_iface = 'uap0'; - } - if ($bridgedEnable) { // for bridged mode we monitor the bridge, but keep the selected interface as AP. - $cli_iface = $session_iface = 'br0'; - } + // determine base interface (validated or fallback) + $baseIface = validateInterface($_POST['interface']) ? $_POST['interface'] : RASPI_WIFI_AP_INTERFACE; - $hostapd->persistHostapdIni($ap_iface, $logEnable, $bridgedEnable, $arrHostapdConf['WifiAPEnable'], $wifiAPEnable, $repeaterEnable, $cli_iface); + // derive interface roles + [$apIface, $cliIface, $sessionIface] = $hostapd->deriveInterfaces($baseIface, $states); - $_SESSION['ap_interface'] = $session_iface; + // persist hostapd.ini + $hostapd->persistHostapdIni($states, $apIface, $cliIface, $arrHostapdConf); - // Verify input - if (empty($_POST['ssid']) || strlen($_POST['ssid']) > 32) { - $status->addMessage('SSID must be between 1 and 32 characters', 'danger'); - $good_input = false; - } + // store session (compatibility) + $_SESSION['ap_interface'] = $sessionIface; - # NB: A pass-phrase is a sequence of between 8 and 63 ASCII-encoded characters (IEEE Std. 802.11i-2004) - # Each character in the pass-phrase must have an encoding in the range of 32 to 126 (decimal). (IEEE Std. 802.11i-2004, Annex H.4.1) - if ($_POST['wpa'] !== 'none' && (strlen($_POST['wpa_passphrase']) < 8 || strlen($_POST['wpa_passphrase']) > 63)) { - $status->addMessage('WPA passphrase must be between 8 and 63 characters', 'danger'); - $good_input = false; - } elseif (!ctype_print($_POST['wpa_passphrase'])) { - $status->addMessage('WPA passphrase must be comprised of printable ASCII characters', 'danger'); - $good_input = false; - } - - $ignore_broadcast_ssid = $_POST['hiddenSSID'] ?? '0'; - if (!ctype_digit($ignore_broadcast_ssid)) { - $status->addMessage('Parameter hiddenSSID not a number.', 'danger'); - $good_input = false; - } elseif ((int)$ignore_broadcast_ssid < 0 || (int)$ignore_broadcast_ssid >= 3) { - $status->addMessage('Parameter hiddenSSID contains an invalid configuration value.', 'danger'); - $good_input = false; - } - if (! in_array($_POST['interface'], $interfaces)) { - $status->addMessage('Unknown interface '.htmlspecialchars($_POST['interface'], ENT_QUOTES), 'danger'); - $good_input = false; - } - if (strlen($_POST['country_code']) !== 0 && strlen($_POST['country_code']) != 2) { - $status->addMessage('Country code must be blank or two characters', 'danger'); - $good_input = false; - } else { - $country_code = $_POST['country_code']; - } - if (isset($_POST['beaconintervalEnable'])) { - if (!is_numeric($_POST['beacon_interval'])) { - $status->addMessage('Beacon interval must be a numeric value', 'danger'); - $good_input = false; - } elseif ($_POST['beacon_interval'] < 15 || $_POST['beacon_interval'] > 65535) { - $status->addMessage('Beacon interval must be between 15 and 65535', 'danger'); - $good_input = false; - } - } - $_POST['max_num_sta'] = (int) $_POST['max_num_sta']; - $_POST['max_num_sta'] = $_POST['max_num_sta'] > 2007 ? 2007 : $_POST['max_num_sta']; - $_POST['max_num_sta'] = $_POST['max_num_sta'] < 1 ? null : $_POST['max_num_sta']; - - if ($good_input) { - $config = $hostapd->buildConfig([ - 'interface' => $_POST['interface'], - 'ssid' => $_POST['ssid'], - 'channel' => $_POST['channel'], - 'wpa' => $_POST['wpa'], - '80211w' => $_POST['80211w'] ?? 0, - 'wpa_passphrase' => $_POST['wpa_passphrase'], - 'wpa_pairwise' => $_POST['wpa_pairwise'], - 'hw_mode' => $_POST['hw_mode'], - 'country_code' => $_POST['country_code'], - 'hiddenSSID' => $_POST['hiddenSSID'], - 'max_num_sta' => $_POST['max_num_sta'] ?? null, - 'beacon_interval' => $_POST['beacon_interval'] ?? null, - 'disassoc_low_ack' => $_POST['disassoc_low_ackEnable'] ?? null, - 'bridge' => $bridgedEnable ? 'br0' : null - ]); + // validate config from $_POST + $validated = $hostapd->validate($_POST, $wpa_array, $enc_types, $modes, $interfaces, $reg_domain, $status); + if ($validated !== false) { try { - $arrConfig = $hostapd->saveConfig($config, $dualAPEnable, $ap_iface); + $validated['interface'] = $apIface; + $validated['bridge'] = $states['BridgedEnable'] ? 'br0' : null; + + $config = $hostapd->buildConfig($validated); + $hostapd->saveConfig($config, $dualAPEnable, $validated['interface']); + $status->addMessage('WiFi hotspot settings saved.', 'success'); + } catch (\RuntimeException $e) { error_log('Error: ' . $e->getMessage()); } - + } else { + $status->addMessage('Unable to save WiFi hotspot settings', 'danger'); + return false; + } + + /// TODO: build out DHCP class + /// finish processing save + /* if (trim($country_code) != trim($reg_domain)) { - $return = iwRegSet($country_code, $status); + $return = $hostapd->iwRegSet($country_code, $status); } // Parse dnsmasq config for selected interface @@ -437,10 +314,7 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom } else { $status->addMessage('WiFi hotspot settings saved.', 'success'); } - } else { - $status->addMessage('Unable to save WiFi hotspot settings', 'danger'); - return false; - } + */ return true; } @@ -476,23 +350,6 @@ function countHostapdConfigs(): int return is_array($configs) ? count($configs) : 0; } -/** - * Retrieves the metric value for a given interface - * - * @param string $iface - * @return int $metric - */ -function getIfaceMetric($iface) -{ - $metric = shell_exec("ip -o -4 route show dev ".$iface." | awk '/metric/ {print \$NF; exit}'"); - if (isset($metric)) { - $metric = (int)$metric; - return $metric; - } else { - return false; - } -} - /** * Updates the dhcpcd configuration for a given interface, preserving existing settings * @@ -557,39 +414,3 @@ function updateDhcpcdConfig($ap_iface, $jsonData, $ip_address, $routers, $domain return $config; } -/** - * Executes iw to set the specified ISO 2-letter country code - * - * @param string $country_code - * @param object $status - * @return boolean $result - */ -function iwRegSet(string $country_code, $status) -{ - $country_code = escapeshellarg($country_code); - $result = shell_exec("sudo iw reg set $country_code"); - $status->addMessage(sprintf(_('Setting wireless regulatory domain to %s'), $country_code, 'success')); - return $result; -} - -/** - * Parses optional /etc/hostapd/hostapd.conf.users file - * - * @return string $tmp - */ -function parseUserHostapdCfg() -{ - if (file_exists(RASPI_HOSTAPD_CONFIG . '.users')) { - exec('cat '. RASPI_HOSTAPD_CONFIG . '.users', $hostapdconfigusers); - foreach ($hostapdconfigusers as $hostapdconfigusersline) { - if (strlen($hostapdconfigusersline) === 0) { - continue; - } - if ($hostapdconfigusersline[0] != "#") { - $arrLine = explode("=", $hostapdconfigusersline); - $tmp.= $arrLine[0]."=".$arrLine[1].PHP_EOL;; - } - } - return $tmp; - } -} From 126f64a793e63e2187b5c325b96d2e77534885ab Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 05:56:32 -0700 Subject: [PATCH 17/44] Fix PHP warning Fix PHP warning --- src/RaspAP/Networking/Hotspot/Validators/HostapdValidator.php | 2 +- templates/hostapd/security.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/Validators/HostapdValidator.php b/src/RaspAP/Networking/Hotspot/Validators/HostapdValidator.php index e391fc49..864ff371 100644 --- a/src/RaspAP/Networking/Hotspot/Validators/HostapdValidator.php +++ b/src/RaspAP/Networking/Hotspot/Validators/HostapdValidator.php @@ -133,7 +133,7 @@ class HostapdValidator 'max_num_sta' => $post['max_num_sta'], 'beacon_interval' => $post['beacon_interval'] ?? null, 'disassoc_low_ack' => $post['disassoc_low_ackEnable'] ?? null, - 'bridge' => $post['bridgedEnable'] ? 'br0' : null, + 'bridge' => ($post['bridgedEnable'] ?? false) ? 'br0' : null ]; } diff --git a/templates/hostapd/security.php b/templates/hostapd/security.php index cc7eb141..a0aa09a1 100644 --- a/templates/hostapd/security.php +++ b/templates/hostapd/security.php @@ -13,7 +13,7 @@
- +
From 5cd07a83a9dda70a3efb517dc48ea78d7744cf35 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 05:59:32 -0700 Subject: [PATCH 18/44] Revise /sbin/iw permissions scope --- installers/raspap.sudoers | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installers/raspap.sudoers b/installers/raspap.sudoers index 2d051035..e3985826 100644 --- a/installers/raspap.sudoers +++ b/installers/raspap.sudoers @@ -45,7 +45,7 @@ www-data ALL=(ALL) NOPASSWD:/sbin/ip link set wl* up www-data ALL=(ALL) NOPASSWD:/sbin/ip -s a f label wl* www-data ALL=(ALL) NOPASSWD:/sbin/ifup * www-data ALL=(ALL) NOPASSWD:/sbin/ifdown * -www-data ALL=(ALL) NOPASSWD:/sbin/iw +www-data ALL=(ALL) NOPASSWD:/sbin/iw dev* www-data ALL=(ALL) NOPASSWD:/bin/cp /etc/raspap/networking/dhcpcd.conf /etc/dhcpcd.conf www-data ALL=(ALL) NOPASSWD:/etc/raspap/hostapd/enablelog.sh www-data ALL=(ALL) NOPASSWD:/etc/raspap/hostapd/disablelog.sh From e12be86c8caf8daaa8a88efc4a043a817b121630 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 06:01:23 -0700 Subject: [PATCH 19/44] Add static methods for 802.11 standards, resolveHwMode() --- .../Networking/Hotspot/HostapdManager.php | 252 ++++++++++++++---- 1 file changed, 195 insertions(+), 57 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/HostapdManager.php b/src/RaspAP/Networking/Hotspot/HostapdManager.php index d1e03058..bacbd232 100644 --- a/src/RaspAP/Networking/Hotspot/HostapdManager.php +++ b/src/RaspAP/Networking/Hotspot/HostapdManager.php @@ -18,6 +18,22 @@ class HostapdManager private const CONF_PATH_PREFIX = '/etc/hostapd/hostapd-'; private const CONF_TMP = '/tmp/hostapddata'; + // IEEE 802.11 standards + private const IEEE_80211_STANDARD = [ + 'a' => '802.11a - 5 GHz', + 'b' => '802.11b - 2.4 GHz', + 'g' => '802.11g - 2.4 GHz', + 'n' => '802.11n - 2.4/5 GHz', + 'ac' => '802.11ac - 5 GHz' + ]; + + // encryption types + private const ENC_TYPES = [ + 'TKIP' => 'TKIP', + 'CCMP' => 'CCMP', + 'TKIP CCMP' => 'TKIP+CCMP' + ]; + /** @var HostapdValidator */ private $validator; @@ -26,6 +42,50 @@ class HostapdManager $this->validator = $validator ?: new HostapdValidator(); } + /** + * Returns IEEE 802.11 standards + */ + public static function get80211Standards(): array + { + return self::IEEE_80211_STANDARD; + } + + /** + * Returns encryption types + */ + public static function getEncTypes(): array + { + return self::ENC_TYPES; + } + + /** + * Returns translated security modes. + */ + public static function getSecurityModes(): array + { + // Build each call to ensure translation occurs under current locale. + return [ + 1 => 'WPA', + 2 => 'WPA2', + 3 => _('WPA and WPA2'), + 4 => _('WPA2 and WPA3-Personal (transitional mode)'), + 5 => 'WPA3-Personal (required)', + 'none' => _('None'), + ]; + } + + /** + * Returns translated 802.11w options + */ + public static function get80211wOptions(): array + { + return [ + 3 => _('Disabled'), + 1 => _('Enabled (for supported clients)'), + 2 => _('Required (for supported clients)'), + ]; + } + /** * Retrieves current hostapd config * @@ -46,7 +106,6 @@ class HostapdManager if ($status !== 0 || empty($hostapdconfig)) { throw new \RuntimeException("Failed to read hostapd config: $configFile"); } - //error_log("HostapdManager::getConfig() hostapdconfig =" . print_r($hostapdconfig, true)); foreach ($hostapdconfig as $hostapdconfigline) { if (strlen($hostapdconfigline) === 0) { @@ -76,23 +135,7 @@ class HostapdManager } elseif ($config['wpa_key_mgmt'] == 'SAE') { $config['wpa'] = 5; } - $selectedHwMode = $config['hw_mode']; - if (isset($config['ieee80211n'])) { - if (strval($config['ieee80211n']) === '1') { - $selectedHwMode = 'n'; - } - } - if (isset($config['ieee80211ac'])) { - if (strval($config['ieee80211ac']) === '1') { - $selectedHwMode = 'ac'; - } - } - if (isset($config['ieee80211w'])) { - if (strval($config['ieee80211w']) === '2') { - $selectedHwMode = 'w'; - } - } - $config['selected_hw_mode'] = $selectedHwMode; + $config['selected_hw_mode'] = $this->resolveHwMode($config); $config['ignore_broadcast_ssid'] ??= 0; $config['max_num_sta'] ??= 0; $config['wep_default_key'] ??= 0; @@ -101,6 +144,29 @@ class HostapdManager } + /** + * Determines the selected hardware mode based on config + * + * @param array $config + * @return string + */ + private function resolveHwMode(array $config): string + { + $selected = $config['hw_mode'] ?? 'g'; // default fallback + + if (!empty($config['ieee80211n']) && strval($config['ieee80211n']) === '1') { + $selected = 'n'; + } + if (!empty($config['ieee80211ac']) && strval($config['ieee80211ac']) === '1') { + $selected = 'ac'; + } + if (!empty($config['ieee80211w']) && strval($config['ieee80211w']) === '2') { + $selected = 'w'; + } + + return $selected; + } + /** * Validates a hostapd configuration * @@ -128,10 +194,11 @@ class HostapdManager /** * Builds hostapd configuration text from array * - * @param array $params + * @param array $params + * @param StatusMessage $status * @return string */ - public function buildConfig(array $params): string + public function buildConfig(array $params, StatusMessage $status): string { $config = []; $config[] = 'driver=nl80211'; @@ -220,8 +287,10 @@ class HostapdManager if (!empty($params['max_num_sta'])) { $config[] = 'max_num_sta=' . (int)$params['max_num_sta']; } - - // Optional additional user config + + $result = $this->maybeSetRegDomain($params['country_code'], $status); + + // optional additional user config $config[] = $this->parseUserHostapdCfg(); return implode(PHP_EOL, $config) . PHP_EOL; @@ -232,7 +301,7 @@ class HostapdManager * * @param string $config, rendered hostapd.conf * @param string $interface, named interface - * @param bool $dualMode, dual-band AP mode enabled + * @param bool $dualMode, dual-band AP mode enabled * @param bool $restart, option to restart hostapd@ after save * @return bool * @throws \RuntimeException @@ -262,8 +331,8 @@ class HostapdManager /** * Derives mode checkbox states from POST + existing ini * - * @param array $post raw $_POST - * @param array $currentIni parsed hostapd.ini + * @param array $post raw $_POST + * @param array $currentIni parsed hostapd.ini * @return array normalized states */ public function deriveModeStates(array $post, array $currentIni): array @@ -334,29 +403,6 @@ class HostapdManager exec('sudo ' . RASPI_CONFIG . '/hostapd/' . $script); } - /** - * Sets transmit power for an interface - * - * @param string $iface - * @param int|string $dbm - * @return bool - */ - public function setTxPower(string $iface, $dbm): bool - { - return false; - } - - /** - * Sets regulatory domain - * - * @param string $countryCode - * @return bool - */ - public function setRegDomain(string $countryCode): bool - { - return false; - } - /** * Parses optional /etc/hostapd/hostapd.conf.users file * @@ -467,20 +513,112 @@ class HostapdManager } /** - * Executes iw to set the specified ISO 2-letter country code + * Sets transmit power for an interface * - * @param string $country_code - * @param object $status - * @return boolean $result + * @param string $iface + * @param int|string $dbm + * @param StatusMessage $status + * @return bool */ - public function iwRegSet(string $country_code, $status): bool + public function maybeSetTxPower(string $iface, $dbm, StatusMessage $status): bool { - $country_code = escapeshellarg($country_code); - $result = shell_exec("sudo iw reg set $country_code"); - $status->addMessage(sprintf(_('Setting wireless regulatory domain to %s'), $country_code, 'success')); - return $result; + $currentTxPower = $this->getTxPower($iface); + + if ($currentTxPower === $dbm) { + return true; + } + + if ($dbm === 'auto') { + exec('sudo /sbin/iw dev ' . escapeshellarg($iface) . ' set txpower auto', $return); + $status->addMessage('Setting transmit power to auto.', 'success'); + } else { + $sdBm = (int)$dbm * 100; + exec('sudo /sbin/iw dev ' . escapeshellarg($iface) . ' set txpower fixed ' . $sdBm, $return); + $status->addMessage('Setting transmit power to ' . $dbm . ' dBm.', 'success'); + } + return true; } + /** + * Gets transmit power for an interface + * + * @param string $iface + * @return string + */ + public function getTxPower(string $iface): string + { + $cmd = "iw dev ".escapeshellarg($iface)." info | awk '$1==\"txpower\" {print $2}'"; + exec($cmd, $txpower); + return intval($txpower[0]); + } + + /** + * Sets a new regulatory domain if value has changed + * + * @param string $countryCode + * @return bool + */ + public function maybeSetRegDomain($countryCode, StatusMessage $status): bool + { + $currentDomain = $this->getRegDomain(); + if (trim($countryCode) !== trim($currentDomain)) { + $result = $this->setRegDomain($countryCode, $status); + if ($result !== true) { + return false; + } + } + return true; + } + + /** + * Gets the current regulatory domain + * + * @return string + */ + public function getRegDomain(): string + { + $domain = shell_exec("iw reg get | grep -o 'country [A-Z]\{2\}' | awk 'NR==1{print $2}'"); + return $domain; + } + + /** + * Sets the specified wireless regulatory domain + * + * @param string $country_code ISO 2-letter country code + * @param object $status StatusMessage object + * @return boolean $result + */ + public function setRegDomain(string $country_code, StatusMessage $status): bool + { + $country_code = escapeshellarg($country_code); + exec("sudo iw reg set $country_code", $output, $result); + if ($result !== 0) { + $status->addMessage(sprintf(_('Unable to set wireless regulatory domain to %s'), $country_code, 'warning')); + return false; + } else { + $status->addMessage(sprintf(_('Setting wireless regulatory domain to %s'), $country_code, 'success')); + return true; + } + } + + /** + * Enumerates available network interfaces + * + * @return array $interfaces + */ + public function getInterfaces(): array + { + exec("ip -o link show | awk -F': ' '{print $2}'", $interfaces); + + // filter out loopback, docker, bridges + other virtual interfaces + // that are incapable of hosting an AP + $interfaces = array_filter($interfaces, function ($iface) { + return !preg_match('/^(lo|docker|br-|veth|tun|tap|tailscale)/', $iface); + }); + sort($interfaces); + + return array_values($interfaces); + } } From c5ff6912ead4e9a3bc3fbeaeb593068bb21174f0 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 06:03:46 -0700 Subject: [PATCH 20/44] Set properties from class methods, process txpower input --- includes/hostapd.php | 70 ++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 41 deletions(-) diff --git a/includes/hostapd.php b/includes/hostapd.php index 0d03f0ba..c2e0d01b 100755 --- a/includes/hostapd.php +++ b/includes/hostapd.php @@ -16,40 +16,31 @@ $wifi->getWifiInterface(); */ function DisplayHostAPDConfig() { + $hostapd = new HostapdManager(); $status = new StatusMessage(); $system = new Sysinfo(); $operatingSystem = $system->operatingSystem(); $arrConfig = array(); - $arr80211Standard = [ - 'a' => '802.11a - 5 GHz', - 'b' => '802.11b - 2.4 GHz', - 'g' => '802.11g - 2.4 GHz', - 'n' => '802.11n - 2.4/5 GHz', - 'ac' => '802.11ac - 5 GHz' - ]; + $arr80211Standard = $hostapd->get80211Standards(); + $arrSecurity = $hostapd->getSecurityModes(); + $arrEncType = $hostapd->getEncTypes(); + $arr80211w = $hostapd->get80211wOptions(); $languageCode = strtok($_SESSION['locale'], '_'); $countryCodes = getCountryCodes($languageCode); + $reg_domain = $hostapd->getRegDomain(); + $interfaces = $hostapd->getInterfaces(); - $arrSecurity = array(1 => 'WPA', 2 => 'WPA2', 3 => _("WPA and WPA2")); - $arrSecurity += [4 => _("WPA2 and WPA3-Personal (transitional mode)")]; - $arrSecurity += [5 => 'WPA3-Personal (required)']; - $arrSecurity += ['none' => _("None")]; - $arrEncType = array('TKIP' => 'TKIP', 'CCMP' => 'CCMP', 'TKIP CCMP' => 'TKIP+CCMP'); - $arr80211w = array(3 => _("Disabled"), 1 => _("Enabled (for supported clients)"), 2 => _("Required (for supported clients)")); + // set defaults $arrTxPower = getDefaultNetOpts('txpower','dbm'); $managedModeEnabled = false; - exec("ip -o link show | awk -F': ' '{print $2}'", $interfaces); - sort($interfaces); - - $reg_domain = shell_exec("iw reg get | grep -o 'country [A-Z]\{2\}' | awk 'NR==1{print $2}'"); - $cmd = "iw dev ".escapeshellarg($_SESSION['ap_interface'])." info | awk '$1==\"txpower\" {print $2}'"; - exec($cmd, $txpower); - $txpower = intval($txpower[0]); if (isset($_POST['interface'])) { - $interface = escapeshellarg($_POST['interface']); + $interface = $_POST['interface']; + } else { + $interface = $_SESSION['ap_interface']; } + $txpower = $hostapd->getTxPower($interface); if (!RASPI_MONITOR_ENABLED) { if (isset($_POST['SaveHostAPDSettings'])) { @@ -98,31 +89,29 @@ function DisplayHostAPDConfig() } } - // Parse hostapd configuration - $hostapd = new HostapdManager(); + // process txpower user input + if (isset($_POST['txpower'])) { + if ($_POST['txpower'] != 'auto') { + $txpower = intval($_POST['txpower']); + $hostapd->maybeSetTxPower($interface, $txpower, $status); + } elseif ($_POST['txpower'] == 'auto') { + $hostapd->maybeSetTxPower($interface, 'auto', $status); + } + $txpower = $_POST['txpower']; + } + + // parse hostapd configuration try { $arrConfig = $hostapd->getConfig(); } catch (\RuntimeException $e) { error_log('Error: ' . $e->getMessage()); } + // assign disassoc_low_ack boolean if value is set + $arrConfig['disassoc_low_ack_bool'] = isset($arrConfig['disassoc_low_ack']) ? 1 : 0; $hostapdstatus = $system->hostapdStatus(); $serviceStatus = $hostapdstatus[0] == 0 ? "down" : "up"; - // set txpower with iw if value is non-default ('auto') - if (isset($_POST['txpower'])) { - if ($_POST['txpower'] != 'auto') { - $txpower = intval($_POST['txpower']); - $sdBm = $txpower * 100; - exec('sudo /sbin/iw dev '.$interface.' set txpower fixed '.$sdBm, $return); - $status->addMessage('Setting transmit power to '.$_POST['txpower'].' dBm.', 'success'); - $txpower = $_POST['txpower']; - } elseif ($_POST['txpower'] == 'auto') { - exec('sudo /sbin/iw dev '.$interface.' set txpower auto', $return); - $status->addMessage('Setting transmit power to '.$_POST['txpower'].'.', 'success'); - $txpower = $_POST['txpower']; - } - } exec('sudo /bin/chmod o+r '.RASPI_HOSTAPD_LOG); $logdata = getLogLimited(RASPI_HOSTAPD_LOG); @@ -142,7 +131,6 @@ function DisplayHostAPDConfig() "txpower", "arrHostapdConf", "operatingSystem", - "selectedHwMode", "countryCodes", "logdata" ) @@ -192,11 +180,11 @@ function saveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom try { $validated['interface'] = $apIface; $validated['bridge'] = $states['BridgedEnable'] ? 'br0' : null; - - $config = $hostapd->buildConfig($validated); + $validated['txpower'] = $txpower; + // build and save configuration + $config = $hostapd->buildConfig($validated, $status); $hostapd->saveConfig($config, $dualAPEnable, $validated['interface']); $status->addMessage('WiFi hotspot settings saved.', 'success'); - } catch (\RuntimeException $e) { error_log('Error: ' . $e->getMessage()); } From 92f9cf745e1724046695a034ed5b785110a1ad76 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 15:04:16 -0700 Subject: [PATCH 21/44] Add getHostapdIni, countHostapdConfigs methods --- .../Networking/Hotspot/HostapdManager.php | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/HostapdManager.php b/src/RaspAP/Networking/Hotspot/HostapdManager.php index bacbd232..cbd9491b 100644 --- a/src/RaspAP/Networking/Hotspot/HostapdManager.php +++ b/src/RaspAP/Networking/Hotspot/HostapdManager.php @@ -343,21 +343,19 @@ class HostapdManager $wifiAPEnable = 0; if ($bridgedEnable === 0) { - // Only meaningful when not bridged + // only meaningful when not bridged $repeaterEnable = isset($post['repeaterEnable']) ? 1 : 0; $wifiAPEnable = isset($post['wifiAPEnable']) ? 1 : 0; } $logEnable = isset($post['logEnable']) ? 1 : 0; - $effectiveWifiAPEnable = $bridgedEnable === 1 ? $prevWifiAPEnable : $wifiAPEnable; return [ 'BridgedEnable' => $bridgedEnable, 'RepeaterEnable' => $repeaterEnable, 'WifiAPEnable' => $effectiveWifiAPEnable, - 'LogEnable' => $logEnable, - '_rawPostedWifiAPEnable' => $wifiAPEnable + 'LogEnable' => $logEnable ]; } @@ -471,6 +469,20 @@ class HostapdManager throw new \RuntimeException("Failed to restart hostapd (tried instance + fallback)."); } + /** + * Gets system hostapd.ini + * + * @return array $config + */ + public function getHostapdIni() + { + $hostapdIni = RASPI_CONFIG . '/hostapd.ini'; + if (file_exists($hostapdIni)) { + $config = parse_ini_file($hostapdIni); + return $config; + } + } + /** * Persist hostapd.ini with mode / interface user settings * @@ -491,6 +503,7 @@ class HostapdManager 'WifiAPEnable' => $states['WifiAPEnable'], 'BridgedEnable' => $states['BridgedEnable'], 'RepeaterEnable' => $states['RepeaterEnable'], + 'DualAPEnable' => $states['DualAPEnable'], 'WifiManaged' => $cliIface ]; foreach ($previousIni as $k => $v) { @@ -620,6 +633,16 @@ class HostapdManager return array_values($interfaces); } + /** + * Returns a count of hostapd-.conf files + * + * @return int + */ + private function countHostapdConfigs(): int + { + $configs = glob('/etc/hostapd/hostapd-*.conf'); + return is_array($configs) ? count($configs) : 0; + } + } - From 3fe4990cfd3a40409bdee0b267415b782aa52b6d Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 15:08:13 -0700 Subject: [PATCH 22/44] Implement buildConfig, saveConfig, getInterfaceConfig methods --- .../Networking/Hotspot/DhcpcdManager.php | 359 +++++++++++++++++- 1 file changed, 339 insertions(+), 20 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/DhcpcdManager.php b/src/RaspAP/Networking/Hotspot/DhcpcdManager.php index 6cff77d2..9e397112 100644 --- a/src/RaspAP/Networking/Hotspot/DhcpcdManager.php +++ b/src/RaspAP/Networking/Hotspot/DhcpcdManager.php @@ -2,46 +2,229 @@ namespace RaspAP\Networking\Hotspot; +use RaspAP\Messages\StatusMessage; + /** * Handles dhcpcd.conf interface configuration. */ class DhcpcdManager { + private const CONF_DEFAULT = RASPI_DHCPCD_CONFIG; + private const CONF_TMP = '/tmp/dhcpddata'; + /** - * Get dhcpcd settings for an interface. + * Builds a dhcpcd config for an interface * - * @param string $iface - * @return array + * @param string $ap_iface + * @param bool $bridgedEnable + * @param bool $repeaterEnable + * @param bool $wifiAPEnable + * @param bool $dualAPEnable + * @param StatusMessage $status + * @return string */ - public function getInterfaceSection(string $iface): array + public function buildConfig( + string $ap_iface, + bool $bridgedEnable, + bool $repeaterEnable, + bool $wifiAPEnable, + bool $dualAPEnable, + StatusMessage $status + ): bool { - // TODO: parse dhcpcd.conf - return []; + // determine static IP, routers, DNS + $jsonData = $this->getInterfaceConfig($ap_iface); + //error_log("DhcpcdManager::buildConfig() jsonData =" . print_r($jsonData, true)); + $ip_address = empty($jsonData['StaticIP']) + ? getDefaultNetValue('dhcp', $ap_iface, 'static ip_address') + : $jsonData['StaticIP']; + $domain_name_server = empty($jsonData['StaticDNS']) + ? getDefaultNetValue('dhcp', $ap_iface, 'static domain_name_server') + : $jsonData['StaticDNS']; + $routers = empty($jsonData['StaticRouters']) + ? getDefaultNetValue('dhcp', $ap_iface, 'static routers') + : $jsonData['StaticRouters']; + $netmask = (empty($jsonData['SubnetMask']) || $jsonData['SubnetMask'] === '0.0.0.0') + ? getDefaultNetValue('dhcp', $ap_iface, 'subnetmask') + : $jsonData['SubnetMask']; + if (!preg_match('/.*\/\d+/', $ip_address)) { + $ip_address .= '/' . mask2cidr($netmask); + } + $config = []; + + if ($bridgedEnable) { + $config = array_keys(getDefaultNetOpts('dhcp', 'options')); + $config[] = '# RaspAP br0 configuration'; + $config[] = 'denyinterfaces eth0 wlan0'; + $config[] = 'interface br0'; + } elseif ($repeaterEnable) { + $config = [ + '# RaspAP ' . $ap_iface . ' configuration', + 'interface ' . $ap_iface, + 'static ip_address=' . $ip_address, + 'static routers=' . $routers, + 'static domain_name_server=' . $domain_name_server + ]; + $client_metric = getIfaceMetric($_SESSION['wifi_client_interface']); + if (is_int($client_metric)) { + $config[] = 'metric ' . ((int)$client_metric + 1); + } else { + $status->addMessage( + 'Unable to obtain metric value for client interface. Repeater mode inactive.', + 'warning' + ); + } + } elseif ($wifiAPEnable) { + $config = array_keys(getDefaultNetOpts('dhcp', 'options')); + $config[] = '# RaspAP uap0 configuration'; + $config[] = 'interface uap0'; + $config[] = 'static ip_address=' . $ip_address; + $config[] = 'nohook wpa_supplicant'; + } elseif ($dualAPEnable) { + $config = [ + '# RaspAP ' . $ap_iface . ' configuration', + 'interface ' . $ap_iface, + 'static ip_address=' . $ip_address, + 'static routers=' . $routers, + 'static domain_name_server=' . $domain_name_server, + 'nogateway' + ]; + } else { + $config = $this->updateDhcpcdConfig( + $ap_iface, + $jsonData, + $ip_address, + $routers, + $domain_name_server + ); + } + $dhcp_cfg = file_get_contents(SELF::CONF_DEFAULT); + + if (preg_match('/wlan[3-9]\d*|wlan[1-9]\d+/', $ap_iface)) { + $skip_dhcp = true; + } elseif ($bridgedEnable == 1 || $wifiAPEnable == 1) { + $dhcp_cfg = join(PHP_EOL, $config); + $status->addMessage(sprintf(_('DHCP configuration for %s enabled.'), $ap_iface), 'success'); + } elseif (!preg_match('/^interface\s'.$ap_iface.'$/m', $dhcp_cfg)) { + $config[] = PHP_EOL; + $config= join(PHP_EOL, $config); + $dhcp_cfg = removeDHCPIface($dhcp_cfg,'br0'); + $dhcp_cfg = removeDHCPIface($dhcp_cfg,'uap0'); + $dhcp_cfg .= $config; + } else { + $config = join(PHP_EOL, $config); + $dhcp_cfg = removeDHCPIface($dhcp_cfg,'br0'); + $dhcp_cfg = removeDHCPIface($dhcp_cfg,'uap0'); + if (!strpos($dhcp_cfg, 'metric')) { + $dhcp_cfg = preg_replace('/^#\sRaspAP\s'.$ap_iface.'\s.*?(?=(?:\s*^\s*$|\s*nogateway))/ms', $config, $dhcp_cfg, 1); + } else { + $metrics = true; + } + } + if ($repeaterEnable && $metrics) { + $status->addMessage(_('WiFi repeater mode: A metric value is already defined for DHCP.'), 'warning'); + } else if ($repeaterEnable && !$metrics) { + $status->addMessage(sprintf(_('Metric value configured for the %s interface.'), $ap_iface), 'success'); + $status->addMessage('Restart hotspot to enable WiFi repeater mode.', 'success'); + $this->saveConfig($dhcp_cfg, $ap_iface, $status); + } elseif (!$skip_dhcp) { + $this->saveConfig($dhcp_cfg, $ap_iface, $status); + } else { + $status->addMessage('WiFi hotspot settings saved.', 'success'); + } + return true; } /** - * Write or update interface section. + * Saves a dhcpcd configuration * - * @param string $iface - * @param array $kv + * @param string $config + * @param StatusMessage $status * @return bool + * @throws \RuntimeException */ - public function writeInterfaceSection(string $iface, array $kv): bool + public function saveConfig(string $config, string $iface, StatusMessage $status): bool { - // TODO: update config - return false; + if (file_put_contents(self::CONF_TMP, $config) === false) { + throw new \RuntimeException("Failed to write temporary dhcpcd config"); + } + + exec(sprintf('sudo cp %s %s', escapeshellarg(self::CONF_TMP), escapeshellarg(self::CONF_DEFAULT)), $o, $rc); + if ($rc !== 0) { + $status->addMessage('Unable to save DHCP configuration.', 'danger'); + return false; + } + $status->addMessage(sprintf(_('DHCP configuration for %s updated.'), $iface), 'success'); + return true; } /** - * Remove interface section from dhcpcd.conf. - * - * @param string $iface - * @return bool - */ - public function removeInterface(string $iface): bool + * Updates the dhcpcd configuration for a given interface, preserving existing settings + * + * @param string $ap_iface + * @param array $jsonData + * @param string $ip_address + * @param string $routers + * @param string $domain_name_server + * @return array updated configuration + */ + private function updateDhcpcdConfig( + string $ap_iface, + array $jsonData, + string $ip_address, + string $routers, + string $domain_name_server): array { - // TODO: delete section - return false; + $dhcp_cfg = file_get_contents(self::CONF_DEFAULT); + $existing_config = []; + $section_regex = '/^#\sRaspAP\s'.preg_quote($ap_iface, '/').'\s.*?(?=\s*^\s*$)/ms'; + + // extract existing interface configuration + if (preg_match($section_regex, $dhcp_cfg, $matches)) { + $lines = explode(PHP_EOL, $matches[0]); + foreach ($lines as $line) { + $line = trim($line); + if (preg_match('/^(interface|static|metric|nogateway|nohook)/', $line)) { + $existing_config[] = $line; + } + } + } + + // initialize with comment + $config = [ '# RaspAP '.$ap_iface.' configuration' ]; + $config[] = 'interface '.$ap_iface; + $static_settings = [ + 'static ip_address' => $ip_address, + 'static routers' => $routers, + 'static domain_name_server' => $domain_name_server + ]; + + // merge existing settings with updates + foreach ($existing_config as $line) { + $matched = false; + foreach ($static_settings as $key => $value) { + if (strpos($line, $key) === 0) { + $config[] = "$key=$value"; + $matched = true; + unset($static_settings[$key]); + break; + } + } + if (!$matched && !preg_match('/^interface/', $line)) { + $config[] = $line; + } + } + + // add any new static settings + foreach ($static_settings as $key => $value) { + $config[] = "$key=$value"; + } + + // add metric if provided + if (!empty($jsonData['Metric']) && !in_array('metric '.$jsonData['Metric'], $config)) { + $config[] = 'metric '.$jsonData['Metric']; + } + return $config; } /** @@ -61,6 +244,142 @@ class DhcpcdManager } } + /** + * Gets current dhcpcd info for an interface + * + * @param string $iface + * @return array + */ + public function getInterfaceConfig(string $iface): array + { + $result = [ + 'DHCPEnabled' => false, + 'RangeStart' => null, + 'RangeEnd' => null, + 'RangeMask' => null, + 'leaseTime' => null, + 'leaseTimeInterval' => null, + 'dhcpHost' => [], + 'upstreamServersEnabled' => false, + 'upstreamServers' => [], + 'DNS1' => null, + 'DNS2' => null, + 'Metric' => null, + 'StaticIP' => null, + 'SubnetMask' => null, + 'StaticRouters' => null, + 'StaticDNS' => null, + 'FallbackEnabled' => false, + 'DefaultRoute' => false, + 'NoHookWPASupplicant' => false, + ]; + + // dnsmasq + $dnsmasqFile = RASPI_DNSMASQ_PREFIX . $iface . '.conf'; + if (file_exists($dnsmasqFile) && is_readable($dnsmasqFile)) { + $lines = []; + exec('cat ' . escapeshellarg($dnsmasqFile), $lines); + if (!function_exists('ParseConfig')) { + // ensure legacy parser available + require_once RASPI_CONFIG . '/functions.php'; + } + $conf = ParseConfig($lines); + + if (!empty($conf)) { + $result['DHCPEnabled'] = true; + + // dhcp-range may be multi-value + $rangeRaw = $conf['dhcp-range'] ?? null; + if (is_array($rangeRaw)) { + $rangeRaw = $rangeRaw[0] ?? null; + } + if (is_string($rangeRaw)) { + $rangeParts = explode(',', $rangeRaw); + $result['RangeStart'] = $rangeParts[0] ?? null; + $result['RangeEnd'] = $rangeParts[1] ?? null; + $result['RangeMask'] = $rangeParts[2] ?? null; + $leaseSpec = $rangeParts[3] ?? null; + if ($leaseSpec) { + if (preg_match('/^(\d+)([smhd])?$/i', $leaseSpec, $m)) { + $result['leaseTime'] = $m[1]; + $result['leaseTimeInterval'] = $m[2] ?? 'h'; // default to hours if missing + } else { + $result['leaseTime'] = $leaseSpec; + $result['leaseTimeInterval'] = null; + } + } + } + + // dhcp-host entries (array or scalar) + $hosts = $conf['dhcp-host'] ?? []; + if (!is_array($hosts) && $hosts !== null) { + $hosts = [$hosts]; + } + $result['dhcpHost'] = array_values(array_filter($hosts)); + + // upstream DNS servers (server= lines) + $servers = $conf['server'] ?? []; + if (!is_array($servers) && !empty($servers)) { + $servers = [$servers]; + } + $servers = array_filter($servers); + if (!empty($servers)) { + $result['upstreamServersEnabled'] = true; + $result['upstreamServers'] = $servers; + } + + // dhcp-option=6,[,] + if (isset($conf['dhcp-option'])) { + $optsRaw = $conf['dhcp-option']; + // may be multiple dhcp-option lines; coalesce + $optLines = is_array($optsRaw) ? $optsRaw : [$optsRaw]; + foreach ($optLines as $optLine) { + $parts = explode(',', $optLine); + if ($parts[0] === '6') { + $result['DNS1'] = $parts[1] ?? null; + $result['DNS2'] = $parts[2] ?? null; + break; + } + } + } + } + } + + // dhcpcd + if (file_exists(self::CONF_DEFAULT) && is_readable(self::CONF_DEFAULT)) { + $dhcpcd = file_get_contents(self::CONF_DEFAULT); + + // match interface block starting with '# RaspAP configuration' + $sectionPattern = '/^#\sRaspAP\s' . preg_quote($iface, '/') . '\sconfiguration.*?(?=^(?:#\sRaspAP\s|\s*$))/ms'; + if (preg_match($sectionPattern, $dhcpcd, $match)) { + $block = $match[0]; + + $result['Metric'] = $this->matchFirst('/\bmetric\s+(\d+)/i', $block); + $staticIPLine = $this->matchFirst('/static\s+ip_address=([^\r\n]+)/i', $block); + $staticRouters = $this->matchFirst('/static\s+routers=([^\r\n]+)/i', $block); + $staticDNS = $this->matchFirst('/static\s+domain_name_server=([^\r\n]+)/i', $block); + + $result['StaticIP'] = $staticIPLine ? (strpos($staticIPLine,'/') !== false + ? substr($staticIPLine, 0, strpos($staticIPLine,'/')) + : $staticIPLine) : null; + $result['SubnetMask'] = $staticIPLine && function_exists('cidr2mask') && strpos($staticIPLine,'/') + ? cidr2mask($staticIPLine) + : ($result['SubnetMask'] ?? null); + $result['StaticRouters'] = $staticRouters; + $result['StaticDNS'] = $staticDNS; + + $result['FallbackEnabled'] = (bool) preg_match('/fallback\s+static_' . preg_quote($iface, '/') . '/i', $block); + $result['DefaultRoute'] = (bool) preg_match('/\bgateway\b/', $block); + $result['NoHookWPASupplicant'] = (bool) preg_match('/nohook\s+wpa_supplicant/i', $block); + } + } + return $result; + } + + private function matchFirst(string $pattern, string $subject): ?string + { + return preg_match($pattern, $subject, $m) ? trim($m[1]) : null; + } } From 5a57d542c5238c90a639fa4d594d0fc801d95727 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 19 Jul 2025 15:11:37 -0700 Subject: [PATCH 23/44] Normalize state flags, try-catch blocks for method calls, prune legacy dhcpcd routines --- includes/hostapd.php | 284 ++++++++----------------------------------- 1 file changed, 51 insertions(+), 233 deletions(-) diff --git a/includes/hostapd.php b/includes/hostapd.php index c2e0d01b..f1aa0f39 100755 --- a/includes/hostapd.php +++ b/includes/hostapd.php @@ -21,7 +21,7 @@ function DisplayHostAPDConfig() $system = new Sysinfo(); $operatingSystem = $system->operatingSystem(); - $arrConfig = array(); + // set hostapd defaults $arr80211Standard = $hostapd->get80211Standards(); $arrSecurity = $hostapd->getSecurityModes(); $arrEncType = $hostapd->getEncTypes(); @@ -30,8 +30,6 @@ function DisplayHostAPDConfig() $countryCodes = getCountryCodes($languageCode); $reg_domain = $hostapd->getRegDomain(); $interfaces = $hostapd->getInterfaces(); - - // set defaults $arrTxPower = getDefaultNetOpts('txpower','dbm'); $managedModeEnabled = false; @@ -41,18 +39,7 @@ function DisplayHostAPDConfig() $interface = $_SESSION['ap_interface']; } $txpower = $hostapd->getTxPower($interface); - - if (!RASPI_MONITOR_ENABLED) { - if (isset($_POST['SaveHostAPDSettings'])) { - saveHostAPDConfig($arrSecurity, $arrEncType, $arr80211Standard, $interfaces, $reg_domain, $status); - } - } - - $arrHostapdConf = []; - $hostapdIni = RASPI_CONFIG . '/hostapd.ini'; - if (file_exists($hostapdIni)) { - $arrHostapdConf = parse_ini_file($hostapdIni); - } + $arrHostapdConf = $hostapd->getHostapdIni(); if (!RASPI_MONITOR_ENABLED) { if (isset($_POST['StartHotspot']) || isset($_POST['RestartHotspot'])) { @@ -73,6 +60,8 @@ function DisplayHostAPDConfig() foreach ($return as $line) { $status->addMessage($line, 'info'); } + } elseif (isset($_POST['SaveHostAPDSettings'])) { + saveHostapdConfig($arrSecurity, $arrEncType, $arr80211Standard, $interfaces, $reg_domain, $status); } elseif (isset($_POST['StopHotspot'])) { $status->addMessage('Attempting to stop hotspot', 'info'); exec('sudo /bin/systemctl stop hostapd.service', $return); @@ -111,7 +100,8 @@ function DisplayHostAPDConfig() $arrConfig['disassoc_low_ack_bool'] = isset($arrConfig['disassoc_low_ack']) ? 1 : 0; $hostapdstatus = $system->hostapdStatus(); $serviceStatus = $hostapdstatus[0] == 0 ? "down" : "up"; - + + // ensure log is writeable exec('sudo /bin/chmod o+r '.RASPI_HOSTAPD_LOG); $logdata = getLogLimited(RASPI_HOSTAPD_LOG); @@ -138,7 +128,7 @@ function DisplayHostAPDConfig() } /** - * Validate user input, save configs for hostapd, dnsmasq & dhcp + * Validates user input + saves configs for hostapd, dnsmasq & dhcp * * @param array $wpa_array * @param array $enc_types @@ -148,16 +138,14 @@ function DisplayHostAPDConfig() * @param object $status * @return boolean */ -function saveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_domain, $status) +function saveHostapdConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_domain, $status) { $hostapd = new HostapdManager(); $dnsmasq = new DnsmasqManager(); $dhcpcd = new DhcpcdManager(); + $arrHostapdConf = $hostapd->getHostapdIni(); $dualAPEnable = false; - $hostapdIniPath = RASPI_CONFIG . '/hostapd.ini'; - $arrHostapdConf = file_exists($hostapdIniPath) ? parse_ini_file($hostapdIniPath) : []; - // derive mode states $states = $hostapd->deriveModeStates($_POST, $arrHostapdConf); @@ -178,227 +166,57 @@ function saveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom if ($validated !== false) { try { + // normalize state flags $validated['interface'] = $apIface; - $validated['bridge'] = $states['BridgedEnable'] ? 'br0' : null; + $validated['bridge'] = !empty($states['BridgedEnable']); + $validated['apsta'] = !empty($states['WifiAPEnable']); + $validated['repeater'] = !empty($states['RepeaterEnable']); + $validated['dualmode'] = !empty($states['DualAPEnable']); $validated['txpower'] = $txpower; - // build and save configuration + + // hostapd $config = $hostapd->buildConfig($validated, $status); $hostapd->saveConfig($config, $dualAPEnable, $validated['interface']); $status->addMessage('WiFi hotspot settings saved.', 'success'); - } catch (\RuntimeException $e) { + + // dnsmasq + try { + $syscfg = $dnsmasq->getConfig($ap_iface ?? RASPI_WIFI_AP_INTERFACE); + } catch (\RuntimeException $e) { + error_log('Error: ' . $e->getMessage()); + } + + try { + $dnsmasqConfig = $dnsmasq->buildConfig( + $syscfg, + $validated['interface'], + $validated['apsta'], + $validated['bridge'] + ); + $dnsmasq->saveConfig($dnsmasqConfig, $validated['interface']); + } catch (\RuntimeException $e) { + error_log('Error: ' . $e->getMessage()); + } + + // dhcpcd + try { + $return = $dhcpcd->buildConfig( + $validated['interface'], + $validated['bridge'], + $validated['repeater'], + $validated['apsta'], + $validated['dualmode'], + $status + ); + } catch (\RuntimeException $e) { + error_log('Error: ' . $e->getMessage()); + } + } catch (\Throwable $e) { error_log('Error: ' . $e->getMessage()); + $status->addMessage('Unable to save WiFi hotspot settings', 'danger'); } - } else { - $status->addMessage('Unable to save WiFi hotspot settings', 'danger'); - return false; } - - /// TODO: build out DHCP class - /// finish processing save - /* - if (trim($country_code) != trim($reg_domain)) { - $return = $hostapd->iwRegSet($country_code, $status); - } - // Parse dnsmasq config for selected interface - try { - $syscfg = $dnsmasq->getConfig($ap_iface ?? RASPI_WIFI_AP_INTERFACE); - } catch (\RuntimeException $e) { - error_log('Error: ' . $e->getMessage()); - } - // Build and save dsnmasq config - try { - $config = $dnsmasq->buildConfig($syscfg, $ap_iface, $wifiAPEnable, $bridgedEnable); - $dnsmasq->saveConfig($config, $ap_iface); - } catch (\RuntimeException $e) { - error_log('Error: ' . $e->getMessage()); - } - - // Set dhcp values from system config, fallback to default if undefined - $jsonData = json_decode(getNetConfig($ap_iface), true); - $ip_address = empty($jsonData['StaticIP']) - ? getDefaultNetValue('dhcp', $ap_iface, 'static ip_address') : $jsonData['StaticIP']; - $domain_name_server = empty($jsonData['StaticDNS']) - ? getDefaultNetValue('dhcp', $ap_iface, 'static domain_name_server') : $jsonData['StaticDNS']; - $routers = empty($jsonData['StaticRouters']) - ? getDefaultNetValue('dhcp', $ap_iface, 'static routers') : $jsonData['StaticRouters']; - $netmask = (empty($jsonData['SubnetMask']) || $jsonData['SubnetMask'] === '0.0.0.0') - ? getDefaultNetValue('dhcp', $ap_iface, 'subnetmask') : $jsonData['SubnetMask']; - if (isset($ip_address) && !preg_match('/.*\/\d+/', $ip_address)) { - $ip_address.='/'.mask2cidr($netmask); - } - $hasDefaults = !( - empty($ip_address) || - empty($domain_name_server) || - empty($routers) || - empty($netmask) || - $netmask === '0.0.0.0' - ); - if (!$hasDefaults) { - $status->addMessage(sprintf(_('Interface %s has no default settings.'), $ap_iface), 'warning'); - $status->addMessage(('Configure settings in DHCP Server before starting AP.'), 'warning'); - } - if ($bridgedEnable == 1) { - $config = array_keys(getDefaultNetOpts('dhcp','options')); - $config[] = PHP_EOL.'# RaspAP br0 configuration'; - $config[] = 'denyinterfaces eth0 wlan0'; - $config[] = 'interface br0'; - $config[] = PHP_EOL; - } elseif ($repeaterEnable == 1) { - $config = [ '# RaspAP '.$ap_iface.' configuration' ]; - $config[] = 'interface '.$ap_iface; - $config[] = 'static ip_address='.$ip_address; - $config[] = 'static routers='.$routers; - $config[] = 'static domain_name_server='.$domain_name_server; - $client_metric = getIfaceMetric($_SESSION['wifi_client_interface']); - if (is_int($client_metric)) { - $ap_metric = (int)$client_metric + 1; - $config[] = 'metric '.$ap_metric; - } else { - $status->addMessage('Unable to obtain metric value for client interface. Repeater mode inactive.', 'warning'); - $repeaterEnable = false; - } - } elseif ($wifiAPEnable == 1) { - $config = array_keys(getDefaultNetOpts('dhcp','options')); - $config[] = PHP_EOL.'# RaspAP uap0 configuration'; - $config[] = 'interface uap0'; - $config[] = 'static ip_address='.$ip_address; - $config[] = 'nohook wpa_supplicant'; - $config[] = PHP_EOL; - } else { - $config = updateDhcpcdConfig($ap_iface, $jsonData, $ip_address, $routers, $domain_name_server); - } - $dhcp_cfg = file_get_contents(RASPI_DHCPCD_CONFIG); - - if (preg_match('/wlan[3-9]\d*|wlan[1-9]\d+/', $ap_iface)) { - $skip_dhcp = true; - } elseif ($bridgedEnable == 1 || $wifiAPEnable == 1) { - $dhcp_cfg = join(PHP_EOL, $config); - $status->addMessage(sprintf(_('DHCP configuration for %s enabled.'), $ap_iface), 'success'); - } elseif (!preg_match('/^interface\s'.$ap_iface.'$/m', $dhcp_cfg)) { - $config[] = PHP_EOL; - $config= join(PHP_EOL, $config); - $dhcp_cfg = removeDHCPIface($dhcp_cfg,'br0'); - $dhcp_cfg = removeDHCPIface($dhcp_cfg,'uap0'); - $dhcp_cfg .= $config; - } else { - $config = join(PHP_EOL, $config); - $dhcp_cfg = removeDHCPIface($dhcp_cfg,'br0'); - $dhcp_cfg = removeDHCPIface($dhcp_cfg,'uap0'); - if (!strpos($dhcp_cfg, 'metric')) { - $dhcp_cfg = preg_replace('/^#\sRaspAP\s'.$ap_iface.'\s.*?(?=(?:\s*^\s*$|\s*nogateway))/ms', $config, $dhcp_cfg, 1); - } else { - $metrics = true; - } - } - if ($repeaterEnable && $metrics) { - $status->addMessage(_('WiFi repeater mode: A metric value is already defined for DHCP.'), 'warning'); - } else if ($repeaterEnable && !$metrics) { - $status->addMessage(sprintf(_('Metric value configured for the %s interface.'), $ap_iface), 'success'); - $status->addMessage('Restart hotspot to enable WiFi repeater mode.', 'success'); - persistDHCPConfig($dhcp_cfg, $ap_iface, $status); - } elseif (!$skip_dhcp) { - persistDHCPConfig($dhcp_cfg, $ap_iface, $status); - } else { - $status->addMessage('WiFi hotspot settings saved.', 'success'); - } - */ return true; } -/** - * Persists a DHCP configuration - * - * @param string $dhcp_cfg - * @param string $ap_iface - * @param object $status - * @return $status - */ -function persistDHCPConfig($dhcp_cfg, $ap_iface, $status) -{ - file_put_contents("/tmp/dhcpddata", $dhcp_cfg); - system('sudo cp /tmp/dhcpddata '.RASPI_DHCPCD_CONFIG, $return); - if ($return == 0) { - $status->addMessage(sprintf(_('DHCP configuration for %s updated.'), $ap_iface), 'success'); - $status->addMessage('WiFi hotspot settings saved.', 'success'); - } else { - $status->addMessage('Unable to save WiFi hotspot settings.', 'danger'); - } - return $status; -} - -/** - * Returns a count of hostapd-.conf files - * - * @return int - */ -function countHostapdConfigs(): int -{ - $configs = glob('/etc/hostapd/hostapd-*.conf'); - return is_array($configs) ? count($configs) : 0; -} - -/** - * Updates the dhcpcd configuration for a given interface, preserving existing settings - * - * @param string $ap_iface - * @param array $jsonData - * @param string $ip_address - * @param string $routers - * @param string $domain_name_server - * @return array updated configuration - */ -function updateDhcpcdConfig($ap_iface, $jsonData, $ip_address, $routers, $domain_name_server) { - $dhcp_cfg = file_get_contents(RASPI_DHCPCD_CONFIG); - $existing_config = []; - $section_regex = '/^#\sRaspAP\s'.preg_quote($ap_iface, '/').'\s.*?(?=\s*^\s*$)/ms'; - - // extract existing interface configuration - if (preg_match($section_regex, $dhcp_cfg, $matches)) { - $lines = explode(PHP_EOL, $matches[0]); - foreach ($lines as $line) { - $line = trim($line); - if (preg_match('/^(interface|static|metric|nogateway|nohook)/', $line)) { - $existing_config[] = $line; - } - } - } - - // initialize with comment - $config = [ '# RaspAP '.$ap_iface.' configuration' ]; - $config[] = 'interface '.$ap_iface; - $static_settings = [ - 'static ip_address' => $ip_address, - 'static routers' => $routers, - 'static domain_name_server' => $domain_name_server - ]; - - // merge existing settings with updates - foreach ($existing_config as $line) { - $matched = false; - foreach ($static_settings as $key => $value) { - if (strpos($line, $key) === 0) { - $config[] = "$key=$value"; - $matched = true; - unset($static_settings[$key]); - break; - } - } - if (!$matched && !preg_match('/^interface/', $line)) { - $config[] = $line; - } - } - - // add any new static settings - foreach ($static_settings as $key => $value) { - $config[] = "$key=$value"; - } - - // add metric if provided - if (!empty($jsonData['Metric']) && !in_array('metric '.$jsonData['Metric'], $config)) { - $config[] = 'metric '.$jsonData['Metric']; - } - - return $config; -} - From 5f4469ab32102469378b6203fde586d61048e6d2 Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 01:49:31 -0700 Subject: [PATCH 24/44] Update legacy handler w/ dhcpcdManager->getInterfaceConfig() --- ajax/networking/get_netcfg.php | 61 ++++------------------------------ 1 file changed, 6 insertions(+), 55 deletions(-) diff --git a/ajax/networking/get_netcfg.php b/ajax/networking/get_netcfg.php index 87411ba7..1520173d 100644 --- a/ajax/networking/get_netcfg.php +++ b/ajax/networking/get_netcfg.php @@ -1,4 +1,7 @@ 1) { - $dhcpdata['DNS1'] = $arrDns[1] ?? null; - } - if (count($arrDns) > 2) { - $dhcpdata['DNS2'] = $arrDns[2] ?? null; - } - } - } - - // fetch dhcpcd.conf settings for interface - $conf = file_get_contents(RASPI_DHCPCD_CONFIG); - preg_match('/^#\sRaspAP\s'.$interface.'\s.*?(?=\s*+$)/ms', $conf, $matched); - preg_match('/metric\s(\d*)/', $matched[0], $metric); - preg_match('/static\sip_address=(.*)/', $matched[0], $static_ip); - preg_match('/static\srouters=(.*)/', $matched[0], $static_routers); - preg_match('/static\sdomain_name_server=(.*)/', $matched[0], $static_dns); - preg_match('/fallback\sstatic_'.$interface.'/', $matched[0], $fallback); - preg_match('/(?:no)?gateway/', $matched[0], $gateway); - preg_match('/nohook\swpa_supplicant/', $matched[0], $nohook_wpa_supplicant); - $dhcpdata['Metric'] = $metric[1] ?? null; - $dhcpdata['StaticIP'] = isset($static_ip[1]) && strpos($static_ip[1], '/') !== false - ? substr($static_ip[1], 0, strpos($static_ip[1], '/')) - : ($static_ip[1] ?? ''); - $dhcpdata['SubnetMask'] = cidr2mask($static_ip[1] ?? ''); - $dhcpdata['StaticRouters'] = $static_routers[1] ?? null; - $dhcpdata['StaticDNS'] = $static_dns[1] ?? null; - $dhcpdata['FallbackEnabled'] = empty($fallback) ? false: true; - $dhcpdata['DefaultRoute'] = $gateway[0] == "gateway"; - $dhcpdata['NoHookWPASupplicant'] = ($nohook_wpa_supplicant[0] ?? '') == "nohook wpa_supplicant"; + $dhcpdata = $dhcpcdManager->getInterfaceConfig($interface); echo json_encode($dhcpdata); } From 636e04fa7833c12bbaf6c2fc786ce553f2c8a97a Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 02:05:05 -0700 Subject: [PATCH 25/44] Migrate non-hostapd methods to HotspotService class --- includes/hostapd.php | 119 +++++-------------------------------------- 1 file changed, 13 insertions(+), 106 deletions(-) diff --git a/includes/hostapd.php b/includes/hostapd.php index f1aa0f39..45004498 100755 --- a/includes/hostapd.php +++ b/includes/hostapd.php @@ -1,8 +1,7 @@ getWifiInterface(); function DisplayHostAPDConfig() { $hostapd = new HostapdManager(); + $hotspot = new HotspotService(); $status = new StatusMessage(); $system = new Sysinfo(); $operatingSystem = $system->operatingSystem(); // set hostapd defaults - $arr80211Standard = $hostapd->get80211Standards(); - $arrSecurity = $hostapd->getSecurityModes(); - $arrEncType = $hostapd->getEncTypes(); - $arr80211w = $hostapd->get80211wOptions(); + $arr80211Standard = $hotspot->get80211Standards(); + $arrSecurity = $hotspot->getSecurityModes(); + $arrEncType = $hotspot->getEncTypes(); + $arr80211w = $hotspot->get80211wOptions(); $languageCode = strtok($_SESSION['locale'], '_'); $countryCodes = getCountryCodes($languageCode); - $reg_domain = $hostapd->getRegDomain(); - $interfaces = $hostapd->getInterfaces(); + $reg_domain = $hotspot->getRegDomain(); + $interfaces = $hotspot->getInterfaces(); $arrTxPower = getDefaultNetOpts('txpower','dbm'); $managedModeEnabled = false; @@ -38,8 +38,8 @@ function DisplayHostAPDConfig() } else { $interface = $_SESSION['ap_interface']; } - $txpower = $hostapd->getTxPower($interface); - $arrHostapdConf = $hostapd->getHostapdIni(); + $txpower = $hotspot->getTxPower($interface); + $arrHostapdConf = $hotspot->getHostapdIni(); if (!RASPI_MONITOR_ENABLED) { if (isset($_POST['StartHotspot']) || isset($_POST['RestartHotspot'])) { @@ -61,7 +61,7 @@ function DisplayHostAPDConfig() $status->addMessage($line, 'info'); } } elseif (isset($_POST['SaveHostAPDSettings'])) { - saveHostapdConfig($arrSecurity, $arrEncType, $arr80211Standard, $interfaces, $reg_domain, $status); + $hotspot->saveSettings($_POST, $arrSecurity, $arrEncType, $arr80211Standard, $interfaces, $reg_domain, $status); } elseif (isset($_POST['StopHotspot'])) { $status->addMessage('Attempting to stop hotspot', 'info'); exec('sudo /bin/systemctl stop hostapd.service', $return); @@ -82,9 +82,9 @@ function DisplayHostAPDConfig() if (isset($_POST['txpower'])) { if ($_POST['txpower'] != 'auto') { $txpower = intval($_POST['txpower']); - $hostapd->maybeSetTxPower($interface, $txpower, $status); + $hotspot->maybeSetTxPower($interface, $txpower, $status); } elseif ($_POST['txpower'] == 'auto') { - $hostapd->maybeSetTxPower($interface, 'auto', $status); + $hotspot->maybeSetTxPower($interface, 'auto', $status); } $txpower = $_POST['txpower']; } @@ -127,96 +127,3 @@ function DisplayHostAPDConfig() ); } -/** - * Validates user input + saves configs for hostapd, dnsmasq & dhcp - * - * @param array $wpa_array - * @param array $enc_types - * @param array $modes - * @param string $interface - * @param string $reg_domain - * @param object $status - * @return boolean - */ -function saveHostapdConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_domain, $status) -{ - $hostapd = new HostapdManager(); - $dnsmasq = new DnsmasqManager(); - $dhcpcd = new DhcpcdManager(); - $arrHostapdConf = $hostapd->getHostapdIni(); - $dualAPEnable = false; - - // derive mode states - $states = $hostapd->deriveModeStates($_POST, $arrHostapdConf); - - // determine base interface (validated or fallback) - $baseIface = validateInterface($_POST['interface']) ? $_POST['interface'] : RASPI_WIFI_AP_INTERFACE; - - // derive interface roles - [$apIface, $cliIface, $sessionIface] = $hostapd->deriveInterfaces($baseIface, $states); - - // persist hostapd.ini - $hostapd->persistHostapdIni($states, $apIface, $cliIface, $arrHostapdConf); - - // store session (compatibility) - $_SESSION['ap_interface'] = $sessionIface; - - // validate config from $_POST - $validated = $hostapd->validate($_POST, $wpa_array, $enc_types, $modes, $interfaces, $reg_domain, $status); - - if ($validated !== false) { - try { - // normalize state flags - $validated['interface'] = $apIface; - $validated['bridge'] = !empty($states['BridgedEnable']); - $validated['apsta'] = !empty($states['WifiAPEnable']); - $validated['repeater'] = !empty($states['RepeaterEnable']); - $validated['dualmode'] = !empty($states['DualAPEnable']); - $validated['txpower'] = $txpower; - - // hostapd - $config = $hostapd->buildConfig($validated, $status); - $hostapd->saveConfig($config, $dualAPEnable, $validated['interface']); - $status->addMessage('WiFi hotspot settings saved.', 'success'); - - // dnsmasq - try { - $syscfg = $dnsmasq->getConfig($ap_iface ?? RASPI_WIFI_AP_INTERFACE); - } catch (\RuntimeException $e) { - error_log('Error: ' . $e->getMessage()); - } - - try { - $dnsmasqConfig = $dnsmasq->buildConfig( - $syscfg, - $validated['interface'], - $validated['apsta'], - $validated['bridge'] - ); - $dnsmasq->saveConfig($dnsmasqConfig, $validated['interface']); - } catch (\RuntimeException $e) { - error_log('Error: ' . $e->getMessage()); - } - - // dhcpcd - try { - $return = $dhcpcd->buildConfig( - $validated['interface'], - $validated['bridge'], - $validated['repeater'], - $validated['apsta'], - $validated['dualmode'], - $status - ); - } catch (\RuntimeException $e) { - error_log('Error: ' . $e->getMessage()); - } - } catch (\Throwable $e) { - error_log('Error: ' . $e->getMessage()); - $status->addMessage('Unable to save WiFi hotspot settings', 'danger'); - } - } - - return true; -} - From cf32a4ba01a2af33eee81c293124d20b2a617e2d Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 02:06:40 -0700 Subject: [PATCH 26/44] Implement saveSettings(), consolidate hotspot functions + static methods --- .../Networking/Hotspot/HotspotService.php | 322 ++++++++++++++++-- 1 file changed, 303 insertions(+), 19 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/HotspotService.php b/src/RaspAP/Networking/Hotspot/HotspotService.php index cc331f2e..0b02af17 100644 --- a/src/RaspAP/Networking/Hotspot/HotspotService.php +++ b/src/RaspAP/Networking/Hotspot/HotspotService.php @@ -1,40 +1,324 @@ + * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE + */ + +declare(strict_types=1); + namespace RaspAP\Networking\Hotspot; use RaspAP\Networking\Hotspot\Validators\HostapdValidator; +use RaspAP\Messages\StatusMessage; + -/** - * Coordinates hotspot configuration and lifecycle - * - * Handles: - * - Hostapd & dnsmasq config updates - * - dhcpcd interface adjustments - * - Service control (start/stop/restart) - */ class HotspotService { - protected HostapdManager $hostapdManager; - protected DnsmasqManager $dnsmasqManager; - protected DhcpcdManager $dhcpcdManager; + protected HostapdManager $hostapd; + protected DnsmasqManager $dnsmasq; + protected DhcpcdManager $dhcpcd; + + // IEEE 802.11 standards + private const IEEE_80211_STANDARD = [ + 'a' => '802.11a - 5 GHz', + 'b' => '802.11b - 2.4 GHz', + 'g' => '802.11g - 2.4 GHz', + 'n' => '802.11n - 2.4/5 GHz', + 'ac' => '802.11ac - 5 GHz' + ]; + + // encryption types + private const ENC_TYPES = [ + 'TKIP' => 'TKIP', + 'CCMP' => 'CCMP', + 'TKIP CCMP' => 'TKIP+CCMP' + ]; + public function __construct() { - $this->hostapdManager = new HostapdManager(); - $this->dnsmasqManager = new DnsmasqManager(); - $this->dhcpcdManager = new DhcpcdManager(); + $this->hostapd = new HostapdManager(); + $this->dnsmasq = new DnsmasqManager(); + $this->dhcpcd = new DhcpcdManager(); } /** - * Apply configuration changes for hotspot. + * Returns IEEE 802.11 standards + */ + public static function get80211Standards(): array + { + return self::IEEE_80211_STANDARD; + } + + /** + * Returns encryption types + */ + public static function getEncTypes(): array + { + return self::ENC_TYPES; + } + + /** + * Returns translated security modes. + */ + public static function getSecurityModes(): array + { + // Build each call to ensure translation occurs under current locale. + return [ + 1 => 'WPA', + 2 => 'WPA2', + 3 => _('WPA and WPA2'), + 4 => _('WPA2 and WPA3-Personal (transitional mode)'), + 5 => 'WPA3-Personal (required)', + 'none' => _('None'), + ]; + } + + /** + * Returns translated 802.11w options + */ + public static function get80211wOptions(): array + { + return [ + 3 => _('Disabled'), + 1 => _('Enabled (for supported clients)'), + 2 => _('Required (for supported clients)'), + ]; + } + + + /** + * Validates user input + saves configs for hostapd, dnsmasq & dhcp * - * @param array $params + * @param array $wpa_array + * @param array $enc_types + * @param array $modes + * @param array $interfaces + * @param string $reg_domain + * @param StatusMessage $status * @return bool */ - public function configureHotspot(array $params): bool + public function saveSettings( + array $post_data, + array $wpa_array, + array $enc_types, + array $modes, + array $interfaces, + string $reg_domain, + StatusMessage $status): bool { - // TODO: validate params, orchestrate managers - return false; + $arrHostapdConf = $this->getHostapdIni(); + $dualAPEnable = false; + + // derive mode states + $states = $this->hostapd->deriveModeStates($post_data, $arrHostapdConf); + + // determine base interface (validated or fallback) + $baseIface = validateInterface($post_data['interface']) ? $post_data['interface'] : RASPI_WIFI_AP_INTERFACE; + + // derive interface roles + [$apIface, $cliIface, $sessionIface] = $this->hostapd->deriveInterfaces($baseIface, $states); + + // persist hostapd.ini + $this->hostapd->persistHostapdIni($states, $apIface, $cliIface, $arrHostapdConf); + + // store session (compatibility) + $_SESSION['ap_interface'] = $sessionIface; + + // validate config from post data + $validated = $this->hostapd->validate($post_data, $wpa_array, $enc_types, $modes, $interfaces, $reg_domain, $status); + + if ($validated !== false) { + try { + // normalize state flags + $validated['interface'] = $apIface; + $validated['bridge'] = !empty($states['BridgedEnable']); + $validated['apsta'] = !empty($states['WifiAPEnable']); + $validated['repeater'] = !empty($states['RepeaterEnable']); + $validated['dualmode'] = !empty($states['DualAPEnable']); + $validated['txpower'] = $post_data['txpower']; + + // hostapd + $config = $this->hostapd->buildConfig($validated, $status); + $this->hostapd->saveConfig($config, $dualAPEnable, $validated['interface']); + $this->maybeSetRegDomain($post_data['country_code'], $status); + + $status->addMessage('WiFi hotspot settings saved.', 'success'); + + // dnsmasq + try { + $syscfg = $this->dnsmasq->getConfig($validated['interface'] ?? RASPI_WIFI_AP_INTERFACE); + } catch (\RuntimeException $e) { + error_log('Error: ' . $e->getMessage()); + } + + try { + $dnsmasqConfig = $this->dnsmasq->buildConfig( + $syscfg, + $validated['interface'], + $validated['apsta'], + $validated['bridge'] + ); + $this->dnsmasq->saveConfig($dnsmasqConfig, $validated['interface']); + } catch (\RuntimeException $e) { + error_log('Error: ' . $e->getMessage()); + } + + // dhcpcd + try { + $return = $this->dhcpcd->buildConfig( + $validated['interface'], + $validated['bridge'], + $validated['repeater'], + $validated['apsta'], + $validated['dualmode'], + $status + ); + } catch (\RuntimeException $e) { + error_log('Error: ' . $e->getMessage()); + } + } catch (\Throwable $e) { + error_log(sprintf( + "Error: %s in %s on line %d\nStack trace:\n%s", + $e->getMessage(), + $e->getFile(), + $e->getLine(), + $e->getTraceAsString() + )); + $status->addMessage('Unable to save WiFi hotspot settings', 'danger'); + } + } + + return true; + } + + /** + * Gets system hostapd.ini + * + * @return array $config + */ + public function getHostapdIni() + { + $hostapdIni = RASPI_CONFIG . '/hostapd.ini'; + if (file_exists($hostapdIni)) { + $config = parse_ini_file($hostapdIni); + return $config; + } + } + + /** + * Sets transmit power for an interface + * + * @param string $iface + * @param int|string $dbm + * @param StatusMessage $status + * @return bool + */ + public function maybeSetTxPower(string $iface, $dbm, StatusMessage $status): bool + { + $currentTxPower = $this->getTxPower($iface); + + if ($currentTxPower === $dbm) { + return true; + } + + if ($dbm === 'auto') { + exec('sudo /sbin/iw dev ' . escapeshellarg($iface) . ' set txpower auto', $return); + $status->addMessage('Setting transmit power to auto.', 'success'); + } else { + $sdBm = (int)$dbm * 100; + exec('sudo /sbin/iw dev ' . escapeshellarg($iface) . ' set txpower fixed ' . $sdBm, $return); + $status->addMessage('Setting transmit power to ' . $dbm . ' dBm.', 'success'); + } + return true; + } + + /** + * Gets transmit power for an interface + * + * @param string $iface + * @return int + */ + public function getTxPower(string $iface): int + { + $cmd = "iw dev ".escapeshellarg($iface)." info | awk '$1==\"txpower\" {print $2}'"; + exec($cmd, $txpower); + return intval($txpower[0]); + } + + /** + * Sets a new regulatory domain if value has changed + * + * @param string $countryCode + * @return bool + */ + public function maybeSetRegDomain($countryCode, StatusMessage $status): bool + { + $currentDomain = $this->getRegDomain(); + if (trim($countryCode) !== trim($currentDomain)) { + $result = $this->setRegDomain($countryCode, $status); + if ($result !== true) { + return false; + } + } + return true; + } + + /** + * Gets the current regulatory domain + * + * @return string + */ + public function getRegDomain(): string + { + $domain = shell_exec("iw reg get | grep -o 'country [A-Z]\{2\}' | awk 'NR==1{print $2}'"); + return $domain; + } + + /** + * Sets the specified wireless regulatory domain + * + * @param string $country_code ISO 2-letter country code + * @param object $status StatusMessage object + * @return boolean $result + */ + public function setRegDomain(string $country_code, StatusMessage $status): bool + { + $country_code = escapeshellarg($country_code); + exec("sudo iw reg set $country_code", $output, $result); + if ($result !== 0) { + return false; + } else { + return true; + } + } + + /** + * Enumerates available network interfaces + * + * @return array $interfaces + */ + public function getInterfaces(): array + { + exec("ip -o link show | awk -F': ' '{print $2}'", $interfaces); + + // filter out loopback, docker, bridges + other virtual interfaces + // that are incapable of hosting an AP + $interfaces = array_filter($interfaces, function ($iface) { + return !preg_match('/^(lo|docker|br-|veth|tun|tap|tailscale)/', $iface); + }); + sort($interfaces); + + return array_values($interfaces); } /** From cbc6ee74c38b63e9c366d975140ef1c846e12c40 Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 02:07:53 -0700 Subject: [PATCH 27/44] Consolidate non-hostapd concerns under HotspotService class --- .../Networking/Hotspot/HostapdManager.php | 212 ++---------------- 1 file changed, 15 insertions(+), 197 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/HostapdManager.php b/src/RaspAP/Networking/Hotspot/HostapdManager.php index cbd9491b..593e93de 100644 --- a/src/RaspAP/Networking/Hotspot/HostapdManager.php +++ b/src/RaspAP/Networking/Hotspot/HostapdManager.php @@ -1,39 +1,26 @@ + * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE + */ + +declare(strict_types=1); + namespace RaspAP\Networking\Hotspot; use RaspAP\Networking\Hotspot\Validators\HostapdValidator; use RaspAP\Messages\StatusMessage; -/** - * Hostapd manager class for RaspAP - * - * @description Manages hostapd configurations and runtime settings - * @author Bill Zimmerman - * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE - */ class HostapdManager { private const CONF_DEFAULT = RASPI_HOSTAPD_CONFIG; private const CONF_PATH_PREFIX = '/etc/hostapd/hostapd-'; private const CONF_TMP = '/tmp/hostapddata'; - // IEEE 802.11 standards - private const IEEE_80211_STANDARD = [ - 'a' => '802.11a - 5 GHz', - 'b' => '802.11b - 2.4 GHz', - 'g' => '802.11g - 2.4 GHz', - 'n' => '802.11n - 2.4/5 GHz', - 'ac' => '802.11ac - 5 GHz' - ]; - - // encryption types - private const ENC_TYPES = [ - 'TKIP' => 'TKIP', - 'CCMP' => 'CCMP', - 'TKIP CCMP' => 'TKIP+CCMP' - ]; - /** @var HostapdValidator */ private $validator; @@ -42,50 +29,6 @@ class HostapdManager $this->validator = $validator ?: new HostapdValidator(); } - /** - * Returns IEEE 802.11 standards - */ - public static function get80211Standards(): array - { - return self::IEEE_80211_STANDARD; - } - - /** - * Returns encryption types - */ - public static function getEncTypes(): array - { - return self::ENC_TYPES; - } - - /** - * Returns translated security modes. - */ - public static function getSecurityModes(): array - { - // Build each call to ensure translation occurs under current locale. - return [ - 1 => 'WPA', - 2 => 'WPA2', - 3 => _('WPA and WPA2'), - 4 => _('WPA2 and WPA3-Personal (transitional mode)'), - 5 => 'WPA3-Personal (required)', - 'none' => _('None'), - ]; - } - - /** - * Returns translated 802.11w options - */ - public static function get80211wOptions(): array - { - return [ - 3 => _('Disabled'), - 1 => _('Enabled (for supported clients)'), - 2 => _('Required (for supported clients)'), - ]; - } - /** * Retrieves current hostapd config * @@ -141,7 +84,6 @@ class HostapdManager $config['wep_default_key'] ??= 0; return $config; - } /** @@ -288,8 +230,6 @@ class HostapdManager $config[] = 'max_num_sta=' . (int)$params['max_num_sta']; } - $result = $this->maybeSetRegDomain($params['country_code'], $status); - // optional additional user config $config[] = $this->parseUserHostapdCfg(); @@ -469,20 +409,6 @@ class HostapdManager throw new \RuntimeException("Failed to restart hostapd (tried instance + fallback)."); } - /** - * Gets system hostapd.ini - * - * @return array $config - */ - public function getHostapdIni() - { - $hostapdIni = RASPI_CONFIG . '/hostapd.ini'; - if (file_exists($hostapdIni)) { - $config = parse_ini_file($hostapdIni); - return $config; - } - } - /** * Persist hostapd.ini with mode / interface user settings * @@ -499,11 +425,11 @@ class HostapdManager // compose new ini payload $cfg = [ 'WifiInterface' => $apIface, - 'LogEnable' => $states['LogEnable'], - 'WifiAPEnable' => $states['WifiAPEnable'], - 'BridgedEnable' => $states['BridgedEnable'], - 'RepeaterEnable' => $states['RepeaterEnable'], - 'DualAPEnable' => $states['DualAPEnable'], + 'LogEnable' => $states['LogEnable'] ?? false, + 'WifiAPEnable' => $states['WifiAPEnable'] ?? false, + 'BridgedEnable' => $states['BridgedEnable'] ?? false, + 'RepeaterEnable' => $states['RepeaterEnable'] ?? false, + 'DualAPEnable' => $states['DualAPEnable'] ?? false, 'WifiManaged' => $cliIface ]; foreach ($previousIni as $k => $v) { @@ -525,114 +451,6 @@ class HostapdManager exec('sudo ' . RASPI_CONFIG . '/hostapd/' . $script); } - /** - * Sets transmit power for an interface - * - * @param string $iface - * @param int|string $dbm - * @param StatusMessage $status - * @return bool - */ - public function maybeSetTxPower(string $iface, $dbm, StatusMessage $status): bool - { - $currentTxPower = $this->getTxPower($iface); - - if ($currentTxPower === $dbm) { - return true; - } - - if ($dbm === 'auto') { - exec('sudo /sbin/iw dev ' . escapeshellarg($iface) . ' set txpower auto', $return); - $status->addMessage('Setting transmit power to auto.', 'success'); - } else { - $sdBm = (int)$dbm * 100; - exec('sudo /sbin/iw dev ' . escapeshellarg($iface) . ' set txpower fixed ' . $sdBm, $return); - $status->addMessage('Setting transmit power to ' . $dbm . ' dBm.', 'success'); - } - return true; - } - - /** - * Gets transmit power for an interface - * - * @param string $iface - * @return string - */ - public function getTxPower(string $iface): string - { - $cmd = "iw dev ".escapeshellarg($iface)." info | awk '$1==\"txpower\" {print $2}'"; - exec($cmd, $txpower); - return intval($txpower[0]); - } - - /** - * Sets a new regulatory domain if value has changed - * - * @param string $countryCode - * @return bool - */ - public function maybeSetRegDomain($countryCode, StatusMessage $status): bool - { - $currentDomain = $this->getRegDomain(); - if (trim($countryCode) !== trim($currentDomain)) { - $result = $this->setRegDomain($countryCode, $status); - if ($result !== true) { - return false; - } - } - return true; - } - - /** - * Gets the current regulatory domain - * - * @return string - */ - public function getRegDomain(): string - { - $domain = shell_exec("iw reg get | grep -o 'country [A-Z]\{2\}' | awk 'NR==1{print $2}'"); - return $domain; - } - - /** - * Sets the specified wireless regulatory domain - * - * @param string $country_code ISO 2-letter country code - * @param object $status StatusMessage object - * @return boolean $result - */ - public function setRegDomain(string $country_code, StatusMessage $status): bool - { - $country_code = escapeshellarg($country_code); - exec("sudo iw reg set $country_code", $output, $result); - if ($result !== 0) { - $status->addMessage(sprintf(_('Unable to set wireless regulatory domain to %s'), $country_code, 'warning')); - return false; - } else { - $status->addMessage(sprintf(_('Setting wireless regulatory domain to %s'), $country_code, 'success')); - return true; - } - } - - /** - * Enumerates available network interfaces - * - * @return array $interfaces - */ - public function getInterfaces(): array - { - exec("ip -o link show | awk -F': ' '{print $2}'", $interfaces); - - // filter out loopback, docker, bridges + other virtual interfaces - // that are incapable of hosting an AP - $interfaces = array_filter($interfaces, function ($iface) { - return !preg_match('/^(lo|docker|br-|veth|tun|tap|tailscale)/', $iface); - }); - sort($interfaces); - - return array_values($interfaces); - } - /** * Returns a count of hostapd-.conf files * From 9df3baa5f1fc7c14bc6a17b55c32042d0c7becc8 Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 02:08:33 -0700 Subject: [PATCH 28/44] Minor: standardize comment header, declare strict_types=1 --- src/RaspAP/Networking/Hotspot/DhcpcdManager.php | 17 ++++++++++++----- .../Networking/Hotspot/DnsmasqManager.php | 7 ++++--- .../Hotspot/Validators/HostapdValidator.php | 11 +++++++---- src/RaspAP/Networking/Hotspot/WiFiManager.php | 10 ++++++---- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/DhcpcdManager.php b/src/RaspAP/Networking/Hotspot/DhcpcdManager.php index 9e397112..50fc9433 100644 --- a/src/RaspAP/Networking/Hotspot/DhcpcdManager.php +++ b/src/RaspAP/Networking/Hotspot/DhcpcdManager.php @@ -1,12 +1,19 @@ + * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE + */ + +declare(strict_types=1); + namespace RaspAP\Networking\Hotspot; use RaspAP\Messages\StatusMessage; -/** - * Handles dhcpcd.conf interface configuration. - */ class DhcpcdManager { private const CONF_DEFAULT = RASPI_DHCPCD_CONFIG; @@ -99,6 +106,7 @@ class DhcpcdManager ); } $dhcp_cfg = file_get_contents(SELF::CONF_DEFAULT); + $skip_dhcp = false; if (preg_match('/wlan[3-9]\d*|wlan[1-9]\d+/', $ap_iface)) { $skip_dhcp = true; @@ -280,8 +288,7 @@ class DhcpcdManager $lines = []; exec('cat ' . escapeshellarg($dnsmasqFile), $lines); if (!function_exists('ParseConfig')) { - // ensure legacy parser available - require_once RASPI_CONFIG . '/functions.php'; + require_once 'includes/functions.php'; } $conf = ParseConfig($lines); diff --git a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php index 94a45803..0b4cd81b 100644 --- a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php +++ b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php @@ -1,7 +1,5 @@ * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE */ + +declare(strict_types=1); + +namespace RaspAP\Networking\Hotspot\Validators; + +use RaspAP\Messages\StatusMessage; + class HostapdValidator { diff --git a/src/RaspAP/Networking/Hotspot/WiFiManager.php b/src/RaspAP/Networking/Hotspot/WiFiManager.php index 4e2e4a9f..bc07fe1d 100644 --- a/src/RaspAP/Networking/Hotspot/WiFiManager.php +++ b/src/RaspAP/Networking/Hotspot/WiFiManager.php @@ -1,14 +1,16 @@ * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE */ +declare(strict_types=1); + +namespace RaspAP\Networking\Hotspot; + class WiFiManager { From ed1938d10b977e4b5238f235ee4aab7537b53b8a Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 06:35:19 -0700 Subject: [PATCH 29/44] Migrate validate, remove + removeIface methods from global function defs --- .../Networking/Hotspot/DhcpcdManager.php | 96 ++++++++++++++++++- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/DhcpcdManager.php b/src/RaspAP/Networking/Hotspot/DhcpcdManager.php index 50fc9433..19dd5678 100644 --- a/src/RaspAP/Networking/Hotspot/DhcpcdManager.php +++ b/src/RaspAP/Networking/Hotspot/DhcpcdManager.php @@ -116,13 +116,13 @@ class DhcpcdManager } elseif (!preg_match('/^interface\s'.$ap_iface.'$/m', $dhcp_cfg)) { $config[] = PHP_EOL; $config= join(PHP_EOL, $config); - $dhcp_cfg = removeDHCPIface($dhcp_cfg,'br0'); - $dhcp_cfg = removeDHCPIface($dhcp_cfg,'uap0'); + $dhcp_cfg = $this->removeIface($dhcp_cfg,'br0'); + $dhcp_cfg = $this->removeIface($dhcp_cfg,'uap0'); $dhcp_cfg .= $config; } else { $config = join(PHP_EOL, $config); - $dhcp_cfg = removeDHCPIface($dhcp_cfg,'br0'); - $dhcp_cfg = removeDHCPIface($dhcp_cfg,'uap0'); + $dhcp_cfg = $this->removeIface($dhcp_cfg,'br0'); + $dhcp_cfg = $this->removeIface($dhcp_cfg,'uap0'); if (!strpos($dhcp_cfg, 'metric')) { $dhcp_cfg = preg_replace('/^#\sRaspAP\s'.$ap_iface.'\s.*?(?=(?:\s*^\s*$|\s*nogateway))/ms', $config, $dhcp_cfg, 1); } else { @@ -143,6 +143,51 @@ class DhcpcdManager return true; } + /** + * Validates DHCP user input from $_POST data + * + * @param array $post_data + * @return array $errors + */ + public function validate(array $post_data): array + { + $errors = []; + define('IFNAMSIZ', 16); + $iface = $post_data['interface']; + if (!preg_match('/^[^\s\/\\0]+$/', $iface) + || strlen($iface) >= IFNAMSIZ + ) { + $errors[] = _('Invalid interface name.'); + } + if (!filter_var($post_data['StaticIP'], FILTER_VALIDATE_IP) && !empty($post_data['StaticIP'])) { + $errors[] = _('Invalid static IP address.'); + } + if (!filter_var($post_data['SubnetMask'], FILTER_VALIDATE_IP) && !empty($post_data['SubnetMask'])) { + $errors[] = _('Invalid subnet mask.'); + } + if (!filter_var($post_data['DefaultGateway'], FILTER_VALIDATE_IP) && !empty($post_data['DefaultGateway'])) { + $errors[] = _('Invalid default gateway.'); + } + if (($post_data['dhcp-iface'] == "1")) { + if (!filter_var($post_data['RangeStart'], FILTER_VALIDATE_IP) && !empty($post_data['RangeStart'])) { + $errors[] = _('Invalid DHCP range start.'); + } + if (!filter_var($post_data['RangeEnd'], FILTER_VALIDATE_IP) && !empty($post_data['RangeEnd'])) { + $errors[] = _('Invalid DHCP range end.'); + } + if (!ctype_digit($post_data['RangeLeaseTime']) && $post_data['RangeLeaseTimeUnits'] !== 'i') { + $errors[] = _('Invalid DHCP lease time, not a number.'); + } + if (!in_array($post_data['RangeLeaseTimeUnits'], array('m', 'h', 'd', 'i'))) { + $errors[] = _('Unknown DHCP lease time unit.'); + } + if ($post_data['Metric'] !== '' && !ctype_digit($post_data['Metric'])) { + $errors[] = _('Invalid metric value, not a number.'); + } + } + return $errors; + } + /** * Saves a dhcpcd configuration * @@ -166,6 +211,49 @@ class DhcpcdManager return true; } + /** + * Removes a dhcp configuration block for the specified interface + * + * @param string $iface + * @param StatusMessage $status + * @return bool $result + */ + public function remove(string $iface, StatusMessage $status): bool + { + $configFile = SELF::CONF_DEFAULT; + $tempFile = SELF::CONF_TMP; + + $dhcp_cfg = file_get_contents($configFile); + $modified_cfg = preg_replace('/^#\sRaspAP\s'.$iface.'\s.*?(?=\s*^\s*$)([\s]+)/ms', '', $dhcp_cfg, 1); + if ($modified_cfg !== $dhcp_cfg) { + file_put_contents($tempFile, $modified_cfg); + + $cmd = sprintf('sudo cp %s %s', escapeshellarg($tempFile), escapeshellarg($configFile)); + exec($cmd, $output, $result); + + if ($result == 0) { + $status->addMessage('DHCP configuration for '.$iface.' removed', 'success'); + return true; + } else { + $status->addMessage('Failed to remove DHCP configuration for '.$iface, 'danger'); + return false; + } + } + } + + /** + * Removes a dhcp configuration block for the specified interface + * + * @param string $dhcp_cfg + * @param string $iface + * @return string $dhcp_cfg + */ + public function removeIface(string $dhcp_cfg, string $iface): string + { + $dhcp_cfg = preg_replace('/^#\sRaspAP\s'.$iface.'\s.*?(?=\s*^\s*$)([\s]+)/ms', '', $dhcp_cfg, 1); + return $dhcp_cfg; + } + /** * Updates the dhcpcd configuration for a given interface, preserving existing settings * From 8f19d759f28e52e2eeb0826b822e7b877171434f Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 06:36:43 -0700 Subject: [PATCH 30/44] Consolidate functions in their respective dhcpcd + dnsmasq classes --- includes/functions.php | 71 ------------------------------------------ 1 file changed, 71 deletions(-) diff --git a/includes/functions.php b/includes/functions.php index 9541e307..8b388cf4 100755 --- a/includes/functions.php +++ b/includes/functions.php @@ -64,77 +64,6 @@ function cidr2mask($cidr) return $netmask; } -/** - * Removes a dhcp configuration block for the specified interface - * - * @param string $iface - * @param object $status - * @return boolean $result - */ -function removeDHCPConfig($iface,$status) -{ - $dhcp_cfg = file_get_contents(RASPI_DHCPCD_CONFIG); - $dhcp_cfg = preg_replace('/^#\sRaspAP\s'.$iface.'\s.*?(?=\s*^\s*$)([\s]+)/ms', '', $dhcp_cfg, 1); - file_put_contents("/tmp/dhcpddata", $dhcp_cfg); - system('sudo cp /tmp/dhcpddata '.RASPI_DHCPCD_CONFIG, $result); - if ($result == 0) { - $status->addMessage('DHCP configuration for '.$iface.' removed.', 'success'); - } else { - $status->addMessage('Failed to remove DHCP configuration for '.$iface.'.', 'danger'); - return $result; - } -} - -/** - * Removes a dhcp configuration block for the specified interface - * - * @param string $dhcp_cfg - * @param string $iface - * @return string $dhcp_cfg - */ -function removeDHCPIface($dhcp_cfg,$iface) -{ - $dhcp_cfg = preg_replace('/^#\sRaspAP\s'.$iface.'\s.*?(?=\s*^\s*$)([\s]+)/ms', '', $dhcp_cfg, 1); - return $dhcp_cfg; -} - -/** - * Removes a dnsmasq configuration block for the specified interface - * - * @param string $iface - * @param object $status - * @return boolean $result - */ -function removeDnsmasqConfig($iface,$status) -{ - system('sudo rm '.RASPI_DNSMASQ_PREFIX.$iface.'.conf', $result); - if ($result == 0) { - $status->addMessage('Dnsmasq configuration for '.$iface.' removed.', 'success'); - } else { - $status->addMessage('Failed to remove dnsmasq configuration for '.$iface.'.', 'danger'); - } - return $result; -} - -/** - * Scans dnsmasq configuration dir for the specified interface - * Non-matching configs are removed, optional adblock.conf is protected - * - * @param string $dir_conf - * @param string $interface - * @param object $status - */ -function scanConfigDir($dir_conf,$interface,$status) -{ - $syscnf = preg_grep('~\.(conf)$~', scandir($dir_conf)); - foreach ($syscnf as $cnf) { - if ($cnf !== '090_adblock.conf' && !preg_match('/.*_'.$interface.'.conf/', $cnf)) { - system('sudo rm /etc/dnsmasq.d/'.$cnf, $result); - } - } - return $status; -} - /** * Returns a default (fallback) value for the selected service, interface & setting * from /etc/raspap/networking/defaults.json From 1e2f77abcbde2bb9f3e313d530ad857773bd7d1d Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 06:38:33 -0700 Subject: [PATCH 31/44] Added buildEx, buildDefault, saveConfigDefault, remove, scanConfigDir methods --- .../Networking/Hotspot/DnsmasqManager.php | 203 +++++++++++++++++- 1 file changed, 197 insertions(+), 6 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php index 0b4cd81b..1fb1165c 100644 --- a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php +++ b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php @@ -12,10 +12,14 @@ declare(strict_types=1); namespace RaspAP\Networking\Hotspot; +use RaspAP\Messages\StatusMessage; + class DnsmasqManager { + private const CONF_DEFAULT = '/etc/dnsmasq.d/'; private const CONF_SUFFIX = '.conf'; private const CONF_TMP = '/tmp/dnsmasqdata'; + private const CONF_RASPAP = '090_raspap'; /** * Retrieves dnsmasq configuration for an interface @@ -74,7 +78,7 @@ class DnsmasqManager $config[] = 'dhcp-option='.$syscfg['dhcp-option']; } $config[] = PHP_EOL; - scanConfigDir('/etc/dnsmasq.d/','uap0',$status); + $this->scanConfigDir(SELF::CONF_DEFAULT,'uap0',$status); } elseif ($bridgedEnable !==1) { $dhcp_range = ($syscfg['dhcp-range'] =='') ? getDefaultNetValue('dnsmasq',$iface,'dhcp-range') : $syscfg['dhcp-range']; $config = [ '# RaspAP '.$_POST['interface'].' configuration' ]; @@ -105,24 +109,105 @@ class DnsmasqManager return $config; } + /** + * Builds an extended dnsmasq configuration + * + * @param string $iface + * @param array $post_data + * @return string $config //todo: standardize return type as array + */ + public function buildEx(string $iface, array $post_data): string + { + $config = '# RaspAP '. $iface .' configuration'.PHP_EOL; + $config .= 'interface='. $iface . PHP_EOL .'dhcp-range='.$post_data['RangeStart'].','.$post_data['RangeEnd'].','.$post_data['SubnetMask'].','; + if ($post_data['RangeLeaseTimeUnits'] !== 'i') { + $config .= $post_data['RangeLeaseTime']; + $config .= $post_data['RangeLeaseTimeUnits'].PHP_EOL; + } else { + $config .= 'infinite'.PHP_EOL; + } + // Static leases + $staticLeases = array(); + if (isset($post_data["static_leases"]["mac"])) { + for ($i=0; $i < count($post_data["static_leases"]["mac"]); $i++) { + $mac = trim($post_data["static_leases"]["mac"][$i]); + $ip = trim($post_data["static_leases"]["ip"][$i]); + $comment = trim($post_data["static_leases"]["comment"][$i]); + if ($mac != "" && $ip != "") { + $staticLeases[] = array('mac' => $mac, 'ip' => $ip, 'comment' => $comment); + } + } + } + // Sort ascending by IPs + usort($staticLeases, [$this, 'compareIPs']); + // Update config + for ($i = 0; $i < count($staticLeases); $i++) { + $mac = $staticLeases[$i]['mac']; + $ip = $staticLeases[$i]['ip']; + $comment = $staticLeases[$i]['comment']; + $config .= "dhcp-host=$mac,$ip # $comment".PHP_EOL; + } + if ($post_data['no-resolv'] == "1") { + $config .= "no-resolv".PHP_EOL; + } + foreach ($post_data['server'] as $server) { + $config .= "server=$server".PHP_EOL; + } + if ($post_data['DNS1']) { + $config .= "dhcp-option=6," . $post_data['DNS1']; + if ($post_data['DNS2']) { + $config .= ','.$post_data['DNS2']; + } + $config .= PHP_EOL; + } + if ($post_data['dhcp-ignore'] == "1") { + $config .= 'dhcp-ignore=tag:!known'.PHP_EOL; + } + + return $config; + } + + /** + * Builds a RaspAP default dnsmasq config + * Written to 090_raspap.conf + * + * @return string $config //todo: standardize return type as array + */ + public function buildDefault(): string + { + $config = '# RaspAP default config'. PHP_EOL; + $config .='log-facility='. RASPI_DHCPCD_LOG . PHP_EOL; + $config .='conf-dir=/etc/dnsmasq.d'. PHP_EOL; + // handle log option + if (($post_data['log-dhcp'] ?? '') == "1") { + $config .= "log-dhcp".PHP_EOL; + } + if (($post_data['log-queries'] ?? '') == "1") { + $config .= "log-queries".PHP_EOL; + } + $config .= PHP_EOL; + + return $config; + } + /** * Saves dnsmasq configuration for an interface * - * @param array $config + * @param string $config * @param string $iface * @return bool */ - public function saveConfig(array $config, string $iface = self::DEFAULT_IFACE): bool + public function saveConfig(string $config, string $iface): bool { - $configFile = RASPI_DNSMASQ_PREFIX . $iface . self::CONF_SUFFIX; + $configFile = RASPI_DNSMASQ_PREFIX . $iface . SELF::CONF_SUFFIX; $tempFile = SELF::CONF_TMP; - $config = join(PHP_EOL, $config); file_put_contents($tempFile, $config); $cmd = sprintf('sudo cp %s %s', escapeshellarg($tempFile), escapeshellarg($configFile)); exec($cmd, $output, $status); if ($status !== 0) { throw new \RuntimeException("Failed to copy temp config to $configFile"); + return false; } // reload dnsmasq to apply changes @@ -134,6 +219,113 @@ class DnsmasqManager return true; } + /** + * Saves dnsmasq default configuration + * + * @param string $config + * @return bool + */ + public function saveConfigDefault(string $config): bool + { + $configFile = SELF::CONF_DEFAULT . SELF::CONF_RASPAP . SELF::CONF_SUFFIX; + $tempFile = SELF::CONF_TMP; + + file_put_contents($tempFile, $config); + $cmd = sprintf('sudo cp %s %s', escapeshellarg($tempFile), escapeshellarg($configFile)); + exec($cmd, $output, $status); + if ($status !== 0) { + throw new \RuntimeException("Failed to copy temp config to $configFile"); + return false; + } + + // reload dnsmasq to apply changes + exec('sudo systemctl reload dnsmasq.service', $output, $status); + if ($status !== 0) { + throw new \RuntimeException("Failed to reload dnsmasq service"); + } + + return true; + } + + /** + * Validates dnsmasq user input from $_POST object + * + * @param array $post_data + * @return array $errors + */ + public function validate(array $post_data): array + { + $errors = []; + $encounteredIPs = []; + + if (isset($post_data["static_leases"]["mac"])) { + for ($i=0; $i < count($post_data["static_leases"]["mac"]); $i++) { + $mac = trim($post_data["static_leases"]["mac"][$i]); + $ip = trim($post_data["static_leases"]["ip"][$i]); + if (!validateMac($mac)) { + $errors[] = _('Invalid MAC address: '.$mac); + } + if (in_array($ip, $encounteredIPs)) { + $errors[] = _('Duplicate IP address entered: ' . $ip); + } else { + $encounteredIPs[] = $ip; + } + } + } + return $errors; + } + + /** + * Removes a configuration block for the specified interface + * + * @param string $iface + * @param StatusMessage $status + * @return bool $result + */ + public function remove(string $iface, StatusMessage $status): bool + { + system('sudo rm '.RASPI_DNSMASQ_PREFIX.$iface.'.conf', $result); + if ($result == 0) { + $status->addMessage('Dnsmasq configuration for '.$iface.' removed.', 'success'); + } else { + $status->addMessage('Failed to remove dnsmasq configuration for '.$iface.'.', 'danger'); + } + return $result; + } + + /** + * Scans configuration dir for the specified interface + * Non-matching configs are removed, optional adblock.conf is protected + * + * @param string $dir_conf + * @param string $interface + * @return bool + */ + public function scanConfigDir(string $dir_conf, string $interface): bool + { + $syscnf = preg_grep('~\.(conf)$~', scandir($dir_conf)); + foreach ($syscnf as $cnf) { + if ($cnf !== '090_adblock.conf' && !preg_match('/.*_'.$interface.'.conf/', $cnf)) { + system('sudo rm /etc/dnsmasq.d/'.$cnf, $result); + return true; + } + } + } + + /** + * Compares two IPs + * + * @param array $ip1 + * @param array $ip2 + * @return int + */ + private function compareIPs(array $ip1, array $ip2): int + { + $ipu1 = sprintf('%u', ip2long($ip1["ip"])) + 0; + $ipu2 = sprintf('%u', ip2long($ip2["ip"])) + 0; + return $ipu1 <=> $ipu2; + } + /** * Add static DHCP lease * @@ -145,7 +337,6 @@ class DnsmasqManager */ public function addStaticLease(string $iface, string $mac, string $ip, ?string $comment = null): bool { - // TODO: append to conf return false; } } From 83ab53dd87b84fa9c7f3b16b55828449816c68c1 Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 06:39:59 -0700 Subject: [PATCH 32/44] WIP: refactored to use dnsmasq + dhcpcd manager classes --- includes/dhcp.php | 199 ++++------------------------------------------ 1 file changed, 17 insertions(+), 182 deletions(-) diff --git a/includes/dhcp.php b/includes/dhcp.php index 4c35fac1..3747fa59 100755 --- a/includes/dhcp.php +++ b/includes/dhcp.php @@ -2,11 +2,13 @@ require_once 'config.php'; +use RaspAP\Networking\Hotspot\DhcpcdManager; +use RaspAP\Networking\Hotspot\DnsmasqManager; use RaspAP\Networking\Hotspot\WiFiManager; use RaspAP\Messages\StatusMessage; /** - * Manage DHCP configuration + * Displays DHCP configuration */ function DisplayDHCPConfig() { @@ -91,32 +93,38 @@ function DisplayDHCPConfig() */ function saveDHCPConfig($status) { + $dhcpcd = new DhcpcdManager(); + $dnsmasq = new DnsmasqManager(); $iface = $_POST['interface']; $return = 1; - // handle disable dhcp option + // dhcp if (!isset($_POST['dhcp-iface']) && file_exists(RASPI_DNSMASQ_PREFIX.$iface.'.conf')) { // remove dhcp + dnsmasq configs for selected interface - $return = removeDHCPConfig($iface,$status); - $return = removeDnsmasqConfig($iface,$status); + $return = $dhcpcd->remove($iface, $status); + $return = $dnsmasq->remove($iface, $status); } else { - $errors = validateDHCPInput(); + $errors = $dhcpcd->validate($_POST); if (empty($errors)) { - $return = updateDHCPConfig($iface,$status); + $return = updateDHCPConfig($iface, $status); } else { foreach ($errors as $error) { $status->addMessage($error, 'danger'); } } if ($return == 1) { - $status->addMessage('Dnsmasq configuration failed to be updated.', 'danger'); + $status->addMessage('DHCP configuration failed to be updated.', 'danger'); return false; } + // dnsmasq if (($_POST['dhcp-iface'] == "1") || (isset($_POST['mac']))) { - $errors = validateDnsmasqInput(); + $errors = $dnsmasq->validate($_POST); if (empty($errors)) { - $return = updateDnsmasqConfig($iface,$status); + $config = $dnsmasq->buildEx($iface, $_POST); + $return = $dnsmasq->saveConfig($config, $iface); + $config = $dnsmasq->buildDefault(); + $return = $dnsmasq->saveConfigDefault($config); } else { foreach ($errors as $error) { $status->addMessage($error, 'danger'); @@ -124,183 +132,10 @@ function saveDHCPConfig($status) $return = 1; } } - - if ($return == 0) { - $status->addMessage('Dnsmasq configuration updated successfully.', 'success'); - } else { - $status->addMessage('Dnsmasq configuration failed to be updated.', 'danger'); - return false; - } return true; } } -/** - * Validates DHCP user input from the $_POST object - * - * @return array $errors - */ -function validateDHCPInput() -{ - $errors = []; - define('IFNAMSIZ', 16); - $iface = $_POST['interface']; - if (!preg_match('/^[^\s\/\\0]+$/', $iface) - || strlen($iface) >= IFNAMSIZ - ) { - $errors[] = _('Invalid interface name.'); - } - if (!filter_var($_POST['StaticIP'], FILTER_VALIDATE_IP) && !empty($_POST['StaticIP'])) { - $errors[] = _('Invalid static IP address.'); - } - if (!filter_var($_POST['SubnetMask'], FILTER_VALIDATE_IP) && !empty($_POST['SubnetMask'])) { - $errors[] = _('Invalid subnet mask.'); - } - if (!filter_var($_POST['DefaultGateway'], FILTER_VALIDATE_IP) && !empty($_POST['DefaultGateway'])) { - $errors[] = _('Invalid default gateway.'); - } - if (($_POST['dhcp-iface'] == "1")) { - if (!filter_var($_POST['RangeStart'], FILTER_VALIDATE_IP) && !empty($_POST['RangeStart'])) { - $errors[] = _('Invalid DHCP range start.'); - } - if (!filter_var($_POST['RangeEnd'], FILTER_VALIDATE_IP) && !empty($_POST['RangeEnd'])) { - $errors[] = _('Invalid DHCP range end.'); - } - if (!ctype_digit($_POST['RangeLeaseTime']) && $_POST['RangeLeaseTimeUnits'] !== 'i') { - $errors[] = _('Invalid DHCP lease time, not a number.'); - } - if (!in_array($_POST['RangeLeaseTimeUnits'], array('m', 'h', 'd', 'i'))) { - $errors[] = _('Unknown DHCP lease time unit.'); - } - if ($_POST['Metric'] !== '' && !ctype_digit($_POST['Metric'])) { - $errors[] = _('Invalid metric value, not a number.'); - } - } - return $errors; -} - -/** - * Compares to string IPs - * - * @param string $ip1 - * @param string $ip2 - * @return boolean $result - */ -function compareIPs($ip1, $ip2) -{ - $ipu1 = sprintf('%u', ip2long($ip1["ip"])) + 0; - $ipu2 = sprintf('%u', ip2long($ip2["ip"])) + 0; - return $ipu1 > $ipu2; -} - -/** - * Validates Dnsmasq user input from the $_POST object - * - * @return array $errors - */ -function validateDnsmasqInput() -{ - $errors = []; - $encounteredIPs = []; - - if (isset($_POST["static_leases"]["mac"])) { - for ($i=0; $i < count($_POST["static_leases"]["mac"]); $i++) { - $mac = trim($_POST["static_leases"]["mac"][$i]); - $ip = trim($_POST["static_leases"]["ip"][$i]); - if (!validateMac($mac)) { - $errors[] = _('Invalid MAC address: '.$mac); - } - if (in_array($ip, $encounteredIPs)) { - $errors[] = _('Duplicate IP address entered: ' . $ip); - } else { - $encounteredIPs[] = $ip; - } - } - } - return $errors; -} - - -/** - * Updates a dnsmasq configuration - * - * @param string $iface - * @param object $status - * @return boolean $result - */ -function updateDnsmasqConfig($iface,$status) -{ - - $config = '# RaspAP '.$iface.' configuration'.PHP_EOL; - $config .= 'interface='.$iface.PHP_EOL.'dhcp-range='.$_POST['RangeStart'].','.$_POST['RangeEnd'].','.$_POST['SubnetMask'].','; - if ($_POST['RangeLeaseTimeUnits'] !== 'i') { - $config .= $_POST['RangeLeaseTime']; - $config .= $_POST['RangeLeaseTimeUnits'].PHP_EOL; - } else { - $config .= 'infinite'.PHP_EOL; - } - // Static leases - $staticLeases = array(); - if (isset($_POST["static_leases"]["mac"])) { - for ($i=0; $i < count($_POST["static_leases"]["mac"]); $i++) { - $mac = trim($_POST["static_leases"]["mac"][$i]); - $ip = trim($_POST["static_leases"]["ip"][$i]); - $comment = trim($_POST["static_leases"]["comment"][$i]); - if ($mac != "" && $ip != "") { - $staticLeases[] = array('mac' => $mac, 'ip' => $ip, 'comment' => $comment); - } - } - } - // Sort ascending by IPs - usort($staticLeases, "compareIPs"); - // Update config - for ($i = 0; $i < count($staticLeases); $i++) { - $mac = $staticLeases[$i]['mac']; - $ip = $staticLeases[$i]['ip']; - $comment = $staticLeases[$i]['comment']; - $config .= "dhcp-host=$mac,$ip # $comment".PHP_EOL; - } - if ($_POST['no-resolv'] == "1") { - $config .= "no-resolv".PHP_EOL; - } - foreach ($_POST['server'] as $server) { - $config .= "server=$server".PHP_EOL; - } - if ($_POST['DNS1']) { - $config .= "dhcp-option=6," . $_POST['DNS1']; - if ($_POST['DNS2']) { - $config .= ','.$_POST['DNS2']; - } - $config .= PHP_EOL; - } - if ($_POST['dhcp-ignore'] == "1") { - $config .= 'dhcp-ignore=tag:!known'.PHP_EOL; - } - file_put_contents("/tmp/dnsmasqdata", $config); - $msg = file_exists(RASPI_DNSMASQ_PREFIX.$iface.'.conf') ? 'updated' : 'added'; - system('sudo cp /tmp/dnsmasqdata '.RASPI_DNSMASQ_PREFIX.$iface.'.conf', $result); - if ($result == 0) { - $status->addMessage('Dnsmasq configuration for '.$iface.' '.$msg.'.', 'success'); - } - - // write default 090_raspap.conf - $config = '# RaspAP default config'.PHP_EOL; - $config .='log-facility='.RASPI_DHCPCD_LOG.PHP_EOL; - $config .='conf-dir=/etc/dnsmasq.d'.PHP_EOL; - // handle log option - if (($_POST['log-dhcp'] ?? '') == "1") { - $config .= "log-dhcp".PHP_EOL; - } - if (($_POST['log-queries'] ?? '') == "1") { - $config .= "log-queries".PHP_EOL; - } - $config .= PHP_EOL; - file_put_contents("/tmp/dnsmasqdata", $config); - system('sudo cp /tmp/dnsmasqdata '.RASPI_DNSMASQ_PREFIX.'raspap.conf', $result); - - return $result; -} - /** * Updates a dhcp configuration * From 98922434f27dafca497e0fb317177ddd5130ebe2 Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 08:07:51 -0700 Subject: [PATCH 33/44] Added buildConfigEx(), migrated from legacy controller --- .../Networking/Hotspot/DhcpcdManager.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/RaspAP/Networking/Hotspot/DhcpcdManager.php b/src/RaspAP/Networking/Hotspot/DhcpcdManager.php index 19dd5678..9b8f58db 100644 --- a/src/RaspAP/Networking/Hotspot/DhcpcdManager.php +++ b/src/RaspAP/Networking/Hotspot/DhcpcdManager.php @@ -143,6 +143,53 @@ class DhcpcdManager return true; } + /** + * (Re)builds an existing dhcp configuration + * + * @param string $iface + * @param StatusMessage $status + * @param array $post_data + * @return string $dhcp_cfg + */ + public function buildConfigEx(string $iface, array $post_data, StatusMessage $status): string + { + $cfg[] = '# RaspAP '.$iface.' configuration'; + $cfg[] = 'interface '.$iface; + if (isset($post_data['StaticIP']) && $post_data['StaticIP'] !== '') { + $mask = ($post_data['SubnetMask'] !== '' && $post_data['SubnetMask'] !== '0.0.0.0') ? '/'.mask2cidr($post_data['SubnetMask']) : null; + $cfg[] = 'static ip_address='.$post_data['StaticIP'].$mask; + } + if (isset($post_data['DefaultGateway']) && $post_data['DefaultGateway'] !== '') { + $cfg[] = 'static routers='.$post_data['DefaultGateway']; + } + if ($post_data['DNS1'] !== '' || $post_data['DNS2'] !== '') { + $cfg[] = 'static domain_name_server='.$post_data['DNS1'].' '.$post_data['DNS2']; + } + if ($post_data['Metric'] !== '') { + $cfg[] = 'metric '.$post_data['Metric']; + } + if (($post_data['Fallback'] ?? 0) == 1) { + $cfg[] = 'profile static_'.$iface; + $cfg[] = 'fallback static_'.$iface; + } + $cfg[] = ($post_data['DefaultRoute'] ?? '') == '1' ? 'gateway' : 'nogateway'; + if (substr($iface, 0, 2) === "wl" && ($post_data['NoHookWPASupplicant'] ?? '') == '1') { + $cfg[] = 'nohook wpa_supplicant'; + } + $dhcp_cfg = file_get_contents(RASPI_DHCPCD_CONFIG); + if (!preg_match('/^interface\s'.$iface.'$/m', $dhcp_cfg)) { + $cfg[] = PHP_EOL; + $cfg = join(PHP_EOL, $cfg); + $dhcp_cfg .= $cfg; + $status->addMessage('DHCP configuration for '.$iface.' added.', 'success'); + } else { + $cfg = join(PHP_EOL, $cfg); + $dhcp_cfg = preg_replace('/^#\sRaspAP\s'.$iface.'\s.*?(?=\s*^\s*$)/ms', $cfg, $dhcp_cfg, 1); + } + + return $dhcp_cfg; + } + /** * Validates DHCP user input from $_POST data * From 87f55c8b1e55738d47e33d86f942df1ead892504 Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 09:58:56 -0700 Subject: [PATCH 34/44] Standardize array return/input type for build + save config --- .../Networking/Hotspot/DnsmasqManager.php | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php index 1fb1165c..b55ebac4 100644 --- a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php +++ b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php @@ -85,6 +85,7 @@ class DnsmasqManager $config[] = 'interface='.$_POST['interface']; $config[] = 'domain-needed'; $config[] = 'dhcp-range='.$dhcp_range; + // handle multiple dhcp-host + option entries if (!empty($syscfg['dhcp-host'])) { if (is_array($syscfg['dhcp-host'])) { @@ -114,18 +115,19 @@ class DnsmasqManager * * @param string $iface * @param array $post_data - * @return string $config //todo: standardize return type as array + * @return array $config */ - public function buildEx(string $iface, array $post_data): string + public function buildConfigEx(string $iface, array $post_data): array { - $config = '# RaspAP '. $iface .' configuration'.PHP_EOL; - $config .= 'interface='. $iface . PHP_EOL .'dhcp-range='.$post_data['RangeStart'].','.$post_data['RangeEnd'].','.$post_data['SubnetMask'].','; - if ($post_data['RangeLeaseTimeUnits'] !== 'i') { - $config .= $post_data['RangeLeaseTime']; - $config .= $post_data['RangeLeaseTimeUnits'].PHP_EOL; - } else { - $config .= 'infinite'.PHP_EOL; - } + $config[] = '# RaspAP '. $iface .' configuration'; + $config[] = 'interface='. $iface; + $leaseTime = ($post_data['RangeLeaseTimeUnits'] !== 'i') + ? $post_data['RangeLeaseTime'] . $post_data['RangeLeaseTimeUnits'] + : 'infinite'; + $config[] = 'dhcp-range=' . $post_data['RangeStart'] . ',' . + $post_data['RangeEnd'] . ',' . + $post_data['SubnetMask'] . ',' . + $leaseTime; // Static leases $staticLeases = array(); if (isset($post_data["static_leases"]["mac"])) { @@ -145,23 +147,23 @@ class DnsmasqManager $mac = $staticLeases[$i]['mac']; $ip = $staticLeases[$i]['ip']; $comment = $staticLeases[$i]['comment']; - $config .= "dhcp-host=$mac,$ip # $comment".PHP_EOL; + $config[] = "dhcp-host=$mac,$ip # $comment"; } if ($post_data['no-resolv'] == "1") { - $config .= "no-resolv".PHP_EOL; + $config[] = "no-resolv"; } foreach ($post_data['server'] as $server) { - $config .= "server=$server".PHP_EOL; + $config[] = "server=$server"; } if ($post_data['DNS1']) { - $config .= "dhcp-option=6," . $post_data['DNS1']; + $config[] = "dhcp-option=6," . $post_data['DNS1']; if ($post_data['DNS2']) { - $config .= ','.$post_data['DNS2']; + $config[] = ','.$post_data['DNS2']; } - $config .= PHP_EOL; + $config[]= PHP_EOL; } if ($post_data['dhcp-ignore'] == "1") { - $config .= 'dhcp-ignore=tag:!known'.PHP_EOL; + $config[] = 'dhcp-ignore=tag:!known'; } return $config; @@ -171,21 +173,24 @@ class DnsmasqManager * Builds a RaspAP default dnsmasq config * Written to 090_raspap.conf * - * @return string $config //todo: standardize return type as array + * @param array $post_data + * @return array $config */ - public function buildDefault(): string + public function buildDefault(array $post_data): array { - $config = '# RaspAP default config'. PHP_EOL; - $config .='log-facility='. RASPI_DHCPCD_LOG . PHP_EOL; - $config .='conf-dir=/etc/dnsmasq.d'. PHP_EOL; + // preamble + $config[] = '# RaspAP default config'; + $config[] = 'log-facility='. RASPI_DHCPCD_LOG; + $config[] = 'conf-dir=/etc/dnsmasq.d'; + // handle log option if (($post_data['log-dhcp'] ?? '') == "1") { - $config .= "log-dhcp".PHP_EOL; + $config[] = "log-dhcp"; } if (($post_data['log-queries'] ?? '') == "1") { - $config .= "log-queries".PHP_EOL; + $config[] = "log-queries"; } - $config .= PHP_EOL; + $config[] = PHP_EOL; return $config; } @@ -193,15 +198,16 @@ class DnsmasqManager /** * Saves dnsmasq configuration for an interface * - * @param string $config + * @param array $config * @param string $iface * @return bool */ - public function saveConfig(string $config, string $iface): bool + public function saveConfig(array $config, string $iface): bool { $configFile = RASPI_DNSMASQ_PREFIX . $iface . SELF::CONF_SUFFIX; $tempFile = SELF::CONF_TMP; + $config = join(PHP_EOL, $config); file_put_contents($tempFile, $config); $cmd = sprintf('sudo cp %s %s', escapeshellarg($tempFile), escapeshellarg($configFile)); exec($cmd, $output, $status); @@ -222,14 +228,15 @@ class DnsmasqManager /** * Saves dnsmasq default configuration * - * @param string $config + * @param array $config * @return bool */ - public function saveConfigDefault(string $config): bool + public function saveConfigDefault(array $config): bool { $configFile = SELF::CONF_DEFAULT . SELF::CONF_RASPAP . SELF::CONF_SUFFIX; $tempFile = SELF::CONF_TMP; + $config = join(PHP_EOL, $config); file_put_contents($tempFile, $config); $cmd = sprintf('sudo cp %s %s', escapeshellarg($tempFile), escapeshellarg($configFile)); exec($cmd, $output, $status); From 7cc436fbaa3b0c10d0cdc014f6280cf7ed76bffa Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 10:00:15 -0700 Subject: [PATCH 35/44] Add missing param in dnsmasq->saveConfig() --- .../Networking/Hotspot/HotspotService.php | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/HotspotService.php b/src/RaspAP/Networking/Hotspot/HotspotService.php index 0b02af17..e86185eb 100644 --- a/src/RaspAP/Networking/Hotspot/HotspotService.php +++ b/src/RaspAP/Networking/Hotspot/HotspotService.php @@ -20,7 +20,6 @@ namespace RaspAP\Networking\Hotspot; use RaspAP\Networking\Hotspot\Validators\HostapdValidator; use RaspAP\Messages\StatusMessage; - class HotspotService { protected HostapdManager $hostapd; @@ -168,7 +167,7 @@ class HotspotService $validated['apsta'], $validated['bridge'] ); - $this->dnsmasq->saveConfig($dnsmasqConfig, $validated['interface']); + $this->dnsmasq->saveConfig($dnsmasqConfig, $validated['interface'], $status); } catch (\RuntimeException $e) { error_log('Error: ' . $e->getMessage()); } @@ -181,7 +180,7 @@ class HotspotService $validated['repeater'], $validated['apsta'], $validated['dualmode'], - $status + $status, ); } catch (\RuntimeException $e) { error_log('Error: ' . $e->getMessage()); @@ -322,48 +321,44 @@ class HotspotService } /** - * Start hotspot services for given interface. + * Starts services for given interface * * @param string $iface * @return bool */ public function start(string $iface): bool { - // TODO: implement systemctl or service logic return false; } /** - * Stop hotspot services. + * Stops hotspot services * * @return bool */ public function stop(): bool { - // TODO: implement return false; } /** - * Restart hotspot services for given interface. + * Restart hotspot services for given interface * * @param string $iface * @return bool */ public function restart(string $iface): bool { - // TODO: implement return false; } /** - * Get current hotspot status. + * Get current hotspot status * * @return array */ public function getStatus(): array { - // TODO: query service state + configs return []; } } From 7a7bdda708ff89a4d7d60501f28f4a6fbc53ff67 Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 10:02:58 -0700 Subject: [PATCH 36/44] Delegate dnsmasq + dhcpcd config build/save to manager classes --- includes/dhcp.php | 62 +++-------------------------------------------- 1 file changed, 4 insertions(+), 58 deletions(-) diff --git a/includes/dhcp.php b/includes/dhcp.php index 3747fa59..f209813e 100755 --- a/includes/dhcp.php +++ b/includes/dhcp.php @@ -96,7 +96,6 @@ function saveDHCPConfig($status) $dhcpcd = new DhcpcdManager(); $dnsmasq = new DnsmasqManager(); $iface = $_POST['interface']; - $return = 1; // dhcp if (!isset($_POST['dhcp-iface']) && file_exists(RASPI_DNSMASQ_PREFIX.$iface.'.conf')) { @@ -106,82 +105,29 @@ function saveDHCPConfig($status) } else { $errors = $dhcpcd->validate($_POST); if (empty($errors)) { - $return = updateDHCPConfig($iface, $status); + $dhcp_cfg = $dhcpcd->buildConfigEx($iface, $_POST, $status); + $dhcpcd->saveConfig($dhcp_cfg, $iface, $status); } else { foreach ($errors as $error) { $status->addMessage($error, 'danger'); } } - if ($return == 1) { - $status->addMessage('DHCP configuration failed to be updated.', 'danger'); - return false; - } // dnsmasq if (($_POST['dhcp-iface'] == "1") || (isset($_POST['mac']))) { $errors = $dnsmasq->validate($_POST); if (empty($errors)) { - $config = $dnsmasq->buildEx($iface, $_POST); + $config = $dnsmasq->buildConfigEx($iface, $_POST); $return = $dnsmasq->saveConfig($config, $iface); - $config = $dnsmasq->buildDefault(); + $config = $dnsmasq->buildDefault($_POST); $return = $dnsmasq->saveConfigDefault($config); } else { foreach ($errors as $error) { $status->addMessage($error, 'danger'); } - $return = 1; } } return true; } } -/** - * Updates a dhcp configuration - * - * @param string $iface - * @param object $status - * @return boolean $result - */ -function updateDHCPConfig($iface,$status) -{ - $cfg[] = '# RaspAP '.$iface.' configuration'; - $cfg[] = 'interface '.$iface; - if (isset($_POST['StaticIP']) && $_POST['StaticIP'] !== '') { - $mask = ($_POST['SubnetMask'] !== '' && $_POST['SubnetMask'] !== '0.0.0.0') ? '/'.mask2cidr($_POST['SubnetMask']) : null; - $cfg[] = 'static ip_address='.$_POST['StaticIP'].$mask; - } - if (isset($_POST['DefaultGateway']) && $_POST['DefaultGateway'] !== '') { - $cfg[] = 'static routers='.$_POST['DefaultGateway']; - } - if ($_POST['DNS1'] !== '' || $_POST['DNS2'] !== '') { - $cfg[] = 'static domain_name_server='.$_POST['DNS1'].' '.$_POST['DNS2']; - } - if ($_POST['Metric'] !== '') { - $cfg[] = 'metric '.$_POST['Metric']; - } - if (($_POST['Fallback'] ?? 0) == 1) { - $cfg[] = 'profile static_'.$iface; - $cfg[] = 'fallback static_'.$iface; - } - $cfg[] = ($_POST['DefaultRoute'] ?? '') == '1' ? 'gateway' : 'nogateway'; - if (substr($iface, 0, 2) === "wl" && ($_POST['NoHookWPASupplicant'] ?? '') == '1') { - $cfg[] = 'nohook wpa_supplicant'; - } - $dhcp_cfg = file_get_contents(RASPI_DHCPCD_CONFIG); - if (!preg_match('/^interface\s'.$iface.'$/m', $dhcp_cfg)) { - $cfg[] = PHP_EOL; - $cfg = join(PHP_EOL, $cfg); - $dhcp_cfg .= $cfg; - $status->addMessage('DHCP configuration for '.$iface.' added.', 'success'); - } else { - $cfg = join(PHP_EOL, $cfg); - $dhcp_cfg = preg_replace('/^#\sRaspAP\s'.$iface.'\s.*?(?=\s*^\s*$)/ms', $cfg, $dhcp_cfg, 1); - $status->addMessage('DHCP configuration for '.$iface.' updated.', 'success'); - } - file_put_contents("/tmp/dhcpddata", $dhcp_cfg); - system('sudo cp /tmp/dhcpddata '.RASPI_DHCPCD_CONFIG, $result); - - return $result; -} - From c19bd6024186ad85be4a24e7b379e30ee096ac45 Mon Sep 17 00:00:00 2001 From: billz Date: Sun, 20 Jul 2025 14:46:33 -0700 Subject: [PATCH 37/44] Fix: set return value (bool) --- src/RaspAP/Networking/Hotspot/DnsmasqManager.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php index b55ebac4..4942ffe2 100644 --- a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php +++ b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php @@ -294,10 +294,11 @@ class DnsmasqManager system('sudo rm '.RASPI_DNSMASQ_PREFIX.$iface.'.conf', $result); if ($result == 0) { $status->addMessage('Dnsmasq configuration for '.$iface.' removed.', 'success'); + return true; } else { $status->addMessage('Failed to remove dnsmasq configuration for '.$iface.'.', 'danger'); + return false; } - return $result; } /** From a13e1b880454def40c377c58e43caa5379583037 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 28 Jul 2025 12:56:36 -0700 Subject: [PATCH 38/44] Populate static-lease container from jsonData.dhcpHost --- app/js/ajax/main.js | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/app/js/ajax/main.js b/app/js/ajax/main.js index c9dae9ea..f58ffa51 100644 --- a/app/js/ajax/main.js +++ b/app/js/ajax/main.js @@ -94,12 +94,41 @@ function loadInterfaceDHCPSelect() { $('#dhcp-iface').removeAttr('disabled'); } else { $('#chkdhcp').closest('.btn').addClass('active'); - $('#chkdhcp').closest('.btn').button.blur(); + $('#chkdhcp').closest('.btn').blur(); } if (jsonData.FallbackEnabled || $('#chkdhcp').is(':checked')) { $('#dhcp-iface').prop('disabled', true); setDhcpFieldsDisabled(); } + + const leaseContainer = $('.js-dhcp-static-lease-container'); + leaseContainer.empty(); + + if (jsonData.dhcpHost && jsonData.dhcpHost.length > 0) { + const leases = jsonData.dhcpHost || []; + leases.forEach((entry, index) => { + const [mainPart, commentPart] = entry.split('#'); + const comment = commentPart ? commentPart.trim() : ''; + const [mac, ip] = mainPart.split(',').map(part => part.trim()); + console.log(`Lease ${index}: MAC=${mac}, IP=${ip}, Comment=${comment}`); + const row = ` +
+
+ +
+
+ +
+
+ +
+
+ +
+
`; + leaseContainer.append(row); + }); + } }); } From f30abc4bd7e6133521f44994e5654cd164fe4961 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 28 Jul 2025 13:34:26 -0700 Subject: [PATCH 39/44] Remove debug output --- app/js/ajax/main.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/js/ajax/main.js b/app/js/ajax/main.js index f58ffa51..f8bf997a 100644 --- a/app/js/ajax/main.js +++ b/app/js/ajax/main.js @@ -110,7 +110,6 @@ function loadInterfaceDHCPSelect() { const [mainPart, commentPart] = entry.split('#'); const comment = commentPart ? commentPart.trim() : ''; const [mac, ip] = mainPart.split(',').map(part => part.trim()); - console.log(`Lease ${index}: MAC=${mac}, IP=${ip}, Comment=${comment}`); const row = `
From 3c1d4325f24b2cd042a5eb0c4718d920bb1c9e28 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 28 Jul 2025 13:34:45 -0700 Subject: [PATCH 40/44] Implement dnsmasq restart button --- includes/dhcp.php | 12 ++++++++++++ templates/dhcp.php | 1 + 2 files changed, 13 insertions(+) diff --git a/includes/dhcp.php b/includes/dhcp.php index f209813e..a4b06f76 100755 --- a/includes/dhcp.php +++ b/includes/dhcp.php @@ -37,6 +37,18 @@ function DisplayDHCPConfig() $status->addMessage('Failed to start dnsmasq', 'danger'); } } + } elseif (isset($_POST['restartdhcpd'])) { + if ($dnsmasq_state) { + exec('sudo /bin/systemctl restart dnsmasq.service', $dnsmasq, $return); + if ($return == 0) { + $status->addMessage('Successfully restarted dnsmasq', 'success'); + $dnsmasq_state = false; + } else { + $status->addMessage('Failed to restart dnsmasq', 'danger'); + } + } else { + $status->addMessage('dnsmasq already stopped', 'info'); + } } elseif (isset($_POST['stopdhcpd'])) { if ($dnsmasq_state) { exec('sudo /bin/systemctl stop dnsmasq.service', $dnsmasq, $return); diff --git a/templates/dhcp.php b/templates/dhcp.php index b83a6444..9e5803f9 100755 --- a/templates/dhcp.php +++ b/templates/dhcp.php @@ -3,6 +3,7 @@ " name="savedhcpdsettings" /> " name="stopdhcpd" /> + " name="restartdhcpd" /> " name="startdhcpd" /> From dadc4e4fb4aac25b8d37c3f50859249428b291e0 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 28 Jul 2025 15:56:01 -0700 Subject: [PATCH 41/44] Refactor setKnownStationsWPA(), add helper method addWpaNetwork() --- src/RaspAP/Networking/Hotspot/WiFiManager.php | 153 ++++++++++++++---- 1 file changed, 119 insertions(+), 34 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/WiFiManager.php b/src/RaspAP/Networking/Hotspot/WiFiManager.php index bc07fe1d..bf4e4e32 100644 --- a/src/RaspAP/Networking/Hotspot/WiFiManager.php +++ b/src/RaspAP/Networking/Hotspot/WiFiManager.php @@ -304,55 +304,140 @@ class WiFiManager return $elem; } - /* + /** * Parses output of wpa_cli list_networks, compares with known networks * from wpa_supplicant, and adds with wpa_cli if not found * * @param array $networks + * @throws Exception on wpa_cli command failure */ public function setKnownStationsWPA($networks) { $iface = escapeshellarg($_SESSION['wifi_client_interface']); - $output = shell_exec("sudo wpa_cli -i $iface list_networks"); - $lines = explode("\n", $output); - array_shift($lines); - $wpaCliNetworks = []; + $output = shell_exec("sudo wpa_cli -i $iface list_networks 2>&1"); - foreach ($lines as $line) { - $data = explode("\t", trim($line)); - if (!empty($data) && count($data) >= 2) { - $id = $data[0]; - $ssid = $data[1]; - $item = [ - 'id' => $id, - 'ssid' => $ssid - ]; - $wpaCliNetworks[] = $item; + if ($output === null) { + throw new \Exception("Failed to execute wpa_cli command - command returned null"); + } + + // check for common wpa_cli errors and try to fix them + if (strpos($output, 'Failed to connect') !== false || strpos($output, 'No such file or directory') !== false) { + error_log("wpa_supplicant not available for interface, attempting to start it"); + + // try starting wpa_supplicant for this interface + $unescapedIface = trim($iface, "'\""); + $startCmd = "sudo /sbin/wpa_supplicant -i $unescapedIface -c /etc/wpa_supplicant/wpa_supplicant.conf -B 2>&1"; + $startResult = shell_exec($startCmd); + sleep(2); + + // retry + $output = shell_exec("sudo wpa_cli -i $iface list_networks 2>&1"); + + // tf it still fails, throw an exception + if ($output === null || strpos($output, 'Failed to connect') !== false) { + throw new \Exception("Failed to start wpa_supplicant for interface: " . trim($startResult ?? 'unknown error')); } } - foreach ($networks as $network) { - $ssid = $network['ssid']; - if (!$this->networkExists($ssid, $wpaCliNetworks)) { - $ssid = escapeshellarg('"'.$network['ssid'].'"'); - $psk = escapeshellarg('"'.$network['passphrase'].'"'); - $protocol = $network['protocol']; - $netid = trim(shell_exec("sudo wpa_cli -i $iface add_network")); - if (isset($netid) && !isset($known[$netid])) { - $commands = [ - "sudo wpa_cli -i $iface set_network $netid ssid $ssid", - "sudo wpa_cli -i $iface set_network $netid psk $psk", - "sudo wpa_cli -i $iface enable_network $netid" - ]; - if ($protocol === 'Open') { - $commands[1] = "sudo wpa_cli -i $iface set_network $netid key_mgmt NONE"; - } - foreach ($commands as $cmd) { - exec($cmd); - usleep(1000); + + // split output into lines + $lines = explode("\n", trim($output)); + + // check for header line + if (empty($lines) || count($lines) < 1) { + error_log("wpa_cli list_networks returned no output"); + $wpaCliNetworks = []; + } else { + // remove header line if it exists + $headerLine = trim($lines[0]); + if (strpos($headerLine, 'network id') !== false || strpos($headerLine, 'id') !== false) { + array_shift($lines); + } + + $wpaCliNetworks = []; + foreach ($lines as $line) { + $trimmedLine = trim($line); + + // skip empty lines + if (empty($trimmedLine)) { + continue; + } + + $data = explode("\t", $trimmedLine); + if (count($data) >= 2) { + $id = trim($data[0]); + $ssid = trim($data[1]); + + // add if we have valid data + if ($id !== '' && $ssid !== '') { + $wpaCliNetworks[] = [ + 'id' => $id, + 'ssid' => $ssid + ]; } } } } + + // process networks to add + foreach ($networks as $network) { + if (!isset($network['ssid']) || empty($network['ssid'])) { + error_log("Skipping network with missing or empty SSID"); + continue; + } + + $ssid = $network['ssid']; + if (!$this->networkExists($ssid, $wpaCliNetworks)) { + $this->addWpaNetwork($network, $iface); + } + } + } + + /** + * Helper method to add a single network to wpa_supplicant + * + * @param array $network Network configuration + * @param string $iface Escaped shell argument for interface + */ + private function addWpaNetwork($network, $iface) + { + $ssid = escapeshellarg('"' . $network['ssid'] . '"'); + $psk = escapeshellarg('"' . $network['passphrase'] . '"'); + $protocol = $network['protocol'] ?? 'WPA'; + + // add network and get its ID + $netid = trim(shell_exec("sudo wpa_cli -i $iface add_network 2>&1")); + + // validate network ID + if (!$netid || !is_numeric($netid)) { + error_log("Failed to add network '{$network['ssid']}': Invalid network ID returned: '$netid'"); + return; + } + + // prepare command based on protocol + $commands = [ + "sudo wpa_cli -i $iface set_network $netid ssid $ssid", + ]; + + if (strtolower($protocol) === 'open') { + $commands[] = "sudo wpa_cli -i $iface set_network $netid key_mgmt NONE"; + } else { + $commands[] = "sudo wpa_cli -i $iface set_network $netid psk $psk"; + } + + $commands[] = "sudo wpa_cli -i $iface enable_network $netid"; + + // execute commands, checking errors + foreach ($commands as $cmd) { + $result = shell_exec("$cmd 2>&1"); + if ($result === null || strpos($result, 'FAIL') !== false) { + error_log("Command failed: $cmd - Result: " . ($result ?? 'null')); + // remove the failed network + shell_exec("sudo wpa_cli -i $iface remove_network $netid 2>&1"); + return; + } + usleep(1000); + } + error_log("Successfully added network: {$network['ssid']}"); } /* From 3922832b5330181df11227b7e338b4f79c74cf8e Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 28 Jul 2025 15:56:31 -0700 Subject: [PATCH 42/44] Fix: update w/ CSRF::hiddenField() --- templates/wifi_stations.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/wifi_stations.php b/templates/wifi_stations.php index 2816d71a..fb884cca 100755 --- a/templates/wifi_stations.php +++ b/templates/wifi_stations.php @@ -5,7 +5,7 @@

wpa_supplicant.") ?>

- +
" />
From 63491b17d64ce94de994545a3ddd03cb97624234 Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 2 Aug 2025 13:12:39 -0700 Subject: [PATCH 43/44] Run wpa_supplicant in background mode (-B) --- installers/raspap.sudoers | 1 + src/RaspAP/Networking/Hotspot/WiFiManager.php | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/installers/raspap.sudoers b/installers/raspap.sudoers index e3985826..bd073f33 100644 --- a/installers/raspap.sudoers +++ b/installers/raspap.sudoers @@ -5,6 +5,7 @@ www-data ALL=(ALL) NOPASSWD:/bin/cat /etc/wpa_supplicant/wpa_supplicant-[a-zA-Z0 www-data ALL=(ALL) NOPASSWD:/bin/cp /tmp/wifidata /etc/wpa_supplicant/wpa_supplicant.conf www-data ALL=(ALL) NOPASSWD:/bin/cp /tmp/wifidata /etc/wpa_supplicant/wpa_supplicant-wl*.conf www-data ALL=(ALL) NOPASSWD:/sbin/wpa_supplicant -B -Dnl80211 -c/etc/wpa_supplicant/wpa_supplicant.conf -i[a-zA-Z0-9]* +www-data ALL=(ALL) NOPASSWD:/sbin/wpa_supplicant -i [a-zA-Z0-9]* -c /etc/wpa_supplicant/wpa_supplicant.conf -B www-data ALL=(ALL) NOPASSWD:/bin/rm /var/run/wpa_supplicant/[a-zA-Z0-9]* www-data ALL=(ALL) NOPASSWD:/sbin/wpa_cli -i [a-zA-Z0-9]* scan_results www-data ALL=(ALL) NOPASSWD:/sbin/wpa_cli -i [a-zA-Z0-9]* scan diff --git a/src/RaspAP/Networking/Hotspot/WiFiManager.php b/src/RaspAP/Networking/Hotspot/WiFiManager.php index bf4e4e32..316ba074 100644 --- a/src/RaspAP/Networking/Hotspot/WiFiManager.php +++ b/src/RaspAP/Networking/Hotspot/WiFiManager.php @@ -183,7 +183,7 @@ class WiFiManager if (preg_match('/ESSID:\"([^"]+)\"/i', $line, $iwconfig_ssid)) { $ssid=hexSequence2lower($iwconfig_ssid[1]); $networks[$ssid]['connected'] = true; - $check=detectCaptivePortal($_SESSION['wifi_client_interface']); + //$check=detectCaptivePortal($_SESSION['wifi_client_interface']); $networks[$ssid]["portal-url"]=$check["URL"]; } } @@ -248,9 +248,9 @@ class WiFiManager */ public function reinitializeWPA($force) { - $iface = escapeshellarg($_SESSION['wifi_client_interface']); + $iface = $_SESSION['wifi_client_interface']; if ($force == true) { - $cmd = "sudo wpa_supplicant -B -Dnl80211 -c/etc/wpa_supplicant/wpa_supplicant.conf -i$iface"; + $cmd = "sudo /sbin/wpa_supplicant -i $unescapedIface -c /etc/wpa_supplicant/wpa_supplicant.conf -B 2>&1"; $result = shell_exec($cmd); } $cmd = "sudo wpa_cli -i $iface reconfigure"; @@ -333,7 +333,7 @@ class WiFiManager // retry $output = shell_exec("sudo wpa_cli -i $iface list_networks 2>&1"); - // tf it still fails, throw an exception + // if it still fails, throw an exception if ($output === null || strpos($output, 'Failed to connect') !== false) { throw new \Exception("Failed to start wpa_supplicant for interface: " . trim($startResult ?? 'unknown error')); } From 7f2eb6e88fcf3546363e4d86a241c4301970e15d Mon Sep 17 00:00:00 2001 From: billz Date: Sat, 2 Aug 2025 13:13:41 -0700 Subject: [PATCH 44/44] Coalesce dhcp-option=6 lines, prevents invalid config --- .../Networking/Hotspot/DnsmasqManager.php | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php index 4942ffe2..49bb8ee7 100644 --- a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php +++ b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php @@ -97,12 +97,19 @@ class DnsmasqManager } } if (!empty($syscfg['dhcp-option'])) { - if (is_array($syscfg['dhcp-option'])) { - foreach ($syscfg['dhcp-option'] as $opt) { - $config[] = 'dhcp-option=' . $opt; + $dhcpOptions = (array) $syscfg['dhcp-option']; + $grouped = []; + + foreach ($dhcpOptions as $opt) { + $parts = explode(',', $opt, 2); + if (count($parts) < 2) { + continue; // skip malformed option } - } else { - $config[] = 'dhcp-option=' . $syscfg['dhcp-option']; + list($code, $value) = $parts; + $grouped[$code][] = $value; + } + foreach ($grouped as $code => $values) { + $config[] = 'dhcp-option=' . $code . ',' . implode(',', $values); } } $config[] = PHP_EOL; @@ -155,17 +162,17 @@ class DnsmasqManager foreach ($post_data['server'] as $server) { $config[] = "server=$server"; } - if ($post_data['DNS1']) { - $config[] = "dhcp-option=6," . $post_data['DNS1']; - if ($post_data['DNS2']) { - $config[] = ','.$post_data['DNS2']; + if (!empty($post_data['DNS1'])) { + $dnsOption = "dhcp-option=6," . $post_data['DNS1']; + if (!empty($post_data['DNS2'])) { + $dnsOption .= ',' . $post_data['DNS2']; } - $config[]= PHP_EOL; + $config[] = $dnsOption; } if ($post_data['dhcp-ignore'] == "1") { $config[] = 'dhcp-ignore=tag:!known'; } - + $config[]= PHP_EOL; return $config; }