shape used throughout the * dashboard (no CSS class — same convention as the existing banners * in index.php / configure.php this file replaces). * * @param string $html Banner body. INTERPOLATED RAW — callers must * ensure any dynamic content is already escaped * (none of the current banners include dynamic * content, so this is a non-issue today; flagged * for any future caller). * @param string $level 'warn' (yellow) or 'danger' (red). * * @return void */ function _pistar_banner_emit($html, $level = 'warn') { $style = ($level === 'danger') ? 'background-color: #ff9090; color: #f01010;' : 'background-color: #ffff90; color: #906000;'; echo '
' . "\n"; echo '
' . "\n"; echo ' ' . "\n"; echo ' ' . "\n"; echo ' ' . "\n"; echo '
' . $html . '
' . "\n"; echo '' . "\n"; } /** * Extract the HTTP Basic auth user + password from the current request. * * On Pi-Star's stock nginx + PHP-FPM config, $_SERVER['PHP_AUTH_PW'] * is populated automatically (PHP synthesises it from the forwarded * Authorization header). The HTTP_AUTHORIZATION fallback handles * unusual setups where only the raw header survives. * * Note: the REMOTE_USER fallback only sets $user — that var contains * the authenticated username but never the password. Callers that need * the password (default-password check, etc.) treat empty $pw as * "unable to verify" and fail closed. * * @return array{0: string, 1: string} [$user, $password]; either or * both may be '' when basic auth * hasn't happened (e.g. on the * unauthenticated public root). */ function _pistar_get_basic_auth() { $user = isset($_SERVER['PHP_AUTH_USER']) ? (string)$_SERVER['PHP_AUTH_USER'] : ''; $pw = isset($_SERVER['PHP_AUTH_PW']) ? (string)$_SERVER['PHP_AUTH_PW'] : ''; if ($user !== '' && $pw !== '') { return array($user, $pw); } if (!empty($_SERVER['HTTP_AUTHORIZATION']) && stripos($_SERVER['HTTP_AUTHORIZATION'], 'Basic ') === 0) { $decoded = base64_decode(substr($_SERVER['HTTP_AUTHORIZATION'], 6), true); if ($decoded !== false && strpos($decoded, ':') !== false) { list($user, $pw) = explode(':', $decoded, 2); return array($user, $pw); } } if ($user === '' && !empty($_SERVER['REMOTE_USER'])) { $user = (string)$_SERVER['REMOTE_USER']; } return array($user, $pw); } /** * True iff the operator is the canonical 'pi-star' account AND * still using the factory default password 'raspberry'. * * Account scope is deliberately narrow: the requirement is to * protect against drive-by auth using the well-known stock * credentials. Operators who created their own usernames are * out of scope (they made an active choice). * * Constant-time compare via hash_equals — defence in depth; the * default password is publicly documented so timing isn't really * an oracle, but it's the right idiom for password compares. * * @return bool */ function _pistar_default_password_in_use() { list($user, $pw) = _pistar_get_basic_auth(); if ($user !== 'pi-star' || $pw === '') { return false; } return hash_equals('raspberry', $pw); } /** * The Pi's local interface subnets (IPv4 + IPv6). * * Read once per request from `ip -j addr show` (iproute2 JSON output; * always present on Pi-Star). Cached in a static for repeated calls. * * @return array */ function _pistar_local_subnets() { static $cached = null; if ($cached !== null) { return $cached; } $cached = array(); $out = array(); $rc = 1; exec('ip -j addr show 2>/dev/null', $out, $rc); if ($rc !== 0 || empty($out)) { return $cached; } $ifaces = json_decode(implode('', $out), true); if (!is_array($ifaces)) { return $cached; } foreach ($ifaces as $iface) { if (empty($iface['addr_info']) || !is_array($iface['addr_info'])) { continue; } foreach ($iface['addr_info'] as $a) { if (empty($a['local']) || !isset($a['prefixlen'])) { continue; } $cached[] = array( 'ip' => (string)$a['local'], 'prefix' => (int)$a['prefixlen'], ); } } return $cached; } /** * Family-aware bitmask subnet membership test. * * Handles IPv4, IPv6, and the IPv4-mapped IPv6 form (`::ffff:a.b.c.d`) * that nginx may surface in REMOTE_ADDR when the listener is dual-stack. * * @param string $remote The address to test. * @param string $netIp The subnet's network/anchor address. * @param int $prefix Prefix length in bits (0..32 for IPv4, 0..128 for IPv6). * * @return bool */ function _pistar_ip_in_subnet($remote, $netIp, $prefix) { $remoteBin = @inet_pton($remote); $netBin = @inet_pton($netIp); if ($remoteBin === false || $netBin === false) { return false; } // Handle v4-mapped v6 (::ffff:1.2.3.4) when comparing against a // bare v4 subnet — strip the 12-byte v4-mapped prefix. Mapping is // one-directional by design: nginx surfaces REMOTE_ADDR in mapped // form on dual-stack listeners while `ip -j addr show` reports the // interface as bare v4, so the asymmetric strip is what the real // topology produces. The reverse case (bare v4 client, mapped // subnet) cannot occur in practice and falls through to "not a // match" below. if (strlen($remoteBin) === 16 && strlen($netBin) === 4 && substr($remoteBin, 0, 12) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff") { $remoteBin = substr($remoteBin, 12); } if (strlen($remoteBin) !== strlen($netBin)) { // Different families and no compatible mapping — not in subnet. return false; } $bits = strlen($remoteBin) * 8; if ($prefix < 0 || $prefix > $bits) { return false; } $bytesFull = intdiv($prefix, 8); $bitsExtra = $prefix % 8; if ($bytesFull > 0 && substr($remoteBin, 0, $bytesFull) !== substr($netBin, 0, $bytesFull)) { return false; } if ($bitsExtra > 0) { $mask = chr((0xFF << (8 - $bitsExtra)) & 0xFF); if ((substr($remoteBin, $bytesFull, 1) & $mask) !== (substr($netBin, $bytesFull, 1) & $mask)) { return false; } } return true; } /** * Is REMOTE_ADDR in the same L3 subnet as ANY of the Pi's interfaces? * * Loopback (127.0.0.0/8 on v4, ::1/128 on v6) is treated as local — * those addresses appear when an admin page is hit via the device * itself or via a CLI test harness on the Pi. * * @return bool */ function _pistar_remote_is_local() { $remote = isset($_SERVER['REMOTE_ADDR']) ? (string)$_SERVER['REMOTE_ADDR'] : ''; if ($remote === '') { return false; } foreach (_pistar_local_subnets() as $sub) { if (_pistar_ip_in_subnet($remote, $sub['ip'], $sub['prefix'])) { return true; } } return false; } /** * Layer 2: redirect to the forced-change page when default password + * remote client. Must be called BEFORE any output so header() works. * * Skips: * - Public (non-/admin/) URLs — anonymous visitors don't get told * the device is on default credentials. * - The /admin/change_password_required.php page itself — would * loop. * - Pages reached during the setup-not-done flow (no mode marker * file yet) — let the existing "No Mode Defined" handler in * index.php drive the operator through configure.php first. * * @return void */ function pistar_warnings_enforce_redirect() { $self = isset($_SERVER['PHP_SELF']) ? (string)$_SERVER['PHP_SELF'] : ''; if (strpos($self, '/admin/') !== 0) { return; } if ($self === '/admin/change_password_required.php') { return; } // Long-running operational tools — let the operator finish what // they're doing. The Layer-1 banner still appears at the top of // these pages so the warning isn't suppressed; only the redirect // is. update.php and expert/upgrade.php both poll a `?ajax` // log-tail every 1 s; redirecting on those polls causes jQuery // to follow the 302 and append the full change-password HTML // into the page's #tail div, "flooding" the operator with copies // of the change-password form. if ($self === '/admin/update.php' || $self === '/admin/expert/upgrade.php') { return; } // Defence in depth: any GET that includes ?ajax skips the redirect. // Catches AJAX log-tail / status-poll endpoints elsewhere in the // dashboard (live_modem_log.php, expert/jitter_test.php, etc.) so // a future poll-style page can't accidentally regress the same way. // The initial GET that loaded the parent page already carried the // banner — defaulting AJAX to "no redirect" is safe. if (isset($_GET['ajax'])) { return; } if (!file_exists('/etc/dstar-radio.mmdvmhost') && !file_exists('/etc/dstar-radio.dstarrepeater')) { return; } if (!_pistar_default_password_in_use()) { return; } if (_pistar_remote_is_local()) { return; } header('Location: /admin/change_password_required.php', true, 302); exit; } /** * Render every applicable warning banner. Admin URLs only. * * Adding a new banner here? The body string is interpolated into the * raw — pre-escape any dynamic content with htmlspecialchars() * before passing it to _pistar_banner_emit(). Today's seven banners * are all string literals so the issue does not arise. * * @return void */ function pistar_warnings_render() { $self = isset($_SERVER['PHP_SELF']) ? (string)$_SERVER['PHP_SELF'] : ''; if (strpos($self, '/admin/') !== 0) { return; } // Default-password banner FIRST — highest severity, most actionable. if (_pistar_default_password_in_use()) { _pistar_banner_emit( 'Security Alert: Your dashboard is using the default password. ' . 'Please change it now ' . 'from the Configuration page.', 'danger' ); } // Pi-Star release-derived banners. parse_ini_file() once per request. $release = @parse_ini_file('/etc/pistar-release', true); $version = is_array($release) && isset($release['Pi-Star']['Version']) ? (string)$release['Pi-Star']['Version'] : ''; $hardware = is_array($release) && isset($release['Pi-Star']['Hardware']) ? (string)$release['Pi-Star']['Hardware'] : ''; if ($version !== '' && $hardware === 'RPi' && version_compare($version, '4.1', '<')) { _pistar_banner_emit( 'Alert: You are running an outdated version of Pi-Star, please upgrade.
' . 'New versions are available from here: ' . 'http://www.pistar.uk/downloads/.', 'warn' ); } if ($version !== '' && version_compare($version, '4.1.0', '>=') && version_compare($version, '4.1.13', '<')) { _pistar_banner_emit( 'Alert: An upgrade to Pi-Star has been released, click here to upgrade now: ' . 'Upgrade Pi-Star.', 'warn' ); } if ($version !== '' && version_compare($version, '4.2.0', '>=') && version_compare($version, '4.2.6', '<')) { _pistar_banner_emit( 'Alert: An upgrade to Pi-Star has been released, click here to upgrade now: ' . 'Upgrade Pi-Star.', 'warn' ); } if ($version !== '' && version_compare($version, '4.3.0', '>=') && version_compare($version, '4.3.7', '<')) { _pistar_banner_emit( 'Alert: An upgrade to Pi-Star has been released, click here to upgrade now: ' . 'Upgrade Pi-Star.', 'warn' ); } // DMR public mode without ACL (loop risk). Was configure.php-only; // now appears across the admin surface so the operator sees it // regardless of which page they land on first. if (file_exists('/etc/dstar-radio.mmdvmhost')) { $mmdvm = @parse_ini_file('/etc/mmdvmhost', true); if (is_array($mmdvm) && isset($mmdvm['DMR']['Enable']) && $mmdvm['DMR']['Enable'] == 1 && isset($mmdvm['DMR']['SelfOnly']) && $mmdvm['DMR']['SelfOnly'] == 0 && isset($mmdvm['General']['Id']) && strlen((string)$mmdvm['General']['Id']) >= 7 && !isset($mmdvm['DMR']['WhiteList'])) { _pistar_banner_emit( 'Alert: You are running a hotspot in public mode without an access list for DMR, ' . 'this setup *could* participate in network loops!', 'warn' ); } } // BM API v1 key (length-based heuristic, kept identical to the // configure.php trigger this consolidates). $bmKey = '/etc/bmapi.key'; if (file_exists($bmKey)) { $bm = @parse_ini_file($bmKey, true); if (is_array($bm) && !empty($bm['key']['apikey']) && strlen((string)$bm['key']['apikey']) <= 200) { _pistar_banner_emit( 'Alert: You have a BM API v1 Key, click here for the announcement: ' . 'BM API Keys - Announcement.', 'danger' ); } } }