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.") ?>