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); } 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']; } ); diff --git a/app/js/ajax/main.js b/app/js/ajax/main.js index c9dae9ea..f8bf997a 100644 --- a/app/js/ajax/main.js +++ b/app/js/ajax/main.js @@ -94,12 +94,40 @@ 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()); + const row = ` +
+
+ +
+
+ +
+
+ +
+
+ +
+
`; + leaseContainer.append(row); + }); + } }); } 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..a4b06f76 100755 --- a/includes/dhcp.php +++ b/includes/dhcp.php @@ -2,12 +2,20 @@ 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() { - $status = new \RaspAP\Messages\StatusMessage; + $status = new StatusMessage(); + $wifi = new WiFiManager(); + $wifi->getWifiInterface(); + if (!RASPI_MONITOR_ENABLED) { if (isset($_POST['savedhcpdsettings'])) { saveDHCPConfig($status); @@ -29,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); @@ -43,7 +63,6 @@ function DisplayDHCPConfig() } } } - getWifiInterface(); $ap_iface = $_SESSION['ap_interface']; $serviceStatus = $dnsmasq_state ? "up" : "down"; exec('cat '. RASPI_DNSMASQ_PREFIX.'raspap.conf', $return); @@ -86,262 +105,41 @@ 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); + $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('Dnsmasq 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->buildConfigEx($iface, $_POST); + $return = $dnsmasq->saveConfig($config, $iface); + $config = $dnsmasq->buildDefault($_POST); + $return = $dnsmasq->saveConfigDefault($config); } else { foreach ($errors as $error) { $status->addMessage($error, 'danger'); } - $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 - * - * @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; -} - 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 diff --git a/includes/hostapd.php b/includes/hostapd.php index 5def9a71..45004498 100755 --- a/includes/hostapd.php +++ b/includes/hostapd.php @@ -1,9 +1,13 @@ getWifiInterface(); /** * Initialize hostapd values, display interface @@ -11,51 +15,31 @@ getWifiInterface(); */ function DisplayHostAPDConfig() { - $status = new \RaspAP\Messages\StatusMessage; - $system = new \RaspAP\System\Sysinfo; + $hostapd = new HostapdManager(); + $hotspot = new HotspotService(); + $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' - ]; + + // set hostapd defaults + $arr80211Standard = $hotspot->get80211Standards(); + $arrSecurity = $hotspot->getSecurityModes(); + $arrEncType = $hotspot->getEncTypes(); + $arr80211w = $hotspot->get80211wOptions(); $languageCode = strtok($_SESSION['locale'], '_'); $countryCodes = getCountryCodes($languageCode); - - $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)")); + $reg_domain = $hotspot->getRegDomain(); + $interfaces = $hotspot->getInterfaces(); $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']); - } - - 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); + $interface = $_POST['interface']; + } else { + $interface = $_SESSION['ap_interface']; } + $txpower = $hotspot->getTxPower($interface); + $arrHostapdConf = $hotspot->getHostapdIni(); if (!RASPI_MONITOR_ENABLED) { if (isset($_POST['StartHotspot']) || isset($_POST['RestartHotspot'])) { @@ -76,6 +60,8 @@ function DisplayHostAPDConfig() foreach ($return as $line) { $status->addMessage($line, 'info'); } + } elseif (isset($_POST['SaveHostAPDSettings'])) { + $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); @@ -85,80 +71,37 @@ 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; } } - $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') + // process txpower user input 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']; + $hotspot->maybeSetTxPower($interface, $txpower, $status); } 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']; + $hotspot->maybeSetTxPower($interface, 'auto', $status); } - } - // 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; + $txpower = $_POST['txpower']; } - $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'; - } + // parse hostapd configuration + try { + $arrConfig = $hostapd->getConfig(); + } catch (\RuntimeException $e) { + error_log('Error: ' . $e->getMessage()); } - $arrConfig['ignore_broadcast_ssid'] ??= 0; - $arrConfig['max_num_sta'] ??= 0; - $arrConfig['wep_default_key'] ??= 0; + // 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"; + + // ensure log is writeable exec('sudo /bin/chmod o+r '.RASPI_HOSTAPD_LOG); $logdata = getLogLimited(RASPI_HOSTAPD_LOG); @@ -171,7 +114,6 @@ function DisplayHostAPDConfig() "interfaces", "arrConfig", "arr80211Standard", - "selectedHwMode", "arrSecurity", "arrEncType", "arr80211w", @@ -179,607 +121,9 @@ function DisplayHostAPDConfig() "txpower", "arrHostapdConf", "operatingSystem", - "selectedHwMode", "countryCodes", "logdata" ) ); } -/** - * Validate user input, save 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) -{ - // It should not be possible to send bad data for these fields. - // 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'); - - // 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'); - } - } - - // set AP interface default, override for ap-sta & bridged options - $iface = validateInterface($_POST['interface']) ? $_POST['interface'] : RASPI_WIFI_AP_INTERFACE; - - $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'; - } - - // 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'); - $_SESSION['ap_interface'] = $session_iface; - - // Verify input - if (empty($_POST['ssid']) || strlen($_POST['ssid']) > 32) { - $status->addMessage('SSID must be between 1 and 32 characters', 'danger'); - $good_input = false; - } - - # 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 = buildHostapdConfig([ - '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 - ]); - - 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); - } - - if (trim($country_code) != trim($reg_domain)) { - $return = iwRegSet($country_code, $status); - } - - // Parse dnsmasq config for selected interface - exec('cat '.RASPI_DNSMASQ_PREFIX.$ap_iface.'.conf', $lines); - $syscfg = ParseConfig($lines); - - 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; - $config = join(PHP_EOL, $config); - file_put_contents("/tmp/dnsmasqdata", $config); - //system('sudo cp /tmp/dnsmasqdata ' . RASPI_DNSMASQ_PREFIX . $ap_iface . '.conf', $return); - } - // 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'); - } - } else { - $status->addMessage('Unable to save WiFi hotspot settings', 'danger'); - return false; - } - 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; -} - -/** - * 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; - } -} - -/** - * 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 - * - * @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; -} - -/** - * 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; - } -} - 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 diff --git a/installers/raspap.sudoers b/installers/raspap.sudoers index 3b7fd055..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 @@ -22,6 +23,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 @@ -44,7 +46,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 diff --git a/src/RaspAP/Networking/Hotspot/DhcpcdManager.php b/src/RaspAP/Networking/Hotspot/DhcpcdManager.php new file mode 100644 index 00000000..9b8f58db --- /dev/null +++ b/src/RaspAP/Networking/Hotspot/DhcpcdManager.php @@ -0,0 +1,527 @@ + + * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE + */ + +declare(strict_types=1); + +namespace RaspAP\Networking\Hotspot; + +use RaspAP\Messages\StatusMessage; + +class DhcpcdManager +{ + private const CONF_DEFAULT = RASPI_DHCPCD_CONFIG; + private const CONF_TMP = '/tmp/dhcpddata'; + + /** + * Builds a dhcpcd config for an interface + * + * @param string $ap_iface + * @param bool $bridgedEnable + * @param bool $repeaterEnable + * @param bool $wifiAPEnable + * @param bool $dualAPEnable + * @param StatusMessage $status + * @return string + */ + public function buildConfig( + string $ap_iface, + bool $bridgedEnable, + bool $repeaterEnable, + bool $wifiAPEnable, + bool $dualAPEnable, + StatusMessage $status + ): bool + { + // 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); + $skip_dhcp = false; + + 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 = $this->removeIface($dhcp_cfg,'br0'); + $dhcp_cfg = $this->removeIface($dhcp_cfg,'uap0'); + $dhcp_cfg .= $config; + } else { + $config = join(PHP_EOL, $config); + $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 { + $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; + } + + /** + * (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 + * + * @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 + * + * @param string $config + * @param StatusMessage $status + * @return bool + * @throws \RuntimeException + */ + public function saveConfig(string $config, string $iface, StatusMessage $status): bool + { + 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; + } + + /** + * 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 + * + * @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 + { + $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; + } + + /** + * Retrieves the metric value for a given interface + * + * @param string $iface + * @return int $metric| bool false on failure + */ + public 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; + } + } + + /** + * 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')) { + require_once 'includes/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; + } + +} + diff --git a/src/RaspAP/Networking/Hotspot/DnsmasqManager.php b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php new file mode 100644 index 00000000..49bb8ee7 --- /dev/null +++ b/src/RaspAP/Networking/Hotspot/DnsmasqManager.php @@ -0,0 +1,358 @@ + + * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE + */ + +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 + * + * @param string $iface + * @return array + * @throws \RuntimeException + */ + public function getConfig(string $iface): array + { + $configFile = RASPI_DNSMASQ_PREFIX . "$iface.conf"; + $lines = []; + + if (!file_exists($configFile)) { + throw new \RuntimeException("dnsmasq config not found: $configFile"); + } + if (!is_readable($configFile)) { + throw new \RuntimeException("Unable to read dnsmasq config: $configFile"); + } + if (!function_exists('ParseConfig')) { + throw new \RuntimeException("Unable to execute ParseConfig()"); + } + + exec('cat ' . escapeshellarg($configFile), $lines, $status); + if ($status !== 0 || empty($lines)) { + throw new \RuntimeException("Failed to read dnsmasq config for $iface"); + } + + $config = ParseConfig($lines); + 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; + $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' ]; + $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'])) { + $dhcpOptions = (array) $syscfg['dhcp-option']; + $grouped = []; + + foreach ($dhcpOptions as $opt) { + $parts = explode(',', $opt, 2); + if (count($parts) < 2) { + continue; // skip malformed option + } + list($code, $value) = $parts; + $grouped[$code][] = $value; + } + foreach ($grouped as $code => $values) { + $config[] = 'dhcp-option=' . $code . ',' . implode(',', $values); + } + } + $config[] = PHP_EOL; + } + return $config; + } + + /** + * Builds an extended dnsmasq configuration + * + * @param string $iface + * @param array $post_data + * @return array $config + */ + public function buildConfigEx(string $iface, array $post_data): array + { + $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"])) { + 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"; + } + if ($post_data['no-resolv'] == "1") { + $config[] = "no-resolv"; + } + foreach ($post_data['server'] as $server) { + $config[] = "server=$server"; + } + if (!empty($post_data['DNS1'])) { + $dnsOption = "dhcp-option=6," . $post_data['DNS1']; + if (!empty($post_data['DNS2'])) { + $dnsOption .= ',' . $post_data['DNS2']; + } + $config[] = $dnsOption; + } + if ($post_data['dhcp-ignore'] == "1") { + $config[] = 'dhcp-ignore=tag:!known'; + } + $config[]= PHP_EOL; + return $config; + } + + /** + * Builds a RaspAP default dnsmasq config + * Written to 090_raspap.conf + * + * @param array $post_data + * @return array $config + */ + public function buildDefault(array $post_data): array + { + // 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"; + } + if (($post_data['log-queries'] ?? '') == "1") { + $config[] = "log-queries"; + } + $config[] = PHP_EOL; + + return $config; + } + + /** + * Saves dnsmasq configuration for an interface + * + * @param array $config + * @param string $iface + * @return 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); + 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; + } + + /** + * Saves dnsmasq default configuration + * + * @param array $config + * @return 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); + 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'); + return true; + } else { + $status->addMessage('Failed to remove dnsmasq configuration for '.$iface.'.', 'danger'); + return false; + } + } + + /** + * 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 + * + * @param string $iface + * @param string $mac + * @param string $ip + * @param string|null $comment + * @return bool + */ + public function addStaticLease(string $iface, string $mac, string $ip, ?string $comment = null): bool + { + return false; + } +} + diff --git a/src/RaspAP/Networking/Hotspot/HostapdManager.php b/src/RaspAP/Networking/Hotspot/HostapdManager.php new file mode 100644 index 00000000..593e93de --- /dev/null +++ b/src/RaspAP/Networking/Hotspot/HostapdManager.php @@ -0,0 +1,466 @@ + + * @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; + +class HostapdManager +{ + private const CONF_DEFAULT = RASPI_HOSTAPD_CONFIG; + 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 + { + $configFile = SELF::CONF_DEFAULT; + + if (!file_exists($configFile)) { + throw new \RuntimeException("hostapd config not found: $configFile"); + } + if (!is_readable($configFile)) { + throw new \RuntimeException("Unable to read hostapd config: $configFile"); + } + exec('cat ' . escapeshellarg($configFile), $hostapdconfig, $status); + if ($status !== 0 || empty($hostapdconfig)) { + throw new \RuntimeException("Failed to read hostapd config: $configFile"); + } + + foreach ($hostapdconfig as $hostapdconfigline) { + if (strlen($hostapdconfigline) === 0) { + continue; + } + if ($hostapdconfigline[0] != "#") { + $line = explode("=", $hostapdconfigline); + $config[$line[0]]=$line[1]; + } + }; + + // assign beacon_int boolean if value is set + if (isset($config['beacon_int'])) { + $config['beacon_interval_bool'] = 1; + } + // assign disassoc_low_ack boolean if value is set + if (isset($config['disassoc_low_ack'])) { + $config['disassoc_low_ack_bool'] = 1; + } + // assign country_code from iw reg if not set in config + if (empty($config['country_code']) && isset($country_code[0])) { + $config['country_code'] = $country_code[0]; + } + // map wpa_key_mgmt to security types + if ($config['wpa_key_mgmt'] == 'WPA-PSK WPA-PSK-SHA256 SAE') { + $config['wpa'] = 4; + } elseif ($config['wpa_key_mgmt'] == 'SAE') { + $config['wpa'] = 5; + } + $config['selected_hw_mode'] = $this->resolveHwMode($config); + $config['ignore_broadcast_ssid'] ??= 0; + $config['max_num_sta'] ??= 0; + $config['wep_default_key'] ??= 0; + + return $config; + } + + /** + * 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 + * + * @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 + * + * @param array $params + * @param StatusMessage $status + * @return string + */ + public function buildConfig(array $params, StatusMessage $status): 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[] = $this->parseUserHostapdCfg(); + + return implode(PHP_EOL, $config) . PHP_EOL; + } + + /** + * Saves a hostapd configuration + * + * @param string $config, rendered hostapd.conf + * @param string $interface, named interface + * @param bool $dualMode, dual-band AP mode enabled + * @param bool $restart, option to restart hostapd@ after save + * @return bool + * @throws \RuntimeException + */ + public function saveConfig(string $config, bool $dualMode, string $iface, bool $restart = false): bool + { + $configFile = $this->resolveConfigPath($iface, $dualMode); + $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; + } + + /** + * 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 + ]; + } + + /** + * 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); + } + + /** + * Parses optional /etc/hostapd/hostapd.conf.users file + * + * @return string $tmp + */ + private function parseUserHostapdCfg() + { + if (file_exists(SELF::CONF_DEFAULT . '.users')) { + exec('cat '. SELF::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)."); + } + + /** + * Persist hostapd.ini with mode / interface user settings + * + * @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(array $states, string $apIface, string $cliIface, array $previousIni = []): bool + { + $this->applyLogState($states['LogEnable']); + + // compose new ini payload + $cfg = [ + 'WifiInterface' => $apIface, + '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) { + if (!array_key_exists($k, $cfg)) { + $cfg[$k] = $v; + } + } + 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); + } + + /** + * 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; + } + +} + diff --git a/src/RaspAP/Networking/Hotspot/HotspotService.php b/src/RaspAP/Networking/Hotspot/HotspotService.php new file mode 100644 index 00000000..e86185eb --- /dev/null +++ b/src/RaspAP/Networking/Hotspot/HotspotService.php @@ -0,0 +1,365 @@ + + * @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; + +class HotspotService +{ + 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->hostapd = new HostapdManager(); + $this->dnsmasq = new DnsmasqManager(); + $this->dhcpcd = new DhcpcdManager(); + } + + /** + * 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 $wpa_array + * @param array $enc_types + * @param array $modes + * @param array $interfaces + * @param string $reg_domain + * @param StatusMessage $status + * @return bool + */ + public function saveSettings( + array $post_data, + array $wpa_array, + array $enc_types, + array $modes, + array $interfaces, + string $reg_domain, + StatusMessage $status): bool + { + $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'], $status); + } 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); + } + + /** + * Starts services for given interface + * + * @param string $iface + * @return bool + */ + public function start(string $iface): bool + { + return false; + } + + /** + * Stops hotspot services + * + * @return bool + */ + public function stop(): bool + { + return false; + } + + /** + * Restart hotspot services for given interface + * + * @param string $iface + * @return bool + */ + public function restart(string $iface): bool + { + return false; + } + + /** + * Get current hotspot status + * + * @return array + */ + public function getStatus(): array + { + return []; + } +} + diff --git a/src/RaspAP/Networking/Hotspot/Validators/HostapdValidator.php b/src/RaspAP/Networking/Hotspot/Validators/HostapdValidator.php new file mode 100644 index 00000000..2a8d70af --- /dev/null +++ b/src/RaspAP/Networking/Hotspot/Validators/HostapdValidator.php @@ -0,0 +1,144 @@ + + * @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 +{ + + /** + * 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'] ?? false) ? 'br0' : null + ]; + } + +} + diff --git a/src/RaspAP/Networking/Hotspot/WiFiManager.php b/src/RaspAP/Networking/Hotspot/WiFiManager.php new file mode 100644 index 00000000..316ba074 --- /dev/null +++ b/src/RaspAP/Networking/Hotspot/WiFiManager.php @@ -0,0 +1,479 @@ + + * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE + */ + +declare(strict_types=1); + +namespace RaspAP\Networking\Hotspot; + +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 = $_SESSION['wifi_client_interface']; + if ($force == true) { + $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"; + $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 + * @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 2>&1"); + + 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"); + + // 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')); + } + } + + // 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']}"); + } + + /* + * 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; + } + +} + 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" /> 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 @@
"> - +
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 @@
- +
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.") ?>

- +
" />
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 {